GO类型参数-吃水设计

2020-11-05 20:20:06

我们建议扩展Go语言,在类型和函数声明中添加可选的类型参数。类型参数受接口类型的约束。接口类型用作类型约束时,允许列出可能分配给它们的一组类型。在许多情况下,通过统一算法的类型推断允许从函数调用中省略类型参数。该设计完全向后兼容GO 1。

然后,我们从零开始解释整个设计,在需要的时候介绍细节,并用简单的例子介绍它们。

在完整描述了设计之后,我们将讨论实现、设计中的一些问题以及与其他泛型方法的比较。

然后,我们将提供几个完整的示例,说明如何在实践中使用这种设计。

本部分非常简要地解释了设计草案建议的更改。本部分面向已经熟悉泛型如何在Go这样的语言中工作的人。以下各节将详细解释这些概念。

函数可以有一个使用方括号的附加类型参数列表,但在其他方面看起来像一个普通参数列表:func F[T any](P T){...}。

这些类型参数可以由常规参数使用,也可以在函数体中使用。

每个类型参数都有一个类型约束,就像每个普通参数都有一个类型一样:func F[T Constraint](P T){...}。

用作类型约束的接口类型可以有一个预声明类型列表;只有与其中一个类型匹配的类型参数才能满足约束。

在接下来的几节中,我们将详细介绍这些语言更改中的每一个。您可能更喜欢跳到示例,看看写到此设计草案中的通用代码在实践中会是什么样子。

有很多人要求在Go中增加对泛型编程的额外支持。关于问题跟踪器和一份活生生的文件,人们进行了广泛的讨论。

这份设计草案建议扩展Go语言以增加一种形式的参数多态性,其中类型参数不是由声明的子类型关系(就像在一些面向对象语言中那样)限定的,而是由明确定义的结构约束限定的。

这一版本的设计草案与2019年7月31日提交的设计草案有许多相似之处,但合同已被移除,取而代之的是接口类型,语法也发生了变化。

有几个关于添加类型参数的建议,可以通过上面的链接找到这些建议。这里提出的许多想法以前就出现过。这里描述的主要新特性是语法和对作为约束的接口类型的仔细检查。

此设计不支持模板元编程或任何其他形式的编译时编程。

由于术语泛型在围棋社区中被广泛使用,下面我们将使用它作为一种缩写,表示接受类型参数的函数或类型。不要将本设计中使用的术语泛型与其他语言(如C++、C#、Java或Rust)中的相同术语混淆;它们有相似之处,但又不相同。

泛型代码是使用抽象数据类型编写的,我们称之为类型参数。运行泛型代码时,类型参数将替换为类型参数。

下面是一个打印切片的每个元素的函数,其中切片的元素类型(这里称为T)是未知的。这是我们为了支持泛型编程而想要允许的函数类型的一个很小的例子。(稍后我们还将讨论泛型类型)。

//print打印切片的元素。//应该可以使用任何切片值调用它。func print(s[]T){//只是一个示例,不是建议的语法。For_,v:=range s{fmt.Println(V)}}。

使用这种方法,首先要做的决定是:应该如何声明类型参数T?在像Go这样的语言中,我们希望每个标识符都以某种方式声明。

这里我们做了一个设计决策:类型参数类似于普通的非类型函数参数,因此应该与其他参数一起列出。但是,类型参数与非类型参数不同,因此尽管它们出现在参数列表中,但我们希望区分它们。这将导致我们的下一个设计决策:我们定义一个描述类型参数的附加可选参数列表。

此类型参数列表显示在常规参数之前。为了区分类型参数列表和常规参数列表,类型参数列表使用方括号而不是圆括号。正如常规参数具有类型一样,类型参数也具有元类型,也称为约束。我们稍后将讨论约束的细节;现在,我们只需注意Any是有效的约束,这意味着允许任何类型。

//Print打印任何切片的元素。//Print有一个类型参数T,并且只有一个(非类型)参数s,它是该类型参数的切片。func print[T any](s[]T){//同上}。

这说明在函数print中,标识符T是一个类型参数,这是一种当前未知但在调用函数时就会知道的类型。Any表示T可以是任何类型。如上所述,在描述普通非类型参数的类型时,类型参数可以用作类型。它还可以用作函数体中的类型。

与常规参数列表不同,在类型参数列表中,类型参数需要名称。这避免了语法歧义,而且碰巧没有理由省略类型参数名称。

因为print有一个类型参数,所以任何对print的调用都必须提供一个类型参数。稍后我们将看到,通过使用类型推断,通常可以从非类型参数中推导出此类型参数。目前,我们将显式传递类型参数。类型参数的传递方式与类型参数的声明方式非常相似:作为单独的参数列表。与类型参数列表一样,类型参数列表使用方括号。

//使用[]int调用print。//print有一个类型参数T,我们希望传递一个[]int,//因此我们通过编写print[int]传递一个int的类型参数。//函数print[int]需要一个[]int作为参数。Print[int]([]int{1,2,3})//这将打印://1//2//3。

让我们使我们的示例稍微复杂一些。让我们将其转换为一个函数,通过对每个元素调用一个字符串方法,将任何类型的片段转换为[]字符串。

//此函数为INVALID.func Stringify[T any](s[]T)(ret[]string){for_,v:=range s{ret=append(ret,v.String())//INVALID}return ret}。

乍一看,这似乎没问题,但在本例中,v的类型是T,T可以是任何类型。这意味着T不需要字符串方法。因此,对v.String()的调用无效。

当然,同样的问题也会出现在其他支持泛型编程的语言中。例如,在C++中,泛型函数(在C++术语中是函数模板)可以对泛型类型的值调用任何方法。也就是说,在C++方法中,调用v.String()就可以了。如果使用没有字符串方法的类型参数调用函数,则在使用该类型参数编译对v.String的调用时会报告错误。这些错误可能很长,因为在错误发生之前可能有几层泛型函数调用,必须报告所有这些调用才能了解哪里出了问题。

对于围棋来说,C++方法不是一个好的选择。其中一个原因是语言的风格。在Go中,我们不引用名称,例如,在本例中是字符串,并且希望它们存在。当看到所有名称时,Go会将它们解析为其声明。

另一个原因是Go是为支持大规模编程而设计的。我们必须考虑泛型函数定义(如上所述的Stringify)和对泛型函数的调用(未显示,但可能在其他包中)相距甚远的情况。通常,所有泛型代码都希望类型参数满足某些要求。我们将这些要求称为约束(其他语言也有类似的概念,称为类型界限或特征界限或概念)。在本例中,约束非常明显:该类型必须有一个string()字符串方法。在其他情况下,这一点可能不那么明显。

我们不想从Stringify碰巧做的任何事情中派生约束(在本例中,调用字符串方法)。如果我们这样做了,对Stringify的微小更改可能会改变约束。这意味着微小的更改可能会导致调用该函数的代码意外崩溃。Stringify可以故意更改其约束,并强制调用者更改。我们要避免的是Stringify意外地改变了它的约束。

这意味着约束必须对调用方传递的类型参数和泛型函数中的代码设置限制。调用方只能传递满足约束的类型参数。泛型函数只能以约束允许的方式使用这些值。这是一条重要的规则,我们认为应该适用于在Go中定义泛型编程的任何尝试:泛型代码只能使用其类型参数已知要实现的操作。

在我们进一步讨论约束之前,让我们简单地注意一下当约束为ANY时会发生什么。如果泛型函数对类型参数使用Any约束(就像上面的print方法一样),则允许该参数使用任何类型参数。泛型函数可以对该类型参数值使用的唯一操作是允许对任何类型的值执行的操作。在上面的例子中,print函数声明了一个类型为类型参数T的变量v,并将该变量传递给一个函数。

定义和使用使用这些类型的复合类型,例如该类型的片段。

未来的语言变化可能会添加其他此类操作,尽管目前预计不会添加任何此类操作。

Go已经有了一个接近我们约束所需的构造:接口类型。接口类型是一组方法。可以赋给接口类型变量的唯一值是其类型实现相同方法的那些变量。除了允许任何类型的操作之外,可以使用接口类型的值执行的唯一操作是调用方法。

使用类型参数调用泛型函数类似于向接口类型的变量赋值:类型参数必须实现类型参数的约束。编写泛型函数类似于使用接口类型的值:泛型代码只能使用约束允许的操作(或任何类型允许的操作)。

因此,在此设计中,约束只是接口类型。实现约束意味着实现接口类型。(稍后我们将了解如何为方法调用以外的操作定义约束,例如二元运算符)。

对于Stringify示例,我们需要一个带有字符串方法的接口类型,该方法不接受任何参数,并返回一个字符串类型的值。

//Stringer是一种类型约束,它要求类型参数具有//字符串方法,并允许泛型函数调用字符串。//String方法应返回值的字符串表示形式。type Stringer接口{string()string}。

(这与本文的讨论无关,但这定义了与标准库的fmt.Stringer类型相同的接口,真正的代码很可能只使用fmt.Stringer。)。

既然我们知道约束只是接口类型,我们就可以解释约束是什么意思了。如上所述,Any约束允许任何类型作为类型参数,并且只允许函数使用任何类型允许的操作。其接口类型为空接口:接口{}。因此,我们可以将打印示例编写为。

//Print打印任何切片的元素。//Print有一个类型参数T,并且只有一个(非类型)参数s,它是该类型参数的切片。func print[T接口{}](s[]T){//同上}。

然而,每次编写不对其类型参数施加约束的泛型函数时,都必须编写接口{},这是很乏味的。因此,在此设计中,我们建议使用与接口{}等效的类型约束Any。这将是一个预先声明的名称,在语义块中隐式声明。除了类型约束之外,将Any用作任何其他类型约束都是无效的。

(注意:显然,我们可以将任何类型作为接口{}的别名,或者作为定义为接口{}的新定义类型)。然而,我们不希望这份关于泛型的设计草案导致对非泛型代码的可能重大改变。将Any添加为接口{}的通用名称可以而且应该单独讨论)。

对于泛型函数,可以将约束视为类型参数的类型:元类型。如上所示,约束在类型参数列表中显示为类型参数的元类型。

//Stringify对s的每个元素调用String方法,//返回结果。func Stringify[T Stringer](s[]T)(ret[]string){for_,v:=range s{ret=append(ret,v.String())}return ret}。

单个类型参数T后跟应用于T的约束,在本例中为Stringer。

虽然Stringify示例只使用一个类型参数,但函数可以有多个类型参数。

//Print2有两个类型参数和两个非类型参数。func Print2[T1,T2 any](s1[]t1,s2[]t2){...}。

//Print2Same有一个类型参数和两个非类型参数。func Print2Same[T any](S1[]T,S2[]T){...}。

在Print2中,S1和S2可以是不同类型的切片。在Print2Same中,S1和S2必须是相同元素类型的切片。

就像每个普通参数可能有自己的类型一样,每个类型参数也可能有自己的约束。

//Stringer是需要字符串方法的类型约束。//字符串方法应返回值的字符串表示形式。type Stringer接口{String()string}//Plusser是需要Plus方法的类型约束。//Plus方法应将参数添加到内部//字符串并返回结果。type Plusser接口{Plus(字符串)字符串}//ConcatTo使用字符串方法获取元素切片,使用Plus方法获取元素切片//。切片应具有相同//数量的元素。这会将s的每个元素转换为一个字符串,//将其传递给p的相应元素的Plus方法,//并返回一段结果字符串。func ConcatTo[S Stringer,P Plusser](s[]S,p[]P)[]string{r:=make([]string,len(S))for i,v:=range s{r[i]=p[i].plus(v.String())}return r}。

一个约束可以用于多个类型参数,就像单个类型可以用于多个非类型函数参数一样。该约束分别应用于每个类型参数。

//Stringify2将两个不同类型的分片转换为字符串,//并返回所有字符串的拼接。func Stringify2[t1,t2 Stringer](s1[]t1,s2[]t2)string{r:=";";for_,v1:=range s1{r+=v1.String()}for_,v2:=range s2{r+=v2.String()}return r}。

我们想要的不仅仅是泛型函数:我们还想要泛型类型。我们建议将类型扩展为接受类型参数。

//向量是任何元素类型的切片的名称。类型向量[T任意][]T。

类型的类型参数就像函数的类型参数一样。

若要使用泛型类型,必须提供类型参数。这称为实例化。和往常一样,类型参数出现在方括号中。当我们通过为类型参数提供类型参数来实例化类型时,我们会生成一个类型,在该类型中,在类型定义中每次使用类型参数都会被相应的类型参数替换。

//v是整数值的向量。/这类似于假装";向量[int]";是有效的标识符,//写入//type";向量[int]";[]int//var v";向量[int]";//所有对向量[int]的使用都将引用相同的";向量[int]";类型。//var v向量[int]

泛型类型可以有方法。方法的接收方类型必须声明与接收方类型定义中声明的类型参数数量相同的类型参数。它们的声明没有任何约束。

//PUSH将值添加到向量的末尾。func(v*Vector[T])Push(X T){*v=append(*v,x)}。

方法声明中列出的类型参数不必与类型声明中的类型参数同名。具体地说,如果方法没有使用它们,则它们可以是_。

泛型类型可以在通常可以引用其自身的情况下引用自身,但当它这样做时,类型参数必须是按相同顺序列出的类型参数。此限制可防止类型实例化的无限递归。

//List是T类型的值的链接列表。type list[T any]struct{Next*List[T]//对list[T]的引用是OK ValT}//此类型为INVALID。type P[T1,T2 any]struct{F*P[T2,T1]//无效;必须为[T1,T2]}。

//ListHead是链接列表的头部。type ListHead[T any]struct{head*ListElement[T]}//ListElement是链表中具有头部的元素。//每个元素都指向头部。键入ListElement[T any]struct{Next*ListElement[T]val T//在此处使用ListHead[T]即可。//ListHead[T]引用ListElement[T]引用ListHead[T]。//使用ListHead[int]不太好,因为ListHead[T]//将间接引用ListHead[int]。Head*ListHead[T]}。

(注意:随着人们对如何编写代码有了更多的了解,可以放宽这一规则,允许某些情况下使用不同类型的参数。)。

//StringableVector是某种类型的切片,其中的类型//必须具有字符串方法。type StringableVector[T Stringer][]Tfunc(s StringableVector[T])string()string{var SB string。i的Builder,v:=range s{if I>;0{sb.WriteString(";,";)}//这里可以调用v.String,因为v是T类型。的约束是Stringer。Sb.WriteString(v.String())}返回sb.String()}

尽管泛型类型的方法可以使用该类型的参数,但方法本身可能没有附加的类型参数。在向方法添加类型参数非常有用的情况下,人们必须编写一个适当参数化的顶级函数。

正如我们已经看到的,我们使用接口类型作为约束。接口类型提供一组方法,其他什么都不提供。这意味着,根据我们到目前为止所看到的,泛型函数唯一可以对类型参数值执行的操作(任何类型都允许执行的操作)就是调用方法。

然而,方法调用并不足以满足我们想要表达的所有内容。考虑这个简单的函数,它返回值切片的最小元素,其中假设切片为非空。

//此函数是INVALID.func Minimest[T any](s[]T)T{r:=s[0]//如果_,v:=range s[1:]{if v<;r{//无效r=v}}返回r}。

任何合理的泛型实现都应该允许您编写此函数。问题在于表达式v<;r。这假设T支持<;运算符,但对T的约束只是ANY。对于ANY约束,最小函数只能使用对所有类型都可用的操作,但并非所有围棋类型都支持<;。不幸的是,由于<;不是一个方法,因此没有明显的方法来编写允许<;的约束(接口类型)。

我们需要一种方法来编写只接受支持<;的类型的约束。为了做到这一点,我们观察到,除了我们将在后面讨论的两个例外之外,所有的算法。

.