Rust 语法辨析:切片和字符串

#何为切片 Slice

Rust 中,切片(slice)属于原始数据类型 primitive type[1],被写进 Rust core 库。切片类型的泛型写法是 [T],它是对内存中一系列 T 类型元素所组成序列的“视图(View)”。这里的内存,可能是堆(Heap)、栈(Stack)、只读数据区(Literals)。特别的,字符串切片 str 本质上就是符合 UTF-8 编码的数组切片 [u8]

UTF-8(8-bit Unicode Transformation Format/Universal Character Set)是在 Unicode 标准基础上定义的一种可变长度字符编码。它可以表示 Unicode 标准中的任何字符,而且其编码中的第一个字节仍与 ASCII 兼容。

Slice 类型非常特殊,你不能在代码中声明 [T]str 类型的变量。以 str 为例,它只能以 &str &mut str Box<str> String 等形式呈现,前两者是对 str 的引用,后两者包含了指向 str 的指针。

换言之,Slice 实例不能存放到栈上,除非使用 nightly 版本并且开启名为 unsized_localsfeature

#![feature(unsized_locals)]
let mut s = String::from("Hello");
s.push_str(" world");
let boxed_str: Box<str> = s.into_boxed_str();
let ss: str = *boxed_str;

对于 slice 类型 [T] 而言,有三种常见的切片引用:

  • &[T]:共享切片(shared slice),是切片的不可变借用,它不拥有 [T] 内存对象的所有权。为了方便,共享切片也被简称为切片
  • &mut [T]:可变切片(mutable slice),可变借用于它指向的 [T] 内存对象,同样没有所有权
  • Box<[T]>:智能指针切片(boxed slice),[T] 内存对象存储在堆(heap)上,Box 切片拥有它的所有权
// 一个在堆上分配的数组 [i32; 3] 被自动强转成切片 [i32]
let boxed_array: Box<[i32]> = Box::new([1, 2, 3]);

// 数组形式的共享切片
let slice: &[i32] = &boxed_array[..];

虽然 [T]str 本身都是可变的(不妨试着用 Box<str> 调用 make_ascii_uppercase() 验证),但某些情况下是只读/不可变的,这时 Rust 编译器只允许我们使用它的不可变引用 &[T] &str。一个常见的例子是字符串字面量,它被硬编码进可执行程序,在程序运行的整个生命周期内都有效,因此绑定它的变量具有静态生命周期,换言之,绑定该字面量的变量类型实际是 &'static str。(这并不意味着有 'static 生命周期的 str 类型就不可变,仍然有办法构造出具有 'static 生命周期的 &mut str

切片的所有元素总是初始化过的,使用 Rust 中的安全(safe)方法或操作符来访问切片时总是会做越界检查。

有些编程语言(如 C 语言)会在字符串末尾添加一个零字符 \0,并记录起始地址。要确定字符串的长度,程序必须从起始位置开始遍历原始字节,直到找到这个零字节。但 Rust 采用的方法不同:它用来访问字符串的 &str 引用是宽指针,包括了字符串起始地址(裸指针)和所需字节数,这比追加零字节更好,因为计算在编译时就提前完成。

事实上,上述三种切片引用都是宽指针,均包括了指向内存对象的指针和内存对象的尺寸,是普通指针的两倍大小。你可能会好奇,为什么切片的引用都是宽指针?这是因为切片是一种动态尺寸类型(Dynamically sized type)

#动态尺寸类型 DST

Rust 中大多数的类型都有一个在编译时就已知的固定尺寸,并实现了 Trait Sized。只有在运行时才知道尺寸的类型称为动态尺寸类型(dynamically sized type)(DST),或者非正式地称为非固定尺寸类型(unsized type)。切片和特征对象(Trait object)是 DSTs 的两个例子。

注意,这里提到的尺寸未知是对类型而言,即 DST(slice, Trait object) 类型的尺寸无法确定,而非变量值的尺寸。例如,str 类型可以是任意长度(只要不超出计算机内存的限制),但具体到一个字符串字面量 "Hello World!",其长度在编译时是确定无疑且不可更改的。

固定尺寸类型的引用只需要指向内存对象的第一个字节,不需要知道内存对象的尺寸,因为 Rust 在编译时会生成包含类型信息的机器码,对每个固定尺寸类型的数据,Rust 都能知道其类型,从而确定大小。但对于动态尺寸类型,即使知道内存对象的类型(比如明确是 str 类型),由于尺寸可以是任意值,仍无法确定应该引用的内存范围,因而必须使用宽指针。

编译器在编译时需要计算局部变量和参数所需的内存,并相应地为每个栈帧分配空间。[1..4] 是切片语法,会从变量 a 的内存对象中截取一部分。编译器无法从切片语法中确定结果切片的大小,因此下面的代码报错:

fn slice_out_of_array() {
    let a: [u8; 5] = [1, 2, 3, 4, 5];
    let nice_slice = a[1..4]; // 报错:[u8] doesn't have a size known at compile-time
    assert_eq!([2, 3, 4], nice_slice)
}

#String 字符串

如前所述,Rust str 是符合 UTF-8 规范的一串 [u8] 数据,同理 String 类型是基于 Vec<u8> 的封装,二者堆内存分配策略一致:2->4->8,如果容量不够,下次申请的为前一次的 2 倍。和 Vec<u8> 一样,String 类型变量的内存对象存储在堆上,且拥有它的所有权。

String 类型在标准库中的定义:

pub struct String {
    vec: Vec<u8>,
}

可以看出,String 类型定义中的 vec 字段是私有的。这意味着我们不能直接创建字符串实例,只能通过封装的方法来创建。之所以保持私有,是因为并非所有 [u8] 字节流都符合 UTF-8 标准,与底层 u8 字节的直接交互可能会破坏字符串数据。通过这种受控访问,编译器可以确保 String 数据始终有效。以下是两种初始化 String 的方式:

let hello_world: &str = "hello world"; // hello_world 指向只读数据区

let s: String = String::from(hello);
let s: String = hello.to_string(); // 发生了变量遮蔽

let world: &str = &s[6..] // world 指向堆

显然,&str 类型可以指向堆,也可以指向只读数据区,还可以指向栈:只需将分配到栈上的字节数组转换为 &str 类型,这时 str 自然是栈上的内存对象:

use std::str;

let x: &[u8] = &[b'a', b'b', b'c']; // &[u8; 3] 隐式转换为 &[u8]
let stack_str: &str = str::from_utf8(x).unwrap();

作为存储在栈上的宽指针,String 类型包括三部分:指针、长度和容量,相比于 &str 类型仅增加了一个容量字段,因为 String 指向堆内存,所以运行过程中它的长度可以动态改变。

alt text◎ s 是 String 类型,world 是 &str 类型

&String 类型还可以被隐式的转换为 &str 类型,因此当函数参数为 &str 类型时,不仅能传入 &str 变量,也可以传入 &String 变量,这样的函数使用更加灵活。

#Box<str> 字符串

Box<str> 类型是 Box<[T]> 的子集,如前所述,它是一个智能指针/宽指针,str 被存储在堆上。不同于 &str&mut strBox<str> 拥有内存对象的所有权。相比 String 类型,Box<str> 缺少 capacity 字段,这意味着无法修改 Box<str>str 的长度,只能改变 str 中每个字符的值。

#切片语法

在 Rust 中,我们可以用切片语法 [x..y] 从内存中截取一串连续的同类型值,返回一个切片。x..y 表示 [x, y) 的数学含义。.. 两边可以没有运算数:

  • ..y 等价于 0..y
  • x.. 等价于位置 x 到数据结束
  • .. 等价于位置 0 到结束

切片不能直接与变量绑定,所以必须在切片语法 [x..y] 前加上 & 符号,这会得到切片的引用。考虑到自动解引用,切片语法可作用于 str [T] [T; N] String Vec<T> 类型及其引用。

对字符串使用切片语法需要格外小心,切片的索引必须落在字符之间的边界位置,也就是 UTF-8 字符的边界,例如中文在 UTF-8 中占用三个字节,若只取只取中文字符串的前两个字节,连第一个字都取不完整,此时程序会直接崩溃退出。

#总结

[T] str 类型数据可以存储在以下三种位置:

  • Heap 堆:Box<T> String 类型
  • Stack 栈:如前所述
  • 只读数据区:绑定的字符串字面量 "hello" 直接被硬编码进可执行程序中,运行时载入内存的只读数据区

一图以蔽之,Rust 字符串内存模型如下:

alt text

updatedupdated2024-07-172024-07-17