Rust程序设计语言读书笔记一
大约半年前,看了基于本书内容的一套视频教程。觉得很不错。然后当时没有记笔记,这半年一直也没有机会找个Rust项目来撸,几乎把语法忘干净。所以现在决定找到原著,从头看一遍,这次一定要记一记。
本书是在线的,Rust官网点名推荐的,还是很不错的入门教程。
因为本人是前端开发,然后在学校期间也系统的学习过一些C++,所以基本上是基于前端的角度记录的笔记。不一定全面,尽量记下自己的理解。
一:入门指南
第一章简单讲了Rust相关工具。
rustup
rustup是一个管理Rust版本和相关工具的命令行工具。emmm可以理解为官方的mvn、n。但是更强。
- rustup update更新至最近rust版本
- rustup doc可以在本地浏览器中下载当前版本文档
rustc
rustc是Rust的编译器,可以类比为node、java的javac?
- rustc —version,即可打印当前rust版本
Cargo
Cargo是Rust的构建系统和包管理器。可以类比Node的npm,Python的pip。但是要强大的多。绝大多情况下,编译Rust都是跟Cargo打交道。
- cargo new [projectname]是生成一个rust项目,类比与npm init
- 项目文件夹里有个Cargo.toml文件,类比与package.json
- TOML(Tom’s Obvious, Minimal Language)
- 在Rust中,代码包被称之为crates(板条箱)
- cargo build:生成可执行文件
- cargo check:检查代码是否可编译
- cargo run:生成可执行文件并运行
- cargo build —release:去除开发信息的可执行文件
切换源
需要在~/.cargo
下新建config文件,填入:
1 | [source.crates-io] |
再删掉~/.cargo/.package-cache
,重新cargo build即可。
cargo.lock
简单来说,cargo.lock会在第一次构建时创建。这时cargo会根据依赖版本号计算出最终要安装的版本。如^0.8.1。第一次安装时,会发现有^0.8.3,则cargo会实际安装^0.8.3,并且写入cargo.lock。之后再安装,即使有再新的版本,也会根据cargo.lock安装^0.8.3
其他
- 与c++、java一样,main函数是个特殊的函数,在可执行的Rust程序中,它总是最先运行的代码。
- 打包了一个hello world,是一个4M的exe文件。还记得6年前用electon打包了个helloworld,63M好像。。
二:常见的编程概念
如果需要的类型不在预导入内容中,就必须使用use语句显式的引入作用域。
变量与可变性
Rust 用let
关键字声明变量,但是默认是不可变的,声明可变变量则是let mut
唯一可以修改的时机是,声明式未赋值,然后第一次赋值。
1 | let x; |
如果是可变变量,则加一个mut关键字
1 | let mut x; |
Rust还有一个常量,用const
关键字声明。与不可变变量的区别有几点:
- 常量必须立即赋值,任何时候都不能修改。
- 常量必须手动指定类型,不能推断类型
- 常量不能用println!宏来打印输出
隐藏
本质上就是Rust允许声明名称重复的变量,之后声明的会覆盖前面的变量。一个小特性。但是有一个要小小注意的是,需要加上let才称之为隐藏,否则操作原先的变量就叫隐藏
1 | fn main() { |
花括号内的x是隐藏变量,y则是在花括号作用域内修改了外面的x变量而已。
复合类型 - 元组
复合类型可以将多个值组合成一个类型。Rust有两种原生的复合类型:元组(tuple)和数组(array)
Rust是静态类型语言,这也就使得一个变量只能存储一种类型的数据。包括数组,一个数组只能存储一个类型的元素。
Rust的元组可以存储不同类型的复合类型类型。
1 | let tup:(i32, f64, u8) = (500, 6.4, 1); |
有两中方式可以获取元组中的数据,结构与.操作符
1 | let tup:(i32, f64, u8) = (500, 6.4, 1); |
不能用下标获取。
复合类型 - 数组
Rust的数组与C++的一样,需要固定类型,指定数组长度。
1 | let a: [i32; 5] = [1, 2, 3, 4, 5]; |
因为Rust相同类型的数据占用内存的空间是一样的。所以必须指定长度与类型,这样才能在栈中分配空间。效率很高。
数组的访问方式则是下标访问,这点与元组区分开来。
与Javascript的数组类似的是Vector数据结构,Vector才是变长的
函数
- 与C++、Java类似,Rust需要一个入口函数:main
- 用fn加上函数名来声明函数。就不像JS那样有好几种函数声明方式。
- 函数推荐使用snake case风格,如果不是,则会warning。但实际上还是可以跑起来的。
- 函数声明没有先后顺序的要求,会自动提升,这点倒是与Javascript相同
形参与实参
形参指的是函数声明时的变量。是签名的一部分,英文名对应(parameters)
实参指的是函数运行时具体的参数。所以是“实”的,英文名对应(argument)
终于有一个合理的解释,来区分形参实参了。在Rust中,倾向于不区分形参实参。
语句于表达式
语句(Statements)是执行一些操作但不返回值的指令。
表达式(Expressions)计算并产生一个值。
那么是不是可以理解为可以产生值的就是表达式?
举几个书上的例子。
let y = 6
是一个语句,所以在Rust中let x = (let y = 6)
是会报错的
大部分Rust代码是由表达式组成的。一个数学运算5+6
,一个数字6
也是表达式,它返回6
。函数调用是表达式,宏调用是表达式,大括号创建的新的块作用域也是。比较有意思的是,下方的块作用域表达式中的x+1
没有分号。如果加上了分号,就不是表达式了。
1 | { |
控制流
Rust相比于JS、多了一个loop控制流。可以理解为while(true)无限循环,只能通过break语句中断循环。
有两个不一样的是,loop可以返回值(或者说loop本身也是一个表达式?),多个loop可以通过循环标签跳出指定loop;
1 | fn main() { |
while与for循环几乎一样,不展开说。
还有一个不一样的是,因为是静态类型的语言,条件判断处只能用boolean值,不能用其他值。因为在Rust中不存在类型自动转换。JS之所以能在条件判断中填写任意值,是因为不同值之间可以发生隐式转换成boolean。
其他
- 默认,Rust设定了若干个会自动导入到每个程序作用域中的标准库内容,这组内容被称为
预导入(preclude)
内容。
总结
- 变量虽不可变,但不是常量
- 表达式在Rust中无处不在,应该时刻注意,才能很好理解。
- 元组能通过.来访问元素,数组只能通过[]方括号来访问元素,不能互换
三: 所有权
所有权是Rust最为特别的特性,就凭这个特性,Rust就是一个非常值得学习的语言。
说道所有权,得先介绍一下目前其他语言的内存管理方式。目前主流有两种,一种是C#、Java、Javascript的自动垃圾回收,这种方式的缺点是,垃圾回收会阻塞进程,会消耗额外的性能。
Nodejs就因为采用单线程自动垃圾回收方式,导致默认内存必须限制在1.4GB以内,如果太大,垃圾回收会导致明显的卡顿。
另一种是一C、C++为代表的手动内存管理。这种问题就更大了,如果忘记回收,则会导致内存泄漏。如果提前回收,变量就会非法。如果多次回收,那就有可能删除掉意想不到的内容,影响更严重。
而Rust走的是另一种方式,这就是Rust的所有权机制。
所有权规则
- Rust中每一个值都有一个所有者
- 值在任意时刻有且只有一个所有者
- 当所有者(变量)离开作用域,这个值将被丢弃
字符串与字符串字面值
字符串字面值是被硬编码进程序里的
字符串值。字符串字面值是固定长度的,不能被修改的。如果需要可变字符串,则需要另一种类型:String
,声明String类型的方法是:
1 | let mut s = String::from("hello"); |
字符串字面值是在编译时就知道其内容,所以文本被直接硬编码进最终的可执行温建宗。这使得字符串字面值快速且高效。这个高效得益于字符串字面值的不可变性
String类型为了支持可变,可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容。这意味着:
- 必须在运行时向内存分配器(memory allocator)请求内存。
- 需要一个当我们处理完String时将内存返回给分配器的方法。
第一部分由String::from来实现的,在编程语言是非常通用的。
然而,第二部分实现起来就各有区别了。在有 垃圾回收(_garbage collector_,_GC_)的语言中, GC 记录并清除不再使用的内存,而我们并不需要关心它。在大部分没有 GC 的语言中,识别出不再使用的内存并调用代码显式释放就是我们的责任了,跟请求内存的时候一样。从历史的角度上说正确处理内存回收曾经是一个困难的编程问题。如果忘记回收了会浪费内存。如果过早回收了,将会出现无效变量。如果重复回收,这也是个 bug。我们需要精确的为一个 allocate
配对一个 free
。
Rust 采取了一个不同的策略:内存在拥有它的变量离开作用域后就被自动释放。下面是示例 4-1 中作用域例子的一个使用 String
而不是字符串字面值的版本:
1 | { |
这是一个将String需要的内存返回给分配器的很自然的位置:当s
离开作用域的时候。当变量离开作用域,Rust为我们调用一个特殊的函数。这个函数叫做drop
,在这里String的作者可以放置内存的代码。Rust在结尾的}
处自动调用drop
总之Rust走了一个全新的内存回收方式,这种方式对Rust有着深远的影响。
举个例子:
1 | let a = 5; |
这里a和b可以正常打印。但是s1不能打印,报错borrow of moved value: \
s1``
在js里,字符串赋值给a,则会在堆内存分配内存给字符串,然后变量a会有一个指针指向字符串内存。
a再赋值给b,则b也会有一个指针指向字符串内存。这时字符串内存有了两个指针指向它。
在Rust里的情况是:String::from
申请了一块内存,并且声明了一个变量a,a的指针指向字符串内存。这里和js是一样的。
a再赋值给b,则指向字符串内存的指针则移动
到变量b上。a不再拥有指向字符串内存的指针。所以s1无法打印输出。
Rust中从头到尾,所有堆内存都至多只有一个指针,当其指针对应的变量离开作用域,那么对应的堆内存也会被清除
栈内存里不会有这个限制。上面的例子,a和b都能打印,b是a的一份复制。因为栈内存上的占用空间是固定的,所以复制是很快的。
所有权与函数
函数传参也会发生所有权移动
1 | fn main() { |
引用与借用
上面那样移来移去的确实太麻烦了。Rust也有另外的方式更方便一些
1 | fn main() { |
Rust使用&
来表明参数s的类型是一个引用。我们需要传参时,对实参添加&
符号,也需要在形参的声明中声明是一个引用&String
Rust将创建一个引用的行为称为借用(borrowing)
,就如现实生活中,如果一个人拥有某样东西,你可以从他那里借来。当你使用完毕,必须还回去。我们并不拥有它。
如果我们尝试修改借用的变量?答案是:无法修改。
可变引用
Rust也提供了另外一种方式可以对引用修改,语法是,形参于实参的&
改为&mut
。当然变量本身也需要是可变的
1 | fn main() { |
可变引用有一个很大的限制:如果你有一个对该变量的可变引用,你就不能再创建对该变量的引用。
1 | let mut s = String::from("hello"); |
Rust这么设计的好处是可以再编译时就避免数据竞争(data race),数据竞争有三个行为造成:
- 两个或更多指针同时访问同一数据
- 至少有一个指针被用来写入数据
- 没有同步数据访问的机制
数据竞争会导致未定义的行为,难以在运行时追踪,并且难以诊断和修复;Rust避免了这种情况的发生,因为它甚至不会编译存在数据竞争的代码!
不可变引用的用户可不希望在他们的眼皮底下值就被意外的改变了!然而,多个不可变的引用是可以的。因为不可变引用没有能力影响别人读取到的数据。
一个要注意的是,引用的作用域从声明的地方开始一直持续到最后一次使用为止
1 | let mut s = String::from("hello"); |
但是下面是可以的
1 | let mut s = String::from("hello"); |
Slice类型
Slice是一个引用,引用字符串、数组中的一部分。
看看文章给的例子:
1 | fn first_word(s: &String) -> &str { |
留意到形参接受的类型是&String,返回值是&str。书中没解释。
网上搜寻得知:
str是切片类型,String是字符串类型,但是往往说String类型。
因为切片是引用,所以一般都是搭配&
使用,所以一般见到的都是&str
。
1 | let s: String = String::from("hello world"); |
由插件自动提示类型如上,可知,s4、s5都是切片后的,就是切片类型。
而字符串字面量也是一个切片类型,默认是个不可变引用。所以s2是永远不能修改的。
至于啥是切片类型?由[starting_index..ending_index]
这样的表达式返回的值就是切片类型。
语法很直观,可以去看原文。
总结
- Rust是从语法上就避免了数据竞争问题,所以在编译阶段就能彻底避免数据竞争问题。
- 引用是在
最后一次使用
之后被销毁的,与所有权的在作用域结束时释放节点不一样。 - 不可变引用可以重复声明,可变引用只能声明一次。为了避免数据被意外篡改。
五、结构体
结构体的概念与JavaScript的对象类似。是一个可以键值对存储数据的一个容器。
1 | // 声明 |
基本内容上面例子注释能解释清楚。但有几点要注意
- 结构体实例的从另外实例更新也会发生移动。官方口吻是,没有实现copy trait的数据会发生移动,移动过后原来的实例将无法访问。
- 创建实例时在每个花括号后面必须加分号
- 创建实例时每个字段后面都要加逗号,如果是从其他实例更新,则不需要加逗号
- 结构体声明,字符串类型是String,大写开头。布尔是bool,小写,整形是u32,小写。容易混淆
元祖结构体
还有两种特殊的结构体:
1 | struct Color(i32, i32, i32); // 元祖结构体 |
方法
先看文中给的一个计算面积的函数
1 | struct Rectangle { |
area函数
接受Rectangle的引用,输出宽高。再看看什么是方法
1 |
|
抛开debug使用的#derive[Debug]
,area的声明被移动到了impl块中。那么这个area函数
就是方法
。类比与JavaScript是类中的实例方法。
impl块中的所有内容都将于Rectangle类型相关联
。area被关联到Rectangle类型上。area方法的第一个参数变成了&self
,&self是self: &Self的缩写,在impl块中,Self类型是impl块的类型的别名。此处用&表示借用了Self实例,此处只想读取数据,而不是写入。如果需要写入,第一个参数修改为&mut self
。
关联函数
所有在impl块中定义的函数被称为关联函数(associated functions),因为它们与impl后面命名的类型相关。Rust也可以定义不以self为第一参数的关联函数(不是方法)
1 | impl Rectangle { |
区别应该就是形参的第一个参数不命名为self: &Self或&self。这样的方法调用方式是两个冒号::
这个就类比与JavaScript的类的静态方法。
还一些要注意的是:
- 构造体的字段可以与方法同名,调用时加上括号就是调用方法,没括号就是读取字段
- 可以拥有多个impl块。他们会类似Typescript的interface合并。
枚举
Rust的枚举区别相当大,功能相当强大。
声明与使用:
1 | enum IpAddrKind { |
一个更复杂的声明
1 | enum Message { |
可以枚举
很多类型。
枚举有一个和Typescript不一样的是,Rust枚举也可以用impl块来为枚举定义方法。
所以这里得重新整理一下语言。Rust里的描述是:用impl块来为xxx定义方法
。而在JavaScript里应该是:给类加上静态方法等
。
这么想就不奇怪了。impl块可以给结构体、枚举定义方法。这么理解的话,枚举可以给枚举定义方法就不奇怪了。搞不好impl块也能给String、i32等定义方法也是可以理解的。(猜想而已)
1 | impl Message { |
Option枚举
空值(Null)在开发过程中非常常见,也是最容易出问题的地方。原文来引用了null的发明者Tony Hoare的演讲,提到的 “Null References: The Billion Dollar Mistake”,来强调Null容易带来错误。
原文用到Rust并没有很多其他语言中有的空值功能
,靠的就是这个Option枚举。Option枚举使用在可能会有空值的地方,并且强制用户去处理空值的情况,以达到在开发过程就彻底消灭空值
Option枚举是标准库定义的一个枚举,使用场景非常的广泛。
Option枚举定义如下:
1 | enum Option<T> { |
如何理解Some(T)?有点难以理解。首先,Some(T)是枚举Option中的一个值,除了None表示空值,那么Some(T)就表示的是有值
,但又因为是Option枚举的值,它代表的不是肯定有值
,而是一种可能有值
。
在vscode Rust插件的自动提示中。Some(5)会自动推断为Option(i32)、Some('e')推断为Option(char)
所以不能Some(5)当做5。只能当做是Option(T)
的一个模式、一个值。
反过来说,不要错以为Some(T)是函数、方法,Some(T)是Option::Some(T)的简写
。
另外一点,Option<T>和Some(T)永远记得要带上泛型T,暂不知道为啥
match控制流
match是一个强大的控制流运算符
,它允许我们将一个值与一系列的模式相比较,并根据相匹配的模式执行响应代码。模式可由字面值、变量、通配符和许多其他内容构成;
看看代码:
1 | enum Coin { |
match将结果值按顺序与每一个分支的模式相比较。如果匹配了这个值,这个模式相关的代码将被执行。如果模式不匹配,将继续执行下一个分支。
看着其实非常像JavaScript的switch。但是Rust的match语法上更简洁,强调一个模式匹配
。并且match与枚举关联性非常好。JavaScript则没有约束。
match与Option<T>
Option是枚举、match是匹配模式(匹配枚举),所以他们两是最搭配的一对。
1 | let vec: Vec<i32> = vec![1,2,3,4]; |
如上,vec的get方法返回的是Option<T>类型,代表着不一定
读取到数据。所以我们需要用match表达式去处理所有的情况。
Rust为了严谨,安全,默认是需要写出所有匹配模式的。有时候不需要匹配所有值,Rust提供通配符和_占位符。
1 | let dice_roll = 9; |
或者是占位符
1 | let dice_roll = 9; |
如果不需要通配,不能用下划线来当做通配符,是保留值,会报错
if let 简洁控制流
有时候会有这样的写法:
1 | let config_max = Some('s'); |
有时候变量(config_max)是一个Option的值,如果config_max最终不是None,则会走Some(max)这个匹配,并且把值赋给max,打印出来。如果变量是None则不做任何处理。这种情况应该是非常多的,而且_ => ()
是什么必要的。所以Rust提供一个语法糖
1 | let config_max = Some('x'); |
当config_max是Some(T)时,就会走花括号内的逻辑。
if let还支持else
1 | enum Zone { |
match语法是不是专门匹配枚举类型的?即使不是,那也是与枚举天生一对
总结
- Rust的枚举相当强大,其值可以是很多类型,可以用impl块为其添加方法
- Option(T)是最常见的枚举类型,Some(T)、None是Option::Some(T)、Option::None的简写。不是方法、也不是函数。Option<T>专门用来表达“可能没有值”的状态,很奇妙的枚举,与其他语言完全不同的概念。可以消灭(取代)null的一个新概念
- match方法专门用来匹配枚举(待确认),if let是match的语法糖,也是专门匹配枚举。