本文为译文, 原文地址为: Exploring Strings in Rust
前言
“新锈”们常会对 Rust 中的 String 感到困惑, 这篇文章能够帮助理解它们.
在我们开始理解计算机如何存储和解释字符序列之前, 让我们回顾一些基本概念
- 如今的电脑在序列中使用 bytes ( 8 bit )进行存储
- bytes 意味着任何东西, 我们(程序员?)碰巧在某些领域达成共识并赋予它们意义.有这项能力, 我们也同时能解释这些字符. 人们约定了一个表格去映射哪个 bytes 对应哪个字符. 详见 ASCⅡ 以及 Unicode 表. ASCⅡ 表较小, 只需要一个 byte 就能进行映射全部字符, 但是 Unicode 表需要 4bytes 进行定义全部字符. 详细了解可以查看这个.Unicode是张大表, 涵盖了大量字符, 但仍有空余的空间
- 字符串是我们遵守字符映射的 byte 序列. 我们通过语言的类型系统进行遵守约定. Rust 通过简单调用一块内存来实现 String 或 str 或 &str 或 &String 或 Box<str> 或 Box<&str> 或..
一点 C 和 JS
从我所见的情况, Rust 的”新锈”总是来源于 Javascript 或者 C 和 C++. 在深入 Rust 中字符串之前, 先来看看这些语言中是怎么处理 String 是值得一提的.
C
1 | // 在栈中创建一个不可修改的数组 |
注意第三个示例中数组的最后一位使用的是‘\0’
这是一种在事先不知道序列长度的情况下, 在计算机内存中保存字符序列时的一种方式.那个字符称为终止符. 字符串类型使用这个作为终止符号(一个 null byte )称为: 空终止字符串(null terminated strings)
在下列的场景中派上了用场1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
int main() {
// 一个指向在 data section 上分配不可变 string 的指针
// A pointer to an immutable string allocated on data section
const char *string_literal = "banana";
// 一个在堆上分配可变 string 的指针
// A pointer to a mutable string allocated on heap
char *heap_string = (char*) malloc(7*sizeof(char));
// "strcpy" 怎么得知指针 "string_literal" 中的 "banana" 长度呢
// How would "strcpy" know the length of "banana" which is the pointee
// of "const char *string_literal"?
// 它将从指针所指的位置遍历内存并记录数量直到遇到'\0'后退出循环计数
// It will start walking the memory from
// where the pointer points to and count every step.
// When it matches the '\0' character (a null byte) it will break the loop!
// Check the implementation of strcpy after the example.
strcpy(heap_string, string_literal);
//..
// 不要忘记清理分配的堆内存
// Don't forget to free your heap allocations when you're done with them,
// 因为使用的是 C 所以你要自己清理你的内存
// since you're writing C, you need to clean your own mess ;)
free(heap_string);
return 0;
}
strcpy在glibc的实现使用strlen.看看实现者如何在 strlen 的实现中尝试匹配 null byte 以获得字符串的长度
但是所有的字符串的实现都必须用 null-terminated 结尾吗? 这是获得字符串长度的唯一方法吗?
JavaScript
让我们看看 JavaScript 的情况1
2
3let string = "banAna".toLowerCase();
let concatinatedString = "I want a cherry " + string;
let shout = concatinatedString.toUpperCase() + "!!";
JavaScript 引擎在哪里存储这个字符串? 且我们该如何在这个抽象层面自由使用?
字符串是 JavaScript 的原生类型, 不过你是否注意到我们直接在字符串文字上使用toLowCase方法?
我们能直接使用这个方法的原因是因为 JavaScript 引擎创建了一个 String 对象, 并且立刻调用 toLowCase 方法并返回新的实例对象. 临时对象将被丢弃.
你也可以显式的告诉引擎创建对象1
let string = new String("banana");
如果你想要深入, 有个著名引擎 V8 实现的 String Class 这个类有着许多有用方法, 其中 length 是返回字符串的长度方法.
由于引擎负责解释 JS 代码并在必要时在运行时包装文字,因此还在字符串的包装类的实例中存储和更新字符串的长度。
这是一种无需 null-terminator 去标识, 在构造类型时就知道长度. 我没有去了解具体的实现细节, 但它是有效的.
正如你可能已经猜到的那样,以 null terminator 结尾的字符串并不是对字符串长度进行编码的唯一方法,而且 JS 也没有使用这种方式。
V8 或者其他的 JS 引擎的实现是非常复杂的, 主要是因为性能优化和必要的快速解释语句, 如果你有兴趣,可以参考这个链接
Rust 也没使用 null-terminator 去标识字符串的结尾. 但能够按需使用它(Rust also does not use null-terminated strings but is capable to work with them on demand. 不知道翻译的对不对). 将在文章的末尾给出例子.
回到Rust
我们可以通过前面的部分总结出以下信息.
- 字符串只是一个字节序列, 可以根据我们选择遵守共识(编码)以不同的方式解释。
- 要想利用字符串, 我们需要知道这个序列在内存中的开始结束地址
- 可以在该字节序列上构建简单或复杂的数据结构,以存储或派生有关它们的属性并添加功能以便更好地使用
这些理解能够帮助我们这篇文章Rust的部分
str
- C 语言没有应用任何编码, 只是纯字节序列并在结尾附上 null-terminator
- JavaScript 字符串使用 UTF-16 编码
- Rust 字符串使用 UTF-8 编码
我们常见的字符串类型是字符切片. 大多数情况下以 &str 或者相关生命周期形式.
&’static str 或 &’a str 但稍后会详细介绍
1 | let string: str = "banana"; |
当尝试运行时, 你会看到由于告警而无法编译!
错误可能是这样:
目前有一个不稳定的特性也许能够让这段代码编译。如果你有兴趣可以看看这个
所有的切片类型在Rust被归类为动态大小类型(DST)在Rust语法中, 这些类型被!Sized自动 trait 或者没有使用 Sized trait 实现.
编译器在编译时无法知道这些切片的字节大小, 也就无法进行分配.
str 定义了内存数据块中的一个切片, 这个切片被解析成字符序列. 不确定这个切片的存储位置和存在时间. 也是我们不能简单的直接使用这种类型. (Rust 1.58)
让我们尝试更直接些
Box<str>
我们可能会认为明确这些数据的存储位置就能解决这个问题.
为什么不使用 Box 呢? 在堆上分配它并获取指向它的指针.
1 | let string: Box<str> = Box::new(“banana”); |
好像也不太行…
似乎Box::new(“banana”)返回了一个Box<&str>替代了Box<str>, 或者我们可以使用解引用的方式进行获得Box<str>?
1 | let string: Box<str> = Box::new(*"banana"); |
我们无法获取动态大小类型的所有权, 因为我们不知道它们的范围. 解引用是想要获取引用指向数据的所有权.
还有另一种构造这种类型的方法。我们将在最后回到它并明确它存在的原因。
&str
&str是一种更加常见的类型
在Rust中也称为字符切片. 常与 str 混淆. 当我们在 Rust 中称”字符串切片时”, 几乎都是表示 &’str. 因为str是DST(动态大小类型)不能直接被使用. 在对话中使用”字符串的引用” 或者 “字符串切片的指针” 也不太方便(Because it would be inconvenient to use a longer phrase-like reference to a string slice or pointer to a string slice in a conversation.)
现在, 这个类型让编译器的工作变得更加简单,因为这个引用在编译期间总是知道它的大小. 共享引用(&)是具有特殊承诺的指针, 并且指向的数据总是不可变以及有效的.
在撰写本文时, 指针的长度(usize)通常是 8字节,因为常见的处理器都是 64 字节的, 你可以使用以下方式进行确认1
dbg!(std::mem::size_of::<&str>());
啊, 16 字节…, 这是指针大小的两倍!
这是由于,任何包含字符串的切片, 实际上存储的是一个胖指针.使用前 8 个字符字节存储它指向的切片的内存地址, 后 8 字节存储的是字符切片的长度.
可以在运行的时候,使用长度信息1
2
3let string: &str = "banana";
dbg!(string.len())
// Outputs: string.len() = 6
那么胖指针指向的区域是哪部分呢? 堆, 栈, 静态存储?
我们并不知道, 且在使用切片时并不在乎.一旦我们获得切片.我们唯一关心的是属性以及提供给我们的方法. 切片只是存储在任意位置底层数据的视图.
在字符串文字的情况下, 编译器将会硬编码成二进制!这样做是因为我们在编译期间已经知道了字符串的内容且字符串不可变.
就像下面展示的这样:
只是说明切片指针指向的数据可被分配在任何地方. 下列分别是分配在栈和堆中的例子:1
2
3
4
5
6
7
8let banana_bytes: &[u8] = &[0x62,0x61,0x6e,0x61,0x6e,0x61];
let heap_string: String = String::from("banana");
// Points to the stack
// Unwrapping is safe here because we feed the data directly.
// We know that it is valid data.
let string: &str = std::str::from_utf8(banana_bytes).unwrap();
// Points to the heap
let string: &str = &heap_string;
String
到目前为止, 最有用的字符类型是 &str.
除了前部分所说的内容. 如果字符串切片指向堆, 我们也可以获得可修改的引用 &mut str.
这有个常见的示例.
make_ascii_uppercase方法会在切片位置修改内容.1
2
3let mut string: String = "banana".to_owned();
let string_slice: &mut str = &mut string;
s.make_ascii_uppercase();
&str定义后就不可变. 即使在少数场景下,我们能获取可变的引用,并且对底层数据进行更改,但是我们无法扩展为它分配的内存块.换句话说, 它的大小不能增长或缩小.所以有时我们不选择使用字符串引用.
为了这个或者是未列出的其他目的, 我们将使用 String 类型.
例如. string 让下列的操作可行. 且更加的灵活:1
2
3
4let mut banana_string: String = String::from("banana");
let mut cherry_string: String = String::from("I want a cherry");
banana_string += " ";
cherry_string += &banana_string;
string类型含有大量的方法可供使用.
string 灵活扩张收缩的能力来源于堆分配的性质. 与 Vector十分相似.
假如你执行:1
dbg!(std::mem::size_of::<String>());
你会发现它比胖指针更”胖”了一个字节(ง︡’-’︠)
这是因为 String 是一个结构体而不是指针, 类型大小的增加的原因是因为 String 增加保存了容量信息.
1 | fn main() { |
当我们超过了容量, 是如何增长的: String 在原大小的基础上翻倍. 从而能够装入更多的数据.
实际上, String 类型在Rust源码看起来是这样的:1
2
3pub struct String {
vec: Vec<u8>,
}
我们也可以通过原始部分手动组装, 尽管在大多数情况下是没有必要的.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn main() {
unsafe {
// Assemble a string manually.
let bytes: &mut [u8] = &mut [0x62, 0x61, 0x6e, 0x61, 0x6e, 0x61];
// Data, length, capacity
let string: String = String::from_raw_parts(bytes.as_mut_ptr(), 6, 6);
// Break it to its raw parts
// Unstable in the time of writing
// https://github.com/rust-lang/rust/issues/65816
let (bytes, length, capacity) = string.into_raw_parts();
}
}
&String
在看了之前的内容, 这应该很好理解. 这只是 String 的指针.
像其他指针一样(usize), 在64位计算机中是 8 bytes.1
2
3
4dbg!(std::mem::size_of::<String>());
// 24 bytes
dbg!(std::mem::size_of::<&String>());
// 8 bytes
回到装箱后的 str
Box<str>
我们之前不能直接分配 str ,但通过 Box 我们可以在堆上分配, 如下列所示:1
2
3
4let string: Box<str> = String::from("banana").into_boxed_str();
// or
let string: Box<str> = Box::from("banana");
// from implementation will yield the same result.
into_boxed_str()
也许你会好奇, 我们不是有一个更强大的 String 类型, 已经能够在堆上进行存储了.
让我们比较下他们的大小差别1
2
3
4dbg!(std::mem::size_of::<Box<str>>());
// 16 bytes
dbg!(std::mem::size_of::<String>());
// 24 bytes
前者的大小缩短了 8 byte, 并且是一个胖指针. 在极少数的情况下, 它可能有一些优势. 可能只有一小部分人需要这个类型.
例如 Rust Interner 的实现
Rust 和 有空终止符的字符串
之前我们提到, Rust 默认不使用空终止符作为结尾.
相反, 它使用胖指针或堆分配的结构来直接存储长度信息.
如果我们愿意, 我们可以使用以空字符结尾的字符串. 这些在 FFI 上下文中使用 C 库时特别有用.
有两种类型实现了这个目的. std::ffi::CStr 和 std::ffi::CString
Rust 文档非常简明说明了这两种类型, 如果你愿意深入了解这两种类型CStr, CString.
结语
本文并不是列出了 Rust 中所有可能的字符串输入错误和用法的详尽列表.
希望通过本文的说明, 可以对 Rust 如何看待字符串有一个大致的直觉和感受, 并希望这能在您将来遇到更多 Rust 类型时更好的理解.
在发布这篇文章前, 再次确认某些知识时. 我在 StackOverflow 找到一个特别的主题, 如果对这个主题进一步感兴趣, 必看.