这期讲 Go 的类型。
Go 的数据类型分为基本类型和派生类型。篇幅关系,这期主要讲 基本类型,派生类型简单带过。
往期内容:
类型
下面的内容,会反复提到一个词:零值。如果声明一个变量,却不指定它的值,又或者直接 new(T)
(T 是某个类型)申请一块内存,Go 会把这块内存置零。但同样是 0,在不同的类型下,会有不同的语义。了解零值,就是要知道不同类型的默认值的含义和行为。
1. 基本类型
基本类型又分为 布尔类型、数字类型 和 字符串类型。
1.1 布尔类型
类型标识符 bool
,零值为 false。bool 没有直接的字面量,true
和 false
在 Go 是预定义的 bool 常量,不过使用上跟字面量没有太大区别。而很多时候,用到的不是这两个常量,而是 关系运算的结果 (关系运算符 ==
, !=
,>
, >=
, <
, <=
)和 函数返回值。
要注意的是,不像有些语言 bool 其实是数值类型的一种特例,可以或显式或隐式转换成数值。Go 的 bool 不是数值,也无法转换为数值,无法参与任何 数值运算(加减乘除) 和 位运算(按位与、或、取反等);反之,数值也不能转换为 bool。
假设现在有 int 数组 nums
,要统计其中大于 0 的数的个数:
C 里面可以这样
|
|
但在 Go 里会报错
|
|
只能老老实实用 if
|
|
1.2 数字类型
包括整型数、浮点数、复数等,区分有符号、无符号,还有不同字节长度选择(对应不同的内存占用和数值范围),有多种组合,具体看表格。
1 字节 | 2 字节 | 4 字节 | 8 字节 | 16字节 | 架构相关 | |
---|---|---|---|---|---|---|
无符号整型 | uint8 | uint16 | uint32 | uint64 | uint | |
有符号整型 | int8 | int16 | int32 | int64 | int | |
浮点数 | float32 | float64 | ||||
复数 | complex64 | complex128 | ||||
特殊类型 | byte | rune | uintptr |
- 数字类型的零值均为 0,各种意义上的零(整数零
0
,浮点数零0.0
, 复数零0+0i
)。 - 数字类型的字面量,涉及类型推断,参考上一期的字面量部分。
byte
是uint8
的别名,rune
是int32
的别名。类型别名以后再详细展开,你只要知道它们是同一个类型就可以了。- 除了复数以外,所有数值类型的变量之间可以互相转换。 规则参考上期的类型转换部分。
- 复数需要经由内置函数提取 实部 和 虚部(都是浮点数),或者将两个浮点数组合成复数。如果不是科学计算,一般很少用到复数,可以留个印象,用到再查。
位宽、范围与精度
位宽是类型后面的数字,它表示该类型占用了多少个二进制位。因为计算机以 字节(byte,等于 8 bit)为组织单位,位宽总是 8 的 2 整数次方倍。
uint
、int
和 uintptr
三个类型的位宽与系统架构相关。其中 uint
和 int
不小于 32 位,在 64 位系统则为 64 位;但需要注意它们是独立的类型,以 int
在 64 位系统为例,尽管范围完全一样,int
变量跟 int64
变量之间仍需转换。
uintptr
的范围则保证可以存下当前系统架构下的地址,无需特别考虑。
既然有不同的字节长度,就涉及到表示范围。范围太小,会导致数字溢出;范围太大,则引起内存的浪费。当然,如果只是个别变量,几个字节的差异可以忽略,可以直接用范围较大的类型;但涉及大数组或者大量生成的结构体的字段,则最好确定数据范围然后挑选合适的类型。(事实上,如果真的有需要节省空间和访问时间,还得考虑 内存对齐(memory alignment)或者 数据结构对齐(data structure alignment),那又是另一个话题了。)
- 对于无符号整型,假设位宽为 N(下同),表示范围为 $ 0 \sim 2^N - 1 $ ,如
uint8
表示的最大值为 $ 2^8 - 1 = 255 $ 。这个值除了自己算,也可以通过math
包的常量math.MaxUint8
获得。16、32、64 位以此类推。 - 对于有符号整型,最高位表示符号位,正负数的范围分别只有无符号数的一半(实际上因为不需要表示 负零,负数范围多一个),表示范围为 $ -2^{N-1} \sim 2^{N-1} - 1 $ ,如
int8
最小值为 -128,最大值为 127。这两个值也可以通过math
包的常量math.MinInt8
和math.MaxInt8
获得。其它有符号整型以此类推。 - 浮点数实际上是 科学记数法 的二进制实现,二进制位除了最高位的符号位,剩下的分成了 尾数 和 指数 两部分。跟整型不同的是,浮点数的范围包括 绝对值最大值 和 非零绝对值最小值(跟指数位宽有关),还有小数精度的问题(跟尾数位宽有关)。这些规范其实是 IEEE-754 国际标准规定的。因为精度还涉及进制转换问题,就不在这里展开。
float32
的绝对值范围可以通过math.SmallestNonzeroFloat32
和math.MaxFloat32
获得。64 位以此类推。 - 复数实际上由实部和虚部两个浮点数(各占一半位宽)组成,具体范围参考浮点数。
既然数字类型都有表示范围,而浮点数还可能有精度损失(有可能是超出尾数范围,也可能是进制转换造成),那么就有可能表示范围或者精度不足。如果需要表示超出范围的值,或者涉及金钱等业务需要非常高的精度,则需要用到 math/big
包的几个大数类型,包括整型 big.Int
、浮点型 big.Float
和 分数 big.Rat
。留个印象,后续再介绍。
1.3 字符串类型
类型标识符 string
,就是一串有固定长度的字符序列。
- 字符串的底层是一个 byte 数组(以 UTF-8 编码,所以支持中文),可以当做 byte 数组访问读取。例如读取
str[i]
得到的就是一个 byte 类型的值。 - 字符串是不可变类型,内容不可修改;对字符串变量的修改实质上是整个替换。试图修改字符串的内容(如
str[0] = 'a'
),会引起报错。而str1 = str1 + str2
中则是合法,因为str1
和str2
的内存都没有被修改,而是开辟新的内存存放拼接后的字符串,然后str1
指向新的内存。原来str1
的内存如果没有被其它地方引用,会在后续的 GC 被回收。这点有点像 Java 的 String。大量频繁拼接字符串的场景,需要考虑优化。 - 如果需要修改,则要转换成切片
[]byte
或者[]rune
;修改完之后也可以重新转换为string
。无论哪个方向的转换,内存都发生了拷贝(copy),返回的新切片 / 新字符串指向新分配的内存,所以互不影响。 频繁转换时需要考虑性能损耗。 []byte
和[]rune
差别是前者每个字符的范围是只有一个字节的 Unicode (只存得下 ASCII 码 + 拉丁符号扩展1),后者则是 四个字节的 Unicode。包含中文等等内容、字符编码可能大于一个字节的字符串只能转换为[]rune
否则会出现乱码;反之 ASCII 码的内容转换为[]rune
并不影响内容正确性,只是有一定的性能浪费而已。- 跟很多语言不同,Go 字符串的零值是空串
""
。 而不是特殊的空值nil
、null
、None
等。未初始化的字符串和空白字符串不像 Java 那样需要区分,判断都是if str == ""
。 - 字符串有两种字面量形式,除了以双引号包裹内容,还可以用反引号(` ,
Esc
下方的键)包裹。区别是双引号内支持转义,不支持换行;反引号字符串恰恰相反,不支持转义,所有字符都会原样保留,包括换行(但为了跨平台兼容性,换行符统一替换成newline
,去掉carriage return
)。反引号字符串常用于一大段的内容(为了保留换行)和 正则表达式(为了保留特殊符号不被转义)。 - 字符串支持的转义字符跟字符字面量一致,只有一个差别:不支持
\'
改为支持\"
;这是因为字符串以双引号界定,单引号不再是特殊字符。
string 本身值得专门开一篇文章,先说这么多。
2. 派生类型
派生类型,又叫衍生类型。顾名思义,它是在其它类型的基础上衍生出来的。
一个派生类型,严格来说,是一个大类,底下可以包含多种具体类型。例如 *int
和 *bool
虽然同为指针类型,但由于指向的类型不同,它们也是不同的类型(称作 int
指针 和 bool
指针);int
数组 和 bool
数组也是不同的类型;甚至,长度为 10 的 int
数组 [10]int
和 长度为 11 的数组 [11]int
也是不同的类型。
派生类型是个比较大的话题,个别类型光一个类型就够写一篇文章,所以这里不详细展开,只作简单罗列:
指针:从 C / C++ 一脉相承,内存管理的高阶操作;不过 Go 的指针比 C / C++ 要简单和安全得多,类型安全,也不用关心内存的释放和悬挂指针。
var iptr *int
声明了一个指向int
的指针iptr
,零值为nil
(相当于某些语言的null
,None
)。数组(array)和 切片(slice):类似其它语言的数组和动态数组。(注意这是两个不同类型,只是性质相近一起介绍)
var a [10]int
声明了一个长度为 10 的int
数组a
,零值为成员都是零值的数组,可以直接使用。var s []int
则声明了一个int
切片s
,零值为nil
。映射(map):类似其它语言的 map。不过不像 C++ 和 Java 作为库引入,Go 的 map 是语言内置的。因为 Go 没有 集合(set),很多时候也需要用 map 模拟。
var m map[string]int
声明了一个 key 为string
,value 为int
的 mapm
,零值为nil
。函数(function):Go 里面函数也是第一等公民。函数既然是一种类型,那么就可以作为变量和参数。Go 支持函数式编程。
var f func(int)bool
声明了一个 『接受一个int
参数并返回一个bool
值的函数』变量f
,零值为nil
。当然你也可以用
func
关键字直接声明一个函数f
:123func f(a int) bool {// 函数体}两者都是通过
f()
调用(当然,实际调用要提供一个int
参数)。后者只能用于全局(包级)函数,必须给出函数体,f
不是一个变量,f
的值是一个具体的函数而且不能修改。从效果上接近一个函数常量(但不是)。结构体(struct):类似 C / C++ 的结构体,但是可以定义行为(方法)。Go 没有 类(class)和 继承(inheritance),而是通过 结构体 和 组合(composition)实现面向对象。
下面声明了一个『拥有一个
string
字段 和一个int
字段 的结构体』的变量alice
,零值是所有字段都为对应零值的结构体:1234var alice struct {name stringage int}但这样写,每次声明都要把结构体重新写一遍,啰嗦还容易错——只要字段的名称、类型或者顺序,随便一样有差别,都会被认为是不同的类型。一般情况下,除非这个结构体只使用这么一次,否则都不应该这样写。
正确的做法,是给结构体一个类型名,然后用名字来声明:
123456789type person struct {name stringage int}var (alice personbob person)在这里
person
成了一个新的自定义类型,与之相对应,前面没有定义名称的结构体称为匿名结构体。接口(interface):接口是一系列行为(方法签名,方法是一种特殊的函数)的集合。跟其它语言不同,Go 的接口不需要显式声明实现(implementation),一个类型只要实现了接口的所有方法,它就隐式地满足接口。是否满足接口可以在编译期静态检查,所以是类型安全的。Go 实现了类型安全的鸭子类型(duck typing) 。这种设计是 Go 的组合式面向对象的重要组成部分。
跟结构体类似,接口的定义比较长,也应该定义成一个自定义类型:
12345678910type runner interface {// 满足 runner 接口的类型,应该具有 walk() 和 run() 两个方法walk() // walk 是一个没有参数也没有返回值的方法run(int)bool // run 方法接收一个 int 参数,并返回一个 bool 值}var (cindy runnerdanny runner)这里声明了两个 『拥有两个方法的接口』的变量。接口变量的零值是
nil
,可以接受任何满足接口的类型的值。通道(channel):channel 用于并发时在协程间通信,是 CSP 模型的重要部分。
channel 除了区分传递的消息的类型,还分读写和缓冲区大小。其中缓冲区在初始化时决定,剩下的在类型上体现:
12345var (ich chan int // 可以读写的通道,消息类型是 intbch <-chan bool // 只读的通道,消息类型是 boolfch chan<- float64 // 只写的通道,消息类型是 float64)channel 的零值为
nil
。
关于派生类型,最后补充两点:
所有零值不是
nil
的变量,都可以声明之后直接使用(只不过值都是零);而零值为nil
的类型,意味着需要额外的初始化,其中 切片(slice)、映射(map)和 通道(channel)都是通过内置函数make()
申请内存并初始化。先定义为自定义类型,再用新类型声明变量,对 结构体 和 接口 来说,既不是必选项,也不是特权。这句话的意思是:
- 不是必选项:匿名结构体 和 匿名接口 也是合法的代码。只不过由于这两种类型的定义太长(一定会换行),匿名使用会有很多麻烦,还是推荐先定义类型。这虽然不是强制选项,却是最佳实践。
- 不是特权:只要你愿意,所有类型都可以定义为自定义类型(参考下一节),只不过有没有必要而已。
这部分内容只是为了让大家对派生类型有一个整体的印象。细节会在用到这些类型时详细展开。
3. 自定义类型
Go 使用 type
关键字自定义类型,有两种用法:
3.1 类型定义
语法 type TypeName TypeDefinition
例子:
|
|
类型定义 看起来很像 C / C++ ,只是把名字移到了前面:
|
|
或者 Java 的
|
|
虽然像,差别也很明显:
- Java 里,新名字只能是一个类(即使里面只有一个值);Go 既能创建新结构体 / 接口,也能复用已有的类型——包括基本类型和内置的派生类型。
- C 的
typedef
倒是也可以用于基本类型,但得到的实际上是一个别名,新旧类型仍然是同一种类型;而 Go 声明了一种新的类型。
以 type NewInt int64
举例,虽然它们共享同样的内存实现(8 个字节的连续内存),在基本运算符上有同样的结果,但是 NewInt
被认为是跟 int
不同的一个类型,可以拥有自己的方法。两种类型不能直接一起运算,也不能用作另一种类型的参数,需要经过转换。
不直接使用原类型,而是定义命名的新类型,我认为有以下几个原因:
使用方便。
这是对冗长的派生类型——尤其是 结构体 和 接口 而言的。简洁的名字当然比冗长的结构方便且不易出错。
自注释。
名字可以体现用途和意图。
借用静态检查发现错误。
将底层实现一样但是业务逻辑不一致的类型分别定义为不同的类型,可以借由静态检查发现逻辑错误。这在上一期类型转换部分,我用 砧板 和 地板 的 底层类型都是 木板 做了一个类比。
添加方法。
Go 可以(且只可以)给当前包定义的类型添加方法。内置类型和导入类型定义成新类型之后,就可以给新类型添加方法,实现面向对象编程。
需要注意的是,定义成新类型之后,原来类型的方法就全部丢失,不能再访问了(毕竟已经不是同一个类型)。如果需要保留原来的方法,应该选择将旧类型匿名嵌入新类型的结构体。匿名嵌入效果上接近继承,实际上是组合,只是跟一般成员组合相比,被匿名嵌入类型的成员和方法可以直接访问。具体在 方法 和 结构体 部分展开。
3.2 类型别名
语法 type TypeAlias = AnotherType
例子:
|
|
类型别名 type IntAlias = int
中,IntAlias
被认为是 int
的别名,看作是同一类型,可以直接一起运算或者作为参数,无需转换。类型别名自 Go 1.9 引入,用来解决迁移、升级等重构场景下,类型重命名的兼容性问题,以及方便引用外部导入的类型。
实际上,类型别名仅在代码中存在,编译时会全部替换成实际的类型。只有类型定义产生了新的类型。
4. 枚举(模拟)
说到自定义类型,就顺便提一下枚举。
在数学和计算机科学上,枚举是指列出一个有限集合的所有成员。而枚举类型是一种特殊的类型,只能取限定的某几个值。有些语言只是限定了枚举类型的取值(C / C++);而有些语言则(以常量的形式)直接预先初始化了枚举类型所有可能值的实例,变量不仅仅只能取有限的值,而是只能是这几个实例之一(Java,Python)。
Go 没有提供对枚举的支持。是的,Go 跟谁都不像,根本没有枚举。相对应地,在 Go 里一般通过 自定义类型 + 常量 模拟枚举。我们来看看官方库里面 time
包对 月份 Month
和 周几 Weekday
的定义:
|
|
在这个例子里,Month
和 Weekday
都是底层类型为 int
的自定义类型;然后这两个类型定义了一系列的常量作为取值范围,并且定义了一个 String()
方法,返回对应值的字符串形式。
在 1.4 之后,Go 工具链提供了 go generate
命令,配合 stringer
工具可以自动生成常量的 String()
方法。除此之外,也可以按需给新类型添加各种方法,模拟其他语言里的枚举,或者增加需要的功能。详情可以自己查阅,这里不再展开。
有独立的类型、通过常量给定取值、能返回字符串,肯定比直接用一个整型数来表示要强。自定义类型被认为是跟原来不一样的类型,在赋值或者传参过程中,如果使用了不同类型的变量,直接在编译时就报错了;另一方面,如果只通过常量引用这些 “枚举类型” 的值,取值范围也限制住了。基本实现了枚举的目的。
但也只能说部分实现。提供的常量只是给出了范围的建议值,而不是强制值。Go 没有提供『把类型的取值范围硬性限制在某几个值』的语义。新类型的取值范围仍然是和底层类型一样:
|
|
如果说 m1
是违反了(模拟的)枚举类型只使用常量引用的原则,注意一下就可以避免;那么 m2
这种忘记赋值的情况可能更难发现一些。
既然没有办法通过类型安全本身限制取值,就只能在使用时注意判断值的范围,特别要处理意外的情况。在判断枚举值时,一般使用 if-else
或者 switch-case
代码块,这时记得加上一个 else
或者 default
处理无效值。又或者干脆给类型添加一个 IsValid()bool
方法,判断值是否有效。
当然这种实现方式也并不全是坏处。Go 没有限定枚举必须是什么样子,就可以按自己的需要设计:
- 枚举的底层值不一定是整数,可以是任意基本类型。
- 枚举的值允许重复;这个有些时候可能会引起错误,有些时候又可能有用。
- 没有增加特殊情况,保持了类型系统的简单和高效;引入专门的枚举类型其实涉及很多问题,例如序列化和反序列化怎么处理。
总的来说,我个人觉得在枚举这个问题上处理得不够好,好像不太符合 Go 一向强类型和自带最佳实践的风格。当然也有可能是我理解得不够深入。但无论怎么说,目前的实现方式在官方库非常普遍,出于兼容性的考虑,至少在 Go 1.x 阶段不太会改动的样子。那就接受这个设定,并且小心避开潜在的坑吧。
练习
问题1 :
假设程序中需要储存一个状态,有 待办、进行中、完成 三种状态,应该怎么样定义类型?
如果这样的状态需要储存非常多个,定义成一个大数组储存,该如何节省空间?
问题 2:
问以下程序的输出,为什么。老规矩,不用运行就得出答案更佳。
|
|
上期练习答案
第二期最后的练习,答案如下
如果还没做练习,不要直接看答案
|
|
你答对了吗?
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。