目录
- 所有权
- 基本概念
- 所有权介绍
- 栈与堆
- 变量作用域
- 字符串
- 字符串字面值(&str)
- String 类型
- 相互转换
- 所有权 + 内存结构对比
- 注意事项和常见坑
- 使用场景
- 内存与分配
- 变量与数据交互的方式(一):移动
- 变量与数据交互的方式(二):克隆
- 所有权与函数
- 引用
- 基本使用
- 可变引用
- 悬垂引用
- Slice 类型
所有权
所有权(系统)是 Rust 最为与众不同的特性,对语言的其他部分有着深刻含义。它让 Rust 无需垃圾回收(garbage collector)即可保障内存安全,因此理解 Rust 中所有权如何工作是十分重要的。
基本概念
所有权介绍
所有权(ownership)是 Rust 用于如何管理内存的一组规则。所有程序都必须管理其运行时使用计算机内存的方式。一些语言中具有垃圾回收机制,在程序运行时有规律地寻找不再使用的内存;在另一些语言中,程序员必须亲自分配和释放内存。Rust 则选择了第三种方式:通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查。如果违反了任何这些规则,程序都不能编译。在运行时,所有权系统的任何功能都不会减慢程序。
所有权规则:
- Rust 中的每一个值都有一个 所有者(owner)。
- 值在任一时刻有且只有一个所有者。
- 当所有者(变量)离开作用域,这个值将被丢弃。
栈与堆
在 Rust 中,**栈(Stack)与堆(Heap)**是内存管理的重要概念,理解它们对于掌握 Rust 的所有权(Ownership)、借用(Borrowing)和生命周期(Lifetime)机制非常关键。
栈(Stack):
-
特点:
- 后进先出(LIFO):像叠盘子,最后放上去的最先被取出。
- 分配速度快:因为只需要移动一个指针。
- 存储固定大小的数据:如基本数据类型(i32, bool)、元组((i32, bool))等。
-
在 Rust 中的表现:
fn main() {let x = 5; // x 被分配在栈上let y = true; // y 也在栈上 }
这里的 x 和 y 都是已知大小的基本类型,会直接分配在栈上。
堆(Heap):
-
特点:
- 动态分配内存:用于大小在编译时不确定的值(比如 Vec、String)。
- 分配慢于栈:需要操作操作系统请求内存。
- 需要手动释放(Rust 使用所有权机制自动释放,无需开发者手动调用 free)。
-
在 Rust 中的表现:
fn main() {let s = String::from("hello"); // String 的数据部分存在堆上 }
这里:s 是一个变量,保存在栈上,里面包含一个指向堆中实际字符串内容的指针。字符串 “hello” 的内容是动态分配的,存在堆上。
栈与堆的关系图解:
总结对比:
特性 | 栈(Stack) | 堆(Heap) |
---|---|---|
分配速度 | 非常快 | 较慢 |
管理方式 | 编译器自动 | Rust 自动通过所有权管理 |
数据大小 | 编译期已知 | 编译期未知,运行时决定 |
访问速度 | 快 | 慢 |
示例类型 | i32 , bool , char | String , Vec<T> , Box<T> |
变量作用域
Rust 中的变量作用域是理解程序生命周期、内存管理和所有权系统的一个重要概念。它决定了变量的有效范围、生命周期以及如何管理内存。
作用域(Scope)是指变量、函数、结构体等在程序中有效的范围。Rust 使用静态分析来确保变量在其作用域结束时被销毁,并且会在作用域结束时自动回收内存(这是 Rust 所独有的所有权机制的一部分)。
变量作用域的基础:
- 作用域定义:
变量通常在代码块(block)内定义,代码块是由{}
包围的区域。只要变量处于该块内,它就是有效的(也就是“可见”的)。 - 生命周期:
Rust 使用 所有权(ownership) 机制来确保每个变量只有一个有效的“所有者”,并且当该变量超出作用域时会被自动销毁。
fn main() {let x = 5; // 变量 x 在这里被声明并初始化println!("x is {}", x); // x 在作用域内有效
} // 这里 x 超出作用域,内存被释放
作用域规则:
- 变量
x
的作用域从它被创建的地方(let x = 5
)开始,一直到所在的代码块结束(在这里是main
函数的结束)。 - 当作用域结束时,变量
x
会被自动销毁,并释放相关内存。
嵌套作用域:
Rust 允许作用域嵌套,外层作用域的变量可以在内层作用域中使用,但内层作用域结束时,外层作用域的变量不会受到影响。
fn main() {let x = 5;{let y = 10; // y 的作用域在这个内部作用域内println!("x = {}, y = {}", x, y); // x 和 y 都有效} // y 超出作用域,被销毁println!("x = {}", x); // x 仍然有效,因为它在外层作用域,而 y 在内层作用域结束后被销毁
}
输出结果如下:
x = 5, y = 10
x = 5
字符串
字符串字面值(&str)
示例:
let s: &str = "hello";// let s = "hello";
特点:
&str
是字符串切片类型,表示某段 UTF-8 编码的字符串引用。- 字面值
"hello"
是 静态存在于程序的只读数据段 中。 "hello"
的类型实际上是&'static str
,它有'static
生命周期,即整个程序运行期间都有效。
内存结构:
&str = {指针:指向字符串首地址,长度:5(字节数)
}
特性:
特性 | 说明 |
---|---|
长度固定 | 不能动态添加字符 |
不可变 | 不支持修改内容 |
存储位置 | 静态分配的只读内存或堆中其他 String 的切片 |
无所有权 | 不能拥有资源,只是“借用” |
生命周期相关 | 经常和 &'static str 或函数参数一起使用 |
String 类型
示例:
let mut s: String = String::from("hello");
// let s = String::from("hello");
s.push_str(" world");
特点:
String
是拥有所有权的、可变的、堆分配的 UTF-8 字符串。- 支持动态增长和修改,常用于处理来自用户输入、文件等动态数据。
内部结构:
String = {指针:指向堆上数据,长度:已用字节数,容量:分配的总字节数
}
特性总结:
特性 | 说明 |
---|---|
可变 | 可以添加、替换、删除内容 |
拥有所有权 | 当变量离开作用域自动释放内存 |
堆分配 | 内容存储在堆中 |
灵活 | 适合处理动态、拼接的字符串 |
相互转换
&str → String
(借用到拥有):
let s1 = "hello"; // &str
let s2 = s1.to_string(); // String
let s3 = String::from(s1); // 等价写法
String → &str
(拥有到借用):
let s1 = String::from("hello");
let s2: &str = &s1; // 自动解引用为 &str
所有权 + 内存结构对比
维度 | &str | String |
---|---|---|
是否拥有数据 | 否(只借用) | 是(拥有) |
是否可变 | 否 | 是 |
存储位置 | 只读段或堆(借用) | 堆 |
生命周期 | 有生命周期限制 | 生命周期随变量作用域自动管理 |
内部结构 | 指针 + 长度(胖指针) | 指针 + 长度 + 容量(结构体) |
let s = "hello";
这里s
中的并不是"hello"
,而是指针指向"hello"
的地址值和值的长度,也就是说s
内部是一个胖指针而不是值的本身,String
也是同理,变量内部是一个结构体而不是值的本身。
注意事项和常见坑
字节边界问题:
let s = "你好"; // UTF-8 每个汉字占 3 字节
// println!("{}", &s[0..1]); // panic:不是合法 UTF-8 边界
println!("{}", &s[0..3]); // 输出:你
String
的按索引访问:
let s = String::from("hello");
// println!("{}", s[1]); // 编译错误,不能用索引访问 String
let ch = s.chars().nth(1); // 正确方式
使用场景
场景 | 推荐类型 |
---|---|
只读字面值,固定内容 | &str |
动态构建、修改字符串 | String |
需要拥有字符串(函数返回值) | String |
接收字符串参数 | &str (更通用) |
拼接多个字符串 | String |
&str
是借用的只读 UTF-8 字符串切片,适合轻量访问;String
是拥有堆内存的可变字符串,适合需要修改或管理生命周期的场景。
内存与分配
就字符串字面值来说,在编译时就知道其内容,所以文本被直接硬编码进最终的可执行文件中。这使得字符串字面值快速且高效。不过这些特性都只得益于字符串字面值的不可变性。不幸的是,不能为了每一个在编译时大小未知的文本而将一块内存放入二进制文件中,并且它的大小还可能随着程序运行而改变。
对于 String
类型,为了支持一个可变,可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容。这意味着:
- 必须在运行时向内存分配器(memory allocator)请求内存。
- 需要一个当我们处理完
String
时将内存返回给分配器的方法。
第一部分由开发者完成:当调用 String::from
时,它的实现请求其所需的内存。
第二部分,Rust 采取了一个策略:内存在拥有它的变量离开作用域后就被自动释放。当变量离开作用域,Rust 为我们调用一个特殊的函数,这个函数叫做 drop
,Rust 在结尾的 }
处自动调用 drop
。
变量与数据交互的方式(一):移动
在 Rust 中,多个变量可以采取不同的方式与同一数据进行交互。
let x = 5;
let y = x;
将 5 赋值给 x
,然后生成 x
的副本并将其拷贝赋值给 y
。现在 x
和 y
,都等于 5,因为整数是有已知固定大小的简单值,所以这两个 5 被放入了栈中。
类似的过程,放在 String
类型上结果完全不一样。
let s1 = String::from("hello");
let s2 = s1;
前面提到过 String
类型的变量内部是一个结构体而不是值的本身。
当 s1
赋值给 s2
,String
的数据被复制了,这意味着从栈上拷贝了它的指针、长度和容量。我们并没有复制指针指向的堆上数据。
之前提到过当变量离开作用域后,Rust 自动调用 drop
函数并清理变量的堆内存。不过图中展示了两个数据指针指向了同一位置。这就有了一个问题:当 s2
和 s1
离开作用域,它们都会尝试释放相同的内存。这是一个叫做二次释放的错误,也是之前提到过的内存安全性 bug 之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。
为了确保内存安全,在 let s2 = s1;
之后,Rust 认为 s1
不再有效,因此 Rust 不需要在 s1
离开作用域后清理任何东西。
将拷贝指针、长度和容量而不拷贝数据并使第一个变量无效的操作在 Rust 中称为移动,有一点类似于其他编程语言中的浅拷贝。这样就解决了二次释放的错误,因为只有 s2
是有效的,当其离开作用域,它就释放自己的内存。
Rust 永远也不会自动创建数据的 “深拷贝”。因此,任何 自动的复制都可以被认为是对运行时性能影响较小的。
变量与数据交互的方式(二):克隆
如果确实需要深度复制 String
中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone
的通用函数。
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {s1}, s2 = {s2}");
这就相当于把堆上的值也复制了一份给 s2
。
输出结果如下:
s1 = hello, s2 = hello
回到一开始讲解移动中的代码
let x = 5;
let y = x;
这段代码明明没有用 clone
是如何实现的数据拷贝?
Rust 有一个叫做 Copy
trait 的特殊注解,可以用在类似整型这样的存储在栈上的类型上。如果一个类型实现了 Copy
trait,那么一个旧的变量在将其赋值给其他变量后仍然可用。
Rust 中的
Copy
是一种轻量级、无资源所有权的按位拷贝,它只在栈上发生,不会去复制堆内存或执行任何逻辑代码(比如 Drop)。
Rust 不允许自身或其任何部分实现了 Drop
trait 的类型使用 Copy
trait。如下是一些 Copy
的类型:
- 所有整数类型,比如
u32
。 - 布尔类型,
bool
,它的值是true
和false
。 - 所有浮点数类型,比如
f64
。 - 字符类型,
char
。 - 元组,当且仅当其包含的类型也都实现
Copy
的时候。比如,(i32, i32)
实现了Copy
,但(i32, String)
就没有。
所有权与函数
将值传递给函数与给变量赋值的原理相似。向函数传递值可能会移动或者复制,就像赋值语句一样。
fn main() {let s = String::from("hello"); // s 进入作用域takes_ownership(s); // s 的值移动到函数里 ...// ... 所以到这里不再有效println!("{}", s);let x = 5; // x 进入作用域makes_copy(x); // x 应该移动函数里,// 但 i32 是 Copy 的,// 所以在后面可继续使用 x} // 这里,x 先移出了作用域,然后是 s。但因为 s 的值已被移走,// 没有特殊之处fn takes_ownership(some_string: String) { // some_string 进入作用域println!("{some_string}");
} // 这里,some_string 移出作用域并调用 `drop` 方法。// 占用的内存被释放fn makes_copy(some_integer: i32) { // some_integer 进入作用域println!("{some_integer}");
} // 这里,some_integer 移出作用域。没有特殊之处
该代码会产生以下错误:
error[E0382]: borrow of moved value: `s`--> src/main.rs:6:20|
2 | let s = String::from("hello"); // s 进入作用域| - move occurs because `s` has type `String`, which does not implement the `Copy` trait
3 |
4 | takes_ownership(s); // s 的值移动到函数里 ...| - value moved here
5 | // ... 所以到这里不再有效
6 | println!("{}", s);| ^ value borrowed here after move|
note: consider changing this parameter type in function `takes_ownership` to borrow instead if owning the value isn't necessary--> src/main.rs:17:33|
17 | fn takes_ownership(some_string: String) { // some_string 进入作用域| --------------- ^^^^^^ this parameter takes ownership of the value| || in this function= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable|
4 | takes_ownership(s.clone()); // s 的值移动到函数里 ...| ++++++++
这是由于 s
传给 takes_ownership
之后,所有权转移到 takes_ownership
里,随着 takes_ownership
的结束一起被释放了,导致后面无法继续使用 s
。
这种情况可以通过函数返回值转移所有权来解决。
fn main() {let s1 = gives_ownership(); // gives_ownership 将返回值// 转移给 s1let s2 = String::from("hello"); // s2 进入作用域let s3 = takes_and_gives_back(s2); // s2 被移动到// takes_and_gives_back 中,// 它也将返回值移给 s3
} // 这里,s3 移出作用域并被丢弃。s2 也移出作用域,但已被移走,// 所以什么也不会发生。s1 离开作用域并被丢弃fn gives_ownership() -> String { // gives_ownership 会将// 返回值移动给// 调用它的函数let some_string = String::from("yours"); // some_string 进入作用域。some_string // 返回 some_string // 并移出给调用的函数//
}// takes_and_gives_back 将传入字符串并返回该值
fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域// a_string // 返回 a_string 并移出给调用的函数
}
变量的所有权总是遵循相同的模式:将值赋给另一个变量时移动它。当持有堆中数据值的变量离开作用域时,其值将通过 drop
被清理掉,除非数据被移动为另一个变量所有。
虽然这样是可以的,但是在每一个函数中都获取所有权并接着返回所有权有些繁琐。幸运的是,Rust 对此提供了一个不用获取所有权就可以使用值的功能,叫做引用。
引用
基本使用
为了能在调用函数后仍能使用变量,就可以使用引用。引用像一个指针,因为它是一个地址,可以由此访问储存于该地址的属于其他变量的数据。 与指针不同,引用确保指向某个特定类型的有效值。使用符号 &
进行引用。
fn main() {let s1 = String::from("hello");let len = calculate_length(&s1);println!("The length of '{s1}' is {len}.");
}fn calculate_length(s: &String) -> usize {s.len()
}
引用的原理图如下:
&s1
语法创建一个 指向 值 s1
的引用,但是并不拥有它。因为并不拥有这个值,所以当引用停止使用时,它所指向的值也不会被丢弃。
同理,函数签名使用 &
来表明参数 s
的类型是一个引用。
变量 s
有效的作用域与函数参数的作用域一样,不过当 s
停止使用时并不丢弃引用指向的数据,因为 s
并没有所有权。当函数使用引用而不是实际值作为参数,无需返回值来交还所有权,因为就不曾拥有所有权。
输出结果如下:
The length of 'hello' is 5.
小提一下:与使用
&
引用相反的操作是 解引用,它使用解引用运算符,*
。
将创建一个引用的行为称为借用。正如现实生活中,如果一个人拥有某样东西,你可以从他那里借来。当你使用完后,必须还回去。因为你并不拥有它的所有权。
如果尝试修改借用的变量是行不通的。
fn main() {let s = String::from("hello");change(&s);
}fn change(some_string: &String) {some_string.push_str(", world");
}
会产生一个错误:
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference--> src/main.rs:8:5|
8 | some_string.push_str(", world");| ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable|
help: consider changing this to be a mutable reference|
7 | fn change(some_string: &mut String) {| +++
正如变量默认是不可变的,引用也一样。(默认)不允许修改引用的值。
可变引用
通过一个小调整就能修复上述代码中的错误,允许修改一个借用的值,这就是可变引用:
fn main() {let mut s = String::from("hello");change(&mut s);
}fn change(some_string: &mut String) {some_string.push_str(", world");
}
必须将 s
改为 mut
。然后在调用 change
函数的地方创建一个可变引用 &mut s
,并更新函数签名以接受一个可变引用 some_string: &mut String
。这就非常清楚地表明,change
函数将改变它所借用的值。
可变引用有一个很大的限制:如果你有一个对该变量的可变引用,你就不能再创建对该变量的引用。这些尝试创建两个 s
的可变引用的代码会失败:
fn main() {let mut s = String::from("hello");let r1 = &mut s;let r2 = &mut s;println!("{}, {}", r1, r2);
}
错误如下:
error[E0499]: cannot borrow `s` as mutable more than once at a time--> src/main.rs:5:14|
4 | let r1 = &mut s;| ------ first mutable borrow occurs here
5 | let r2 = &mut s;| ^^^^^^ second mutable borrow occurs here
6 |
7 | println!("{}, {}", r1, r2);| -- first borrow later used here
这个报错说这段代码是无效的,因为不能在同一时间多次将 s
作为可变变量借用。第一个可变的借入在 r1
中,并且必须持续到在 println!
中使用它,但是在那个可变引用的创建和它的使用之间,又尝试在 r2
中创建另一个可变引用,该引用借用与 r1
相同的数据。
这一限制以一种非常小心谨慎的方式允许可变性,防止同一时间对同一数据存在多个可变引用。这个限制的好处是 Rust 可以在编译时就避免数据竞争。数据竞争类似于竞态条件,它可由这三个行为造成:
- 两个或更多指针同时访问同一数据。
- 至少有一个指针被用来写入数据。
- 没有同步数据访问的机制。
数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复;Rust 避免了这种情况的发生,因为它甚至不会编译存在数据竞争的代码。
一如既往,可以使用大括号来创建一个新的作用域,以允许拥有多个可变引用,只是不能同时拥有:
fn main() {let mut s = String::from("hello");{let r1 = &mut s;} // r1 在这里离开了作用域,所以我们完全可以创建一个新的引用let r2 = &mut s;
}
Rust 在同时使用可变与不可变引用时也采用的类似的规则。这些代码会导致一个错误:
fn main() {let mut s = String::from("hello");let r1 = &s; // 没问题let r2 = &s; // 没问题let r3 = &mut s; // 大问题println!("{}, {}, and {}", r1, r2, r3);
}
错误如下:
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable--> src/main.rs:6:14|
4 | let r1 = &s; // 没问题| -- immutable borrow occurs here
5 | let r2 = &s; // 没问题
6 | let r3 = &mut s; // 大问题| ^^^^^^ mutable borrow occurs here
7 |
8 | println!("{}, {}, and {}", r1, r2, r3);| -- immutable borrow later used here
不可变引用的借用者可不希望在借用时值会突然发生改变!然而,多个不可变引用是可以的,因为没有哪个只能读取数据的引用者能够影响其他引用者读取到的数据。
注意一个引用的作用域从声明的地方开始一直持续到最后一次使用为止。例如,因为最后一次使用不可变引用(println!
),发生在声明可变引用之前,所以如下代码是可以编译的:
fn main() {let mut s = String::from("hello");let r1 = &s; // 没问题let r2 = &s; // 没问题println!("{r1} and {r2}");// 此位置之后 r1 和 r2 不再使用let r3 = &mut s; // 没问题println!("{r3}");
}
不可变引用 r1
和 r2
的作用域在 println!
最后一次使用之后结束,这也是创建可变引用 r3
的地方。因为它们的作用域没有重叠,所以代码是可以编译的。编译器可以在作用域结束之前判断不再使用的引用。
悬垂引用
在具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个悬垂指针,所谓悬垂指针是其指向的内存可能已经被分配给其它持有者。相比之下,在 Rust 中编译器确保引用永远也不会变成悬垂状态:当你拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。
这里尝试创建一个悬垂引用,Rust 会通过一个编译时错误来避免:
fn main() {let reference_to_nothing = dangle();
}fn dangle() -> &String {let s = String::from("hello");&s
}
错误如下:
error[E0106]: missing lifetime specifier--> src/main.rs:5:16|
5 | fn dangle() -> &String {| ^ expected named lifetime parameter|= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime, but this is uncommon unless you're returning a borrowed value from a `const` or a `static`|
5 | fn dangle() -> &'static String {| +++++++
help: instead, you are more likely to want to return an owned value|
5 - fn dangle() -> &String {
5 + fn dangle() -> String {|
因为 s
是在 dangle
函数内创建的,当 dangle
的代码执行完毕后,s
将被释放。当尝试返回它的引用时,意味着这个引用会指向一个无效的 String
,Rust 不会允许这么做。
这里的解决方法是直接返回 String
:
fn no_dangle() -> String {let s = String::from("hello");s
}
- 在任意给定时间,要么只能有一个可变引用,要么只能有多个不可变引用。
- 引用必须总是有效的。
Slice 类型
什么是 Slice 类型?
Slice 是对一块连续内存区域的借用,它本身不拥有数据,只是引用。比如:数组、字符串,都可以通过切片来引用一部分内容。
一句话理解:Slice = 指向连续元素的一段引用 + 长度信息。
let arr = [1, 2, 3, 4, 5];
let slice = &arr[1..4]; // 包括arr[1], arr[2], arr[3],不包括arr[4]
这里的 slice
是一个 &[i32]
类型的切片引用,指向数组的一部分。
为什么 Rust 要引入 Slice 类型(切片)?
- 为了在不复制数据的情况下,安全、灵活地访问连续内存的一部分。
- Slice 是一种只借用部分数据的机制,能零拷贝地、安全地处理数据片段。
Slice 类型有什么优势?
- 避免复制,提高性能
- 如果没有 Slice,每次想操作一部分数据(比如一个字符串的一小段),就要复制一份,非常浪费内存和时间。
- Slice 只借用原数据的一部分,不分配新内存,零拷贝。
- 安全访问内存
- Slice 包含长度信息,Rust 在访问时能自动检查边界,避免访问越界、野指针等内存错误(C 语言就容易出问题)。
- 通用性和灵活性
- Slice 设计得很通用,不管是
String
、Vec
、数组[T; N]
,都可以切成 Slice 来操作。 - 比如:
&[T]
是对数组或向量的借用,&str
是对字符串的借用。
- Slice 设计得很通用,不管是
- 支持共享只读和可变借用
&[T]
是不可变切片(只读访问);&mut [T]
是可变切片(可以修改内容)。
- Rust 借用检查器可以追踪
- Slice 是一个标准的借用,符合 Rust 的所有权和生命周期系统,可以被编译器静态检查,保证不会悬垂引用。
Slice 的基本使用:
fn main() {let arr = [10, 20, 30, 40, 50];let slice = &arr[1..4]; // 取arr[1], arr[2], arr[3]println!("slice: {:?}", slice); // 输出: slice: [20, 30, 40]for x in slice {println!("{}", x);}
}
slice
只是引用,不占用额外空间。切片里保存了两个信息:指针 + 长度。
切片语法:
&array[start..end] // 从start开始,到end前结束(左闭右开区间)
&array[..]
:整个数组,&array[start..]
:从start到末尾,&array[..end]
:从开头到end前,&array[start..=end]
:从start到end,包括end(注意用 ..=
)。
let arr = [1, 2, 3, 4, 5];// 从2开始到4前
let a = &arr[1..3]; // [2, 3]// 从0到2
let b = &arr[..2]; // [1, 2]// 从2到最后
let c = &arr[2..]; // [3, 4, 5]// 包括索引2到4
let d = &arr[2..=4]; // [3, 4, 5]
&[T]
是不可变切片,有时需要可变的情况,跟前面将不可变改为可变一样,加上 mut
关键字,例如:
fn main() {let mut arr = [1, 2, 3, 4, 5];// 不可变let slice = &arr[1..4];println!("{:?}", slice);// 可变let slice_mut = &mut arr[1..4];slice_mut[0] = 99;println!("{:?}", arr); // arr变成了 [1, 99, 3, 4, 5];
}
结果输出如下:
[2, 3, 4]
[1, 99, 3, 4, 5]
Slice 类型是特殊的引用,所以可变切片不能跟不可变引用同时存在,Rust 的借用检查器保证这一点。
字符串切片的用法跟数组切片的用法一样,字符串切片其实就是之前提到过的字符串字面值 &str
,String
和 &str
是不同的:
-
String
:堆上的可变字符串 -
&str
:字符串切片,不可变的引用(指向 UTF-8 编码的一段字节序列)
fn main() {let s = String::from("hello world");let hello = &s[0..5]; // "hello"let world = &s[6..]; // "world"println!("{}, {}", hello, world);
}
可以对字符串进行切片,但要注意是按照字节切,不是按字符数
(如果切到中间的UTF-8字符,就会 panic)。来看下面例子:
fn main() {let s = String::from("你好"); // "你"和"好"在UTF-8下每个字占3个字节!println!("{:?}", s.as_bytes());// 输出: [228, 189, 160, 229, 165, 189]
}
如果写成 let slice = &s[0..1];
这是错的,只有一个字节,UTF-8 破坏了,程序直接 panic:
warning: unused variable: `slice`--> src/main.rs:4:9|
4 | let slice = &s[0..1];| ^^^^^ help: if this is intentional, prefix it with an underscore: `_slice`|= note: `#[warn(unused_variables)]` on by defaultwarning: `hello_cargo` (bin "hello_cargo") generated 1 warningFinished `dev` profile [unoptimized + debuginfo] target(s) in 0.00sRunning `target/debug/hello_cargo`thread 'main' panicked at src/main.rs:4:19:
byte index 1 is not a char boundary; it is inside '你' (bytes 0..3) of `你好`
stack backtrace:0: rust_begin_unwindat /rustc/05f9846f893b09a1be1fc8560e33fc3c815cfecb/library/std/src/panicking.rs:695:51: core::panicking::panic_fmtat /rustc/05f9846f893b09a1be1fc8560e33fc3c815cfecb/library/core/src/panicking.rs:75:142: core::str::slice_error_fail_rt3: core::str::slice_error_failat /rustc/05f9846f893b09a1be1fc8560e33fc3c815cfecb/library/core/src/str/mod.rs:68:54: core::str::traits::<impl core::slice::index::SliceIndex<str> for core::ops::range::Range<usize>>::indexat /Users/huangruibang/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/str/traits.rs:240:215: <alloc::string::String as core::ops::index::Index<I>>::indexat /Users/huangruibang/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/alloc/src/string.rs:2669:96: hello_cargo::mainat ./src/main.rs:4:197: core::ops::function::FnOnce::call_onceat /Users/huangruibang/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
正确切法应该是按照字符来切到完整的 3 个字节:
fn main() {let s = String::from("你好"); // "你"和"好"在UTF-8下每个字占3个字节!let slice1 = &s[0..3]; // 切到完整的3个字节,才是"你"let slice2 = &s[3..]; // 切到完整的3个字节,才是"好"println!("{}", slice1);println!("{}", slice2);
}
接下来讲讲 Slice 类型的底层原理,以便更好地理解:
从编译器的角度看,切片是一个胖指针(fat pointer)。
它包含两部分信息:
- 一个指针(指向数据的起始地址)
- 一个长度(告诉你有多少个元素)
可以理解成这样的结构(简化版伪代码):
struct Slice<T> {ptr: *const T, // 指向第一个元素的地址len: usize, // 有多少个元素
}
所以:&[T]
是对 Slice<T>
的不可变引用;&mut [T]
是对 Slice<T>
的可变引用。
let arr = [10, 20, 30, 40, 50];
let slice = &arr[1..4];
内存大概是这样(数字是地址举例):
地址 | 内容 |
---|---|
0x1000 | 10 |
0x1004 | 20 |
0x1008 | 30 |
0x100C | 40 |
0x1010 | 50 |
然后:slice.ptr
指向 0x1004
(也就是20所在位置),slice.len
是 3
(指向3个元素:20, 30, 40)。所以 slice
能正确访问 [20, 30, 40]
。
切片本身是独立的小结构体,占据一点栈空间,但它指向的数据是在原数组里。