在互联网上经常可以看到程序员因为编程语言的选择而争论不休,常常出现类似 “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 作为上下文,传递出去方便在出错时定位到问题。
return err ... 白白的创建一个并且运行新的栈帧...
这里列举一些其他编程语言我认可的设计例子,例如 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() 这种函数级链式,但每一步的错误传播非常干净。
? + Result/Option 抽象,是我目前使用过语言里,对链式调用错误传播综合体验最好的,Rust 代码示例如下:
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 的进行判断,就很容易在访问这些属性时导致程序崩溃或报错,不能达到预期执行路径的结果。
?. 使用此运算符访问的对象或调用的函数是 null 或 undefined 则表达式会短路并计算为 undefined 而不是抛出错误。可选链运算符和 ?? 配合一起使用就能实现预定义默认值,从而安全地处理可能缺失的返回值,并使用预先定义的默认值保证程序能够正常运行,这是我认为最好的 PL 语法糖设计。
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();
}errdefer 关键字,与 defer 在函数退出时无论正常还是出错都执行不同,errdefer 仅在函数出错返回时触发,用于回滚、解锁或释放资源,而正常执行路径可以选择提前释放或交由调用方管理,例如上面 Go 语言手动错误分支解锁的逻辑,使用 Zig errdefer 关键字代码如下:
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 内部的状态变更和错误如何传播到上层,会导致协程内部错误被丢弃乃至不处理,最后会出现协程泄漏。
channel 结构和 context 包。然而在实际使用 goroutine 处理异步并发任务时,Go 语言并未在语法或类型系统层面强制要求传入 channel 或 context 来管理 goroutine 及其子 goroutine 的生命周期,这使得并发结构的正确性完全依赖于开发者的经验和良好编码习惯。
一段典型使用 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 {
...
}equeue 缓冲区已满而导致某个 goroutine 阻塞,进而引发 goroutine 泄漏或整体流程无法正常退出。
在当前工业级实践中,最成熟、最系统化的方案来自新版本的 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
}struct 中的字段都是以小写开头,导致如果需要对此 Node {} 这个结构体进行 JSON 正反序列化,就会出现无法进行序列化的情况,原因就是因为上面的需求导致的,导致一个很矛盾的问题。要解决这个序列化问题,可以让要被序列化的 struct 实现自定义的 MarshalJSON 方法,这种方式要额外的编写正反序列化的代码实现,另外就是无法被反射编程动态修改其值。
封装和扩展限制
final 、sealed 密封类。
目前我个人认为 Go 的优点在于语法简单甚至略显简陋,同时保留了指针的特性。Go 的源代码可以直接编译为原生机器码,能够在计算机上直接运行,这带来了快速冷启动和低内存占用的优势,在 Cloud Native 场景下,这种静态编译特性确实非常适合。然而这并不意味着 Go 在所有方面都能与基于 VM 的语言相比。例如 JVM 提供了强大的运行时反射能力,支持动态加载和卸载 Class 字节码,对于业务开发者来说,JVM 能够实现动态类的 AOP 或动态注入 DJ,这种能力在某些场景下尤其方便。举例来说,在金融或银行系统中,可以通过自定义 Class Loader 从 JDBC 获取字节码并动态加载,从而增强程序的安全性和保密性。
Go 虽然也提供一定的反射能力,但总体上并不如 Java 灵活,设计一门编程语言,本质上就是一种权衡:语言在某些特性上取舍了便利性或性能,必然会在其他特性上有所牺牲,如何在性能、安全、易用性和灵活性之间保持平衡,是语言设计者面临的核心问题。
许多人在决定入门一门新的编程语言或者为新项目选择技术栈时往往会陷入纠结,甚至最后讨论变成了“语言之争”。这种争论通常围绕一些抽象的概念展开,例如 “某某语言设计很优雅” “某某语言语法很糟糕” “这门语言不适合写大型系统” 等等,但这些讨论往往缺少关键的一点实践和实际的使用体验来做论点的有力支撑,或者哪些程序员就只会某门编程语言...我这篇文章是结合实际的开发遇到问题所编写而成,让读者明白不同设计之间差异在哪里,好根据自己情况做选择。