文章506
标签266
分类65

学习Rust两周后我的一些感想

学习Rust零零碎碎已经有两周的时间了,老实说最开始其实是本着学习好C++的态度打算好好学习C++的;但是个人对自己的代码能力并没有什么自信,因为C++实在太太太太太容易写出Memory-Leak的代码了!

最后,就打算试一试Rust这门语言。用过之后不得不说,Rust应该是神级的Program Language了,编译检查简直严格到变态!

本文主要想谈一谈我在学习了Rust两周后的一些感受;

源代码:


学习Rust两周后我的一些感想

GC掩盖了什么?

关于内存管理的一些常见的问题有:

  • 没有及时释放分配的内存导致内存泄漏;
  • 释放了某一个内存区域两次;
  • 引用了一个被释放的内存空间;

目前大部分现代编程语言,比如:Java、Golang、Python等等,基本上都实现了自己的垃圾回收器GC,这让我们忽略了在写代码时,我们肆意在堆上分配的各种内存;

可能很少会有人考虑,编程语言的GC到底帮我们做了什么?如果没有了GC,我们应该怎么办?

对于C++、Rust这种无GC的编程语言,一个很重要的特点就是要自己控制堆内存,而控制堆内存一个很重要的工具就是:指针

在这里我不打算谈论如何使用智能指针实现内存的开辟和释放,并且避免内存泄漏;

首先我们来看看下面这个Rust中的例子:

fn main() {
    let r;
    {
        let x = 5;
        r = &x;
    }
    println!("r: {}", r);
}

上面是Rust官方提供的一个很经典的例子;

在一个新的作用域中创建的新的变量x的引用被赋值给了引用r,随后退出新的作用域后,使用引用r访问变量x

如果你尝试编译这个文件,将会产生一个编译错误:x在退出作用域后就被自动释放了,因此此时引用r指向的是一个堆中已经不属于x的空间;

类似的这种操作是非常危险的,但是在C++中,这些操作是无法被编译器捕获的;

不妨来看一下在C++中这段相同逻辑代码的表现:

注意:在C++中这是被允许的操作!

#include <iostream>

using namespace std;

int main() {
    int* r;
    {
        int x = 5;
        r = &x;
    }
    cout << *r << endl;
}

代码会正常输出5

你可能会说,这是因为在C++中,并不会自动释放x的内存;

下面我们使用string和智能指针unique_ptr来实现:

#include <iostream>
#include <memory>

using namespace std;

int main() {
    string *r;
    {
        unique_ptr<string[]> x{new string[100]};
        x[0] = "hello";
        x[1] = "world";

        r = &x[0];
        cout << *r << endl;
    }
    cout << *r << endl;
}

上面的代码通过unique_ptr保证了string数组x在退出作用域后内存空间被自动释放,但是我们的引用r仍然指向了这个内存空间的开头位置!

尝试运行这个例子,可以得到下面的结果:

hello
  # 空行

即内存被释放前,引用r的确指向了数组开头,而退出作用域后,由于空间被释放掉了因此此时引用r指向的空间是空的!

但是引用r还是成功的指向了我们的内存,并且可以肆无忌惮的访问!

没什么大惊小怪的,C++甚至都不会检查数组越界!

这些危险的行为按道理应当在编译器就被发现,并且被解决!

Rust正是做到了这一点:

fn main() {
    let r;
    {
        let x= [
            String::from("hello"),
            String::from("world"),
            String::from("something else"),
        ];
        r = &x[0];
        println!("r: {}", r);
    }
    // println!("r: {}", r);
}

运行程序,正常输出:

r: hello

但是如果取消注释最后一行println!("r: {}", r);,将无法编译:

error[E0597]: `x[_]` does not live long enough
  --> src\main.rs:10:13
   |
10 |         r = &x[0];
   |             ^^^^^ borrowed value does not live long enough
11 |         println!("r: {}", r);
12 |     }
   |     - `x[_]` dropped here while still borrowed
13 |     println!("r: {}", r);
   |                       - borrow later used here

因为Rust中的生命期检查将会发现当退出作用域后引用r将会指向一个被释放的内存空间;


关于垂悬引用(Dangling Refer)

除了上述很明显的引用了一个被释放的内存空间之外,还有另外一类也会产生这种错误的例子,就是在函数返回时产生了垂悬引用(Dangling Refer);

下面是一个Rust中的例子:

fn main() {
    println!("get_str: {}", get_str())
}

fn get_str() -> &str {
    return "hello";
}

例子中,get_str()函数创建了一个关于字符串"hello"的引用并返回;

尝试编译会报一个错:

error[E0106]: missing lifetime specifier
 --> src\main.rs:5:17
  |
5 | fn get_str() -> &str {
  |                 ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime
  |
5 | fn get_str() -> &'static str {
  |                 ^^^^^^^^

报错的原因主要是因为:我们是在函数的内部创建的字符串"hello",但是在返回时,我们将这个字符串的引用返回到了函数体外;之后字符串"hello"被回收,因此返回的引用是一个已经被释放的区域;这就是一个垂悬引用(Dangling Refer);

其实我们是可以将这个返回值高性能的返回的,只要声明返回值是一个右值引用即可!

这就相对于函数将这个变量的所有权转移到了函数体的外部;

因此在Rust中,只需要返回含有所有权的String类型即可:

fn main() {
    println!("get_str: {}", get_str())
}

fn get_str() -> String {
    return String::from("hello");
}

可以看到,通过所有权的方式,可以很好的理解垂悬引用这个概念,而Rust也是这么做的!


再谈函数入参和引用

从上面我们可以看到:如果需要返回函数内部在堆上创建的变量,需要将变量的所有权也一并交出;

但是如果返回值是一个引用呢?

那就必须要和入参有关系了!

因此,在Rust中添加了生命期泛型,用于标注入参和出参之间的生命期关系;

下面是一个Rust中的例子:

fn main() {
    let str_list = vec!["hello", "hi", "ok"];
    println!("before: {:?}", str_list);

    let after = to_lowercase(str_list);
    println!("after: {:?}", after);
}

fn to_lowercase(str: Vec<&str>) -> Vec<&str> {
    str.into_iter()
        .map(|x| x.to_uppercase().as_str())
        .collect()
}

例子中,通过遍历vector中的各个str引用,返回一个新的、将字符串大写的数组;

需要注意的是,在调用x.to_uppercase()函数时会创建一个新的String,因此依然犯了上面的Dangling Refer的错误:在函数内部返回了引用;

我们可以通过将返回值修改为String:

fn main() {
    let str_list = vec!["hello", "hi", "ok"];
    println!("before: {:?}", str_list);

    let after = to_lowercase(str_list);
    println!("after: {:?}", after);
}

fn to_lowercase(str: Vec<&str>) -> Vec<String> {
    str.into_iter()
        .map(|x| x.to_uppercase())
        .collect()
}

这是合理的,因为函数执行完成后需要将所有权转接;

如果函数必须要返回引用类型,由上面的分析可知,出参是必须要从入参来的(否则就会引用函数中创建的变量,从而造成Dangling Refer错误!)

如,下面的函数返回了长度大于指定值的引用:

fn main() {
    let str_list = vec!["hello", "hi, there", "ok"];
    println!("before: {:?}", str_list);

    let after = longer_than(str_list, 3);
    println!("after: {:?}", after);
}

fn longer_than(str: Vec<&str>, len: usize) -> Vec<&str> {
    str.into_iter()
        .filter(|x| x.len() > len)
        .collect()
}

上面的代码可以被正确的执行:

before: ["hello", "hi, there", "ok"]
after: ["hello", "hi, there"]

那么为什么我们这里没有声明生命期泛型呢?

这是因为:对于Rust的编译器而言,目前入参和出参的结构已经可以判断出生命期了;

因此Rust会自动加上生命期:

fn longer_than<'a>(str: Vec<&'a str>, len: usize) -> Vec<&'a str> {
    str.into_iter()
        .filter(|x| x.len() > len)
        .collect()
}

那么什么时候需要显式声明生命期呢?

下面是一个经典的例子:

fn main() {
    let longer = longest("hello", "hi");
    println!("longer: {:?}", longer);
}

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

此时,函数无法推断是返回X还是Y,因此需要显式声明!


总结

本文草草总结了关于学习Rust时的一些感想;

最后想说的是:如果是老的项目难以迁移,则可以继续使用C++;否则为什么不试试更加安全的Rust呢?


附录

源代码:



本文作者:Jasonkay
本文链接:https://jasonkayzk.github.io/2021/06/16/学习Rust两周后我的一些感想/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可