具名参数与可选参数很棒

Rust 缺少具名参数和可选参数,作者对比 Dart、C#、TypeScript 与 Python 的做法,梳理这个缺陷带来的问题以及可能的实现方案。

中文
复制

每次有人问我 Rust 最让我不满的地方是什么,我的答案始终如一:它没有具名参数,也没有可选参数。

我能容忍的编程语言不多:Rust、C#、TypeScript、Dart、Python。在这些语言里,Rust 是最难搞出具名参数的。这篇文章我先逐一看看其他语言是怎么实现具名参数的。然后再看 Rust,看看没有具名参数带来了哪些问题,以及未来 Rust 可能通过哪些方案把它加进来。

其他语言如何处理具名参数

Dart

Dart 是专为图形用户界面设计的。GUI 组件往往有大量可选参数,所以 Dart 在这方面下了很大功夫。我认为在我们今天要讨论的所有语言里,它做得最好。

先看一个普通函数,熟悉一下这门语言:

Widget Text(String text) {
 // ...
}

就语言的一般水平而言,这很普通。哪怕你不懂 Dart,大概也能猜出它在做什么。Dart 基本上就是在实验室里设计出来,专门让程序员容易上手的。

现在加一个具名参数来设置文字大小,默认值设为 16 像素:

Widget Text(String text, {int fontSize = 16}) { /* ... */ }

// usage:
Text("Hello, world!", fontSize: 24)

具名参数用花括号包起来,看起来有点像 map。具名参数也可以默认设为 null,甚至可以是必填的。

Widget Text({
 int fontSize = 16,
 Color? color,
 required String text,
});

Dart 还支持可选位置参数,把参数用方括号包起来即可。

// From the Dart documentation
String say(String from, String msg, [String device = 'carrier pigeon']) {
 var result = '$from says $msg with a $device';
 return result;
}

assert(say('Bob', 'Howdy') == 'Bob says Howdy with a carrier pigeon');
assert(say('Bob', 'Howdy', 'smoke signal') == 'Bob says Howdy with a smoke signal');

C#

C# 对具名参数的处理也相当不错。事实上,任何参数都可以具名。

void ExampleMethod(string foo, string bar)
{
 Console.WriteLine($"{foo} {bar}")
}

ExampleMethod(bar: "Hello", foo: "world");

任何参数都可以有默认值,只要写在必填参数之后就行。

void ExampleMethod(string foo, string bar = "world")
{
 Console.WriteLine($"{foo} {bar}")
}

ExampleMethod("Howdy");

就这么简单。我喜欢这种简洁。

TypeScript

在我看来,TypeScript 对具名参数的处理不太好。但可选位置参数虽然不具名,用起来还挺顺手,所以先从它说起。

function example(required: string, optional?: string = "world"): string {
 return `${required} ${optional}`;
}

example("Hello") === "Hello world";
example("Hello", "TypeScript") === "Hello TypeScript";

我觉得这没什么问题。如果不指定默认值,它就会被设为 undefined。问题在于,TypeScript 没有一套好用的内置具名参数语法,只能靠其他特性硬凑。

function example(
 required: string,
 {
 requiredNamed,
 namedOptional = "world"
 }: { requiredNamed: string, namedOptional?: string }
): string {
 return `${required} ${requiredNamed} ${namedOptional}`;
}

example("Hello", { requiredNamed: "beautiful" });

这里同时用到了匿名类型和解构。函数的第二个参数是一个匿名对象类型,包含两个字段:requiredNamed 和 namedOptional。然后我们解构这个参数,就能在函数里使用这些字段。

最烦人的地方在于,字段名得写两遍:一遍给类型,一遍给函数参数。这种啰嗦是任何一门好语言都该尽量避免的。函数调用也因为那对大括号而比必要的更冗长。

可惜这种写法在 React 里到处都是。组件通常就是一个接收单个对象参数的函数,所以定义组件时,你不得不一遍遍这么写。

Rust

有人尝试过在 Rust 里实现具名参数,但效果普遍不太好。最常见的做法是为函数定义一个 struct,再让它实现 Default trait,这样就能用 ..Default::default() 结束 struct 初始化。

struct ExampleParams {
 foo: String,
 bar: String,
}

impl Default for ExampleParams {
 fn default() -> Self {
 Self {
 foo: "Hello".into(),
 bar: "Hello".into(),
 }
 }
}

fn example(Example { foo, bar }) -> String {
 format!("{foo} {bar}")
}

assert_eq!(
 example(Example { foo: "Hello".into(), ..Default::default() }),
 "Hello world".to_string()
);

前面我抱怨过 TypeScript 的写法啰嗦,但这个更糟,因为类型不是匿名的。更麻烦的是,如果你的默认值不是类型的默认值(比如 String 的空字符串),那就得专门写一整个函数来设置默认值。而且这只有在每个参数都有默认值时才能用。如果只有部分参数有默认值,你就需要一套更啰嗦的方案。

为什么 Rust 需要可选参数

标准库并不倾向于遵循上面这种模式(在我看来,理由很充分)。但仍有一些场景,标准库如果用了它会更好。

以 HashMap 的某些工厂方法为例。

impl HashMap {
 pub fn new() -> HashMap;
 pub fn with_capacity(capacity: usize) -> HashMap;
}

impl HashMap {
 pub fn new_in(alloc: A) -> Self;
 pub fn with_capacity_in(capacity: usize, alloc: A) -> Self;
}

impl HashMap {
 pub const fn with_hasher(hash_builder: S) -> HashMap;
 pub fn with_capacity_and_hasher(capacity: usize, hasher: S) -> HashMap;
}

impl HashMap {
 pub fn with_hasher_in(hash_builder: S, alloc: A) -> Self;
 pub fn with_capacity_and_hasher_in(capacity: usize, hasher: S, alloc: A) -> Self;
}

仅仅三个可选参数,就需要八个函数来表示所有重载。每个函数都得有自己的名字,因为 Rust 实际上没有重载。而当用到那些比较复杂的工厂方法时,程序员或代码审查者还得记住或去查参数的顺序。

另一个问题是,参数没有名字时更容易出错。看看下面这个 print 函数。

fn print(text: &str, bold: bool, italics: bool, underline: bool);

这里的参数很容易搞混,因为它们没有名字。如果我忘了 italics 参数是第三个而不是第二个,就可能不小心把文字变成粗体。

有些函数本身参数就很多。设想我在上面再加上颜色、下划线颜色、背景色、闪烁文字、隐藏文字、圈选文字和快速闪烁文字。没人愿意给一个函数传 14 个参数,其中好几个还有合理的默认值。而且如果这个结构体只为一个函数服务,我看不出有什么理由给它起名字。

具名参数的各种提案

我先介绍一些我见过的、用于改进 Rust 具名参数的提案,这些我都不喜欢。然后我会揭晓我偏好的方案。

默认字段值

这个特性在 Nightly Rust 中已经可用。syn 最近甚至也加入了对它的支持。思路是为实现 Default trait 添加特殊语法,让它更容易。这并不是说该提案本身能解决具名参数问题,但它可能是更大方案的一部分。

#[derive(Default)]
struct Pet {
 name: Option, // impl Default for Pet will use Default::default() for name
 age: i128 = 42, // impl Default for Pet will use the literal 42 for age
}

// Pet { name: Some(""), age: 42 }
let _ = Pet { name: Some(String::new()), .. };
// Compilation error: `name` needs to be specified
let _ = Pet { .. };
// Pet { name: None, age: 42 }
let _ = Pet::default();

我对这个提案本身没有意见。我甚至自己写了一个库来给它做 polyfill(feluments)。它用在结构体上看起来很完美。但对函数帮助不大。

结构记录

默认字段值的 RFC 提到了一份已关闭的结构记录 RFC。可以把结构记录理解为匿名字段结构体,或者带字段名的元组。

fn do_stuff_with(color: { red: u8, green: u8, blue: u8 }) { // More ergonomic!
 some_stuff(color.red); // *And* readable! :)
 ...
 other_stuff(color.green);
 ...
 yet_more_stuff(color.blue);
}

do_stuff_with({ red: 255, green: 127, blue: 63 });

该 RFC 指出,这模拟了具名参数,但没有模拟可选参数。这并不是它的主要动机。主要动机是兼得元组的便利和结构体的可读性。

假设这个 RFC 通过了。那我们那些 “Hello, world” 示例的语法会是什么样?再假设默认字段值也一并合入,并且能和结构记录配合使用。

fn example(
 required: String,
 {requiredNamed, namedOptional}: {
 requiredNamed: Option,
 namedOptional: String = "world".into()
 }
): String {
 format!("{required} {requiredNamed} {namedOptional}")
}

example("Hello", { requiredNamed: "beautiful", .. });

哇,看起来很眼熟。实际上这正是 TypeScript 使用的语法,也带着它的一堆问题。参数名你得写两遍。

结构化记录还带来许多其他麻烦:

  • 只有一个字段的记录怎么办?{ x } 既可以理解成返回 x 的块表达式,也可以理解成结构化记录表达式:{ x: x, }。为了消除歧义,RFC 提议多加一个逗号:{ x, },跟元组的做法一样。但这会让 Rust 解析器复杂得多,需要向前看若干个 token。
  • 没法给这些类型实现任何 trait。RFC 提议用魔法自动实现若干 trait。而且和元组不同,我们没法直接定义几个泛型 trait 实现,因为字段名的变化太多了。
  • 刚接触这门语言的人可能会把这个语法误认成 Python 的字典或 Dart 的 map 类型。

有意思的是,TypeScript 其实有具名元组字段。但它们只存在于类型声明里,你不能用字段名去访问字段。

type NewLocation = [lat: number, long: number]

const newLocations: NewLocation[] = [
 [52.3702, 4.8952],
 [53.3498, -6.2603]
]

const firstLat = newLocations[0][0]
const firstLong = newLocations[0][1]

Rust 语言团队认定,虽然类似这样的功能看起来有用,但实现起来会非常困难,不值得花这个力气。

照抄 C#

既然我们已经就结构体中可选字段的语法达成了一致,为什么不干脆把它照搬到函数上?首先,我们可以允许参数以任意顺序指定,只要它们都是具名的,并且具名参数后面不再跟乱序的位置参数。

print_order_details(order_num: 31, product_name: "Red Mug", seller_name: "Gift Shop");
print_order_details(seller_name: "Gift Shop", product_name: "Red Mug", order_num: 31);
print_order_details("Gift Shop", 31, product_name: "Red Mug");
print_order_details(seller_name: "Gift Shop", 31, product_name: "Red Mug");

// this would cause an error
print_order_details(product_name: "Red Mug", 31, "Gift Shop");

然后,我们可以定义默认值,并允许带默认值的参数不传。

fn print_order_details(product_name: &str, order_num: 31, seller_name: &str = "Gift Shop");

print_order_details("Red Mug", 31);

和默认字段值的提案不同,这里不能在末尾用 ..,因为它本身也是一个合法表达式。

这个想法最大的问题是,它让参数名成了函数公开 API 的一部分,而函数并没有主动选择这么做。之前有人反对,理由是它会和提议中的 type ascription 功能冲突。但那个 RFC 后来被删掉了,所以我觉得不用再担心这一点。

公开讨论

据我所知,这件事从未有过正式的 RFC,但几年前有人在 Rust Internals Forum 上发过一个 pre-RFC,这是我所知道的最详尽的一份具名参数提案。这里我不打算把整份 RFC 复制过来,只做一个高层次的概述。

下面是 RFC 中一个带具名参数的函数示例:

pub struct Database;
pub struct RegistrationError;

pub fn register(
 pub name: String,
 pub surname: String,
 to db: Database
) -> Result {
 /* ... */
}

register(name: "Alexis".into(), surname: "Poliorcetics".into(), to: my_db);

在 to db 这个例子里,参数名是 to,但在函数体内它被称作 db。另外两个参数则用 pub 关键字表示:调用函数时的参数名和函数体中的参数名相同。

RFC 里的细节要多得多,提出了函数重载、在 Fn trait 中使用具名参数、从模式中暴露名称、方法重载,还解释了文档方面的考量。

以下是我在评论中看到的批评意见:

  • 无序具名参数被排除了。我不喜欢 RustyFrog 给出的理由,但在这个模型下支持它似乎并不难。
  • 默认参数值也没有被提出,尽管它正是人们想要具名参数的主要原因。
  • pre-RFC 没有提出默认参数值,却提出了重载——重载在 Rust 中极难实现,而且不如可选参数有用。它提的不是基于类型的重载,这稍微好一点,但也仅此而已。
  • 这份 RFC 篇幅很大,可能因此难以获批。
  • 存在一些语法歧义,比如 fn foo(name (a, b): name),这在目前是合法语法。还有一个与之冲突的类型标注提案,但正如我之前所说,那个 RFC 后来已经被撤掉了。
  • 使用 pub 可能会让人困惑,考虑到它目前的用法。我个人倒不太介意,因为它的效果是让参数名成为函数公开 API 的一部分。但我理解这确实有点怪。
  • 关于修改 Fn trait 的提案显得不够成熟。Fn(f32, f32) -> String trait 会被脱糖成 Fn,但具名参数的脱糖应该是什么样子,并不清楚。
  • 虽然解释了 Fn 实现的用法,却完全没有提到 fn 类型。
  • pre-RFC 没有说清楚具名参数是否必须按名称使用,还是位置正确在技术上就足够了。
  • 带具名参数的 extern 函数处理起来相当复杂,复杂到大概不如干脆不支持。

据我所知,作者后来忙于其他事情,没有回应这些问题,但我认为这份 pre-RFC 是一个不错的起点。

结构体风格的函数调用

这个想法最早由 nixpulvis 在 2016 年提出。我也在其他一些地方见过它。

// 1. Tuple style method call (current functions).
fn foo(a: u32) {}
foo(2);

// 2. Struct style method call.
fn foo { a: u32 }
foo { a: 2 };
// SomeType { a: 1 } -> SomeType
// SomeFunc { a: 1 } -> Codomain
// generally_lowercase { a: 1 } -> Codomain

// 3. Together.
fn foo(a: u32) { a: u32 } {}
foo(1) { a: 2 };

我个人不喜欢,因为它让结构体构造和函数调用看起来太像了。我也不知道编译器要怎么解析它。不过话说回来,大多数语言都用 new Foo() 语法来写构造函数,所以也许有人会喜欢。

照搬 Dart

另一个被提出来的方案,包括我自己也提过,是照搬 Dart 的语法,像这样:

fn foo({ foo: &str = "Hello", bar: &str = "World" }) -> String {
 format!("{foo} {bar}")
}

在我看来,这太像现有的模式匹配语法了。但我确实喜欢它不需要给每个参数额外加一个关键字。

按 edition 区分

有人提议用 edition 来解决向后兼容问题:只允许在 Rust 未来某个 edition 中编写的函数使用命名参数。在我看来,这个破坏性变更太大,不值得考虑。

点前缀

2020 年有人开了一个 RFC,提议用点前缀来区分命名变量和未命名变量。它没有被合并,因为这个 RFC 只是一个非常渐进的步骤,没有说明命名参数未来打算怎么发展。下面是那个 PR 里的例子:

fn split(string: &str, .at: char, .limit: usize, .case_sensitive: bool) {}

split("hello world", .at = ' ', .limit = 2, .case_sensitive = true);

选点是因为它只有一个字符(相比之下,pub 关键字加一个空格要四个字符)。但说实话,我觉得它不够直观。点还会让人联想到结构体字段,而那是完全无关的概念。

我的提案

读完这些提案之后,我觉得一个完整的命名参数提案所需的所有零件都已经齐备,而且可以避开上述所有缺点。我的感觉是,不管怎样总会有人反对这个概念,但我在本文中尽可能回应了对命名参数的各种反驳。本文剩下的部分是一份 pre-RFC,我认为它能尽可能让更多人满意。文章发到哪里,我都会盯着那里的评论,看看有没有重大的批评意见。我不能保证自己一定会正式提交 RFC,但如果我没提交,授权任何人去创建 RFC。距离上一次认真提出命名参数已经过去很久了,我觉得现在是提出新提案的好时机。

摘要

为函数添加具名参数和可选参数。函数可以同时拥有位置参数和具名参数。在函数调用中,可以通过名字来引用具名参数,从而提升可读性和可维护性。具名参数还可以带有默认值,这样在调用时就不必再指定它们。

动机

提升安全性与可读性

记错哪个参数位于哪个位置,是许多语言(包括 Rust)中 bug 的主要来源之一。以标准库中的函数为例:[hard_link](https://doc.rust-lang.org/stable/std/fs/fn.hard_link.html)。

pub fn hard_link

, Q: AsRef>(
 original: P,
 link: Q,
) -> Result

两个参数接受相同的类型,因此不查阅文档就无法知道哪个是哪个。一旦记错,就会导致运行时错误或 bug。

有了具名参数,在调用处一眼就能看出发生了什么。这也让生成的代码更易读。

hard_link(original: "a.txt", link: "b.txt")?;

对于参数更多的函数,效果更加明显。

// Without named arguments: what are these numbers?
solar_elevation(1787517098.0, 38.897957, -77.036560)

// With named arguments, it's obvious
solar_elevation(timestamp: 1787517098.0, latitude: 38.897957, longitude: -77.036560);

如今在 Rust 中也能实现类似的效果:新建一个 struct,让它成为函数唯一的参数。但这样写起来非常啰嗦,所以实际上很少有库采用。更重要的是,标准库几乎不用这种模式。

减少样板代码

许多函数都有合理的默认值,本应传给它们的众多参数,但由于所有位置参数都是必填的,所有参数都必须指定。给每个参数都传入 None 会带来可读性问题。

repository.checkout_index(None, None)?;

把 struct 作为参数传入确实给了我们名字,但仍然需要为可选值指定 None。有时可以通过在构造函数末尾加上 ..Default::default() 来减少这种样板代码,但只要有任何字段不是可选的,就无法实现 Default。如果无法直接用 derive 宏实现 Default,手动实现它同样是一大堆样板代码。其中一些问题可以通过 RFC 3681 解决,但它仍然需要专门为一个函数定义一个新的 struct。

let instance = Instance::new(InstanceDescriptor {
 backends: Backends::default(),
 flags: InstanceFlags::default(),
 memory_budget_thresholds: MemoryBudgetThresholds::default(),
 backend_options: BackendOptions::default(),
 display: None,
});

为了减少调用这类函数时的样板代码,很多库实现了 builder 类型,允许不指定每一个值。代价是需要更多样板代码来创建 builder,而它又成了库公开 API 的一部分。有像 bon 这样的 crate 可以更轻松地为 struct 声明 builder,但仍然需要为一个函数单独声明一个 struct。库也不总会用这些 crate,因为这样会增加编译时间。Command struct 用了 60 行代码(以及更多行文档)才让我们能这样写:

Command::new("printenv")
 .stdin(Stdio::null())
 .stdout(Stdio::inherit())
 .env_clear()
 .envs(&filtered_env)
 .spawn()
 .expect("printenv failed to start");

如果有命名参数,下面这个函数定义起来就很简单:

spawn(
 command: "printenv",
 stdin: Stdio::null(),
 stdout: Stdio::inherit(),
 env_clear: true,
 envs: &filtered_env
).expect("printenv failed to start");

Bon 也提供了在函数中模拟命名参数的工具,但调用这样的函数同样要写一些样板代码。

// Example from the bon README

#[builder]
fn greet(name: &str, level: Option) -> String {
 let level = level.unwrap_or(0);

 format!("Hello {name}! Your level is {level}")
}

// This could be shortened to greet(name: "Bon", level: 24)
let greeting = greet()
 .name("Bon")
 .level(24)
 .call();

assert_eq!(greeting, "Hello Bon! Your level is 24");

实际上,额外的复杂度意味着这些模式只会用在公开 API 中,所以私有函数没有什么值得用的变通办法来提升可读性。

不破坏兼容性的扩展

目前,给已有函数新增一个参数属于破坏性变更。如果你想给 struct 加一个新字段,已有的工厂必须实现一个合理的默认值,并且必须新建一个函数来启用新功能。

这导致一些 API 拥有许多功能相似但参数不同的函数。例如,标准库中的 HashMap 就有以下工厂。

impl HashMap {
 pub fn new() -> HashMap;
 pub fn with_capacity(capacity: usize) -> HashMap;
}

impl HashMap {
 pub fn new_in(alloc: A) -> Self;
 pub fn with_capacity_in(capacity: usize, alloc: A) -> Self;
}

impl HashMap {
 pub const fn with_hasher(hash_builder: S) -> HashMap;
 pub fn with_capacity_and_hasher(capacity: usize, hasher: S) -> HashMap;
}

impl HashMap {
 pub fn with_hasher_in(hash_builder: S, alloc: A) -> Self;
 pub fn with_capacity_and_hasher_in(capacity: usize, hasher: S, alloc: A) -> Self;
}

这些函数全都出现在文档里,污染了 API。函数的数量随参数数量呈指数增长。如果忽略泛型参数上的差异(“未来可能性”一节讨论了可能的解决方案),那么我们可以设想只用一个函数。

pub fn new(
 pub capacity: usize = 0,
 pub alloc: A = Global,
 pub hasher: S = RandomState,
);

如果哪天想加一个 fill 参数,我们可以把它加到现有函数上,既不会造成破坏性变更,也不用把 HashMap 的工厂数量翻倍。

指南级说明

参数可以具名,也可以不具名。默认情况下,函数的所有参数都不具名,无法通过名字引用。在参数名前面加上 pub 关键字,就能创建具名参数。

fn print_labeled_measurement(pub value: i32, pub unit_label: char) {
 println!("The measurement is: {value}{unit_label}");
}

fn main() {
 print_labeled_measurement(5, 'h');
}

乍看之下,这和不具名参数似乎没什么区别。但参数一旦具名,调用函数时就可以使用它的名字。这样一来,一眼就能看出传进函数的那些值各自是做什么的。

print_labeled_measurement(value: 5, unit_label: 'h');

参数具名之后还可以重新排序,不必再记住每个参数的位置。

print_labeled_measurement(unit_label: 'h', value: 5);

排序有一些限制。一个函数可以同时包含具名参数和不具名参数。但如果是这样,无论是在函数声明还是函数体中,所有具名实参都必须写在位置实参之后。

// unit_label is named, value is not
fn print_labeled_measurement(value: i32, pub unit_label: char) {
 println!("The measurement is: {value}{unit_label}");
}

// forbidden: all named parameters must be after all unnamed parameters
fn _print_labeled_measurement(pub value: i32, unit_label: char) {
 // ...
}

fn main() {
 // forbidden: the parameter that 'h' is being passed into must be named
 print_labeled_measurement(value: 5, 'h');
 // forbidden
 print_labeled_measurement(unit_label: 'h', 5);
}

具名参数还可以有默认值。这意味着调用函数时,调用方不必指定该实参的值,会自动使用默认值。

fn print_labeled_measurement(pub value: i32, pub unit_label: char = 'm') {
 println!("The measurement is: {value}{unit_label}");
}

fn main() {
 // unit_label is unspecified and is defaulted to 'm'
 print_labeled_measurement(5)
}

如果参数没有名字,而是以模式的形式定义,就不能使用具名参数。例如,下面这样是无效的。

fn print_coordinates(pub &(x, y): &(i32, i32)) {
 // ...
}

不过,可以用 @ 绑定给这个参数起个名字。

fn print_coordinates(pub point @ &(x, y): &(i32, i32)) {
 // ...
}

trait 也可以选择使用具名参数,这种情况下实现必须使用相同的名字。但 trait 函数的参数不能有默认值。

trait Trait {
 fn f(pub a: i32, pub b: i32);
}

impl Trait for () {
 // This is okay, because the parameter names match
 // unused parameter warnings can be ignored using @
 fn f(pub a: i32, pub b @ _: i32) {}
}

impl Trait for i32 {
 // invalid: the second argument is named `b` in the definition
 fn f(pub a: i32, pub c: i32) {}
}

impl Trait for bool {
 // invalid: `b` is not public
 fn f(pub a: i32, b: i32) {}
}

impl Trait for X {
 // invalid: trait methods cannot have default parameter values
 fn f(pub a: i32, pub b: i32 = 0) {}
}

由于参数名是可选的,带具名参数的函数仍然可以创建函数指针。不过,函数指针上访问不到参数名,默认值也无法使用。Fn* 系列 trait 同样如此。

fn foo(pub a: i32, pub b: i32) {}

let f: fn(i32, i32) = foo;

f(4, 2); // ok
f(a: 4, b: 2); // ERROR! `f` can't be called with named arguments

fn higher_order(f: Fn(i32, i32)) {
 f(4, 2); // ok
 f(a: 4, b: 2); // ERROR! `f` can't be called with named arguments
}
higher_order(foo); // ok
higher_order(|_, _| {}); // ok

参考级说明

语法

要在函数声明中支持公开参数,唯一需要改动的是函数参数。首先,加入 pub 关键字,它后面必须跟一个标识符模式。这样的模式也可以包含默认值。

FunctionParamPattern = PatternNoTopAlt ":" ( Type | "..." )
 | "pub" IdentifierPattern ":" ( Type | "..." ) ( "=" Expr )?

函数调用的语法解析起来稍复杂一些,但只影响 CallParams。

CallParam = Expression ( ":" Expression )?
CallParams = CallParam ( "," CallParam )* ","?

之所以稍复杂,是因为标识符同时也是合法的表达式。解决办法是先解析整个表达式,再看后面是否跟着冒号。如果参数名不是标识符,编译器随后可以报错。

静态语义

函数参数

给定一个 FunctionParamPattern,且其中指定了默认值,即:

FunctionParamPattern = "pub" pat:IdentifierPattern ":" ( ty:Type | "..." ) ( "=" expr:Expr )

适用与默认字段值类似的规则。具体来说,

  • 如果函数是 const,那么表达式 expr 必须是常量表达式。
  • 表达式 expr 必须强制转换为类型 ty
  • 当前条目的泛型参数可访问。
fn foo(bar: usize = A) {}
  • 默认常量表达式不在定义时求值,只在实例化时求值。这意味着以下代码不会编译失败:
fn foo(a: usize = panic!(), b: usize = 42) {}

foo(a: 0);
  • 函数的泛型参数被正确传播,也就是说以下写法是可行的:
fn foo(bar: Vec = Vec::new()) {}

foo::();
  • 当 #[allow(lint_name)] 这类 lint 检查属性放在 FunctionParam 上时,它同样作用于表达式 expr。
  • 如果未使用的参数名通过 @ 绑定到另一个模式,则不会发出警告。被绑定的模式仍可能发出警告

对于公开参数,一般来说所有公开参数必须严格位于所有非公开参数之后。

函数调用

函数调用的语义有显著变化。一般来说,所有具名参数必须位于所有位置参数之后。对于位置参数,现有语义全部适用。

对于以下具名参数的产生式:

CallParam = name:Expression ":" value:Expression

适用以下规则

  • 表达式 name 必须是普通标识符。
  • 标识符 name 必须是该函数的某个公开参数的名称。
  • 表达式 value 必须强制转换为上述公开参数的类型。
Trait 声明

对于 trait 声明,函数声明中的所有规则同样适用。此外:

  • trait 方法不能有默认参数值。
trait 实现

trait 实现的语义与 trait 声明的语义略有不同。

  • 在 trait 声明中声明为 public 的参数,在 trait 实现中也必须是 public。
  • public 参数的名字必须与声明中的名字一致。

缺点

增加了复杂度

这个特性会让语言稍微复杂一点。不过在众多具名参数提案中,本提案已经相当克制。具名参数的语法与结构体字段的语法相似,默认值的语法也与为字段默认值提出的语法很接近。

具名参数提案中常见的一个顾虑是:它只对新函数生效,导致很难记住哪些库支持。本提案可以回溯移植到标准库,让语言用起来更一致。

具名参数不是强制的

名字可选,这一点可以看作是对安全收益的妥协。有些人可能图省事就不写名字,这样一来就得不到任何安全收益。

但正因为不强制,更多库作者才敢用,不必担心破坏性变更。如果作者确信具名参数不会让函数调用变得更啰嗦,有些人也会更愿意支持它。

这个缺点确实会带来一种潜在的 bug。如果在重构中删掉了名字,重构之后参数可能落在错误的位置上,从而引发 bug。

// before refactor
coordinates(y: 45.6, x: 6784.0);

// after refactor: incorrect order
coordinates(y, x);
补救:加一条 clippy lint

可以用一条 clippy lint 来强制在可能的情况下使用具名参数。

补救:在未来的 edition 中强制要求具名参数

尽管这可能是一个很大的破坏性变更,但如果认为有必要,未来的 edition 很可能会强制要求使用具名参数。

限制类型标注

许多早期关于具名参数的提案都指出,这里选择的语法name: value与当时提议的类型标注(type ascription)特性冲突。不过在那之后,类型标注的 RFC 已被撤回,另一个类型标注 RFC 也被否决,部分原因正是它可能与具名参数冲突。所以这大概率已经不再是问题。

理由与替代方案

为什么让具名参数需要显式选择加入?

Kotlin、C# 等许多语言允许任意参数被具名传入,但这在 Rust 里不现实。目前修改参数名不算破坏性变更,而一旦引入具名参数就会变成破坏性变更。解决办法是让作者自己决定参数名算不算实现细节。

pub关键字

从语义上看,用pub关键字似乎有点奇怪。但它的效果本质上就是把参数名公开,所以也说得通。本 RFC 没有提议其他可见性修饰符,因为很难想象有哪种情况,一个公开函数想在内部允许具名参数、对外却不允许。

也有人提议过其他修饰符。可以设想加一个ext关键字来定义具名参数。此前的 RFC 提议过用点号,但被认为太像字段访问。其他符号,比如@,可能更容易被接受。还有一个想法是照搬 Swift 语法,用两个标识符。

fn foo(bar bar: &str) {}

把同一个参数名写两遍很啰嗦,通常也没必要。何况已经有语法可以重新绑定变量了。

Dart 用花括号包住具名参数。好处是不必反复写pub关键字。但放在 Rust 的语境里,这看起来像解构模式,语义上并不正确。

// In Dart
void enableFlags({bool? bold, bool? hidden})
enableFlags(bold: true)

// Similar idea, in Rust
fn enable_flags({ bold: Option = None, hidden: Option = None })
enable_flags(bold: Some(true))

还有一种提议是让带具名参数的函数调用改用花括号而不是圆括号,看起来更像结构体构造。这恐怕会非常令人困惑,也会让这两个特性的界限变得模糊。

fn foo (a: u32) { b: u32 } {}
foo(1) { b: 2 };

冒号与等号

有人提议用等号(=)代替冒号(:)。这样语法会更接近赋值,从语义上讲也确实如此,而且不会与类型标注产生冲突。

本 RFC 选择冒号语法,是为了与结构体初始化的风格保持一致。

参数重排序

一些关于具名参数的提案为了简单起见,省略了参数重排序。出于周全考虑,本 RFC 提议允许参数重排序。另一种可能的做法是要求参数仍按原顺序出现,但缺点是用户想调整参数顺序时会遇到不必要的阻力。

有些语言允许比本 RFC 更灵活的重排序。C# 允许具名参数出现在未具名参数之前,只要参数之间仍保持顺序即可。本 RFC 没有纳入这一特性,是为了让具名参数对用户来说不那么令人困惑。这里提出的重排序方案,仍然为将来实现更灵活的参数重排序留出了空间。

trait 中不支持默认值

本 RFC 目前禁止 trait 使用默认参数值。这是因为,trait 参数的默认值究竟应该定义在 trait 定义处还是 trait 实现处,目前并不明确。如果默认值能由实现方定义,行为大概会更可预测。但很难想象默认值如何能让 trait 保持 dyn 兼容。如果默认值定义在 trait 定义处,那么 trait 就可以保持 dyn 兼容。

关于 const 上下文

在默认字段值 RFC 中,决定结构体的默认值必须是 const。理由是带副作用的函数可能会让编译器和用户都感到困惑。但这些理由并不适用于函数,因为非 const 函数本身并不带有“廉价”或“一致”的预期。始终要求默认值为 const 是可行的,但似乎没有强有力的理由这么做。

不过,如果默认参数在 const 函数里可以不是 const,就会让人困惑,所以这种情况下要求它们必须是 const。

命名参数并非强制

参见上文“缺点”一节。

替代方案:什么都不做

“动机”一节已经详细说明,现状有多么冗长,以至于现有的替代方案——比如结构体参数和 builder——往往根本没人用。这些模式的存在本身就说明,用户想要某种方式来模拟命名参数。而这些替代方案在私有 API 中不被采用,则说明现状确实冗长得令人恼火。

替代方案:结构体记录

很多人提议过,用结构体记录加上字段默认值来模拟命名参数。TypeScript 这类语言里经常用这种写法。声明这样一个函数,语法大致如下。

fn foo({ a, b, c }: { a: f64, b: f64, c: f64 }) {}

我对这种模式有几点批评:

  • 它无法在不造成破坏性变更的前提下移植到已有函数上,所以标准库用不了。
  • 这个声明里的每个标识符都要写两遍。也许可以为此引入语法糖,但目前还没有人提出这样的语法。
  • 结构体记录过去有过一份 RFC 提案,但被否决了,主要原因是实现起来太复杂。

如果结构体记录真的实现了,倒可以作为命名参数的一种临时妥协方案。但我目前并不相信它会被实现。如果妥协方案比真正的方案还难实现,那还不如直接用真正的方案。

替代方案:结构体名推断

让命名参数更好用的一个提议是,允许在构造结构体时推断其名称。这样一来,使用结构体参数的函数,调用处看起来就基本很简洁了。

foo(_ { a: 1, b: "2", c: [] });

但这仍然要求函数显式支持这种做法,也就是定义一个结构体传进去。它同样无法移植到已有函数上。相比现状,它唯一的好处是省去在调用处写结构体名,而这并不是人们对这种模式的主要批评。私有函数依然不太可能采用这种写法。

备选方案:实现 Fn trait

这个提议我见过几次,但提出的人多半是在开玩笑。实现 Fn 相当于把其他语言里的方法重载写得更啰嗦。重载在 Rust 里实现起来既困难又有争议,所以这个方案基本不可能被采纳。

已有实践

本 RFC 中已经提到了 Rust 和其他语言里若干次为命名参数所做的尝试。

Rust 此前有过一份关于命名参数的 RFC,被否决的主要原因是它没有说明命名参数与可选参数结合后的未来形态。本 RFC 尽可能覆盖命名参数未来的各种用法,以便给出完整的图景。

论坛用户 Azerupi 提出过一份 pre-RFC,但似乎没有后续的 RFC 跟进。评论者指出了若干复杂之处。本 RFC 尝试解决语法歧义、类型系统以及按需启用命名这三方面的问题。

另一份 pre-RFC 提议采用更接近 Swift 风格的语法,本 RFC 没有沿用。那份 pre-RFC 还提议支持重载,但不支持默认参数。可以说后者比前者更重要。

如前所述,bon 提供了从函数生成 builder 的过程宏。这一功能的存在说明命名参数确有需求,而作为语言特性来处理会更合适。

其他支持命名参数的语言也被讨论过。这里提出的方案与 C# 或 Kotlin 的命名参数类似,区别在于命名参数是按需启用的。Dart 和 Swift 使用的语法同样被讨论过。Dart 的语法如下,看起来更像解构而不是命名参数。

void enableFlags({bool? bold, bool? hidden})
enableFlags(bold: true)

Swift 默认所有参数都是命名的,作者可以在变量名前加下划线来退出这一行为。要移植到 Rust 就必须对语言做破坏性改动,因此不可行。各种提案尝试过略有不同的语法,但其中不少都带来了语法歧义。

未解决的问题

  • 是否应要求具名参数按位置顺序给出?
  • pub 是最好的关键字吗,还是应该使用其他关键字?
  • 是否应要求默认值始终为 const?

未来的可能性

trait 方法的默认参数值

trait 方法此前被禁止拥有默认值,原因在于默认值应该放在 trait 声明上还是 trait 实现上尚无定论。放在实现上与关联类型和常量的工作方式一致,但这样会让这类 trait 难以保持 dyn 兼容。

一种解决方案是只允许在非 dyn 上下文中使用默认值。

trait Foo {
 fn bar(&self, baz: i32 = 0);
}

impl Foo for i32 {
 fn bar(&self, baz: i32 = i32::MAX) {}
}

fn do_bar(x: &T) {
 x.bar(); // baz doesn't need to be specified, since we're using generics
}

fn do_bar_2(x: &dyn T) {
 x.bar(); // ERROR: baz must be specified when using a method on a dyn object
}

扩展 trait

理论上,只要提供了默认参数值,就没有什么能阻止下面这种写法:

impl Default for Vec {
 fn default(capacity: usize = 0) -> Self {
 Self::with_capacity(capacity)
 }
}

不过,除了会让 default 函数的行为变得不一致之外,这在 dyn 上下文中更难解决。可能的补救办法是禁止将 Vec 转换为 dyn Default,但这样一来,引入具名参数就变成了破坏性变更。

在未来的 edition 中强制要求具名参数

要让具名参数可以向后移植到现有函数而不产生破坏性变更,就必须允许它们是可选的。这带来了一些妥协,正如“缺点”一节所述。不过,本 RFC 的起草方式使得在 Rust 未来的 edition 中强制要求具名参数成为可能。指定具名参数在旧 edition 和新 edition 中都不会产生警告。

过早这样做很可能会损害具名参数的采用,因为将它们向后移植到现有函数会变成破坏性的 API 变更。这还会让使用具名参数的函数调用更加冗长,进一步损害采用。

更灵活的重排序

如前所述,本 RFC 允许以有限的方式对具名参数重排序。保留这些限制是为了让规则易于解释和推理。不过,有些语言允许更灵活地重排序参数,未来也可以做到这一点。

C# 的主要规则是:乱序的具名参数后面不能再跟位置参数。

print_order_details(order_num: 31, product_name: "Red Mug", seller_name: "Gift Shop");
print_order_details(seller_name: "Gift Shop", product_name: "Red Mug", order_num: 31);
print_order_details("Gift Shop", 31, product_name: "Red Mug");
print_order_details(seller_name: "Gift Shop", 31, product_name: "Red Mug");

// this would cause an error
print_order_details(product_name: "Red Mug", 31, "Gift Shop");

默认值会影响推断

2022 年,Gankra 提出把默认参数值纳入泛型类型参数的推断。可以想象写成这样。

fn default_hasher() -> RandomState { ... }

impl HashMap {
 fn new(hasher: S = RandomState::default()) { ... }
}

// uses S=RandomState
let map = HashMap::new();

正如 Gankra 指出的,这种做法的问题在于,只有当 S 的类型是 RandomState 时,才允许省略该参数。不过,现有的 HashMap 工厂方法本来就是这样,除非你用的是 default——它压根没有任何参数。

泛型 const

就 Vec 而言,在这一版 RFC 里没法加一个可选的 capacity 参数,因为 Vec::new 是一个 const 函数,而分配内存无法在编译期完成。这个问题非常难解,甚至可能永远无解。不过,如果语言加入了关键字泛型,也许可以这样写:

const trait Capacity: Copy {
 const fn allocate(self) -> Option]>>;

 const fn as_usize(self) -> usize;
}

const impl Capacity for () {
 const fn allocate(self) -> Option]>> {
 None
 }

 const fn as_usize(self) -> usize {
 0
 }
}

impl Capacity for usize {
 fn allocate(self) -> Option]>> {
 Some(Box::default())
 }

 const fn as_usize(self) -> usize {
 self
 }
}

const impl Capacity> Vec {
 pub const fn new(capacity: Cap = ()) -> Self {
 Self {
 len: 0,
 capacity: capacity.as_usize(),
 buffer: capacity.allocate(),
 }
 }
}

如果不提供参数值,函数就是 const。但一旦提供了 usize,它就不再是 const 了。

来源: botahamec.dev← 返回首页