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 首次提出「协程」概念.
这个表示互相合作, 非竞争关系, 非抢占式. 所以在早期也被认为不太安全, 后来在高并发环境下复兴了起来.
有三种实现方式:

  • 有栈协程
  • 无栈协程
  • 基于状态机的协程
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐