rust 概念
rust本身比较有意思的地方是,他是一个强类型-内存安全的语言,所以说很多东西都会规定的很死,通过一定的动态牺牲,带来了较大的安全性和稳健性。
比如变量上,rust这边的变量默认就是常量,需要 let mut 额外声明的才是可变的(mutable)。而还有关于内存的明确操作,关于字符串这里就能看出来:
// 这是一个指向字符串视图的指针,是纯粹只读的
let s1: &str = "hello";
// 这才是实际的字符串对象,可以修改
let s2: String = String::from("hello");
rust有一个关键的概念叫做「所有权」,这可以说是这个语言的核心。但是,所有权这个词可能导致误解,尤其是它的字面意思——所有。实际上这个所有对应的是一个「责任」,用责任来理解反而会更容易点。本质上,这个机制的目的就是为了能更好的处理内存问题:所有权归根究底说的就是,在一段作用域结束时,谁拥有释放内存的责任——当然是该变量的所有者负责释放。不过这里「负责」指的并不是我们要手动操作释放(如果还要手写 free 就没有什么特性优势可说了),指的是「释放的时机跟着所有者走」,编译器会自动在所有者的作用域结束前插入释放代码,因此不需要任何手动操作。只要责任转移是明确的,释放当然也是明确的。
这个机制的关键,就是为了确保内存在该释放的时候释放。rust下的任何值,在同一时刻都只能有一个所有者。原则也很简单:只要这个所有者的作用域结束,它再继续占据内存空间根本没什么意义,就应该立即释放。所有权机制的单一性确保了释放的时机可以非常紧密,而且每一处占用在编译期就能确定的清清楚楚。这直接使得任何一处内存空间都可以在该释放的时候释放——既然都在编译时就规定清楚了,当然不再需要运行时有一个单独的GC来盯着。编译期就能让内存问题暴露的清清楚楚,同时还避免了GC带来的性能开销。
如何清楚呢,我们从传参和赋值情况发生时来看,此时会有两种情况:首先是所有权转移,这里背后发生的操作是 move 代表着的就是所有者的转换,也就是把这个释放的责任转移出去;其次则是 copy ,这里没有发生所有权转移,而是直接复制了一个新值(也是新的所有者),同时原值也仍然有效。从所有权的角度看,copy 在创建新所有者的同时,也带来了新的所有权,它和旧的是并存;move 则不然,旧的所有者会失去所有权,因此是二者存其一。
当然,这里会有些特殊的存在,比如拥有堆资源的对象,就没办法直接常态去copy ,默认只会是转移,必须要手动触发clone,才会创建一个新的值与所有者。我相信了解浅/深拷贝的人应该都会比较容易理解这部分,当对象拥有堆资源时,这个对象就会同时有指针和它所指向的堆内存空间的占用。因此,为了避免复制之后,一个堆资源有两个可使用它的所有者这种情况,原所有者就必须交出所有权。而 clone 则会让内存空间内直接复制一整组出来,换言之就是深拷贝。但是这里要注意,rust里面的clone不必然绑定到深拷贝(例如Rc类型),具体取决于被操作类型自身的定义。但大部分情况下都是深拷贝。
理清了所有权之后,就能更好地理解它的相关延展,比如拿走和借用:
fn consume(s: String) {
println!("{}", s);
}
fn main() {
let a = String::from("hello");
consume(a);
// println!("{}", a); // error:a 已经被 move
}
这里展现了一个初看会让人困惑的操作,为什么会报错?按其它语言的习惯,比如在ts里,对象或者说值(本质也是对象)是可以被所有人重复使用的,没理由说赋值给另一个之后再用就报错。但在rust这里,是只有所有者才有权限操作——一旦这个所有者失去所有权,它就没了相应的操作资格。而此处的s: String 就意味着所有权转移,当a将自身作为元素传入函数时,也即意味着将自身的所有权转移给了s。s手中接过了原先属于a的「回收责任」,这里所发生的就是 move 操作。在此之后,a就无法再使用,因此该变量失效;因为它的责任/所有权已经被编译器标记为转移给了s,在s的作用域跑完了之后,这个值的内存也会被即时释放——consume 函数跑完之后,赋值给它的 hello 也会直接在作用域结束时被清理掉。
那问题来了,这么一搞,我后面还要用 hello 咋办?你给我全清理掉了,那岂不是每次都要给一个新的?这就轮到借用出场了:
// 注意下面的 &
fn consume(s: &str) {
println!("{}", s);
}
let a = String::from("hello");
consume(&a);
consume(&a);
println!("main 里 a 仍然可用: {}", a); // ✅ 全部正常
我们开场就说过 &str 是一个只读的字符串视图,而&在这里发挥的作用就是「借」,a的所有权在其中没有发生任何转移——自然清理的责任也没有发生转移——所以,s全程都只收到了视图,看完就看完了。而这就保证了 consume 之后,a仍然是可用的。直到a的作用域结束之后,它所属的这部分内存才会得到释放。
上述借用属于「不可变借用」,除此之外还有一种借用是可变借用,它一次只能借给一个对象,且还要用 mut 显式的声明出来。
let mut x = 5; // 显式声明,这是个能够可变借用的变量
let y = &mut x; // x 被可变借用给 y
let z = &mut x; // ❌ 报错!编译器直接拒绝编译!
我们从责任的角度就能很顺利地理解这一点。它无非指的就是,释放的作用域仍然跟着所有者(因为责任仍然在所有者这里),借用者即便有权限修改,它的作用域结束了,这部分内存也不会被释放,而是借用结束,回到所有者手中。并且rust在这里有个严格的机制,那就是一旦借出去,所有者自己的操作范围也会被冻结。如果你借的是只读,那么你自己就不能再改,防止借用者读到错误内容,这是「写冻结」;如果你借出去的是可变的权限,那么这期间你自己就是「完全冻结」,不可读也不可写。
而借用者的生命周期是个值得一说的东西。早期的rust有个毛病,那就是借用者的临时权限会一直带到大括号结束才结束。这就导致了如果我只是想临时借出去,就得人为写一个括号出来,防止所有者一直被冻死,完全无法使用:
fn main() {
let mut x = 5;
let y = &mut x; // 1. 可变借用开始,x 进入「完全冻结」
*y += 1; // 2. y 的工作其实在这里就彻底【结束】了
// 3. 但在老版本 Rust 里,y 的生命必须强行续到大括号结束
println!("{}", x);
// ❌ 报错!老编译器认为 y 还没死,x 依然处于「完全冻结」状态,不准读!
} // 4. 老版本中 y 在这里才算真正死亡
// 以前不得不这么写:
let mut x = 5;
{
let y = &mut x;
*y += 1;
} // 强行加个大括号让 y 死亡,从而解冻 x
println!("{}", x); // 现在可以读了
而后rust推出了NLL,即Non-Lexical Lifetimes(非词法生命周期),使得借用者可以在其最后一次被使用完毕之后就直接归还,解除所有者的冻结。这里的词法说的就是以前要跟着花括号(这就是个词法)走,现在则不需要(跟着使用走)了,其实没什么难理解的。就拿上面的例子来说,原先的归还发生在最后的 }这里,而现在版本的则是在 *y += 1 这一步就会归还。只要它没有在其他地方被继续使用,现在版本的编译器就会将其视为合法状态。
随着 rust在ai开发中的优势 继续说。rust难学的点上,还存在着一个容易让人混淆的部分,就是双冒号(::)和点(.)的区别。这个概念在其他语言下是混用的,但在rust的哲学之下就需要明确区分。前者意味着命名空间导航,即针对于「结构」的索引,即「不需要具体值就存在的东西」,一般的抽象蓝图,除非在蓝图里就预定义好常量的值,否则默认取到的就是成员本身;而后者是对具体「值」的调用,即「必须有具体值才存在的东西」,对蓝图的实际实现。
struct Point {
x: i32,
y: i32,
}
impl Point {
// 关联函数(没有 self),这个类比的话很像class中的静态方法,可以在非实例化的情况下直接使用
fn new(x: i32, y: i32) -> Point {
Point { x, y }
}
// 方法(有 self),这意味着必须先有了值才能去使用这个方法
fn distance(&self) -> f64 {
((self.x * self.x + self.y * self.y) as f64).sqrt()
}
}
let p = Point::new(3, 5); // :: 从类型 Point 取关联函数 new ✅
p.x // . 从实例 p 取字段 ✅
p.distance() // . 在实例 p 上调方法 ✅
Point::x // ❌ 非法,x 是字段不是关联项
这个地方需要明确防止一个误会,那就是双冒号取的是类型成员,而不是类型内的字段,因为只有「成员」才有路径身份,而「字段」是没有的。
Point::new(3, 5) // new 是关联函数,有路径身份 ✅
Point::ORIGIN_Y // ORIGIN_Y 是关联常量,有路径身份 ✅
Point::y // y 是字段,没有路径身份,处处非法 ❌
而这里就要顺带引出一个概念,impl乍看之下很像是类,但实际上并不是。核心点就在于rust从根本上ban了继承,因此这个地方的impl只是一个「给类型附加方法」的结构体,而 &self从字面意思上就指向这个类型Point自身。
因此,上面说的像静态方法的「关联函数」,乍一看很像是类的构造函数,毕竟new的时候返回了一个Point实例。但实际上并非如此。跟一般类的构造函数规定只能有一个不同,fn new在整个 impl 里面,跟其他方法相比是完全平等的。这就意味着写几个都行,也意味着它根本不是一个「构造函数」,只是普通的静态(鉴于rust里没有类这一说,因此这里是类比意义上)工厂方法。
而关于原先OOP的其他概念,rust仍旧有所留存,比如封装,就通过 pub 这个保留关键词来指定暴露想要的接口,而相较于其它语言需要明确声明 private,它在类型内的属性是默认私有的。
mod geometry {
pub struct Point {
x: i32, // 私有字段
pub y: i32, // 公开字段
}
}
// 在模块外面:
let p = geometry::Point { ... };
p.y // ✅ 能访问,因为 y 是 pub
p.x // ❌ 编译错误:x 是私有的,模块外碰不到
而 trait 则是一个比较重要的概念,直接等价于其他语言中的 interface ,即接口(契约)的概念。本质上是对行为结构的抽象定义,也是rust中DI能顺利施行的重要因素——通过接口定义好行为结构后,就可以使后续的组合面向接口而非具体实现编程。但rust的trait本身可以带默认实现(看上去有点像类了,但这个显然跟刚才的一样,是预定义好的方法)。
trait Animal {
fn speak(&self) -> String;
fn greet(&self) -> String { // 默认实现
format!("我说:{}", self.speak()) // 复用 speak,谁实现 Animal 都白捡这个 greet
}
}
而这玩意儿最强也最独特的地方在于给标准库,或者说任何其他已有的类型加实现。
trait Describe {
fn describe(&self) -> String;
}
impl Describe for i32 { // 给标准库的 i32 加行为!
fn describe(&self) -> String { format!("我是数字 {}", self) }
}
42.describe() // ✅ "我是数字 42"
其次则是可以明确的区分编译时和运行时,这里稍微有点复杂,涉及到不同语言彼此之间对于多态的派发策略,即到底是编译期间就直接定死,还是运行时去查一个虚拟表来确定此泛型指向什么具体的函数——大部分带 GC 的语言对于接口上的此类泛型传入处理,默认都是动态派发的,还没法选,因此有一个查表开销和运行时的混合问题;而rust在这里明确的通过 T 和 dyn 的区分,或者说,通过设计了一套「泛型 和 trait object」的两条路,将两者显式化了。这本身就是rust的设计哲学的再一次体现,那就是「将隐含的东西显现化」,无论是通过所有权概念体现的内存控制,还是显式边界,此处只是又一次贯彻。
// 静态派发(编译期多态):泛型 + trait 约束
// 此时的 T 最终是永久单态化的,即编译时定了什么,最终就是什么,不然报错,不放到运行时才决定
fn make_speak<T: Animal>(a: T) -> String {
a.speak() // T 是任何实现了 Animal 的类型,编译期定死,零开销
}
// 动态派发(运行时多态):trait object,&代表引用,一个指向a的指针的指针
// 这里会根据运行时决定,此处的 dyn Animal 取决于运行时到底给入什么
fn make_speak_dyn(a: &dyn Animal) -> String {
a.speak() // 运行时才知道具体是 Dog 还是 Cat,查 vtable
}
// 注意 vec![...] 只是个演示用的list构造,模拟的是运行时给入多个类型的情况
let zoo: Vec<Box<dyn Animal>> = vec![Box::new(Dog), Box::new(Cat)];
for animal in &zoo {
println!("{}", animal.speak()); // 同一个循环,Dog 喵 Cat 各说各的 —— 经典多态
}
然后另一个则是trait最不同于interface的点,那就是它可以写成空的。
// Send / Sync 大致长这样(简化):
trait Send {} // 空的!没有任何方法
trait Sync {}
这里的 Send 和 Sync ,或者说本质上这种空的 trait 是一个会自动连锁传播的标记,可以在编译期就给所属行为打上一个标签。这种标签使得编写者可以显式控制和声明特定操作,从而在该操作不该发生的地方在编译期就拦截。比如Rc标准库,他的用途是建立索引,让一份数据被多个所有者共享,而其内部会维护一个所有者计数器——对这东西的修改会存在并发竞争问题。那么此时 Send 标签就能发挥作用。
Rc 的编写者只需要显式声明此处是 !Send 的,而跨线程操作库的 thread::spawn 再予以配合,要求只有 Send 标签的操作才能够通过。那么通过这种跨方法约定,就可以直接将危险操作拦截在编译器,而不至于到了运行时才暴露。这个操作往广了说可以非常灵活,比如并发,复制,只要能加标签同时要在另一个地方设置门槛时,都可以用类似机制。从编译上就让不符合门槛的操作直接报错,绝对不让你到运行时才出问题。只要一方设置了标签,那么实现了这个接口的子对象都会自动被传播到。这种自动传播的标记叫做auto trait,而 Send / Sync 是其中最常见的两个。
上面那个自动传播机制是 rust 的特色,其他语言没有,但除此之外的其他自定义空壳标签就没有这个传播机制了。比如我写了个表现,但是后来忘了设置查票者或者给下游盖章,那这一套就无效了。换言之,这套标签机制的有效性取决于编写者的设计和对自身代码的边界把控能力。如果毫无设计,该出的问题还是会出现。但如果这样,rust 最额外的优势在哪里呢?核心点在于「编译期」保证。rust 的空 trait 一旦加上,后续没有那就是没有,直接报错,但类似的比如 java,就得到运行期才能暴露问题。
// ① 定义标签(空壳)
trait A {}
// ② 给某个类型盖章
struct Dog;
impl A for Dog {} // 为 Dog 装上 A
struct Cat;
// Cat 不盖章
// ③ 下游:在函数签名里"查票"—— <T: A> 就是那道卡
fn need_ticket<T: A>(x: T) {
// 能进到这个函数体里的 x,类型必然带 A。编译器已经替你保证了。
}
// ④ 消费方尝试
need_ticket(Dog); // ✅ Dog 有 A,过
need_ticket(Cat); // ❌ Cat 没 A,编译报错:the trait `A` is not implemented for `Cat`
fn need_ticket<T: A>(x: T) {} // 写法1:泛型参数上直接标
fn need_ticket<T>(x: T) where T: A {} // 写法2:where 子句,复杂约束时更清爽
fn need_ticket(x: impl A) {} // 写法3:impl Trait 语法,最简洁
rust 这里比ts的优势在于,这个标签没办法被下游轻易的用 as 断言盖过去。如果非得写,必须显式声明,把该对象的类型明确指定到标签上,代价很明确,这意味着下游全部都知道你加了这个标签(因为是空的,所以不影响行为,但会明确打上章,而很难暗度陈仓);而 as 断言就只是一句临时断言,并未根本上改变原先对象的类型。其次则是,在 rust 这里 A {}的主语是A,而ts那里则是{}。ts的类型本身对结构体负责,换言之,空的{}结构可以对应到任何一个对象,而不是标签的语义意涵。