本文内容整理自:https://users.rust-lang.org/t/generic-referencing-enum-inner-data/66342
同质形式的 variants
如果你定义一个这样的枚举体:
#[derive(Debug)]
enum Foo {
Bar(u32),
Bink(u32),
}
这是一种 data-carrying 的枚举体,而且它特殊在:
- variants 中携带你所关心的
u32
类型的数据; - 这个类型位于 tuple variant 在第一个位置上。
从而它们 (variants) 形式上同质:相同的类型位于相同的位置。
那么,为了获取到 u32
数据,通常我们采用模式匹配的方式取出数据,而且对于枚举体,最常用 match
匹配:
// src: https://users.rust-lang.org/t/generic-referencing-enum-inner-data/66342/2
impl Foo {
fn get_value(&self) -> u32 {
match self {
Bar(value) => *value,
Bink(value) => *value,
}
}
}
而 @CKalt 提出一个很有启发性的观点:
It would be nice, if, when all the variants of an enum share an identical type, that there were a way to refer to that data by it's position instead of having to pull it would with some long match statement, etc...
当所有 variants 都有相同的类型时,是否可能有一种方式,按照位置引用/访问数据,而不是只能写很长的匹配语句。
习惯上把 variant 类比 struct,因为它们都有:常规型、元组型、unit 型。
而元组结构体可以直接通过 .number_of_position
语法访问数据,那么在同质形式的 variants 中也使用这种语法,是不是更方便呢:
let bink = Foo::Bink(20);
println!("bink num = {}", bink.0);
当然,这种同质形式的 variants 很少见,因为很多时候,一个枚举体里的各个 variant 形态各异:可能携带数据也可能不携带数据,而且即便携带数据,其数量、类型、位置都不一定相同。
可是这个问题并不算无趣,将思维拓展之后,会发现这是一个值得学习的案例(当然不仅仅是学习 variant)。
思路一:重新设计数据结构/数据类型
回到那个例子:你最终需要 u32
类型,那么你可以考虑从一开始就把这个数据从枚举体中分离出来。
// src: https://users.rust-lang.org/t/generic-referencing-enum-inner-data/66342/2
struct Foo {
value: u32,
kind: FooKind,
}
enum FooKind {
Bar,
Bink,
}
虽然你依然需要使用 match
匹配每种情况,但是你的数据和流程逻辑都放在结构体类型上——枚举体仅仅是作为一个标签而已(FooKind
不携带数据,被称作 fieldless enum)。这样做的缺点可能在于:因为结构体对齐方式,其内存布局会不太紧凑。
思路二:善用模式匹配
在 Rust 1.53 版本中,模式匹配有了一种新模式叫:or-patterns,其一般阐述见 reference#or-patterns 。
- 当所有 variants 都是元组型,而且第一个位置都是相同类型,那么你可以这样写:
// src: https://users.rust-lang.org/t/generic-referencing-enum-inner-data/66342/6 enum Foo { Bar(u32, u32, f64), Bink(u32, f64), } fn demo(x: Foo) { use Foo::*; let (Bar(num, ..) | Bink(num, _)) = x; println!("{}", num); } fn main() { demo(Foo::Bink(0, 1.)); }
- 类似地,当所有 variants 都是常规结构体型,而且相同类型的字段名一致,那么你可以这样写:
enum Foo { Bar {x: u32, y: u32, z: f64}, Bink {x: u32, y: f64}, } fn demo(foo: Foo) { use Foo::*; // 这个 `()` 叫分组模式 (Grouped Pattern) 用于显式控制复合模式的优先级 // 在多种模式表达不清的时候,用它来明确优先级 let (Bar {x, ..} | Bink {x, ..}) = foo; println!("{}", x); } fn main() { demo(Foo::Bink {x: 0, y: 1.}); }
即便相同类型的字段名不一致,你依然可以利用强大的模式匹配:
enum Foo { Bar {xx: u32, y: u32, z: f64}, Bink{x: u32, y: f64}, } fn demo(foo: Foo) { use Foo::*; let (Bar {xx: x, z, ..} | Bink {x, y: z, ..}) = foo; println!("{} {}", x, z); } fn main() { demo(Foo::Bink {x: 0, y: 1.2}); }
思路三:用宏来简化代码
当你以同一种方式重复编写代码时,可以考虑使用宏来简化。编写宏需要很多技巧,所以更需要通过经常学习和训练来提高。(“宏”有时候是声明宏和过程宏的统称,有时候专指声明宏,这里专指声明宏)
技巧 1:$name:pat
在宏的 13 种分类片段符 (Fragment Specifiers) 里,专门有一种来匹配模式:pat 。在思路二中,我们一直在谈论模式匹配,所以这是引导我们考虑编写宏的起点。
从最简单的例子出发:
#[derive(Debug)]
enum Foo {
Bar(u32),
Bink(u32),
}
把 (Bar(n) | Bink(n))
部分用宏进行替换:
//! src: https://users.rust-lang.org/t/generic-referencing-enum-inner-data/66342/9 //! author: https://users.rust-lang.org/u/steffahn macro_rules! Foo { ($p:pat) => { Foo::Bar($p) | Foo::Bink($p) } } fn main() { let x = Foo::Bar(42); let Foo!(n) = x; // 即 let (Bar(n) | Bink(n)) = x; println!("{}", n); }
由思路二可以知道,当 variants 是元组长度不一时或者常规结构体型时,我们都可以使用模式匹配语法轻松处理掉。
//! src: https://users.rust-lang.org/t/generic-referencing-enum-inner-data/66342/9 //! author: https://users.rust-lang.org/u/steffahn #[derive(Debug)] enum Baz { Variant1 { x: u32, y: String, z: bool, }, Variant2 { x: u32, z: bool, qux: fn(), } } macro_rules! Baz { ($($field:ident $(: $p:pat)?,)* ..) => { Baz::Variant1{ $($field $(: $p)?,)* .. } | Baz::Variant2{ $($field $(: $p)?,)* .. } } } fn main() { let baz = Baz::Variant1 { x: 42, y: "hello".into(), z: false }; let Baz!{ x, z, .. } = &baz; println!("{:?} with `x` being {:?} and `z` being {:?}", baz, x, z); }
这里有一些细节:
-
所有 item 的声明顺序与使用顺序无关;但宏不是 item,宏必须在使用之前声明。
-
Foo!
和Baz!
可适用于以下两种模式:- 解构 (destructuring):把值分解成其组成部分,所以
let Foo!(n) = x;
让n
为u32
类型。 - 以
ref
方式绑定:以非引用模式匹配引用时,绑定方式 (binding mode) 会自动变为ref
或ref mut
(详细说明见 RFC: match ergonomics)。所以let Baz!{ x, z, .. } = &baz;
中的x
和z
分别是&u32
和&bool
类型。
- 解构 (destructuring):把值分解成其组成部分,所以
-
正如 Rust By Example 的 macros 一章提到的宏的优点:减少重复;自定义语法;可变长度的参数。虽然无法拥有
Foo::Bink(20).0
或者Baz::Variant1 { x: 42, y: "hello".into(), z: false }.x
直接访问的语法,但我们依然可以通过宏和模式进行创造。
技巧 2:用宏来生成宏
当有很多类似 Baz
的枚举体时,我们可以用宏来生成同名宏 Baz!
。
//! src: https://users.rust-lang.org/t/generic-referencing-enum-inner-data/66342/9 //! author: https://users.rust-lang.org/u/steffahn #![allow(unused)] macro_rules! define_enum_macro { ($Type:ident, $($variant:ident),+ $(,)?) => { define_enum_macro!{#internal, [$], $Type, $($variant),+} }; (#internal, [$dollar:tt], $Type:ident, $($variant:ident),+) => { macro_rules! $Type { ($dollar($field:ident $dollar(: $p:pat)?,)* ..) => { $($Type::$variant { $dollar($field $dollar(: $p)?,)* .. } )|+ } } }; } // -------------------------------------------------------------------------- // #[derive(Debug)] enum Baz { Variant1 { x: u32, y: String, z: bool, }, Variant2 { x: u32, z: bool, qux: fn(), } } define_enum_macro!(Baz, Variant1, Variant2); fn main() { let baz = Baz::Variant1 { x: 42, y: "hello".into(), z: false }; let Baz!{ z, .. } = &baz; println!("{:?} with `z` being {:?}", x, z); }
一些理解/解释:
- 这里编写了两条解析规则:第一次解析的目的是匹配用户输入的语法,取出所需的 fragment specifiers;第二次解析后真正生成宏。
- 这里编写了一条内用规则 (internal rules),主要原因是:生成宏需要
$
符号,而它无法直接在 transcriber(指=>
之后的部分)表示出来,因此通过递归,把$
符号作为 tt 分类符(用于匹配 tokens tree) 传给下一次解析。而#internal
被视为内用规则的名称,你可以取任何名字,也可以使用其他前缀符号(比如常见的@internal_name
)。
结语:
- 通过本篇文章,你可以感受到 Rust 的模式 (patterns) 无处不在,见识了它与宏完美结合的典范。
- 如果你觉得这篇文章有用,请给 steffahn 的 回答 一个点赞以示鼓励。我只是对一则 Rust User Forum 的帖子进行了梳理、记录和补充,没有 steffahn 的回答,也就没有这篇文章。
- Rust User Forum 是学习 Rust 和体验 Rust 圈氛围的好地方,每次逛都可以学到许多额外的技巧与收获。一些高质量的回答淹没在茫茫帖子中,这是可惜的,或许以文章/笔记的方式,让更多人看见,这会是一种不错的尝试。