【Rust】hello-world与所有权

1,209 阅读13分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第5天,点击查看活动详情

在这章,你将会学习到 Rust 中最重要的概念之一:所有权。希望你能仔细阅读并积极思考,掌握 引用切片 的相关用法,并弄清 moveborrow 的区别。这些在之后的 Rust 学习中非常常见!

Hello World


cargo new 命令创建第一个可执行项目:

cargo new hello-world

默认情况下, cargo new 使用了二进制程序模板创建项目。另外也可以用 cargo new --lib 新建一个库项目。

执行完命令之后,会看到如下目录结构:

hello-world/
├── .git
├── .gitignore
├── Cargo.toml
└── src
    └── main.rs

当你创建好以上项目,你甚至可以在无需编写 printf!("Hello, world!") 的情况下直接运行 cargo run 命令!项目会编译并生成 /target 文件目录和可执行的二进制文件,并在控制台打印输出:

cargo run 会使用 cargo build 构建项目,并执行特定的二进制程序。运行完之后,会将生成的二进制代码放到 ./target/debug/hello-world

如果想不运行直接构建,可以用 cargo build 指令。 Cargo 默认使用 dev (debug) 配置来进行构建,同时保留 debug 调试信息。当准备要发布时,就可以使用 cargo build --release 来构建,会生成二进制代码到 ./target/release/hello-world

第一个程序


打开 /src/main.rs

fn main() {
  println!("Hello, world!");
}

在一个独立的可执行程序中,main() 函数是必需的,这是程序的入口。

println!() 是一个用来生成代码的宏,这个宏可以把参数打印到 stdout 标准输出上。宏就像是一个内联的编译器,可以在编译时生成代码。

字符串

JavaScript ES6之后的赋值有两种方法, let 允许变量重新赋值,而 const 则不可以。而 Rust 也有 letconst, 现在先忽略 const

在 JavaScript 里想要 const 语义的地方,在 Rust 中要用 let。在 JavaScript 中想要 let 语义的地方,在 Rust 中要用 let mut。需要关键词 mut 才能把变量声明为可变的。 Rust 中任何事物默认都是不可变的。这对安全有很大帮助。

fn main() {
    let mut num = 1;
    println!("{}", num);
    num = 0;
    print!("{}", num);
}

Rust 语言是强类型的,你只能给变量重新赋值一个同类型的值。这样写是不行的:

fn main() {
    let mut mutable = 1;
    println!("{}", mutable);
    mutable = "3"; // 将字符串赋值给数字是不行的     println!("{}", mutable);
}

💡 编译器会告诉你 mismatched types 类型不匹配:

与C语言不同的是,你可以用另一个 let 声明一个同名变量,给变量重新赋值其他类型的值

fn main() {
    let num = 1;
    println!("{}", num);
    // C language 会报错 "conflicting declaration"
    let num = "3";
    println!("{}", num);
}

Println! 占位符打印

fn main() {
    let mut num = 1;
    println!("{}", num);
}

里面的 {} 是预留在特定位置的占位符:把 {} 想象成小蟹钳,可以夹住合适的值。使用 {} 也可以打印多个值:第一对 {} 使用格式化字符串之后的第一个值,第二对则使用第二个值,依此类推。

Prelude

默认情况下,Rust会将少量标准库中定义的程序项(item)引入到每个程序的作用域中。这些项称作 prelude,详情参考标准库文档

如果需要的类型不在 prelude 中,你必须使用 use 语句显式地将其引入作用域。例如 std::io 库提供很多有用的功能,包括接收用户输入的功能。

use std::io;

fn main() {
    let mut str = String::new();

    io::stdin()
        .read_line(&mut str)
        .expect("Failed to read");
}

String::new 这个函数会返回一个 String 的新实例。String 是一个标准库提供的字符串类型,这是 UTF-8 编码的可增长文本块。

::new 那一行的 :: 语法表明 newString 类型的一个关联函数。关联函数associated function是实现一种特定类型的函数,在这个例子中类型是 String。这个 new 函数创建了一个新的空字符串。你会发现很多类型上有 new 函数,因为它是创建类型实例的惯用函数名。

Rust 的所有权


Rust 通过强制执行一些基本规则 prelude 来保证内存安全,这些规则涉及到如何开展数据的传值、如何引用数据以及数据的所有权。

所有权

在讲解前,我们需要讨论下所有权。 Rust 的规则会强迫你重新思考一些逻辑和结构,这些逻辑和结构本来在别的语言里可以正常运行的。

Rust 不用垃圾回收器(GC, Garbage Collector)也可以保证内存安全,而像 Java、JavaScript、Go 和很多其他的语言,都是使用垃圾回收器管理内存的。他们跟踪所有对象的引用,仅当对象的被引用次数降到 0 时,才会释放内存。垃圾回收器牺牲了资源和性能来保证内存安全。

通常情况下 GC 机制是有用的。但想要使用和优化垃圾回收器,则需要深入学习相关语言的底层运行环境的实现。在 Rust 中,只要你遵守 Rust 的规则时,那么不用垃圾回收器的开销,也能实现内存安全!

💡 内存安全不仅仅是保证代码不崩溃,它关上了一整类安全漏洞的大门。你是否听说过 SQL 注入?它是由于数据库客户端的漏洞,直接拼接未经处理的 SQL 语句导致的。攻击者通过传入攻击语句以利用此漏洞,可以改变最终的查询语句并运行新指令。虽然针对 SQL 注入有一整套的的防御手段,但在现在的 web 应用中它还是最常见的漏洞。内存不安全的代码就像 SQL 注入代码一样,占严重漏洞的大部分。

而 Rust 针对内存泄漏具有一整套优雅的解决方案,这也是 Rust 为什么这么受关注的原因。你将在后续的学习中逐步了解 Rust 在这方面的努力。

在代码块中,当把一个数据传给一个函数(变量)之后,此调用方代码块将无权再访问该数据。因为它放弃了所有权(实际是为了避免释放内存时出现二次释放(double free,我们将在之后的内存分配中谈及原理)。如这段代码:

use std::{collections::HashMap, fs::read_to_string};
fn main() {
    let source = read_to_string("./README.md").unwrap();
    let mut files = HashMap::new();
    files.insert("README", source);
    files.insert("README2", source);
}

你会在这个例子中看到 .unwrap() 这个方法,而在实际的生产环境中,你不应该频繁使用它。 .unwrap() 假定操作成功,否则就会 panic (终结进程)。例子中这样写可以,但项目中不要这么写,除非你十分确定此操作不会失败。

运行这段代码,会产生这个报错信息:use of moved value: source 。之后还会很多次看到它。

当我们第一次把 source 塞进散列映射(HashMap) 时,我们已经放弃了该数据的所有权。如果我们想让这段代码编译通过,则需要在第一次放弃所有权之前,复制(clone) source

use std::{collections::HashMap, fs::read_to_string};
fn main() {
    // rust 默认根地址是项目根路径
    let source = read_to_string("./README.md").unwrap();
    let mut files = HashMap::new();
    files.insert("README", source.clone());
    files.insert("README2", source);
    print!("{:?}", files.get("README"));
}

💡 当数据实现了 Copy trait 时,那么旧的变量在将其赋值给其他变量后仍然可用。相比于 Clone 方法, Copy 方法用于那些能可靠地、简单地复制的数据,Rust 会自动复制这些数据。而对于代价较高的复制,你不得不自己实现的时候,可以用 Clone

任何一组简单标量值的组合和任何不需要分配内存或某种形式资源的类型都可以实现 Copy 。如下是一些 Copy 的类型:

  • 所有整数类型,比如 u32
  • 布尔类型,bool,它的值是 truefalse
  • 所有浮点数类型,比如 f64
  • 字符类型,char
  • 元组,当且仅当其包含的类型也都实现 Copy 的时候。比如,(i32, i32) 实现了 Copy,但 (i32, String) 就没有。

引用

函数返回值也可以转移所有权。在每一个函数中都获取所有权并接着返回所有权有些啰嗦。如果我们想要函数使用一个值但不获取所有权该怎么办呢?

你可以利用一个元组,这个元组至少包括变量本身,还可以放入一些你希望返回的值。

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() 返回字符串的长度

    (s, length)
}

但是这未免有些形式主义,而且这种场景很常见。对此,Rust 提供了一个功能,叫做 引用references)。

使用方法

当你想引用一个数据时,类似C语言语法,你需要在值前加上 & 符号来获取引用,这允许你使用值但不获取其所有权。这能传递大量数据,而无需每次复制他们。

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1);

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

&s1 语法让我们创建一个 指向s1 的引用,但是并不拥有它。因为并不拥有这个值,所以当引用停止使用时,它所指向的值也不会被丢弃。

可变引用

我们将创建一个引用的行为称为 借用borrowing。如果此时我们想要修改借用的变量呢?正如变量默认是不可变的,引用也一样。(默认)不允许修改引用的值。

但是你可以通过 mut 修饰符来创建一个可变引用:

use std::{collections::HashMap, fs::read_to_string};
fn main() {
    let source = read_to_string("./README.md").unwrap();
    let mut files = HashMap::new();
    files.insert("README", source.clone());
    files.insert("README2", source);
    let files_ref = &files;
    let files_ref2 = &files;
    print_borrowed_map(files_ref, "README");
    print_borrowed_map(files_ref2, "README2");
}
fn print_borrowed_map(map: &HashMap<&str, String>, value: &str) {
    println!("{:?}", map.get(value))
}

println!() 中的 {:?} 语法是调试 Debug 格式器。对于一些没有可读性的数据,这是个很便捷的输出方法。

如果我们需要拿到 HashMap 的一个可变引用,我们要这么写: let files_ref = &mut files;

use std::{collections::HashMap, fs::read_to_string};
fn main() {
    let source = read_to_string("./README.md").unwrap();
    let mut files = HashMap::new();
    files.insert("README", source.clone());
    files.insert("README2", source);
    let files_ref = &mut files;
    let files_ref2 = &mut files;
    needs_mutable_ref(files_ref);
    needs_mutable_ref(files_ref2);
}
fn needs_mutable_ref(map: &mut HashMap<&str, String>) {}

💡 不过可变引用有一个很大的限制:在同一时间,只能有一个对某一特定数据的可变引用。尝试创建两个可变引用的代码将会失败。

所以当编译上面的代码时,会遇到下面的报错:

这个报错说这段代码是无效的,因为我们不能在同一时间多次将 s 作为可变变量借用

数据竞争

防止同一时间对同一数据进行多个可变引用的限制允许可变性,不过是以一种受限制的方式允许。如果你曾经学习过操作系统,那么你一定学习过 临界资源互斥问题。类似的,在多个用户同时操作(修改)一个可修改变量时,加锁能有效地保持每个用户所持有的数据信息不会冲突。

因此,这个限制的好处是 Rust 可以在编译时就避免数据竞争。数据竞争data race)类似于竞态条件,它由这三个行为造成:

  • 两个或更多指针同时访问同一数据。
  • 至少有一个指针被用来写入数据。
  • 没有同步数据访问的机制。

💡 以上三个行为同时发生才会造成数据竞争,而不是单一行为。

数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复。Rust 避免了这种情况的发生,因为它不会编译存在数据竞争的代码。

如果调整借用的顺序,使 Rust 编译器能知道是用完一个引用之后,才用的另一个引用,那么代码就能编译成功。

use std::{collections::HashMap, fs::read_to_string};
fn main() {
    let source = read_to_string("./README.md").unwrap();
    let mut files = HashMap::new();
    files.insert("README", source.clone());
    files.insert("README2", source);
    let files_ref = &mut files;
    needs_mutable_ref(files_ref);
    let files_ref2 = &mut files;
    needs_mutable_ref(files_ref2);
}
fn needs_mutable_ref(map: &mut HashMap<&str, String>) {}

💡 当开始使用 Rust 后会发现,很多报错是仅仅调整代码顺序就能解决的。

同样的,我们也不能在拥有不可变引用的同时拥有可变引用。以下代码是不允许的:

use std::{collections::HashMap, fs::read_to_string};

fn main() {
    let source = read_to_string("./README.md").unwrap();
    let mut files = HashMap::new();
    files.insert("README", source.clone());
    files.insert("README2", source);
    let files_ref = &files;
    let files_ref1 = &files;
    let files_ref2 = &mut files;
    println!("{:?}, {:?} and {:?}", files_ref.get("README"), files_ref1.get("README"), files_ref2.get("README"));
}

编译器报错如下:

💡 类似数据库中的读写锁,不可变引用的用户可不希望在他们的眼皮底下值就被意外的改变了!然而,多个不可变引用是可以的,因为没有哪个只能读取数据的人有能力影响其他人读取到的数据。

注意一个引用的作用域从声明的地方开始一直持续到最后一次使用为止。以下代码是没有问题的:

// ...
let files_ref = &files;
let files_ref1 = &files;
println!("{:?} and {:?}", files_ref.get("README"), files_ref1.get("README"));
// files_ref,files_ref1 不再被使用

let files_ref2 = &mut files;
println!("{:?}", files_ref2.get("README"));

💡 最后一次使用不可变引用(println!),发生在声明可变引用之前。所以能够通过编译。

编译器在作用域结束之前判断不再使用的引用的能力被称为非词法作用域生命周期(Non-Lexical Lifetimes,简称 NLL)

悬空指针

在具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个 悬空指针dangling pointer),所谓悬空指针是其指向的内存可能已经被分配给其它持有者。而在 Rust 中,编译器确保引用永远也不会变成悬垂状态:当你拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。

Rust 会通过一个编译时错误来避免它:

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String {
    let s = String::from("hello");

    &s
}

与 JavaScript 的区别

在 JavaScript 中,每一个对象都是引用。如果你传一个对象给函数,在函数中修改对象的一个属性,函数完成之后,这个属性的修改会在外部生效,那么这个过程就是传引用。以下面的代码为例:

function actOnString(string) {
  string += " What a nice day.";
  console.log(`String in function: ${string}`);
}
const stringValue = "Hello!";
console.log(`String before function: ${stringValue}`);
actOnString(stringValue);
console.log(`String after function: ${stringValue}\n`);

function actOnObject(object) {
  object.firstName = "Samuel";
  object.lastName = "Clemens";
  console.log(`Object in function: ${objectValue}`);
}
const objectValue = {
  firstName: "Jane",
  lastName: "Doe",
};
objectValue.toString = function () {
  return `${this.firstName} ${this.lastName}`;
};
console.log(`Object before function: ${objectValue}`);
actOnObject(objectValue);
console.log(`Object after function: ${objectValue}`);

当执行时会看到:

String before function: Hello!
String in function: Hello! What a nice day.
String after function: Hello!

Object before function: Jane Doe
Object in function: Samuel Clemens
Object after function: Samuel Clemens

如果不使用引用的话,那么每次传任何 对象(Object) 给任何一个函数,都会对其做深拷贝。

面试的时候喜欢问“JavaScript 语言是传值,还是传引用?”,答案是“JavaScript 语言是传值的,除了对象,因为对象本身就是引用”。

总结

所有权是 Rust 的一个核心的问题,我们将在之后的课程中逐步讲解它。