关于Go、Rust与Zig的思考

许多人似乎困惑于Rust已存在的情况下为何还需要Zig。区别不仅在于Zig追求更简洁——我认为更关键的差异在于:Zig旨在从代码中彻底剔除面向对象思维。


最近我意识到,自己并非在“为任务选择合适的工具”,而是使用工作中现有的工具——这在很大程度上决定了我掌握的编程语言。因此过去几个月里,我投入大量时间尝试那些工作中用不到的语言。我的目标并非精通,而是希望形成对每种语言适用场景的判断。

编程语言存在诸多维度的差异,若不陷入“存在取舍关系”这种显而易见却1)枯燥乏味且2)毫无助益的结论,实难进行比较。当然存在取舍关系。关键在于:为何该语言要选择这套特定的权衡方案?

这个问题令我着迷,因为我不愿像选购加湿器那样仅凭功能清单来选择语言。我关注的是构建软件本身,更在意工具的本质。语言通过权衡体现其价值取向,我渴望找到与自身理念共鸣的语言。

元素周期表

这个问题同样有助于厘清那些功能集高度重叠的语言之间的差异。若以网络上关于“Go vs. Rust”或“Rust vs. Zig”的讨论热度为衡量标准,可见人们确实感到困惑。试想,要记住语言 X 因具备 abc 特性而更适合编写网络服务,而语言 Y 仅有 ab 特性——这实在困难。但更容易记住的是:语言 X 更适合编写网络服务,因为语言 Y 的设计者(假设)憎恶互联网,认为我们应该彻底断开网络连接。

我在此汇总近期尝试的三种语言——Go、Rust和Zig——的印象。我试图将每种语言的体验提炼为对该语言核心价值及其实现效果的概括性评价。这或许过于简化,但某种程度上,我正试图将一套简化的偏见凝练成文。

Go

Go以极简主义著称,常被称为“现代版C语言”。它虽不同于C——具备垃圾回收机制和真正的运行时环境——却在“可全盘掌握”这一点上与C如出一辙。

之所以能全盘掌握,正因Go特性极少。长期以来,Go因缺乏泛型而饱受诟病。直到Go 1.18版本才终于改变这一状况,但这已是开发者呼吁12年后的结果。其他现代语言常见的功能,如带标签的联合体或错误处理的语法糖,至今仍未加入Go。

Go开发团队对新增语言功能似乎设定了极高的门槛。最终结果是:这种语言迫使开发者编写大量冗余代码来实现逻辑,而其他语言本可更简洁地表达相同功能。但这也造就了Go语言经久稳定且易于阅读的特性。

再以Go的切片类型为例说明其极简主义。Rust和Zig虽都提供切片类型,但仅支持胖指针形式。在Go中,切片既是内存中连续序列的胖指针,又能动态扩展——这意味着它同时兼具Rust的Vec<T>类型和Zig的ArrayList功能。此外,由于Go自动管理内存,它会自动决定切片底层内存驻留在栈还是堆中;而在Rust或Zig中,开发者必须费心思考内存驻留位置。

据我所知,Go的诞生背景大致如此:Rob Pike厌倦了等待C++项目编译,更厌倦了谷歌其他程序员在这些C++项目中频频出错。因此Go在C++繁复之处追求简约,它面向普通程序员设计,旨在满足90%的使用场景,同时保持易懂性——即便(或许尤其)在编写并发代码时亦是如此。

虽然工作中不使用Go,但我认为应该尝试。Go以极简主义服务于企业协作——此言绝非贬义,企业环境下的软件开发自有其挑战,而Go恰恰能解决这些难题。

Rust

Go是极简主义者,Rust则是极致主义者。常与Rust关联的标语是“零成本抽象”,我愿将其改写为“零成本抽象,且数量庞大!”

Rust素以学习难度高著称。我认同Jamie Brandon的观点:真正难点不在于生命周期,而在于语言中塞入了过多概念。虽然我并非第一个指出这条Github评论的人,但它完美诠释了Rust概念的密集程度:

类型 Pin<&LocalType> 实现了 Deref<Target = LocalType>,但未实现 DerefMut。由于 Pin& 类型具有 #[fundamental] 标记,因此可以为 Pin<&LocalType>> 实现 DerefMut。你可以使用 LocalType == SomeLocalStructLocalType == dyn LocalTrait,并将 Pin<Pin<&SomeLocalStruct>> 强制转换为 Pin<Pin<&dyn LocalTrait>>。(确实是两层 Pin!!) 这使得在稳定分支上能创建一对“实现CoerceUnsized但行为异常的智能指针”(Pin<&SomeLocalStruct>Pin<&dyn LocalTrait>正是这类“行为异常”的智能指针,且它们已实现CoerceUnsized)。

当然,Rust 并非像 Go 追求极简那样追求极致。Rust 之所以复杂,是因为它试图实现两个略有冲突的目标——安全性和性能。

性能目标不言自明。而“安全性”的含义则不那么清晰;至少对我而言如此,或许只是我长期使用 Python 导致思维定式。所谓“安全”即“内存安全”,指程序不应允许解引用无效指针或执行双重释放等操作。但其内涵不止于此——“安全”程序还需规避所有未定义行为(常称“UB”)。

何为令人畏惧的UB?我认为理解它的最佳方式是牢记:对于任何运行中的程序,都存在比死亡更可怕的命运。当程序出错时,立即终止其实是最好的结果!因为若错误未被捕获,程序将坠入不可预测的灰色地带——其行为可能取决于哪个线程赢得了下一次数据竞争,或某个内存地址恰好存放了什么垃圾数据。此时便会滋生海森堡错误和安全漏洞——后果极其严重。

Rust通过编译时检查来防范UB,且不牺牲运行时性能。Rust编译器虽智能却非全知。要实现代码检查,它必须理解程序在运行时的行为模式。因此Rust拥有丰富的类型系统和形形色色的特质,让你能向编译器表达其他语言中仅能通过运行时行为体现的内容。

这使得Rust变得困难——你不能直接“做”这件事!你必须先找出 Rust 对该行为的命名规范——找到所需的特性或其他机制——再按 Rust 的预期方式实现它。但只要做到这点,Rust 就能对代码行为做出其他语言无法提供的保证,这对特定应用场景可能至关重要。它还能对 他人编写的代码 做出保证,这使得在 Rust 中使用库变得轻松,也解释了为何 Rust 项目的依赖项数量几乎与 JavaScript 生态系统中的项目相当。

Zig

在三种语言中,Zig最为年轻且最不成熟。截至本文撰写时,Zig仅处于0.14版本。其标准库几乎没有文档,学习使用它的最佳方式是直接查阅源代码。

虽然无法证实,但我倾向于将Zig视为对Go和Rust的双重回应。Go的简洁源于它隐藏了计算机的底层运作细节;Rust的安全性来自其强制执行的诸多规范;而Zig将赋予你自由!在Zig的世界里,你掌控着整个宇宙,无人能指手画脚。

在Go和Rust中,通过函数返回结构体指针即可轻松在堆上分配对象——分配过程隐式完成。而在Zig中,每个字节都需要你亲自显式分配。(Zig采用手动内存管理。)你在此获得的控制权甚至超越C语言:分配字节时必须调用特定分配器的alloc()方法,这意味着你需要根据具体场景选择最优的分配器实现方案。

在 Rust 中创建可变全局变量如此困难,以至于存在长篇论坛讨论专门探讨实现方法。而在 Zig 中,创建全局变量毫无障碍。

未定义行为在 Zig 中依然重要,该语言将其称为“非法行为”。系统会在运行时检测此类行为,一旦发现即终止程序。对于担心检测开销的开发者,Zig 提供了四种不同的“发布模式”供编译时选择,其中部分模式会禁用检测机制。其设计理念在于:开发者可在启用检查的发布模式下充分运行程序,从而合理确信未启用检查的构建版本不会出现非法行为。在我看来,这是一种极具实用性的设计思路。

Zig与另外两种语言的另一差异在于其与面向对象编程的关系。面向对象编程已失宠许久,Go和Rust都摒弃了类继承机制。但这两种语言仍充分支持其他面向对象编程范式,若需构建交互对象图谱的程序,依然可行。Zig虽支持方法,却不存在私有结构体字段,也未提供实现运行时多态(即动态分派)的语言特性——尽管std.mem.Allocator渴望 成为接口。据我所知,这些缺失是刻意为之;Zig本质上是为数据导向设计而生的语言。。

还有一点我想特别强调,因为它让我豁然开朗:在2025年构建需要手动内存管理的编程语言看似疯狂,尤其当Rust已证明无需垃圾回收、编译器即可自动处理时。但这与排除面向对象特性是紧密关联的设计抉择。在Go、Rust等众多语言中,开发者往往为对象图中的每个对象逐块分配内存。程序中暗藏着成千上万个malloc()free()调用,因而衍生出无数不同的生命周期。这就是RAII。在 Zig 中,手动内存管理看似需要大量繁琐且易出错的记账工作,但这仅限于你坚持将内存分配与所有微小对象绑定的情况。你完全可以在程序的合理节点(如事件循环每次迭代开始时)分配并释放大块内存,用其存储操作所需的数据。这正是 Zig 倡导的模式。

许多人似乎困惑于Rust已存在的情况下为何还需要Zig。区别不仅在于Zig追求更简洁——我认为更关键的差异在于:Zig旨在从代码中彻底剔除面向对象思维。

Zig带着一种颠覆性的趣味感。它是用来打破企业级对象类层次结构的语言,是为狂妄自大者和无政府主义者打造的语言。我喜欢它。虽然Zig团队当前的重点似乎是重写所有依赖库,但我仍期待它早日发布稳定版。说不定在Zig 1.0问世前,他们真会先重写完Linux内核呢。

本文文字及图片出自 Thoughts on Go vs. Rust vs. Zig

共有 438 条讨论

  1. > 在 Rust 中创建可变全局变量如此困难,以至于论坛上有长篇讨论专门探讨如何实现。而在 Zig 中,创建可变全局变量轻而易举,毫无障碍。

    其实不然,在 Rust 中创建可变全局变量其实很简单,只需使用 unsafe 关键字或借助提供同步机制的智能指针即可。这是因为Rust程序默认具有可重入性——它提供了编译时线程安全保障。若你不在意静态强制线程安全,那么在Rust中实现可变全局变量与在Zig或C中同样简单。区别在于:不同于Zig或C,Rust为你提供了工具,能对代码的运行时行为做出更多强制性保证。

    1. 使用Rust多年后,我认为可变全局变量正是“你忙于思考能否实现,却从未停下思考是否该实现”的完美例证。

      如今重返常做此类操作的语言时,这种做法在执行安全性方面简直荒谬至极。

    2. > […] 在 Rust 中微不足道 […] 它只需 […]

      这不过是墓碑式的陈词滥调。当年人们对 C++、Perl、Haskell(还有早期的 Prolog)也用过同样的论调。就字面意义而言确实如此。但那些“简单”操作“只需”的语言,其累积效应会迅速变得“不那么简单”。而Rust早已越过了这条界限。它 永远 不会变得简单,句号。

      1. > 那些“简单”操作“只需”的语言,其累积效应会迅速变得“不那么简单”

        确实。而在C和Zig中,创建全局可变变量看似“简单”,却“仅仅要求”你手动在程序所有可能的并发状态下完美维护内存访问不变量。

        别再绕弯子了。Rust编写并发程序的难度远低于其他几乎所有语言,差距甚至不是一点点(尽管必须提一下Erlang)。

        1. >它“只需”你手动在程序所有可能的并发状态下完美维护内存访问不变性。

          对于某些程序而言,这确实简单得离谱——比如“在早期初始化时设置一次值,之后仅读取”。不,这并非线程局部变量。即便在“偶尔需要从特定代码位置原子更新”的场景下,实现无锁操作也相当容易。

        2. >它“仅仅要求”你手动在程序所有可能的并发状态下完美维护内存访问不变性。

          关键区别在于它不会阻止你这样做,所以并非“仅仅要求”

        3. 这是“交付速度”(追求最快交付)与“正确性”(追求代码质量)两种价值观念的沟通障碍。

          Rust能让你快速编写正确的软件,但编写仍能满足MVP需求的错误软件时效率较低。用其他语言编写错误的并发程序或许能蒙混过关…至少暂时如此。而有时这正是业务所需。

          我其实希望“用Rust重写”能在Rust生态中成为更重要的目标。虽然承认Rust不适合快速原型开发,但它在正确性/性能上的优势足以支撑软件长期维护的重写需求——前提是存在能简化迁移的工具链。

          1. 近期Rust已成为我的主力语言,对此观点深表赞同。

            我倾向用TypeScript进行原型开发——因其速度(足够快),且能在服务器端(通过bun)或浏览器中轻松运行。其类型系统与Rust高度相似,切换使用相当便捷,且拥有强大的包生态系统。

            我会先实现基本功能,迭代设计方案,可能经历几次重写,当对网络协议/UI/数据布局满意后,再拿出Rust进行移植和优化。

            这类代码移植比想象中简单。我们对跨语言迁移的认知常被大型项目耗时所误导,但将命令式语言A的代码重写为B的过程其实相当机械化,速度远超预期。这种迁移未被广泛采用令人惊讶。

            1. 我正面临类似情况,不过我的技术栈是从Python迁移到Go

              使用Python时,我可以轻松迭代解决方案,实时观察代码变化,利用REPL调试,甚至可以先写出粗糙的代码让它运行起来。当然我也会尝试添加类型注解等规范,避免完全陷入“随心所欲的JavaScript万物皆对象”模式 🙂

              但说到底,在别人的电脑上运行Python代码实在麻烦,所以我完成后通常会用大型语言模型把代码重写成Go语言。这样不仅能显著提升运行速度,更重要的是能生成单一可执行文件,直接复制粘贴就能运行。

              少数情况下,若解决方案需要依赖Python库而Go又无对应实现,我便直接保留Python版本,通过容器等方式进行分发部署。

              1. 是否有提升Python原型开发效率的优质资源?

                Python的类型系统让我感觉效率低下,尽管我写更多Python代码,但在Go中原型开发反而更快。我确实广泛使用类型注释,甚至理想情况下会用pydantic。

                我常将它用于数据分析和探索,但现在主要用nushell完成这类任务,表现非常出色。

                1. 直接动手试试不就得了?:D

                  当我从API接收随机JSON数据时,直接跳进Python REPL环境浏览结构、摸清数据分布要轻松得多。不像Go需要预先定义带注释的数据结构才能解析。

                  在第一阶段我不会在意任何代码检查工具或类型注释,只需要一个能端到端运行的框架雏形——也就是所谓的概念验证。

                  之后只需用Python反复迭代,摸清输入输出逻辑,最终确定格式即可。

                  1. 感谢建议,但当前我正用nushell处理JSON API相关工作。它让海量数据集的导航变得轻而易举。

                    对我而言,没有类型注释的工作效率会大幅降低。

                    别误会,我确实欣赏Python的特性,只是怀念其他语言能实现的快速原型开发能力。

          2. > Rust能快速编写正确的软件,但编写仍可满足MVP需求的错误软件时效率较低。

            我并不认同这种说法。学习借用检查器可能需要一两个月适应期,但过渡期后,创意涌现的速度与其他语言并无二致。

            此外,函数所需和返回的数据类型一目了然,这大大节省了阅读库文件和梳理上周自己代码的时间。而 Cargo 在快速构建复杂项目方面的优势更是不可估量。

            综合来看,我认为 Rust 的开发效率远高于 C++——后者或许是其功能上最接近的竞争者。这种优势在宏观层面尤为显著——Rust库生态的迅猛发展便是明证。

            1. 我持不同意见。五年深度使用Rust的经验告诉我,你所说的确实存在于某些场景。但问题在于Rust作为低级语言,常需执行冗余操作,即便这些操作毫无价值。简单生命周期尚可接受,但当涉及他人定义的特性边界时——那些特性可能关联六七种类型——复杂度会急速攀升。更别提需要自引用结构体的设计,或是涉及重度异步操作(如数据固定、异步取消等)的场景。

              我承认Rust常能带来高效开发,但任何大型Rust程序都存在隐性成本。我认为这种代价值得(至少对我个人时间而言),但能理解企业对各类程序可能持不同看法。

              1. > 问题在于Rust是低级语言,常需执行冗余仪式,即便这些操作毫无价值。

                正如我对比的C++——完成类似任务时需要更多冗余代码。在C++开发中,我耗费大量时间整合Make和CMake等语言的分散构建系统,这些工作在Rust中完全消失。而这还只是编写代码前的准备阶段。

                > 我确实认同通常能获得良好开发效率,但任何大型Rust程序都存在代价。

                我并非否认存在代价,而是基于自身经验(约四年中等规模Rust项目开发经历,加之二十余年C/C++经验)认为其代价低于C++。C++堪称此类语言中最糟糕的代表——几乎所有其他语言都能更轻松高效地编写软件,尽管它们在嵌入式等特殊场景的适用性较弱,但这并非高标准。神奇之处在于,Rust在保持与C++相当功能的同时,开发成本却略低。当我能直接导入库文件快速上手时,Rust的成本往往接近Python这类语言。但Python无法让我在需要时深入底层操作,而C++和Rust都能做到。在具备这种能力的语言中,Rust无疑是效率最高的开发环境,毫无争议。

                看来我们意见一致。Rust 的生产力常能媲美其他语言(甚至某些方面更胜一筹),但当你需要处理复杂性时,它绝不会刻意掩盖这些复杂性。

                1. > 我并不认同这种说法。学习借用检查器可能需要一两个月的适应期导致效率稍低,但过渡期过后,思路的流畅度完全不逊于任何其他语言。

                  我是在回应“与其他语言一样”的说法。相较于C++,确实迭代速度更快。但相较于C#/Go/Python等语言,Rust在某些场景下迭代稍慢,因为有时需要提供底层细节。

                  1. > 由于有时需要提供底层细节,Rust在某些场景下迭代稍慢。

                    Rust 某些特定任务确实需要额外投入——比如从 WASM 调用文件选择器时,我不得不编写异步函数。在嵌入式场景中,有时需要指定分配器或执行器。有时还需用 Arc(Mutex()) 等机制封装全局应用状态。但这类边缘操作在所有语言中都存在。使用Python开发时,我常需借助C/C++解决运行时链接库的问题。而Rust从未迫使我转用其他语言完成任务。

                    我并不认为类型声明是负担。相反,它通过在代码中清晰标注操作对象,反而加速了开发进程。我唯一需要编写不安全代码的情况是交互GL着色器和绑定C库——这正是Rust设计初衷的场景,在其他语言中若不转向C/C++几乎无法实现。我始终能利用现有数据结构或其组合来解决问题,这点很有帮助。但C#/Go/Python等语言也只能做到这个程度。

                    对我而言真正的转变在于学会围绕数据生命周期思考和构建代码,随后便获得了他人常提的绝妙体验——代码编译通过时,我就能确信其95%符合预期。而编译器正是实现这一目标的助力。

          3. 在理想世界里,若计算软件与其他商品适用相同责任法,则不正确的产品绝不应上市。

            遗憾的是,太多人默认使用计算机就意味着要忍受缺陷产品——这种情况换作其他商品,多数人当天就会退货。

          4. > Rust能快速编写正确软件,但编写仍可满足MVP需求的错误软件时效率较低

            对此见仁见智,但我认为主要差异在于生态系统,尤其后端领域。按此标准衡量,原型开发绝不该使用JavaScript以外的语言。

            Go语言在后端原型开发上也比Rust更快,因为所需功能大多已包含在标准库中。但优势并不显著,且进入生产环境后这种优势就会消失。

            我认为多数人严重高估了借用检查器带来的阻力——一旦上手后这种阻力就微乎其微。

        4. 有趣的是你提到了Erlang——毕竟在Rust中实现Actor模型和消息传递相当棘手(是的,我看过Tokio库)。Rust缺乏优质GUI库或游戏引擎自有其原因:资源必须共享,而共享机制远比内存所有权复杂得多。

        5. > 它“仅仅要求”你手动在程序所有可能的并发状态下完美维护内存访问不变性。

          并非如此。Zig根本不需要你考虑并发问题。你可以完全不做并发编程。

          > 别再绕弯子了。Rust编写并发程序确实比其他语言容易得多

          这与定义共享全局状态的问题毫无关联。

              var x: u64 = 10;
          

          看,我定义了共享全局状态,完全无需考虑并发程序编写。

          Rust(以及你)主张所有代码都应能在并发环境中运行。通过该主张检验的代码可能比未通过的代码更具可移植性。

          你需要理解的关键是:代码在不同断言下 可能 正确。若断言某些代码不会在并发环境中运行,创建可变全局变量完全合理。这种断言可隐式完成(例如:编写程序时明确不创建线程,故知该变量不会被共享可变访问)。

          1. 若不涉及并发,Rust 亦无需你考虑并发问题。全局变量只需添加 thread_local 修饰即可,无需 unsafe 操作。

          2. > Rust(以及你)做出了所有代码都应能在并发环境中运行的断言。

            确实如此。Rust的标准库在某种程度上实现了这一点,因为它提供了在并发环境中运行代码的途径。即便如此,它仍支持线程局部变量和状态这类非并发原语——这些原语无法在线程间传递或共享,而Rust恰恰利用了这一特性。Rust语言本身完全乐意让你定义一个仅支持单线程原语的标准库。

            你知道什么在单线程环境下(通常)不安全吗?可变的全局变量。当然整型变量没问题,只要你没有安全的方式获取指向它的指针类型来保证唯一访问权限(哎呀,Rust确实有。而且它在单线程环境中对代码的局部推理非常友好——我可不想放弃这些特性)。但一旦涉及复杂数据结构(如向量),就会出现内存失效问题:你可能获取指向内存的引用,在持有引用期间释放该内存,从而导致内存释放后仍被使用,最终破坏随机内存区域。

            不过Rust围绕安全模式设计了诸多抽象机制。例如你可以使用Cell<u64>替代u64,将其存入线程局部变量后,基本可像操作u64那样读写数据——但无法获取指向该变量的指针,从而确保数据不存在别名冲突。而Cell<Vec<u64>>则完全禁止获取内部向量元素的引用。或者使用RefCell<_>——它类似于RwLock,但无法在线程间共享,速度更快,且在阻塞导致死锁时直接崩溃而非阻塞。

          3. > 这与定义共享全局状态的问题完全无关

            确实无关。Rust 中共享全局状态唯一可能导致 不安全 的情况,是该“全局”状态被跨线程共享。

            若你明确需要与 Zig 相同的保障(即代码在单线程环境下正常运行,多线程则行为未定义),只需这样写: static mut x: u64 = 0;

            Zig与Rust的唯一区别在于:你需要将共享变量的访问操作包裹在unsafe代码块中(建议添加注释说明“只要在单线程中操作即安全”)。

            参见https://doc.rust-lang.org/nightly/reference/items/static-ite

          4. 我明白你的意思,但问题在于:今天看似正确的做法,明天维护代码的倒霉蛋可能忘记/误解初衷,于是就迎来了未定义行为。

          5. 在无数极力鼓吹“Rust方式就是万能解”的论调中,能看到这样的评论令人欣慰。

            但事实并非如此,Rust 显然没有形成宗教般的狂热追随,任何宣称相反的人都是不诚实的。

        6. 我觉得 Elixir 和 Erlang 更容易上手,不过我对 Rust 还是新手,也许一年后会有不同感受。

          1. Go语言在编写多线程代码时就显露短板,尤其当线程间存在密集交互时。通道无法充分表达复杂任务,显式互斥锁易引发错误,而用于支持取消操作的Context hack机制既丑陋又难以正确使用。

            Rust的通道作为库实现更强大,覆盖更多场景,且显式底层同步机制具备内存安全性。

            我唯一的顾虑在于Rust实现异步的方式需要轮询未来值。作为异步库用户这完全没问题,但当需要实现自定义未来值时会增加复杂度。

          2. https://www.ralfj.de/blog/2025/07/24/memory-safety.html

            Go语言默认不具备线程安全特性。作者通过循环示例展示了这一点:

                for {
                    globalVar = &Ptr { val: &myval }
                    globalVar = &Int { val: 42 }
                 }
            

            由于类型和值是两个独立操作且无法原子更新,可创建类型为42的指针

            因此Go语言虽更易编写,但安全性无法比拟

        7. 这正是我的核心观点。就像宣称“看啊各位,不遵守任何规则开发制造飞机多轻松”。这话固然正确。但我绝不愿乘坐任何此类飞机——即便它们由世上最杰出的天才设计制造。

          1. 若非搞砸了异步机制,它本该更出色

              1. 我同意,我觉得他们应该推迟发布。

                在另一个宇宙里,Rust 至今仍没有异步支持,五年后或许会获得类似 OCaml 的效果系统。

                1. 而在那个宇宙里,Rust很可能沦为无关紧要的小众语言。

      2. 优秀的设计应引导开发者远离错误实践,Rust在此正走在正确的道路上。

      3. 将全局可变变量创建体验伪装成普通绑定操作的语言设计实属反模式,我很庆幸Rust没有试图混淆二者。

        若将共享状态当作拥有状态对待,你注定要吃苦头。

      4. 才怪,学Rust小菜一碟。我都学过三四遍了。

      5. 只需掌握unsafe机制。理解这个概念后,你就能创建全局可变变量。

        这设计很巧妙——输入“unsafe”这个词会让人产生不适感,从而质疑全局可变变量是否真是所需。效果绝佳!因为这能让该软件所有未来的用户免于遭遇与全局可变变量相关的并发错误——包括那些当前软件中尚未存在的错误,以及未来开发者因未考虑全局 unsafe 的影响而可能引入的错误!

      6. > Rust早已越过了这条特定的鲨鱼线。它永远不会变得简单,句号。

        或许 如此,但语言整体的难度与所谓“这个具体特性很难”的论断截然不同。

      7. 他说的只是添加一个关键字而已。仅此而已。我认为这算不上什么。

        1. 但实际上调用unsafe应该提醒你:或许你根本不清楚自己在做什么,可能存在互斥锁之类能实现你需求的更安全抽象方案。

          1. 当然,这个关键词命名得恰如其分。

    3. 那么Rust编译器是否会在编译时检查线程间的竞争条件?如果能做到,就能理解Rust相较于C的吸引力了——某些同步问题确实棘手。另外遇到两个密切相关的变量时,每次访问都需要成对加锁的情况又该如何处理?

      1. > 那么Rust编译器是否会在编译时检查线程间的竞争条件?

        据我理解,Rust能防止数据竞争,但并非所有竞争条件。逻辑竞争仍可能发生——即操作以意外方式交错执行。Rust无法检测此类问题,因为这不属于内存安全范畴。

        因此仍可能出现死锁、饥饿、唤醒丢失、排序错误等问题,但Rust能提供:

        – 无数据竞争
        – 禁止可变数据的非同步别名
        – 通过类型系统强制线程安全(Send/Sync)

        1. 同时也能处理良性竞争(即顺序无关的情况)

      2. > 若存在两个紧密关联的变量,每次访问时需成对加锁的情况呢?

        这在Rust中自然可实现。可让互斥锁拥有该对元素:锁定Mutex<(u32, u32)>即获得访问双元素的保护机制。虽然实际中更常见的是命名结构体 Mutex<MyStruct>,但元组同样适用。

      3. 这正是Rust的核心设计目标!通过类型系统从根本上防止数据竞争(以及UAF等内存安全问题)。

      4. Rust 中存在两种引用类型:独占引用(&mut)与共享引用(&)。Rustc 保证:若提供独占引用,其他线程无法持有该引用。当线程持有独占引用时,可修改内存内容。Rustc 同时保证线程内部不会出现引用丢失的情况,因此内存始终处于有效分配状态。

        由于Rust保证不会出现多个独占引用(即可变引用),因此特定类型的竞争条件不会发生。

        但某些程序要求极为严格时,需要放宽这些保证。为此提供了结构体机制,可在运行时实现与共享/独占引用相同的借用规则(即单独占有,多共享引用)。这意味着:当对象存在时,多处可同时引用(借用)该对象。但若存在活跃的共享引用,则无法获取独占引用——程序会按设计触发panic;若存在活跃的独占引用,则无法再获取任何引用。

        然而这对于多线程应用程序而言尚不充分。当单线程中存在大量内存片段引用同一对象时,上述方案即可满足需求。针对多线程程序,我们需要读写锁(RwLocks)。

        https://doc.rust-lang.org/std/cell/index.html

      5. 它通过借用检查器和互斥锁等安全机制完全杜绝了竞争条件。

        但逻辑竞争条件和死锁仍可能发生。

        1. Rust的具体主张是:安全Rust能避免数据竞争,但无法杜绝包括死锁在内的普遍竞争条件。

        2. 啊我明白了,谢谢。虽然我不懂Rust代码长什么样,但从文章看这似乎是种通过大量元数据描述变量使用意图,从而让编译器进行安全检查的语言。这就是它的诀窍。

          1. 这个理解相当准确。有人抱怨Rust语法过于复杂,但我发现它与C/C++最显著的差异都源于这些元数据(变量类型、返回类型、生命周期),这些不仅对编译器有用,更能帮助我快速理解库函数的数据预期——无需逐行阅读代码就能明确输入输出类型。这显然让代码复用变得更轻松高效,也使我能更轻松地推导自身代码的逻辑。

            1. 学习过程中唯一觉得语法怪异的是用单引号表示生命周期,因为它看起来像个孤立的字符字面量。除此之外它是个相当标准的波括号语言,&符号源自C++,泛型约束也与众多语言相似。

              当然,借用检查器和生命周期机制的学习曲线可能较陡峭,尤其对来自GC世界的开发者而言——但仅就语言语法本身而言其实并不奇怪。

              1. 深表赞同。实践中Rust宛如经过理性化的C++,摒弃了三十年累积的冗余。核心概念被精简至极致并得到强化。编译器错误信息质量大幅提升,工具链设计合理且默认配置颇具见解。这些优势共同推动了库生态系统的进化——模块化程度更高、互操作性更强、实用性更突出。

            2. 我觉得你曲解了论点。我们这些不喜欢Rust语法的人,对C++同样深恶痛绝。这两种语言都是灾难。

          2. Rust的线程安全元数据竟如此精炼!POSIX的并发安全概念比Rust更精细。

            Rust数据类型可分为“Send”(可跨线程移动)和“Sync”(支持多线程并发访问)。其余特性皆由此衍生(结构体若其字段为Send则自动继承Send属性;用互斥锁封装非Sync数据即变为Sync;thread::spawn()要求Send参数等)。

            Rust甚至无需验证函数本身线程安全,只需确保其访问的数据安全即可——只要全局变量强制为“Sync”便足够。

    4. 若我设计新编程语言,必将彻底禁止可变全局变量。它们纯粹纯粹纯粹是祸害。我已数不清多少次被卷入调试棘手的崩溃问题,而根源终究指向某个可变全局变量。

      1. > 它们纯粹纯粹纯粹是祸害。

        需谨慎使用。若执行环境足够简单,它们反而能高效实用。工程实践不该沦为宗教教条。

        > 我被拉去调试棘手崩溃的次数多到数不清,而根源永远是可变全局变量。

        我从未遇到过这种情况。你处理的代码类型为何如此频繁地出现这种问题?

        1. > 若执行环境足够简单,全局变量确实能发挥效用

          这话常被那些在复杂系统中编程的工程师挂在嘴边!

          真正恼人的是全局变量本质上就是糟糕的选择。比如不用单例模式、直接传递指针的代码反而更简单易实现。

          > 你在处理什么类型的代码时会频繁遇到这种情况?

          各类C++项目。

          库文件使用全局变量尤其令人抓狂。不行,绝对不行。库文件必须提供“CreateContext”和“DestroyContext”函数,公共API也应接收上下文句柄。

          从设计之初就该做好库的架构。因为你永远无法预知运行环境的变数。与其日后费力纠正错误,不如一开始就做对——这简直天差地别。

          我此生所求,不过是纯粹的C语言API。它简洁优雅,令人愉悦,还能被封装成可在任何现有编程环境中运行的形式。

      2. 你需要务实且注重实用性。超大型代码库中的控制器/管理器必须能被众多模块访问。相较于数十个局部引用,单一的全局引用反而降低了代码的实用性。

        1. Rust社区曾提出过一个有趣的方案,试图通过隐式上下文参数来解决这个问题…目前没时间追踪所有相关博文,但这个帖子可能是最早的讨论/评论区应该有大部分链接: https://internals.rust-lang.org/t/blog-post-contexts-and-cap

          总之,我认为解决这个问题肯定有比全局变量更好的方案,只是目前还没有哪种语言能完美解决。

        2. 我最喜欢的演讲之一是GDC上关于《守望先锋》击杀镜头系统的分享。这个系统能在多人射击游戏中,让你死亡时以击杀者视角回放最后约4秒的游戏画面。https://www.youtube.com/watch?v=A5KW5d15J7I

          暴雪的实现方式极其精妙:他们创建了一个完全独立的“回放世界”。当玩家阵亡时,服务器会迅速向“回放世界”补全数据(初始阶段为防作弊不会发送全部数据)。此时摄像机切换至“回放世界”进行渲染,而“游戏世界”仍持续接收更新。数秒后摄像机切回“游戏世界”——此时游戏世界数据已同步更新,随时可继续战斗。

          实现该功能必须彻底清除所有恶劣的脏全局变量。因为几乎每次有人断言“这种变量绝对只存在一个!”时,事实都证明是错误的。这正是演讲的核心要点:可变全局变量有害!

          > 超大规模代码库中的控制器/管理器必须供多个模块访问。

          我认为绝大多数情况下,不使用可变全局变量能让代码更优雅简洁。日志记录或许是个勉强接受的例外——但必须非常勉强。Go/Zig/Rust/C/C++都缺乏理想的日志方案。Jai语言的隐式上下文指针设计精妙有趣。

          Rust将unsafe关键字作为“逃生舱口”。若由我设计编程语言,或许也会勉强允许可变全局变量。但会将其声明与使用隐藏在unsafe_and_evil关键字背后——每当程序员声明或访问可变全局变量时,都必须手动输入unsafe_and_evil并承认自己的错误行为。

          1. 能否描述你心目中理想的日志方案?

          2. 这正是经验拖着我哭喊着被迫接受的典型教训:每当你断言“这种东西绝对永远只需要一个”时,你就是错的。毫无例外。文档?显示器?鼠标光标?网络连接?统统不行。

            1. 测试就是绝佳反例。“我们绝对永远只需要一个”。那么,呃,你能用128核服务器并行运行测试吗?还是被迫逐个顺序执行测试——因为并行运行要么彻底崩溃,要么意外串行化?呜呜呜,悲伤的号角。

      3. 在我的编程语言中(见最新提交),我曾试图实现这点。但后来意识到,极少数情况下全局可变变量(包括线程局部变量)是必要的。因此我添加了这类变量,但使用时必须包裹在 unsafe 代码块中。

      4. 在Rust/Zig/C这类系统级语言中基本不可行。进程确实只有一个地址空间…若能操作该空间,就等同拥有全局变量。

        使用Rust这类(在正确性属性方面)高级语言能实现许多有趣功能,消除全局变量或许是其中之一(尽管我能理解正反双方的论点)。希望未来能有人打造出优秀的解决方案。

        1. > 在Rust/Zig/C这类系统级语言中基本不可行。进程确实只有一个地址空间…若能操作该空间,就等同于拥有全局变量。

          这并不意味着必须将其暴露为全局可变变量

    5. 这似乎有些反常。我原以为“trivial”意味着默认方案适用于大多数情况。或许可变全局变量本就不是常见用例。使用unsafe或许能简化实现,但效果不明显且可能并非理想选择。我不熟悉Rust,但听说代码库中零星存在的unsafe代码会削弱对Rust安全保证的信任。这种折中方案让人感觉语言本身并未真正解决问题。

      1. 除单次初始化/延迟初始化(通过安全且平凡的标准库API实现:https://doc.rust-lang.org/std/sync/struct.LazyLock.html)外,几乎没有Rust代码使用全局可变变量。在实际代码中看到全局可变状态的情况极其罕见,这正是阅读真实Rust代码的乐趣所在——尤其当你曾耗费大量时间盯着那些程序员似乎对函数参数存在恐惧症的C代码时。

        1. > 几乎看不到任何全局可变状态 我对Rust略知一二,无需详细解释。如何在Rust中使用本地缓存或数据库连接池(我认为这两者都是全局可变状态的合理用例)?

          1. 用互斥锁包裹即可允许使用。
            全局状态是允许的,只需确保线程安全。

          2. 为何必须是全局?参数依然可以传递。若不想覆盖寄存器,可将其封装在结构体中。我不认为你是在规避解引用指针的开销。

      2. 默认方案是使用强制同步的容器。若需手动控制,你完全可以实现,但必须明确承担随之而来的责任。

        若使用unsafe指令放弃编译器提供的数据竞争保护机制,这与在不支持数据竞争保护的语言中操作并无二致。

      3. > 我认为“简单”意味着默认方案适用于多数场景。

        确实如此。我不确定你所指的默认方案是什么,但对我而言就是用互斥结构体封装数据,确保任何线程都能安全访问。这种方案在大多数情况下效果极佳。

        > 可变全局变量或许并非常见用例。

        虽然实际应用中这类场景的普遍性尚难定论,但我坚决认为它们不应成为常态。几十年来,全局可变变量作为常见的错误根源早已广为人知。

        > Unsafe 或许能简化操作,但其风险不明显且可能不受欢迎。

        Rust所做的只是迫使你承认其中的权衡取舍。若追求安全性,就必须使用同步机制保护数据(语言本身提供了多种方案);若能接受风险,则可使用unsafe。unsafe并非会导致程序崩溃的毒药,所有Rust程序或多或少都使用了unsafe(因为标准库本身就充斥着这类操作)。Rust与C的唯一区别在于:Rust会直截了当地提醒你“此操作可能引发严重后果”并强制你确认。这并不意味着全局变量在Rust中的风险高于其他语言。

      4. > 我认为“trivial”意指默认方案适用于多数场景。或许可变全局变量本就不是常见用例。使用unsafe或许能简化实现,但操作不直观且可能违背设计初衷。

        作为Rust拥趸,我基本认同这种观点。这并非难事,但称其“trivial”也不尽准确。确实,全局变量在Rust中并不常见,若需使用通常会通过LazyLock防止初始化时的数据竞争。

        > 我虽不熟悉Rust,但听说代码库中零星的unsafe代码会削弱对Rust安全性的信任。这种折中方案让人感觉语言并未真正解决问题。

        这种说法完全错误。首先,若非编写设备驱动/内核等底层代码,程序中几乎不可能出现unsafe用法。即便存在,这些代码也相当于有效注释——当出现可疑行为时,它们会指引你查明根源。典型的Rust开发范式是:让底层库(crates)处理不安全操作,通过Miri模糊测试等手段充分验证,再由开发者基于这些库构建安全程序。反观C/C++程序,每个语句都处于“不安全代码块”中。在Rust中,你始终清楚无限定行为可能或不可能发生的具体位置。

        1. > 即便如此,你现在也拥有了有效的注释指引——当出现可疑行为时,它会明确告知你该从何处着手排查。

          可当可疑行为发生时,不正是到了关键的转折点吗?

          例如,关于React和Next的新闻报道。一旦代码部署完毕,重新部署(尤其是在系统语言上,这类语言很可能运行在严格隔离的系统中,对更新有诸多限制)意味着你不如直接用C语言,成本完全相同。

          1. 你当真一本正经地认为:Rust在有限的不安全区域偶发安全漏洞,其功能性后果等同于用C这类不安全语言编写整个程序?

            其一,实际成本 绝非 等同。在开发投入相当的前提下,Rust程序的质量基准线远高于C程序。

            其次,得益于Rust的设计特性(借用计数器、合集类型、数据竞争预防),整类漏洞的潜在影响范围可归零——仅在特定划定区域存在风险,而这类区域在绝大多数Rust程序中也基本为零。

            1. > 在开发投入相同的情况下,Rust程序的质量基准底线将高于C程序。

              嗯,具体依据谁的标准?

              > 第二,得益于Rust的设计特性(借用计数器、总和类型、数据竞争预防),整类漏洞的潜在影响范围可归零——除特定限定区域外,而这类区域在绝大多数Rust程序中实际影响也趋近于零。

              然而某次互联网崩溃,竟源于一个未验证输入的Rust程序。

              1. > 然而某次互联网崩溃竟源于一个未验证输入的Rust程序。

                不,该程序确实进行了输入验证,正因输入无效才触发了错误。

                人们可以尽情讨论unwrap的问题,但如果代码直接用?返回错误给调用方,最终同样会导致HTTP 500错误。

              2. > 嗯,具体是哪个权威这么说的?

                谷歌就是其中之一。https://security.googleblog.com/2025/11/rust-in-android-move

                > 然而互联网却因某个未验证输入的Rust程序而崩溃。

                你忽略了其他因素(问题并非 由Cloudflare的Rust代码引发),即便撇开这些不谈,你的表述也不准确。该Rust程序崩溃是因为程序员选择在遇到无效输入时让程序崩溃——这种情况在 所有编程语言中都可能发生 ,与Rust本身无关。

                1. 谷歌安卓团队同样将陈旧的C代码归类为C++,甚至在现代C++代码中混入goto语句。

                2. > 这在所有编程语言中都可能发生。与Rust无关。

                  事实并非如此。这同样涉及文化因素。在Rust社区中,我观察到大致存在两种群体:

                  第一类群体不认为安全、保密性和正确性是语言的责任,而是将其视为自身责任。他们仅在语言能提供帮助时表示赞赏,并在语言造成阻碍时采取预防措施。他们努力保持自我诚实。

                  第二类群体则漫不经心,常做出各种毫无根据的宣称与行为,有时甚至接近邪教式或帮派式的行为与信念。例如,他们会在完全不适合的项目中滥用 unwrap() 遍布代码库,或宣称某个 Rust 项目具有内存安全性——即便该项目充斥着大量基本错误和引发未定义行为的漏洞,且到处使用了不安全的 Rust 代码。

                  令人惊讶的是,第二类群体规模庞大,对安全、可靠性和正确性造成严重损害。

                  1. 重申:这与当前讨论的核心无关——核心在于“任何语言中,开发者都可选择在不可恢复状态时让程序崩溃”。仅此而已。

                    请解释这些所谓的魔法群体如何与语言特性相关?哪门语言能凭空变出三倍内存——只因上游查询返回了200多条记录,而你只支持60条左右?

                  2. 我认为你并非在反驳当前对话对象。即便你将分类视为事实,也无任何依据表明这类分类仅限于Rust程序员。换言之:

                    > 这在所有编程语言中都可能发生。与Rust毫无关系。

              3. > 然而互联网却因某个未验证输入的Rust程序而崩溃了。

                告诉我哪门魔法语言能编出零错误程序?与其因程序员未预见的不变量导致有序崩溃,不如直接崩溃破坏内存完整性?类型系统和内存安全固然优秀且价值非凡,但作为计算机科学家我们都清楚:逻辑错误至今仍是未解之题。

              4. > 然而互联网却因一个未验证输入的Rust程序而崩溃了。

                什么?Cloudflare的漏洞源于系统配置故障,最终引发连锁反应(包括但不限于)某个硬编码限制的Rust程序发生剧烈崩溃。该Rust程序绝非导致互联网瘫痪的元凶——它只是警报器,而非泄漏源。任何试图将责任归咎于Rust的人都根本不懂自己在说什么。

          2. > 还不如用C语言,成本差别不大。

            当你的不安全区域很小,你会投入大量精力去测试这些小块代码。你会写安全注释解释为什么它是安全的(因为你从一开始就假设那里有龙)。这些代码会经过多人审阅,并使用miri等自动化工具测试。所以别说“干脆用C算了”——两者根本不在同一维度。成功概率天差地别。优秀的Rust程序员审慎使用unsafe,而C程序员几乎不会犹豫,因为他们必须确保代码的每个片段都安全——在大规模程序中这根本是天方夜谭。

            顺带一提,作为资深C语言开发者,Rust的生态系统和现代构造让大型程序编写轻松许多——这还没考虑我前述的内存安全优势。

              1. 我认为你可能误解了GP的评论。他们并非声称安全注释和MIRI能保证正确性/安全性;这些只是作为示例,说明开发者会对代码库中相对较少的不安全代码块投入额外精力,从而使“成功概率大幅提升”,而非“还不如直接用C语言”。

          3. 这段评论省略了原文的关键前提:

            > 首先,若你并非编写设备驱动/内核等底层代码,程序中出现不安全使用的概率极低。

            而所有C代码本质上都是隐式“不安全”的。Rust至少实现了显式化!

            即便忽略“unsafe”绕过的内存安全问题,Rust仍强制你处理错误——它不会让你在空指针处崩溃却毫无编译器保护,还允许你用总和类型等手段全面表示数据等等等等

            1. Rust不是被推崇为系统语言吗?不是那个渴望被Linux内核接纳的语言吗?

              设备驱动程序不都存在于Linux内核树中吗?

              那么设备驱动程序代码中普遍允许使用unsafe代码?

              既然如此,为何不直接使用C语言?

              1. 我确信像你这样长期活跃在HN的用户,完全能理解以下差异:在类型安全保障极弱的语言中仅有0%编译器强制内存安全,与在类型安全保障强劲的语言中——即便在执行DMA的底层驱动程序这种最恶劣场景下——仍有95%以上的代码区域获得强类型安全保障。

                  1. 前两篇文章内容相同,但都指出某些结构在Rust中难以实现,链表便是著名案例。这个观点成立,但我认为这种取舍是值得的(作者文末也提到他们依然认为Rust很棒)。

                    第三个链接简直荒谬至极。谁会想用这种方式在Rust中初始化结构体?这好比说函数式语言难用,只因它不支持goto语句。作者刻意挑战Rust的设计原则,完成违背语言特性的操作后,再抱怨实现难度。

                    若需与非Rust代码交互,直接将C风格字符串写入内存反而更简单。

                  2. 你这种说法仿佛认为程序编写难度增加0-5%就足以否定将内存安全漏洞隔离在该范围内的所有优势。事实并非如此。

                    1. 而且实际情况往往远超5%,因为某些项目既包含大量大型不安全代码块,又存在这样的情况:单个不安全代码块的存在可能需要验证远超该代码块本身的内容。若我的理解远比你透彻,这实在令人沮丧。

                      即便按字面理解,你的论点也站不住脚——若难度显著提升,且涉及最关键的代码(如某些复杂算法这类本就艰深的代码),整体效果反而可能更差。Rust语言正是允许开发者为算法实现使用unsafe特性,以兼顾灵活性与性能。

                    2. > 因为 如果 难度大幅增加, 涉及最关键的代码(本身就已相当复杂的代码,如某些复杂算法),那么整体而言反而可能更糟。

                      (强调部分为后加)

                      但整体而言真的更糟吗?

                      推测某些假设情境 可能 成立很容易。当然,这种推测本身并不能成为相信其真实性的理由。你能提供证据支持你的推测吗?

                  3. 难道三个随机人士声称Rust的unsafe模式很难,就能让我们忘记C语言那些传奇性的问题——未定义行为、空指针、内存管理漏洞以及数不胜数的CVE漏洞?

                    你完全缺乏客观视角。即便我们接受“Rust不安全模式比C语言更难”这个前提(坦白说这本身就荒谬至极),我们讨论的也只是实际Rust程序中极小比例的代码。而C语言几乎每行代码都需要你高度警惕潜在问题。

                    恕我直言,这可能是我在Hacker News参与过的最愚蠢的争论。

                    1. > 即便我们接受“不安全的Rust比C更难”这个前提(坦白说这本身就荒谬至极)

                      我认为这 高度 取决于具体处理何种不安全代码。一方面可能是get_unsafe这类相对简单的操作,或调用基础FFI函数;另一方面则是为自引用结构体设计安全、符合人体工学且可靠的API——这显然是当前活跃的实验领域。

                      当然,在此语境下这些都是吹毛求疵;你的评论本身并不依赖于括号内容。

                    2. 但这并非吹毛求疵。请改进。

                    3. > 应该将Rust与C比较还是与C++比较?

                      既然是你要求与C比较,且本子讨论串主要针对C展开,你自己决定吧。

                      > 现代C++提供了许多类似Rust的特性,使该议题更易处理,尤其在程序规模扩展时。但它无需满足“无全局别名”等要求。尽管C++存在诸多问题。

                      确实如此,后者正是前者带来的权衡代价,这并不意外。

                      遗憾的是,即便是现代C++也尚未(至少目前)为Rust攻克的最棘手问题提供理想解决方案,但任何改进都比毫无进展更值得欢迎。

                      > 这种说法有误吗?

                      真的 有误吗?你能提供证据支持这种论断吗?

      5. 我编译的库中有个使用unsafe的类型。从中领悟到两点:首先,虽然库内部使用unsafe,但用户完全无需接触它——它表现得像普通类型实现,只是占用一半内存。除开发这个库外,我从未使用过unsafe。

        其次,unsafe意味着作者需确保其安全性。在Rust中,安全意味着必须遵循与unsafe代码相同的规则。这绝不意味着可以无视规则。若有人利用unsafe来规避规则,代码必然会导致崩溃。

        我理解有些程序员会用unsafe来“绕过”安全Rust强制执行的规则,但这种做法几乎必然导致崩溃。当编译器禁止某操作时,若强行使用unsafe实现,必然引发崩溃。

        反之,若通过unsafe遵循规则,则不会崩溃。Miri等工具可帮助验证代码是否违反规则。Miri在我开发的库中发现两个问题的事实,恰恰说明正确使用unsafe并非易事。我的库运用了精妙的位操作技巧并包含对象图,因此必须借助 unsafe 实现反向指针等功能。这些操作均在内部进行,用户仍可在安全 Rust 环境中使用该库。若仅用 unsafe 实现双向链表等结构则无碍;但若违反规则允许多线程同时修改相同指针,系统必将崩溃。

        问题在于,当你用C或C++编程时,这相当于 时刻 在编写不安全的Rust代码。在C/C++中,“不安全代码的孤岛”就是整个代码库。当然,你可以编写安全的C代码,就像我能编写安全的“不安全Rust”代码一样。但99%的代码都是安全的Rust代码。而C或C++中根本不存在这样的等价实现。

  2. 我真正喜欢Zig的原因在于,终于出现了一种能在应用层轻松优雅处理内存耗尽的语言。再不必祈祷程序不会因请求更多内存而被粗暴终止——所有分配都默认可能失败,必须显式处理错误。栈空间不再被视为魔法:编译器可通过分析调用图推断其最大容量,因此可预分配栈空间确保栈溢出绝不会发生。

    这种将内存作为资源的一流表示方式,是嵌入式环境中构建健壮软件的必备条件。在该环境中,必须通过启动时预分配所有资源来前置所有潜在错误,同时允许应用程序自由采用适当机制(背压、负载卸载等)处理资源过度消耗问题。

    1. > 无需再祈祷程序不会因请求更多内存而被无情终止——所有分配操作均被视为可能失败,必须显式处理失败情况。

      但在启用超额分配的操作系统(包括Linux)中,你永远不会目睹分配失败的瞬间,这正是问题的症结所在。纵使语言层面设计再完善,也无法真正解决根本问题。

      1. 即便在启用超额分配的Linux系统中,实际场景下仍可能发生内存分配失败。

        可通过进程/cgroup设置限制。服务器环境中不应依赖交换分区运行(性能损耗可能导致所有操作超时,效果等同于离线),因此可设置与物理内存成比例的限制,让进程在系统启动OOMKiller前率先触发内存不足。不进行进程分叉且未采用虚拟内存优化策略的进程,其超额分配影响有限。但当分配规模足够大时,内存分配可能在页面映射阶段(而非故障发生时)真正失败。

        此外,通过https://lib.rs/cap等软限制机制,可在所有操作系统上可靠地观察到Rust的内存不足现象。这对于在内存问题演变为系统级故障前限制进程内存使用极为有用,同时也是应对某些超大分配突破应用程序限制时的有效额外防护。

        在我参与的服务中,这类“不可能”事件时有发生。处理过程中最棘手的是Rust标准库的阻碍——它甚至在尝试之前就放弃了努力。在Rust标准库不干扰的情况下,内存不足处理机制已足够实用。

        问题根源在于Rust本身。

        1. 关于交换空间的说法我常有耳闻,但坦白说并不令人信服。或许十二十年前尚有道理,但如今?DIMM的CAS延迟持续上升,NVMe带宽也在提升。根据内存访问模式,以及数据是否能放入NVMe控制器的缓存(三星9100型号近期已配备4GB DDR4缓存用于预取),你的应用程序可能运行得相当流畅。

      2. 超额分配仅在使用系统分配器时才重要。

        在我看来,Zig语言显式分配器依赖注入设计的精髓,正是为了便于 规避 系统分配器,转而采用更高效的方案。

        例如设想一个Web服务器:每个请求处理器分配1MB空间,所有内存分配仅在该区域内进行简单的“递增分配”。

        这种设计具有多重优势:- 分配操作无需与全局分配器同步- 避免堆内存碎片化- 无需释放内存,可直接复用空间处理下个请求- 无需管理所有权——请求处理器创建的对象仅在处理器返回前有效 – 便于设定内存使用上限,并在触发时轻松检测并返回错误。

        此类系统中必然会出现分配失败的情况。

        若有人介意超额分配,可在启动时预先分配所需空间并调用 mlock() 确保其驻留内存。

        1. Rust团队正在探索在语言中引入局部分配器/存储区,或将其泛化为“存储区”(Storages),该特性可能与安全转换(safe transmute)或定位式new等其他开发中的特性产生复杂交互。整个设计空间尚在演变中,因此尚未纳入稳定版Rust。

      3. 当然,但你可以采取次优方案——精确控制内存分配的发生时机与位置。即便崩溃风险无法完全避免,使其可预测性仍能带来巨大的运行效益。

        最简单的例子是在启动时分配并固定所有资源。若发生崩溃,系统会立即报错并显示清晰的错误信息,解决方案也极其直接,只需“向–memory标志传递更大数值”或“配置更强大的机器”即可。

        1. 不,这仍然是误解。

          超额分配意味着内存分配操作不会立即报告失败,即使系统已耗尽内存。

          相反,失败会在后续某个随机时刻发生——当程序实际尝试使用系统虚假声称已分配的内存时。

          启动时一次性分配所有内存并无助益,因为程序在后续访问该内存时仍可能失败。

          1. 公平地说,只需将分配的内存全部填充为零即可强制实现,因此启动时就可能失败。

            或者更简单的方法:直接关闭超额分配功能。

            但若涉及交换分区,或操作系统后续为关键任务调用该内存,程序仍可能被终止。

            1. 若真有操作系统能检测到全零页面并在需要前保留分配空间,我倒会感到惊讶。这种情况在内存紧张时相当常见,值得特别处理。目前我未见有系统实现此功能,但实现难度不高,理应有人尝试。

          2. 正因如此我才强调“分配 并锁定 ”。POSIX系统通过mlock()/mlockall()实现内存预加载,防止已分配内存被换出。

            1. 作为好奇者提问:mlock()本身是否会触发预加载?还是说还需手动覆盖该内存区域?

              (我理解mlock防止分页,但在我看来这与预加载是两个独立的问题?)

              1. FreeBSD和OpenBSD在mlock(2)手册页中明确提及预加载行为。Linux手册页则暗示需通过系统调用变体mlock2()显式传递MLOCK_ONFAULT标志才能禁用预加载功能。

      4. 我猜在意这类细节的人,要么乐于禁用超额分配,要么直接在嵌入式或专用系统上运行Zig——这类系统本就不支持超额分配。

        1. 运行/编写Zig的开发者中,使用超额分配系统的远多于禁用超额分配的。围绕Zig的大部分炒作都来自非嵌入式领域的人群。

          1. 若能产出大量能应对分配失败的软件,将超额分配设为默认选项的构想便可行。

            不同命名空间可能需要不同语义(例如允许容器选择退出超额分配)的设想并不牵强。

            除非能惠及远超少数用户的群体(否则他们完全可以自行维护内部分支),否则很难证明为此投入的精力具有合理性。

            1. > 若能开发出大量能应对分配失败的软件,则采用超额分配以外的默认机制便具有可行性。

              但这永远不会实现,因为99.9%的程序根本无法做到“应对分配失败”。

              假设你在编写一个需要分配内存的程序。你分配内存,检查结果。失败了。怎么办?若存在闲置内存(如缓存),可尝试清空。但恕我直言,我从不手动在内存中随机缓存数据,几乎没人这么做。内存中存放的唯有程序运行必需的资源。既然没有冗余数据可驱逐,除了放弃别无他法。

              人们不检查分配失败并非懒惰,而是出于务实考量——他们明白这种情况下除了程序崩溃别无他法。

              1. 我以前在Opera浏览器里经常遇到内存分配限制问题。通常是渲染或图像解压时分配大块内存失败,这种情况只能暂时放弃当前标签页的渲染。它对这类错误的容错性非常强。

              2. 你认真考虑过比直接崩溃更优的处理方案吗?

                比如在退出前先将数据写入文件,再优雅地报错退出。可(谨慎地)输出至标准错误流。可关闭远程连接。可终止当前事务并返回错误代码。诸如此类。

                多数程序终究会终止运行,但相比随机地址指令引发的段错误,这些处理方式显然实用得多。

              3. 即使我有缓存——它很可能位于不同的代码路径/模块中,而允许我访问该代码的架构设计将极其糟糕。

                1. 访问“紧急按钮”功能的方式,远比随意崩溃要好得多。

          2. > Zig的大部分炒作都来自非嵌入式领域的人。

            这又与Rust有相似之处。

          3. 我从未说过所有Zig用户都关注内存分配失败的恢复机制。

      5. > 你永远不会看到分配失败的实际过程

        永远?在内存和存储都有限的小型Linux单板计算机上,内存该放哪里?

        1. 它通过终止进程处理内存不足问题。

    2. 我不了解Zig。文章提到“既然已有Rust,很多人困惑Zig为何存在”。但我更想问:既然已有C语言,Zig为何存在?它只是“更优”的C吗?但保留了C语言开发痛点——手动内存管理?除非项目确实需要手动管理,否则使用带垃圾回收的语言更明智。此时你可在C、Rust、Zig(以及C++等数百种语言)中选择。

      1. 没错,它确实是更优的C语言,但如果C能标准化胖指针,让开发者在不同项目间切换时不必反复核对语义,岂不更好?比如将四十年来C语言积累的五十余项“经验教训”纳入语言规范,使其成为语言的核心特性并集成到标准库中。

        1. 面对WG14的现状,连C语言的原始作者都未能推动变革,还能说什么呢?

          注意他们无人继续参与WG14工作,各自在Plan 9系统中发展C语言,而在Inferno系统中,C仅用于内核开发,其余部分采用Limbo语言实现,最终仅对Go语言初始设计作出微小贡献。

          崇拜UNIX和C语言的人们,不妨花些时间了解这些作者早已转向新领域,致力于修正他们认为原始作品存在的缺陷。

      2. 我认为核心理念在于消除C语言的痛点,同时避免给底层代码编写者引入额外困扰。

    3. 若预分配内存,Rust同样能妥善处理吧?

      确实认同在Rust中依赖库(包括std库)的内存分配更令人困扰,毕竟它会因内存不足引发panic。

      无std库的crates集合已全面适配嵌入式开发环境。

    4. 在称赞Zig语言开创先河之前,建议先研究自1958年JOVIAL以来的系统编程语言发展史。

    5. > 栈空间并非魔法般存在——编译器可通过分析调用图推断其最大容量,因此可预分配栈空间确保栈溢出绝对不会发生。

      那么在递归或通过函数指针调用的场景下如何实现?

      1. 递归:很简单,别用。至少别用调用栈。改用基于有界分配器的栈容器,循环执行弹出→处理→压入操作。原本可能发生的栈溢出现在会变成错误。此时可捕获并处理OutOfMemory枚举异常。不过已有提案致力于使递归函数更适于静态分析[0]。

        函数指针:Zig语言提出限制性函数类型方案[1],可用于强制编译时约束函数指针的赋值对象。

        [0]: https://github.com/ziglang/zig/issues/1006 [1]: https://github.com/ziglang/zig/issues/23367

    6. Linux系统启用了超额分配机制,因此malloc失败已逾十年未曾发生。Zig语言却迟迟未能跟进,强迫开发者应对一个早已不复存在的场景。

      1. 在 Linux 上可关闭此功能,某些操作系统默认即为关闭状态。尤其在嵌入式领域——这是原生编程的主战场。若应用程序不愿处理内存分配失败,可直接终止运行。

        此外即使启用超额分配,若误传入明显错误的大小(如 -1),malloc 仍可能失败。

  3. 虽然我不是第一个指出这条GitHub评论的人,但它完美展现了Rust概念的密度:

    但实际使用Rust时,你只需掌握其中约5%的概念即可高效编程。在使用Rust的12年左右时间里,我 从未 需要了解过#[fundamental]这类概念…

    > 在Go和Rust中,分配堆内存对象如同从函数返回结构体指针般简单——分配过程隐式完成。而在Zig中,你必须显式分配每个字节。[…]你需要对特定类型的分配器调用alloc()函数

    在 Go、Rust 以及许多其他语言中,你往往需要为对象图中的每个对象逐块分配内存。你的程序中存在成千上万个隐藏的 malloc() 和 free() 调用,因此也存在成千上万种不同的生命周期。

    Rust同样支持区域分配,并引入了分配器概念。当然也存在默认分配器。

    通常堆分配是显式的,例如通过Box::new实现,但这可能被封装在其他类型或函数中(例如String、Vec同样会进行分配)。

    > 在Rust中创建可变全局变量极其困难,论坛上有大量讨论专门探讨如何实现。

    所链接的讨论串针对特定类型的可变全局变量,且存在该讨论特有的额外要求。而我所说的“默认情况”下,标准的“我需要全局变量”实现可以简单到如下形式:

      static FOO: Mutex<T> = Mutex::new(…);
    

    由于可变全局变量本质上存在内存安全隐患,必须使用互斥锁。

    (显然,此类问题往往存在XY问题——当有人声称需要全局变量时…)

    关于安全性,我想补充的是:Rust不仅倡导内存安全,其类型系统更能为代码提供安全保障。例如String类型能确保始终表示Unicode字符串,且无需语言层面的特殊支持。

    1. > 但你只需掌握该评论中约5%的概念就能高效使用Rust。

      针对C++的类似论点同样适用:其他程序员可能只用到10%(或另一种5%)的概念。与他们协作时你仍需学习这部分内容。阅读随机项目的源代码时也可能遇到这种情况。C程序员很少面临此类问题。复杂度至关重要。

      1. 还有一类人要么聪明反被聪明误,要么自视过高。这两类人都可能用极其迂回的代码实现简单功能,每次遇到都令人抓狂。这当然不是Rust独有问题,但你给他们越多工具,他们制造的混乱就越大。

    2. > Rust也能实现区域分配,

      难道还有语言做不到吗?

      作者并非断言批量分配完全不可能,而是指出Rust和Go的默认编程模式往往会产生大量分配操作。这种观点比简单的“可能”与“不可能”二分法更具层次感。

      1. 在没有JVM原生支持的情况下,Java很难实现竞技场分配。

          1. 所以是一年前的事?在缺失超过25年之后?

            而且很多Java开发者无法升级到这个版本。

    3. > Rust里也有分配器概念。

      分配器在Rust里不是类型吗?

      假设你有种m:n系统(比如事件驱动的HTTP请求服务器,跨多个线程运行,每个线程可能处理多个入站请求),能否给每个请求分配独立的内存区域?

      1. Rust中的分配器是实现分配器特性的对象。通常需将分配器对象传递给使用分配器的函数。例如Vec::new_in(alloc: A)中,A即为Allocator类型。

        因此在您的示例中,若每个请求可使用相同类型的分配器,则需为该类型创建独立实例。例如,您可声明“需要一个Arena”并选择实现分配器特性的Arena类型,随后在每次调用Vec::new_in(alloc)时创建新的Arena实例。

        或者,若希望每个请求都拥有独立的分配器类型和实例,可采用Box<dyn Allocator>作为分配器类型(或使用其他分派模式),并根据需求提供相应的分配器实例。

        1. 需要明确的是,分配器 API 仍处于实验阶段,据我所知这个状态已持续相当长一段时间。

    4. > Rust 同样支持区域分配,且存在分配器概念。

      纯粹疑问:Rust 分配器是否全局化?(所有堆分配是否使用同一分配器?)

      1. 不是。虽然存在默认的全局分配器,但所有分配内存的标准库函数都提供可传入自定义分配器的版本。不过这些函数目前仍处于“不稳定”状态,仅能在开发版编译器中使用。

    5. 在 Go、Rust 以及许多其他语言中,你往往需要为对象图中的每个对象逐次分配少量内存。你的程序中存在成千上万个隐藏的 malloc() 和 free() 调用,因此也存在成千上万种不同的生命周期。

      Rust 同样支持区域分配,并且也存在分配器概念。只是默认分配器也存在。

      谢谢。我见过太多人重复这种说法。Casey Muratori曾制作过关于批量分配的视频,内容极具启发性,但又带着愚蠢的门槛主义倾向[1]。我认为许多自诩为超级开发者的人,根本没理解这个概念就盲目附和。他们总说RAII机制让批量分配成为不可能。

      去年Zig软件基金会针对朝日莉娜关于Rust的评论发表文章,暗示她无意中引入了这些隐式分配,并引用了凯西·穆拉托里这段视频作为依据。这实在令人费解。包括Lina在内的多人指出该文存在诸多谬误[2]。加之Andrew曾称Go是“缺乏品味者的选择”(虽我本人也不喜欢Go),我实在不欣赏Zig这种通过贬低其他公司和语言来推销自家产品的做法。

      [1] https://www.youtube.com/watch?v=xt1KNDmOYqA [2] https://lobste.rs/s/hxerht/raii_rust_linux_drama

      1. Rust中的“批量分配”本质上只是将自定义对象元组装入Box,而非为每个对象单独创建Box。你甚至可以在元组中包含MaybeUninit类型,这些类型可在后续不安全代码中初始化,并事后转换为初始化后的类型。对于这种简单场景,完全不需要分配器库——这类库的价值主要体现在分配结构动态变化的情境中。

        1. > 对于这种简单场景,完全不需要分配器库——这类库的价值主要体现在分配结构动态变化的情境中。

          不过除非有充分理由避免使用,我仍会选择类似 Bumpalo(https://crates.io/crates/bumpalo)的工具。

  4. > 在Go中,切片本质是内存中连续序列的胖指针,但它能动态扩展,因此兼具Rust的Vec<T>类型和Zig的ArrayList功能。

    其实不然。这恰恰体现了Go“简单而非易用”的设计哲学。

    Vec<T>具有内存标识性,而Go切片底层内存则没有。调用append()时返回的是 全新切片 ,其内存可能与旧切片共享也可能不共享。此外,切片底层内存 无法缩减 。因此切片的工作原理与Vec<T>截然不同。新手常犯的错误就是误以为两者相同,直接写“append(s, …)”而非“s = append(s, …)”。这种写法甚至可能在多数情况下随机生效。

    Go程序员的态度是:“按我说的做,相信我在说之前已阅读过库文档”。Rust程序员的态度则是:“验证我是否兑现了承诺,并确保我的说法与库文档的规范一致”。

    因此(概括而言)Go不会为增加语言复杂度而实现易出错的功能;Rust则会通过增加语言复杂度来消除更多错误。

    1. > 切片底层内存也无法缩减。

      抱歉,此说法有误:https://pkg.go.dev/slices#Clip

      > 新手常误以为切片可直接扩展,误写“append(s, …)”而非“s = append(s, …)”。这种写法甚至可能随机生效。

      省略赋值的“append(s, …)”根本无法编译。所以你的整个论点似乎是稻草人谬误?

      https://go.dev/play/p/icdOMl8A9ja

      > 因此(概括而言)Go不会为增加语言复杂度而实现易引发错误的功能

      不,我认为更准确的说法是:Go在添加特性时,会审慎权衡语言复杂化的折衷代价,而其他语言往往做得不够。

      1. 截断操作是否会使剩余部分进入GC回收?

        截断似乎不会自动移动数据,因此虽然追加操作会重新分配内存,但底层数组实际并未缩小,对吧?

    2. 将“s = append(s, …)”改写为“append(s, …)”会因未用表达式导致编译器报错。我不确定新手如何犯这种错误,毕竟这种代码根本编译不了。

      1. 常见错误确实是:

            b := append(a, …)
        
    3. 既然泛型已支持实现,Go社区至今仍未形成通用的List[T]类型,这似乎有些奇怪。或许是因为传递可增长列表的情境并不常见。

    4. Go程序员的态度是“照我说的做,相信我在说这话之前已经读过库文档了”。

      我同意这个观点,并认为Go在某些方面受到不公正的指责:人们所说的Go语言中的多数陷阱在规范/文档中都有明确说明。这些行为令人意外吗?还是你根本没读过文档?

      若编译器不够严格,仅靠安装编译器随意敲代码绝非理想的学习方式。

      1. 当工具行为违背普遍认知时,即使文档有载,指责它也并非无理——这本质上是设计缺陷。

        1. 在编程语言领域,除极简单技术外不存在所谓“公认规范”。学习多种语言者自会发现相同功能存在多种实现方式。

          例如文中提及的三种语言,其错误处理机制各不相同,且均非主流选择。

          尤其内置数据结构方面,每种语言的实现略有差异,学习其特性实属必经之路。

        2. 讽刺的是,在Zig语言中,多数违背预期的行为都与关键字相关。初学者往往会撞上大量这类陷阱(好在编译器会报错),但经历这些后反而能建立起非常扎实的认知模型。

        3. “显然是你的错!难道没发现我们把剃须刀片嵌进锤子里了吗?你以为能安全拿起工具吗?”

  5. 关于UB:

    > 核心思路似乎是:通过在检查发布模式下充分运行程序,就能合理确信未检查构建版本不会出现非法行为。在我看来这是相当务实的设计。

    这种务实性建立在忽视现实经验的基础上——那些试图实现相同目标的清理工具,在部署后的C/C++代码库中始终未能有效防范内存安全和未定义行为问题(例如Android每次提交都运行清理工具,但直到切换到Rust后漏洞才开始消失)。

    1. 能否提供“(例如Android确实对每次提交都运行清理程序,但直到切换到Rust后漏洞才开始消失)”的出处?

  6. Rust纵容你成为糟糕的程序员,这是不可原谅的;使用Zig会让你成为更好的人。

  7. 我喜欢这种观点——部分是因为认同其内容,但更重要的是它展现了比较编程语言(及呈现结果)的正确方式。这种分析以诚实的方式揭示优劣,有助于在脱离工作压力的情况下指导、优化并合理化语言选择。

    遗憾的是它未提及 Raku (https://raku.org) … 因为在我看来存在某种连续谱:C – Zig – C++ – Rust – Go … 低级语言如此划分合理,但脚本化一端呢?Julia – R – Python – Lua – JavaScript – PHP – Raku – WL?

    1. 我曾尝试让大型语言模型按同样思路写Raku章节——不行。最终还是自己写了:

      Raku

      Raku以快速生成可运行代码著称,其宽松编译器支持广泛表达。

      作为一种表达力强的通用语言,它内置了丰富的工具集。多重分派、角色机制、渐进式类型系统、惰性求值以及强大的正则表达式与语法系统构成了其核心设计。该语言旨在提供直接映射问题结构的途径,而非从零构建抽象层。

      语法系统便是最鲜明的例证。多数语言将解析视为需依赖外部库的专项任务,而Raku提供声明式语法来定义规则与语法,使得处理文本格式、日志或领域特定语言时,往往能以更少的代码和更少的变通方案实现目标。这种能力与语言其他部分自然融合,而非显得割裂。

      Raku程序运行于大型虚拟机并依赖运行时调度,这意味着它们通常不具备低级语言或静态语言的启动速度和可预测性能特征。但这种设计理念具有内在一致性:你将获得灵活性、清晰语义,以及随问题演变调整方案的空间。无论是构思草图还是精简已扩展为大型脚本的程序,增量式开发都显得自然而然。

      该语言漫长的开发历程源于对Perl的重新构想,而非简单现代化改造。这段历史造就了一种力求逻辑自洽、书写愉悦的语言——即便体量不小。若你渴望一种能随心编码、助你直面问题而非与编译器搏斗的语言,Raku正是理想之选。

      1. 我注意到我的Raku章节被点了几次踩。好吧,我就是个不加掩饰的吹捧者——为这种既出色又遭人嫌弃的语言摇旗呐喊。没试过就别妄下结论。

        下面有人评论说“想要Go语言,但要更强大的面向对象功能”——Raku正是遵循Smalltalk哲学…万物皆对象,它兼具C++的面向对象丰富特性(如绳子模式),支持多重继承、角色组合、参数化角色、MOP、混合…所有这些都以简洁易读的风格呈现。

          my $forty-two = 42 but ‘forty two’;
        

        若你厌恶符号,现在就别看了。

  8. 我认为Go语言的论述遗漏了关键要素:最简易的并发模型。Goroutines正是我最初选择Go的核心原因之一。

    1. 赞同。Rob Pike的演讲《并发不等于并行》阐释了Go并发模型的设计初衷:https://youtu.be/oV9rvDllKEg

      在没有“彩色函数”的限制下,配合通道通信的简洁性,Go语言能以(相对)快速简便的方式构建行为正确的并发系统,这点总让我惊叹不已。

      1. 仅凭能用直接方案实现原型,再通过包裹“go”语句并添加通道就能轻松实现并发,这本身就令人惊叹。

      2. 虽然用它实现并行处理略显凌乱,但依然可行且模式统一,还有库能为切片处理等场景添加并行支持。我认为这本可以更简洁——官方试图劝阻这种用法,但如今将N个任务分布到多核CPU上处理其实非常普遍。

        1. 确实如此。但根据我的经验,通过errgroup或基于通道的信号量使用短寿命goroutine的模式,只要限制足够高,通常能实现所有核心的满负荷利用。

          若采用向固定数量长周期goroutine供数据的模式,则效果可能不那么可靠。

      1. 但结构化并发如何实现任务间的通信与同步?

        设想一个处理事务请求的服务器:它向多个后台工作者提交任务并获取结果,这些工作者又向远程观察者广播变更事件。

        用Go的通道实现这种架构很简单,但我尚未见过采用结构化并发处理此类工作负载的实例。

        1. 结构化并发的核心价值在于:当代码需要实现此类功能时,必须采用预定义的结构化方式。这能确保安全可靠,避免像使用通道那样存在操作风险。

          1. 但具体该如何实现?架构和代码该是什么形态?

    2. Zig语言中全新的std.Io接口(目前仅在夜间构建版本中)与Go的并发构造映射得相当契合。go关键字对应std.Io.async实现函数异步执行。通道映射为 std.Io.Queue 数据结构,select 关键字则对应 std.Io.select 函数。

    3. > 这是最简单的并发模型

      Erlang 程序员可能不认同此观点。

      1. Erlang 确实擅长分布式系统。但令我困扰的是,人们看到分布式系统固有的并行特性后,面对某个本应并发的程序就说:“我知道了,我把程序变成分布式系统就能实现并发。”

        但分布式系统很复杂。若你的系统本质上并非分布式,就不要急于采用模拟分布式系统的并发模型。对于单台机器上的任何任务,请优先选择结构化并发。

        1. 你部署过Erlang系统吗?

          并发系统最大的痛点在于可变共享数据。而分布式系统天生具备可分布特性,本质上就“放弃了共享数据”,因此并发Erlang系统基本不会尝试处理共享数据——

          仅凭Erlang在并发设计上比Go更合理这一点就足以说明问题

          就像Go的协程并非天生可取消,因此Go开发者常需构建笨拙的上下文机制来处理这类场景,调试过程往往极其棘手

  9. 对于多数场景,我真正想要的是具备Rust般优秀泛型与结果/错误/枚举处理能力的Go语言。

    1. 最接近的可能是C#,但它本质仍是面向对象驱动的语言。

    2. 我也是。市场亟需一种原生编译语言:具备垃圾回收机制,且类型系统优于Go。

      目前我所见过的选项有:OCaml、D语言、Swift、Nim、Crystal,但似乎没有哪一种能占据显著的市场份额。

        1. 还有Haskell、Java、Kotlin、Scala、OCaml、D语言等等,不胜枚举。

    3. 你试过OCaml吗?最新版本拥有极其强大的并发模型。据我所知(虽未亲自测试基准),其性能也足以与Go抗衡。

      1. 若想要类似C语言大括号风格的OCaml,还有ReasonML可选。但两者都明显缺少Golang开箱即用的高性能并发GC。

        1. 据我所知,OCaml最近的多核GC表现相当出色。
          不过我没看过具体基准测试,所以这话还请酌情参考。

      2. 确实,大规模生产环境中OCaml的应用并不多,因此在我公司推广会很困难。这就像…如果收到Jane Street的工作邀约,我可能会纯粹为了用OCaml而接受呢哈哈。

        1. GitLab和Semgrep也有OCaml岗位哦,如果你正在找工作的话 🙂

          1. 说得对哈哈

            不过顺便提一句,我没看到GitLab有公开招聘OCaml的职位。都是Go和Ruby。而Jane Street倒是总在招OCaml开发,他们甚至专门招聘PL人才来做OCaml

      3. 现在的构建工具怎么样?上次我试用时,它用的是jbuild/dune加makefile的组合,搭建过程简直痛苦不堪。而且存在多个标准库和(我记得)异步运行时,它们之间难以兼容。语法和自定义运算符也是让我屡屡踩坑的痛点——虽然我曾认为语法相对不重要,但OCaml的实践彻底改变了我的看法。:)

        此外,至少当时社区氛围相当敌对,不过C++、Ada和Java社区也同样如此。但那些社区后来都变得温和了,OCaml或许也是如此?

        1. 几年前因雇主原因对OCaml产生倦怠后,最近我正重新探索这门语言,所以恐怕无法可靠回答这些问题 :/

          目前所见令人欣喜。

        2. OCaml社区氛围轻松友善,dune工具运行流畅且编译速度极快。

          这确实是门很棒的语言

        3.     $ dune init project my-project
              $ dune build
          

          仅此而已,现在你已拥有可编译的项目,可以开始开发了。

      4. 我曾觉得 OCaml 程序的结构有些令人困惑,Let 的用法也不够直观。Go 和 Rust 至今仍基本保留 C 语言风格。

    4. 虽然未获采纳,但我认为最近的错误提案颇具启发性:https://github.com/golang/go/issues/71528

      我期待他们能注意到这些反复出现的痛点,找到解决错误/结果/枚举问题的方案。(泛型实现会更困难,我认为)

      1. 我曾是原始检查句柄提案的拥趸:https://go.googlesource.com/proposal/+/master/design/go2draf

        我理解避免过度干预控制流的需求,但在半复杂的错误流程中,检查/处理机制的优雅性始终令我倾心。或许我是唯一更倾向此方案而非泛型支持的人。

        如今记忆已模糊——毕竟类似提案层出不穷——但记得后来还有个改进版的检查/处理方案更合我心意,不过显然我已不再关注此事。

      2. 他们不是说不再接受新的错误处理方案了吗?

        我最终也习惯了,但永远无法认同取消枚举是好事。

    5. 据我所知,OCaml是最接近的实现。

      1. 但它有替代方案(函数的编译时求值)。

    6. 你了解Zig的错误处理机制吗?它比Rust的做法更接近Go的风格。

      1. 不熟悉,但Zig的错误处理相当不错——你只需返回错误或值,并借助语法糖进行处理。考虑到该语言的底层特性,这设计相当巧妙。

        而Go的实现只是多次返回值却毫无检查机制,既能返回有效值也能返回错误。

        1. 但有时同时返回值和非空错误也很有用。即使遇到错误,部分结果仍可供后续操作;或者结果值本身就是有用的信息(无论是否伴随错误),就像Go语言中无处不在的io.Writer接口那样,它会同时返回写入的字节数和遇到的错误。

          我欣赏Go语言避免对开发者意图做限制性假设的设计理念(例如不默认认为返回非空错误就意味着不希望返回值)。这种提供简单灵活的底层组件供自由组合的特性深得我心。

          1. 那么直接返回代表预期结果的值即可,不必打破约定强行折腾,还指望调用方读懂注释说明。

            另外,让调用方自行传入存储中间结果的容器(输出变量、指针、可变对象,取决于语言特性)即可。

            1. > 而不是打破约定强行折腾,还指望

              这在Go中本就不是约定,自然不会破坏任何预期

          2. 但多数情况下你可能需要像Rust的Result<T,E>这样互斥的结构。若涉及“可能成功但存在部分失败”的情形,可采用无名元组(Option<T>,E)或其他方案。

    7. 我谨慎地表示赞同,但需补充:虽然我曾期待Rust的错误处理机制,实践中却痛苦不堪。我肯定是操作不当,但至今尝试过:

      * thiserror:耗费大量不可预测的时间调试宏展开

      * 手动实现ErrorFrom等特质:耗费大量可预测时间(或许LLM能解决?)
      * 总之能解决问题,但被告知不应在公共API中暴露这些错误

      除上述顾虑外,我也不喜欢用枚举表示错误,因为新增错误类型必然导致破坏性变更。我不愿承诺这种设计,但或许是我过度纠结了?

      向不同Rust开发者咨询时,我常得到相互矛盾的答案,似乎无人能对此给出权威解释。这些问题是否已在新版《Rust语言手册》中得到解答?

      相比之下,我处理Go错误时只需用fmt.Errorf(“opening file %s`: %w”, filePath, err)包装,特殊错误情况则通过errors.As()`等方法处理后继续推进。这种方式或许不够优雅,但能让我高效完成任务。

      1. > 除此之外,我不喜欢用枚举处理错误,因为这意味着新增错误类型会导致破坏性变更。我不愿承诺这种设计,但或许是我过度思考了?

        如果下游消费者需要了解新错误条件以执行不同逻辑,就添加枚举变体。这种模式的核心价值,正是要实现Java类型化异常的初衷:让调用方能预判可能出现的错误类型,并在可控范围内进行合理处理。

        若调用方无法合理恢复?使用泛型失败变体,若能封装内部错误并实现std::Error接口则更佳——至少调用方可通过.source()获取底层错误进行调试。

        > 相比之下,我直接用 fmt.Errorf(“opening file %s`: %w”, filePath, err)包装Go错误,特殊错误情况则通过errors.As()` 等处理后继续执行。这或许不够优雅,但能让我高效推进工作。

        在Rust中同样可以实现,只需添加一个通配符模式(_)的匹配分支来处理特殊情况之外的所有错误。

        实际上,若预见到可能新增错误变体,#[non_exhaustive]属性正是为此而设。它会强制调用方提供通配符模式的匹配分支,从而避免枚举新增导致API不兼容。该机制存在其他限制,具体请查阅文档,但确实能让你在无需重大版本号升级的情况下向错误枚举添加新变体。

      2. 至少需要说明的是:若新错误标记为#[non_exhaustive],则不会构成破坏性变更。此时编译器会确保枚举的所有匹配语句都包含泛型案例。

        但我不建议这样做。错误引发的破坏性变更未必是坏事。若需修改错误API,且下游实现必须支持泛型情况,它们将被迫在未明确检查新错误类型用途的情况下默默接受新类型。这在诸多关键场景中存在显著弊端。

        1. 诚然,Rust中几乎总能找到解决“非人体工学”问题的方案,但多数方案的存在是为了提供保证或表达假设,从而提高代码实现预期功能的概率。尽管这种安全性在某些大型系统项目中可能显得有些过度,但对于许多场景而言,若无需这些保证,Rust根本不是合适的工具。

          说到这个话题,我曾研究过用Rust开发游戏,但感觉这更像是自找麻烦?不过用它实现高性能后端算法和容器化逻辑倒是挺合适的。

      3. 顺便一提,fmt.Errorf(“opening file %s: %w”, filePath, err) 基本上等同于调用 err.with_context(|| format!(“opening file {}”, path))

        thiserror 或手动实现 Error 的价值在于能对更高层级的错误采取实际处理措施。在Rust设计中,面向公共API时不这样做确实被视为不良实践。而在Go中似乎无人介意,这固然让代码更易编写,但错误捕获很快就会变成字符串类型化。没错,在Go中完全可以正确实现,但过程极其复杂,我从未见过任何第三方库能正确做到。

        话虽如此,我同意在Rust中手动实现Error确实耗时过长。更复杂的是,处理错误这种基础功能竟需依赖第三方库。目前我尚未遇到thiserror相关的困扰。

        > 除此之外,我也不喜欢用枚举处理错误,因为这意味着新增错误类型必然导致破坏性变更。我不太喜欢这种设计思路,不过也许是我想多了?

        若想确保不引发破坏性变更,只需将枚举标记为#[non_exhaustive]。虽然不够优雅,但这正是该标记的用途。

        希望对你有帮助 🙂

        1. > 在Rust设计中,面向公共API时不这样做确实被视为不良实践。而在Go中似乎无人介意,这固然让代码更易编写,但错误捕获很快就会变成字符串类型检查。没错,Go中可以正确实现,但过程极其复杂,我从未见过任何第三方库正确实现过。

          没错,这正是我想说的。Go语言虽能实现,但过程晦涩繁琐,鲜有人实践,且极易出错。

          反过来说,虽然详尽检查所有错误类型很烦人,但很多时候这确实至关重要。至少需要明确的分类机制:将依赖项错误转化为可重试/不可重试、SLO消耗/未消耗、是否向用户暴露等状态。Go语言中常见的做法是直接塞进“if err != nil { return nil, fmt.Errorf}”这样的代码块。或许有人会想到检查特定的上游错误场景,但遗漏一两个情况实在太容易了。

      4. 若你执意要在Go中采用这种做法,无论如何暴露错误本质上都大同小异。唯一区别在于Rust还提供了你提到的其他选项。至于他人反对的做法,似乎不必过度担忧;要知道,若Go具备类似API能力,人们可能也会提出相同批评,但它确实不具备

      5. > 我也不喜欢用枚举处理错误,因为这意味着新增错误类型必然导致破坏性变更

        你可以用 #[non_exhaustive] 标注错误枚举,这样新增变体就不会破坏兼容性。本质上,你强制要求所有枚举匹配操作都必须实现“默认”情况——即匹配失败的处理逻辑。

      6. 使用Rust时要放轻松。随便用宏包装错误并直接输出日志即可。若特定场景必须使用特定错误类型,只需在父级堆栈中使用该错误。

      7. 我个人欣赏这种设计带来的灵活性。既可采用精细化模式——为每个函数定义错误类型,为每个错误情况创建枚举变体;也可采用粗放模式——用字符串错误类型覆盖整个模块。建议在库中使用 thiserror 定义错误类型,程序中则灵活处理。

  10. 根据场景偏爱Go和Rust,尚未尝试Zig

  11. 文章写得很好,我欣赏你的思路。字里行间透着应届毕业生的热忱——对编程世界充满憧憬,初入职场便展现出这份纯粹的激情。

    对于Go语言,我不会说避免泛型的选择是出于刻意为之或本质上的极简主义。据我所知,开发者们只是长期在艰难抉择中挣扎——究竟该做出哪些权衡取舍。我认为他们只是希望,随着时间推移,社区或许能提出某种创新方案来优雅地解决这些问题。十年后随着时间流逝,他们最终选定了某种方案——当然这可能是我误解了。

    关于Rust,我强烈反对两点观点:首先,生命周期确实是我(以及包括C语言权威著作作者Brian Kernighan在内的众多开发者)最头疼的特性;其次,Rust将多种设计理念融合的作法并非首创。诸如C#等语言同样如此。但我确实记得Rust对某些特性采用了颇为奇特的命名。此外,作为非C++背景的开发者,它解决的许多问题我从未接触过——这些问题对C++开发者而言耳熟能详,对我却是陌生的领域。

    关于Zig的手动内存管理,你提到:

    > 这与排除面向对象特性密切相关的设计选择

    或许如此,但我认为这更多源于Andrew在设计高性能应用时对数据导向设计(DOD)的需求。他去年关于DOD的演讲[1]非常精彩。我理解他的核心思想是:若要在保持语言易用性的同时编写极致高性能代码,就必须优先考虑完全不同的特性集。

    [1] https://www.youtube.com/watch?v=IroPQ150F6c

    1. > 对于Go语言,我认为避免泛型的选择既非刻意为之,也非本质上的极简主义。据我所知,他们只是在艰难抉择中纠结了很久——究竟该做出哪些权衡取舍。

      事实上早在2009年,Russ Cox就清晰阐述了他们面临的困境[1],可概括为:

      > 泛型困境的核心在于: 你究竟要慢速编程、慢速编译和臃肿二进制文件,还是要忍受慢速运行时?

      据我所知,他们最终在底层设计中找到了巧妙方案,成功缓解了这一困境。

      [1] https://research.swtch.com/generic

      1. 我不确定是否存在真正巧妙的技术解决这些问题,他们只是通过接受泛型的动态分派,默认选择了执行速度慢的方案。

      2. 颇具讽刺意味的是,谷歌最新研究已明确证明:Rust程序员的开发效率并不比Go程序员“更慢”或更低。尤其当考量整个软件生命周期(含生产环境支持与维护)时,这一结论更为显著。

        1. 在此背景下,“低效开发者”选项实为“无泛型支持”方案(即C语言及Go 1.18之前的版本)——开发者需为每种独立类型重复实现代码,而非通过泛型实现一次通用代码。据我所知,Rust 选择了与 C++ 相同的道路,即“编译耗时长且二进制文件臃肿”的方案(以换取最终优化的二进制文件)。他们称之为“零成本抽象”,但本质上是将成本从运行时转移到编译时。(正如原文所述,这是一种权衡。)

        2. 所谓“研究”,不过是谷歌内部一群Rust拥趸的宣称,缺乏严谨的学术方法论支撑。

  12. 在Go和Rust中,在堆上分配对象就像从函数中返回结构体指针一样简单。

    我实在无法理解作者对Rust的设想。

    难道他们真以为创建局部变量指针再返回指针,就能算作堆分配?并非如此——该局部变量位于栈上,返回时已消失,导致指针失效。不过Rust允许存在无效指针:毕竟 安全 模式下无法解引用任何指针,而 不安全 模式下程序员需确保解引用的指针有效(指向已消失变量的指针显然无效)

    [若使用足够新版本的Rust,Clippy现在会警告此操作不可取——虽然合法,但几乎肯定不是开发者本意]

    或者他们可能认为Box<Goose>是“指向结构体的指针”,因此Box::new(some_goose)函数调用属于“隐式”分配,而他们在Zig中为Goose分配内存的函数调用才是显式分配?

    1. 是的,这让我非常困惑。我不明白为什么有人会混淆Go语言通过逃逸分析隐式决定是否将指针提升到堆上,而程序员除了在运行时复制该逻辑外别无选择,却又必须显式使用那些专门用于堆分配的API——除非是根本性误解,就是故意误导。

  13. 我始终无法接受Zig的语法,而且我知道不止我一个人这样。能否有人解释Zig设计时那些奇怪的选择?

    最奇怪的可能是'const expected = [_]u32{ 123, 67, 89, 99 };'

    其次是偏离标准的'try'关键字替代问号(?);

    第三是繁琐的导入语法;

    而基础输出操作竟要写成try std.fs.File.stdout().writeAll(“hello world!\n”);也实在令人费解。

    1. 我永远无法理解那些贬低其他语言语法和可读性的人,转头却声称自己偏爱Rust。异步Rust是我见过最丑陋、最难读的语言,而我曾大量使用重度模板化的C++

      1. 我永远无法理解那些声称不理解他人因语法贬低语言,却又贬低他人语言偏好的人。事实证明语言语法偏好本就主观,绝非非黑即白。

        比如Python语法总体不错,但我讨厌缩进被当作语法规则。我就是喜欢用大括号控制作用域。Rust对我而言两边都沾边:我喜欢Result和Option的匹配机制,但生命周期语法有时让我困惑。并非人人认同,这终究是个人观点。

      2. 我并非特别偏爱Rust,但比起Zig的语法我更选它——尽管C++模板系统简直邪恶。关键不在可读性,而在于其独特性。

      3. 赞同,但非异步的Rust是另一回事!

        1. 没错,我喜欢Rust但厌恶异步。真希望它从未被加入语言,因为当大多数程序根本不需要异步时,它却彻底侵蚀了库生态系统。

      4. > 异步Rust是我见过最丑陋、最难读的语言,而我曾大量编写重度模板化的C++

        不,这是个荒谬的断言,说明你要么从未写过异步Rust,要么从未接触过重度模板化的C++。若想反驳,请随意提供代码示例。

        1. 所有我不熟悉的语言都令人作呕。

          但说真的,我对语言的评价取决于需要记忆多少晦涩符号。Rust虽位居前列但尚可消化。想到C++就想吐,不过倒不是因为语法。

          1.   template<auto V>
              concept non_zero = (V != 0);
            
              template<typename T>
              concept arithmetic = std::is_arithmetic_v<T>;
            
              template<arithmetic T>
              requires non_zero<T{42}>
              struct complicated {
                  template<auto... Values>
                  using nested_alias = std::tuple<
                      std::integral_constant<decltype(Values), Values>...,
                      std::conditional_t<(Values > 0 && ...), T, std::nullptr_t>
                  >;
            
                  template<typename... Ts>
                  static constexpr auto process() {
                      return []<std::size_t... Is>(std::index_sequence<Is...>) {
                          return nested_alias<(sizeof(Ts) + Is)...>{};
                      }(std::make_index_sequence<sizeof...(Ts)>{});
                  }
              };
            

            我完全赞同。

            1. 区别在于没人会用这种方式编写应用程序代码,它只是编写库和创建抽象的工具。如果Rust异步编程的所有丑陋之处都局限在Tokio内部,我完全不会介意,但它却像病毒般侵蚀着所有接触到的代码

    2. > 第二大问题是用'try'这个词代替简单的?

      Zig的所有控制流都通过关键字实现

    3. 这些都是极其琐碎的细节,我甚至不明白你在抱怨什么。你期望或偏好怎样的设计?

      1. 问题不在于琐碎,而在于为何不采用已被广泛接受的方案?Zig为何偏要另辟蹊径?

        1. Go语言也是如此。但两者相比,我认为Zig仍更接近任何合理的现有语言模式。Go就像在说“我们来写C风格的类型声明,但把顺序反过来”,尽管已有被广泛接受的类型标记法用冒号(:)实现了顺序反转,还能让你以合理的方式推断类型。

          1. <auto/type/name> <name/type> (array?) (:)= (value)
            
            <fn> <generic> <name>(<type/argument>[:] <type/argument> [(->/:) type]
            
            [import/use/using](<package>[/|:|::|.]<type> | “file”) 
            

            (好吧,头文件确实是过去的产物,我不得不承认)

            我尝试过编写Zig代码,作为几乎用过所有常用语言的人,它的语法差异让我不得不反复查阅。

            1. 绝大多数语言都不采用这种写法,而Zig却与此高度相似。偏好哪种语法都无妨,但就编程语言而言,Zig其实相当普通。所以这些差异确实微不足道,抱怨未免有些小题大做。若你真花时间接触过Zig,本该轻松掌握其语法。

    4. ‘const expected = [_]u32{ 123, 67, 89, 99 };’

      用u32定义常量数组,让编译器自动推断元素数量(我保留未来修改的权利)

  14. 了解Rust的复杂性后,我更欣赏OCaml了。OCaml同样采用Hindley-Milner类型系统并提供类似的运行时保障,但语法更简洁,编译器速度极快。生成的代码运行效率也相当不错。

  15. 好吧,但三种语言的评价存在明显不对称。Go因隐藏内存细节遭批评,Rust因限制可变全局变量和概念密度受指责(还配了极具威慑力的Pin引用强调缺陷)。但当Zig出现类似缺陷时,却被重新包装成优点或轻描淡写带过。

    Zig 允许轻松实现全局可变变量(被包装成自由而非“现在可以编写数据竞争了”)

    运行时检查在发布构建中被禁用被称为“高度务实”,却未提及非法行为仅在生产环境显现时的后果

    标准库“几乎零文档”虽被提及,但未像 Go 的冗余代码或 Rust 的学习曲线那样被视为成本

    对RAII机制的批判虽有见地却略显偏颇——Rust同样具备内存分配器,且未强制精细化分配。本质区别在于:Rust让安全路径易行而危险路径显性,Zig则默认开发者具备自知之明。这本是合理的设计选择,属于黑客式创新!

    文章将Rust的防护栏描绘成官僚主义负担,而将Zig的缺失视为解放,这实属曲线评分。若要诚实权衡取舍

    > 你掌控宇宙万物,无人能指手画脚

    …这把双刃剑…

    1. 我刚接触Rust不久,想问为什么全局可变变量这么难?

      乍看之下,直接使用支持内部可变性的类型(如RefCell、Mutex等)的静态变量不就行了?

      1. 理论上没错。但实际操作中RefCell行不通——Rust将全局变量视为多线程共享资源,必须保证线程安全。

        这正是让许多人抓狂的关键点。

        1. 另一个关键点在于静态变量必须使用常量初始化器。因此在Rust早期,若要实现非平凡的全局变量,要么需要大量繁琐操作,要么依赖第三方库(如lazy_static、once_cell)。

          自1.80版本起,绝大多数场景只需使用LazyLock即可解决。

      2. 我刚接触Rust不久,想问为什么全局可变变量这么难实现?

        其实并不难。

          fn main() {
              unsafe {
                  COUNTER += 1;
                  println!(“COUNTER = {}”, COUNTER);
              }
          
              unsafe {
                  COUNTER += 10;
                  println!(“COUNTER = {}”, COUNTER);
              }
          }
        

        Rust中的全局可变变量实现与其他语言同样简单。不同之处在于,Rust还提供了更优雅的替代方案。

        1. 人们总抱怨unsafe语法,所以我更倾向展示安全版本。

            use std::sync::Mutex;
          
            static LIST: Mutex<Vec<String>> = Mutex::new(Vec::new());
          
            fn main() -> Result<(), Box<dyn std::error::Error>> {
          
                LIST.lock()?.push(“hello world”.to_string());
                println!(“{}”, LIST.lock()?[0]);
          
                Ok(())
            }
          
      3. 我认为这并非特别困难,更多是语言设计者认为需要更多底层支持,但担心增加负担,于是让社区自行解决。就像整个异步运行时争论那样

  16. 楼主最后尝试了Zig,目前对其最感兴趣

  17. 我宁愿看三行清晰代码,也不愿看一行晦涩的语法糖。无论博客怎么说,Go相较于Rust或Zig的普及度就是最好的证明

      1. 若Go真属于这组语言,那JS也该列入。Go与JS的亲缘性远超Rust或Zig,这三者并列根本毫无意义。

  18. 最后一段道出了所有编程语言理论论述都未能触及的精髓:“Zig 带着一种有趣而颠覆的感觉”。它为你提供了比 C 语言更出色的工具,让你能自由施展非凡的人类技能,而 Rust 和 Go 则本质上对你持怀疑态度。

    1. 谈及编写无错误代码的能力,我认为人类其实并不擅长此道。我们目前别无选择,而软件本身具有实用价值。这并非意味着我们特别擅长此道,只是当错误成本在短期内不易察觉时,很难激励人们提前投入精力避免漏洞。我认为那种认为“试图让错误更早显现的语言(老实说我不认为Go属于此类)在某种程度上阻碍了我们”的思维方式,恰恰与所需的思维方式背道而驰——尤其在系统编程领域(在我看来Go也不真正属于此类)。

    2. 有自我觉察的人会预判“未来的自己”在各种情境下的行为,并提前规划以抑制不良倾向。我不会在冰箱里放树莓芝士蛋糕——尽管这能最大化某种自由(随时享用芝士蛋糕的能力)。但我更珍视免于诱惑带来的 自由 ,因为它能让我在真正重视的事物上获得更佳结果。

      某种意义上,选择能规避统计学高概率失误的语言本身就是一种强大的自由。我更青睐更高层次的自由——那种由多重安全特性带来的心安。

      此处评论偏向哲学层面——请按自身理解诠释并应用——切勿将其解读为我的个人失败模式与您相同。(例如:或许在宏大格局中,你并不介意空指针异常的存在。)

      轶事一则:至今仍清晰记得在Haskell中顿悟的辉煌时刻——当同事告诉我“若数据类型设计得当,程序便会自然成型”时。

      1. > 轶事:至今仍清晰记得在Haskell中顿悟的时刻——当时同事告诉我“若数据类型设计得当,程序便会自然成型”。

        《人月神话》[0, 第102页]也有类似论述:

        > 给我看流程图而隐藏数据表,我仍将困惑不已。给我看数据表,通常就不需要流程图了——它们会一目了然。

        而林纳斯也说过类似的话[1]:

        我甚至要说,糟糕程序员与优秀程序员的区别在于:前者关注代码本身,后者关注数据结构及其关联关系。

        [0]: https://www.cs.cmu.edu/afs/cs/academic/class/15712-s19/www/p

        [1]: https://lwn.net/Articles/193245/

      2. 我宁愿生活在偶尔能把树莓芝士蛋糕放进冰箱的世界。因为我知道如何享受芝士蛋糕,无需每周购买。绝非那种当我在商店货架上拿起芝士蛋糕时,有人突然说:“树莓芝士蛋糕!你可能属于缺乏自我认知的人群,让我来引导你。你知道这可能不安全吗?你确定这样做会带来更好的结果?”

        编程语言会强加一种文化给项目中的每个人——这不像你举例中的个人选择。

    3. 我个人欢迎类型系统和编程语言研究的介入,它们能引导我以正确方式表达程序,并基于严谨原则指出我的错误。若你只想为乐趣而引发段错误,那自有其时其地——但绝不该出现在我的生产代码中。

    4. 我的意思是,既然要讨论这个,不妨更进一步:Zig允许程序员的自我意识肆意妄为,而Rust和Go则不会。

      这或许是某种自然倾向;人们 喜欢并渴望精通事物 。如何权衡取舍取决于你自身。

  19. 我依然不明白Zig的意义所在,至少从这篇帖子看不出?我实在不想手动管理内存。Rust的设计相当出色,却允许编写极其复杂的代码;Go极力保持简洁,却因此排斥现代特性。

    1. 若您不愿手动管理内存,那么Zig并非为您量身打造的语言。该语言要求任何需要堆分配的代码段都必须显式接收分配器参数,否则无法进行任何内存分配。

  20. 除“合适工具”外,我认为还应增加两项标准。

    1. 互补工具。我选择Python和Rust有其必然性,正因它们的差异性

    2. 长远发展。Rust进驻内核对我意义重大,这昭示着它不会昙花一现。同样,Rust渗透各类语言的工具链并推动全面重构的趋势,尽管惹恼不少人,却向我传递着积极信号——值得为此投入时间

  21. > 这正是Rust的难点——你不能直接实现功能!

    我之所以成为Rust的狂热拥趸,源于过去大量编写Go和JavaScript的经历。我厌倦了那些被人们不断撞上的陷阱和怪癖,却总被轻描淡写地归咎于语言设计的刻意为之。即便多年并行使用这两种语言,我仍会被Go的尖锐边角绊倒。我见过太多资深程序员用 Go 写出的 bug,因为代码里看似简单的操作,实际却会引发意外行为。这正是我多年享受 Go 编程后,仍对其有些微词的原因。Go语言的设计初衷就是如此(而JavaScript/TypeScript正试图弥补历史遗留缺陷)。我开始反思:或许这种“简单”本就不该存在——因为我试图实现的功能,其底层逻辑其实相当复杂。

    我无意在此与人争论语言设计或计算机科学理论。但必须承认,正是被迫掌握Rust语言后,我才成为更优秀的程序员——它迫使我深入理解更底层的概念。有人认为这些知识本应在学习Rust前就掌握,我对此表示认同,但不可否认的是,它确实改变了我的编程与设计思维。诚然,Rust和任何语言一样存在令人抓狂的缺陷与棘手问题,但它是我首个迫使我认真思考“编译器判定为错误的代码背后真正意图”的语言。这也正是我欣赏Zig的原因——仅凭语法设计就让我感受到两种语言并存的价值。

  22. > 它像C语言那样能让你将整个语言装进脑海。

    这正是我认为Go是优秀语言的原因。多数情况下,Go都是合适的工具。

    Rust却不像工具。它仪式感十足却安全高效。

    1. 基于作者特定标准

      因此本文并非通用性文章。在某些标准下,Python可成为Rust的良好替代方案。

      >能否获得类似#Rust的#编程语言/编译器,但语法复杂度更低?

      这是个好问题。但考虑到Zig采用手动内存管理,而Crystal/Go支持垃圾回收,这恰恰回避了Rust的核心优势。

  23. 正如我们常说的,这次讨论再次提醒我:我们不需要更高阶的语言,需要的是更高阶的程序员。

  24. 我认为这夸大了Rust的复杂度和难度。它确实存在某些艰深的概念,但其工具链/编译器如此出色,几乎能引导你掌握这些概念。

    1. 尽管我发现自己的脑力资源正被分配到思考内存问题,而非手头的问题本身。

      若需要速度和安全性的优势,这或许是值得付出的代价。但我认为这确实是一种认知成本。

      1. 不过你可以自由使用引用计数机制来规避内存思考。此时唯一需要关注的内存问题是循环引用,而这在垃圾回收语言中也并非完全可避免。

  25. > 现代语言常见的其他特性,如带标签的联合体或错误处理的语法糖,均未被添加到Go语言中。

    > Go开发团队对语言特性添加的门槛似乎很高。最终结果是迫使开发者编写大量冗余代码来实现逻辑,而这些逻辑在其他语言中本可更简洁地表达。

    逻辑表达的简洁性并非总是好事。以错误处理的语法糖为例,比较以下两段代码:

        let mut file = File::create(“foo.txt”)?;
    

    以及:

        f, err := os.Create(“filename.txt”)
        if err != nil {
            return fmt.Errorf(“failed to create file: %w”, err)
        }
    

    第一段代码更简洁,但更糟糕:错误信息未添加上下文(调试时祝你好运!)。

    有时,被迫采用冗长方式编写代码反而能提升代码质量。

    1. 你同样可以轻松为第一个示例添加上下文,或省略第二个示例的包装。

      1. 尤其考虑到第二个示例仅返回字符串类型的错误。

        若需添加“规范”的错误类型,在Go和Rust中封装同样困难(Go需实现Error,Rust需实现std::Error)。虽然我们可以整天讨论宏魔法,但thiserror库让上述模板代码不再是问题,并能在需要时正确传播带上下文的强类型错误(若非编写供他人使用的库代码,anyhow库同样大有帮助)。

      2. 我不同意。Rust 缺乏像 Go 中 fmt.Errorf 这样的标准错误封装规范——这很大程度上是因为 ? 运算符被广泛使用(恰恰因为它如此便捷)。

        但实践才是检验真理的唯一标准。根据我在开源项目和多家闭源组织中处理Go代码库的经验,错误几乎都经过规范封装和处理。Rust则不然——在我接触的案例中,?(甚至unwrap)始终占据主导地位。

        1. 无论是否添加上下文,Rust开发者仍会使用?,因此有Rust经验的人提及此点显得奇怪。

          至于你举的例子:

              File::create(“foo.txt”)?;
          

          若添加上下文应为:

              File::create(“foo.txt”).context(“failed to create file”)?;
          

          此处使用eyre或anyhow(添加自由格式上下文的常见选择)。

          若自定义错误类型,则需写成:

              File::create(“foo.txt”).map_err(|e| format!(“failed to create file: {e}”))?;
          

          此写法与Go语言行为一致。但这种方式并不推荐,因为eyre/anyhow等错误上下文库能自动生成便捷的错误上下文回溯,无需手动格式化。若文件为目录时,上述示例将输出:

              Error: 
                 0: 创建文件失败
                 1: 文件为目录(os error 21)
          
              Location:
                 src/main.rs:7
          
        2. > Rust中没有统一的错误封装规范

          必须说这是我第一次听到有人认为Rust的返回类型不够丰富。按惯例,可能的错误情况会封装在Result中。当遇到无法处理的情况时(比如尝试反序列化用户传入的配置文件却发现其JSON格式无效),foo()?的用法非常出色。这种场景下还有什么比抛出异常更优的方案?或者程序启动时无法连接配置的数据库URL,除了抛出异常并附带回溯信息外(如?.unwrap()所做),恐怕也无计可施。

          对于其他情况,当需要捕获错误重试、提示用户或其他操作时,标准做法是使用if foo.is_ok()或匹配Ok(value)的惯用法。

          但当你清楚操作可能失败且无法控制时,?.unwrap()就显得尤为出色——何必用冗长的错误处理代码包裹,这些代码给用户的提示信息其实和异常回溯差别不大?

          1. > 除了抛出 traceback 导致程序崩溃外,你可能无能为力…就像 ?.unwrap() 所做的那样。

            ?(即 try 运算符)和 .unwrap() 并非等效操作。

        3. 我的经验与此一致,不过我常发现错误被用于非错误场景,这算是矫枉过正——比如数据库驱动在查询结果为空时返回“NoRows”错误,而空结果本就是完全合理的查询结果。

          有趣的是,.unwrap() 技巧曾导致 Cloudflare 大规模服务中断,我最初反应是“这种事在 Go 里不可能发生哈哈”,但…确实可能,因为你可以直接忽略返回值。

          但不知为何多数人不会这么做。这就像语法本身就清晰传达了意图:好好处理你的错误。

        4. 如果只想实现类似Go的字符串类型错误,标准做法不就是这样吗?

          若不想使用字符串类型错误,或许thiserror也算标准方案?

      3. 没错,但哪种更快速、更易于人类阅读理解?Go刻意采用冗长语法,正是为了让复杂逻辑更易理解。

        1.   let mut file = File::create(“foo.txt”).context(“failed to create file”)?;
          

          在Rust所有难点里,这段代码并不难理解。

          1. 需注意.context()来自anyhow库,并非标准库组件。

          2. 这个“?”符号有什么作用?为什么没有它就无法编译?它的存在是为了简化使用match语句处理错误和unwrap操作的过程——如果你了解Rust语言,这很合理。但Go语言的冗长性恰恰是其优势而非缺陷。我认为在超越此处简单示例的场景中,这种冗长反而更利于逻辑推演。

            1. > 这个“?”符号有何作用?为何缺少它就无法编译?

              我完全无法理解这种思维逻辑。“必须先掌握语言语法才能理解它!”……那又如何?所有编程语言的语法都需要学习才能理解。至少我脑子里可没天生就装着C语言风格的语法。

              在我看来,关于学习/使用Rust的诸多讨论,听起来就像某些只懂英语的人学习外语时的困惑——甚至包括对变音符号之类的抱怨,仿佛在说“这可怖的巫术符号究竟是什么,竟要我用它来正确表达”。

        2. 我倒不觉得它更冗长或更简洁。

          在Rust中若函数返回Result<T, E>,必须提供所有情况的穷尽匹配,除非使用.unwrap()获取成功值(或引发panic),或用?运算符返回错误值(可能通过std::From的实现进行转换)。

          从调用方角度看,这并不比 Go 更冗长。但关键区别在于 match/if 等是 表达式 ,可直接赋值结果,因此代码会更像:

              let a = match do_thing(&foo) {
                Ok(res) => res,
                Err(e) => return e
              }
          

          而非:

               a, err := do_thing(foo)
               if err != nil {
                 return err // (或用 fmt.Errorf 包装继续字符串化错误的混乱,除非你愿意编写自定义错误类型,但这比 Rust 更冗长且安全性更低)
              }
          

          我日常使用Go语言,其错误处理机制确实 有效 ,但坦白说这是该语言最薄弱的环节之一。是否欣赏Go和Rust更显式的处理方式?当然,未检查异常和通过持续栈展开报告 可恢复 错误的设计本就不合理。但当其他语言做得更出色时,别指望我会歌颂Go的优点。

          更别提Go语言实际 处理 错误的方式了。errors.As()这个API简直糟糕透顶,它只是为弥补Go缺乏模式匹配而生,使用时还得额外声明局部变量,纯属增加代码噪音。

    2. Python的

          f = open(‘foo.txt’, ‘w’)
      

      更为简洁,失败时抛出的异常不仅包含原因,还包含文件名和完整的回溯信息,直指出错行。

      1. 但缺乏上下文,因此实际应用中你需要这样写:

            try:
                f = open(‘foo.txt’, ‘w’)
            except Exception as e:
                raise NecessaryContext(“重要信息”) from e
        

        否则调用方将陷入噩梦般的困境:既要追溯异常根源,又要自行处理异常。更糟的是,你可能泄露调用方依赖的实现细节,这将使你未来的工作陷入困境。

        1. 包含行号和异常信息的堆栈跟踪,难道不足以说明异常抛出原因吗?

          诸如open之类的异常总是相当明确的。比如文件不存在,这里有精确的代码行号和完整的调用堆栈。调试还需要知道什么?

          1. 若你甘愿拥有脆弱的API,这些信息或许足够。但为何要刻意为难自己?更何苦让其他开发者每次你修改内部实现细节时,都面临代码崩溃的困境?

            听着,若你只是编写不关心失败的脚本——当出错时可直接退出让终端用户自行处理故障——自然无需顾虑。但Go语言明确定位为系统语言而非脚本语言。这种做法在系统领域根本行不通。

            当然,你也可以用Python编写系统,但它本质上是脚本语言,所以我理解你从脚本角度思考的出发点,但这与当前关于系统的讨论并不完全契合。

            1. 这更说不通了,因为Go错误除了消息链之外提供的信息更少。它们简直就是字符串列表。如果所有错误处理器都热衷于包装错误,你或许还能自己重建调用堆栈。

              1. > 这更说不通了,因为Go错误除了消息链之外提供的信息更少。

                这完全说不通。Go错误恰恰提供了与错误相关的所有信息。错误类型采用接口设计自有其道理。信息承载能力的唯一限制在于计算机硬件层面的存储边界。

                > 它们根本就是字符串列表。

                若你的错误仅包含字符串,说明你犯了严重的错误。

                或者至少说明你试图将Go硬塞进脚本任务中——这绝非Go的理想用途。Python才是为此而生的!Python的设计初衷就是脚本语言。不同工具适用于不同场景。

                Go语言从未被设计为脚本语言。但若你出于某种特殊原因必须将其用于脚本场景,至少应借助其异常处理机制(panic/recover)来实现某种脚本化体验。这些特性本就为此而存在。

                这似乎正是你困惑的根源。你仍执着于认为我们在讨论脚本编程,但显然并非如此。如前所述,若真要讨论脚本编程,我们本该关注Go如何像脚本语言那样使用异常处理器,而非其系统级模式。这两类软件需求迥异,根本无法混为一谈。

                1. 若想讨论就别摆出居高临下的姿态。

                  Go中的错误类型本质上就是字符串

                  type error interface { Error() string }

                  仅此而已。

                  所以我不明白你在说什么。

                  封装错误其实是错误类型的集合,所有类型都包含用于显示的字符串。错误显示正是向用户传递信息的途径。

                  若你实现自定义错误类型,并通过运行时类型断言进行检查,就会陷入你描述的Python困境。这是运行时检查——依赖库的API可能改变返回错误,导致代码失效。这正是你所说的Python脆弱性。此时你甚至更少信息,因为没有调用者信息。

              2. 他们本应强调“预知错误发生位置”的重要性。

                更关键的是,你总能添加上下文信息——但这并非核心要点。

                在Python示例中,任何环节都可能出错。异常可能从库中嵌套库的深层抛出,而编写代码提前穷尽式处理错误根本不可行。结果只能在运行时陷入打地鼠式的追错循环。

                在 Go 中,至少你能预知失败位置。这虽是穷人的错误枚举方案,但至少存在。lib.foo() 返回的错误可能是世上最愚蠢的错误(字符串“oops”), 你知道 lib.foo() 必然出错——这比 Python 提前获取的信息更丰富。

                而在Rust或Elm等语言中,你还能实现更优解:将所有下游错误统一到详尽的AGDT中,例如RequestError = NetworkError(A | B | C) | StreamError(D | E) | ParseError(F | G) | FooError,其中ABCDEFG本身是请求函数调用底层库/函数时产生的下游错误类型。

                此时调用点let result = request(“example.com”)能对所有潜在失败进行完美预判。

                1. 我承认Python的异常机制并不完美,而Rust可能是最接近理想方案的实现(尽管仍有改进空间)。我只是认为异常的堆栈跟踪提供了大量有用的调试信息。在我看来,它们比Go中一串包裹的错误字符串更有价值。

                  异常与返回错误的讨论与我此处的观点不同。

                  1. 我不同意,为错误添加上下文恰恰是调试所需的关键。若上下文不足,责任在于开发者本身。而上下文信息往往比堆栈跟踪更具价值(例如触发问题的用户ID等关键要素)。

                    堆栈跟踪专用于处理未妥善处理的崩溃场景,它能提供技术层面的故障点信息,却无法解释事件发生的原因及具体机制。

          2. 比如程序为何选择打开这个文件?若代码稍具通用性,堆栈跟踪便毫无价值

      2. 我们被教导不应使用异常控制流程,读取不存在的文件本就是代码流程中常见的处理场景,而非异常处理范畴。

        Python中那个简单的示例遗漏了必须添加的其他内容。Go语言会增加额外的错误检查,但执行到该步骤时,我能根据具体情境决定如何处理。

        1. 在Python中,常用异常控制流程。即使退出循环也需通过异常实现:StopIteration

          1. 这并非“常见”用法。只有当你使用底层API编写迭代器时才需要处理StopIteration异常,而这种情况对大多数开发者而言可能职业生涯中仅出现一次。

            1. 关键在于异常机制已内置于语言本身。例如当你编写for something in somegeneratorfunction():时,somegeneratorfunction会通过抛出此异常向for循环发出结束信号。

            2. 我认为基于迭代器的循环更常见的是执行完毕,而非遇到break语句。StopIteration异常正是迭代器传递完成信号的方式。

      3. > 失败时抛出的异常不仅包含原因,还包含文件名和完整的回溯信息,直达出错行。

        …但完全没有其他上下文,因此无法获取导致异常的调用堆栈信息。

        异常机制本质上是另一回事(我认为它甚至比最糟糕的错误值实现更差)。

        1. 你给的Go示例里,那些信息Python开箱就能提供。顺便说一句,既然这是“Go vs Rust vs Zig”的讨论,Rust和Zig都比Go提供更优雅的处理方式,同时同样强制要求你在继续执行前确保调用成功。

      4. 更何况这段代码根本不提示可能抛出此类异常。真令人兴奋!这正是我希望成为凌晨三点被生产环境故障吵醒的理由。

    3. 我也更倾向于使用Rust的枚举和匹配语句进行错误处理,但认为其通用的“符合人体工程学”错误处理模式——特别是那个“?”符号——实际上适得其反。当Go扼杀了类似错误处理简写方案的尝试时,我感到欣慰。优秀的Rust错误处理实际上比Go的更冗长。

      1. 赞同。若let-else能匹配错误场景,对Result类型本可大有裨益。

      2. 不,你只需使用map_err或应用'.context'即可实现(我认为任何实现都能做到,而我的库uni_error确实支持)。

        1. 我对这种惯用法相当熟悉,但始终觉得错误/结果映射的流畅式模式既不易阅读也不易编写。我的经验是:你最终只能理解为“表达式末尾这堆代码只是将返回值强制转换成函数签名要求的替代形式”,这与严谨的错误处理完全是两回事。

          重申:我认为Rust作为语言本身处理得比Go更合理,但若要排序:首选Rust显式枚举/匹配风格,其次Go显式冗余返回,最后才是Rust简洁的错误传播模式。

          本质上,Rust惯用法在某种程度上成了错误缩减文化(及其相关错误处理库)的牺牲品。

          1. > 你逐渐理解到“表达式末尾的这堆代码本质上只是将返回值强制转换为函数签名要求的替代类型”,这与谨慎的错误处理完全是两回事。

            我认为问题在于Rust在提供基础错误处理机制方面做得很好,但稍显不足。

            首先,直到最近我才意识到任何String都能通过?轻松强制转换为Box<dyn Error + Send + Sync>(这本该在标准库里有个类型别名啊),所以若用户只需字符串,在返回前用字符串装饰或替换错误就相当简单。

            其次,Rust不完善的错误处理正是我创建uni_error库的原因——它能让任何Result/Error/Option类型直接添加字符串上下文完成处理。我认为anyhow库基本也能实现同等功能。

            我确实欣赏Go的错误封装机制,但无论是anyhow还是我的库,都能让你迅速回归更优状态——因为它们能在编译时对错误消息进行参数检查。

            我认同Rust的错误处理过于复杂,且thiserroranyhow将库与应用程序区分开的做法并不合理。我发现自己的程序(通常是API服务器)需要类似anyhow+thiserror的组合(因此我编写了uni_error——目前仍处于实验阶段,持续迭代中)。

            以下是使用uni_error处理错误的示例:

                use uni_error::*;
            
                fn do_something() -> SimpleResult<Vec<u8>> {
                    std::fs::read(“/tmp/nonexist”)
                        .context("哎呀... 我本想让它正常工作的!")
                }
            
                fn main() {
                    println!(“{}”, do_something().unwrap_err());
                }
            

            参考:https://crates.io/crates/uni_error

            1. 没错,在错误处理方面,我更倾向于基于Rust的框架而非Go的。虽然我更喜欢Go——只要条件允许,我基本都会优先选择Go而非Rust(当然,开发浏览器或LKM时除外)。但Rust的类型系统在这部分确实比Go优越得多。

              正因如此,Rust的错误处理文化竟如此直接地朝着Go试图达到的方向发展,这让我感到奇怪!

              1. 有意思。我很少遇到既懂Rust又懂Go却偏爱Go的人。是因为用Go编程效率更高吗?

                我对Go是爱恨交织。它能让我快速实现想法,但最终产出总感觉脆弱不堪。而在Rust中,代码仿佛坚如磐石(逻辑部分除外,这需要像其他语言一样充分测试),甚至无需测试——仅凭 absence of nil、模式匹配等特性带来的安心感就足够了。

                1. 我认为这个观察颇具启发性,因为Go相较Rust的优势显而易见:Go拥有完整的自动内存管理,而Rust没有。Rust和Go一样 安全 ,但Rust没有那么自动化。用Rust构建任何东西都需要我做出系列决策,而Go不会要求我这样做。有时能自主决策很有用,但通常并非如此。

                  每当有人拿这些语言比较时,我总爱挖苦一句:其实我挺喜欢计算机科学的,更喜欢在合理时自由设计树结构,而不是翻阅厚重书籍研究如何在Rust里编写双向链表——毕竟那书名就写着“Rust中双向链表编写难如登天”。有趣的是当我吐槽后,总有人回应“你本就不该自由编写可变树结构,操作树本就该困难”——这些人显然只把树结构当作带键查找表的实现方式。

                  不过用Rust编写编译器之类的东西确实有补偿性的优势!枚举和匹配语法在那里也 特别 好用。虽不足以让我放弃自动内存管理来换取这些特性,但确实很棒!

                  我曾是C++/C程序员(在Alexandrescu风格流行时就弃用C++了),若我的背景能提供参考。

    4. Go语言的另一优点在于,你能立即在代码页中识别潜在问题区域。虽然更冗长,但我更偏爱能让问题显而易见的语言。

    5. 我觉得这忽略了Rust中Result的最大优势——你必须对它进行操作。即使想用unwrap()忽略错误,本质上也是在说“出错就触发panic”。

      但在Go里,你只需用_err处理错误,永远不必触碰它。

      另外虽然不属于std::Result,但你可以使用anyhow或error_context之类的方式,在错误发生时添加上下文再返回。

      1. > 但在Go中只需使用_err就无需再处理它。

        Rust同样支持这种做法。以下代码不会触发警告:

            let _ = File::create(“foo.txt”);
        

        (不过若需使用File::create成功路径返回的File结构体,则必须编写处理create()失败的代码——无论是引发panic、向上传播错误,还是实际的错误处理逻辑。但若仅为副作用调用create(),忽略错误确实如此简单。)

      2. 任何理智的Go开发团队都会运行errcheck,因此我认为这点无需争论。

        1. 我认为仍需指出:一种语言将其作为内置特性,另一种则需要额外工具支持。

          1. 同样的情况也适用于Rust及其错误处理机制。任何像样的项目都离不开这些工具链,该语言本身也需要额外的工具来处理错误。

    6. 事实恰恰相反

      Rust早期没有?运算符时,就涌现大量抱怨:“我们才不在乎语法,只要能快速传递错误就行”

      “祝你好运调试吧”这种情况同样常见于Go语言随处可见的“if err!=nil return nil,err”模板代码——如今它不仅烦人还占用视图空间

      1. > “if err!=nil return nil,err” 这种Go语言随处可见的模板代码——如今却令人烦躁且占用视图空间

        据我经验并非如此。我接触过的多数Go代码库都会包装错误。

        若不信,不妨去看看开源Go项目。

    7. 在Rust中为错误添加上下文同样简单,而大量Go程序员直接返回err却不添加任何上下文。即便Go程序员添加了上下文,通常也只是字符串类型的垃圾。Go程序员完全忽略错误也容易得多。我深度使用过两种语言,Rust的错误处理机制要优越得多。

    8. Swift在这方面表现出色:

          do {
             let file = try FileManager.create(…)
          } catch {
             logger.error(“创建文件失败”, metadata: [‘error’: “(error)”])
          }
      

      注意这里的try并非真正的CPU异常,更多是语法糖。

      虽然可以选择放弃错误处理,但这种做法不被推荐,且需要显式声明:

          let file = try? FileManager.create(…)
      

          let file = try! FileManager.create(…)
      

      前者在出错时返回可选文件,后者则直接崩溃。

    9. 这并非同类比较。

      在 Rust 中,我本可这样实现(假设返回类型为 `anyhow::Error` 或 `Box<dyn Error + Send + Sync>`,这是非常典型的):

          let mut file = File::create(“foo.txt”)
              .map_err(|e| format!(“failed to create file: {e}”)?;
      

      Rust在此处的微妙优势在于编译时就能保证字符串参数不会被遗漏。

      在Go中我本可这样写(同样很常见):

          f, err := os.Create(“filename.txt”)
          if err != nil {
              return err
          }
      

      因此Go与Rust同样不会强制要求这种写法,两者都能实现相同效果。

      1. 在Rust中虽可采用上述写法,但实际不会这样做——因为只需输入单个字符

            ?
        

        的诱惑力实在太强了。

        这种用户体验糟糕透顶——阻力最小的路径往往源于惰性。系统应当强制要求提供错误信息,例如:

            ?(“创建文件失败: {e}”)
        

        才应是唯一有效的形式。

        而在Go语言中,出于种种原因,提供错误上下文已成为标准做法;直接返回裸露的err既不常见,更被视为违背惯例的反面教材。

    10. Go代码在此添加的上下文究竟是什么?当File::create或os.Create失败时,它们返回的错误本身已包含失败原因及具体信息。那么“failed to create file:”究竟补充了什么信息?

      1. Rust的File::create错误基本只包含errno结果。例如“permission denied”与“failed to create file: permission denied”的区别。

      2. 在编写错误信息时,应结合具体上下文添加必要信息。不必过度关注示例中的内容,无论是请求ID还是客户姓名——任何与该调用相关的信息都可纳入。

    11. 显式错误处理的另一价值在于,它能清晰揭示获取值失败的可能性(这在纯函数式语言中很常见)。话虽如此,我在业余时间有个Go项目,其代码非常冗长。我决定将其作为项目的新版本用于性能优化,因为原先主要使用bash脚本的版本变得过于晦涩难懂。新版本的逻辑在业务领域更易理解且更健壮,但代码行数大幅增加。

    12. 这里的“上下文”不过是个字符串。调试时只能在代码库里搜索这个字符串,祈祷它具有唯一性。栈中能构思出的独特错误信息终究有限。

      更何况添加上下文并非强制要求。该死的,你甚至可以轻松地让错误无处可寻——既不会触发编译器错误或警告,连静态分析工具也抓不住,全因那些愚蠢的变量语法规则。

      1. 本帖中“轻易”一词的随意滥用实在令人不齿。

        你竟声称错误能“轻易”放任不管,所指的莫非是某种不幸的代码模式?这种模式现实中只会因复制粘贴而出现,且会导致代码明显错误?唉。

        1. 好,让我们剖析一下。

          在此语境下(比如PHP,至少在早期版本),“轻易”并不等于“频繁发生”。

          此处的“轻易”意指当这种情况发生时,通常并不明显。这是我作为日常Go语言用户的切身体会。这并非复制粘贴的结果,而是单纯编辑代码的产物。现实中的代码绝非`op1, op2, op3…`这般优雅的操作序列。其中穿插着条件判断,存在某些情况下不愿终止的for循环(但会累积错误),有时处理错误意味着不返回错误而是执行其他操作,还涉及重试机制…

          虽然工作中不用Rust,但在业余/开源项目中接触足够多,足以说明:Rust里未处理的错误会格外显眼。回到简洁性话题:Rust当然也能吞掉错误,但这意味着你得不断切换错误变量,操作过程立刻就引人注目。而在Go里,你时刻都在切换错误变量,每次调试都得翻遍整个代码库。

      2. > 调试意味着要在代码库里搜索该字符串,并祈祷它具有唯一性。

        实际中这根本不是问题。唯一可能导致错误无法唯一标识调用栈的情况,是你在同个函数内使用完全相同的上下文字符串(且被调用方也如此操作)。我从未遇到过这种情况。

        > 你也不必强制添加上下文

        没错,但据我观察Go开发者确实会这么做。大概是因为他们本就要费心输入if err != nil,而坦白说,Go代码里出现这样的裸写:

            if err != nil {
                return err
            }
        

        在资深开发者眼中显得格外突兀。

        > 由于变量语法规则的愚蠢设计,连代码检查工具都无法识别此类问题。

        我从未遇到过错误检查工具未能检测到未处理错误的情况,但很想听听具体案例。

        1. Go标准库素以直接返回错误而不进行封装而闻名。虽然它正逐渐增加封装频率,但问题依然存在。

              err1 := foo()
              err2 := bar()
              if err1 != nil || err2 != nil {
                  return err1  // 若仅err2失败,返回nil!
              }
          
          func process() error { err := foo() if err != nil { return err }
          ```
              if something {
                  result, err := bar()  // 新错误掩盖外部错误
                  if err != nil {
                      return err
                  }
                  use(result)
              }
              
              if 某个操作 {
                  err := baz()  // 再次覆盖
                  log.Println(err)
              }
              
              return err  // 返回 foo 的错误(nil),baz 的错误丢失
          ```
          
          } 
          
          1. 现在你只需让Go程序员写出这样的代码:

                if somethingElse {
                    err := baz()  
                    log.Println(err)
                }
            

            祝你好运!

            至于你的第一个示例:

                // 若仅err2失败,返回nil!
            

            没错,这准确描述了你所写代码的行为。但这算什么?无论你想论证什么,都仍需依赖有人写出这种代码——而Go开发者根本不会这么写。

            这种写法在现实中会引发错误吗?当然会,且确实发生过。但偶尔因此出现一次错误算什么大事?其实不算什么。

    13. 我认为在Rust中使用?几乎总是错误,它只该用于像unwrap这样的快速测试代码。

      Go的错误封装本质上就是个信息更少的垃圾异常堆栈跟踪。

  26. 建议研究这三种语言的人都试试Odin。

  27. 虽然我不是第一个吐槽这条Github评论的人,但它完美展现了Rust概念的密度

    呃,这其实不算典型Rust项目代码。这是Rust标准库内部的代码。包括Python在内,大多数语言的标准库都是黑魔法的典范,Rust也不例外。

  28. Nim正好满足我对低级、快速语言的需求。推荐的GC运行良好且易于使用。

  29. 这三者中有一位格格不入…

    奥丁 vs Rust vs Zig才更合理,或者Go vs Java vs OCaml之类的对比…

    1. 注意到Go/Rust/Zig在自包含原生编译的网络导向/系统应用/工具/服务领域颇受欢迎,它们在集成编译器/包管理工具方面也颇为相似。因此针对此类场景,这三者值得比较。

    2. 没错,既然如此何不写篇Scratch vs Rust vs Python的对比文章?

  30. 有趣的是,正是那些让人类难以掌握的特性,反而使Rust成为训练大型语言模型的绝佳语言。

    在过去几个月我使用的开发语言中:Go、Rust、Python、TypeScript; 在处理同等复杂度问题时,Rust生成的代码正确性与功能性最稳定,LLM在处理过程中几乎不会出现错误或问题。

    我认为这个外部因素终将推动Rust获得更广泛的应用。

    1. 确实是个有趣的观点,感觉它本应比现在表现得更好(或许我对当前顶尖编程智能体的质量了解不足)。

      Rust似乎特别适合基于代理的工作流,理论上,一个拥有任务的代理可以持续执行cargo check来验证解决方案,或许从docs.rs或导入模块的源代码中提取内容,最终获得一个相对可靠的可行方案(前提是需求定义明确且可实现等)。

      我在多个Rust一次性项目中尝试此方法时收获参差不齐。虽然确实让某些原型功能得以运行,但Rust及其生态系统中crates的持续演进,意味着总需要进行拼凑才能让代码真正编译通过。根据经验,当我深入理解问题/库/项目后,往往会推翻或重写大量LLM代码。似乎很难将智能体的上下文和工作流定制/隔离到所需程度。

      Anthropic收购Bun或许会改变局面。用户通过LLM生成的代码很可能以JS/TS为主,若Anthropic能推动代理与Bun运行时深度集成,这不仅将极大促进Bun发展,甚至可能带动其开发语言Zig的普及?例如:让智能体执行 cargo check 是一回事,但若能在代码运行时监控垃圾回收/内存使用情况,主动诊断开发者可能延迟发现的问题或优化点,那就完全不同了。我认识许多开发者根本不会碰本文提及的任何语言(光是想到内存管理就头疼!),他们宁愿写到死都坚持用 JS 哈哈。

  31. 这篇文章写得不错,但未能抓住这些编程语言的精髓。在我看来,它更像是精心构思的论述,最终归结为大众对这些语言的刻板印象:“Go语言极简,Rust复杂,Zig是时髦的折中方案”。老生常谈。

    读起来挺有趣,但没看到新观点,也不同意太多。

  32. >虽然我不是第一个揪出这条GitHub评论的人,但它完美展现了Rust的概念密度:

    https://github.com/rust-lang/rust/issues/68015#issuecomment-

    哇,Rust确实将编程复杂度提升到了新高度。

    包括编程语言在内的一切事物,都应做到简单但不过分简单。我认为绝大多数计算和内存资源的复杂性都应由操作系统处理并抽象化,例如地址空间隔离[1]。

    作者不妨试试D语言——相较于Go、Rust和Zig,它在复杂度和元编程方面堪称“恰到好处”[2]。

    [1] Linux地址空间隔离机制在降低性能开销后重获新生(59条评论):

    https://news.ycombinator.com/item?id=44899488

    [2] Ask HN:既然有D语言可用,为何选择Rust?(255条评论):

    https://news.ycombinator.com/item?id=23494490 [2]

    1. 复杂性总要存在于某个地方。如果不在语言本身,那就会出现在运行时环境、你的代码,或是代码中未记录的行为中。

  33. 总体而言是篇不错的文章,但作者似乎对未定义行为有些混淆。

    > 何谓可怕的UB?理解它的最佳方式是记住:对于任何运行中的程序,都存在比死亡更糟的命运。若程序出错,立即终止其实是最好的结果!

    这与未定义行为无关。未定义行为正如其名,指语言执行语义中未给出定义的行为——无论有意或无意。本质上就是“若发生此情况,后果难料”。以下是C语言示例:

        int x = 555;
        long long *l = (long long* )&x;
        x = 123;
        printf(“%dn”, *l);
    

    这违反了严格别名规则,属于未定义行为。除非编译时禁用优化或使用-fno-strict-aliasing参数(实质上禁用该规则),否则编译器“可自由处理”。实际上,它只会输出555而非123。所有未定义行为都类似如此:编译器输出偏离预期输入,且仅是“可能”偏离。可想而知,在更激进的优化下这类问题会变得相当棘手,但潜在偏差就是全部可能发生的后果。

    由于编译器对代码的重命名(受未定义行为影响),可能引发竞争条件、隐性错误等问题,同时也会导致崩溃及其他各种问题。当然,未定义行为也可能完全无害,甚至带来益处。但这类情况实在难以判断。在庞大代码库中预测优化编译器的行为可能极其困难,尤其当你并非编译器开发者时。这种不可预测性正是我们认为其有害的原因。若使用TCC而非clang等编译器,情况则截然不同。

    以上就是关于UB的全部内容。

    1. 我认为初学者常被教导“未定义行为极度危险”,部分是为了简化调试流程,部分是帮助理解语言边界,部分则是因为存在众多真正规避未定义行为的标准纯粹主义者。但根据我的经验,未定义行为本质上意味着“请咨询编译器具体行为,因为这个问题超出我们的能力范围”。

      有趣的是,最近项目中我首次使用了volatile关键字——这与上述讨论仅有半相关性。主要因为我编写的汇编代码直接访问内存,需要确保编译器不会优化掉该变量。这大概是我技术清单上最后一个要掌握的C语言关键字了。

      1. > 但根据我的经验,UB本质上就是“去问编译器它在这里做了什么,因为这个问题超出了我们的能力范围”。

        人们被教导说UB很糟糕,正是因为他们会直接这么做——这才是问题所在。编译器在此处的行为可能随每次调用而变化,原因可能是看似无关的编译器选项、无关代码中的微小扰动,或是其他诸多因素。这种做法鼓励程序接受UB。引发UB的代码就是错误的,句号。

        1. > 引发UB的代码就是错误的,句号。

          这完全错误,谁教你的?试想:有符号整数的溢出/下溢就是UB。所有整数加法操作都可能引发UB。

             int add (int a, int b) { return a + b; }
          

          按此标准这段代码也算错误,显然荒谬至极。

          编译器之所以提供精细控制的优化禁用机制,正是因为大量有用行为属于未定义范畴,而编译单元有时过于复杂,难以预判编译器如何处理变量重命名。-fno-strict-aliasing 选项并不会突然使指针别名成为定义行为。

          编译器对错误代码的行为是拒绝编译。你认为未定义行为最多只触发警告是偶然疏忽吗?编译器对未定义行为拥有自由裁量权的根本目的,正是为了能在该基础上实现平台特有的优化。未定义行为并非任意而为。

          1. > -fno-strict-aliasing 不会突然使指针别名成为定义行为。

            不,它只是保护你免受针对错误代码的有效但意外优化。文档中对此有明确说明:https://www.gnu.org/software/c-intro-and-ref/manual/html_nod

            “遵循这些规则优化后仍表现异常的代码,本质上就是错误的C代码。”

            > 针对错误代码,编译器行为是直接拒绝编译

            这种情况在C语言中不存在且永远不会存在,因为代码正确性可能是运行时属性。上述定义的add函数本身并无错误,但当与运行时传入溢出值的调用代码结合时,就会导致错误。

          2. > 所有整型加法操作都可能引发未定义行为。

            “可能引发”与“实际引发”是两个概念。

        2. 我理解,但你该明白自己正被我归为标准原教旨主义者了吧?如果微软在其文档中对某些UB C代码的行为做出 保证 ,且该保证可追溯至约14年前,而我在互联网上看到许多可信人士证实这种行为确实存在且至今仍在发生,这些评论散见于过去14年间,那么我认为可以安全地依赖这种行为——只要我能接受一点供应商锁定。

          1. > 若微软在其文档中对某些UB C代码行为作出保证

            但他们真这么做过吗?具体在哪?

            更可能的情况是,你指的是某个特定编译器宣称“尽管标准定义此行为为UB,但在本编译器中并非如此”。这完全是另一回事,因为此时你已不再涉及UB范畴。

            1. 没错,这依然属于未定义行为。行为是否被定义属于标准层面的区分,而非编译器层面的问题。

      2. > 但根据我的经验,UB(未定义行为)其实就是“去问你的编译器它在这儿干啥,因为这问题超出我们的能力范围”。

        注意。这可不只是“去问你的编译器”那么简单,因为特定编译器对含UB代码的行为,还可能因具体编译器版本、操作系统、硬件甚至月相变化而不同。

    2. 竞态条件、隐性缺陷等现象可能因编译器对UB代码的篡改而产生,但崩溃及其他无数问题同样可能由此引发。 […] 仅此而已。这就是UB的全部本质。

      你不觉得这相当糟糕吗?

      1. 这些问题同样可能源于定义行为。关键在于它们完全无关联。

  34. 我用了几天Zig语言,目前遇到这些陷阱:

    – 无法使用for (-1..1) {,必须改用while

    – 若在代码块内分配资源且需使其在块外持续存在,defer无法实现资源释放。目前尚未找到延迟释放至函数末尾的方法。

    – 对usize变量添加包含-1的变量操作过于繁琐。建议全程使用isize类型,仅在需要时作为最后一步转换为usize。

    – 语言已大幅演进,大型语言模型几乎毫无助益。

  35. > 若“Go vs. Rust”或“Rust vs. Zig”的在线讨论量可作为可靠指标

    人类大脑天生渴求“vs”类文章

  36. 除非有人创造出超越这些语言的新语言…

    1. 此类对比可纳入的语言众多。为何选择Scala Native(确实不错)而非更具影响力的C/C++后继者/替代方案,如D、Nim、V、Odin、Hare等?

  37. > [Go] 如同C语言,整个语言体系都能装进脑子里。

    但Go与C不同的是,你 真的 能把整个语言装进脑子里。自认掌握C语言的多数人,仍会不断遭遇未预见的UB(未定义行为)等陷阱。我不禁怀疑,C语言的简洁声誉是否源于它长期与C++的毗邻关系?

    1. 我深耕C/C++三十载。

      请举个你在现实中写过的无限定义代码实例,博客里的不算。我真心好奇。

      1. 所有内存安全漏洞?这占大多数C/C++项目的绝大多数缺陷吧?

      2. > 请举个你在现实中写过的无限定义代码实例

            struct foo {
                ...
                atomic_int v;
                ...
            };
            
            struct foo x;
            memset(&x, 0, sizeof(x));
        
        1. 若在多线程原子操作前初始化结构体,我不认为这属于未定义行为。

  38.     > OOP已过时许久
    

    我超爱这类论断。谁写的?告诉你:就是那些在HN上写“在欧洲,X成立”的人(…欧洲明明有50个国家!)。

        > Zig是面向数据设计的语言
    

    但不是面向对象的,对吧?或者说面向对象做不到同样的事?

    多年来浏览网络帖子,我发现一个规律:美国人就是爱用最高级。他们痴迷于宏大而夸张的表述。看看他们的报纸,这种风格比比皆是。若能稍加节制,他们的文字会更有说服力。

    尽管这番人身攻击会招致反对票:此人为何在领英拥有387个联系人?这意味着点击“接受”按钮387次。想想看。

    1. 若能看到像Zig这样传递分配器的面向对象语言,将非常有趣。概念本身确实不存在任何阻碍。

      1. C++ STL(标准模板库)里的分配器呢?说实话,我写C++代码已有上亿年,但(1)我自己从未用过分配器,(2)也从未见过别人使用。(当然,我接触的企业级C++代码库数量有限。)

  39. 若需对接现有代码库或实现经典面向对象编程,现代C++可能比上述语言更胜一筹。

  40. Go语言是合理的折中方案,其优势难以忽视。

  41. 哇,这篇分析写得真好,完全避开了人们对这些语言的常见偏见。干得漂亮!

  42. 做项目时最容易陷入的困境,就是光顾着纠结该用哪种语言。

  43. 若这些语言由大型语言模型创造,你(相对成熟的)批判性思维链会如何展开?

  44. 如今大型语言模型默认倾向于选择Go语言

    Rust用于WASM

    若启动全新数据库管理系统项目,我会选择Zig语言

  45. 我实在厌恶那些反对RAII的言论和论点。记得Zig社区负责人曾对RAII大加挞伐,声称“Linux绝不会采用这种设计”(https://github.com/torvalds/linux/blob/master/include/linux/…)。

    RAII API确实存在糟糕的案例,但并非一无是处。安德鲁曾发文表示同情Go开发者——他们永远无法通过0xaa内存段来调试程序,我理解这种感受。但当你用魔数隐式初始化时,却大谈特谈“未初始化”的危害,这未免有些牵强附会。当然, 或许你并不总希望采用清零机制。Go语言“零值永有用”的信条未能说服我——我见过太多人为了强行贯彻此原则而写出糟糕代码。当遇到挑战时,构造函数API才是更优解,所谓“规则”仅适用于简单场景,切勿强行套用。

    但回到RAII本身,或者说人们听到RAII时联想到的内容。基于作用域或自动化的清理机制是好的。在更优雅的世界里工作多年后,我厌恶在复杂程序中处理Go的互斥锁。人们会犯错,人们会耍小聪明,而长远来看结果几乎总是糟糕的——那些“本不该被编写/发布”的错误确实会出现,这令人痛苦。我认为Zig的errdefer是对defer模式的酷炫扩展,但关键任务中defer模式绝对逊于基于作用域的自动化。我认同有时需要偏离作用域控制的观点,同时提供两种模式的底层设计也合理,但海量代码的默认模式应当优化为避免人力投入和人为失误。

    最终我对内存分配的看法亦是如此。我欣赏Zig试图开创不同世界的尝试,这本身就是极具价值的实验。我曾在Go程序(以及Java等语言)中与内存分配搏斗,也曾与“意外”过度消耗资源的C++代码交锋(经典的哈希表字符串滥用,你好忍者,你好GN),但我认为任何场景下都不会存在“永远包办所有琐事”与“永远不包办任何琐事”的完美权衡。我希望Rust能更接近理想路径,它在多数情况下确实相当符合人体工学,但当你真正需要掌控权时,有时我更渴望Zig那样的特性。不过在Zig里耗时过久,那些繁文缛节也会让人厌倦。

    我认为下一个创新点应聚焦于全局变量与线程状态的合理价值。当前围绕这些概念的争论充斥着太多无谓的热议,滥用或误用确实会导致不良后果,但创新更应着力于构建_合理隐含的上下文机制_——既能减轻程序员负担又不致过度隐藏,同时支持简单明了的局部特化。我设想的解决方案介于Rust和Zig之间,但具体落点尚不明确。这套方案会引发纯粹主义者痛恨的层级违背问题——毕竟我们许多ABI决策都基于历史惯例——但我仍期待在此领域看到更多探索。

    因此RAII并非可怕的怪物,我们该停止用这种方式讨论RAII、全局变量等概念。我们需要评估优劣,尝试新的架构方案以最大化优点、最小化缺点。

    1. 嘿,听起来你会喜欢我即将在MWPLS 2025展示的在研项目 🙂

    2. 你试过Swift吗?它恰恰具备你所说的务实且默认安全的特性。

      1. 还没到能郑重回答“是”的程度。我在工作中协助维护部分Swift代码,但很少深入代码库。我自己也没用这门语言写过什么重要作品。我见过某些代码中,多种事件/互斥锁/线程模型混杂共存。一方面欣喜于它能与macOS/iOS运行时等实现潜在的整洁架构,但另一方面,相关代码却混乱不堪,存在诸多未被捕获的严重并发问题——包括未定义行为和数据竞争,似乎编译器或工具都未能指出这些隐患。我倒想看看复杂度合理的尖端项目。

    3. > 因此RAII并非可怕的怪物,我们该停止用这种方式讨论RAII、全局变量等概念了。

      我持反对意见,认为RAII是划分编程语言复杂度的分水岭,才是真正的“可怕怪物(tm)”。

      一旦编译型语言引入RAII,便需围绕其构建层层递进、相互交织的语言特性体系,才能避免…令人痛苦不堪的体验。这几乎就是“大型”语言(如Rust或C++)与“小型”语言(如C、Zig、C3等)的本质区别。

      对我而言,编程语言的下一次创新在于让垃圾回收/内存管理的语言终于不再将性能编程语言领域的大片疆土拱手让给编译型语言。托管运行时不必如此愚蠢地迟缓,不必如此愚蠢地不可预测,也不必拥有那种极其复杂却又可怜巴巴的外置函数接口(FFI)。我认为“无处不在的强类型”是实现这一目标的第一步。Fil-C或许能成为该领域有趣的存在性证明。

      我认为必须动用C、Zig、C++、Rust等语言才是高级编程语言的失败。底层总需要这类语言的存在,但我真心希望它们的适用范围能极度缩小。只要能避免,我绝不愿在它们的层面上操作。此言出自近期编写了逾十万行Zig代码的实践者之口。

      以具体案例Ghostty为例,它采用Zig语言编写。选择Zig并无强性能需求支撑(除非考虑到除Rust外的其他语言实现都慢得离谱)。选择Zig也并非基于内存效率考量(除非考虑到除Rust外的其他语言实现会消耗海量内存)。然而,开发团队仍选择用这种相对新颖、不稳定且底层的语言从零构建Ghostty。而其他所有实用的终端仿真器似乎都在使用Rust。

      所有支持托管内存语言的人都该把这当作 个人侮辱 ——人们竟选择用Rust和Zig编写现代终端仿真器。

      1. > 所有支持托管内存语言的人都该把这当作个人侮辱——人们竟选择用Rust和Zig编写现代终端仿真器。

        为何如此?垃圾回收相较于手动内存管理存在固有性能开销,而Rust通过提供受控内存的必要保障 同时避免 GC开销解决了这一问题。

        现代终端模拟器不会涉及复杂引用图——即对象间存在循环引用且无明确“所有者”的场景;这恰是垃圾回收在低级系统语言中仍不可或缺的关键情境。这类程序根本不需要垃圾回收。更合理的方式是调整程序的高级设计,确保在无需昂贵运行时支持的情况下,正确管理对象的生命周期。

        1. > 为何如此?相较于手动内存管理,垃圾回收存在固有的性能开销。而Rust通过提供所需的内存管理保障,同时避免了垃圾回收的开销,解决了这一问题。

          对此我稍有异议,特别是隐含的“所有GC都有开销而替代方案没有”的论断。Rust在提供入门友好性方面做得不错,但当你需要处理多种不同的分配问题时,修复起来仍然相当不友好。Zig则略有不同,它入门更痛苦,但随着问题深入,痛苦程度保持更稳定。理想状态下我希望融合两者优势——姑且用尚未完全成形的构想来说明:我期待一种机制,能由系统导向的开发者在请求路径顶层部署,使其成为下游代码的隐式依赖,既降低配置门槛又允许调用链末端的贡献者逐步理解(他们通常无需关注细节),同时在关键时刻提供便捷的逃逸通道。

          人们过度强调GC与分配器的区别,但现实是:高级操作系统环境中常用的分配器本质上都属于GC的一种形式。这当然不是他们讨论的重点,却也是关键的区别所在。

          人们所称的“GC”与普通分配器的主要区别在于:典型“GC”会在malloc时“严重”中断程序,而典型分配器则在free时“严重”中断程序(且频率更高)。这确实是个普遍存在的奇特现象:无论是“GC”还是“分配器”,其实都可以采用“相反的方式”作为常规代码路径。两者本质上都采用内存池机制,在高性能调优时还需进行超额分配。当前许多常用“快速”分配器通过mmap内存池绕过智能分配机制,但其扩展性极差:mmap阻塞往往难以预测,且常伴随不受欢迎的跨线程副作用。

          第二个差异(我认为更常被内部化)在于:通常“垃圾回收机制”会通过多种方式与运行时深度耦合,例如集成到调度器中(如Go语言、多数动态语言等),并在FFI边界处产生显著影响。

          可以更明确地描述一种通用分配器,它既具备更接近垃圾回收的语义特性,又同时提供系统级malloc/free风格的API,以及一种由语言辅助实现的、具有巧妙语义或额外集成的自动化API。我猜fil-C就拥有这样一个系统(我尚未研究过其具体实现)。我未曾察觉存在隐性约束,要求API只能分为两类:完全隐式的对数型GC,或将大部分智能工作集中在free函数中的通用分配器。

          我的核心观点是:我不太认同“GC与非GC之争”的论调——这不过是行业内众多过度概括的论点之一,人们对此过度执着,反而隐性限制了我们在该边界领域探索新设计的深度。我依然坚持许多系统工程的论点:完全隐式集成的GC(如Java、Go及各类动态语言)对于可扩展系统(无论是超大规模还是微型系统)往往过于不透明,一旦被迫使用就会令人头疼。同时,在可扩展系统中,你依然无法忽视实际使用的分配器所搭载的GC机制。你无法回避诸如重启程序时200+GB堆栈带来的巨额页面分配成本——无论由何种中间件配置。同样地,对多数嵌入式或资源受限系统而言,对数分配策略绝非良选:这类设计仅适用于服务器场景,却会损害电池续航并推高系统整体运营成本。

          我期待看到更多明确融合这些界限的研究——基于对数分配的GC在扩展性方面存在诸多缺陷,其表现与更简单的mmap内存分配器如出一辙。过度分配带来的实际问题需要超越经典文献的解决方案,我希望这类研究能更多以独立模块形式实现,而非几乎总是隐式嵌入语言/运行时中。当前领域存在过度隐式耦合的问题,而Zig语言在标准语言库模型中突破了若干边界(如今似乎也在I/O调度领域采用相同策略——这非常棒)。

          1. > 我部分持异议,尤其针对“所有GC都有开销而替代方案没有”的隐含主张。

            这并非我的主张。显然存在比通用堆分配器开销更低的内存管理方式(如栈分配、纯静态内存或可插拔“区域”/本地分配器),Rust项目也竭力在相关场景(尤其是深度嵌入式代码)中支持这些方案。

            原则上,垃圾收集器本身也应能成为“可插拔”特性(其设计空间如此庞大复杂,选择单一通用实现并将其纳入语言本身实非明智之举),仅在绝对必要时启用——类似Zig语言中的分配器机制——但这需要精心设计,因为完整的追踪式垃圾收集器系统级接口 (包括正确追踪所需的不变量、读写屏障、暂停机制、并发控制等要求)远比简单分配器的接口复杂得多。

      2. 尽管去尝试吧,设计一个既不需要占用程序工作集2-4倍内存,又不会在代码中布满分支和内存屏障的GC。

        你定会因此暴富。

      3. 能否举例说明“……并非痛苦不堪”的具体情形,并解释为何你认为这些痛点是 RAII 本质特征?

  46. > 许多人困惑于既然已有Rust,为何还要Zig存在。区别不仅在于Zig追求更简洁——我认为更关键的差异在于:Zig旨在从代码中彻底剔除面向对象思维。

    我感觉Zig是为那些厌恶Rust的C/C++开发者准备的。

    此前虽有Carbon等尝试,但Zig是首个真正实现语言现代化并解决新痛点的项目。

    > 我并非首个指出这个Github评论的人,但它完美诠释了Rust的概念密度:[疯狂示例省略]

    这完全不公平。99%的Rust开发场景都不会如此复杂。

    > 这正是Rust的难点所在——你不能直接实现功能!必须先找出Rust对应的命名规范(定位所需的特性或其他元素),再按Rust的预期方式实现。

    什么?

    Rust并不难。它的标准库与Python或Ruby极为相似,方法命名也如出一辙。

    如果你试图将某种新颖的类型硬塞进特定的特征接口,以便传递特征对象,那当然可以。或许你需要记住更多内容。但我会问:除非你在编写库,否则为何要写这样的代码?

    这种渴望写面向对象风格代码的念头,让我觉得那些追求面向对象风格的人,往往正是在Rust的操作逻辑上遭遇诸多挫折与困扰。

    Rust为你提供了所有想要的面向对象特性,但若以函数式方式使用它,体验绝对更佳。

    > 使Rust中使用库变得轻松,这也解释了为何Rust项目的依赖项数量几乎与JavaScript生态系统中的项目相当。

    这正是Rust的超能力之一!

    1. > Rust并不难。它的标准库与Python或Ruby极为相似,方法命名也如出一辙。

      这句话更适用于Go而非Zig。Go的开发效率惊人,虽然无法像Django那样快速交付,但几乎可以做到——而且完全无需依赖外部库。在嵌入式领域Go的优势稍减,操作复杂度略高,但其强意见化的设计在此仍能保持高效。

      我实在想不出有哪门语言能像Go这样,仅凭标准库就能实现如此迅捷的开发。即便使用SQLC这类框架,外部组件也能完全隔离运行——若你需要这种特性的话。

      必须承认,在我们C语言编写的Python二进制文件中,Zig的互操作性实现异常轻松——这点Rust可做不到。虽不能断言对他人同样便捷,但对我而言确实如此。

      > 这正是Rust的超能力之一!

      某些领域却并非如此。

    2. Rust的难点在于它给你提供了大量自缢的绳索,而有些人就是执意要上吊。

      我多数时候觉得Rust相当简单。我乐在其中,通常用Rust编写的代码与Go代码差异不大(不过在Rust中我用得少些通道)。但我确实认同关于绳索的比喻——有些人就是无法克制自己。

      1. 这似乎是对Rust的奇怪描述。借用检查器及其他类型安全特性,还有send/sync这类功能,本质上都是为了不给你自缢的绳索。

        1. 我举例中的“绳索”指的是复杂度。即在不需要甚至不想要的情况下,仍选择使用“所有功能”。例如有时简单的克隆就足够了。有时你无需采用Rust提供的所有泛型和性能优化特性——这些特性数量庞大。

          不过我的论点似乎遗漏了关键点。我从Go转投Rust,是因为发现Rust提供了更优的工具链来封装和复用逻辑。例如迭代器底层更复杂,但通过更优化的泛化代码复用,我在Rust中感知到的复杂度反而低于Go。因此这个例子中,我反而觉得Go更复杂。

          或许更精确的说法是:Rust给你更多可见的绳索让你自缢…但听起来不太悦耳。我还是喜欢原来的比喻呢。

          1. 我渴望看到一种语言,它之于C如同Rust之于C++——能让像我这样普通人脑子也能理解的语言。保留无GC的内存安全特性,但将其他部分简化千倍。

            并非要取代Rust,两者可如C与C++般并存。

            1. 好奇你想简化哪些部分。移除特质?既然要保留借用检查器,还有什么可简化的?

      2. 我觉得恰恰相反,Go 给你提供了大量自杀的绳索,并希望你能意识到自己这么做了:错误处理本质上是可选的,没有总和类型和完备性检查,标准库会默认文件路径是有效的字符串,如果你忘记赋值,它就会变成零,无论这对你的程序是否合理,指针没有空值检查强制执行等等。

        反观Rust,它对这类机制的强制执行近乎偏执。

        当然Rust功能丰富,编译速度较慢。

        1. > 错误处理本质上是可选的

          理论上 或许如此。

          > 标准库默认假设文件路径是有效字符串

          Go的字符串本质只是字节数组。

          其余说法基本属实,但Rust并非仅提供最低限度功能来弥补这些缺陷,而是提供了十倍的复杂度。这值得吗?

      3. 人们通常在Rust里写什么?我试过几次,但总被“不可变变量”问题卡住,实在不明白它们存在的意义。

        1. > 但我总被“不可变变量”问题卡住

          …这不正是 mut 的用途吗?不太明白你指的是什么。

          1. 我不太理解不可变变量,也不明白为什么要复制对象——结果就有了更新后的变量和过时的变量。这不就是自找麻烦吗?

    3. 同感。Zig的定位类似那些鼓励用指针处理业务逻辑的语言。若你偏好这种风格,Rust及其他大多数 语言都不适用。

    4. 关于函数式编程的组织问题:哪里能学习大型代码库的函数式编程实践?

      或许是因为DDD类书籍往往带有强烈的面向对象倾向,但每当我阅读函数式编程模式时,总不清楚如何将练习内容转化为能在现实世界单体应用中运行的方案。

      需要澄清的是,我并非认为函数式编程在这方面更差,只是尚未能轻松找到相关信息。

      1. elm的创建者和Richard Feldman有很多讲座/演讲探讨如何进行“函数式”思考

        这里有一个关于项目结构设计的讲座(大致内容)

        https://youtube.com/watch?v=XpDsk374LDE

        我认为研读elm的源代码、官网文档以及elm实战案例也很有帮助。

    5. > 我觉得Zig是为那些厌恶Rust的C/C++开发者设计的。

      深有同感。作为一名热爱Rust的前C++开发者,我深有体会 🙂

    6. > Rust的标准库与Python或Ruby极为相似,方法命名也如出一辙。

      能否展开说明?虽然存在明显重叠,但Rust的标准库刻意保持精简(甚至需要依赖crates.io才能获得随机数库),而Python的标准库庞大无比。实际使用中,两者的体验截然不同。

    7. > Rust并不难。它的标准库与Python或Ruby极为相似,方法命名也如出一辙。

      > 如果你试图将某种新颖类型硬塞进特定特质接口以便传递特质对象,那确实需要记住更多内容。但除非你在编写库,否则我得问问:你为何要写这种代码?

      我觉得你没抓住重点——他们说的(至少在我理解中)并非“Rust因抽象层过多而难”,而是“Rust因你必须通过这些抽象层向编译器更明确地阐明意图而难”

      我认为这种评价很有道理(天啊,大多数Rust开发者会把这当作特性而非缺陷)

    8. Rust之所以难,是因为它本身就难以阅读。

      如果你懂Java,就能看懂C#、JavaScript、Dart和Haxe的代码并理解其逻辑。Go语言大概也能摸索出来。

      而Rust就像重新学习编程一样。

      年轻时接触C++时,我就觉得这太难了,根本搞不定。

      后来发现JavaScript,一切都变得美好起来。

      我真正想要的是能编译成小巧二进制文件、运行速度超越C语言的JavaScript。或许可以清理npm依赖树,让专业审核员把关每个包。

      虽然觉得这不可能实现,但做做白日梦总可以吧

  47. 读起来像是对Rob Pike略带迷恋的肤浅评论。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

你也许感兴趣的: