0%

为什么 Rust 里的字符串很难

这是一篇国外的 帖子的翻译。顺便强化一下自己的记忆。

前言

有不少的同学在学 Rust 的过程中被 Rust 的 字符串相关 (&strString) 内容噎的够呛。字符串的相关内容,确实是除了Rust 的所有权外,另一个让人望而生畏的知识点。

因为对比来看,在其他大多数的语言中,程序员处理字符串都是非常容易的,可以把字符串拼接到一起,或者分割开,怎么折腾都行,都不会出错,用起来就像是原始类型。

如果你只有这类语言的开发经验,碰巧 C语言 或者 C++的开发经验也不多,那么让你操作一下 Rust 里的字符串很可能是你学习开发语言经历中的一次当头棒喝。

“怎么回事,字符串怎么能这么复杂,操作起来咋这么多限制,咋需要这么多步骤,看起来就像脱裤子放…”

如果你想做一些低层次编程,但是从没有低层次编程的经验,用 Rust准没错!用途广,对开发者友好。可是如果你真的没有 C 语言或者 C++开发背景,那么用 Rust 的过程中,遇到的挑战也不少。

“我劝你回去学一遍 C++,本文完。”。等一哈,我可不会这么说,我要深入探讨一下这个最常见的问题,还会给出一些解释,最好能让各位同学读完之后能觉得,“果然Rust 友好了一点了呢”。

闲话少叙,这闲话…….

那么,字符串到底是个啥?

string 还不简单吗?就是个基本类型呗,和数值啊布尔类型啥的都差不多,能通过字面量创建,比如把”张三“赋值给一个字符串变量,再传个值,再复个制,再拼接个字符串,何止,再拼个数值拼个布尔,再从方法里返回,咋整都行。

Rust 的字符串真不这样的。

大多数程序都会用到字符串,这给了人们一种错觉,字符串是基本类型吧。还真不是!

真正的基本类型与字符串相比,一个特别明显的区别就是,基本类型有固定大小,这在 Rust 中很重要。在 Rust 中,两个相同类型的数值(比如 f64,不熟悉的话就当成 Java 的 double 类型)会占用相同的内存大小。这意味着我们可以为变量或函数参数留出空间,任何可能的f64类型都能放得下。不管做什么样的数学运算,也不管这个数是非常大,还是非常小,都放得下。

然而,字符串并没有固定大小。比如,有一个字符串 A和字符串 B,他们内容不相同(”foo“和”foo“是相同的),那么很大可能他们的大小也是不同的。”foofoo“的大小是”foo“的两倍大。

注意:
严格来讲会有一些额外的数据,比如长度,所以大小并非非常精确的两倍大。不过还是很接近的,足够表达我想说的。

这样问题来了,语言的底层实现里,在编译期,扁平的数据结构必须要有已知的大小,这即便在高级语言中也不例外。任何数据,如果在编译期不能确定大小,或者在运行期大小会动态改变,那么这个数据需要存放在堆区,在运行期才动态分配空间。这一点在大多数语言中被隐藏了,比如 JavaScript。

注意:
堆区就是一个大的内存桶,你的代码可以向系统借用里面的内存空间。假设你的程序说:”我需要 X 字节的空间,我想干点啥“,系统说:”好勒,您的X 字节的空间来了“。过一会你的程序又说:”X 字节有点不够用,其实我需要 Y 字节空间“,一个新的内存空间会被划过来,把刚刚旧的全部内容复制进新的里,再把旧的空间归还给系统。RustVec 其实就是这么工作的!JavaScript 的数组也是这么工作的,并且等会我们就会发现,RustString 也是这么工作的。

那么数值这类的类型为什么不需要这么做呢?因为编译器可以知道数值的内存大小,并且知道数值的内存大小永远不会改变,所以根本不需要向堆区申请空间。

所以我希望大家记住,字符串是一种数据结构,是存放在堆区的数据结构,而不是原始类型。字符串基本上可以算作是字符数组(在 C语言中可真就是字符数组)。当你把两个字符串加到一起时,程序无法预期结果所需要的空间有多大,所以需要在运行期,从堆区申请空间来存放结果。当你把一个字符串传给函数时,实际上传递的是一个指针,这个指针指向了放在堆区里的数组,其他代码可以通过这个指针找到堆区里的内容。而指针本身,就像数值类型一样,有固定的大小。

”但是字符串特别常用啊!“你可能会想。”我们经常会把字符串分割,拼接。要是把字符串当成数组来用的话,你还是杀了我吧,而且一旦长度发生变化了,还得手动分配空间,复制,取消之前的分配空间?“

你说对了!

大多数语言是如何处理字符串的

这里的”大多数语言“我指的是目前比较常见的使用的比较多的语言。例如 JavaScript,Python,Java,C#,Go,Kotlin,Swift 等等。在这些语言中,字符串是不可变的。你可以拿一个不同的字符串放入这个字符串位(或者字符串槽),但是你不能改变字符串本身的内容。新的字符串替换掉了旧的,旧的字符串随风而逝,被扫地大妈一波带走。(要是还有其他代码在使用它,它就不会被带走)。

小贴士
垃圾回收是一种语言特性,系统可以自动的找出某些被借出的内存空间已经不再被使用了,然后把他们回收到堆区,以便之后再分配给别人使用。

这会带来两个影响:

  1. 这会让人感觉字符串就是一个原始类型:这些语言里的数值或者布尔就是不可变的,就像是数值类型一样,操作数值类型时,你会把一个新的数值放入数值位(数值槽)里,而不是改变数值本身。
  2. 这在很多方面操作字符串变得非常简单。就像使用数值一样,可以把字符串放心大胆的传给一个函数,甚至传给一个线程,完全不用担心他们会如何修改这个字符串,而接收这个字符串的一方也完全不用担心这个字符串会被传递者修改。这一切都可以和这些语言所有的垃圾回收机制完美的契合。

    小贴士
    Ruby 语言是个例外,我对他还不太熟。尽管Ruby是一门带有垃圾回收的高级语言,但他的字符串是可变的。

如果你是一个 C,C++,Rust 的初学者,以上可能就是你对字符串的理解。这种对字符串理解方式很可能已经深入骨髓了,改变需要很费一番力气。

C++和 Rust 是如何处理字符串的

那么C++和 Rust 是如何处理字符串的呢?这里有两个消息,你想先听哪个?

我先说好消息吧,字符串其实并非仅是字符数组。这两种语言都用了数据结构包装了字符串数组,让一些字符串操作变容易了,比如说把一个字符串拼接到另一个字符串,不需要手动的控制长度或者重新分配空间。这些操作都会被自动完成。

而坏消息呢(是否是坏消息取决于你看问题的角度),和上面的其他语言相比,Rust 并没有把字符串的细节隐藏的太多。你必须把字符串看做数据结构体,而不是原始类型。Rust 里,String结构体和字符型的 Vec(相当于 Python 里的list,或者 Java 里的ArrayList,或者 JavaScript 里的数组) 非常相似,并且附带了一些额外的功能。跟Vec一样,你能在结尾添加字符或移除字符,还可以改变中间的字符。其他引用了相同String的代码也会看到内容被变化了。你必须显示的创建String(通常是使用String::new()或者String::from()),也可能是可变的,超出作用域之后也会被回收。

为什么要这么做呢?为什么不能像其他语言一样简单一点呢?

因为要控制。C++和 Rust的设计目标是可以细粒度的进行控制。比如有时候你能想重用一个已经存在的String,替换掉里面的内容,或者在之前的内容上添加一些内容,而不是在+操作之后拿一个全新分配的内存替换掉它。当然提供这种程度的控制就意味着更复杂的认知模型。

&str又是个啥?

String是最接近其他语言中字符串概念的Rust 概念,但是当你敲出一行字符串字面量时,你得到的类型是&str:

1
let foo: &str = "What the heck?";

&str是字符串切片。我们上面提过字符串是可以看做是一个字符数组,那么&str就是对字符数组一个片段的引用(有指针,有长度)。如果是一个可变的切片(&mut str),那么可以改变其内容,但不能改变其长度。一个字符串切片总是代表内存中相同的一串字节,你不能给它增加或者删除字节,因为它并不知道在自己的空间不够的时候如何去重新分配自己的空间。它甚至都不知道这些字节是否是在堆区中。它仅仅就是个引用而已。

理解 Rust 中字符串的关键是,时刻谨记:每个&str都需要存放在某处。通常它存放在String当中(我们可以调用String.as_str()方法获取&str),但是当你的代码里包含像“What the heck?”的字符串字面量时,这个字符串指向的是你的程序的一部分。你的程序代码里包含这个字符数组,因此&str可以直接指向它。但是它不能变大或者缩小空间(你甚至都不能修改它的内容,根本拿不到它的&mut str),因为它的周围还有其他的东西跟它紧挨着。

综上所述

大多数刚开始使用 Rust 里的字符串的同学撞到的第一堵墙,应该是下面这样。

1
2
3
4
let a: &str = "hello ";
let b: &str = "world";

let c = a + b;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
error[E0369]: cannot add `&str` to `&str`
错误[E0369]: `&str`不能和`&str`相加
--> src/main.rs:9:15
|
9 | let c = a + b;
| - ^ - &str
| | |
| | `+` cannot be used to concatenate two `&str` strings
| | `+` 不能连接 2 个`&str`字符串
| &str
|
help: `to_owned()` can be used to create an owned `String` from a string
reference. String concatenation appends the string on the right to the string
on the left and may require reallocation. This requires ownership of the string
on the left
帮助: 调用`to_owned()` 可以从字符串引用生成一个带有所有权的`String`。
字符串连接:将右边的字符串拼接到左边的字符串里,需要重新分配内存。
要求左边的字符串必须有所有权。
|
9 | let c = a.to_owned() + b;
| ~~~~~~~~~~~~

“什么鬼?我把两个字符串相加还要先调用一个方法?这语言弱爆了!”

先别急,回顾一下我们刚刚提到的:ab都存放在我们的程序代码里,而程序代码是不能改变的。所以如果我们要把它们相加,那么新的字符串应该放在哪呢?

是的,它必须存在于也必须存在于一个String当中。语言无法预先知道它的大小会是多少,所以我们需要给它安排一个动态的空间来适应它的大小需求。

我们怎么获得这个String呢?我们有若干种方式!我们可以单独的创建一个:

1
2
3
4
5
6
7
8
9
let a: &str = "hello ";
let b: &str = "world";

let mut new_string: String = String::new();

new_string.push_str(a);
new_string.push_str(b);

let c = new_string.as_str();

这个方式显得有点笨拙。幸运的是,Rust 还提供了一些方便的方式。Rust 里,+操作符如果左边是个String,右边是个&str,它会调用左边String.push_str()方法,让它可变,并拥有&str的内容的副本,最后这个String就是这个加法操作的结果。所以下面这个方式可以达到效果:

1
let c = String::from("hello ") + "world";  // c is a String, not a &str!

跟下面这个方式是等效的:

1
2
3
let mut new_string: String = String::from("hello ");
new_string.push_str("world");
let c = new_string;

译者注:其实看String对操作符+的重载可以看到其实内部就是这么实现的

1
2
3
4
5
6
7
8
9
10
#[cfg(not(no_global_oom_handling))]
#[stable(feature = "rust1", since = "1.0.0")]
impl Add<&str> for String {
type Output = String;
#[inline]
fn add(mut self, other: &str) -> String {
self.push_str(other);
self
}
}

好了,最后一件事:刚刚的错误提示里,.to_owned()是什么呢?.to_owned()是 Rust 里一些数据类型实现的方法,就是用来处理这种情况的。我们来看一下文档:

[ToOwned 是]对借来的数据进行克隆的一种泛型。
有些类型通过实现 Clone trait,可以从借用变成拥有所有权。但是 Clone 仅适用于从&T 到 T。而 ToOwned trait 将 Clone 泛化到让指定类型从借用变成拥有所有权。

说的有点难懂,这段话的要点就是:当你有一个非可变引用时,你还要让它可变(我们做的就是这样的,需要把“world”放到一个地方),调用这个方法会得到一个可变的副本。在&str的时候,它的副本就是String。所以,就有:

1
let foo: String = "some &str".to_owned();

当你有 2 个&str要拼接到一起,得到一个String的话,这可能是最简洁的方式,也就是编译器建议你使用的方式:

1
let foo: String = "hello ".to_owned() + "world";

总结

呼~~,这真的比我想的要长的多。

Rust 是一种很特别的强大的语言,它的独特性体现在很多方面,尽管也都伴随着一些挑战。因为 Rust,大量的程序员开始进行低层级的代码开发,而没有被 C 和 C++劝退。这真是太赞了。

但是提高Rust 的开发效率就意味着要知道它到底是怎么工作的,否则你会一直和编译器斗争(这也比不停地编写 bug 要好)。希望我今天帮大家解决了一个痛点。

总结:每一个字符串内容都要存放在某个位置,这个位置可能是引号里的字符串字面量,可能是堆区的String里,或者其他地方。操作字符串时,你得弄明白字符串内容存放在哪,或者将会存放在哪。如果没有地方放,那么代码就不能编译。如果一个引用或者切片生存期超过了它指向的字符串内容(借用是另外的主题),代码也不能编译。时刻谨记“在哪”,会让你的代码更容易的运行起来。

祝你 Rust 愉快。

欢迎关注我的其它发布渠道