一年前,我提交了我的第一行 Rust 代码,截至 2023/5/20,Rust Code 占了我自己写的程序的 73.28 %(现在应该应该更高)

图片

现在我已经可以很自豪地说我可以用 Rust 写几乎所有的程序。

这篇程序文章我会分享自己写的经验,以及为什么我选择了 Rust,可以给别人在选择该不该学习某种语言时,一点不同于别人的观点。

声明:这篇文章只是「分享」而不是「推坑文」

偏好这种东西因人而异,像DHH就喜欢没类型的语言,ThePrimeagen觉得没类型很糟糕(我也是这派的)
我尊重所有人的选择,各位也不要强迫别人去学习某种语言或某种开发喔~

我曾接触过的语言

除了Python之外,我并没有受过任何正式的程序语言教育,我现在常写的语言都是我自学的,其中有真正在工作上使用的语言有Java、JS/TS和Rust

我要稍微说下,我现在的台湾教育没有任何改变,但在我从小开始上资讯课到高中毕业的这几年里,我 从来没有在学校学习过课程语言!
总算有一点点……App发明者
我绝对不是说每个人都要学习计划语言,但至少在我的成长过程中,文组生(对我来说是文组仔)如果想要学习计划语言,是非常困难的
今年要不是我和我爸妈主动要求,要不然连Python都碰不到,希望现在已经有所改变

先说 Java 吧

Java(爪哇)

它是我第一个正式用于养活自己的语言,在我写 Java 的两年间,我成功地将一批比我还老、没人维护的依赖更换成有人维护的依赖、将 Java 8 换成Java 11重新换成Java 17、将Spring换成Spring Boot,这一切都是我利用我个人时间看技术文章、看书、看演讲和自己测试出来的。学习使用Java的同时,我普及了这门语言的特性:Java对于「对象」与「其他类型」的执着达到了我从未接触过的强度,带来的是我甚至不开IDE,纯看程序也能了解我正在处理什么类型的其他对象,就业务逻辑本身的解释力而言,我个人觉得非常优秀。

看文件的习惯也是从Java学到的,一大堆的框架与依赖都会提供JavaDoc,我仔细阅读文件学习到别人是如何设计库的,也期许自己在程序写的时候也要留下足够的文件,让其他人可以更容易地了解程序的逻辑与实现。

但Java(爪哇)也有令人讨厌的地方:

一、恶名昭彰的 NullPointerException

Java的null可以出现在任何非原始值上,包含为了解决这个问题的Optional<T>! 这绝对是所有Java工程师的噩梦,我曾为了去掉API疯狂NullPointerException 熬夜看了一整晚的程序代码,最后索性在每个都有可能null的对象都换上了Optional<T>,但是问题还是没有解决,因为其中Optional<T> 隔天就NPE了……

二、程序语言的「赘字」其实太多了

嘿Java工程师们,你们的 main()怎么写啊?

蛤什么?你说你都直接用Code Snippet?那突然要你在只有JDK的环境下用记事本写个小程序呢?
不要觉得这事不会出现因为,我就遇过,体验十分糟糕!

三、用户年龄老化严重,语言更新无法带动用户更新

其实我觉得这一点在 Java 身上不能完全怪异,毕竟是老语言了。但问题是很多前辈很固执啊!

Java 8 推出已经是 2014 年的事情了,当时我还在读国中,猜猜看 2022 年,我前雇主跟当时大三的我说新专案使用了哪个版本的 JDK 开?

「Java 8!」

并不是说旧版本的 JDK 不能用,但一些新的语言特性没办法用,到了升级我什至得专门致力于 JDK 8 的依赖才能实现某些功能,反而无意义的增加了开发的困扰。

JavaScript / TypeScript

我的第一份工作和现在的工作都用到了 JavaScript 跟 TypeScript ,这两种语言(或者说一种语言跟一种语言补强工具?Linter ?)在前面几乎就是独占市场,不用也不行

我知道啦,现在可以写 WebAssembly ,你现在看到的博客就是我用 WebAssembly 写出来的(更新:现在不是了,HTMX超香)。但是现在WebAssembly还是会需要JS Glue Code啊,没办法100%平整JS的魔掌

弱型别+动态型其他语言在需要快速迭代的场景非常适合,不需要花时间去处理型别问题(哈哈这是个谎言)可以把更多的时间用于思考实际的程序逻辑,但我坦白话说,我对这两种语言的好感甚至不如Java

主要有以下几点

一、语法与行为繁杂混乱
  • 方法不用的变数会被深拷贝还是浅拷贝行为不明显,每天都在猫抓老鼠跟试错:
    写这篇文章前我花了四天修改了一个JS响应,只因为32行的某一个变数突然失去了它的领域,最后用不同的方法发现了一行代码浅拷贝了具体的物品,原来知道了具体的值。好笑,错误的地方还不是我自己查到的,我拿去跟我部门三个JS工程师一起查才查出来。

  • const/ let/var 跟即将到来的using:真的是太多了,增加学习难度之外,这边我还要特别点名 const,它并不等价于其他语言的 const 跟Rust 不加 mut的 let,它只确保变数不会被重新宣告或重新指定,并不确保里面的field 也不会被重新宣告或重新指定,遇到这坑的次数已经多到数不清了

  • 同步操作有 Callback 、Promise 跟 async await一个解决办法:
    这个在使用 Library 的时候首先要吃饭,为了在使用 sqlite3 时也能统一使用Promise,整个套件被我重新包装过,最后更直接用 Rust 写了个 rusty-sqlite3,从 Library 层面就改使用 Promise

二、错误处理困难

写这篇文章的前几天,知名 TypeScript Wizard (虽然我不怎么喜欢 TS,但还是会去学习下最好的)分享了TypeScript 拒绝 Typed Error GitHub Issue 的 TR

图片

 

其中有一点我觉得很有趣:“在 JS 中,没有库声明它们抛出什么错误的文化”

在其他强类型的其他语言中,Error 通常都会继承自代表错误或异常的 Interface / Trait ,并在文件中或类型别上提供足够的信息供二进制用户或库用户进行错误处理。JavaScript 系列没有这回事情了,虽然也有Error对象,但是一堆库根本就没有规定他到底会扔啥东西出来,节日情况的人就只能继续乱抓一通,想要好好做处理的人也只能试错或者直接去翻Library的原始码,这导致要在JS下写稳定程序的引用程序其他语言更难。

三、undefined

还有需要解释吗?所有的错都是 Java 害的,真就万恶之源啊

图片

 

Rust

我大约一年前的时候就开始写 Rust ,对它很感兴趣,主要是因为我实在受够了这些废话了,做了两年多的维护,只觉得天天都在吃屎、天天都在重复之前的错误。Rust 的官网清楚地写着“性能、可靠性、生产力”,这深深地打动了我。

后来发现我日常使用的 M1 MacBook Air 所装载的Apple Silicon Linux GPU 驱动程序也是用 Rust 写的,想着 GPU 驱动程序全部用这语言写的,想必是真的有效?充满怀疑的态度,我开始了我的第一个 Rust 专案。

当时我在开发时遇到了一些问题:

一、开发体验仍有待加强

由于我是从 Java转向 Rust,当时我用的 IDE 是 IntelliJ IDEA,Rust Plugin很大程度上帮助了我程序进行的开发,但是……实在是太慢了。而且当时的IDEA默认不会进行宏的分析,导致一批宏使用起来体验非常差

后来被 ThePrimeagen 的影片推坑 Neovim,改用原生的 rust-analyzer 体验已经很明显了,不过有时开启大型专案时依然会出现卡顿的问题,只能等待 Rust Team 持续优化了

二.语法需要重新熟悉

虽然 Rust 语法很多,我个人认为跟 TypeScript 很像,但许多概念依然跟其他语言有所不同,甚至因为设计理念的关系,会需要重新学习不一样的做法

Java 常用的万恶之源 null,到了Rust是不安全的存在。这个观念让我痛苦了很久,因为写了两年Java的我已经变成了 null 形状啊(大雾),当然在接触到的Option<T> 好之后,相反让我原来在Java也改用 Optional<T>了

至于 try catch 转向 Result<T, E>,对于我来说反而好改,因为我在以前公司根本没有养成错误处理的习惯……,用了以后 Result<T, E>,才知道以前的做法有可能会出错

学习圆形这件事,个人觉得不必担心,而且学了绝对是利大于弊,因为然而……

Rust 的保姆级编程体验

我们做个小实验,先在 JavaScript 写个函数做 Hello world ,然后贴到Rust Playground,直接点击 Build!

function main() {
    console.log("Hello world!");
}
   Compiling playground v0.0.1 (/playground)
error: expected one of `!` or `::`, found `main`
 --> src/lib.rs:1:10
  |
1 | function main() {
  | -------- ^^^^ expected one of `!` or `::`
  | |
  | help: write `fn` instead of `function` to declare a function

error: could not compile `playground` (lib) due to previous error

我们可以看到,Rust 编译器提供了非常有用的信息!我们应该使用 fn 而不是 function 去声明一个函数。

类似于这种有效的错误消息,在其他语言中非常少见,比如我们在 JS 中写 Rust 的 Hello world:

fn main() {
    println!("Hello world!");
}

main() // JS 沒有 main function ,需要主動呼叫
fn  main() {
   ^^^^

SyntaxError: Unexpected identifier 'main'
    at internalCompileFunction (node:internal/vm:73:18)
    at wrapSafe (node:internal/modules/cjs/loader:1177:20)
    at Module._compile (node:internal/modules/cjs/loader:1221:27)
    at Module._extensions..js (node:internal/modules/cjs/loader:1311:10)
    at Module.load (node:internal/modules/cjs/loader:1115:32)
    at Module._load (node:internal/modules/cjs/loader:962:12)
    at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:83:12)
    at node:internal/main/run_main_module:23:47

    Node.js v20.3.1

看了跟没看一样,我还是不知道要改什么

Rust 的编译器跟 Clippy 提供了非常多有效的建议跟帮助,只需习惯看错误提示,甚至可以单靠错误提示进行语言的学习,这是跟其他不同的,比如第一次写 Rust 一定会碰到的借阅检查器:

fn main() {
    let val1 = vec![1, 2];
    let _val2 = val1; // 此處加底線,是為了避免 rustc 噴出未使用變數的警告
    println!("{val1:?}");
}

  Compiling playground v0.0.1 (/playground)
error[E0382]: borrow of moved value: `val1`
 --> src/main.rs:4:15
  |
2 |     let val1 = vec![1, 2];
  |         ---- move occurs because `val1` has type `Vec<i32>`, which does not implement the `Copy` trait
3 |     let _val2 = val1;
  |                 ---- value moved here
4 |     println!("{val1:?}");
  |               ^^^^^^^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let _val2 = val1.clone();
  |                     ++++++++

For more information about this error, try `rustc --explain E0382`.due to previous error

喔~原来物品已经被移动到了 val2,而且编译器推荐我如果性能影响的话,可以考虑使用 clone()

但这又是怎么兼容的呢,在 JavaScript 中显然是可以用的啊:​​​​​​​

const val1 = [1, 2];
const val2 = val1;

console.log(val1);
$ node index.js
[ 1, 2 ]

让我们执行看看编译器提示的 rustc --explain E0382

由于 MyStruct 是一种未标记为 Copy 的类型,因此当我们设置 y 时,数据就会从 x 中移出。这是 Rust 所有权系统的基础:除了 Rc 等变通办法之外,一个值不能由多个变量拥有。

有时我们不需要移动该值。使用引用,我们可以让另一个函数借用该值而不改变其所有权。在下面的示例中,我们实际上不必将字符串移动到calculate_length,我们可以用 & 给它一个引用。

雷达附上网页版链接,其实在Playground里也可以直接点错误代码看到同一个喔页面!

原来在 Rust 的字节中,除了被标记为 Copy 的别跟 Rc 等例外,物品的数据机制会被移动到另一个物品啊!诸如此类清晰明了的说明在我学习 Rust 的时候,给了我很大的帮助的帮助。

另外,这个编译器还能避免开发者错误,比如上面的 JS 例子,稍微修改一下就会开始崩溃了:​​​​​​​

const val1 = [1, 2];
const val2 = val1;

/* 假設這邊有 50 行塞在中間 */

val2.pop();

/* 再假設這邊有 70 行塞在中間 */

console.log(val1);
$ node index.js
[ 1 ]

「奇怪了,我的值到底为什么被改了??」为了让程序正常工作,身为工程师的我就只能开始逐行检查。流程可能需要耗费的时间与难度,会根据逻辑的复杂度性提高,与程序原始码的熟悉程度随着时间下降,而逐渐增加。

如果换成Rust呢?

fn main() {
    let val1 = vec![1, 2];
    let val2 = val1;

    /* 假設這邊有 50 行塞在中間 */

    val2.pop();

    /* 再假設這邊有 70 行塞在中間 */

    println!("{val1:?}");
}

​​​​​​​除了上面提到的借阅检查器错误提示外,你还会收到这个东西:​​​​​​​

 Compiling playground v0.0.1 (/playground)error[E0596]: cannot borrow `val2` as mutable, as it is not declared as mutable --> src/main.rs:7:5  |7 |     val2.pop();  |     ^^^^^^^^^^ cannot borrow as mutable  |help: consider changing this to be mutable  |3 |     let mut val2 = val1;  |         +++

编译器提示,需要我们将可变数定义为可变的可变数,才能使用 pop(),可变不可变,说清楚了,也不会突然就被改了导致需要重新审查自己的程序码,因为编译器提供的保证,除非你主动退出(不安全),否则一定有效,只要遵守编译器的规则,就可以在编译前避免很多问题

如果想要实现和 JavaScript 相同的行为,我们需要这样写才行:​​​​​​​

fn main() {
    let mut val1 = vec![1, 2];
    let val2 = &mut val1;

    /* 假設這邊有 50 行塞在中間 */

    val2.pop();

    /* 再假設這邊有 70 行塞在中間 */

    println!("{val1:?}");
}

 Compiling playground v0.0.1 (/playground)
    Finished dev [unoptimized + debuginfo] target(s) in 0.60s
     Running `target/debug/playground`

[1]

val2 需要被声明为 val1 可变引用(可变引用),并且 val1 也必须利用 mut 声明为可变才能起作用。开发者会理解自己所操作的即将到来的物件,而维护者维护时需要耗费的脑力与时间,也可以随着这些就在码上的关键词而大幅降低

学习 Rust 甚至能学到一些新知识,比如由于 Rust 不像 JavaScript 几乎所有使用场景都是单线程(没人爱用 Workers 吧???),所以还会有很多特殊的规则是使用 JS 这样主要为单线程的语言所学不到的,比如下面这个例子:​​​​​​​

const globalVal = [];

async function receiveFromExternalSource() {
    const dataList = await externalSource();
    dataList.forEach((data) => globalVal.push(data));
}

receiveFromExternalSource().then(() => console.log(globalVal));

这在 JavaScript 中是完全合法的,你可能也不会发现任何问题。

但如果我们将程序换成 Rust 呢?​​​​​​​

use tokio;

static mut GLOBAL_VAL: Vec<String> = vec![];

#[tokio::main]
async fn main() {
    receive_data_from_external_source().await;
}

async fn receive_data_from_external_source() {
    let data_list = external_source().await;
    data_list.into_iter().for_each(|data| GLOBAL_VAL.push(data));
}
   Compiling playground v0.0.1 (/playground)
error[E0133]: use of mutable static is unsafe and requires unsafe function or block
  --> src/main.rs:12:43
   |
12 |     data_list.into_iter().for_each(|data| GLOBAL_VAL.push(data));
   |                                           ^^^^^^^^^^^^^^^^^^^^^ use of mutable static
   |
   = note: mutable statics can be mutated by multiple threads: aliasing violations or data races will cause undefined behavior

For more information about this error, try `rustc --explain E0133`.
error: could not compile `playground` (bin "playground") due to previous error

当看到这个错误后,我才恍然大悟,原来做可变的全局变量竟有可能造成预期的外部行为(未定义的行为)!

题外话,遇到这种问题,有多种解法:
第一种:利用「内部可变」对象将值打包起来,就可以正常使用了,比如 Mutex互斥锁、RwLock 读写锁跟 Atomic​​​​​​​

use tokio;
use std::sync::Mutex;

static GLOBAL_VAL: Mutex<Vec<String>> = Mutex::new(vec![]);

#[tokio::main]
async fn main() {
    receive_data_from_external_source().await;
}

async fn receive_data_from_external_source() {
    let data_list = external_source().await;
    let mut locked_global_val = GLOBAL_VAL.lock().unwrap();
    data_list.into_iter().for_each(|data| locked_global_val.push(data));
}

其次:使用不安全,这种做法实际上跟编译器说的“放心吧我知道比特币可能会有问题,我自己处理”​​​​​​​

use tokio;

static mut GLOBAL_VAL: Vec<String> = vec![];

#[tokio::main]
async fn main() {
    unsafe {
       receive_data_from_external_source().await;
    }
}

async unsafe fn receive_data_from_external_source() {
    let data_list = external_source().await;
    data_list.into_iter().for_each(|data| GLOBAL_VAL.push(data));
}

以上这些只是 Rust 编译器功能的一部分,干扰于篇幅,仲裁基本不可能完整展示全部的功能。所以雷达提供Rust 错误代码索引和Clippy Lints的链接,感兴趣的话点进去查看上千个 rustc 和 Cargo Clippy 规则

我将 Rust 编译器视为我的好友,在我练习时提供提示帮助我学习,在我开发 Rust 程序时监督我写出安全的程序,并在我犯错误时提醒我处理,使我受益良多,这是我在其他语言中都未曾见过的。

就这一点,我什至认为 Rust 的学习曲线并没有太多其他语言。

接受自己并不如想像的那么聪明,至少,没有电脑那么聪明

我经常看到或听到以下攻击:

  1. 如果程序写的好,用什么语言写得很快很安全

  2. 只要我自己记得,草莓的逻辑可以用某种解决方法写,然后之后再做检查就好了

我曾经一度接受这种说法,甚至自己发表过这种言论,但现在我却认为这些说法都是致命的不明智的。因为人类会犯错,人无法确保自己永远都是正确的

程序则不一样,只要在正常的硬体中执行正确的逻辑,它们运行的效率与精准度绝对比人类还高。我认识到了这一点,也最终接受了这个事实。

因为在写程序的这几年里,我一直都在不断地名为“除错”的代价,草莓“除错”的对象往往不是需要人工处理的业务逻辑错误,而是“空指针引用”、 「使用未初始化的对象」与「改变不了改变的值的错误」等低级错误,这些错误完全都可以突破良好的语言设计,配合智能的编译器,在编译时就抓出问题!

从 rustc 、 Cargo Clippy 到 Miri ,Rust 生态的各种辅助工具只是将程序编译出来执行,它们帮助我不犯下傻瓜的错误,帮助我楼梯更优秀稳定的程序,并可以把宝贵的时间,完全使用业务逻辑的除错与优化。

在本文第一章提到的各种其他语言的问题,在 Rust 的实现中可以发现许多改进的尝试:

  1. 所有默认变数都必须进行初始化,避免使用到未初始化的对象

    std::mem::MaybeUninit 是例外,通常只有在要使用 FFI 的时候才会使用,且取用它是不安全的
  2. 利用 Option<T>enum 作为可能为空的物体表示,且开发者必须进行模式匹配才能取用里面的值,有效避免 NullPointerException

    std::ptr::null 可以创建空原始指针,但是在 Rust中空指针永远无效,即使对于大小为零的访问也是如此。
  3. 利用 Result<T, E> 进行错误处理,且由于 Rust 的强型其他特性,使所有错误都会被提示进行处理,再也不需要灵通或试错了

  4. 语法减少许多冗文赘字

这就是我选择使用 Rust ,而我也继续使用它:简单的格式比其他程序语言稍长的开发时间,便能扬长避短,何乐而不为呢?

Logo

为开发者提供按需使用的算力基础设施。

更多推荐