介绍
ch01-00-introduction.md
commit c51c14215d2ee2cb481bc8a942a3769c6d9a2e1a
欢迎阅读“Rust 程序设计语言”,一本关于 Rust 的介绍性书籍。Rust 是一个着用于安全、速度和并发的编程语言。它的设计不仅可以使程序获得性能和对底层语言的控制,并且能够享受高级语言强大的抽象能力。这些特性使得 Rust 适合那些有类似 C 语言经验并正在寻找一个更安全的替代者的程序员,同时也适合那些来自类似 Python 语言背景,正在探索在不牺牲表现力的情况下编写更好性能代码的人们。
Rust 在编译时进行其绝大多数的安全检查和内存管理决策,因此程序的运行时性能没有受到影响。这让其在许多其他语言不擅长的应用场景中得以大显身手:有可预测空间和时间要求的程序,嵌入到其他语言中,以及编写底层代码,如设备驱动和操作系统。Rust 也很擅长 web 程序:它驱动着 Rust 包注册网站(package registry site),crates.io!我们期待看到你使用 Rust 进行创作。
本书是为已经至少了解一门编程语言的读者而写的。读完本书之后,你应该能自如的编写 Rust 程序。我们将通过小而集中并相互依赖的例子来学习 Rust,并向你展示如何使用 Rust 多样的功能,同时了解它们在后台是如何执行的。
为本书做出贡献
本书是开源的。如果你发现任何错误,请不要犹豫,在 GitHub 上发起 issue 或提交 pull request。
安装
ch01-01-installation.md
commit f828919e62aa542aaaae03c1fb565da42374213e
使用 Rust 的第一步是安装。你需要联网来执行本章的命令,因为我们要从网上下载 Rust。
我们将会展示很多使用终端的命令,并且这些代码都以$
开头。并不需要真正输入$
,它们在这里代表每行指令的开头。在网上会看到很多使用这个惯例的教程和例子:$
代表以常规用户运行命令,#
代表需要用管理员运行的命令。没有以$
(或#
)的行通常是之前命令的输出。
在 Linux 或 Mac 上安装
如果你使用 Linux 或 Mac,所有需要做的就是打开一个终端并输入:
$ curl https://sh.rustup.rs -sSf | sh
这会下载一个脚本并开始安装。你可能被提示要输入密码。如果一切顺利,将会出现如下内容:
Rust is installed now. Great!
当然,如果你不赞成curl | sh
这种模式,可以随意下载、检查和运行这个脚本。
在 Windows 上安装
在 Windows 上,前往https://rustup.rs并按照说明下载rustup-init.exe
。运行并遵循它提供的其余指示。
本书其余 Windows 相关的命令假设你使用cmd
作为你的 shell。如果你使用不同的 shell,可能能够执行 Linux 和 Mac 用户相同的命令。如果都不行,查看所使用的 shell 的文档。
自定义安装
如果有理由倾向于不使用 rustup.rs,请查看Rust 安装页面获取其他选择。
卸载
卸载 Rust 同安装一样简单。在 shell 中运行卸载脚本
$ rustup self uninstall
故障排除
安装完 Rust 后,打开 shell,输入:
$ rustc --version
应该能看到类似这样的版本号、提交 hash 和提交日期,对应你安装时的最新稳定版本:
rustc x.y.z (abcabcabc yyyy-mm-dd)
如果出现这些内容,Rust 就安装成功了!
恭喜入坑!(此处应该有掌声!)
如果有问题并且你在使用 Windows,检查 Rust(rustc,cargo 等)是否位于%PATH%
系统变量中。
如果还是不能运行,有许多可以获取帮助的地方。最简单的是 irc.mozilla.org 上的 IRC 频道 #rust-beginners 和供一般讨论之用的 #rust,我们可以使用 Mibbit 访问。然后我们就可以和其他能提供帮助的 Rustacean(我们这些人自嘲的绰号)聊天了。其它给力的资源包括用户论坛和Stack Overflow。
本地文档
安装程序也包含一份本地文档的拷贝,你可以离线阅读它们。输入rustup doc
将在浏览器中打开本地文档。
任何你太确认标准库提供的类型或函数是干什么的时候,使用文档 API 查找!
Hello, World!
ch01-02-hello-world.md
commit aa1801d99cd3b19c96533f00c852b1c4bd5350a6
现在你已经安装好了 Rust,让我们来编写你的第一个 Rust 程序。当学习一门新语言的时候,编写一个在屏幕上打印 “Hello, world!” 文本的小程序是一个传统,而在这一部分,我们将遵循这个传统。
注意:本书假设你熟悉基本的命令行操作。Rust 本身并不对你的编辑器,工具和你的代码存放在何处有什么特定的要求,所以如果你比起命令行更喜欢 IDE,请随意选择你喜欢的 IDE。
创建项目文件
首先,创建一个文件来编写 Rust 代码。Rust 并不关心你的代码存放在哪里,不过在本书中,我们建议在你的 home 目录创建一个项目目录,并把你的所有项目放在这。打开一个终端并输入如下命令来为这个项目创建一个文件夹:
Linux 和 Mac:
$ mkdir ~/projects
$ cd ~/projects
$ mkdir hello_world
$ cd hello_world
Windows:
> mkdir %USERPROFILE%\projects
> cd %USERPROFILE%\projects
> mkdir hello_world
> cd hello_world
编写并运行 Rust 程序
接下来,创建一个新的叫做 main.rs 的源文件。Rust 文件总是以 .rs 后缀结尾。如果文件名多于一个单词,使用下划线分隔它们。例如,使用 my_program.rs 而不是 myprogram.rs。
现在打开刚创建的 main.rs 文件,并输入如下代码:
Filename: main.rs
fn main() {
println!("Hello, world!");
}
保存文件,并回到终端窗口。在 Linux 或 OSX 上,输入如下命令:
$ rustc main.rs
$ ./main
Hello, world!
在 Windows 上,运行.\main.exe
而不是./main
。不管使用何种系统,你应该在终端看到Hello, world!
字符串。如果你做到了,那么恭喜你!你已经正式编写了一个 Rust 程序。你是一名 Rust 程序员了!欢迎入坑。
分析 Rust 程序
现在,让我们回过头来仔细看看你的“Hello, world!”程序到底发生了什么。这是谜题的第一片:
fn main() {
}
这几行定义了一个 Rust 函数。main
函数是特殊的:这是每一个可执行的 Rust 程序首先运行的函数(译者注:入口点)。第一行表示“定义一个叫 main
的函数,没有参数也没有返回值。”如果有参数的话,它们应该出现在括号中,(
和)
。
同时注意函数体被包裹在大括号中,{
和}
。Rust 要求所有函数体都位于大括号中(译者注:对比有些语言特定情况可以省略大括号)。将前一个大括号与函数声明置于一行,并留有一个空格被认为是一个好的代码风格。
在main()
函数中:
println!("Hello, world!");
这行代码做了这个小程序的所有工作:它在屏幕上打印文本。有很多需要注意的细节。第一个是 Rust 代码风格使用 4 个空格缩进,而不是 1 个制表符(tab)。
第二个重要的部分是println!()
。这叫做 Rust 宏,是如何进行 Rust 元编程(metaprogramming)的关键所在。相反如果调用一个函数的话,它应该看起来像这样:println
(没有!
)。我们将在 24 章更加详细的讨论 Rust 宏,不过现在你只需记住当看到符号 !
的时候,就代表在调用一个宏而不是一个普通的函数。
接下来,"Hello, world!"
是一个 字符串。我们把这个字符串作为一个参数传递给println!
,它负责在屏幕上打印这个字符串。轻松加愉快!(⊙o⊙)
这一行以一个分号结尾(;
)。;
代表这个表达式的结束和下一个表达式的开始。大部分 Rust 代码行以;
结尾。
编译和运行是两个步骤
在“编写并运行 Rust 程序”部分,展示了如何运行一个新创建的程序。现在我们将拆分并检查每一步操作。
在运行一个 Rust 程序之前,必须编译它。可以输入rustc
命令来使用 Rust 编译器并像这样传递你源文件的名字:
$ rustc main.rs
如果你来自 C 或 C++ 背景,你会发现这与gcc
和clang
类似。编译成功后,Rust 应该会输出一个二进制可执行文件,在 Linux 或 OSX 上在 shell 中你可以通过ls
命令看到如下:
$ ls
main main.rs
在 Windows 上,输入:
> dir /B %= the /B option says to only show the file names =%
main.exe
main.rs
这表示我们有两个文件:.rs 后缀的源文件,和可执行文件(在 Windows下是 main.exe,其它平台是 main)。这里剩下的操作就只有运行 main 或 main.exe 文件了,像这样:
$ ./main # or .\main.exe on Windows
如果 main.rs 是我们的“Hello, world!”程序,它将会在终端上打印Hello, world!
。
来自 Ruby、Python 或 JavaScript 这样的动态类型语言背景的同学,可能不太习惯在分开的步骤编译和执行程序。Rust 是一种 静态提前编译语言(ahead-of-time compiled language),这意味着可以编译好程序后,把它给任何人,他们都不需要安装 Rust 就可运行。如果你给他们一个 .rb
, .py
或 .js
文件,他们需要先分别安装 Ruby,Python,JavaScript 实现(运行时环境,VM),不过你只需要一句命令就可以编译和执行程序。这一切都是语言设计的权衡取舍。
仅仅使用rustc
编译简单程序是没问题的,不过随着项目的增长,你将想要能够控制你项目拥有的所有选项,并使其易于分享你的代码给别人或别的项目。接下来,我们将介绍一个叫做 Cargo 的工具,它将帮助你编写现实生活中的 Rust 程序。
Hello, Cargo!
Cargo 是 Rust 的构建系统和包管理工具,同时 Rustacean 们使用 Cargo 来管理它们的 Rust 项目,因为它使得很多任务变得更轻松。例如,Cargo负责构建代码、下载代码依赖的库并编译这些库。我们把代码需要的库叫做 依赖(dependencies)。
最简单的 Rust 程序,例如我们刚刚编写的,并没有任何依赖,所以目前我们只使用了 Cargo 负责构建代码的部分。随着你编写更加复杂的 Rust 程序,你会想要添加依赖,那么如果你使用 Cargo 开始的话,这将会变得简单许多。
因为绝大部分 Rust 项目使用 Cargo,本书接下来的部分将假设你使用它。如果使用安装章节介绍的官方安装包的话,Rust 自带 Cargo。如果通过其他方式安装 Rust 的话,可以在终端输入如下命令检查是否安装了 Cargo:
$ cargo --version
如果看到了版本号,一切 OK!如果出现一个类似“command not found
”的错误,那么你应该查看安装方式的文档来确定如何单独安装 Cargo。
使用 Cargo 创建项目
让我们使用 Cargo 来创建一个新项目并看看与hello_world
项目有什么不同。回到项目目录(或者任何你决定放置代码的目录):
Linux 和 Mac:
$ cd ~/projects
Windows:
> cd %USERPROFILE%\projects
并在任何操作系统运行:
$ cargo new hello_cargo --bin
$ cd hello_cargo
我们向cargo new
传递了--bin
因为我们的目标是生成一个可执行程序,而不是一个库。可执行文件是二进制可执行文件,通常就叫做 二进制文件(binaries)。项目的名称被定为hello_cargo
,同时 Cargo 在一个同名(子)目录中创建它的文件,接着我们可以进入查看。
如果列出 hello_cargo 目录中的文件,我们将会看到 Cargo 生成了两个文件和一个目录:一个 Cargo.toml 文件和一个 src 目录,main.rs 文件位于目录中。它也在 hello_cargo 目录初始化了一个 git 仓库,以及一个 .gitignore 文件;你可以改为使用不同的版本控制系统,或者不使用,通过--vcs
参数。
使用你选择的文本编辑器(IDE)打开 Cargo.toml 文件。它应该看起来像这样:
Filename: Cargo.toml
[package]
name = "hello_cargo"
version = "0.1.0"
authors = ["Your Name <you@example.com>"]
[dependencies]
这个文件使用TOML (Tom's Obvious, Minimal Language) 格式。TOML 类似于 INI,不过有一些额外的改进之处,并且被用作 Cargo 的配置文件的格式。
第一行,[package]
,是一个部分标题表明下面的语句用来配置一个包。随着我们在这个文件增加更多的信息,我们还会增加其他部分。
最后一行,[dependencies]
,是列出项目依赖的 crates(我们这么称呼 Rust 代码的包)的部分的开始,这样 Cargo 也就知道去下载和编译它们。这个项目并不需要任何其他的 crate,不过在猜猜看教程章节会需要。
现在看看 src/main.rs:
fn main() {
println!("Hello, world!");
}
Cargo 为你生成了一个“Hello World!”,正如我们之前编写的那个!目前为止我们所见过的之前项目与 Cargo 生成的项目区别有:
- 代码位于 src 目录
- 项目根目录包含一个 Cargo.toml 配置文件
Cargo 期望源文件位于 src 目录,这样将项目根目录留给 README、license 信息、配置文件和其他跟代码无关的文件。这样,Cargo 帮助你保持项目干净整洁。一切井井有条。
如果没有使用 Cargo 开始项目,正如我们在 hello_world 目录中的项目,可以把它转化为一个 Cargo 使用的项目,通过将代码放入 src 目录并创建一个合适的 Cargo.toml。
构建并运行 Cargo 项目
现在让我们看看通过 Cargo 构建和运行 Hello World 程序有什么不同。为此,我们输入如下命令:
$ cargo build
Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
这应该创建 target/debug/hello_cargo(或者在 Windows 上是 target\debug\hello_cargo.exe)可执行文件,可以通过这个命令运行:
$ ./target/debug/hello_cargo # or .\target\debug\hello_cargo.exe on Windows
Hello, world!
好的!如果一切顺利,Hello, world!
应该再次打印在终端上。
第一次运行的时候也会使 Cargo 在项目根目录创建一个叫做 Cargo.lock 的新文件,它看起来像这样:
Filename: Cargo.lock
[root]
name = "hello_cargo"
version = "0.1.0"
Cargo 使用 Cargo.lock 来记录程序的依赖。这个项目并没有依赖,所以内容有一点稀少。事实上,你自己永远也不需要碰这个文件;仅仅让 Cargo 处理它就行了。
我们刚刚使用cargo build
构建了项目并使用./target/debug/hello_cargo
运行了它,不过也可以使用cargo run
编译并运行:
$ cargo run
Running `target/debug/hello_cargo`
Hello, world!
注意这一次,并没有出现告诉我们 Cargo 正在编译 hello_cargo
的输出。Cargo 发现文件并没有被改变,所以只是运行了二进制文件。如果修改了源文件的话,将会出现像这样的输出:
$ cargo run
Compiling hello_cargo v0.1.0 (file:///projects/hello_cargo)
Running `target/debug/hello_cargo`
Hello, world!
所以又出现一些更多的不同:
- 使用
cargo build
构建项目(或使用cargo run
一步构建并运行),而不是使用rustc
- 不同于将构建结果放在源码相同目录,Cargo 会将它放到 target/debug 目录中的文件,我们将会看到
Cargo 的另一个有点是不管你使用什么操作系统它的命令都是一样的,所以之后我们将不再为 Linux 和 Mac 以及 Windows 提供特定的命令。
发布构建
当项目最终准备好发布了,可以使用cargo build --release
来优化编译项目。这会在 target/release 下生成可执行文件,而不是 target/debug。这些优化可以让 Rust 代码运行的更快,不过启用他们会让程序花更长的时间编译。这也是为何这是两种不同的配置:一个为了开发,这时你经常想要快速重新构建;另一个构建提供给用户的最终程序,这时并不会重新构建并希望能运行得越快越好。如果你在测试代码的运行时间,请确保运行cargo build --release
并使用 target/release 下的可执行文件进行测试。
把 Cargo 当作习惯
对于简单项目, Cargo 并不能比rustc
提供更多的价值,不过随着开发的进行终将体现它的价值。对于拥有多个 crate 的复杂项目,可以仅仅运行cargo build
,然后一切将有序运行。即便这个项目很简单,现在它使用了很多接下来你 Rust 程序生涯将会用到的实用工具。事实上,无形中你可以使用下面的命令开始所有你想要从事的项目:
$ git clone someurl.com/someproject
$ cd someproject
$ carg
注意:如果你想要查看 Cargo 的更多细节,请阅读官方的 Cargo guide,它覆盖了其所有的功能。
猜猜看
ch02-00-guessing-game-tutorial.md
commit 77370c073661548dd56bbcb43cc64713585acbba
让我们通过自己动手的方式一起完成一个项目来快速上手 Rust!本章通过展示如何在真实的项目中运用的方式向你介绍一些常用的 Rust 概念。你将会学到let
、match
、方法、关联函数、使用外部 crate 等更多的知识!接下来的章节会探索这些概念的细节。在这一章,我们练习基础。
我们会实现一个经典新手编程问题:猜猜看游戏。它是这么工作的:程序将会随机生成一个 1 到 100 之间的随机整数。接着它会提示玩家输入一个猜测。当输入了一个猜测后,它会告诉提示猜测是太大了还是太小了。猜对了,它会打印出祝贺并退出。
准备一个新项目
要创建一个新项目,进入你在第一章创建的项目目录,并使用 Cargo 创建它,像这样:
$ cargo new guessing_game --bin
$ cd guessing_game
第一个命令,cargo new
,获取项目的名称(guessing_game
)作为第一个参数。--bin
参数告诉 Cargo 创建一个二进制项目,与第一章类似。第二个命令进入到新创建的项目目录。
看一样生成的 Cargo.toml 文件:
Filename: Cargo.toml
[package]
name = "guessing_game"
version = "0.1.0"
authors = ["Your Name <you@example.com>"]
[dependencies]
如果 Cargo 从环境中获取的作者信息不正确,修改这个文件并再次保存。
正如第一章那样,cargo new
生成了一个“Hello, world!”程序。查看 src/main.rs 文件:
Filename: src/main.rs
fn main() {
println!("Hello, world!");
}
现在让我们使用cargo run
在相同的步骤编译并运行这个“Hello, world!”程序:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Running `target/debug/guessing_game`
Hello, world!
run
命令在你需要快速迭代项目时就派上用场了,而这个游戏就正是这么一个项目:我们需要在进行下一步之前快速测试每次迭代。
重新打开 src/main.rs 文件。我们将会在这个文件编写全部的代码。
处理一次猜测
程序的第一部分会请求用户输入,处理输入,并检查输入是否为期望的形式。首先,允许玩家输入一个猜测。在 src/main.rs 中输入列表 2-1 中的代码。
这些代码包含很多信息,所以让我们一点一点地过一遍。为了获取用户输入并接着打印结果作为输出,我们需要从标准库(被称为std
)中引用io
(输入/输出)库:
use std::io;
Rust 默认只在每个程序的 prelude 中引用很少的一些类型。如果想要使用的类型并不在 prelude 中,你必须使用一个use
语句显式的将其导入到程序中。使用std::io
库将提供很多io
相关的功能,接受用户输入的功能。
正如第一章所讲,main
函数是程序的入口点:
fn main() {
fn
语法声明了一个新函数,()
表明没有参数,{
作为函数体的开始。
第一章也讲到了,println!
是一个在屏幕上打印字符串的宏:
println!("Guess the number!");
println!("Please input your guess.");
这些代码仅仅打印一个提示,说明游戏的内容并请求用户输入。
用变量储存值
接下来,创建一个地方储存用户输入,像这样:
let mut guess = String::new();
现在程序开始变得有意思了!这一小行代码发生了很多事。注意这是一个let
语句,用来创建变量。这里是另外一个例子:
let foo = bar;
这行代码会创建一个叫做foo
的新变量并把它绑定到值bar
上。在 Rust 中,变量默认是不可变的。下面的例子展示了如何在变量名前使用mut
来使一个变量可变:
let foo = 5; // immutable
let mut bar = 5; // mutable
注意:
//
开始一个注释,它持续到本行的结尾。Rust 忽略注释中的所有内容。
现在我们知道了let mut guess
会引入一个叫做guess
的可变变量。等号(=
)的另一边是guess
所绑定的值,它是String::new
的结果,这个函数会返回一个String
的新实例。String
是一个标准库提供的字符串类型,它是可增长的、UTF-8 编码的文本块。
::new
那一行的::
语法表明new
是String
类型的一个关联函数(associated function)。关联函数是针对类型实现的,在这个例子中是String
,而不是String
的某个特定实例。一些语言中把它称为静态方法(static method)。
new
函数创建了一个新的空的String
,你会在很多类型上发现new
函数,因为这是创建某个类型新值的常用函数名。
总结一下,let mut guess = String::new();
这一行创建了一个可变变量,目前它绑定到一个String
新的、空的实例上。哟!
回忆一下我们在程序的第一行使用use std::io;
从标准库中引用输入/输出功能。现在在io
上调用一个关联函数,stdin
:
io::stdin().read_line(&mut guess)
.expect("Failed to read line");
如果我们在程序的开头没有use std::io
这一行,我们可以把函数调用写成std::io::stdin
这样。stdin
函数返回一个 std::io::Stdin
的实例,这是一个代表终端标准输入句柄的类型。
代码的下一部分,.read_line(&mut guess)
,调用 read_line
方法从标准输入句柄获取用户输入。我们还向read_line()
传递了一个参数:&mut guess
。
read_line
的工作是把获取任何用户键入到标准输入的字符并放入一个字符串中,所以它获取字符串作为一个参数。这个字符串需要是可变的,这样这个方法就可以通过增加用户的输入来改变字符串的内容。
&
表明这个参数是一个引用(reference),它提供了一个允许多个不同部分的代码访问同一份数据而不需要在内存中多次拷贝的方法。引用是一个复杂的功能,而 Rust 的一大优势就是它是安全而优雅操纵引用。完成这个程序并不需要知道这么多细节:第四章会更全面的解释引用。现在,我们只需知道它像变量一样,默认是不可变的。因此,需要写成&mut guess
而不是&guess
来使其可变。
这行代码还没有分析完。虽然这是单独一行代码,但它只是一个逻辑上代码行(虽然换行了但仍是一个语句)的第一部分。第二部分是这个方法:
.expect("Failed to read line");
当使用.foo()
语法调用方法时,明智的选择是换行并留出空白(缩进)来把长的代码行拆开。我们可以把代码写成这样:
io::stdin().read_line(&mut guess).expect("Failed to read line");
不过,过长的代码行难以阅读,所以最好拆开来写,两行代码两个方法调用。现在来看看这行代码干了什么。
使用Result
类型来处理潜在的错误
之前提到过,read_line
将用户输入放入到传递给它字符串中,不过它也返回一个值————一个io::Result
。Rust 标准库中有很多叫做Result
的类型。一个Result
泛型以及对应子模块的特定版本,比如io::Result
。
Result
类型是 枚举(enumerations),通常也写作 enums。枚举拥有固定值集合的类型,而这些值被称为枚举的成员(variants)。第六章会更详细的介绍枚举。
对于Result
,它的成员是Ok
或Err
,Ok
表明操作成功了,同时Ok
成员之中包含成功生成的值。Err
意味着操作失败,Err
之中包含操作是为什么或如何失败的信息。
Result
类型的作用是编码错误处理信息。Result
类型的值,正如其他任何类型,拥有定义于其上的方法。io::Result
的实例拥有expect
方法可供调用。如果io::Result
实例的值是Err
,expect
会导致程序崩溃并显示显示你作为参数传递给expect
的信息。如果io::Result
实例的值是Ok
,expect
会获取Ok
中的值并原原本本的返回给你,这样就可以使用它了。在本例中,返回值是用户输入到标准输入的一些字符。
如果不使用expect
,程序也能编译,不过会出现一个警告:
$ cargo build
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
src/main.rs:10:5: 10:39 warning: unused result which must be used,
#[warn(unused_must_use)] on by default
src/main.rs:10 io::stdin().read_line(&mut guess);
^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Rust 警告说我们没有使用read_line
返回的值Result
,表明程序没有处理一个可能的错误。消除警告的正确方式是老实编写错误处理,不过因为我们仅仅希望程序出现问题就崩溃,可以使用expect
。你会在第九章学习从错误中恢复。
使用println!
占位符打印值
除了位于结尾的大括号,目前为止编写的代码就只有一行代码值得讨论一下了,就是这一行:
println!("You guessed: {}", guess);
这行代码打印出存储了用户输入的字符串。这对{}
是一个在特定位置预留值的占位符。可以使用{}
打印多个值:第一个{}
对应格式化字符串之后列出的第一个值,第二个对应第二个值,以此类推。用一个println!
调用打印多个值应该看起来像这样:
let x = 5;
let y = 10;
println!("x = {} and y = {}", x, y);
这行代码会打印出x = 5 and y = 10
。
测试第一部分代码
让我们来测试下猜猜看游戏的第一部分。使用cargo run
运行它:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Running `target/debug/guessing_game`
Guess the number!
Please input your guess.
6
You guessed: 6
至此为止,游戏的第一部分已经完成:我们从键盘获取了输入并打印了出来。
生成一个秘密数字
接下来,需要生成一个秘密数字,用户会尝试猜测它。秘密数字应该每次都不同,这样多玩几次才会有意思。生成一个 1 到 100 之间的随机数这样游戏也不会太难。Rust 标准库中还未包含随机数功能。然而,Rust 团队确实提供了一个rand
crate。
使用 crate 来增加更多功能
记住 crate 是一个 Rust 代码的包。我们正在构建的项目是一个二进制 crate,它生成一个可执行文件。 rand
crate 是一个 库 crate,它包含意在被其他程序使用的代码。
Cargo 对外部 crate 的运用是其真正闪光的地方。在我们可以使用rand
编写代码之前,需要编辑 Cargo.toml 来包含rand
作为一个依赖。现在打开这个文件并在[dependencies]
部分标题(Cargo 为你创建了它)的下面添加如下代码:
Filename: Cargo.toml
[dependencies]
rand = "0.3.14"
在 Cargo.toml 文件中,任何标题之后的内容都是属于这个部分的,一直持续到直到另一个部分开始。[dependencies]
部分告诉 Cargo 项目依赖了哪个外部 crate 和需要的 crate 版本。在这个例子中,我们使用语义化版本符号0.3.14
来指定rand
crate。Cargo 理解语义化版本(Semantic Versioning)(有时也称为 SemVer),这是一个编写版本号的标准。版本号0.3.14
事实上是^0.3.14
的缩写,它的意思是“任何与 0.3.14 版本公有 API 相兼容的版本”。
现在,不用修改任何代码,构建项目,如列表 2-2:
可能会出现不同的版本号(不过多亏了语义化版本,它们与代码是兼容的!),同时显示顺序也可能会有所不同。
现在我们有了一个外部依赖,Cargo 从 registry (Crates.io)上获取了一份(兼容的)最新版本代码的拷贝。Crates.io 是 Rust 生态环境中的人们向他人贡献他们的开源 Rust 项目的地方。
在更新完 registry (索引)后,Cargo 检查[dependencies]
部分并下载还不存在部分。在这个例子中,虽然只列出了rand
一个依赖,Cargo 也获取了一份libc
的拷贝,因为rand
依赖libc
来正常工作。在下载他们之后,Rust 编译他们接着用这些依赖编译项目。
如果不做任何修改就立刻再次运行cargo build
,则不会有任何输出。Cargo 知道它已经下载并编译了依赖,同时 Cargo.toml 文件中也没有任何相关修改。Cargo 也知道代码没有做任何修改,所以它也不会重新编译代码。因为无事可做,它简单的退出了。如果打开 src/main.rs 文件,并做一些普通的修改,保存并再次构建,只会出现一行输出:
$ cargo build
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
这一行表明 Cargo 只构建了对 src/main.rs 文件做出的微小修改。依赖没有被修改,所以 Cargo 知道可以复用已经为此下载并编译的代码。它只是重新构建了部分(项目)代码。
The Cargo.lock 文件确保构建是可重现的
Cargo 有一个机制来确保每次任何人重新构建代码都会生成相同的成品:Cargo 只会使用你指定的依赖的版本,除非你又手动指定了别的。例如,如果下周rand
crate 的v0.3.15
版本出来了,而它包含一个重要的 bug 修改并也含有一个会破坏代码运行的缺陷的时候会发生什么呢?
这个问题的答案是 Cargo.lock 文件,它在第一次运行cargo build
时被创建并位于 guessing_game 目录。当第一次构建项目时,Cargo 计算出所有符合要求的依赖版本并接着写入 Cargo.lock 文件中。当将来构建项目时,Cargo 发现 Cargo.lock 存在就会使用这里指定的版本,而不是重新进行所有版本的计算。这使得你拥有了一个自动的可重现的构建。换句话说,项目会继续使用0.3.14
直到你显式升级,多亏了 Cargo.lock 文件。我们将会在这个文件编写全部的代码。
更新 crate 到一个新版本
当你确实需要升级 crate 时,Cargo 提供了另一个命令,update
,他会:
- 忽略 Cargo.lock 文件并计算出所有符合 Cargo.toml 中规格的最新版本。
- 如果成功了,Cargo 会把这些版本写入 Cargo.lock 文件。
不过,Cargo 默认只会寻找大于0.3.0
而小于0.4.0
的版本。如果rand
crate 发布了两个新版本,0.3.15
和0.4.0
,在运行cargo update
时会出现如下内容:
$ cargo update
Updating registry `https://github.com/rust-lang/crates.io-index`
Updating rand v0.3.14 -> v0.3.15
这时,值得注意的是 Cargo.lock 文件中的一个改变,rand
crate 现在使用的版本是0.3.15
。
如果想要使用0.4.0
版本的rand
或是任何0.4.x
系列的版本,必须像这样更新 Cargo.toml 文件:
[dependencies]
rand = "0.4.0"
下一次运行cargo build
时,Cargo 会更新 registry 中可用的 crate 并根据你指定新版本重新计算rand
的要求。
第十四章会讲到Cargo 和它的生态系统的更多内容,不过目前你只需要了解这么多。Cargo 使得复用库文件变得非常容易,所以 Rustacean 们能够通过组合很多包来编写出更轻巧的项目。
生成一个随机数
让我们开始使用rand
。下一步是更新 src/main.rs,如列表 2-3:
我们在顶部增加一行extern crate rand;
来让 Rust 知道我们要使用外部依赖。这也会调用相应的use rand
,所以现在可以使用rand::
前缀来调用rand
中的任何内容。
接下来,我们增加了另一行use
:use rand::Rng
。Rng
是一个定义了随机数生成器应实现方法的 trait,如果要使用这些方法的话这个 trait 必须在作用域中。第十章会详细介绍 trait。
另外,中间还新增加了两行。rand::thread_rng
函数会提供具体会使用的随机数生成器:它位于当前执行线程本地并从操作系统获取 seed。接下来,调用随机数生成器的gen_range
方法。这个方法由我们使用use rand::Rng
语句引入到作用域的Rng
trait 定义。gen_range
方法获取两个数作为参数并生成一个两者之间的随机数。它包含下限但不包含上限,所以需要指定1
和101
来请求一个1
和100
之间的数。
并不仅仅能够知道该引用哪个 trait 和该从 crate 中使用哪个方法。如何使用 crate 的说明在每个 crate 的文档中。Cargo 另一个很棒的功能是可以运行cargo doc --open
命令来构建所有本地依赖提供的文档并在浏览器中打开。例如,如果你对rand
crate 中的其他功能感兴趣,运行cargo doc --open
并点击左侧导航栏的rand
。
新增加的第二行代码打印出了秘密数字。这在开发程序时很有用,因为我们可以去测试它,不过在最终版本我们会删掉它。游戏一开始就打印出结果就没什么可玩的了!
尝试运行程序几次:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Running `target/debug/guessing_game`
Guess the number!
The secret number is: 7
Please input your guess.
4
You guessed: 4
$ cargo run
Running `target/debug/guessing_game`
Guess the number!
The secret number is: 83
Please input your guess.
5
You guessed: 5
你应该能得到不同的随机数,同时他们应该都是在 1 和 100 之间的。干得漂亮!
比较猜测与秘密数字
现在有了用户输入和一个随机数,我们可以比较他们。这个步骤如列表 2-4:
新代码的第一行是另一个use
,从标准库引入了一个叫做std::cmp::Ordering
的类型到作用域。Ordering
是另一个枚举,像Result
一样,不过Ordering
的成员是Less
、Greater
和Equal
。这是你比较两个值时可能出现三种结果。
接着在底部的五行新代码使用了Ordering
类型:
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => println!("You win!"),
}
cmp
方法比较两个值并可以在任何可比较的值上调用。它获取一个任何你想要比较的值的引用:这里是把guess
与secret_number
做比较。cmp
返回一个使用use
语句引用的Ordering
枚举的成员。我们使用一个match
表达式根据对guess
和secret_number
中的值调用cmp
后返回的哪个Ordering
枚举成员来决定接下来干什么。
一个match
表达式由 分支(arms) 构成。一个分支包含一个 模式(pattern)和代码,这些代码在match
表达式开头给出的值符合分支的模式时将被执行。Rust 获取提供给match
的值并挨个检查每个分支的模式。match
结构和模式是 Rust 中非常强大的功能,它帮助你体现代码可能遇到的多种情形并帮助你处理全部的可能。这些功能将分别在第六章和第十九章详细介绍。
让我们看看一个使用这里的match
表达式会发生什么的例子。假设用户猜了 50,这时随机生成的秘密数字是 38。当代码比较 50 与 38 时,cmp
方法会返回Ordering::Greater
,因为 50 比 38 要大。Ordering::Greater
是match
表达式得到的值。它检查第一个分支的模式,Ordering::Less
,不过值Ordering::Greater
并不匹配Ordering::Less
。所以它忽略了这个分支的代码并移动到下一个分支。下一个分支的模式,Ordering::Greater
,正确匹配了Ordering::Greater
!这个分支关联的代码会被执行并在屏幕打印出Too big!
。match
表达式就此终止,因为在这个特定场景下没有检查最后一个分支的必要。
然而,列表 2-4 的代码并不能编译,尝试一下:
$ cargo build
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
error[E0308]: mismatched types
--> src/main.rs:23:21
|
23 | match guess.cmp(&secret_number) {
| ^^^^^^^^^^^^^^ expected struct `std::string::String`, found integral variable
|
= note: expected type `&std::string::String`
= note: found type `&{integer}`
error: aborting due to previous error
Could not compile `guessing_game`.
错误的核心表明这里有不匹配的类型(mismatched types)。Rust 拥有一个静态强类型系统。不过,它也有类型推断。当我们写出let guess = String::new()
时,Rust 能够推断出guess
应该是一个String
,并不需要我们写出类型。另一方面,secret_number
,是一个数字类型。一些数字类型拥有 1 到 100 之间的值:i32
,一个 32 位的数字;u32
,一个 32 位无符号数字;i64
,一个 64 位数字;等等。Rust 默认使用i32
,所以secret_number
的类型就是它,除非增加类型信息或任何能让 Rust 推断出不同数值类型的信息。这里错误的原因是 Rust 不会比较字符串类型和数字类型。
最终我们想要把程序从输入中读取到的String
转换为一个真正的数字类型,这样好与秘密数字向比较。可以通过在main
函数体中增加如下两行代码来实现:
Filename: src/main.rs
extern crate rand;
use std::io;
use std::cmp::Ordering;
use rand::Rng;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_range(1, 101);
println!("The secret number is: {}", secret_number);
println!("Please input your guess.");
let mut guess = String::new();
io::stdin().read_line(&mut guess)
.expect("Failed to read line");
let guess: u32 = guess.trim().parse()
.expect("Please type a number!");
println!("You guessed: {}", guess);
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => println!("You win!"),
}
}
这两行代码是:
let guess: u32 = guess.trim().parse()
.expect("Please type a number!");
这里创建了一个叫做guess
的变量。不过等等,难道这个程序不是已经有了一个叫做guess
的变量了吗?确实如此,不过 Rust 允许我们通过覆盖(shadow) 用一个新值来覆盖guess
之前的值。这个功能经常用在类似需要把一个值从一种类型转换到另一种类型的场景。shadowing 允许我们复用guess
变量的名字而不是强迫我们创建两个不同变量,比如guess_str
和guess
。(第三章会介绍 shadowing 的更多细节。)
guess
被绑定到guess.trim().parse()
表达式。表达式中的guess
对应包含输入的String
类型的原始guess
。String
实例的trim
方法会消除字符串开头和结尾的空白。u32
只能包含数字字符。不过用户必须输入回车键才能让read_line
返回。当用户按下回车键时,会在字符串中增加一个换行(newline)字符。例如,如果用户输入 5 并回车,guess
看起来像这样:5\n
。\n
代表“换行”,回车键。trim
方法消除\n
,只留下5
。
字符串的parse
方法解析一个字符串成某个数字。因为这个方法可以解析多种数字类型,需要告诉 Rust 我们需要的具体的数字类型,这里通过let guess: u32
指定。guess
后面的冒号(:
)告诉 Rust 我们指明了变量的类型。Rust 有一些内建的数字类型;这里的u32
是一个无符号的 32 位整型。它是一个好的较小正整数的默认类型。第三章会讲到其他数字类型。另外,例子程序中的u32
注解和与secret_number
的比较意味着 Rust 会推断secret_number
应该是也是u32
类型。现在可以使用相同类型比较两个值了!
parse
调用容易产生错误。例如,如果字符串包含A👍%
,就无法将其转换为一个数字。因为它可能失败,parse
方法返回一个Result
类型,非常像之前在 XX 页“使用Result
类型来处理潜在的错误”部分讨论的read_line
方法。这里再次类似的使用expect
方法处理这个Result
类型。如果parse
因为不能从字符串生成一个数字而返回一个Err
的Result
成员时,expect
会使游戏崩溃并打印提供给它的信息。如果parse
能成功地将字符串转换为一个数字,它会返回Result
的Ok
成员,同时expect
会返回Ok
中我们需要的数字。
现在让我们运行程序!
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Running `target/guessing_game`
Guess the number!
The secret number is: 58
Please input your guess.
76
You guessed: 76
Too big!
漂亮!即便是在猜测之前添加了空格,程序依然能判断出用户猜测了 76。多运行程序几次来检验不同类型输入的相应行为:猜一个正确的数字,猜一个过大的数字和猜一个过小的数字。
现在游戏已经大体上能玩了,不过用户只能猜一次。增加一个循环来改变它吧!
使用循环来允许多次猜测
loop
关键字提供了一个无限循环。增加它后给了用户多次猜测的机会:
Filename: src/main.rs
extern crate rand;
use std::io;
use std::cmp::Ordering;
use rand::Rng;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_range(1, 101);
println!("The secret number is: {}", secret_number);
loop {
println!("Please input your guess.");
let mut guess = String::new();
io::stdin().read_line(&mut guess)
.expect("Failed to read line");
let guess: u32 = guess.trim().parse()
.expect("Please type a number!");
println!("You guessed: {}", guess);
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => println!("You win!"),
}
}
}
如上所示,我们将提示用户猜测之后的所有内容放入了循环。确保这些代码多缩进了四个空格,并再次运行程序。注意这里有一个新问题,因为程序忠实地执行了我们要求它做的:永远地请求另一个猜测!看起来用户没法退出啊!
用户总是可以使用Ctrl-C
快捷键来终止程序。不过这里还有另一个逃离这个贪得无厌的怪物的方法,就是在 XX 页“比较猜测”部分提到的parse
:如果用户输入一个非数字回答,程序会崩溃。用户可以利用这一点来退出,如下所示:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Running `target/guessing_game`
Guess the number!
The secret number is: 59
Please input your guess.
45
You guessed: 45
Too small!
Please input your guess.
60
You guessed: 60
Too big!
Please input your guess.
59
You guessed: 59
You win!
Please input your guess.
quit
thread 'main' panicked at 'Please type a number!: ParseIntError { kind: InvalidDigit }', src/libcore/result.rs:785
note: Run with `RUST_BACKTRACE=1` for a backtrace.
error: Process didn't exit successfully: `target/debug/guess` (exit code: 101)
输入quit
就会退出程序,同时其他任何非数字输入也一样。然而,毫不夸张的说这是不理想的。我们想要当猜测正确的数字时游戏能自动退出。
猜测正确后退出
让我们增加一个break
来在用户胜利时退出游戏:
Filename: src/main.rs
extern crate rand;
use std::io;
use std::cmp::Ordering;
use rand::Rng;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_range(1, 101);
println!("The secret number is: {}", secret_number);
loop {
println!("Please input your guess.");
let mut guess = String::new();
io::stdin().read_line(&mut guess)
.expect("Failed to read line");
let guess: u32 = guess.trim().parse()
.expect("Please type a number!");
println!("You guessed: {}", guess);
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
}
}
}
}
通过在You win!
之后增加一行break
,程序在用户猜对了神秘数字后会退出循环。退出循环也就意味着退出程序,因为循环是main
的最后一部分。
处理无效输入
为了进一步改善游戏性,而不是在用户输入非数字时崩溃,需要让游戏忽略非数字从而用户可以继续猜测。可以通过修改guess
从String
转化为u32
那部分代码来实现:
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
从expect
调用切换到expect
语句是如何从遇到错误就崩溃到真正处理错误的常用手段。记住parse
返回一个Result
类型,而Result
是一个拥有Ok
或Err
两个成员的枚举。在这里使用match
表达式,就像之前处理cmp
方法返回的Ordering
一样。
如果parse
能够成功的将字符串转换为一个数字,它会返回一个包含结果数字Ok
值。这个Ok
值会匹配第一个分支的模式,这时match
表达式仅仅返回parse
产生的Ok
值之中的num
值。这个数字会最终如期变成新创建的guess
变量。
如果parse
不能将字符串转换为一个数字,它会返回一个包含更多错误信息的Err
值。Err
值不能匹配第一个match
分支的Ok(num)
模式,但是会匹配第二个分支的Err(_)
模式。_
是一个包罗万象的值;在这个例子中,我们想要匹配所有Err
值,不管其中有何种信息。所以程序会执行第二个分支的代码,continue
,这意味着进入loop
的下一次循环并请求另一个猜测。这样程序就有效地忽略了parse
可能遇到的所有错误!
现在万事俱备(只欠东风)了。运行cargo run
来尝试一下:
$ cargo run
Compiling guessing_game v0.1.0 (file:///projects/guessing_game)
Running `target/guessing_game`
Guess the number!
The secret number is: 61
Please input your guess.
10
You guessed: 10
Too small!
Please input your guess.
99
You guessed: 99
Too big!
Please input your guess.
foo
Please input your guess.
61
You guessed: 61
You win!
太棒了!再有最后一个小的修改,就能完成猜猜看游戏了:还记得程序依然会打印出秘密数字。这在测试时还好,但会毁了游戏性。删掉打印秘密数字的println!
。列表 2-5 为最终代码:
总结一下,
此时此刻,你顺利完成了猜猜看游戏!恭喜!
这是一个通过动手实践的方式想你介绍许多 Rust 新知识的项目:let
、match
、方法、关联函数,使用外部 crate,等等。接下来的几章,我们将会详细学习这些概念。第三章涉及到大部分编程语言都有的概念,比如变量、数据类型和函数,以及如何在 Rust 中使用他们。第四章探索所有权(ownership),这是一个 Rust 同其他语言都不相同的功能。第五章讨论结构体和方法语法,而第六章侧重解释枚举。
通用编程概念
ch03-00-common-programming-concepts.md
commit 2067b6e2bff990bceb39ae8f35780bd3bed08644
这一章涉及到几乎出现在所有编程语言中的概念,以及他们在 Rust 中如何工作。很多编程语言在核心概念上都是共通的。本章中展示的所有概念没有一个是 Rust 所特有的,不过我们会在 Rust 环境中讨论他们并解释他们的使用习惯。
具体的,我们将会学习变量,基本类型,函数,注释和控制流。这些基础知识将会出现在每一个 Rust 程序中,提早学习这些概念会使你在起步时拥有一个核心的基础。
关键字
Rust 语言有一系列被保留为只能被语言使用的关键字(keywords),如大部分语言一样。注意你不能使用这些关键字作为变量或函数的名称。大部分关键字有特殊的意义,并将会被用来进行 Rust 程序中的多种任务;一些关键字目前没有相关的功能不过为了将来可能添加进 Rust 的功能而被保留。可以在附录 A 中找到一份关键字的列表
变量和可变性
ch03-01-variables-and-mutability.md
commit b0fab378c9c6a817d4f0080d7001d085017cdef8
第二章中提到过,变量默认是不可变(immutable)的。这是 Rust 中许多鼓励以利用 Rust 提供的安全和简单并发优势编写代码的助力之一。不过,仍然有使变量可变的选项。让我们探索一下为什么以及如何鼓励你拥抱不可变性,还有为什么你可能想要弃之不用。
当变量使不可变时,这意味着一旦一个值被绑定上了一个名称,你就不能改变这个值。作为说明,通过cargo new --bin variables
在 projects 目录生成一个叫做 variables 的新项目。
接着,在新建的 variables 目录,打开 src/main.rs 并替换其代码为如下:
Filename: src/main.rs
fn main() {
let x = 5;
println!("The value of x is: {}", x);
x = 6;
println!("The value of x is: {}", x);
}
保存并使用cargo run
运行程序。应该会看到一个错误信息,如下输出所示:
$ cargo run
Compiling variables v0.0.1 (file:///projects/variables)
error[E0384]: re-assignment of immutable variable `x`
--> src/main.rs:4:5
|
2 | let x = 5;
| - first assignment to `x`
3 | println!("The value of x is: {}", x);
4 | x = 6;
| ^^^^^ re-assignment of immutable variable
这个例子显示了编译器如何帮助你寻找程序中的错误。即便编译器错误可能是令人沮丧的,他们也仅仅意味着程序不能安全的完成你想让它完成的工作;他们不能说明你不是一个好的程序员!有经验的 Rustacean 们也会遇到编译器错误。这些错误表明错误的原因是对不可变变量重新赋值
(re-assignment of immutable variable
),因为我们尝试对不可变变量x
赋第二个值。
当尝试去改变之前设计为不可变的值出现编译时错误是很重要的,因为这种情况可能导致 bug。如果代码的一部分假设一个值永远也不会改变而另一部分代码改变了它,这样第一部分代码就有可能不能像它设计的那样运行。你必须承认这种 bug 难以跟踪,尤其是当第二部分代码只是有时当变量使不可变时,这意味着一旦一个值被绑定上了一个名称,你就不能改变这个值。
Rust 编译器保证如果声明一个值不会改变,它就真的不会改变。这意味着当阅读和编写代码时,并不需要记录如何以及在哪可能会被改变,这使得代码易于推导。
不过可变性也是非常有用的。变量只是默认不可变;可以通过在变量名之前增加mut
来使其可变。它向之后的读者表明了其他部分的代码将会改变这个变量值的意图。
例如,改变 src/main.rs 并替换其代码为如下:
Filename: src/main.rs
fn main() {
let mut x = 5;
println!("The value of x is: {}", x);
x = 6;
println!("The value of x is: {}", x);
}
当运行这个程序,出现如下:
$ cargo run
Compiling variables v0.1.0 (file:///projects/variables)
Running `target/debug/variables`
The value of x is: 5
The value of x is: 6
通过mut
,允许把绑定到x
的值从5
改成6
。在一些情况下,你会想要使一个变量可变,因为这比只使用不可变变量实现的代码更易于编写。
除了避免 bug 外,这里还有数个需要权衡取舍的地方。例如,有时使用大型数据结构时,适当地使变量可变可能比复制和返回新分配的实例要更快。对于较小的数据结构,总是创建新实例并采用一种更函数式的编程风格可能会使代码更易理解。所以为了可读性而造成的性能惩罚也许使值得的。
变量和常量的区别
不能改变一个变量的值可能会使你想起另一个大部分编程语言都有的概念:常量(constants)。常量也是绑定到一个名称的不允许改变的值,不过常量与变量还是有一些区别。首先,不允许对常量使用mut
:常量不光是默认不能改变,它总是不能改变。常量使用const
关键字而不是let
关键字声明,而且必须注明值的类型。现在我们准备在下一部分,“数据类型”,涉及到类型和类型注解,所以现在无需担心这些细节。常量可以在任何作用域声明,包括全局作用域,这在一个值需要被很多部分的代码用到时很有用。最后一个区别是常量只能用于常量表达式,而不能作为函数调用的结果或任何其他只在运行时使用到的值。
这是一个常量声明的例子,它的名称是MAX_POINTS
而它的值是 100,000。Rust 常量的命名规范是使用大写字母和单词间使用下划线:
const MAX_POINTS: u32 = 100_000;
常量在整个程序生命周期中都有效,位于它声明的作用域之中。这使得常量可以用作多个部分的代码可能需要知道的程序范围的值,例如一个游戏中任何玩家可以获得的最高分或者一年的秒数。
将用于整个程序的硬编码的值命名为常量(并编写文档)对为将来代码维护者表明值的意义是很有用的。它也能帮助你将硬编码的值至于一处以便将来可能需要修改他们。
覆盖
如第二章猜猜看游戏所讲到的,我们可以定义一个与之前变量名称相同的新变量,而新变量会覆盖之前的变量。Rustacean 们称其为第一个变量被第二个给覆盖了,这意味着第二个变量的值是使用这个变量时会看到的值。可以用相同变量名称来覆盖它自己以及重复使用let
关键字来多次覆盖,如下所示:
Filename: src/main.rs
fn main() {
let x = 5;
let x = x + 1;
let x = x * 2;
println!("The value of x is: {}", x);
}
这个程序首先将x
绑定到值5
上。接着通过let x =
覆盖x
,获取原始值并加1
这样x
的值就变成6
了。第三个let
语句也覆盖了x
,获取之前的值并乘以2
,x
的最终值是12
。当运行这个程序,它会有如下输出:
$ cargo run
Compiling variables v0.1.0 (file:///projects/variables)
Running `target/debug/variables`
The value of x is: 12
这与将变量声明为mut
是有区别的。因为除非再次使用let
关键字,不小心尝试对变量重新赋值会导致编译时错误。我们可以用这个值进行一些计算,不过计算完之后变量仍然是不变的。
另一个mut
与覆盖的区别是当再次使用let
关键字时,事实上创建了一个新变量,我们可以改变值的类型。例如,假设程序请求用户输入空格来提供在一些文本之间需要多少空间来分隔,不过我们真正需要的是将输入存储成数字(多少个空格):
let spaces = " ";
let spaces = spaces.len();
这里允许第一个spaces
变量是字符串类型,而第二个spaces
变量,它是一个恰巧与第一个变量名字相同的崭新的变量,它是数字类型。因此覆盖使我们不必使用不同的名字,比如spaces_str
和spaces_num
;相反,我们可以复用spaces
这个更简单的名称。然而,如果尝试使用mut
,如下所示:
let mut spaces = " ";
spaces = spaces.len();
会导致一个编译时错误,因为不允许改变一个变量的类型:
error[E0308]: mismatched types
--> src/main.rs:3:14
|
3 | spaces = spaces.len();
| ^^^^^^^^^^^^ expected &str, found usize
|
= note: expected type `&str`
= note: found type `usize`
现在我们探索了变量如何工作,让我们看看他们能有多少数据类型。
数据类型
ch03-02-data-types.md
commit d05b7c63ff50b3f9126bb5533e0ba5dd424b83d1
Rust 中的任何值都有一个具体的类型(type),这告诉了 Rust 它被指定为何种数据这样 Rust 就知道如何处理这些数据了。这一部分将讲到一些语言内建的类型。我们将这些类型分为两个子集:标量(scalar)和复合(compound)。
贯穿整个部分,请记住 Rust 是一个静态类型(statically typed)语言,也就是说必须在编译时就知道所有变量的类型。编译器通常可以通过值以及如何使用他们来推断出我们想要用的类型。当多个类型都是可能的时候,比如第二章中parse
将String
转换为数字类型,必须增加类型注解,像这样:
let guess: u32 = "42".parse().unwrap();
如果这里不添加类型注解,Rust 会显示如下错误,它意味着编译器需要我们提供更多我们想要使用哪个可能的类型的信息:
error[E0282]: unable to infer enough type information about `_`
--> src/main.rs:2:5
|
2 | let guess = "42".parse().unwrap();
| ^^^^^ cannot infer type for `_`
|
= note: type annotations or generic parameter binding required
在我们讨论各种数据类型时会看到不同的类型注解。
标量类型
标量类型代表一个单独的值。Rust 有四种基本的标量类型:整型、浮点型、布尔类型和字符类型。你可能在其他语言中见过他们,不过让我们深入了解他们在 Rust 中时如何工作的。
整型
整数是一个没有小数部分的数字。我们在这一章的前面使用过一个整型,i32
类型。这个类型声明表明在 32 位系统上它关联的值应该是一个有符号整数(因为这个i
,与u
代表的无符号相对)。表格 3-1 展示了 Rust 内建的整数类型。每一个变体的有符号和无符号列(例如,i32)可以用来声明对应的整数值。
Table 3-1: Integer Types in Rust
Length | Signed | Unsigned |
8-bit | i8 | u8 |
16-bit | i16 | u16 |
32-bit | i32 | u32 |
64-bit | i64 | u64 |
arch | isize | usize |
每一种变体都可以是有符号或无符号的并有一个显式的大小。有符号和无符号代表数字是否能够是正数或负数;换句话说,数字是否需要有一个符号(有符号数)或者永远只需要是正的这样就可以不用符号(无符号数)。这有点像在纸上书写数字:当需要考虑符号的时候,数字前面会加上一个加号或减号;然而,当可以安全地假设为正数时,可以不带符号(加号)。有符号数以二进制补码形式(two’s complement representation)存储(如果你不清楚这是什么,可以在网上搜索;对其的解释超出了本书的范畴)。
每一个有符号的变体可以储存包含从 -(2n - 1) 到 2n - 1 - 1 在内的数字,这里n
是变体使用的位数。所以i8
可以储存从 -(27) 到 27 - 1 在内的数字,也就是从 -128 到 127。无符号的变体可以储存从 0 到 2n - 1 的数字,所以u8
可以储存从 0 到 28 - 1 的数字,也就是从 0 到 255。
另外,isize
和usize
类型依赖运行程序的计算机类型(构架):64 位构架他们是 64 位的而 32 位构架他们就是 32 位的。
可以使用表格 3-2 中的任何一种形式编写数字字面值。注意除了字节字面值以外的数字字面值允许使用类型后缀,例如57u8
,而_
是可视化分隔符(visual separator),例如1_000
位的。
Table 3-2: Integer Literals in Rust
Number literals | Example |
Decimal | 98_222 |
Hex | 0xff |
Octal | 0o77 |
Binary | 0b1111_0000 |
Byte (u8 only) | b'A' |
那么如何知晓该使用哪种类型的数字呢?如果对此拿不定主意,Rust 的默认类型通常就是一个很好的选择,这个默认数字类型是i32
:它通常是最快的,甚至是在 64 位系统上。使用isize
或usize
的主要场景是索引一些集合。
浮点型
Rust 也有两个主要的浮点数(floating-point numbers)类型,他们是有小数点的数字。Rust 的浮点数类型是f32
和f64
,分别是 32 位 和 64 位大小。默认类型是f64
,因为它基本上与f32
一样快不过精度更高。在 32 位系统上使用f64
是可能的,不过会比f32
要慢。大部分情况,牺牲潜在可能的更低性能来换取更高的精度是一个合理的首要选择,同时如果怀疑浮点数的大小有问题的时候应该对代码进行性能测试。
这是一个展示浮点数的实例:
Filename: src/main.rs
fn main() {
let x = 2.0; // f64
let y: f32 = 3.0; // f32
}
浮点数采用 IEEE-754 标准表示。f32
是单精度浮点数,f64
是双精度浮点数。
数字运算符
Rust 支持所有数字类型常见的基本数学运算操作:加法、减法、乘法、除法以及余数。如下代码展示了如何使用一个let
语句来使用他们:
Filename: src/main.rs
fn main() {
// addition
let sum = 5 + 10;
// subtraction
let difference = 95.5 - 4.3;
// multiplication
let product = 4 * 30;
// division
let quotient = 56.7 / 32.2;
// remainder
let remainder = 43 % 5;
}
这些语句中的每个表达式使用了一个数学运算符并计算出了一个值,他们绑定到了一个变量。附录 B 包含了一个 Rust 提供的所有运算符的列表。
布尔型
正如其他大部分编程语言一样,Rust 中的布尔类型有两个可能的值:true
和false
。Rust 中的布尔类型使用bool
表示。例如:
Filename: src/main.rs
fn main() {
let t = true;
let f: bool = false; // with explicit type annotation
}
使用布尔值的主要场景是条件语句,例如if
。在“控制流”(“Control Flow”)部分将讲到if
语句在 Rust 中如何工作。
字符类型
目前为止只使用到了数字,不过 Rust 也支持字符。Rust 的char
类型是大部分语言中基本字母字符类型,如下代码展示了如何使用它:
Filename: src/main.rs
fn main() {
let c = 'z';
let z = 'ℤ';
let heart_eyed_cat = '😻';
}
Rust 的char
类型代表了一个 Unicode 变量值(Unicode Scalar Value),这意味着它可以比 ASCII 表示更多内容。拼音字母(Accented letters),中文/日文/汉语等象形文字,emoji(絵文字)以及零长度的空白字符对于 Rust char
类型都是有效的。Unicode 标量值包含从 U+0000
到 U+D7FF
和 U+E000
到 U+10FFFF
之间的值。不过,“字符”并不是一个 Unicode 中的概念,所以人直觉上的“字符”可能与 Rust 中的char
并不符合。第八章的“字符串”部分将详细讨论这个主题。
复合类型
复合类型可以将多个其他类型的值组合成一个类型。Rust 有两个原生的复合类型:元组(tuple)和数组(array)。
将值组合进元组
元组是一个将多个其他类型的值组合进一个复合类型的组要方式。
我们使用一个括号中的逗号分隔的值列表来创建一个元组。元组中的每一个位置都有一个类型,而且这写不同值的类型也不必是相同的。这个例子中使用了额外的可选类型注解:
Filename: src/main.rs
fn main() {
let tup: (i32, f64, u8) = (500, 6.4, 1);
}
tup
变量绑定了整个元组,因为元组被认为是一个单独的复合元素。为了从元组中获取单个的值,可以使用模式匹配(pattern matching)来解构(destructure )元组,像这样:
Filename: src/main.rs
fn main() {
let tup = (500, 6.4, 1);
let (x, y, z) = tup;
println!("The value of y is: {}", y);
}
程序首先创建了一个元组并绑定到tup
变量上。接着使用了let
和一个模式将tup
分成了三个不同的变量,x
、y
和z
。这叫做解构(destructuring),因为它将一个元组拆成了三个部分。最后,程序打印出了y
的值,也就是6.4
。
除了使用模式匹配解构之外,也可以使用点号(.
)后跟值的索引来直接访问。例如:
Filename: src/main.rs
fn main() {
let x: (i32, f64, u8) = (500, 6.4, 1);
let five_hundred = x.0;
let six_point_four = x.1;
let one = x.2;
}
这个程序创建了一个元组,x
,并接着使用索引为每个元素创建新变量。跟大多数编程语言一样,元组的第一个索引值是 0。
数组
另一个获取一个多个值集合的方式是数组(array)。与元组不同,数组中的每个元素的类型必须相同。Rust 中的数组与一些其他语言中的数组不同,因为 Rust 中的数组是固定长度的:一旦声明,他们的长度不能增长或缩小。
Rust 中数组的值位于中括号中的逗号分隔的列表中:
Filename: src/main.rs
fn main() {
let a = [1, 2, 3, 4, 5];
}
数组在想要在栈(stack)而不是在堆(heap)上为数据分配空间时十分有用(第四章将讨论栈与堆的更多内容),或者是想要确保总是有固定数量的元素时。虽然它并不如 vector 类型那么灵活。vector 类型是标准库提供的一个允许增长和缩小长度的类似数组的集合类型。当不确定是应该使用数组还是 vector 的时候,你可能应该使用 vector:第八章会详细讨论 vector。
一个你可能想要使用数组而不是 vector 的例子是当程序需要知道一年中月份的名字时。程序不大可能回去增加或减少月份,这时你可以使用数组因为我们知道它总是含有 12 个元素:
let months = ["January", "February", "March", "April", "May", "June", "July",
"August", "September", "October", "November", "December"];
访问数组元素
数组是一整块分配在栈上的内存。可以使用索引来访问数组的元素,像这样:
Filename: src/main.rs
fn main() {
let a = [1, 2, 3, 4, 5];
let first = a[0];
let second = a[1];
}
在这个例子中,叫做first
的变量的值是1
,因为它是数组索引[0]
的值。second
将会是数组索引[1]
的值2
。
无效的数组元素访问
如果我们访问数组结尾之后的元素会发生什么呢?比如我们将上面的例子改为如下:
Filename: src/main.rs
fn main() {
let a = [1, 2, 3, 4, 5];
let element = a[10];
println!("The value of element is: {}", element);
}
使用cargo run
运行代码后会产生如下结果:
$ cargo run
Compiling arrays v0.1.0 (file:///projects/arrays)
Running `target/debug/arrays`
thread '<main>' panicked at 'index out of bounds: the len is 5 but the index is
10', src/main.rs:4
note: Run with `RUST_BACKTRACE=1` for a backtrace.
error: Process didn't exit successfully: `target/debug/arrays` (exit code: 101)
编译并没有产生任何错误,不过程序会导致一个运行时(runtime)错误并且不会成功退出。当尝试用索引访问一个元素时,Rust 会检查指定的索引是否小于数组的长度。如果索引超出了数组长度,Rust 会panic,这是 Rust 中的术语,它用于程序因为错误而退出的情况。
这是第一个在实战中遇到的 Rust 安全原则的例子。在很多底层语言中,并没有进行这类检查,这样当提供了一个不正确的索引时,就会访问无效的内存。Rust 通过立即退出而不是允许内存访问并继续执行来使你免受这类错误困扰。第九章会讨论更多 Rust 的错误处理。
函数如何工作
ch03-03-how-functions-work.md
commit 52b7fcbfdd35915cb21e6d492fb6c86764f53b47
函数在 Rust 代码中应用广泛。你已经见过一个语言中最重要的函数:main
函数,它时很多程序的入口点。你也见过了fn
关键字,它用来声明新函数。
Rust 代码使用 snake case 作为函数和变量名称的规范风格。在 snake case 中,所有字母都是小写并使用下划线分隔单词。这里是一个函数定义程序的例子:
Filename: src/main.rs
fn main() {
println!("Hello, world!");
another_function();
}
fn another_function() {
println!("Another function.");
}
Rust 中的函数定义以fn
开始并在函数名后跟一对括号。大括号告诉编译器哪里是函数体的开始和结尾。
可以使用定义过的函数名后跟括号来调用任意函数。因为another_function
在程序中已经定义过了,它可以在main
函数中被调用。注意,源码中another_function
在main
函数之后被定义;也可以在之前定义。Rust 不关心函数定义于何处,只要他们被定义了。
让我们开始一个叫做functions的新二进制项目来进一步探索函数。将上面的another_function
例子写入 src/main.rs 中并运行。你应该会看到如下输出:
$ cargo run
Compiling functions v0.1.0 (file:///projects/functions)
Running `target/debug/functions`
Hello, world!
Another function.
代码在main
函数中按照他们出现的顺序被执行。首先,打印“Hello, world!”信息,接着another_function
被调用并打印它的信息。
函数参数
函数也可以被定义为拥有参数(parameters),他们是作为函数签名一部分的特殊变量。当函数拥有参数,可以为这些参数提供具体的值。技术上讲,这些具体值被称为参数( arguments),不过通常的习惯是倾向于在函数定义中的变量和调用函数时传递的具体值都可以用 "parameter" 和 "argument" 而不加区别。
如下被重写的another_function
版本展示了 Rust 中参数是什么样的:
Filename: src/main.rs
fn main() {
another_function(5);
}
fn another_function(x: i32) {
println!("The value of x is: {}", x);
}
尝试运行程序,将会得到如下输出:
$ cargo run
Compiling functions v0.1.0 (file:///projects/functions)
Running `target/debug/functions`
The value of x is: 5
another_function
的声明有一个叫做x
的参数。x
的类型被指定为i32
。当5
被传递给another_function
时,println!
宏将5
放入格式化字符串中大括号的位置。
在函数签名中,必须声明每个参数的类型。这是 Rust 设计中一个经过慎重考虑的决定:要求在函数定义中提供类型注解意味着编译器再也不需要在别的地方要求你注明类型就能知道你的意图。
当一个函数有多个参数时,使用逗号隔开他们,像这样:
Filename: src/main.rs
fn main() {
another_function(5, 6);
}
fn another_function(x: i32, y: i32) {
println!("The value of x is: {}", x);
println!("The value of y is: {}", y);
}
这个例子创建了一个有两个参数的函数,都是i32
类型的。函数打印出了这两个参数的值。注意函数参数并一定都是相同的————这个例子中他们只是碰巧相同。
尝试运行代码。使用上面的例子替换当前 function 项目的 src/main.rs 文件,并cargo run
运行它:
$ cargo run
Compiling functions v0.1.0 (file:///projects/functions)
Running `target/debug/functions`
The value of x is: 5
The value of y is: 6
因为我们使用5
作为x
的值和6
作为y
的值来调用函数,这两个字符串使用这些值并被打印出来。
函数体
函数体由一系列的语句和一个可选的表达式构成。目前为止,我们只涉及到了没有结尾表达式的函数,不过我们见过表达式作为了语句的一部分。因为 Rust 是一个基于表达式(expression-based)的语言,这是一个需要理解的(不同于其他语言)重要区别。其他语言并没有这样的区别,所以让我们看看语句与表达式有什么区别以及他们是如何影响函数体的。
语句与表达式
我们已经用过语句与表达式了。语句(Statements)是执行一些操作但不返回值的指令。表达式(Expressions)计算并产生一个值。让我们看看一些例子:
使用let
关键字创建变量并绑定一个值是一个语句。在列表 3-3 中,let y = 6;
是一个语句:
函数定义也是语句;上面整个例子本身就是一个语句。
语句并不返回值。因此,不能把let
语句赋值给另一个变量,比如下面的例子尝试做的:
Filename: src/main.rs
fn main() {
let x = (let y = 6);
}
当运行这个程序,会得到如下错误:
$ cargo run
Compiling functions v0.1.0 (file:///projects/functions)
error: expected expression, found statement (`let`)
--> src/main.rs:2:14
|
2 | let x = (let y = 6);
| ^^^
|
= note: variable declaration using `let` is a statement
let y = 6
语句并不返回值,所以并没有x
可以绑定的值。这与其他语言不同,例如 C 和 Ruby,他们的赋值语句返回所赋的值。在这些语言中,可以这么写x = y = 6
这样x
和y
的值都是6
;这在 Rust 中可不行。
表达式进行计算而且他们组成了其余大部分 Rust 代码。考虑一个简单的数学运算,比如5 + 6
,这是一个表达式并计算出值11
。表达式可以是语句的一部分:在列表 3-3 中有这个语句let y = 6;
,6
是一个表达式它计算出的值是6
。函数调用是一个表达式。宏调用是一个表达式。我们用来创新建作用域的大括号(代码块),{}
,也是一个表达式,例如:
Filename: src/main.rs
fn main() {
let x = 5;
let y = {
let x = 3;
x + 1
};
println!("The value of y is: {}", y);
}
这个表达式:
{
let x = 3;
x + 1
}
这个代码块的值是4
。这个值作为let
语句的一部分被绑定到y
上。注意结尾没有分号的那一行,与大部分我们见过的代码行不同。表达式并不包含结尾的分号。如果在表达式的结尾加上分号,他就变成了语句,这也就使其不返回一个值。在接下来的探索中记住函数和表达式都返回值就行了。
函数的返回值
可以向调用它的代码返回值。并不对返回值命名,不过会在一个箭头(->
)后声明它的类型。在 Rust 中,函数的返回值等同于函数体最后一个表达式的值。这是一个有返回值的函数的例子:
Filename: src/main.rs
fn five() -> i32 {
5
}
fn main() {
let x = five();
println!("The value of x is: {}", x);
}
在函数five
中并没有函数调用、宏、甚至也没有let
语句————只有数字5
它子集。这在 Rust 中是一个完全有效的函数。注意函数的返回值类型也被指定了,就是-> i32
。尝试运行代码;输出应该看起来像这样:
$ cargo run
Compiling functions v0.1.0 (file:///projects/functions)
Running `target/debug/functions`
The value of x is: 5
函数five
的返回值是5
,也就是为什么返回值类型是i32
。让我们仔细检查一下这段代码。这有两个重要的部分:首先,let x = five();
这一行表明我们使用函数的返回值来初始化了一个变量。因为函数five
返回5
,这一行与如下这行相同:
let x = 5;
再次,函数five
没有参数并定义了返回值类型,不过函数体只有单单一个5
也没有分号,因为这是我们想要返回值的表达式。让我们看看另一个例子:
Filename: src/main.rs
fn main() {
let x = plus_one(5);
println!("The value of x is: {}", x);
}
fn plus_one(x: i32) -> i32 {
x + 1
}
运行代码会打印出The value of x is: 6
。如果在包含x + 1
的那一行的结尾加上一个分号,把它从表达式变成语句后会怎样呢?
fn main() {
let x = plus_one(5);
println!("The value of x is: {}", x);
}
fn plus_one(x: i32) -> i32 {
x + 1;
}
运行代码会产生一个错误,如下:
error[E0269]: not all control paths return a value
--> src/main.rs:7:1
|
7 | fn plus_one(x: i32) -> i32 {
| ^
|
help: consider removing this semicolon:
--> src/main.rs:8:10
|
8 | x + 1;
| ^
主要的错误信息,“并非所有控制路径都返回一个值”(“not all control paths return a value,”),揭示了代码的核心问题。函数plus_one
的定义说明它要返回一个i32
,不过语句并不返回一个值。因此,这个函数没有返回任何值,这与函数定义相矛盾并导致一个错误。在输出中,Rust 提供了一个可能会对修正问题有帮助的信息:它建议去掉分号,这会修复这个错误。
注释
ch03-04-comments.md
commit 74d6fc999b986b74bf94edd6dcbb5a08a16c12de
所有编程语言都力求使他们的代码易于理解,不过有时额外的解释需要得到保障。在这种情况下,程序员在源码中留下记录,或者注释(comments),编译器会忽略他们不过其他阅读代码的人可能会用得上。
这是一个注释的例子:
// Hello, world.
在 Rust 中,注释必须以两道斜杠开始并持续到本行的结尾。对于超过一行的注释,需要在每一行都加上//
,像这样:
// So we’re doing something complicated here, long enough that we need
// multiple lines of comments to do it! Whew! Hopefully, this comment will
// explain what’s going on.
注释也可以在放在包含代码的行的结尾:
Filename: src/main.rs
fn main() {
let lucky_number = 7; // I’m feeling lucky today.
}
不过你会经常看到他们被以这种格式使用,也就是位于它解释的代码行的上面一行:
Filename: src/main.rs
fn main() {
// I’m feeling lucky today.
let lucky_number = 7;
}
这就是注释的全部。并没有什么特别复杂的。
控制流
ch03-05-control-flow.md
commit 784a3ec5e8b9c6bff456ab9f0efd4dabcc180dda
通过条件是不是真来决定是否某些代码,或者根据条件是否为真来重复运行一段代码是大部分编程语言的基本组成部分。Rust 代码中最常见的用来控制执行流的结构是if
表达式和循环。
if
表达式
if
表达式允许根据条件执行不同的代码分支。我们提供一个条件并表示“如果符合这个条件,运行这段代码。如果条件不满足,不运行这段代码。”
在 projects 目录创建一个叫做 branches 的新项目来学习if
表达式。在 src/main.rs 文件中,输入如下内容:
Filename: src/main.rs
fn main() {
let number = 3;
if number < 5 {
println!("condition was true");
} else {
println!("condition was false");
}
}
所有if
表达式以if
关键字开头,它后跟一个条件。在这个例子中,条件检查number
是否有一个小于 5 的值。在条件为真时希望执行的代码块位于紧跟条件之后的大括号中。if
表达式中与条件关联的代码块有时被叫做 arms,就像第二章“比较猜测与秘密数字”部分中讨论到的match
表达式中分支一样。也可以包含一个可选的else
表达式,这里我们就这么做了,来提供一个在条件为假时应当执行的代码块。如果不提供else
表达式并且条件为假时,程序会直接忽略if
代码块并继续执行下面的代码。
尝试运行代码,应该能看到如下输出:
$ cargo run
Compiling branches v0.1.0 (file:///projects/branches)
Running `target/debug/branches`
condition was true
尝试改变number
的值使条件为假时看看会发生什么:
let number = 7;
再次运行程序并查看输出:
$ cargo run
Compiling branches v0.1.0 (file:///projects/branches)
Running `target/debug/branches`
condition was false
另外值得注意的是代码中的条件必须是bool
。如果像看看条件不是bool
值时会发生什么,尝试运行如下代码:
Filename: src/main.rs
fn main() {
let number = 3;
if number {
println!("number was three");
}
}
这里if
条件的值是3
,Rust 抛出了一个错误:
Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: mismatched types
--> src/main.rs:4:8
|
4 | if number {
| ^^^^^^ expected bool, found integral variable
|
= note: expected type `bool`
= note: found type `{integer}`
error: aborting due to previous error
Could not compile `branches`.
这个错误表明 Rust 期望一个bool
不过却得到了一个整型。Rust 并不会尝试自动地将非布尔值转换为布尔值,不像例如 Ruby 和 JavaScript 这样的语言。必须总是显式地使用boolean
作为if
的条件。例如如果想要if
代码块只在一个数字不等于0
时执行,可以把if
表达式修改为如下:
Filename: src/main.rs
fn main() {
let number = 3;
if number != 0 {
println!("number was something other than zero");
}
}
运行代码会打印出number was something other than zero
。
使用else if
实现多重条件
可以将else if
表达式与if
和else
组合来实现多重条件。例如:
Filename: src/main.rs
fn main() {
let number = 6;
if number % 4 == 0 {
println!("number is divisible by 4");
} else if number % 3 == 0 {
println!("number is divisible by 3");
} else if number % 2 == 0 {
println!("number is divisible by 2");
} else {
println!("number is not divisible by 4, 3, or 2");
}
}
这个程序有四个可能的执行路径。运行后应该能看到如下输出:
$ cargo run
Compiling branches v0.1.0 (file:///projects/branches)
Running `target/debug/branches`
number is divisible by 3
当执行这个程序,它按顺序检查每个if
表达式并执行第一个条件为真的代码块。注意即使 6 可以被 2 整除,也不会出现number is divisible by 2
的输出,更不会出现else
块中的number is not divisible by 4, 3, or 2
。原因是 Rust 只会执行第一个条件为真的代码块,并且它一旦找到一个以后,就不会检查剩下的条件了。
使用过多的else if
表达式会使代码显得杂乱无章,所以如果有多于一个else if
,最好重构代码。为此第六章介绍了 Rust 一个叫做match
的强大的分支结构(branching construct)。
在let
语句中使用if
因为if
是一个表达式,我们可以在let
语句的右侧使用它,例如列表 3-4:
number
变量将会绑定到基于if
表达式结果的值。运行这段代码看看会出现什么:
$ cargo run
Compiling branches v0.1.0 (file:///projects/branches)
Running `target/debug/branches`
The value of number is: 5
还记得代码块的值是其最后一个表达式的值,以及数字本身也是一个表达式吗。在这个例子中,整个if
表达式的值依赖哪个代码块被执行。这意味着if
的每个分支的可能的返回值都必须是相同类型;在列表 3-4 中,if
分支和else
分支的结果都是i32
整型。不过如果像下面的例子一样这些类型并不相同会怎么样呢?
Filename: src/main.rs
fn main() {
let condition = true;
let number = if condition {
5
} else {
"six"
};
println!("The value of number is: {}", number);
}
当运行这段代码,会得到一个错误。if
和else
分支的值类型是不相容的,同时 Rust 也准确地表明了在程序中的何处发现的这个问题:
Compiling branches v0.1.0 (file:///projects/branches)
error[E0308]: if and else have incompatible types
--> src/main.rs:4:18
|
4 | let number = if condition {
| ^ expected integral variable, found reference
|
= note: expected type `{integer}`
= note: found type `&’static str`
if
代码块的表达式返回一个整型,而else
代码块返回一个字符串。这并不可行因为变量必须只有一个类型。Rust 需要在编译时就确切的知道number
变量的类型,这样它就可以在编译时证明其他使用number
变量的地方它的类型是有效的。Rust 并不能够在number
的类型只能在运行时确定的情况下完成这些功能;这样会使编译器变得更复杂而且只能为代码提供更少的保障,因为它不得不记录所有变量的多种可能的类型。
使用循环重复执行
多次执行一段代码是很常用的。为了这个功能,Rust 提供了多种循环(loops)。一个循环执行循环体中的代码直到结尾并紧接着从回到开头继续执行。为了实验一下循环,让我们创建一个叫做 loops 的新项目。
Rust 有三种循环类型:loop
、while
和for
。让我们每一个都试试。
使用loop
重复执行代码
loop
关键字告诉 Rust 一遍又一遍的执行一段代码直到你明确要求停止。
作为一个例子,将 loops 目录中的 src/main.rs 文件修改为如下:
Filename: src/main.rs
fn main() {
loop {
println!("again!");
}
}
当执行这个程序,我们会看到again!
被连续的打印直到我们手动停止程序.大部分终端都支持一个键盘快捷键,ctrl-C,来终止一个陷入无限循环的程序。尝试一下:
$ cargo run
Compiling loops v0.1.0 (file:///projects/loops)
Running `target/debug/loops`
again!
again!
again!
again!
^Cagain!
符号^C
代表你在这按下了 ctrl-C。在^C
之后你可能看到again!
也可能看不到,这依赖于在接收到终止信号时代码执行到了循环的何处。
幸运的是,Rust 提供了另一个更可靠的方式来退出循环。可以使用break
关键字来告诉程序何时停止执行循环。还记得我们在第二章猜猜看游戏的“猜测正确后退出”部分使用过它来在用户猜对数字赢得游戏后退出程序吗。
while
条件循环
在程序中计算循环的条件也很常见。当条件为真,执行循环。当条件不再为真,调用break
停止循环。这个循环类型可以通过组合loop
、if
、else
和break
来实现;如果你喜欢的话,现在就可以在程序中试试。
然而,这个模式太常见了所以 Rust 为此提供了一个内建的语言结构,它被称为while
循环。下面的例子使用了while
:程序循环三次,每次数字都减一。接着,在循环之后,打印出另一个信息并退出:
Filename: src/main.rs
fn main() {
let mut number = 3;
while number != 0 {
println!("{}!", number);
number = number - 1;
}
println!("LIFTOFF!!!");
}
这个结构消除了很多需要嵌套使用loop
、if
、else
和break
的代码,这样显得更加清楚。当条件为真就执行,否则退出循环。
使用for
遍历集合
可以使用while
结构来遍历一个元素集合,比如数组。例如:
这里代码对数组中的元素进行计数。它从索引0
开始,并接着循环直到遇到数组的最后一个索引(这时,index < 5
不再为真)。运行这段代码会打印出数组中的每一个元素:
$ cargo run
Compiling loops v0.1.0 (file:///projects/loops)
Running `target/debug/loops`
the value is: 10
the value is: 20
the value is: 30
the value is: 40
the value is: 50
所有数组中的五个元素都如期被打印出来。尽管index
在某一时刻会到达值5
,不过循环在其尝试从数组获取第六个值(会越界)之前就停止了。
不过这个过程是容易出错的;如果索引长度不正确会导致程序 panic。这也使程序更慢,因为编译器增加了运行时代码来对每次循环的每个元素进行条件检查。
可以使用for
循环来对一个集合的每个元素执行一些代码,来作为一个更有效率替代。for
循环看起来像这样:
当运行这段代码,将看到与列表 3-5 一样的输出。更为重要的是,我们增强了代码安全性并消除了出现可能会导致超出数组的结尾或遍历长度不够而缺少一些元素这类 bug 机会。
例如,在列表 3-5 的代码中,如果从数组a
中移除一个元素但忘记更新条件为while index < 4
,代码将会 panic。使用for
循环的话,就不需要惦记着在更新数组元素数量时修改其他的代码了。
for
循环的安全性和简洁性使得它在成为 Rust 中使用最多的循环结构。即使是在想要循环执行代码特定次数时,例如列表 3-5 中使用while
循环的倒计时例子,大部分 Rustacean 也会使用for
循环。这么做的方式是使用Range
,它是标准库提供的用来生成从一个数字开始到另一个数字结束的所有数字序列的类型。
下面是一个使用for
循环来倒计时的例子,它还使用了一个我们还未讲到的方法,rev
,用来反转 range:
Filename: src/main.rs
fn main() {
for number in (1..4).rev() {
println!("{}!", number);
}
println!("LIFTOFF!!!");
}
这段代码看起来更帅气不是吗?
总结
你做到了!这是一个相当可观的章节:你学习了变量,标量和if
表达式,还有循环!如果你想要实践本章讨论的概念,尝试构建如下的程序:
- 相互转换摄氏与华氏温度
- 生成 n 阶斐波那契数列
- 打印圣诞颂歌“The Twelve Days of Christmas”的歌词,并利用歌曲中的重复部分(编写循环)
当你准备好继续的时候,让我们讨论一个其他语言中并不常见的概念:所有权(ownership)。
认识所有权
ch04-00-understanding-ownership.md
commit 759067b651a48a4a66485fe0876d318d398fb4fe
所有权(系统)是 Rust 最独特的功能,它令 Rust 可以无需垃圾回收(garbage collector)就能保障内存安全。因此,理解 Rust 中所有权如何工作是十分重要的。本章我们将讲到所有权以及相关功能:借用、slices 以及 Rust 如何在内存中安排数据。
什么是所有权
ch04-01-what-is-ownership.md
commit cc053d91f41793e54d5321abe027b0c163d735b8
Rust 的核心功能(之一)是所有权(ownership)。虽然这个功能理解起来很直观,不过它对语言的其余部分有着更深层的含义。
所有程序都必须管理他们运行时使用计算机内存的方式。一些语言中使用垃圾回收在程序运行过程中来时刻寻找不再被使用的内存;在另一些语言中,程序员必须亲自分配和释放内存。Rust 则选择了第三种方式:内存被一个所有权系统管理,它拥有一系列的规则使编译器在编译时进行检查。任何所有权系统的功能都不会导致运行时开销。
因为所有权对很多程序员都是一个新概念,需要一些时间来适应。好消息是随着你对 Rust 和所有权系统的规则越来越有经验,你就越能自然地编写出安全和高效的代码。持之以恒!
当你理解了所有权系统,你就会对这个使 Rust 如此独特的功能有一个坚实的基础。在本章中,你将会通过一些例子来学习所有权,他们关注一个非常常见的数据结构:字符串。
栈(Stack)与堆(Heap)
在很多语言中并不经常需要考虑到栈与堆。不过在像 Rust 这样的系统变成语言中,值是位于栈上还是堆上在更大程度上影响了语言的行为以及为何必须做出特定的选择。我们会在本章的稍后部分描述所有权与堆与栈相关的部分,所以这里只是一个用来预热的简要解释。
栈和堆都是代码在运行时可供使用的内存部分,不过他们以不同的结构组成。栈以放入值的顺序存储并以相反顺序取出值。这也被称作后进先出(last in, first out)。想象一下一叠盘子:当增加更多盘子时,把他们放在盘子堆的顶部,当需要盘子时,也从顶部拿走。不能从中间也不能从底部增加或拿走盘子!增加数据叫做进栈(pushing onto the stack),而移出数据叫做出栈(popping off the stack)。
操作栈是非常快的,因为它访问数据的方式:永远也不需要寻找一个位置放入新数据或者取出数据因为这个位置总是在栈顶。另一个使得栈快速的性质是栈中的所有数据都必须是一个已知的固定的大小。
相反对于在编译时未知大小或大小可能变化的数据,可以把他们储存在堆上。堆是缺乏组织的:当向堆放入数据时,我们请求一定大小的空间。操作系统在堆的某处找到一块足够大的空位,把它标记为已使用,并返回给我们一个它位置的指针。这个过程称作在堆上分配内存(allocating on the heap),并且有时这个过程就简称为“分配”(allocating)。向栈中放入数据并不被认为是分配。因为指针是已知的固定大小的,我们可以将指针储存在栈上,不过当需要实际数据时,必须访问指针。
想象一下去餐馆就坐吃饭。当进入时,你说明有几个人,餐馆员工会找到一个够大的空桌子并领你们过去。如果有人来迟了,他们也可以通过询问来找到你们坐在哪。
访问堆上的数据要比访问栈上的数据要慢因为必须通过指针来访问。现代的处理器在内存中跳转越少就越快。继续类比,假设有一台服务器来处理来自多个桌子的订单。它在处理完一个桌子的所有订单后再移动到下一个桌子是最有效率的。从桌子 A 获取一个订单,接着再从桌子 B 获取一个订单,然后再从桌子 A,然后再从桌子 B 这样的流程会更加缓慢。出于同样原因,处理器在处理的数据之间彼此较近的时候(比如在栈上)比较远的时候(比如可能在堆上)能更好的工作。在堆上分配大量的空间也可能消耗时间。
当调用一个函数,传递给函数的值(包括可能指向堆上数据的指针)和函数的局部变量被压入栈中。当函数结束时,这些值被移出栈。
记录何处的代码在使用堆上的什么数据,最小化堆上的冗余数据的数量以及清理堆上不再使用的数据以致不至于用完空间,这些所有的问题正是所有权系统要处理的。一旦理解了所有权,你就不需要经常考虑栈和堆了,不过理解如何管理堆内存可以帮助我们理解所有权为什么存在以及为什么以它的方式工作。
所有权规则
首先,让我们看一下所有权的规则。记住这些规则正如我们将完成一些说明这些规则的例子:
- Rust 中的每一个值都有一个叫做它的所有者(owner)的变量。
- 同时一次只能有一个所有者
- 当所有者变量离开作用域,这个值将被丢弃。
变量作用域
我们在第二章已经完成过一个 Rust 程序的例子了。现在我们已经掌握了基本语法,所以不会在所有的例子中包含fn main() {
代码了,所以如果你是一路跟过来的,必须手动将之后例子的代码放入一个main
函数中。为此,例子将显得更加具体,使我们可以关注具体细节而不是样板代码。
作为所有权的第一个例子,我们看看一些变量的作用域(scope)。作用域是一个 item 在程序中有效的范围。假如有一个这样的变量:
let s = "hello";
变量s
绑定到了一个字符串字面值,这个字符串值是硬编码进我们程序代码中的。这个变量从声明的点开始直到当前作用域结束时都是有效的。列表 4-1 的注释标明了变量s
在哪里是有效的:
换句话说,这里有两个重要的点:
- 当
s
进入作用域,它就是有效的。 - 这一直持续到它离开作用域为止。
目前为止,变量是否有效与作用域的关系跟其他变成语言是类似的。现在我们要在此基础上介绍String
类型。
String
类型
为了演示所有权的规则,我们需要一个比第三章讲到的任何一个都要复杂的数据类型。之前出现的数据类型都是储存在栈上的并且当离开作用域时被移出栈,不过我们需要寻找一个储存在堆上的数据来探索 Rust 如何知道该在何时清理数据。
这里使用String
作为例子并专注于String
与所有权相关的部分。这些方面也同样适用于其他标准库提供的或你创建的复杂数据类型。在第八章会更深入地讲解String
。
我们已经见过字符串字面值了,它被硬编码进程序里。字符串字面值是很方便,不过他们并不总是适合所有需要使用文本的场景。原因之一就是他们是不可变的。另一个原因是不是所有字符串的值都能在编写代码时就知道:例如,如果想要获取用户输入并储存该怎么办呢?为此,Rust 有第二个字符串类型,String
。这个类型储存在堆上所以储存在编译时未知大小的文本。可以用from
从字符串字面值来创建String
,如下:
let s = String::from("hello");
这两个冒号(::
)运算符允许将特定的from
函数置于String
类型的命名空间(namespace)下而不需要使用类似string_from
这样的名字。在第五章的“方法语法”(“Method Syntax”)部分会着重讲解这个语法而且在第七章会讲到模块的命名空间。
这类字符串可以被修改:
let mut s = String::from("hello");
s.push_str(", world!"); // push_str() appends a literal to a String
println!("{}", s); // This will print `hello, world!`
那么这里有什么区别呢?为什么String
可变而字面值却不行呢?区别在于两个类型对内存的处理上。
内存与分配
字符串字面值的情况,我们在编译时就知道内容所以它直接被硬编码进最终的可执行文件中,这使得字符串字面值快速和高效。不过这些属性都只来源于它的不可变形。不幸的是,我们不能为了每一个在编译时未知大小的文本而将一块内存放入二进制文件中而它的大小还可能随着程序运行而改变。
对于String
类型,为了支持一个可变,可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容。这意味着:
- 内存必须在运行时向操作系统请求
- 需要一个当我们处理完
String
时将内存返回给操作系统的方法
第一部分由我们完成:当调用String::from
时,它的实现请求它需要的内存。这在编程语言中是非常通用的。
然而,第二部分实现起来就各有区别了。在有**垃圾回收(GC)**的语言中, GC 记录并清除不再使用的内存,而我们作为程序员,并不需要关心他们。没有 GC 的话,识别出不再使用的内存并调用代码显式释放就是我们程序员的责任了,正如请求内存的时候一样。从历史的角度上说正确处理内存回收曾经是一个困难的编程问题。如果忘记回收了会浪费内存。如果过早回收了,将会出现无效变量。如果重复回收,这也是个 bug。我们需要allocate
和free
一一对应。
Rust 采取了一个不同的策略:内存在拥有它的变量离开作用域后就被自动释放。下面是列表 4-1 作用域例子的一个使用String
而不是字符串字面值的版本:
{
let s = String::from("hello"); // s is valid from this point forward
// do stuff with s
} // this scope is now over, and s is no
// longer valid
这里是一个将String
需要的内存返回给操作系统的很自然的位置:当s
离开作用域的时候。当变量离开作用域,Rust 为其调用一个特殊的函数。这个函数叫做 drop
,在这里String
的作者可以放置释放内存的代码。Rust 在结尾的}
处自动调用drop
。
注意:在 C++ 中,这种 item 在生命周期结束时释放资源的方法有时被称作资源获取即初始化(Resource Acquisition Is Initialization (RAII))。如果你使用过 RAII 模式的话应该对 Rust 的
drop
函数不陌生。
这个模式对编写 Rust 代码的方式有着深远的影响。它现在看起来很简单,不过在更复杂的场景下代码的行为可能是不可预测的,比如当有多个变量使用在堆上分配的内存时。现在让我们探索一些这样的场景。
变量与数据交互:移动
Rust 中的多个变量以一种独特的方式与同一数据交互。让我们看看列表 4-2 中一个使用整型的例子:
根据其他语言的经验大致可以猜到这在干什么:“将5
绑定到x
;接着生成一个值x
的拷贝并绑定到y
”。现在有了两个变量,x
和y
,都等于5
。这也正是事实上发生了的,因为正数是有已知固定大小的简单值,所以这两个5
被放入了栈中。
现在看看这个String
版本:
let s1 = String::from("hello");
let s2 = s1;
这看起来与上面的代码非常类似,所以我们可能会假设他们的运行方式也是类似的:也就是说,第二行可能会生成一个s1
的拷贝并绑定到s2
上。不过,事实上并不完全是这样。
为了更全面的解释这个问题,让我们看看图 4-3 中String
真正是什么样。String
由三部分组成,如图左侧所示:一个指向存放字符串内容内存的指针,一个长度,和一个容量。这一组数据储存在栈上。右侧则是堆上存放内容的内存部分。
长度代表当前String
的内容使用了多少字节的内存。容量是String
从操作系统总共获取了多少字节的内存。长度与容量的区别是很重要的,不过目前为止的场景中并不重要,所以可以暂时忽略容量。
当我们把s1
赋值给s2
,String
的数据被复制了,这意味着我们从栈上拷贝了它的指针、长度和容量。我们并没有复制堆上指针所指向的数据。换句话说,内存中数据的表现如图 4-4 所示。
这个表现形式看起来并不像图 4-5 中的那样,它是如果 Rust 也拷贝了堆上的数据后内存看起来是怎么样的。如果 Rust 这么做了,那么操作s2 = s1
在堆上数据比较大的时候可能会对运行时性能造成非常大的影响。
之前,我们提到过当变量离开作用域后 Rust 自动调用drop
函数并清理变量的堆内存。不过图 4-4 展示了两个数据指针指向了同一位置。这就有了一个问题:当s2
和s1
离开作用域,他们都会尝试释放相同的内存。这是一个叫做 double free 的错误,也是之前提到过的内存安全性 bug 之一。两次释放(相同)内存会导致内存污染,它可能会导致安全漏洞。
为了确保内存安全,这种场景下 Rust 的处理有另一个细节值得注意。与其尝试拷贝被分配的内存,Rust 则认为s1
不再有效,因此 Rust 不需要在s1
离开作用域后清理任何东西。看看在s2
被创建之后尝试使用s1
会发生生么:
let s1 = String::from("hello");
let s2 = s1;
println!("{}", s1);
你会得到一个类似如下的错误,因为 Rust 禁止你使用无效的引用。
error[E0382]: use of moved value: `s1`
--> src/main.rs:4:27
|
3 | let s2 = s1;
| -- value moved here
4 | println!("{}, world!",s1);
| ^^ value used here after move
|
= note: move occurs because `s1` has type `std::string::String`,
which does not implement the `Copy` trait
如果你在其他语言中听说过术语“浅拷贝”(“shallow copy”)和“深拷贝”(“deep copy”),那么拷贝指针、长度和容量而不拷贝数据可能听起来像浅拷贝。不过因为 Rust 同时使第一个变量无效化了,这个操作被成为移动(move),而不是浅拷贝。上面的例子可以解读为s1
被移动到了s2
中。那么具体发生了什么如图 4-6 所示。
这样就解决了我们的麻烦!因为只有s2
是有效的,当其离开作用域,它就释放自己的内存,完毕。
另外,这里还隐含了一个设计选择:Rust 永远也不会自动创建数据的“深拷贝”。因此,任何自动的复制可以被认为对运行时性能影响较小。
变量与数据交互:克隆
如果我们确实需要深度复制String
中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做clone