为了帮助实现 Rust 的 2017 年愿景,Rust 子团队正在启动针对特定路线图目标的行动计划。本文将介绍语言团队的主要行动:改进核心语言的人体工程学。目标是通过精简功能和忽略不相关细节来提高生产力并降低学习曲线。
人体工程学
人体工程学是衡量你使用工具完成工作时遇到的摩擦程度。你希望达到一种“心流”状态,在这种状态下,想法和直觉能够顺畅地转化为可工作的代码,尽可能减少麻烦。(而且,对于 Rust 来说,我们希望代码既可靠又快速。)这种体验的最大威胁是中断,它以多种形式出现:查阅资料、切换上下文、进行大量仪式性的操作,或者处理编译器过于死板地指出你遗忘的微小细节时产生的大量错误。任何将你的注意力从当前问题上转移到那些无关紧要(或者*暂时*无关紧要)的细节上的事物。
一个推论是,人体工程学很少关乎原始字符计数。当你势头良好时,输入 pos
和 position
的区别并不大;更重要的是记住该输入哪个(词语)的容易程度。同样,在 match
模式中*输入* ref
,或者使用 *
解引用 &i32
,这都很容易;知道或记住何时输入它们,另一方面就...
你作为专家必须记住的事情,正是你作为新手必须学习的事情。人体工程学改进让每个人的生活都更美好。
通常,问题的核心是什么应该隐式化的问题。在本文的其余部分,我将介绍一个思考这个问题的基本框架,然后将该框架应用于 Rust 的三个领域,分析当前设计以及我们今年可能考虑的一些精简。
隐式 vs 显式
当信息是被*暗示*而非被*表达*时,它是隐式的。潜在的人体工程学优势显而易见:强迫你写下显而易见(因为它已经被暗示)的信息很麻烦,因为它增加了干扰性的噪音,而且容易忘记。允许你将其隐式化可以减少这种摩擦。然而隐式化却声誉不佳,Python 甚至将其核心设计原则定为“显式优于隐式”。为什么?
隐式化可以非常强大。毕竟,编译器非常了解你的代码,并且可以利用这一点以各种微妙的方式注入行为。如果做得太过分,这些技术会损害可读性,或者更糟的是:引入令人惊讶的行为,这些行为可能难以追踪,因为它们是一个细微的推断链条的结果。如果你亲身经历过这些陷阱,很容易形成隐式化本身就是罪魁祸首的印象。
但这在我看来是对问题的误诊,是一种因噎废食的做法。根本问题反而是:你需要多少信息才能自信地理解某一行代码正在做什么,以及这些信息有多难找到?我们称之为代码段的*推理足迹*。上述陷阱源于推理足迹失控,而非隐式化本身。
那么可读性是否要求我们最小化推理足迹?我不这么认为:如果做得太小,代码会变得令人绝望地冗长,迫使所有信息时刻可见,从而使代码难以阅读。我们想要的是一个最佳点,例行或容易找到的细节可以省略,而相关或令人惊讶的信息则保持在显眼位置。
如何分析和管理推理足迹
隐式化的推理足迹有三个维度:
-
适用性。在哪些地方允许你省略隐式信息?是否有任何提示表明这可能会发生?
-
能力。省略的信息有什么影响?它能根本性地改变程序行为或其类型吗?
-
上下文依赖性。你需要了解多少其他代码才能知道正在暗示什么,即省略的细节将如何补充?是否总有一个明确的地方可以查看?
本文的基本论点是,隐式功能应该平衡这三个维度。如果某个功能在其中一个维度上很大,最好在其他两个维度上对其进行强力限制。
Rust 中的 ?
操作符就是这种权衡的一个很好的例子。它显式地(但简洁地)标记了一个在发生错误时你将从当前上下文退出的点,并且可能在途中进行隐式转换。它被标记的事实意味着该功能的适用性受到强力限制:你绝不会对其发挥作用感到惊讶。另一方面,它相当强大,并且有点上下文依赖性,因为转换可能取决于使用 ?
的类型,以及它将跳转到的作用域中期望的类型。总的来说,这种仔细的平衡使得 Rust 中的错误处理感觉就像使用异常一样具有人体工程学特性,同时避免了它们一些众所周知的缺点。
相比之下,像不受限制的隐式转换这样的功能理所当然地声誉不佳,因为它普遍适用、相当强大,而且上下文依赖性强。如果我们要扩展 Rust 中的隐式转换,我们可能会限制它们的能力(例如,将它们限制在 AsRef
式的强制转换,这种转换能做的事情非常少)。
强力限制上下文依赖性的一种途径是采用*约定*,即除非另有说明,编译器会简单地假定一个默认值。通常这样的约定是普遍且众所周知的,这意味着你不需要了解代码的其余部分就能知道它们是什么。在 Rust 中这种技术的一个很好的例子是 mod foo;
默认会在 foo.rs
(或 foo/mod.rs
)中查找这一事实。
最后一点。“隐式化”通常是相对于语言当前状态而言的,一开始看起来很激进的东西——比如类型推断!——但随后很快就融入背景,到了一种程度,它完全不再感觉是隐式的了(参见 Stroustrup 定律)。但有时一点点的隐式化确实是个坏主意。关键在于仔细考虑对推理足迹的影响。
示例:类型注解
越来越被视为理所当然的人体工程学特性之一是*类型推断*。在过去的日子里,你必须为每个局部变量标注其类型,这种做法现在看来极其冗长——但在当时,类型推断显得极其隐式。
Rust 中的类型推断相当强大,但我们限制了其他两个维度:
-
适用性:类型推断仅发生在变量绑定上;数据类型和函数必须包含完整、显式的签名。
-
上下文依赖性:由于数据类型和函数都进行了标注,很容易确定影响推断结果的信息。你只需要浅显地查看当前函数之外的代码即可。换句话说,类型推断是模块化进行的,一次一个函数体。
总的来说,我们在 Rust 中进行的类型推断量似乎与你能记住的内容很好地匹配。
类型系统也提供了一个使用约定来提高人体工程学性能的很好的例子:生命周期省略。该功能允许你在绝大多数情况下从函数签名中省略生命周期(看看那篇 RFC——我们测量过了!)。生命周期省略极大地帮助了可学习性,因为它允许你在深入研究显式生命周期之前,在直观层面进行借用操作。
-
适用性:生命周期省略适用于广泛的位置类别——任何函数签名——但仅限于那些生命周期被*强力*暗示的情况。
-
能力:有限;省略只是使用生命周期参数的速记,如果你弄错了,编译器会报错。
-
上下文依赖性:在这里,我们做得过头了。省略适用于
&
和&mut
之外的类型这一事实,意味着要判断在fn lookup(&self) -> Ref<T>
这样的签名中是否正在发生重新借用 (reborrrowing),你需要知道Ref
是否有一个被省略的生命周期参数。对于函数签名这种常见情况来说,这提供了太多上下文信息。我们一直在考虑引入一个虽小但显式的标记,来表明Ref
的生命周期正在被省略,类似于前面提到的?
的策略。
原始的省略提案也有一些扩展,再次精心设计以遵循这些规则,比如 静态变量中的生命周期 RFC。
想法:隐式约束 (implied bounds)
Rust 当前的一个小痛点是,对于某些数据结构,你最终不得不一遍又一遍地重复同一组 trait 约束。HashMap
是一个很好的例子;它接受一个 key 类型,在实践中,该类型必须满足 Hash
和 Eq
trait。所以问题是,我们应该如何理解如下所示的签名?
目前,这样的签名会被接受,但如果你尝试使用 map
的任何方法,你会得到一个错误,提示 K
需要是 Hash
和 Eq
,并且不得不返回去添加这些约束。这是一个例子,说明编译器有时会过于死板,这会中断你的心流,而且实际上并没有增加任何东西;我们将 K
用作 hashmap 的 key,这一事实本质上对该类型强制了一些额外的假设。但编译器却要求我们在签名中显式地写出这些假设。这种情况似乎非常适合进行人体工程学改进。
假设由类型“暗示”的约束是很直接的,就像上面假设 K
必须是 Hash
和 Eq
一样,通过将其与类型定义绑定。
对推理足迹有什么影响?这意味着要完全理解一个像这样的签名:
你需要了解你应用于类型变量(如 K
)的任何类型构造函数(如 HashMap
)上的约束。因此,特别地,如果你尝试调用 use_map
,你需要知道 K
上有一些未说明的约束。
-
适用性:非常广泛;适用于任何泛型的使用。
-
能力:非常有限;这些约束几乎总是需要的,而且在任何情况下添加约束的风险并不大。
-
上下文依赖性:相当有限;它来源于应用于类型变量(如
HashMap<K, V>
)的所有类型构造函数上的约束。通常你无论如何都会清楚这些约束,当使用像use_map
这样的函数时,你通常会传入一个现有的HashMap
,根据构造方式,这将确保约束已经满足。编译器也可以可靠地生成一个错误,直接指向施加未满足约束的类型。
示例:所有权
投入了大量工作使得 Rust 的所有权系统具有人体工程学特性,这些工作包括明智地使用“隐式”功能。查看借用何时是显式的以及何时不是显式的,是特别有启发性的:
- 在调用方法时,接收者的借用是隐式的。
- 对于普通函数参数和在其他表达式中,借用是显式的。
所有权在 Rust 中很重要,并且在局部推理所有权是至关重要的。那么我们为何会得到这种显式和隐式所有权跟踪的特殊组合呢?
-
适用性:常见,但范围狭窄:它仅适用于方法调用的接收者。
-
能力:中等强大,因为它可以决定接收者是否可以被修改(通过可变借用它)。这在一定程度上被借用检查缓解了,借用检查至少会确保允许进行这样的借用。
-
上下文依赖性:原则上,你需要知道方法是如何解析的,然后是它的签名。在实践中,
self
借用的风格几乎总是由方法名暗示的(例如push()
对比len()
)。值得注意的是,这一点不适用于函数参数。
这种设计也帮助了可学习性,通过通常只是对借用做“显而易见的事情”,从而限制了新手不得不纠结于选择借用的情况。.
想法:隐式借用 (implied borrows)
尽管如此,Rust 在借用方面仍然存在一些痛点。具体来说:
丢弃所有权。有时你拥有某个值的所有权,比如一个 String
,你想将其传递给一个只需要借用(比如 &str
)的函数,之后你就不再需要这个值了。目前,你*必须*在参数中借用这个值:
let mut path = new;
path.push;
// we have to borrow `path` with `&` even though we're done with it
let config = read_config;
但我们可以轻松地允许你写 read_config(path)
,隐式地将 path
借用给 read_config
,然后在函数调用结束后立即*丢弃*它。这将保留人们在局部推理所有权的能力,因为从调用者的角度来看,path
的所有权确实完全被放弃了(并且缓冲区在 read_config
调用结束时被销毁)。但它允许你忽略调用者恰好只需要一个借用这个不重要的细节。同样,如果你只是忘了借用,并在之后尝试使用 path
,编译器会像现在一样捕捉到这个错误。这是一个不是非常强大的推断示例(它仅仅是为一个即将被丢弃的对象引入一个共享借用),我们会允许它几乎在所有地方发生。
Match 模式中的借用。学习 Rust 时的一个绊脚石是模式匹配与借用之间的交互。特别是当你对借用的数据进行模式匹配时,你通常需要进行一些小的重新借用操作:
match *foo
这里我们使用 *
来解引用一个 Option
,然后使用 ref
来*重新*引用其内容。新手和有经验的 Rustacean 都容易漏掉这些标记中的一个或两个,部分原因在于,这通常是你唯一能做的事情。因此,我们可以考虑从上下文推断这些标记:
-
基于被匹配表达式的类型和 match 的分支来推断是否需要解引用。这是一种非常有限的上下文,并且已经处于程序员思维的前沿和中心。
-
基于 match 分支中的借用使用来推断是否需要
ref
(或ref mut
),很像我们已经对闭包所做的那样。这稍微扩大了推理足迹,因为你无法仅凭模式一瞥就看出正在进行哪种借用。但检查代码块以确定它们进行的借用是 Rust 程序员一直在做的事情,并且正如所有权一节所述,借用系统旨在使其易于进行。而且无论如何,它仍然是相当局部的上下文。照例,如果你弄错了,借用检查器会捕捉到错误。
除了关于上下文依赖性的这一点外,该功能仅适用于狭窄的范围(仅限于 match
),且能力适中(因为,同样,借用检查器会捕捉错误)。
这两个变化都会稍微扩大推理足迹,但方式非常可控。它们消除了写下那些实际上已经被附近代码强制要求的注解的需要。这反过来降低了 match
的学习曲线。
示例:模块系统
最后,我们来看看模块系统。在最常见的用法中,模块是这样定义的:
其中 some_module.rs
是源文件树中一个合适位置的文件。如果你愿意,可以指定显式路径,所以这是一种通过约定实现的隐式化。但虽然这一点隐式化有所帮助,模块系统仍然有一些细微的区别会绊倒新手,并且需要一些冗余,即使是老手也会忘记。
extern crate
的必要性,甚至可能包括 mod
想法:消除 最明确的例子是 extern crate
声明,它用于将外部 crate 引入作用域。绝大多数 Rust 项目使用 Cargo 进行依赖管理,并且已经在 Cargo.toml
文件中指定了它们所依赖的 crate。因此,extern crate
通常是冗余的,并且在更新 Cargo.toml
后很容易忘记添加它。新用户经常抱怨 mod
、use
、extern crate
和 Cargo.toml
中的条目之间复杂的区别;也许我们可以通过取消 extern crate
的必要性来改善这种情况。这对推理足迹意味着什么?
这意味着要了解根模块中有哪些 crate 在作用域内,你需要查阅 Cargo.toml
,Cargo.toml
成为关于此事的唯一真相来源。这是一个相当有限的上下文:只有一个地方需要查看,在许多情况下,你已经需要对其内容有所了解,以便知道正在假定哪个版本的 crate。推断 extern crate
在适用性方面也表现良好:只有根模块受到影响,因此很容易知道何时需要精确查阅 Cargo.toml
。
沿着相似但更激进的思路思考,也可以对 mod
本身的必要性提出论证。毕竟,如果我们通常只是写 mod some_module
来告诉 Rust 从具有相同名称的规范位置引入一个文件,我们被迫重复已经随时可用的信息。你可以转而想象文件系统层级直接影响模块系统层级。关于有限上下文和适用性的顾虑与 Cargo.toml
的情况基本相同,并且可学习性和人体工程学方面的收益是巨大的。
现在,这两个提案都假定你的代码遵循*典型*模式,不利用额外的非默认灵活性。关于具体细节和表达能力还有很多问题。但是,至少从隐式化的角度来看,这两个变化都不会给推理足迹敲响警钟。
该行动计划
考虑到这些目标和设计理念,我们计划如何进行呢?
首先,我们将使用路线图跟踪器来帮助组织关于人体工程学改进的想法。跟踪器中已经填充了一些语言团队一直在思考的想法,但随着提案在内部论坛和其他地方出现,我们会持续更新它。语言团队渴望提供指导 (mentor),所以如果其中一个想法引起了你的注意,并且你想获得指导以完成一份完整的 RFC (Request for Comments),请在跟踪器上记录你的意向!RFC 合并后,实现方面也是如此。
更深入地看,还有一个至关重要的跨领域关注点:*同理心*。这里的目标是尝试想象和评估 Rust 可能有所不同的方式。要做到这一点,我们需要能够设身处地地站在新手的角度思考。站在喜欢不同工作流程的人的角度。我们需要能够以全新的视角来看待 Rust,抛弃我们目前的习惯和心智模型,尝试新的。
而且,也许最重要的是,我们需要彼此之间的同理心。具有变革性的洞见可能是脆弱的;它们一开始可能蕴含在有很多问题的想法中。如果我们因为这些问题就过快地否定一种思路,我们就有可能关闭通往更好事物的道路。我们必须有耐心去接受那些陌生和令人不适的想法,并从中获得新的视角。我们必须相信我们都想让 Rust 变得更好,并且真诚的商议是使生产力成为核心价值,同时不牺牲其他价值的方式。
让我们开始吧!