首页 Go的基本数据类型
文章
取消

Go的基本数据类型

Go的数据类型分四大类:

  • 基础类型(basic type)
  • 聚合类型(aggregate type)
  • 引用类型(reference type)
  • 接口类型(interface type)。

本章的主题是基础类型,包括数字(number)、字符串(string)和布尔型(boolean)。

聚合类型一数组(array,见4.1节)和结构体(struct,见4.4节)一是通过组合各种简单类型得到的更复杂的数据类型。

引用是一大分类,其中包含多种不同类型,如指针(pointer,见2.3.2节),slice(见4.2节),map(见4.3节),函数(function,见第5章),以及通道(channel,见第8章)。它们的共同点是全都间接指向程序变量或状态,于是操作所引用数据的效果就会遍及该数据的全部引用。接口类型将在第7章讨论。

整数

各种整数类型

Go的数值类型包括了几种不同大小的整数、浮点数和复数。各种数值类型分别有自己 的大小,对正负号支持也各异。

Go同时具备有符号整数和无符号整数。有符号整数分四种大小:8位、16位、32位、64 位,用int8int16int32int64表示,对应的无符号整数是uint8uint16unint32uint64

此外还有两种类型intuint。在特定平台上,其大小与原生的有符号整数\无符号整 数相同,或等于该平台上的运算效率最高的值。int是目前使用最广泛的数值类型。这两种类型大小相等,都是32位或64位,但不能认为它们一定就是32位,或一定就是64位;即 使在同样的硬件平台上,不同的编译器可能选用不同的大小。

rune类型是int32类型的同义词,常常用于指明一个值是Unicode码点(code point)。这两个名称可互换使用。同样,byte类型是uint8类型的同义词,但强调一个值是原始数据,而非量值。

最后,还有一种无符号整数uintptr,其大小并不明确,但足以完整存放指针。uintptr类型仅仅用于底层编程,例如在Go程序与C程序库或操作系统的接口界面。第13章介绍unsafe包,将会结合uintptr举例。

intuintuintptr都有别于其大小明确的相似类型的类型。就是说,intint32是不同类型,尽管int天然的大小就是32位,并且int值若要当作int32使用,必须显式转换;反之亦然。

有符号整数以补码表示,保留最高位作为符号位,位数字的取值范围是$[{-2}^{n-1}, {2}^{n-1}-1]$。无符号整数由全部位构成其非负值,范围是$[0, 2^{n}-1]$。

二元操作符

Go的二元操作符涵盖了算术、逻辑和比较等运算。按优先级的降序排列如下:

*   /   %   <<  >>  &   &^
+   -   |   ^
==  !=  <   <=  >   >=
&&
||

二元运算符分五大优先级。同级别的运算符满足左结合律,为求清晰,可能需要圆括 号,或为使表达式内的运算符按指定次序计算,如mask & (1<<28)

上述列表中前两行的运算符(加法运算+)都有对应的赋值运算符(+=),用于简写赋值语句。

算术运算符+-*/可应用于整数、浮点数和复数,而取模运算符%仅能用于整数。取模运算符%的行为因编程语言而异。就Go而言,取模余数的正负号总是与被除数一致,于是-5%3-5%-3都得-2。除法运算/的行为取决于操作数是否都为整型,整数相除,商会舍弃小数部分,于是5.0/4.0得到1.25,而5/4结果是1

不论是有符号数还是无符号数,若表示算术运算结果所需的位超出该类型的范围,就称为溢出。溢出的高位部分会无提示地丢弃。假如原本的计算结果是有符号类型,且最左侧位是1,则会形成负值,以int8为例:

1
2
3
4
5
var u uint8 = 255
fmt.Println(u, u+1, u*u)    // "255 0 1"

var i int8 = 127
fmt.Print1n(i, i+1, i*i)    // "127 -128 1"

下列二元比较运算符用于比较两个类型相同的整数;比较表达式本身的类型是布尔型。

==  等于
!=  不等于
<   小于
<=  小于或等于
>   大于
>=  大于等于

实际上,全部基本类型的值(布尔值、数值、字符串)都可以比较,这意味着两个相同类型的值可用=!=运算符比较。整数、浮点数和字符串还能根据比较运算符排序。许多其他类型的值是不可比较的,也无法排序。后面介绍每种类型时,我们将分别说明比较规则。

另外,还有一元加法和一元减法运算符:

+   一元取正(无实际影响)
-   一元取负

对于整数,+x0+x的简写,而-x则为0-x的简写。对于浮点数和复数,+x就是x-xx的负数。

Go也具备下列位运算符,前四个对操作数的运算逐位独立进行,不涉及算术进位或正负号:

&   位运算AND
|   位运算OR
^   位运算XOR
&^  位清空(AND NOT)
<<  左移
>>  右移

如果作为二元运算符,运算符^表示按位异或(XOR);若作为一元前缀运算符,则它表示按位取反或按位取补,运算结果就是操作数逐位取反。运算符&^是按位清除(AND NOT):表达式z = x & y中,若y的某位是1,则z的对应位等于0;否则,它就等于x的对应位。

下面的代码说明了如何用位运算将一个uint8值作为位集(bitset)处理,其含有8个独立的位,高效且紧凑。Printf用谓词%b以二进制形式输出数值,副词08在这个输出结果前补零,补够8位。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var x uint8 = 1 << 1 | 1 << 5   // 二进制位00000010与00100000或运算的结果
var y uint8 = 1 << 1 | 1 << 2   // 二进制位00000010与00000100或运算的结果
fmt.Printf("%08b\n", x) // "00100010",集合{1, 5}
fmt.Printf("%08b\n", y) // "00000110",集合{1, 2}

fmt.Printf("%08b\n", x & y) // "00000010",交集{1}
fmt.Printf("%88b\n", x | y) // "00100110",并集{1, 2, 5}
fmt.Printf("%88b\n", x ^ y) // "00100100", 对称差{2, 5}
fmt.Printf("%88b\n", x &^ y)// "00100000", 差集{5}

for i := uint(0); i < 8; i++ {
    if x & (1 << i) != 0 {  // 元素判定
        fmt.Println(i)      // "1","5"
    }
}
fmt.Printf("%08b\n", x << 1)    // "01000100",集合{2, 6}
fmt.Printf("%88b\n", x >> 1)    // "00010001",集合{0, 4}

在移位运算x << nx >> n中,操作数n决定位移量,而且n必须为无符号型;操作数x可以是有符号型也可以是无符号型。算术上,左移运算x << n等价于x乘以2^n;而右移运算x >> n等价于x除以2^n,向下取整。

左移以0填补右边空位,无符号整数右移同样以0填补左边空位,但有符号数的右移操 作是按符号位的值填补空位。因此,请注意,如果将整数以位模式处理,须使用无符号整型。

尽管Go具备无符号整型数和相关算术运算,也尽管某些量值不可能为负,但是我们往往还采用有符号整型数,如数组的长度(即便直观上明显更应该选用uint)。下例从后向前输出奖牌名称,循环里用到了内置的len函数,它返回有符号整数:

1
2
3
4
medals := []string{"gold", "silver", "bronze"}
for i := len(medals) - 1; i >= 0; i-- {
    fmt.Println(medals[i])  // "bronze", "silver", "gold"
}

相反,假若len返回的结果是无符号整数,就会导致严重错误,因为i随之也成为uint型,根据定义,条件i >= 0将恒成立。第3轮迭代后,有i == 0,语句i--使得i变为uint型的最大值(例如,可能为2^64-1),而非-1,导致medals[i]试图越界访问元素,超出slice范围,引发运行失败或宕机。因此,无符号整数往往只用于位运算符和特定算术运算符,如实现位集时,解析二进制格式的文件,或散列和加密。一般而言,无符号整数极少用于表示非负值。

通常,将某种类型的值转换成另一种,需要显式转换。对于算术和逻辑(不含移位)的 二元运算符,其操作数的类型必须相同。虽然这有时会导致表达式相对冗长,但是一整类错 误得以避免,程序也更容易理解。比如,下面这段代码:

1
2
3
var apples int32 = 1
var oranges int16 = 2
var compote int = apples + oranges  // 编译错误

尝试编译这三个声明将产生错误消息:

非法操作:apples+oranges(int32与int16类型不匹配)

类型不匹配(+的问题)有几种方法改正,最直接地,将全部操作数转换成同一类型:

var compote = int(apples) + int(oranges)

对于每种类型T,若允许转换,操作T(x)会将x的值转换成类型T。很多整型-整型转换不会引起值的变化,仅告知编译器应如何解读该值。不过,缩减大小的整型转换,以及整型与浮点型的相互转换,可能改变值或损失精度:

1
2
3
4
5
f := 3.141  // a f1oat64
i := int(f)
fmt.Println(f, i)    // "3.141 3"
f = 1.99
fmt.Println(int(f)) // "1"

浮点型转成整型,会舍弃小数部分,趋零截尾(正值向下取整,负值向上取整)。如果 有些转换的操作数的值超出了目标类型的取值范围,就应当避免这种转换,因为其行为依赖 具体实现:

1
2
f := 1e100  // a float64
i := int(f) // 结果依赖实现

不论有无大小和符号限制,源码中的整数都能写成常见的十进制数;也能写成八进制数,以0开头,如0666;还能写成十六进制数,以0x0X开头,如0xdeadbeef。十六进制的数字(或字母)大小写皆可。

当前,八进制数似乎仅有一种用途——表示POSIX文件系统的权限;

而十六进制数广泛用于强调其位模式,而非数值大小。

输出格式

下例展示,如果使用fmt包输出数字,我们可以用谓词%d%o%x指定进位制基数和输出格式:

1
2
3
4
5
6
7
o := 0666
fmt.Printf("%d %[1]o %#[1]o\n", o)  // "438 666 0666"

x := int64(0xdeadbeef)

fmt.Printf("%d %[1]x %#[1]x %#[1]X\n", x)   // 输出:
// 3735928559 deadbeef 0xdeadbeef 0XDEADBEEF

注意fmt的两个技巧。通常Printf的格式化字符串含有多个%谓词,这要求提供相同数目的操作数,但:

  • %后的副词[1]告知Printf重复使用第一个操作数。
  • %o%x%X之前的副词#告知Printf输出相应的前缀00x0X

源码中,文字符号(rune literal)的形式是字符写在一对单引号内。最简单的例子就是ASCII字符,如'a',但也可以直接使用Unicode码点(codepoint)或码值转义,稍后有介绍。

%c输出文字符号,如果希望输出带有单引号则用%q:

1
2
3
4
5
6
ascii := 'a'
unicode := '国'
newline := '\n'
fmt.Printf("%d %[1]c %[1]q\n", ascii)   // "97 a 'a'"
fmt.Printf("%d %[1]c %[1]q\n", unicode) // "22269 国 '国'"
fmt.Printf("%d %[1]q\n", newline)       // "10 '\n'"

浮点数

Go具有两种大小的浮点数f1oat32f1oat64。其算术特性遵从IEEE754标准,所有新式CPU都支持该标准。

这两个类型的值可从极细微到超宏大。math包给出了浮点值的极限。常量math.MaxFloat32f1oat32的最大值,大约为3.4e38,而math.MaxFloat64则大约为1.8e308。相应地,最小的正浮点值大约为1.4e-454.9e-324

十进制下,f1oat32的有效数字大约是6位,f1oat64的有效数字大约是15位。绝大多数情况下,应优先选用f1oat64,因为除非格外小心,否则f1oat32的运算会迅速累积误差。另外,f1oat32能精确表示的正整数范围有限:

1
2
var f f1oat32 = 16777216    // 1 << 24
fmt.Println(f == f + 1)         // "true"

在源码中,浮点数可写成小数,如:

1
const e = 2.71828 // 近似值

小数点前的数字可以省略(.77),后面的也可省去(1.)。非常小或非常大的数字最好使用科学记数法表示,此方法在数量级指数前写字母e或E:

1
2
const Avogadro = 6.02214129e23
const Planck = 6.62606957e-34

浮点值能方便地通过Printf的谓词%g输出,该谓词会自动保持足够的精度,并选择最 简洁的表示方式,但是对于数据表,%e(有指数)或%f(无指数)的形式可能更合适。这三个谓词都能掌控输出宽度和数值精度。

1
2
3
for x:= 0; x < 8; x++ {
    fmt.Printf("x = %d e^x = %8.3f\n", x, math.Exp(float64(x)))
}

上面的代码按8个字符的宽度输出自然对数e的各个幂方,结果保留三位小数:

x = 0   e^x =    1.000
x = 1   e^x =    2.718
x = 2   e^x =    7.389
x = 3   e^x =   20.086
x = 4   e^x =   54.598
x = 5   e^x =  148.413
x = 6   e^x =  403.429
x = 7   e^x = 1096.633

除了大量常见的数学函数之外,math包还有函数用于创建和判断IEEE754标准定义的特殊值:正无穷大和负无穷大,它表示超出最大许可值的数及除以零的商;以及NaN(Not a Number),它表示数学上无意义的运算结果(如0/0或sqrt(-1))。

1
2
var z float64
fmt.Println(z, -z, 1 / z, -1 / z, z / z)    // "0 -0 +Inf -Inf NaN"

math.IsNaN函数判断其参数是否是非数值,math.NaN函数则返回非数值(NaN)。在数字运算中,我们倾向于将NaN当作信号值(sentinel value),但直接判断具体的计算结果是否为NaN可能导致潜在错误,因为与NaN的比较总不成立(除了!=,它总是与==相反):

1
2
nan := math.NaN()
fmt.Println(nan ==nan, nan < nan, nan > nan)//"false false false"

一个函数的返回值是浮点型且它有可能出错,那么最好单独报错,如下:

1
2
3
4
5
6
7
func compute() (value float64, ok bool) {
    // ...
    if failed {
        return 0, false
    }
    return result, true
}

复数

Go具备两种大小的复数complex64complex128,二者分别由f1oat32f1oat64构成。

内置的complex函数根据给定的实部和虚部创建复数,而内置的real函数和imag函数则分别提取复数的实部和虚部:

1
2
3
4
5
var x complex128 = complex(1, 2)    // 1 + 2i
var y complex128 = complex(3, 4)    // 3 + 4i
fmt.Println(x * y)  // "(-5 + 10i)"
fmt.Println(real(x*y))  // "-5"
fmt.Println(imag(x*y))  // "10"

源码中,如果在浮点数或十进制整数后面紧接着写字母i,如3.141592i2i,它就变成一个虚数,表示一个实部为0的复数:

1
fmt.Print1n(1i * 1i)    // -1

根据常量运算规则,复数常量可以和其他常量相加(整型或浮点型,实数和虚数皆可),这让我们可以自然地写出复数,如1 + 2i,或等价地,2i + 1。前面xy的声明可以简写为:

1
2
x := 1 + 2i
y := 3 + 4i

可以用==!=判断复数是否等值。若两个复数的实部和虚部都相等,则它们相等。math/cmplx包提供了复数运算所需的库函数,例如复数的平方根函数和复数的幂函数。

1
fmt.Println(cmplx.Sqrt(-1)) // "(0 + 1i)"

布尔值

bool型的值或布尔值(boolean)只有两种可能:真(true)和假(false)。iffor语句里的条件就是布尔值,比较操作符(如==<)也能得出布尔值结果。一元操作符()表示逻辑取反,因此!true就是false,或者可以说(!true==false)==true。比如,考虑到代码风格,布尔表达式x==true相对冗长,我们总是简化为x

布尔值可以由运算符&&(AND)以及||(OR)组合运算,这可能引起短路行为:如果运算符左边的操作数已经能直接确定总体结果,则右边的操作数不会计算在内,所以下面的表达式是安全的:

1
s != "" && s[0] == 'x'

其中,如果作用于空字符串,s[0]会触发宕机异常。

因为&&||优先级更高(助记窍门:&&表示逻辑乘法,||表示逻辑加法),所以如下形式的条件无须加圆括号:

1
2
3
4
5
if 'a' <= c && c <= 'z'||
    'A' <= c && c <= 'Z' ||
    '0' <= c && c <= '9' {
    // ··ASCII字母或数字
    }

布尔值无法隐式转换成数值(如0或1),反之也不行。如下状况下就有必要使用显式if:

1
2
3
4
1 := 0
if b {
    1 = 1
}

字符串

字符串是不可变的字节序列,它可以包含任意数据,包括0值字节,但主要是人类可读的文本。习惯上,文本字符串被解读成按UTF-8编码的Unicode码点(文字符号)序列,稍后将细究相关内容。

内置的len函数返回字符串的字节数(并非文字符号的数目),下标访问操作s[i]则取得第i个字符,其中0 <= i < len(s)

1
2
3
s := "hello, world"
fmt.Println(len(s)) // "12"
fmt.Println(s[0], s[7]) // "104 119" ('h' and 'w')

试图访问许可范围以外的字节会触发宕机异常:

1
c := s[len(s)]  // 宕机:下标越界

字符串的第i个字节不一定就是第i个字符,因为非ASCII字符的UTF-8码点需要两个字节或多个字节。

子串生成操作s[i:j]产生一个新字符串,内容取自原字符串的字节,下标从i(含边界值)开始,直到j(不含边界值)。结果的大小是j-i个字节。

1
fmt.Println(s[0:5]) // "hello"

再次强调,若下标越界,或者j的值小于i,将触发宕机异常。

操作数ij的默认值分别是0(字符串起始位置)和1en(s)(字符串终止位置),若省略ij,或两者,则取默认值。

1
2
3
fmt.Println(s[:5])  // "hello"
fmt.Println(s[7:])  // "world"
fmt.Println(s[:])   // "hello, world"

加号(+)运算符连接两个字符串而生成一个新字符串:

1
fmt.Println("goodbye" + s[5:])  // "goodbye, world"

字符串可以通过比较运算符做比较,如==<;比较运算按字节进行,结果服从本身的字典排序。

尽管肯定可以将新值赋予字符串变量,但是字符串值无法改变:字符串值本身所包含的字节序列永不可变。要在一个字符串后面添加另一个字符串,可以这样编写代码:

1
2
3
s := "left foot"
t := s
s += ", right foot"

这并不改变s原有的字符串值,只是将+=语句生成的新字符串赋予s。同时,t仍然持 有旧的字符串值。

1
2
fmt.Println(s)  // "left foot, right foot"
fmt.Println(t)  // "left foot"

因为字符串不可改变,所以字符串内部的数据不允许修改:

1
s[0] = 'L'  // 编译错误:s[0]无法赋值

不可变意味着两个字符串能安全地共用同一段底层内存,使得复制任何长度字符串的开销都低廉。类似地,字符串s及其子串(如s[7:])可以安全地共用数据,因此子串生成操作的开销低廉。这两种情况下都没有分配新内存。

字符串字面量

字符串的值可以直接写成字符串字面量(string literal),形式上就是带双引号的字节序列:"He11o,世界",因为Go的源文件总是按UTF-8编码,并且习惯上Go的字符串会按UTF-8解读,所以在源码中我们可以将Unicode码点写入字符串字面量。

在带双引号的字符串字面量中,转义序列以反斜杠(\)开始,可以将任意值的字节插入字符串中。下面是一组转义符,表示ASCI控制码,如换行符、回车符和制表符。

\a "警告"或响铃         \b 退格符
\f 换页符              \n 换行符(指直接跳到下一行的同一位置)
\r 回车符(指返回行首)    \t 制表符
\v 垂直制表符           \' 单引号(仅用于文字字符字面量'\'')
\" 双引号(仅用于"..."字面量内部)
\\ 反斜杠

源码中的字符串也可以包含十六进制或八进制的任意字节。十六进制的转义字符写成\xhh的形式,h是十六进制数字(大小写皆可),且必须是两位。八进制的转义字符写成\ooo的形式,必须使用三位八进制数字(0~7),且不能超过\377。这两者都表示单个字节,内容是给定值。后面,我们将看到如何将数值形式的Unicode码点嵌入字符串字面量。

原生的字符串字面量的书写形式是`…`,使用反引号而不是双引号。原生的字符串字面量内,转义序列不起作用;实质内容与字面写法严格一致,包括反斜杠和换行符,因此,在程序源码中,原生的字符串字面量可以展开多行。唯一的特殊处理是回车符会被删除(换行符会保留),使得同一字符串在所有平台上的值都相同,包括习惯在文本文件存入换行符的系统。

正则表达式往往含有大量反斜杠,可以方便地写成原生的字符串字面量。原生的字面量也适用于HTML模板、JSON字面量、命令行提示信息,以及需要多行文本表达的场景。

1
2
3
4
5
const GoUsage = `Go is a tool for managing Go source code.

Usage:
    go command [arguments]
    `

Unicode编码的介绍

从前,事情简单明晰,至少,狭隘地看,软件只须处理一个字符集:ASCII(美国信息交换标准码)。ASCII(或更确切地说,US-ASCII)码使用7位表示128个字符:大小写英文字母、数字、各种标点和设备控制符。这对早期的计算机行业已经足够了,但是让世界 上众多使用其他语言的人无法在计算机上使用自己的文书体系。随着互联网的兴起,包含纷 繁语言的数据屡见不鲜。到底怎样才能应付语言的繁杂多样,还能兼顾高效率?

答案是Unicode(unicode.org),它囊括了世界上所有文书体系的全部字符,还有重音符和其他变音符,控制码(如制表符和回车符),以及许多特有文字,对它们各自赋予一个叫Unicode码点的标准数字。在Go的术语中,这些字符记号称为文字符号(rune)。

Unicode第8版定义了超过一百种语言文字的12万个字符的码点。它们在计算机程序和数据中如何表示?天然适合保存单个文字符号的数据类型就是int32,为Go所采用;

  • 正因如此,rune类型作为int32类型的别名。

我们可以将文字符号的序列表示成int32值序列,这种表示方式称作UTF-32UCS-4,每个Unicode码点的编码长度相同,都是32位。这种编码简单划一,可是因为大多数面向计算机的可读文本是ASCII码,每个字符只需8位,也就是1字节,导致了不必要的存储空间消耗。

因此,诞生了新的编码方式:UTF-8编码;

UTF-8以字节为单位对Unicode码点作变长编码。UTF-8是现行的一种Unicode标准,由Go的两位创建者Ken Thompson和Rob Pike发明。每个文字符号用1~4个字节表示,ASCII字符的编码仅占1个字节,而其他常用的文书字符的编码只是2或3个字节。一个文字符号编码的首字节的高位指明了后面还有多少字节。若最高位为0,则标示着它是7位的 ASCII码,其文字符号的编码仅占1字节,这样就与传统的ASCII码一致。若最高几位是110,则文字符号的编码占用2个字节,第二个字节以1开始。更长的编码以此类推。

Oxxxxxxx                            文字符号0~127   (ASCII)
110xxxxx 10xxxxxx                   128~2047    少于128个未使用的值
1110xxxx 10xxxxxx 10xxxxxx          2048~65535  少于2048个未使用的值
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 65536~0x10ffff  其他未使用的值

变长编码的字符串无法按下标直接访问第n个字符,然而有失有得,UTF-8换来许多有用的特性。UTF-8编码紧凑,兼容ASCII,并且自同步:最多追溯3字节,就能定位一个字 符的起始位置。UTF-8还是前缀编码,因此它能从左向右解码而不产生歧义,也无须超前预读。于是查找文字符号仅须搜索它自身的字节,不必考虑前文内容。文字符号的字典字节顺序与Unicode码点顺序一致(Unicode设计如此),因此按UTF-8编码排序自然就是对文字符号排序。UTF-8编码本身不会嵌入NUL字节(0值),这便于某些程序语言用NUL标记字符串结尾。

Go的源文件总是以UTF-8编码,同时,需要用Go程序操作的文本字符串也优先采用UTF-8编码。unicode包具备针对单个文字符号的函数(例如区分字母和数字,转换大小写),而unicode/utf8包则提供了按UTF-8编码和解码文字符号的函数。

许多Unicode字符难以直接从键盘输入;有的看起来十分相似几乎无法分辨;有些甚至不可见。Go语言中,字符串字面量的转义让我们得以用码点的值来指明Unicode字符。有两种形式,\uhhhh表示16位码点值,\Uhhhhhhhh表示32位码点值,其中每个h代表一个 十六进制数字;32位形式的码点值几乎不需要用到。这两种形式都以UTF-8编码表示出给定的码点。因此,下面几个字符串字面量都表示长度为6字节的相同串:

"世界"
"\xe4\xb8\x96\xe7\x95\x8c"
"\u4e16\u754c"
"\U00004e16\U0000754c"

后面三行的转义序列用不同形式表示第一行的字符串,但实质上它们的字符串值都一样。

Unicode转义符也能用于文字符号。下列字符是等价的:

'世'    '\u4e16'    'U80004e16'

码点值小于256的文字符号可以写成单个十六进制数转义的形式,如'A'写成'\x41',而更高的码点值则必须使用\u\U转义。这就导致,'\xe4\xb8\x96'不是合法的文字符号,虽然这三个字节构成某个有效的UTF-8编码码点。

由于UTF-8的优良特性,许多字符串操作都无须解码。我们可以直接判断某个字符串是否为另一个的前缀:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func HasPrefix(s, prefix string) bool {
    return len(s) >= len(prefix) && s[:len(prefix)]== prefix
}    
// 或者它是否为另一个字符串的后缀:
func HasSuffix(s, suffix string) bool {
    return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
}

// 或者它是否为另一个的子字符串:
func Contains(s, substr string) bool {
    for i :=0;i < len(s); i++ {
        if HasPrefix(s[i:], substr) {
            return true
        }
    }
    return false
}

UTF-8编码的文本的逻辑同样也适用原生字节序列,但其他编码则无法如此。(上面的函数取自strings包,其实Contains函数的具体实现使用了散列方法让搜索更高效。)

另一方面,如果我们真的要逐个逐个处理Unicode字符,则必须使用其他编码机制。考虑我们的一个字符串,它包含两个东亚字符。该字符串的内存布局在书中已展示。它含有13个字节,而按作UTF-8解读,本质是9个码点或文字符号的编码:

1
2
3
4
5
6
7
8
9
10
11
import "unicode/utf8"

s := "Hello, 世界"
fmt.Println(len(s)) //"13"
fmt.Println(utf8.RuneCountInString(s))  //"9"个码点
// 我们需要UTF-8解码器来处理这些字符,unicode/utf8包就具备一个:
for i := 0; i < len(s); {
    r, size := utf8.DecodeRuneInString(s[i:])
    fmt.Printf("%d\t%c\n", i, r)
    i += size
}

每次DecodeRuneInString的调用都返回r(文字符号本身)和一个值(表示r按UTF-8编码所占用的字节数)。这个值用来更新下标i,定位字符串内的下一个文字符号。可是按此方法,我们总是需要使用上例中的循环形式。所幸,Go的range循环也适用于字符串,按UTF-8隐式解码。注意,对于非ASCII文字符号,下标增量大于1。

1
2
3
4
// 下面的循环会自动处理不同ASCII编码字符的下标增量
for i,r := range "Hello, 世界" {
    fmt.Printf("%d\t%q\t%d\n", i, r, r)
}

之前提到过,文本字符串作为按UTF-8编码的Unicode码点序列解读,很大程度是出于习惯,但为了确保使用range循环能正确处理字符串,则必须要求而不仅仅是按照习惯。

如果字符串含有任意二进制数,也就是说,UTF-8数据出错,而我们对它做range循环,会发生什么?

每次UTF-8解码器读入一个不合理的字节,无论是显式调用utf8.DecodeRuneInString,还是在range循环内隐式读取,都会产生一个专门的Unicode字符'\uFFFD'替换它,其输出通常是个黑色六角形或类似钻石的形状,里面有个白色问号。如果程序碰到这个文字符号值,通常意味着,生成字符串数据的系统上游部分在处理文本编码方面存在瑕疵。

显示出来的文本与字符串的Unicode码点序列存在着对应关系,[]rune则可以将相应的字符串转化为字符串的Unicode码点序列;

如果文字符号非法,将被专门的替换字符所取代,以下代码将展示这个过程:

1
2
3
4
5
6
7
8
fmt.Println(string(65))      // 输出字符"A"
fmt.Println(string(0x4eac))  // 输出字符"京"
fmt.Println(string(1234567)) // 无意义字符,也就是上面提及的非法字符

s := "我爱你"
fmt.Printf("% x\n", s) // 十六进制的表现形式
r := []rune(s)
fmt.Printf("%x\n", r) // 输出了码点序列

字符串操作

字符串分两种类型:

  • 首先是字符串类型,包含一个字节数组,创建后一般就无法改变。
  • 其次是字节slice,可动态增长;

类比CPP中的字面值常量和string字符串即可;

Go中的4个标准包对字符串操作特别重要:bytesstringsstrconvunicode

  • strings包提供了许多函数,用于搜索、替换、比较、修整、切分与连接字符串。
  • bytes包也有类似的函数,用于操作字节slice([]byte类型,其某些属性和字符串相同)。由于字符串不可变,因此按增量方式构建字符串会导致多次内存分配和复制。这种情况下,使用bytes.Buffer类型会更高效,范例见后。
  • strconv包具备的函数主要用于转换布尔值、整数、浮点数为与之对应的字符串形式,或者把字符串转换为布尔值、整数、浮点数,另外还有为字符串添加/去除引号的函数。
  • unicode包备有判别文字符号值特性的函数,如IsDigitIsLetterIsUpperIsLower。每个函数以单个文字符号值作为参数,并返回布尔值。若文字符号值是英文字母,转换函数(如ToUpperToLower)将其转换成指定的大小写。

上面所有函数都遵循Unicode标准对字母数字等的分类原则。strings包也有类似的函数,函数名也是ToUpperToLower,它们对原字符串的每个字符做指定变换,生成并返回一个新字符串。

下例中,basename函数模仿UNIX shell中的同名实用程序。只要s的前缀看起来像是文件系统路径(各部分由斜杠分隔),该版本的basename(s)就将其移除,貌似文件类型的后缀也被移除:

1
2
3
fmt.Println(basename("a/b/c.go"))   //"c"
fmt.Println(basename("c.d.go"))     //"c.d"
fmt.Println(basename("abc"))        //"abc"

初版的basename独自完成全部工作,并不依赖任何库:

path包和path/filapath包提供了一组更加普遍适用的函数,用来操作文件路径等具有层次结构的名字。path包处理以斜杠/分段的路径字符串,不分平台。但它不适合用于处理文件名,却适合其他领域,像URL地址的路径部分。

相反地,path/filepath包根据宿主平台(host platform)的规则处理文件名,例如POSIX系统使用/foo/bar,而Microsoft Windows系统使用c:\foo\bar

若字符串包含一个字节数组,创建后它就无法改变。相反地,字节slice的元素允许随意修改。

  • 字节slice是Go中的一种切片(字节)序列,是一种动态长度的数据结构;

字符串可以和字节slice相互转换:

1
2
3
s := "abc"
b := []byte(s)  // 这个就是字节slice类型的数据结构
s2 := string(b)

概念上,[]byte(s)转换操作会分配新的字节数组,拷贝填入s含有的字节,并生成一个slice引用,指向整个数组。具备优化功能的编译器在某些情况下可能会避免分配内存和复制内容,但一般而言,复制有必要确保s的字节维持不变(即使b的字节在转换后发生改变)。反之,用string(b)将字节slice转换成字符串也会产生一份副本,保证s2也不可变。

为了避免转换和不必要的内存分配,bytes包和strings包都预备了许多对应的实用函数(utility function),它们两两相对应。例如,strings包具备下面6个函数:

1
2
3
4
5
6
func Contains(s, substr string) bool
func Count(s, sep string) int
func Fields(s string) []string
func HasPrefix(s, prefix string) bool
func Index(s, sep string) int
func Join(a []string, sep string) string

bytes包里面的对应函数为:

1
2
3
4
5
6
func Contains(b, subslice []byte) bool
func Count(s, sep []byte) int
func Fields(s []byte) [][]byte      // []byte类型的数组
func HasPrefix(s, prefix []byte) bool
func Index(s, sep []byte) int
func Join(s [][]byte, sep []byte) []byte

唯一的不同是,操作对象由字符串变为字节slice

bytes包为高效处理字节slice提供了Buffer类型。Buffer起初为空,其大小随着各种类型数据的写入而增长,如stringbyte[]byte

字符串与数字间转换

除了字符串、文字符号和字节之间的转换,我们也常常需要相互转换数值及其字符串表现形式,这个过程主要由strconv包的函数完成。

要将整数转换成字符串,一种选择是使用fmt.Sprintf,另一种做法是用函数strconv.Itoa(integer to ASCII):

1
2
3
x := 123
y := fmt.Sprintf("%d", x)   // Sprintf将格式化的字符串写入到一个字符串类型的变量y当中
fmt.Println(y, strconv.Itoa(x)) // 输出"123 123"

FormatIntFormatUint可以按不同的进位制格式化数字:

1
fmt.Println(strconv.FormatInt(int64(x),2))  // 二进制格式:"1111011"

fmt.Printf里的谓词%b%d%o%x往往比Format函数方便,若要包含数字以外的附加信息,它就尤其有用:

1
s := fmt.Sprintf("x = %b", x) // 同样表示的是输入二进制的形式:"x = 1111011"

strconv包内的Atoi函数或ParseInt函数用于解释表示整数的字符串,而ParseUint用于无符号整数:

1
2
x, err := strconv.Atoi("123")               // x是整型
y, err := strconv.ParseInt("123", 10, 64)   // 十进制,最长为64位int

ParseInt的第三个参数指定结果必须匹配何种大小的整型;

  • 16表示int16
  • 0作为特殊值表示int

上述例子,任何情况下,结果y的类型总是int64,可将他另外转换成较小的类型。

有时候,单行输人由字符串和数字依次混合构成,需要用fmt.Scanf解释,可惜fmt.Scanf也许不够灵活,处理不完整或不规则输入时尤甚。

常量

常量是一种表达式,其可以保证在编译阶段就计算出表达式的值,并不需要等到运行时,从而使编译器得以知晓其值。所有常量本质上都属于基本类型:布尔型、字符串或数字。

常量的声明定义了具名的值,它看起来在语法上与变量类似,但该值恒定,这防止了程序运行过程中的意外(或恶意)修改。例如,要表示数学常量,像圆周率,在Go程序中用常量比变量更适合,因其值恒定不变:

1
const pi = 3.14159  // 近似数;math.Pi是更精准的近似

与变量类似,同一个声明可以定义一系列常量,这适用于一组相关的值:

1
2
3
4
const (
    e = 2.71828182845904523536028747135266249775724709369995957496696763
    pi = 3.14159265358979323846264338327950288419716939937518582097494459
)

许多针对常量的计算完全可以在编译时就完成,以减免运行时的工作量并让其他编译器优化得以实现。某些错误通常要在运行时才能检测到,但如果操作数是常量,编译时就会报错,例如整数除以0,字符串下标越界,以及任何产生无限大值的浮点数运算。

对于常量操作数,所有数学运算、逻辑运算和比较运算的结果依然是常量,常量的类型转换结果和某些内置函数的返回值,例如len、cap、real、imag、complexunsafe.Sizeof,同样是常量。

因为编译器知晓其值,常量表达式可以出现在涉及类型的声明中,具体而言就是数组类型的长度:

1
2
3
4
5
6
const IPv4Len = 4

// parseIPv4函数解释一个IPv4地址
func parseIPv4(s string) IP {
    var p [IPv4Len]byte     // byte是一个无符号整数类型
}

常量声明可以同时指定类型和值,如果没有显式指定类型,则类型根据右边的表达式推断。下例中,time.Duration是一种具名类型,其基本类型是int64time.Minute也是基于int64的常量。下面声明的两个常量都属于time.Duration类型,通过%T展示:

1
2
3
4
5
const noDelay time.Duration = 0
const timeout = 5 * time.Minute
fmt.Printf("%T %[1]v\n", noDelay)       // %T会显示具体类型,%[1]v表示打印第1个变量noDelay的值,"time.Duration 0"
fmt.Printf("%T %[1]v\n", timeout)       // "time.Duration 5m0s"
fmt.Printf("%T %[1]v\n", time.Minute)   // "time.Duration 1m0s"

若同时声明一组常量,除了第一项之外,其他项在等号右侧的表达式都可以省略,这意味着会复用前面一项的表达式及其类型。例如:

1
2
3
4
5
6
7
const (
    a = 1
    b       // 复用上一行
    c = 2
    d       // 同样复用上一行
)
fmt.Println(a, b, c, d) //"1 1 2 2"

如果复用右侧表达式导致计算结果总是相同,这就并不太实用。假若该结果可变该怎么办呢?我们来看看iota

常量生成器iota

常量的声明可以使用常量生成器iota,它创建一系列相关值,而不是逐个值显式写出。常量声明中,iota从0开始取值,逐项加1。

下例取自time包,它定义了Weekday的具名类型,并声明每周的7天为该类型的常量,从Sunday开始,其值为0。这种类型通常称为枚举型(enumeration,或缩写成enum)。

1
2
3
4
5
6
7
8
9
10
type Weekday int
const (
    Sunday Weekday = iota   // 从0开始取值
    Monday
    Tuesday
    Wednesday
    Thursday
    Friday
    Saturday
)

上面的声明中,Sunday的值为0,Monday的值为1,以此类推。

更复杂的表达式也可使用iota,借用net包的代码举例如下,无符号整数最低5位数中的每一个都逐一命名,并解释为布尔值。

1
2
3
4
5
6
7
8
type Flags uint
const (
    FlagUp F1ags = 1 << iota    // 向上
    FlagBroadcast               // 支持广播访问
    FlagLoopback                // 是环回接口
    FlagPointToPoint            // 属于点对点链路
    FlagMulticast               // 支持多路广播访问
)

随着iota递增,每个常量都按1 << iota赋值,这等价于2的连续次幂,它们分别与单个位对应。若某些函数要针对相应的位执行判定、设置或清除操作,就会用到这些常量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 都是对网络状态判断的逻辑,不是很懂,了解个大概
func IsUp(v Flags) bool { return v & FlagUp == FlagUp } // 先进行位运算
func TurnDown(v *Flags) { *v &^= FlagUp }   // *是指针运算符
func SetBroadcast(v *Flags) { *v |= FlagBroadcast }
func IsCast(v Flags) bool   { return v&(FlagBroadcast|FlagMulticast) != 0 }
func main() {
    var v Flags = FlagMulticast | FlagUp
    fmt.Printf("%b %t\n", v, IsUp(v))   // "10001 true"
    TurnDown(&v)
    fmt.Printf("%b %t\n", v, IsUp(v))   // "10000 false"
    SetBroadcast(&v)
    fmt.Printf("%b %t\n", v, IsUp(v))   // "10010 false"
    fmt.Printf("%b %t\n", v, Iscast(v)) // "10010 true"
}

下例更复杂,声明的常量表示1024的幂。

1
2
3
4
5
6
7
8
9
10
11
const (
    _ = 1 << (10 * iota)    // iota从0开始
    KiB // 1024
    MiB // 1048576
    GiB // 1073741824
    TiB // 1099511627776 (超过1<32)
    PiB // 1125899906842624
    EiB // 1152921504606846976
    ZiB // 1180591620717411303424 (超过1<<64)
    YiB // 1208925819614629174706176
)

iota机制存在一定局限性,比如因为不存在指数运算符,所以无从生成更为人熟知的1000的幂(KB、MB等);

  • 但还是有一定诀窍可以实现

无类型常量

Go的常量自有特别之处。虽然常量可以是任何基本数据类型,如intf1oat64,也包括具名的基本类型(如time.Duration),但是许多常量并不从属某一具体类型。编译器将这些从属类型待定的常量表示成某些值,这些值比基本类型的数字精度更高,且算术精度高于原生的机器精度。可以认为它们的精度至少达到256位。从属类型待定的常量共有6种,分别是:

  • 无类型布尔
  • 无类型整数
  • 无类型文字符号
  • 无类型浮点数
  • 无类型复数
  • 无类型字符串。

借助推迟确定从属类型,无类型常量不仅能暂时维持更高的精度,与类型已确定的常量相比,它们还能写进更多表达式而无需转换类型。比如,上例中ZiBYiB的值过大,用哪种整型都无法存储,但它们都是合法常量并且可以用在下面的表达式中:

1
fmt.Println(YiB/ZiB)    // "1024"

再例如,浮点型常量math.Pi可用于任何需要浮点值或复数的地方:

1
2
3
var x float32 = math.Pi
var y float64 = math.Pi
var z complex128 = math.Pi

若常量math.Pi一开始就确定从属于某具体类型,如f1oat64,就会导致结果的精度下降。另外,假使最终需要f1oat32值或comp1ex128值,则可能需要转换类型:

1
2
3
4
5
const Pi64 float64 = math.Pi

var x float32 = float32(Pi64)
var y float64 = Pi64
var z complex128 = complex128(Pi64)

字面量的类型由语法决定。00.00i\u0000全都表示相同的常量值,但类型相异,分别是:无类型整数、无类型浮点数、无类型复数和无类型文字符号。类似地,truefa1se是无类型布尔值,而字符串字面量则是无类型字符串。

根据除法运算中操作数的类型,除法运算的结果可能是整型或浮点型。所以,常量除法表达式中,操作数选择不同的字面写法会影响结果:

1
2
3
4
var f float64 = 212
fmt.Println((f - 32) * 5 / 9)   // "100"; (f-32)*5的结果是float64型
fmt.Println(5 / 9 * (f - 32))   // "0"; 5/9 的结果是无类型整数,0
fmt.Print1n(5.0 / 9.0 * (f - 32))   // "100"; 5.0/9.0 的结果是无类型浮点数

只有常量才可以是无类型的。若将无类型常量声明为变量(如下面的第一条语句所示),或在类型明确的变量赋值的右方出现无类型常量(如下面的其他三条语句所示),则常量会被隐式转换成该变量的类型。

1
2
3
4
var f f1oat64 = 3 + 0i  // 无类型复数3 + 0i -> float64
f = 2   // 无类型整数2 -> f1oat64
f = 1e123   // 无类型浮点数1e123->f1oat64
f = 'a' // 无类型'a'->f1oat64

上述语句与下面的语句等价:

1
2
3
4
var f float64 = float64(3 + 0i)
f = float64(2)
f = float64(1e123)
f = float64('a')

不论隐式或显式,常量从一种类型转换成另一种,都要求目标类型能够表示原值。实数和复数允许舍入取整:

1
2
3
4
5
6
7
8
9
const (
    deadbeef = 0xdeadbeef   // 无类型整数,值为3735928559
    a = uint32(deadbeef)    // uint32,值为3735928559
    b = f1oat32(deadbeef)   // f1oat32,值为3735928576(向上取整)
    c = f1oat64(deadbeef)   // f1oat64,值为3735928559(精确值)
    d = int32(deadbeef)     // 编译错误:溢出,int32无法容纳该常量值
    e = f1oat64(1e309)      // 编译错误:溢出,f1oat64无法容纳该常量值
    f = uint(-1)            // 编译错误:溢出,uint无法容纳该常量值,32位,uint只能到8位
)

变量声明(包括短变量声明)中,假如没有显式指定类型,无类型常量会隐式转换成该变量的默认类型,如下例所示:

1
2
3
4
i := 0  // 无类型整数;隐式int(0)
r := '\000' // 无类型文字字符;隐式rune('\000'),码点类型
f := 0.0    // 无类型浮点数;隐式f1oat64(0.0)
c := 0i     // 无类型整数;隐式comp1ex128(0i)

注意各类型的不对称性:无类型整数可以转换成int,其大小不确定,但无类型浮点数和无类型复数被转换成大小明确的f1oat64complex128。Go语言中,只有大小不明确的int类型,却不存在大小不确定的f1oat类型和complex类型,原因是,如果浮点型数据的大小不明,就很难写出正确的数值算法。

要将变量转换成不同的类型,我们必须将无类型常量显式转换为期望的类型,或在声明变量时指明想要的类型,如下例所示:

1
2
var i = int8(0)
var i int8 =0

在将无类型常量转换为接口值时(见第7章),这些默认类型就分外重要,因为它们决定了接口值的动态类型。

1
2
3
4
fmt.Printf("%T\n", 0)       // "int"
fmt.Printf("%T\n", 0.0)     // "float64"
fmt.Printf("%T\n", 0i)      // "complex128"
fmt.Printf("%T\n", '\000')  // "int32"(rune)

至此,Go的基本数据类型介绍完毕。

本文由作者按照 CC BY 4.0 进行授权

Go语言介绍

Go的复合数据类型