严父 Rust

本文来自【2022 开年特辑 | 语言粉·征文活动】
参与活动领取新年福袋:https://www.oschina.net/essay-soliciting

一名后端程序员的自述

我是一名后端程序员,同时也是一名 rust 爱好者。 我有过 Java、Kotlin、Clojure、Go、C、C++、Python、Javascript 的或多或少的编程经历,还稍微看了一点 Haskell 和 Erlang。最近正在做一个 “将 c 编写的 JavaScript 引擎 quickjs 通过 rust 进行拓展,混合编译到 webassembly 上,以试图在 webassembly 上运行 js ” 的项目。 我认为,一门好的编程语言,一定在某种问题上有自己独到的理解,并且作者会将他认为最好的解决方案包含在这门语言种,贯穿始终。例如协程之于 Go、Actor之于Erlang。 我非常喜欢学习各种编程语言,主要就是想要学习它们背后的思想,从不同的角度看待问题。当我们在某种语言的思想下,遇到一些玄学问题的时候,翻翻另一门语言,也许就能找到完美的解法。 算上大学,在编程语言的海洋里漂泊了七年的我,最终投入了 Rust 的怀抱。现在的我觉得,也许 Rust 就是我最终的归宿了。 Rust 真的可以搞定一切!(最多再加点 Javascript。) 那么 Rust 真的有这么好吗?好在哪里呢?

我与 Rust 的相遇

初遇

在之前的工作中,我需要做一个 IOT 设备的网关程序,它主要是负责 IO 数据的读写和编解码,最后上传服务器。由于是在 IOT 设备上,所以是一种中小型的嵌入式场景。领导说 “Java”(我的心情相信大家能懂)。 最后我排除万难,终于说服他们不用Jvm,于是我自然开始了我的技术选型之路。 C 是不可能 C 的,这辈子都不可能 C 的。一不小心就内存泄漏、段错误。那会是 18 年,我只是一个只有四年编程经验的应届毕业生。C 有多凶险,我是亲眼目睹过的。不是我这种级别可以把持得住的。 为了满足 IOT 的场景,我应该选择一个编译型,而且可以方便交叉编译的语言。

最后摆在我面前的是 Go 和 Rust 。在经过仔细的甄选后,我选择了 Go。对!是的!你没看错!我第一次的选择不是 Rust 而是 Go。

主要是什么原因呢?

我点开了菜鸟教程,搜索了 “Go 教程”,准备速成。 “协程?这是什么?哇好厉害,居然还有这么神奇的东西,i了i了。” “自动异步 IO 诶!省时省力!这不是正好适合我 IO 多的场景吗!” “哇!这个交叉编译好方便!一个环境变量就搞定。自动内存管理!perfect!不用和指针做斗争了,简直是为我这个场景量身定做。” 少不经事的我对于 Go 直接一个一见钟情。

然后我如法炮制,搜索了 “Rust” 教程,准备速成。 “所有权模型?可变引用?不可变引用?什么玩意?” “Move?Drop?Copy?Send?Sync?” “智能指针?” 我满脑子都是问号。

结果不言而喻了。就这样,我第一次错过了我的命中注定。

步入正轨

半年之后,我越发的喜欢 Go。我天真的以为,只要我使用了 Go,我就可以摇身一变,成为并发编程的高手。因为,Go “天生适合高并发”。

直到 2019 年的某一天,我发现 Go 原来一直都默认使用单核,那岂不是“一核有难,七核围观”?典中典了属于是。 于是我把我那个聊天室的 demo 用 runtime.GOMAXPROCS 设置成了多核并行。 编译!0 error,0 warn! 运行!Ok 一切正常。 Test!当场 panic!?

错误栈说我保存 socket 的那个 Map 被多线程共享访问了,而我没有加锁。(此处 Go 的用户可能会说 “不要通过共享内存来通信,而应该通过通信来共享内存”。也许我这个例子确实不符合 Go 的编程范式,当时我写 Go 只有半年时间。但是,我想说的是,这个错误确实很容易犯,而且 Go 也不会阻止我犯这种错)

啊这?所以我还是得自己加锁。 还是得研究 Java 里面线程间同步的那一套,没能逃脱。 所以,我还是没有抄到捷径,编程并发高手。。。

于是我发奋图强,买了一本 《Go 高级编程》,准备继续深入学习 Go,争取变成一名 “高级Golang开发工程师”。等我兴冲冲的翻开新书,映入眼帘的是这么几个大字

import "C"

good!

于是在某一天,我重新寻找心仪的语言,Rust 进入了我的视野。当我认认真真一字一句的阅读 Rust 教程。最终领略到 Rust 独特的魅力。而且 Rust 也有 “协程”,可以做到和 Go 一样的事情,于是我就转入了 Rust 门下,当起了一名虔诚的 “锈儿”。

复杂的 Rust

rust 难学吗?

要是有人跟你说 “Rust 很好学的”,我劝你跟这个人绝交。 我第一次入门 Rust 的时候,要实现这样一个逻辑:从一个 map 里获取一个 value,如果对应 key 的 value 不存在,就插入一个 default value,然后返回这个 default value。 结果我硬生生的被 rustc 爸爸打,怎么改都是不过。我当时写的代码类似这样

fn get_with_default<K:Clone,V:Default>(map:&HashMap<K,V>,key:&K)->&T{
	let v = map.get_mut(key);
	if v.is_some(){
		v.unwrap()
	}else{
		let default_value = V:default();
		map.insert(key.clone(),default_value);
		map.get_mut(key).unwrap()
	}
}

因为 rustc 说,我的 v 已经借用了 map 的可变引用了,在后面的 insert 就不能第二次再借出 map 的可变引用了。

那为什么 rustc 会阻止我做这种事情呢?因为照我的写法,在 else 的后面,我还是可以使用 v。v 的 lifetime 一直到了 最外层的大括号。 那我使用 v 和我 map.insert 有什么关系呢?这里有一个很细思极恐的细节,假如我的 map.insert 发生了 resize,底层的数据会被移动,那么我的 v 这个引用就会指向一个已经被 free 的地方!最后想明白的我还是承认了自己的错误。原来它是在阻止我犯这样的错。(昨天有一个人跟我说,“go 就没这么多限制”。首先这个问题是确实存在的,如果 go 没有限制,那么它是解决了吗?怎么解决的?或者是根本没有解决,只是它不在意,到时候突然暴雷)

rust 入门确实很难,但是难就要放弃吗?“啊对对对”? 入门难就多入几次呗。

那为什么 Rust 那么难学?

其实它就像武侠小说里写的那种绝世秘籍,欲练此功,得先自废武功。因为你得接受它的思维方式,抛弃自己以前的编程陋习。然后要戒骄戒躁,一步一步脚踏实地的慢慢学习。要是像我之前妄想的,在《菜鸟教程》上随意学一学,看几个例子,就能直接上手产出,实话实说,rust 里的概念太多,大多数人的理解能力还没达到能 一遍就理解的地步。

为什么 Rust 有那么多概念?

其实并不是 Rust 事多,这些概念的背后,都是一个个血淋淋的问题,是一份份复盘报告。别的语言选择了隐藏它们,但并非帮你解决了他们。只有你自己踩进去才会寒毛倒竖的知道有多么可怕。 而 Rust 则是毫不掩饰,当你写的代码可能会踩进坑的时候,rustc 爸爸就会毫不留情的指出你的错误。

例一:Rust 的可变引用(&mut T)和不可变引用(&T)。 引用就是 C 的指针(各语言自行对号入座),那为什么要分成两种呢?“读者写者问题”大家都知道,读者可以存在多个,并且可以共存。写者只能有一个,并且和读者互斥。这种情况带来的问题,大家在各种博客文 章里面都了解过,有缘人甚至为它写过复盘报告。 这种又读又写的代码,相信大家都不想写出来,但是计算机是一个玄学的世界,指不定各种仙术叠加起来,它就发动了呢。以 JAVA 举个例子(不是针对 JAVA,单纯是比较熟悉)。

void demo(){
        HashTable map = new HashTable();
        // insert some data
        String key = "demo_key";

        Thread thread0 = new Thread(()->{
                Data data1 = map.get(key);
                // do someting 1
        }).start();

        Data data2 = map.get(key);
        // do something 2

}

在这个例子里,我的 map 是线程安全的,但是 data 同时被两个线程访问,也许在写这行代码的人这里,他会明白不可以同时读写。 但是当他传递到各种模块的函数中时,他无法传递这个信息(当然他可以做一些包装,但是各位读者有多少人意识到这个问题并且采取了措施呢?),那些地方可就保证不了会不会同时对这个对象进行读写了。玄学就这样不知不觉的发动了。

这样的代码,在 Rust 中编译就会被 rustc 爸爸无情的打回。由于涉及到 Send Sync 等具体概念,就不深入讲解了。

例二:一个 Rust 所有权的例子。 在之前的 quickjs 这个 C 的项目中,我曾经遇到这样一个 C 的 API,同时它没有注释。

JSValue NewJSString(char* data,int len);

我需要使用的场景

void my_function(){
        char* str = malloc_string("abc");//malloc a string
        JSValue js_str = NewJSString(str,3);
        // p1
        DoSometing(js_str)
        // p2
}

我当时写到这里我就疑惑了,我的str可以在 p1 的地方 free 吗? 还是说需要等到 p2? 还是说根本不需要free?DoSometing 里面已经 free 了? (如果我没有写过这么多 Rust 的话,我也不会产生这么多的疑惑,因为根本想不到)

最后我点到 NewJSString 的实现中看到了一个 memcpy,所以我就放心的在 p1 处 free(str) 了(我并不知道 C 语言的世界有什么约定,我更不知道写这个 API 的人有没有遵守这个约定)。 而在 Rust 的世界里,谁拥有这个内存的所有权,谁负责 free。 什么?你问如果不遵守这个约定会怎么样?会被 rustc 爸爸恶狠狠的打屁股!

这就是 Rust 所有权模型解决的众多问题中的两个。而 Rust 那些难懂的概念和严格的限制,都是为了避免你写出这种毛骨悚然的代码。

自由的 Rust

有人说,rust 限制太多,一点都不自由。 确实。假如你可以接受 例一 中的多线程读写内存带来的玄妙现象。你也可以接受 例二 中的内存泄露的风险,或者是访问野指针的风险。还有类似的数不胜数的玄学。那确实 rust 限制太多。 对我而已,恰恰相反,Rust 是我写过最自由的语言。这个自由并不是像 C 那种,我想“做什么就做什么”的自由,而是“我想不做什么,就可以不做什么” 的自由。我可以随意写 Rust,只要我犯了错,它立刻就会指出,我完全不用担心之后需要加班加点去排查一些莫名其妙的错误,甚至写冗长的复盘报告。 因为能导致这些问题的代码,rustc 爸爸第一个不同意。

Rust 的 “缺点”

Rust 有一个很可怕的 “缺点”,就是学习了 Rust 之后,再写别的语言,编程效率会降低。 因为学习 Rust 能养成你良好的编程习惯,严谨的思维。经过 rustc 的敲打,你变成一个注重细节的人,对数据竞争产生一种本能的敏感性,自动的会在多线程访问的地方做出同步处理。对任何一个没有释放的资源都从头到脚的不舒服。但是这一切没有 Rust 的辅助之后,就会变得非常麻烦,所以你在别的语言的编程效率会降低。

即便会这样,我还是推荐大家学习 Rust,为了能写出高质量的代码,更为了可以形成编写高质量代码的习惯。

{{o.name}}
{{m.name}}

猜你喜欢

转载自my.oschina.net/u/4489239/blog/5417312