Rust语言的编程范式

Rust语言的编程范式

总是有很多很多人来问我对Rust语言怎么看的问题,在各种地方被at,其实,我不是很想表达我的想法。因为在不同的角度,你会看到不同的东西。编程语言这个东西,老实说很难评价,在学术上来说,Lisp就是很好的语言,然而在工程使用的时候,你会发现Lisp没什么人用,而Javascript或是PHP这样在学术很糟糕设计的语言反而成了主流,你觉得C++很反人类,在我看来,C++有很多不错的设计,而且对于了解编程语言和编译器的和原理非常有帮助。但是C++也很危险,所以,出现在像Java或Go 语言来改善它,Rust本质上也是在改善C++的。他们各自都有各自的长处和优势

因为各个语言都有好有不好,因此,我不想用别的语言来说Rust的问题,或是把Rust吹成朵花以打压别的语言,写成这样的文章,是很没有营养的事。本文主要想通过Rust的语言设计来看看编程中的一些挑战,尤其是Rust重要的一些编程范式,这样反而更有意义一些,因为这样你才可能一通百通

这篇文章的篇幅比较长,而且有很多代码,信息量可能会非常大,所以,在读本文前,你需要有如下的知识准备

  • 你对C++语言的一些特性和问题比较熟悉。尤其是:指针、引用、右值move、内存对象管理、泛型编程、智能指针……
  • 当然,你还要略懂Rust,不懂也没太大关系,但本文不会是Rust的教程文章,可以参看“Rust的官方教程”(中文版

因为本文太长,所以,我有必要写上 TL;DR ——

Java 与 Rust 在改善C/C++上走了完全不同的两条路,他们主要改善的问题就是C/C++ Safety的问题。所谓C/C++编程安全上的问题,主要是:内存的管理、数据在共享中出现的“野指针”、“野引用”的问题。

  • 对于这些问题,Java用引用垃圾回收再加上强大的VM字节码技术可以进行各种像反射、字节码修改的黑魔法。
  • 而Rust不玩垃圾回收,也不玩VM,所以,作为静态语言的它,只能在编译器上下工夫。如果要让编译器能够在编译时检查出一些安全问题,那么就需要程序员在编程上与Rust语言有一些约定了,其中最大的一个约定规则就是变量的所有权问题,并且还要在代码上“去糖”,比如让程序员说明一些共享引用的生命周期。
  • Rust的这些所有权的约定造成了很大的编程上的麻烦,写Rust的程序时,基本上来说,你的程序再也不要想可能轻轻松松能编译通过了。而且,在面对一些场景的代码编写时,如:函数式的闭包,多线程的不变数据的共享,多态……开始变得有些复杂,并会让你有种找不到北的感觉。
  • Rust的Trait很像Java的接口,通过Trait可以实现C++的拷贝构造、重载操作符、多态等操作……
  • 学习Rust的学习曲线并不平,用Rust写程序,基本上来说,一旦编译通过,代码运行起来是安全的,bug也是很少的。

如果你对Rust的概念认识的不完整,你完全写不出程序,那怕就是很简单的一段代码这逼着程序员必需了解所有的概念才能编码。但是,另一方面也表明了这门语言并不适合初学者……

变量的可变性

首先,Rust里的变量声明默认是“不可变的”,如果你声明一个变量 let x = 5;  变量 x 是不可变的,也就是说,x = y + 10; 编译器会报错的。如果你要变量的话,你需要使用 mut 关键词,也就是要声明成 let mut x = 5; 表示这是一个可以改变的变量。这个是比较有趣的,因为其它主流语言在声明变量时默认是可变的,而Rust则是要反过来。这可以理解,不可变的通常来说会有更好的稳定性,而可变的会代来不稳定性。所以,Rust应该是想成为更为安全的语言,所以,默认是 immutable 的变量。当然,Rust同样有 const 修饰的常量。于是,Rust可以玩出这么些东西来:

  • 常量:const LEN:u32 = 1024; 其中的 LEN 就是一个u32 的整型常量(无符号32位整型),是编译时用到的。
  • 可变的变量: let mut x = 5; 这个就跟其它语言的类似, 在运行时用到。
  • 不可变的变量:let x= 5; 对这种变量,你无论修改它,但是,你可以使用 let x = x + 10; 这样的方式来重新定义一个新的 x。这个在Rust里叫 Shadowing ,第二个 x  把第一个 x 给遮蔽了。

不可变的变量对于程序的稳定运行是有帮助的,这是一种编程“契约”,当处理契约为不可变的变量时,程序就可以稳定很多,尤其是多线程的环境下,因为不可变意味着只读不写,其他好处是,与易变对象相比,它们更易于理解和推理,并提供更高的安全性。有了这样的“契约”后,编译器也很容易在编译时查错了。这就是Rust语言的编译器的编译期可以帮你检查很多编程上的问题。

对于标识不可变的变量,在 C/C++中我们用const ,在Java中使用 final ,在 C#中使用 readonly ,Scala用 val ……(在Javascript 和Python这样的动态语言中,原始类型基本都是不可变的,而自定义类型是可变的)。

对于Rust的Shadowing,我个人觉得是比较危险的,在我的职业生涯中,这种使用同名变量(在嵌套的scope环境下)带来的bug还是很不好找的。一般来说,每个变量都应该有他最合适的名字,最好不要重名。

变量的所有权

这个是Rust这个语言中比较强调的一个概念。其实,在我们的编程中,很多情况下,都是把一个对象(变量)传递过来传递过去,在传递的过程中,传的是一份复本,还是这个对象本身,也就是所谓的“传值还是传引用”的被程序员问得最多的问题。

  • 传递副本(传值)。把一个对象的复本传到一个函数中,或是放到一个数据结构容器中,可能需要出现复制的操作,这个复制对于一个对象来说,需要深度复制才安全,否则就会出现各种问题。而深度复制就会导致性能问题。
  • 传递对象本身(传引用)。传引用也就是不需要考虑对象的复制成本,但是需要考虑对象在传递后,会多个变量所引用的问题。比如:我们把一个对象的引用传给一个List或其它的一个函数,这意味着,大家对同一个对象都有控制权,如果有一个人释放了这个对象,那边其它人就遭殃了,所以,一般会采用引用计数的方式来共享一个对象。引用除了共享的问题外,还有作用域的问题,比如:你从一个函数的栈内存中返回一个对象的引用给调用者,调用者就会收到一个被释放了个引用对象(因为函数结束后栈被清了)。

这些东西在任何一个编程语言中都是必需要解决的问题,要足够灵活到让程序员可以根据自己的需要来写程序。

在C++中,如果你要传递一个对象,有这么几种方式:

  • 引用或指针。也就是不建复本,完全共享,于是,但是会出现悬挂指针(Dangling Pointer)又叫野指针的问题,也就是一个指针或引用指向一块废弃的内存。为了解决这个问题,C++的解决方案是使用 share_ptr 这样的托管类来管理共享时的引用计数。
  • 传递复本,传递一个拷贝,需要重载对象的“拷贝构造函数”和“赋值构造函数”。
  • 移动Move。C++中,为了解决一些临时对象的构造的开销,可以使用Move操作,把一个对象的所有权移动到给另外一个对象,这个解决了C++中在传递对象时的会产生很多临时对象来影响性能的情况。

C++的这些个“神操作”,可以让你非常灵活地在各种情况下传递对象,但是也提升整体语言的复杂度。而Java直接把C/C++的指针给废了,用了更为安全的引用 ,然后为了解决多个引用共享同一个内存,内置了引用计数和垃圾回收,于是整个复杂度大大降低。对于Java要传对象的复本的话,需要定义一个通过自己构造自己的构造函数,或是通过prototype设计模式的 clone() 方法来进行,如果你要让Java解除引用,需要明显的把引用变量赋成 null 。总之,无论什么语言都需要这对象的传递这个事做好,不然,无法提供相对比较灵活编程方法。

在Rust中,Rust强化了“所有权”的概念,下面是Rust的所有者的三大铁律:

  1. Rust 中的每一个值都有一个被称为其 所有者(owner)的变量。
  2. 值有且只有一个所有者。
  3. 当所有者(变量)离开作用域,这个值将被丢弃。

这意味着什么?

如果你需要传递一个对象的复本,你需要给这个对象实现 Copy trait ,trait 怎么翻译我也不知道,你可以认为是一个对象的一些特别的接口(可以用于一些对像操作上的约定,比如:Copy 用于复制(类型于C++的拷贝构造和赋值操作符重载),Display 用于输出(类似于Java的 toString()),还有 Drop 和操作符重载等等,当然,也可以是对象的方法,或是用于多态的接口定义,后面会讲)。

对于内建的整型、布尔型、浮点型、字符型、多元组都被实现了 Copy 所以,在进行传递的时候,会进行memcpy 这样的复制(bit-wise式的浅拷贝)。而对于对象来说,则不行,在Rust的编程范式中,需要使用的是 Clone trait。

于是,CopyClone 这两个相似而又不一样的概念就出来了,Copy 主要是给内建类型,或是由内建类型全是支持 Copy 的对象,而 Clone 则是给程序员自己复制对象的。嗯,这就是浅拷贝和深拷贝的差别,Copy 告诉编译器,我这个对象可以进行 bit-wise的复制,而 Clone 则是指深度拷贝。

String 这样的内部需要在堆上分布内存的数据结构,是没有实现Copy 的(因为内部是一个指针,所以,语义上是深拷贝,浅拷贝会招至各种bug和crash),需要复制的话,必需手动的调用其 clone() 方法,如果不这样的的话,当在进行函数参数传递,或是变量传递的时候,所有权一下就转移了,而之前的变量什么也不是了(这里编译器会帮你做检查有没有使用到所有权被转走的变量)。这个相当于C++的Move语义。

参看下面的示例,你可能对Rust自动转移所有权会有更好的了解(代码中有注释了,我就不多说了)。

// takes_ownership 取得调用函数传入参数的所有权,因为不返回,所以变量进来了就出不去了
fn takes_ownership(some_string: String) {
    println!("{}", some_string);
} // 这里,some_string 移出作用域并调用 drop 方法。占用的内存被释放

// gives_ownership 将返回值移动给调用它的函数
fn gives_ownership() -> String {
    let some_string = String::from("hello"); // some_string 进入作用域.
    some_string // 返回 some_string 并移出给调用的函数
}

// takes_and_gives_back 将传入字符串并返回该值
fn takes_and_gives_back(mut a_string: String) -> String {
    a_string.push_str(", world");
    a_string  // 返回 a_string 将所有权移出给调用的函数
}

fn main()
{
    // gives_ownership 将返回值移给 s1
    let s1 = gives_ownership();
    // 所有权转给了 takes_ownership 函数, s1 不可用了
    takes_ownership(s1);
    // 如果编译下面的代码,会出现s1不可用的错误
    // println!("s1= {}", s1);
    //                    ^^ value borrowed here after move
    let s2 = String::from("hello");// 声明s2
    // s2 被移动到 takes_and_gives_back 中, 它也将返回值移给 s3。
    // 而 s2 则不可用了。
    let s3 = takes_and_gives_back(s2);
    //如果编译下面的代码,会出现可不可用的错误
    //println!("s2={}, s3={}", s2, s3);
    //                         ^^ value borrowed here after move
    println!("s3={}", s3);
}

这样的 Move 的方式,在性能上和安全性上都是非常有效的,而Rust的编译器会帮你检查出使用了所有权被move走的变量的错误。而且,我们还可以从函数栈上返回对象了,如下所示:

fn new_person() -> Person {
    let person = Person {
        name : String::from("Hao Chen"),
        age : 44,
        sex : Sex::Male,
        email: String::from("[email protected]"),
    };
    return person;
}

fn main() {
   let p  = new_person();
}

因为对象是Move走的,所以,在函数上 new_person() 上返回的 Person 对象是Move 语言,被Move到了 main() 函数中来,这样就没有性能上的问题了。而在C++中,我们需要把对象的Move函数给写出来才能做到。因为,C++默认是调用拷贝构造函数的,而不是Move的。

Owner语义带来的复杂度

Owner + Move 的语义也会带来一些复杂度。首先,如果有一个结构体,我们把其中的成员 Move 掉了,会怎么样。参看如下的代码:

#[derive(Debug)] // 让结构体可以使用 {:?}的方式输出
struct Person {
    name :String,
    email:String,
}

let _name = p.name; // 把结构体 Person::name Move掉
println!("{} {}", _name, p.email); //其它成员可以正常访问
println!("{:?}", p); //编译出错 "value borrowed here after partial move"
p.name = "Hao Chen".to_string(); // Person::name又有了。
println!("{:?}", p); //可以正常的编译了

上面这个示例,我们可以看到,结构体中的成员是可以被Move掉的,Move掉的结构实例会成为一个部分的未初始化的结构,如果需要访问整个结构体的成员,会出现编译问题。但是后面把 Person::name补上后,又可以愉快地工作了。

下面我们再看一个更复杂的示例——这个示例模拟动画渲染的场景,我们需要有两个buffer,一个是正在显示的,另一个是下一帧要显示的。

struct Buffer {
    buffer : String,
}

struct Render {
    current_buffer : Buffer,
    next_buffer : Buffer,
}
//实现结构体 Render 的方法
impl Render { 
    //实现 update_buffer() 方法,
    //更新buffer,把 next 更新到 current 中,再更新 next
    fn update_buffer(& mut self, buf : String) {
        self.current_buffer = self.next_buffer;
        self.next_buffer = Buffer{ buffer: buf};
    }
}

上面这段代码,我们写下来没什么问题,但是 Rust 编译不会让我们编译通过。它会告诉我们如下的错误:

error[E0507]: cannot move out of self.next_buffer which is behind a mutable reference
--> /.........../xxx.rs:18:31
|
14 | self.current_buffer = self.next_buffer;
|                          ^^^^^^^^^^^^^^^^ move occurs because self.next_buffer has type Buffer,
                                            which does not implement the Copy trait

编译器会提示你,Buffer 没有 Copy trait 方法。但是,如果你实现了 Copy 方法后,你又不能享受 Move 带来的性能上快乐了。于是,到这里,你开始进退两难了,完全不知道取舍了

  • Rust编译器不让我们在成员方法中把成员Move走,因为 self 引用就不完整了。
  • Rust要我们实现 Copy Trait,但是我们不想要拷贝,因为我们就是想把 next_buffer move 到 current_buffer

我们想要同时 Move 两个变量,参数 buf move 到 next_buffer 的同时,还要把 next_buffer 里的东西 move 到 current_buffer 中。 我们需要一个“杂耍”的技能。

这个需要动用 std::mem::replace(&dest, src) 函数了, 这个函数技把 src 的值 move 到 dest 中,然后把 dest 再返回出来(这其中使用了 unsafe 的一些底层骚操作才能完成)。Anyway,最终是这样实现的:

use std::mem::replace
fn update_buffer(& mut self, buf : String) { 
  self.current_buffer = replace(&mut self.next_buffer, Buffer{buffer : buf}); 
}

不知道你觉得这样“杂耍”的代码看上去怎么以样?我觉得可读性下降一个数量级。

引用(借用)和生命周期

下面,我们来讲讲引用,因为把对象的所有权 Move 走了的情况,在一些时候肯定不合适,比如,我有一个 compare(s1: Student, s2: Student) -> bool 我想比较两个学生的平均份成绩, 我不想传复本,因为太慢,我也不想把所有权交进去,因为只是想计算其中的数据。这个时候,传引用就是一个比较好的选择,Rust同样支持传引用。只需要把上面的函数声明改成:compare(s1 :&Student, s2 : &Student) -> bool 就可以了,在调用的时候,compare (&s1, &s2);  与C++一致。在Rust中,这也叫“借用”(嗯,Rust发明出来的这些新术语,在语义上感觉让人更容易理解了,当然,也增加了学习的复杂度了)

引用(借用)

另外,如果你要修改这个引用对象,就需要使用“可变引用”,如:foo( s : &mut Student) 以及 foo( &mut s);另外,为了避免一些数据竞争需要进行数据同步的事,Rust严格规定了——在任意时刻,要么只能有一个可变引用,要么只能有多个不可变引用

这些严格的规定会导致程序员失去编程的灵活性,不熟悉Rust的程序员可能会在一些编译错误下会很崩溃,但是你的代码的稳定性也会提高,bug率也会降低。

另外,Rust为了解决“野引用”的问题,也就是说,有多个变量引用到一个对象上,还不能使用额外的引用计数来增加程序运行的复杂度。那么,Rust就要管理程序中引用的生命周期了,而且还是要在编译期管理,如果发现有引用的生命周期有问题的,就要报错。比如:

let r;
{
    let x = 10;
    r = &x;
}
println!("r = {}",r );

上面的这段代码,程序员肉眼就能看到 x 的作用域比 r  小,所以导致 rprintln() 的时候 r 引用的 x 已经没有了。这个代码在C++中可以正常编译而且可以执行,虽然最后可以打出“内嵌作用域”的 x 的值,但其实这个值已经是有问题的了。而在 Rust 语言中,编译器会给出一个编译错误,告诉你,“x dropped here while still borrowed”,这个真是太棒了。

但是这中编译时检查的技术对于目前的编译器来说,只在程序变得稍微复杂一点,编译器的“失效引用”检查就不那么容易了。比如下面这个代码:

fn order_string(s1 : &str, s2 : &str) -> (&str, &str) {
    if s1.len() < s2.len() {
        return (s1, s2);
    }
    return (s2, s1);
}

let str1 = String::from("long long long long string");
let str2 = "short string";

let (long_str, short_str) = order_string(str1.as_str(), str2);

println!(" long={} nshort={} ", long_str, short_str);

我们有两个字符串,str1str2 我们想通过函数 order_string() 把这两个字串符返回成 long_strshort_str  这样方便后面的代码进行处理。这是一段很常见的处理代码的示例。然而,你会发现,这段代码编译不过。编译器会告诉你,order_string() 返回的 引用类型 &str 需要一个 lifetime的参数 – “ expected lifetime parameter”。这是因为Rust编译无法通过观察静态代码分析返回的两个引用返回值,到底是(s1, s2) 还是 (s2, s1) ,因为这是运行时决定的。所以,返回值的两个参数的引用没法确定其生命周期到底是跟 s1 还是跟 s2,这个时候,编译器就不知道了。

生命周期

如果你的代码是下面这个样子,编程器可以自己推导出来,函数 foo() 的参数和返回值都是一个引用,他们的生命周期是一样的,所以,也就可以编译通过。

fn foo (s: &mut String) -> &String {
    s.push_str("sou-ip");
    s
}

let mut s = "hello, ".to_string();
println!("{}", foo(&mut s))

而对于传入多个引用,返回值可能是任一引用,这个时候编译器就犯糊涂了,因为不知道运行时的事,所以,就需要程序员来标注了。

fn long_string<'c>(s1 : &'c str, s2 : &'c str) -> (&'c str, &'c str) {
    if s1.len() > s2.len() {
        return (s1, s2);
    }
    return (s2, s1);
}

上述的Rust的标注语法,用个单引号加一个任意字符串来标注('static除外,这是一个关键词,表示生命周期跟整个程序一样长),然后,说明返回的那两个引用的生命周期跟 s1s2 的生命周期相同,这个标注的目的就是把运行时的事变成了编译时的事。于是程序就可以编译通过了。(注:你也不要以为你可以用这个技术乱写生命周期,这只是一种“去语法糖操作”,是帮助编译器理解其中的生命周期,如果违反实际生命周期,编译器也是会拒绝编译的)

这里有两个说明,

  • 只要你玩引用,生命周期标识就会来了。
  • Rust编译器不知道运行时会发生什么事,所以,需要你来标注声明

我感觉,你现在开始有点头晕了吧?接下来,我们让你再晕一下。比如:如果你要在结构体中玩引用,那必需要为引用声明生命周期,如下所示:

// 引用 ref1 和 ref2 的生命周期与结构体一致
struct Test <'life> {
    ref_int : &'life i32,
    ref_str : &'life str,
}

其中,生命周期标识 'life 定义在结构体上,被使用于其成员引用上。意思是声明规则——“结构体的生命周期 <= 成员引用的生命周期

然后,如果你要给这个结构实现两个 set 方法,你也得带上 lifetime 标识。

imp<'life> Test<'life> {
    fn set_string(&mut self, s : &'life str) {
        self.ref_str = s;
    }
    fn set_int(&mut self,  i : &'life i32) {
        self.ref_int = i;
    }
}

在上面的这个示例中,生命周期变量 'life 声明在 impl 上,用于结构体和其方法的入参上。 意思是声明规则——“结构体方法的“引用参数”的生命周期 >= 结构体的生命周期

有了这些个生命周期的标识规则后,Rust就可以愉快地检查这些规则说明,并编译代码了。

闭包与所有权

这种所有权和引用的严格区分和管理,会影响到很多地方,下面我们来看一下函数闭包中的这些东西的传递。函数闭包又叫Closure,是函数式编程中一个不可或缺的东西,又被称为lambda表达式,基本上所有的高级语言都会支持。在 Rust 语言中,其闭包函数的表示是用两根竖线(| |)中间加传如参数进行定义。如下所示:

// 定义了一个 x + y 操作的 lambda f(x, y) = x + y;
let plus = |x: i32, y:i32| x + y; 
// 定义另一个lambda g(x) = f(x, 5)
let plus_five = |x| plus(x, 5); 
//输出
println!("plus_five(10)={}", plus_five(10) );
函数闭包

但是一旦加上了上述的所有权这些东西后,问题就会变得复杂开来。参看下面的代码。

struct Person {
    name : String,
    age : u8,
}

fn main() {
    let p = Person{ name: "Hao Chen".to_string(), age : 44};
    //可以运行,因为 u8 有 Copy Trait
    let age = |p : Person| p.age; 
    // String 没有Copy Trait,所以,这里所有权就 Move 走了
    let name = |p : Person | p.name; 
    println! ("name={}, age={}" , name(p), age(p));
}

上面的代码无法编译通过,因为Rust编译器发现在调用 name(p) 的时候,p 的所有权被移走了。然后,我们想想,改成引用的版本,如下所示:

let age = |p : &Person| p.age;
let name = |p : &Person | &p.name;

println! ("name={}, age={}" , name(&p), age(&p));

你会现在还是无法编译,报错中说:cannot infer an appropriate lifetime for borrow expression due to conflicting requirements

error[E0495]: cannot infer an appropriate lifetime for borrow expression due to conflicting requirements
  --> src/main.rs:11:31
   |
11 |     let name = |p : &Person | &p.name;
   |                               ^^^^^^^

然后你开始尝试加 lifetime,用尽各种Rust的骚操作(官方Github上的 #issue 58052),然后,还是无法让你的程序可以编译通过。最后,上StackOverflow 里寻找帮助,得到下面的正确写法(这个可能跟这个bug有关系:#issue 41078 )。但是这样的写法,已经让简洁的代码变得面目全非。

//下面的声明可以正确译
let name: for<'a> fn(&'a Person) -> &'a String = |p: &Person| &p.name;

上面的这种lifetime的标识也是很奇葩,通过定义一个函数类型来做相关的标注,但是这个函数类型,需要用到 for<'a> 关键字。你可能会很confuse这个关键字不是用来做循环的吗?嗯,Rust这种重用关键字的作法,我个人觉得带来了很多不必要的复杂度。总之,这样的声明代码,我觉得基本不会有人能想得到的——“去语法糖操作太严重了,绝大多数人绝对hold不住”!

最后,我们再来看另一个问题,下面的代码无法编译通过:

let s = String::from("sou-ip");
let take_str = || s;
println!("{}", s); //ERROR
println!("{}",  take_str()); // OK

Rust的编译器会告诉你,take_str  把 s 的所有权给拿走了(因为需要作成返回值)。所以,后面的输出语句就用不到了。这里意味着:

  • 对于内建的类型,都实现了 Copy 的 trait,那么闭包执行的是 “借用”
  • 对于没有实现 Copy 的trait,在闭包中可以调用其方法,是“借用”,但是不能当成返回值,当成返回值了就是“移动”。

虽然有了这些“通常情况下是借用的潜规则”,但是还是不能满足一些情况,所以,还要让程序员可以定义 move 的“明规则”。下面的代码,一个有 move 一个没有move,他们的差别也不一样。

//-----------借用的情况-----------
let mut num = 5;
{
    let mut add_num = |x: i32| num += x;
    add_num(5);
}
println!("num={}", num); //输出 10

//-----------Move的情况-----------
let mut num = 5;
{
    // 把 num(5)所有权给 move 到了 add_num 中,
    // 使用其成为闭包中的局部变量。
    let mut add_num = move |x: i32| num += x;
    add_num(5);
    println!("num(move)={}", num); //输出10
}
//因为i32实现了 Copy,所以,这里还可以访问
println!("num(move)={}", num); //输出5

真是有点头大了,int这样的类型,因为实现了Copy Trait,所以,所有权被移走后,意味着,在内嵌块中的num 和外层的 num 是两个完全不相干的变量。但是你在读代码的时候,你的大脑可能并不会让你这么想,因为里面的那个num又没有被声明过,应该是外层的。我个人觉得这是Rust 各种“按下葫芦起了瓢”的现象。

线程闭包

通过上面的示例,我们可以看到, move 关键词,可以把闭包外使用到的变量给移动到闭包内,成为闭包内的一个局部变量。这种方式,在多线程的方式下可以让线程运行地更为的安全。参看如下代码:

let name = "sou-ip".to_string();
let t = thread::spawn(move || {
    println!("Hello, {}", name);
});
println!("wait {:?}", t.join());

首先,线程 thread::spawn() 里的闭包函数是不能带参数的,因为是闭包,所以可以使用这个可见范围内的变量,但是,问题来了,因为是另一个线程,所以,这代表其和其它线程(如:主线程)开始共享数据了,所以,在Rust下,要求把使用到的变量给 Move 到线程内,这就保证了安全的问题—— name 在线程中永远不会失效,而且不会被别人改了。

你可能会有一些疑问,你会质疑到

  • 一方面,这个 name 变量又没有声明成 mut 这意味着不变,没必要使用move语义也是安全的。
  • 另一方面,如果我想把这个 name 传递到多个线程里呢?

嗯,是的,但是Rust的线程必需是 move的,不管是不是可变的,不然编译不过去。如果你想把一个变量传到多个线程中,你得创建变量的复本,也就是调用 clone() 方法。

let name = "sou-ip".to_string();
let name1 = name.clone();
let t1 = thread::spawn(move || {
    println!("Hello, {}", name.clone());
})
let t2 = thread::spawn(move || {
    println!("Hello, {}", name1.clone());
});
println!("wait t1={:?}, t2={:?}", t1.join(), t2.join());

然后,你说,这种clone的方式成本不是很高?设想,如果我要用多线程对一个很大的数组做统计,这种clone的方式完全吃不消。嗯,是的。这个时候,需要使用另一个技术,智能指针了。

Rust的智能指针

如果你看到这里还不晕的话,那么,我的文章还算成功(如果晕的话,请告诉我,我会进行改善)。接下来我们来讲讲Rust的智能指针和多态。

因为有些内存需要分配在Heap(堆)上,而不是Stack(堆)上,Stack上的内存一般是编译时决定的,所以,编译器需要知道你的数组、结构体、枚举等这些数据类型的长度,没有长度是无法编译的,而且长度也不能太大,Stack上的内存大小是有限,太大的内存会有StackOverflow的错误。所以,对于更大的内存或是动态的内存分配需要分配在Heap上。学过C/C++的同学对于这个概念不会陌生。

Rust 作为一个内存安全的语言,这个堆上分配的内存也是需要管理的。在C中,需要程序员自己管理,而在C++中,一般使用 RAII 的机制(面向对象的代理模式),一种通过分配在Stack上的对象来管理Heap上的内存的技术。在C++中,这种技术的实现叫“智能指针”(Smart Pointer)。

在C++11中,会有三种智能指针(这三种指针是什么我就不多说了):

  • unique_ptr。独占内存,不共享。在Rust中是:std::boxed::Box
  • shared_ptr。以引用计数的方式共享内存。在Rust中是:std::rc::Rc
  • weak_ptr。不以引用计数的方式共享内存。在Rust中是:std::rc::Weak

对于独占的 Box 不多说了,这里重点说一下共享的 RcWeak

  • 对于Rust的 Rc 来说,Rc指针内会有一个 strong_count 的引用持计数,一旦引用计数为0后,内存就自动释放了。
  • 需要共享内存的时候,需要调用实例的 clone() 方法。如: let another = rc.clone() 克隆的时候,只会增加引用计数,不会作深度复制(个人觉得Clone的语义在这里被践踏了)
  • 有这种共享的引用计数,就意味着有多线程的问题,所以,如果需要使用线程安全的智能指针,则需要使用std::sync::Arc
  • 可以使用 Rc::downgrade(&rc) 后,会变成 Weak 指针,Weak指针增加的是 weak_count 的引用计数,内存释放时不会检查它是否为 0。

我们简单的来看个示例:

use std::rc::Rc;
use std::rc::Weak

//声明两个未初始化的指针变量
let weak : Weak; 
let strong : Rc;
{
    let five = Rc::new(5); //局部变量
    strong = five.clone(); //进行强引用
    weak = Rc::downgrade(&five); //对局部变量进行弱引用
}
//此时,five已析构,所以 Rc::strong_count(&strong)=1, Rc::weak_count(&strong)=1
//如果调用 drop(strong),那个整个内存就释放了
//drop(strong);

//如果要访问弱引用的值,需要把弱引用 upgrade 成强引用,才能安全的使用
match  weak_five.upgrade() {
    Some(r) => println!("{}", r),
    None => println!("None"),
} 

上面这个示例比较简单,其中主要展示了,指针共享的东西。因为指针是共享的,所以,对于强引用来说,最后的那个人把引用给释放了,是安全的。但是对于弱引用来说,这就是一个坑了,你们强引用的人有Ownership,但是我们弱引用没有,你们把内存释放了,我怎么知道?

于是,在弱引用需要使用内存的时候需要“升级”成强引用 ,但是这个升级可能会不成功,因为内存可能已经被别人清空了。所以,这个操作会返回一个 Option 的枚举值,Option::Some(T) 表示成功了,而 Option::None 则表示失改了。你会说,这么麻烦,我们为什么还要 Weak ? 这是因为强引用的 Rc 会有循环引用的问题……(学过C++的都应该知道)

另外,如果你要修改 Rc 里的值,Rust 会给你两个方法,一个是 get_mut(),一个是 make_mut() ,这两个方法都有副作用或是限制。

get_mut() 需要做一个“唯一引用”的检查,也就是没有任何的共享才能修改

//修改引用的变量 - get_mut 会返回一个Option对象
//但是需要注意,仅当(只有一个强引用 && 没有弱引用)为真才能修改
if let Some(val) = Rc::get_mut(&mut strong) {
    *val = 555;
}

make_mut() 则是会把当前的引用给clone出来,再也不共享了, 是一份全新的。

//此处可以修改,但是是以 clone 的方式,也就是让strong这个指针独立出来了。
*Rc::make_mut(&mut strong) = 555;

如果不这样做,就会出现很多内存不安全的情况。这些小细节一定要注意,不然你的代码怎么运作的你会一脸蒙逼的

嗯,如果你想更快乐地使用智能指针,这里还有个选择 – CellRefCell,它们弥补了 Rust 所有权机制在灵活性上和某些场景下的不足。他们提供了 set()/get() 以及 borrow()/borrow_mut() 的方法,让你的程序更灵活,而不会被限制得死死的。参看下面的示例。

use std::cell::Cell;
use std::cell::RefCell

let x = Cell::new(1);
let y = &x; //引用(借用)
let z = &x; //引用(借用)
x.set(2); // 可以进行修改,x,y,z全都改了
y.set(3);
z.set(4);
println!("x={} y={} z={}", x.get(), y.get(), z.get());

let x = RefCell::new(vec![1,2,3,4]);
{
    println!("{:?}", *x.borrow())
}

{
    let mut my_ref = x.borrow_mut();
    my_ref.push(1);
}
println!("{:?}", *x.borrow());

通过上面的示例你可以看到你可以比较方便地更为正常的使用智能指针了。然而,需要注意的是 CellRefCell 不是线程安全的。在多线程下,需要使用Mutex进行互斥。

线程与智能指针

现在,我们回来来解决前面那还没有解决的问题,就是——我想在多个线程中共享一个只读的数据,比如:一个很大的数组,我开多个线程进行并行统计。我们肯定不能对这个大数组进行clone,但也不能把这个大数组move到一个线程中。根据上述的智能指针的逻辑,我们可以通过智指指针来完成这个事,下面是一个例程:

const TOTAL_SIZE:usize = 100 * 1000; //数组长度
const NTHREAD:usize = 6; //线程数

let data : Vec<i32> = (1..(TOTAL_SIZE+1) as i32).collect(); //初始化一个数据从1到n数组
let arc_data = Arc::new(data); //data 的所有权转给了 ar_data
let result  = Arc::new(AtomicU64::new(0)); //收集结果的数组(原子操作)

let mut thread_handlers = vec![]; // 用于收集线程句柄

for i in 0..NTHREAD {
    // clone Arc 准备move到线程中,只增加引用计数,不会深拷贝内部数据
    let test_data = arc_data.clone(); 
    let res = result.clone(); 
    thread_handlers.push( 
        thread::spawn(move || {
            let id = i;
            //找到自己的分区
            let chunk_size = TOTAL_SIZE / NTHREAD + 1;
            let start = id * chunk_size;
            let end = std::cmp::min(start + chunk_size, TOTAL_SIZE);
            //进行求和运算
            let mut sum = 0;
            for  i in start..end  {
                sum += test_data[i];
            }
            //原子操作
            res.fetch_add(sum as u64, Ordering::SeqCst);
            println!("id={}, sum={}", id, sum );
        }
    ));
}
//等所有的线程执行完
for th in thread_handlers {
    th.join().expect("The sender thread panic!!!");
}
//输出结果
println!("result = {}",result.load(Ordering::SeqCst));

上面的这个例程,是用多线程的方式来并行计算一个大的数组的和,每个线程都会计算自己的那一部分。上面的代码中,

  • 需要向每个线程传入一个只读的数组,我们用Arc 智能指针把这个数组包了一层。
  • 需要向每个线程传入一个变量用于数据数据,我们用 Arc<AtomicU64> 包了一层。
  • 注意:Arc 所包的对象是不可变的,所以,如果要可变的,那要么用原子对象,或是用Mutex/Cell对象再包一层。

这一些都是为了要解决“线程的Move语义后还要共享问题”。

多态和运行时识别

通过Trait多态

多态是抽象和解耦的关键,所以,一个高级的语言是必需实现多态的。在C++中,多态是通过虚函数表来实现的(参看《C++的虚函数表》),Rust也很类似,不过,在编程范式上,更像Java的接口的方式。其通过借用于Erlang的Trait对象的方式来完成。参看下面的代码:

struct Rectangle {
    width : u32,
    height : u32,
} 

struct Circle {
    x : u32,
    y : u32,
    radius : u32,
}

trait  IShape  { 
    fn area(&self) -> f32;
    fn to_string(&self) -> String;
}

我们有两个类,一个是“长方形”,一个是“圆形”, 还有一个 IShape 的trait 对象(原谅我用了Java的命名方式),其中有两个方法:求面积的 area() 和 转字符串的 to_string()。下面相关的实现:

impl IShape  for Rectangle {
    fn area(&self) -> f32 { (self.height * self.width) as f32 }
    fn to_string(&self) ->String {
         format!("Rectangle -> width={} height={} area={}", 
                  self.width, self.height, self.area())
    }
}

use std::f64::consts::PI;
impl IShape  for Circle  {
    fn area(&self) -> f32 { (self.radius * self.radius) as f32 * PI as f32}
    fn to_string(&self) -> String {
        format!("Circle -> x={}, y={}, area={}", 
                 self.x, self.y, self.area())
    }
}

于是,我们就可以有下面的多态的使用方式了(我们使用独占的智能指针类 Box):

use std::vec::Vec;

let rect = Box::new( Rectangle { width: 4, height: 6});
let circle = Box::new( Circle { x: 0, y:0, radius: 5});
let mut v : Vec<Box> = Vec::new();
v.push(rect);
v.push(circle);

for i in v.iter() {
   println!("area={}", i.area() );
   println!("{}", i.to_string() );
}
向下转型

但是,在C++中,多态的类型是抽象类型,我们还想把其转成实际的具体类型,在C++中叫运行进实别RTTI,需要使用像 type_id 或是 dynamic_cast 这两个技术。在Rust中,转型是使用 ‘as‘ 关键字,然而,这是编译时识别,不是运行时。那么,在Rust中是怎么做呢?

嗯,这里需要使用 Rust 的 std::any::Any 这个东西,这个东西就可以使用 downcast_ref 这个东西来进行具体类型的转换。于是我们要对现有的代码进行改造。

首先,先得让 IShape 继承于 Any ,并增加一个 as_any() 的转型接口。

use std::any::Any;
trait  IShape : Any + 'static  {
    fn as_any(&self) -> &dyn Any; 
    …… …… …… 
}

然后,在具体类中实现这个接口:

impl IShape  for Rectangle {
    fn as_any(&self) -> &dyn Any { self }
    …… …… …… 
}
impl IShape  for Circle  {
    fn as_any(&self) -> &dyn Any { self }
    …… …… …… 
}

于是,我们就可以进行运行时的向下转型了:

let mut v : Vec<Box<dyn IShape>> = Vec::new();
v.push(rect);
v.push(circle);
for i in v.iter() {
    if let Some(s) = i.as_any().downcast_ref::<Rectangle>() {
        println!("downcast - Rectangle w={}, h={}", s.width, s.height);
    }else if let Some(s) = i.as_any().downcast_ref::<Circle>() {
        println!("downcast - Circle x={}, y={}, r={}", s.x, s.y, s.radius);
    }else{
        println!("invaild type");
    }
}

Trait 重载操作符

操作符重载对进行泛行编程是非常有帮助的,如果所有的对象都可以进行大于,小于,等于这亲的比较操作,那么就可以直接放到一个标准的数组排序的的算法中去了。在Rust中,在 std::ops 下有全载的操作符重载的Trait,在std::cmp 下则是比较操作的操作符。我们下面来看一个示例:

假如我们有一个“员工”对象,我们想要按员工的薪水排序,如果我们想要使用Vec::sort()方法,我们就需要实现这个对象的各种“比较”方法。这些方法在 std::cmp 内—— 其中有四个Trait : OrdPartialOrdEqPartialEq  。其中,Ord 依赖于 PartialOrdEq ,而Eq 依赖于 PartialEq,这意味着你需要实现所有的Trait,而Eq 这个Trait 是没有方法的,所以,其实现如下:

use std::cmp::{Ord, PartialOrd, PartialEq, Ordering};

#[derive(Debug)]
struct Employee {
    name : String,
    salary : i32,
}
impl Ord for Employee {
    fn cmp(&self, rhs: &Self) -> Ordering {
        self.salary.cmp(&rhs.salary)
    }
}
impl PartialOrd for Employee {
    fn partial_cmp(&self, rhs: &Self) -> Option<Ordering> {
        Some(self.cmp(rhs))
    }
}
impl Eq for Employee {
}
impl PartialEq for Employee {
    fn eq(&self, rhs: &Self) -> bool {
        self.salary == rhs.salary
    }
}

于是,我们就可以进行如下的操作了:

let mut v = vec![
    Employee {name : String::from("Bob"),     salary: 2048},
    Employee {name : String::from("Alice"),   salary: 3208},
    Employee {name : String::from("Tom"),     salary: 2359},
    Employee {name : String::from("Jack"),    salary: 4865},
    Employee {name : String::from("Marray"),  salary: 3743},
    Employee {name : String::from("Hao"),     salary: 2964},
    Employee {name : String::from("Chen"),    salary: 4197},
];

//用for-loop找出薪水最多的人
let mut e = &v[0];
for i in 0..v.len() {
    if *e < v[i] { 
        e = &v[i]; 
    }
}
println!("max = {:?}", e);

//使用标准的方法
println!("min = {:?}", v.iter().min().unwrap());
println!("max = {:?}", v.iter().max().unwrap());

//使用标准的排序方法
v.sort();
println!("{:?}", v);

小结

现在我们来小结一下:

  • 在Rust的中,最重要的概念就是“不可变”和“所有权”以及“Trait”这三个概念。
  • 在所有权概念上,Rust喜欢move所有权,如果需要借用则需要使用引用。
  • Move所有权会导致一些编程上的复杂度,尤其是需要同时move两个变量时。
  • 引用(借用)的问题是生命周期的问题,一些时候需要程序员来标注生命周期。
  • 在函数式的闭包和多线程下,这些所有权又出现了各种麻烦事。
  • 使用智能指针可以解决所有权和借用带来的复杂度,但带来其它的问题。
  • 最后介绍了Rust的Trait对象完成多态和函数重载的玩法。

Rust是一个比较严格的编程语言,它会严格检查你程序中的:

  • 变量是否是可变的
  • 变量的所有权是否被移走了
  • 引用的生命周期是否完整
  • 对象是否需要实现一些Trait

这些东西都会导致失去编译的灵活性,并在一些时候需要“去糖”,导致,你在使用Rust会有诸多的不适应,程序编译不过的挫败感也是令人沮丧的。在初学Rust的时候,我想自己写一个单向链表,结果,费尽心力,才得以完成。也就是说,如果你对Rust的概念认识的不完整,你完全写不出程序,那怕就是很简单的一段代码。我觉得,这种挺好的,逼着程序员必需了解所有的概念才能编码。但是,另一方面也表明了这门语言并不适合初学者。

没有银弹,任何语言都有些适合的地方和场景。

(全文完)

(转载本站文章请注明作者和出处 宝酷 – sou-ip ,请勿用于任何商业用途)

好烂啊有点差凑合看看还不错很精彩 (130 人打了分,平均分: 4.57 )
Loading...

Rust语言的编程范式》的相关评论

  1. 皓哥,关于shadowing是有适用范围的,比如说函数从外面拿到一个String,我现在只想要这个String的长度而不需要它的内容,shadowing可以免去起新名字.
    fn Foo(s:String) {
    let s=s.len();
    }
    而注意到let s其实是重新绑定了变量(新s和旧s类型不同),而不是直接s=s.len(),这样编译是不会通过的.

    1. 看完文章以后,我觉得Rust比C++难多了。在C++11以后,只要用好shared_ptr,unique_ptr和weak_ptr,在性能和安全性上就问题不大了。

      看了Rust的语法以后,感觉要想写出一个冒泡排序也要好久。不太明白为什么Rust不直接禁止掉Shadowing,直接编译报错就好了?新语言有没有旧代码的负担。

      个人建议,仅供参考。

      1. Shadowing不是让滥用的啊…Shadowing的时候最好你自己清楚自己在干什么,新旧同名变量最好不是同种类型的

    1. 其实我感觉 shadowing 也是受 OCaml 影响的 … 这东西在 OCaml 代码里很常用

  2. 关于里面提到的多线程问题,需要move本质上是因为标准库里的 thread::spawn 要求闭包是 ‘static 的,因为无法在编译时验证线程的生命期。但在多线程计算时,线程的生命期通常都是有限的,因为你需要它的结果,这时就可以通过crossbeam或者rayon里提供的scoped thread来无开销地在多线程之间共享数据而不需要使用Arc。

      1. 但Rust相对来说有较小的标准库和更好的包管理,所以很多常用库也应该算作范式的一部分吧,毕竟是Rust的设计使得这样一些功能可以在库里被安全地包装出来。

  3. 注意到这里甚至没有提到Arc……C++里的shared_ptr其实应该对应到Rust的Arc,因为shared_ptr是线程安全的,Rc不是(跨线程使用会导致无法通过编译)。Rc只是Arc的单线程版本(不使用原子计数)。另外多线程下也不能使用Cell和RefCell,只能使用Atomic*和Mutex/RwLock等同步原语。

    1. shared_ptr只是操作引用计数是原子的,但是多个线程reset, swap肯定不是安全的

  4. 关于 Clone::clone() 语义的问题,实际上是因类型而定的,而非泛泛的浅拷贝/深拷贝概念(Rust 里就没有这俩概念),所以个人认为不存在“践踏” Clone 语义的问题。

      1. 这里是各类型自己定义“什么叫 clone 自身”。对于 Copy 类型, clone 就是 memcpy ;对于 String ,就是弄出来一个一毛一样的新 String ;对于带所有权的智能指针类型,就要根据自身的语义分别判断(Box 的 clone 是透传到里面的类型的 clone 的行为,Rc/Arc 的 clone 就是加引用计数);可以看出来这里面并没有一个统一的范式。至于 clone 的名字,估计是当时设计的时候也想不出来什么更好的了,至少没有浅拷贝/深拷贝这俩定义不清语义混乱的概念,已经是一大进步了。

        1. 我不再跟你辩论了,本文我围绕着共享、引用、拷贝、复制、克隆、这些范式来讲,就是不想混淆这些东西,Rust本意是想做到,但是还是在很多地方令人困扰……

          1. 嗯…… Rust 设计和实现的时候有意识地(我这么认为)刨除了这些传统上较为常见的术语和定义,导致了这样的情况。至于是好是坏就见仁见智吧……

            1. 我人具觉得Rust的初衷是非常不错的,正如文中所说,限制严格了,带来的好处和问题都很明显。

  5. 自认为高级编程语言是提高生产效率,降低程序员心智负担,能快速造出产品产生价值创造收益的。很难想象这么多规则下来,还有人能乐在其中构建产品,尤其是经过 C++ 折磨过的人,当然对语言本身就有兴趣的人除外。据说 Rust 编译也很慢,算是继承了 C++ 了 ;)

    1. “高级”在很多方向上具有不同的含义:Haskell 、SQL 、Prolog 都很“高级”,但它们的目标完全不同。从这个意义上说,不存在普适的“高级”定义。(而且作为一个 Rust 吹子我其实不觉得 Rust 很高级……)

  6. 关于 Rust 学习曲线陡峭这个事情我倒觉得是好事情,从术业有专攻的角度来看,编程并不是一件容易的事情。尤其是所谓入门简单的语言最后都会给你狠狠的一记耳光,在你觉得自己入门了,可以大展身手的时候。Rust 从一开始就你放下这种自负的幻觉,专注于掌握语言的所有细节,否则寸步难行一点不夸张。定位于全新系统开发语言,追求稳定高效,门槛高一点是好事,毕竟不是随便搞搞就能去做系统开发的,系统知识要求高。当然当前流行的 JavaScript、python 甚至 Go 都是以简单易上手而获得大量用户,我只能说 Rust 再走一条自己的路

    1. 嗯,好坏都是相对的,这一边有了,另一边就没有了。当你说编程并不容易,我也可以说编程也没什么神秘的事,当你说,编译需要了解这些变量所有权的东西,我也可以说,编程本就不应该要去关心这些。你可以说门槛高的东西容易培养高手,而我也可以说这样的东西在传播上都会遇到问题,最终曲高和寡……任何事情都是trade-off,这一面有多光亮,另一面就有多黑暗……

      看看Rust的那些语法糖的操作,还有为了要同时move两个对象用unsafe搞出来的之类的骚操作,其实都是让这门语言复杂度攀升的原因,这些东西,我并不觉得有多好!

      1. 楼主经常写错别字,这是拼音的一个弊端,推荐一个好方法给你,改用五笔,包你以后很少打错别字

        1. 汗,我就是打五笔的……打了20多年了,现在打字都是下意识的,所以,错别字也让人读不懂。见谅见谅。

  7. 耗子树,厉害了,基本上全涵盖了。

    关于&mut _ 为了reborrow 倒是死查一顿资料

  8. 皓哥,完全不需要replace这种东西,只需要用Option包裹一下即可,take可以取出所有权.

    struct Buffer {
        buffer : String,
    }
    struct Render {
        current_buffer : Buffer,
        next_buffer : Option
    }
    //实现结构体 <code>Render</code> 的方法
    impl Render { 
        //实现 update_buffer() 方法,
        //更新buffer,把 next 更新到 current 中,再更新 next
        fn update_buffer(& mut self, buf : String) {
            self.current_buffer = self.next_buffer.take().unwrap();
            self.next_buffer = Some(Buffer{ buffer: buf});
        }
    }
      1. 应该是这样的:next_buffer : Option
        另外当我们比编译器知道的更多的时候可以用unwrap,比如在这里我们可以在某处保证next_buffer一定存放了数据! 所以可以放心用unwrap

    1. 您用Option的写法让我想起了leetcode上第二题(Add Two Numbers)遍历输入链表以及构造新链表的方法,不过leetcode上链表的数据结构是写死的Option<Box<ListNode>>,似乎也没法用std::mem::replace去优化性能

  9. 线程闭包

    你可能会有一些疑问,你会质疑到

    一方面,这个 name 变量又没有声明成 mut 这意味着不变,没必要使用move语义也是安全的。

    这里需要 move , 原因是: name 在子线程访问到的时候需要保证还有效, 和是否 mut 无关. 如果能保证 name 在子线程中还有效, 比如使用 scoped thread, 是不需要 move 的

  10. 但是这样的写法,已经让简洁的代码变得面目全非。

    //下面的声明可以正确译
    let name: for<‘a> fn(&’a Person) -> &’a String = |p: &Person| &p.name;

    创建的闭包如果有调用, 可以避免 Higher-Rank Trait Bound (HRTB). 有其他不能避免的情况. https://doc.rust-lang.org/nomicon/hrtb.html, 但在创建闭包调用闭包这种 local 场景下, 比较少见

  11. 对于内建的类型,都实现了 Copy 的 trait,那么闭包执行的是 “借用”
    对于没有实现 Copy 的trait,在闭包中可以调用其方法,是“借用”,但是不能当成返回值,当成返回值了就是“移动”。

    这里的描述不太准确, 几个概念有点混淆.
    简单来说, 闭包会根据闭包内使用需求, 采取最小化的动作, borrow, 然后是 mut borrow, 最后才是 move:

    foo_ref(&var) 是 borrow
    foo_mut(&mut var) 是 mut borrow
    var; 是 move, foo(var) 也是 move;

    是否 Copy, 和上面的行为正交, 影响的是 move 本身的行为.

    move |x: i32| num += x

    这里的 move, 是为了改变上面的最小化动作原则, 让单纯从闭包内使用看起来只 (mut) borrow 就可以的场景也强制 move.

    1. 你的这个描述也好不到哪去啊。主要是Rust的这些概念太复杂了,导致不同的人要用自己喜欢的方式去理解。

  12. 最近照着 《自己动手写java虚拟机》用 Rust 写一个玩具 JVM,前 5 章平均写出来只需要半天一天,要通过编译要花 3、4 天,太难了 T_T

  13. Rust的编程范式和语义是解决c/c++的一些安全问题,同时想保证性能(不想引入虚拟机和GC带来的性能和不确定性)。于是要通过一些手段来协助编译器检查,导致代码的复杂度会提高,编程时会对编译的过程有感知(要去一些语法糖衣)。我觉得对于rust解决的问题,带来的这些复杂度是可以接受的。没有银弹。
    注:有一处”引用技术”应该是写错了,是”引用计数”

  14. println!("{?:}", p)
    是不是
    println!("{:?}", p)
    好像示例代码没有初始化Person

    #[derive(Debug)]
    struct Person{
        name:String,
        email:String,
    }
    
    fn main() {
        let p=Person{name:String::from("fdsfdsf"),email:String::from("[email protected]")};
        let _name = p.name;
        println!("{} {}", _name, p.email);
        println!("{:?}", p);
    }
  15. shadowing的用法肯定会有人误用。
    记得文档中说过,一般用在类型转换的时候,主要是Rust不可变,转换类型时,如果弄一堆s_str, s_int之类的挺麻烦的。

  16. 还有官方文档中的所有权图示其实挺好的。
    你的文章看到后面确实头晕,虽然以前学过几天Rust,但是现在看还是很多看不懂。
    建议加点图示,对新手友好点。

  17. 骚操作太多了,看那个《Learning Rust With Entirely Too Many Linked Lists》就花了2周时间才彻底看明白,&mut self必须replace Empty也真是醉了。

  18. 纠错:1. 引用或指针。也就是不建『复本』=>副本;2. 我觉得可读性「下性」=>下降。

  19. 这篇文章对比c++的部分让我对c++有些知识理解得更深刻了,比较喜欢这种文章。

  20. 写的很棒,但也想了解下您对rust宏、甚至是过程宏的看法哈哈(我似乎有点贪得无厌了

  21. 现代程序最麻烦/容易出错的地方就是内存管理。GC的引入本来是为了解决这问题。
    Rust为了解决内存管理推出了一堆owner之类的新概念,然后又在引入智能指针这类编译器不能识别的循环引用问题,那新一堆owner概念还有什么意义呢?
    是让review的人只需检查有智能指针的地方?
    那和C+只用智能指针(object C?)区别也不大了吧

    1. 内存安全和内存泄漏是两个话题。rust保证在不使用unsafe的情况下的内存安全和线程安全性,但是不保证内存不会“泄露”。

      rust的这套无gc的内存管理系统可以保证其在嵌入式开发环境下或其他不适用于GC的情况下(如编写操作系统)依然可以有良好的表现,还是那句话嘛,没有银弹。

      虽然说博主在文中一直提到rust在“去糖”化,但是我觉得rust基本上已经把现有的编译器成果全用上了……

      C++全用智能指针也可能出问题啊,君不见Google写的Chrome开发水平够高了吧,还不是三天两头出内存安全问题,没有严格的编译器限制真的很难写出内存安全的代码(你不可能像rust编译器那样认真对待自己的C++代码,也没有安全规范能做到……

  22. 我想你没明白我说的问题。
    我意思是rust使用了智能指针,就不能保证不出循环引用的内存管理问题。这样就把rust费尽心思引进owner之类一堆概念想达到的内存自动管理目的破坏了。还不如去掉智能指针,只使用owner类的概念管理更能达到效果(但rust在这种情况下是否效率打折扣就不得而知,也不是我想讨论的范畴了)
    至于说C+智能指针就可以避免内存管理问题的话,我没有说过,只是你自己错误理解。我开始就说智能指针解决不了所有内存自动管理的问题,所以才说rust引进智能指针是回到了内存手动管理的时代

    1. 这个论点不成立,考虑到总有动态生命周期的 object ,这些如果不用 GC ,就必须用智能指针做生命周期管理。至于循环引用,那都是小事——你想 safe 的写出来循环引用也不是那么直接的一件事,更何况如果大家都知道有存在循环引用的设计,那改用其他的技术嘛,比如 Arena 或者 Opt-in GC 之类的

      1. 基于扫描和智能指针的差别还是很大的,如果不是由于智能指针的缺陷,java就没有这么大的GC瓶颈,直接改智能指针做内存管理好了。
        rust也不用花那么大劲引入owner的一套机制出来了,都让用智能指针得了。
        用户如果大部分都用rust的智能指针,我想rust的内存管理问题又会回退到C的情况,owner机制在这种情况下感觉就是浪费人和机器的工作量罢了。就像是用java+native JNI 绑定C开发,重新命名为javaCC类名字,然后说javaCC是内存安全的语言没两样。
        当然这么比较rust和javaCC肯定不合适,我只是打个不大恰当的比方。问题是javaCC如果内存管理出问题,是不是可以说是native JNI 绑定C的功能问题,不是javaCC本身的问题?我想rust继续两边分裂下去,有人会抱有这论调不(出问题的是智能指针,rust还是内存安全的?)

        1. 所以你还是没搞明白rust这套内存机制的意义,内存所有权机制不能解决所有问题,所以需要Rc来补充,本来编程就自然地需要共享所有权的机制。

          内存所有权的主要意义在于,它是运行时无开销的,也就是只在编译时检查,且可以保证内存安全,只用这套系统也可以保证内存不泄露。而GC当然也能达到这个目的,但是问题是它是以运行时的开销为代价的。而另一个更重要的问题是GC本身能保证内存安全吗,Java的GC是C++写的,人类能保证GC本身的内存安全吗?(rust的一个设计目标就是通过形式化验证,完全确保语言本身的内存安全性。)

          在我眼中,rust的内存安全性存在3种级别:
          完全的内存安全(无悬挂指针,无内存未初始化,无泄漏),在这种情况下你不能使用Rc;
          部分内存安全(无悬挂指针,无内存未初始化),这是你使用了Rc的情况,这种情况下虽然内存可能泄漏,但是不会导致栈溢出、堆溢出等exploitable的安全问题;
          完全不保证内存安全,这是你使用了unsafe的情况。

          你需要什么样的内存保护级别,就去使用对应级别的语言功能。我还是那句话,你想要获得什么特性就去用什么,安全没有银弹。

          rust本质上是一套应用于程序上的约束,你满足这个约束就能获得一些性质,但是有时候约束太严格一些程序就写不出来了,所以也需要适度的给程序员这个解开约束的方法。

          1. 你就是我说的会有这种论调的典型了:(出问题的是智能指针,rust还是内存安全的)有问题的是智能指针RC,rust除掉RC方案后就是内存安全了,(因为没有RC功能上不足,要加上RC方案扩展)。
            另外说一下Java的GC不安全不是因为GC,而是java的GC是用C++写的。如果用java的JRE全用
            java写的话,估计是不会有这种问题,但是会有另外一个问题:java会变得太慢。

        2. 当然我也明白对于非安全出身的程序员,可能很难理解为什么写出一个“安全”的程序有多难,C++写出一个可被利用的程序有多容易(虽然这一情况现在已经好得多得多了,因为有stack canary,ASLR,CFI等默认集成在编译器和操作系统中的安全机制)。

          1. Rust可能对系统级(尤其是嵌入式裸机)的安全,具有更大的意义。这个场景下多数安全机制不复存在,内存就是程序员面前的一张白纸,自由而危险。

    2. Rust终究只保内存不炸,可以保不容易漏,但不保不漏。
      承认局限。事实上保证即使不引入GC也不会炸内存(safe Rust安全范围内),这已经不是一个小目标。

  23. 感谢皓哥,不是程序员也看懂了一部分!
    发现 2 处错别字:
    4.1. 引用(借用)
    但是这「中」编译时检查的技术对于目前的编译器来说

    Rust的智能指针
    因为有些内存需要分配在Heap(堆)上,而不是「Stack(堆)」上

  24. 看到【多态和运行时识别】这一节就完全晕了,完全看不下去了,等什么时候对这部分有更深入的理解了会再来拜读

    PS:所有权借用 加上 智能指针引用、生命周期及深浅拷贝,简直有毒。

    另外,还找到了两处勘误:
    1. 【Owner语义带来的复杂度】这一节的最后一句:应该是“可读性下降”而不是“可读性下性”
    2. 【线程与智能指针】一节中应该是“通过智能指针”而不是“智指指针”

  25. 关于文中说的move持怀疑态度:
    通过打印rust对象的裸指针地址. 发现通过函数返回对象move所有权, 并不是zero copy move, move前后的对象地址是不一样的. 应该是不同栈帧上的地址. 也就是说. 实际是做了copy的.

  26. “shared_ptr。以引用计数的方式共享内存。在Rust中是:std::rc::Rc”
    cpp的shared_ptr应该是对应std::rc::Arc吧

  27. 皓哥 这段代码输出应该是 5.

    //———–Move的情况———–
    let mut num = 5;
    {
    // 把 num(5)所有权给 move 到了 add_num 中,
    // 使用其成为闭包中的局部变量。
    let mut add_num = move |x: i32| num += x;
    add_num(5);
    println!(“num(move)={}”, num); //输出10
    }

    1. 亲测这个程序确实依次返回10、5、5;
      move 闭包对于实现了 Copy 的值就是拷贝,仅此而已,不关所有权的事,这一块没什么难理解的。
      这一块只要不讲什么所有权,在最后那个println的位置根本不需要解释为什么“这里还可以访问”,就像介绍一段类似的Java代码不需要解释这一点一样。

      我写了一段Java的:

      public static void main(String[] args) {
              // 对应 Rust 引用的情况
              /<em>final</em>/ AtomicInteger num = new AtomicInteger(5);
              // 要在Java闭包里引用int并修改是不可能的,只能用final的引用类型
      
      <pre><code>{
          IntConsumer add_num = x -&gt; {
              num.addAndGet(x);};
          add_num.accept(5);
      }
      
      System.out.println("num=" + num);
      
      // 对应Rust Move 的情况
      int num1 = 5;
      
      {
          IntConsumer add_num = x -&gt; {
              // 也只能 Copy,对非 final 的 num1 进行修改是不可能的
              int n = num1;
              n += x;
          };
          add_num.accept(5);
          System.out.println("num1=" + num1);
      }
      
      System.out.println("num1=" + num1);
      </code></pre>
      
      }
      

      我觉得要解释Java为什么要写成这样需要的篇幅并不比Rust短。

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注