感受 Rust
1. ()
这个空类型, 我在学习 rust 之前是没有接触过的, 所以我对于有这样一个类型觉得很奇怪, 也很有趣.
首先, 有了它, 就可以将语句也纳入类型系统了, 这样子整个类型系统就完整了, 可以将所有的语言特性都归纳到类型系统之上. 类型系统可以做到如此的严谨性是我在学习 rust 之前所没有认识到的. 就接近于数学系统或者逻辑系统了.
其次, 为什么用这个符号()来表示空呢? 之前也没有过多的思考, 总是觉得用两个符号来表示一个概念, 是有点奇怪, 但是今天突然醒悟, 如果不用这个, 那么又能用什么呢?
这个其实是个非常好的设计, 就是用有来表示无.
另外, 也可以从外在上来看, 两个括号之间的空隙, 就表示无 😃 非常形象
真是个有意思的设计.
2. 泛型
我对于泛型的语法, 一直都非常不适应.
首先是对泛型的概念, 在 rust 学习之前是没有接触过的, 但是接触过宏, 没有系统的接触过泛型的使用.
所以心理上就怯怯的, 虽然知道具体的概念和意思, 但是在使用上没有那么随心应手.
其次是从词法方面, Evm<CTX, INSP, I, P> 这种尖括号里面带上以逗号隔开的一组大写的字母或者缩写, 看上去就非常难受.
我们试着跟之前的知识进行匹配和理解.
这个是使用符号来描述抽象化内容的一种方式.
我们在数学上学到的符号表达, 首先就是代数, 使用 x, y, z 来表示具体的数字, 使用 f, g 等来表示函数.
我个人认为, 数字虽然表达的是抽象的内容, 但是不算是抽象化的符号, 我觉得它们是有点具象化的符号,
就是跟自然界存在的`图形`是有点对的上的, 而 x, y, z 这些我们完全忽略了它们字形上的意义
那么如何在文字描述方面来使用抽象的符号表达出泛型的内容, 就是语言设计者要解决的问题.
我们已经有了函数的表达, fn main() 类型的, 那么使用泛型的适合, 也有类似的表达方式.
这里没有选择小括号或者其他的成对的符号, 而是选择了<>尖括号. 这是一个比较少用到的符号.
C++ 之前已经有了泛型的概念, 但是 C++ 是第一个使用 <> 来表示模板+类型的, 后世就沿用了它的这种用法.
看来大家肯定是认可这种表达方式的.
但是从个人感受上, 我觉得可以这样理解: struct Evm<CTX, INSP, I, P> 这种形式, 自己可以想象成这样子 struct Evm_CTX_INSP_I_P 这样一种类型, 如果用户指定了具体的哪一个类型参数, 就替换成具体的类型, 比如 Evm_EthContext_EthInpsector_I_P, 那些没有指定的类型, 还保留原位, 也就是说, 相当于是个占位符.
而且我们从大小写的写法上也容易分清, 类型泛型要么是全部大写的, 要么是单个字母的大写, 而具体类型都是驼峰式的写法, AbbCdd 这种, 不会是全部大写, 也不会是单个字母(可读性太差了), 是容易分得清的.
之前之所以感觉混淆的原因就是: 一般全部大写的都是不变的, 比如 const 常量, 都是固定值, 而在泛型的写法中, 全部大写的类型却是变量, 所以感觉有点混淆, 但是一旦你认识到这一点之后, 就会发现泛型的写法非常好理解了, 毕竟全部大写/单个字母和驼峰式写法非常好区分.
注意:
有个别的写法在写泛型的适合, 也采用驼峰式的, 我觉得这样子不太好.
3. 生命周期
Rust 的编译器可以做很多东西, 这个是之前在其他语言中很少见到的.
- 编译器像是一个老师, 它在检查你的写法, 不正确的地方会不让你通过(compile error), 不高效的地方会给你一些建议, 多余的地方会给你告警(warning) .
** 为什么说像老师或者教练呢, 就是因为它会提示你一些更高效的写法, 给你一些建议上的参考, 而其他语言是很少有这一点的, 基本上就是 error 或者 warning, 这个也就是为什么说 Rust 是现代语言的一个原因. - 它也像一个你的下属或者秘书, 比如你可以写泛型, 可以写异步,函数式这些语法糖, Rust 提供了非常多种的语法糖. 它会给你转换成具体可以工作的内容, 就类似于你在公司里面给下属发指令, 而下属在理解了你的指令之后做事情, 当然不是它在做, 而是它理解了之后交给它的下属去具体做, 也就是硬件机器.
** 这一点跟用 C 语言编程是很不一样的, 因为在 C 编程时, 你的脑子里面要有一台机器在运转, 你的语言与机器的执行是基本上可以一一对应上的, 而Rust 做到了一个秘书或者下属的角色, 帮助你隔绝了最底层的细节, 所以你面对的不再是最底层的工作, 而是更加高层的任务的思考, 可以降低你的心智负担, 思考更加高级的东西
** 这一点跟使用 Java Python 编程也不一样, 这些语言编程的时候不需要考虑机器, 与 Python 不同的是 Rust 的类型更加严谨, 非动态类型; 与 Java 不同的是, Rust 非常灵活, 不局限于面向对象这套概念中, 而 Java 是无法脱离整个面向对象框框的. 当然执行方式也不一样, Rust 是编译执行, 它们俩是解释执行. 回到角色上, Python Java 把你和机器隔离开了, 你也无法控制最底层的执行方式, 而 Rust 是可以让你深入到最底层的执行方式的. 也就是秘书没有把你完全和底层隔开. 你可以用最底层的工作方式告诉秘书, 它会直接转达.
总结起来, 把 Rust 编译器理解为一个智囊会好一些, 智囊这个角色既有老师的一些特性, 也有下属的一些特性. 所以一个推论就是: 用 Rust 编程会是一件非常开心的事情, 因为和比自己聪明的下属说话会非常轻松且愉快.😃
回到生命周期这个话题上来, 我们还是老规矩, 先讨论概念, 再讨论写法, 也就是先语义, 后语法.
生命周期这个概念, 它有着自己的起源和历史发展, 只是我们普通人不太熟悉学术界的这些发展.
-
在 1950s 开始, 人们就开始研究变量的存活期(live ranges), 用于寄存器的分配和优化,
-
后来就形成了
作用域(scope)的概念, 每个变量都有自己的作用域, 而引用也有着自己的作用域, 后面出现的那些内存的问题, 就是变量的作用域和引用的作用域对不上, 比如像悬垂指针, 二次释放, 就是变量的作用域已经消失, 而引用还继续存在, 等等问题. -
1990s:静态内存管理研究, 研究编译器如何判断:哪些对象可以安全地分配在栈上, 哪些会逃逸到外部作用域必须放在堆上, 这是为后来的逃逸分析的基础.
-
线性类型/仿射类型: 区别是 线性类型的变量必须恰好使用一次, 仿射类型的变量必须最多使用一次(可以不用), 这个最早起源于逻辑学, 1987年 Jean-Yves Girard 提出 “Linear Logic”, 这是整个线性类型家族的根源; Wadler 等人推动 linear types 进入 λ-calculus 理论, 在工程上启发了资源管理 / 内存安全, 没有 double-free, 没有 use-after-free, 没有悬垂指针, 没有数据竞争等好处
-
关键点在于1994 年Tofte & Talpin发表了一篇论文 “Region-Based Memory Management”, 基于 region 来进行内存管理.
** region 是什么呢?Region = 一个程序执行区间(scope)+ 一个对应的内存区域(region memory) 的组合概念。
** 从语义角度(语言层面):region 是一个代码执行范围(类似作用域 block); 从运行时角度(内存层面):region 是对应的一个连续内存池(arena). 所以 region 是 语义 + 内存 双重属性的概念, 将代码和内存关联起来
** 举例
在执行方面
let region r in
…
end
这个 region 从进入作用域开始,到离开作用域结束。
所有在 region 中创建的值,其生命周期都绑定到这个 region。在内存方面
每个 region 都有一个对应的 region memory / arena:
Region r:
├─ allocation 1
├─ allocation 2
├─ allocation 3
└─ …
退出 region 时:一次性释放整个 region 的内存
** 也就是 region 是一个比 scope 更加高阶的概念, 在内存管理方面做的更多.
** region 的好处是, 对比于传统的手动式内存管理, 它更加稳定, 不容易出错; 对比于 GC 来说, 运行时开销非常低, region 可以静态推理出生命周期, 是一个静态系统,region = 用语法结构来管理内存的静态系统. -
Cyclone 给出了一个具体的实现, 引入了 线性/仿射类型和region系统, 证明了可行性
-
Rust 做了一个
集大成者, 把 ownership(来自线性类型), region-based memory management, borrow analysis, escape analysis 融合在一起变成可用的“生命周期系统”。
这样子整个生命周期理论的发展, 也能够看的出来了, 基于 region 也对 lifetime 有了精确的认识.
再来看词法方面.
- 'a 这种写法最早出现在 ML 家族, 这是最早的泛型的写法, 代表着类型变量, fun length (xs : 'a list) = … 这里的 'a list 表示元素类型是 'a 的 list,
- 而在 rust 中表示生命周期, 而不是类型变量. 也就是说, Rust 中有了对 region/lifetime 的符号化表示. 我们在上面已经介绍了 region/lifetime 的概念, 这里就有了符号化的表示, 所以我们是可以基于它进行思考, 表达, 和分析的. 有了表达就会更进一步.
我们再进一步思考一下为什么在词法上选择 'a 这种形式来表达 region 变量呢?
- 我们用 snake case 的形式来表示函数内的变量, Rust 的全局变量有 static 写法, 是用 camel case 的写法;
- 类型变量, 我们上面分析了, 是用全部大写或者单个字母大写的形式
- 生命周期变量, 该选择哪种形式呢, 来与其他的写法区分开来呢, 设计者选择了 'a 这种写法, 因为它原来也表示了类型变量的意思, 但是设计者选择了 C++ 的写法来表示泛型, 那么应该就是选择了 'a 这样本身携带变量特性的写法来表示生命周期变量了.
4. 多态和泛型
上面已经讲了泛型, 这里再讲一下多态的概念, 加深一下对概念的把握.
泛型是多态的一种实现.
多态是为了描述编程中的什么情况呢?
最关键的是要认识到这一点: 区分开类型的定义和类型的使用.
不同的类型可以有不同的定义, 但是在使用上看, 多个类型可以有相同的用法.
这个在日常生活中是普通存在的, 我们在菜市场买菜, 用秤来称量物品, 不管水果还是蔬菜, 不管是瓜子, 花生, 还是白菜萝卜, 都是一种操作方法. 现实中多态是普遍存在的. 只是我们在日常生活中没有提炼出来对应的概念, 因为我们没有意识到, 而在计算机的编程世界中, 我们要有对应的概念出来, 一旦有了这个概念之后, 反过来审视我们的现实世界, 就会发现到处都是,
泛型在 Rust 中的应用, 几乎可以用在所有的语言要素上:
- 类型上: struct, enum, type
- 函数/方法上: fn< T: Debug> print(t:T) {}
- 实现 impl 上: impl< T: XXX> YYY …
- 接口上: trait< T>
其他的都好理解, 接口泛型说一下, 这是一种行为泛型, 跟在函数/方法上的泛型还不太一样, 那里是作为输入, 是被刀切的菜, 这里是说, 这些类型有通用的行为 trait. 举个例子:
trait Into<T> {
fn into(self) -> T;
}
可以转换成各种其他的类型, 当然也有很多其他的用法.
泛型在 rust 的使用上还有更多的细节, 但是只要理解了概念, 这些细节自然就可以想得通, 多用用就好了.
5. 并发和异步
我在看这个"高性能高并发"系列的文章:
<看完这篇还不懂高并发中的线程与线程池你来打我>
这篇讲了 cpu, 操作系统, 进程, 线程.
这里对线程的区分和使用, 我觉得是挺有价值的:
从生命周期上看, 线程要处理的任务有两类: 长任务和短任务.
- 长任务是要随着进程的存在而一直存在的, 或者可能短一些, 但是是需要长期使用的
- 短任务有两个特点: 处理所需的时间短; 任务数量多.
互联网请求基本上都是短任务, 为了解决短任务的数量多且时间短的问题, 就引出了线程池.
线程池就是生产者-消费者模式.
线程池的数量该如何确定呢? 如果数量太少, 不能充分发挥硬件的性能, 如果数量太多, 反而造成系统性能下降.
要区分任务是cpu密集型还 I/O密集型.
- CPU密集型, 那么线程数量与 cpu核数基本一致就可以;
- I/O密集型, 要进行性能测试, 要评估出 WT(wait time), 和 CT(computing time), 一个简单的参考公式:
N * (1 + WT/CT), N 是 cpu 核数.当然, 在实际中要切合具体场景.
<读取文件时,程序经历了什么?>
这篇以阻塞式I/O 的形式介绍了操作系统的进程调度,
这里有一句话非常好: I/O就是简单的数据Copy.
不管是打印输出, 还是读写文件, 还是网络通信, 都是 I/O.
除了阻塞式I/O 之外, 还有非阻塞式, 当然, linux 操作系统现在调度的是线程, 而非进程.
还有零拷贝技术, 就是尽量减少数据的拷贝次数.
有多种实现技术.
<你管这破玩意叫 I/O 多路复用 ?>
这里有几句话也非常好:
有了文件描述符,进程对文件一无所知,比如文件在磁盘的什么位置上、内存是如何管理文件的等等,这些信息属于操作系统,进程无需关心,操作系统只需要给进程一个文件描述符就足够了。
这样子就完全隔离了进程和文件管理.
I/O 多路复用, 就是当文件描述符过多的时候, 统一交给内核, 让它来帮忙监听.
- select
- poll
- epoll
<从小白到高手,你需要理解同步与异步>
同步和异步, 在编程的代码形式上不难理解, 就如同之前只有 可变变量, 后面有了不可变变量和可变变量之分.
使用 mut 来区分, 这里同步和异步就使用 async 来区分.
重点不在于理解, 而在于如何完全掌握异步.
或者说, 异步就是一种最高层的接口, 对开发者暴露, 编程人员可以直接操作的. 编程人员使用异步来编程, 底层的语言实现方法有多种, 这个是语言级别了, 不需要开发者过多关注.
<10张图让你彻底理解回调函数>
这里讲了回调函数, 以及回调地狱(callback hell)的写法.
<高并发高性能服务器是如何实现的>
人们在高并发的发展中, 提炼出了一种编程模式: 事件驱动编程.
服务器就是这种形式, 其实, 菜馆里面的服务员的管理模式, 也是如此, 或者大胆一点的说, 所有的服务类型都是这种模式? 菜馆里, 大堂经理就相当于监听线程, 各个服务员就是具体处理线程, 服务员组成一个线程池, 客人点菜就相当于下达了 I/O 操作, 厨师做饭就是执行具体的 I/O 操作, 送菜员上菜就是I/O 完成后的回调.
厨师做饭的时候, 服务员不能干等着服务于用户, 还要接待其他的用户, 这个就是非阻塞式
<函数运行时在内存中是什么样子?>
这个里面是一些基本常识.
<程序员应如何理解高并发中的协程>
这里非常好, 介绍了协程的有栈模式的实现. 但是还有很多的问题没有说清楚.
协程的发展历史
完全是用户态的内容, 历史比线程还久远, 在1958 年 Melvin Conway 首次提出「协程」概念.
这个协表示互相合作, 非竞争关系, 非抢占式. 所以在早期也被认为不太安全, 后来在高并发环境下复兴了起来.
有三种实现方式:
- 有栈协程
- 无栈协程
- 基于状态机的协程
更多推荐


所有评论(0)