数据(值和类型、指针和引用)、代码(函数、方法、闭包、接口和虚表)、运行方式(并发并行、同步异步和 Promise / async / await ),以及编程范式(泛型编程)。

数据

数据是程序操作的对象

值和类型

严谨地说,类型是对值的区分,它包含了值在内存中的长度\对齐以及值可以进行操作等信息.一个值是符合一个特定类型的数据的某个实体.比如64u8,它是u8类型,对应一个字节大小,取值范围在0~255的某个整数实体,这个实体是64.

值以类型规定的表达方式被存储成一组字节流进行访问.比如64,存储在内存中的表现形式是0x40,或者0b 0100 0000.

值是无法脱离具体类型讨论的.同样是内存中的一个字节0x40,如果其类型是ASCII char,那么其含义就不是64,而是@符号

编程语言的类型可以分为原生类型和组合类型两类

原生类型是编程语言提供的最基础的数据类型.比如字符\证书\浮点数\布尔值\数组\元组\指针\引用\函数\闭包等,所有原生类型的大小都是固定的,因此他们可以被分配到栈上

组合类型或者说是复合类型,是指由一组原生类型和其他类型组合而成的类型,组合类型也可以分为两类

  • 结构体:多个类型组合在一起共同表达一个值的复杂数据结构.比如Person结构,内部包含name\age\email等信息
  • 标签联合:也叫不相交并集,可以存储一组不同但固定的类型中的某个类型的对象,具体是那个类型由其标签决定.

另外,不少语言不支持标签联合,只取其标签部分,提供了枚举类型.枚举是标签联合的子类型,但功能比较弱,无法表达复杂数据结构

指针和引用

在内存中,一个值被存储到内存中的某个位置,这个位置对应一个内存地址.而指针是一个持有内存地址的值,可以通过解引用来访问它指向的内存地址,理论上可以解引用到任意数据类型.

引用和指针非常类似,不同的是,引用的解引用访问是受限的,它只能解引用到它引用数据的类型,不能用作他用.比如指向42u8这个值的一个引用,它解引用的时候只能使用u8数据类型

所以指针的使用限制更少,但也会带来更多的危害.如果没有正确的类型解引用一个指针,那么会引发各种各样的内存问题,造成系统崩溃或者潜在的安全漏洞

指针和引用是原生类型,它们可以被分配在栈上

根据指向数据的不同,某些引用除了需要一个指针指向内存地址外,还需要内存地址的长度和其他信息

比如”hello world”字符串的指针,还包含了字符串长度和字符串容量,一共使用了3个word,在64为CPU下占用24个字节,这样比正常指针携带更多信息的指针,称之为胖指针.很多数据结构的引用,内部都是由胖指针实现的

代码

数据是操作程序的对象,而代码是程序运行的主题,也是我们开发者把真实世界中的需求,转化为数字世界中逻辑的载体

函数\方法和闭包

函数是编程语言的基本要素,它是对某一个功能的一组相关语句和表达式的封装.函数也是对代码中重复行为的抽象.在现代编程语言中,函数往往是一等公民,这意味着函数可以作为参数传递,或者作为返回值返回,也可以作为复合类型中的一个组成部分.

在面向对象的编程语言中,在类或对象中定义的函数,被称为方法(method).方法往往和对象的指针发生关系

而闭包是将函数,或者说代码和其环境一起存储的一种数据结构,闭包引用的上下文中的自由变量,会被捕获到闭包的结构中,成为闭包类型的一部分

接口和虚表

接口是一个软件系统开发的核心部分,它反映了系统的设计者对系统的抽象理解.**作为一个抽象层,接口将使用方和实现方隔离开来,使两者不直接有依赖关系,大大提高了复用性和拓展性.

当我们在运行期使用接口来引用具体类型的时候,代码就具备了运行时多态的能力。但是,在运行时,一旦使用了关于接口的引用,变量原本的类型被抹去,我们无法单纯从一个指针分析出这个引用具备什么样的能力。

因此,在生成这个引用的时候,我们需要构建胖指针,除了指向数据本身外,还需要指向一张涵盖了这个接口所支持方法的列表。这个列表,就是我们熟知的虚表(virtual table)。

运行方式

程序在加载后,代码以何种方式运行,往往决定着程序的执行效率.

并发与并行

并发是同时与多件事情打交道的能力,比如系统可以在任务1做到一定程度后,保存该任务的上下文,挂起并切换到任务2,然后过段时间再切回任务1.

并行是同事处理多件事情的手段.也就是说,任务1和任务2可以再同一个时间片下工作,无需切换上下文

并发是一种能力,并行是一种手段

同步和异步

同步是指一个任务开始执行后,后续的操作会阻塞,直到这个任务结束.在软件中,我们大部分的代码都是同步操作.同步执行保证了代码的因果关系,是程序正确性的保证.

异步是指一个任务开始执行后,与它没有因果关系的其他任务可以正常执行,不必等到前一个任务结束.

在异步操作里,异步处理完成后的结果,一般用 Promise 来保存,它是一个对象,用来描述在未来的某个时刻才能获得的结果的值,一般存在三个状态;

  1. 初始状态,Promise 还未运行;
  2. 等待(pending)状态,Promise 已运行,但还未结束;
  3. 结束状态, Promise 成功解析出一个值,或者执行失败。

如果你对 Promise 这个词不太熟悉,在很多支持异步的语言中,Promise 也叫 Future / Delay / Deferred 等。除了这个词以外,我们也经常看到 async/await 这对关键字。

一般而言,async 定义了一个可以并发执行的任务,而 await 则触发这个任务并发执行。大多数语言中,async/await 是一个语法糖(syntactic sugar),它使用状态机将 Promise 包装起来,让异步调用的使用感觉和同步调用非常类似,也让代码更容易阅读。

编程范式

引入各种各样的编程范式,来提升代码的质量。

数据结构的泛型

首先是数据结构的泛型,它也往往被称为参数化类型或者参数多态,比如下面这个数据结构:

1
2
3
4
struct Connection<S> {
io: S,
state: State,
}

它有一个参数 S,其内部的域 io 的类型是 S,S 具体的类型只有在使用 Connection 的上下文中才得到绑定。你可以把参数化数据结构理解成一个产生类型的函数,在“调用”时,它接受若干个使用了具体类型的参数,返回携带这些类型的类型。比如我们为 S 提供 TcpStream 这个类型,那么就产生 Connection这个类型,其中 io 的类型是 TcpStream。

这里你可能会疑惑,如果 S 可以是任意类型,那我们怎么知道 S 有什么行为?如果我们要调用 io.send() 发送数据,编译器怎么知道 S 包含这个方法?

这是个好问题,我们需要用接口对 S 进行约束。所以我们经常看到,支持泛型编程的语言,会提供强大的接口编程能力,在后续的课程中在讲 Rust 的 trait 时,我会再详细探讨这个问题。

数据结构的泛型是一种高级抽象,就像我们人类用数字抽象具体事物的数量,又发明了代数来进一步抽象具体的数字一样。它带来的好处是我们可以延迟绑定,让数据结构的通用性更强,适用场合更广阔;也大大减少了代码的重复,提高了可维护性。

代码的泛型化

泛型编程的另一个层面是使用泛型结构后代码的泛型化。当我们使用泛型结构编写代码时,相关的代码也需要额外的抽象。

总结

值无法离开类型单独讨论,类型一般分为原生类型和组合类型。指针和引用都指向值的内存地址,只不过二者在解引用时的行为不一样。引用只能解引用到原来的数据类型,而指针没有这个限制,然而,不受约束的指针解引用,会带来内存安全方面的问题。

函数是代码中重复行为的抽象,方法是对象内部定义的函数,而闭包是一种特殊的函数,它会捕获函数体内使用到的上下文中的自由变量,作为闭包成员的一部分。

而接口将调用者和实现者隔离开,大大促进了代码的复用和扩展。面向接口编程可以让系统变得灵活,当使用接口去引用具体的类型时,我们就需要虚表来辅助运行时代码的执行。有了虚表,我们可以很方便地进行动态分派,它是运行时多态的基础。

在代码的运行方式中,并发是并行的基础,是同时与多个任务打交道的能力;并行是并发的体现,是同时处理多个任务的手段。同步阻塞后续操作,异步允许后续操作。被广泛用于异步操作的 Promise 代表未来某个时刻会得到的结果,async/await 是 Promise 的封装,一般用状态机来实现。

泛型编程通过参数化让数据结构像函数一样延迟绑定,提升其通用性,类型的参数可以用接口约束,使类型满足一定的行为,同时,在使用泛型结构时,我们的代码也需要更高的抽象度。