Skip to content

高级数据类型1

数组类型

一个数组(Array)就是一个可以容纳若干类型相同的元素的容器。这个容器的大小(即数组的长度)是固定的,且是体现在数组的类型字面量之中的。比如,我们声明了一个数组类型:

type MyNumbers [3]int
注:类型声明语句由关键字type、类型名称和类型字面量组成。

所谓类型字面量,就是用于表示某个类型的字面表示(或称标记方法)。相对的,用于表示某个类型的值的字面表示可被称为值字面量,或简称为字面量。比如之前提到过的3.7E-2就可被称为浮点数字面量。 类型字面量[3]int由两部分组成。第一部分是由方括号包裹的数组长度,即[3]。这也意味着,一个数组的长度是该数组的类型的组成部分,是固定不变的。该类型字面量的第二个组成部分是int。它代表了该数组可以容纳的元素的类型。说到这里,上面这条类型声明语句实际上是为数组类型[3]int声明了一个别名类型。这使得我们可以把MyNumbers当做数组类型[3]int来使用。

我们表示这样一个数组类型的值的时候,应该把该类型的类型字面量写在最左边,然后用花括号包裹该值包含的若干元素。各元素之间以(英文半角)逗号分隔,即:

[3]int{1, 2, 3}

现在,我们把这个数组字面量赋给一个名为numbers的变量:

var numbers = [3]int{1, 2, 3}
注:这是一条变量声明语句。它在声明变量的同时为该变量赋值。

另一种便捷方法是,在其中的类型字面量中省略代表其长度的数字,像这样:

var numbers = [...]int{1, 2, 3}

这样就可以免去我们为填入那个数字而数出元素个数的工作了。

接下来,我们可以很方便地使用索引表达式来访问该变量的值中的任何一个元素,例如:

1
2
3
numbers[0] // 会得到第一个元素
numbers[1] // 会得到第二个元素
numbers[2] // 会得到第三个元素

注:索引表达式由字符串、数组、切片或字典类型的值(或者代表此类值的变量或常量)和由方括号包裹的索引值组成。在这里,索引值的有效范围是[0, 3)。也就是说,对于数组来说,索引值既不能小于0也不能大于或等于数组值的长度。另外要注意,索引值的最小有效值总是0,而不是1。

相对的,如果我们想修改数组值中的某一个元素值,那么可以使用赋值语句直接达到目的。例如,我们要修改numbers中的第二个元素的话,如此即可:

numbers[1] = 4

虽然数组的长度已经体现在了它的类型字面量,但是我们在很多时候仍然需要明确的获得它,像这样:

var length = len(numbers)

注:len是Go语言的内建函数的名称。该函数用于获取字符串、数组、切片、字典或通道类型的值的长度。我们可以在Go语言源码文件中直接使用它。

最后,要注意,如果我们只声明一个数组类型的变量而不为它赋值,那么该变量的值将会是指定长度的、其中各元素均为元素类型的零值(或称默认值)的数组值。例如,若有这样一个变量:

var numbers2 [5]int
则它的值会是
[5]int{0, 0, 0, 0, 0}

范例:

package main

import "fmt"

func main() {
    var numbers2 [5]int
    numbers2[0] = 2
    numbers2[3] = numbers2[0] - 3
    numbers2[1] = numbers2[2] + 5
    numbers2[4] = len(numbers2)
    sum := (11)
    // “==”用于两个值的相等性判断
    fmt.Printf("%v\n", (sum == numbers2[0]+numbers2[1]+numbers2[2]+numbers2[3]+numbers2[4]))
}

切片类型

切片(Slice)与数组一样,也是可以容纳若干类型相同的元素的容器。与数组不同的是,无法通过切片类型来确定其值的长度。每个切片值都会将数组作为其底层数据结构。我们也把这样的数组称为切片的底层数组。 表示切片类型的字面量如:

[]int
[]string 

可以看到,它们与数组的类型字面量的唯一不同是不包含代表其长度的信息。因此,不同长度的切片值是有可能属于同一个类型的。相对的,不同长度的数组值必定属于不同类型。对一个切片类型的声明可以这样:

type MySlice []int

这时,类型MySlice即为切片类型[]int的一个别名类型。除此之外,对切片值的表示也与数组值也极其相似,如:

[]int{1, 2, 3}  

这样的字面量与数组(值)的字面量的区别也只在于最左侧的类型字面量。

我们在上一节讲到的操作数组值的方法也同样适用于切片值。不过,还有一种操作数组值的方法我们没讲到。这种操作的名称就叫“切片”。实施切片操作的方式就是切片表达式。举例如下:

var numbers3 = [5]int{1, 2, 3, 4, 5}
var slice1 = numbers3[1:4]

请注意第二条赋值语句中在“=”右边那个部分。切片表达式一般由字符串、数组或切片的值以及由方括号包裹且由英文冒号“:”分隔的两个正整数组成。这两个正整数分别表示元素下界索引和元素上界索引。在本例中,切片表达式numbers3[1:4]的求值结果为[]int{2, 3, 4}。可见,切片表达式的求值结果相当于以元素下界索引和元素上界索引作为依据从被操作对象上“切下”而形成的新值。注意,被“切下”的部分不包含元素上界索引指向的元素。另外,切片表达式的求值结果会是切片类型的,且其元素类型与被“切片”的值的元素类型一致。实际上,slice1这个切片值的底层数组正是numbers3的值。

实际上,我们也可以在一个切片值上实施切片操作。操作的方式与上述无异。请看下面这个例子:

var slice2 = slice1[1:3] 

据此,slice2的值为[]int{3, 4}。注意,作为切片表达式求值结果的切片值的长度总是为元素上界索引与元素下界索引的差值。

除了长度,切片值以及数组值还有另外一个属性——容量。数组值的容量总是等于其长度。而切片值的容量则往往与其长度不同。请看下图。

Image title

图解

如图所示,一个切片值的容量即为它的第一个元素值在其底层数组中的索引值与该数组长度的差值的绝对值。为了获取数组、切片或通道类型的值的容量,我们可以使用内建函数cap,如:

var capacity2 int = cap(slice2) 
最后,要注意,切片类型属于引用类型。它的零值即为nil,即空值。如果我们只声明一个切片类型的变量而不为它赋值,那么该变量的值将会是nil。例如,若有这样一个变量:
var slice3 []int
则它的值会是
nil

范例:使程序打印到标准输出上的内容为true, true

package main

import "fmt"

func main() {
    var numbers3 = [5]int{1, 2, 3, 4, 5}
    slice3 := numbers3[2 : len(numbers3)]
    length := 3
    capacity := 3
    fmt.Printf("%v, %v\n", (length == len(slice3)), (capacity == cap(slice3)))
}

切片的更多操作方法

我们已经知道,在进行“切片”操作的时候需要指定元素下界索引和元素上界索引,就像这样:

numbers3[1:4]

在有些时候,我们还可以在方括号中放入第三个正整数,如下所示:

numbers3[1:4:4] 

这第三个正整数被称为容量上界索引。它的意义在于可以把作为结果的切片值的容量设置得更小。换句话说,它可以限制我们通过这个切片值对其底层数组中的更多元素的访问。下面举个例子。让我们先来回顾下在上一节讲到的numbers3slice1。针对它们的赋值语句是这样的:

var numbers3 = [5]int{1, 2, 3, 4, 5}
var slice1 = numbers3[1:4] 

这时,变量slice1的值是[]int{2, 3, 4}。但是我们可以通过如下操作将其长度延展得与其容量相同:

slice1 = slice1[:cap(slice1)]   

通过此操作,变量slice1的值变为了[]int{2, 3, 4, 5},且其长度和容量均为4。现在,numbers3的值中的索引值在[1,5]范围内的元素都被体现在了slice1的值中。这是以numbers3的值是slice1的值的底层数组为前提的。这意味着,我们可以轻而易举地通过切片值访问其底层数组中对应索引值更大的更多元素。如果我们编写的函数返回了这样一个切片值,那么得到它的程序很可能会通过这种技巧访问到本不应该暴露给它的元素。这是确确实实是一个安全隐患。

如果我们在切片表达式中加入了第三个索引(即容量上界索引),如:

var slice1 = numbers3[1:4:4] 

那么在这之后,无论我们怎样做都无法通过slice1访问到numbers3的值中的第五个元素。因为这超出了我们刚刚设定的slice1的容量。如果我们指定的元素上界索引或容量上界索引超出了被操作对象的容量,那么就会引发一个运行时恐慌(程序异常的一种),而不会有求值结果返回。因此,这是一个有力的访问控制手段。

虽然切片值在上述方面受到了其容量的限制,但是我们却可以通过另外一种手段对其进行不受任何限制地扩展。这需要使用到内建函数appendappend会对切片值进行扩展并返回一个新的切片值。使用方法如下:

slice1 = append(slice1, 6, 7)

通过上述操作,slice1的值变为了[]int{2, 3, 4, 6, 7}。注意,一旦扩展操作超出了被操作的切片值的容量,那么该切片的底层数组就会被自动更换。这也使得通过设定容量上界索引来对其底层数组进行访问控制的方法更加严谨了。

我们要介绍的最后一种操作切片值的方法是“复制”。该操作的实施方法是调用copy函数。该函数接受两个类型相同的切片值作为参数,并会把第二个参数值中的元素复制到第一个参数值中的相应位置(索引值相同)上。这里有两点需要注意:

  1. 这种复制遵循最小复制原则,即:被复制的元素的个数总是等于长度较短的那个参数值的长度。
  2. 与append函数不同,copy函数会直接对其第一个参数值进行修改。

举例如下:

var slice4 = []int{0, 0, 0, 0, 0, 0, 0}
copy(slice4, slice1)   

通过上述复制操作,slice4会变为

[]int{2, 3, 4, 6, 7, 0, 0}
范例:打印到标准输出上的内容
func main() {
    number := [...]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    slice1 := number[4:6:8]
    length := 2
    capacity := 4
    fmt.Println("长度分别是:", len(slice1), cap(slice1))
    // 长度分别是: 2 4

    fmt.Printf("%v, %v\n", length == len(slice1), capacity == cap(slice1))
    // true, true

    slice1 = slice1[:cap(slice1)]
    fmt.Println("slice1", slice1)
    // slice1 [5 6 7 8]

    slice1 = append(slice1, 11, 12, 13)
    fmt.Println("slice1", slice1)
    // slice1 [5 6 7 8 11 12 13]

    length_num := 7

    fmt.Printf("%v\n", length_num == len(slice1))
    // true

    slice2 := []int{0, 0, 0}
    copy(slice1, slice2)
    fmt.Println("slice1:", slice1, "slice2:", slice2)
    // slice1: [0 0 0 8 11 12 13] slice2: [0 0 0]

    a1 := 0
    a2 := 8
    a3 := 11
    fmt.Printf("%v, %v, %v\n", a1 == slice1[2], a2 == slice1[3], a3 == slice1[4])
    // true, true, true
}

字典类型

Go语言的字典(Map)类型其实是哈希表(Hash Table)的一个实现。字典用于存储键-元素对(更通俗的说法是键-值对)的无序集合。注意,同一个字典中的每个键都是唯一的。如果我们在向字典中放入一个键值对的时候其中已经有相同的键的话,那么与此键关联的那个值会被新值替换。

字典类型的字面量如下:

map[K]T

其中,“K”意为键的类型,而“T”则代表元素(或称值)的类型。如果我们要描述一个键类型为int、值类型为string的字典类型的话,应该这样写:

map[int]string

请注意,字典的键类型必须是可比较的,否则会引起错误。也就是说,它不能是切片、字典或函数类型。

字典值的字面量表示法实际上与数组和切片的字面量表示法很相似。首先,最左边仍然是类型字面量,右边紧挨着由花括号包裹且有英文逗号分隔的键值对。每个键值对的键和值之间由英文冒号分隔。以字典类型map[int]string为例,它的值的字面量可以是这样的:

map[int]string{1: "a", 2: "b", 3: "c"}

我们可以把这个值赋给一个变量:

mm := map[int]string{1: "a", 2: "b", 3: "c"}

然后运用索引表达式取出字典中的值,就像这样:

b := mm[2] 

注意,在这里,我们放入方括号中的不再是索引值(实际上,字典中的键值对也没有索引),而是与我们要取出的值对应的那个键。在上例中变量b的值必是字符串"b"。当然,也可以利用索引表达式来赋值,比如这样:

mm[2] = b + "2"

这使得字典mm中与键2对应的值变为了"b2"。现在我们再来向mm添加一个键值对:

mm[4] = ""

之后,在从中取出与45对应的值:

d := mm[4]
e := mm[5]

此时,变量de的值都会是多少呢?答案是都为"",即空字符串。对于变量d来说,由于在字典mm中与4对应的值就是"",所以索引表达式mm[4]的求值结果必为""。这理所应当。但是mm[5]的求值结果为什么也是空字符串呢?原因是,在Go语言中有这样一项规定,即:对于字典值来说,如果其中不存在索引表达式欲取出的键值对,那么就以它的值类型的空值(或称默认值)作为该索引表达式的求值结果。由于字符串类型的空值为"",所以mm[5]的求值结果即为""

在不知道mm的确切值的情况下,我们无法得知mm[5]的求值结果意味着什么?它意味着5对应的值就是一个空字符串?还是说mm中根本就没有键为5的键值对?这无所判别。为了解决这个问题,Go语言为我们提供了另外一个写法,即:

e, ok := mm[5]

针对字典的索引表达式可以有两个求值结果。第二个求值结果是bool类型的。它用于表明字典值中是否存在指定的键值对。在上例中,变量ok必为false。因为mm中不存在以5为键的键值对。

从字典中删除键值对的方法非常简单,仅仅是调用内建函数delete而已,就像这样:

delete(mm, 4)   

无论mm中是否存在以4为键的键值对,delete都会“无声”地执行完毕。我们用“有则删除,无则不做”可以很好地概括它的行为。

最后,与切片类型相同,字典类型属于引用类型。它的零值即为nil

范例:打印到标准输出上的内容为:25, 50, 0

package main

import "fmt"

func main() {
    mm2 := map[string]int{"golang": 42, "java": 1, "python": 8}
    mm2["scala"] = 25
    mm2["erlang"] = 50
    delete(mm2, "python") 
    fmt.Printf("%d, %d, %d \n", mm2["scala"], mm2["erlang"], mm2["python"])
}

通道类型

通道(Channel)是Go语言中一种非常独特的数据结构。它可用于在不同Goroutine之间传递类型化的数据,并且是并发安全的。相比之下,我们之前介绍的那些数据类型都不是并发安全的。这一点需要特别注意。

Goroutine(也称为Go程序)可以被看做是承载可被并发执行的代码块的载体。它们由Go语言的运行时系统调度,并依托操作系统线程(又称内核线程)来并发地执行其中的代码块。至于怎样编写这样的代码块以及怎样驱动这样的代码块执行,我们先按下不表。

通道类型的表示方法很简单,仅由两部分组成,如下:

chan T

在这个类型字面量中,左边是代表通道类型的关键字chan,而右边则是一个可变的部分,即代表该通道类型允许传递的数据的类型(或称通道的元素类型)。这两部分之间需要以空格分隔。

与其它的数据类型不同,我们无法表示一个通道类型的值。因此,我们也无法用字面量来为通道类型的变量赋值。我们只能通过调用内建函数make来达到目的。make函数可接受两个参数。第一个参数是代表了将被初始化的值的类型的字面量(比如chan int),而第二个参数则是值的长度。例如,若我们想要初始化一个长度为5且元素类型为int的通道值,则需要这样写:

make(chan int, 5)

顺便说一句,实际上make函数也可以被用来初始化切片类型或字典类型的值。

确切地说,通道值的长度应该被称为其缓存的尺寸。换句话说,它代表着通道值中可以暂存的数据的个数。注意,暂存在通道值中的数据是先进先出的,即:越早被放入(或称发送)到通道值的数据会越先被取出(或称接收)。

下面,我们声明一个通道类型的变量,并为其赋值:

ch1 := make(chan string, 5)

这样一来,我们就可以使用接收操作符<-向通道值发送数据了。当然,也可以使用它从通道值接收数据。例如,如果我们要向通道ch1发送字符串"value1",那么应该这样做:

ch1 <- "value1"

另一方面,我们若想从ch1那里接收字符串,则要这样:

<- ch1

这时,我们可以直接把接收到的字符串赋给一个变量,如:

value := <- ch1

与针对字典值的索引表达式一样,针对通道值的接收操作也可以有第二个结果值。请看下面的示例:

value, ok := <- ch1

这样做的目的同样是为了消除与零值有关的歧义。这里的变量ok的值同样是bool类型的。它代表了通道值的状态,true代表通道值有效,而false则代表通道值已无效(或称已关闭)。更深层次的原因是,如果在接收操作进行之前或过程中通道值被关闭了,则接收操作会立即结束并返回一个该通道值的元素类型的零值。按照上面的第一种写法,我们无从判断接收到零值的原因是什么。不过,有了第二个结果值之后,这种判断就好做了。

说到关闭通道值,我们可以通过调用内建函数close来达到目的,就像这样:

close(ch1)

Danger

请注意
1. 对通道值的重复关闭会引发panic,会使程序崩溃。所以一定要避免这种情况的发生。
2. 在通道值有效的前提下,针对它的发送操作会在通道值已满(其中缓存的数据的个数已等于通道的长度)时被阻塞。而向一个已被关闭的通道值发送数据会引发panic
3. 针对有效通道值的接收操作会在它已空(其中没有缓存任何数据)时会阻塞。除此之外,还有几条与通道的发送和接收操作有关的规则。不过在这里我们记住上面这三条就可以了。

最后,与切片和字典类型相同,通道类型属于引用类型。它的零值即为nil

范例:使程序打印到标准输出上的内容为“数据已到达!”

package main

import "fmt"

func main() {
    ch2 := make(chan string, 1)
    // 下面就是传说中的通过启用一个Goroutine来并发的执行代码块的方法。
    // 关键字 go 后跟的就是需要被并发执行的代码块,它由一个匿名函数代表。
    // 对于 go 关键字以及函数编写方法,我们后面再做专门介绍。
    // 在这里,我们只要知道在花括号中的就是将要被并发执行的代码就可以了。
    go func() {
        ch2 <- "已达到!"
    }()
    var value string = "数据"
    value = value + <-ch2
    fmt.Println(value)
}

通道的更多种类

我们在上一节所说的通道,实际上只是Go语言中的通道的一种。它被称为带缓冲的通道,或简称为缓冲通道。

通道有带缓冲和非缓冲之分。我们已经说过,缓冲通道中可以缓存N个数据。我们在初始化一个通道值的时候必须指定这个N。相对的,非缓冲通道不会缓存任何数据。发送方在向通道值发送数据的时候会立即被阻塞,直到有某一个接收方已从该通道值中接收了这条数据。非缓冲的通道值的初始化方法如下:

make(chan int, 0)

注意,在这里,给予make函数的第二个参数值是0

除了上述分类方法,我们还可以以数据在通道中的传输方向为依据来划分通道。默认情况下,通道都是双向的,即双向通道。如果数据只能在通道中单向传输,那么该通道就被称作单向通道。我们在初始化一个通道值的时候不能指定它为单向。但是,在编写类型声明的时候,我们却是可以这样做的。例如:

type Receiver <-chan int

类型Receiver代表了一个只可从中接收数据的单向通道类型。这样的通道也被称为接收通道。在关键字chan左边的接收操作符<-形象地表示出了数据的流向。相对应的,如果我们想声明一个发送通道类型,那么应该这样:

type Sender chan<- int

这次<-被放在了chan的右边,并且“箭头”直指“通道”。想必不用多说你也能明白了。我们可以把一个双向通道值赋予上述类型的变量,就像这样:

1
2
3
var myChannel = make(chan int, 3)
var sender Sender = myChannel
var receiver Receiver = myChannel

但是,反之则是不行的。像下面这样的代码是通不过编译的:

var myChannel1 chan int = sender

单向通道的主要作用是约束程序对通道值的使用方式。比如,我们调用一个函数时给予它一个发送通道作为参数,以此来约束它只能向该通道发送数据。又比如,一个函数将一个接收通道作为结果返回,以此来约束调用该函数的代码只能从这个通道中接收数据。这属于API设计的范畴。因此我们在这里仅了解一下即可。

范例:使程序打印到标准输出上的内容为:Received! 6 Sent!

package main

import (
    "fmt"
    "time"
)

type Sender chan<- int

type Receiver <-chan int

func main() {
    var myChannel = make(chan int, 0)
    var number = 6
    go func() {
        var sender Sender = myChannel
        sender <- number
        fmt.Println("Sent!")
    }()
    go func() {
        var receiver Receiver = myChannel
        fmt.Println("Received!", <-receiver)
    }()
    // 让main函数执行结束的时间延迟1秒,
    // 以使上面两个代码块有机会被执行。
    time.Sleep(time.Second)
}