rust 中的 String 和 str

str 在 rust 中是一个比较特殊的类型,因为一般来说类型都会有个固定的大小,比如 u8 是 1 个字节,i32 是 4 个字节,usize 在 64 位系统上是 8 个字节。但是 str 却没有固定的大小,更准确的说在编译期 str 没有固定的大小。

为什么强调是编译期,因为在运行期 str 是有大小的,而且一般情况下 str 的大小是不变的。这听着有点绕,怎么一会儿没有固定大小,一会儿又有固定大小的。

其实很好理解,运行期所有的东西都会从各种抽象映射成一个个字节按照约定排列在内存中,在任意时刻都可以计算出任意类型或变量的大小。因此当我们说某个类型的大小是否确定时,所隐含的前置条件就是特指编译期。

那么问题来了,编译期的类型为什么会有有无固定大小之分呢?其实搞明白这点才能真正理解 str 到底是什么,以及 str&str, String 等的区别。

正如文章开始所举的例子所示,类型一般都有大小,而类型之所以会有大小是因为这方便编译器在编译的时候就能确定某个函数栈帧所需要的最大空间是多少,相当于把计算栈帧大小的工作在编译期就处理掉了,极大的提高了程序的执行效率。

那为什么又会有动态大小类型(DST)的 str 呢?我们都知道 i32 是一个 4 字节大小的类型,在栈中它的值由 4 个连续的字节构成,而 String 是一个指针,在栈中它的值由 24 个连续的字节构成(我们会在后文详细探讨),那 str 又是什么呢,它在栈中或是堆中又是以什么样的姿态所存在的呢,这困扰了我很久。凡是入门过 rust 的一般都知道我们在实际写代码时几乎用不到 str,用的最多的是 &str。有段时间我就一直在想,既然如此,为什么就不能将我们现在理解的 &str 当成是 str 呢,为什么非要整出一个 str 这层抽象出来呢?

首先,str 不是 rust 中唯一的 DST,除此之外还有 dyn Trait[T] 等。dyn Trait 相对比较复杂我们暂且不谈,这个 [T] 在我看来和 str 比较相似。[T] 表示的是一段 T 类型的序列,具体长度不知(可以对比数组的定义看下,数组 [T; 2] 表示的是 2 个 T 组成的序列)。而 str 其实和 [u8] 比较相似,因为 str 本质也是表示一段 u8 序列,只是附带了额外的要求,那就是这些 u8 序列必须是合法的 utf-8 编码的字节序列。抛开这个限制不谈,str 本质上其实就是 [u8]

另外考虑到不定长这个概念,我们就会想到 Vec。从系统编程角度来看,Vec 本质是一个指针,所以 Vec 变量是定长的,可以在栈上存储;但是从业务角度来看,Vec 表示一组可变长度的具有相同类型的数据序列。其实现的原理也很简单,那就是栈上存指针 pointer 和长度信息 len,pointer 指向堆内的某个位置,此时该 Vec 就表示从堆内 pointer 位置开始取 len 长度的字节所构成的数据序列。

我们都知道,类型在运行时可以映射为内存中的某一个或一段字节序列,比如 u8 可以映射为内存中的 1 个字节, Vec 则可以映射为内存中的 24 个字节,但是且慢,如果 Vec 只是单纯的映射为内存中的 24 个字节,那它指向堆中的那一段字节序列又算什么呢?该用什么类型来描述这段字节序列呢?

答案就是 [T]

对比来看,u8 可以映射为内存中的 1 个字节; i32 可以映射为内存中的 4 个字节; Vec 可以映射为内存中的 24 个字节; [T] 可以映射为内存中不定长度的可以组成若干个 T 类型数据的字节; [u8] 可以映射为内存中不定长度的可以组成若干个 u8 类型数据的字节; str 可以映射为内存中不定长度的可以组成若干个 utf-8 编码字符的字节。

所以相比于 String 这个指针类型来说,str 才是更基本的字符串类型。这也是为什么官网是这么介绍 str 的:

The str type, also called a ‘string slice’, is the most primitive string type. It is usually seen in its borrowed form, &str. It is also the type of string literals, &'static str.

str 类型,也称为字符串切片,是最基本的字符串类型。它通常以借来的形式出现。它也是字符串字面值的类型, &'static str

&str 从形式上看表示的是引用的概念,但却不同于一般的引用。像 &String, &u8, &i32 这些也都是引用,它们的大小都是 8 个字节(64位系统),代表实际数据所在的内存地址。而 &str 却是 16 个字节,除了 8 个字节代表其指向的第一个字节的地址之外,还有 8 个字节代表其所涵盖的字节长度(所以 &str 又被称为胖指针,rust 中引用也是指针的一种)。

说完 str 我们再来聊聊 String

我们在前文说过 String 的大小是 24 个字节,大家可能会有疑问,String 不是可以动态改变大小的吗,怎么会是 24 个字节呢。那是因为 String 在 rust 中本质上是一个智能指针,而指针当然是有固定大小的了,为什么是 24 个字节呢?我们可以看下 String 的定义:


#![allow(unused)]
fn main() {
pub struct String {
    vec: Vec<u8>,
}

pub struct Vec<T, A: Allocator = Global> {
    buf: RawVec<T, A>,
    len: usize,
}

pub struct RawVec<T, A: Allocator = Global> {
    ptr: Unique<T>,
    cap: usize,
    alloc: A,
}

pub struct Unique<T: ?Sized> {
    pointer: *const T,
    _marker: PhantomData<T>,
}

pub struct Global;
pub struct PhantomData<T: ?Sized>;
}

我们不难看出,在 64 位系统中:

sizeof(String) 
	= sizeof(Vec<u8>)
	= sizeof(RawVec<u8, Global>) + sizeof(usize)
	= sizeof(Unique<u8>) + sizeof(usize) + sizeof(Global) + 8 Bytes
	= sizeof(*const u8) + sizeof(PhantomData<u8>) + 8 Bytes + sizeof(Global) + 8 Bytes
	= 8 Bytes + 0 Bytes + 8 Bytes + 0 Bytes + 8 Bytes
	= 24 Bytes

除去零大小的 PhantomDataGlobal 之外,我们可以看出 String 本质上由三个类型构成:

  • len: usize -> 表示 String 所代表的字节序列长度
  • pointer: *const u8, -> 表示 String 所指向的堆内存地址
  • cap: usize -> 表示 String 的当前容量

总结一下:

  • str 表示一段合法的 utf-8 序列,String 表示存储在堆上的一段可以动态扩容和收缩的 utf-8 序列;
  • str 是动态大小类型,编译期无法确定大小,因此通常通过 &str 使用;
  • &str 16 (8 * 2) 字节大小,包含一个裸指针和一个长度;
  • String 24 (8 * 3) 字节大小,包含一个裸指针,一个长度和一个容量。
  • String&str 构成比较相似,String&str 多了一个容量的字段,也就拥有了自动缩扩容的能力,是一个智能指针。
  • String 拥有所指向字节的所有权,&str 则是对所指向字节的借用,一般需要配合生命周期一起使用。