Rust 赋能:意想不到的开发效率跃升

图0:Rust 赋能:意想不到的开发效率跃升

简而言之

Lubeno 的后端完全由 Rust 代码构成,随着规模扩大,我已无法同时将整个代码库的所有部分都牢记于心。

根据我的经验,项目通常会在这个阶段遭遇显著的进展放缓。仅仅确保修改不会引发意外后果就变得极其困难。

我发现Rust强大的安全保障让我在修改代码时信心倍增。这份额外信心使我更愿意重构应用的关键部分,这对我的生产力和长期可维护性产生了极大积极影响。

今天Rust又救了我一命!

最近遇到的问题引发了我的思考,最终促使我写下这篇文章。由于存在并发访问需求,我需要将某个结构体封装到互斥锁中。访问内部结构体时,必须先获取该互斥锁的锁定权限。

let lock = mutex.lock();
// … The locked data is used to generate a commit …
db.insert_commit(commit).await;

这个改动在我看来完全合理,rust-analyzer也未报错。但编辑器突然将路由器定义文件标记为红色,提示编译错误。这让我百思不得其解——锁机制怎么会影响路由器的处理器选择?

.route("/api/git/post-receive", post(git::post_receive))
                                     ^^^^^^^^^^^^^^^^^
error: future cannot be sent between threads safely
help: within 'impl Future<Output = Result<Response<Body>>', the trait 'Send' is not implemented for "MutexGuard<'_, GitInternal>"

我花了远超预期的时间才理清其中缘由。让我为你详细解析!

元素周期表

当新的HTTP连接建立时,我们使用的Web框架会为此创建一个新的异步任务。这些异步任务在工作窃取调度器上执行——这意味着当某个线程完成所有工作后,它会“窃取”其他线程的任务以平衡负载。这种窃取行为仅能在Rust的’.await’点发生。

还有一条重要规则:若互斥锁在某个线程被锁定,必须在同一线程释放,否则将导致未定义行为。

Rust会追踪所有生命周期,它知道锁的存活时间足够长且能通过’.await’点。这意味着锁可能在不同线程被释放,而这种情况是不被允许的——因为它可能引发未定义行为。

解决方案非常简单:只需在’.await’语句之前释放锁。

这类错误堪称最棘手!开发过程中几乎无法捕获它们,因为系统负载永远不足以迫使调度器将执行转移到其他线程。最终你只能面对这类“无法复现、偶尔出错、但永远不会在你面前发生”的错误。

Rust编译器能检测此类问题实在令人惊叹。更令人赞叹的是,互斥锁、生命周期和异步操作等看似无关的语言特性,竟能构成如此严密的系统。

反观TypeScript则令人胆寒

相比之下,我们TypeScript代码库中最近出现的异步错误,在部署到生产环境后仍长期未被发现。罪魁祸首如下:

// User logged in successfully!
if (redirect) {
    window.location.href = redirect;
}

let content = await response.json();
if (content.onboardingDone) {
    window.location.href = "/dashboard";
} else {
    window.location.href = "/onboarding";
}

逻辑极其简单:登录时检测是否存在重定向指令,若有则跳转至指定页面,否则进入仪表盘或引导页面。向’window.location.href’赋值即可实现浏览器跳转。

我确信当时测试通过了,但突然失效了。它真的曾经正常工作过吗?究竟发生了什么?我们始终被重定向到仪表盘,即使存在重定向请求也是如此。

这里存在调度竞争条件。给’window.location.href’赋值不会立即触发重定向——这与我的预期相反。它只是设置值并排程尽快执行重定向,但代码执行不会停止!这意味着后续赋值操作可能在浏览器启动重定向前执行,导致跳转至错误地址。我花了很长时间才弄明白这个情况。解决方案就是在if代码块中添加一个return语句,确保后续代码永远不会被执行。

if (redirect) {
    window.location.href = redirect;
    return;
}

我觉得Rust和TypeScript这两个问题很相似。它们都与异步调度相关,都表现出一些非常不明显的未定义行为。但Rust类型检查器实用得多,它能阻止这类错误编译通过。TypeScript编译器既不追踪生命周期也不支持借用规则,根本无法捕获此类问题。

无畏重构

Rust常被推荐为系统编程的理想语言,但在Web应用领域却鲜少成为首选。Python、Ruby和JavaScript/Node.js始终被视为更“高效”的Web开发语言。若刚入门确实如此!这些语言开箱即用,初始开发速度极快。

但项目规模达到一定程度后,一切便陷入停滞。代码库各部分之间存在大量松散耦合,导致任何改动都变得极其困难。

我们都经历过这样的情况:你修改了某个部分,一切运行良好,但两天后突然收到通知,说你的改动搞砸了另一个(完全无关的)页面。当这种情况发生第三次时,你对触碰代码库的意愿就会急剧下降。

使用Rust后,我的担忧大大减少,这让我能尝试更多新事物。随着代码库的增长,我甚至感觉自己的生产力有所提升。现在我可以更放心地构建、复用和修改代码,不必担心意外破坏现有功能。

Rust最厉害的地方在于它会明确告诉你:“你正在做的这个改动,会影响项目中另一个你可能完全没考虑到的部分——因为你深陷六层函数调用中,而截止日期迫在眉睫。但这里正是可能引发问题的根源。”

测试呢?

我认为测试太棒了!当进行大规模重构时,它们是捕捉回归问题的强大工具。但编译器并不强制要求代码必须通过测试才能运行。这意味着你可以轻易决定不添加测试。

有些日子压力格外大,时间紧迫且任务繁重。而测试会带来额外的心理负担——我需要决定抽象层级的合理性:该测试行为还是实现细节?这个测试能否真正预防未来错误?这些决策过程既耗神又易出错。

Rust的学习和编写有时颇具挑战,但它的妙处在于能将决策负担从我肩头卸下。这些决策早已由比我聪明得多的人完成——他们经手过庞大的代码库,并将所有常见错误都编码进了编译器。

当然,应用程序的某些特性无法纳入类型系统。这种情况下,测试就显得至关重要!

附赠:Zig同样令人胆寒!

Zig常被拿来与Rust比较,二者都致力于成为系统编程语言。我认为Zig非常酷炫,这种语言总能点燃我内心的极客热情。但转念一想,它同样令人胆寒。且看这个简单的错误处理示例:

const std = @import("std");

const FileError = error{
    AccessDenied,
};

fn doSomethingThatFails() FileError!void {
    return FileError.AccessDenied;
}

pub fn main() !void {
    doSomethingThatFails() catch |err| {
        if (err == error.AccessDenid) {
            std.debug.print("Access was denied!n", .{});
        } else {
            std.debug.print("Unexpected error!n", .{});
        }
    };
}

我们有一个名为’doSomethingThatFails’的函数,它总会抛出’FileError.AccessDenied’错误值,随后我们捕获错误并打印出访问被拒绝的提示。

但实际上我们并未这样做。错误处理逻辑中存在拼写错误’AccessDenid != AccessDenied’。代码编译毫无问题。Zig编译器会为每个唯一的’error.*’生成新编号,完全不关心你比较的是什么类型——它只看数字。

然而,若用’switch’语句替代’if’,Zig编译器突然会跳出来:”这显然不对!返回的错误值不可能是这个数值,因为它不在’FileError’中”,并拒绝编译代码。它有能力检测到这个错误,只是选择不理会。只要看起来像数字,’if’语句就当作数字来比较。

这种语言设计中的细微决策与Rust形成鲜明对比。对于经常误输名称的人来说,这可能会令人不安。

本文文字及图片出自 The unexpected productivity boost of Rust

共有 483 条评论

  1. 去年我移植了一个用Rust编写的virtio-host网络驱动程序,彻底改造了其后端架构和中断机制,并将其从库文件转换为独立进程。这个复杂程序需要处理底层内存映射、虚拟机中断、网络套接字、多线程等多种任务。

    值得注意的是:(a) 我整体上几乎没有Rust经验(主要是一名Python程序员),(b) 几乎没有virtio经验,(c) 基本上没有使用过任何相关库的经验。然而,我仅用一周就完成了重构,因为当项目最终编译成功时,它运行得完美无缺(仅有一个与Drop相关的轻微错误,很容易就被发现并修复了)。

    这得益于那些精心设计的库——它们竭力避免被错误使用,效果显而易见。

    • 我编写Rust代码已有相当长的时间,通常只要能编译通过,代码就能正常运行。虽然偶尔会遇到死锁和更高层次的顺序问题,但排除错误因素后,编译器的成功通常意味着项目的大部分功能都能正常运行。

      • 虽然我的代码复杂度远不及你所写的,但体验颇为相似。存在几个陷阱和chrono库的漏洞,我至今没精力去报告或修复;这导致每半年出现一次问题,除此之外我还是个快乐的小程序员。

        • 好奇你是否研究过jiff?它能否解决你使用chrono时遇到的漏洞/陷阱问题?

      • 这点很像Haskell。一旦成功编译,代码往往就能稳定运行。

      • 像你这样的评测正让我对Rust兴趣渐浓。显然其工具链和生态系统相当出色,建立在扎实的概念和基础之上。

        • Rust存在三大问题:

          – 编译耗时

          – 编译耗时
          – 编译周期过长

          小型项目尚可接受,但若引入依赖库或代码量庞大,就必须提前规划编译单元。

          • Rust的增量编译表现如何?大型项目中单文件增量重编译是否迅速?希望链接时间不会太糟。

            JVM最吸引我的特性是热代码重载。多数情况下,方法/函数内的修改能通过热代码重载即时生效。

            • 链接时间最令人头疼,但可通过mold[1]/sold解决。增量编译通常比完整编译快一个数量级(甚至两个),不过老实说仍会感觉迟缓。调试时借助sccache[2]或cranelift[3]等工具能有所改善。虽然仍不及支持热重载的语言,但根据我的经验,速度已提升至相对舒适的水平。

              [1] https://github.com/rui314/mold [2] https://github.com/mozilla/sccache [3] https://github.com/rust-lang/rustc_codegen_cranelift

              • 我从未成功让 sccache 真正加速项目,不过话说回来,只有发布构建对我来说才真正难以忍受——而且仅限于开发 Deno 时,那项目规模实在庞大。

                • sccache 在某些项目上将我的干净编译速度提升了 2 倍,但多数时候这解决方案效果因人而异

            • 我们在Deno项目中遭遇过漫长编译时间的困扰。发布构建极其痛苦,调试构建只要采用增量方式尚可接受。这可能是当时最大的开源Rust应用之一,但我们的开发效率依然很高。

              这类问题很可能数年后才会显现,且已有缓解方案。

  2. 我认为Rust很棒,也认同文章这部分观点。

    但我不认同将href赋值漏洞未被发现归咎于TypeScript。我认为这与TypeScript无关。真正的漏洞在于:设置href竟会延迟位置切换至后续执行,这种设计违背直觉。试想如果Rust也存在类似延迟执行的set_href函数,同样会引发此类错误:

        set_href(‘/foo’);
    
        if (some_condition) {
            set_href(‘/bar’);
        }
    

    当然,Rust绝不会这样设计,因为这属于糟糕的库设计:在设置器中执行操作不合逻辑,且href赋值后不立即跳转也毫无意义。Rust自然不会采用如此愚蠢的设计。或许我在吹毛求疵,但这并非Rust与TypeScript之争——而是Rust标准库与Web平台API的差异。对此我完全赞同,Rust 绝不会做出如此愚蠢的设计。

    • 法官大人,我提出异议 😉

      “setter” 绝不应触发任何操作,尤其不应在 setter 内部立即执行。

      至少应修改命名,例如改为 navigate_to(href)

      但在浏览器环境中,为什么操作不会立即发生也是显而易见的——你的整个 JavaScript 代码本质上只是一个回调函数,它服务于浏览器的事件循环,并告知循环接下来该做什么。一个永远不会返回给调用者的函数,显然不符合这种整体机制。

      • 说得有道理。我其实修改了评论,因为原以为大家都默认setter里不该做任何操作 🙂

        > 永不返回调用方的函数不符合整体设计逻辑。

        嗯,这个我不确定。在Node环境中,你可以通过process.exit()退出回调函数。如果href的设置也能这样实现,我觉得会更清晰。

        • > 如果设置 href 也能这样操作,我认为会更清晰。

          你设想这种机制如何与 try-finally 配合?后者常用于清理资源、释放锁、关闭文件等操作。

          • 无论如何,JavaScript中的try-finally机制存在泄漏风险。若try块包含await点,其终结器可能永远不会执行。浏览器也有权在JavaScript回调中途终止标签页进程,且不触发任何终结器(例如因运行浏览器的计算机遭雷击)。

            因此 try-finally 最多只能作为强制代码局部不变性的工具。当 process.exit() 之类的函数彻底退出当前 JavaScript 环境时,跳过 finally 块并无危害。

    • 感谢指正!我本该更清晰地说明那个示例。

      我的核心观点是:Rust的所有权模型允许设计这样的API——调用window.set_href(‘/foo’)会获取window的所有权,从而禁止重复调用。而TypeScript完全不具备这种可能性,因为它不追踪生命周期。

      当然,TypeScript在此处无论如何都无能为力。即便它能识别生命周期,JavaScript API早已存在,且无法在其上叠加所有权模型——因为全局变量和API实在太多。

      我更想展示的是Rust整套特性如何完美契合,仅凭“类型系统”很难实现同等保障。

      • 我对Rust了解有限,但这是否仍存在漏洞?例如若将window.set_href改为移动语义,以下代码是否仍能运行(即不报错)?

            let win = window.set_href(“/foo”)
            win.set_href(“/bar”)
        

        你可能会问“谁会这么做”,但我的观点是:若问题真源于缺少移动语义(而非延迟更新),那么只要类型正确就永远不该出错。况且存在延迟更新时,你可能确实需要在 set_href 后执行操作,比如在 finally() 块中发送分析数据。

        事实上,TypeScript确实有解决此问题的方案——只需让setHref返回never[1]!这样后续调用setHref或其他任何操作都会报错。若我理解无误,这类似于Rust中!的机制。

        看来TypeScript终究没那么糟糕嘛 🙂

        [1]: [https://www.typescriptlang.org/play/?ssl=9&ssc=1&pln=9&pc=2#…](https://www.typescriptlang.org/play/?ssl=9&ssc=1&pln=9&pc=2#code/GYVwdgxgLglg9mABGAhgNxgcxVApgChACcAbALkQGcoiYxMBKCsXNXIxAbwChFEB3OgBM4 -AHQk4EHPDBiAFkVzBEAXkTESAbl6Ioi0clz9EAUSJE4RfACIAqmCUoI8lACMSuGwy2IA9H6IAFYg1HpwVDKUwACeiAAqAMrcAL7c3BAIYXhh6vgMagB8XLp8qBjYePgA5CiUQsDVPq XI6Fg4BNVC7I3NKUA)

    • Rust的示例很有意思,但TypeScript的示例并未说明TS是否适合大型项目。

      我害怕Ruby是因为总在运行时抓到bug,但关键在于:提交前它总能正常运行,实现过程也足够简单,阅读和编辑代码的过程令人满足。现在的问题是,当项目规模扩大时,我能否继续这样下去。

      location.href的问题本质上是JavaScript遗留给TypeScript的难题。由于JS允许修改属性,浏览器不得不处理这种变更。但这不同于Ruby的exit关键字——页面会持续存在直至新页面加载,理解原理后这完全合乎逻辑。

    • 技术上Rust可通过set_href返回值()!来暗示语义。但条件重定向场景下,“错误”用法确实不会被提示(非条件重定向时,你或许能察觉后续代码存在死循环)。

    • 这并非文章中代码的仓库。文章的实际代码应为:

          set_href(‘/foo’);
      
          let future = doSomethingElse()
          block_on(future)
      
          if (some_condition) {
              set_href(‘/bar’);
          }
      

      这段代码更清晰地展现了问题。doSomethingElse 实质上允许页面退出。在多数应用中(包括 Rust 应用)这并无不同。

      当设置 window.location.href 时,浏览器不会立即启动进程。它会在代码退出后启动进程,并让事件循环处理其他任务。示例代码中的 await 允许其他任务运行,包括加载新页面(或退出应用等)的任务——该任务是在设置 window.location.href 时添加的。

      若仍不明确

          // 任务1
          window.location.href = ‘/foo’ // 任务2(将任务2加入队列以加载页面)
      
          let content = await response.json(); // 添加任务3加载json
                                               // 该任务完成后将触发任务4
                                               // 继续后续流程
      
          // 任务4
          if (content.onboardingDone) {
              window.location.href = “/dashboard”;
          } else {
              window.location.href = “/onboarding”;
          }
      

      任务2在任务1之后执行。任务1在await处退出。任务2清空所有任务队列,导致任务3和任务4永远不会执行。

      • 不,我认为你误解了其工作原理。问题在于你所说的任务4在重定向值触发的导航之后执行。

        作者期望window.location.href设置器的副作用——跳转至新页面——能终止其下方的代码执行。显然这不会发生,因为第一个if语句中没有return语句。

        • 其实存在return,只是伪装成“await”的形式

          *简化说明*:“await”的语义本质是语法糖

              const value = await someFunction()
              console.log(value);
          
          return someFunction().then(function(value) {
            // 该代码在返回语句后的条件执行
            // 除非其他操作(如加载新页面)清除了所有事件
            console.log(value);
          });
          
          
          
    • 没错,问题在于某些老旧的Web API显然是在90年代仓促拼凑而成,如今我们只能承受这种设计缺陷的后果。不过这种情况并非Web独有——根据我的经验,WinAPI的全部函数和大多数libc函数也基本如此

    • 所以你的论点是:Rust更优秀,因为优秀程序员都用Rust。

      我特别指的是这句:“Rust绝不会有如此愚蠢的库设计”。

      那同样可以反驳说:Rust程序员也绝不会做出这种循环论证。

      • 若想更宽容些,可以说“Rust库设计优于Web API库设计”,我认同此观点——尤其针对像.href这类几十年前设计的陈旧东西。

  3. 若未提前返回,你代码行下方的内容仍会执行。更多突发新闻请关注8频道。

    说真的,你凭什么认为赋值操作能阻止脚本执行?TypeScript示例或许缺少上下文,但拿这种情况当作“数据竞争”案例实在离谱。

    • 对 `window.location.href` 的赋值具有副作用。该副作用会使浏览器跳转至你设定的地址,如同点击链接般生效。这种行为本身已令人意外,但考虑到此赋值本质上是在原地加载新页面——类似 `execve` 对进程的操作方式——完全能理解为何有人会认为点击链接后 JS 执行会立即停止。

      在编程时依赖此类假设显然不明智,当你产生这种直觉时,通常应暂停并核实规范的实际要求。但此处的行为异常诡谲,一切推测都失效了。有人因此上当受骗,我丝毫不感到意外。

      • 问题的一部分在于,我们在软件开发过程中每天都会不自觉地做出无数细微的假设。其中许多是合理的,有些在技术上虽不合理但在实践中可行,还有些则是潜在的灾难。不仅难以分辨它们的性质,甚至要察觉其中一小部分都极其困难。

        我确信自己曾经知道过href相关的事。文档里可能也写过。但_API本身_为这类误解留下了巨大漏洞,这几乎是无数人犯过的错误。我们需要记住的文档条目越多,为避免日常失误而努力,犯错的概率就呈指数级增长。

        在我看来,优秀的软件工程在于让事物难以被错误使用。强类型系统、尽可能采用无副作用的纯函数、默认不可变语义等实践,都能为构建不易误用的软件奠定坚实基础。

        • 这其实主要关乎语言的表达力——这种能力既能成就卓越,也可能滋生晦涩难懂的代码。(另如JS的演变历程:从设计拙劣、存在浏览器注入漏洞的脚本语言,蜕变为支撑现代网络核心的工业级语言,同时保持强大的向后兼容性)

          这种特性可被推向极端(如C/Zig试图让每行代码在局部可理解——另一极端则是符号的过度重载,参见Haskell/Scala)。

        • 坦白说,href的处理方式在我看来完全合理。虽然API设计确实欠佳,但既然接口本身如此设计,脚本在遇到该行时停止执行也合乎逻辑。

          对我而言,这正是我惯常高度警惕并默认采取防御性处理的场景。若非今日查证,我无法确切说明设置location.href的精确行为,但能确认多年前编写的代码已正确处理此情况——因为当时主动添加return语句毫无成本。

          正如这个例子所示,防御性编程往往能避免令人抓狂的海森堡错误(不仅来自错误假设,也因正确假设被第三方变更推翻)。即便在技术上并非必要,它仍是提升代码可读性、减少歧义的有效风格选择。

      • > 当你产生此类直觉时,通常应暂停并核查规范的实际表述

        令人欣慰的是,如今我们已发展到这样的阶段:为浏览器编写JavaScript时,建议参考规范而非浏览器版本矩阵。

        话虽如此,为何有人宁可展开研究,也不愿通过简单修改代码来减少依赖假设?这样既能降低代码复杂度,又能让团队中不熟悉规范的程序员轻松理解代码?

      • 我使用JavaScript已有约15年,原以为它本该如此运作。

        • 我确信以前确实是另一种机制。即便不是,近期也发生了变化,使得“延迟执行”的行为在主流浏览器中更常见。

      • 这究竟是JavaScript的缺陷还是浏览器的缺陷?JavaScript是通过API与浏览器通信,Rust也需要采用相同方式。

    • exit()、execve()等函数确实会立即终止执行——我能理解你为何认为重定向也会如此。

      • 完全正确。考虑到JavaScript在页面上下文中运行,重定向离开页面看似应该像“noreturn”函数那样行为… 但实际并非如此。这似乎是个极易犯的错误。

      • 直到某天出错。常见问题是未验证 execve() 是否成功执行,误以为后续代码不会运行——这种假设并不总是成立。

      • 看,这就是前文所说的“不要妄下结论”:

        [https://man7.org/linux/man-pages/man3/atexit.3.html](https://man7.org/linux/man-pages/man3/atexit.3.html)

      • 重定向本质是赋值操作。在任何语言中,变量赋值都不会中断程序执行。

        • $ python3
          Python 3.13.7 (main, Aug 20 2025, 22:17:40) [GCC 14.3.0] on linux
          Type "help", "copyright", "credits" or "license" for more information.
          >>> class MagicRedirect:
          ...     def __setattr__(self, name, value):
          ...         if name == "href":
          ...             print(f"Redirecting to {value}")
          ...             exit()
          ... 
          >>> location = MagicRedirect()
          >>> location.href = "https://example.org/"
          Redirecting to https://example.org/
          $
          
          
          
          
          • 你这里是在重载设置器。挺有意思的,我在JS里也这么做过,但我不认为这算反例。若按原博客的思路,把这当作常态就有点奇怪了。

            • 这没什么奇怪的。以下是Python中属性设置器可执行任意操作的典型示例,这种设计本就是语言特性。

              “`python
              import sys

              class Foo:
              @property
              def bar(self):
              return 10

              @bar.setter
              def bar(self, value):
              print(“bye”)
              sys.exit()
              “` ()

              foo = Foo()
              foo.bar = 10

              若排除动态语言,C# 中的实现如下:

                  using System;
              
                  class Foo
                  {
                      public int Bar
                      {
                          get { return 10; }
                          set
                          {
                              Console.WriteLine(“bye.”);
                              Environment.Exit(0);
                          }
                      }
                  }
              
                  class Program
                  {
                      static void Main()
                      {
                          Foo obj = new Foo();
                          obj.Bar = 10;
                      }
                  }
              

              这在许多编程语言中并非什么深奥的概念。

              • 你同时还覆盖了设置器。或许我在此观点与众不同,但这绝对是晦涩难懂的。赋值运算符本不该产生副作用,或许这是我逻辑思维使然,但暗示我们执行x = 5时可能发生诡异现象,这种想法根本荒谬至极。

                • 你开篇说“没有任何语言的变量赋值会中断执行流程”,现在却说“赋值运算符不应产生副作用”。location.href就是反例,各类工具、语言和库中还存在大量反例。你认为事物“应该”如何运作,并不影响事物“实际”如何运作——理解后者至关重要。(我确实认同这是“不良实践”,但现实中这种情况确实存在,且人们往往无法完全掌控所处的环境。)

                  既然 location.href 确实存在副作用,有人误以为该副作用是即时而非异步发生的,这种认知并不荒谬。

                  话虽如此,若你_不喜欢_使用这类语言,这恰恰是选择避免此类问题的语言的理由——这又回到了文章的核心观点。

                  • > 你开篇说“没有任何语言的变量赋值会中断执行”,

                    讽刺的是我严格来说依然正确——所有示例(从C++到C#到Python到JS)都是滥用getter/setter的对象属性赋值,绝对_不是_变量赋值(除UB示例外)。

                    • 整场讨论的核心就是属性赋值。日常用法中这也被称为变量赋值。既然无人纠正你的表述,说明这点显而易见。你现在试图偷换概念的行为实在荒谬。

                    • 整场讨论的核心在于“=”操作符的异常行为,而这种异常在99.9%的情况下并不存在。我的观点是:任何语言若不通过异常手段(如重载)实现功能,就不可能让“=”产生异常效果(从而保持纯粹性)。所有反驳都涉及非标准契约。因此默认情况下,绝不会预期使用“=”会产生某种神奇副作用。

                    • > 所有反驳都涉及非标准契约。因此默认情况下,绝不会预期使用“=”会产生某种神奇副作用。

                      这听起来像是每次遇到非标准契约都会出问题的配方。你_真的_在说要刻意忽略这种可能性吗?还是把“不该存在”和“不存在”混为一谈了?

                      如果我用的是支持属性的语言,这种可能性_绝对_随时可能成为预期。这也是我不太喜欢这类语言的原因之一。

                      举个类似的例子:若用C语言编程,当涉及可能区分函数与宏的场景(如将函数名作为函数指针传递时),“此函数可能实为宏”始终是需要警惕的可能性。

                    • 因此,对于非基本类型(如int、bool等),赋值操作可能需要内存分配或其他类似操作。若发生这种情况,就存在引发严重问题的风险(例如内存不足导致程序终止)。

                      更常见的情况是,以C++的unique_ptr为例,赋值操作会在后台执行大量操作以保持unique_ptr的特性一致性。Rust等语言可能也会对特定类型进行类似操作,以实现语义层面的保障。

                    • 在JavaScript(或Python等)语言中,即使涉及整型和布尔型这类“基本类型”,也可能发生内存分配等操作。

                      Haskell同样如此,但原因不同。

                    • 严格来说并非如此。设置器产生副作用并不意外,尽管设置器产生大量意外副作用通常并非最佳选择。但获取器产生副作用则绝对出乎意料且不可取。有趣的是,这恰恰是Rust真正发挥价值的领域——它强制开发者通过可变性表达意图,并能有效执行我们预期的规范。

                    • 为维护不变量而重载赋值运算符是一回事,但这种会自行执行I/O操作的特殊情况,对我这个嵌入式C++背景的人来说很奇怪。我向来不喜欢运算符重载,认为应该避免使用——这点偏见我坦白承认。我也已经多年不用C++编程了,转投Rust后再没回头。

                    • 但它根本不会跑去执行I/O操作…这才是真正的漏洞!楼主误以为设置变量会立即加载新页面,实则不然——它仅标记了待加载页面。页面需等到应用进入空闲状态才会加载。因此关于设置器与副作用的整场讨论完全偏离了重点。

                    • > 有趣的是,这恰恰是Rust真正发挥价值的领域——它迫使你通过可变性表达意图,并能强制执行这些预期。

                      尽管Rust只关注可变性,但它不会追踪你究竟是要发射核弹还是格式化硬盘。

                    • 确实如此。但我不会期待任何编程语言做到这一点。

                      Rust在语言层面提供了保障机制,帮助你强制执行可变性与所有权规则,但如何利用这些保障仍取决于你。

                      若你执意为之,调用不可变函数时依然能让Rust改变数据——就像你能用纸吸管捅死人一样。

                    • > 确实如此。但我不会指望任何编程语言做到这一点。

                      Haskell(及其更偏研究导向的同类语言)恰恰实现了这一点。你只需用 IO 标记函数即可执行 I/O 操作,纯函数则无需标记。

                      作为 Haskell 用户,我曾怀疑 Rust 的保障机制是否真正有效——毕竟它们无法阻止你发射核弹。但实践中这些机制仍出乎意料地实用。

                      顺便一提,D语言支持将函数标记为'纯函数'。纯函数允许内部变量修改,但禁止副作用。这比C++的const机制实用得多。(由此可见D语言与Rust一样,是由致力于规避并改进C++缺陷的设计者打造的。)

                • 赋值行为_本质上_就是副作用。

                  这场讨论完全偏离了焦点,因为变量设置本身并不会终止脚本——这才是真正的漏洞;它仅仅是设置变量(即设置全局可访问结构中的属性)。真正的操作是在稍后时间从已设置的变量中加载新页面。

                  除此之外,你的评论充斥着移动目标等令人不快的谬误和逻辑错误。

                  顺带一提,我成长于那个年代(其实当时已是编程十年的成年人):直接将值存储在PDP-11内存的I/O页中,会直接改变将操作寄存器映射到这些内存地址的硬件设备。这正是C语言引入volatile关键字的主要原因。

                • > 赋值运算符本不该有副作用,

                  内存映射I/O对此持反对意见。写入值可能触发各种连锁反应。

                • 赋值本身就是副作用,通过对象/类实例的setter方法进行赋值时更是如此

            • 但 window.location.href 本身就是重载的设置器。它会调度页面导航。

        • > 重定向属于赋值操作。在任何语言中,变量赋值从未导致过执行中断。

          许多语言支持基于方法调用的属性赋值语义。在这些语言中,若运行时环境允许,被调用的方法可终止程序执行。

          例如以下定义的代码:

            foo.bar = someValue
          

          其评估效果等同于:

            foo.setBar (someValue)
          
        • 尝试在C语言中执行:

          *(int*)0 = 0;

          现代C编译器可能要求你刻意复杂化代码以混淆其判断,因为它们对未定义行为的处理方式很奇怪——一旦遇到未定义行为,它们可能执行任意操作。但在早期时代,此类赋值操作会始终导致SIGSEGV信号并终止程序。

          • 除非你在地址映射为可写但始终为零值的系统上运行,这样就能无忧进行加载存储推测。

            IBM长期采用这种机制

            • 我最喜欢的是老式的嵌入式系统,其中0是一个你确实会与之交互的地址。因此在某些代码段中,你需要进行空指针访问。具体细节记不清了,但记得跳转到空地址重置系统相当常见。

              • 可能是系统中断表。索引0可能指向不可屏蔽中断NMI的处理程序,通常与上电复位功能相同。

                记得在DOS环境下,Borland Turbo C会检测对地址0的写操作,并在程序正常退出时弹出提示信息。

              • RANDOMIZE USR 0

            • 在Wasm中可自由读写线性内存地址0处的数据。

              对clang而言这仍属未定义行为,因此C代码可执行任意操作。但不会当场导致程序崩溃。

        • 可在C++中重载operator=()并调用exit(),实现“导致程序终止的变量赋值”。

          • 至于Rust的刻意示例,可让+=操作终止执行:

                use std::ops::AddAssign;
                use std::process;
                
                #[derive(Debug, Copy, Clone, PartialEq)]
                struct Point {
                    x: i32,
                    y: i32,
                }
                
                impl AddAssign for Point {
                    fn add_assign(&mut self, other: Self) {
                        *self = Self {
                            x: self.x + other.x,
                            y: self.y + other.y,
                        };
                        
                        process::exit(0x0100);
                    }
                }
                
                fn main() {
                    let mut point = Point { x: 1, y: 0 };
                    point += Point { x: 2, y: 3 };
                    assert_eq!(point, Point { x: 3, y: 3 });
                }
            
          • 我原本忽略这类花哨的重载场景,但即便是JS也能通过篡改设置器引发意外行为(见下方代码)。

          • 我不确定是否该将重载赋值运算符来调用函数的行为,视为真正的赋值操作。

            • 不过当你查看调用方的源代码时,它看起来完全像普通赋值操作。

        • 这在我看来并不明显。设想一个仅调用exit并终止整个程序的setter。

          • 确实,这点值得注意。理论上可以实现自定义setter,表面看似赋值操作,实则执行复杂逻辑。

                const location = {
                  set current(where) {
                    if (where == “boom”) {
                        throw new Error(“Uh oh”); // 控制流在此中断
                    }
                  }
                };
            
                location.current = “boom” // 虽然看似赋值,实则跳出控制流,JS真蠢哈哈
            
        • 在Blink中setHref会自动绑定到C++代码[1]。可以说这里完全没有限制。

          [1]: [https://source.chromium.org/chromium/chromium/src/+/main:thi…](https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/frame/location.cc;bpv=1; bpt=1;l=145?gsn=setHref&gs=KYTHE%3A%2F%2Fkythe%3A%2F%2Fchromium.googlesource.com%2Fcodesearch%2Fchromium%2Fsrc%2F%2Fmain%3Flang%3Dc%252B% 252B%3Fpath%3Dthird_party%2Fblink%2Frenderer%2Fcore%2Fframe%2Flocation.cc%235dpUtEs_gNfN7GTs_HuW1d8L5ztFqGh3JuuAWoNXHCU)

    • 嘲笑别人谈论自身经历似乎很奇怪?

    • 你是否认同并非重点——一旦指出问题所在,修复方案就显而易见。作者的论点在于:像TS问题这样的漏洞往往极其难以追踪且耗时,编译器也无法检测到。

    • 每当有人提及这类出人意料的“纸割伤”问题,总会冒出“这还用说”的误导性评论。

      废话。你刚读完解释原理的博客才觉得显而易见。关键在于:当大量“显而易见”的漏洞散布在庞大代码库中,迟早会有某个漏洞反噬。

      语言本身能帮助避免这类陷阱才更理想。

    • > 说真的,你凭什么认为赋值操作会阻止脚本执行?

      该赋值操作存在显著副作用——导致页面跳转。认为这是即时操作而非异步定时任务并不为过(我敢说自己当初看到或操作时也这么想)。

    • 这本质是控制流问题而非数据竞争问题。我见过无数次类似情况,往往表明你没花太多时间写JavaScript/TypeScript。这种陷阱会让你屡屡自食其果。部分代码检查工具能捕捉此类问题——实际上多数工具都能做到

    • 楼主以为重定向是同步操作,而非会阻塞脚本执行

      • 不,你误解了。请看父评论下的其他回复。若是同步操作,它本应像 POSIX exec() 那样终止脚本执行。既然 OP 认为脚本不会停止,为何要让执行流程落入本不该执行的代码?而他正是通过阻止流程落入来修复问题的?

  4. 这些优势不都归结于使用静态类型且可编译的语言吗?无论是Java、Go还是C++;TypeScript稍复杂些,因为它编译为JavaScript并继承了某些问题,但依然可行。

    我知道Rust因更严格的类型系统提供了额外的编译时检查,但这并非免费——它更难学习,甚至可以说更难阅读。

    • 在很大程度上确实如此,但Rust为类型系统增添了更多维度:所有权、共享与独占访问、线程安全、互斥字段(合集类型)。

      所有权/借用机制明确了函数参数是仅在调用期间临时提供查看权限,还是授予函数独占使用权限。这确保了数据被修改时不会出现意外的远程操作,因为始终清晰可知谁拥有修改权限。在大规模程序及第三方库使用场景中,这极具价值。反观Go语言:虽然提供切片类型,但类型系统对数据能否追加切片毫无约束(实际行为取决于运行时容量),且无法将切片作为临时只读视图借用(除非通过非切片类型的抽象封装实现)。

      类型系统的线程安全机制能在编译时可靠捕获一类数据竞争错误——这类错误在其他语言中几乎无法定位调试,即便能发现也至少需要在运行时通过 sanitizer 捕获。

      • 借用机制令我困扰之处在于:我的默认操作模式是尽可能避免变异操作,为此不惜付出额外努力。但Rust却强制要求我复制或克隆参数——即便这些参数在传递给其他过程后本就不会被修改。这带来了巨大的认知和语法开销。而在函数式语言中,传递的是值本身,系统默认不会修改参数值,因此无需额外操作就能实现参数传递与后续使用。

        本质上,若不修改数据,我根本不需要所有权机制。当需要修改时,所有权概念固然有用,但当我根本不修改数据时,却必须时刻关注所有权状态并在代码中携带它,这实在令人不快。

        • 这似乎表明你可能并不真正了解Rust——因为非所有权借用和所有权关系都能在类型系统中直接表达:

          无需克隆/复制的非所有权非可变借用:

              fn foo(v: &SomeValue)
          

          所有权转移,无需克隆/复制,不可变:

              fn foo(v: SomeValue)
          

          所有权转移,foo可变:

              fn foo(mut v: SomeValue)
          

          据我所知,Rust已支持你所要求的全部表达能力。但若需两个实体同时持有值的所有权,则必然需要克隆操作——若需统一底层值的版本,还需根据需要使用 Rc/Arc 进行封装。相较于 F#(因不熟悉该语言无法具体比较),这可能需要更多语法操作,但这是系统工程语言的特性使然,其性能目标定位也截然不同。

          • 能否举例说明这些过程的调用方式?根据我的经验,当传递值(而非引用)时,必须借用该值且无法在调用过程中后续使用。传递引用则截然不同——这需要额外语法来处理被引用的对象。

            • > 因为根据我的经验,当传递值(而非引用)时,必须进行值借用操作,且调用方后续无法继续使用该值。

              啊,你对术语存在混淆。值借用仅发生在创建引用时。当传递非复制值时,实际操作是值移动。

              通常,传递给函数的非复制类型都应采用(非可变)引用传递,除非有特殊需求。这允许被调用方借用该值,意味着调用方在调用后能取回原始值。这种工作流最契合类型系统的设计理念——得益于自动引用机制,让所有函数使用借用值是编写代码最便捷的方式。

              需注意:在Rust中传递值类型时,始终会生成副本。对于非副本类型,这意味着调用方必须停止使用该值,即采用移动语义。通常不应通过克隆所有值来处理此问题,而应为符合条件(值语义且规模较小)的类型派生copy,其余情况则使用借用引用。

              • 那么,如果我理解正确的话,在不实现或派生Copy或Clone的情况下,就无法传递值(而非引用)。这正是我早先的印象。其他语言允许传递值,而我通常会避免修改该值。我通常不愿传递引用,因为这意味着在被调用方使用被引对象时需要额外的语法操作。而在许多其他语言中,这无需任何语法代价。我通过名称传递对象,调用后既可在被调用方使用,也可在调用方继续使用。

                我更希望Rust仅在传递可变值时,才关注调用方是否在调用后使用该值——毕竟此时若被调用方修改该值,显然可能导致不安全。

                有时无法推导 Copy,此时就需要手动实现 Copy 或 Clone。几个月前我短暂重拾 Rust 时就遇到过这种情况——记得是某个 Serde 结构体因内部包含 String 或 &str 而无法推导 Copy。这种场景应该相当普遍。

                • 你可以传递既非Copy也非Clone的值,但该值会被移入被调用方,调用方将无法继续访问。

                  需注意:对大型类型而言,按值调用开销巨大。其他语言采用的始终是按引用调用机制,你似乎将其与按值调用混淆了。

                  Rust 确实无法实现你期望的行为。函数类型检查仅需该函数代码及所有其他元素的类型定义,函数内部实现无关紧要。这是极佳的设计原则,能显著提升代码可读性。

                  • 在Rust中开销大吗?通常只有栈中数据会被复制,堆中数据保持不变

                    • 是的,但大型值类型除非手动装箱,否则会驻留在栈上。按值传递的开销会迅速累积,尤其当值在调用链中反复传递时。

                • 没错,Rust不会自动将值转换为引用。引用才是你需要的语义。若持有值,必须显式添加&操作符——这才是符合惯例的做法,而非传递值并克隆。
                  &str支持Copy,而String不支持。

                • > 其他语言允许传递值,而我尽量避免修改传递的值

                  它们如何在不自动获取引用或复制/克隆的情况下实现?若能提供示例将很有帮助。

                  • 我并未断言它们不会自动复制或克隆。

                    不过我可能搞错了它们的实际行为。我只是厌恶必须为参数显式添加&,然后在函数内部又不能将其当作值处理——必须时刻谨记它们只是引用。

                    • C++同样支持自动复制,这是值语义的特性。引用必须手动获取——不同于Java或C#,它们默认引用并需显式声明复制。据我所知,Rust默认对多数类型采用移动赋值(而非复制,但很接近)。

                      值语义的优势在于兼具安全性与高效性。类似PHP中数组赋值看似复制,实则底层采用延迟复制机制。因此在不修改数据时能保持极高效率,同时保留值语义的安全保障。

                • 你需要的模式是:

                  fn operate_on_a(a: A) -> A { // 在该作用域仍拥有A时执行任意操作
                  }
                  
            • 若仅涉及不可变访问,你完全可以自由地以不可变方式借用该值任意次数(即使在多线程环境中,只要T支持Send):

                  let v = SomeValue { ... }
                  foo(&v);
                  foo(&v);
                  eprintln!(“{}”, v.xyz);
              

              你必须获取引用。我不确定你如何实现“向函数传递非引用值但仍保留所有权且不复制”——比如如果foo将值存储在某个位置呢?若没有克隆/复制来生成独立实例,你可能面临两个所有者——foo和foo的调用者,这在所有权严格唯一的情况下是不合法的。若F#允许此操作,很可能是因为它为你隐式生成副本(Rust在声明继承Copy的类型时会透明实现此功能)。

              但除此之外,我不清楚你试图表达何种所有权语义——若能提供示例将更有助理解。

          • 我也深有同感,问题不在于不可行,而在于我更希望赋值时默认采用复制或移动操作。就像在C++中使用STL组件时的体验那样。

            • 不可复制类型不能默认复制,因为可能存在巨大的性能断崖(例如复制大型向量——这是C++的默认行为)。

              但Rust在赋值时始终默认移动,所以我不太明白你的不满。如果类型声明实现了Copy,当所有权冲突时Rust会在赋值时自动复制它。

              • 我一直在思考如何表达这个观点。

                我的不满在于:由于移动是默认行为,成员访问和容器元素访问通常涉及借用操作,而我厌恶处理借用对象。

                这属于个人偏好问题,我更希望所有类型默认支持复制,仅标记为不可复制的类型例外。

                我理解Rust开发者选择相反方案的初衷,基于他们的优先级考量这很合理。但我并不认同。

                附注:我日常编写Python代码时,引用是默认行为,但由于无需担心生命周期、借用检查器或内存泄漏,我对这种默认机制更为满意。

                • 你讨论的并非值的复制。你希望像Python和Java那样轻松创建智能引用并随意复制,但Rust因缺乏GC机制使得实现更为复杂。

                  在Rust中,“Copy”意味着编译器可安全地按位复制值。但对于String/Vec/Rc/Arc等类型,按位复制无法复制底层值(例如对String操作会导致内存安全违规——两个不同的拥有字符串会指向同一底层缓冲区)。

                  若存在类似Copy的“AutoClone”Trait,能让编译器在需要时自动注入.clone操作以保障所有权机制,这或许很有趣。但这种方案不太可能实现,因为可能出现实现 AutoClone 的类型包含巨大 Vector 或 String,导致克隆耗时过长;这将阻碍 Rust 在系统编程场景(如操作系统内核)中的应用,而该场景正是 Rust 的核心定位。

                  顺便提一句,Rust 通常不会出现内存泄漏。若想避免生命周期和借用检查器的困扰,只需将所有内容封装在 Arc<Mutex<T>>(多线程访问引用时)或 Rc<RefCell<T>>(单线程时)中。你也可以自定义类型实现此功能,提供便捷的Deref/DerefMut访问(但性能会低于精心编写的Rust代码),同时仍存在类似Python的线程安全问题(对象内部一致性有保障,但若执行r.x = 5; r.y = 6 时,可能观察到 x=5/y=旧值 或 x=5/y=6)。但每次需要独立所有权时,必须显式克隆引用。

                  • 不,我完全理解区别。只是既然没有垃圾回收机制,我更希望系统自动进行复制而非处理引用。

                    至少在性能允许的情况下如此。否则借用机制就行。但我更倾向默认语义采用复制。

                    • > 至少在性能上我能承受时。否则就借用它。但我更希望默认语义是复制。

                      语言如何判断你能否承受?默认语义不可能是“复制”,因为在Rust中复制具有非常明确的含义,相当于C++中的is_trivially_copyable。默认不能是复制,因为Rust并非试图成为脚本语言的替代品(尽管实践中似乎正在发生这种情况)——人们接受C++的“默认克隆一切”做法令人惊讶,但我怀疑这更多是出于历史遗留问题,以及人们学会了让它勉强工作。顺便一提,C++中引用无处不在,只是不强制显式声明(例如void foo(const Foo&)、void foo(Foo)和void foo(Foo&)在调用点都接受Foo实例,尽管实际行为差异巨大)。

                      但你的论点本质上是“希望保留Rust核心特性却舍弃其独特机制”,这种矛盾要求实在难以调和。

        • 借用机制并非为可变性而生,其核心在于内存管理和数据访问范围限制。恰巧该机制同时支持共享借用与独占借用的简洁语法。

          拥有对象默认具有独占所有权,但用Rc/Arc包装后即可实现共享。

          共享可变状态是万恶之源。函数式语言通过禁止变异解决此问题,而Rust可在禁止变异与禁止共享之间切换。未共享的可变对象不会引发意外副作用(至少不会比Rust的共享引用更严重)。

        • > 在函数式语言中传递的是值

          传递值是指“移动”吗?即不传递引用?

          • 是的,用Rust术语来说就是移动。但当代码将值“移动”到另一个过程后,调用后的代码就无法再使用被移动的值。

            因此我希望在移动值的同时仍能使用该值——毕竟目标函数并未修改该值。这本质上类似于复制操作,但无需在内存中创建副本。

            如果Rust能识别出我没有进行任何修改操作,直接允许我使用该值就好了。当然,当存在修改操作时,编译器应该报错,因为这属于不安全操作。

            • 我不确定你描述的情况与传递不可变/共享引用有何不同。

              若调用foo(&value),则foo返回后value在调用作用域内仍可访问。若foo中未修改value,且仅从value推导新值,那么共享引用似乎能满足你的需求?

              Rust强制显式区分值的移交与移除,这是设计决策。Rust选择裸语法value表示移交,&value表示借用。或许你主张默认语法应为共享不可变借用?

              若理解有误还请见谅!

            • 难道不能直接传递值的引用(即&T)吗?若你绝对需要拥有权,调用的函数可返回拥有权值,或使用共享拥有权机制如Rc<T>。在带GC的函数式语言中,你实际获得的是后者 (尽管通常采用引用计数之外的其他GC形式)

              • 我认为可以。但这意味着每个过程定义中所有参数都需添加&前缀,还得处理过程内部的引用操作及其伴随的语法问题。

                • 对此感到困扰可以理解,但没什么好纠结的:这只是存在合理依据的语法细节。

                  语法通常是语言中最不重要/最乏味的部分。

                • 传递 &variable 时,我认为这不会影响被调函数内部的语法,对吧?

                  • 正确。若后续需要重新引用或解引用该引用(这种情况偶尔发生),则需相应地使用&*操作符。但若直接使用原始语法name(无论具体形式),它本身已指向一个引用。

                    • 此外,Rust会隐式进行解引用,因此实际操作中这并非大问题。

        • 所有权还承担另一项关键功能:决定值何时被释放。

          • 既然语言没有垃圾回收机制,当值脱离所有引用它的闭包作用域时,系统无法自动感知——这是否意味着所有权机制是语言设计中必要的复杂性?

            • 确实如此,但机制更微妙。Rust会追踪对象何时脱离作用域,并确保引用它的闭包存活时间短于该对象。这与你设想的逻辑恰恰相反。

            • 借用检查器本质是编译时垃圾回收机制。若从这个角度理解,就能明白它诸多限制的合理性。

        • 这取决于函数式语言的具体实现。它们在逻辑层面上仅是值,但也可作为引用存在,因此存在多种等值比较方式。

          • 从原理上讲这无可厚非,任何类型系统都必须拒绝某些有效的程序。根本不存在既能接受所有有效代码又能拒绝所有无效代码的“百分百完美”类型系统。

            • 我并非质疑原理本身,而是批评其实现方案相较于竞品的表现。Swift做得不够好,它太频繁地选择放弃。

          • 若用20%的时间解决80%的问题,是否值得耗费80%的时间去解决剩余20%的问题?即便值得,是否更应优先交付80%的完整方案让用户使用,同时完善更复杂的版本?

            • 若他人推出100%的解决方案,或其方案避免了你那可能半吊子的“80%方案”存在的问题,你就会陷入困境。

              这里存在微妙差异:关键在于区分两种情况——一种是粗制滥造的80%方案,后期引发问题且难以修复;另一种则是精简的最小子集,虽因达成共识而限制功能,但不存在严重设计缺陷。

              • 确实。况且类型检查器在某些边界案例中报错并要求修改代码以提升友好性,若这种情况在绝大多数开发者实践中罕见,我认为这未必是重大障碍。

      • 读者若能区分效应系统与类型系统将获益良多——错误处理、异步代码、所有权、指针逃逸等概念,都更适宜理解为效应,因其本质涉及值/类型的使用(尽管使用约束可能取决于类型属性)。

        类似地,Java通过主要采用引用类型规避了诸多问题,却衍生出另一类错误。因此C/指针类语言的静态分析与JVM语言存在显著差异。

        Swift在排他性与数据竞争安全性方面大致与Rust持平,所有权机制则正在迎头赶上。

        Rust的Traits与宏实为独特特性,因其支持程序员自定义约束(而非仅限编译器定义),从而使标准库更精简。

        Swift的类型系统整体易用性还有漫长路要走,与Rust相比差距甚远。类型检查器会持续运行直至超时,然后要求用户重构代码才能继续——这种设计在我看来简直荒谬,实在不明白他们怎么能面不改色地发布这样的功能。

        • Swift即便稍作追赶,恐怕也不会严格划分安全与不安全的边界。

      • 我认为带标签的联合体配合穷举式类型检查和杜绝空值,才是确保正确性的两大杀手锏特性

      • 抱歉离题了

        你认为Zig能否成为Rust在这类编程场景中的有力竞争者?

        • Zig致力于成为“更友好的C语言”,兼具易学性与快速编译特性。它拥有诸多精妙设计,在Rust主攻的系统级、性能导向领域确实具备成为“有力竞争者”的潜力。但它并未在安全/程序正确性层面与Rust展开竞争。

          本讨论中提及的Rust特性几乎都不存在于Zig中,例如所有权机制、借用操作、共享/独占访问、生命周期、Trait、RAII(资源获取即初始化)以及静态检查的线程安全机制。

    • 我怀疑如果你将所有静态类型语言混为一谈而不作区分,可能尚未完全理解联合类型(即Rust枚举/总和类型)数据结构结合穷举模式匹配的深层含义。

      我称之为“联合体麻醉”——一旦熟悉这种机制,就很难接受其他静态类型语言了。

      • 更何况Rust的类型系统包含Send和Sync等特性,这些在多数静态类型语言中既不被追踪也不强制执行。

        C语言虽是静态类型,但其类型系统追踪的细节少得多。

        • 我的理解是:C语言没有数据类型,只有内存布局类型。

          • 甚至可以说连这点都不算,因为许多内置类型的内存布局取决于目标平台和/或编译器。

      • 枚举 + 匹配表达式 + 带标签的联合体,正是Rust的秘密武器。

        • 喜欢这个代码片段吗?

              (* Expressions *)
          
              type Exp = 
                    UnMinus of Exp
                  | Plus of Exp * Exp
                  | Minus of Exp * Exp
                  | Times of Exp * Exp
                  | Divides of Exp * Exp
                  | Power of Exp * Exp
                  | Real of float 
                  | Var of string
                  | FunCall of string * Exp
                  | Fix of string * Exp
                  ;;
          
          
              let rec tokenizer s =
                  let (ch, chs) = split s in
                  match ch with
                        ' ' ->    tokenizer chs
                      | '(' ->    LParTk:: (tokenizer chs)
                      | ')' ->    RParTk:: (tokenizer chs)
                      | '+' ->    PlusTk::(tokenizer chs)
                      | '-' ->    MinusTk::(tokenizer chs)
                      | '*' ->    TimesTk::(tokenizer chs)
                      | '^' ->    PowerTk::(tokenizer chs)
                      | '/' ->    DividesTk::(tokenizer chs)
                      | '=' ->    AssignTk::(tokenizer chs)
                      | ch when (ch >= 'A' && ch <= 'Z') ||
                                (ch >= 'a' && ch <= 'z') ->
                                  let (id_str, chs) = get_id_str s
                                  in (Keyword_or_Id id_str)::(tokenizer chs) 
                      | ch when (ch >= '0' && ch <= '9') ->
                                  let (fl_str, chs) = get_float_str s
                                  in (RealTk (float (fl_str)))::(tokenizer chs)
                      | '$' ->    if chs = "" then [] else raise (SyntaxError (""))
                      | _ ->      raise (SyntaxError (SyntErr ()))
                  ;;
          

          提示:这不是Rust。

          • 没错,Rust的秘密在于它同时具备两点:a) 继承了70年代末期某些重要但略显微妙的语言特性——这些特性遗憾地缺失于Algol '52,因而未被主流语言传承;b) 拥有几项炫技功能,尤其能在无意义的微基准测试中超越C语言; b)是吸引人们采用它的原因,而a)则使其编程体验尚可。诚然,人们未曾采用Rust之前的ML家族语言,这确实是对编程文化的严厉谴责,但情况本可能更糟——他们甚至可能连Rust也不采用。

            • >70年代末期那些重要却微妙的语言特性

              直到1989年,编程语言研究者才开始探索线性(或仿射)类型。若没有向量、数据框、字符串等结构必须线性的约束,Rust就无法实现内存安全保证(除非彻底改用垃圾回收运行时)。

              >人们未采纳Rust之前的ML家族语言,这本身就是对编程文化的严厉谴责

              在Rust之前的ML家族语言中,相较于C和Rust等语言,更难推导CPU使用率、内存使用率及内存局部性。其中一个原因是这些语言需要垃圾回收机制。

              总而言之,ML、Haskell等语言未能像Rust那样普及是有充分理由的。

              • > 直到1989年,编程语言研究者才开始探索线性(或仿射)类型。

                确实如此,但正如ModernMech所言,Rust绝大多数优势源于其合类型与模式匹配特性。

                > 在Rust之前的ML家族语言中,相较于C和Rust等语言,更难推导CPU使用率、内存使用率及内存局部性。

                前两者确实略有难度,后者则显著困难。但在Rust实际应用的绝大多数场景中,这些差异都不足为虑。

                • > 没错,但正如ModernMech所言,Rust绝大多数优势源于其和类型与模式匹配机制。

                  存疑。提供这些特性的语言多如牛毛,却从未引发如此热潮。参见 Scala、OCaml、Haskell 等。

                  Rust 拥有独特能力,而其他语言共享多数特性。其受欢迎程度显然源于前者(尽管语言是整体,它无疑是各方面都精心打磨的语言)。

                  • Scala、OCaml和Haskell都从函数式优先的角度切入编程。而Rust开创性地将这些特性融入了设计精良的命令式核心——这是此前任何语言都未曾实现的。

                    虽然这对于Rust的成功是必要的,但我认为还不够充分——它还需要强大的企业支持、热情包容的社区,以及恰逢其时的机遇。

                    Haskell始终定位为面向研究者的学术语言,从未试图突破此范畴;OCaml既缺乏庞大社区也未获企业支持;Scala则始终未确立核心定位——其主要价值在于为Java生态开发者提供函数式编程能力。三者价值主张迥异,故虽具备相似特性,却未能引发如Rust般的市场反响。

                    • Scala本质上是混合了函数式与面向对象的语言。既有倡导完整单子等函数式设计的拥趸,也有追求平衡路线的追随者(参见李浩毅的库)。尽管如此,Scala确实始终围绕着特定领域发展。

                    • 我认为Scala的核心问题在于它仍可能出现空值引发的运行时错误,因此无法像Rust/Haskell/OCaml那样通过模式匹配实现真正的运行时安全保障。例如以下代码在Scala中会触发运行时恐慌:

                      https://scastie.scala-lang.org/fnquHxAcThGn7Z8zistthw

                      这在 Rust 中无法编译。Scala 作为语言尚可,其主要优势在于能编写 JVM 代码而无需使用 Java。

                    • Scala 3 提供编译器选项,可将 null 设为独立类型,从而实现完全空安全。

                      例如这段代码:

                      “` val a: String | Null = someLegacyJavaCodeReturningNullableString()

                      “`

                      显式空值检查只会让非空字符串继续执行。

                    • 这是理论问题而非实际问题。Scala 程序员和库通常不使用 null,且常配备会拒绝此类代码的代码检查工具。(Scala 中可能因 FFI 调用检查不当导致空指针,但 Rust 同样存在此类情况)

                    • 软件工程实践与语言设计在认识到两个重要事实后已显著进步:

                      1. 若某功能在技术上可行,程序员不仅会使用它,还会滥用它。

                      2. 无法通过规范强制执行大规模编程实践。

                      正如兄弟所言,静态分析工具和近期新增的编译器标志(某种程度上承认了这是个问题)都属于被动应对方案,而Rust采取的是主动设计语言机制来彻底杜绝这类问题的做法。

                      > 你未正确检查FFI调用,但Rust中同样存在此类情况)

                      正因如此,Rust中FFI被定义为不安全,空值需主动启用而非默认禁用。设置合理的安全默认值也是优秀软件工程实践的关键经验。

                    • > 1) 若技术上可行,程序员不仅会实施还会滥用。

                      > 2) 无法通过规范强制大规模良好编程实践。

                      并非如此。程序员会选择阻力最小的路径,但不会刻意寻找更糟的方式。unsafemem::transmute虽是Rust组成部分,却未破坏其安全优势,因为程序员已被充分引导远离这些特性。Haskell的unsafePerformIO、Scala的null、OCaml的面向对象特性皆是如此。它们确实存在,但实际应用中并非问题。

                      > 最近新增的编译器标志(某种程度上承认了问题存在)

                      并非如你所想;该编译器标志恰恰表明 Scala 当前未使用 null。此标志通过赋予类型,使 Kotlin 风格的 null 能融入 Scala 惯用表达(坦白说我认为这是个错误)。

                      > 这是与 Rust 截然相反的思路——Rust 通过语言设计彻底禁止此类操作。

                      每种语言都有缺陷,Rust 也不例外。是的,Scala 没有 null 会更好。但这绝非影响语言普及的实际问题(除非通过恐吓宣传,特别是来自 Kotlin 支持者的宣传)。真实的 Scala 代码库中不会出现 null 相关错误(正如真实的 Rust 代码库中不会出现 mem::transmute 相关错误)。试着找个非 FFI null 真正引发问题的案例。

                  • > 许多语言都具备这些特性,却从未获得如此热捧。看看Scala、OCaml、Haskell等语言。

                    它们未被炒作是因为缺乏微基准测试性能这类花哨噱头。但它们提供了Rust的所有实用优势,甚至更多。

            • > 人们未采纳Rust之前的ML家族语言,确实是对编程文化的严厉控诉。但情况本可能更糟——他们甚至可能连Rust也不采纳。

              长期以来,我对行业在语言设计及内存安全等领域的整体发展方向始终持积极评价。多年来我们目睹函数式特性逐步融入主流命令式语言——这或许始于谷歌推动map/reduce成为主流技术。因此我认为我们终究迎来了进步。

              真正令我忧心的是近期的人工智能趋势:让AI编写Python代码后直接采纳其输出结果。这实在算不上进步。

            • C语言的性能优势,是在优化编译器普遍利用未定义行为(UB)的时代才显现的。在8位和16位家用电脑时期,其性能几乎不比手写汇编代码强多少,这正是《汇编语言禅》这类书籍能留下深刻印记的原因。

              若论优化编译器,MLton值得一提——它在确保应用程序不会以奇怪方式崩溃的同时实现了优化。

              问题不在于人们从Rust学习这些特性——我很高兴他们这样做——而在于他们误以为这些特性是Rust首创的。

              • > 问题在于他们误以为这些特性是Rust首创的

                抱歉,我的帖子并非暗示Rust发明了这些特性。我的观点是:Rust作为语言的成功正源于这些特性。

                当然还有其他因素,但Rust真正做对的是融合了函数式与命令式编程风格。“match”语句堪称向命令式程序员传递函数式概念的绝佳方式——它既保留了熟悉的switch语句“感觉”,又具备超能力。因此它堪称绝佳的“入门级工具”——其优势立竿见影(“它在运行时问题爆发前就帮我捕获了边界情况,省去不少麻烦…”)。

                由此你将学会将 match 用作表达式,继而开始质疑为何其他语言的 if 语句不能成为表达式。到那时,你就彻底入坑了。

            • 诚然“无畏并发”是句夸张的宣传语,但在C语言世界里,单线程代码就足以让你“炸掉腿”,更别提多线程了——相比之下Rust简直是天壤之别。这种优势在Unix工具重构案例中尤为明显。

              当然,重写本身往往能带来改进,但其中涉及的并行处理在C语言中可能根本无法实现。

        • 也许我需要再读一遍,但我记得Rust书里说过……你可以像C语言那样使用枚举,但如果改用这种更简洁的方式呢?(匹配成员,无需容器。)好吧,虽然能继续往下看了,但感觉还没真正理解它们的本质。

      • 据我所知它们并非真正的联合类型,而是和集类型,两者有不同含义。

        顺便说一句,我在TypeScript里大量用过联合类型,但并不认为这是好设计。它们确实提供某种编码灵活性,但这种灵活性能否带来良好设计选择?我不知道。

        • TypeScript的联合类型与Rust枚举差异显著,会导致不同的设计决策。我认为Rust式的带标签联合类型是当今任何新语言的必备特性,遗憾的是70年代的开发者未能意识到这一点。

          • 具体会导致哪些差异?我个人认为联合类型更具灵活性,其缺点在于成功/错误结果的语义会有所损失(但我个人并不认为这是问题)。不过除了写些玩具程序外,我对Rust的了解并不算深入。

            在TypeScript中可以自定义Result<T, Error>类型,但在Effect等生态系统之外很少有人这么做,因为通常没有必要。

    • 这与扩展方法有诸多相似之处——两者都能为类型增添新特性。但Traits/实现机制在Rust中植根更深,是支撑_所有_功能的核心。据我所知,扩展方法本质仍是方法,而Rust真正实现了为现有定义对象添加新表达能力的类型。

      最令我震惊(绝非显而易见)的是Rust的借用检查总被过度关注。其实其类型系统才是真正颠覆传统的创新——这种开放式、延迟定义的机制彻底打破了“预先定义一切”的类型系统教条。这种持续为事物注入类型特性、不断拓展其功能边界的机制,堪称Rust的超级能力。这种颠覆性设计,在我看来正是 Rust 持续成功的核心优势之一:开发者无需预先规划程序的完整类型系统,可随需为现有类型分层添加类型,这种动态适配的特性,使其成为比我们苦苦忍受数十年的高度约束静态类型系统更具活力的存在。重大突破:静态系统却仍具动态特性(在编译时)。

      • 这在Standard ML中通过函子实现。

      • 是否有深入探讨此主题的优质文章或博客?听起来非常有趣

      • 这不就是[1]类型类吗?它们并非新概念,简直是祖宗级设计!
        [1]无意贬低设计者,能在整合所有特性时做到精准平衡实属壮举!

    • > 静态类型化因此可编译

      静态类型化并不等同于编译型。例如静态类型语言同样可以解释执行。而并非所有编译型语言都完全静态。

      比如C语言是静态类型化的,却能玩指针类型转换的把戏。那么编译器究竟能保证多少安全性?它无法保证,而我们已目睹C语言催生出脆弱的产物。

      Rust不仅是静态类型语言,更对类型操作设定了多重限制。在Rust中无法随意进行指针类型转换,此类操作会被编译器直接拒绝。因此Rust代码必须满足比多数自称“静态”语言更高的“静态”标准。

      类型转换只是 Rust 实现这一目标的方式之一,其他方式也已被提及。这些机制共同作用,使得 Rust 程序更安全可靠。

      • > 在 Rust 中不能随意将指针类型强转换,编译器会直接拒绝此类操作

        你无法_安全地_自行实现这种转换。也就是说,你无法编写能对任意两个对象执行此操作的_安全_Rust代码。但 Rust 确实会进行此类转换——实际上相当频繁——因为只要操作得当,它完全是安全的。

        那个著名的《雷神之锤3竞技场》“快速反平方根”算法,涉及类型转换技巧?你完全可以用安全的Rust实现它,效果照样完美。当然不建议这么做——在任何稍显现代的硬件上,CPU本身就能更快完成这项运算——但若你执意要写,实现起来轻而易举,只是速度稍慢。

        为何可行?因为在实际运行Rust的硬件上,32位整数类型与32位浮点类型具有完全相同的位宽(这还用说)、相同的位序等等特性。CPU根本不在乎这个32位对齐的32位值“究竟是”整数还是浮点数,因此将f32转换为u32或u32转换为f32不会消耗任何CPU指令——这点和看似复杂的C语言实现完全相同。所以Rust标准库只需_承诺这种转换是安全的_,而在所有支持的Rust平台上确实如此。若某天采用某款呻吟的1980年代CPU导致无法工作,他们就得为该平台编写定制代码——但约翰·卡马克在相同条件下也得这么做。

        • > 因为只要我们小心谨慎,这完全是安全的。

          Rust的核心理念在于:整体而言,不可能人人都谨慎行事,因此默认允许所有人操作是完全_不安全的_。

          当然Rust允许执行不安全操作,但将这类操作限制在“成人专区”的隔间里,反而能提升所有人的代码质量。事实证明,若在接触危险武器前设置障碍,不该使用的人便不会触碰,整体代码质量自然提升。

      • > 那么编译器究竟能保证多少安全性?

        编译器能确保自身零错误。真正失去保障的是程序员。

    • > 这些优势不都归功于使用静态类型编译语言吗?

      静态类型语言未必需要编译…但确实如此。

      > 无论是Java、Go还是C++;

      哈哈!不。所有静态类型系统并不相同。

      你列举的语言中,只有TypeScript能带来相同优势。但整个系统因必须兼容无数JS怪胎而彻底崩坏。

      > 它更难学习,甚至可以说更难阅读

      正确学习它其实更容易,但靠试错法硬塞代码直到看似运行则更难。诚然,靠感觉硬塞代码直到看似运行是初学编程的重要环节,所以没错,别选Rust当入门语言。这些特性在Java等语言中缺失,自然无法防范此类缺陷

    • > 这些优势不都归结于使用静态类型编译语言吗?无论是Java、Go还是C++;TypeScript稍复杂些,因其编译为JavaScript并继承部分问题,但总体尚可。

      是的。这些现代编译型语言的类型系统远比JavaScript和TypeScript能提供的任何方案更严谨。

      任何使用完全弱类型系统和动态类型系统的开发者都会遭遇无数难题——这正是他们青睐Rust这类设计精良的强类型语言的原因。

    • 并非所有静态类型系统都具备同等的表达力/安全性/一致性——Java频繁退化为Object并依赖运行时强制转换,Go语言缺乏枚举类型,而C++的变体机制因是后期拼凑的非原生语言特性,存在显著的安全隐患和操作体验问题(例如安全访问需使用try/except,这与其他控制结构存在排他性)。

      • > Java 频繁退化为 Object 并依赖运行时强制转换

        真的频繁吗?泛型机制固然不够完美,但令人惊讶的是它对几乎所有库都足够“适用”,例如像 JOOQ 这样完整的类型安全 SQL 领域特定语言。不安全的强制类型转换极为罕见,而需要使用Object等类型的情境往往涉及高度动态的场景——这类场景即使在理论上也无法扩展编译时保证(例如运行时代码生成、动态代码加载等,人们常忽略这些用例,并非所有场景都能在封闭环境中运行)。

        • 不过jOOQ的内部实现确实充斥着不安全的强制类型转换。

    • 没错,这四种语言都存在动态语言所没有的检查机制,但它们之间的差异足以构成实质性区别。骑自行车和开车都比步行快得多,但若仅将其视为“使用轮子带来的便利”,就忽略了相当重要的细节。

    • 是的。Rust 真正增添的是更优越的类型系统。静态类型系统的优劣取决于其设计(这正是 TypeScript 依然糟糕的原因)。

      并发/安全性/内存管理方面的优势仅在极少数特殊场景成立,我希望人们不要试图以此作为推销 Rust 的卖点。

    • Go、Java或C++都无法捕获这种并发错误。

      • C#会在编译时捕获该错误,与Rust机制相同。

        https://www.rocksolidknowledge.com/articles/locking-asyncawa

        • 我不懂C#,但看起来他们只是为锁机制添加了特定检查,远不如Rust的Send/Sync机制强大。

        • 这篇文章简直像是直接回应原文,炫耀C#十多年来领先多少。

          • C#展示的本质是Monitor模式,上世纪Java也有类似实现。

            Rust的互斥锁是拥有型互斥锁,这是另一项特性——其优势在于必须获取锁才能访问受保护数据,从而避免因部分代码忘记解锁而引发的同步问题。在C#中这类错误可能无法被检测到,或仅触发运行时异常,无法保证安全性。

            但更关键的是——这也是其他语言虽能实现拥有型互斥却往往不采用的原因——Rust的借用检查机制能让编译器发现错误:当你归还锁却保留对受保护数据的访问权限时。这实现了双重保护:既防止忘记获取锁,也阻止在未归还访问权限时释放锁。

            监视器仅能部分防范后者(且效果有限),多数语言的拥有型互斥锁能防范前者,而Rust则能同时防范两者。

      • > Go、Java或C++都无法捕获这种并发错误。

        此说法有误。Java强制要求监视器锁(或Lock)必须由获取它的线程释放。若尝试从其他线程解锁,将抛出IllegalMonitorStateException异常。

        • 这是运行时而非编译时的机制,这正是线程的意义所在。

    • > 这些优势不都归结于使用静态类型编译语言吗?无论是Java、Go还是C++;TypeScript更复杂些,因其编译为JavaScript并继承了部分问题,但总体尚可。

      并非如此。类型系统必须具备基础功能,尤其是总和类型——令人惊讶的是许多语言至今仍缺乏此特性。

      (需注意静态类型化并非编译的必要条件,反之亦然)

      > 我知道Rust因更严格的类型系统提供了额外的编译时检查,但这并非免费获得——它更难学习且可读性存疑

      若从ML家族语言起步,它们通常更易学习和阅读。这只是熟悉度的差异。

    • 该具体示例在C++或Java中不会被捕获。

      • 有趣的是重定向问题在Java中会被捕获。由于Java不支持属性,该操作必须通过方法调用实现,此时作者的错误假设便不再成立。

    • Rust的生产力远超所有列举语言。

      • 惊人论断至少需要…某种证据支撑。

        关于编程语言生产力的对比研究极少,因为这类研究很难做到严谨。

    • 我认为Rust简洁语法的大部分精髓,源于其为错误处理添加的糖衣语法。

    • 你第一段提出的问题,第二段就自己解答了。

    • 没错,但更重要的是——用Rust编写能编译通过的程序,绝对能让你登上HN首页。

  5. 虽然并非唯一优势,但在重构代码时我最享受Rust和F#的类型系统。“无畏重构”正是此处的最佳诠释。

    • 唯一的问题在于Rust对半成品代码的排斥性——重构期间不允许存在“部分可运行代码”:要么完成要么放弃,绝不容忍代码库处于不一致状态。这对探索性代码尤其令人困扰。

      但另一方面,正是这种严苛性促使人们最终获得普遍合理的代码。

      • 我发现适度使用 todo!() 对此效果极佳。

            match foo {
              (3...=5, x, BLABLABLA) => easy(x),
              _ => todo!(“我应该为非平凡情况实现这个功能”),
            }
        
        match foo {
          (3...=5, x, BLABLABLA) => easy(x),
          _ => todo!(“我应该为非平凡情况实现这个功能”),
        }
        
        
        todo!() 的妙处在于它能通过类型检查——虽然类型匹配永远是平凡的,但这意味着代码能编译通过,只要我们不触发非平凡情况,它甚至能在运行时生效。
        
        
        • 问题在于,我需要一个适用于类型系统的`todo!()`等效方案。一种即使存在编译错误仍可运行测试的运行模式。例如,当你有

          fn foo() -> impl Display {
              NotDisplay::new()
          }
          
          
          而测试用例引用`foo`时,该函数会在测试过程中被替换为:
          
          fn foo() -> impl Display {
              panic!(“`NotDisplay` 没有实现 `Display` 接口”)
          }
          
          
          这不应成为_语言本身_的特性,而应属于_工具链_的功能。
          
          借用检查器同样需要类似机制。
          
          
          • 这类似于Haskell生态系统中的'类型孔洞'[1]。

            [1]: [https://downloads.haskell.org/~ghc/7.10.3-rc1/users_guide/ty…](https://downloads.haskell.org/~ghc/7.10.3-rc1/users_guide/typed-holes.html)

          • 那么或许可以设计一个过程宏,让开发者能使用 #[dummy(Clone,Eq,PartialEq)] 替代 #[derive(Clone,Eq,PartialEq)] ?

            虽然你提到“运行模式”的概念我无法认同,但我认为默认在发布构建中为整数类型包裹溢出处理的做法很可能是错误的。虽然能手动关闭该功能是好事,但它本就不该成为默认选项。

          • 比起在`foo()`开头添加`return todo!();`,这种方案有什么优势?虽然需要手动标记待处理代码,但至少不必完成整个重构。

            • 我提出的并非具体功能,而是通用策略:凡编译器能以不影响应用程序运行方式恢复的错误,都应自动修复。以借用检查为例。若能自动将 &T 转换为 Arc<MUTEX<T>>,运行测试时不仅能指出借用检查器的错误,还能定位到借用检查器保护你免受影响的具体运行时场景(若有测试触发该场景)。

              这些策略需要逐个传授给开发者。我并非认为现状糟糕,只是认为存在改进空间。

              • > 我提出的并非具体功能,而是通用策略:凡编译器能以不影响应用程序运行方式恢复的错误,都应予以恢复。

                我认为这在多种场景下都很有用,尤其适用于测试套件。虽然我不建议尝试猜测如何用`Arc`/`Mutex`/`RwLock`替代失败的借用操作,但确实存在几种相对安全的替代策略。

                除自动生成 todo!() 的方案外,还可采用编译时污染机制:将编译失败的项树分支标记为污染项。当某项编译失败时,将其转化为引用时会导致引用项同样编译失败的污染项。如此便能确保测试套件中所有成功编译的项都能被测试。

                > 添加 `return todo!()` 在某些情况下效果尚可,但并非万能,因为它无法验证实现Trait的返回类型。

          • 直接在 foo() 中添加 panic 不行吗?它返回的是 !

      • 这是权衡取舍,让我想起 Go 语言对未使用变量强制编译失败的机制;这种严格性是设计特性,本质上能杜绝草率/半成品代码。

        我个人认为Rust是理想的“第二系统”语言,即先用容错性更强的语言解决业务场景,待方案验证后再将(部分)代码迁移至Rust以获取额外性能/可靠性。

      • Fsharp的类型推断在这方面很出色。与Rust不同,它甚至能推断函数类型。我认为Fsharp适合探索性编程,而其类型推断机制正是关键推手。

  6. Zig的示例让我震惊。

    它简直脆弱不堪。居然有人觉得这是好主意?

    • 我_推测_这是个缺陷。但由于这是作者语言,若想修复该缺陷,关键在于确保作者_同样_认为这是缺陷。若他们认定此设计本应如此,便会固执己见——无论多少人感到困扰,他们都将坚持到底。

      • 老实说,若担心此问题,你本该使用ErrorTypeName.ErrorKind进行相等性检查,而非error.ErrorKind。

        这本质上是便利性与安全性的权衡。编译器目前可能尚不成熟到能捕捉这类错误,但终将实现。

        在我看来,Zig作为作者语言具有显著优势——例如其创新的IO设计就极为出色,若非Andrew Kelley身居要职,这类突破恐怕难以实现。

        过去几年我一直在用Rust编写存储引擎,但它的异步和I/O系统存在诸多性能缺陷。整个生态系统似乎本质上是为编写Web服务器设计的。

        另一个例子是文件格式库和计算库滥用asyncio Traits,强制所有操作都采用send+sync+'static模式,这使得在单线程环境下配合本地分配器几乎无法使用。

        我并非否定Rust的价值,其生态系统极具生产力。但Zig能深入底层实现更多功能实属可贵——这得益于开发者能果断决策:“我将重构整个IO API以提升性能”。

        • > 若你对此有所顾虑

          显然没人意识到自己犯了这个错误,正因如此编译器拒绝错误并提醒用户才至关重要。

          我不愿使用作者语言,事实是Andrew在某些方面确实有误——人人皆然,但正因这是Andrew的语言,在Zig中讨论就此终结。

          我喜欢Rust的`break ‘label value’`。虽然它很少是正确选择,但偶尔——只是偶尔——它恰好满足需求,而缺少它会令人抓狂。不过据我所知,曾有段时间多位Rust核心开发者极度厌恶这个特性,导致它被永久阻挡在稳定版之外。若Rust是作者语言,一人之见便足以让特性永世不得天日。

      • 了解 Zig 开发者的运作方式后,可以断言这绝对不是 bug,完全符合你描述的情况。

      • Andrew 实际上认同这种普遍观点。该问题将被改为编译错误。

        问题在于编译器虽知两个错误源于不相交的错误集,却将它们都提升为任意错误

        详情见[https://github.com/ziglang/zig/issues/25046](https://github.com/ziglang/zig/issues/25046)

      • 读到这段我笑得合不拢嘴 :),说得太好了

        (为避免误解说明:这并非完全是坏事,但人们总爱抨击“委员会式设计”,适度反其道而行之反而有益)

      • 你凭什么这么想?这是非常刻意设计的决策。

    • 每种语言都存在导致代码脆弱的争议性设计决策,只是程度不同。

      比如,要求用户必须始终显式编写`mutex.unlock()`或`defer mutex.unlock()`,而非默认允许可选显式解锁并在作用域结束时自动解锁——这种设计怎么可能被认为是好主意?Go和Zig都存在这个缺陷。又或者,谁会认为在广泛类型推断的环境下,允许任何数值类型隐式转换为其他类型的强制转换是个好主意?就像Rust那个糟糕的`as`运算符?(我曾为此耗费整整一天调试相关漏洞。)

      • 你说得对,但这不代表我们不能抱怨 🙂

        顺带一提,我讨厌Rust的`as`转换。它脆弱又危险,甚至不像语言的组成部分。简直像JavaScript开发者偷偷加进去的,没人察觉。希望后续版本能移除它。

        • 尽管我厌恶'as'并尽量避免使用,但它确实解决了若干无法替代的场景(例如整数与浮点数的转换就离不开它)。有时能直接表达“不管后果如何强制转换类型”的需求确实很有用。

          另一个令人头疼的问题是:当我需要在usize与u32/u64之间转换时,明明知道多数情况会是u64,偶尔可能是u32,却必须遵守usize=u128或usize=u16的约束。求求你们给我个方法,让我能声明u32在我的代码中就是Into<usize>!

        • 顺带一提,我讨厌Rust里的`as`转换。它脆弱又危险,甚至不像语言的组成部分。简直像JavaScript开发者偷偷加进去的,没人察觉。希望后续版本能移除它。

          Rust语言开发者视角:我也这么希望。我们非常想这么做,等替换完它的各种用例后。

          • 作为Rust用户,我希望“as”永远保留。它实在太过实用,比如刻意截断数字位数。虽然有其他实现方式,但都比不上“as”便捷。最多我能接受将`as`限制在unsafe代码块内,但这并非完美解决方案——unsafe本不该用于此类场景。

            • 我期待为所有具体用例提供更符合人体工学的替代方案。`as`的简洁性正是我们至今未移除它的主要原因。

              我们已有 `.into()` 用于无损转换(如 u32 转 u64)。

              必须解决 `usize` 无法参与无损转换的问题(例如即使在 64 位系统上,也无法通过 `.into()` 将 `usize` 转换为 `u64`)。

              我们需要解决无法通过 `.into::<u64>()` 明确类型的问题。

              我希望我们能添加 `.trunc()` 用于有损转换。

              最终,在为所有这些及其他情况提供替代方案后,我们讨论过将 `as` 改为 `.into()` 的简写形式。

              • 像`.trunc()`这样的方法调用,其操作体验仍远逊于`as`。它依赖类型推断或turbofish来选择类型,且额外增加了函数调用的语法噪音。

                更不用说这种本应≤1条指令的操作被拆解为大量微调用,将显著增加调试成本和/或编译时间(尽管这本身就该被修复)。

                • > 像 `.trunc()` 这样的方法调用,其操作体验仍将远逊于 `as`。它依赖类型推断或turbo鱼机制选择类型,还额外增加了函数调用的语法噪音。

                  若将`as`重新定位为安全转换(如u32转u64),那么让高风险转换略显冗余倒也有其合理性。我完全赞同避免不必要的冗余,但即便在我的高转换频率代码中(需要频繁转换usize和u64),只要不必写`.try_into()?`之类的表达式,我完全接受到处写`.into()`或`.trunc()`。

                  > 更不用说这种本应≤1条指令的操作却衍生出大量微调用,这会损害调试效率和/或编译速度(尽管这本身就该修复)。

                  我完全预期这类方法会被内联,甚至在调试模式下也会如此(例如`#[inline(always)]`),最终编译为相同的最小指令集。

                  • > 我完全可以接受编写`.into()`或`.trunc()`

                    是的,这正是我持异议之处。

                    > 我完全预期这类方法会被内联处理,甚至在调试模式下(例如`#[inline(always)]`),最终编译为相同的最小指令集。

                    这就是我提到的编译时开销。

                • 编译器无需将调用实现为字面调用;通过代码生成器对“魔法函数”调用进行特殊处理,是历史悠久的传统做法。

              • pub trait To {<BR /> fn to<T>(self) -> T where Self: Into<T> {<BR /> <SELF as Into<T>>::into(self)<BR /> }<BR /> }
                现在只要该Trait在作用域内,你只需调用 `.to::<U64>()` 即可实现与 Into 完全相同的功能。若你更倾向于添加微型依赖而非复制粘贴代码,我已发布提供此功能的 crate:[https://crates.io/crates/to_method](https://crates.io/crates/to_method)

              • > 我们需要解决无法通过 `.into::<u64>()` 明确类型的问题。

                这点最初也让我困惑。必须使用`u64::from(_)`对吧?某种程度上这很合理,类似于必须使用`Vec::<u64>::new()`而非`Vec::new::<u64>()`,但对`into`操作来说确实更麻烦。

                • 没错,你需要使用`u64::from`,或者写成`let x: u64 = expr.into()`之类的写法。

                  这确实“说得通”,但实在令人反感,我们应该有更好的方案。

                  • 要是表达式能支持类型赋值就好了… _躲闪_

                  • 不会的,官方承诺不会淘汰旧版。新版本/前端每三年发布一次,所以Rust(甚至人类文明)很可能在版本数量达到数百之前就彻底消亡了。

                  • 补充sunshowers的观点:由于版本仅影响编译器的早期部分,大部分代码与版本无关,这使得维护变得简单。

              • 最终,在为所有这些及其他情况提供替代方案后,我们讨论过将 `as` 改为 `.into()` 的简写形式。当然,`SomeExplicitType::from`也能实现,但有时会破坏代码流畅性。

                对于这类常见场景,仅需`val as SomeExplicitType`或许更理想。虽然担心会显得过于魔幻…但我对语言团队的方案充满期待。

          • 语法不断变化的语言,用户该如何使用?

            • Rust的版本系统允许语言前端每隔几年更新一次。

              • 特别需要注意的是,新版Rust能同时编译新旧版本的代码。升级Rust并不强制要求迁移到新版本。且单个项目可同时引入多版本的crates库。

        • 作为频繁使用位域和枚举转整数的人,除非从我冰冷的尸体上夺走,否则别想拿走我的“as“。

      • 我渴望一种语言,其调用栈深度永远不超过二到三层。

        主函数内部可调用子函数,但这些子函数不得再进行任何调用(仅限于函数内部定义的扁平辅助函数)。

        我认为让所有程序都保持扁平化结构能节省大量时间。开发者自然会倾向于采用扁平化机制。

    • 第一眼根本没发现错误,直到读到你的评论才注意到。

      • 我先看到这条评论再读Zig的留言,仍没发现错误,直到看到下面的解释。这绝对是程序漏洞。

      • 没错,我读文章时不得不反复确认,因为看代码时完全没注意到这个错误。

    • 难道静态分析工具不能检测到你引用了系统中不存在的错误,并建议用switch替代if吗?

    • 我原以为错误集是基于函数签名生成的。真厉害。

  7. 我完全赞同。

    Rust对我来说合理得多。编译器给出合理的错误提示,代码结构清晰,每个元素的位置都一目了然。

    而TypeScript我完全无法忍受。它那种不确定性简直令人窒息,很难对代码产生信心。

  8. >代码完全能编译通过。Zig编译器会为每个唯一的'error.*'生成新编号。

    这太疯狂了。至少应该有工具能捕获这类错误吧?

    • 没有专门工具捕获这种错误,因为没人会用if-else处理错误——这根本不符合惯例。捕获错误时所有人都使用switch语句,我从未见过有人用其他方式处理。

    • 这是编译器缺陷,该代码本不应通过编译(参见issue #25046)

    • 这个示例有点可疑。当然它能编译通过,因为作者在Zig中并未正确使用错误处理机制。这里他使用全局错误集配合`error.AccessDenid`,正如所述能编译通过,因为当访问全局错误集时,其内部存储的都是整数。

      若作者写成`FileError.AccessDenid`,则无法编译,因为此时会与`FileError`错误集进行比较。

      全局错误集几乎从未被使用,除非需要允许用户自定义错误,此时可让方法返回`anyerror`。

      • 你说“从不”,但连Zig标准库偶尔也会这么做。

        比如在`std/tar.zig`中:[https://github.com/ziglang/zig/blob/50edad37ba745502174e49af…](https://github.com/ziglang/zig/blob/50edad37ba745502174e49af922b179b1efdd99c/lib/std/tar.zig#L642)

        或在此处的 `main.zig` 中:[https://github.com/ziglang/zig/blob/50edad37ba745502174e49af…](https://github.com/ziglang/zig/blob/50edad37ba745502174e49af922b179b1efdd99c/src/main.zig#L7631)

        以及其他许多地方:[https://github.com/search?q=repo%3Aziglang%2Fzig+%22%3D%3D+e…](https://github.com/search?q=repo%3Aziglang%2Fzig+%22%3D%3D+error.%22&type=code&p=1)

      • 所有此类示例归根结底都是“用户操作失误”,但这恰恰是关键所在。在Rust中根本不可能发生这种错误。

        • 我并非说Zig的安全性与Rust同级,只是想说明:用刀刃抓刀并非使用勺子的理由。

          这个示例中的错误任何Zig开发者都不会写。老天,在看到这个示例前,我甚至不知道能直接比较全局错误集——而我可是维护着一个小型库呢。

          Zig和Rust的适用范围不同。老实说我不认为它们该被比较。Zig更适合与C对比,Rust则更适合与C++对比。

          • Rust和Zig是当前最突出的两种新型系统编程语言。人们自然会从“启动项目需选择语言”等角度进行比较。

            这两种语言在范围、规模和设计目标上确实存在显著差异。这意味着它们各有取舍,可能使其中一种语言更适合特定人员或项目,因此探讨这些取舍关系既有趣又值得。

            尤其值得注意的是,Rust的首要目标是程序正确性——该语言竭力避免用户编写“你拿错了”类型的错误代码,而Zig则更倾向于选择简单明了的设计。这种设计目标的差异正是本文的核心论点,而非否定文章的理由。

            • 当然。我是否否定了这篇文章?还是说我只是指出例子不好?

              • 我是在回应“Zig和Rust不属于同一范畴,我真心认为不该拿它们比较”的论断。

                我对Zig了解有限,无法对具体示例给出专业评价(除了惊讶它居然能编译通过)。不过前几日首页这篇帖子提供了更实用且深思熟虑的同类示例:[https://www.openmymind.net/Im-Too-Dumb-For-Zigs-New-IO-Inter…](https://www.openmymind.net/Im-Too-Dumb-For-Zigs-New-IO-Interface/)

          • > 本例中的错误代码绝不会出自任何Zig开发者之手。

            这并非“真正的苏格兰人谬误”。它确实出自那位Zig开发者之手。

            • 所谓“真正的苏格兰人”谬误,仅在提出反例后改变定义时才构成谬误。但它也可能表现为坚持非标准定义。

              例如:“真正的苏格兰人绝不会讨厌哈吉斯!”“你错了,我的朋友安格斯就讨厌哈吉斯,而他可是地地道道的苏格兰人。” “那他若讨厌羊杂,就不是真正的苏格兰人!”

              首发言者并未改变定义,故不构成谬误。他只是坚持自己对“真正苏格兰人”的独特标准,并断言安格斯不符合其标准。

              关于“没有真正的苏格兰人”的讨论到此为止。现在请各位回归常规的代码争论。:-)

              • > 只有在反例出现后才改变定义时才构成谬误

                此说不成立。

                编辑:关键在于我们确实存在反例——某段由Zig开发者编写的代码,而该开发者实际上并非Zig开发者。反例的来源、提出者及时间点,与该论点是否构成谬误无关。维基百科条目过度强调了事件顺序,但这从来不是谬误的本质问题。数以千计的案例表明:当有人声称“不遵循基督教义者非基督徒”时,这种论断会被指为“假苏格兰人谬误”——其本质是为否认某基督徒身份而隐含地重新定义“基督徒”概念,从而在确凿反证面前维护基督教某种美德的主张。

                • 那么你或许该更新维基百科条目了。该条目宣称此谬误“指针对反例修改原论点时,声称反例被定义所排除”,并援引三处链接佐证:[https://iep.utm.edu/fallacy/](https://iep.utm.edu/fallacy/), [http://www.fallacyfiles.org/scotsman.html](http://www.fallacyfiles.org/scotsman.html),以及[https://archive.org/details/godphilosophy0000flew/page/104/m…](https://archive.org/details/godphilosophy0000flew/page/104/mode/2up)。

                  “真正的苏格兰人不会讨厌哈吉斯”的表述实质上是断言“若A则B”:若你是真正的苏格兰人,则你会喜欢哈吉斯。回应“安格斯不喜欢羊杂”是在断言“非B”。而“因此他不是真正的苏格兰人”的反驳则断言“非A”。但“若A则B”在逻辑上蕴含“若非B则非A”。因此当定义未改变时,这并非谬误。它可能_错误_——他定义的“真正的”苏格兰人可能是错误前提——但结论逻辑上从前提推导而来,故不构成谬误。

                • 回应你的编辑:他在_示例_中写下这段并不意味着他会在实际_代码_中这样写。示例往往是人为构造的。你回复的对象显然意指“没有Zig开发者会在实际代码中这样写”,这种观点或许有误,但这个反例并非实际代码中的情况。

                  至于你编辑的第二部分,关于群体成员定义的争论常被误解为“真正的苏格兰人谬误”。例如,自称苏格兰人并不必然使你成为苏格兰人:若你没有苏格兰血统、不居住在苏格兰、不持有苏格兰护照,即便你声称自己是苏格兰人,也很少有人认同。人们会坚持要求某种超越“我自称是苏格兰人,因此我显然是苏格兰人”的标准。再以基督徒为例:全球数百万信徒会宣称“真正的基督徒绝不会否认基督的神性——这是基督教的基本教义”。尽管确实存在自称基督徒却否认基督神性的人,但这并非“真正的苏格兰人”谬误。真正的谬误在于:“你自称苏格兰人,却未达到普遍认可的苏格兰人标准”。

                  反观你举的例子——“他未践行基督教义,故非真正基督徒”——恰是“非真正苏格兰人”谬误,企图重新定义普遍认可的标准。因为几乎每个基督徒都明白(我本人也是基督徒),我们永远无法完全践行基督的教诲,总有进步空间。所以说“他不达标所以不是真正的基督徒”,本质上是在重新定义标准,这种定义几乎会把所有人排除在外——而这完全背离了基督教的本意。

                  简短版本:某些群体的成员资格确实可能存在标准(如果你从未写过一行Zig代码,你就不能算Zig开发者),而主张这些标准未必构成谬误,即使这些标准遭到某些人的质疑。(例如某些自称基督教团体却否认基督神性的情况——任何非该团体成员都会认为,承认基督神性是成为真正基督徒的基本标准。)

                  *编辑说明:* 上文最后一句写成“任何非该团体成员”,若要完全准确应表述为“任何非该团体的基督徒”。因为许多非基督徒确实会否认基督神性是定义组成部分,但几乎所有承认基督确为道成肉身的基督徒都认同该教义是基本要求,否认者便不能诚实地自称基督徒。(希望此表述既准确又清晰。)

          • 将这些例子比作“抓刀刃”,无异于把看似正确的代码比作徒手抓起锋利锯齿物体——明知会受伤却仍执意握持。

            更贴切的比喻是拿起叉子时,发现它灼热异常却毫无视觉差异。

      • 为何编译器会将FileError和全局错误映射为整数?若它们是独立类型,该if语句根本无法编译通过。

        • `FileError.AccessDenied`是独立错误集中的唯一值。而`error.AccessDenid`从未被定义,因此编译器在某个环节直接赋予其整数值。

          如我先前所述,这种错误在任何代码库中都不应存在:请注意失败方法返回的是`FileError`而非`anyerror`。

          尽管如此,仍有人会正确指出它本就不该通过编译。

  9. Rust 检测到锁被跨 await 边界持有,但若解决方案是在 await 前释放锁,我认为在缺乏上下文的情况下仍可能存在并发问题。

    该锁的初衷应是阻塞直至提交创建完成,而这仅在 await 之后才能保证。若在向数据库提交事务后、但未获得成功确认前释放锁,很可能引发更多边界情况。我不熟悉Rust的async机制,是否应使用join/select进行阻塞,之后再释放锁?通常仅在跨 await 点持有锁时才需要此特性,因为这类锁比阻塞互斥锁开销更大(另一种使用场景是预期锁持有时间较长,此时异步感知锁能让其他任务在等待锁期间继续执行)。

  10. > 为'window.location.href'赋值并不会立即跳转,这与我的预期不符。

    这并非“TypeScript”或语言问题,而是DOM/浏览器API的特殊性

    • 有人可能会说 DOM API 的怪异部分源于其设计时 TypeScript 尚未存在,因此 API 并未考虑 TypeScript 的特性。若 DOM API 是基于 Rust 编写的,其设计本可依托 Rust 的类型系统来避免此类错误。

    • 天啊,属性赋值为什么要有副作用?这太恶心了!

      • 我认为对于足够复杂的对象或变量,这是不可避免的。想想C++的unique_ptr,它保证内存仅存在单一引用,从而确保作用域结束时能安全释放内存。或者考虑一个包含内存引用复杂对象。要么分配大量内存并复制值,要么让两个对象共享同一块内存。两种方案都存在重大缺陷。其实对象不必多么特殊,想想存储1024个fp64值的向量,或是8KB长的字符串。

        • 天哪,共享内存块简直违反了德墨特尔法则!这种情况真常见吗?或许是我被面向对象思维洗脑太深,实在难以理解其合理性。

    • *不过如果TypeScript开发者重视这个问题,本可以修复的。

      • TypeScript怎么可能修复这个问题?

        • 他们本可以在这个基础上提供独立的API,并在使用时抛出错误?这显然是个易出错的API。

          • 我真心不明白——TypeScript怎么可能在此基础上提供独立API?我通常不指望TypeScript开始为我生成代码和API。

            况且TypeScript是在JavaScript语言上添加类型系统,而非DOM API。

          • 不应该这么做。浏览器API实现并不存在于TypeScript中,它属于浏览器本身。TypeScript不该干预浏览器的行为逻辑。

            想想实际发生的情况:当你以浏览器为目标构建时,TSC本质上是将TypeScript代码转译为JavaScript。TypeScript提供的浏览器API只是一堆类型定义。转译过程中它唯一能做的是检查类型匹配。

  11. 首个救赎源于Rust自身的缺陷:异步Rust。这个糟糕的概念堪称自掘坟墓,仅靠类型系统的其他部分勉强使其尚可忍受。

    • (但它仍是异步编程领域最强大的语言之一)

      • 或许吧。我认为异步概念本身存在缺陷,但若非要使用,GC能使其更符合人体工学。

        • 我认为异步最实用的场景是嵌入式领域——Embassy框架表现惊艳,而该领域无法使用GC。Rust的异步特性似乎更适合嵌入式场景,而非多数人使用的Web应用。

          不过其设计确实可以更人性化。Pin机制极其混乱,陷阱设计也令人失望——例如循环/选择语句极易出错,这种情况非常普遍。

  12. 这正是我偏爱强大可靠的静态类型系统及其创新特性的原因。

    在处理超过百万行代码的Haskell大型代码库时,我也经历过类似情况。正因为类型系统的存在,大规模重构变得轻而易举。

    而且我们甚至没用什么花哨的线性类型,只是在核心代码和关键部分撒了点依赖类型的传统Haskell。

  13. 我建议大家至少停止用Python写代码。

    • 那些建议他人放弃使用全球最佳文档语言的人——它拥有庞大的库生态、友好的用户群体、简洁的语法、卓越的参考文档、直观的函数命名、可读的异常回溯,以及出色的编辑器和AI支持。

      • 诸如__new__()和__init__()这类直观函数名?还是id()和pickle.dumps()?

        Python的易用性被过度美化了。它和其他语言一样存在缺陷和问题。缺乏静态类型更是真实的障碍(是的我知道mypy的存在)。

        • > 像 __new__() 和 __init__() 这样直观的函数名?还是 id() 和 pickle.dumps()?

          我用 Python 写些基础脚本,从不编写大型程序。这些函数大多能实现我预期的功能。

          > __new__ 是静态方法,负责创建并返回类的新实例(对象)。它以类作为第一个参数,随后接受其他参数。

          > 在 Python 中,__init__ 是实例方法,用于初始化新创建的实例(对象)。它以对象作为第一个参数,随后接受其他参数

          > Python的id()函数返回对象的“标识”。该标识为整数值,在对象生命周期内保证唯一且恒定不变。

          pickle.dumps()是唯一稍显特殊的方法,需了解pickle模块功能才能理解其作用。

          > Python的可访问性被高估了。

          可访问性并未被高估。Python拥有许多语言所欠缺却鲜少被提及的特性——它在快速应用开发(RAD)领域表现卓越。既能快速构建出功能完善的原型,又具备足够的语言特性支撑大型项目开发。

          > 它和其他语言一样存在缺陷与问题。

          和其他语言并无二致。

          • 仅凭名称无法理解new和init的区别,pickle亦是如此。从定义上讲,这使得它们缺乏直观性。

            包括Clojure、C#和JavaScript在内的许多语言都适用于RAD,Python并无特殊之处。

            • >仅凭名称无法理解new和init的区别,pickle亦然。从定义上讲,这使得它们缺乏直观性。

              按此标准,没有任何东西是直观的。使用编程语言时,总会有需要查阅手册的时刻。你刻意挑选的这些例子,新手也根本不会用到。

              你列举的所有例子都属于我所说的“Ronsil”([https://en.wikipedia.org/wiki/Does_exactly_what_it_says_on_t…](https://en.wikipedia.org/wiki/Does_exactly_what_it_says_on_the_tin))。

              即使pickle.dumps()的示例在阅读模块说明时也显而易见,其工作原理与json.dumps()完全相同——后者与其他编程语言中的dumps()方法及术语具有相似性。

              我感觉自己在重复。

              > 许多语言都支持快速应用开发(RAD),包括Clojure、C#和JavaScript。这并非Python的特权。

              胡说八道。我认为这些语言都不算RAD。JavaScript如今根本没有标准库,必须依赖node/npm,这本身就是个麻烦事。C#则高度依赖依赖注入(DI)。Clojure我不了解,不便置评。

              C#和JS的快速应用开发都高度依赖第三方脚本和模板,这些工具不仅存在各种恼人的怪癖,还会让代码库臃肿不堪。Python完全不存在这种问题。

              • 关于C#的框架构建,在即将发布的.NET 10中已极简化:- 在myfile.cs编写代码 – 执行`dotnet run myfile.cs`

                这甚至无需框架辅助。标准库同样庞大,你甚至能在该文件中添加依赖项。

                既然讨论快速应用开发,Python根本无法与Clojure相提并论。拥有独立的REPL“服务器”,通过文本编辑器与之交互,在“活”环境中访问JVM生态系统和标准库,并借助LISP特性实现结构化导航——这才是纯粹的RAD。老天,当我需要快速编程化地与网站交互或编写脚本时,常在Chrome开发工具中用scittle[1]启动REPL“服务器”; 这种体验在其他任何平台都无法实现,纯JS环境也不例外。

                [1]: [https://github.com/babashka/scittle](https://github.com/babashka/scittle)

                • > R.E. 在C#中搭建框架,借助即将发布的.NET 10,操作极其简单: – 在myfile.cs中编写代码 – `dotnet run myfile.cs`> 这甚至不需要框架辅助。标准库同样庞大;你甚至能在该文件中添加依赖项。

                  我快速浏览了部分内容,他们基本上只是将项目文件中的内容移入了 cs 文件。记得在复习 .NET 8 时,他们就提到过这个计划。

                  // app.cs
                  #:package Humanizer@2.*

                  using Humanizer;

                  
                  此外,任何非基础功能似乎仍需依赖 proj 文件。这意味着项目模板等功能很可能依然存在。
                  
                  > 说到快速应用开发(RAD),Python 根本无法与 Clojure 相比。
                  
                  我大概永远不会用到 Clojure,工作环境更不可能支持它。
                  
                  > 拥有独立的REPL“服务器”,通过文本编辑器与之交互,在“活”的环境中访问JVM生态系统和标准库,并借助LISP的特性实现结构化导航——这才是纯粹的RAD。老天,当我需要快速编程化地与网站交互或编写脚本时,常在Chrome开发工具里用scittle[1]启动REPL“服务器”; 这种体验在其他任何地方都无法实现,纯JS也不行。
                  
                  听起来很复杂,而这正是我试图摆脱的东西。我认为这些东西更多是阻碍而非助力。
                  
                  
              • > 按这个标准,没有什么是真正的。

                好吧,那又怎样?我从未宣称其他语言有多么完美,只是驳斥Python具备这种特质的说法。

                > 即便pickle.dumps()的例子也显而易见

                但我们讨论的范围始终局限于函数名——这正是争议焦点。Python中存在大量晦涩的命名,比如ABCMeta、继承自`object`的机制、MRO、slots、dir、spec等等。

                认为无法用库实现快速应用开发(RAD)的想法简直荒谬。游戏开发速度极快,许多游戏引擎都采用C#。使用Unity这样庞大的依赖库,与能否实现RAD毫无关系——关键在于是否具备正确的架构、工具链和开发周期。

                • > 好吧,那又怎样?我从未宣称其他语言有多么完美,只是在驳斥 Python 具备这种特质的说法。

                  我认为人们应当认真阅读文档。任何基于未研读语言文档却又佯装其晦涩难懂的论点,坦白说我都不会理会。

                  > 目前我们讨论的仅限于函数名,这正是争议焦点。Python里还有大量晦涩名称:ABCMeta、继承自`object`、MRO、slots、dir、spec等等。

                  你仍在断章取义试图证明观点。这毫无说服力。

                  > 认为库无法实现快速应用开发(RAD)的想法简直荒谬。游戏开发速度极快,许多游戏引擎都采用C#。你使用Unity这个庞大依赖库的事实,与能否实现快速应用开发毫无关联——后者更取决于架构设计、工具链和开发周期是否合理。

                  我从未否认库无法实现快速应用开发。你完全曲解了我的意思。

                  我能在短短几分钟内搞定Python的开发环境。它不需要应用程序模板或框架应用(像C#和JS/TS那样),只需一个文本编辑器和终端即可。比起其他语言需要搞定的一堆繁琐步骤,Python的入门速度更快、操作更简单。顺便说一句,我用了JS/TS和.NET大约15年。

                  只希望英国能多些Python和Go的职位。

                  • C# 并不比 Python 更需要框架搭建。它自带庞大的标准库(即 .NET)。如今连 NodeJS 的标准库也相当庞大。实际需要多少配置取决于具体需求:使用 Django 时几乎无需配置,而我见过最复杂的配置反而出现在 Pylons/Pyramid 项目中。若仅开发命令行工具,我认为Python的配置复杂度并不比Node低。况且快速应用开发(RAD)的核心在于迭代效率,而非配置耗时。

                    关于Go语言岗位稀缺的问题,我深有同感。这类职位在全球分布似乎并不均衡…

                    • > C# 的脚手架需求并不比 Python 多。

                      这些年变化实在太大,我坦白说已经跟不上节奏了。这正是整个问题的症结所在。

                      我上次认真用 C# / .NET 编程还是 .NET 8 的时代。当时确实有针对主流项目类型的脚手架工具,从空白项目开始配置绝非易事。

                      > 它自带庞大的标准库(即 .NET)。如今连 NodeJS 的标准库也相当庞大了。

                      我发现处理 C++ 和 CMake/Make(我业余编程 Vulkan/OpenGL)比处理 Node JS 和 NPM 更轻松。当我说这话时,人们总以为我在夸大其词,但事实并非如此。这恰恰说明 JS 生态系统有多疯狂。

                      说实话,我对C#和JS都感到厌倦。两者都带来更多头疼问题(尤其使用TypeScript时)。

                      若使用TypeScript又不想用babel,直到最近你基本只能选择tsx或tsnode。随后还得在tsconfig.json里折腾一堆神秘选项才能让常用库正常工作。

                      .NET 5之后的ASP.NET在DI方面简直乱成一团,相关文档要么缺失(至少我找不到),要么随每次.NET/ASP.NET更新就悄然变动。

                      我最终只能拉取完整源代码研究Startup的实现逻辑。如今的C#语言本身和语法糖都已严重臃肿。

                      而Python和Go几乎不会让我头疼这些问题。

                      > 况且,快速应用开发(RAD)的精髓不在于初始配置时间,而在于迭代速度。

                      两者兼顾。我发现Python比JS或.NET更快、更简单、更省心。我精通C#和JS。

                      虽然我对Python的了解不如.NET和JS/TS,却觉得它更容易上手。

                      > 我理解你对Go岗位稀缺的感受。这类职位似乎全球分布不均…

                      确实如此。英国发布的Go岗位基本都集中在伦敦。

        • 它虽不能解决库边界的所有问题,但pyright作为新生力量已远超mypy。

          借助它,Python的类型安全水平已接近TypeScript——虽不及原生支持类型的语言,但在CI环境下严格执行规则后,其安全性已远胜于毫无保障的状态。

          • 反观TS则糟糕透顶。为兼容JS的各种怪异特性,其类型系统复杂得荒谬。我常收到长达20多行的古怪类型乱码错误提示,而当我最终找到解决方案时,往往发现从错误信息中勉强获取的零星线索纯属干扰。

            虽然我不喜欢 JS,但断断续续使用 TS 多年后,我开始觉得 JS 反而是更好的选择。至少它不会用伪装成其他类型的类型化对象来欺骗我,也不会让我浪费时间为某些本无需 TS 就能完美运行的代码声明正确类型。

            TS投入太多精力却回报甚微。我宁愿用最简洁的前端实现尽可能少的逻辑,把真正的编程工作交给后端的真正编程语言。

        • Dunders故意把它们排除在公共命名空间之外。

        • Mypy是垃圾,但Pyright其实相当出色——你完全能用Python获得近乎TS的静态类型体验。

          问题在于,你将耗尽毕生精力也无法说服那些懒惰的同事真正使用它们。

      • > 最完善的文档语言之一

        我们讨论的是同一个Python吗?你见过Python文档吗?它在谷歌排名垫底绝非偶然。

        更别提那惨不忍睹的性能和笑掉大牙的工具链(幸亏uv拯救了我们这个烂摊子)。

      • 是啊,要不是逛了HN,我永远都不会知道用Python写超过1k LOC的代码根本不可能。

      • 你这里指的是哪种语言?

    • 实现的效益主要归功于强大的类型检查机制。

      我是一名全职Rust开发者。我完全认同上述观点,但更希望人们意识到这并非“仅Rust独有”的特性。

      谨防有人产生FOMO(错失恐惧症)。

      • 除Rust外,可知晓其他语言是否存在类似docs.rs的文档平台?JavaScript和Python从不重视文档或参考资料,近期令我沮丧的典型案例是OpenAI的TypeScript SDK——完全没有文档支持,只能翻源代码摸索实现原理。

        • > 在 JavaScript 和 Python 中,开发者从不费心提供文档或参考资料

          拜托,主流 Python 库的文档丰富程度远超 Rust 生态系的绝大多数项目。

        • javadoc.io类似于Java生态的docs.rs(但不如后者优秀)。它从发布到Maven中央仓库的包中提取JavaDoc文档。不过它缺乏docs.rs那样的可发现性,且依赖发布者是否实际包含javadoc文件。

          • javadoc.io 的相似之处在于它同样提供列表功能?其界面体验更差(客观评价),但依赖性更低,因此默认情况下会被视为次选方案。正如你所说,它也不像 docs.rs 那样支持自动生成文档。

    • Python的强大在于REPL环境。它特别适合处理那些超出Bash或图形计算器能力范围的临时任务。其生态系统足够庞大,几乎任何概念验证都能在一天内完成。

      对于<1000行代码且无需维护的短期项目,它简直是绝佳选择。(况且若全程在REPL中编写,你可能压根不会保存代码。)

      • 问题在于当你积累足够多的<1kloc项目时,其中几个会因实用性而持续使用——届时你便在生产环境中维护Python代码了。

        • 没错,但替代方案是“完全不存在这些项目”,而非“用'更优'语言实现它们”

          • 当然不是,替代方案是用更易维护的语言编写这些项目

            • 理想状态如你所言,但多数时候终究要权衡截止期限与开发者/团队的熟练度/技能水平。

        • 我觉得这有些夸大其词。若你花几天用Python构建出可用的概念验证,完全可以再花几天用更优语言重构。你已明确需求,可能已有完善的实现方案,复现过程理应简单直接。

          • 这并非夸大其词。你确实可以花几天用更优语言重构(我亲身实践过!),但现实情况是:人们对新增功能的需求远高于对重构的渴望。因此大多数情况下,系统会_逐渐_膨胀,_逐渐_变得难以掌控,_逐渐_难以找到重写的正当理由。从来没有一个必须重写的明确节点,于是它不断膨胀,直到你真的真的希望当初重写过——但那时也早已为时过晚。

            根据我的经验,这种情况屡见不鲜。甚至像Facebook、谷歌和Dropbox这样的大公司,最终都选择编写自己的Python/PHP运行时环境,甚至开发全新的语言(如Hack和谷歌的新版C++),而非重写现有代码——因为重写工作往往很快就会变得难以完成。

            正因如此——尽管常有人说语言无关紧要——从一开始就选对语言至关重要(如果可能的话)。

            • 这观点很合理。我并非在为用Python做原型辩护。就个人而言,我几乎所有项目都会直接用C#起步,力求从一开始就做对。

              我主要想强调的是:在描述的情境下,应尽早进行重写,避免你提到的困境。

        • 我认为这并非必然,完全可以避免。我从业十五年从未遇到过这种情况。

    • 这里有些真正有用的建议:

      使用类型检查器!Pyright能提供接近Rust 80%的类型安全性。

      • 我没试过Pyright,但任何实际代码库在mypy下都会报出约8万条错误。这种情况很难上手。

        据我观察,mypy的输出结果具有非确定性,且据我所知不支持程序化格式。这使得编写封装脚本进行错误差异比较几乎不可能实现——例如仅展示当前变更引入的错误。

        指望开发者手动翻阅8万行错误信息来排查可能新增的问题,根本是徒劳。

        我们的代码库还大量使用 SQLAlchemy,而它与类型检查器难以兼容。(虽有扩展可辅助处理,但遗憾的是会引发 SIGSEGV 异常。)

        另外这个代码让我费了好大功夫才理解:

        from typing import Dict

        JsonValue = str | Dict[str, “JsonValue”]

        def foo() -> JsonValue:
        x: Dict[str, str] = {“a”: “b”}
        return x

        x: JsonValue = foo()

        
        运行后会得到:
        

        example.py:7: error: 返回值类型不兼容(实际为“dict[str, str]”,预期为“str | dict[str, JsonValue]”) [return-value]

        
        
      • 我不认同类型检查器能拯救Python。这门语言本身及其生态系统都存在不可逆的缺陷,简直是一团乱麻。用Python无法构建可靠的软件。

        • 这是在挑起争论吗?语言本身并不决定可靠性。现实中有大量基于Python的大型系统在运行。至于所谓“不可逆的缺陷”,我倒想听听你举例说明。

          • 所有编程语言都是图灵完备的,这点无可争议。但实现相同结果所需的先验知识和努力程度各不相同,有时甚至根本无法实现。反过来说,所有程序都可能零缺陷,但现实中绝大多数并非如此。

          • > 语言本身并不决定可靠性。

            没人会这么说。但你是否想暗示语言对可靠性毫无影响?这显然是无稽之谈。

            语言选择确实会影响可靠性,而我认为Python的影响属于中等偏差。关键在于是否遵循Pyright规范——遵循的话尚可,否则则相当糟糕。

        • 这种观点幼稚可笑。任何像Python这样普及的语言都能诞生可靠软件。所有语言都存在缺陷。

        • 虽不能保证100%安全,但正如楼主所言,其安全性已达80%。

          说Python无法构建可靠软件是不准确的。事实证明这完全可行,相关案例比比皆是。尽管Python并非最安全的语言,但用它编写的可靠软件比比皆是。

          我认为核心问题在于技术能力。你不懂如何在缺乏完整类型覆盖的语言中构建可靠软件,这纯粹是能力不足。

          我并非有意冒犯,只是陈述逻辑:

          A. 你声称Python无法构建可靠软件
          B. 事实上Python可靠软件确实存在,故你的主张错误

             C. 故你必然缺乏Python软件开发经验,只能依赖Rust类型检查器的全程守护
          

          纯粹陈述事实。

          • 若你掌握了在无类型系统、存在空指针和运行时异常的编程语言中构建可靠软件的秘诀,我洗耳恭听。我承认“无法构建可靠软件”这种笼统说法有些夸张,但本意是为强调效果而非追求事实准确。如果你从零开始编写所有代码,或许能在Python中构建可靠软件,但我绝不会让自己陷入这种境地。我更愿意使用具备类型系统等特性、且生态更成熟的编程语言。

            • 我也偏爱类型系统。

              但无类型也能构建可靠软件。许多人都能做到,这并非我独有的秘密。Python、Ruby和JavaScript平台上已有成千上万可靠软件问世。

              • 确实,我每天都在编写高度可靠的Python代码。效率远超Rust的束缚。

                我们部署了Sentry监控,因此精确掌握异常发生频率——几乎为零。数据库也设置了大量测试与约束条件。

                话虽如此,其他时候我倒喜欢这种严谨的束缚。只是不必天天如此。;-)

                附注:Python没有空值相关的“十亿美元级错误”。必须主动将变量设为None才有效。

                • 楼主态度过于强硬。但若要编写正确程序(即实现预期功能),我会选择Rust。并非Python无法实现正确程序,而是Rust更易于_证明_其正确性。

                  作为独立开发者,我发现自己通常从Python起步,但当项目规模达到一定程度时,Python就变得难以驾驭(即难以在不破坏现有功能的前提下进行修改),这时我就会将项目部分或全部迁移到Rust实现。

                  • >必须编写正确的程序

                    没错,正是如此。这种需求虽不常见,但确实存在。

                    人们似乎忘了——不知为何——Python在“旧时代”向来被定位为原型设计语言,甚至被称为“可执行的伪代码”。这正是它最擅长的领域。

        • 笑死,Instagram是用Python写的。

      • > 仅实现了Rust类型安全性的80%。

        这就像关闭潜艇80%的舱门就下潜。

        • 极少数漏洞会危及生命。

          • TypeScript这类不安全的类型系统存在根本缺陷:一旦意外类型的值通过安全漏洞渗入程序,它可能出现在任何位置,甚至看似完全安全的代码中。

            (这还意味着静态类型化无法带来任何性能收益。)

          • 但它们确实降低了生产力。我认为许多团队并未准确统计修复漏洞/缺陷所耗费的时间,他们严重高估了自己的生产力——尤其是最初因无需考虑类型而获得的生产力提升。

      • 完全正确!我每天用Python编程,多年来从未离开类型检查器(通常是mypy),每次修改都必须先通过类型检查——它能捕捉愚蠢的错误,时刻避免我破坏生产环境。

      • 抱歉,它和Rust根本不在一个档次。甚至比不上C#或Java。它无法提供同样的“无畏重构”体验。虽然比完全依赖类型推断要好些,但这优势实在微不足道。

        而且这还假设代码库及所有依赖都具备正确的类型注解。

      • Pyright和Mypy各自的主要优缺点是什么?

    • 对于我用Python解决的小型任务,你推荐什么替代语言?需要具备:优秀的图像处理库、二进制数据操作与转换工具、强大的列表推导功能、易于实现Web客户端/服务端的能力,以及良好的REPL环境。

      • 若你愿意投入大量前期精力学习,推荐Haskell。否则选择OCaml。

      • Bun

        • 这不就是JavaScript吗?

          • 这是JavaScript(TypeScript)的特殊形式,它以单一可执行文件形式打包了打包器、编译器、PostgreSQL客户端等组件。我用它进行脚本编写,比如初始化数据库等。

          • 对多数人而言Rust难度极高,你认为这种优势能抵消其劣势吗?若非编写驱动程序或大规模互联网压缩器,这真是唯一有意义的优势吗?

            • 你严重高估了Rust的难度。大量高质量软件正用它编写的事实,证明许多人认为它值得付出努力。

  14. 任何语言和JS/TS比起来都会显得出色。我个人对Rust最大的问题是实现单一功能存在上百种方式(当然不是字面意义),这严重损害了代码可读性,尤其当团队存在技术水平差异时。

    • 能否具体说明或举例说明“实现单一功能存在过多方式”的问题?此前从未听闻针对Rust的此类批评。

      Rust甚至配备了工具(clippy)专门警示不符合惯例的代码。

  15. 真希望有个网页应用:选择两种编程语言后,它能展示语言A的简洁代码片段,并标注“看语言B实现同样功能多么臃肿笨拙”(反之亦然)。

    语言本质是权衡取舍的集合,因此我确信任何两种语言都能找到对比案例。这也使得此类比较变得~毫无意义。

    • 首先,代码片段恰恰无法体现语言间的本质差异。

      例如据我所知,Python是实现快速开发的领先语言。至少在AoC排行榜上如此。但它绝非生产环境的理想选择(亲身经历过70万行代码的灾难)。

      Rust用于AoC也可行,但据我所见数据,实现耗时约为Python的两倍。在生产软件中这绝对值得——因为能减少修复低级错误的成本,但代码片段无法展现这种价值。

    • Rosetta Code应该正是你想要的:https://rosettacode.org/

    • 实际上这类比较极具价值,而基于虚构未知场景来论证的论点本身就是谬误。

  16. Rust是极少数能让我在重构时把代码搞得一团糟的语言——多个模块出现上百个编译错误,以为永远修不完,结果到最后总能让所有代码顺利编译,运行测试时…一次全部通过。全是绿灯。难以置信吧?但我经历过很多次。

    • 同感。我常经历应用突然恢复正常时恍惚想:“等等…搞定了?”

  17. 我始终认为POSIX线程语义强制要求获取锁的线程必须是释放锁的线程,这种限制过于严苛且毫无必要。在某些场景下,这会迫使你以更复杂的方式重构代码。

    • 这限制并不严苛。释放pthread_mutex的语义本质是释放内存屏障,意味着该线程的任何写操作都会被后续执行获取屏障(如获取互斥锁)的其他线程可见。

      若需要此行为,基于futex实现自定义互斥锁相对简单,但没人会预期它提供这种行为。

    • 我曾持相同观点,但后来了解到API如此设计的合理依据:优先级继承机制。优先级与线程绑定,当高优先级线程试图锁定已被占用的互斥锁时,我们需要提升当前持有者的优先级。而POSIX规范恰好简化了这一操作——必须由锁定该互斥锁的线程来持有它。

      相关阅读:https://man7.org/linux/man-pages/man2/futex.2.html#:~:text=%

  18. 无畏重构正是我将公司内部工具从Python重写为Rust的根本原因。当代码规模膨胀到必须重构时,我突然意识到所有逻辑关联都如此松散。此刻我终于明白为何众多Python开发者热衷编写大量低效单元测试——他们是在弥补强静态类型系统的缺失[1]。

    这只是非核心产品的内部工具,我认为耗费额外时间编写冗长的“用户参数是否正确”测试,或配置静态分析工具毫无价值。因此我选择了一种尽可能减少构建系统/静态分析/内置单元测试/独立运行时安装等麻烦的语言,以更轻松地推进开发。

    这是三年前的事,如今该工具的功能已比初始版本提升约4倍。

    [1]: https://dmerej.info/blog/post/i-dont-need-types/

    • 重构是所有动态语言面临的巨大难题。代码极易在运行时才显现的隐蔽方式下被破坏。

      这类语言虽适合初稿开发,但随着多人协作或重构,代码往往会“熔化”——缺乏静态类型系统既无法捕捉明显问题,也无法强制执行任何规范。

      • 我完全认同初稿阶段的价值。时至今日,我仍会用Python实现灵光乍现的创意和一次性脚本——它实在太便捷了。直到尝试用它编写大型程序时,我才真切体会到它的脆弱本质。

  19. 今天我用XKCD #1987向学生解释:尽管Python被普遍视为“简单”且“易用”的语言,但其生态系统的复杂性往往在后台埋下隐患。

    Rust则将复杂性前置,因此被认为“难以掌握”。但必须承认,Rust的“复杂性”让我得以构建出比以往任何职业编程语言(C/C++/Java/Swift/JavaScript/Python)更坚固的软件大厦。

    这正是多数人未能理解Rust之处——唯有攀越陡峭的学习曲线,才能真正领略其价值。

    至今我已多次对庞大的Rust代码库进行高风险且耗时数周的重构,每次成功时都惊叹于它竟未演变成其他语言重构时常见的灾难——那些重构因变得过于复杂而被迫放弃,最终让所有人丧失希望与动力。

    Python教程里只强调省略大括号和泛型类型的便利,却绝不会提及这种痛苦。而Rust的真正魅力,也唯有在积累足够经验、面对重大重构时才能体会。因此我完全理解,对于初学编程的新手而言,Rust的价值主张为何显得如此令人怀疑。

    [1] https://xkcd.com/1987

    • > 今天我不得不借用XKCD #1987向学生解释:尽管Python被普遍视为“简单”且“易用”的语言,但其生态系统中的诸多复杂性实则由后端承担了代价。

      不,这只是无关的巧合。Python恰好是工具链糟糕但语言本身优秀的案例,但同样存在工具链优秀却语言糟糕的情况,以及工具链糟糕且语言本身也糟糕的情况。

  20. 在这张图表上,两条曲线都应呈单调下降趋势,只是下降速度不同而已。

  21. 这些生产力提升在任何受Standard ML影响的类型系统中都能实现。

    • Rust与其他采用Standard ML影响的类型系统的语言主要区别在于:Rust具备能让管理层批准切换语言的特性。

      • 在此方面,Rust相较OCaml在多数应用场景中的核心优势是什么?

      • 倒也不尽然,我工作中Scala、F#、Swift和Kotlin都是可选语言,而Rust大概率永远用不上——除非是使用用Rust编写的JavaScript工具,纯粹因为它存在。

        在基于SaaS产品、移动操作系统和托管云环境的分布式系统领域,借用检查器毫无用武之地。

    • 文章举例依赖的借用检查器,并非标准ML类型系统的常规组成部分。

      • 这被称为仿射类型系统,ML的某些衍生语言采用了该机制。

        你甚至可以更进一步探索线性类型、效应、形式化证明或依赖类型。

        Rust的成就,无疑是让这些理念更趋主流。

      • 类型系统能否跨越翻译单元边界检测此类问题?我清楚这正是C类编译器缺乏全程序编译时的重大局限。

        • 最终答案就是“可以”。你明白为什么六个整数的数组和三个整数的数组属于不同类型吗?尽管它们都是整数数组?若对此概念模糊,建议花时间重温基础知识——若你仅接触过C语言,这将是一次深度学习,但绝对值得。

          在Rust中,引用类型包含生命周期(lifetimes),因此&'a str和&'b str可能属于不同类型,尽管它们都是字符串切片引用。

          除此之外,Rust 还追踪两个重要的“线程安全”属性:Sync 和 Send。因此,如果你的对象最终需要具备 Send 属性(因为其他线程会获取该类型),但它本身不具备 Send 属性,这将引发类型错误——就像它缺少其他必需属性一样,比如不具备完全序属性 (Ord),或无法转换为迭代器 (IntoIterator)

    • >> 任何受 Standard ML 影响的类型系统

      具体指哪些语言?

      • 虽然我猜这是个带有倾向性的问题。

        Caml Light、OCaml、Miranda、Haskell、Coq、Agda、Lean、Scala、Swift、F#、F*、Idris、ATS,当然还有Rust。

    • 这是否意味着比单纯使用类型系统更深层的含义?

      • 是的,至少对未接触过的人而言。许多C++或Java程序员认为他们的语言具备类型系统,甚至部分Python或Ruby开发者也如此认为。

      • 类型的概念并非固定不变,Rust对“类型”的定义更为宽泛,其静态类型系统检查的范围也更广。

      • 你展示的那个bug与TypeScript(JS)无关,而是浏览器API的问题。TypeScript虽非完美语言,但仍远胜原生JS。

        我甚至希望Haxe能取代TypeScript的位置,毕竟它整体上是更优秀的语言。

  22. 这本质上是“开发者发现静态类型系统很有用”。

    看到这类帖子总让我忍俊不禁。

    • > 看到这类帖子总让我忍俊不禁。

      哪里好笑?

      • 说到底,你列举的这些优势早在70年代就为人所知(却在新的博客文章里重提)。

        这本质上是种重新发现的过程,我很高兴人们能“发现”其中的乐趣。

        Rust大量借鉴了其他编程语言,这正是它广受欢迎的原因,但人们往往忽略了背后数十年的研究积淀。

  23. 文中给出的TypeScript示例在我看来根本就是个明显错误——甚至在我了解window重定向机制前就已如此。这显然是控制流问题

    至于事件循环与互斥锁实现的并发机制,简直是风马牛不相及的比较。两者虽都涉及并发,但实现方式天差地别。

    重申一次,TypeScript根本算不上真正的语言,它只是大型项目中管控JavaScript的工具。若在语言层面比较Rust和TypeScript,实在不是恰当的对比。

    至于无畏重构,这话题我可聊不完。初次将原生JavaScript后端移植到TypeScript时,那种体验简直震撼。虽不能说它和Rust效果完全相同,但若你曾将JavaScript写的REST API迁移到TypeScript——绝对能体会到类似的魔力。

  24. JavaScript浏览器漏洞与语言本身无关。

    纯粹是逻辑缺陷。

    例如代码与开发者英文逻辑描述不符:“若为真,跳转至特定页面;若为假,转至仪表盘或引导页面。”

    代码遗漏了“若为假”的分支(最佳实现应在if语句后添加else子句)。

    • 我倒不会说_毫无意义_。毕竟我们讨论的本质上是语言中定义的全球变量。

      • window 和 window.location 不属于 JavaScript 本身,而是标准浏览器提供的 JavaScript API 的一部分。

        必须指出浏览器API确实容易造成混淆。人们通常不会意识到设置属性会触发异步操作,尤其当其初始效果看似即时生效时。

        但该代码的基本控制流逻辑本身存在错误。纠结API调用的副作用能否帮你规避错误,这根本不是重点。

        • 我好奇Rust(通过JS互操作实现的WASM?)是否也能访问window.location,以及该API是否具备更完善的防护机制。

          • web_sys允许调用window.location,该方法返回Location对象,其包含href和set_href方法。这些方法与原生API功能完全一致,因为这正是它们的设计初衷。

          • TypeScript同样具备ORM框架。

            问题根源不在TypeScript或JavaScript本身,而是浏览器API的特殊设计——修改对象的某个随机属性值会导致页面重定向,但并非同步执行。

            即便浏览器语言是Rust,其类型系统也无法专门捕获此类错误(至少据我所知如此)。推测后台存在周期性读取href值并更新页面的机制,但由于该后台任务仅需读取权限而非写入权限,借用检查器在此场景下并无作用)

            • > 设置href值将导航至指定URL [0]

              这种情况本应被捕获,因为Rust语言不支持此类API(设置器)。最佳方案是使用.set_href(String).await,该操作会阻塞线程直至位置更新完成且值稳定。最坏情况是出现公共的.href变量,但由于设置器模式不可行,可确定必然存在某个进程负责检查并调度更新。

              [0]: https://developer.mozilla.org/en-US/docs/Web/API/Location/hr

      • 具体位置在哪?
        https://tc39.es/ecma262/multipage/

  25. 这个 TypeScript/JavaScript 示例有些不诚实。若不理解运行时/环境/域语义的工作原理,任何机制都无济于事。

    • 我热爱 TypeScript,但对此观点持异议。这篇帖子的核心似乎在于:Rust编译器的特性能强制你采用特定的运行时/环境/领域语义,从而消除常见错误类型。这固然无法杜绝所有错误,但能避免大量常见错误,让你只需手动记住更少的运行时/环境/领域语义规则,这本身就有价值。

      • 这不能怪 TypeScript。借用检查器无法帮你避免发送到数据库管理系统(DBMS)的 SQL 查询中的错误。TypeScript 不关心浏览器,就像 Rust 不关心 SQL 一样。

        888/3/bryanlarsen

  26. "我发现Rust强大的安全保障让我在修改代码库时信心倍增。这份额外信心让我更愿意重构应用的关键部分,这对我的生产力和长期可维护性产生了极大积极影响。"

    这通常是编写代码测试的原因。但若没有测试,具备严格编译器的语言自然更有助益。不过最佳方案仍是编写测试——这样即使使用“松散”的编程语言编写的代码,也能充满信心地进行重构。

    • 不,属性尽可能由编译器静态验证比运行时测试更优。

      测试应用于无法静态验证正确性的场景。但若能实现静态验证则更佳。

      最终目标是形式化验证,此时几乎无需运行时测试。但软件形式化验证极其困难,通常难以实现。

      • 即便形式化验证也需要测试,因为逻辑证明本身可能存在缺陷。

        • 确实如此。通常而言。某些情况下,你所验证的属性极其简单,根本无需测试——例如压缩库中只需验证 decompress(compress(x)) == x。若该属性已获证明,测试便毫无必要。

          但通常还是建议保留少量测试。你所能形式化证明的属性越多,所需测试就越少。

    • 根据我的经验,一个没有测试的Rust代码库值得的信任度,要高于任何粗制滥造编程语言中实际可行的测试水平——至少在达到SQLite那种“测试代码量是主代码10倍”的项目之前都是如此。完善的类型系统能以远低于测试的成本达到同等可信度。若再辅以精心设计的少量测试,其效果将远超纯测试方案。

    • 赞同你的观点。编写优质测试并合理运用类型系统,对发现缺陷大有裨益。

      但不知为何,编写测试总让我想起xkcd的《标准》(https://xkcd.com/927/)——这就像用更多代码来捕捉代码漏洞,而非“通过制定新标准来修正标准”。

      至少类型系统由语言维护者负责维护,而非项目维护者。

    • 而每次重构代码时,工作量都会翻倍——因为测试也需要同步重构。

      • 若测试仅验证代码结构,此言不虚。但若测试API的功能特性,则可在抽象层之下自由重构。

  27. 这张图表同样适用于Haskell。你甚至可以替换横轴为:

    – 参与的不同项目数量
    – 自上次参与该项目以来的时间
    – 参与该项目的不同人员数量
    而曲线走势基本不变。

  28. 如何在类型系统中编码锁定问题?这似乎很神奇?是否只需在调用 await 时永不持有锁?调度器是否足够智能,能识别线程间的工作迁移?

    • 作者很可能使用了tokio库,该库要求构建的未来值(如异步函数)必须是Send类型(无论是基于Rust规则还是显式标注为Send),因为tokio是工作窃取式运行时,任何线程都可能执行某个未来值(甚至可能在执行过程中暂停,随后将其移交至其他线程完成)。std::sync::MutexGuard 刻意未标注 Send,因为某些平台要求获取互斥锁的线程必须由同一线程解锁。

      但需注意:在异步环境中使用标准库 Mutex 是反模式,应避免使用——这可能引发各种问题,甚至导致整个代码死锁。应使用 tokio 同步原语(如 tokio Mutex),它们能在需要阻塞时向反应器让出控制权。否则,执行Future的线程将永久阻塞等待互斥锁,导致反应器无法执行其他任务——这违背了Tokio的设计初衷。

      因此编译器虽提示了一个问题,但开发者还需警惕:切勿在异步函数中调用阻塞函数。

      • > 在异步环境中使用标准库的普通互斥锁属于反模式,应避免使用

        此说法完全错误,Tokio官方文档明确指出:

        “与普遍认知相反,在异步代码中使用标准库的普通互斥锁是可行的,且通常更受推荐。”

        https://docs.rs/tokio/latest/tokio/sync/struct.Mutex.html#wh

        • 我认为这是危险的建议,既忽略了在单线程反应器中操作的后果(若锁发生争用,应用将永久挂起),又假设不会出现N个线程同时争夺该锁的情况——其中N代表线程池规模。在高访问量服务器上,若每个请求都需要访问的核心状态被该互斥锁控制,这种场景完全可能发生。

          • 若使用单线程反应器,根本无需互斥锁;Rc<RefCell<_>>完全能胜任。若需让其他任务在借用持有期间让出执行权,解决方案很简单:在借用结束前不要使用await。https://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html

            Tokio的互斥锁和读写锁确实存在适用场景,但绝大多数情况下你根本不需要它们

            • 众所周知,当数据结构被其他线程访问时(例如通过 spawn_blocking 或显式创建的线程),Rc<RefCell> 无法胜任。即便使用单线程反应器,也可能存在其他线程访问数据的情况。

              • 你似乎发现了一个非常特殊的场景,此时tokio的Mutex确实有用。但用单线程反应器配合大量阻塞式spawn构建异步Rust应用本就不常见。优先采用std库的Mutex仍是普遍建议。

      • 这可能是个问题,但仅当互斥锁的争用周期足够长时才成立。在异步环境中,仅被短暂持有的互斥锁完全可以正常工作。它只会引发死锁——前提是该互斥锁在线程环境中本就会导致死锁,因为互斥锁只能在让点之间持有,因此只有正在运行的任务才会争夺它。

        • 试想每个线程在处理HTTP请求时都试图获取该互斥锁,同时你在spawn_blocking中也对其加锁。在激烈竞争的锁上,所有反应器线程最终都会卡在等待 spawn_blocking 完成的状态,服务将看似挂起——因为即使不需要该锁的请求也无法处理。若 spawn_blocking 正在执行同步网络 I/O 或从挂起的 NFS 挂载点读取文件,系统将彻底崩溃。

          • 即使该锁采用异步机制,情况同样糟糕。无论如何都应尽可能缩短锁定时长,因为持有锁的卡死任务通常会引发系统级连锁故障,直至问题得到解决。

            • 若存在可跨越 await 点持有的异步锁,你启动_block 的动机就会减弱。此外,即使长期持锁会造成问题,但未获取该锁的 API 仍能正常工作,这与整个 HTTP 服务挂起存在巨大差异(例如健康检查超时导致服务看似无响应,而实际只是某个高竞争任务在等待锁)。

              • >例如健康监测超时导致服务看似无响应,与单个高竞争任务仅等待锁的情况不同

                这反而可能成为劣势。健康监测应充当“金丝雀”机制,而非在系统已停止有效工作时仍持续运行的组件。(参见经典安全关键软件失误案例:"我需要看门狗程序… 我只需在隔离任务中定期向它供电」)

                • 我持不同意见,希望说服你。你假设所有请求引发问题的概率相等,因此服务器在负载下死锁尚可接受——毕竟失败的健康检查会触发监控机制重启。但假设你有三个API接口:

                      /healthz
                      /very_common_operation
                      /may_deadlock_server
                  

                  通常情况下,/may_deadlock_server 的流量不足以引发问题(假设其请求速率为10次/秒,而/very_common_operation为1000次/秒,服务器运行正常)。但当/may_deadlock_server突然涌入大量请求时(仅需数百次请求量级),可能导致服务陷入死锁。您是否仍希望服务器完全锁死并永久等待健康检查超时以重启服务?若健康检查仍显示正常,但整个服务响应时间从10毫秒骤升至200毫秒——这种足以引发问题却未触发健康检查失败的延迟呢?而这一切仅仅源于/may_deadlock遭遇流量峰值。此外,健康检查失败仅会重启服务,却无法缓解持续存在的流量峰值。更需警惕的是,/may_deadlock_server对攻击者而言不过是实施网站DOS攻击的简单工具。

                  或者,您希望Web服务器保持正常响应,同时依靠指标和警报来监测/may_deadlock_server处理请求耗时过长/影响性能的情况?健康监测是自动缓解问题的绝对最后手段,但仅当故障是服务卡在瞬态状态时才有效——若重启后仍陷入相同饥饿状态,你将陷入无限重启循环,情况反而更糟。

                  Healthz并非指标与警报的替代方案——它是试图自动脱离恶劣状况的最后应急措施。但若问题超出服务状态范畴,它反而会引发更严重后果。因此通常应让服务保持可用,除非重启也无法解决问题。

      • > 需注意:在异步环境中使用标准库互斥锁属于反模式,应避免使用——这可能引发各类问题,甚至导致整个代码死锁。

        确实如此。我曾用tokio搭配std::mutex,几天后API就无法响应,必须重启容器才能恢复。我原以为只要能编译通过就能正常运行(无畏并发),通常确实如此。

        • 听起来你的代码某处存在死锁,选择Mutex本身很可能并未真正解决问题

          • 或者你在持有互斥锁期间执行了极其耗时的操作

            又或者服务器资源竞争过于激烈,导致所有工作线程都被该互斥锁阻塞,使得任何反应器都无法继续执行

      • 使用Tokio互斥锁更是反模式的典范 🙂 下周来我的RustConf演讲了解异步取消机制,揭晓原因!

        • 本论坛多数人不会参加RustConf。建议至少发布你想法的摘要。

          • 关键在于未来值是被动的,因此任何未来值都可在任意 await 点通过丢弃或停止轮询来取消。若遇到如下场景——根据我的经验,这正是互斥锁的常见用法:

              let guard = mutex.lock().await;
              // guard.data 为 Option<T>,初始值为 Some
              let data = guard.data.take(); // 此时 guard.data 为 None
            
              let new_data = process_data(data).await;
              guard.data = Some(new_data); // guard.data 重新变为 Some
            

            此时若在持有锁期间取消中间的 await 点,guard.data 将不会恢复为 Some。

            • 不过我不确定这是否引入了新的故障:

                  let data = mutex.lock().take();
                  let new_data = process_data(data).await;
                  *mutex.lock() = Some(new_data);
              

              此处使用传统锁机制,当 process_data 被取消时,锁将处于你担忧的异常状态。这是取消操作与异步任务的普遍陷阱——每个 await 边界处,数据必须保持某种内部一致的有效状态,因为 await 可能永远不会返回。要更稳健地解决此问题,需要使用 async drop 语言特性。

              • 确实!标准库互斥锁同样存在此问题。但若在 await 点持有 std MutexGuard,会导致未来对象无法发送(Send),因此通常无法在 Tokio 运行时上启动 [1]。不过据我所知,这并非设计初衷——只是偶然规避了该陷阱的解决方案。

                遗憾的是Tokio的MutexGuard支持Send,因此极易引发取消相关错误。

                (另有相关讨论涉及基于panic的取消机制与互斥锁污染问题,标准库的互斥锁存在该缺陷,而Tokio的则没有。)

                [1] 虽然spawn_local确实存在,但估计多数人不会使用它。

                • 你的论点是:多次获取锁的机制能促使开发者考虑取消操作及保持中间状态的有效性?否则我不明白MutexGuard采用send机制如何改变取消漏洞的发生概率。

                  • 没错,互斥锁的典型用途往往是暂时违反在锁释放期间必须维持的不变量,这种情况通常可在局部推导出。而在 await 点推导取消操作本质上是跨域的,因此难度大得多。(而 Rust 的核心理念正是将局部推理扩展为全局正确性,因此异步取消机制对许多实践者而言如同背后捅刀。)

                    普遍推荐的替代方案是采用消息传递/通道/“演员模型”,通过单一数据所有者确保取消操作不会发生——或者至少保证若取消发生,对应的无效状态也会被同步撤销。但这种方案本身存在饥饿等缺陷。

                    遗憾的是,这些方案都令人难以满意。

                  • 我此刻才意识到您提议的是多次解锁并重新锁定互斥锁。通常而言,若开发者在执行过程中解锁互斥锁,说明其已考虑到与其他调用者的竞争(即便期间代码未被取消)。

                    确实可以主张开发者应像考虑完全释放互斥锁那样思考 await 点,以防发生取消操作。但互斥锁是否利于这种思考?实践中我发现这种思路极易出错。

            • 任何需要清理的资源都适用同样逻辑,对吧?将停止轮询未来操作称为取消可能不是好命名。通常取消某些工作需要清理,即使只是优雅地退出,更不用说正确释放资源了。

              • 是的,这适用于任何资源。但Tokio互斥量作为共享可变状态,在生产环境中本质上容易引发错误。

                在Rust社区,“取消”已是对此类操作的成熟术语。

                希望我的演讲视频能在RustConf结束后尽快上线,同时也会制作文字版供偏好阅读的观众参考。

      • 感谢分享!事后看来,设计一个表示“可安全移至其他线程”的类型似乎很简单。但函数中使用互斥锁会改变对象“类型”的机制确实新颖——在 await 之前释放锁后立即变为 Send 类型,这个设计简直绝妙。

        • 其完整协同机制如下:

          – Mutex::lock() 的返回类型是 MutexGuard,这是一种智能指针类型,它 1) 实现了 Deref 操作符,因此可通过解引用访问底层数据;2) 实现了 Drop 操作符,当守护对象作用域结束时自动解锁互斥锁;3) 实现了 ! 操作符。Send 操作,使编译器知晓跨线程发送操作存在安全隐患:https://doc.rust-lang.org/std/sync/struct.MutexGuard.html

          – Rust 的 async/await 实现机制是将异步函数转换为实现 Future Trait 的状态机对象。编译器会生成一个枚举类型,用于存储状态机的当前状态以及所有需要跨越yield点的局部变量,并提供一个poll函数(同步地)将协程推进到下一个yield点:https://doc.rust-lang.org/std/future/Trait。Future.html

          – 若需在线程间迁移任务的异步运行时,任务未来对象必须实现 Send。

          因此在本例中:由于作者在 await 点跨点持有锁,编译器必须将 MutexGuard 智能指针存储为 Future 状态机对象的字段。由于 MutexGuard 不支持 Send,该未来对象同样不支持 Send,这意味着它无法用于在线程间迁移任务的异步运行时。

          若作者在等待前释放锁(即移除锁保护),则该保护机制不会跨越yield点存续,因此无需作为状态机对象的组成部分持久化——它将在Future::poll()单次调用周期内完全创建并销毁。此时未来对象可为Send类型,意味着任务可在线程间迁移。

      • 原帖作者能用上工作窃取引擎算是“走运”。若引擎不跨线程调度任务,编译器本可顺利通过,但他恐怕得享受调试同一线程重复锁定互斥锁的乐趣了。Rust编译器可不会帮你规避这类错误。

        > 在已持有锁的线程中再次锁定互斥锁的具体行为未作规定。但该函数在第二次调用时不会返回(可能引发panic或死锁)。

        若类型支持跨线程移动则为“Send”,若支持多线程并发访问则为“Sync”。

        若感兴趣,可查阅《Rust 编程语言规范》了解更多:https://doc.rust-lang.org/nomicon/send-and-sync.html

    • 是的,Rust通过借用检查器和生命周期机制能验证锁的单次访问。

      • 确实如此,但这似乎更复杂。若代码在单线程执行则正确无误。编译器似乎能预知 await 可能将任务移至其他线程——若非存在恼人的未定义行为,该代码在其他线程仍应正确。在所有节点看似都是单次访问的情况下,它却能捕获此问题。

    • 更准确地说,锁保护机制本身并非Send类型,因此当其跨越await点时,异步函数返回的impl Future同样不具备Send属性。这导致该值无法传递给执行任务窃取的调度器——因为该调度器的类型要求接收的未来值必须是Send类型。

    • 没错,若使用不跨线程移动任务的调度器,它不会要求任务为Send类型,示例代码就能编译通过。

      Rust能利用类型信息和生命周期判断何时安全、何时不安全。

  29. 我认同更强大的编译器能提升开发效率的观点。但使用 cargo 编译可能非常耗时,配备高性能工作站是值得的。

  30. 根据我的经验,人们往往更烦躁的是编写代码和克服那些并非“解决问题”的障碍所耗费的时间——这些障碍会让人陷入手动调试、重构和测试的循环,仿佛在听冥想播客般虚度光阴。

  31. 这篇帖子完全印证了我的经历。当项目涉及多人协作,或维护非自己创建的项目时,这种感受尤为强烈。此时静态类型系统和基于ADT的领域建模优势便不可或缺。以GHC为例——它虽是Haskell项目而非Rust项目,但自1990年起由众多开发者持续维护,至今仍保持着旺盛的生命力。难道Haskell类型系统提供的保障对实现这一成就毫无助益吗?倒非说它绝对必要——Linux内核这个更令人惊叹的项目是用C语言实现的——但非必需的因素仍能提升成功概率。

    问问任何专业Python程序员,他们花了多少时间去推敲PyTorch函数返回对象可调用的方法,答案必然是每周至少要面对一次挑战。问问任何C++程序员,他们花了多少时间调试段错误。你可以问任何Java程序员,他们花了多少时间调试空指针异常。这些都是常见问题,会浪费大量时间,而在Rust中几乎不会以同样的频率出现。

    诚然,编写测试可以获得部分这些优势。但测试能否避免楼主帖子中提到的问题——从一个线程获取互斥锁,却在另一个线程释放时导致行为未定义?除非你拥有那种人人谈论却无人真正具备的密集模糊测试基础设施,否则答案极不可能。试问哪种方式更省时:搭建测试环境、运行检测、发现互斥锁释放点存在未定义行为,最后才意识到问题源于互斥锁被移交至其他线程?还是直接在编写代码时就收到编译错误提示:“嘿伙计,互斥锁守护程序不能移到其他线程”?况且,任何接触过大量测试代码库的人都能告诉你:有时修复测试耗费的时间甚至超过实际编写代码。不知为何,我修复类型错误的时间远少于修复测试。

    还有一个累积效益:当重构变得轻松(而单元测试往往不会让重构更容易…),你就能不断迭代代码架构,直至找到与领域模型自然契合的方案。当需求变化、领域演进时,你还能再次重构。若重构成本过高而不敢尝试,架构将与领域日益脱节,最终使代码库沦为无法维护的意大利面。设想一个简单模型:每次新增需求都迫使你重构代码或让代码变得混乱,且每次混乱都会导致开发速度下降1%。由此可见,重构几乎成为必需。因为未来100个新需求中,意面式开发者的效率将仅为坚持重构者的36%。由此可见,重构势在必行,而能否快速完成重构则成为生产力的关键要素——这正是Rust广受认可的优势所在。

    虽然Rust仍有诸多不足之处,但这并不影响我们为其成就感到自豪。它不仅将ML和Haskell的诸多创新带入主流,更在此基础上开创了全新的类型系统特性,最终打造出这款高效且设计精良的编程语言。

    (我也在reddit上留了这条评论,现复制于此。)

  32. 完全赞同,用Rust编程通常第一次尝试就能成功!

    调试时间减半,能专注于产品本身

  33. > 我发现Rust强大的安全保障让我在修改代码库时更有底气。这份信心让我更愿意重构应用的关键部分,这对我的生产力和长期可维护性都有极大裨益。

    这固然很好,但顶部的图表显示项目规模越大你的生产力反而翻倍增长,这似乎不太可信。或许这只是视觉夸张手法,但我的胡说八道探测器已经警报了。

  34. Rust的难度反而有助于重构大型代码库,这点很棒。

  35. 你提到的Rust示例在其他语言可能不会触发编译时警告,但大多数操作系统互斥锁(Windows和Linux系统中的锁尤其如此)会在不同线程释放锁时报错。

    • 你似乎误解了。关键在于Rust能在编译时检测错误,从而避免运行时错误(这类错误可能极其罕见或依赖上下文)。

  36. 或者你可以使用Clojure的不可变数据结构,在并发访问数据结构时无需锁定互斥锁。

  37. 在异步环境中使用同步锁是代码异味,因此Rust代码从一开始就是错误的。除非你完全清楚自己在做什么,否则请坚持使用异步锁并承受微小的性能损失。

    • 若我没记错,tokio Mutex文档提到除非跨await持有锁,否则应使用std Mutex

      • 这正是当前代码的问题所在!

        • 该部分更像是伪代码的删减版,而非实际代码。

          他本可在 await 点之前50行处使用 mem::dropped 释放锁

    • 我无需在 await 点跨时段持有锁,这种情况下官方文档推荐使用常规锁。

  38. 图表未标注单位或比例尺。交叉点的项目规模是多少?文章称“特定规模”含糊不清。

  39. 拜托,作者竟拿Rust和TypeScript、Zig、Python比较,然后得出结论说Rust在大型代码库中比“某些其他语言”更高效?难道那三种语言的设计初衷就不是针对大型(后端)代码库吗?要进行公平分析,作者本该拿Java、Scala或C#这类语言作对比。

    关于文章本身:因结构体被并发访问而用互斥锁包裹的做法令人警觉。若真在大型代码库中工作,你更希望避免这种操作:应将结构体封装为服务,确保所有访问都排队执行。这样更简洁,也更不易引发棘手的死锁。

    • > 要进行公平分析,作者本应选择Java、Scala或C#这类语言进行对比。

      这样对比是否对它们不公平?它们仍使用null或可空类型,缺乏抽象数据类型(ADT),线程安全不变量依赖文档维护等等。

      • 或许吧。但至少这些语言本就面向大型应用设计,对比结果会更有参考价值。

        此外,Scala使用Option类型替代null,且具备ADT;C#情况不明,但Java和Scala中使用底层互斥锁被视为代码异味,标准库已提供更高阶的并发数据结构。

        更重要的是,Scala提供多个广泛使用的IO框架,使并发操作和副作用处理更简洁且不易出错,这方面它会胜过Rust。

        • > 更重要的是,Scala提供多个广泛使用的…

          使用Scala的优势不在于你缺少什么,而在于你拥有什么。

          – 自定义运算符。它们虽能为领域特定语言增色,却常让代码变得晦涩难懂,更会混淆IDE的识别。
          – SBT。光是想到就令人发怵。
          – Scala 2代码库。

          • 前两点确实中肯。但最后那条显然与选择企业级应用开发语言无关。

            总之,我认为将Rust与Scala/Java/C#进行对比会更有趣也更有价值——无论结果如何。

            • > 当然,当你考虑为新企业应用选择语言时,最后一点并不相关。

              我可能正在编写新应用,但如果某个依赖项要求Scala 2.12-呢?

              至于你后面的观点,我依然认为Rust的生产力会超越所有列举的语言。我预计Scala可能会给Rust带来最大挑战。C#在性能上或许最接近Rust——也可能不然,这取决于具体工作负载。

      • Java和Scala确实支持抽象数据类型(ADTs),而Scala和C#还支持可空类型。

        • 好吧,我应该更明确些。所谓ADT指的是和类型,或者更精确地说,是区分联合类型。产品类型简直是人尽皆知。

          Java确实没有区分联合类型(C#在8.0版本前也没有)。它确实有|运算符,能将两个对象转换为最近的共同祖先类型。

          关键在于可空支持。我在C#里折腾过可空类型,简直糟糕透顶——这话出自曾经认为Option<T>很糟糕的人之口。

          很容易引发类型混淆,尤其当非可空类型曾被赋予空值(或至少在调试器中出现过空值)的次数大于零时。公平地说,当时涉及反射和代码生成机制。

          • 建议您更新语言知识至Java 24、C# 13、Scala 3版本。

            另见我先前评论:

                    type Exp = 
                      UnMinus of Exp
                    | Plus of Exp * Exp
                    | Minus of Exp * Exp
                    | Times of Exp * Exp
                    | Divides of Exp * Exp
                    | Power of Exp * Exp
                    | Real of float 
                    | Var of string
                    | FunCall of string * Exp
                    | Fix of string * Exp
                    ;;
            

            将其转换为Java中你所说的绝对不存在的抽象数据类型(ADTs),

                public sealed interface Exp permits UnMinus, Plus, Minus, Times, Divides, Power, Real, Var, FunCall, Fix {}
            
                public record UnMinus(Exp exp) implements Exp {}
                public record Plus(Exp left, Exp right) implements Exp {}
                public record Minus(Exp left, Exp right) implements Exp {}
                public record Times(Exp left, Exp right) implements Exp {}
                public record Divides(Exp left, Exp right) implements Exp {}
                public record Power(Exp base, Exp exponent) implements Exp {}
                public record Real(double value) implements Exp {}
                public record Var(String name) implements Exp {}
                public record FunCall(String functionName, Exp argument) implements Exp {}
                public record Fix(String name, Exp argument) implements Exp {}
            

            以及一个典型的ML风格评估器,纯粹为了好玩:

                public class Evaluator {
                    public double eval(Exp exp) {
                        return switch (exp) {
                            case UnMinus u -> -eval(u.exp());
                            case Plus p -> eval(p.left()) + eval(p.right());
                            case Minus m -> eval(m.left()) - eval(m.right());
                            case Times t -> eval(t.left()) * eval(t.right());
                            case Divides d -> eval(d.left()) / eval(d.right());
                            case Power p -> Math.pow(eval(p.base()), eval(p.exponent()));
                            case Real r -> r.value();
                            case Var v -> context.valueOf(v.name);
                            case FunCall f -> eval(funcTable.get(f.functionName), f.argument);
                            case Fix fx -> eval(context.valueOf(v.name), f.argument);
                        };
                    }
                }
            
            • > 建议你更新语言知识到Java 24、C# 13、Scala 3。

              需要时我会更新的 😛 我敢肯定永远用不上 Scala 3。

              > 进入你所说的 Java 绝对不具备的抽象数据类型领域

              这看起来还是在向通用对象强制转换 + 一堆 instanceof 判断。不过既然它像鸭子一样叫,像鸭子一样走…

              > C#13

              等等。你提到C#通过密封类实现了ADT(如我理解)。那为什么他们还有个https://github.com/dotnet/csharplang/issues/8928的票据讨论区分联合类型?

              这暗示密封接口与区分联合体存在某些差异。或许在于它们处理值类型(结构体和引用结构体)的方式。

              • 我从未说过C#有ADT,

                > Java和Scala确实有ADT,而Scala和C#支持可空类型。

                • 那你为何要我更新C#知识?当前讨论中我的说法并无谬误。

                  此外你未提及Java泛型ADT仍极其糟糕(这点至关重要,因为讨论始于“用ADT提升Java生产力”,而ClassCastException除了让我血压飙升外,实在谈不上提升任何效率):

                  看来得等Java 133才能用上实例化泛型了。

                  • 是啊,Java的泛型确实还有待改进。据传在Valhalla项目之后,Java语言维护者可能会在未来加入实例化泛型。不过我觉得当前Java的抽象数据类型和泛型在多数场景下还算够用。

                    不过由于其默认可空的类型系统和向后兼容性,若试图将Java的函数式编程与抽象数据类型混合使用,同时又涉及空值操作,确实会埋下不少陷阱。

                    关于你的代码示例,可以这样做避免显式强制类型转换:

                      sealed interface Result<T,E> {
                          record Ok<T,E>(T value) implements Result<T,E> {}
                          record Error<T,E>(E error) implements Result<T,E> {}
                          public static <T,E> Object eval(Result<T,E> res) {
                              if (res instanceof Error<T,E>(E e)) // 类似Rust的if let语句
                                  System.out.println(e);
                              return switch (res) {
                                  case Ok(T v) -> v;
                                  case Error(E e) -> e;
                              };
                          }
                      }
                    

                    instanceof的新“模式匹配”特性确实能巧妙规避愚蠢的ClassCastException。

                  • 由于你的英语阅读理解忽略了我讨论的是空值处理改进的事实。

                    以你的专家水平,应该清楚大多数面向消息的语言在实现中并不支持实例化多态性,对吧?

                    • 由于你的英语阅读理解忽略了我讨论的是空值处理改进的事实。

                      谢谢 😛 我从未声称自己是英语母语者。

                      C# 8.0与C# 14.0在空值语义上是否存在实质性变更?我遇到的难题涉及复杂游戏引擎的运行时反射、依赖注入、代码生成等机制。

                      我也从未自诩为ML专家。但无论是否具象化,都无法改变我的观点:Java中的抽象数据类型(ADT)与泛型设计如出一辙,都像是事后补救的产物。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注


京ICP备12002735号