3.2 默认参数机制
译文 · 基于 Learning Rust
默认参数机制
原文链接: https://quinedot.github.io/rust-learning/misc-default-param.html
Rust 中的默认参数不如人们希望的那么方便。默认类型参数的 RFC 从未完全完成;特别是「推断回退到默认值」部分被无限期推迟。因此有时默认参数不会生效,你必须显式写出或使用其他变通方法。变通方法为何表现不同也可能不清楚。
默认参数也尚未写入参考手册。
本页旨在解释当今默认参数背后的机制,并澄清变通方法的确切含义。关于推断与默认参数未来如何交互的探索,我推荐 Gankra 这篇精彩博客文章。
动机
最可能遇到默认参数「不工作」的原因是:某些表达式脱糖为将所有类型(和 const)参数替换为推断变量,结合推断变量不会回退到默认值这一事实。
什么是推断变量?对类型和 const 参数,推断变量与通配符 _ 相同,告诉编译器为你推断类型或 const。
(本指南大部分关注类型。后面有专门关于 const 参数的小节。)
看一些涉及默认参数编译失败的例子:
1
2
3
4
5
6
7
8
9
10
11
12
| # use std::collections::HashSet;
// `HashSet` 将作为同时有必需(非默认、非生命周期)和默认参数的类型的贯穿示例:
// struct HashSet<Key, S = RandomState> { .. }
// 以下 `insert` 足以让编译器推断 `HashSet::default` 调用中省略的
// `Key` 参数,但不能推断 `S` 参数。
let mut hs = HashSet::default();
hs.insert(String::new());
// 含义相同:*所有*类型(和 const)参数都变成推断变量,包括默认参数。
let mut hs = HashSet::<_, _>::default();
hs.insert(String::new());
|
这可能令人困惑,因为类似代码能工作:
1
2
3
4
5
6
7
8
9
10
11
12
13
| # use std::collections::HashSet;
// 能编译,但编译器可以自己推断 `Key`
//(是 `S` 无法推断),为什么?
let mut hs = HashSet::<String>::default();
hs.insert(String::new());
// 事实上...这个也能编译!
let mut hs = HashSet::<_>::default();
hs.insert(String::new());
// 而 `new` 没有 `default` 那样的推断问题,也可能令人困惑。
let mut hs = HashSet::new();
hs.insert(String::new());
|
当类型的所有类型(和 const)参数都有默认值时,也可能出现错误:
1
2
3
4
5
6
7
8
9
10
11
| // 这将是所有非生命周期参数都有默认值的贯穿示例:
pub enum Foo<T = String> {
Bar(T),
Baz,
}
// 失败,因为省略的参数脱糖为推断变量。
let foo = Foo::Baz;
// 因此含义完全相同:
let foo = Foo::<_>::Baz;
|
某些变通方法可能更令人困惑:
1
2
3
| # pub enum Foo<T = String> { Bar(T), Baz }
// 这个有效!
let foo = <Foo>::Baz;
|
我们要解释哪些表达式最终有问题,以及变通方法为何解决问题。
简要说明
首先说明为何最后例子中用 <> 包裹类型有效:
1
2
| # pub enum Foo<T = String> { Bar(T), Baz }
let foo = <Foo>::Baz;
|
前导 <Foo>:: 记号称为「限定路径类型」。
关于省略默认参数的简短答案是:在 <> 中,类型行为与类型标注相同:
1
2
3
| # pub enum Foo<T = String> { Bar(T), Baz }
// 也有效:
let foo: Foo = Foo::Baz;
|
类型标注以可能更接近直觉的方式使用默认参数。(下面探讨细节。)注意类型在 turbofish 等其他 <> 位置也像类型标注,不仅是限定路径类型。
至于这里的差异:
1
2
3
4
| # use std::collections::HashSet;
// 若将 `HashSet::new()` 改为 `HashSet::default()` 则失败。
let mut hs = HashSet::new();
hs.insert(String::new());
|
此例有效是因为 HashSet::new(及许多其他方法)仅为 HashSet<_, RandomState> 定义。相比之下,Default 为所有可能的 HashSet<_, _> 实现。因此在某种意义上,这是 HashSet 实现侧的变通!若推断与默认参数协同工作, new 大概也会为所有可能的 hasher 定义。
最后看这个变通:
1
2
3
4
| # use std::collections::HashSet;
// 记住,`HashSet::default()` 失败。
let mut hs = HashSet::<_>::default();
hs.insert(String::new());
|
关键区别是:若没有指定任何必需参数,则所有类型(和 const)参数——包括默认参数——都用推断变量填充。但若指定一个或多个非生命周期参数,则脱糖为限定类型路径——默认参数行为与类型标注相同。
1
2
3
4
5
6
7
| # use std::collections::HashSet;
// 以下相同且失败:
// let mut hs = HashSet::default();
// let mut hs = HashSet::<>::default();
// let mut hs = HashSet::<_, _>::default();
let mut hs = <HashSet::<_, _>>::default();
hs.insert(String::new());
|
1
2
3
4
5
| # use std::collections::HashSet;
// 以下相同且成功:
// let mut hs = HashSet::<_>::default();
let mut hs = <HashSet<_>>::default();
hs.insert(String::new());
|
如例所示,显式使用 _ 算作指定类型参数。还要注意,脱糖为「所有参数都是推断变量」仅在类型不在 <> 内时发生。
类型位置的机制详解
「类型位置」指语言明确期望类型的上下文。包括变量类型标注、实现头、类型参数字段本身和限定路径类型。
在类型位置,只能省略默认参数。省略的默认参数被其默认类型(或 const 值)替换(即不是推断变量)。
看一些例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
| # use std::collections::HashSet;
# use std::hash::RandomState;
# enum Foo<T = String> { Bar(T), Baz, }
// 这些标注含义相同
// vvvvvvvvvvv
let e: Foo = Foo::Baz;
let e: Foo<> = Foo::Baz;
let e: Foo<String> = Foo::Baz;
// 这些标注含义相同
// vvvvvvvvvvvvvvvvvvvvvvvvvvvv
let hs: HashSet<String> = Default::default();
let hs: HashSet<String, RandomState> = Default::default();
|
以下错误表明省略的参数不是推断变量,推断变量不会回退到默认值。
1
2
3
| # enum Foo<T = String> { Bar(T), Baz, }
// 因歧义失败
let e: Foo<_> = Foo::Baz;
|
1
2
3
4
| # use std::collections::HashSet;
# use std::hash::RandomState;
// 因歧义失败
let hs: HashSet<String, _> = Default::default();
|
1
2
3
| # enum Foo<T = String> { Bar(T), Baz, }
// 失败,因为省略的类型正是默认类型(`String`)
let e: Foo = Foo::Bar(0);
|
最后一个例子与我们见过的大多数例子相反:是希望推断覆盖默认值的情况。将标注改为 Foo<_> 可编译(但对这例更简单的修复是去掉冗余标注)。
关于限定路径表达式
<> 内的类型处于类型位置,包括限定路径表达式。
限定路径表达式是路径表达式以 <> 内段开头的情况。在 RFC 0132 中定义,有两种形式:
1
2
3
4
5
| // vvvvvvvv `<T>` 其中 `T` 是类型
let s = <String>::default();
// vvvvvvvvvvvvvvvvvvv `<T as Tr>` 其中 `T` 是类型,`Tr` 是 trait
let s = <String as Default>::default();
|
第一种形式可解析为固有函数或 trait 方法,第二种只能解析为命名 trait 的方法。Rust 没有「trait 推断变量」,因此必须命名 trait;例如不能用 _ 代替 trait。(仍可用于 trait 的类型参数。)
还要注意虽然以下有效:
1
2
| // vvvvvvv trait 名
let s: String = Default::default();
|
以下不允许:
1
| let s: String = <Default>::default();
|
Trait
trait 的默认参数与类型的默认参数在「类型位置」内外工作方式相同。将路径中的 trait 视为限定路径的糖时,概念上的脱糖如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| trait Trait<One, Two = String>: Sized {
fn foo(self) -> (Self, One, Two) where One: Default, Two: Default {
(self, One::default(), Two::default())
}
}
impl<T, U> Trait<T, U> for i32 {}
impl<T, U> Trait<T, U> for f64 {}
// 失败版本:
//let _: (i32, (), _) = Trait::foo(0);
//let _: (i32, (), _) = Trait::<_, _>::foo(0);
let _: (i32, (), _) = <_ as Trait<_, _>>::foo(0);
// ^^^^^^^^^^^^^^^^^^
|
1
2
3
4
5
6
7
8
9
10
11
| # trait Trait<One, Two = String>: Sized {
# fn foo(self) -> (Self, One, Two) where One: Default, Two: Default {
# (self, One::default(), Two::default())
# }
# }
# impl<T, U> Trait<T, U> for i32 {}
# impl<T, U> Trait<T, U> for f64 {}
// 有效版本:
let _: (i32, (), _) = Trait::<_>::foo(0);
let _: (i32, (), _) = <_ as Trait<_>>::foo(0);
// ^^^^^^^^^^^^^^^
|
唯一新值得注意的是实现类型在此情况下是推断变量。
基本属于历史的小注
Edition 2021 之前,可以在 dyn Trait 类型上省略 dyn(虽然会触发 lint)。这意味着同一名称可指 trait 或类型(trait 对象类型)。用哪个取决于上下文。
例如:
1
2
3
4
5
6
7
8
9
10
11
12
| let _: i32 = Trait::name(0.0);
// 若 `Trait` 有名为 `name` 的方法,上面脱糖为:
let _: i32 = <_ as Trait>::name(0.0);
// 但若没有,且 `dyn Trait` 有名为 `name` 的方法,
// 第一行反而脱糖为:
let _: i32 = <dyn Trait>::name(0.0)
// 以下行总是指 `dyn Trait`
//(trait 不允许在 `<>` 中,除非在 `as` 之后)。
let _: i32 = <Trait>::name(0.0);
|
表达式中类型的更多说明
本节「表达式中的类型」指在表达式中但不在 <> 内的类型(例如不是限定路径类型或类型参数)。这些位置必须使用 turbofish(如 Vec::<String>)而非仅附加参数列表(如 Vec<String>)。
在这些位置,总是允许省略所有类型和 const 参数,即使有必需(非默认、非生命周期)参数。这样做时——即使所有类型和 const 参数都有默认值——行为与对所有参数使用类型推断变量(_)相同。
若不省略所有非生命周期参数——即若指定一个或多个类型参数或 const 参数——则必须指定所有必需参数。换言之:若至少指定一个类型或 const 参数,只能省略默认参数(和生命周期)。
省略默认参数的行为如下:
- 若指定零个非生命周期参数
- 若指定一个或多个非生命周期参数
上面我们从脱糖为限定类型路径的角度表述了表达式中类型对默认参数的不同行为。但该行为也适用于其他上下文,如 struct 表达式语法:
1
2
3
4
| struct Two<T, U = String> { t: T, u: U }
// 有歧义
let _ = Two { t: (), u: Default::default() };
|
1
2
3
| # struct Two<T, U = String> { t: T, u: U }
// 但这个有效
let _ = Two::<_> { t: (), u: Default::default() };
|
此位置不允许限定路径类型,因此并非所有路径变通都适用。
1
2
3
4
| struct One<T = String> { t: T }
// 有歧义
let _ = One { t: Default::default() };
|
1
2
3
| # struct One<T = String> { t: T }
// 语法上不接受
let _ = <One> { t: Default::default() };
|
自 Rust 1.89 起,也可在限定路径类型(或任何其他类似类型注解的位置)用 _ 表示推断的、非默认的 const 参数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| struct Pixel<const N: usize>([u8; N]);
impl<const N: usize> Default for Pixel<N> {
fn default() -> Self {
Self([0; N])
}
}
// 调用此函数会让 Rust 推断 `const` 参数为 `3`
fn drive_inference(_: Pixel<3>) {}
// Rust 1.89 之前就可以。
let pixel = Pixel::default();
drive_inference(pixel);
// Rust 1.89 之前失败,因为 `const` 参数不允许 `_`,但现在可以。
let pixel = <Pixel<_>>::default();
drive_inference(pixel);
let pixel: Pixel<_> = Default::default();
drive_inference(pixel);
|
非类型泛型参数
本指南主要关注类型参数。我们全文尽量措辞谨慎,但花点时间专门说明非类型参数与默认值如何配合。
生命周期参数
生命周期参数不能给默认值,也不改变我们讨论的默认参数行为。
我尽量用「指定一个或多个非生命周期参数」而非「空参数列表」等表述。但更明确:生命周期参数的包含或省略不改变默认参数如何工作。
例如以下仍是未指定必需参数的情况,因此对类型参数使用推断变量(然后因歧义失败)。
1
2
3
4
5
6
7
| pub enum Foo2<'a, T = String> {
Bar(&'a T),
Baz,
}
let foo = Foo2::<'_>::Bar;
let foo = Foo2::<'static>::Bar;
|
可将泛型列表视为两个列表:生命周期(未省略时必须在前)和非生命周期。例如:若注解一个生命周期,必须注解所有生命周期,但仍可省略非生命周期参数。若注解某些非生命周期,仍可省略生命周期。
const 参数
const 参数默认值在 Rust 1.59 稳定, 同时允许在参数列表中混合 const 和类型参数(否则有默认类型参数会迫使所有 const 参数也有默认值等)。const 参数的 _ 在 Rust 1.89 稳定。
一般而言,默认 const 参数行为与默认类型参数相同(尤其 _ 对 const 稳定之后)。
关于实现和函数参数的警告
默认类型参数在实现头和函数参数列表中的行为与其他「类型位置」相同。与省略生命周期参数相比可能令人意外。
在实现和函数参数列表中,省略生命周期参数会引入新的独立泛型生命周期参数。但省略类型参数从不意味着「引入新泛型」。省略的类型参数总是解析为单一类型(或错误),无论该类型来自推断还是默认类型。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| pub enum Foo<T = String> { Bar(T), Baz }
// 这是仅针对 `Foo<String>` 的实现:
impl Foo {
fn papers_please(&self) {}
}
// 这是针对所有(`Sized`)`T` 的实现:
impl<T> Foo<T> {
fn welcome(&self) {}
}
let foo = Foo::Bar(0);
// 有效
foo.welcome();
// 失败
foo.papers_please();
|
(在其他位置省略生命周期有时意味着 'static,有时意味着「为我推断」,但那是另一天的话题。生命周期参数不能有默认值。)
关于用默认参数扩展结构体的警告
语言中有默认参数的部分动机是:可以在原本没有参数的类型上添加参数而不破坏所有使用者。例如截至本文写作,Vec<T> 实际是 Vec<T, Allocator>。 但 Allocator 类型参数并非一直存在(目前仅在不稳定版可用)。
此时应该清楚,尽管添加参数时提供了默认值,仍可能发生比预期更多的推断破坏。通常不视为重大破坏性变更,但仍可能显著。
特别是,若结构体之前没有类型或 const 参数,添加一个后的影响可能更糟——因为使用者无法指定任何非生命周期参数(那会「激活」默认值)。
从此处的评论开始 有真实例子:为 String 添加分配器比为 Vec<_> 添加需要更复杂的变更。(该 PR 也是第三次尝试添加该参数。)
其他位置的默认类型参数
在类型、trait 或 trait 别名之外的声明上声明默认参数要么报错,要么触发默认拒绝的 lint,说明支持将被移除。
尽管有 lint,函数上的默认参数与类型声明上的默认参数工作方式相同。但每个函数有唯一类型(「函数项类型」)无法命名。因为函数项类型无法命名,我们讨论的大多数变通无法应用。
话虽如此,使用 turbofish 且有一个或多个非省略类型参数的情况仍有效:
1
2
3
4
5
6
7
| #[expect(invalid_type_param_default)]
fn example<X, Y: Default = String>() -> Y {
Y::default()
}
let s = example::<()>();
println!("{}", std::any::type_name_of_val(&s));
|
据我所知,impl 头上的默认参数没有任何用途。实现根本没有名称(因此参数在 impl 关键字上)。
1
2
3
4
| # struct MyStruct;
# trait WhyThough<T, U> { }
#[expect(invalid_type_param_default)]
impl<T = String> WhyThough<i32, T> for MyStruct {}
|
GAT 上的默认参数目前直接被拒绝,即使允许该 lint。
1
2
3
4
| #![allow(invalid_type_param_default)]
trait MyTrait {
type Gat<T = String>;
}
|