Skip to content

6.1 数组

数组是一个容器,它在一块连续空间内存中,存储了一系列的同样类型的数据。 数组中元素的占用空间大小必须是编译期确定的。数组本身所容纳的元素个数也必须是编译期确定的,执行阶段不可变。 如果需要使用变长的容器,可以使用标准库中的 Vec/LinkedList 等。

数组类型的表示方式为[T; n]。其中T代表元素类型;n代表元素个数,它必须是编译期常量整数;中间用分号隔开。

下面看一个基本的示例:

rust
fn main() {
	let xs = [1, "12", 3, 4, 5];
}

error[E0308]: mismatched types数组类型必须一致!

txt
let xs = [1, "12", 3, 4, 5];
|                  ^^^^ expected integer, found `&str`

正确示例:

rust
fn main() {
	// 定长数组
	let xs: [i32; 5] = [1, 2, 3, 4, 5];

	// 所有的元素,如果初始化为同样的数据,可以使用如下语法
	let ys: [i32; 500] = [0; 500];
}

在 Rust 中,对于两个数组类型,只有元素类型和元素个数都完全相同,这两个数组才是同类型的。 数组与指针之间不能隐式转换。同类型的数组之间可以互相赋值。

示例如下:

rust
fn main() {
	let mut xs: [i32; 5] = [1, 2, 3, 4, 5];
	let     ys: [i32; 5] = [6, 7, 8, 9, 10];
	xs = ys;
	println!("new array {:?}", xs);
}

把数组xs作为参数传给一个函数,这个数组并不会退化成一个指针。而是会将这个数组完整复制进这个函数。函数体内对数组的改动不会影响到外面的数组。

对数组内部元素的访问,可以使用中括号索引的方式。Rust 支持 usize 类型的索引的数组,索引从0开始计数。

rust
fn main() {
	let v : [i32; 5] = [1,2,3,4,5];
	let x = v[0] + v[1];        // 把第一个元素和第二个元素的值相加
	println!("sum is {}", x);
}

6.1.1 内置方法

与其他所有类型一样,Rust 的数组类型拥有一些内置方法,可以很方便地完成一些任务。比如,我们可以直接实现数组的比较操作,只要它包含的元素是可以比较的:

rust
fn main() {
	let v1 = [1, 2, 3];
	let v2 = [1, 2, 4];
	println!("{:?}", v1 < v2 );
}

我们也可以对数组执行遍历操作,如:

rust
fn main() {

	let v = [0_i32; 10];

	for i in &v {
		println!("{:?}", i);
	}
}

在目前的标准库中,数组本身没有实现 IntoIterator trait,但是数组切片是实现了的。 所以我们可以直接在for in循环中使用数组切片,而不能直接使用数组本身。 更详细的内容请参阅后文中关于迭代器的解释。

6.1.2 多维数组

既然[T; n]是一个合法的类型,那么它的元素 T 当然也可以是数组类型,因此[[T; m]; n]类型自然也是合法类型。示例如下:

rust
fn main() {
	let v : [[i32; 2]; 3] = [[0, 0], [0, 0], [0, 0]];

	for i in &v {
		println!("{:?}", i);
	}
}

6.1.3 数组切片

对数组取借用操作,可以生成一个“数组切片”(Slice)。数组切片对数组没有“所有权”,我们可以把数组切片看作专门用于指向数组的指针,是对数组的另外一个“视图”。 比如,我们有一个数组[T; n],它的借用指针的类型就是&[T; n]。它可以通过编译器内部魔法转换为数组切片类型&[T]

数组切片实质上还是指针,它不过是在类型系统中丢弃了编译阶段定长数组类型的长度信息,而将此长度信息存储为运行期的值。 示例如下:

rust
fn main() {
	fn mut_array(a : &mut [i32]) {
		a[2] = 5;
	}

	println!("size of &[i32; 3] : {:?}", std::mem::size_of::<&[i32; 3]>());
	println!("size of &[i32]    : {:?}", std::mem::size_of::<&[i32]>());

	let mut v: [i32; 3] = [1,2,3];
	{
		let s: &mut [i32; 3] = &mut v;
		mut_array(s);
	}
	println!("{:?}", v);
}

变量v[i32; 3]类型;变量s&mut[i32; 3]类型,占用的空间大小与指针相同。 它可以自动转换为&mut[i32]数组切片类型传入函数mut_array,占用的空间大小等于两个指针的空间大小。通过这个指针,在函数内部,修改了外部的数组v的值。

6.1.4 DST 和胖指针

从前面的示例中可以看到,数组切片是指向一个数组的指针,而它比指针又多了一点东西 —— 它不止包含有一个指向数组的指针,切片本身还含带长度信息。

Slice 与普通的指针是不同的,它有一个非常形象的名字:胖指针(fat pointer)。与这个概念相对应的概念是“动态大小类型”(Dynamically Sized Type,DST)。

所谓的 DST 指的是编译阶段无法确定占用空间大小的类型。为了安全性,指向 DST 的指针一般是胖指针。

比如:对于不定长数组类型[T],有对应的胖指针&[T]类型;对于不定长字符串str类型,有对应的胖指针&str类型;以及在后文中会出现的 Trait Object;等等。

由于不定长数组类型[T]在编译阶段是无法判断该类型占用空间的大小的,目前我们不能在栈上声明一个不定长大小数组的变量实例,也不能用它作为函数的参数、返回值。但是,指向不定长数组的胖指针的大小是确定的,&[T]类型可以用做变量实例、函数参数、返回值。

通过前面的示例我们可以看到,&[T]类型占用了两个指针大小的内存空间。我们可以利用 unsafe 代码把这个胖指针内部的数据打印出来看看:

rust
fn raw_slice(arr: &[i32]) {
	unsafe {
		let (val1, val2): (usize, usize) = std::mem::transmute(arr);
		println!("Value in raw pointer:");
		println!("value1: {:x}", val1);
		println!("value2: {:x}", val2);
	}
}

fn main() {
	let arr: [i32; 5] = [1, 2, 3, 4, 5];
	let address: &[i32; 5] = &arr;
	println!("Address of arr: {:p}", address);

	raw_slice(address as &[i32]);
}

在这个示例中,我们arr是长度为5的 i32 类型的数组。address是一个普通的指向arr的借用指针。 我们可以用as关键字把address转换为一个胖指针&[i32],并传递给raw_slice函数。 在raw_slice函数内部,我们利用了 unsafe 的transmute函数。我们可以把它看作一个强制类型转换,类似reinterpret_cast,通过这个函数,我们把胖指针的内部数据转换成了两个 usize 大小的整数来看待。

编译,执行,结果为:

txt
Address of arr: 0x7ffc25a668fc
Value in raw pointer:
value1: 7ffc25a668fc
value2: 5

由此可见,胖指针内部的数据既包含了指向源数组的地址,又包含了该切片的长度。

对于 DST 类型,Rust 有如下限制:

  • 只能通过指针来间接创建和操作 DST 类型,&[T] Box<[T]>可以,[T]不可以;

  • 局部变量和函数参数的类型不能是 DST 类型,因为局部变量和函数参数必须在编译阶段知道它的大小因为目前 unsized rvalue 功能还没有实现;

  • enum 中不能包含 DST 类型,struct 中只有最后一个元素可以是 DST,其他地方不行,如果包含有 DST 类型,那么这个结构体也就成了 DST 类型。

Rust 设计出 DST 类型,使得类型系统暂时更完善,也有助于消除一些 C/C++ 中容易出现的 bug。这一设计的好处有:

  • 首先,DST 类型虽然有一些限制条件,但我们依然可以把它当成合法的类型看待,比如,可以为这样的类型实现 trait、添加方法、用在泛型参数中等;

  • 胖指针的设计,避免了数组类型作为参数传递时自动退化为裸指针类型,丢失了长度信息的问题,保证了类型安全;

  • 这一设计依然保持了与“所有权”“生命周期”等概念相容的特点。

数组切片不只是提供了“数组到指针”的安全转换,配合上 Range 功能,它还能提供数组的局部切片功能。

6.1.5 Range

Rust 中的 Range 代表一个“区间”,一个“范围”,它有内置的语法支持,就是两个小数点..。示例如下:

rust
fn main() {
	let r = 1..10;   // r 是一个 Range<i32>,中间是两个点,代表 [1,10) 这个区间
	for i in r {
		print!("{:?}\t", i);
	}
}

编译,执行,结果为:

txt
1       2       3       4       5       6       7       8       9

需要注意的是,在begin..end这个语法中,前面是闭区间,后面是开区间。这个语法实际上生成的是一个std::ops::Range<_>类型的变量。该类型在标准库中的定义如下:

rust
pub struct Range<Idx> {
	/// The lower bound of the range (inclusive).
	pub start: Idx,
	/// The upper bound of the range (exclusive).
	pub end: Idx,
}

所以,上面那段示例代码实质上等同于下面这段代码:

rust
use std::ops::Range;

fn main() {
	let r = Range {start: 1, end: 10};   // r 是一个 Range<i32>
	for i in r {
			print!("{:?}\t", i);
	}
}

两个小数点的语法仅仅是一个“语法糖”而已,用它构造出来的变量是 Range 类型。

这个类型本身实现了 Iterator trait,因此它可以直接应用到循环语句中。 Range 具有迭代器的全部功能,因此它能调用迭代器的成员方法。 比如,我们要实现从100递减到10,中间间隔为10的序列,可以这么做(具体语法请参考后文中的迭代器、闭包等章节):

rust
fn main() {
	use std::iter::Iterator;
// 先用 rev 方法把这个区间反过来,然后用 map 方法把每个元素乘以 10
	let r = (1i32..11).rev().map(|i| i * 10);

	for i in r {
		print!("{:?}\t", i);
	}
}

执行结果为:

txt
100	90	80	70	60	50	40	30	20	10

在 Rust 中,还有其他的几种 Range,包括

  • std::ops::RangeFrom代表只有起始没有结束的范围,语法为start..,含义是[start, +∞)

  • std::ops::RangeTo代表没有起始只有结束的范围,语法为..end,对有符号数的含义是(-∞, end),对无符号数的含义是[0, end)

  • std::ops::RangeFull代表没有上下限制的范围,语法为..,对有符号数的含义是(-∞, +∞),对无符号数的含义是[0, +∞)

数组和 Range 之间最常用的配合就是使用 Range 进行索引操作。示例如下:

rust
fn print_slice(arr: &[i32]) {
	println!("Length: {}", arr.len());

	for item in arr {
		print!("{}\t", item);
	}
	println!("");
}

fn main() {
	let arr : [i32; 5] = [1, 2, 3, 4, 5];
	print_slice(&arr[..]);    // full range

	let slice = &arr[2..];    // RangeFrom
	print_slice(slice);

	let slice2 = &slice[..2]; // RangeTo
	print_slice(slice2);
}

编译,执行,结果为:

txt
Length: 5
1	2	3	4	5	
Length: 3
3	4	5	
Length: 2
3	4

第一次打印,内容为整个arr的所有区间。第二次打印,是从arr的 index 为2的元素开始算起,一直到最后。注意数组是从 index 为0开始计算的。 第三次打印,是从 slice 的头部开始,长度为2,因此只打印出了34两个数字。

在许多时候,使用数组的一部分切片作为被操作对象在函数间传递,既保证了效率(避免直接复制大数组),又能保证将所需要执行的操作限制在一个可控制的范围内(有长度信息,有越界检查),还能控制其读写权限,非常有用。

虽然左闭右开区间是最常用的写法,然而,在有些情况下,这种语法不足以处理边界问题。比如,我们希望产生一个 i32 类型的从0i32::MAX的范围,就无法表示。 因为按语法,我们应该写0..(i32::MAX + 1),然而(i32::MAX + 1)已经溢出了。所以,Rust 还提供了一种左闭右闭区间的语法,它使用这种语法来表示..=

闭区间对应的标准库中的类型是:

  • std::ops::RangeInclusive,语法为start..=end,含义是[start, end]

  • std::ops::RangeToInclusive,语法为..=end,对有符号数的含义是(-∞,end],对无符号数的含义是[0,end]

6.1.6 边界检查

在前面的示例中,我们的“索引”都是一个合法的值,没有超过数组的长度。如果我们给“索引”一个非法的值会怎样呢:

rust
fn main() {
	let v = [10i32, 20, 30, 40, 50];
	let index: usize =std::env::args().nth(1).map(|x|x.parse().unwrap_or(0)).unwrap_or(0);
	println!("{:?}", v[index]);
}

编译通过,执行 thread'main'panicked at'index out of bounds:the len is 5 but the index is 10'。 可以看出,如果用rustc ./test.rs 10,则会出现数组越界,Rust 目前还无法任意索引执行编译阶段边界检查,但是在运行阶段执行了边界检查。 下面我们分析一下边界检查背后的故事。

在 Rust 中,“索引”操作也是一个通用的运算符,是可以自行扩展的。 如果希望某个类型可以执行“索引”读操作,就需要该类型实现std::ops::Index trait,如果希望某个类型可以执行“索引”写操作,就需要该类型实现std::ops::IndexMut trait

对于数组类型,如果使用 usize 作为索引类型执行读取操作,实际执行的是标准库中的以下代码:

rust
impl<T> ops::Index<usize> for [T] {
	type Output = T;

	fn index(&self, index: usize) -> &T {
		assert!(index < self.len());
		unsafe { self.get_unchecked(index) }
	}
}

代码中使用的 assert! 宏定义在 libcore/macros.rs 中,源码是这样的:

rust
macro_rules! assert {
	($cond:expr) => (
		if !$cond {
			panic!(concat!("assertion failed: ", stringify!($cond)))
		}
	);
	($cond:expr, $($arg:tt)+) => (
		if !$cond {
			panic!($($arg)+)
		}
	);
}

也就是说,如果 index 超过了数组的真实长度范围,会执行panic!操作,导致线程 abort。使用 Range 等类型做 Index 操作的执行流程与此类似。

为了防止索引操作导致程序崩溃,如果我们不确定使用的“索引”是否合法,应该使用get()方法调用来获取数组中的元素,这个方法不会引起panic!,它的返回类型是Option<T>,示例如下:

rust
fn main() {
	let v = [10i32, 20, 30, 40, 50];
	let first = v.get(0);
	let tenth = v.get(10);
	println!("{:?} {:?}", first, tenth);
}

输出结果为:"Some(10)None"。

Rust 宣称的优点是“无 GC 的内存安全”,那么数组越界会直接导致程序崩溃这件事情是否意味着 Rust 不够安全呢?不能这么理解。Rust 保证的“内存安全”,并非意味着“永不崩溃”。 Rust 中关于数组越界的行为,定义得非常清晰。相比于 C/C++,Rust 消除的是“未定义行为”(Undefined Behaviour)。

对于明显的数组越界行为,在 Rust 中可以通过 lint 检查来发现。大家可以参考“clippy”这个项目,它可以检查出这种明显的常量索引越界的现象。 然而,总体来说,在 Rust 里面,靠编译阶段静态检查是无法消除数组越界的行为的。

一般情况下,Rust 不鼓励大量使用“索引”操作。正常的“索引”操作都会执行一次“边界检查”。 从执行效率上来说,Rust 比 C/C++ 的数组索引效率低一点,因为 C/C++ 的索引操作是不执行任何安全性检查的,它们对应的 Rust 代码相当于调用get_unchecked()函数。 在 Rust 中,更加地道的做法是尽量使用“迭代器”方法。“迭代器”非常重要,本书将在第 24 章专门详细分析,下面是使用迭代器操作数组的一些简单示例:

rust
fn main() {
	use std::iter::Iterator;

	let v = &[10i32, 20, 30, 40, 50];

	// 如果我们同时需要 index 和内部元素的值,调用 enumerate() 方法
	for (index, value) in v.iter().enumerate() {
		println!("{} {}", index, value);
	}

	// filter 方法可以执行过滤,nth 函数可以获取第 n 个元素
	let item = v.iter().filter(|&x| *x % 2 == 0).nth(2);
	println!("{:?}", item);
}

Iterator 还有许多有用的方法,合理地组合使用它们,能使程序表达能力强,可读性好,安全高效,可以满足我们绝大多数的需求。

Vec 动态数组

在 Rust 中,Vec 是一个动态数组,可以用来存储同种类型的元素并在需要时动态调整数组大小。

rust
fn main() {
    let mut vec1: Vec<i8> = Vec::new();
    let vec2 = vec![1, 2, 3];

    vec1.push(4);
    vec1.push(5);
    vec1.remove(0);
    println!("vec2: {:?}", vec2);
    println!("vec1: {:?}", vec1);
    println!("vec2[1]: {}", vec2.get(1).unwrap());
    println!("vec1 length: {}", vec1.len());
    println!("Is vec1 empty? {}", vec1.is_empty());
}

vec! 是 Rust 标准库提供的一个宏,可以用来方便地创建一个新的 Vec 对象,并在其内部添加一些已知的元素。

Vec 中元素的类型必须相同,否则编译器会报错。

下面是一个使用 vec! 宏创建 Vec 的简单例子:

rust
fn main() {
  let vec = vec![1, 2, 3];
  println!("{:?}", vec);
}

上述代码中,vec 变量被赋值为一个新创建的 Vec 对象。 该对象的初始元素是 123,输出结果为:[1, 2, 3]

需要注意的是,由于 vec! 宏创建的 Vec 是已知元素的一个列表,因此其内部缓冲区的容量大小可以预先计算得出。 Rust 会根据这个容量大小为新创建的 Vec 分配恰当大小的内存空间。这与使用 Vec::with_capacity 方法创建指定初始容量的 Vec 对象不同,后者需要手动指定初容量大小。

除了直接在 vec! 宏中添加元素外,它还支持一些高级用法,例如,下面的代码展示了如何使用 vec! 宏创建一个初始容量为10Vec 对象,并将其所有元素初始化为0

rust
fn main() {
  let vec = vec![0; 10];
  println!("{:?}", vec);
}

输出结果为:[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

Released under the MIT License