Leon Ding Blog - Rust编程
type: channel
link: https://blog.leonding.me/category/rust-language.html
description: 收录关于 rust 编程语言相关的文章
items: Go 语言设计丑陋之处
items: 函数式编程的思想和优势
items: 结构化并发程序设计
items: Rust 实现基础数学加法小游戏
items: 有栈协程和无栈协程

Go 语言设计丑陋之处
type: item
date: 2024-11-03T02:24:00+08:00
description: 在互联网上经常可以看到程序员因为编程语言的选择而争论不休，常常出现类似 “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 作为上下文，传递出去方便在出错时定位到问题。[s…

函数式编程的思想和优势
type: item
date: 2024-04-15T01:37:00+08:00
description: 自从高中开始学编程已经使用过好几门（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", …

结构化并发程序设计
type: item
date: 2023-03-08T01:21:00+08:00
description: 什么是结构化并发？日常开发中我们编写的最多就是多线程程序，服务器端应用更是如此，传统的方式都是依靠着操作系统提供的 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.d…

Rust 实现基础数学加法小游戏
type: item
date: 2022-12-13T16:28:00+08:00
description: 阅读本篇文章你需要有编程逻辑的基础，本篇文章将使用 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 ques…

有栈协程和无栈协程
type: item
date: 2022-10-09T15:15:00+08:00
description: 协程这一特性在很多编程语言中都有相关的实现，例如 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 语言早期的版本实现中用的是分段栈，后面新版本又改成连续栈，当内存不够时会扩容将其复制到新的内存中，可以阅读之前的这篇文章Sy…