降低Rust的学习难度的方法

我发现人们在学习Rust时总是重复犯同样的错误。以下是我关于如何简化学习过程的看法(按重要性排序)。我的目标是帮助你节省时间并减少挫折感。

放下戒备

停止抵触。这是最重要的教训。

接受学习 Rust 需要采用与您习惯的完全不同的思维模式。有许多新概念需要学习,比如生命周期、所有权和特性系统。根据您的背景,您还需要将泛型、模式匹配或宏添加到列表中。

你的学习速度与你是否聪明或是否有丰富的编程经验关系不大。相反,更重要的是你对这门语言的态度。

我见过没有接受过任何培训的初级开发者在Rust上表现出色,而资深工程师却可能挣扎数周/数月,甚至完全放弃。把你的傲慢留在家里。

元素周期表

将借用检查器视为合作者,而非对手。这重新定义了关系。让编译器来教学:例如,这在生命周期方面效果很好,因为编译器会告诉你生命周期不明确时。然后只需添加它,但花时间思考_为什么_编译器无法自行解决。

fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

如果你尝试编译这个,编译器会要求你添加生命周期参数。它提供这个有用的建议:

1 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
  |           ++++     ++          ++          ++

因此你无需猜测编译器想要什么,只需遵循其指示。但也要坐下来思考为什么编译器无法自行推断出来。

大多数情况下,与编译器对抗实际上暴露了设计缺陷。同样,如果你的代码变得过于冗长或看起来丑陋,很可能有更好的实现方式。承认失败并学会以Rust的方式去做。

如果你来自 Python 这样的动态语言,你会发现 Rust 总体上更冗长。其中大部分只是来自类型注释。有些人可能会认为 Rust “不够优雅”或“丑陋”,但这种冗长实际上有着良好的目的,并且对构建大型应用程序极为有用:

  • 首先,你阅读代码的频率远高于编写代码,因此类型注释能为你提供更多本地上下文进行推理。
  • 其次,它极大助力重构,因为编译器能在你移动代码时检查是否破坏了现有逻辑。如果你的代码最终看起来非常丑陋,不妨退一步思考是否存在更简洁的解决方案。不要立即否定这门语言。

从第一天起就启用所有clippy lints——即使是那些挑剔的规则。运行代码检查工具并严格遵循其建议。即使程序编译通过,也不要跳过这一步。

抵抗是徒劳的。你拒绝学习的时间越长,受苦的时间就越长;但当你放下戒备的那一刻,就是你开始学习的时刻。忘记你认为自己了解的编程知识,真正开始倾听编译器、标准库和Clippy试图告诉你的一切。

Baby Steps

我确实试图在学会走路之前就跑步。仅此一项就让我浪费了大量宝贵时间。

在刚开始时不要对自己要求太高。以下是一些建议:

  • 尽情使用Stringclone()unwrap;你随时可以稍后重构——而重构正是Rust最棒的部分!我曾撰写过一篇关于如何在该阶段节省时间的文章。
  • 在开始学习一些更具Rust风格的.and_then等组合器之前,先使用简单的if或match语句
  • 在第一周避免使用异步Rust。额外的规则对仍在学习核心所有权模型的学习者来说是一种负担。

不要一次引入太多新概念!相反,在学习新概念时,打开编辑器并写出几个示例。有帮助的是在 Rust playground 中编写一些代码并尝试让它编译通过。编写非常小的代码片段(例如,一个 main.rs 文件对应一个概念),而不是使用一个大型的“教程”仓库。养成将大部分代码丢弃的习惯。

我至今仍这样做,并在 playground 或与客户脑暴时测试想法。

例如,这是我最喜欢的解释所有权概念的代码片段:

fn my_func(v: String) {
    // do something with v
}

fn main() {
    let s = String::from("hello");
    my_func(s);
    my_func(s); // error, but why?
}

你能修复它吗?你能解释它吗?问自己如果 vi32 的话会有什么变化。

如果Rust代码让你感到害怕,分解它。先写一个更简单的版本,然后逐步增加复杂度。Rust更容易编写而非阅读。通过大量编写Rust代码,你也会逐渐学会更好地阅读它。

保持准确

你做任何事情的方式,就是你做所有事情的方式。——古 Rust 谚语

在其他语言中你可以粗心大意,但在 Rust 中不行。这意味着你在编写代码时必须准确无误,否则代码根本无法编译。这种做法的预期是,它将帮助你在未来节省调试时间。

我发现,那些学习 Rust 最快的人都非常注重细节。如果你只是想尽快完成任务并继续前进,那么你将会比那些第一次就力求做对的人遇到更大的困难。如果你在按下“编译”按钮之前重新阅读代码以修正愚蠢的拼写错误,你将会有更好的体验。同时,养成在必要时自动添加 &mut 的习惯。

一个在编码时注重这些细节的典型例子是Tsoding。例如,观看他从零开始用Rust构建搜索引擎的直播即可明白我的意思。只要你全力以赴并给予足够时间,我认为你可以掌握这项技能。

不要作弊

借助当今的工具,将大部分工作交给计算机处理非常容易。起初,你会觉得进展很快,但实际上,你只是在强化工作流程中的不良习惯。如果你无法向他人解释自己编写的代码,或者不清楚代码中某些部分所做的权衡与假设,那就说明你走得太远了。

这种做法往往源于对进展不够快的焦虑。但你无需向他人证明自己足够聪明,能迅速掌握Rust。

付诸行动

要真正掌握Rust,你必须亲手编写大量代码。不要只是在Reddit上当旁观者,阅读他人的成功故事。要全身心投入!投入时间,因为没有灵丹妙药。一旦成功,即使你知道代码并不完美,也考虑将其开源。

不要使用自动驾驶模式

大语言模型(LLMs)就像驾驶汽车时使用自动驾驶模式一样。起初感觉很舒适,但你会感到无法控制,慢慢地,一种不安的感觉会悄悄袭来。在学习过程中,请关闭自动驾驶。

快速取得成功的秘诀是,先在 Rust Playground 中编写代码进行学习。不要使用大语言模型(LLMs)或代码完成功能。只需输入代码即可!如果你做不到,那就意味着你还没有完全理解这个概念。没关系!前往标准库并阅读文档。花多长时间都行,然后回来再试一次。

慢就是稳,稳就是快。

建立肌肉记忆

编程中的肌肉记忆被严重低估。人们会告诉你这是代码补全的功能,但我认为这是达到流畅状态的必要条件:如果你不断在语法错误上犯错,或者更糟糕的是,只是等待下一次自动补全来继续,那将是一种糟糕的开发体验。

手动编写代码时,你会犯更多错误。拥抱它们!这些错误将帮助你理解编译器输出。你会对不同错误场景下的输出结果产生“感觉”。不要忽视这些错误。随着时间推移,你会培养出对“粗糙”输出结果的直觉。

预测输出

我喜欢做的一件事是进行“预测练习”,即在运行代码前猜测它是否能编译通过。这有助于培养直觉。尽量在运行程序前确保其没有语法错误。不要马虎。当然,你并不总能成功,但随着时间的推移,你会变得越来越擅长。

先尝试自己解决问题,然后再查找解决方案。

多阅读他人的代码。我推荐ripgrep,例如,这是目前最好的Rust代码之一。

培养健康阅读/编写代码的习惯。

不要害怕动手实践。你避开Rust的哪些领域?你逃避什么?专注于这些方面。解决你的盲点。追踪你常用的“逃生通道”(如unsafe、clone等),以识别当前的弱点。例如,如果你害怕过程宏,那就多写一些。

打破你的代码

完成一个练习后,打破它!看看编译器说什么。看看你是否能解释发生了什么。

学习时不要使用他人的库

一个糟糕的个人版本比一个完美的外部库更好(至少在学习阶段)。自己编写一些小型库代码作为练习。值得注意的例外可能是serdeanyhow,它们可以帮你节省处理JSON输入和设置错误处理的时间,让你能将时间花在其他任务上,只要你了解它们的工作原理。

培养良好的直觉

像生命周期这样的概念很难理解。有时画出数据在系统中流动的方式会有所帮助。养成通过绘图向自己和他人解释概念的习惯。我不确定,但我认为这对“视觉型”/创造型的人最有效(与高度分析型的人相比)。

我个人使用excalidraw进行绘图。它有一种“漫画风格”的氛围,这让它显得不那么严肃。这意味着它并不追求高度准确性,而是作为一个粗略的草图。许多优秀的工程师(以及伟大的数学家和物理学家)都能通过草图来可视化概念。

在Rust中,草图可以帮助可视化数据的生命周期和所有权,或者用于架构图。

基于你已掌握的知识进行构建

之前我提到过,你应该忘记关于编程的一切。那么现在我又如何能主张你应该基于已掌握的知识进行构建呢?

我的意思是,Rust 在一些熟悉的领域(如控制流处理和值传递)与其他语言差异最大。例如,Rust 中可变性非常明确,调用函数时通常会“移动”其参数。这就是你必须接受 Rust 就是“不同”的地方,并从“第一性原理”开始学习。

然而,将 Rust 概念映射到你已经了解的其他语言是可以的。例如,“trait有点像接口”是不正确的,但这是理解这个概念的一个很好的起点。

以下还有几个例子:

  • “struct就像类(但没有继承)”
  • “closure就像lambda函数(但可以捕获变量)”
  • “module就像命名空间(但更强大)”
  • “借用类似于指针(但只有一个所有者)”。

如果你有函数式编程背景,那么可能是:

  • Option 类似于 Maybe 单子”
  • “Traits 类似于类型类”
  • “Enums 是代数数据类型”

这个想法是,映射概念有助于更快地填补空白。

将你从其他语言(如 Python、TypeScript)中已知的概念映射到 Rust 概念。只要你知道存在细微差异,我认为这很有帮助。

我很少看到有人提到这一点,但我认为 Rosetta Code 是为此提供的一个绝佳资源。你基本上可以浏览他们的任务列表,选择一个你喜欢的任务,然后开始将Rust的解决方案与你最擅长的语言进行比较。

此外,将你熟悉的语言的代码移植到Rust中。这样,你不需要在学习Rust的同时学习一个新的领域。你可以利用你现有的知识和经验。

  • 将你最擅长语言中的常见语言惯用表达翻译成 Rust。例如,如何将 Python 中的列表推导式转换为 Rust?先尝试自己实现,然后查找解释该概念的 Rust 资源。例如,我专门写过一篇关于这个主题的文章。
  • 我认识一些人,他们会将几个标准练习移植到他们学习的每种新语言中。例如,这可能是光线追踪器、排序算法或小型网络应用程序。

最后,找到与你有相同背景的人。阅读他们关于学习 Rust 经验的博客。同时记录下你的经验。

不要猜测

我发现那些倾向于通过猜测来解决问题的学习者,往往在学习Rust时遇到最大困难。

在Rust中,细节至关重要。不要忽视细节,因为它们总能揭示与当前任务相关的智慧。即使你对细节不感兴趣,它们也会在未来给你带来麻烦。

例如,为什么你需要对一个已经是字符串的东西调用 to_string()

my_func("hello".to_string())

这些绊脚石都是学习机会。虽然提出这些问题看似浪费时间,且会延长任务完成时间,但从长远来看是值得的。

真的要仔细阅读编译器输出的错误信息。大家都以为自己会这么做,但一次又一次地看到有人一脸茫然,而解决方案就明明白白地显示在终端上。还有一些“提示”;不要忽视它们。仅此一项就能为你节省大量时间。以后再感谢我吧。

你可能会说,这对于每种语言都是如此,而你说的没错。但在Rust中,错误信息实际上值得你花时间去研究。其中一些错误信息就像是短暂的冥想:让你有机会从更深层次思考问题。

如果你遇到任何借用检查器错误,请抑制住猜测错误原因的冲动。不要盲目猜测,而是要_手动追踪数据流_(谁在何时拥有什么)。尽量自己思考清楚,只有在理解问题后再尝试重新编译。

借助类型驱动开发

Rust 代码质量的关键在于其类型系统。

一切都体现在类型系统中。你所需的一切都明明白白地展现在眼前。但很多人往往跳过太多文档,只看示例。

很少有人会去_阅读实际函数的文档_。你甚至可以点击标准库一直到源代码,阅读他们所使用的内容。这里没有魔法(而这正是它如此神奇的地方)。

在 Rust 中,你可以比在大多数其他语言中做得更好。这是因为例如 Python 是用 C 语言编写的,这要求你跨越语言边界来了解正在发生的事情。同样,C++ 标准库并不是一个单一的、标准化的实现,而是由不同组织维护的多个不同实现。这使得了解 确切发生了什么 变得非常困难。在 Rust 中,源代码直接包含在文档中。充分利用这一点!

函数签名透露了大量信息!你越早接受这些额外信息,就越能快速掌握Rust。如果有时间,阅读标准库文档中有趣的部分。即使过了多年,我每次阅读时仍能学到新东西。

尽量用类型首先建模你的项目。这就是你开始真正享受这门语言的时候。这感觉就像你与编译器就你试图解决的问题进行对话一样。

例如,一旦你了解了表达式、迭代器和特性等概念是如何相互配合的,你就可以编写更简洁、更易读的代码。

一旦你学会了如何在类型中编码不变量,你就可以编写更正确的代码,而无需运行测试。相反,你根本无法编译不正确的代码。

通过“类型驱动开发”学习Rust,让编译器错误引导你的设计。

花时间寻找优质学习资源

在开始之前,先寻找适合你个人学习风格的资源。说实话,目前市面上优质资源还不多。不过,在确定具体平台/书籍/课程之前,浏览资源列表并不需要太长时间。合适的资源取决于你是什么类型的学习者。从长远来看,找到合适的资源能节省时间,因为你能更快地学习。

我个人不喜欢做别人为我准备的练习题。这就是为什么我对Rustlings不太感冒;这些练习题不够“有趣”且过于理论化。我更希望有更多实践性的练习。我发现Project EulerAdvent of Code对我来说效果好得多。这个问题经常被提起,所以我写了一篇博客文章,介绍了我最喜欢的Rust学习资源。

不要只看YouTube

我喜欢看YouTube,但仅限于娱乐目的。在我看来,观看ThePrimeagen只是为了娱乐。他是一位了不起的程序员,但试图通过观看别人编程来学习编程,就像试图通过观看奥运会来学习如何成为一名伟大的运动员一样。同样,我想我们都能同意Jon Gjengset是一位卓越的程序员和教师,但如果你刚开始学习,观看他的内容可能会让人感到压力山大。(不过内容真的很棒!)

同样,对于会议演讲或播客也是如此:它们对于理解上下文和培养软技能非常有用,但不适合学习Rust。

相反,如果你能做到的话,投资一本好书吧。书籍尚未过时,你可以离线阅读,添加个人笔记,自己输入代码,并通过翻阅页面获得对内容深度的“空间概述”。

同样地,如果你打算认真地将Rust用于专业工作,建议你购买相关课程或让老板投资聘请培训师。当然,我在此处有明显偏好,因为我经营一家Rust咨询公司,但我真心认为这将为你和公司节省无数时间,并为长期成功奠定基础。想想看:你将与这个代码库共事多年。不如让这段经历变得愉快些。一位优秀的培训师,就像一位优秀的老师,不会和你一起逐字逐句地阅读Rust书籍,而是会观察你在实际编程中的表现,并针对你的薄弱环节提供个性化的反馈。

寻找编程伙伴

“跟随”经验更丰富的团队成员或朋友。

不要害怕在Mastodon或Rust论坛上请求代码审查,并反过来为他人提供代码审查。抓住机会进行配对编程。

向非Rust开发者解释Rust代码

这是检验你是否真正理解某个概念的绝佳方式。不要害怕说“我不知道”。然后一起去文档中寻找答案。这样做既更有成就感,也更诚实。

帮助维护被遗弃的开源代码。如果你付出努力修复一个无人维护的代码库,你不仅能帮助他人,还能学会如何与他人的Rust代码协作。

大声朗读代码并解释它。这没什么好害羞的!它能帮助你“序列化”思维,避免遗漏重要细节。

做笔记。编写自己的“Rust术语表”,将Rust术语映射到业务领域的概念。它不必完整,只需满足你的需求即可。

记录你觉得困难的内容和学到的知识。如果你发现优秀的学习资源,请分享出来!

相信长期收益

如果你学习Rust只是为了把它写进简历,那就停下来。去学其他东西吧。

我认为你必须真正喜欢编程(而不仅仅是编程的概念)才能享受Rust。

如果你想在Rust上取得成功,你必须长期投入。设定现实的期望:你不会在一周内成为“Rust大师”,但你可以通过一个月专注的努力取得很多成就。没有捷径可走,但如果你能避免最常见的自掘坟墓的方式,你就能更快地掌握这门语言。Rust是一门需要时间积累的语言。你不会像在Go或Python的第一周那样“感觉”到高效,但坚持下去,它会带来回报。祝你好运,享受学习的过程!

本文文字及图片出自 Flattening Rust's Learning Curve

共有 398 条讨论

  1. 这就像在读迪杰斯特拉的《编程的艺术》。当时需要这种道德剧式的做法,因为没有人知道如何思考这些问题。

    Rust 中关于所有权的大多数解释都过于冗长。参见 [1])。核心概念大多存在,但被所有示例所掩盖。

        - Rust中的每个数据对象都有且仅有一个所有者。
          - 所有权可以以不违反“单一所有者规则”的方式转移。
          - 若需多所有权,真实所有者必须是引用计数单元。
            这些单元可被克隆(复制)。
          - 若所有者消失,其所拥有的对象也会消失。
    
        - 你可以使用引用借用数据对象的访问权限。
          - 拥有和引用之间存在重大区别。
          - 引用可以传递和存储,但不能比对象更长寿。
            (否则会引发“悬空指针”错误)。
          - 借用检查器会在编译时严格执行此规则。
    

    这解释了模型。一旦理解了这一点,所有细节都可以追溯到这些规则。

    [1] https://doc.rust-lang.org/book/ch04-01-what-is-ownership.htm

    1. 可能是我的学习能力有限,但我发现很难理解这样的解释。我对封装的解释也有类似的感受:它会说我可以隐藏信息,但没有详细说明。为什么,从谁那里?如果我可以在屏幕上看到它,那它怎么能被隐藏呢?

      同样,我无法理解例如“谁”是所有者。它是栈帧吗?为什么栈帧要将所有权转移给其调用者,毕竟由于LIFO特性,调用者栈总会先被销毁,因此在调用者返回前持有它并无危险。这是为了优化,以便更早销毁对象吗?所有者是否可能是栈帧以外的其他对象?为什么可变引用只能传递一次?如果我只使用单线程,一个函数肯定会在另一个开始之前完成,那么将可变引用传递给两者有什么害处?当我实际使用多线程时再打我的手吧。

      当然,所有这些都有原因,而且它们可能并不难理解。不知何故,每次我想深入了解Rust时,我都会追逐这些问题,然后稍后放弃。

      1. _> 为什么栈帧要将所有权转移给其调用者?

        Rust 的所有权和借用系统本质上允许你分配数据访问的“权限”。所有者拥有最高权限,包括分配引用(授予较低权限)的能力。

        在某些情况下,这些权限对性能确实有用。所有者有权立即销毁某物以释放内存。它还拥有“移出”数据的权限,这允许你避免不必要的复制。

        但它也有其他用途。例如,线程不遵循栈纪律;被调用者不保证在调用者返回前终止,因此将发送到另一个线程的数据的所有权传递出去对正确性至关重要。

        当然,将所有权传递给更高栈帧(从被调用者到调用者)的能力对于正确性也是必要的。

        在实际中,人们会编写需要最少权限的函数。被调用者通常会获取引用而非获取所有权,因为他们所做的事情根本不需要所有权。

      2. 我认为你的评论已经得到了很好的回复。然而,到目前为止,没有人真正回答你的问题:

        > _谁_是所有者。是栈帧吗?

        我认为将栈帧称为所有者在借用检查器的意义上并不 helpful。如果所有者是栈帧,那么为什么它需要将对象借用给自己?以下代码无法编译的事实似乎支持这一观点:

            fn main() {
                let a: String = “Hello”.to_owned();
                let b = a;
                println!(“{}”, a);  // error[E0382]: borrow of moved value: `a`
            }
        

        用户 lucozade 的评论指出,对象所在的 内存 实际上是 被拥有的对象。因此,它也不能是所有者。

        那么,如果 neither a) 栈帧 nor b) 对象所在的内存都不能被称为 Rust 意义上的所有者,那么什么是所有者?

        所有者是否可以是某个时间点上与被拥有的内存块绑定的变量?在我目前的思维模型中,是的。这与我目前理解的所有借用检查器语义是一致的。

        如果我的理解有误,请随时指正。

        1. 我认为这个答案是正确的。所有权存在于语言层面,而非机器层面。将栈的一部分或内存的一块视为拥有某物是不正确的。在Rust中,语言实体(如变量)才是拥有其他对象的实体。当该对象作用域结束时,其资源会被释放,包括它所拥有的所有内容。

          1. 我觉得很有趣的是,我通过实践对Rust的所有权有了一种“清晰”的理解,但反复追问“为什么”会让这种理解的清晰度出现一些漏洞。这主要是因为我对C++和RAII的概念以及解决一些所有权问题有一定的熟悉度。这有点像当别人问你一个单词的定义时,你知道它的意思,但你却无法准确解释它。

            我认为你说的没错,所有权确实只存在于语言层面。回到文档:https://doc.rust-lang.org/book/ch04-01-what-is-ownership.htm

            第一个提供线索的部分是:

            >Rust采用第三种方法:内存通过一套所有权规则进行管理,这些规则由编译器进行检查。

            这显然意味着所有权是 Rust 语言中的一个概念,由编译器检查的一组规则定义。

            后续内容:

            >首先,让我们看看所有权规则。在我们通过示例来阐述这些规则时,请记住这些规则:

            >

            >*Rust 中的每个值都有一个所有者*。

            >同一时间只能有一个所有者。

            >*当所有者超出作用域时*,值将被释放。

            因此,所有者可以超出作用域,这导致值被释放。同时,每个值都有一个所有者。

            因此,我们可以得出结论。所有者可以超出作用域,因此所有者是作用域内存在的事物。可能是变量声明?在文本的后续部分,这一点似乎得到了证实。变量可以是所有者。

            >Rust 采取了不同的路径:一旦拥有该值的变量作用域结束,内存将自动释放。

            好的,所以变量可以拥有值。而借用的变量(引用)由它们借用的变量拥有,这一点似乎很清楚。我们可以一直递归下去。那么向上呢?谁拥有变量?我猜是程序或作用域,而作用域又由程序拥有。

            因此,我认为变量直接拥有值,引用由它们借用的变量拥有。所有变量都由程序拥有,并且在作用域内存在(这在程序级别上是存在的)。

          2. > 所有权存在于语言层面,而非机器层面。

            没错。这是关键所在。“移动语义”可以让你将某物从栈移动到堆,或从堆移动到栈,前提是严格遵守大量繁琐的规则。这种操作非常常见。你可以在栈上创建一个结构体,然后将其压入向量,以在末尾追加。这没有问题。数据必须被复制,而语言会自动处理这一点。它还会防止你在结构体不支持安全移动复制时执行此操作。

            C++现在也支持“移动语义”,但由于历史原因,其强制执行力度不足以阻止不应允许的移动操作。

      3. > 为什么可变引用只能分配一次?

        以下是一个单线程程序,如果Rust允许对正在被修改的数据分配多个引用(可变或不可变),该程序将出现悬空指针:

            let mut v = Vec::new();
            v.push(42);
            
            // 第一个元素的地址:0x6533c883fb10
            println!(“{:p}”, &v[0]);
            
            // 在 v 之后的堆上放置一些内容
            // 因此它无法就地扩展
            let v2 = v.clone();
            
            v.push(43);
            v.push(44);
            v.push(45);
            // 超出容量并触发重新分配
            v.push(46);
            
            // 首个元素的新地址:0x6533c883fb50
            println!(“{:p}”, &v[0]);
        
        1. 在几乎所有现代编程语言中,类似的程序都不会出现此问题,尽管多引用被随意允许。

          要安全地引用向量中的单元,我们需要一个“定位”对象来跟踪_v_和v中的偏移量0。

          1. > 在几乎所有现代编程语言中,类似的程序都不会出现问题,尽管允许多个引用。

            每次底层数据移动时,程序的运行时要么需要动态查找所有指向该数据的指针,然后遍历所有指针以指向新位置,要么需要引入另一层间接引用(甚至更糟的是,可以使用链表)。许多语言在某些领域并不介意承担这样的运行时开销,但Rust试图在尽可能快的同时保持尽可能高的内存安全性。

            换句话说,选择你的毒药:

            1. 允许可变数据,但不支持直接内部引用。

            2. 允许内部引用,但不允许可变数据。

            3. 允许可变数据,但仅允许间接/动态调整的引用。

            4. 允许可变数据和直接内部引用,强制作者手动确保内存安全。

            5. 允许可变数据和直接内部引用,通过静态分析确保安全,仅允许在变异不会使引用失效时持有引用。

          2. 这是另一种实现方式,在Rust中也可以实现。

        2. > // 在 v 之后的堆上放置一些内容

          > // 因此它无法就地增长

          > let v2 = v.clone();

          我怀疑 Rust 保证“在 v 之后的堆上放置一些内容”的行为。

          堆的整个想法是,你放弃对分配位置的控制,以换取一种轻松分配、释放和重用内存的方式。

          1. 它当然不保证这一点,这只是在特定情况下诱发重定位所需的条件。但这使得Rust的所有权跟踪更加重要,因为在C++等语言中,这种情况可能会“意外工作”,但一旦未来任何更改扰乱了堆,或向vec中添加足够多的元素以触发重定位,就会立即引发问题。

      4. > 为什么栈帧要将所有权转移给被调用函数,毕竟由于 LIFO 特性,被调用函数的栈帧总是先被销毁,因此在被调用函数返回前持有它并无危险。

        这确实需要一段时间适应,但确实存在某些场景下你希望某个对象将所有权转移到被调用函数中,而扩展它则是不正确的。

        例如,如果它代表某项只能执行一次的操作,如删除文件。一旦执行完毕,你就不想再能重复执行该操作。

      5. > 拥有者可以是栈帧以外的其他东西吗?

        是的。对象可能被拥有的方式有很多:

        – 栈上的局部变量

        – 结构体或元组的字段(该结构体或元组本身可能在栈上被拥有,或嵌套在另一个结构体中,或属于以下其他选项之一)

        – 堆分配容器,最常见的基本数据结构如Vec或HashMap,也包括Box(C++中的std::unique_ptr)、Arc(std::shared_ptr)和通道等

        – 静态变量——需注意在Rust中这些变量始终为常量初始化且永不销毁

        我肯定还有其他我没有想到的情况。

        > 为什么栈帧想要将所有权转移给其调用者,毕竟由于 LIFO 的特性,调用者的栈总是会被先销毁

        以下是一些在 Rust 中“按值传递”的示例情况:

        – 你可能在处理“Copy”类型,如整数和布尔值,在许多常见情况下,值类型更易于操作(与C、C++或Go类似)。

        – 你可能将某个对象插入到一个将拥有它的容器中。也许被调用方通过其他参数获得该容器的引用,或者被调用方是包含容器的结构体类型上的方法。

        – 你可能需要将所有权传递给另一个线程。例如,我的程序中的 main() 循环可以监听一个套接字,对于每个获得的连接,它可能会创建一个工作线程来拥有该连接并处理它。(从所有权的角度来看,使用 async 和“任务”基本上是相同的。)

        – 你可能在处理一种使用所有权来表示除内存以外其他事物的类型。例如,拥有一个 MutexGuard 允许你通过释放守护对象来解锁互斥锁。通过值传递 MutexGuard 告诉调用方“我已经获取了这个锁,但现在你负责释放它”。有时人们还使用非可复制枚举来表示需要通过值传递的复杂状态机,以保证状态转换中他们关心的属性。

      6. > 为什么栈帧想要将所有权转移给调用方

        在现代编程中这种情况很常见:

        callee(foo_string + “abc”)

        参数表达式 foo_string + “abc” 构造了一个新字符串。这里没有变量捕获该字符串;它被传递给调用方。只有调用方知道这一点。

        这种情况可能暴露运行时垃圾回收系统中的漏洞。如果被调用方是用低级语言编写的,且负责向垃圾回收器标记“已固定”对象,但它忘记标记参数对象,垃圾回收器可能会过早回收该对象,因为图像中其他部分都不知道该对象的存在:只有被调用方知道。在 callee(foo_string) 这种情况下,该错误不会显现,因为调用方仍持有 foo_string 的引用(至少在该变量为活动状态且有后续使用时)。

      7. > _who_ 是所有者。它是栈帧吗?

        已分配的内存可能位于栈帧中,也可能是堆内存。它甚至可能位于内存映射二进制文件中。

        > 为什么栈帧要将所有权转移给其调用者

        因为它希望将全部责任移交给程序的其他部分。假设你在堆上分配了一些内存,并将引用传递给被调用者,然后被调用者返回给你。他们是否释放了内存?他们是否将引用传递给另一个线程?他们是否将引用传递给一个你无法访问其代码的库?因为对这些问题的回答将决定你是否可以安全地继续使用你拥有的引用。包括但不限于,你是否可以安全地释放内存。

        如果你将所有权传递给被调用方,你根本不需要关心这些问题,因为在被调用方返回后,你无法再使用对该对象的引用。编译器会强制执行这一点。现在,被调用方在理论上可以将同一内存的所有权交还给你,但如果它这样做了,你就知道它没有销毁该数据,否则它无法将数据交还给你。同样,编译器会强制执行所有这些规则。

        > 为什么可变引用只能传递一次?

        假设你有两个指向类型 T 的数组的引用,你想从一个数组复制到另一个数组。它会做你期望的事情吗?如果它们是不同的,它可能会这样做,但如果它们重叠呢?memcpy 有这个问题,并通过将重叠的复制定义为未定义来“解决”它。在单一可变引用系统中,这种情况不可能发生,因为如果存在两个重叠的引用,你无法向其中任何一个写入数据。如果你能向其中一个写入,那么另一个必须是另一个对象的引用(可变或不可变)。

        如果知道两个对象是不同的,还存在优化机会。这就是 C 语言添加 restrict 关键字的原因。

        > 如果我只使用单线程

        如果你只是编写小型脚本或其他类似内容,那么这些内容可能过于复杂。但如果你在编写库、大型应用程序、多开发系统等,即使你使用单线程,谁又能保证系统中的每个部分在任何时候都是单线程的呢?人们通常在这种长远规划上做得非常糟糕。这就是这些更自动化的方法大放异彩的地方。

        > 隐藏信息……为什么,从谁那里?

        主要原因是您希望向系统其他部分暴露一个特定的契约。例如,您可能需要维护不变量,如双重记账,或确保正方形的边长相等。或者,您可能希望指定一个高级算法,如矩阵求逆,但希望它适用于多种矩阵实现,如稀疏矩阵、正方矩阵等。在这些情况下,你希望消费者能够通过标准接口使用你的对象,而无需了解或关心实现细节。换句话说,你正在通过接口隐藏实现细节。

    2. 这并不是在解释所有权,而是在解释其动机。这没问题。难以解释和学习的是如何阅读涉及<'a, 'b>(…) -> &'a [&'b str]或类似内容的函数签名。以及如何理解并修复调用此类函数时编译器报错的原因。

      1. 这与 C++ 中的 std::unique_ptr 有很大不同吗?

        我认为《Rust 语言指南》过于冗长,但我喜欢《全面掌握 Rust》:https://google.github.io/comprehensive-rust/

        我感觉通过粗略阅读理解了书中的内容,但尚未尝试实际使用。

        1. >它与C++中的std::unique_ptr有很大不同吗?

          了解C++是先决条件吗?

          1. 根据我的经验,教学生学习Rust时,如果他们先掌握了C++,反而会成为学习的障碍,因为他们需要摆脱许多C++的编程习惯。这些学生在使用借用检查器时会遇到更多困难,因为他们已经形成了一些关于代码“应该”如何编写的固有观念。

            1. 如果他们从未学习过现代之前的 C++,并且习惯于使用所有的 std::foo_ptr,并期望“三规则”(或五规则)能自动处理,这种情况是否仍然成立?

              1. 我的Rust课程的先修课程是Java,但来我这里的学生有三种类型:

                1. 只知道Java的学生

                2. 知道Java并且由我教过C++的学生。我教这门课的方式是,他们对前现代C++非常熟悉,因为我们也学习C。

                3. 知道Java和C++但他们是自学成才的学生。

                最后一个群体遇到的困难最大。据我观察,他们遇到的具体问题在于对共享可变状态的理解。他们习惯于像分发糖果一样随意分配可变状态的指针,却不担心竞争条件,也不在意各种可能导致漏洞的内存错误。他们编写的代码难以重构或模块化。他们倾向于将所有内容都放在一个头文件或一个 main.cpp 文件中,因为他们无法理解链接错误的原因。

                因此,当他们尝试用Rust以这种方式编写代码时,他们遇到的第一个问题就是与他们随意共享状态相关的借用错误,他们无法理解为什么不能像以前那样编写代码,因为在他们有限的经验中,这种方式一直运作良好。

                从教育角度来看,我必须先消除他们这些习惯,然后从头开始重建他们的知识体系。

                1. > 因此,当他们尝试以这种方式在Rust中编写代码时,他们首先遇到的就是与他们随意共享状态相关的借用错误,他们无法理解为什么不能像以前那样编写代码,因为在他们有限的经验中,这种方式一直运作良好。

                  啊,真可惜他们没有先看到C++代码的失败测试结果 😉

          2. 我认为了解C++的无用特性是学习Rust的先决条件。这又是一个由博士委员会设计的C++替代品。

            你可能会认为他们足够聪明,意识到一种需要X小时才能学会的语言是语言本身的缺陷,而不是用户的问题,但现代教育更注重培养专业技能而非通用智力。

            1. 某些语言的目标可能是易于学习。但大多数“系统”语言专注于帮助设计良好的软件,其中“良好”可能意味着可靠、可维护或高性能。

              编写优质软件通常并不容易。特定语言的学习曲线通常只是所需努力中的一小部分。

            2. 没错,但Rust本应是一种系统语言。我们已经拥有数十种易于学习的语言。编译时内存管理的存在正是为了满足系统语言的约束条件……

              我认为它并不比学习 C 或 C++ 难,而 C 和 C++ 是唯一可与之媲美的主流语言。

              1. 我认为它实际上更容易,这要归功于 Cargo 和 Crates 生态系统。对于学生来说,最难的一些事情只是构建和链接代码,尤其是第三方库。

                我开设了两门中级编程课程,一门教授 C++,另一门教授 Rust。在Rust课程中,第一周学生就能编写代码并使用第三方库;而在C课程中,我们花大量时间处理链接器错误、包含错误、段错误等。C/C++的学习曲线会迅速变得陡峭。但Rust的学习曲线实际上相当平缓,直到需要涉及借用时,甚至可以通过clone()函数推迟对借用的理解。

                在C++课程结束时,学生的最终项目是一个文件服务器,他们可以在14周内完成。

                在Rust课程中,最终项目是一个实现LSP的服务器,其中还包括他们设计的语言的解释器。这个项目的提交通常比C++课程的提交要健壮得多,我认为这种差异源于语言的设计。

        2. > 它与 C++ 中的 std::unique_ptr 有很大不同吗?

          它既相同又非常不同,这取决于你想要深入的细节程度。从概念上讲,它是相同的。严格来说,实现方式在几个关键点上有所不同。

      2. 好消息是,用Rust编写符合规范的干净代码通常不需要频繁依赖此类借用签名。这更多是在你偏离常规并做一些“聪明”的事情时才会出现。

        我知道这会让人们感到困惑,编译器错误也可能令人费解,但实际上,作为签名一部分的显式生命周期比你想象的要少见。

        对我来说,看到大量这样的签名是一种代码臭味。

    3. 将一组概念以一种对理解它们的人来说正确且完整的方式总结出来,比向不理解它们的人解释要容易得多。如果我们将这放在只使用过按共享调用语言的人面前,你认为他们能立即理解吗?我对此表示怀疑。

      1. 对我来说,当我意识到“所有权/生命周期/引用”只是用来描述“何时释放内存”的术语时,一切突然明朗了。可能是因为我有C语言背景,习惯了手动内存管理。Rust基本上会在变量作用域结束时自动调用“free”函数。

        所有这些专业术语确实让我难以把握这个简单核心概念。

        1. 几乎全部。

          Rust 还拥有“单一可变引用”规则。如果你对一个变量拥有可变引用,可以确定此时没有人同时拥有另一个可变引用。(且该值本身不会被修改。)

          从技术上讲,每个变量可以处于三种模式之一:

          1. 直接可编辑(x = 5)

          2. 拥有单个可变引用(let y = &mut x)

          3. 拥有任意数量的不可变引用(let y = &x; let z = &x)。

          编译器始终能识别任何特定变量的模式,因此可证明你未违反此约束。

          若以 C 语言思维,“单个可变引用”规则是 Rust 确保对程序中每个变量强制应用 noalias 约束的方式。

          这是在 Rust IDE 中非常值得期待的功能。无论我的光标位于何处,都希望根据它们在该时刻所处的模式对范围内的所有变量进行颜色编码。

          1. 是的,我认为对于新手来说,`mut` 并不像内存管理方面那么难。

        2. “Rust 基本上会在变量作用域结束时自动调用 free。”

          C++ 通过 RAII 机制也实现了类似功能。你可以随意使用 STL 容器,将对象放入其中,所有对象都会被安全地单一拥有,无需手动分配或释放内存。

          区别在于,C++ 在这方面的保证源于:a) 一系列实现魔法,这些魔法的存在是为了隐藏这样一个事实,即那些看似在栈上分配的容器实际上是在背后分配堆对象,以及 b) 你需要遵守 API 文档中给出的限制,同意不持有成员对象的指针或进行奇怪的类型转换操作。你可以使用 scoped_ptr/unique_ptr,但整个过程中你都会清楚地意识到它是在语言后期附加的,而且随时可以调用 get() 获取底层的“原始”指针并用它来“自掘坟墓”。

          Rust 正式化了这种保护机制并将其集成到编译器中,从而阻止你以“错误”方式操作。

          1. > 一系列实现魔法,旨在隐藏那些看似栈分配的容器实际上在背后分配堆对象的事实

            堆只是分配器支持的内存来源之一。我也曾使用栈空间来实现这一点。还可以使用一个完全静态大小和分配的数组。

          2. 权衡在于,你必须猜测Rust在哪里进行释放,而你可能会出错。最终这将与显式释放指令严格等价,编译器会拒绝编译如果释放位置违反规则。

            Rust选择RAII模式实在令人遗憾。

            1. 无需猜测——Rust有明确的释放顺序。若需覆盖默认顺序,也可手动执行释放。

              1. 抱歉我之前不该用“猜测”一词,我的意思是“考虑”

            2. 你有多常关心对象被释放的顺序?

              1. 任何需要以常数时间运行的场景。

                1. 如果你在硬实时环境中,就不能有任何内存分配;那完全是另一回事。

                  析构函数应该尽可能简单且无副作用,除非是像锁或文件句柄这样的情况,因为很清楚对象退出作用域时会触发释放操作。

            3. 好的,但IDE可以为你可视化这一点。一个代码检查规则可以强制你手动释放,如果你想明确表示的话。

      2. 没错。如果你是从 C++ 转到 Rust,并且能写出好的 C++ 代码,你会把这看作“哦,这就是如何理解所有权”。因为你必须有一个所有权的心理模型才能让 C/C++ 代码正常工作。

        但如果你来自 JavaScript、Python 或 Go,这些语言中所有这些都是自动化的,这会显得非常奇怪。

      3. 上述评论中的列表不是总结——它是精确的定义。它可以且必须通过大量示例、与其他语言的对比等进行仔细解释,但精确的定义本身必须占据突出位置,而示例和直觉应透明地与之相关。

      4. 实际上,我认为这表明学习借用检查器应从理解内存工作原理开始,而非任何特定于Rust的概念。

    4. 而且,当一个不了解Rust的人读完这个简洁漂亮的总结后,他们对Rust仍一无所知。(除了“这个语言的编译器一定有什么黑魔法。”)

    5. 我认为最重要的教训是:

      所有权很简单,借用也很简单,但让这门语言难以学习的是,函数必须具有签名和用法,二者共同证明引用不会比对象存活更久。

      此外:除非确实非常必要,否则最好不要将引用对象存储在类型中,因为这会使证明变得复杂得多。

      1. > 所有权简单,借用也简单

        100%。程序员需要适应这种风格。这绝非难事,只是需要一些调整。

        1. 确实。程序员们对Rust的理解有误。

          1. 你是指刚接触Rust的程序员。

            这有点像职业Java程序员第一次使用JavaScript或Python时,带着他们一贯的编程习惯。

            1. 这不仅仅是习惯问题。Rust的值与引用传递语义与大多数程序员一生中习惯的思维方式完全不同。

              在Rust中,当你向函数传递参数,或将值赋给结构体或变量等时,你是在_移动_它(除非它是Copy)。这与人们习惯的其他编程语言截然不同,在那些语言中,参数传递通常是按值传递或按引用传递,你可以随意这样做,编译器不会在意。这就像在 C++ 中,你对每个参数或赋值都使用 std::move。

              因此,作为程序员,你必须转变思维方式,去思考这种行为。这在最初是极其反直觉的,但随着时间的推移会成为习惯。

              有了这种习惯后,当你回到其他语言工作时,这实际上是一种很好的推理能力。

              1. 我认为有时 Rust 需要一种替代语法,这种语法总体上更冗长(使用关键字而非标点符号等),并且明确规定这种行为。换句话说,对于每个调用的每个参数,你都必须写上“move”或“copy”,赋值等情况也是如此。

                1. RustRover最近添加了代码块内借用过程的可视化指示,这挺不错的。目前似乎只在某些情况下显示,且仅在出现错误时生效?但至少能高亮显示借用发生的位置,让你能回溯查找并修复问题,而无需逐行解析编译错误。

                  我猜他们最终会为用户提供一个快速且实时的指示器,显示所有引用“流向”的位置,随着用户输入代码实时更新。

    6. 这种解释在我看来没有揭示任何有意义的内容,因为它没有定义所有权和借用,这两个术语显然源自与金融资产管理相关的类比。

      我对Rust不熟悉,所以不太清楚,但我想知道措辞是否在概念理解的难度中起到了作用。类比往往是双刃剑。

      也许采用更直接的内存相关术语作为替代的呈现角度会有所帮助?

      1. 我对它的理解大致与C程序的工作方式类似:假设一个堆分配的数据结构,所有者是负责在适当时候释放分配的代码片段。而引用只是一个带有额外编译时元数据的指针,使借用检查器能够证明引用不会比被引用对象存活更久,并且不存在可变别名。

      2. 如果你在CPython或其他具有手动引用计数的程序中工作过,借用这个概念就会出现,即你从程序的另一部分接收一个引用,然后对对象进行操作而不修改引用计数,因为你对地址的任何副本都将是短暂的。这个术语在CPython中随处可见。

      3. 我认为将借用和所有权与金融资产管理联系起来有些奇怪。

        从这个角度来看,确实似乎没有意义。

        我认为,但可能完全错误,从这些动作的通常含义来看更 helpful:你拥有一个玩具,它是你的,你可以随心所欲地使用它。你借用一个玩具,它不是你的,你不能随心所欲地使用它,所以如果你不允许,你就不能保留它,你也不能修改它,原因相同。

        1. 类比往往存在漏洞。

          1. 在现实生活中,我可以从你那里借一个玩具,而当我拿着这个玩具时,所有者可以与其他人交换所有权,而这个玩具正被我借用。也就是说,在现实生活中,借用与所有权是相互独立的。在Rust中,你不能这样做。

          2. 借用一个玩具更类似于Rust中可变引用的工作方式。不可变引用允许多人同时玩同一个玩具,只要他们不改变它。

          类比只是类比

        2. 你所说的“通常意义”指的是什么?也许是“财务”这个词让解释偏离了轨道,但“财务”来自“fidus”,即信任,指的是对现实结果将符合某种心理表征的期望的信任。¹

          “你拥有一个玩具”是孩子在现实中首先被教导为错误的观念,如果没有经过仔细的社会教育的话,对吧?现实是,“你可以在一定时间内玩这个玩具,而与他人分享是唯一能让我们所有人都享受欢乐游戏时刻的方式,而声称对玩具进行无限期独占使用,尽管个人对玩具的注意力是有限的,这种行为在社会上是有害的。”

          记忆作为一个抽象对象,其实际运作方式与玩具截然不同。如果我们能将任何人类可抓握的物体作为记忆载体中的信息进行复制,那么任何经济活动几乎都将成为对人类注意力的浪费。

          ¹ 编辑:实际上我这里搞错了,我一直将“fiduciary”与“fiduciary”混淆。金融一词源自法语“fin”(结束),意为“债务的结束”。

        3. 许多人可以借用你的玩具一睹为快,但只有一个人可以借用并玩耍。而且他们只能在无人注视时玩耍。如果你想用工具修改玩具,它就不再属于你了,它已经转移并归属于工具。

          我想我是在说这个类比在这里用途有限。

      4. 你认为借用割草机或拥有电钻是财务资产管理?

        1. 在最抽象的层面上,甚至“我”和“思考”都是对当前注意力所关注事物的误导性概念。因此,“借用”和“拥有”并不是在这种意义上“思考”的理想起点概念。但在更日常的层面上,处理事物时,这种类比自有其优点(当然也有缺点)。

    7. 这并不能解释模型,因为你完全忽略了独占/共享(或可变/不可变)借用的区别。Rust在允许此类借用方面做出了大量选择,而这些选择既不源于这个简要概述,也不源于直觉或常识。例如,无别名规则并非源于直觉或常识,而是出于优化函数的考虑。

      借用的最复杂方面来自省略规则,这些规则会默默地做错事情,并且会非常有效,直到它们失效为止,此时编译器错误会指向一个函数,抱怨具有 trait 方法的参数的生命周期参数,这意味着该参数必须存在太久,但真正的问题是底层结构中的生命周期或之前损坏的生命周期边界。这些省略规则同样缺乏直观性,无法从你的解释中自然推导出来。它们是为简化程序员工作而做出的设计决策。

    8. 我通常通过将概念映射到物理世界中的物体(如书籍)来教学,我认为这更具直观性。

      我有一本书。我拥有它。我可以阅读它,并在页边空白处做笔记。如果我想撕下页面,也可以这样做。当我不再需要它时,我可以毁掉它。它是我的。

      我可以将这本书以只读模式借给你和其他人。无法进行任何修改。没有人可以写入它,甚至我也不行。但我们都可以阅读它。借阅者可以以只读模式将它递归借给其他人。

      或者我可以将这本书以读写权限独家借给你。除了你之外,没有人可以在这本书上写字。在你借阅期间,没有人可以阅读它,甚至包括我。你可以撕毁书页,但不能毁掉这本书。你可以将它以读写权限独家借给其他人,形成递归借阅。当他们读完,当你读完,这本书将回到我手中。

      我可以将这本书赠予你。在此情况下,它归你所有,你可以随意处置,甚至销毁它。

      若从底层角度思考,共享引用类比甚至描述了计算机中的实际情况。访问共享资源时,没有任何操作是真正并行的。我们需要轮流阅读页面。硬件通过缓存副本快速实现这一点。若不希望他人撕毁页面,可提供一本仅限边栏可写的只读书籍。

    9. 似乎不完整。例如,如果借用者离开会发生什么?

      1. 它就不再被借用了?!这是个什么问题。

        1. 这是一个关于定义的问题。其他可能的选项包括:

              - 对象被销毁
              - 程序发生核心转储
              - 这是编译时错误
          

          假设在信息缺失的情况下,假设最佳结果通常是一种糟糕的策略。

    10. >真正的所有者必须是一个引用计数单元。

      那是什么?很容易陷入解释得非常好的陷阱(如果你已经理解了)。

    11. 我还没开始学习Rust,主要是因为时间和需求,但过去几年我一直在做很多C++开发。

      从这个背景来看,这些规则听起来很棒。过去几年C++也投入了大量工作试图让这些规则更容易执行,但即使使用智能指针,要正确实现仍然很困难。

      1. 主要问题是,许多在生命周期上正确的东西仍然无法编译,因为借用检查器无法证明它们是正确的。即使是相对简单的场景,比如带有后向链接的树,有时也会遇到这种情况。

        1. Rust 成功地让我们对使用原始指针感到犹豫。以至于我们有时会忘记,我们可以按需选择使用不安全的原始指针。与 C/C++ 不同,C/C++ 是选择性地使用可能相对安全的指针。

    12. 我认为布朗大学对 Rust 书籍的修改对解释借用检查器做得很出色。

    13. 我经常想寻找关于 60 年代如何在汇编级别处理系统/应用程序状态的文献。我知道 Sutherland 的 Sketchpad 论文中有大量关于数据结构的细节,但我从未读过(除了 2-3 页)。

    14. 我唯一能理解借用检查器的方式是实现自己的版本。然后它就说得通了。

    15. 我喜欢你的表述方式,但它缺少可变的XOR共享引用。

    16. 根据我的经验,仅仅理解借用检查器的规则并不足以在实际中编写Rust代码。例如,在使用Rust大约6个月后,我曾因试图从可变引用中移动数据而陷入困境。直接通过解引用尝试这样做会导致编译器错误,如“无法从`*the_ref`移动,因为它位于可变引用之后”。如果你了解Rust,你可能会大喊“你这个白痴!你不能从可变引用中移动数据!”或者“你这个白痴!只要使用std::mem::take!”(后者当然是正确的做法),但这些并不是从借用检查器规则中显而易见的。

      我学习Rust的经历就像是被千刀万剐,有太多小而简单的问题,你必须在实际编程中遇到才能理解。没有一套简单的规则能让你为所有这些情况做好准备。

    17. 第二部分的第二个要点承诺过多。事实上,有_许多、许多、许多_种方法可以编写可验证正确的代码,这些代码不会留下悬空指针,但无法通过rustc编译。

      坦率地说,你抱怨的大部分复杂性源于试图精确指定借用检查器可以证明正确的魔法,以及它无法证明的咒语。

      1. Rust 感觉像是一种优秀的语言,搭配一个 beta 质量的借用检查器。问题是,他们修复的细节越多,类型系统就越复杂。

        1. 你的意思是什么?有哪些借用检查器改进导致类型系统复杂性增加的例子?

      2. 我从一位优秀的编程教练那里学到一个很好的教学技巧:在解释核心概念时,简化的定义不必完全正确。它们更容易理解,而且在这些定义中添加例外情况也比一开始就试图理解正确但复杂的定义要容易得多。

        1. 是的,但这里的核心目的是“降低学习曲线”,而告诉人们代码会工作但实际上不会,恰恰相反。

          这个要点,在最宽容的解释下,定义了借用收集器的“理想化目标”。实际的实现要弱得多(因为它必须如此,因为目标在形式上是不可决定的!),而“学习Rust”需要理解如何实现。

          1. > 是的,但这里的核心目的是“降低学习曲线”,而告诉人们代码会工作但实际上不会,这恰恰相反。

            “降低学习曲线”或许是个错误的比喻——你无法改变需要学习的内容;你只能让学习过程更容易。

            说一些“通常正确”且“可以稍后纠正”的内容是一种标准的教学方法——参见https://en.wikipedia.org/wiki/Wittgenstein%27s_ladder 。延伸这个比喻,梯子就是用来帮助你攀登学习曲线的。

            1. 但它并非“通常正确”。Rust无法在不使用unsafe的情况下编译双向链表[1]!

              人们在开始编写Rust代码时,会立即遇到这个问题,因为此类代码在其他编程环境中非常普遍。因此,诸如“Rust就是不喜欢悬空指针”之类的表述并无帮助,因为虽然这种说法没错,但仅凭这一点并不足以编写除最简单的代码之外的任何内容。

              [1] 或者基本上任何无法轻易证明为无环的图结构;即使许多“应该”可验证的DAG结构也无法验证。

              1. 人们在编写非trivial代码时,根本不会担心这类问题。仅使用普通树结构就能实现很多功能。在现实世界中,你的数据是扁平的,即使你使用非扁平的约定(如JSON)来解释数据,也只会生成树结构,这些树结构可能通过另一种非正式协议模拟反向链接。

                1. 唉。_链接文章的整个前提_实际上是,人们在学习Rust时早期会遇到借用检查器的验证问题,需要关注以“降低学习曲线”,帮助他们理解我们都认为相对于竞争语言而言独特且有些令人困惑的语义。

                  Rust的批评实在令人疲惫。无论观点多么合理明显,总有人愿意在长达四十条评论的争论中坚持Rust是无懈可击的。

                  1. 知道我甚至不使用Rust(尽管我对学习它感兴趣),并且事实上曾在HN上抱怨过程序性能和其他特性被错误地归因于“它是用Rust编写的”会对你有帮助吗?尤其是在Python生态系统中,这是我的主要编程语言?

                    我并不是在做你似乎认为我正在做的论点。我只是基于自己的经验,对现实世界中的编程做一个务实的观察。

                    1. 你应该尝试学习Rust,然后基于你的Python经验,看看你多久会遇到GP所说的那些情况。这可能不会花太久时间。

          2. 讽刺的是,大多数人对“学习曲线”的理解是反直觉的。

            如果“学习曲线”是一个简单的X-Y坐标图,其中“时间”和“知识”分别位于两个轴上,那么哪种学习曲线更可取:平缓的还是陡峭的?

            显然,如果在较短时间内知识增长幅度较大,那么陡峭的学习曲线更可取。“平缓学习曲线”反而会让情况更糟!

            但不知为何,人们总是反转这种含义,因此这个常见的俗语对试图理性分析的人来说就失效了。

            1. 将“知识”替换为“所需知识”。这并非关乎你学习的效率,而是你在特定时间内需要学习的知识量。如果你需要在短时间内学习大量知识(这是一件困难的事情),那么学习曲线就会陡峭。你可以通过增加可用时间或减少所需知识量来平缓曲线。

            2. > 但不知为何,人们总是扭曲这个含义,因此这个常见的比喻对试图理性分析的人来说就失效了。

              因为人们会把“曲线”想象成物理拓扑结构,目标是达到顶点。

          3. > … 定义了“理想化目标”的借用收集器。实际设备的能力要弱得多

            我认为你对原点的扩展很到位。我建议继续添加一系列更详细的论点,以及一些常见场景的示例及其解决方法。

    18. 我不懂Rust,但感觉你的描述中一定还缺失了某些关键点,因为你写的内容听起来和C++的智能指针一模一样?

      除了最后一点(我无法想象在C++中添加这一点)

      也许我漏掉了某个微妙的点?

      1. 在这种抽象层次上它们非常相似,最后一点才是关键。

  2. 我花了几次尝试才适应Rust——它的所有权模型、生命周期以及对枚举和模式匹配的广泛使用,起初让我感到望而生畏。在第一次尝试时,我很快感到不知所措。第二次尝试时,我过于教条,从第一章开始逐行阅读书籍,最终失去了耐心。然而,到那时我已经明白,Rust 将帮助我更深入地学习编程和软件设计。第三次尝试时,我终于成功了;我开始用之前积累的初步理解来重写我的小程序和脚本。我根据需要填补空白——学习惯用的错误处理方式、用类型表达数据,以及利用模式匹配等技术。

    经历了这一切的磨难后,我敢肯定地说,学习Rust是我在编程生涯中做出的最佳决策之一。提前声明类型、结构体和枚举,然后编写函数来处理不可变数据和模式匹配,这种方法如今已成为我即使在使用其他语言编程时也会采用的思路。

    1. 你的经历与我观察到的现象一致:当C++开发者首次接触Rust时,他们常常在使用C++惯用法时与借用检查器“对抗”。随后他们开始学习Rust的惯用法,并将这些惯用法带回C++,这使得他们即使没有借用检查,也能编写出更健壮的代码。

      1. 大部分情况确实如此,但存在一些在 C++ 中可以安全轻松实现的模式,而在 Rust 中却无法实现。结构化并发就是其中一个主要例子。如果子对象需要引用父对象,我本不应通过 Arc 来实现,但由于内存泄漏在 Rust 中是安全的,因此在不使用 unsafe 关键字的情况下无法实现。因此我不得不使用比预期更多的引用计数。(这通常在异步编程的上下文中出现。)我不会将这种模式带回 C++。

    2. 我也有过类似的经历。在第三次学习尝试时,一切似乎都豁然开朗,我能够有效地编写几个程序。

      这尽管我已有漫长的编程职业生涯。看来有些事情就是需要反复练习。

      针对JVM的“Dagger”依赖注入框架也让我花了3次学习尝试才理解。这可能更多反映了我自身的问题,而非学习复杂事物的难度。

    3. 出于好奇,你在接触Rust之前熟悉过多少种其他编程语言?

  3. Rust对新用户有几个重大障碍:

    – 它与其他语言非常不同。这是有意为之,但也是一个障碍。

    – 它是一种非常复杂的语言,具有非常简洁的语法,看起来像是人们用肘部打字并随机按键。一个字符就可以完全改变一个事物的含义。而且,这种语法深度嵌套的特点也无济于事。

    – 许多特性在没有深入理解其背后的理论时难以理解。这进一步增加了复杂性。类型系统和借用机制就是很好的例子。除非你是类型系统专家,否则对普通 Python 或 JavaScript 用户来说,这些内容就像天书一样。这也使得 Rust 成为一种不适合没有计算机科学硕士学位的人使用的语言。而如今,大多数程序员都属于这一类。

    – 它广泛使用宏,这些宏使许多事情变得难以理解,进一步增加了复杂性。如果你不知道宏的定义,就更难理解到底发生了什么。所有使用宏的语言都或多或少存在这个问题。

    我认为大语言模型(LLMs)现在可以在这方面提供很大的帮助。当我上次尝试理解 Rust 时,这还不是一个选项。我可能会在某个时候再尝试一下。但这并不是我目前的优先事项。不过,大语言模型(LLMs)无疑降低了我尝试新事物的门槛。我当然看到像用户这样的语言的价值。但它并不能真正解决我使用语言(kotlin、python、typescript 等)时遇到的问题。我一生中曾使用过大多数流行的语言。Rust 在学习难度上非常独特。

    1. >它与其他语言截然不同。这是有意为之,但也构成障碍。

      它与人们通常使用的许多语言大不相同,但所有核心特性和语法都源自其他地方。例如:

      >类型系统和借用机制就是很好的例子。除非你是类型系统专家,否则对普通 Python 或 JavaScript 用户来说,这些内容可能只是天书。

      没错,但他们通常根本不喜欢类型。如果你只接触过这些内容,那么在学习其他语言时,你可能没有太多可以借鉴的知识,除非你学习的是同一领域、面临相同问题的另一门语言。

      1. Python正在以迅猛的速度发展类型注释,而TypeScript则以惊人的速度蚕食JavaScript。再加上Java也开始支持抽象数据类型(ADTs),我怀疑那些抱怨“类型极客”的人将面临艰难的几年,因为动态语言的受欢迎程度正在下降。

        我怀疑那些见过类似 `dict[str, int]` 的人,可以将其映射到类似 `HashMap<String, i32>` 的东西上,而无需费力思考,然后从那里开始学习。

    2. > 它广泛使用宏,这些宏模糊了很多东西,进一步增加了复杂性。

      这就是我在学习Rust时感到困惑的地方。可能是因为我使用的资源在早期就引入了宏,却没有解释。

      1. Rust在早期引入宏有几个原因。

        1. println!() 是一个宏,因此如果你想输出任何内容,就需要弄清楚这个 ! 的含义,以及为什么 println 在 Rust 中需要是一个宏。

        2. 宏在 Rust 中非常重要,它们并非次要或辅助功能。Rust 团队在宏系统上投入了大量精力,所有 Rust 开发者都应努力掌握并理解元编程。这并非仅供内部 Rust 开发者精英使用的语言特性,而是每个人都应习惯并使用的功能。

        1. 强烈反对,宏几乎总是导致“过于聪明反而害了自己”,即使宏系统是安全的。宏应该始终谨慎使用,我认为 Rust 正在走向鼓励为了方便而增加过多复杂性的边缘。作为系统程序员,我对那些使我难以理解系统实际在做什么以及性能和硬件交互将如何受影响的额外间接层过敏。

          1. 我同意,如果使用宏来创建不必要的间接层,使事情更难理解,那是不明智的。无论是使用宏还是其他抽象,都不应这样做。

            例如,有时人们会对非常直观的线性代码过于热衷,将其分解为上百个相互调用的函数,这一切都是为了减少代码重复。这样做是对函数的滥用,但人们不会说函数应该被节制使用。

            我认为宏是一个许多程序员缺乏经验的领域,因此他们也会抛弃一些最佳实践,以便理解自己在做什么。

            但如果正确应用,宏可以非常有用,而Rust使这变得更具人体工学且安全。

            例如,如果我需要编写 100 个结构相似但函数签名略有不同的函数,我会使用宏。我认为这并不会降低代码的清晰度,反而会提高可维护性,因为如果需要调整,我无需修改 100 个函数的代码。

            宏在 Rust 中创建领域特定语言(DSL)时也非常有用。这通常是 LISP 的领域,但它是一种编写测试脚本的便捷方式。

        2. 同时,你不需要编写宏。例如,我基本上从未写过宏。

  4. 与C++相比,“安全”的Rust通常是一种简单的语言。借用检查器的规则清晰且一致。然而,使用它编写代码并不像我们多年来在流行系统语言中看到的那样简单或直观。如果你的数据结构有明确的依赖关系——比如无环图,那么就没有问题。但编写高性能的自引用数据结构,例如,与 C++、C、Zig 等相比,要困难得多。

    另一方面,“不安全”的 Rust 并不简单,但没有它,我们无法编写许多程序。它与 C 类似,甚至在某些方面更糟。很容易违反规则(例如别名)。原始指针操作的 ergonomics 不如 C、C++、Zig 或 Go。但原始指针是计算机科学中最重要的概念之一。这一部分对学习至关重要,我们不能视而不见。

    我甚至还没提到 Rust 的未解决问题,例如:thread_local(仍存争议)、自定义分配器(仍处于夜间版本)、 Polonius(夜间版本,希望它能成功)、panic处理(在内核级代码中不可接受),以及“pin”,这似乎是由于早期语言设计不完善导致的异步和自我引用问题的一种权宜之计(hack)——许多学习者对此感到困惑。

    Rust是一门优秀的语言,毫无疑问。但它感觉像是一个过渡阶段。学习曲线在很大程度上取决于你试图解决的任务类型。有些事情非常简单直观,而另一些则非常困难,最终的解决方案与 C++、C、Zig 等语言相比,并不那么简单、直观或易于理解。

    像 Mojo、Carbon(希望它能成功)和可能的 Zig(尚不确定)这样的语言正在从 Rust 和其他语言中学习。其中一种语言可能会成为未来几十年内下一代通用系统语言,且拥有更友好的学习曲线。

    1. “原始指针是计算机科学中最重要概念之一”——这有点牵强,我记不清上一次使用原始指针是什么时候了

      1. 能够引用原始内存地址并直接访问该位置的数据,这感觉像是计算机科学的基础概念。

        也许你在特定的语言/框架中进行软件工程?

        离合器是汽车工程的基础,即使你每天都不使用它。

        1. 这完全取决于你如何定义计算机科学与计算机工程的区别。在我所有的计算机科学课程中,我从未需要处理指针运算或内存布局。这是因为我的计算机科学课程都是理论性的,涉及伪代码和算法复杂度。将伪代码映射到实际硬件从未被考虑过。

          相比之下,几乎没有哪门计算机工程课程可以让我忽略原始内存地址。无论是优化内存结构以适应缓存布局,还是在资源匮乏(无MMU)的微控制器上高效实现某些算法,内存使用都绝非自动完成。

        2. 我以为你指的是无类型指针。据我所知,大多数算法和数据结构都使用有类型指针。当然,低级计算机工程领域会使用无类型指针。这确实触及了邻近评论的核心,对我来说这是计算机工程而非计算机科学,但合理的人可能有不同看法

        3. >然后访问该位置的数据

          或者由其他作者在其他时间或同时访问其他位置。

        4. 我认为Java指针不应被视为原始指针,尽管它们具有许多相似特性。

  5. 作为系统程序员,我发现Rust相对容易学习,不禁怀疑问题是否出在非系统程序员首次学习系统语言时,语言明确告知他们“不,这样做很危险。不,这样做没有意义”。如果你让一个前端开发者突然开始编写C语言代码,他们可能会导致内存泄漏、产生未定义行为、指向垃圾数据的指针、数组越界等。但他们可能会“感觉”自己做得很好,因为程序能编译并勉强运行。

    如果你已经达到 C 或 C++ 的熟练或精通水平,那么 Rust 就会很容易学(对我来说是这样)。这些概念只是从隐含的变成了显式的(所有权、生命周期、Traits 代替 vtables 等)。

    1. 我认为这是很好的见解,我进一步扩展为“从一个不太严格的语言转向一个非常严格的语言”。

      作为一名在Rust 1.0时代自学Rust的人,在经历了半年的高中水平Java 6学习后,我从未遇到过人们(甚至现在)报告的关于所有权系统等概念的问题。尽管Rust 1.0比现代Rust严格得多,而且我学习的是被认为更难理解的《The Book》版本。

      我认为这是因为我和其他早期学习Rust的人在讨论这个问题时,对编程语言应该如何工作几乎没有先入之见。因此,Rust施加的限制与任何其他编程语言一样“任意”,而且没有所谓的“更好”的方式来实现某件事。

      一般来说,像JS或Python这样更流行的语言允许你足够自由地塑造你想要使用的模式,使它们适应语言。至少对我来说,像Rust或Haskell这样的语言,如果你试图用太过不同的概念来做这件事,代码会变得相当丑陋。这可能会给人留下“编程语言无法满足你的需求”和“强加限制”的印象。

      我认为这也有另一种可能性,可能只是某种培养出来的品味。

      1. 无论如何,我过去做过很多教学工作,我也有类似的直觉,

        > 我认为这是因为我和其他早期学习Rust的人在讨论这个问题时,对编程语言应该如何工作几乎没有先入之见。因此,Rust施加的限制与任何其他编程语言一样“任意”,而且没有所谓的“更好”的方式来实现某件事。

    2. 作为一名低级程序员,我对 Rust 最头疼的是类型系统。更确切地说,是特性(traits)和特性边界(trait bounds)。

      我猜如果你有 C++ 经验,理解起来会更简单,但我写的大部分代码都是 C,而 Rust 的一些东西对我来说并不熟悉。

  6. > 停止抵抗。这是最重要的教训

    > 接受学习Rust需要…

    > 放下你的傲慢

    > 承认失败

    > 抵抗是徒劳的。你拒绝学习的时间越长,你将遭受的痛苦就越长

    > 忘记你认为你知道的…

    现在我终于明白,奥威尔的电视屏幕操作系统是用Rust编写的

    1. 但这是事实。我学习Rust时最大的错误就是试图将面向对象的范式强加于它。那结果……很糟糕。当我终于说“算了,我按你想要的方式做”时,一切都顺畅了。

      1. 说实话,这听起来像是一种虐待关系。你的编程语言不应该以那种方式限制你。

        1. >你的编程语言不应该以那种方式限制你

          谁说的?编程语言种类繁多,各有优缺点。Rust的缺点在于,编译器对什么是有效的程序有非常明确的看法。但作为回报,它提供了与C/C++相当的性能,同时避免了许多相同的漏洞和安全隐患。

          1. 此外:每个人都能编写一个能完成预期任务的程序。这是容易的部分。困难的部分是编写一个在不断变化的环境中运行,甚至其源代码本身也会发生变化的程序,同时确保它不会做那些不该做的事情。

            因此,困难之处不在于让代码运行,而在于确保它仅以预期方式运行,即使你的同事(或未来的自己)像个失去理智、毫无约束的傻瓜一样行事。这意味着必须使用强制类型系统、验证机制和严格规则。

            如果你是一个初学者,正在拼凑业余程序,那么一种无拘无束的软件开发方法可能会让你感到轻松和自由,但一旦复杂性达到一定程度,它会让你陷入痛苦的境地。

            任何我曾有幸阅读过其代码的伟大C程序员,都有许多不成文的规则,他们通过自己的头脑来强制执行这些规则。而这些规则的存在是有原因的。当你使用一种能为你强制执行这些规则的语言时,这反而能让你敢于尝试更多,而非更少,因为某些事情在手动检查时会非常冒险。

            这就像极限运动中的泡沫坑。虽然连续尝试十次三周空翻时摔断脖子可能更“阳刚”,但有了泡沫坑,你就能更快地达到目标,因为你可以安全地尝试各种动作。泡沫坑改变了整个场景,因为人们现在可以编写之前会导致程序崩溃的代码,而不会感到受限。有趣的是,事情就是这样。

            1. 限制能激发创造力,同时也能释放思维空间,让你更详细地思考实际问题。

        2. 每种编程语言由于语法特性都存在限制。

          在 JavaScript 中,你可以声明一个变量,将其赋值为 5(数字),然后再赋值为 “hello”(字符串),但在 C 语言中这是不允许的。难道 C 语言的限制太过严苛,因为我必须按照 C 语言的方式来做?

          1. 我认为在C中可以用空指针轻松实现,如果我错了请纠正我。

            你应该这样做吗?这是完全不同的问题。

            1. 但你不能将两个空指针相加并无缝地得到整数加法(如果它们指向整数),或进行字符串连接(如果它们指向字符串)。

              (你可以创建自己的自定义数据类型,在共享头文件中包含类型元数据,并使用它来实现加法函数,但这样你就等于在构建自己的自定义语言,这与原来的问题并不完全相同。)

              所以,是的,C语言在某些方面确实比JavaScript更具限制性。

              1. 那不是我所指的限制,你转移了话题!:)

            2. 严格来说,你不能。你可以有一个变量指向整数 5,然后让它指向字符数组 “hello”。但无论哪种情况,变量的值都是指针。

              有趣的是,Python 也是如此——只是不太明显,因为它没有非指针变量,而且大多数运算符会隐式解引用。

              1. _> 严格来说,你不能。你可以有一个指向整数 5 的变量,然后让它指向字符数组 “hello”。但该变量的值在两种情况下都是指针。

                联合体可能是更好的类比。

                _> 值得注意的是,Python 也是如此——只是不太明显,因为它没有非指针类型的变量,而且大多数运算符都会隐式解引用。

                这更像是实现细节,而非语言本身的特性。JavaScript 实现也可能如此。例如,V8 中的值要么是堆上的指针,要么是小整数(31 位),而较不优化的实现甚至可能不做这种区分,将一切都分配到堆上。同样,Python 实现也可能像 V8 一样,直接将 SMIs 存储在指针应在的位置。PyPy 同时使用带标签的指针/SMIs,甚至可能为值分配寄存器。

                1. > 联合体可能是更好的类比。

                  带标签的联合体可以是,但它们在 C 中不是第一类对象。

                  这里最好的类比可能是 OCaml 的多态变体(https://dev.realworldocaml.org/variants.html)

                  > 这更像是实现细节,而非语言本身的特性。

                  不过,情况并非如此,因为与 JavaScript 不同,Python 中“一切都是对象的引用”这一事实,以及每个对象都具有唯一标识,是 Python 语义的明确组成部分,这一点在许多情况下都非常明显。对于“基本”类型,只需继承自它们即可轻松观察到这一点。

                  (另一方面,某个 Python 实现可能仍通过使用标记指针等技术来实现这一点,这确实是实现细节,因为它仍需表现得仿佛一切都是对象。)

                  1. _> 带标签的联合体本可以实现,但它们在 C 中并非第一类对象。

                    对于最初提到的示例,‘声明一个变量,将其设置为 5(数字),然后将其设置为 “hello”(字符串)’,普通的联合体就足够了。

                    _> 这里最好的类比可能是 OCaml 的多态变体。

                    它本可以是,但OCaml不是C。

                    > 它不是,因为——与JavaScript不同——一切都是对象的引用

                    显然,引用和指针不是同一回事,因此引用是否对应于指针是一个实现细节。

                    > 每个对象都有唯一的身份

                    这与它们的地址无关。这是一个在对象生命周期内保证唯一的不可见值。

                    与此同时,C 语言的指针值并不一定对不同对象唯一。例如,指向包含一个或多个字段的结构体的指针与指向其第一个字段的指针共享值。只有在考虑类型时,指针才能唯一标识特定对象,因此它们并不完全对应于 Python 的对象标识。

        3. 虐待关系涉及胁迫、控制、恐惧,并常常侵犯个人自主权和同意权。一方以有害的方式支配另一方。使用Rust并不有害。

          对程序员可编写的程序施加限制并非虐待。这些规则旨在确保清晰性、安全性、性能和设计目标。在虐待关系中,规则被用来控制或惩罚行为,往往随意更改且没有理由或协商。相比之下,Rust是由一群人共同设计,以推进语言发展,遵循一套明确的目标。规则清晰且不会随意更改。

          虐待会造成情感创伤、孤立和长期伤害。Rust 可能会让你感到沮丧和烦躁,可能会让你成为一个效率较低的程序员,但使用它不会造成虐待关系中常见的心理或身体伤害。

        4. 了解原始 Rust 开发者的文化理念以及产生这种情况的背景是有帮助的。

        5. 如果你的编译器不允许你编译垃圾代码,那么它是在限制你,但这正是你想要的——你不想编译错误的代码。Rust只是比Ruby或C/C++等语言制定了更多的规则。

      2. 学习一门口语语言也是如此。我无法学习第二语言,直到我停止认为自己应该判断语言中的事物是“好”还是“坏”。语言并非为了在美学竞赛中“好”而存在,它们应该是实用的。接受它们因为被许多人使用而“有用”,然后去学习它们。

        如果我之前不是Erlang用户,可能无法做到这一点。Rust似乎是Erlang去掉高开销的Erlang特性,加上极端的类型签名和有意识的内存管理。就像Erlang只提供“零成本抽象”,而编译器始终运行Dialyzer。

  7. 我对Rust的看法不是学习曲线,而是语法绝对丑陋。它就像Perl和C++模板元编程的结合体。我就是无法忍受它。

    Python是我的最爱,C是简单中的优雅,Go是可以接受的。

    1. C 语言虽然简单,但过于简单以至于谈不上优雅。首先想到的是缺乏命名空间。或者说它是一种静态类型语言,其类型系统几乎没有强制执行(你必须声明所有类型,但有时感觉一切都会退化为 int 和 *void,除非使用正确的编译器选项)。或者构建系统,你必须学习一种独立的语言来生成另一种语言以编译程序(在我看来,这两者都既不简单也不优雅)。或者空终止字符串:为了节省每个字符串约7字节的空间(在现代平台上),C语言使用了编程世界中流行部分中最危险且不优雅的构造之一。或者绝对不优雅的错误处理,你只能返回一个内置错误值、设置全局变量,或者两者兼而有之,或者直接静默失败。或者标准库中布满了危险函数。或者语言定义对未定义行为的依赖,迫使你不得不连续阅读一份700页的昂贵文档,才能确定程序中某个关键检查是否会被编译器忽略,或者程序何时会损坏你的硬盘,尽管你从未指示它这样做。或者……

      C语言的语法简单,但绝非优雅。

      1. C 之所以优雅,是因为作为一种极具强大功能的编程语言,它被用于创建无数高知名度的项目,其简单性让我感到乐观,如果真的必要,我认为自己能够编写一个 C 编译器。

        它可能不适合某些任务,但其功能与复杂度的比例非常令人印象深刻。Lua 在这方面也类似。

        1. 共享定义的机制,即“include”(即“将文件复制到此处”),绝对不够优雅。这可能是最基本的实现方式,但绝非优雅。

          1. 更不用说整个预处理器机制的古怪设计……

            我认为包含文件/标准库/编译器在未定义行为(UB)上的疯狂表现,是围绕语言的基础设施或生态系统的一部分,而非语言本身。我完全同意,虽然 C 语言本身很优雅,但其周围的基础设施却糟糕透顶。

        2. Modula-2 非常优雅。C 语言则是拼凑而成。两者拥有相等的强大功能,且大多一一对应,但设计至关重要。编写一个 Modula 编译器会更容易。

    2. 关于语法,口味问题无法讨论。个人而言,我认为 Rust 的语法并不刺眼,而 Go 的语法则显得有些……奇怪,尤其是类型签名看起来像是一个讨厌标点符号的人写成的冗长句子。C 的类型签名在我看来是一团糟;像这样 https://www.ericgiguere.com/articles/reading-c-declarations…. 的东西在我看来就是设计失误。而 Python …… 基本上就是“算了,直接用 `Any` 类型签名算了”的领域。

      有些人就是喜欢这样!但这确实不是适合所有人的选择。

    3. 你看看 Nim 怎么样?我觉得你可能会喜欢。

      1. 我喜欢Nim的界面和使用体验,但发现它陷入了一个奇怪的鸡生蛋还是蛋生鸡的困境:由于缺乏足够的用户基础,无法提供“万能包”,这最终让我对它失去了兴趣。当然,我明白一种语言要获得“万能包”的唯一途径就是变得流行,但……

  8. Rust 既神奇又令人谦卑!

    它内置了一个教练:借用检查器!

    借用检查器一直纠缠着我——错误接踵而至——所以我妥协了。我让它通过一次又一次的编译错误教我如何正确实现线程安全的共享内存环形缓冲区。我以为自己已经明白了。我错了。C 和 C++ 缺乏所有权语义,因此它们的编译器无法指导你。

    每个人都应该学习 Rust。你永远不知道你会发现自己什么。

    1. “Rust 真是太棒了,但又让人谦卑!”

      它是一种抽象和便利,避免在最低级别上摆弄寄存器和内存。

      每个人都可以以自己的方式享受自己选择的计算平台。无需强求某种特定方式。你可能对某种高级语言充满热情,认为它以你认为正确的方式进行抽象和部署。但并非所有人都这样认为。

      你不需要编程语言来发现自己。如果你对某种特定语言或范式过于执着,那么很有可能你已经失去了如何处理需要处理的事物的能力。

      你只是在把玩工具,而不是正确使用它们。

      1. @gerdesj 你的语气不必要地粗鲁和刻薄。你信息中的一部分提出了有效观点,但被不必要的侮辱所削弱。希望你接下来的日子能有所改善。

        我并不特别喜欢Rust本身。而且,一个人不需要编程语言来发现自我。

        我学习Rust的经历是,它通过足够的约束教会了我关于正确性的重要课程。很多人可以从中学习更多关于正确性的知识!

        我承认——“每个人”这个说法太绝对了;我犯了过于挑衅的错误。

        1. 它不会教你任何关于正确性的基本教训。它只会在Rust设定的框架内教你关于正确性的教训;仅此而已

        2. “@gerdesj 你的语气不必要地粗鲁和刻薄。”

          “你”不是人类。

      2. 哇,谁在你咖啡里撒尿了?他喜欢Rust,行吧?

        1. 而他还在告诉别人也应该喜欢它,因为他已经看清了真相。

          我的直觉告诉我,人们对Rust的依恋中掺杂了相当多的斯德哥尔摩综合症。

          过去C++也曾出现过类似的行为问题,但Rust将这种现象提升到了另一个层次。

          1. 我认为,我们大多数喜欢 Rust 的人都是 C++ 难民,很高兴痛苦减轻了。不过,包括编译器错误在内的工具确实非常棒。我喜欢 C 的简单性,但为了 crates 以及知道我永远不必调试段错误,我仍然会为任何新项目选择 Rust。我喜欢 pytorch 和 matlab 进行原型设计。对于像 Go 或 C# 这样的中间语言,我用处不大,但我喜欢它们的 ergonomics。我认为对于从 C++ 甚至 C 转过来的开发者来说,喜欢 Rust 并更倾向于它并不奇怪。我们已经付出了入门的代价,而它确实带来了实实在在的好处。

            1. 没错!100% 同意!

              对我来说,用C++编程就像用沙子建城堡。我总是无法让它们建得足够高,因为它们会因自身重量而倒塌。

              但有了Rust,我提升了能力,建成了比我想像中更大的程序。为此,我对Rust心怀感激,因为它是一门真正让我觉得有意义的语言。

          2. > 过去在C++中也能看到类似的行为问题

            我认为这种情况在某种程度上几乎发生在每种计算机编程语言上——首先是C语言爱好者沉迷于他们的“非Pascal/Fortran/汇编语言”,接着是C++爱好者,然后是Java、Perl、PHP、Python、Ruby、JavaScript/Node、Go,现在是Rust。

            目前,那些热衷于编程的人似乎正在抢占Rust粉丝的声量——几乎每个博客都在告诉人们这个工具有多棒,或者有多糟糕。

    2. > 每个人都应该学习Rust。

      我知道这听起来像是一篇积极向上的帖子,我不想破坏别人的好心情,但就我个人而言,当有人告诉我“每个人都应该”做某事时,我的脑海中就会响起警钟,尤其是涉及编程语言时。

      1. 我认为每个人都应该学习多种不同的编程语言,因为接触不同的编程范式有助于提升编程技能。

        1. 是的,我同意,我喜欢这个过程。但我认为这并非“每个人都应该学习Rust”背后的真正原因,在许多情况下也是如此。这更像是一种“使命”。

    3. 编译器可能做不到,但静态分析工具已经走得很远了,可惜让开发者采用它们仍是一场徒劳的斗争,即使它们还未达到100%完美。

      如果不是那个备受憎恨的SecDevOps团队在推动开发者不关心的安全工具,至少在构建管道中,这些工具只会继续积满数字灰尘。

    4. > 借用检查器一直纠缠不休——错误不断——所以我妥协了。我允许它教我

      束缚驱动开发。

    5. 有推荐的学习路径吗?我倾向于通过视频跟随式学习。

        1. 我不同意这一点。Jon的内容很棒,但真的不适合初学者,而且他的一些内容真的深入细节。

          1. Gjengset对我这个新手很有帮助,而“Crust of Rust”和“Decrusted”系列确实针对初学者,但效果因人而异,如果有其他建议也欢迎分享。

  9. 我认为一个不太常见的教学方法是先专注于学习语言的一部分。例如,在我自己的Rust书籍中,我跳过了关于生命周期的教学。在编写许多功能完整的程序时,并不需要使用生命周期来编写函数。同样,宏也是如此(尽管它们的签名不透明,这对于初学者来说并不容易)。另一方面,我不同意依赖 copy() 或 clone() 的建议——从一开始就学习借用机制更好,因为它是语言的核心部分。

  10. >例如,为什么对一个已经是字符串的东西还要调用 to_string()?

    对我来说,要认真对待Rust确实很困难,尤其是当我不得不去解答像这样反直觉的问题时

    1. Python社区曾通过惨痛的教训了解到,有时程序员需要知道字符串有多种类型。

      我个人一直使用to_owned。查看我代码的一些人并不写Rust,我认为这样能让代码更容易理解。

      1. 现代Python只有一种字符串类型。

        在Python 2中被称为“string”的类型现在不再使用这个名称,正是为了避免不必要的混淆。它现在被称为“字节”,因此“为什么我必须将其转换为字符串?”这个问题就不会出现。

    2. 在另一个不提出这个问题的地方,我们有这样的情况

          > “1” + 2 
          3
      

      用这样的语言做任何重要事情都是彻头彻尾的疯狂。

      1.   Python 3.11.12 (main, Apr  8 2025, 14:15:29) [Clang 16.0.0 (clang-1600.0.26.6)] on darwin
          输入“help”、“copyright”、“credits”或“license”以获取更多信息。
          >>> “1” + 2
          Traceback (most recent call last):
            File “<stdin>”, line 1, in <module>
          TypeError: 只能将字符串(而非“整数”)与字符串连接
        
        1. 是的,Python 具有强类型系统,从这个意义上说它通过了这一标准。它适用于许多场景,如果在持续集成中强制执行某些规则,它可以适用于更多场景。

    3. 我不明白为什么 &str 和 String 是不同的东西会让人觉得反直觉。你在 C++ 中是否也觉得 std::string 和 const char* 是不同的东西会让人觉得反直觉?那 &[u8] 和 Vec<u8> 呢?

      1. 更好的类比是 std::string_view 与 std::string

        1. 从技术上讲,这确实更接近,但更多人听说过 char* 而不是 string_view,而且 char* 与 &str 足够相似,因此类比仍然成立。

        2. 不。&str 就是 const char*。它是 Rust 中最原始的类型之一。

          1. 不。&str 包含切片的长度,而 const char* 不包含。std::string_view 才是正确的类比。

            1. 另一个区别是 &str 保证是 UTF-8 编码,而 const char* 可以是任何编码(或无编码)。

              1. 此外,&str 更接近 const uint8_t* 而不是 const char*。字符默认是有符号的,且至少为 8 位,但可以更宽。

    4. 字符串就像时间对象:大多数人和语言只处理它们的简化版本,忽略了许多关于它们如何工作的边界情况。

      不幸的是,从大多数语言切换到Rust会迫使你快速完成这一过渡。

    5. 这个问题故意用奇怪的措辞提出。一个是字符串切片(如 char*),另一个是 String 或 &String,后者更接近对象。

    6. 仅仅因为一种语言不够高级到拥有独特的“字符串”类型概念,并不意味着你不应该认真对待它。

      1. 即使是非常高级的语言也没有单一的字符串概念。我能想到的每种严肃的语言都会区分:

        – 任意字节序列

        – 作为 ASCII 解释的非空字节序列

        – 多种可能编码下的 Unicode 码点序列

        1. 我很难想到有多少语言会在你的列表中区分#1和#2。而那些区分#1和#3的语言通常根本不将#1称为“字符串”,因此你仍然有一个明确的字符串概念(以及一些明确不是字符串的东西)。

          1. 是的,很多语言并没有严格区分这三者。

            例如,C++区分#1和#2(尽管它对#3的内置支持非常不足)。

            Python(>3)将#1称为字节/字节数组,将#3称为字符串。#2 仅在与 C 语言的 FFI 中得到真正支持(即 ctypes.c_char_p 及其相关函数)

    7. C++ 是一门极其复杂的语言,有时我不得不将一个已经是整数的值强制转换为 int。/s

      我很难理解为什么人们如此难以接受在不同文本表示之间进行转换,而对于数字的转换却被广泛接受。

  11. 学习曲线以时间为 x 轴,以进步为 y 轴。

    平坦的学习曲线意味着你永远学不到任何东西 :-

    1. 你可以用这种方式绘制图表,但它完全忽视了人们通常使用“陡峭的学习曲线”这一术语的方式——这并非易于学习的事物。

      事实上,我认为该成语所指的图表应为:努力(y轴)与达到特定掌握程度(x轴)之间的关系。

      1. 我认为这个成语并没有特指任何特定的曲线。我认为这只是又一个例子,即误用逐渐成为成语,其含义仅限于整个短语本身。例如:

        – another think coming -> another thing coming

        – couldn't care less -> could care less

        – the proof of the pudding is in the eating -> the proof is in the pudding

        通常没有必要试图确定右侧短语的含义,因为它们本身没有特定含义。例如,“证明在布丁中”是什么意思?

        这个成语本身没问题,它只是一个比喻,将学习困难的事物与攀登高山相比较。但学习曲线是真实存在的,至今仍被广泛使用,所以我只是觉得把平坦的学习曲线视为理想状态很有趣。

        1. “陡峭的学习曲线”指的是在短时间内需要大量学习才能取得有意义进展的情况。

          它并非与平缓的学习曲线对立,而是指温和的学习曲线

        2. (与你的整体观点无关)

          > – 另一件事即将发生 -> 另一件事即将发生

          有趣。我之前从未见过这种用法。我只见过人们使用“另一件事即将发生”。

          1. 同样。然而,我已经思考这个问题一段时间了,查阅了大量文章,我仍然认为“事情”这个词完全——如果不是更合适的话——更合适。

            我完全理解并使用“think”作为名词——例如“have a think”——但当我说“你还有另一件事要来”时,并没有期待或暗示他们会在未来重新考虑任何事情。人们通常不会那样做。相反,我只是暗示现实将会与他们所想/所期待的截然不同(而且很可能对他们不利)。

            这相当于说“当心——你将面临与你预期不同的结果”。

            事实上,它甚至将重新考虑的时间从未来移到了现在——“现在就重新考虑吧,因为事情不会像你预期的那样发展”而且,更重要的是,它常常被用作最后的警告——“我现在警告你是为了你好——这是我在这件事上能给你的唯一善意”

    2. “平滑Rust学习曲线的导数”这个说法确实不太顺口

      1. 没错,这是真的。但这与强调准确性和细节重要性的帖子相符。

    3. 这是错误的。学习曲线在x轴上衡量专业知识,在y轴上衡量努力。因此有“陡峭的学习曲线”这一说法。

      1. 称其不准确有些苛刻;我的定义直到1970年才成为通用用法,而原来的“时间对学习”在学术界仍被使用。

        1. 学术界?这只是普遍用法。你才是特例。

          1. 澄清一下,你认为“陡峭的学习曲线”的常见用法意味着容易学习?

            1. 我反对你对“努力与专业知识”的定义。显然应该是“专业知识与时间/经验”的对比。

              这在另一篇评论中链接的维基百科文章中有描述。

              https://en.wikipedia.org/wiki/Learning_curve

              该文章还指出,人们很少正确使用该术语,常将难以学习的事物称为“陡峭”的学习曲线。实际上恰恰相反。

        1. 该评论的意图尚不明确;无论如何,感谢您的反馈。如链接中所言:

          “英语中的常见用法与将学习曲线比作一座需要攀登的山丘的隐喻解释相一致。”

          随后附有一幅以x轴表示“经验”和y轴表示“学习”的图表。

        2. 这很有趣。我一直直觉认为x轴代表进展,y轴代表累计努力。

    4. 应该称之为“学习之丘”。

      人们(口语中)使用“陡峭的学习曲线”这类短语,因为他们想象学习曲线是需要攀登的事物,即一座山丘。

    5. 我们想要的是一个“努力/难度曲线”,用来衡量从入门到熟练过程中某件事的难度随时间变化的情况。

    6. 但平缓学习曲线并不意味着要让它完全平坦,只是让它没那么陡峭 🙂

    7. 这也可能意味着你不需要在某个点之后继续学习。

  12. 这读起来像是Rust人体工程学问题的症状列表。这不是在批评Rust。它有其用途。但你需要权衡你所牺牲的与所获得的。

  13. 所有权和生命周期或许并非 Rust 的核心,而是关乎我们当今如何构建程序。在 Rust 或 C++ 中,使用庞大且相互依赖的对象图来构建程序并非明智之选。

  14. 我认为我唯一会学习Rust的方式是,如果出现大量薪资在30万美元及以上的岗位,且这些岗位要求使用Rust。

    潜力确实存在,它似乎有可能与C++在量化领域竞争。

    但我们已经有了OCaml。从Jane Street的角度来看,至少对我来说,如果你告诉我,是时候学习一门极其困难的编程语言了,我需要看到相应的回报。

    到目前为止,我薪资最高的编程工作是使用Python的

  15. 我不确定有多少情况下我会选择Rust。我对此持开放态度。我只是认为在任何特定情况下,很可能会有更好的选择。

    也许未来它会普及到足以让它变得有意义。

    1. 作为系统程序员,没有更好的选择。C语言天生不安全,C++难以使用且不安全,除非小心使用预先制作的安全抽象,但这些抽象也无法在运行时捕获所有问题……

      我用C语言编程已有数十年,热爱这门语言,但Rust让我相信我们需要超越70年代的编程方式。现在再也没有借口了。

    2. 这是我所知最适合任何需要正确性而值得牺牲开发时间的任务的语言。这包括系统编程任务,其中你需要在内存、文件和系统资源之间进行操作,一个错误可能导致程序崩溃或资源损坏;或者作为程序关键部分的库,然后为其他语言提供接口,以实现更快的开发和原型设计。

    3. 它无疑是编写浏览器的完美语言,因为它早在近20年前就被设计出来。当然,如今它已经完全主导了该领域,而其创建者的“Unix”系统也已席卷全球,并未被某个叫“Ladybird”的随机人物所取代。

    4. Rust 只有在修复了与 C++ 的互操作性问题后才能胜出

  16. > 尽可能使用 String、clone() 和 unwrap;你以后可以随时重构

    到那时,你可能干脆直接用 Java 或 Go 之类的语言了。垃圾回收运行时实际上对于这类代码要快得多,因为它们可以通过共享底层资源来避免所有这些复制操作。同样的逻辑,你可以通过你选择的FFI来重构性能关键部分。

    1. 只要你清楚自己没有在优化,这样做是可以的。作为一名新的Rust开发者,在担心生命周期问题的同时尝试构建有用的东西会相当具有挑战性,除非你的目的是专门学习生命周期和借用检查器。

      是的,借用检查器是 Rust 的核心,但该语言还有其他功能,人们也需要学习和探索才能提高工作效率。其中一些功能可能会吸引他们使用 Rust(如模式匹配/特质等)。

    2. > 到那时,你可能还不如写 Java 或 Go 之类的语言。

      你会错过 Option、Result、适当的枚举、强大的模式匹配、详尽的模式匹配、仿射类型、特质、文档测试……以及许多其他 QoL 功能,当我转用 TS/Node 时,我非常怀念这些功能。

      我使用Rust并非因为借用检查器,但当需要它时,它能提供帮助,而当不需要时,这也不是什么大问题。我曾想喜欢Go,但就是做不到。

      切换到无标准库(no_std)……那是一次令人痛苦的经历。

    3. >对于这种类型的代码,速度显著提升

      “显著”和“这种类型”是关键句。在需要_可预测_延迟的应用中,克隆比垃圾回收更好。

      这也是学习语言的初级阶段。随着程序员水平的提升,他们会意识到何时在进行多余的克隆。然而,在FFI中重构性能关键部分是痛苦的,且随时间推移不会变得更容易。

      此外,在实际应用中,这主要适用于字符串和向量。在我大多数应用中,大多数`克隆`都是引用类型——这与GC下的内存共享相比仅略微昂贵。

    4. 我在第一次用Rust参与Advent of Code时就遇到过这个问题,比如我从输入文件中读取所有字符串并将其放入向量中,然后打算遍历向量并将这些字符串的引用添加到另一个结构中,但当然这些引用仍由原始向量拥有,这很尴尬。哦,等等,我可以使用iter_into,这样就能获得拥有权的对象,并且可以将拥有权转移到另一个结构中,但现在我需要这些对象也作为映射的键,我是否也需要使用引用来实现这一点?

      克隆小型对象的速度极快,事实上在许多情况下直接进行克隆是合理的,尤其是作为初始化操作时。值得一提的是,Rust会强制你显式调用clone()方法,这样你就能清楚地知道克隆操作何时发生,而其他语言中则容易忽视哪些操作会占用内存。因此你可以看到克隆操作正在发生,可以对其进行分析,一旦算法的框架确定,就可以说:“好的,这就是最终应该拥有这些数据的对象,这是它到达那里的路径,而其他用法将是引用或克隆。”

      1. > 克隆小型对象的速度极快

        其实并非如此,这是 Python 的工作方式。堆分配在现代 CPU 上“很快”,但对于大多数操作来说,它们的速度快到无法测量,但它们比你用来操作所克隆对象的函数调用和代码慢得多(慢得多)。

        需要内存安全且能满足此类性能要求的代码,在源语言选择上有多种方案,其中几乎没有哪一种需要通过博客文章来“降低学习曲线”。

        (再次强调:这比垃圾回收器慢得多,后者根本不需要进行克隆。我认为用Rust写出“比Java更慢”的代码完全偏离了重点。Java虽然枯燥无味,但超级简单!)

        1. 克隆并不意味着堆分配。这取决于类型。

          1. 如果对象具有栈限定的生命周期,借用检查器本可以证明该分析。建议是克隆它无法处理的对象,这基本上要求它进入通用堆。我确信有一些有趣的反例,但你想象的情况似乎有点学术化。

            1. 对象进入堆并不一定意味着需要更多堆分配。我多次遇到过这样的情况:我在堆上实例化了一个新对象,并需要向其传递克隆的参数(因为它必须拥有这些参数),但最终这些参数变成了内联字段,因此没有额外分配。这种情况在 Rc / Arc 中很常见。

              1. 再次强调:如果编译器能将对象内联、放在栈上或用按值/寄存器复制替换,那么借用检查器从一开始就不会报错。建议非常明确:通过堆分配来规避借用分析。我不明白这有什么争议。

    5. 更不用说,尽管你_可以_在以后重构,但你真的会这样做吗?不这样做要容易得多。

      根据我的经验,业余爱好者的Rust项目最终会到处使用unwrap和panic,这会变成一个巨大的混乱,没有人会去重构。

  17. 我以为在初学者水平上这还算容易管理……不过我还没深入研究异步,听说那是个完全不同的痛苦层次

    1. 如果整个应用都在异步运行时中,异步和“函数颜色”“问题”就会消失。

      我最近写的Rust代码中,几乎90%都是异步的。我尽量避免使用非异步/阻塞库。

      我认为这个问题被夸大了。

      1. “把一切都写成异步”绝不是解决问题的良好方案。并非所有内容都需要异步(事实上大多数不需要),且异步代码的逻辑推导难度要高得多。这个问题绝非被夸大。

        1. 为什么异步代码更难理解?我在C#中使用过异步代码,其核心优势在于能以与同步代码几乎相同的语法编写回调函数。若深入并发编程(这虽是独立概念但可与异步代码结合使用,例如同时处理多个未来对象),无论采用异步还是显式线程,该部分实现难度都相当高。

          1. > 我一直在C#中使用它

            异步-等待在.NET中之所以简单,是因为垃圾回收器。C#将异步函数重写为状态机,通常在堆上分配。垃圾收集器会自动管理方法参数和局部变量的生命周期。当从其他异步函数中等待异步函数时,运行时会同时处理多个异步帧,但这没有问题,只是一个正常的对象图。另一个原因是,运行时对所有这些功能的支持都集成到了语言、标准库和生态系统的其他大部分部分中。

            Rust 则截然不同。并发运行时并非语言的一部分,标准库仅定义了最基本的 API。并发运行时由“Tokio”外部库实现。Rust 没有垃圾回收器;取而代之的是借用检查器,它要求每个对象在任何时候都只有一个所有者,所有内存分配都必须显式进行,并且通过类型系统将这些细节全部暴露给程序员。

            这些因素使得异步 Rust 比普通 Rust 更难使用。

            1. 这一切都不值得害怕。

              > 并发运行时由“Tokio”外部库实现。

              在Tokio周围加上引号?

              你不能在没有Rails的情况下使用Rails,也不能在没有Django的情况下使用Django。

              Rust之所以将这些内容放在外部,是因为他们不希望在语言中过早地做出决定。就像PHP那永远过时的字符串库函数,或是Python那臃肿的“内置电池”标准库,里面塞满了四种不同的XML库和其他垃圾。

              > 相反,它有一个借用检查器,坚持每个对象在任何时候都只有一个所有者,使所有内存分配显式化,并将所有这些细节通过类型系统暴露给程序员

              这是基本要求。大家都知道这一点。这并不难或可怕,只是需要一点时间适应。就像一个初次学习编程的学生。这甚至不算难。任何人都可以学会。

              有趣的是,人们会抱怨这么简单的事情。一旦学会骑自行车,就不会再抱怨学习骑自行车了。

              > Rust 非常不同。

              哦,不!

              说真的,这是 2025 年。我可以用 Rust 编写异步代码而无需费力。这些都是不接触该语言的人写的。

              Rust并不难。停止这个荒谬的梗。一旦你坐下来学习,它其实是一门相当简单的语言。

              1. > 我可以轻松地编写异步Rust代码

                困难的部分在于你试图“正确”地做到这一点。

          2. > 为什么异步代码更难理解?

            我不清楚C#的情况,但在Rust中,一个原因是普通(非异步)函数具有这样的特性:它们会一直运行直到返回、引发panic或程序终止。也就是说,一旦进入函数,它就会运行到完成,除非它“永远”运行或发生异常情况。异步函数则不同——调用异步函数的代码可以直接丢弃对应的未来对象,导致其消失并永远不再被轮询。

          3. 因为某个地方的一个错误会让整个应用程序卡住,然后你必须定位哪个调用正在阻塞。

      2. 异步的问题不在于着色。它引入了在同步代码中根本不存在的问题,如固定、同步、未来生命周期等。

        着色只是加剧了这些问题,因为它具有传染性,而不是因为着色本身就是问题。

      3. 这完全被夸大了。几乎所有支持异步的语言都有同样的“问题”。

        我并不认为这是异步设计的巅峰,但它非常熟悉且现在相当不错。我更倾向于尽可能多地编写异步代码。

        1. 那些认为“函数颜色是问题”的人发明了一种放大严重性的构造。这其实不是什么大问题。

          1. 我对整个“函数颜色”问题的最大顾虑是,许多函数具有不同的颜色。比如,这两个:

                fn foo() -> String
                fn bar() -> Result<String, Error>
            

            我不能简单地将`bar`与`foo`视为相同,因为它不会直接返回一个字符串,它可能无法成功返回字符串。因此我需要对它进行特殊处理才能获得字符串。

                async fn qux() -> String
            

            这个函数同样不会直接返回字符串。它返回的是一个“可能返回字符串”的类型(具体来说是impl Future<Output=String>),我需要对它进行特殊处理才能获得字符串。

            所有这些函数都有不同的颜色,我不明白为什么对 qux 来说突然成了大问题,而对 bar 来说却不是。

      4. 同意。函数着色是一个解决方案(而不是问题),比其他替代方案更好。

        那些声称“函数着色问题”的人正在损害整个生态系统。例如在 JavaScript 中,有一些非常流行的框架选择通过抛出异常来将异步操作包装为同步执行,并在值解析时重新运行程序的某些部分。这些试图去除着色的解决方案的荒谬之处在于,它们并没有真正去除着色,而是将其隐藏(且隐藏得不好)。因此,你无法知道程序的哪些部分是异步的。

      5. 异步闭包/闭包类型,尤其是与未来绑定相关的问题?

        1. 虽然我希望拥有这些功能,但它们并不妨碍我编写大量生产代码。

          当该功能稳定后,我需要撤销我编写的全部异步特性库黑客代码时,我意识到没有这些功能并没有真正阻碍我。

        2. 异步闭包最近已正式发布,确实带来了一些不错的质量提升,不过我之前已经习惯了在没有它们的情况下工作,所以从“启用新的架构模式”等角度来看,它们还算不上革命性。

          我很少需要关心未来绑定,主要是偶尔在处理流时调用绑定宏。

  18. 这些回复让我看到了人们在被纠正时的普遍反应。在行业中待久了,很容易变得固执。

    我建议这些人亲自弄清楚为什么他们如此抵制编译器的建议。你想做事情的方式不同吗?是什么阻止了你这样做?

  19. 感觉像是一本教派手册。“一切都要按我们的方式做!不要质疑任何事情!”

    1. 如果你在学习时不愿意保持谦逊,那你其实并没有真正尝试学习。(或者说,至少没有非常努力地尝试)

      “照这样做”和“在做这件事时保持开放心态”是有区别的。

      你可以用任何方式学习Rust;这只是一个关于如何有效学习的指南。

      一个更公平的比较是去日本学习日语,却坚持除了日语课外都用英语交流。

      是的,你可以这样做。

      ……但这不是学习的_最有效_方式。

      最佳学习方式是沉浸式学习。只是这样更难。

      如果你不想这样做,就别做。这不是什么邪教。这只是懒惰、轻率的反Rust情绪。

    2. 这就是每个被迫选修编程课程的学生对任何编程语言的感受。

  20. 我似乎已经习惯了一种避免这些问题的Rust编程风格,或许是以牺牲一些优化为代价。以第一个例子为例(文章建议这样做):不要返回一个`&str`;只将它们用于临时变量,如函数参数;不要用于结构体字段或返回类型。

    我开始怀疑这样做会错过什么。文章中未提及:使用更抽象的功能(如 Cow 等)有何技巧?今天我遇到一个问题,某个库使用 Cow<&str> 而不是 String,导致生命周期错误蔓延到我的代码中。

    编辑:我发现文章中这点很有趣:他们用 `String` 作为参数,用 `&str` 作为返回类型来触发错误;你只需反其道而行之就能规避这些错误!

    1. > 我开始怀疑这样做会错过什么。

      这取决于具体情况。在某些情况下,你并没有错过什么。在其他情况下,你可能会因为一些本不需要的复制操作而损失一点效率。这取决于你正在做的事情,可能无关紧要。

      > 有什么使用更抽象功能(如 Cow 等)的建议吗?我今天遇到一个问题,一个库使用 Cow<&str> 而不是 String,导致生命周期错误在我的代码中浮现。

      你可以像使用 &str 一样,通过调用 Cow 的 _owned 方法来获取 String。

    2. 通过尽可能使用 String 等拥有类型而非 &str 等借用类型,你可以避免很多痛点。这通常是推荐的做法;使用语言的更高级特性往往没有实际好处。

      通常,高级特性会在追求更好性能时出现。例如,在循环中使用引用类型(借用类型)来消除深拷贝(和分配)会大大提升性能。

      库作者通常无法预知代码在下游的使用方式,因此认真的作者会努力使代码具有合理性能,并利用这些高级语言特性来实现。你永远无法确定库的消费者是否会在热点循环中使用你的函数。

    3. +1,了解这种情况何时适用会非常有趣!我目前仅通过在结构体中使用 owned 类型处理单个对象,以及使用 (A)Rc 类型处理多个对象,但学习更多内容会很有趣。

  21. 有没有一份简洁的文档,能解释Rust语言设计背后的主要决策,供了解C++的人阅读?不是新手教程,而是直奔主题:为什么选择就地可变性而非其他选项,为什么鼓励栈分配,它解决了C++的哪些问题以及付出了什么代价等。

    1. Rust 的类型默认值比 C++ 更好,这主要是因为 C++ 的默认值来自 C。在这一点上,Rust 更符合人体工程学。如果今天重新设计 C++,它很可能会采用这些默认值。

      然而,对于高性能系统软件而言,对象往往具有_本质上_模糊的所有权和生命周期,这些只能在运行时解决。Rust 对这类问题有相当严格的看法。在这种情况下,C++ 更加人性化,因为具有这些属性的对象本质上超出了 Rust 的模型范围。

      在我自己的思维模型中,Rust 就是 Java 本应成为的样子。它在低级系统代码方面做出了太多妥协,导致其在该使用场景下的人体工程学设计欠佳。

      1. 有趣的是,CPU 密集型高性能系统也与 Rust 的模型不兼容。对于这类系统,所有权是明确的,但 Rust 还有另一个问题,即不支持多个 CPU 核心并行访问同一内存时,对同一内存的多个可写引用。

        一个简单的例子是大型方阵的乘法运算。实现需要利用所有可用 CPU 核心,而传统方法(在许多 BLAS 库中可见)是将输出矩阵的不同片段分配给不同 CPU 核心计算。片段并非连续的内存切片,而是密集 2D 数组的矩形段。在 C++ 中并行存储同一矩阵的不同片段非常简单,但在 Rust 中却极为困难。

        1. 这就是哥德尔不完备定理(或可能是莱斯定理,甚至两者兼而有之)的桎梏:有用的形式系统要么是正确的,要么是完整的。Rust选择了正确性,当然代价是一些有效的操作无法在该语言中表达。C语言则相反;所有有效的程序都可以表达,但没有(通用的)方法来区分无效程序和有效程序。

          对于你提到的矩阵分割的具体示例,这在Rust中似乎也应该相对简单。你可以将对数据的可变引用转换为指针,将指针运算操作包裹在不安全块中,并在顶部添加一条注释,大致说明“这是安全的,因为不同的子程序始终操作于数据的互不相交子集,因此不会发生可变别名”。

        2. safe Rust 中很难实现。你可以在那个特定区域使用 unsafe,同时在应用程序的大部分其他部分仍能享受 safe Rust 的好处。

          1. 我大多数应用程序都不使用 C++。我只用 C++ 来构建实现 CPU 密集型性能敏感数值计算的 DLL,有时也用于消费 C++ API 和第三方库。

            我大部分应用程序都是用C#编写的。

            C#提供了与Rust非常相似的内存安全保证,其他安全保证更好(例如编译器选项可将整数溢出转换为运行时异常),它是更高层次的语言,拥有功能丰富且强大的标准库,即使是大项目也能在几秒钟内编译完成,支持异步IO,拥有高质量的GUI框架……用Rust替换C#并不会带来好处。

            1. 这听起来确实是一个非常相似的模型:在自包含区域中使用不安全Rust,而在大多数区域中使用安全Rust。

              顺便说一句,在不通过动态库边界分离代码的情况下,编译器有机会对这些不安全用法进行优化,例如将不安全代码的内联机会传递给调用者。

              1. > 相当类似的模型

                是的,这个模型相当古老:https://en.wikipedia.org/wiki/Greenspun%27s_tenth_rule实际上,复杂的软件系统多年来一直是用多种语言编写的。性能关键的底层组件与高层逻辑的需求差异过大且存在冲突。

                > 你为编译器提供了优化这些不安全使用场景的机会

                一个解决方法是改进 DLL API 的设计。 instead of implementing performance-critical outer layers in C#, do so on the C++ side of the interop, possibly injecting C# dependencies via function pointers or an abstract interface.

                另一种选择是将这些较小的函数重新实现为 C#。现代 .NET 运行时并不特别慢;它甚至支持 SIMD 内置函数。你可能无法达到优化后的 C++ 发布构建(带 LTO)的性能,但也不太可能相差太大。

                1. > 通过函数指针或抽象接口注入 C# 依赖项

                  这与我之前建议的恰恰相反;这些函数指针或抽象接口会阻碍我之前提到的优化(例如,内联导致边界检查的死代码删除,或将比较函数内联到排序实现中,这些都是经典案例)。

                  EDIT:不过,确实仍然有可能不让它影响性能,只是在设计接口时需要稍微小心一些,如果你使用的是同一个编译器/链接步骤,就不用考虑这个问题

                2. > LTO

                  在某些工作负载下(例如无法在热循环内进行内联的调用),我发现LTO是C代码达到C#性能的必要条件,而非反之。我们已经取得了长足的进步!

                  (如果你问是否有任何注意事项——是的,JIT 能够通过不受 SSE2/4.2 的限制,以及通过直接提供更多高度向量化的原始操作,从而获得额外的性能提升。这些操作允许进行单行代码修改,其性能提升超过了普通 C 库所能达到的水平)

                  1. > 在某些工作负载下,我发现 LTO 是 C 代码与 C# 性能持平的必要条件

                    是的,我也观察到了这一点。据我所记得,那段代码进行了大量小的内存分配,而 .NET GC 比 malloc 更快。

                    然而,上次我测试时(当时使用的是.NET 6),对于使用AVX进行数字运算的代码,我的C++使用SIMD内置函数比C#使用SIMD内置函数更快。虽然差距不大,但明显可察觉,大约20%。代码生成器在C++中表现更好。我怀疑主要原因是.NET JIT编译器没有时间进行昂贵的优化。

                    1. > C++ 的代码生成器表现更好。我怀疑主要原因是 .NET JIT 编译器没有时间进行昂贵的优化。

                      是的,对各阶段的数量和每个阶段能完成的工作量存在严格限制。除了内联预算外,编译器内部还存在许多隐藏的“限制”,这些限制旨在降低吞吐量损失的风险。

                      例如,JIT 同时跟踪局部变量断言的数量是有限的,如果方法包含过多代码块,它可能无法在整个代码块范围内完美跟踪这些断言。

                      GCC 和 LLVM 可以从容地重复优化阶段,而 RyuJIT 则避免这样做(即使某些阶段会重复之前已进行的优化)。一旦“Opt Repeat”功能实现产品化[0], 我们很可能首先在NativeAOT中看到它,正如你所期待的。

                      关于匹配GCC生成的向量化代码的代码生成质量——我通常可以通过迭代重构实现并快速使用Disasmo扩展测试其反汇编来复制它。此类代码的主要问题在于,GCC、LLVM 和 ILC/RyuJIT 在 SIMD 处理上各有其独特特性(例如,编译器是否会在循环体内错误地重新 materialize 向量常量构造,从而破坏其加载操作?)。此前我以为这是.NET的独特弱点,但后来发现GCC和LLVM也容易受到此类问题影响,甚至在更新中出现退化,这在.NET的SIMD边界案例中偶尔也会发生。但这种情况确实不常见。GCC/ LLVM 在处理 SIMD 代码时表现更佳,但一旦你开始抽象化 SIMD 代码,可能需要更多帮助。因为当可用寄存器因寄存器分配不佳而耗尽时,可能会出现寄存器溢出,或者在向量重排的场景下,JIT 需要复制 可移植 行为,但未能识别你的常量无需此操作,因此你需要借助平台特定的内置函数来绕过该问题。

                      [0]: https://github.com/dotnet/runtime/issues/108902

            2. 我肯定更愿意在图形用户界面应用程序中使用 C# 或 Java,是的。

      2. > 然而,对于高性能系统软件而言,对象的所有权往往具有内在的模糊性

        这有什么证据吗?大量高性能系统软件(浏览器、内核、Web 服务器等)都是用 Rust 编写的。此外,Rust 支持通过 Rc<RefCell<_>> 进行运行时借用检查。虽然它不如引用方便,但完全可行。

        1. 任何从事数据库内核开发(即所有高性能内核)的人都会遇到这种情况。硅芯片并不关心你的编程语言的所有权模型,会随意违反它。你无法通过语言本身解决这个问题,必须接受硅片的固有行为。生命周期本质上是模糊的,因为对象既没有一致的内存地址,也没有持久的内存地址,这是数据库的标准特性,也是大型数据库的必要特性。虽然你可以用 Rust 的惯用方式绕过这个问题,但性能将无法与原生实现相提并论。你必须接受事物的本质。

          在安全 Rust 中构建一个具有竞争力的高性能 I/O 调度器的近乎不可能,在严肃的性能工程圈子中几乎已成为一个陈词滥调。

          需要明确的是,C++ 对此也不太适应,但它承认这些情况存在并提供了管理它们的工具。Rust 则不然。

          1. 新的数据库如Tigerbeetle是用Zig编写的。内存控制是主要原因之一。Rust标准库的自定义分配器已经开发了十年。

          2. 你可以退回到不安全的模式。再次强调,几乎没有C/C++能支持而Rust不能支持的工作负载。

      3. Java 本应像 Modula-3、Eiffel、Active Oberon 一样,可惜它没有做到,如今正在努力重新设计其架构,同时保持其 ABI。

        幸运的是,C# 大多已经赶上了这些语言,作为我喜欢使用的另一种语言。

        之后,就是编程语言采用中常见的人为因素。

    2. 我不知道具体是哪一个,但很乐意回答问题。

      > 就地可变性

      我不确定这是什么意思。

      > 为什么鼓励栈分配

      这与 C++ 相同,默认情况下事物在栈上分配,只有在请求时才会放在堆上。控制很重要

      > 它解决了 C++ 的哪些问题,以及付出了什么代价

      这里最大的问题是默认的内存安全。你无法拥有悬空指针、迭代器失效等问题。代价是这通过编译时检查实现,你必须学会如何结构化代码以向编译器证明这些属性正确。这需要一些时间,也是人们所说的难度所在。

      Rust还颠覆了许多默认设置,使语言更简单。例如,以 C++ 的术语来说,一切都是可重定位的,这意味着 Rust 默认支持移动,并决定取消移动构造函数。从技术上讲,Rust 根本没有构造函数,因此不存在“三规则”或“五规则”。Rust 代码的风格与 C++ 代码截然不同,它更像是“如果现代 C++ 进一步融入函数式编程影响,且几乎没有面向对象编程元素”的产物。

    3. 我认为Rust背后的主要决策是明确性,迫使程序员做出决策。没有空指针,没有隐式转换,没有悬空指针。生命周期、可选类型、结果类型,每个匹配分支都必须存在等。

      附注:栈分配执行速度更快,因为它被缓存的概率更高。

      这是一本关于 C++ 到 Rust 解释的免费书籍。https://vnduongthanhtung.gitbooks.io/migrate-from-c-to-rust/

      1. > 明确规定并让程序员做出决策

        那么为什么使用 RAII 呢?

        > C++ 到 Rust 的解释

        我见过这本书。它非常适合初学者,充满了简单的示例,甚至没有 Rust 引用与 C++ 智能指针的比较表。

        1. 我认为 RAII 非常明确:资源获取即初始化。当你初始化表示资源的结构体时,你就获得了该资源。如果你有一个表示资源的结构体,你就拥有该资源。基于此,你同时获得了在作用域结束时调用 drop 的机制。我认为这里的区别不在于明确性。

          相反,我认为 Rust 更倾向于将明确性与正确性相结合。你必须清理该资源。我见过有人主张应该允许资源泄漏,我对此表示同情,但如果我们同意明确性是一个目标,那么或许你能理解这样一种观点:泄漏应该明确,而不是隐含在未调用某个方法中。由于线性类型难以实现,如果倾向于轻松做正确的事情,自动释放会更容易。如果你想泄漏资源,可以将其存放在某个泄漏列表中或使用不安全方式删除它。需要明确的是:异常选择,而非所有选择或仅常规选择。

          但确实,显式初始化中隐含的释放操作会导致开发者忽视它,就像忘记调用函数导致资源泄漏一样,这常会引发意外的 bug。因此,当函数调用结束时,他们不会意识到大量对象即将被释放。

          回答你的原问题,理由并非集中在一个简洁的位置,而是分散在各种导致语言特性的 RFC 中。

        2. >> 明确并让程序员做出决策

          > 那么为什么是 RAII?

          他们的引用可能更好重新表述为 _明确并让程序员在编译器的决策可能影响安全时做出决策_

          原始类型之间的隐式转换可能影响应用程序的安全性。隐式内存管理和初始化是编译器可以安全执行的操作,也是Rust安全设计的核心。

        3. Rust在编译器层面实现了RAII。这是语言规范本身。这可能是描述Rust内存模型设计意图的最佳方式。

          当您创建一个对象时,您会为其分配内存。该对象拥有并销毁它,除非您将所有权传递给其他对象(而 C++ 的 RAII 无法像 Rust 那样干净地实现这一点)。

          然后它会做一些其他事情来消除所有尖锐的边缘:

          – 没有空值,没有异常。Option<T> 和 Result<T, E> 非常出色,它们让一切都明确无误,并确保问题得到妥善处理。这些语法糖让编程变得轻松。如果你曾经疑惑函数是否应该返回错误代码、设置错误引用或抛出异常——这些设计考虑现在都已成为过去。Rust 提供了业界最佳的解决方案,而且它以坚如磐石的安全性实现这一切。

          – 使用几个特性(Send、Sync)检查您在线程之间传递内存的方式。如果您的类型未实现这些特性(通常使用原子和锁),则您的代码将无法通过编译器检查。因此,多线程代码在编译时在很大程度上可以证明是安全的。如果您做了一些愚蠢的事情,它不会阻止您陷入死锁,但可以解决 99% 的问题。

          – Traits 比类更好。您仍然可以完成经典类所能完成的一切,但您还可以通过将 Traits 附加到任何您想要的地方,进行类无法提供的更多基于组合的继承。

          – Rust 的标准库(如果你在做嵌入式工作,你可以不用它)拥有任何语言中最优雅的数据结构、算法、操作系统原语、输入输出、文件系统等。它从 40 年的错误中吸取了教训,其中包含了一些真正优秀的内容。它在所有平台上都运行得非常出色。我经常为 Windows、Mac 和 Linux 编写代码,所有代码都能开箱即用。移植从来不是问题。

          – Rust 的函数式编程模式非常简洁易读。语法并不晦涩。

          – Cargo 是目前世界上最好的包管理器。你可以轻松导入大量库功能,管理这些库及其特性也非常简单。只需六十秒就能找到你想要的内容并将其引入代码库。

          – 你几乎无需考虑系统库和链接问题。无需 Makefile、CMake,也无需面对构建复杂性或垃圾代码。编译器和 Cargo 会为你完成所有工作。这和 Python 一样简单,你无需为此操心。

    4. 就地可变性和栈分配是为了速度。这使得变量和值在其中都作为独立的一等公民存在。整个借用检查器只是用于跟踪变量访问,这样你就不需要克隆值。我认为,基于这个核心决策,Rust 中几乎没有任意的决策。其余的部分基本上必须以这种方式实现。Rust 更像是一种发现,而非人为构建。

      C++ 只是没有尝试跟踪变量访问和克隆的 Rust,这导致了一团糟,因为人们在手动和临时处理这些事情时太糟糕了。所以 Rust 解决了这个问题。

  22. 整个学习曲线似乎被夸大了。你不需要完全理解语言的每个部分就能开始使用它并提高效率。

  23. 对我来说,Rust 最重要的特质是它是一种具有值语义的语言。这使其与你之前接触过的所有主流语言完全不同。

    在Rust中,变量不是你可以随意传递和重复使用的标签。它是一个固定大小的物理内存,值可以被移动到其中或从其中移出。一旦你理解了这一点,一切都变得有意义了。移动语义、克隆、借用、Sized、impl。Rust的每一个语言设计元素都是这一原则的直接结果。被创建、销毁和移动的是值,而变量是实际的一级存储位置,它们拥有与所占用的值独立的身份。很难注意到这一点,因为Rust做了很多工作来伪装成一种“正常”的语言以吸引人们。但对于有编程经验并试图学习Rust的人来说,我认为这一认识至少能让学习过程轻松几倍。

    要转变到这个新范式并接受它并不容易,因此在过渡期间,如果你只是想快速编写一些程序,就像在其他主流语言中一样,可以大量使用 Rc<> 和克隆。

  24. “如果你在按下‘编译’按钮之前重新阅读代码以修复愚蠢的拼写错误,你会过得更好。”

    这是一个奇怪的观点——我以为Rust编译器以提供有用的错误信息而闻名,那么为什么我要花时间仔细检查代码中的拼写错误,而让编译器替我找到它们呢?我肯定会犯一些愚蠢的拼写错误,希望计算机能帮助我修正它们。

    1. 因为如果你“肯定会犯低级错误”,而且没有养成在每次小改动后反复尝试的习惯(而当编译时间变长时,这种习惯就更难养成),那么你很可能一次就会犯多个低级错误。如果你自己先检查这些错误,你很有可能一次就能发现并修复多个错误——而试图一次处理多个编译器错误通常并不是明智之举,无论这些错误有多么有帮助。

    2. cargo fix 可以自动修复一些问题,但并非所有问题。

  25. 编写一个 CHIP8 模拟器!

    额外挑战:不使用堆分配。这实际上更简单,因为你基本上不需要处理生命周期。你只需将一个状态对象传递给输入系统,然后传递给宾客 CPU 系统,再传递给渲染器,循环重复。

    而且我指的是……看看匹配表达式在处理操作码时有多么出色:https://github.com/ablakey/chip8/blob/15ce094a1d9de314862abb

    我的第二个(也是最后一个)Rust 项目是一个 Game Boy 模拟器,基本上工作原理相同。

    但学习通过编写模拟器来学习的最佳之处在于,重复性足够高,你会开始寻找抽象概念并学习宏等内容,这一切都源于自我探索和必要性。

    1. 我发现模拟器作为Rust的第一个项目并不理想,原因正如你所提到的:你需要知道如何在不进行堆分配(或其他跳过步骤,只要避免处理生命周期)的情况下编写它,而如此多的文献和示例模拟器代码都没有做到这一点,这会导致糟糕的体验。问我怎么知道的。

      如果你要用这种风格编写模拟器,为什么还要使用命令式语言,而不用像Haskell这样专为这类任务设计的语言呢?

      1. 这些模拟器几乎在每种语言中都已存在,那为什么还要做呢?重点在于过程,而过程并不需要是最短、最优的路径。

        1. 我只是说这对于学习语言来说并不最优,而不是说不值得去做。过去几年我出于兴趣开发了三个不同的模拟器,第一个是用Rust写的。这段经历对学习Rust并不理想,因为我参考了大量依赖共享数据结构和随意操作内存块的现有实现,这种思维方式在沉迷于8位CPU机制时非常自然。

          后来我编写光线追踪器和路径追踪器时体验更好,不过那时我已学会避免与借用检查器打交道……

  26. 如果一种语言需要这样的文章,迫切地恳求人们咬紧牙关去学习它,也许这就是语言设计上的缺陷。

    免责声明:我还没有花时间去学习Rust,所以也许不要太认真对待这个观点。

    1. 我不知道该如何理解你的评论,除了“不值得做的事情就不值得做”。有些事情有利有弊,难道存在弊端就意味着它完全不可行吗?

      我尽量委婉地表达,但真的感到困惑。

      如果有人写了一篇文章,说弹竖琴很难,但只要坚持下去……你会说弹竖琴是个糟糕的爱好吗?

      1. 也许人们需要被说服去学习Rust,不仅仅是因为他们认为它很难,还因为他们认为它不好?不是所有困难的事情都值得去做。难度只是需要考虑的因素之一。

        我开始学习Rust,但被语言施加的严格限制和“这是唯一安全方式”的态度所吓退。至少在入门材料中,缺乏对这样一个事实的承认:选择编写安全的Rust意味着你正在牺牲许多编译器无法理解的良好模式,以换取安全性。最终我决定放弃,因为我不喜欢这种权衡(而且我的工作也不需要它)

        1. > 选择编写安全的Rust代码意味着你正在牺牲许多编译器无法理解的良好模式,以换取安全性

          历史上,程序员们严重高估了自己编写完全安全代码的能力,因此如果编译器能够判断代码是否真正安全,这将是一个巨大的优势。

          1. 你陈述的第一部分听起来有道理,尽管这……未经证实且缺乏实际依据。

            您陈述的第二部分基于“安全”在此情境下的具体含义,以及它是否对特定情境构成巨大优势,存在较大争议。

            关于Rust在某些任务和目标中阻碍进展、并不适用的案例比比皆是[0][1],而那些所谓的“巨大优势”在不同视角和使用场景下,可能变成“巨大障碍”。

            在我个人且非常主观的观点中,我认为Rust在应用于安全应用、实时且具有关键安全要求的情境(例如某些嵌入式场景)时,表现非常出色。但我认为在其他具有严格规则和模式的情境中,Rust会过多地阻碍开发,使得难以进行实验和快速探索解决方案。

            [0]https://barretts.club/posts/rust-for-the-engine/ [1]https://loglog.games/blog/leaving-rust-gamedev/

        2. 问题是,如果你想学习Rust,这个网站提供了很好的建议。我知道,因为我就是通过它学习的Rust。

          Rust 不是一种你应该随意选择的语言,如果你还没有准备好投入精力。就像你不会选择完整的汽车级 C 编程来快速学习编程以获得一份工作或类似的事情。

          Rust 学习曲线陡峭,但更难的部分(如文章中所提到的)是如果你认为自己已经是一个好的程序员,那么你需要卸下其他编程语言的模式。

          1. 我认为一个人可以理解Rust,同时又不喜欢它?对Rust的批评并不都源于认为它太难。我欣赏它本身以及它试图解决的问题。我只是不喜欢语言设计中的许多方面,认为它们为了实现目标而显得不必要地丑陋。

            1. 我认为你回复的人从未说过“只有不理解Rust的人才会讨厌它”,或者类似的话。

              即使假设他们这么说了,我也不确定“欣赏”Rust是否意味着你“理解”它。在两句话的论述中,第二句选择不同的词汇,可能是一种微妙的暗示,表明你其实并不了解Rust,尽管你读过关于Rust的文章并对此做出判断。如果这是真的,那么它并不能有力地支持第一个陈述。

              1. 我不确定你是否完全理解我的意思。

                我试图区分因Rust学习/使用难度(真实或感知到的)而讨厌它,与因原则上不喜欢它的权衡、实现目标的方法、语法、类型系统等而讨厌它。这种二分法无论一个人对Rust的经验水平如何都具有意义,只要超过一定水平(顺便说一句,我相信我对Rust的了解足以形成有根据的看法)。

                例如,我对Haskell了解不多。在我看来(以及我在线上读到的许多人的观点),它似乎很难学习(可能也难以使用),尽管我对函数式语言有一定的了解。然而,基于我目前对它的有限了解,这是一门我非常希望在有时间的情况下深入研究的语言,因为它几乎所有方面都让我觉得非常有道理。

                这里有一个令人惊奇的事实,我在开始学习Haskell之前就设计了自己理想的语言,而我所学习的Haskell中的几乎每一个语言构造都与我设计的语言完全吻合(甚至包括像“where”、“do”块等关键字)。

      2. 让我来帮你

        如果普通人需要100万小时才能学会Rust,那么普通人就不会学会Rust

        如果普通人只需要1小时就能学会Rust,那么普通人就会学会Rust。

        如果你在设计一种语言,在其他条件相同的情况下,你会选择哪种?

        对于你的问题,不,但我不会对大多数人拿起吉他感到惊讶。(两者都比任何编程语言直观得多,所以这个比喻会产生错误的期望。这是一个精明的政治举措,但可能只是让更多人对Rust失去兴趣)

        1. > 如果你在设计一种语言,在其他条件相同的情况下,你会选择哪种语言?

          但为什么你会认为其他条件都相同?你可能不同意Rust做出的权衡,而且并不存在适用于所有用途的完美语言,但它确实让编写复杂软件变得更容易。

          我曾有机会调试过奇怪的内存损坏问题,以及“哇,用Rust设计这个真难”的情况,经过与这类问题的妥协,我现在能完成更多工作,且内存损坏和设计问题都减少了。

    2. 事实上,等到你成为资深开发者时,你已经遇到了让Rust值得学习的教训,但可能并未真正理解所有影响。

      许多人会认为,既然我使用的是垃圾回收语言,Rust 没什么可教我的。即使在垃圾回收语言中,人们也会创建不可变类型,因为可变性与共享引用相结合的可能性会让事情变得极其混乱,因此他们将不可变性视为一种万能药。然而,一旦你有了不可变类型,你很快就会意识到,你还需要一些方便的方法来修改这些对象,而你创建的用于此目的的方法往往比允许对可变对象进行的操作更加繁琐。你希望有一种方法可以表达:“这个对象在某个时间是可变的,然后它变得不可变。”这就是借用检查器的作用。

      一旦你开始进行借用检查……为什么还要进行垃圾回收?嗯,表达这些可变性和存在的时间线是有成本的,因为你需要理解时间线,而大多数人宁愿不花这个精力——也许可变性或不可变对象的糟糕易用性并没有那么糟糕。所以我进行垃圾回收,因为我不想理解对象的生命周期。不理解对象的生命周期是共享可变性困难的原因。不可变性消除了这个问题,而无需我理解。Rust 可以教你这个教训,让你做出明智的选择。

      当然,你也可以直接听我的话,学习同样的教训,但对许多人来说,亲身体验是有价值的。

      1. > 不理解对象的生命周期是共享可变性困难的原因。

        嗯,不;根据我的经验,困难主要来自于思考语义。例如: 这两个客户端目前共享一个可变对象;它们是否应该观察彼此的修改?或者:如果我克隆这个对象,是否会后悔没有将更改传播给其他客户端?

        1. 如果你理解它将如何工作,那只是一个需要做出的决定,而决定(尽管设计并不一定容易)并不是我所说的困难:这是普通的工作。我所说的困难是指 bug 和不稳定性,而 bug 在这个领域发生是因为存在意外的竞争条件。完全不可变性只是迫使自己理解的一种方式,如果这两个客户端需要观察修改,那么你必须手动传播更改,而不是依赖共享内存语义。但更糟糕的是,即使你决定它们应该观察到更改,如果你以非原子方式修改多个属性,往往会导致数据撕裂。这是生命周期问题:可变引用可以与不可变引用同时存在。在Rust中,你无法通过共享普通引用来实现这种运行时可观察性,一旦共享,对象就变得不可变。这迫使你使用RefCell实现内部可变性,而独占/共享引用在运行时被强制执行以消除撕裂。这些借用的生命周期很重要,它们的重要性取决于语义的选择,但我不会称选择本身是困难的部分。

    3. 作为一名学习过Rust但日常主要使用Python的开发者,我认为Rust并不存在语言设计上的缺陷。它只是一个非常严格的语言,如果试图像编写非Rust代码那样编写Rust代码,其严格性可能会让你头疼。

      这意味着,例如,如果你有很高的美学标准并试图编写面向对象的代码,最终你会遇到障碍。为什么?不是因为Rust是一个糟糕的语言,而是因为你试图像编写Java或其他语言一样编写Rust。

      如果尊重Rust有其独特的方式来做事情,并且这些方式比你习惯的更注重数据,那么Rust是一个非常不错的语言。

      严格性对初学者来说可能令人望而生畏,但随着复杂性的增加,它变得绝对是天赐之物。在其他语言中,我只能在错误发生时发现它们,而大多数Rust代码只需按Rust的方式编写就能正常运行,因为错误会在编译时被捕获。

      这并不能防止逻辑错误,但这些错误可以通过绝对出色的测试集成来解决。Rust并非完美无缺,但它无疑是一门值得学习的语言,即使你从未使用过它。它在缓解某些类型的错误方面的做法,也可以转化为其他语言中的良好编码实践。

    4. Rust的设计决策有时很难理解,Mojo是另一门带有借用检查器的语言,但由于做出了几个不同的决策,它比Rust容易学习得多。首先是值语义,在 Rust 中,人们在学习时被要求始终进行克隆,为什么这种语义没有内置到语言中?这是大多数静态语言(如 C、C++、Go 等)所具备的特性。这是许多人接触 Rust 时所携带的思维模型。

      次要地,Mojo 的生命周期并不会告诉编译器何时可以安全地使用一个值,而是何时可以安全地删除它。因此,生命周期并非基于作用域,引用会延长其所引用的值的生命周期,但值会在最后一次使用后立即被销毁。在 Mojo 中,你永远不会看到“值的生命周期不够长”这样的提示。

      仅仅这两个设计决策就解决了如此多的使用便利性问题。

      1. > 人们在学习时被告知要始终克隆,为什么这种语义没有内置到语言中?

        因为与复制相比,克隆是昂贵的,并且会生成类型的新实例。在 C 中,你不会克隆,而是简单地复制结构体或指针,这将导致指向同一内存的指针或成员指向同一内存的结构体。

        C++ 则有复制构造函数,你必须显式移动,这常常会产生不必要的复制(在克隆的意义上)

        > Mojo 的生命周期并不会告诉编译器何时可以安全使用一个值,而是何时可以安全删除它,

        如果将变量以可变方式传递给函数会发生什么?

        1. > 如果将变量以可变方式传递给函数会发生什么?

          会以何种方式发生?Mojo 使用 ASAP 内存模型,值将在最后一次使用时被销毁。Mojo 的数据流分析会跟踪这一点。

          在安全性方面,Mojo 将强制执行 `别名或可变性` – 类似于 Rust。

          > 另一方面,C++ 有复制构造函数,你必须显式移动,这常常会产生不必要的复制(在克隆的意义上)

          Mojo 也有复制和移动构造函数,但与 C++ 不同,这些构造函数默认不会被合成;类型创建者必须显式定义构造函数或添加合成器。在 Mojo 中,你可以定义不可复制且不可移动的类型,这些类型只能通过引用传递。你还可以定义可复制但不可移动的类型,或可移动但不可复制的类型。

      2. > 但值将在最后一次使用后立即被销毁

        值得一提的是,这似乎曾在 Rust 中被考虑过,但开发者最终决定不采用。正如 Steve Klabnik 在 2018 年所描述的 [0]:

        > 这被称为“早期释放”,我们没有实现它,因为担心不安全代码。是的,编译器可以识别安全代码,并且没有问题,但不安全代码,从定义上讲,无法被检查。

        [0]: https://users.rust-lang.org/t/drop-values-as-soon-as-possibl

      3. > 但值将在最后一次使用后立即被销毁

        这是引用计数吗?

        1. 不,这是确定性编译器分析。他们称之为ASAP内存管理

    5. 学习任何编程语言对初学者来说都感觉难上10倍,所以你也可以说在这种情况下编程不值得学习。任何新事物都有学习曲线。

    6. > 如果一种语言需要这样的文章,恳求人们咬紧牙关去学习它,也许这就是语言设计上的问题。

      这类文章的问题在于,它们并没有真正触及问题的核心:

      有些程序是Rust根本不允许你编写的。

      Rust 这样做有其合理性。然而,这与人们之前使用过的几乎所有编程语言都截然不同,因为在那些语言中,你可以编写最糟糕的代码并让它编译通过,有时甚至能勉强运行。作为程序员,你必须接受无法编写某些类型的程序,否则 Rust 可能不适合你。

      1. > 有些程序,Rust 根本不允许你编写。

        你能具体列举几个这样的程序吗?

        我能理解 Rust 可能不允许你以你想要的方式编写某些内容,但我无法理解为什么一个程序在 Rust 中无法表达……

        1. 他们指的是安全Rust。Rust包含Unsafe正是出于这个原因。

          1. 安全Rust不是图灵完备的吗?我能理解纯粹的“仅安全Rust”程序可能较慢的论点,但它仍然可以表达

            1. 图灵完备性不考虑效率,也不考虑“调用操作系统以显示输出”等在构建实际系统时必要的现实情况。

              1. 恕我直言,我认为这是在转移话题。如果我们已经排除了所有形式的I/O,那么我们实际上在讨论什么?

                1. 我们没有,这就是为什么图灵完备性与当前问题无关。

                  我可以使用安全的Rust实现Brainfuck的非I/O部分,因此它是图灵完备的。这并不改变这样一个事实:即存在无法用它表达的有用程序。

      2. > 有些程序Rust根本不允许你编写。

        如果你在编写纯粹安全的代码,我认为从实际角度来看这是正确的,但你几乎总是可以使用不安全模式来实现你认为Rust不允许你做的事情。

    7. 我怀疑这样的文章更多地反映了作者的观点,而非语言本身。

      请注意,我在此并非在批评作者。我认为将热情转化为帮助他人学习的努力是件很棒的事情。

    8. 这篇文章更关注学习曲线而非Rust解决的问题,这是一个观察点。我认为需要同时考虑这两个方面才能得出是否值得使用的结论。

    9. 我花时间学习了Rust,你完全正确。它是一种非常复杂、由委员会设计的语言。它拥有出色的工具链,但与同样由委员会设计的竞争对手C++相比,它仍然简单得多,但学习起来绝非易事。

      1. > 它非常复杂

        我认为它相对简单。比C++简单得多(显然)。对于能编写C++且有OCaml/Haskell/F#经验的人来说,这不是一门难学的语言。

        1. 当然,C++的规范更复杂,没人能否认这一点。

          “复杂”不是恰当的词汇。“令人费解”更贴切,或者说“反直觉”或“繁琐”。如果“对有C++、OCaml、Haskell和F#经验的人来说足够简单”等同于“不难”,那么我认为这种争论不会频繁出现。

          1. 你所说的“令人困惑”,我称之为“不同”。不同并不意味着“复杂”或“困难”(孤立来看),但它可能令人困惑,就像第一次在另一侧行驶时会感到困惑(但这并不意味着它是“错误的”)。

          2. 当然,这非常主观。对于只对Python或JavaScript有浅层了解的人来说,Rust可能显得难以企及。但如果你对最常见的编程范式感到舒适,我并不觉得Rust令人困惑。

            我的意思是,你不能指望几天内就能学会一门新语言,它总是需要一些努力。我认为抱怨语言难学的人只是没有付出足够的努力。

            我的经验是,Rust 是一门相对较小的语言,没有引入太多新概念。语法相当直观,编译器也非常有用。借用检查器是我唯一需要适应的新东西。我并非专家,但我的经验是,在全职阅读书籍并进行实验两周后,我就能专业地使用这门语言而不会感到太多阻力。

            另一方面,在C++上花费了更多时间后,我对这门语言并不感到特别舒适。

            1. 我的感觉是,当你深入研究时,Java/C++/Python/JavaScript 实际上都是同一种语言。Rust 从 OCaml 借鉴的比其他流行命令式语言更多(Graydon Hoare 的第一个实现就是用 OCaml 写的,所以灵感无处不在),因此它对许多从未接触过函数式编程范式的开发者来说确实非常不同。优秀的Rust代码是命令式编程与强烈的函数式风格的混合。糟糕的Rust代码则是试图通过迭代时修改数组来完成一切,就像在命令式语言中那样。

              对我来说,我几乎从不在Rust中编写“for循环”和“if语句”;相反,我使用“函数式迭代器”和“匹配表达式”,这些与借用检查器配合得更好。

              例如,在命令式语言中,边迭代数组边修改数组是一种常见模式,虽然能编译通过,但常在运行时引发难以推导的逻辑错误。而在Rust中,此类操作会导致编译时错误。因此你需要重写为更函数式的代码,编译通过后,神奇的是它“就是能正常工作”,这正是Haskell等函数式语言的常见特性。

              我认为,人们对学习曲线成本的担忧很大程度上是因为开发者尚未意识到,一旦克服学习曲线,代码更常正确无误,从而减少运行时错误。在其他语言中,代码虽能更快编译,但运行时却需花费大量时间调试那些在编译时未预料到的错误。

            2. C++ 是一种庞大而复杂的语言。我从 2002 年到 2014 年左右断断续续地使用它,但从未真正感到舒适。每个人似乎都使用自己的方言。

              (我正在利用业余时间学习 Rust。)

      2. 这是一种权衡。Rust 给我们带来了速度和安全性,但它没有给我们带来“易于学习”。

        我认为这是一个很好的例子,说明“委员会设计”是好的。Rust 委员会做得非常出色

        谢谢

        人们说骆驼是委员会设计的马(https://en.wiktionary.org/wiki/a_camel_is_a_horse_designed_b…)

        是的:

        * 骆驼的耐力是马的两倍

        * 只需马匹一半的食物和四分之一的水

        * 载重是马匹的两倍

        是的,我喜欢委员会式的设计。我参加过一些非常好的委员会,也参加过一些非常糟糕的委员会,但没有什么能比得上一个好委员会的力量

        感谢Rust!

          1. 嗯,社区中确实有模仿宗教社会结构的意愿,尽管可能是以讽刺的形式。我的意思是,他们故意将煎饼命名为“货物”,就像“货物崇拜”一样,对吧?Rustacean、rustomicon以及我从社区中看到的其他几个词汇,都似乎遵循了相同的理念。我几乎惊讶他们没有为这些核心概念(所有权和借用)使用更花哨的术语。Perl 也充满了宗教元素,比如“祝福你的对象”,尽管 Larry 实际上更倾向于“真正的信徒”那一派。

            1. 教条主义文化可能是我的第一个建议。我总是问为什么Rust有CVE漏洞,如果它“内存安全”,但令人惊讶的是从未得到答案

              1. > 我总是问为什么Rust有CVE漏洞,如果它“内存安全”,但令人惊讶的是从未得到答案

                答案很简单:漏洞存在。即使在形式上证明的软件中,也可能出现错误。没有什么是不完美的。

                此外,内存安全是一个属性,当人们谈论它时,他们指的是“默认情况下”。所有语言在其实现中都包含一定数量未经证明的不安全代码,或通过FFI等功能。当这两个世界相互作用时,问题可能会出现。然而,现实世界中的使用表明,与没有这些默认设置的语言相比,此类情况非常少见。这些例外情况也是你所说的CVE的来源。

              2. CVE 不仅仅用于内存泄漏,虽然消除(或大幅减少)此类问题是一个值得宣传的合理点,但不应将其误认为是消除任何安全问题的魔法安全设施。

      3. 这不是委员会设计,而是通过拉取请求进行设计它没有一个中央https://en.wikipedia.org/wiki/Benevolent_dictator_for_life像 Python 过去那样,人们可以作为一个团队提出并实现功能,代码质量至关重要(尽管安全/设计方面的理论问题也同样重要),而不是公司无休止地为自己的特色功能争论不休却毫无进展。看看 C++ 获得新功能需要多长时间。

        1. > 看看C++获得新功能需要多长时间。

          我不确定“功能不足”曾是任何人对C++的抱怨。

          1. 有些 C++ 功能确实有人呼吁了十多年

            其中之一是模式匹配(不过公平地说,Rust 从一开始就具备这个功能)。

    10. 也许 Rust 太复杂了,对于大语言模型 (LLM) 来说,生成正确的代码(一次性)而不产生不存在的函数,更加复杂。

      我宁愿选择这种复杂性,也不愿面对 JavaScript 或任何其他弱类型和动态类型语言带来的问题。

      1. 虽然确实存在超过两种编程语言。我感觉关于 Rust 的争论往往会演变成安全与易用性之间的虚假选择。

        在 Rust 出现之前,我曾听到 Haskell 或 Scala 开发者用同样的论点来为他们的语言选择辩护。

        我知道Rust会长期存在,但这主要是因为它拥有可行的生态系统和高质量的开发工具。它的流行是_尽管_其许多语言特性以牺牲额外的1%安全性为代价,换取了90%的额外学习曲线。

        1. > 特性以牺牲额外的1%安全性为代价,换取了90%的额外学习曲线。

          我记得微软和谷歌都曾讨论过现实世界中约50%的安全问题是由Rust不允许的操作引起的(如内存释放后使用、悬空指针、双重释放等)。即使谷歌在开发Go(另一种具有出色实际应用的优秀语言)的同时也使用Rust,这一点在我看来颇具启发性。

    11. 我认为关于Rust的设计决策可以展开很多讨论,但这些文章的存在并不能说明语言本身的问题。我认为Python比Rust更需要这类文章,但原因完全不同。在过去二十年里,越来越多的程序员并非来自工程背景,但我从未见过有人使用过Python生成器或槽。数据类并不罕见,但主要以pydantics的“版本”形式存在。这对于很多Python代码来说并不重要…… 这是一个4chan可以同时为400万用户提供服务,而运行一个1万行PHP文件的Apache服务器自2015年以来从未更新过的世界……因此,你可以在95%(或更多)的时间里使用低效且完全基于内存的Python代码。

      但这并不意味着你应该这样做。想想全球范围内因糟糕的 Python 代码而浪费的能量……当然,区别在于任何人都可以编写 Python 代码,而并非所有人都能编写 Rust。我个人并不特别喜欢 Rust,我更倾向于选择 Zig……但同样,我也会选择 C 而不是 C++,而当我优化属于最后 5% 的 Python 代码时,我确实会这样做。从这个角度来看……作为一个真正需要理解 Python 内部工作原理以及何时该做什么的人,我认为 Rust 是一种更容易学习的语言,且设计上少了很多“设计缺陷”。当然,Python 并不是最好的例子,因为即使是我们这些热爱它的人也知道它是一门糟糕的语言。但我认为它显然已经成为“所有人”的语言,在大语言模型(LLM)时代更是如此。由于我们的 AI 朋友不会编写优化的 Python 代码,除非你明确告诉他们使用生成器之类的东西以及在哪里使用它们,而且由于你(不是你个人)不会这样做,因为你从未听说过生成器,那么我们的 AI 统治者实际上不会提供帮助。

    12. > 也许这是语言设计上的缺陷

      为什么

  27. 好文章,思路清晰,文笔流畅,(撇开成语不谈)其中许多内容也适用于学习其他语言。

  28. 与其他编程语言相比,Rust的编译器和静态分析工具在构建时实现了最佳实践。

  29. 是什么让Rust被认为与其他同类编程语言相比学习曲线陡峭?

    1. 并非如此。我所知道的与Rust同类别的唯一语言是C++,它学习和使用起来要困难得多。

    2. 主要是借用检查器。

      生命周期语法可能令人望而却步。

  30. 关于第一个示例,最长的()函数,为什么编译器不能自己推断出来?设计缺陷是什么?

    1. 你传入了两个引用并返回了一个引用。

      编译器知道返回的引用必须与传入的引用之一相关联(因为你不能返回函数内部创建的引用的引用,而所有输入都是引用,因此输出必须引用输入)。但编译器无法确定结果来自哪个引用,除非你告诉它。

      理论上它可以通过检查函数体来确定,但编译器只处理函数签名,因此必须在函数签名中添加注释,以便它确定返回引用预期生命周期。

      1. > 理论上它可以通过检查函数主体来确定,但编译器仅基于签名工作

        请注意,这是有意为之的选择而非限制,因为如果编译器通过分析函数主体来确定参数和返回值的生命周期,那么修改函数主体可能导致非显式的 API 变更。如果生命周期仅依赖于签名,那么你对函数调用者关于对象生命周期的承诺是明确的,而更改这些承诺必须通过显式修改签名而非隐式方式进行。

        1. > 修改函数主体可能导致非显式的 API 破裂变更

          确实如此。许多微小变更都会导致 API 破裂。这对库开发者而言并不理想。

          你可以认为它已经破裂,但这种破裂是被强加给每个 API 调用者,而不仅仅是某些破裂的调用者。

        2. 哦,我不是想让它听起来像个问题。我个人强烈偏好基于签名的类型系统,而非像TypeScript那样,你可能在不知不觉中返回一个完全意外的类型,直到在显式类型上下文中使用时才意识到。

          我认为编译器的类型检查阶段仅检查签名会快得多。

    2. 编译器可以推断出这一点,但问题是编译器还需要理解在调用该函数的位置的生命周期。一般情况下,编译器不会查看被调用函数的代码以了解其具体实现,而是依赖于函数声明。

      如果未显式指定生命周期,则 `longest` 的生命周期与第一个参数的生命周期相同。这是“生命周期省略”规则,允许在大多数情况下无需显式指定生命周期。

      longest也可以返回第二个引用。添加生命周期后,函数头部明确指出:返回值的生命周期是所有参数生命周期的最小值。而非第一个参数的生命周期。

    3. 这是设计选择。

      要让编译器自动处理所有此类情况,需要进行广泛的静态分析,这会导致编译过程耗时过长。

      1. 如果 IDE 能自动修复就好了。

        也许在输入时自动修复,或在保存文档/换行时自动修复。

  31. > 将借用检查器视为合作者,而非对手

    为什么我要与不理解双向链表的人进行配对编程?

    1. 对于不了解引用的人来说,这可能指的是在Rust中实现双向链表这一臭名昭著的棘手任务[1]

      虽然可以实现,但不像其他语言那样简单,因为生产级别的链表在Rust中是不安全的,因为Rust的所有权模型与双向链表的结构本质上是冲突的。双向链表中的每个节点都需要同时指向其下一个和上一个节点,但Rust的所有权规则不允许同一数据有多个所有者或循环引用。

      你可以使用 Rc<RefCell<Node>>(带内部可变性的引用计数)在安全 Rust 中实现双向链表,但这会增加运行时开销且性能较差。或者你可以使用原始指针和不安全代码,这是大多数生产环境实现的做法,包括标准库的 LinkedList。

      https://rust-unofficial.github.io/too-many-lists/

      1. Rust 仍需找到解决这一问题的办法。从概念上讲,可以通过编译时检查来实现。想想 RefCell/Weak 和 .upgrade() 以及 .borrow() 在编译时被检查的情况。

        我已经与一些 Rust 开发人员讨论过这个问题。问题在于 traits。你需要知道一个 trait 函数是否可以借用其参数之一,或者其参数之一所引用的东西。这需要进行分析,而分析只能在泛型扩展之后才能进行。或者对特质参数进行更多属性分析。这需要大量繁重的操作来解决一个小问题。

        1. > Rust 仍然需要一种方法来摆脱这种混乱。

          它有一个方法:使用原始指针和 unsafe。人们太害怕 unsafe 了,它专门用于在需要时使用。

        2. > Rust 仍然需要一种摆脱这种混乱的方法。

          实际上,它并不需要。实现双向链表的难度并未阻止人们在现实世界中高效地编写数百万行 Rust 代码。大多数程序员花在重新实现链表结构上的时间不到 0.1%;Rust 对剩下的 99.9% 非常有用。

          1. 双向链表虽不常见,但常需通过后向链接指向所有者。本质上是相同的问题。

            1. 后向链接与弱 Arc 引用配合使用没问题吧?

              1. 是的。但 Arc 必须包裹一个 Mutex,这意味着你需要锁定才能访问。这与 Rc/RefCell/借用机制是等价的。

                调用 .lock() 的问题在于存在死锁的潜在风险。有人正在研究静态分析以防止死锁,这与静态分析双重借用保护问题是等价的。我们可能还需要一两个博士论文才能找到解决方案。以下是来自上海的最新研究[1],概述了理论,但代码似乎尚未公开。

                [1] https://arxiv.org/pdf/2401.01114

      2. 抱歉,我还没有花时间学习Rust,但我写过很多现代C++。所有权模型是否类似于std::unique_ptr和std::move,而`Rc<RefCell<Node>>`是否与`std::shared_ptr`相同的概念?但不太符合惯例?还是我理解错了?

        1. 并非如此,因为Rust对所有对象强制执行“多个读取器或单个写入器”的不变性,而C++中没有对应的机制。正是这一不变性使得双向链表的实现变得困难(因为链表中的每个内部节点都可从两个位置读取,这意味着它永远无法被写入)。

      3. 我正在处理一个代码库,其中所有列表都是双向链表,这既是其优点也是缺点。

        停!

        如果你使用双向链表,你(可能)不需要或不希望这样做。

        几乎没有需要在列表中双向遍历的情况(你想要一个树吗?)

        双向链表会因不需要的后向链接而浪费内存。

        单向链表的逻辑非常简单:有一个节点和其余节点。双向链表则会使认知负荷增加一倍以上。

        思考!花时间仔细分析你使用的数据结构。你不需要那个复杂、浪费的双向链表

        1. > 几乎没有需要在两个方向上遍历列表的情况

          但你可能需要以 O(1) 的时间复杂度移除一个你拥有指针的给定元素,而单向链表无法做到这一点

          1. 如果这是你需要处理的特定用例,那么如果你同时拥有要删除的节点和前一个节点的指针,操作时间复杂度仍是O(1)。

            在操作列表时携带第二个指针是否更高效,还是在每个列表节点中存储第二个指针(即双向链表),这取决于你的问题场景。

            或者,O(n)的删除操作是否可以接受。

          2. 获取该元素的指针意味着需要在堆中随机跳转以遍历列表。

            链表非常适合插入/删除节点,只要你不需要遍历列表或访问任何特定节点。

            1. 你假设没有其他数据结构指向该元素。可能有。例如:实现一个缓存。

              每个元素包含:键、值、哈希表桶的链表节点、LRU的链表节点。通过哈希表查找元素。元素同时是哈希表和链表的成员。链表用于在需要时作为LRU来释放内存。

              LRU从未被遍历,但经常需要删除和重新插入。

    2. 这样你就能明白,借用是用于在静态已知作用域内提供临时共享/独占访问权限,而_不是_用于存储数据。

      尝试使用非所有权引用构建永久数据结构是Rust中常见的新手错误。这类似于来自垃圾回收语言的用户可能期望局部变量的指针在离开作用域/函数后仍保持有效。

      就像在 C 中需要知道何时需要使用 malloc 一样,在 Rust 中需要知道何时需要使用自包含/拥有类型。

      1. 我遇到过最需要自引用类型的场景是,我希望执行一次操作后缓存结果,同时仍需访问原始数据。

        例如:解析 cookie 头部以获取 cookie 名称和值。

        在这种情况下,我选择存储指示每个键和值范围的索引,而不是字符串片段,但显然这更容易出错且难以阅读。基准测试显示,与将值克隆到拥有字符串相比,这种方法几乎快了两倍,因此值得一试,因为它位于热点路径中。

        我确实希望这能更简单一些。我知道可以通过Pin来绕过这个问题,但在我看来这非常令人困惑,而且你仍然需要处理指针,而不是仅仅使用&str。

      2. 需要注意的是,一些使用支持栈分配的GC语言的用户,已经习惯于将此类指针/引用视为编译器错误。

        D 示例,https://godbolt.org/z/bbfbeb19a

        > 错误:返回 `& my_value` 逃逸了对局部变量 `my_value` 的引用

        C# 示例,https://godbolt.org/z/Y8MfYMMrT

        > 错误 CS8168:无法通过引用返回局部变量 ‘num’,因为它不是引用局部变量

    3. 我更愿意与对双向链表持谨慎态度的人进行配对编程,但对方对理解所有权的热情远超于此。

    4. 因为我的大部分代码都不是双向链表!

    5. 因为你更关心生产力和安全性,而非 l33t h4x0r 仪式?

    6. 不是它不理解双向链表。只是你没有意识到其后果,且对将程序状态暂时变成不一致的垃圾以实现它们毫无异议。编译器会介意。除非你使用Rc<>。这就是该语言用于表达不一致性的机制。或者如果你够自信,可以使用unsafe {}。借用不是命名指针是有原因的。

  32. 投降吧!进行编译

    度过狂暴的风暴

    你会发现真正的幸福

  33. 无论作者的意见如何,Traits 都是实现 CS 接口概念的另一种方式。

    对一组已知操作进行多态调度,这些操作构成一种特定类型。

  34. 抱歉,但学习基础 C++/C 更容易,让初学者开发者通过基础代码检查工具或 clang-tidy 获得类似结果,成本仅为开发者成本的一小部分。

    我希望 Rust 被采用,并认为公司应强制推行,但你无法从年轻开发者那里获得采用,更不用说资深 C++ 开发者了。

    更不用说将现有的 C++ 代码重写为 Rust 代码,这将耗费巨额成本,尽管我认为公司应该投资于将代码重写为 Rust,因为这是正确的做法。

  35. > …. 例如,“特质有点像接口”这一说法是错误的,……

    等一下。这句话有什么问题?

    1. 我认为这只是一个错误,因为“有点像”是正确的。特质与接口的不同之处取决于你从哪种语言中借用“接口”的概念,但说它“有点像”接口是准确的。

  36. 还有人觉得学习Rust有困难吗?我以为这已经是2015年的事了

    1. 对我来说,它就像Haskell。我花了一个下午研究它,脑子疼得受不了,于是把它归类为“对我来说太复杂的语言”。

      我喜欢的语言,我一见钟情。我不用先爬一座山。

    2. 问题是,一旦你掌握了这些概念(所有权、借用、生命周期),就很难记得当初为什么觉得它难。这在某种意义上是“知识的诅咒”。

      自2015年以来,我们已经解决了语言中的一些问题(非词法作用域的生命周期、异步),但要以所有权为基础进行思考所需的根本性思维模式转变,仍然是新手难以跨越的障碍。

      1. 100%。新手仍然会遇到一些困难,尤其是如果他们之前从未使用过C/C++。

        在借用检查器出现之前,让人们熟悉语言语义的好方法是鼓励他们克隆字符串和结构体,即使生成的代码性能不佳。

        一旦他们涉足线程和异步编程,Arc<Lock<T>>就是他们的朋友,而内部可变性会给他们一些有趣的分心,同时他们吸收更困难的概念。

        1. 你是指 `Arc<Mutex<T>>` 吗?是的,我同意。我也写了一篇关于这个主题的博客文章:https://corrode.dev/blog/prototyping/ 标题有点误导,但它讨论了语言中对初学者友好的逃生机制。或许对新手有帮助。

          1. 任何锁都可以,但这通常是最佳选择。

            精彩的文章!其中包含大量提升效率的建议,对初学者尤其有用。

    3. 作为C++背景的开发者,我并不觉得学习Rust困难。但我确实觉得它的语法令人烦躁,对Cargo更是深恶痛绝。

      仁者见仁吧……

    4. 不幸的是,确实如此。我仍然会用C++而不是Rust来处理低级系统任务。由于我也了解Go,当需要轻量级中间件服务时,我通常更倾向于使用Go。我虽然勉强学会了Rust,但至今仍未在任何地方使用它。我仍然没有弄清楚如何有效地使用Rust进行设计,而文章中提到的方法,如clone()/unwrap()等操作,以及后续的重构,都让我感到不适。

    5. 这真是令人欣慰!这或许是互联网计算圈中唯一一次,我反复看到人们以一种归结为:

      “我不如你擅长学习”的方式争论。

      1. >'我学东西不如你'

        我学校的一些计算机科学教授真的很擅长教我东西,但Haskell和Rust的教授通常没有他们那么擅长。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

你也许感兴趣的: