Tim McNamara,一位著名的Rust教育家,Rust in Action(Manning)的作者,以及2023年Rust基金会奖学金获得者,与SE Radio主持人Gavin Henry讨论了Rust中的错误处理。他们讨论了Rust防止的错误、Rust中的错误是什么、Tim将哪些内容归类为“四个级别的错误处理”以及达到这些级别的旅程的生命周期。McNamara解释了为什么Rust会这样处理错误,它与其他语言有何不同,以及开发人员在处理Rust错误时的体验如何。他提倡错误处理的最佳实践,什么是Result,Rust枚举的功能,问号运算符是什么,何时解包,Box的真正含义,如何在FFI边界处理错误,以及您可以使用的各种Rust错误处理板条箱以获得更多控制权。由IEEE计算机协会和IEEE软件杂志提供。</context> <raw_text>0 这是软件工程广播,面向专业Web开发人员的播客,网址为SE-radio.dev。SE-radio由IEEE计算机协会和IEEE软件杂志在线提供,网址为computer.org/software。
欢迎收听软件工程广播。我是您的主持人Gavin Henry,今天我的嘉宾是Tim McNamara。他是一位著名的Rust教育家。
他撰写了《Rust in Action》一书,并于2023年获得了Rust基金会奖学金,以表彰他的努力。他精通这门语言。他还经营着Excellent Development,一家专注于Rust编程语言的咨询公司。
他还主持YouTube频道,并在大多数社交媒体平台上活跃,用户名为@timClicks。Tim,欢迎回到软件工程广播。我们上次交谈是在2021年。您在个人简介中还有什么想补充的吗?
没有,没有,非常感谢您的介绍。没有遗漏,好的。所以今天的节目是关于Rust中的错误处理,我非常喜欢您为Rust Nation UK做的演讲。
好的,关于Rust错误的演讲。所以我认为再次邀请您来节目会很棒,因为那真的帮助我理解了它们。我认为这对我们的听众来说会很棒,不仅可以探讨错误和软件中的bug,还可以探讨Rust如何帮助我们避免它们,并赋予我们完成所需工作的灵活性。
很高兴能来。
在我们进入您在Rust Nation UK演讲中提到的“四个级别的错误”之前,我想先了解一下Rust基金会。让我们开始吧。首先,对所有人来说,什么是错误?
这是一个具有欺骗性的难题。这可能是您从这个问题开始的原因,因为答案有很多。
有很多不同的正确答案。如果我们考虑一个简单的程序,它从一系列指令开始,然后遇到错误,我们可以将错误定义为在这些步骤中的某一步,程序接收到的某些输入它无法理解或无法处理。或者,某些前提条件不满足,程序无法继续执行到下一步。然后,您可以将错误视为控制流中的一个分支。
错误处理是一种将程序分成主要路径和错误路径的方法。比如说,主要路径是您预期的控制流。而所有这些错误路径,也可以用“异常路径”这个词来形容,暗示了异常处理这个名称,它们是较小、不太可能发生的案例,作为程序员,我们事先已经考虑过这些案例。
处理问题的办法是我们预料到可能会遇到的问题。例如,我谈到了输入,我的意思是广义上的输入。但是,如果您使用的是Web框架,您会期望人们会从网络发送各种无意义的数据。
因此,您的Web框架应该处理不满足HTTP规范或有效输入定义的输入。有效输入的定义在每个步骤中都会略有变化。但另一方面,错误也可能是完全出乎意料的事情,或者程序可能会遇到无法继续执行的程序状态。
大多数程序在这种情况下通常会显示错误消息。操作系统会关闭所有文件、关闭所有网络套接字、清理并基本上要求用户重新启动程序。我认为错误就像道路上的颠簸或坑洼。您不能指望一切都是完美的。
像Rust这样的程序,特别是假设输入是某种特定类型,这也可能包含在您所说的输入错误中。作为一种静态类型语言,您假设某些东西,但它并不正确。
我们的输入不仅仅是数据本身,每个块、每个语句或每个表达式都有一些额外的元数据,例如它期望的数据类型。如果您将字符串传递给一个试图执行数学运算的函数,您可能会遇到很大的麻烦。
也许不会,尽管有些语言基本上认为我们的方法是接受它并尽最大努力处理它,而不管输入是什么。我理解Perl,例如,如果您将字符串传递给一个期望数字的函数,并且它使用字符串文字,特别是如果字符串中或字符串开头有数字,那么Perl会将其视为数字本身。因此,关于软件的哲学有很多,什么是可以接受的?这是一个很大的范围。
动态类型语言这一点非常重要,因为Rust是一种静态类型语言。它对数据类型非常挑剔。嘲笑Perl很容易,他们会说“我无法相信你把字符串当作数字”。
但另一方面,Rust几乎是相反的,它非常挑剔。它区分不同整数类型的特定位宽。因此,32位整数和64位整数之间的区别与字符串和蚂蚁以及Python对象或其他任何东西之间的区别一样大。
事实上,情况比这更糟。在Rust中,您可以定义一个数据类型,其宽度为64位。因此,它的表示与CPU的原生位宽完全相同。
因此,我们大多数在64位CPU上运行的程序都具有一个数据类型,该数据类型在每个体系结构上都精确地为64位宽,并且是一个原始类型,这意味着它的尺寸被假定为与CPU体系结构的原生位宽相同。假设CPU。
CPU是其原生位宽的CPU。对不起,这是一个很长的解释,我想说的是,您可以有两个值,它们都具有相同的数字42,编码为64位的无符号整数,以及我们称之为`usize`的类型。每个位都相同,所有位都相同。
然而,Rust编译器不允许它们进行互操作。您不能将一个加到另一个上。Rust语言中没有隐式整数提升或转换。因此,Rust非常严格。这使得像错误处理这样的事情对于初学者来说非常令人沮丧,因为它非常严格。
为什么Rust会以这种方式处理错误?
因为本质上,语言本身以及语言生态系统都认为,如果我们尽可能多地将工作推给编译器,如果我们能够避免问题,那么我们就应该这样做。如果您在32位CPU上编译程序,那么64位类型的位表示和32位类型的位表示将不同。
因此,这可能意味着不同的语义。因此,程序的输入实际上包括程序编译的CPU和操作系统。Rust试图通过在编译时尽可能多地消除错误的可能性来减少错误的可能性。一旦我们消除了正式程序中错误的可能性,不幸的是,这有时需要非常严格,那么我们就会拥有运行速度非常快的程序,与编写原生汇编代码一样快,甚至更快,而且没有可能崩溃的可能性。因为我们不需要在很多地方进行错误检查,因为语言本身提供了保证,并且在运行之前就已经检查过了。
所以我认为Rust的创建者这样做是因为他们有使用C++和其他语言的经验,在第一遍中,您需要编写程序来完成您想做的事情。然后在第二遍中,您可能需要考虑,如果体系结构不同,我们需要做您刚才解释的事情,即答案可能不同。所以我们需要为此做一些检查。
我们需要对大小端做一些检查。Rust的创建者是否将所有这些都推到了编译时?因为他们被这些问题困扰了很多次?您对这段历史有什么看法?
我对这段历史了解一些,但我可能是间接了解的,但让我们这么说吧,Rust编程语言在早期阶段,在1.0版本之前,是一个关于如何安全地运行程序的实验。它有一个活跃的模型。
所以它看起来更像早期的Go,而不是在Mozilla采用该项目作为Firefox Web浏览器的实验性替代品之后出现的版本。它被认为是一个可怕的烂摊子,但突然间,大量的C++开发人员涌入该项目,并认为:“如果我们真的关心需要运行速度非常快的东西,并且我们需要防止一大堆会困扰我们的bug,那么C++不行。”
C++由于其C语言的遗产,允许的bug之一是与技术上称为整数提升或隐式类型转换相关的bug。如果您允许语言中出现这种情况,那么您将在运行时遇到这种情况,即您作为程序员的假设与运行时的假设不同,您会遇到bug。Rust的设计者在那时做出了一个决定,在使用该语言构建这个新的浏览器引擎之后,我们应该对人们非常严格,因为该语言本身将在编程语言的生态系统中找到自己的位置,因为它与原生代码一样快,但完全是内存安全的。而内存安全的一部分,或者说提供速度和安全性的语言哲学的一部分,是应该提供一种非常低开销的错误处理机制。所以在某种意义上,Rust的方式或Rust的方法与C++模型相反,许多程序员似乎已经厌倦了尝试查找和修复bug,尝试查找异常突然出现或其他任何类型的bug,一些他们很久以前没有遇到过的事情。
好的,谢谢,Tim。既然Rust社区已经存在多年了,至少十年了,人们已经体验过Rust的方法,创建者会说它有效吗?或者自从他们开始以来,事情是否发生了变化?
我认为业界总体上有一种趋势,那就是采用函数式风格。我还没有提到的一件事是,Rust结合了一些函数式编程语言的概念。我们很快就会看到,Rust用户实际上从ML家族的语言(包括Haskell和OCaml)中采用了非常函数式的风格来处理错误类型或错误处理过程本身。而且我通常认为,来自函数式编程语言的许多想法已经被采用或融入到更传统的编程语言中,例如Java、C#或C++。这些语言通常与命令式或面向对象的风格相关联,在企业软件中大量使用。
是的,我在过去一年里花了很多时间在这些语言上。然后,当我今年开始更多地使用Rust时,它感觉非常不同。我喜欢你谈论函数式方面的内容。
是的,Alexa是一个有趣的程序,我花了好几个月甚至几年的时间来学习Alexa的经验。
这很好地引出了我的下一个问题,作为一个多语言程序员,我认为我们都有关于在这些语言中处理错误的看法。您认为Rust的错误处理是好是坏?如果我们退一步,撇开我们对Rust的喜爱,我们如何将Rust中处理错误的开发人员体验分类,假设我们不知道我们已经知道的事情?
是的,我认为开发人员体验在两个方面都存在问题。刚开始的时候会很不舒服,因为它与众不同。而且,如何构建程序有点不清楚,因为您不会得到与异常处理相同的东西。
您处于一种不熟悉的状态,很难考虑程序的结构以及您想要实现某些东西的方式,在Python中很容易实现,或者在您遇到的任何其他编程语言中很容易实现,但突然间您在Rust中无法做到这一点。具体来说,我指的是处理异常。另一件很有挑战性的事情是,一旦您编写了大量的Rust代码并开始编写您自己的库,您需要开始学习如何组合不同的错误处理方法。
对于不熟悉我所说内容的人来说,这听起来可能有点荒谬。但大约15到20分钟后,我们将讨论错误处理的第四个级别,本质上是大量优秀的第三方库或开源库已经解决了这个问题。
Rust程序员的生态系统不仅仅是轻微的打磨。它本质上是对错误处理方式的重新思考。因此,已经有一些团队实际上创建了一个错误处理的抽象层。我们之前谈到的Rust非常挑剔的事情,当您遇到来自不同来源的上游错误时,就会变得更加严重。总的来说,这是一个不同的类别。
我们可能会使用“类”这个词,尽管 Rust 没有类,但这指的是从 I/O(输入/输出)生成的错误类别,例如我们尝试读取的文件没有读取权限或文件可能不存在。这与来自不同模块的错误类别不同,例如与在字符串中使用数字或格式化输出相关的错误,本质上是将数据转换为字符串。因此,一旦你开始尝试将所有这些不同的错误放在一起,Rust 就感觉非常冗长。它非常……
我的下一个问题是,是否有任何处理错误的最佳实践?
我认为基本的实践是逐步工作,扩展你的知识,而不是直接跳到理想状态,而是坚持你的思维模型,然后随着你对语言的更多了解而扩展它。例如,Rust 的 Trait 系统,Trait 就像接口或抽象类。我们可能会说某些东西是一个错误,我们可以说它实现了……
错误 Trait 实际上与你可能预期的有所不同。因此,如果你试图直接采用这种理想化的实践,而没有建立一个思维模型,你可能会迷失方向并感到沮丧。所以,这……希望如此。这并不是说我只是在回避问题。另一种更快速的方法是说,我们正在讨论的四个级别是一个渐进的过程,最终你会到达故障级别。
是的,这就是我理解的。简而言之,这四个级别与刚才提到的最佳实践有什么关系?
是的。我的意思是,如果我们认为这是一个朝着某种理想状态发展的过程,我说,这是处理错误的好方法。我们基本上可以从忽略错误并直接导致程序崩溃开始。
然后我们转向使用最初未充分利用的信息,例如提供一个非常非常基本的类似于 try-catch 的东西,并带有非常通用的错误。然后我们可以添加一些特殊的功能。我们用 Rust 做到这一点的方法是,通常是创建一个枚举,Rust 枚举有点特殊。
它们比命名常量更丰富,最后一级本质上是,我说的是对语言公开的一些粗糙服务的扩展。使用枚举的问题是,在这个第三级,我们需要实现我们可能遇到的每一个 Trait 或每一个接口或每一个错误的组合。我们还需要告诉 Rust 如何将这些东西打印到屏幕上。这相当笨拙。因此,最后一级本质上是对前面方法的简化。
是的,我认为我犯了一个错误,直接使用了某个库,而不是先理解我在做什么。我知道的是,如果你正在编写一个库,应该使用什么;如果你正在为用户编写一个二进制文件,应该使用什么。所以让我们从忽略 Result 类型开始,例如 Result<T, E>。
是的,很棘手,对吧?所以在源代码中,你会有大写的 Result,然后是尖括号,大写的 T,方括号。T 代表某个操作成功时 Result 中包含的类型。
但这很烦人,因为最初的 T 感觉应该有类型,但实际上它……我的意思是,它有一个类型,但它并不明确说明那实际上意味着什么。
他们为此道歉,这并不是语言的新特性,或者至少这是惯例。他们把责任推给了外部库。是这样吗?
你所说的忽略是什么意思?如果你使用它,会是什么样子?你在这里忽略了什么?
Result 是一种元类型,它有两个方面。一个是 Ok 方面,包含我们一直在讨论的 T;另一个是 Err 方面。我们可以将 Result 视为一个简单的对。
它本质上是一个容器或某些东西,我们希望它是 T,但它可能是 E,所以我们调用 Result 对象上的一个方法(我在这里使用“对象”这个词不太恰当),我们调用 unwrap,这通常会打开 Result 并取出我们希望是 T 的值。
我们需要记住,Rust 的类型系统非常严格。所以我们说我们期望 Result 中的值是 T,然后我可以将我的大写 T 的某个值赋给一个变量。
如果我的假设是错误的,那么程序就会崩溃。这就是一种方法。另一种方法是,有些方法接受对其他事物的引用。让我举个例子。
让我们编写一个读取文件的程序。我们将通过初始化一个缓冲区来开始读取文件。我有一个缓冲区变量和一个文件变量。当我调用文件上的 read 时,我传入对缓冲区的引用,然后该方法将返回一个 Result。
如果 Result 是 Ok,它可能会告诉我实际能够读取的字节数;另一方面,它将包含关于错误类别的特定错误消息。例如,如果我没有文件的读取权限,缓冲区实际上将保持为空。处理错误的一种方法是,基本上假设你的缓冲区已被填满,并通过将 read 返回的 Result 赋给一个永远不会再次使用的变量来使程序保持沉默。
在 Rust 中,错误是值,它们不会中断程序的执行。它们不像异常那样会中断并破坏流程。因此,处理错误的一种方法是,是的,将它们赋给永远不会再次使用的变量,我们可以对 Rust 编译器非常明确,它会非常乐意给我们一个警告,说这是一个未使用的变量,通过用下划线开头变量名来表示我们基本上忽略了这个值。
好的,所以如果我理解正确的话。Result 是一个函数返回的东西,无论是我们自己编写的函数,还是来自标准库或其他库的函数。所以它不是函数的结果,Result 是一个东西,就像他说的,把它想象成一个对象,里面可能有 happy path(Ok),或者另一条路径(Err),所以我们期望对这个 Result 进行操作,因为这就是它的存在方式。但是,如果我们选择一种简单的处理错误的方法,我们只需忽略它,将其赋给一个我们不使用的变量,并用下划线开头变量名来告诉编译器忽略它。
没错。是的。这两种方法的区别在于,一种是在 Result 上调用 unwrap,另一种是使用一个我们永远不会再次读取的变量。如果你调用 unwrap,而实际上没有错误,你的程序将会崩溃,尽管它们是截然不同的方法。而如果你将一个表示错误的 Result 对象赋给一个永远不会使用的变量,那么你的程序将继续执行,就好像什么都没发生一样。
我们可能会在快速原型设计时使用这种技术,你知道,我们不想让它……
防弹,准确地说。我们会使用这样的方法,如果存在两种情况。一种是你只是在编写一些 12 行长的代码。
另一种情况是,确实存在一种情况,你并不关心是否发生错误,因为 Result 的任何一方都很好。他们说,本质上是,我不知道什么时候会发生这种情况,但大概……我的意思是,它对你是可用的,但我实际上不会……这本质上只是开始了解 Result 可以是 happy path 或异常情况。错误方面是……
寻找一级,二级将是我们所说的,你所说的返回字符串作为错误。那是什么样的?
好的。所以当我们开始……让我们决定定义一个 Result,我们不确定所有或将接受 Result 作为返回值的人会想要什么,我们仍在设计我们的 API。我们可以做的一件事是只返回一个字符串。
然后,如果我们想要……如果我们想要引发异常,我们只需要将任何我们想要的、适合当前时刻的文本放在 Result 的 Err 变体中。然后,我们的调用者需要做的是在调用方解释文本。例如,让我们谈谈从文件读取的情况。
你可以想象这样一种情况,文件上的 read 方法返回一个错误,或者返回一个包含一个字符串错误的 Result。然后你的调用者会想知道出了什么问题。所以你会查看字符串,说,好吧,我们做字符串操作。
它是否以“权限被拒绝”开头?好吧,如果它包含这个子字符串,那么我们假设来自操作系统的错误是权限错误。存在某种权限错误。
事实上。所以如果我们把字符串作为错误,我们将返回错误类型给你的用户。所以可能是命令行工具或其他什么东西,我们会编写……
哦,所以是用户。这个场景截然不同。这是导入你的库的应用程序代码。用户不是最终用户,而是另一个程序,它可能有一个你返回的代码调用的函数。
所以,如果你正在定义一个 Result 类型,这很容易做到,那么它将与稍后某人将调用的类型上的方法一起工作。所以你现在定义的 Result 将由你可能无法控制的其他代码来解释。特别是如果你正在编写开源代码或将库发布到全世界。
根据你的例子,你是在解释错误字符串吗?我理解你会到达一个你试图寻找其他东西的点,对吧?是的,我理解“权限被拒绝”,但是你是否会寻找一个我可以查找或理解的数字,你知道,你有不同级别的错误,例如内存错误或写入问题系统错误。不,错误使用了一个组类型。
东西,对吧?我们实际上可以……所以另一个,而不是返回字符串,是相同的概念。
我们可以返回整数。事实上,我们可以返回……因为我们正在讨论文件 I/O,我们可以返回 lib sys errno 本身,由操作系统定义。现在,你刚才说的有趣的事情是,你需要查找 errno 来弄清楚这个特定数字的含义。本质上,代码不是自文档化的。
或者另一种说法是,如果你犯了一个错误,如果你将错误消息作为字符串或整数传递,但误读了某些内容,我们有一个类型错误,突然你根据上游提供的代码的最坏解释做出了决定。你为另一种错误打开了大门,那就是处理你自己的错误的错误。这是一种促使下一步的动机因素。
清楚了吗?是的,我的意思是,我们处于返回字符串作为错误的级别。我们已经超越了仅仅忽略事情的步骤。我们感觉更勇敢,更聪明。我们所以啊,是的,让我们为错误赋予一些意义。然后我们有了下一级,或者实际上我们需要复制一些其他类型的环境信息,这将我们引向第三级,即枚举。我认为这是对的吗?
是的。枚举在 Rust 中之所以有效,是因为枚举有两个特性。一个是它们是命名常量,你从每种语言中都了解到这一点。
我们本质上是,而不是有一个角色数字,我们可以给你一个名称,我们可以将所有这些错误分组到某个类别中,或者,你知道,这不是一个正式的术语,但也许是一个类。然而,这是一种打开方式。Rust 通过允许你将数据打包到这些变体中的每一个中来提供更多的丰富性。
所以,如果你曾经使用过……看,这就是标记联合的概念,其中一个字节用作数字标记。然后有一个类似结构的东西,你可以将任何你想要的东西推入剩余的数据中,这可以本质上是一个类型。这是一个在同一个数据包中的内联类型吗?现在在 Rust 中,你还有一个额外的非常好的特性,例如,因为编译器知道有九种不同的错误类型,现在我们正在定义,而你只在代码中处理了六种,它将拒绝编译,直到你为另外三种添加处理。
这意味着,再次强调,Rust 将要求你处理所有可能的情况,并在编译时强制执行此操作。因此,使用 C NIO 错误类型的程序将要求你考虑可能发生的所有错误类别。这比仅仅传递整数要强大得多,因为编译器不会给你很多支持,例如 C 编译器并不关心。如果你用整数打赌,而在 Rust 中,我们可以确保你至少考虑了你可能遇到的情况。你可以有一个 catch-all 模式匹配,这本质上是,我不在乎,但你已经选择这种行为,这比编译器说我期望你不会……更强的思考。
介意我们回到用字符串返回错误吗?当我们从函数返回某些东西时,我们必须对其进行处理或将其标记为可处理的,忽略它。所以,如果我们试图将其分解,但就像你提到的那样,返回一个结果,然后你必须对其进行处理和检查。
但是事情是,正如你所料,我们可能会在字符串匹配上遇到巨大的麻烦。所以我理解这一点。但是使用枚举,我们可以定义我们想要在枚举中表示什么。然后抱怨当在调用的函数中返回枚举时。
是的,完全正确。假设我们,你知道,一直在谈论同一个错误,同样的场景。但同样,我们正在从文件中读取。
我们可能被拒绝权限,我们可能耗尽内存。有很多事情可能出错。文件可能已被删除。我们不想解释操作系统本身可以提供信息的地方。
编译器对类型系统所做的就是要求你摆脱解释操作系统返回的是什么的难题。它是完全明确的,并且有一种非常令人解放的感觉,而不是匹配字符串或,你知道,不以这个开头,或者,你知道,我们经常在像Java、C++或Python之类的类型脚本中处理这种情况。我有时会像任何合理的程序一样,你只是与字符串进行比较,它是否等于“权限被拒绝”或其他什么,而Rust可以提供更高级的东西?
是的。所以我在脑子里想着回到之前的例子,通常会进行匹配,如果它是代码1或2或9,或者它甚至不是1,都没关系。如果是审计,如果是负数,但是你必须考虑所有可能的返回值。
Rust会阻止你这样做,除非你对每个结果都进行匹配。我喜欢这一点,忽略结果,无论你是否进行匹配。因为你想让它健壮,你分解内部并对每一层进行处理。
或者决定,他说,选择忽略九种可能性中的三种。离开。很好,对吧?是的。
没错。你在这里还提到另一件事,我想快速聊聊,那就是在小型语言中,我是一个很好的开发者很多年了。在HF的一个阶段,人们过去常常返回负数来表示存在错误。
他们可能做的另一件事,尤其是在Go中,是使用零值来表示缺少值,基本上是一个未初始化的零值,例如空字符串。这基本上是一样的,表示根本没有字符串。最后一种方法是将你正在处理的整数包装在另一种类型中,并将大量工作交给编译器和类型系统。
因为如果你忘记检查负数,即错误,那么你的程序稍后就会出现真正的问题,因为该数字在数值上是有效的,你的问题只有在发生某些崩溃时才会出现,因为发生了某些你没有预料到的情况。我没想到会得到一个负数。现在你必须追溯到我们在地球上的哪个位置。
这个负数是从哪里来的?这实际上是我第一次创建时应该仔细检查的错误。Rust在这方面并不宽容。我认为这对健壮的软件来说是一个巨大的好处。
你什么时候会求助于内部方法级别?与字符串相比?
是的,我认为如果你是一个专业的Rust程序员,你会很快发现这一点,如果你只是在学习,或者如果你还在设计一个库,你想给自己足够的灵活性来快速更改事物。所以你花更多时间生成错误而不是处理错误。所以字符串方法是一种松散类型的,字符串类型的。这种错误类型是可以的,但我通常建议大多数预期运行时间超过几小时的程序应该为其定义一个自定义错误类型。
好的,所以我将继续讨论第四级。我认为我们已经很好地介绍了其他级别,因为它们迫使你处理可能会妨碍你的事情。但这在达到这个级别时是一件好事。在你继续讨论库部分之前,还有什么你想介绍关于枚举的内容吗?因为它们是之前所有内容的核心。
是的,也许是为什么你会更进一步的动机。你可以用它做的一件事是使用上游错误作为你自己的错误类型的一个变体。所以基本上,你正在创建一个超级错误,它组合或基本上允许你将其他类型组合在一起。
这有两个问题。一个是定义这种组合实际上需要大量的工作。然后你需要做很多机械工作来描述,基本上是用字符串表示错误类型。
我们还没有讨论的一件事是,Rust中的错误接口仅仅关注于序列化数据类型本身的能力,也就是说,所有错误都应该能够被打印到日志文件中,这就是为什么字符串能够用作错误的原因之一。对于你定义的自定义用户定义数据类型,根本没有任何实现。当你创建一个类型时,当你定义它时,你还需要定义它实现的每个接口,以及它实际工作的每个特征。这包括如何将它打印到屏幕上以及将其序列化为流。对吧?
谢谢。所以你现在是精英。你知道如何处理所有遇到的错误,你达到了这个级别,然后你意识到我不再需要学习了,我可以直接使用这些库之一,或者我可以完全重新思考我的做法,因为其他人已经创建了这些库。是的。
完全正确。所以你刚刚听了我将近一个小时的讲解。我希望你每周都这样做。现在我要告诉你,顺便说一句,大部分工作实际上已经完成了。
有了宏的帮助,定义你自己的错误类型的过程现在非常简单,宏的工作是告诉编译器该做什么,这样你就不需要编写汇编代码了。我讨厌成为那些做同样事情的人之一,我记得我真的很讨厌,这基本上是在教授所有基础知识后再教授这种高级技巧。但是,我觉得在处理像错误处理这样基础的事情时,了解这些库在做什么确实很有帮助。
否则你必须处理它们。否则它就会变得神奇。而我强烈反对软件中的魔法。
是的,这与任何计算机科学一样,你需要先了解基础知识,然后才能轻松地进行抽象。我认为我同意。所以,在你的演讲中。
好的,还有三个库。第一个叫做thiserror,第二个是anyhow,第三个是fehler。我们快到节目结束的时候了。所以如果我们能在每个库上花几分钟时间,以及你将在哪里使用它们,我就可以进入总结环节。
是的,没问题,完全没问题。thiserror是一个库,或者定义一个表示不同变体的枚举。例如,你会说它有一些有用的宏,用于说明我希望这样。如果遇到这个变体,或者我正在包装一个上游错误,这就是我想打印出来的字符串。所以我想让编译器做的事情是分派到上游错误,例如。
你能够将上游错误更改为更有意义的东西吗?
是的,是的,是的,它可以提供一些钩子来简化事情或使它们更具体。例如,如果你查看文档,首页上有一个例子,这是一个数据存储错误。所以我们正在编写一个库,它是某种数据库的客户端库。
错误类型之一是断开连接。但是数据存储,底层网络套接字已经关闭了我们正在使用的库。我们不知道为什么它关闭了。
所以我们实际上继承自标准库自己的IO错误。这里没有太多模型,但在这种情况下,我们基本上包装了标准库自己的IO错误,然后我们提供我们自己的上下文,即数据存储断开连接。然后它基本上打印出底层错误。
是的,我在想类似于你使用第三方API或其他东西的情况,并且当它返回500时,它实际上是可以接受的,或者你可以忽略它。
LL哈拉。是的,是的,绝对可以。这将是你可以做的一件完美的事情。你有机会去检查任何保存的东西,很可能它是一个500,但他们说500在某种意义上是可以接受的,因为它们表示空数据状态,例如。
我理解这个,它们应该被用来做什么?好吧,如果你正在用错误消息与程序的用户沟通,那么你应该使用它。用户。
是的,这是一个关于哪个库应该用于应用程序代码与库的奇怪争论,这是一个我认为没有特别帮助的区别。但是实际上,我们现在要讨论的最后两个库在大多数情况下可能更适合应用程序代码。thiserror是一种定义你自己的结果的方法,它提供了很多服务。
但它的缺点是你的硬编码涉及一种官僚主义的方法,其中每个实例都需要处理。在应用程序代码中,有时你并不关心为什么会出现问题。你只需要知道出现问题了。也许,注释输出,并附带一些上下文,例如我试图做这个,这就是打印出来的日志行。对于这些用例,我们将要讨论的最后两个库可能更合适。
好的。下一个是anyhow。
对吧?所以我想实际讨论一下anyhow和fehler。fehler和anyhow是同一个想法的两个略微不同的版本。
你应该使用你喜欢的任何一个版本。但它们在概念上是相同的。它们只是对同一个想法的不同看法。
所以Tim,你能为我定义一下吗?他只是说anyhow和fehler依赖于表示的细微不同的机制。
那是什么?好的。我的意思是,我为我的声音感到抱歉,它感觉有点不舒服。
但区别在于,前两个依赖于某种东西,trait对象。这基本上意味着某种东西可以实现一个接口。在我们的例子中,它实现了错误接口,或者更确切地说,它实现了错误特征。
错误特征仅仅意味着该事物可以表示为字符串,并且可以打印到控制台或记录到文件中。但是如果你收到一个特征对象,那么错误可能是在调用堆栈中的某个地方生成的。它到达你的函数,你试图处理它。
你唯一能做的事情就是读取消息。你不知道生成错误的实际具体类型。错误类型已被擦除,你只能访问接口。
而fehler依赖于具体的类型本身,你创建的一些新类型,它可以包装你拥有的任何其他东西。这种新类型表示为你可以彻底检查的东西。如果你曾经使用过C程序,你可能遇到过标记联合这个术语。
你有一些数字标记,然后旁边有一个数据字段或结构字段。这就是它们在Rust内部的工作方式。你可以访问并检查错误的具体标记。所以fehler提供了更丰富的体验,你拥有更多控制权。但是,缺点是,有时你实际上并不想要控制权。
你可能希望能够尽快退出程序,或者只是知道某些事情出了问题,现在是中止程序并向用户返回消息的时候了。在这种情况下,你可能更容易使用只说这个错误的库,anyhow或fehler,它只是一种能够获取任何生成的错误的方法,因为错误是值,并且可以非常快速地备份、退出。我们可以将其注释为字符串,并添加一些额外信息。所以这些库通常都有方法来添加上下文到消息中。
好的。我有一个问题,我在使用 Rust 与 C 交互方面做了很多工作,现在我正尝试将更多信息从 Rust 传递回 C,我不确定哪些技术适用,我将解释我做了什么。所以我们有一个例子,看看我们能用到哪些技术。
我有一个项目,它尽可能地底层,它监听 TCP/UDP 套接字,模拟电话系统,然后记录试图接入它的恶意行为者,以进行语音通话。人们已经这样做了很长时间。我正打算添加 TLS 支持。
TLS,现代特性,我非常害怕去做,看看在 Rust 中怎么做。所以到目前为止我的旅程非常疯狂。今年我学习 Rust,学习如何添加 extern "C" 声明,如何编译调用 Rust 函数,通过我生成的 header 文件,最初是动态调用,然后从 C 使用它,然后我切换到使用 bindgen 来生成它。然后我构建 Rust 代码,然后,因为这个 TLS 函数实际上记录了东西,我需要回到 C 端调用现有的 C 代码来记录东西,比如写入文件,而不是重写相同的代码。所以它就像一个双向的外部函数接口。
对吧?所以你在里面……对。我正要回来寻求更多信息。
所以,对不起,我必须在 Rust 端生成 C 风格的字符串。我把它放在堆上,然后我必须在 C 端释放它们。然后我需要处理错误。
我明白了。这听起来像……是的,我可以想象你为什么寻求帮助。
这是这个节目的驱动因素之一,因为你知道,最初的想法是在 main 函数中使用 Box<dyn std::error::Error>,但这在你从 C 调用函数时不好用。
不幸的是,不行。
所以在 Rust 端,也许有些板条箱可以帮助我,我只是让所有我调用的 C 端函数返回一个简单的整数,我用 0 表示成功,非 0 表示失败,然后在 Rust 端打印错误信息。但我目前在 Rust 端只是简单地记录这些东西。
我让它失败,因为它是一些重要的事情,比如找不到证书和私钥。但在我们今天讨论的一些内容中,我们有 `?` 运算符或 `Result`,它将错误信息向上传递,但我不能真正使用它。所以我只是想寻求一些建议,基于我们所说的。这些方法能传递……
不能。所以你真正问的是,我们能否使用 Rust 提供的这些不错的特性,并将它们传递到 FFI 边界,然后在 C 端集中处理它们?我们无法做到这一点,这会让 C 代码知道 Rust 正在做什么。所以我会向听众解释一下。
例如,这里提到的两个术语是 extern 和 no_mangle。这里的想法是,extern "C" 是 Rust 代码上的一个注解,它告诉 Rust 编译器使用 C 调用约定。所以我们实际上是在编写 C 代码……不,我们正在编写一个函数,它遵循 Rust 中 C 的规则,例如,C 精确定义了局部变量出现的顺序以及它们是如何放入寄存器中的,以及其他一些大多数程序员都不想关心的东西。
no_mangle 表示……它告诉编译器不要更改符号名称,使其与源代码中定义的名称不同,这样你就可以从 C 端访问相同的符号名称。所以我们实际上是在说……是的,你知道,这是一种非常困难的交互。如果你错了,请纠正我,但你正在制造冲突。你正在用 Rust 编写一些软件,它与一个 C 库交互,这个库处理你的 VoIP。然后你想能够……但你想传递,比如说,在 Rust 中生成的 TLS 连接到 C 库。是这样工作的吗?
C 是一个二进制文件。好的,它调用一个名为 listen_tls 的 Rust 函数,它是一个 Tokio……嗯,感谢。
所以它使用异步线程。所以……我将一个配置指针传递给该函数。所以在 Rust 端,它会理解我是否处于调试模式,我们需要打印错误消息,证书在哪里,异常等等。我只是想了解是否有任何更丰富的机制可以在 Rust 和 C 之间传递。
可能不是在底层,但我认为……对不起,不是你,而是创建两个端之间的一个抽象层,作为某种胶水。而这……这只是我听到你的问题后的一个想法。
你定义一个结构体,它可能类似于错误报告,它可以在 C 端或 Rust 端定义,哪个更方便。它有空间用于……你需要给它一个……在 Rust 端,标准库有一个 C 字符串类型,你需要能够……所以你需要在某个点……然后你需要一个链接字段。
你可能应该……在这个抽象层中,作为一种链接,在返回 Rust 端的函数之前,你会检查这个全局变量吗?不,不会。
这个想法是,我们定义某种隐藏的符号,一个全局变量,我们传递它,你的 Rust 代码会快速检查是否有东西在那里。如果有,我们就会返回一个实际的错误,我们可以根据一些信息来解释它。
它可能是一个实际的字符串,也可能是错误代码,或者其他什么,但你想在中间放一些东西。这种方法比较常见。所以如果你暴露……你必须创建许多板条箱。
在 Rust 生态系统中,你会看到它们通常为一个 C 库定义一个板条箱。假设我们有一些库是 void_voip,用于 VoIP。它在 Rust 端,所以它将是 void_voip 板条箱。然后在 C 端,它可能是 libvoid_voip,这可能不是一个好名字,但好的,所以如果这些是公开的 API。然而,两个端的实际链接通常在 Rust 端定义为第二个板条箱,称为 dash_voip。
所以 dash_voip 会使用你之前提到的 bindgen 读取所有 C 头文件,在 Rust 端生成相应的结构体,并大量使用 unsafe 代码,所有 unsafe 代码都将被包含在这个 dash_voip 中,而这个板条箱的工作是公开一个……它很容易使用,易于使用的 ABI 实际上是在另一个板条箱中定义的。所以一个比较常见的模式是从你想要使用的 C 端 ABI 开始,然后定义一个中间板条箱来使用该 ABI,但它会公开一些更友好的东西。
你的工作将是尝试提供某种上下文结构体,可能是通过全局变量,但它可能不是……它可能是额外的参数,你可以通过某种方式传递它,这又是很典型的事情。如果它不是 None,那么我们需要……那么我们需要考虑操作失败,然后像往常一样处理其他一切。
不幸的是,当你在这两个世界之间导航时,我们不能将 Rust 强制转换为其他东西。你需要精确地……当 Rust 试图成为 C 时,它需要使用 C 的约定。我非常好奇是否有任何听众……实际上,这里有一些建议,因为我认为这是与了解这个领域的人进行讨论的好机会。我有非常强烈的意见,并且非常有兴趣听到你们的意见。
好的。我认为这解释得相当好。谢谢。我很高兴我们做了这个,希望这对像我一样的人有所帮助。我们有没有错过什么你想提到的?
没有,只是……唯一的一点是,如果你在学习 Rust 时遇到困难,给自己一些喘息的机会,让你的身体和大脑逐步吸收这门语言。它引入的语义与其他语言不同,Rust 感觉非常严格,在某些情况下甚至很苛刻。我认为这是因为它应用的安全规则与你习惯的不同。所以你的大脑需要时间来适应,学习时要耐心。
人们可以在 Twitter 上关注你。或者其他方式,你想让他们联系你吗?
如果你不在 Twitter 上或 X 上……我也在 Mastodon 上,也在 YouTube 上。我越来越多地将我的内容发布到 YouTube 上,如果你喜欢的话,这是一个很好的方式来关注我,因为我每隔几周就会进行一次直播,我们会一起完成一个小的练习,我会尝试笨拙地完成它,并进行编译,这些都会出现。
我看过一些,它们非常好。感谢你这样做。感谢你来到节目。这真是令人愉快。我是 Gavin Henry,来自软件工程师广播,感谢收听。
感谢收听。SE Radio,一个由 IEEE Computer Society 和 IEEE Software 杂志带来的教育节目。更多关于播客的信息,包括其他剧集,请访问我们的网站:se-radio.net。提供反馈。
你可以在网站上对每一集发表评论,或者通过我们的 LinkedIn、Facebook、Twitter 或 Slack 频道联系我们:se-radio.slack.com。你也可以通过电子邮件联系我们:[email protected]。本节目及所有其他 SE Radio 剧集的许可证为 Creative Commons 许可证 2.5。感谢收听。