【外评】 我使用(并喜爱)Rust 已经有 10 年了, 以下是它让我失望的地方
从 2015 年 5 月 Rust 1.0 发布前不久开始,我使用 Rust 已经有大约 10 年的时间了。我用 Rust 开发过很多不同的项目,包括桌面 GUI 应用程序、服务器后端、CLI 程序、通过 WASM 开发的沙盒脚本接口,以及多个游戏相关项目。最近,我为 Bevy 游戏引擎做了大量工作。
我在其他几种语言方面也有丰富的经验: Java、Python、Typescript、Elixir、C,以及一些经验相对较少的小众语言。虽然不足以说我是这些语言的专家,但也足以让我熟悉并体验到它们之间的主要差异。我主要将 Rust 与 Java 进行比较,因为这是我最近在 Rust 之外经常使用的。
在所有这些语言中,Rust 是迄今为止我最喜欢的语言,而且我不打算离开它!我每天都在使用它,90% 的时间都在享受它带来的乐趣。
当然,就像任何实际使用的语言一样,它也有自己的问题。有的时候你会觉得 “这到底是怎么回事?为什么会这样?哦,嗯,也许是这个?不完全是,这太令人沮丧了”。我不是来讨论这些情况的。
我要谈的是我经历过的主要痛点。这些问题反复出现,严重影响了我完成工作的能力,如果不从根本上改变,就无法解决。
以下是我不打算讨论的问题:
- Async/await:: 在我看来,Rust 中的 Async/await 其实相当不错。在没有额外成本或内置运行时、取消等限制条件下,它相当可靠。我还记得在 Rust 2018 版前后出货的压力,尽管如此,我认为它还是出得相当不错。主要问题在于同步代码和异步代码的混合、Pin、生态系统中的多个执行器,以及零成本是否是一个明智的权衡。这些问题已经被讨论得差不多了,我没什么可补充的。也许虚拟线程会更好一些,只需要承担运行时成本,我也不知道。我觉得,既然我们已经有了 async 特性,那么在网络服务器等应用中使用 async 本身就非常可靠了。
- 库生态系统: 是的,我希望它更稳定、更无错误(例如,将 winit 与 sdl 进行比较),但这其实不是语言问题。在这里我没什么可说的。
说说我的抱怨吧。
Result<T, E>
当我刚开始使用 Rust 时,我很喜欢错误只是另一种类型。隐式错误是非常糟糕的;迫使用户意识到函数可能会出错,并处理该错误是一个伟大的设计!
多年来,我在库和应用程序代码中都使用了 Rust,对这种做法越来越失望。
作为一个库的作者,不得不为每一个可能的问题创建新的错误类型并在它们之间进行转换,这很糟糕。最糟糕的事情莫过于添加一个依赖项,调用其中的一个函数,然后还得想办法把它自己的错误类型添加到你的封装错误类型中。像 thiserror(我想这是我试过的最主要的一个)这样的 Crates 虽然能帮上点忙,但在我的经验中仍然是一种糟糕的体验。这还只是一个函数的情况,如果你还想用另一个函数做一些不同的事情,你可能需要一个全新的错误类型。
然后是应用程序代码。通常情况下,您并不关心函数是如何/为何失败的,您只想将错误向上传播,并将最终结果显示给用户。当然,无论如何都会有错误,但根据我的经验,Java 等语言在这方面处理得更好。除了希望使用单一动态派发类型这个显而易见的问题外,对我来说真正的问题是回溯。
在 Java 中,我可以看到一份完美的日志,记录了究竟是哪个函数首先出错,以及错误是如何通过堆栈传播到程序所使用的日志或显示机制中的。在 Rust 中,只要使用 ? 运算符传播错误,就不会有回溯。当然,回溯会产生性能代价,这也是为什么没有内置回溯的原因。
程序库也会遇到这个问题–当用户报告错误时,很难弄清问题出在哪里,因为你只能看到 “顶层函数失败”,没有任何回溯,除非是出现了恐慌。同样,也很难追查为什么你的依赖程序会自己出错。
Rust 在 “迫使开发人员思考错误 ”这一点上做得很好。与 Java 不同的是,函数可能失败的情况一目了然,你不能意外地跳过处理这个问题。我在其他语言中见过很多这样的 bug:某个函数抛出了一个错误,导致程序完全无法运行,而这个错误本应在 10 层以下重试处理。
然而,虽然它是零成本的,而且非常明确,但我认为 Rust 犯了一个错误,那就是它认为人们(在大多数情况下)除了通知用户之外,还会关心函数失败的原因。我真的认为 Rust 是时候标准化一种类似 Box<dyn Error>
的单一类型(包括对字符串错误的支持),并在函数之间传播时自动附加上下文。这并非适用于所有用例,因为它并非零成本,也不那么明确,但它对很多用例都有意义。
除此之外,还有错误信息。错误信息的格式是否应该是 “错误: 还是 “执行 x 失败”?结尾是句号?大写?这其实不是语言的错,但我希望有一个全生态系统的错误格式标准。
Modules
orphan 规则有时很糟糕,模块系统可能也太灵活了。
在 Bevy 的开发过程中,bevy_render、bevy_pbr、bevy_time、bevy_gizmos、bevy_ui 等模块组成了一个 monorepo,而一个顶级的 bevy crate 会重新导出所有模块。
跨crates 组织代码相当困难。你可以在不同 crate 之间随意地重新导出类型,让某些部分成为 pub(crate)
、pub(super)
或 pub(crate::random::path)
。对于导入,也有同样的问题,你可以选择从其他模块中重新导入特定模块或类型。这很容易让你不小心暴露了你本不想暴露的类型,或者重新导入一个模块而失去了你为它编写的模块文档。
比起任何实际问题,这只是权力太大了。这很奇怪,因为 Rust 喜欢明确,但在如何安排类型方面却给了你很大的余地。随你怎么说 Java 的 “一个文件 = 一个类;模块路径遵循文件系统文件夹 ”的方法,但如果不明确的话,那就什么都不是了。在 Java 中跳转到一个大型项目中,要比在 Rust 中更容易知道在哪里可以找到某个类型。
orphan 规则也是一个问题,但我对此没有太多发言权。它有时真的很碍事,甚至对于库开发人员来说,因为要在一个项目中把东西分割成不同的板块(而 Rust 真的鼓励你把东西分割成多个板块)。
编译时间和集成开发环境工具
我的集成开发环境的编译时间和错误检查太慢了。人们在加速 rustc 和 rust-analyzer 方面做了大量工作,我无意贬低他们的努力。但从根本上说,Rust 将 1 个 crate 视为 1 个编译单元,这确实损害了最终用户的体验。触碰 Bevy monorepo 中的一个函数就意味着整个 crate 以及依赖于它的其他 crate 都要重新编译。我真的非常希望,修改函数实现或文件就像重新编译该函数/文件和修补二进制文件一样简单。
Rust 分析器也有同样的问题。IntelliJ 会在启动时对我的项目进行一次索引,并在剩余的开发时间里立即显示错误。Rust 分析器感觉就像每次输入时都在重新索引整个项目(减去依赖关系)。这对小项目来说还行,但在 Bevy 的规模下就几乎无法使用了。
我不是编译器开发人员–也许这些都是无法解决的根本问题,尤其是考虑到宏、编译脚本、cargo功能和其他问题。但我真的希望编译器能维护我的项目结构图,并检测到我只修改了这一部分。这种情况在使用 VDOM 开发 UI 时经常出现,有什么理由不能在 cargo/rustc 中实现呢?
结论
文章到此结束。写作不是我的强项,这篇文章是我在晚上匆忙写成的,目的是记下我最近的一些想法,因为我没有时间坐下来在我很少使用的博客上写一篇合适的文章。我所说的一切都只是表面上的考虑,并没有深入研究围绕这些问题的现有讨论。
不过,这些都是过去几年困扰我的主要问题。我很想听听其他人的想法,看看他们是否也面临同样的问题。
本文文字及图片出自 I've used (and loved) Rust for ~10 years. Here are the ways it disappoints me
你也许感兴趣的:
- Android 全力押注 Rust,Linux 却在原地踏步?谷歌:用 Rust 重写固件太简单了!
- C 语言老将从中作梗,Rust for Linux 项目内讧升级!核心维护者愤然离职:不受尊重、热情被消耗光
- 从电梯故障到编程新宠,Rust为何连续七年称霸「最受推崇语言」
- 【外评】不要把 Rust 写成 Java
- 语言设计: Rust 的几乎规则
- 美国国防部建议将C代码转换为Rust
- 【外评】Why Not Rust?
- 【外评】为什么我希望不要让 Rust 锈化一切?
- 【外评】Rust 版的 Linux 文件系统
- Vue诞生10年,创始人尤雨溪推动“锈化”——通过Rust提升Web基础设施性能
如果 Rust 分析器在每次更改时都要重新编译很长时间,这很可能意味着它在编译时使用的特性或环境变量与您构建应用程序时使用的不同。默认情况下,RA 会使用与 cargo build 相同的目标目录来存储构建工件,如果它们的构建不兼容,最终就会导致彼此不断进行完整构建。
这种情况在使用 Bevy 时尤为常见,如果您为自己的构建启用了 bevy/dynamic_linking(动态链接)功能,却没有为 Rust analyzer 的构建启用该功能。
最简单的解决方法是告诉 Rust 使用不同的目标目录,请参见 rust-analyzer.cargo.targetDir: https://rust-analyzer.github.io/manual.html。
另一个解决方法是确保所有特性和环境变量相同,这样它们就能重复使用彼此的构建工件,不过这可能比较棘手。
假设我在项目文件中什么都没做,而且它是最基本的,为什么会出现这种情况?
你可能是从一个设置了与 Rust analyzer 运行时不同的环境变量的 shell 中运行的。例如,在这个问题中,有人看到这种情况是因为他们从 vscode 终端运行 cargo,而从那里运行时 PATH 不同,当 PATH 发生变化时,blake3 crate 会导致重新编译 https://github.com/BLAKE3-team/BLAKE3/issues/324。
正如我所说,这很棘手。我建议尝试设置不同的目标目录,看看是否能解决这个问题。
Rust-analyzer 是否记录了相关问题?如果这是一个常见问题,它确实应该注意到并提示用户或提供调整设置的服务。
有一些看起来相关的问题:https://github.com/rust-lang/rust-analyzer/issues?q=is%3Aissue+is%3Aopen+rebuild
从技术上讲,这不是 RA 的问题,通常是由于你使用的环境或 crate 添加了依赖于你环境中某些东西的构建脚本,或者你添加了 RA 不知道的功能。
如果它们能检测到这种情况并发出警告,那就太酷了。它们可以默认使用不同的目标目录,但这样做会使目标目录的大小增加一倍,因此对于磁盘空间有限的用户来说,这是一个很大的权衡。
实际上支持回溯。你只需设置 RUST_BACKTRACE=1,并在错误时调用 .backtrace():
https://docs.rs/anyhow/latest/anyhow/struct.Error.html#method.backtrace
这很好,我只是希望它能内置于 std 库中,并在我的依赖项中使用
实际上,std 中已经有了反向跟踪,将其与错误类型一起使用非常简单。
我知道,但要让我的所有依赖库在出现错误时捕获这些信息就不那么容易了
我没有遇到过这样的问题,我只是让我所有的结果函数都返回我的封装结果类型,如果启用 – vvv 标志,它就会处理生成回溯。
您能发布一个代码示例吗?
好的,我使用了 backtrace crate,因为我不需要在 nightly 上使用这种方式,我编辑了其中的代码,因为它是专有的,但它应该能给你一个概念。
我不想在这里粘贴一个巨大的文件,所以这里有一个链接:
alkeryn.com/example.rs
Source: alkeryn.com/example.rs
std Backtrace 未完成 – 例如,frames() 仅在夜间可用
是的,如果你不想使用夜间版,还有一个 backtrace crate,非常不错。
关键是它不在标准版中,所以不是无摩擦的。
对我来说,它是否在标准中与它是否无摩擦无关,rand 不在标准中,crypto crate 也不在标准中,itertools、tokio 等也不在标准中。
在我看来,它们不需要在标准中。
那就太好了
谢谢
是的
我仍然对模块感到困惑。比异步或借用要困惑得多。
我认为,在不了解的情况下就能轻松使用模块,会让情况变得更糟。
只要复制/粘贴并遵循现有模式,你就能很容易地活下来。在这里添加文件,在这里使用发布模式,在这里使用发布模式。但突然需要做一些稍有不同的事情时,我就不知道该如何操作了。
我记得的唯一规则是 “mod <module>”会查找同一目录下的 <module>.rs 或 <module>/mod.rs
还有第三种。在 “module1.rs ”中,“mod module2; ”也会查找 “module1/module2.rs”
这与第一种情况相同,只是在嵌套模块的情况下。
我依赖 Rust-analyzer 来帮我做这些事情。
一厢情愿:
rust-analyzer:
然后,我将使用 VS Code 的 “快速修复 ”功能添加模块。阅读 “一厢情愿 ”阶段的错误信息也有助于加深我对模块系统的理解。
我认为,学习 Rust 模块系统的一个好方法是在单个文件中真正熟悉使用模块:了解公共和私有事物是如何划分的,pub mod 和 pub use 是做什么的,等等。我认为这并不难,关键在于实践。一旦你了解了单个文件中模块系统的来龙去脉,你就可以将这些知识应用到多个文件中,因为你知道一个文件就是另一个模块。这对我很有用。我第一次直接处理多个文件时没有掌握好。
我想这就是为什么我觉得 Rust 的模块系统如此令人不满意的部分原因。
它的心智模型主要是围绕几乎没人想真正做的事(在一个文件中定义所有模块)设计的,而绝大多数常见的用例(在单独的文件中定义模块)却被当成了尴尬的次要特性。
老实说,我真的很喜欢 Rust 的模块系统。它既直观又灵活,真的。在单个文件中定义模块非常方便,原因有很多(代码组织、功能标志门等……),而且使用相同的模型处理多个文件是一致的,也是合理的:你只需在主文件中插入 “mod whatever”,就大功告成了。
还有很少使用的路径属性!
https://doc.rust-lang.org/reference/items/modules.html#the-path-attribute
免责声明:这是我就最近思考的一些问题写的一篇草率的不完全评论。语气显得相当消极,所以我想再次重申: 我喜欢 Rust!
这门语言真的很棒–如果我讨厌它,我就不会用它 10 年,而且每天都在用。我想花一点时间来肯定这些努力,而不仅仅是抱怨:)
我觉得语气还不错。
能把你的博客链接到这里吗?文章发表后,我很想读一读全文。
对不起,如果我没说清楚的话–我并不打算就这个话题写一篇博文。我在 reddit 上写这篇文章是为了在一小时内快速记下我的想法,而不是写一篇正式的博文,因为正式的博文需要几周的时间(我写作速度很慢)。写博文的时间本可以用来写代码,而我又不太喜欢写作。
为了回答这个问题,我的博客在这里:https://jms55.github.io。如果你想阅读一些关于 Bevy 和 3D 渲染的内容,我有两篇文章。
请记住,你完全没有义务让你的博文变得超高强度。这种想法让太多的人无法写作,包括我在内。
我和这个主题中的其他人都非常欣赏你分享的想法,我绝对认为这非常值得放在你的博客上。
我在你的帖子里没有看到任何负面的东西,恰恰相反。谢谢你分享你的经验和见解。
你是一位出色的作家,这篇文章是平衡的、经过深思熟虑的。感谢你为之付出的努力!
事实上,我觉得你的文章很有分寸,也很合理。
也许是因为我主要来自 Scala 和 Haskell 的世界……?:-)
你找到了完美的基调。这是我第一次看到有人谈论 Rust 的编译时间问题,而没有被降权到看不见。
关于库中的 “Result”,用 “struct Error(anyhow::Error) ”或类似的东西(必要时用枚举)如何?这样就不需要不断映射每个错误发生,而且 Anyhow 的 `.context(“reason”)` 方法可以帮助追踪错误上下文。
我在最近的原型中使用了这个方法,目前我对它很满意,当然我的经验有限。
编辑说明:不一定非得是 anyhow::Error,thiserror::Error 或 std::error::Error 也可以使用。这取决于上下文。
用具体类型返回错误的全部意义在于,让用户有能力决定如何处理错误。
任何秘密包含 anyhow 的返回类型都不能满足此属性。
我这篇文章的主要观点之一是,你很少会关心具体类型。在 90% 的用例中,即使是对于库来说,你也只是想把错误传递给用户。如何出错并不重要,你真正关心的是函数从一开始就是可出错的(而回溯对于找出错误的根源非常重要)。
如果这是你的观点,那就在内部使用 anyhow/eyre,然后在将其传递给用户之前将其进行字符串化,并将其放入一个结构中,用你的字符串化调用堆栈来实现 Error。
TIL anyhow 现在可以自动捕获回溯,我还不知道这一点。是啊,我觉得 anyhow 基本上就是我理想中的 API,我只是希望它能被上游使用,而且不只是在我自己的 crate 中使用(这样我就能在依赖库中获取回溯)。
难道不能只返回 anyhow 错误,让用户自己通过 {:#} 获取回溯吗??
在这种情况下,使用 color_eyre–它不需要与其他东西集成,甚至还可以从跨度中建立回溯。
我的建议更像是一种以原型为导向的速度技巧,适用于尚未完全确定 API 的全部范围,并且需要经常来回处理错误的情况。在这种情况下,必须对其进行字符串化,然后将其放入一个结构中,用字符串化的调用堆栈来实现 Error,这简直就是模板。我最终可能会这么做,但不一定是在现阶段。
如果你处于 “我不在乎以后是否会因为原型设计而做出破坏性修改 ”的状态,那就……直接使用 anyhow/eyre 吧
当然,这在某些情况下会更好用,但在其他情况下,我需要一个新类型或在现有枚举中增加一个变体。没有适用于所有用例的单一规则。一种模式在其中一种情况下有效,并不一定会导致另一种模式在另一种情况下无效。
我想说的是,在这种情况下,实用的方法和被认为是 “干净 ”的方法是相互冲突的。
你确实关心函数失败的原因。例如,如果函数打开文件失败,你想知道是权限问题,还是路径不存在。务实的做法是,在 99% 的情况下,只需记录一条信息即可。但如果有人想做任何逻辑工作,你就需要在类型系统中表达所有错误。
是这样吗?还是只想向用户显示错误信息,然后要求他们提供新文件?
你想在错误类型上选择分支,但实际上你几乎从来不想在类型系统中得到这些信息。如果错误类型可以在运行时获得,那么就可以两者兼得。在 Java 等语言中,这可以通过运行时类型信息实现,也可以通过简单的错误代码枚举实现(不过后者需要更多的预见性)。无论采用哪种方式,出错时都应提供字符串信息和回溯信息。
正如我所写的那样–我知道在绝大多数情况下,显示此类信息的能力才是您想要的。让用户来处理就可以了。
至于第二部分–实际实现取决于你使用的语言和风格;就像你在 Java 中提到的,在 C++ 或 Python 中,通常的做法是将不同的原因表示为错误类的继承图。枚举也不错,不过如果您的语言允许您表达该函数可以返回一组特定的错误,那就更好了。基本上,不管是通过 RTTI 还是通过枚举,或者是其他我没想到的机制,它们都会成为类型系统的一部分,而不仅仅是对从互联网/聊天工具中获取的整数进行大条件运算,或者更糟糕的是对字符串进行比较。
我应该澄清一下我所说的 “不希望在类型系统中出现 ”是什么意思。你不希望错误信息成为函数类型签名的一部分。根据我在几种语言中的经验,这样做只会让人头疼,最终的结果通常是使用一些技术来移除这些信息,例如在 Java 中用未检查异常包裹已检查异常,或者在 Rust 中使用 anyhow。
在处理错误(实际处理,而不仅仅是传播)时,类型信息是非常有用的。不过,由于这是运行时分支,因此需要运行时类型信息。在没有运行时类型信息的语言中,枚举是一种替代方法。
我在一个有数千个 crate 和数百万行代码的 monorepo 中工作。人们确实非常关心失败的原因。我认为,在 “太多错误变量 ”与 “只有一种错误变量 ”之间做出选择是错误的二分法。不同的 Rust 用户有不同的需求,这很好。
如果我没记错的话,thiserror 可以做到这一点
我经常将两者结合起来:)
当我正在处理的 crate 已经依赖于 anyhow 时,我会在原型设计时使用 newtype,而当接近完成时,我会重新查看错误案例,因为这样我就能同时拥有所有错误案例的广阔视野。
无论如何都不会保留类型,对库来说不是很好。
error_stack 解决了保留堆栈调用和附加上下文的问题,但明确地为开发人员保留了管理错误类型的功能。
当然不是这样,但蛋糕不可兼得。
在某些情况下,您可能希望为代表潜在实现错误的错误维护一个追溯兼容的错误 API,但在其他情况下,您并不希望依赖程序来处理这些情况,也不希望每次对实现进行微小改动时都引入破坏性改动。
这就是我最近经常使用的模式:
编辑以澄清:不一定非得是 anyhow::Error 才行,thiserror::Error 或 std::error::Error 也可以。
你可以对 anyhow::Error 进行降类,以获得调用它的原始错误–我曾多次这样做,以便在错误发生处编写更可读的代码,同时对错误处理代码进行降类并进行适当处理。
还是我误解了你所说的 “anyhow 不会保留类型”?
但你不知道是什么错误/什么类型,尤其是因为它不一定是一个特定的错误,所以你需要考虑它是该类型还是其他类型。使用 error_stack,您只需在错误上进行匹配即可。
你说的对。是的,我之所以使用它,只是因为我知道我可能会从中得到一个比如说 sqlx::Error 的结果,因为我可以控制评级错误的代码。
我会研究一下 error_stack。感谢您的提示!
有时,如果您想避免公共依赖关系(如果您直接暴露其他库的错误类型,如果他们对其错误类型进行了破坏性修改,您也必须进行破坏性修改以更新依赖关系;但如果您保持错误不透明,您可以对依赖关系进行重大调整,而无需对您自己的库进行破坏性修改),这样做是很好的
这听起来像是单体的一个用例:
return 在一个地方构建错误类型
绑定,将错误泛应用于每个需要它们的函数。
Now do it without std!
对不起,请让我说得更具体一些:现在,请勿分配。
我为什么要这么做?
你的回答很有趣,我同意。
来个 “结构错误(anyhow::Error) ”怎么样?
你不这样做怎么样
我认为错误经常被误解–我曾多次在网络服务器/cli工具等中看到一个巨大的thiserror枚举,但实际上你并没有匹配各个变体,只是用它来获取From impl。这时,无论如何都要使用它。
我想有些人听到 “Rust 有很好的错误处理能力”,就会认为这意味着你可以不假思索地得到很好的错误信息,但我不确定有哪种语言可以做到这一点。Rust 给你的不是好的错误,而是对错误的控制。
在我维护的许多库中,我只使用了 struct Error(String, Backtrace),因为虽然错误发生的原因各不相同(即如果是枚举的话,会有不同的 “变体”),但我的库的用户并不一定希望在此基础上进行匹配。例如,在之前的工作中,我维护了一个验证自定义加密协议的库。我们的错误枚举看起来是这样的:枚举错误 { MalformedData, FailedToVerify, }
每个变体都可能有许多不同的原因,例如,畸形数据可能是 – 输入不是有效的 CBOR – CBOR 中的数组长度错误 – 数据包含无效的索引,指向存储在自身内部其他地方的数据
所有这些都可能是不同的错误类型。
同样,验证失败也可能是 10 种不同原因中的一种。但程序库的用户并不关心这些。他们只关心失败的原因是数据畸形还是错误。
我同意原作者对回溯的抱怨。在我看来,它们通常处理得很糟糕,而使用虚拟机的语言通常会有更多的信息,至少能让你找到错误发生的位置。我仍在等待能解决这个问题的 “完美 ”错误 crate,因为我觉得这个问题非常容易解决,但我无法确定它到底会是什么样子……
关于 “孤儿 ”规则,我曾被它咬过几次,但考虑到有其他选择,我还是很高兴它的存在。但我还是希望能有办法把它关掉,也许结果是只允许在二进制文件中使用,而不允许在库中使用(或者至少禁止在 crates.io 上发布此类库)。用某种方式来表达 “我知道我在做什么,就用这个植入吧”。
我知道人们会拿起叉子,但是…… Java
您提到 Java 也许是开玩笑,但有趣的是,我刚刚用 Java 处理了这个问题: https://www.reddit.com/r/java/comments/1e24mtb/i_tried_to_use_data_oriented_programming_with/
我曾尝试实现一种结果类型,但很快就发现,要在编译时对错误类型进行检查,就必须为每个可能抛出异常的方法创建单独的结果类型。如果尝试使用通用的通用结果类型,就必须对错误的实际类型进行转换和检查。
出乎我意料的是,我不得不勉强使用备受诟病的检查异常来获得所有编译时检查,在不需要的地方可以不关心异常(只需声明抛出子句),并拥有完整的堆栈跟踪。
我相信,如果在 Result 的泛型签名中添加异常类型,就能更接近你想要的结果。因此,它将是 Result<T, E extends Exception>。这里需要注意的是,你只能用一种异常类型来实例化它,而不能用异常类型的联合来实例化它。
他们关心吗?难道他们不需要从完整的错误链中生成一条信息,以了解数据的畸形之处或验证失败的原因吗?
我同意,他们通常不想在每一个小细节上都进行匹配;比如他们的代码不需要某个级别以下的枚举变量。
但我认为,他们在调试出错的地方时,可能需要完整详细的解释。这时,枚举变量(和附加数据)只是提供细节和上下文的一种方式。
也许有更好的方法来实现这一点,但目前的感觉是,如果我不从一开始就定义变体,那么我以后会后悔的。
我不想在这里吹毛求疵,因为我同意这种体验很糟糕,但我感觉很多人都误解了 Rust 的编译,错误地将它与 C++ 进行了比较。如果你已经非常了解 Rust 编译,我无意袒护,但希望这能对一些读者有所教育。
Cargo 配置文件设置 incremental = true 会尽量避免在 crate 的任何部分发生变化时重新编译整个 crate。增量编译在开发配置文件(plain cargo build/test/run)中默认启用。我在 bevy repo 中没有看到任何提及,可能是我的搜索能力太差了。增量编译的问题在于,它在很大程度上取决于 CGU 分区,以及 crate 中的函数是使用 LocalCopy(类似头文件)还是 GloballyShared(类似 .o 文件)编译的。从根本上说,最重要的是被污染的 CGU 的总大小。如果你修改了某个函数,该函数获取的 LocalCopy 代码源也被 crate 中的每个 CGU 引用,那么增量编译系统就帮不上什么忙了。但如果当前 crate 有很多 CGU,而你编辑了一个 GloballyShared 函数,那么只有它的 CGU 需要重新编译。rustc 增量编译系统的大部分设计都集中在缓存查询和做好查询失效上;我认为对于做增量货物构建而非货物检查的大型项目来说,所有的查询杂耍都无关紧要。查询缓存对于货物检查时间非常重要,但只要你想生成代码,最重要的因素就是有多少 IR 交给了 LLVM。
这些都与更改后的 crate 的依赖关系无关。
在 C++ 中,你可以修改一个实现文件,然后重新编译该文件并重新链接。在 Rust 中,你不能这样做的主要原因是 Rust 语言不允许你将头文件和实现文件分开。Rust 仍然有头文件,但却是自动化的。它们被称为 rmeta 文件(或 rlib 文件的 rmeta 部分)。编译一个 crate(在 Cargo 检查之外的任何模式下)首先会生成一个 rmeta 文件,然后再生成一个 rlib 文件。编译取决于 rmeta 文件/依赖关系部分的内容。问题在于,对源代码的任何改动都会改变 rmeta 文件,而 Cargo/rustc 只能理解这意味着需要重建。如果你经常编辑 C++ 头文件,你就会觉得增量很差……但同时你也会采取某种措施,确保你不需要一直编辑头文件。
此外,Rust 中的增量与优化非常不协调,因为 CGU 边界(你无法控制!)会阻止内联。添加 #[inline] 就等于把函数移到了头文件中;它们会被粘贴到每个使用它们的 CGU 中,每个副本都会被 LLVM 单独优化,而且根据我的经验,它们只会被 LTO 重复,而不会被正常链接重复。
所以我不认为你遇到的问题是 “Rust 编译模型”,而是 rustc 的增量编译系统不足以满足你的工作流程。问题不在于语言,而在于编译器。如果增量编译系统能理解依赖关系 rmeta 文件的增量更新,并且只能重新编译被更改的内容,那么这些多速率工作流程就会得到显著改善。
不,我完全没有参与编译。我很高兴能从有经验的人那里学到东西。
我想我以前听说过 CGU,是在优化编译器启发式算法的背景下,我记得有一篇关于它的文章,我觉得很有意思。
是的,我并不是说这是 Rust 语言的错。我就是这个意思,我觉得我们可以做得更好,但我还没有(在我与 Rust 编译器社区非常有限的接触中)看到有人认真地提出这样的建议。在我看来,每次发生变化都要重新编译整个 CGU,而不是在更细粒度的函数级别上重新编译,这是一种巨大的浪费。当然,重新链接整个二进制文件的成本也很高……
关于 内联: 没问题。Cargo/rustc 编译 bevyecs、wgpu 和我所有的依赖程序时,都会进行较大的优化,甚至是 O3,我完全没问题。但我希望在编译 bevy_pbr 和更高级的 crate(我正在积极工作)中的函数时,能尽快完成编译,而不进行任何跨函数(针对我 “积极工作 ”的 crate 中的函数)内联,并用新编译的函数体给二进制文件打上热补丁。
对于想要快速调整游戏玩法的 Bevy 用户来说,这对于 “热加载 ”也是非常重要的,例如将重力从 5 改为 10,而无需设置按键绑定或图形用户界面来进行调整。
这里有很多有趣的回复。我要开始着手解决我认为导致情况如此糟糕的问题。
首先,CGU 可以是单个函数。但它们几乎从来都不是,因为跨 CGU 的优化被称为 LTO,所以在过去的某些时候,一些人通过编译器让一些类的项目总是 LocalCopy,这比听起来要糟糕得多,因为修改一个 GloballyShared 项目需要重新编译它所使用的所有 LocalCopy,甚至是临时性的。因此,获得更好增量的方法之一就是不使用任何 LocalCopy。当然,这也陷入了技术债务的泥潭,因为在为构建添加可内联性方面已经付出了很多努力(我在此难辞其咎)。
我同意,从理论上讲,你根本不在乎内联,因此你希望所有函数都单独编译,以获得最大的增量。最棘手的部分是当你想调用依赖项时;在一个依赖项中调用 LocalCopy 函数,而这个依赖项因为你需要优化以获得可用帧率而被设置为 LocalCopy,这可能会导致未优化 crate 中 CGU 的大小爆炸。LocalCopy 项目会获得内部链接。我不记得我们是否也可以使用 GloballyShared 实例,这样下游的未优化 crate 就可以链接到它。当然,这是一个值得尝试的有趣想法。链接器的可见性经常让我困惑。
热修补二进制文件的上树变化可能是一种构建策略,需要在外部工具中开发。快速重建更改后的 crate 本应由 rustc 来完成,但热补丁需要 rustc、Cargo 和链接器通力合作。事实上,如果没有人已经构建了这样一个工具,我会感到有点惊讶。
关于在 Live++ 中添加对 Rust 的支持,Live++ 的作者曾发起过一次讨论,但似乎还没有任何公开的内容。几个月前,我曾询问是否仍在路线图上,得到的回答大致是 “是的,但不确定何时”。
所以:显然是可能的,但似乎还没有完成。
增量编译需要语言支持……仅仅让编译器 “聪明 ”太脆弱了。
也许这只是我作为 rustc 贡献者的偏见,但我并不同意。正如我试图解释的那样,Rustc 现在非常笨,所以我认为声称编译器不能变得足够聪明还为时过早。
也许可以,但应该吗?
作为 Rust 的新手,我很喜欢阅读这篇文章以及两者之间的比较。这似乎是经过深思熟虑的,而不是像我们经常在 Reddit 上看到的那样,只是一个用了一个月 Rust 语言的人为了让自己的博客有点击率而快速发表的观点。感谢你的写作!
快速提问:你试过 RustRover 吗?你将 IntelliJ 与 Rust-analyzer 相提并论,我不完全确定这样的比较是否公平。
尽管如此,我还是要说,我来自 C++: 我从未见过一个集成开发环境和/或代码检查器能做到万无一失。总有一些过时的或纯粹错误的检查。我根本不相信它们。
说到集成开发环境:这也是我个人的不满,虽然编译器的信息非常棒,但它们往往过于冗长,无法在集成开发环境的编译日志窗口中显示,所以我不得不切换到终端,在那里运行编译。而简短的错误信息… 太短了,根本没有提供任何信息。如果能有一个中间选项就好了。
使用 Rust 插件的 Clion 似乎可以工作,但速度有点慢。
你试过 Clion Nova 吗?
没有,我用的是传统的 Clion。Nova 会更好吗?
是的,几个月前它刚发布的时候。我觉得它并不比 RA 好多少。
您可以在 vscode/intellij 中以准确的错误位置显示货物检查结果
是的 我 100% 同意您的观点。我也喜欢 “结果”,不过,在一个庞大的代码库中生活了一年多之后,大量的错误处理模板(一个封装成另一个)已经变得毫无用处。最糟糕的是,当你想创建一个有多个结果类型的闭包。编译器会强制你处理不同的错误类型,最终导致闭包的意义落空。
Swift 也采用了类似的方法,但我喜欢的是它内置的类型清除功能。在每一点上,你都可以决定将错误 “捕获 ”为通用类型,并在此处理任何错误。
我已经开始在我的项目中采用 “类型擦除 ”技术,因为我在玩 “类型擦除 ”游戏时已经筋疲力尽了。老实说,它应该成为 std 库的一部分。
对于小项目来说,处理各种结果错误是可以应付的,但到了一定程度,就太乏味了,无法跟踪。今后的小型重构任务就变成了一个问自己是否从头开始重写更省钱的练习。
结果,在 “需要重新考虑异常处理 ”的语言设计时代,锈蚀技术的发展也是如此。然而,我现在清楚地认识到,在某个地方存在着一个中间地带,而 Rust 却在另一个方向上走得太远了。
在了解到 anyhow 现在可以自动嵌入回溯(我说现在,显然它已经有 ~2 年的历史了,只是我很少看而已)之后,我绝对认为与 anyhow 非常类似的东西值得被上游化。
看起来回溯功能正在开发中,但目前还处于实验性功能标记的后面:std::error::Report 类型有一个 show_backtrace() 选项,它建立在同样是实验性的 std::error::Error::provide() 方法之上。
我不知道它是否会稳定运行,也不知道会以何种形式运行。我很好奇它能在多大程度上帮助创建符合人体工程学的有用错误。
我喜欢 rust 的模块系统和 cargo。不像 go mod…
当我与开始学习 Rust 的资深开发者交谈时,模块声明是我最常感到需要道歉的事情。
一开始我觉得它们挺让人困惑的,但现在我发现它们完全简单明了,而且对控制可见性很有用。
我曾经为错误模板而苦恼,现在我只是从不自定义错误类型,诚然,我所做的唯一事情就是私有库 crate,但我无论如何都会使用它,而且它很好,YAGNI(你不会需要它的)、
人们需要对特定错误执行特定操作的情况很少见,这些情况可以通过字符串模式匹配来处理,只需在错误字符串中写入模块/函数名称即可。
让所有错误都是 “anyhow ”所带来的简洁性收益(阅读:删除大量模板)远远超过花哨的错误枚举所带来的好处。
主要的问题是 anyhow::Error 没有实现 Error;把孤儿规则归咎于 From,记住,YAGNI
关于 Result 和跟踪,我也遇到过同样的问题,它启发我制作了一个小的概念验证类型,每当对其使用 ? 时,它都会添加返回跟踪。我会找到链接的…
编辑:我好像从来没在软件仓库里添加过自述文件,不过现在有了: https://github.com/Zoybean/error-return-trace
它基于 Zig 实现的错误返回跟踪概念,至少根据他们自己的文档是这样(我还没用过 Zig)。我会找到我在 Reddit 上发布的相关帖子,里面会有更多信息……
编辑:找到了:R/Rust/s/pVTEIXD6dB
我也喜欢 Java 栈跟踪,谁在乎错误信息,我只想要真实的行号和文件。同样的问题也适用于 go。
我仍然认为Rust的错误处理是同类中最好的。没有其他语言能与之媲美。
唯一的问题是创建新的错误类型需要大量的模板。这意味着一般来说,库不会定义更细粒度的错误类型;通常只会定义一个错误或最多几个错误。
但这些模板是可以修复的!像 thiserror 这样的库只是权宜之计,但新的语言特性应该能解决这个问题。
例如:没有类似匿名枚举(OCaml 称之为多态变体)或 Typescript 中的 | 类型级操作符的操作符来组合错误。这就迫使你为对基本错误类型的细微修改创建一个新的错误类型。因此,你不能编写一堆基础错误类型,然后写出类似于
这有时会使错误签名更易读。
必须是 Result<T, Error1 | Error2>,但我非常同意匿名联合体/表在错误处理方面的潜力。问题是,这对语言来说是一个非常重大的改动,而且我确信要实现它需要大量的工作。
这也无法解决 “依赖程序通常不会在错误中包含回溯 ”的问题。
惶恐者可能会对此有所帮助?
你是贝维团队的?尊敬的老兄
虽然不是正式的,但我倾向于参与渲染方面的工作。我最近参与的工作几乎都是虚拟几何体方面的。
那些不只是抱怨,而是真正拿起铲子的人,无论其正式隶属关系如何,100% 都值得尊敬。
对于错误,我认为 Rust 需要匿名枚举。错误很糟糕,因为你从函数中返回错误,而错误类型包含了函数永远不会返回的一堆错误。我应该可以创建一些错误结构体,然后像在 typescript 中使用类型交叉那样将它们组合起来。
试试 Swift
谢谢你对错误,尤其是堆栈跟踪的看法。我感觉这是 Rust 和 Go 都比 Java 有明显退步的地方,但你需要一段时间才能注意到,因为堆栈跟踪在维护其他人的旧代码时最有用。年轻语言的旧代码更少。
对我来说,这其实不是 Rust 的问题,但要让 gnu 调试器在苹果芯片上得到完全支持和运行,却是一个很大的遗憾。据我所知,gnu调试器拥有最佳的开箱即用调试体验。
老实说,我喜欢 golang 在这方面的做法。没有大写字母(专有名词除外),没有结尾标点符号。这是我指导我的团队采用的方法,对我们来说效果很好。
我真的认为 Java 的风格指南一直到 Javadoc 属性都没有得到足够的重视。我希望有更多语言在这方面效仿 Java。不过日志很难写。老实说,我认为最好的办法是定义错误代码,这样错误信息的格式和字符串的一致性就不那么重要了。不过这也是个大麻烦,所以我完全理解只需吐出格式化的字符串就可以了。“完美是优秀的敌人 “这句话绝对适用于大多数日志记录。写出来的糟糕日志信息总比写不出来的 “正确 ”日志信息要好。
关于 Result<T,E>,我基本同意你的观点,我希望看到某种 “异常,但这次要正确 ”系统。Result<T, E> 的部分优点在于它是函数签名的一部分,你必须明确处理错误情况。因此,只需将异常作为函数签名的一部分,并在抛出时进行显式处理,同时在语言级别添加堆栈跟踪数据,就可以在需要处理的函数中将它们组合在一起。我在考虑类似 Zig 的做法。
这绝对是语言及其标准库的错!
Rust 的核心开发人员曾在 Firefox 上工作过,他们在开发 Windows 和 MacOS 时不得不面对成熟的用户体验模式。
这些系统中的一个常见模式是传播错误代码,然后使用操作系统和/或标准库中内置的国际化系统将其格式化为显示字符串。
例如:如何在错误中显示时间戳?如果你天真地调用 “to string”(字符串)或语言中的同类功能,那么你几乎肯定犯了一个错误,因为你最终会得到 3/4/12,而现在没人能知道这是 2012 年 3 月 4 日、2012 年 4 月 3 日还是 2003 年 12 月 4 日。
我经常在每个 Linux 和云产品中看到这种错误。所有的产品都是这样,而且都是随机的,所以根本不可能找出错误的规律并加以弥补。基本上,你必须有一天大于 12 日,但不是 24 日,否则你就完蛋了。最有趣的是,同一个网页上有三种不同的日期/时间格式,因为三个不同的开发人员在一个产品中犯了三个错误。
如果你的母语不是英语,我想你就不能从事专业的 IT 工作了,对吗?放弃吧。
我们生活在未来,却还没弄明白这些东西,而 Windows 和 MacOS 早在 2000 年就弄明白了。
我个人认为,在 2024 年,我们已经超越了错误代码。别误会我的意思,错误代码对于反复出现的特定错误来说非常有用,而这些错误又很难在日志输出中用简短的句子解释清楚。Bevy https://bevyengine.org/learn/errors/introduction 和 Rust https://doc.rust-lang.org/error_codes/error-index.html 都使用它们。
不过,对于标准的 “内部出错 ”错误,我不认为它们能提供任何价值。如果你不能在 Rust 中对时间戳调用 to_string(),并获得一致且合理的日志输出,那么你就应该使用像 chrono 或新发布的 jiff 这样的库,将时区编码到类型本身中。鉴于 Rust 注重严格类型,给定值究竟代表什么不应该有任何歧义。单独的错误代码/错误格式化系统只是额外的工作,也是额外的错误源。
当然,各人有各人的看法。
这个 “你 ”是谁?是作为最终用户的我吗?使用大量 crates 的终端开发者?一个 crate 开发者?
在谈论一个拥有成千上万开发者的平台和生态系统时,说 “你 ”的问题在于,没有一个人有权改变任何有影响的事情,但会通过其他人间接地受到平台设计决策后果的影响。
如果 99999 名 Rust 开发人员 “提前 ”将字符串格式化为错误,那么第 100000 名开发人员就没有希望在自己的代码中解决这个问题。
没有 “你们”,只有 “我们”。
PS:这并不是 Rust 所特有的,其他语言,如 Go 和 JavaScript,往往也会做同类型的事情,结果也一样。
我以前说过,现在再说一遍
是任何大于几个 crate 的工作区的合理工作流程所必需的。
这只会显示一些错误,但正确的解决办法是用 rustc 改进 ra/share 代码,直到它能检测出所有错误。(在此之前,你可以使用 “rust-analyzer: Run “操作来按需获取全部错误信息)。
:思考: 我在想,在 Rust-analyzer 中实现 flycheck 是否是一个错误,而应该是一个单独的 VS 代码扩展,以便在保存时运行货物检查?
我会试试这个,谢谢。
如果这是一个重大改进,或许可以检测大工作区/慢速货物检查,并弹出提示?我对 RA 并不陌生(我还记得在它出现之前,我们使用的是 iirc racer),但我并没有花时间去摆弄设置。我相信除了启用一些非默认的 Cargo 功能,以及跳过检查示例/所有目标(因为 bevy 有几十个示例)之外,我把所有设置都设置为默认。
我们真的需要一种方法来简单地切换终端用户 crate 的 “孤儿 ”规则,我没有理由因为 “下游 crate 可能会实现 bla bla bla ”而遵守规则,因为我正在开发一个应用程序,而下游 crate 永远不会存在。
关于错误和 Result<T,E>,我不得不同意你的观点,并不是说这不是一件麻烦事。它确实很麻烦。但我真的很惊讶你会拿 Java 来举例说明如何做得更好。Java 对异常的解释是许多人讨厌 Java 的原因之一。
尽管 C# 等语言在早期都是以 Java 为蓝本,但 Java 的异常处理方法却无人效仿!
在实践中,Java 是这样运行的…
一开始,你会认为可以简单地使用抛出来提出异常。但这很快就会变成一个异常的雪球,而这些异常与你的方法的直接签名完全无关,只是实现的最深处的孙子。
现在,你 “应该 ”做的是尽量避免引发与方法签名无关的错误。这需要大量的代码,根据具体情况来 catch (FooBar e) {raise BazBob()} 。如果是 Rust,我会说如果可以避免,就不要添加错误包装,确保错误是针对方法签名的。
但开发人员不会这么做。人们都很懒!相反,他们只会到处引发 RuntimeException 或 IOException。当库这样做时,应用程序根本无法对错误做任何有用的处理,因此应用程序不得不在屏幕上吐出内部错误。在这种情况下,程序库还不如直接慌了神!
真的吗?作为最终用户,没有什么比看到堆栈跟踪更糟糕的了。我可能是个开发人员,知道它意味着什么,但大多数用户都讨厌它。它只比写 “出错了 ”好一点点,却没有任何信息。
你描述的问题是检查异常。但 Rust 的结果类型在逻辑上等同于检查异常,而且存在完全相同的问题。这就是为什么有 anyhow 这样的库来掩盖错误类型。使用 anyhow 相当于用 RuntimeException 封装一切。
此外,在 Java 中使用 RuntimeException 并不妨碍你检查实际的异常类型,并根据它采取适当的措施。如果 RuntimeException 封装了已检查异常,那么只需将其拆开并检查已检查异常类型即可。如果抛出的是 RuntimeException 的子类,则可以捕获该子类。
不,你看错了。我并没有说这是个问题。事实上,我在上一篇文章中已经指出了这种协同作用。
我所说的问题是,开发人员懒于处理不愉快的路径。我们本能地不愿意考虑这个问题,尽管正确地处理这个问题可能占逻辑的 20%-50%。
我想指出的是,Java 的 “变通方法 ”是一种可怕的反模式,它使用得如此懒散,破坏了整个系统的实用性。它把婴儿和洗澡水一起倒掉了。显然,OP 想要以 Box<dyn Error> 的形式将通用的 RuntimeException 移植到 rust 中。我想说的是,结果将与 Java 世界中的愚蠢懒惰如出一辙。
我从未见过有人在任何语言的生产代码中使用这种方法。
我的意思是,你能深入到什么程度呢?包装器,还是原始原因的另一个包装器的包装器。从字面上看,你是在寻找理论上可以处理的问题,但当你的代码找到它时,却不可能知道它被抛出的上下文,这意味着你不知道究竟是什么出了问题,这意味着你无法处理它。
这里的根本问题在于,开发人员本能地(懒惰地)想要传播错误,而无需考虑它。但传播的层数越多,理解异常含义的上下文就越少,因此优雅处理异常的可能性就越小。
在需要通过 Stream API 来隧道检查异常时,我见过(也用过)很多次。
一层就够了。如果 RuntimeException 被用来把已检查的异常变成未检查的异常,那么一层就能让你找到代表实际问题的已检查异常。
绝大多数异常都无法从容处理,因此这种传播行为是可取的。如果要对异常进行自定义处理,通常会在异常发生地附近进行。
这对上下文真的很敏感,老实说,我看到很多开发人员如果真的尝试过,就能做得更好。当然,在编写共享库时,这种心态很不好。
我并不是说不存在意外错误。我写了几十年代码,我并不笨。
但是,如果你不能优雅地处理某件事情,那你为什么还要宣传它呢?仔细想想:你想要倡导什么?你最终想通过推动它达到什么目的。
我在这里要说的是,恐慌!有它的用武之地。
但如果这样做过于极端,那么我们所要寻找的可能就是 NearPanic(reason: str, trace std::backtrace::Backtrace) 这与 Box<dyn Error> 的概念大相径庭。Box<dyn Error> 是故意将💩向上推进到调用堆栈,但却没有任何东西可以处理它。
如果你想实现的是一种防火墙,就像 Java 的 catch (Exception e),那么你真的不需要接收某个动态类的错误信息。你需要的是足够的信息来写入日志。
仅仅传播动态错误是不考虑不愉快路径的借口。相反,你至少应该有意识地决定说 “唉,这是不可能恢复的”。
绝大多数错误都需要传播到某个点,以便记录并向用户显示。无论正在执行的任务是什么,都应该中止,但惊慌失措并不是正确的选择,除非程序是命令行工具之类的东西,无论如何只执行一项任务。任何交互式或长时间运行的程序几乎都不应该惊恐。程序库也几乎永远不会崩溃。程序库也几乎永远无法处理自己的错误。它们必须将错误传播给调用者,然后由调用者决定是否处理(但通常不会处理)。
类似 NearPanic 的问题在于,你失去了处理错误的选择权。唯一能做的就是记录日志。虽然这通常是你想要做的,但你并不希望它成为你唯一的选择。在 Java 中,您可以在运行时检查异常类型,并根据该类型命名处理决定,同时在绝大多数情况下仍能及早向上传播。
Box<dyn Error> 在这方面也比较有限,因为 Rust 没有运行时类型信息。因此,需要使用其他技术来获得同等功能。
你一定会喜欢 zig 错误处理
Roc 语言使用标记联盟对错误进行了非常有趣的处理。
简而言之,您无需定义自己的错误类型和 From 转换。每个易出错函数的错误签名都是从其主体自动推断出的所有可能错误的集合。它仍然迫使你处理每一个可能的错误(或忽略它们;基本上,你只需匹配错误并做你想做的),但它去除了繁琐的模板。
这里有一个关于这方面的精彩演讲。
错误情况与 async 结合在一起让一切都变得非常糟糕。Rust 很适合编写真正低级的(系统中的)组件,比如 Android 中的蓝牙守护进程,但对于常规应用,GC 语言和异常会让代码更可读、更易维护。
我非常非常希望用匿名和类型来处理错误。在 no-std 中进行错误处理是一种痛苦的体验。
作为一个新手:缺乏某种默认的 stdlib 解决方案,而这种解决方案可以为你提供带有回溯的错误,并避免转换错误的需要,这在我看来简直是痴人说梦。
我同意单一错误类型。我的系统就是这样,因为我几乎不使用第三方代码,也不使用大量运行时代码,而且主要是封装我使用的少量代码。因此,一切都以单一的单态错误类型为基础。我以前的 C++ 系统也是这样设置的。很多问题和烦恼就这样消失了。
我的观点是,如果有人对上游的错误做出反应并做出决定,那么这就不是错误,而是状态。因此,我不需要各种不同的错误类型和各种不同的信息。无论如何,这都是一份无法执行的合约,所以你不应该这么做。没有任何东西会告诉你,下五层的库已经停止生成这个错误,而这个错误正是你做出决定所依赖的。
这也意味着我可以使用简单的宏来生成错误和日志,我的日志系统也可以使用相同的类型,并可以将错误以单态方式流式传输到文件或日志服务器,而日志服务器可以将错误流式传输回来,并完全理解它们,而不是将它们当作文本或其他什么。
我使用了三种策略。一种是只显示错误,不显示值。二是一个错误和一个值。或者是一个错误加上一个值枚举,其中一个值是 Success<T>,其他值提供状态信息。这样,我就不必查看错误并做出决定,调用可以自动延续,只有状态信息是我需要做出反应的。对于那些不在意的调用者来说,任何返回类似状态的调用都有一个琐碎的封装版本,可以将 Succcess() 以外的所有内容都变成错误。
它包含一个调用堆栈,因此我可以在关键点插入一些调用堆栈信息,以便在可能有歧义或重要的情况下更清楚地说明所采取的路径。
虽然严格的错误处理并不简单,但它的运行情况与人们的合理预期差不多。当然,没有人会为 Rust 商定一个真正的、单一的、单态的错误类型,因此即使在技术上可以解决,在实际应用中也永远无法解决。充其量,它也只是一个被祝福的类型擦除包装工具,所以问题永远不会消失。
出于同样的基本原因,我也不存在编译时的问题。我不使用包含大量 proc 宏的大型第三方代码,也不使用像 Serde 这样让我在自己的代码中到处插入 proc 宏的代码。我也不像某些人那样拥有几乎完全通用的代码库。因此,到目前为止,编译时间还算合理,尽管它还有很大的发展空间。我敢肯定,到最后分析器扫描会成为一个问题。
关于错误类型,你看过 error_stack 吗?
它更接近 Java 风格的错误报告,因为您定义的错误类型只需关心本地上下文–我们做错了什么?有一个封装的 Report 类型可以处理跨上下文边界时的错误值历史链。当错误传播时,报告还提供了附加上下文的位置。
以前从未见过,但我的第一反应是它似乎有点复杂?我必须使用它才能真正了解它。
关于错误,Result<T,Any> 或某种 Result<T,&dyn ErrorConvertibleToString>是否具有通用性?
我最近很担心编译时间,但我发现模块系统和 Cargo 可以让我很轻松地为各种功能建立小型测试平台;我还发现在顶层更多地依赖 dyn 可以让我把事情分解成更小的 crates,我仍然可以为单个系统实现次秒级构建,并为某些任务实现顶层的整体应用变更。
我还发现 #[test] 真的很方便
关于 “孤儿 ”规则,我希望能有一种变通办法,比如用 #[…]来声明你知道你可以接受库对你的应用程序进行破坏性修改的可能性。这种代码可以直接从 crates.io 中禁止,同时让 Rust 社区在使用库时有更多自由。
孤儿规则阻止我为我的矢量数学库使用共享类型–如果我想完全控制它(我想)并拥有运算符重载,我必须在共享类型和个人类型之间做出选择。我很早就在代码库中反反复复地考虑这个问题,在我的数学特质中还残留着一些乱七八糟的东西,我试图在其中避开我的赌注。
特质中的字段也有助于解决这个问题。
关于 vecmath 我希望能够声明 “这里有一个类型,它将是 x:T,y:T,z:T,我绝对不会给它添加更多的字段,我希望它能兼容其他库 x,y,z,同时在我自己的代码库中保留对使用它的函数的完全控制”。
有多种选择,但都有缺点,所以我们又回到了在它们之间来回切换的诱惑中(“好吧,如果我使用 [T;3] 作为存储和互操作格式,也许代码库会更好,好吧,现在我要做一些数学助手,直接在上面工作,啊哈,现在我的代码库里有两个数学库了……”)。
总之,虽然 Rust 并不完美,而且转换语言确实让我付出了很多代价(多年来来回跳来跳去,不确定自己是否会坚持使用它,这基本上耽误了我的项目),但我认为它做对的比做错的多。我有一些想法,希望它能稍微软化一些,以减少对新用户的反感,但我相信团队已经看到了类似的建议,而且有很多声音会将它引向不同的方向。
你想到的是 Result<T, Box<dyn std::error::Error + Send + Sync>>
可能需要在这里加上 + ‘static’。
一样。我无法保持格式一致,这让我很烦恼。我不能保持一致是因为我不知道有什么规则。
规则是有的: 没有大写字母,基本上没有标点符号,绝对不是一个句子……
这绝对太糟糕了。这是谁想出来的?这与有用的错误信息恰恰相反。
即使是小项目,编译时间也糟透了
我非常喜欢您的帖子,而且几乎同意其中的所有内容。我非常怀念 Java 将错误类型像 matrioshka 娃娃一样简单嵌套的能力,这样就可以轻松地将错误作为一个系列或按类型处理,如果需要的话,还可以按潜在原因处理。
对于您对孤儿规则的批评,我很好奇它与 Java 相比有何不同?
根据我的经验,Rust 中的孤儿规则比我所见过的静态类型面向对象语言中的任何规则都要严格得多。
比较 Rust: “`rust trait MyTrait {} struct MyStruct {}
impl MyTrait for MyStruct {}
impl MyTrait for String {}
impl Read for MyStruct {…}
// impl Read for String {…} // illegal java: java interface MyInterface {}
class MyClass: MyInterface, Readable {}
// 如何为 String 实现 MyInterface?(孤儿规则允许)
// 如何为 String 实现 Readable?(孤儿规则禁止) ““
我最近发现了一个有趣的事实(与 OOP 无关):
OCaml 中的模块大多等同于 Rust 的 traits,但却不存在这个问题。它允许 “traits”(OCaml 模块签名)有多个 “实现”(OCaml 模块)。缺点是没有默认的实现,你必须明确选择你想使用的模块。
老实说,我倾向于避免使用 Java 中的泛型。我坚持使用非常简单的接口,如果确实需要,有时会通过嵌套来组成类。
我认为孤儿规则在 Rust 中更像是一个问题,因为特质和泛型被大量使用。
我试着用 bevy 进行了一些游戏开发,在我的笔记本电脑上花了 20 分钟才完成构建。当然,我的笔记本电脑并不是最先进的设备,但这速度也太慢了。
第一次编译可能是这样。之后的编译应该会快很多,尤其是如果你是 bevy 的用户。我在 bevy 本身上工作,这意味着每当我更改一个内部 crate 时,都要重新编译一半的 bevy。
“Rust喜欢显式”
就像枚举,实际上到处都是标记的联合体
你错过了创建单例
有趣的是,我的想法恰恰相反。我认为,让编译器告诉你,你的代码现在可能会以更多的来源/原因失败,并迫使你将这些错误转化为有意义的错误,或者将它们冒泡到你自己的错误堆栈中,是一个相当不错的设计。你提到了 thiserror(即此处的 #[from]),我认为这种实用程序或许应该成为语言核心的一部分(就像 #[default] 用于枚举的默认 impls 一样)。
老实说,我认为这种做法很不诚实。我还没见过哪个库会为其导出的每个可能函数都设置专门的错误类型。一旦你将库的错误类型从植入转换为你自己的类型,“…… ”就会为你解决所有的模板问题。
谁说的 “通常”?对错误进行模式匹配以做各种不同事情的情况并不少见。在进行底层 IO(根据经验,我认为是 mio)和与系统 crate 接口(比如在工作中,我们对 Kafka 客户端通信进行了封装,我们对其错误进行模式匹配,以做各种事情)时,这种情况尤为典型。
Java 使用的异常可以完全从类型签名中省略。我看不出这有什么好的。
对我来说,错误和回溯是两码事。错误是逻辑错误。gRPC 调用失败应该提供错误信息。它应该提供回溯吗?我真的不这么认为。
此外,当你想了解应用程序崩溃的原因时,反向跟踪实际上是一种非常糟糕的工具。在我看来,Coredumps 的价值要大得多。
同样,错误、日志和回溯/核心转储是截然不同的东西。如果你想要可观察性,你可以看看跟踪 crate。
Rust 为错误提供了枚举类型。如果库提供的错误反馈不佳,那么库中使用的错误描述就不够充分。你在这里使用的论据实际上也是我反对 Zig 的论据;因为它只是普通的联合(而不是标记的联合),所以无法为错误附加上下文。在 Rust 中可以。
哇……不,完全不是。我认为错误有两种用途:
库中的错误类型。它们被其他程序员使用;依赖于项目。至于它们是否会对用户产生影响,则完全取决于调用者库/应用程序的操作。
最终用户应用程序的错误类型。这些错误类型确实可以提供给用户,但在这里,不同用户的报告类型可能截然不同。视频游戏在获取某些资产时出现故障,不应该向玩家提供回溯信息,同样,也不应该直截了当地说 “对不起,内部错误”。
我不同意。再说一遍,要跟踪代码,只需使用带有 spans 等功能的跟踪库即可。
取决于应用程序的选择。这与语言无关。
我同意这一点,我希望我们能命名 impls。
我在工作中也遇到过类似的问题。我希望他们能在某个时候解决这个问题。
我完全同意你对 “Result<T,E>`”的看法。我认为他们的精神是正确的,但在实现上却完全失败了。
定义错误枚举是如此笨拙和令人沮丧。我想要的是类似于 TypeScript 的联合类型,在这种类型中,我可以积累越来越多的错误类型,如 `Result<T, E1 | E2 | E3>`,并且可以使用简单的语法来组合错误。我的应用程序中的每个函数都有一棵嵌套错误枚举的可怕树。但我并不想完全放弃它而使用 `anyhow::Error` ,因为在某些情况下,我需要告知用户 “内部出错了,是我的错,请重试 ”和 “是您的错,请为 Foo 修改您的输入 ”之间的区别。
除了已经计划好的 IDE 工具外,他们已经有效地解决了你的所有痛点。当然,这并不是说你应该换一种语言,但你可能会发现它很有趣
zigs 的错误系统非常简单,但也相当不错,整个项目中所有可能出现的错误都属于全局 “错误集 ”的一部分,实际上就是一个整数,默认为 u16。
模块系统也非常简单,它实际上只是一个可能导入其他文件的文件。如果某个模块是 “pub”,它对所有能访问该模块的文件都是公开的,没有特殊的命名或路径搜索,如果你有一个叫 “foo ”的模块,你只需“@import(”foo“)”,而无需更多或更少的东西。
至于编译速度的问题,zig 其实已经非常接近其增量编译模型的 MVP 了,该模型可以在 Rust 中实现你想要的就地二进制修补(in-place binary patching)。基本上整个核心团队现在都在研究它,我估计在未来几周内就会有一个可运行的版本(尽管可能会有很多错误)。遗憾的是,它现在已经消失了,但不久前 zig 的创建者做了一个增量编译的粗略演示,它非常酷,小规模的重新编译都在 1 毫秒以下。
我是一个完全的 Rust 新手,只是在像这样的地方闲逛,偷听更有经验的人对 Rust 的看法。最近,我喜欢上了元编程,并充实了我的软件哲学(见仁见智)。那就是
归根结底,构建软件就是通过混合和匹配各种工具来实现预期目标。
它总是需要从逻辑构件中构建某种形式的复杂性
复杂性本身就是实现目标的障碍,从(抽象的)如何用逻辑结构表达需求,到(具体的)通过语言实现逻辑所涉及的特定工作流程,都是如此。
编程语言要做到自洽,就必须增加一层复杂性。
我一直在转变自己的世界观,从学习一门语言是因为它看起来最适合我想要解决的问题类型,转变为根据语言表达特定类型问题的难易程度来选择语言。在现实世界中,我们会遇到用不同语言编写的软件系统在不同环境中运行,并通过网络进行交互的情况。如果你从狭隘的角度出发,认为精通一种编程语言就是最好的出路,那么你就无法成为最好的开发人员,这种想法简直太天真了。这种说法见仁见智,并不适用于专家,所以如果你不同意,也无需争辩。
随着 Rust 开始流行,我曾经并将继续对 Cargo 留下深刻印象。现在,我有多个 Rust 项目,它们在我的开发工作流程中不可或缺,而且我不需要花费任何时间来摆弄它们,就能让它们在我的系统上运行。为了每次都能无压力安装,我很乐意为漫长的编译时间买单。
我精通 C++,是为游戏开发而开始编程的,游戏开发在我心中一直占有特殊的位置。我写得最多的是 JS 和 TS,因为我的实际工作主要是全栈网页开发,而不是其他任何东西,但我也写了很多与运营和管理相关的东西。如果不是因为对游戏开发的潜在渴望,以及对只能通过手动内存管理(即使用计算机创造身临其境的幻觉)才能做到的事情的欣赏,我不会太在意 Rust。
说到幻想,我很早就对 Rust 产生了幻灭感,在我学习 Rust 之前,我甚至都还没有站稳脚跟,因为我甚至都没能爬上生命周期和借用检查器的学习曲线。我知道承认这一点会让很多人失去同情,但我写这篇文字并不是为了博取同情!但我必须要说的是,当我们谈论元语言,并思考如何让人类更容易将其作为衡量语言价值的标准时,技能问题仍然是真正的问题。肆意提高门槛会削弱语言社区的规模及其所能取得的成就。我从未跨过成为乡村人的障碍。我并不为此感到痛苦,我想我只是在某种意义上感到失望,因为我可能永远都无法建造出自己的超棒 Rust crate 与世人分享了。
在我看来,有一点甚至可以说是优雅的,那就是 Rust 的大问题正是其伟大之处的重要组成部分。这些都是它的内置限制,可以帮助你避免内存管理陷阱。编译速度慢以及与借用检查器之间的争执,都是为了让你获得也许难以量化,但却非常真实的安全优势。
也许是我的大脑太老派,也许是我在学校学习计算的方式太特殊,但明确管理内存、思考比特、字节和缓冲区、进入并改变内存以及理解处理器、缓存和内存如何交互,这些事情对我来说本来就是直观的,就像图灵机的理想化磁带和读写头对我来说是直观的一样。
多年来,我曾多次尝试学习借用检查器和生命周期,但对我来说,这些东西从未变得直观。也许我放弃学习有价值的东西是对自己的一种伤害。但我越来越怀疑这些东西到底有多大价值。我相信在未来的 10 年或 20 年后,当我回首往事,看到未来有多少语言将借用检查器和生命周期注释作为核心原则继续传承下去时,我就能找到答案了。如果是这样,也许到那时我会更加努力地寻找它们背后的直觉。
归根结底,一如既往,完成工作的最佳方式就是利用你已经熟悉的工具。同样重要的是,要经常扩展自己的视野,这样才不会落后。这也是我专门花时间认真复习 Rust 和 Zig 等语言的原因。
至于内存安全和构建优秀软件… Rust 只能帮你避免某些类别(毫无疑问,这是一组令人印象深刻的类别)的内存安全问题,但在算法设计或分析、你所构建的逻辑系统在任何意义上是否合理,或者其他任何数量惊人的、不受限制的软件失败方式方面,它却无法提供任何帮助,而不是让我担心自己会患上痴呆症……最主要的是,Rust 只能帮你避免某些类别(毫无疑问,这是一组令人印象深刻的类别)的内存安全问题,但在算法设计或分析、你所构建的逻辑系统在任何意义上是否合理,或者其他任何数量惊人的、不受限制的软件失败方式方面,它却无法提供任何帮助。
Rust 之所以能成为 Rust,是因为它在编译器运行时深度集成了一个连贯、强大的静态分析套件,在很多方面可能比其他任何语言都更完整。如果作为程序员,这是你想要的,也许这很好,如果你是编程新手,这也很好,但对我来说,我知道这不是我想要的。事实上,如果静态分析器在运行时足够苛刻,我可能只想在发布前执行静态分析,因为我在努力追求健壮性,而不是在尝试黑客实验和提高速度时执行静态分析。
你所编写的所有代码,在不得不完全重写之后,计算机和你为确保其通过严格的完整性检查所花费的大部分时间和精力都白费了。但也有一小部分时间和精力没有浪费,因为这些时间和精力让你的程序达到了稳定运行的状态,而不是崩溃。但我们必须权衡这一点与所有累积起来的机会成本,因为让代码编译起来要耗费更多的时间。
制作优秀软件的真正方法是思考,真正思考你要制作的软件,并制作自己的工具!制作优秀软件的真正方法是在构建软件时考虑到测试。与软件一起创建的测试是一种工具,它能赋予你超强的能力,让你能够验证你需要的一切,而不是你不需要的任何东西。这样,你就可以在需要的时候利用这些工具,而在不需要的时候不被它们所累。
我说的是优雅,但我认为它所强调的是,有一些低垂的果实被关在笼子里。我认为,如果不安全的 Rust 不被人为地变得不符合人体工程学,如果我们能真正地,你知道的,只是切换编译器来不应用所有(或某些,我很乐意能够选择)内存检查,那么 Rust 对我和许多其他开发人员的吸引力会增加 500%。我敢打赌,这甚至可以在分叉程序中简单实现。异端、亵渎、渎神,当然可以。让我禁用借用检查器。可以吗?
当然,所有这一切都与我们的理念背道而驰,而且很有可能与我们的设计背道而驰,以至于无法成为一个可行的工作流程。我只能说,我希望事情能有点不同。
请注意,Java 也有这种功能,叫做检查异常。
不幸的是,它们已经背上了毫无道理的恶名,几乎没人使用。
它们存在实际问题,在某些情况下无法使用。这是众所周知的,谷歌一下就能查到。
不过,值得指出的是,Java 的检查异常确实能让人明显看出哪些方法会出错。它并不完美(哪种语言是完美的?
其实不然。因为我认为任何方法都可能抛出运行时异常(例如 NullPointerException)。
在我看来,它们是一个用心良苦的好主意,而且对于初次接触它们的人来说,它们仍然是这样的。但它们目前最大的 “优势 ”就是通过这种显而易见的设计来显示所有的缺陷。这就是其他语言(如 Rust)采用不同方式的原因。
是的,但运行时异常被设计为致命错误。它们不是用来被捕获的。它们相当于 Rust 的恐慌。Rust 的 panic 会破坏其正常的错误处理机制吗?
这是一篇很棒的文章!我完全同意错误处理部分。也请继续努力(在 bevy 和其他 crates 上)
我是从 Ocaml 的背景开始学习 Rust 的。从你的评论来看,你可能会喜欢 Ocaml,尤其是在结果类型和错误处理方面。
我以前用过 OCaml,它还不错。虽然不是我最喜欢的语言,但我看到了 Rust 的很多灵感来源。
考虑到为了绕过孤儿规则,我不得不将一些东西合并到比我希望的更少的 crate 中,因此我很欣赏至少可以使用 pub(in path::to::mod) 来控制仅限于我的伪 crate 的可见性。在这种情况下,仅限于 pub(crate)对我来说作用不大。
我发现 Rust 拖慢我的地方是快速重构。也就是说,当我真的想改变代码结构或算法工作方式时。当我使用 C++ 或 C/C++ 进行嵌入式开发时,也会遇到同样的问题。
因此,我的 rust 编程语言是 python。我玩,我摆弄,我乱搞,然后当我的设计开始固化时,我就转到 Rust 重新做。
这样编程两次,速度会快很多。
明确一点,我说的是一种高度模块化的架构,每个模块都在 Python 中完成,然后转到 Rust 中,而不是整个模块都在 Python 中完成一次,然后转到 Rust 中。
这样,在开发更多依赖于该模块的模块之前,每个模块一般都已完成,而且非常稳固。
关键是要确保 python 是 rust 风格的。例如,async 等设计非常相似,因此移植起来非常容易。
对于复杂的算法,通常会从 jupyter 开始。
如果要解决并行计算的问题,上述方法可能是最大的弱点之一。Python 和并行计算可能是一场相当激烈的战斗,而且不会很 Rust。
不过,我还没有为上述问题找到一个很好的 CUDA 工作流程。
通常情况下,我都不会一开始就写有效的 Rust。你可以先模拟函数签名,然后再填写类型,这样你就能感受到你想要的结构。每个人都可以找出适合自己的方法。
我认为与 Result<T, Box<dyn Error>> 等价的问题在于 no_std 环境。我在业余爱好的嵌入式处理器中使用 Rust,我没有分配器,甚至没有真正的分配器空间。你说的 “Box ”是什么?
其次,它只有在返回字符串时才有用。我在另一个项目中发现,将错误转换为 HTML 是非常有用的。目前,只需实现 IntoHTML 或本地等效功能即可。但 dyn Error 很可能会在整个库中传播,从而导致无法实现。或者使用 Rust 错误类型作为 HTTP 状态代码:
让 user = check_user(req).map_err(|_|HttpStatus::NotAuthorized)?
另一个效果不佳的地方是根据现有接口编程的库。它们需要将错误转换为现有类型–通常是整数。Box<dyn Error> 将使其无法实现。
我个人认为,不对类型进行动态分派是有益的。
根据我的经验,Box<dyn Error> 涵盖了很多领域。不管怎么说,我还没有找到需要它的地方。
很高兴知道我不是唯一一个喜欢 Rust 的 async/await 实现的人。
我使用 nvim 和 RustAnalyzer,速度快得惊人。不过我有专门的开发服务器,能力很强。
我有一台 Ryzen 2600。速度不算超慢,但也算不上最先进。我认为主要问题在于项目规模–Bevy 不是一个小项目,而且随着时间的推移,规模只会越来越大。
如果使用 sccache 这样的缓存,大小问题应该不大。此外,Rust-analyzer 只针对变更运行,而不是重新分析整个 crate。