这一篇简单介绍Rust的一些高级特性,包括unsafe的操作,trait、类型、函数、闭包的高级特性以及宏~
本篇不会介绍得过于深入,甚至不会写得很完整,因为每一项都能延伸处非常多的内容,待后续需要用到时再在本文补充或新写一篇文章介绍。
本篇是《第N次入门Rust》的完结篇。后续我还会逐步深入学习Rust,并针对不同的主题写Rust相关的学习笔记与各位同学一起学习探讨,共同进步。
Unsafe Rust需要开发者自身保证内存操作安全。
本质上Rust的所有权机制并不是编程开发必须遵守的要求,比如像Java、Python就没有这种约束。它更像是一种编程思想,跟面向对象编程、函数式编程等思想一样,都是对问题建模时的一种角度。个人觉得面向对象编程的侧重点更多是降低建模难度,能更好地与现实世界找到对应关系。函数式编程的侧重点是无副作用,相同输入能得到相同输出,这样能使程序更好地并发。而所有权机制它侧重点是内存安全。想要找到一门完美的编程语言到目前为止应该是不可能的,不同的侧重点通常与不同领域的问题有关,有的场景看中并发,有的场景看中内存安全。而在侧重点投入越多,在其它点上可能就不如其它编程语言做得好,比如Rust关注内存安全采用了所有权机制,则编程模式上就会比Java等语言更难让初学者习惯。
扯远了,上面的意思是所有权机制只是Rust默认的开发模式,它在某些场景下并不是最优的,Rust为了内存安全实际上是做出了一定的取舍。当遇到一些需要绕开Rust限制的场景时,Rust实际上提供了Unsafe Rust这种开发模式,只是此时需要开发者负责。
Unsafe Rust需要使用unsafe
关键字开启,Unsafe的功能包括:
union
的字段;unsafe
并不会关闭借用检查器或禁用任何其他 Rust 安全检查:如果在不安全代码中使用引用,它仍会被检查。unsafe
关键字只是提供了上述五个不会被编译器检查内存安全的功能,开发者仍然能在不安全块中获得某种程度的安全。
unsafe
不意味着块中的代码就一定是危险的或者必然导致内存安全问题:其意图在于明确有开发者确保 unsafe
块中的代码以有效的方式访问内存。
尽可能隔离unsafe代码,最好将其封装在安全的抽象里,提供安全的API。
在使用Unsafe Rust的代码时,需要在unsafe
代码块中执行,当一个方法为不安全的时候,需要将其声明为unsafe
,同时调用它时也需要在unsafe
中执行。
unsafe fn unsafe_func() { /*...*/ }
unsafe {
unsafe_func();
}
解引用裸指针包括两种类型:
*const T
;*mut T
;*
是类型名的一部分,而不是解引用运算符。解引用裸指针与引用和智能指针的区别在于:
null
;可以认为解引用裸指针就是C语言中最基础的指针。
示例:
let mut num = 5;
let r1 = &num as *const i32;
let r2 = &mut num as *mut i32;
unsafe {
println!("r1 is: {}", *r1);
println!("r2 is: {}", *r2);
}
有上面的例子中可以知道,创建裸指针并不需要在unsafe中声明(因为没有任何危险),但是使用的时候必须在unsafe块中使用。
unsafe函数和方法与常规函数方法十分类似,只是其开头有一个额外的 unsafe
。
unsafe函数体也是有效的 unsafe
块,所以在不安全函数中进行另一个不安全操作时无需新增额外的 unsafe
块。
unsafe fn dangerous1() {}
unsafe fn dangerous2() {
dangerous1();
}
unsafe {
dangerous2();
}
创建unsafe代码的安全抽象:很多时候不需要总将整个函数声明为unsafe,因为不安全的部分很多时候只有小部分。最好的做法是只将不安全的部分使用unsafe
块包裹。
创建外部函数接口(Foreign Function Interface, FFI),需要在extern
块内声明,然后使用时必须在unsafe
块中使用。
任何extern块中的方法都是不安全的,因为无法保证外部方法一定安全。
extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
unsafe {
println!("Absolute value of -3 according to C: {}", abs(-3));
}
}
如果是其他语言调用Rust编写的函数:需要在fn
关键字前添加extern
关键字,并需要添加#[no_mangle]
注解来告诉 Rust 编译器不要 改变 此函数的名称(因为一般Rust编译器会改变函数名,使其带上更多信息)。
#[no_mangle]
pub extern "C" fn call_from_c() {
println!("Just called a Rust function from C!");
}
extern
的使用无需unsafe
关键字修饰。
默认情况下,Rust中的全局变量(静态变量)是不能被修改的,因为在并发环境下修改全局变量会出现数据竞争。
声明一个全局变量(静态变量):
static HELLO_WORLD: &str = "Hello, world!";
fn main() {
println!("name is: {}", HELLO_WORLD);
}
SCREAMING_SNAKE_CASE
写法,并必须标注变量的类型。'static
生命周期的引用,这意味着 Rust 编译器可以自己计算出其生命周期而无需显式标注。访问不可变静态变量是安全的。static mut COUNTER: u32 = 0;
fn add_to_count(inc: u32) {
unsafe {
COUNTER += inc;
}
}
fn main() {
add_to_count(3);
unsafe {
println!("COUNTER: {}", COUNTER);
}
}
当至少有一个方法中包含编译器不能验证的不变量时 trait 是unsafe的。
可以在 trait
之前增加 unsafe
关键字将 trait 声明为 unsafe
,同时 trait 的实现也必须标记为 unsafe
。
unsafe trait Foo {
// methods go here
}
unsafe impl Foo for i32 {
// method implementations go here
}
union
和 struct
类似,但是在一个实例中同时只能使用一个声明的字段。
联合体主要用于和 C 代码中的联合体交互。
访问联合体的字段是unsafe的,因为 Rust 无法保证当前存储在联合体实例中数据的类型。
关联类型(associated types)是一个将类型占位符与 trait 相关联的方式,这样 trait 的方法签名中就可以使用这些占位符类型。trait 的实现者会针对特定的实现在这个类型的位置指定相应的具体类型。如此可以定义一个使用多种类型的 trait,直到实现此 trait 时都无需知道这些类型具体是什么。
最常见的带有关联类型的trait的例子是标准库提供的Iterator
trait:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
其中Item
是占位类型,在有类型实现Iterator的时候,需要指定Item的类型。
当对一个类型Counter
实现Iterator
的时候,细节如下:
impl Iterator for Counter {
type Item = u32; // 指定关联类型
fn next(&mut self) -> Option<Self::Item> { /* ... */ }
}
为什么实现Counter
的时候指定Item
是什么类型,而不是通过泛型的形式指定next
方法的返回值类型,就像下面的代码:
pub trait Iterator<T> {
fn next(&mut self) -> Option<T>;
}
原因:实际上通过泛型的形式决定next
返回值的类型也是可以的,但是如果采用这种形式则在每次调用next
时都要指定泛型类型是什么。但是有的时候一个trait对于一个类型来说只能有一种实现,此时就需要关联类型而不是泛型。(对于Counter
来说只能有一个impl Iterator for Counter
,而不应该会返回其他类型的迭代值)。
关联类型与泛型的区别:
泛型 | 关联类型 |
---|---|
每次实现trait时需要标注类型
|
无需标注类型 |
可以为一个类型多次实现某个trait(不同的泛型参数) | 无法为单个类型多次实现某一个trait |
默认泛型类型参数:在trait使用泛型的时候,显式指定一种默认的泛型类型。
trait TraitName<T=默认类型> {
fn func1(t: T);
/* ... */
}
默认参数类型主要用于如下两个方面:
运算符重载(Operator overloading)是指在特定情况下自定义运算符(比如 +
)行为的操作。
Rust 并不允许创建自定义运算符或重载任意运算符,不过 std::ops
中所列出的运算符和相应的 trait 可以通过实现运算符相关 trait 来重载。
以Add
trait为例,其定义如下:
trait Add<RHS=Self> {
type Output;
fn add(self, rhs: RHS) -> Self::Output;
}
RHS=Self
是默认类型参数(default type parameters)。RHS
是一个泛型类型参数(“right hand side” 的缩写),它用于定义 add
方法中的 rhs
参数。Add
trait 时不指定 RHS
的具体类型,RHS
的类型将是默认的 Self
类型,也就是在其上实现 Add
的类型。直接使用默认参数:
use std::ops::Add;
#[derive(Debug, 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 });
}
如果需要自定义类型:
use std::ops::Add;
struct Millimeters(u32);
struct Meters(u32);
impl Add<Meters> for Millimeters {
type Output = Millimeters;
fn add(self, other: Meters) -> Millimeters {
Millimeters(self.0 + (other.0 * 1000))
}
}
当一个类型T
同时实现了多个trait T1
、T2
、T3
,这三个trait都有相同名字的方法func
,同时T
自身也有一个func
,即T
实现了4个不同的func
。如果有一个变量t: T
,默认情况下调用的是T类型的func
,而不是另外三种trait的func
。如果要调用T1
的func
,则需要以T1::func(&t)
或T1::func(t)
的形式调用。
如果T
、T1
、T2
、T3
同名的是关联函数(即静态方法)a_func
,则t: T
默认调用的也是a_func
,要调用T1
的a_func
,就需要使用完全限定语法消除歧义:
<T as T1>::a_func()
完全限定语法定义为:
<Type as Trait>::function(receiver_if_method, next_arg, ...);
其中对于关联函数来说没有receiver
,因此不需要填receiver_if_method
。
类型和trait:
struct Human;
trait Pilot {
fn fly(&self);
fn name() -> String;
}
trait Wizard {
fn fly(&self);
fn name() -> String;
}
实现:
impl Pilot for Human {
fn fly(&self) {
println!("This is your captain speaking.");
}
fn name() -> String {
"Pilot"
}
}
impl Wizard for Human {
fn fly(&self) {
println!("Up!");
}
fn name() -> String {
"Wizard"
}
}
impl Human {
fn fly(&self) {
println!("*waving arms furiously*");
}
fn name() -> String {
"Human"
}
}
调用:
fn main(){
let person = Human;
// This is your captain speaking.
Pilot::fly(&person);
// Up!
Wizard::fly(&person);
// *waving arms furiously*
person.fly();
// Pilot
<Human as Pilot>::name();
// Wizard
<Human as Wizard>::name();
// Human
Human::name();
}
supertrait也叫父trait。
supertrait存在的意义:由于trait只能定义一个类型拥有什么样的行为,但是有的时候有这样的需求,类型Type
同时实现Trait1
和Trait2
,此时需要在Trait1
的某个方法self.trait1_func()
中调用Trait2
的某个方法self.trait2_func()
。然而Trait1
并不知道Trait2
的存在,因为它们之间并没有关系。为了实现在Trait1
实现中调用Trait2
的实现,需要规定这两个trait的关系。可以将Trait2
声明为Trait1
的supertrait,则Trait1
的实现中可以调用Trait2的行为。
trait SubTraitName: SuperTraitName {
/* ... */
}
示例:
use std::fmt;
trait OutlinePrint: fmt::Display {
fn outline_print(&self) {
let output = self.to_string(); // 调用了Display的行为
let len = output.len();
println!("{}", "*".repeat(len + 4));
println!("*{}*", " ".repeat(len + 2));
println!("* {} *", output);
println!("*{}*", " ".repeat(len + 2));
println!("{}", "*".repeat(len + 4));
}
}
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
struct Point {
x: i32,
y: i32,
}
impl OutlinePrint for Point {}
newtype模式(newtype pattern)的作用是绕开孤儿规则(只要 trait 或类型对于当前 crate 是本地的话就可以在此类型上实现该 trait)。
newtype模式涉及到在一个元组结构体中创建一个新类型。这个元组结构体带有一个字段作为希望实现 trait 的类型的简单封装。接着这个封装类型对于 crate 是本地的,这样就可以在这个封装上实现 trait。使用这个模式没有运行时性能惩罚,这个封装类型在编译时就被省略了。
示例:如果想要在 Vec
上实现 Display
,而孤儿规则阻止我们直接这么做,因为 Display
trait 和 Vec
都定义于我们的 crate 之外。可以创建一个包含 Vec
实例的 Wrapper
结构体,接着在 Wrapper
上实现 Display
并使用 Vec
的值:
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);
}
newtype 模式可用于抽象掉一些类型的实现细节:例如,封装类型可以暴露出与直接使用其内部私有类型时所不同的公有 API,以便限制其功能。
newtype 也可以隐藏其内部的泛型类型。例如,可以提供一个封装了 HashMap
的 People
类型,用来储存人名以及相应的 ID。使用 People
的代码只需与提供的公有 API 交互即可,比如向 People
集合增加名字字符串的方法,这样这些代码就无需知道在内部将一个 i32
ID 赋予了这个名字了。
Rust 提供声明 类型别名(type alias) 的能力,使用 type
关键字来给予现有类型另一个名字。
type NewTypeName = TypeName;
可以认为有一种类型叫"类型"(type),则声明语句和赋值语句相当于将一个“类型”绑定到另一个新名字上。
当给类型起了新别名以后,使用新别名和原来类型名声明的值会被当做相同类型的值对待。但通过这种手段无法获得newtype模式所提供的类型检查的好处。
类型别名的主要用途是减少重复,比如有一个符合类型名特别长且很多地方都用到,此时可以给它一个别名,既让看代码的人更容易明白这是什么类型,也方便开发。
类型别名可以带有类型占位符,从而起到给泛型类型起别名的目的:
type Result<T> = std::result::Result<T, std::io::Error>;
Rust 有一个叫做 !
的特殊类型。在类型理论术语中,它被称为 empty type,因为它没有值,也称之为 never type。这个名字描述了它的作用:在函数从不返回的时候充当返回值。
()
。!
,无法产生可供返回类型。从不返回的函数被称为发散函数(diverging functions)。
描述 !
的行为的正式方式是 never type 可以强转为任何其他类型。(有点像scala的Nothing类型)
never类型的表达式可以强制被转化为任意类型。
示例:
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
这里的continune返回的是!
类型,由于其可以是任何类型,因此guess
是u32
类型。
动态大小类型(dynamically sized types)的概念。这有时被称为 “DST” 或 “unsized types”,这些类型允许开发者处理只有在运行时才知道大小的类型。
Rust 中动态大小类型的常规用法:有一些额外的元信息来储存动态信息的大小。
动态大小类型的黄金规则:必须将动态大小类型的值置于某种指针之后。
另一种动态大小的类型:trait
&dyn Trait
或Box(Rc)
之后。Sized
trait:这个 trait 决定一个类型的大小是否在编译时可知,同时这个 trait 自动为编译器在编译时就知道大小的类型实现。
Rust
隐式的为每一个泛型函数增加了 Sized bound。fn generic<T>(t: T) {
// --snip--
}
// 被当做如下处理:
fn generic<T: Sized>(t: T) {
// --snip--
}
泛型函数默认只能用于在编译时已知大小的类型。然而可以使用如下特殊语法来放宽这个限制:
fn generic<T: ?Sized>(t: &T) {
// --snip--
}
?Sized
trait bound 与 Sized
相对;也就是说,它可以读作 “T
可能是也可能不是 Sized
的”。这个语法只能用于 Sized
,而不能用于其他 trait。
fn
被称为函数指针(function pointer),即平时声明函数的时候语法上看起来像是声明一个函数指针并赋值(函数实现)。
函数指针可以作为函数的参数进行传递,当函数指针作为参数时:
fn fun1(f: fn(参数类型列表) -> 返回值类型, arg: type1) {
/* ... */
}
其中: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);
}
不同于闭包,fn
是一个类型而不是一个 trait,所以直接指定 fn
作为参数而不是声明一个带有 Fn
作为 trait bound 的泛型参数。
函数指针实现了所有三个闭包 trait(Fn
、FnMut
和 FnOnce
),所以总是可以在调用期望闭包的函数时传递函数指针作为参数。倾向于编写使用泛型和闭包 trait 的函数,这样它就能接受函数或闭包作为参数。
闭包表现为 trait,这意味着不能直接返回闭包。对于大部分需要返回 trait 的情况,可以使用实现了期望返回的 trait 的具体类型来替代函数的返回值。但是这不能用于闭包,因为他们没有一个可返回的具体类型;例如不允许使用函数指针 fn 作为返回值类型。
宏(Macro)包括:
macro_rules!
#[derive]
宏在结构体和枚举上指定通过 derive
属性添加的代码宏是生成代码的代码,即元编程。元编程对于减少大量编写和维护的代码是非常有用的。
一个函数标签必须声明函数参数个数和类型,相比之下,宏能够接受不同数量的参数。
宏可以在编译器翻译代码前展开,例如,宏可以在一个给定类型上实现 trait 。而函数则不行,因为函数是在运行时被调用,同时 trait 需要在编译时实现。
宏和函数的最后一个重要的区别是:在一个文件里调用宏 之前 必须定义它,或将其引入作用域,而函数则可以在任何地方定义和调用。
macro_rules!
的声明宏用于通用元编程(可能会被弃用)声明宏(declarative macros)核心概念是,声明宏允许开发者编写一些类似 Rust match 表达式的代码。
宏将一个值和包含相关代码的模式进行比较;此种情况下,该值是传递给宏的 Rust 源代码字面值,模式用于和传递给宏的源代码进行比较,同时每个模式的相关代码则用于替换传递给宏的代码。所有这一切都发生于编译时。
语法:
#[macro_export]
macro_rules! 宏名 {
( 参数列表 ) => {
展开以后的操作
其中参数列表中的值可以在这里使用
}
}
// 使用宏
宏名![参数列表]
无论何时导入定义了宏的包,#[macro_export]
注解说明宏应该是可用的。 如果没有该注解,这个宏不能被引入作用域。
接着使用 macro_rules!
和宏名称开始宏定义,且所定义的宏并 不带 感叹号。名字后跟大括号表示宏定义体。
参数列表中设计宏模式语法,可以参考:https://doc.rust-lang.org/reference/macros.html
示例,简化版的vec!
定义:
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
过程宏(procedural macros)接收 Rust 代码作为输入,在这些代码上进行操作,然后产生另一些代码作为输出,而非像声明式宏那样匹配对应模式然后以另一部分代码替换当前代码。
过程宏有三种:自定义派生(derive)、类属性和类函数。
当创建过程宏时,其定义必须位于一种特殊类型的属于它们自己的 crate 中。使用这些宏需采用下列例子的代码形式,其中some_attribute
是一个使用特定宏的占位符。
use proc_macro;
#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
过程宏包含一个函数,这也是其得名的原因:“过程” 是 “函数” 的同义词。定义过程宏的函数接受一个 TokenStream
作为输入并产生一个 TokenStream
作为输出。这也就是宏的核心:宏所处理的源代码组成了输入 TokenStream
,同时宏生成的代码是输出 TokenStream
。最后,函数上有一个属性;这个属性表明过程宏的类型。在同一 crate 中可以有多种的过程宏。
derive
宏过程式宏必须在其自己的 crate 内实现,这个限制最终可能会被去掉。这个crate的命名管理如下:对于一个 foo
的包来说,一个自定义的派生过程宏的包被称为 foo_derive
。在 hello_macro
项目中新建名为 hello_macro_derive
的包。
具体用法等用到再补充。
类属性宏与自定义派生宏相似,不同于为 derive
属性生成代码,它们允许你创建新的属性。derive
只能用于结构体和枚举;属性还可以用于其它的项,比如函数。
类属性宏作用上更像Java中的注解。
类函数宏定义看起来像函数调用的宏。
类函数宏获取 TokenStream
参数,其定义使用 Rust 代码操纵 TokenStream
,就像另两种过程宏一样。