第二部分:语言的核心结构与技术
第4章:基本结构和基本数据类型
控制结构
函数(function)
数组与切片
Map
包(package)
结构(struct)与方法(method)
接口(interface)与反射(reflection)
第三部分:Go 高级编程
函数(function)
介绍
函数参数与返回值
传递变长参数
defer 和追踪
内置函数
递归函数
将函数作为参数
闭包
应用闭包:将函数作为返回值
使用闭包调试
计算函数执行时间
通过内存缓存来提升性能
Go 语言拥有一些不需要进行导入操作就可以使用的内置函数。它们有时可以针对不同的类型进行操作,例如:len、cap 和 append,或必须用于系统级的操作,例如:panic。因此,它们需要直接获得编译器的支持。
以下是一个简单的列表,我们会在后面的章节中对它们进行逐个深入的讲解。
名称 | 说明 |
---|---|
close | 用于管道通信 |
len、cap | len 用于返回某个类型的长度或数量(字符串、数组、切片、map 和管道);cap 是容量的意思,用于返回某个类型的最大容量(只能用于切片和 map) |
new、make | new 和 make 均是用于分配内存:new 用于值类型和用户定义的类型,如自定义结构,make 用于内置引用类型(切片、map 和管道)。它们的用法就像是函数,但是将类型作为参数:new(type)、make(type)。new(T) 分配类型 T 的零值并返回其地址,也就是指向类型 T 的指针(详见第 10.1 节)。它也可以被用于基本类型:v := new(int) 。make(T) 返回类型 T 的初始化之后的值,因此它比 new 进行更多的工作(详见第 7.2.3/4 节、第 8.1.1 节和第 14.2.1 节)new() 是一个函数,不要忘记它的括号 |
copy、append | 用于复制和连接切片 |
panic、recover | 两者均用于错误处理机制 |
print、println | 底层打印函数(详见第 4.2 节),在部署环境中建议使用 fmt 包 |
complex、real imag | 用于创建和操作复数(详见第 4.5.2.2 节) |
使用闭包调试:
where := func() { _, file, line, _ := runtime.Caller(1) log.Printf("%s:%d", file, line) }
实际编程中实用的部分:
数组与切片
声明和初始化
切片
For-range 结构
切片重组(reslice)
切片的复制与追加
字符串、数组和切片的应用
Map
声明、初始化和 make
测试键值对是否存在及删除元素
for-range 的配套用法
map 类型的切片
map 的排序
将 map 的键值对调
看起来二者没有什么区别,都在堆上分配内存,但是它们的行为不同,适用于不同的类型。
&T{}
。换言之,new 函数分配内存,make 函数初始化;
包(package)
标准库概述
regexp 包
锁和 sync 包
精密计算和 big 包
自定义包和可见性
为自定义包使用 godoc
使用 go install 安装自定义包
自定义包的目录结构、go install 和 go test
通过 Git 打包和安装
Go 的外部包和项目
在 Go 程序中使用外部库
结构(struct)与方法(method)
结构体定义
使用工厂方法创建结构体实例
使用自定义包中的结构体
带标签的结构体
匿名字段和内嵌结构体
方法
类型的 String() 方法和格式化描述符
垃圾回收和 SetFinalizer
可以使用 make()
的三种类型:slice map channels
试图 make()
一个结构体变量,会引发一个编译错误,这还不是太糟糕,但是 new()
一个映射并试图使用数据填充它,将会引发运行时错误! 因为 new(Foo)
返回的是一个指向 nil
的指针,它尚未被分配内存。所以在使用 map
时要特别谨慎。
结构体中的字段除了有名字和类型外,还可以有一个可选的标签(tag):它是一个附属于字段的字符串,可以是文档或其他的重要标记。标签的内容不可以在一般的编程中使用,只有包 reflect
能获取它。我们将在下一章(第 11.10 节)中深入的探讨 reflect
包,它可以在运行时自省类型、属性和方法,比如:在一个变量上调用 reflect.TypeOf()
可以获取变量的正确类型,如果变量是一个结构体类型,就可以通过 Field 来索引结构体的字段,然后就可以使用 Tag 属性。
结构体可以包含一个或多个 匿名(或内嵌)字段,即这些字段没有显式的名字,只有字段的类型是必须的,此时类型就是字段的名字。匿名字段本身可以是一个结构体类型,即 结构体可以包含内嵌结构体。
可以粗略地将这个和面向对象语言中的继承概念相比较,随后将会看到它被用来模拟类似继承的行为。Go 语言中的继承是通过内嵌或组合来实现的,所以可以说,在 Go 语言中,相比较于继承,组合更受青睐。
指针方法和值方法都可以在指针或非指针上被调用
总结
在 Go 中,类型就是类(数据和关联的方法)。Go 不知道类似面向对象语言的类继承的概念。继承有两个好处:代码复用和多态。
在 Go 中,代码复用通过组合和委托实现,多态通过接口的使用来实现:有时这也叫 组件编程(Component Programming)。
许多开发者说相比于类继承,Go 的接口提供了更强大、却更简单的多态行为。
备注
如果真的需要更多面向对象的能力,看一下 goop
包(Go Object-Oriented Programming),它由 Scott Pakin 编写: 它给 Go 提供了 JavaScript 风格的对象(基于原型的对象),并且支持多重继承和类型独立分派,通过它可以实现你喜欢的其他编程语言里的一些结构。
接口(interface)与反射(reflection)
接口是什么
接口嵌套接口
类型断言:如何检测和转换接口变量的类型
类型判断:type-switch
测试一个值是否实现了某个接口
使用方法集与接口
第一个例子:使用 Sorter 接口排序
第二个例子:读和写
空接口
反射包
Printf 和反射
接口与动态类型
总结:Go 中的面向对象
结构体、集合和高阶函数
接口是一种契约,实现类型必须满足它,它描述了类型的行为,规定类型可以做什么。接口彻底将类型能做什么,以及如何做分离开来,使得相同接口的变量在不同的时刻表现出不同的行为,这就是多态的本质。
编写参数是接口变量的函数,这使得它们更具有一般性。
使用接口使代码更具有普适性。
标准库里到处都使用了这个原则,如果对接口概念没有良好的把握,是不可能理解它是如何构建的。
总结
在接口上调用方法时,必须有和方法定义时相同的接收者类型或者是可以从具体类型 P
直接可以辨识的:
将一个值赋值给一个接口时,编译器会确保所有可能的接口方法都可以在此值上被调用,因此不正确的赋值在编译期就会失败。
译注
Go 语言规范定义了接口方法集的调用规则:
在经典的面向对象语言(像 C++,Java 和 C#)中数据和方法被封装为 类
的概念:类包含它们两者,并且不能剥离。
Go 没有类:数据(结构体或更一般的类型)和方法是一种松耦合的正交关系。
Go 中的接口跟 Java/C# 类似:都是必须提供一个指定方法集的实现。但是更加灵活通用:任何提供了接口方法实现代码的类型都隐式地实现了该接口,而不用显式地声明。
和其它语言相比,Go 是唯一结合了接口值,静态类型检查(是否该类型实现了某个接口),运行时动态转换的语言,并且不需要显式地声明类型是否满足某个接口。该特性允许我们在不改变已有的代码的情况下定义和使用新接口。
接收一个(或多个)接口类型作为参数的函数,其实参可以是任何实现了该接口的类型。 实现了某个接口的类型可以被传给任何以此接口为参数的函数
。
类似于 Python 和 Ruby 这类动态语言中的 动态类型(duck typing)
;这意味着对象可以根据提供的方法被处理(例如,作为参数传递给函数),而忽略它们的实际类型:它们能做什么比它们是什么更重要。
静态分发 v.s. 动态分发?
像 Python,Ruby 这类语言,动态类型是延迟绑定的(在运行时进行):方法只是用参数和变量简单地调用,然后在运行时才解析(它们很可能有像 responds_to
这样的方法来检查对象是否可以响应某个方法,但是这也意味着更大的编码量和更多的测试工作)
Go 的实现与此相反,通常需要编译器静态检查的支持:当变量被赋值给一个接口类型的变量时,编译器会检查其是否实现了该接口的所有函数。如果方法调用作用于像 interface{}
这样的“泛型”上,你可以通过类型断言(参见 11.3 节)来检查变量是否实现了相应接口。
Go 提供了动态语言的优点,却没有其他动态语言在运行时可能发生错误的缺点。
对于动态语言非常重要的单元测试来说,这样即可以减少单元测试的部分需求,又可以发挥相当大的作用。
Go 的接口提高了代码的分离度,改善了代码的复用性,使得代码开发过程中的设计模式更容易实现。用 Go 接口还能实现 依赖注入模式
。
提取接口
是非常有用的设计模式,可以减少需要的类型和方法数量,而且不需要像传统的基于类的面向对象语言那样维护整个的类层次结构。
Go 接口可以让开发者找出自己写的程序中的类型。假设有一些拥有共同行为的对象,并且开发者想要抽象出这些行为,这时就可以创建一个接口来使用。 我们来扩展 11.1 节的示例 11.2 interfaces_poly.go,假设我们需要一个新的接口 TopologicalGenus
,用来给 shape 排序(这里简单地实现为返回 int)。我们需要做的是给想要满足接口的类型实现 Rank()
方法:
所以你不用提前设计出所有的接口;整个设计可以持续演进,而不用废弃之前的决定
。类型要实现某个接口,它本身不用改变,你只需要在这个类型上实现新的方法。
如果你希望满足某个接口的类型显式地声明它们实现了这个接口,你可以向接口的方法集中添加一个具有描述性名字的方法。大部分代码并不使用这样的约束,因为它限制了接口的实用性。但是有些时候,这样的约束在大量相似的接口中被用来解决歧义。
在 6.1 节中, 我们看到函数重载是不被允许的。在 Go 语言中函数重载可以用可变参数 ...T
作为函数最后一个参数来实现(参见 6.3 节)。如果我们把 T 换为空接口,那么可以知道任何类型的变量都是满足 T (空接口)类型的,这样就允许我们传递任何数量任何类型的参数给函数,即重载的实际含义。
函数 fmt.Printf
就是这样做的:
fmt.Printf(format string, a ...interface{}) (n int, errno error)
这个函数通过枚举 slice
类型的实参动态确定所有参数的类型。并查看每个类型是否实现了 String()
方法,如果是就用于产生输出信息。
类型可以通过继承多个接口来提供像 多重继承
一样的特性:
type ReaderWriter struct {
*io.Reader
*io.Writer
}
上面概述的原理被应用于整个 Go 包,多态用得越多,代码就相对越少(参见 12.8 节)。这被认为是 Go 编程中的重要的最佳实践。
有用的接口可以在开发的过程中被归纳出来。添加新接口非常容易,因为已有的类型不用变动(仅仅需要实现新接口的方法)。已有的函数可以扩展为使用接口类型的约束性参数:通常只有函数签名需要改变。对比基于类的 OO 类型的语言在这种情况下则需要适应整个类层次结构的变化。
我们总结一下前面看到的:Go 没有类,而是松耦合的类型、方法对接口的实现。
OO 语言最重要的三个方面分别是:封装,继承和多态,在 Go 中它们是怎样表现的呢?
封装(数据隐藏):和别的 OO 语言有 4 个或更多的访问层次相比,Go 把它简化为了 2 层(参见 4.2 节的可见性规则):
1)包范围内的:通过标识符首字母小写,对象
只在它所在的包内可见
2)可导出的:通过标识符首字母大写,对象
对所在包以外也可见
类型只拥有自己所在包中定义的方法。