在道教的卦辞解读中,有这么一副批语:
世间万物皆有主
一厘一毫君莫取
英雄豪杰自天生
也需步步循规矩
世界万物皆有主,万物都有它的来处,一朵花,一粒沙,皆有姓名,你不能随意的处置它,轻视它,你得步步循规矩,才能天人合一,才能羚羊挂角,不着痕迹。否则就是胡搅乱缠,惊起一滩鸥鹭了。
rust就是这样一门世界万物皆有主的语言,在rust中,核心功能之一就是所有权(ownership)机制:
- 内存值都有唯一的owner(所有者)
先说结论
- 所有权机制就是内存值都有唯一的owner
- rust是静态内存管理
- 所有权机制帮助编译器实现静态内存管理
所有权理解
要理解所有权,我们只需要对比其他的语言就明白了。
1. 首先对比手动管理内存的c语言
下面代码中,我们分配了一段100字节的内存,这段内存分别有两个指针:owner1和owner2。
#include <stdlib.h> #include <stdio.h> #include <string.h> int main() { void* owner1 = calloc(100, 1); void* owner2 = owner1; char* data = (char*)owner2; strcpy(data, "Hello, World!"); printf("output:%s\n", owner1); free(owner2); return 0; }
很显然,owner1和owner2地位完全是平等的,都可以操作内存值:
- 通过owner2往内存中写入数据
- 通过owner1读出数据
- 通过owner2释放内存
因此c语言没有所有权一说。
2. 对比gc管理内存的golang
同样,下面这段代码中,我们使用golang分配一段slice,owner1和owner2的地位是相同的,他们都可以访问该slice。golang也没有所有权一说。
owner1 := make([]byte, 100) owner2 := owner1 copy(owner2, []byte("Hello, World!")) fmt.Println(string(owner1))
3. 但是在rust中,情况不一样了
fn main() { let owner1 = String::from("hello"); let owner2 = owner1; println!("{}, world!", owner1);//error }
这段代码运行出错,原因就在于所有权机制。
这段代码的逻辑为:
- 先为String类型分配了一片内存,owner1拥有该内存的所有权
- 在赋值语句中,owner1把该所有权转让给了owner2。owner1丧失了所有权。
- println所在行代码出错,它尝试通过owner1获取内存中的值,但是owner1已经丧失所有权。正确的做法,应该用owner2访问内存值
明白了吗,所有权机制就是owner唯一性,内存中的每一个值,在任意时刻,都只能有一个owner。
借用
所有权这个设计似乎有点反常识,毕竟几乎所有的主流语言,都没有所有权一说的。
比如常见的函数调用场景为:
- 定义一个变量
- 传递变量给函数
- 函数返回后继续使用该变量
以golang为例,我们写一个把hello world变成hello kitty的 函数
func main() { owner := make(map[string]string) owner["hello"]="world" fmt.Printf("hello, %v\n", owner["hello"]) toKitty(owner) fmt.Printf("hello, %v\n", owner["hello"]) } func toKitty(m map[string]string){ m["hello"]="kitty" }
在函数toKitty中,m变量和外面的owner变量,都共同拥有map对应内存的所有权。
函数执行完后,通过Printf继续使用owner变量,没有任何问题。
但是在rust中,唯一所有权的存在,上面的代码无法执行,owner将会在调用toKitty函数的时候,参数传递时失去所有权。
为了解决这个问题,rust中使用了借用(引用)的概念。Rust中,借用跟引用在rust中是相同的含义,意思就是:不改变所有权,临时借用下变量。
注意下面代码中,第10行使用了&,表明传递给函数的是一个引用,而不是所有权转移。因此,owner依然掌握着map的所有权。
use std::collections::HashMap; fn main() { let mut owner:HashMap<String, String> = HashMap::new(); owner.insert("hello".to_string(),"world".to_string()); if let Some(v) = owner.get("hello"){ println!("{} {}", "hello", v) } toKitty(&mut owner); if let Some(v) = owner.get("hello"){ println!("{} {}", "hello", v) } } fn toKitty(m: &mut HashMap<String, String>){ m.insert("hello".to_string(),"kitty".to_string()); }
总结一下:
- Rust中内存值都有唯一的owner,但可通过借用(引用)的方式访问该值
静态内存管理
rust设计这么一套机制有什么意义呢,似乎除了把问题搞复杂,暂时未看到好处。
我还看到有文章声称,这样做的目的,是为了培养程序员优良的编程习惯,深入思考变量之间的关系,这就有点搞笑了。
rust引入所有权,本质是由rust的内存回收机制决定的。可以说,rust是不得不引入所有权。
在软件开发中,大多数场景下,内存管理是一个绕不开的坎,常见的内存管理方式有两种:
- 垃圾回收机制(GC),在程序运行时GC也在运行,不断回收无用的内存,典型代表:Java、Go
- 手动管理内存,需要程序员主动申请和释放内存,典型代表:C
通过GC的方式,程序员不需要管理内存释放,交给GC管理,代价就是GC很蠢,常常把系统搞得很慢。几乎没有强调高性能的软件,会使用Java和Go编写。即使使用了,也是一个脑袋几个大,天天琢磨各种优化。
而手动管理内存,则对程序员要求极高,一不小心就内存泄露,导致程序员秃头率居高不下。
而Rust则另辟蹊径,采用了第三种方式:
- 静态内存管理
Rust会在编译期间,由编译器分析变量的作用域,在作用域结束时,自动由编译器添加上内存释放代码。
比如一段rust代码
{ owner <- 分配一片内存 owner存活期 }//作用域结束
经过编译器分析后,就会变成:
{ owner1 <- 分配一片内存 owner存活期 (释放owner1内存,Drop)//由编译器添加 }//作用域结束
可以看到,编译器在变量作用域结束时,会自动加上释放内存的代码,而代码作者完全不用考虑。
这样妙啊,只要编译器对作用域判断得足够准确,就不需要runtime GC,避免了GC引入的性能开销和不确定性。也不需要手工管理,彻底防止了内存泄露。这使得 Rust 能够在对内存安全和性能要求较高的场景中发挥优势。
明白了这个rust对内存管理的思路之后,是不是所有权就理所应当了?
毕竟在编译器眼中,变量的作用域很容易判断,大多数情况下,通过大括号就可以判断。但作用域结束后,往往无法决定是否应该释放内存,因为该变量对应的内存,可能被其他变量继续使用,也就是,该内存有多个所有者。
那么,只要严格规定每个内存值都有唯一的所有者,那问题是不是迎刃而解了?在所有者作用域结束时,释放内存,perfect,简直是完美!
有人会问了,那内存释放了,其他引用怎么办,会不会引用到空内存?
显然rust也有对策的,在编译期间,同样会检查引用与owner的关系,确保所有引用的作用域,都小于owner的作用域,还是那句话,编译器很容易判断变量的作用域。
结论
现在结论是明显的了:
- rust在编译期间执行静态内存管理
- 所有权机制帮助编译器实现静态内存管理
好处多多,缺点也很明显,那就是程序员们又得花不少时间适应这种所有权风格了。但是对于真正的极客来说,这又算得了什么呢?
君不见,程序猿,多少性能追逐客,夜夜键盘响,白了少年头。
发表回复