Rust 的语言人体工程学计划

2017 年 3 月 2 日 · Aaron Turon

为了帮助实现我们 2017 年 Rust 的愿景,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 的一个麻烦之处在于,对于某些数据结构,您最终不得不一遍又一遍地重复相同的 trait 边界集。HashMap 就是一个很好的例子;它采用一个键类型,在实践中,必须满足 HashEq trait。所以问题是,我们应该如何理解像下面这样的签名?

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),为 read_config 隐式借用 path,然后在之后立即丢弃它。这将保留人们在本地推理所有权的能力,因为从调用者的角度来看,path 的所有权确实被完全放弃了(并且缓冲区在调用 read_config 结束时被销毁)。但这允许你忽略不重要的细节,即被调用者恰好只需要借用。再说一遍,如果你只是忘记了借用,并尝试在之后使用 path,编译器会像今天一样捕获它。这是一个不太强大的推理的例子(它仅为即将被丢弃的对象引入共享借用),我们允许它几乎在任何地方发生。

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

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

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

  • 根据匹配的表达式的类型和匹配的分支,推断出解引用的需要。这是一个非常有限的上下文,它已经会在程序员的脑海中占据中心位置。

  • 根据匹配分支中的借用用法,推断出 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 变得更好,并且真诚的审议是使生产力成为核心价值而不牺牲其他价值的方式。

我们开始行动吧!