关于参数的争论

Rust 的形参与实参没有花哨特性,但 Ruby 的 redirect_to 支持字符串、模型、哈希等多种调用方式。作者借编码 agent 重新审视具名参数,改变了坚持十几年的看法。

中文
复制
浅色背景上并排两段 Rust 代码,展示同一个函数在具名参数与位置参数下的不同调用写法

下面是一段 Rust 代码:

// define a function
fn foo(x: i32, y: i32) -> i32 {
 // body elided
}

// call it
let z = foo(5, 6);

在编程语言的行话里,x 和 y 称为形参,5 和 6 称为实参。

Rust 在形参和实参方面没有太多花哨的特性,但其他语言有。比如,这是 Ruby 中的一个函数,Ruby 是我写过大量代码的另一门语言:

# its definition in Rails
def redirect_to(options = {}, response_options = {})
 # body elided
end

# you can call it in all of these ways:

# pass a string with a URL
redirect_to "http://www.rubyonrails.org"

# pass an instance of a model to go to it, if post.id = 5 then this might go to
# "/posts/5"
redirect_to @post

# invoke an action directly, might be equivalent to the above in a Posts controller
redirect_to action: "show", id: 5

# redirect to a model's URL, passing a flash message
redirect_to post_url(@post), alert: "Watch it, mister!"

# do the same but also set a specific HTTP code
redirect_to post_url(@post), status: :found

# do both
redirect_to post_url(@post), status: 301, flash: { updated_post_id: @post.id }

我曾经很喜欢 Ruby,现在我喜欢 Rust。说实话,redirect_to 之类的东西正是我如此反对 Rust 引入这类花哨特性的重要原因:它们可以非常简洁,在某种眼光看来甚至很美,但也让人极难搞清楚一个函数到底能做什么。你基本只能指望有人写了好的文档,而 Rails 的文档虽然相当不错,却不是每个库都有。

需要说明的是,这里其实捆绑了好几样东西:灵活的实参类型、options hash、默认值,以及看起来像关键字的语法。让我产生怀疑的是这整套 API 风格,而不是实参标签本身。

那么,这些特性到底是什么?

具名参数

最简单的一种是具名参数。顾名思义,它允许我们在调用处显式写出参数名:

// without named parameters
let z = foo(5, 6);

// if Rust had them
let z = foo(x: 5, y: 6);

具名参数的好处是能在调用处提供更多信息,同时也更灵活。有些语言还允许以不同的顺序提供具名实参:

// since we have names, we can write this:
let z = foo(x: 5, y: 6);

// but we can also write this:
let z = foo(y: 6, x: 5);

具名参数的缺点是可能变得相当冗长:

# FastAPI in Python, UserOut class not shown
@app.get(
 "/me", 
 response_model=UserOut, 
 status_code=200, 
 tags=["Users"],
 summary="Get current user profile"
)
def get_current_user():

我特意没有用极端的例子来树稻草人,但 get 可以接受 23 个不同的关键字实参,所以真写起来可能会非常非常长。

在传递表达式时,具名实参可能很好用,因为这样我们在调用处能多理解一些东西。在上面的例子里,status_code=200 比光秃秃的 200 好得多;如果你熟悉 HTTP,它是什么其实挺明显的,但多出来的这点清晰度还是不错的。不过,真正复杂的例子常常会被拆成变量,再转发给函数,这时它就既冗长又多余了。以 FastAPI 内部的一个例子来说,那里直接用所有这些选项调用 get:

self.router.get(
 path,
 response_model=response_model,
 status_code=status_code,
 tags=tags,
 dependencies=dependencies,
 summary=summary,
 description=description,
 response_description=response_description,
 responses=responses,
 deprecated=deprecated,
 operation_id=operation_id,
 response_model_include=response_model_include,
 response_model_exclude=response_model_exclude,
 response_model_by_alias=response_model_by_alias,
 response_model_exclude_unset=response_model_exclude_unset,
 response_model_exclude_defaults=response_model_exclude_defaults,
 response_model_exclude_none=response_model_exclude_none,
 include_in_schema=include_in_schema,
 response_class=response_class,
 name=name,
 callbacks=callbacks,
 openapi_extra=openapi_extra,
 generate_unique_id_function=generate_unique_id_function,
)

我们在这个例子里其实还偷偷塞了两个特性:一个函数有 23 个参数,我们怎么只传了五个进去?

可选参数与默认参数

顾名思义,可选参数让你可以选择不传某个参数,默认参数则让你在参数没传时给它设一个默认值。

回到那个例子:

@app.get(
 "/me", 
 response_model=UserOut, 
 status_code=200, 
 tags=["Users"],
 summary="Get current user profile"
)
def get_current_user():

22 个可能的参数里我们只传了五个。要是每次都得把 22 个全传一遍,那也太啰嗦了。所以只要给部分参数声明默认值,就可以在调用处把它们省掉,不传这些参数,直接拿默认值。我们在 Ruby 里见过:

# its definition in Rails
def redirect_to(options = {}, response_options = {})

= {} 是 options 和 response_options 的默认值:一个空的 hash map。

可选参数和默认参数经常一起出现:毕竟,如果你不传某个参数,那个形参该取什么值?如果你的语言有某种 null 值,那可以是一种解法,用起来就像没有默认值的可选参数。

但下一个特性可以让你真正只拥有其中一个而没有另一个:函数重载。

函数重载

支持函数重载的语言允许你定义多个同名但签名不同的函数。调用哪个函数取决于你传了哪些参数。这样一来,我们就能真正拥有没有默认值的可选参数。在 Java 里:

// Version 1: Requires both arguments
void connect(String url, int timeout) { ... }

// Version 2: Timeout is optional to the caller, and handled internally
void connect(String url) { ... }

这里,如果你传了 timeout,就会命中第一个函数;不传,就命中第二个。参数不是变成 null,而是根本不存在。

这是「函数派发」的一种具体形式,说白了就是「一个函数被调用时,到底具体调用了什么?」做法有很多种,我不想在这篇文章里展开讲,但可能性确实五花八门:静态派发与动态派发、单派发与多派发、谓词派发、模式匹配派发、基于原型的派发……也许哪天我再专门写一篇。

那么,这里有哪些利弊?

这些特性让我不安

如上所述,Rust 目前不支持这三个特性中的任何一个。而多年来,社区一直在请求加入它们。这里有一个 12 年前的 GitHub issue 在讨论这项支持,里面甚至提到了我们没聊过的东西:“可变元数参数列表”。

这就引出了我对这些特性的第一个不满:它们实在太多了,而且彼此纠缠。前面说过,如果你有可选参数,你多半也想要默认参数。如果你有具名参数,你是想只支持具名参数,还是两者都支持?全都支持实在太诱人了,然后你就堆起了一座巨大的复杂度大山。

在现在的 Rust 里,你只能写多个名字略有不同的函数:

// a new empty vector
let v = Vec::new();

// one with a set capacity
let v = Vec::with_capacity(5);

代价不过是“给函数起两个不同的名字”,换来的是极其简单的规则:只有一个函数定义,按名字调用。完事。能像下面这样写当然好:

let v = Vec::new();
let v = Vec::new(5);
let v = Vec::new(capacity: 5);

也许吧。但在我看来,代价非常非常高。如果你习惯了这些特性,可能觉得代价没那么高,可我身上纹着 Ruby。我见过太多烂摊子。所以我渐渐喜欢上了 Rust 在这方面的简洁。没错,如果你的函数有一大堆参数,你可能得写个 builder,而 builder 有它自己的一套啰嗦:

// if Vec had a builder
let v = Vec::new()
 .with_capacity(5)
 .build();

// all of the code you have to write to make the builder elided

但语言本身保持简单,规则也容易。对于一门已经被认为很复杂的语言来说,我一直觉得这样是对的。这也是这些年来我一直反对各种以这种方式扩展 Rust 的提案的原因。

但最近,我对其中一个特性改变了看法,仅此一个,如果 Rust 加入它,我会觉得可以接受。也许吧。另外要说明的是,我已经多年没参与 Rust 的开发了,所以我的意见其实无关紧要,但无所谓:这是我的博客,意见就是用来表达的。

我现在能接受具名参数了

我觉得 Rust 可以有具名参数,但不能有可选参数或默认参数。真正改变我想法的是编码 agent。抱歉,你可能已经听烦了我聊 AI,但先听我说完。

如上所述,我对具名参数的不满之一就是啰嗦。但这份啰嗦换来的是清晰。我不认为啰嗦和清晰永远是一回事;比起 do/end,我更喜欢 {},个人也觉得它更好读。不过我们来看看 image crate 里的这个函数:

// definition
pub fn crop_imm(
 image: &I,
 x: u32,
 y: u32,
 width: u32,
 height: u32,
) -> SubImage { ... }

// calling it
let cropped = image::imageops::crop_imm(&img, 10, 20, 200, 100);

// if we had named arguments
let cropped = image::imageops::crop_imm(
 image: &img,
 x: 10,
 y: 20,
 width: 200,
 height: 100,
);

这确实大大提升了可读性,毫无疑问。但对我来说,把这些全部打出来实在不太值。可如果是 Claude 来写呢?那我就没那么在意了。而且对人类来说的可读性优势,对 agent 来说更强:调用点上多出来的这些清晰文字,似乎(我还没做过真正的评测……)更有帮助,消耗的 token 和调用也更少。如果 agent 读到上面那个具名参数的例子,它不用去查函数签名就能知道 10 是 x 的值。“对人类好的,对 agent 也好”再次应验。

这也是我仍然反对可选参数的原因:它们刻意隐藏了传进函数的东西,让调用点变得不清晰。它们的好处是少打点字。但我现在不用自己打字了。所以好处没了,坏处还在。这也没有解决 FastAPI 例子里传 foo=foo 的啰嗦问题,它依然是冗余的。但坏处总算被缓解了一部分。

值得注意的是,这套推理同样适用于 builder 模式,这也是我在 Rust 里尽量少用 builder 的原因。它可以模拟具名参数,这是好事,但它也能模拟可选/默认/可变参数,这就是坏事。有时候这份麻烦值得,但不是每次都值得,甚至可能大多数时候都不值得。

这样我们就消除了最大的缺点,却保留了最大的优点。听起来不错。但即便我们喜欢这个特性,具体到 Rust,还有大量实际问题,使得把它加入语言并非轻而易举。

但实际问题一大堆

“这个特性在抽象层面好不好”和“这个特性该不该实现”完全是两回事。实现具名参数有 大量 悬而未决的问题和缺点。一个简单的问题是,你没法干掉之前那些函数,with_capacity 会永远存在。跟后面那些更难的问题比起来,这还算小缺点。

首先,严格来说 Rust 的参数不是名字,而是模式。名字只是简单的模式,但你可以写出更复杂的:

fn foo((x, y): (i32, i32)) {

这个参数没有名字,它是一个引入两个绑定的模式。你可以设计某种语法来区分外部名字和内部名字,但复杂度又冒头了。

函数变成值时怎么办?今天就可以这么写:

fn resize(width: u32, height: u32) {
 // body elided
}

fn offset(dx: u32, dy: u32) {
 // body elided
}

let f: fn(u32, u32) = if resizing { resize } else { offset };

f 的参数该用什么名字?它们可以各自命名。这会变成错误吗?调用 f 时能用哪些名字?更麻烦的是,Rust 现在其实接受这样的代码:

type Callback = fn(width: u32, height: u32);

fn f(g: Callback) {}

fn bar(x: u32, y: u32) {}

fn main() {
 f(bar) ;
}

Callback 里的名字只是文档,你看,它们并非必需。这会变成错误吗?我们要不要要求 bar 改掉名字?trait 定义其实也一样:

trait Writer {
 fn write(&mut self, data: &[u8]);
}

struct Sink;

impl Writer for Sink {
 fn write(&mut self, bytes: &[u8]) {
 // body elided
 }
}

这是错误吗?还是没问题?

还有一个问题:求值顺序。

fn consume(data: Vec, length: usize) {
 // body elided
}

目前 Rust 在调用点总是从左到右求值参数。那如果用具名版本:

fn consume(data: Vec, length: usize) {
 // body elided
}

fn consume2(length: usize, data: Vec) {
 // body elided
}

fn main() {
 let data = vec![1, 2, 3];

 // this is an error, borrow after move
 consume(data, data.len());
 // this is okay
 consume2(data.len(), data);

 // does this compile or not?
 consume(length: data.len(), data: data);
}

如果我们按定义保持从左到右的求值顺序,却允许你以任意顺序使用具名值,那调用点突然就变成有些顺序能编译、有些不能。我们要不要把求值顺序改成“能编译就行”?这感觉非常危险且令人困惑。

Rust 在结构体字段上确实允许这么做:

struct Args {
 data: Vec,
 length: usize,
}

fn main() {
 let data = vec![1, 2, 3];
 
 // works
 let args = Args {
 length: data.len(),
 data,
 };

 // doesn't
 let args = Args {
 data,
 length: data.len(),
 };
}

很多关于具名参数的提案都想把它和 struct 统一起来,原因之一就在这里。就像我之前说的,这并非无法克服,只是有些东西需要理清。

另一个小问题是兼容性风险:一旦开始给参数命名,重命名就会破坏调用方。这可以接受吗?能不能引入别名来缓解?那就更复杂了……

这些只是设计空间中会冒出来的一部分问题,而这篇文章已经太长了,所以先写到这里。但想要做这件事的提案必须面对这一切,甚至更多,这也是语言设计困难的原因。

结论

我愿意相信,随着世界变化、或者我对世界的理解变化,我的观点也会随时间改变。所以我觉得,分享一下我坚持了十多年的一个观点最近如何发生了一点变化,可能挺有意思。我仍然不确定具名参数是否适合 Rust,但至少现在我对这个想法多少有些开放了。如果真有人决定继续推进这件事,就看语言团队怎么定了。而确实有人在推,比如,我写这篇文章的部分原因就是今天看到了这篇帖子。这篇文章既不是支持它,也不是反驳它,其实我还没读,只是我最近一直在想这件事(在做 Rue 的时候),看到它刷过去,就促使我把一些想法写下来。你应该去看看,因为那是一份真正的提案,而我这篇更像是对这个大致想法和我个人看法的探讨。

来源: Steve Klabnik← 返回首页