前言

虽然我主要使用C++,但是最近也想学点现代化的新语言。初步想的是从golangRust里先选一个。

这两年golang在国内很火,最大的特点莫过于语言层面提供了协程支持,能够极大地简化异步逻辑地理解。我之前也接触过一点,还写了个goroutine压力测试对比我的libcopp的性能。但是golang的语法我实在不喜欢,特别是那个不管啥类型声明都是反着来,感觉在复杂的类型下会非常反人类。而且听用过的人说golang的GC还很不稳定。另外之前有新闻说golang正在准备2.0,2.0版本即将加入泛型支持,然后导致很多语法不兼容和语法分析得重写。所以我还是懒得踩这个坑了,至少等2.0出来再说。

Rust是Mozilla搞出来想拿来重写Firefox的。说实话Mozilla和Google还有点差距,导致Rust的发展还比较慢。对比起来就是感觉golang很快就提供了一些快速可用的原型给大型项目使用,标准库也足够丰富。而Rust还纠结在底层、语言层面的优化和最求极致。很多组件都还不成熟,编程设计模型也还没完全统一。

但是接触了一点Rust以后,我发现Rust真的是挠到了C++程序员的痒点,语言层面解决了用C++得费很多脑力和用各种奇技淫巧实现并且还不能完全阻止被绕过的质量控制问题,而且保留了C++很多编译期推断得高级特性。并且和C++一样,提供给你能力,但不限定你方法提供 零成本抽象(zero-cost abstractions) 或者说叫 零开销(zero-overhead)

In general, C++ implementations obey the zero-overhead principle: What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better.

Bjarne Stroustrup "Foundations of C++"

从整体来说,C++ 的实现遵循了零开销原则:你不需要的,无需为他们买单。更有甚者的是:你需要的时候,也不可能找到其他更好的代码了。

本贾尼·斯特劳斯特卢普 "Foundations of C++"

这是和很多其他语言不同的。比如你可以实现操作符重载,并且不会有可见性问题和冲突问题;今年的版本对宏的增强可以让你很容易实现很多语言支持的await功能,现在已经有不少Rust协程库了。感觉Rust就是搞出来挖C++墙角的。

诡异的命名和符号

Rust也有一些让初学者不太爽的地方。就是它为了解决一些工程上的问题提出了一系列概念,然后里面用的符号特别的奇葩。就算是已有的概念,它也非得搞个特立独行的命名。感觉完全就是增加学习成本。

比如后面会提到的生命周期,它非得用 '标识符 这种奇怪符号。别人都是用在字符(串)里的。还有宏,非得用感叹号结尾。

还有lambda表达式,我见过 (参数列表) => {代码块} 的,甚至不带括号的 参数列表 => 代码块 或者不带箭头的 (参数列表) {代码块} 的。Rust偏偏使用竖线来声明参数 |参数列表| 代码块

还有其他语言的多分支处理几乎都是 switchRust的叫 match 。人家语言一般成功都叫success,Rust的叫Ok。其他语言的interface,Rust的叫trait。不过这个C++也叫trait而且它和C++的类似都有编译期推断所以还勉强说的过去。但是别人叫package Rust叫crate是什么鬼?

本来我觉得golang的函数声明的关键字是 func 已经很偷懒了。结果Rust这货更短,是 fn 。之前看到一个文章是说啥来着现在完全没必要使用缩写的。因为现代的编辑器/IDE已经足够“聪明”了,并且也不缺那点磁盘空间。然后别的语言里的 abortassert 之类, [Rust][1 用的 panic! 这种大量的缩写和奇怪的命名我感觉的增大了非常多学习成本。也不知道设计者怎么想的。

设计核心

Rust的设计核心感觉上就是在不牺牲效率的前提下,最大化地利用编译期分析,来帮助我们杜绝可能地错误。这点我是觉得用起来非常爽地地方。

默认不可变和可变借用唯一

比如说Rust地数据类型默认是不可变的(当然很多函数式语言也这样)。像C/C++默认是可变的导致工程设计上很容易就忽略了提供一个const的函数。然后后来很难对一些场景做优化和假设。而默认不可变,可以使得编译期对很多变量、存储做优化。另外还有一个重要的设计是可变借用(mut)只能有一个。这样就可以准确地分析对象地生命周期然后控制对象什么时候析构。并且在多线程编程地时候,可以放心地认为这个对象不会在奇怪地地方被修改,也就不容易出现线程安全问题。

神一样的枚举类型和模式匹配机制

Rust的枚举类型也很有意思。它允许你给枚举类型的每一项绑定一个不同的值。这就延伸出了Rust对那种可成功可失败的API的推荐返回值是 Result<T, E> 。这是一个枚举值,其中有Ok(T)表示成功和成功的返回值,还有Err(E)表示失败和失败的返回值。当然如果有需要返回多种失败则可以换个有更多条目的枚举值。不同枚举值可以带出不同类型的信息。这也导致了我第一眼看这段代码的时候一直没理解这是什么奇怪的流程。

// Send the message
match sender.send_message(&message) {
    Ok(()) => (),
    Err(e) => {
        println!("Send failed: {:?}", e);
        break;
    }
}

这里面其实是对sender.send_message(&message)的返回值做枚举判定,然后Ok的case绑定了一个空值,Err的case绑定了一个e,包含了错误信息。我认为这种方式比Exception机制要好得多得多得多。像C/C++之类得语言,会推荐返回错误码。但是这种错误码一般只有一个整数,能够提供得信息很少,而且如果被漏判了很难发现。而另一部分语言多使用异常机制,那么有个问题是你在使用的时候很容易忽略了他可能会抛个异常然后忘记了fallback。而这种机制兼顾解决了这两个问题。特别是不同的case绑定的数据类型可以不一样,这样为以后扩展和防止遗漏提供了最大的便利。比如我之前写的websoccket的小工具。

match message {
    OwnedMessage::Close(_) => {
        // Got a close message, so send a close message and return
        let _ = tx_1.send(OwnedMessage::Close(None));
        return;
    }
    OwnedMessage::Ping(data) => {
        match tx_1.send(OwnedMessage::Pong(data)) {
            // Send a pong in response
            Ok(()) => (),
            Err(e) => {
                println!("Send Pong failed: {:?}", e);
                return;
            }
        }
    }
    // print what we received
    OwnedMessage::Text(data) => {
        println!("Response: {:?}", json::stringify(data));
        let _ = tx_1.send(OwnedMessage::Close(None));
    }
    // print what we received
    _ => {
        let _ = tx_1.send(OwnedMessage::Close(None));
    }
}

上面就是根据不同类型的消息执行不同的流程。我们看到有很多的 _ 是因为Rust要求你处理所有的返回值。如果你要忽略,可以,显式告诉编译器。而 _ 就是告诉编译器我要忽略返回值或者case类型。这个要求对稳定性有很大的帮助,而我们以前的C++项目都是人工用编码规范的形式执行的。但是万一漏了或者有人偷懒,你完全发现不了,等爆发的时候已经GG了。

还有一个语法,是Rust用来简化 match 的,但是我第一眼也没看懂。它主要是只处理成功或者只处理失败,忽略其他case的简写。拿上面发送消息举例就是:

if let Err(e) = sender.send_message(&message) {
    println!("Send failed: {:?}", e);
}

然后匹配还支持一些其他语言的语法糖,像这种:

let num = Some(4);

match num {
    Some(x) if x < 5 => println!("less than five: {}", x),
    Some(x) => println!("{}", x),
    None => (),
}

还有范围匹配和忽略范围匹配:

// 按范围匹配
let x = 'c';

match x {
    'a' ... 'j' => println!("early ASCII letter"),
    'k' ... 'z' => println!("late ASCII letter"),
    _ => println!("something else"),
}


// 忽略部分匹配项
let numbers = (2, 4, 8, 16, 32);

match numbers {
    (first, .., last) => {
        println!("Some numbers: {}, {}", first, last);
    },
}

还有不是很好懂的解引用语法糖:

struct Point {
    x: i32,
    y: i32,
}

fn main_1() {
    let p = Point { x: 0, y: 7 };

    let Point { x: a, y: b } = p;
    assert_eq!(0, a);
    assert_eq!(7, b);
}


fn main_2() {
    let p = Point { x: 0, y: 7 };

    match p {
        Point { x, y: 0 } => println!("On the x axis at {}", x),
        Point { x: 0, y } => println!("On the y axis at {}", y),
        Point { x, y } => println!("On neither axis: ({}, {})", x, y),
    }
}

这里面的解出的变量是放在Object的值的部分的,看起来十分诡异。然后这个解引用语法糖和上面的范围限制的语法糖结合,还有个新的语法糖:(用 @ 符号限定范围)

enum Message {
    Hello { id: i32 },
}

let msg = Message::Hello { id: 5 };

match msg {
    Message::Hello { id: id_variable @ 3...7 } => {
        println!("Found an id in range: {}", id_variable)
    },
    Message::Hello { id: 10...12 } => {
        println!("Found an id in another range")
    },
    Message::Hello { id } => {
        println!("Found some other id: {}", id)
    },
}

返回值和语句块的最后一个值

和很多其他函数式语言一样,Rust使用最后一个值作为语句块的“返回值”。如果语句块是一个函数,那么就是函数返回值。比如:

fn print_usage() {
    123
}

这时候123是返回值,这和传统的语言不太一样。之所以说是语句块的“返回值”,是因为他还可以这么用:

let a = if abc {
    123
} else {
    456
}

甚至再嵌套一层:

let a = if abc {
    match def {
        Ok(m) => m,
        Err(f) => panic!(f.to_string()),
    };
} else {
    456
}

这就有意思多啦。怪不得不建议用 return 语句,因为这个的功能不是传统意义的 return

生命周期和转移语义

在看Rust文档的过程中,看得出来Rust的设计哲学是尽可能地无运行时开销并在编译期发现更多地错误。并且要求你关注失败和避免意料之外地开销。

所谓意料之外的开销是指比如C++有很多隐式类型转换和复制构造。而且由于历史和优先保证正确性的原因,默认就是复制。像 std::string 传递的时候可能会产生的复制,以及一不小型模板推断出了个std::vector类型作为函数参数,那会导致整个复制。

另外在C++里,我们经常会用一些技巧去限制工程实现,以达到提高工程质量的效果,而且写技巧往往晦涩难懂。而Rust没有这些历史包袱,就设计了一个比较良好的编译期检查。

生命周期,api级别阻止内存错误,编译期。

自动转移(move)语意和api强制关注坑(str拷贝,未处理的match)

泛型 <T: Display + Clone> 或<T> where T: Display + Clone
    操作符重载 PartialOrd
    Mut => Copy
    println!格式化字符串 => ToString(标准库的Display::to_string函数)

Rust有着极其难看懂但是究极变态得宏。

Rust是可以禁用标准库的,然而像 format! 之类是标准库的内容,格式检查的traits也属于标准库。所以看上去感觉里面的内容检查之类不像是c++编译器那样的编译器hard code实现。我大致看了一下,Rust的宏大致是 宏(参数) => 代码块 的形式。但是这个参数是可以直接 匹配和控制AST 的。 而且由于后面那个跟的是代码块,所以可以更容易实现一些复杂的功能。

另外它的宏还支持自定义过程宏,像这种的:

#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

等价于

struct Point {
    x: i32,
    y: i32,
}

use std::fmt;

impl fmt::Debug for Point {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Point {{ x: {}, y: {} }}", self.x, self.y)
    }
}

相当于魔改写代码了。官方文档还有个更蛇皮的例子:

extern crate proc_macro;
extern crate syn;   // 使用syn包来描述语法结构
#[macro_use]
extern crate quote; // 使用quote包来辅助扩展语法

use proc_macro::TokenStream;

// 为 #[derive(HelloWorld)] 提供语法支持。这样所有标记 #[derive(HelloWorld)] 的类型都会在编译期执行下面的代码
#[proc_macro_derive(HelloWorld)]
pub fn hello_world_derive(input: TokenStream) -> TokenStream {
    // Construct a string representation of the type definition
    let s = input.to_string();

    // Parse the string representation
    let ast = syn::parse_derive_input(&s).unwrap();

    // Build the impl
    let gen = impl_hello_world(&ast);

    // Return the generated impl
    gen.parse().unwrap()
}

fn impl_hello_world(ast: &syn::DeriveInput) -> quote::Tokens {
    let name = &ast.ident;
    // 给语法树注入hello_world()函数
    quote! {
        impl HelloWorld for #name {
            fn hello_world() {
                println!("Hello, World! My name is {}", stringify!(#name));
            }
        }
    }
}

然后,只要指定 #[derive(HelloWorld)] 就可以再编译期注入hello_world()函数:

extern crate hello_world;
#[macro_use]
extern crate hello_world_derive;

use hello_world::HelloWorld;

#[derive(HelloWorld)]
struct Pancakes;

fn main() {
    Pancakes::hello_world(); // 会输出 "Hello, World! My name is Pancakes"
}

上面的用法非常的蛇皮,我没进入细看,感觉format!和println!之类可能也是使用了类似的技术。而且忍不住再吐槽一下Rust的命名。 我看到 syn 的第一反应是同步机制(synchronization) ,谁知道他是语法(syntax)的缩写。

我看了下几个文档,还是 The Little Book of Rust Macros 对宏的解析流程描述的比较完整。Rust的宏还有一个挺有意思的地方是它受代码块的作用域的限制。现在看起来有一些复杂的概念,而且2018年说是要出2.0版,来支持await么?这个后面再细看吧。

工程化

测试框架

单元测试、集成测试和压力测试

Rust 自带单元测试、集成测试和文档测试框架并且可以直接通过执行 cargo test [查找名] 来运行。如果没有指定 查找名 ,这时候所有打了 #[cfg(test)] 标记的模块里,打了 #[test] 标记的函数会执行,还有文档里的测试代码也会执行。而如果指定了 查找名 ,所有测试名称中包含 查找名 的都会执行。

在运行测试的时候,panic! 之类的宏不会再崩溃,而是输出错误信息。额外还有一些测试专用的宏 assert!assert_eq!assert_ne!等等。

额外还有一些函数标记:

  • #[should_panic] 表示这个函数一定会 panic 。而 #[should_panic(expected = "panic文本")] 还能指定 panic 的原因。
  • #[ignore] 用来标记默认情况下忽略这个测试。这时候用 cargo test -- --ignored 可以指定仅运行默认忽略的测试。
  • #[bench] 标记是一个压力测试函数,使用 cargo bench 来执行。压力测试的报告是纳秒级。

测试代码可以写在源代码里打标记(单元测试),也可以专门提出来写在 工程目录/tests 目录里(集成测试)。当写在 工程目录/tests 目录里的时候可以通过 cargo test --test 测试模块或文件名 来仅允许某个集成测试文件。

最后,cargo test --release 可以指定按release编译,然后运行测试。这样可以测试编译优化后的结果,也可以拿来做压力测试。

Mock API

《Rust 程序设计语言(第二版)》RefCell 和内部可变性模式 章节介绍了如何用 内部可变性 来实现对API的Mock,这对一些特别是依赖异步API数据的测试就很有用了。

其实这里的Mock API就是实现一个Mock的类型,然后 impl 原类型 for Mock类型 ,里面实现要mock的方法。在不能改变可变性(非mut)的参数里,使用 RefCell 来完成运行时借用检查。也就是所有权唯一性检查。RefCell 是运行时检查借用所有权的,万一破坏了这个规则会导致 panic,所以这时候单元测试就非常有用了,能提前发现大多数不正确的使用流程。

基本上这个测试框架已经覆盖了工程上的各种使用场景了。

工具链管理(rustup.rs)

Rust 提供了一个官方的工具链管理器叫rustup.rs 。这个工具统一了我们管理Rust的工具链、库、文档的流程。使用起来也很方便。目前功能和说明还比较简单,只在 https://github.com/rust-lang-nursery/rustup.rs 上有,但是足够使用了。

初步接触下来Rust只提供了编译器,链接器和汇编器还是用了gcc或者MSVC的。所以如果依赖C/C++的库的话,可以写编译脚本,然后直接引进来,还算方便。也可以直接用rustup.rs下载预编译好的各种平台的工具链,当然包含链接器。

当前版本已经提供了下面这些平台的支持。

  • aarch64-apple-ios
  • aarch64-linux-android
  • aarch64-unknown-cloudabi
  • aarch64-unknown-fuchsia
  • aarch64-unknown-linux-gnu
  • aarch64-unknown-linux-musl
  • arm-linux-androideabi
  • arm-unknown-linux-gnueabi
  • arm-unknown-linux-gnueabihf
  • arm-unknown-linux-musleabi
  • arm-unknown-linux-musleabihf
  • armv5te-unknown-linux-gnueabi
  • armv7-apple-ios
  • armv7-linux-androideabi
  • armv7-unknown-cloudabi-eabihf
  • armv7-unknown-linux-gnueabihf
  • armv7-unknown-linux-musleabihf
  • armv7s-apple-ios
  • asmjs-unknown-emscripten
  • i386-apple-ios
  • i586-pc-windows-msvc
  • i586-unknown-linux-gnu
  • i586-unknown-linux-musl
  • i686-apple-darwin
  • i686-linux-android
  • i686-pc-windows-gnu
  • i686-pc-windows-msvc
  • i686-unknown-cloudabi
  • i686-unknown-freebsd
  • i686-unknown-linux-gnu
  • i686-unknown-linux-musl
  • mips-unknown-linux-gnu
  • mips-unknown-linux-musl
  • mips64-unknown-linux-gnuabi64
  • mips64el-unknown-linux-gnuabi64
  • mipsel-unknown-linux-gnu
  • mipsel-unknown-linux-musl
  • powerpc-unknown-linux-gnu
  • powerpc64-unknown-linux-gnu
  • powerpc64le-unknown-linux-gnu
  • s390x-unknown-linux-gnu
  • sparc64-unknown-linux-gnu
  • sparcv9-sun-solaris
  • wasm32-unknown-emscripten
  • wasm32-unknown-unknown
  • x86_64-apple-darwin
  • x86_64-apple-ios
  • x86_64-linux-android
  • x86_64-pc-windows-gnu
  • x86_64-pc-windows-msvc
  • x86_64-rumprun-netbsd
  • x86_64-sun-solaris
  • x86_64-unknown-cloudabi
  • x86_64-unknown-freebsd
  • x86_64-unknown-fuchsia
  • x86_64-unknown-linux-gnu (default)
  • x86_64-unknown-linux-gnux32
  • x86_64-unknown-linux-musl (installed)
  • x86_64-unknown-netbsd
  • x86_64-unknown-redox

这些是编译目标,然后工具链和库,也给了个平台支持状况的文档。基本上主要的平台都覆盖全了(除了很老的环境)。

比如我这里本地安装交叉编译Linux-x86_64的工具链(主要为了发布的二进制不依赖发行版)并且使用nightly版本(主要有些新功能只能nightly支持)的安装流程就是:

rustup self update
rustup install nightly
rustup run nightly rustc --version
rustup default nightly
rustup update
rustup install stable-x86_64-unknown-linux-gnu
rustup install nightly-x86_64-unknown-linux-gnu
rustup target add --toolchain stable x86_64-unknown-linux-musl
rustup target add --toolchain nightly x86_64-unknown-linux-musl

Native库依赖和交叉编译

上面也能看到,rustup.rs还提供了可以直接下载不依赖本地系统的工具链,这使得交叉编译方便了很多。比如可以用 x86_64-unknown-linux-musl 来编译不依赖发行版和系统库的Linux可执行程序,也可以 armv7-unknown-linux-musleabihf 编个什么程序扔路由器上跑。之前看到一些用go写的工具,可以直接很容易地编译出嵌入式设备无依赖地二进制,就觉得很惊艳。现在Rust也能做到了,我打算过段时间想个什么小工具扔家里路由器上玩玩。

构建系统和包管理

年代比较久的语言(比如C/C++)里都没有统一的构建工具,所以各个不同的平台有很多的轮子。很多语言像python、java之类都有一些趋于统一的2-3个软件包管理器和构建工具,而像C/C++就极其混乱。GNU系用得多的是Makefile,近期还有个新的叫Ninja。macOS和Visual Studio系都有自己的工程文件格式,像Visual Studio还有不止一种(MSBUILD、NMAKE和基于VS工程文件的系统等等)。然后GNU系呢,因为Makefile的功能十分有限、和其他语言的集成也不方便,还晦涩难懂,所以又产生了一系列的生成Makefile的工具,像automake、autoconf、cmake等等。还有一些平台采用自己的构建系统,像gradle、yotta等等。

另外,包管理也完全不统一。Linux的发行版几乎都用自己的包管理工具,像Debian系用apt,Red Hat系用yum和的最近高出了个dnf,BSD系用ports,SUSE系用zypper。还有一些平台用pacman和brew之类的。微软又搞了个vcpkg。

这些都导致管理多平台和多目标的程序非常呃麻烦,费时费力。好在Rust在这直接有一个规范化的系统。

构建系统 (cargo)

首先是构建系统是cargo。用于构建Rust的项目。cargo目前的文档还比较简单,详见 https://doc.rust-lang.org/cargo/ 。基本上文档就是个快速上手版本,里面有些细节功能我没有写,我之前想找怎么附加链接选项就没查到。

对于依赖库的构建,我看到很多仓库里是写了个build.rs来指定构建流程的,文档里也只是说可以指定编译脚本,但是没有更多细节了,光看文档不知道这个build.rs有哪些功能要怎么写。而且它是怎么当脚本执行的?难不成是先编译build.rs然后执行完再构建依赖库或二进制的吗?现阶段只能看看别人怎么写的,然后照抄了。

cargo直接集成了realse构建、文档构建和单元测试构建运行。也就是 cargo build --release 等于编译 [profile.release] 的内容。

cargo有个有意思的功能是源替换(嗯,直译就这个: Source Replacement)。我打算等百度那个openssl的替代品(MesaLink)的跨平台适配好了,openssl的其他接口也适配全了,试试用它替换掉websocket依赖的openssl和openssl-dev。这种更换binding实现的机制是挺有意思,可以很方面我们做局部优化而不用背太多的技术债了。

包管理 (https://crates.io)

这个基本上就是类似 vcpkg 。提供了一个统一的官方的包管理。它们版本号的规则都基本一样,感觉都是抄NPM的吧。不过总感觉https://crates.io这种先到先得的名字占用方式不是很好,感觉还是 用户名/包名 ,这种比较好。

文档管理 (https://docs.rs)

对应包管理 (https://crates.io) 系统,Rust也附带了统一的文档管理系统 https://docs.rs。基本上发布包的文档都可以在这里找到,结构还是挺简单易懂的。

Rust自带了文档系统。它的 // 是普通注释, /// 是给函数或模块的文档注释, //! 是给文件的文档注释。文档采用Markdown格式。文档里没说,但是我看了下现在版本里的代码和注释,它使用了pulldown-cmark(提供基于CommonMark的功能)在基础Markdown的基础上增加了:

  • 目录(TOC):
  • 开启脚注([^脚注名称]: 内容(支持多行)
  • 开启表格扩展(合并单元格、引用内的表格、列表内表格、单元格代码等)
  • CommonMark spec在基础Markdown上的扩展(当前引用的是0.27-0.28之间的一个commit),这个太多了我就不列举了
  • 对于代码块,新增了一批Rust专用的语言标记
    • 啥都不填 => 自动检测代码
    • no_run => 标识Rust代码能够编译但不能运行(猜测是不会在运行服务器上在线Run)
    • ignore => 忽略代码
    • allow_fail => 标识Rust代码能够编译和运行,但是允许运行失败
    • rust => Rust代码
    • test_harness => 不太清楚啥意思,好像是会添加到单元测试里去?
    • compile_fail => 标识Rust代码会编译失败
    • should_panic => 标识Rust代码能够编译,但执行会 panic

最后还可以设置服务器来运行sample程序,主题现在似乎只有light/dark的选项。文档系统还支持通过增加metadata来增加功能,比如这个crate: https://docs.rs/katex-doc/ 就通过Katex给文档系统增加了公式支持。

写在最后

前段时间我已经用rust简单地写了个命令行小工具给服务器用。接下来我会更深入研究下IO复用地部分和高并发编程地部分然后优化这个小工具,再来写第二阶段总结吧。

再记一下国内Rust社区, https://rust.cc