对比学习 Go 和 Rust:集合类型

2022年6月22日 // Go Rust 编程

集合类型也叫容器类型,用于保存同类型元素的序列或集合。为了便于访问其中的元素,每个元素都关联着一个键值。这些键值可以是整数类型的索引值,或其他任意可比较类型。本文将讲述 Go 和 Rust 中最常用集合类型,包括数组、切片、向量(也称动态数组,Rust 独有)、映射。另外,字符串也属于集合类型,将另行讲解。Go 的通道也属于集合类型,本文也不讲解。

数组

数组是由相同类型的元素的集合所组成的数据结构,分配一块连续的内存来存储,利用元素的索引可以计算出该元素对应的存储地址。 关于数组,不必讲太多原理,这里先直接给出 Go 和 Rust 的一些数组定义。为了便于理解和编写,这里均用 _ 指代变量名称,并显式地给出变量的类型。

Go:

var _ [3]int                                            // 包含三个元素的数组,每个元素的值默认为 0
var _ [3]int = [3]int{}                                 // [3]int{} 字面量属于 [3]int 类型数组,所有元素为 0
var _ [3]int = *new([3]int)                             // 通过内建的 new 函数创建数组,该方法很少用
var _ [3]int = [3]int{1, 2, 3}                          // 包含三个元素的数组,给出了各个元素值
var _ [3]int = [3]int{1, 2}                             // 包含三个元素的数组,但只给出了前两个元素值
var _ [3]int = [3]int{0: 1, 2: 3, 1: 2}                 // 显式指定数组的 索引:值 对
var _ [3]int = [...]int{1, 2, 3}                        // 通过三个圆点 ... 让编译器自动计算元素个数
var _ [3]int = [...]int{2: 3}                           // 除了最后一个元素(索引为 2)的值是 3 外,其他元素都是 0
var _ [3]*int                                           // 数组的每个元素是一个指向整数值的指针,元素值默认为 nil
var _ *[3]int                                           // 定义了一个指向 [3]int 类型的数组的指针
var _ [2 * N]int                                        // 2 * N 必须是一个能转换为 int 的非负的常量表达式
var _ [2][2]int = [2][2]int{[2]int{1, 2}, [2]int{3, 4}} // [2]([2]int) ,相当于一个 2 维数组
var _ [2][2]int = [2][2]int{{1, 2}, {3, 4}}             // 以上二维数组值的简写形式
var _ [3][4][5]int                                      // [3]([4]([5]int)),该数组相当于一个 3 维数组
var _ [2]string = [2]string{"dog", "sheep"}             // 元素类型为 string 的数组

Rust:

let _: [i32; 3];                         // 包含三个元素的数组,每个元素处于未初始化状态
let _: [i32; 3] = [1, 2, 3];             // 包含三个元素的数组,给出了各个元素值
let _: [i32; 3] = [1; 3];                // 快速将数组中所有元素的值指定为 1
let _: [&i32; 3];                        // 数组的每个元素是一个指向整数值的指针
let _: &[i32; 3];                        // 定义了一个指向 [i32; 3] 类型数组的指针
let _: [i32; 2 * N];                     // 2 * N 是一个常量表达式,其中 N 必须是一个 usize 类型的常量
let _: [[i32; 2]; 2] = [[1, 2], [3, 4]]; // 相当于一个二维数组
let _: [[[i32; 5]; 4]; 3];               // 相当于一个三维数组
let _: [&str; 2] = ["dog", "sheep"];     // 元素类型为 &str 的数组

Go 和 Rust 数组的一些相同点

  • 数组的尺寸(元素数目)必须是固定的,各个元素的类型必须是相同的,各个元素的尺寸也必须是固定的。以上代码中最后的字符串数组中每个字符串的长度看似不等,但其实在两种语言中,字符串字面量实际上都是用结构体表示的,结构体中包含一个指向实际字符串的指针类型的字段,这样数组元素的尺寸还是固定的。
  • 数组长度是数组类型的一部分。
  • 两种语言中,数组是值而非引用,他们默认会被放在栈中(Go 有逃逸分析,数组值有时会逃逸到堆中),当进行变量间赋值、传递参数时,将复制数组的每个元素。当数组很大时,这种操作花费巨大,这时不应再使用数组,而使用其他数据结构。
  • 数组的元素类型、尺寸都是数据类型的一部分, [10]int[20]int[i32; 10][i32; 20])属于不同的类型。
  • 可以直接比较两个数组的大小,仅当两个数组的类型相同,各个元素值对应相等时,两个数组才相等。
  • 访问数组的元素,都使用 a[2] 这样的形式,对于二维数组则使用类似 a[2][1] 的形式。
  • 两种语言中的数组都是一维的,但可以通过组合的方式构建多维数组。
  • 如果在访问数组时索引越界,将不允许访问,并 panic。

不同点

  • Go 的数组类型以 [size]T 形式表示,而 Rust 以 [T; size] 形式表示。

  • Go 的多维数组的类型表示形式为 [size1][size2][size3]T,它等同于 [size1]([size2]([size3]T)),Rust 则为 [[[T; size3]; size2]; size1],Go 的书写要稍微方便一点。

  • Go 的数组复合字面量以 [size]T{a1, a2, a3}[...]T{a1, a2, a3} 的形式表示,而 Rust 以 [a1, a2, a3] 形式表示。正是因为 Go 支持无类型常量,因此在声明数组时,需要显式指定元素的类型。

  • 在 Go 中表示数组字面量时,可以用 index: value 的“索引: 值”对的形式显式指定各个索引对应的元素值,Rust 则不能。

  • Go 中,数组的索引为 int 类型,int 是最常用的整数类型,更便于参与各种计算,Rust 则为 usize 类型。

  • 要获得数组的长度,Go使用内建函数 len,即 len(a),Rust 中则使用类型的方法 len,即 a.len()

  • 若为给数组元素赋值,Go 的数组元素默认为该元素类型的零值,Rust 则处于未初始化状态。

  • Rust 可以通过 [value; size] 形式的数组字面量一次性将数组的所有元素值初始化为 value,Go 没有这种语法糖。

  • Go 可以表示形如 [3]int{} 的数组字面量,其所有元素为 0,Rust 表示数组字面量时必须给定所有元素值,但能通过 [0; 3] 实现同样的效果。

  • Go 的数组变量是可变的。Rust 的数组变量默认是不可变的,这意味着:变量不可重新赋值为其它数组,且数组的元素不可以修改。要使数组变量可变,需要声明时在变量名称前面加 mut 关键字。如:

    let mut arr = [1, 2, 3];
    

无论 Go 或是 Rust,数组都不够灵活,因此都不是常用的数据结构,Go 在表示数据序列时更倾向于使用切片,而 Rust 则可分别使用切片和向量。

切片

顾名思义,切片(slice)就是对某种东西切分(slicing)取其中的片段。这里要被切分的东西主要是数组,每个切片只能取数组中连续的一个片段。或者说,切片是对数组中部分数据的引用。Go 和 Rust 中都有名称为切片的数据结构,他们有一些共同点,但区别也较大。还是还是直接看例子。

Go:

// 先声明一个包含 10 个元素的数组 arr,用于后续的切分
var arr [10]int = [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}

// 通过切分数组创建切片,数组索引从 3 到 5(6-1)变为切片元素,所得切片的长度为 3(6-3),容量为 7(10-3)
var _ []int = arr[3:6]
var a []int = arr[3:6:cap(arr)]                   // 通过指定底层数组的索引显式限定切片容量为 10-3,所得切片与上面相同
var _ []int = arr[:6]                             // 如果从 0 开始,若起始索引为 0,则可以省略
var _ []int = arr[3:]                             // 若终止索引为数组长度,则可以省略
var _ []int = arr[:]                              // 起始索引和终止索引可以同时省略,则切片引用整个数组
var _ []int = arr[:][3:6]                         // 可以对切片 arr[:] 进行再切分
var _ []int = make([]int, 10, 30)                 // 为数组 [30]int 分配内存,并从中切分前 10 各元素
var _ []int = (*new([30]int))[0:10]               // 该切片与上面的切片等价
var _ []int = new([30]int)[0:10]                  // 利用自动解引用特征,上一行代码可以进一步简化此行
var _ []int = make([]int, 10)                     // 用 make 创建切片时没有指定容量,则容量等于长度,即为 10
var _ []int                                       // 声明切片但没有赋值,则其默认为零值 nil
var _ []int = nil                                 // 当然也可以显式地指定切片的值为 nil,但没必要这样
var _ []int = []int(nil)                          // 更绕了,该切片的值还是 nil,实际没必要这样做
var _ []int = []int{1, 2, 3}                      // 这是切片的复合字面量,和数组类似,但不指定长度
var _ []int = []int{}                             // 注意这是一个长度和容量都为 0 的空切片,它不等于 nil
var _ []int = []int{0: 1, 2: 3, 1: 2}             // 显式指定切片的 索引:值 对
var _ []int = []int{2: 3}                         // 除了最后一个元素(索引为 2)的值是 3 外,其他元素都是 0
var _ [][]int = [][]int{[]int{1, 2}, []int{3, 4}} // 二维切片字面量的表示方法与数组类似,但不指定长度
var _ [][]int = [][]int{{1, 2}, {3, 4}}           // 以上二维切片的简写形式
var _ []string = []string{"dog", "sheep"}         // 元素类型为 string 的切片

Rust:

// 先声明一个包含 10 个元素的数组 arr 和一个包含 10 个元素的向量 vec,用于后续的切分
let arr: [i32; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];

// 通过切分数组创建切片,数组索引从 3 到 5(6-1)变为切片元素,所得切片的长度为 3(6-3)
let _: &[i32] = &arr[3..6];
let _: &[i32] = &arr[..6]; // 如果从 0 开始,若起始索引为 0,则可以省略
let _: &[i32] = &arr[3..]; // 若终止索引为数组长度,则可以省略
let _: &[i32] = &arr[..]; // 起始索引和终止索引可以同时省略,则切片引用整个数组
let _: &[i32] = &arr; // 可以将对数组的借用强制转换为切片
let _: &[i32] = arr.as_slice(); // 所得切片与以上切片等价
let _: &[i32] = &arr[..][3..6]; // 可以对切片 &arr[..] 进行再切分
let _: &[i32]; //声明切片但没有赋值,则该切片处于未初始化状态,不可用
let _: &[i32] = &[1, 2, 3]; // 强制将一个数组转换为切片
let _: &[i32] = &[]; // 注意这是一个长度和容量都为 0 的空切片,它已经初始化
let _: &[i32] = &[1; 3]; // 快速将数组中所有元素的值指定为 1
let _: &[&[i32]] = &[&[1, 2], &[3, 4]]; // 二维切片
let _: &[&str] = &["dog", "sheep"]; // 元素类型为 &str 的切片
let _: &[i32] = &vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9][..]; // 也可以通过切分向量创建切片
let _: &[i32] = &vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9].as_slice(); // 所得切片与以上切片等价
let _: &[i32] = &Box::new([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])[..]; // 切分一个分配到堆上的数组
let _: &[i32] = &Box::new([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]).as_slice(); // 所得切片与以上切片等价

切片的类型、字面量的表示方法都与数组非常相似,大家可以通过阅读上面的代码来体会,在此不再赘述。下面来说说两种语言的切片的其他方面的异同点。

Go 和 Rust 切片的一些相同点

  • 在两种语言中,切片都是对底层数据(数组、向量)切分得到的东西(或者说是底层数据的部分引用、包装或视图),切片和被切分的变量共享底层数据。
  • 切片一种轻量级的数据结构,你可以把它想象成一个包含指向底层数组首位的指针加一个切片长度(或再加一个切片容量)构成的结构体,即所谓的切片头(slice header),当传递切片变量时,只传递该切片头,不会传递其底层的数组。

不同点

  • 在 Go 中,切片和数组类型表示的主要区别是将 [size]T 中的 size 去掉,即变为 []T 的形式。在 Rust 中,主要区别是在 [T; size] 的前面加一个 & 表示引用,同时可以省略(经常省略)方括号中的 ; size 部分,变为 &[T] 的形式。

  • 从切片的表示方式就可以看出,Go 中 []T 的形式更像是一种灵活的数组,它非常常用。而 Rust 中 &[T] 的形式则是借用,它的使用要遵循借用的规则,比如它是没有所有权的。或者说,Go 的切片是对整个数组的包装盒,像切片的长度、容量就属于包装盒的属性,通过调节切片长度就可以看到不同的数组元素;而 Rust 的切片则像是一个取景框,通过它可以从一列数组中临时框定几个连续的元素来使用(通过可变借用也可以编辑数组元素),这个框的长度是固定的,你不能调节它的长度来看到不同的元素。

  • 在切分数组时,Go 的切分表达式主要使用 array[starting_index:ending_index] 形式,而 Rust 的范围表达式主要使用 array[starting_index..ending_index] 形式,即将冒号 : 变为两个点 ..

  • 在 Go 中,默认可以对切片变量重新赋值,可以改变元素值,可以在不超过切片容量的前提下通过再切分改变其长度。Rust 中,要重新改变切片长度,只有先将该切片声明为可变的,然后通过再次对数组进行切分并赋值给切片变量来实现。

    var arr [10]int = [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    var sli []int = arr[3:6]
    sli = sli[:5] // 通过对切片本身进行再切分来改变其长度,这里的结束索引必须小于等于切片容量
    fmt.Println(sli)
    
    let arr: [i32; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
    let mut sli: &[i32] = &arr[3..6]; // 要能更改切片变量,必须将其声明为可变的
    println!("{:?}", sli);
    sli = &arr[3..];
    println!("{:?}", sli);
    
  • 上一条已经说过,可以改变 Go 切片的元素值。但在 Rust 中,只有当将切片声明为可变借用时,才能改变内部元素值。

    // 要能改变后续切片的值,必须将其底层数组 arr 声明为可变的
    let mut arr: [i32; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
    let sli: &mut [i32] = &mut arr[3..6]; // 切片 sli 是对数组 arr 的可变借用
    sli[1] = 0; // 改变切片元素的值
    println!("{:?}", sli);
    
  • 切片长度并不是切片类型的一部分,因此可以自由地将相同元素类型但不同长度的切片分别赋值给同一个变量。在 Go 中,指向数组的指针 *[size]T 和切片 []T 属于不同的类型,不能混用。在 Rust 中,对数组的引用 &[T; size) 和切片 &[T] 虽然不是相同的类型,但在一定程度上却可以混用。如下所示:

    let mut sli1: &[i32; 3] = &[1, 2, 3]; // 显式指定切片的类型为 &[i32; 3]
    let mut sli2: &[i32] = &[1, 2, 3]; // 显式指定切片的类型为 &[i32]
    let sli3 = &[1, 2, 3]; // 没有显式指定切片的类型,在使用时既可以是 &[i32; 3],也可以是 &[i32]
    // sli1 = sli2; // 不能编译
    sli2 = sli1; // 可以编译
    sli1 = sli3; // 可以编译
    sli2 = sli3; // 可以编译
    
  • Go 中的切片同时可查看长度和容量属性,即 len(slice)cap(slice)(Go 的数组也可查看容量)。Rust 中只可查看长度 slice.len(),不可查看容量。实际上,因为 Rust 切片的长度是不可变的,查看容量的意义也不大。

  • 在 Go 中,切片是不可比较的。Rust 中的切片实现了 std::cmp::Eqstd::cmp::Ord trait,因此可以像数组那样比较,即当两个切片的长度相等,其各元素的值对应相等时,这两个切片相等。如下所示:

    let arr1: [i32; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
    let arr2: [i32; 10] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
    let a: &[i32] = &arr1[3..6];
    let b: &[i32] = &arr2[3..6];
    assert_eq!(a, b);
    
  • 在 Go 中,字符串不是切片,但却支持与切片类似的切分操作,所切分出来的东西还是字符串,字符串字面量是不可改变的,因此其元素也是不可改变的。在 Rust 中,存在字符串切片这种叫法,并且字符串字面量本身的类型就是字符串切片 &str&str 本身就是一个不可变借用,因此字符串字面量是不可改变的,其元素当然也不可改变。同样,Rust 对于 String 类型的字符串,也可以借用为 &str 类型的字符串切片。总之,在用切片切分字符串方面,Go 和 Rust 还是非常近似的。

    var s1 string = "Hello, world!"
    var s2 string = s1[:5]
    fmt.Println(s2)
    
    let s1: &str = "Hello, world!";
    let s2: &str = &s1[..5];
    println!("{}", s2);
    let s3: String = String::from("Hello, world!");
    let s2: &str = &s3[..5];
    println!("{}", s2);
    
  • 在 Rust 中,还可以生成堆上数组的切片,如向量、 Box<T> 类型的数组(其中 T 为数组类型),Go 中没有这类智能指针。

  • 在对切片的操作方面,Go 提供了 appendcopy 这两个内建函数,分别可以实现切片的追加和复制。Rust 的切片类型则具有更多的方法可供使用。

    Go 中的 appendcopy 函数的签名为:

    func append(dst []T, elements ...T) []T
    func copy(dst, src []T) int
    

    其中 append 函数将变长参数 elements 的各个值追加到切片 dst 后面,并返回新生成的切片。当原来 dst 的容量能容纳其原内容和所有 elements 元素时,将仍使用 dst 原来对应的底层数组;否则将新生成一个底层数组;正是因为可能新生成一个底层数组,所以才需要重新返回切片。示例如下:

    func main() {
        arr := [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
        s1 := arr[1:4]
        s2 := arr[:]
        s3 := append(s1, s2...) // s2 后面的三个点,告诉编译器将 s2 作为一个参量列表对待
        fmt.Println(arr)        // [0 1 2 3 4 5 6 7 8 9]
        fmt.Println(s3)         // [1 2 3 0 1 2 3 4 5 6 7 8 9]
        s1 = arr[1:4]
        s2 = arr[7:]
        s3 = append(s1, s2...)
        fmt.Println()
        fmt.Println(arr) // [0 1 2 3 7 8 9 7 8 9]
        fmt.Println(s3)  // [1 2 3 7 8 9]
    }
    

    copy 函数将 src 切片的元素值复制到 dst 切片中,从而改变了 dst。其返回值是被复制的元素数量,该值为 min(len(src), len(dst))。示例如下:

    func main() {
        s1 := []int{1, 2, 3}
        s2 := []int{4, 5, 6, 7, 8, 9}
        copy(s1, s2)
        fmt.Println(s1) // [4 5 6]
        s1 = []int{1, 2, 3}
        copy(s2, s1)
        fmt.Println(s2) // [1 2 3 7 8 9]
    }
    

    Rust 的切片具有太多方法了,这里只举简单的几个例子:

    fn main() {
        let mut arr = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
        let s1 = &mut arr[1..4];
        // 因为上一行已经可变借用了 arr,这里不能再借用了,所以新建了一个独立的切片
        let s2 = &[4, 5, 6];
        s1.clone_from_slice(s2); // s1 和 s2 的长度必须相等
        println!("{:?}", s1);
    }
    
    fn main() {
        // Rust 不能像 Go 那样为切片追加内容,但可以为向量追加内容
        let mut vec = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
        let s2 = &[4, 5, 6];
        vec.extend_from_slice(s2);
        println!("{:?}", vec);
    }
    

    不得不说,虽然 Rust 的切片有很多方法可用,但使用起来不够灵活,且心智负担较大,不像 Go 中仅依靠 appendcopy 两个函数就能包打多种使用场景。

因此,从使用的方便性来说,Go 的切片是吊打 Rust 切片的。在 Rust 中,如果只有数组和切片,对于许多使用场景是不够用的,因此 Rust 又提供了另外一种数据结构:向量。请接着往下看!

向量

向量(Vector)是 Rust 有而 Go 没有的一种数据结构,经常被翻译为动态数组或可变数组。向量类型并不是 Rust 的基本类型,而是由 Rust 标准库提供的。向量是一种容器,它像数组一样存储值,但所存储的值被分配在堆上,并且可以动态地调整数组长度。向量的类型为 Vec<T>,其中 T 表示泛型,可以替换为其他类型。

以下给出一些创建向量的方法,同样显式地给出向量的类型:

let _: Vec<i32> = Vec::new(); // 使用 Vec::new() 创建一个长度为 0,容量为 0 的向量
let _: Vec<i32> = vec![1, 2, 3]; // 使用 vec! 宏更加便捷地创建向量
let _: Vec<i32> = vec![0; 3]; // 创建一个长度为 3,所有元素值均为 0 的向量
let _: Vec<i32> = Vec::from([1, 2, 3]); // 从数组创建向量
let _: Vec<i32> = Vec::from(&[1, 2, 3][..]); // 从切片创建向量
let _: Vec<i32> = Vec::with_capacity(10); // 创建一个长度为 0,容量为 10 的向量

相对来说,Rust 中的向量更类似 Go 中的切片,主要表现在可以动态调整向量的长度和容量,使其具有动态特征。不像 Go 那样主要使用 appendcopy 函数实现大多数功能,向量具有很多方法,要灵活使用,需要仔细地研读其文档。下面通过示例演示一下部分方法的使用:

fn main() {
    let mut vec: Vec<i32> = Vec::with_capacity(10); // 创建一个长度为 0,容量为 10 的向量
    print_vector(&vec); // Len: 0, Cap: 10, Value: []

    // 依次往向量的末尾追加 0、1、2
    vec.push(0);
    vec.push(1);
    vec.push(2);
    print_vector(&vec); // Len: 3, Cap: 10, Value: [0, 1, 2]

    vec.append(&mut vec![3, 4]);
    print_vector(&vec); // Len: 5, Cap: 10, Value: [0, 1, 2, 3, 4]

    let _: i32 = vec[1]; // 获得向量的第二个元素值
    let _: Option<&i32> = vec.get(8); // 获得对向量第 9 个元素的引用,由于该值不存在,实际返回 None
    let _: Option<&[i32]> = vec.get(0..1); //获得向量的前两个元素的切片

    vec.resize(7, 0); // 调整切片的长度为 7,新加入的值为 0
    print_vector(&vec); // Len: 7, Cap: 10, Value: [0, 1, 2, 3, 4, 0, 0]

    vec.resize(5, 0); // 调整切片的长度为 5,原有的值被截断
    print_vector(&vec); // Len: 5, Cap: 10, Value: [0, 1, 2, 3, 4]

    let _: Option<i32> = vec.pop(); // 将向量末尾的值弹出,并返回该值
    vec.pop(); // 再次弹出值
    print_vector(&vec); // Len: 3, Cap: 10, Value: [0, 1, 2]

    vec.shrink_to(8); // 将切片的容量缩减到 8
    print_vector(&vec); // Len: 3, Cap: 8, Value: [0, 1, 2]

    vec.shrink_to_fit(); // 将切片的容量缩减为其长度
    print_vector(&vec); // Len: 3, Cap: 3, Value: [0, 1, 2]

    assert_eq!(vec, vec![0, 1, 2]); // 向量可以和向量比较
    assert_eq!(vec, [0, 1, 2]); // 向量和数组比较
    assert_eq!(vec, &[0, 1, 2][..]); // 向量和切片比较
}

fn print_vector(v: &Vec<i32>) {
    println!("Len: {}, Cap: {}, Value: {:?}", v.len(), v.capacity(), v)
}

Rust 向量和 Go 切片的主要不同点在于:

  • Rust 的向量数据是被分配在堆上的,它具有移动语义,要遵循所有权规则;Go 却不需要关心切片数据是被分配在堆上还是栈上,可以直接将一个切片变量赋值给另外一个切片变量,他们共享底层的数组。

  • 一旦可变借用了 Rust 的向量,并通过借用变量修改了其长度和容量,则当结束借用时,其所做的修改对其拥有者也有效。如下所示:

    fn main() {
        let mut v1 = vec![0, 1, 2, 3];
        let v2 = &mut v1;
        v2[1] = 33;
        v2.append(&mut vec![4, 5]);
        print_vector(&v2); // Len: 6, Cap: 8, Value: [0, 33, 2, 3, 4, 5]
        print_vector(&v1); // Len: 6, Cap: 8, Value: [0, 33, 2, 3, 4, 5]
    }
    
    fn print_vector(v: &Vec<i32>) {
        println!("Len: {}, Cap: {}, Value: {:?}", v.len(), v.capacity(), v)
    }
    

    但在 Go 中,若将一个切片赋值给另外一个切片,并修改了新切片的长度和容量,则这些修改对原有切片是不可见的。要使修改可见,需要使用指向切片的指针。如下所示:

    func main() {
    	s1 := []int{0, 1, 2, 3}
    	s2 := s1
    	s2[1] = 33
    	s2 = append(s2, 4, 5)
    	printSlice(s2) // Len: 6, Cap: 8, Value: [0 33 2 3 4 5]
    	printSlice(s1) // Len: 4, Cap: 4, Value: [0 33 2 3]
    
    	s3 := &s1
    	*s3 = append(*s3, 4, 5)
    	printSlice(*s3) // Len: 6, Cap: 8, Value: [0 33 2 3 4 5]
    	printSlice(s1)  // Len: 6, Cap: 8, Value: [0 33 2 3 4 5]
    }
    
    func printSlice(sli []int) {
    	fmt.Printf("Len: %v, Cap: %v, Value: %v\n", len(sli), cap(sli), sli)
    }
    
  • 在 Rust 中,在安全模式下,对于超出向量长度,但却在容量范围内的元素,无论其原来是否已初始化,其值都是不可恢复的。但可以通过在不安全模式下,使用 set_len 方法强制修改向量长度,从而恢复原来的元素值。

    let mut vec: Vec<i32> = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
    vec.truncate(5);
    // Len: 5, Cap: 10, Value: [0, 1, 2, 3, 4]
    println!("Len: {}, Cap: {}, Value: {:?}", vec.len(), vec.capacity(), vec);
    unsafe {
        vec.set_len(10); // 新的长度不能大于向量容量,且 old_len..new_len 之间的元素必须已经初始化
    }
    // Len: 10, Cap: 10, Value: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
    println!("Len: {}, Cap: {}, Value: {:?}", vec.len(), vec.capacity(), vec);
    

    在 Go 中,可以通过对切片的再次切分,轻易地找回这些元素:

    var arr [10]int = [10]int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
    var sli []int = arr[:5]
    // Len: 5, Cap: 10, Value: [0 1 2 3 4]
    fmt.Printf("Len: %v, Cap: %v, Value: %v\n", len(sli), cap(sli), sli)
    sli = sli[:9]
    // Len: 9, Cap: 10, Value: [0 1 2 3 4 5 6 7 8]
    fmt.Printf("Len: %v, Cap: %v, Value: %v\n", len(sli), cap(sli), sli)
    

映射

映射(map)是一种保存“键/值对”集合的数据结构。相对于数组、切片、向量,映射的键不必是整数类型,可以选择任何可比较的类型,从而使其键能表示一些特殊的含义。许多编程语言中都有此数据结构,它经常又被称为哈希、哈希表、关联数组、符号表、字典等。映射经常使用哈希表实现,哈希表就是将键映射到值的一种表,它实际上使用哈希函数将任意键转变为更短的数字,再使用此数字作为哈希表中的索引。

在 Go 中,映射是一种内建的数据类型,其类型表示方法为 map[K]V,其中键 K 必须是可比较类型,值 V 可以是任意类型。在 Rust 中,与 Go 的映射对应的数据结构是在标准库中实现的哈希映射 HashMap,其类型表示方法为 HashMap<K, V>,其中键 K 必须是实现了 EqHash trait 的类型,值 V 可以是任意类型。除此之外,Rust 还有另外一种映射类型 BTreeMap,它是使用 B 树实现的有序的映射。这里对 BTreeMap 不做介绍。

以下分别是两种语言创建和操作映射的方法,为了便于表述,同样显式地标注各变量的类型。

Go:

func main() {
	var _ map[byte]bool                                     // 声明一个映射,但没有进行初始化
	var _ map[string]float64 = make(map[string]float64)     // 使用 make 函数初始化映射
	var _ map[string]float64 = make(map[string]float64, 10) // 创建一个容量为 10 的空映射
	var urls map[string]string = map[string]string{         // 使用映射字面量初始化映射
		"go":     "https://go.dev/",
		"rust":   "https://rust-lang.org/",
		"python": "https://www.python.org/",
		"java":   "https://www.java.com/",
	}

	fmt.Println("Go:", urls["go"]) // Go: https://go.dev/
	var _ string = urls["ruby"]    // 键 "ruby" 不存在,返回值为空 ""
	if rubyURL, present := urls["ruby"]; present {
		// 键 "ruby" 不存在, 此时 present 为 false,将什么也不打印
		fmt.Println("Ruby:", rubyURL)
	}

	fmt.Println("Len:", len(urls)) // Len: 4

	urls["javascript"] = "https://www.javascript.com/" // 添加 "javascript" 项
	delete(urls, "java")                               // 删除 "java" 项
}

Rust:

use std::collections::HashMap;

fn main() {
    let _: HashMap<u8, bool>; // 声明一个 HashMap,但没有进行初始化
    let _: HashMap<String, f64> = HashMap::new(); // 使用 new 创建一个空的 HashMap
    let _: HashMap<String, f64> = HashMap::with_capacity(10); // 创建一个容量为 10 的空 HashMap

    // 通过 from 方法根据二元元组的数组创建 HashMap
    let mut urls: HashMap<&str, &str> = HashMap::from([
        ("go", "https://go.dev/"),
        ("rust", "https://rust-lang.org/"),
        ("python", "https://www.python.org/"),
        ("java", "https://www.java.com/"),
    ]);

    println!("Go: {}", urls["go"]); // Go: https://go.dev/
    // println!("Ruby: {}", urls["ruby"]); // 键 "ruby" 不存在,,将会引起 panic
    if urls.contains_key("ruby") {
        // 键 "ruby" 不存在, 因此什么也不打印
        println!("Go: {}", urls["ruby"]);
    }
    let _: Option<&&str> = urls.get("go"); // Some("https://go.dev/")
    let _: Option<&&str> = urls.get("php"); // None

    println!("Len: {}, Cap: {}", urls.len(), urls.capacity()); // Len: 4, Cap: 7

    urls.reserve(20); // 保留至少 20 个额外未用的容量
    println!("Len: {}, Cap: {}", urls.len(), urls.capacity()); // Len: 4, Cap: 28

    urls.shrink_to(10); // 以 10 为下界减少 HashMap 的容量
    println!("Len: {}, Cap: {}", urls.len(), urls.capacity()); // Len: 4, Cap: 14

    urls.shrink_to_fit(); // 尽可能缩减 HashMap 的容量
    println!("Len: {}, Cap: {}", urls.len(), urls.capacity()); // Len: 4, Cap: 7

    urls.insert(
        "javascript",
        "https://www.javascript.com/",
    ); // 添加 "javascript" 项
    // 使用 Entry API 插入元素,or_insert 仅当键值不存在时才进行插入
    urls.entry("python").or_insert("https://python.org/");
    println!("Python: {}", urls["python"]); // Python: https://www.python.org/

    urls.remove("java"); // 删除 "java" 项
}

Go 的映射类型和 Rust 的 HashMap 类型在使用时的主要不同点是:

  • Go 不需要关注映射的数据是分配在堆上还是栈上,可以直接将一个映射变量赋值给另外一个映射变量,他们共享底层数据。Rust 的HashMap 底层数据是被分配的堆上的,它具有移动语义,要遵循所有权规则。

  • 虽然两者都是有容量的,但在 Go 中,只能在使用 make 函数初始化时指定映射的容量,此后将无法查看和修改映射的容量。在 Rust 中,可以查看和调整一个 HashMap 变量的容量。

  • Go 的映射是内建类型,因此具有映射字面量。Rust 的 HashMap 则是标准库实现的类型,没有相应的字面量,不过可以通过二元元组的数组快速构建。

  • Go 在操作映射时相对较为简单,而 Rust 的 HashMap 类型则有一大堆可用的方法,需要深入研读。不过,HashMap

    的操作与向量比较类似,这倒是稍微方便记忆了。

遍历

遍历是对集合类数据的常用操作。要遍历 Go 的集合类型,一般用 forfor-range 循环语句实现。Rust 则借助迭代器实现,由于所有权规则的限制和功能扩展的需要,其使用规则稍微有点复杂。在基本控制流一文中已经介绍过一些使用循环语句遍历集合类型的方法,这里再举几个例子。

Go 示例:

sli := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
for i := 0; i < len(sli); i++ {
	fmt.Printf("Index: %d, Value: %d\n", i, sli[i])
}
fmt.Println()
// 使用 for-range 遍历切片
for i, v := range sli {
    fmt.Printf("Index: %d, Value: %d\n", i, v)
}
fmt.Println()

var urls map[string]string = map[string]string{
    "go":     "https://go.dev/",
    "rust":   "https://rust-lang.org/",
    "python": "https://www.python.org/",
    "java":   "https://www.java.com/",
}
// 使用 for-range 遍历映射
for k, v := range urls {
	fmt.Printf("Key: %s,\tValue: %s\n", k, v)
}

Rust 使用的迭代器是一种设计模式,它可以让用户透过特定的接口遍历容器中的每一个元素而不用了解底层的实现。迭代器的相关定义在迭代器模块中,其中 Iterator trait 是该模块的核心和灵魂。Iterator trait 的形式为:

trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    // 还包含其他许多基于 next 定义的默认方法。
}

以上 Item 是将要被迭代的集合元素类型,nextIterator 实现者被要求定义的唯一方法。next 一次返回迭代器中的一个元素值,该值被封装在 Some 中,当迭代结束时,将返回 None

为了能通过迭代器对一个集合类型进行遍历,可以在该类型上定义一些能创建迭代器的方法。针对所有权系统中的移动、借用和可变借用,通常集合类型 T 会分别提供以下三个创建迭代器的方法:

  • into_iter(),对 T 进行迭代。
  • iter(),对 &T 进行迭代。
  • iter_mut(),对 &mut T 进行迭代。

其中 into_iter() 方法对应于 IntoIterator trait,使其可用于一些通用场景(如在 for 语句中不指定生成迭代器的方法时),而另外两种方法尚没有定义特定的 trait。像数组、切片、向量、HashMap 这些集合类型都同时具有这三个方法。对于数组类型,使用 into_iter() 方法时将会复制数组;而使用后两种方法时,不会复制数组。对于向量、HashMap 类型,使用 into_iter() 方法时,该集合的所有权将被转入方法内部,后续将无法使用;而使用后两种方法时,将借用集合。

由于 Iterator trait 只有一个必须的 next 方法,其他方法都是构建于 next 的基础之上,因此在迭代过程中,只能往集合的后方移动游标。这意味着一个迭代器只能使用一遍,即遍历过程会消费迭代器。要从头遍历集合,需要重新创建迭代器。

Rust 的 for 循环实际上是迭代器的语法糖。当出现在 in 后方的集合没有显式指定迭代器时,它默认调用 IntoIterator trait 的 into_iter() 方法。当然,也可以显式指定该集合所具有的具体某个返回迭代器的方法。在每个循环中,将调用迭代器的 next 方法,通过模式匹配在该方法返回 Some 值时取出该值,返回 None 值时终止循环。

以下是在 Rust 中对集合类型进行遍历的方法示例:

let vec = vec![0, 1, 2, 3, 4];

// 不通过迭代器遍历向量
let mut i = 0;
while i < vec.len() {
    println!("Index: {}, Value: {}", i, vec[i]);
    i += 1;
}

// 在 vec 变量上(显式)创建迭代器。
// 后面的 next 方法以可变借用的形式使用迭代器,因此这里必须将迭代器声明为可变的
let mut vec_iter = vec.iter();
println!("{:?}", vec_iter.next()); // Some(0)
println!("{:?}", vec_iter.next()); // Some(1)
println!("{:?}", vec_iter.next()); // Some(2),迭代器还没有被消费完

// 继续消费迭代器,将打印:
// Value: 3
// Value: 4
for v in vec_iter {
    println!("Value: {}", v);
}

// 前面声明的 vec_iter 迭代器已经被消费,要再次遍历 vec,必须重新生成一个迭代器。
// 此迭代器不必是可变的,因为下面的 for 语句取得了其所有权,并在内部使其可变
let vec_iter_new = vec.iter();
for (i, v) in vec_iter_new.enumerate() {
    println!("Index: {}, Value: {}", i, v);
}

// for 语句中若不指定创建迭代器的方法,默认将使用 into_iter() 方法
for v in vec {
    println!("Value: {}", v);
}
// vec 的所有权在以上循环中已被移动到 into_iter() 方法中,将不能再被使用
// println!("{:?}", vec);

// 声明一个可变数组,然后利用 iter_mut() 迭代修改数组的每个元素
let mut arr = [0, 1, 2, 3, 4];
for v in arr.iter_mut() {
    *v += 2;
}
// for 语句中若不指定创建迭代器的方法,默认将使用 into_iter() 方法
for v in arr {
    println!("Value: {}", v);
}
// arr 是拷贝语义的,所以还能再被使用
println!("{:?}", arr);

let urls: HashMap<&str, &str> = HashMap::from([
    ("go", "https://go.dev/"),
    ("rust", "https://rust-lang.org/"),
    ("python", "https://www.python.org/"),
    ("java", "https://www.java.com/"),
]);
// HashMap 迭代器的每个 next 返回值中包含键值对
for (k, v) in urls.iter() {
    println!("Key: {},\tValue: {}", k, v);
}

迭代器 Iterator trait 中还定义了许多其他实用的方法。如以上代码中的 enumerate 方法又在现有迭代器的基础上生成了一个新的迭代器 Enumerate,该迭代器的每个 next 方法返回 (i, val) 形式的索引/值对,其中 i 是迭代器的当前索引。enumerate 方法在对数组、切片和向量类型进行迭代是非常有用。像这种接受一个迭代器,同时生成另外一个迭代器的函数叫作迭代器适配器(iterator adapter)。 Iterator trait 提供的常用的迭代器适配器有 maptakefilter,这里就不再过多介绍了。另外请注意,Iterator trait 中的许多方法会全部或部分消费迭代器,如 countlastnthsum 等。

Iterator trait 中另外一个值得一提的是强大的 collect 方法。该方法可以实现将一个迭代器转化为一个集合,以此可以实现将一种集合类型转换为另外一种集合类型。即通过已有集合类型的 iter 方法生成一个迭代器,然后调用迭代器的 collect 方法创建另外一个不同类型的集合。为了显式标明使用 collect 方法时所生成的集合的类型,可以使用一种形如 ::<> 的涡轮鱼(turbofish)语法。如下所示:

use std::collections::HashMap;

fn main() {
    let v1: Vec<char> = vec!['H', 'e', 'l', 'l', 'o'];
    // 将 char 类型的向量转换为字符串,显式标明 s1 的类型
    let s1: String = v1.iter().collect();
    println!("{s1}"); // Hello

    // 将 char 类型的向量转换为字符串,没有标明 s2 的类型,
    // 但使用 turbofish 语法标明要转换的类型
    let s2 = v1.iter().collect::<String>();
    println!("{s2}"); // Hello

    let v2 = vec![0, 1, 2, 3, 4];
    // 使用 map 迭代适配器通过闭包将原来向量的每个元素乘以 2,进而创建一个新的向量
    // 使用 turbofish 语法,同时使用形如 Vec<_> 的部分类型提示
    let v3 = v2.into_iter().map(|x| x * 2).collect::<Vec<_>>();
    println!("{:?}", v3); // [0, 2, 4, 6, 8]

    // 使用 collect 方法根据元素向量创建 HashMap
    let score_list_1 = vec![("张三", 67.5), ("李四", 88.0), ("王老五", 56.3)]
        .into_iter()
        .collect::<HashMap<_, _>>();
    println!("{:?}", score_list_1); // {"张三": 67.5, "李四": 88.0, "王老五": 56.3}

    // 先通过 zip 方法将两个迭代器合并为一个值对的迭代器,再通过 collect 方法生成 HashMap
    let names = vec!["张三", "李四", "王老五"];
    let scores = vec![67.5, 88.0, 56.3];
    let score_list_2: HashMap<_, _> = names.iter().zip(scores.iter()).collect();
    println!("{:?}", score_list_2); // {"张三": 67.5, "李四": 88.0, "王老五": 56.3}
}

迭代器是惰性的(lazy)。这意味着如果只创建迭代器并不再做什么(没有调用 next 方法),将什么也不会发生,即不会对集合类型变量产生任何影响。

在 Rust 中,还可以为自定义的类型实现迭代器,从而能像以上集合类型一样用 for 语句遍历。不再讲解了,累了!

讨论

在集合数据的表示上,两种语言各自都走在各自的路上,Go 仍然是大道至简,Rust 则能让我们操控一切。本文的篇幅不小,更多都是 Rust 占用的,可见其学习真的不易。