https://t4wydfkrrq.feishu.cn/docx/doxcnM3juNBUJfxGcIWOfJRLk5g
https://www.bilibili.com/video/BV1sN411N7oX/
为啥Rust要人为指明生命周期
评论区有几个评论是值得看到~
链表类的(包括树和图),以及池化对象,无法在编译期的有限时间内推导出准确的释放时机,只能人工标注 或 引入GC那样的定期扫描引用关系。
还是不能说服我 :比如go虽然是gc的 但是用内存逃逸分析解决悬垂指针问题 而且是在编译阶段解决的
视频8:30和up前两个视频关于rust黄金法则已经说明了,编译器只会关心函数签名而不关注函数体,这是合理的,因为对函数体的修改不应该影响函数的返回值,否则如果生命周期可以根据函数体推导,当这个函数被大量的调用时,可能无意间对函数体的修改会隐式的改变返回值的类型,这不仅违反了rust的显式哲学,而且可能无意间导致现有的函数调用被破坏而发生编译错误,尤其是开发库时更为严重
所以我可以理解为,生命周期的标注是函数的创建者针对后来的维护者或者修改者做出的限制,这样对吗?
我记得有个 Lobster 语言就走了“帮人帮到底”的路子,模型用引用计数,但其静态分析号称可以消除一个实际项目中95%的引用计数需要,楼主可以去围观围观。另外有个 Vale 语言,推出了一个叫 generational reference 的东西,号称效率高于GC/RC,最近还有个 Hylo 语言,搞出来了个 mutable value semantics 模型,发了一篇30页的 paper 讨论这个模型,我感觉编程语言领域照这么发展,再过10年真有“完美编程语言”也说不定[原神_嗯]
之前关注那个“比Python快35000倍”的 Mojo 语言的时候,我就在他们仓库里看到了引入生命周期标注的提案,那个提案有说“The major thing that Mojo (and Rust) need from lifetimes is what is called “local reasoning”: we need to be able to reason about the memory behavior of a call just by looking at its signature”,这么看来这也是为了显式地给“使用者”(程序员)展示出有用的信息。
2024-03-22 01:31
以前版本的Rust,生命周期是必须标的不能省略,后来觉得有些生命周期标注比较公式化,就专门弄了几条规则来允许省略。你全标上去是不报警告的
来看一个比较有意思的问答,问题是:为什么Rust需要开发者明确指定变量的生命周期,编译器不能自己推断吗? 这个问题的核心在于,Rust编译器为什么不能在编译期间自行计算或推断生命周期,而需要开发者明确指定。
提问者表示,自己理解生命周期的概念,但应用起来觉得非常复杂。他无法理解为什么在编译期间,Rust无法自行推断生命周期,而需要开发者手动指定。他还举了一个例子,提到当一个字符串切片来自另一个字符串切片时,生命周期看起来应该很容易推断。那么,既然人可以根据输入判断出来,为什么Rust编译器不可以呢?
提问者随后更新了问题,表示在阅读了一些答案后,有了一个进一步的问题:为什么需要指定生命周期是Rust独有的?这是否与Rust使用所有权模型而不是像其他语言那样使用垃圾回收有关? 提问者对此还不完全清楚,但他觉得两者之间一定有联系。
之后,提问者感谢了社区的回复,表示自己对这个问题的理解有所提高,但仍需要一些时间去消化。我们可以看到,这个问题并不是特别容易理解,但社区的支持非常棒,回答也非常丰富。
高赞回答
其中一个非常高赞的回答来自用户ZZMY。他简短但切中要害地指出:编译器在决定省略生命周期时,只关注函数签名,而不关心函数体的内容。这是设计上的选择,因为函数签名应该涵盖函数的所有行为。他解释了为什么要在函数签名中标明生命周期,而不是让编译器进行推断。
提问者对此回复道:这说得通,类似于在函数签名中定义类型的方式。但他仍然不理解,为什么需要在Rust中指定生命周期,而其他语言没有这个问题。他猜测,这可能是因为Rust选择了所有权模型,而其他语言使用垃圾回收机制。
更详细的回答
另一位用户给出了更详细的回答,他从不同语言的内存管理机制进行了比较:
- C++:具有类似的生命周期要求,但它是隐式的。违反这些要求会导致未定义行为。C++依赖开发者自行管理内存生命周期。
- C# 和 Java:这些语言使用垃圾回收机制,引用通常被限制在堆上。由于堆上的内容只要有引用存在,内存就不会被释放,因此不需要开发者显式管理生命周期。
- Rust:使用所有权模型,编译器要求开发者明确指定变量的生命周期,以避免悬垂指针等内存安全问题。
他指出,Rust采取了不同的内存管理方式,避免了跨函数边界隐式确定生命周期。这是为了防止动态行为中的生命周期问题,也为了避免模块边界的生命周期验证难题。此外,如果Rust允许自动推断生命周期,函数体的任何改动都会导致函数签名发生变化,从而影响到其他调用者,这会带来很多不必要的麻烦。
生命周期的省略
有时候,Rust可以省略生命周期标注,特别是在编译器能够推断出输入和输出引用拥有相同生命周期的情况下。例如,当一个函数获取一个引用并返回对相同内存的另一个引用时,编译器能够推断出它们的生命周期是相同的。这种情况下,生命周期注解可以省略。
不过,虽然Rust允许在某些情况下省略生命周期,但这个机制是基于非常明确的规则的。如果有多个可能的生命周期组合,编译器就无法推断,需要开发者明确指定。
最后的总结
有用户指出,Rust不会自动从函数体中推断生命周期,因为如果允许编译器这样做,函数体的任何改动都会导致函数签名的变化。这意味着,当开发者重构代码时,可能需要调整所有调用者的代码,这会带来很大的维护成本。Rust选择让开发者在函数签名中明确标注生命周期,以确保代码结构的稳定性和可维护性。
这也解释了为什么Rust要求在函数签名中指明生命周期,而不是依赖编译器的推断。
为什么我喜欢用Rust进行词法分析和解析(一)
来看一篇文章,名字叫《为什么我喜欢用Rust进行词法分析和解析》。文章写于4号,对吧?这就是他原文链接。作者叫MATTEO,是个德国人,非常年轻,才21岁。接下来我们来看详细内容。
项目背景
我目前正在编写一个用于SCL分析的工具,叫scale labies,它专门针对skyline的方言。这个工具的目标是对SKR输入进行静态分析,包括语法检查,检查表列和函数是否存在。它将这些功能与嵌入式skylight runtime相结合,并在这个环境中添加断言条件的能力,从而为SQL开发带来非常好的体验。
此外,我希望能够向用户提供高质量的错误信息,包括上下文解释以及屏蔽某些诊断的功能。这个分析过程包括词法分析(token生成),根据scale light文档对scale进行解析,以及对生成的结构进行分析。在完成项目的静态分析部分之后,我还计划为4K和LSP(语言服务器协议)编写一个服务器,敬请期待。
在实现上述功能的过程中,我需要为SGI编写一个词法分析器和解析器。虽然skr label在开发上还远未完成,但我已经在Rust中发现了一些非常有用的特性,它们非常适合用于开发这种软件。
Rust 的特性
首先,文章提到的是宏。在大多数编程语言中,宏的工作方式有所不同,但它们的主要用途是减少代码的重复和冗余。
AST节点
接下来是AST(抽象语法树)节点。在SCLBLAZE的实现中,一个语句节点的定义如下:他提到一个小提示,展示了literal节点(literal node,自变量节点)。在source types model s中,给出了链接,所有的node都需要实现node特性,该特性是所有解析器函数的返回值,稍后用于分析语句的内容。
具体来说,需要一个public trade node来实现node,这里有个提示:这个public trade node使用了standard formatter debug作为其超级特性。简而言之,使用超级特性可以确保只有在满足standard format debug特性的情况下才能实现node特性。
代码重复问题
接着讨论了代码重复的问题。每个node不仅需要定义,还需要为其编写node特性的实现,这导致了大量的代码重复。Rust 提供了一种解决方案——使用宏来减少代码重复。
使用宏优化代码
他提到使用宏来完成以下任务:定义结构体,给结构体添加字段,并通过实现function token来引用self,满足node特性。接着,作者希望宏能够为literal node和explain node生成完整代码。第一个node除了token字段T之外没有其他字段,而第二个explain node还需要一个带类型的child字段。
通过两次调用这个宏,就能搞定两个node的定义,并减少代码的重复。他给出的宏定义非常简单,即使Rust的文档不太详细也能理解。这两个node的宏都可以通过一个宏搞定。
接下来是关于宏的参数和原变量定义。原变量定义从node name(标识符)和documentation literal(文档自变量)开始。在宏中,使用$符号来定义原变量,并通过不同的分隔符来定义字段类型。作者还详细解释了如何通过宏来处理多个原变量。
测试与验证
作者表示自己喜欢表驱动测试,因为它不需要嵌套逻辑,便于维护。他定义了一个测试案例数组,并为每个案例执行测试函数。虽然Rust没有类似Go的表驱动测试方式,但他自己实现了一套词法分析器的测试逻辑。
通过cargo test运行测试,每个测试函数都有日志和反馈。宏接收测试组的名称,并包含了输入和预期输出的列表。输入被传递给解析器初始化,解析结果与预期结果进行比较。
测试组的断言宏有两种:
Test group pass assert:用于成功测试,断言结果标记类型和预期标记类型相等。Test group fail:用于边界测试,断言错误情况。
解析器的测试
解析器模块中使用了与词法分析器相同的概念和宏来测试解析器的生成结果。专注于边界情况和完整的SQL语句。失败测试验证了前提条件是否符合标准要求,比如explain语句后是否跟随了一个有效的语句。
解析器测试宏生成的代码与词法分析器的类似,测试过程中断言无错误且节点类型正确。Test group fail宏则增加了错误检查。
结语
这篇文章主要讲述了使用Rust宏减少代码重复的过程,重点在于如何通过宏优化AST节点定义以及测试。这是文章的第一部分,后续内容将深入讨论宏的陷阱和相关问题,值得期待。
Rust view类型
我们来看一篇比较老的文章,可能是3年前的。文章主要讨论了Rust中关于pin的问题,并且可以看到,社区一直在尝试通过各种方式改变这个问题。接下来我们会尝试用view types(视图类型)来解决这些问题,看看具体内容。
我想谈谈一个在我脑海里酝酿已久的想法,我称之为“视图类型”(view类型)。基本理念是让引用(无论是可变引用还是不可变引用)能够明确标示它实际要访问的字段。主要的应用场景是允许多个互不干扰的方法可以同时操作同一个引用。注意,这还不是一个正式的提案,至少在文章写作时还不是。
明确一点,这不是一份RFC或提案,只是一些早期的想法,我想把它们记录下来,希望听到大家的反馈和想法。具体的结论部分会在后面讨论。
示例
我们从一个运行示例开始。假设有一个结构体OneCashipmentManifest,它包含一个向量bus(chocolate bus)和一个列表golden_tickets(黄金票)的索引。现在我们想要遍历这些chocolate bus,并把它们放入package中,在这个过程中会插入一张golden ticket。
首先,我们编写一个函数来检查给定的条目是否应该收到这张golden ticket。编写完这个函数后,我们再编写一个循环,遍历chocolate bus并准备将它们进行运输。当我们编写完代码并感到满意时,启动编译器,结果却遇到了一个报错。
这个报错是partially moved,编译器认为我们已经从cf.bus字段移动了值,但是我们仍然在借用self。说白了,它认为cf中的一个字段被借用了,于是整个cf都被借用了。然而,我们知道should_insert_ticket只会访问golden_ticket字段,而这个字段保持不变,因此实际上没有冲突。
深入思考一下,编译器为什么会报错呢?因为它一次只看一个函数,它无法知道should_insert_ticket会读取哪些字段。它给出警告是为了防止将来出现问题,尽管当前没有问题。编译器保守的行为是合理的,但我们需要一种方法让should_insert_ticket向调用者描述它可能使用哪些字段,哪些字段不会被使用。
视图类型的概念
当我过去考虑这个问题时,我通常会想象可能被访问的字段列表附加在引用上。但这有点奇怪,因为引用类型&mut T本身是没有字段的。这时我想到,如果我们引入视图类型呢?
视图类型的想法是,我们可以通过字段路径place1..placeN T来访问结构体的字段,而不是整个结构体。这样可以让引用只访问一部分字段,而不是整个结构体。视图类型也可以像其它类型一样被借用。在我们的例子中,&golden_tickets OneCashipmentManifest会描述一个只提供对golden_ticket字段访问的引用。
当视图类型处于活跃状态时,仍然可以通过原始路径访问其他字段,这样我们就不会因为一个字段被借用而无法访问另一个字段。
方法中的视图类型
正如前面示例所示,我们可以在should_insert_ticket定义中使用视图类型来指定它会使用哪些字段。这样一来,我们可以成功编译prepare_shipment函数。
带有访问模式的视图模型
到目前为止,我们的例子都是通过共享引用来展示视图类型。我们同样可以有可变引用,允许对某些字段进行可变操作。例如,&mut bus_counter可以用于对bus_counter字段的可变借用,而不会影响其它字段的借用。
视图类型与所有权
我们可以将视图类型与所有权结合起来,产生一些有趣的可能性。假设我们希望golden_ticket字段中的某些部分永远不被修改,而其它部分可以被修改,那么我们可以用视图类型来限制访问。
视图类型与私有性
视图类型与传统的私有机制有交互。传统的私有机制会拒绝直接访问私有字段,而视图类型则是传递型的,可以直接操作也可以间接操作字段。
更复杂的路径
我们不仅可以创建单个字段的视图,还可以创建任意路径的视图。比如,我们可以创建一个引用upper_left字段的视图,也可以同时引用多个字段,只要它们之间不冲突。
视图类型与命名组
视图类型还可以与命名字段组结合使用,避免重复编写字段名。命名字段组可以让代码更简洁,同时也避免了因为重构导致的字段名变化问题。
视图类型与Trait中的字段
不久前,我曾草拟了一份关于在Trait中使用字段的RFC,这个想法显然与视图类型有些重叠。我认为通过在Trait中使用字段,可以为用户提供命名字段组的途径。
视图类型与非重叠闭包捕获
Rust 2021引入了非重叠闭包捕获,允许闭包独立捕获每条引用路径。视图类型可以为闭包捕获提供一种更精确的方法。
学习Rust的影响
如果引入视图类型,我担心它可能会增加学习Rust的难度。然而,视图类型也可能会带来相反的效果——它可以让初学者更容易理解哪些路径被借用,减少困惑。
结论
到此为止,我介绍了视图类型的概念。我希望你喜欢这个过程,并且非常好奇大家的看法。我已经在Internals论坛上开启了一个讨论帖,希望听到反馈。
或许 Rust 需要 defer
在观看了上一个视频后,作者提出了一个续篇,讨论 Rust 是否需要 defer 关键字。作者通过一个例子展示了 Rust 在内存管理方面存在的挑战,尤其是在与 C 语言进行 FFI(Foreign Function Interface)互操作时。
主要内容整理
Rust 的内存管理与 C 的内存管理模式不同,特别是在释放动态分配的内存时,Rust 和 C 有各自的机制。例如,在 C 中,我们常常使用 malloc 分配内存,用 free 释放它。而在 Rust 中,内存分配和释放是由 Rust 的内存管理器自动处理的。但在某些情况下,尤其是与 C 互操作时,我们需要手动管理内存。
作者提到,Rust 的 Vec<T> 结构体在分配内存时可能使用 libc 的 malloc,但释放时却不能直接使用 free,因为 Rust 的内存分配器和 C 的内存分配器之间并不总是兼容。这种情况下,如果我们错误地使用了 C 的 free 来释放由 Rust 分配的内存,就会导致未定义行为(UB)。
Rust 和 C 的 FFI 互操作问题
- 内存分配与释放
Rust 的内存管理机制与 C 不同,C 中的动态内存需要手动释放,但 Rust 的内存分配器并不和 C 的malloc和free完全对应。如果 Rust 分配了内存,而你使用free来释放它,会引发错误。作者在视频中详细讨论了这种场景,并展示了一个例子:
1 | let ptr = libc::malloc(size_of::<T>()) as *mut T; |
在这个例子中,虽然代码看起来没问题,但 Rust 的内存管理器并不知道这块内存是如何分配的,因此如果你在 Rust 中调用类似的函数,可能会导致未定义行为。作者建议使用 Rust 的 Box<T> 来进行内存分配和释放。
defer关键字的需求
作者提到,在某些复杂的内存管理场景中,可能会希望有一个类似 Go 语言defer的机制,来确保函数结束时自动释放资源。defer允许程序员在函数末尾执行一些清理工作,而不必显式地在每个可能的退出点手动调用释放函数。Rust 当前没有类似的关键字,尽管可以通过 RAII(资源获取即初始化)模式来实现类似的效果。手动释放内存的挑战
作者在文中提到了一个典型的场景:在 C 的 FFI 接口中,Rust 分配了内存,并返回指针给 C 使用,而 C 需要释放这个内存。如果 Rust 分配的内存没有正确地在 C 端释放,或者释放方式不对,就可能导致内存泄漏。
他展示了如何在 Rust 中手动释放内存,并建议遵循 Rust 的惯用法,比如使用 Vec::from_raw_parts 进行内存管理。这样可以避免直接使用 C 的 free 函数错误地释放 Rust 分配的内存。
MIR 和内存泄漏检测
在调试过程中,作者发现虽然代码通过了测试,但MIR(中间表示)工具检测到内存泄漏。Rust 编译器在编译时可以捕捉到一些错误,但有些问题只能通过运行时的工具(比如Valgrind或MIRI)来检测。这说明即便代码在编译时没有报错,也有可能存在内存管理方面的问题。Rust 内存管理的复杂性
作者最后总结,Rust 的内存管理虽然强大,但在与 C 互操作时,仍然存在许多复杂性,特别是在处理动态内存分配和释放时。Rust 中的Vec<T>和Box<T>等数据结构本身会自动管理内存,但当它们与 C 代码交互时,开发者需要特别小心,确保内存能够正确地被释放。
结论
Rust 目前没有 defer 关键字,但可以通过 RAII 模式和其他机制来确保资源的正确释放。作者认为引入类似 defer 的机制可以简化内存管理,并减少错误的可能性。他还指出,Rust 和 C 的 FFI 互操作是一个复杂且容易出错的领域,尤其是涉及到手动管理内存时,需要对 Rust 和 C 的内存模型有深入了解。
理论上,它的指针不应该是空的。但是考虑到这是一个 C 代码的示例,在这种情况下,my_labor_free 函数可能会接收到一个 data 字段为空的指针,从而可能在 worked_form_from_port 中触发断言。
因此,在 my_labor_free 函数中,应该检查指针是否为空(后面会讨论关于这个“衣服”的问题)。对于纯 Rust 开发者来说,这种做法可能有些出乎意料,因为 Vec 本身提供了指针非空的保证。但考虑到 C 代码可以传入任何内容,我们必须防御性地进行检查。
这就是本文的主要内容。其实看完文章后,我感觉它里面有很多问题。所以我在想,可能大家的感受和我不一样吧?正好在论坛上有讨论这篇文章的内容,我摘录了一些观点,尤其是一些比较有见地的看法。
首先,作者主要关注的是内存分配问题,对吧?我也是这么感觉的。他明显是在讨论内存分配和释放的问题,而并不是 defer 语句的实现。在所有允许手动管理内存的语言中,混用不同的分配器会立即触发未定义行为(UB),这是很正常的。即使底层可能调用的是 malloc 或 free,这并不意味着它总是这样。分配器和释放器应该是成对出现的,特别是在 Rust 中,可以使用 global allocator 替换全局分配器,未来还会支持每个容器自定义分配器。
在 C++ 中也有类似的情况,使用 operator new 分配内存后,不能用 free 来释放它,而必须使用 delete。C++ 也支持替换全局的 operator new。如果你确实需要一个 defer 功能,其实可以很轻松地在代码中实现,比如用 B 包来处理。
接下来,文章中也提到,如果 B 包 F 中包含了可变引用声明,在 defer 作用域内就无法使用这些可变引用,且该方法不能包含 async/await,因此也无法用于异步取消操作或包装在函数的返回值中。
Rust 通常避免使用这种功能,因为在 drop 中执行复杂逻辑往往适得其反。在 drop 中执行的逻辑应尽量简单,这一点在一些文章或书籍中都有提到。如果在 drop 中执行太多复杂逻辑,出现异常的可能性就会增加,这会导致内存泄漏。因为一旦 drop 中出现异常,后续逻辑无法继续执行,进而导致没有正确释放资源。因此,在 drop 中执行复杂逻辑不仅难以调试,还会影响代码的清晰度。
Rust 提供了更好的替代方案来处理这些需求,避免在 drop 中放入复杂的逻辑。文档明确指出,必须通过 Vec::from_raw_parts 来进行释放,而不是使用 free。这并不是很难理解,分配器和释放器应该成对使用。
在其他语言中也是类似的情况,比如 C 或者 C++。如果有一个分配内存的函数,通常会提供一个相应的释放函数,确保成对使用。就如同“解铃还须系铃人”,谁分配的内存,谁负责释放。比如在 C++ 中,使用 new 或 new[] 分配的内存不能用 free 释放,应该用 delete 或 delete[]。在 C 中,通常依赖库的 API 也会成对出现,比如 malloc 和 free,除非有特别说明,否则必须成对使用。
文章中的问题让人觉得作者可能是一个长期使用 C 的开发者,但他对这一块的理解却有些奇怪。另一个人提到,作者的困惑可能源于他假设一切都像 C 语言中那样工作。某个操作在非常特定的情况下有效,并不代表它在所有情况下都是正确的。
在很多情况下,Rust 的全局分配器可以通过 global allocator 来改变,而这在 C 中并没有这样的机制。因此,不能假设 Rust 的分配器和 C 的分配器是兼容的。特别是当内存需要更高的对齐要求时,使用 malloc 分配的内存可能并不适合在 Windows 系统上使用 free 来释放。在 Linux 和 Mac 系统上,malloc 可能会支持对齐,但在 Windows 系统中,必须使用 aligned_free 来释放这些内存。
此外,Vec::from_raw_parts 不仅会释放 Vec 的内存,还会释放 Vec 中的元素,而 free 不能做到这一点。这样,Vec::from_raw_parts 就抽象掉了复杂性,避免了用户需要关心数据的对齐要求或系统分配器在不同操作系统上的行为。Rust 的设计让你不需要关心这些细节。
至于作者提到的 Vec 指针悬挂的问题,我们可以通过检查 Vec 的容量来判断是否已经分配了内存。用零容量和空指针来表示同一状态是一种浪费。空指针可以用来表示 Vec 尚未被创建,而不是用来表示一个空的 Vec。
这也是为什么 Option<Vec<T>> 和 Vec<T> 的大小是一样的。Rust 的这种优化使得大多数开发者不会遇到问题。在 Rust 中,可以在 Vec 为空时传递一个空指针,这样 C 代码就不需要处理这个问题了。而在 Rust 中释放数据时,只需要记住指针可能为空。
另外,作者提到的 if force_capacity > 看起来是多余的。根据 into_raw_parts 的文档,只要把 Vec 传回给 from_raw_parts,就能恢复原始的 Vec,所以不需要做额外的检查。
总体来说,文章中的问题反映了作者对 Rust 的内存管理机制有一些误解。特别是他习惯了一种不需要了解内存大小或对齐要求就进行释放的编程风格。这种风格在 Rust 中并不适用,Rust 需要在分配和释放时提供明确的容量和类型信息。
Rust 的内存管理机制使得分配器可以更加高效,而不需要存储额外的信息。这些信息在编译时已经被计算并存储,所以在运行时不需要再传递。这种方式不仅更安全,还能提高性能。
关于 defer,文章前半部分提到的很多内容实际上与 defer 没有太大的关系。如果你需要解决资源释放问题,Rust 的 Drop 特性就是为了解决类似的问题。作者并没有提到这一点让人觉得很奇怪,尤其是他在讨论 Vec 的 drop 实现时却忽略了这一点。
这篇文章很有意思,或许它反映了一些 C 开发者的常见误解。他们可能习惯了 C 的自由风格,但在尝试用这种方式去处理 Rust 的内存管理时,反而遇到了问题。事实上,Rust 提供的内存管理机制比 C 更加安全和高效。
整体上来说,这篇文章及其回复提供了很多有价值的信息,特别是关于 Rust 内存管理的一些细节。
Generic Traits, Impls, and Slices in Rustlang - YouTube
Transcript:
(00:00) here’s some code in Rust that uses generics in traits and also generics and the implementations of those traits in this video we’re going to cover how this works so that anybody no matter how much experience you have with rust will be able to understand what’s going on I have two binaries here in Source bin one is called small and one is called trait we’re going to spend most of our time in the trait.
(00:21) rs file which you can run using cargo by running cargo run dash dash bin trait the same goes for the small binary each of these files has a main function which is the entry point to our rust application the point of both of these programs overall is to detect whether a list has a sub list inside of it so if we look here we have a VEC of say 10 integers and we have a prefix of three four five the goal is to determine whether 345 exists in this list now before we get into the actual generics I do want to point out that if I was going to write this functionality I wouldn’t necessarily use traits and generics to do it if we look at small.rs
(00:56) we’ve got our main function and if we run the small binary we see the result is true the small program has a vect of 10 numbers just like we saw before these values we iterate over with windows with a window size of four this means that we will get an iterator of one two three four two three four five three four five six as each item so because we have an iterator over Windows of four we can use the find function on iterators to take that window and compare it to a slice in this case we’re looking for the slice three four five six if the sub slice 3456 exists inside of this values vac find will return an enum called option
(01:34) which will either be sum or none if it’s sum then result will be a Boolean and will return true I can bring up the additional types here with the type inlays which will show you actually how the types flow through this application so values is a vect of i32s which are integers 32-bit integers we take values and we turn it into this struct that says Windows of i32 Windows implements the iterator trait which is why we can call this an iterator and I won’t dive too deep into what that means except to say that what this does is creates Windows of four items overlapping as we go through this effect so like I said
(02:09) before the first item we see is a slice of one two three four the second item we see is a slice of two three four five and so on until we hit the end so Windows gives us an iterator over these slices if we run find on those windows we get a reference to those slices which is why we see a double reference here we get a reference which is the first one to a slice and we can compare that slice to another slice in this case it’s a hard-coded slice that we came up with but it could be a slice that came in from a function call an argument something like that now for this code in particular it is useful to understand
(02:43) what a slice is a slice is a view into some other sequence so for example if we look at the docs here we have a VEC this VEC has one two three in it this is range Syntax for the entire range basically so this is a slice that looks at the whole VEC so this slice will have one two three in it as well but they will be looking into the VEC which owns that data so in this case when we get slices this window is going to be a view into the VEC for each of these items the Vex still owns that data we’re just borrowing it to look at it so small.
(03:18) rs shows maybe how I would write this code in a regular application but let’s say for example that we want to write a trait that implements this has prefix function here we have two examples of how we would use such a function we have a vect of integers on top and a vekka floats on the bottom we take the integers as a slice so we are looking at that VEC and then we call the function has prefix on that slice and we look for some given prefix so in this case we’re looking for three four five which does exist which we can see returns true and in this case we’re looking for 420 and 5.0 which doesn’t exist in this vecca
(03:55) floats so we’ll get false the important point to note here other than the fact that we’re dealing with slices is that this has prefix function works for both a vect of integers and a vecca floats that’s where generics are coming in for us there’s nothing to say that we couldn’t have implemented this trait for both Vex of integers and four Vex of floats but we didn’t the way we implemented this trait is four slices of some type T that Implement partial equality so let’s start digging into what that means we have this prefix trait it’s a completely made up trait it doesn’t exist anywhere else we just
(04:30) chose the name prefix and we decided to use it if you’re unfamiliar with the term trait you can think of them as interfaces in other languages so this trait defines a function that has to be implemented called has prefix has prefix takes a reference to self this is why we can call has prefix on the slice itself because once we had this slice this reference to self is the slice and the has prefix argument that we see here is going to be this prefix argument right here which is a slice of T so it’s a view into some list of items T we have to define the generic that we’re using inside of this trait so in this case we’re using T
(05:07) and what this is saying is basically we have some trait for which we have some generic type T that we’re using to define the type of this prefix argument this has prefix function has to return a Boolean which is why we get float result as false and integer result as true so once we say that there is a trait we can Implement for some type we have to actually implement it for a type in general if we don’t implement it we’ll never be able to use this has prefix function the way we’ve decided to implement it is by using more generics so we have a generic in this prefix here that basically passes through some type
(05:43) to be the argument of this function and when we go to implement this trait so we’re implementing prefix T this is the same thing we wrote up here for some type slice of T’s and when we write our implementation we’re also using the generic and we’re using this generic to say that the type that we’re using inside of prefix so this T the type inside of this slice argument and the type inside of this slice that we’re implementing it for are the same type it doesn’t particularly matter to us as the implementers of this trait what type that is actually so that means it can be integers like we saw up here or Floats
(06:18) or in fact anything that implements the partial equality trait and that’s what this syntax means so we have an implementation of prefix for a slice where the items inside of the slice are of type T where the items themselves Implement partial equality so what that means is that we can use a double equal sign to do an equality check on the items of type T so if you have a t on the left and a t on the right and you’d use equals equals in between them that works that’s all that means then we have to actually implement the function as prefix so we’ve tied these types together using this generic we have some
(06:54) generic T we pass through that generic to the prefix trait basically so this prefix argument is also the same T that we’re implementing the trait for here so when we run function has prefix we can use that t again so and self here refers specifically to the type that we’re implementing this trait for so n self is going to be a reference to a slice of T’s because we wrote it here because we’re implementing for that slice of T’s the prefix type is defined separately we’ve also defined it as being a slice of T’s but theoretically we could have defined it as something else so this reference to self is a slice of T’s this
(07:32) prefix is also a slice of T’s and the t’s Implement partial equality this function of course at some point has to return a Boolean and then we can get into the body of our function so at this point the only thing we know about the type inside of the slice is that it implements partial equality that’s kind of why we’re using generics here we’re implementing this trait for any type that implements partial equality so a reference to self is going to be this slice which we can iterate over so if we have a slice that looks at a number of items in say a vac or something like that you could say I want to iterate
(08:05) over the items that I’m looking at if you have a slice that is looking at the items three four five six you can iterate over three four five six without iterating over this whole vac so we iterate over the items in self we use a function called dot positions which actually comes from the inner tools crate looking at the position’s function definition it returns an iterator adapter which basically just means we can keep iterating that yields the indexes of all the elements satisfying a predicate counted from the start of the iterator so if we use positions for example and we want to get all of the even numbers so in this case it’s V mod
(08:42) 2 equals zero is how we tell whether it’s even or odd then we can look at this vect that we’re iterating over in this example and we can say okay if this is index 0 then we should get index one not two not three but we should get four we should get five and then not six or seven so on the thing that we’re iterating over we can call position we can write a function to basically filter out the values that we don’t want from the values that we do want and then that will give us only the values that we want moving forward through the iterator and specifically and importantly it will give us the index of the items that
(09:15) we’ve passed so in this case we’ve got positions if I bring up the type inlays we can see that V is a reference to a t so it’s one of the items from the self slice and then we’ve got prefix so if we’re looking for a sub slice you can imagine that if we’re iterating through self for each item the first item in the prefix has to match that item for it to even be possible for there to be a full slice match there’s this star operator here which is dereferencing V if I remove that and we try to run the trait binary we get can’t compare a reference to T with a t no implementation for a
(09:46) reference the T equals T so what we’re getting from prefix zero when we index into the zeroth element of the prefix we’re getting a t whatever that type is and on the left hand side when we run positions because we’re iterating over this with itter we’re getting references into that slice so V is a reference to some T that lives somewhere else so we have two different ways to solve this we can either reference the value that we’re getting from prefix or we can dereference the shared reference that we’re getting from positions both options work it’s kind of just a matter of taste and because these are both T’s
(10:20) and we’ve specified that t has to implement partial equality we have the ability to run an equality check here if the item that we’re looking at matches the first element in the prefix that we’ve passed in then pass that item further along this iterator and if it doesn’t match if they’re not the same then that can’t possibly be the start of the prefix so don’t pass it through so at the end of positions before we get to find find is going to get the index of all of the items that match the first element of the prefix we passed in so we’re iterating over the slice that we have called the function on we’re
(10:53) checking to see which of the items in our self slice match the first element of the prefix and then we’re forwarding the indexes of those positions so we get an index here rust has a concept called a range so we can build a range from the index of the character or number or whatever we’re given whatever T where we matched on we get the index of that then we do dot dot and then we can have another number in this case the size of this range is going to be the size of the prefix so we start at index and we end at index plus the size of the prefix so if the index is 5 and the size of the prefix is 4 5 6 7 8 and then this number
(11:35) will be 9 so we’ll go from 5 to 9 non-inclusive so we don’t include nine once we have this range which you can see is a real type so it’s capital r range we can use that range to get a slice of the slice that we called this on so because the items T have partial quality implemented for them if we get a slice out of the original slice that we call the function on we can compare that to the prefix and this will tell us whether this slice is equal to that slice and thus we know whether the prefix exists for this character at this position in our original slice now we have another one of those D reference
(12:13) operators here and we have another Ampersand here that we didn’t cover if I show you the type of index it’s a reference to a u size so positions gives us a u size and find takes a reference to the items in the iterator so positions gives us that U size that’s an index it’s a number and find will reference the items in the iterator that is given in this case it happens to be a u size so index is a reference to that index that we’ve passed through from positions but if we run this we can see we have some type errors so we have a suggestion here but what I’ve chosen to do is destructure that reference so
(12:50) index then is just going to be a u size now because we’ve effectively destructured that shared reference that find is giving us off of the argument of the function that means that our range is a range of use sizes and that’s what we need to pass in to get the slice here now if we look at the type so I’ve changed the code a little bit I’ve taken self range and I’ve put it in a variable so that we can see the type of it very clearly if we take a range of self what we get back is this slice right no shared reference but we know from the type of our function that prefix is a shared reference to that slice so if we
(13:24) take self range which is just the brackets with the T in it and we try to compare it to a shared reference of the brackets with the T in it we don’t get to do that and if I remove the star operator here the D reference operator and we run the trade again we can see that we’re not allowed to do that we can’t compare something with a type of a slice of t with a shared reference to a slice of T so again we have two options we can either put a shared reference on the slice that we’re getting out of self or we can dereference the prefix so we can either take this Ampersand and we can add it to the start of this type or
(13:57) we can take this Ampersand away from the type on the right so we’ve basically compared all the potential places that we could have this sub slice to the actual full sub slice find then we’ll return an option type so if we look at the documentation we can see that find operates on self which is the iterator with some predicate which is a fancy word for a test and it returns this option with a type of self item inside of it self item is just the items that we’re iterating over so this is going to be an option in our case if I change the code here it makes it a little bit more obvious but it’s going to be an option
(14:30) of view sizes because we iterated over self we got the indexes using positions we did a find on the indexes this returned a Boolean for whether that index was the correct index or not but in our case we don’t care about that index in the end we only care about whether the sub slice exists or not so in this case I’ve chosen to use the function is sum where is sum is a function on options so we can say x is sum if it’s sum with a value inside of it then this is going to be true if it’s it’s none then it’s going to be false and this option type can either be sum with a value or it can be none there are
(15:07) no other options so using generics we’ve taken the type that we want to implement for we’ve tied it to needing to implement partial equality so that we can do this a quality check but otherwise we’ve left it open to anything we’ve made sure that this generic type is the type we’re using for all of our arguments as well as the type that we’re implementing for and what this means is that as long as we have a slice into a list of the same type that we’re checking the sublist against we can use this for any of those types that Implement partial equality so you can imagine one equal equal 2 being a valid
(15:42) operation even though it’ll be false 4.0 equal equal 4.0 also works for floats so we automatically because we’ve implemented this trait generically we get implementations for anything that implements partial equality and that’s the power of generics we could have written an individual implementation for u32s and u8s and use sizes and f-32s and f64s but we don’t need to because we aren’t using any of that functionality in our implementation the only functionality we need from this type T inside of this container slice is the fact that it implements partial equality and if that’s all we rely on and we let
(16:21) rust and the compiler know that’s all we’re going to rely on WE automatically get this implementation that we’ve given across all of these different types that already Implement partial equality for us now there’s one more thing that I want to say we used as slice here so we did integers as slice I did this for clarity because it can be a little confusing but we could also do a shared reference to that integers VEC with all of the items in the VEC using this kind of infinite range syntax if I split this out it’s a little bit easier to see I’ve defined this variable eye slice that we can call has prefix on and we’ve taken a
(16:58) slice of integers in this case case we’ve taken the whole slice and if we look at the types we can see that I slice is a reference to a slice of i32s so as slice does the same thing as this sort of grab everything syntax so we could theoretically if we wanted to let’s see we’re looking for three four five here so that’ll be index 0 1 2.
(17:18)so let’s start at index three right if we start at index three and we run this with the index at 3 to the end of the slice so we get four five six seven eight nine ten then integer result is false because it doesn’t include three four five anymore because we’ve taken a view of this part of the VEC and we’ve called has prefix on that part of the back so if you have any questions feel free to leave them in the comment this is not really again a trait that you would Implement normally I used it as an example to sort of show how you could use generics to implement something like this generically if I was going to write
(17:51) this code as part of my application I might use something like Windows because that doesn’t require you know implementing a whole trait and doing a whole bunch of things but there are other situations in which generics are useful and I hope you enjoyed the video so have a great rest of your day
下面是整理后合理通顺的内容:
Rustlang中的泛型、特征和切片 - YouTube
https://www.youtube.com/watch?v=ykQbsTHqKFo
视频内容翻译:
(00:00) 这是一些使用了泛型和特征的Rust代码,同时也在这些特征的实现中使用了泛型。在本视频中,我们将介绍它们是如何工作的,无论你对Rust的经验如何,都能理解其中的内容。我们在src/bin目录下有两个二进制文件,一个叫做small,另一个叫做trait。我们将主要在trait.rs文件中进行探索,你可以通过运行cargo run --bin trait来执行它。small二进制文件也是同样的运行方式,每个文件都有一个主函数,这是Rust应用程序的入口。这两个程序的主要目的是检测一个列表中是否存在子列表。
(00:21) 例如,这里有一个含有10个整数的Vec,以及一个前缀[3, 4, 5],目标是确定这个前缀是否存在于列表中。在进入泛型之前,我想指出,如果我写这个功能,我不一定会使用特征和泛型来实现它。如果我们看看small.rs文件,我们的主函数运行后会返回true。这个小程序有一个包含10个数字的Vec,这些值通过窗口遍历,窗口大小为四。这意味着我们可以得到一个迭代器,其中每个窗口的元素依次是[1, 2, 3, 4]、[2, 3, 4, 5]、[3, 4, 5, 6]等。
(00:56) 由于我们有一个窗口迭代器,我们可以使用find函数来查找窗口并将其与一个切片进行比较。在这个例子中,我们查找的切片是[3, 4, 5, 6],如果这个子切片存在于values这个Vec中,find函数将返回一个Option枚举类型,它可能是Some或None。如果是Some,结果将是一个布尔值,返回true。我们可以通过类型嵌入显示类型信息,这样可以看到类型是如何在应用程序中流动的。例如,values是一个Vec<i32>(32位整数),我们将values转换为一个Windows<i32>结构体,Windows实现了Iterator特征,因此可以调用迭代器相关的函数。
(01:34) Windows为我们提供了这些切片的迭代器,如果我们在这些Windows上调用find,我们得到对这些切片的引用,因此会看到双重引用。我们可以将这些切片与另一个切片进行比较,譬如这里我们使用了硬编码的切片,但它也可以是从函数调用的参数中传入的切片。要理解这个代码,必须知道切片是什么。切片是对某个序列的视图。比如,这里有一个Vec,包含[1, 2, 3],这个切片是对整个Vec的视图,所以它包含同样的元素,但它并不拥有这些数据,只是借用它。
(02:43) small.rs文件展示了我在常规应用程序中可能如何编写这段代码。但是,假设我们想写一个特征来实现这个has_prefix函数。这里有两个例子展示了如何使用这个函数:上面是一个整数的Vec,下面是一个浮点数的Vec。我们将整数作为切片来调用has_prefix函数,查找前缀是否存在。在这个例子中,我们查找[3, 4, 5],它确实存在,因此返回true。在另一个例子中,我们查找[420, 5.0],它不存在于浮点数Vec中,所以返回false。
(03:55) 这里的重点是,has_prefix函数适用于整数Vec和浮点数Vec,泛型在这里发挥了作用。我们并没有为整数Vec和浮点数Vec单独实现这个特征,而是为某种实现了部分相等性的类型切片实现了这个特征。我们定义了一个prefix特征,这是一个完全自定义的特征。Rust中,特征类似于其他语言中的接口。这个特征定义了一个必须被实现的函数:has_prefix。has_prefix接收一个对self的引用,这就是为什么我们能在切片上调用has_prefix函数。
(04:30) self是一个切片的引用,prefix参数也是一个切片的引用。我们必须在特征中定义泛型类型T,这意味着我们为某种实现了部分相等性的泛型类型T定义了这个has_prefix函数。该函数返回一个布尔值,告诉我们前缀是否存在。定义了特征后,我们还必须为某种类型实现它。我们使用了更多的泛型来实现这个特征。我们为slice<T>实现了prefix特征,前提是T实现了部分相等性,这意味着我们可以使用==来比较类型为T的元素。
(05:07) 接下来我们要实现这个函数。在泛型中,我们定义了T类型,它实现了PartialEq特征(部分相等性)。self是一个T类型的切片引用,prefix也是一个T类型的切片。这个函数返回布尔值。我们可以迭代self,并使用positions函数来查找匹配前缀的元素。positions函数返回一个迭代器,生成满足某个条件的元素的索引。在我们的例子中,条件是前缀的第一个元素是否与self的某个元素匹配。
(09:15) positions为我们提供了索引,我们可以通过这个索引构建一个范围。Rust有一个范围的概念,我们可以从匹配的索引开始,构建一个从索引开始到索引加前缀长度的范围。然后,我们可以使用这个范围从self中获取一个切片,并将其与前缀进行比较。如果它们相等,则说明前缀存在。整个实现的关键是我们使用了泛型和部分相等性特征,这使得这个实现适用于所有实现了PartialEq的类型。
(15:07) 通过泛型,我们实现了一个适用于所有部分相等类型的特征,而无需为每种类型单独实现。Rust的编译器会通过我们给出的泛型约束,自动为所有实现了PartialEq的类型提供这个特征的实现。这就是泛型的强大之处。我们可以避免为每种类型编写单独的实现。
(16:21) 最后,我想再提一点。我们这里使用了切片。实际上,&Vec也可以作为函数的参数,我们可以使用完整的范围语法来取出整个Vec的切片。你还可以通过指定索引范围来获取Vec的子视图,譬如[3..]。这样,你可以在这个子视图上调用has_prefix函数,并得到相应的结果。
(17:51) 如果你有任何问题,欢迎在评论区提问。再次声明,这个特征的实现只是为了展示如何使用泛型来实现类似的功能。如果是实际应用,我可能会使用窗口迭代器来简化实现,而不是从头开始实现一个特征。希望本视频对你有所帮助,祝你有个愉快的一天!
我对Rust的负面看法
我们来看一篇文章,这篇文章稍微有些老,是去年10月份发布的,标题是**“我对Rust的负面看法”**,这是它的原文链接,有兴趣可以点击查看。作者提到,他在10月13号根据反馈进行了修改,但保留了一些原始内容。虽然是去年的文章,但我们可以看看具体内容,作者在这篇文章中对Rust当前的看法做了小结。他自己也表示好奇,5年后回看这些观点是否会有所改变。其实,我认为不用5年,可能一两年内就会变化。毕竟Rust的发展速度相当快。
首先,作者先讲述了他对Rust的一些优点,并表示自己并非对Rust持完全否定态度。以下是他认为Rust表现不错的一些方面:
宏系统
Rust的宏非常出色,类似于Lisp的宏,而不像Haskell的宏。我们在之前的视频中提到过,Rust的宏系统确实与Lisp的宏十分相似,而与Haskell的有所不同。类型类(Traits)
Rust引入了类型类(Traits),类似于Haskell中的代数和数据类型(例如Enums)。在之前的视频中我们也讨论过这些特性。孤儿规则(Orphan Rule)
Rust将孤儿规则设定在包级别,而不是模块级别,这是一个明智的决定。记录类型的处理
作者喜欢Rust对记录类型的处理方式,认为Rust在这方面做得非常好。标准库
Rust的标准库在某些方面表现不错,例如它将字符串处理为UTF-8的方式,以及对可变性的区分,这对开发者来说非常有帮助。纯函数判断
虽然Rust中的函数并不总是纯函数,但它的语法让开发者可以很容易判断某个函数是否“道德上是纯的”,即没有副作用。这对代码阅读非常友好。
接着,作者谈到了他对Rust的负面看法:
- Unsafe和Panic
使用unsafe让人不安,虽然许多库中使用了unsafe,开发者也常为了绕过语言的限制而使用它,但这与其他编程语言中的FFI(外部函数接口)没有太大区别。比如,在C++中使用非安全的方法也会遇到类似的问题。所以,作者并不觉得这是什么大问题。
但是,作者对Rust的panic机制表示担忧。Rust为了明确处理错误,使用了很多语法技巧,例如问号操作符(?)和较小的错误类型自动转换为较大的错误类型。然而,panic会导致栈展开(stack unwind),如果在panic中再次触发panic,析构函数甚至不会运行。这让作者对Rust如何处理错误产生了疑问。
复杂的语法
Rust中那些自动插入解引用、复制和释放操作的语法初看起来很简单,但实际使用中往往会导致难以理解的编译错误。最糟糕的错误类型是编译器抱怨它自己生成的代码,而不是开发者编写的代码。这种现象类似于Haskell中的Monad语法,随着“魔法”的引入,代码变得越来越难以理解,特别是对新手来说更加不友好。对高效内存表示的执着
作者曾见过使用Rust多年的开发者在会议上花了20分钟试图搞明白为什么他们的代码不符合Rust的严格内存限制。虽然Rust对堆栈与堆内存模型的处理非常好,但作者认为这与实际开发的需求有所脱节。人们只想轻松地编写树形结构的类型,而不想与内存管理问题抗争。
作者预测,垃圾回收机制最终会在Rust中流行起来,但他对此并不赞同。他认为,如果Rust引入垃圾回收机制,那么就等于放弃了所有权和生命周期的概念,这与Rust的核心理念相违背。
重写的误解
作者经常看到一些文章声称,用Rust重写了某些项目,性能得到了显著提升。他对此表示怀疑,不确定这样的性能提升究竟是Rust本身带来的,还是开发者在重写过程中有了性能优化的意识。复杂性
作者认为Rust已经达到了Haskell和C++的复杂程度,每年需要掌握更多的新词汇才能跟上语言的最新发展。Rust中大量重复已有模式的库(如Web服务、解析器等)让作者感到厌倦。友好的社区
作者对Rust社区的友好度表示怀疑。他认为所有新的语言社区在最初都是友好的,因为当事情无关紧要时,人们没有理由发脾气。一旦语言涉及利益冲突,局势就会升温,情绪也会爆发。Rust社区正在以更快的速度经历这种变化。异步编程
去年,作者对Rust的异步编程表示了担忧。他认为Rust选择不为语言内置一个标准的运行时,导致了异步和同步函数之间的兼容性问题。然而,随着时间的推移,Rust的异步库在今年已经有了很大的进步。Rust的定位
作者认为,Rust自称是系统编程语言,但现在却被用于编写Web应用程序、命令行工具等各种项目,这让他感到失望。不过,他承认这是语言成功的自然结果。
结论
基于以上原因,作者表示他不会在个人项目中使用Rust,但由于他在工作中使用了Rust,因此有必要表达自己的看法。作者认为,如果仅使用Rust替代单线程的C,并且仅使用标准库,可能会有趣一些。但总体上,他认为Rust的开发工具和团队的出色表现,以及大公司对它的资助,使得许多人误以为Rust是一门简单而值得投入的语言,而这种想法是危险的。
这是作者在去年写下的观点,虽然有些负面看法,但也能理解,毕竟时间的推移可能会改变一些看法。而且,作者对Rust的了解程度可能相对有限,Rust的核心理念与其他语言有些不同,它的目标是安全和性能,而不是为了讨好开发者。
漫谈:Rust语言与学习建议
唉,没想到我也会被人催更啊,催更的朋友太多了。今天就先更一期,聊聊 Rust 这门语言。我会跟你们说一些 Rust 的现状,推荐一些书籍,聊聊我的个人经验,最后我会带你们写写 Rust。我挺喜欢这个语言的。
OK,我们先来说说最现实的问题:学 Rust 能不能找到工作?
- Rust 的工作薪资 确实会高,因为它是一个比较新的语言,而且难度相当大。会 Rust 的人很少,所以竞争力高。
- Rust 工作机会少,大多数集中在北上广深这些一线城市。你很难在三线城市找到 Rust 的工作。如果你未来不想在大城市挤着生活,Rust 可能不是一个好的选择。此外,国内目前也很少有公司会提供线上办公的机会,所以你得好好考虑自己是否真的想学 Rust。
Rust 是一门比较新的语言。之前我有朋友带我去参加过字节跳动的几个 Rust 技术大会,他们确实在尝试用 Rust 重构一些关键部分。Rust 难度较高,偏底层,用来处理一些非常稳定且关键的系统。它的优势在于内存安全、代码体积小、速度快。
Rust 的内存安全模型
Rust 有一个非常有趣的内存安全模型。当你想修改一个对象时,Rust 编译器会确保只有一个可变的引用存在,这样可以保证在你修改对象时,不会有其他地方同时修改它。这就是 Rust 的内存安全机制。当然,Rust 还支持类似裸指针的 Arena 模型,但这属于进阶内容。
Rust 的包管理器和工程化
Rust 的包管理器(Cargo)非常方便,工程化做得非常优秀。即便团队成员水平参差不齐,Rust 通过严格的静态检查,能够确保代码的质量。这与 C++ 和 Java 等更自由的语言不同。Rust 不信任程序员,而是通过严格的编译规则来确保代码安全。Rust 引入了所有权和生命周期的概念,这两个特性是它的核心。所有权的使用量很大,而生命周期的使用量相对较低。它们看似复杂,但其实并不难掌握。
Rust 的学习建议
如果你真的想学 Rust,首先你要对这门语言感兴趣。Rust 的心智负担并不算高,虽然稍微复杂一些,但它非常有用。我这里有几本推荐的书籍:
- 《Rust 程序设计》
- 《深入理解 Rust 系统编程》
- 《Rust in Action》
- 《从零构建 Rust 生产级服务》(我非常喜欢这本书)
对于学生来说,如果你未来打算找工作但暂时找不到 Rust 相关的岗位,可以先做一两年 C++ 的工作,再转 Rust 会相对轻松。
如何入门 Rust
Rust 的入门其实不难,我推荐两本书:
- 《Rust 圣经》
- 《Rust 程序设计》
这两本书足够你打好基础。Rust 圣经内容广且深,适合入门。学习 Rust 时,基础语法部分大概需要 5 到 7 天时间,跟着书中的项目做一做,比如写一个文件搜索工具或一个简单的命令行应用。之后你可以学习自动化测试和 Cargo 的使用指南,这样你的入门基本就完成了。
进阶学习
如果你想精进 Rust 的使用,我推荐你关注它的库(Crate),尤其是与异步开发相关的库。Rust 的高级部分涉及到很多库的使用,而不是语言本身的内容。如果你想深入了解 Rust 的工程化应用,推荐的书包括:
- 《从零构建 Rust 生产级服务》:这本书专注于网络开发,介绍了错误处理、容错机制、API 安全认证等内容,非常实用。
- 《Rust 实战》:如果你想了解更多系统编程相关的内容,书中的第二部分有详细介绍。
此外,如果你对系统编程感兴趣,可以关注书中的线程容器、硬件中断、内核相关的章节。
数据结构与算法
Rust 的数据结构书籍意义不大,因为数据结构与算法是所有语言的基础知识。Rust 只是用它的语法来描述这些内容。如果你已经理解了数据结构与算法,可以轻松理解 Rust 的实现。
标准库学习
你应该深入学习 Rust 的标准库。推荐的资源有:
- Rust Reference
- Error Code Index
这些都是非常有用的参考资源。
Rust 的内存安全模型
Rust 的内存安全模型非常强大,可以允许你写出安全的代码。如果你遇到一些解决不了的问题,可以使用 unsafe 代码块,Rust 的标准库中也有大量的 unsafe 代码,不过大多数情况下你不需要手动编写这类代码。
总结
Rust 是一门以工程化为导向的语言,非常实用。你可以按照以下步骤学习 Rust:
- 入门书:选择《Rust 圣经》或《Rust 程序设计》作为入门书,学习基础语法。
- 进阶书:阅读《从零构建 Rust 生产级服务》或《Rust in Action》,根据你的需求选择适合的书籍。
- 参考资源:在学习过程中,参考 Rust Reference 和 Error Code Index。
- 实践:写一些实际项目,比如一个 Redis 实现、异步编程项目等,来提升自己的技能。
最后,Rust 是一门非常强调实际应用的语言,学习的重点在于实践。如果你想通过 Rust 找到工作,一定要了解市场需求,看看公司需要哪些技能。到这里,你的语言基础已经打好了,接下来就是不断实践,积累经验。
额外提示
最后补充一点,有些书籍口碑不佳,比如《Rust 编程之道》,我不太推荐你看这本书。
Rust 构造函数的天才之处
https://www.youtube.com/watch?v=6mVkva3_z9M
Rust 故意做出了一些非常大胆的设计选择。一个令人着迷的设计选择是 Rust 与其他众所周知的语言不同,它不使用传统的构造函数原语。面向对象的语言如 C++、Java 和 C 提供了专门的构造函数,用于处理对象初始化,封装对象构造过程,并通过提供初始化基类的方法来支持继承。例如,在 C++ 中,构造函数是一个与类同名的函数,但这不是一个普通的函数。实际上,这段代码中存在大量隐藏的逻辑、复杂性和潜在陷阱。
C++ 构造函数的第一个问题在于,它们允许你创建不完整的对象。如果我们保持构造函数和传入的参数完全相同,但忘记用传入的值初始化 age 字段,C++ 仍然会创建一个 person 实例,但 age 将包含不确定的值,这可能会导致未定义的行为,显然这是不好的。
C++ 构造函数的第二个问题是,错误处理非常棘手。注意,这个函数没有指定返回类型,这是因为 C++ 在构造函数执行完毕后隐式返回指向新创建的 person 对象的指针。我们无法指定返回类型为某种 optional(可选类型)或 result(结果类型),相反,我们被迫使用不直观的错误处理方法,如抛出异常。这远非理想,因为我们引入了一种隐藏的失败模式,调用者必须记得处理这种情况。
C++ 构造函数的第三个问题是,当涉及继承时,极易意外引入错误。例如,假设我们通过添加一个虚函数 introduce 来更新 person 类。虚函数是 C++ 中可以在派生类中重写的函数。接着我们创建一个从 person 继承的 employee 类,覆盖 introduce 函数,并在 employee 构造函数中调用 introduce。我们刚刚无意中引入了一个错误。你可能会期望在 employee 构造函数中调用 introduce 时,会调用 employee 实现的 introduce,但事实并非如此。由于 employee 实例在构造函数执行完成前并未完全初始化,C++ 实际上会调用 person 基类中的 introduce 实现。如你所见,如果不了解 C++ 构造函数的细微差别,可能会导致意外行为,而没有任何警告。
以上仅仅是传统面向对象构造函数的一些明显陷阱,还有很多其他我没有提到的。与此相反,Rust 对象创建和继承的方式非常不同。Rust 无情地优先考虑显式初始化,而不是隐式构造函数,选择简单而不是复杂或晦涩的特性,并且推崇组合优于继承。那么,这些原则是如何消除传统面向对象语言中对象构造陷阱的呢?让我们来看看。
在 Rust 中,创建对象的最简单方法是使用字段初始化语法。这类似于某些其他语言中的对象字面量,但有一个主要区别。在许多其他语言中,缺失的字段要么被隐式初始化为默认值,要么保持未初始化状态。而在 Rust 中,当你创建对象时,必须显式地初始化所有字段。这一严格要求是 Rust 的核心安全特性,它消除了意外行为和难以调试的内存错误的风险。
为了获得更多的灵活性,你还可以将字段初始化逻辑移动到一个关联函数中,通常命名为 new。这种方法提供了与传统构造函数类似的封装好处,但没有任何隐藏逻辑或额外复杂性。你仍然可以执行自定义初始化逻辑、验证或资源获取,类似于传统的构造函数。你还可以将字段设为私有,这样它们只能通过定义在对象上的函数访问。
但 Rust 的美妙之处在于,这个函数没有任何特殊之处,它只是一个普通函数。命名为 new 仅仅是一种惯例,你可以随意命名它。你还可以根据需要创建多个构造函数。而且由于这是一个普通函数,错误处理变得非常简单,你可以直接返回 Result 类型,这将迫使调用者处理错误情况。
此外,如果我们希望能够创建具有默认值的对象,可以使用标准库中的 Default trait。这个 trait 要求我们实现一个名为 default 的普通函数。我们可以手动实现这个 trait,或者对于简单的情况,可以通过 derive 自动实现。注意,要创建具有默认值的对象,你必须显式调用 default 函数,因为 Rust 始终优先显式初始化,而不是隐式构造函数。
由于 Rust 还推崇组合优于继承,如果我们想创建一个与 person 对象共享行为的 employee 对象,我们可以使用 Rust 的 trait 系统和组合。我们可以创建一个名为 Introduce 的 trait, person 和 employee 都实现该 trait,然后让 employee 结构体包含一个 person 实例。这种方法避免了传统面向对象继承中的许多陷阱,例如脆弱的基类或构造函数链中的意外行为,同时仍然通过 trait 提供多态性。
通过做出这些大胆的设计选择,Rust 避免了传统面向对象语言的许多陷阱,简化了低级软件开发,并让来自更高级语言(如 JavaScript、Python 和 Ruby)的程序员也能够轻松上手。这就是为什么我相信 Rust 将会长久存在,并且学习它非常有价值,尤其是如果你觉得自己在开发者的道路上停滞不前,无法成长的话。
如果你有兴趣成为一名 Rust 开发者,或者只是对相关机会感到好奇,确保访问 letsry.com/bootcamp 获取免费的 Rust 培训。希望你喜欢这个视频,记得保持 Rusty(Rust 化)。
掌握 Rust 语言的异步编程:使用 Tokio 的完整教程和示例,适合初学者
https://www.youtube.com/watch?v=ztqY45gjLZ8
在本综合教程中,我们将涵盖使用强大的 Tokio 运行时在 Rust 中进行异步编程所需的一切知识。无论你是在构建高性能的 Web 服务器、微服务还是基于网络的应用程序,掌握异步编程对于使你的 Rust 程序更具可扩展性和效率至关重要。
欢迎回到 Aram Dave Hub,今天我们将深入探讨 Rust 中最令人兴奋的一个方面——使用 Tokio 进行异步编程。如果你有兴趣编写快速且可扩展的应用程序,你需要了解如何在 Rust 中有效使用异步代码。在本视频中,我们将涵盖以下内容:
- 什么是 Tokio?
- 异步编程与同步编程的区别
- 如何在 Rust 中编写你的第一个异步程序
- 任务生成(task spawning)、任务连接(joining tasks)和使用超时(timeouts)等高级概念
在开始之前,如果你喜欢我的内容并希望支持频道,可以通过视频描述中的链接捐款。你的捐款将帮助我为 Rust 社区制作高质量的内容。另外,欢迎加入我们的 Discord 频道,在那里我们讨论 Rust 编程项目并互相学习,邀请链接在视频描述中。让我们一同成长吧!
现在让我们进入 Tokio 的世界。Tokio 是 Rust 编程语言中的一个异步运行时,它允许你以非常高效的方式同时运行多个任务。这在构建服务、网络服务或任何需要高扩展性的项目时特别有用。
在同步编程中,你的程序一次只运行一个操作,线程会被阻塞,直到任务完成。但在异步编程中,多个任务可以同时运行,这意味着一个任务可以在等待其他操作(如 I/O 操作)完成时继续工作。Tokio 是基于 Rust 的“零成本抽象”构建的,这意味着你可以在不牺牲性能的情况下获得异步代码的所有好处。
现在你已经了解了什么是 Tokio,接下来我们来讨论为什么异步编程如此强大。
为了理解异步编程,首先让我们看看同步编程。在同步编程中,当你执行一个函数时,它会在完全完成之前不会继续下一个任务。如果一个任务需要很长时间(例如读取文件或等待网络请求),整个程序都会停下来等待任务完成。
相比之下,异步编程允许你的代码在等待长时间任务时暂停,但可以继续执行其他工作。一旦长时间任务完成,程序会继续执行暂停的代码。这对于 I/O 密集型应用(如网络服务)来说非常理想,因为等待输入/输出会阻止其他任务的执行。
这里有一个类比:同步编程就像在商店里排队,每次只能为一个顾客服务。而异步编程则像有多个收银台,可以同时为多个顾客服务,并且没有人需要等待某个单一的长时间操作完成。Rust 通过其强大的内存安全性和零成本抽象,高效地处理异步编程。
现在让我们通过编写第一个异步程序来实践这些理论。我们将使用 Tokio 在 Rust 中编写一个简单的异步程序,该程序会生成两个任务,每个任务模拟一些工作,并让它们同时运行。
首先,创建一个新的 Rust 项目。打开终端并输入:
1 | cargo new my_async_project |
然后,打开 Cargo.toml 文件,并将 Tokio 添加为依赖项:
1 | [dependencies] |
接下来,打开 main.rs 文件,编写以下代码:
1 | use tokio::time::{sleep, Duration}; |
我们使用了 #[tokio::main] 宏,使 main 函数成为异步函数。程序打印“Hello, Tokio!”并生成两个异步任务。每个任务都会打印一条开始信息,使用 tokio::sleep 睡眠几秒钟,然后打印一条完成信息。最后,我们使用 tokio::join! 等待两个任务完成。
现在运行程序:
1 | cargo run |
这是一个基本的异步执行示例。你可以看到两个任务几乎是同时启动的,尽管任务 2 先完成了,但它们在运行时并没有阻塞对方。
接下来,让我们提升难度。Tokio 的一个强大功能是能够生成任务并让它们独立运行。以下是一个更高级的示例,我们在循环中生成任务,并使用异步函数处理每个任务:
1 | use tokio::time::{sleep, Duration}; |
我们定义了一个名为 process_task 的异步函数,它接受一个任务 ID,打印一条消息,等待 2 秒钟,然后打印完成消息。在 main 函数中,我们使用循环生成了五个任务,并将这些任务的句柄存储在一个向量中。最后,我们使用 await 等待所有任务完成。
这个例子展示了在 Rust 中如何轻松管理多个异步任务,尤其是在需要并发处理时。
有时你可能希望确保任务在一定时间内完成。Tokio 提供了一种方式来为异步任务添加超时。让我们为任务添加一个超时:
1 | use tokio::time::{sleep, timeout, Duration}; |
在这里,我们使用 tokio::time::timeout 将任务的执行时间限制为 2 秒。如果任务在超时时间内完成,程序会打印“Task completed within the timeout”;如果超时,则打印“Task timed out”。这个功能在处理可能需要中断的长时间任务时非常有用。
在本视频中,我们从编写第一个 Tokio 异步程序,到生成多个任务,处理超时,以及管理并发,涵盖了很多内容。如果你觉得本视频对你有帮助,请考虑通过视频描述中的链接捐款,这将帮助我继续创作类似的内容。
另外,不要忘记加入我们的 Discord 社区。它是一个分享 Rust 项目、提问和与其他开发者联系的好地方,邀请链接在视频描述中。
一如既往,如果你喜欢这个视频,记得点赞、订阅并打开通知,这样你就不会错过未来的 Rust 教程了。欢迎在评论区告诉我你正在使用 Tokio 构建什么项目。感谢观看,祝你编码愉快!
Rust中的结构体
https://www.youtube.com/watch?v=n3bPhdiJm9I
欢迎回到“Let’s Get Rusty”,我的名字是Bogdan,这个频道是关于Rust编程语言的。如果你对此感兴趣,请确保点击订阅按钮和通知铃铛,这样你可以在新视频发布时收到通知。
上次我们讲解了《Rust编程书》第四章的内容,我们讨论了Rust的所有权模型。如果你还没有看那期视频,记得去看看。枚举和结构体是用于创建新类型的构建块。在这期视频中,我们将介绍第五章,讲述如何使用结构体来分组相关数据。我们还将讨论如何在结构体上定义方法和关联函数,以及它们与元组的比较。所以,让我们开始吧!
正如我之前提到的,结构体允许你将相关数据组合在一起。你可以把它们想象成面向对象编程语言中的对象属性。如果你还没有准备好,先创建一个新的Cargo项目。我已经创建了一个名为“struxx”的项目,作为示例,我们将为用户创建一个结构体。
首先,我们使用 struct 关键词,后跟结构体的名字、花括号,然后列出我们想要在结构体中包含的属性。我们想要的第一个属性是用户名,它的类型是 String。接下来是电子邮件地址,它的类型也是 String。再来,我们想要跟踪用户的登录次数,这将是一个无符号的64位整数。最后,我们要跟踪用户是否活跃,这将是一个简单的布尔值。
就像元组一样,结构体允许我们将不同类型的相关数据组合在一起。因此,我们这里有字符串、无符号64位整数和布尔值的组合,但我们可以为我们的结构体命名,这就创建了一个新的类型,并且我们也为结构体内的数据命名。这使得我们可以通过名称而不是索引位置来引用我们的数据。
现在,我们已经定义了 User 结构体,让我们创建一个新实例。在 main 函数中,我们将创建一个名为 user1 的新变量,并将其设置为 User 的一个新实例。通过指定 User、花括号,然后指定属性值来创建实例,这些属性的顺序是无关紧要的。我们从 email 开始,然后是 username,active 设置为 true,signing_count 设置为 1。
我们可以使用点符号从结构体中获取特定的值。例如,让我们创建一个名为 name 的新变量,并将其设置为 user1 变量的 username。我们也可以使用点符号修改结构体中的特定值,首先,我们需要将 user1 变量设置为可变的(mutable)。现在,让我们更改 user1 变量的 username。需要注意的是,我们必须将整个结构体设置为可变的,而不是仅仅将结构体中的某个字段设置为可变。
我们还可以使用函数来构造新的 User 实例。举个例子,让我们创建一个名为 build_user 的新函数,它将接收 email 和 username,并返回一个新的 User 实例。新用户的电子邮件地址应该是传递给这个函数的电子邮件地址,用户名同理。我们还将为其他属性设置默认值。我这里出现了一个错误,因为我们没有明确指定返回的类型是 User。最后,由于函数参数与结构体字段同名,我们可以简化声明,这被称为字段初始化简写语法。
好了,现在让我们回到 main 函数,使用我们的函数来创建另一个用户。
另一个方便的特性是,我们可以使用现有的结构体实例创建一个新的结构体实例。举个例子,让我们创建另一个用户,并提供电子邮件和用户名。然后我们可以说,对于其余的字段,使用 user2 的值。为此,我们只需输入 ..user2。在这种情况下,user3 的电子邮件和用户名将是新的值,而 active 和 signing_count 将来自 user2,即 active 为 true,signing_count 为 1。
我们还可以创建没有命名字段的结构体,这些结构体称为元组结构体。举个例子,让我们创建一个名为 Color 的元组结构体和一个名为 Point 的元组结构体。当你希望整个元组拥有一个名称,并且与其他元组类型不同的时候,元组结构体是有用的。举个例子,Color 和 Point 都有三个 i32 类型的字段,但它们是不同的类型,一个是 Color,一个是 Point。因此,如果一个函数期望的是 Point 类型的元组结构体,你不能传入 Color。
还有一种称为类单元结构体的结构体,它们没有任何字段,但我们将在第十章介绍这些内容。最后,注意我们 User 结构体中的两个字段 username 和 email 都是 String 类型。这是因为我们希望这些字段实际拥有字符串数据。我们也可以让字段引用借用的数据,比如字符串切片(&str),但这需要使用生命周期标注,我们将在第十章讨论这一主题。
为了理解结构体的价值,我们来重写一个计算矩形面积的程序。正如你在 main 函数中看到的,我们有两个变量,分别表示矩形的宽度和高度,然后我们将这些变量传递给一个名为 area 的函数,它接收宽度和高度并计算面积。现在我们运行这个程序,输入 cargo run,你可以看到我们得到了 1500 像素的结果,这是正确的。
虽然这个程序可以正常工作,但它在可读性方面还可以改进。比如我们正在计算一个矩形的面积,因此宽度和高度是相关的,但我们的程序没有表达出这两个变量的关联性。为了让代码更加可读且易于管理,我们首先使用元组来将这两个变量组合在一起。我们不再传递两个变量,而是传递一个名为 dimensions 的变量,它是一个包含宽度和高度的元组。然后我们修改函数的主体,乘以元组的第一个和第二个值。
现在我们的函数可以正常工作了,我们将传递一个名为 rect 的变量,它定义在上方。现在让我们再次运行程序,确保它能够正常运行。很好,程序运行正常,现在我们的宽度和高度被组合在一起了。
然而,我们现在遇到了一个新问题。虽然我们将宽度和高度组合在一起,但元组中的字段没有名字,所以很难知道第一个值是宽度还是高度。为了消除这种歧义,我们再次重构代码,使用结构体。首先,我们创建一个名为 Rectangle 的新结构体,它有两个字段:width 和 height。接下来我们将变量 rect 修改为 Rectangle 结构体的一个实例。
然后我们修改函数,使其接收一个 Rectangle 结构体。在这里你可以看到,我们传递了对 Rectangle 的引用,因为我们想使用它的字段,但我们不想实际取得它的所有权。接着我们修改函数的主体。最后,我们回到 main 函数,传递对 rect 的引用。
现在我们重新运行程序,结果仍然是1500,正如预期的那样。但现在程序的可读性大大提高了,因为我们为矩形定义了一个带有有意义字段的类型。
接下来我们讨论派生特性。当构建这个程序时,能够看到我们的 Rectangle 实例的样子会很有帮助。让我们尝试打印出一个矩形,并看到一个错误提示。如果将鼠标悬停在错误上,我们可以看到提示信息是 Rectangle 结构体没有实现 Display 特性。Display 特性指定了某个类型应该如何打印。像整数这样的原生类型默认实现了 Display 特性,因为打印整数只有一种方式。但对于像我们这样的自定义类型,我们需要自己实现这个特性。
注意,我们还得到了一个有用的提示:在格式化字符串中,你可以使用这种语法。在大括号内添加一个冒号和问号。让我们试试这个,结果出现了另一个错误,提示 Rectangle 结构体没有实现 Debug 特性,Debug 特性允许我们打印出对开发者有用的信息。这里我们还得到了一个有用的提示信息,它建议我们添加 derive(Debug) 或者手动实现 Debug 特性。让我们试试添加这行代码到我们的结构体上,现在错误消失了。运行程序后,你可以看到我们成功打印出了 Rectangle 实例。
我们可以通过在冒号和问号之间添加一个井号(#)使输出更美观。现在你可以看到,字段被打印在了新的一行上,这更加可读。
回到结构体的 Debug,这里 Debug 是一个特性,添加 derive 允许编译器为我们提供一个 Debug 特性的基本实现。我们将在第十章中学到更多关于特性的内容。
现在我们程序看起来不错了,相关数据通过 Rectangle 结构体组合在一起。然而现在我们有一个 area 函数,它与 Rectangle 密切相关,但它是在 Rectangle 之外定义的。我们可以通过使用方法语法将 area 与 Rectangle 结构体组合起来,进一步改进代码。
方法与函数类似,但它们与结构体的实例绑定在一起。让我们到上面为 Rectangle 结构体添加一个 area 方法。首先,我们为 Rectangle 结构体创建一个实现块(impl)。实现块(impl)用于存放与结构体相关联的函数和方法。
现在我们定义 area 方法,方法的第一个参数总是 self,它是调用该方法的实例。在这里,我们接收对 Rectangle 实例的引用。我们也可以接收一个可变引用,或者在罕见情况下,我们可以取得实例的所有权。在这个案例中,我们只需要一个引用。接下来我们指定返回类型。在方法的主体中,我们将实例的宽度和高度相乘。
接下来我们可以滚动到下方,删除原来的 area 函数,然后用 rect 实例的 area 方法调用替代原来的 area 函数调用。最后我们再次运行程序,得到了相同的输出结果。
使用方法的好处是我们的代码现在更加有组织了,而且很明显 area 方法是与 Rectangle 相关联的。
另一件要知道的事情是,在这里我们使用了点符号来调用 rect 实例的 area 方法。在像C++这样的语言中,如果你直接在对象上调用方法和在对象指针上调用方法,语法是不同的。而在Rust中,语法是相同的,因为Rust具有自动引用和解引用的功能。
接下来让我们创建一个接受多个参数的方法。这个方法将被称为 can_hold,它将接受对另一个 Rectangle 的引用,并判断当前实例是否能够容纳传入的矩形。和之前一样,我们从声明方法开始,第一个参数总是 self,第二个参数叫做 other,它将是对另一个 Rectangle 的引用。最后,返回类型是布尔值。在方法的主体中,我们简单地检查当前实例的宽度和高度是否大于传入矩形的宽度和高度。
现在回到 main 函数,我们创建另外两个矩形。第一个矩形会比较小,然后我们创建一个更大的矩形。现在我们调用 can_hold 方法,传入这两个新的矩形。再次运行程序,你可以看到 rect 可以容纳 rect1,因为前者较大,但不能容纳 rect2,因为 rect2 更大。
在我们的实现块中,我们还可以定义关联函数。与方法不同,关联函数不与结构体的实例绑定在一起。举个例子,让我们编写一个关联函数来构造一个正方形。我们可以在这个实现块中编写我们的关联函数,但结构体允许我们有多个实现块。为了举例说明,我们创建一个新的实现块。
接着我们定义 square 函数。因为它是一个关联函数而不是方法,我们不会接收 self 参数。这里我们只接收一个名为 size 的参数,返回类型是 Rectangle。在函数体内,我们创建一个 Rectangle 实例,并将 size 传入作为宽度和高度。
同样,我们可以在原来的实现块中定义 square 函数,但为了举例说明,我们在这个新的实现块中定义了它。
在第十章中,我们将讨论多个实现块的用处,特别是在讨论泛型类型和特性时。还需要注意的是,区分方法和关联函数的方式是通过 self 参数。方法会接收 self,而关联函数不会。
现在让我们回到 main 函数,使用我们的 square 关联函数。我们创建一个名为 rect3 的变量,现在我们不再使用点符号来调用函数,而是使用 Rectangle::square。
好了,到此为止。在本章中,我们学习了如何使用结构体来分组相关数据和功能。我们学习了结构体与元组的关系,并使用结构体重构了一些现有代码,使其更加可读和易于管理。
如果你喜欢这个视频,记得点赞。如果你想看到更多Rust的内容,确保订阅并点击通知铃铛,这样你就能在新视频发布时收到通知。下期见!
为什么Rust采用混合宏卫生规则
我们来看一篇文章,文章的标题是“真正卫生的Rust宏语法”,所谓“真正卫生的”(truly hygienic)其实打了引号。文章写得非常诙谐调皮,读起来有点晦涩难懂。我花了一些时间才搞懂,的确有点难度。这是文章的原文链接,作者叫Supreme A. Johnson,文章的发布时间是9月22号。我们来看具体内容,确实非常有趣,读起来也非常有收获。这篇文章主要讲的是为什么Rust的宏卫生是混合的或部分卫生的,而不是完全卫生的。
文章的核心在于解释Rust为什么采用部分卫生的规则,而不是完全卫生的规则。接下来作者通过一个有趣的方式来讲解这个问题。
故事的主人公叫Raymond,他是一位经验丰富的库开发者,追求代码的稳定性、灵活性和正确性。他擅长利用各种工具来达到这些目标,他的库项目都经过了自动化测试,并配有详尽的文档。他对代码的设计决策有清晰的思路,熟练掌握Rust语言的特性和惯例,知道如何把它们发挥到极致。
Raymond正在开发一个组件,这个组件将提供传统方法无法实现的便利性,并通过声明宏来简化开发过程。他设计了一个简化的宏,虽然它没有传统宏那么强大,但已经足够满足大部分需求。
Raymond的好友王菲被邀请来审查代码。王菲认为这个宏非常棒,但指出宏中的 net 语句不符合语法卫生规则。Raymond知道Rust的宏在局部变量方面是语法卫生的,不会影响作用域中的变量,除非明确传入了变量名。于是Raymond问王菲,为什么 net 语句不符合卫生规则。
王菲通过一个代码示例解释了这个问题。在Rust中,常量是没有语法卫生的保护的,因此如果在调用宏时有一个同名的常量,它就会被引用,而不是创建一个新的变量。这就导致了代码的错误。
Raymond意识到问题的原因在于,常量没有语法卫生的保护,因此使用常量时可能会与宏中的变量产生冲突。为了解决这个问题,Raymond提出了一个修复方案:使用 identity 模式确保变量不会被常量覆盖。虽然这个修复解决了部分问题,但代码变得更复杂了。
Raymond最终决定放弃这个复杂的修复方案,继续使用稍微不那么卫生的声明宏。毕竟,谁会用小写字母去命名常量呢?Rust的卫生规则是混合的,这种混合卫生规则是经过多种权衡后得出的,既保证了代码的简洁性,又避免了过多的复杂性。
这篇文章通过一个有趣的故事,讲解了Rust为什么不采用完全卫生的规则,而是选择了混合卫生规则。读起来非常有意思,如果没有仔细理解,可能不太清楚作者想表达的内容。
过程宏幕后揭秘
来看一篇两年前的文章,标题叫过程宏幕后揭秘,第一部分的作者是 Marina Kalaschina。这篇文章写于2022年3月,它的好处在于对“宏”和“span”(范围)的讲解非常浅显易懂。假如你对宏和 span 不了解,看这篇文章肯定会有所理解,因为作者讲得非常简单,而且配图也非常好。
文章内容概述
你有没有想过 Rust 的过程宏是如何工作的?在这个系列博客中,我们将深入探讨这个话题。我们的团队成员 Vlois Lover Basket Valley 最近在 RustConf 上做了关于这一主题的演讲,本文系列基于该演讲并做了一些轻微的修改和补充。Valandison Of Best Gavi 是这个主题的演讲者,他是俄罗斯人,隶属于 IntelliJ Rust 工具团队的技术领导者。
这位演讲者本来在 2022 年的 RustConf 上做了一个主题演讲,但遗憾的是,没有找到相关的视频,甚至在 YouTube 上都没有找到。2022 年的 RustConf 是远程会议,俄罗斯语的演讲者做了一次,然后英语的又做了一次。不过,俄罗斯这边的系列有九个视频,但没有找到他的演讲,实在很可惜。如果不是这篇文章,我们可能永远看不到那个视频的内容。
宏的基础知识
在这篇文章中,我们将首先了解 Rust 世界中的宏的基础知识,然后深入研究过程宏的具体内容,特别是它们的 API。Rust 中的宏无处不在,但有些编程语言根本不使用宏。让我们来研究一下宏是什么,以及它们带来了哪些可能。
宏的三个主要目标:
- 允许你编写生成代码的代码(即宏)。
- 允许你通过自定义结构扩展语言的语法。
- 帮助减少样板代码数量。
宏生成新代码
让我们创建一个向量,并向其中添加三个数字。最简单的代码如下:
1 | let mut a = Vec::new(); |
我们可以使用标准库的 vec! 宏重写上述代码,写起来更加简洁:
1 | let a = vec![1, 2, 3]; |
这里的 vec![1, 2, 3] 是对 vec! 宏的调用。这个宏是一个声明式宏,它没有动作,只是声明。简化后的声明如下:
1 | macro_rules! vec { |
这里的 $( $x:expr ),* 是宏的模式,即模式匹配中的模式,宏调用的主体是 1, 2, 3,它与模式 $x 匹配。
宏展开后的代码如下:
1 | let mut temp_vec = Vec::new(); |
可以看到,宏展开后的代码与原始代码非常相似。
创建新的语法
Rust 允许通过宏创建新的语法。来看一个使用宏嵌入 HTML 代码的过程宏例子:
1 | html! { |
这个 html! 宏将调用的代码解析为类似 HTML 的语言,并生成一个称为虚拟 DOM 的层次结构。最终展开的代码完全是 Rust 代码,之后可以被 Rust 编译器处理和编译。
减少样板代码
宏的第三个作用是减少样板代码。为了证明这一点,我们来看一个结构体的例子:
1 | struct Point { |
通常,我们需要为这个结构体实现许多特性(traits),比如 Copy、Clone 等等。在这种情况下,derive 宏可以帮助我们简化代码:
1 |
|
过程宏的类型
过程宏是编译时执行的 Rust 函数,分为三种类型:
- 函数式过程宏:使用
#[proc_macro]属性声明,并像普通函数一样调用。 - 自定义派生过程宏:使用
#[proc_macro_derive]声明,通常用于结构体和枚举。 - 自定义属性过程宏:使用
#[proc_macro_attribute]声明,并作为属性调用。
过程宏的 API
过程宏的 API 可以操作 TokenStream 和 TokenTree(即 token 树)。对于函数式过程宏,宏的主体是括号内的所有内容。对于派生宏,宏的主体是整个结构体。对于属性宏,宏的主体包括整个项和属性。
编写过程宏
我们来看一个简单的过程宏示例,它会展开为一个函数调用并传递所有给定的参数:
1 |
|
在这个例子中,我们创建了一个包含两个元素的数组,并将其转换为 TokenStream。现在可以这样调用这个宏:
1 | my_macro!("world"); |
展开后的代码如下:
1 | println!("Hello, world!"); |
什么是 span
span 是 token 在原始代码中的位置信息,它帮助编译器在宏展开过程中正确标记错误。例如,当我们传递错误类型的参数时,编译器会准确指出错误的位置。这是因为每个 token 都包含一个 span,它告诉编译器这个代码片段对应的原始代码位置。
过程宏结构总结
过程宏的结构由以下几部分组成:
- TokenStream:它是
TokenTree的向量。 - TokenTree:是三种 token 类型的枚举,还有一个组(group)。
- Group:由括号组成,每个 token 都有一个
span,用于错误映射。
通过深入解析过程宏,现在你应该对其更加了解了。在这个系列的第二部分,我们将讨论过程宏的编译过程,以及其 API 和 IDE 的处理方法,敬请期待。
刨根究底宏卫生
我一直在思考一个问题:为什么“宏卫生”被叫做“卫生”?为什么叫做“Hygiene”?我对此非常好奇。因此我查找了相关资料,试图找到最早提出“hygiene”这个概念的地方。通过查阅一些文章和问答,终于对这个抽象概念和实际情况有了一些理解。
我们来看一些较早的文章,其中最早提到“hygienic”一词的文章是《hygienic macro expansion》(卫生宏展开)。这篇长文由Daniel P. Friedman等人撰写,讨论了在宏展开过程中捕获自由变量的问题。
这篇文章提到,当一个包含自由变量的表达式 (M) 被替换到另一个表达式 (N) 中时,(N) 中的绑定变量必须与 (M) 中的自由变量不同,避免同名。换句话说,(N) 中的绑定变量不能捕获 (M) 中的自由变量。这种避免绑定变量捕获自由变量的现象被称为“hygiene condition”(卫生条件)。
最早提出该概念的是Kleene,他将此称为“being free for”,即“自由的”。而“hygiene condition”是一个非正式但贴切的名称。尽管我认为“being free for”更容易理解,但文章中解释为什么“hygiene”是一个合适的名字时提到,它更形象地描述了宏展开过程中变量绑定的约束。
具体来说:
- 自由变量在某个范围内是自由的,不能被绑定变量捕获。例如,当一个自由变量被绑定变量捕获时,可能会导致语义错误。
- 在替换过程中,避免变量名称冲突是至关重要的。绑定变量和自由变量必须有所区分。
为了确保这种卫生条件,Rust等语言引入了“时间戳”机制。时间戳(timestamp)是一种用于标识变量生成时间的机制。通过给每一个标识符打上时间戳,可以避免在宏展开过程中产生意外的变量捕获。
举个例子,当一个简单的宏展开为 lambda x. x 这样的表达式时,经过时间戳处理后,可能变成 lambda x0. x0,其中的 x0 是带有时间戳的变量。这样就有效避免了在宏展开时的变量冲突。
时间戳的作用:
- 时间戳作为捕获机制的一部分,用于跟踪标识符的来源。
- 它通过非负整数来记录每个标识符的生成时间,在宏展开中确保不同时间生成的标识符不会发生冲突。
在实际应用中,时间戳的使用可以确保宏展开过程中的卫生条件。例如,生成的标识符只能绑定到同一宏展开步骤中生成的变量,而不会捕获外部的自由变量。此外,通过时间戳机制,Rust等语言可以追踪变量的生成,并在宏展开的每一步中确保变量的唯一性。
为什么叫“卫生”?
在查阅了一些问答后,我终于理解了为什么“hygiene”是一个合适的名称。有人形象地类比道,搞卫生就像是清除胡子里的食物残渣。如果代码中的变量有冲突,就像胡子里的残渣一样,是不洁净的,需要清除。卫生宏的作用就是确保代码中的变量不会发生冲突,从而保持“清洁”。
更形象地说,卫生宏就像一个干净的勺子。如果你用脏勺子去舀干净的汤,汤就会被污染。同样,如果宏展开时没有遵循卫生条件,代码中的变量就会发生冲突,导致程序出错。因此,卫生宏能够在展开过程中避免污染符号表,确保代码的“干净”。
总结来说,“hygiene condition”确保了宏展开过程中的变量绑定不会发生意外的捕获,使得代码的语义保持一致。这种机制被形象地称为“卫生”,因为它避免了变量之间的污染,保持了代码的“清洁”。
具体实现卫生条件的方法:
- Rust等语言通过时间戳机制来实现卫生条件。时间戳记录了每个标识符的生成时间,确保宏展开过程中不同步骤生成的标识符不会发生冲突。
- 时间戳机制的核心是在每步宏展开时,为生成的标识符打上不同的时间戳,从而区分不同的绑定变量和自由变量。
总的来说,卫生宏的概念起源于Lambda演算中的变量捕获问题。通过引入卫生条件,宏展开可以避免变量绑定的意外捕获,确保代码的正确性和一致性。
可恢复错误及结果 | Rust Book
https://www.youtube.com/watch?v=0DjRNEQmUls
所以嗨,欢迎所有人回来,欢迎收看另一期 Rust 系列的激动人心的视频。如果我们有结果,那就是可恢复的错误,但我们不使用结果。所以,让我们开始看视频。
我们现在处于第九章,有两个可恢复的错误与结果。大多数错误不够严重,程序不需要完全停止。当一个函数失败时,可能是由于你可以轻易理解和回应的原因。例如,如果你尝试打开一个文件,而这个操作失败是因为文件不存在,你可能会选择创建文件而不是终止进程。所以,让我们通过文件操作来实现一个追踪器,既包括现有的文件也包括新的文件,这样程序就不会崩溃。你会觉得很有趣,不要删除现有的东西。
这基本上就像是在处理潜在的失败。第十章讲的是结果类型,结果类型基本上意味着你要处理变化,保持变化。好的,是的,错误。所以结果以这种方式出现,其他结果也是如此。比如 Matlab 成功了没有?这是其中一个问题。结果类型中的 T 和 E 是通用类型,第十章的内容就是讲解这个。
你现在需要知道的是,T 代表将返回的值的类型,E 是返回的错误类型。HoA 只是为了以防万一。所以你基本上生成它们,因为结果有这些通用参数。我们可以使用 Result,并且函数定义可以在多种情况下使用,成功的值和返回的错误值可能不同。举个例子,他们允许你这样做。
嗯,让我们在例子中做一件事,并在彼此之间交叉引用。例如 hamcould,他们让你感受到一些代码的逻辑。假设你有一个函数,但不包括头部,函数接受 x,并将它设置为 x。无论是做还是返回值,当他返回时,他返回 i32 并返回 x 除以 y。
这是我的函数 pia,让我们开始游戏。所以 let r = homoping(x),代码函数返回并打印,并且它执行了那个操作。嗯,r 应该是 q 的值。虽然他们最好疯狂,但变量 banana 可以看到你正在得到两个值,这是可以的。如果是零,基本上就像是四除以零,你可以看到发生了什么。代码会恐慌,oga 不能除以零,尝试除以零时代码会恐慌。
但实际上,我们会选择计算器场景,并使用零来触发。你想要程序崩溃吗?也许不,也许你不希望程序崩溃。现在,假设我的用户在印度将零提高到非常高的水平,直到除以不可恢复的错误,程序崩溃了。这是一个可恢复的路径,但并不是所有情况都需要返回结果。
函数 hamresult 返回结果,成功的结果返回 Ok,这就是 hamai 想要的。或者返回错误,程序也可以这样处理。让我们说,朋友们,hamnik 返回 Ok,结果是成功的。
是的,成功的结果在 Ok 上返回。让我们做 ham,返回一个字符串作为失败的例子。所以是的,他们在说什么,让我重新排列这些内容。
当你打开一个文件时,显示的结果是 Ok 或 Err。对于这个情况,我的代码可能会返回错误。返回这些字符串的错误,不要除以零。好的,作为 ota 正确的做法,返回错误变体。
所以现在,如果你阅读这段代码,结果快速返回错误变体。错误变体中的 q 值是零,并返回错误给控制台。错误类型的字符串需要管理在字符串中。很多时候,字符串作为错误类型更合理。
好的,我能做什么?所有的错误都可以使用标准库中的 Prelude 进行处理。现在,你可以包含这些内容,获取结果或错误,继续调用它们。范围可能需要作为系统来创建。好的,就这样,错误变体返回,程序继续执行。
错误的处理方式通常是返回结果。你可以传递其他函数或闭包来处理错误。闭包函数有时候可能会有问题,但这是因为我们还没有深入讨论闭包。
你可以使用 unwrap 去解包结果,或者使用 expect 来处理错误。unwrap 会在遇到错误时引发恐慌,而 expect 可以自定义错误消息。当你不希望程序崩溃时,Result 类型是更好的选择。
所以这是 Rust 中错误处理的基本方法。你可以使用 panic! 处理不可恢复的错误,使用 Result 处理可恢复的错误。随着经验的积累,你会知道何时使用 panic!,何时使用 Result。
好的,就这样,这就是关于第九章的内容。这一章讲解了错误处理、恐慌和结果。如果你喜欢这个视频,请点赞并订阅,期待我的下一个视频。再见,祝你好运!
我最喜欢的 Rust 设计模式[https://www.bilibili.com/video/BV1tS411w7dw/]
https://www.youtube.com/watch?v=qrf52BVaZM8
当谈到Rust的惯用写法时,我最喜欢的设计模式之一被称为扩展特征(Extension Traits)。实际上,我是在处理Rust中的一个限制时偶然发现这个强大的模式的。Rust有一个不寻常的规则,称为孤儿规则(Orphan Rule)。孤儿规则规定,你不能在一个外部类型上实现一个外部定义的特征。换句话说,只有当特征或类型之一是内部的(属于你自己的crate),你才能为某个类型实现一个特征。
举个例子,你可以在Rust标准库中定义的外部Vector类型上实现一个内部特征,比如sum,或者你可以在一个内部定义的Rectangle类型上实现一个外部特征,比如Rust标准库中的Debug特征。然而,你不能在Vector类型上实现Debug特征,因为这两者都是外部的(不属于你自己的crate)。这个规则可以防止特征实现的冲突。你可以想象,标准库已经为Vector类型实现了Debug,如果我们被允许定义一个替代实现,编译器将不知道该使用哪个实现。孤儿规则消除了这一歧义。
现在我们了解了孤儿规则及其必要性,让我们深入探讨一下如何在不违反孤儿规则的前提下实现特征。首先,我们会讨论如何为你定义的内部类型实现外部特征,这是Rust中的一种最佳实践。其次,我们将讨论扩展特征设计模式,它源于你可以为外部类型实现你自己定义的内部特征。
假设你正在编写一个Rust库,并且将struct和enum作为公共API的一部分暴露出来。最佳实践是为你的内部类型实现大多数标准库特征,比如Debug、Clone、Default和PartialEq,这些特征都是外部的。实际上,我们是在预见库的用户很可能会使用这些常见的特征。如果我们决定不实现这些特征,可能会为库的用户带来不必要的麻烦。这是因为孤儿规则的存在。
让我们换个角度,站在库用户的立场上。作为用户,如果某个类型没有实现Debug特征,我们将无法做一些基本的事情,比如使用Debug格式化打印该类型。而因为孤儿规则的存在,我们自己也不能为该类型实现Debug。从用户的角度来看,Debug特征是外部的,因为它定义在Rust标准库中,而用户类型也是外部的,因为它定义在第三方库中。
绕过这个问题的一种方法是使用新类型模式(Newtype Pattern)。通过在我们自己的crate中定义一个元组结构体,将外部类型包裹起来,我们就可以在该类型上实现外部特征。但这并不理想,因为它需要额外的样板代码,而且在没有访问类型内部的情况下,编写一个合理的特征实现可能非常困难。所以,再次强调:如果你在编写一个Rust库,最佳实践是尽早为你的API暴露的类型实现这些标准库特征。
这是一个为我们crate中定义的内部类型实现外部特征的例子。那么,反过来呢?如何为外部类型实现我们crate中定义的内部特征呢?这实际上是Rust中广泛使用的一种设计模式,叫做扩展特征(Extension Traits)。通过在外部类型上实现我们自己的特征,我们可以扩展它们的功能。
让我们通过一个简单的例子来介绍这个概念,然后再讨论一个更复杂的例子。假设我们正在使用Actix Web框架编写一个Rust的Web服务器。Actix Web期望路由处理器接受一个HTTP请求对象,并返回一个HTTP响应对象。使用扩展特征模式,我们可以为任一对象添加功能。例如,我们可以定义一个HttpRequestExtension特征,并为其添加一个名为get_jwt的方法。接着,我们可以在外部的HTTP请求对象上实现这个内部特征,从而从授权头中提取JSON Web Token。
最后,在我们的路由处理器中,我们可以使用这个新方法。仅仅用几行代码,我们就能够扩展一个外部类型的功能。很酷吧?实际上,我最近就用了这个方法,在HTTP请求对象上添加了一个叫做is_arch_linux_user的方法。现在,每当我检测到有Arch Linux用户试图访问我的网站时,我会友好地将他们重定向到Ubuntu的安装页面。
在了解了基础知识之后,让我们来看一个更高级的例子。假设我们想扩展迭代器的功能,添加一个名为unique的方法,用来过滤掉重复的项。在这种情况下,我们不是在扩展某个单个外部结构体或枚举的功能,而是想扩展所有实现了Rust标准库中Iterator特征的具体类型的功能。
这个解决方案有点复杂,所以让我们一步一步来。首先,我们定义一个新的扩展特征,并将Iterator作为一个超特征。这意味着任何实现我们扩展特征的类型也必须实现Rust标准库中的Iterator特征,这使得我们可以在Iterator的基础上进行构建。接着,我们定义一个方法,该方法消耗原始迭代器并返回一个新的内部结构体,叫做UniqueIterator,它存储了原始迭代器和一个记录已见项的哈希集合。我们还会添加一个默认实现。基本上,我们希望消耗原始迭代器并返回一个特殊的迭代器,该迭代器只产生唯一的项。注意,我们需要确保self是可调整大小的,以便可以被我们的方法消耗。我们还需要确保可迭代项实现Eq和Hash,以便它们可以存储在哈希集合中,并实现Clone,以便在添加到哈希集合时可以被克隆。
接下来,我们会在内部的UniqueIterator类型上实现外部的Iterator特征,这样它就可以无缝地与Rust的迭代器方法一起工作。唯一需要实现的方法是next,它会在迭代过程中产生下一个项。我们的实现会使用哈希集合来确保只返回唯一的项。
最后,与其手动为各种具体的迭代器类型实现我们的扩展特征,我们可以利用Rust的另一个强大的特性,称为通用实现(Blanket Implementations)。通过使用泛型和特征约束,我们可以为任何实现了Iterator特征的具体类型实现我们的扩展特征。现在,我们可以在任何具体的迭代器类型上调用unique方法。
扩展特征模式非常强大,因为它允许你在不修改现有类型定义的情况下为其添加自定义方法。这就是为什么它在Rust生态系统中被广泛使用的原因。实际上,我们刚刚讨论的例子来自于流行的itertools crate。其他使用这个模式的流行crate包括Serde、Rayon和Chrono。
如果你想了解更多关于Rust的独特特性和模式,确保前往“Let’s Get Rusty”网站获取免费的Rust备忘单。我希望你喜欢这个视频,并记住:保持Rusty!
展平化AST_哔哩哔哩_bilibili
来看一篇文章,标题是“展平化AST”,它的核心思想是将抽象语法树(AST)进行展平处理,即将AST这种数据结构拉平。文章最早发布于去年5月份,已经有一年多了,原文链接在「can奈尔」这个平台,作者是Anderling Simpson。接下来,我们来看看这篇文章的具体内容。
现代编程语言的实现中,几乎随处可见arenas(内存池),或者称之为竞技场,也有人称其为区域。内存池的基本形式非常简单,但对于编译器及类似编译器的程序而言,却有着惊人的效率。也许正因为这种方法过于简单,所以在很多编译器课程,甚至整个计算机科学课程体系中,我都未曾见过对它的详细讲解。本文将介绍这种方法的原理及其诸多优点。
需要注意的是,不同的人对内存池(或者叫region)的理解可能不同。为了方便表述,本文将我感兴趣的一种内存池称为展平AST,它仅容纳一种类型的数据结构,因此本质上它只是一个普通的数组。你可以直接通过数组索引来代替指针。
在本文中,我们将重点讲解展平抽象语法树(AST),不过这个概念也适用于任何包含指针的数据结构。
为了学习展平化AST,我们将构建一个基本的解释器两次。第一次是使用传统的方式,第二次则是使用展平的方式。可以参考相关代码库中的代码进行对比。值得注意的是,代码改动非常少,但我们将看到其性能提升了2.4倍。除了提升性能之外,展平化还能带来一些易用性上的优势,后面会详细讲解。
传统的AST
让我们从教科书上介绍的传统方式来表示抽象语法树(AST)开始,假设一种最简单的语言,它只允许使用四种基本的二元运算符(加、减、乘、除)对数值进行运算。例如,0 + 14 * 3 或者 100 - 16 / 2 这样的表达式。
如果用ML语言来声明类型,最清晰的写法可能是:
1 | type binary_operator = Add | Sub | Mul | Div |
不过在本文中,我们会使用Rust代码来表示。以下是等价的Rust类型:
1 | enum BinaryOperator { |
如果你不是资深的Rust用户,Box<Expression> 可能看起来有点奇怪,但它表示指向一个Expression的原始指针。在C语言中,Box<Expression> 表示相同的含义;在Java、Python和OCaml中,可以直接写成Expression,因为默认情况下一切都是引用。
有了这个抽象语法树,我们可以编写语言实现的所有教科书部分,比如解释器、打印器等。这些实现都非常常规。整个解释器只是Expression上的一个方法:
1 | impl Expression { |
我们使用的语言具有继续运行的语义,即每个表达式最终都会计算出一个i64。除此之外,我还写了一个简单的随机程序生成器。通过固定的伪随机数生成器种子,我们可以进行一些简单的微基准测试,来测量AST操作的性能,而不需要解析和打印IO的开销。
展平化AST
展平化AST包括两个部分:内存分配的改变和子节点引用方式的改变。
- 内存分配的改变:不再像过去那样随意在堆上分配
Expression对象,而是将它们打包到一个连续的数组当中。 - 子节点引用方式的改变:原来的
Expression对象通过指针引用子节点,展平化后则使用数组中的索引来取代指针。
为了实现展平化,我们首先需要构建一个ExpressionPool来容纳所有的AST节点:
1 | struct ExpressionPool(Vec<Expression>); |
同时,我们需要为普通整数取一个别名,用于索引ExpressionPool:
1 | struct ExpressionRef(u32); |
然后,我们改变Expression本身的定义,将原来的Box<Expression>替换为ExpressionRef:
1 | enum Expression { |
接下来,我们需要添加一些工具函数,用于创建和查找Expression。在我的实现中,这些函数被称为add和get。以下是用于创建新Expression的代码:
1 | impl ExpressionPool { |
我们还需要在所有创建新Expression的地方进行替换。过去,parse函数是Expression上的一个方法,现在则改为ExpressionPool上的方法。以前我们直接返回一个新分配的Expression,现在需要返回ExpressionRef。
为什么要使用展平化AST?
展平化AST有很多好处,最常被提及的都是关于性能的:
- 局部性:展平的
Expression会被打包到内存中连续的区域,有利于空间局部性。 - 更小的引用:展平后可以使用更小的整数引用,而不是64位的指针。对于像AST这种指针密集型数据结构来说,可以大大减少内存占用。
- 廉价的分配:每次创建新节点都不需要调用
malloc,只需要增加一个指针即可。 - 廉价的释放:整个AST可以一次性释放,而不需要遍历所有指针。
除了性能优势外,展平化还有一些易用性的优势:
- 更简单的内存管理:整个AST只有单个生命周期,而不是每个节点有独立的生命周期。
- 便捷的去重:展平化后可以更简单地实现去重技术。
性能测试
为了测试性能,我们随机生成了一个包含约1亿个AST节点的程序,并直接输入到解释器中运行。基准测试显示,展平化后的解释器速度提升了2.4倍。传统的AST解释器耗时3.1秒,而展平化后的版本耗时1.3秒。
为了进一步分析性能提升的原因,我们创建了两个不释放内存的版本。结果显示,即使不关心内存释放,展平化解释器仍然比传统解释器快1.5倍。
这就是展平化AST的基本内容。通过简单的修改,我们可以显著提升AST操作的性能,同时简化内存管理。这种技术在Rust和其他手动内存管理的语言中都有广泛的应用。
你可能在 Rust 中误用了 unwrap……让我们来解决这个问题吧
转载自 https://www.youtube.com/watch?v=k10xtq02nqY
关于 Rust 中不需要的状态(或错误)的一个非常重要的事情:它们仍然会发生,Rust 甚至有逃生舱口(有充分的理由)。 这意味着:您仍然需要花一些心思来处理它们。很高兴,在处理错误时,您可以遵循一些规则来获得最佳语言体验。 准备好了解更多了吗?我们走吧!
如果你最近使用过Rust,你可能已经遇到过像这样的代码。根据你当前的经验水平,你可能会对这些代码有不同的看法。虽然这些代码本身对于演示目的来说是完全可以的,但它往往会教给那些经验不足的Rust开发者一种不健康的unwrap和错误处理方式。不过,幸运的是,我们可以改进这一点。让我们深入了解一下Rust中的错误处理,以及为什么unwrap绝不是你在生产代码中应该使用的方法。
Rust是一种非常特别的编程语言,因为它在安全子集中既没有空指针,也没有异常。这与许多主流语言形成了鲜明的对比。值的潜在缺失通过Option枚举来建模,可能出现的错误通过Result枚举来建模。这个系统非常合理,因为它消除了两大类常见错误:意外的空指针异常和未处理的错误。Rust强制我们处理操作的所有可能状态,这一点是由类型系统强制执行的,而不像其他语言那样依赖于“常识”。
然而,重要的是要注意,Rust的系统并不会导致我们编写大量额外的、不必要的代码。实际上,它透明地展示了我们在其他语言中可能走了一些不该走的捷径。Rust的错误处理系统并非完全封闭,因为还存在另一类错误:panic。语言本身将panic描述为不可恢复的错误或代码中的漏洞。panic会导致所有程序线程以及程序本身的终止。
对于大多数软件来说,这通常是一种不理想的状态。我们没有机会清理任何东西或在程序关闭之前进行必要的工作。程序就这样死掉了。根据程序的类型,这种panic对用户来说可能更或少不方便。一个以panic终止的命令行程序(CLI)虽然不理想,但好在它只是一个CLI,通常只是一次性运行。而一个微服务在panic时终止则要糟糕得多,因为它在终止期间无法处理任何请求,并且需要希望能够重新启动。
举个例子,微服务的这种情形糟糕到,甚至有方法可以捕获panic并防止服务不受控制地关闭,这也说明了panic的严重性。不过,你知道比沉迷于理论更好的方法是什么吗?做一些实践!所以让我们创建一个小示例来体验Rust中不同的错误状态以及如何处理它们。
假设我们正在构建一个用于键值记录的小解析器。所有键和值都是字符串,记录可以表示为一个用等号分隔的字符串。我们可以创建一个小的结构体来表示我们的记录,并给它两个字符串属性,分别表示键和值。如果我们现在想要将一个字符串解析成记录,我们需要仔细考虑如何设计一个好的API来做到这一点,以及我们可能会遇到哪些问题。
字符串可以包含无限多种字符组合,但我们只能处理其中的一个有限子集。实际上,我们只需要字符串中有一个等号,其他部分都无关紧要。但如果我们收到一个无法处理的字符串呢?对我们来说,这不是什么大问题,至少从我们的角度来看,不应该让任何程序因此崩溃。我们可以选择在收到无法处理的输入时返回某种错误,使得这个操作成为可失败的操作。
顺便提一下,重要的是要注意,对别人来说,我们无法解析他们的输入可能是一个足以导致他们的程序崩溃的原因,因为错误总是与上下文和视角有关。不过,Rust内置了一个用于可失败转换的特性,叫做TryFrom。我们可以为我们的记录类型实现TryFrom,并使用字符串切片作为泛型参数。这个特性有一个关联类型,允许我们指定结果的错误类型。
我们现在可以简单地使用字符串作为我们的错误类型,对于我们的小示例来说,这可能已经足够了,但良好的API设计也包括合理的错误设计。字符串是一种非常通用的错误表示方式,无法清楚地告诉用户究竟发生了什么错误。而一个专门的错误类型则可以让我们附加更多有意义的信息,同时让API使用者有机会测试不同的错误,并决定如何处理这些不理想的状态。因此,让我们创建一个新的枚举,并使用thiserror派生错误处理功能。
这个宏基本上为我们的枚举实现了标准库中的Error特性,从而使其与许多基础逻辑兼容。然后,我们可以添加一个新的枚举成员,称之为MalformedRecord,它接受一个字符串属性,并由thiserror的#[error]宏标记,该宏自动为我们的成员派生了Display实现,包括应用的消息模板。
接下来,我们可以专注于实现解析器。首先添加一个最基本的检查:测试输入是否包含等号。如果没有等号,我们就无法继续,因为我们已经知道我们手头没有一个有效的记录。对我们而言,这种情况是可以预见的。我们不需要因为收到一个无效的值而panic,我们可以透明地返回给调用者,告诉他们我们无法处理这个输入,并且我们还可以通过我们的错误类型给出一些上下文,提示记录格式不正确。
接下来,我们可以将输入按等号分割,然后映射结果迭代器项并修剪它们,从而移除周围的空白部分。然后我们可以将结果的字符串切片转换为拥有的字符串,并将它们收集到一个向量中。
之后,我们可以实现另一个小的错误处理部分。我们已经知道我们的向量中至少有两个元素,因为如果输入中没有等号,开始的检查语句就会导致返回一个错误。这意味着我们至少有一个等号。但我们还不能确定是否有更多的等号。如果有更多等号,输入也是格式错误的。由于我们手头有一个向量,我们可以简单地通过检查向量是否包含多于两个元素来判断等号的数量是否超过了预期。如果确实有更多等号,我们也可以返回一个MalformedRecord错误。
现在我们知道向量中恰好有两个元素,一个键和一个值,因此我们可以提取这两个元素。我们可以通过调用first来获取第一个元素,但这会返回一个Option,这意味着我们可能会遇到Rust中另一个潜在的错误状态。这时我们非常确定这个调用不会失败,但向量的API当然是通用的,因为它们需要适用于所有用例。这些用例包括向量可能为空,在这种情况下,调用first会返回None。
然而,类型系统与我们的代码没有联系,它不会根据代码的上下文动态变化。因此,这是一个我们比编译器更了解情况的例子。我们现在可以简单地unwrap这个值并获取记录的键,但这并不是最好的处理方式。我们完全正确地认为这个调用不会失败,因此放弃Option的安全保障是可以的。但我们之所以能够安全地放弃Option,是因为我们写的其他代码确保了这一点。
将输入字符串分割确保我们至少会得到一个向量项;即使字符串中没有等号,分割操作也只会返回完整的切片,这至少保证了有一个项。如果我们或其他人现在改变了我们从输入字符串中提取键和值的方式,那么调用first时可能会在将来引发panic,如果我们尝试unwrap它,那将成为一个bug。因此,我们发现了一个在非常特定情况下的不可恢复错误。
此外,如果unwrap引发panic,它只会给出一个非常通用的错误信息,最终用户通常无法从这样的信息中获得任何有用的信息,甚至其他开发者也会陷入困惑,被迫深入我们的源代码去查找究竟发生了什么,这非常糟糕。
一种更好的方式是使用expect来替代unwrap。expect的行为与unwrap类似,但它提供了两个额外的功能。首先,它允许我们指定一个好的错误消息,这个消息实际上可以帮助别人了解这是我们的bug,而不是他们的错误。其次,它体现了我们的意图,方法的名称已经表明“我期望这里有值”,这也暗示了如果没有值,那是我们犯了错误。对于任何阅读我们代码的人来说,这些信息都非常有用,可以帮助他们理解我们为什么要以这种方式编写代码。最后,它也是一个自解释代码的好例子。
现在我们知道可以通过在first调用之后添加expect来提取我们的键,并添加一个好的错误消息来解释如果发生panic,这是不应该发生的。接下来,如果我们想提取值,可以使用get(1)来获取向量中的第二个元素,这同样返回一个Option。
这个情况与第一个类似,但这次依赖于另外两个检查语句才能继续工作。分割输入确保我们至少有一个元素,而开始的检查语句确保有一个等号,这保证了我们可以提取第二个元素。如果这两个语句中的任何一个发生变化,就不再保证我们可以提取到值。因此,我们又发现了一个潜在的bug。
最终,我们可以像处理第一个情况那样处理这个问题。我们可以使用expect来提取值,标记我们的意图,并为可能由于不小心导致的bug提供一个好的错误消息。之后,我们可以返回一个包含我们记录结构体实例的Ok值,至此,我们的小解析器就完成了。
诚然,这只是一个非常小的例子,在实际情况中,我们会遇到更多的细微差别,但我们仍然可以从中总结出一些Rust中错误处理的通用规则。让Rust程序发生panic从来都不是一个好主意。虽然有方法可以延迟甚至防止panic,比如catch_unwind,但这些方法应保留给那些确实需要它的软件。毕竟,语言本身就明确指出panic是最后的手段,通常只是代码中的bug。
例如,一个Web服务器可能会延迟panic,以便清理资源或关闭仍然打开的上游服务连接,甚至可能完全防止服务器关闭。但这是一个非常特殊的情况,通常有很好的理由。一般来说,我们应该使用Result来标记并处理失败状态,甚至我们的CLI也可以在main函数中返回一个错误,并在某些操作失败时优雅地关闭。这也使我们能够为用户提供比默认的panic消息更好的错误提示。
如果处理得当,正确的错误处理有时可能会导致大量代码,但那是问题的复杂性所在,而不是Rust的问题。当我们在匹配Result时,如果我们只处理通用的错误情况而不查看返回的具体错误,这样的代码通常是捷径;同样的道理也适用于Option。我们通常需要相应地处理值的缺失,要么返回None给我们函数的调用者,要么提供一个默认值,或者我们自己使用其他方式处理。
至于unwrap,在测试代码之外使用它从来都不是一个好主意。当我们需要使用unwrap时,通常已经进入了类型系统无法跟上我们实际实现的情况。通过使用可能会在代码中某个条件稍有变化时导致panic的方法,我们就有可能引入潜在的未来bug。unwrap还会生成一个糟糕的错误信息,如果它触发panic,错误信息非常模糊,而且它的使用也不明确。我们永远无法确切知道某人是故意调用了unwrap,还是忘记处理了所有必要的情况。
然而,调用expect则不同,它传达了更多信息,因为它附带了一种声明,通常在其上方添加一条注释,解释哪些条件可以防止panic的发生,这样那些重构代码或为其添加功能的人至少有机会知道应该注意哪些地方。
这就是关于Rust中错误及其处理的小型主观看法展示。我希望你学到了一些东西,或许你会在下一个项目中使用到这些内容。不过,这个视频到此结束了。你还在这里干什么?下次见!
Rust 进行了多少次内核系统调用?
各位,这是一段即兴的视频回应,同时也是对Hussein Nasser昨天发布的精彩视频的补充和附录。他的视频讨论了运行时系统调用的数量,并给出了以下数字:Python 662次系统调用,Node 800次系统调用,Go 950次系统调用,而不出所料,C语言仅发起了40到47次系统调用。因此,我查看了这些数字,并试图理解它们的含义。让我给你展示一下我所说的内容。
哦,顺便说一下,请务必先去看一下Hussein Nasser的频道,他是一个非常棒的内容创作者,我非常敬仰他。简单来说,他有一个输入文件,然后他读取这个文件并将其输出到控制台,以获取系统调用的数量。那么,什么是系统调用呢?系统调用基本上是对内核的调用,几乎所有有用的东西都发生在内核中。无论是与设备通信、写入文件,还是分配内存,这些都是系统调用。他提到使用了strace工具来记录这些调用。
接下来,我们先编译一下我们的C程序。使用GCC编译C程序,然后我们使用strace命令来追踪系统调用。顺便一提,我现在连接到一台Ubuntu计算机,即使我使用的是Mac OS系统。所以,我成功地复现了他得到的系统调用数量,虽然不完全相同,但相差不大。
现在我们在C语言中得到了这些系统调用的数量。接下来,我们创建一个类似的Rust程序,我已经事先准备好了。你会看到有很多被注释掉的代码,稍后我会解释这些内容。我们将Rust程序编译为发布版本,然后使用sudo strace命令来追踪调用。最终得到了98次系统调用。
我想直接告诉你们,不要仅仅根据系统调用的数量来判断性能,比如说Rust的调用次数是C的两倍,就认为C性能是Rust的两倍。这是一种非常简化的观点。我们需要考虑这些调用的代价是什么,我们在Rust中多执行的这些调用是否带来了什么好处。如果你去问ChatGPT为什么Rust的调用次数更多,它可能会告诉你这是因为类型安全、借用检查器等原因,但这些解释并没有什么意义。
因此,我决定深入研究一下。我尝试将所有用户代码注释掉,结果即使没有任何用户代码,Rust仍然产生了89次系统调用。这意味着这些系统调用并不是由我们自己编写的代码触发的,而是由框架进行的一些设置导致的,可能这是件好事。我想让你们明白的一点是,较少的调用并不一定意味着系统更糟糕,而过多的调用则是坏事,因为这意味着你在内核空间和用户空间之间频繁切换,正如Hussein所清楚解释的那样。但是,仅仅根据系统调用的数量来判断运行时的好坏,是非常肤浅和愚蠢的做法。
接下来,我尝试看看这些系统调用具体是什么,结果发现你可以通过去掉-c选项来查看所有系统调用的详细信息。然后我把这些系统调用与C语言的系统调用进行对比,当我们移除所有代码后,我们可以进行更公平的对比。
起初,两个程序的系统调用非常相似。第一个调用是执行程序的execve,它加载可执行文件,这很正常,两个程序都做了同样的事情。接下来是brk调用,这是内存管理的部分,brk用于调整程序数据段的末尾,操作系统在此时设置内存布局。两个程序都执行了这些操作。接下来是ld.so.preload,这是为了预加载应用程序可能需要的共享库,这也是一样的。
但是,我们注意到Rust和C之间的一个关键差异。在Rust中,我们还加载了线程库libpthread,而C程序并没有显式设置信号处理器或管理线程局部存储。Rust的运行时自动设置了信号处理(比如当你按下Ctrl+C时程序可以正常退出)以及线程基础设施,这就是为什么我们需要加载libpthread。
关于内存管理,Rust的运行时更为健壮,执行了更多的mprotect调用来强制执行内存保护。而C程序通常依赖开发者来处理这些问题,这可能会导致程序崩溃。通常,C程序的系统调用数量较少,因为它只加载了最低限度的库,主要是libc,并且执行了最少的系统级配置。
与此相对,Rust的运行时引入了额外的安全检查、线程设置和库加载,这导致了更多的系统调用,尤其是在内存管理、线程处理和信号处理方面。尽管两个程序实现了相同的目标,但Rust的运行时承担了更多的责任,以确保安全性和鲁棒性,这反映在系统调用的数量和种类上。
还有一个特别有趣的点是,所有Rust程序都订阅了rt_sigaction,这意味着当发生致命异常时(比如panic),Rust总是有机会捕获信号并进行异常展开。所谓的异常展开,就是在清理栈追踪时,防止内存泄漏。因为如果你泄漏了所有内存,就会像Windows 95那样,所有程序都会泄漏内存,操作系统无法回收这些内存,最终你只能重启系统。Rust通过增加这些信号处理来保护你。
结论是,别只看表面,更多的系统调用并不意味着运行时更差,这只是意味着它为你做了更多的工作。如果你对Rust感兴趣,我相信这个视频会对你有帮助,所以去看看吧。祝你愉快!
Rust 中的程序宏
https://www.bilibili.com/video/BV1jYtXeuE3A/
你好,锈们,欢迎回来!让我们生锈起来!这是关于Rust编程语言的第一手资源。如果你还没有订阅频道,请务必订阅,以获取每周的Rust视频,最重要的是,还可以获取免费的Rust速查表。是的,我说的是免费的!前往 ‘Let’s Get Rusty’ 网站,获取速查表。
在上一个视频中,我们讨论了宏,特别是Rust中的声明式宏。如果你还没有看那个视频,今天务必去看看。今天,我们将通过讨论过程式宏,来完成高级Rust系列的最后一部分。
那么,话不多说,让我们开始吧!
过程式宏
过程式宏和函数类似,它们接受代码作为输入,操作这些代码,并输出代码。这与声明式宏不同,声明式宏通过模式匹配并将代码替换为其他代码。Rust 中有三种过程式宏:自定义派生宏、属性宏和函数宏,它们的工作方式都非常相似。由于某些复杂的技术原因,Rust团队未来计划移除一些宏特性。
过程式宏必须在它们自己的crate中定义,且具有自定义的crate类型。定义过程式宏的语法非常类似。首先,我们引入了 proc_macro 库的命名空间,它定义了 TokenStream,然后我们定义我们的宏,通过定义一个函数。该函数的名称就是宏的名称,输入是一个 TokenStream,这是我们要操作的代码,输出也是 TokenStream,即我们生成的代码。
TokenStream
TokenStream 是程序的最小单位,它们可以代表关键字、标识符、运算符、分隔符或字面量。函数还需要一个属性,指定我们创建的过程式宏的类型。
现在,让我们创建一个自定义派生宏。这个宏将被称为 hello_macro,它会实现一个名为 HelloMacro 的trait,并且这个trait将有一个默认实现,输出 “Hello, Macro!”。
代码示例
首先,我们需要将宏带入作用域。接下来我们可以定义一个结构体,如 Pancake,并为其添加一个派生属性,指定我们的宏。这将为 Pancake 结构体实现 HelloMacro trait。之后,我们就可以从 Pancake 结构体中调用 hello_macro 函数。
实现过程
首先,我们需要创建一个新的库crate,命名为 hello_macro。打开终端并输入:
1 | cargo new hello_macro --lib |
接着,进入新创建的目录并在VS Code中打开项目。在 lib.rs 文件中,我们定义了一个trait HelloMacro,并为其提供了默认实现。如果我们想要做更复杂的事情,比如打印trait实现的类型名,Rust没有反射机制,所以我们无法在运行时查找类型名。解决方案是使用我们的宏来生成默认实现。
定义过程宏
接下来,我们需要定义过程宏。过程宏必须在自己的crate中定义,所以我们再次打开终端,输入:
1 | cargo new hello_macro_derive --lib |
根据命名约定,如果你有一个自定义派生宏,crate的名字应该为宏的名字加上 _derive。我们在 hello_macro crate 中创建了 hello_macro_derive crate,两个crate紧密相关,但需要分别发布。你需要将每个crate带入作用域,这就是为什么之前的例子中我们需要两个 use 语句。
然后,打开 hello_macro_derive crate 的 Cargo.toml 文件,并添加如下内容:
1 | [lib] |
接着,打开 lib.rs 并替换自动生成的代码。我们首先引入 proc_macro crate,它是Rust自带的库,用于处理Rust代码。接着引入 syn 和 quote crate,syn 用于将Rust代码解析为语法树,quote 则用于将语法树转换回Rust代码。
宏实现
我们定义了自定义派生宏 hello_macro_derive,它负责将 TokenStream 解析为语法树。然后,我们将语法树传递给 hello_macro 函数。这个函数将提取结构体或枚举的名称,并生成Rust代码,实现 HelloMacro trait。
最后,我们将生成的代码转换回 TokenStream,并返回给编译器。
编译和测试
我们已经定义好了 hello_macro crate 和 hello_macro_derive crate。执行 cargo build 来确保一切构建成功。然后,我们可以在之前的示例项目中添加我们的两个crate作为依赖项,并测试我们的宏。
运行 cargo run 后,预期输出如下:
1 | Hello, Macro! My name is Pancake. |
属性宏和函数宏
属性宏类似于自定义派生宏,但它们可以用于函数和其他程序元素,而不仅仅是结构体或枚举。比如,你可以为Web框架创建一个 route 宏,将HTTP请求映射到特定函数。
函数宏看起来像函数调用,但它们更加灵活,可以接受可变数量的参数,并且操作Rust代码。比如,你可以创建一个 sql 宏,验证SQL语句的语法,并生成允许执行该SQL语句的代码。
总结
这就是关于Rust程序宏的视频。如果你有任何问题,或者想要看到更多关于宏的内容,请在下方留言,也别忘了订阅以获取每周的Rust视频。另外,记得获取免费的Rust速查表,前往 Let’s Get Rusty。我们下个视频见!
Rust的错误处理其实很完美
来看一篇文章,标题是《Rust的错误处理其实很完美》,这是原文的链接,作者叫 John Arrando。这篇文章很有意思,虽然没有特别深刻的内容,但它引发的讨论特别有意思。特别是文章末尾的评论,在 Word 上面的评论也很有意思。
我们先来看文章的内容,然后再看评论之间的相互讨论,真的很有趣。
程序中总会出问题。你应该也注意到了,有时候函数调用会遇到无法给出答案的情况。要么是因为发生了错误,要么是因为正确答案就是没有结果。那如果没有答案的时候,我们应该怎么设计 API 来处理这些情况呢?比如说,假设我们要写一个函数 first,它返回给定列表中的第一个元素。这没问题,但如果列表是空的呢?在 Go 语言中,我们可能会让函数返回两个值:一个是元素类型的值(如果有结果),另一个是布尔值来表示是否有结果。布尔值为 true 表示有结果,false 表示没有结果。例如:
1 | func first(list []int) (int, bool) |
这种写法也不是很差,且在 Go 中是常见的做法。
但是,在 Rust 中,我们可以使用 Option 类型来解决这个问题。比如:
1 | fn first(list: &[i32]) -> Option<i32> |
Option 表示答案可能有,也可能没有。Option 类型的返回值要么是 None,表示没有数据返回,要么是 Some(x),表示结果是 x。调用者可以通过 match 表达式来检查是否有结果:
1 | match first(list) { |
if let 表达式
通常,我们只想在有结果时做点事情,没有结果就跳过。写一个空的 match 分支很麻烦,可以用 if let 来简化代码:
1 | if let Some(x) = first(list) { |
问号操作符 (?)
有时候,如果结果是 None,我们什么也做不了,那就直接返回。虽然可以通过 match 或 if let 实现,但我们还有一个更简单的方法,就是在返回值后面加问号 (?) 操作符,它可以将 None 传递给调用者:
1 | fn first_plus_one(list: &[i32]) -> Option<i32> { |
如果 first(list) 返回的是 Some(x),我们就给它加一返回;如果返回的是 None,问号操作符会让函数直接返回 None。
unwrap 和 expect
如果我们完全确定某个操作会有结果,就可以使用 unwrap() 强制获取这个值。但如果列表是空的,调用 unwrap() 会导致程序崩溃并输出粗暴的错误信息。不过,我们可以用 expect() 来替代 unwrap(),使错误信息更加友好。
1 | let first = list.first().expect("The list is empty"); |
但好的代码不应该随便崩溃,通常我们应该用 match 或者问号操作符来处理 None 的情况。
Result 类型
虽然 Option 很方便,但它无法告诉调用者为什么没有结果。对于可能因多种原因失败的函数,区分不同的错误很有用。这时,Rust 的 Result 类型派上用场。它有两个变体,一个是 Ok,表示有结果;另一个是 Err,表示出错并附带了错误信息。
1 | fn sqrt(x: f64) -> Result<f64, String> { |
我们可以用 match 显示地处理这两种可能性,或者用问号操作符将错误返回给调用者。
仅返回错误
有时候函数的任务只是做某些操作,虽然没有返回实际的值,但依然可能产生错误。这时可以用 Result 的 Ok 变体表示成功,而里面不包含数据:
1 | fn print_sqrt(x: f64) -> Result<(), String> { |
Rust 提供了一种优雅的解决方案,通过返回 Option 或 Result 来表示答案可能没有数据或出错。最终,这些可选性或结果性是答案的一部分,迟早我们要处理它们。Rust 通过类型检查确保错误处理不会被忽视。
文章的讨论
文章写的内容很基础,但引发的讨论非常有意思。以下是高赞评论的整理:
254个赞的评论:
这篇文章确实涵盖了 Rust 错误处理的所有亮点,这些亮点也都很棒。但是,对于大型项目和系统中不同部分返回的许多类型的错误来说,错误处理远不止这些。我认为它还不够完美。大型项目中的错误类型有很多种,Rust 的错误处理方式远远不够。110个赞的评论:
我也注意到了这个问题,尤其是在写底层网络代码时,几乎所有错误都是 I/O 错误。然而,Rust 的枚举类型有太多变体,文档也没有说明每个错误是在哪种情况下触发的。要区分和处理不同的错误情况,缺乏必要的信息。Potato Gun 的评论:
很多人提到 Rust 的错误类型难以维护,我同意这种说法。其实,不仅仅是 Rust,很多语言的错误处理都难以维护。设计一个既简单又有类型检查的错误系统真的可能吗?要么像 Go 那样随意忽略错误,要么像 Python 那样有一些模糊的异常。Rust 的错误处理虽然更清晰,但使用时不够方便。Annoyed Wu Six 的评论:
很多人抱怨 Rust 的错误处理,但相比之下,使用异常带来的问题更大。异常既会用来传播错误,又常常被滥用作为控制流工具。Rust 没有异常,这意味着我们能明确知道代码的输入和输出是什么。Bike1926 的评论:
完善的错误处理在遇到多种类型错误时就没那么完美了。这时,问号操作符的优雅性也不复存在。你能选择的几种方案是:
- 使用
anyhow这样的库; - 将错误映射成字符串;
- 返回
Box<dyn Error>; - 编写一个枚举,包含所有可能的错误类型。
在这些方案中,都需要写样板代码。无论哪种方式,都有权衡。
他还提到,Java 的检查异常也有类似问题,每创建一个错误都会触发堆栈展开,堆栈越深,性能代价越大。
- 更深入的讨论:
语言设计者总是倾向于将错误处理纳入类型系统,因为这看起来很优雅,但实际上错误处理和类型系统的其他需求本质上是冲突的。Rust 的类型系统非常强大,但如果能有一种单一的错误类型,允许附加可转换的内容,情况会好很多。
这篇文章和讨论都非常有启发性,能引发关于错误处理的深入思考。
理解和实现Rust的 Arc和Mutex
已独立成章
为啥我这么喜欢Rust的enums
整理后的内容如下:
我看了一篇有点像考古的文章,这篇文章是2021年6月6日写的,差不多有两三年了吧。作者叫din,这篇文章是在他失业状态下写的。所以他的blog已经访问不了,我是通过Google的缓存看到的这篇文章,当然有些图片就找不到了。但整体内容还是可以理解的。
自从4年前我开始学习Rust,我就深深地被它的枚举迷住了,特别是它的枚举系统(NS)。Rust的枚举不仅仅是简单的枚举,它更接近于像Option或Result这样的高级类型,用于表达变量。作者在文章中说,让我们看看Rust的枚举是什么,以及一些很酷的使用案例。他提到他其实之前在The Rust Programming Language这本书上已经看过这些东西,包括Rust在内存上的表现。
每个人看同样的事物,感受是不一样的。多看些别人对同一事物的表达,对我们的理解非常有帮助。首先,作者讲了关于Rust枚举和C语言中的标记(tag)的比较。我们应该已经熟悉联合体和传统枚举了,而Rust的枚举介于两者之间。你可以通过书籍或Rust的实例学习基础知识。作者接着展示了一个Rust枚举的例子,以及它在C中的等价实现。
需要注意的是,Rust的枚举支持多种类型,并且这些类型共享内存,浪费的空间非常少。这个是作者提到的第一个非常好的点。你可以在在线编辑器(例如Playground)中去探索它的内部表达,或者在nomicon(Rust的高级指南)中进一步了解。作者提到他会有时间把这部分文章翻译一下,那个链接应该是指向nomicon。
Rust的枚举有一个很大的优点,就是多个类型共享内存,所以只浪费很少的空间。Jim Blandy在书中也提到过,当多个类型共享内存时,内存中的字段空间是以最大的类型为准,其他的则通过少量的判定来处理。这是Rust在内存中的表现。
接下来,作者讲到了如何用枚举处理错误,这是一个非常有意思的话题。两个特别有用的枚举是Result和Option,它们在Rust程序中非常常见。Result提供了表示操作成功或失败的方法,并允许你访问结果数据或错误信息。而Option则用来表达某个值是否存在,通常用来替代可空类型。在Rust中,没有可空类型,Option就是用来替代这个的。
Rust的强大之处在于,它会在访问底层数据之前强制你处理枚举变量,这通常是通过match关键字来完成的。当遇到错误路径时,Rust还有专门的语法来处理Result和Option类型。换句话说,当一个函数返回Result或Option类型时,你必须显示地处理它们,不能忽视错误。这就是Rust在处理错误时的一个优势。
这是作者提到的第二个好处:Rust通过变量处理错误的方式非常强大。接下来,他比较了如何在Go的gRPC和Rust的Rocket中处理一个简单的HTTP端点。虽然文章中的图已经找不到了,但不影响我们理解他的意思。通过在变量中嵌入数据,我们可以表达一个操作是否成功,同时能够传播、转换和处理潜在的错误,而不会干扰正常的执行路径。
这是Rust的另一个好处:枚举不仅可以携带数据,还可以在不同场景中传播、转换和处理这些数据。
接下来,作者继续探讨了堆分配和动态分配在Rust中的优势。他举了一个编写游戏音频系统的例子。想象一个有向无环图(DAG),图中有很多节点,各个节点可能代表不同的音频输入输出或效果。每个节点都有一个process函数,这个函数负责处理输入和输出。我们将这些节点的共同行为称为AudioNode,并通过trait来实现它。
然而,图中的每个节点都是动态大小的,所以必须在堆上分配。为了在Rust中进行堆分配,节点必须包装在智能指针(例如Box<dyn AudioNode>)中。这样做会带来显著的开销,包括多次访问虚表(vtable),而且间接性会阻碍编译器的优化。请记住,process函数每秒可能会被调用数千次,所以这些开销是不可忽视的。
作者提到,我们可以通过枚举来改进这种情况。在他的项目中,通过使用枚举,他获得了高达10%的性能提升。在音频编程中,这绝对是一个不可忽视的提升。你可以使用类似enum_dispatch这样的库来减少大量重复代码。
接着,作者讲到了消息传递的例子。想象你在编写一个音乐播放器,用户界面中有播放、暂停、快进、快退等控制,这些输入可能来自不同的地方,例如D-Bus、快捷键或简单的UI交互。我们遇到的场景是典型的MPSC(多生产者,单消费者)。Rust的通道(channel)是一个简单的原子队列,生产者和消费者通过它来通信。
每当用户执行某个操作,Rust的MPSC通道会发送消息来控制播放。为了实现这一点,我们需要确定要发送什么数据。在Java中,普通的枚举无法处理这种情况,因为有些控件(如SeekTimestamp)有相关的数据。我们可能会使用一个带有枚举字段的类,或者使用字符串来解决这个问题。
幸运的是,Rust的枚举使得这一切变得容易。Rust的多线程编程非常美妙,因为枚举不仅可以携带数据,还可以控制消息的类型和相关数据。虽然枚举不能携带大量的数据,但对于控制消息来说已经足够了。
文章最后,作者提到了有限状态机(FSM),并给出了一个状态机的链接。他表示以后会将这部分内容翻译出来。通过这篇文章,我们可以看到Rust的枚举有许多优势,特别是在处理错误、优化性能以及消息传递等场景中。尽管书中可能没有详细描述这些优势,但通过不同的视角,我们对Rust的枚举有了更深的理解。
这就是整理后的内容,保持了原文的完整性,同时使其更通顺、易读。
深度理解Rust move(一)
深度理解Rust move(一)
https://www.bilibili.com/video/BV1Jw411B77K/
在看Rust ping的时候,感觉我对其中的一些概念理解得还不够深入,于是我决定深入了解一下Rust中的move语义,觉得这对理解其他概念可能会更有帮助。我搜索了一些资料,发现很多资料还是值得记录的。比如《Programming Rust》这本书,作者是Jim Blandy,这本书讲解得非常容易理解。所以我决定通过这本书来看一下move语义到底是什么样子的。
在理解move之前,首先要理解Rust中的所有权(ownership)。如果你读过大量的C或C++代码,可能会遇到这样的注释:某个类的实例拥有它指向的另一个对象。这意味着拥有对象的实例可以决定什么时候释放对象。当所有者被销毁时,它会连同所拥有的对象一起销毁。例如,C++代码会像下面这样展示这一点。
字符串S通常在内存中表示如下:
Rust的标准库中的String对象在内存中有三个部分:
- 一个指向缓冲区的指针
- 它的容量(capacity)
- 它的长度(length)
这三个部分是String对象的私有成员,因此用户无法直接访问它们。由于String对象拥有自己的缓冲区,这意味着当String对象被销毁时,它会释放其缓冲区,也就是它在堆上占用的空间。在以前,一些C++库会在多个String对象之间共享一个缓冲区,但这已经被C++的规范禁止了。现在,所有现代的C++库都使用的是每个String对象都有自己的缓冲区的做法。这就意味着,如果其他代码创建了指向某个String缓冲区的指针,那么该代码必须确保在这个String对象被销毁之前,指针已经不再使用,否则这个指针会变得无效,可能会导致内存泄漏或其他问题。
Rust中的所有权(ownership)是语言的核心部分,每个值都有一个单一的所有者,所有者决定了这个值的生命周期。当所有者被释放时,拥有的值也会被释放。这有助于防止内存泄漏和其他内存安全问题。所有权的主要目的是防止内存泄漏,并保证内存安全。在Rust中,变量拥有其值。当控制流离开变量声明所在的范围时,变量就会被释放,其值也会被释放。
例如,下面的例子展示了这个过程:
我们定义了一个变量并在堆上为它分配了空间。然后通过一个小循环对其值进行操作,操作完成后变量超出了作用域,Rust会自动销毁该变量并释放它占用的内存。
Rust的Box类型是所有权的另一个例子。Box<T>是一个指向堆上存储的T类型值的指针。Box::new(v)函数会在堆上分配空间,将值v移动到堆中,并返回指向该堆空间的Box。Box变量的所有者负责释放其指向的空间,当Box变量超出作用域时,它会被自动释放。
例如,下面的代码展示了Box的使用:
程序调用Box::new时,它会在堆上分配空间来存储两个f64类型的值(0.62和0.5),并返回指向该空间的指针。在到达assert_eq!函数调用时,程序会将堆上存储的值进行比较。此时,栈中包含了指针和堆上分配的内存。当这些变量超出作用域时,它们将自动释放其占用的堆空间。
与变量类似,结构体拥有它们的字段,元组、数组和向量拥有它们的元素。例如,下面的代码展示了一个包含Person结构体的向量composers。每个Person结构体都有一个名字和一个出生年份。在内存中,composers的最终值看起来像这样:
栈上有一个composers变量,它指向堆上存储的多个Person结构体。每个结构体都有一个名字和一个出生年份。栈中的composers指向堆上存储这些结构体的缓冲区。
Rust中的所有权模型意味着每个值都有一个所有者,所有者负责在值超出作用域时释放其内存。所有权关系可以是嵌套的,这意味着一个值可以拥有另一个值,而后者又可以拥有其他值。例如,composers拥有一个向量,向量拥有其元素,每个元素是一个结构体,结构体拥有其字段,字段拥有字符串或数字。当composers超出作用域时,它将自动释放并带走所有元素。类似地,其他类型的集合(如HashMap或BTreeMap)也有类似的所有权关系。
Rust的所有权模型禁止任何结构重新连接,这意味着每个值只能有一个所有者。Rust程序中每个值都是某个树形结构的成员,该结构的根是某个变量。当这个变量超出作用域时,整个树形结构将随之消失。
Rust的所有权模型使其能够进行更强大的分析,并提供更可靠的内存安全保证。
以下是整理后的内容:
在Rust中,所有权的根是一个变量。Rust程序通常不会显式地删除值,而是通过变量离开作用域、从向量中删除元素或类似操作来删除值。这一点与其他语言不同的地方在于,Rust并不是手动删除值,而是通过所有权的控制来自动管理这些资源。Rust的所有权模型使其能够进行更强大的分析,并提供更可靠的安全保证。
Rust通过借用(borrowing)、引用(reference)和生命周期(lifetime)等机制扩展了所有权的概念,使其更加灵活和实用。这些机制让Rust的所有权模型变得更加通用,能够适应更多的场景。虽然所有权模型本身可能显得僵化——每个值只能有一个所有者,但通过借用、引用和生命周期这些机制,Rust让所有权管理变得不那么僵化,能够动态适应更多的使用场景。
这些机制在Rust的所有权概念中依然存在,但它们扩展了所有权的应用范围,使得其更灵活。通过借用和引用等方式,Rust允许你在某些限制下拥有多个所有者。引用计数指针(如Rc)和借用机制等都进一步扩展了所有权模型,从而让Rust代码既灵活又强大,同时还保持了内存安全。
在Rust中,有一些类型(如整数、浮点数和字符等简单类型)被豁免了所有权规则的严格限制,它们遵守的是“复制”(copy)语义。Rust的所有权模型在性能和安全之间做出了平衡:对于一些简单的类型,它允许豁免严格的所有权规则,从而提升性能;而对于复杂的类型,Rust通过所有权模型确保其内存管理的安全性和可控性。
Rust的所有权模型通常只允许一个所有者,但在某些情况下,它允许多个所有者。这可以通过引用计数指针和借用等机制来实现。通过这些机制,Rust能够构建既灵活又安全的代码,同时仍然保持语言的内存安全承诺。
在下一章中,我们将介绍引用的概念,并详细解释这些策略。而在深入讨论move语义之前,先对所有权有一个更深的理解是必要的。在Rust中,对于大多数类型,赋值给变量、传递给函数或从函数返回时,不会复制值,而是移动值。
Rust中的move语义可以理解为:值的所有权从一个变量转移到另一个变量。原来的变量放弃了对值的所有权,并进入未初始化状态。这样,目的变量就控制了该值的生命周期。Rust通过一次次的移动值,逐步构建和拆除复杂的结构。
Rust改变了传统编程语言中赋值操作的语义,这对于大多数开发者来说,可能会感到有些惊讶。历史上,赋值操作一直是一个相当成熟和完善的特性。然而,Rust的选择带来了不同的后果:它的赋值语义决定了程序的内存管理方式,并对程序的性能产生了深远的影响。
举个例子,Python中的对象有引用计数,当对对象进行赋值时,只会增加引用计数,而不会复制对象本身。Python通过引用计数和垃圾回收机制(GC)来管理内存,赋值操作非常廉价,但需要持续跟踪对象的引用计数。
而在C++中,赋值操作则会执行深度复制。当变量超出作用域时,所有分配的内存将被自动释放。这种方式让内存管理更加清晰,但深度复制会消耗大量的内存和CPU资源。
相比之下,Rust在赋值语义上做出了不同的选择。Rust的move语义使得内存管理更加高效,并避免了深度复制的开销。在Rust中,赋值操作将转移值的所有权,而不是复制值,从而实现了既灵活又安全的内存管理。
这是整理后的内容,保持了原文的完整性,并使其更加流畅和连贯。
Rust 类型必须实现的 5 个特征
https://www.bilibili.com/video/BV1vg4peUEeH
讨论您的类型应该实现的 5 个 Rust 特征,特别是当您将类型作为公共接口的一部分公开时。
Summary
视频讲解了在Rust中应实现的五个标准库特性,以确保类型在公开接口中有效。包括Debug、Clone、Default、PartialEq、Send和Sync等特性。这些特性提供了基本功能,便于调试、复制、比较和线程安全。此外,视频还提到了一些非标准库的特性,如Serialize和Deserialize,用于序列化和反序列化,并介绍了如何通过Cargo配置来启用这些功能。
Highlights
- 🛠️ Debug: 使类型能够使用调试格式化输出,便于错误处理和调试。
- 🔄 Clone: 允许类型进行深度复制,使用户能够复制实例。
- 🏷️ Default: 提供类型的默认值,便于创建具有合理默认值的实例。
- 🔍 PartialEq: 使类型支持实例间的比较,尤其在测试时非常有用。
- 🧵 Send & Sync: 确保类型在多线程环境下安全传输和共享;需要注意使用非线程安全类型时会导致问题。
- 📦 Serialize & Deserialize: 提供序列化和反序列化功能,允许类型在不同格式间转换。
keyword
- Rust
- 类型实现
- 线程安全
在 Rust 中,有五个标准库的 trait 你绝对应该为自己的类型实现,尤其是当你将这些类型作为公共接口的一部分暴露出来时。Rust 的孤儿规则(orphan rule)规定,你不能为外部类型实现外部 trait。这意味着你库的用户将无法自己实现这些 trait,除非他们创建一个包装类型,这非常令人烦恼。此外,如果没有访问类型内部的权限,他们可能无法编写一个合理的实现。你最不希望看到的情况就是有人尝试使用你的 Rust 库,结果放弃了。幸运的是,这五个 trait 都可以通过自动派生(derive)来实现,这为我们提供了便利。在视频的最后,我还会介绍一个不是标准库中的 trait,但你也应该为自己的类型实现它。确保看到最后哦!
在开始之前,别忘了前往 letsgarusty.com/cheat-sheet 获取免费的 Rust 速查表!
假设我们正在创建一个 Rust 库,公开一个名为 User 的类型。User 是一个结构体,包含三个字段:ID、name 和 role。我们在 main 函数中模拟一下库的用户如何使用这个类型。
首先,我们创建一个 User 类型的实例,然后尝试使用调试格式输出它。
调试格式化在很多情况下都非常有用。例如,如果发生错误,我们可能希望使用调试格式打印出该错误,并同时打印出 User 结构体,以提供一些额外的上下文信息。不幸的是,我们得到一个编译时错误:User 不能使用调试格式(Debug)输出,因为它没有实现 Debug trait。错误信息建议我们派生这个 trait,这正是我们要做的。
我们还需要为 role 枚举派生 Debug trait。
接下来,用户可能希望对你的类型进行复制操作,这可以通过 Clone 方法来实现。
此时,我们得到一个编译时错误,提示没有为 User 类型定义 clone 方法。这是因为我们需要派生 Clone trait。
第三件用户可能希望做的事是使用默认值创建你的类型实例。这可以通过 default 关联函数来实现。
和 Clone 一样,为了这个函数能正常工作,我们需要派生 Default trait。
当我们派生 Default trait 时,默认值将会被分配给那些有合理默认值的类型。例如,无符号 32 位整数的默认值是 0,字符串类型的默认值是空字符串。对于枚举类型,并不存在一个合理的默认值,所以我们需要通过属性指定一个。在这个例子中,我们指定 Guest 作为默认变体。你也可以为 Default 进行自定义实现。例如,可能你不希望 ID 默认是 0,或 name 默认是空字符串,但在我们的例子中,因为默认角色是 Guest,这些值是可以接受的。
接下来,用户可能希望对给定类型的实例进行比较。这在编写测试时尤其有用。
要实现这一功能,我们需要实现 PartialEq trait。
这为我们提供了比较该类型实例的基本功能。不过,还有一些相关的 trait 能为你提供更多功能,它们是 PartialOrd、Hash、Eq 和 Ord。查看这些 trait 的文档,看看它们是否适用于你的用例。
接下来,你的类型应该实现的 trait 是 Send 和 Sync。如果你的类型可以安全地在线程之间传递,则实现 Send;如果你的类型可以通过引用在线程之间共享,则实现 Sync。好消息是,这些是自动实现的 trait,也就是说,它们会自动为你的类型实现。但这里有个注意点:这些 trait 只有在你的类型没有包含非 Send 或非 Sync 的值时,才会自动实现。
例如,假设我们在 User 类型中添加了一个新的字段,这个字段持有一个引用计数类型。
在这里,我们有一个名为 db 的字段,它持有对数据库实例的共享引用。问题在于,Rc 智能指针既不是 Send 也不是 Sync,这意味着我们的 User 类型也不再是 Send 或 Sync 了。这很糟糕,因为你的库用户将无法在线程之间传递这个类型。为了解决这个问题,我们可以用原子引用计数智能指针替代 Rc。
Arc 是 Send 和 Sync 的,因此我们的类型可以再次在线程之间传递。但正如你刚刚看到的,添加一个非 Send 或非 Sync 的类型非常容易。那么我们如何防止这种情况发生呢?Jon Gjengset 在他写的《Rust for Rustaceans》一书中给出了一个巧妙的技巧,我强烈推荐你阅读这本书。
首先,我们实现一个名为 is_normal 的函数。
然后我们创建一个简单的测试函数。is_normal 定义了一个泛型类型 T,它必须实现 Send 和 Sync trait,同时还需实现 Sized 和 Unpin。这些都是普通类型应该实现的自动 trait。注意,T 并没有在这个函数中被使用,这完全没问题。我们在测试中所做的只是调用这个函数,并替换 T 为我们想测试的类型。这样做将确保 User 实现了这四个 trait。
让我们看看如果我们改回使用 Rc 智能指针会发生什么。
正如你所看到的,我们现在得到了一个编译时错误,提示 Rc 不能在线程之间安全传递。我们甚至不需要运行这个测试,因为这些 trait 约束是在编译时被检查的。
好了,现在我们切换回使用 Arc 智能指针。
最后,让我们讨论一组你应该实现的 trait,这些 trait 不在标准库中。它们是 serde crate 中的 Serialize 和 Deserialize trait。实现这些 trait 将允许你的类型以常见格式(例如 JSON)进行序列化和反序列化。
为了实现这些 trait,我们首先需要将 serde crate 添加为依赖项。
我们还需要添加 serde_json 作为依赖项。
现在我们可以从 serde crate 派生 Serialize 和 Deserialize。首先,我们需要导入这些 crate。
然后我们为 User 和 Role 添加派生。
此时我们得到一个错误,因为这个类型没有实现 Deserialize trait。我们可以修复这个问题,但在这个例子中,我们不需要序列化这个字段,所以我们将跳过它,通过添加一个属性来忽略该字段。
很好,现在我们的类型可以被序列化和反序列化了。让我们在 main 函数中看看它是如何工作的。
首先,我们创建一个 JSON 字符串。
然后我们将该字符串反序列化为 User 类型。
最后,我们使用调试格式打印出 User。
一切都按预期工作。
最后要提到的一点是,你可能不希望默认实现 Serialize 和 Deserialize,即使这是非常常见且有用的功能。一些用户可能并不需要这样的功能。通常,库会通过一个功能标志(feature flag)来控制这部分功能,这样只有在需要时,用户才会依赖 serde。让我们来看看这个功能是如何工作的。
首先,我们打开 Cargo.toml 文件,并将 serde 设置为一个可选依赖项。
然后我们添加一个名为 serde 的新功能标志。
当该功能启用时,serde 依赖项将被包含在内。
最后,在 main 函数中,我们将代码放在新的功能标志后面。
首先,通过配置表达式引入 Deserialize 和 Serialize。
然后通过配置属性表达式为 User 实现 Deserialize 和 Serialize。
我们对 User 类型也做相同处理。
最后,我们将 main 函数中使用 serde_json 的代码置于功能标志后。
然后,如果我们在没有启用任何功能的情况下运行代码,输出中只有一行打印语句。
但如果我们启用 serde 功能标志,我们会看到第二行打印语句。
至此,你的类型已经准备好与世界分享了。
这就是本期视频的全部内容。在你离开之前,别忘了前往 letsgarusty.com/cheat-sheet 获取免费的 Rust 速查表。如果你喜欢这个视频,记得点赞并订阅频道,我们每周都会发布 Rust 相关的内容。就这样,我们下期见!
无需类的Rust数据建模
Summary
本视频讲解了如何利用Rust的强大类型系统和编译器避免无效状态的出现。重点介绍了代数类型系统、数据规范化和状态机三种技术。Rust的枚举(enums)可以用来表示复杂的数据模型,通过匹配表达式(match expression)来确保所有可能的状态都被覆盖。此外,Rust还通过数据规范化将数据建模为表格形式,避免了面向对象编程中的复杂性。视频中还使用了状态机的概念,展示了如何使用Rust的枚举来建模有效状态,确保系统的状态转换符合预期。
Highlights
- 🛠️ 代数类型系统:Rust的枚举(enums)用于表示复杂的代数和数据模型,使无效状态不可表示。
- 🔍 数据规范化:通过将数据建模为规范化的表格形式,避免了面向对象编程中的复杂性。
- 🎮 状态机:使用Rust的枚举和匹配表达式来实现状态机,确保所有状态和转换都是有效的。
Keyword
- Rust编程
- 数据建模
- 状态机
https://www.bilibili.com/video/BV1JcpEevEmW/
嗨,朋友们!我是 Tris,欢迎来到No Boilerplate专注于快速技术视频的频道。Rust 的众多特性中,我最喜欢的是它丰富的类型系统。它不仅仅检查你是否正确地使用了变量的类型,还允许你构建属于自己问题领域的复杂模型,并与之深度集成。今天,我将向你展示如何通过三种技术来让无效状态无法表示,这三种技术完美契合了 Rust 强大的类型系统和编译器。我们将讨论代数数据类型系统、数据规范化以及状态机。
本视频中的所有内容,从脚本到图片,都是一个在 GitHub 上公开的 Markdown 文档,并已通过公共领域许可发布。
大多数流行的编程语言都有“乘积类型”。这里的第一个例子是一个点(Point),它就是一个乘积类型,这类似于一个类或 C 风格的结构体,它是多个属性的容器。第二个类型,web 事件(Web Event)是一个枚举类型(Enum),但枚举在大多数流行语言中通常不是核心部分。其他语言中的所谓枚举,通常是对一组整数的枚举,为它们赋予一个名称、位标志或消息类型。这些实现很糟糕。Rust 中的枚举不同,它们是真正的代数和类型(Algebraic Sum Types),有时也被称为标签联合(Tagged Unions)。乘积类型和和类型的区别很简单:一个假的猫类在任何流行的语言中都可以用普通的类或结构体来编写。在 Rust 中也是可以的,但它需要在运行时验证死猫不能是饿的(我会担心如果它饿了)。最自然的做法是用 Rust 的胖枚举(Fat Enums)来建模,只有活着的变体才有额外的“饿”属性。没有什么需要额外检查的,真正的猫只有两个有效状态:活着和死了。因为你通过类型系统强制执行了这一点,所以你获得了安全性保证和“超能力”。例如,如果你使用枚举,那么 Rust 编译器会阻止你犯错误。
这段代码不是有效的 Rust 代码,我犯了一个错误,你能看出来吗?与可以执行任何操作且不受类型系统约束的 if 语句不同,match 表达式是与类型系统配合工作的。我忘记处理两种状态之一。如果我使用了 if 语句或原始类型,我可能根本不会意识到这个问题。但因为我使用了枚举和 match,编译器知道我在做什么,并保护了我。编译器甚至告诉我如何修复我的错误。我们今天将使用 match 表达式构建我们的状态系统,让我们看看它能带给我们什么。
Rust 提供了通过 match 关键字进行的模式匹配,它的用法类似于 C 风格的 switch。第一个匹配的分支会被执行,并且会短路,所有可能的值必须被分支覆盖,这一点由编译器强制执行。这里我们匹配的是一个有序值、一组值、一个值的范围或通配符。注意,在这里我们正在匹配整数的运行时值,我们可以同时匹配编译时类型和运行时值。为了获得我所说的极致安全性,你必须通过类型系统丰富你的模型,而不是仅仅使用匿名数字和字符串。这样做在 Rust 中没有运行时开销——在运行时,它们确实只是数字和字符串,CPU 根本不会察觉。这是 Rust 的众多零成本抽象之一。但在编译时,正是你和我所在的世界,我们通过类型丰富数据。
代数类型系统(Part 1: Algebraic Type Systems)
面向对象编程(OOP)是个奇怪的东西。如果你想要一个类,你还必须带来这个类的所有父类。面向对象系统中的重用几乎为零。大多数面向对象语言通过不允许多重继承来解决菱形继承问题。哦,太棒了,多么聪明的解决方案!到底是谁在操控这个系统?我们不会这样设计数据库表,也不会这样构建公司。我们通过一种过于人类中心化的方式,创建了一个无法真正建模现实世界的系统,它只是用来建模猫、狗和基因的。将现实世界强行塞进继承模型中会一直带来实际问题。
如果你像 Rust 那样抛弃面向对象编程,你会发现一个更简单、更好的世界。如果你拥有像 Rust 那样的代数类型系统,并使用枚举,你根本不需要面向对象编程带来的那些负担。也许 Smalltalk 可能奏效,但 Python、Ruby、JavaScript、Java 和 C++ 中的面向对象实现充满了妥协,它们不得不允许“逃生舱口”以接触到实际的数据世界,并通过“孪生函数”来工作。为什么不一开始就把数据和函数处理得非常好呢?Rust 就是这样做的。
但如果我们不通过类和继承来建模数据,那我们该如何设计数据呢?我们如何替代整个行业在设计数据上积累的经验?在介绍我们今天的赞助商 Quadratic 之后,我会给你答案。
Quadratic
Quadratic 正在为工程师和数据科学家构建一个开源的电子表格,它是用 Rust、WebAssembly 和 WebGL 构建的。Quadratic 将电子表格的功能数据可视化与完整编程语言的强大功能相结合,首先是 Python。标准的 Python 数据科学库已内置。实际上,由于 Quadratic 使用了 Pyodide 运行于 WebAssembly 中,任何纯 Python 依赖项都可以像这样安装,比如 Faker 库。因为所有 Python 都运行在本地的 WebAssembly 中,复杂的工作,比如从 API 获取数据,都是可行的。这一切都在 GPU 上以 60fps 运行,使用 WebGL 完全在你的浏览器中实现。Quadratic 在 WebGL 上构建了它的无限画布,支持顺畅滚动和缩放。他们最近发布了 GPT 集成,允许你在编写时拥有一个合作伙伴或编程助手。这是开源的,可以免费使用。今天就注册吧,访问 Quadratic HQ 试用一下。感谢 Quadratic 对本频道的支持。
数据建模:抛弃面向对象,采用数据规范化
答案是,我们并没有要取代整个行业在数据建模上的经验。今天我不会教你任何新的东西,我只是建议你像我们在数据库中那样思考你的数据,并应用古老的数据规范化原则来设计它。数据库表早在面向对象编程之前就存在,并且在面向对象编程成为遥远的回忆后,它们依然会存在。你知道我们的行业如何分裂为两派:一派热爱 ORM,因为它们把数据库带入了我们语言(如 Java 或 Ruby)的面向对象世界;另一派则热爱 DAL,因为它们把结构化数据的世界带入了我们的面向对象语言。这种分裂不应该存在,它是人为的。面向对象编程搞砸了一切。表格比继承模型更好地模拟了现实世界。让我们在我们的语言模型中直接使用表格。
Rust 的结构体可以一对一地建模表格。这个例子改编自 Rust 中的 Diesel 查询构建器,这是我第二喜欢的数据库连接工具,仅次于 SQLx。让我们逐步分析这个例子,看看如何通过规范化来改进它。
有很多不同的规范化形式,你知道计算机科学家喜欢发明新名词。我们今天要讨论的是第三范式(3NF),就像大多数人那样,我们就此打住。这些规则其实是常识,你几乎在不知不觉中已经在做了。但是,了解第三范式的规则将帮助你避免后续的数据设计错误。你可以忘记“四人帮设计模式”,但你必须记住规范化。
这是之前提到的 Post 结构体。我们现在已经将结构体规范化了,数据被合理地拆分成了多个结构体,像 Author 和 Image 这样的数据松散耦合,这样多个作者可以撰写多篇文章,图片也可以被重用。根据你程序中数据的关系程度,你可能想去掉 ID,或者内联一个关联表,这都没问题。我们不是严格为了查询优化,而是试图掌控我们的数据设计。还要注意 Image 元组结构体,它允许我们创建一个包裹内置类型的全新类型。文章的图片需要是图片类型,而不仅是一个普通的字符串。
现在你已经回顾了数据设计的知识,让我们讨论如何通过状态机使无效状态无法表示。
状态机
我在为本视频寻找一个不无聊的状态机例子时找了好几天。状态机很好,但它们不太有趣。状态是圆圈,状态之间的转换是箭头。如果你对这门科学感兴趣,Computerphile 有一个关于状态机的精彩视频系列,链接在源码中。他们的视频很好,但举的例子(如转门或自动售货机)相当枯燥。我几乎失去了所有希望,但后来我在翻阅《超级马里奥世界》手册时,发现了一个非常棒的图表,在第 11 页。让我们放大看看:有状态和转换图,这是一个状态机!我想每个人至少熟悉这个图中的两个状态。让我们在 Rust 中构建这个状态机,看看如何让无效的马里奥状态无法表示。
使用 Rust 强大的枚举来构建系统的有效状态。图中的每个马里奥都是一个新状态,我们有马里奥、小马里奥、火焰马里奥和披风马里奥。今天我们将保持例子简单,Rust 的胖枚举可以包含你想要的任意多的数据。就像在这个无关的例子中,枚举可以包含结构体,而结构体又可以包含枚举。
接下来,我们来建模状态转换。我们同样将转换建模为一个枚举。如果我们想为转换添加更多数据,我们可以在这里加上。
第一行添加了 #[derive(Debug)] 属性,如果你之前编写过结构体,这应该对你来说比较熟悉。它允许我们调试打印这个新类型。而最后一行则是一个小技巧,允许我通过未限定的名称来引用转换。我也对状态做了同样的处理,这会让我们今天的例子更短一些。但对于非小型程序,你应该使用完全限定的名称,就像这里的 Transition::Flower。
我们快完成了。现在我们有了状态和转换,接下来需要映射状态之间的有效转换规则。以下是我们状态机的规则。首先,我们创建了一个 Player 结构体来保存状态。这是你应用程序的长生命周期顶层结构体。如果你在构建 UI,这就是窗口;如果你在构建 Web 服务器,这就是服务器应用,它包含数据库连接等。
我们为 Player 实现了 new 方法构造函数,并将默认状态设置为“马里奥 世界 1-1”。接下来,collect 方法是唯一能在状态之间移动的有效方式。这个函数的输入是马里奥收集的某种道具,它被传入我们的 match 表达式中。这个表达式定义了我们有效状态改变的规则。
有几种实现方式。对于更复杂的系统,你可以使用嵌套的 match 表达式,或者使用辅助函数将 match 拆分为多个函数,甚至是整个模块。对于简单的状态机,我建议将状态和转换事件放入元组中,并在其上进行匹配。
在开发状态机时,Rust 的类型系统和 match 表达式会保护你。而且因为它可以编译,我知道我已经涵盖了所有情况。
这是我们的状态机如何运行的。在其他语言中,我们可能需要大量测试来证明我们写的 if 语句没有漏掉某个情况或写了不可到达的代码。这种方式很糟糕。而在 Rust 中没有这些边缘情况。我只需要仔细检查 match 表达式,以确保业务逻辑得到正确实现。而当我需要更改状态时,比如在《超级马里奥64》中,它很容易实现,编译器会告诉我需要更新哪些 match 语句。
状态机无处不在,它们易于推理、易于调试,并且在需要的时候,它们严格定义的范围允许它们被正式证明,以确保当系统的运行关系到人类生命时是安全的。使用 Rust 丰富的类型系统来建模你的数据,你将能让无效状态无法表示。
如果你想支持我的频道,并获得早期、无广告和无追踪的视频,以及 VIP Discord 访问权限,请前往 patreon.com/no-boilerplate。如果你对超人类主义(Transhumanism)和希望朋克(Hope Punk)故事感兴趣,请收听我的科幻音频小说播客《Lost Terminal》。如果你更喜欢都市奇幻(Urban Fantasy),请收听我制作的《Modem Prometheus》播客。所有的字幕和编译检查过的 Markdown 源代码都可以在 GitHub 上找到,链接在视频描述中,修正内容在置顶评论中。非常感谢你的观看,我们在 Discord 上再见!
作为Rust开发者看C++的优缺点
以下是整理后的内容:
我看了一篇挺有意思的小文章,作者叫 Child Nosing,标题是《作为 Rust 开发者,看 C++ 的优缺点》。这是他的原文链接,有兴趣的可以去看一下。我来讲一下他文章的具体内容。
我是一个专业的 Rust 开发者,也有点推崇 Rust。所以,当我那个几乎没有什么编程经验的妹妹让我教她 C++ 时,我真的是感到有些无奈。我试图告诉她,有一天她会因为调试段错误(segfault)而感到崩溃,并意识到学习 C++ 是个错误的开始。但她比我还固执,非要学一种所谓“人们真正用的编程语言”。
自从高中用 Cocos2D 做游戏以来,我就没有写过 C++ 了,但我觉得自己还记得什么“三法则”之类的知识,应该够用。然而,事实证明我也需要重新学习。不过,我很兴奋地发现,C++ 中的大多数概念都能用 Rust 的东西来类比。虽然 C++ 有它的缺点,但它也有自己的美。我早就知道这一点,当然,重新学习后我更加意识到 Rust 能够超越 C++。不过,这也要看你是否同意 Rust 超越了 C++,因为毕竟 Rust 站在了 C++ 的肩膀上。
我们花了几周时间,跟着一个叫 The Cherno 的 YouTube 视频学习 OpenGL。顺便提一下,这个系列非常棒。两周后,我们终于在屏幕上显示出了一个静止的蓝色方块。我开始担心妹妹会怀疑学习 C++ 做游戏是否明智,于是我决定脱离教程,真正开始开发游戏。我们开始实现游戏功能,并将其拆分为一个简易的游戏引擎。我很自豪地说,现在我们有了一个可以下载并玩的游戏,代号叫 Space B。
当游戏引擎和玩法逻辑分开后,妹妹就主导了玩法的开发,很多部分是她自己实现的。而我则负责引擎功能方面的需求。
先来看 C++ 的优点。自由度:C++ 是一种非常自由的语言,它允许你先编写代码,之后再修复。你尝试做的事情可能是个坏主意,也可能是个好主意,取决于你,而不是某个唠叨的借用检查器(指 Rust),或自以为更懂的傲慢语言设计者。用指针?最坏的情况能怎么样呢?崩溃?或者发生一些奇怪的事情?这只是个视频游戏而已。再说了,你已经决定使用 C++ 了,显然坏主意并没有阻止你。当然,我有些夸张了,开个玩笑而已,但 C++ 确实让你可以为所欲为。
我曾经写过一个非常复杂的模板,用来缓存那些可以共享但又需要三重 I 特性的类型。令人惊讶的是,妹妹的游戏代码只做了一些小改动,帧率就提升了四倍。我们经常会不小心把浮点数传给需要整数类型的函数,虽然编译器会发出警告,但我们通常会选择暂时搁置它。如果需要全局变量,我们就先设为静态的,至于后果以后再讨论。
我非常熟悉 Rust 的编程方式,可以很快用 Rust 写出代码。不过,我也不羡慕平行时空中的自己——那个在教妹妹 Rust 的我。在那个时空,可能正在向妹妹解释什么是一次性单元(OnceCell)和带引用计数的引用单元(Arc
再来看 表达力。面向对象编程在编写游戏时非常好用。我知道现在大家都批评面向对象,但它在游戏开发中确实非常愉快。我们创建了一个 GameObject 类,然后派生出 SquareObject 等类,所有东西都放在一个大的 std::vector<std::unique_ptr<GameObject>> 中。你只需对每个对象调用 update 和 render,它就会调用正确的函数并正常工作。
我以前是 Rust 游戏引擎 Bevy 的重度用户,Bevy 有很多值得喜欢的地方,但被认为是易用 ECS 的黄金标准。相比之下,像我们这样直接用 C++ 制作游戏,体验更加有趣,也更有效率。这个观点是不是有点似曾相识?我们之前的视频中提到过,用 Rust 做游戏开发时会有哪些糟糕的体验。确实,C++ 更有效率。然而,使用真正的游戏引擎最终可能会比我们几周内搭建的 C++ 代码有更糟糕的开发体验。再次强调,我们开始时甚至都不太懂 C++,这不是一个公平的比较。
再来看 调试。我非常喜欢在 Visual Studio 和 VS Code 中的调试体验。相比之下,Rust 似乎没有同样级别的调试工具。即使有类似功能,我也很少用。在我的 Rust 项目中,我通常用 cargo 和 Bevy 来运行程序,失去了使用断点调试的便利。所以至今我只在 Rust 中用过一次调试器,其他时候都是依赖 println! 来调试。
再来看 现代 C++。在 C++ 中有许多旧特性,除非有足够的理由,否则通常不应该使用它们,而应该使用现代 C++ 的等效物,比如 std::unique_ptr 和 std::array。这有点像那本经典的《JavaScript: The Good Parts》,去粗取精。同样地,现代 C++ 实际上非常好用,特别是像 std::unique_ptr、std::shared_ptr 和 std::array 这样的工具,让你基本上不用担心内存安全问题。当然,这可能有点夸张了。实际上,在游戏开发过程中,我们还是遇到了一些 segmentation fault,但它们通常很容易修复。
再来看 C++ 的缺点。C++ 处于一个不幸但也很棒的位置。它是基于 1973 年开发的语言,经过多次升级,几乎没有破坏兼容性。这是一个巨大的技术成就,不应对此持否定态度。但有时候这会使事情变得有点复杂,积重难返。
再说说构建系统。缺乏标准化的构建系统真的很令人沮丧。我们没有使用 Windows,我用的是 MacBook。某个时候,我们从直接使用 Visual Studio 转向了 CMake。CMake 是个很棒的项目,但与 cargo 相比,CMake 的用户体验并不那么好。Visual Studio 有一个 CMake 模式,但我们遇到了大量 bug,迫使我们经常关闭并重新打开 IDE。
包管理也是个问题。有人告诉我 Conan 和 vcpkg 解决了很多包管理的问题,但我们还是浪费了很多时间。C++ 开发者似乎习惯了全局安装包,并让构建系统自动找到它们。但当我在我的 Mac 上使用一个库时,我妹妹却无法使用,导致构建失败。我通常会选择从源代码构建所有依赖项,把依赖库作为 git 子模块添加到项目中。
相比之下,Rust 的 cargo 工具添加依赖就简单多了,只需要执行 cargo add crate_name 就行了。
再来看 错误消息。无论是 Clang 还是 Visual Studio,C++ 的错误消息与 Rust 相比糟糕多了。我妹妹在学习 C++ 时,错误消息是她遇到的最大障碍之一。像我在头文件中修改了函数签名,但没有在 cpp 文件中修改时,编译器会报出非常晦涩的错误。
C++ 的语言复杂性让编译器提供非常友好的提示信息变得困难,但对于像这种常见的错误,完全可以给出更清晰的提示。
这篇文章很短,也相对有趣,但它的场景比较特殊。文章讲的是 C++ 在游戏开发中的优点,如果放到操作系统开发或嵌入式开发中,它的优缺点可能会有所不同。所以,多少有些以偏概全,也可能是一种比较随意的比较。
德式字符串是什么?
德式字符串(German-style string literals)是程序设计语言中的一种字符串表示方式,主要是在 C 语言及其变种中使用。它是指在字符串的结尾使用两个单引号 '' 来表示字符串的结束,而不是使用常见的转义字符(例如 \0)来表示字符串的结束。
这种字符串表示方式最早出现在一些早期的 C 语言编译器中,尤其是一些德国开发者所使用的编译器,因此得名为“德式字符串”。
示例
假设我们有一个普通的字符串 "Hello, world!",在 C 语言中可能是这样定义的:
1 | char str[] = "Hello, world!"; |
而使用德式字符串时,会写作:
1 | char str[] = "Hello, world!''"; |
在这种情况下,两个单引号 '' 表示字符串的结束,并且不需要使用空字符 \0 来终止字符串。
特点
- 终止符:德式字符串使用两个单引号
''作为终止符,而不是传统的\0。 - 兼容性:这种字符串在现代编译器中并不常见,也不被广泛支持。它更多出现在早期的编译环境中,尤其是在一些德国开发的编译器中。
- 历史意义:虽然德式字符串在早期有使用,但随着 C 语言标准化和编译器的发展,它的使用逐渐减少。现代 C 语言编译器和库通常不再支持这种字符串表示方式。
现代替代
如今,大多数编译器和语言都使用空字符('\0')来表示字符串的结束符。这种做法更符合 ANSI C 和 ISO C 标准,因此在现代编程中,德式字符串的使用几乎已经消失。
小结
- 德式字符串 是一种历史遗留的字符串表示方式,使用两个单引号
''作为字符串结尾。 - 它主要出现在一些早期的 C 语言编译器中,特别是德国开发的编译器里。
- 在现代编程中,德式字符串已经不再常用,取而代之的是以空字符
\0作为字符串的结束。
在 Rust 中不可能实现的优化
https://www.bilibili.com/video/BV1SXtFeyE3W
中文整理与翻译
好吧,一个在 R 中不可能实现的优化。所以我们要先读这个,然后再回头读另一个内容,因为我对这个优化是否在 Rust 中不可能实现确实有些好奇。Rust 中的字符串类型不够多。
哦,不是,我们的字符串类型不够多吗?天哪,是的,我确实有严重的阅读障碍。嘿,这是你们的错,你们居然听一个不会读书的人讲课,好吧,这不是我的错。最后一次我检查时间时,我看了我的手表,我没有做这个决定,是你们做的。
当我在攻读数据库系统的硕士学位时,这篇文章《为什么德国字符串无处不在》立刻引起了我的注意。我非常兴奋地了解到这是在一篇论文《Umbra:一个具有内存性能的基于磁盘的系统》中描述的字符串数据结构,这篇论文通过另一篇关于其存储引擎 Lean Store 的文章介绍给我,这是关于超越主内存的数据管理的。我更感兴趣的是,它已经在许多不同的数据解决方案中实现了,比如 DuckDB、Apache Arrow 和 Polar。
哦,Polar 是 Pandas 的替代品,对吧?我老了。没有什么受害者归咎的意思。嗯,其实是你们的错,你们才是受益者。你们知道吗?越想越觉得,你们其实是从我的阅读障碍中受益最多的人。
不,Polar 是 Rust 中的数据框架库。但 Polar 也是一个 Python 库,Polar.rs 是 Rust 的版本。他开始发现了我们。
有一件事让我特别困惑,那就是文章中的以下声明:“这种字符串实现允许非常重要的短字符串优化。一个足够短的字符串可以就地存储,也就是说,我们在容量字段中设置一个特定的位,然后容量的剩余部分、大小和指针本身就成为了字符串。这样我们可以节省每次访问字符串时分配缓冲区和解引用指针的开销。”这在 Rust 中是不可能的,顺便说一句,带个眨眼表情。太酷了。
所以,在我查看任何细节之前,我很难理解这一点,因为我首先想到的是,字符串应该是指针、长度、容量三个部分。所以它是一个超级胖的字节,是一个超级胖的指针,三倍胖的指针。那么这是否意味着它在这里存储一个位?那么会发生什么呢?好的,如果我理解正确的话,指针指向一个内存空间,而短字符串优化是这个内存空间实际上直接存储在这里。这真是太妙了。
嗯,没错,确实是在栈上。天哪。如果我正确地解释了这个说法,它是完全错误的,因为在 Rust 中已经有很多支持该功能的包,比如 compact_str、smart_str 和 small_str。此外,文章中提到的 Polar 是用 Rust 编写的,并且实现了 Umbra 样式的字符串。我被技术问题吸引住了,开始自己实现德国字符串,以证明这是可能的。我不能责怪这个家伙被技术问题吸引住了,因为这就是我现在被吸引的状态。因此,与其让 AI 失败并要求人类总是提供反馈,直到最终结果正确,有时你可以自动化反馈过程并让 AI 循环运行。所以在可以自动检查的情况下,我构建了自己的工具,它看起来像这样:生成代码、运行测试、运行代码生成、运行测试、运行推理,然后它就自动循环执行。这一句话让我构建了整个 AI 系统,因为我忍不住,我必须弄清楚。
好吧,我们将提示 AI,我们将运行测试,然后让 AI 推理它哪里错了。天哪,我明白了,我完全理解被技术问题吸引住的感觉,这种情况发生在我们很多人身上,不要感到难过。
它有多好?这是一个非常有趣的学习体验,我学到了很多关于测试生成的知识。
什么是“技术问题吸引”?有人提出一个问题,比如说这个操作是不可能的,或者说“嘿,如果你这样做会更好”,你不相信他们,然后你就去做了,结果发现你开始写了一大堆代码。这就是所谓的“技术问题吸引”:突然之间你发现自己编程了很多,因为你无法放下这个问题。
某种类型的大脑很容易被某个有趣的问题所吸引,它会不由自主地放下其他事情来解决这个问题。这种情况发生在我身上,这促使我发明了一项新运动——“技术问题吸引”。
你看那个过马路的物理学家。“嘿,在这个由理想的 1 欧姆电阻组成的无限网格上,两个标记节点之间的等效电阻是多少?”“哦,我不知道是什么。”有趣,也许你可以从……“哦,他死了。”我不想参与这个。“拜托,伙计,试试吧,这很有趣。”物理学家们是 2 分,数学家们是 3 分。所以他在马路中间被“杀”了,我明白了,技术问题吸引,挺有趣的。
我只听说过“技术问题吸引”,不知道它源自 XKCD。
在这篇文章中,我将描述我是如何实现德国字符串的以及在 Rust 中这样做的挑战。特别是我将探讨如何实现这种数据结构的共享所有权。提供唯一所有权相对简单,RustCon 上已经有一篇很棒的教程教你如何实现 Vec,它与字符串并没有太大不同。
但首先,让我们谈谈德国字符串的概念。
我们在 Rust 中已经有足够多的字符串类型了吗?“德国风格的字符串”这个术语是由 Andy Pavlo 在他的《高级数据库系统》讲座中提出来的。顺便说一句,我因为这个优化问题再次被技术问题吸引住了。看看吧,我因为这篇文章的标题而读这篇文章了。我感同身受,但并不喜欢这种感觉。
在他的《高级数据库系统》讲座中,德国字符串和 Umbra 字符串都指的是同一个概念。从高层次来看,字符串概念上是简单的,只是一串字符。然而,关于字符串的实际情况比我们意识到的要复杂得多。即使是“字符”的概念本身也是一个复杂的问题。根据使用场景,字符串在内存中的表示方式可能会有很大差异。仅在 Rust 内部,就有几种不同类型的字符串。以下是每种类型及其对应的借用版本:
String:UTF-8 编码的字符串CString:拥有的、C 兼容的、以空字符结尾的字符串,但中间没有空字节OsString:拥有的、可变的、平台原生的字符串,能够与 Rust 字符串进行廉价的互相转换
还有更多类型。
Zig 的一个优点是它的字符串只是一个 u8 数组。
Chris Lattner 是不是因为技术问题吸引才创建了 LLVM?是的,他确实是。
Rust 的 String 是 Rust 中最常用的字符串类型,因此也是我们讨论的主题。内部来说,String 只是一个 Vec<u8>,也就是一个字节序列。此外,这个字节序列必须是 UTF-8 编码的,这意味着字符不总是一个字节,而是 1 到 4 个字节不等。String 的实现如下(去除了一些细节)。
是的,这就是假设字符串的字节对显示信息有某种编码,而 Zig 或其他一些语言通常不会强制要求字符串是 UTF-8 编码的,它只是一个简单的 8 位(bit)块列表。
通过这些编码,可以假设字符串是有一定的显示规则的。Vec 就是一个简单的、基于字节的动态数组。
字符串有三个部分:长度、容量和指针。
内部来说,字符串(String)只是一个 Vec<u8>,也就是一串字节序列。此外,这个字节序列必须是 UTF-8 编码的,这意味着字符并不总是一个字节,而是介于 1 到 4 个字节之间。接下来是字符串的实现(去除了一些细节)。
是的,这假设字符串的字节有某种显示信息的编码,而 Zig 或其他语言通常不强制要求字符串是 UTF 编码的。它们只是简单地将字符串视为 8 位的块列表而已。
编码方式有时会被假定。字符串只是一个 Vec,就是一个原始的字节序列,包含指向数据的指针和容量。这里有些代码我不太确定是干什么的,比如 global allocator,我也不清楚它的作用。还有 Phantom data,我现在还不完全理解它的用途。我在学习 Rust 时,刚学到 Phantom data 就放弃了,因为我觉得太复杂了。
简单来说,字符串在栈上有三个部分,长度、容量和指针,总共占用 24 字节。嗯,长度、容量、指针,没错。指针在最后而不是在最前面,稍微有些意外。Phantom data 只是为了使用类型 T 吗?不过 NonNull 也是类似的东西吧?虽然字符串是可变的,但我认为它还有其他一些特性,只是我现在记不清了。我记得 John 曾经讨论过 Phantom data,当时我理解了一阵子,但现在已经记不得了。
总而言之,字符串是可变的,必要时可以追加内容。所以我们需要跟踪它的长度和容量,并确保容量总是大于等于长度。当长度超过容量时,会创建一个新的缓冲区来容纳新的字符串,并且旧的缓冲区会被释放。
德国字符串
理论上,一个字符串可以容纳无数种不同的文本,并且可以拥有无限的大小。然而,现实世界中的字符串,尤其是在数据库中的使用,往往具有一些共同的特征:
- 大多数字符串都很短。
- 大多数字符串不会改变。
- 比较或者排序字符串时,通常只会涉及到一个很小的子串。
基于这些观察,德国字符串(German String)应运而生,以应对这些特定的用例,旨在加速字符串处理性能。它的基本思想是实现所谓的“短字符串优化”(short string optimization)。对于足够短的字符串,避免在堆上分配缓冲区,而是直接在栈上存储字节。通过重新利用容量和指针字段,可以避免昂贵的内存分配。此外,没有内存分配也意味着在访问短字符串时,不需要解引用指针。
这确实是一种非常酷的优化。实际上,德国字符串在栈上只占用 16 字节,并且根据字符串的长度有两种不同的表示方式。此外,德国字符串是不可变的,因为无需跟踪缓冲区和容量。
德国字符串的布局
对于短字符串(最多 12 字节),它们会被内联并直接存储在栈上。在这 16 字节中,4 字节用于存储字符串的长度,剩下的 12 字节用于存储字符串内容。对于长字符串(超过 12 字节),它们会在堆上分配,并且指向堆分配的缓冲区。不过有一些不同之处,只有 4 字节用于存储字符串长度,而不是通常的 8 字节,这将字符串的最大长度限制为 4 GB。这个限制是合理的,因为现实世界中的大多数字符串都很短。很少会遇到超过 4 GB 的字符串。
此外,字符串的前 4 字节会内联存储在栈上。对于大多数字符串比较操作,这可以避免昂贵的指针解引用,当只通过前缀就可以确定结果时,比较操作就可以在前 4 字节内完成。
这确实非常巧妙!通过仅比较前 4 字节,如果它们不相等,就没有必要检查整个字符串的内容了。这种优化非常聪明且高效。
Rust 中的德国字符串实现
现在我们已经了解了德国字符串的基本结构及其动机,让我们看看如何使用 Rust 实现它。首先,我们的目标是实现一个拥有的、不可变的字符串,它是 UTF-8 编码的,并且支持前面描述的优化技术。此外,堆分配的缓冲区将采用原子引用计数(Atomic Reference Counting),允许我们在多个线程之间共享它。
使用原子引用计数(ARC)管理字符串可能不是所有情况下都需要的。有时候,你可能更希望使用普通的引用计数(RC)。每当使用 ARC 或 RC 时,我总是有一种违背 Rust 所追求的内存管理哲学的感觉。因为这意味着你不再严格管理对象的生命周期了,而是通过引用计数来共享对象。这并不总是理想的选择,但在某些情况下确实很有用。
德国字符串的结构
1 | struct UmbraString { |
根据 Rust 的布局规则,SharedBytes 的大小为 16 字节,内存对齐为 8 字节。这导致 UmbraString 的大小为 24 字节,内存对齐为 8 字节。不过,这不是我们想要的布局。为了使 UmbraString 的大小为 16 字节,我们可以将 SharedBytes 的内存对齐调整为 4 字节,并使用 #[repr(packed(4))] 来确保大小正确。然而,Rust 的 Linter(Clippy)可能会对我们发出警告,特别是在我们引用 SharedBytes 的指针字段时,因为这可能导致对未对齐的地址的引用,而这是 Rust 中的未定义行为。
Rust 中的比较与排序
在字符串比较或者排序时,我们希望优先处理前缀字段。通过当前的布局,我们必须先检查字符串长度,确定 data 是缓冲区还是指针,然后才能访问前缀。为了优化这一点,我们可以在 SharedBytes 的第一个字段放置前缀,这样我们就可以在无需检查长度的情况下直接访问前缀。
动态大小类型(DSTs)
大多数 Rust 类型具有静态已知的大小和对齐方式,但动态大小类型(DSTs)则是个例外。Rust 提供了两种主要的 DSTs:trait 对象(dyn trait)和切片(slice)。因为 DSTs 的大小在编译时未知,所以它们不能直接放在栈上,必须通过指针访问。因此,指向 DST 的指针是一个胖指针,它包含指针本身以及关于数据的额外信息(例如切片的长度)。
这也意味着我们不能简单地用 Arc<[u8]> 来实现德国字符串,因为这会导致 UmbraString 的大小增加到 24 字节,而不是我们期望的 16 字节。
以上就是关于如何在 Rust 中实现德国字符串的讨论。通过这些优化,我们可以有效地减少内存分配,提升字符串处理的性能。
因为我们可能会创建对未对齐地址的引用,而这是 Rust 中的未定义行为,尽管我们永远不需要创建这样的引用,但这使得我们无法使用一些很好的指针 API。长度,填充,前缀,填充,指针——嗯,这不是 UmbraString 的长字符串布局。看着这个布局感觉不太对,确实不对。
哦,把聊天框往上移一点,好的,好的,抱歉,我把聊天框放在那里了,避免与提醒相交。让我先抓住提醒框,然后把它移开。好了,移动完成。
Rust 的 Linter 叫 Clippy,没错。我拒绝使用 Rust 的 Linter,因为它的建议有时很糟糕。我喜欢 return 关键字,Rust 你随便吧,我不在乎。
一个更麻烦的问题是,这个布局让字符串的比较和排序变得复杂。当我们比较或排序字符串时,我们希望首先处理前缀。然而,使用这种布局,我们必须先检查字符串长度,以确定 data 是缓冲区还是指针,然后才能访问前缀。为了解决这个问题,我们可以用 #[repr(C)] 或 #[repr(transparent)] 标记所有内容,并依赖这样一个不变性:前缀是 SharedBytes 中的第一个字段。这样我们就可以在不检查长度的情况下将 data 解释为缓冲区,并且只访问前 4 个字节时仍然是安全的。
有一个更好的方法,不需要依赖于这种不变性,也不需要担心字段的顺序。如下所示:
现在,UmbraString 包含长度和前缀。结构体中的联合体现在可以是 8 字节或手动销毁的 SharedBytes。在内存上,与使用 #[repr(C, packed(4))] 的版本相比,没有任何区别。我们新的联合体只占用 8 字节,包含字符串的最后 8 字节或指向堆分配缓冲区的指针。这里我们将前缀从联合体中拉出来以便于访问,并且 Rust 的布局规则可以在不需要我们过多干预的情况下工作。
将前缀定义为一个 4 字节数组也提高了性能,因为比较固定大小的数组要比比较切片快得多。
我从来不知道 Rust 有联合体。我很好奇,使用联合体的语义是什么?你能对联合体进行匹配吗?比如说,如果它是缓冲区,那就处理缓冲区,如果是指针,那就处理指针?如果是这样的话,它怎么知道是缓冲区还是指针呢?
这是我有点困惑的地方,因为这不像一个带标签的联合体(tagged union),你不能对它进行匹配。
我猜测你确实不能进行匹配,因为它没有标签。你必须检查一个比特位来指定是否是短字符串。是的,没错,某个地方有一个位表示这是短字符串。好的,明白了,你在手动理解联合体的意义。非常酷,我没想到 Rust 也能做到这一点。
今天我才知道,Rust 竟然有联合体。我一直以为它只有枚举(enums),没想到它还有联合体(unions)。
它们是否可以同时有效?技术上说,联合体中的两者确实可以同时有效。我之前不知道你可以这样做,太酷了。
使用原子引用计数的 SharedBytes
SharedBytes 是采用原子引用计数(ARC)的字节切片,它像 Arc 一样工作。这引发了一个问题:为什么不直接使用 Arc<[u8]>?
动态大小类型(DSTs)
Rust 中的大多数类型都具有静态已知的大小和对齐方式,但动态大小类型(DSTs)是个例外。Rust 提供了两种主要的 DSTs:trait 对象(dyn trait)和切片(slice)。因为 DSTs 的大小在编译时未知,所以它们不能直接存放在栈上,必须通过指针访问。因此,指向 DST 的指针是一个胖指针,包含指针本身以及关于数据的额外信息。在切片的情况下,额外的信息就是长度。
这导致 Arc<[u8]> 的大小为 16 字节,而不是通常的 8 字节,使得 UmbraString 的大小变为 24 字节,而不是我们期望的 16 字节。
这样就完全说得通了。我明白为什么他们不这么做。
大家理解这点吗?问题在于,当你有一个切片时,你不能在编译时知道它的长度。你需要知道将什么放在栈上,什么放在堆上。
因此,这个长度是未知的,所以它必须有两个部分——一个是指针,另一个是长度。它必须能够知道如何将其放到栈上。因此,你不能简单地这样做,也就是说,不能在数据本身或指向数据的指针之间进行简单选择,具体由这个指针来指定。
由于 Rust 不处理位字段(bitfields),这让问题变得更加复杂。在 C++ 中,你可以通过位字段使这件事变得简单很多。是的,这不是未知的未知问题,没错。我们是在处理内存布局吗?确实如此,这实际上很有趣,我非常喜欢这种讨论。
关于 UmbraString 布局,使用 Arc<u8>。我们知道 UmbraString 已经跟踪了长度,因此与胖指针相关的额外长度可以被丢弃,没错。这就是为什么它只是一个指针,因为你已经有了长度。我们需要一种方法将胖指针转换为瘦指针,而这在目前的 Rc<T> 中是不支持的。目前还没有办法构造一个动态大小类型(DST),我们只能通过使其泛型并执行不定大小的类型转换来创建一个自定义的 DST 实例。
我不太理解这个部分,因为我还没有深入到 Rust 的这一层次,所以我不太理解描述的问题。我们的结构在描述一个引用计数的字节,它与之前的例子稍有不同。好吧,我们重新定义了 SharedBytes,它是一个指向非空指针的类型。我不太明白这个泛型是做什么的。这是说它是一个 0 字节的数组吗?我不理解这个。
而且同样,这个我也不太理解。是的,我在这里有点跟不上了,我不理解。这是原子引用计数的部分,是一个 ZST(零大小类型)。我不确定他们在这里做什么。
很多人也不理解,然后是 data 字段。一个结构体可以通过其最后一个字段是动态大小类型(DST)来使其成为 DST。在这里,SharedBytesInner 持有一个引用计数和一个对不定大小类型的泛型值。SharedBytesInner 总是分配在堆上,并且原子计数跟踪当前被分配的引用数。通过同时分配引用计数和数据,我们可以避免一次额外的分配和一次间接引用。
虽然它是泛型的,但我们只关心该类型的两个特化版本,它们具有相同的结构对齐和字段。SharedBytesD 和 SharedBytesPointer,这两者的引用计数分别为 2 和 1。
我们明白了,SharedBytesInner 是动态大小类型,指向它的指针是胖指针。而 SharedBytesPointer 是静态大小类型,指向它的指针是瘦指针。哦,这就是他们所说的意思。哦,好的,所以通过使类型具有已知大小,就不会有胖指针。换句话说,它可以去掉长度字段,因为长度已经是已知的。我觉得这就是我所理解到的。太神奇了。
你已经超越了我的能力范围。一个很酷的技巧是,我们可以在胖指针类型 &mut SharedBytesInner 和瘦指针类型 &mut SharedBytesPointer 之间进行转换。这个转换允许我们根据使用需求添加或移除关于切片长度的信息。将胖指针转换为瘦指针很简单,只需一个快速的操作即可完成。
胖指针 -> 瘦指针,没错,但将瘦指针转换为胖指针还有更多的“黑魔法”。我们不能直接进行转换,而是必须首先将瘦指针转换为指向特定长度的切片的指针,这样才能告诉 Rust 将长度信息嵌入到指针中。只有这样,切片指针才能被转换成指向我们自定义 DST 的指针。
从瘦指针转换为胖指针的过程如下:瘦指针 -> 假切片指针 -> 标准切片指针(slice::from_raw_parts)-> 最终转换成胖指针。
现在我们可以在瘦指针和胖指针之间进行转换了,接下来我们需要为其分配内存。除非我们能够将静态大小类型强制转换为动态大小类型,否则我们只能通过手动分配来创建一个 DST 的值。在此之前,我们必须定义一个布局并分配值。我们有一个新的布局,它扩展了 u8 数组的布局。预期的布局是这样的:.0 应该是某种类型的元组。然后我们对齐布局,完成分配。
让我们继续!这个 .0 到现在为止仍然让我感到有些困惑。通过 slice::from_raw_parts_mut 创建切片不是不安全的吗?是的,没错,你可以通过这里看出我们进入了不安全模式。在 Rust 中,当你使用指针时,你就进入了不安全区域。在安全的 Rust 中,你使用引用,而不是指针。当你进入不安全的 Rust 时,你会看到像这样的东西,你现在进入了不安全区域,除非我完全理解错了。
所以 .0 是元组中的第一个元素。那么安装向导会为我做这些黑魔法吗?也许。这个 Zero 会和联合体有关吗?有可能,但不涉及底层结构。我假设从这里返回的东西是某种选项类型(Option),它是你可以调用 expect 的东西。所以它要么是 Option,要么是 Result 或其他类型。你可以通过程序中止来解包它,然后提取第一个元素,它是某种元组。
不安全的转换确实让人有点头疼。这个布局是通过首先为 SharedBytesInner 创建一个布局来构造的,其中 data 字段是一个零大小类型(ZST),因此不占用空间。然后我们将该布局扩展为 u8 数组的布局,这样我们就得到了一个带有 u8 切片的 SharedBytesInner 布局。通过这个布局,我们可以分配足够的空间,用于我们的原子计数和任意大小的字节数组。
经典操作,我们只需要这样做就行了。
经典,经典,我们必须这样做,老实说。
我,嘿哥们儿,为你感到高兴,或者抱歉哈。胖指针指的是胖指针羞辱(fat shaming),这基本上就是 C++ 的感觉。实际上这比 C++ 还要难。你现在已经正式让 Rust 比 C++ 更令人困惑了,恭喜你,或者我为你感到抱歉。看起来没问题。是的,Ted 是对的,Ted 真的没错。
我们采用了一种懒惰的分配方式:如果给定的切片为空,则不分配任何东西。如果不为空,我们执行以下操作:使用我们定义的布局进行分配,将返回的指针转换为我们的 DST 的胖指针,将给定切片中的数据复制到数据字段中,再将胖指针转换回瘦指针并存储它。完成了,这就是这个过程,这是胖到瘦的转换,对吧?是的,很多 copy_nonoverlapping 操作。是的,我现在完全跟不上 Rust 了,我只能模糊地跟着流程,但我还是想完成它,因为它真的很难。
不安全的 Rust 听起来像是 C,实际上比 C 难得多,因为你仍然需要做所有的操作。让我看看你能不能搞定,做吧。由于我们的引用计数类型不包含它所持有的数组长度的信息,我们无法为它实现 Drop。相反,我们提供了一种方法,允许用户通过给出正确的数组长度来手动释放内存。我们提供了 deallocate_unchecked 方法。
如果长度大于零,inner 就变为不安全指针。如果引用计数等于 1,那么我们需要释放它,因为这意味着你是最后一个引用它的人。我们使用 Atomic::load 来获取引用计数,并且如果它等于 1,我们调用 deallocate 来释放内存,将指针转换为 u8 类型。
是的,我之前没有做过这些操作,所以现在我进入了深入的不安全 Rust 阶段。不安全的 Rust 之所以难,是因为它让不安全的 C 变得安全。是的,这确实是一个公平的说法。除非你在做特定的低级项目,否则你不会每天都做这些事情。确实如此,Rust 有时候很难,当它真的变难时,难度真的很大。
当这个方法被调用时,首先检查给定的长度是否为零。如果为零,说明没有分配内存,我们不需要做任何操作。如果内存已分配,我们减少引用计数,看看我们是否是唯一的所有者。如果是,我们使用之前定义的布局来释放内存。
同样地,我们也无法为我们的类型实现 clone,并且需要依赖用户提供的长度来判断内存是否已分配。克隆涉及的操作不是复制数据,只是增加引用计数。是的,我不知道“non-al dangling” 是什么意思。我不知道为什么我们会出现悬空指针。
然后,我们获取指针。如果长度等于零,没问题,仍然是 PhantomData 切片。获取底层数组的切片也依赖于用户提供的长度,并且分两步完成:将瘦指针转换为胖指针,使用指向数据字段的指针和用户提供的长度创建一个切片。
这里就是胖指针到瘦指针的转换,对吧?不,这是胖指针,然后我们真正获取它,并从这里返回。或者我们只是返回一个零长度的默认值。然后这个是我们实际在不安全操作中执行的部分。
哇,当我睡不着的时候,我会读这篇文章。把所有必要的组件放在一起后,我们现在可以开始实现 UmbraString。虽然我们还需要很多工作来提供一个可用的字符串,但我们这里只展示了一些基本功能。
分配和创建字符串是直接的,我们复制前缀,同时处理两种情况:如果字符串接近 12 个字节,我们将后缀内联处理,否则我们分配一个新的 SharedBytes 来保存字符串的内容。
我相信任何人最终都能理解这一点,对吧?只是可能需要一点努力。你有了字节和长度,如果超过了最大尺寸,就无法内联处理。我们提取前缀和前缀长度,通过切片操作获取后缀。如果少于内联长度,我们就不需要额外操作了。否则,我们必须做一些写入操作,然后从一边复制切片到另一边,并使用手动释放的指针创建后缀。
然后我们返回这个带有长度、前缀和后缀的结构体,并允许 Kippy 进行可能的截断转换。
现在我们可以通过为 UmbraString 实现 Drop 来提供内存释放功能,因为它已经有了所有必要的信息。我们通过检查长度来判断是否有堆内存需要释放。如果有,我们调用之前介绍的 deallocate_unchecked 方法。
Drop 函数:我们为 Drop trait 实现了 Drop 函数。该函数获取一个可变的 self,我们获取长度,如果长度大于内联长度,那么我们需要执行这个操作,对吧?我们需要从这里开始释放它。
好的,以下是整理后的内容:
我们现在可以实现 clone 方法,它来源于 umber 的字符串。当字符串是内联的(inlined)时,我们会将字节复制到一个新的实例中;否则,我们复制前缀,并将 CloneUnchecked 方法委托给共享的动态字节(shared dynamic bytes)。这就是你如何进行“廉价克隆”的地方。不过,我猜我们不应该称它为廉价克隆,它实际上是一个原子引用克隆(atomic reference clone)。至于它是否比克隆某些字符串更便宜或更贵,我并不清楚。我也不知道原子操作在何时比克隆某些字符串更昂贵或更便宜。
部分相等比较(Partial Equals Comparison)首先使用前缀进行检查。对于大多数字符串,这个检查会失败,方法会立即返回。结果是,许多比较不需要读取字符串的其余部分,只需检查前缀即可。顺便说一句,我觉得这个方法非常巧妙,能将前缀存储在栈上,从而使得比较变得如此之快,简直令人叹为观止。针对固定大小数组的比较也非常快,并且得到了很好的优化。此外,如果两个字符串都很短且是内联的,我们可以在不进行指针解引用的情况下比较最后的八个字节。指针解引用可能只会发生在调用后缀比较时,当字符串未对齐时,这是一种非常了不起的优化。
我真的是被这个优化惊呆了。这么做的好处是什么呢?它表明了在 Rust 中这种优化是可能的,而这在其他语言中可能是非常艰难的。如果我直接跳进 C 而没有扎实的 C 和 C++ 基础,我会错失很多东西。它可能会非常困难。我自己并没有非常扎实的 C++ 基础,我的 C 基础还算不错,但我肯定不是世界上最好的 C 程序员。尽管如此,我还是立刻看出了 Rust 的酷炫之处。它真的非常整洁,但我不会说它简单。
部分排序(Partial Ordering)也采取了与比较类似的执行路径。如果可以仅通过前缀确定顺序,我们会立即返回。只有当前缀相等时,字符串后缀才会进行比较。通过检查两个字符串是否内联,也可以避免指针解引用。因此,部分比较可以这样做:我们可以对左边和右边进行比较,如果它们相等且内联,或者需要进行后缀比较。太酷了,真的太酷了。我觉得这些优化太赞了,你只需要做一次前缀比较,就能得到结果。哇,我真的很喜欢这个优化。
我几乎让不可能的事情成为了可能。在 Rust 中实现德语字符串需要对 Rust 的内存模型和类型布局有深入理解,但相对来说还是比较简单的。我想对 Rust 公平一点:Rust 的设计目标之一是创建一种内存安全且速度接近 C 的系统级编程语言,它允许你决定内存布局。然而,这些事情其实非常难做。
从写这篇文章的作者的角度来看,由于他已经具备了相关的预定义知识,比如理解 Phantom 的用途以及如何正确使用它,理解布局以及 Rust 如何在底层进行内存分配(实际上就是调用 malloc),所以对他来说这比较容易。至于我,我并不知道如何调用 malloc,也从未做过这些事情。
因此,你可以创建一个相对安全的版本,虽然实现起来比较困难,但却非常令人印象深刻。这意味着你依然可以拥有所有的速度和其他好处。只是对我们来说,这看起来非常复杂,因为我们大多数人(至少在这个聊天中的人)不具备足够的 Rust 专业知识来理解这一切。所以我并不想贬低 Rust,因为这些东西看起来有些令人费解。
把这一切整合在一起时,确实看起来有点多。所以,我并不想因为 Rust 难而贬低它,因为这是 Rust 的一个非常边缘的案例。而真正可以批评 Rust 的地方是简单的性能优化。是的,Rust 的生命周期(lifetimes)确保了安全性,但为了处理生命周期,你可能最终会让结构体(structs)和函数也受到影响,一旦有了一个生命周期,它就会传播开来。我想 Ryan Winchester 在他的个人资料顶部可能还有一个有趣的 Meme,如果他还有的话,我希望他保留着。是的,你可以用生命周期做和 async 函数一样的事情,只不过是函数带有生命周期。一旦这样做了,所有东西都需要拥有这个生命周期。Ryan,我特意为你做了这个,谢谢你。我每个月都会去看一次这个 Meme,并且展示给大家看。Ryan,你永远不能改变这个 Meme,它是一个很棒的 Meme。
以上是整理并翻译的内容。
以下是整理后的内容:
你知道吗,一旦涉及到生命周期,所有东西都必须拥有这个生命周期。我是专门为你做的,Ryan,谢谢你。我真的每个月都会去看一次这个 Meme,并展示给别人看。Ryan,你永远不能改变这个 Meme,这是一个很棒的 Meme。
实际上,如果你想的话,你当然可以改变它。我觉得这对批评 Rust 来说是一个更公平的点。因为实际上你可能会写五年的 Rust 代码,却从未遇到这些问题;但如果你写 Rust 一年,肯定会遇到生命周期的问题。Rust 的生命周期需要重新考虑,生命周期可能会极其复杂。我觉得 Rust 的问题是它作为系统级编程语言,必须以这种方式设计,而 OCaml 由于有垃圾回收机制,能够避免很多这些问题。因此,在 OCaml 中,生命周期可以与函数关联,而不是与数据类型关联,这让编程更加友好。
不过,我不认为生命周期需要被重新设计。如果你重新设计它,可能会毁掉这门语言。当然,或许有一个更简单的前进方向,你可以在不引入所有复杂性的情况下处理生命周期。但我不确定这是否可能,我没有深入思考过 Rust 以至于能确定这一点是对还是错。不过,无论如何,我知道 Rust 的发明者 Gren 曾提到过,可能有更简单的方式来处理生命周期。所以,我不确定是否真的可以简化。
是的,生命周期并不疯狂,但它们确实会成为巨大的麻烦。当你试图从一个克隆系统转向一个生命周期系统以提升性能时,这需要付出大量的努力。而且即使这样,它们也不能防止内存泄漏。是的,在 Rust 中你可以任意地出现内存泄漏,Rust 并没有声称可以防止这一点,这也不是 Rust 的目的。
生命周期作为一个概念是简单的,但重构过程却很难。重构确实会变得非常棘手,尤其是当你引入 async 后,问题会变得更加复杂。如果这说得通的话。无论如何,这是一篇很棒的文章。文章开篇说在 Rust 中这是一个“不可能的类型”,但随后展示了通过 unsafe 和对内存布局的深刻理解,实际上是可以实现的。这很酷。
我必须说,Rust 的一个酷点就是你可以花五年的时间去深入理解它,并成为这门语言的专家。语言允许你不断深入掌握,这一点非常美妙。但这也是一把双刃剑,因为你还得和那些语言专家合作,而要理解他们的代码可能需要多年训练。这真的是一把双刃剑,但也很酷。
你是否曾和 C++ 大师一起工作过?我曾经在一个五个 C++ 大师的团队中工作过。我只是想说,情况会变得非常疯狂,你可能会觉得:“天哪,我完全搞不懂这里发生了什么。”这也是为什么我真的很享受使用 Zig 的原因之一。Zig 非常直接,几乎就是从你脑海中的想法直接映射到计算机中的实现。哦,你需要在堆上创建一些东西?那你必须调用 malloc。哦,你需要它在栈上?那你就直接创建它。Zig 的使用体验真的非常好。我真的很享受与 Zig 一起工作的时光,因为你不需要 Rust 的所有复杂特性,但你仍然可以编写相对安全的程序,比 C 更安全的程序。
总之,名字……嗯,那就是我的名字,严格来说,你刚刚听到的确实是我的名字。这个名字很长,我觉得它甚至可以和德语中的某些长词相媲美。
以上是整理并翻译后的内容。
Rust与RAII内存管理
https://www.bilibili.com/video/BV1mEHQeMEk1/
以下是整理后的内容和中文翻译:
之前你做过一个关于垃圾回收的视频。我们需要资源来运行程序,但如果我们长时间保留这些资源,就会出现问题,因为其他人无法使用它们,最终我们会耗尽内存。垃圾回收是一种解决这个问题的方法。不过,正如那个视频中提到的,垃圾回收本身需要消耗内存和 CPU 时间,这并不理想,因为如果你真的想让某个程序运行得非常快,你会希望尽可能减少消耗的资源。
今天我们要讨论的是一个叫做 RAII 的概念,资源获取即初始化(Resource Acquisition Is Initialization)。特别是我们要讨论一种叫做 Rust 的语言,它将这个概念内建到语言中,以尽可能防止程序员在内存管理方面犯错误。
Rust 并不是一个小众语言,但也许没有其他一些语言那么知名。所以我们可以先展示一个简单的“Hello World”程序。如果我在这里创建一个新文件 hello.rs,我们从 main 函数开始。
这看起来有点像 C,是吗?是的,确实有点像 C,它确实借鉴了 C、Java 这类语言的通用语法。所以如果我在这里打印“Hello World”,并关闭它,你可以看到它的基本结构看起来非常熟悉。我们使用 fn 来声明一个函数,而不是像 C 那样从返回类型开始。如果我想返回某个值,它会出现在函数名后面,但基本语法并没有太大的不同。
Rust 和 C、C++ 等语言的第一个不同之处在于变量声明的方式。我们不是先写类型、然后写变量名和赋值,而是使用 let 关键字,先写变量名,然后指定类型。比如我们声明一个 i32 类型的变量,并赋值为 10。这个语法相对于 C 和 Java 来说有点不同,但也没什么太大的区别。
第一个有趣的地方是 Rust 尝试防止你犯错的机制。如果你想要修改一个变量,你必须显式地告诉它你想这么做。在 Rust 中,普通变量和可变变量是有区别的。如果我想修改一个变量,我必须显式地声明 let mut,比如 let mut y: i32 = 20。一些其他语言也有类似的机制,比如 Kotlin 区分 val 和 var。在 TypeScript 中,你使用 let 来声明可变变量,使用 const 来声明不可变变量。一些其他语言也在逐步采用这种机制。但 Rust 是第一个通过要求你显式声明可变性,来帮助你避免犯错的地方。
这可以确保你的变量始终具有你期望的值。
我现在先展示 C++,因为这样更容易向那些没有接触过 Rust 的人解释。然后我们再切换到 Rust,展示它是如何将这些概念内建到语言中的。在 C++ 中,你必须手动管理内存,而在 Rust 中,这些机制几乎是内置的。
RAII 的基本思想是,当你构造一个对象时,你为这个对象分配所需的内存,并确保在对象销毁时释放这些内存。这个想法是,你请求的内存的生命周期与使用它的对象的生命周期绑定在一起。这意味着你不需要在其他地方担心释放内存,也不需要担心在其他地方分配内存。
当我创建对象时,我分配内存;当我销毁对象时,内存自动释放。这对程序员来说是一种解脱,因为我不再需要担心悬空的内存。
让我们在 C++ 中做一个简单的例子。假设我们有一个类叫做 Bob,它将持有一些整数。我们创建一个类变量 n,它是一个指向整数的指针。然后我们有一个构造函数,用来初始化 Bob,我们传入一个参数,表示 Bob 要持有多少个整数。接着我们分配相应的内存:
1 | n = new int[x]; |
这在 C 中会调用 malloc,而 C++ 用了一个更友好的关键字来处理这个问题,但本质上它们做的是相同的事情。
然后在析构函数中,我释放这块内存:
1 | delete[] n; |
在这里,当我创建 Bob 时,我分配了足够的内存来存储整数;当 Bob 超出作用域或被删除时,他会释放他使用的所有内存。
这很好,因为我不再需要跟踪 Bob 的内存。只要 Bob 超出作用域,内存就会被释放,减少了内存泄漏的机会。不过,这仍然要求程序员自己实现这个机制。如果我忘了为某些资源释放内存,仍然可能发生内存泄漏。
这就是 Rust 试图改进的地方。Rust 想要防止程序员犯这些错误。
让我们写一段简单的 Rust 代码展示这些概念。首先,Rust 没有类,它使用结构体(Structs)。假设我们有一个结构体叫做 Bob,它持有一个 Vec<i32>,类似于 C++ 版本中的 Bob 持有整数的数组。
1 | struct Bob { |
当 Bob 被创建时,他会在堆上为他的整数分配内存。这是 RAII 的基本原则,内存的生命周期与对象的生命周期绑定在一起。当 Bob 超出作用域时,内存会自动释放。
Rust 中的每个变量都有一个所有者(Owner)。每块内存都有一个所有者。当所有者被删除或超出作用域时,所有者控制的内存也会被释放。这种机制是内置的。
让我们继续写一个 main 函数:
1 | fn func1() { |
在这个例子中,n 是在 func1 中创建的 Bob 实例。当 func1 返回时,n 超出作用域,Bob 的内存自动被释放。这就是 RAII 的原则,它完全内建在语言中。
Rust 确保每块内存只有一个所有者,并且当所有者超出作用域时,内存会被释放。如果我们想要让两个变量指向同一块内存,Rust 会通过一种叫做“移动”(Move)的机制来处理。
1 | let m = n; |
在这里,Rust 并没有复制 n,而是将 n 的所有权移动给了 m。之后,n 不再有效,如果你尝试使用 n,编译器会报错,因为它的所有权已经被移动。
Rust 还提供了“借用”(Borrowing)机制,这允许你临时访问数据而不改变其所有权。通过借用,你可以在不改变数据所有者的情况下读取或修改数据。
1 | fn func2(b: &Bob) { |
在这个例子中,func2 通过引用借用了 n,而 n 仍然保持它的所有权。借用结束后,n 仍然有效。
Rust 还区分可变引用和不可变引用。如果你想修改通过引用借用的数据,必须显式声明它是可变的:
1 | fn func2(b: &mut Bob) { |
Rust 不允许同时存在可变引用和不可变引用,以确保数据的一致性。
通过这些机制,Rust 确保了内存的安全性,避免了悬空指针和数据竞态问题。这就是为什么 Rust 在内存管理方面如此受欢迎,它为程序员提供了多种管理内存的方式,同时避免了常见的错误。
这就是 Rust 中关于 RAII、内存管理、所有权、借用和移动的基本概念。
Iterators in Rust
翻译并整理后的中文内容
欢迎回来,RustGang!如果你是新来的,我的名字是 Bogdan,这个 YouTube 频道完全是关于 Rust 编程语言的。所以如果你想每周看到 Rust 的视频,请点击订阅按钮。
在上一个视频中,我们开始了《Rust 编程语言》这本书的第 13 章,讨论了闭包。如果你还没有看过那个视频,记得去看看。在这个视频中,我们将继续第 13 章,讨论迭代器。那么,准备好让我们开始 Rust 之旅吧!
[音乐播放]
在我们进入代码之前,先来谈谈什么是迭代器模式。迭代器模式允许你遍历一系列元素,而不管这些元素是如何存储的。例如,元素可能存储在一个数组中,但它们也可以存储在一个哈希表中、一个图中,或者是你自己创建的自定义数据结构中。迭代器封装了遍历这些不同数据结构的逻辑。
遍历一个数组很简单,你从索引 0 开始,然后递增索引。但遍历一个哈希表或自定义数据结构就不那么明显了。因此,迭代器会封装遍历这些数据结构的逻辑,这使得你可以以统一的方式遍历各种数据结构。
了解了这些背景知识后,接下来我们进入代码示例。
在下面的例子中,我们创建了一个名为 v1 的变量,它是一个向量,然后我们创建了一个名为 v1_iter 的变量,这将是该向量的迭代器。我们可以通过调用 v1.iter 来创建这个迭代器。注意,在 Rust 中迭代器是惰性的,所以在第 4 行创建迭代器时,并不会发生什么特别的事情,直到我们真正使用这个迭代器。
接下来让我们在 for 循环中使用我们的迭代器。我们输入 for value in v1_iter,然后打印出 value。让我们运行程序,看看结果。你会看到我们得到了向量中的值。
这里很棒的一点是,我们的 for 循环能够直接使用迭代器,而不需要额外的代码。这是因为迭代器是 Rust 标准库的一部分。
在这个例子中,我们使用了向量,但再次提醒,迭代器可以为任何数据结构实现。并且注意到,在我们的 for 循环中,我们没有任何逻辑来指定如何遍历元素序列,因为这些逻辑已经封装在迭代器中。
现在让我们来谈谈迭代器是如何工作的。Rust 中的所有迭代器都实现了 Iterator trait,该 trait 定义在 Rust 标准库中,大致是这样的:
1 | trait Iterator { |
注意,这里有一些新的语法。我们有一个名为 Item 的类型,然后我们在 Option 返回值中使用了这个 Item 类型。这被称为关联类型(associated type),我们将在第 19 章中详细讲解这种类型。
这段代码的意思是,当你实现 Iterator trait 时,你还需要定义 Item 类型,而该类型将从 next 方法返回。正如你所看到的,迭代器其实很简单,你只需要实现一个方法,就是 next 方法。其他所有方法都有默认的实现。next 方法返回迭代中的下一个元素,并将其包裹在 Some 中,当迭代结束时,它返回 None。
注意,我们还需要对 self 的一个可变引用,这是因为调用 next 会改变迭代器的内部状态,用于跟踪它在序列中的位置。
在 Iterator trait 下方,我们可以定义一个测试函数来演示 next 方法是如何工作的。
在这个例子中,我们创建了一个包含三个元素的向量,然后我们创建一个变量来存储我们的迭代器。注意,这个变量必须是可变的,因为我们即将调用 next,而 next 需要一个可变引用来操作迭代器。接下来,我们有一些 assert_eq! 语句。当我们第一次调用 next 时,我们期望得到一个包含 1 的 Some。下一次调用时,我们期望得到 2,再下一次是 3,最后我们期望得到 None,因为我们已经到达序列的末尾。
注意,我们的迭代器返回的是不可变引用。如果我们想要可变引用,应该使用 iter_mut,如果我们想要获取所有权的类型,则可以使用 into_iter。
让我们运行测试函数,你可以看到它通过了。
Iterator trait 有许多方法,它们在标准库中都有默认实现。它们大致分为两类:适配器(adapters)和消费者(consumers)。适配器接收一个迭代器并返回另一个迭代器,而消费者接收一个迭代器并返回其他类型的值,比如一个整数、集合或其他类型。
这里有一个使用 sum 方法的例子。我们有一个包含三个元素的向量,然后我们创建一个迭代器,最后我们对迭代器调用 sum。sum 会反复调用 next 方法以获取序列中的每个元素,然后将这些元素相加并返回总和。在这个例子中,我们将总和存储在一个名为 total 的变量中,并断言 total 等于 6。
让我们运行测试套件,这次我们有两个测试通过了。
接下来让我们看看适配器方法。一个这样的方法是 map,它接收一个闭包并创建一个迭代器,该迭代器会对序列中的每个元素调用闭包。注意,我们在这里得到了一个警告,提示 map 未使用,因为 map 返回了一个迭代器,而且记住,Rust 中的迭代器是惰性的,所以它实际上不会做任何事,直到我们使用一个消费者方法。
让我们通过使用 collect 消费者方法来修复这个问题,collect 会将迭代器转换为一个集合。接着我们断言向量中的所有元素都被加 1。
让我们运行程序,看到它没有发生 panic,这意味着我们得到了正确的向量。
接下来这个例子中,我们将看看如何使用带有捕获环境的闭包来配合迭代器使用。我们有一个 Shoe 结构体,表示一只鞋子,其字段为 size(鞋子的尺码)和 style(鞋子的风格)。在结构体下方,我们有一个函数叫做 shoes_in_my_size,它接受一个鞋子的向量和一个鞋码。这个函数的目标是过滤鞋子的向量,只保留符合指定鞋码的鞋子。
让我们看一下函数签名。我们接收一个名为 shoes 的参数,它是一个鞋子的向量。注意,这不是一个引用,因此我们实际上获取了该向量的所有权。接着我们接收另一个名为 shoe_size 的参数,它是一个整数。最后我们返回一个鞋子向量。
在函数体内,我们对 shoes 向量调用 into_iter,这将创建一个迭代器,该迭代器获取向量的所有权。然后我们调用 filter 方法,它接收一个闭包,并对迭代器中的每个元素调用这个闭包。filter 会创建另一个迭代器,如果传递给 filter 的闭包对某个特定元素返回 true,那么该元素将被包括在结果迭代器中;如果返回 false,则该元素不会被包括在结果迭代器中。
最后,我们调用 collect,它会将 filter 返回的迭代器转换为一个集合。在这个例子中,我们的集合将是一个鞋子的向量。
现在回到 filter,在这个闭包中,我们检查当前鞋子的尺码是否等于传入函数的鞋码。这是闭包捕获其环境的一个例子。shoe_size 并没有在闭包中定义,但我们仍然可以访问它。
在函数下方,我们有一个测试模块,里面有一个叫做 filters_by_size 的测试。在这个测试中,我们创建了一个名为 shoes 的变量,它是一个鞋子的向量。第一个鞋子的尺码是 10,然后是一个尺码为 13 的鞋子,最后是另一个尺码为 10 的鞋子。接着我们调用 shoes_in_my_size 函数,并传入我们刚刚创建的 shoes 和鞋码 10,然后将结果存储在一个名为 in_my_size 的变量中。最后我们断言 in_my_size 是一个只包含尺码为 10 的鞋子的向量。
让我们运行测试套件,结果显示 filters_by_size 测试通过了。
现在我们已经学习了如何使用迭代器,在最后一部分中,我们将学习如何实现我们自己的迭代器。为了演示,我们的迭代器将只会从 1 数到 5。首先,我们将创建一个结构体来保存一些值。在这个例子中,我们的结构体名为 Counter,它有一个字段叫做 count,这是一个整数。接着我们将实现一个构造函数,叫做 new,它将构造一个新的 Counter 并将 count 设置为 0。count 字段将用于跟踪我们在 1 到 5 的迭代过程中的位置。该字段是私有的,因为我们希望它只能通过我们的实现块访问。此外,由于我们有这个新的构造函数,它将 count 设置为 0,因此我们确保每次创建一个新的 Counter 时,count 都会初始化为 0。
接下来我们将为 Counter 实现 Iterator trait。我们在这里指定了 Item 关联类型,并将其设置为无符号 32 位整数。再次提醒,我们将在第 19 章中详细学习关联类型,但这里我们只是在说我们的迭代器将返回无符号的 32 位整数类型的元素。
然后我们实现了 next 方法,记住,next 是我们唯一需要实现的方法。next 接收一个对 self 的可变引用,并返回一个 Option 枚举,它包含一个 Item。在方法体中,我们检查 self.count 是否小于 5,如果是,我们递增 self.count,然后返回它。否则,如果 self.count 大于或等于 5,那么我们返回 None,因为迭代已经结束。
接下来我们添加一个测试函数,使用了我们的 next 方法。我们的函数叫做 calling_next_directly,在其中我们创建了一个新的 Counter 实例,然后调用 counter.next 六次。在前五次调用中,我们期望得到一个从 1 到 5 的数字序列,而在第六次调用时,我们期望得到 None,因为我们的序列已经结束。
让我们运行测试,结果显示测试通过了。
标准库为我们迭代器的许多其他方法提供了默认实现。让我们再写一个测试,利用这些默认的方法。
现在,这是一个非常人为的例子,但让我们来看一下它的过程。我们创建了一个名为 sum 的变量,它是一个整数,并且等于这个表达式。首先,我们创建一个新的 Counter,然后我们对它调用 zip 方法。zip 迭代器将两个迭代器组合成一个包含值对的迭代器。第一个迭代器是调用该方法的迭代器,第二个迭代器是传递给该方法的迭代器。在这里,我们创建了一个新的 Counter,并对它调用 skip 方法,这是一个适配器方法,意味着它会返回另一个迭代器。skip 将创建一个迭代器,跳过前 n 个元素,n 是传递的数字。在这个例子中,这个迭代器将跳过第一个元素。
现在 zip 会返回一个迭代器,所以我们对该迭代器调用 map 方法。map 接收一个闭包,并对迭代器中的每个元素调用该闭包。在这个例子中,每个元素是一个值对,因为我们刚刚调用了 zip 方法。在闭包中,我们将这两个元素相乘。然后我们调用 filter 方法,只保留能被 3 整除的元素。最后我们调用 sum 消费者方法,它会对迭代器中的所有元素求和。经过所有这些处理,sum 应该等于 18。
让我们运行测试套件,看看结果是否正确。结果显示,我们有两个测试通过了。
再次提醒,这是一个非常人为的例子,但它只是表明,简单地实现 next 方法,便可以让我们访问其他具有默认实现的方法。
这就是第 13 章的第 2 部分。如果你喜欢这个视频,记得点一下赞。在下一个视频中,我们将利用我们对迭代器的知识,来重构我们在第 12 章中制作的命令行程序。所以,如果你想在视频发布时收到通知,以及未来所有的视频,记得点击订阅按钮。就这样,我们下次见!
Rust:你的代码可以是完美的
翻译并整理后的中文内容
你好,朋友们!我叫 Tris,这里没有废话,专注于快速的技术视频。这是我重新上传的视频,包含了许多修复和更正。作为开发者,我们构建的是重要的基础设施,现在是时候用一种专为构建关键基础设施而设计的语言来进行开发了。
你有多少次见过这样的代码?这里可能会有什么问题?最大的隐患是,这行代码可能会在凌晨 4 点把你叫醒。因为不安全的假设会导致程序在运行时崩溃,并把我们在凌晨 4 点叫醒。这与 Python 解决方案同样不安全,但现在代码中明确指出了三个可能崩溃的地方。如果你想让它永远不会崩溃,你需要为这些 unwrap 找到替代方案。这里是一个详细的解决方案展示。
Rust 不会让你从一开始就写出不安全的代码,你必须处理所有的错误。一个符合 Rust 规范的解决方案是这样的。不要太担心具体细节,我只是想给你一点感受。
不健全的软件会伤害人们,并减缓人类的进步。我们现在有一个更好的解决方案来应对这些问题。Rust 是一种非常愉快的编程语言,但这不是我们今天要讨论的重点。我们今天要讨论的是如何构建完美的软件。
多年来,我一直在寻找可以让我的代码更加可靠或有保障的系统框架和方法。作为开发者,我们往往接受这样的生活——我们的生活被错误所支配,时常是像这样的错误。我今天告诉你的是,不必如此。
你可能听说过,Rust 提供了不依赖垃圾回收器的内存安全。我不在乎内存安全,我从未需要过它。Java、Python、Ruby、Node.js、Go 它们都通过一个与程序同时运行的第二个程序来确保内存安全,检查内存是否在不再使用时被释放。这通常被称为垃圾回收器(Garbage Collector)。
Rust 团队将无需垃圾回收器的内存安全作为一个需要解决的核心问题,因此他们实现了一种巧妙的方法来追踪内存,称为借用检查器(Borrow Checker)。但事实是,在解决内存安全问题的同时,Rust 团队意外地解决了我关心的所有问题。
通过创建一个能够深刻理解你代码的编译器和一个支持该编译器的强大类型系统,他们赋予了我们开发者所有的控制权和潜力,来构建完美的语言和生态系统。Rust 社区经过16年的努力,已经实现了这一点。
Rust 的语法类似于 JavaScript、Go 和 Java 开发者们熟悉的风格。即便是像我这样的 Python 开发者,也不会感到太困惑。这里有函数式风格的迭代器,但这只是众多零开销抽象之一,它们最终都会转化为简单的循环。
无论你的语言多么聪明,运行代码的处理器只理解位(bits)和少数操作符。Rust 能够让你写的高级代码直接落地到最底层的硬件,而不牺牲高级开发者的操作体验。
分号(;)终于有了正确的意义。行导向的语句源自穿孔卡片,而穿孔卡片是语句的原型。在 Rust 中,我们可以做得更好。
在 Rust 中,你会把数据看作是通过函数进行转换的迭代器。你可以对 Option 类型使用所有的迭代器方法。Rust 语言中没有空值(nulls)。在这个例子中,一个人要么是活着的,要么是死去的,并且一定生活在某个星球上。没有空值,也没有匿名对象。在 Rust 中,你告诉编译器世界是如何运作的,这样它就会对你写下的契约负责,并确保所有贡献你代码的人也遵守这个契约。
这可能对你来说是一种新的编程方式,但这是一个如此好的模式,以至于我现在尝试在写 Python 代码时也采用这种方式。
Rust 拥有业界领先的包管理器,解决了我们日常面对的所有依赖问题。这是一个专注于正确性的社区所带来的成果。
还记得 JavaScript 吗?我们不得不等待多年,才让异步编程(async/await)成为标准化的功能,但即便如此,我们仍然无法在所有浏览器中使用它。在 Rust 中,它被作为宏原型化。语言通过宏得以扩展,宏是编译时执行的代码,并作为简单库安装,和其他所有东西一样。宏将新的语法转换为类型安全的 Rust,然后再传入编译器。你不必为了使用今天的新特性而放弃 Rust 的安全性。
如果你用过 webpack 或者其他成千上万的 JavaScript 预编译器,你就会知道那些糟糕、容易出错且定义不完善的宏系统是多么令人头疼。下面是你在 Rust 的 web 框架 Rocket 中编写一个简单 GET 请求的代码。甚至不需要我的帮助,你可能就能读懂它。这是我们都见过的模式。第一行并不是注释,而是一个宏,它会在源码传递到编译器之前,丰富并重写封闭的函数。
这是一个简单的 Hello World HTTP 端点,内置编译级别的验证,保证了有效的 UTF-8 字符串,并具有基本的理解:人不应该是负年龄或超过 255 岁。虽然这种验证有点乐观,但我们可以做得更好,我们有技术能力。
别害怕,我保证我们可以一起搞定。让我们来分析一下这段简短的代码。顺便一提,它仍然使用 Rocket web 框架。你可以将它看作是 Express、Sinatra 或 Flask 的等价物。如果我们的程序能够编译,我们就知道很多事情是有保障的:
ID将是一个有效的 UUID,来自一个有效的 HTTP 路径。- 返回的 JSON 将始终符合我们设计的模式,名为
FormResponse,并且具有定义的值,作为我们与客户端之间永远不会破坏的契约。
SQLx 实际上会在我的本地开发数据库上运行那个查询,使用在类型上生成的有效测试输入,并在编译时回滚事务。如果它是无效的,我的代码将无法编译。是的,这就是“魔法”,Rust 的魔法。
到目前为止,一切都很棒,但还有更多内容:
- 没有内存泄漏。
- 没有 SQL 注入。
这些都在编译时得到了保证,你无需使用任何笨重的、低效的抽象。这一切都会被编译为接近 C 速度的 for 循环和 if 语句,并运行在裸机上。
但我们可以更进一步。通过添加 forbid unsafe code 指令,我们可以禁止不安全的代码块,因此也就禁止了与操作系统库(如 C 代码)的任何链接,确保我们的应用是纯 Rust 编写的,因此不会破坏我们之前的任何保证。
但是,那些你需要链接的原生库怎么办?比如 libpq(用于 PostgreSQL)、Pandas、Numpy 或 OpenSSL?Rust 社区已经将所有这些库都用纯 Rust 重写了。甚至连 OpenSSL 都没有逃过 Rust 的“氧化”(Oxidation)。过去 40 年是用 C 编写的,而接下来的 40 年将会是用 Rust 编写的。
写到这里,我本想邀请你一起参与 Rust 的早期发展,因为它将带来行业的变革。然而,当我写到这里时,我意识到你其实还有很多路要走。Rust 电梯目前已经到了第 16 层。GitHub 上的 Rust 项目数量已经超过了 Scala、Kotlin、Swift、CoffeeScript 和 Perl。是时候认真对待 Rust 了,先在你的个人项目和学习中使用它,随后在你的工作中使用它。因为现在我们的代码终于可以做到完美了。
如果你想看看你能用 Rust 写出什么,请点击上方的视频,我用它为我的科幻和心理健康播客《迷失终端》制作了一个有趣的复古计算机可视化。如果你想看更多我快速的技术视频,请点击下方的视频。GitHub 上有转录和 Markdown 源代码,链接在描述中,修正内容在置顶评论中。非常感谢您的观看,下次见!
13 个简单的 Rust 技巧
https://www.bilibili.com/video/BV1ZetFeoEAt/
整理并翻译后的内容
你不需要这样做,我的意思是,我对你写的代码没问题,但你可以直接那样做,而不是先解包(unwrap)然后再包装(wrap),你可以直接传递(propagate)。
你不需要这样做,如果你只是说 or self rate x = 10。你的输入已经是一个字符串切片,所以这个本身就可以是字符串切片(string slice),而不是重新创建一个字符串并把字符放进去。你可以直接对输入进行切片,这样就避免了创建一个新的字符串。
你有这个 if break break 模式,我觉得你可以这样做。
哦,不行,你不能合并这些吧?我不知道你能不能在那里用括号。你能用括号吗?你能这样写吗?我不知道你能不能这样写,但是如果你能这样写的话,或许可以试试看。
这个不能是 while 循环吗?如果可以这样做,那这可以是一个 while 循环,对吧?Rust 有 while 循环吗?你有 do while 吗?
为什么要写这些乱七八糟的东西?
看,这样就行了。
我们在直播后检查了一下,发现 while let 和这个特性并不真正工作。首先你需要启用实验性的 let chains 特性,但即使启用了,它接受语法,但不允许你在 let 中声明的变量在 let 外使用。所以,也许将来它会有效,但现在这个技巧至少在一般情况下不行,可能在你的情况下可以。
我觉得你不想这么做,这会复制这个 token 的内容,所以它会复制所有这些东西。我会把它做成一个静态引用,这样它就会指向全局变量中的某一个,而不是复制所有这些无用的东西。因为你不需要这些东西的一个新副本,这个也可以是静态的。
你真的不应该为这些东西使用字符串。你会进行字符串比较,而字符串比较就像 JavaScript 那样慢。你不想给 Rust 一个坏名声,所以枚举(enum)会是一个更好的选择。
不过我可能不会使用哈希集合(hash set),我会做一个包含一堆布尔值的结构体(struct)。比如布尔值表示标识符、数字等,然后这些布尔值会被打开或关闭。这样就没有字符串比较,没有哈希操作,也没有动态内存分配之类的东西。
你可能会使用 u8 并进行位操作。不过如果你从汇编层次考虑,布尔值就是一个可以读写的地址,操作布尔值的指令是单条的。位操作应该也是单条指令。
哦,好吧,我确实喜欢你的位操作想法。在一个操作中你可以设置很多位,这确实比一堆布尔值更好。
你有测试吗?
你的测试在哪里?我看了一下,在底部的评估器那里。
没有测试?这是世界上最简单的东西了,测试 2 + 2 = 4。你可以为这个东西写出很多测试。虽然写错误测试有点烦人,但是在你的主函数里,这不算测试。这只是一个你手动运行和验证结果的测试程序,但没有什么东西能明确告诉你结果是否正确。
所以当你运行这个程序时,它可能会输出 18,但你会想:“这是对的吗?” 如果你写一个测试脚本,检查数以千计的组合,那你就不会再猜测了。这种东西绝对值得写一些测试,而且非常容易通过自动化测试来验证。你可以轻松写出 50 个测试,测试也很短,比如 2 + 2 应该等于 4。
哦,原来这全是关于操作符的。
天啊,我理想中的做法是把这部分代码拆开。我会把处理操作符的代码放在一个函数里,如果我把它放到一个函数里,可能需要传递所有这些变量。因此,与其传递所有这些变量,我会为这些东西创建一个结构体(struct)。然后在这个结构体上添加一些函数,比如 parse operator(解析操作符)。也许你可以用 return 来代替 continue 或 break,这样可能更容易理解,缩进也会更少,因为你这段代码已经很深了,几乎快比 yak 输出还要深了。
如果你花点时间在这上面,我觉得你可以把代码改进一下。你只需要多花点精力。
这部分代码在多个地方重复了,总是 evaluator error 加上一些信息。你可以为此写一个函数,把它变成一行代码,这样可以压缩代码,使其更易于阅读。
分配操作符 assignment operator,如果你没有插入它,什么时候会为真呢?我觉得这段代码永远不会运行。我认为它必须存在,因为这段代码的重点就是确保它存在。如果你引用它,就应该把它变成一个断言(assert)。同样这里也是。
你需要查看如何断言,assert 就是插入一个断言,比如 2 + 2 = 4,如果不成立,你的程序就会崩溃。这同时也能作为文档。
你可以创建一个 get variable 函数,返回一个 Option<&VariableValue>。你可以这样做,然后你会有一个 get variable myself 函数,这样代码会更简洁。
哦,你需要 token 标识符。为什么你这么写?你可以这样写,对吧?
哇,你这里有个 equals if。我觉得用 match 语句会更好,我更喜欢看到 match 语句。所以,match self.values.pop(),如果是 Some(number),那么处理 number,否则处理其他情况。我个人觉得这样写更熟悉。不过你的写法也没问题。
或者你可以这样写,ok_or,然后加一个 ?,这样更漂亮些。这样说的是:如果 pop 成功,返回 ok,如果失败,返回错误。代码更短一些。
你看,我第一次也不会想到最好的办法。我觉得你在这里也可以做同样的事,你这里有相同的模式。
总的来说,这段代码看起来不错,结构也符合我对解析器和评估器的预期。尽管缩进有点多,但你可以通过一些重构减少它。
哦,对了,猴子厕所梯子(Monkey Toilet Ladder),好名字!
为什么德式字符串无处不在
为什么德式字符串无处不在
https://www.bilibili.com/video/BV19RpTeZEkp/
我们来看这篇文章,标题是 “为什么德式字符串无处不在”。作者是 LUCASWAGO,原文链接是 Sader DB Docom。文章讨论了德式字符串的概念。尽管我们之前讨论过 Rust 的字符串实现,本文会深入探讨德式字符串。
字符串的本质
字符串在概念上非常简单,本质上就是一个字符序列。那么,为什么每种编程语言都有不同的字符串实现呢?事实证明,字符串远不止是字符的简单序列。我们构建了自定义的字符串类型,针对数据处理进行了高度优化。当我们首次撰写 Umbra 研究论文时,未预料到这一点,但许多新系统已经采用了我们的格式。它们现在已经在 DK DB、Apache、Error Palace 和 Facebook Values 中实现了。
在这篇博文中,我们想告诉你更多关于德式字符串的优势和我们所做的权衡。首先,让我们回顾一下字符串通常是如何实现的。
C语言与C++的字符串实现
在 C 语言中,字符串只是一个字节序列,并且有一个模糊的承诺,那就是字符串会通过一个转义零字符('\0')终止,如 Hello, World!。虽然这是一个概念上简单的模型,但实践中却非常繁琐。如果你的字符串没有正确终止怎么办?这会导致巨大的安全问题。此外,计算字符串的长度需要遍历整个字符串。如果想扩展字符串,还必须分配新内存并移动它。
相比之下,C++ 的标准库提供了更好的字符串支持。C++ 的标准并不强制规定特定的实现方式,但 libC++ 的实现与 Rust 非常相似。每个字符串对象存储大小和容量,并且有一个指向数据的指针。只要字符串长度不超过容量,就可以随意附加数据,而不需要重新分配内存。
这种实现还允许短字符串优化(SSO)。足够短的字符串可以直接存储在对象中,避免了分配内存的开销。这种优化在 Rust 中也存在,但不完全相同。
Rust 的字符串优化
尽管 C++ 的短字符串优化做得不错,但如果你了解自己的用例,你会发现可以做得更好。在构建 Sader DB 时,我们做了一些观察:大多数字符串都很短。尽管可以存储任意长度的文本,但大多数字符串存储的都是较短且可预测的数据,比如 ISO 国家代码、机场代码、性别等。
字符串通常不会频繁更改,大多数数据只会写入一次,但会被多次读取。由于字符串大小不经常更改,C++ 的方法似乎有点浪费。我们希望在可能的情况下使用不可变字符串,因为这样可以避免昂贵的锁定操作。
前缀匹配
通常我们只查看字符串的前几个字符。例如,当我们查询哪些消息是以 HTTP 开头时,我们只关心字符串的前四个字符。每次访问字符串都要借用指针来比较前几个字符,这样就显得有些浪费。
德式字符串通过优化前缀匹配来解决这个问题。Sader DB 的研究前身发明了德式字符串,它通过删除容量字段节省了 13 个字节,并允许我们通过两个寄存器传递字符串,而不是使用堆栈。
德式字符串的结构
每个德式字符串有一个 128 位的结构,分为短字符串和长字符串两种表示方式。
短字符串:如果字符串长度不超过 12 个字符,内容会直接存储在对象内。这种情况下,访问字符串的前缀或内容非常简单。
长字符串:如果字符串长度超过 12 个字符,会存储一个指向实际数据的指针,同时保留字符串的前四个字符作为前缀。这样可以加速等式、不等式和字典排序等操作。
存储类与生命周期
在开发德式字符串时,我们注意到开发人员对字符串的生命周期有不同的需求。为此,德式字符串引入了存储类的概念,允许字符串是永久的、暂时的或瞬态的。
- 永久字符串:类似于字符串常量,数据会被永久存储且不可更改。
- 暂时字符串:类似于临时变量,超出作用域后会释放内存。
- 瞬态字符串:指向当前有效但可能以后失效的数据。适用于只需要访问一次的情况,避免了不必要的内存分配和数据复制。
优缺点
德式字符串的优点在于它节省了内存分配和数据移动的开销,使得系统更加高效。由于字符串是不可变的,它们可以在多个线程中安全使用,并行处理变得更加简单。存储类的引入使得开发人员可以根据需求严格管理字符串的生命周期。
然而,德式字符串也有一些缺点。它们要求开发人员更加深入地思考字符串的生命周期以及如何处理不可变字符串。如果你的应用程序需要频繁更新字符串,德式字符串可能并不合适。
结论
德式字符串通过优化内存使用和提高性能,成为数据库系统中不可或缺的一部分。如果你能够回答关于字符串生命周期的问题,你将可以从德式字符串中获益,即使你并不是在开发数据库系统。
Type Theory for Busy Engineers - Niko Matsakis
今天我要讲一讲类型理论,特别是为工程师准备的内容。最近我通常会做一些关于Rust路线图和Rust功能的演讲,但这次我决定做一些不同的事情。那么,首先介绍一下我自己。虽然有点害羞,但我确实负责Rust类型系统的开发和Rust语言的设计,我已经做了很长时间了。
在这个过程中,我注意到一些事情。最初,当我们开始构建和设计Rust的某些方面时,我们会先做一些小的模型。我们会说:“好吧,我们想要考虑类型系统该如何工作。”于是,我们会专注于某个特定的部分,并暂时将它与整体分离开来。这样,我们可以走得很远。接着我们会再考虑另一个部分,于是又创建另一个模型,专注于Rust的某个子集。通过这种方式,我们一点一点地完善了Rust。
然而,现在我们已经到了一个地步,想要把整个系统都装进脑子里变得有些困难。我们开始遇到一些意想不到的交互问题,或者是一些小问题。因此,我对如何构建一个更好的模型产生了兴趣,这样可以帮助我们理解并扩展Rust的未来,并且考虑Rust应该具备的新功能。目标是创建一个可以让你“玩转”类型系统的工具,通过原型设计来实验类型系统,而不是构建一个可以发布的编译器,而是帮助你理解今天的Rust以及它未来可能的发展方向。这就是这个项目的目标。
这个项目叫做“mere formality”(仅仅是形式),我被告知需要为非英语母语者解释一下这个词的含义。在英语中,”mere formality” 表示一件微不足道、可以轻松解决的小事。而在这个项目中,MIR 也是编译器中的中间层IR(Intermediate Representation,简称IR)。所以这个项目名称是一个双关语。这个项目的目标是以一种形式化的方式,建模类型检查器的工作机制,尤其是中间层IR,而不是Rust的表层语法。不过,今天我不打算讨论“mere formality”这个项目的全部,而是要讲其中的一个子集,我称之为“formality core”。
其实,我之所以称它为“formality core”是因为在crates.io上“formality”这个名称已经被占用了。我不记得是谁占用了这个名字,我应该去找出来。不过这个项目的核心部分是一个可以重复使用的模块,如果你正在设计一种语言,比如今天我们看到的那些很酷的语言,比如June语言(June Language),那它可能对你有用。我自己在做实验时觉得它很有趣。
这个工具的灵感主要来自一个叫做PLT Redex的工具,它是另一个编程语言Racket中的工具。它与我现在做的事情本质上是一样的,都是一个轻量级的工具,用来玩转类型系统的定义,让你能够把你可能在学术论文中读到的符号写进计算机,并实际运行和实验。
我刚才提到,你可能在学术论文中读到过这些符号,但我发现令人震惊的是,实际上并没有那么多人真正读过学术论文。学术论文的设计并不友好,难以入门和理解。因此,我今天想做的是,讲一讲我自己是如何思考这些问题的,以及如何使用formality core。我们会一步步走过一个非常简单的类型系统,让你感受一下它的样子。
顺便说一句,真实情况是我还没有对这个演讲做过预演,所以我不确定它究竟需要多长时间。要么我们会超时,要么会提前结束,然后我们可以进行一堆问答。无论如何,我们会一起摸索出一个有趣的过程。
不过,这也是因为我自己并没有真正学会……
我们先来回答一些问题,摸索一下这个过程,它会很有趣。不过,部分内容是因为我并没有真的学过……
实际上,我从来没有上过类型系统的课程。我只是自己阅读一些东西,最终慢慢理解了。所以,今天我要讲的是我自己对类型系统的理解方式,可能有些真正研究过这个领域的人会有不同的看法。
无论如何,我对formality core的理解是这样的:如果你曾经在Java中编写过并行系统,那么你可能会有类似的体验。其实,我想讲一个笑话,因为它很有趣。Rust的早期课程之一是在东北大学开设的系统编程课程。他们教学生Rust,直到他们学到并发编程时,遇到了一个问题:他们想教学生数据竞争问题,但却无法在Rust中制造数据竞争。所以他们说:“好吧,我们暂停一下,去学Java吧。”然后他们开始教Java,并展示了一个可怕的bug。最后,他们说:“好吧,现在回到Rust,你们在Rust中是不会遇到这个bug的。”这就是一个真实的故事。
Rust让并发编程变得简单。至少根据我的经验,虽然仍然可能会有bug,但我可以轻松地将代码拼接在一起,进行思考和实验。如果代码在简单的情况下能正常工作,通常在复杂的情况下也会正常工作。formality core给我带来了类似的感觉,我希望能够将这种感觉带到类型系统的实验中。
接下来,我将带你走过一个叫做“EG”的语言。它实际上是一个高级的计算器,比如下面这个程序,当你运行它时,它会输出66。我知道这看起来很简单,但它还能做更多的事情。你可以使用元组,它们会按元素逐对相加。你还可以定义函数,比如这样,并且可以使用“@”符号来调用它们(这是因为某些限制,稍后我会解释)。你甚至可以使用泛型。这些都是可以在formality中实际运行的例子,而今天我会展示这个程序的实际运行情况。
哦,等等,之前有人展示过类似的东西,我忘记是谁了。我想我们从运行cargo clean开始吧,毕竟每次构建时都需要清理一下。在这个系统中构建程序时,它实际上就是一个普通的Rust程序,你可以直接使用它。让我们看看源代码在哪……哦,是在tests目录下的talk examples文件夹,比如说,这里有一个例子。希望你能看清楚,执行22 + 44会输出66,等等,对吧?
那么,我们如何用更正式的方式定义一个编程语言呢?哦,我在这里做什么?哦,我只是想展示一下我并没有在这个定义中建模类型推断。虽然你可以使用泛型,但必须显式地指定它们的值,这实际上让事情变得更加简单。那么,如何更正式地定义一个程序呢?首先,我们需要一个叫做语法(grammar)的东西。你们有多少人听说过语法?好吧,我猜应该不用过多解释。基本上,语法定义了程序的结构。例如,你可以写这样一个规则:一个EG程序是由零个或多个函数定义组成,接着是一个表达式,这个表达式将计算出最终的值。
在formality core中,我们也会定义类似的语法,只不过我们用Rust结构体来实现。比如,我们可能会有一个结构体,表示一个程序,它包含不同的字段。你可以看到,我们会给这个结构体添加一个叫做term的注释,它是编程语言领域中的一个术语,表示程序中的某个部分,比如一个值或表达式。我们需要一个词来表示“某个东西”,所以我们用term来表示。在这个结构体中,你会看到语法规则。比如,美元符号告诉你应该解析这个字段的值,而星号表示可以有零个或多个这样的元素,把它们收集到一个向量中。所以,这段代码表示零个或多个函数定义。
当然,所有这些都使用Rust的特性和其他底层机制来驱动执行。接下来,这段代码会解析一个单一的表达式。这是一个非常简单的语法,有时候语法可能会复杂一些,比如表达式可以是一个整数,也可以是表示变量的标识符,或者是一组表达式组成的元组。当你有多个选择时,我们会用枚举(enum)来建模。我们会给每个枚举变体添加term注释,并为每个变体提供相应的语法规则。这些是不同的选择,比如,如果是整数,它应该看起来像一个整数。如果是匿名字段,我们会简单地称之为v0、v1等。
以下是整理后的内容和中文翻译:
有不同的选择,如果它是整数,它应该看起来就像一个整数。嗯,当它是匿名字段时,我就称它为 v0、v1,依此类推。
这里有一个很方便的小宏叫做 ID 宏,它声明了一个字符串作为标识符。我们在说,好的,这里会有一个叫做 ID 的标识符类型,当我解析时,如果我看到一个标识符,那就是一个变量(VAR)。
这里你可以看到一点方便的符号表示,你可以使用 $ 加括号,这意味着这是一个括起来的列表。我试图加入一些常见的约定,你会经常遇到这些约定。所以我们将解析一个或多个表达式并构建一个元组(tuple)。接着,我们可能想要添加加号、减号、乘号和星号(如 *),我们可以做到。这只是一些变体,没有什么复杂的。唯一的区别是,我可以添加这些优先级注释来引导解析器。
因此,我们在结构体上进行这些声明,并基本上告诉 formality-core 自动生成解析器和其他所有东西,使用这些语法注释。对了,有一个小问题是,如果你有递归类型,毕竟这还是 Rust,它们必须有 Arc(引用计数智能指针),否则这种类型会有无限大小的问题,但这并不是什么大问题。
有时语法可能有点不太明显,比如 let。这里我们声明了一个局部变量,它会被叫做 ID 或其他名字,它将有一个初始值,并执行一个表达式,看起来会像这样。
我有 ID 和 Arc,你大致明白了。所以我们来看一个例子。
现在我们想要写一些测试,因为整个想法是你可以写这段语法,然后进行实验。让我看看哪里有一些测试。好的,在这里。所以这是一个例子,我可以通过解析来测试我的解析器。例如,我可以这样做:
让我现在解释一下,EG Lang——我稍后会讲到这一点,但基本上我必须在某处声明我的语言,它只是给它一个名字,并创建一个带有一些样板代码的 crate。其中之一就是这个 term 函数,它可以解析一个字符串并返回一个数据结构。所以这里我在说,解析这个东西中的一个表达式。e 是某个表达式,然后我们可以运行一个测试。实际上,我们可以利用语法来帮助指导,不仅是解析,还包括调试实现等。
比如说,如果我们只是写 e:?,那就是普通的调试实现(debug impl),你会得到一个带有语法生成器的调试输出,类似于格式化输出。但是如果你加上 # 符号请求详细信息,你实际上会得到标准的调试实现输出。这在写解析测试时非常有用,因为它给你所有的细节。
有些人曾经使用过我的系统会告诉你,或者在我们没有这个功能时,当你真的想知道你得到的是什么数据结构时,这很有帮助。在这个特定的例子中,我们可以看到乘法有更高的优先级。我们解析了 x + y * 3 + 66,如果我们做错了,可能会出现像这样的括号,乘法可能会影响加法,而不是先进行加法。但我们没有这样做,乘法在里面,接着是加法。好的,这只是解析,它不是一个特别智能的解析器。我已经展示过了。
但是你必须声明语言,好的,这有点无聊。我要提一下,你确实需要告诉它你想要哪些作为关键字。好了,无聊的部分结束了,让我们谈谈类型检查,这部分更有趣一些。
到目前为止,我们允许你定义数据结构并指定它们的语法,你还能免费得到一个解析器,它会解析并给你一个抽象语法树(AST)。抽象语法树只是这些数据结构的一个实例,一种将程序视为数据结构而不是字符串的方式。但现在我们想要进行类型检查。对于这个 EG 语言,类型检查规则非常简单。如果你要进行加法操作,它们基本上必须是相同的类型。所以我可以加两个整数,我可以加两个都是整数的元组,但我不能加一个元组和一个整数,或者两个不同维度的元组,这样是不行的。
这就引出了一个问题:在这个语言中,类型是什么?接下来我们要定义的是类型的规则。我们定义了什么是编程语言,但我们没有定义类型的语法。所以接下来你要做的是定义这个。我们将它基本上设定为,我从简单的开始,暂时排除泛型。它可以是一个整数,或者是一些其他类型的元组。
所以这只是一个包含两个变体的枚举,一个是整数,一个是某些其他类型的元组。
现在我们要进入推理规则的部分了。你们中有多少人见过类似的东西?你们有多少人明白这是什么?嗯,我也不太明白。
这种符号其实非常简单,就像世界上最简单的解释器。你运行它,它必须很简单,因为我们必须能够将它映射到数学上,所以它必须非常简单,以便我们能够正式地证明一些东西。
但我要慢慢讲解它。我认为关于推理规则最令人困惑的事情是它的名字。推理(Inference)这个词到底是什么意思?我不知道。当我想到类型推断时,它并不是关于推断的,而是关于这个英文单词“infer”(推断)。推理规则基本上是:我们如何根据一组前提得出结论。你可以这样理解:如果你看到一个大规则,规则上方的内容是前提条件,如果 X 和 Y 成立,那么 Z 就成立。这是结论。而旁边的东西只是名字,方便引用它。
例如,这是你可能在 EG 中写的一个推理规则,相对简单。但这到底是什么意思呢?
以下是整理后的内容和中文翻译:
那么,这到底是什么意思?到底发生了什么?即使在这个非常简单的例子中,我们也有点搞不清楚这些复杂的符号到底在说什么。
当你看到像这样的字符串时,这也是一个术语。它基本上有两个部分。整体来说,这是一个谓词,它是我们可以说是对或不对,或者至少是对或未知的东西。在它内部,有一些变量。这里的 T 就是一个变量,表示某种类型,我将它称为 T。实际上,你可以很正式地称它们为元变量,但简单地称它们为变量就可以了。基本上,当你看到这些字母时,通常都是变量。所以,T 是某种类型,可能是 u32,也可能是 (u32, u32)。
Γ(Gamma)通常是用来表示类型环境的一个符号。我用它主要是因为它是希腊字母,但我个人更喜欢用字母 E,因为我的母语是英语。不过 Γ 通常用来表示类型环境,它基本上是所有已声明变量的类型集合。当然,它也可能包含其他东西,比如在 Rust 中,它可能还包含生命周期关系等。这只是一种约定。所以,实质上它就是一个变量名和这些变量的类型的列表。
我们实际上可以像定义其他东西一样定义 Γ。当我们在 formality 中工作时,我们必须使用 Rust 数据结构来定义这些东西。这里我没有使用 term 宏,因为我不需要解析它,但所谓的环境实际上就是一个在作用域中的变量列表及其类型,我只是用了一个映射,实际上是一个 BTreeMap,它将变量名映射到对应的类型。我还携带了程序一起,这样当你调用函数时,我可以查找函数定义。我们现在暂时不会用到它。
所以,当你看到 Γ 的时候,基本上可以把它当作某种环境来看待。嗯,我们看到的 Γ(x) 这种符号,你可以把 Γ 想象成一个函数,你可以调用它来获取某个变量的类型。我们通过访问这个映射来做到这一点。
那么其他的部分呢?我们看到的这些变量,还有这个奇怪的符号,叫做转闸(Turnstile),为什么叫转闸呢?因为当你走进地铁时,那东西会转动(这就是转闸的由来)。还有这个冒号,答案是这些符号本身没有任何特别的含义。它们就像一些不透明的字符串,类似于一个函数的名字。如果你愿意的话,可以把这个谓词的名字叫做 Γ:。有点像 Objective-C,如果你这么理解的话,或者像 Swift(我不太清楚 Swift 的工作原理),但在 Objective-C 中,方法的名字是分散在各处的。比如,insertObject 实际上应该是 insertObject:atIndex:,这是一个完整的字符串,尽管你是分开写的。
所以在这里,你可能会说这个规则的名字是 Γ:,但大声念出来有点别扭。所以我们也许可以给它取个更好的名字,我们稍后会看到一些例子。
我之前说过,这些只是随机的符号,这是我的理解,你必须接受它们就是这样,但其实有一些约定人们经常使用。比如,转闸符号(Turnstile)通常用来分隔假设(你的环境)和你想要在这些假设下证明的东西。比如,如果我知道变量 X 是 u32,那么我就知道对 X 的引用的类型是 u32。这是基于这个假设的。而冒号通常表示“具有类型”。
所以你可以这样解读:假设 Γ,那么 e 具有类型 T。我想说的是,这对我个人来说是一个转折点,当我意识到这个奇怪的符号其实基本上意味着“假设”或者“让我们说”,就是这样。我当时心想:“哦,我现在可以读懂它了,它现在像英语一样,而不是一串奇怪的符号。”通常知道人们的意思对理解这些符号非常有帮助。
那么我们如何在 formality 中编写它呢?我们将把它写成一个 Rust 函数,所以我们必须给它命名。我将它称为 type_expression,因为它是为表达式计算类型的函数,计算表达式的类型。接下来我们会为每个变量定义它们,它们会作为变量出现。但你会注意到,前两个变量会作为参数传递给这个函数,而最后一个变量,即出现在冒号后的类型,实际上是返回值。
这有点不那么抽象,通常当你定义规则时,并没有输入和输出的概念,它只是这些东西之间的关系。比如,如果你有这个变量,它就有那个类型,你可以双向运行它。但当我们在现实中真正要执行它时,我们必须考虑输入是什么,什么是从输入计算出来的,否则它就不像一个可执行的函数。
所以我只是将这些都内置进去了。这个函数的意思是:给定一个环境和一个表达式,我们应该能够确定这个表达式的类型。这实际上是一个性质(property),我们不会去猜它,它必须是确定的。
好的,接下来我们要写的是一个“判断函数”(judgment function)。这个函数是一种非常复杂的宏,我稍后会展示给你。
好的,以下是整理后的内容,并翻译为中文:
我们要编写一个判断函数,这个函数是一个非常复杂的宏,我稍后会向你展示。我们将从一个看起来相当普通的 Rust 函数开始,除了一个地方不同:我用了两个箭头而不是一个。这是因为在编写类型判断时,可能会有多个规则适用,也可能一个都不适用。因此,我们实际上是在计算这个表达式的所有可能类型。如果没有任何可能的类型,那就说明这个表达式类型不正确,是无效的。但如果有多个可能的类型,那也有可能是可以的,有时得出多个结论中的任意一个都是成立的。
这是我们将要使用的规则的形式。这是数学上的等价表示。我们会使用括号,上面是前提,下面是结论。比如说,当你请求我对一个表达式进行类型判断时,假设环境是 n,表达式是一个变量,变量名叫 X,那么我可以通过让环境查找变量 X 的类型来计算它的类型。你看到我用了问号,这是因为这个定义可以返回一个 Result(可能会失败)。这是该函数的定义,它实际上是 Rust 中定义在环境上的一个方法,返回的结果是可失败的。问号表示如果返回 Ok,那么规则适用;如果返回 Err,则规则不适用,但我们会跟踪错误值以便稍后提供更好的错误消息,因为我们需要告诉你为什么类型规则失败了。
如果成功了,那么我们就知道了 X 的类型,这将成为最终结果。这是计算变量类型的一种方式,在这种情况下没有其他方式,但理论上可能会有其他方式。接下来,我会向你展示一些实际代码。
这是一个 Rust 程序,我可以运行它,我不记得它需要什么参数,但我们可以用它来执行一些表达式。如果表达式类型不正确,会弹出一个相当复杂的错误信息,但实际上它告诉你发生了什么。我会解释一下:基本上它是在说 “嘿,我尝试在这个环境中计算这个表达式的类型,但我没能做到”。这很有用,因为有一个加法类型规则,它几乎适用了,但最后没有适用。为什么呢?因为有另一个针对二元表达式的规则没有通过。最终,规则没有适用,所以这个程序是无效的。
现在你可以看到真正的代码。type_expr 就像我说的那样是一个宏。这里有一些规则,比如针对变量的规则。接下来我要告诉你 let 的规则,它有点复杂但很有趣。这个规则说,当我们对一个 let 表达式进行类型判断时,如果它有一个变量、一个初始化器和一个主体,我们首先会递归地对初始化器进行类型判断,计算出它的类型并存储在变量 VAR 中。注意,这里使用了右箭头,这意味着我们再次应用了类型判断。即使表达式有多个可能的类型,这个箭头表示从中挑选一个类型,只要它是有效的就可以。
接着我们会创建一个新的环境。我们通过 Rust 的变量遮蔽特性来实现这一点,这可能对习惯了形式化语言的人来说很难接受,但它非常方便。这样我们就可以说,从这行代码开始,我们有了一个修改后的环境,该环境包含了我们刚刚引入的新变量。然后我们会对主体进行类型判断,这时我们就可以在这个新的环境中访问新变量了。
让我们运行一下这个例子,比如 let X = 22。这个规则中,变量 VAR 是 X,初始化器是 22,主体是 44 + X。我们首先对初始化器 22 进行类型判断,得到它的类型是 u32。接着我们会创建一个新的环境,原本是空的环境,现在我们有了一个变量 X,它的类型是 u32。这个函数的定义非常简单,它只是把新变量插入到一个映射中。然后我们会在修改后的环境中重新对表达式 44 + X 进行类型判断,这时当我们查找 X 的类型时,得到的是整数类型。
对于二元运算符的类型判断,有点复杂但基本思路是,我们分别对左右两边的表达式进行类型判断,得到 L_Ti 和 R_Ti,然后检查它们是否相等。如果你用更形式化的数学符号来写,通常会写成 T,这隐式地表示所有地方都必须是相同的类型。但在 Rust 中,由于遮蔽等原因,我们必须为它们分别命名,然后再断言它们相等,但这个方法同样有效。
最后,我想展示的是,由于这一切都是 Rust 代码,所以我们可以写测试。例如,写一个测试检查 1 + 2 作为元组不应该与整数相加,然后运行这个测试。这个测试使用 Rust 的分析器,因此它可以正常工作。
这是整理并翻译后的内容。
规则中,变量 VAR 是 X,初始化器是 22,然后主体是 44 + X。所以我们首先对初始化器 22 进行类型判断,得到的类型是 u32。接着我们会创建一个新的环境,之前的环境是空的,因为没有任何变量。现在新的环境将包含一个变量 X,它的类型是 u32。如果你查看该函数的定义,它做的事情非常简单,就是将这个新变量插入到一个映射中。
然后我们会在这个修改后的环境中重新对表达式进行类型判断。现在,当我们对 44 + X 进行类型判断时,当我们查找 X 的类型时,会发现它是一个整数。对于二元运算符的类型判断,这很有趣,虽然在源代码中稍有不同,但基本思路是这样:我们会对左右两边的表达式分别进行类型判断,它们分别会给出类型 L_Ti 和 R_Ti,然后我们检查它们是否相等。如果用更加形式化的数学符号来写,你通常会这样写:在所有地方都使用相同的类型 T,这隐含地表示它们必须是相同的类型。但由于 Rust 中的遮蔽等特性,我们必须为它们分别命名,然后再断言它们相等。不过,这种方式依然有效。
好的,我一直在进行演示,不过没关系。我还剩几分钟时间,我想给你们展示另一件事。也许你们知道,或者不知道,但我非常喜欢的一点是,这一切都是 Rust 代码。这意味着我可以写一个测试,比如测试 1 + 2 作为元组不应当与整数相加,然后运行这个测试。这是一个 Rust Analyzer 测试,所以它可以正常工作。哦,测试通过了,但假设我对测试做了一些修改,现在测试会成功,这意味着类型是正确的,但我告诉它应该断言一个错误。我有一个方便的实用函数,它会检查返回的结果是否为错误。当然,现在测试不会通过。这有点像模拟调试体验。
让我做一个更好的演示。假设我进入并修改了我的规则,比如这里的 if El_Tai,如果我去掉这个 if,那么现在我可以进行类型判断,而不关心它是否等于预期的类型。也就是说,我不再测试类型是否相等。所以,当我运行测试时,自然会得到测试失败。当然,我还会得到一堆警告,这也挺好。这和我们编写其他代码时的流程类似:你有测试,运行测试,检查类型系统的修改是否仍然如预期运行。很好。
我们还使用了一个叫做 expect 的工具,不知道你们是否看到过,这是 Rust Analyzer 提供的一个 crate,它允许我们记录预期的结果。比如这里,我期望类型检查失败,而且是以特定的方式失败:类型不等于预期的类型。我可以通过 Rust Analyzer 告诉 IDE 运行测试,虽然现在似乎没有正常工作,可能因为我不在正确的位置,但本来它会自动更新为正确的输出。这非常有用,因为手动输入这些结果很麻烦。
当然,你也可以直接运行 cargo test,它会自动更新 expect。这样的话,当前的输出结果会被更新。不过现在我将这个 bug 直接编码到了我的测试中,这其实不太好。但你可以看到,输出被更新了。这引出了我最后的一个观点。你仍然会看到一个错误,为什么呢?因为我还写了一个解释器,现在解释器在类型检查之前执行。
当你运行测试时,首先它会检查程序的类型。如果类型检查通过,程序就会被执行。之前类型检查失败,所以程序没有被执行。但现在类型检查成功了,因为我破坏了类型检查器,所以程序运行了,但程序报错了,因为它不知道如何处理元组和整数的相加。这很酷,因为虽然我还没写好,但我可以通过引入模糊测试和其他技术来进一步检测类型检查器。
比如,我可以告诉模糊测试工具,类型检查器的目的是保证解释器不会以这种方式失败。也就是说,程序通过类型检查后,应该不可能再出现像元组和整数相加这种错误。所以,我可以让模糊测试工具生成随机程序,看看是否有任何通过类型检查但在运行时失败的情况。虽然找不到这样的例子不能证明类型检查器是完全正确的,但它会增加我们的信心。
我的经验是,通常我会进行模糊测试,类似于 Nico 说的:他认为某件事是对的,然后把它发到 Zulip 上,接着人们指出这个实例是错误的。这种方式很好,但如果不需要总是依赖其他人在 Zulip 上参与,那就更好了。我希望能在本地进行模糊测试,而不是等待别人指出问题。
我们正在构建的这个项目有一个模型,用来模拟 Rust 的类型检查器,几乎涵盖了它的全部复杂性。我的目标是能够在真实的 Rust 程序上运行这个模型,寻找那些实际 Rust 编译器接受但模型认为不应该接受的程序。这可能是 Rust 类型检查器的 bug,也可能是模型的 bug。通过这种方式,我们能够找到差异,并使这个模型更加准确。
好了,我的演讲到此结束。非常感谢大家的聆听。如果有任何问题,我很乐意回答。
通常我做的事情是这样的:在 Rust 中进行模糊测试,就像 Nico 说的,他认为某件事是对的,然后把它发到 Zulip 上,接着人们会说,“不,这不对,看看这个示例程序”。这很棒,但如果我不需要等别人醒着在 Zulip 上回复,而可以本地完成测试,再把结果发到 Zulip 上,让大家来解决更复杂的问题,那就更好了。所以,这就是我们想要达到的目标:我想进行模糊测试,我还想做一些其他很好的事情。我不想有这个 hack,不记得它是什么了,但我们希望能后退一步来看——我们正在构建的这个版本实际上是对 Rust 类型检查器的建模,它几乎涵盖了类型检查器的全部复杂性。我的目标是,我们应该能够在真实的 Rust 程序上运行这个模型,寻找一些有趣的情况,比如:“嘿,Rust 编译器接受了这个程序,但模型认为它不应该接受。”那么,这是一个 bug 吗?问题出在哪里?可能是在真实的 Rust 类型检查器中,但也可能是在模型中。我们可以通过这种方法寻找差异,并不断测试和完善模型,使其更加精确。
这就是我所有的内容,感谢大家的聆听。如果有任何问题,我很乐意回答。
哇,看到学术工作也被这样实际应用到 Rust 的未来真是很酷,至少感觉非常不错。那么,有问题吗?
——嗯,全部都非常有趣,谢谢。那么它扩展到真实的东西,比如 Rust 的 trait、实现、推导、生命周期等是怎样的?当你真的试图愤怒地使用它时,它表现如何?
让我看看我这里有什么,我不知道它是否是最新的,因为我通常在远程桌面上工作。而这是在本地的。我想简单的答案是:我们正在摸索中,我觉得它表现得相当不错。需要记住的是,像 trait 系统或者类型推导等有意不被包含在形式化核心中,而是我们编写的规则的一部分。这是因为我们想要验证 Rust 的推导规则是否有效。我还没有完全实现整个系统,但它确实有一个实现,比如 coherence 和 trait 系统的实现。让我看看能不能找到一个有趣的例子。
这里是一个有趣的例子。这有点奇怪,但这是我们作为输入的基本程序。我们有一个迭代器,称为 Iterator,还有一个叫做 Mirror 的 trait,它查找关联类型。我们为所有类型 A 实现了 Mirror,并将关联类型设置为 A。我们有一个本地类型和一个本地 trait。然后我基于这个基本程序生成了一些示例程序,添加了一些额外的东西。所以,如果只是这个基本代码,它能正确通过类型检查。但是如果我添加了第二个迭代器,导致了重叠,那么我确实会得到 impls may overlap 错误。所以,它在逐渐扩展到一个相对完整的系统,但我还没有做我之前提到的那些事情,比如与 RustC 进行模糊测试对比。所以,我也无法说我们会发现多少有趣的角落。
我还要补充一点,我的目标是,形式化核心是这样一个系统:假设你在编写 Rust 时完全不在乎性能,无论能否克隆,你都随时克隆,只是为了好玩。你可能会认为:“是的,我不需要克隆它,但我还是会这么做。”所以,我并不指望它能扩展到上百行代码,我很高兴它能处理几十行。我想它实际上确实能扩展到上百行,但这不是我的目标。我只想要小的代码片段,通常类型系统的 bug 可以在四行代码中展示出来,这就是我想要做的。因此,它绝对优先考虑尽可能简洁和清晰,而不是性能。
——这不是个问题。你好,我听说 Rust 正在转向或已经转向语义生命周期管理,所以我想知道这个系统能否描述这些?它是如何做到的?
哦,出了点问题,哦,搞错了命令。我一直在玩这个玩具编程语言,它只是个玩具,纯属好玩。但在这个上下文中,我试图通过形式化来测试它,看看是否有效。我发现它运行得很好。在这个模型中,它确实处理了这些问题,这有点像一个游乐场,展示了如果借用检查器按这种方式工作会是什么样子。我在这个模型中对很多东西进行了建模,发现它扩展得很好,能够测试很多东西。我们现在正在考虑将这个模型移植到 Rust 上,或者至少我在考虑这样做,尝试用这种方式实现 Rust 的借用检查器。所以,简短的回答是:是的,它可以扩展到这种情况,我们已经验证了这一点,但还没有在 Rust 上验证。我预计下一步会在 Rust 上进行。
Rust 更加复杂,有其他要处理的事情。这也是我在这个模型中尝试的原因——即使是一个模型,Rust 仍然太复杂了。但如果我们对 Rust 进行简化建模,我们可以做到,然后逐步推进。
——你想看语法吗?好的,让我们看看语法。我甚至不记得了,这个语言的语法非常不同,但我们找一个例子来看看。我不应该打开这个模块,让我们找一个测试。嗯,这里有一个测试。好的,这是一个测试,展示了在这个疯狂的语言中,像我说的,这是个很奇怪的介绍方式,但没关系。这个测试展示了一个拥有对自身共享引用的类 Choice。它在结构体中使用了内部引用,我想我们在 Rust 中也可以做到这一点,如果我们采用了我们的模型。我能够像这样创建一个 pair,并共享它。所以这些东西展示了这个系统扩展到新语言和其他有趣的类型检查器问题。我想展示一个会出错的例子,哦,这里有个有趣的错误。
我注意到的一个问题是,可能需要稍微精简这些错误信息,因为最重要的部分是最后一部分,解释了为什么会失败。但是,这个例子在做什么呢?我不确定,这些例子有点复杂,我找不到一个看起来简单易懂的。但这个程序做了一些无效的操作。
——我可能错过了在讲座中的这一部分,但你似乎创建了一个可以帮助你通过语法规则构建并进行推理的系统。而这类框架已经有很多了。为什么要在 Rust 中重新创建这个?
好问题!这是个非常好的问题。因为一开始我也不想在 Rust 中实现这个系统。我最初在 PLT Redex 中构建了整个系统,那是一个非常棒的系统。但我遇到了一个问题,我希望 Rust 的贡献者能够使用它。我的目标是,我们最终会有一个模型,当你编写涉及类型系统的 RFC 时,这个模型会成为重要的参考。
这是个好问题,实际上是个非常好的问题。因为一开始我并不打算在 Rust 中实现这个系统。我最初在 PLT Redex 中构建了整个系统,它非常漂亮,PLT 是一个很棒的系统。但是我遇到了一个问题,我希望 Rust 的贡献者能够使用它。我的目标是,当有人编写涉及类型系统的 RFC 时,可能不是你,但会有人基于这个想法扩展模型。我们会在最终确定和稳定之前,对它进行建模、测试并进行实验,这就是类型团队理解它的方式。
但为了让这项工作顺利进行,结果发现没有人想去学习 Racket,因为他们习惯了 Rust,想要在 Rust 中工作,这也说得通。另外,我发现很多系统,至少我曾接触过的那些,并不是为长期工程设计的。比如,Redex 明确是设计成一个短期的实验平台,你可以用它构建一个小例子,学到一些东西,然后再把它抛弃。因此,长时间维护这个系统会变得非常困难,它没有类型检查等功能。我当时想:“为什么我不能直接用 Rust 呢?我想写一个 match,并确保我已经穷尽了所有的情况。我想做 Rust 擅长的所有事情。”
实际上,我发现这两者结合得很好。使用 Rust,整个系统感觉很灵活,但同时也提供了我习惯的 Rust 类型检查保证,并自动发现了很多愚蠢的 bug。我们甚至在这里看到了这一点吧?比如,我注释掉了一行代码,结果立刻得到了未使用的变量警告等提示。所以在我运行测试之前,Rust 就已经帮我排除了大部分愚蠢的 bug。我不能说我尝试了每一个系统,但对于我想做的事情来说,Rust 这一套体验要好得多。
好,我还有一个问题。你使用了这种来自数学的符号表示,当然也来自编程语言的论文。为什么你选择采用这种推导规则的表示形式,而不是像 Rust 代码那样的表示?是因为你想吸引理论编程语言的研究者,还是因为这种表示减少了样板代码?
这是个好问题。简短的回答是,论文使用这种符号表示是有原因的,这种表示非常适合用来思考类型系统并将规则写下来。实际上,我没有完全展示给你们所有内容,实际上它是一个混合体。你们大概看到了,当我编写第一个调用 check_program 并执行它的程序时,那只是常规的 Rust 代码。比如环境中的那些方法,也只是常规的 Rust 代码。但是有些东西是使用类型规则书写的。实际上,我在 miror formality 中遵循的一个经验法则是:只要可以,我就尽量使用 Rust 语法。对于那些完全确定性的内容,Rust 语法通常非常适合。
但是,当涉及到 trait 匹配和一些更复杂的类型规则时,会有很多不同的选项。在 Rust 编译器中,我们编写了大量潜在的正确代码,试图以高效且周全的方式决定使用哪个选项。而在 miror formality 中,它会尝试所有可能的选项。如果有四个选项,它会尝试全部,看看哪个有效。这大大减轻了编写规则的人的负担,因为你不必思考如何决定使用哪个规则,那是别人的问题。你只需要考虑,如果采用了某个规则,它是否正确?如果这种假设成立,它是否应该被接受?你可以逐个思考每一个规则,而不必考虑选择哪个规则,因为无论选择哪个,它们都应该是正确的。这简化了你的工作模式,我发现它非常有帮助。
所以,我使用这种符号表示是因为它好用,大家都用它是有原因的,它确实很有意义。
好的,最后一个问题。首先,非常感谢你的演讲,太棒了,我非常喜欢。我有一个问题,我记得听说 Rust 语言技术上没有正式的规范。你是否认为这项工作也是朝着那个方向努力的一部分?
这是个好问题,我很高兴你喜欢我的演讲。是的,我确实认为这项工作是朝着那个方向迈进的一部分。目前我们有一些相关的努力正在进行,时间会告诉我们它们最终会如何发展。一个方面是规范工作组,实际上他们正在从 Ferris 团队的工作开始编写一个规范,Mars 就在这里,Mars 正在参与这个工作。这是其中的一部分工作,我认为这将会产生一些更接近你所期望的传统语言规范的东西。
然后你还有 miror formality,以及 Ralph Young 的 mini Rust,它们都是不同的但类似的努力,旨在形式化不安全代码的规则。它们基本上都是用 Rust 代码编写的,都是非常精确的定义,尽管它们分别负责 Rust 的不同部分。我不确定它们最终会如何关联,可能我们只是检查它们是否一致。我个人喜欢的一个想法是,英文规范将不会涉及所有细节,比如借用检查器究竟是如何工作的,或者类型检查器的具体工作机制。而是说一些类似:“借用检查器保证了这些不变性”,比如“你的程序不会以某种方式引用内存”,或者“你的程序不会做某些事情”。然后我们可以通过测试,可能 miror formality 非常精确地定义了借用检查器。我们可以根据这些不变性进行测试,看看是否能找到一个违反这些不变性但通过了类型检查的例子。如果找到了,那就是一个问题:要么不变性是错误的,要么 miror formality 是错误的。我认为这是一个不错的分离方式,至少我对第一版规范是这样感兴趣的。但随着时间的推移,或许我们会发现另一种关系更好,我也不确定。
好的,我的麦克风好像坏了。所以,再次感谢 Niik 的这次精彩的演讲!
Rust Powered Polymorphism ⚡️ With Traits
Rust 支持的 Traits 多态性
这段视频介绍了如何在 Rust 中使用 traits 实现多态性。虽然 Rust 不支持传统的继承,但它通过 traits 提供了类似的功能。视频展示了如何定义 traits,使用动态分发和静态分发来处理不同类型的数据,并介绍了 traits 的默认实现和超类型(super traits)的概念。通过这些功能,Rust 可以实现类似于其他面向对象语言的编程风格。
Rust 不支持传统的继承概念,而考虑到许多流行的现代语言都支持继承,这可能让一些人难以接受,有些人甚至认为,如果一种语言不支持继承,它就不能被认为是面向对象的。但 Rust 确实支持多态性,你仍然可以编写与其他语言非常相似的面向对象的 Rust 代码。不过,如果你不了解某些关键概念,它们可能会让你感到困惑。在本视频中,我们将揭开这些概念的神秘面纱。
当然,多态性指的是根据你需要做的事情,能够将同一数据视为不同类型的能力。一个简单的多态性例子是,如果我们考虑不同种类的交通工具,有些交通工具可以在陆地上行驶,有些则可以在水上漂浮,还有一些特殊的交通工具可以同时在陆地和水上行驶。假设我们想要编写一些业务逻辑,具体的交通工具类型无关紧要,但交通工具可以行驶的地表类型则很重要。如果你熟悉在其他语言中实现多态性,这个例子在开始时可能会让你感觉非常熟悉,但别担心,中途会有一个意想不到的变化,让事情变得更加有趣。
首先,我们要创建一个名为 Sedan 的结构体,Sedan 会有一个名为 drive 的方法,这个方法只会打印 “Sedan is driving”。它接受对自身的共享引用,虽然我们现在还没有使用它,但暂时不用担心。然后让我们编写一个主函数来测试它。
运行时我们得到了 “Sedan is driving”。现在,假设我们想要编写一个名为 road_trip 的函数,它接受对 Sedan 的共享引用,并调用 Sedan 的 drive 方法。因此,我们将不再直接调用 drive,而是改为调用 road_trip。
假设我们还有另一个名为 SUV 的结构体,它也实现了一个 drive 方法,而 SUV 的 drive 方法将与 Sedan 的 drive 方法几乎相同。
很好,road_trip 只关心传入的东西是否有 drive 方法,而 SUV 和 Sedan 都有 drive 方法,但我们只能传入 Sedan。如果我们想传入 SUV,我们可以编写一个单独的函数并将其改为 SUV。这是一个选择。如果你在其他语言中做过多态性,可能会觉得这一切都非常熟悉,你可能也知道接下来会发生什么,但是正如我之前提到的,中途会有一个意想不到的变化。
很好,我们得到了 “Sedan is driving” 和 “SUV is driving”。这符合预期。那么我们如何实现这样的功能,即 road_trip 可以接受任何实现了 drive 方法的类型,并在这一类型上调用 .drive 方法?如果你在像 Java 或 TypeScript 这样的语言中实现这一点,你可以简单地创建一个名为 LandCapable 的接口,并让 road_trip 接受一个 LandCapable 类型的参数,这样就可以了。但 Rust 有些不同,Rust 使用的是特征(trait),它们与接口非常相似,但有一些细微的差异。我们将创建一个名为 LandCapable 的特征,它将包含一个 drive 方法,该方法接受对自身的共享引用。
我们要告诉编译器,这个代码块中的 drive 方法是用来实现 LandCapable 特征的,为此我们将为 SUV 实现 LandCapable,同样的,我们也为 Sedan 实现。
现在你可能会觉得我们要删除这里的 SUV 方法,你可能会觉得我们可以这样做:我们只需要让 road_trip 接受一个类型为 LandCapable 的交通工具,但事实并非如此。编译器会提示我们需要在特征前加上 dyn 关键字。我们暂时按字面意思处理这一点,添加 dyn LandCapable。
好,这样就可以了。我们把 SUV 的特定函数删掉。
现在看上去没有任何编译器错误了,让我们看看是否有效。我们得到了预期的结果。
那么到底发生了什么?为什么我们不能直接告诉编译器这个参数是 LandCapable 类型的?我们在这里正面碰上了 Rust 的核心原则之一:零成本抽象。所谓零成本抽象,意味着如果有任何额外的成本,我们必须显式地添加这些成本,而 dyn 就是一个很好的例子。dyn 代表的是动态分发(Dynamic Dispatch),与之相对的是静态分发(Static Dispatch),我们稍后会讨论。对于那些带有 dyn 关键字的函数参数,传递给这些函数的参数实际上是由一个所谓的“胖指针”(fat pointer)表示的。胖指针是两个指针的集合,其中一个指向结构体的数据,另一个指向所谓的虚表(vtable),虚表中包含了该结构体实现的所有函数的指针。动态分发的缺点是我们会在运行时受到性能损失,因为我们必须做两次指针解引用来调用传入的函数。
动态分发的替代方案是静态分发。如果你曾在像 C++ 或 Rust 这样的语言中使用过泛型,你会知道泛型的工作原理是为每种类型在编译后的二进制文件中创建一个相应的实体副本。因此,如果我有一个泛型函数 road_trip<T>,编译器会查看所有可能传递给 road_trip 的类型 T,并在编译后的可执行文件中为每个类型创建一个副本。当 road_trip 被大量不同类型调用时,这可能并不理想,因为这会导致 road_trip 的多个副本,动态分发在这种情况下可能是更好的选择。因此,选择使用哪种分发方式取决于你的实际需求。
要实现静态分发,我们只需将 dyn 替换为泛型 impl,这是唯一的改变。
我们依然得到了相同的结果。在这个小程序中,我们不会注意到任何显著的差异,但是在一个有大量类型或对性能要求很高的程序中,动态分发与静态分发之间的选择非常关键。
Rust 中关于特征的另一个有趣的概念是默认实现。如果你熟悉 Java 或 TypeScript 中的接口,这个概念也同样存在。如果我想让 LandCapable 的实现者可以选择是否实现 drive 方法,我可以通过在 LandCapable 特征中提供一个默认实现来实现这一点。因此,我在特征中删除方法名后的分号,并在这里编写实现。
现在,由于我有了这个默认实现,我实际上可以删除 SUV 和 Sedan 中的 drive 实现。
即使我没有 drive 方法,也不会出现任何错误。
现在我们得到的是默认的 drive 方法输出,因为它会在 Sedan 上调用一次,然后在 SUV 上调用一次。
假设我有另一个名为 WaterCapable 的特征,它有一个名为 float 的方法。然后,我想创建另一个特征来表示既能漂浮又能驾驶的交通工具,比如气垫船。因此,我将创建一个名为 Amphibious 的特征,Amphibious 不会有任何特定的方法,但我们希望确保实现 Amphibious 的任何东西都实现了 LandCapable 和 WaterCapable。
接下来,我们创建一个 Hovercraft 结构体,并为它实现 Amphibious。
目前 Amphibious 没有任何方法,但如果我们希望强制所有实现 Amphibious 的类型同时实现 LandCapable 和 WaterCapable,我们可以使用所谓的“超特征”(Super Traits)。要指定超特征,只需在特征名称后加上冒号,然后加上一个用 + 分隔的超特征列表。因此,我们将添加 WaterCapable 和 LandCapable。
因此,我们在 Hovercraft 上实现了 Amphibious,但我们得到了一个错误,提示 Hovercraft 没有满足 LandCapable 的特征限定。因此,我们需要为 Hovercraft 创建一个 LandCapable 的实现。
由于我们已经有了 drive 的默认实现,我们只需添加这个空的 LandCapable 块。同样的,我们也为 WaterCapable 添加一个实现。
现在,我们编写一个函数,它接受一个 Amphibious 类型的交通工具作为参数。我们编写 traverse_frozen_lake 函数,然后在函数中调用 drive 和 float。
接着,在主函数中添加一个 Hovercraft。
为了避免混淆,我们删掉 SUV 和 Sedan。
现在我们成功地编写了 traverse_frozen_lake 函数。
这里我们不需要分号。
很好。当然,就像之前一样,如果我们愿意,我们可以为 Hovercraft 实现一个特定的 LandCapable。
因此,我们可以去实现 drive 方法。
很好,我们看到了 “Hovercraft driving” 和 “default float” 的输出。这就是一个快速演示,展示了如何使用特征来促进 Rust 中的面向对象编程。现在你已经正式成为“面向对象编程能力特征”的实现者了!
说到用 Rust 做一些有趣的事情,去看看这段视频《Rust 是数据科学的新王吗》吧,看看如何使用 Rust 进行数据科学,不需要任何先前的 Rust 或数据科学知识。感谢观看,我们下次见!
Rust’s second most complicated feature explained
Rust 的第二复杂特性解析
探索 Rust 的第二大复杂功能之一。发现它在流行库中的重要性,并了解它如何重塑您对 Rust 泛型、特征和生命周期的理解,从而增强高级编程技术和灵活性。
这是 Rust 中最复杂的特性之一——高阶特征限定(Higher Rank Trait Bounds),理解它非常重要,原因有两个:首先,你可能会在流行的 Rust 库的源码中遇到它,比如 Sur、Tokyo 或 Axom;其次,它会从根本上改变你对泛型、特征和生命周期如何协同工作的理解,让你的生活变得像噩梦一样复杂——噢,我是说,它能启用高级的泛型编程技术,提供最大的灵活性。
高阶特征限定之所以难以理解,部分原因在于它的文档非常稀少。因此,在本视频中,我将解释什么是高阶特征限定,以及它们如何在高级泛型代码中使用,以创建极其灵活的 API。
为了理解高阶特征限定,我们必须简要介绍特征、特征限定和生命周期注解。特征类似于其他语言中的接口,它们允许我们通过函数和方法以抽象的方式定义共享行为,类型则实现这些特征。另一方面,特征限定允许我们将特征与泛型结合使用。这段代码会给我们一个编译时错误,提示泛型 T 没有实现 Debug 特征。问题在于我们的函数接受任何泛型类型,而这些类型可能无法用 Debug 格式化进行打印。解决方法是使用特征限定,将这个泛型接受的具体类型限制为那些实现了 Debug 特征的类型。换句话说,这个泛型受到了 Debug 特征的限制,这就是特征限定名称的由来。需要注意的是,我们也可以使用 where 子句或 impl 语法来指定特征限定——稍后我们在高阶特征限定的例子中会看到。
高阶特征限定利用了 Rust 中的两种不同类型的泛型。到目前为止,我们见过类型泛型,它允许我们对具体类型进行抽象,但 Rust 还提供了另一种泛型——生命周期注解。与类型泛型不同,生命周期注解主要用于表达引用之间的生命周期关系,这有助于编译器确保引用在使用期间保持有效。
假设我们有一个函数,它接受一个字符串切片作为输入,并返回该切片中的第一个单词。实际上,编译器会将函数签名展开为这样:'a 是一个泛型生命周期注解,它创建了输入引用的生命周期与返回引用的生命周期之间的关系。这个关系声明,该函数返回的引用必须至少与输入引用一样长。这有助于编译器检查无效的引用。如果我们让返回引用的生命周期比输入引用的生命周期更长,编译器就会抛出错误。在这种情况下,my_string 会在内部作用域结束时被释放,因此之后使用返回的引用将是无效的。Rust 编译器阻止了这个内存安全问题,幸运的是,在大多数情况下,我们甚至不需要显式地编写生命周期注解,因为 Rust 编译器足够聪明,可以推断它们。
现在我们已经理解了特征、特征限定和泛型生命周期注解,让我们讨论高阶特征限定。到目前为止,我们是分别使用特征限定和生命周期泛型的。高阶特征限定通过允许特征限定在生命周期之上进行高阶(Higher-Ranked)的操作,将这些概念结合起来。换句话说,它允许我们指定特征限定对于所有可能的生命周期都成立。这在表达复杂的生命周期关系时非常有用。
这里有一个特征叫 Formatter,它定义了一个函数,接受任何实现了 Display 特征的类型并返回格式化的字符串。我们将在一个简单的 Formatter 结构体上实现这个特征,然后创建一个函数,该函数接受一个 Formatter 并返回一个闭包,该闭包使用该 Formatter 格式化给定的字符串。注意,为了返回一个闭包,我们使用了特殊的 Fn 特征来定义闭包的签名。
在 main 函数中,我们可以创建这个闭包,并对字符串切片和堆分配的字符串调用它。如果我们回头看 apply_format 函数,你会注意到返回的闭包接受了一个引用。编译器推断了这个引用的泛型生命周期注解,但如果我们要显式地编写它,会是什么样子呢?你可能会直觉地这样编写:通过在 apply_format 函数上定义一个泛型生命周期,并将该泛型生命周期赋给引用。然而,这段代码会给我们一个编译时错误,提示借用的值可能没有足够长的生命周期。
为了理解这个错误,我们需要分解 apply_format 的函数签名。'a 被声明为函数的泛型生命周期参数,但它没有出现在输入参数中,而是只出现在返回类型中。那么,'a 在这里创建了什么关系呢?在这种情况下,'a 建立了返回闭包接受的输入引用的生命周期与闭包本身的生命周期之间的关系。这个关系确保了 apply_format 返回的闭包只能接受与闭包本身生命周期至少一样长的引用。
回到 main 函数,我们可以看到闭包存储在 format_function 变量中,直到 main 作用域结束时才会被释放。Rust 中的值是按照定义的相反顺序被释放的,这意味着 S2 会在 format_function 之前被释放。一旦 S2 被释放,所有指向 S2 的引用都会变得无效,因为这些引用指向了已被释放的内存。因此,所有指向字符串 S2 的引用的生命周期将在 S2 被释放时结束。问题是,存储在 format_function 中的闭包期望所有传递给它的引用的生命周期至少与闭包本身一样长。
解决这个错误的一种方法是,在创建 S2 之后再定义 format_function 变量,这样 format_function 会在 S2 之前被释放。但这并不能解决所有可能的闭包超出传递给它的引用的生命周期的问题。例如,如果我们创建了一个内部作用域,并使用一个在该内部作用域中定义的字符串引用调用我们的闭包,我们会遇到类似的编译时错误,提示借用的值没有足够长的生命周期。
在这种情况下,S3 会在内部作用域结束时被释放,这是不允许的,因为指向 S3 的引用在闭包被释放之前就已经无效了。现在,这段代码在技术上是内存安全的,但它并不完全满足我们最初设定的生命周期限制,因此会出现编译时错误。核心问题在于我们最初设定的生命周期限制过于严格,因为我们定义的泛型生命周期注解将传递给闭包的引用的生命周期与闭包本身的生命周期绑定在了一起。
我们真正想要的是,闭包能够处理任何生命周期的引用。我们可以通过高阶特征限定来实现这一点。与其在函数上定义生命周期泛型,不如使用 for<'a> 语法,使 Fn 特征限定在生命周期之上进行高阶操作,意味着返回的闭包可以处理任意生命周期的字符串切片,只要该生命周期至少涵盖闭包执行的持续时间。
通过这一简单的修改,我们的代码现在可以编译了。与生命周期泛型类似,在许多情况下,高阶特征限定并不需要显式编写,因为编译器足够智能,能够推断它们。然而,在某些情况下,显式地写出它们是必须的,这就是为什么当你查看处理复杂泛型抽象的 Rust 包时,可能会遇到它们的原因。现在,当你遇到这种语法时,你将知道它是什么意思以及为什么要使用它。
如果你想成为一名 Rust 开发者,记得到 letsry.com/bootcamp 免费获取为期 4 天的培训。希望你喜欢这个视频,记住要保持 Rusty(保持用 Rust 编程的状态)!
Rust 中同步和异步都是什么?为何要用异步写代码?
你给你喜欢的女生发了消息,但她没有立刻回复。你左等右等,浪费了一整天,最后只等来一个“嗯”。这就是一个同步操作的例子。在同步编程中,程序会被阻塞,只有当一个操作完成后,才能继续执行下一个操作。就像你等待她回复时一样,如果对方没有立刻回复,你就只能原地等待,别的事情也做不了。
阿伟和彬彬在网吧打游戏,杰哥想搭讪他们,但他们正忙着打游戏,暂时无法互动。如果杰哥选择原地等着,像同步操作一样,他就只能浪费时间。然而,杰哥没有傻等,而是决定去超市买个面包。在这段等待时间里,他还能做其他事情。这就是异步操作的概念。30分钟后,当阿伟和彬彬结束游戏,杰哥刚好回来,顺势搭讪,一点时间都没浪费。这种一边等待任务完成,一边继续处理其他任务的方式,就是异步编程的核心思想。
通过异步编程,程序在等待某个操作(比如 I/O 操作)时,可以继续处理其他任务,直到等待的操作完成。这是深度挖掘编程知识的小报,如果你对编程感兴趣,不妨一键三连并关注我,支持一下。哦对了,长按点赞可以一键三连。
你可能会问:“这是什么?” 这就是并发。在计算机编程中,同步编程是一种最直接的编程方式。在这种模式下,程序的每一行代码都会依次执行,只有当前操作完成后,才会执行下一步操作。比如当你发起一个网络请求时,程序会等待服务器返回数据后,才会继续执行代码。这种方式虽然简单易懂,但在涉及大量 I/O 操作时(比如网络请求或文件读写),可能导致程序效率低下。
相比之下,异步编程虽然稍微复杂一些,但更加高效。它允许程序在等待某些操作完成时,不必停下来等待,而是继续执行其他任务。当等待的操作完成后,程序会收到一个通知,告诉它可以处理结果了。这种非阻塞的执行方式在处理大量 I/O 操作时尤为有效,因为程序可以在等待响应的同时,充分利用 CPU 资源处理其他任务,从而提升整体性能。
异步编程通常用于需要处理大量 I/O 操作的场景,比如外部服务、数据库操作、文件处理等。在这些场景中,程序需要同时处理大量请求或数据流,异步编程可以让这些操作并发执行,从而提高整体处理效率。比如在外部服务器上,异步编程可以让服务器同时处理成千上万的客户端请求,而不会因为等待某个请求的完成而影响其他请求的处理。
如今,许多 Rust 的 Web 框架都是基于异步运行时编写的,比如大名鼎鼎的 Actix Web。在 Rust 中,我们经常看到异步编程的语法糖 async。通过使用 async 语法糖和诸如 join 这样的宏或异步库,我们可以实现多个任务的并发执行。
可能有人会问:“同步代码也可以通过多线程实现并行,为什么还要用异步呢?” 在 Rust 中,异步编程相比同步多线程通常更轻量,性能损耗更小。这是因为异步任务在同一个线程上调度,避免了频繁的线程切换开销。如果你的应用程序是小型服务或者请求量不大,那么差异可能不明显。但在高并发场景下,异步编程的优势就非常明显了。
举个例子,我们写了两个函数,一个通过多线程并行执行同步任务,另一个通过异步运行时并发执行任务。在多轮测试中,我们发现异步编程的效率稍胜一筹。这是因为操作系统管理线程的开销通常比异步任务的调度开销要大,这种差异在高并发的网络服务中尤为显著。因此,大多数网络服务通常采用异步编程来提高系统的可用性和性能。
在 Rust 中,async 语法使我们能够以直观的方式编写异步代码,但这并不等于异步编程本身。当你在 Rust 中使用 async 关键字时,实际上是让编译器将你的代码转换成一个状态机。这个状态机包含所有的逻辑,它会在不同的状态之间切换,而这些状态代表了异步任务的不同阶段。例如,当一个异步操作需要等待外部资源(如网络请求)时,状态机会保存当前的执行状态,并让出控制权,等资源准备好时再恢复执行。这种状态机的生成和管理是由编译器自动完成的,因此从表面上看,使用 async 语法的代码看起来就像在写普通的同步代码。
然而,光有状态机和 async 语法是不够的,它们只是实现异步编程的基础。为了让这些状态机实际运作起来,我们还需要一个异步运行时。这个运行时负责调度这些状态机,让它们在合适的时机被唤醒并继续执行。在 Rust 中,常见的异步运行时包括 Tokio 和 Actix。
异步运行时不仅要管理状态机的调度,还要处理底层的 I/O 操作,比如网络请求或文件读取。它通过非阻塞的方式执行这些操作,使得程序在等待 I/O 的过程中可以继续处理其他任务。因此,async 语法只是实现异步编程的一部分,它让我们能够定义和构建异步任务,但真正的异步编程还依赖运行时的调度来管理这些任务的执行过程。
所以,虽然 async 是异步编程中非常重要的工具,但它并不等于异步编程本身。真正的异步编程是一个更复杂的系统工程,它包括状态机的生成、运行时的调度以及任务的非阻塞执行。比如,外部服务中经常使用的回调机制也是异步编程理念的实践。回调机制允许程序在执行异步操作时不必等待结果,而是通过回调函数在操作完成时收到通知,这样程序可以继续执行其他任务,而不会被阻塞。
无论是通过回调、Promise,还是现代语言中的 async 和 await 语法,背后实现的通常都是异步编程的核心理念:在处理长时间运行或 I/O 操作时,最大化程序的并发性和响应性。虽然 async 是实现异步编程的重要部分,但异步编程的真正核心在于通过状态机、运行时的调度以及非阻塞操作来提高程序的并发性和效率。这些技术和理念共同构成了异步编程这一复杂而强大的系统工程。
这里是深度挖掘编程知识的小报,如果你也对编程感兴趣,不妨一键三连并关注我。我们下期节目再见,拜拜!
Rust - 简单的代码布局(简单起步,更好扩展)
https://www.youtube.com/watch?v=oxx7MmN4Ib0
这是我喜欢用来开始新 Rust 项目的方式,并且效果一直不错。最后还有一个小彩蛋,我们将看看如何使用 newtype 模式来为外部类型实现外部 trait。
首先,我使用 thiserror 进行错误处理。其次,如果我知道要使用异步操作,我会使用 tokio;如果需要实现命令行接口,我会使用 clap,但今天不在讨论范围内。然后,在开发依赖项中,我只会使用 anyhow 来处理错误,这比 thiserror 更宽松一些,适合单元测试、示例和基准测试。
接下来,在 main.rs 中,我通常会在一开始屏蔽一些警告,比如未使用的代码。最终我会注释掉这行代码,甚至删除它。第一个我创建的模块是 crate::error,我会创建一个文件,遵循 NM 模式,使用 thiserror,并为现在创建两个错误变体:一个是通用错误,另一个通常用于 IO 错误(例如,如果我知道会使用 IO 操作)。通用错误的目的是为了开始使用,最终我会移除它,比如在移除后可以跟随编译器的提示来修复相关代码。
接下来,我会创建第二个模块 crate::prelude。这是一个我非常喜欢用于应用程序的模式,但要注意保持这个模块很小,不要滥用它,否则会适得其反。在这个模块的开头,我会重新导出我们刚刚创建的 Error 类型,然后导出一个 Result 类型别名,使用我们自定义的错误类型。这是 Rust 中非常常见的模式。
接下来,我们将创建一个包装器结构体(newtype 模式),这将用于在外部类型上实现外部 trait,将来这会非常有用。然后,我还会加入一些个人喜好,比如我喜欢将 format! 宏的别名设为 F!,但这只是个人偏好。
第三个我喜欢创建的模块是 utils 模块,但与其只在一个文件中创建它,我会将其放在一个文件夹中,因为这个模块在初期会增长得非常快,所以我直接将其创建为一个目录模块。此外,我会在该模块中创建一个 VS Code 代码片段,方便我们使用 crate::prelude。
接着回到 main.rs,我会引入 Prelude,通常 main 函数的返回值是 Result<(), Error>,并且返回 Ok(())。这样,我们就准备好开始了。
现在,作为一个彩蛋,我们来看看如何使用 newtype 模式为外部类型实现外部 trait。比如,我们想读取本地目录,所以我们会引入 read_dir,然后在 main.rs 中,使用 for entry in read_dir 来遍历当前目录。接着,我们会使用 filter_map 来过滤掉无效的条目,并以调试模式打印每个条目。我们关闭编辑器的左侧,打开右侧,运行 cargo run,然后我们可以看到所有的条目。
目前为止一切正常。假设我们不想要条目对象,而是想要一个字符串,因为我们想把它存储在某个对象中。我们可以通过 entry.path().to_str().map(String::from) 的方式来实现,从条目获取路径,再转换为字符串。如果转换失败,我们返回一个通用错误。运行 cargo run,我们可以看到字符串。
这样做的问题是,如果应用程序代码中经常使用条目对象,这种转换会显得很繁琐。此时,我们可以实现一个 trait,或者实现 TryFrom,在这种情况下非常合适。但因为 DirEntry 和 TryFrom 都是外部类型,我们需要使用 newtype 模式。
我们将复制这段代码,移除不需要的部分,关闭右侧,打开左侧,进入 utils 模块,创建一个子模块,命名为 dir_entry,并使用小写字母,这是我的最佳实践。我习惯将所有 TryFrom 实现放在这种模块下。
然后我们进入文件,包含 prelude,并使用一个 VS Code 代码片段来实现 TryFrom。目标类型是 W<&DirEntry>,其中 W 是 newtype 模式的包装器。我们现在可以为这个类型实现 TryFrom<String>,使用 val.0 来解引用 newtype,然后粘贴我们之前的转换代码,移除最后两个字符,将 entry 替换为 val.0,导入 DirEntry,这样我们就有了干净的 TryFrom 实现。
现在,我们回到 main.rs,声明一个 let entry_str: String,使用 W(&entry).try_into()?,这样就完成了。关闭左侧,打开右侧,运行 cargo run,我们得到了字符串。我们可以移除调试代码,运行 cargo run,得到了干净的字符串输出。
这就是我们使用 newtype 模式和 W 包装器的方式。最后,关闭所有文件,清理 utils 模块,这样我们就有了干净的代码。
今天的内容就到这里,这是一个简单的代码布局示例,希望它可以很好地扩展。希望你喜欢,下次见,祝你编码愉快!
Rust 编程:终极构建者模式教程
https://www.youtube.com/watch?v=Z_3WOSiYYFY
Rust 编程构建器模式教程和构造函数模式。我们还将概述非消费型和消费型构建器模式之间的优缺点。
1)结构体文字和简单的构造函数是创建结构体和枚举的好方法。
2)构建器模式在构建更复杂的数据结构时很有用,尤其是在需要分步构建时。
3)非消耗性构建器模式 prop…(&mut self) 和 build(&self) 可以被认为更符合人体工程学,但当构建的对象需要拥有传递给构建器的数据时,需要不必要的分配。
具有可变构建的非消费构建器模式(即 build(&mut self) )可能被视为一种不好的模式,因为两次调用 build() 将产生不同的结果(第 6) 条情况除外)。
消费构建器模式需要为每个“构建器链命令”重新分配构建器,并在需要重用构建器时进行克隆。但它可以避免不必要的分配。
在某些情况下,构建的对象不会从构建器中获取任何值,非消费构建器模式可能是合适的。
就我个人而言,除了第 6) 条之外,我发现消费构建器模式更符合编译器所有权规则。通过要求克隆,新的分配变得明确,并且构建器无法在没有明确克隆的情况下重用。
但是,请研究每种模式的优缺点,并针对您的给定用例使用对您和您的团队有意义的模式。
“impl Into String”模式与构造函数或构建器模式无关,但可以与任何函数一起使用。
下面是整理后的内容及其中文翻译:
今天我们将对构建者模式(Builder Pattern)进行概述。我们将从非消耗型的构建者模式开始,它使用 &self 引用,然后将其与消耗型构建者模式进行比较。首先,我们将从结构体字面量到构造函数、默认值以及结构体字面量和构造函数的混合使用,来概述构造模式。
首先,让我们从一个简单的结构体 Task 开始。我们将它派生为 Debug,因为我们打算打印它,结构体中包含三个公共属性:title(标题)、done(完成标记)和 description(描述),其中 description 是一个可选的字符串(Option<String>)。构建这个结构体最简单的方法是使用结构体字面量,定义一个 let task: Task,然后为每个属性设置值。接着,我们可以打印这个任务,并通过 cargo run --quiet 在终端中查看输出,结果显示该任务及其三个属性。
这种方法是最简单的,并且对于非常简单的数据来说非常完美。下一步是创建一个构造函数。为此,我们将为 Task 实现 impl 块,并定义一个与 self 无关的关联函数(associated function),因为它将创建 Task 实例。对于这个新函数,我们只需要传入 title,并遵循一种转换模式:将输入的参数转换为 String,这允许我们传入 &str、String 或字符串切片,并且只有在需要时才会进行内存分配。然后,该函数将返回 Task 实例。
在构造函数中,我们将使用 title.into() 来设置标题,这样只有传入 &str 或字符串切片时才会进行新的分配。接着,done 属性设置为 false,description 设置为 None。如果有多个构造函数,并且每个函数处理不同的属性,一种常见的命名模式是使用 with_ 前缀加上属性名,但在我们的例子中,new 就足够了。使用方法是 Task::new("Task 01"),通过 cargo run --quiet,我们可以看到任务的值。
通常,为数据类型实现 Default 也是非常有用的。因此,在这个例子中,我们将手动实现 Default trait。我有一个小代码片段,可以快速实现这个功能。在 Default 实现中,我们返回一个 Task 实例,并将 title 设置为 “Untitled”。我们也可以直接派生 Default,但手动实现可以让我们精确控制每个字段的默认值。使用时可以通过 Task::default() 来创建默认的任务实例。运行 cargo run 后,我们可以看到默认字段的值。
实现 Default 的好处是,许多其他函数可以利用类型的默认实现。例如,如果我们有一个 Option<Task>,并且它的初始值为 None,我们可以使用 task.unwrap_or_default(),这会在 Option 为 None 时返回默认的 Task 实例。如果我们运行 cargo run,可以看到任务的默认数据。
实现 Default 还有另一个用途,就是当你使用结构体字面量构造时,可以结合 ..Default::default() 语法来自动填充未定义的字段。例如,如果我们希望 done 为 true,其他字段使用默认值,可以使用 ..Default::default() 语法。如果我们运行 cargo run,输出将显示 title 为 “Untitled”,并且 done 为 true。
这种方法的一个灵活之处在于,它不需要字段的默认值。例如,我们可以调用之前的构造函数 Task::new() 来设置 title,同时使用结构体字面量来设置 done 为 true。因此,构造函数和结构体字面量的组合是非常好的模式,但有时你可能希望逐步构建更复杂的对象。接下来,我们将以一个 Web 请求构建器为例,展示如何分步骤构建请求。
首先,我们创建一个 web 模块,使用我喜欢的 Prelude 模式来处理错误和结果。然后我们定义一个 Request 结构体,这是我们要创建的对象。现在,这个结构体中的所有属性都是公共的,稍后我们会看到其实不需要这样做。首先,url 是一个字符串,method 也是字符串(在实际应用中,它应该是一个枚举),headers 是一个包含键值对的元组向量,最后 body 是一个可选的字符串。
这是一个非常简单的例子,实际的 Web 请求可能会更复杂。我们将 Request 派生为 Debug,这样我们可以打印它。接下来,我们创建一个 RequestBuilder 结构体,它与 Request 结构体几乎相同,不同之处在于所有字段都是可选的。例如,url 是 Option<String>,method 是 Option<String>。在这个简单的例子中,headers 仍然是一个向量,因为如果它是空的,意味着没有头部信息。而 body 已经是 Option<String>,所以不需要再次包装。
现在,这些字段是公共的,但稍后我们会看到,不需要它们是公共的。我们不需要为 RequestBuilder 派生 Debug,但可以为它派生 Default。通过派生 Default,我们可以使用 RequestBuilder::default() 来创建一个默认的构建器实例,其中所有的字段都是 None,并且 headers 向量默认是空的。
接着,我们为 RequestBuilder 实现了一些方法。第一个是构造函数 new(),它返回 RequestBuilder 实例。因为我们已经为 RequestBuilder 派生了 Default,所以可以直接调用 RequestBuilder::default()。接下来,我们为每个属性(如 url、method、body 等)定义了一个方法,这些方法会接收一个 &mut self 引用,表示这是一个非消耗型模式。每次调用这些方法时,它只会修改 self 的可变引用,而不会消耗它,因此我们可以链式调用这些方法。
例如,url() 方法接收一个字符串参数,并将其转换为 String,然后设置 self.url 为 Some(url.into())。方法的返回值是 &mut self,因此可以支持链式调用。我们为 method()、body() 等其他字段实现了类似的方法。
对于 headers(),它稍微有些不同。我们设计的构建器模式是一次添加一个头部信息。方法会接收 name 和 value,并将它们作为一个元组添加到 headers 向量中。然后,返回 &mut self 以支持链式调用。
最后,我们实现了 build() 方法,它会构建最终的 Request 对象。在这个方法中,我们检查是否提供了 url,如果没有,我们返回一个错误。我们使用 let Some(url) = self.url.as_ref() 来检查 url 是否存在。如果不存在,我们返回错误。对于 method,我们默认设置为 "GET",然后返回构建好的 Request 对象。
在 main.rs 中,我们可以使用 RequestBuilder 来构建请求对象。通过链式调用,我们可以设置 url、method 和 headers,最后调用 build() 方法。如果我们运行 cargo run,可以看到构建的请求对象。
通常,你可能希望在构建请求时加快速度。例如,你可以提前构建 RequestBuilder,然后在中间执行一些操作,最后再调用 build() 方法。然而,此时会出现一个问题:RequestBuilder 的方法返回的是 &mut self,因此必须保持 RequestBuilder 的可变性。为了解决这个问题,我们可以在创建 RequestBuilder 时将其声明为 mut,这样可以在多次调用 build() 方法后继续使用它。
然而,当前的实现中,即使只调用一次 build(),我们也会克隆数据,因为我们使用了 &self 引用。为了解决这个问题,我们可以将 build() 方法的参数改为 &mut self,这样我们可以在构建请求时移动数据,而不必克隆它们。通过这种方式,我们可以避免不必要的内存分配。
但是,使用 &mut self 的 build() 方法会导致一个问题,即如果你多次调用 build(),在第一次调用后,RequestBuilder 中的 url 等字段会被移动,导致后续的调用失败。因此,我们通常不建议这样做,因为它可能会导致 API 的行为不一致。
接下来,我们将 build() 方法改为消耗型的,参数类型从 &self 改为 self。这样,我们可以移动数据而不是克隆它们。同时,我们需要确保 RequestBuilder 的所有方法也返回 self,而不是 &mut self,以支持链式调用。
在 main.rs 中,我们可以直接调用 RequestBuilder::new(),并链式调用 url()、method() 等方法,最后调用 build()。这样可以避免不必要的克隆操作。如果你需要在同一构建器上多次调用 build(),可以通过实现 Clone trait 来解决。每次调用 build() 之前,先克隆 RequestBuilder,然后再调用 build()。
最后,我们可以进行一些代码清理。通常,构建器模式中的属性不应是公共的,因此我们将 RequestBuilder 和 Request 中的属性设为私有。同时,我们可以对 url() 等方法进行优化,直接使用 Option::insert() 来替代新建 Option 的操作,这样可以避免不必要的内存分配。
最后,运行 cargo run,一切工作正常。这就是构建较为复杂对象时可以使用的构建者模式。希望你觉得这个教程有帮助,下次见,祝你编程愉快!
LLVM基础架构与Rust
看了一篇介绍LLVM基础架构的文章,这篇文章大概写了一年多,标题叫做“LLVM基础架构与Rust”。虽然标题看起来像是在蹭Rust的热度,但文章大部分内容还是在讲解LLVM的基础知识。以下是文章的内容整理。
LLVM是许多编程语言的底层引擎,包括C++、Rust、Go、Swift等。文章主要探讨了几个主题:什么是LLVM基础架构、LLVM是如何工作的、LLVM的程序架构,以及LLVM与Rust、C之间的关系。
什么是LLVM基础架构?
LLVM是一个模块化、可重用的编译器和工具链技术集合。它包含如C语言的编译器、调试工具(如LLDB)、以及libc++等子项目。经过多年的研究与开发,LLVM已经成为构建语言和编写程序的强大工具,很多现代技术都依赖它。
LLVM项目最早于2000年由Vikram Adve和Chris Lattner在伊利诺伊大学厄巴纳-香槟分校发起。最初,LLVM是“Low-Level Virtual Machine”(低级虚拟机)的缩写,但现在这个缩写已经不再使用。LLVM最初是一个开源项目,后来Chris Lattner被苹果公司雇佣,LLVM被用于苹果产品的多种用途。随后,LLVM的功能扩展,发展成为一个伞形项目,集合了LLVM IR、LLVM调试器、libc++等库。
LLVM编译器框架使用LLVM基础架构,提供端到端的代码编译、模块化和可重用的编译架构。它用于构建、优化、清理和生成中间代码(IR)或二进制代码(如bitcode)。LLVM允许将编程语言转译为中间表示(IR),并将其转换为适用于任意硬件架构的二进制代码。
LLVM是如何工作的?
LLVM是一个模块化、可重用的编译器框架,支持多种前端和后端。编译过程分为三个阶段:前端、优化和后端。
前端:前端首先对源代码进行预处理,并扩展外部库。在Rust中,这通过
use语句来实现;在C和C++中,通过#include语句实现。然后,编译器会检查源代码的语法错误,并构建抽象语法树(AST)。接下来,前端生成中间表示(IR)。优化:编译器对IR进行各种转换和清理,以提高程序的性能和安全性,并修复错误。优化器通过一系列称为“pass”的操作完成这些任务。优化pass分为两类:分析pass和转换pass。分析pass用于分析IR并获取程序信息;转换pass用于修改IR,以改善程序性能。
后端:后端将IR转换为目标平台的汇编代码,最后链接器将多个机器代码合并为可执行文件。
LLVM IR的特点
LLVM IR是一种类似汇编语言的低级编程语言,但相比汇编语言,它具有更好的类型注释和更人性化的语法。IR使用无限数量的临时寄存器,而不是预定义数量的寄存器,这使得它在编译过程中更加灵活。
LLVM IR支持三种主要的存储格式:内存格式、bitcode格式和汇编格式。其中,内存格式适用于编译器前端,bitcode格式适合快速加载,而汇编格式则更适合人类阅读。
LLVM IR可以通过LLVM的汇编器转换为原生二进制文件,也可以通过优化器对源代码进行优化。LLVM的程序结构是层次化的,顶层容器是模块(module),它包含一个或多个函数,每个函数包含多个基本块,而基本块包含指令。指令是单行的可执行代码。
LLVM与Rust的关系
Rust编译器(rustc)使用LLVM的主要原因包括:
- 完整的编译器后端:LLVM提供了一个完整的后端,这减少了rustc的实现和维护工作。
- 高级优化套件:LLVM提供了大量高级优化工具,可以帮助rustc生成更高效的代码。
- 多平台支持:LLVM支持多个平台,使rustc能够将Rust编译到多种平台,如x86、ARM等。
- 安全漏洞修复:由于Rust和其他编译器都使用LLVM,因此一旦发现安全漏洞,修补LLVM就可以解决所有使用LLVM的编译器项目的安全问题。
总结
LLVM IR具有许多用途,允许通过pass对源代码进行分析和优化。了解LLVM IR有助于编写pass,并构建用于调试、测试和优化的工具。尽管LLVM目前没有Rust的API,但有一些用户创建的存储库可以用于Rust中的LLVM绑定。
文章详细地展示了LLVM的运作方式,包括Rust编译器如何利用LLVM进行代码优化和生成。
Rust 生产级别 Workspace 组织示例
https://www.youtube.com/watch?v=zUxF0kvydJs
我们将使用Cargo工作区(cargo workspace)来构建我们的生产Web应用程序,这将允许我们将Web应用程序扩展为多服务系统。生产环境中的应用程序通常从单一服务开始,但几乎总是会逐步演变为多服务系统。其中一些服务可以由Kubernetes、AWS Lambda或其他平台管理,但无论如何,我们都需要规范行为并最大化代码重用,而这正是Cargo工作区的优势所在。
目前,我们的架构是单体布局,主要包括模型存储(model store)、上下文(context)、事件(event)、辅助工具(helper)、与Web无关的RPC、身份认证(auth)和工具(utils)。然后,我们将这些内容打包成一个Web服务器。然而,正如我们之前所说,我们最终会演化为多服务系统。因此,我们希望将模块拆分为可重用的部分。
首先,我们将模型存储、上下文和事件归为核心模块(core)。然后,将RPC、身份认证和工具分别拆分成各自的crate。接着,我们的Web层将成为Web服务器服务(web server service)。当我们有多个Web服务器时,可能还会创建一个lib web库,其中包含一些可重用的Web构件(例如,Axum请求日志等)。从结构上来说,我们会将所有这些crate放在一个文件夹下,并且将它们组织成可重用的库。目前我们只有一个服务,但未来可能会有更多服务,例如面向终端用户的服务器、面向DevOps的服务器,或者一些AI服务、数据抓取服务等。
此外,我们还有一些工具,这些工具可能只在开发机器或构建机器上运行,用于管理或其他类似的工作。现在我们有一个名为gen key的工具。接下来,我们来看看Cargo.toml如何配置。
首先,我喜欢在最开始启用工作区链接(workspace link)。自Rust 1.74版起,我们可以禁止使用不安全代码(unsafe code),因此,如果你没有15年以上的C++经验,不要编写不安全代码。此外,我喜欢启用一个未使用代码警告的设置(unused allow),在进行实验性开发时,我会临时打开它,这样就不会有大量警告信息,但在提交代码前我会将其关闭。很棒的是,这些设置现在可以写在Cargo.toml文件中,而不是放在源代码中。
接下来是工作区配置。非常重要的一点是,我们必须将resolver设置为2,因为我们将所有内容都放在各自的crate中,因此我们没有基础crate,也就没有package,因此无法直接指定2021版标准(Edition 2021)。因此,我们必须显式地将resolver设置为2。
然后,我们有应用程序库(application libraries)。我将所有内容都放在crates文件夹下,这只是个人的偏好,以便所有内容都在同一个文件夹中,不会与其他内容混淆。接着,我们有三个主要类别:libs(库)、services(服务)和tools(工具)。libs是我们所有的应用程序库,我习惯为所有库加上“lib-”前缀,这样当我在应用程序代码或库代码中使用时,我可以清楚地知道它们来自我们的应用程序库,而不是外部库。然后是services,目前我们只有一个Web服务器服务,但正如我们讨论过的,未来可能会有其他服务。最后是tools,目前我们只有一个gen key工具,这些工具可以帮助我们更好地扩展。
未来我们可能还会有一个macros文件夹,用于存放过程宏(proc-macro)。尽管现在还没有这个文件夹,但将来我们很可能会添加它。这个代码结构应该能够很好地扩展。
现在让我们来看一个crate的Cargo.toml文件结构。我们有package部分,定义了包名和版本号(为了简单,我们将版本号定为1.0.0)。然后是edition部分,我们选择不运行文档测试(doc test),尽管你可以采用不同的策略。对于我来说,我喜欢在文档中放置示例代码,但这些代码并不需要测试,因为它们并非为外部使用而设计的。不过,另一种策略也是完全可行的。
接着是Rust 1.74引入的新功能——工作区级别的lint(Lin workspace)。这很重要,因为它会生成工作区内的所有lint规则。当前的策略是不对lint进行过于细致的划分,而是将其统一管理在根目录下,之后我们可能会改变这个策略。
接下来是依赖管理部分。我们可以看到,所有依赖项都使用相对路径进行管理。无论我在哪个服务或crate中,我都可以复制依赖项的路径,并将其放入另一个crate中,保证一切正常工作。这样做的原因是,如果路径设置得过于智能化,就很难在不同的crate间复用配置。因此,我采用这种简单的路径管理方式,以便可以在任何地方复用。
此外,我们希望尽量减少每个库所需的依赖项数量。我通常会先复制所有依赖项,然后逐一删除,直到代码无法编译为止,这样我就能只保留当前crate真正需要的依赖项。
需要注意的是,工作区中只有一个Cargo.lock文件,这意味着所有crate共享同一个Cargo.lock文件。尽管如此,每个crate都有自己的Cargo.toml文件,用于定义其运行所需的依赖项。
工作区的一个重要特点是,crate之间不能有循环依赖。例如,核心模块不能依赖于RPC模块,同时RPC模块也不能依赖核心模块。我们必须确保依赖关系是单向的。例如,Web应用程序依赖于RPC模块,RPC模块依赖于身份认证(auth)模块,但身份认证模块不能依赖RPC模块。通过这种方式,我们可以确保代码的依赖结构合理且清晰。
最后,关于不同crate的相互依赖问题。Web是我们Web服务的一部分,而核心模块(libcore)、RPC模块(librpc)、身份认证模块(libauth)和工具模块(libutils)也是如此。假设我们在核心模块中有一个组件(例如,CTX),而在Web层中我们需要使用Axum框架实现FromRequest trait。然而,核心模块是与Web无关的,这就导致我们无法在Web模块中为CTX实现FromRequest。一种解决方案是将该trait的实现放在核心模块中,但这会导致核心模块依赖于Axum和Web层,违背了设计初衷。
为了解决这个问题,我们使用了新类型模式(newtype pattern),创建了一个CTX包装器(CTXW)。这种模式允许我们在Web模块中为包装器实现FromRequest,而不会影响核心模块的独立性。我们可以在需要时从CTXW中提取出真实的CTX对象,并在整个代码中使用它。
还有一点需要注意的是,当你设置环境变量时,特别是在使用cargo-conf技巧时,确保在Rust日志环境变量中正确配置不同crate的名称,否则可能会导致日志记录的问题。运行应用程序时,可以使用cargo run -p crate_name命令来运行特定的crate,例如cargo run -p gen-key来运行gen-key工具,或者cargo run -p web-server来运行Web服务器。
最后一个提示是,当你从单体代码迁移到工作区代码布局时,我通常会从依赖最少的模块开始迁移。首先迁移身份认证模块(libauth),然后是RPC模块(librpc)、核心模块(libcore),最后迁移Web服务器。在每次迁移时,我都会创建一个新的crate,并确保代码能够编译成功。这个过程虽然有些繁琐,但一旦完成,后续的开发工作会非常顺利。
我已经将代码提交到了Rust 10x的GitHub仓库,提交的版本号是”E3”。你可以在那里查看代码。希望你喜欢这期内容,感谢CRA Nebula的赞助。期待下期节目,再见!
如何写好构造函数
https://www.bilibili.com/video/BV1Tb4y1N7Ux/
https://www.youtube.com/watch?v=KWB-gDVuy_I
Constructors promise a safe, guaranteed way to initialize objects in C++. But what they actually provide is many subtle opportunities to read uninitialized memory, and ergonomic issues that can negatively affect performance (by needing to throw exceptions to signal failure) and memory usage (by needing to juggle class layout in order to initialize members in the right order in the constructor initializer list). Join me as we spelunk into some of these topics and learn a healthy alternative from Rust, the factory function, that might help our code be safer and better.
Special guest appearances from struct update syntax/functional record update, non-static data member initializers/NSDMI, NonZeroU8 and niche optimizations, Option/optional, and lots of pontification about type invariants.
构造函数承诺在C++中以安全、有保证的方式初始化对象。但它们实际上提供了许多微妙的机会来读取未初始化的内存,以及可能对性能(需要抛出信号失败的异常)和内存使用(需要调整类布局,以便在构造函数初始化器列表中以正确的顺序初始化成员)产生负面影响的人体工程学问题。和我一起深入了解这些主题,并从Rust的工厂函数中学习一种健康的替代方案,这可能有助于我们的代码更安全、更好。
结构更新语法/函数记录更新、非静态数据成员初始化器/NSDMI、NonZeroU8和利基优化、选项/可选以及大量关于类型不变量的断言中的特殊访客出现。
很多人都在谈论 Rust,并讨论他们希望在 C++ 中看到的功能,或者 Rust 编程如何让他们成为更好的 C++ 程序员。我自己在两天前的 YouTube 评论中也说过类似的内容。我想讨论一个与此相关的话题,但这并不是关于语言特性、模式匹配、借用检查等内容,而是 Rust 代码中的一个习惯——使用工厂函数来创建对象,而不是像 C++ 那样依赖完整的构造函数语言特性。
我希望能展示出,C++ 在可能的情况下使用工厂函数来代替构造函数,能够带来很多好处。如果你是一个 Rust 程序员,我希望你能够在这方面对 Rust 做出的好决策有新的认识。如果你是 C++ 程序员,请理解我是你们中的一员,我并不是来批评 C++ 的,但我要坦诚地指出我认为它可以改进的一些地方。如果你不属于这两个群体,欢迎,我希望你能学到一些新东西。
这个视频将围绕初始化这一主题展开,所以我们先来定义一下。我的定义是:在已分配的内存中建立一个有效的对象。注意,这不是关于分配内存,而是关于将 0 和 1 写入我们已有的内存中,使得这块内存代表给定类型的有效对象。确保我们的对象是有效的,能够让类型系统发挥作用,强制执行数据流动的规则。如果我们无法确信给定类型的对象是有效的,类型系统基本上就无能为力了。无效的对象也会对程序的安全性和稳定性构成严重威胁。
那么,什么是有效的呢?我会定义为:该类型的所有不变性(invariants)都得到了保证。那么,什么是不变性?它是某个特定字段或者字段之间的关系永远成立的性质。我这里提到“字段”是因为,作为程序员,我们通常编写的是由语言的基本类型构成的聚合类型。但基本类型其实也可以有不变性。例如,在 C++ 和 Rust 中,引用的一个不变性就是它们绝不会为空(null)。因此,任何违反这个不变性的引用都会被认为是无效的。为了保证这个不变性,你必须始终将引用初始化为非空值。
希望这些定义对你有帮助,接下来我们会深入探讨不变性,特别是它如何与用户定义类型以及我们在 Rust 和 C++ 中创建它们的方式相关。
初始化字段
大多数类型都有一个不变性,即每个字段都已被初始化。语言通常会在某种程度上强制执行这一点。例如,Rust 强制你在结构体初始化器中提到每个字段并为其提供初始值。你也可以使用一种称为结构体更新语法(struct update syntax)或函数式记录更新(functional record update)的语法,它会从另一个值中获取你没有提到的字段值。这个语法通常与 Default trait 一起使用,但你也可以直接使用任何值,前提是你可以从需要的字段中进行复制或移动。
类似的初始化过程通常会被封装在一个关联函数中,通常命名为 new,如果你想不出更好的名字。这种方式抽象了这些细节,并为用户提供了一个简洁的对象创建 API。在 Rust 中,这些函数通常被称为构造函数,但在这个视频中,我会称它们为工厂函数,以将它们与 C++ 中的构造函数区分开来,因为二者实际上有很大不同。顺便提一下,工厂函数可以有明确的名称,这是使用它们的一个强大优势,但我不会展开讨论,因为我认为这点是显而易见的。
C++ 的初始化
现在让我们看看 C++ 中的对象初始化。C++ 的初始化极其复杂,互联网上已经有无数的梗图在调侃它了。所以,我会避开大部分讨论,只专注于这个问题——构造函数是 C++ 中用于初始化的通用工具。它是一个函数,虽然有点奇怪,你不能对它取地址或做其他操作。
它的函数签名是什么样的?看起来它应该返回一个对象,例如 S,但实际上它并不返回任何东西。它的函数签名实际上是从 S* 到 void 的,也就是说,它通过写入传递进来的指针(即 this 指针)来完成所有工作。如果你查看构造函数的函数签名,就会发现 this 指针本质上是一个输出参数(out parameter)。在 C++ 中,输出参数通常被认为是不良实践,而这种函数签名很可能无法通过代码审查,但我们每次编写构造函数时都在使用这种特殊的输出参数,这很有趣。
更离谱的是,this 参数的类型是指向 S 的指针,但这是一个谎言。实际上,它是指向未初始化内存的指针,这也是构造函数的全部意义所在——在这个未初始化的内存位置上建立一个有效的 S 对象。C++ 的对象模型实际上明确指出,在构造函数返回之前,这块内存位置并不存在一个 S 对象。因此,虽然我们有一个类型为 S* 的参数,但在整个构造函数的执行期间,这个类型实际上并不准确。我们稍后会看到,如果你错误地从这个参数中读取并期望它指向一个有效的 S,这可能会导致严重问题。
和 Rust 一样,C++ 也强制你初始化类的所有成员,方式是自动递归地调用所有成员的构造函数,包括任何基类的子对象。构造函数后面有一个小节,称为成员初始化列表(member initializer list),你可以在这里指定传递给这些成员构造函数的参数。对于你没有提到的任何字段,将调用其默认构造函数。不幸的是,int 的默认构造函数什么都不做,因此我们的 S 对象中的成员 x 仍然未初始化。
一旦所有成员都被构造完毕,你可以在构造函数的花括号中运行任意代码。我有时希望这些花括号根本不存在,因为每对花括号都是复杂性和错误的潜在来源。我通常尽量保持它们为空,尽可能在成员初始化列表中处理所有内容,但正如我们稍后会看到的,这往往比说起来容易得多。
为了完整性,我还要提到,你可以直接在字段声明时初始化字段,称为非静态数据成员初始化(non-static data member initializer,NDMI)。我认为这是一个很棒的特性,你应该尽可能使用它,但在接下来的讨论中,我会忽略它的存在,因为它并不会真正影响我的论点。
Rust 的工厂函数
接下来我们来看一个更有趣的不变性。Rust 的标准库中有许多类型表示不能为零的整数。我们来看一个例子,NonZeroU8 是一个结构体,它有一个私有的 u8 字段,我们知道这个字段的值不是零。为什么这很有用呢?举个例子,你可以自信地使用它进行除法运算,因为它永远不会导致除以零的错误。更有趣的是,编译器可以利用这个不变性来让 Option<NonZeroU8> 和 u8 在内存中具有相同的表示形式。因为我们知道零永远不是 NonZeroU8 的有效值,所以编译器可以使用零来表示 Option<NonZeroU8> 的 None 值。
这是一个非常有用的类型。让我们看看 Rust 如何设计它的 API 来创建一个这样的对象。我们有一个名为 new 的工厂函数,它接收一个 u8,并返回一个 Option<NonZeroU8>。这非常合理,因为根据传入的值,我们可能无法建立这个类型的不变性,特别是如果用户传入了零值。所以我们在 API 中表达了这种可能性,并且这与 Option 类型的内存表示方式完美契合,因为没有额外的内存开销。
如果你有一些特殊的知识,知道传入的值不为零,但编译器无法证明这一点,而且你在对性能要求极高的代码中,不想因为这个检查而付出代价,该怎么办?许多 Rust 类型,尤其是这种低级别的类型,还提供了一个单独的工厂函数,由调用者来保证类型的不变性。注意,这个函数无条件地接受你传入的任何 u8,那么这是否破坏了一切?你可以轻松创建一个持有零值的 NonZeroU8 吗?并不会,因为这个工厂函数以及类似的函数总是被标记为 unsafe。unsafe 是 Rust 赋予你的一种能力,允许你说“我知道我在做什么,如果我做错了,我愿意承担后果”。换句话说,Rust 可以表达两个强有力的声明:所有的 NonZeroU8 确实都是非零的,如果你有一个零值的 NonZeroU8,那是因为你在 unsafe 代码块中做了坏事。而且你可以通过工具来检查代码,找到你可能违反规则的地方。
C++ 的对比
让我们尝试在 C++ 中实现 NonZeroU8。我们已经遇到了第一个问题:构造函数的函数签名是从未初始化内存和 u8 到 void,这种签名没有好的方式来表示失败。最简单的选项,也是很多人推荐的做法,是在构造函数中抛出异常。
对我来说,这个选项非常不吸引人。首先,异常对于一个如此简单的低级类型来说,开销非常大。许多代码库为了性能考虑禁用了异常,许多其他代码库也没有准备好处理异常。此外,异常也会创建一个隐形的失败路径,调用方需要记住并考虑这一点,而不是像 Rust 那样通过类型系统来显式表达。
我们可以使用静态成员函数来实现类似 Rust 的工厂函数。我们将默认构造函数设为私有,这样只有我们可以调用它。然后,我们有一个私有的 create_unchecked 函数,它调用默认构造函数,设置 val 并返回它。虽然初始化被分散到多行,我稍后会提出一个可能的解决方案。
我们的公共 API 是 create,它进行检查,如果 x 不为零则返回一个对象,否则返回 std::nullopt。通过这个公共 API,我们的不变性得到了充分保护。
然而,如果我们希望跳过这个检查,像 Rust 那样公开不安全的工厂函数呢?我们可以这么做,但这样一来,我们就无法再确保所有的 NonZeroU8 都是有效的。C++ 缺少内建的安全机制,当我们为了性能而引入必要的 API 时,这会破坏我们对系统状态的推理能力。
接下来我们来看看一个更复杂的例子:一个管理拥有的以 null 结尾的字符串的类,它有三个成员变量。第一个成员 buf 拥有一个包含正好一个零字节的缓冲区,第二个成员 len 记录了字符串的长度,第三个成员 is_ascii 表示缓冲区中的字符是否都是 ASCII 编码。
为啥Rust在逻辑运算符重载上厚此薄彼
Summary
在Rust中,逻辑运算符的重载有一定限制。虽然Rust支持按位取反(叹号运算符),但不支持重载逻辑与(&&)和逻辑或(||)。这是因为Rust的短路计算机制使得重载这些逻辑运算符变得复杂。短路计算会引入额外的复杂性,如可能的借用问题和异步代码的兼容性。Rust的设计者认为,引入这些复杂性并不值得,因此决定不支持这些逻辑运算符的重载。
Highlights
- ⚙️ 短路计算机制: Rust的短路计算机制会在遇到第一个操作数为假时停止计算第二个操作数,这使得逻辑与和逻辑或的重载变得复杂。
- 📦 B包问题: 引入B包处理未计算的操作数可能会导致借用规则和异步代码兼容性的问题。
- ✅ 现有机制足够: 现有的短路计算机制能够满足大多数场景,不需要引入更复杂的特性。
keyword
- Rust
- 逻辑运算符
- 短路计算
好的,以下是整理后的内容,确保其连贯且合理通顺:
我们在看 Rust 的运算符重载时,会发现有些逻辑运算符是可以重载的,而有些却不行。比如,有人提到,Rust 中可以重载取反运算符(即叹号运算符 !),但不能重载逻辑与运算符 (&&) 和逻辑或运算符 (||)。有个叫 PEW 的人在某个链接上问道:为什么 Rust 可以重载取反运算符,但不允许重载与或或运算符?这看起来确实有些奇怪,因为这些运算符都是逻辑运算符,按理说都应该可以重载。
我猜,这可能与 Rust 的短路计算机制有关。所谓短路计算,就是在处理逻辑运算时,如果表达式的某个部分已经确定了整个表达式的值,那么后面的部分就不再计算了。比如,在逻辑与运算中,如果第一个操作数为 false,那么无论第二个操作数是什么,结果都是 false,因此不需要计算第二个操作数了。这个机制是 Rust 中逻辑运算的核心。
那么,为什么这种机制会妨碍与或或运算符的重载呢?这个问题并不容易回答。
第一个获得较多赞同的回答是这样说的:! 运算符在 Rust 中实际上是按位取反运算符,而不是逻辑取反运算符。即使不能重载与或或运算符,按位取反仍然是很有用的。这个回答没什么毛病,但并没有真正解释为什么与或或运算符不能重载。
第二个高赞的回答来自一个叫 NOKIA 的人,他的解释更有意义。他指出,真正的难题在于短路计算机制。如果第一个操作数为 false,那么第二个操作数就不会被计算。这个现象在逻辑与运算中非常明显:当第一个操作数是 false 时,结果必然是 false,因此不需要计算第二个操作数。
问题在于,如果允许重载与或或运算符,那么我们需要引入闭包来处理未计算的第二个操作数。为什么会引入闭包呢?想象一下,如果我们重载了逻辑与运算符,当第一个操作数为 true 时,我们需要计算第二个操作数。这时,第二个操作数应该被封装在闭包中,以便按需调用。但闭包该如何工作呢?我们知道,闭包在 Rust 中可能通过引用传递,也可能通过移动语义传递。我们该选择哪种方式?是用 Fn,还是用 FnOnce?这些问题都没有明确的答案。
此外,如果我们考虑异步代码,情况会变得更加复杂。如果逻辑运算符的重载涉及到异步的 process 函数,闭包与异步代码的兼容性并不完美,这会引发更多麻烦。
因此,虽然理论上可以让 Rust 中的短路逻辑运算符支持重载,但考虑到潜在的复杂性和问题,实现起来并不值得付出那么多的精力。相比之下,像 Haskell 这样的语言则允许重载逻辑运算符,但 Rust 的设计选择了不支持这种功能。
接下来,有人提到一个相关的提案,即让与或或运算符支持重载的提案。这份提案在 2021 年 5 月被搁置。总结原因是:大多数逻辑运算符的使用场景都可以通过其他方法实现,比如序列化逻辑,而不需要引入闭包来处理逻辑运算符的重载。因此,再去实现这种特性并没有太大的意义。
另一个有意义的回答指出,除了布尔值之外,短路计算对于其他类型来说并不合理。也就是说,逻辑运算符的重载可能会带来混淆,因为其他类型并没有这种短路的语义。因此,使用逻辑运算符对其他类型进行重载并没有意义。
总结一下,主要有以下几点原因:
- 技术复杂性:引入闭包会带来技术上的复杂性,尤其是在借用规则和异步代码的兼容性方面。
- 现有方案足够:现有的逻辑运算方式已经能够满足大多数场景,不需要再引入新的特性。
- 一致性问题:为了保持与布尔逻辑的一致性,避免引入混淆,因为其他类型并不适合使用逻辑运算符重载。
总的来说,虽然理论上让短路运算符支持重载是可行的,但它的复杂性和实际收益并不足以让 Rust 选择开放这些功能。这就是为什么有些逻辑运算符可以重载,而有些则不行的原因。这个回答已经解释得非常清楚了。
整理完毕。
Rust在形式化验证上做错了什么-1
上次看了一下 Green Hall 发表的文章,因为他是针对某个博主 Boss 的博客写的文章。然后呢,有一个叫 Gavin 的人,基于 Green Hall 的文章又写了一篇文章,叫《Rust 在形式化验证方面做错了什么》。这是他的原文链接,发表日期是在 5 月 22 号,距离现在也没多少天。
他说他的目标受众是程序员,尤其是 Rust 社群,以及对静态分析感兴趣的人。这是他认为会对这篇文章感兴趣的目标群体。知识状态是自信的,但并不像他希望的那样完全自信,因为他没有太多时间来实现这些想法。他的开头有点戏谑性质。
文章的简介是这样的:在 Rust 语言诞生九周年之际,创始人 Green Hall 写了一篇博文,拓展了另一位博主 With Bots 的文章。这些厉害的编程语言开发者们正在攻克一个难题:如何让编程语言既易于分析理解,又方便好用。Rust 已经取得了很大的进步,但人们还想要更强大的功能。文章透露了一个答案,那就是如何做到既容易分析又方便好用的关键:结构化并发。
不过,文章里提到的结构化并发概念有点宽泛。线程的运行应该限制在一个明确的范围内,而不仅仅是和某些附属对象沾点关系就算完事了。作者甚至想进一步限制结构化并发,削弱线程组孵化器之类的功能元素。作者将这种改进后的结构化并发称为“受限结构化并发”,简称 RSC。这听起来确实有点疯狂。
让我们从头开始捋一捋吧。首先是内存泄漏浩劫。内存泄漏浩劫(Leakpocalypse)这个词只有资深 Rust 成员才会认识。我猜想它在 Rust 1.0 团队成员心中留下了难以磨灭的阴影。我就不详细解释了,因为已经有人讲述过了。简而言之,某些原本认为安全的 API 其实是不安全的,因为 Rust 无法保证一定会调用析构函数。这个析构函数主要是用来释放内存的嘛。
今年是 Rust 1.0 发布九周年,也就是 2015 年 5 月发布的。导致 Leakpocalypse 的漏洞报告日期是 2015 年 4 月 10 号。如果你在 2015 年 4 月是 Rust 团队的一员,那么留给你修复这个设计缺陷的时间还不到两个月。也就是说,2015 年 5 月 Rust 1.0 就要发布了,而你在 4 月 10 号才发现这个漏洞,留给你的修复时间确实不多了。这个缺陷会导致原本安全的代码变成不安全的,而 Rust 是以安全性著称的。如果在发布前一两个月发现这么大的问题,确实让人头疼。
由于时间紧迫,只能采取捷径,最终的决定是 Rust 不再保证一定会调用析构函数。为了保持 Rust 的安全特性,即使故意泄露一点内存,看起来也无关紧要。我认为这在当时是正确的决定,但这也意味着 Rust 并非完美。Rust 的缺陷就是 Gavin 的机会。Gavin 的态度看似自信,他认为 Rust 不行,而自己行。
接下来是“共享或可变”的问题。Rust 的成功在于“共享或可变”这个规则。为了方便,我把它称为“Shared xor Mutable”。是的,Rust 最大的成功并不是借用检查器(Borrow Checker),而是“Shared xor Mutable”这个概念。
作为一个 C 程序员,我曾认为“Shared xor Mutable”是 Rust 最愚蠢的决定之一,毕竟我习惯了随时随地使用指针。不过,当我仔细阅读了 With Bots 和 Green Hall 的文章后,他们通常知道自己在讲什么,尽管他们比我更能接受复杂性。我最终被说服了,认为“Shared xor Mutable”确实增加了设计层面的复杂性,但一旦你学会了这些模式,使用合适的架构就变得很容易了。
从七天前开始,Gavin 表示他自己也在写一门语言,这门语言将会包含“Shared xor Mutable”。他建议先阅读 With Bots 的文章,再看 Green Hall 的拓展文章。如果你想要快速总结,Green Hall 提供了一个总结,那就是支持局部推理是进行程序自动化推理的重要因素。Rust 比许多其他命令式语言更能支持这种推理,甚至得到了形式化验证专家哈维尔的支持。
哈维尔认为,Rust 避免了共享的可变状态,这在形式化验证中产生了深远的影响。我们可以使用一级逻辑(FOL),并避免使用分离逻辑,因为类型系统保护我们免于可变别名的困扰。尽管一些函数式语言如 Caml 没有做到这一点。
接下来的部分是关于局部推理的。局部推理对于静态分析非常有用,但为什么它对静态分析如此有用呢?回顾我们之前写的关于图灵完备的文章,图灵完备的重要意义在于:总有些东西是没法被证明的。你可以证明程序的一些特性,但无法证明所有特性,这就是图灵完备的局限。
Rust 的局部推理能力使得我们可以逐个函数分析程序,然后使用数学归纳法证明这些特性适用于整个程序。虽然这很复杂,但通过设计语言可以简化这一过程。每个函数的调用本质上都是一个独立的子程序。如果函数不触碰全局变量、其他线程或全局状态,那么它就是一个独立的子程序。这就是为什么程序员不喜欢全局变量和多线程的原因。
共享可变引用和局部推理并不是非此即彼的关系,而是取决于你想分析的程序特性。Rust 可以对某些特性进行局部推理,但并非所有特性都可以。With Bots 和 Green Hall 遗憾的是,Rust 在更多情况下无法进行局部推理。他们还认为,业界应该朝向更多局部推理的方向发展。
然而,线程破坏了函数作为子程序的假设。假设你有一个函数,这个函数启动了一个线程并返回,而新启动的线程会在函数返回后继续运行。这意味着线程泄露出了函数之外,破坏了函数的独立性。虽然我们需要某种并发特性,但线程直接被排除在外。
那么,异步函数呢?异步函数会返回一个 Future 或 Promise,而这些也是一种承诺,表示代码会在稍后的某个时间点执行。异步函数的代码会在函数返回后继续执行,因此异步也泄露出了函数之外。Rust 的异步编程书籍提到了三种方法:事件驱动编程、协程和 Actor 模型,但这些方法要么泄露出函数之外,要么一次只能使用一个 CPU。
这篇文章有点长,后续的部分将在第 2 部分继续讨论。
整理完毕。
Rust:聊聊在 Linux 内核中的应用
https://www.youtube.com/watch?v=CEznkXjYFb4
摘要:
Linux内核是一个对你的代码有很多特殊要求的项目,直到去年年底,其中一个要求是你必须使用C。如今,这已不再适用,现在有几个Linux内核驱动程序项目使用Rust。
在本次演讲中,您将了解在Linux内核中使用Rust的经验,以及构建复杂驱动程序的过程。
大家好,首先感谢大家的到来。今天我想和大家讨论一下 Rust 在 Linux 内核中的应用。
在开始之前,我想先简单介绍一下目前的情况。大家知道,Linux 内核是一个非常有趣的项目,特别是将 Rust 引入其中。因为内核代码有很多特殊的要求,在去年之前,这些要求之一就是必须使用 C 语言。然而,今天我们已经可以使用 Rust 了,这是件令人兴奋的事情。所以接下来我们就来看看目前的进展,了解一下现状。
在讨论具体进展之前,我想先解释一下,为什么在内核中使用 Rust 是个好主意。
我主要是从安全性的角度来看待这个问题的。根据研究表明,当你有一个包含数百万行代码的大型代码库,而且这个代码库是用内存不安全的语言编写的,那么你大多数的漏洞都将与内存安全有关。这来自于一篇博客文章,该文章列出了七个拥有数百万行 C 或 C++ 代码的大型项目,逐一指出这些项目中大多数的漏洞确实都是内存安全问题。Linux 内核也在这份名单中,尽管内核团队在编写正确代码方面付出了很大的努力,但他们依然存在大量与内存安全相关的漏洞。
我想大多数人可能已经知道这些情况了,但有些有趣的统计数据可能并不为人所知。其中一个统计数据显示,大多数漏洞其实存在于新代码中。这个图表来自于 Android 代码库,显示了在操作系统中发现的所有漏洞,其中有一半的漏洞是在不到一年的代码中发现的。这意味着,如果你在一个大型的 C 或 C++ 代码库中引入 Rust,你不需要重写所有的代码就能获得显著的安全优势。很多人会说,你不可能重写所有的代码,代码量太大,成本太高,这确实是个合理的观点。但是,根据这项研究,你只需要将精力放在那些新项目或者频繁修改的部分就可以了。
另一个有趣的现象是,尽管这些项目使用了很多工具,比如 Sanitizer 等,来确保 C 或 C++ 代码的正确性,但它们依然有大量的内存安全漏洞。在 Android 中,他们通过减少内存不安全代码的比例,成功地将内存安全漏洞的比例压缩到了 50% 以下。这虽然只是相关性,但你如果想证明这种做法无效,恐怕需要找到更好的解释。
现在你已经看到了这些统计数据,即使大家都认为在内核中使用 Rust 是个好主意,我相信这些数据对很多人来说还是新的,所以我将它们放在了这里。
接下来我将向大家介绍目前内核中正在进行的项目。现在有很多不同的项目在进行中。第一个是我参与的项目——Android Binder 驱动,我们稍后会详细介绍。除此之外,还有一些文件系统项目,比如 POS FS 和 TFS,它们与容器相关。这些项目允许你将容器外部的某些代码暴露为容器内部的文件系统。还有针对 ARM Mac 的 GPU 驱动项目,该项目旨在让 Linux 支持 ARM Mac 的硬件加速。再就是块层相关的 NVMe 和 Null Block 驱动,它们处理的是存储设备的硬件交互。而最后一个项目是 SXF 以太网驱动,它允许你用 Rust 来使用某些特定的以太网芯片。
现在我来详细介绍一下 Android 项目。我目前正在重写一个叫做 Binder 的驱动程序。Binder 主要用于进程间通信,图中可以看到有两个进程通过 Binder 进行通信。那为什么我们选择 Binder 作为第一个用 Rust 重写的驱动程序呢?事实上,Binder 对 Android 来说非常重要。Android 是全球使用最广泛的 Linux 分支,因此它的工作是否良好至关重要。Binder 在 Android 中非常关键,几乎所有的进程间通信都要通过 Binder 进行。
Binder 驱动非常复杂,包含了数千行的函数代码,且有大量使用 goto 语句的清理代码。随着时间的推移,Binder 积累了大量的技术债务,并且它的确曾经出现过安全问题。由于它的复杂性,每次我们尝试重构以简化代码时,都会面临引入新安全漏洞的风险。
Binder 的安全漏洞密度非常高,每千行代码有 3.1 个漏洞,这是 Android 中漏洞密度最高的模块之一。而且情况并没有改善,每年都有三起高严重性漏洞被发现,今年已经发现了三起漏洞。更糟糕的是,Binder 的漏洞往往会被利用,已发现的漏洞中有一半都被实际利用过。
因此,我的工作就是从头开始用 Rust 重写 Binder 驱动程序。目前进展非常顺利,我们已经实现了所有功能,且所有测试都能通过。我们甚至已经能够让设备在运行 Rust 实现的 Binder 驱动时正常启动,性能测试结果也非常有希望,虽然目前只做了微基准测试,但性能与 C 实现几乎没有差别。这表明 Rust 可以用于内核中的性能关键模块,Binder 就是其中之一。
接下来我想谈谈在内核中编写 Rust 驱动的感受。与在用户态编写 Rust 程序不同,内核中需要经常调用 C API。为此,我们需要为 C API 编写 Rust 包装器。你会发现,编写内核驱动时,往往需要包装一些 C API,而这些 API 可能还没有现成的 Rust 包装器。因此,编写内核驱动的经历之一就是要编写这些 C 函数的包装器,这通常需要对 Rust 中的 unsafe 有深入的理解。
我们在内核中设计了一个系统来管理这些包装器。首先,我们通过 Bindgen 工具生成一组可以直接调用的 C 函数,但这些函数使用起来并不方便。因此,我们会创建一个专门的 crate 来为每个 C API 提供安全的 Rust 接口,驱动程序只需要调用这些安全接口即可,从而避免直接使用 unsafe 代码。
例如,内核中的工作队列(Work Queue)API。我们为它编写了一个包装器,使其可以在 Rust 中使用。通过这个包装器,我们可以在 Binder 中实现工作队列功能,而不需要在驱动中使用 unsafe 代码。
我们还编写了很多其他的包装器,包括集合、互斥锁、自旋锁、内存操作和文件处理等。这些包装器在我们开始重写 Binder 项目时都还不存在,因此我们不得不自己编写。
尽管如此,我们的代码大部分是安全的。为什么会有 unsafe 代码呢?主要是因为我们并没有重写整个驱动程序,Binder 中的 BinderFS 文件系统我们保留了 C 实现,因为它没有那么多的安全历史问题,并且重写它需要额外的包装器。大多数 unsafe 代码用于调用这个 C 实现的部分。
总的来说,这种将 unsafe 代码和 C 代码封装在安全 API 中的做法,对内核开发非常重要。这样可以确保驱动程序开发者不需要直接接触 unsafe 代码。
另一个与用户态不同的地方是内存分配。在用户态,我们假定内存分配总是成功的,但在内核中,处理内存分配失败是非常重要的。如果你假设它不会失败,而它真的失败了,后果可能是内核崩溃。因此,在内核中写代码时,必须非常小心处理内存分配失败的情况,避免导致系统崩溃。
最后,在内核中经常会遇到一些特殊的设计模式,比如在创建对象时预留内存,以便在销毁对象时使用。这种模式在用户态中并不常见,但在内核中却很常见。
以上是整理后的内容。
以下是整理后的中文译文:
在消息中,我们有最后一个字段,就是一个红黑树节点。虽然我们目前没有使用它,但我们必须保留它以便销毁它。因为当我们销毁它时,我们需要存储这个区域现在已经空闲的信息,并将其存储在红黑树中。
在内核开发中,这是一种经常出现的模式,而在用户空间中,我并没有那么频繁地见到类似的情况。另一个有趣的点是,在内核中,链表相比于向量有一个独特的优势。通常来说,链表不太好用,但在内核中它们有一个显著的优势:你可以进行内存分配,并且只需要保留它。当你想把它放入链表时,不需要再分配内存,你只需更新一些指针(如 next 和 previous),这样它就被插入链表了,不需要额外的内存分配。
这意味着内核中会使用更多的链表,而不仅仅是你平常会使用的那些。
另一个需要讨论的点是,内核中有一种称为“原子上下文”(atomic context)的情况,基本上意味着在这种情况下你不能进行上下文切换。也就是说,在这个 CPU 上你必须保持运行,直到当前任务完成,不能将其他线程切入执行。这也意味着你不能休眠(sleep)。当然,调用 sleep 函数会让线程进入休眠状态,但阻塞一个互斥锁(mutex)也会使其阻塞,直到互斥锁变得可用。而内存分配器(memory allocator)内部某处会使用互斥锁。
因此,分配内存并不是你随时都能做的事情。
这导致了一个有趣的结果:你现在有两种类型的互斥锁。一种是你熟悉的用户空间的互斥锁,锁住它之后,其他线程会休眠,直到你释放它。而另一种是自旋锁(spin lock),它表面上和互斥锁类似,但当你无法获取锁时,不是进入休眠,而是不断循环尝试获取锁。你会进入一个快速循环,直到你能获取到这个锁。
通常情况下,当你持有自旋锁时,你需要非常快速地释放它,因此在持有自旋锁时,你不能进入休眠。这也意味着你不能在持有自旋锁时分配内存,因此你需要经常使用某种特定的模式。
让我解释一下这个模式吧。假设你有一个从某个 ID 到某个对象的映射,你想根据给定的 ID 获取值,如果这个值不存在,你就想创建一个新的对象。假设通常情况下,所需的值已经存在,因此缺失的情况比较少见。你首先获取锁(即橙色部分),然后检查集合中是否已经存在那个值。如果存在,那么任务完成(即上方的情况)。但如果不存在,你必须先释放自旋锁,让其他线程能够访问集合,同时你在后台创建新的值。这通常涉及到一些内存分配操作。然后,你重新获取锁并插入新值。但你需要小心,因为你释放了自旋锁,这意味着其他线程可能在你创建新值的同时插入了同一值。如果这种情况发生,你就需要丢弃刚创建的值,使用已经插入的值。
这种模式,我不能说它从来不会在用户空间出现,但至少在我的经验中,内核中更常见。我在 Binder 项目中就多次实现了这种模式。
因此,在内核中,你需要确保处理所有情况,确保代码正确无误。我这里有一些代码,这是来自 Binder 的例子,和我刚才讲的完全一样。我们有一个从线程 ID 到线程对象的映射,每当你调用内核时,我们检查是否已经知道该线程。如果不知道,我们就创建它。
首先,在黄色区域中,我们检查线程是否已经存在。如果存在,我们任务完成。如果不存在,我们则分配一个新线程,这需要进行内存分配。一旦分配完成,我们重新获取锁并插入该线程对象并返回它。但也可能其他线程已经插入了这个值,因此我们也必须处理这种情况。
事实上,在原子上下文中,你不能进入休眠,这是一个安全性要求。有时在原子上下文中进入休眠会导致“使用后释放”(use-after-free)问题,而通过类型系统解决这个问题并不容易。因此,我们编写了一个类似于 Clippy 的自定义静态分析工具,用来检查这种错误并捕获它。所以,内核中的一个安全性要求是通过这个自定义的静态分析工具来检查的。我认为这很有趣。
接下来是关于“固定”(pinning)的问题。你们可能已经熟悉这个概念,虽然我不确定你们有多熟悉,但我相信你们听说过它。问题在于,Rust 的固定机制对于内核来说并不够强大。
让我解释一下这个问题。通常情况下,当你创建一个值并想要将其固定时,在它被固定之前的某段时间,它是未固定的。通常你会先创建值,将其移动到正确的位置,然后在第一次使用时固定它。然而,在内核中,我们使用了很多 C 类型,它们假设自己从一开始就已经固定,而不是在第一次使用时才固定。我们不想每次使用这些类型时都去检查它们是否是第一次使用,因此我们使用了一个特殊的宏来初始化固定的值。
这个宏引入了一种自定义语法,使用箭头符号来表示“就地初始化”(in-place initialization),即在构造函数中知道值的地址,并从一开始就将其固定。我们在内核中使用这个宏来初始化固定的值,而不是采用通常的方式。一旦初始化完成,后续的固定操作就可以使用用户空间中的相同机制。
此外,我们还需要一些不稳定的 Rust 特性。这对内核来说实际上是个问题,因为我们希望在内核中能够说:“你只需要使用至少这个版本的编译器,任何比它新的版本都可以工作。”但由于我们需要使用不稳定特性,我们必须将编译器固定在某个特定版本。这是我们在内核中未来需要解决的问题之一。
为什么会这样呢?最大的原因是你无法在安全的 Rust 中实现 Arc。你几乎可以做到,但有一些功能缺失。比如说,如果你有一个 Arc 结构体,并且想要将其转换为 Arc<Vec<T>>,标准库中的 Arc 可以做到,但你无法编写自己的 Arc 实现来完成这个转换。还有一些其他问题,比如自引用参数(self-parameters)。
这个问题对内核来说尤其突出,因为在用户空间中,当引用计数达到最大整数值时,我们可以直接调用 abort() 函数。然而,在内核中,我们无法调用 abort(),所以我们使用内核的引用计数逻辑。当计数达到最大值时,我们调用“饱和”(saturate),这意味着我们泄露了内存,引用计数器将永远不会减少。相对于让整个系统崩溃,内存泄露显然是更好的选择。
当然,内核中的 Arc 还存在一些其他差异。比如我们不希望内核中的 Arc 支持弱引用(weak references),因为它们会在某些情况下引发问题。另外,内核中的 Arc 实现总是会隐式地固定值,不过这些都算是次要问题,最重要的还是第一个问题。
我们还有一些其他问题,比如可失败的内存分配(fallible allocations)。在这里,我们不仅需要不稳定特性,还必须对标准库的 alloc crate 进行分叉,因为有一些缺失的函数。因此,我们不得不启用不稳定特性并分叉标准库,以确保我们拥有所需的所有可失败内存分配方法。
此外,还有一些与常量计算(constant evaluation)相关的东西。因为在内核中,我们需要定义那些具有特殊含义的全局变量。另外还有 offset_of 宏,我认为这个宏很快就会稳定下来,所以我们对此感到乐观。不过,关于自定义 Arc 的问题,我认为还需要一段时间才能解决。
这实际上是一个我想强调的点:这些问题不仅仅是内核中的问题,实际上它们在所有嵌入式代码中都会出现,不仅仅是内核,还包括微控制器。如果你想帮助 Linux 内核成功,不仅可以加入我们编写驱动程序,还可以帮助推动这些不稳定特性的稳定化。我非常期待看到 Rust 在嵌入式系统上的广泛应用,尤其是在稳定版的 Rust 上,这会让我非常开心。
非常感谢大家的聆听。
好的,现在是提问时间。如果你有任何问题,可以举手提问。
翻译完毕。
Rust的#![ ]是控制编译器行为的
好,大家来看一下这个格式。有个井号叹号的符号,它看起来有点像过程宏。我们以前定义过程宏的时候,并没有使用这种调用格式。而且像这种带井号和叹号的格式,我们是无法调用的。以前我们写的时候,举个例子,比如说answer,我们是这样写的,前面并不会有叹号或者其他标志。而这种属性宏,无论加不加括号,其实都不能加叹号,加叹号后是无法调用过程宏的。所以这种宏看上去有点奇怪。
我点进去romango,发现它并没有找到具体的实现。然后,我在Rust的一个论坛上找到了一条英文的回复,我把它翻译成了中文。回复内容是:我们目前还不能创建这种内部属性的宏。也就是说,这是一种内部属性的宏,我们目前无法自定义它。以前我们创建过程宏时,是可以自定义的,然后我们就可以调用自定义的内部属性宏。但是像这种宏,你是无法自定义的。
那么,这种自定义的宏是从哪里来的呢?根据论坛的回复,这种宏目前没有任何特殊的用途,也不知道该如何清楚地实现它。这种实现方式目前还不明确,也没有任何特殊的用途。而这种格式的宏,它并不是一个普通的属性宏,而是一个内置属性。类似的属性有两种类型:一种是内部属性,另一种是工具属性。它们各自有不同的功能。
论坛的回复还提到,这是一种特殊的语法,用来处理编译器指令或内部属性。它的主要作用是控制编译器的行为。所以我们可以得出结论,这种宏实际上是用于控制编译器行为的,而且我们目前无法自定义这种格式的宏。
接下来,他提到内置属性。也就是说,像这种调用格式的宏,它们是内置属性,也可以被称为编译器的内置属性。常见的内置属性有很多。我们可以选择几个来看看。比如说deny属性,当我们编译时会收到某些警告,比如三角形的警告。如果我们想禁止某些警告,就可以使用这种写法来屏蔽它们。
还有一些其他的内置属性。比如说,我点进去查看build-in,可以看到当前有哪些内置属性,网页上显示的内置属性有很多。目前来看,网页显示的内容就是这些,后续我会把链接发出来。
这样整理后,逻辑清晰且没有遗漏任何内容。
[随缘分享] Rust 实际效果大于理论理解的技巧。
事情发生在我最近正在分解学习官方的如何去绘制控件的过程中。在这个学习过程中,我遇到了一些坑。我从官方的GitHub上下载了IC的版本,开始查看它的源码。当前我使用的版本是12.1,但从官方仓库下载的master分支是13.00的开发版本。在这个过程中,耗费了很长时间,因为代码读不通,导致我走了岔路,忘记了自己的初衷。
等我回过神来,发现了一个问题——我一直在念错特性的英文,”Trait”。在大量的代码中,IC使用了大量的fn trait。我写了一个简单的例子,当我们将tab bar从布局中移除后,它失去了上下文关联,这导致编译无法通过,即使你不使用它。在IC的view布局过程中,也大量使用了into fn trait这一特性。我们可以看到在type button里如何定义,它需要实现一个Element的fn trait作为参数。
在我的开发过程中,我发现fn trait带来了一些益处。我举了一个例子,实际上,如果你是在一个系统化的团队中,或者是一个有经验的个人开发者,在定义数据库结构时,你可以预先做好枚举类型。比如,你在售卖一个产品,它可能是APP的Web授权或桌面授权。我通过枚举类型将其进行了归纳。
然而,在实际开发过程中,痛苦的事情是,你无法预先知道你的产品类型有哪些。因此,快速开发时,特别是对于独立开发人员,在没有设计和筹划的情况下,你通常会选择使用一个Value,可能是i64或usize类型,从1到9等,直接写入数据库。有些人也会使用字符串。
在开发的某个阶段,你会发现无论使用数值类型还是字符串,都会带来语义上的不清晰。数值类型会导致语义不清晰,而字符串类型则容易出现笔误。这种情况下,检查代码的工作就变得困难了。最终,当你确定了自己的枚举类型后,你可能会意识到,原来分类可以归为三大类。
以前我使用Go或Swift开发时,到了这一步,我通常会进行大量的重构,将所有使用Value的代码改为枚举类型。这是一个很痛苦的过程。后来我在看IC的源码时,发现了fn trait,我觉得这个过程是可以优化的。
我举了一个例子,如果前期你使用的是Value类型,后期做置换时,你可以使用Value.into()将其转换成枚举类型。我们可以看到运行情况,无论是通过打印枚举结构,还是从i64转换,或者通过字符串序列进行打印,都能正确输出结果。
如果仅仅是看到这个功能,你可能觉得fn trait没什么特别的,不过是实现了数字和字符串之间的转换。但实际上,它有很多进阶的小细节,在实际开发中非常有用。
我再给你展示一下。我们可以定义一个Value,然后通过fn trait将它转换为枚举类型。更多时候,你可能在写循环或者其他变量时,会强制给变量赋值为1,并将它传递到格式化器中。这时,你可以通过类似的方法,使用into()将其转换为你需要的类型。
对于字符串来说,fn trait的优势更明显。以前我有个同事喜欢讨论谷歌的变量命名规范,比如驼峰命名法和下划线命名法。我们当时开了个玩笑,我是C语言派,喜欢下划线,因为它可以将单词断开,小写看上去也很舒服。
如果使用了fn trait,团队中可以允许不同的命名风格。在插入视口之前,有人可能喜欢app.camelCase,有人喜欢用下划线,甚至有人喜欢全大写常量。通过fn trait,这些不同命名风格的实现可以统一转换为枚举类型,避免了后期代码优化和清理的麻烦。
当然,如果你实现了fn trait,就一定可以实现相对应的into()方法。这两个是孪生兄弟,互相配合,在代码整合和调试过程中体现了很大的优势。
接下来,我展示了如何使用my_kind函数,它可以接受数值、字符串,甚至是不同的字符串。这个函数的参数要求实现了fn trait,输入参数可以被转换为枚举类型。前期你可能传递的是数值,后期则可以转换为枚举,极大地简化了代码。
此外,我在查看IC源码时,发现在泛型中使用了fn trait的关联类型。这种方式比较隐晦,源码中没有直接实现fn trait或into trait,但通过查阅资料,我发现这是通过泛型的关联类型实现的。你可能会在代码中看到参数声明需要into(),但实际源代码中找不到fn trait的实现,这需要注意。
在我拆解官方button实现的过程中,花了很多精力理解这些泛型和关联类型的实现。希望这些经验能帮助大家更好地阅读代码。
Rust闭包其实就是语法糖
以下是对您提供内容的整理,使其更加合理通顺,注意不遗漏任何细节:
Rust闭包其实就是语法糖
这几天我在看金布兰迪的《Program Rust》这本书,刚好看到闭包的章节。当时我在饭后散步,琢磨着闭包这个东西,突然想到,闭包不就是语法糖吗?于是我掏出手机搜索了一下,还真发现有很多人这样说。看到有人和我有相同的看法,当时我心里挺欣慰的,就把相关的内容截图保存了下来,想着晚上再仔细看看具体的链接。
等到晚上我仔细查阅,发现铺天盖地的文章都在说闭包是语法糖,几十个链接都是这么讲的。我才意识到,原来我是后知后觉的人,思考了半天才领悟到闭包其实就是语法糖。于是我摘取了其中的一些链接,如果有时间,我会把这些链接再翻译一下,看看有没有新的知识点可以吸收。
事实上,这些链接的讲解内容和金布兰迪书中的内容基本一致,可能事实就是如此。第一个链接来自“Web Meat Education”,它的教程非常好,讲的东西也都很本质。我在想,等我看完这本书后,可能会把它当作第三本书来看,因为它讲得真的很好。
Rust对闭包的实现和其他语言略有不同,它巧妙地利用了特征系统来实现闭包的语法糖。在阅读相关内容之前,最好先确保你了解特征和特征对象的相关章节,这样才能更好地理解闭包的本质。换句话说,闭包的本质其实就是特征。
理解Rust闭包工作原理的关键在于一个稍显陌生的概念:闭包其实是通过运算符()来调用函数的。基于这个概念,其他部分就变得容易理解了。Rust使用特征系统重载运算符,调用函数也不例外。我们有三个可用于重载的独立特征:
Fn:接受不可变引用&self作为参数,只读。FnMut:接受可变引用&mut self作为参数。FnOnce:接受self,表示完全拥有self。
这三个特征涵盖了所有类型的self,并通过惯用的方法调用语法进行操作。我们选择将它们拆分为三个独立的特征,而不是使用一个单一的特征,这样可以根据需要选择接受哪种类型的闭包。
闭包语法(用两个竖线加上括号)其实是对这三个特征的语法糖。Rust会自动生成一个环境结构体,并为这个结构体实现适当的特征,以便使用它。总的来说,Rust巧妙地利用了特征系统和语法糖,为闭包提供了强大的功能和灵活性。理解这一机制,可以让我们更深入地掌握Rust的编程艺术。
我们再来看第二个链接。这个链接也提到,闭包的本质是语法糖,是由三个特征协同工作实现的。它的例子和前面的内容类似,只是表达方式不同。我们可以通过反复理解这些例子,加深对闭包的认识。
举个例子,最简单的闭包就是不捕获任何内容的闭包,它的返回值是一个()类型。这个闭包解糖后会生成一个结构体,结构体有三个特征的实现:Fn、FnMut、FnOnce。在调用时,Rust会根据调用场景,选择最合适的特征实现。比如,如果闭包不捕获环境变量,Rust就会选择Fn。
当闭包捕获了环境变量时,生成的结构体就会包含这个环境变量。比如,如果闭包捕获了message,那么生成的结构体就会有一个message字段。在访问这个字段时,Rust会对它进行解引用。这种情况下,闭包的生命周期就变短了,因为它借用了环境变量。
如果闭包使用了move关键字,那么它会将环境中的所有内容移动到新的匿名结构体中。这样生成的闭包拥有'static生命周期,可以跨线程传递,但失去了Copy特征,只能被克隆。这也是为什么在处理线程时,必须使用move闭包。
不过,即使使用了move,闭包中仍然可能捕获来自环境的引用。这种情况下,闭包的生命周期仍然受限,无法跨线程传递。
第三个链接也提到,Rust中的闭包只是特征的语法糖,它可以属于三个特征之一:Fn、FnMut或FnOnce。这些特征定义了闭包的行为,比如是否可以修改环境变量,是否拥有环境变量等。Rust为闭包提供了强大的支持,每个项目几乎都会用到闭包。
最后,阅读完这些内容,我不禁开始思考Rust的哲学。几个月前我看到一篇文章,讨论为什么Rust的异步是这样设计的。文章中特别提到了Pin。看完这些对闭包的讲解后,我突然觉得,Pin在Rust中显得非常突兀,和Rust的生命周期、所有权等核心理念好像格格不入。或许Pin只是一个临时的解决方案,未来可能会有更符合Rust哲学的设计来取代它。
以上就是对您提供内容的整理。
Rust的暗黑面
Rust的暗黑面
https://www.bilibili.com/video/BV1tJ4m1s7Bm/
这篇文章发布于2024年2月16日,讨论了Rust语言的阴暗面。虽然Rust一直被认为是一门优秀的编程语言,但它也有其不为人知的一面。文章作者是Dr. Ashlebmi BMI,他多年来一直致力于学习和撰写有关Rust的文章。尽管Rust连续8年被评为最受欢迎的编程语言,并且得到了许多大型科技公司的资助和采用,但仍有不少程序员对这门语言持有负面看法。
在文章的评论区中,我们可以看到一些典型的反对声音。下面列出了一些例子:
- 评论一:
“While you make a big force of Rust’s admirating, our parents, it defined as how many people using a language one to keep u. You make no mention of its low desire ranking suggestion. The hype is coming from the likes of yourself, not a wider industry.”
这个评论认为,Rust虽然被大肆吹捧,但其实它的受欢迎程度并没有想象中那么高。评论者暗示,Rust的热度主要是由一些像作者这样的狂热爱好者推动的,而不是整个行业的普遍共识。
- 评论二:
“Rust feels like a bunch of poorly patched together features made by someone without any programming language design experience. It seems like a poorly thought-out language with no solid base, no full specification, and multiple implementations. It’s unclear why anyone would use it for serious work.”
这位评论者认为,Rust看起来像是由没有编程语言设计经验的人拼凑在一起的,缺乏坚实的基础、完整的规范和工具生态。因此,他们不理解为什么会有人在严肃的工作中使用这门语言。
- 评论三:
“Another super green bar, I’m sorry to see, but the Rust pills I see today remind me very much of what I saw when Rails was at its peak. Everyone was saying it was the best thing since sliced bread. Please stop with the creepy posts.”
这个评论者表达了对Rust的厌恶,认为Rust的热潮让他想起了Ruby on Rails当年流行时的情景。他觉得这种对Rust的夸赞已经变得令人反感,甚至让人不适。
- 评论四:
“Rust will never be more popular than Go because Go is an easier language to get started with. If you don’t believe me, look at PHP, JavaScript, and Python. If a language only needed to be good rather than easy, then these three would never have existed.”
这位评论者认为,Rust永远不会比Go更受欢迎,因为Go更容易上手。他举了PHP、JavaScript和Python为例,指出这些语言之所以流行,是因为它们易于学习,而不仅仅是因为它们”好”。
作者表示,虽然这些评论看起来很刺耳,但他并不会被网络上的陌生人冒犯,尤其是那些连真实姓名和头像都没有的人。此外,他认为对技术和编程语言抱有固定的意识形态是可笑的,试图改变这些人的想法更是徒劳。
接下来,作者分享了他对Rust受到批评的一些原因的看法。
1. 陡峭的学习曲线
作者指出,Rust引入了许多全新的编程概念,如生命周期、所有权、借用、特征、宏等,这些概念会让新手感到困惑。2022年Rust基金会的一项调查显示,许多开发者不使用Rust的主要原因是它太难学了。虽然这种说法可能在一定程度上属实,但作者认为,其中一些夸大了Rust的学习难度。
作者提到,自己并不是计算机科学的神童,但经过两周的学习后,他已经能够自信地写出基础的Rust代码。Google的开源博客也支持他的观点,根据一项内部调查,2/3的Google开发者在学习Rust两个月内就能上手,1/3的开发者在更短的时间内掌握了这门语言。
2. 复杂的语法
Rust的语法对于简单的程序来说相当直观,但随着程序复杂性的增加,Rust的语法会变得非常棘手。以下是一个Rust编写的函数示例,该函数将两个排序列表合并为一个排序列表:
1 | fn merge<T: Copy + PartialOrd>(list1: &[T], list2: &[T]) -> Vec<T> { |
你可以看到,泛型约束、生命周期、特征等使得代码显得复杂。相比之下,Python的实现则简单得多:
1 | def merge(list1, list2): |
类似的例子比比皆是。许多开发者认为,Rust的某些特性如生命周期管理让人困惑,即使是那些使用Rust来开发像Figma这样复杂应用的工程师,也表示Rust的生命周期管理既棘手又容易出错。
虽然Rust的语法具有挑战性,但它带来的内存安全性、并发性和代码稳定性是不可忽视的优势。
3. 缺乏库和生态支持
与JavaScript等语言相比,Rust的库和工具链相对较少,尤其是在异步编程、机器学习和数据科学领域。这也是Rust目前仍然被认为是较为”早期”的语言的原因之一。
4. 社区和就业机会不足
Rust虽然已经发布了多年,但与C++、Go等语言相比,其社区仍然相对较小,就业机会也较少。然而,作者相信这种情况会迅速改变,随着越来越多的大型科技公司认识到Rust的优势,Rust的就业市场将会扩大。例如,Google最近向Rust基金会捐赠了100万美元,以促进C++和Rust的互操作性。
尽管Rust仍处于发展的早期阶段,但它为构建可预测、可靠且稳定的软件提供了重要的基础。未来,随着Rust的逐渐成熟,这些目标将会一步步实现。
避免在Rust中过度依赖MPSC通道
好的,以下是对您提供内容的整理,确保其合理通顺,且不遗漏任何内容:
避免在Rust中过度依赖MPSC通道
原文链接:避免在Rust中过度依赖MPSC通道
今天我们来看一篇不长的文章,标题是**“避免在Rust中过度依赖MPSC通道”**。文章的原文链接已经贴出,有兴趣的可以去查看。文章是在一个多月前发表的,作者叫Jin。接下来我们来看看文章的具体内容。
首先,作者特别感谢Rust社区的朋友们,他们帮助作者指导并改进了本文的初稿。
引言
在Rust中,通道(channel)是非常强大的工具,可以实现跨线程通信。几乎在每个项目中,作者都会使用它们,无论是工作项目还是个人项目。通道允许线程之间进行消息传递,这种模式既安全又高效,而且使用起来很有意思。特别是在使用Tokio的MPSC通道和类似select!宏这样的工具时,这些工具可以帮助我们设计出对变化非常有弹性的actor系统,并且很好地利用了底层硬件。
然而,在最近的一次直播中,John Gainset提出了一个让作者印象深刻的观点:在某些情况下,开发者可能会过度依赖MPSC通道。通常的设计方案是类似于“扇入模式”(fan-in)。那么,什么是扇入模式呢?
作者通过一张配图很好地解释了这种模式。扇入模式就是从多个生产者(producers)汇聚到一个消费者(consumer),即多对一的模式。相反的,我们也听说过“扇出模式”(fan-out),就是从一个生产者向多个消费者分发消息,即一对多的模式。
当生产者的数量相对较少且稳定时,扇入模式的效果非常好,MPSC通道在这种情况下表现得非常出色。然而,当生产者的数量大幅增加时,消费者端可能会出现拥塞,导致消息处理的开销增加,消息积压,甚至丢失。特别是在没有“反压”(backpressure)的情况下,生产者生成消息的速度远超消费者的处理速度,这种情况会更加危险。
文章探讨的内容
这篇文章探讨了另一种启用扇入模式的方法,并研究了它的性能特征。
基准测试
首先,作者描述了一个基准测试场景。想象一下,我们有大量数据需要被快速处理并发送到客户端。类似于“Have I Been Pwned”这样的网站,用户输入一个域名后,系统会生成大量可能的排列组合,并使用地理位置、RDAP、WHOIS、服务器banner等数据丰富这些排列组合,并将结果快速传输给客户端。
为进行基准测试,作者使用了一个简单的socket服务器,监听来自客户端的传入TCP连接。这个服务器可以看作是一个简单的客户端-服务器模式,使用一个socket来读取和发送数据。
在这种一对一的基准测试中,服务器通过MPSC通道处理消息。多个生产者(producers)生成数据,消费者(consumer)则通过socket发送消息。这里,生产者被称为worker,消费者则被称为actor。
使用MPSC通道的方案
第一个实现方案是使用MPSC通道。在这个方案中,多个worker向一个actor发送消息。actor通过socket发送这些消息。
使用Arc和Mutex管理缓冲区
另一种方法是使用Arc<Mutex<Vec<T>>>管理缓冲区。这种方案的优点在于,它减少了内存分配。通过预先分配两个缓冲区,worker和actor可以在这两个缓冲区之间进行交换。为了减少锁的竞争,Mutex保护的代码块应该尽可能小,以避免并发访问带来的性能问题。
在这种实现中,worker和actor通过交换缓冲区来处理消息。为此,使用std::sync::Mutex来锁定缓冲区,并将其与一个新的缓冲区交换。这种操作放在临界区内,以避免并发问题。
Tokyo Mutex和标准库Mutex的区别
在文章中,作者强调了std::sync::Mutex和tokio::sync::Mutex之间的区别。作者发现,在异步环境下,使用标准库的Mutex性能更好,而tokio::sync::Mutex的性能较差,慢了大约25倍。因此,作者建议尽量使用标准库的Mutex,只有在绝对必要时才使用tokio::sync::Mutex。
测试结果
在基准测试中,服务器的缓冲区大小设置为65536,确保客户端不会受到反压影响。这些基准测试在一台拥有32GB内存、AMD Ryzen 9 处理器和NVIDIA 3080显卡的计算机上运行。
测试结果表明,在少量worker的情况下,使用std::sync::Mutex的性能略逊于MPSC通道。然而,当worker数量增加时,标准库的Mutex实现迅速超越了MPSC通道。在并发较高的情况下,使用标准库的Mutex更具优势。
此外,作者还进行了tokio::sync::Mutex的性能测试,结果与预期一致:tokio::sync::Mutex的性能远不如std::sync::Mutex。
总结
文章最后,作者指出还有一些可以进一步优化的地方,比如根据数据的大小和格式进一步优化缓冲区,或者完全移除Mutex,改用AtomicPtr。不过,这种改进需要更加复杂的工程技术。
文章中的所有代码都可以在提供的链接中找到,开发者可以根据自己的需求进行扩展和改进。
通过这篇文章,我们可以看到,虽然MPSC通道在少量生产者的情况下表现良好,但随着并发的增加,使用Mutex的方式在性能上更具优势。这篇文章虽然不长,但内容非常实用,非常值得参考。
Rust内核代码的内存模型
已独立成篇
Rust文件模块API入门
已独立成篇
如何优化Rust增量编译时间
已独立成篇
我可以用 Rust 编写的 3 段代码(但 Go 不行)
Rust不仅仅是为了内存安全
https://rustcc.cn/article?id=a5a0b0c3-409a-43d9-8d32-52375a72cf20
https://o-santi.github.io/blog/rust-is-not-about-memory-safety/
Rust不仅仅是为了内存安全
https://www.bilibili.com/video/BV1Zi421Y7Ta/
看了一篇文章,和我们最近几个视频的内容看上去比较相关,文章的名字是《Rust不仅仅是为了内存安全》。原文的发布时间是6月1号,作者是莱纳德3TEO。我们来看看它的具体内容。
现在关于Rust的讨论大多集中在内存安全上,的确,很多人都在说Rust最重要的一个特征是它的安全性,大部分讨论都围绕这个话题。大家常常提到它比C、C++、Zig等语言更安全,或者比在社交媒体上被批评的任何语言更安全。虽然这确实是事实,很多语言的内存安全性标准并不高,但我认为语言的主要焦点总是被忽略了。这个焦点就是语言的正确性。
当有人试图批评上述语言时,常常会得到这样的回应,比如“你的程序崩溃了,那是你的技术问题。”但我想反驳说,这跟技术问题无关。
我们先来看一下形式语言理论,也就是我们之前讲的形式化验证。形式语言理论研究的是语言的语法、状态自动机等内容。学习形式语言的第一步就是理解描述某个语法的规则,必须准确匹配你希望语言所包含的内容。换句话说,描述的语法直接定义了解析该语言的自动机,以及它解析的单词和程序执行的语义,它们之间存在双向关系。由此可以推断,语法不允许语言中出现没有定义语义的结构,反之,语言也不应该为无法解析的程序指定语义。这两个条件缺一不可,才能让我们高效、愉快地研究语法和语言之间的关系,并保证研究的严谨性。
进一步讲,形式语言理论还让我们了解到,任何程序的执行都可以表示为抽象机上的一组语法规则。最著名的抽象机是图灵机,就像你可以使用栈自动机来定义一组语法规则来解析带括号的算术表达式一样,你也可以定义一组语法规则来模拟C程序的执行。虽然非常复杂,但C程序的执行仍然可以用图灵机来建模。这种模型通常被称为C抽象机,它是语言行为形式化规范的基础。
我并不仅仅是在讨论将C编译器建模为状态机,这可能会比大多数语言都简单,尤其如果你忽略预处理部分。我指的是将C程序的执行过程本身建模为一种可解析的语言。比如,当你解析带括号的表达式时,需要将词法单元压入或弹出栈中,以表示表达式的平衡性。类似地,当你解析C代码时,你需要将数据写入内存,并显式处理副作用,表示类型转换、指针转换等等。
就像你期望算术表达式解析器能够识别出无效的表达式一样(比如少一个括号),你也希望C编译器能够验证某些词法单元组合是否是合法的程序,并在发现错误时报告。这就是所谓的“良好行为”(well-behaved),因为C语言规范中定义了未定义行为(undefined behavior)。例如,解引用空指针在C语言中是未定义行为,而C语言并不会禁止此类程序,而是会编译并生成一些不可预测的输出。这种设计暴露了C语言的脆弱性,因为未定义行为不可预测,程序可能会崩溃、输出垃圾数据,甚至删除文件。
幸运的是,在某些情况下,程序会抛出一个段错误(segmentation fault),但这并不是必然的。这种不确定性令人难以接受,因为程序的行为无法预测。
虽然许多工具可以帮助查找代码中的未定义行为,但这些工具并非C开发的标准,也并不完美,总会有一些未定义程序逃过检测。引入运行时异常系统(runtime exception)并不能解决问题,虽然它比C语言的方式安全得多,但它仍会让程序的推理变得困难。
当前大多数现代语言都有这个问题,并且几乎没有语言能够提供一个好的解决方案。Java要求你在函数类型签名中声明可能抛出的错误,但也允许未检查异常(unchecked exception)存在,从而导致游戏崩溃等问题。Python、Ruby等语言甚至没有尝试标识可能抛出异常的函数。
所有这些问题的根源在于,编译器或解释器允许程序执行一些预料之外的操作。正则表达式匹配器是一个相反的例子,虽然语法让人困惑,但它的特性是:一旦编译通过,它就能完全按预期工作。
Rust的主要优势在于它与所有主流语言不同,它非常注重程序的正确性。Rust通过其所有权检查器(borrow checker)静态地保证所有引用都指向有效内存,避免出现空指针或悬空指针。这在C语言中是无法做到的。
Rust不仅关注内存安全,还通过特性使得编写正确的软件变得更加容易。以下是一些Rust的特性:
- 枚举类型:表示不同类型的可能值,并强制程序员显式处理不同情况。
Option类型:表示值可能存在或不存在,避免空指针问题。Result类型:用于表示操作结果,可以是成功(Ok)或错误(Err),强制程序员处理错误情况。- 强大的静态类型系统:帮助捕获类型错误,提高代码的可靠性。
- 使用
trait代替继承:提供代码复用,同时避免面向对象编程的复杂性。
需要注意的是,即使C语言不存在内存漏洞,Rust仍然比C更好,因为Rust静态地保证了代码的正确性,而C语言没有这样的保证。只有当你绕过Rust的所有权检查器(使用unsafe代码块)时,才可能出现问题。
Rust语言还具有一些其他优点,比如无需垃圾回收(GC)、支持异步编程、多线程编程等。这些特性让Rust在开发人员中获得了很高的评价。
总结来说,Rust不仅仅是为了内存安全,它还通过严格的类型系统和所有权模型,帮助我们编写出更加正确和可靠的软件。
Rust德式字符串
这些天我一直在研究内存,作为C和C++中的编程概念,这篇文章正好讲到关于内存的内容,特别有意思。这篇文章的标题是《Rust德式字符串》,原本的标题是《在Rust中不可能的优化》,意思是这种优化在Rust中无法实现。文章发布的时间非常近,几天前而已,原文链接也有,有兴趣的可以去看看,作者是TNG。
我在德国攻读硕士学位期间,一直在研究数据库系统,这篇文章立刻抓住了我的眼球。我非常兴奋地发现,他描述的字符串数据结构正是论文Umbra中提到的、具有内存性能的磁盘系统。如果有时间,我也会尝试翻译这篇论文。而Umbra这篇论文,又是通过另一篇关于存储引擎的论文《Lime Store》介绍给我的,这些文章都非常有趣,尤其是在优化上非常讲究。
更让我感兴趣的是,这种字符串结构已经被广泛应用于许多数据解决方案中,比如DuckDB、Parquet和Polars。这几个项目都很出名,特别是文章提到了一种叫“短字符串优化”的技术。对于足够短的字符串,我们可以直接将它们存储在预留的空间中,而无需额外分配内存。具体来说,我们可以将cap字段中的一个特定位置设置为标志,然后将剩余的容量、大小和指针字段直接用于存储字符串内容。
这样一来,每次访问字符串时,我们可以省去内存分配和指针访问的开销。不过,文章作者认为这种优化在Rust中是无法实现的。如果我理解没错,这个观点似乎是错误的。因为在Rust的生态中,已经有很多crate提供了类似的功能,比如compact_str、smartstring和smallstring。更有趣的是,文章中提到的Polars数据库就是用Rust编写的,并且实现了Umbra风格的字符串。
出于好奇,我决定自己动手实现一个德式字符串,看看在Rust中是否真的无法实现这种优化。我会在本文中描述我是如何实现德式字符串的,以及在Rust中这样做所面临的挑战。具体来说,我将探讨如何为这种数据结构提供共享所有权。唯一的所有权相对简单,已经有一篇很好的教程《Rustonomicon》教你如何实现类似Vec<T>,这和String并没有太大区别。
什么是德式字符串?
Rust中已经有很多字符串类型,比如String、&str、CString等。德式字符串的概念是由Andy Pavlo在他关于高级数据库的讲座中提出的,德式字符串或德国风格的字符串,指的是一种优化字符串存储和访问的技术。字符串看似简单,但实际上它比大多数人意识到的要复杂得多。字符串在内存中的表示方式可以有很大的不同,这也是它复杂的原因之一。
在Rust中,字符串有几种不同的类型,每种类型都有相应的借用版本或引用版本。比如:
String:Rust中的默认字符串类型,是一个UTF-8编码的、可增长的字符串。CString:一个以null结尾的、与C兼容的字符串。OsString:一个平台原生的可变字符串,可以轻松与Rust字符串相互转换。PathBuf:一个围绕OsString的轻包装器,表示一个拥有的可变路径。
Rust字符串的内部结构其实是一个Vec<u8>,也就是一系列字节。因为这些字节必须是UTF-8编码的,所以一个字符不一定只是一个字节,而是介于1到4个字节之间。这使得Rust的String实现如下:
1 | struct String { |
德式字符串的优化
德式字符串的基本思想是采用所谓的“短字符串优化”(SSO)。对于足够短的字符串,我们可以通过重新利用capacity和pointer的空间,来避免在堆上分配缓冲区,而将字符串的内容直接存储在栈上。这样不仅可以避免昂贵的内存分配,还可以提高访问短字符串的效率。
在内部,德式字符串在栈上只占用16个字节,并且根据字符串长度有两种不同的表示方式:
短字符串:长度最多为12字节的字符串会内联存储在栈上,16字节的存储空间中,4个字节存储字符串长度,12字节存储字符串内容。
长字符串:超过12字节的字符串会像常规
String一样在堆上分配,并且使用指针指向堆上的缓冲区。不同的是,它只用4个字节来存储长度,而不是8个字节,所以字符串大小限制在4GB以内。
Rust中的实现
为了在Rust中实现德式字符串,我们需要深入了解Rust的内存模型和类型布局。具体来说,我们要面对以下几个挑战:
共享所有权:我希望实现一个不可变的德式字符串,它使用UTF-8编码,并且支持短字符串优化。另外,堆上分配的缓冲区使用原子计数的引用,这样可以在多个线程之间安全共享。
动态大小类型(DST):在Rust中,大多数类型的大小和对齐方式在编译时是固定的,但动态大小类型(DST)是个例外。Rust提供了两种主要的DST:特征对象(
dyn Trait)和切片([T])。由于DST的大小在编译时未知,它们不能存储在栈上,只能通过指针访问堆上的数据。因此,每个指向DST的指针都会是一个胖指针。内存分配和释放:为了在Rust中实现德式字符串的内存分配和释放,我们需要依赖Rust的布局规则和手动内存管理。具体来说,我们可以通过Rust的
alloc模块来手动分配和释放内存。
结论
在Rust中实现德式字符串并不难,关键在于理解Rust的内存模型和类型布局。通过使用短字符串优化技术,我们可以在栈上存储短字符串,从而避免昂贵的内存分配,提高性能。对于长字符串,我们依然可以使用堆上的缓冲区,并通过共享所有权来优化资源管理。
这篇文章展示了如何在Rust中实现德式字符串的基本功能,虽然还有很多细节需要处理,但这是一个良好的开端。如果你对更多细节感兴趣,可以查看我在crates.io上的库stringumbra,里面有完整的实现代码。
希望这篇文章对你有帮助!
停止在 Rust 中使用面向对象编程(这样做更好)
https://www.youtube.com/watch?v=0XFq9K7N9o4
在这个视频中,我将解释为什么你应该停止在 Rust 中使用面向对象编程,以及你应该使用什么样的替代方法。告别传统的面向对象编程思维,拥抱在 Rust 中更高效的编码方式!
当我开始学习 Rust 时,因为我有 JavaScript 的背景,所以我试图通过使用 traits 和字符串来创建非常复杂的对象层次结构。因为 Rust 没有类(class),所以我不得不做些类似的替代方案。然而,当你尝试使用这种模式时,你会发现你需要使用 box 指针来包装你的对象。就在这个时候,我意识到代码似乎在向我传达某些信息。代码看起来非常丑陋且混乱。经过一段时间的思考,我终于明白了问题所在。让我通过一个例子来展示组合模式(composition)在 Rust 中有多么糟糕(至少在我看来)。
假设你想创建一个包含动物的结构体 Zoo,其中有一个 Vec<Animal>(即一个动物的向量)。问题从这里开始,因为我定义的 Animal 是一个实现了特定 trait 的结构体。但是 Rust 编译器无法在编译时确定这个对象的大小,因为它只知道这个对象会实现 Animal trait。因此,编译器会说:“好吧,我不知道这个对象具体是什么,但我知道它会实现 Animal,所以我会把它放在堆内存中。” 这不仅使得代码变得丑陋和冗长,而且每次你想创建一个 Animal 时,都必须将它放入一个 box 指针中,发送到堆内存,然后才能使用它。这显得很麻烦、低效且糟糕。
这里有一个更好的方法。你可以使用枚举(enum)。在 Rust 中,枚举的变体(variant)可以持有对象。例如,你可以定义枚举的 Dog 变体存储一个 Dog 结构体实例,而 Cat 变体存储一个 Cat 结构体实例。这样,代码变得更加简洁,因为编译器在编译时就可以确定这些对象的大小。没有了那些混乱的 box 指针,这样的代码运行起来也会更快。我敢肯定,如果你喜欢这样的内容,别忘了点赞和订阅,这样你就能在我发布下一期视频时收到通知。
你可能会好奇,为什么栈内存比堆内存更高效?我找到了 Jenny Chen 和 Ruhao Gao 写的一篇很棒的文章,讨论了这个话题。让我们一起浏览一下这篇文章。
概述
当程序运行时,它会占用内存。有时我们甚至没有意识到内存正在被分配。实际上,每次你分配一个新变量,你的程序都会为你分配更多的内存来存储那个变量。本文专注于两种内存:栈(stack)和堆(heap)。
一般的内存布局
每个运行的程序都有自己独立的内存布局,这些内存布局与其他程序是隔离的。内存布局包含许多段,包括:
- 栈:用于存储局部变量。
- 堆:动态内存,由程序员来分配。
- 数据段:用于存储全局变量,分为已初始化和未初始化的部分。
- 文本段:包含正在执行的代码。
为了访问程序内存中的每个位置,我们为每个字节的内存分配了一个地址。这些地址从 0 开始,一直到机器支持的最大地址。如下图所示,文本段、数据段和堆的地址编号较低,而栈的地址较高。
这里有一个漂亮的内存布局图,展示了栈和堆的分布。
虽然这张图标注为 C++ 程序的内存布局,但在 Rust 中也是相同的。按照惯例,我们使用 16 进制来表示这些地址。例如,最小的可能地址是 0x0,而最大的地址可能就是一堆 F。让我们做一些数学计算。每个 F 代表 4 个比特(bits),也就是一个 nibble,一个字节(byte)包含两个 nibbles。所以 4 个 F 就是 4 个字节,这是一个 32 位处理器的内存空间。
栈
如上图所示,栈段位于内存的顶部,地址较高。每当一个函数被调用时,机器会为它分配一些栈内存。当声明一个新的局部变量时,更多的栈内存会被分配给该函数以存储这个变量。这样的分配使得栈向下增长。
当函数返回时,这个函数的栈内存会被释放,也就是说所有的局部变量都会失效。栈内存的分配和释放是自动完成的,分配在栈上的变量被称为栈变量或自动变量。下图展示了当相应代码运行时,栈内存的示例。
这是一段不错的动画,展示了如何将变量
a、b、c放入栈中。
然后它展示了如何将变量 b 的地址赋值给指针 p。可以看到我们正在填充栈内存。接着调用了 hello 函数,并在栈中放入 int a = 100。当函数执行完毕后,我们从栈中弹出这些元素,因此 a = 100 被从内存中释放。
栈内存的分配和释放非常高效。但是,由于函数返回后栈内存会被释放,因此无法保证栈上存储的值在函数返回后仍然有效。一个常见的错误是在辅助函数中返回指向栈变量的指针。当调用者获取这个指针时,栈内存可能会被随时覆盖。不过这是 C 语言中的问题,Rust 中不会有这种问题。
堆
在前面的部分中,我们看到函数不能返回指向栈变量的指针。为了解决这个问题,你可以选择返回变量的拷贝,或者将值放在一个比栈内存更持久的地方,比如堆内存。堆内存就是这样一个地方。与栈内存不同,堆内存需要程序员显式地进行分配,并且它不会在函数返回时自动释放,直到显式调用 free 函数。
在 C++ 中,你可以使用 new 关键字来分配堆内存,后面跟着你想要分配的对象的构造函数。Rust 中也有类似的操作,通过 Box::new 来分配堆内存。new 操作符的返回值是一个指针,指向你刚刚在堆中创建的对象。
下图展示了栈和堆在相应代码执行时的表现:
当我们创建一个对象时,它并不会放在栈中,而是放在堆中。你可以看到,堆是内存布局中的一个单独部分。
这就是为什么堆内存比栈内存更昂贵,因为你需要在栈和堆之间来回跳转,写入堆内存,并从堆内存中取回数据。你注意到这些交叉的方框了吗?这意味着堆内存不是连续存储的,它是分散的,这也是它运行较慢的原因。
希望这个内容对你理解 Rust 中的内存管理有所帮助。如果你对更多 Rust 的内容感兴趣,可以查看这个有用的视频。
Rust’s Module System Explained!
欢迎回到 Let’s Get Rusty,我是 Bogdan,这个频道专注于 Rust 编程语言。如果你对这些内容感兴趣,记得点击订阅按钮。
在上一个视频中,我们讨论了第6章,讲解了枚举(enums)和模式匹配(pattern matching)。如果你还没有看过,记得去看看那期视频。在本视频中,我们将讲解第7章,讨论如何通过包(packages)、crate 和模块(modules)来管理不断扩展的项目。
在之前的视频中,我们所有的代码都写在一个文件中,该文件位于默认模块中。但随着项目的增长,我们需要一种方式来组织代码,并且需要一种方法来封装代码。例如,假设你有一段处理身份验证的代码,你不希望其他代码知道身份验证代码的所有细节。你可能只想公开一个叫做 login 的方法,而将其余的代码对程序的其他部分隐藏。为了解决这些需求,Rust 提供了一个模块系统,它以包(package)为起点。
当你输入 cargo new 时,你创建了一个新的包,而一个包存储着 crate。crate 可以是二进制 crate(可执行的代码)或库 crate(可供其他程序使用的代码)。crate 包含模块,模块允许你组织代码块并控制隐私规则。
回到身份验证的例子,假设你有一个包含身份验证模块的库 crate。你可以将身份验证模块中的代码设为私有,但公开一个叫做 login 的公共方法。如果我们希望身份验证模块外的代码调用这个公共的 login 方法,那么它必须指定该方法的路径。这就是基本的模块系统。
此外,Rust 还有一个叫做工作空间(workspaces)的概念,适用于非常大的项目,允许你在工作空间内存储相互关联的包。我们将在第14章中详细讨论这一点。
有了这些背景知识,让我们开始创建一些包和 crate。首先,我们创建一个名为 my_project 的新包。为此,我们输入 cargo new,然后是包的名称。接着,我们进入该目录,并在 VS Code 中打开它。
在这里,我们看到了这个包的默认文件结构。我们可以打开 Cargo.toml 文件,顶部显示了包和其名称 my_project。正如我之前提到的,包存储 crate,我们实际上可以在 Cargo.toml 文件中定义 crate。但在这里,我们没有看到任何定义的 crate,这是否意味着我们的包没有 crate 呢?实际上我们有一个二进制 crate,这是因为如果你查看 src 目录,会看到一个名为 main.rs 的文件。Rust 遵循这样的约定,如果在 src 目录中定义了 main.rs 文件,那么一个与包同名的二进制 crate 将被自动创建,而 main.rs 将成为 crate 的根文件。crate 根文件是 Rust 编译器构建 crate 时首先从中开始的源文件,它也构成了 crate 的根模块。
对于库 crate,也有类似的约定。所以在我们的 src 目录中,让我们创建一个名为 lib.rs 的文件。如果在 src 目录的根目录中定义了 lib.rs 文件,那么 Rust 将自动创建一个与包同名的库 crate,而 lib.rs 将成为 crate 的根文件。因此,在这种情况下,即使我们在 Cargo.toml 文件中没有定义任何 crate,我们的包实际上还是根据 lib.rs 和 main.rs 的约定拥有了两个 crate。
关于 crate,还有一些规则需要注意。第一个规则是,一个包必须至少有一个 crate。第二个规则是,一个包可以有零个或一个库 crate。最后,一个包可以有任意数量的二进制 crate。在我们的例子中,我们有一个库 crate 和一个二进制 crate。如果我们想要更多的二进制 crate,可以创建一个名为 bin 的文件夹,并在这个文件夹中定义文件。例如,我们可以创建一个名为 another_one.rs 的文件夹,每个文件都代表另一个二进制 crate。
现在我们了解了包和 crate,我们来讨论模块。为此,我们将创建一个名为 restaurant 的新包,并希望该包包含一个库 crate。为此,我们指定 --lib 参数。接着我们进入目录,并在 VS Code 中打开包。
我们看到了与之前包类似的文件结构,但如果我们去 src 目录,默认情况下没有 main.rs,而是 lib.rs。这里已经自动为我们添加了一个测试模块,但我们现在不需要它,所以让我们删除它。
我们的目标是创建一个帮助管理餐厅的库。我们将餐厅分为两个部分:前厅(front of the house),即顾客所在的区域;后厨(back of the house),即制作食物、清洗餐具以及经理工作的位置。
首先,我将粘贴一些代码,代表前厅模块。顶部我们有一个名为 front_of_house 的模块。模块是通过 mod 关键字加上模块名称和大括号来定义的。在 front_of_house 模块内部,我们有两个子模块 hosting 和 serving。在 hosting 模块中,我们有两个与接待职责相关的函数:add_to_waitlist 和 seat_at_table。在 serving 模块中,我们有三个与服务职责相关的函数:take_order,serve_order 和 take_payment。
模块可以包含其他模块,也可以包含结构体(structs)、枚举(enums)、常量(constants)、trait 等。这样组织代码可以保持其结构清晰。如果以后我们想添加一个功能,比如将顾客安排在 VIP 桌,我们就知道该放在哪里。
根据 Rust 书籍,我们可以看到我们的模块树结构。顶部是一个名为 crate 的模块(是的,模块被称为 crate 有点令人困惑,但请坚持一下),crate 是为 crate 根文件 lib.rs 自动创建的模块。在 crate 模块内部,我们有 front_of_house 模块,而 front_of_house 模块内部有 hosting 和 serving 模块,每个模块都有相应的定义函数。
一个好的类比是将模块树形结构想象成计算机上的文件夹或目录树。如果你想引用目录树中的一个文件,你需要指定该文件的路径。同样地,如果你想引用模块树中的某个项,比如一个函数,你需要指定该函数的路径。在 Rust 中,路径可以是绝对路径或相对路径,路径是通过标识符并用双冒号分隔来指定的。
例如,这里有一个绝对路径,它从模块树的根开始,crate,然后是 front_of_house,接着是 hosting,最后是我们的函数。相对路径是从当前模块开始的。因此,在这个例子中,我们的函数 eat_at_restaurant 在 crate 模块内部,这意味着我们可以从 front_of_house 开始引用,然后是 hosting,最后是函数。
不过请注意,这里有一些错误。悬停在红色波浪线上可以看到,它提示 hosting 模块是私有的。这是由于 Rust 的隐私规则。默认情况下,子模块及其内部的一切对父模块是私有的。因此,front_of_house 无法看到 hosting 或 hosting 内的任何内容。反过来,子模块可以看到父模块中定义的任何内容。
这种系统允许我们默认隐藏实现细节,只将需要的函数暴露给外部世界。在这种情况下,我们确实希望公开我们的 add_to_waitlist 函数。为此,我们可以使用 pub 关键字。首先,我们将 hosting 模块公开。为此,我们在 mod 前面加上 pub 关键字。你可以看到错误从 hosting 移到了函数上,这是因为此时 hosting 是公开的,但函数仍然是私有的。为了解决这个问题,我们还需要在函数定义前加上 pub 关键字。现在,我们可以在 hosting 模块外部访问 add_to_waitlist 函数了。
接下来是更多关于模块、路径和隐私规则的细节…
我们来看另一个使用相对路径的例子,这次我们使用 super 关键字。在这个例子中,我们在顶层的 crate 模块中定义了一个名为 serve_order 的函数。然后我们有一个 back_of_house 模块,该模块中有两个函数:fix_incorrect_order 和 cook_order。在 fix_incorrect_order 函数内部,我们可以调用 cook_order,因为它们定义在同一个模块中。我们还可以通过使用相对路径来调用 serve_order。在这里,我们使用 super 来引用父模块,在这种情况下,父模块是 crate,然后我们指定要调用的函数。
现在让我们来讨论结构体(struct)的隐私规则,我们通过另一个例子来说明。在这个例子中,我们有一个名为 back_of_house 的模块,模块内部有一个名为 Breakfast 的结构体,代表早餐套餐。这个结构体有两个字段:toast 和 seasonal_fruit。接着我们定义了一个 impl 块,其中有一个与结构体相关联的函数,叫做 summer。这个函数用于构建一个夏季早餐套餐,它接收一个叫做 toast 的字符串,然后创建一个新的 Breakfast 结构体,同时将 toast 设置为我们传入的字符串,并将 seasonal_fruit 硬编码为桃子。
在底部,我们有一个名为 eat_at_restaurant 的函数。在这个函数内部,我们想调用 summer 关联函数来创建一个新的早餐套餐,但你可以看到我们遇到了一些错误。我们的 Breakfast 结构体默认是私有的,因此我们无法访问它。同样,你可以看到我们的 summer 关联函数默认也是私有的。为了解决这些错误,我们需要在 Breakfast 结构体和 summer 关联函数前加上 pub 关键字。
错误消失了,这很不错。现在假设我们想在获得早餐后将面包从黑麦改为全麦。为此,我们尝试重新分配 meal 变量的 toast 字段。然而,这里你可以看到出现了一个错误,错误提示 toast 字段是私有的。即使我们的结构体是公开的,默认情况下结构体中的字段依然是私有的。为了解决这个问题,我们可以将 toast 字段显式地标记为公共的。现在我们可以重新分配它。
需要注意的是,要创建新的 Breakfast 结构体,我们必须使用 summer 关联函数,而不能在 eat_at_restaurant 内直接创建 Breakfast 结构体。这是因为 Breakfast 结构体包含了一个私有字段 seasonal_fruit。如果我们尝试直接创建 Breakfast 结构体,如下所示,我们会遇到一个错误,错误提示 seasonal_fruit 字段是私有的,因此我们无法直接创建该结构体。
现在让我们看另一个使用枚举(enum)的例子。在这个例子中,我们有一个名为 back_of_house 的模块,模块内有一个名为 Appetizer 的枚举,它有两个变体:Soup 和 Salad。然后在底部的 eat_at_restaurant 函数中,我们创建了两个订单,分别指定了 Appetizer 的变体。正如预期的那样,我们遇到了错误,因为 Appetizer 默认是私有的。为了解决这个问题,我们将 Appetizer 枚举标记为公共的。
需要注意的是,在 eat_at_restaurant 函数中,我们使用了 Soup 和 Salad 变体,但我们不需要显式地将它们标记为公共的。如果枚举的变体是私有的,枚举就没有太大用处。因此,默认情况下,如果你将一个枚举标记为公共的,它的所有变体也会自动是公共的。
接下来,我们再看一个例子,这个例子主要讨论 use 关键字。在这个例子中,我们有一个 front_of_house 模块,模块内有一个 hosting 模块,hosting 模块内有一个 add_to_waitlist 函数。在底部,我们有一个 eat_at_restaurant 函数,它调用了 add_to_waitlist 函数。
每次调用这些函数时都指定完整路径并不美观或理想。为了解决这个问题,Rust 提供了 use 关键字。use 关键字允许你将路径引入作用域。例如,我们可以将 hosting 模块引入作用域。现在 hosting 模块已经在作用域中,我们可以在使用它时省略 front_of_house。这里我们使用的是绝对路径,但我们也可以使用相对路径。为此,我们将 crate 改为 self,self 引用了当前模块,然后我们可以指定 front_of_house 和 hosting。
在这里,我们没有将 hosting 模块引入作用域,而是进一步将 add_to_waitlist 函数引入作用域,这样我们就不需要再指定 hosting。然而,在 Rust 中,将函数的父模块引入作用域是更符合惯用法的做法。这样做不仅减少了我们需要指定的路径,还清楚地表明 add_to_waitlist 函数不是一个本地函数,而是定义在另一个模块中。
另一方面,如果你要引入枚举、结构体或其他项,使用完整路径是惯用的做法。唯一的例外是当你从不同模块引入两个具有相同名称的项时。此时,你应该引入父模块,以避免名称冲突。
例如,我们来看以下代码。我们有两个函数,它们使用 Result 类型,但每个函数使用的是来自不同模块的 Result 类型。为了防止名称冲突,我们将父模块引入作用域,然后通过指定父模块来引用 Result 类型。另一种做法是,在引入作用域时重命名其中一个 Result 类型。在这个例子中,我们将 fmt 模块中的 Result 类型引入作用域,然后将 io 模块中的 Result 类型引入作用域,但我们将其重命名为 IoResult。我们可以使用 as 关键字来实现重命名,然后我们的函数可以使用各自的名称来引用不同的 Result 类型。
现在让我们回到餐厅的例子,来讨论一下重新导出(re-exporting)。和之前一样,我们有一个 front_of_house 模块,里面有一个 hosting 模块,hosting 模块内有一个 add_to_waitlist 函数。在底部的 eat_at_restaurant 函数中,我们调用了 add_to_waitlist 函数,并且我们不需要指定完整路径,因为我们已经将 hosting 模块引入了作用域。
假设我们希望外部代码也可以访问 add_to_waitlist 函数。目前它无法访问,外部代码只能访问我们公开的 eat_at_restaurant 函数。如果我们希望外部代码能够直接调用 add_to_waitlist 函数,我们需要重新导出 hosting 模块。为此,我们只需在 use 语句前加上 pub 关键字。
现在我们不仅将 hosting 模块引入了作用域,还将其标记为公共的,这样外部代码也可以引用 hosting 模块。
use 关键字不仅允许我们将程序中的项引入作用域,还可以将外部依赖项的项引入作用域。例如,让我们打开 Cargo.toml 文件,并添加 rand 依赖项。
然后在 lib.rs 文件中,我们可以将 Rng trait 引入作用域。Rng trait 是从外部依赖项引入的。现在它已经在作用域中,我们可以使用它来创建一个随机数。如果你还记得我们的猜数字游戏,这段代码和之前的一样,这里我生成了一个从 1 到 100 的数字。
现在假设我们还想从 rand 引入更多的项。我们可以将这些项分别列在不同的行上,但考虑到它们都以 rand 开头,我们可以使用嵌套路径将代码简化为一行。这时我们指定了路径的公共部分 rand,然后使用双冒号和花括号来包含嵌套路径。如果你引入了很多项,使用嵌套路径可以让你的代码更加简洁。
我们再来看一个例子。在这里,我们从标准库中引入了 io,并且还引入了 Write,它定义在 io 内部。这两个 use 语句的公共路径部分是 std::io,所以我们可以将它们合并为一行。在这里,我们再次使用嵌套路径,指定了 std::io,然后使用 self 来引用 io 本身,接着引入了 Write。
最后我们来讨论一下全局导入操作符(glob operator)。假设我们想将 io 下的所有公共项引入作用域。为此,我们可以指定 std::io::*,现在 io 下的所有公共项都被引入了作用域。
到目前为止,我们在一个文件中定义了多个模块,但可以想象,如果程序变得庞大,某个模块的代码可能也会变得较大。这时你可能希望将模块定义移到另一个文件中。让我们来看一下如何操作,通过将 front_of_house 模块的定义移动到另一个文件中。
首先,我们在 src 目录的根目录中创建一个名为 front_of_house.rs 的新文件。然后我们将 front_of_house 模块的内容移动到这个新文件中。
回到 lib.rs 文件,在 front_of_house 模块定义后,我们不再使用大括号,而是用分号代替。这告诉 Rust 在这里定义 front_of_house 模块,但模块的内容来自与模块同名的另一个文件。
为了继续我们的例子,让我们回到 front_of_house 文件,并将 hosting 模块的定义提取到一个单独的文件中。首先,我们在 front_of_house 目录中创建一个名为 hosting.rs 的新文件。然后我们回到 front_of_house.rs 文件中,将 hosting 模块的内容粘贴到 hosting.rs 文件中。最后,再次回到 front_of_house.rs 文件,我们移除大括号并用分号替代。
回到 lib.rs 文件,我们可以看到一切仍然正常运行。
让我们再回顾一下。我们可以使用 mod 关键字在 Rust 中声明模块,并可以通过大括号内联定义模块内容。但如果我们希望模块的内容位于不同的文件中,我们只需在模块定义后使用分号,并将模块的内容放在与模块同名的文件中。在这个例子中,我们在这里声明了 front_of_house 模块,其内容位于名为 front_of_house.rs 的文件中。同样地,我们可以声明子模块,并让它们的内容位于不同的文件中。但对于子模块,文件必须位于与父模块同名的目录中,在这个例子中,父模块是 front_of_house,因此 hosting.rs 文件位于 front_of_house 目录中。
这一节内容非常多。在本视频中,我们学习了 Rust 的模块系统,了解了包、crate、模块、隐私规则、路径、将路径引入作用域、重新导出以及将模块内容分离到单独的文件中。如果你喜欢这期视频,记得点赞。如果你希望每周看到更多 Rust 相关的内容,记得订阅。
最后,如果你有任何特定的 Rust 主题希望我讲解,请在下方留言告诉我。我们下期再见!
Understanding Ownership in Rust
欢迎回到 “Let’s Get Rusty” 频道,我是 Bogdan,这个频道专注于 Rust 编程语言。如果你对此感兴趣,请悬停在订阅按钮上并给它一个拳头碰击(fist bump)。上次我们讲解了《Rust 编程语言》一书的第三章,涵盖了在 Rust 语境下的基本编程概念。如果你还没有看过那个视频,建议先去看看。在这期视频中,我们将讨论第四章。这是一个非常特别的视频,因为第四章涵盖了 Rust 最独特的特性:所有权(Ownership)。所有权是 Rust 能够在没有垃圾回收器的情况下保证内存安全的关键特性。我们还将讨论引用、借用、切片类型以及 Rust 如何在内存中布局数据。那么话不多说,让我们开始吧。
首先,我们来回答一个基本问题:什么是所有权?或者说,Rust 中的所有权模型是什么?所有权模型是一种管理内存的方式。那么,你可能会问,为什么我们需要一种方法来管理内存?为了理解这一点,有必要看看现今用于管理内存的其他两种解决方案。
首先是垃圾回收(Garbage Collection)。如果你曾经使用高级编程语言(如 Java 或 C#)编写应用程序,你可能不需要担心内存管理,因为垃圾回收器会为你处理。这种方法有一些优点和缺点。第一个优点是免于内存错误,这意味着如果你自己管理内存,可能会引入内存错误,但由于这是由垃圾回收器处理的,你可以相对放心地认为不会有内存问题。我这里加了个星号,因为垃圾回收器本身也可能有 bug,但通常情况下,你可以放心你的内存是安全的。第二个优点是编写代码的时间更快,因为你不需要处理内存问题,可以更快地编写程序。
现在让我们看看缺点。首先,我们放弃了对内存的精细控制,因为垃圾回收器现在处理了我们所有的内存。其次,运行时性能可能会变慢且不可预测,因为垃圾回收器可能会在任何时候选择清理内存,而当它这样做时,会减慢我们的程序。最后,程序体积更大,因为垃圾回收器是一段我们需要包含在程序中的代码。
现在让我们看看手动内存管理(Manual Memory Management)。如果你曾写过 C 或 C++,你需要手动分配和释放内存。它的优点是你可以完全控制内存,这通常会导致运行时更快,因为你可以进行优化,并且程序体积更小,因为你不需要包含垃圾回收器。缺点是这非常容易出错,许多 bug 和安全问题都是由于错误的内存管理引起的。其次,编写代码的时间更长,因为你需要考虑内存问题,这会拖慢程序编写的过程。
注意,这里垃圾回收和手动内存管理的优缺点正好是相反的。我们在做相反的权衡。根据上下文的不同,这两种解决方案中的任何一种都可能是合适的。如果你在编写一个高层次的应用程序,比如一个网站,那么牺牲运行时性能和更大的程序体积,以换取垃圾回收器带来的易用性和更快的编写时间是合理的。另一方面,如果你在编写底层系统组件,那么你可能更关心运行时性能和程序体积,所以使用手动内存管理会更合适。
现在我们可以谈谈所有权模型,这是第三种管理内存的方法。Rust 是一种系统编程语言,所以它确实关心运行时性能和程序体积。你可以看到,在这里我们得到了手动内存管理的所有好处:对内存的控制、更快的运行时和更小的程序体积。然而,Rust 也是一种内存安全语言,所以我们不能使用手动内存管理,因为正如前面提到的,它非常容易出错。你可以看到,所有权模型是免于错误的,Rust 通过在编译时进行大量检查来确保你以安全的方式使用内存。我这里加了星号,因为尽管 Rust 默认是内存安全的,但它允许你使用 unsafe 关键字来选择不遵守内存安全,但这应该谨慎使用。
正如你所知道的,软件中的一切都是权衡。所有权模型给了我们内存安全,但缺点是编写代码的时间比手动内存管理更慢,这是因为 Rust 对内存管理有一套严格的规则,如果你违反这些规则,你会遇到编译时错误。这有时被称为与借用检查器(borrow checker)作斗争,可能会让人沮丧,但随着时间的推移,你会变得更好,事情也会变得更容易。这里的核心思想是,这种权衡是值得的,值得在前期花时间处理借用检查器,以避免后期花费大量时间调试运行时内存问题。
由于 Rust 是一种系统编程语言,了解我们的内存在运行时的布局非常重要。Rust 会根据我们的内存是存储在栈(stack)上还是堆(heap)上来做出某些决定。接下来,我们将简要介绍什么是栈和堆。
在运行时,我们的程序可以访问栈和堆。栈的大小是固定的,不能在运行时增长或缩小。栈还存储栈帧(stack frames),每次函数执行时都会创建一个栈帧,栈帧存储了正在执行的函数的局部变量。栈帧的大小在编译时计算出来,这意味着栈帧中的变量必须具有已知的固定大小。栈帧中的变量的生命周期也仅限于栈帧的生命周期。例如,在这个程序中,a 首先执行,所以我们将 a 推入栈中,然后 a 执行 b,于是我们将 b 的栈帧推入栈中。现在,当 b 执行完毕时,它会从栈中弹出,所有的变量都会被丢弃。然后,当 a 执行完毕时,它的所有局部变量也会被丢弃。
另一方面,堆的组织性较差,它可以在运行时增长或缩小,存储在堆中的数据可以是动态大小的,可以是大量的数据,并且我们可以控制数据的生命周期。让我们回到我们的例子。首先我们执行函数 a,这会创建一个新的栈帧。a 初始化了变量 x 和 y。x 是一个字符串字面量,实际上存储在我们的二进制文件中,所以在栈帧中,x 这里将是对二进制文件中字符串的引用。y 是一个有符号的 32 位整数,它是固定大小的,所以我们可以直接将 y 存储在栈帧中。然后我们执行函数 b,于是另一个栈帧被创建,b 创建了它自己的变量 x。x 的类型是 String,它的大小可能是动态的,所以我们不能直接将它存储在栈中。相反,我们请求堆分配内存给这个字符串,堆分配了内存,然后堆返回一个指针,我们实际在栈中存储的就是这个指针。注意,将数据推入栈比在堆上分配内存更快,因为堆需要花时间寻找一个地方来存储新数据。还要注意的是,访问栈中的数据比访问堆中的数据更快,因为对于堆,你必须遵循指针。
我知道这是一个非常简短的解释,所以如果你还感到困惑或者想要更详细的解释,我在描述中放了一个视频的链接,该视频更详细地介绍了栈和堆,所以一定要去看看。好了,让我们回到所有权上。在继续之前,有三个重要的所有权规则需要记住。记下这些规则,把它们放在文档中,甚至纹在身上——无论如何都要记住它们。这些规则是:
- Rust 中的每个值都有一个变量,这个变量被称为它的所有者。所以一个变量只能有一个所有者。
- 同一时间只能有一个所有者。所以一个变量不能同时有两个所有者。
- 当所有者超出作用域时,值会被丢弃。
举个例子,我在这里使用大括号创建了一个新的作用域,并在里面定义了变量 s。在这个作用域中,s 在声明之前是无效的。然后我们声明了 s,从此刻起它是有效的,我们可以对 s 进行操作。然后当作用域结束时,s 失效了,Rust 会丢弃这个值。这里的 s 是一个字符串字面量,正如我之前提到的,字符串字面量直接存储在二进制文件中,并且大小是固定的。那么如果我们想要一个大小是动态的、可以修改的字符串呢?我们就必须使用 String 类型。我已经将 s 转换为 String 类型,现在我们的字符串存储在堆上。
在像 C++ 这样的编程语言中,你需要使用 new 关键字来在堆上分配内存,然后在使用完后使用 delete 关键字来释放内存。而在 Rust 中,这些都是自动完成的。当我们在这里声明 s 时,Rust 会自动在堆上为我们的字符串分配内存,然后当作用域结束时,s 失效,Rust 会丢弃我们的值,这意味着它会自动释放堆上的内存。
接下来,让我们讨论变量和数据之间的交互。这里我们有两个变量 x 和 y,x 被设置为 5,y 被设置为 x。正如注释所示,这将做你预期的事情,即将值 5 复制到 y。让我们看一个更有趣的例子:这里我们有一个变量 s1,我们将其设置为一个 String 类型。在右侧,你可以看到 s1 在底层的样子:我们有一个指针,它指向堆上的实际内存位置,我们有一个长度(length),它是字符串的长度,还有一个容量(capacity),这是堆上为我们的字符串分配的实际内存量。在下一行,我们声明了 s2 并将其设置为 s1。那么在这种情况下,我们会期望发生什么呢?
有些人可能会期望值被克隆,就像我们在右侧看到的那样,s1 指向堆上的一个字符串,s2 是另一个指针,指向堆上的一个新字符串。但这并不是实际发生的情况,因为创建一个新字符串在堆上是非常昂贵的。其他人可能认为我们会做一个浅拷贝(shallow copy),所以 s1 有一个指针指向堆上的 hello,s2 有一个指针指向同一个堆上的 hello。但这也不完全正确,因为为了确保内存安全,Rust 会使 s1 无效。所以这不是一个浅拷贝,而是被称为 “移动”(move)。回到我们的程序,试图在 s1 被移动到 s2 之后打印 s1。我们运行 cargo run,可以看到我们得到了一个编译时错误,提示 s1 已经在这里被移动,然后我们试图在 s1 被使用后再使用它。
那么如果我们确实想要克隆字符串而不是移动 s1 呢?Rust 提供了一个常用的方法来实现这一点。我们不再将 s2 设置为 s1,而是将 s2 设置为调用 clone 方法的 s1。现在我们可以运行我们的程序,它成功编译了。所以 Rust 默认是移动一个值,如果你想执行更昂贵的克隆操作,可以使用 clone 方法。这里还有一个细节:当我们将 y 设置为 x 时,这实际上是一个复制操作,而不是移动操作。Rust 有一个 Copy 特性(trait),简单类型(如整数、布尔值和字符)存储在栈上,会实现这个特性,从而允许这些类型被复制而不是移动。
接下来,让我们讨论所有权和函数。这里我们有一个变量 s,它是 String 类型,还有一个函数 takes_ownership。takes_ownership 函数接受一个字符串作为参数,然后将其打印出来。在 main 函数中,我们试图在调用 takes_ownership 后打印变量 s,但我们得到一个错误,错误提示 s 不能在移动后被借用。这是因为当我们将参数传递给函数时,这与将 s 赋值给另一个变量是一样的。所以这里传递 s 会将 s 移动到 some_string 变量中,然后 some_string 被打印出来,在这个作用域结束后,some_string 被丢弃。
让我们看另一个例子。我们有一个变量 x,它是一个整数,还有一个函数 makes_copy。makes_copy 接受一个整数作为参数,然后将其打印出来。你可以看到,我们传递了 x,但它没有被移动。记住整数是复制的,所以它被复制到 some_integer 变量中并打印出来,但我们仍然可以在函数调用后使用 x。
这也可以反过来进行。这里我们有一个变量 s1,它等于 gives_ownership 函数的返回值。gives_ownership 是一个返回字符串的函数。你可以看到,我们创建了一个名为 some_string 的字符串,然后我们返回这个字符串。返回字符串会将字符串的所有权移动到 s1 变量中,然后我们可以在之后使用它。
最后,我们可以将所有权传递给函数并返回。例如,这里有一个名为 s2 的变量,它是一个字符串,我们将它传递给一个函数 takes_and_gives_back。takes_and_gives_back 接受一个字符串作为参数,并返回一个字符串。所以这里我们将 s2 的值移动到函数中,然后我们只是返回字符串,这会将值从函数中移出并赋给 s3。将所有权移动到函数中并返回是很繁琐的。如果我们只是想使用一个变量而不需要获取它的所有权呢?这就是引用(references)的作用,接下来我们将讨论它们。
为了理解引用,让我们看看它们如何解决以下情况。这里我们有一个名为 calculate_length 的函数,我们希望它接受一个字符串并返回该字符串的长度。然而,我们不想获取字符串的所有权。这里的解决方案是返回一个包含字符串和字符串长度的元组。在上面,你可以看到我们将字符串赋给 s2,将长度赋给一个名为 len 的变量。你也可以看到,这看起来非常奇怪,可能不是你想要编写的代码。
要解决这个问题,让我们先修改我们的 calculate_length 函数。首先,我们将返回类型改为仅返回字符串的长度。接下来,我们只返回长度。上面我们去掉了 s2 并在下面打印 s1。正如预期的那样,我们在这里得到了一个错误,因为我们在字符串被移动到函数后尝试借用它。要解决这个错误,而不是让 calculate_length 接受一个字符串,我们让它接受一个字符串的引用,我们通过在字符串前添加一个 & 来实现这一点。接下来,在 main 函数中,我们不再传递字符串,而是通过再次使用 & 传递字符串的引用。太好了,现在我们没有错误了,因为 s 是对字符串的引用,而引用不会获取底层值的所有权。
在 Rust 书中,你可以看到引用的示意图:s 是引用,它指向 s1,s1 实际上指向堆上的字符串。s 是 calculate_length 函数中的局部变量,当函数执行完毕时,s 被丢弃,但这没关系,因为即使我们在这里丢弃 s,我们仍然有 s1 指向我们的字符串。将引用作为函数参数传递被称为借用(borrowing),因为我们借用了这个值,但实际上并没有获取它的所有权。还要注意,引用默认是不可变的,所以如果我们尝试修改 s,你会看到我们得到了一个错误,提示我们不能将值借用为可变的。
这里有另一个例子,我们有一个字符串和一个名为 change 的函数,它接受这个字符串的引用,然后尝试修改它。再说一次,引用默认是不可变的,所以我们不能修改这个值。但假设我们确实想在不获取它的所有权的情况下修改这个值。要实现这一点,首先我们需要使 s1 成为可变变量。接下来,我们不再传递引用,而是传递一个可变引用。最后,我们让 change 函数接受一个可变引用。现在 change 函数可以修改我们的字符串,而不获取底层值的所有权。
可变引用有一个很大的限制,那就是在特定作用域内,你只能有一个可变引用指向特定数据。例如,我们在这里有一个字符串和一个名为 r1 的变量,它是指向字符串的可变引用。现在让我们添加另一个可变引用并打印出这两个引用。我们在这里有 r2,它是指向字符串的另一个可变引用,然后是一个打印语句,打印出 r1 和 r2。你可以看到我们得到了一个错误,错误提示你不能将 s 借用为可变引用超过一次。这种限制的一个重要好处是,Rust 可以在编译时防止数据竞争(data race)。
数据竞争(data race)指的是当两个或多个线程同时访问同一个内存位置,并且至少有一个线程试图写入数据时所发生的情况。这种情况下,线程之间的执行顺序是不确定的,可能会导致难以追踪的 bug。Rust 通过可变引用的这个限制,在编译时防止数据竞争,从而确保内存安全。
接下来,让我们讨论不可变引用(immutable references)。你可以在同一作用域内拥有多个不可变引用,但不能同时拥有一个可变引用和一个不可变引用。例如,如果我们有一个变量 r1,它是指向字符串的不可变引用,然后我们再添加一个不可变引用 r2,这是完全允许的。你可以看到,我们可以在同一作用域内使用这两个不可变引用,并且没有任何问题。然而,如果我们在有不可变引用的情况下尝试创建一个可变引用,Rust 会拒绝编译。因此,不可变引用和可变引用之间是互斥的。
最后,我们来讨论切片(slices)。切片允许我们引用集合中的一部分,而不是整个集合。切片是一种引用类型,因此它不会获取所有权。字符串切片最常见的形式是从字符串中截取一部分,例如:
1 | let s = String::from("hello world"); |
这里,我们创建了一个字符串 s,然后我们创建了两个切片 hello 和 world,分别引用字符串的不同部分。切片的范围是使用 start..end 语法表示的,其中 start 是切片的起始索引,end 是切片的结束索引(不包括 end 索引)。在上面的例子中,hello 切片引用了从 0 到 5 的部分,而 world 切片引用了从 6 到 11 的部分。
如果省略 start 或 end,Rust 会默认使用字符串的开头或结尾。例如:
1 | let hello = &s[..5]; // 等价于 &s[0..5] |
切片不仅仅适用于字符串,还可以用于数组。数组切片的用法类似于字符串切片:
1 | let a = [1, 2, 3, 4, 5]; |
在这个例子中,slice 是一个数组切片,包含值 [2, 3]。
切片在 Rust 中非常有用,因为它们允许我们安全地引用集合中的一部分,而无需担心内存管理问题。切片的引用是不可变的,这意味着你不能通过切片修改底层数据。
总结一下,Rust 的所有权系统通过所有权、借用和切片等概念,帮助确保内存安全。虽然这个系统可能需要一些时间来适应,但一旦掌握了这些概念,你将能够编写更加高效、安全的代码。
如果你对 Rust 的所有权模型有更多问题,或者对其他 Rust 特性感兴趣,请在评论区告诉我。别忘了订阅 “Let’s Get Rusty” 频道,并点击旁边的小铃铛,这样你就不会错过任何更新。感谢收看,我们下期再见!
Rust Lifetimes Finally Explained!
欢迎回来,Rust 团队!如果你是新来的,我叫 Bogdan,这个频道是关于 Rust 编程语言的。如果你对这个话题感兴趣,确保点击订阅,每周都会有 Rust 相关的视频。在上一个视频中,我们继续讲解了第十章的内容,讨论了特征(traits)。在本视频中,我们将完成第十章,通过讨论生命周期(lifetimes)来结束本章的内容。那么,让我们开始学习 Rust 吧!
首先,我们来讨论悬空引用(dangling references)。什么是悬空引用?它是指向无效数据的引用,而 Rust 是不允许悬空引用存在的。我们来看下面的例子:我们在这里声明了一个变量 r,然后创建了一个内部作用域,在这个作用域中声明了另一个变量 x,并将其设置为 5。接着,我们将 r 设置为对 x 的引用。顺便提一下,在 Rust 中,你可以像我们这里做的一样引用原始值(primitive values)。之后,我们尝试打印 r,此时 r 是一个悬空引用。为什么呢?因为 r 是对 x 的引用,而 x 是在内部作用域中声明的,当这个内部作用域结束时,x 就无效了。但我们这里还在尝试打印指向 x 的 r。幸运的是,Rust 不会允许这段代码编译。你可以看到这里有一些红色波浪线,如果你把鼠标悬停在上面,会看到提示:x 的生命周期不足够长。对,x 的生命周期不足以让我们在下面打印 r。
Rust 在编译时就知道这一点,并且借助借用检查器(borrow checker)来实现这一点。借用检查器在编译时运行,并检查所有被借用的值或引用是否有效。在这里,我已经注解了变量的生命周期,以便你更好地理解借用检查器是如何工作的。变量的生命周期指的是变量存在的时间长度。举个例子,这里我们声明 r,并用 tick a 注解它的生命周期,它的生命周期持续到 main 函数的末尾。然后在内部作用域中,我们声明了变量 x,并用 tick b 注解了它的生命周期,它的生命周期仅持续到内部作用域的末尾。利用这些信息,借用检查器可以说:好的,r 在这里引用了 x,当我们尝试打印它时,但 x 的生命周期在这里已经结束了,所以 r 是一个悬空引用,这就是为什么我们会得到这个错误提示:x 的生命周期不足够长。
让我们来看一个稍微不同的例子。这里我们在顶部声明了 x,把它设置为 5,然后声明 r 并将其设置为对 x 的引用,然后打印 r。这次我们没有收到编译时的错误,为什么呢?因为 x 的生命周期从这里开始,并持续到 main 函数的末尾,而 r 的生命周期从这里开始,也持续到 main 函数的末尾。因此,当我们在这里打印 r 时,r 正在引用 x,而此时 x 的生命周期仍然有效。所以,我们不会得到编译时的错误。
这次,借用检查器能够在没有任何帮助的情况下为我们解决这一切。接下来,我们将讨论在某些情况下我们需要使用通用生命周期注解(generic lifetime annotations)来帮助编译器。
让我们来看下面的例子:在 main 函数中,我们定义了 string1 和 string2,它们只是字符串。然后我们定义了一个 result 变量,它被设置为调用 longest 函数的返回值。longest 函数接受两个对字符串切片的引用 x 和 y,然后返回最长的字符串。所以,返回类型将是一个对字符串切片的引用。一旦我们得到了结果,我们就简单地打印它,因此这里我们会打印最长的字符串。
现在,从借用检查器的角度来看,当我们在这里打印 result 时,借用检查器如何知道 result 不是一个悬空引用?记住,借用检查器检查引用的生命周期以确定它们是否有效。但是 result 的生命周期是什么呢?longest 函数在这里返回一个引用,但我们对这个引用的生命周期一无所知。首先,我们根据这个 if 语句返回 x 或 y。然而,x 的生命周期可能与 y 不同,这是我们的第一个问题:我们不知道返回的是 x 还是 y,而它们可能有不同的生命周期。其次,我们不知道 x 或 y 的确切生命周期,因为这些只是占位符。想象一下,这个函数可能会从许多不同的地方调用,因此 x 和 y 可能有许多不同的生命周期。借用检查器不知道如何处理这种模糊性,因此我们会得到一个错误,你可以通过这些小红色波浪线看到这个错误。如果我把鼠标悬停在上面,它会提示:缺少生命周期说明符(missing lifetime specifier)。函数的返回类型包含一个借用的值,但签名没有说明它是从 x 还是 y 借用的。——这很有道理,对吧?x 或 y 可能有不同的生命周期,而我们需要知道使用了哪个生命周期作为返回的生命周期。
现在,为了解决这种情况,我们需要使用一种叫做通用生命周期注解的东西。那么,什么是通用生命周期注解呢?通用生命周期注解描述了多个引用之间的生命周期关系以及它们如何相互关联。因此,它们实际上并不会改变引用的生命周期,而只是解释不同生命周期之间的关系。
现在,这可能有点混乱,因为 “通用生命周期注解” 是个绕口令,所以通常人们提到它们时,直接称为 “生命周期”。让我们使用通用生命周期注解来修复这段代码。首先,关键词是 “通用”。就像常规的泛型一样,要定义通用生命周期注解,我们需要在函数名后用尖括号括起来的方式来指定它们。
通用生命周期注解总是以撇号(’)或勾号(tick)开头,接下来是生命周期的名称。我以后会叫它 “tick”。我们可以为生命周期命名任何我们想要的名字,例如我们可以把它命名为 “apple”。我不知道为什么你会想这么做,但你可以。然而,惯例是用小写字母来命名它,从 a 开始,然后按字母顺序排列。因此,我们可以命名为 a、b、c,等等。现在我们已经声明了我们的通用生命周期注解,现在我们可以在函数内部使用它了。我们可以说 x 将使用我们的生命周期,y 也将使用我们的生命周期,并且我们的返回类型将使用相同的生命周期。
顺便说一句,这里是如何指定通用生命周期注解的:在这里我们有一个引用,下面我们有一个带有显式生命周期注解的引用,再下面我们有一个带有显式生命周期注解的可变引用。
回到我们的例子,这里我们声明了一个通用生命周期 a,然后我们用这个 a 注解了 x、y 和返回值。这意味着 x、y 和返回值将拥有相同的生命周期。嗯,其实不完全是。记住,通用生命周期注解实际上并不会改变生命周期,它们只是创建多个引用之间的关系。因此,在这里我们实际上是在说,x、y 和返回值之间存在一种关系,这种关系是:返回引用的生命周期将与参数中最短的生命周期相同。所以,如果 x 的生命周期比 y 短,那么返回引用的生命周期将与 x 的生命周期相同;如果 y 的生命周期比 x 短,那么返回值的生命周期将与 y 的生命周期相同。希望这一点你能理解。我们的 longest 函数将返回一个对字符串切片的引用,而这个引用将是 x 输入或 y 输入之一,但我们不知道会返回哪个。在 main 函数中,我们获取 result 类型并在这里打印出来。因此,从借用检查器的角度来看,借用检查器如何知道 result 不是一个悬空引用呢?我们刚刚告诉借用检查器,无论从 longest 返回的值是什么,并在这里保存到 result 中,它将具有与传入的最短生命周期相同的生命周期。所以现在,借用检查器只需要说:好吧,传入的最短生命周期是什么?是 string1 还是 string2?在这种情况下,它们是相等的,但你可以想象它们可能具有不同的生命周期。然后借用检查器只需要说,当这个结果在这里打印时,最短生命周期是否仍然有效。如果是这样的话,比如在这个例子中,string1 和 string2 在这里仍然有效,那么我们就没有问题。
让我们再来看一个例子,在这个例子中,string1 和 string2 有不同的生命周期。string1 是在顶部定义的,它的生命周期将持续到 main 函数的末尾。其他所有的内容都定义在这个内部作用域中,包括 string2,所以 string2 的生命周期将仅持续到这个作用域的末尾。然后我们再次调用 longest 函数,将结果保存到 result 变量中,并将其打印出来。现在 result 的生命周期将与传入 longest 函数的最短生命周期相同,在这种情况下,它是 string2,因为它的生命周期仅持续到这个内部作用域的末尾。现在借用检查器查看这个 print! 语句并说:这是有效的,因为当我们写这个 print! 语句时,string2 仍然是有效的。
现在让我们来看最后一个例子:这里我们在顶部声明了 string1,它的生命周期有效到 main 函数的末尾。然后我们声明 result,接着进入一个内部作用域。在这个内部作用域中,我们声明了 string2,然后将 result 设置为我们的 longest 函数的返回值,并尝试在这个内部作用域结束后打印 result。在这种情况下,我们会得到一个错误。如果我们把鼠标悬停在红色波浪线上,我们会看到 string2 的生命周期不足够长。这意味着什么呢?记住,result 的生命周期将与传入的最短生命周期相同,而在这个例子中,它是 string2。所以 result 的生命周期只会持续到这个内部作用域的末尾。因此,当我们试图在内作用域结束后打印 result 时,会出现问题,因为 string2 的生命周期不足够长,无法使这个操作有效。你可以想象,如果 longest 返回了 string2,那么 string2 将在这个作用域结束后失效,result 将指向无效的数据,也就是一个悬空引用。因此,使用生命周期注解可以帮助借用检查器理解多个引用之间的生命周期关系,这反过来可以帮助借用检查器识别悬空引用。
现在,你可以根据函数的不同需求来指定不同的生命周期注解。例如,假设我们希望 longest 函数始终返回 x,无论如何。在这种情况下,我们不关心 y 的生命周期,因为我们总是会返回 x。所以我们可以从 y 中移除这个 tick a。现在你可以看到我们在 main 中的错误消失了,我们的代码现在是有效的。为什么呢?因为我们知道 longest 总是会返回一个与 x 具有相同生命周期的引用。在这种情况下,x 是 string1,而 string1 的生命周期持续到 main 函数的末尾,所以在这里打印 result 是有效的。
这里有一点有趣的是返回值的生命周期总是必须与某个参数的生命周期相关联。现在你可能会问,为什么会这样?原因是如果我们从函数中返回一个引用,它必须是对某个传入参数的引用。这是因为我们不能返回对函数内部创建的某些东西的引用。比如说,让我们尝试这么做。这里我们在函数内部创建了一个字符串,并返回对该字符串的引用。现在我们可以从 x 中移除这个 tick a,因为我们不关心 x 的生命周期,结果我们得到了一个错误。现在我们把鼠标悬停在错误上,提示说:不能返回引用本地变量的值。返回的值引用了当前函数拥有的数据。那么这实际上意味着什么呢?这个错误提示说我们不能返回引用函数内部创建的东西,因为一旦这个函数结束,局部变量就会被销毁。所以,如果我们返回一个引用,它必须是对传入参数的引用。另一个选项是返回一个拥有的类型。例如,我们可以返回一个字符串,而不是对字符串切片的引用,然后我们可以返回 result。这可行,因为即使我们在函数内部创建了一个 result,我们返回的是一个拥有的类型,这会转移所有权。
接下来,我想简要介绍一下带有生命周期注解的结构体(structs)。在以前的章节中,当我们使用结构体时,我们总是使用拥有的数据类型,但如果我们想要使用引用,那么我们需要指定生命周期注解。这里我们有一个名为 ImportantExcerpt 的结构体,它有一个对字符串引用的 part 字段。为了让这段代码工作,我们需要指定一个生命周期注解。再次说明,我们通过在结构体名称后用尖括号括起来的方式来指定生命周期注解,并在这里注解引用。在 main 函数中,我们有一个名为 novel 的变量,它是一个字符串,然后一个名为 first_sentence 的变量,它引用了字符串切片的第一个句子。然后我们创建了一个新的结构体实例,并将 first_sentence 传递给它。现在,生命周期注解在这里表示我们的结构体不能比传入 part 的引用活得更久。所以在 main 中,如果我们尝试在 first_sentence 作用域结束后使用我们的结构体实例 i,我们会得到一个错误。
现在让我们来谈谈生命周期省略规则(lifetime elision rules)。如果你还记得第四章,我们编写了一个名为 first_word 的函数,它接受对字符串切片的引用并返回第一个单词。这里我已经包括了一个生命周期注解,但如果你还记得第四章,我们实际上并没有指定生命周期注解。如果我在这里移除 tick a,你会看到函数仍然可以工作,我们没有看到红色波浪线,我们的代码编译得很好。那么这是为什么呢?有一些情况,编译器可以确定性地推断生命周期注解,它通过检查三条生命周期省略规则来做到这一点。
在我详细解释这些规则之前,需要注意的一点是,传入参数的生命周期叫做输入生命周期(input lifetimes),而返回值的生命周期叫做输出生命周期(output lifetimes)。第一条规则是,每个作为引用的参数都会获得自己的生命周期参数。所以,在我们的 first_word 函数中,s 将获得一个生命周期参数。第二条规则是,如果只有一个输入生命周期参数,那么该生命周期将被赋予所有输出生命周期参数。所以在我们的 first_word 函数中,我们的返回类型将获得 tick a,这是因为我们这里只有一个输入生命周期。所以你可以看到,编译器使用前两条规则生成了这些生命周期,因此我们不需要自己指定它们。
如果我们有多个输入生命周期,比如我们在 longest 函数中的那种情况,那么我们就需要手动指定生命周期,因为这样就违反了第二条规则,即只有一个输入生命周期参数。第三条规则规定,如果有多个输入生命周期参数,但其中一个是对 self 的引用或者是对 self 的可变引用,那么 self 的生命周期将被赋予所有输出生命周期参数。第三条规则仅适用于方法,当我们稍后看到一个例子时,这会更容易理解。但总的来说,编译器会尝试遵循这三条规则,如果在应用这三条规则后仍然无法确定生命周期,那么我们就必须手动指定它们。
让我们回到 ImportantExcerpt 例子中,看看方法中的生命周期注解。这里我们有我们的结构体,并在下面声明了一个结构体的实现块。现在,生命周期注解是一种泛型,所以就像泛型一样,我们需要在 impl 之后和结构体名称之后的尖括号中包含生命周期注解。然后我们有一个名为 return_part 的函数,它接受对 self 的引用,一个名为 announcement 的字符串切片的引用,并返回一个字符串切片的引用。在函数内部,我们打印 announcement,然后返回 self.part。
那么,为什么我们不需要指定生命周期注解呢?让我们来看看我们的规则。规则一是,每个作为引用的参数都会获得自己的生命周期,所以对 self 的引用将获得一个生命周期,我们称之为 tick a,announcement 将获得一个生命周期,我们称之为 tick b。第二条规则是,如果我们只有一个输入生命周期,那么输出生命周期将与输入生命周期相同。在这种情况下,我们有两个输入生命周期,所以第二条规则不适用。第三条规则是,如果我们函数的参数之一是对 self 的引用或对 self 的可变引用,那么所有输出生命周期将与 self 的生命周期相同。这正是这个例子的情况,所以我们不需要显式地包括生命周期注解,但如果我们想要,我们也可以这样做。
在这种情况下,我们不需要在函数名后面的尖括号中指定 tick a,因为它已经在作用域中了。接下来,我想简要介绍一下一个特殊的生命周期,叫做静态生命周期(static lifetime)。静态生命周期意味着引用可以存在与程序的持续时间一样长。所有的字符串字面量都有一个静态
此回复受到限制,因为已达到允许的最大输出 Token 数。
如果只存在一个输入生命周期参数,那么这个生命周期会被分配给所有输出生命周期参数。因此,在我们第一个 word 函数中,返回类型会得到 tick a,这是因为这里只有一个输入生命周期。正如你所看到的,通过使用前两条规则,编译器能够生成这些生命周期,所以我们不需要自己指定它们。如果有多个输入生命周期,比如我们在 longest 函数中所做的那样,那么我们就必须手动指定这些生命周期,这是因为这违反了第二条规则,即只有一个输入生命周期参数。
第三条规则规定,如果有多个输入生命周期参数,但其中一个是对 self 的引用或对 self 的不可变引用,那么 self 的生命周期会被分配给所有输出生命周期参数。第三条规则仅适用于方法,当我们稍后看到一个例子时,这一点会更清楚。总的来说,编译器会尝试遵循这三条规则,如果在遵循这三条规则之后仍然无法确定生命周期,那么我们就必须手动指定它们。
让我们回到之前的重要摘录例子,看看方法内部的生命周期注解。这里我们有一个结构体,并在其下方声明了针对该结构体的实现块。生命周期注解是一种泛型类型,所以就像泛型一样,我们需要在 impl 和结构体名称之后的尖括号内包含生命周期注解。然后我们有一个名为 return_part 的函数,该函数接收对 self 的引用、对名为 announcement 的字符串切片的引用,并返回对字符串切片的引用。
在函数内部,我们打印 announcement,然后返回 self.part。那么为什么我们不需要指定生命周期注解呢?让我们来回顾一下我们的规则。规则一是每个作为引用的参数都会得到一个生命周期,所以对 self 的引用会得到一个生命周期,我们称之为 tick a,而 announcement 会得到另一个生命周期,我们称之为 tick b。规则二是,如果我们只有一个输入生命周期,那么输出生命周期将与输入生命周期相同。在这种情况下,我们有两个输入生命周期,所以规则二不适用。规则三是,如果我们函数的参数之一是对 self 的引用或对 self 的不可变引用,那么所有输出生命周期将与 self 相同。在这个例子中就是这种情况,因此我们不需要显式地包含生命周期注解,但如果我们想这样做,也是可以的。
在这种情况下,我们不需要在函数名后的尖括号中指定 tick a,因为它已经在作用域内了。接下来,我想简要介绍一种特殊的生命周期,称为 static 生命周期。static 生命周期意味着引用可以存在与程序生命周期一样长的时间。所有字符串字面量都有 static 生命周期,这是因为(如果你还记得的话)字符串字面量存储在程序的二进制文件中,因此它们可以在程序的整个生命周期内存在。
最后,我们将把第十章所学的所有内容,包括泛型、特征、特征约束和生命周期,结合在一起进行一次代码演示。这里我们有一个名为 longest_with_an_announcement 的函数,它接受两个字符串引用 x 和 y,以及一个泛型类型 an,其中 an 代表 announcement 的缩写。然后函数会打印 announcement,并返回最长的字符串。首先,让我们讨论一下这里泛型的使用。我们的参数 an 是类型 T,因此它是一个泛型类型,但我们在 where 子句中使用了特征约束来限定它,要求它是任何实现了 Display 特征的类型,即可以打印到屏幕上的类型。最后,我们有生命周期注解。我们接收两个参数 x 和 y,它们都是对字符串切片的引用,随后返回一个对字符串切片的引用。由于我们有两个引用,编译器无法进行自动生命周期推断,所以我们必须手动指定生命周期,在这种情况下,tick a 会应用于 x、y 和返回值。
第十章到此结束,这确实是一个大章节。我们讨论了泛型、特征、特征约束和生命周期。如果你完成了本章的学习,给自己一个大大的鼓励。如果你喜欢这个视频,一定要点赞。最后,别忘了订阅,这样你就可以加入 Rust 小队,并在我发布下一期视频时收到通知,下一期视频将涉及 Rust 中的测试编写。到时见!
Understanding Strings in Rust - String vs &str
大家好,当我在几年前开始学习Rust时,首先让我深思的是字符串。在Rust中,有两种类型的字符串:String类型和str类型。随着你继续看例子和教程,你会发现这两者经常被交替使用。那么自然的问题是,什么时候应该使用其中一种而不是另一种?在这段视频中,我们将探讨这两者之间的区别。但首先,我们需要讨论字符串在内存中的存储方式。
让我们从第一个例子开始。不要过多关注具体语言本身,因为接下来的讨论可以应用于Python、Go、Java,以及其他很多语言。我们想要创建一个被空格包围的字符串,比如s = " hello "。从内存的角度来看,这个情况由两个内存实体来描述:我们在堆(heap)上有一个内存区域,里面包含实际的字符串内容;然后我们在栈(stack)上有一个字符串记录,它包含了堆上第一个元素的内存位置以及通常的长度。
看一下我们的例子,我们可以说s并不包含实际的字符串内容,而只是一个引用,指向包含内容的内存区域。这与其他基本数据类型(比如整数)形成了对比,因为整数的值通常存储在栈上,因为它们有固定的大小。
假设我们想要对字符串应用修剪操作(在Python中为strip),以获得其内容但去掉周围的空格。我们可以这样做:t = s.trim()。在这种情况下,变量t将会保存值"hello",没有任何周围的空格。在大多数垃圾回收语言中,这种操作会导致一次单独的分配,因此你最终会在堆上有两个内存块。
这就是Rust的魔力所在。如果我们假设t和s都不会被修改,它们是不可变的,那么我们可以通过简单地创建另一个字符串记录并将开始索引设置为s的第二个单元格,将长度设置为5来优化之前的操作。从用户的角度来看,t将是一个长度为5,内容为"hello"的字符串,但不需要额外的内存分配,从而极大地提高了内存效率。
在Rust中,我们称这个字符串记录为str类型,它表示对一个连续的字符数组的引用以及它的长度。技术上,这被称为对字符串切片(slice)的引用。当然,这种技术不能应用于所有情况。例如,如果我们调用to_uppercase()方法,得到的字符串将不会是同一个字符串的不同视图,而是一个完全不同的字符串。所以在这种情况下,需要额外的内存分配。
字符串切片的强大之处在于,字符串内容的位置可以根据情况而变化。有时内容会位于堆上,有时位于栈上,有时则是Rust二进制文件中的静态部分。经常让初学者困惑的是字符串字面量,特别是当你写如下代码时:
1 | let string1 = "hello"; |
你并没有在堆上分配字符串,而是创建了一个不可变的字符串字面量,其内容存储在二进制文件本身中,这是因为它的内容在编译时是已知的。另一方面,如果你写以下代码:
1 | let i = 10; |
在这种情况下,我们将一个整数i(值为10)转换为字符串。在这种情况下,字符串的内容在编译时是未知的,所以我们需要在堆上进行内存分配,创建字符串"10"。
如果我们现在分析前面两个变量的类型,会注意到string1的类型是字符串切片(str),而string2的类型是String。这是因为第一个字符串没有在堆上分配任何内存,而第二个字符串则有。
作为一个经验法则,每当你需要分配新的内存时,你需要使用String类型,而当你只需要对现有字符串的不同视图时,字符串切片(str)类型会更合适。技术上,String类型是一个已知大小的类型,而字符串切片(str)则不是。要真正理解这两者之间的区别,你必须掌握所有权(ownership)和借用(borrowing)的概念。顺便说一句,如果你需要这方面的帮助,我几个月前在频道上发布了一个视频,链接应该在这里。
简而言之,当你在堆上分配一些内存时,必须有一种方式在不再使用它时释放它,否则你将产生内存泄漏。在Java或Go中,这是垃圾回收器的责任,但在Rust中,是所有权系统在变量(即所有者变量)超出作用域时释放那片内存区域。
现在你了解了基础知识,让我给你几个小贴士。你可以轻松地在两者之间转换,使用to_string和借用操作,例如在这个例子中:我们有一个字符串字面量"foo",我们将其转换为一个拥有所有权的字符串,因此在这个过程中我们在堆上分配了一些内存,然后我们使用借用操作符&来得到字符串切片。
另一个重要的使用场景是字符串在函数签名中的使用。当你设计函数签名时,请记住,通常字符串作为字符串切片传递,并作为拥有所有权的字符串返回。这样可以防止在传递参数时不必要的复制,同时也保证了输出字符串的生命周期足够长,以便被调用代码接收。此外,这还允许函数接受字符串切片和拥有所有权的字符串(String)。当然,这个规则有很多例外,但在大多数情况下,最重要的是,在你真正理解所有权和借用之前,这种方法会对你有帮助。
好了,这就是对Rust字符串的一个简单介绍。我真的希望你喜欢它。如果你喜欢,请考虑订阅频道和点赞这个视频,因为这真的对我有帮助。我希望能在下一个视频中再见到你。
Async Rust Is A Bad Language | Prime Reacts
Rust 异步编程是一门糟糕的语言,但要理解我到底什么意思,我们需要先讨论为什么异步 Rust 会存在。让我们来谈谈这个问题。
现代并发编程:它们是绿色的,它们是强大的,它们吃掉了我的机器。我不明白,而且那个人的脸看起来也不想在这里。假设我们希望代码运行得更快,我们有两个大问题需要解决:我们希望使用整台计算机。代码运行在 CPU 上,而在 2023 年,即使是我的手机也有八个 CPU 核心。顺便说一下,我刚换了一部新手机,看看这款精美的 Pixel 7。我再也不用看着手机中间那条亮线了,真酷。如果我想使用超过 12% 的计算机,我就需要多个核心。即使在我最好的尝试中,我仍然无法让 Node 使用 100% 的 CPU。我尝试了很多方法让它使用所有 CPU,但我做不到。
我们希望在等待慢速操作完成时继续工作,而不是干等着。发送消息通过互联网甚至打开一个文件在计算机时间里需要很长时间,同时我们可以做成百上千的其他事情。这是非常合理的,等等。所以我们转向并行性和并发性,这是 CS(计算机科学)极客们最喜欢争论的领域。为了过度简化,并行性是指在多个 CPU 上并行运行代码,而并发性则是将问题分解为独立的部分。我给出我的看法:并行性是同时执行机器指令,并发性是当任务不需要 CPU 时让开,让其他任务在等待结果时运行。
并行性是多个事情同时发生,而并发性是像砖块一样堆叠它们。并行性是并发性的一部分。我不相信并行性是在完全相同的时间间隔内发生的事情。
现在我觉得这变得更加不清楚了,我觉得每个人都更困惑了。我仍然很困惑,它们之间到底有什么区别?好吧,想象一下 Node.js,当你执行一个操作时,这段代码可能会运行一秒钟,另一段代码可能会运行两秒钟。我们可以一次执行它们,这既不是并发也不是并行,或者我们可以同时执行它们。但问题是,一个会运行直到它不再需要 CPU,然后另一个会运行,接着第一个会在等待一秒钟后继续运行并完成,然后第二个会在等待两秒钟后继续运行并完成。这总共需要两秒钟。它是并发的,但不是并行的,它们不会同时执行。
并发进程是重叠的,并行进程是真正同时运行的。这就是为什么我特别指出至少有两行机器代码在运行的情况,因为你可以有并行性,而它们并不一定要运行相同的代码。它们可以运行不同的部分。
这些并不是同一回事,单核机器已经并发运行代码半个世纪了,但它们确实有关联。许多在线讨论忽略了我们经常将程序分解为并发部分,以便这些部分可以并行运行并以我们希望的方式交错,以保持 CPU 核心的工作。如果我们不关心性能,为什么还要费心去做呢?
那么,该如何实现并发呢?构建一个并发系统的最简单方法之一是将代码分成多个进程。毕竟,操作系统是一个精简高效的并发机器,与硬件协同工作,使每个进程都认为自己拥有整个计算机。操作系统的调度程序为我们免费提供了并行性,运行的任何进程都可以在可用的 CPU 核心上获得时间片。曾几何时,这是唯一的方式,我们今天仍然使用它,比如在 shell 命令间管道传递数据。
让我们谈谈 Gulp 构建工具。哦不,很多 JavaScript 开发者发现,将数据从一个任务传递到另一个任务比在每个步骤之间将它写入磁盘要高效得多。或者,在 Scala 中使用 Akka actors,使用不共享内存和不可变消息使得并发性变得更简单。
让我们从处理管道开始,Unix 系统已经使用了管道 40 年了。这确实很有趣,而且非常好笑,但这种方法也有其局限性。进程间通信并不便宜,因为大多数实现将数据复制到 OS 内存并返回。这就是为什么基于互斥锁的并发性会被认为有害。有人在面对问题时想出了一个办法:“我知道,我会用线程。”然后他们有了两个问题。
这是一种非常危险的游戏。简单的错误会让你遭遇竞争条件、死锁和其他可怕的疾病,这些疾病会让你的代码充满 bug,但只会在周二、下雨天、温度是三的倍数时发生。上帝保佑你,如果你想学习这些东西在现代硬件上是如何工作的。
还有另一种方式。1978 年,Tony Hoare 在他的论文《通信顺序进程》中建议使用队列或信道将线程连接起来,它们可以用来相互发送消息。这有很多优点。是不是我们正在慢慢变成 Go?还是 Erlang?对不起,是 Erlang。Erlang 就像编程界的《辛普森一家》,什么都做过。
线程享有与进程类似的隔离,因为它们不共享内存。对于使用内存安全语言的额外加分,这样的语言使得意外打乱另一个线程的内存变得困难。每个线程都有一组非常明显的输入(它接收的信道)和输出(它发送的信道)。这很容易理解,也很容易调试。实际上,调试这些问题非常简单。线程永远不会在有工作要做时休眠,如果它们的速度超过了系统的其余部分,它们会优雅地暂停。
经过数十年的互斥锁疯狂之后,许多现代语言都遵循 Hoare 的建议,并在其标准库中提供了信道。在 Rust 中,我们称之为 sync::mpsc。
大多数软件可以到此为止,用线程和信道构建并发系统,并结合工具来并行化 CPU 密集型循环,比如 Rust 的 Rayon 或 Haskell 的 Par。顺便提一句,提到 Haskell 了,这可真棒!每次提到 Haskell,似乎都会有 35 个个体互相击掌欢呼。
有些问题需要大量的并发性。Dan Kegel 在 1999 年描述的经典例子是 C10K 问题:一个与成千上万的并发用户连接的 web 服务器。在这个规模上,线程已经不够了。虽然线程相对便宜,但为每个连接启动一个线程,你的计算机会立即停滞。为了解决这个问题,一些语言提供了一种并发模型,其中任务是轻量级的,并在用户空间中管理(即无需操作系统的帮助),运行时将这些任务调度到一个操作系统线程池,通常每个 CPU 核心分配一个线程以最大化并行性。Rust 称之为 M:N 线程问题。
这个方案有很多名字:绿色线程、轻量级线程、轻量级进程、纤程、协程。你知道在 React 中,从 React 16 开始,他们也使用了纤程这个术语。作为一个最近一直在阅读 React 源代码的人,我可以告诉你,React 也有这个概念。
所有这些都存在,并且有无数的技术极客不断争论它们之间的微妙差别。Rust 通过 async/await 模型来解决这个问题,这种模型之前在 C# 和 Node.js 中已经出现过。在这种模型中,标记为 async 的函数不会阻塞你,而是立即返回一个 future(或者在 JavaScript 中称为 promise),你可以等待这个 future,以获得结果。
Rust 的 future 和 Node.js 的有些不同。在 Node.js 中,async 函数是贪婪的,它会立即运行,而在 Rust 中,除非调用 .await,否则它永远不会运行,这可能会带来一些问题。你可能不知道,我曾听过“冷”这个术语,用于描述这种懒加载的行为。Foo 返回一个 i32,async Foo 返回一个 future,我们可以等待这个 future 来获取结果。
在一方面,Rust 的 futures 非常小且快,这得益于它们的协作调度和无栈设计。但与其他具有用户空间并发的语言不同,Rust 试图在提供这一抽象的同时还承诺给程序员提供完全的低级别控制。这两者之间存在根本的矛盾,苦命的 Rust 异步程序员永远被困在中间,在语言设计目标和他们试图构建的大规模并发世界之间左右为难。
Rust 试图在编译时静态验证程序中每个对象和引用的生命周期,而 futures 承诺的是相反的:我们可以将代码及其引用的数据分解成成千上万的小片段,这些片段可以在任何时候基于我们只能在启动 future 后知道的条件,在任何线程上运行。一个从客户端读取数据的 future 只应在客户端的套接字有数据可读时运行,而生命周期注解无法告诉我们何时会发生这种情况。
这就是为什么 Send 这个问题会出现,保证编译器能确定一切都会顺利的尝试遇到了与使用原始线程类似的挑战。数据必须要么被标记为 Send,要么被移动,或者传递带有 'static 生命周期的引用。这两者都说起来容易做起来难,尤其是没有克隆的情况下移动数据通常是不可能的。
在异步代码中,通常会生成许多共享状态的任务,而引用又是一大麻烦。没有线程作用域的等效工具来帮助我们将 future 的生命周期限制在“永远”之外。我以为在 Mara 的书中有一些线程作用域的概念,可以允许某种版本的这种限制,但我可能错了。
与启动原始线程不同的是,你可能只需要在少数几个函数中处理这些麻烦,而由于异步的“病毒性”本质,你必须无处不在地解决这个问题。因为任何调用异步函数的函数本身也必须是异步的。正如我之前提到的,这就是泄漏抽象的问题。当你有一个东西使用了一些东西,使用它的东西也必须变成它本身,然后从头到尾全都变成这种方式,或者你创建一个可以在 Rust 中运行 future 的东西,而我仍然不太确定这到底是如何工作的。你需要无处不在地、一直解决这个问题——尽管有时候你可以把所有东西都打包成 Arc。
一个经验丰富的 Rust 开发者会回应说 Rust 给了我们一个简单的方法来处理跨多个线程的动态生命周期问题,我们称之为 Arc(原子引用计数)。虽然确实如此,它们解决了立即的问题,借用检查器和编译器也能完成任务,但它们远非银弹。广泛使用 Arc 会给你提供世界上最糟糕的垃圾收集器。像垃圾收集器一样,对象及其表示的资源(如内存、文件、套接字)的生命周期是无法知道的,但你只能接受这个损失而没有得到真正垃圾收集器带来的好处。
不要相信“垃圾收集器慢”的说法。垃圾收集器可以很快,你可以在编译语言中实现一个高效的垃圾收集器,比如 Go 的垃圾收集器就非常好。这个说法是对延迟与吞吐量的误解,最好的情况下是误解,最糟的情况下是一个离奇的心理战术。现代的移动垃圾收集器带来了更多的内存分配吞吐量、更少的碎片化,还避免了你为了避免循环泄漏而玩弄弱指针的把戏。
你甚至可以通过称其为“延迟销毁”来诱使系统程序员在世界上最重要的软件项目之一中使用垃圾收集器。Rust 的协程是无栈的,编译器将每一个协程转换为一个状态机,该状态机在遇到下一个 await 时前进。但这使得任何递归的异步函数成为递归定义的类型。
一个用户试图从一个函数中调用它自己时,会遇到无法理解的错误,直到他们手动将其装箱,或者使用一个 crate 来做同样的事情。我从未尝试过异步递归函数。
有一个重要的区别在于 future(在被 await 之前什么都不做)和 task(在运行时的线程池中生成工作,返回一个表示完成的 future)之间。两者都没有阻止你在 future 中调用阻塞代码,也没有阻止那个调用阻塞它所在的运行时线程——这正是我们试图通过异步避免的。
把这些混在一起,你就得到了异步 Rust,它与普通 Rust 有着截然不同的味道——有许多让人难以理解和教授的陷阱,迫使用户要么深入理解这些抽象究竟是如何工作的,编写复杂的代码来处理它们,要么在代码库中撒上 Arc、Pin、static、Unpin、Pin Project 等神秘符号,并祈祷一切顺利。
我就是这种人,我选择了第二个选项。我喜欢 Rust,但我也喜欢 Go,我也喜欢 TypeScript,我也不介意用 JavaScript 写 JS Doc,当我不得不做的时候,我也能用 Lua,甚至会使用 C++。我也很想学习 OCaml。
对于我来说,我喜欢在很多地方工作,但这也意味着我不能深入学习 Rust 异步到编译器级别。或许我可以花时间做到这一点,但这对于我来说并不现实。
Rust 支持者可能会说这些批评夸大其词,但我看到过整个团队的经验丰富的开发者在尝试使用 Rust 开发新项目时陷入这些细节之中。无论 Rust 教学的挑战是什么,异步增加了一个全新的难题。Rust 中的“异步痛苦”不仅仅是一个笑话,它是真实存在的。
Rust 异步编程就像重新学习指针和内存分配一样。Rust 自己的命令行工具是相当简单的,但一旦你进入 Rust 一些更深奥的部分,难度就会急剧增加。你不仅要学习概念,还要学习如何在 Rust 中表达这些概念,这种语言非常严格。
这些问题在其他语言中并不存在,比如 Haskell 或 Go 中,异步代码就是正常代码。你可能会说这不是一个公平的比较,因为这些语言通过一个庞大的运行时隐藏了阻塞代码和非阻塞代码之间的区别,并且生命周期问题通过垃圾收集器被抹去了。但这正是重点:它们在处理这类编程时是纯粹的胜利。
也许 Rust 并不是一个适合大规模并发用户空间软件的好工具。我们可以将它保留给那些不需要大量并发的 99% 的项目。我喜欢用 Rust 开发命令行工具。
我们可以通过共享内存来减少进程间通信的开销,但这会放弃多个进程的主要优势之一:操作系统将它们彼此隔离。Mara Bos 最近出版了一本非常出色的书,虽然它专门针对 Rust,但它很好地解释了任何语言中低级别并发的基础。如果你没有时间读完整本书,我尽我所能在几页纸中总结了它的内容。
当然,我在这里进行了简化,不是每个程序都可以表达为一个有向无环图。你会找到其他原语的好场合,比如使用原子标志来指示全局状态的变化。但 Hoare 的模型是一个很好的默认选择,我总是发现思考数据如何在系统中流动非常有帮助。
每个线程在 Linux 上有 4KB 的控制块,在线程之间切换需要进入操作系统调度程序。这个上下文切换比一个普通的函数调用要昂贵得多。
Rust 独特地没有为其 future 提供运行时,而是将其交给像 Tokyo 这样的库来处理。这对用户来说是件好事,Rust 的构建工具 Cargo 和生态系统为开发者提供了在独特环境中选择更合适的替代品的自由。
这确实是事实,尽管它也意味着开发 Tokyo 等库的人必须承担大部分并发性的责任。
你可以通过命令整个运行时在 future 完成时阻塞来打破链条,但你不应该广泛地这样做,因为它不可组合。如果一个函数在 future 上阻塞,而那个 future 调用了一个阻塞在另一个 future 上的函数,恭喜你,运行时会崩溃。
Amos Wagner,又名 Fast and Limes 的 Pin and Suffering 是一个很棒的讽刺性入门教程。
总结:虽然我在 Rust 异步编程中的冒险让我构建了几个我满意的程序,但我认为 Rust 异步编程确实存在许多挑战,特别是对于那些不想深入学习 Rust 的人来说。你需要权衡的是,你是否愿意花大量时间去掌握 Rust 异步编程的复杂性,还是选择更适合你需求的工具。
Rust 的运算符重载实际上是有意义的
https://www.youtube.com/watch?v=rXgSOfr6rbQ&t=153s
运算符是一个非常有趣的概念。一方面,它们可以大大改善我们的生活。另一方面,它们也可能使情况恶化。然而,Rust 使用一个非常可靠的概念来处理运算符及其重载,现在可能是时候多了解一些它们了。让我们深入了解什么是运算符、它们通常如何在编程中使用、Rust 如何处理它们以及为什么这个系统有意义。尽情享受吧!
你知道吗?Rust 语言中的运算符重载概念非常类似于 Python 的运算符重载,并且这个概念实际上非常合理。然而,究竟什么是运算符?什么是运算符重载?Rust 是如何实现运算符重载的?我们什么时候应该真正使用它呢?看起来我们得先了解一些基本概念,所以让我们直接进入正题。
运算符是我们赋予特定意义和规则的符号或字符。我们最常见的运算符可能是我们在数学中都熟悉的那些。例如,加号表示两个数相加,减号表示两个数相减,等等。运算符的一个重要特点是我们总是需要根据特定的上下文来理解它们。例如,两个整数相加的规则与两个向量相加的规则略有不同。当我们考虑乘法时,这种上下文的敏感性可能会变得更明显。乘法通常用点号(DOT)或叉号(X)来表示,这取决于我们所在的地区。这里我们先用点号来表示乘法。
如果我们在两个数字之间遇到一个点号,这意味着我们将第一个数字乘以第二个数字。但是,如果我们在两个向量之间遇到一个点号,规则就完全不同了。在这种情况下,我们讨论的是所谓的点积(Dot Product)。对于点积,第一个向量的每个分量与其对应分量相乘,然后将所有中间结果相加,最终得到的是一个标量(Scalar)而不是一个向量。
我们还可以在编程中找到运算符在不同上下文中具有不同含义的另一个例子。这里,两个数字相加的工作方式与我们在数学中习惯的相同,但两个字符串相加的结果是字符串的连接,生成一个包含原始两个字符串中所有字符的新字符串。
如果我们快速总结一下上述内容,可以得到以下几点:
- 运算符是字符或符号。
- 这些运算符可以得到特定的意义,并为其工作设定不同的规则。
- 同一个运算符在不同的上下文中可以有不同的意义和规则。
既然我们知道了什么是运算符,那么我们应该看一下它们在编程中的使用。如我们已经隐含地通过字符串示例所涉及的那样,大多数编程语言都有内置的运算符。这些语言通常至少具有基本数学运算符,而且大多数还可以在其他类型(如字符串)上使用这些运算符。将这些运算符集成到编程语言中实际上非常合理。如果我们要进行数学运算,为什么不让我们已经熟悉的概念可用呢?通过函数调用来完成基本数学运算也是可行的,但当我们阅读代码时,使用我们已经熟悉的运算符要比使用函数调用更容易识别。
一些语言更进一步,允许我们重载这些运算符。运算符重载是一个临时多态性的例子,它允许我们根据运算符的参数赋予它们新的意义。听起来很复杂,但实际上意思是这样的:我们可以将运算符的任何使用看作是函数调用。如果我们再次以加法为例,我们可以将 a + b 视为调用符号 + 的一种形式,这个符号对变量 a 进行操作,并将变量 b 作为其参数。如果该函数调用对整数类型的变量有效,那么该语言的创建者必须以某种方式实现了代码,使我们能够使用该运算符与整数类型的变量一起工作。如果一门语言支持运算符重载,则说明该语言的创建者还实现了概念,允许用户在自己的代码中使用这些概念,从而允许他们为特定类型实现特定运算符的逻辑。
不同语言在这方面的实现程度各不相同。例如,Scala 允许我们自由定义运算符,甚至创建自己的运算符,而其他一些语言则只允许重载一组预定义的运算符。C++ 例如,有一种特殊的语法来重载一组定义良好的符号作为运算符,这种语法甚至包含了 operator 作为关键字,以明确标记正在发生的事情。Python 是另一个例子,它使用基于协议的方法和特殊函数。该语言还包含了一组定义良好的可重载运算符,并期望使用两个前导和尾随下划线的特殊函数,每当解释器在运行时遇到运算符时,它会调用相应的特殊函数,该函数在底层实现了该操作。这个概念实际上与 Rust 的非常相似,所以现在可能是我们终于看看 Rust 是如何处理运算符重载的好时机。
Rust 中抽象的基石是特性(Traits),语言的许多功能都基于它们。无论是简单的泛化、方法和函数调用分发、标记特定行为、闭包等,Rust 中的运算符重载也是基于特性的。因为为什么要改变已经有效的系统呢?标准库在 std::ops 模块中提供了与运算符重载相关的广泛特性集,它们是允许我们作为开发者使特定运算符与自定义类型一起使用的用户接口。由于该系统基于特性,意味着我们不能随意将任何符号作为运算符。特性的列表以及因此可重载的运算符是有限的,但幸运的是,该列表涵盖了所有我们可能已经知道并习惯的重要运算符。
Rust 系统的一个伟大之处在于,通过将这一功能放在特性之后,它变得非常透明,哪个类型重载了哪个运算符更容易查找,而不是去查找它定义了哪些方法。在 Rust 中,我们可以重载像加号和减号这样的运算符,通常与乘法关联的星号,以及通常作为除法运算符的斜杠,还可以重载一些位操作运算符,比如 & 和 |。此外,我们还可以重载一些组合运算符,如加号或星号与等号一起使用的情况。总的来说,我们几乎可以重载在任何 Rust 代码中通常遇到的所有运算符,当我们处理基本类型和选择结构(如字符串)时,都是如此。
要重载一个运算符,我们需要为一个类型实现对应的特性,但我们不能随意为任何类型这样做,因为 Rust 的孤儿规则仍然适用。这些规则规定,我们只能为一个类型实现特性,如果特性或类型位于我们自己的 crate 中。因此,不能为外部 crate 的类型重载运算符。
现在让我们通过快速实现一个自定义结构体——一个 3D 向量(Vector 3D)来看看运算符重载的一个小例子。在这个例子中,向量由三个分量组成,分别指定了它在三维空间中的位置:一个 x 分量,一个 y 分量和一个 z 分量。当我们现在想要对自定义向量进行一些数学运算时,我们可以至少为它重载一些运算符。例如,我们可以通过为我们的向量实现 Add 特性来重载加号运算符。特性本身定义了一个关联类型,它标记了输出类型。对于我们的向量,输出应该是一个 Self,因为两个向量相加会产生一个新向量。然后实现 add 方法非常简单,我们只需要创建一个新向量,将 x 分量相加,然后是 y 分量,最后是 z 分量。
如果我们希望允许对我们的类型使用加法赋值运算符(+=),还可以为我们的向量实现 AddAssign 特性,这样 += 也成为我们可以使用的操作。这些特性还能做更多事情,因为其中一些具有关联类型,如果相关特性有一个关联类型,我们还可以修改操作的返回类型。还记得之前提到的点积例子吗?将两个向量相乘实际上会得到一个标量而不是另一个向量。我们可以通过为我们的向量实现 Mul 特性来建模这种情况,它的关联类型应该是某种数字,这里用 i64,然后在方法体内实现必要的数学运算,计算结果是一个单一的数字。
不过还有更多内容。大多数这些特性实际上都是泛型的,这意味着我们可以修改通常指向 self 类型的输入参数。例如,我们的向量也可以乘以一个标量。为了使其工作,我们需要再次为其实现 Mul 特性,但这次我们使用泛型类型参数 i64 实现 Mul,输出类型仍然是 Self,在方法体内实现必要的数学运算。
然而,有一个问题:所有运算符参数都是位置相关的,这意味着目前我们只实现了将 Vector3D 乘以 i64 的操作,即 Vector3D * i64 是可行的,但反过来却不行。如果我们想要允许 i64 * Vector3D 的操作,我们需要再次实现 Mul,这次我们使用 Vector3D 作为泛型类型参数来实现 Mul,输出类型是 Vector3D,函数体则是前一种运算的反过来的实现,因为这次 Self 是一个 i64,而参数是我们的向量。完成这一步后,我们就可以在乘法运算中自由交换变量的位置了。
我们现在还可以快速实现 MulAssign 特性,这样我们就可以使用组合运算符 *=,但这只是乘法的实现,或者说是这样吗?其实不完全是,因为对于向量来说,还有一种乘法类操作,即叉积(Cross Product),与点积不同,它会产生一个新向量。但如果我们现在考虑实现它,我们可能很快会意识到我们其实遇到了一个瓶颈。我们已经将 Mul 特性的实现分配给了点积,而 Rust 没有另一个通常与乘法关联的符号。那么现在该怎么办呢?
这个问题显示了 Rust 以及其他语言在运算符重载方面的一个限制,而这个限制其实是有意义的。阅读代码是一项困难的任务,取决于我们的经验,有时我们理解代码的速度更快,有时则更慢。最终,我们都习惯了一定的代码风格,比如如何命名变量、如何排列代码等。当然,我们也习惯了一些运算符及其特定的含义,因为我们大多数人在学校里花了不少时间学习数学等学科,并且希望在阅读和编写代码时经常遇到这些运算符。如果我们再看看我们点积的实现,我们应该诚实地问自己一个问题:阅读涉及我们 Vector3D 类型的代码以及使用我们重载的星号运算符的代码的人,真的能理解他们实际上看到的是点积的计算吗,而不必查看我们希望已经编写的运算符文档?如果我们诚实的话,答案可能是否定的。如果我们再次诚实并承认星号既不是点号也不是叉号(这两者通常用于数学中的点积和叉积),那么答案可能更是否定的。
在这种特殊情况下,可能更好地移除乘法运算的重载,并用一个具有合适名称的函数取而代之,比如 dot_product。叉积也是如此,我们应该给它一个自己的方法,并根据其功能命名为 cross_product。是的,运算符更容易使用,因为它只是一个符号,而不是一个函数调用,尽管在底层它仍然是一个函数调用。但一旦我们遇到像这个例子中的歧义,我们就应该避免仅仅为了重载而重载运算符。
另一方面,如果我们与之合作的所有人通常都同意将多个歧义运算符中的一个与特定操作关联,我们也可以直接去实现一个。总的来说,我们应该考虑可读性和上下文,仅为重载而重载运算符只会产生几乎不可读的代码,而这绝不应该是目标。然而,在适当的地方,运算符也可以提高代码的可读性。限制可重载运算符的范围也是 Rust 和其他语言的明智选择。试想一下,在代码中左右出现未知符号,这会增加可读性吗?可能不会,因此有时在某些事情上设置边界是明智的,以防止人们伤害他人,包括他们自己,这正是 Rust 所做的事情。
总体来说,Rust 的系统确实很有意义。一方面,它防止了未知符号作为运算符在代码中的不受控出现,另一方面,通过使用特性,它创造了透明度,这是 Rust 中的一个通用概念,因此 Rust 开发者不需要学习新东西。也许有更好的概念,但 Rust 也可以做出更糟糕的选择,所以我认为这已经是一个胜利了。不过,这个视频到此为止,希望你能享受新获得的知识,下次视频再见。
Rust 最重要的容器 📦 10 个有用的模式
https://www.youtube.com/watch?v=f82wn-1DPas&t=12s
关于 Rust 的 Option 和 Result 容器的 10 个有用模式的详细讲解和说明。
Summary
本视频介绍了 Rust 中 Option 和 Result 容器的使用模式。Option 用于处理可能没有值的情况,而 Result 用于处理可能出错的情况。视频通过示例代码演示了如何使用这两种容器来处理字符串到整数的转换、错误处理以及如何在代码中选择适当的错误处理策略。
Highlights
- 📦 Option 类型:
Option是 Rust 中用来处理可能无值的情况的枚举类型,包含None和Some(T)两种值。 - ✔️ Result 类型:
Result用于明确表示操作的成功与失败,包含Ok(T)和Err(E)两种值。 - 🔄 unwrap 和 unwrap_or:
unwrap用于直接获取Result的值,但若失败则会导致程序崩溃;unwrap_or则允许提供一个默认值以避免崩溃。 - 🔄 Option 和 Result 转换: 使用
ok()函数将Result转换为Option,而ok_or和map_err用于将Option转换为Result,以便处理不同的错误类型。
Keywords
- Rust
- 错误处理
- 编程模式
理解 Option 和 Result 的概念可能相对简单,但在实践中使用它们可能会有些棘手。让我们来看一个非常简单的例子,代码不会超过几行,我们将通过这个例子来了解这些容器的常见和有用的用法。
Option 是 Rust 对臭名昭著的“十亿美元错误”的解决方案。我并不是在谈论某个具体的错误,而是指在没有适当的编译时检查的情况下,处理可能为空的值的概念。这个术语来自 Tony 本人。当然,Option 并非 Rust 独有,现在几乎每种编程语言都有某种形式的 Option。它的主要目的是明确表示某些东西可能没有值。
在 Rust 中,Option 是一个枚举类型 T 的泛型,可以有两个值之一:None 或 Some。在 Rust 中,每个枚举值都是一个结构体。在这种情况下,None 恰好是一个单元结构体,它是一个没有字段的结构体,而 Some 恰好是一个元组结构体,它有一个类型为 T 的字段,但这个字段没有名称。
接下来让我们看看 Result。你会发现 Result 与 Option 非常相似,但它有两个泛型类型,而不是一个,并且它没有 None 值,而是有一个类型为 E 的 Error 值。所以我们有两个元组结构体:Ok,它有一个类型为 T 的值;Error,它有一个类型为 E 的值。Result 是 Rust 明确定义成功与错误状态的一种方式,并使开发人员更容易在必要时将错误正确地向上传递。许多成熟的编程语言使用异常的概念来完成这个任务,但 Rust 和其他一些新兴语言使用一个特殊的容器来返回值,这就是 Result。它有一个用于成功的容器,叫做 Ok,还有一个用于错误的容器,叫做 Error。
我提到 Option 和 Result 的核心概念相对容易理解,但在实践中处理和发出它们的方式有很多,甚至可以在它们之间进行转换。让我们来看看一些最常见的模式。
我们要编写的函数将接受一个字符串向量,尝试将每个字符串解析为整数,计算所有这些整数的和,然后将结果转换回字符串并返回给调用者。这个函数将被称为 sum_strvec,它将接受一个字符串向量并返回一个字符串。
首先,我们将创建一个类型为 i32 的累加器,这将用于存储数字的累积和。然后我们将编写一个 for each 循环。我们可以使用 map 函数以函数式的方式编写它,但我们将使用 for each 循环来说明一些概念。循环内部,我们将调用 to_int 函数,这是我们将要编写的一个函数,它会将字符串转换为整数并返回它。然后我们将该整数添加到我们的累加器中。在 for 循环之后的函数末尾,我们只需将该整数转换回字符串并返回给调用者。
我们编写一个快速的 main 函数来测试这个函数,然后我们再回去实现 to_int 函数。
在 main 函数中,我们将创建一个向量,其中包含两个字符串,一个是 “3”,另一个是 “4”,然后我们将该向量传递给 sum_strvec,得到总和并输出结果。现在我们只需要实现 to_int 函数。
to_int 函数将接受一个对字符串切片的引用并返回一个整数。我们在这个函数中所需要做的就是使用 s.parse(),然后调用 unwrap,就可以完成了。在现实世界中,我们可能不会为此编写一个独立的函数,但我们将使用它来说明一个概念。s.parse() 实际上返回一个 Result。如果我们查看 s.parse() 的定义,可以看到它返回一个 Result。Result 有一个叫做 unwrap 的函数,我们在这里使用它,它其实非常简单。如果 Result 是 Ok 变体,unwrap 将返回 Ok 中包含的值;如果 Result 是 Error 变体,程序将会崩溃。因此,当你调用 unwrap 时,你基本上是在同意,如果 Result 中存在错误结果,那么程序将会崩溃。
在原型设计和快速构建时,这可能是非常有用的,因为你只是在测试一些正常的路径,但一般来说,你可能不希望在生产代码中使用 unwrap。调用 unwrap 时,我基本上是在推迟任何错误处理,并且表示如果 Result 是错误变体,我同意让我的程序崩溃。好,让我们测试一下这个函数。
我们得到了预期的结果 “7”。现在我们在 main 函数中添加一些代码来测试向量中的某个元素不是数字的情况。
现在我们创建了另一个向量,其中包含字符串 “3” 和另一个显然不是数字的字符串 “ABC”。我们首先需要问自己的是,当向量中的某些数字是可解析的,而其他的不是时,我们希望程序如何处理这种情况。我们想让 sum_strvec 返回一个错误还是返回 None?或者我们想让它给我们返回可解析数字的总和,并假设不可解析的元素为零?按照当前的写法,如果向量中的任何元素不可解析,程序将完全崩溃,这可能不是我们想要的。让我们再次运行这个程序。
我们可以看到我们得到了 “7”,然后程序崩溃了。我们试图调用 s.parse() 来解析字符串 “ABC”,这返回了一个错误,由于 Result 是错误变体,unwrap 导致程序崩溃。因此我们可以在这里看到:“线程主程序在调用 Result.unwrap 时发生崩溃”。
Result 还有另一个叫做 expect 的函数,expect 类似于 unwrap,如果 Result 是 Ok 变体,它将返回 Ok 中包含的值;如果 Result 是错误变体,它仍然会崩溃,但你可以指定一个自定义的错误消息。
现在我们不再调用 unwrap,而是调用 expect,并传入一个带有自定义错误消息的字符串。让我们运行这个程序。
好的,所以我们得到了 “7” 作为第一个测试的结果,然后我们仍然得到崩溃,但这次我们得到了一个稍微有帮助的错误消息,比 unwrap 稍微好一些,但可能仍然不是我们想要的结果。现在假设我们希望程序将任何不可解析的元素解释为零,这实际上可以使用 Result 的一个叫做 unwrap_or 的函数来实现。我们不再调用 expect,而是调用 unwrap_or 并传入零。
unwrap_or 类似于 unwrap,如果 Result 是 Ok 变体,它将返回 Ok 中包含的值,但如果 Result 是错误变体,它将返回你传递给 unwrap_or 的值,而不是让程序崩溃。在大多数情况下,这是更好的选择。所以在这种情况下,当我们传入 “ABC” 时,解析失败,s.parse() 返回错误变体,然后 unwrap_or 返回我们传递给它的值,即零。让我们运行这个程序。
好的,这次正常工作了。我们得到 “7” 作为第一个结果,一如既往,第二个结果是 “3”,因为第一个元素是 “3”,然后 “ABC” 是第二个元素,被解释为零,所以总和是 “3”。调用 unwrap_or 并传入零在假设不可解析的数字为零时工作得很好,但如果我们不希望 to_int 假设任何内容,而是让 to_int 的调用者决定在数字不可解析时应采取何种行为呢?在这种情况下,我们可能希望 to_int 返回一个 Option<i32> 而不是 i32。所以如果 s.parse() 返回一个错误变体,我们希望返回 None,但如果 s.parse() 返回 Ok 变体,我们希望返回包含 Ok 值的 Some 变体。
希望这听起来有道理。虽然这听起来有点复杂,但我们基本上是想从 Result 转换为 Option。实际上,Result 和 Option 都有一些函数可以非常容易地在它们之间进行转换。在这种情况下,我们得到了一个 Result,并希望将其转换为 Option,所以我们实际上可以直接调用 ok 函数。
然后我们将函数的返回值更改为 Option<i32>。ok 的作用正是我刚才描述的:如果 Result 是 Ok 变体,它返回包含 Ok 值的 Some;如果是错误变体,它返回 None,因此它会丢弃实际的错误值。如果我们希望函数返回 Option,但它调用的一些函数可能返回 Result,我们可以使用这段非常简单的代码将 Result 转换为 Option,我们不需要编写任何 if let 语句或 match 子句,只需使用 ok。
当然,现在我们需要在 sum_strvec 中处理这个 Option。现在我们要编写一个 match 子句,它有两个分支:在 Some 的情况下,我们将返回 Some 中包含的值;在 None 的情况下,我们将返回零。match 语句的结果将被添加到累加器中。如果 to_int 返回 Some,我们将提取 Some 中的值并将其添加到累加器中;如果 to_int 返回 None,我们只会将零添加到累加器中,因此它实际上是一个无操作。
让我们运行这个程序,确保它能正常工作。是的,我们得到了 “7” 和 “3”。我们得到了与上次相同的结果,但这次我们使用了不同的方法来实现。我们不再让 to_int 假设不可解析的数字为零,而是让 sum_strvec 决定在数字不可解析时的行为。为此,我们必须返回一个 Option<i32>:如果数字是可解析的,我们返回解析后的数字的 Some,如果数字不可解析,我们返回 None。
但由于我们只需要在 to_int 返回 Some 变体时将值添加到累加器中,所以实际上有一种更简单的方法来编写这段代码,而不是使用 match 子句。我们可以使用 if let,它看起来是这样的:
这个 if 语句提取了 Some 中的值。这个 if 语句的条件表示,如果 to_int 返回的值是 Some 变体,则提取 Some 中的值,然后在 if 语句块中将该值添加到累加器中。如果 to_int 返回 None,我们什么都不做。所以这比编写包含 Some 和 None 两个分支的 match 子句要简单一些。
让我们运行这个程序,确保它能正常工作。我们应该得到相同的结果。是的,我们得到了相同的结果。但是实际上还有一种更简单的方法来编写这段代码。我们之前看到 Result 有一个 unwrap_or 函数,实际上 Option 也有一个 unwrap_or 函数。所以代替 if let Some(val),我们可以直接写 accumulator += to_int.unwrap_or(0)。
这与我们之前在 to_int 函数中做的是同样的事情,但那是 Result 的 unwrap_or 函数,而不是 Option 的。Option 的 unwrap_or 函数与 Result 的 unwrap_or 函数工作方式非常相似:如果 Option 是 Some 变体,它将返回 Some 中存储的值;如果 Option 是 None 变体,它将返回你传递给 unwrap_or 的值,在这个例子中是零。
假设我们不再希望假设不可解析的数字为零,而是希望在一个或多个数字不可解析时直接失败。在这种情况下,我们可能希望 sum_strvec 也返回一个 Option。所以让我们将 sum_strvec 的返回值更改为 Option<String>,然后我们不再直接返回 accumulator.to_string(),而是返回 Some(accumulator.to_string())。如果我们省略行末的分号,实际上不需要 return 关键字。
现在 sum_strvec 也返回一个 Option,当 to_int 返回 None 时,我们希望 sum_strvec 也返回 None,因为它发现其中一个数字不可解析。我们可以编写一个 if let 子句或一个 match 子句来实现这一点,但是在 Rust 中有一个非常方便的简写方法,我们可以使用问号操作符来将 None 传播到上层调用栈。
在 to_int 的调用中,我们可以用问号操作符替换 unwrap_or。这就是我们需要做的全部。如果 to_int 对所有向量中的元素都返回 Some,我们将退出循环并返回一个包含结果字符串的 Some 值。如果 to_int 对任何一个值返回 None,问号操作符将导致 sum_strvec 返回一个 None 值。由于问号操作符在这里作用于 Option,而 None 变体没有类型,这些 Option 实际上不需要是相同类型。因此,即使一个是 Option<i32>,另一个是 Option<String>,我们仍然可以在 sum_strvec 中使用问号操作符,因为它只在 to_int 返回 None 时才会生效,而 None 实际上没有类型。
Result 也有一个类似的问号操作符,我们稍后会讨论,但它没有这种便利。让我们运行这个程序,确保它能正常工作。这次当我们运行它时,对于第二个测试,我们应该不会得到一个数值,而是得到 None。
我们得到了预期的结果:第一个测试返回了包含字符串 “7” 的 Some,第二个测试返回了 None。这正是我们所期望的。让 sum_strvec 返回 Option 的缺点在于,如果出现问题,它将返回 None,这并不能向调用者传达出什么出了问题。解决方案是让 sum_strvec 返回 Result 而不是 Option。
Result 需要两个类型,一个用于成功的情况,一个用于错误的情况。对于错误情况,我们将创建一个自定义错误结构体。
我们将其称为 SummationError,这只是一个通用的表示“在求和过程中出错”的错误。
对于 Result 的第二个类型,我们将使用 SummationError。在函数的最后一行,我们不再返回 Some(accumulator.to_string()),而是返回 Ok(accumulator.to_string()),因为 Ok 是 Result 的成功变体。
现在,to_int 返回一个 Option,如果 Option 是 None,我们希望返回一个错误 Result,那么我们该怎么做呢?显然,问号操作符不再适用了,因为问号操作符在 Option 是 None 时返回 None。我们需要以某种方式将 Option 的 None 变体转换为 Result 的错误变体。有一个非常简单的方法可以做到这一点,那就是使用 ok_or 函数。
我们之前看到了 ok 函数,它是一个将 Result 转换为 Option 的函数,而 s.parse() 返回的是 Result。ok_or 类似于 ok 函数,它在 Option 和 Result 之间进行转换。在这种情况下,我们需要调用 ok_or 而不是 ok,因为如果 to_int 返回 None,None 没有类型。而在 to_int 返回 Some 变体的情况下,ok_or 会将其转换为具有相同值的 Result 的 Ok 变体。如果 to_int 返回 None 变体,ok_or 会返回我们传入的错误类型值。由于 Result 也有一个类似于 Option 的问号操作符,ok_or 返回一个 Result,然后问号操作符会在 Result 是错误变体时将其向上传递到调用栈上。因此我们将立即返回这个错误变体。
让我们运行这个程序。现在我们不再有 Some 和 None,而是有 Ok(7),这是第一个测试的结果,然后是类型为 SummationError 的错误,这是第二个测试的结果。这正是我们想要的结果。
现在 sum_strvec 返回一个 Result,to_int 返回了一个 Option,但 s.parse() 最初返回的是 Result。这似乎有点绕远了。如果我们想将 s.parse() 的任何结果都传递到调用栈的最上层该怎么办?为此,我们可以直接将 to_int 的返回类型更改为 Result<i32, ParseIntError>,其中 s.parse() 返回一个 ParseIntError,我们将其作为 Result 的第二个类型,并导入这个错误类型。这样我们就不再需要转换为 Option 的 ok 函数了,我们可以直接返回 s.parse() 返回的 Result。
即使 to_int 和 sum_strvec 现在都返回 Result,它们的错误类型不同,所以我们不能直接从 to_int 返回的 Result 一直向上传递到调用栈的最上层。因此我们不再需要 ok_or,因为我们不再从 Option 转换。我们也不能再使用问号操作
好的,我们继续探讨 Rust 中的 Result 和错误处理。
我们已经看到,to_int 函数可以直接返回 Result<i32, ParseIntError>,而 sum_strvec 返回 Result<String, SummationError>。由于这两个函数返回的 Result 类型的错误类型不同,我们不能直接使用问号操作符将 to_int 返回的 Result 向上传递到 sum_strvec 中。这是因为问号操作符只能在返回相同错误类型的情况下工作,但这也是 Rust 错误处理的强大之处:它可以让你在多个错误类型之间进行转换。
使用 map_err 进行错误类型转换
为了将 to_int 返回的 Result 错误转换为 sum_strvec 返回的 Result 错误,我们可以使用 map_err 方法。map_err 方法允许我们将 Result 的错误类型从一个类型转换为另一个类型,而不改变 Ok 的值。
让我们来看看如何使用 map_err 将 ParseIntError 转换为 SummationError:
1 | fn to_int(s: &str) -> Result<i32, SummationError> { |
在 to_int 函数中,我们使用 map_err 将 s.parse::<i32>() 返回的 Err(ParseIntError) 转换为 Err(SummationError)。这样,我们就可以在 sum_strvec 中使用问号操作符将 to_int 返回的 Result 向上传递。
测试和运行代码
让我们测试一下这个代码的运行情况:
1 | fn main() { |
运行这个代码,我们可以预期第一个 vec1 测试将成功并打印 Sum: 7,而第二个 vec2 测试应该会失败并打印 Error during summation。
提升错误的表达力
目前,我们的 SummationError 结构体非常简单,只是一个表示“在求和过程中出错”的通用错误类型。我们可以通过让 SummationError 包含更多的信息来提升错误的表达力,例如错误消息或错误代码。
我们可以修改 SummationError 结构体,使其包含一个字段来存储错误消息:
1 |
|
我们可以通过这种方式创建更多有意义的错误消息:
1 | fn to_int(s: &str) -> Result<i32, SummationError> { |
现在,to_int 将为不可解析的字符串返回更加详细的错误消息。
处理多个不同的错误类型
在实际项目中,我们可能会处理多个不同的错误类型。Rust 生态系统中有一些库,例如 thiserror 和 anyhow,可以帮助我们更好地处理和组合多个错误类型。
例如,使用 thiserror,我们可以为 SummationError 定义不同的错误类型:
1 | use thiserror::Error; |
使用这种方式,我们可以将错误类型区分得更加清晰,也可以更好地处理不同类型的错误。
总结
我们已经探讨了 Option 和 Result 在 Rust 中的使用,并讨论了如何在函数之间传递错误,以及如何提升错误处理的表达力。Rust 的错误处理系统非常强大,但也可能需要一些时间来习惯。通过明确地处理错误,Rust 使得代码更加健壮和可维护。
希望这些示例能够帮助你更好地理解 Rust 中的错误处理机制。如果你有更多问题或需要进一步的示例,请随时提问!
Rust 的外来数据类型 👽 Box、Rc、Arc
https://www.youtube.com/watch?v=CTTiaOo4cbY
Rust 的智能指针可能会让来自垃圾收集语言的开发人员感到有些困惑。让我们通过一些非常简单的示例来了解何时以及如何使用最常见的示例
Rust中的智能指针非常简单。你可以想象成有三种:一种是猫喜欢玩的,一种可以用来控制玩具车,还有一种可以用来在大洪水中生存。然后,还有一种,我实在太害怕了,没法拿它开玩笑。不过,认真说,要成为一个高效的Rust开发者,你需要理解不同类型的智能指针。它们可能有点棘手,但如果我们保持例子的简单性,我们可以很快覆盖核心概念。
我们在这个视频中讨论的所有智能指针都有一个共同点,那就是它们规定数据应该存储在堆上而不是栈上。稍后我会详细解释这一点。
我们要讨论的第一个智能指针是Box。Box是Rust中最简单的智能指针。它的唯一目的就是规定被封装的数据需要存储在堆上而不是栈上。除此之外,它的功能几乎和借用引用差不多。让我们来看两个最常见的使用Box的场景。
假设我们有一个名为Vehicle的trait,它有一个方法叫做drive,该方法接受一个对self的借用引用。然后我们有一个名为Truck的结构体,它将实现Vehicle。我们将只实现drive方法,这个方法将打印出“Truck is driving”。这是一个非常简单的设置。那么,如果我们想创建一个类型为Vehicle的变量,并且暂时不初始化它,怎么做呢?在第14行,我们会写let t,然后在第二行,我们给t赋值,所以t = Truck,接着我们调用t.drive,来看一下会发生什么。
好吧,我们遇到了一些错误。第一个错误是”trait objects must include the dyn keyword”(trait对象必须包含dyn关键字)。这个问题超出了本视频的范围,但在本视频中,每当我们引用一个trait时,都需要在前面加上dyn以修复这个错误。那么还有什么问题呢?另一个错误是”the size for values of type dyn Vehicle cannot be known at compilation time”(在编译时无法确定dyn Vehicle类型的值的大小),因为在理想的世界中,你会把所有东西都放在栈上,因为栈的访问时间比堆快得多。问题是,为了将某些东西存储在栈上,Rust编译器需要在编译时知道该内存的大小,而在这种情况下,任何实现Vehicle的东西都可能有不同的大小,所以编译器无法知道或计算栈中所需内存的确切大小。唯一的解决方案就是将其存储在堆上,尽管不幸的是,堆的访问时间要慢得多。所以如果不需要,你不会想把东西存储在堆上,但在这种情况下我们确实需要这么做,而我们可以通过使用Box来实现。
我们将Vehicle类型封装在一个Box中,然后在第15行调用Box::new,传入我们想要创建的新Truck。好,来看一下这是否运行正常。
“Truck is driving”,好,成功了。这是使用Box的两种用例之一。另一个使用Box的场景是递归类型。如果我们想表示一列卡车,比如在红绿灯前等待的卡车队列,我们可以添加一个字段next_truck,它是可选的,因为列表必须在某个点结束。所以我们定义了Option<Truck>。
删除main中的内容。
好,我们遇到了一个错误:“recursive without indirection”(递归类型没有间接性)。它提示我们插入一些间接性,比如Box、Rc或&。这就是Box的另一个用例。我们只需要将Truck封装在一个Box中,这样就解决了错误。
回顾一下,使用Box的两个主要场景是:第一,当我们有一个编译时无法计算大小的trait类型变量时,我们可以使用Box;第二个用例是递归数据类型,比如如果我们有一个结构体,其中一个字段是该结构体自身。
使用Box和普通的Rust变量时,该变量引用的内存将在其超出作用域时自动清理。如果你有一个情况,你想要多个引用指向同一块内存,但你不确定这些引用会在何时超出作用域,并且你希望内存保留到最后一个引用超出作用域时,这时Rc(引用计数)就派上用场了。Rc会计算指向同一块内存的引用数量,并在最后一个引用超出作用域时清理该内存。
一个很好的例子是,如果我们有一些结构体,并且希望该结构体的引用存储在多个数据结构中,但我们不确定哪个数据结构会先超出作用域,或者该数据结构会如何处理。但我们知道,如果没有引用保留,我们希望内存被释放。我们来看一个例子。
回到Truck结构体,我们删除next_truck字段,添加一个名为capacity的字段,表示这辆卡车的载重,然后在main中,我们创建三种不同类型的卡车,代表不同的尺寸:小型卡车、中型卡车和大型卡车。
我们有了这三辆不同容量的卡车,现在我们想要创建两个货运设施,每个设施由该设施中可用卡车的列表表示。这是一个完全人为的例子,但我们继续吧。
所以我们有facility1,它的卡车列表包含TruckA和TruckB,另一个是facility2,包含TruckB和TruckC。
我们已经遇到了一个错误,因为TruckB被引用了两次。第一次引用TruckB时,它的所有权转移到了这个向量(Vector),所以该向量现在拥有了TruckB的所有权,然后我们试图再次引用它来创建第二个向量。你可以通过使用常规借用来解决这个问题,假设你只需要对其进行只读访问。但这种方法的问题在于主函数必须保持对TruckB的所有权,即使我们很早就不再需要TruckB了,它也会在主函数结束时被释放。
我们可以通过使用Rc解决这个问题,将每辆卡车封装在Rc中。
在将这些卡车转换为Rc后,我们仍然有所有权问题,但我们可以克隆这些Rc而不用担心底层数据也被克隆。所以如果我在这里调用Rc::clone,传递一个对我想要克隆的Rc的引用,第一个向量的第二个元素与第二个向量的第一个元素指向相同的内存。所以我们没有实际复制底层结构,而是有两个指向同一事物的指针。
这种设置是好的,因为主函数不再拥有TruckB,我们有两个向量,我们不确定这些向量会发生什么,它们可能会被释放,可能会被修改,但我们可以确信一旦所有包含TruckB的向量被释放,或者所有的TruckB从所有向量中移除,无论出于什么原因,TruckB的内存都会被释放。所以从这个角度来看,这非常节省内存,主函数不需要保持对TruckB的所有权。
我们打印这些向量,看看我们得到了什么。
我们需要实现Debug。
我们得到了预期的结果,facility1有TruckA和TruckB,facility2有TruckB和TruckC。所以我们将TruckB的克隆传递给了facility1,并将实际的TruckB的Rc所有权传递给了facility2。因为我们使用了Rc,所以无论我们先释放哪一个都没关系。我们可以先释放facility2,然后使用mem::drop来实现。mem::drop的作用和让变量超出作用域一样,我在这里使用mem::drop只是为了更清楚地展示。
我们将原始的TruckB的Rc传递给了facility2,并释放了facility2,所以我们想确认facility1仍然引用TruckB,确保这是正确的。
我们看到facility2在释放facility1后仍然有效地引用了TruckB。原因是Rc的引用计数没有归零,所以内存没有被释放。
当处理Rc时,一个有用的函数是strong_count,它会给出当前Rc的引用数量。为了展示这个功能,我需要在主函数中保持对TruckB的引用。我将为所有这些卡车添加Rc::clone。
因为我已经为这些卡车调用了Rc::clone,所以主函数仍然保留了所有卡车的所有权,我可以引用这些卡车来调用strong_count函数。
我们来看一下释放前后的引用计数。
释放前的计数是3,这是因为为什么是3而不是2呢?嗯,主函数有一个对TruckB的引用,这是一,然后facility1有一个引用,这是二,然后facility2有一个引用,这是三,所以引用计数是3。释放后,facility2被移除,唯一的引用来自主函数和facility1。再次强调,我们可以首先删除facility1并保留facility2,释放这些引用的顺序无关紧要,Rc会在需要时保留内存。
现在假设我们创建了这些智能指针TruckA、TruckB和TruckC,并希望将它们交给另一个线程处理。我们调用thread::spawn,实际上在一个线程中构造facility1和facility2,然后在该线程上调用join,以便获取facility1和facility2的结果,然后像之前一样继续打印。
我们试图将这些智能指针传递给另一个线程,结果遇到一个错误。错误提示:Rc<Truck>不能安全地在线程之间传递,Rc<Truck>没有实现Send trait。Send trait由任何可以安全发送到其他线程的数据结构实现,而Rc没有实现Send,但有另一种智能指针叫做Arc实现了Send,Arc代表原子引用计数。
为什么不直接使用Arc呢?Arc有一些额外的开销,它并不使用锁或互斥量之类的东西,而是使用了一种叫做原子数据类型的东西,编译器知道如何排序这些数据类型,以确保它们是线程安全的。其工作原理的细节有点难以捉摸,非常复杂,所以我在这里不会解释,但你需要知道的是,它并不使用锁,但原子数据类型永远不如非原子等效类型高效,因为在非原子情况下,不需要线程安全性让编译器可以进行更多的优化。所以你不会想在任何场合下都使用Arc,你只会在真正需要它的时候使用它。
让我们试着将所有Rc转换为Arc,看看会发生什么。导入Arc,然后将所有Rc换成Arc。
看起来错误消失了。我们看到Arc确实允许我们将引用传递给另一个线程,但我们在TruckB上有另一个错误,因为我们实际上将TruckB的所有权交给了线程。如果我们想再次获取TruckB,可以从facility2或facility1中克隆出来。让我们试试看。
现在我们又得到了TruckB。运行代码确保它能正常工作。和之前的例子类似,区别是facility1和facility2的向量是在一个单独的线程中构造的,然后我们在主函数中调用join来获取结果,并像之前一样打印输出。
看起来工作正常,其他部分和上一个例子基本相同。这就是原子引用计数智能指针,因为我太害怕了,没法拿它开玩笑。我在这个视频中无法覆盖RefCell,但如果你想看关于RefCell的视频,请告诉我。我希望这能帮助你更好地理解Rust的智能指针。感谢观看,我们下次见。
如何读取复杂的 Rust 类型
https://www.youtube.com/watch?v=uh9i3be2wIE
欢迎回到 Let’s Code。我是 Chris Biscard,今天我们要讨论一下 James 的这条评论。他问道如何阅读他们在 warp crate 中发现的这个类型。他们说这是他们从 warp crate 文档中随便找的一个例子。他们一直在尝试学习一个 Rust 框架,目前为止,axum 看起来是最直观的,但每当他们开始深入某些 crate 的文档时,就会出现类似上面的内容,而这些内容看起来非常吓人,这一点我完全同意。这确实是一个非常令人望而生畏的类型。James 希望能有一个视频来分解这种函数,并讲解如何理解其中的内容。
所以我要做的第一件事就是去 warp 文档中找到这个类型。因为我注意到的第一件事是,这个类型有一个 self 参数。这意味着这是一个在某个结构体的 impl 块中定义的函数。这会帮助我们更好地理解这个函数在做什么。所以我所做的就是直接访问 docs.rs,然后进入 docs.rs/warp,搜索 recover,这是函数名称。看起来 warp::filter::recover 就是我们给出的函数签名。那么既然找到了我们要找的类型,我们就先简单了解一下 warp 的基本情况。
warp 文档中说这是一个超级容易组合的 Web 服务器框架,用来实现 Warp 速度。所以它可能是很快的。warp 的基本构建块是 Filter。立即可以看到,recover 是一个定义在 Filter 类型上的函数。所以这个 recover 函数与 warp 期望你使用的核心基元有关。Filter 可以组合在一起,表达对请求的丰富要求。这在我看来解释了为什么这个类型如此复杂,为什么看起来这么吓人?因为它是为了高度泛型设计的,旨在作为这个非常可重用的 HTTP 库的核心部分以最通用和最可重用的方式使用。
Filter 处理很多事情,比如路径路由、头信息、查询字符串、多部分表单数据、WebSocket、访问日志、压缩等等,然后调用 hyper 完成其他一堆事情。大致就是这样。如果我真的要使用 warp,接下来我可能会查看一些示例。我个人不使用 warp,我使用 axum。所以我们将继续解析这个类型,并尝试阅读它的内容。从使用的角度来说,你基本上可以忽略所有这些内容。因为 recover 是 Filter 类型上的一个函数,我猜它们会被放置在这里。所以 path 宏在这里吗?是的。让我们打开 path 宏。我只是使用 command + F 搜索 path,完全猜测它会在侧边栏这里找到。这是一个方便的方式来链接多个路径过滤器。一个 warp::path,一些字符串作为路径,还有一些参数作为路径的参数。然后我们在其上进行映射,得到这两个 a 和 b,它们是 u32 作为参数,然后我们可以返回一些字符串。所以如果不使用 path 宏,等效的过滤器链看起来像 warp::path 和 warp::path::param,再加上 warp::path::end,然后我们在其上进行映射。那么 warp::path 给我们返回了什么?我猜它返回了一个 Filter。所以我认为这是宏可能在根部,然后我们有 warp::filters::path。是的,所以如果我们查看 path,无论它在哪里,它就在这里。path 返回 Exact,Exact 是一个 Filter。所以 Exact 实现了 Filter。所以我们应该会在这里看到,然后我们可能还会有一个 end 在这里。我们有 map,这在 Filter 上就像我们之前看到的那样。所以 dot map,接受一个闭包,然后返回一些内容,我们这里有 end,在示例代码中,我不确定那需要什么。无论 f 是什么,我们要看另一个例子,而不是深入这个例子。所以这就是这个想法。这个例子我知道我们刚刚看过,所以我要右键单击稍后再看。
现在我们回到了我们的类型。我们已经做了一些上下文设置。这些是你如果要使用这个库可能已经见过的内容。你可能已经查看了示例,可能已经看了 warp 的文档,比如“我该如何使用它?”这将我们带回到 recover。所以 recover 是 Filter 上的一个函数。如果我们看一下这些其他函数的用法,我们可以说,在我们执行像 warp::path::end 这样的操作之后,我们可以调用 .recover。仅从其他函数的上下文知识来看,.map 接受一个函数或闭包,我们暂时称它们为函数。如果我们看 recover,字面上理解 recover 的话,是 Filter trait 上的 recover 函数接受一个泛型参数 f。所以 f 只是某个东西。在这种情况下,我们使用 f,因为我们… 看起来我们在尝试调用它为一个函数,但我们还不确定。我们只知道 f 是我们传入的某个类型参数。所以 self 将是 Filter。我一直双击列表将其打开和关闭。所以 recover,我们传入一些稍后确定的 f 类型。self 将是 Filter,具体来说,或者是实现 Filter 的类型。因为记住,如果我们去看源代码并且熟悉 traits 的实现方式。这里如果我们向上滚动,我们正在定义具体的 Filter trait。所以这些都只是函数签名,而某些东西必须实现这些函数。所以在我们之前看的那个例子中,Exact 是实现 Filter 的具体类型,所以这实现了,例如,你会那样做通过 Filter 来实现 Exact,我敢打赌,如果我们查看这里的源代码,我们会看到。所以 struct Exact,FilterBase 为 Exact 实现。所以我不确定 FilterBase 是什么,但对我们的目的来说无关紧要。基本上,知道 Filter 是一个 trait,我们必须为某些具体类型实现 recover,我们可以在调用这个函数时传入该类型。所以无论那个类型是什么,我们暂且称它为 MyType。如果我们执行 MyType::recover,然后传入一个函数或其他 f,因为我们还没有弄清楚它是什么。然后那个函数调用,recover 函数调用将返回一个 Recover。
所以我们也将其复制粘贴在这里。我没有在这里看到 Recover 类型。我不知道 Recover 来自哪里。我猜 Recover 来自 hyper。看起来 Recover 并不是来自 hyper。什么是 Recover?我现在有点好奇了。那么 Recover 定义在哪里呢?所以 Recovery 在这里。看起来这是一个私有类型。我猜这就是为什么我们在文档中没有链接到它的原因。因为 recover 并不是我们曾经会使用的东西,我敢打赌,如果我们去找 recover,查看源代码。那么… 这是 recover。在这个文件的顶部是 mod recover。所以源代码目录是 filter/recover,这里是 Recovery 的定义,它是这个模块的公共类型,但不一定能被我们导入。我们为 Recover 实现了 FilterBase,你可以看到这实际上是另一个复杂的类型,因为 Warp 试图成为一个非常通用的基础设施。这并不是你在应用代码中实现的东西。所以 FilterBase 为 Recover 实现的地方 T 实现了 Filter,无论 T 是什么类型,f 是一些函数,无论是什么。但这里重要的是 Recover 是一个具体类型,并且它存在于这里,我们可以找出它上面实现了什么等等。对我们来说,知道它实现了 Filter 就足够了。所以在这种情况下,再次回顾一下,我们定义了 Filter trait 上的 recover 函数,它接受一个泛型参数 f 并在 self 上操作。所以你可以在实现 Filter 的具体类型上调用这个函数。你必须传入某种类型的函数,它会给你返回 Recover 或者我们定义的类型的 Recover 结构体,并且给它传入的函数类型。另外,self 必须实现 Filter 和 Sized。f 必须实现带有 Rejection 类型参数的 Fun。f 的关联类型 Output 必须实现 TryFuture 和 Send。而 f 的关联类型 Output 因为它必须实现 TryFuture,所以其关联类型 Error 必须实现 IsReject。所以在这种情况下,我们处理了 Header 或 Body 的 .recover,然后传入 handle_rejection。所以如果我们回顾我们学到的东西,handle_rejection 将是那个 f。对吧?所以 handle_rejection 可能是实现了 async 的东西。如果我们去查看 handle_rejection,它确实是一个 async 函数。对吧?所以这个函数接收一个 Rejection 并尝试返回一个自定义值,否则简单地传递 Rejection。
所以如果我们回顾一下我们的类型签名,我们现在理解 f 意味着要实现 Fun<Rejection>,意味着我们需要一个接受 Rejection 类型的函数。如果我们回顾一下这里的处理程序,它接受 Rejection 作为第一个参数。所以这就是在那个类型签名中 Fun trait 为我们做的事情。基本上是在定义这个函数必须能够处理的类型。然后这里,除了 TryFuture 和 Send 以及 Error 必须是 IsReject 之外,对那个输出类型没有其他限制。所以 Infallible 来自于 std::convert::Infallible。这可能不是我们能够搜索到的东西。让我们在 Github 仓库中搜索它。你可以看到其他一些函数。例如,optional 要求返回类型实现 Filter,其中 Error 是 Infallible。在某个地方,我预计会看到 IsReject,但也许我找错了地方,因为我不太熟悉 warp 本身。所以这里有 Reject::IsReject,IsReject 的定义在哪里?让我们回到仓库中,查看 source/reject,在这里的某个地方应该有 IsReject。它们是 sealed 的,所以我们不应该接触它们。我假设这意味着它们是私有的。但这里是 Infallible 的 IsReject 实现,它告诉我们这个实现必须存在。你可以看到这里的输出类型,结果的错误关联类型必须实现 IsReject,如果我们查看… 我刚才删除了。
回到我们的例子。我们有这个输出类型,然后这里是一个结果,因此结果的错误类型必须实现 IsReject。所以这就是我们知道这个实现必须存在的原因。在这种情况下,它实际上看起来并没有做什么。它看起来像是某种身份验证,这很有道理,因为 Infallible 意思是不能失败。但在我们的例子中,我们只是寻找 IsReject 是否实现了 Infallible。所以这就是这个类型。理解这个类型并深入研究它需要一段时间。我在这里想说的是,这些类型是泛型的有原因。它们是泛型的,因为 Warp 是一个基础设施,而不一定是应用代码,如果这样说的话。我希望应用代码更简单,不需要这么多泛型和 trait 限制。但我希望这能有所帮助。我希望下次你看到这个时,你会说,“哦,没错。这是一个关联类型。”这个类型签名具体来说,我也知道在我们处理 SQLx 并为数据库创建自定义类型时也会出现这种类型签名。但是,是的。这就是我如何在一个我不太熟悉的库中阅读这个类型签名的方式。如果对我来说,只关心如何在应用级别使用它,那么我们可以去看 rejections,我们可以编写这个函数,然后就完成了。我们真的不需要做其他任何事情。我们不需要编写这个 handle_rejection 函数。我们不需要理解所有这些类型。但如果你确实理解这些内容的话,那会非常有帮助。所以我完全理解,这些类型很大,这些类型很吓人,它们都有点吓人。但这里发生的事情是我们… 我们正在定义一些用户或其他库稍后允许定义的参数。这个参数实现了这个函数的 trait。当它这样做时,我们试图尽可能少地限制它,同时尽可能有帮助。所以当我们想要在这些类型上调用 reject 时,我们可以。但我们不要求这个类型做任何绝对需要做的事情。所以就是这样,希望这对你有所帮助。随时乐意回答你们的任何问题,关于阅读文档或搜索内容的问题。我希望我在视频中手动查找这些东西的过程能帮助你找到一些查找信息的新地方,鼓励你点击右侧的 source 按钮,鼓励你查看文档或去 Github 上搜索这些内容。我们下个视频见。再见。
我们能用Rust构建什么
Summary
视频介绍了使用Rust编程语言构建的各种程序类型。Rust是一种系统编程语言,适合开发对性能和正确性要求高的软件。支持的程序类别包括命令行程序、WebAssembly程序、服务器端应用程序和嵌入式程序。此外,还提到了一些不太成熟的生态系统领域,如游戏开发和区块链智能合约。最后,建议在性能和正确性不重要时,考虑使用其他更易学的语言。
Highlights
- 🚀 Rust的优势:Rust提供对系统资源的低级访问,没有垃圾收集器,并且内存安全。
- 🖥️ CLI程序:Rust适合创建命令行界面的程序,如货币转换工具,这是一种学习编程语言的好方法。
- 🌐 WebAssembly:Rust可以编译成WebAssembly,与JavaScript代码一起运行,适合计算密集型任务,如游戏和图像处理。
- 🌍 服务器端应用程序:Rust适用于高性能的后端服务,支持多种数据库和框架,但没有像Rails或Django这样的主导框架。
- 🛠️ 嵌入式程序:Rust适合用于嵌入式设备,如智能手表和物联网设备,因其低级系统特性和内存安全。
- 🎮 不成熟领域:游戏开发和区块链智能合约在Rust中的生态系统尚不成熟,但已有相关开发。
keyword
- Rust编程
- WebAssembly
- 嵌入式系统
我们能用Rust构建什么
https://www.bilibili.com/video/BV1LwpAeUE4F/
欢迎回来,Rust爱好者们。如果你是新来的,我的名字是Bogged In,这个频道是关于Rust编程语言的。所以,如果你想看到每周的Rust相关内容,花几秒钟悬停在那个订阅按钮上,给它点个小戳。
Rust本身是一种伟大的语言。它给你对系统资源的低级访问,它没有垃圾收集器,并且由于它有一个出色的包管理器和一个丰富的crates生态系统,它是内存安全的。但最终,编程语言只是构建软件的工具。Rust特别设计用于构建那些对性能和正确性要求非常高的软件。
这引发了一个问题:在这个视频中,你可以使用Rust构建什么样的程序?我将讨论你可以在Rust中构建的各种程序类别,那些有着支持生态系统的Rust程序。同时,请等到视频的结尾,了解较少支持的程序类别,并了解你现在不应该使用Rust构建的东西。
在具体类别之前,总的来说,Rust是一种系统编程语言,它给你对系统资源的访问和精细控制,比如内存等。所以你可以构建的任何东西,在C++的低级环境中,你也可以用Rust来构建。这包括数据库、编译器、低级网络代码等。有了这个介绍,我们可以继续了。
Rust社区已经特别努力地改善了围绕某些程序类别的开发者体验。第一个是CLI程序。CLI代表命令行界面,这些是你通过命令行运行的程序。尽管没有图形用户界面,它们通常通过功能和灵活性来弥补。创建CLI程序是学习许多不同编程语言的一个很好的第一步,包括Rust。
在创建CLI程序时,我建议选择一些对你自己有用的功能或工具,然后围绕那个构建你的项目。例如,在这里,我创建了一个CLI程序,允许我在不同货币之间转换,特别是将美元转换为我的家乡货币——乌克兰货币。让我们开始运行程序,然后我们输入二十美元。在这里,你可以看到二十美元可以买到什么——我们可以买到十二个面包,两头牛和一个小婴儿。
下一个有支持生态系统的程序类别是WebAssembly程序。WebAssembly是一种低级汇编语言,它是一种具有二进制格式的语言,可以在接近原生速度下执行。这种二进制格式是为基于栈的虚拟机创建的,现已被所有主要浏览器实现。此外,各种语言都可以编译成WebAssembly代码。
这意味着什么?这意味着你可以使用像C++或Rust这样的语言编写代码,编译成WebAssembly,并在网页浏览器中与你的JavaScript代码一起运行。还有接口允许你的WebAssembly代码和JavaScript代码相互交互。这意味着你的Web应用程序现在可以运行计算密集型任务,并接近原生速度。
这种技术非常棒,它打开了许多可能性,但也引发了一个问题:对你来说,是否值得编写WebAssembly代码?没有人会在乎你是否拥有世界上最快的待办事项列表应用程序。所以,一般来说,建议你仅在处理计算密集型任务时编写WebAssembly代码,比如游戏、图像处理、数学物理、音频效果等。还要注意,WebAssembly是可移植的,它被设计为在其他环境中执行和集成。这意味着WebAssembly代码可以在Web浏览器中运行,但它也可能在基本上的任何地方运行,包括移动设备和服务器。所以,如果你有一些核心功能,你希望在所有不同的环境中都是可移植的,那么WebAssembly也可能对你有吸引力。
下一个支持的程序类别是服务器端应用程序或网络服务。如果你正在寻找构建高性能后端服务的方法,Rust是一个非常好的选择,尤其是与生产就绪的框架如actix-web和Rocket一起使用,再加上对MongoDB、SQLite、PostgreSQL和MySQL的原生驱动程序支持,以及基本需求如日志、授权、模板和电子邮件的支持,生态系统相对成熟。请注意,尽管有一个成熟的生态系统,Rust目前没有一个占主导地位的框架,类似于Rails或Django,所以预计需要投入一些额外的努力来自己连接各个部分。
总的来说,来自Web开发背景的我,对服务器端的Rust肯定是我想更加深入参与的领域。
最后一个我想讨论的有支持生态系统的程序类别是嵌入式程序。嵌入式程序指的是运行在嵌入式设备上的程序。嵌入式设备是一种高度专门化的设备,用于特定目的,通常它被包括在一个更大的系统中。一些例子包括电子计算器、智能手表或健身追踪器、GPS系统、中央供暖系统或物联网设备。Rust是一个低级系统编程语言,没有运行时,并且具有内存安全性,这使得它非常适合这种用例。
我还想简要覆盖一些你可以使用Rust构建的东西,这些类别没有非常成熟的生态系统。第一个是游戏开发。虽然生态系统并不超级成熟,但人们已经能够使用Rust创建游戏。我最喜欢的是叫铁马的游戏。还有一些库允许你使用Rust构建图形用户界面应用程序,但再次强调,生态系统还不够成熟。实际上,你可以使用Rust来创建区块链智能合约。如果你对这个感兴趣,可以去看看Solana区块链。
在我结束这个视频的时候,我想谈谈你应该避免使用Rust的地方。一般来说,如果你的程序对性能和正确性要求不是特别高,那么请考虑使用一些更容易的东西,一种有垃圾收集器的语言,一种抽象掉低级系统概念的语言,一种易于学习且生态系统丰富的语言。但如果我刚才所说的没有阻止你,而你说“我想在Rust中构建一切东西”,那么这个频道就是为你设计的。我们一起克服痛苦,好吗?
这就是这个视频的全部内容。如果你喜欢它,确保点赞。如果你想看每周的Rust内容,那么请确保订阅。我将发布一个Rust速查表,你不想错过它。所以,如果你想获得那个,点击描述中的链接,这样我们就能在下一个视频中见面了。
我最喜欢的Rust设计模式
我最喜欢的Rust设计模式
https://www.bilibili.com/video/BV1LwpAeUEDT/
当涉及到Rust的习惯用法时,我最喜欢的设计模式之一叫做“扩展特性”(Trait)。实际上,我还发现了这个强大的模式在处理Rust的限制时非常有用。Rust有一个不寻常的规则,叫做“孤儿规则”,这个规则规定,你不能在你的crate之外为一个在你的crate之外定义的类型实现一个trait。换句话说,你只能对一个给定的类型实现一个trait,如果要么这个trait是在你自己的crate内部定义的,要么这个类型是在你自己的crate内部定义的。
例如,你可以在你的crate外部定义的Rust标准库向量类型上实现一个内部trait,叫做sum;或者你可以在Rust标准库外部的Debug trait上实现一个内部定义的矩形类型。然而,你不能在向量类型上实现Debug trait,因为这两个都是外部于你的crate的。这个规则防止了冲突的trait实现。你可以想象,如果标准库有自己的向量Debug实现,而我们被允许定义一个替代实现,编译器将不知道使用哪个实现。这个规则消除了这种模糊性。
现在,我们已经理解了孤儿规则及其必要性,让我们更深入地探讨在不违反孤儿规则的情况下实现trait的两种方式。首先,我们将讨论一种最佳实践,即当涉及到为你定义的内部类型实现其他地方定义的外部traits时。其次,我们将讨论“扩展特性”设计模式,这是你能够在你crate外部定义的类型上实现你自己定义的内部traits的能力所产生的。
想象你是在编写一个Rust库,并且正在将结构体和枚举作为公共API的一部分暴露。最佳实践是为标准库中的大部分traits实现,如Debug、Clone、Default和部分相等(PartialEq)。这些traits都是外部于你的crate的,但是你正在暴露它们在你的内部类型上。我们预计库的使用者很可能会使用这些常见的traits。如果我们决定不实现这些traits,这将给库的使用者带来不必要的麻烦。这是因为孤儿规则。
让我们换个角度,把自己放在库使用者的角度。作为使用者,我们不能做基本的事情,如使用Debug格式打印用户的类型,因为Debug trait未被实现。由于孤儿规则,从我们的角度来看,我们不能自己实现Debug trait。作为库使用者,Debug trait是外部的,因为它是在Rust标准库中定义的,而用户的类型也是外部的,因为它是在第三方库中定义的。
一种绕过这个问题的方法是使用“新类型模式”。通过在我们自己的crate中定义一个包含外部类型的元组结构,我们可以在该类型上实现外部trait。然而,这并不是最理想的解决方案,因为它确实需要额外的样板代码(boilerplate),而且可能非常困难,因为没有访问类型内部的权限来编写一个合理的trait实现。
再次,如果你正在编写一个Rust库,提前在类型上实现标准库trait是最佳实践,因为这是在我们自己的crate中定义内部类型并实现外部trait的一个例子。那么另一边呢?在我们的crate中定义内部trait并实现外部类型呢?这实际上是Rust中广泛使用的一种设计模式,叫做“扩展特性”。通过在外部类型上实现我们自己的trait,我们可以扩展它们的功能。
让我们从一个简单的例子开始,然后讨论一个更复杂的例子。假设我们在Rust中使用actix-web框架编写一个Web服务器。Actix-web期望路由处理器接受一个HTTP请求对象,并返回一个HTTP响应对象。使用扩展特性模式,我们可以为这两个对象添加功能。例如,我们可以定义一个HTTP请求扩展特性trait,有一个名为get_jwt的方法,然后我们可以在这个外部HTTP请求对象上实现这个内部trait,从授权头中提取JSON Web令牌。最后,在我们的路由处理器内部,我们只需几行代码就能使用这个新方法。我们成功地扩展了一个不属于我们自己crate的类型,真的很酷。
对,我实际上最近使用了这个确切的方法,用来在HTTP请求对象上添加一个名为is_arch_linux_user的方法。现在,每当我检测到一个试图访问我网站的Arch用户时,我会礼貌地将他们重定向到包含基本信息的Ubuntu安装页面。
让我们看一个更高级的例子。想象我们想要扩展迭代器的功能,通过添加一个叫做unique的方法,这将过滤掉重复的项。在这种情况下,而不是扩展单个外部结构或枚举的功能,我们想要扩展所有具体类型的功能,这些类型实现了Rust标准库中定义的Iterator trait。解决方案有点复杂,所以让我们一步一步地来看。
我们将定义一个新的扩展特性trait,并添加Iterator作为超trait。这意味着任何实现我们扩展特性trait的类型也必须实现Iterator trait。这允许我们在Iterator trait的基础上构建。接下来,我们将定义一个消费迭代器的方法,并返回一个新的内部结构,称为UniqueIterator,它存储原始迭代器和一个已见项的哈希集。
我们还将添加默认实现。基本上,我们想要消费原始迭代器,并返回一个只产生唯一项的特化迭代器。请注意,我们需要确保self被大小化,以便它可以被我们的方法消费。我们还需要确保可迭代项实现PartialEq和Hash,以便它们可以被存储在哈希集中,并实现Clone,以便它们在被添加到哈希集时可以被克隆。
我们将在内部UniqueIterator类型上实现外部Iterator trait,以便它与Rust的迭代器方法无缝工作。唯一需要的方法是next,它产生迭代的下一项。我们的实现将使用哈希集最终只产生唯一项。与其在各种具体迭代器类型上手动实现我们的扩展特性,我们可以利用Rust的另一个强大特性,称为泛型实现。通过使用泛型和trait约束,我们可以在我们的扩展特性上实现任何实现了Iterator的具体类型。
现在,我们可以在任何具体迭代器类型上调用unique方法。扩展特性模式极其强大,因为它允许你在不修改原始定义的情况下向现有类型添加自定义方法。这就是为什么它在Rust生态系统中广泛使用的原因。实际上,我们刚刚讨论的例子来自流行的itertools crate。其他使用此模式的流行crate包括serde、Rayon和chrono。
如果你想要了解更多关于Rust的独特特性和模式,请确保访问Let’s Get Rusty获取你的免费Rust速查表。希望你喜欢这个视频,并记住:保持Rust!
请解释一下如何在Rust中实现trait的默认方法。
在Rust中,trait(特性)不仅可以定义方法的签名,还可以为这些方法提供默认实现。如果一个类型实现了这个trait,它可以直接使用这些默认实现,或者根据需要重写这些方法。
下面是一个简单的例子,展示了如何为trait的方法提供默认实现:
1 | // 定义一个简单的 trait 叫做 `Greet` |
解释
- 定义trait和默认方法:
- 通过
trait Greet定义了一个名为Greet的trait。 Greet有一个方法greet,它有一个默认实现,返回字符串"Hello!"。
- 为结构体实现trait:
Person结构体实现了Greettrait,并且覆盖了默认的greet方法,提供了一个自定义的实现。Animal结构体也实现了Greettrait,但没有覆盖greet方法,因此会使用Greettrait 中的默认实现。
- 调用trait方法:
- 对于
Person实例,调用greet方法时,会输出"Hello, my name is Alice!",因为Person提供了自定义实现。 - 对于
Animal实例,调用greet方法时,会输出"Hello!",因为它使用了Greettrait 的默认实现。
总结
在Rust中,为trait的方法提供默认实现是很常见的做法。这允许在实现trait时只需要重写那些需要特殊行为的方法,而其他的方法可以直接使用默认的实现。这种机制提高了代码的复用性和灵活性。
Rust Pin大起底
单独成文了
Rust所有特性详解
如果你曾难以理解Rust特性,这个视频适合你。Rust有许多令人难以置信的特性,但这些特性并非Rust独有。实际上,Rust特意借鉴了其他语言的优秀特性,并将它们整合成一个单一的杰作。理解Rust最重要特性的来源,以及Rust如何在此基础上构建,至关重要。所以本视频将解释Rust最受欢迎的特性,其他语言如C++、Haskell和JavaScript如何影响它们,以及Rust如何整合这些特性,并在此基础上成为我们时代最受欢迎的语言。
注意,这些特性按复杂性排序,所以请坚持到最后,了解Rust的最先进特性。本视频是即将推出的官方Rust开发者训练营系列的一部分,这是你们一直在要求的全面Rust学习计划。确切的官方发布日期将很快确定,你可以加入候补名单。现在请暂停视频并访问相关网站。
首先,让我们谈谈一个惊人的特性。Rust从C++借鉴了零成本抽象原则。这个原则可以用以下短语总结:”你不用,你不付费;你使用的,手工编码不会更好”。这意味着高级语言抽象,如泛型和集合,不应该比使用低级抽象,如循环和计数器,有更多的运行时开销或性能惩罚。
在C++示例中,我们创建一个整数向量,用for循环找到最大元素。这个示例需要循环初始化、条件检查和元素比较,使代码更不易读且易错。使用迭代器抽象,代码变得简单,只需调用最大元素函数。迭代抽象封装了迭代和比较逻辑,使代码更干净、更简洁,错误更少,而不牺牲性能。
这是我们在Rust中如何编写相同代码:首先,通过调用iter方法将向量转换为迭代器,然后通过调用max方法找到最大元素。在这个例子中,我们实际上在Rust中使用了几个零成本抽象:第一个是迭代抽象,第二个是枚举选项,第三个是模式匹配。它的美妙之处在于它建立在零成本抽象原则之上,同时进一步强调内存安全和线程安全。
回到C++示例,如果我们用空向量调用max_element函数,在C++中会导致未定义行为。另一方面,Rust的max方法返回一个Option类型,因此,如果我们用空向量调用它,它只会简单地返回None。Option类型迫使我们检查返回类型,使代码更健壮,同时保持其他语言如Python的快速性能。
在Python中,迭代器不是零成本抽象。Python需要额外的运行时检查以确保迭代过程的完整性,因为集合在迭代过程中可以改变大小或结构。像Rust这样的编程语言无法承受这种性能损失。
接下来,让我们谈谈Rust最基础的特性之一:所有权模型。所有权基于资源获取即初始化(RAII)设计模式,这是从C++中借鉴的。RAII模式指出,资源如分配内存、文件句柄和数据库连接应绑定到对象的生命周期。当对象创建时,它将获取资源;当对象被销毁时,这些资源将被释放。
这是C++中未使用RAII模式打开数据库连接的示例:首先定义db和db_name变量,然后打开数据库连接,检查db是否为空指针,执行一些数据库操作,最后关闭连接。若未关闭数据库连接,将产生资源泄漏。现在,我知道这是简单示例,想象代码间有数百行,打开连接与关闭连接之间,开发者易忘释放资源,因此常常发生资源泄漏。
看看使用RAII模式的代码会是什么样子:在示例中,我们在构造函数中创建数据库连接类,获取资源(即数据库连接);在析构函数中释放资源(即关闭数据库连接)。在main函数中,我们创建数据库连接实例,传入数据库名,确保有数据库连接,执行数据库操作。Connection是栈变量,将在main函数末尾析构,析构时会调用析构函数,关闭数据库连接。
如你所见,此模式使资源管理更方便、可靠,代码更清晰、易读,并消除了资源泄漏。实际上,此模式非常有用,以至于Rust直接将其集成到语言中。因此,你不必记住使用该模式,编译器将为你自动完成,通过执行一组称为所有权规则的规则。
实际上,这些规则非常简单:
- Rust中的每个值都有一个称为其所有者的变量。
- 同一时间,一个值只能有一个拥有者。
- 当拥有者离开作用域时,该值将被丢弃。
这是数据库示例在Rust中的样子:我们有一个名为DatabaseConnection的结构体,它包含一个连接对象。当创建新的数据库连接实例时,我们在main函数中获取数据库连接并将其存储在结构体中。我们可以创建一个新的DatabaseConnection结构体实例并传入我们的数据库名称,然后执行我们想要的任何数据库操作。
不同于C++示例,这里没有析构函数来释放数据库连接。相反,connection变量将在main函数末尾超出范围,DatabaseConnection实例将被丢弃,连接持有的任何值也将被丢弃。这一切都会自动为你发生,就像魔法一样。但实际上,Rust编译器只是在遵循所有权规则。
如你所见,这导致非常简单和干净的代码,同时避免了资源泄漏。Rust在所有权规则的基础上进一步构建,通过将它们与借用规则结合起来。借用规则规定:
- 任何时候只能有一个可变引用,或任意数量的不可变引用。
- 引用必须始终有效。
回到Rust示例,可以看到创建多个不可变引用没问题,但如果引入一个可变引用,将出现编译时错误,因为混合了可变和不可变引用,违反了借用规则。
现在,你可能在想,为何需要这些规则?通过确保资源有多个读者或单个写者,Rust防止了一类称为数据竞争的bug。通过确保引用有效,Rust防止了空指针解引用。
所有权和借用是许多初学者甚至中级者难以理解或没有全面理解的概念。这就是我总是推荐做练习或项目的原因。专注于Rust训练营的这两门课,很快会发布,我为所有权和借用创建了特定的练习和考试。稍后我们再谈更多。
接下来,让我们谈谈Rust从函数式语言如Haskell借鉴的极有用功能。第一个是代数数据类型(ADT)。代数数据类型是函数式语言中常见的类型系统概念,它们允许我们使用和类型(sum types)和积类型(product types)创建复合类型。
和类型也称为变体类型,让我们定义一个可以是几种变体之一的数据类型。例如,我们可以定义一个Shape类型,它可以是Circle、Rectangle或Triangle。
积类型,另一方面,类似于C中的结构体,它们允许我们将多个类型捆绑在一起。例如,这里有一个由字符串、无符号32位整数和一个浮点数组成的Person类型。
ADT很有用,因为它们允许我们建模复杂的数据结构,并在类型级别上强制执行数据约束。这有助于在编译时捕获错误。
让我们看看ADT在Haskell中的实现。这里有一个Employee数据类型,这是一个由两个变体组成的和类型:Manager和Worker。每个变体可以持有不同类型和数量的数据。Manager有一个名字和一个下属列表,而Worker有一个名字和一个经理。
这是同一示例在Rust中的实现。ADT通过枚举(和类型)和结构体(积类型)实现。在这个例子中,Employee是一个和类型,所以我们使用枚举。
尽管Rust和Haskell中的ADT概念相似,但有一些重大区别。Rust将ADT集成到命令式编程风格中,与Haskell不同,它是纯函数式的。此外,Rust将ADT与其他语言功能(如模式匹配)结合,以及生命周期和借用检查器以确保内存安全和数据访问预防。
在这段视频中我多次提到模式匹配,这实际上是Rust从函数式语言如Haskell借鉴的另一个伟大功能。在这个例子中,我们使用模式匹配来匹配Employee实例的不同变体,允许我们为不同变体分别处理。Rust中的模式匹配强制穷尽性,意味着枚举的所有变体都必须处理,这可以防止潜在的运行时错误。
接下来让我们谈谈面向对象编程的支柱之一:多态。多态是对象或函数具有多种形式的能力,或根据使用上下文表现出不同行为的能力。一些语言通过继承实现多态,但Rust采取了不同的方法。在Rust中,多态通过特质(traits)和泛型实现。
特质定义了类型可以实现的函数和方法,类似于其他语言的接口。例如,我们有一个名为Shape的特质,它有一个名为area的方法。然后我们可以在Rectangle结构体和Circle结构体上实现该特质,以不同的方式计算面积。注意,类型可以实现多个特质,特质可以为函数和方法提供默认实现。
泛型,另一方面,使我们能够编写抽象于类型的代码,导致可重用和高效的代码。例如,这里我们有一个名为print_area的函数,在Rust中接受泛型参数shape。泛型可以由特质绑定,例如,在这种情况下,泛型T可以是任何实现Shape特质的类型。
在main函数中,我们创建了一个Rectangle实例并将其传递给print_area,然后我们创建了一个Circle实例并将其传递给print_area。我们的函数可以接受两个参数,因为Rectangle和Circle都实现了Shape特质。
Rust的特质系统源于Haskell的类型类,为类型提供共享行为。但Rust在类型类的概念上进行了构建,将所有权、借用和生命周期纳入特质系统。这些是Rust内存安全保证的核心。
Rust还支持动态分发与特质对象。特质对象允许你将不同类型实现相同特质视为可互换的。例如,这里我们创建了一个存储Rectangle和Circle的Vec<Box
Rust的特质系统相对于经典继承具有几个优势:
- 它允许更灵活的代码设计和组合。
- 多个类型可以独立实现相同的特质,实现灵活的组合,而不依赖严格的继承层次。
- 特质是非侵入性的,它们允许在不修改原始实现或继承层次的情况下为类型添加行为。
- 特质避免了脆弱的基类问题,即基类修改影响所有派生类。
- Rust特质系统默认静态分发,允许高效的代码生成。
接下来,让我们谈谈Rust从JavaScript借鉴的特性:异步编程的async/await语法。异步编程是一种编程范式,允许任务独立并发执行。在JavaScript中,它允许操作在不阻塞主程序的情况下执行。
在JavaScript中,Promise用于处理异步操作。Promise是封装异步操作结果的对象,允许附加回调来处理成功或失败。
示例中,我们有一个获取用户数据的函数。函数内传入用户ID,使用fetch进行API调用。这是一个异步操作,fetch返回一个Promise。我们可以在Promise上调用then方法并传入回调,在操作成功时调用。我们也可以在Promise上调用catch方法并传入回调,在操作失败时调用。
现在,这段代码的可读性还不错。但想象一下示例稍微复杂一些:函数现在是获取帖子数据,传入用户ID和帖子ID。函数应返回用户数据、帖子数据和评论。假设要实现此功能,需要依赖彼此的3个API调用:首先获取用户数据,然后获取帖子数据,最后获取帖子评论。
此时才能返回所需的全部数据。使用Promise会导致深度嵌套的代码,不易读。
让我们看看使用async/await语法的示例。async/await语法简化了异步编程,允许开发者编写看起来类似同步的异步代码,使其更易读。在函数定义前添加async关键字,函数内每个异步调用前使用await。错误处理使用try/catch块。这样的代码更易读。
这里没什么特别的,实际上async/await是Promise工作方式的语法糖。Rust采用类似方法处理异步编程,语法略有不同。与JavaScript类似,在函数定义前添加async关键字,函数内每个异步调用后添加.await。我们使用问号操作符传播错误,而不是try/catch块。就像JavaScript一样,Rust中的async/await语法是处理futures的语法糖,类似于JavaScript中的promises。
尽管JavaScript和Rust中的async/await语法看起来相似,但有一些重大区别:
Rust的async/await模型包含了零成本抽象原则。Rust的futures被设计为懒惰的,意味着它们不会在显式拉取之前执行。这意味着futures可以被调度、组合、与其他futures结合,而不会产生不必要的开销。
futures被设计成这样是因为Rust的一般原则是明确,意味着偏爱清晰和明确的代码,开发人员需要明确他们的意图。
相比之下,JavaScript中的promises是急切的,意味着它们在创建时立即执行。当一个promise被创建时,它立即启动异步操作,即使promise没有被await。但这可能导致不必要的工作和资源利用。
另一个区别是JavaScript是单线程的,这意味着并发操作并不是真正意义上的并行。相反,JavaScript使用非阻塞事件循环,可以几乎同时处理多个操作,给人一种并行的错觉。
Rust,另一方面,提供了真正的并行性。它可以通过利用多个线程同时运行并发操作。
接下来,让我们谈谈Rust最强大的功能之一:宏。但在深入探讨宏之前,我们需要理解元编程。元编程是一种编程技术,允许程序在编译时或运行时操纵或生成代码。宏是语言特性,允许开发人员定义自定义语法并执行代码生成或代码转换。它们通过提供一种编写代码的方式实现元编程,该代码在编译时生成或修改其他代码。
这是一个Rust中称为map的声明性宏的例子,用于创建哈希映射。声明性宏使用macro_rules!关键字定义。这些宏接受代码作为输入,将其与一组模式匹配,并根据这些模式在编译时生成新代码。
第一个模式在宏被调用时匹配两个类型表达式,用逗号分隔作为参数。如果传递的代码匹配此模式,宏将生成代码,使用指定的键和值类型创建空哈希映射。
第二个模式在宏调用时匹配用逗号分隔的键值对,由箭头分隔。星号符号表示重复,允许多个键值对匹配。该模式用于创建新的哈希映射,并使用指定的键值对填充它。
这是一个如何使用此宏的例子。而不是手动创建哈希映射,我们可以调用map!宏并传递键值对类型。而不是调用insert方法填充哈希映射,我们可以简单地调用map!宏,并使用箭头语法只需几行代码就完成。我们已经用一些新语法扩展了Rust语言。
关于Rust宏的一个很酷的事情是它们是卫生的,这是从Scheme中借鉴的一个特性。卫生的宏是一个概念,它确保宏内部使用的变量和标识符不会意外与周围代码中的变量冲突。
在这个例子中,我们有一个greet!宏。该宏有一个模式,它匹配任何表达式并扩展为使用提供的名称打印问候语的代码。注意,宏在main函数中捕获提供的名称作为局部变量。我们定义一个name变量,然后调用greet!宏并传递name变量。
这个例子展示了Rust中宏的卫生特性。宏内部使用的变量名称不会与周围代码中的变量名称冲突。多亏了宏系统的自动重命名和作用域机制,即使我们在宏内部更改name变量的值,它不会影响周围代码。如果我们运行程序,你可以看到”John”被打印,这是name变量在main函数中的值。
最后,让我们谈谈从世界上最受欢迎的Web框架中借鉴的一个特性。在我们深入探讨这一点之前,请记住,深入理解这些令人惊叹的Rust特性,以及如何在实际应用中使用它们需要大量的练习。这就是为什么我创建了Rust开发者训练营。我将在视频结尾的部分更多地谈论它。
Ryan Dahl通过引入Node.js革新了JavaScript,一个JavaScript运行时,允许JavaScript突破浏览器的限制。这导致了基于Node.js的JavaScript后端应用程序的爆炸式增长。Ryan还创建了Node包管理器或简称npm。不久之后,npmjs.com被创建,作为托管和发现公共包的中心枢纽,服务于Node.js生态系统。
Rust采纳此想法并创建了Cargo和crates.io。Cargo是Rust的官方构建系统和包管理器。它处理许多任务,如构建代码、下载和编译库、管理Rust版本和配置。这些操作可以通过简单的CLI命令执行,如cargo build、cargo test和cargo run。这类似于npm命令,如npm install、npm test和npm start在Node.js中的作用。
应用程序依赖项在Node.js中列在一个名为package.json的文件中。类似地,在Rust中依赖项列在一个名为Cargo.toml的文件中。在Node中,公共依赖项来自npmjs.com,而在Rust中,公共依赖项从crates.io获取。
Cargo和crates.io启用了可靠的依赖管理、简化的项目构建、集成测试和简单的包分发。这些是系统编程空间以前缺乏的功能。这使得开发如此简单,可能是我最喜欢的Rust功能。
所有这些功能使Rust成为一个令人难以置信的语言,但理解每个功能如何协同工作以及如何在实际中使用它们可能非常困难。这就是为什么我创建了Rust开发者训练营。
你们一直在给我发邮件,要求我创建一个高质量、全面的Rust学习计划,远远超出我的YouTube视频。这正是我过去一年一直在努力的事情。Rust开发者训练营是全面的Rust学习计划,是我希望我在第一次学习Rust时就能有的东西。我包括了超过100个视频,涵盖了你想要了解的关于Rust的一切。我还包括了编码练习、考试和现实世界项目,以帮助你内化这些课程。
现在,Rust开发者训练营即将推出,所以为了获得早期访问,请访问letsgetrusty.com/bootcamp。
希望你喜欢这个视频,我们下一个视频见。
Rust 类型系统的魔力
下面是经过整理的内容,保留了原文的所有要点,并使其更加通顺易读:
Rust 类型系统的魔力
这段代码是一堆噩梦,等待理解原因和如何修复它。首先,你需要理解Rust教给我的最大教训,那就是利用类型系统来强制不变性的力量。让我们在编程的上下文中分解这个问题。
不变性是一种必须始终为真的规定或条件。如果我们正在编写管理银行账户的软件,一个不变性可能是余额永远不会小于零(假设账户不允许透支)。但我们如何强制执行这个不变性呢?
有几种方法,可以分为两个类别:手动执行和自动执行。
手动执行包括:
- 代码审查
- 代码注释
- 文档
- 设计文档
- 团队成员之间共享的部落知识
你可以想象,这种执行方式非常脆弱,但它有其用例。想象一个UI不变性:任何删除操作都需要向用户显示确认提示。这将非常难以自动执行,因此,您可能选择更手动的方法,例如设计指南和代码审查的形式的文档。违反这个不变性不会是一个灾难性的事件,所以手动方法可能足够。
但如果我们在银行余额的例子中使用手动方法,它将很快导致灾难。我们无法承受违反,我们需要一种更强大的方法来执行。
这带我们到自动执行,包括:
- 运行时断言
- 运行时检查
- 测试
- 输入验证
- 利用类型系统
我们将讨论所有这些方法,特别是如何利用类型系统,这是最robust的方法来执行不变性。
让我们从第一个自动执行方法断言开始。我们将断言初始余额大于或等于零,我们将在存款和提款时做类似的断言,还将断言账户有足够的资金。我们的不变性现在自动通过代码,但断言在运行时检查,这意味着开发人员仍然可以编写有缺陷的代码。此外,如果断言失败,我们的程序将恐慌并崩溃。
让我们通过利用Rust的类型系统来改进这个代码。这就是利用类型系统强制不变性的力量。我们将平衡类型从正弦三更改为”无符号的二位整数转换为三位无符号二进制整数”,这意味着平衡不能是负数。
“我们现在可以删除新存款和提款函数中的断言,并在撤销函数中的第一个声明。但是,我们还需要确保这里的账户有足够的资金。”
“我们可以利用Rust类型系统的一个重要特性。我们将更改返回值类型,以考虑这种潜在失败模式。然后,我们在函数内部将进行一次简单的运行时检查。因为撤出返回结果类型,这将迫使调用者处理潜在的错误。”
我们还可以添加测试以确保提款行为正确。在这种设置下,我们的代码不会编译,或者我们的测试会失败,如果不变量被违反,使我们的强制执行更加robust。
这种强大的软件设计方法论,利用类型系统来强制不变量,被称为类型驱动设计。尽管我们的前一个例子很简单,类型系统实际上可以强制执行一些非常复杂的不变量,尤其是如果语言是静态类型的,并且有一个像Rust这样表达性强的类型系统。
在视频的开头,我说这代码是等待发生的噩梦。为什么?我们有一个创建新用户的API端点,不变量是电子邮件和密码始终有效。我们通过输入验证来强制执行这一点。用户给我们提供未经信任的输入,我们调用几个验证函数来确保输入符合我们的要求,只有在那时我们才将数据持久存储在数据库中。
问题是这些检查只在一个时间点进行,在我们的请求处理器开始处。因此,插入用户函数可以安全地期望电子邮件和密码是有效的。如果我们单独看类型签名,没有保证电子邮件和密码是有效的信息,它们被定义为简单的字符串。这个函数必须信任调用者在传递输入之前进行了适当的验证。
这是灾难的配方。随着代码库的增长和变化,你可以想象一个验证检查被意外地删除,或者数据被以某种方式修改,这可能会导致灾难性的结果。
防止这种情况的一种方法是在插入用户函数内部再次进行验证。然而,这引入了不必要的冗余和错误空间。
相反,我们可以利用类型驱动设计中的一个原则:解析不验证。而不是在我们的代码库中散布验证函数,我们可以将用户输入解析为新的类型,这些类型被保证能够维护我们的不变量。
首先,我们将创建两种新的类型:电子邮件和密码。两者都是元组结构体,它们包裹一个字符串值。包裹在内置类型中,这些类型在用户定义类型中有宽松的要求。要求更严格的类型被称为Rust的新类型模式。
在这种情况下,我们的要求或不变量是电子邮件格式正确,并且密码满足我们的长度要求。为了强制执行此,我们将添加一个解析函数,该函数接受一个不受信任的字符串作为输入,并将其解析为电子邮件或密码类型。解析操作可能失败,因此我们将返回一个结果类型。
在这里我们正在利用Rust类型系统的几个独特特性。由于Rust的可见性规则,内部的字符串是私有的,无法从结构体外部访问。并且Rust没有内置或默认构造函数,唯一构造电子邮件或密码实例的方法是通过解析函数。
我们仍然在使用之前的验证函数,但现在验证逻辑包含在类型中,并且验证状态持久在类型中。我们还将添加一个as_str方法,以允许只读访问底层的字符串数据。
我们现在可以更新用户结构体以使用我们的新类型,并更新路由处理器以解析用户输入。现在,任何下游代码都可以确保电子邮件和密码是有效的。
类型驱动设计是一个重要的主题,这只是冰山一角。我们刚刚讨论了解析不验证原则以及如何实现它,在Rust中使用新类型模式。
我们还可以使用更先进的模式,例如,类型状态模式。它允许你定义不同的状态,一个对象可以处于,为每个状态定义特定的动作,并确保有效的状态转换。
例如,在我们的API示例中,用户可以处于三种状态之一:查看者、编辑或管理员。首先,我们将创建一个结构体来表示每个状态,然后定义一个用户角色泛型在用户上,它默认为查看者,将泛型存储在状态字段中。它使用幽灵数据来避免不必要的分配。
然后我们可以定义对所有状态的方法和特定状态的方法,如编辑方法对于编辑器。我们还可以强制执行适当的状态转换:查看者可以晋升为编辑器,编辑器晋升为管理员,管理员可能降级为编辑器。
注意我们正在利用Rust的所有权模型。这些状态转换函数接受self作为输入,这将使实例在函数内部移动并使其不可用。这意味着如果一个查看者实例被晋升为编辑器,旧的用户实例不能使用。
这是对类型模式极其简化的概述,但我确实制作了一个视频,如果你想要关于这种高级模式如此类型的详细解释。
那么,如何在你自己的Rust项目中利用类型驱动设计?在Rust中实现这种强大的软件设计方法有很多种方式。理论上,你可以将这些模式应用到其他语言中,但Rust使其特别实用,因为它的类型系统在其他语言中的设计方式。
这些模式并不总是实用的。如果有可能,首先开始使用类型驱动设计。你可以做的第一件事是查看你以前的项目,以查找是否有机会应用更直接的模式,如parse,我们谈论的验证就是我们在这个视频中讨论的。
在你做那之前,确保你已经掌握了Rust的基础知识。你可以通过获取我们的免费Rust快速参考手册来实现这一点,让我们变得生锈点。Com斜杠cheat sheet。
希望你喜欢这个视频,记住要保持生锈。
Rust 函数虽怪,却值得庆幸
我理解您的要求。我会尽力整理这段内容,使其变得更加合理通顺,同时确保不遗漏任何内容。以下是整理后的版本:
Rust 函数虽怪,却值得庆幸
在这个视频中,我们将讨论 Rust 如何在类型系统中表示函数。我们将探讨一种思维模型,这种模型可以帮助我们理解 Rust 的一些设计选择,这些选择可能看起来奇怪。首先,让我们看一看,然后留下来,因为我们接下来要讨论这些设计选择在你的日常代码中的一些重大实际影响,以及来自其他编程语言的警告故事,该语言与 Rust 做出了不同的设计选择。
现在让我们明确一点,这个视频不是关于 Rust 的三个函数特性的,这些特性非常重要,需要深入了解,如果你想写有效的 Rust 代码。但它们不是本视频的主题。我将在这里谈论实际的函数项类型,如 foo,我还将谈论闭包。
让我们开始吧。我们将通过实验来学习一些函数类型。首先让我们认识我们的测试对象。第一个是一个叫做 f 的函数,它接受一个 32 位二进制整数,并返回该整数与自己相加的结果。第二个是一个叫做 g 的函数,它也接受一个 32 位二进制整数,并返回将其提升到第四次方的结果。注意,这两个函数有相同的签名,它们都接受一个 32 位二进制整数并返回一个 32 位二进制整数,但函数体是不同的。
接下来,让我们介绍我们将用于实验的工具,可以说是我们的实验室设备。对于接下来的几段代码,我正在做一个全局导入,导入任何已安装模块。”任何”已安装模块有许多有趣的工具,用于动态地检查值的类型,这对这种实验来说是完美的。尽管我应该提到,你可能应该在实际代码中谨慎使用它。这些工具内部非常强大,可以帮助你摆脱困境,但它们可能不是好东西来围绕你的设计来锚定。我在这里会使用它们。
我还在使用这种函数类型,名为 type_name,它接受一个值的引用,并返回其类型的名称作为字符串。这个函数实际上由标准库在 std::any 模块中提供,但截至我记录这一天,它不稳定,所以这里有一个稳定的实现,如果你需要它,一切都好。
所以让我们开始实验。这里是 f 和 g,你还记得它们两者都有相同的签名。如果以全新的态度来对待这个问题,特别是如果你来自另一种语言,你可能会合理地假设,因为它们有相同的签名,所以它们也有相同的类型。所以让我们测试这个假设。我将断言,f 的类型 id 等于 g 的类型 id。
如果我们运行这段代码,我们将看到这个断言实际上失败。从这里我们可以看到,f 的类型 id 并不等于 g 的类型 id。所以因为 f 和 g 的类型不同,我们可以得出它们实际上没有相同的类型。
让我们修复这个断言。如果 f 和 g 有不同的类型,它们是什么?f 的类型是什么?让我们尝试打印出来。当我用 f 和 type_name 函数调用并打印出来,我获得的 f 的类型名称是 f。顺便说一句,我正在一个叫做 example 的模块中,这就是为什么无论如何都要对 f 进行两次调用的原因。到目前为止,这看起来很奇怪。如果我们将其更改为 g,我们可以看到 g 的类型名称是 g。
到目前为止,我们学到的是 f 和 g 有不同的类型,类型似乎只是它们自己。所以让我们真正证明这一点。如果 f 和 g 是不同的类型,那应该意味着如果我将 f 赋值给一个局部变量,然后后来尝试将该局部变量赋值给 g,我应该得到一个编译错误。而且这正是发生的情况。你看它说 local 的类型是函数 f,尽管我们正在尝试将其赋值给类型为函数 g 的值。
有趣的是,编译器在这里告诉我们的类型名称与刚刚从 type_name 中看到的不同。原因在于这些类型实际上没有名称,它们是编译器理解我们代码内部存在的类型。这些类型存在于编译器内部的理解中,但是它们在文本中没有一个标准的表示形式。所以我们不能像 let local: f 这样写,因为 f 没有我们可以在源代码中实际拼写的类型。
因此,编译器和标准库以两种不同的方式渲染该类型,但它们谈论的是同一种类型,这是未知和不可拼写的 f 和 g 的类型分别。如果我们往下看,你可以看到编译器只是在告诉我们确切发生了什么,不同的函数项有不同的类型,即使它们的签名相同。
现在这是一个优秀的错误消息,而且以典型的 Rust 风格,它正在教我们 Rust 而不是只是向我们大喊。但如果我要调整它,我会添加它不是只有其签名相同,每个函数都有一个不同类型。如果我引入另一个函数,f’,它有与 f 相同的签名和相同的实现,然后我尝试使用它,在这种情况下我会得到相同的错误。编译器想要 f,我给它了 f’,所以所有函数都有不同类型。
那么我们如何使它工作呢?我的意思是,能够将函数存储在局部变量中是有用的,相当动态地。你使它工作的方法是通过明确引入间接性,通过注释 local 为函数指针类型。现在 f 在将其赋值给 local 时强制转换为函数指针类型,然后 g 也这样做。在下一行,请注意这里的小写 fn,这是函数指针类型的语法表达方式,而大写的 Fn 是 trait 的名称,所以请确保你知道两者之间的区别。
我想确保我提到了与闭包相同的情况也适用。所以你可以看到我在这里将一个闭包赋值给一个变量叫做 local,然后下一行我再次将相同的闭包赋值给它,但编译器告诉我这不行。它再次告诉我,两个闭包即使相同也没有相同的类型。这个错误的修复方法是相同的,你可以注释 local 为具有函数指针类型的,然后闭包将隐式转换为函数指针,当你将它们赋值给 local 时。但请注意,这仅适用于不捕获周围环境的闭包。
所以你可以看到我在这里有一个捕获 y 值的闭包,它声明在它上面。在这种情况下,我收到另一个错误,它告诉我闭包只能对函数指针类型一致,如果它们不捕获任何变量。顺便说一下,当你从这个角度看待你要求的东西时,这是有道理的。local 只是一个函数指针,它只是某个要执行的指令的地址。但在这一行中,你在 local 中存储的不仅仅是某个要执行的指令的地址,还包括 y 和 local 的值。local 只是一个指针,它没有地方存储 y 给你,所以这不起作用。而且如果你确实想要一种方法,可以抽象过可能捕获闭包的闭包,你需要更强大的东西。你需要一个 trait 对象,但这是另一个视频的主题。
所以这里发生了什么?我们刚刚学习了,所有函数在 Rust 中都有不同的类型。那么如何理解这一点,这意味着什么?不,我们得到了什么?我们将很快讨论这个问题,但它如何与我们对类型系统和程序中数据的心智模型相适应?
嗯,我认为以下插图非常有趣和有帮助。警告:接下来的部分包含一些原创思想,而且可能我所说的不一定都是真实的,在 Rust 中,按照字面意思。但我认为这是有价值的思考食物。
所以这是我们的老朋友,f 和 g,它们在我们的 Rust 代码中的存在形式。但在这些函数在运行时运行之前,它们经历了一个非常重要的转变。编译器将它们转化为具有等效行为到我们的 Rust 代码的机器代码。这些机器代码指令最终出现在我们程序的最终二进制文件中,并在程序运行时被加载到内存中。
这时,内存中的数据就像我们所有其他数据一样生活。但不是像我们的所有其他数据一样,因为它们是可执行的代码。但它们仍然是由零和一组成的,它们是由字节组成的,它们生活在内存中,它们占用空间。但我们使用类型来描述内存中数据的形状。但如果这些函数,以及构成它们指令的所有字节只是内存中的字节,那么我们必须问自己,这些字节的类型是什么?
构成顶部函数的字节的类型是 f,底部的字节组合形成类型为 g 的值。如果你仔细注意,你可能意识到这不是完全正确的。你记得函数实际上有类型,但不是可命名的。所以这些顶部函数的字节类型是某种东西,底部函数的字节类型是另一种东西。f 和 g 就像全局的常量,它们是程序中唯一值的类型。
那么,关于这两种无名类型,我们可以说什么呢?嗯,它们是不同类型,这毫无疑问。它们也是零大小的,这是我还没有提到的。它们实际上不包含你函数执行的指令,从物理角度来看,它们只是简单地给它们命名。所以它们是零大小的,从某种意义上说,它们只在编译时存在。否则,它们只是这种不透明的,我们无法触及的未知事物,除非通过它们实现的特性,包括 Fn 特性。
如果你眯眼看,你可以把 Fn 特性看作是调用函数的 ABI。我们并不清楚函数内部的确切内容,但只要我们知道如何按照调用约定设置一切,我们就可以调用它。我个人认为这是对函数类型的一种非常优雅的设计。它不是像其他语言中函数那样,只是这种神奇的东西你可以调用,因为你仅仅可以,因为它们是语言特性,然后可能还有一些其他抽象来实施你自己的可调用事物。在 Rust 中,函数只是这些奇怪的不透明类型的值,你可以调用,因为它们实现了 Fn 特性。我认为这很酷,它感觉像是一种漂亮的统一理论函数。调用其他语言不干净地执行我们,但像什么,我们只是来这里对语言设计着迷。
嗯,我一直在向你承诺一个实际的应用。现在是时候理解这个设计的实际好处了。首先,我们将看看另一种做事情不同的语言,这种方式会导致问题。这种语言是 C++,现在给 C++ 一点信用,它继承了很多从 C 这里做出的决定。我将在这里谈论 C,在许多方面,当 C++ 出现时,它做得最好。但它仍然陷入了一些不幸的问题。
所以让我们快速看一下。以下是将函数 f 和 g 翻译成 C++ 的两段代码:一眼就可以看出 f 和 g 的类型相同,所以静态断言成功。f 的类型与 g 的类型相同。
所以,那种类型的东西是什么?在运行时获取类型的名称有点拗口在 C++,但你可以看到这里,我只是打印出了类型的名称。并且,这种 f 类型的名称是这个奇怪的 int(int) 东西。如果你不熟悉 C++ 的语法,这里的第二个 int 是参数类型为 int,然后第一个是返回类型为 int。所以你可以看到,f 的类型并不以任何方式引用 f 本身,它只是 f 签名的这种抽象表示。
所以 f 和 g 的类型相同是合理的,它们只是这种奇怪的 int(int) 东西。那么这如何成为一个问题呢?当您开始谈论高阶函数时,这个问题就会出现,特别是那些接受其他函数作为参数的函数。
所以想象一下在 Rust 中这样的函数,接受一个函数作为参数,并且函数必须具有我们一直在视频中谈论的签名,int32 到 int32。在函数体内,我们看到我们只是五次调用它,使用数字 0、1、2、3、4,并将答案相加。
所以如果我们将这个函数翻译成 C++,在 C++20 中它看起来像这样。这可能会导致问题,特别是性能问题,当我们在 C++ 中天真地传递函数时。
为了看到它在行动,我会请你跟我一起去我最喜欢的网站 Compiler Explorer。如果你不知道 Compiler Explorer,给自己一个大大的 favor 去看看 Compiler Explorer。在描述中有一个链接。但总的来说,我们左边有一些源代码,你可以从它支持的任何这些语言中选择。在右边,我们有编译器运行该源代码的结果。
所以,我有一些 C++ 代码在左边,我正在用它通过 Clang 编译器运行。在这里,我有 Clang 生成的 x64 汇编代码。你可以看到,我们现在没有什么在做,因为 C++ 程序在 main 函数中实际上什么都没做。但你可以看到,在 main 的上面,我把我们一直在谈论的函数复制过来了。我在这里有 f,然后,我们有这里的高阶 calculate 函数。
你可以看到,我已经添加了这个属性,noinline,这就确保编译器不会将 calculate 函数本身内联,只是为了我们在一会儿能看到一些情况。所以,在 main 函数中,我想要调用 calculate 函数,记住 calculate 函数接受数字 0、1、2、3、4 分别调用我们的函数,然后添加结果。所以我们应该得到 20 在这里,因为 f 基本上只是加倍其输入,所以我们基本上只是在将数字相加,2、4、6 和 8。
所以让我们试试看。让我们调用 calculate 使用 f。所以看这里发生了什么,我们实际上得到了正确的答案,因为 main 函数返回了 20。但是让我们来看看在生成的代码中发生了什么。
从一开始,我们可以看到,我们写的函数模板 calculate 被实例化了,以一个函数指针类型的参数。你看到这个类型 int(*)(int),顺便说一句,你可能对指针的来源感到好奇,因为我们在代码中没有取任何东西的地址。C++ 有一个特点,当您将函数作为值传递给另一个函数时,它会经历一个过程叫做衰变,这就变成了一种隐式的指针。这就是在那里发生的事情。
但这应该已经引起了一些怀疑。C++ 编译器必须消除 calculate 的实例化,这适用于任何函数指针,不仅仅是 f,因为 f 的类型并没有说关于 f 本身的任何事情,它只描述了 f 的签名。当我们将它传递给 calculate 时,编译器将替换为类型,然后它必须生成代码,适用于具有该签名的任何函数。
因此,你可以看到,函数体内部有这些调用指令。操作数是这个寄存器 rbx。这意味着我们在调用一些未知的代码,rbx 寄存器中存储的地址属于谁。因为那是我们的函数指针参数最终所在的地方。这就是一个间接调用,因为我们正在调用我们不知道其功能的代码,甚至是提前的。这防止了你的 CPU 执行像指令预取这样聪明的事情。
但是,也许更重要的是,它阻止编译器看到它正在调用的函数的内部,以便它可以进行如内联这样的优化,以便它可以将 calculate 的体合并为一个非常简单的东西。我的意思是,想想看,我们正在通过名称将一个特定的函数传递给 calculate,但由于 C++ 的静态类型系统,编译器必须生成代码,那就是好像它不知道我们正在传递什么函数。
你也要注意,编译器必须为函数 f 生成代码,尽管在这个例子中我标记了 f 为静态,which,除此之外,告诉编译器 f 实际上不需要出现在最终的二进制文件中,并且完全优化出去是允许的。但它不能完全优化出去,因为需要我们能够获取它的地址来在这里传递它。
这一切都源于在 C++ 中的事实,函数类型不包含关于它是什么函数的任何信息,它们只知道它的签名。因此,当您将它们传递时,您别无选择,只能间接地调用它们。
因此,对于这个问题的典型解决方案,在 C++ 中是将要调用的函数包裹在一个 lambda 表达式中。C++ 中的 lambda 表达式行为更像 Rust 中的函数项,每个 lambda 表达式都有一个不同的数据类型。看看发生了什么,我们的 calculate 模板现在正在被这个生活在 main 函数内部的 lambda 实例化。这实际上是内部表示 lambda 的方式,但就像在 Rust 中,lambda 在 C++ 中有难以拼写的类型。
但你可以看到我们现在正在以 lambda 类型实例化 calculate,并且一个 lambda 类型知道它代表的确切代码。因此现在编译器能够直接看穿我们传递的函数,你看到它已经优化了函数体完全消失。它在编译时计算答案,并且仅仅返回 20,不需要在运行时做任何工作,除了产生那个返回值之外。这就是我们想要达到的效果。
所以这是 C++ 中一种非常常见的优化技术。即使你只是想原样传递一个函数,你还是必须将它包装在一个只向前传递那个函数的 lambda 中,以便你能得到你想要的代码生成。而且在 C++ 中,这种情况非常常见,即将某件事情以更短、更容易、更美丽的方式写出来实际上对微妙的技术原因不利。而且你需要学习所有这些小技巧,使它们的代码稍微丑陋一些,并且稍微不那么自然地阅读和写作,只是为了让它跑得更快。
所以这是 C++ 的情况。我相信你已经可以看到这要往哪去了,但让我们来看看 Rust 的情况,看看它与此如何相比。别担心,会有一两个惊喜。
当然,我会继续整理剩余的内容:
所以这里是用 Rust 编写的相同代码。我有一个内联的 calculate 函数,不是为了让 calculate 自己阻塞,这样我们就可以看到发生了什么。然后我在这里有我们的老朋友 f。所以在 main 函数的函数体内部,我将 f 传递给 calculate。
立即你可以看到,在生成的代码中,直接将函数名称传递给 calculate 就足够了,因为 f 有一个唯一的类型。这明确地标识了函数 f 内部代码的位置。因此,当 calculate 被以 f 作为参数单态化时,编译器知道我们正在谈论什么代码,并且它可以优化掉函数中的循环和所有粗俗的东西,完全基于函数名称。
如果我们想要,我们可以将这个包装在一个闭包中,就像我们在 C++ 版本中做的一样。但你看到,在 Rust 中,结果是相同的。简单而美丽的东西已经足够好了。
现在,在离开之前,我想提到的最后一件事。我一直在把它描绘为一件好事,因为我认为它通常确实是一件好事。所有的函数都触发它们自己的特定单态化,但没有免费的午餐。我也需要提到,有许多单态化可能会膨胀你的程序,并对编译时间和最终二进制文件的大小产生负面影响。这可能会产生自己的性能问题。
但我想要展示给你一个更多的技巧。Rust 已经准备好了一些手段来帮助你在这里。你记得之前的 f’ 吗?它与 f 有相同的函数体。我在这里添加了它,位于 f 的下面。我改变了 main 函数的函数体,而不是将 f 传递给 calculate,而是 f’。
所以看看我们的生成代码。我们有一个函数叫做 calculate,我们有一个函数叫做 f 和一个函数叫做 main。现在 f’ 发生了什么?嗯,编译器已经识别出 f 和 f’ 是相同的,所以它决定只需要生成 f 的代码。f’ 将成为 f 的同义词。尽管我正在实际上使用 f’,它识别出 f’ 和 f 是相同的,并更改我对 f’ 的调用到对 f 的调用。
为了证明这一点,让我改变 f’ 的函数体,使它不同。所以现在如果我在这里添加一个 x,我将得到生成 f’ 和 f 的代码。如果我将这个 x 去掉,它将再次消失。但这是编译器为你做的一个小小的反措施,这可能会在与高阶函数许多单态化导致的不必要的代码膨胀的战斗中帮助你。
我认识到这不常见于编写具有相同函数体的函数,但请记住,这个技巧在优化后触发。如果我改变 f’ 的函数体,使它是 x 乘以 2 而不是 x 加 x,这最终归结为相同的机器代码,尽管它是不同的 Rust 代码。你看,我在右侧的输出中仍然没有找到 f’ 的实现,因为编译器在函数生成后查看它们是否相同。
所以,像往常一样,Rust 语言和编译器都会支持你。我希望在这个视频中,我能说服你,Rust 在其类型系统中如何建模函数不仅优雅且美丽,而且在更广泛的意义上,也能帮助你在日常代码中。
我只想表达我对这些视频得到如此支持的深深感激。如果你有任何关于你想深入探讨的东西的想法,请告诉我。我会在这个视频的评论部分见到你,而且我迫不及待地想在下一个视频中见到你。再见!
什么是Rust位置表达式
我已经整理了您提供的内容,使其变得更加合理通顺。以下是整理后的内容:
什么是Rust位置表达式
这篇文章讲述了Rust语言中一个比较微妙的概念 - 表达式分为两种:值表达式和位置表达式。这与其他编程语言中常见的左值和右值概念完全一致。
大多数情况下,程序员不需要特别注意这个区别,因为Rust编译器会在需要时自动进行转换。但在编写unsafe代码时,理解这两种表达式的区别就变得很重要了。因为标记为unsafe的代码中,编译器不会自动进行这些转换,程序员需要自己负责确保代码的正确性。
让我们看一个例子:
1 |
|
在这个例子中,我们定义了一个packed结构体Packet。field y是u32类型,需要4字节对齐,但因为结构体是packed的,所以y只有1字节对齐。最后一行代码*point就出现了未定义行为(UB),因为point指向的是一个没有对齐的打包记录的字段。
但奇怪的是,为什么*point会产生UB,而&mut packet.y却没有呢?这正是本文要讨论的内容。
位置表达式和值表达式之所以难理解,主要是因为它们的区别通常是隐含的。为了理解前面代码中发生了什么,我们需要引入一些新的语法,使这种隐含的区别在代码中变得显而易见。
通常我们可以把Rust的表达式语法片段大致理解为:
1 | expression = literal | local_var | expression + expression | &mut expression | ... |
但要理解位置表达式和值表达式的区别,我们可以使用一种不同的语法:
1 | value_expression = literal | value_expression + value_expression | ... | load place_expression |
值表达式是能够计算出值的表达式,比如字面量5,或者5+7这样的运算表达式,以及生成指针类型的表达式,比如&my_variable。
但根据这套语法,my_variable并不是一个值表达式,而是一个位置表达式。因为my_variable实际上表示内存中的一个位置,可以对这个位置进行多种操作:从内存中加载这个位置的内容(得到一个值),创建指向这个位置的指针,或者将一个值存储到这个位置。
除了局部变量,另一个常见的例子是解引用操作符。它将一个指针类型的值转换成一个位置。对于结构体类型的值,我们可以通过字段来获取特定字段的内存位置。
这意味着像let new_var = my_var;这样的语句在我们的新语法中实际上是不合法的。为了让这段代码符合语法规则,Rust编译器会自动在需要的地方添加load操作符来转换语句。load的作用是从内存中读取某个位置当前存储的值。所以这条语句在解糖后的形式是:let new_var = load my_var;
让我们用这种更显式的语法重写开头示例中的相关部分:
1 | let point = &mut packet.y; |
现在我们可以清楚地看到为什么最后一行会产生未定义行为,而前面一行不会。表达式&mut packet.y只是计算了point的位置,并没有实际从中读取数据。使用&mut将这个位置转换成一个值。需要特别指出的是,解引用操作符(*)实际上并不会访问内存,它只是将一个指针类型的值转换成一个位置。这是一个不会出错的纯操作。
而在最后一行中,我们多加了一个load操作,这就导致了实际的内存访问。在这种情况下,由于位置没有对齐,导致了UB的发生。
计算一个未对齐的位置表达式是完全合法的,将这个未对齐的位置转换成一个原始指针值也是合法的。通常情况下,你可以把位置表达式看作是类似于原始指针:它们不要求指向有效的值,甚至不要求指向实际存在的内存。但是从未对齐的位置读取数据是不合法的,这就是load (*point)会导致UB的原因。
位置表达式在某些情况下可能会导致意想不到的行为,特别是和下划线模式结合使用的时候。例如:
1 | let point = std::ptr::null(); |
之所以会有这种区别,是因为下划线模式不会触发位置到值的转换。上面代码的简化形式是:
1 | *load point; // 没问题 |
第一行实际上并没有从指针中读取数据。当位置表达式和下划线模式结合使用时,并不会实际创建任何值。而在第二行中,由于创建了一个新的局部变量,所以触发了位置到值的转换,以计算这个变量的初始值。这就导致了UB。
类似的情况也出现在match语句中。如果match表达式匹配对象是一个位置表达式,那么下划线模式不会触发任何值的计算,但如果模式中出现了绑定变量,这就引入了一个局部变量,并且会触发位置到值的转换。
那么,会不会存在从值到位置的转换呢?比如说:
1 | let x = &mut 15; |
根据我们的语法规则,&mut需要一个位置表达式,而15是一个值表达式。那么为什么Rust编译器能够接受这样的代码呢?在这种情况下,编译器会自动把代码转化为引入临时局部变量的形式:
1 | let mut temp = 15; |
这样,&mut后面接的就是一个位置表达式了。这种转换让代码符合了更加明确的语法规则。
不过有一个例外的情况,如果你在赋值语句的左边写了类似15 = 12 + 19这样的代码,左边的值15就不会被转换成临时的位置,而程序会直接报错。这是因为在这种情况下引入临时变量很难产生有意义的结果。
结论:当我们在需要值的地方使用位置表达式,或者在需要位置的地方使用值表达式时,Rust编译器会隐式地将程序转化为符合语法规则的形式。如果你只编写安全代码,你几乎完全可以忽略这种转化。但是如果你在编写unsafe代码,并且想理解为什么某些程序会产生UB而其他程序不会,了解实际发生的事情就非常重要了。
如果你只想记住一点的话,那就是:解引用操作符(*)将一个指针转换成一个位置,但不会从内存中加载数据。实际的内存加载是通过随后的隐式的位置到值的转换完成的。
最后,有一个比较微妙的问题:place_expression.field这个表达式在进行字段访问的时候,实际上是在指针的基础上进行了一次安全的偏移计算。也就是说,它假定这个指针指向的内存是有效的。这就有点像我们访问一个数组:通常我们认为数组的下标是有效的、不会越界,但是如果这个数组指针本身就是无效的,那么就算下标是合法的也没有意义。虽然这种设计限制了灵活性,但它对编译器的优化非常有利。而且自从有了offset_of!这个宏之后,我们就很少需要在unsafe代码中手动计算偏移量,所以对解引用指针进行字段访问的情况已经非常罕见了。
Rust字符串全解析
好的,我会按照您的要求整理这段内容,使其变得合理通顺,同时保留所有原有信息,不做省略或总结。以下是整理后的内容:
Rust字符串全解析
C语言中字符串为何是字符数组,但Rust中字符串由不同类型表示?许多Rust初学者认为这是不必要的复杂性,但Rust团队在设计Rust字符串时非常有意图,这与安全性、效率和灵活性有关。在这个视频中,我将解释Rust中所有不同的字符串类型,以及它们在构建快速Rust应用中如何发挥重要作用。接近尾声,我也会涵盖一些罕见的专业字符串类型,让你能充分利用Rust的性能。
要真正理解Rust如何处理字符串,首先需了解字符串本质。编程数据本质上由二进制0和1表示,程序将二进制数据转换为可读字符串需两个关键信息:字符编码及确定字符串长度方法。
先谈字符编码。二进制数据通常处理为字节序列,每个字节含8位,可用整数表示。字符编码是一种将字节映射到字符的标准。处理字符串时,你需要知道两种非常常见的编码:ASCII和UTF-8。
ASCII代表美国标准信息交换码,这是一种非常简单的编码,每个字符由一个字节表示。使用ASCII,我们可以将示例中的第一个字节映射到大写H,第二个字节映射到小写e。如果我们继续,我们可以用ASCII编码整个’hello world’字符串。这对简单的英语字符串效果很好。然而ASCII非常有限,一个字节只能表示256个不同的值,因此ASCII仅支持英语字母、符号和控制字符,不支持其他语言或复杂字符,如表情符号。
这就是UTF-8的用武之地。UTF-8是一种可变长编码,字符长度从1到4字节不等。这意味着它能编码超过一百万个字符,包括世界上的每一种语言和复杂的字符,如表情符号。它也是完全可逆的,与ASCII兼容。所有这些伟大的特性是为什么UTF-8是世界上最广泛采用的字符编码。
现在我们知道如何将字节转换为字符,让我们讨论程序需要表示字符串的另一个重要信息:长度。字符串是内存中更大块中的一系列字节。当你创建程序中的字符串变量时,它指向字符串的第一个字节。但程序如何确定字符串的结束?有两种主要方法:
第一种方法是使用终止字符,通常是一个空字节来标记字符串的结束。这种方法简单且节省内存,但某些操作会有运行时成本。例如,要获取字符串的长度,您必须逐字节遍历字符串,直到到达终止字符。
第二种方法涉及存储字符串的长度,以及与字符串第一个字节的指针一起,在更高层次的数据结构中。优点是某些运行时操作将更快。例如,获取字符串的长度可以在常数时间内完成。然而,这种方法确实使用了一些额外内存。
有了对字符串的基本理解,我们现在可以探索为什么C中的字符串既简单又危险,然后讨论Rust如何解决这些问题。
C字符串简单地表示为字符数组,或指向字符串第一个字符的指针。编译器会自动添加空终止符来标记字符串的结束。C并不强制执行任何特定编码。这种简单性是有代价的,开发人员负责确保字符串有效且正确处理,这常常导致灾难。
例如,假设您的程序接收了一些用户输入,并且您期望该输入为有效的UTF-8。如果您忘记验证此输入,或者您的验证没有正确执行,可能会导致数据损坏,甚至更糟的安全漏洞。
让我们再看一个例子。假设您有一个字符串,并想要复制该字符串。因此您创建了一个包含16个字符的缓冲区,这是字符串中字符的数量。然后使用字符串复制函数填充缓冲区。结果,此代码导致缓冲区溢出,可能会导致数据损坏、未定义行为、安全漏洞,或系统崩溃,因为我们忘记了编译器自动在字符串末尾插入的空字节字符。现在我们可以通过在创建新缓冲区时使用size_of函数来避免这个错误,但重点是很容易犯灾难性错误。
现在我们已经看到了C中字符串的危险性,让我们转向Rust,一种重新构想字符串处理的更安全语言,注重效率与灵活性。
Rust利用强类型系统确保字符串安全:
Rust中的字符串类型存储长度元数据,使运行时操作更高效,防止缓冲区溢出等漏洞。
Rust中的字符串保证为有效UTF-8,确保跨语言和系统兼容,同时防止数据损坏。这便于处理字符串,开发者无需考虑编码。
Rust中的字符串(或更广义上,Rust中的变量)默认不可变,有助于防止字符串内容意外更改。
Rust中有多种字符串表示方式,本视频将涵盖所有内容,但核心是两种主要类型:String和字符串切片。理解这两种类型及其差异至关重要,因为你将经常使用它们。若记住一点,应是更深入了解这两种类型。接下来将讲解每种类型的技术细节和用例。
Rust中的String类型是堆分配的可扩展UTF-8编码字符串,称为”拥有”类型,因为它拥有底层数据并负责清理。当String变量超出范围时,底层数据自动释放。该类型包括指向字符串的指针、堆上的数据、字符串长度和容量。这种设计有利于字符串操作。
字符串切片,另一方面,是字符串的视图,表示连续的UTF-8字节序列,适用于只读操作。称为”借用”类型,因为它不拥有底层数据,仅能访问它。与String类型不同,大多数情况下字符串切片不拥有数据,它们本质上是对String或其他字符串切片的引用,仅持有切片起始指针和长度。与String类型不同,字符串切片不包含容量信息,因为它们不可扩展。另一个区别是,虽然String类型始终在堆上分配,字符串切片可以引用堆上或编译二进制数据段中的数据。字符串字面量或栈上字符串情况虽然罕见但也是可能的。
这两种类型有不同用途。String类型在动态创建或修改运行时字符串数据时有用,例如,读取和修改文件内容或收集用户输入时。另一方面,字符串切片在读取或分析现有字符串数据而不做更改时有用,例如,解析命令行参数或在大字符串中搜索子字符串时。
我们已讨论如何通过存储长度元数据确保Rust字符串安全,而非使用终止字符,强制执行UTF-8编码,以及默认使字符串不可变。我们还涵盖了Rust中的两种主要字符串类型:String和字符串切片。现在系好安全带,因为我们要涵盖Rust中所有其他字符串类型。这些类型提供了效率和灵活性。了解这些类型很重要,注意这些类型,以免遇到时措手不及。真正理解这些类型,可充分利用Rust的强大功能。
谈谈字符串切片的不同变体。这里有一个字符串字面量,它引用一个字符串切片。这实际上是语法糖,表示具有静态生命周期的引用。静态生命周期表示指向的数据在整个程序执行期间保证可用。这对字符串字面量有意义,因为它们存储在编译的二进制文件中。大多数时候,你不需要明确写出静态生命周期,但有时必须写出来。例如,在结构体或枚举中存储字符串切片时,在这个例子中,解析错误变体存储字符串字面量,或在无其他借用参数的函数中返回字符串切片。
现在你可能已经注意到字符串切片类型由两部分组成:引用操作符和str类型。str类型表示动态大小的UTF-8编码字节序列。换句话说,str描述字符串切片,但不可直接用作独立类型,因为编译时大小未知,需用指针如引用包裹。这是最常见的字符串切片类型,但不是唯一一种。
探索3种特殊指针类型:
不用引用,可用智能指针包裹str类型,表示拥有非全局堆分配字符串切片。当你希望冻结字符串以防止进一步修改时,它很有用,或通过丢弃String类型在此示例中存储的额外容量信息来节省内存。我们创建一个String类型,然后将其转换为Box
类型以指示我们希望保持字符串不变,无需进一步修改。这节省了少量内存,通过丢弃String类型在真实世界代码中存储的容量信息。您可能会在处理需要返回自己的字符串且不会进一步修改的API的情况下使用Box 类型,或当您希望积极优化内存使用时。 使用引用计数智能指针Rc
,与不可变字符串切片共享所有权,无需克隆实际字符串数据。例如,假设我们有大字符串表示文本,程序多部分需引用某部分。为避免复制子串,可用Rc 类型。实际字符串数据内存只存一次,无论创建多少Rc实例。处理超大字符串时有益,克隆会很昂贵。 Rc智能指针的对应是Arc智能指针,代表原子引用计数。与Rc智能指针不同,Arc是线程安全的。用此智能指针包装str类型有用,当您有一个不可变的字符串切片,希望跨多个线程共享时,无需克隆字符串数据。示例中,创建常规字符串切片并包装为Arc智能指针,然后可启动3个线程,它们都可读取切片而无需克隗字符串数据本身。
现在对字符串切片有了更好理解,让我们深入研究String类型。String类型本质上是一个字节向量的包装,区别在于这些字节保证为有效的UTF-8。这使String类型能够提供方便处理Unicode文本的方法,并允许安全操作字符串数据。
然而,将字符串表示为字节向量(Vec
现在,让我们换个话题,谈谈您在Rust代码中可能经常看到的东西,那就是不同的字符串字面量表示,特别是原始字符串字面量和字节字符串。
这是Rust中的一个字符串字面量。如果我们想在字符串中包含特殊字符,如双引号或反斜杠,我们需要用反斜杠转义它们。在某些情况下,这变得很繁琐,比如编写正则表达式或定义JSON对象作为字符串字面量。在这些情况下,我们可以使用原始字符串字面量,在字符串前添加一个小写字母r,并在字符串的两侧添加一个井号符号。原始字符串字面量允许您编写特殊字符,如反斜杠和引号,而无需转义它们。我们可以看到一个原始字符串字面量被用于创建正则表达式模式。
字节字符串,另一方面,是通过在字符串字面量前添加一个小写字母b创建的。这创建了一个字节切片。这对于处理期望字节序列的网络协议(如HTTP协议)非常有用。您还可以将原始字符串字面量与字节字符串组合。在这个例子中,我们定义了一个包含PNG文件格式签名的原始字节字符串。我们可以用它来识别PNG文件。
我们刚刚涵盖了字符串字面量,它们在Rust中是直接且常见的。但如果您有更专业化的需求或限制,如果您想榨取出每一盎司的性能,那就是Rust中一些不太为人知的字符串类型发挥作用的地方。让我们深入这些隐藏的宝石,看看您如何可以利用它们。
字符串切片通常表示为&str,对UTF-8编码的字节序列的不可变引用。然而,有可能创建可变引用&mut str。这允许您直接修改字符串切片的内容,同时确保内存安全和UTF-8合规。虽然罕见,这对于就地字符串转换非常有用,而不需要为单独的字符串分配新内存。
在这个例子中,我们有一个名为匿名电子邮件的函数,它接受一个可变的字符串切片作为输入,并使用正则表达式在字符串中找到电子邮件地址,然后将它们替换为星号。请注意,我们在这里使用了一些不安全代码,因为我们调用的是静音字节,返回可变的字节切片。我们有责任确保这些字节对UTF-8有效,即使被修改后。可变切片在惯用Rust代码中通常避免,由于其复杂性和确保数据有效性的潜在陷阱。然而,您可能会在低级库中看到这种类型,或需要积极优化的编码器。
你可能遇到的另一种特殊字符串类型是Cow枚举。Cow代表写时复制(Clone on Write)。这种类型在函数有时会修改字符串时很有用,其他时候不会。你想避免在不必要修改的情况下进行新的分配。例如,假设你有一个函数,它接受一个字符串并返回其净化版本。如果输入字符串不包含任何黑名单单词,那么你可以直接返回它,而不分配新的字符串。这种情况下返回借用的Cow,本质上是无成本操作。否则创建新的净化字符串并返回,这种情况下是拥有的Cow。
到目前为止,我们讨论了如何利用Rust的健壮类型系统确保字符串安全。我们还探索了Rust中的两种主要字符串类型,并检查了各种其他为效率和灵活性设计的字符串类型。现在让我们谈谈一组特殊的字符串类型,它们处理互操作性。这些类型抽象了操作系统之间的差异,并帮助您的Rust代码与其他语言连接。
例如,Rust中的OsString和OsStr对处理字符串很有用,以与操作系统兼容的方式。与String和字符串切片不同,它们不保证是UTF-8编码的字符串。在类Unix系统中,OsStr可以包含任何字节序列,或在Windows中包含任何16位值序列。这在与不需要字符串为UTF-8编码的系统调用交互时很有用。
例如,考虑处理文件操作时目录中所有文件名。确保每个文件名都是有效的UTF-8,可以读取这些名称,即使包含无效UTF-8序列。这允许优雅处理非UTF-8文件名。若转换为常规Rust UTF-8编码字符串失败,仍可按需处理操作系统字符串值。这对编写跨平台代码尤其重要,不同操作系统对字符串的要求和惯例不同。
Path和PathBuf是Rust中处理文件系统路径的特殊字符串。Path是不可变的路径视图,用于读取或检查路径。PathBuf是Path的可变和拥有版本,类似于String类型,用于创建或修改路径。这些类型用于互操作性,因为操作系统以不同方式处理文件路径。
在这个例子中,我们使用Path和PathBuf读取文件内容。首先我们使用Path类型引用目录,然后将其传递给读取文件函数。该函数使用PathBuf类型构建目录内的完整文件路径。然后我们将这个完整路径传递给文件打开函数。
最后,我们有CStr和CString类型,在与C库交互时很有用。这些库期望以null终止的字符串。这些类型提供了一种安全处理C兼容字符串的方式。
例如,假设我们想调用C标准库中的getenv函数,该函数获取环境变量的值。该函数接受C字符串作为输入并返回C字符串作为输出。首先我们创建一个包含环境变量名为”PATH”的null终止C字符串。然后使用CString指针调用getenv C函数。该函数返回一个指向null终止字符数组的指针。我们获取这个指针并将其转换为CStr实例。最后我们将CStr转换为常规Rust字符串切片。确保数据有效。使用CString和CStr,可以安全地在Rust和C函数之间传递字符串数据,确保C对null终止符的期望得到满足。
在本视频中我们涵盖了大量内容,所以让我们快速总结一下:
Rust以三种关键方式确保字符串安全:
- Rust字符串类型不使用null终止符。相反,字符串的长度存储在类型中。
- Rust字符串和UTF-8保证有效。
- Rust字符串默认不可变。
Rust有两个主要字符串类型:
- String是堆分配的可增长UTF-8编码字符串。它是一个拥有类型,意味着它负责清理底层字符串数据,当String变量超出范围时自动完成。String类型用于运行时创建或修改字符串。
- 字符串切片是字符串或字符串部分的视图,可能分配在堆上、栈上或在编译的二进制文件中。字符串切片表示为对str类型的引用。str表示UTF-8序列,动态长度的编码字节。因为str尺寸编译时未知,必须使用某种指针类型,在这种情况下是引用。这是借用类型,因为它不拥有底层字符串数据,仅能访问它。字符串切片用于读取和分析字符串。
字符串字面量在Rust是静态生命周期的字符串切片。大多数情况下,无需明确写出静态生命周期,因为编译器将自动推断。但有时确实需要指定,例如在枚举定义中。
99%情况下str将位于引用后,但也可用其他指针类型包装。例如,Box
使用Rc智能指针,允许在程序多个部分共享不可变字符串切片的所有权,无需克隆实际字符串数据。使用Arc智能指针,无需克隆字符串数据即可在多个线程间共享。
String在Rust可表示为字节向量或字节切片,这对非UTF-8编码的字符串有用。
字符串字面量有特殊格式:
- 原始字符串字面量允许包含特殊字符如双引号,无需转义。这在编写正则表达式或定义JSON对象字面量时有用。
- 字节字符串允许将字符串字面量表示为字节切片。这对处理期望字节序列的网络协议(如HTTP协议)很有用。
- 你可以结合原始字符串字面量和字节字符串创建原始字节字符串。
Rust还有一些特殊化的字符串类型:
- 可变字符串切片允许直接修改字符串切片的内容。这在原地字符串转换而不为单独字符串分配新内存时很有用。
- Cow(写时复制)类型在函数有时需要修改字符串时很有用,并希望在不需要修改的情况下避免分配新内存。
最后,我们有促进互操作性的字符串:
- OsString和OsStr用于以与操作系统兼容的方式处理字符串。这些类型用于与不需要UTF-8编码的系统调用交互。
- Path和PathBuf用于处理跨操作系统的文件系统路径。
- CStr和CString在与C库交互时很有用,这些库期望以null终止的字符串。
如果你想看到更多这样的Rust内容,确保点击订阅按钮。希望你喜欢这个视频,记得保持生锈(Keep Rusting)!
Rust 初学者常犯的 8 个致命错误
https://www.bilibili.com/video/BV1tp4y1w7pd/
这两个链接内容一样,都是从youtube上抄的
https://www.youtube.com/watch?v=PbR4ECFIckg
螃蟹们,哈喽~, 我叫博格丹。今天的视频中,我将涵盖8个新手Rust开发者常犯的错误,视频末尾还会介绍一个甚至更有经验的Rust开发者也会犯的额外错误,所以请一定要看到最后。本视频基于迈克尔·布莱恩的精彩博文,我会在描述中提供链接。如果你还没有获取,请确保获取免费的Rust速查表,前往 Let’s get rusty.com/cheat-sheet。现在让我们开始视频。
- 不必要的间接
新手常犯的第一个错误是不必要的间接。例如,我们有一个名为fancy_print的函数,它接受一个字符串作为输入并打印出来。注意,fancy_print不需要修改输入,所以我们可以更新输入类型为指向String的引用。这可行,然而它引入了不必要的间接性。String可以自动转换为字符串切片,因此将参数类型改为&str仍然有效,并且具有使API更灵活的额外好处,因为fancy_print现在可以接受String或&str的引用。
- 过度使用切片索引
新手常犯的第二个错误是过度使用切片索引。在C等其他语言中,切片或数组的索引非常常见,但很容易因索引越界错误导致程序崩溃。幸运的是,在Rust中我们可以避免这些问题。
在这个例子中,我们正在计算向量中相邻元素之间的差异。我们必须记住从索引1而不是0开始循环。但是,与其对向量进行索引,我们可以使用数组窗口法访问邻元素。进一步改进,我们可以弃用循环而使用map方法。如你所见,Rust的类型系统可以安全地操作向量、数组和切片。
- 使用哨兵值
新手常犯的第三个错误是使用哨兵值。哨兵值有特殊意义,如-1、空字符串和null。例如,在C语言中,find函数接受数组、数组长度和查找值作为输入,如果未找到则返回-1。
在Rust中,当找不到值时,我们无需使用哨兵值。类型系统可以表示可选值。例如,不要返回空字符串表示未找到用户名,而应该更新函数签名,返回一个枚举类型,告知调用者可能返回None。这样可以确保枚举的所有情况都被处理。
- 不利用枚举
新Rustaceans的第四个错误是不利用枚举。例如,用字符串表示用户角色有很多缺点:角色可能被误写,新增或删除角色不容易被察觉。相反,使用枚举是正确的做法。在这种情况下,我们可以定义一个Role枚举,然后使用match表达式返回正确的布尔值。match表达式和模式匹配在枚举上非常强大。
- 不正确处理错误
新Rustaceans的第五个错误是不正确处理错误。错误处理在Rust中是一个复杂的主题,值得单独做一期视频,但我想强调两个常见错误。
第一个错误是不使用问号操作符进行错误传播。例如,有一个名为parse的函数,在函数体中解析两个字符串切片为整数,检查解析错误,如果没有错误则返回总和。这段代码可以工作,但可以使用问号操作符来改进错误传播,将函数缩小到只有几行代码。
另一个错误是创建不实现Error trait的自定义错误类型。实现Error trait有几个优点:语义上标记类型为错误,为类型提供Rust中良好错误的基本组件,允许类型与Rust错误生态系统的其余部分一起工作,特别是第三方库。然而,手动实现Error trait很繁琐,并涉及样板代码,可能使代码变得混乱。相反,应该使用thiserror库通过宏来实现必要的trait。
- 不使用标准库提供的trait
新手Rustaceans犯的第六个错误是不使用标准库提供的trait。让我们浏览一些有用的trait:
Default trait允许你为类型提供有用的默认值。例如,我们有一个名为Player的结构体,我们可以实现Default trait,而不是每次构建新的Player实例时都手动初始化level、items和special_move字段。现在我们可以通过调用Default::default()函数构建新的Player实例。注意,如果所有字段都实现了Default,则Default trait可以被派生。
From和TryFrom这两个trait允许你在类型之间进行转换,特别是对于错误之间的转换非常有用。这些trait之间的唯一区别是TryFrom用于可能失败的转换,而From用于必须不会失败的转换。
在这个例子中,我们定义了一个名为CliError的自定义错误类型,它有两个变体:IoError和ParseError。然后我们为CliError实现了两次From trait,一个实现将io::Error转换为CliError,另一个将ParseIntError转换为CliError。最后我们定义了一个名为open_and_parse_file的函数,并使用问号操作符轻松转换和传播错误。
另一个有用的trait是FromStr。此trait允许你从字符串解析用户定义的类型。例如,我们有一个名为Point的结构体,具有x和y字段。通过实现FromStr trait,我们可以编写代码将任何给定的字符串解析为Point实例,或在字符串格式不正确时失败。在这种情况下,我们指定字符串应包含用括号定义的坐标,由逗号分隔。然后我们可以从字符串创建新的Point实例。这在解析来自文件的字符串数据时特别有用。
- 没有充分利用标准库中的宏
新手Rustaceans犯的第七个错误是没有充分利用标准库中的宏。
第一个有用的宏是todo!()。它允许你在不完成代码的情况下编译程序。这在原型设计时特别有用。在以下示例中,我们有一个名为Db的结构体,实现了Database trait。为了编译代码,必须实现所有必需的方法。然而,假设我们只关心connect方法。我们可以在query方法的函数体内使用todo!()宏,这样就可以编译代码而不实际实现该方法。然后在main中创建MyDb的新实例并调用connect方法。注意,如果调用query方法,程序将在运行时崩溃。
最后要讲的宏是concat!和format!。concat!允许轻松将字面量拼接成静态字符串,而format!允许轻松创建自己的字符串,使用插值。这两个宏在处理字符串时很有用。
- 不使用Rust提供的工具
新手Rust开发者犯的第八个错误是不使用Rust提供的工具,特别是cargo fmt和cargo clippy。
cargo fmt是一个根据预定义样式指南格式化Rust代码的工具。这确保了你的代码格式一致且易于阅读。运行cargo fmt,只需导航到Rust项目的根目录并在终端中运行cargo fmt。你还可以配置cargo fmt以在保存时自动运行,通过安装rust-analyzer并在设置中启用”format on save”。
clippy是Rust代码的lint工具。它可以捕获常见错误并改进你的代码。可以通过在cargo项目的根目录中执行cargo clippy来运行clippy。还可以添加其他参数,例如,可以将复杂代码违规作为错误而不是警告,或者可以将所有警告提升为错误。这对于持续集成构建很有用。
谈到持续集成,设置clippy与GitHub Actions非常简单。在这种情况下,我们在git push时运行clippy并在出现任何警告时使构建失败。
最后要讲的错误(即使是经验丰富的Rust开发者也会犯的错误)是关于组织代码的。像Python、Java和JavaScript这样的流行语言允许你轻松共享和更改对象。然而,由于Rust的所有权内存模型,在组合多个所有权和更改时必须小心。Michael在他的博客中给出了一个很好的例例,让我们一起过一遍。
想象我们要实现一个游戏逻辑,玩家必须攻击一群怪物。一旦玩家造成足够的伤害,他们就赢了。在TypeScript中实现这个逻辑很简单:创建一个名为Monster的类,有health属性,一个takeDamage方法,和一个接收onDamage属性,存储回调函数列表。这些函数在下次执行takeDamage方法时被调用。创建一个名为DamageCounter的类,跟踪玩家对怪物造成的总伤害。damageDone用于跟踪总伤害,reachedTargetDamage用于检查是否达到目标。最后创建DamageCounter和Monster数组,为每个怪物添加一个回调,调用counter.onDamageReceived,并设置一个while循环,对随机怪物造成随机伤害,直到达到目标。在TypeScript中,此代码简单且按预期工作。
让我们尝试将其移植到Rust。我们定义一个Monster结构体,而不是使用Monster类。代码结构非常相似:有一个take_damage方法,减少自身健康并调用每个回调,收到的伤害作为参数;和一个add_listener方法,接受一个闭包并将其添加到回调向量中。我们还实现了Default trait,以便轻松构建新的Monster实例。然后在main中,我们创建一个新的DamageCounter实例,创建一个Monster向量,为所有怪物添加一个回调,执行counter.on_damage_received,并运行一个while循环,对随机怪物造成随机伤害,直到达到目标。看起来很简单,对吧?
但是,这段代码实际上无法编译。存在几个编译时错误:
- 不能借用counter作为可变的,因为它是闭包中的捕获变量。
- 多次借用counter作为可变的,因为闭包是在循环中添加的。
- counter不够长,因为它在一个Box<dyn Fn()>闭包中,没有生命周期注解。
- 无法不可变借用counter,因为它已经被可变借用。
所有这些错误源于Rust严格的借用规则。常见的解决方法是引入多重所有权和内部可变性。首先用Rc智能指针包裹counter实现共享所有权,再用RefCell智能指针实现内部可变性。然后更新for循环,克隆counter,移动到闭包中,创建可变引用后调用on_damage_received。最后,更新while循环,引用counter,调用reached_target_damage。这样代码就可以编译了。
但你可能会好奇,一般的错误在哪里?Rustaceans常犯的错误是过度使用Rc和RefCell(或它们的线程安全对应物Arc和Mutex)。除了增加代码复杂性和降低性能外,这种做法还会降低内存安全性,因为内部可变性通过运行时检查借用规则工作,而不是在编译时。这意味着如果你不小心,代码可能导致死锁和崩溃。
那替代方案是什么?替代方案是改变你的API或代码结构,这样你就不会持有其他对象的长期引用。例如,与其在Monster结构体中存储回调,我们可以让take_damage方法接受一个回调作为输入。这将消除对智能指针的需求并简化main函数。
如果为你的特定用例传递回调不是一个好选择,还有另一种方法。take_damage方法可以返回伤害结果摘要,而不是接受闭包,然后我们可以使用摘要数据调用on_damage_received。
如你所见,在Rust中编程需要考虑代码结构和内存使用。这就是本期视频的全部内容。请在评论中告诉我你认为新Rustaceans还会犯哪些错误。最后,别忘了访问 Let’s get rusty.com/cheat-sheet 获取你的免费Rust速查表。那么,下期再见!
为什么 JavaScript 生态系统转向 Rust. Why is the JavaScript ecosystem switching to Rust
youtube.com/watch?v=dZQMoEWe5uY
为什么JavaScript生态系统转向Rust
今天我们要讨论JavaScript,但不仅仅是JavaScript。我们还要讨论Rust如何为JavaScript库和构建工具提供支持。我们将讨论原因和方法,讨论实际上有哪些工具是用Rust构建的,以针对JavaScript工程师,然后我们将讨论这是如何实现的,以及你如何做到这一点。
但在开始之前,我想明确一点。这是关于Rust和JavaScript的。我并不是要你去用Rust重写所有东西。我是在告诉你,在合适的地方,有人正在决定今天使用Rust。为你已经使用的工具提供动力,在某些情况下你也可以做出同样的决定,而且Rust能很好地为JavaScript体验提供支持。所以不要去重写所有东西,但也不要害怕它。
当然,我们首先要讨论的是为什么会发生这种情况。要做到这一点,我们必须谈谈Webpack。Webpack几乎是当今构建工具的标准。可以说,过去几年里人们一直在积极构建项目以摆脱Webpack。过去曾有一些替代方案,比如Rollup,它针对的是略有不同的用例。但Webpack本身,虽然在2014年左右推出时很棒,但已经经历了几个主要版本。如果你关注生态系统中发生的事情,似乎Webpack已经到了生命周期的尽头。它仍然是一个还不错的工具,但有裂缝和压力,人们想要更好的工具。
其中一个人就是Webpack的维护者。Vercel有一个叫TurboPack的东西。他们非常突出地宣传TurboPack是Webpack的Rust驱动继任者,实际上是Webpack的创建者在开发的。它被定义为一个为JavaScript和TypeScript优化的增量打包器,用Rust编写,这基本上就是Webpack的定义,只是没有Rust部分。他们为什么选择Rust?他们声称因此速度很快。这个说法会在许多不同的项目中重复出现。我不希望你太认真地对待速度声明。我想展示和提到的是,例如,使用TurboPack的Next.js 13与使用SWC的V进行比较,SWC也是一个Rust项目。所以我们最终得到的比较是:嘿,我们正在使用Rust来提高速度,与其他也在使用Rust的项目相比。
但Vercel不是唯一试图构建Webpack替代品的人。字节跳动有RSpack。RSpack是一个快速的、基于Rust的网络打包器。基本上是同样的东西。Webpack是什么,但用Rust编写。那么为什么RSpack使用Rust呢?正如你可能猜到的,他们也声称”闪电般快速”。我不知道你怎么想,但如果我在另一个产品页面上看到”闪电般快速”,我基本上就忽略它了。这在JavaScript生态系统中是一个被过度使用的术语。这里的比较是RSpack和Webpack,再次与SWC一起作为默认的替代品。所以Webpack和Babel自2014年、2015年以来一直是标准…已经有好几年了。就像Webpack上面构建了像Gatsby和Next.js这样的工具一样,RSpack现在也有了建立在它之上的工具。现在有了RSpress,它是建立在RSpack之上的静态站点生成器。在这里我想指出,他们明确表示它简单、高效且易于扩展。所以即使这个项目在很大程度上是Rust优先的,他们仍然声称它很容易扩展。
在这个视频的早些时候,我们简单讨论了Vercel用TurboPack做了什么,他们与使用SWC的Vite进行了比较。事实证明,Vite不会进行完全的Rust重写,但他们会在关键的基础设施部分采用Rust。接下来的三张幻灯片来自Evan You在ViteConf 2023上的Vite现状演讲。如果你对这个特定部分感兴趣,可以去找幻灯片。我会提供链接。但我们之前谈到了Rollup,而Rolldown是Rollup的Rust移植版。Rolldown仍在进行中。它还处于早期阶段。它现在还没有为Vite提供支持,但将来会的。它建立在OXC之上,OXC是另一个Rust的JavaScript解析器项目。它是与Rollup团队合作构建的。这很重要,因为它是一个巨大的语言转变,由一大群JavaScript项目维护者完成的。我认为Vite特别是一个项目,如果没有意义的话,它是不会使用Rust的。如果他们无法以他们想要的方式呈现Vite体验,他们就不会使用Rust来支持这个项目。为此,Rolldown将暴露一个插件容器作为API。Vite将获得一个以Rolldown为依赖的Rust核心。Vite内部性能关键的部分将被Rust化。这个演讲还有很多内容。所以如果你对Evan You的演讲感兴趣,可以去看看。
这让我们想到了Parcel,这是JavaScript生态系统中另一个相当流行的打包工具。它是一个零配置的网络构建工具,也开始用Rust重写部分内容。Parcel为什么要这样做?好吧,和其他人一样,它速度极快。但我想在这里指出的是,Parcel的JavaScript编译器是建立在SWC之上的,就是我们在其他例子中看到的同一个SWC。Parcel在SWC之上构建了相当多的内容,比如打包本身、树摇优化等。我还想提到的是,Parcel的CSS转换器和压缩器是用Rust构建的,但它是建立在用于Firefox中CSS的相同crates之上的。这意味着他们正在使用他们称之为浏览器级CSS解析器的东西,这个CSS解析器在你每次在Firefox中加载页面时运行。这出于多种原因非常好。一个原因是有一个工业级的crate用于CSS解析,这意味着如果某些东西在Firefox或浏览器中工作,你就不用担心一个定制的一次性解析器无法处理它。这也意味着这些工具需要非常高性能。如果Firefox因为CSS解析而变慢,人们就会减少使用Firefox。通过使用Rust,你可以将这些crates引入你的项目,并利用它们所做的工作。
当然,我们已经提到SWC好几次了。它现在被广泛使用,实际上是Babel的竞争对手。同样,SWC声称速度是它的改进之一,就像其他人一样。OXC是另一个被提到的JavaScript解析器。他们称之为JavaScript氧化编译器。OXC同样声称速度有所改进。这次不仅是相对于Babel的速度改进,还有相对于SWC的速度改进。Lightning CSS是一个处理CSS的工具,部分与Parcel项目相关,但也可以在许多其他项目中使用。这就是使用Firefox CSS解析器的项目。当然,Lightning CSS也声称速度惊人。从在网上看到这个AST开发一段时间来看,我不得不说确实在成为一个快速的CSS解决方案方面做了相当多的工作。但再次强调,Lightning CSS网站上说Lightning CSS是用Rust编写的,使用Mozilla创建并由Firefox使用的CSS解析器和选择器crates。这再次回到了这些项目能够使用浏览器级crates来支持自定义工具的能力。
当然,这还不是全部。不仅仅是打包器,不仅仅是CSS解析器。不仅仅是Babel的竞争对手,还有像Tailwind这样的工具。Tailwind已经合并了PR,使基于Rust的解析器成为默认解析器。在制作这个视频时,我不认为这个版本的Tailwind已经发布,但它即将到来。NX是一个多包工作空间工具,也有一些Rust。现在NX可能是使用Rust较少的项目之一,但这是一个关于在合适的地方使用Rust而不是用Rust重写所有内容的视频。所以这实际上是一个很好的例子。
那么根据所有这些JS工具的营销页面,为什么要使用Rust呢?如果你看看他们给出的理由,通常是它很快,就像我们谈到的那样。并行架构是他们说的另一件事,或者是浏览器级工具。那么Rust快吗?好吧,我想是的,如果你写一个快速的程序,你知道你可以用任何语言写慢代码。你可以用任何语言写快速代码。如果你在做性能工作,最好的做法是不做工作,因为不做工作或避免不需要做的工作比做任何事都快。你可以用所有语言什么都不做。所以是的,Rust可以很快,它不是一种强制速度的语言。实际上,在一天结束时,它真的取决于你对程序采取什么样的方法。
并行架构是JavaScript营销页面中的另一件事。那是关于什么的?好吧,这里面有一个真理的内核。如果你想遍历一个大的集合,你可以在Rust中做到,就像其他任何东西一样。for循环,迭代器什么的。但Rust的所有权模型和借用检查器确实使得编写更容易并行化的代码变得更容易。例如,如果你用Rust迭代器编写代码,有一个很棒的crate叫rayon。Rayon允许你替换iter,这是Rust中迭代器的一个函数,你可以用pariter替换它,这是一个并行迭代器。就是这样。你就有了并行性。我不是说这是解决所有并行问题的方法,但这些JavaScript页面的说法确实有一定的真实性。
所以JavaScript营销页面的最后一点是,Rust somehow是浏览器级的。我们确实谈到了这一点。在某种程度上这是真的。如果你使用Firefox用来加载页面的crates,那就尽可能接近浏览器级了。但是,想想看,还有其他工具使用浏览器中使用的工具。你知道Node有V8。那是浏览器级的吗?所以再说一次,这里面确实有一个真理的内核,但也要带点盐粒。
这个列表没有说服你吗?我不能责怪你。我也不太相信。这些感觉不像是强有力的论点。那么根据另一群人的说法,为什么要用Rust呢?不是JS营销页面,而是互联网上的人。为什么喜欢使用Rust的人现在使用Rust?
其中之一是类型系统。类型系统非常富有表现力。它以TypeScript不能与JavaScript集成的方式集成到语言中。这并不是要贬低TypeScript。它试图解决一个棘手的问题,在一个最初没有类型系统的语言上。但这确实有一些缺点。Rust是一种从一开始就建立了类型系统的语言,可以更好地利用该类型系统,给你带来像TypeScript这样的语言中你喜欢的令人惊叹的自动完成功能。它既是低级的,又是高级的。所以你可以构建一个操作系统,但你也可以构建一个网络UI。如果你正在构建一个网络UI,你不必处理指针。它更像是编写TypeScript而不是编写C。所以你获得了低级性能。你可以获得尽可能细粒度的东度来编写操作系统级代码,但你也可以获得尽可能高级的东度来编写不仅仅是网络API,还有UI代码。
Wasm是与我刚才所说的相关的东西。所以如果你正在编写UI代码,你可能正在编译成Wasm。而Rust,在我看来,是今天编写Wasm的最佳生态系统。我们稍后会讨论包管理器、crates和生态系统之类的东西,但Rust非常擅长编写Wasm。所以如果你有理由在你的网站中构建Wasm,如果你有理由在你的应用程序中构建Wasm,Rust可能是一个很好的选择。
代数数据类型。这个视频要放到互联网上,所以你们中的一些人会确切知道我说的是什么意思,但绝大多数人不会。这些非常棒,但你可以把它们称为结构体和枚举。重要的是这些携带数据,但我们稍后会讲到这一点。
它没有垃圾收集器,这意味着它可以很好地嵌入。这意味着它可以很好地嵌入到JavaScript、Python、Ruby、Elixir中。它可以嵌入到你的JavaScript UI的浏览器中。它不会与你已经运行的任何垃圾收集器冲突。
它快速且安全。再说一次,这里没有太多可说的。这只是一
超出了最大长度~
为什么 JavaScript 生态系统转向 Rust. Why is the JavaScript ecosystem switching to Rust
https://www.bilibili.com/video/BV1LW4y1F7QT/
So today we’re going to talk about JavaScript, and we’re not just going to talk about JavaScript., . We’re going to talk about Rust, powering JavaScript libraries and build tools., . We’re going to talk about the whys and the hows. . We’re going to talk about what tools are actually being built with Rust to target JavaScript engineers., . And then we’re going to cover how that’s actually being done and how you can do it., . But before we get started, I want to be very clear about something., . This is about Rust and JavaScript. . I’m not telling you to go rewrite everything you have in Rust., . I’m telling you that in the right places there are people making the decision to write Rust today., . To power tools that you already use, that you can make the same decision in some cases., , and that Rust works well to power JavaScript experience. . So don’t go rewrite everything,, but also don’t be scared of it. . Of course, the first thing we have to cover is why this is happening at all., . And to do that we kind of have to talk about Webpack. . Webpack is pretty much the standard for build tooling these days., . It’s been a couple years, I’ll say, of people trying to actively build projects to move away from Webpack., . There have been alternatives, like Rollup, that target slightly different use cases in the past., . But Webpack itself, while it was great when it came out in 2014-ish., , has gone through a number of major versions. . And it would seem,, if you pay attention to what’s happening in the ecosystem. , that Webpack is kind of at the end of its life cycle., . It’s still an okay tool, but there are creaks and strains and people want better tooling., . One of those people is the Webpack Maintainer. . So Vercel has this thing called TurboPack., . And TurboPack. they advertise very prominently as being a Rust-powered successor to Webpack., that the creator of Webpack is actually working on. . It’s framed as an incremaintal bundler optimized for JavaScript and TypeScript written in Rust., , which is you know, without the Rust part, vaguely, what Webpack was.., And why did they choose Rust? ? Well, they claim to be very fast as a result., . And this is a claim that’s going to get repeated over a number of different projects-., I don’t want you to take the speed claims too seriously. . The things that I want to show and maintion are,, for example, Nextjs 13 with TurboPack. is being compared to V with SWC,, which is also a Rust project.. So we end up having the comparisons of:, , hey, we’re improving our speed using Rust against other projects that are also using Rust., . But Vercel isn’t the only people trying to build a Webpack replacemaint., . ByteDance has RSpack. . RSpack is a fast, Rust-based web bundler.., Basically the same thing. Webpack was, but written in Rust. ., So why does RSpack work with Rust? ? Well, as you might have guessed,, they also claim BlazingFast.. I don’t know about you, but if I see BlazingFast on another product page,, , I basically just ignore it. . It’s an extremely overused term in the JavaScript ecosystem., . Here the comparison is RSpack and then Webpack, again with SWC.,, another Rust project with the stand-in default. . So Webpack and Babel are kind of what’s been the standard since 2014,, 2015… It’s been a number of years. . And just like Webpack had tools built on top of it like Gatsby and Nextjs and so on., , RSpack is also getting tools built on top of it. . So there’s RSpress now,, which is a static site generator built on top of RSpack. . And here I want to point out that they explicitly call out that it’s simple,, efficient and easy to extend.. So again, even though this project is very largely Rust-forward,, , they are still claiming it’s easy to extend. . Earlier in this video we talked a little bit about what Vercel was doing with TurboPack., , and they had a comparison against VeeT with SWC. . Well, as it turns out,, VeeT is going to go through, not a full Rust rewrite., but they’re going to adopt Rust for critical pieces of their infRusttructure., . The next three slides here I want to point out are from the State of VeeT., VeeT Conf 2023. presentation by Evan Yu.. So if you’re interested deeper in this specific piece,, you can go find the slides.. I’ll link to them. . But we talked about Rollup earlier and Rolldown is a Rust port of Rollup., . Rolldown is still a work in progress. . It’s early stage. ., It’s not powering VeeT today, but it’s going to in the future., . It’s built on OXC, which is another Rust JavaScript parser project., , and it’s built in collaboration with the Rollup team. . So there’s a lot of inter-open source collaboration going on here to make this happen., . And this is important because it’s a huge language shift. being done by a large group of JavaScript project maintainers., . And I think VeeT in particular is a project that wouldn’t be using Rust if it didn’t make sense., . If they weren’t going to be able to present the VeeT experience the way they wanted to., , they wouldn’t be using Rust to power the project. . And to that extent,, Rolldown is going to expose a plug-in container as an API. . VeeT is going to get a Rust core with Rolldown as a dependency., . Performance-critical pieces of VeeT internally are going to be Rustified., . And there’s a whole lot more to this presentation. . So if you’re interested in Evan Yu’s talk,, go check it out.. That brings us to Parcel, another bundling tool that’s pretty popular in the JavaScript ecosystem., . Zero-configuration build tool for the web, also starting to rewrite pieces of itself in Rust., . Why is Parcel doing this? ? Well, just like everybody else,, it’s lightning fast.. But what I want to point out here is Parcel’s JavaScript compiler is built on SWC., , same SWC that we’ve seen in the other examples. . And Parcel builds quite a bit on top of SWC things like the bundling itself,, tree shaking etc.. The other thing I want to maintion here is that Parcel’s CSS transformer and minifier is built in Rust., , but it’s built in Rust on top of the same crates that are used to power CSS in Firefox., . What this means is that they are using what they’re calling a browser-grade CSS parser., , which is the CSS parser that runs whenever you load a page in Firefox., . This is really nice for a variety of reasons. . One is that there’s an industrial-strength crate for doing the CSS parsing., , which means that if something works in, say, Firefox or in a browser,,, you don’t have to worry about a custom one-off parser not being able to handle it., . It also means that these tools need to be very performant. ., If Firefox is slow because of the CSS parsing, people will use Firefox less.., And by using Rust you get to pull these crates into your project., and take advantage of the work that’s gone into them. . Of course,, we’ve maintioned SWC a few times now. . It’s used all over the place these days and it’s effectively a competitor to Babel., . Again, SWC is claiming speed as one of its improvemaints, just like everybody else.., OXC is the other JavaScript parser that’s been maintioned. . The JavaScript oxidation compiler is what they call it., . And OXC claims again speed improvemaints. . This time not just speed improvemaints over Babel,, but speed improvemaints over SWC.. Lightning CSS is a tool for dealing with CSS., that is partially associated with the Parcel project. , but can also be used in many other projects., . This is the project that’s using that Firefox CSS parser. ., Lightning CSS, of course, also makes claims to being amazingly fast., . And from having seen this AST developed over a period of time on the internet., , I have to say that there has been a decent amount of work towards being a fast CSS solution., . But again the Lightning CSS website says Lightning CSS is written in Rust., using the CSS parser and selectors: crates created by Mozilla and used by Firefox., . Again, this goes back to the ability for these projects to use browser-grade crates to power custom tooling., . Of course, that’s not all. It’s not just bundlers, It’s not just CSS parsers.., It’s not just Babel competitors, It’s also tools like Tailwind.., Tailwind has merged to PR to make the Rust-based parser the default parser., . As of the making of this video. I do not believe this version of Tailwind has been released,, but it is coming.. NX is a multi-package workspace tool that also has some Rust., . Now NX probably has one of the fewer uses of Rust compared to the other projects., , but this is a video about using Rust where it makes sense not rewriting everything in Rust., . So this is actually a great example. . So that brings us to why Rust,, according to all of these JS tool marketing pages. . And if you look at the reasons they give,, it’s often it’s fast, like we talked about. . Parallelized architecture is another thing that they say,, or browser-grade tools.. So is Rust fast. ? Well sure, I guess, If you write a fast program,, you know like you can write slow code in any language. . You can write fast code in any language., . If you’re doing performance work, the best thing to do is to not do the work., , because not doing work or avoiding work that you don’t need to do is faster than doing anything., . And you can do nothing in all languages. . So yes, Rust can be fast., It is not a language that forces speed on you. . And it really,, at the end of the day, does depend on what kind of approach you’re taking with your program., . Parallelized architectures was another thing. from the JavaScript marketing pages.., So what is that about? Well, there’s a kernel of truth to this., . If you want to iterate over a large collection of things, you can do that in Rust, just like anything else.., For loops, whatever iterators.. But Rust’s ownership model in the borrow checker does lend it to making it easier to write code that is more easily parallelizable., . So if you write code with Rust iterators, for example, there’s a great crate called rayon., . Rayon will let you replace iter, which is a function for iterators in Rust., , and you can replace that with pariter, which is a parallel iterator.., And that’s it. You’ve got parallelism.. I’m not saying this is the solution to all your parallelism problems., , but there is some truth to these claims that these JavaScript pages made., . So the last one for JavaScript marketing pages, that Rust is somehow browser-grade.., And we did talk about this a little. It is true to some extent.., If you use crates that are also used by Firefox to load pages., , then that’s about as close to browser-grade as you’re going to get., . But, thinking about it, there are other tools that use, tools that are used in browsers., . You know Node has V8. . Is that browser-grade?? So again, yes, there’s a kernel of truth here,, but also a little grain of salt.. And are you not convinced by this list?, ? I can’t blame you. I’m not really either.. These don’t really feel like strong argumaints., . So why Rust According to a different group of people, right??, So not JS marketing pages, but people on the internet.. Why do people who like using Rust currently use Rust?, ? One of them is the type system. The type system is very expressive., . It is integrated into the language in a way that TypeScript does not integrate with JavaScript., . That is not to hate on TypeScript at all. . It has a tough problem that it’s trying to solve on a language that was not built with a type system in the first place., , but that does have some drawbacks. . Rust is a language that was built with types from the start., and can take better advantage of that type system to give you amazing things like., autocomplete that you like from languages like TypeScript. . It’s low level and it’s high level., . So you can go build an operating system, but you can also go build a web UI.., And if you’re building a web UI, you don’t have to deal with pointers., . It’s much more like writing TypeScript than it is like writing., . So you get that low level performance. . You can get as granular as you need to write operating system level code., , but you can also get as high level as you need to to write not just web APIs,, but also UI code.. Wasm is something that is connected to what I just said., . So if you’re writing UI code, you’re probably compiling to Wasm., . And Rust, in my opinion, is the best ecosystem for writing Wasm today., . We’ll get into things like the package manager and crates and the ecosystem and things like that., , but Rust is very good at writing Wasm. . So if you have a reason to build Wasm into your website,, , if you have a reason to build Wasm into your application at all,, Rust can be a great choice.. Algebraic datatypes. . This video is going on the internet., , so some of you will know exactly what I mean when I say that., , but the vast majority of you will not. . These are very great,, but you can just call them structs and enum. . It’s important that these carry data,, but we’ll get into that.. It has no garbage collector, which means it embeds well.., That means it can embed well in JavaScript, in Python, in Ruby, in Elixir.., It can embed in the browser in your JavaScript UI. . It’s not going to conflict with any garbage collector you already have running., . It’s fast and safe. . And again, there’s not much to say here., . This is just kind of like very generic, but people say this kind of thing all the time., . You saw it all in the marketing material. . Oh, this is so fast.., And then there’s another group of people that is like: oh, this is so memory safe.., But as JavaScript developers, you already work in a memory safe language., , so it doesn’t mean a lot. . The error handling is quite good in Rust., . Errors in Rust tend to be values. , so you tend to be able to deal with them on a per error basis., . And the error reporting is getting better every year with awesome crates like Anyhow Color Air., , and yet Cargo is Rust’s package manager. . People really like it., . Rust had. what I will say is the advantage of time.in that it got built after all of these other languages., . So it got to take insight from Ruby’s Bundler and JavaScript’s NPM and Yarn., and all of the other package managers that existed. . Cargo is somewhat the result of having seen all of those ecosystems., . It’s got testing built in. . It’s got benchmarking built in., . It’s got documaintation built in. . We’ll talk about that more later., . But it’s an amazing tool and it’s got so much built in that you need., . Rust Analyzer is something people really like. . It’s what powers all of the great autocomplete in your editor., . And because the type system exists in such a way that it does in Rust., , this means that you get access to very high signal editor autocomplete., . There are functions associated with types that you can call., , and those functions are very often the ones that you can call., . They are not text matches. . They are not pulling from some dictionary database?, . These are actual functions that are defined to be associated with the types., that you have and the values that are in your program. . Scoped resource managemaint is actually really nice., . This is the kind of thing where, if you open a file,, you also kind of have to remember to close the file., . In Rust. this is often handled via the drop trait. . So you can open a file in some function and at the end of the function., , Rust will call drop for you closing the file. . Zero cost abstractions come up quite a bit., , and this is kind of a foreign concept for JavaScript people., . But the idea here is that if you use a high-level language feature,, right., if you use generics or something like that. , that will add a potential compile time cost., , but it will not add a runtime cost. . So you get to use nice high-level language features., , and you only pay that cost when you’re compiling. , not when it matters,, when you’re running your production application. . Pattern matching is huge., . I love pattern matching in basically every language. . I think JavaScript is supposed to get this soon., . We’ll talk about this a little bit more later. . The type inference in Rust is really,, really, really good.. You have to put type signatures on all of your functions., for the argumaints and the return values. , but everything inside of your function,, for the vast majority of cases can be inferred: . This is really nice because you don’t have to put a lot of extra annotations on things., that don’t actually need them. . You are obviously always welcome to put as much type annotation as you need., for it to make sense for your program. , but Rust’s type inference is very strong., . The compiler error messages we’re going to cover in a little bit., , but they are very good and people put a lot of work into making them good., . The most high praise that I can give the Rust compiler. error messages., are that they are worth reading. . Again, cargo has built-in testing,, built-in formatting, built-in linting. . This all comes with the package., . You know it comes as part of the Rust package. . So that leaves us with fearless concurrency,, refactoring, et cetera, and memory safety..I’m not going to talk about memory safety., because JavaScript developers already work in a memory-safe language., , and it really doesn’t mean a lot to you. . Memory safety is a concept that’s kind of used to sell Rust to C developers., , but fearless concurrency and the refactoring that you can do with Rust., are huge selling points. . The way the memory model works, the way the borrow checker works,, , the way the ownership model works, the way Rust Analyzer will support you., as you try to refactor your program. . Rust is not unique in being able to refactor well., , but I will say that Rust refactors very well. . So, with all of that said,, all of the JavaScript marketing material, , all of the people on the internet., why Rust?? And in my opinion, it’s not because Rust is amazing., . It’s because Rust can enable you to be amazing. . So there’s this concept of fire flowers,, which is basically like the idea of you get a fire flower in Mario,, and now Mario is a superpower in Mario.that can shoot fire flowers., . So what does Rust enable you to do? ? It enables you to write programs in wide areas of applicability., . So, like we said before, you can write operating systems, embedded software networking.., You can do web APIs, you can do CLI tools and you can do web UI., . And there’s not a lot of languages that can span that whole width., . We maintioned it earlier, but the testing is built in the documaintation built in., , and there’s some modern package managemaint. . Having testing built in,, I have to say, means that I write far more tests than I used to., . Tests are pretty much always good to write, but having the capability to write a test,, in the file that I’m working in the source code with and then just run cargo test., , has led to me building so many more tests and giving me such a higher level of confidence in my code., . Documaintation is also built in. . You can do a cargo doc and that doesn’t just mean that you’re getting like type docs., , you know, those people who are like: oh yeah, types are documaintation., . We don’t need to write any. . That’s not what this means. ., This means that you can write full-on Markdown pros inside of your Rust source files., , and that will get documainted when you run cargo doc and send your library somewhere., . This also means that any documaintation that contains examples and example code will also get compiled., . That documaintation example for that library that you’re trying to use., that was out of date three years ago. because nobody remembered to update it., you can avoid that in Rust by using compiled documaintation examples., . Modern package managemaint. again, Rust really has the benefit of being built after a whole bunch of other things., and being able to learn from them. . So cargo learns a lot from all of the other ecosystems., . Rust allows you to build maintainable programs. . I’ve spoken about this on my channel before,, but the idea that you can write some code, and it will still compile next month or three months from now,, even if you don’t touch it., or that you can come back to it and start refactoring and not have to worry about too much., . You can let the compiler kind of guide you and tell you what things need to be changed., if you say: change a data structure right. ? So the idea that you can write code that targets some regular generic data structure like a vec,, , and then later find out that you need to change that vec to something a little bit more optimized., because it doesn’t quite fit your access patterns for your program., , Rust makes swapping that out and figuring out all the places you need to change things very easy., . Relating to that Rust has performance that sticks. . And what I mean by performance that sticks is that you can make changes to your Rust program., . Let’s say you wrote with a bunch of allocation. you remove all that allocation.,, you use references, do a bunch of pre-allocated data structures., . The data structures that you put shared references into, the types of, are very obviously shared references.., They are not just going to go magically back to an allocated version when somebody makes a PR right., ? So you don’t get much spooky action at a distance when you’re dealing with performance,, which is really nice.. And again, it embeds well in other languages., . This can be seen very obviously in ecosystems like Python, where Rust is being used to power data frame libraries.., But this means that Rust can be an addition to wherever you’re working., . This is a video about JavaScript. . Rust embeds very well inside of JavaScript to superpower the things that you’re building in JavaScript when you need it to., . You don’t need to rebuild everything in Rust to see the benefits of Rust., . The error messages. again, my highest praise for those is that they’re worth the time to read., . I can’t say that about every error message that I’ve ever read., . And the editor support is fantastic. . So Rust Analyzer is a really solid tool and can write a lot of code for you., . It can show you all of the types in your program, because Rust type system is great.., The inference is great. . And Rust Analyzer can show you all of that., . And, in general, I think it’s fair to say that Rust helps you build better programs and empowers you to do so., . And that’s why I would suggest using Rust. . This is a quote from somebody that works on Rust., . Specifically, this is in relation to working on the error messages that come out of the compiler., . And its diagnostics should always be clear on what the problem is., . And when they don’t, that’s a bug.. So what this is saying is,, if the Rust compiler outputs an error message that doesn’t tell you what’s actually wrong with your program., , the Rust team will consider that a bug that you can file an issue against and somebody will work on fixing it., . This, to me, is huge and is a large reason as to why the compiler messages in Rust is so good., . So that brings us to how do you actually do Rust and JavaScript?, ? How do you embed Rust in JavaScript? ? How do you use Rust with JavaScript?, ? And the answer in this video is going to be three ways. . One is going to be write Rust embed in the JavaScript package., . Second is that you can publish Rust binaries to NPM if you want to., . The third is compiling Rust to Wasm. . So embedding Rust. ., Rust has no garbage collector. . We talked about this earlier., . No, garbage collector means it embeds really well. . And API RS is the crate that we’re going to look at for building native modules in Rust., , compiling those to something that can run, say inside of Node, and expose JavaScript APIs., . Again. Rust does this very well.. So you can get this in Python and Ruby,, Elixir, JavaScript, etc.. There is some maintenance downside., . You do end up with N plus one packages. . So you end up with the library that you’re using to expose to users., , and you end up with an additional package per platform. . Because,, again, Rust is a compiled language. . You do need the package that you compiled for a particular architecture., . This isn’t a big deal for your users. . They won’t even notice., . But from the maintenance side you have to build Rust for, say, an M1., differently than you build Rust for Linux. . Rust can do zero copy over the JavaScript to Rust boundary., . If you know what that means, then you know what this means., . And if you don’t consider it a performance benefit. . And also really important is you’re not using some arbitrarily small subset of Rust to do this., . You can use fully async Rust and a bunch of other regular features., . So this is what it looks like to write an NAPI library. . On the top., we’ve got a rs Rust file.. We’re bringing NAPI into scope. . This is basically an import of the top of the file., . We use something called an attribute macro. . So hash bracket and API is going to write some code for us., . It’ll generate the code that is required to do the binding of this function., . Because, again at the bottom, we’re going to call this function from JavaScript., . But writing the code to like glue those two things together is super boring and very repetitive., . So we just let macro write it for us. . I’m sure none of you care about Fibonacci., . So the implemaintation of this function isn’t really important., . I just needed something short enough to show you. . Fibonacci takes some number and returns some number., . U32,, if you’re not aware, is a 32-bit unsigned integer. . That means it’s a positive number,, that is an integer that can be from 0 to something that is fairly large., . And on the bottom you can import this Fibonacci function from this file like it’s a normal JavaScript package., , and then use it like it’s a normal JavaScript function. . So our users do not have to know that you wrote this library using Rust., . This is what the package setup looks like for NAPI. . On the left-hand side., you have an example.. In this case, we’re using something that is Node RS,, slash, bcrypt- some theoretical package. . Your dependencies go in optional dependencies,, which then get downloaded on the relevant platforms. . And on the right-hand side we have all of those platforms specified with an OS and a CPU architecture., , so that they get downloaded on the right platforms. . Again,, this package setup is mostly a maintenance burden. . It is not a runtime burden., It’s not an end-user burden.. So your users won’t know and they won’t have to care., . So option two: you can publish Rust binaries to npm. . It seems really simple at first., . There’s a bin path in packagejson. . If you point that to a path,, then that binary should be copied into you know node modules up in whatever., . It turns out that it’s not that simple, but it’s still doable.., So if it’s not that simple, what are your options?? Oh, you can manage the binary download yourself., . There are libraries to do this. . One of them is called binary install., It’s at the link right here.. You can write this code yourself., It is not that much code.. Your other option is to do exactly what we were just doing with NAPI., . Publish multiple optional packages, which has a slightly higher maintenance cost., , but this can all be automated and it’s not that big of a deal., . Option three: compile Rust to Wasm.. The browser can run Wasm., Node can run Wasm.. Other ecosystems can run Wasm, Wasm, all the things.., Compiling to Wasm is not hard. . It is more or less built into the Rust compiler., . If we cargo build, specify the Wasm 32: unknown, unknown target.,, and we get a Wasm file instead of a binary file that you can run on Linux., . Depending on what you’re doing. there’s obviously a lot of variations., , flags and whatever that you can optionally set. . This is the basics., . Wasm bindgen is how you bind to JavaScript functions in Wasm., . So if you were going to call the alert function from the window in the browser,, , Wasm bindgen would be how you bind the Rust code to be able to do that., . It is also how you would expose a function from Wasm to be called from JavaScript., . And obviously on the web. we care a lot about the size of the things that we’re sending over the wire., . I don’t have the numbers in this video specifically. , but the last numbers that I saw was something roughly like you can download and parse., and compile about 2x the amount of Wasm compared to the same amount of JavaScript., . Wasm opt, then, is an optimizing compiler that you can run on the Wasm file that comes out of cargo build., , which will make it even smaller. . There’s additional tools,, called trunk, that we’re not going to talk about today. . Suffice it to say that Rust has a rich ecosystem for developing Wasm applications., . WebSIS is the package that already contains all of the bindings to all of the browser APIs., . So if you’re looking for that alert function, like we were talking about earlier,,, that already exists in WebSIS. . You do not have to bind it yourself., . And then we’re not going to talk about it today. , but Rust is so good at compiling to Wasm that there are full stack Rust web frameworks., that you can compile to Wasm and go deploy on an edge platform like dino deploy., . In this case, I’m specifically talking about Leptos. . That’s for another day., . This is Wasm bindgen in practice. . This is raw Wasm bindgen., . This is as if you’re not using any of the other crates or anything like that., . So, even though we have access to WebSIS which would have this alert binding,, , this is how you would bind it yourself. . Basically, you define an external,, define what the function is.. The argumaints it accepts. . And you use the Wasm bindgen macro to generate the glue code., . Same thing for functions that are defined inside of Rust that you want to expose., . In this case, we’ve got a greet function.. We’ve got Wasm bindgen on top of it., . It takes an argumaint of a string. . It uses the alert function that we just bound to above and greets you with hello name., , which is the argumaint we passed in. . Again, this looks very much in the JavaScript file., , like it doesn’t even know the Wasm exists. . I import greet from hello world,, call greet hello world and everything runs. . And when using Wasm bindgen,, you do tend to compile for specific platforms. . If you intend to use something inside of node,, it would compile with a node target.. If you’re going to use it on the web,, you would compile with a web target, and so on.. After doing the cargo build,, this is what the target looks like. . So Wasm bindgen. we create an out directory,, which is where all of our files are going to go. . You choose your target., . In this case, we’re going to run this on the web. . And then we grab that Wasm file from our previous cargo build., . If we look at the output directory, the JavaScript file, that will load the Wasm file., , we get a TypeScript definitions file for both our JavaScript file and for the Wasm., . And of course we get the Wasm. . Now, this is the default.., So I got a size of about 26 kilobytes. . I have done zero performance optimization,, zero size optimization.. This is just what you get by default if you do nothing but build., . But you can bring that down. . That can come down to about 12., 6 kilobytes.. This is about the lowest you’re going to get. ., 10 kilobytes of this are what’s called an allocator. . If you really care,, you can go full, no standard, and you can drop the allocator and drop that 10 kilobytes., . But most people aren’t going to be working in that environmaint., . But what we do have is tools that are very similar to something like Webpack Analyzer,, if you’ve used that,. In this case, I’m looking at a CLI tool called Twiggy that will show me what’s in my Wasm bundle., . In this case, DLM alloc is the allocator that I talked about., . So whenever you’re looking at the percentage on the left, anything that’s labeled as DLM alloc on the right would get removed if we’ve removed the allocator., . And you can see our greet function down there. . It’s 1.4% of our program.., So now, maybe you’re convinced that you would like to learn a little bit of Rust., . This is going to be a super high level, sky level overview of Rust, designed to make you just not scared of it., basically.. If you have concerns about maybe needing to write pointers or manually manage memory or something., , this section is here to assuage all of those fears and to basically tell you that you don’t have to do that., . So we’ve got Rust on the left, JavaScript on the right.. If you’ve worked with JavaScript,, you know that npm, Volta, N, FNM, etc.. We’ve got tons of options for version managers., . We’ve only got one option in Rust. . It’s Rust up. . For all of the other things that you do in JavaScript,, whether that’s package managemaint with npm, PMPM, Yarn, other new tools.,, testing with Jest Vtest or the new node test in Node building with Webpack Turbo or any of the other tools that we talked about., , type checking, documaintation, extra linting, formatting your code automatically- all of that is kind of built into Cargo., , and you can compare that against your favorite JavaScript tool for doing the same., . The one thing here that I will point out specifically is that Cargotoml is basically our package., JSON in Rust land.. That’s where all of our dependencies would go,, and so on and so forth.. You can, of course, if this isn’t enough,, extend Cargo.. So you can run cargo, install cargo-watch.. cargo-watch is a crate up on cratesio,, our package registry, and that gives you access to the command cargo-watch now., . So you can cargo-watch dash x build and every time the files change in your project it’ll build., . Wonderful. . Similar tools to this exist for Lambda, which is for testing building cross-compiling invoking AWS Lambdas., . There’s cargo-flamegraph for flamegraphs. . There’s esp-flash for ESP32 devices., . cargo-dist and cargo-release are release automation, not just for getting a crate to cratesio.,, but also getting it to Homebrew or GitHub releases or any number of other places., . cargo-insta is a crate that does snapshot testing. . cargo-insta is a CLI tool to manage the snapshot tests., . cargo-nexttest is: if you end up having so many tests that it becomes an issue for you., well, you could move to cargo-nexttest.. So let’s take a high-level look at the language itself., . This is a hello world for Rust. . It uses some new syntax. ., So right up at the top, we’ve got shebangs.. On the right-hand side we’ve got hello world js., . On the left-hand side we’ve got hello world rs. . The shebang defines what program we’re going to run when we execute this file., . In the output doc text on the bottom you can see /hello-worldrs., . This shebang at the top is what allows us to do that. . Now,, if you use Node and you’re watching this video, you’ve probably never used a nightly release version of Node., . In cargo. using a nightly release version or any other version is really easy., . You do cargo plus nightly or plus any other release and then whatever command you want to run., . In this case, we’re using the script command which requires that we use nightly,, which is why we’re using nightly.. That should stabilize sometime soon., . I don’t know when It’s not terribly important though. . So on the left-hand side we’ve got function main and then we’ve got a macro print line with the word hello., . Print line is basically analogous to console log. . It allows us to do some additional stuff,, but there are multiple kinds of macros in Rust. . This one is a function-like macro,, so we call it like a function.. Earlier in this video we saw attribute macros,, and there are some others.. We’re going to skim really quickly over variables., . In JavaScript we’ve got const let and var. . In Rust. we have just let and let mute., . Let is an immutable binding, while let mute is a mutable.. So if we do let x equals five and we do let mute y equals five and debug them out on the right-hand side,, here you can see we’ve got x equals five and y equals five.. If we set y equals to ten and we debug them out again,, we get x equals five and y equals ten.. It worked. . The only other thing I want to maintion here before we move on is the debug macro., dbg gives some really useful output.. So on the right-hand side it gives you the file name,, the line location, the expression that you gave it..So in this case,, that is variables. rs is the file line number eight, the expression is x, which is just a variable,, and then the value that expression evaluates to.So in this case that’s just five., . So on line 11, if we uncommaint that line and we try to set x equal to ten and we run our program,, we get an error message.. And I want to point out that this is one of the error messages that is useful to read., . In this case we get cannot assign twice to a mutable variable x., . We get an error code that we can go look up on the Internet if we want to., . We get the file name, the line number, the column number, typical stuff.., Now I will point out that we only have the code that matters., . So we have the code on line five, which is: let x equals five, and we have the code on line 11., .. Which is where we try to reassign x to ten. . The red error message says we can’t assign twice to an immutable variable,, which is true because we did let. x equals five.. The blue text is kind of like help text., . It’s saying: hey, this is your first assignmaint to five. ., This is where you first defined it. . And some help text that says:, hey, consider making this binding mutable. . And if you see that example that they give,, it is exactly what we did to make y mutable. . So it’s the right thing to do., . So if we did let mute x equals five, this error would go away.., Here’s a brief intro to structs. . There’s one more type of macro that we didn’t talk about,, and that’s derived macros.. Like we talked about earlier, writing code that is repetitive and boring is not fun., . So that’s why we have macros in Rust. . In this case we’re deriving an implemaintation of debug because on line 14 we use the dbg macro., . And that calls out to the debug implemaintation for whatever type we have., . Not super important, just doing some code gen for us. . So we have a person struct., that is roughly equivalent to a typed object. . We’ve got a name,, that’s a string, and a height that’s a U32… In this case, it’s my name Chris and my height 206 centimeters., . And then we debug that out. . In JavaScript. the result we get is just the object., . In Rust we get a lot more output. . And we get a lot more output because Rust already knows that we aren’t using the fields in this struct., . So we’ve defined this person struct. . We’ve instantiated this person struct., . But we never use the field. . We never access name and we never access height., . Other than that, right at the bottom it says structsrs 14 person,, which is the expression, and then the struct person. . That brings us to enums., . Enums are the second thing. when I maintioned, you know algebraic data types earlier., . Structs and enums. . In this case, we’ve got an enum of fruit with variants apple,, pear and banana.. We instantiate fruit apple and we debug it out., . On the right hand side. this is roughly equivalent to the same JavaScript., . And if we run the JavaScript code, we get symbol apple.. If we run the Rust code again,, we get significantly more output. . But that’s because Rust knows that we used apple but we didn’t use pear and we didn’t use banana., . So Rust to some extent knows when you’re using pieces of code and when you’re not., . You are getting dead code elimination already. . You do not have to configure it., . You do not really have to worry about losing it. . And when dealing with enums,, we can use Rust Analyzer.. So this is inside of VS code here., . We’re writing a match expression on my fruit and we’re using Rust Analyzer to write this code for us., . I click fill match arms and we get all of the variants matched against., and to do macros in the locations that we need to write code., . So if we pull this into an executable file and we run right at the bottom here it says not yet implemainted., . That’s the to do macro. . But to do. macros are really nice because you can also pass in some string of text to do., . You know, apples are for later.. We’re going to do it later., . The to do macro will panic your program and cRusth it with this message,, but only if you hit that at runtime.. So it will interfere with type inference right., ? So we don’t need to return anything here. . We don’t need to write out scaffolding code., . We can just say to do, and the Rust compiler is going to be like hey., , if we run into this, we’ll stop running, because you’re supposed to write this code later., . But if we don’t run into this, then the rest of the program will operate as normal., . And if we include that string in this to do macro. , we get not yet implemainted., Apples are for later.. Enums- very importantly- can also hold data., . If you’ve ever used something like Redux or written a reducer,, you might recognize this pattern.. Instead of having a type field on all of our JavaScript action objects along with a payload., , we can have an enum called action, with a bunch of variants.., So in this case we’ve got an add to do variant that’s written much like a regular struct would be., . The remove to do and complete to do, variants which hold only one value., . So it’s a type of i32, which is a signed 32-bit integer. . We can construct an add to do just like we would any other struct., . And we can match on that action and destructure the fields out of those variants to do whatever we want in these branch arms., . And when we run this again, Rust knows that we didn’t use the IDs., . So, if we wanted to, we could put an underscore before the IDs and silence these warnings., . But it also knows that remove to do and complete to do weren’t even constructed., . There are many built-in enums, such as option in the Rust standard library and used across the ecosystem., . I wanted to emphasize that you can build your own first, and that option and other enums like it are not special., . In this case, on the bottom right, we’ve got the definition of what an option is., . It’s just an enum with two variants, one of which is none and one of which is some that contains some type., . It’s useful to maintion at this point that Rust doesn’t have undefined or null., . So if we want to represent data that can be missing, we need to use an enum like this.., So in maybe something on the left hand side, we construct some 200…, So this is an option type where that T type is, you know, an i32…, We match on maybe something. If it’s some number, then we debug that number and we return it., . If it’s none, then we default to zero.. It’s useful to maintion here that Rust is an expression based language and that means that the match expression returns a value., . In this case, that value is going to be at either one of these branches,, so either the sum branch or the none branch.. And that will either be line 9,, , which is the last expression in the sum branch, or line 11,, which is the last expression in the none branch., . And either way, our number will be some number and we can debug that out., . It’s either going to be zero or whatever number we gave it., . There’s a simpler way to write this, though, as there often is in Rust.., All of these types like option have a bunch of functions defined associated with them., . In this case, we have a function called unwrapOr that does basically exactly what we just saw., . If there’s an integer inside of this option, if the variant sum value is in there, then unwrap that and give us the integer., . Otherwise use the integer zero. . And this is the exact same thing., . Iterators are a really important concept in Rust because they appear all over the place., . They are what powers the FOR syntax, but they also power really powerful things like collect., . So in this case we’re looping over the numbers 0 to 10. .. If you’ve never seen a range,, this is a range.. This range will give us the number 0, , 1,, 2,, 3,, 4,, 5,, 6,, 7,, 8,, 9,,, stopping before 10… We debug that out. . So on the right hand side you can see all of those numbers for each number in the loop., . But iterators are also more powerful than that. . We don’t just have to use them in FOR loops., . On line 5, we define a range of characters. . This is notably different than strings,, because characters are single characters. . So in this case, we are defining a range from A to Z lowercase,, and we’re using collect. to collect all of that into a string., . So we get the alphabet as a full string. . Now, collect is super generic,, so we do have to tell it what we want it to collect into.because it can collect into many,, many different things.. Now it’s often said that these ranges and structs and values and whatever are iterators., . But what does that mean? ? It means that they implemaint the iterator trait., . Traits. if you’re unfamiliar with Rust, you can basically call interfaces., . So there’s this iterator trait. . It’s got an item type associated with it., . This item type is going to be, whatever we’re iterating over., . In the first example that was an i32. .. In the second example that was characters., . The only function that is required to be defined here is the next function., . Everything else can be implemainted in terms of next. . And you’ll see that next gives us an option:, self item.. So if we call next, enough, eventually there are no items left in the iterator., , and it returns none. . And that’s how we know we’re done. ., And, of course, going by what we just said,, there has to be an implemaintation of this iterator trait for ranges., . And in fact there is. . So if we take our newfound knowledge and we say that a range from 0 to 10 implemaints iterator,, , that means that we can call next on that range repeatedly. ,, which will return us an option that we can unwrap because we know it’s going to be there., . And we can just debug that number out. . So we can keep calling next,, and keep calling next, and keep calling next.and debug that value out and debug that value out., . And it just keeps giving us the next one. . This is what’s happening under the hood when you use a for loop., . So if we go back quickly and look at the for loop, , this for loop is just calling next automatically until that iterator returns none., , and then it’s not iterating anymore and it moves on with our program., . In the same way, collect is just calling next for us under the hood., . And whenever next returns none, it stops and wraps everything up into this string and returns it for us., . I maintioned testing and how it’s built in. . In this case,, we’ve got a library that we’re building. . This super complicated add function that takes two use sizes and adds them together., . A use size. if you’re unfamiliar is a platform dependent unsigned integer., . Usually that means it’s a U64, but it can be a U32 or something else., . It depends on what computer you’re running your program on., . The config macro allows us to only compile this module. . So mod tests is like having another file,, more or less, in JavaScript.. Modules and sub modules in Rust aren’t tied to the file system., , but we can create this tests module. . We can say: only compile this when the test flag is true., . Bring in any functions that we defined in the parent module., . So superstar is anything in the parent module which happens to be in the same file., , and we can use the test attribute macro on a regular function., . And if this function panics, our test will fail.. If it doesn’t panic,, our test will pass.. So if we run cargo test, it will run our tests.., It runs our unit tests and you can see it’s actually running our doc tests here., , even though we don’t have any and our test passed. . Wonderful., . Async also exists in Rust. . The biggest syntactic difference is that await is after the expression rather than before., . In JavaScript. we await something.. In Rust we call something that returns effectively., what is a promise?. It’s called a future in Rust. . And then we await that., . The postfix await is actually really nice. . I have grown to prefer it over the JavaScript version., . It allows you to chain async expressions together. . So if we had another and then we awaited that and it returns something that we needed to await again., , we could just keep doing that. . But in this case we’ve got async function main., . Main is the function that we have to define for any binary., . And we’ve got some async function, another that takes a number, adds five to it and returns it., . Now, the biggest difference here in terms of how async functions is that the runtime comes from a third-party crate., . In this case, it’s coming from TokIO.. And TokIO provides us with this TokIO main macro that will write some code for us., . So this TokIO main macro will take the async off the front of our main function and start the TokIO runtime., , which will then make sure that all of our futures are pulled appropriately and whatnot?, , allowing us to use this async syntax. . If it sounds a little bit more confusing than JavaScript’s version,, it’s for a good reason.. And that reason is because TokIO is a production-grade web server level kind of async runtime., . Doesn’t need to be the one we use. . We can also use an async runtime on embedded systems., . There’s a crate called embassy that does this. . So you can make different trait-offs for how you want to write your async code based on which runtime you want to use., . And this is actually really nice. . If we want to see what the TokIO main macro is actually doing for us,, Rust Analyzer again, can just tell us.. If we expand the macro recursively,, it opens up a pane with the expansion and the code we wrote on the left directly compiled to the code on the right., . So in this case we’ve got a main function, an async block with the code that we defined in our main function., , a couple of clippy lints that we won’t talk about, and then the TokIO runtime that builds a new runtime., , enables some things, builds it, expects it to work and then runs our code., . The point here is not that you perfectly understand what’s going on on the right-hand side., . It’s that macros are not magical and you can directly see the code that they compile to if you want to., . And getting to the end here here’s our one slide on borrowing., . So we’ve got this dog struct that has a coat color, that has a string.., So in our main function we have a dog with a white coat color., . We’ve got three functions here defined on the right pet dog die coat and someone adopts dog., . Each of them takes a different kind of argumaint, even though they’re all dogs.., pet dog takes a shared reference to a dog and then uses that shared reference to debug the coat color out to the console., . What this is doing is it’s basically saying, hey, you can pet the dog, but when you’re done petting the dog,, I’m going to take it back.. While you’re petting it, you can’t change anything about the dog., . You can’t change the color of its coat, You can’t cut its hair, You can’t adopt it,, You can’t do anything with it.. But you can look at it. That’s why it’s a shared reference., . Die coat takes an exclusive reference, or something people sometimes call a mutable reference., . An exclusive reference means that I’m going to give you the dog., . Maybe you’re giving the dog to the groomer, for example.. The groomer., then, because Halloween is going to come up soon, dyes your dog’s coat orange.., They can do that because they have an exclusive reference. . Once they’re done,, they’ll give it back to you and we have the dog once again. ., So in our main function on line 13, , we get the dog back.. But maybe that orange coat color really sings to somebody and they decide they want to adopt the dog., . Well, if somebody adopts the dog, we are giving up ownership of the dog., . The dog is then moved into this function and this function owns the dog., now It doesn’t come back.. There is no reference here. That’s why it’s not coming back., . So the dog gets adopted and goes away, and that means we can’t pet the dog anymore., . Because in the main function, someone adopted it, we gave ownership away and now it’s not there anymore., . We can’t use it. . So if we run this program as is, you get the output you might expect., . You get, the coat color is white. the dog then gets adopted and at the end the dog has a coat color of orange., . But if we uncommaint that code on line 16, and we try to pet the dog after we’ve given it away,, , after we’ve given up ownership, we do get a compiler error message., . And this is one of the compiler error messages that you will see when you start writing Rust., . If you start writing Rust. . A borrow of moved value dog. ., So our variable dog owns the value that we created with the dog struct., . On line 14, the value was moved into the someone adopts dog function., . And on line 16, we are trying to borrow the dog again after moving it., . But it’s not around anymore. . Now the Rust compiler helpfully tries to say:, hey, you could change this if owning isn’t necessary. . But you know,, adopting is kind of an ownership-centric thing, right?? If somebody adopts a dog,, they get to take the dog home. . It doesn’t stick around forever., . But it does tell us that this parameter takes ownership of the value in this function., . So that’s your one pager on borrowing. . Again, it’s more about sharing references or giving away ownership., . You can also choose other approaches, like making copies., although that doesn’t really cleanly map to the dog analogy,, unless you’re a mad scientist, unfortunately,. But making copies of data and passing that into other functions is a totally valid approach., . And then you just have two copies, one of which still lives in the main function., , and one of which can go with the person who adopted the dog., . So, to close up here, how is Rust useful?? You can build standalone build tools., . You can embed it natively in npm packages and expose JavaScript APIs., . You can use Wasm in the browser or Node doing the same thing., . You can build serverless functions and other tools like that., , or you can use it to build sort of industrial strength tools., . So, like WGPU, is the WebGPU implemaintation inside of Firefox., . It is a crate that you can just pull into your project and start using., . If you want to build the next Figma, WGPU could be a good option for you to start with., . And finally, what is Rust.? This comes from Rustlangorg. . It’s a language empowering everyone to build reliable and efficient software., . And that is really the selling pitch that I believe in. . If you are going to use Rust,, it is going to be. because it enables you to do things that you either weren’t able to before., or had a harder time doing before. . It is not going to be because somebody told you Rust was memory safe., . So that’s it for today. . I hope you enjoyed the video. . If you have any questions,, leave them in the commaints., and I will catch you in the next one., . Have a great rest of your day. .,
社区说|使用 WebF 连接 Flutter 与 Web/Rust 技术生态
好的,我会按照您的要求整理这段内容,使其变得合理通顺,同时不会遗漏或省略任何内容。以下是整理后的内容:
社区说 | 使用 WebF 连接 Flutter 与 Web/Rust 技术生态
大家好,我是董天成,是 OpenWebF 项目的创始人。今天我给大家分享使用 WebF 连接 Flutter 与 Web 和 Rust 生态。
Flutter 这个项目大家应该都了解,它是使用 Dart 语言去开发一个跨平台应用程序。但是众所周知,由于国内很多人其实都不太会 Dart,大家都比较熟悉 HTML、CSS,会擅长使用 Vue 或者 React 开发 Web 应用。同时国内还有特殊的小程序生态这样的一个现状。以至于大家去使用 Flutter 一个最大的门槛,在于需要去学习一门新的语言和生态,才能够去掌握这门开发技术。
但是今天我给大家介绍 WebF,它就是这样一款 Flutter 的插件。你把它装在 Flutter 之上,你的应用就可以去加载一个 Vue 应用,或者说是一个 React 应用。它同时也给你提供和浏览器非常相似的环境。现在来说应该是支持的是一样的,还有一些缺少的。包括 DOM,还有 window,还有各种 Web API 的环境,还有 CSS 以及选择器。然后甚至像 Vue、React 的应用,也可以直接跑到 WebF 上。然后底下就是 Flutter。
然后做一下自我介绍吧,其实 WebF 的前身是阿里在 2021 年开始的北海项目,我当时也是北海的创始团队之一。因此这个项目从立项到现在,已经过去了 4 年时间了。然后在整整 4 年时间,我们一直在为最初的北海的渲染器,添加各种和浏览器相关功能,包括 CSS 的支持。还有以及在 WebF 成立之后,所添加的 CSS 选择器。
WebF 它的定位是什么?它其实目标就是,能够让过去掌握前端技术的开发者,准确来说是掌握跟 Web 相关的技术,包括不管是纯 H5 开发,还是说是微信小程序开发,还是说就是过去属于 React Native 这些用户。这些用户他们所掌握的以 JavaScript 为主的技术,我们都希望能够让他直接跑在以 Flutter 为主平台的这样应用上。
这样我们就可以解决一个大问题,就是在于我们可以只用使用一套平台、一套渲染架构,然后构建出一个应用,可以同时跑在任何的带屏的设备上,同时还能兼顾浏览器、小程序等各种平台。就包括现在所有的程序都行,因为现在目前的生态就是以浏览器、小程序这样的一个外圈子。但是有了 WebF 就可以再扩充一下,变成了 Flutter、浏览器、小程序。因此你就可以带来一些很有意思的事情。
WebF 是如何实现的?这个工作其实是个非常复杂的任务,也就是为什么我们现在做了整整 4 年,完成度只有 70%。因为你要想完成这一点,你需要做的工作,实际上就是等于以 Flutter 为一个基础,然后再做一个像 Chrome 一样的东西出来。就是把 Blink 的代码要深入了解,然后再移植到 Flutter 上,就是 WebF 做的内容。
所以说你可以看到中间这个红色的框,就是 WebF 做的所有工作。它其实就是在 Flutter 的绿色的 FBO 上又重新写了渲染内容,加了很多 CSS,加了一堆 Web API、elements。然后在上层红色部分是 C++ 部分,然后又支持了各种 Web API。然后再接上了一个 JavaScript 引擎,目前的选型是 QuickJS。同时我们还对 QuickJS 做了非常深入的性能优化,它比官方版本要快 40%。然后就是这么多一大堆的工作,就是 WebF。
所以当有了 WebF 和 Flutter,就可以在一个 Flutter 的环境下使用 Web API、使用 window,然后像前端一样去开发一个应用。当然它也没有限制你去使用任何 Flutter 的能力,因为你可以看到这个图,这个图上面其他的 Flutter 部分是完全完整的。也就是说任何一个 Flutter 的 package,在 pub.dev 上找到任何组件,或者说是什么组件库,widget 等,都可以轻松地用到这个项目中。那么在 Flutter 已有的、已建立的客户端那些生态上都可以一直到这里,你就可以同时享受前端和客户端这两边生态带来的收益,是一个非常高性价比的选择。
WebF 主要做的其实就是和浏览器做的事情是一样的。我们就是做 HTML、CSS,然后 JavaScript 实际上是选择一个 JS 引擎。WebF 的主要代码就是围绕 HTML、CSS 来的。当你有了非常足够多的 HTML、CSS,那么就会产生一个量变的新效果,就是现在的前端的生态。比如说最典型的是 ReactJS 和 VueJS,然后它上面存有大量的库和生态可用,还有非常多的开发者。这些库和开发者,他们其实针对工作的底层都是由浏览器提供的 Web API、HTML、CSS 这些很基础的功能。
同时这些非常基础功能,它是受制于一个标准委员会所限制的,就是 W3C、WHATWG 委员会。他们制定非常详细的功能标准,来约束这些 API 的实现细节。所以说不同的浏览器所提供的 API 是一模一样的,所以大家的应用可以轻松的在 Safari 和 Chrome 这两个非常不一样的浏览器上获得完全一样的工作行为。
不过反过来想,正是因为有 W3C、WHATWG 这样非常精确的标准,那么这个标准同样也可以拿来做一款新的浏览器。或者说就是准确来说不算浏览器,应该是以 Flutter 之上再做一个新的渲染系统。这个新的渲染系统遵循这个标准,那么我们就可以获得跟浏览器一样的行为。所以这就是 WebF 早期技术选型我们要这么做的原因。
然后我们要去了解这个如何去实现跨生态的背景,我们要先了解前端是怎么发展出来的。首先最初前端就是围绕 DOM 来的,那个时候没有 React,也没有 Vue,只有 jQuery,还有像什么非常传统的 prototype.js。但是这些库其实都是围绕 DOM 来的,然后那个时候前端就是只掌握了传统 DOM 操作,以及相关浏览器开发方式。
但是直到突然有一天,Virtual DOM 出现在大家的面前,然后带来了一些非常新的东西。因为 Virtual DOM 的发明,它代表了你可以在 DOM 的下层又做一个另外一套的实现层。那么 React Native 就是一个非常明显的代表,因为它把 React 底层那个 DOM 版本给删掉了,换成一个 Native 的版本。那么即使你上层应用没有什么改变,都是 React 代码,但是你通过不同的构建,就可以跑在浏览器和 Native 上。所以这就是 React Native,它的一个模式就是上层都是 JS 代码,都是 React,但是不同的构建带来不同的版本,不同的版本在不同的平台。
然后 Vue 2 以上的版本,也添加了同样的 Virtual DOM 支持,所以说 Vue 也具备了跟 React 一样的扩展能力。但是虽然说有了 React Native,但是其实我作为一个曾经的前端工程师,我非常知道,你去开发一个 RN 项目,很多时候你是希望它先跑到浏览器,然后再反向去看一下客户端这些应用的。因为我们更习惯于使用浏览器进行开发,因为反正有了 React 或 Vue,这不管是浏览器和不同的端,应该说大体来说是差不多的,但是会有一些小差别。所以说我们会先在浏览器上把页面开发好,然后再跑到不同端上去调。
但是 React Native 或者 Weex 这种平台技术,有非常大的限制。在于因为它每一个平台又有不同的实现,然后不同的实现提供不同的组件,又有不同的差异。就会使得每一个平台都会有些奇怪的行为,而不是安卓坏了,就是 iOS 坏了。这就是 React Native 被吐槽最多的地方,就是多端实现不一致。所以说才会有像 Flutter 这样,它可以用一套渲染、一套引擎,得到了完全一致的渲染效果,就像浏览器一样。浏览器也是这样。
然后后来小程序就出现了。小程序准确来说就是基于 Web 实现,它就是以 Web 为基础,然后又拿了 React Native 的多线程的思路,然后就这样缝缝合合,又阉割了一些东西,缝合做出了一个奇怪的东西出来。然后就是这样一个奇怪的东西,因为有微信霸权的存在,使得虽然它的开发成本还有各种都很恶心,没有其他观众喜欢它,所以就催生出了一个特殊的技术方案,就是跨端框架。然后这个跨端框架目前做得好的一个是 UniApp,一个是 Taro。当然 Taro 是以 React 为主,UniApp 是以 Vue 为主的,所以 UniApp 在国内更加流行。
UniApp 是如何实现的呢?他们就是用 JS 又写了一个模拟浏览器环境的东西出来,包括 JS 做的 DOM,JS 提供的 CSS 选择器,然后又给你做了一套组件,然后强行抹平了这些东西。就是在微信挖的坑里面又把坑给填上了,但是用 JS 的方式填的。所以在这么绕了一大圈之后,你的小程序在编译之后会塞入大量 UniApp 所提供的兼容层代码。然后这些兼容代码虽然解决了问题,但是不可避免会带来很多性能上的问题。
然后因此 JS 线程它会跑的比较慢,但是微信小程序有一个大杀器,就是它把 React Native 的多线程,就是 JS 独立线程给借鉴过来。因此虽然 JS 跑的很卡,但用户感觉不出来,因此用户还会觉得 UI 很流畅。除了你要写这种实时详细动画这种场景,那么问题就非常明显,就发现延迟特别高,巨卡。只是因为你想要动画,你就需要 UI 跟渲染线程交互同步,那么问题直接就暴露出来了。
所以有了这样的小程序框架之后,上层的业务其实也没什么变化,因为 Virtual DOM 刚好就可以再做一个小程序的操作,就是小程序版本。然后对应的就是小程序模式。UniApp 它的框架里面就提供了各种各样的构建,什么 build h5、build app 都是这个原因,因为我可以对应不同的 Virtual DOM,来产生不同的版本。
但是大家开发小程序依然是采用跟 React 相同的方式,就是说我先起个浏览器把页面写出来。反正就是 UniApp 帮我填了很多坑,我直接在 Chrome 上把应用先做出来之后,再扔到小程序里调一下,看看还有哪些地方不一样的,然后最终再把页面给弄好。所以你会发现,不管你用各种框架技术,前端工程师始终都是开个 Chrome 写代码,然后至于后边的事情交给框架就解决。
所以我们就可以把整个前端生态总结成三层,包括平台层、适配层,还有就是框架层。框架层就是 Virtual DOM,然后前端工程师再用 Virtual DOM 在浏览器把页面写出来。但是正是因为有这些适配层,然后去强行抹平那些差异,就会导致一个是还无法解决跨端差异,就尤其是像 React Native 这种不同端不同的奇怪行为。虽然适配层能够在一定程度上减缓这种情况,但是它不能彻底解决。还有就是小程序它提供了一种更奇怪的方式,以至于会发出更多奇怪额外的东西,导致你的应用变得很臃肿。
我将按照您的要求整理内容,尽量保持原文的完整性和连贯性,不省略或总结内容。以下是整理后的内容:
Flutter能否解决这个问题?实际上,Flutter解决了一半。但是如果有了Flutter加WebF,那这个情况就变得不一样了。因为Flutter提供了一个非常好的渲染引擎,未来会有更好的引擎。同时Flutter整体的宣传管理非常优秀。在这个基础之上再做一个浏览器这样的环境,那么前端工程师最熟悉的H5模式也可以跑在WebF加Flutter之上。那么Flutter就能够彻底解决多端不一致问题,直接从根源上去杜绝了这种情况。
然后反过来使用H5运行模式,同样也可以去跑在uni-app上,因为你可以直接把uni-app的组件拿来build H5跑到WebF上。这个我测试过是可以work的。所以你就只需要用uni-app的前端框架,然后把你的业务写出来,然后以H5的模式编译跑在WebF加Flutter上。这样就可以去搞定客户端方面的内容。然后只需要你再切换成build mini app,那么剩下的就是小程序。我们就这样实现了真正的跨端,就不会有那些各种各样问题的跨端场景。
正是因为有了这些跨端方案,它们其实都提供了向后兼容浏览器的编译模式。不管是微信还是React Native还是说是app,它们都有H5运行模式进行构建。刚好浏览器是一个完全由开放标准所约定的运行环境。因此基于这个同样的标准,浏览器不管是Chrome还是Safari还是WebF,我们只要遵循这个标准,那么这个上层应用代码就可以跑在各种各样的底层上。所以才使得像WebF这样的技术成为可能。
刚好WebF底层就是Flutter,Flutter它的渲染性能会相比于普通web好一些。因此我们未来是可以比普通的web可以快到30%到50%。不知道大家有没有了解过像微信的Skyline这样的新的小程序渲染引擎,他们也是基于Flutter做了一个类似于WebF这样的东西。可惜他们并没有开源,但是通过我的调查发现,他们其实做了跟WebF相同的事情。同时Skyline也比WebF有更好的表现。
这是其中一个使用案例,比如说像VIVO游戏VIP,他们很早就使用WebF来做一些他们线下业务。像右边这个图就是一个非常典型的场景,他们目前使用的方案就是Vue和一些公司内部的组件库。之前是使用web来进行渲染,这个业务使用了WebF之后,在安卓平台上可以获得大约30%到40%的首屏提升。
介绍完WebF如何去连接Flutter与web生态,我们接下来看另外一个分支,就是2024年WebF要做的另外一项事情。我们要去补齐它在一些客户端能力上的短板。首先前端生态里有一个特殊分支,就是像Electron这样的技术,它是使用web技术去开发客户端应用。当然它并不局限于桌面端,虽然一开始是一个专注于做桌面的方案,但是它的整体思路其实就是通过一个浏览器引擎加上一个可以操作系统API的运行时。Electron选的是Node.js。那么如果我们把Electron里面的Chrome给换成WebF加Flutter,那么这个Node.js它应该要替换成什么呢?因为我们也希望给使用WebF的前端开发者以一种可以调用系统API的能力,包括获取文件、访问网络这些相关的东西。但是似乎并没有相关的技术方案在这里出现。
所以说我们要把这样的场景进行剖析一下。比如说Electron,它在整合了Chrome和Node.js之后,它其实主要做的事情就是做一个app的壳子。这个壳子里面有各种调用Node.js、Chrome的组件,然后还有很多原生的功能,包括视窗管理、资源控制,还有一些native的应用相关特殊功能,比如下拉菜单这种。然后剩下的主要的UI渲染就是通过前端技术HTML、CSS、React、JavaScript来完成。也要通过打包一个Chromium内核进去,就可以实现完全统一的UI绘制,还有各种各样的页面资源管理。同时JavaScript环境还有一个独立的进程,就是Node.js进程。然后你可以在里面使用Node.js去访问各种系统功能,包括获取文件,以及使用Node.js的C++ addon(native addon)去调用很多原生C++写的扩展。
然后通过这样一套组合,我们就可以把这样一套技术的能力范围划分成右边这些内容。因此我们后面要专注于讨论native语言扩展和扩大平台能力这方面的短板。
同时,和Electron类似的技术,叫Tauri,在近两年也非常流行。Tauri是一个使用系统内置WebView加上Rust这样一套整合,做了一个类似于Electron的替代方案。Tauri相比Electron最大的优势就在于它非常小,因为没有Chromium内核,同时也没有Node.js(Node.js也非常大),只有你需要用的Rust代码,就十兆以内就可以搞定。因为也没有额外的第三方库在里面。
但是通过仔细分析,你会发现,Tauri其实也就仅仅是做了和Electron相同的工作,就是做一个壳子。至于里面的内容都是第三方的东西。它只是做了一个窗口管理,还有各种native应用特殊功能。然后HTML、CSS、JavaScript依然是通过浏览器去实现,但是它使用系统内置浏览器,所以在不同的操作系统会有很多明显的差异。不过这里仅仅局限于在Linux这样的一个特殊场景,因为Linux使用的内核通常是像WebKit、Gecko(Firefox)这样的内核。但是像最新的Windows和Mac分别使用WebView2和WebKit,这两个内核之间的差异就很小。然后底层就是直接使用Rust去访问系统自带的那些系统能力,去访问各种I/O。这个就是Rust本身的强项,因此它可以很轻松地去解决。
那么如果我们把Flutter引入之后加WebF,我们是不是可以做到相同的事情呢?首先Flutter是支持桌面应用开发的,还有Flutter for Desktop。我们可以使用Flutter Desktop做出一个像Tauri或者Electron的壳子出来,来完成各种各样的相关功能。然后WebF就负责提供HTML、CSS,以及JavaScript支持。那么如果WebF同时提供了像native addon这样的新能力之后,我们就可以直接使用Rust去编写很多跟平台有关的、但有performance相关的代码去帮助Flutter和WebF去获取很多原生相关的能力。
虽然说使用Dart依然也可以搞定system I/O,但是Dart和JavaScript之间的通信效率实际上并没有直接使用Rust来得效率更高。这个是受限于Dart VM的影响。因此对于像这样的组合,或许system I/O可以同时选择向上Rust或者说Dart来完成。
同时Rust在开发web应用方面,其实已经有好几年的相关积累了,也有相关的像类似于React的Rust版本框架出现。比如说像Dioxus就是一个类似于React的Rust版本框架。你可以看到这里面的一个示例,就是非常类似于JSX,但是它是Rust实现。甚至它连Hooks这种API都给你支持。
但是像这样的一套技术,它其实是基于浏览器的DOM API来实现。因此它是需要通过JS转发调到DOM API,然后去操作UI。那么它会就存在很多问题。首先Rust需要先编译成WebAssembly的格式,才可以跑到浏览器里。但是WebAssembly的格式相比native格式要慢到1.5到2.5倍,就只有不到一半的速度。同时WebAssembly要获取到DOM,就必须要绕道JS去。就要先又要走一遍WebAssembly到JS这样的壳子,然后获取去访问那些JS版的DOM API,才能够操作UI。然后性能进一步损耗。通过这两部分损耗就会使得即使Rust很快,但是它跑到浏览器里和JS差不多。因此就没有任何的吸引力可言,就不会有人愿意去用Rust写一个web应用。因为写起来也快不了多少,反而会增加你的开发时间。
但是如果说我们把浏览器给替换掉,换成Flutter,那这个事情就变得不一样了。因为Flutter提供的是一个完全的客户端的运行环境。那么Rust首先是不需要编译成WebAssembly就可以执行,你可以直接把它编译成native,变成一个像machine code这样的原生的native产物。同时Rust还刚刚提供了与C++相关通信的API,这个通讯API和WebAssembly to JS它的差距非常大,可以说是接近零损耗。因此在浏览器环境下Rust应用所面临的问题,在Flutter这样的环境中就完全不存在。你可以直接从Rust调到Flutter提供的C++、DOM和Web API。然后后面就是真正的渲染过程。既不需要跑到WebAssembly,也不需要绕道到JS,就直接调用这些暴露给Flutter的API。其实是按设计用来给JS提供的,还是通过另外一条链路暴露给JS。可以说在Flutter环境下,Rust应用和JS是走了完全两条不一样的道路,但他们其实最终都会去调用DOM API。
好,然后我们总结一下。就是当WebF在支持了原生Rust应用之后,像Dioxus这样的应用,它就可以获得至少两到三倍的性能提升,带来完全不一样的性能表现。应该说这是一个非常好的标准事件,因为当你愿意为牺牲你的开发效率,换一门编程语言去开发一个相同的应用的时候,我们必须要获得更好的性能表现,才会让这项工作变得值得。但是过去的浏览器Rust并没有做到这一点,值得你去投资这门新语言的行为。但是Flutter加WebF却可以很轻松地去做到。这就是WebF要到2024年去原生支持Rust所看到的一种技术潜力。
通过这个图可以看出,这就是相同的Rust代码,你只需要使用不同的编译方式,就可以在不同的平台获得几乎一样的渲染效果。
好了,我总结来看。前面的分享介绍了我们在WebF将原有的Flutter与JavaScript的生态粘在一起。那么在未来尤其是今年WebF的开发计划中,我们需要将JavaScript世界又和Rust世界粘在一起。因此我们就可以最终在WebF在2024年底的时候看到像现在这个图中所呈现的一个样子,就是一个更大的技术生态板块。它融合了Flutter和过去的所有技术积累,同时又融合了Web应用生态的各种类。你可以使用Vue或者其他Web框架去开发一个应用、扩大应用,同时还可以使用Rust去做一些性能上的增强,尤其是在一些特殊的应用场景下。当然大部分应用肯定还是会选择使用JavaScript。
好,然后这就是未来WebF要做的很多事情。那么现在究竟到哪一步了呢?所以现在的WebF完成度大约是在70%左右。然后像Vue其实是可以直接跑在WebF上,但是随着包括像VIVO这些VIP公司,我们的内部的测试的一些反馈来看,其实现在还差一些。比如像CSS的伪类的支持还并不是特别完整,目前只支持5~7个相关的伪类。像:before、:after还是支持,但是像:has这种更加新的CSS标准都还没有。同时CSS选择器的性能相当于浏览器还有一定的距离,它也是目前导致WebF在特定场景下不如Web的一个主要原因,就是因为页面用了太多的选择器,导致性能衰减非常明显。但如果你减少使用那些比较少用的选择器的页面,WebF的表现就会比浏览器更好。
还有一个就是,目前WebF只提供了QuickJS这样一款解释模式进行JavaScript引擎,所以说我们也做了很多针对QuickJS的优化。但是在安卓上面,JIT所带来的性能增速是非常明显的。
因此,我们今年也会提供对V8和JavaScript的支持。还有一些特殊的布局场景,比如图文排版,这个应该快好了,还差一点点。另外,z-index的支持也并不是很理想。
完整支持HTML和CSS是一个非常复杂且庞大的工作,我们还需要在2024年继续努力。2024年之后的计划如下:
首先,针对CSS目前的短板,我们会推出全新的CSS 28.0版本。这个版本会把现有的大部分相关实现移到C++那边,既可以利用独立线程的优势,同时我们也会尽可能移植Blink相关的代码。即使大家使用WebF,你的HTML和CSS依然是运行在与Chrome一样的C++代码之上,所以你也会得到相同的行为。CSS 28.0版本将基于Blink已有的实现,然后再整合Flutter来完成。
第二,前面提到的Rust生态支持,目前Rust都还处于概念阶段,所以这也是今后要做的事情。
第三,JavaScript和V8支持,以及图文排版上下文等功能。
关于目前使用WebF的限制和挑战,以及是否可以在生产环境中使用:现在已经有一些公司在生产环境中使用了。但实话说,在目前完成度70%的情况下,想要应用到生产环境是需要付出一定代价的。这个代价主要是CSS适配成本。除了适配成本之外,你是可以直接把它用于生产的。但这个过程可能要取决于你们公司目前使用的组件库,以及你们使用的一些样式与目前WebF所支持的CSS之间的匹配程度。
所有WebF支持的CSS都可以在OpenYF官网上查到,这里面有所有CSS属性支持的列表,还有各种与前端相关的功能。在目前这个阶段,遇到一些样式问题还是非常常见的情况,因为我们并没有公开宣称我们的样式支持已经很完整。但是相比React Native还是强了不少,因为至少有非常完整的盒模型、Position、Flex,还有各种CSS单位以及选择器。在CSS单位上,WebF支持非常全面,像百分比单位、rem单位等都可以得到完整的支持。
现在在生产环境中使用WebF的体验,有点像把React Native和Web进行兼容的工作。因此,你现在需要付出一些时间来完成这项工作。但是一旦团队迈过了这个坎,拥有了一套同时可以兼容WebF和Flutter的组件库之后,后续的工作就会变得很轻松。之后就可以直接把你的Web换成WebF,然后上线就好了。
目前WebF的运作模式是有几个VIP公司。开源社区的反馈其实很少,更多的是通过微信和私下交流来完成,包括我会查看他们的生产代码。其中像VIVO这样的公司属于非常活跃的开发者,还有滴滴、唯品会等,这些都属于目前WebF的VIP公司。我们保持非常密切的内部合作,共同推进这个项目,同时他们会给我分享很多线上数据。
对于考虑在下个项目中使用WebF的Web开发人员,有以下建议:目前选择WebF的技术团队都有一个共同特点,就是他们希望寻找一款性能比Web更强、同时扩展能力更优秀的方案。所以如果你们的团队也希望能够更大程度上提升应用的加载时间,同时又不想改变现有的开发方案,那么WebF将会是你最佳的选择。
WebF的设计初衷是提供一个Web的替代选择,但要把性能做得更快,同时提供更多的扩展能力。比如说像Rust这些都是属于增值功能,但它最核心的竞争力在于要做到比Web更快的渲染效果,包括首屏时间和滑动性能等。
9 个Rust 最佳实践 - Part2
转载自:https://www.youtube.com/watch?v=LRfDAZfo00o
这是我在构建库时使用的 9 个 Rust 编程最佳实践的第 2/3 部分。 我们将继续使用一个真实的库 genai 作为例子。
这里介绍9个构建复杂时的最佳实践。第一集我们看了前3个,这一集将讨论util的最佳实践,包括扩展特征模式示例和测试。我们仍使用geni作为实际示例。
首先谈谈util。我使用两个术语:
utils:用于共享于crate之外的东西。例如在source目录下有util.rs,我将所有util放在下面。在source目录中,mods下会有b模式,Util.rs。
support:仅对crate或模块私有的utilities。实际上更多是关于模块而不是其他任何东西。
对于库,如果可能的话最好避免使用utils。但在多crate项目(工作空间)中经常会大量使用。通常会有一个lib_util crate,在其中按功能组织所有想要的utilities,如64位UUID等。这些可以在所有其他crates中重复使用,非常有用。它移除了一些常见代码,并统一了所有crates中的规范化行为。例如,当你使用UUID时,你想以一种方式使用它,而不必向所有人暴露整个UUID crate。
对于support,如果它很小,我可以将每个单独的函数放入一个文件中。或者如果这些小utilities有相同的名称,我会创建一个support mod。这取决于函数的数量。这是模块内部私有的utilities。例如,我们可以有一个source/adapter/support.rs,它仅由adapter内的代码访问。这样我可以有不同级别的utilities。当我看到support时,我知道这是一个内部空间,支持任何功能,并且仅属于这个模块结构。
让我们通过geni库的例子来看看这是如何工作的。在lib.rs中,我们看到有一个mod support。这意味着它仅属于lib.rs所在的根目录。根目录以下的每个模块都可以访问它,只要support内的某些内容是公开的。我曾经也有一个pub mod utils来遵循之前讨论的模式,但通常对于库来说,我们想要避免这一点。因为从很多方面来说,我们的库本身就是一个工具,所以在这里再有一些utils会有点奇怪。这就是为什么我使用两个不同的名称,因为仅仅通过查看我的库的代码结构,我就可以看到我有support,所以这是我的内部工具,我的私有utilities。但现在我没有公共utilities。
让我向您展示我之前的做法以及我是如何改变的。在geni 0.15版本中,我在开始时有一些pub mod。我也有我的utilities在那里。这意味着它在根目录,并且按照我的惯例,这应该被导出。如果我不想导出,我就把它放在support中。这仍然是最佳实践。其他模块也可以有自己的support。
但这里的重点是,我决定在这里放置mod utils,因为我有一些打印工具,如聊天打印机,这并非核心功能。这些更像是调试工具。所以这就是我在这里这样做的原因。我们可以看到功能,我们只打印一个流,然后我压平了那些工具,确保我暴露的内容清晰。那很好,但有点牵强,因为现在我遵循这种潜在的反模式,试图有一个utils,实际上并不需要。
所以在最新版本中,我移除了pub utils。我们不再暴露那个。我把它放在chat模块中。我有chat.printer在那边,然后我有mod printer。这个我现在不压平,我只是把printer留在那儿。所以我认为utils对于库来说是一种例外,它可能在某些情况下适用,但更多的是例外而不是规则。
我认为这是一个非常好的最佳实践,特别是当你做一个多crate项目时。让我解释一下。我要给你看一个未来的项目,它是一个多AI提供商桌面应用程序,名为Tory。因为这是一个相对较大的应用程序,我喜欢多crate方法,这使我们的代码能够很好地扩展。对于这类应用程序,我总是有一个lib crate。我经常在前面加上lib_,这有点粗糙,但当我们导入时,它非常清楚。我经常有,如果不是总是,有一个lib_utils crate。这是我将要在所有其他crates中重复使用的工具。
例如,在lib_utils中,我们有base64的东西,一些宏工具,然后是我们的特征扩展模式,还有我们的UUID。在lib.rs中,我重新导出它们,我不展开它们,因为我想它们主要独立可用。我甚至没有通用错误类型。例如,在base64.rs中,这个有自己的错误类型,因为它很小,我不想有一个mod和错误的文件夹,所以那里只有一个函数和一个错误类型。
这也允许我标准化我想要支持的错误类型或在我的应用程序中表达的错误类型,而不过于依赖我使用的库。所以这里我抽象了大部分内容。我这样做的一个原因是,通常我喜欢所有这些crate相对独立。例如,对于UUID,我决定对于我的应用程序,当我创建新的UUID时,我想要UUID v7。这可能不适合所有人,但我不需要在每个地方暴露这种灵活性,我现在要标准化它。
我有一些宏,如machine_id,这是一个帮助派生东西的宏。有点黑客,但对于大的派生可以帮很多。这不是很灵活,我不是说这是最佳实践,我仍在尝试。然后我有一些宏助手,用于我的字符串处理。我对这个不太自豪,但直到我有更好的替代方案之前我会继续使用它。然后是一些对Option的实验。我不是说那是好的,但核心是这个lib_utils允许我实验和拥有自己的小工具代码。
所以这是我在多crate的上下文中处理utilities的方式。这工作得很好。
现在如果我们回到geni,我们现在有printer在那里,所以我们现在没有utils了。这是好的。然后我们有我们的support。在lib.rs中我们只有两个模块。这是一个mod,它只包含value_ext。通常当我在根目录有一个support时,我直接从一个文件夹开始,因为我知道那可能会增长。我保持简单。
然后在这里我们可以看到我们只有adapter的support。甚至在adapter内部我又有support,并且我知道根据我的惯例那只是为adapter模块服务的。我从未将它们展平。然后在内部我决定我想要做什么。在这种情况下我有这个小类型,我本可以创建一个模块stream_option,那也会起作用,但在这种情况下我想把所有东西都放在这里,因为我有另一个类型。所以我不想在那里有太多的小东西。
所以通常如果它是utils或support,它们对我来说总是以同样的结构组织,只是一个是跨crates的,另一个只是在模块内部。
然后在Rust中有一个非常酷的模式,那就是扩展特征模式。在去那里之前让我们理解什么是扩展特征模式,以及它为什么可能非常有用。
首先要理解什么是扩展特征模式,让我们有一些术语:
- 如果我以My开始一个类型名,那意味着它在同一个crate内。
- 当我开始一个类型名与Other时,那将意味着它在crate之外。
到目前为止都很好。所以现在我们能做三件事,并且有一件事我们不能做:
显然我们可以为MyType实现MyTrait。这是显而易见的。
我们可以为MyType实现标准库中的所有trait,或从其他crate中的trait。这是我们实现Default、From等trait时所做的。
我们可以为其他类型实现我们的trait。这就是所谓的扩展特征。这是因为我们在获取其他类型,然后我们正在扩展它的一些行为。这对工具来说特别有用。
然后为了完整性,有一件事我们做不到,那就是为其他类型实现另一个crate的trait。这是因为在前三种情况下,编译器总能确定谁为谁实现了什么,但如果两个外部库为同一类型实现相同的trait,我们将没有办法解决这个问题。所以那真的行不通。
这里有一个为事物实现特征的概念,这就是为什么我们有时需要newtype模式。但在这种情况下和扩展特征中,我们有trait作为工具,然后我们可以扩展一个外部类型,这很好。但在Rust中,没有其他方法在另一种类型上实现另一个函数,除非通过定义我们自己的trait。
好的,所以现在我们可以回到geni,我们将进入我们的value_ext.rs。我喜欢的做法是,我们将用Ext后缀命名我们要扩展的类型。这在Rust社区相对普遍且被接受。然后在这种情况下,我喜欢有匹配的文件名,显然带有_ext后缀。到目前为止还不错。有时我可以将多个扩展特征放入同一文件中,这更罕见。有时我会有同一个扩展特征的多个实现,例如,我可以有一个反转字符串的特征,String和str实现相同的扩展特征。
我的第二个最佳实践是,定义trait时用X_前缀。无论名称如何,选择与函数名、类型或其他trait实现冲突概率接近零的名称。因为这是内部操作,仅用于支持性事物。所有以X_开头。但是,当我做多crate项目时,然后我知道当我以X_前缀时,那是我的小工具。这种方式也有助于自动完成。如果你已经实现了这个特征扩展在这个类型上,这很好。显然因为特征实现的方式,即使你有冲突的名称,你可以用一些符号来区分,但代码会变得相当复杂。
显然ValueExt将需要被导入,但通常我们使用的IDE和语言服务器做得很好。然后我可以使用我自己的函数。然后根据用例,我有self的引用。我尝试获取str的引用,当它只是一个字符串时。在这种情况下我正在获取一个值,所以我要获取mut self的引用,所以我不想在这里消耗它。它不会像有一个mut的引用那样自由。
然后我有一个很酷的insert方法,它将获取一个带有name和value的&mut self,基本上是一个Map的小包装,它也对value进行序列化。它只是一个方便的方法。整个东西都是为了方便。
然后我还有另一个x_pretty方法,它允许我们将相邻的值转换为漂亮的字符串。这对我们的测试很有用。当我做打印和所有这些东西时实际上都很实用。所以我注释它允许使用。通常我试图避免#[allow(dead_code)],但在这种情况下是合适的,因为我不想它只用于测试,我想能够在其他地方使用它。我在一些地方有一些调试打印。
所以这是我的扩展特征定义。现在在我们的特征实现方面,其中一些实现可以非常简单。例如这个x_get几乎像一个普通的get方法,但有几个优点。例如,在这种情况下我允许path只是一个属性名,所以会是这样的。但如果它以斜杠开始,将使用JSON指针路径。因此无需重新实现JSON指针规范。这允许我深入获取值,JSON值非常酷。然后一旦获取值,另一个好处是它会设置相邻值。显然返回的T需要反序列化,因此可以无需做这些事就获取值。
我经常使用的方式是,例如,如果我有data是一个Value,并从请求中获取,我可以有total_tokens,然后设置两个。我给定类型,我只需执行data.x_get()。将会有自动完成。如果有自动完成但未导入,然后可以导入。我们只需按下.就可以了。因此非常简单。然后可以说实际上深入,所以我要说使用到tokens。然后这是一个可能返回的错误。然后仅通过这样做就节省了这几行代码。这使得工作更加愉快。
现在下一个酷例子是take,但尝试避免做克隆。所以使用Value的take方法实现。
我已经整理了您提供的内容,使其更加合理通顺。以下是整理后的版本:
我没有实现这个功能,但可以使用相同的逻辑来传递指针或属性名。我会将获取的任何值转换为所需的类型,这样就避免了克隆。我们现在可以决定稍后再做,或者作为团队决定不使用value take。因为value take会获取值并将其放在附近,而不会从父容器中删除属性。所以我们可能想要的策略是找到容器,即mute本身,如果是属性名的话。如果不是路径的父节点,那么就完全删除该属性。这将是不同的实现策略,但在我们的情况下,我们并不真正关心属性名是否有值,这不重要。那只是实现细节,其他代码无需担心。
这里有一个很酷的X插入功能,允许插入和自我更新。显然名字也可以是路径,将重命名为”我”,路径保持一致,值可以是任何类型。我想在函数中使用泛型,因为这是有用的工具,会生成更多代码。如果成问题,可以把所有逻辑放入内部实现中。从API角度来看这非常灵活,同时支持属性名或路径。这里获取路径的逻辑是,如果需要就创建父节点,然后设置实际的值。
我将使用这种方式来创建发送给AI提供者的载荷。有一些获取消息的逻辑等。如果是流式传输,可以设置载荷中的X插入为true。显然可以直接设置流式传输,因为那是布尔值。在这种情况下,可以决定仅在流式传输时设置,否则使用默认值。也可以像这样做,这也能很好地工作。这个小API是用来获取X插入的,使使用更加方便,同时保留了Cell API的全部功能。
这就是我喜欢你的超级名字的原因,这种类型的东西是实用优先然后追求优雅。好的,这将结束我们的新层章节,现在可以谈论我们的示例和测试最佳实践。事实上它们是相关的,让我解释一下。
我认为示例显然用于文档,但这也提供了测试API易用性的机会。然后是那些测试,单元测试或集成测试将允许我们测试API以及行为的实现。所以从很多方面来说,这是在测试外观,也在测试幕后。显然,这里的例子和文档是一个关键点,通常是库的入口点。所以通常当我学习一个新库时,我喜欢去看示例,然后看那里的代码。那通常是在阅读说明后了解如何使用的良好入口点。
在我现在的库中,我喜欢有一个小故事骨架,像这样,其中c代表章节可以是所有东西。我有一个数字填充像这样,有时我可能有破折号,然后这里有数字。如果我想这是一个README,我确保我的README中有示例,并且示例总是能编译。这是一个很好的方式,不让它不同步。所以我只是复制粘贴,但至少我确保它能比较。然后人们可以像这样运行README。
我有一个用于定制身份验证的对话,对于数据类型,哪些是选项和模型名称。在我的README中,如果我们向下看,我们有我复制粘贴的例子,Cargo run –example。然后我有更多例子,我将描述每个库和他们的目标。所以这就是我现在做的,那不必那样做,只要有良好的常见用例示例,以及您拥有的任何一致的命名转换,您现在就可以开始了。
我的第二个最佳实践是我不选择unwrap,所以这又是我在我的Rust错误最佳实践中解释的。我也不使用anyhow。所以那是我作为错误的示例,但我的测试错误也会是一样的。我使用Result<(), Box
我的第三个最佳实践是我不使用宏。管理方式的原因是不展开,代码中剩一个展开,复制粘贴等。应用实际会崩溃,我们的目标是构建健壮代码。如果新手犯错,无意中崩溃线程,可能成大问题。这种模式不好,不是生产代码的做法。
我喜欢有Result,不做类型别名,定义任何东西,如主要Result,无装箱错误。现在可随意使用,大部分问号。如果去那里有await,无缝工作。甚至Ok或给静态字符串,或格式化字符串,问号等都能工作。这种模式非常灵活。
这就是我所拥有的,目标是尽可能有指导性,展示流程。此例有流程,一个问题,聊天请求客户端,打印选项打印东西。用户应能在终端运行cargo run –example caesar_zero_read_me。实际上我编码时,有cargo watch像这样,为什么安静,清除,执行,然后运行。我的例子像这样,让我能快速感受人机工效。
在库开发初期,我可能有些人不同意,我更关注API的人机工效,而不是测试行为。这看起来有点疯狂,但重点是至少要有API核心流程,我想要有的,并确保正确。对我来说,例子是因为我把自己放在库用户的头脑中,它允许我真正专注于API的人体工程学。然后我会确保它工作,然后最终显然我会写我的单元测试或我的集成测试。
对于所有这些,这里有一些别名,Mac上的别名。例如,我不必输入这个长命令行,所以例如,在我的zsh文件中,我有一个这样的函数可以调用。我将只做,例如,在我们的情况下,我将做一个cw E见零零与我,那将启动一切,然后我们将做安静,然后例子等等。那就是我喜欢做的,有时我只做,当它工作时这个我不得不这样做。所以作为一个函数它工作得更好,但你以同样的方式使用它。
然后就这样,所以这是我的对话,与AI的对话,改变解析器,制作自定义解析器,为特定适配器,OpenAI的配置,然后类型聊天选项等。所以我在那里有所有这些东西,尝试添加注释总是更好,有些总比没有好。
好的,所以这将结束示例方面的事情,现在我们可以去我们的测试最佳实践。所以用词我们有两种测试,第一种是单元测试,我们放在源文件中。它允许我们测试公共API以及私有API和私有模块。这非常强大,因为现在我们可以测试我们实现的内部,深度为我们想要的,而不必使任何东西公开,而不必做任何奇怪的扭曲。然后我们有集成测试,它进入一个名为tests的文件夹,然后我们把文件放在里面。这允许我们从外面测试,所以在这种情况下是公共API。
所以现在我喜欢对于集成测试的方式是,我喜欢把所有文件像这样。在我的tests文件夹中我有我的支持文件,有时经常。这允许我提供一些助手,我可以在那里重复使用。所以这允许我让我的测试代码更清晰,因为现在无需重复常见内容。所有测试文件以_s结尾。此库测试的内容,因为我有提供者的概念,我决定使用前缀,像这样,以p表示提供者。所有提供者都如此,相同的测试类型,正在测试的提供者。因为之后,我可能还有其他与提供者无关的测试。
例如,查看OpenAI实现,我们有测试镜头。简单好的测试镜头JSON,以test前缀。这些是tokio测试,因为它们需要是异步的。如果不需要,不要放tokio测试,只需放test。然后在这里调用我的注释测试。通常不为所有类型的库这样做,但在这个设计中,对所有提供者都是相同的逻辑。然后唯一改变的是模型名称。实际上,如果你看这一个,我们有这些人,然后对于Alma等等也是如此。
我这样做是因为当我进行测试时,当我进行实现时,我这样做。逐个提供者,然后检查它对所有人都有效。所以使用cargo test,我们可以做cargo test,然后我们可以做测试。然后我们可以给一个第一个过滤器,这将是一个确切的文件名,所以那将是我们的测试。P和_open_ai,所以那将运行所有。所以通常我做那个来调试,然后我们可以做测试。稍后您将看到所有函数的前缀,我想运行所有函数确切,所以我做一个text_chat。那将测试此文件中的所有text_chat,或者我做测试流。或者如果我想测试特定函数,通常我有个简单的类似的东西。
我经常做类似的事情。如果不做货检,我就做货运。我会看QCX然后做那件事,然后做无捕获。这就是我通常做的事。
实际上我不总是打出所有这些字,因为那会很繁琐。我有一个功能允许我只输入”cw t”和测试函数名,这样就能在所有五个单元测试中使用。或者我可以输入”cwt”加上一个测试文件名,实际上是带s的测试,然后是我想测试的前缀。或者我只输入”cwt”,它会执行货运或无捕获测试,在所有版本中都会执行无捕获测试。
新的捕获功能非常有用。我的工作流程是:先做货运清洗测试,然后添加大量打印语句确保一切正常,然后把打印改成断言,这样就完成了。这种方式运行起来很顺利。
显然我多次修改过这个函数,现在的写法可能更聪明一些。我大量使用GPT或LLMs来帮助我创建它,因为我并不完全了解它的原理,但效果很好。
这是目前组织代码的方式。如果我们看测试部分,会有导入语句等等,然后是mod支持。重要的是每个文件都是独立的,以某种方式创建自己的二进制文件等。所以你需要重新导入mod支持,在这种情况下只是为了再次使用。
这个库因为我有通用测试,这不是我通常用于库的做法,但在这个情况下它很合适。所以我把所有地方都重复使用的通用测试放在这里,除了mod名称在里面。这样我就可以只拥有函数,不需要mod名称,然后执行结果。所有最佳实践都很灵活。
然后我有模型,它们看起来都很相似。在这种情况下实现就是测试,因为需要一个接收器。我的命名规则是:测试_[我想测试的内容][测试的变种][测试OK或错误]。在这种情况下只是简单测试,我做文本聊天JSON测试,两者都正常。在这种情况下做TED聊天流测试,简单捕获内容和捕获所有内容。
因为这些库有意义,所有实现都在同一个文件中。如果我去那里,我可以看到所有聊天测试,然后是流测试。内容应该在那里,如果没有这种模式。但对于特定库,现在如何组织单元测试内容呢?
我在注释中使用VS Code的区域功能,然后有类似这样的部分。这只是最佳实践。测试的第一部分是设置和夹具,定义变量和进行设置。我看到数据,我经常有一个seed_放入fixture中。当我开始第一个测试时,这只是一个我在行内做的东西,然后经常重用。
然后放入我的系列测试,接着是执行部分。我说我在这里执行,但实际上是在那里测试。然后做检查。现在很酷的是,当我们做cargo watch等操作时,在检查开始时实际上有一个打印语句。我用rest和x扩展有一个小的调试打印,我用”调试打印”作为前缀。这样我总能搜索并查看是否有遗留的调试打印。如果有任何剩余的,我会确保删除它们,不要出现在提交中。
然后可以进行聊天测试。与cargo watch配合使用非常棒,现在只需编码,只需执行一个coo命令,观察测试的cdt。函数正是我想要的,然后可以转到适配器那里。我按保存保存保存,我看到打印打印打印,当我满意时,就把打印改成断言。显然在调试时,使用assert!()宏,或者创建一个新的调试打印部分,然后将调试打印放在那里。
这就是我的做法,非常快速高效,因为非常直观。你不必太担心阅读代码,如果你开发一些基于相同实践的东西。这就是我们的测试方式,简单地测试一些令牌,然后获取内容确保相邻内容正确。
所以这是一个mod。有时在这种情况下会取另一个变量,因为ulama现在有bug。如果我们使用一个魔术师模型,它不返回令牌,这不是大问题,因为它不会导致崩溃,但检查使用总额很重要。那将会完成测试。所以这是我们在这里做的一件事,确保我们的测试对AI模型的变化有一定的灵活性。
这基本上就是我在这类事情上的实践。我们有一些宏规则,这是一个不太优雅的做法,但它允许我获取结构的属性,断言它不是None,如果是None则抛出错误。这将是一个应该有的检查。当我们stringify字段时很有用,例如我们不必总是使用unwrap()或expect()。
然后再次,因为我们使用闭包,非常棒,因为那将非常有效。然后它将返回我们获取的值。所以这只是一个不太优雅但很方便的做法。再次强调,它的支持总是私有的,所以不要因为编写实用但不太优雅的代码而感到难过。我们可以之后清理并使其更优雅。
这些是契约助手,因此我在这里使用带有新层和支持的助手,因为那只是其他事物的集合。然后我仅提取流,这是我经常在评论测试中使用的。那就是我做的。
然后我所做的最后一个有点像是种子模式。再次强调,它更像是一种惯例。为什么我有一个seat?聊天请求很简单。我把所有种子都放在那里,我经常使用它。同样,当我进行数据库测试时,或者无论我有什么,我都有播种器。例如,这样我就不必到处重复相同的设置代码。
好的,所以我认为这就结束了这部分内容。实际上,最后一件事我想说的是,有时对于值我会添加前缀。例如,如果我们想测试结果,我会创建一个fx_返回来转换令牌,像这样。所以当我创建fixture时,那是一些我可以在这里使用的东西,然后检查它或做其他操作。
好的,所以这个内容很长,但那种结束了回归测试的部分。现在我们可以转到单元测试,基本上与相同的模式。我们将有设置,精确检查,所以这并没有真正改变,只是我们放置它们的位置稍微改变了一点。
说实话,这就是全部内容了。我专注于集成测试,特别是在开发初期,而不是深度单元测试。有两种测试策略:关注覆盖率百分比(显然更高的百分比更好),或者说让我们编写所有可能的测试。不写单元测试就编写函数并不意味着你不是好开发者。
那是可能的做法。另一个选择是更实际一点,开始时多写一些测试,当我们进行智能覆盖时,不仅仅是覆盖所有功能,而是关注所有可能出问题的地方,或需要不断重新验证的地方。
话虽如此,这里有一些单元测试我应该编写。这可能是这里的代码之一,所以可能已经在util里了。特别是这个,这里获取然后插入,这是一个相当大的错误机会。像这样简单的操作可能不会真的失败。
是的,所以如果我回顾一下,这个很简单,可以放在这里。我猜我在这里遗漏了一些内容,最终应该测试一下。我不是说不应该测试,但我不会专门写测试来获取第一个元素。我会用GPT来写那样的测试,但我想测试那两个主要功能,特别是插入操作。
我将要采用的方法是:我在这里有一个约定,我仍保留这些结构。这对我们来说很熟悉。我将使用我的VS Code Roten X扩展,从1开始编号。我有一个测试模块,我想做类似这样的事情:我将创建test_01,这基本上是做配置测试。这是Rust中进行单元测试的方式。
我将在行内定义修改后的测试,带上s后缀。我们可以把测试代码存入文件,就是同一件事。或者如果测试代码太大,把它放在单独的文件可能更好。通常我喜欢把测试代码放在内联代码中,我喜欢使用折叠部分,这只是我个人的偏好。我仍然遵循最佳实践。
我有错误类型别名和结果类型。我没有特别的原因这么做,是的,那也会很好。我们只关心void返回类型,无论如何,我们不关心Box类型。有时我喜欢用super::*导入,那可能包括父模块中的错误类型,这样可以确保一切都匹配。当我处理错误时,结果类型将匹配错误类型,不会有意外情况。
所以我这样做测试函数,与之前相似。在我的Rust和VS Code扩展中,我有一个f快捷键,基本上是创建带注释的异步函数。那将给我一个函数框架,我将编写测试代码。
嗯,我们来测试值插入功能。好的,例如,这里我将创建一个let绑定,我可能命名为fx_value。也许,然后我将创建一些测试数据。在这种情况下我给它一些令牌,我不知道为什么选择令牌,但就说三个令牌吧。这将创建我们的测试值。
然后因为我们可能使用super::*导入,我不用担心找不到类型。像这样,我不用想太多就可以使用类型。然后在这里创建我们的值,需要使用mut关键字。显然这样还不能编译通过,这里需要正确设置值。
然后调用x_insert函数,传入我们的测试数据。然后再插入一个”hello world”字符串。在值上调用类似halo_press的方法。保存代码,应该能编译通过了。可能是因为trait没有正确导入,导致编译器抱怨。可能是因为没有使用?操作符。
实际上我会这样做:这就是为什么我们创建fixture会更方便,我们可以创建一个effect_value而不是直接使用value。然后你知道接下来要做什么。然后我会获取那个值,应该能正常工作。然后我会进行检查。
现在的问题是:使用x_get方法还是手动获取值?这是可以争论的问题,但在这个例子中我会选择更简单的方式。我会这样写:let actual_value = value.x_get(“hello world”).unwrap()。实际上我会这样做,这样更清晰。这里有很多细节,因为我们用相邻路径做插入。顺便说一句,那样写可能行不通。
然后我们在这里进行断言。然后我会使用assert_eq!宏,比较actual_value和expected_value。可能需要将actual_value转换为字符串。然后我会在fx_value那里设置预期值,使测试有效。是的,然后我可能还会检查令牌数量以确保它没有被意外删除。
例如,我会这样写断言。完成后,我会运行cargo watch来执行这个测试。我希望它能通过这个测试。使用cw t命令的问题是它会运行所有测试,所以你可能想要一个只运行单元测试的命令。这样你就不必运行所有其他外部测试。那可能也是一个不错的选择,你可以改进cw t命令使其更灵活。
但这里就是这样,我们通过了测试。使用cargo watch的酷炫之处是,如果我们想添加一些调试打印,比如打印value的值,我们可以直接修改代码,按保存键,它会自动重新编译并运行测试。我们能看到打印出的”hello world”,一切正常。然后我们可以移除调试语句,测试仍然通过。
就是这样,这就是我的工作流程。实际上非常高效。这个测试我可以提交了。我将提交这个改动。我也有很多Git别名,因为我不喜欢输入长命令。我会使用git commit -m或者我的别名gc -m。我遵循最佳实践,使用点号开头表示这是一个小改动。提交信息会说”添加值插入测试”。然后通常我会执行git push origin或我的别名gpo。
就是这样,这就是我们实际的工作流程。这就结束了这一集的内容。在下一集中,我们会讨论何时同步项目文件和版本控制。感谢Craila的赞助。下次再见,祝编码愉快!
细说 Rust 结构体的所有权经典错误
在这段代码中,我们定义了一个person结构体,每个结构体包含三个属性:name(名字)、age(年龄)以及watermelon(瓜)。”你这瓜多少钱一斤呢?”
当我们在main函数里面新建一个person实例后,代码先从结构体的watermelon属性拿走了对应的值,最后又调用结构体的方法来改变结构体实例的属性。一切看起来顺风顺水,但编译器却”DUANG”的一下子报错了。
这就是正在深度挖掘Rust语言的小报。视频干货较多,建议一键三连加关注之后再观看。
String类型是一种动态分配的可变字符串类型。由于本身在堆上分配内存,使得它可以在运行时进行动态调整大小,持有任意长度的文本。在Rust的机制里,当我们将一个String这样类型的值分配给别的变量时,其所有权会发生变更,原先的变量不再拥有这个值的所有权。
这样的设计有效地防止多次释放内存造成的风险。因为如果同时两个变量指向同一块内存,当其中一个变量释放内存之后,另一个变量再去尝试释放内存,就会导致悬空指针的错误。
回到我们的代码案例中,再看一开始实例化的华强变量中的watermelon属性。在这里重新改变了值的所有者,所有权从华强的watermelon属性转移到独立的watermelon变量中。此时,由于华强的属性所拥有的值被转移,之前我们所提到的Rust的内存管理规则被触发,这个结构体已经变成了不完整的结构体。
当我们尝试从华强实例中调用sell_watermelon的方法(也就是卖瓜的方法)时,由于结构体已经是一个不完整结构体,同时因为我们调用结构体下的方法仍然需要访问结构体本身(也就是self),使得编译器判断这次访问是一个不安全的操作。因为值已经不在那里了,原来的属性已经成为一个空值,如果强行进行访问,就会出现内存错误。
说到这里,有帅哥美女就问了:”这是个什么设计啊?我在其他语言里不是可以随意操作变量吗?怎么Rust里面这么麻烦啊?那么这个Rust是非选不可吗?”
这里我们分为两种情况进行讨论:
首先,对于有垃圾回收机制的语言来说,变量操作已经被语言处理好了,代价是牺牲语言性能。但是语言本身会在特定的时间执行操作进行清理,对于一些高并发调用的功能来说并不友好。
其次,在其他无垃圾回收机制的语言里面,如果你可以随意操作变量,同时没有有效的机制来规避不安全操作的话,就会出现内存安全问题。像前段时间发生的全球Windows电脑蓝屏宕机事件,就是因为指针操作不当,导致这一场灾难性事故的发生。
在Rust里面,编译这一环节就能捕获大多数内存安全问题,从而大大降低运行时会出现的错误风险。这也是为什么网传”Rust的编译通过之后,就再也不用担心程序运行的问题”。虽然不能百分之百解决全部问题,但是在前人的作品基础上来说,也是个非常大的一个提升。
说了这么多,我们案例中的情况需要如何正确操作呢?其实处理方法也很简单,熟悉之后就能”信手拈来”。首先,我们在定义watermelon变量时,不像在Java或者Python里面一样需要直接传入值,而是在这里加上引用符号。此时我们赋予watermelon变量的值就是华强实例中watermelon属性的值的引用,而不是值的本身,所以所有权并没有发生变化。编译器也没有报错,程序也可以正常运行。
所有权操作只是Rust语言的冰山一角,但每次使用Rust的时候,都会让我感慨这个语言在工程上考量得相当周全。我想Rust一定是借鉴了其他语言的前车之鉴。
这里是分享干货的小报,欢迎大家在评论区里面友好交流。如果觉得我的视频有用的话,就赶紧一键三连加关注,支持我吧!我们下期再见。
Rust让你感觉自己是个天才
好的,我会按照您的要求整理这篇内容,使其更加合理通顺,同时保留所有原有内容不遗漏。以下是整理后的版本:
Rust让你感觉自己是个天才
嗨,朋友们,我的名字是克里斯,而且这不是boilerplate。我专注于快速技术视频。当我写Rust时,我感觉自己像天才,我会在片刻后告诉你为什么。
但首先,一个简单的问题是这段代码是否正确?嗯,这取决于你或你的同事是否想要完全确保这个函数总是能工作。你需要添加大量的boilerplate代码:你检查错误n的错误类型,如果不是一个数字,你将确保n不是一个如此大以至于引起溢出的数字。那么关于负数或浮点数呢?也许我们将确保n是通过值传递的,而不是引用,或我们捕获共享内存中n所在的部分的锁。
大多数语言对于许多这些情况都有合理的默认值,但你是否记得所有这些规则和边缘情况?当你编写代码时,所有这些不确定性,我们只是添加了一个数字到一个数字上,我们如何希望保持整个语言的假设都在我们的头脑中?我们需要一种符号来简洁地封装所有这些。Rust提供这种符号在其丰富的类型系统中,我们将在下一刻看它。
正如你们所知,Rust在过去的六年里一直在Stack Overflow开发者调查中排名第一的最爱语言。编译器的反馈是编写Rust满足感的秘密,我认为它使我感到像天才,我的代码在运行在生产环境中时第一次成功。
在告诉你Rust为什么使你感到像天才之前,让我看看其他语言如何使你感到愚蠢,通过迫使你进入什么,我将称之为错误驱动的开发。最坏的情况是没有错误,你运行代码,但它不是做你想要的,但并没有错误。你必须坐在那里想象为什么你不能得到你的错误,因为JavaScript肯定不会在没有注释的情况下告诉你。甚至TypeScript也不能帮助你。
比没有错误更好的是坏错误。让我们看看Python如何处理这个问题。你可能认为它做得很好,但实际上Python不会告诉我确切的这里有什么错误,它只会给我一些信息。Python只会告诉我一部分信息,而不是全部,它只是给我一些信息。这就是大多数语言典型的情况。
当然,一旦你知道在哪里寻找,你可以看到,列表索引超出了范围,但这里缺少太多。是哪个列表的哪个边界在哪里?甚至在第二行也是。动态类型的错误通常很坏,这并不是因为编译器作者没有投入工作,他们在尽力做最好的工作。如果你有一个简单的类型系统,编译器simply无法为你捕获许多错误。
顺便说一句,Python似乎捕获了这个错误,但实际上它没有。再看一次,这个错误发生在运行时,你的用户捕获了这个错误。如果你幸运,你的测试可能会捕获这个错误。
这就是它应该看起来的样子。Rust的错误是我见过的最美丽的,只要看看它。我们有错误本身,值不应该超过三,但实际上值是六。我们不必记住值是什么,编译器已经告诉我们。Rust的错误发生在编译时,这是一种华丽的说法,意思是它们必须在开发者的机器上发生,不在某个服务器上。
让我们深入一点,说如果我想在循环中打印一个数字以供调试目的,但是whoops,就像在C中,数字需要格式字符串来打印。看看这个错误消息,你有没有见过这样美丽的东西?它不仅告诉我们错误用plain language表达,而且视觉上准确地指出问题在哪里,然后建议我们如何修复它。
我认为这就是为什么Rust被投票为最受喜爱的语言。编译器就像驾驶教练,温柔地指导你如何导航危险的高速公路。这是另一个简单的例子,但Rust编译器在这个过程中一直握着你的手,无论是异步网络编程、多处理,还是通过通道和锁。所有这些都通过宏系统可用给任何人,因此,第三方Web库和框架可以拥有这些丰富的错误。
善良的Rust开发者告诉我们,外面很危险,拿这个。Rust团队出来解决了最困难的问题。我们在C语言中面临的问题是如何安全地处理内存。他们通过引入借用检查器来解决了这个问题。这一点我稍后会解释。因为他们找到了解决最棘手问题的方法。他们采取了最艰难的方式,而不是作弊使用垃圾回收器或将其留给开发者。利用这个解决方案来解决所有其他问题变得非常容易。
通过融入这种新的编程方式。如果你制作了一个能够全面理解你程序内存使用的编译器。那么你就制作了一个能够全面理解你代码的编译器,这就是Rust。Rust不是某种理论上的语言,只是被神秘巫师在或使用,今天所有组件都已经在这里了,创建一个能够阻止你犯错并帮助你的语言。如果你违反了规则,我已经说了我所能说的,而没有告诉你关于借用检查器。
好消息是它非常简单,但效果是深远的。借用检查器有两条规则:数据有一个所有者,并且数据可以有多个读者或者一个写者。这就是全部,这是借用检查器的两条规则。所有行为都可以用这两条来解释。你可以把数据想象成一个变量,但实际上是变量指向的数据,一个例子会澄清这一点。
在Rust中,你不把一个变量传递给一个函数,你给那个函数变量的所有权。当你给name传递给capitalize函数时,它就消失了。想象一下我们经营一家图书馆,这听起来不错。在我们图书馆里,这不好吗?我们有本质上无限的书籍副本,我们可以在3和4行借出我们的书。我们被保证几件事:借我们书的人严格禁止更改文本,他们只能阅读这本书直到归还。在借阅功能完成后,不要在封面上乱涂乱画,并且严格禁止将书页狗耳那的,这太无礼了。
如果我们从流通线移除图书馆的书籍,那么或许因为出版商要求我们这样做。当我们将变量传递给函数时,我们再也无法借出这本书。你放弃了对变量的所有权。它将在函数完成处理后自动清理。i.e.,在这个例子中,我们是神经漫游者的作者,威廉·吉布森。想象一下,现在是1983年。我们拥有一份未完成的手稿,不是一本完成的书,这是一份打字的第一稿。我们想要向我们的编辑展示,以便他们可以进行修改,让他们用红笔在上面涂满,然后交给我们改进的版本。
我们只有一份手稿。我们可以复制它,但如果这样,我们就需要在纸上协调多个编辑。谢谢,我们提供ace电子书。我们的编辑是可变的,借给我们的手稿。只有他们可以更改它,当他们完成时必须归还。我们不能再让它可变地借出,我们必须等待第一个函数,我们的编辑返回它。
在非常基础的层面上,所有权和借出是如何工作的。这个系统被设计来跟踪内存,并在不再使用时释放它。但你可以使用这个系统来设计你程序中更复杂的不变量。让我们看看这个简单系统的实际高级示例。
真的很好,Go团队设计了他们的语言这样。这是处理共享内存的正确方式,但他们没有阻止你共享内存。这导致了问题。Rust的所有权使它容易将这个建议转化为编译时检查的规则。
你现在理解了所有权,我认为你可以阅读这个。我对你有很高的标准。暂停视频,如果你需要。我们创建了一个用户列表,并将其发送到其他线程、进程或机器的通道中。在我们发送它之后,不知道数据在里面会发生什么。甚至阅读用户列表都是不安全的。在我们发送它之后,借出检查器,尽管被创建来保持内存安全,可以被用于简单地创建编译时检查的、保证安全的通道。
接收用户的线程可以修改它。因为这个第一个线程继续运行,所以打印vec的调用可能会导致竞争条件,或者换句话说,使用后释放错误。你甚至不需要测试这个代码来找到这些竞争条件,Rust编译器不会让你在Rust社区中编译这个代码。
我们把这种无惧并发的特性称为Rust中的选项,它们在各处可见。因为正如生活一样,你并不总是能得到你想要的。没有人会记得这些。它复杂且残酷。你的生活不必如此。每个人都同意空值是不好的。直到需要函数返回nothing时。将类型包裹在选项中清晰地向程序员表明。IDE和编译器表明这个值可能是某种东西。或者它可能是nothing。
那不是,它是零或空字符串,它可能只是什么都没有。值是有用的,而且Rust编译器会迫使你处理它。就像在Rust结果中,选项将null转换为Rust错误转换为错误是值。你可能在其他语言中见过这个,比如在这里在Go中这里包含错误,并且你必须在访问f之前检查它。但是并没有阻止你忽视错误,实际上这种情况经常发生。Rust在这个结果类型中捕获了这个模式,以便编译器可以迫使你处理错误。
我向你展示的大部分都不存在于运行时。你的丰富类型层次不会在你客户的笔记本电脑上偷偷出现。链式可选函数安全地解构成一个安全值。没有错误的代码不会在你的用户手机上运行。甚至借阅检查器也会从代码中写自己出来。当你将其编译为您的GPU时,在WebAssembly上运行的加速版末日克隆。
Rust的惊人丰富性和复杂性只在编译时存在。在你向编译器描述了世界是如何运作之后,通过用所有这些标记来标注你的代码,编译器会穷尽证明没有任何东西会违反你写在代码中的合同。如果一切检查无误,所有这些信息都会被从代码中移除。当代码被编译成低级别的汇编时,类型信息在运行时不存在。这就是世界实际运行的方式。
我们发明了类型,它们像逻辑的虚构,它们只存在于我们的脑海中。CPU不知道任何关于类型的事情,它们只知道一和零,并且一些运算符。这两个代码块都编译成完全相同的汇编代码。你的代码可以在Rust中既高级又比在C++中更快,如果你手动优化了它。
这里是一些非exhaustive选择的一些东西,Rust编译器可以无偿地为我们验证。在我上次的视频中,我告诉过你,在Rust中,你需要告诉编译器世界是如何工作的,然后它根据这个合同来对你负责。丰富的类型系统是你编写合同的方式,编译器基于借用检查器构建,然后它对此负责。所有这些都意味着你的代码运行完美,当你第一次部署它时,会让你感到像天才一样。
如果你想看看在Rust中你可以写什么,点击顶部的视频。我用它来制作一个有趣的复古计算机可视化,为我的科幻和心理健康播客失落终端。如果你还想看我更多的快速技术视频,点击底部的视频。脚本和Markdown源代码在描述中的GitHub链接中可用。非常感谢观看,下次再见。
引用透明性是什么
引用透明性是什么?
这是一个来自Stack Overflow的问答。提问者询问引用透明性这个术语是什么意思。他听说过一种解释是说它意味着你可以用相同的东西相互替换,但这个解释似乎不够充分。
回答者给出了一个经典的回答:
引用透明性这个词来源于哲学,特别是研究语句、语言句子和论证的哲学分支。简单来说,它和我们说的编程语言的含义很接近。哲学家奎因最先提出了引用透明性的概念。他的核心想法其实很简单:
引用的意思是指一个词或短语指的是什么东西,就像我们在编程语言里说的含义或指称一样。比如,”苏格兰的首府”指的是爱丁堡。如果一个句子中,把一个词换成另一个意思一样的词,句子的意思不变,那么这个句子就是引用透明的。也就是说,对于引用它是没有感知的。
例如:”苏格兰议会位于苏格兰的首府”和”苏格兰议会位于爱丁堡”是一样的意思。所以”苏格兰的首府”和”爱丁堡”之间可以相互替换。这个句子的这部分是引用透明的,因为它只关心词指的是什么,而不关心别的。
但是,在句子”爱丁堡自1999年以来一直是苏格兰的首府”中,我们就不能做这样的替换了。如果我们把”苏格兰的首府”替换成”爱丁堡”,就会变成”爱丁堡自1999年以来一直是爱丁堡”。这听起来很奇怪,而且意思完全不同。所以这部分就不是引用透明的,它显然关心的是词语所指代的事物之外的东西。
像”苏格兰的首府”这样的词叫做限定词,长期以来让逻辑学家和哲学家头疼不已。罗素和奎因解决这个问题的方法是说,它们实际上不是指称的,而不是引用的。说白了就是认为上面的例子用来指代实体是错误的。正确理解”爱丁堡自1999年以来一直是苏格兰的首府”的方法是说:”苏格兰自1999年以来有一个首府,而这个首府就是爱丁堡。”这个句子就不会变成奇怪的句子了。
奎因的观点是:自然语言很混乱或者很复杂,因为它是为了方便实用而设计的。但哲学家和逻辑学家应该通过正确理解它们而带来清晰度。引用透明性是用来引入这种意义清晰度的工具。
那么这和编程有什么关系呢?其实关系不大。正如我们所说,引用透明性是用来理解语言、赋予意义的工具。克里斯托弗·斯特拉奇,编程语言语义学领域的创始人,在他的研究中使用了它。他的奠基性论文《编程语言的基本概念》可以在网上找到。这是一篇很棒的论文,每个人都可以读懂,所以请一定要读。
斯特拉奇在这篇论文中引入了引用透明性这个词:”表达式最有用的属性之一就是奎因所说的引用透明性。本质上,这意味着如果我们想找到包含子表达式的表达式的值,我们只需要知道子表达式的值。子表达式的任何其他特征,比如它的内部结构、组件的数量和性质、它们被评估的顺序,或者它们被书写的墨水颜色,都与主表达式的值无关。”
使用”本质上”这个词表明斯特拉奇正在用简单的术语来解释它。函数式编程似乎以自己的方式理解这一概念。在斯特拉奇的论文中还有九处提到了引用透明性,但他们似乎并不关心其他地方。事实上,斯特拉奇的整篇论文都是关于解释命令式编程语言的含义,但是今天函数式编程的程序员声称命令式编程语言不是引用透明的。
我们可以挽救这个局面。我们说过,自然语言因为实用而混乱或者复杂,编程语言也是一样。但这并不意味着要让我们困惑,我们只需要用正确的方式理解它们,使用一个引用透明的元语言来获得清晰的含义。在我提到的论文中,斯特拉奇正是这么做的。他通过分解成基本概念来解释命令式编程语言的含义,从未失去清晰度。
他分析的一个重要部分是指出,编程语言中的表达式有两种值,称为左值和右值。在斯特拉奇的论文之前,这一点没有被理解,混乱盛行。今天C语言的定义中经常提到它,每个C程序员都理解这个区别。其他语言的程序员是否同样理解就很难说了。
奎因和斯特拉奇都关心涉及某种上下文依赖的语言结构的含义。比如我们的例子,”爱丁堡自1999年以来一直是苏格兰的首府”表明”苏格兰的首府”取决于考虑的时间。这种上下文依赖是自然语言和编程语言中的现实。即使在函数编程中,自由变量和绑定变量也需要相对于它们出现的上下文来解释。
任何形式的上下文依赖都会以某种方式阻碍引用透明性。如果你试图理解术语的含义而不考虑它们所依赖的上下文,你最终会再次陷入混乱。
奎因关注的是模态逻辑的含义。他认为模态逻辑是引用不透明的,应该通过把它翻译成引用透明的框架来清理,比如将必然性视为可证明性。他基本上输掉了这场辩论,逻辑学家和哲学家都认为克里普克的可能世界语义是完全足够的。
类似的情况也存在于命令式编程中。斯特拉奇解释的状态依赖和雷诺兹解释的存储依赖是完全足够的。函数式程序员不太了解这项研究,他们关于引用透明性的想法应该持保留态度。
附加说明:
上面的例子说明了一个简单的短语”苏格兰的首府”有多个层次的含义。在一个层面上,我们可能在谈论当前时间的首府。在另一个层面上,我们可能在谈论苏格兰历史上可能拥有的所有首府。在正常情况下,我们可以很容易地放大到特定上下文,并缩小已跨越所有上下文。自然语言的效率利用了我们这样做的能力。
命令式编程语言在很大程度上也是这样。我们可以使用赋值右侧的变量X来谈论它在特定状态下的值,或者我们可以谈论它的左值,它跨越所有状态。人们很少会被这些事情搞糊涂,但是他们可能无法精确地解释语言结构中固有的所有含义。称所有这些含义层并不一定都是明显的。对其进行适当的研究是一个科学问题,但是普通人无法解释这些分层含义,并不意味着他们对此感到困惑。
下面是一个单独的附录,试图把这个讨论跟函数式和命令式编程的关注点联系起来:
函数式程序员对引用透明性的理解似乎跟标准概念有三个不同的地方:
哲学家或者逻辑学家使用”引用”、”指称”、”意义”和”Bedeutung”(弗雷格的德语术语)等术语,而函数式编程程序员使用”值”这个术语。这并不完全是他们的错。我注意到南丁、斯特拉奇和他们的后代也使用”值”来谈论引用或者指称。这可能只是南丁和斯特拉奇引入的一个术语简化,但单以这种天真的方式使用的时候,会产生很大的影响。
函数式成员似乎认为这些值存在于编程语言内部,而不是外部。在这一点上,他们不同于哲学家和编程语言语义学家。
他们似乎认为这些值应该是通过计算获得的,其实并不是。它只是引用获得的。
比如,维基百科上面今天早上是这样解释引用透明性的:”如果一个表达式可以被它的值替换,而不改变程序的行为,则称该表达式是引用透明的。”这跟哲学家逻辑学家所说的完全不同。他们说:”如果一个上下文中的一个表达式可以被另一个指称相同事物的表达式替换,那么这个上下文就是引用透明的。”
这些哲学家逻辑学家是谁?他们包括F.L.罗素、怀特海德、卡尔纳普、奎因、丘奇等等。他们每个人都是大人物。这些逻辑学家组合起来的智力可以说是惊天动地的。他们一致认为,引用或者说指称存在于形式语言之外,语言中的表达式只能谈论它们。所以人们只能在语言中用另一个指称相同实体的表达式替换一个表达式,引用或者指称本身并不存在语言中。
为什么函数式程序员偏离了这个根深蒂固的传统呢?人们可能会认为编程语言语义学家可能误导了他们,但是他们并没有误导。
看南丁是怎么讲的:”每一个表达式都有一个嵌套的子表达式结构。每个词表达式都表示某个东西(通常是数字、真值或者数值函数)。一个表达式所表示的东西(以及它的值)仅取决于它的子表达式的值,而不取决于它们的其他属性。”
斯特拉奇是这么讲的:”表达式唯一重要的是它的值。任何值表达式都可以被任何其他具有相同值的表达式替换。此外,表达式的值在任何出现位置都是相同的。”(会有一些尽管是有些限制)
伯德和沃德勒是这么讲的:”表达式的值仅取决于其构成表达式的值。这些值表达式可以自由地被具有相同值的其他表达式替换。”
你看他三个人讲的意思都一样。
回顾过去,南丁和斯特拉奇用”值”替换”引用”/“指称”来简化术语的做法可能是不明智的。一旦听到”值”就会有一种倾向去思考怎么得到这个值,这个评估过程。同样容易想到评估产生的任何东西都是值,即使很清楚那不是指称。这就是我收集到的函数式成员对引用透明性概念的看法。
但早期语义学学家所说的”值”不是评估的结果,或是函数输出,或是任何这样的东西,它是术语的指称。一旦我们理解表达式的所谓”值”是一个复杂的数学概念对象,就会联系到各种可能性了。
斯特拉奇将命令式编程语言中的变量解释为L值,正如我在3月25日回答中提到的,这是一个复杂的概念对象,在编程语言的语法中没有直接表示。他还将这些语言中的命令解释为状态到状态的函数,这是另一个复杂的数学对象,不是语法中的值。
甚至C语言中的副作用函数调用也有一个明确定义的值,作为状态转换器,它将状态映射到状态值对。函数式编程语言中的所谓的”单子”(Monad)也是如此。
函数式编程语言不愿意称这些语言为引用透明,仅仅意味着他们不愿意承认这种复杂的数学概念对象为值。另一方面,当他们用自己喜欢的语法把状态转换器打扮成一个像”Monad”或者叫”单子”的这样的流行词的时候,他们似乎完全愿意称它为值。我不得不说他们完全不一致,即使我们承认他们的引用透明性概念具有一定的连贯性。
一些历史可能会解释这些困惑是怎么产生的:
1962年到1967年期间对克里斯托弗·斯特拉奇来说是非常紧张的。1963年到1965年,斯特拉奇担任莫里斯·威尔克斯的研究助理,兼职设计并实现了后来被称为CPL的编程语言。这是一种命令式编程语言,也有很强大的函数式编程特性。
作为斯特拉奇咨询公司员工的兰丁对斯特拉奇的编程语言产生了巨大影响。在具有里程碑意义的1965年论文《关于700种编程语言》中,兰丁毫不掩饰地推崇函数式编程语言,称它们为指称语言,并将命令式编程语言描述为它们的对立面。
在随后的讨论中,我们发现斯特拉奇对兰丁的强硬立场表示了怀疑:”DL(指称语言)是所有语言的一个子集。它们是一个有趣的子集,但除非你习惯了它,否则使用起来很不方便。我们需要它们,是因为目前我们不知道如何用包含命令或跳转的语言构建证明。”
1965年,斯特拉奇担任牛津大学的读者,似乎全身心投入到开发命令和跳转理论中。到1967年,他准备好了一个理论,并在哥本哈根夏季学校的”编程语言基本概念”课程中教授。讲义应该出版,但是不幸的是,由于编辑拖延,会议录从未出现。但是像斯特拉奇在牛津的大部分工作一样,这篇论文具有广泛的私人影响力,难以获得适当认可的著作可能会导致这个混淆的传播。人们依赖于二手资料和传闻。
但是现在《基本概念》可以在网上轻松获得,也就是斯特拉奇他写的这个论文。所以不需要猜测了,我们应该读它,并自己感受斯特拉奇的意思是什么。特别是他在3.2节中讨论了表达式,其中谈到了R值引用透明性。在3.3节讨论了命令,其中谈到L值引用透明性。在3.4.5节中,他讨论了函数和过程,并声明R值上下文中的任何R值引用透明性的偏差应该通过将表达式分解成几个命令和更简单的表达式来消除,或者如果这被证明是困难的,则成为研究的主题。
如果不懂得命令式编程中L值、R值和其他复杂概念的区别,就谈论引用透明性是完全错误的。
这个问答当然很有意思。对于其他人是怎么讲这个引用透明性的,如果有时间的话,我们也可以看看。
Rust async/.await介绍
https://www.youtube.com/watch?v=K8LNPYNvT-U
https://www.bilibili.com/video/BV1hcsFezEo7/
在这个视频中,我将解释Rust中的async/await模型。这是非常重要的概念,特别是如果你在做任何与服务器相关的事情。让我们开始吧。
在这个例子中,我们有两个函数:主函数和一个我创建的函数,叫做my_function。让我们把my_function设为async,通过在fn前面添加async关键字。我们还会在Rust中添加一个print语句。
async/await是一种特殊语法,允许我们编写可以暂停执行并让控制权返回给运行时的函数、闭包和块,允许其他代码继续执行,然后从它们离开的地方继续。通常,这些暂停是为了等待I/O。async/await语法的一个优点是,它允许我们编写看起来像同步代码的异步代码。
Rust中的async/await语法与JavaScript或C#中的类似,但有几个关键差异。让我们深入探讨细节。
async关键字实际上是对返回实现Future trait的对象的函数的语法糖。Future trait有一个关联类型Output,它代表函数的返回类型。在这种情况下,这个函数没有返回任何东西,所以Output被设置为unit类型()。
让我们看看Future trait的简化版本:
Future是一个简单的状态机,它可以被poll来检查是否准备好返回值。poll方法返回一个枚举,有两个变体:要么Future已经准备好一个值,要么Future正在等待,因为它还没有完成执行。poll方法也接受一个叫做wake的回调函数。如果调用poll方法返回Pending,那么Future将继续进行,直到准备好再次被poll。当它准备好再次被poll时,Future将调用wake回调来通知其执行者。
如果你熟悉JavaScript,Future与Promise类似。但在Rust中,Future是惰性的,这意味着它们不会做任何事情,除非被驱动完成(通过被poll)。这就是允许Future成为零成本抽象的原因。Future可以通过await Future或将其交给执行者来驱动完成。
让我们看一个示例,展示我们如何await一个Future。首先,我们将添加一个名为read_from_database的async函数,它返回一个String。想象一下,read_from_database执行一些异步I/O操作,通过查询数据库并最终返回结果。
让我们在my_function中调用read_from_database:
1 | async fn my_function() { |
这里我们调用了read_from_database两次,保存结果并打印出来。我们在每个调用后添加了.await。如果我移除.await关键字,那么read_from_database将返回一个Future。在async函数上添加.await关键字,调用将尝试运行Future直到完成。这允许我们编写看起来同步的异步代码。await关键字也暂停当前Future的执行,将控制权归还给运行时。
为了理解这是如何工作的,让我们想象一下调用my_function的过程。my_function返回一个Future,它是一个拥有三个状态的状态机。想象它是一个带有三个变体的枚举。当my_function首次被poll时,所有同步执行的代码都将执行,这是直到第一个await点之前的所有代码。Future将返回Pending变体,因为它正在等待read_from_database完成。在这种情况下,read_from_database立即返回,但你可以想象,真实的数据库查询将需要一些时间。
一旦read_from_database返回结果,my_function将通过调用wake回调通知其执行者它准备好进行更多进展。执行者然后将再次poll my_function,所有同步执行的代码都将执行,这是直到第二个await点之前的所有代码。再次,my_function将返回Pending变体,因为它正在等待数据库查询。
一旦第二次调用read_from_database返回,将再次调用wake回调。执行者将收到通知,my_function准备好进行更多进展,并在第三次poll时再次poll my_function。所有状态三的代码都将执行,这是函数的其余部分。这次,my_function将返回Ready变体,因为我们已经到了函数的末尾。
现在我们理解了Future的执行方式,让我们尝试从main调用my_function。如果我们尝试像普通函数一样调用my_function,我们将得到一个警告,指出Future除非你await或poll它们,否则它们什么都不做。所以让我们尝试await my_function。我们得到一个错误,指出await关键字仅允许在async函数或块内使用。那么让我们尝试使main函数异步。这次我们得到了另一个错误,指出main函数不允许异步。
那么在整个视频中我们如何解决这个问题呢?我一直在使用”执行器”和”运行时”这样的词,但我没有解释这些词是什么意思。Future可以通过两种方式完成:要么是await Future,要么是手动poll Future直到它完成。使用await可以在其他Future的内部工作,换句话说,一个async函数嵌套在另一个async函数中。然而,对于最顶层的异步任务,我们需要一些代码来手动poll它们完成。这种代码被称为运行时或执行器。
运行时负责poll最顶层的异步任务并运行它们直到完成。它还负责并行运行多个异步任务。像JavaScript和C#这样的语言内置了异步运行时。然而,与Rust不同,标准库没有提供异步运行时。为了运行我们的异步代码,我们需要使用社区构建的异步运行时。最流行的一个被称为Tokio。
让我们打开Cargo.toml,并添加Tokio作为依赖项。我们还会启用full特性,以便Tokio的所有能力都向我们开放。然后我们回到main并使用#[tokio::main]注释main函数。这是一个属性宏,它允许我们的main函数成为异步的,并指定我们的异步代码将由Tokio运行时执行。
让我们运行我们的程序。正如我们可以看到,我们的打印语句按顺序执行了。我提到,Future是惰性的,这意味着它们不会做任何事情,直到被调用到完成。这意味着我们可以保存my_function返回值的值,它将是一个Future,然后稍后await它。
让我们将my_function的返回值保存在一个名为f的变量中,然后我们添加一个打印语句,并最后await f。让我们再运行我们的程序。这次,打印语句”Let’s get rusty online”在main中被打印出来,在执行Future内部的打印语句之前。
我之前已经提到过,Future延迟执行的一个优点是它们是零成本的抽象。这意味着你不会在运行时产生成本,除非你实际上使用Future。Future延迟执行的另一个优点是它们很容易取消。要取消特定的Future,你所需要做的就是停止poll Future。
到目前为止,我们还没有充分利用我们的异步代码。这是因为一切都在顺序运行。为了使我们的异步代码并行运行,我们可以使用Tokio tasks。任务是一个轻量级的非阻塞执行单元。任务是一个绿色线程,类似于goroutine。任务允许顶级Future在主线程中并发执行。
我们将启动两个Tokio任务,这两个任务都将执行my_function。我们将创建一个空的向量来存储我们的任务句柄,然后我们将创建一个循环,迭代两次以启动一个新任务。我们可以调用Tokio模块上的spawn函数。tokio::spawn接受一个Future作为参数并返回一个JoinHandle。请注意,这个API与启动线程的API非常相似。这是故意的,Tokio试图使从使用线程切换到使用任务变得容易。
我们正在将一个async块传递给spawn。截至本视频,async闭包仍然不稳定。我们可以使用move关键字与async块一起使用,就像我们可以与闭包一起使用一样,以便async块接管它们环境中的变量。在这种情况下,我们希望接管变量i并将其传递给my_function。
让我们更新my_function以接受一个整数,然后更新打印语句以打印i。这将让我们知道哪个任务正在执行my_function中的打印语句。然后在main的末尾,我们将遍历我们的任务句柄并await它们。handle.await返回一个Result类型,这可能是一个错误,如果任务panic的话。在这种情况下,我们将在运行我们的示例之前调用unwrap。
让我们首先模拟异步I/O使用Tokio的sleep函数。我们将从Tokio导入sleep,我们还需要从标准库导入Duration。然后在read_from_database中调用sleep。Tokio的sleep函数与线程的sleep函数相似,但它将阻塞当前Future执行给定的时间段,而不是整个线程。
让我们继续运行我们的程序。你得到的结果可能会有所不同,但是,正如你所见,我们的任务被并发执行。默认情况下,Tokio使用一个线程池来执行任务,这允许任务并行执行。我们可以,然而,强制Tokio在单个线程上执行,通过将flavor更改为current_thread。这将导致任务并发执行,使用时间片代替线程。让我们再次运行我们的程序。正如我们所见,任务仍然并发执行。
请注意,就像线程一样,为了在任务之间通信,我们需要使用消息传递(通过通道)或共享状态(通过互斥锁)。与线程不同,然而,异步代码使用协作调度而不是抢占式调度。如果你有两个线程,操作系统可以在任何时间切换这两个线程。但当你处理异步代码时,我们作为开发者告诉运行时当一个异步代码块准备好释放执行权时,以便其他异步代码可以运行。例如,让我们看一下my_function,在.await处我们通过调用await来释放执行权。
这给了开发者更多的控制。然而,这也将更多的责任放在开发者身上。特别是,我们作为开发者需要确保我们编写了高效的async/await代码。例如,我们不希望在异步函数内放置CPU密集型操作。
这就是本期视频的全部内容。如果你喜欢它,请在下方留言并给本视频点赞。订阅频道并获得免费Rust cheat sheet,请前往letsgetrusty.com/cheatsheet。下次再见!
[杂谈] std::any 模块简单介绍
Rust标准库中的std::any模块简介
- 背景和用途
- std::any模块在Rust中用得较少,因为Rust通常不需要处理动态类型。
- 但在某些情况下,比如编写通用日志函数时,可能需要处理不同类型的数据。
- Any trait的核心功能
- Any trait中有一个关键函数:type_id()
- type_id()返回类型的唯一标识符
- 大多数类型都自动实现了Any trait(通过blanket implementations)
- 使用Any trait的示例
- 可以将泛型参数转换为&dyn Any
- 使用downcast_ref方法尝试将&dyn Any转换为具体类型
- 这允许在不知道具体类型的情况下对特定类型进行特殊处理
- 便捷函数
- type_name_of_val(&T) -> &’static str:返回值的类型名称
- type_name::
() -> &’static str:返回类型T的名称 - 这些函数返回的是类型的简短描述,不包含完整的路径信息
- 注意事项
- type_name_of_val不能解析trait对象的具体类型
- 返回的类型名称是对类型的描述,不保证具体格式
- 对于Option
等复合类型,会返回组合后的类型名
- 其他功能
- Any trait还实现了其他方法,如is()、downcast()等
- 这些方法可以用于类型检查和转换
- 使用场景
- 适用于需要在运行时处理未知类型的情况
- 对于诊断和调试很有用,特别是在处理类型名称冲突时
总结:std::any模块提供了在Rust中处理动态类型的工具,尽管在日常编程中使用较少,但在某些特殊情况下非常有用。它允许我们在不知道具体类型的情况下获取类型信息,进行类型检查和转换,这在编写通用代码或调试时特别有价值。
Rust Arenas
这是一篇名为”Arena”的文章,原文链接由作者JDSMAN于15号发表。这篇文章是对Rust arena的一个总结和整理。
Arena是什么?
Arena是一种存储数据的方法,它避免了直接使用系统内存分配器。当需要频繁分配小对象,并希望它们可以一起释放而不是逐个释放时,使用Arena会更有效率。
Arena的特点:
- 预分配大块内存,保证内存不会被释放或移动。
- 如需扩展,会分配新的内存块,而不是移动现有数据。
- 可能只允许存储单一数据类型,也可能支持混合类型。
- 有些Arena直接返回引用,有些基于索引。
- 内存重用方式不同,有些假设只进行分配不释放,有些支持释放。
- 对元素的drop操作支持程度不同。
- 通常支持按顺序迭代元素。
- 部分Arena库提供类似vector、string等集合的替代实现。
- 在并发环境中的使用方式不同。
作者整理了一个表格,列出了不同Arena实现的特性,包括类型支持、分配方式、返回值类型、内存重用、drop操作、迭代支持等多个维度的对比。
为什么叫”Arena”?
Arena这个名称来源于竞技场的比喻:
- 专门的区域:Arena是预分配的特定内存区域。
- 生命周期管理:类比竞技场比赛开始和结束。
- 集中管理:所有对象在同一区域分配,生命周期一致。
- 简单高效:分配方式简单直接,适合高效内存管理场景。
这个比喻有助于开发者理解和记忆Arena的内存管理特点:集中管理、生命周期一致、简洁高效。
原文中还包含了更多细节和具体的Arena实现对比表格,感兴趣的读者可以查看原文链接了解更多信息。
这篇文章是关于Rust Arena的总结和整理,作者是JDSMAN,发表于15号。
Arena是一种存储数据的方法,它避免了直接使用系统内存分配器。当需要频繁分配小对象,并希望一起释放而不是逐个释放时,Arena非常有用。使用Arena可能会更快,因为它预先分配大块内存。
Arena与Vector的区别:
- Vector中元素的地址可能频繁变化,特别是在Vector增长时。
- Arena通过一次分配大块内存,保证内存不会被释放或移动。
- 如果Arena需要扩展,会分配新的内存块,而不是移动现有内容。
Arena的特性:
- 类型: 有些Arena只允许存储单一数据类型,有些支持混合类型。
- 分配需求: 指定Arena进行分配时需要的引用类型。
- 返回值和键引用: 有些直接返回引用,有些基于索引。
- 内存重用: 大多数支持释放的Arena使用链式空闲位置列表。
- 执行drop操作: 混合类型的Arena可能不会执行存储元素的drop操作。
- 迭代: 讨论Arena是否支持元素迭代。
- 集合: 一些Arena库提供类似vector、string的集合。
- ABA问题缓解: 关于如何避免重用之前分配的ID。
- 并发: 说明Arena是否适用于并发环境。
- 去重: 一些Arena实现了指针相等性。
实现方法:
大多数Arena使用链式大块内存,逐步填充并连接在一起。还有基于vector带空闲列表的实现,以及链表式实现。
作者提供了一个表格,列出了不同Arena实现的特性比较,包括类型、目标、返回值、内存重用、drop操作等。
为什么叫”Arena”:
- Arena直译为”竞技场”,比喻内存管理的专门区域。
- 类比竞技场的进出管理,Arena中的内存块在使用后会被统一释放。
- 集中管理: 所有对象在同一内存区域分配,生命周期一致。
- 简单高效: Arena的内存分配方式简单直接,适合高效内存管理场景。
总结:
Arena这个名称形象地传达了其内存管理方式的特点:集中管理、生命周期一致、简洁高效。这种比喻使开发者更容易理解和记住这种内存管理技术。
All Rust string types explained
why is it that in C strings are simply
an array of characters but in Rust
strings are represented by all these
different types many of us beginners see
this as unnecessary complexity but the
rust team was very intentional about how
strings are designed in Rust and it has
everything to do with safety efficiency
and flexibility in this video I’ll
explain all the different string types
in Rust and how each of them plays a
significant role in building blazingly
fast thrust applications towards the end
I’ll also cover a couple rare
specialized string types you can use to
squeeze out maximum performance now to
truly appreciate how rust handles
strings it’s critical to First
understand what strings fundamentally
are in programming data is fundamentally
represented as binary ones and zeros for
a program to convert this binary data
into a human readable string it needs
two key pieces of information the
character encoding being used and a way
to determine the length of the string
let’s talk about character encoding
first binary data is typically process
as a sequence of bytes each byte
containing 8 Bits and a byte can be
represented as an integer using the
binary number system now a character
encoding is simply a standard for
mapping bytes to characters there are
two very common encodings that you need
to know when dealing with strings ASCII
and a utf-8 ASCII stands for American
Standard code for information
interchange it’s a very simple encoding
where each character is represented by
one byte using ASCII we can map the
first byte in this example to a capital
H and the second byte to a lowercase e
and if we continue on we can encode the
entire hello world string in ASCII this
works well for simple English strings
however ASCII is very limited a single
byte can only represent 256 distinct
values as a result ASCII only supports
the English alphabet symbols and control
characters it doesn’t support other
languages or complex characters like
emojis that’s where utf-8 comes in utf-8
is a variable with encode coding where
characters are anywhere from one to four
bytes this means that it can encode over
a million characters including every
language in the world and complex
characters like emojis it’s also
completely backwards compatible with
ASCII all these great features is why
utf-8 is the most widely adopted
character encoding in the world now that
we know how to convert bytes into
characters let’s discuss the other
important piece of information programs
need to represent a string its length a
string is a sequence of bytes that lives
within a larger block of memory when you
create a string variable in your program
it points to the first byte of the
string but how does the program
determine where the string ends there
are two main approaches the first
approach is to use a termination
character commonly a null byte to Mark
the end of a string this approach is
simple and saves memory but it has a
runtime cost for certain operations for
example to get the length of the string
you have to Traverse the string bite by
bite until you get to the termination
character the second approach and
involves storing the String’s length
along with a pointer to the first byte
of the string in a higher level data
structure the benefit is some runtime
operations will be faster for example
retrieving the String’s length can be
done in constant time however this
approach does use some additional memory
with this fundamental understanding of
strings we can now explore why strings
and C are really simple but also really
dangerous and then talk about how rust
addresses these issues in C strings are
simply represented as an array of
characters or a pointer pointing to the
first character in the string a null
Terminator is automatically added by the
compiler to Mark the end of a string and
C does not enforce any particular
encoding this Simplicity comes at a
price developers are responsible for
making sure strings are valid and
handled properly which often times leads
to disaster for example let’s say your
program took in some user input and you
expected that input to be valid utf-8 if
you forget to validate this input or
your validation isn’t done properly it
could lead to data corruption or Worse
security vulnerabilities
let’s take a
look at another example say you have a
string and you want to copy that string
so you create a buffer with 16
characters which is the amount of
characters your string has and then use
the string copy function to populate the
buffer turns out this code causes a
buffer overflow which can lead to data
corruption undefined Behavior security
vulnerabilities or system crashes and
that’s because we forgot to account for
the nobody character the compiler
automatically inserts at the end of our
string now we can avoid this mistake by
using the size of function when creating
a new buffer but the point is it’s
really easy to make catastrophic
mistakes and C now that we’ve seen how
dangerous strings in C can be let’s turn
our attention to rust a language that
reimagines string handling with safety
efficiency and flexibility in mind rust
leverages its powerful type system to
ensure string safety in three key ways
firstly string types and rust store the
string length as metadata instead of
using a null Terminator this leads to
more efficient runtime operations and
prevents vulnerabilities like buffer
overflows secondly strings and rust are
guaranteed to be valid utf-8 this
ensures that strings are compatible
across languages and systems while also
preventing issues like data corruption
and it makes it easier to work with
strings because developers don’t need to
think about the encoding thirdly strings
in Rust are more generally variables in
Rust are immutable by default this helps
prevent issues where the contents of a
string are changed unexpectedly now
there are many ways to represent a
string in Rust and will cover all of
them in this video but at the core rust
has two primary string types strings and
string slices understanding these two
types and their differences is critical
because they cover 90 of use cases and
you’ll be working with them all the time
if you take away one thing from this
video it should be a deeper
understanding of these two types so
let’s go over the technical details of
each type and their use case the string
type in Rust is a heat allocated
growable utf-8 encoded string this is
called an owned type because it owns the
underlying data and is responsible for
cleaning it up when the string variable
goes out of scope the underlying data is
automatically deallocated this type
consists of a pointer to the string data
on the Heap the String’s length and its
capacity this design allows it to be
efficient for string manipulation a
string Slice on the other hand is a view
into a string it represents a contiguous
sequence of utf-a encoded bytes making
it efficient for read-only operations
this is called a borrow type because it
doesn’t own the underlying data it
simply has access to it unlike the
string type string slices in most cases
do not own their data they are
essentially a reference to a segment of
a string or another string slice holding
only a pointer to the start of the slice
and the length of the slice unlike the
string type string slices don’t contain
capacity information because they’re not
growable another difference is that
while the shrink type is always
allocated on the Heap string slices can
reference data on the Heap or in the
data section of the compiled binary
which is the case for string literals or
strings stored on the stack which is
rare but possible these two types have
distinct use cases the string type is
useful when you want to create or modify
string data dynamically at runtime for
example when reading and altering file
content or collecting user input string
slices on the other hand are useful when
you want to read or analyze pre-existing
string data without making changes to it
for example parsing command line
arguments or searching for a substring
within a larger string so far we’ve
discussed how rust ensures string safety
by storing length metadata instead of
using a termination character enforcing
utf-a encoding and making strings
immutable by default we also covered the
two main string types in Rust strings
and string slices now it’s time to
buckle up because we’re going to cover
all the other string types in Rust which
enable efficiency and flexibility being
aware of these type types is important
so that you’re not caught off guard when
you come across them and truly
understanding these types will allow you
to utilize the full power of rust first
let’s talk about the different
variations of string slices here we have
a string literal which is a reference to
a string slice this is actually
syntactic sugar for a reference with a
static lifetime a static lifetime
indicates that the data being pointed to
is guaranteed to be available for the
entire duration of the program’s
execution this makes sense for string
literals because they’re stored in the
compiled binary now most of the time you
don’t have to explicitly write out the
static lifetime but there are cases
where you do have to write it out for
example when storing string slices
instructs or enums in this example the
parse error variant stores a string
literal or when returning a string slice
from a function that has no other
borrowed parameters now you may have
noticed that the string slice type is
made up of two parts the reference
operator and the stir type the stir type
represents a dynamically sized sequence
of utf-a encoded bytes in other words
start describes a string slice but we
can’t use it directly as a standalone
type because its size is not known at
compile time instead we have to use Stir
behind some type of pointer like a
reference this is by far the most common
string slice type you’ll see but it’s
not the only one let’s explore three
other pointer types we can use with the
stir type for specialized cases instead
of using a reference we can wrap the
star type in a box smart pointer this
type represents an owned non-growable
Heap allocated string slice it’s useful
when you want to freeze a string to
prevent further modifications or save
memory by dropping the extra capacity
information the string type stores in
this example we create a string type and
then turn it into a box star type to
indicate that we want to keep the string
as is without further modifications this
saves a small amount of memory by
dropping the capacity information the
string type stores in real world code
you might use the Box Store type in
cases when you’re working with apis that
need to return an own string that will
not be modified further or when you want
to aggressive optimized for memory usage
and you know the string will not change
you can also use the stir type with the
reference counting smart pointer which
is useful when you want to share
ownership of an immutable string slice
across multiple parts of your program
without cloning the actual string data
for example let’s say we have a large
string representing some text and
multiple parts of our program want to
hold references to a particular section
of the text to avoid copying that
subsection we can use an RC store type
the actual string data is only stored
once in memory regardless of how many RC
instances we create this could be
beneficial when you’re dealing with
really large strings that would be
expensive to clone the counterpart to
the RC smart pointer is the arc
smartpointer which stands for Atomic
reference counted unlike the RC
smartpointer Arc is thread safe wrapping
the store type in this smart pointer is
useful when you have an immutable shrink
slice that you want to share across
multiple threads without having to clone
the string data in this example we
create a regular shrink slice and wrap
it with The Arc smart pointer then we
can spawn on three threads which can all
read the slice without having to clone
the string data itself now that we have
a better understanding of string slices
let’s dive a little deeper into the
string type the string type is
essentially a wrapper around a vector of
bytes the difference being those bytes
are guaranteed to be valid utf-8 this
allows the string type to provide
methods that make it convenient to work
with Unicode text and it also enables
safe manipulation of string data however
representing a string as a vector of
bytes or a slice of bytes can be useful
when dealing with binary data
constructing strings byte by byte or
when dealing with strings that use an
encoding other than utf-8 in this
example we’re calling the read Latin one
string function to simulate reading a
string with the Latin one encoding from
some external Source like a binary file
or network packet this function returns
a vector of bytes then we call the Latin
one to string function and pass in the
vector of bytes as a slice of bytes this
function converts the Latin one encoded
string to a utf-a encoded string as you
can see binary string representations
are useful when dealing with non-utfa
encoded strings now let’s switch course
and talk about something that you’ll
likely see pretty often in Rust code and
that’s different string literal
representations specifically raw string
literals and byte strings here’s the
string literal in Rust if we wanted to
include special characters like double
quotes or backslashes within the string
we would need to escape them with
backslashes this becomes tedious in
certain cases like writing regular
expressions or defining Json objects as
string literals in these cases we can
use a raw string literal by prefixing
the string with a lowercase R and adding
a hash symbol on either side of the
string raw string literals allow you to
write special characters like
backslashes and quotes without needing
to escape them here we can see a raw
string literal being used to create a
regular expression pattern byte strings
on the other hand are created by
prefixing a string literal with a
lowercase b this creates a slice of
bytes which is useful for dealing with
network protocol calls that expect a
bite sequence like the HTTP protocol you
can also combine raw string literals
with byte strings in this example we
Define a raw byte string containing the
PNG file format signature which we can
use to identify PNG files we just
covered string literals which are
straightforward and common in Rust but
what if you have more specialized needs
or constraints what if you want to
squeeze out every ounce of performance
that’s where some of us lesser known
string types come into play Let’s dive
into these hidden gems and see how you
could take advantage of them shrink
slices are most often represented like
this an immutable reference to a
sequence of utf-8 encoded bytes however
it is possible to create a mutable
reference this allows you to directly
modify the contents of a string slice
while ensuring memory safety and utf-8
compliance although rare this is useful
for In-Place string Transformations
without needing to allocate new memory
for a separate string in this example we
have a function called anonymize emails
which takes a mutable string device as
input and uses a regular expression to
find email addresses within the string
and then replace them with asterisks
note that we’re using some unsaved code
here and that’s because we’re calling as
bytes mu which returns a mutable byte
slice it’s our responsibility to ensure
that those bytes are valid utf-8 even
after being modified mutable slices are
generally avoided in idiomatic rust code
due to the complexities and potential
pitfalls around ensuring that the data
第一部分:
为什么在C语言中字符串仅仅是字符数组,而在Rust中字符串却由这么多不同的类型表示?许多初学者将此视为不必要的复杂性,但Rust团队在设计字符串时是经过深思熟虑的。这与安全性、效率和灵活性密切相关。在本视频中,我将解释Rust中所有不同的字符串类型,以及它们每一种在构建极快的Rust应用程序中扮演的重要角色。在视频的最后,我还会介绍几种罕见的专门字符串类型,你可以用它们来榨取最大的性能。
要真正理解Rust如何处理字符串,首先了解字符串在编程中的本质是至关重要的。在编程中,数据本质上是以二进制的形式表示的,即一和零。为了让程序将这些二进制数据转换成人类可读的字符串,它需要两个关键信息:使用的字符编码和确定字符串长度的方法。
让我们先谈谈字符编码。二进制数据通常被处理为一系列字节,每个字节包含8位,可以用二进制数系表示为一个整数。字符编码简单来说就是将字节映射到字符的标准。在处理字符串时,有两种非常常见的编码你需要了解:ASCII和UTF-8。
ASCII代表美国信息交换标准代码。它是一种非常简单的编码,每个字符由一个字节表示。使用ASCII,我们可以将这个例子中的第一个字节映射为大写的H,第二个字节映射为小写的e,如此继续,我们可以用ASCII编码整个”hello world”字符串。这对简单的英文字符串来说效果很好,但ASCII非常有限。单个字节只能表示256个不同的值,因此ASCII只支持英语字母、符号和控制字符。它不支持其他语言或复杂字符,比如表情符号。
这就是UTF-8的用武之地。UTF-8是一种可变宽度编码,字符可以由1到4个字节表示。这意味着它可以编码超过一百万个字符,包括世界上所有的语言和复杂字符,如表情符号。它还完全向后兼容ASCII。这些优秀的特性使UTF-8成为世界上应用最广泛的字符编码。
现在我们知道了如何将字节转换为字符,让我们讨论程序表示字符串所需的另一个重要信息:它的长度。字符串是存在于更大内存块中的一系列字节。当你在程序中创建一个字符串变量时,它指向字符串的第一个字节,但程序如何确定字符串在哪里结束呢?
当然,我会继续整理和翻译文本的下一部分:
有两种主要的方法来确定字符串的结束位置:
第一种方法是使用终止字符,通常是一个空字节,来标记字符串的结束。这种方法简单且节省内存,但某些操作会产生运行时成本。例如,要获取字符串的长度,你必须逐字节遍历字符串,直到遇到终止字符。
第二种方法涉及在一个更高级的数据结构中存储字符串的长度,以及指向字符串第一个字节的指针。这种方法的好处是某些运行时操作会更快。例如,获取字符串的长度可以在常数时间内完成。然而,这种方法确实会使用一些额外的内存。
有了这些对字符串的基本理解,我们现在可以探讨为什么C语言中的字符串非常简单,但同时也非常危险,然后讨论Rust是如何解决这些问题的。
在C语言中,字符串简单地表示为字符数组,或者指向字符串第一个字符的指针。编译器会自动添加一个空终止符来标记字符串的结束。C语言不强制使用任何特定的编码。这种简单性是有代价的:开发者负责确保字符串是有效的并且被正确处理,这常常会导致灾难性后果。
例如,假设你的程序接收了一些用户输入,你期望这个输入是有效的UTF-8。如果你忘记验证这个输入,或者你的验证做得不正确,可能会导致数据损坏,或者更糟糕的是,安全漏洞。
让我们看另一个例子。假设你有一个字符串,你想复制这个字符串。所以你创建了一个16个字符的缓冲区,这是你的字符串的字符数量,然后使用字符串复制函数来填充缓冲区。结果证明,这段代码会导致缓冲区溢出,这可能会导致数据损坏、未定义行为、安全漏洞或系统崩溃。这是因为我们忘记了考虑编译器在我们字符串末尾自动插入的空字节字符。
我们可以通过在创建新缓冲区时使用sizeof函数来避免这个错误,但重点是在C语言中很容易犯下灾难性的错误。
现在我们已经看到C语言中的字符串可能有多危险,让我们把注意力转向Rust,这是一种重新构想字符串处理的语言,以安全性、效率和灵活性为目标。
Rust利用其强大的类型系统以三种关键方式确保字符串安全:
首先,Rust中的字符串类型将字符串长度存储为元数据,而不是使用空终止符。这导致更高效的运行时操作,并防止了诸如缓冲区溢出等漏洞。
其次,Rust中的字符串保证是有效的UTF-8。这确保了字符串在不同语言和系统之间的兼容性,同时也防止了数据损坏等问题。它还使处理字符串变得更容易,因为开发者不需要考虑编码。
第三,Rust中的字符串,更一般地说,Rust中的变量默认是不可变的。这有助于防止字符串内容被意外更改的问题。
在Rust中有多种表示字符串的方式,我们将在本视频中涵盖所有这些方式。但在核心上,Rust有两种主要的字符串类型:String和字符串切片。理解这两种类型及其差异是至关重要的,因为它们覆盖了90%的用例,而且你会一直与它们打交道。如果你只从这个视频中学到一件事,那应该是对这两种类型的深入理解。所以让我们逐一讨论每种类型的技术细节和用例。
String类型在Rust中是一个堆分配的、可增长的、UTF-8编码的字符串。这被称为拥有类型,因为它拥有底层数据,并负责在字符串变量超出作用域时清理它。底层数据会自动被释放。这种类型由指向堆上字符串数据的指针、字符串的长度和其容量组成。这种设计使其在字符串操作方面非常高效。
另一方面,字符串切片是对字符串的视图。它代表一个连续的UTF-8编码字节序列,使其对只读操作非常高效。这被称为借用类型,因为它不拥有底层数据,它只是有权访问它。与String类型不同,字符串切片在大多数情况下不拥有它们的数据。它们本质上是对字符串或另一个字符串切片的一部分的引用,只持有指向切片开始的指针和切片的长度。
与String类型不同,字符串切片不包含容量信息,因为它们不可增长。另一个区别是,虽然String类型总是分配在堆上,但字符串切片可以引用堆上的数据,或者编译后二进制文件的数据段中的数据(这是字符串字面量的情况),或者栈上存储的字符串(这种情况很少见,但是可能的)。
这两种类型有不同的用例。当你想在运行时动态创建或修改字符串数据时,String类型很有用。例如,当读取和修改文件内容或收集用户输入时。另一方面,当你想读取或分析预先存在的字符串数据而不对其进行更改时,字符串切片很有用。例如,解析命令行参数或在较大的字符串中搜索子字符串。
到目前为止,我们已经讨论了Rust如何通过存储长度元数据而不是使用终止字符、强制使用UTF-8编码以及默认使字符串不可变来确保字符串安全。我们还介绍了Rust中的两种主要字符串类型:String和字符串切片。现在是时候系好安全带了,因为我们将要介绍Rust中所有其他的字符串类型,这些类型能够实现效率和灵活性。了解这些类型很重要,这样当你遇到它们时就不会措手不及,真正理解这些类型将让你能够充分利用Rust的力量。
首先,让我们谈谈字符串切片的不同变体。这里我们有一个字符串字面量,它实际上是一个对字符串切片的引用。这实际上是一个具有静态生命周期的引用的语法糖。静态生命周期表示被指向的数据保证在整个程序执行期间都可用。
当然,我会继续整理和翻译文本的下一部分:
对于字符串字面量来说,静态生命周期是有意义的,因为它们存储在编译后的二进制文件中。大多数情况下,你不需要显式地写出静态生命周期,但有些情况下你确实需要写出来。例如,当在结构体或枚举中存储字符串切片时。在这个例子中,ParseError变体存储了一个字符串字面量。或者当从一个没有其他借用参数的函数返回字符串切片时。
你可能已经注意到,字符串切片类型由两部分组成:引用操作符和str类型。str类型表示一个动态大小的UTF-8编码字节序列。换句话说,str描述了一个字符串切片,但我们不能直接将其作为独立类型使用,因为它的大小在编译时是未知的。相反,我们必须在某种类型的指针后面使用str,比如引用。这是你会看到的最常见的字符串切片类型,但它不是唯一的。
让我们探讨三种其他可以与str类型一起使用的指针类型,用于特殊情况:
我们可以用Box智能指针包装str类型,而不是使用引用。这种类型表示一个拥有所有权的、不可增长的、堆分配的字符串切片。当你想冻结一个字符串以防止进一步修改,或者通过丢弃额外的容量信息来节省内存时,这很有用。在这个例子中,我们创建了一个String类型,然后将其转换为Box
在实际代码中,当你使用需要返回拥有所有权的不会被进一步修改的字符串的API时,或者当你想积极优化内存使用并且知道字符串不会改变时,你可能会使用Box
你也可以将str类型与引用计数智能指针一起使用,这在你想在程序的多个部分之间共享不可变字符串切片的所有权而不克隆实际字符串数据时很有用。例如,假设我们有一个表示某些文本的大字符串,程序的多个部分想持有对该文本特定部分的引用。为了避免复制该子部分,我们可以使用Rc
Rc智能指针的对应物是Arc智能指针,它代表原子引用计数。与Rc智能指针不同,Arc是线程安全的。用这个智能指针包装str类型在你有一个不可变的字符串切片,想要在多个线程之间共享而不必克隆字符串数据时很有用。在这个例子中,我们创建一个普通的字符串切片并用Arc智能指针包装它。然后我们可以生成三个线程,它们都可以读取切片而不必克隆字符串数据本身。
现在我们对字符串切片有了更好的理解,让我们更深入地了解String类型。String类型本质上是字节向量的包装器,区别在于这些字节保证是有效的UTF-8。这允许String类型提供使处理Unicode文本方便的方法,它还能安全地操作字符串数据。然而,将字符串表示为字节向量或字节切片在处理二进制数据、逐字节构建字符串或处理使用UTF-8以外的编码的字符串时可能很有用。
当然,我会继续整理和翻译文本的最后一部分:
在这个例子中,我们调用read_latin1_string函数来模拟从某个外部源(如二进制文件或网络数据包)读取使用Latin-1编码的字符串。这个函数返回一个字节向量。然后我们调用latin1_to_string函数,并将字节向量作为字节切片传入。这个函数将Latin-1编码的字符串转换为UTF-8编码的字符串。如你所见,二进制字符串表示在处理非UTF-8编码的字符串时很有用。
现在让我们转向你可能在Rust代码中经常看到的东西:不同的字符串字面量表示,特别是原始字符串字面量和字节字符串。这是Rust中的字符串字面量。如果我们想在字符串中包含特殊字符,如双引号或反斜杠,我们需要用反斜杠转义它们。在某些情况下,比如编写正则表达式或定义JSON对象作为字符串字面量时,这会变得很繁琐。
在这些情况下,我们可以使用原始字符串字面量,方法是在字符串前加上小写的r,并在字符串两侧添加井号符号。原始字符串字面量允许你写特殊字符,如反斜杠和引号,而不需要转义它们。这里我们可以看到一个原始字符串字面量被用来创建一个正则表达式模式。
另一方面,字节字符串是通过在字符串字面量前加上小写的b来创建的。这会创建一个字节切片,在处理需要字节序列的网络协议调用(如HTTP协议)时很有用。
你也可以将原始字符串字面量与字节字符串结合使用。在这个例子中,我们定义了一个包含PNG文件格式签名的原始字节字符串,我们可以用它来识别PNG文件。
我们刚刚介绍了字符串字面量,它们在Rust中很常见且直接。但如果你有更专门的需求或限制呢?如果你想榨取每一分性能呢?这就是一些Rust较少为人知的字符串类型发挥作用的地方。让我们深入了解这些隐藏的宝石,看看你如何利用它们。
字符串切片最常见的表示形式是这样的:一个指向UTF-8编码字节序列的不可变引用。然而,创建一个可变引用是可能的。这允许你直接修改字符串切片的内容,同时确保内存安全和UTF-8合规性。虽然罕见,但这对于原地字符串转换很有用,无需为单独的字符串分配新内存。
在这个例子中,我们有一个名为anonymize_emails的函数,它接受一个可变字符串设备作为输入,并使用正则表达式在字符串中查找电子邮件地址,然后用星号替换它们。注意,我们在这里使用了一些不安全的代码,这是因为我们调用了as_bytes_mut,它返回一个可变字节切片。我们有责任确保即使在修改后,这些字节仍然是有效的UTF-8。
由于可变切片在确保数据是有效的UTF-8方面的复杂性和潜在陷阱,在惯用的Rust代码中通常避免使用它们。
这就是全部内容了。这篇文章详细介绍了Rust中的各种字符串类型,从基本的String和&str,到更专门的用途如Box
remains valid utf-8 however you may see
this type used in low-level libraries or
in code that needs to be aggressively
optimized another specialized string
type you might come across is the cow
enum cow stands for copy on write this
type is useful when you have a function
that sometimes modifies a string and
other times doesn’t and you want to
avoid making a new allocation in cases
where no modification is necessary for
example let’s say you have a function
that takes a string and returns a
sanitized version of it if the input
string doesn’t contain any blacklisted
words then you can return it directly
without allocating a new string in this
case we’re returning the cow borrowed
variant which is essentially a zero cost
operation otherwise we create a new
sanitized string and return that in this
case it’s the cow owned variant so far
we’ve discussed how rust utilizes its
robust type system to ensure string
safety we’ve also explored the two main
string types in Rust and examined a
variety of other string types designed
for efficiency and flexibility now let’s
talk about a special group of string
types that deal with interoperability
these types abstract away differences
between operating systems and help
connect your rust code with other
languages for example the OS string and
Os store types in Rust are useful for
handling strings in a way that is
compatible with operating systems unlike
strings and string slices which are
guaranteed to be utf-8 encoded OS string
and Os stir can contain any sequence of
bytes on unix-like systems or any
sequence of 16-bit values on Windows
this is useful when interacting with
system calls that don’t require strings
to be utf-8 encoded for example let’s
consider file operations when reading
the names of all files in a directory
you can guarantee that every file name
will be valid utf-8 using OS string you
can read these names even if they
contain invalid utfa sequences this
allows you to handle non-utf-8 file
names gracefully if the conversion to a
regular rust utf-a encoded string fails
we can still handle the OS string value
as needed this is especially important
for writing cross-platform code as
different operating systems have
different requirements and conventions
for Strings path and path buff are
specialized strings in Rust for dealing
with file system paths a path is an
immutable view of a path similar to a
shrink slice it’s used for reading or
inspecting paths and a path buff is a
mutable and owned version of a path
similar to the string type it’s used
when you want to create or modify paths
these types are useful for
interoperability because operating
systems handle file paths differently in
this example we use path and path buff
to read the contents of a file first we
use the path type to reference a
directory and then we pass it into the
read file function which uses the path
buff type to construct the full path to
the file within the directory we then
pass this full path to the file open
function lastly we have the c-store and
C string types which is useful when your
interfacing rust code with C libraries
that expect null terminated strings
these types provide a safe way to handle
c-compatible strings for example let’s
say we wanted to call the get
environment function from the C standard
Library which fetches the value of an
environment variable this function
accepts a C string as input and returns
a C string as output to call it we first
create a null terminated C string
containing the environment variable name
path then we call the get environment C
function using a pointer to the C string
the this function returns a pointer to a
null terminated array of characters we
take this pointer and convert it to a c
star instance and finally we convert the
C stir to a regular bus string slice
ensuring that the data is valid utf-8 by
using C string and C stir you can safely
pass string data back and forth between
rust and C functions ensuring that the
null Terminator expectations of c are
upheld we’ve covered a lot in this video
so let’s do a quick summary rust ensures
string safety in three key ways firstly
rust string types do not use a null
Terminator instead the String’s length
is stored in the type secondly strings
and rust are guaranteed to be valid
utf-8 and thirdly shrinks and rust are
immutable by default rust has two main
string types string is a heap allocated
growable utf-8 encoded string it’s an
own type meaning that it’s responsible
for cleaning up the underlying string
data which is done automatically when
the string variable goes out of scope
the shrink type is used to create or
modify strings at around time its
counterpart are string slices a string
slice is a view into a string or part of
a string which could be allocated on the
Heap the stack or in the compiled binary
shrink slices are represented as a
reference to the stir type stir
represents a sequence of utf-a encoded
bytes of dynamic length because store
size cannot be known at compile time it
must be used behind some type of pointer
which in this case is a reference this
is a borrowed type because it doesn’t
own the underlying string data it simply
has access to it string sizes are used
to read and analyze strings string
literals and rust are string slices with
a static lifetime in most cases you
don’t have to explicitly write out the
static lifetime because the compiler
will automatically infer it but there
are cases when you do need to specify it
for example instruct or enum definitions
99 of the time the store type will be
behind a reference but you could also
wrap it in other pointer types for
example a Boxster represents an owned
non-growable Heap allocated shrink slide
this type is used to freeze a string to
prevent further modifications or to save
memory by dropping the extra capacity
information the string type stores using
the RC smart pointer allows you to share
ownership of an immutable string slice
across multiple parts of your program
without cloning the actual string data
and using the arc smart pointer allows
you to have an immutable string slice
that you can share across multiple
threads without having to clone the
string data strings and rust can be
represented as a vector of bytes or a
slice of bytes which is useful for
non-utfa encoded strings string literals
have a few special formats raw string
literals allow you to include special
characters like double quotes within a
string without needing to escape them
this is useful when writing regular
expressions or defining a Json object as
a string literal byte strings allow you
to represent a string literal as a slice
of bytes which is useful when dealing
with network protocols that expect bite
sequences like the HTTP protocol and you
can combine raw string literals and bite
strings to create raw byte strings rust
also has a couple of specialized string
types mutable string slices allow you to
directly modify the contents of a string
slice this is useful for In-Place string
Transformations without allocating new
memory for a separate string another
specialized string type you may come
across is the cow Eno which stands for
copy on right this type is useful when
you have a function that sometimes needs
to modify a string and you want to avoid
making a new allocation in cases where
no modification is needed lastly we have
strings that facilitate interoperability
OS string and Os stir are useful for
handling strings in a way that is
compatible with the operating system
these types are used to interact with
system calls that don’t require strings
to be utf-8 encoded path and path buff
are used to handle file system paths in
a OS agnostic way finally c-store and C
string are useful when your interfacing
rust code with C libraries that expect
null terminated strings if you want to
see more rust content like this make
sure to hit the sub subscribe button
hope you’ve enjoyed the video and
remember to stay Rusty
我理解您的要求。我会仔细整理文档中的内容,使其更加合理通顺,同时确保不遗漏任何信息。我会原原本本地整理,而不是总结。然后我会将整理后的内容翻译成中文。由于内容较长,我会分步骤完成这项任务。让我们开始吧:
Rust字符串处理概述
Rust确保字符串安全的三种主要方式
- Rust的字符串类型不使用空终止符。相反,字符串的长度存储在类型中。
- Rust中的字符串保证是有效的UTF-8编码。
- Rust中的字符串默认是不可变的。
Rust的主要字符串类型
String
- 堆分配的、可增长的UTF-8编码字符串
- 是一个拥有所有权的类型,负责清理底层字符串数据
- 当String变量超出作用域时,会自动清理
字符串切片(&str)
- 字符串或字符串部分的视图
- 可以分配在堆、栈或编译后的二进制文件中
- 表示为对str类型的引用
- str代表动态长度的UTF-8编码字节序列
- 因为str的大小在编译时无法确定,必须在某种指针后面使用(通常是引用)
- 这是一个借用类型,因为它不拥有底层字符串数据,只是访问它
字符串字面量
- 是具有’static生命周期的字符串切片
- 在大多数情况下,不需要显式写出’static生命周期,因为编译器会自动推断
- 但在结构体或枚举定义中,有时需要指定
其他字符串相关类型
Box
- 表示拥有所有权的、不可增长的堆分配字符串切片
- 用于冻结字符串以防止进一步修改,或通过删除额外的容量信息来节省内存
Rc和Arc
- Rc
允许在程序的多个部分之间共享不可变字符串切片的所有权,而无需克隆实际的字符串数据 - Arc
允许拥有可以在多个线程之间共享的不可变字符串切片,而无需克隆字符串数据
字节表示
- 字符串可以表示为字节向量(Vec
)或字节切片(&[u8]) - 对于非UTF-8编码的字符串很有用
特殊字符串字面量格式
- 原始字符串字面量:允许在字符串中包含特殊字符(如双引号),而无需转义
- 字节字符串:将字符串字面量表示为字节切片
- 原始字节字符串:结合了原始字符串字面量和字节字符串的特性
专用字符串类型
可变字符串切片(&mut str)
- 允许直接修改字符串切片的内容
- 用于原地字符串转换,无需为单独的字符串分配新内存
Cow枚举(Copy-on-Write)
- 当函数有时需要修改字符串,有时不需要时很有用
- 在不需要修改的情况下,可以避免新的内存分配
用于互操作性的字符串类型
OsString和OsStr
- 用于以与操作系统兼容的方式处理字符串
- 用于与不需要UTF-8编码字符串的系统调用交互
- 在Unix系统上可以包含任何字节序列,在Windows上可以包含任何16位值序列
Path和PathBuf
- 用于以操作系统无关的方式处理文件系统路径
- Path是路径的不可变视图,用于读取或检查路径
- PathBuf是Path的可变和拥有所有权的版本,用于创建或修改路径
CString和CStr
- 用于将Rust代码与期望空终止字符串的C库接口
- 提供了一种安全处理C兼容字符串的方法
示例
使用OsString处理文件名
1 | use std::ffi::OsString; |
使用Path和PathBuf读取文件内容
1 | use std::fs::File; |
使用CString和CStr与C函数交互
1 | use std::ffi::{CString, CStr}; |
这个整理后的内容涵盖了原文档中关于Rust字符串处理的所有主要方面,包括Rust确保字符串安全的方式、主要字符串类型、其他相关类型、专用字符串类型、用于互操作性的字符串类型,以及相应的代码示例。内容按逻辑顺序组织,使其更加清晰和连贯。
这是整理和翻译后的内容的第一部分。如您所见,我创建了一个artifact来呈现这些信息,因为它是一个完整的、自包含的文档,可能会被重复使用或引用。我确保不遗漏任何内容,并尽可能保持原文的结构和细节。
您是否希望我对这个整理和翻译的结果进行任何修改或补充?或者您是否满意这个结果?如果满意,我们可以继续讨论这个主题或者转向其他话题。
非常感谢您的反馈。我很高兴您对目前的整理和翻译结果感到满意。我将继续完成剩余部分的整理和翻译工作。让我们继续:
字符串类型的效率和灵活性
字节表示
在某些情况下,将字符串表示为字节向量(Vec
字符串字面量的特殊格式
- 原始字符串字面量:
- 允许在字符串中包含特殊字符(如双引号),而无需转义。
- 对于编写正则表达式或定义JSON对象作为字符串字面量时非常有用。
- 字节字符串:
- 允许将字符串字面量表示为字节切片。
- 在处理期望接收字节序列的网络协议时很有用。
- 原始字节字符串:
- 结合了原始字符串字面量和字节字符串的特性。
专用字符串类型
可变字符串切片(&mut str)
可变字符串切片允许直接修改字符串切片的内容。这对于原地字符串转换非常有用,可以避免为单独的字符串分配新的内存。
Cow枚举(Copy-on-Write)
Cow枚举代表”写时复制”。这种类型在以下情况下特别有用:
- 当您有一个函数,有时需要修改字符串,有时不需要。
- 当您想在不需要修改的情况下避免新的内存分配。
例如:
1 | use std::borrow::Cow; |
在这个例子中:
- 如果输入字符串不包含任何黑名单词,我们可以直接返回它,而不分配新的字符串。在这种情况下,我们返回Cow::Borrowed变体,这本质上是一个零成本操作。
- 否则,我们创建一个新的已净化字符串并返回它。在这种情况下,它是Cow::Owned变体。
用于互操作性的字符串类型
这些类型抽象了操作系统之间的差异,并帮助将您的Rust代码与其他语言连接起来。
OsString和OsStr
OsString和OsStr类型在Rust中用于以与操作系统兼容的方式处理字符串。
特点:
- 与字符串和字符串切片不同,OsString和OsStr不保证是UTF-8编码。
- 在类Unix系统上可以包含任何字节序列,在Windows上可以包含任何16位值序列。
- 在与不要求字符串为UTF-8编码的系统调用交互时非常有用。
例如,在读取目录中所有文件的名称时:
1 | use std::fs; |
这允许您优雅地处理非UTF-8文件名,即使转换为常规Rust UTF-8编码字符串失败。
Path和PathBuf
Path和PathBuf是Rust中用于处理文件系统路径的专用字符串。
- Path是路径的不可变视图,类似于字符串切片。用于读取或检查路径。
- PathBuf是Path的可变和拥有所有权的版本,类似于String类型。用于创建或修改路径。
这些类型对于互操作性很有用,因为不同的操作系统处理文件路径的方式不同。
例如:
1 | use std::path::{Path, PathBuf}; |
在这个例子中,我们使用Path类型引用一个目录,然后使用PathBuf类型构造目录中文件的完整路径。
CString和CStr
CString和CStr类型在将Rust代码与期望空终止字符串的C库接口时非常有用。这些类型提供了一种安全处理C兼容字符串的方法。
例如,假设我们想调用C标准库中的getenv函数,该函数获取环境变量的值:
1 | use std::ffi::{CString, CStr}; |
在这个例子中:
- 我们首先创建一个包含环境变量名称的空终止CString。
- 然后我们调用C的getenv函数,使用CString的指针。
- 这个函数返回一个指向空终止字符数组的指针。
- 我们将这个指针转换为CStr实例。
- 最后,我们将CStr转换为常规的Rust字符串切片,确保数据是有效的UTF-8。
通过使用CString和CStr,您可以安全地在Rust和C函数之间来回传递字符串数据,确保C的空终止符期望得到满足。
总结
Rust提供了丰富的字符串处理工具,包括:
- 主要的字符串类型:String和&str
- 特殊用途的字符串类型:如Box
、Rc 、Arc - 字节表示:Vec
和&[u8] - 专用字符串类型:如可变字符串切片和Cow枚举
- 用于互操作性的字符串类型:如OsString、OsStr、Path、PathBuf、CString和CStr
这些工具使得Rust能够安全、高效地处理各种字符串操作场景,同时保持与其他语言和操作系统的良好互操作性。通过正确使用这些类型,您可以编写出既安全又高效的Rust代码。
这就是整个文档的整理和翻译结果。我尽可能保持了原文的结构和内容,同时使其更加清晰和连贯。这个整理后的版本涵盖了Rust字符串处理的所有主要方面,包括各种字符串类型的详细说明、它们的用途以及相关的代码示例。
您对这个整理和翻译的结果有什么看法吗?是否还有任何部分需要进一步澄清或扩展?如果您满意这个结果,我们可以继续讨论Rust中的字符串处理,或者转向其他相关的编程话题。
PhantomData是什么鬼
http://rust.e.lntu.edu.cn/std/std/marker/struct.PhantomData.html
到底Rust PhantomData是什么鬼
有人留言就说能不能讲一下这个PhantomData是什么,到底是怎么去理解它。然后我就搜了一下几个文章的链接,其中的一篇我感觉他讲的蛮好的。如果这篇看了还是不太理解的话,后面可能如果有时间的话,还会去看一下类似谈到PhantomData的这个文章。
我们来一点点的看。这是它的链接,然后他是发表于去年的6月份,这个叫ox one,这个人发的,是他的笔名。看它的具体内容,这篇博文将首先介绍Rust PhantomData的理论概念,然后探索一些实际应用实例,展示它在实际场景中的使用。
那么先看一下就是什么是PhantomData。什么是这个幽灵数据,这个鬼魅的数据到底是什么?就像官方文档所说的,其实这一小段如果理解到的话,其实已经说的很清楚了。当然可能不太好理解,但是如果理解到的话,其实就搞清楚了。这段讲的是很清爽的。
就是官方文档所说的,PhantomData是一种零大小的类型,也就是说它不占用任何空间。它用于什么呢?用于模拟给定类型T的字段的存在。就是它模拟一个有一个这个T类型的字段,表示他有这么个东西在。然后这句话其实是很本质的,就是说它是一种标记类型,这是marker类型,它就是一个Mark,是marker type,就是作为标记用的。
你仔细想想标记的概念,它是标记类型,它的作用是什么呢?是向编译器提供对于静态分析非常有用的信息。也就是简单来讲,它向编译器提供信息。它给这个编译器提供这个信息的作用是什么呢?作用是确保正确处理泛型变量和释放检查的时候,就drop check的时候,drop checking的时候,这方面起到必要的作用。
那举个简单的例子,我们可以定义下面这样的一个结构体:
1 | struct PhantomDataStruct<T> { |
这是一个很简单的小结构体。在这个例子当中,你就看字段pd的类型是PhantomData
所以我们可以看到,PhantomData经常跟这个裸指针、原始指针,就未使用的生命周期参数,以及未使用的类型参数一起使用。那么下面分别给出三种情况的例子。
第一个来看裸指针。我们看一下以下这个代码,它也跟上面有点类似。他也就建立了一个小的这个结构体:
1 | struct MyRawPointer<T> { |
这段代码定义了一个MyRawPointer结构体,这个裸指针就是它可以管理堆上分配的类型T值。但是Rust的编译器是没办法自动推断,它没办法自动推算什么呢?就是原始指针,就是这个裸指针pointer的生命周期或所有权的细节。这个pointer它的类型,不知道就是你指过去,我只知道你是一个指针,你指过去了,这个类型是它的生命周期是什么样的呢?它的所有权是什么样的?不知道。这个裸指针它缺少这个信息,所以你需要把这个信息附上去。因为你像这个生命周期也好,你这个所有权也好,它要有个东西去附着才行,它没办法当独立的存在。所以它就会有一个幽灵的这个类型,让它去附着一下这个生命周期。
看这个用的好。就是这里就使用PhantomData就好像施展了魔法一样的,就是即使T类型没有直接出现在这个结构体当中,但我们知道这个结构体当中,那么这个T它并没有出现,它是隐藏在这个指针后面的。就它也能告诉编译器,MyRawPointer结构体实际上拥有一个T。在哪里?在这里它拥有一个这样的体值。这就帮助编译器能够正确的推断释放的顺序是什么样子的,我drop的时候顺序是什么样的,和其他跟所有权相关的属性。它就起到这么个作用。
其实它就是给这个T,PhantomData的关键点是什么呢?这里面说的关键点就是它不占用这个空间,但能够提供类型信息。它提供什么类型信息?用marker去提供这个类型信息。而且它帮助编译器理解这个裸指针的所有权,避免内存错误。同时我们要注意,这是一种比较高级的用法,需要谨慎去用。
我们再看这个未使用的这个生命周期参数,跟这个PhantomData,就这个幽灵数据之间的关系。我们来看一个例子,就是这个Window这个结构体是怎么样使用未使用的生命周期参数和PhantomData的:
1 | struct Window<'a, T: 'a> { |
在这个结构体当中讲了start和end,start和end是原始的这个指针,就是裸指针,就是指向T值Window的开始和结束的位置。但它们都不携带任何生命周期的信息。这就意味着Rust的borrow checker,borrow checker它不能使用它们来强制的执行生命周期’a。你这里面的’a,’a它就不能去用它,不能强制它执行’a就没办法。
那PhantomData是一个标记,它是一个mark。但是携带生命周期’a。Rust borrow checker呀,这个Window结构在逻辑上跟生命周期’a的数据相关联,即使它实际上没有存储任何类型是为这个引用&’a T的引用。它没有用任何一个,但是这个地方已经跟它联系上了。虽然Window没有直接存储这个引用&’a T那些引用,但它在逻辑上是依赖于生命周期’a的数据的。PhantomData告诉Rust编译器这一点,就确保上面这个Window的这个结构体指向的数据在Window这个使用期间不会被释放。假如如果没有这个的话,编译器就没办法知道这种生命周期之间的关系,可能就会导致错误。
换句话讲就是什么呢?就是PhantomData<&’a T>表明这个Window结构体就像拥有一个生命周期为’a的T值的引用一样。这样子就帮助到Rust强制执行正确的所有权和借用规则。另外由于这个例子使用了这个PhantomData,所以Window结构体就成为了什么呢?’a和T的这个它的一个协变的一个类型,就跟着它去变的一个类型。
我们再看这个未使用的类型参数和这个PhantomData之间的关系。它指明了这个数据的关系,其实是在这里:
1 | struct LibraryBook<T> { |
PhantomData被用来指明一个结构体绑定了哪种类型的数据,就是这个结构体绑定了哪种类型的结构体。也就是说它会指明数据的这个类型的关系。就这种情况常常是实现外部函数接口(FFI)的时候。如果想了解更多细节,可以看一下这标准文档。但这里面我们就讲到,就是说通俗的一个解释,就是我们举个例子,就是类比一下。就想象你要用Rust代码管理一个这个外部图书馆的书籍,你需要一个结构体来表示一本书,但是书籍本身是由外部系统管理的,就是你只有一个指向书籍的这么一个标签,就是就指向了一个指针,就是裸指针。这时候那个PhantomData
比如这里的pointer是指向图书馆的书籍,这就是一个说白了它就是一个指针嘛。那么这个地方你可以指明这是一本书,你看这里面就是一个Book。就是表明它这种类型是什么,这里是一个幽灵数据是一个Mark,表示它是什么类型的。就虽然LibraryBook这里没有存储任何书籍的内容,没有任何数据内容,PhantomData告诉编译器它代表了一本书,确保在使用这个结构体的时候不会搞错这个数据类型。就指明了你这个数据类型是什么,因为你拿了指针,指针可以指任何的东西。那么这个时候我要知道指针指的这个数据类型是什么,那么就是需要一个幽灵的数据告诉它,这个是一本书这个类型。
那么这里面关键点就是PhantomData不占用空间,但是它提供类型信息。所以它经常用到这个FFI场景,就指明这个外部资源是哪种类型。
这是这几种三种作用讲完了。那么上述作用在现实世界中,它的应用实例是什么样子的?比如说这个是直接从这Rust的标准库当中,代码来自这个1.7的,就Rust 1.7里面展示的是一些PhantomData的这个实际的应用的例子。
我们先看这个BorrowedFd,就是借用文件描述符的这个。看BorrowedFd是Fd,就已经有文件描述符的,是它的一个借用,就是owned Fd的一个借用。在标准库的这个standard::os::fd::owned::OwnedFd这个Rust这个代码中定义是这样定义的:
1 | pub struct BorrowedFd<'fd> { |
其实看一看到也就是很简单,Struct,然后有一个fd RawFd,裸的Fd就是file descriptor文件描述符。然后它也给了它一个’fd。它也给了一个生命周期’fd。在这里面会看到在这个里头PhantomData字段,就这个下划线PhantomData是用于告诉Rust编译器BorrowedFd跟它从OwnedFd借用的生命周期。看这里就是生命周期’fd就引用了,可以引用了这个OwnedFd。是绑定在一起的,可以看到它俩是绑定在一起的。
这时候你有没有发现就这个PhantomData就下划线,这个PhantomData就有点像一个连接的连接的接口一样的,它是由它去跟OwnedFd之间建立绑定的关系。就这对于确保BorrowedFd在使用时OwnedFd不被删除就显得特别重要了。
那通俗的来讲的话,就是说,那么想象就是BorrowedFd是借来的书籍,而OwnedFd是借来的这个实体的这个书。虽然就是BorrowedFd并没有真正的持有这本书,但PhantomData就像这个它像一个借书证一样的,就告诉图书馆这个BorrowedFd的有效期。以下生命周期跟实体书的归还期限是相关联的。那这样子在实体书这个没有归还之前,那图书馆就不会把它把这个书收回。
我们再看就是用PhantomData来保障生命周期,是怎么保障生命周期的。那Rust的标准库中,这个Iter结构体,就是用于这个迭代切片的,就迭代这个切片T。它的定义也是这样子的:
1 | pub struct Iter<'a, T: 'a> { |
比如说我有pointer,然后加上这个end,然后加上这个Mark。你看这就是作作为这个Mark来用的标识,用标识来用的。那但是它可以用于保障这个生命周期, 其实跟上一个例子是有点相似的。在这里我们可以看到就是PhantomData<&’a T>就扮演着其实跟上面是不是很像,就扮演着至关重要的角色。它明确的指出了就是Iter结构体跟生命周期’a就绑定在一块,绑定在一起。换句话讲,就是Iter的生命周期不能超过它所迭代数据的生存期。在一个’a生命周期的引用,它就不能超过这个引用。
那Iter使用两个原始指针,也就是裸指针ptr和end。那这个ptr和end指向这个数据,而这两个指针本身不携带任何的这个生命周期信息。你看没有任何生命周期信息,所以如果要把它生命周期信息搞出来的话,就需要借用这个PhantomData<&’a T>。这Rust编译器就可以得知Iter与数据之间生命周期的关系,从而确保在使用Iter期间,数据不会被意外的释放掉了。那如果没有PhantomData<&’a T>,编译器就无法判断这种关系可能会导致内存安全问题。
你看这个地方就可以看到,如果有这个&’a T,那么就在释放的时候,你知道释放顺序就一开头就讲了,释放顺序就是它处理顺序就知道了。谁先处理,你肯定是说你你不可能我的引用还没有结束,你先释放了,这顺序就不对了。肯定是我引用先没有了,然后你才能才能释放掉。这个顺序不能反掉了,否则就出现什么悬空。
所以我们通俗的一看通俗的一个比喻在里面,就是说想象Iter像是一个火车,它的乘客都是生命周期为’a的数据。它的乘客是’a,那PhantomData<&’a T>就相当于这个火车时刻表。就是这个东西已经放在了,它的生命周期是什么样子的,就是明确规定火车只能在这个数据,就是生命周期存在这个期间运行。那确保这个乘客安全上下车嘛。
这就是起到这个作用,就是说它相当于是生命周期当中有一个内存安全在里面。就是讲到了这个生命周期在上面的作用,原因就是说我的生命周期应该有一个地方可以去附着,那就是谁呀,PhantomData它去附着。因为它没办法附着在这个pointer上面。
我们再看这个内存释放,就是PhantomData跟内存释放之间的一个关系。就最后我们来看看就是标准库中定义这个Rc
那为什么Rc需要PhantomData呢?更深入的假设你可以看到,这个回答好。你既然要我看这个,那我们就把这个拿过来看。那它的假设是这样的:在Rc
那正是这个检查就阻止了以下这个代码,就是下面这个代码的编译。在这个例子当中,Rc的这个泛型参数是PanicOnDrop<&’a u8>,其中就包含了生命周期’a。但是PanicOnDrop结构体本身并没有持有对任何值的引用,它没有引用任何,因为看到它没有引用任何东西,没有引用任何东西。所以说’a实际上没有意义的。那Rust编译器会检测到这一点,并由于这个drop check没法保证’a的生命周期,这个代码就没办法编译。
所以完整的解释要参考这个Rust Reference这个参考手册。这里面有主要几点是什么,就是上面讲到就是说这个’a没有附着点,没有附着点。那没有这个PanicOnDrop的时候,代码是可以编译的,如果没有它,你可以编译。如果去了PanicOnDrop结构体,之前提到的这个示例代码也是可以编译的,就是上面这个代码也可以编译的。
这是因为Rc
这就说明仅靠声明这个may_dangle是不够的,因为实际情况是PanicOnDrop会访问这个T。也就PhantomData的重要性是什么呢,就是使用PhantomData
我的进一步说明的话,它还提供了一个危险行为的这个实例代码。就是它通过使用这个may_dangle缓解PanicOnDrop的drop实现不会访问T,就是它假装它不会使用这个T。如果Rc没有使用这个PhantomData
所以这个地方我们通俗的讲,就说我们想象一下,Rc
但后面说还有一些额外的资源。就是这一节提供几个外部的这个链接资源,帮助进一步了解这个PhantomData的。也就是标准库当中,它是有定义,是位于这个core里可以看到的。Marker::PhantomData就是它是在什么地方的,它是一个标记性类型标记,它就是做一个标记用的。所以PhantomData是一个零字节大小的,没有大小的,也就是说它不占任何内存空间。而且它的对齐是这个只有一个字节。
那同时这个PhantomData跟释放检查就是drop check,规则以及不稳定属性非常的相关密切相关。想了解更多信息,可以参阅一下这个RFC。像要注意的就是,RFC 1238就引入了新的规则,就改变了需要使用PhantomData和这个may_dangle的这种情况。就例如就是说它在标准库中用于实现一个合理的Rc
那PhantomData的另一个有趣的用法是实现这个type state模式,特别是这个泛型状态类型的这个参数的变体。那这个type state我们也单独的会有文章可以去看。
那其实我们总结下来看一下这个PhantomData,这地方其实应该好理解了。它事实上就是一个像这是一个mark,它是一个标记型类型。它的目的是附着谁呢,让谁来附着呢?让这个生命周期等等一些东西来附着它,因为我们在这个pointer上面没有办法直接去标注。我们这个生命周期是什么,’a没法去标记。那总得有个东西给它标记,跟它去形成一个联系。那么这个PhantomData就扮演着这样一个角色。
用类型状态模式优化你的Rust接口
本视频中,将展示如何使Rust API更优十倍。我们将利用Rust出色的类型系统,具体来说,将讨论使用泛型和零大小类型,防止API消费者误用。还将逐步讲解实际案例,以便在开始前将此技术应用于自己的代码,确保访问。
获取免费Rust速查表。设想我们为密码管理器建库,并公开一个名为密码管理器的结构体,包含两个私有字段:主密码和用户名密码哈希表。接着,添加功能使密码管理器有用,包括构造函数new、解锁和锁定方法、列出密码方法、添加密码方法、获取加密算法方法以及获取密码管理器版本的方法。
首先定义密码管理器的API,先看如何使用示例:创建密码管理器的新实例,传入主密码列表解锁管理器,处理密码管理器中的密码,最后再次锁定管理器。
在列出密码之前应如何使用API?必须先解锁管理器。一旦管理器被锁定,我们不能再列出或添加密码。但该API的用户误用并无障碍。例如,我们可以无序调用这些函数,或连续调用函数,尽管这没有意义。如你所见,代码仍可编译。这是一个问题,因为我们不希望API用户遇到运行时错误或意外结果。
使用我们API时,我们想编码API,将密码管理器看作有两个状态:锁定状态和解锁状态。这些状态有不同的功能。例如,当密码管理器锁定时,你应该能调用解锁,但不应该能调用锁定、列出密码或添加密码。相反地,如果密码管理器已解锁,你应该能调用列出密码和添加密码,但不应该能调用解锁。还有一些功能你应该在两种状态下都能调用。在这种情况下,是加密和版本方法。
现在我们知道问题所在以及我们想要编码的信息,让我们回顾一些解决方案。第一个解决方案是向密码管理器添加一些额外状态。在这种情况下,我们可以添加一个新的字段称为locked,这是一个布尔值。然后在构造函数中会设置locked为true,在解锁时,当调用时,如果密码与存储的主密码匹配,我们可以设置locked为false。最后在列出密码和添加密码方法中,我们可以检查locked是否为false,如果locked为true,那么我们需要进行一些错误处理。
我们可以用两种方式来处理这个问题。第一种方式是简单地触发恐慌,这不是理想的选择,因为我们不希望程序崩溃,仅仅因为用户错误地调用了列出密码。第二种选择是改变这些函数的签名以返回一个结果类型,然后如果locked为true,我们可以简单地返回一个错误。
这种方法有几个原因不是理想的选择。首先,它使列出密码的API复杂化,现在返回的是一个结果类型,这迫使API的用户理解为什么这个函数可能会出错。其次,这个API中的所有方法都显示给用户,无论密码管理器是锁定还是解锁。例如,在创建一个新的密码管理器实例后,如果我尝试检查可用的方法,我可以看到添加密码和列出密码是可用的,即使密码管理器处于锁定状态。理想情况下,这些方法甚至不应该被列出,除非密码管理器解锁,以防止混淆。
当前解决方案的问题是它使用运行时检查,因此调用list_passwords在线47不会给我们任何编译时错误,我们只会注意到运行时的问题。为了解决这些问题,让我们探索一个编译时解决方案。我们假设不再只有一个密码管理器结构,而是两个结构:一个锁定密码管理器和一个解锁密码管理器。然后我们可以定义两个单独的实现块。
锁定密码管理器将包含一个构造函数和解锁方法,它将返回一个解锁密码管理器。它还将包含加密和版本方法。然而,我们将移动list_passwords,并在解锁密码管理器的实现块中添加add_password方法,并将实现lock方法,该方法返回一个锁定密码管理器。解锁密码管理器也必须实现加密和版本方法,因为这些方法对两个结构都是通用的。
现在我们的API更新了,让我们看看它在main中的使用情况。我们将更新密码管理器为锁定密码管理器,这将在第66行自动引发错误,因为list_passwords方法在锁定密码管理器上不可用。如果我们看一下可用的方法,唯一可用的方法是加密、版本和解锁。这很好,因为用户只能访问在锁定状态下有意义的方法。
让我们继续修复这个代码,首先调用unlock,我们还将unlock的返回值保存在名为manager的变量中,遮盖原始的manager变量。现在manager指向一个解锁的密码管理器,我们可以在第67行调用list_passwords。然而,我们不能在第68行调用unlock,因为我们已经有一个解锁的管理器。我们在第68行得到一个最后的编译时错误,因为我们正在调用lock,这需要一个可变的self引用,但我们有一个不可变的manager实例。
让我们继续更新锁定和解锁方法以消耗self。很好,我们现在有一个API,它可以在编译时防止用户误用它。但这个解决方案仍然不是理想的,有几个原因。注意,这里有很多重复。这两个结构包含相同的字段,我们需要这种情况,以便在状态变化之间保留这些信息。两个结构也必须实现两个状态之间共有的函数,在这种情况下,是加密和版本。
我们希望保留这些漂亮的编译时检查,而不需要所有这些重复。Rust允许我们实现这一点,使用泛型和零大小类型。首先,让我们回到使用一个名为密码管理器的结构。我们还将创建两个单元结构来表示锁定和解锁状态。接下来,我们将向密码管理器结构添加一个名为state的泛型参数,并将其默认设置为Locked。
我们现在得到一个编译时错误,它说状态参数从未使用,建议删除state,或在其字段中引用它,或使用标记,如PhantomData。在这种情况下,我们选择选项三,使用PhantomData。因此我们将添加一个新的名为state的字段,并将其设置为PhantomData。我们的结构现在没有错误,但我们刚刚到底做了什么?
我们添加了一个名为state的泛型参数。当我们创建新的密码管理器实例时,我们必须用具体类型替换这个参数。它默认设置为Locked结构,但也可能是Unlocked结构。这是有益的,因为加密的密码管理器不等于未加密的密码管理器,这是两种不同的类型。这意味着我们可以为每种类型实现不同的方法,我们将在下一秒这样做。
这种方法的酷炫之处在于我们能够创建不同的类型,而无需为每种类型创建单独的结构。但添加泛型参数会导致一个问题:我们必须在结构内部使用泛型参数。问题是我们并不真正关心这个泛型参数,我们只是用它来创建不同的类型。这就是PhantomData的作用。PhantomData是一种零大小的类型,用于标记看似拥有它们的东西。在这种情况下,state通过使用PhantomData,密码管理器将表现得像它在state字段中存储了状态,但在编译时,这个字段实际上将被优化掉。这就是PhantomData被称为零大小类型的原因,因为它不占用空间。我们可以不使用PhantomData,简单地在state字段中存储状态并从不使用state字段,然而,那只是在浪费内存。
现在我们已经定义了密码管理器结构,让我们先更新实现块,将锁定密码管理器更改为密码管理器,其中state设置为Locked。这个块中我们将只放入unlock方法。我们还将更新此方法以返回解锁密码管理器。接下来我们将更新解锁密码管理器。这个实现块将包含三个方法:lock、list_passwords和add_password。我们将加密和版本移动到单独的实现块中。最后我们将创建一个包含构造函数的更多实现块。
好吧,让我们一起浏览这些实现块。从顶部开始,这个实现块仅适用于已锁定的密码管理器。密码管理器有一个名为unlock的方法,它返回一个解锁的密码管理器。此实现块仅适用于解锁的密码管理器。解锁的密码管理器可访问三个方法:lock、list_passwords和add_password。下一个实现块使用泛型而不是具体类型,这意味着这些方法将适用于密码管理器的所有实例,无论具体状态类型是什么。此最终实现块包含相关函数,在这种情况下只是新的构造函数。
完成密码管理器的实现后,让我们更新main。它将简单地更改锁定密码管理器为密码管理器。当我们首次构建密码管理器时,它默认是一个锁定的密码管理器。因此,如果我们查看可用的方法,它们是解锁、加密和版本。然后在调用解锁后,我们得到一个解锁的密码管理器。如果我们检查可用的方法,你可以看到它们是列出密码、添加密码、锁定、加密和版本。
恭喜,现在你知道如何使用泛型和零大小类型以防止用户误用你的Rust API,并更接近成为Rust专家。在你走之前,确保访问Let’s get rusty获取速查表。如果你喜欢这个视频,确保给它点赞,并订阅频道以获取每周的Rust内容。就这样,下次见!
Rust Cell与RC全貌视角
在 Rust 语言中,有一个关于 Cell 和 Rc(引用计数)的问答被提及。这个问题已经存在了六七年,但最近仍然有人在编辑和讨论,说明它依然很受关注。
问题的核心
该问题的提出者询问了 Rust 标准库中几种包装器类型的功能。具体来说,问题涉及以下类型:
std::cell模块中的Cell和RefCell,即Cell和引用计数的包装器Rc以及AtomicRc。std::sync模块中的Mutex和AtomicBool。
提问者掌握了一些基础知识,但对这些类型的全貌还没有完全理解。他的问题是:这些 Cell 和 Rc 系列的功能到底是什么?它们之间是正交的还是相似的?
高票回答:MATTHEWM 的解释
Rust 语言有两个核心概念:所有权和可变性。Rust 的安全性主要依赖于以下原则:
- 一个对象通常只有一个所有者(所有权)。
- 对象通常是不可变的(可变性)。
这些原则确保了 Rust 的安全性,但也导致了使用上的某些不灵活性。因此,出现了包装器类型(wrappers),以提供更多的灵活性。
指针类型
Rust 中的各种指针类型(如 Box、Rc、Arc、AtomicRc)主要与所有权相关。它们允许控制一个对象是有单一所有者还是多个所有者。
- 单个对象通常只有一个所有者,出于安全考虑。
- 然而,这些指针类型提供了灵活性。
Cell 和 RefCell 类型
另一方面,各种 Cell 类型(如 Cell、RefCell、Mutex、RwLock、Atomic 类型)与可变性相关。通常情况下,Rust 要求对象是不可变的,如果要改变对象,必须具有独占访问权。
Cell通过防止创建指向其内部的引用,来避免悬垂引用问题。RefCell将可变性检查从编译时推迟到运行时,从而提供所谓的内部可变性。这意味着对象从外部看起来是不可变的,但实际上它是可以被修改的。
当涉及多个线程时,Mutex、RwLock 或 Atomic 类型可以提供类似的功能。
Atomic类型不允许指向内部的引用,只能整体读写数据。RwLock允许通过Guard获取指向内部的引用。
Rust 的安全规则
Rust 的安全规则是基于以下原则:
- 如果有一个指向类型
T的不可变引用,那么在相同作用域内不能同时拥有指向相同对象的可变引用。 - 如果有一个指向类型
T的可变引用,那么在相同作用域内不能同时拥有任何指向相同对象的引用。
然而,这些限制有时不够灵活,因此需要使用 Cell 类型。在受控制的情况下,Cell 和 RefCell 允许存在别名(aliasing)并进行修改。
总结
如果你有 C++ 背景:
Box类似于unique_ptr(单个指针)。Arc类似于shared_ptr(共享指针)。Rc是非线程安全的shared_ptr。
Cell 和 RefCell 类似于可变类型,但它们增加了防止别名问题的机制。Cell 可以看作是 std::atomic 的替代品,而 RefCell 则是非线程安全的 std::shared_mutex 的替代品。
这个解释详细探讨了 Rust 的所有权和可变性原则,及其在实际编程中的应用,帮助读者理解在不同情况下应该使用哪些类型。
不要把Rust写成Java
不要把Rust写成Java
https://www.bilibili.com/video/BV1o2Y5eCEGi/
看了一篇非常初级的小文章,篇幅很短。这篇文章是去年8月份写的,已经有一年了。作者叫James Skier,有兴趣的可以看一下原文链接。文章的标题是“不要把Rust写成Java”。
我对Rust的这个想法已经感兴趣好几年了。毕竟,如果有内存安全并且强调正确性,这有什么不好的呢?我在开发Apollo(一个Python应用程序)的时候,遇到很多错误,这些错误本可以被Rust的编译器捕获。虽然Rust编译器不能捕获百分之百的错误,但已经非常接近了。一般来说,编译器可以捕获很多在使用动态语言(如Python或Ruby)时可能进入生产环境的问题。尽管并非所有编译器都一样,内存安全很棒,但Rust对正确性的强调是我最感兴趣的地方。
我在工作中写了不少Java。虽然Java不是我最喜欢的语言,但它的类型检查功能真的很强大。相比Python或Ruby,重构代码时没那么可怕,因为有编译器保驾护航。如果少写了一个import语句,或者写错了,编译器会直接报错,而不是等到程序运行时才发现问题。当然,我们还是会写测试来确保代码的正确性,但编译器的提前检查无疑是一个大的优势。
然而,Java编译器并不完美,它无法保护整个类别的错误。最臭名昭著的就是空引用。在Java中,几乎所有东西都可以为空,直到运行时才会发现。另一方面,Rust有结构来引导你处理未知值。当然,你可以选择忽略这样的指导方式,但编译器会强迫你做出一个深思熟虑的决定。
Rust确实有很多吸引人的地方,它的承诺非常诱人。然而,我的Rust之旅并不总是那么顺利。虽然Rust和Java有相似之处,但Rust不是Java。直到我停止试图把Rust写成Java,我才真正享受到了写Rust代码的乐趣。
一切都应该是接口吗?
虽然不完全准确,但有一种说法是,Java的开发者需要一切都是接口。我就是这样的开发者。Java中的接口很好用。你的应用程序由小的工作单元组成,其中一个工作单元不直接了解另一个工作单元的工作原理。启动依赖注入需要一些前提工作,但一旦完成了,你就有一支随时待命的独立服务大军。有很多很多接口,你不需要管具体怎么实现,只要拿着接口用就行了。
然而,在Rust中是没有接口的,但是Rust中有特性(traits)。它们在很多方面类似于Java中的接口,但试图在Rust中将一切都变成一个trait就不好玩了。还记得Rust的内存安全特性吗?它的代价是你无法轻松地去注入一个实现某个特性的东西。
举个例子:
1 | trait Named { |
上面的代码无法编译,为什么呢?因为name的大小在编译时是没法确定的。为了解决这个问题,我们可以将这个特性用Box包装起来。Box允许我们指向堆上的动态分配内存。在Rust中,这种对象叫做动态对象。Box本身是已知大小的,因此允许程序去编译。
1 | trait Named { |
虽然Box是一个解决方案,但它不是我最喜欢的模式,因为它们使用起来很难。我会尽量避开它们。我们可以使用泛型来指定特征的类型:
1 | trait Named { |
乍一看,Box<dyn Named>和泛型的结果是一样的,但它们的区别在于动态分配和静态分配。对于特征对象,具体类型在运行时解析;而对于泛型,具体类型在编译时解析。
在实践中,这意味着只要我们可以在编译时推断出所有类型,就可以使用泛型。如果类型必须在运行时推断,那就需要使用Box来包装它。
所有权问题
另一个问题是所有权。如果我们的name特征是其他服务的必要依赖项,怎么办?我们可以创建一个Arc<dyn Named>,并且把它的引用传递给每个依赖项。这允许对原有的资源进行并发访问。我尝试过这两种方法,虽然可行,但并不爽,尤其是在应用程序中的每个服务都受到影响的时候。
那么怎么办呢?我们可以使用函数。强迫Rust成为一门纯粹的面向对象语言不会让人感觉愉快。事实上,Rust中有很多事情可以通过函数来解决,而不是接口或特性。虽然仍然可以编写类似面向对象的服务对象,但我尽量只在必要时使用它们。
例如,我们可以考虑一个处理Stripe结账完成事件的函数。这个函数更新系统中Stripe客户的ID。
1 | fn complete_checkout(client: &Client, context: &Context, user_id: &str) { |
虽然我们可以把UserWrapper当作一个服务注入进来,但这会增加很多不必要的复杂度。而且,把这个逻辑写成一个服务也没有太大必要,因为我们可以轻松替换UserWrapper的实现。例如,创建一个不连接真实数据库的版本。
当然,这样的函数参数列表可能会比较长,但这比其他的方法要好得多。你不需要把它做成一个服务,只需稍微变动一下实现就可以了,更加简单。
拥抱Rust的本质
我曾经深深陷入Rust的误区,一个主要原因是我固执地认为Rust代码应该像我以前写的其他代码一样。虽然借鉴过去的经验是好事,但要真正掌握Rust,就必须接受它的独特风格。Rust需要我们转变思维方式,不要试图把它变成其他语言,而是要拥抱它自己的独有特性。
这篇文章很短,举的例子也非常浅显,但很有意思。如果你写Java比较多,这可能会带来一点点小的启发。
28. Rust Clippy Lint,通过 Rustlings 快速学习 Clippy
以下是您提供的内容经过整理后的版本:
28. Rust Clippy Lint,通过 Rustlings 快速学习 Clippy
这一节我们要练习 Rust 的 Clippy 工具。Clippy 是一个帮助我们分析代码语法的工具,它会给我们一些代码提示,帮助我们改进代码质量。
安装和使用 Clippy
安装 Clippy 可以通过 rustup component add clippy 命令完成。安装后,我们可以使用 cargo clippy 来运行 Clippy。如果想了解更多用法,可以使用 cargo clippy --help 查看帮助信息。
第一个练习
在第一个练习中,Clippy 提示我们在第14行使用了一个自定义的 pi 常量。实际上,Rust 标准库已经提供了 std::f32::consts::PI 常量,我们可以直接使用它,而不必自己定义。
我们可以将代码中的 pi 常量替换为 std::f32::consts::PI,这样可以使代码更简洁和规范。此外,还可以直接将 PI 常量放在需要的地方,而不必定义额外的变量。
第二个练习
在第二个练习中,Clippy 提示我们在第九行使用了一个 for 循环来处理 Option 类型。Clippy 建议我们使用 if let 语法来处理 Option,这样代码会更加符合 Rust 的惯用写法。
我们可以将代码改为:
1 | if let Some(x) = option { |
这样写不仅功能相同,还更加简洁。
第三个练习
第三个练习中有多个错误提示。首先是在第14行,Clippy 提示我们可能漏掉了一个逗号。事实上,在最后一个元素后加逗号是没有问题的。
其次,在第九行,Clippy 提示我们在 Option 为 None 时使用了 unwrap,这会引发 panic。代码中确实存在这个问题,最简单的解决方法是不使用 unwrap,而是使用更安全的处理方式。这个练习主要是让我们感受 Clippy 的能力展示。
在第22至23行,代码在替换字符串时使用了 mut。Clippy 建议我们可以使用标准库中的 replace 或者 swap 函数来进行替换,这样代码会更简洁和安全。
最后,在第16行,Clippy 提示我们忽略了 let 绑定,因为返回值是 unit 类型。我们可以直接将代码简化,或者声明一个变量来接收返回值,再进行打印。
总结
Clippy 是一个非常有用的工具,它可以帮助我们在项目中发现潜在的问题,并且可以集成到 CI 流程中,每次提交代码时自动进行检查。Clippy 能够提高代码质量,使我们的 Rust 代码更加规范。
下一节我们将讨论类型转换(Conversion),这也是一个非常有趣的话题。如果我的视频对你有帮助,欢迎点赞、收藏、分享,给我更多的支持,让我有动力继续制作更多的视频内容!今天的内容就到这里,谢谢大家!
cargo expand 试试这个
有问题访问 https://www.deelmind.com.cn 加我的微 WX 信号: DeeLMind
第四课 - Rust 调试分析
📅 DeeLMind 2024-10-24 07:26:24
🔗 哔哩哔哩 - Rust 调试分析
开场白
哈喽,大家好!我是你们的 中央new MAD。
今天我们继续讲 Rust。
在之前的几节课里,我们已经学习了 Rust 语法,上节课讲解了 复合类型。虽然它并不算复杂,但通过学习,大家应该已经可以写一些基础的代码了,比如 for 循环、基本的加减乘除 等等。
那么今天,我们来讲解 软件调试。
Rust 代码调试的重要性
在写代码的过程中,如果不会调试代码,那就相当于不会写代码!
任何一个会写代码的人,都必须会调试代码,没有例外。
今天,我们会介绍如何使用 Rust 的调试工具,并结合 逆向分析 的方法,帮助大家理解和优化代码。
Rust 调试工具
1. 逆向分析工具
在调试软件时,我们可以使用多种工具,如:
- IDA
- x64dbg(64 位调试器)
2. 代码编译与架构
Rust 编译时,默认会按照当前 操作系统架构 进行编译:
- 32 位系统 → 默认编译为 32 位 可执行文件
- 64 位系统 → 默认编译为 64 位 可执行文件
当然,你也可以手动指定编译为 不同架构,但需要额外的编译选项。
创建 Rust 调试项目
我们新建了一个 Rust 工程,命名为 db_机,使用 cargo new 创建:
1 | cargo new db_机 |
然后执行 cargo build 进行编译:
1 | cargo build |
默认生成的可执行文件名称可以通过 Cargo.toml 配置:
1 | [package] |
如果修改了 name,则需要先清理再编译:
1 | cargo clean |
这样,新生成的可执行文件名就会跟随 name 变化。
Rust 代码调试
Rust 调试可以使用 rust-gdb 或 rust-lldb,也可以在 VS Code 或 RustRover(JetBrains 系列 IDE)中进行调试。
1. VS Code 调试
在 VS Code 中,我们可以使用 CodeLLDB 插件 进行调试。
和安装 Rust Analyzer 一样,我们只需在扩展市场安装 CodeLLDB 插件即可。
配置环境变量
调试 Rust 代码时,需要配置 LLDB 相关的环境变量,否则可能会遇到 无法识别调试信息 的问题。
如果不熟悉环境变量的配置,可以直接使用我的配置文件,或者手动调整 launch.json 进行适配。
2. 断点调试
在 VS Code 中,我们可以:
- F8 / F10 单步调试
- 查看变量值
- 查看内存地址(指针调试)
调试的核心目标:
- 查看变量值(局部变量、静态变量、全局变量)
- 检查寄存器状态(整数寄存器、浮点寄存器)
- 分析调用栈(函数调用链)
逆向分析 - 无源码调试
如果没有源码,我们可以使用 IDA / x64dbg 进行二进制分析,方法如下:
1. IDA 逆向分析
在 IDA 中,我们可以:
- 载入 Rust 编译的可执行文件
- 观察 main 函数的汇编代码
- 交叉引用字符串,如
"The value of e is"
由于 Rust 编译会增加 额外的封装层,其汇编代码比 C 语言复杂,直接分析 Rust 代码的难度较大。
2. x64dbg 逆向调试
在 x64dbg 中,可以:
- 查找 字符串(
The value of e is) - 查看 寄存器状态
- 观察 内存区域(数据段、堆栈段)
Rust 代码的安全性较高,编译后的代码较难逆向分析,不像 C 语言那样直接可读。
Rust Rover 调试
Rust Rover 是 JetBrains 旗下的 Rust 专用 IDE,相比 VS Code,它的调试功能更强大,支持:
- 一键调试
- 查看寄存器状态
- 查看汇编代码
- 内存调试(搜索变量地址)
- 使用 LLDB 进行高级调试
示例:查看 v3 变量的内存地址:
1 | let v3 = 3; |
在 Rust Rover 的 内存视图 中,可以搜索 v3 的内存地址,并查看其 实际存储值。
动态调试 vs 静态分析
Rust 调试方式分为:
- 源码调试(有源码)
- 通过 VS Code / Rust Rover / LLDB 进行调试
- 适用于 开发阶段的 Bug 修复
- 无源码调试(二进制逆向)
- 通过 IDA / x64dbg 进行分析
- 适用于 安全研究、漏洞挖掘
由于 Rust 代码在编译时会增加 额外的封装层,直接分析其汇编代码较为困难,因此 Rust 代码的 安全性较高,不易被逆向分析。
总结
- Rust 调试 是写代码的必备技能
- VS Code 适合一般开发者,Rust Rover 更加强大
- IDA / x64dbg 可用于 无源码调试,但 Rust 代码安全性较高
- Rust 代码在编译后比 C 语言更难逆向分析,适合安全性要求高的项目
- 调试能力 = 代码能力,不会调试就不会写代码!
希望大家通过本节课掌握 Rust 调试技巧,我们下期再见!👋
📌 下节预告
下一课 我们将深入探讨 Rust 内存管理,包括:
- 堆 vs 栈
- 所有权机制
- 智能指针
- Rust 内存安全性
https://www.youtube.com/watch?v=XCrZleaIUO4
https://www.bilibili.com/video/BV1VT421r7u9/
整理后的内容(中文)
Rust 库开发的九大最佳实践
在构建 Rust 库时,我遵循九个最佳实践,并将使用真实的库 Gen 作为示例。
1. 模块结构
首先,我们来看 lib 目录的结构。我使用 Rust 的模块系统主要有三个目的:
- 命名空间(Name Spacing)
- 作用域(Scoping)
- 代码组织(Code Organization)
命名空间
例如,我们有 support 和 webc 这两个模块,它们是库的私有模块,因此可以在整个库的代码中访问它们。
作用域管理
在底部,我们有一个 公共模块(public module),它被导出到 API 中。我使用作用域管理(scoping)来决定哪些模块是公开的。例如,一些模块被限定在 crate 作用域,以确保它们只能被库内部使用。
代码组织
在代码组织方面,比如 mod client、mod common 和 mod error,我采取“扁平化”策略(flattening)。这样做的原因是,我不希望用户必须写 gen::client::client,而是直接能使用 let client = gen::Client::default();。
通过这种方式,Client 直接位于根模块,而不是嵌套在 client::client 之下,这样既符合逻辑,也避免了冗余。
子模块的导出
有些子模块是需要导出的,比如 chat,因为该库的一个核心功能就是聊天功能。这样,用户可以直接使用:
1 | let chat_request = gen::chat::ChatRequest::default(); |
这种叫outer标记,只对后面的一个结构块有效。
定义基本结构
按照Rust的方式,lib.rs里不要写太多内容,应该分配到其他module里。我们创建一个sub module来定义分数:
1 | pub struct Fraction { |
为什么用u32而不是u64?因为我们考虑到嵌入式设备,64位在嵌入式设备里的支持不一定好,所以直接用32位。
最大公约数实现
每次创建Fraction时,应该保证它是最简化的形式,需要让分子和分母同时除以最大公约数。我们使用欧几里得算法:
比如求42和12的最大公约数:
- 42 % 12 = 6
- 12 % 6 = 0
- 结果是6
关于是否需要提前比较大小并交换位置,实际上不需要。如果把小数放前面,只是多做一次循环就自动换位了。但提前判断会多一次分支预测,可能影响CPU性能。我们后面会用benchmark测试哪种方式最好。
1 | fn gcd(mut m: u32, mut n: u32) -> u32 { |
单元测试
写完后立即写单元测试:
1 |
|
实现Fraction构造
第一个实现需要处理分母为零的情况。参考标准库的做法,我们返回Option<Self>而不是Result。
为什么标准库很多地方用Option而不是Result?因为早期要把这些方法放到core里面,而core原本不支持Result(Result需要堆分配)。直到最近Result才被移进core,原因是Rust成为了Linux内核的开发语言,内核开发需要用Result来代表错误。
1 | impl Fraction { |
特别注意零的表示:零可以表示成0/1、0/2等多种形式,但我们希望唯一表示为0/1。通过最大公约数算法,当分子是零时,最大公约数是分母本身,所以除法后分母会变成1。
添加符号支持
目前Fraction只能表示非负数,需要添加符号。我们定义一个专门的类型:
1 |
|
为什么用0和1?因为两个数相乘时,符号的异或运算就是结果的符号:
- 正 × 正 = 正 (0 ^ 0 = 0)
- 负 × 负 = 正 (1 ^ 1 = 0)
- 正 × 负 = 负 (0 ^ 1 = 1)
添加符号后的结构:
1 | pub struct Fraction { |
添加辅助方法:
1 | impl Fraction { |
类型转换
当定义了自己的结构时,首要考虑的是原有数据类型怎么转换成我们的类型。
基本类型转换
1 | impl From<u32> for Fraction { |
使用宏简化多个类型的实现:
1 | macro_rules! impl_from_unsigned { |
TryFrom for 更大范围类型
对于u64、i64等超出u32范围的类型,需要用TryFrom:
1 | impl TryFrom<u64> for Fraction { |
处理i128时要特别小心,因为-128无法直接转正:
1 | impl TryFrom<i128> for Fraction { |
运算符实现
加法实现
分数加法:a/b + c/d = (a×d + c×b)/(b×d)
但直接实现会有溢出问题。解决方法是先转换成u64:
1 | impl Add for Fraction { |
需要考虑符号的所有情况:
- 两个都非负:直接相加
- 一正一负:实际是减法,需要判断大小
- 两个都负:相加后符号为负
负号实现
先实现负号运算符,可以简化减法:
1 | impl Neg for Fraction { |
减法实现
有了负号后,减法就是加上负数:
1 | impl Sub for Fraction { |
乘法实现
乘法相对简单,注意符号的异或运算:
1 | impl Mul for Fraction { |
除法实现
除法需要先判断除数是否为零,然后乘以倒数:
1 | impl Div for Fraction { |
文档和测试
文档注释
需要写详细的文档和示例:
1 | /// 有理数表示 |
文档测试
运行cargo test时会同时运行文档中的示例代码(doctest)。这确保文档中的代码始终有效。如果修改了实现,必须同时更新:
- 单元测试
- 文档示例
文档是对用户的承诺,如果文档中的代码跑不过,用户就不会再用这个library。
关键要点总结
- 平台兼容性:使用
#![no_std]支持嵌入式设备 - 类型转换:实现From/TryFrom让用户方便地转换类型
- 单元测试:每个public API都要写单元测试
- 文档测试:文档中的示例代码也会被测试
- 性能考虑:后续需要benchmark测试和优化
后续计划
- 实现更多标准库trait
- 使用泛型支持不同大小的整数类型
- 支持BigUint实现任意大小的有理数(需要std)
- 性能优化和基准测试
- 考虑SIMD优化
claude:
Rust库开发的九个最佳实践
一、模块结构
在构建Rust库时,我使用模块系统实现三个目的:命名空间、作用域和代码组织。
以gen库为例,在lib.rs中可以看到这样的结构:
命名空间:我有support和webc等私有模块,它们对crate是私有的,只能在应用代码内部访问。
作用域:底部有公共模块,会被导出到API中。我使用作用域来控制可见性,这些模块作用域是crate级别的。
代码组织:对于mod client、common、error等,我会将它们扁平化处理。因为我不需要用户写gen::client::client这样的代码。我想要的是:
1 | let client = gen::Client::default(); |
我可以写成client::Client,但那会显得多余,因为client本身就是库的入口点,有client::Client会很冗余。
不过,我确实导出了一些子模块,比如chat。这是因为我的库可能有多种功能,chat是其中之一。所以我允许用户这样写:
1 | let chat_request = gen::chat::ChatRequest::default(); |
这样就在命名空间中组织了ChatRequest。对于client,我不想要命名空间,我希望它在根目录,这就是我的库的人机工程学设计。
我通过扁平化来实现这一点。因为我不想把client的所有代码都放在lib.rs里,只是为了避免子命名空间。这就是我使用模块系统进行代码组织的方式。
如果查看chat模块,我采用同样的策略。在模块文件中,我有所有这些不同的子模块,但我把它们全部扁平化了。因为我不需要也不想让用户遍历所有不同的子模块,只是因为我想这样组织代码。但同时我也不想把所有代码都放在下面,所以我用文件来组织它们,加上一些命名约定,然后将它们扁平化。
这样的命名如果我以后想改变或重新组织,也没关系,因为任何使用这些模块类型的人都会以扁平的方式使用它们。所以我可以随时改变它们。如果我想让某个工具实际成为一个子模块,我可以那样做,可能会做一次,那将是chat::tools。
这是使用Rust模块系统的强大方式,既可以实现命名空间,也可以进行代码组织。
作用域方面更加精细:
如果查看adapter,我使用了同样的模式,但更高级一些。我有mod support,稍后会讨论,然后我有我的模块,我会在不同的作用域中重新导出其中一些。在这种情况下是私有的——所有适配器我都不想让它们泄露到外部。我不想让client知道adapters的内部细节,但我希望adapters和adapter dispatcher能够调用它们。这就是为什么我把它们放在那里。
Rust的规则是:模块的每个子节点和孙节点都可以看到其父节点能看到的一切。这意味着它对所有这些子模块都是可访问的。但是types和dispatcher这两个家伙要导出它们,因为client需要它们。
这样我使用作用域来真正在代码设计中编码我的意图,确保一切都有意义。开始时可以比较宽松,随着代码设计的成熟,我会添加它们来确保一切都有意义。
实际上,如果查看adapters,open_ai adapter有trait的实现和streamer等,被anthropic和groq使用。如果查看adapter实现,我有adapter trait的实现,这是内部的东西,确保我可以在所有适配器之间重用逻辑。然后我有一些所有adapter都可以使用的工具,我也把它们放在结构体中。我可以把它们作为函数,但我把它们的作用域相对精确。
例如,这个我说我只想让adapters能够访问它,这不是我想要泄露到外部的东西。我可以用pub(crate),对库的用户来说完全一样,他们都无法访问。我这样做的原因是验证代码设计按我的意图工作。
然后这个例仅用pub(super),意味着只能在open_ai中使用,其他的无法访问。这是作用域如何工作的例子。
在Rust中,我们可以非常深入地控制作用域,这很酷。你不必一开始就使用它,可以先用pub(crate),然后逐步缩小范围,以确保你的代码设计有意义。
二、错误处理
我已经做过错误处理最佳实践,这里展示如何在gen库中使用这个最佳实践。
对于这个最佳实践,我们定义error模块,把所有错误放在error.rs中,然后扁平化它们——error和result。因为我不想要像gen::error::Error这样的冗余代码。我更喜欢只用error:
1 | match error { |
这样更清晰。实际上我们还有另一种类型的错误,用于resolver,也是这样做的。因为我不需要error模块名,这又是模块系统扁平化能力的好用法。
如果查看error文件,我们有error enum和result类型别名。这是个人偏好,我喜欢将result类型别名为T,然后以某种方式硬编码error为模块的error。这样当我使用result时不需要给出error,我知道它总是模块的error。我喜欢这样做,但还有另一种方法,就是使用普通的result和crate的error。最重要的是正确处理error。
对于error,我通常喜欢用enum,有时我会有一个struct和里面的enum,但通常我从enum开始。然后我有error的名字,我实现了Debug,这非常重要,稍后会看到原因。
然后我有from,这里我特意不使用thiserror,因为实际上我使用的是derive_more的from,我用的是beta版本,非常稳定,已经很老了,所以非常稳定。这允许我将display和from分开。这样我去掉了from的样板代码,但我控制了display。
技巧是display——这对某些人可能看起来很丑,但我认为实际上是个好方法——我将display包装到debug中,这是error的某种要求。这就是为什么我有那里的debug。
原因是我想让我的error enum尽可能明确。我不需要添加英文,无论如何我的英文都会是蹩脚的英文,而且你也不想让开发者的英文传给最终用户。
当你做命令行时这完全没问题,但当你要做一个将在其他地方使用的库时,要阅读消息的人无论如何都会是技术人员、运维人员甚至开发者。在这种情况下,我们想要的是我们的enum及其数据尽可能明确,这是关键。
不必是清晰的英文,但必须对库的领域非常明确。这消除了很多冗余,不用在这里写某种display,我们经常不能保持与其余部分同步,而且我们在那里放太多含义,有时这并不真正有意义,我们需要display来获得内容的含义,在我看来,这更像是一种反模式,而不是说不要英文,只要结构化。
然后确保在ModelInfo中我有model_name和adapter_kind,这里我有实际的role,所以一切都可以非常非常清楚。这就是我所做的。
现在第二部分有点反直觉,实际上我在0.1.3版本中做了error,然后在0.1.4版本中又改回来了。这些错误实际上今天是由adapter触发的。
通常这可能是一个信号,说明这些应该属于adapter,我们有一个adapter error等等,这很好,实际上这就是我们对error所做的,我们有mod error,这里有一个error。完全没问题。
但技巧是当我这样做时,我没有意识到的是,我将内部责任暴露到了外部API中。因为实际上chat request今天没有消息,adapter在检查它,但明天client可能在检查它。所以说这是adapter error会有点错误。
实际上,这里的很多东西可能是adapter的,但实际上它们更多是在库级别的。所以通常我开始的地方,我总是从库根部的error开始。我做我的enum,我让它们尽可能明确和清晰,我不太担心命名,我试图保持一致而不是正确的英文,但我试图让它们能够独立存在。
然后最终随着代码的增长,我可以看到什么应该属于另一个子模块。例如,这里非常清楚的一件事是resolver,它有自己的error是有意义的,因为这是用户可以覆盖的一个东西,所以让他们有一个清晰的error来响应。
我们的support中也有一些错误,在我们的value扩展中,扩展trait将在另一个视频中讲。
如果我要做命令行,命令行最好有display,我实际上会使用derive_more的display,然后有一个宏类似于thiserror,将是display。所以这不是教条式地永远不会有display,而是不要默认使用它,然后在需要时添加它。
三、构造函数和Builder最佳实践
我喜欢这样划分:构造函数模式、setter、链式setter和builder。这是因为不是每个从链式setter受益的类型都需要builder。
构造函数级别
如果你有一个相对简单的类型,可以不带任何参数构建,并且是不会失败的,那么实现default通常是最好的选择。因为它对Rust非常惯用,很多Rust库和其他库在自己的API中使用default实现,所以如果对类型有意义,提供它有很多好处。
例如在gen中,我们有chat_request,然后有default,将构建一个空白的chat_request,这可能很有意义。
下一个模式是new,当有明显的数据可以作为类型的起点时使用。那将是一个new,接受一个或多个数据,通常是一个,可能是多个。gen中的一个好例子是chat_request的new,然后接受一个chat_message的vector。这是一个清晰的模式,显然可以只有一个。在这种情况下,我的chat_request有一个messages的vector是完全有意义的,所以非常合适。
当需要多个构造函数时,我们做from_然后是有意义的东西。例如在我们的chat_request中,我们有from_system,它会将system添加到chat_request中,不是作为消息,而是作为system属性,两者都支持。
最后是自定义名称,这些更多是例外而不是规则,但有时可以使API好得多。例如,如果有chat_message的from_user,我猜会是这样的,然后为什么天空是红色的,那会有点长,虽然在教条上是正确的,但在这个聊天消息的领域中,实际上有user是很有意义的。
因为我们可以有user_message、assistant_message,甚至system_message。这真的很合适。所以在chat_message的上下文中做一个小例外,因为这真的非常合适。
目标是保持纪律性和一致性,但不要过度教条,当某些东西真的很合适时。所以当你看到这样的东西,用户自动理解这是一条带有这个文本的用户消息,没有歧义,非常清楚。清晰度第一。
From trait
下一种也非常重要的构造函数类型是实现正确的from,这可以极大地帮助API的人机工程学。
例如在我们的例子中,我们有一个类型叫model_name,显然我们有从string到model_name的实现,它会做正确的实现。基本上model_name就像model_name: Arc<str>,这允许非常高效地克隆它,因为在请求期间它被克隆很多次等等,所以这使它高效。但我们可以做一个into,工作得很好。
顺便说一下,有时你可以稍微花哨一点,你可以说T例如into string。这样我们不必实现&str和所有东西。问题是这被称为blanket实现,有时可能会冲突。
所以如果model_name也可以into string——在我们的例子中确实可以,因为我们为它实现了display——你会得到一个编译错误。所以有时你不能在blanket实现上过于聪明。
你在这里做的完全没问题,你做string,然后如果需要,你做&String和&str或其他技术,这将允许你覆盖大多数用例。代码稍微多一点,但会工作得很好,你有更好的控制。这就是我们为model_name所做的,两个方向都有。
Setter
对于setter,我喜欢的模式是with_,这是我的默认选择。我喜欢消费式的链式setter,你也可以做&mut,取决于你想如何设计API。然后你通常接受一个,可能是多个,但通常是一个。
gen中的一个例子是,一旦有了chat_request,我们也有with_system,你可以像这样放入你的system。这样你可以像这样启动你的请求,然后之后添加system,或者做from_system。
我们有的不会是with_messages,那会很奇怪,所以我们使用append,也可以用push,也将是消费式的。例如那将是:
1 | chat_request.append_message(ChatMessage::user("why sky red")) |
现在事情开始配合在一起了,我们有构造函数模式,在那里有意义,我们有可以链式的setter等等。
有时我也有另一个命名约定,更像键值对,我做insert,然后我会做self、key、value。在gen中我为client builder使用它,我们有insert_adapter_config,这是adapter_kind,这是adapter_config。这样非常合适。
顺便说一句,很酷的是,因为我们实现了正确的from,在gen中我们有一个类型叫message_content。所以这里感觉我们总是给一个string,system的情况是这样,但对于chat_message,这实际上会实现像这样的东西,我们有content,我们有impl Into<MessageContent>,message_content有from string等等,所以这就是为什么它工作。
这将在以后有用,今天我们只支持文本,但我们将支持图像和所有东西,但我们不会让API用户必须构建message_content和所有这些东西。如果他们想要,他们可以,他们可以明显地传递它,或者他们可以使用任何有from的类型,它会工作得很好。
显然这会增加一点二进制大小,但净价值是巨大的,所以我们不必太担心。
Builder模式
现在让我们讨论builder。首先,这里非常重要的注意是,builder并不是所有类型都需要的。不是因为一个类型有某种链式setter,突然我们就需要一个builder。有时就地可变就足够了。
Rust有Arc和引用等东西,你可以决定你的类型何时可变或不可变。对于更简单的类型,例如在我们的例子中chat_request、chat_message,我们不需要builder,我们可以就地可变它们,然后如果我们不想要函数可变它,就作为引用传递,把它放在Arc中或我们想要的任何东西。
builder模式非常有用的地方是当目标类型——我们想要构建的东西——是不可变共享的,Arc包裹的东西。
例如,通常当你有某种client,像web client或在我们的例子中是gen client,你想构建一次client,然后你有相当昂贵的资源需要构建和维护,或者有某种内部不可变性。
当目标类型完全不可变并且这样做有好处时,builder就非常合适。可能还有其他情况,但这是一个好的用例。
在我们的例子中,通常我喜欢非常简单直接,用Builder作为后缀。在我们的例子中那将是ClientBuilder。
然后显然我们有build函数,有时可能返回T或Result
如果某些东西是不会失败的,并且将来可能是不会失败的,那么build只返回T就可以了。现在有时你有一个类型T需要一些验证等等,在构建之前应该被验证,在这种情况下你会返回Result
同样的规则在这里,通常你会返回self,但有时可能有一些用例你想返回Result
然后这里我们有相同的setter模式。例如在gen中我们有client builder,我们能够让用户设置他们自己的request client,来自request库,然后这里你给你自己的request,你也用request builder构建它。然后显然你可以使用这里的任何这些模式,例如这里我们有client builder的insert_adapter_config。
现在还有另一个小技巧我喜欢有,这真的是个人偏好,但我喜欢它。我喜欢在我的类型T上有一个方法,在这种情况下是client builder,它会返回builder类型。在我们这里是client builder,它会返回这个,然后我们可以设置设置设置,然后调用build,它会返回client。
这只是一种非常表面的方便的东西,这样用户不必导入client builder,可以直接从client做所有事情,然后最后build。所以这只是一个方便的东西,这真的是个人偏好,不花费任何成本,简单且从命名上非常清楚。
代码回顾
现在让我们快速代码回顾,看看事情是如何实现的。
我们要去我们的chat,再次,我们有我们的模块,这里我扁平化一切,这样我有所有的mod,但一切都是扁平的,因为我不需要暴露我的代码组织。
然后现在我决定把所有与chat request相关的东西都放在chat_request模块中。然后我喜欢有这种代码区域,最终可能会分成不同的文件,但同样,这对其他人都不重要,因为无论如何都会被扁平化。
如果我去chat_request,我喜欢的方式是首先有类型。在这种情况下,我大多数时候实现Clone,因为能克隆很好,当你有对话时你需要能克隆。
然后我们有default,对这个主题有default是有意义的,default很简单,因为这里一切都是option或vector。我没有把vector包装成option,这有时是一种技术,让它更高效一点,但无论如何chat request总是会有一条消息,因为它通常至少有一条。
然后我喜欢为不同的东西有不同的实现块,这是Rust的一个很酷的东西,我们可以有不同的块,这和如果它们是一个实现完全一样,但这样我可以分割它们而不用代码区域。这是我的做法,个人偏好。
如果我去这里,我可以看到我有new,只是vector消息,就这样做,完成。然后from_system会做这些事情,然后我把system放在system属性中,然后我之后有我的消息。
然后我有我的链式setter,我们有with_system,它只是设置system或覆盖它,然后我们有append_message,只是推送一条消息。然后我可以有append_messages,接受一个messages的vector,然后做extend,所以我可以随着进行添加。
然后在这种情况下我也实现了一些getter,这是另一个最佳实践。这里的原因是有些很巧妙的事情我需要做,在AI中你需要将chat request的system与最终的system消息结合起来,这就是它的工作方式。
所以在这种情况下我有一个iter_system,基本上是将system的option与messages的vector、迭代器链接起来,所以这会给我一个包含所有东西的迭代器。
然后我有这个家伙做一些很酷的东西,它尽最大努力在system之间添加换行符。它会查看末尾,然后尽力添加换行符。所以这是我在adapters中使用的,然后我决定也为用户暴露它。
如果我们去chat_message,这是一个好例子。我们有相同的模式,我有chat_roles、content和extra,然后我们有message_content。所以这里所有的构造函数都接受impl Into<MessageContent>,我有from string、&str等等。但现在message_content只支持文本,但目标是以后它也将是多部分的,这样你可以发送图像和所有东西。
但很酷的是,在message_content中我有所有这些from,这允许我这样做,并为最终用户提供一个非常方便的API,这就是原因。
然后就是这样,在这个上我真的不需要任何类型的setter,因为通常你用构造函数创建,以后可以手动设置东西。我可能会有一些setter用于extra、tools或其他东西,但那会在以后出现。所以现在我有那个,就这样。
这就结束了这一集,下一个我们将讨论其他最佳实践:support和扩展trait、测试和示例,然后我们将讨论何时使用async和不使用async、项目文件和版本控制。
希望你喜欢它,非常感谢Craul的赞助。下次见,编程愉快!
20251022
Rust编程基础:初识impl —> 这个系列很不错~
大家好,欢迎来到Rust编程完整开发者指南,我是躺平Coder。这一节我们将进行impl的学习。
在开始本节的内容之前,我们先要回到上一节的代码。这是我们上一节所有部分最后一个代码,在这里我们定义了一个GroceryItem的结构体,然后我们有两个成员,两个方法分别是展示GroceryItem的数量和它对应的ID number。
我们可以思考一下这个问题:display_quantity和display_id_number,其实它接收的都是一个GroceryItem类型的变量。既然如此,我们是不是有其他的办法,像其他的面向对象语言的方式来更好地处理这个需求呢?答案是肯定的,这就要借助于我们本节需要讲的impl。
impl的基本语法
首先我们看impl的基本语法。impl其实是implement的缩写,然后紧接着是我们要为哪一个对象、哪一个类型实现功能。我这里StructA表示是为struct的一个结构体实现以下的功能,紧接着是一个花括号。花括号部分其实就是对应的为StructA实现的具体内容。
我们在第一个方法里面,fn new——当然new的名字可以自己定义——就是声明一个成员方法名字叫new,它返回了一个StructA的对象,然后里面是具体的返回值。第二个方法是fn func,这里接收了第一个参数是&self。在这个方法里,我们的&self其实是对自身的一个引用,然后其他的参数,最后是返回类型。当然这里的返回类型可以是任意的类型,当然也可以是没有返回值。
和其他的编程语言类比起来,我们的new这个方法,其实更像其他编程语言中的一个类的构造方法,然后func这个方法就像是一个对象的成员方法。
代码实现
那我们怎么在上一节的例子中完成优化呢?我们打开我们的代码。首先我们看一下我们的声明方式,这里我必然是要对我的GroceryItem实现特定的功能。
这里我们要实现一个它的构造方法。构造方法的话直接声明就可以,就是声明一个new——当然你这里new可以随意定义——然后返回的是一个Self。这里大家注意一下,它返回值里面是要用大写的S,就是大写的Self。
然后再声明第二个方法,我们的第二个方法其实就是display_quantity。第三个方法是display_id_number。由于我的这个方法里面只是打印它本身的一个自身的属性,所以我们没有第二个参数。如果说你有对应的需求的话,比如说你要对这个id_number加上对应的、拼接上对应的字符串,你也可以自己传入第二个参数。
这样的话我们的具体的实现已经完成了。那具体实现完成之后,我们怎么来使用它呢?我们这边就定义一个新的对象:item2等于GroceryItem,通过这个GroceryItem结构体的名字加上两个冒号再加上new,就可以声明一个新的结构体对象。这里我对应的quantity是100,然后id_number是123456。
这个时候我们怎么调用它呢?所谓的惯用的方式,其实我们就是把display_quantity和display_id_number这两个成员方法,直接通过点操作符的方式来进行调用。因为display_quantity和display_id_number已经变成了这一个item2这个变量的自身的一个属性,或者说一个成员方法。
我们直接运行一下,大家可以看到,通过这种方式调用,第一部分就是上一次的代码里面的内容,还是可以正常使用的,它不会影响我们上一部分的代码。但是呢,我们通过第二个方法完全实现了对应的功能,而且只定义了一个item2的这个变量,就可以把display_quantity和display_id_number变成它自身的一个属性。
代码调用说明
我们回到代码。我们在第59行的时候,实际调用的是一个普通函数,第46行的函数。同理,第60行实际调用的是第50行的display_id_number这个普通函数。当我们在第64行通过item2进行调用的时候,我们实际调用的是第37行的display_quantity这个方法。我们在65行display_id_number实际是调用的是第41行的display_id_number这个成员方法。这就是成员方法的一个具体的使用,impl在成员方法当中的一个具体使用。
impl的其他特性
让我们回到课件。因为我们本节的内容是初识impl,其实impl有很多其他的功能,我们这节只讲了关联函数,然后第二是实现成员函数。
这里还有一个点,就是我们可以把我们的impl块分割成多块。比如说我这里可以把第二部分display_id_number这一部分再写一部分,就是它可以有多个实现。我们重新编译运行一下,大家可以发现,其实我哪怕写成了多块,它也是可以正常运行的。就是我们impl块可以分割成多块。
然后第三点,这一部分我有一部分是把它标黄了,是因为它在我们这节课并没有真正的讲到。我们这一节主要是为结构体实现了一个impl的功能。那关于枚举、基本类型和trait这些东西,由于我们这节还无法——以我们目前掌握的内容还无法进行这部分的讲解。当然枚举是可以的,基本类型和trait,trait我们没有接触过。为基本类型实现对应的功能的话,需要借助于trait对象,所以这两个我们可能暂时就先不进行讲解。等我们讲到了后面的时候,我们会给大家再重新回顾一遍。
好的,今天的内容就到这里。
https://space.bilibili.com/353612018/lists/5699468?type=season
Rust 函数进化!一行代码复用 N 次
课程介绍
最近怎么样,今天视频中的所有人,我们将学习如何在 Rust 中使用参数和返回类型。这两者都将大幅提升我们的函数功能。
函数参数的使用
示例 1:打招呼函数
所以对于第一个示例,我们将创建一个向指定名字打招呼的函数。
函数定义:
为此,我们将创建一个名为 hello 的函数,或者将被命名为 hello。它将接收一个参数,这个参数将被命名为 name。
实际上我们在函数签名中创建了一个临时变量,我们可以在函数体内后续使用它。我们需要指定提供的参数类型。
然后在函数体内我们可以打印 “hello” 并使用该参数作为变量。
函数调用:
现在完成这一步后,我们可以通过调用函数名并传入参数如 bob 来执行。然后复制这行代码并插入 james。
正如你所见,现在可以轻松用不同名字调用该函数。
运行结果:
如果我们清空控制台并运行程序,我们将得到 “hello bob” 和 “hello james”。
可复用性的好处:
这对可复用性非常有益,因为我们无需为这项任务创建多个函数。否则我们需要为 “hello bob” 和 “hello james” 分别创建函数,但这很愚蠢,尤其是当这些函数仅在程序中使用一次时。
示例 2:多参数函数
此外,在这个示例中我使用了单个参数,但也可以为单个函数定义多个参数。
函数定义:
接下来我们将创建另一个示例,这个函数将被命名为 repeat。它将接收:
- 一个字符串切片类型的
text - 一个名为
times的usize类型参数
我们将执行打印行操作,传入占位符,然后插入文本,重复并传入次数。
函数调用与测试:
要调用此函数,只需输入 repeat,传入 bob,对于次数参数传入 3。
现在,当我们运行程序时,控制台应显示 bob 被打印三次。
我们现在可以用其他内容测试,也可以传入字母 z 并重复十次。这样运行时,控制台将显示 z 被打印十次。
这非常实用,如果你想使用带有多个参数的函数。
返回值的概念
但接下来我要教你们返回值的概念。因为现在我们只用函数来执行代码,但函数也可以返回值。
示例 3:温度转换函数
例如我们可能有一个将摄氏度转换为华氏度的函数。
华氏度在没有拼写检查的情况下确实是拼写最疯狂的单词之一,我永远都不会拼对的。
函数定义:
它将接受一个名为 celsius 的参数,类型为浮点数 f64。
Rust 要求我们指定返回类型,如果我们需要返回一个值。因此我们需要使用这个箭头 -> 并提供 f64,因为这就是我们要返回的类型。
然后我们可以返回:
1 | celsius * 9.0 / 5.0 + 32 |
这就是我们要使用的神奇公式,用于将摄氏度转换为华氏度。
函数调用方式:
现在要调用这个函数,我们只需输入 celsius_to_fahrenheit 并传入 20.0,或者任何我们想要的浮点数。
或者实际上由于这个函数会返回值,这样不会有任何效果。我们必须将其赋值给变量或直接打印。
所以这里我们可以输入,或者我直接重置这个,然后输入我常用的调试语句,即 println! 并传入 celsius_to_fahrenheit(20.0)。
运行结果:
现在当我们运行这个,应该得到的结果是 celsius_to_fahrenheit(20.0) 返回 68。
另一种使用方式:
否则你也可以输入其他内容,例如:
1 | let converted = celsius_to_fahrenheit(20.0); |
或者将参数改为 10.0,然后打印 converted 的值,此时会得到 50。
Return 关键字的使用
显式 return:
在这个例子中,我使用了 return 关键字和分号,但两者都不是必需的。
如果你要返回值,这是少数不需要使用分号的情况,代码仍然可以运行。
隐式返回:
甚至更疯狂的是,我们甚至不需要使用 return 关键字。我们可以直接编写返回值的代码,作为函数的最后一条语句,Rust 会自动识别返回值。
现在如果我们运行这个,仍然会得到 50。
注意事项:
唯一能做的就是添加分号,否则 Rust 无法识别我们试图从函数返回该值,因此必须省略。
这不需要是等式,你可以直接返回单个值,代码仍能运行。
测试示例:
所以如果我们输入 cargo run,我们要得到 10 这个结果,因为我们没有使用 celsius 参数,现在收到了一些警告。但如你所见,我们仍然得到 10 这个结果,因为这就是我们指定的返回值。
多种返回方式:
再次,我们也可以输入:
return 10- 或
return 10 + 分号
这些都是从函数返回值的有效方式。
综合示例:加法函数
但在结束视频前再看一个例子。
函数定义:
所以我先清空这里,创建一个新函数 add。这个函数将接收:
- 一个名为
a的参数,类型为i32 - 还有一个名为
b的i32类型参数
我们将能够相加两个数字,返回类型为 i32。
然后我们可以执行类似打印的操作,我们正在相加 a 和 b 的值。在底部返回 a + b 的结果,以 i32 形式返回。
再次,这是代码的最后一行,并且它返回某个值。
函数调用:
现在这个函数可以完美使用,我们可以输入例如 result,或者:
1 | let result = add(20, ...); |
调试宏的使用
我之前没展示过,但在 Rust 中我们有一个用于调试的特殊宏,它叫做 dbg!。
我们可以传入变量或表达式。当我们运行这段代码时,我们将得到一些调试信息。你可以轻松用它作为替代方案,但大多数情况下我还是会使用我自定义的 print_line 函数。
函数的灵活性
回到我们的函数,正如你看到的,这里我们指定了返回类型,只需提供返回值的代码行。
我们也可以选择添加 return 关键字,代码运行效果完全相同。
多次调用的优势:
再次,这使我们的代码非常灵活,因为我们可以多次使用这个函数,传入不同参数。
甚至可以输入 50 + 20,当我们运行时,将返回 70。
总结
不过这样,今天视频的内容就差不多了。
如你所见,在自定义函数时我们有很多选择。目标是让一切尽可能可复用。
提供参数和返回类型能极大帮助我们实现这一目标。
Rust编程系列 使用Arc<[T]> 代替 Vec
引言
大家好,今天我们来聊一个话题:在你的 Rust 代码中,为什么应该考虑将 Arc<[T]> 作为默认选项,而不是 Vec<T>。
我知道这听起来可能有些激进,所以请允许我详细解释一下。
核心观点
对于不可变的数据,Arc<[T]> 可能是比 Vec<T> 更好的选择。
具体来说,如果你创建了一个庞大的数据集合,并且之后不会再修改它,那么 Arc<[T]> 就是一个非常值得考虑的选择。
适用场景
它特别适合用来存储在:
- 结构体数组
- 各种集合里
- 或者需要在代码各处传递的数据
我指的不是那种临时在函数里创建用完就丢弃的 Vec,而是那些需要长期持有的数据。
对于这类数据,你可能需要考虑使用 Arc<[T]>,特别是对于实现了 Clone 特性的数据,Arc 的优势尤为明显。
Clone 可以说是 Arc 的杀手锏,我们稍后会看到。如果你需要频繁克隆大型的不可变数据序列,Arc 可能带来比 Vec<T> 大幅度的性能提升。
当然,如果你连 clone 都不需要,那你甚至可以做的更好,直接使用 Box<[T]>。这个我们最后再谈。
三大推荐理由
那么我为什么要这么推荐呢?主要有三个原因:
第一个原因:Clone 操作非常廉价
我们刚才提到了,Arc 的 clone 操作非常廉价,时间复杂度是常数级的。
无论你指向的数据有多大,克隆它的耗时都是一样的。它只需要:
- 增加一个引用计数的整数
- 再复制一下
Arc指针本身
这个过程非常快,并且不涉及任何内存分配。而克隆 Vec 通常是需要分配内存的。
这是一个巨大的性能提升,而你只需要把类型换成 Arc<[T]> 就能轻松获得。
第二个原因:内存占用更小
Arc<[T]> 本身只占用 16 字节。它只需要存储:
- 一个指针
- 一个长度
相比之下,Vec 需要存储:
- 指针
- 长度
- 容量
- 总共 24 字节
虽然只是 8 字节的差距,看起来不大,但如果你要存储成千上万个这样的数据,尤其是在结构体或数组里,这些多出来的内存一旦累积起来,就会降低缓存局部性,导致你遍历大量数据时效率变低。
第三个原因:易用性保证
第三个也是最关键的一点,Arc<[T]> 和 Vec<T> 一样,都实现了 Deref, target = [T]。
这意味着所有能在 Vec<T> 上进行的只读操作,在 Arc<[T]> 上也同样适用。你可以:
- 查询它的长度
- 可以遍历
- 可以索引
这一点至关重要。如果 Arc<[T]> 用起来比 Vec 麻烦很多,那么前面两点性能优势再好,我可能也不会如此极力推荐。
正因为它实现了 Deref,它也顺带实现了许多其他你可能需要的特性。这使得 Arc<[T]> 在很多场景下实际上是 Vec<T> 的直接替代品。
总结:
- 前两点是实打实的性能优点
- 第三点则保证了易用性
既然如此,为什么不优先考虑它呢?
用字符串举例说明
刚才我一直在谈 Arc<[T]> 和 Vec<T>,接下来为了方便举例,我会用 Arc<str> 和 String 来继续讨论。
它们是完全一样的道理,只是用字符串举例更直观,更容易理解。我接下来要讲的关于 Arc<str> 与 String 的一切,都完全适用于 Arc<[T]> 与 Vec<T>。
关于 Rc 的补充说明
另外补充一点,任何时候用 Arc,如果你的场景不需要线程安全,都应该优先考虑 Rc。
我这里统一用 Arc 来讲解,是因为这是最通用的情况。但如果可以,请务必尝试 Rc,它的开销比 Arc 更小。
重要澄清:Arc vs Arc
我还要再澄清一点,我说的是 Arc<str>,而不是 Arc<String>。
Arc<String> 虽然也具备 Arc 的部分优点,但它有一个致命的缺点:你需要两次指针跳转才能访问到真正的字符数据。
我会在最后用图示说明,但总的来说,Arc<String> 通常既笨重,内存效率又低。这种双重指针跳转对性能的伤害很大,所以我不推荐使用它。
我谈论的是 Arc<str>。Arc 指针的强大之处就在于,我们可以让一个 Arc 直接指向堆上一个动态大小的字符缓冲区。
实战案例:游戏中的 Monster ID
好,假设我正在开发一个游戏,游戏里有个类型叫 MonsterId,它的底层数据是一个文本字符串,我打算用 String 来管理。
派生基本特性
我可能想在 MonsterId 上做的第一件事就是派生一系列常用的特性。我希望它能:
- 被克隆
- 能支持调试打印
- 能比较
- 能哈希
我还想用 serde 进行序列化和反序列化,比如从配置文件或存档中读取。用 String 的话,这些都没问题。
访问器方法
接下来我希望 MonsterId 有个方法能让我把它作为字符串切片来访问,也许我想把它打印到控制台或者用于某些分析。
配置数据存储
然后我可能需要一些配置数据,比如一个 HashMap:
- 键是
MonsterId - 值是怪物的各种属性
这就是为什么我需要派生 Eq 和 Hash。
怪物列表
接着我想把所有已生成的怪物 ID 存储在一个列表里。注意这里我用了 Vec,因为在游戏运行期间会不断有新的怪物生成。
我可能会把 MonsterId 克隆一份,放进这个列表。这个列表可能会非常大。
创建怪物实例
我还需要一个函数,它能根据 MonsterId 创建出游戏里的怪物实例。这个过程可能也需要克隆 MonsterId 并把它存到怪物实例里,让每个怪物都知道自己的 ID。
统计数据
最后假设我用一个 BTreeMap 来记录统计数据,记录我在本次游戏会话中消灭某种怪物的次数。
这里用 BTreeMap 只是为了展示另一种数据结构。由于 MonsterId 实现了 Ord,它可以作为 BTreeMap 的键。
类型扩散问题
以上只是几个例子,实际用例可能更多。它展示了这样一个基础类型是如何在整个代码库中扩散开来的。
你可能需要不断地:
- 克隆它
- 把它存储在各种数据结构中
慢慢的这些操作的成本就会逐渐累积,变得不可忽视。
String 的成本分析
那么让我们来看看 String 的克隆成本和内存占用。
String 的内存布局
String 的字符串数据实际上是存储在堆上的一个字符缓冲区。它会为你的文本分配内存,通常还会预留一些额外容量,以便字符串增长时无需立刻重新分配内存。
比如这里的 “goblin”,它有 6 个字符,但容量是 10,预留了 4 字节的额外空间。
String 结构体自身由三个字组成,每个 8 字节:
- 一个指向堆数据的指针
- 一个长度
- 一个容量
String 的克隆过程
那么克隆一个 String 会发生什么呢?
首先我们需要完整的克隆整个字符缓冲区,这意味着:
- 要先分配一块新内存
- 然后把所有字符复制过去
这是一个线性时间复杂度的操作,字符串越长,耗时越久。
然后我们要在栈上创建一个新的 String 结构体,指向这个新的字符缓冲区。
注意 String 的克隆操作不会超额分配,所以新 String 的容量和长度是一样的。
如果再克隆一次,就得重复同样的过程:
- 分配新内存
- 复制所有字符
- 创建新的
String结构体
你应该能看出来,每次都分配新内存的过程相当繁琐,而且 String 结构体本身也为了我们并不需要的可变性而显得有些臃肿。
Arc 的优势
现在我们来看看用 Arc<str> 的情况。
Arc 的内存布局
使用 Arc<str> 时,堆上的数据布局有所不同。
在字符串数据 “goblin” 前面有两个额外的 8 字节字段:
- 一个是强引用计数
- 另一个是弱引用计数
这看起来似乎更大了,有点奇怪,但别急,它的优势马上就体现出来了。
我们栈上的 Arc 结构体只是:
- 一个指针
- 一个长度
- 总共 16 字节
因为它没有 String 那个额外的容量字段,它直接指向堆上的数据。
Arc 的克隆过程
现在看看克隆时会发生什么。我们要做的仅仅是:
- 复制栈上的
Arc结构体 - 然后把引用计数加 1
就这么简单!
你注意到了吗?我们:
- 没有进行任何内存分配
- 也没有深拷贝字符串数据
我们只是让两个 Arc 指向了同一份数据。因此克隆操作变得极其廉价。
缓存效率提升
此外,多个 Arc 共享同一份数据,这也提高了数据在缓存中的命中率。因为无论我通过哪个 Arc 访问数据,加载到缓存里的都是同一块内存。
相比之下,对于多个 String,每个都有自己的内存区域,只有当我最近访问了某个特定的 String,它对应的数据才可能在缓存里。
总体优势
所以 Arc<str> 的整个方案要清爽得多:
Arc结构体本身更小,同样的内存空间能放下更多实例- 坦白说,对于在程序中传递的不可变字符串,这是一种对资源的更智能的利用
我们在堆上多出的两个字的成本被分摊到了每一个 Arc 实例上,最终的开销远小于每个 String 都必须携带的那个额外容量字段。
将 MonsterId 改用 Arc
现在让我们把 MonsterId 中的 String 直接换成 Arc<str>,看看感觉如何。
派生特性
首先我们所有的派生都和以前一样正常工作。Arc<str> 同样可以:
- 克隆
- 调试打印
- 比较
- 哈希
你也可以序列化和反序列化它,不过你需要启用一个 serde 的 feature 标志才能使用。其中有一些细节需要你留意,但配置起来很简单。
访问器方法
我们的 as_str 访问器呢?这个函数完全不用改动。
因为 Arc<str> 和 String 一样实现了 Deref, target = str,我们直接返回一个引用,它就会自动解引用成 &str。
HashMap 配置数据
接下来存放怪物属性的 HashMap 呢?也没问题。
我们的 MonsterId 结构体依然实现了 Eq 和 Hash,因为 Arc<str> 本身就实现了它们,所以这里什么都不用改。
事实上我认为在这种场景下,用 Arc<str> 甚至更合适。因为 HashMap 的键是不允许被修改的,用 Arc<str> 这种不可变类型作为键,比用可变的 String 在语义上更贴切。
Vec 列表
存放 MonsterId 的 Vec,现在缓存效率会更高,因为每个元素的大小只有原来的 2/3。
创建怪物实例
我那个根据 MonsterId 创建怪物实例的函数,现在效率可能也更高了,因为创建过程中的克隆操作在 Arc 的加持下变得效率极高。
BTreeMap 统计数据
最后我们的 BTreeMap 和 HashMap 类似,使用不可变类型作为键更加合理。并且这个 BTreeMap 现在效率更高,因为它存储的数据更小,也不必为根本用不到的可变性付出代价。
总结优势
所以 Arc<str> 在所有这些场景下都表现更优:
- 它效率更高
- 而且几乎是无痛替换
Vec 和 String 的设计初衷
前面我一直在暗示,现在我就直说了:
Vec 和 String 的设计初衷是为了修改数据,比如:
- 往里添加元素
- 删除元素
- 扩展和截断
如果你用不到这些修改功能,就别用它们,因为它们有额外的开销。
Vec 和 String 上那些有趣的方法,你会发现它们几乎都接受一个 &mut self,都是关于修改缓冲区的。
只读操作
如果你只需要读取数据,比如:
- 查询长度
- 检查是否为空
- 索引
- 遍历
- 切分
- 搜索
所有这些只读功能,[T] 和 str 切片类型都已经直接提供了。而通过 Arc,你可以非常方便地获得它们。
因此,只要不为 String 和 Vec 的可变性开销买单,你就能换来可观的性能提升。
为什么 Arc 不好
现在我想展示一下,为什么我说 Arc<String> 不好。
Arc 的内存布局
Arc<String> 的数据布局和 String 一样,堆上有一个带容量的缓冲区。
但接着我们还需要把 String 结构体本身也放进一个 Arc 里,所以堆上又多了引用计数的空间。
最后栈上的 Arc 指向那个堆上的 String。
双重指针跳转问题
克隆这个 Arc 确实很快,但问题在于,如果我们想访问真正的文本 “goblin”,就必须:
- 先通过指针找到
String结构体 - 再通过
String里的指针才能找到真正的文本
这个过程有两次指针跳转,整个结构非常笨重,访问起来也很别扭。
这就是为什么 Arc<String> 不是个好选择,而 Arc<str> 更好。
Box<[T]> 的选择
最后我提一下,如果你连克隆都不需要,那么可以使用 Box<[T]>,它比 Arc 更好。
Box 的优势
如果你不需要克隆,那么这基本上就是内存效率最高的选择了。它:
- 没有任何额外的容量
- 没有引用计数
它就是在堆上分配一块刚好大小的内存,然后用一个指针指向它。在内存效率上你不可能做得比这更好了。
Box 的局限
但当你克隆它时,它会对堆上的数据进行深拷贝,这会涉及:
- 内存分配
- 线性时间的复制
所以如果你的用例不需要克隆,请考虑 Box。
总结
以上就是我今天想分享的所有内容。欢迎在评论区告诉我你的想法,感谢观看,我们下期再见。
Build with Naz:利用类型系统消除Rust中的偏移量
嗨,我是Naz,我是Rebel的CEO和创始人,也是《开发者生活》的作者,还是Rebel的维护者。我认为开源源于开放的心灵,并源于编码的快乐。
今天我将讲解一个主题,我认为这是Rust最酷的功能之一——不是内存安全或性能,而是使用类型系统表达不变量,使得无效状态或事物无法在语言中表达,因为编译器不会通过编译。
问题背景
在文本用户界面中经常发生的情况是,有很多数组操作。无论是零拷贝间隙缓冲区,这是原生数据结构,就像一个非常非常长的一维数组,作为内存中存储可编辑信息的基础结构。事物都存放在数组中,正如我们所知的编程知识,这与内存本身的寻址方式密切相关——在硬件中是零索引或零起点。
所以长度,如果你有一个五元素数组,大小是五,长度是五,有效访问索引是零到四。因此当你本质上遍历可访问区域时,可能会发生下溢或越界。这很容易发生,因为我们只需用数字表示索引和长度,假设它们都是usize或u16,类型系统中没有任何东西能区分它们。
我只是要遍历所有可能的下标越界错误,可能出现的使用场景,然后我会展示边界检查(bounce check),这是rebel crate中的一个模块,这是我开发的,为了使这种情况不会发生。
代码其实没做什么复杂操作,它只是对小于等于运算的抽象,还有一些范围,独占范围,包含范围。但通过类型系统,我能创建结构体和关系以及这些trait之间的关联,这样就无法混淆零基索引和一基索引,或零基索引与长度,或一基索引与长度。
具体应用场景
比如滚动区域和选择范围,以及各种高级表达结构,这些与编辑领域相关。同时我认为这类通用计算也很易用,因此任何涉及索引和长度的通用操作都可以通过类型系统确保无法比较错误类型,因为该类型系统直接禁止此类操作。
在展示边界检查之前,我想先介绍一些启发我构建它的使用场景。因为这段代码现在已有约十万行,到了我几乎无法阅读代码的地步,变得过于复杂,比如小于等于、奇怪的范围,各种情况,全是算术运算。我心想到底在做什么,到底是什么使用场景。
这让我感到不安,我需要编写大量测试,花了很多时间思考如何编写这些测试。后来,几个月或几年后,当我回来查看同一段代码时,仍然毫无头绪。于是我决定采取行动,我用bounce check重写了整个库,不再有尺寸比较或十六进制检查,至少代码中明确使用了bounce技巧,底层自动处理这些问题。
从开发者角度,从维护者视角来看,这段代码让我感觉良好,因为我清楚不会表示错误内容。我知道不能随意获取行索引与列索引比较,系统不会允许;不能将高度与宽度直接比较;通过索引获取长度,智能处理范围,还有很多其他功能。
VT100模拟器案例
最近让我震惊的功能之一,或者说压垮骆驼的最后一根稻草,是我最近在集成VT100模拟。我正在为库添加的功能之一是实现原生tmux,然后我在构建这个VT100模拟器,解析器和CSI解析器。
就像在构建一个shell,比如wezterm或alacritty,当这些shell启动进程时,这些进程在标准输出标准输入中会输出字节,这些字节可以解析为ANSI转义序列。这是一个解析这些字节并处理它们的工具。
但当我研究终端模拟器和VT100规范时,所有内容都是从1开始计数。所以这很快变得很混乱,因为某些场景会使用1索引的列和行位置,比如VT100相关的内容,但其他情况又使用0索引。如何统一处理?这变得非常复杂,因为我突然想到,到底是0索引还是1索引,然后需要进行0到1索引转换,这些转换让代码难以阅读。
所以现在我需要做这个,因为这已经失控了。尽管我想添加更多功能和能力,让这个库更有用,但那个偏移量的问题——零基和一基,零基索引、一基索引——这些阻碍了任何实质性进展。
设计理念
我花了很多精力在文档上,因为大部分视频会讲解文档。我们已经讨论了一些内存用例,事物的表示,线性数组,基于长度,然后像一个字符串。然后有像VT100模拟器,其中行索引和列索引是一基,顶部原点为(1,1),不是(0,0)。
然后像给定一基或零基索引,长度是独立的语义概念。索引可能是零基或一基,而长度不是索引。长度从零开始,这有含义,并延伸到任意数值,这描述了长度,描述数量,这个容器能放多少项,本质上这就是它的长度。
为了区分这些概念,我最终想出了一种描述方式,比如所有这些基元如何工作,然后在这些基元之上构建,将它们组合起来,具有明确意图和含义的结构体,语义非常清晰,仅从外观就能看出。
这有点像,如果你看金字塔顶端,我们将向下推进,用例最终决定了trait以及结构体的设计。因此使用了Rust trait系统,类型系统被广泛使用,新类型模式被采用,这真的很棒。我实际上对结果非常满意。
主要用例
这里列举了所有这些案例:
- 数组访问
- 光标定位:光标定位类似于编辑器中的操作,想要移动光标。光标是一个有趣的功能,因为不同于索引,有效光标位置在数组末尾或其后,这属于不同的语义,这将导致数组越界
- 视口:涉及包含范围的可见性
这些是主要的整体用例,它们会过滤到更低级的基元,然后可以组合使用。由于这些基础组件存在,该应用的范围已不限于二维,它可以扩展到图形界面,它可以适用于任何带用户界面的场景。在这些概念适用的领域,如果存在视口概念,或文本编辑的光标功能,如果存在访问权限,有范围验证功能,这些都是通用型的。
尽管这是为特定工具库设计的,这里没有任何内容与二维绑定。
核心特性与语义特性
存在一些基础特性并提供核心操作,然后还有非基础特性,这些是语义特性,与具体用例概念绑定。例如光标定位的用例,数组访问的用例,这些都是语义特性。
目标是:
- 在模块内实现易发现性,每个文件有明确单一的用途
- 尽可能在编译时进行比较,无意义的比较甚至无法编译
- 可维护性——必须确保应易于维护、扩展和理解
基础特性
基础特性包括:
- NumericValue:本质上是一种约定,说明实现该特性的结构具有数值,可获取该数值为u16或类似类型,这是进行比较的基础
- NumericConversion:作为NumericValue的超trait
- ChZero:任何实现数值类型的都可以被清零
请记住我们曾提到过VT100行和列不能被清零,因为VT100行和列的起始位置是(1,1)。所以VT100,它被称为终端行(TermRow)和终端列(TermCol),这是我最终命名的。终端行和终端列结构体,它们是NumericValue,但它们不是ChZero,因为无法被清零。
IndexOps特性
这是一个用于零基位置或索引类型的特性。我们有一个关联类型Length,我想要双向链接,某些索引类型与特定长度类型。如果你有一个行索引,则双向链接到行高度;列索引链接到列宽度。索引与长度是相互关联的,因此类型系统不允许你做类似的错误操作。
索引知道其长度,而长度也了解其索引,这是对应的索引和长度。这在实际中非常有用,当你需要在它们之间转换时,以及当你需要比较两者时。
你可以对索引做的一件事是,对于列索引,有对应的列宽度,然后你可以将索引转换为长度,转换方法是将其加一。假设你位于索引位置5,这个位置的长度是多少?是6。这就是索引到长度的转换。你也可以反向操作,如果你的长度是6,对应的值就是减一。
这就是封装的IndexOps,然后还有像:
- 钳制到最大长度(clamp_to_max_length)
- 钳制到最小(clamp_to_min)
- 最小索引和钳制范围
这些都是很好的方法,在大多数使用场景中都很有用。
标准库中有一个针对u16的钳制方法,还有一个更特定领域的版本,带有类型信息,这就是唯一的区别。并不是在发明新的钳制方法,只是让类型安全。所以你可以调用clamp,但如果你使用clamp_to_max_length,只能在索引类型上调用,然后该索引类型现在有一个对应的长度类型,所有这些关系和不变量都随之而来。
LengthOps特性
长度操作看起来非常相似,它有一个关联类型Index。有一个convert_to_index方法,实际上我想展示的重要方法是溢出检查。
这实际上是检查给定索引是否超出边界,这个长度的,不是下溢而是溢出。然后返回枚举ArrayOverflowResult,你可以看到是否在范围内还是溢出。这不是布尔值,而是一个枚举,所以再次强类型化。
还有remaining等方法,这些都是有用的方法。所有这些方法会利用一些注意点,所有这些疯狂的trait边界,比如remaining,参数是一个索引,因为该方法基于长度,但参数是一个索引,但索引类型与实现该长度的结构双向关联。
所以再次,这些所有有趣的双向平衡约束,所以它指向self的索引类型,这表示你可以相减索引类型。因为核心特性不涉及减法或加法,还有一个叫ArrayBoundsCheck。
语义特性
现在我们进入语义特性,这里变得有趣,这类似于主要用例。我们有所有这些,以便检查数组中的位置是否为有效索引,数组中的索引是否安全可访问。
这是一个语义用例,这是一个独立处理的。唯一的是,实现ArrayBoundsCheck的任何结构,也必须实现NumericValue。NumericValue可以清零,你不能在无法清零的对象上实现此特性,比如VT100规格的行和列内容。
ArrayBoundsCheck
ArrayBoundsCheck直接有一个通用类型,通用类型取决于长度类型,必须要有LengthOps作为超trait,必须基于LengthOps进行子类型化。我们知道LengthOps会带来一系列其他约束,以及其关联的IndexOps。
因此这实际上说明了实现ArrayBoundsCheck的任何类型,必须满足这些trait边界。现在我可以免费为您提供溢出和下溢的实现,所有这些只是为了达到这里。
实际上有一些不错的示意图。这些是数组访问语义:这是一个数组,我有一个索引,我有一个长度,我想检查是否溢出。但我不想做数学计算,我只是想说,这是一个长度类型,这是一个索引,告诉我是否溢出,无需过多关注数组内容。
因此这些简单检查,它们覆盖了非常小的表面区域,并且可以组合使用。这实现了溢出检查、下溢检查。因此如果你有一个索引和一个长度,可以从索引角度进行比较,或从长度角度进行比较。这个简单系统允许您同时进行这两种检查——从索引视角,即索引是否超过长度,或从长度视角,长度是否被索引超出。所以它非常符合人体工学。
实现类型
哪些类型实现了ArrayBoundsCheck?一种叫做Index的泛型索引,你可以用它来处理任何需要的场景,作为索引表示,不与终端或行索引绑定。还有:
- RowIndex / ColIndex:代码库中用于表示行列的不同类型
- SegmentIndex:用于处理Unicode的段索引
- ByteIndex:字节级操作
- ByteOffset:字节偏移
并且有巧妙的运算符重载机制,例如,行索引和列索引,将它们相加,得到一个位置。或者取一个字节索引,并给它一个偏移量,然后它会生成,你可以将偏移量加到字节索引上,得到另一个字节索引。
溢出和下溢检查
溢出检查非常直接,我们有一个边界,你可以检查是否超出范围,而且我们无需担心是否大于等于长度减一或大于长度,这些数学计算不再需要了。现在我们有了索引类型,我们有长度类型,并且可以相互转换,或反向检查是否溢出。
然后是下溢检查,类似的情况,它们都返回枚举类型,没有布尔值。这就像提供了一种映射,就像你现在的位置,如果你想处理其他用例,这些都是进入其他用例的切入点。
你编写的代码不再需要”这个索引是否小于该长度”,或者”这个索引是否小于等于该长度减一”或类似情况。现在只需,如果索引在宽度范围内,那就没问题;如果不在范围内,则会发生溢出。这就是如何读取这个逻辑,再次没有强制操作。
然后你可以使用match语句,你可以说,如果这个索引超出该宽度,我该怎么办;如果未超出,那么我该做什么,类似这样的处理。这些都是常见模式。
CursorBoundsCheck
光标边界检查类似的情况,但光标边界固定。围绕光标的检查是针对编辑器使用场景。光标可以移动到此处,你经常看到这种情况,你甚至不会多想。
比如,我需要在VS Code中查看我的光标,在这行代码中,这是起始索引0,然后我到达了文本末尾,文本结束后我可以再移动一位,这没问题,因为超出一位就是输入位置,可以输入内容。
这很麻烦,如果你不清楚处理的是索引还是长度,如果是光标使用场景,这段代码看起来很奇怪。通过引入光标语义使用场景或特性,光标使用场景已集成到语义特性中,我们有了更好的提问方式:
- 我们是否在行尾?是或否
- 这个光标,是否是有效光标位置?
- 这个索引,是否在长度/宽度/高度范围内的有效光标位置?
我想确认光标位置到某个位置,我想检查我的光标位置边界,是否有效,直接返回布尔值。
是否是有效光标位置,快速检查以确定,我的光标位置是否有效,我是否在该行内,我是否位于行尾。对于编辑器来说,这很有道理。
谁实现了这个功能?CursorBoundsCheck通过长度实现,高度、宽度,所以基本是行高、列宽、Height、Length和SegmentLength。如果你不导入trait,就看不到这些方法,真酷。是否需要光标边界检查功能,只需支持该功能然后搞定,突然可以执行光标特定的方法操作,在你的长度而非索引。
因此库中所有内容都与此兼容,所有内容都无缝衔接,因此无法引入u16或usize随随便便。确实如此,它不会接受,它无法编译。
ViewportBoundsCheck
然后还有类似的内容用于视口。ViewportBoundsCheck同理,如果你不需要,不要导入它。如果你确实导入了,你可以看到,所以视口是不同的语义用例。
有很多报告和边界检查,这很有趣。视口边界检查你无需额外操作,对于任何有IndexOps的,因为视口,包含性主要是比较索引,这就是为什么它在IndexOps中定义。
如果你还记得在IndexOps中很久以前我们有clamp_to_index_in,clamp_to_max_length,clamp_to_range,这就是滚动功能的应用场景。
RangeBounds扩展特性
实际上还有许多特性,这是范围边界特性。范围边界,这是一个扩展特性,因为受孤儿规则限制,我无法直接在现有类型上实现方法,该类型在标准库中,它不在我的模块中。
RangeBoundsExt,这基本上允许你,为RangeIndex和IntervalIndex提供扩展。所以这个特性为Range和RangeInclusive提供了泛型实现。所以如果你在处理Range或RangeInclusive,这些都是标准库,然后你可以导入trait,然后你将拥有这些方法。
Range是他们所说的独占范围,RangeInclusive正如名称所示,是包含的。对于某些用例如滚动,真正包含,RangeInclusive更像是这个,这是处理这类问题更人性化的做法,而这更多是标准。
还有一种叫做范围转换(RangeConversion),它可以转换,另一个扩展特性,并允许将RangeInclusive转为Range,并进行一些计算。许多功能的底层用例是编辑器,以及各种VT100终端模拟和终端特定功能。
但没有终端特有的内容。例如我有一个VT100滚动区域,但需要一个Range,它不会改变语义含义,只是提供一个范围,这是该RangeInclusive的等效Range,无需你自己制作并担心数学计算。
因为你可以看看这个常见错误——并非与直接排除范围相同。所以从包含b到排除的范围与a到b的点点不同,诸如此类。存在大量细微的越界错误,等到你发现时,为何要让它渗入代码库。
使用指南
鉴于存在某些具体类型,比如Index、Length、Row、Height,如果你想获得这些额外功能,只需导入这些特性,然后这些方法会神奇地出现在这些实例上。
何时使用哪种方法
有各种有趣的用例:
- 直接使用基础特性:长度、空间大小计算和文本换行
- 如果你想做泛型操作:使用NumericConversion,这就是如何构建类似TermRow或TermCol
所以TermRow和TermCol特定于VT100,区别在于不能为零,其原点为一,但这并非长度,也不是索引。这就是栈的美妙之处——在于不会混淆这些术语。
当我编写解析VT100转义序列,或CSI转义序列,来自VT100,要符合VT100规范,然后我就知道该使用哪些单位。我也知道不要将它们与编辑器组件中的光标混淆,或者零索引中的某个索引,零拷贝间隙缓冲区。
这非常令人解放,不必为基本操作费心思考,这真的减轻了认知负担。
何时直接使用语义特性
- 光标位置:编辑器使用场景
- 数组访问使用场景
- 视口可见性检查
- 范围验证成员资格:用于复制粘贴,当你选择内容时
- 滚动和裁剪
还有决策树——我需要哪个特性,何时使用,基本上可以覆盖所有情况。
区间表示法
这是区间表示法(Interval Notation),我之前提到过。仔细阅读,这很酷,就像为那些我们不得不隐式理解的概念赋予定义,用这种方式使其明确。
代码库中的所有图表,使用这种区间位置表示法,当它被使用时,会链接回模块文档中的这一章节。尖括号表示包含,圆括号表示不包含。我有一个完整的关于区间表示法的描述。
快速入门
还有一个快速入门指南,如果你想使用它用于你的代码,这就是如何逐步开始使用,然后你可以进一步使用它。
不允许的操作
这些是我不允许你做的,这段代码无法编译:
- 你不能拿行索引(RowIndex)与列宽度(ColWidth)比较,它们不可比较
- 我可以比较行索引与高度,我可以比较列索引与列宽度,但我不能跨类型比较
- 这将无法编译,我不能使用usize
这太棒了,因为你知道这是小于等于这个减一,我不确定取决于具体情况。类型上下文已足够确定应使用哪种类型,这就是为什么这不是,你知道你不该这样做,现在不要再这样做了。
类型层次结构
还有另一个图表,这些类型是如何分层构建的。如果你在寻找如何查看这些示例,新类型模式,如何利用trait边界等等,如果你习惯继承,然后你会想,如何在Rust中高效操作,这是一个很好的例子。
因为它足够复杂,但这并不复杂——其实挺复杂的,算了。就是只是个奇怪的小细节,有些地方差一的情况。
然后这完全涵盖了我之前说的所有内容。确实,这挺好的,如果你没看上一个视频只看了文档,那你就能理解,你可以从这里获取所有信息。
这就是我一直提到的类型系统映射,然后TermRow是独立实体。所以这很赞,现在我可以创建不直接相关但有一定关联的事物,它们不需要是u16,我可以定义一个ByteOffset,我可以定义TermRow和TermCol。
这样可以做领域特定操作,充分利用这些而不丢失任何优势,这相当酷。
类型安全总结
类型安全正是这样做核心目的:
- 行索引与列索引一次编译
- 行索引与行高是类型安全的
- 清楚区分你的内容
- 这是基于一的
- 长度的测量是什么
- 索引的测量是什么
- 索引代表什么
- 长度代表什么
- 基于一的计数,原点在哪里
实际应用
差不多就是这样。我想多花点时间深入探讨这个,但如果你对这类设计模式感兴趣,看看这个。扩展特性包含各种内容,泛型实现,所有细节都为了提升易用性。所以这在自己的代码中使用并不麻烦。
这就是整个功能的实际应用。这是从哪里来的——开始学习bounce check,这应该有超链接。有很多文档,请看这个,这就是类似这样的内容。
这就是所有功能的魔力,我一直在构建文档并测试,在这运行过程中,我做了这个。
通用模式
通用模式是使用Rust的新类型模式(Newtype Pattern)。我有关于这个的视频教程:
- 创建新结构体
- 然后为所需特性派生所有实现
我也会展示示例,但你只需定义结构体,然后只需标记该结构体,作为有效,这是一个长度,因此其他结构是其对应的索引,或者类似这样的内容。
所以这大致就是这些功能提供的内容。所以你编写的代码看起来像这样,顺便说一句这是它的姐妹或同类,是其兄弟,是LengthOps。所以有IndexOps和LengthOps,它们被结合使用。
例如,你可以说,我有列位置,并且我有宽度,然后你可以将位置转换为对应长度,然后进行clamp,该列位置到你拥有的宽度,这很有趣。然后进行最小边界clamp,但最小边界也是,这不是宽度,而是列位置,等等。
所以本质上这些就是构建模块。
总结
是的,这是一段有趣的视频,更像是Rust的设计,而不是太多像代码示例或实时编码。文档中有大量示例,文档中有大量代码示例,如果您感兴趣,请查看它们。
如果您喜欢这个视频的风格,请给它点个赞,请订阅这个频道。如果你有任何问题,请在视频的GitHub issue中留言,我会将这个issue链接到描述并提供更多链接。
非常感谢观看,祝你有美好的一天!
20、宏
是个系列,这是其中一个
Rust 宏(Macro)教程
课程定位与Rust特性
这节内容我们来继续学习Rust里面的宏,看来已经接近尾声了,最后还剩一节内容。其实在整个Rust学习中,我对它的定位是一个经典入门,但是讲的过程中其实并不怎么精简,因为没办法,Rust这门语言它本身是有很多复杂性的,如果说太精简化很困难。很多时候给大家去讲的太过于细节或太过于底层,但是这些东西你不去讲,好像也没有办法。
Rust这门语言本身是一个很不错的语言,从它的特性,包括集百家语言之长,还有它的类型和安全,整体做的都是很不错,而且是无GC,也不需要你手动释放内存,它是靠它的一套机制去整个维护程序的内存的申请和释放。这个过程真的是非常经典、非常优雅,它开创了一门语言独有的一种特性,很另类,同时也很惊艳。
JetBrains对Rust的支持
Rust这种语言,你比如说像JetBrains,专门做IDE的这么一个公司,我们可以看到就是说但凡这个世界上流行的,或者说是真正能拿来在生产里面用的语言其实并不多,真正能流行用的其实就那么几种,不超过十个。
你看从JetBrains公司开发的这个IDE就可以看出来了:
- IntelliJ IDEA:主要用Java写,是Java编辑器当中的宇宙第一,没有之一,基本上是宇宙最强
- Visual Studio:搞C#的宇宙第一的编辑器
- WebStorm:搞前端的,TypeScript、JavaScript以及其他一流框架
- PHPStorm:我最早就是写PHP的,我最早入门的是PHP,因为那个时候PHP真的是世界上最好的语言,配合jQuery基本上全栈就能搞了
- CLion:写C和C++的
- PyCharm:写Python的
- Rust Rover:写Rust的
- GoLand:写Go的
- Rider:写C#的
可以看到就是说但凡这门语言它有潜力,或者说是真正能解决问题,JetBrains公司一般情况下都会出一款专门的IDE为它做支持。Rust Rover虽然是前两年才刚出来的,但是至少Rust,它是得到了这个整个世界上大多数人的一个认可的。
关于异步Trait的重要补充
在上一节当中我们刚把异步给大家讲了,但是异步这个东西讲的比较浅显,因为没有讲特别复杂。我建议就是说你先去用,先去搞点小项目,或者说是搞点小示例,你可以先去练习,等你练的熟练了之后,你再回过头来再去扒它的底层:
- 它的Future是怎么去推动的
- 它是怎么去Poll的
- 它内部的运行时
- 事件循环是什么个机制
到时候你再去研究这些东西,你就会理解很多,包括还有它里面整个的一个跟异步特别相关的一个自引用的机制。因为Future它本身就是自引用,它要自己引用自己的,因为它可能会存在一些引用类型的数据,那么一旦有引用类型数据就形成了一个自引用。
自引用这种结构在Rust里面是很难去实现的,所以它就需要借助不安全的Rust,然后以及我们那个Pin。Pin就是那个Pin的翻译过来,就是个钉子,就把你给钉住的那种感觉。所以这些特性其实都比较偏高阶了,要理解是非常困难的,所以需要很长一段时间。所以还是你先去练,你练了之后再回过头理解的东西就好理解了,否则的话就跟听天书差不多。
Rust 2024版本新特性
在Rust Edition 2024的这个版本,我们可以看一下,这个版本已经是2024了,这个是已经是前一个月发布的。那么这个版本它增加了一些特性,最引人注目的一个最大的特性,就是异步Trait。
在这个版本之前,Rust原生是不支持异步Trait的。什么是异步Trait呢?就是你比如说你可以定个Trait,这里面比如说有一个叫async,你可以定一个异步的方法。现在这个方法是没问题的,不会报错,但是你要切换到以前那个版本,它会报错,因为这种语法原生是不支持的。
原生不支持怎么办呢?Rust社区有一个这样子的一个库叫async-trait。这个库可以看到,下载量还不错,以前基本上很多库都这么用。所以这个库暂时先不会废,还先不会废,为啥呢?因为不可能说是所有的生产级别的库还有包括三方依赖,都立马升到这个1.85,都不太可能,因为为了往前去兼容,它尽可能的会去放低这个整个Rust的版本,这样的话它就可以兼容更多的Rust版本了。
所以说这个库短期内它是不会消亡的,但是随着时间的推移,到后面这个库一定会被弃用,这是显而易见的。但是短时间内不会,因为很多老项目它还要用,包括一些其他的项目,依赖这个异步Trait特性的一些库,也会去用它。
async-trait的使用注意事项
有了就是装了这个库之后,你就可以用一个属性宏叫#[async_trait]。当然这个东西我没有安装它,加上它之后,下面这个就不会报错了,就可以继续去使用了,它本质上就是一个属性宏。
这个属性宏会把这个Trait进行展开,里面有一个方法进行重做,会包装一层这种Future的机制。但是这里面有一个坑需要大家注意一下:
1.85原生支持的这种async,最后就是编译完解析后的这种方式,跟这个async-trait通过宏生成的这种方式的方法签名是不兼容的,是不兼容的。
所以说当你使用一些库的时候,如果某些库它自己的异步Trait是通过async-trait去标注了的话,那么当你实现这个Trait的时候,一定也要用它标注。
比如说来实现这一个impl SomeTrait for Person,那就随便来了,然后这里面你也必须得把这个加上,你不加它是不行的。所以这点需要注意一下。
如果三方库你用的三方库,它用的是async-trait声明的异步Trait,那么你同理,在实现它的时候也要加这个注解,否则就会报错。你如果不加它,相当于你用了原生的这个async,等于一个Trait的一个实现,它会报错,它会不兼容,它会有个类型提示类型不兼容。
但是这个类型不兼容很晦涩,它基本上是看不出来到底是哪有问题,看不出来的。所以这点需要注意,如果说你遇到这样类似的问题,你不妨可以朝这个方向去思考一下,因为这个错误非常的晦涩,它根本就没有指出到底是因为你没加这个东西的错误,它指不出来的,它只是说类型不兼容。
Rust宏的分类
这节内容我们主要是去看一下这个宏。Rust里面的宏它分为两大类:
- 声明宏(Declarative Macros)
- 过程宏(Procedural Macros)
过程宏里面又分为三类:
- 派生宏(Derive Macros)
- 属性宏(Attribute Macros)
- 函数宏(Function-like Macros)
我们这些内容,主要是看一下声明宏,因为声明宏比较简单一点。过程宏就比较复杂了,因为它涉及到这种语法树AST,所以理解过程就非常复杂了。
所以我们这个过程宏的话,不打算太过于去深入的去讲解它,因为这个如果要把它真的要全部覆盖到,光这个教程是不够的,真的就要重新开一个新的篇章,去专门去讲解宏这么一个说法。
当然这个东西大家可以自行决定,可以通过留言的方式也好,还是别的方式也好,我可以去看一看,看一看有没有这种兄弟,确实想要知道这一块。如果说想要去学习这一块,我可以后期考虑专门加一个这样的一个系列,去说宏、过程宏这么一个过程。
因为它这个篇章真的很复杂,Rust里面的宏非常的强大,因为它强大,所以它复杂,所以真的就足以让它开一个全新的篇章,去只说宏这一块,真的一点都不夸张。
声明宏(Declarative Macros)
声明宏就相对来说要简单一点,但是当然刚开始你接触的话,可能还是略微有些复杂。
基础语法
声明宏是通过macro_rules!,然后一个叹号,然后这里面就是宏的名字。这个宏的名字你比如说叫say_hello,然后这里面就是它的模式匹配了。它的模式匹配它可以有多个模式匹配,不一定要有一个。
示例1:say_hello宏
1 | macro_rules! say_hello { |
在这里面我们假设它有两个模式匹配:
- 第一个是空的,啥也没有,那这里面就是说你可以自己去实现一个方法,比如说
println!("Hello, Windows") - 第二个,如果你指定了名字,我就给这个指定名字的人Say Hello
如果你没有指定名字,那就我就给这个Windows去Say Hello。所以我们在这里面再加一个可以有参数的。
这个名字我们可以用$name:expr。首先在这里有会起一个变量的名字,这个变量的名字用$开头,$name,然后冒号。这个冒号就是指定它这个$name到底是捕获什么样类型的一个东西,模式匹配嘛就是匹配什么样的内容。
那么expr就是expression的一个缩写,意思就是说这个匹配一个表达式。
捕获类型
这个expr,它这里面这个类型到底有多少种,有很多,常见的就是说有:
- expr:表达式
- ident:标识符(identifier的缩写)
- ty:类型(type,因为关键字冲突的问题,所以缩写成ty)
- tt:token tree的缩写
常见的就这么几种,就是经常写的就这么几种。当然你还可以写别的东西,具体有多少种,你可以自行到Rust的官方手册里面,到宏的那个篇章里面去参考一下,它那里面会列全。到底支持多少种类型,这个东西我也记不住,就是常见的,一般都会用的,是常见的。那些复杂的,还有一些不常见的那些东西一般都不会用。
调用宏怎么调用呢?就是跟你调用这个println!是一样的,先写宏的名字,后面再加个叹号,这个叹号是必须的,然后来一个括号,括号里面可以给东西,也可以不给东西。
如果啥也不给,那就是给这个Windows去Say Hello。但如果你给的话,比如我起个名字"Rust",那就是Hello, Rust,我们向Rust问好。
示例2:name_of宏
接下来我们再来举个例子,这个例子其实我是想举一个叫name_of的一个东西。不知道大家有没有用过C#,C#里面有个运算符叫nameof。这个nameof呢就是你比如说有一个类叫Person,然后在C#里面,你可以用这个nameof运算符,把这个Person类型传进去,这样子的话这个nameof的结果,运算符运算的结果就是这个Person这个字符串。
这有什么好处呢?这个好处就是说可以防止你硬编码,为啥呢?因为有时候你比如说你想打日志,你在打这个类型,你最简单的方式就来个字符串,那这种的话相当于是字符串就写死了。
那么通过nameof的话,你可以相当于是复用,这样子的话相当于就是防止硬编码,你不会把这个Person硬编码在这里,这是nameof的一个作用。
那么在这里面我们就模拟它,我们用宏实现一个name_of的宏:
1 | macro_rules! name_of { |
这里面我们用一个$name:ident,这个类型呢我们就叫它ident,标识符。标识符就是只要是变量的名字,还有我们的这个整个的一个结构体的名字,它都叫标识符。
name_of了之后呢,我们把它返回一下。这个name是一个ident,相当于是个标识符,这个标识符,它不是个字符串,它也不是个字面量,你也不能说是直接去用它,所以是通过stringify!这个宏,就可以把这个ident标识符转换成一个字符串,这样子的话我们就可以直接返回了。
跑一下可以看到Struct name is: Person,这是没毛病的。也就是说通过这种方式的话,你可以防止硬编码。你比如说你现在这里面有很多的类型,这个类型怎么怎么样,或者说是你校验了之后,这个类型发现有个属性什么什么不合法,这个时候你往往就需要把这个类型的名字,在这个字符串里面敲一遍。
但实际上你可以不用敲,你可以用个占位符,然后用name_of运算符,相当于是防止类型的名称的硬编码,这个好处还是挺明显的。
示例3:create_fn宏
接下来的话我们再看一个例子。假设你有一个场景,比如说你要调一个三方库的一个函数,那这个三方库的函数需要一个参数,里面又有一个函数的签名,它需要一个函数,这个函数就是返回一个整型。
1 | macro_rules! create_fn { |
我们写macro_rules! create_fn,既然你要创建一个函数,那这个函数的名字叫什么呢?必须是不是要有个名字,这名字叫$name:ident,因为函数的名字它本质是个标识符。
然后接下来它函数体,它函数体呢就必须是要有一个整型的一个东西,我们一般就是说把它叫表达式,这样的话它的范围要广一点,比如说函数的函数体,它就是一个表达式。
有了它之后,我们就可以在这定义了,定义一个fn $name(),然后我们在这里限定它返回一个i32,返回一个整型,然后在这里面我们就把这$body放这来。这样子的话就是说这个函数,直接就会把这个表达式的结果直接返回,而且要求你表达式的结果,必须是一个i32,因为这个函数的签名我们已经去写死了。
当然你想搞活一点,你可以把类型也放在上面,但是我们在这里演示就不这么干了。
这么干了之后,你就可以在这里面去使用create_fn!这么一个宏。在这里面怎么办呢?我们就调一个,比如说叫get_one,比如说这是一个1。这里可以看到不匹配,因为这是个字符串了,不是那个标识符了,标识符必须是一个普通的标识符。
那再来一个,比如说get_ten,10。这样子的话我们就有两个方法了。
来跑一下,可以看到get_one: 1,get_ten: 10,没问题。可以看到我们在这里快速的定义了一个函数叫get_one,又定义了函数get_ten,在这又把这个函数给调用了一下。
不知道大家有没有体会到宏的魅力。宏的本质可以看到,就是说这个函数里面,你可以通过多个分支,根据你想要的结果去进行模式匹配,匹配到了之后,这个函数可以定义的非常非常的复杂,你想把它定义的有多复杂就有多复杂,随你定。
只要它是语法允许的,你随便去搞。这里面可以定义的就是Rust所有语法都支持的东西,你都可以搞。所以理论上来说这个非常灵活,这里面你能定义的所有的东西都可以定义。
那么Rust在编译时会把宏展开,展开的时候,这个宏就会被替换成所有的这一切的代码。
CRUD示例的思路
那你想假设我这里有一个非常繁琐、逻辑非常复杂的函数,但是因为有其中一两个点,或者是两三个点不太一样,那你是不是可以把它整成一个宏,把这两三个不一样的点,用这种模式的变量把它提到这里面,放到这里面来。这样的话你定义这个函数的时候,两行代码可以定义非常复杂的功能。
我记得我之前应该给你们提过一个,比如说叫CRUD的东西,比如说这个impl_crud。这个东西你可以完全定义一个这样的宏,这个宏比较复杂,我这里就不去演示了,因为这个里面要写很多东西。
这里面你完全可以去把它的:
find_one:查单条find:查多条find_page:查分页create:创建update:更新delete:删除delete_all:删除所有batch_create:批量创建batch_update:批量更新
就一个宏,你可以把这些所有的东西全部实现。其实这些逻辑你仔细想想,如果是简单功能的情况下,它有些什么地方不一样?你们可以想象一下,其实它就只有一个实体不太一样,实体不太一样,然后实体字段有点不太一样。
当然实体字段不太一样,你可以通过Trait去规范化它的功能,就像我们在Java里面,用接口和抽象类去规范化它的功能是一样的道理。你可以用Trait规范了它,规范了它的功能之后,那这里面你就可以完全用Trait的方法去调用它。
这样可以屏蔽它不同实体有不同字段的细节,把这层细节给它屏蔽了之后,你不需要去关心细节,你只需要关心抽象。只要你关心抽象,你这个方法一定能够写出来,你可以顺着我的思路来一定可以写出来。
这样的话你可以通过快速的宏调用,比如说:
1 | impl_crud!(User); |
这就很快,一个功能直接就写个宏就直接出来了。你想这个魅力有多么大。除非就是特别复杂的功能,你就稍微定制一下,但是简单的功能直接干CRUD,你还用写CRUD吗?
以前写CRUD的痛苦已经都不复存在了。当然你说用其他语言写CRUD吧,你现在也不是说一行一行代码去写的吧,有很多代码生成器,一生成。但实际上你生成这些东西,无非也是整理一堆代码。
当然它这个本身也是代码生成器,也是代码生成器,但是它更加简洁,它是把这种代码生成的复杂代码给你屏蔽掉了,你不需要去关注那些东西,它完全都是自动化的。
但是其他语言的代码生成器,生成这个代码,你还得维护它,你还得维护它。这个就举个例子,给大家去开拓一下思维,让大家可以更加的发挥自己的创造性,然后你试试,看看能不能解决一些有意思的问题。
示例4:create_struct宏
接下来再举一个例子。比如说我们要创建结构体,那结构体的话我们经常要给它一个简单的结构体,但是我们很多地方都要创建这么简单的一个结构体,那就会显得有点繁琐。那怎么能够快速的去创建一个结构体呢?
我们同样可以定义一个宏,来创建一个快速的结构体:
1 | macro_rules! create_struct { |
在这里面我们要去定义参数。首先结构体的名字你得知道吧,那同样也是$name:ident,因为这个名字它就是一个标识符。
然后接下来的时候我们需要创建结构体的字段,那这个结构体的字段是不是很多,它不一定是一个,也不一定是两个。那在这里面我们给它以一种更加灵活的方式定义,这样的话可以满足更多的需求。我们就不假设它是一个字段,我们假设它有零到N个字段。
那么对于这种形式呢,我们如何在这个模式匹配里面去体现这一点呢?在Rust宏里面是通过这样的方式去体现的,表示零到多个。这个形式是通过一个星号去表示的。
说到这个星号,不知道大家有没有熟悉,是不是很跟那个正则表达式很像?正则表达式表示量词的有星号、加号,还有问号:
- 问号是0~1个
- 加号是1到多个
- 星号是0到多个
当然还有量词的大括号,比如{1,},如果不填,那就是大于等于1;如果填了,那就1到某个范围。是不是跟正则表达式很像,所以说如果你熟悉正则表达式,你可以完全把它按照正则表达式的这个思维去理解,只不过语法上稍微有点不太一样。
它怎么写呢?就是说先用一个$大括号把它括起来,然后再加个星号。它的意思就代表这个意思是,把这个括号里面的东西给我重复零到N次,零到N次就是零到多次。
这个里面你可以认为就是,其实跟正则表达式很像,你可以把它作为一个正则表达式的捕获组。正则表达式的一个捕获组呢,就是通过一个圆括号,只不过它没有那个捕获的名称,它也不像正则表达式有个问号冒号去定义非捕获组。
你可以认为它就是个简单的捕获组,然后只不过它这个捕获组前面还有个$,然后一个大括号,然后在里头去定义什么呢?去定义它整个的一个模式。
这个模式是什么呢?比如说我们的这个结构体有字段,有字段那个字段的名字叫什么呢?还是最重要的还是个名字,这名字我们也不能叫name了,因为跟前面就冲突了,比如说我们就叫做field_name吧。
然后这个名字也是ident,然后我们在这里面还要去定义一个模式,定义个什么模式呢?就是说这个字段和对应的字段的类型,应该是什么,这个模式应该怎么去定义。
你可以用逗号,用了逗号之后,你这个模式里面必须也得是逗号,因为它模式嘛,它就要跟这个模式一模一样。但是为了我们这因为这个结构体,它定义字段是这种名称冒号值的一种形式,所以说我们为了契合Rust它这个语法,我们在这里用冒号。
用了冒号之后,再加个空格,加空格是为了好看,你完全可以不用加空格。然后这里面就是它的一个type,就说这样的ty吧,它是一个类型叫ty,前面我们刚刚也说过。
然后有了它之后呢,在这里注意有一点就是说,这个定义的就是说定义了一个我们的一个键值对,一个键值对。有了这个键值对之后,在这里面就可以去写真正的这个结构体定义了。
同样的也是struct $name,我们先把这个先写,先把它写完一个大括号,然后这里面就要用到循环了,用到循环怎么去把它展开呢?展开还是一样
的道理,通过这个方式去展开:
1 | $( |
它这里面已经提示了,就是我们在这里定义的模式,是多个去进行匹配,然后这里面也要对多个展开。中间这里面还少个逗号,这里要注意就是我们多个字段要用个逗号去分割一下。
这里面就是意思,就是说把这一坨内容整个用一个星号,然后去重复它。这样子的话相当于在这里面就会把这个$field_name和$field_type进行不断的重复。其实这个模式它就会不断的重复,因为我们在这定义了,本身它就是一个模式重复了,然后把这个模式重复搬到这来,它就可以去不断的在这里进行重复。
重复完了之后你会发现一个字段、两个字段、三个字段、四个字段,就类似这种形式,相当于我们这个结构体就定好了。
那这个结构体,我们为了让它可以方便调试,我们同时给它来个derive,然后加个Debug宏。那这样的话我们是不是相当于是可以快速去定义一些结构体。
同样的就是说我们再把这个例子再拓展一下,我们可以让它更复杂一点,我们还可以同时给它实现一些方法。那这个方法比如说叫new,这个还挺智能的,那个这个提示出来了,那就直接用。
第一个方法new,new里面它同样它也要接受这么一堆参数,就跟上面这个模式是一模一样的,只不过它少了这个类型声明,然后相当于是把这个参数给它解构到这来,放到这来。
放到这来之后,然后在这里面去返回Self的时候,同样的只用name就行,为啥呢?因为名字跟属性是一样的,就类似于JavaScript ES6的这个语法,它就可以直接把这个冒号后面的value省略掉了,直接写个名字,然后逗号,然后重复。
这个方式就是你会发现,就是你写熟练了会很爽,会很爽,就是它能让很多重复代码瞬间化为乌有。这就是它最牛逼的一个点,就是让很多重复代码化为乌有。
你看这个其实定义的其实也并不是特别复杂,你完全可以把它定义的更加更加的复杂。就在第三方库里面,我们说Rust第三方库里面有好多的优秀的库,基本上都在大量的使用macro,因为基本上很多重复的模式,基本都可以用macro_rules!来解决。
就是说在这个宏里面,我们说除了声明宏,还有过程宏。过程宏又有这个派生宏、属性宏和这个函数宏。在这些过程宏里面其实它也很重要,但是它实现的相对来说是更加高端、更加特定化的一些场景。
但比起这个声明宏macro_rules!来说,声明宏一定是你用的最多的,一定是最多的。也是这节内容,我为什么就是说把它要单独讲,第一是因为它简单,第二也是因为它的使用场景,而且它的用例场景会更加的丰富,相比这个过程宏会更加的丰富。所以我们就把这个东西讲的更加细一点。
然后这个宏创建完之后,在这里你就可以继续使用它了:
1 | create_struct!(Student, name: String, age: u8); |
在这里面我按照我们的模式定义,我们比如说我们要在这定义一个,定个什么呢,定个Student吧,学生。然后比如说我们要有个学生,要有个名字,名字呢是一个String类型,然后我们需要有个年龄,比如说这个学生是什么年龄,年龄我们一般用u8。
这样的话就定义了一个结构体叫Student。我们把鼠标挪上去看看能不能展开,看到这个我们的Rust Rover还很智能,它可以跟我们展开。看到没展开之后,我们这个Student定义是什么样的:Student,然后这个有一个派生的一个属性叫Debug,然后有一个name和age,然后实现了它,然后它的new函数有name、age,然后返回new。
是不是非常爽,是不是非常的清晰。然后我们在这里面就可以调用它了,通过Student::new就可以说,我们在这里用String::from,假设其中一个叫维纳斯,然后18岁。
这里面定义个变量,把它接收一下,叫student。定义好了之后呢,我们把它打印出来,println!("{:#?}", student)。还记得这个井号是干啥的吗?没有井号,就是什么Debug打印;那么有了井号之后,相当于是带有格式化的Debug打印。
我们把它运行一下,可以看到没问题,我们的student name、age。是不是非常强大,一行代码来解决所有的问题。
这个黄波浪线不用管它,我看一下它的意思是这个变量可以never read,意思你没有读过它,你要读过它就好了,你读一下它就好了。但是没读过,我们不管,在这里面就是没有用它。
这里面你还可以创建很多的,你看创建结构体一行代码,你可以创建一个非常复杂的结构体。你看这个结构体代码多少行,1、2、3、4、5、6、7、8、9、10、11、12、13,13行。一行代码顶13行的功能。
你想这里面你还可以再定义五六个字段,一般字段的数量控制在5~6个,你完全可以用这种方式去创建。创建完了之后,你想有多少?顶了十几行代码,一行代码顶十几行代码,这是个什么概念。非常是不是非常的快速。
所以说你要体会Rust这个魅力,而且这个东西它在编译时会被展开,展开之后就跟你手写了这么一个Student这个结构体一模一样,是零成本开销的。所以它这个零成本开销多么强大。
示例5:hashmap宏
我们再说一个例子吧,再说一遍,尽量的把这个例子丰富一下,然后让大家对这个声明宏有一个非常感性的一个认知,争取就是说你可以灵活的去使用它。因为它真的相比过程宏非常的简单,非常的简单。
而且也应该是你项目当中,很多时候用的最多的用例。因为它没有抽象的这种语法树,它不需要去你去懂一些语法树的概念,也不需要去懂一些编译的概念啥的。所以说相比过程宏来说,门槛就低了很多。
这也就是为什么它在很多的这种开源项目当中,会被用的数量最多的一个原因。因为它确实简单,而且解决的问题也是大多数的,也就是说80%的问题。一个二八原则嘛,80%的问题都可以用macro解决,20%的问题需要用过程宏解决。所以说把它学好基本上就够用了。
过程宏的话,除非就是你真的确实要定制一些自定义一些特别好用的一些东西,你用过程宏。当然前期的话可能小项目或者说简单的项目,一般都不会遇到它。就算复杂点的项目,其实你一般不会遇到那种确实要特定制自定义整个过程宏的一个过程。顶多就是派生宏,派生宏可能会要用的多一点。一会我说派生宏的时候,给大家简单介绍一下派生宏。
然后其他的这个像属性宏和函数宏,它更多的就有点像那种编译的东西了。那真的是你要去弄编译的东西,它要把那段代码真的弄成抽象语法树,然后你要把它进行修改、魔改,然后弄完弄成一个树,然后再又返回返回去把它变成一个代码。
当然这里有现成的一些库,比如说syn、quote,这些它可以帮你解决一部分的问题,但是本质上还是需要你去懂很多相关的一些概念。
好,我们再说一个例子。这个例子定义啥呢?在这个例子我们定义一个hashmap的东西吧。我们标准库里面不是给我们已经声明了一个叫vec!的一个东西吗,通过它可以快速的创建一个我们的Vector。
那么我们定义一个hashmap!:
1 | macro_rules! hashmap { |
这个hashmap呢,然后在这里面我们要去定义它是什么呢,就是说让它可以快速的为我们构建一个hashmap,并且把这些KV对给它整进去。所以说我们这个模式里面需要的就是什么,需要的就是K和V,K是什么东西,V又是什么东西。
所以在这里面同样因为KV可能是有多个,可能是有多个,所以说我们要在这里面用到一个$($key:expr => $value:expr),然后是多个。
因为这两个都是表达式,它K和V也都应该是个表达式。然后就是逗号,然后是多个。
这个模式你可以用逗号,你也可以用这个箭头符号,但是因为我们最后要分隔的时候,是要用这个逗号去分隔的,所以你最好不要用逗号。你可以用这个箭头,我先用这个。
然后在里面我们就可以在这里面去写了,那在这怎么去写呢?我们去定义一个map,定义一个map,然后把这个map呢所有的值给它插进去。
let mut map,然后我们定义一个map,这个map应该是HashMap::new()。这里面的话就是说尽量的把它写全,为啥呢?因为你不写全的话,你不确定当前的用户在用你这个宏的时候,它有没有导入这个std::collections::HashMap。如果它没有导入,那你这个宏就会报错。所以你写全的话就不存在这个问题。
当然你也可以在这里用use,你可以在这这么去写,但这么去写的话也不好,为啥?因为你这个是全局的一个变量,就是你在这去显式use,是影响了你这个使用宏的外部的这个环境,你会让它也可以不用导入,直接使用HashMap,这样的话会让它代码不太可控。
所以说这里面的话就是说,如果你要用这种方式,你就最好用一个大括号,这样子这样子的话就没问题了。我想一想,这个大括号还是有必要的,为啥呢?
首先你想象一下,就是说比如说我这样子不这么去写,我就这么去写了。我们知道就是在这个模块系统,我们说过use这个语句非常灵活,它可以用在任何地方,它包括这个大括号里面去。
然后这个语句我们把它放到大括号里面的原因是什么?首先你不放在大括号里面,相当于你这坨代码在宏展开的时候,就跟这个调用的地方是平级的。如果是平级的话,相当于是它可以在这里面,把你定义的一些临时变量进行操作。
你假设你在这个里面我们遇不到临时变量,假设你在别的地方,你可能在这定义一个临时变量,比如temp要干一些辅助性的工作。那这个时候呢,就是说如果你把它没有包在这个大括号里面,就导致它的作用域在你使用宏的这个同级,它是可以也可以去访问这个东西的。
但实际上这并不是你的用意,你并不希望它去访问到的,所以用一个宏包起来的作用域,这样的话让它作用域限制在这个大括号里面,这样的话使用宏的地方,它也不会访问到你的内部的这种变量。明白了吧,就这么个意思,类似于封装的概念。
我们就这么去写,然后在这里面,我们map.insert($key, $value);,然后就把它展开,用星号。星号的话就相当于是把这个模式重复零到N次。你这里输入了多少次,这里面就重复多少次。比如说这里面输了两次,那这里就重复两次。
这样的话相当于什么?map这个值给insert进去了。那insert进去之后呢,我们就可以把这个map返回了。返回你不需要return,因为表达式的最后一条语句它就是就是这个整个块的一个结果,它的一个值。
定义完之后,我们的hashmap这个工具定义完了。定义完我们就用一下它吧:
1 | let map = hashmap! { |
我们来定义一个map等于一个hashmap,这样的话我们就可以很快速的去定义我们的hashmap了。然后比如我们定义一个叫name,然后比如说它叫维纳斯。
然后我们再定义一个age。这个时候注意,这个时候我们不能这么去定义,你这么定义它就不行了,为啥呢?因为在这个第一个表达式里面,它已经把值推断为String了,所以说这里面它跟Java不太一样。Java它是有个Object的东西的,所以Object可以容纳万物,容纳一切。
所以说在这个Rust里它没有Object概念,它不像那个Go,Go里面有这个any,any其实是interface{}的一个别名,所以它也不像Go或者说是其他语言的对象语言里面有个any的东西。所以说它这里不能这么用,你不能既放一个整型,又放一个这个字符串,所以说不行的。所以说这里面的话就暂时就把先写到18吧。
但是这个问题怎么去解决呢?我先把这个东西给大家去讲完。这样定义完成之后,这个map就定义好了,定义好了之后把它跑一下。可以看到我们这个map就顺利的把它定义完了,是不是很强大。
你可以在这里定义N多值,我把鼠标挪上去,我看它展开的效果。看到没,它展开之后就定义一个HashMap,然后insert、insert。是不是非常方便,对于这种,你比如说你想要去快速的去插两对键值对这个值的话,用这个方式就非常方便。
甚至我觉得这个东西这个宏,你可以完全把这个代码copy到你的生产环境,直接去用,我觉得它也是一个非常实用的宏。
这里面你还可以给更多的值,你比如说是gender,比如说它的性别是什么。那这里面可以看到加个逗号就不行了,这个逗号,其实我们最好应该是把这个逗号兼容一下,因为这样子的话就是说你可以有逗号,也可以没逗号。
那这种方式其实在这个宏里面怎么去实现呢?其实它是也是可以去实现的,我们只需要让这个宏有一个,还是把它想象成正则表达式,跟正则表达式非常的像。这里面就是,你比如说这里面来个逗号,然后来个问号。
这就代表意思这个逗号是可选的,问号的话是0次和1次嘛,在正则表达式的语法里面也是一模一样的,0次到多次。这样的话就是可选了,可选了之后,那这里面就是说这个逗号你可以有了,你可以有也可以没有。
没问题,同理上面这个其实也是一样的,你可以在这加,我这里就不加了,你自己可以去自己去加一下。这样的话这个map就更加舒服了。现在它是有三个值,没问题,一点问题都没有。
可以看到它这种定义方式,是不是就非常方便了。然后这个箭头,只是我们自己定义的语法,自己定义的语法,你完全可以定义一个很奇怪的语法。
你比如说来个什么,这个箭头不太支持,因为这个不是说所有的字符都支持,因为Rust这个编译器它还是有限制的,有限制。至于哪些支持不支持,你自己试一下就知道了。
这个单个箭头也不支持,箭头是支持的,光一个箭头呢也不支持。哎呀还挺难受的,这也不支持,@呢,@也不支持。好了算了吧,不管了,就不研究它这个东西了。
这个东西你可以自行去这个官网去研究一下,这个东西我也并没有特别在意这个东西,到底你要用哪些字符啥的。既然用这个就可以,我们就用这个了,无所谓。
并且这个东西其实也挺好,因为Rust其他的标准库用的还挺多的。你比方说这个Tokio,我们在上一节就是讲异步的时候,Tokio里面不是有个select吗,那select它是不是左边是一个模式,然后中间是一个箭头,然后右边它是一个Future,右边又是一个大括号里面加的语句,它就是用的这个符号。
所以这个符号就是说尽量用大家就是很多库里面都通用的符号,这样的话如果你在写个第三方库,别人用你的库的时候,它也会比较亲切一点。如果你弄一个乱七八糟的这种玩意儿,看着很奇怪,而且会让人很困惑,这是什么玩意。
所以说没关系的,这个东西其实就用它就行了,而且我觉得它也是非常符合语义,key到value,key到value。好这个就不纠结了,就这么去干吧。
声明宏总结
然后再就是整个宏的一些概念,其实这个声明宏就讲这么多吧,其实也差不多了,基本上这些用例,我觉得应该是能涵盖大部分场景。如果说没有涵盖到的,你可以自行研究一下,因为它本身也并不是特别复杂。这里面的话就是说能给你开一扇窗户,就是做一个启发。
过程宏(Procedural Macros)
那接下来我们说一下过程宏,过程宏呢就是说我不打算在这里去详细讲,因为它这里面本身涉及的内容特别复杂,特别复杂,就是讲起来肯定光靠这一节内容,肯定是远远不够的,是远远不够的。所以说我们就简单的带一下。
那么过程宏里面我们说它分为三种:
- 派生宏(Derive Macros)
- 属性宏(Attribute Macros)
- 函数宏(Function-like Macros)
派生宏(Derive Macros)
什么是派生宏?你先把这几个宏的含义先理解。我先看一下哪里用到了派生宏,派生宏我们就用这个struct,用到这个派生宏,就是用这个derive。derive就派生,派生。
所以说你要自己写个派生宏,你就自己可以在这里面去写一个名字,你自己的派生宏的名字,比如Debug,它就是个派生宏,Debug就是派生宏。
这里面点它到这个具体里面去了,这是编译器,它自己做了个优化,就是说有个派生宏叫Debug,那Debug就是说相当于是它能够自动为你去实现这个Debug trait。
你可以自己去实现,你比如说我可以自己实现Display,当然这个标准库没有实现这个宏,没有实现这个宏,所以你可以自己实现一个Display的这么一个派生宏。这就是派生宏。
属性宏(Attribute Macros)
属性宏是什么呢?属性宏就是,不知道大家有没有用过Actix,就是这个Actix Web,这也是Rust里面也比较老牌的一个框架了。
这个Actix Web这个库里面它就是说,你去写一个控制器的时候,有点像Spring的东西了,就比如说:
1 |
|
井号get这么个东西,然后在这里它可以去定义一个path路径,比如说你是get路径这么个东西。那这个东西就是属性宏,就是属性宏有点像Java里的注解,这个就更像Java里的注解了。它是属性宏。
这个属性宏,相当于本质上会把下面的这个整个的一个函数,当成一个就是编译成一个AST,然后就是token stream,做成一个token流,它也是个AST。然后会传给你自己写的这个属性宏的一个函数。
那这个函数里面去对这个宏对这个token stream做解析,解析完了之后,你可以根据你这个逻辑,你重写这个函数也好,包装这个函数也好,还是自己完全就给它改头换面一个函数也好,非常的灵活,责任在你,想法在你,你想怎么干就怎么干,就是你完全把它不要了都行。这就是它属性宏的一个概念。
函数宏(Function-like Macros)
什么是函数宏呢?函数宏是,是像跟函数一样是调用的,所以叫顾名思义叫函数宏。比如说sqlx,不知道大家用过没,就是你们如果说开Web项目,大概率会去用到这个SQL的框架。
那SQLx呢就是有个宏,然后这里面有个叹号,叹号在这里面就可以:
1 | sqlx::query!("SELECT * FROM users") |
那这种方式调用,看着是不是就有点像我们的函数调用了。那么这个过程就是函数宏,就是函数宏。
你能区分出来它是什么宏就行了,然后简单的有个印象就行了,至于要怎么去用,这个东西你可以暂时先不用去关心,可以先不用关心。
Slint GUI库示例
我看一下,我打开这个Web看一下有Rust,应该有一个叫GUI库,我看一下。有点记不太清楚了,应该有一个GUI库叫,啊对对对,这个Slint。真是想不清楚了,这个GUI库在Rust里面算是比较成熟一点了,算是也比较强大一点了。
所以说如果你想做个小demo,你比如说你想做一个GUI的小demo,用Rust去写,你就可以不妨采用一下它这个Slint。Slint这个东西,它就可以解决这个问题,就是这个Slint它是一套DSL的语言。
它有一个,我看一下,看一下它有没有demo,它有一个,我们来看一下。这里的话它是另起了一个文件。不管了,就是它有一个宏,它有个宏,就叫slint!是个叹号,然后这个宏里面你可以写它的这个DSL。
看到没,它这个完全就跟这个Rust语法不搭边了,然后这个宏里面相当于就是会把它进行,作为一个token stream给它,然后这个宏负责进行对它进行词法分析解析,完全就是一套类似于新的编程语言那种感觉。
把它进行词法分析解析成这个AST,然后再执行它自己的逻辑,或者生成相关的Rust相关的一些类啥的东西,就是根据它自己的AST的规则嘛。所以这个就非常灵活了,就函数宏非常灵活,你可以定义自己的DSL,像这个Slint的这个框架,这个东西就是它的DSL,它就是DSL。
当然这个东西对你这个开发这个宏的要求也是非常高。这个很困难。好,这个东西我就提这么多吧,其实也就那样,然后你大概有个了解就行,可以暂时先不用去深究,主要就是macro_rules!,就是我们重点讲的这个宏就可以了。
派生宏实例讲解
然后这个派生宏的一个写法的话,其实派生宏可能稍微也可能有有一点用途,然后我想的是我看一下,我把这个,我开一个项目,我觉得大家看一下这个项目吧,这两年前的这个项目。
然后这里面用到了一个派生宏,一个概念。然后这个派生宏里面主要写了个啥,写了一个有一个DataDictionaryTranslate的一个这个派生宏,这是数据字典翻译的过程。
我相信大家在写Web的时候,肯定会遇到一些数据字典翻译的过程,那么这个翻译的话本身它就很繁琐,如果说你要自己去写很繁琐。所以说当时我就写了一个数据字典的一个翻译的一个派生宏。
那这个派生宏怎么写呢?我们就简单看一下,就是说通过#[proc_macro_derive]这么一个属性,然后把这个传进去,把这个传进去。然后这里面就还可以定义属性,比如说这个属性就需要的是什么,就translate。
然后在这里面就用parse_macro_input!,这个这个其实是syn
的库,我们说写过程宏的话,基本要用这三个库,这就是三剑客,写过程宏的三剑客叫:
- proc-macro2:这是对标准库的一个封装和拓展,因为标准库这个东西用起来不是很好用
- quote:用于生成代码
- syn:用于解析
然后在这里面解析,解析成为一个DeriveInput,然后这里面就可以真正调用实现了。那实现的话相当于是把AST传进来,然后在这你就是把它的ident拿到,这就相当于是你注解派生宏的那个结构体,它的名字叫什么。
在这里面它不一定是结构体,你可以用别的东西,所以说在这里要判断一下是不是结构体,所以进行了模式解构。如果解构成功了,就进行实现,如果解构不成功就报错,可以看到”Only struct is allowed”。
在实现的时候我们可以看到,它这个实现的过程就很那个了,就是比如说先把它字段判断是不是为空,空的话直接返回空。然后在这定义一个Vec,然后在这就遍历每一个field,就是它这个结构体每个field,然后判断这个attr是不是为空。
如果不为空,说明它就有这个属性,那有属性的话,就看一下这个属性是什么,就parse attr把这个属性给它解析出来。解析出来之后,它就会得到一堆的这个要去翻译的属性,包括它翻译的名字是什么,field是什么。
然后它生成翻译的默认的名字,它不存在的话,就生成默认的名字,比如说叫它的字段后面追加”_name”,然后再把它插进去。
然后主要的看一下这个生成宏的过程,就是构建一个token stream。同时也构建一个token stream,然后对这个map来进行遍历,遍历的时候,在这里面主要是通过它,还去把这token stream给它生成。
通过quote生成一个token stream,然后在这里面就是真正调翻译的过程。真正要写全路径,全路径,比如调这个函数叫translate,然后把这个传进去。
##name呢是相当于引用上面的变量,然后在这里面就将它翻译,翻译好了之后,就把这个翻译的结果返回进去。就这么简单,这就是真正的代码。
通过quote,它里有这个宏,你看到没,它也用的是一个宏,这个宏就是函数宏。这个宏会把里面的这个代码,又会编译成一个token stream,然后返回。
所以然后在这里面extend,相当于是把这个token stream,加到这个token stream里面去,然后最后就返回。返回的时候就是Ok里面又是个token stream。
这个token stream,然后这里面我们可以看到,注意它用的async和trait。因为当时两年前是吧,两年前这个Rust还不支持这个异步的async trait,所以说用的是async-trait这个trait这个库。
然后在这里面就是实现这个GetDictionaryTranslator,然后为这个struct name,然后在这里面定义一个async方法,这个async方法就是translate,然后revenue self,然后把这个token stream传进来。
这个token stream,就是我们上面构建的这个token stream,所以说有多少个字典需要翻译,这里面就会有多少条这样的一个语句。所以说基本算下来还算简单一点吧,还算简单一点,就稍微要比声明宏要复杂一些。
但是比起这个函数宏,还有这个属性宏,要稍微还是要稍微的要简单一点。
然后就是parse attr就是我们parse那个属性嘛,比如说有这个translate那个属性。在这里面,它要去parse。parse的时候,要判断你这个name是不是ident,还有这个field是不是一个ident,是不是标识符啊等等。
这东西好简单,浏览浏览一下就行了,你不必去纠结这每帧里面的细节,因为这里面细节也太多了,这东西一时半会也是说不完的。
使用示例
我们看个例子,比如说是像这个User,这里面这个ORM框架用的是SeaORM。可以看到这个User是用到了这个DataDictionaryTranslate。
我们可以看到它生成的代码,是不是没问题,translate。在这里面就是开关状态怎么怎么样。
可以看到它使用的方式,使用的方式就是除了在这里去加derive DataDictionaryTranslate之外,在这还要加一个translate属性,然后name,这个name就是它字典的名字,这个字典就是比如说开关状态。
然后这个field就是它翻译完的一个名字,翻译完的名字是在这放的,就是它最后就会把它翻译,翻译完了之后把值就放在这里面,就这么简单。
这个就是整个的一个过程宏,派生宏。
EnumSupport派生宏
那在这里面其实我还实现了一个叫EnumSupport,这个派生宏就稍微简单一点了,稍微简单一点。它主要是做了些啥事呢?是实现一些默认的方法,实现啥方法呢?
就是比如说实现一个new方法,new方法相当于是传一个字符串的引用切片,然后在这里面就是给它把根据这个值的大小,根据这个值匹配,然后返回对应的枚举项。
可以看到它这么去做的,注意这里面也用到了这个模式匹配循环。为啥呢?因为values本身是个Vec,看到没,它是Vec是多个,所以在这里可以通过一个井号这么个东西,进行循环。
还实现了ToString,还实现了TryFrom,然后实现了into。可以看到就是说当你这个Rust写多了,其实这些都是通用trait,trait基本上是都要去都要去用的。
我们可以看一个枚举,这个比如说这个SwitchStatus。在这里面可以看到它这个定义个EnumSupport,这是derive。我们看一下这个derive它的生成结果,可以看到它生成之后,你看它生成这么多方法:
- 一个是首先impl
- 然后是public new。new的话,可以看到它是不是根据value进行match,match的话,如果是1就是Enable了,然后2就是Disabled,如果是其他所有的任何值,就是InvalidInnerValue
- 然后实现ToString。ToString的时候,相当于是匹配一下,如果是Enable的,就返回1,如果是Disable返回2
- 然后就是TryFrom。TryFrom从String或者reference str
- 然后还是into string等等等等
这是为SeaORM特有的一个trait,实现了一个特有的trait,IntoActiveValue。还算简洁吧,还算简洁。
就是这个EnumSupport,这个东西相对来说要简单一点,比上一个DataDictionaryTranslate要简单一点。因为它那个还要遍历里面的字段,去找那个属性。这个是没有额外的属性的,它直接就被这个数据测,那么去实现相关的东西。
当然你说这个东西能不能用这个声明宏去实现呢?也是可以的。你定一个macro_rules!,然后在这里面把这个类型给它传进去,传进去之后在macro_rules!里面去impl它,然后impl from str等等等等。
不对不对,这个还不行,不行。为啥呢?因为你把这个传进去之后,这个声明宏的话,它不支持知道有Enabled和Disabled这些字段的。你只能用这个派生宏,派生宏的话,它可以把这个里面,把这个枚举项给它编译出来。那个声明宏还是辨别不出来的,不知道的。
所以说刚刚那个说错了,不行,不行。但如果说只是简单的实现,不是比如说是简单的实现这种ToString,或者说是我不需要了解这个它的枚举的变体的情况下,你完全可以用这个声明宏去声明它,去实现一些接口或者别的一些trait,不是接口,trait。
好吧,那这个就介绍这么多,这也是以前那个老项目了,这个尘封许久。我把它就是为了给大家说一些这些东西,把它拉出来给大家看一下。
课程总结
好,那这个就是基本上就这么多内容,然后这节主要还是希望大家去着重把这个macro_rules!好好去了解一下,好好学习一下。
我觉得这个东西应该是你日常工作或者说日常编码过程中,应该是要用用的最多的一个宏,一个宏。
好,那就这节内容就到这儿,然后还是下去要多练,要多多去理解。其实把它多多的跟这个正则表达式去类比,你可能会稍微理解的快一点。
好吧,好,我们下节内容再见,拜拜。
为什么选择Rust
我们来看一篇文章,一个初学者说为什么选择Rust。这篇文章叫《为什么选择Rust》,作者叫Rowland Hner,这是原文链接,有兴趣可以看一下原文。这篇文章是9月19号写的。
先看一下泰山不让:选择Rust是我深思熟虑且毫不后悔的决定,他是一位严厉但优秀的导师,为我打下了比其他方式都坚实得多的基础。这个其实跟之前很多的说法比较类似。
编程语言选择的困境
哪种编程语言最适合入门?这大概是有抱负的软件工程师最常问的问题之一。这其中既有好的原因,也有不那么好的原因。从定义上讲,初学者没有能力做出这样的选择。选择一种语言而非另一种语言的原因有很多:我是在寻找最容易学的?需求量最大的?还是跟特定兴趣领域最相关的?你想要深度呢还是简洁呢?
我没有花太多时间在网上找到一些建议。长话短说,就是普遍的共识大致分为两种:
C语言阵营
一方面就是C语言,是本世纪你能够合理接触到的最低级的编程语言。通过C语言,你能学习计算机的工作原理,并打下坚实的基础。C语言是无处不在、应用广泛,50多年来一直是这样的。从C语言,也可以说是C++,开始是全世界正规计算机科学课程和大学所青睐的这种方式。C和C++是元老级的语言,有大量优秀的资源可供学习编程。
高级语言阵营
另一方面,就是你拥有高级语言阵营:Python的语法读起来像英语,JS是事实上的网页标准。两者都能够教你编程的基础知识,那些知识你以后会随处用到,不管你愿不愿意,都没有办法避开它。大多数自学成才的软件工程师都从其中的一种开始。另外也有大量学习Python和JS的资源:书籍、课程、游戏、应用、训练营不胜枚举。
各语言的问题
但事情并非这么简单,不是吗?
我知道有些人会因为我把它们混为一谈而生气,但C和C++的入门门槛确实很高。它们都伴随着非常混乱的生态系统、编译器标准、辩题和风格,使得从它们开始成为一项艰巨的任务。C++也苦于它渐进的、有时甚至是相互矛盾的改进历史,以及它的生态系统的丰富性,导致了选择困难症。
Python简单,但可能过于简单,把复杂性隐藏在直观的语法之下。在惊人的UV出现之前,Python的包和环境管理不可靠,而且充满了摩擦。而且Python速度慢。说实话,它在机器学习和人工智能领域的广泛采用和无处不在,确实有点诱惑我,但最终这并非我所寻求的。
至于JS,有很多话要说,有些是好的,但大多数不那么好。我非常尊重它为其范围扩展到最初设计之外所做的工作,但是在我看来,它是一种不连贯、被过度使用的语言,是我们迟早都得学习的东西,但不是我真正想用有限的空闲时间去学习的语言。
说实话,最后一点可能是对我选择第一门语言影响最大的一点:学习编程是我真心想做的事情,上述任何一种都没有能够让我感到足够的兴奋,来提供所需的动力。
为什么选择Rust
在Stack Overflow开发者调查中,它是最受喜欢的语言,近10年来一直这样,原因很充分。
Rust从头设计的目的是利用、结合并建立在50多年计算机科学研究成果之上。Rust拥有卓越的性能、最先进的统一工具链和包管理。它正逐步接管低级编程领域,从嵌入式系统到系统编程,再到各种领域的工具。截至本文撰写之时,它尚未成为前端开发的首选,并且由于各种原因可能永远不会是,但它确实能够做到。
Rust是一款设计如此精良的软件,以至于你很难找到它工程方面的负面评价。
Rust符合我成为第一门语言寻找的所有特性:
- 足够快
- 它既能进行低级编程,也能进行高级编程
- 它建立在过往经验教训之上
- 它拥有出色的生态系统和工具链
- 它拥有广泛且不断增长的采用率
- 它不会让你养成坏习惯
- 它让我和其他人一样感到很兴奋
所以选择Rust的唯一注意事项是:它有学习难度大的名声。然而这对我来说并不是什么大问题,也许对你来说也不是。
关于”难”的思考
我认为困难本身很少是问题,更多时候是这种困难通过缺乏清晰度和不必要的复杂性来表现自己。学习说话、走路、数数、社交都很难;足球、体操、舞蹈、绘画都很难;写作、阅读、使用电脑都很难。我们能做难的事情,做难事不容易,但是值得。这是米亚洛夫所讲的。
所以问题从”它难吗”变成了更有趣的”为什么要选择这种特定的难”。
软件工程是复杂的,这是毋庸置疑的。我指的是复杂而不是繁琐。作为一个初学者,面对这个充满复杂性的新世界,可能会感到非常畏惧。要编写代码,更不用说好的代码,需要了解和理解无数的事情。但是这种复杂性是计算机工作方式以及我们跟它们交互方式的内在属性,它可能而且大部分已经被层层的抽象所隐藏。
对于一个希望尽可能理解和掌握我们技术系统的初学者来说,了解底层细节至关重要。
Rust的独特之处
Rust在寻找这种平衡方面做得非常出色。它并没有完全隐藏软件固有的复杂性,但不同于C语言把你扔进新的丛林、只给你一根尖木棍作为唯一的工具,Rust则用大量优秀的文档、工具、编译器信息和库来包装这个现实。
Rust还提供了安全规则,并无情地强制执行,迫使你遵守,或者至少承认你没有遵守。有时候它很难甚至令人恼火,就像你最喜欢的动漫中严厉的师傅,强迫主角一次次训练。
与C和C++不一样,如果你的意图不应该实现,Rust的借用检查器就不会让你如愿。它也不会像Python和JS那样牵着你的手、假装一切都很容易。不,Rust会让你学习、让你理解、让你验证,并在你不够格的时候提醒你。
真相会让你自由,但首先它会让你痛苦——这是詹姆斯·加菲尔德讲的。
个人经验总结
那么Rust是一个好的选择吗?是的。
在用Rust开始我的编程之旅一年多以后,我一点也不后悔。我通过Rust学到了很多,从我的技能水平来看,甚至看不到这门语言的局限性。我用出色的Ratatui库构建了一个很棒的终端游戏Shared。我对编程的热情随着时间和经验的增长而不断增加,扩展到一般的开发领域,最近还包括算法。我发现无论在知识的广度还是深度上,我都跟大多数同行不相上下。我在线上和现实生活中找到了一个非常支持和鼓舞人心的导师社区。
挑战与不足
但是并非所有的事情都是阳光和彩虹。
独自学习编程有时候会感到孤独,特别是在我鼓起勇气跟Rust在线社区建立联系之前。Rust社区的绝大多数成员都是经验丰富的工程师,他们通常在Rust和各种其他语言方面都拥有非常高的专业知识。这并不是说他们有任何形式的排他性,相反他们中的大多数人都非常友善和热情,但当你还在学走路的时候,身处一群马拉松跑者之中,可能会让你感到望而生畏。
针对真正初学者的Rust资源相对比较少。公平地讲,Rust团队和社区非常重视学习资源,并且已经采取很多措施来确保文档丰富、易于访问而且统一。不幸的是,到目前为止,完全的初学者可能只占用户群中很小的一部分,不足以优先考虑。我确实很欣赏Will Crichton在”Rust for Air”网方面投入的思考和努力,以及Carver、CF、Forest Matthenic为优秀的Rust Book编写入门指南所做的努力。我正尝试跟作者直接为这个项目做出贡献。
Rust的工作机会仍然相对比较少,而且分散。其中大多数都在寻找”拥有30年经验的年轻天才高级工程师”。尽管我很快就在终端和一般的编码逻辑方面变得熟练,但我的Rust经验在前端工作方面提供的机会很少。除了设计之外,我认为社区的倾向有点偏向后端和系统设计。在不久的将来,我需要通向”蓝色药丸”,来补充我的Rust和Dextrous经验。
结语
最终,Rust开启我的编程之旅,可能已经让我对大多数其他语言都提不起兴趣了。我最终会涉猎其他的,但你知道人们怎么形容他的初恋吗?它往往是源于欲望和缺乏经验的空洞迷恋。这是一个小比喻,就是可以看到有些初学者把Rust作为第一门语言。
实测对比:Go/Rust vs Python 的运行速度,如何精准抓住 Python 的优势?
记住:没有 “最慢的语言”,只有 “用错地方的工具”。理解 Python 的优势边界,比盲目追求速度更重要。
优先选 Python:若项目更关注开发速度、生态丰富度、灵活性,且性能要求 “够用即可”(如业务逻辑层、数据处理层)。
优先选 Go/Rust:若项目需极致性能、内存安全、高并发(如底层基建、性能敏感型服务)。
终极方案:用 Python 做 “大脑”,Go/Rust 做 “肌肉”,通过混合编程实现 “效率与性能双优”。
Python 的 “慢” 不是绝对性能差,而是在特定场景(CPU 密集型、单线程)下的相对表现。其设计哲学是 “让开发者更快产出价值”,而非 “榨干最后 1% 的硬件性能”。如果你的项目需要极致性能,选择编译型语言;但如果需要快速迭代、拥抱生态,Python 仍是性价比之王。
Python 的 “性能短板” 本质是其设计哲学的副产品 ——用执行效率换取开发效率与易用性。
这使其成为数据分析、机器学习、Web 开发等领域的首选语言,而非系统级高性能计算的工具。
若项目需极致性能,优先选择 C/C++、Rust、Go 等编译型语言;
若追求开发效率与生态成熟度,Python 仍是最优解,性能问题可通过 “混合编程” 或工具链优化解决。
编程语言没有绝对的 “好坏”,只有 “适合” 与 “不适合”。理解 Python 的定位与边界,才能更高效地发挥其优势。
各位网友大家早上好,我是小安迪,今天给大家评测一下Python、Go、Rust、Deno这几种语言,同样做一个API后台服务器,我们看一下它的性能方面的一些表现。
测试环境介绍
首先我对这个代码做一些介绍。这个server.py文件,我是用了一个FastAPI做了一个构建的后台API服务。通过这个API服务,我们对它的性能进行一下压测。
这是一个客户端,使用aiohttp这是一个异步的HTTP库,通过这个aiohttp来对服务器做了一个压力测试。这里还有一些阻塞式的和多线程的,它的性能肯定是比较差的,肯定是没有异步的快,所以我这里就不对这两个性能做一些测试,我主要是考察了这个异步的测试。
Python测试结果
首先我们将这个服务器给运行起来。服务器已经运行起来之后,我们通过模拟一个客户端来对它进行一个测试,我们对它进行一些压力的测试。我们来看一下这个时间是多少,大家稍等一下。我们可以看到这个时间是14秒多,单位是秒,是14秒多。这是一个Python的一个数据,14秒多我们可以再将它记录下来。
Go语言测试结果
这个时候我们再来看一下Go语言的。我们来看一下这个Go是怎么写的,我们Go语言是通过另外一个框架叫Fiber写的,然后这里我们使用的是Go Routine,也就是Go的那个协程。我们先来测试一下它的性能,我们先把这个服务给跑起来。这个时候Fiber这个服务已经启动了,然后这个服务我没有对它做一些任何的优化,就是比较粗糙的。
然后我们再来对它进行一个测试。我们可以看一下这个时间,它是只是花费了两秒多钟,我们可以在这里记录一下Go语言的时间。
Rust测试结果
我们再来看一下Rust。我们这样子来运行一下,看一下我们Rust它是使用了2.5秒钟的时间。Rust它是采用了一个Web框架,它采用的框架是Actix。这个我用的比较少,这段代码的话也没有做过多的去优化,也没有采用什么其他的什么Tokio这些异步框架什么的,比较复杂。我这个只是做了一些初步的,就是直接做了一个框架方面的一个比较粗糙的,没有做一些过多的优化。但是我们可以看它的时间,它是用了大概是2.5秒的时间,这个时间基本上是和Go语言是差不多的。
Deno测试结果
最后一个还有一个Deno,我们看一下Deno它的表现怎么样。Deno服务器已经跑起来了,让我们来测试一下。这里好像出错了,刚才那个启动服务器有点小问题,我们来看一下,重新再把这个Deno的服务器启动一下,这样子启动好了。然后我们再来测试一下,我们可以看到它花费的时间是2.53,我们这里记录一下。
测试结论
我们可以看一下除了Python之外,其他的几个比较主流的语言,比如说Go、Rust和Deno,它的性能响应的时间,花费的时间具体来说都大致上差不多。这个程序我没有做具体的细化去优化,就是比较粗糙的一个评测,但是它们来说的话是比Python是要快很多的,比Python这个14秒的话是要快很多。
还有一个就是我要告诉大家的一个结论,就是虽然它们的响应时间大致上来说都差不多,但是它们我发现一个问题,就是说它们那个吞吐量可能会有一些差异性。
这里首先说结论就是Deno,它的那个吞吐量是最低的,你看在这里是107.78或者112.56。因为这个Deno它是运行在Linux下面的,我不知道跟这个有没有关系,运行在Windows上面会不会好一点,不太清楚。
其次就是那个Go,Go的吞吐量它是在Rust和Deno之间的,Go的话它是比较均衡一点。吞吐量最高的话是Rust,吞吐量最高的是Rust。
实际应用建议
所以这是告诉大家一个结论,就是我们在做产品的时候,或者是做软件方案的时候,我们首先考虑的是产品原型。在产品原型我们要快速构建产品原型的时候,我们可以采用Python来做。但是我们也必须清楚,Python的性能都是比较拉垮的,Python的性能是比较拉垮的,这个性能的话是比较差的。
在生产条件下的话,我们有条件的话,还是尽量将这个Python的代码重构成Go、Rust这两个或者是Deno,这个具体要看大家的需求了,也要看这个生态了,也要看大家对这几种语言的掌握的能力了。
另外还有其他一些Web的一些框架,比如说Node.js,Node.js的性能其实比Python好那么一点点,但是跟Go、Rust和Deno来比起来的话,也会差很多。Java的话也是在这个梯队里面的,性能方面各方面也是比较好的。
好吧,那今天就跟大家分享到这里。这个代码只会给大家做一个参考,如果有什么问题的话,可以评论区留言告诉我,喜欢我的视频的话,可以订阅我。好了,今天就分享到这里,再见!
Rust 发行版调研及质量保障支撑平台介绍
各位老师下午好,我简单介绍一下我们在Rust发行版的调研。今天其实有好多位老师都提到了Rust发行版这个事。
Ferrocene 发行版介绍
其实现在社区里面,Rust发行版只有这一个Ferrocene这个工具的发行版。它是由Ferrous Systems这个公司开发的一个Rust编译器的分发版,release出来给大家用的。
它通过了ISO 26262和IEC 61508等认证,主要是解决了工控、医疗和汽车这三个领域的研发规范要求。
Ferrocene 的主要特点
首先它并不是一个全新的工具链,它主要是基于几个重要的Rust版本进行修改和定制的。它的升级一直是追Rustc的最主要的那几个版本或者最核心的那几个版本,它不像Rustc一样每隔几周就会更新一个版本出来,它相对稳定。
它的主页上列了一些Rust的专家,大概四五个人,可以提供一些付费的服务。服务里面最重要的一项就是他会及时回你邮件——这个不是我跟大家开玩笑,他写到服务里面的最重要的一项就是你发邮件他会及时回复你。
ISO 26262 认证要求
目前在汽车、医疗和工业制造行业,Ferrocene已经都通过了他们的规约和规范的认证。其中我这里以ISO 26262作为一个例子来讲,它是通过ASIL D级别的认证,这是ISO 26262里面的最高风险级别的认证。
Ferrocene 为了通过认证做了什么
这个其实是我们需要关注的。比如说如果我们要建一个面向我们中国的一个发行版,或者说面向我们国家的某一些特殊领域,或者国防建设要用的一个发行版,那么我们要做什么事?
首先第一个就是工具鉴定还有它的要求。这是ISO 26262里面的要求,我把这个文档下载来看了。ISO 26262首先它是一个国际标准,这个国际标准里面有十几个部分,其中4~7部分谈到了硬件软件的开发,然后第八部分是涉及到的工具。编译器(Compiler)这个东西,它明确的在这个26262里面是定义为一个工具,所以对它的约束是写在第八部分。
它对整个汽车的安全等级分为了若干级,其中最低的就是ASIL A,然后最高级的就是ASIL D。在这个里面对ASIL D,对工具链、对编译器的要求,就是要说你要对整个的软件的整个生命周期和它的文档、它的bug以及它的编码规范等等,全部还有它的测试验证等等,全都有要求。
也就是说如果我们要做一个发行版出来,面向26262进行一个验证的话,它要求这个编译器自身是要通过了一系列的验证或者测试,有这样的一个约束。
补充一下,就是目前正常里面很多东西都是用C写的,C里面其实GCC这些它们是有自身的通过这个认证的版本。
工具鉴定的具体要求
它的工具鉴定要求验证流程、文档的可追溯性、测试的完备性。文档的可追溯性就很明确的要求了,比如说我们的这个编译器提了一个bug,我们要有能够明确的定位到这个bug它是怎么提的,它的trigger case是什么,然后我们又是怎么来把它修复的,修改了哪些代码,然后在哪一个版本上修复了,然后它的回归测试又是什么样的,它要求这么一个完备的定义。
这一点说实话,现在的Rustc的官方社区没有做到如此完备。基本上Rust官方社区对这个test、对它提的一些issue的一些回归测试,其实还有待提高。当然这个后面我也会讲它的测试,在Rust官方社区测试这方面它的实践是怎么做的。
确定性行为要求
ISO 26262的要求就要求有确定性行为,就是我们的这个编译器它所有的行为都是确定的。这就有一个比较尴尬的事情,就是可能大家都知道我们Rust这个语言还没有一个官方release的Language Specification。
Ferrocene这个公司就写了一个草稿,其实现在叫草稿,实际上已经被社区拿过去,就是说这个草稿在迭代几版就成为一个官方的Language规范发布出来。在Language的这个Specification里面,它就约束了这个语言在不同的场景下它应该做什么事。有了这个东西,基本上就定义了这个语言当中的所谓的行为都是确定的,因为你已经约束好了。
这个其实是符合一个程序设计语言成长的一个流程的。我们程序设计语言每一个版本迭代,它都会有一个官方的Spec更新出来。你的编译器必须要把这个官方的这个约束Spec定义实现了,你这个编译器才能说是执行了这个语言的版本,它一定是这样的一个逻辑。
所以包括现在我们的Rust这个语言,虽然说出了这么多版,它没有一个这种官方的release的语言规范出来。Ferrocene就做了一个草稿出来,然后这个草稿现在基本上据我所知是被社区接受,并且在评审这个东西,可能近期会有一个这样的一个官方文档出来。这个东西出来之后,我们就有了一个相对确定的约定。
测试覆盖要求
它要求这个对编译器的测试,它的测试覆盖要达到一个什么样的程度,还有它的各种各样的对编译器的验证等等。还有它的第三方的工具链,比如说我们的Rust它基于LLVM,还有它的调试器,还有链接器这些东西的一个测试和鉴定,它都要有一定的完备性。
其实这个地方可以简单说一下,就是不管是学术界还是工业界,其实对我们的debugger,像我们的调试器这些东西的testing和验证,其实是一个长期忽略的问题。国内外做这个debugger的,据我所知就好像两三个团队在做对debugger的testing的这个工作,好像就两三个团队在做。其实这一块是有一些缺失的。
Ferrocene 的测试实践
Ferrocene这个公司其实公开了绝大部分的测试用例,以及这个测试用例在不同的Rust版本上运行的这个测试报告。这个我是专门去看了,去读了它的GitHub,就能够找到它的一些代码的test case。
然后我也去社区的官方版本上看了,就是Ferrocene那个公司搞的这些test case,很多是被官方release出来一段时间之后就被官方接受了,拿到他们的官方的test suite里面。我去核对了大部分,然后有一些没有找到,所以我说我不能确定每一个都公开了,但是我认为它的大部分测试用例是公开了,就在Ferrocene这个组织下的Ferrocene这个repo里面。
编译器测试技术的现状
它的testing的这个过程,其实是明显落后于当前技术的。对编译器测试,其实在这几年,其实有很多先进的技术发展出来。
其实最先进的技术应该是最前沿的这个课题被提起来,应该是2014年的时候,苏黎世那边的孙文老师,他提了一个EMI(Equivalent Modular Input),就是等效模块输入的那个技术。那个技术是引起了编译器测试这套质量保障的一大批课题,后面有茫茫多的人去follow up这个工作。
这套工作其实专门针对编译器的测试的一系列的工作。但是很遗憾这一系列的工作,绝大多数研究者,85%以上的研究者关注的是GCC——我说的是靠谱的统计,放心,因为我也做这个研究,我也发了相关的文章,我也读了很多paper。大多数人是做的GCC和Clang,对这方面的testing,做Rustc的几乎没有,很少很少,大概只有几篇文章是在看Rustc testing版本,Rustc testing的相对要少很多。
所以我们先不讨论学术创新和发paper的事,因为我是老师,我们先不讨论这个东西。如果有我们这个组织,或者是我们中国的社区愿意有一些人来做这个事,其实这也是很美好的。因为他们那些paper发到了C和C++的那个topic,我们不讨论技术的创新,我们把它拿过来面向Rust去做一套,做一套这样的工具,它也是一个很好的东西。
现在官方社区用的还是那个libFuzzer那一套,那一套说实话是落后很多的一套,就是很经典的一套东西,并没有一个很前沿的创新在里面。它有RustSmith那些东西,但那些东西跟前沿的这东西还是有一定距离的。
Rust 错误提示的优缺点
还有一个点就是今天早上讲到的Rust Health。其实挺有意思的,我们团队也在做这个事,但它不是今天讨论的主题。就是说Rust的那个提示信息出错。那个东西我在上课的时候给学生们讲课的时候,学生们很重视,他们很喜欢Rust的那个编译器提的错误提示。他们说Rust的编译器错误提示是所有编译器里面最优秀的,它不仅告诉你哪错了,很准确的告诉你哪错了,还会很准确的告诉你为什么错了,还会很准确的告诉你怎么修。
这个对于代码量很小的那种小程序,一两百行那种小程序,非常精确。但今天听报告,有一个很重要的老师跟我说了,400行以上,Rust Health就比较容易出错。我赶紧跟我的团队说,我说去把400行以上的搞来做实验看看,这个是一个很好的事。
还有一个就是Rust的优化,这个目前在做的testing的技术是比较少的。
形式化验证的现状
形式化验证这个主要是有paper说了Rust的所有权模型的形式化验证,但是Rust compiler的形式化验证是没有的。这两者的区别是Rust的模型,它就等于是它的Spec,这个是确实形式化验证它是完备的。但是Rust的compiler,就是对这套模型的代码实现有没有做形式化验证,这个还不是很完备。
所有权模型指Rust Belt吗?就是这个所有权模型的形式化证明,就是有paper专门讨论Rust里面的那个ownership那套模型,是Rust Belt那块。但它并不是一个子集,只是个简化版的。但就是说这种简化版的证明,能认证能认。我只是告诉他们它做了什么事。
TCL 级别说明
刚才今天有老师都提到了,它是有一个TCL 1、2、3。这个TCL 1我补充一下,就是如果我们的这个编译器它只是用于生成这个车载上面的那个软件系统的测试代码,并不是直接运行在车子上面的那些代码,它的那个要求就没有那么高,所以这个是有一个区别的,就是三级。
Ferrocene 的文档和资源
Ferrocene的官方的一些文档,我们可以看到各种各样的文档Long Documentation,这个是它的一个很重要的一个贡献。它还有那个编译器功能和ISO 26262的这个追溯矩阵,它说它有,但是我没有找到。然后这边是test suite的链接,我全部都点开看了,大家都是有货的,很多东西基本上都是开源了的,然后测试报告也是开源了的。我没有一个去一个一个去核对,没有每个去核对。
(关于测试覆盖的讨论)
这个test suite是面向API的coverage还是面向编译器的code coverage?编译器的code coverage。编译器其实最重要是能执行,还有LLVM,它和LLVM也做同样事情吗?没有。它是说Rust部分它搞的。那后端怎么办呢?后端不验?LLVM有人专门放后端出了很多的bug,就是苏老师他们那一系列工作就干了很多bug,就在Clang上面干了很多。
你看完全部都是那个library的这些东西testing,它没有LLVM相关的。我的理解对不对,就是说它保证它生成到LLVM IR这块地方,LLVM它没管,当然这样。但是我记得当时我们说ISO 26262,原来是那个OS,那些库它全都验证它才叫库。
这其实也是一个问题。按照它这个整个编写,你可以看到它,请看右边的那个截图,那截图上面很清楚的写了,别说LLVM了,它连STD(标准库)它都没有验证。这不是我写的,是我去官网截的图。
它的公司认证是谁?它的公司是TÜV,难得它在德国莱茵TÜV认证。这不是我说的,是它官网自己就说了,我们不管STD了,那STD它都没干,它不可能去改LLVM的,都没这么干的。
确定性保证
它做了一些确定性的保证,就是把一些非确定性优化的那些优化选项给禁用了,然后把这些哈希种子给固定了。它还搞了一个一堆lint,这个lint是就是Clippy的那个约束工具规则集。
第三方的安全分析,这个它并没有去验证,它只是提了一些那种修复或者调用的一些审计,它没有做全部LLVM的那个审计,大概是这样。
然后有一些开源报告这些,这个我没有找到,不知道是不是付费之后可以给我们share一下。
然后这是最新的版本,这个版本是最新的版本,对应于1.83,应该是针对的那个2024 edition那个版本出的。这就是它们没有验证的一个事。
质量保障支撑平台介绍
后面就是我们的一个平台。我们也做了一个类似于给Rust的Crates.io做那个分析测试的一些平台。
平台目标
这个平台主要是面向Rust的生态社区的。因为我们已经把Crates.io整个给弄到了本地,然后有几十万个creates。然后我们现在研发了一些测试技术,对Crates.io里面的所有的crate进行那个test case的生成。生成完了之后,然后我们来进行代码的测试和它的扫描。
这件事其实我们在做的过程中发现了一个很有意思的点,就是我们去社区提问,有没有能够对生态系统整个生态系统安全、供应链安全的dependency graph生成的一个工具或者技术。社区的那个人说,他们知道有很多这种dependency graph生成的工具,但是没有一个工具能够做到生态系统级的,没有符合他们要求。他们要求我们去做这么一个东西,那这个东西我们目前在研发过程中。
其实我们的Crates Pro,它的目的是这样的,就是希望能够比如说某一天某一个crate上的某一个版本出了一个CVE,我们能够在最短的时间内告诉所有基于这个版本的、基于这个东西的人,你的哪行代码哪个地方可能出现安全问题。这是我们做供应链安全分析的一个最重要的一个课题。
平台功能
这个Crates Pro它既兼容Crates.io上的东西,也允许用户自己给我一个GitHub的链接,然后我就根据你GitHub链接传的那个source code去搞这个事。也可以给你生成测试用例。
这个生成用例,大家可能比较好奇是怎么搞的。两年之前在大模型没出来之前,我们是用的经典的那种基于模板那种生成方法。现在我们是基于大模型去生成。它有一个很好的一个点,就是你给我一个crate,我可以在5分钟——其实说的比较长,如果你crate比较大的话,像rand这样的crate,就是我们经常用到的repo里面写随机数那个rand那个crate,我们可以在十秒钟之内给你生成一个代码覆盖率达到95%以上的test suite。
这是我们目前测试的,我是在现场机上生成过的。但是现在的一个缺点就是我们生成这个crate和这个test suite,它没有oracle信息,就我不知道——它只有覆盖,就是你这样代码我确保你不panic,但是我现在还没有一个oracle信息。
下一步就是再想办法用大模型来搞这个oracle的信息的事。希望这个东西能够更快的在高覆盖的情况下,并且能够更快的帮助Rust开发者检测问题。
这个东西我觉得搞出来之后应该是有一定意义的,就是如果在大家愿意自己付费调大模型的API的情况下,把key填一填的情况下,它应该可以在极短的时间内帮助我们写的crate生成那个测试用例。
技术架构
我们整个架构就是有前端有后端,也是用了我们的TuGraph,就是那个蚂蚁那边开发了一个图数据库。我们因为我们本来就是算dependency的问题。
后端就是我们有CircleCI,有TuGraph,然后还有一些前端的东西。这些细节我们就不再去,后端的设置就不再去讲了。这个存储是TuGraph的一个特点,TuGraph性能很高,如果大家有兴趣的话也可以去用一下。它的性能和效果是我们见过的图数据库里面最好的。TuGraph也是用Rust写的,所以原生软件,底层存储。
平台界面和功能展示
这是我们的一个小界面,当然这个平台我们暂时这只是暂时的界面,这个界面是我们自己设计的,后面会改。后面会随着我们中国社区的那个开放,会对这个界面进行一个大规模的修改。应该很快就会公开了吧,反正我们这个平台就是随时都能随时上线。
然后就是我们一些界面,就是搜索你可以找到全部的crate,然后还有它的安全的问题。我们和CVE的官方网站的信息也连通了,安全信息,然后还有它的dependency的数量。你可以快速的找到,其实这就是构建了一个中国版的那个Java Maven的一个生态,但是我们这个生态是可以对安全性进行分析的,Java Maven的生态是没有这个功能的。
这个就是国外用也没关系的,就是叫它国际版也可以。反正我们将来找开放原子要资源,就是主要是服务器。现在开放原子那边服务器没搞定,然后就是服务器什么都没到,所以就没弄这东西。现在南大在南大机房里。
我想说的是,其实将来谁更谁,我们其实都不想跟Rust基金会,其实有很好的联系。其实我们也想跟它去谈,因为它的Crates.io里面其实现在有很多问题,包括也有投毒的,现在投毒类的也有。所以它里边我们也希望说,给它一个更全面的分析,能够去提升第三方库质量,这个其实对于大家开发的人也很OK。
这东西目前是放在南大的,然后它的数据量很大,好像是几十个TB吧,大概因为你把那个crate全都拿下来了,然后你要对它进行代码生成,然后形成一个报告,是这样的一个问题。
可视化分析
这是它生成的那种dependency的一些图,当然这个图截得不好。它里面的不同的颜色的节点表示了它的危险性等等,这是一个dependency的那种图。无漏洞是绿色的,漏洞多的风险为红色,所以你一眼就可以看出来它的威胁性。
然后这是我们的一些版本的信息,大概是这样的。这个漏洞是CVE,CV一直连通了CVE。当然你个人可以登录,你开发者可以把自己的GitHub的link传过来,我给你生成测试用例和对应的扫描的一些结果。
我们后面也会去集成一些现代的一些测试工具进去。但是我们今天遇到的一些问题,就是比如说Miri这个工具,现在它的在新的版本的Rust上用就很困难,基本上跑不了。大概这样的,我们不用花钱提供支持。
插件系统
后面就是一些插件吧,因为我们这个做了一些插件,就是大家可以研发一些新的技术出来,然后我们把这些checker、testing的这些工具做成一个个插件放到我们这个服务里面,就是以后大家上传一些crate,我们这些插件就可以去跑。
这是我们自己研发的一个小插件,就是也是生成test case的。这个我们就不去细讲了。它有的时候能找到一些稀奇古怪的这种bug,但这种bug就是你必须要去用这种插件去找,然后才能找到它那个上下文调用相关的这种bug。这个我就不再去细讲了。
好,谢谢大家!
轻量级 Rust 程序分析和验证
非常感谢陈老师的邀请,其实我这个报告之前很多内容可能刘老师都听过,那今天结合今天这个场合,我又做了一些新的PPT,然后结合大家报告,我对这个问题有一些新的思考。所以今天报告的题目是轻量级Rust程序分析和验证。
背景:Linus 与 Rust 的争议
这个是我新加的,这是过去2022年的一个问题。当时这个操作系统引入Rust,这个时候就是很大的一个争议。MADSEN他就是非常坚信Rust可以解决这个问题,然后初生牛犊不怕虎在社区里边,在这个邮件里面提。
但是Linus他就是非常不能认同,他就说这个是Reality vs Fantasy,就是没有这个Silver Bullet。你要么就是如果要从事Kernel的Programming,一定要坚持Kernel的这些Environment还有它的安排,要么你就别干。所以当时这个争议其实还蛮严重的。
关于 Unsafe 的讨论层次
但上午我中午又跟王磊老师,又跟几个老师大家都聊了这个Unsafe的问题。其实我们这个叫轻量级,我想了一下,其实我们讨论的Unsafe其实不是一回事。
关于Unsafe和这个Soundness,我们是做的这个Unsafe的一个代码的验证,但是其实我们做更多的是核心的这个语言级的验证。这语言级它其实很多通用的东西,它有一个很完美的一个假设。
比如说最简单的假设,就是说你不能有一个超级的Process去修改我当前Process Memory,那否则一定会造成问题的。所以这个是有一个很完美的假设,其实这就是两个层面了。
另外一个层面就是,其实刚才大家讨论的更多的就是其实实现的这个正确性。你去实现一个操作系统,包括实现HYPERVISOR里面有很多Unsafe,其实这个很多的其实是实现的正确性的问题。只有实现正确,才能为上层提供这个正确的内存管理啊这些功能。
其实我在想这个可能包括这个Ferrocene,大家用Miri其实做验证,让很多操作系统做这个形式化的验证,其实都是做的功能性的验证这方面。
甚至之前跟田博在交流,这个可能还有一层就是关于操作系统它自身的权限管理,就是你的用户跟等级,可能还有第四层就是你实现正确了,你还要有一个自身的感知。但其实我今天分享的这个轻量级,其实更多的是关注中间的语言层,从这个层面上去讨论的。
所以我的报告基本上从这三个角度,我尽量的讲快一点,就是留给大家更多的时间来交流和讨论。
第一部分:Rust 面临的关键挑战
第一个方面是Rust面临的关键挑战。我们研究的这个问题,就是我们研究Rust,我们课题组是从2019年下半年开始研究Rust。我们就是研究Rust首先要分析这个Rust它的特点,就是说他为什么能成功,他为什么有前景。
Rust 的核心优势
从这个理论层面,我们认为它是语言设计赋能编译器缺陷检测能力的一个尝试。我们都知道这个静态分析其实很多是NP-Undeterministic的问题,大家都会把它归约到就是一个难题。但具体为什么是难题,其实里边有各种各样的难题,比如说指针分析问题是一个难题。
Rust这两个关键的设计其实是尝试去规避了这两类难题:
1. 所有权规避复杂的指针分析问题
他就是打破了这个——他通过这种唯一可变引用打破了。比如说1997年的时候,有一篇paper证明Alias分析是一个哈密尔顿回路的问题,所以他是一个NP-hard的问题。那所有权可以完美的去把这样的一类困难问题给它避免掉,所以这是其中的一个,他简化了指针分析问题的难度。
2. 分治的思想
第二个就是说他的这种分治的思想,具体体现在两个层面:
- 一个是安全域的思想,包括Safe和Unsafe
- 另外一个就是它的生命周期标注
这样一个思想其实都是通过分治的思想来实现基于这个摘要的过程间分析。你不用对函数的caller code做这种context-sensitive的这种分析,而只要做这个context-insensitive的这种分析就好了。
下面给一个最简单的一个例子,就是我有两个参数,两个string,然后我要返回值是一个长的string。但是两个string它lifetime是不一样的,这里是A大于B的。那显然编译器只要看当前这个函数,我就认为你是unsound的。我只要看这个函数,不用看你是怎么用的,所以就看一个函数就OK。这就是这个基于摘要的过程间分析的一个魅力。
Rust是这样做的,所以我们认为很有前景,所以就过去几年一直在做这样一个事。
面临的关键问题
但他面临的一个关键问题,就是说这套思想它实现的是不是彻底,是不是正确。现在面临的关键的问题:
1. 编译器的Soundness问题
左边这个例子都可以在那个OOPSLA去年的这个paper中去找到,就是说我把一个任意lifetime的东西,通过这种Trait这种Type Parameter的一个组合,把它变成一个static的,就是一个有限的lifetime的一块内存把它变成static。
右边这个例子就是我去open一个file,这个file是一个proc memory,所以我就可以改变其他进程的内存,那显然会造成内存安全问题。但是这个文件的抽象它又都是safe的。所以这个其实是我们前面那个圈里面外边那一层的东西,不是属于语言级的。
但是如果这个界限划不清的话,大家可能就会有这样的concern,就是你Rust其实你看什么问题都解决不了,就有很多这样的难题。
关于 Soundness 这个词的理解
除了编译器的这个Soundness,这里我想强调一点,就是Soundness这个词,其实我读博士的时候,我老板是做可靠性的。当时可靠性还有另外一个词叫Robustness,大家都翻译成鲁棒性,其实这个鲁棒性也挺搞笑的一个词。
那现在又出来一个新的词叫Soundness,你如果去查那个大模型或者是百度搜索各种搜索,大家都给你回的是健全性,要么就可靠性,其实没有一个词。但是我觉得这个词真正对的一个中文词性应该是严谨性。
它为什么叫严谨性呢?就是不管你怎么调用,都不会有UB,所以一定是要严谨。所以这个我想澄清一下我对这个词的看法,要不然其实健全这个词,就是你四肢不健全,你少了什么,其实这个很容易有歧义的。我想就是做这样一个澄清。
2. API Soundness 问题
第二个问题就是API Soundness,这个问题其实是比Compiler Soundness相比起来一点都不简单的一个问题。
这里有一个例子,就是因为Rust类型系统太复杂了,比如这个参数是一个generic的参数,里边是把这个参数这个slice把它转成一个u32,后边调用一个unsafe的函数。
但Rust很多人把这个unsafe封装成safe,叫Interior Unsafe,来实现这个安全封装的一个概念。但是这个封装其实想封装的严谨是非常困难的一个事。
因为像这个例子,只有在你传的这个参数,它的alignment的要求要小于32 bit的时候才会出问题。比如说我这里传一个u8的slice会有问题,但是你如果一般大家用,可能默认就认为你这个就是用的u32或u64的slice,那就不会有问题。所以就是说这个问题还是一个比较简单的问题。
除了这样的函数单纯的一个函数封装,还有structure会有method、constructor,它怎么样组合来实现一个安全封装。甚至还有trait这种重载的实现安全封装,你想到当前trait实现是安全,但是我重载一下是不是还是能保证soundness,这个问题就太难了。我们还有另外一篇paper来实现来做这种tag的发展,去检测这种问题。
像刚才这个这种问题,如果是u8,你编译器执行是可以完全可以正确的执行的,但是你用Miri官方的run就会报错。因为他在使用这个unsafe函数from_raw_parts_mut的时候,其实没有遵循其中的——这是标准库中的一个函数,它其实有这样的要求的,就是”it must be properly aligned”,就是它的内存对齐要符合这样的一个对齐原则。
刚才我们把它转换成一个指向u32了,所以要求u32 byte的对齐,但你传一个u8显然是不符合的。但现在这个Rust的官方文档对于safety的要求普遍是这样文本类型的描述,其实这样文本类型的描述你去读的话,其实有很多非常含糊不清的一些东西。
所以这个也是我们在解决的一个问题,就是说把这些可能比较implicit的这种描述把它变成一个formal的描述。
Unsafe 开发不可避免
其实上午大家在讨论操作系统开发的时候,就是讨论了很多unsafe开发不可避免。这除了操作系统,还有很多就是因为不用unsafe限制了语言的表达能力,还有很多其他的功能其实也绕不开unsafe。
比如说就是说的最多的一个双向链表,双向链表前后必须要access中间这个node,并且要有至少有一个是可变的,那这个就是跟操作系统无关,那也是涉及到这样的unsafe。所以在Rust标准库里面有很多这样的封装。
Rust 标准库验证挑战
他的官方Rust Foundation就组织了这样一个标准库验证的一个挑战,其实他有这样14个challenge,然后每个challenge都会有对应的一个包子,大概率有1万美金左右。
我们也在尝试想,去年尝试想参加这个事儿,然后包括也跟前面的我们这个OS Checker的作者周继平我们也讨论过这个事,看看这个来分析这个可行性。
我们再讨论下来就发现其实要做这个验证,其实是——现在他官方推荐的工具是Kani——要做这个事其实有很多的困难。
第一最大的一个困难就是我们刚才说的,其实它每个unsafe它的安全需求其实都是没有定义清楚的,那你怎么verify他的这个实现的正确性。
其实我们认为前面还是有一步要做的,所以我们就暂缓了这个去参加这个挑战的一个想法。那我们就开启了另外一个新的坑,就是对标准库的unsafe的API安全标注,也就是我们的Tags Project。
做完这个事,我们在Rust基础上去验证其中的这个问题。
Tags 项目:形式化安全标注
这个标注是你们自己提的?对,这个我们在尝试用一个Specification Language来把它描述一下。
就是这是刚才的from_raw_parts的例子,它的前面有很多文本,我们就把文本用比较形式化一点的方法去描述下来。
比如这里是”data must be valid both read and write for”这么长的byte,然后我们就说这样的一块内存,你又读又写,它一定是被分配的(allocated),并且它可读一定是in-bounds,就是不会超出你这个object的界限。然后这个pointer一定是non-null,然后它下面还有一些细节,那都是可以有这样对应的描述的,比如说这个align等等,大概是这样。
Contract 不够用
具体这个怎么用呢?这些其实我们发现传统这些表示,其实传统一般大家认为这个就是一个contract的思想,但是contract是完全不够的。
我们在分析以后,就是他这个描述它比较松散,大部分是这种前置条件,就是requirement,就是你在调用的时候一定要满足这个条件。但是还有一些东西,它不是像我们霍尔逻辑你去验证的时候,它是这种post condition。
那它在这里其实它是一种我调用完这个API它有可能有一些危害,而不是说你调用完它以后一定要满足什么东西。
那我们就把这个弄成一个Hazard State。比如说这里的有一句描述,就是说倒数第二个那个bullet,”the memory referenced by the returned slice cannot be accessed by any other pointers”,那这个明显的就是说是一个Rust的一个now——而是一个exclusive mutability的这样一个思想。
所以我们就把它标记成一个alias,就表示这个data就是那个pointer它的参数,那有可能存在其他的alias还要对这个值进行一个修改。所以你的函数退出之前,如果你的函数要变成safe,你要一定要把其他alias给它消掉,来保证不会有这样的有害状态在这个API退出return以后。
所以这个是传统的合约是不够的,所以我们就对这样的——去年的时候陈宇老师,我当时做了一个报告,当时还不是很成熟,现在已经我觉得有六七成的成熟状态了。
然后我们总结了这五类的property,然后都是有描述的,每一个描述都是有对应的意思的。大部分意思,其中有80%的意思应该已经自洽了,还有一些只是我们用看起来是比较formal的,但其实里面有一些是不能自动化的一些property的描述,大概是这样一个情况。
发现的问题
我们在调研的时候,我们是开发一个工具做这样的分析的,去检查这样缺失、还有多余的情况,以及是不是consistent,发现了一些问题。
标注缺失的例子
比如说这个就是一个它的安全标准库文档描述缺失的一个例子,这个是一个from_raw_in的一个函数。它有两个参数,一个是指针,一个是——这是个Box,他要构造一个Box,就是在heap上的一个object。第二个参数是一个allocator,就是是哪个allocator分配的内存。
他下边的这个safety的描述缺了一条,就是这个raw pointer一定要是这个allocator分配,因为你可能有不同的allocator,导致你的这个一致性会出现问题。他少了这一句,所以我们就给他提的Pull Request,把这个加上来了。
像这个同样的Box::from_raw,它里面就有一个PTR,一个raw pointer,一个参数没有allocator。但他就所以没有加任何allocator的要求,但其实是不对的。因为它里边调用了from_raw_in,他传了一个global allocator,但是他safety的要求里边并没有要求这个allocator一定是global allocator分配的。那我们就检查出来了一个问题,所以给它加上了这样的一个描述。
标注多余的例子
还有一些多余的例子,就是他的函数定义里其实他接口没有,但他描述文档有。因为你看接口的时候不涉及到任何allocator参数的传递,但其实他传了一个这个global参数。
其实标注现在的这套标注方法,就是说很容易出这种错误的,然后程序员像刚才根本意识不到这种问题。
还有一些多余的,比如说他的zero size的这个对象,那null pointer是不是要考虑zero sized type这种,就是null是不是对于zero size object这种type是不是valid。
他这个最早认为是不valid,但是后来他认为reference是invalid,但是裸指针是valid。但是他这个因为是因为他在调整这样的,他慢慢的去形成他的一个更加准确的一个理解。但是他早期的版本其实还是比较模糊的,有这样一个不正确的说法。那我们就让他把这东西删掉。
第二部分:RustX 平台介绍
接下来再介绍我们的这个平台,我们做了一个叫RustX的平台。
工具对比
Miri是最强的,毫无疑问,刚才在OS Checker那里也是都提到了,Kani和Miri是最强的,并且他强主要是因为他报的东西基本上都是对的,不会有误报这种问题。
验证器有不同的东西,比如说Kani,它是基于符号执行这种思想,所以他会有很多的局限性。Verus要手动的去写证明。
那静态分析工具,我们都说静态分析是sound的,其实是不然。静态分析是什么,是对它——为什么它实际上什么是sound呢?它其实是sound的,就是说它面向——比如说对于use-after-free这样的一类检测,他的sound指的是我面向use-after-free其中的这个模式是sound,因为我就写代码就去检测这种模式。那你还有其他的模式,所以基本上大家对静态分析工具的传统意义上会有这样一个误解,那么就把这个误解澄清一下。
像这些工具其实都是在GitHub上去跑的,我就不再给大家演示了。
RustX 的分层设计
我们的这个工具其实是一个分块的,就是分层的一个思想。就是我们发了很多论文以后,我们会把论文里面的核心的这个指针分析、类型分析、数值分析的东西抽象成一些基础的模块,为上层来提供一些功能,比如说悬空指针检测、内存泄漏等等。
但这些东西也可以服务于其他的,比如说像安全验证的反面就是一个性能缺陷。你过度的——你本来已经安全了,但是你还用safe的代码,其实有可能会损失一些性能。所以这个反面就是一个性能缺陷的一个问题,就是用的恰到好处而不过度的检查。
因为我们过去——我们现在比较成熟的就是悬空指针检测、内存泄漏检测比较成熟。
悬空指针检测
悬空指针检测就是面向所有权的问题,因为他会——编译器会强制的去加注,所以这个有可能会造成这个dangling pointer,尤其在unsafe passing上这种问题很多。所以我们就有这样的路径敏感的指针分析算法来检测dangling pointer是不是被访问了。
内存泄漏检测
内存泄漏的检测,我们会检测每一个heap object是不是都有一个owner,然后因为这个owner要负责他的drop。如果没有的话,任意一个路径都没有owner的话,那我们认为就是泄漏了。
第三部分:Unsafe Audit 工具
接下来我们还是聚焦其中的unsafe,那是前期的工作。我们越做越发现这个unsafe其实才是关键,把它抽象清楚才比较关键。所以我们现在着重开发的是一个unsafe的audit的工具。
为什么需要 Audit
这个audit工具为什么重要?我们先看一下,就是因为大家写unsafe代码方式是各种各样的。
比如说我这两个structure其实它都是有一个raw pointer、有个length,这是一个slice的一个比较类似的一个构造。里面有两个函数,一个是from,一个constructor,还有一个get就是通过unsafe去读一个东西。
但其中ST1就是很中规中矩,from是safe,然后get因为有unsafe的调用并没有排除任何的unsafe和这个UB,所以是unsafe的,那这个是很合理的。
那第二个是unsafe constructor,但是里面没有做任何的raw pointer的操作,所以看来这个很异常。但是他可以把其他的——如果你有很多其他method,那method里面就有unsafe,那你都把它claim safe其实不会有问题。因为你只有先通过unsafe构造了constructor,才能通过method来调用。
这样的好处其实就是减少了unsafe的这个interface。你要audit interface,但其实后面这种是更成熟的一种方法。
但是这个说明了一个问题,就是大家到底应该怎么标,怎么declare一个函数是不是safe,尤其涉及到trait as structure的时候,其实是一个很复杂的一个事。
Audit 的思想
所以这个是需要audit,那我们现在就提出了一种audit的思想。就是给一个代码,然后我们把他的那个图画上,里边包括object flow、call graph等等,然后我们把它拆分成单个的审计单元。
单个的审计单元可以合并成一个最终的audit,每个audit都是有这样的一个formula,就是他一定要满足这样的一个formula。
其中formula的一部分是Required Safety Property,就是我们前面的那个标注。另外一部分是Verified Safety Property,就是你如果消掉了required,那就说明你可以声称为safe。
所以我们就做了这样的一系列的审计单元的一些分析,所以这块是我们现在在做的事。
基于签名的分析优势
我们现在的一个核心的方向,就是说怎么样把用这套思想去把之前的实现都统统给它替换掉,都统统重新实现一遍。
其实它本质上是把函数签名的思想发挥到更加极致,把Rust的这个思想发挥到更极致的地方,就是因为他是分治的思想。
比如说我们前面的这个检测悬空指针的这个方法其实是有缺陷的。比如说这个FnA去调F2,F2又调了一个函数,那我们检测这个调用链有没有悬空指针的问题、use-after-free的问题,其实我们是缓存每个函数的alias,最后检查在FnA里边有没有这样的问题。
但这个时候FnA可能它的使用是正确的,但是有一个FnB它是后我去后分析的。这个时候只会取F2的缓存,但其实F2是一个safe的函数,但其实你不应该把它declare成safe,因为你的实现是有问题。
但是FnA已经分析完了,它认为它没有问题,FnB基于他的分析结果也不会认为有什么问题,但其实就检测不出来bug。
但是如果基于这种签名的思想,我们就可以检测出来F2其实是有unsafe的问题。所以其实他比我们前面的那个SafeDrop还有RCanary的检测更能解决这种误报和漏报的问题,很方便。
实践中的挑战
其实很多人可能就是说会问我们的这个工具,我们有一些prototype,然后有一些test case都是可以检测的。
所以我们其实现在面临的一个痛点,就是说像刚才周继平同学也说到,一直在尝试着解决这个问题,就是说版本一升级,然后我们的工具其实就不能用了。
因为它的API甚至每个nightly版本的更新都有可能触及到那个API,导致我们取不到我们想要的东西了,都需要update。Update导致我们支持的case,过去支持的case现在又检测不出来了。所以我们过去一直在升级,很痛苦。
然后也非常感谢周希明同学,过去给我们提供了很多支持。刚才那个Pull Request可以看到,已经给我们有十多个Pull Request。他是那种一言不合就给你提Pull Request的人,我特别喜欢,不是提issue,而是提Pull Request,真的是帮我们解决问题的人,而且是非常有passion的这样的年轻人。所以我觉得大家应该多多关注我们周围这样的人。
所以非常感谢,他是给我们提了,比如说workflow,怎么样去做这种每次做回归测试,然后脚本去执行支持不同的版本这样的问题。
工具演示
我现在就演示一下,我们比如说我们的工具除了小的bug,然后比如也可以跑Ruxos,当然这个是基于他们20241012构建的一个版本上,其实是可以跑的。
就是这个kernel其实也有1万行代码,我们跑比如说跑kernel这样的一个project,其实可以跑的是10万——快10万。因为静态分析你就是自己写就行,你把它写的小一点,面向的pattern少一点,其实它就是可以很快的run起来,然后并且可以检测到一些问题。
那所以现在这个问题其实很多的问题是误报,所以我们现在也在研究一个概率程序分析的一个东西,就是把我们分析算法中间对哪些东西里面有approximation,里边最后报的这个错误给它加上一个概率。
但是我们其实像这个静态分析就OK,我们在那个内存泄漏检测的时候我们尝试用了一些Model Checking的东西,那就比较悲剧,就比较慢。甚至像这个检测内存泄漏,甚至不知道哪里中间会有这个stack overflow还是什么导致的,编译器中间就crash了,我们现在就Debug非常痛苦。
像这个是检测这个明显就慢很多,然后大概跑上5分钟10分钟,然后就——其实中间也会可以测出一些问题。可以看到这是一个明显的Box leak,这个因为操作系统可能去reserve这样的一个memory,所以他可能是有自己的需要,所以我们也会把它报出来。所以这个可能对他来说也是误报。
像刚才我们说的怎么样去标注、推荐那个安全的property,我们现在安全property主要是实现到tag这样一个层面,就是说每个tag其实里面还有参数。
我大概就讲到这里吧,好,谢谢大家!
提案导读: 逆天!!! rust连这些东西都没有, 还叫什么0成本抽象(上)
大家好,我是自然选择。美国时间2025年10月28日,Rust发布了2025 H2共42个规划提案。其中18个是之前就在推进的老项目,也有24个全新提案。
我把这42个提案分成了五大类:
- Cargo与构建系统
- 语言特性与人体工程学
- 编译器内部与性能
- 互操作性与平台生态规范
- 文档与测试
在这些新提案中有不少重磅内容,比如能大幅改善Rust应用迭代开发体验的Relink Don’t Rebuild、万众期待的静态反射和Pin人体工程学的改造等等。
一、Cargo与构建系统的三个新提案
1. Cargo构建元数据记录
你有没有遇到过编译就是莫名其妙的慢,但你完全不知道是哪个环节出了问题?目前Cargo只有一个临时的timing报告,这导致我们很难回溯和诊断构建问题。
这个提案要做的就是让Cargo把每次构建的详细原数据都记录储存起来。未来你就可以用一个新的cargo report去查询历史记录,问问他上次为什么这么慢,或者哪个crate触发的重编译最多。
这样一来,编译过程就不再是一个黑盒问题,能被看见才能针对性解决。
2. Target目录锁定优化
第二个问题,我相信很多人也都遇到过。Rust Analyzer正在后台检查代码,这时候你想在终端跑一个cargo build,结果提示你target的目录被锁定,只能等待。在大型项目中这个问题尤为明显。
之前我给Rustc提交代码的时候就经常发现这种情况,有时候懒得等就直接重启电脑,非常影响开发体验。
为什么会这样?这是因为目前Cargo会锁定整个target目录,一个进程在用,其他进程就只能干瞪眼。
这个提案要做的就是彻底重构target目录的内部结构,把每个编译单元的产物打包成更小的独立的单元。这样一来,Rust Analyzer检查他的,你编译你的,大家可以更好的并行工作,相互阻塞争夺写入锁的情况会大大减少。
从长远来看,这还能为未来的一些高级功能打下基础,比如用户级的共享缓存,或者自动清理旧的编译产物等等。
3. Relink Don’t Rebuild(最重磅提案)
第三个也是我认为这次Cargo相关提案里影响最大的一个。
现在哪怕只改一个注释,或者一个完全私有没有对外暴露的函数实现,这个crate和所有依赖它的下游crate全部都要重新编译一遍。这绝对是现在Rust的应用迭代体验差的一个重要原因。
而且根据Rust基金会2024年度调查,编译太慢就是开发者抱怨最多的问题。
这个提案要做的就是让Rustc和Cargo变聪明。如果编译器判断你的修改没有动到这个crate的公共API,也就是说对外部没有任何影响,那下游的crate就不需要rebuild,只执行成本更低的relink。
这个提案要是能落地,那日常开发中改一点小东西就要等半天的痛苦就能大大减缓了。我个人认为,这是2025 H2所有新提案里最有影响力的一个。
Cargo其他持续推进的工作
当然Cargo这边还有一些之前正在推进的工作也都在继续往前走:
Build-std - 提供一个稳定的官方方式,让你可以按需自定义编译标准库。这对于一些没有预编译库的目标平台,或者需要特定编译优化的场景非常有用。
Cargo Plumbing - 把Cargo从黑盒拆分成一系列模块化的指令,让其他工具能更灵活地与Cargo集成,不再需要绕路。
Cargo Script - 现在编写一个最简单的Rust程序,至少也得创建Cargo.toml和main.rs两个文件。这个提案就是要让Rust支持单文件脚本,像Python那样一个文件就能跑起来,写点自动化小脚本会方便很多。
Cargo SemVer Checks - 在cargo publish的时候自动检查语义化版本,防止你不小心在小版本更新里引入了破坏性的API变更。
二、语言特性和人体工程学
这部分有八个新提案,都是要啃一些Rust里长期存在的硬骨头。
1. 静态反射(Static Reflection)
像Serde这种序列化库,或者Bevy这样的游戏引擎,他们需要能处理用户自定义的五花八门的类型。现在主要靠derive宏或者开发者手动实现trait。
提案旨在提供一种只能在编译期运行的、基于const fn的静态反射系统。你可以编写一种特殊的const fn,然后在编译的时候就能用代码去检查一个类型的内部结构,比如它有哪些字段、枚举有哪些变体等等。
这会彻底改变像Serde、Bevy这类库的编写范式,它们将变得更通用简洁,也不再需要依赖那么多宏技巧。
2. Pin人体工程学(重磅提案)
Async Rust的性能很强,但理解他、用对他的心智负担太重,尤其是Pin相关的事情。这个提案要做的就是彻底改善Pin的使用体验,让他不再那么反人类。
核心想法是将Pin从一个库类型升级为语言的关键字,就叫pin,然后为他设计原生自然的语法。
具体来说有以下几点:
引入新的借用语法 &pin / &pin mut
设想一种新的引用类型,写作&pin T和&pin mut T。这种引用不仅像&mut T一样提供了独占访问权,还在类型系统层面就保证了它指向的数据是被pin住的。这使得Pin这个概念从一个包装类型变成了原生的借用模式,更加清晰和直观。
支持模式匹配(&pin pattern)
你可以直接对&pin进行模式匹配,就像处理普通的引用一样,代码逻辑会更加自然。
支持自动解引用和方法调用
目标是让你能直接在&pin上调用方法,编译器会自动处理借用,无需手动转换。
与现有代码无缝衔接
如果一个类型是Unpin,那么&pin mut可以被自动降级为一个普通的&mut。这意味着你可以将一个&pin mut i32直接传递给一个期望&mut i32的函数,无需任何像Pin::get_mut这样的显式转换。
如果这个提案能够落地,那么异步代码的门槛和痛苦程度就能大大降低。
3. 原地初始化(In-Place Initialization)
你写过这样的代码吗?目前Rust并不原生支持原地初始化。他会先尝试在栈上构建一个1MB的数组,然后再把它移动到堆上。如果栈不够大,程序就直接栈溢出了。
原地初始化就能解决这样的问题,允许值直接在它最终要待的内存位置上被构造出来,跳过中间那个栈拷贝步骤。
但这只是Rust没有原生原地初始化问题的冰山一角。原生的原地初始化将解锁Rust在多个领域的关键能力:
1. 避免栈溢出,安全地构造超大对象
2. C++互操作
C++的对象通常是不可随意移动的,它们有复杂的构造函数和析构函数,必须在特定的内存地址上被创建。要在Rust中安全的构建一个C++对象,你必须先分配好内存,然后调用C++的构造函数在那块内存上完成初始化。这正是原地初始化的核心。
3. 与C语言API高效交互
很多C API使用”out参数指针”的方式来返回值,调用者提供一个指针,函数在指针指向的内存里填充好一个结构体。原地初始化允许Rust代码安全自然的与这类API交互,直接在pin住的内存上完成初始化。这对于Rust for Linux项目至关重要。
4. 解锁 async fn in dyn Trait
这是目前语言的一大缺失。原地初始化是让动态分配的async函数成为可能的核心前提。因为async fn返回一个匿名的Future,其大小在编译期对dyn Trait的使用者是未知的。
唯一的解决方案是调用者提供一块内存,然后让dyn Trait的具体实现在调用者提供的内存上原地构造他的Future。
5. 安全的自引用类型
允许在构造函数中安全地创建指向自身字段的引用,为实现侵入式数据结构等高级模式铺平道路。
社区里其实已经有好几个库在做类似的事情了,比如pin-init、moveit,说明这个需求在社区中很强烈。
2025 H2的目标主要是先充分评估这些现有的探索方案,然后确定下来官方打算走哪一条路,为后面的正式实现定个方向。
可以说原地初始化是Rust解决一系列底层问题、打通能力瓶颈的一个基础性工作。
4. 自动重借用(Auto Reborrow)
现在只有&mut T能自动重借用,就是你把它当参数传递时,它不会被移动,而是自动为你重借用。但是像Option<&mut T>这种包装类型就不行,当你传递的时候他整个就被move走了。
但从语义上说,Option<&mut T>只是一个可能为空的&mut,他应该跟&mut有相似的语义才对。
为了绕过这个问题,开发者不得不手动调用as_mut或者as_deref_mut。这不仅代码冗长,更严重的是它会引入更严格的生命周期限制,使得一些复杂的API无法实现。
这个提案要把自动重借用这个行为从编译器内置的魔法变成一个公开的trait。以后只要类型实现了这个trait,他也能像原生的&mut一样自动重借用。
这意味着:
- 能简化代码,少写
as_mut - 对Pin人体工程学提案和Rust for Linux那里自定义的指针类型也很重要
- 它能让编译器中的重借用特例被优雅的通用机制所取代
- 也能解锁更强大的API设计
语言特性其他新提案
Delegation委托(顺带一提,在我写稿的时候这个提案已经被拒绝了,疑似没有存活超过24小时)
他本来是想通过简单的语法糖,将一个类型的trait实现自动委托给他的某个字段,从而减少newtype模式的模板代码。
Evolving Trait
允许在不破坏向后兼容性的情况下,为一个已有的trait添加新的super trait。这将解锁如为Derive添加super trait、Receiver等重要的标准库演进的能力。
Comprehensive Niche Checks
在调试模式下为NonNull、NonZeroUsize等带有无效值的类型添加运行时检查。这对于提升FFI和unsafe代码的健壮性至关重要。
语言特性持续推进的工作
字段投影
允许你直接通过Pin<&mut T>获得Pin<&mut U>,或者通过MaybeUninit<T>获得MaybeUninit<U>,让智能指针在访问内部字段时像Box一样无感。
人体工程学的引用计数
在2025 H1中已经实现了一个nightly可用的方案,解决大量Arc克隆的问题。社区的反馈比较复杂,大家都觉得很好,但对其易用性提出了质疑。所以H2的计划是继续探索其他的更自动化的方案,最好能不引入复用关键字就把事解决了,真正提高异步Rust的人体工程学。
Unsafe字段
目前Rust缺乏表达字段携带不变式的机制,也没法强制说修改某个字段时必须小心。比如Vector的len端永远不能超过capacity,这是一种不变式,但在当前的Rust中这种规则只存在于注释和程序员的脑中,编译器对此一无所知。
这意味着任何safe代码都可以修改这些字段,导致未定义行为的定时炸弹。这使审查代码的安全性变得困难,因为风险点散布在所有可能接触到这些字段的safe代码中。
提案允许你将某些字段标记为unsafe,这样编译器就知道修改这个字段必须在unsafe代码块里进行。这样就能把真正危险的操作隔离起来,也更容易审计代码的安全性。
其他元系提案
- Const Generics - 继续稳定化const泛型中剩余的功能
- Open Namespace - 允许通过包名拓展API命名空间
- Pub Private - 稳定化公共私有依赖项,帮助开发者更好地控制API边界
三、编译器内部与性能优化
这块最引人注目的新提案就是Production Ready Cranelift(生产就绪的Cranelift后端)。
我们都知道Rust默认用LLVM作为后端。LLVM优化代码质量很高,但编译起来较慢,尤其是开发阶段的debug build。
Cranelift是另一个编译器后端,它的设计目的就是快——编译速度快。
这个提案要把Cranelift打磨到生产就绪的状态,让它足够稳定、功能足够完善,可以成为开发者在本地调试(比如跑cargo build、run、test)以及成为CI环境里的一个可靠选择。
这意味着以后你可以在Cargo.toml里改个配置,让你的debug构建使用Cranelift来跑,从而大大缩短编译等待时间。这可以说是直接回应了”编译太慢”这个最大的抱怨点。
开发用快的Cranelift,发布时再切换回优化好的LLVM,这是一种非常可能的做法。
当然Cranelift目前还有一些功能需要追赶LLVM,比如像栈展开的支持、某些复杂ABI的兼容性,还有一些SIMD指令集的支持。这个提案就是要集中精力去解决这些遗留问题。
其他编译器优化新提案
剩下的两个新提案都是编译器内部的优化工作,简单提一下:
MIR Move Elimination - 在MIR中消除多余的移动操作
Codegen Retag - 在代码生成中发出retag
延续提案
在延续提案中最重要的就是Polonius(下一代借用检查器)。
Polonius确实是大家持续期待的一个项目,他也还在继续开发中。2025 H2的目标是希望拿出一个相对成熟的版本,可以开始进行稳定化的评估工作了。
Polonius主要想解决什么问题?
它主要是想解决当前借用检查器NLL有时候过于保守的问题,就是说有些代码我们人眼看是逻辑安全的,但是NLL会拒绝编译。
最典型的例子就是这个对HashMap先调用get_mut获取一个可变引用,然后再调用insert插入新元素的模式。明明我知道这两个操作不会冲突,但编译器就是无法通过。NLL无法分析出None分支中第一个借用已经结束,因此错误地报告了借用冲突,迫使开发者使用Entry API等更迂回的方式来编写代码。
Polonius基于更准确的程序流分析算法,它能更好地理解代码中借用的生命周期和作用范围,从而能接受更多像这样NLL会拒绝但实际上是安全的代码模式。
最终目标是提升Rust的人体工程学,减少开发者尤其是新手跟借用检查器搏斗的时间和挫败感,让Rust的代码写起来更顺畅自然,同时也能降低学习曲线。
Parallel Front-end(并行编译器前端)
让编译器前端能并行处理,这个工作已经取得了不错的进展。目前已经能带来20%到30%的编译速度提升,H2的目标是稳定化这个成果。
Next Solver(下一代trait求解器)
主要是为了修复当前求解器的一些健全性问题并且提升性能。Rust Analyzer在前一段时间就已经完成了这一代trait求解器的迁移。
A-MIR Formality
这个更理论层面的工作,用形式化方法来验证Rust的类型系统的安全性。
😂rust除了绑架linux和碰瓷cpp还会什么?(下)
Rust除了绑架Linux和碰瓷C++还会什么?(下)
大家好,我是自然选择。这期视频我们接着看剩下的提案。
互操作性和平台生态主题
互操作性和平台生态主题有六个新提案,旨在打通Rust与其他技术生态的连接。
GPU Offload
GPU offload是一种计算技术,将原本由CPU处理的计算密集型任务转移到GPU上执行。Rust强大的别名信息为这些领域带来了显著的性能优化空间,其性能提升远超C++与Rust的常规对比表现。这主要得益于自动微分和GPU卸载技术,能够充分利用别名信息实现性能提升。
但现在你要用Rust在GPU上运算,基本上得依赖rust-gpu、wgpu这样的第三方库。这个提案要完成std::offload的开发,这意味着Rust将拥有内置的一等GPU编程能力。
未来开发者将能够:
- 编写单个Rust函数
- 使用std::simd获得其SIMD或算子融合版本
- 使用std::autodiff对其进行微分
- 使用std::offload在GPU上运行生成的代码
机器学习或线性代数库将能够通过选择基于MLIR的新编译器后端来进一步提升性能。该后端会自动重写计算密集型操作以获得更好的性能。
Sanitizer支持
尽管Rust本身提供内存访问安全和线程安全访问的保证,但在混合语言二进制文件中使用unsafe Rust及外部代码时,这些保证将不再适用。因此必须在Rust编译器中添加sanitizer的支持以保证Rust的安全。
采用像MemorySanitizer、ThreadSanitizer这些工具,对发现unsafe代码、FFI接口里的内存错误、数据竞争这些问题非常有用,并且这些工具在C++里用得很多。但目前在Rust中使用它们比较麻烦,不仅需要用nightly版本的工具链,而且通常还得自己手动去编译一个打了桩的标准库。
这个提案要正式稳定化对这类sanitizer的支持,并且更关键的是,通过rustup直接提供预编译好的、已经打桩的标准库。这意味着以后在stable Rust上也能开箱即用地跑这些sanitizer,拓展Rust的安全边界。
C++互操作性
C++在其40年发展历程的大部分时间里,始终是领先的系统编程语言。现有数十亿行C++代码库,承载着数万亿美元的价值。短期内大规模重写当前活跃使用的C++代码既不可行也不明智。
从谷歌、微软等公司的实证表明,使用内存安全语言编写新代码能带来显著收益,这使得在C++代码库中开展Rust开发变得至关重要。
这个提案是一个基础性的组织和调研工作,目标是创建一个权威的问题图谱或者说路线图,系统性地梳理Rust与C++之间互操作时遇到的技术挑战,比如:
- ABI兼容性
- 头文件的处理
- 错误处理机制的对接
- 生命周期和所有权如何映射
- 等等
这份问题图谱就是要凝聚社区的共识,明确挑战所在,为未来设计更好的互操作工具和方案,无论是在Rust/C++层面还是在底层的LLVM层面,打造一个坚实的基础。
Rust for Linux相关
Rust for Linux Compiler & Rust for Linux Language
继续推进让Rust for Linux内核开发工作能在稳定版Rust上进行的旗舰提案。需要把很多内核依赖的编译器特性和语言特性给稳定下来。
GCC Backend Test
在GCC Rust的CI中添加更多测试,以更快地发现回归并加速其开发流程。
Scalable Vectors
继续为ARM SVE、SME等可伸缩向量指令集提供支持,这对于现代硬件上的高性能计算至关重要。
规范文档和测试主题
规范文档和测试主题有五个新提案,旨在让Rust的知识体系更完善、更易于访问,并提升社区的贡献体验。
Rust Reference完善
Rust Reference是Rust官方的核心语言文档,但它其实还不够完善。比如像:
- 类型推导的具体规则
- trait求解的详细过程
- 名称解析的优先顺序
- 宏展开的具体机制
- 等等
这些核心的语言机制在Reference中的描述还不够深入和精确,有些地方甚至还是空白。
这个提案要做的是组织一个由多位专家组成的团队,为Reference编写新的章节,系统性地填补这些空白。一个更全面、更精确的语言参考将使Rust的行为不再模糊不清,为所有用户和实现者提供一个可靠的真理来源。
Doc-cfg功能
大家平时查标准库或者其他库的文档时,会不会经常遇到看到一个函数或者类型,但不确定它是否在所有平台上可用,或者它是不是需要开启某个特定的feature flag才能用?现在只能靠文档作者在说明里手动标明,否则只能自己看源码。
这个提案要稳定doc-cfg功能。启用后,rustdoc在生成文档时就能自动识别出那些受条件编译(cfg)影响的API项,然后自动给这些API项添加一个醒目的标记,比如说”仅在Windows平台可用”。
类型系统文档
这个提案是专门针对Rust复杂但又核心的类型系统,比如泛型常量、新的trait求解器等来编写文档。它现在有一点像个黑盒,尤其是对想参与编译器开发的新人来说门槛很高。
这个提案希望能系统性地把类型系统的关键组件讲清楚,降低理解和贡献的难度,也让审查那些复杂的类型系统相关的改动能更容易、更可靠。
其他文档和测试改进
Formal Language Spec Up-to-date Capabilities
为形式化语言规范建立一套流程,使其能与Rust语言的发展保持同步。
Rustdoc Team Charter
为rustdoc团队制定一份正式的团队章程,明确其流程和成员职责。
Libtest JSON
完成libtest的JSON实验输出,为cargo-nextest等高级工具和cargo内置的测试报告提供支持。
Rustc-perf Improvement
继续改进rustc-perf性能追踪基础设施,也支持在多平台、多配置下进行分布式基准测试。
Rust Vision Doc
继续为Rust愿景文档进行数据收集,以确保其结论建立在对各类用户需求的充分理解之上。
总结
看完这42个提案,总的来说可以梳理出几条比较清晰的主线:
第一:提升开发者体验
最明显的是Rust正在努力提升开发者的开发体验:
- rust-analyzer和cargo build不再相互锁定
- 迭代应用中重编译次数大幅减少
- 更强大的IDE插件
- Clippy可用性大幅提升
- 包装可变引用的自动重借用
Rc/Arc包自动克隆- 更聪明的借用检查器
- 并行的前端编译
- 更快的后端编译
这些都与开发者的日常息息相关。
第二:增强语言本身的表达力
比如:
- 反射
- 原地初始化
- 字段投影
- 观察者字段
- Involving trait
第三:拓展Rust的应用边界
更好地融入现有生态,比如:
- GPU支持
- Sanitizer工具集成
- C++互操作
- Rust for Linux Compiler
- Rust for Linux Language
第四:标准化完善
补全缺失的Rust内部复杂问题的文档。
你会发现很多提案都在简化Rust中那些曾经被认为复杂、学习曲线比较陡峭的地方。所以我觉得完全不用担心Rust未来会成为下一个C++,更何况有Edition机制——即使设计失误也可以通过Edition进行隔离。
特别说明
最后我想特地提一下,Rust for Linux项目对Rust语言演进的巨大推动作用。在2025年29个非文档新提案中,有五个与其直接相关。在Rust for Linux Language中更是直接有这样的描述:
如果说Python在经历Python 2/3的动荡后被AI需求复活,那么Rust的生命力则可以说被Rust for Linux项目真正点燃。
大型Rust项目卡-或许cargo配置可以解决
问题背景
今天聊一下Rust分析器的问题。如果你使用的Rust项目里面包含的依赖包非常非常多,达到几百个,比方说六七百个,像Bevy这样的几百个依赖,那么不管你是用VS Code还是用Vim,都可能会感觉到非常非常卡。
我们来看一下Bevy中关于rust-analyzer的一些说法。可以看到第一个说法是,Bevy应该减少对rust-analyzer的压力测试,因为一些不好的指标会对rust-analyzer这个项目产生很大的负面作用。
最后是由一个人去做了一些记录,他是rust-analyzer的一个核心开发者。他就像一个”小媳妇”一样。为什么这么说呢?因为他站在rust-analyzer项目的角度上面,对外沟通的时候态度是比较强硬的,有很多东西是说不出来的。但是对于内部的一些核心开发者,他就相当于是两面受气,所以像一个”小媳妇”一样。
可以看到其他的一些开发者对他的说法也是,跟现在国内很多开发测试的情况一样——测试拿到东西之后,就开发第一时间问他:”你是不是按照规范去做的?你有没有做什么?有没有做什么?”反正就是一堆问题。
然后我们再看一下,反正就是在Bevy这个项目里面,对于rust-analyzer来说是经常会爆掉的。
问题严重性
我们在这个地方看一下,在rust-analyzer里面看一下这个内存到底是怎么样的,因为内存和CPU的资源消耗太多了。
这是两个小时之前的一个新的issue,他说在开发期间消耗过多内存。而这种问题特别是出现在最近半个月,特别是版本0.3.17发布之后,以及Rust 1.80、1.81即将发布的时候,这种问题就暴露得特别特别多。
反正只要我更新了rust-analyzer之后,我的两台电脑都已经扛不住了。可以看一下这三天前,这是8月份的,一直往下面看,基本上像这种VS Code崩溃,还有OOM(内存溢出)都是资源被占光了。
你看这是”让Rust飞起来,运行更快”的讨论,上面一直在说特别特别多的问题。包括2.2万多star,1万多fork的项目,他也一直在说这个问题,因为这个地方是一个长期存在的问题,而一直没有解决。
还有包括在这个里面,他说”为什么不将rust-analyzer占用了这么多资源?为什么用这么多资源添加到手册上面?”其实他不希望这样。
在其他的那些issue里面,官方的作者也明确表示,像这种问题他们一直在改进。但像这种抱怨太多了,他们已经把相关的issue全部给关闭掉了。
还有一个Bevy的核心开发者也在上面说了一些,能不能提供一些模式,在看代码和编写代码的时候进行不同的功能开关呢?但实际上在这上面已经被无情地拒绝了。
而且Bevy开发者,核心开发者已经不是一个个例了,很多项目都这样。而且除了Bevy的项目,还有一些其他的大型项目都会遇到类似的问题。然后他们的处理方式基本上都是要等消息、等排计划,所以说很无奈。
解决方案
但是有个好消息,至少我今天去用的时候解决了这个问题。我们看一下具体的。
我的电脑配置
先看一下我的电脑情况,我电脑是24核64G的。如果我的电脑都出现这种问题的话,基本上大部分的电脑都会出现这种问题,基本上都接受不了的。
优化前的状况
在我打开Bevy的一个项目之后,我随便打开几个文件,然后我就不动了。可以看到这边会一直跑一直跑,CPU会跑满。
优化后的效果
我稍微放大一点,可以看到现在平均的CPU占用只有百分之几,然后内存占用只有十几个G,对我来说够用了。这边已经加载完了。
那是为什么呢?是因为我现在整个Rust的环境配置已经改了。现在这边占用了700多G的磁盘空间。在修改之前,它占用的磁盘数量是300多G。所以说等到我把Rust的配置修改完了之后,整个Bevy的项目跑起来占了400多G。
它的代价就是占了400多G的磁盘空间,然后好处就是它已经不再疯狂占用资源了。可以看到上面CPU的平均只占用了2%,2%的话还是下面的OBS占的。不管是内存还是网络,基本上都没有在疯狂占用了。
没有改这个配置之前,我的内存是占满的,CPU是跑满的,网络的话每秒会有三四百K的下载流量,导致很多LD相关的东西都处于一个IO等待的状态,所以说就特别特别卡。
具体配置方法
我是按照Bevy的这个issue去重新去配置的,把所有东西全部重置了之后,整个rust-analyzer相关的东西我全都重置了之后重新去配的。
其实主要是用了这几个方法。从这地方开始看就OK了:
1. 编译时启用性能优化
第一个是性能优化,还有一些release发布,还有一些workspace里的一些相关配置。这些东西如果你使用GitHub、使用Rust CLI去创建项目的话,这些东西都自动帮你做好了,所以这些东西就完全不用管。
2. 替代的链接器
第二个是动态链接,动态链接是一个优化点,但是跟下面的一些东西有冲突,所以我是使用了下面的方法,没有使用动态链接。
可以看一下替代的链接器。链接器默认使用的是LD,然后Rust提供了一个LLD的方式。LLD相对于LD来说,可能提高了30%的速度。Rust 1.80的话默认是开启使用的LLD,而不是用LD。所以说如果你使用最新版本的Rust工具链的话,这个东西可以不配。
但是如果你是在Linux下面去做开发的话,那么会有一个替代方案叫MOLD。它比LLD快五倍,那相当于比LD快1.3×5倍。
具体方式是安装它之后,在配置的时候使用它。我们先把这个说完之后,最后再说配置在哪个地方去配。这是第一点,链接器要改一下。
3. 启用nightly版本工具链
第二个是在项目中启用一个nightly版本的工具链、Rust工具链,这也是比较好的。
4. 使用Cranelift代码生成器
第三个是在这个地方添加一些配置,这是nightly版本专用的一个代码生成器Cranelift,它比LLVM快30%。所以说在Linux下面使用它的话会非常快。
5. 通用共享泛型
另外还有一个是”通用共享”,我理解的话应该是泛型的共用吧。在这地方也是做一个配置,配置了之后,你每次去做迭代的时候,它会使用已经有的东西,也算是一个改进吧。
6. 日志优化(可选)
另外还有就是如果你的依赖包里面把日志、跟踪日志做一些处理的话,也会改进些性能。这个我没有处理。
最终配置文件
最后的结果就是在.cargo/config.toml这个配置文件里面,也就是Cargo的配置文件里面,我添加了这么多配置:
- 添加MOLD链接器
- 添加通用共享
- 添加夜间版本的代码生成器
这些都是挺好用的。
关于sccache
另外还有一个,以前我还用过sccache这个缓存包装器,我没有特别明显的感觉。反正就是如果用它的话,需要注意不要启用分布式的构建。如果启用分布式构建的话,它可能会对网络IO有一个瓶颈。对网络IO有瓶颈的话,就会进一步影响rust-analyzer的性能。
最终效果
改进之后,我们看到最后的效果就这样:CPU不再疯狂地跑了,内存也只维持在14个G,也没有往上面升。对我来说的话,那就是一个比较成功的优化。
大家可以参考一下。
Rust Sync和Send完全解读[程序员资讯]
我来帮您整理这段关于 Rust Sync 和 Send 的演讲内容:
Rust Sync 和 Send 完全解读
大家刚开始见到 Rust 的 Send 和 Sync 这两个 trait 的时候,是不是觉得很不好理解,为什么会有这两个东西呢?我刚好看到一个博客,可以跟大家分享一下。
当我开始学 Rust 的时候,他们说所有权和借用是最难的,但是当我开始学习以后,我觉得其实也没什么,直到我遇见了 Send 和 Sync,也就是在多线程环境下的所有权和借用。我花了几个小时去读文档,但是依然不知道这个东西是什么,还是搞不懂。在有那么一瞬间,我都想回到 Go 了,为了不让自己头疼,我就准备放弃去直接写 Go。
官方定义
然后我们看一下 Rust 官方给的一个定义:
- Send:如果这个类型可以安全地在多个线程之间传递,也就是安全地传递给另外一个线程,它就是 Send 的。
- Sync:如果这个类型可以安全地被多个线程共享,它就是 Sync 的。
What?这个定义是不是也搞得一头雾水?我只能机械地去记住什么类型是 Send 的,什么类型是 Sync 的,但是我不能推导一个新的类型。比如说我只能记住 Rc 它不是 Send 的,然后 Arc 它就是了。我们记得 Rc 是用来单线程里面的,Arc 就可以用来多线程里面了。
所以呢我就挖呀挖、挖呀挖,然后我就写了这篇博客,把我的经验写成了这篇博客。
核心理解
我们可以看一下,这里有一句话特别醍醐灌顶:Send 和 Sync 其实就是要把多线程安全编码进类型系统。也就是说在 Rust 里面,它试图用类型系统去做到多线程安全。我们先记住这句话,这也就是在 Rust 里面引进 Send 和 Sync 的原因。
数据竞争示例
那它是怎么去引进的呢?我们可以看到这里给出了一个例子,这是一个计算网页浏览量的例子。比如说当前这个网页,它已经有 1000 的浏览量了,然后又有一个 A 请求,还有个 B 请求。
- 当 A 请求来的时候,它会读到这个当前的数字是 1000
- B 来的时候,它也读到这个数字是 1000
- 然后 A 加了一,变成 1001
- B 也是从拿到的 1000 的基础上加了一,也是 1001
- 然后又写回去,A 写回去 1001,B 也写回去 1001
大家是不是发现什么问题了?其实应该是 1002 才对,但是最终的结果是 1001,数少了。这就是线程安全需要去避免的问题。
Send 的含义
Send 的话,它就是说你这个数据在移动到另外一个线程的时候,不要引起这种数据的冲突。也就是说它需要保证:
- 所有权的唯一性
- 线程的安全性
Sync 的含义
Sync 的话就是说多个线程当它同时持有一份数据的引用的时候,不能引起数据的冲突。也就是说这个数据是可以共享访问的,并且是线程安全的。
这是作者最初下的一个定义。
具体例子
String 类型
然后我们可以看一下他给出了一些例子。比如说 String 类型,它既是 Send 的也是 Sync 的。
比如说我们创建了个 String,然后可以把它直接放到另外一个线程里面。当我们把它放到另外一个线程时,这里是发生了 move 的,所以这个 s 就跑在了这个线程里面,所有权已经发生了移交。在这个线程外边,我们这个 s 已经不能用了,所以这个 s 的所有权还是唯一的,并且它还是线程安全的。
字符串引用
然后下面它又给出了一个新的例子,用了一个字符串的引用。字符串的引用的话,它在两个线程中都可以去打印出来。为什么现在是可以的?因为这里没有牵涉到修改,没有修改就不会对数据产生冲突,都是只读的,所以它也是 Sync 的。
内部可变性
OK,你觉得就这么简单了吗?其实并不是的。在 Rust 里面还有内部可变性这个东西,这也是我们在实际开发中需要用到的。
Cell 类型的问题
那什么样的类型它是非 Sync 的呢?比如说这里提到了一个例子是 Cell<u64>。Cell&mut 可变引用,但是我们依然可以对这个数据进行修改。
那这就有问题了。比如说你要是把这个放到多线程环境的时候,虽然这里当然在 Rust 中是不能编译的,因为这里会报错,因为 Cell 它是不 Sync 的,是非 Sync 类型。
如果 Cell 可以被放到多线程里面,就是在线程间去移动的话,那这个 Cell 是不是就出问题了?因为它会产生竞争:
- 第一个线程可能会对它进行写
- 第二个线程也会对它进行写
- 然后它们的数据就会乱掉
所以 Cell 不能是 Sync 的,这就是为什么我们会有一个非 Sync 的这样一个类型。
解决方案
那怎么样去避免这种非 Sync 的问题呢?可以去使用一些像原子类型 AtomicU64 这样的类型,它就是 Sync 的。还有一些 Mutex 锁之类的,因为它内部的话会做好这种数据的同步,去防止数据的竞争。
比如说下面给出了一个例子,在多线程环境里面就用的是原子类型,这样的话就可以保证它的操作是同步的,不会产生数据的竞争。
像这个也是,你看这里是用了一个锁。你要改变数据,要先获得这个锁。如果你获取不到这个锁,你就不能去修改这个数据。
Sync 的重新定义
所以基于此,我们又对 Sync 进行一个新的定义:
- 如果这个共享的引用它不能改变数据,就是我们只读的一个引用,那它就是 Sync 的
- 如果它能改变数据,但是没有包含什么同步性的机制,没有使用锁或者原子类型去保证避免数据竞争,那它就是非 Sync 的
- 但是如果你用了这种同步机制,有原子类型或者锁的话,那它就是 Sync 的
Send 类型详解
说完了 Sync 类型,接下来我们看一下 Send。
你可能会觉得 Send 比 Sync 会简单一些,但是也不尽然。这里的话是直接拿了一个经典的 Rc 类型,也就是 Reference Counter(引用计数)这样的一个类型做例子。
Rc 的问题
Rc 这个类型呢就是在它的内部维护一个引用计数器:
- 当你对这个 Rc 进行克隆的时候,它的内部的计数器就加一
- 当这个引用消亡的时候,它内部的计数器就会减一
- 直到这个计数器达到零的时候,这整个数据才会得到清理
Rc 的话,如果是说你把 Rc 这样的类型放到多线程环境里面,那就会造成一个问题。比如说这个 Rc1 跑到这个线程里面,然后这个线程要对它进行清理,就是 Rc1 的生命周期结束的时候,它就会把 Rc1 清理掉。然后你外边还有一个 Rc2,就会在多线程环境里面去操纵你的那个计数器,而你的计数器又不是线程安全的,所以它就有可能会造成冲突,就会造成像我们第一个看到的那个图一样,多个线程同时去修改一个计数器,这样就出问题了。
Arc 的解决方案
如果是要解决这个问题的话,那个计数器是不是要维护得线程安全?你就需要用到我们刚才提到的那些原子类型,或者是使用一些锁去做。也就是 Arc,Arc 内部维护的计数器就是用到了一些安全的机制,所以可以保证它在多线程环境下累加或者减少都不出错。
Send 的重新定义
好,我们就可以对我们的 Send 做一个重新的定义:
- 如果移动创建的这个所有权仍然是唯一的,而且没有一些隐藏的共享状态,那它就是 Send 的
- 如果说虽然这个数据所有权移交了,但是还遗留了一些非线程安全的共享状态的话,那它就是非 Send 的
- 如果所有权移交了,但是你也有一些共享状态,但这个共享状态是线程安全的,就像 Arc 一样,那它就是 Send 的
这就是作者经过这一番思考给出来的一个比较完整的定义。
判断流程图
然后这里他还很贴心地给我们了一个图,来判断一个类型它是不是 Send 的:
判断 Send:
- 问:这个状态信息是不是有多个实例之间共享的?
- 如果不是,就是实例之间不会共享状态,那它就是 Send 的
- 如果是,你还要去问一下:这种共享是不是线程安全的?有没有对数据竞争做一些保障?
- 如果是的话,它就是 Send 的
- 如果不是,那就是非 Send 的
判断 Sync:
- 问:这个共享的引用是不是可变的?
- 如果是不可变的,那就是 Sync 的
- 如果是可变的话,你还要看一下有没有一些阻止数据竞争的机制
- 如果有的话,那就是 Sync 的
- 如果没有,那就是非 Sync 的
OK,大家如果有什么问题可以留言。
发明Rust traits的家伙真是一个天才~~ 类似其他语言的interface,但功能更强大
我来帮您整理这段关于 Rust Traits 的演讲内容:
发明 Rust Traits 的家伙真是一个天才!类似其他语言的 interface,但功能更强大
各位网友大家中午好,我是 Andy。今天给大家分享一下如何去自定义自己的 trait。
问题引入
我们首先来看一下代码。比如说,我们首先生成了一个 range,然后我们对这个 range 进行一个筛选,做一个 filter:
1 | let data = range.filter(|x| ...).collect(); |
我们创建一个 lambda 表达式,传入一个 x,然后我们将它存入到一个容器里面去。然后呢,我们将这个容器给打印一下。
哦,这个地方报错了,那是需要我们声明一个显式的类型。这样子,然后我们再将它打印出来。大概就是这个意思。
一个想法
那这里呢我突然有一个想法。我心想,如果我在 data 里面把这个偶数全部给筛选出来了,那我用这个 data 减掉这个偶数,那是不是可以得到奇数呢?
那我们就可以这样想,可以这样写:data - even。如果从语义上的话,我们可以这样去写。可是这里也会报错,因为这个减号,这个 Sub 它并没有实现这个减号操作。
那这个减号我们要怎么去实现它呢?那在这里就涉及到如何去自定义我们的 trait。
方法一:包装新类型实现 Sub Trait
那这里我们如何去实现它呢?我们看一下。
好,我们将这代码给 copy 过来了。那如果我们要实现一个我们自定义的 trait 呢,那我们需要对它再进行一个包装。我们需要将一个新的类型把它包装进来,把我们这个数据包装到新的容器里面来,新的类型里面来。
然后呢,我们再对这个类型实施一个 Sub,就是实现一个减法的操作。
那我们看代码是长成什么样子的呢?我们将这个注释起来。
那我们在这里面实现了一个 Sub 的一个 trait,Sub trait。为我们这个新定义的类型实现了一个 Sub trait。
那这个 Sub trait 里面它利用了一个哈希算法,哈希 set 的算法。这个算法的性能是比较高的。
然后呢我们就可以这样去写了:
首先我们可以去定义我们一个 data。我们通过这个 range 的方法来定义我们一个 data。注意之前我们是直接用的。
然后呢我们这个地方收集这个奇数和偶数,收集在这里来。
然后呢,这个时候我们就可以调用这个减法了。因为这个 data,这个 data 是这个 IntVector,我们为它实现了一个 Sub trait,所以这个地方就直接用了一个减法,就直接可以将它给算出来的。
就是在一个集合里面,我们将这个偶数全部减掉,那我们就可以得到一个奇数。
最终我们会得到这样一个输出。我们来运行一下。
嗯,大致上就是这个结果。
关于操作符重载
这个井号呢就相当于这个 C++ 里面的 operator,叫操作符重载。
这个井号在 Rust 里面呢,它是通过这个 trait 去做了一个实现。我们可以看到这种看法呢是这个地方看起来是比较直接,减号这里是比较直观。
方法一的缺点
可是呢它破坏了我们这个数据类型的抽象程度。那么我们如何去保障这个数据的抽象?
这里就是说我们如何不去包装它,直接通过 vector 这种方式来实现我们的减法的那个逻辑。
方法二:直接为类型实现自定义 Trait
好,让我们来看另外一种方式的实现。我们将现在这个代码给它注释起来。
好的,那我们来看一下这段代码。
那这段代码我们就不是去包装这个类型了。我们上面是去新包装了一个,生成了一个新的类型去包装的。
那这个时候呢我们是新定义了一个 trait。我们上面是包装了一个新的类型,是使用这个 Sub 这个 trait。
这个时候呢我们不去包装类型,那我们先定义一个 trait,用我们的一个 trait 叫做 VecSub,这个新的 trait。
然后就把这个新的 trait 呢,为我们这个 Vec<i32> 去给它做一个实现。
方法二的使用
然后呢,我们就直接可以通过这种不破坏我们的数据结构的情况下来去使用它。
只不过是在这里,这个地方我们去调用这个 trait 的时候呢,那就不是减号了,没有减号那么直观了。
这里呢相当于我们是调用这个 trait 的这个接口了,相当于调用这个接口去实现了这个方法。
那结果呢都是一样,也是能实现我们想要的,也是能达到我们的目的的。
总结
好,所以呢要实现我们的逻辑,这种 trait 的实现是有两种方式的:
- 包装新类型实现标准 trait:直观但破坏了抽象
- 直接为类型实现自定义 trait:保持抽象但调用略显繁琐
大家可以结合自己的需求来具体采用哪种方式。
这两种方式,Rust 的 trait 它是一个非常灵活的一种方式。
好,我们平常工作中使用 trait,可以对我们的代码来进行一些很好的整合,和做一些很好的重构。
好啦,那我今天就给大家分享到这里,希望对大家有帮助。如果喜欢我的视频的话,可以帮忙收藏点赞关注。
好啦,那今天就分享到这里啦,感谢大家的收看,再见!
Rust 中 if let 代码块的隐性死锁问题
我来帮您整理这段关于 Rust 中 if let 代码块隐性死锁问题的演讲内容:
Rust 中 if let 代码块的隐性死锁问题
来看一篇短文,也是很基础的一篇文章。它的标题叫《Rust 中 if let 代码块的隐性死锁问题》。这篇文章是 11 月 1 号写的,这是原文链接,作者叫 Brooks。
作者背景
来看它的详细内容。Rust 是我最喜欢的编程语言,从大学到现在的工作,我已经写了大约 6 年的 Rust 代码。我经常夸赞 Rust 的编译器在捕捉棘手的多线程问题和内存错误方面的能力。
不过 Rust 编译器并不总能发现死锁问题,这在使用 Mutex 和读写锁等类型时可能会发生。
通常只要确保程序没有同时获取多个锁,你就能判断程序是否会出现死锁。
本文重点讨论一种常见情况,也就是在同一资源上获取多个同时竞争的读写锁。
简单示例
来看下面的代码示例:
这个示例很好理解。就是我们先有一个读写锁,然后先加读锁,然后再加写锁。这样子就相当于说锁出现竞争了。
这段代码会在尝试获取写锁的时候死锁,在获取写锁的时候出现死锁。
避免方法
避免这种情况有多种方法,最简单的方法就是先释放这个读锁,再获取写锁。
我们看这里想把这个读锁把它 drop 掉,然后再获取这个写锁。
而且你还可以通过显式的代码块来强制释放锁,也就是说利用作用域。在这个代码块里面加锁,然后出了这个作用域,自然就会 drop 掉这个锁,自动 drop 掉,就不需要明文地写。但是你可以用显式的代码块来操作,基本上是一样的。
反正一个是手动 drop,一个是作用域自动 drop。
因此在单线程情况下来处理竞争锁的方法呢,相对比较简单。因为你是单线程嘛,你先用,用完了,我来用,就这么简单。
复杂情况:多线程场景
那现在我们来看一下复杂的情况。复杂情况就是说有并行了,多线程的时候。
正常的 if-else 代码
看,在理解上面的内容之后,我们再来看一个代码片段,像这里的 if-else。
这个代码片段里面,我们使用这个 RwLock 对一个 Option 的数字来加锁,就是可能是某一种情况。
如果变量是 Some,is_some 的时候表示已经初始化,就不需要去操作了。否则我们要获取写锁,然后写一个数字进去,也做一个初始化。
相当于这段代码是运行正常的,没有毛病的。因为它直接进到 if 是 2 嘛,那直接就进来了,所以代码运行正常。
使用 if let 优化
现在呢我们用 if let 语句优化一下代码,就是把这个 if-else 优化一下,就直接 if let。同时呢打印出这个 RwLock 中的数字,把它 print 出来。
逻辑上面其实没有变化,跟上面无非就是一样的,一样的逻辑,无非就是写法不一样。只是利用了这个读锁获取的值呢,调整了打印的内容而已。
如果运行这段程序呢也没什么毛病。输出就是 “Number in there”,在这里,Number 在这里,是跳到这个分支了。”Number there”,然后是 2。这给出来的是 Some 是 2 嘛,然后完成了,完事了。好,没毛病。
死锁出现
那现在如果把这个 map 里面改成什么呢?我们把这个 RwLock new,把这个 Some 改成 None。
就是说你有两种情况,Option 是有两种情况,要么 Some,要么 None。
那 None 的话并尝试进行这个初始化,一开始是没东西的,None。
哎,程序死锁了,锁死了。这个 None,然后就变成了进到 else。
它为什么会死锁呢?
死锁原因分析
好,来看看为什么死锁,为什么会这样呢?
简单来说就是当 if let 语句中声明一个变量的时候,这个变量会在整个语句范围内生效。就整个 if let 的话,整个里面生效,就包括 else 块。就在 else 块里面,它依然有效。
就是在这个 let 里面,就可以把这个程序理解为下面这个形式的一个语法糖,就是它是相当于把它展开之后是这样的。
相当于是这样的,也就是这个 let 这个 map,这个变量会在这整个的作用域里面都是有效的,包括 else 里面都是有效的,整个涵盖整个的这个周期。
问题所在
那这样看就很明显了。就是读锁会在整个代码块里面是保持有效的。读锁是相当于这个读锁是一直,你看这个 read 是一直拿着的,这个 map 的这个锁是一直拿着的。
这个可以看到,就一直拿,你看到这里,它一直都是在拿着的,在这个作用域中一直是有作用的,拿着的。
解决方法
我们可以使用之前提到的解决方法,就显式地 drop,把它 drop 掉。
在哪个地方?在 else 之前,或者在哪个地方把它 drop 掉。或者是进来,比如说在这个加写锁之前,把它 drop 掉也行。
显式 drop 掉来解决这个问题。在上面的例子呢,这样做确实能解决问题,尽管会让代码就显得稍微有点点复杂,没关系,可以 drop 掉。
实际经验
那我写这篇文章是因为这种情况在 one club 项目中的一些 Rust 的库里面已经多次出现了。最近一次是在这里面,他有个链接可以看一下。
希望能够帮助其他人避免同样的错误。
建议
如果你在 Rust 中处理锁,最好就是为各种可能的这种控制分支去编写单元测试,确保所有的分支路径都能够正确执行。
如果有疑问,可以把这个锁放在显式的代码块里面去,来确保它们在使用后会被正确的释放。
就是我们如果遇到这样的问题的话,这样子。
好,那么这个小短文也是很基础的一个文章,引发了一些讨论。
Reddit 上的讨论
我们来看一下在 Reddit 上面的讨论。
讨论一:2024 版本已修复
第一个非常高赞的,229 个赞的,这个叫 Dtolnay 说了这个问题在 Rust 的 2024 版本已经 fix 掉了,就相同的代码就不会出现死锁了。
哎,那为什么用 edition 的这种方式来解决呢?就是用版本的这种方式来解决,更新版本的方式来解决的原因是什么呢?我们后面会讲到。
讨论二:行为看起来很奇怪
好,在下面叫 Steven014 这个人说:这种行为看起来很奇怪,令人意外。
直观上来说,if 是一个代码块,else 是另一个代码块,所以如果进入 else 块的话,我们应该期待的时候条件变量就应该被释放掉了。
就我在想,即使可能会破坏现有代码,这种行为是否可以在未来的 Rust 版本中更改?
你看这就讲到了,就是说它的语义在现在的这个版本中是固定的。那如果说你要把它的作用域改动了,那么就需要相当于你出台一个 policy 一样的,你要就是强制说我们的语义变了,它作用域应该是这样子的。
然后所以说你在下个版本才能行,否则的话你在现有的版本中该怎么做还得怎么做。
讨论三:向后兼容性问题
好,我们这 plug_wh 这样讲的:具体来说,他们似乎通过版本更新来处理向后兼容性问题,因此现有的版本仍然保留旧的行为。
也就是说为了不影响之前的代码,就是如果你在语义上有更新,你如果把之前的那种版本也更新的话,那以前的代码就会出现问题了。这是这个原因。
就是你更改语义的时候会出现这种情况,除非你要加个新的什么东西,向前进入没有问题。这是他的原因。
讨论四:关于副作用
好,这个叫 hllalex 是这样讲的:有些锁的获取是围绕它副作用,比如说下面这样的一个代码。
它是为了利用到它的副作用。那在当前的代码中,无论是进入哪个分支,文件都会在锁持有期间创建并进行操作。
这可能不是最好的风格,但这段代码依赖的是已公开和文档化的行为,而不是编译器的实现细节。
更改这类行为确实需要通过版本更新来实现,就这个必须要通过版本更新来实现。就是你之前的这种行为,就是根据那个之前的定义所来的,所以是公开和文档化的这个行为来的。
讨论五:关于锁的获取顺序
好,这个叫 mere_worthy_quick 这个讲的:我可能有些天真,但很久以前我学到就是避免死锁的最可靠的方法是始终以相同顺序获取和释放锁。
比如说如果线程 A 先获取锁 X,然后是 Y,那么线程 B 也应该先获取 X 再获取 Y。所以要相同的顺序,这样它们就永远不会发生死锁。
那如果你先获取 Y 再获取 X,那就会有可能同时在竞争 Y。
所以这样就可以。所以考虑到这一点的话,我现在是仔细读这篇文章了。
对讨论五的回复
但是呢,下面有对他进行一个回复,就说:这是一个好主意,按顺序来是个好主意。但是我们现在讨论的是单个锁的情况,所以获取顺序的问题,在这里面是没什么用的。
就你多个锁的话,你可能就是说按照哪个顺序来是可以的。我们现在讨论的是单个锁的情况。
关于内存屏障(Fence)
这个后面一个讲的就是说 Rust 的互斥锁使用了这个编译器的 fence。
其实这个编译器的 fence,就是说它是控制它的顺序,相当于内存的这个 fence。
如果没有办法使用 fence 来保护关键代码段的话,那么互斥锁就没有什么意义了。
其实这个 fence 这个概念来保持这个顺序的话,我们在之前就是一系列的文章里面有,就是《程序员都应该知道的》那一系列的那个视频里面讲到过,这个 fence 怎么来保障它的顺序,尤其是 acquire 和 release 顺序。
Rust 使用 LLVM 的顺序语义来实现这个 fence。锁定使用这个 acquire 顺序,解锁使用 release 顺序。
这就意味着指令可以移入它的关键区间,但不能够移出来。按顺序获取或释放锁会按照预期的这种顺序去工作。
可以详细地看下这个链接,我们之前讲到的。
总结
这是一个非常基础的一个文章,其实。
如果遇到死锁这种情况下,在以前的版本中的确就会有,2024 年之前的版本都会有这种事情。
那个是这个地方,其实他说得很好,就说你在每个分支上面应该做个测试,这就保证它的安全性。
Rust 的两种“Assert”使代码更好
我来帮您整理这段关于 Rust 两种 Assert 的演讲内容:
Rust 的两种 “Assert” 使代码更好
来看篇文章,它的名字叫《Rust 的两种 Assert 使代码更好》。这是它的原文链接,这篇文章写在 2023 年的 3 月 16 号,作者叫 Sunfished(或 Sunset)。
但这篇文章是 23 年写的,但是好像是一年多之后,然后引起了一个讨论,这个也是一个很有意思的事情。
文章内容
来看它的详细内容。
Daniel Lemire 最近的帖子叫《运行时的断言,它不是免费的》,就是要需要资源的,探讨了在 C 当中运行时断言的成本,并表明在频繁执行的循环中,一个简单的断言可能会导致非常显著的开销。
作者对断言看法的转变
多年来,我对断言的看法发生了一些系列性的变化,就是从:
- 我看不出来有什么意义
- 到谨慎使用
- 再到尽可能多地使用
后一种转变很大程度上是由于 Rust 有两种断言语句:assert! 和 debug_assert!。
这就使我能够准确地表达两种不同的断言,从而使我在很大程度上摆脱了这种性能上面的担忧。
如果你来自只有一种断言语句的语言,那这种区别可能看起来就没什么意义。因此在这篇文章中,我想要简要地解释一下,为什么它有助于改变我的想法。
背景知识
首先来看一个背景。我快速定义一下我所说的这个断言。
断言是一种编程语言语句,用于检查属性,并在这个属性不成立的时候导致崩溃,让它去崩溃。通常来说它叫断言失败。
示例
比如说如果我有一个包含人的年龄的 Python 程序,并计算最小年龄,我可能想检查最年轻的人,他的年龄是不是为负值。
比如说像这样:
1 | youngest = min(ages) |
就是最小的,然后断言它是不是大于等于零的。
如果 ages 里面包含负值,或者是这个 min 没有工作的话,那么 assert 会失败并导致运行时异常。会有 AssertionError 就直接导致它失败了。
换句话讲,写 assert 大致相当于:
1 | if not (youngest >= 0): |
断言的用途
实际上,断言主要用于检查程序状态的一个假设。
在这种情况下,也就是系统中从来没有输入负值的年龄值,就不会有负值的年龄值。
那我想要检查这个特定的假设,可能有两个主要原因:
原因一:防止错误传播
首先,我可能编写了后续的代码,这个代码仅仅是在非负的 youngest 值的情况下才能够正确执行。我希望在这个属性被违反的时候,阻止后续代码接着跑。
原因二:记录和检查
其次,assert 既能够记录,又能够检查属性。
换句话说,我也可以只写一个注释:
1 | # youngest must be non-negative or bad things will happen |
这个注释准确地描述了程序的这个假设,但是如果假设不正确,也许是因为程序的另一部分使用 -1 来表示我们不知道这个年龄,那么危险的坏事就会发生了。
如果我比较幸运的话,影响会相对比较轻微,甚至可能都看不到。但是如果运气不好的话,真正糟糕的事情就会发生,比如说奇怪的输出,甚至说会导致安全漏洞。
调试的困难
调试这种类型的错误假设很困难,因为假设违规的影响通常在违规发生后很长时间才会被注意到。
一些程序员花一整天甚至更长时间来找到问题,最后发现它是一个简单假设的违反引起的,这其实还不少见。
相比之下,assert 会让我的程序以一种可以预测的方式崩溃,而且可以提供非常清晰的报告,并且在最早可能的机会崩溃。
通常来讲,修复导致断言失败的原因相对比较简单。
为什么断言使用频率不高
那么为什么断言的使用频率不像我们想象的那么高呢?为什么我用的比较少呢?
就像我上面描述的那样,断言听起来是一个明显的优势,但大多数程序使用的断言比人们希望的要少得多。
原因一:缺乏意识
最明显的原因是,程序员经常没有意识到他们在程序中嵌入的假设,或者说没有考虑这些假设的后果。
对于初级程序员来说尤其是这样,他们还没有从很多很多的这种调试会话中积累到经验教训,而这些调试会话仅仅是因为他们没有想到使用断言。
我花了很多年编程,才意识到我没有考虑并断言检查我的程序属性,浪费了很多的时间。
原因二:表达困难
有时候也很难弄清楚怎么样来断言自己关心的属性。在像 C 这样的语言中尤其是这样,因为没有内置的帮助来表达诸如”列表中的任何元素都不能为负”之类的属性。
断言越长越难,尤其是如果它需要一个辅助函数,就越不可能被写下来。
原因三:断言错误
不可避免的,一些断言是完全错误的,要么表达了错误的属性,要么错误地表达了正确的属性。
我认为我们大多数人都预期这样的错误,但是很多人没有意识到是断言如果有副作用,可能会改变程序的行为。
我多次因为将比如说像 i++ 之类的这种代码复制粘贴到断言中而自食其果,导致程序的行为因为断言是否被编译而不同。
我认为这是我的问题,而不是断言概念的缺陷,但我听说至少有一个组织因为这个问题禁止断言。
原因四:性能问题
那性能问题。Daniel 指出了避免断言的一个非常不同的原因,就是什么呢?他们在错误的地方使用的时候,可能会导致非常严重的性能问题。
断言引入了一个分支,必须在运行时执行。断言的存在也可能导致编译器错过编译时优化机会。
编程社区普遍担心断言的性能成本,尽管我们没有人知道它是怎么影响给定的程序,而无需实际测量它。
调试模式和发布模式
那为了避免性能问题,大多数软件都是以调试(有时候叫做测试)或者是发布模式编译。
- 在调试模式下:断言被编译,并且在运行时检查
- 在发布模式下:断言不被编译,所以在运行时不检查
在 C 等等这个语言中,没有标准的什么调试和发布模式的概念,但是很多人认为发布模式就是 Release 这种模式,意味着添加标志 NDEBUG,这会导致 assert 成为一个空操作,没有操作。
那 Rust 的标准 Cargo 构建系统默认的是调试模式,但使用 --release 就是发布模式会执行发布的这种构建。
两种断言
虽然在发布模式下不编译断言可以消除这种性能问题,当然它也会削弱我们对程序正确性的保证。
仅仅因为测试套件没有违反假设,并不意味着真实的用户不会以违反假设的方式来使用程序。比如说用一种非法的这种输入去输入。
所以现在我把断言分为两类:
- 第一类:检查问题域的假设
- 第二类:检查内部假设
两类断言的区别
这种区别可能看起来是人为的,甚至是不存在的,所以让我举个例子来说明我的意思。
第一类:问题域假设
第一类包含关于我的程序试图帮助解决的现实世界问题的假设。
比如说如果我正在编写一个库存系统,我的程序某些部分可能假设某个项的条形码永远不能为空之类的这个属性。
第二类:内部假设
第二类是包含关于我怎么样来构建程序的假设。
比如说我可能写一个函数,如果我假设输入整数大于 1,那么这个函数会运行得更快。
我可能会在我编写这个函数的时候,不会以违反这个属性的方式调用它,但以后的程序员包括我自己,很可能会忘记或者是没有注意到这个属性。
所以对于未来程序员来讲的话,断言特别有用,尤其是当他们重构代码的时候,可以让他们更有信心地不会以微妙的方式破坏程序。
信心水平的差异
我花了多年时间才意识到,我对每一类假设的信心水平有很大差异。
- 对于第二类假设的违规,在正常测试期间会被捕获,我有很高的信心
- 但是对于第一类假设的违规,在测试期间被捕获的信心要低得多
不同信心水平并不奇怪,毕竟我被雇佣是因为我会编程,而不是因为我对库存系统或者条形码了解很多。
但是由于正常测试意味着调试模式,而用户正在运行程序意味着发布模式,这就意味着我信心最低的假设在最需要的时候没有得到验证。
Rust 的解决方案
问题根源
我刚刚表达的问题,最终发生是因为像 C 这样的语言,迫使我们使用单个 assert 语句来编码两种假设。
怎么呢?要么所有断言都被编译,要么都不编译,就两种。
我长期以来认为这是不可避免的,但几年前当我转向 Rust 的时候,我逐渐意识到我现在可以使用两种断言语句。
debug_assert! 和 assert!
debug_assert!:有点像 C 中的 assert,因为它表达的假设仅仅在调试模式检查assert!:是始终在调试和发布构建中检查,并且不能被禁用
这似乎是一个很小的区别,但对我来说,它完全释放了断言的力量。
实际使用
如果你看我写了很多代码,你会发现我大量地使用了 debug_assert!,经常检查一些很小的假设,包括我认为不太可能违反的假设。
我甚至从来没有想过,更不用担心 debug_assert! 的性能影响。
但是偶尔你会发现一个 assert!,甚至是在相当频繁执行的代码中。那些是我检查特别重要假设的地方,或者是我对这些假设特别不自信的地方。
每次我写 assert! 的时候,我都会考虑可能的性能影响,以及是否有办法增加我对假设的信心,以至于我可以把它降级为 debug_assert!。
同样,在调试的时候,我经常非常仔细地检查 assert! 语句,因为它们表明我对特定假设的信心比较低。我更有可能重新考虑 assert!,而不是 debug_assert!。
其他语言
当然你可以在 C 或者任何其他语言中,编写自己的 assert 和 debug_assert 等等这个宏。
但是把它们内置到语言中,并清楚地记录和广泛理解它们的不同动机,使得使用它们就变得更加容易。
我希望除了 Rust 之外的其他语言也能够在断言方面做出这种区别,尽管我希望有一个比 debug_assert 更短的名称。
社区讨论
其实这篇文章讲了这个断言的两种方式,就是对性能上面的一些影响,或者怎么样。
但是我们从这篇文章当中可以明显感觉到,他是一个用 C 写了很久的人,很久很久的人。
所以说我们来看这个引起的这个讨论,是怎么对他这个文章进行讨论的,非常的有益处。
如果你也是写 C 写了很多年的话,你会发现他这篇文章的问题可能在哪里。
讨论一:断言的分类问题
他引用了说,这一天我认为断言分为两类。
第一类就是不应该使用 assert,因为这是需要通过错误处理来解决的问题。就是说他认为这个不是 assert 的事情,而是错误处理。
其实我们之前有看过很多的这个,我们有视频写了一系列这个错误处理,就是 Error Handler,包括像 Rust 的 Error Handler 也是它的一个亮点,就是它必须要求你去处理这个错误的。
所以说这个第一类的话,其实不应该用这个断言,它应该是用错误处理来解决问题,而不是简单的断言错误就完了。
两种假设
比如说:
- 假设 A:针对不受控制的外部输入的假设
- 假设 B:针对应该受到控制或已知的输入的假设
两种假设都是这个,有点像是这个用户用的时候,他出现了一些可能。
对于第一个假设 A 来讲的话,应该总是通过错误检查来进行处理,就是错误处理或错误检查来处理。
对于假设 B 这种,就是如果实现完整的错误处理太麻烦的话,可以在某些情况下使用断言。也就这种情况下使用断言,就相当于让程序停下来。
但是仍然需要谨慎,因为我们不希望这个程序会意外地停下来。
所以这类假设的问题通常需要尽早发现并且解决,而通过明确的断言,可以更快地定位问题。也就是说它在假设 B 这种情况下,受控制这种情况下,就是说我已经预测到可能是这种问题,让它停下来是可以的,用 assert 让它停下来是可以的,同时你可以定位那个问题。
讨论二:模糊测试
另一方面,断言特别是针对第二类的这个内部假设,也就是他讲的这种第二类检查内部假设,能够显著地提高模糊测试的效果。
但是这里面讲到这个模糊测试,就是 Fuzz Testing 或者叫 Chaos Testing。
模糊测试的目的并不是触发崩溃或者产生明显错误的输出,而是通过触发 assert 来暴露这个问题。
这就是为什么针对意外的输入不应该使用断言的原因。意外的输入不能用断言。
模糊测试的本质
模糊测试本质上就是要制造意外的输入,就是 Chaos Test。我们经常有那种大型的 Chaos Test,就是搞那种错误是你没有想到的,就这这这种东西就是乱搞,看看这个程序够不够健壮,其实是这个目的。
所以它目的并不是触发崩溃、产生明显错误输出,而是通过触发来暴露问题。
所以说你看模糊测试本质上叫制造意外输入,而程序应该以合理的方式处理这些输入,而不是通过断言直接把它终止掉。
所以如果对于输入的约束过于严格的话,模糊测试的有效性就会下降,因为测试工具可能没有办法充分地探索这些边界条件。
模糊测试的目标是扮演一种混乱测试的角色,就是它的原文叫 Chaos Monkey,就是像一个捣乱的猴子一样的,这混乱的这种捣乱,混乱测试就搞得乱七八糟的这种。
这种目的就是要把这个搞乱,看你的程序能不能控制住这种混乱。所以允许它这样运行效果会更好。
这里面就讲到了,其实他这个 assert 有些地方用得是不太恰当的,不太合适的。
这是对他的一个评论,这个你看到这个评论链接是来自 YC(Hacker News)上面的。
讨论三:更好的建议
下面这个评论,其实我是比较赞同他的说法的。
就是说其实他认为 Rust 的这个断言很有好处,其实未必。
就是其实我们要时刻记住有一点,就是 Rust 是现代语言。为什么认为它是现代语言?就是说你常用的那些事情,比如说错误处理也好,断言这种东西,其实它已经在这个语言当中,已经把它解决得很好了,尤其是错误处理这块,包括类型系统这块已经处理好了。
那或者说他这个举的例子不太合适。Anyway,他这种方式其实我个人感觉他是有一点那么重复造轮子这种感觉,就是造而且造的是边角的轮子。
就是要善于利用到 Rust 当中它的类型系统是比较合适的。就是你既然用了这种语言,你就要把它的东西用到,你要理解它的语言,它的好处在哪里。
而不是说我用这个语言就像用 C 或者用其他的一样,就是我用各种语言一样。那么你就用 Rust 就很可惜了。
就是没有,你当然这样写可能也在某些情景下也可能能行,但是就说你做了太多的事情,你用它就用得有点可惜了,就是这个意思。
类型系统的力量
你看他讲得就这样讲的:尽管断言功能强大,但是在 Rust 当中,我更加倾向于通过类型系统来保证数据的这个约束和它的正确性,而不是频繁地使用断言。
举个例子,我们可以定义一个 Age 的类型,我们定义一个类型,就是年龄的这种类型,并且确保它只能存储非负值。
比如说我们用 u32 或者 usize,我们用这个 u 可以的,确保它只能存储非负值。
这样一来的话,就不需要通过断言来检查 youngest 是不是为负值了,因为类型系统已经帮我们保证了这一点。
也就是说,你如果用一个语言要运用到它的长处,那如果它已经有这个东西了,有类型系统了,你还去做这种断言的话,就只能说你还没有,就相当于之前有有有个文章讲到,你还没有开窍。
至少是对这个语言你还没有开窍。
总结
就是这篇文章,他的一个点很有意思的一个点。
当然在评论当中,有人指出他对 Python 的那个地方,好像举的例子不太对,就是他的方式是不太好的地方。
但是这种问题,像这种文章,可能会是比较一种典型的思维,他可能是一种比较典型的思维。
但这种思维要想掰过来一点的话,其实是需要对这个语言有那种开窍的那种感觉,那才会用得特别好。
就是顺着它去用,而不是去跟它去抗争。
就感觉你这地方不对,又不能把它想成一种,你可能感觉我用哪种语言写哪个东西都是一样的。
可能这话是不错,但是你如果针对某种特定语言,利用了它的特定的优势,就会显得更加的和谐。
部分借用会导致UB吗(上)
我来帮您整理这段关于 Rust 部分借用与未定义行为的演讲内容:
部分借用会导致 UB 吗(上)
来看一篇博文,这篇博文比较长,所以准备分两期来讲。它的标题叫《绕过借用检查之后——部分借用会导致未定义行为吗》,部分借用会导致 UB 吗?
这篇文章是 8 月 9 号写的,然后这是它原文链接,作者叫 Wanna传说。有兴趣可以看一下原文的,我们来看一下它的内容。
问题背景
Rust 的部分借用,也就是跨函数边界并不容易实现,这确实是个大问题。
其实像这个部分借用的这个事呢,其实包括 Boss(可能指某位知名开发者)他们都已经讲到过这事,确实是个大问题。
现有解决方案的缺点
虽然有一些变通方法,但都存在比较大的缺点:
内联的问题
内联会带来明显的问题。重构会破坏这个结构布局,这对于性能就很重要,并可能使代码可读性降低,因为相关信息就不再打包在一起了,或者是内部关系变得比较混乱。
自由函数或关联函数的问题
自由函数或者是关联函数简直就糟糕透了。它们破坏了这个封装性,与周围代码的交互也很别扭,而且更容易出错。
如果我调用这个 self.lookup,就可以查看这个 self 和 lookup 的这个约束条件,确保函数不会出错。
一个只接受哈希映射的通用引用的关联函数,可能会因为 unwrap 或 panic,就因为有人不小心放入错误的哈希映射。
方法的目的是防止把无意义的数据传入高度受限的函数里面去。不得已要绕过 Rust 清晰方便的语义是很令人沮丧的。
View 结构的问题
我对 View 结构没有太多经验,但至少它们需要额外的工作,并将逻辑代码拆分到多个不同的结构中。
作者的想法
我一直有一个想法,就是可以用裸指针来绕过一些限制。
这种部分借用问题在 C 语言中不存在,就是指针嘛。所以 Rust 中应该不会有什么麻烦。
犹豫的原因
我之所以犹豫不决的原因有几个:
原因一:问题严重程度
第一,我还没有遇到特别严重的部分借用问题,让我觉得非要用这个 unsafe 不可。
原因二:文档不清楚
第二,什么呢?关于指针的文档有时候不太清楚,我不确定我的做法会不会直接导致这个未定义行为。
原因三:社区氛围
第三,就是每次在公开论坛问 unsafe 的问题,总有人用这个居高临下的语气说教,说我这个代码结构有问题。那这种氛围让人很难学习。
个人学习理念
我想说点自己的想法,如果你不想看,可以直接跳过。
刨根究底的态度
我是一个喜欢刨根究底的人,习惯于弄清楚事情的底层逻辑,喜欢有明确的规则和合理的解释。
我想深入理解问题,以便在各种情况下灵活运用知识,包括知道什么时候可以打破规则。
我认为只有通过仔细的尝试和探索,才能达到这种理解。单纯地去遵循所谓的最佳实践是不够的。
死记硬背和按部就班并不是真正的学习。如果那些所谓的限制是错误的、表述不清的或过于保守,怎么办?
即使我最终决定遵循最佳实践,我也必须确信它们确实是最佳的,而这只有通过经验才能得出。
挑战惯例
这听起来可能像是不必要的麻烦,某种程度上确实是这样。但我的经验是,无论编程还是其他领域,所谓的惯例往往是”我们一直这样做的”盲目遵循,而不是真正的智慧。
尝试新事物可能需要更多时间,但我始终相信,最终我会比那些墨守成规的人更胜一筹。
受到的启发
我最早看的一个编程视频是 Javidx9 的 “Forbidden C++”。这个视频非常棒,它揭开了那些被视为坏习惯的编程技巧的神秘面纱,比如全局变量、空指针和内存泄漏。
他解释了为什么这些东西通常被认为是不好的,但也给出了它们合理的使用场景。
最重要的是,作者鼓励大家大胆尝试。
重要的建议
另一个视频中,作者说:
记住,工具是为你服务的。不要害怕打破规则,到处用全局变量,使用不安全的系统调用,别老想着检查整数范围。这些都是学校里不教的,知道吗?没关系,你是在学习,没啥大不了的。电脑不会生气,也不会有人在网上羞辱你的代码。大胆去干吧!
陷入困境是程序员成长的必经之路。如果你想成为程序员,调试代码是家常便饭。挺过来你就能学到很多。实在不行,删掉重写。最好的学习方法就是犯错。
这句话对我刚开始编程的时候非常有启发,现在还经常想起。
只要不是上线项目,代码有点乱也没关系,重要的是学习。
对未定义行为的理解
一开始,未定义行为就像编程里的幽灵,让人害怕。但随着对编译器的了解,我发现事情没那么可怕。
编译器不是魔法,它不会无缘无故把你的代码搞乱。即使出现未定义行为也是有原因的,并不是随机的,可能是某些错误假设导致的。
虽然现在有更多的内存保护措施,但理解未定义行为的边界仍然很重要。
它不是一个模糊的概念,而是真实存在的,只是我们有时候不知道它的位置。
程序要么遵循编写的规则,要么不遵守。是否出现未定义行为不是随机的,而是有规律可循的。
总之,我想通过尝试了解未定义行为的边界。即使最后没有找到完美的解决方案,我也能学到很多东西。
部分借用的问题
我们先看部分借用的问题。
我在开发星际争霸模拟器的时候,遇到了借用检查器的问题,特别是当添加 Buff 或者是 Debuff 的效果的时候。
数据结构
我们有个 Coordinator 结构体,里面包含两个 Army 结构体和其他一些数据。
每个 Army 的结构体大致是这样的:
1 | struct Army { |
这里面的 base_unit 是一个哈希表,存储每个游戏单元(游戏对象)的类型的基础属性。
unit 是一个向量,存储每一个游戏对象的动态数据。
需要注意的是,这个系统是在运行前设置的模式。一旦设置完成了,base_unit 和 unit 的大小和内容都不会改变。
我们定义那个 State 结构体来表示这个游戏对象的状态:
1 | struct State { |
实现减速效果
我想实现一个减速效果,一个 Debuff 的效果,叫做震荡弹(Concussion Shell)。
这种效果会降低被击中的游戏对象的移动速度,并且不能叠加。
我们用 Effect 这个枚举来表示各种状态修改器。为了简化,我们假设 Effect 只有一个变体:
1 | enum Effect { |
state表示要修改的状态apply是一个函数指针,用来应用这个效果timestamp表示这个效果持续时间
效果更新的问题
游戏引擎每一帧都会检查每个游戏对象身上的所有效果,移除过期的效果。
就是你要更新这种效果嘛,那肯定是要查一下它有没有什么新的效果。
但是移除效果并不容易,并不简单,因为一个游戏对象可能同时受多个影响相同属性的效果。
比如说狂战士同时受到加速和减速。
最简单的解决方法是在移除某个效果时候,重新计算这个属性的最终值。
星际争霸中的增益和减益效果数量有限,而且没有叠加效果,所以说这种重新计算的性能影响应该就不大。
借用冲突
这个逻辑会体现在 Coordinator 结构体中的一个函数里面。
在代码当中,遍历 army.unit 会导致程序暂时没有办法修改 Army 本身。
什么原因呢?因为它借用嘛,已经先借用了,然后你再去修改,改不了了,借用冲突。
那调用 army.reset_speed 的 unit 的函数呢,也需要暂时拥有对 Army 的可变的权限,这同样会导致编译器报错。
类似的问题也出现在 reset_speed 函数中。
需要注意的是,目前我们只处理速度属性,但以后可能会加入更多的属性。
如果想要让 effect.apply 函数能够灵活地处理各种属性的话,就需要完全可变借用 State 的结构体。
比如说我们要用迭代的这种方式要改很多,这个 effect 的话,比如:
1 | for effect in state.effects { |
对吧,就没法修改了。
所以函数内部 state.effects 临时借用了 State 结构体,但同时我们也需要将可变的 state 传给 effect.apply 函数进行修改。
绕过借用检查
这里虽然可以通过 Rust 的安全机制来解决这一问题,但这里为了探讨一种特殊情况,使用安全机制会带来一些不便。
比如没有办法使用迭代器的方法,比如说 filter。
稍后讨论完这个问题之后,我们会介绍这些函数的常规写法。
使用裸指针
现在要介绍一种绕开机制,也就是使用裸指针来绕过 Rust 的借用检查。
不过为了保持 Rust 的编程编码习惯,我们稍后会把这个指针又换回这个引用。
需要注意的是,转换后的引用拥有和传入值不同的生命周期,这要注意的,它生命周期变了。
不冲突的操作
下面列出了一些操作,由于这些操作不会产生冲突,所以我们可以绕过借用检查器:
- 读取
state.base,它只是一个 Copy 类型 - 然后读取
army.base_unit.movement_speed - 在调用
reset_speed之前,写入state.effects - 在
reset_speed函数中读取state.effects - 在
effect.apply函数中写入state.max_speed
这些它都是不重叠的,不会冲突的。
那这些操作并不会改变我们已经借用过的值,也不会造成可变值之间的相互干扰。
因为 C 语言实现这些操作非常简单,在 Rust 中也可以通过使用这个裸指针的方式,达到类似的效果。
就用 unsafe 把它用个裸指针过去。也按照类似的方式,可以模仿 C 去写个,写这样的一个 Rust 也可以。
问题的关键
如果只是使用这个原始指针,一种裸指针,问题好像就解决了,对不对?
因为原始指针不像可变引用那样有严格的限制。
但实际上,把这个裸指针再转为引用,并同时使用结构里的其他引用,这个才是问题的关键。
危险性
这种做法非常危险,任何逻辑错误都可能导致未定义行为。
即使同时访问可变的和不可变引用本身不会立即引发问题,我们实际上是在欺骗这个借用检查器。
而 Rust 仍然会按照原来的规则对待这个 borrow,就好像它在其他地方不可访问一样。
所以任何错误的假设都会非常危险。
解决的问题
通过使用这些函数,我们可以绕过对 Army 和 State 的这个借用限制,从而就可以灵活地调用方法和传递参数。
但这只是解决了这个借用检查的问题,编译器可能还会带来其他挑战。
有趣的发现
有意思的是,我发现这个 Amazon 的 Rust 的 Ion 数据格式解析器也使用了类似的这种函数。
虽然这个实现方式略有不一样,但还是很有意思的。他说看到这个跟他类似的。
预告
那么后面还有一部分,讲的是虽然是这么去做了,但是它真的会出现未定义行为吗?会吗?
哎,我们下一期去看。
部分借用会导致UB吗(下)
我来帮你整理这篇关于Rust部分借用与未定义行为的演讲内容:
部分借用会导致UB吗(下)
我们继续看这篇博客文章,讲的是部分借用会出现未定义行为吗。
问题的复杂性
这个问题的答案非常复杂。一开始我觉得应该不会出现未定义行为,但看到原始指针的文档后,又觉得可能会。咨询了一些开发者后,我们一致认为很可能会出现。经过一番调研后,我也觉得可能不会。但现在我只能说:谁知道呢?
这个问题似乎没有明确的答案,可能是有意设计的灰色地带。这个问题让我对编译器的理解达到了极限。如果我有什么误解,请告诉我。在我发布这篇文章之前,我还在阅读专家们的相关论文。这从一个很小的抱怨,变成了一个耗时数周的难题。所以请谨慎对待我的观点,如果发现重大错误,我会及时更正。
Rust的规则
先看Rust的规则是怎么讲这个事的。std::ptr模块提供了一些线索:指针是否有效,取决于操作类型(你是读还是写)和访问的内存范围这两点。这跟我之前读到的读写访问的概念是一致的。
MIRI的作者也表达了类似的观点:别名主要是为了让编译器优化内存访问。因为未使用的指针不会影响程序,所以通过unsafe获取可变引用,并调用一个接受&mut self和这个引用的函数,可能不会导致未定义行为,只要我们不访问可变引用self。
但是指针文档明确指出:这种有效性的精确规则还没有确定。这就是为什么我还不能百分之百保证我的观点的原因——语言本身也没有明确的规定,专家们还在研究这个问题。我不是专家,只是个业余爱好者,所以我会继续深入研究,争取找到更确定的答案。但由于这个问题涉及到编译器的底层细节,可能会非常复杂。
引用转换的规则
由于所有的引用都来源于有效的引用,并且这是一个单线程应用程序,所以指针文档中给出的概述中,唯一相关的部分是:只要底层对象存在,并且没有其他引用同时访问相同的内存,那么将引用转换为指针就是可行的、有效的。
但这句话并没有多大帮助。在这种情况下,我认为我们可以假设:引用转换为指针,再转换为引用,等同于引用转换为指针。
我们通过指针访问army.units和state.effects,同时又通过可变引用调用reset_speed,并检查army.base_unit的值。在我之前引用的博客文章中,作者讲到,通常应该避免混合使用引用和不安全指针。所以这也没有给出明确的肯定或否定,并且有点跟官方文档相矛盾。
堆栈借用(Stacked Borrows)
我认为这是跟堆栈借用有关系,也就是MIRI如何跟踪内存访问,但这里就不深入讨论了。
“访问”的含义
那么”访问相同内存”到底是什么意思呢?
说”读或写”很容易,但那是什么意思呢?如果我把这个可变引用&mut army传递给一个方法,这算不算访问了指针呢?在我看来,除非方法是内联的,否则我认为内存中没有任何物理内容被读取或写入。
那么这就是Rust和LLVM文档中重要的内容:访问army的字段,这算作读取整个结构体呢,还是只读取字段呢?在局部范围里,它只访问字段,但编译器会做特殊处理吗?
我询问的社区成员的意见是:可变引用&mut army有效地意味着访问整个army,无论它在函数中如何使用。这是通过split_at_mut来解释的——它同时返回两个可变引用,因此只需要借用原始切片一次。
但我越想越不同意这个观点。我认为它是这样工作的,以确保百分之百安全:split_at_mut的目的是你不能在访问它的两个部分的时候还访问原始切片。但”不是百分之百安全”并不意味着立马就会产生未定义行为。
Rust参考手册中的UB定义
我们可以调查另一个线索:Rust的参考手册中关于被视为未定义行为的部分。它并不是很详尽,但仍然有一些我们尚未涵盖的新信息。我们快速扫一眼这个列表:
- 没有数据竞争:因为是单线程的,所以不会有未定义行为
- 指针有效性:指针是由有效引用生成的,所以它们不会悬空或错位
- 字段访问:我们通过常规的点语法访问字段,因此不会违反指针算术规则
- 可能的违规:我们可能会违反LLVM的指针别名规则,特别是因为我们没有使用
UnsafeCell,我会在下面详细讨论 - 来源传递性:我们可能会修改不可变值,这跟指针来源传递性有关,我稍后会讲到
其他方面:
- 我们没有使用编译器内在函数
- 我们没有进行跨平台编译或使用特定架构的功能
- 我们没有与任何外部函数交互,因此在函数调用中没有使用错误的ABI
- 我们没有产生任何无效值
- 我们没有使用内联汇编
- 这不是一个常量上下文
这些都表示我们没有产生这些类型的未定义行为。
LLVM的规则
我们再看LLVM的规则是什么样的。我们可能违反了LLVM的两条指针别名规则,这些规则决定了指针值是怎么来源于另一个指针值的。我们仔细研究一下最令人担忧的一个规则——指针别名规则:
任何内存访问都必须通过一个指针进行,这个指针关联的地址范围涵盖了要访问的内存区域,否则行为是未定义的。一个指针的值与它来源的任何值的关联地址相关联。通过
getelementptr操作生成的指针值来源于getelementptr的指针类型操作数。来源关系是传递的。
“来源”(Provenance)的含义
“来源于”这个词听起来有点难懂,但我猜它跟Rust文档中的”来源”意思差不多,都跟臭名昭著的LLVM指令getelementptr有关。文档直接指出这个指令非常复杂,甚至有专门的文档来解释它,但那些解释文档也一样让人费解。老实说,这并不是LLVM的错,而是我缺乏相关背景知识。
这里有一个关键点需要注意:Rust的文档提到了”缩小来源”,这似乎跟LLVM文档”与其来源的任何值的关联地址相关联”有些矛盾。
来源会隐式地与所有指针共享,这些指针是通过比如偏移量、借用和指针转换等操作从原始指针派生来的。一些操作可能会缩小派生来源,限制它可以访问的内存量或它的有效生命周期,比如借用字段和子切片。
举例说明
简单来讲:
- 可变引用
&mut foo.bar相当于能访问整个&mut foo的内存,因为getelementptr指令最初用到的指针是指向foo本身的,然后才指向它的字段 - 根据相关规则,任何指向
foo的子集的指针(比如&mut foo.baz)也能访问整个foo的内存 - 但是如果用
getelementptr获取foo的某个字段,那么这个指针只能访问那一部分内容
换句话讲:
- 通过这个指针访问
foo本身的数据是没有问题的 - 但不能拿另外一个指针进行运算让它指向
foo.bar并写入数据,这种操作是非法的,因为它超出了这个指针的访问范围
据我所知,这并没有说明如何以及什么时候访问指向相同数据的多个指针所指向的数据,只是说我们希望能够知道这个指针的来源,并且它的访问是合法的。
严格来讲,getelementptr只是指针运算,而不是访问。更直白的说,我认为LLVM希望能够撤销任何指针运算来找出原始的基指针到底是谁——它想把这些指针运算全部解开。
如果我理解正确的话,LLVM在引用指针的时候没有类型或对象的概念,只有地址和访问范围。所以我认为这个逻辑是合理的。
LLVM的noalias属性
关于指针别名规则,LLVM的noalias属性文档给我们带来了一些新线索,但同时也带来了困惑:
通过函数的参数和返回值指针访问的内存区域,在函数执行期间不会通过其他指针再次访问。但这个保证只适用于被修改的内存区域。
函数返回值上的
noalias属性表示这个函数类似于系统的内存分配函数,返回的指针指向与调用者可访问的任何其他对象存储分离的已分配内存。
这主要针对函数的参数和返回值指针,并且允许使用可变指针访问数据,即使带有noalias属性,只要这些数据始终只通过基于参数/返回值的指针访问就可以。
语义的歧义
老实讲,这段话的表述相当模糊。举个例子:我有两个可变指针,它们都显然基于同一个&mut army,理论上都可以访问相同的数据。
LLVM的表述可以理解成两种截然不同的意思:
严格理解:如果一个带有noalias属性的指针被传递给函数,那么任何对这个内存的访问都必须在函数内通过对传入指针进行getelementptr运算得到的指针进行。换句话讲,没有其他传入指针、全局变量或其他途径可以访问这些内存——必须把指针全解开才行。
宽松理解:如果把两个带有noalias属性的指针传递给函数,并且它们都指向相同的内存,那么只要它们都显然基于相同的指针,就可以进行访问——如果你很明确地知道它们都是基于相同的指针,就可以进行访问。
这种歧义存在的部分原因是:LLVM文档说noalias属性故意类似于C99中的restrict属性。restrict属性允许编译器在把多个指针传递给函数时缓存内存读取。如果文档明确指出它们是完全相同的,那么悲观(严格)的理解肯定是正确的。但事实并不是这样,那么宽松的理解也可能是正确的。
前提是LLVM足够智能,可以在单个调用的地方进行别名分析,并在适当情况下忽略noalias声明。
可能的解释
这种理解方式可能看起来有点牵强,但考虑到以下几点,我并不认为它完全不切实际:
- 使用
getelementptr的时候,基于关系是明确的 - 整篇文章讨论的基于关系在某种程度上是被跟踪的,并且某些操作会混淆这种跟踪
这最终取决于noalias属性是被视为程序员保证的不变式,还是允许潜在优化的提示。
!noalias和!alias.scope装饰器
最后我们来看一下LLVM中间表示中!noalias和!alias.scope装饰器的说明:
!noalias和!alias.scope元数据允许指定通用的非别名内存访问。这意味着携带!noalias元数据的内存访问指令集可能被明确指定不会与携带!alias.scope、用另一个域的指令集混淆。
这允许定义非参数/返回值指针的选择性别名。添加这些属性可以对指针进行细粒度控制。比如说这个指针可能会与程序中的任何其他指针混淆,但可以保证它不会与这里的另一个指针混淆。
总结LLVM的规则
根据LLVM的说法:
- 指针由地址和它们可以访问的字节数构成
getelementptr只是指针运算,不算作读取或写入(它就是解引用)- 如果只通过一个指针访问,以下所有规则都无关紧要:
- 函数传入指针上的
noalias属性意味着指向的值不会在函数期间通过任何其他内容进行修改 - 返回值上的
noalias属性意味着调用者拿到的指针是完全唯一的
- 函数传入指针上的
- LLVM执行某些形式的别名分析,至少会稍微跟踪基于关系,并在函数内部应用
!noalias和!alias.scope来明确避免混淆
深入LLVM IR
我们之前讨论了很多关于指针和别名规则的理论知识,但这些知识只有在理解Rust怎么样跟LLVM交互之后才能起作用。即使查看汇编代码,也没有办法直接帮助我们,因为这些规则是通过Rust语义和LLVM IR装饰器来体现的。
cargo rustc --emit=llvm-ir工具可以让我们查看Rust代码生成的LLVM IR,包括优化前和优化后的版本。
我们重点关注push_shift_mut函数,因为它是我们绕过借用检查器的关键。但由于优化器的原因,push_shift_mut函数实际上被优化掉了,即使使用了#[inline(never)]属性,所以我们只能查看优化前的LLVM IR。
LLVM IR的观察
LLVM的IR语法确实很独特,不是特别好用。我注意到了一个有趣的事情:函数的返回值实际上是数值的一个复制品,而不是简单的指针转换。我还不确定这个复制操作会不会影响原始指针的来源关系,但我觉得应该不会。
更重要的是,函数的返回类型是nondef nonnull align 8 dereferenceable(24) ptr。可以看到这里面没有noalias标签。这就意味着我们可以忽略返回值的noalias限制。
此外,dereferenceable(24)表示指针可以合法访问的字节数,这里都是24,因为我们的两个unsafe_mut调用都是对向量的引用。
into_iter函数的LLVM IR
在查看优化后的代码之前,我们先看一下into_iter函数的LLVM IR。
这个函数简单地返回了指向数组第一个元素和最后一个元素之后的两个指针。你可以看到army.units在into_iter函数内部被视为noalias,但返回的指针不是。这就意味着LLVM并不认为state不能被其他东西访问——它可以被访问。
reset_speed和effect_apply的调用
接下来我们检查使用基于unsafe_mut引用的函数调用点:reset_speed和effect_apply。
在reset_speed中,我们发现指向army和state的指针都被标记为noalias。这似乎有点违反常理,但仔细分析后,我们发现这并没有违反LLVM的期望。
LLVM把&mut army看作是一个指向144个字节的指针,而&mut state看作是指向88个字节的指针。作为程序员,我们知道这两个指针指向的内存区域完全不同,因为大小都不一样。
可变引用state指向堆上的一个数组元素,而army在栈上——一个在堆上,一个在栈上。虽然state最初是通过指向army.units的指针访问的,但我们确保了在函数执行期间不会修改army.units所占用的内存区域。
reset_speed函数的完整LLVM IR表明:在函数执行期间,state不会被其他指针访问,并且不会修改effects迭代器指向的内存区域。
effect_apply也是类似的情况。
总结与推测
如果我们之前的分析是正确的,并且没有误解任何内容的话,那么我认为这种用法在任何情况下都不会产生未定义行为——也就是说,部分借用不会产生UB(前提是我所有的理解都是对的)。
即使我们采取最悲观的观点,也就是访问&mut foo.bar仍然算作访问&mut foo的话,也很明显:
- 从
reset_speed访问&mut army不会以任何方式跟&mut state重叠 &mut state的分配在堆上,跟&mut army完全分离——一个在堆,一个在栈- 只有在通过
&mut state访问内存的同时,通过&mut army的指针访问相同内存,或者调整向量大小的情况下,才会真正出现问题
推广这种模式
我们可以推广这种模式到结构体:
1 | s.method(unsafe_mut(&mut s.a), &s.b) |
只要你在方法内部不通过&mut s访问a,调用s.method应该不会导致未定义行为。
如果b是一个指向向量的指针,并且传入的是unsafe_mut,那么在方法内部访问b甚至b[x]也应该是安全的,前提是b的大小不变,且x不等于i(即我们修改的索引)。
MIRI编译器似乎也支持这种观点——它只会在故意交错访问&mut s和&s.a时报错。
更复杂的情况
对于b是普通值的情况,问题可能更加复杂。虽然我们违反了指针不重叠的假设,但只要我们小心避免修改重叠的内存区域,可能是安全的(不修改读一下也没关系)。这取决于Rust编译器怎么处理这些情况,以及怎么生成LLVM的中间表示。
可以相对安全地认为:
- 调用
s.method(unsafe_mut(&mut s.a), ...),观察b指向的值,甚至改变s.b的指针都是可以的 - 因为访问
s.b可以视为一种缩小的getelementptr操作
但是,对整个结构体s赋值可能会导致未定义行为。
可能存在一些例外情况,比如s2.a = s.a。但问题在于:如果写入的位跟已经存在的位相同,它能算作内存访问吗?这涉及到更深层次的语义问题,我不想深入探讨。
实用性
不管怎么样,研究LLVM的工作方式非常有意思。在这个过程中,我发现Rust的语言有很多”障眼法”来处理LLVM IR。如果这种技巧确实不会导致未定义行为,那么对于原型开发来说可能会非常实用。但这还只是一个假设,并没有定论——这是我根据我所知道的知识得出的结论。
安全版本的对比
我之前提到过示例函数的安全版本,这些版本使用索引而不是迭代器,避免了长期借用。我们还把state的引用传递给reset_speed,而不是&mut state。
生成的汇编代码和LLVM IR在安全和不安全版本之间差别不大,主要是计数器分配和数组索引处理方面的差异。你会发现它们的差异不大,无非就是计数器分配不太一样而已。
结论
看完这篇文章,我们发现:在正常情况下,部分借用不太会产生UB。当然,前提是我的理解都是正确的。大家可能会有自己的看法。
整理完成! 这篇演讲深入探讨了Rust中部分借用与未定义行为的关系,涉及Rust语义、LLVM IR、指针别名规则等复杂技术细节。演讲者通过分析文档、咨询专家、研究LLVM IR等方式,试图找出明确答案,但最终发现这个问题在语言层面尚无定论,需要更多研究。
无人提及的真实Rust问题
转载自:https://www.youtube.com/watch?v=E5x2I26jE8w
00:00 引言
00:44 Linux内核:Rust开启新纪元?
01:59 GPL的神圣领域:解读冲突根源
06:13 法律雷区:林纳斯·托瓦兹与FSF的立场
07:33 超越代码:社群、文化与妥协
08:56 Linux的未来:Rust的下一步?
10:06 结语
Rust进入Linux内核看似进步,却存在无人提及的法律难题:GPL兼容性。这不仅是技术争论,更关乎开源未来的走向。且看我剖析为何这种整合可能动摇数十年的版权左保护机制。
我来帮你整理这篇关于Rust与Linux内核GPL争议的演讲内容:
无人提及的真实Rust问题
耳语声越来越响亮——Linux内核中的Rust,但对GPL的代价究竟有多大?这不仅仅是关于代码,更关乎开源运动的灵魂。我们将揭开这场复杂辩论的核心,揭示其中的复杂挑战,并呈现可能重塑Linux内核与开源社区未来的解决方案。
别再想当然,你对Rust和Linux的了解远不止表面。一场风暴正在酝酿,一场全面的GPL争议即将爆发,或将重新定义开源协作。让我们揭开这场高风险整合的面纱,揭示对开发者、用户以及整个开源生态的影响。
Rust进入Linux内核的背景
Rust在系统编程中日益流行,最近有人提议将其整合到Linux内核。乍看之下,这似乎是积极举措——Rust承诺提升内存安全性、并发性及整体安全性,可能惠及内核。
但有一个根本细节常被忽视:Rust并非诞生于GPL。
Rust最初由Mozilla开发,采用双许可协议:MIT协议和Apache 2.0。这意味着任何人都可以使用、修改、分发编译器,或整合它,甚至在专有软件中使用,无需copyleft义务。这是有意为之,以最大化工业采用,如同LLVM。
而问题由此开始。
为何引发争论?
为何这项看似积极的发展引发如此激烈争论?要理解这一点,我们需要深入技术特性背后,并考虑影响Linux许可的根本问题——GPL。
核心问题是:能否将Rust真正整合到Linux内核,而不违背GPL的根本原则?更重要的是,是否应该这样做?
GPL的本质
通用公共许可证(GPL)并非普通许可协议,它是Linux内核的基石,及更广泛的开源生态。GPL的核心目标是确保软件保持自由,并共享修改成果至社区。
GPL的核心在于copyleft原则:如果你基于GPL代码创作衍生作品,你的工作也必须遵循GPL协议。这意味着源代码必须在相同许可下可用。
这就是Rust问题的起点。
技术核心问题
现在进入问题的技术核心。请记住Rust的双重MIT和Apache 2.0许可,这就是问题变得复杂的地方。
许可证不兼容
管理Linux内核的GPLv2与Apache 2.0不兼容。
Apache许可引入了额外的专利条款,而GPLv2明确禁止添加额外条件的许可。这是根本性的法律不兼容,不是微不足道的技术细节。
如果Rust模块或标准库包含Apache 2.0代码,即使无意中也是如此,就会产生问题。
GPLv3将解决这个问题,但Linus Torvalds始终拒绝升级内核到版本3,我们只能用v2应对问题。
临时解决方案
一些开发者创建了名为”GPL兼容Rust”的分支:
- 工具链编译时移除了Apache不兼容部分
- 标准库采用更兼容的许可发布
- 或创建仅用于内核的Rust子集
这是Rust for Linux团队正在推进的项目,由谷歌和ARM赞助。但这并非官方Rust版本,是临时性的重构方案,一个方便的分支。
这才是真正的问题所在。
法律雷区
想象这样的场景:
- 你在GPLv2下的Linux内核代码
- 调用一个GPL兼容的Rust模块
- 该模块又使用通过LLVM编译的MIT和Apache混合标准库
- 生成最终应为纯GPL的二进制文件
问题在于:
- 模块与标准库间的GPLv2关系是静态链接还是动态链接?
- 是否在库与LLVM间形成GPL组合作品?
- 哪种许可在编译中占主导?
- 最终二进制是否所有依赖均为纯GPL?
无人能确定,这是法律雷区。
Rust工具链庞大且依赖LLVM。LLVM和Cargo(包管理器)的部分组件不受GPL覆盖,构建过程需极度谨慎。
先例问题
还有最隐秘的风险——先例问题。
若今日允许例外,明日有人会要求为Go或Swift做同样的事,或任何其他现代语言。这将削弱维持生态系统的通用强copyleft原则。
这不是偏执,这是基于数十年自由软件历史的合理担忧。
令人不安的事实
令人不安的事实是:存在一个与GPL兼容的Rust版本专为Linux内核设计,但这是一种临时性重构,并非官方Rust。兼容性仅限技术层面,并非百分之百合法,因为GPLv2对现代语言过于僵化。
我们正在扭曲技术现实以适应未为此设计的法律框架。
怀疑者担心这个临时例外会打开大门,导致内核核心GPL的逐步侵蚀。他们的担忧有道理:
- Rust编译器
- 其标准库
- 模块加载系统
它们如何与GPL的链接和分发要求整合?存在关于Rust可能或可能不适用GPL组合作品条款的不同解读。
这种模糊性危险在于制造法律不确定性,可能分裂社区并打开copyleft漏洞。
我们不能让对现代语言的热情损害数十年GPL保护使Linux成为今日模样的根基。
关键人物的立场
Linus Torvalds的态度
Linus Torvalds一如既往务实,已承认Rust的技术潜力,同时承认许可问题。他的立场似乎是顺其自然,但需谨慎。
但2022年后,在GPL问题上务实是否足够?
Rust已正式进入Linux内核整合流程,但幕后,许可争议从未停息。
自由软件基金会的担忧
自由软件基金会(FSF)——GPL的守护者——持有更严格解读。他们的担忧具体明确:
- 内核被非GPL兼容组件污染的风险
- Rust组件可能规避copyleft意图的可能性
- 通过看似无害的技术例外逐步侵蚀GPL保护,但未来可能成为系统性缺陷
具体的法律问题
这场辩论聚焦具体问题:
- Rust编译器和链接是否构成GPL组合作品?
- 标准库能否被视为免GPL的系统库?
- 动态模块加载是否尊重GPL精神?
这些问题均无明确答案,而模糊性只会削弱GPL保护系统。
超越技术的影响
但这场辩论远超技术与法律层面,我们谈论的是开源社区的未来:
- 社区分裂:Linux社区可能分裂为GPL纯粹主义者与Rust实用主义者
- 开发者犹豫:开发者可能犹豫贡献代码,如果法律不确定性持续存在
- 创新受阻:碎片化可能阻碍创新
对双授权模式的反对
有人提议采用双授权模式或其他变通方案。我持反对意见。为什么?
因为会:
- 制造危险先例
- 削弱copyleft原则
- 为未来妥协敞开大门
今天是Rust,明天就是其他东西。十年后我们或许会发现一个内核,仅在名称上属于GPL。
文化转变的挑战
还有一个更深层的问题——这代表着文化转变。
在嵌入式系统中转向现代编程语言,与现有授权范式产生冲突。问题是:我们是否愿意为技术现代化牺牲根本原则?
答案是:不。
Rust是优秀的编程语言,有优缺点,但不能以牺牲GPL技术优势为代价无条件采用。不要为妥协风险找借口,不要动摇保护自由软件数十年的法律基础。
未来的三种可能性
目前辩论仍在继续,已有新进展,关键人物发表声明,但未达成最终结论。我看到未来有三种可能性:
第一种:全面整合但需让步
Rust被整合,需修改工具链并确保GPL保障。
问题是:双方都需要妥协,这可能永远无法实现。
第二种:有限沙盒方案
仅在特定领域使用Rust,与核心内核隔离。
问题是:这大幅限制了技术优势。
第三种:完全拒绝
Linux社区认为GPL风险过高。这也是我的倾向。
如果我们无法保证GPL保护,这是唯一可行路径。
深远影响
无论最终决定如何,影响将远超Linux本身:
- 将影响其他开源项目处理现代语言的方式
- 可能重新定义现代编译语言时代的GPL解释
- 并将塑造开源授权的整体未来
核心观点总结
无论你对此有何看法,无论我的个人观点如何,有一点是事实:整个故事极其复杂,远超Rust或C的技术特性。
这关乎完全不同的层面——这可能动摇我们整个生态系统的一个核心支柱。Copyleft就像第四修正案或言论自由的权利一样重要。这远比表面看起来严重得多。
这不是nerd、YouTuber和程序员之间关于编程语言和代码的争端,这触及生态系统本身的存续与进化。
我的立场
Rust是一种有优缺点的编程语言,它并非万能良药,绝不能以损害GPL为代价。
GPL绝非单纯的许可证协议,这是价值观的宣言。它代表了数十年为保持软件自由、可访问并防止私有化的斗争。
我们不能被技术炒作蒙蔽双眼。在热烈拥抱Rust之前,必须确保整合的每个环节既尊重字面条款,更尊重GPL的精神。
Linux的未来容不得仓促妥协。
结语
你的立场是什么?
你认为Rust的技术优势能否正当化GPL的风险?
还是你认为,正如我所坚信的那样,我们应始终优先保护开源的根本原则?
整理完成! 这篇演讲深入探讨了Rust整合进Linux内核所面临的GPL许可证兼容性问题,强调这不仅是技术问题,更是关乎开源运动核心价值观的根本性争议。演讲者明确表达了反对在损害GPL原则的前提下整合Rust的立场。
【组内分享】Rust Pointer
Rust 指针与内存安全机制分享
核心主题
本次分享主要探讨 Rust 如何提供内存安全保障,以及智能指针的实现机制。内容主要来源于 GitHub 上的相关资料。
Rust 提供的安全保障
编译期安全机制
Rust 在编译层面提供的明确安全保障其实很少,主要包括:
- 变量必须初始化后才能使用
- 引用是安全的
- 指针引用指向的变量必须是已初始化的
- 默认私有访问控制
- 严格的类型系统及 trait 约束
Unsafe 代码
需要显式标记为 unsafe 的操作包括:
- 裸指针操作
- FFI (外部函数接口)
- 嵌入式代码
- 以及包含以上成分的所有代码
安全工具
Rust 提供的安全工具包括:
- 所有权系统
- 生命周期
- Drop trait
- 自动解引用(点操作符会自动添加引用或解引用)
实现内存安全的基础设施
封装模式
实现安全内存管理的关键模式:
- 将原始变量封装在类型结构中
- 封装类型拥有原始变量的所有权
- 对封装类型实现相应 trait
- 通过生命周期在适当时机清理资源
复杂操作的处理
复杂操作通常需要额外的封装类型结构。例如 RefCell 的 borrow() 返回的不是直接引用,而是专门的 Ref 结构体,用于管理借用状态。
智能指针实现层次
底层:裸指针 (*const T / *mut T)
裸指针是最底层的类型,负责:
- 内存块操作
- 显式拷贝操作
- 指针偏移操作
- 裸指针类型转换(重要)
- 数字类型转换为指针类型(重要,嵌入式和内核开发必需)
MaybeUninit
MaybeUninit 允许在未初始化内存上进行安全操作:
- 使用
#[lang]属性标记,编译器会特殊处理 - 使用
#[repr(transparent)]确保类型布局与其唯一字段一致 - 避免在未初始化内存上的未定义行为
NonNull 与 Unique
NonNull:
- 结构中只包含一个
*const T - 语义上保证非空,但实际不由编译器保证
- 使用时需要用
Option包装处理安全逻辑 - 是协变的
Unique:
- 比 NonNull 多了
PhantomData,让编译器知道指针所指内存被该 Unique 独占 - 保证唯一性
- 提供协变性,供上层智能指针使用
跨函数传递建议
跨函数使用指针时,最好不要直接传递 *const T 或 *mut T,而应转换为 NonNull 或 Unique,以获得更好的类型安全保障。
RefCell 机制
RefCell 的核心是其 trait 实现:
borrow()返回Ref类型borrow_mut()返回RefMut类型- 通过
BorrowFlag在运行时检查借用规则 Ref和RefMut是专门的结构体,用于确保生命周期不重合
Box 的实现
在当前项目中,Box 的实现流程:
- 调用
try_new - 从分配器获取内存
- 创建
MaybeUninit - 使用
write()写入数据 - 调用
assume_init()完成初始化
这套逻辑对于将 C 代码迁移到 Rust 很有参考价值,特别是处理先分配内存后初始化的场景。
内存分配器
项目中的内存分为两块:
- Linux 内存
- VShell 内存(用于用户态和内核间通信)
实现 Allocator trait 主要需要实现:
allocate()deallocate()
其他函数都有默认实现,都通过这两个函数实现。使用 Box 配合自定义分配器可以保持 Rust 风格的代码,且零成本抽象。
Arc 的使用注意事项
当前项目中的问题
代码中使用了 from_raw() 和手动的 strong_count / weak_count 管理,容易出现问题:
发现的 Bug:
- 使用
from_raw()获取线程指针后 - 没有调用
into_raw()或increment_strong_count() - 函数结束时触发了 Arc 的 Drop
- 导致后续
from_raw()时只有数据内存正确,其他内存未初始化
改进建议
Factory 的 build 和 dispose:
- 在
build()函数中创建 Arc 后立即调用increment_strong_count() - 后续所有操作使用正常的 Rust 风格(clone、正常传递)
- 避免使用
from_raw()和into_raw() - 在
dispose()时调用对应的decrement_strong_count()
线程管理:
当前方案是将 SpinLock<ThreadInfo> 存储在线程结构中,建议改为:
- 直接存储
Arc<ThreadInfo> - 创建 Arc 后调用一次
increment_strong_count()保持其存活 - 所有使用处直接传递 Arc,不需要
from_raw() - 减少大量的 unsafe 代码和类型转换
优势:
- 从手动引用计数管理改为编译器检查
- 只需确保 build 和 dispose 配对
- 其他地方使用标准的 clone 和 drop
- 显著减少 unsafe 代码(约 1/3)
总结
Rust 的内存安全主要通过:
- 最小化的编译期安全保证
- 明确标记的 unsafe 边界
- 层层封装的智能指针体系
- 所有权、生命周期和 trait 系统的配合
实现安全的关键是正确使用这些基础设施,特别是在 C 与 Rust 混合编程时,要谨慎处理原始指针和智能指针的转换,优先使用 Rust 的类型系统而非手动管理。
rust-async-初探
Rust 异步编程初探
一、异步编程的必要性
传统并发的问题
线程并发的缺陷:
- 不可避免的内核态和用户态切换
- 内核线程栈的内存开销
- 资源消耗大
用户态并发的优势:
- 相当于从进程到线程的演进,再轻一级
- 基本都是对任务进行切分和调度
- 不需要内核态切换
并发调度方式
协作式调度:
- 任务执行一定时间后,主动将控制权交给调度系统
- 系统再进行调度
抢占式调度:
- 各个任务不知道其他任务的存在
- 任务可能被抢占,但对任务本身是透明的
二、有栈协程 vs 无栈协程
有栈协程(如 Go)
实现方式:
- 在用户态也有类似线程的协程栈
- 比内核态线程轻量,但仍有栈开销
Rust 早期的尝试:
- 早期 Rust 有”绿色线程”版本,采用有栈协程
- 当时考虑的核心问题:能否比操作系统线程更优?
有栈协程的实现方法:
分段栈(Segmented Stack):
- 一段一段地扩展栈,类似链表
- 基本没有太大开销
- 问题:热调用函数频繁进入退出时,会反复分配和释放新段
- Rust 早期采用此方法
栈复制(Stack Copying):
- 当栈不够用时,重新申请更大的栈
- 将当前栈内容复制到新栈
- 问题:开销较大
为何放弃:
- 需要调整每段栈的大小(类似调参)
- 即使调得好,也和操作系统线程没有太大区别
- 没有明显优势,最终被移除
无栈协程(Rust 当前方案)
Rust 现在采用无栈协程的设计,基于 Future 和状态机。
三、Rust 异步的发展历史
Future 1.0 - 回调风格
最初设计:
- 定义一个
schedule,可以注册回调 - 轮到任务时执行回调
问题:
- 组合两个 Future 时,需要同时拥有两个 Future 最后完成任务的回调
- 需要额外申请内存空间
- 层层嵌套会有不可避免的开销
- 社区无法接受
借鉴 C 的状态机方案
转向 C 开发者的实现方式:
- C 开发者原生没有语法支持
- 使用状态机处理非阻塞 IO
新的 Future 设计:
- 基于
Poll状态:Ready或Pending - 由外部执行器轮询所有 Future
- 开发者可以实现一系列 Combinator 来组合 Future
Future 1.0 的所有权问题
问题场景:
1 | foo.bar().and_then(...) |
- Future 需要从当前上下文中逃离(因为是异步的)
- 链式调用会有所有权问题
解决方案:
- 必须用
Arc<Mutex<T>>包装 - 大家觉得很麻烦
四、当前的 Future 设计
Future Trait 定义
1 | trait Future { |
核心组成:
Output:关联类型,表示 Future 最终输出的类型
Poll 方法:需要外部执行器轮询所有 Future,推进执行
Pin:固定内存位置,防止移动
Context:包含 Waker,用于通知
Pin 的引入原因
为什么需要 Pin:
async/await语法会自动生成匿名的 Future 结构体- 编译器自动实现 Future trait
- 如果方法传入引用,生成的结构体中会有引用字段
- 可能导致自引用产生
Pin 的作用:
- 编译器开发者知道某些匿名结构体不会 move
- 但 Rust 编译器需要保证每个对象都可以安全 move(这是 Rust 的语言保证)
- 引入 Pin 告诉编译器:”这个地方不会 move,没事”
- 避免打破 Rust 的语言保证
Context 和 Waker
Context 的作用:
- 核心是包含一个 Waker
- 为未来留下足够冗余(如果完全确定,其实只有 Waker 有用)
Waker 的作用:
- 用于通知某个组件
- 是 Reactor 和 Executor 之间的桥梁
五、Reactor-Executor 模式
为什么需要异步
核心问题:解决 IO 阻塞
- 如果没有 IO 阻塞问题,单核纯计算任务完全不需要异步
- 每一步计算都是必要的,没有等待时间
- 多核计算任务会用多线程,而不是异步
Reactor 和 Executor 的职责
Reactor(反应器):
- 检测 IO 是否完成
- 如果完成就通知 Executor
Executor(执行器):
- 接收 Reactor 通知
- 继续执行任务
它们的桥梁:Waker
Waker 的设计
Waker 结构:
1 | pub struct Waker { |
RawWaker 的虚表:
定义了四个方法,实现异步库时需要实现:
clone:
- 一个任务可能需要多个 IO 源
- 例如:同时监听文件和网络 IO
- 每个 IO 源对应一个 Reactor
- 需要将 Waker 分发给多个 Reactor
wake 和 wake_by_ref:
- wake 会 consume 掉 Waker
- wake_by_ref 不会
- 推荐使用
waker.clone().wake()的方式 - 将任务重新挂到 Executor 的队列中
drop:
- RawWaker 中有 data 指针(类似
*const ()的感觉) - 需要手动清理内存
- 必须提供 drop 方法清理 data 中的内存
- RawWaker 中有 data 指针(类似
六、Leaf Future 和 Non-Leaf Future
Non-Leaf Future
定义:
- 由
async/await关键字生成的 Future - 没有阻塞操作
- 除了
await之外的代码都是非阻塞的
作用:
- 通过编译器构建 Future 树结构
- 将 poll 方法层层包裹
- 将 Context 从根节点往下传递
Leaf Future
定义:
- Future 树的叶子节点
- 真正对 IO 资源进行非阻塞访问的 Future
- 需要手动实现
由 Runtime 提供:
- 例如 Tokio 实现了对文件、网络 IO 的非阻塞访问
- 如果 Runtime 没有实现 Leaf Future,整个异步系统没有意义
- 只会把同步代码重新执行一遍
async/await 关键字
async 的作用:
- 为代码块或函数生成匿名结构体
- 编译器自动实现 Future trait
- 实现 poll 方法
await 的作用:
- 表示代码形式上立即想拿到结果
- 但执行过程中,如果资源未就绪,Future 会被移出调度队列
- 不是真的等待,而是让出控制权
嵌套结构:
- 如果代码块中有
await,说明前面是 Future - 会形成嵌套的 poll 调用
- 形成树形结构
七、Runtime 需要实现什么
Runtime 的两大核心组件
Executor(执行器):
- 接收 Future 并执行
- 轮询 Future 的 poll 方法
- 管理任务队列
Reactor(反应器):
- 对阻塞资源的非阻塞实现
- 作为 Leaf Future 的根节点
- 监测 IO 就绪状态
实现 Leaf Future 的方法
Linux 平台:
- 使用 epoll 或 io_uring
- 因为 Linux 的”一切皆文件”,所以大部分资源都可以用统一方式处理
其他方式:
- 定期轮询
- 如果在内核中实现,可能只能用轮询方式
各家实现不同:
- Rust 标准库没有限制实现方式
- 各个 Runtime 库(如 Tokio)的实现完全不同
八、Demo 代码解析
简单的 Executor 实现
1 | fn spawn(future: impl Future<Output = ()> + Send + 'static) { |
工作流程:
- 接收一个 Future
- 创建新线程执行
- Pin 住 Future
- 创建 Waker(包含当前线程的 handle)
- 创建 Context(包装 Waker)
- 循环轮询 poll 方法
- 如果 Ready 则退出
- 如果 Pending 则
thread::park()(让出 CPU)
简单的 Waker 实现
1 | struct Waker { |
工作原理:
- 存储线程 handle
- wake 时调用
thread::unpark()唤醒线程
简单的 Reactor - 定时器示例
1 | struct Timer { |
工作流程:
- 检查是否超时
- 如果超时,返回
Poll::Ready(()) - 如果未超时:
- 从 Context 中取出 Waker
- 创建新线程睡眠一段时间
- 睡眠结束后调用
waker.wake()唤醒执行器
依赖操作系统调度
注意:
- 这个 Demo 实现依赖于 LINUX 系统提供的 thread 机制
- 是一对一的模型(一个 Future 一个线程)
thread::park()让线程睡眠,操作系统负责调度- 自己没有做调度逻辑
真实的 Runtime:
- 不会为每个 Future 创建线程(开销太大)
- 会有复杂的任务队列和调度逻辑
- 使用 epoll 或 io_uring 等机制
九、Rust 内核中的异步实现
9P 文件系统的案例
实现方式:
- 在
rust/kernel/下新增osync模块 - 实现简单的执行器
- 对接传统的 work queue
- 向上提供 Rust 语义的异步 Runtime
意义:
- 让函数看起来更清晰
- 避免回调地狱
- 线性的写法,而不是嵌套的回调
内核中的限制
没有 epoll:
- 内核中无法使用 epoll
- 只能使用轮询方式
- 可以有一个内核线程轮询各个驱动或资源
十、异步编程的优势和应用场景
适用场景
IO 密集型任务:
- 有多个 IO 资源需要监听
- 单线程监听多个资源,效率不下降
- 减少线程数量,降低资源占用
在项目中的应用体会
CAN 和串口通信项目:
- 监听串口和 CAN 口
- 使用一个线程监听所有资源
- 好处:
- 省 CPU 资源(最多占用一个核)
- 省内存资源
- 如果多个监听任务崩溃不会互相影响
局限性:
- 如果只有一个 IO 资源,体感不明显
- 没有多个 IO 切换,感受不到优势
与用户态线程库的相似之处
与协程库的对比:
- 结构上有相似之处
- 都涉及任务调度
- 从一个任务执行完,选择下一个任务
- 区别:
- 异步是无栈的
- Runtime 需要提供
async/await关键字 - 提供异步调用的语法糖
十一、学习资源推荐
核心资源
- Rust 异步编程发展历史博客(2023 年开发者撰写)
- Small async Runtime 示例
- Async TD
- 各种 Runtime 库的源码(如 Tokio)
学习建议
博客系列:
- 适合写成博客系列,不适合视频分享
- 可以补充更多细节
- 提供参考资源链接
代码阅读:
- 阅读小型 Runtime 实现(一两个文件)
- 理解基本结构后再看大型库
- Tokio 等库太大太复杂,需要逐步学习
十二、总结
核心概念
- Future:异步任务的抽象
- Poll:驱动 Future 执行的方法
- Pin:防止自引用结构体被移动
- Context 和 Waker:Reactor 和 Executor 的桥梁
- Leaf Future:真正访问 IO 资源的 Future
- Non-Leaf Future:由 async/await 生成的组合 Future
Runtime 的组成
- Executor:执行器,轮询 Future
- Reactor:反应器,监测 IO 就绪
- Waker:通知机制
异步编程的价值
- 解决 IO 阻塞问题
- 用少量线程处理大量 IO 任务
- 代码更清晰,避免回调地狱
- 降低资源消耗
注意事项
- 真正实现 Runtime 会遇到很多并发冲突问题
- Future 的 poll 方法和 Reactor 可能有并发冲突
- 需要仔细处理同步问题
[最后一次入门Rust]#3:函数栈帧与Box智能指针
我会帮您整理这段关于Rust函数栈帧与Box智能指针的内容,保持所有原有信息,使其更加通顺易读。
[最后一次入门Rust] #3:函数栈帧与Box智能指针
str与String的区别
str不可以进行扩容,而String可以进行扩容。比如将此处的s设为可变,我们可以使用push_str来追加一段文字。在修改前和修改后分别输出s,可以看到修改前后字符串的内容是发生改变的。
这也就是说,String这个类型它的长度是完全不固定的。这种类型称为动态大小类型,简称DST(Dynamically Sized Type)。
栈区与堆区
Rust对于这种大小无法预知的情况,无法把它直接放到栈帧里面,于是就要用到另一个区域——堆区。
堆区可以自由存储任意大小的数据。程序可以自由申请指定大小的空间,并自由向空间内存储任意数据。而且,对于一块已经申请过的堆区内存,还可以对它进行扩容和缩容的操作。这样我们就可以把动态大小类型存在堆区里面了。
通过指针访问堆区数据
但是,我们编写的代码大部分都在函数里面,而函数都在栈区的栈帧内部执行。如何在栈区访问到堆区的内存?这就需要通过指针。
如图所示,栈区的变量s,它通过一个地址指向了堆区的字符串”world”,这样就可以在栈区访问到堆区的数据了。
变量s本质不是一个字符串,而是一个栈区的指针。在这个指针s内部存储了堆区数据的相关信息,比如说字符串的长度以及字符串所在的地址。
刚刚的图片实际上是简化版,实际情况是这张图片:在栈区中,s是一个指针,内部存储了ptr和len。ptr表示堆区数据的地址,而len表示字符串的长度。像这样携带有其他相关数据的指针称为胖指针。
动态大小类型的封装
我们用的绝大部分动态大小类型,实际上都已经封装好了。实际操作的是指针,间接才能操作到真正的数据。比如说String。
也就是说,刚才在main函数中声明的这个String变量s,本质是一个指针,而不是真正的这个字符串”world”。这个”world”被它隐式地开辟到了堆区。包括后面对字符串扩容,也是在对堆区的字符串进行扩容。
Box智能指针
那么我们要如何自己把一个数据开在堆上呢?Rust提供了一个最基础的工具——智能指针Box。
Box的作用就是把一段数据放在堆区存储,同时在栈区留下一个指针。通过Box,我们仍然可以像使用普通变量一样方便地去访问里面的数据。此处尖括号<T>表示一个泛型,可以把任意类型的数据通过Box放在堆上。
比如说,2025本身是一个i32类型的字面量,我们可以通过Box::new把它开在堆区,随后留下的变量p就是指向堆区数据的指针。
如图所示,在栈区留下了p指针,而p指针指向了堆区的数据2025。
使用Box指针
那么要如何使用这个p?可以通过一个星号*,也就是解引用操作符来访问堆上的数据。
比如将p设为可变:
- 你可以正常地读取它
- 你也可以对它进行修改
可以看到它正常地作为一个i32类型进行输出了。通过解引用p,你既可以对它进行赋值操作,你也可以把它当成一个只读的值进行输出。
内存泄漏问题
现在有这样一段代码:这段代码在main函数中调用了func函数,而func函数将数据2025开在了堆区,并将指针p留在func的栈帧内部。
如图所示,在第二步进入func栈帧后,p的指针指向了堆上的数据2025。但是当func函数返回,那么func函数的栈帧就会被销毁。此时回到main时,栈帧已经没有任何指针指向堆区的数据2025了。
也就是说,现在我们无法通过任何手段访问到这个数据,也无法通过任何手段对它进行释放。那么像这样一段永远占用内存的位置,但是我们无法使用的空间,就叫做内存泄漏。
堆区的内存分配,不论是申请还是回收,都需要用户自己操作。如果你学过C语言,应该深有体会。但是我们在这里并没有回收堆区的数据,而是等到变量p被销毁,那么堆区的数据2025就永远无法访问了。在C/C++中,这是很常见的错误。后来C++用RAII机制大幅缓解了这个错误的可能性。
RAII机制
其实,刚刚这个情况在Rust中不会发生,因为Rust学习了RAII的思想。
智能指针Box自带RAII机制:当一个智能指针在栈区的变量被销毁时,会自动把它指向的堆区数据一起销毁。
可以理解为,智能指针把一个堆区数据的生命周期与栈区的变量绑定在了一起。当栈区的变量被销毁,堆区的数据会随着一起销毁。这个机制就叫做RAII(Resource Acquisition Is Initialization,资源获取即初始化)。
这种机制的逻辑性在于:如果堆区的数据还在,那么说明一定有栈区的智能指针指向它,那么这个堆区的数据就还有被使用的可能。
[最后一次入门Rust]#10:Trait 抽象接口与泛型约束
原文链接: https://dashen.tech/2018/08/22/Rust视频转文字汇总/
版权声明: 转载请注明出处.