在互联网上经常可以看到程序员因为编程语言的选择而争论不休,常常出现类似 “XXX vs XXXX” 的评测文章,同时也不排除一些语言布道师或商业公司在背后炒作某种语言的热度。大多数文章的评测重点通常是程序的执行效率和内存占用,很少从语言特性、语法或语义的角度进行深入分析和讲解,我这篇文章将从这个角度来讨论 Go 语言设计丑陋之处。

在我编程生涯中也就使用过很多门编程语言,这其中就包括所谓的大道之间的 Go 语言,总体来说使用 Go 语言来编写逻辑是非常容易的,天生支持协程,采用 CSP 和 GMP 模型的高并发的语言,使得开发高并发应用也非常简单。它的语法简单和 25 关键字非常容易掌握,能使程序员快速掌握并且编写代码逻辑,并不意味着 Go 的设计有多么好。反而在我使用 Go 编程过程中出现很多感觉很丑陋的设计,而不像国内某些 Go 语言布道师吹崇它多么多么好,我个人感觉哪些很多布道师从 PHP 转换过来的,就没有写过其他高级的编程语言,例如 Kotlin 和 C# 、Rust 或者新版本的 Java 等... 如果读者是 Go 语言的粉丝,这篇文章的内容会让你感到非常不适,乃至会让你感到作呕。


错误处理

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 ... 白白的创建一个并且运行新的栈帧...


return 和 defer 冲突

在多数编程语言 return 作用都是用来结束栈帧,结束函数的执行,并且如果函数签名中有返回值,那么还要顺带着返回值一起返回,但在 Go 语言还设计了 defer 关键字来辅助 return 来完成一些辅助清理工作,defer 关键字是用来延迟执行某个函数用途,并且保证在函数执行栈出现异常或者 return 时,能正常执行某个预定函数操作。

在结合 defer 场景下就会出现问题,为了防止文件句柄打开忘记关闭情况下,在打开文件操作的代码逻辑后面就会紧跟上 defer 的操作,但 defer 并不能处理这个函数的调用返回值,returndefer 关键字的在关闭文件描述时发生干扰,例如下面的代码:

  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 的情况出现异常手动解锁,同样是一个很难用的设计。

这里举几个其他语言的例子,例如 Zig 语言的 RAII 风格的设计,锁和 defer 范围设计就相对好一些,代码如下:

const std = @import("std");

pub fn main() !void {
    var mutex = std.Thread.Mutex.init();
    defer mutex.deinit();

    {
        const guard = try mutex.lock(); // 自动加锁
        // critical section 开始
        std.debug.print("Inside lock scope\n", .{});
    } // guard 离开作用域,自动 unlock
}

Zig 的设计锁的范围由 guard 的作用域控制,离开作用域自动解锁,无需手动调用 unlock 这样就避免了避免忘记解锁或异常提前返回导致死锁情况。

另外一种就是 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) {
        try (MyResource r = new MyResource()) {
            r.doWork();
        }
    }
}

没有方法重载入,还有三目表达式,没有模式匹配,内置 init 函数和自定义 init 互相冲突并且自定义 var x = func() {}() 执行顺序和内置 init 函数执行顺序冲突,函数式编程没有 lambda 表达式的支持,范型不支持协变和逆变,标识符、Struct、变量、字段的访问控制和 JSON 正反序列化矛盾的冲突。另外构造器随意创建,不支持函数重载导致需要使用魔法编程可变参数进行实现类似于其他有重载功能语言,这对于一个设计友好 API 的人来说确实需要;另外接口太小,名称统一会导致被很多其他拥有相同字段接口所隐式的实现。


协程泄漏和协程错误传播


访问控制与构造器封装

在编写项目中最常见的就是把基础标量类型封装为一个 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 方法,这种方式要额外的编写正反序列化的代码实现,另外就是无法被反射编程动态修改其值。


目前个人感觉 Go 优点只是能静态编译,将程序源代码直接通过编译,编译为 Native Code 直接在计算机上运行,有着较快的冷启动和低内存占用特性。静态编译在 Cloud Native 场景下确实合适,并不意味着它能和其他基于 VM 的语言相比,例如 JVM 可以在运行时动态反射编程,Go 虽然也有,但我觉得并没有能媲美 Java 一样反射能力,毕竟写业务代码 JVM 可以支持动态类的 AOP 和 DJ 编程语言才是最爽的。

另外有人可能看到这里要和我抬扛 Go 语言不需要类似于 JVM 这样的形式来运行程序,Java 和 Go 在设计的时候存在着本质上的区别,JVM 是语言平台,基于 JVM 去实现的编程语言,你只需要实现将源代码的 AST 转换为 JVM 字节码就行了,至于内存管理和垃圾回收工作全部由 JVM 来完成,这对于想要去实现一门编程语言的人来说只需要关注于 JVM 编译器前端,对应着 AST => ByteCode 的过程,无需关系其他的实现,标准库可以服用 JRE 里面的 API 来完成。

许多人在决定入门一门新的编程语言或者为新项目选择技术栈时往往会陷入纠结,甚至最后讨论变成了“语言之争”。这种争论通常围绕一些抽象的概念展开,例如 “某某语言设计很优雅” “某某语言语法很糟糕” “这门语言不适合写大型系统” 等等,但这些讨论往往缺少关键的一点实践和实际的使用体验来做论点的有力支撑,或者哪些程序员就只会某门编程语言...我这篇文章是结合实际的开发遇到问题所编写而成,让读者明白不同设计之间差异在哪里,好根据自己情况做选择。

便宜 VPS vultr
最后修改:2025 年 11 月 21 日
如果觉得我的文章对你有用,请随意赞赏 🌹 谢谢 !