Rust科普向:Rust到底难在哪?特色语言特性 20min 速通攻略

13,107 阅读11分钟

本文面向对象:

  • 希望尝试了解Rust相关代码仓库(eg. deno)的Rust零基础同学
  • 希望了解Rust语法特性拓展知识范围的同学
  • 希望对编程语言底层逻辑加深理解的同学

前言

我们为什么要学习Rust?

在这里插入图片描述

上图是知乎上一个关于Rust的问题

不少人抱有疑问: “为什么人们还要卷一个rust新语言出来呢?

这个问题回答的思路可以有多条:

  • Rust如何解决现有编程语言的内存管理问题痛点
  • Rust如何兼顾工程化和性能
  • Rust如何从源头上提升代码质量

你可能还会问: “可是我目前没有Rust落地的场景?”

这个问题就更好解决了,只要你对目前业界最特色的垃圾回收机制感兴趣,那继续读下去肯定会有一定的收获。

你可能的最后一个问题:“听说Rust学习曲线出了名的陡峭,是不是很难入门啊?”

确实,他的学习难度业界闻名(大概比Java还难一个C的程度),但是他的代码review难度却是公认比较简单的,你不好奇为什么会有这样的现象吗?

缺点VS优点

那些舒服的地方

在这里插入图片描述

  • 看看这红线,像不像小学班主任批改作业的注释?保姆级别的编译报错提示,手把手教你改bug
  • 如果你的代码可以编译成功,那你不需要再考虑内存相关的逻辑,Rust已经完全处理完成,你只需要聚焦于业务内容
  • 可以比肩C/C++的强大性能,底层(操作系统,区块链,WebAssembly等)开发者的利器

不那么舒服的地方

  • 新手写一个小时Rust的报错提示可能就长到翻不完,学习难度客观存在
  • 生态不如其他成熟的编程语言那样完善,但依然是富有活力的发展中
  • Rust的地狱笑话:为什么不尝试用Rust写个链表呢? (由于语言特性原因,这会非常困难)
  • 一些和现有其他热门语言设计有出入的细节(天国的return/天国的分号/::满天飞/'a满天飞)

与众不同的设计

基础概念

栈与堆

栈(stack)堆(heap) 都是可以在运行期使用的内存空间。栈是结构规整,每块大小相当,先进先出的数据结构,而堆的结构较为松散,需要使用空间时还需要进行合理的分配。

一些大小固定的数据类型(eg. Int/Char),这些数据往往会存储在栈中,栈大小固定,不需要计算需要分配的空间,时间开销也更小。

而一些大小不固定的数据类型(eg. JavaScript : Object / Rust : String),这些数据的实际内容会存储在堆内,在栈中存储数据在堆中的指针等相关信息。

在这里插入图片描述

JavaScript中的堆和栈

得益于 CPU 高速缓存,使得处理器可以减少对内存的访问,高速缓存和内存的访问速度差异在 10 倍以上,栈数据往往可以直接存储在 CPU 高速缓存中,而堆数据只能存储在内存中。访问堆上的数据比访问栈上的数据慢,因为必须先访问栈再通过栈上的指针来访问内存

试想,如果堆中数据存储得不到释放,一直无限增加下去,我们的程序势必会出现各种异常的现象。

GC(垃圾回收)

如果你是一个一直使用某种高级编程语言(JavaScript/Java/Python等)的开发者,可能会对垃圾回收机制了解的不是很多。

例如JavaScript,由于JavaScript引擎帮你做了一切,所以开发者不需要在代码层面过多关注于内存开销带来的影响。

GC 全称 Garbage Collection ,什么是“垃圾”?我们在程序开发过程中,会使用到一些系统内存,当某块内存使用完毕后,就需要被回收。如果不被回收,这块内存就会一直被占用下去,无法被重复利用,严重的内存泄露会引起程序卡死。

如下图,js堆的大小呈阶梯状上升:

在这里插入图片描述

三种垃圾处理方法

  • 全自动回收:JavaScript/Java/Python【彻底解放双手】
  • 手动回收:C/C++ 【自己动手,丰衣足食】
  • 按特定规则自动回收:Rust 【剑走偏锋】

全自动的垃圾回收能力在大部分业务场景看起来都非常美好,它大大减小了开发者对于内存控制的心智负担,我们可以把目光集中在其他需要持续关注的角度上。

而在偏向系统层级的底层技术领域(eg.音视频领域/游戏客户端),我们对内存占用的开销有更高的要求,这时候手动进行内存管理使我们有多大的优化空间来施展拳脚。

C/C++带来“自由”的内存管理是把无情的双刃剑,“自由”也伴随着无尽的bug和更高昂的代码理解成本。这时候,为什么不试试Rust?Rust也不需要开发者自己进行空间申请/释放等操作,为了兼顾更好的性能,它引入了特别的【所有权】和【生命周期】概念,编译器在编译时会根据一系列规则进行检查,在执行时就能保证安全且不带来性能开销。

所有权

所有权是一组控制 Rust 程序如何管理内存的规则。

基础规则

  • Rust 中的每个值都有一个名为所有者的变量。
  • 一次只能有一个所有者。
  • 当所有者超出范围时,该值将被删除。

简单解释这个规则:我们的每一个量有且只有一个所有者,当所有者失效后,这个值也就不存在了。

让我们来看看Rust的String类型,它比起int/char等类型更为特殊

fn main() {

    let s1 = String::from("hello");

    let s2 = s1;

    println!("{}, world!", s1);

}

String::from:从字符串字面值创建 String

上面的逻辑如果出现在 js / java 中,只是非常简单的赋值过程,顺利执行。而Rust要求每一个量只有一个所有者,在执行到第三行let s2 = s1; 时,"hello"的所有者变成了s2,这个时候再次打印s1,我们只会收获一个error。

在这里插入图片描述

用图形描述为下图:栈中的s1转移对"hello"的所有权到s2身上,且s1失效。

假设,如果s1不失效,在编译阶段回收数据时,Rust会发现有两个指针指向同一片内存,"index"这片内存会被错误的释放两次!两次释放相同的内存会导致内存污染,它可能会导致潜在的安全漏洞。

在这里插入图片描述

如果我们想要让s1也正常打印,就必须强拷贝一份"hello",如下图:

在这里插入图片描述

Rust 永远也不会自动创建复杂数据类型的 “深拷贝” ,因为这对内存开销实在太大,我们在需要时要手动调用。


如果我们想使用一个Int类型的数据:

fn main() {

    let x = 5;

    let y = x;

    println!("x = {}, y = {}", x, y);

}

以上代码是可以正常执行的,因为 x 是简单类型(Int),是固定大小可以存储在栈中的数据类型。这时Rust会帮助我们拷贝一份放在栈中,因为在栈中拷贝是非常快速的。

如果我们的所有权只能简单的唯一归属,那势必会加大我们的编码难度,下面我们来看看引用

引用

引用又被分为可变引用和不可变引用:

let x = 10;

let r = &x;

r 就是 x 的一个不可变引用(引用是对内存中的另一个值的非拥有(nonowning)指针类型

当我们想希望用指针指向一个变量且希望改变这个变量时,我们也会用到可变引用( &mut ):

fn main() {

    let mut s = String::from("hello");

    change(&mut s);

}

fn change(some_string: &mut String) {

    some_string.push_str(", world");

}
  • 同一时刻,你只能拥有要么一个可变引用, 要么任意多个不可变引用 (保证绝对安全)
  • 引用必须总是有效的 (无效就是空指针了)

由于Rust新老编译器的区别,编译是否成功也不同:

fn main() {

   let mut s = String::from("hello");

    let r1 = &s; 

    let r2 = &s; 

    println!("{} and {}", r1, r2);

    // 新编译器中,r1,r2作用域在这里结束

    let r3 = &mut s; 

    println!("{}", r3);

} // 老编译器中,r1、r2、r3作用域在这里结束

  // 新编译器中,r3作用域在这里结束

let mut s 定义变量s

let r1 = &s; r1为s的不可变引用,即指向对应内存的指针

let r3 = &mut s; r3为s的可变引用,这时s可以更改

因为篇幅原因,这里只简单介绍一下引用的相关知识,希望有更多了解的同学可以阅读后面的深入学习资料。

生命周期

基础规则

生命周期,简而言之就是有效作用域。部分情况时,我们无需手动的声明生命周期,因为编译器可以自动进行推导。

fn main() {

{

    let r;                // ---------+-- 'a

    {                     //          |

        let x = 5;        // -+-- 'b  |

        r = &x;           //  |       /

    }                     // -+       |

    println!("r: {}", r); //          |

}                         // ---------+

}

以上代码会在编译时报错:因为 'x' 不能存活那么长。 在这里插入图片描述

在 Rust 中,从数据定义到一对大括号的结束,就是一个生命周期范围( 见上图的 'a 和 'b ),'a 是 r 的生命周期,'b 是 x 的生命周期,而 x 的生命周期小于 r 的生命周期,所以在执行r = &x; 后,x 的生命周期就结束了,这时 r 指向了一个被回收的数据的地址,变成了一个悬垂指针,所以就出错了


当的函数入参出现引用类型时,稍有不慎就可能出现悬垂指针,所以Rust编译器比我们更加紧张:

fn main() {
    let string1 = String::from("long string is long");
    let string2 = String::from("xyz");
    let result = longest(string1.as_str(), string2.as_str());
    println! ("The longest string is {}", result);
}
    fn longest(x: &str, y: &str) -> &str {
        if x.len() > y.len() {
            x
        } else {
            y
        }
    }

上面是一个判断字符串长度的函数,看起来完全ok,但其实也会报错:

在这里插入图片描述

这个报错的原因是,Rust无法推断 x 和 y 的生命周期谁更长!因为编译器无法分析出要return x 还是 y,所以我们要显式声明入参的生命周期。

&i32        // 一个引用

&'a i32     // 具有显式生命周期的引用

&'a mut i32 // 具有显式生命周期的可变引用

生命周期的格式如上面所示,我们来修改一下刚才错误的的代码:

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";
    let result = longest(string1.as_str(), string2);
    println! ("The longest string is {}", result);
}
    fn longest<'a> (x: &'a str , y: &'a str) -> &'a str{
        if x.len() > y.len() {
            x
        } else {
            y
        }
    }

现在就可以正常运行了!

首先请牢记:生命周期标注并不会改变任何引用的实际作用域

生命周期标注简单来说就是你在教编译器做事,且它只是起一个指导作用!

因为我们的编译器有时候还是很智慧的,比如它在一些简单的场景面前可以自己推导出生命周期(当只有一个入参是引用类型的时候,如果能正常编译,返回值的生命周期只可能与这一个入参相关),但是复杂场景它往往无法推测出来!

回到之前的例子,我们的标注可以说明:

  • 和泛型一样,使用生命周期参数,需要先声明 <'a>
  • xy 和返回值至少活得和 'a 一样久(因为返回值要么是x,要么是yx、y的生命周期相同)

如果我们不添加标注,对Rust编译器来说,其实相当于:

    fn longest<'a,'b> (x: &'a str , y: &'b str) -> &str{//

Rust无法自动推断 x 返回值的生命周期是 'a 还是 'b,所以我们需要手动“告知”Rust。

欺骗编译器可行吗?

我们简单修改一下刚才例子中的代码,让 string1 和 string2 拥有不同的生命周期,但是我们在 longest函数中还是把两个入参标注为同样的生命周期:

fn main() {
    let string1 = String::from("long string is long");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(string1.as_str(), string2.as_str());
    }
    println! ("The longest string is {}", result);
}
fn longest<'a> (x: &'a str , y: &'a str) -> &'a str{
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

string1 的生命周期明显大于 string2,结果:

在这里插入图片描述

哈哈,不出意料的报错了,再次验证开头说的“生命周期标注并不会改变任何引用的实际作用域

你永远无法欺骗Rust编译器~

如果你想深入学习

文档资料

本文只是抛砖引玉!Rust还有很多很多内容值得研究!

以下两篇基本是必读了,本文的部分内容也参考了下面的教程:

Rust 程序设计语言 - Rust 程序设计语言 简体中文版

进入Rust编程世界 - Rust语言圣经(Rust教程 Rust Course)

视频资料

在b站发现的宝藏视频,讲解的上面的第一篇文档。

老师讲的的时候会带代码演示,比纯啃文章好理解多了(老师的东北口音也很带劲,越听越精神)

www.bilibili.com/video/BV1hp…

参考

juejin.cn/post/698158…

栈、堆、队列深入理解,面试无忧 - 掘金

juejin.cn/post/684490…