Rust是如何在内存中储存字符串的?

什么是字符串?

Rust的核心语言中只有一种字串类型:str,字符串slice,它通常以被借用的形式出现,&str。我们了解到字符串slice:它们是一些储存在别处的utf-8编码字符串数据的引用。比如字符串字面值被储存在程序的二进制输出中,字符串slice也是如此。

称作String的类型是由标准库提供的,而没有写进核心语言部分,它是可增长的、可变的、有所有权的、utf-8编码和字符串类型。当Rustacean们谈到Rust的“字符串”时,它们通常指的是String和字符串slice &str类型,而不仅仅是其中之一。虽然本部分内容大多是关于String的,不过这两个类型在Rust标准库中都被广泛使用,String和字符串slice都是utf-8编码的。

Rust标准库中还包含一系列其它字符串类型,比如OsString、OsStr、CString和CStr。相关库crate even会提供更多储存字符串数据的选择。看到这些由String或是Str结尾的名字了吗?这对应着它们提供的所有权和可借用的字符串变体,就像是你之前看到的String和str。举例而言,这些字符串类型能够以不同的编码,或者内存列表形式上以不同的形式,来存储文本内容。

新建字符串

很多Vec可用的操作在String中同样可用,从以new函数创建字符串开始,如下所示:

let mut s = String::new();

这新建了一个叫做s的空的字符串,接着我们可以向其中装载数据。通常字符串会有初始数据,因为我们希望一开始就有这个字符串。为此,可以使用to_string方法,它能用于作何实现了Display trait的类型,字符串字面值也实现了它。

let mut s = String::new();
let data = "data";
let s = data.to_string();
//该方法也可直接用于字符串字面值
let s = "data".to_string();

也可以使用String::from函数来从字符串字面值创建String。等同于使用to_string。

let s = String::from("hi");

记住字符串是utf-8编码的,所以可以包含任何可以正确编码的数据,如下所示:

    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שָׁלוֹם");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");

更新字符串

String的大小可以增加,其内容也可以改变,就像可以放入更多数据来改变Vec的内容一样。另外,可以方便的使用+运算符或format!宏来拼接String值。

使用push_str和push附加字符串

可以通过push_str方法来附加字符串slice,从而使String变长,如下所示:

 

let mut s = String::from("hi");
s.push_str("aa");

 

push_str方法采用字符串slice,因为我们并不需要获取参数的所有权。如下展示了如果将s2的内容附加到s1后,自身不能被使用就糟糕了:

    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {}", s2);

如果push_str方法获取s2的所有权,就不能打印出s2的值了。

push方法被定义为获取一个单独的字符作为参数,并附加到String中。

使用+运算符或format!宏拼接字符串

通常你会希望将已知的字符串合并在一起。一种办法是像这样使用+运算符,如下:

 

    let s1 = String::from("hello ");
    let s2 = String::from("world.");
    let s3 = s1 + &s2;//注意,s1被移动了,不能再使用
    println!("s3 is {}",s3);

 

执行完这些代码后,字符串s3将会包含hello world。s1在相加后不再有效的原因,和使用s2的引用的原因,与使用+运算符时调用的函数签名有关。+ 运算符使用了add函数,这个函数签名看起来像这样:

fn add(self, s:&str) -> String {

这并不是标准库中实际的签名;标准库中的add使用泛型定义。这里我们看到的add的签名使用具体类型代替了泛型,这也正是当使用String值调用这个方法会发生的。

首先,s2使用了&,意味着我们使用第二个字符串的引用与第一个字符串相加。这是因为add函数的s参数:只能将&str和String相加,不能将两个String值相加。不过等一下,正如add的第二个参数所指定的,&s2的类型是&String而不是&Str。

那么为什么 s1 + &s2能编译呢? 之所以能够在add调用中使用&s2是因为&String可以被强转(coerced)成&str。当add函数被调用时,Rust使用了一个被称为Deref强制转换(deref coercion)的技术,你可以将其理为它把&s2变成了 &s2[..]。因为add没有获取参数的所有权,所以s2在这个操作后仍然是有效的String。

其次,可以发现签名中add获取了self的所有权,因为self没有使用&。这就意味着s1的所有权将被移动到add调用中,之后就不再有效。所以虽然 let s3 = s1+&s2;看起来就像它会复制两个字符串并创建一个新的字符串,而实际上这个语句会获取s1的所有权,附加上从s2中copy的内容,并返回结果的所有权。换句话说,它看起来好像生成了很多copy,不过实际上并没有:这个实现比copy要更高效。

对于更为复杂的字符串链接,可以使用format!宏:

 

    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");
    let s = format!("{}-{}-{}", s1,s2,s3);

 

这些代码会将s设置为"tic-tac-toe"。format!与println!的工作原理相同,不过不同于将输出打印到屏幕上,它返回一个带有结果内容的String。并且不会获取任何参数的所有权。

索引字符串 

在很多语言中,通过索引来引用字符串中的单独字符是有效且常见的操作。然而在Rust中,如果尝试使用索引语法访问String的一部分,会出现一个错误。如下例子:

    let s = String::from("hello");
    let h = s[0];

以上代码会导致如下错误:

error[E0277]: the type `String` cannot be indexed by `{integer}`
   --> src/main.rs:202:13
    |
202 |     let h = s[0];
    |             ^^^^ `String` cannot be indexed by `{integer}`
    |
    = help: the trait `Index<{integer}>` is not implemented for `String`

错误和提示说明了全部问题:Rust的字符串不支持索引。那为什么不支持呢?为了回答这个问题,我们必顺先聊一聊Rust是如何在内存中储存字符串的。

内部表现

String是一个Vec<u8>的封装。让我们看看一些正确编码的字符串的例子:

let s = String::from("Hola");
    let len = s.len();
    println!("s len is {}", len);

在这里,len的值是4,这意味着储存字符串“Hola”的Vec的长度是4个字节:这里每一个字母的utf-8编码都占用一个字节。那我们再看一个例子:

    let len = String::from("Здравствуйте").len();
    println!("s len is {}", len);

很多人可能说len的值为12。然而,Rust的回答是24.。这是使用utf-8编码"Здравствуйте"所需要的字节数,这是因为每个Unicode标量值需要两个字节储存。因为一个字符串字节值的索引并不总是对应一个有效的Unicode标量值。

即便&"hello"[0]是返回字节值的有效代码,它应返回104而不是h。为了避免返回意外的值并造成不能立刻发现的bug,Rust根本不会编译这些代码,并在开发过程中及早杜绝了误会的发生。

还有一个原因是Rust不允许使用索引获取String字符的原因是,索引操作预期总是需要常数时间O(1)。但是对于String不可能保证这样的性能,因为Rust必顺从开头到索引位置遍历来确定有多少有效的字符。

字节、标量值和字形簇

这引起了关于utf-8的另外一个问题:从Rust的角度来讲,事实上有三种相关方式可以理解字符串:字节、标量值和字形簇(最接近人们眼中字母的概念)。

字符串slice

索引字符串通常是一个坏点子,因为字符串索引应该返回的类型是不明确的:字节值、字符、字形簇或者字符串slice。因此,如果你真的希望使用索引创建字符串slice时,Rust会要求你更明确一些。为了更明确索引并表明你需要一个字符串slice,相比使用[]和单个值的索引,可以使用[]和一个range来创建含特定字节的字符串slice:

 

    let s2 = "Здравствуйте";
    let answer = &s2[0..4];
    println!("answer:{}", answer);

 

这里answer会是一个&str,它包含字符串的头4个字节。我们知道这些字母都是两个字节长的,所以这意味着s将会是"Зд"。

如果获取&s2[0..1]会发生什么呢?答案是:Rust在运行时会panic,就跟访问vector中的无效索引时一样:

 

thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`',

 

你应该小心谨慎的使用这个操作,因为这么做可能会使你的程序崩溃。

遍历字符串的方法

还好还有其它获取字符串元素的方式,如果你需要操作单独的Unicode标量值,最好的选择是使用chars方法。对“नमस्ते”调用chars方法会将其分开并返回6个char类型的值,接着就可以遍历其结果来访问每一个元素了:

 

    for c in "नमस्ते".chars(){
        println!("c is {}:", c);
    }

 

这些代码会打印出如下内容:

c is न:
c is म:
c is स:
c is ्:
c is त:
c is े:

bytes方法返回每一个原始字节,这可能会适合你的使用场景:

    for b in "नमस्ते".bytes(){
        println!("{}",b);
    }

以上代码会打印机组成String的18个字节:

224
164
168
224
164
174
224
164
184
224
165
141
224
164
164
224
165
135

不过请记住有效的Unicode标量值可能会由不止一个字节组成。

从字符串中获取字形簇是很复杂的,所以标准库并没有提供这个功能。crates.io上有些提供这样功能的crate。

 

posted @ 2021-03-07 21:40  johnny_zhao  阅读(576)  评论(0编辑  收藏  举报