正如我们在 ByteSize 中看到的,方法可以为任何命名类型定义(指针或接口除外);接收者不必是结构体。
方法
指针与值
在上面关于切片的讨论中,我们编写了一个
Append 函数。我们可以将其定义为切片上的方法。为此,我们首先声明一个命名类型,以便我们可以将方法绑定到该类型,然后将方法的接收者定义为该类型的值。这仍然要求方法返回更新后的切片。我们可以通过将方法的接收者重新定义为指向
ByteSlice 的 指针 来消除这种笨拙,因此该方法可以覆盖调用者的切片。实际上,我们可以做得更好。如果我们将函数修改为看起来像标准的
Write 方法,如下所示:那么类型
*ByteSlice 满足标准接口 io.Writer,这非常方便。例如,我们可以将内容打印到其中。我们传递一个
ByteSlice 的地址,因为只有 *ByteSlice 满足 io.Writer。关于接收者的指针与值的规则是,值方法可以在指针和值上调用,但指针方法只能在指针上调用。这个规则的产生是因为指针方法可以修改接收者;在值上调用它们会导致方法接收值的副本,因此任何修改都会被丢弃。因此,语言禁止这种错误。不过,有一个方便的例外。当值是可寻址时,语言会通过自动插入地址操作符来处理在值上调用指针方法的常见情况。在我们的示例中,变量
b 是可寻址的,因此我们可以仅使用 b.Write 调用它的 Write 方法。编译器会自动将其重写为 (&b).Write。顺便提一句,在字节切片上使用
Write 的想法是 bytes.Buffer 实现的核心。接口与其他类型
接口
Go 中的接口提供了一种指定对象行为的方式:如果某个东西可以做 这个,那么它就可以在 这里 使用。我们已经看到了一些简单的例子;自定义打印器可以通过
String 方法实现,而 Fprintf 可以将输出生成到任何实现了 Write 方法的对象上。只有一个或两个方法的接口在 Go 代码中很常见,通常会根据方法的名称给接口命名,例如 io.Writer 表示实现了 Write 的对象。一个类型可以实现多个接口。例如,如果一个集合实现了
sort.Interface(包含 Len()、Less(i, j int) bool 和 Swap(i, j int) 方法),那么它就可以被 sort 包中的例程排序,同时它也可以有一个自定义格式化器。在这个例子中,Sequence 同时满足这两种接口。转换
Sequence 的 String 方法重做了 Sprint 已经为切片做的工作。(它的复杂度也是 O(N²),这很糟糕。)如果我们在调用 Sprint 之前将 Sequence 转换为一个普通的 []int,就可以共享工作(并加速)。这个方法是从
String 方法安全调用 Sprintf 的另一个示例。因为这两种类型(Sequence 和 []int)在忽略类型名称时是相同的,所以在它们之间进行转换是合法的。转换不会创建新值,而只是暂时使现有值表现得像有了新类型。(还有其他合法的转换,例如从整数到浮点数,这会创建新值。)在 Go 程序中,将表达式的类型转换为访问不同方法集是一种常见的习惯。例如,我们可以使用现有的类型
sort.IntSlice 来简化整个示例:现在,我们不再需要让
Sequence 实现多个接口(排序和打印),而是利用数据项转换为多种类型的能力(Sequence、sort.IntSlice 和 []int),每种类型执行部分工作。这在实践中比较少见,但可能有效。接口转换与类型断言
类型切换是一种转换形式:它接受一个接口,并在切换的每个案例中,将其“转换”为该案例的类型。下面是
fmt.Printf 如何通过类型切换将值转换为字符串的简化版本。如果它已经是一个字符串,我们想要接口中持有的实际字符串值;如果它有一个 String 方法,我们想要调用该方法的结果。第一个案例找到一个具体值;第二个将接口转换为另一个接口。以这种方式混合类型是完全可以的。
如果我们只关心一种类型呢?如果我们知道值持有一个
string,我们只想提取它?一个单案例的类型切换就可以做到,但类型断言也可以。类型断言接受一个接口值并从中提取出指定的显式类型的值。语法借用了类型切换的开头,但使用显式类型而不是 type 关键字:结果是一个具有静态类型
typeName 的新值。该类型必须是接口所持有的具体类型,或是可以转换的第二个接口类型。为了提取我们知道在值中的字符串,我们可以写:但如果结果值不是字符串,程序将因运行时错误崩溃。为了防止这种情况,可以使用“逗号,ok”习惯用法来安全地测试值是否是字符串:
如果类型断言失败,
str 仍然存在并且是字符串类型,但它的值将是零值,即空字符串。作为能力的说明,这里有一个
if-else 语句,它等价于本节开头的类型切换。泛化
如果一个类型仅用于实现一个接口,并且不会有超过该接口的导出方法,则没有必要导出该类型。仅导出接口使得值没有超过接口描述的有趣行为也更加明确。它还避免了在每个常见方法的实例上重复文档。
在这种情况下,构造函数应该返回一个接口值,而不是实现类型。例如,在哈希库中,
crc32.NewIEEE 和 adler32.New 都返回接口类型 hash.Hash32。在 Go 程序中将 CRC-32 算法替换为 Adler-32 仅需更改构造函数调用;其余代码不受算法更改的影响。类似的方法允许在各种
crypto 包中的流密码算法与它们连接的块密码分开。crypto/cipher 包中的 Block 接口指定块密码的行为,提供单个数据块的加密。然后,通过类比 bufio 包,可以使用实现该接口的密码包构造流密码,由 Stream 接口表示,而无需了解块加密的细节。crypto/cipher 接口如下所示:以下是计数器模式(CTR)流的定义,它将块密码转换为流密码;注意块密码的细节被抽象化:
NewCTR 适用于任何实现 Block 接口和任何 Stream 的情况。因为它们返回接口值,所以用其他加密模式替换 CTR 加密是局部的变化。构造函数调用必须编辑,但因为周围的代码只能将结果视为 Stream,所以它不会注意到差异。接口和方法
由于几乎任何东西都可以附加方法,因此几乎任何东西都可以满足接口。一个说明性示例是
http 包,它定义了 Handler 接口。任何实现 Handler 的对象都可以处理 HTTP 请求。ResponseWriter 本身也是一个接口,提供访问所需的方法,以便将响应返回给客户端。这些方法包括标准的 Write 方法,因此 http.ResponseWriter 可以在任何需要 io.Writer 的地方使用。Request 是一个结构体,包含来自客户端的请求的解析表示。为了简洁起见,我们忽略 POST 请求,假设 HTTP 请求总是 GET;这种简化不影响处理程序的设置。下面是一个简单的处理程序实现,用于计算页面访问的次数。
(保持主题,注意
Fprintf 可以打印到 http.ResponseWriter。)在真实服务器中,需要保护对 ctr.n 的访问以防止并发访问。请参见 sync 和 atomic 包以获取建议。作为参考,以下是如何将此类服务器附加到 URL 树的节点。
但为什么要将
Counter 定义为结构体?一个整数就足够了。(接收者需要是指针,以便增量对调用者可见。)如果你的程序有一些内部状态需要在访问页面时通知该状态呢?可以将通道与网页绑定。
最后,假设我们想在
/args 上显示用于调用服务器二进制文件的参数。编写一个函数以打印参数是很简单的。我们如何将其转换为 HTTP 服务器?我们可以将
ArgServer 作为某种类型的方法,但有一种更简洁的方法。因为我们可以为任何类型定义方法(除了指针和接口),所以我们可以为函数定义方法。http 包包含以下代码:HandlerFunc 是一个具有方法 ServeHTTP 的类型,因此该类型的值可以处理 HTTP 请求。查看方法的实现:接收者是一个函数 f,并且该方法调用 f。这可能看起来奇怪,但与接收者是通道并且方法在通道上发送并没有太大区别。要将
ArgServer 变成 HTTP 服务器,我们首先将其修改为具有正确的签名。ArgServer 现在具有与 HandlerFunc 相同的签名,因此可以转换为该类型以访问其方法,就像我们将 Sequence 转换为 IntSlice 以访问 IntSlice.Sort 一样。设置它的代码简洁明了:当有人访问页面
/args 时,安装在该页面的处理程序的值是 ArgServer,类型是 HandlerFunc。HTTP 服务器将调用该类型的 ServeHTTP 方法,以 ArgServer 作为接收者,这将进一步调用 ArgServer(通过调用 f(w, req) 在 HandlerFunc.ServeHTTP 中)。然后将显示参数。在本节中,我们使用结构体、整数、通道和函数创建了一个 HTTP 服务器,都是因为接口只是方法的集合,可以为(几乎)任何类型定义。
空白标识符
我们已经提到过空白标识符几次,主要是在
for range 循环和映射的上下文中。空白标识符可以被赋值或声明为任何类型的值,而这些值将被无害地丢弃。它有点像写入 Unix 的 /dev/null 文件:它代表一个只写的值,用作占位符,在需要变量但实际值无关紧要的地方使用。它的用途超出了我们已经看到的内容。在多重赋值中的空白标识符
在
for range 循环中使用空白标识符是一个特例,它属于一个更一般的情况:多重赋值。如果赋值需要在左侧多个值,但程序只会使用其中一个值,则在赋值的左侧使用空白标识符可以避免创建一个虚拟变量,并明确表明该值将被丢弃。例如,当调用一个返回值和错误的函数时,但只对错误感兴趣,可以使用空白标识符来丢弃无关的值。
有时你会看到代码丢弃错误值以忽略错误;这种做法是非常糟糕的。始终检查错误返回;它们提供是有原因的。
未使用的导入和变量
导入一个包或声明一个变量而不使用它是错误的。未使用的导入会使程序臃肿并减慢编译速度,而初始化但未使用的变量至少是浪费的计算,并可能指示更大的错误。然而,在程序处于积极开发中时,未使用的导入和变量经常出现,删除它们以便编译顺利进行可能会很麻烦,尤其是在稍后又需要它们的情况下。空白标识符提供了一个解决方法。
下面这个半完成的程序有两个未使用的导入(
fmt 和 io)和一个未使用的变量(fd),因此它无法编译,但我们希望检查到目前为止代码是否正确。为了消除未使用导入的警告,可以使用空白标识符引用导入包中的符号。同样,将未使用的变量
fd 赋值给空白标识符也会消除未使用变量的错误。这版程序能成功编译。根据惯例,消除导入错误的全局声明应该紧跟在导入之后,并加以注释,以便于查找,并作为稍后清理的提醒。
仅为副作用导入
像
fmt 或 io 这样的未使用导入应该最终被使用或移除:空白赋值标识符将代码标识为正在进行中的工作。但有时,仅为其副作用导入一个包是有用的,而不需要显式使用。例如,在其 init 函数中,net/http/pprof 包注册 HTTP 处理程序以提供调试信息。它有一个导出的 API,但大多数客户端只需要处理程序注册,并通过网页访问数据。为了仅为副作用导入该包,可以将包重命名为空白标识符:这种导入形式明确表示该包是为了其副作用而被导入,因为在这个文件中没有其他可能的使用(如果有,它会被使用的名称,编译器会拒绝程序)。
接口检查
正如我们在接口讨论中看到的那样,一个类型不需要明确声明它实现了一个接口。相反,一个类型只需实现接口的方法即可实现该接口。在实践中,大多数接口转换是静态的,因此在编译时检查。例如,将
*os.File 传递给一个期望 io.Reader 的函数,除非 *os.File 实现了 io.Reader 接口,否则将无法编译。不过,有些接口检查确实发生在运行时。例如,在
encoding/json 包中,定义了一个 Marshaler 接口。当 JSON 编码器接收到一个实现该接口的值时,编码器会调用该值的序列化方法将其转换为 JSON,而不是进行标准转换。编码器通过类型断言在运行时检查这一属性,例如:如果只需要询问某个类型是否实现了一个接口,而不实际使用该接口本身,可能作为错误检查的一部分,可以使用空白标识符来忽略类型断言的值:
这种情况出现的一个地方是需要确保在实现该类型的包内,它确实满足接口。如果一个类型——例如
json.RawMessage——需要自定义 JSON 表示,它应该实现 json.Marshaler,但没有静态转换会导致编译器自动验证这一点。如果类型不小心未能满足接口,JSON 编码器仍能工作,但不会使用自定义实现。为了确保实现正确,可以在包中使用全局声明,使用空白标识符:在这个声明中,涉及将
*RawMessage 转换为 Marshaler 的赋值要求 *RawMessage 实现 Marshaler,这一属性将在编译时检查。如果 json.Marshaler 接口发生变化,该包将无法编译,我们将收到通知需要更新。在这个构造中,空白标识符的出现表明声明仅用于类型检查,而不是创建变量。不要对每个满足接口的类型都这样做,按照惯例,这种声明仅在代码中没有静态转换时使用,这是一个罕见的事件。
嵌入
Go 不提供典型的、基于类型的子类化概念,但它确实有能力通过在结构体或接口中嵌入类型来“借用”实现的某些部分。
接口嵌入非常简单。我们之前提到过
io.Reader 和 io.Writer 接口;它们的定义如下:io 包还导出了多个其他接口,指定可以实现多个此类方法的对象。例如,有 io.ReadWriter,一个包含 Read 和 Write 的接口。我们可以通过显式列出这两个方法来指定 io.ReadWriter,但通过嵌入这两个接口来形成新的接口更简单、更具表现力,如下所示:这表明
ReadWriter 可以执行 Reader 和 Writer 的操作;它是嵌入接口的并集。只有接口可以嵌入到接口中。相同的基本思想适用于结构体,但影响更深远。
bufio 包有两个结构体类型,bufio.Reader 和 bufio.Writer,当然它们实现了来自 io 包的相应接口。而 bufio 还实现了一个缓冲读写器,它通过将一个读者和一个写者组合成一个结构体来完成,使用嵌入:它在结构体中列出类型,但不为它们指定字段名。嵌入的元素是指向结构体的指针,当然必须初始化为指向有效结构体,才能使用。
ReadWriter 结构体可以写成但这样一来,为了提升字段的方法并满足
io 接口,我们还需要提供转发方法,如下所示:通过直接嵌入结构体,我们避免了这种繁琐的管理。嵌入类型的方法自动继承,这意味着
bufio.ReadWriter 不仅拥有 bufio.Reader 和 bufio.Writer 的方法,还满足所有三个接口:io.Reader、io.Writer 和 io.ReadWriter。嵌入与子类化有一个重要的区别。当我们嵌入一个类型时,该类型的方法成为外部类型的方法,但当它们被调用时,方法的接收者是内部类型,而不是外部类型。在我们的示例中,当
bufio.ReadWriter 的 Read 方法被调用时,它的效果与上述转发方法完全相同;接收者是 ReadWriter 的 reader 字段,而不是 ReadWriter 本身。嵌入类型也可以是一个简单的便利。下面这个示例展示了一个嵌入字段和一个常规命名字段。
Job 类型现在拥有 *log.Logger 的 Print、Printf、Println 和其他方法。当然,我们可以给 Logger 一个字段名,但这样做并不是必要的。一旦初始化,我们可以对 Job 进行日志记录:Logger 是 Job 结构体的常规字段,因此我们可以像往常一样在 Job 的构造函数内部初始化它,例如:或者使用复合字面量:
如果我们需要直接引用嵌入字段,则字段名称是该字段的类型名称,忽略包限定符,就像在
ReadWriter 结构体的 Read 方法中一样。在这里,如果我们需要访问 Job 变量 job 的 *log.Logger,我们可以写 job.Logger,这在我们想要扩展 Logger 的方法时会很有用。嵌入类型引入了名称冲突的问题,但解决这些问题的规则很简单。首先,字段或方法
X 隐藏任何在类型的更深层嵌套部分的其他项目 X。如果 log.Logger 包含一个名为 Command 的字段或方法,Job 的 Command 字段将优先于它。其次,如果同一名称出现在同一嵌套层次,通常会导致错误;如果
Job 结构体包含另一个名为 Logger 的字段或方法,则嵌入 log.Logger 将是错误的。然而,如果重复名称在程序外部从未提及,则没有问题。如果嵌入的类型发生变化,添加了与另一个子类型中其他字段冲突的字段,而这些字段从未被使用,则不会有问题。