Collection Types
本章的第一节讲解了Go values, pointers, and reference类型,这些知识是本章以及后续章节的基础。Go's pointer跟c与c++的指针在语法和语义上相同,除了Go's pointer不支持运算,因此也避免了指针在c与c++中的潜在bug. 由于Go支持垃圾回收以及内存自动管理,所以不需要使用free()或者delete,释放内存。
本章的其它章节专注于讲解Go's内置collection types. 所有的内置类型包括——array, slice和maps. 在标准库中还提供了一些其它的集合类型。比如 container/heap(堆) container/list(列表) container/ring(环)。
Values, Pointers, and Reference Types
在本章节中,我们将讨论变量是什么(values, pointers, and references——including array values and slice and more reference). 因此在之后的章节中,我们将解释如何在实际中使用arrays, slices, and maps.
值传递给functions 或者方法,都是通过复制的形式。比如Boolean, Number, String. 由于Boolean, Number 仅占1-8个字节,所以复制它们的开销非常低,而String在经过Go编译器优化化,也只是传递很少部份的数据,所以也没有多大的开销,而不管字符串有多大, Go string的大小都是固定的(64位机器上是16 bytes, 32位机器上是8 bytes).
不像c 或 c++, GO array也是通过值来传递。 所以如果传递一个非常大的数组,将是非常昂贵的。 幸运的是Go编程中很少使用到Array, 而是用slices替换。传递一个slices的开销跟传递一个string差不多(比如 64位机器上是16 bytes, 32位机器上是12位). 而不管slice的 length 或者 capacity. 对于slice的修改,不会像 string (+=)那样,在写的时候进行复制。所以不会产生写的开销。(slice是引用传递,对一个变量的修改,会影响到所有的引用)
图4.1说明了变量与内存的关系
Statement | Variable | Value | Type | Memory Address |
y := 1.5 | y | 1.5 | float64 | 0xf8400000f8 |
y++ | y | 2.5 | float64 | 0xf8400000f8 |
z := math.Ceil(y) | y | 2.5 | float64 | 0xf8400000f8 |
x | 2.5 | float64 | Modifiable copy of y in Ceil() | |
Z | 3.0 | float64 | 0xf8400000c0 |
理论上讲,Go会为保存float64(i.e., 8 bytes)设置足够的内存,并且将1.5 value存放到内存中。从这点上来讲,当y在此作用域下——Go对变量y的处理跟保存float64的内存地址是相同的。 所以当我们使用y++时, Go将增加y的相关内存的中的值。可是,当我们将y传递给函数或者方法。 Go将复制y,并传递给函数或者方法。换句语说,Go将创建一个新的变量(相应的参数名), 并且将y的值复制到新变量。
有时我们想要一个函数修改我们传递给它的变量。对于引用类型来说可以直接修修,但对于value types,它们都是通过复制进行传递。任何的修改只是对copy value有效,而对于原始值来说,没有任何修改。同时,有些值的传递是非常昂贵的,因为它们非常大(e.g., array or struct). 幸运的是,如果本地变量不在被使用,它将会垃圾回收器回收(e.g., 当它们不在被引用,并且出了变量的作用域范围). 然而在很多情况下,我们想要创建的变量的生命周期应当由于我们决定,而不是它们的闭包作用域。
通过指针,我们可以使参数在传递时不用复制(更低的开销), 同时参数可以任意修改, 而且指针变量的生命周期不依赖于调用函数的作用域。指针,是一个变量,持有另一个变量的内存地址。 指针被创建时,需要指定具体的类型——这可以让Go确定,指针指向的值占据多少内存。一个变量被指针指向,则可以通过指针修改变量值。 指针的传递是廉价的(64位的机器占用8 bytes, 32位的机器占用4 bytes). 这跟它指向的值的大小无关。被指向的变量,只要被一个指针指向,则一直会存在于内存中。 所以它们的生命周期不依赖于创建时的作用域。
在Go中, &操作符被重载。 当它用于位操作符时,则是按位与。 但它用于一元操作符(unary operator), 则返回内存的地址。 图4.2, 第三条语句,我们将变量int类型的变量x的地址赋值给变量pi, pi的类型是*int(表示指针指向一个int). 一元符&有时称为地址操作符。 指针的专业术语,可以说一个变量持有另一个变量的地址,可以认为指向另一个变量。
* 操作符也被重载,二元时就是乘号。 当用于一元操作时,用于获得指向变量的值。所以在图4.2中,在pi := &x之后(但不能是pi指向到其它变量之后), *pi 和 x 在使用时可以互换使用. 由于它们与相同的int类型的内存地址有关,所以任何一方的改变都会影响到任一方。有时一元操作符*被称为内容操作符或才间接运算符或者取值运算符(dereference operator).
一个指针不会一直指向相同的值。举例来说,在图4.2的下面部分,我们将指针指向了不同的值(pi :=&y).
Go也可以使用指针到指针(也可以是pointer to pointer to pointer). 使用一个指针来引用一个值,我们称为间接寻址(indirection). 而如果我们使用一个指针指向到另一指针,我们需要使用多级间接寻址, 来获得对应的值(**ppi). 指针到指针的使用在c与c++很常见。但在Go中并不是经常使用,因为Go使用的是引用类型。
z := 37 // z is of type int
pi := &z // pi is of type *int (pointer to int)
ppi := &pi // ppi is of type **int (pointer to pointer to int)
fmt.Println(z, *pi, **ppi)
**ppi++ // Semantically the same as: (*(*ppi))++ and *(*ppi)++
fmt.Println(z, *pi, **ppi)
/*
37 37 37
38 38 38
*/
在这段代码里,pi为*int指针类型(指向int), 指向int类型的z. ppi是**int类型(pointer to pointer to int). 当进行取值时,我们对每一级间接寻址(indirection)前加上*. 所以ppi取得ppi的值为int,这是一个内存地址。 在加一个*(**ppi), 我们将获得int类型的值。
除了当作乘号和取值操作符, * 操作符还可以被重载为第三种——类型修饰符。当一个 * 类型名的左边(*int).
让我们看一个小的例子。
i := 9
j := 5
product := 0
swapAndProduct1(&i, &j, &product)
fmt.Println(i, j, product)
//5 9 45
func swapAndProduct1(x, y, product *int) {
if *x > *y {
*x, *y = *y, *x
}
*product = *x * *y // The compiler would be happy with: *product=*x**y
}
我们在这里创建了三个int类型的变量,并且给定了初始值。然后我们调用了自定义的方法swapAndProduct1()函数,它接受3个int类型的指针。此函数将对前两个参数进行升序排列,并且设置第三个参数的值为前两个参数的乘积。 由于函数的参数是指针,而不是值。 所以我们只能传递地址,而不是变量本身。 所以每当我们当到在调用函数时使用了 & 地址操作符,我们应当假设函数内部可能会修改这个变量的值。
swapAndProduct1参数申明中使用了 *类型修饰符。 表示所有的参数指向整型。因此我们只能传递整型变量的地址给这个函数,而不能是整型变量本身或者数字(literal integer values).
在函数内,我们只关心指针指向的整型值。 所以我们必须使用 *操作符取得它们的值。 在最后一行,我们将乘以两个指针的值,并赋值给另一个指针的值。 Go可以根据上下文区分开,这是取值还是相乘。
在C和C++两的版本中,经常会这样写一个函数。 但在Go中去很少发现。如果我们只有一个或一些值时,Go惯用的是返回它们。 如果是大量的值时,则通过slice或者map传递(传递很廉价,又可以不使用指针). 如果是多个不同类型的值,则可以使用struct 指针。以下是没有使用到指针的函数.
i := 9
j := 5
i, j, product := swapAndProduct2(i, j)
fmt.Println(i, j, product)
//5 9 45
func swapAndProduct2(x, y int) (int, int, int) {
if x > y {
x, y = y, x
}
return x, y, x * y
}
swapAndProduct2相对于swapAndProduct1来说更清晰一些。但因为没有使用指针,它的缺点是不能就地交换两个值,而是返回,并赋给原来的变量。
在C和C++中, 经常会看到函数可以接收一个Boolean的指针。 用来标识成功或者失败。 在Go中也可以直接使用*bool完成。 但更方便的是在返回值的最好, 返回一个Boolean类型的标志(或者最好返回一个 error值),这在Go中是一种标准的做法。
在迄今为此所示的代码中, 我们已经使用&取址操作符,取得函数参数或者本地变量的地址。 而由于Go是自动管理内存, 因此只要有一个指针引用了一个变量,则它一直保存在内存中,所以这也是为什么,在Go中可以安全的返回一个指向定义在函数内的局部变量的指针(在C与C++中,是不能返回一个指针,它指向到局部变量,因为局部变量会在出了C函数后会被释放, 此时指针指向的是一个未知).
在需要修改非引用类型的值或者更加高效的传递一个大类型时,我们需要使用到指针。 Go提供了两种语法来创建指针。 一种是使用内置的new()函数。另一种是使用取址操作符(&). 我们可以对比一下两种语法。
type composer struct {
name string
birthYear int
}
下面的代面,我们创建了composer类型的值和指向到composer values的指针(变量类型 *composer). 两者都利用了{}来初始化struct.
antónio := composer{"António Teixeira", 1707} // composer value
agnes := new(composer) // pointer to composer
agnes.name, agnes.birthYear = "Agnes Zimmermann", 1845
julia := &composer{} // pointer to composer
julia.name, julia.birthYear = "Julia Ward Howe", 1819
augusta := &composer{"Augusta Holmès", 1847} // pointer to composer
fmt.Println(antónio)
fmt.Println(agnes, augusta, julia)
/*
{António Teixeira 1707}
&{Agnes Zimmermann 1845} &{Augusta Holmès 1847} &{Julia Ward Howe 1819}
*/
当Go打印出指向到structs的指针。 它们将打印出取值后的struct, 但会在前面加上&地址操作符,以表明这是一个指针。 agnes和julia指针的创建,两种方法的创建是等效的
以下两种语法都是创建一个给定类型的零值。 并返回一个指针,指向到这个零值。如果Type是一种不可以使用{}初始化的类型时,可以使用new()函数. 当然我们不用担心创建的这个值的生命同期或删除它,因为Go的内存管理系统会为我们负责这些事情。
使用&Type{}创建一个struct的优点在于,我们可以在初始时指定每个字段的值。 如我们在创建augusta变量(甚至可以只选择部分,而其它的则使用零值)。
除了values and pointers. Go还有引用类型(reference types). Go也有interface, 但从实用性上来说,我们可以认为interface是引用类型的一种. 将一个引用类型的值,赋值给变量, 则这个变量引用的是内存中的一个隐藏值,其存储实际数据. 引用类型的变量传递也很廉价(e.g., 在64位的机器上, slice的大小为16字节,map为8字节). 它的使用跟值类型的使用是一样的(即,我们不需要取得一个引用类型的地址或通过取值符来访问一个引用类型的值).
一但我们的函数或者访问需要返回4到5个值时,如果返回的值是同类型的最好使用slice, 如果是不同类型,则使用指向到struct的指针。 传递一个slice或者指向到struct的指针,都很廉价。 并且允许我们就地修改数据。 我们将通过一些小的例子进行更好的说明。
grades := []int{87, 55, 43, 71, 60, 43, 32, 19, 63}
inflate(grades, 3)
fmt.Println(grades)
//[261 165 129 213 180 129 96 57 189]
func inflate(numbers []int, factor int) {
for i := range numbers {
numbers[i] *= factor
}
}
在上面的例子中,我们对slice中的所有数字进行操作(乘以3). Maps and slices都是引用类型,对map或者slice元素的任何修改,不管是一直的修改还是传递给函数,在函数内部修改。都将影响到引用它们的变量。
grade slice被传递给inflate的numbers参数,不像传递值类型, 任何对numbers的修改,都会体现在grades. 因为它们都引用相同的底层slice.
由于我们需要直接修改slice的值, 我们使用了循环计算器(i), 依次访问每一项。 而没有使用for index, item ... range 循环, 因为它是获得每一个项的copy —— 导致的结果就是每次都是拷贝factor, 然后在弃用这个拷贝,而原始的slice没有发生变化,我们也可以使用 for循环(e.g., for i:=0; i < len(numbers); i++).
现在让我们想像一下,我们有一个rectangle类型,它保存了长方型的左上角和右下角的位置坐标x,y,以及填充的颜色。 我们可以用一个struct表示rectangle的数据
type rectangle struct {
x0, y0, x1, y1 int
fill color.RGBA
}
以下我们创建rectangle类型的value. 并打印,重置大小,再次打印。
rect := rectangle{4, 8, 20, 10, color.RGBA{0xFF, 0, 0, 0xFF}}
fmt.Println(rect)
resizeRect(&rect, 5, 5)
fmt.Println(rect)
/*
{4 8 20 10 {255 0 0 255}}
{4 8 25 15 {255 0 0 255}}
*/
我在传递给自定义函数resizeRect()的只是一个地址(64位的系统为8字节,而不用管struct的大小),而不是整个rectange(到少需要16字节保存单独的4 int).
func resizeRect(rect *rectangle, Δwidth, Δheight int) {
(*rect).x1 += Δwidth // Ugly explicit dereference
rect.y1 += Δheight // . automatically dereferences structs
}
这个函数的第一条语句使用了明确的取值,只是为了更好的让你理解这里发生了什么。 (*rect) 取得了指针指向的实现值。.x1 引用了rectangle的x1字段。 第二条语句是获得struct 值的常用方法——或者对于struct指针的取值——对于后者,它依赖于Go会为我们做好取值。这是因为在Go中 .(dot)选择操作符会自动对指向struct指针的进行取值。
Go中的引用类型有: maps, slices, channels, functions, and methods. 不同于指针, 引用类型没有特殊的语法,直接像值类型一样操作。也可能存在指向到引用类型的指针。但也只是对于slice有用。 如下
func main() {
i := []int{1, 2, 3}
addEntry(i)
fmt.Println(i)
}
func addEntry(a []int) {
a = append(a, 1, 2, 3, 4)
}
//[1, 2, 3]
如果只是传递一个引用到addEntry, 那么调用 append时只是复制了一个无用的本地,然后又弃用了,可以这么理解,addEntry中的a开始引用了i, 而调用了append后返回了新的slice. 并赋值给a. 而a等于新创建的slice. 而i没有发生变化。而如果这里没有调用append(),而是调用a[0] = 8,则才会影响到i. 所以传递给addEntry的时候,只能是一个*[]int的指针。
如果我们声明了一个变量来保存一个函数。 这个变量获得了这个函数的引用。 函数引用(变量)知道它们引用的函数的签名是什么。所以你不能将函数引用传递给一个函数,而这个函数引用的签名不符合函数要求的函数签名。比如strings.Map(mapping func(rune) rune, s string) string, 所以传递给Map的函数引用必须符合func(rune) rune.