在互联网上经常可以看到程序员因为编程语言的选择而争论不休，常常出现类似 “XXX vs XXXX” 的评测文章，同时也不排除一些语言布道师或商业公司在背后炒作某种语言的热度，来达到某种商业目的。大多数文章的评测重点通常是程序的执行效率和内存占用，很少从语言特性、语法或语义的角度进行深入分析和讲解，我这篇文章将从这个角度来讨论 Go 语言设计丑陋之处。在我编程生涯中使用过很多门编程语言，这其中就包括所谓的大道至简的 Go 语言，总体来说使用 Go 语言来编写逻辑是非常容易的，天生支持协程，采用 CSP 和 GMP 模型来设计，极度克制的语法和运行时并发模型，显著降低了高并发程序的工程门槛，使得开发高并发应用也非常简单。它的语法简单和 25 关键字非常容易掌握，能使程序员快速掌握并且编写代码逻辑，并不意味着 Go 的设计有多么好。反而在我使用 Go 编程过程中出现很多感觉很多丑陋的设计，而不像国内某些 Go 语言布道师吹崇它多么多么好，我个人感觉哪些很多布道师从 PHP 转换过来的，就没有写过其他高级的编程语言，例如 Kotlin 和 C# 、Rust 或者新版本的 Java 等... 当然作为某个编程语言的布道师可能说这门编程语言有多么多么好，这样才能忽悠到没有任何经验新人去学习这门编程语言。如果读者是 Go 语言的粉丝，这篇文章的内容会让你感到非常不适，乃至会让你感到作呕，但能让读者明白好的设计和相关问题在不同编程语言是如何解决的。空值 nil 和错误处理在计算机科学史上，很少有设计决策像 null 这样，既普遍存在又饱受诟病，它几乎出现在所有主流编程语言中，却也是无数程序崩溃、漏洞与安全事故的根源。这一现象并非偶然，与 C 语言的历史地位密切相关，由于 C 语言诞生于基础软件快速发展期，其设计选择在早期被广泛接受并固化下来，null 作为 “无对象” 未分配的内存的默认表示方式也随之成为事实标准。随后伴随着大量基础软件、操作系统内核、运行时库、编译器以及工具链都以 C 语言或其内存模型为基础构建，导致 null 这一设计被层层继承和放大，深度嵌入整个软件生态之中。众多编程语言实现自身编译器、虚拟机和运行时系统都是以 C 语言为依赖基础，null 不再只是某一门语言的设计细节，而演变为跨语言、跨系统的结构性问题，持续影响着后续数十年的语言设计与软件工程实践。Go 程序的错误处理，在编写逻辑复杂并且存在多种复杂异常的情况时会出现写出满屏 if err != nil，这个和 C 语言的一脉相承的，C 语言使用是错误码，Go 的错误处理只是添加一些上下文描述，并没有附加堆栈信息，例如下面的代码： if len(files) <= 0 { // 它这个错误设计要不停写 if err != nil err := lfs.createActiveRegion() if err != nil { return err } return nil }但一般程序员会自己优化掉，写成下面的这种方式：// return lfs.createActiveRegion()可以直接这么写，但是会丢弃错误堆栈处理的上下文，特别是在处理一些比较细节的错误分类的时候。Go 的错误处理机制中，错误值通过返回值 return 在调用栈中逐层向上冒泡传递，每个函数在检测到错误时，通常会将错误返回给上层调用者，由上层决定如何处理。这对于喜欢函数编程的程序员是一个很糟糕的设计，例如下面代码： sql2 := gsql.SelectAs([]string{"name", gsql.As("age", "年龄"), "id"}).From(UserInfo{}).ById(2) // SELECT name, age AS '年龄' FROM UserInfo WHERE id = 2 t.Log(sql2) sql3 := gsql.SelectAs(gsql.Alias(UserInfo{}, map[string]string{ "name": "名字", })).From(UserInfo{}).ById(1) // SELECT id, name AS '名字', age FROM UserInfo WHERE id = 1 t.Log(sql3)这段代码核心功能是我编写的一个用于 Go 的结构化查询语言代码生成器，类似于一个 SQL 在 GO 语言中 DSL 抽象实现，可以生成出 SQL 查询语句的字符串。但在调用这个 gsql 包提供的各种函数和 API 过程中会出现一个问题就是每个函数逻辑复杂有其他逻辑分支，可能会出现 error 的情况，因此按照 Go 的错误处理设计，每个函数必须返回其函数内部的 error 作为上下文，传递出去方便在出错时定位到问题。[scode type="yellow"]但为了满足写起来像函数编程链式调用的风格，又不得将这些函数的错误信息隐藏，包装起来，只有当调用完最后一个函数才能获取到具体的错误信息。每个函数在处理之前都需要检查基于上一个函数调用的结果的有没有出错，如果出错了就直接 return err ... 白白的创建一个并且运行新的栈帧...[/scode]这里列举一些其他编程语言我认可的设计例子，例如 C++ 和 Java 的设计，注这里的代码为 C++ 示例：try { // 任一步 throw 都会冒泡 auto c = A().b().c(); } catch(const std::runtime_error& e) { // 处理错误 }或者 Zig 的错误处理设计，Zig 也是类 C 的编程语言，它也是采用的 try ... catch ... 的方式来做错误处理，代码如下：// 任一步 try 都会将错误冒泡上一层 const a_val = try a(); const b_val = try a_val.b(); // 对 b_val.c() 进行错误处理 const c_val = b_val.c() catch |err| { ... }; // foo 函数有错误时为 x 变量设置默认值为 0 const x = foo() catch 0; // orelse 一个默认值 const value = p orelse defaultPtr;虽然不能函数级连调用，也是每一步来使用 try 来做错误处理，比 Go 每步都需要写 if err != nil 设计要好，Zig 每一步都有错误处理语义，try 失败就立刻返回当前函数，配合 catch 和 orelse 来为出错时设置默认值，不需要引入临时变量控制流是线性的，虽然不能 a().b().c() 这种函数级链式，但每一步的错误传播非常干净。[scode type="green"]这里我要指出是 Rust 的 ? + Result/Option 抽象，是我目前使用过语言里，对链式调用错误传播综合体验最好的，Rust 代码示例如下：[/scode]let x = a()?.b()?.c()?;链式调用发生未知的错误时 ? 只负责提前结束函数调用并且返回，在某些场景下上层的调用者在发生错误时还希望程序能继续正常运行，这时可以使用 unwrap_or 函数来设置默认值。// 函数式级联调用来处理错误 fn get_x() -> Option<T> { let x = a()?.b()?.c()?; Some(x) } // 在发生了错误的时，通过 unwrap_or 的设置默认值 let x = get_x().unwrap_or(default_value);在程序运行时，状态可能不确定，函数执行过程中可能抛出异常或返回 null ，导致无法获得预期的值。为此一些编程语言提供了默认值机制或语法糖，例如 Java 的 Optional<T> 、Kotlin 的可空类型，Kotlin 的代码如下：// 可为 null val name: String? = null // 非空类型，不能为 null val age: Int = 30 // name 为 null，则 length 也为 null，非法时使用 0 作为默认值 val length = name?.length ?: 0以及新版本的 ES6 也支持类似的默认值语法，JavaScript 代码如下：// ?? 预定义默认值 let value = someVariable ?? defaultValue;如果 someVariable 是 0、'...'、false、null、undefined、NaN ，就会使用默认值，someVariable 的非法的时，?? 可以让 defaultValue 成为 value 的值。同样 ES6 也是支持 object?.field 安全访问某个属性的值：// js 对象定义方式 var user = { name: "Alice", address: null }; let city = user.address?.city ?? "Unknown"; // "Unknown" console.log(city); 显然 JavaScript 作为一种动态类型语言，在运行时允许对已初始化的对象动态添加或删除字段，这就会导致对象的属性在运行过程中可能不确定，如果不对属性字段进行 null 或 undefined 的进行判断，就很容易在访问这些属性时导致程序崩溃或报错，不能达到预期执行路径的结果。[scode type="green"]因此 ES6 设计了可选链运算符 ?. 使用此运算符访问的对象或调用的函数是 null 或 undefined 则表达式会短路并计算为 undefined 而不是抛出错误。可选链运算符和 ?? 配合一起使用就能实现预定义默认值，从而安全地处理可能缺失的返回值，并使用预先定义的默认值保证程序能够正常运行，这是我认为最好的 PL 语法糖设计。[/scode]return 和 defer 冲突在多数编程语言 return 作用都是用来结束栈帧，结束函数的执行，并且如果函数签名中有返回值，那么还要顺带着返回值一起返回，但在 Go 语言还设计了 defer 关键字来辅助 return 来完成一些辅助清理工作，defer 关键字是用来延迟执行某个函数用途，并且保证在函数执行栈出现异常或者 return 时，能正常执行某个预定函数操作。在结合 defer 场景下就会出现问题，为了防止文件句柄打开忘记关闭情况下，在打开文件操作的代码逻辑后面就会紧跟上 defer 的操作，但 defer 并不能处理这个函数的调用返回值，return 和 defer 关键字的在关闭文件描述时发生干扰，例如下面的代码： file, err := os.Open(filePath) if err != nil { return fmt.Errorf("failed to open index file: %w", err) } defer file.Close()上面这段只以只读的方式打开了文件，所以只需要执行 defer file.Close() 操作就可以完成文件句柄关闭，但是下面这种情况在写入情况进行关闭，因为操作系统内核有缓冲区的存在，不一定能及时刷盘，所以在关闭文件的时候还要执行 file.Sync() 操作保证内核缓冲区的数据能及时刷盘再关闭，例如下面的代码：err := lfs.active.Sync() if err != nil { return fmt.Errorf("failed to sync active file: %w", err) } err = lfs.active.Close() if err != nil { return fmt.Errorf("failed to close active file: %w", err) }优化之后的代码，把两个操作合并为一个工具函数：defer utils.CloseFile(lfs.active)但 defer 并不能处理这个函数的调用返回值...这就导致执行会丢弃掉函数所传递出来的关键的错误信息...等价于下面的代码：// ??? _ = utils.CloseFile(lfs.active)上面的示例都是以 defer 来释放资源所面临的问题，下面的示例将开始对 defer 在配合 sync.Mutex 出错时一些代码示例的问题进行。在 Go 语言中编写并发程序防止资源竞争，通常都是使用 sync.Mutex 提供的互斥锁来保障临界区的资源。上锁的目的就是保证临界区的数据不被其他线程同时修改，但临界区范围需要程序员手动控制，也就是手动控制的上锁和解锁。Go 语言的互斥锁是不可重入的锁，意味着下一个锁进入临界区时，前一个持有锁的人必须解锁，通常这个解锁操作会配合 defer 来使用，例如下面代码：for _, imap := range lfs.indexs { // 上读锁，临界区结束时自动解锁 imap.mu.RLock() defer imap.mu.RUnlock() for inum, inode := range imap.index { bytes, err := serializedIndex(buf, inum, inode) if err != nil { return fmt.Errorf("failed to serialize index (inum: %d): %w", inum, err) } _, err = fd.Write(bytes) if err != nil { return fmt.Errorf("failed to write serialized index (inum: %d): %w", inum, err) } } }这是使用的好处是能在出错时主动释放锁，但不能控制临界区的范围，因为使用了 defer imap.mu.RUnlock() 来自动解锁，使用了 defer 需要整个函数嵌套调用栈执行完成才能解锁，并且被嵌套的 serializedIndex(...) 函数中也再次上锁，不可重入锁，会导致死锁。如果想控制锁的范围的，即锁的颗粒度，那么程序员就必须手动控制互斥锁主动释放和解锁，优化之后的代码：for _, imap := range lfs.indexs { // 上读锁 imap.mu.RLock() for inum, inode := range imap.index { bytes, err := serializedIndex(buf, inum, inode) if err != nil { // 出错提前解锁 imap.mu.RUnlock() return fmt.Errorf("failed to serialize index (inum: %d): %w", inum, err) } _, err = fd.Write(bytes) if err != nil { // 出错提前解锁 imap.mu.RUnlock() return fmt.Errorf("failed to write serialized index (inum: %d): %w", inum, err) } } // 循环结束后解锁 imap.mu.RUnlock() }优化之后的代码能控制锁的范围，但是又要在出错时手动释放互斥锁，那就要处理 if err != nil 的情况出现异常手动解锁，同样是一个很难用的设计。这里举几个其他语言的例子，另外一种就是 Java 7 之后的提供了 try-with-resources 语法，任何资源类只要实现 AutoCloseable 接口离开作用域时就会自动调用 close() 方法释放资源，代码如下：class MyResource implements AutoCloseable { public MyResource() { System.out.println("Resource acquired"); } public void doWork() { System.out.println("Working..."); } @Override public void close() { System.out.println("Resource released"); } } public class Main { public static void main(String[] args) { // 因为实现了 AutoCloseable 在 try 语句块会主动关闭资源 try (MyResource r = new MyResource()) { r.doWork(); } } }另外一个 Zig 语言同样使用 defer 在函数返回时做一些主动清理工作，同样会面临一些 defer mutex.unlock(); 只能等到整个 doSomething 函数执行完成时才能返回，但是在 Zig 允许使用 { ... } 定义作用域来缩小 defer 所清理资源的范围，解决了上面的 Go 语言每一步出做时需要在 if err != nil { ... } 中来手动控制处理每个错误分支的解锁逻辑。fn doSomething() !void { var mutex = std.Thread.Mutex.init(); { try mutex.lock(); // 只在这个作用域自动解锁 defer mutex.unlock(); try step1(); } // 离开作用域自动解锁 // 不需要锁 try step2(); }[scode type="green"]Zig 的一个特别设计是新增了 errdefer 关键字，与 defer 在函数退出时无论正常还是出错都执行不同，errdefer 仅在函数出错返回时触发，用于回滚、解锁或释放资源，而正常执行路径可以选择提前释放或交由调用方管理，例如上面 Go 语言手动错误分支解锁的逻辑，使用 Zig errdefer 关键字代码如下：[/scode]const std = @import("std"); fn doSomething() !void { var mutex = std.Thread.Mutex.init(); try mutex.lock(); // 正常路径手动解锁 errdefer mutex.unlock(); // 正常路径可以手动释放 try step1(); // 出错自动解锁 try step2(); // 出错自动解锁 // 正常路径手动解锁 mutex.unlock(); // 这两个函数不涉及到并发资源竞争不需要加锁 step3(); step4(); }如果使用的是 defer 那么要等整个 doSomething 正常函数返回才会自动解锁，就失去了对锁颗粒度范围的控制，例如上面的 step3() 和 step4() 函数不涉及到数据竞争就不需要加锁，如果采用 defer 则整个 doSomething 函数包括内部调用函数都上锁了。没有方法重载入，还有三目表达式，没有模式匹配，内置 init 函数和自定义 init 互相冲突并且自定义 var x = func() {}() 执行顺序和内置 init 函数执行顺序冲突，函数式编程没有 lambda 表达式的支持，范型不支持协变和逆变，标识符、Struct、变量、字段的访问控制和 JSON 正反序列化矛盾的冲突。另外构造器随意创建，不支持函数重载导致需要使用魔法编程可变参数进行实现类似于其他有重载功能语言，这对于一个设计友好 API 的人来说确实需要；另外接口太小，名称统一会导致被很多其他拥有相同字段接口所隐式的实现。协程泄漏和协程错误传播Go 语言最引以为傲的设计，是内建抢占式调度的 gorontine 以及与之配套的 runtime 并发模型，在 Go 语言里开启一个异步任务非常容易，只需要使用 go 关键字就使一个普通 func() 函数变成一个异步协程任务，代码如下：go func() { // ... }() // 👈 这个函数 return 了，goroutine 就死了goroutine 协程是轻量级的用户线程实现，并不意味着能无限得创建和泛用 go func() {...} 创建协程，Go 语言的 runtime 调度器只负责何时进行调度、栈管理、回收协程栈，根本就不会处理 goroutine 内部的状态变更和错误如何传播到上层，会导致协程内部错误被丢弃乃至不处理，最后会出现协程泄漏。[scode type="yellow"]原因就是 Go 语言在设计的时候，采用的所谓的 CSP 通过通信而非共享状态，goroutine 之间的状态同步与协作完全依赖于显式的通信机制如 channel 结构和 context 包。然而在实际使用 goroutine 处理异步并发任务时，Go 语言并未在语法或类型系统层面强制要求传入 channel 或 context 来管理 goroutine 及其子 goroutine 的生命周期，这使得并发结构的正确性完全依赖于开发者的经验和良好编码习惯。[/scode]一段典型使用 Go 语言实现非结构化并发代码示例如下：package main import ( "fmt" "time" ) func f(from string) { for i := 0; i < 3; i++ { go fmt.Println(from, ":", i) } } func main() { f("direct") go f("goroutine") go func(msg string) { go f(msg) }("going") time.Sleep(2 * time.Second) fmt.Println("done") }如上代码随意创建 goroutine 却没有对错误或异常退出路径进行统一管理，就很容易产生 goroutine 泄漏，这本质上属于一种非结构化并发的代码设计。如果想解决协程泄漏和错误传播的问题，就得使用 Go 所倡导的做法是采用 CSP 并发模型，通过 channel 或 context 将各个 goroutine 串联起来进行协同管理，这种方式要求开发者要在每个 goroutine 中显式编写对取消信号、错误返回和退出条件的处理逻辑，因此在每个 goroutine 协程里面都有一定的重复代码逻辑侵入。例如一段生产级的两个协程相互协作，错误使用 channel 代码逻辑如下：func recoveryIndex(reader *mmap.ReaderAt, indexs []*indexMap) error { offset := int64(len(dataFileMetadata)) type index struct { inum uint64 inode *inode } nqueue := make(chan index, (int64(reader.Len())-offset)/_INDEX_SEGMENT_SIZE) equeue := make(chan error, 1) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() defer close(nqueue) buf := make([]byte, _INDEX_SEGMENT_SIZE) for offset < int64(reader.Len()) && len(equeue) == 0 { _, err := reader.ReadAt(buf, offset) if err != nil { equeue <- fmt.Errorf("failed to read index node: %w", err) return } offset += _INDEX_SEGMENT_SIZE inum, inode, err := deserializedIndex(buf) if err != nil { equeue <- fmt.Errorf("failed to deserialize index (inum: %d): %w", inum, err) return } if inode.ExpiredAt > 0 && inode.ExpiredAt <= time.Now().UnixMicro() { continue } nqueue <- index{inum: inum, inode: inode} } }() wg.Add(1) go func() { defer wg.Done() for node := range nqueue { imap := indexs[node.inum%uint64(shard)] if imap != nil { imap.index[node.inum] = node.inode } else { equeue <- errors.New("no corresponding index shard") return } } }() wg.Wait() select { case err := <-equeue: close(equeue) return err default: close(equeue) return nil } }在这段代码 recoveryIndex 函数中，有两个 goroutine 需要相互配合完成任务，并且要求任意一个 goroutine 发生错误时，另一个能够及时感知并同步退出，目前采用的实现方式是通过一个名为 equeue 的 channel 进行协调，并通过 for 循环的条件进行控制：for offset < int64(reader.Len()) && len(equeue) == 0 { ... }[scode type="red"]该实现表面上看是可行的，但在实际运行过程中会暴露出明显的并发问题：产生的错误无法可靠、及时地在 goroutine 之间传递；另一方面在错误发生或消费不及时的情况下，可能因为 equeue 缓冲区已满而导致某个 goroutine 阻塞，进而引发 goroutine 泄漏或整体流程无法正常退出。[/scode][scode type="yellow"]与这种非结构化并发相对应的，是结构化并发的程序结构设计；结构化并发的核心目标是：让并发任务的生命周期、错误传播与取消语义，严格受控并与代码结构保持一致。[/scode]在当前工业级实践中，最成熟、最系统化的方案来自新版本的 Java 语言，JavaSE 21 版本引入的 StructuredTaskScope 为结构化并发提供了语言层面清晰、可组合且可推理的 API 设计，使并发代码首次具备了类似顺序代码那样的可读性与可维护性。例如下面这个基于 JavaSE 21 的结构化并发示例：void serve(ServerSocket socket) throws IOException, InterruptedException { try (var scope = new StructuredTaskScope<Void>()) { try { while (true) { var conn = socket.accept(); scope.fork(() -> handle(conn)); } } finally { // 如果发生异常或线程被中断，停止接收新连接 scope.shutdown(); // 主动取消所有仍在运行的子任务 scope.join(); // 等待子任务有序结束 } } }访问控制与构造函数在编写项目中最常见的就是把基础标量类型封装为一个 struct 类型结构体，例如下面代码是一段实现了 gossip 协议所需要的代码，分别为 Node 和 Cluster 结构体，这两个结构体要提供外部的包进行调用和访问。这两个结构体的字段都是大写开头，这是由 Go 访问控制权限规则设计要求的，但是在某些需求下为了不让外部包的调用者能直接通过 new(Node) 和 &Node{} 构造器（准确来说没有传统 class 的构造器）来创建 Node 类型的实例，只能通过 gossip.NewNode() 静态工厂函数进行实例化一个节点，因为 gossip.NewNode() 内部会提供一些默认的初始化规则。在这种场景下只能使用下面的方式来封装一个 innerNode 类型，将其嵌入到 Node 中，这样就可以避免其他人滥用 Node {} 创建，代码如下：type ( // 类似于私有化 Node {} 构造器，其他包就不能直接 {} 来创建 Node 实例 // 只能通过 gossip.NewNode() 进行实例化一个节点 innerNode struct { ID string // 节点唯一标识 Address string // 节点地址 Heartbeat int // 心跳计数 Timestamp int64 // 最新心跳时间 Alive bool // 节点状态 Hash uint32 // 节点哈希值，用于一致性哈希 } innerCluster struct { Neighbors map[string]*Node // 所有已知节点的信息 Self *Node // 本节点信息 Mutex sync.Mutex // 保护 Nodes 的并发访问 Interval time.Duration // 心跳间隔 Timeout time.Duration // 失效阈值 HashRing []*Node // 一致性哈希存储范围的键 } // Node 协议集群中的节点 Node struct { innerNode } // Cluster 协议的集群集合 Cluster struct { innerCluster } ) // NewNode 只能使用此暴露外部的函数进行创建 func NewNode(id, addr string) *Node { node := &Node{ innerNode{ ID: id, Address: addr, Heartbeat: 0, Timestamp: time.Now().Unix(), Alive: true, }, } // 通过节点 ID 算出在哈希环中的值 node.Hash = NodeHash(id) return node }[scode type="yellow"]在这段代码逻辑中存在一个问题，就是由于 struct 中的字段都是以小写开头，导致如果需要对此 Node {} 这个结构体进行 JSON 正反序列化，就会出现无法进行序列化的情况，原因就是因为上面的需求导致的，导致一个很矛盾的问题。要解决这个序列化问题，可以让要被序列化的 struct 实现自定义的 MarshalJSON 方法，这种方式要额外的编写正反序列化的代码实现，另外就是无法被反射编程动态修改其值。[/scode]封装和扩展限制final 、sealed 密封类。目前我个人认为 Go 的优点在于语法简单甚至略显简陋，同时保留了指针的特性。Go 的源代码可以直接编译为原生机器码，能够在计算机上直接运行，这带来了快速冷启动和低内存占用的优势，在 Cloud Native 场景下，这种静态编译特性确实非常适合。然而这并不意味着 Go 在所有方面都能与基于 VM 的语言相比。例如 JVM 提供了强大的运行时反射能力，支持动态加载和卸载 Class 字节码，对于业务开发者来说，JVM 能够实现动态类的 AOP 或动态注入 DJ，这种能力在某些场景下尤其方便。举例来说，在金融或银行系统中，可以通过自定义 Class Loader 从 JDBC 获取字节码并动态加载，从而增强程序的安全性和保密性。Go 虽然也提供一定的反射能力，但总体上并不如 Java 灵活，设计一门编程语言，本质上就是一种权衡：语言在某些特性上取舍了便利性或性能，必然会在其他特性上有所牺牲，如何在性能、安全、易用性和灵活性之间保持平衡，是语言设计者面临的核心问题。许多人在决定入门一门新的编程语言或者为新项目选择技术栈时往往会陷入纠结，甚至最后讨论变成了“语言之争”。这种争论通常围绕一些抽象的概念展开，例如 “某某语言设计很优雅” “某某语言语法很糟糕” “这门语言不适合写大型系统” 等等，但这些讨论往往缺少关键的一点实践和实际的使用体验来做论点的有力支撑，或者哪些程序员就只会某门编程语言...我这篇文章是结合实际的开发遇到问题所编写而成，让读者明白不同设计之间差异在哪里，好根据自己情况做选择。
自从高中开始学编程已经使用过好几门（PL）编程语言了，目前主流最多使用的编程语言 C/C++ 、Java 、JavaScript 这些都属于结构式和命令式编程语言。但在 Java 在 8 版本发布之后引入了 Lambda 表达式方式对函数式编程的支持，从现代的 Java 设计角度上来说并不是一个纯粹的结构和命令式编程语言了，个人将其归属为一个综合性很强的多编程范式的语言。在过去一年的重新学习了基于 ES6 版本的 JavaScript 之后，让我对函数式编程语言更为感兴趣了。在此之前使用过 Go 语言的实现过一些类似于函数式编程的代码，但 Go 在语言设计的时候并没有添加三目运算符和 Lambda 表达式，使其在编程体验上逊色于 JavaScript 和 Java8 这类原生支持三目运算符和 Lambda 表达式的函数式语言，目前我已经从完全放弃了 Go 语言传统命令式进行编程了，除了几个极少历史遗留开源项目正在使用它进行维护之外。如果读者正在考虑深入学习一门 PL 编程语言的话，我建议学习并且使用现代 Java21 之后的版本，现代 Java 已经添加很多新特性，这篇文章我会介绍一下函数式编程的优势。面向过程程序员日常开发中使用的最多编程方式是面向过程和面向对象，面向对象 OOP 是从 C 语言的面向过程演变发展设计出来的；面向过程取决于程序员如何设计一个好的数据结构和用编写好的函数去操作数据完成逻辑计算，在面向过程编程代码逻辑过程中，函数是主要的组织单元，程序的执行是一系列的函数调用，使用结构体来组织数据，但是能操作这些结构体仍然是通过函数来进行的。[scode type="yellow"]编程范式最典型的例子，生活中一件事情抽象到计算机程序如何做？例如一个抽象的问题：如何把一头大象放到冰箱里面该如何做？换到人类的思维第一反应是怎么可能会有冰箱能装得下一头大象？在计算机里你只要想将具体逻辑告诉它，它能可以按照你逻辑去做某件事情，它很笨不会考虑这么多其他因素，不能去考虑大象是不是比冰箱大，除非在装填函数中编写了一些逻辑来检查是否合规。[/scode]下面就是使用 C 语言就编写一个 3 个函数把大象结构放到冰箱结构里，每个步骤对应着一个操作函数，面向过程的窜行代码：#include <stdio.h> #include <stdbool.h> // 定义大象的结构体 struct Elephant { char name[20]; int weight; }; // 定义冰箱的结构体 struct Refrigerator { bool isOpen; bool hasElephant; }; // 函数原型 void openRefrigerator(struct Refrigerator *fridge); void closeRefrigerator(struct Refrigerator *fridge); void placeElephantInRefrigerator(struct Elephant *elephant, struct Refrigerator *fridge); int main() { // 创建大象对象 struct Elephant elephant = {"大象", 5000}; // 创建冰箱对象 struct Refrigerator refrigerator = {false, false}; printf("将大象放到冰箱里面：\n"); // 打开冰箱 openRefrigerator(&refrigerator); // 将大象放到冰箱里 placeElephantInRefrigerator(&elephant, &refrigerator); // 关上冰箱 closeRefrigerator(&refrigerator); return 0; } // 打开冰箱的函数 void openRefrigerator(struct Refrigerator *fridge) { printf("打开冰箱门\n"); fridge->isOpen = true; } // 关闭冰箱的函数 void closeRefrigerator(struct Refrigerator *fridge) { printf("关闭冰箱门\n"); fridge->isOpen = false; } // 将大象放到冰箱里的函数 void placeElephantInRefrigerator(struct Elephant *elephant, struct Refrigerator *fridge) { if (fridge->isOpen) { printf("将 %s 放进冰箱\n", elephant->name); fridge->hasElephant = true; } else { printf("无法将大象放进冰箱，因为冰箱门没有打开\n"); } } [scode type="green"]面向过程重点就是数据和算法的组织，采用顺序执行函数的方式，通常会存在一些全局结构体变量，这些变量会在函数之间通过参数传递；面向过程编程解决问题是：如何设计解决问题的过程？ 这里的过程是指的是函数和函数之间的关系，将问题分解为一个一个小函数，而问题所需要的元数据信息则被抽象为了变量，变量可以传入到函数中执行改变其状态，一步一步顺序执行调用这些过程函数就可以解决问题。[/scode]面向对象典型的面向过程的编程语言是 C 语言，在很长一段历史时间内程序员唯一选择就是使用 C 语言进行面向过程进行程序设计，在复杂业务场景下没有更好的其他 PL 替代选择；只能把编程中所需要的数据变量简单的抽象，在不断的代码项目迭代过程中可能会代码的重用性和可维护性较差，编写的函数和结构体之间没有任何的关联，只能通过显示的函数实参进行调用，使得函数和具体结构体类型之间没有明确绑定关系，在需求不断变化过程中扩展能力差，不利于后期对代码结构进行扩展。直到后面 Smalltalk 、C++ 、Objective-C 、Java 等 Object-Oriented Programming 语言的出现给计算机编程领域加快发展，使得程序员可以用其他编程语言选择了。[scode type="yellow"]在面向对象编程中提倡的是程序员将有状态的变量和能修改变量状态的函数封装成一个单一对象，其对象被作为基本的程序操作单元，把变量和函数封装一个单独对象，通过调用对象上面的函数这里称之为对象的方法（method），通过方法来操作对象状态数据的变更。这些抽象的对象又可以彼此之间具有相互依赖性，对象可以包含另外一个对象，对象方法可以接受另外一个对象作为参数或者返回另一个对象，让程序员编程只需要关注对象之间关系，通过操作对象的实例来解决程序设计问题。[/scode]正如上图一辆汽车可以被抽象为一个 Class 名为 Car 基础类型，汽车的属性信息是可变的可以抽象为对应的对象属性字段，而汽车所应用基础功能可以抽象为功能方法。如果需要编程的过程中需要使用汽车的时候，就可以使用 Car 类型创建出来一辆自定义的汽车，每辆汽车可以具有不同的属性值，但都拥有相同的方法和功能。[scode type="green"]面向对象编程思想本质是现实世界中的事物进行抽象，通过抽象的表达来模拟现实世界中物理关系，此种编程方式更符合人类的直觉，编程过程中关注的是对象与对象之间的关系。对象是通过一个物抽象模版 Class 创建出来的实例，通过实例进行编程。在编程过程中程序员都是提倡如果一段代码被重复的使用，或者类似的代码不止出现于一次，可以考虑将其共同的特性抽象出来。这样对应了面向对象编程的 3 大特性：继承 、封装 、多态，笔者认为面向对象编程最终编程模式是面向接口 interface 编程，程序员更应该更关注是对象之间共同特性就其抽象出来，而不需要关注实现对象的数据结构和算法功能实现的细节，抽象的一个对象包含了数据和操作数据的函数。[/scode]函数编程在 OOP 编程风格中一个 Class 在程序运行中被实例化成为一个对象变量，变量对象中存储运行时所需要的数据信息，在运行时通过对象进行修改其内部属性的值，我们可以改变对象的状态，从而影响程序的行为。这在单线程编程环境很好的设计，但是在多线程环境未必很好设计，当程序在并行执行的时候会涉及到多线程的数据竞争问题，当多个线程同时访问和修改共享的对象状态时，可能会出现不确定的行为和错误的结果。在函数编程中提倡像数学中函数一样求值，强调函数的纯粹性、不可变性和无副作用性，避免修改可变状态和使用可变的数据。这个怎么理解呢？大家在编程中可能使用过类似于 int add(int x，int y){ ... } 这样的函数，该函数对传入的参数进行求值计算。这个 add(x，y) 函数体里面的逻辑就不会对外部的数据进行修改，而是进行某些数学计算得到另外一个结果，纯函数是指函数的输出只依赖于输入不会对外部状态造成影响，也不会产生副作用，即给定相同的输入永远得到相同的输出。下面是使用 C 语言编写一个纯函数例子：#include <stdio.h> // 定义一个纯函数 add，接受两个参数并返回它们的和 int add(int x, int y) { return x + y; } int main() { // 1. 使用 add 函数来执行整数相加的操作 // 2. 调用 add 函数并将返回值赋给 result int result = add(5, 3); // 3. 输出结果，输出结果：8 printf("%d\n", result); return 0; }在这段代码中声明一个 add(x,y) 纯函数，它接受两个参数并返回它们的和，不需要依赖外部的条件变量，而且不会改变任何外部状态。[scode type="yellow"]与传统指令式编程不同，函数编程是围绕着函数进行的，在传统的指令式编程范式中，程序通常被组织为一系列的语句和命令，这些命令会改变程序的状态和数据。而在函数编程中，程序的主要执行单元是函数，它们接受输入并产生输出，而不会改变外部状态或数据。函数之间可以被组合、嵌套和传递，从而实现复杂的逻辑和操作。[/scode]下面这段 C 语言代码就是使用函数式的方式抽象中缀表达式计算过程函数式编程，中缀表达式是数学和计算机科学中常见的表达式形式，其中运算符位于操作数之间。当中缀表达式被抽象为函数的时，每个函数就对应着每个运算符和具体计算步骤，总体整合就会形成一个函数调用嵌套，其结果和中缀表达式计算结果一致，抽象例子如下：#include <stdio.h> /* 数学表达式：(1 + 2) * 3 - 4 过程式和命令式： int add = 1 + 2; int mul = add * 3; int sub = mul - 4; 函数式： int result = subtract(multiply(add(1,2),3),4); */ int add(int x,int y); int mul(int x,int y); int sub(int x,int y); int add(int base,int n) { return base + n; } int mul(int base,int n) { return base * n; } int sub(int base,int n) { return base - n; } int main(){ int res = sub(mul(add(1,2),3),4); printf("(1 + 2) * 3 - 4 = %d",res); }const twoSum = (nums, target) => { const search = (index, complement, map) => { if (index >= nums.length) return []; if (map.has(complement)) return [map.get(complement), index]; map.set(nums[index], index); return search(index + 1, target - nums[index], map); }; return search(0, target - nums[0], new Map()); };
什么是结构化并发？日常开发中我们编写的最多就是多线程程序，服务器端应用更是如此，传统的方式都是依靠着操作系统提供的 1:1 线程方式进行请求处理这对于管理和复用线程有很多挑战，如果一个普通线程大小 8MB 那么开启 1000 个线程，几乎是无法完成的，并且管理这些线程的状态也是很复杂的。今天这篇文章要介绍的是结构化并发，就是为解决并发编程中线程并发任务管理，传统的方式非常容易造成管理混乱。结构化并发解决的问题就是对统一的任务和统一作用域下的任务进行管理，可以统一启动和统一关闭，如果读过我之前的 Linux 进程组那篇文章的话，就完全可以理解是什么意思了，文章地址：Linux 进程树。在了解结构并发编程范式之前得先讲讲编程语言流程控制发展史，了解一件事的全部应该是去了解完整的历史，并且要找到正确的资料和原版资料去了解，而不是已经修改几个版本的资料，让我们回顾编程语言的一些历史：早期如果想在计算机上写程序必须使用很低级的编程语言去写程序，例如汇编语言，通过一条一条硬件指令去操作计算机，并且顺序执行的，这种编写程序的方式真是令人头疼的。这就使一些计算机界大佬想去重新设计一些编程语言，当时一些美籍计算机科学家们 John Warner Backus 和 Grace Hopper 开发了 Fortran 和 FLOW-MATIC初代的编译命令式编程语言，最后在这些基础之上开发了商业通用编程 COBOL 语言。有趣的事情是世界上的第一个 Bug 也是 Grace Hopper 所发现的，当时的计算机(Harvard Mark II)体积还很大。当时这台计算机在运算的时候老是出现问题，但是经过排查编写的程序指令是没有问题的，最后发现原来是一只飞蛾意外飞入电脑内部的继电器而造成短路如下图所示，他们把这只飞蛾移除后便成功让电脑正常运作，这就是世界上第一个计算机程序BUG。早期的 FLOW-MATIC 是第一种使用类似英语的语句来表达操作的编程语言，会预先定义输入和输出文件和打印输出，分为输入文件、输出文件和高速打印机输出，下面是一段程序代码的例子：看完上面的实例，会发现和现在开发者所使用的更高级的 Java 或者 C 语言还是有一些差距的，例如没有函数代码块，没有条件控制语句，在 FLOW-MATIC 被推出的时候这些现在高级语言的特性还没有被发明出来，在当时看来 FLOW-MATIC 应该是能满足编写程序需求。设想一下如果和输入指令一条一条执行程序是不是很麻烦，如果不能复用一些以有编写逻辑那就要重新编写一些代码逻辑会很费时费力，所以 FLOW-MATIC 的设计者在语言加入了 GOTO 语句块， goto 可以让程序在执行的时候执行到了 goto 然后去执行指定位置的代码块，本质上还是非结构化编程，不过可以做到程序的代码复用和重执行， goto 的加入 FLOW-MATIC 之后如下程序执行流程图：FLOW-MATIC 执行语句通常都是顺序执行的，但是下面这种情况就会发生跳转操作，它可以直接将控制权转移到其他地方，例如下面从 8 行跳转到第 4 行。极少量的 goto 语句是很清晰的，但是令人头疼的问题是程序代码逻辑量变多了之后就会产生很多无法通过正常人类思维所理解的代码跳转逻辑，并且跟踪代码的逻辑很困难。这种基于跳转的编程风格是 FLOW-MATIC 几乎直接从汇编语言继承而来的。它功能强大，非常适合计算机硬件的实际工作方式，但直接使用它会非常混乱。像上面图片中的箭头箭头太多了，就发明 Spaghetti Code 一词的原因，代码逻辑存在各种飞线关系，揉成一坨的代码逻辑。显然我们开发者需要更好的流程控制设计，而不是让代码逻辑写出来像意大利面条一样。当然目前讨论的话题是编程语言的结构化编程设计问题，这个不是本篇文章的重点，本篇文章更偏向的是一些编程语言在线程并发状态转播和控制管理上的一些问题，下面正式开始正文内容。非结构化并发介绍了早期编程语言中的 goto 关键字，可以在当前的执行控制流中开一个分支去执行另外的操作，和我们现在在高级编程语言中使用的 Thread 差不多，例如下面代码：package main import ( "fmt" "time" ) func f(from string) { for i := 0; i < 3; i++ { go fmt.Println(from, ":", i) } } func main() { f("direct") go f("goroutine") go func(msg string) { fmt.Println(msg) }("going") time.Sleep(2 * time.Second) fmt.Println("done") }在线运行代码地址： https://go.dev/play/p/wQ7Yz9mxXlu在这个例子中我使用的是 Go 语言的 goroutine 为例，在 Go 语言中想启动一个协程就可以使用 go 关键字，这和上面我们讨论的 goto 语句很接近，会从主控制流中分离出另一个代码逻辑执行分支，流程如下图：当然在 Go 语言中是保留 goto 跳转语句块的，例如下面这行代码就是 Go 中的 goto 语句块：package main import "fmt" func main() { /* 定义局部变量 */ var a int = 10 /* 循环 */ LOOP: for a < 20 { if a == 15 { /* 跳过迭代 */ a = a + 1 goto LOOP } fmt.Printf("a的值为 : %d\n", a) a++ } }在这个例子中 goto 代替了传统的 break 关键字的作用，那个例子准确来说应该是说类似于 continue 作用，看怎么用了，这里不接受任何反驳。直接跳过满足 a==15 的逻辑块。这就是目前高级语言中的跳转应用，当前这种还是在主程序流上运行的指令的，于 Go 语言中的 go func(){} 关键字去跑起一个协程做并行任务处理是完全不一样的，为此我特定花了一张图来比较两者的关系，如下：像上面这样的通过 go 关键字启动的协程就和一个不透明的盒子一样，你不知道被启动代码块里面是否还有 go 关键字启动其他协程，递归启动协程是一件很难控制的事件，这就和 MapReduce 思想很像，最终还是要汇总的每个协程中产生的数据和控制协程状态的，如下图：像上面这幅图中如果里面的每个圆圈 ⭕️ 都代表着一个正在并行处理任务的协程，我们要如何管理这些协程状态呢？当然 Go 语言在设计的时候就引入了 channel 概念，我们开发者可以显示将 channel 提供代码的方式嵌入到每个要执行协程任务代码块中；早期的 Go 版本中为了控制协程中的协程状态是直接嵌入 channel 然后再每个协程内部编写具体状态控制代码，如果上级发送了通知那么此协程会做出相应的动作，这是初步的 Go 版本状态控制。在最新 Go 语言设计的版本中为了管理这些协程，在语言默认标准库中通过了 context 包所提供功能来做并行协程上下文通讯和状态同步：package main import ( "context" "fmt" "time" ) func doSomething(ctx context.Context) { ctx, cancelCtx := context.WithCancel(ctx) printCh := make(chan int) go doAnother(ctx, printCh) for num := 1; num <= 3; num++ { printCh <- num } cancelCtx() time.Sleep(100 * time.Millisecond) fmt.Printf("doSomething: finished\n") } func doAnother(ctx context.Context, printCh <-chan int) { for { select { case <-ctx.Done(): if err := ctx.Err(); err != nil { fmt.Printf("doAnother err: %s\n", err) } fmt.Printf("doAnother: finished\n") return case num := <-printCh: fmt.Printf("doAnother: %d\n", num) } } }本示例代码在线地址：How To Use Contexts in Go关于结构化并发在 Go 语言中一些问题上面是我个人见解，还有一些关于 Go 中的结构化并发讨论的文章可以查看这篇文章：Go statement considered harmful，在这篇文章里面作者对现有的Go语言协程设计抛出很多观点值得一读。结构化并发设计在上面我介绍了一些关于非结构化并发的程序设计问题，如果单独创建协程没有做好错误处理或者异常情况下的处理，可能就会出现协程泄露问题，这就是本节要讲的结构化并发来做的并发控制设计。[scode type="yellow"]其结构化并发核心设计原则是：可以通过代码明确并发程序任务集的入口点和出口点，并确保所有衍生线程在退出之前完成的控制流构造来封装并发执行线程，能将子线程发生的错误传播到控制结构到父范围上，并且达到无子线程存在泄漏问题。[/scode]这里我会拿我目前还稍微熟悉一点的 Java 语言举例，例如在 Java19 中添加的结构体并发特性，所采用的线程控制就是结构化并发的应用，如下的示例代码：void serve(ServerSocket serverSocket) throws IOException, InterruptedException { try (var scope = new StructuredTaskScope<Void>()) { try { while (true) { var socket = serverSocket.accept(); scope.fork(() -> handle(socket)); } } finally { // If there's been an error or we're interrupted, we stop accepting scope.shutdown(); // Close all active connections scope.join(); } } }上面代码的逻辑就是一个简单的 socket 处理逻辑，采用的就是结构化并发，可以看到 finally 里面的异常处理逻辑和 scope 任务线程块，当然这些内容在 Oracle 公司的 Open JDK 设计草案里面就有地址如下：https://openjdk.org/jeps/428，我只是对这篇内容做了导读和个人见解分享，当然这里我拿几个语言作为例子不是为了讨论谁好谁坏，而是从语言设计角度来看每个不同语言面对这些问题是怎么解决的，瑕瑜互见。小结我个人认为结构化并发是未来的并发和并行程序设计方向，现在有结构化并发程序设计的语言 Kotlin 、Java 、 Swift 等，Rust 语言中也有这方面相关第三方实现目前还不够完善。由此可见通过作用域定义了主协程的子协程的生命周期和关系，事实证明，这一原则在协程中实施了层次结构。如果协程需要为自己创建子协程，那完全没问题，就像您如何将if语句嵌套在一起并理解分支如何嵌套一样，协程也可以嵌套，最顶级的协程不仅取决于他们的孩子完成，还取决于他们孩子的孩子，这就是一个多叉树型的结构，更多相关研究还要靠着开发者们一起探索，我下面给出一些这方面领先的技术文章分享链接。其他资料Go statement considered harmfulProject Loom And Structured ConcurrencySwiftUI and Structured ConcurrencySwift 结构化并发
阅读本篇文章你需要有编程逻辑的基础，本篇文章将使用 Rust 编程语言的基本的条件控制语句和随机数生成语句来实现一个两位数的基础数学加法计算命令行小游戏程序。这可能是 The Book 原书中的 猜数字 的演进版本，Rust 程序会生成两个数的数字，用户自己则计算答案输入进去，然后由程序来判题。准备工作在编写程序之前你的电脑必须先安装好 Rust 的基础开发环境，并且有 Cargo 支持，如果你是其他编程语言转过来的例如 C、 Java 、Python 、JavaScript 、Swift 等，此程序将会很简单不涉及复杂的操作。首先明白什么控制台输出、什么变量、知道什么是数学中的四则运算，和编程中条件控制语句和循环语句，这是看得懂本程序的前提，这个程序很简单大概 100 行代码左右，我很劲量让小白也能看得懂，整体的逻辑图如下：在编写程序之前我们需要使用 cargo 创建一个名字为 math_game 的项目，使用命令如下：$: cargo new math_game Created binary (application) `math_game` package到此创建完成了，打开项目目录下面的 src/main.rs 源文件并且开始编写程序主体逻辑代码。主体逻辑首先我们要定义几个全局的基础变量，这些变量是帮助为我们来控制程序主体的逻辑的变量，具体的变量的作用已经在上方的注释中说明了。值得注意的是 Rust 中默认声明一个变量是不可变的，如果想在运行的过程中改变其值需要再变量定义的时候使用 mut 关键字，mut 关键字对应着 Mutable 的意思，如下：fn main() { // 当前回合数 let mut count = 0; // 需要几轮游戏 let rounds = 10; // 用了存储分数 let mut score = 0; // 生成两个随机数 默认值都为 0 let mut addend: u32 = 0; let mut adding: u32 = 0; }接下来我们要定义两个函数，分别来处理随机数生成和并将随机数转成数学公式展示给用户，下面定义一个函数，在 Rust 中定义一个函数要使用 fn 关键字。在 Rust 因为特殊的内存管理方式，所有权规则，默认在函数之间传递变量可能互导致变量的所有权被转移，所以这里在函数签名中采用的是可变借用的方式，具体什么是所有权规则和借用规则这不是本篇文章的重点，对应 Rust 初学者来说应该关注着怎么写一个完整的小程序然后慢慢深入，函数体如下：fn next_math(addend: &mut u32, adding: &mut u32) -> u32 { *addend = 0; *adding = 0; // 直接返回2个数的和方便结果判断 *addend + *adding }上面定义一个名为 next_math 的函数，参数列表分别为外部传入的是存储被加数和加数的变量，返回值则是这两个数的之和。目前具体的随机数生成逻辑还没有写，如果需要随机数生成这时我们就使用第三方的 Crates，什么是 Crate ？Crate 可以认为是第三方开发者实现的一些类库，类似于 Java 中的 jar 包，而 Crates 则是一个存放这些工具库的中心仓库，类似于 Java 中央创库 Maven。使用第三方的 rand 库只需要在项目的根目录中 cargo.toml 添加对应的依赖名称：[dependencies] rand = "0.8.3"添加完成之后，需要使用 cargo update 更新一下项目，更新完成之后如果没有错误，即可去代码中完善 next_math 函数的逻辑，如下：fn next_math(addend: &mut u32, adding: &mut u32) -> u32 { *addend = rand::thread_rng().gen_range(1..=100); *adding = rand::thread_rng().gen_range(1..=100); // 直接返回2个数的和方便结果判断 *addend + *adding }在函数中使用 rand::thread_rng().gen_range(1..=100) 即可生成 1 至 100 之间自然数，如果需要生成更大的数字只需要调整范围即可，最后如果不写 return 表示最后一行的结果作为返回值，返回值类型为 u32 类型，这里返回值充当着两数之和并且返回给上层调用程序使用。接下来要编写一个提问函数 question 向用户展示计算公式要求计算公式，传入刚才通过 next_math 函数生成的数字，并且传入题目的索引编号，如下：// 向用户展示计算公式要求计算公式 fn question(addend: &mut u32, adding: &mut u32, index: &mut u32) { println!("🤔: 第{index}题为 {addend} + {adding} = ？ 请输入正确结果？", index = *index + 1, addend = addend, adding = adding); }这里我们使用了 println! 这是一个 Rust 内置的宏，可以将字符串格式化输出，在前段字符串中的 {} 则为后面所需要传入的变量名称占位符。接下来我们可以编写程序主体逻辑了，要让用户不断输入信息，必须使用循环控制语句来编写，在 Rust 中有多种循环语句，这里我们使用的是 while 循环语句，当条件满足时会进入循环体执行里面逻辑，当不满足时则跳出了循环体执行剩下的代码逻辑，代码逻辑如下：use std::cmp::Ordering; use rand::Rng; use std::io; fn main() { // 当前回合数 let mut count = 0; // 需要几轮游戏 let rounds = 10; // 用了存储分数 let mut score = 0; // 生成两个随机数 默认值都为 0 let mut addend: u32 = 0; let mut adding: u32 = 0; println!("😄: 让我们开始游戏吧！ 一轮游戏为10个回合，每题10分，总分100分！"); // 这里我们使用 while 制造一个循环 while count < rounds { // 开始生成题目并且出题，将随机数加起来计算总和数 let sum = next_math(&mut addend, &mut adding); // 向控制输出数学问题信息 question(&mut addend, &mut adding, &mut count); // 用来存储用户控制台输入的变量 let mut guess = String::new(); // 从控制台上读入输入字符串 io::stdin() .read_line(&mut guess) .expect("😠: 你能不能好好输！请输入正数！"); // 解析输入的值 let guess: u32 = match guess.trim().parse() { Ok(num) => num, // 如果非法设置默认值 Err(_) => 0, }; match guess.cmp(&sum) { Ordering::Less => { println!("❌: 你的答案太小了！正确答案是 {sum}！", sum = sum); } Ordering::Greater => { println!("❌: 你的答案太大了！正确答案是 {sum}！", sum = sum); } Ordering::Equal => { score += 10; println!("✅: 恭喜你！答案正确！加10分！"); } }; // 添加轮数将其加一 count += 1; } println!("🥳: 本轮游戏结束！你的分数为 {score} ！{exp}", score = score, exp = if score >= 60 { "成绩合格！" } else { "成绩不合格！" }) } fn next_math(addend: &mut u32, adding: &mut u32) -> u32 { *addend = rand::thread_rng().gen_range(1..=100); *adding = rand::thread_rng().gen_range(1..=100); // 直接返回2个数的和方便结果判断 *addend + *adding } // 向用户展示计算公式要求计算结构 fn question(addend: &mut u32, adding: &mut u32, index: &mut u32) { println!("🤔: 第{index}题为 {addend} + {adding} = ？ 请输入正确结果？", index = *index + 1, addend = addend, adding = adding); }这里跟要关注的是主体代码逻辑里面函数作用，要让用户输入信息那就必须使用系统调用从控制台中读入输入的数据，使用两段代码逻辑完成，如下： // 用来存储用户控制台输入的变量 let mut guess = String::new(); // 从控制台上读入输入字符串 io::stdin() .read_line(&mut guess) .expect("😠: 你能不能好好输！请输入正数！");第一段中的 guess 为用来存储临时用户输入的字符串信息，第二段逻辑则是通过标准库中的 io 包从标准输入中读入一行输入数据，而 expect 则防止用户输入的信息是非法，如果非法程序就提示该错误信息，非常容易理解。 // 解析输入的值 let guess: u32 = match guess.trim().parse() { Ok(num) => num, // 如果非法设置默认值 Err(_) => 0, }; match guess.cmp(&sum) { Ordering::Less => { println!("❌: 你的答案太小了！正确答案是 {sum}！", sum = sum); } Ordering::Greater => { println!("❌: 你的答案太大了！正确答案是 {sum}！", sum = sum); } Ordering::Equal => { score += 10; println!("✅: 恭喜你！答案正确！加10分！"); } };最后我们得到用户输入的字符串，这时我们需要将字符串转成数字，将字符串转成数字的方式有很多种，这里使用的具有 Rust 特性的模式匹配的方式，如果字符串转换成功将执行 Ok() 返回正常的数字，而如果字符串不能成功转换成数字则会走 Err(_) ，这里如果转换错误就使用默认值 0 作为用户输入的结果。最后我们再次使用了模式匹配，将用户输入的结果和计算机计算的结果进行比对，在程序的头部我们增加了另一个 use 声明，从标准库引入了一个叫做 std::cmp::Ordering 的类型到作用域中。 Ordering 也是一个枚举，不过它的成员是 Less、Greater 和 Equal。这是比较两个值时可能出现的三种结果，接着，底部的五行新代码使用了 Ordering 类型，cmp 方法用来比较两个值并可以在任何可比较的值上调用。它获取一个被比较值的引用：这里是把 guess 与 sum 做比较。 然后它会返回一个刚才通过 use 引入作用域的 Ordering 枚举的成员。使用一个 match 表达式，根据对 guess 和 sum 调用 cmp 返回的 Ordering 成员来决定接下来做什么。有了上面的解释就很容易明白这段代码的逻辑了，用户输入正确的答案就将分数加 10 分，如果不正常可以告诉时候离准确的答案的差值或者大小，这里的使用的大小，最后完成整个程序的逻辑并且将游戏轮数加 1 回合，当循环体执行完成打印分数信息并且展示到控制台中。 println!( "🥳: 本轮游戏结束！你的分数为 {score} ！{exp}", score = score, exp = if score >= 60 { "成绩合格！" } else { "成绩不合格！" } )因为 Rust 没有三目表达式，这里我采用的是 if 语句块来控制具体的分数合格信息的输出。小 结至此我们就完成一个 Rust 小游戏的编写工作，代码其实一点都不复杂，主要是将思路转换成具体代码实现，在线运行此小程序地址 https://github.com/auula/math_game/blob/master/src/main.rs
协程这一特性在很多编程语言中都有相关的实现，例如 Java 的 Project Loom 项目为 Java 支持了协程，还有一些其他编程语言例如提倡 CSP 理论的 Go 语言，还有 Rust 语言采用的异步的方式来做的，这些都属于协程范畴了，但是有人知道无栈协程和有栈协程吗？协程作为弥补传统平台线程的不足之处被发明出来的，传统平台线程每个可能需要 ulimit -s 来查看，假设默认创建线程为线程指定栈大小为 8MB 左右，协程就相当于线程来说就要小得多了，例如协程栈为 2KB 大小这就使得协程更加轻量级。在操作系统上程序大部分都是平台线程运行中，线程最 CPU 最小基本调度单位，学过操作系统都知道如果想要支持多个线程在 1:1 模式下并行操作或者出现了系统调用阻塞的状态，就会触发线程 TCB 上下文切换，如果频繁这样操作会导致性能下降，本篇将从程序内存调用布局下来看看是如何受影响的。函数调用栈在计算机中每个程序在运行的时候都需要一块儿内存区域，而这块称之为为堆栈内存，例如下面这段 C 语言程序：#include <stdio.h> int square(int num) { return num * num; } int main() { int x = 5; int result = square(x); printf("%d\n",result); }下面则为上面 C 语言代码转换成的汇编指令代码，这段 C 语言代码的逻辑很简单就编写一个计算平方的函数 square，当我们运行程序就会产生函数调用栈，如下：// x86-64 gcc 12.2 square: push rbp mov rbp, rsp mov DWORD PTR [rbp-4], edi mov eax, DWORD PTR [rbp-4] imul eax, eax pop rbp ret .LC0: .string "%d\n" main: push rbp mov rbp, rsp sub rsp, 16 mov DWORD PTR [rbp-4], 5 mov eax, DWORD PTR [rbp-4] mov edi, eax call square mov DWORD PTR [rbp-8], eax mov eax, DWORD PTR [rbp-8] mov esi, eax mov edi, OFFSET FLAT:.LC0 mov eax, 0 call printf mov eax, 0 leave ret在计算机处理器运行程序的时候会将其加入到内存中，从逻辑来说存储器就是一个连续的字节数组，并且可以通过索引地址访问这个位置上的数据的值，启始为是从 0 开始的，对于一个程序来说有多条机器指令组成的，在不同的字节位置上。至于上面的汇编指令略知一二即可，笔者也不是专业编译器开发人员，所以也不做更多介绍，怕误人子弟。 主要介绍的一个程序运行过程，函数的栈帧变化过程。CPU 是一条一条指令运行我们编写的逻辑的代码的，我们虽然写的都是人类能理解的代码，但是经过编译之后会生成机器码存储在磁盘上，开发者编写的都是一些文本文件，而 C 语言编译生成可执行文件，当然这里不排除一些脚本语言例如 Python 只需要被解释器加载执行。不管源文本还是编译好的可执行文件，当运行起来之后就产生一个进程，进程里面包含上一段文字中描述的内容，而栈空间是由操作系统维护的，进程运行中可能会产生零时的变量，目些变量的内存可能会被分配到堆中，而栈则存储这些变量指针引用，每次调用一次函数都会产生一个 Stack Frame，多个栈帧组成了整个程序运行状态，整个过程我画了一张图：需要注意在计算机里的栈从顶部向下增长的，而堆则是向上增长的，这里我为了更直观画的更像普通的栈。通过上面的例子可以看出一个调用栈的栈帧被使用完成之间会被释放，而 有栈 和 无栈 的含义不是指协程在运行时是否需要栈，而是指协程是否可以在其任意嵌套函数中被挂起，此处的嵌套函数读者可以理解为子匿名函数，显然有栈协程是可以的，而无栈协程则不可以。有栈协程有栈协程的实现会当协程发生上下文切换时可以保存当前栈帧上的一些寄存器数据信息，恢复协程的时候会将之前保存信息恢复写入到对应的栈帧和寄存器，而上下文切换过程就是保存和恢复，和普通线程切换类似。有栈协程特点就是不管在什么位置都可以挂起。基本的原理就是，在运行协程时候会申请一段能存储上下文的内存空间，用来保存上下文信息，这段内存就充当为协程运行时的栈帧空间。但是一个问题就是如何申请合理的内存，如果内存小了会爆栈，而大了又有些浪费，这里 Go 语言早期的版本实现中用的是分段栈，后面新版本又改成连续栈，当内存不够时会扩容将其复制到新的内存中，可以阅读之前的这篇文章Synchronization Primitives。无栈协程无栈协程无栈协程在不改变函数调用栈的情况下，采用类似生成器（generator）的思路实现了上下文切换，使用起来和同步编写逻辑类似，但是程序会通过异步的方式来完成相关的操作，这里典型的例子就是 Rust 语言中 async 修饰的函数体，至于详细的介绍可以查看下面我给出的阅读链接。小 结程是一种用户态线程，不同于操作系统线程，协程可以在一个或多个线程中调度，但不需要切换到内核空间，因此其上下文切换代价较低，可以有效提高并发性能，有栈协程和无栈协程是协程实现中的两种不同的方式：有栈协程（Stackful Coroutine）： 有栈协程指的是协程的调用栈是由程序员显式分配的，类似于线程，协程在创建时需要分配一块内存用于存储其调用栈，因此在切换协程时需要保存和恢复整个调用栈。有栈协程的优点是灵活性较高，可以直接访问协程的调用栈，方便协程的调试和跟踪。但由于每个协程需要独立的调用栈，因此协程的创建和销毁代价较高。无栈协程（Stackless Coroutine）： 无栈协程指的是协程的调用栈是由运行时系统隐式管理的，协程本身不持有自己的调用栈，因此在切换协程时不需要保存和恢复整个调用栈，仅需要保存和恢复协程的执行状态即可。无栈协程的优点是创建和销毁代价较低，可以实现更高的并发性能。但由于协程的调用栈由运行时系统管理，因此无法直接访问协程的调用栈，不利于协程的调试和跟踪。总的来说，有栈协程和无栈协程各有优缺点，选择哪种实现方式应该根据具体应用场景和性能需求进行选择。其他资料有栈协程与无栈协程初识协程有栈协程 vs. 无栈协程CPU眼里的：函数调用CPU眼里的：{函数括号} | 栈帧 | 堆栈 | 栈变量C程序在内存中的栈单线程的JavaScript是如何实现异步的