Rust 语言的人体工程学倡议

2017 年 3 月 2 日 · Aaron Turon

为了帮助实现我们 Rust 2017 年愿景,Rust 子团队正在启动针对特定路线图目标的倡议。本文介绍了语言团队的主要倡议:改进核心语言的人体工程学。 目标是通过简化功能和淡化无关细节来提高生产力并降低学习曲线。

人体工程学

人体工程学衡量的是你在使用工具完成任务时遇到的阻力。 你希望达到一种“流动”状态,在这种状态下,想法和直觉能够以最小的麻烦转化为可工作的代码。(而且,对于 Rust,我们希望这些代码也可靠且快速。)这种体验的最大威胁是中断,它以多种形式出现:查找信息、切换上下文、进行大量仪式或处理大量错误,编译器在其中吹毛求疵地指出你忘记的小事。任何将你的注意力从手头的任务上转移到无关紧要的细节(或暂时无关紧要的细节)上的事情。

一个推论是,人体工程学很少与原始字符数有关。当你拥有良好的势头时,在键入posposition之间并没有那么大的区别;更重要的是记住要键入哪个更容易。同样,在match模式中键入ref,或使用*来取消引用&i32很容易;另一方面,知道或记住要键入它们......

作为专家,你必须记住的事情,也是新手必须学习的事情。人体工程学的改进让每个人都过得更好。

通常,问题的核心是什么应该隐式的问题。在本文的其余部分,我将介绍一个用于思考这个问题的基本框架,然后将该框架应用于 Rust 的三个领域,分析当前设计以及我们今年可能想要考虑的一些简化。

隐式与显式

当信息被暗示但没有表达时,它是隐式的。 人体工程学的潜在优势很容易看到:强迫你写下显而易见的信息(因为它已经被暗示)很痛苦,因为它会增加令人分心的噪音,而且很容易忘记。允许你将其保留为隐式可以减少这种摩擦。然而,隐式性却声名狼藉,Python 甚至将“显式优于隐式”作为核心设计规则。为什么呢?

隐式性可能非常强大。毕竟,编译器对你的代码了解很多,并且可以利用它以各种微妙的方式注入行为。如果过度使用,这些技术会损害可读性,或者更糟糕的是:引入令人惊讶的行为,这些行为可能难以追踪,因为它是微妙的推理链的结果。如果你亲身经历过这些陷阱,很容易得出隐式性本身就是罪魁祸首的结论。

但我认为,这是对问题的一种误诊,一种将孩子和洗澡水一起倒掉的行为。根本问题是:你需要多少信息才能自信地理解特定代码行在做什么,以及这些信息有多难找到? 我们称之为代码的推理足迹。上面的陷阱来自推理足迹失控,而不是隐式性本身。

那么,可读性是否要求我们最小化推理足迹?我不这么认为:如果将其缩小到太小,代码就会变得过于冗长,难以阅读,因为所有信息都始终强制显示。我们想要的是一个最佳点,在该点上,常规或易于查找的细节可以省略,但相关或令人惊讶的信息可以保持在首位。

如何分析和管理推理足迹

隐式性的推理足迹有三个维度

  • 适用性。你可以在哪里省略隐式信息?是否有任何提示表明可能正在发生这种情况?

  • 力量。省略的信息有什么影响?它会彻底改变程序行为或其类型吗?

  • 上下文依赖性。你需要了解代码的其余部分多少才能知道正在暗示什么,即省略的细节将如何填充?是否总有一个明确的地方可以查看?

本文的基本论点是,隐式功能应该平衡这三个维度。如果一个功能在一个维度上很大,最好在另外两个维度上严格限制它。

Rust 中的 ? 运算符 是这种权衡的一个很好的例子。它明确地(但简洁地)标记了一个点,你将在该点上退出当前上下文以处理错误,可能在途中进行隐式转换。它被标记的事实意味着该功能的适用性受到严格限制:你永远不会对它发挥作用感到惊讶。另一方面,它相当强大,并且在一定程度上依赖于上下文,因为转换可能取决于使用?的类型以及它跳转到的范围中预期的类型。总而言之,这种谨慎的平衡使 Rust 中的错误处理感觉起来像使用异常一样符合人体工程学,同时避免了它们的一些众所周知的缺点。

相比之下,像不受限制的隐式转换这样的功能理所当然地声名狼藉,因为它具有普遍适用性、相当强大,并且依赖于上下文。如果我们要扩展 Rust 中的隐式转换,我们可能会限制它们的力量(例如,将它们限制为AsRef样式的强制转换,这些转换几乎无法做到)。

严格限制上下文依赖性的一种途径是采用约定,在该约定中,编译器只是假设一个默认值,除非另有说明。通常,这些约定是普遍且众所周知的,这意味着你无需了解代码的其余部分即可知道它们是什么。Rust 中这种技术的很好的例子是,mod foo; 默认情况下会查找foo.rs(或foo/mod.rs)。

最后一点。“隐式性”通常相对于语言当前的位置而言,最初看起来很激进——比如类型推断!——但很快就会消失在后台,以至于它不再感觉像隐式了(参见 Stroustrup 规则)。但有时,一点隐式性确实是一个坏主意。关键是要仔细考虑对推理足迹的影响。

示例:类型注释

越来越被人们认为理所当然的人体工程学方面之一是类型推断。在过去,你必须用类型注释每个局部变量,这种做法现在看起来非常冗长——但在当时,类型推断似乎非常隐式。

Rust 中的类型推断非常强大,但我们限制了另外两个维度

  • 适用性:类型推断仅适用于变量绑定;数据类型和函数必须包含完整的显式签名。

  • 上下文依赖性:因为数据类型和函数是带注释的,所以很容易确定影响推断结果的信息。你只需要浅层查看当前函数之外的代码。换句话说,类型推断是模块化执行的,一次一个函数体。

总的来说,我们在 Rust 中进行的类型推断量似乎与你可以在脑海中保留的量相匹配。

类型系统还提供了一个使用约定来提高人体工程学的良好示例:生命周期省略。该功能允许你在大多数情况下从函数签名中省略生命周期(查看 RFC——我们进行了测量!)。生命周期省略极大地帮助了可学习性,因为它允许你在处理显式生命周期之前,以直观的级别使用借用。

  • 适用性:生命周期省略适用于广泛的类位置——任何函数签名——但仅限于生命周期被强烈暗示的那些情况。

  • 力量:有限;省略只是使用生命周期参数的简写,如果你弄错了,编译器会抱怨。

  • 上下文依赖性:在这里,我们走得太远了。省略适用于&&mut以外的类型这一事实意味着,即使要了解在像fn lookup(&self) -> Ref<T>这样的签名中是否正在发生重新借用,你也需要知道Ref是否有一个被省略的生命周期参数。对于像函数签名这样常见的东西来说,这太依赖于上下文了。我们一直在考虑朝着一个小的但明确的标记的方向推进,以表明Ref正在省略生命周期,这与前面提到的?的策略类似。

原始省略提案也有一些扩展,这些扩展经过精心设计以遵循这些规则,例如 静态中的生命周期 RFC。

想法:隐式边界

今天 Rust 的一个纸张切割问题是,对于某些数据结构,你最终不得不一遍又一遍地重复相同的特质边界集。HashMap就是一个很好的例子;它接受一个键类型,在实践中,该键类型必须满足HashEq特质。所以问题是,我们应该如何理解以下签名?

fn use_map<K, V>(map: HashMap<K, V>) { ... }

现在,这样的签名会被接受,但是如果你尝试使用map的任何方法,你就会收到一个错误,指出K需要是HashEq,并且必须返回并添加这些边界。这是一个编译器以一种可能中断你的流程的方式吹毛求疵的例子,而且它并没有真正添加任何东西;我们使用K作为哈希映射键这一事实实际上强加了一些关于该类型的额外假设。但是编译器强迫我们在签名中明确地写出这些假设。这种情况似乎适合进行人体工程学的改进。

通过将其绑定到类型定义,假设由类型“暗示”的边界,例如假设上面的K必须是HashEq,是直接的

struct HashMap<K: Hash + Eq, V> { ... }

对推理足迹有什么影响?这意味着要完全理解像这样的签名

fn use_map<K, V>(map: HashMap<K, V>) { ... }

你需要了解你应用于像K这样的类型变量的任何像HashMap这样的类型构造函数的边界。因此,特别是如果你试图调用use_map,你需要知道K有一些未说明的约束。

  • 适用性:非常广泛;适用于任何泛型使用。

  • 力量:非常有限;边界几乎总是需要的,无论如何添加边界风险很小。

  • 上下文依赖性:相当有限;它借鉴了应用于类型变量的所有类型构造函数的边界(例如 HashMap<K, V>)。通常您会很清楚这些边界,并且在使用use_map 这样的函数时,您通常会传入一个现有的 HashMap,它在构造时会确保边界已经成立。编译器也可以可靠地生成一个错误,直接指向导致边界未满足的类型。

示例:所有权

Rust 的所有权系统在设计上非常注重易用性,而这需要明智地使用“隐式”特性。观察借用显式和隐式的地方尤其有启发性。

  • 在调用方法时,接收者对借用是隐式的。
  • 对于普通函数参数和其他表达式,借用是显式的。

所有权在 Rust 中很重要,并且在本地对其进行推理至关重要。那么为什么我们最终会得到这种特定类型的隐式和显式所有权跟踪的混合呢?

  • 适用性:常见,但描述范围狭窄:它仅适用于方法调用的接收者。

  • 能力:中等能力,因为它可以确定接收者是否可以被修改(通过可变地借用它)。这在一定程度上被借用检查所缓解,借用检查至少会确保允许进行这种借用。

  • 上下文依赖性:原则上,您需要知道方法是如何解析的,然后是它的签名。在实践中,self 借用的风格几乎总是由方法名称暗示的(例如 push()len())。值得注意的是,这一点适用于函数参数。

这种设计也有助于学习,它通常只是对借用做“显而易见的事情”,从而限制了新手必须处理有关借用选择的情况。.

想法:隐式借用

尽管如此,Rust 中的借用仍然存在一些痛点。例如:

放弃所有权。有时您拥有一个值的 所有权,例如 String,并且想要将其传递给一个只需要借用(例如 &str)的函数,之后您不再需要该值。今天,您必须在参数中借用该值。

fn read_config(path: &Path) -> Config { ... }

let mut path = PathBuf::new(src_dir);
path.push("Config.toml");

// we have to borrow `path` with `&` even though we're done with it
let config = read_config(&path);

但我们可以轻松地允许您编写 read_config(path),隐式地借用 path 用于 read_config,然后在之后立即丢弃它。这将保留您在本地推理所有权的能力,因为从调用者的角度来看,path 的所有权确实完全被放弃了(并且缓冲区在调用 read_config 结束时被销毁)。但它允许您忽略调用者只需要借用的不重要细节。同样,如果您只是忘记了借用,并在之后尝试使用 path,编译器会像今天一样捕获它。这是一个不太强大的推理示例(它只是为即将被丢弃的对象引入共享借用),我们允许它在几乎所有地方发生。

在匹配模式中借用。学习 Rust 时的一个绊脚石是模式匹配和借用之间的交互。特别是,当您对借用数据进行模式匹配时,您通常需要进行一些重新借用操作。

match *foo {
    Some(ref contents) => { ... }
    None => { ... }
}

这里我们使用 * 来解引用 Option,然后使用 ref重新引用其内容。新手和经验丰富的 Rustaceans 都倾向于错过其中一个或两个标记,部分原因是它通常是唯一可以做的事情。因此,我们可以考虑从上下文中推断这些标记。

  • 根据正在匹配的表达式的类型和匹配的分支来推断对解引用的需求。这是一个非常有限的上下文,程序员已经会将其放在首位。

  • 根据匹配分支中的借用使用情况来推断对 ref(或 ref mut)的需求,就像我们对闭包所做的那样。这扩展了推理范围,因为您无法从模式中一目了然地知道正在进行哪种借用。但是检查代码块以确定它们所进行的借用是 Rust 程序员一直都在做的事情,正如所有权部分所解释的那样,借用系统旨在使这很容易做到。无论如何,这仍然是一个非常本地的上下文。像往常一样,如果您弄错了,借用检查器会捕获它。

除了上下文依赖性的故事之外,该功能的适用范围也很窄(仅限于 match)并且能力也中等(因为,再次,借用检查器会捕获错误)。

这两个更改都会稍微扩展推理范围,但以一种非常受控的方式。它们消除了编写本质上已经被附近代码强制执行的注释的必要性。这反过来又降低了 match 的学习曲线。

示例:模块系统

最后,让我们看看模块系统。在最常见的用法中,模块的定义如下:

mod some_module;

其中 some_module.rs 是源代码树中适当位置的文件。如果您愿意,可以指定一个显式路径,因此这是一个通过约定实现隐式性的案例。但是,虽然这种隐式性有助于,但模块系统仍然做出了许多细微的区分,这些区分会让新手感到困惑,并且需要即使是老手也会忘记的冗余。

想法:消除对 extern crate 的需求,也许也消除对 mod 的需求

最明确的案例是 extern crate 声明,它用于将外部 crate 引入作用域。绝大多数 Rust 项目使用 Cargo 进行依赖管理,并且已经在它们的 Cargo.toml 文件中指定了它们所依赖的 crate。因此,extern crate 通常是冗余的,并且在更新 Cargo.toml 后很容易忘记添加它。新用户经常抱怨 moduseextern crateCargo.toml 中的条目之间的繁琐区别;也许我们可以通过消除对 extern crate 的需求来改善这种情况。这对推理范围意味着什么?

这意味着要了解根目录中有哪些 crate 在作用域内,您需要查阅 Cargo.toml,它成为对此问题的唯一真相来源。这是一个非常有限的上下文:它是一个可以查找的地方,并且在许多情况下,您已经需要对其内容有一定的了解,才能知道正在假设的 crate 的哪个版本。推断 extern crate 在适用性方面也表现良好:只有根模块受到影响,因此很容易准确地知道何时需要查阅 Cargo.toml

沿着类似但更激进的思路,可以对 mod 本身的需求提出论据。毕竟,如果我们通常只是编写 mod some_module 来告诉 Rust 从具有相同名称的规范位置拉取文件,那么我们被迫复制已经很容易获得的信息。相反,您可以想象文件系统层次结构直接告知模块系统层次结构。关于有限上下文和适用性的担忧与 Cargo.toml 几乎相同,并且可学习性和人体工程学方面的收益是显著的。

现在,这两个提议都假设您的代码遵循典型模式,不使用额外的非默认灵活性。关于细节和表达能力有很多问题。但是,至少从隐式性的角度来看,这两个更改都没有对推理范围发出任何警报。

倡议

有了这些目标和设计理念,我们打算如何进行呢?

首先,我们将使用 路线图跟踪器 来帮助组织人体工程学改进的想法。跟踪器中已经包含了一些语言团队一直在考虑的想法,但我们将随着提案在 内部论坛 和其他地方出现而对其进行更新。语言团队渴望指导,因此,如果其中一个想法引起了您的注意,并且您希望在制定完整的 RFC 时获得指导,请在跟踪器上记录您的兴趣!同样,对于实现,一旦 RFC 合并。

深入挖掘,有一个重要的跨领域问题:同理心。这里的目标是尝试想象和评估 Rust 可以变得不同的方式。为了做到这一点,我们需要能够重新回到新手的角度。那些喜欢不同工作流程的人。我们需要能够以全新的方式来到 Rust,抛弃我们当前的习惯和思维模式,尝试新的模式。

也许最重要的是,我们需要对彼此有同理心。变革性的见解可能是脆弱的;它们可能最初嵌入在有许多问题的想法中。如果我们太快地基于这些问题来关闭一条思路,我们就有可能阻碍通往更好的东西的道路。我们必须有耐心去接受那些陌生和不舒服的想法,并从中获得一些新的视角。我们必须相信我们都希望让 Rust 变得更好,并且真诚的讨论是 使生产力成为核心价值,而不牺牲其他价值 的途径。

让我们开始吧!