之前有段时间,我对Rust特别着迷,满心欢喜地学了一阵子。可后来因为实在太忙,又找不到合适的使用场景,就暂时放下了。最近在梳理今年的学习目标时,重新学习一门新语言这个想法又冒了出来。了解到Zig之后,我发现它的不少特性很吸引人,像语法简洁、能和C语言无缝交互,还能编译成单体包等等。虽然它在内存安全方面有所欠缺,但这些优点还是让我心动不已。于是,我找了不少资料,想深入对比一下Rust和Zig,看看该怎么选。在reddit上,我发现了一篇写得很不错的关于两者对比的博客,下面就和大家分享一下我的收获。

一、从软件可靠性看Rust和Zig的差异

在软件开发中,我们都希望写出没有缺陷的程序,但实际上,完全没有缺陷的程序几乎不存在,大多数程序即便有缺陷,也能正常运行。比如说,很多程序都会用到堆内存,可很少有程序能清楚地知道自己的堆使用情况以及剩余空间。调用malloc分配内存时,我们往往只是假设堆空间足够,基本不会去检查。同样,Rust程序在遇到内存不足(OOM)的情况时,也会直接中止,而且没办法预先声明内存需求。所以说,我们真正追求的,是在程序的实用性和开发成本之间找到一个平衡点。为了达到这个平衡,有两种常见的工程风格。

(一)Erlang风格:容忍缺陷,设计容错程序

这种风格承认软硬件存在不可靠性,在设计程序时就考虑到如何容错。rust – analyzer就是这类风格的典型例子。它作为Rust的LSP服务器,需要具备很强的扩展性,因为优秀的开发工具得满足各种小众的使用场景。而且它是个快速迭代的开源项目,要紧跟rustc编译器的更新步伐。对于IDE工具来说,可用性比绝对的正确性更重要。比如,一个错误的代码补全建议可能不会引起太多注意,但要是服务器崩溃导致语法高亮都没了,用户肯定马上就能发现。所以,rust – analyzer在设计上就接受软件可能存在缺陷这一事实。它把所有功能在运行时都严格隔离开,这样单个功能崩溃了也不会影响整个进程。并且,它的代码中很少有能接触可变状态的部分,这样catch_unwind就不会导致状态污染。在开发流程上也是如此,新功能的PR只要核心场景能正常运行就会被合并,就算有些边缘用例可能会导致崩溃也没关系。因为修复一个可复现的独立功能缺陷,常常是开发者深入参与项目的好机会。而且,严格的周更发布机制和nightly版本能让修复快速推送给用户。总的来说,这种风格就是先专注主流场景,发挥最大价值,边缘案例慢慢再完善。

(二)SQLite风格:严格工程,克服环境不可靠性

与Erlang风格相反,这种风格通过严格的工程手段来克服不可靠的环境。TigerBeetle就是很好的例子。它是一个采用分布式架构的数据库,实现的是复式记账功能。它有六个在地理和运维上相互隔离的副本,通过消息交换来保证以相同顺序处理交易。这是个非常复杂的问题,毕竟要在允许机器故障的情况下保证数据处理的一致性,所以它采用了非拜占庭式共识算法。传统的共识算法通常假设存储是可靠的,可现实中的存储并不靠谱,磁盘可能返回错误数据还不报错,一次这样的错误就可能破坏共识。TigerBeetle为了解决这个问题,允许副本利用其他副本的数据修复本地存储。从工程角度来看,它构建的是一个可靠且可预测的系统。这里的“可预测”意味着真正的确定性,不是简单地限制非确定性因素,而是从底层开始,用完全确定的手工组件搭建整个系统。为此,它做了很多非常规的设计选择:

  • 硬核内存模式:在启动时就分配好全部内存,之后不再进行内存分配操作。这样就消除了内存分配过程中的不确定性。
  • 极致简洁架构:它没有使用JSON、ProtoBuf或者Cap’n’Proto这些常见的序列化方式,而是直接把网络字节流强制转换为目标类型。这么做主要不是为了提升性能,而是为了减少系统组件的数量。因为解析数据的过程很复杂,但如果通信双方都在掌控之中,直接发送校验过的原始数据就可以了。
  • 最小化依赖:所有的IO操作都是自己实现的代码,在Linux生产环境下,甚至都不链接libc库。
  • 低抽象度设计:组件之间紧密协作。比如说核心类型Message贯穿整个系统,网络层直接从TCP连接把字节写入Message,共识层处理和发送Message,存储层则把Message写入磁盘。这种设计让代码变得简单高效,因为预先分配了所有内存,甚至都没有多余内存来复制数据。在容错分布式系统中,存储也不能被看作是独立的黑盒,因为它本身也可能出故障。
  • 显式上限控制:在TigerBeetle里,没有随意使用的u32类型,所有数据在系统边界都会经过严格的数值检查。它限定了内存中同时存在的Message数量,并且精确地进行预分配。从消息池获取新消息既不会出现分配失败的情况,也不会进行额外的内存分配。

在这种严格的资源管理模式下,所有的IO操作,包括时间相关的操作,都被外部化了,输入也都是显式传递的,这样就杜绝了环境因素的干扰。所以,对它进行测试时,主要就是针对环境效应的各种可能组合进行测试,通过确定性随机化模拟,能有效发现分布式系统实现过程中存在的问题。本质上,TigerBeetle就像是一个明确编码的有限状态机。

二、Rust和Zig的特性对比

(一)Rust

Rust自诞生以来,虽然核心设计有了不少变化,但很多本质的东西一直保留着。可以说,Rust不太适合那些喜欢独自钻研的“天才黑客”式开发者,它更适合用来构建模块化软件。它的核心价值在于,提供了一种能够精确表达组件契约的语言,让各个组件可以以一种机器能够验证的方式集成在一起。打个比方,就好像搭积木,每个积木都有明确的接口和规则,按照规则搭起来,就能组成一个稳定的结构。

(二)Zig

Zig和Rust在很多方面不太一样。它甚至都不保证内存安全,代码量也比Rust小很多。在Zig里,你可以在结构体里存储指向自身字段的指针,可这也可能带来一些问题,比如很容易出现段错误。用Zig编写程序,需要你对整个程序有全面的掌控,把所有代码都装在脑子里,这样才能避免资源管理错误。虽然这听起来有点难,但对于某些特定的架构设计,Zig也有它的优势。比如说,如果能设计出几乎不需要资源管理的架构,像TigerBeetle采用的预分配模式,或者很多嵌入式系统的编译时分配模式,Zig就能发挥出很大的作用。

Zig还有一个很独特的特性——编译期(comptime)动态类型。这个特性涵盖了Rust的大部分特殊机制,虽然在复杂场景下,实例化错误可能会更难处理,但在多数情况下,它能让事情变得更简单,因为很多时候都不需要用复杂的类型系统来编程了。Zig的语言设计非常简朴,它没有闭包,如果需要闭包功能,就得手动打包胖指针。它的设计目的是生成精准的汇编代码,而不是追求高度抽象的源代码。Zig的创始人Andrew Kelley就说过,Zig是一种“生成机器码的领域特定语言(DSL)”。

在资源管理方面,Zig也有自己的特点。它强烈偏好显式资源管理,和Rust有很大不同。很多Rust编写的Web服务器程序,通常会并发处理大量独立的短期请求。一种比较自然的实现方式是为每个请求分配专用的bump分配器,这样请求结束后,通过重置偏移量就能批量“释放”内存,释放操作就变得很简单,还能方便地对每个请求的内存使用情况进行分析和限制。但在主流的Rust框架中,很少采用这种方式,因为全局分配器用起来更方便,大家也就习惯了这种局部最优的选择。而Zig则强制要求传递分配器,这就促使开发者去思考哪种方案才是最合适的。

另外,Zig的标准库在分配方面也和Rust不一样。Zig的集合类型不像C++ 或未来的Rust那样对分配器进行参数化,而是采用调用点依赖注入的方式,也就是在每个需要分配内存的方法中,都要显式地传递分配器。这种设计更加灵活,比如TigerBeetle在启动时,需要几个固定大小、永不调整的哈希映射,就可以向初始化方法传递分配器,但不传给事件循环,这样既能使用标准的哈希映射,又能保证事件循环不会意外分配内存。

三、对Zig的改进期望

(一)明确自身定位

Zig的优势主要体现在编写“完美”的系统软件方面,虽然这个细分市场不算大,但却很重要。Rust的生态位比较广,这既给它带来了社区活力,也让它在专注度上有所欠缺。对于Zig来说,Rust在一定程度上已经扮演了类似“现代ML”的角色,所以Zig更需要明确自己的定位,做到更加专业化。

(二)清晰语义定义

目前,Zig在别名、来源、可变性和自引用等方面的语义还不够明确。像“迭代器失效”这类未定义行为,在TigerBeetle运行在-DReleaseSafe模式下,并且不做动态内存分配的情况下,通过全面的模糊测试套件,基本可以解决。但Zig语言本身的语义问题还是让人有些担忧。要正确编译类似C语言的低级语言,明确指针语义非常关键。可现在对于“可移植汇编”是否真的存在还不太确定,虽然可以创建一个优化较少、“通常符合预期”的编译器,但要准确定义它的行为却好像不太容易。一旦深入研究指针和内存的本质,就会遇到很多复杂的问题。Rust在这方面做了很多努力,尝试精确定义相关规则,但如果没有借用检查器,开发者很难遵守这些规则,因为它们实在太微妙了。而目前Zig对于潜在别名指针、含内部指针的结构体拷贝等概念的定义还比较模糊,真希望Zig能把这些语义明确一下。

(三)加强IDE支持

之前在博客中也讨论过Zig的IDE支持问题。现在Zig的开发体验还算不错,语言服务器虽然简单,但也挺实用,有些需求用grep命令也能满足。不过,凭借Zig的惰性编译模型和缺乏语言外元编程的特性,它在IDE支持方面其实还可以做得更好。为了给未来更好的IDE支持打下基础,建议Zig编译器能提供面向IDE的基础数据模型API。可以创建一个持久化的分析进程,让它接收代码编辑流,这样不用每次都显式地请求编译,就能持续更新代码模型。这个模型一开始可以设计得简单一些,比如只提供“给我当前时点的文件AST”这样的功能,更高级的功能以后再慢慢补充。关键是要改变编译器的数据流形态,从原来的编辑 – 编译循环模式,转变为持续更新的模式。

(四)完善自包含流程

Zig倡导低依赖、自包含的开发流程,这一点我非常认同。理想的情况是,只要获取一个./zig二进制文件,就能开始开发。目前比较好的做法是把特定版本的./zig打包进项目,而不是使用系统级的zig。不过,在这方面还有两个地方可以改进:

  • 获取Zig更便捷:获取Zig的过程因为涉及引导,所以有点麻烦,不同平台的操作方式也不一样。要是Zig能提供标准脚本,像get_zig.sh或者get_zig.bat,或者一个小型的可移植二进制文件,让项目可以直接内置,这样贡献者在参与项目时,就能实现完全本地化和自包含的开发体验,直接运行下面的命令就可以开始:
$ ./get_zig.sh $ ./zig build 
  • 自动化扩展更丰富:有了./zig之后,其实还可以用它来驱动更多的自动化操作。虽然现在已经有./zig build命令,但软件开发可不只是构建这一步。以前很多和平台相关的琐事,都是用一堆bash脚本解决的,希望Zig能更积极地引导用户用Zig来编写这些自动化脚本。比如:
# 不太好:依赖操作系统 $ ./scripts/deploy.sh --port 92 # 还行:没有依赖,但命令有点冗长 $ ./zig build task -- deploy --port 92 # 理想方案:更简洁 $ ./zig do deploy --port 92 

四、总结与思考

从上面的分析来看,Rust注重的是组合安全性,在构建模块化软件方面有着很强的扩展性,就像是一个精心设计的大型工厂,各个模块紧密配合,有条不紊地运转。而Zig则追求完美,它更像是一把锋利的宝剑,虽然用起来有点危险,但在特定的场景下,能发挥出巨大的作用。

不过,在选择学习哪门语言的时候,我又有了新的思考。如今AI编程发展得很快,我试用了几款AI编程工具,像Cursor、Windsurf,搭配GPT – 4.1、Claude – 3.7 – Sonnet等模型之后,发现语言的语法好像变得没那么重要了。只要把功能逻辑描述得足够清晰,AI可以用很多种语言实现相同的功能。而且随着大模型不断进化,它会越来越智能,上下文理解能力也会越来越强,说不定以后真的不用写代码就能实现需求。

但这并不意味着传统编程就没有价值了。AI写的代码虽然功能覆盖很全面,但并不能保证不会出现内存泄露、边界问题导致的Core等情况。在这方面,像Rust这样具有强校验功能的编程语言就有很大的优势。很多内存问题在编译期间就能被发现并解决,就算是AI写的代码,用Rust来实现,也能减少上线时需要关注的安全性问题。所以综合考虑,学习Rust这种能在编译器层面解决大部分问题的语言,可能是更明智的选择。