跳转到主要内容

热门内容

今日:


总体:


最近浏览:


Chinese, Simplified

 

这是关于我们在dwell如何在Rust中重写物联网平台的系列文章的第3部分。

所以现在我已经彻底,也许不公平烤几个设计缺陷的一种编程语言超过四十岁,经营着世界上大多数嵌入式设备,让我们来谈谈如何锈设计出这些问题,同时仍然保留了C和c++的部分,让他们强大的和有用的语言。

注意:具体来说,我将在这里讨论“安全”生锈。你仍然可以使用unsafe关键字跳过护栏。但通常大多数代码都不需要这样做。

代数数据类型

“代数数据类型”是描述枚举类型的一种奇特方式,枚举类型是完全集成的,实际上是正常的和安全的,并且允许语言规范强制执行最佳实践。在经典语言的现代版本中,它们是一个常见的特性,因为它们具有关于正确性的有用属性。这是Scala、Kotlin和Swift等语言相对于Objective C和旧版本Java的优势。代数类型有点像类固醇上的C枚举:Rust枚举可以包含数据字段。它们也类似于C并集,因为它们只占用最大字段的空间(在大多数情况下,加上一个鉴别器)。但与union不同的是,您不会意外地将字段的字节误读为错误的变体。

因为这是一个有点抽象的概念,所以最好使用两个通用类型来解释它的实用程序,它们是核心语言的一部分,并且到处都在使用:Result和Option。

Result是一种类型,既可以是成功值,也可以是错误值。C函数通常会返回一个可能为负的int值或一个可能为0的文件句柄,而Rust的做法不同。

use std::fs::File;
use std::io::prelude::*;fn open_with_header() -> Result<File, std::io::Error> {
    let mut file = File::create("foo.txt")?;
    file.write_all(b"Header line\n")?;
    Ok(file)
}

create的返回类型是一个包含文件句柄或错误的单数项。甚至在您可以使用文件句柄之前,您必须解包结果类型并对潜在的错误进行处理。在上面的例子中,?操作符在出现错误时从函数中提前返回。如果成功,包装好的文件将被解包到File变量中,我们可以使用它。注意,write_all调用也会返回错误,我们必须处理它。同样,这个例子使用了?操作符,因为作者想用一个早期的返回过滤该错误。我们可以很容易地打印一个错误消息并跳过文件操作,或者提供一个替代的默认值,甚至惊慌失措并立即停止程序。但我们不能就这样无视它。

fn frob_widget() -> Result<(), SomeErrorType> { ... }
frob_widget(); // Compiler warning
frob_widget().unwrap(); // Halts with a stack trace on failure

对于在正常情况下不返回任何内容的函数,代码可以表示可能发生错误并必须处理。

Option表示某物可能存在也可能不存在的情况。假设您要求一个键/值存储(一个字典或一个映射,取决于您在哪里学习这个概念)返回并删除与键相关联的值。如果键在存储中,函数应该返回值。如果不是,函数应该返回没有值的值。对于结果,你不能只是假设它存在然后使用它。这里有一个例子:

use std::collections::HashMap;let mut map = HashMap::new();
map.insert(1, "a");
assert_eq!(map.remove(&1), Some("a"));
assert_eq!(map.remove(&1), None);

在我们删除字符串之后,它就不在映射中了,所以第二次尝试删除它将返回None。

没有神秘的指针

Rust为了引用而放弃指针。通过围绕引用的一组聪明的设计决策,safe Rust消除了普遍存在于C和c++程序中的“神秘指针”问题。

默认情况下使用常量

在C语言中,变量和函数参数默认情况下是可变的,const关键字用于限制可变性。在Rust中,情况恰恰相反:变量和函数参数默认情况下是const,您必须添加关键字来表示不是const。这有一个非常微妙的影响,即不鼓励带有副作用的代码,并促进具有较少移动部件的编码风格。如果您的代码在不必要的时候使用mut关键字,编译器会生成一个警告。

构建和return-by-move(Build and return-by-move)

将未初始化指针传递给函数来存储结果是C中的常见做法,但这也是传递要在适当位置读取和修改的结构体的标准方法。这造成了一些输入和输出的混合,并且允许在某些场景中使用有效数据编写“输出”指针,而在其他场景中不进行初始化。例如:

/* Modifies an entity position and returns nonzero on error. */

/* Writes the Cartesian distance changed into distance */

/* if the object could be moved. */

int move(obj_t *obj, double *distance, const vec_t *v);

在Rust中,规范的例子使意图更加清晰,并防止指向未初始化的double对象的悬空指针:

/// If successful, returns distance moved

fn move(&mut self, v: &Coordinates) -> Result<f64, ErrorType> { ... }

References always point to something

在Rust中,一个引用总是指向一个实际的t。就像c++引用一样,一个Rust引用不能为空——在安全的Rust范围内,不可能故意或无意地创建一个指向“null”或一个尚未创建的结构体的引用。如果只有一个对对象的引用,那么也没有办法释放对象。此外,还有另一个聪明的特性允许该语言提供更强的保证。

Rust引用具有生存期。这是《Rust》真正独特的地方,而且这个想法有最大的学习曲线。在编译时,这种对语言的添加保证了没有办法从引用中释放或移动对象——如果您试图以一种可能危及这种保证的方式对潜在的引用对象做一些事情,程序将无法编译。这种保证甚至跨线程也适用。永远和免费使用问题说再见吧!

不需要空指针

在C/ c++中,你想要传递一个指向可选数据的指针,例如,一个可能指向或可能不指向某物的指针,用例是什么呢?在这些语言中,您将传递一个指针参数,然后(希望如此)函数实现将在使用它之前检查是否为空。在Rust中,选项<&T>是安全的选择。Rust内部使用指针来表示它的引用类型,所以在那些指针值不是0的计算机上(例如Rust支持的架构),编译器将优化选项<&T>的实现,以避免枚举的任何大小惩罚。如果你真的对细节感兴趣,你可以深入了解这个主题。

总而言之:对于不需要动态调度的T,选项<&T>生成的机器码与正确的null检查的C指针相同。它是更安全。

Slices, not pointers

C中的数组只是带有特殊语法的指针。如果API文档不清楚,这可能会导致各种混乱。在Rust中,对单个对象的引用具有不同于复合类型的语法,因此这两者不会意外混淆。

对于复合类型,Rust为可变大小的数组(vec)、固定大小的数组和连续数据的“切片”提供了不同的类型。这些复合类型都天生知道它们的大小,并通过函数范式和命令式循环支持迭代。如果在Rust中使用数组索引访问复合类型,则在运行时对访问进行边界检查。这使得不可能无声地溢出缓冲区。(你可以通过使用迭代器来完全避免这种检查。)

总之:《Rust》中的参考(references )是可以预测的

当阅读我自己或其他人的生锈代码时,引用的这些属性使我作为程序员能够更好地假设函数调用的两端是什么。如果我调用一个函数,它返回选择<科技>函数是告诉我它会返回什么,我必须明白参考使用寿命有限,而且指向不可变数据,我可以立即调用功能,甚至可以克隆对象,但我不能修改它。另一方面,选项<&'static T>的返回值表明,如果返回的引用存在,则保证在程序的整个执行过程中是有效的。如果一个函数接受String作为参数,这意味着该函数将使用该字符串而不返回它。我可以安全地将数组的一部分作为不可变片传递,而不必担心缓冲区溢出,而且签名使我确信函数不会尝试修改或释放内存。指针的所有功能和灵活性都是存在的,但未定义的行为是设计出来的。

安全的转换规则(Safer casting rules)

在给定的平台上,u64和usize在内存中可能有相同的表示,但实际上它们是需要显式转换的不同类型。在大多数情况下,这消除了64位的可移植性问题——显式强制类型转换在代码审查中很显眼,而不是潜伏在普通的数学表达式中间。这鼓励每个人从一开始就使用正确的类型。如果有舍入错误,有一个明显的地方开始调试。

我不会撒谎说隐式类型转换已经完全消失了。仍然有一些无声的转换可以发生在“引用到引用到T”(这通常会消除混乱的地方,只有一种明智的方式来做事情),但大多数情况下,很少有魔法发生。

线程安全

Image for post

在类型系统中存在生存期,这使得编译器可以防止您意外地使用引用做一些愚蠢的事情。如果试图在线程之间将一个裸引用传递给一个堆分配或堆栈分配的变量,这是一个编译错误,并且会提醒您将对象包装在一个原子引用计数器(Arc)中,以防止使用后使用的可能性。如果至少有一个持有该引用的线程需要写访问,那么这个对象需要被包装在互斥锁或RwLock中,以避免数据竞争。与其他一些语言不同的是,锁完全包装了原始对象,因此不可能在不获得锁的情况下意外地访问它。

如果您只是需要一个线程安全的队列,可以使用内置的性能。创建一个mpsc,将接收端移动到另一个线程中,这样就完成了。在工作中使用合适的工具很容易,而且很有效。

如果所有这些听起来都非常复杂,那是因为正确执行线程实际上是非常复杂的。如果您在c++中使用任何类型的共享状态进行线程化工作,并且不是什么天才,那么您可能会犯至少一个微妙的错误。如果你在一个团队中编写一个多线程应用程序,你最好希望每个接触代码的人都能始终如一地遵循你所能想到的最严格的代码指导方针——即使这样也不能保证每个部分不会完全对齐。但在Rust中,当线程代码编译时,有强大的正确性保证。它保证在你的代码和你接触的所有其他生锈的代码中不存在数据竞争。Rust团队称这个概念为“无畏的并发”,经过几十年的追踪线程bug,我发现它令人难以置信的解放。

您仍然可以在生锈中编写不可维护的代码。您仍然可以编写带有bug和死锁的代码。但这种语言会温和地引导您找到干净、易读的解决方案。结果,很多精神包袱都消失了。

在dwell,我们想为我们的智能公寓物联网平台建立一个可靠的嵌入式系统。我们希望它能被普通人维护,它需要快速,并且我们希望避免在初始实现中普遍存在的线程安全问题。我们选了拉斯特,这是正确的决定。

在下一个系列中,我们将开始深入我们的实际实现的核心,并详细介绍我们在哪些方面遇到了困难(并希望避免其他人做同样的事情)。

 

原文:https://medium.com/dwelo-r-d/designing-around-our-flaws-e0fccd7070af

本文:http://jiagoushi.pro/node/1440

讨论:请加入知识星球【全栈和低代码开发】或者微信【it_training】或者QQ群【11107767】

最后修改
星期四, 一月 5, 2023 - 21:56
Tags
 
Article