【跟小嘉学 Rust 编程】一、Rust 编程基础
【跟小嘉学 Rust 编程】二、Rust 包管理工具使用
【跟小嘉学 Rust 编程】三、Rust 的基本程序概念
【跟小嘉学 Rust 编程】四、理解 Rust 的所有权概念
【跟小嘉学 Rust 编程】五、使用结构体关联结构化数据
【跟小嘉学 Rust 编程】六、枚举和模式匹配
【跟小嘉学 Rust 编程】七、使用包(Packages)、单元包(Crates)和模块(Module)来管理项目
【跟小嘉学 Rust 编程】八、常见的集合
【跟小嘉学 Rust 编程】九、错误处理(Error Handling)
【跟小嘉学 Rust 编程】十一、编写自动化测试
【跟小嘉学 Rust 编程】十二、构建一个命令行程序
【跟小嘉学 Rust 编程】十三、函数式语言特性:迭代器和闭包
【跟小嘉学 Rust 编程】十四、关于 Cargo 和 Crates.io
【跟小嘉学 Rust 编程】十五、智能指针(Smart Point)
【跟小嘉学 Rust 编程】十六、无畏并发(Fearless Concurrency)
【跟小嘉学 Rust 编程】十七、面向对象语言特性
【跟小嘉学 Rust 编程】十八、模式匹配(Patterns and Matching)
【跟小嘉学 Rust 编程】十九、高级特性
到目前为止,我们已经学习了 Rust 之中最常用的部分,本章节讲解如下特性
主要教材参考 《The Rust Programming Language》
到目前为止,我们讨论的所有代码在编译时强制执行了 Rust 的内存安全保证,然而 Rust 内部隐藏着另一种语言,它不强制执行这些内存安全保证,它被称为不安全 Rust 和常规 Rust 一样工作,但赋予我们额外的能力。
不安全 Rust 之所以存在,是因为静态分析本质是保守的。当编译器试图确定代码是否支持这些保证时候,拒绝一些有效的程序比接受一些无效的程序要好。虽然代码可能没有问题,但如果 Rust 编译器没有足够的信息来确定,它将拒绝代码。在这些情况下,您可以使用不安全代码告诉编译器:“相信我,我知道我在做什么”。但是请注意,使用不安全的 Rust 的风险由您自己承担,如果不正确地使用不安全的代码,可能会由于内存不安全而出现问题,例如空指针解引用。
Rust 具有不安全另一面的原因是底层计算机硬件本质上是不安全的。如果 Rust 不允许你做不安全的操作,你就不能完成某些任务。 Rust 需要允许您进行低级系统编程,例如直接与操作系统交互,甚至编写自己的操作系统。处理低级系统编程是该语言的目标之一,让我们来探索一下不安全 Rust 可以做什么 以及如何做。
要切换到不安全 Rust 使用 unsafe 关键字,然后启动一个包含不安全代码的新块,你可以在不安全的 Rust 中执行五个在安全 Rust 中无法执行的操作,我们称之为不安全的超能力。
裸指针(raw pointer,又称原生指针)在功能上跟引用类型,同时也需要显式地注明可变性。但是又和引用有所不同,裸指针形式如: * const T
和 *mut T
,分别代表了不可变和可变。
*
操作符,可以用于解引用,但是裸指针 * const T
中,*
只是类型名称的一部分,并没有解引用的含义。
至此我们已经学过三种类似指针的概念:引用、 智能指针、裸指针。与前两者不同,裸指针可以绕过 Rust 的借用规则,可以同时拥有一个数据的可变、不可变指针,甚至可以拥有多个可变的指针,并不能保证指向合法的内存,可以是null,没有实现任何自动回收的(drop)
总之裸指针和 C指针非常像,它需要以牺牲安全性为前提,但是我们获得了更好的性能,也可以跟其他语言和硬件打交道。
范例:基于引用创建裸指针
let mut num = 5;
let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;
as 关键字可以用于强制类型转换,我们这里将引用 &mut / & mut num
强制转换为 * const i32 / * mut i32
在这段代码里面并没有 unsafe 身影,因为创建裸指针是安全的行为,而解引用裸指针才是不安全的行为。
范例:解引用裸指针
fn main() {
let mut num = 5;
let r1 = &num as *const i32;
unsafe {
println!("r1 is: {}", *r1);
}
}
我们基于引用创建裸指针,这种行为是很安全的,但是接下来的方式就不安全了。
let address = 0x012345usize;
let r = address as *const i32;
这里是基于一个内存地址来创建裸指针,这种行为相当危险,试图使用任意的内存地址往往是一种未定义的行为(undefined behavior),因为该内存地址有可能存在值,也有可能没有,就算有值,也大概率不是你需要的值。
同时编译器也有可能会优化这段代码,会造成没有任何内存访问发送,甚至程序还可能会发生段错误(segmentation fault)。总之,你几乎没有好的理由像上面这样实现代码,虽然它是可行的。
如果真的要使用内存地址,也是类似下面的用法啊,先取地址,再使用,而不是凭空捏造一个地址。
use std::{slice::from_raw_parts, str::from_utf8_unchecked};
// 获取字符串的内存地址和长度
fn get_memory_location() -> (usize, usize) {
let string = "Hello World!";
let pointer = string.as_ptr() as usize;
let length = string.len();
(pointer, length)
}
// 在指定的内存地址读取字符串
fn get_str_at_location(pointer: usize, length: usize) -> &'static str {
unsafe { from_utf8_unchecked(from_raw_parts(pointer as *const u8, length)) }
}
fn main() {
let (pointer, length) = get_memory_location();
let message = get_str_at_location(pointer, length);
println!(
"The {} bytes at 0x{:X} stored: {}",
length, pointer, message
);
// 如果大家想知道为何处理裸指针需要 `unsafe`,可以试着反注释以下代码
// let message = get_str_at_location(1000, 10);
}
*
解引用let a = 1;
let b: *const i32 = &a as *const i32;
let c: *const i32 = &a;
unsafe {
println!("{}", *c);
}
使用 * 可以对裸指针进行解引用,由于该指针的内存安全性并没有任何保证,因此我们需要使用 unsafe 来包裹解引用的逻辑(切记,unsafe 语句块的范围一定要尽可能的小,具体原因在上一章节有讲)。
以上代码另一个值得注意的点就是:除了使用 as 来显式的转换,我们还使用了隐式的转换方式 let c: *const i32 = &a;
。在实际使用中,我们建议使用 as 来转换,因为这种显式的方式更有助于提醒用户:你在使用的指针是裸指针,需要小心。
let a: Box<i32> = Box::new(10);
// 需要先解引用a
let b: *const i32 = &*a;
// 使用 into_raw 来创建
let c: *const i32 = Box::into_raw(a);
使用裸指针可以让我们创建两个可变指针指向同一个数据,如果使用安全的 Rust 是无法做到这一点,违背了借用规则,编译器会阻止。因此裸指针可以绕过借用规则,由此带来的数据竞争问题,需要大家自己处理。
重要用途就是跟 C 语言的代码进行交互(FFI),在讲解 FFI 之前,先看看调用 unsafe 函数和方法。
unsafe 函数从外表上来看跟普通函数并无区别,唯一区别就是需要使用 unsafe fn
来进行定义,这种定义行为告诉调用者:当调用此函数时候,你需要注意它的相关需求,因为 Rust 无法担保调用者在使用该函数时能满足它所需要的一切需求。
强制调用者加上 unsafe 语句块,就可以让他清晰认识到正在调用一个不安全的函数,需要小心看看文档,看看函数有哪些特别的要求需要被满足。
unsafe fn dangerous() {}
fn main() {
dangerous();
}
如果试图这样调用,编译器就会报错
error[E0133]: call to unsafe function is unsafe and requires unsafe function or block
--> src/main.rs:3:5
|
3 | dangerous();
| ^^^^^^^^^^^ call to unsafe function
范例:修改
unsafe fn dangerous() {}
fn main() {
unsafe{
dangerous();
}
}
使用 unsafe 声明的函数时候,一定要看看相关文档,确定自己没有遗漏什么。
一个函数包含了 unsafe 不代表我们需要将整个函数定义 unsafe fn。事实上,在标准库中有大量的安全函数,他们内部都包含了 unsafe 函数。
对于 Rust 的借用检查器来说,它无法理解我哦们分别借用了同一个切片的两个不同部分,但是事实上,这种行为是没有问题,毕竟两个借用没有任何重叠之处。
use std::slice;
fn split_at_mut(slice: &mut [i32], mid: usize) -> (&mut [i32], &mut [i32]) {
let len = slice.len();
let ptr = slice.as_mut_ptr();
assert!(mid <= len);
unsafe {
(
slice::from_raw_parts_mut(ptr, mid),
slice::from_raw_parts_mut(ptr.add(mid), len - mid),
)
}
}
fn main() {
let mut v = vec![1, 2, 3, 4, 5, 6];
let r = &mut v[..];
let (a, b) = split_at_mut(r, 3);
assert_eq!(a, &mut [1, 2, 3]);
assert_eq!(b, &mut [4, 5, 6]);
}
相比安全事项,这段代码没有那么好理解,我们甚至需要像C语言那样,通过指针地址的偏移去控制数组的分割。
FFI(Foreign Function Interface) 可以用来与其他语言进行交互,但是并不是所有语言都这么称呼,例如 Java 称之为 JNI(Java Native Interface)。
FFI 之所以存在是由于现实中很多代码库都是由不同语言编写的,如果我们需要使用某个库,但是它是由其它语言编写的,那么往往只有两个选择:
前者相当不错,但是在很多时候,并没有那么多时间去重写,因此 FFI 就成了最佳选择。回到 Rust 语言上,由于这门语言依然很年轻,一些生态是缺失的,我们在写一些不是那么大众的项目时,可能会同时遇到没有相应的 Rust 库可用的尴尬境况,此时通过 FFI 去调用 C 语言的库就成了相当棒的选择。
还有在将 C/C++ 的代码重构为 Rust 时,先将相关代码引入到 Rust 项目中,然后逐步重构,也是不错的(为什么用不错来形容?因为重构一个有一定规模的 C/C++ 项目远没有想象中美好,因此最好的选择还是对于新项目使用 Rust 实现,老项目。。就让它先运行着吧)。
当然,除了 FFI 还有一个办法可以解决跨语言调用的问题,那就是将其作为一个独立的服务,然后使用网络调用的方式去访问,HTTP,gRPC 都可以。
言归正传,之前我们提到 unsafe 的另一个重要目的就是对 FFI 提供支持,它的全称是 Foreign Function Interface,顾名思义,通过 FFI , 我们的 Rust 代码可以跟其它语言的外部代码进行交互。
范例:
extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
unsafe {
println!("Absolute value of -3 according to C: {}", abs(-3));
}
}
C 语言的代码定义在了 extern 代码块中, 而 extern 必须使用 unsafe 才能进行进行调用,原因在于其它语言的代码并不会强制执行 Rust 的规则,因此 Rust 无法对这些代码进行检查,最终还是要靠开发者自己来保证代码的正确性和程序的安全性。
ABI 定义了如何在汇编层面来调用该函数,在所有的ABI中,C语言是最常见的。
在 Rust 中调用其它语言的函数是让 Rust 利用其他语言的生态,那反过来可以吗?其他语言可以利用 Rust 的生态不?答案是肯定的。
我们可以使用 extern 来创建一个接口,其它语言可以通过该接口来调用相关的 Rust 函数。但是此处的语法与之前有所不同,之前用的是语句块,而这里是在函数定义时加上 extern 关键字,当然,别忘了指定相应的 ABI:
范例:
#[no_mangle]
pub extern "C" fn call_from_c() {
println!("Just called a Rust function from C!");
}
上述代码可以编译称一个共享库,然后链接到C语言。
#[no_mangle]
注解告诉编译器:不要乱改函数的名称。
Mangling 的定义是:当 Rust 因为编译需要去修改函数的名称,例如为了让名称包含更多的信息,这样其它的编译部分就能从该名称获取相应的信息,这种修改会导致函数名变得相当不可读。
因此,为了让 Rust 函数能顺利被其它语言调用,我们必须要禁止掉该功能
我们在全局变量章节中讲解过,这里不讲述了
unsafe 的trait 确实不多见,如果大家还记得话,我们在之前的 Send 和 Sync 章节过实现过 unsafe 特征 Send。
范例:unsafe trait 声明很简单
unsafe trait Foo {
// 方法列表
}
unsafe impl Foo for i32 {
// 实现相应的方法
}
fn main() {}
union 是用于 C 代码进行交互。访问 union 的字段是不安全的,因为 Rust 无法保证存在在 union 实例中的数据类型。
#[repr(C)]
union MyUnion {
f1: u32,
f2: f32,
}
从上述代码可以看出,union 的使用方式跟结构体确实很相似,但是前者的所有字段都共享同一个存储空间,意味着往 union 的某个字段写入值,会导致其它字段的值会被覆盖。
关于 Union 可以查看 https://doc.rust-lang.org/reference/items/unions.html。
1、rust-bindgen 和 cbindgen
对于 FFI 的调用来说,保证接口的正确性是非常重要的,这两个库可以帮我们自动生成相应的接口,其中 rust-bindgen 用于在 Rust 中访问 C 代码,而 cbindgen则反之。
2、cxx
如果需要跟 C++ 代码交互,非常推荐使用 cxx,它提供了双向的调用,最大的优点就是安全:是的,你无需通过 unsafe 来使用它!
3、Miri
miri 可以生成 Rust 的中间层表示 MIR,对于编译器来说,我们的 Rust 代码首先会被编译为 MIR ,然后再提交给 LLVM 进行处理。
可以通过 rustup component add miri 来安装它,并通过 cargo miri 来使用,同时还可以使用 cargo miri test 来运行测试代码。
miri 可以帮助我们检查常见的未定义行为(UB = Undefined Behavior),以下列出了一部分:
但是需要注意的是,它只能帮助识别被执行代码路径的风险,那些未被执行到的代码是没办法被识别的。
4、Clippy
官方的 clippy 检查器提供了有限的 unsafe 支持,虽然不多,但是至少有一定帮助。例如 missing_safety_docs 检查可以帮助我们检查哪些 unsafe 函数遗漏了文档。
需要注意的是: Rust 编译器并不会默认开启所有检查,大家可以调用 rustc -W help 来看看最新的信息。
5、prusti
prusti 需要大家自己来构建一个证明,然后通过它证明代码中的不变量是正确被使用的,当你在安全代码中使用不安全的不变量时,就会非常有用。
6、模糊测试(fuzz testing)
cargo install cargo-fuzz
关联类型(associated type) 是 Trait 中 的类型占位符,它可以用于 Trait 的方法签名中,可以定义包含某些类型的 Trait ,而实现前无需要知道这些类型是什么
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
泛型 | 关联类型 |
---|---|
每次实现 Trait 时标注类型 | 无需标注类型 |
可以为一个类型多次实现某个 trait(不同的泛型参数) | 无法为单个类型多次实现某个 Trait |
可以在使用泛型参数时为泛型指定一个默认的具体类型,语法
这种技术常用于运算符重载, Rust 不允许创建自己的运算符以及重载任意的运算符,但是可以通过实现 std::ops 中列出的那些 Trait 来重载一部分相应的运算符。
use std::ops::Add;
#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
x: i32,
y: i32,
}
impl Add for Point {
type Output = Point;
fn add(self, other: Point) -> Point {
Point {
x: self.x + other.x,
y: self.y + other.y,
}
}
}
fn main() {
assert_eq!(
Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
Point { x: 3, y: 3 }
);
}
Add Trait 声明如下
trait Add<Rhs=Self> {
type Output;
fn add(self, rhs: Rhs) -> Self::Output;
}
示例代码:
trait Pilot {
fn fly(&self);
}
trait Wizard {
fn fly(&self);
}
struct Human;
impl Pilot for Human {
fn fly(&self) {
println!("This is your captain speaking.");
}
}
impl Wizard for Human {
fn fly(&self) {
println!("Up!");
}
}
impl Human {
fn fly(&self) {
println!("*waving arms furiously*");
}
}
fn main() {
let person = Human;
person.fly();
}
默认调用 Human 的fly方法,我们如果要调用 Trait 的实现方法可以使用下列调用方式
fn main() {
let person = Human;
Pilot::fly(&person);
Wizard::fly(&person);
person.fly();
}
如果我们要调用的是关联函数。
trait Animal {
fn baby_name() -> String;
}
struct Dog;
impl Dog {
fn baby_name() -> String {
String::from("Spot")
}
}
impl Animal for Dog {
fn baby_name() -> String {
String::from("puppy")
}
}
fn main() {
println!("A baby dog is called a {}", Dog::baby_name());
}
此时如果我们想要调用 Animal 的 baby_name 语法,则需要使用 完全限定语法。
fn main() {
println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
}
完全限定语法形式如下
<Type as Trait>::function(receiver_if_method, next_arg, ...);
有需要在一个 trait 使用其他 trait。
use std::fmt;
trait OutlinePrint: fmt::Display {
fn outline_print(&self) {
let output = self.to_string();
let len = output.len();
println!("{}", "*".repeat(len + 4));
println!("*{}*", " ".repeat(len + 2));
println!("* {} *", output);
println!("*{}*", " ".repeat(len + 2));
println!("{}", "*".repeat(len + 4));
}
}
孤儿规则:只有当 trait 或 类型定义在本地包,才能为该类型实现这个trait。
可以通过 newtype 模式来绕过这一规则,利用 tuple struct 创建一个新的类型。
use std::fmt;
struct Wrapper(Vec<String>);
impl fmt::Display for Wrapper {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}
fn main() {
let w = Wrapper(vec![String::from("hello"), String::from("world")]);
println!("w = {}", w);
}
使用type 关键字创建类型别名,类型别名并不是独立的类型。主要用途是减少代码字符重复
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
有一个名为 !
的特殊类型,它没有任何值,行话叫做空类型(empty type),它在不返回的函数中充当返回类型
不返回值的函数也被称做发散函数(diverging function)
fn bar() -> ! {
// --snip--
}
这种代码会报错。
loop循环 和 continue 的返回类型就是 !
Rust 需要在编译时确定为一个特定的类型值分配多少空间。动态大小的类型(Dynamically Sized Types, DST)概念,编写代码时使用只有在运行时才能确定大小的值。
Rust 使用动态大小类型的通用方式:附带一些元数据来存储动态信息的大小,使用动态类型大小总会把它的值放在某种指针后面。
为了处理动态大小的类型, Rust 提供了一个 Sized Trait 来确定一个类型的大小在编译时是否已知
使用 ?Sized 表示 泛型 可能是 Sized 也可能不是Sized,参数必须是引用。
可以将函数传递给其他函数,函数在传递过程中会被强制转换 fn 类型,fn 类型就是函数指针。
fn add_one(x: i32) -> i32 {
x + 1
}
fn do_twice(f: fn(i32) -> i32, arg: i32) -> i32 {
f(arg) + f(arg)
}
fn main() {
let answer = do_twice(add_one, 5);
println!("The answer is: {}", answer);
}
函数指针是一个类型,不是一个 trait,全部实现了三种闭包 trait。
fn returns_closure() -> Box<dyn Fn(i32) -> i32> {
Box::new(|x| x + 1)
}
宏在 Rust 里面指的是一组相关特性的集合称谓
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
#[macro_export]
注释将宏进行了导出,这样其他包可以将宏导入到当前座女与中,然后才能使用。标准库 vec!宏已经通过 std::prelude 自动引入。
对于 macro_rules! 存在一些问题,Rust 计划在未来使用新的生命宏来替换它。
从形式上来看,过程宏跟函数较为相像,但过程宏是使用源代码作为输入参数,基于代码进行一系列操作后,再输出一段全新的代码。注意,过程宏中的 derive 宏输出的代码并不会替换之前的代码,这一点与声明宏有很大的不同!
有三种类型:自定义 derive、属性宏、函数宏。
extern crate proc_macro;
use proc_macro::TokenStream;
use quote::quote;
use syn;
use syn::DeriveInput;
#[proc_macro_derive(HelloMacro)]
pub fn hello_macro_derive(input: TokenStream) -> TokenStream {
// 基于 input 构建 AST 语法树
let ast:DeriveInput = syn::parse(input).unwrap();
// 构建特征实现代码
impl_hello_macro(&ast)
}
fn impl_hello_macro(ast: &syn::DeriveInput) -> TokenStream {
let name = &ast.ident;
let gen = quote! {
impl HelloMacro for #name {
fn hello_macro() {
println!("Hello, Macro! My name is {}!", stringify!(#name));
}
}
};
gen.into()
}
#[proc_macro_attribute]
pub fn route(attr: TokenStream, item: TokenStream) -> TokenStream {
#[proc_macro]
pub fn sql(input: TokenStream) -> TokenStream {
以上就是今天要讲的内容