rust随笔(一)
Rust随笔(一)
最近将《the book》看到了闭包部分,感触良多,发现rust的设计其实和oop关系不大,并且“组合优于继承”这句话不仅仅只是和结构体说的,还是对函数说的。本篇探讨一下问题:
- 何为生命周期
- rust和代码整洁之道有什么关系
- 为什么说学好组合子就学会了rust基本思想了
何为生命周期
我这里不从具体的函数定义出发,我从闭包出发,在定义中,闭包有以下trait:
trait Fn;
trait FnOnce;
trait FnMut;
这三种 trait 并非人为指定,而是 Rust 编译器根据闭包对上下文的“使用模式”自动推导的。 例如,下面的闭包不会获取 c 的所有权,因此编译器推导为 Fn:
let c = 1;
let closure = |x| x + c; // 只读取,不修改
而若闭包消费了变量(如 move),那么只能是 FnOnce:
let s = String::from("hello");
let closure = move || println!("{}", s); // s 被 move 进闭包
FnOnce的定义是可以消费外部上下文的变量(这里的命名感觉有点隐晦,因为变量在不引用的情况下通常只能被调用一次,然后这个闭包消耗这个变量之后就再也不能调用了。很多人以为这个闭包只能调用一次,其实关键在于它是否仍拥有捕获变量的所有权)。 如果闭包对变量进行了可变借用,则需要 FnMut:
let mut count = 0;
let mut closure = || {
count += 1; // 可变借用
};
那么这个和生命周期有什么关系呢,从以上的例子可以得到:
Fn:闭包可以在多次调用中共享使用捕获变量,因此需要变量在 整个闭包使用期内保持有效;FnMut:闭包可能多次调用且每次调用会修改变量,因此生命周期依旧不能太短,但有可变借用排他性要求;FnOnce:闭包只调用一次,消费变量所有权,因此生命周期最宽松,可以是短生命周期或者临时值。 进一步来说,Fn如果出现了返回引用的情况,那这个变量的生命周期甚至要比整个闭包都要长,同理FnMut也是,但是FnOnce就不需要考虑,因为它消费所有权并且只能返回所有权。所以说,其实生命周期就是变量在一整段上下文的存活时间,不仅仅是函数的,而函数在这里可以视作为上下文的一个分段或者说是一个结束段。回到这个经典的例子:
fn judge_size<'a>(x:&'a str,y:&'a str) -> &'a str {
if (x > y){
x
}else{
y
}
}
可以从或者例子观察到,形参都是引用的,返回值也是引用的,那么在上下文的理解中,这个函数必然在这两个变量的上下文的其中一段。你也许会说,如果存在两个不同的或者说是n个不同的生命周期呢。其实这也是一样的,因为在具体的某一个函数里面,生命周期只有比较关系,不存在衡量总长度,并且只有这个函数变成入参的上下文的其中一个分段,生命周期才有意义,如果是一个结束段(即不返回引用),那么这个里的生命周期都是相等的。而且在生命周期的比较中,较长的生命周期通常是需要返回的,若不需要返回的,相当于这个入参在这个函数就结束了。那么在n个带生命周期的引用中,我们只需要区分哪些需要返回哪些不需要返回,这样生命周期只剩下两个了,以下代码仅供参考
//这里只需要一个,因为都要返回
fn same_life_time<'a>(a:&'a str,b:&'a str) -> (&'a str,&'a str){
println!(a);
println!(b);
(a,b)
}
//这里的b需要比a要长,因为需要返回 (更准确地说:返回的是b,其生命周期是'b,且'b必须至少和'a一样长)
fn different_life_time<'a,'b>(a:&'a str,b:&'a str) -> &'b str
where 'b:'a
{
println!(a);
println!(b);
b
}
//这里就不需要显性的标注生命周期了,因为走到尽头了,这里需要澄清的一点就是,是入参的引用在这个函数走到了尽头,但是在调用方那里变量还存活。可以说这是入参上下文的一个结束分支。
fn move_out_life_time(a:&str,b:&str){
println!(a);
println!(b);
}
fn main(){
let a = "this is a"; // 'static lifetime
let b = "this is b"; // 'static lifetime
let slt = same_life_time(a,b);
println!("slt: ({}, {})", slt.0, slt.1);
let dlt = different_life_time(a,b);
println!("dlt: {}", dlt);
move_out_life_time(a,b);
// a 和 b 在 main 函数的剩余部分仍然有效
println!("a: {}, b: {}", a, b);
}
rust和代码整洁之道有什么关系
这里有个函数:
fn process_complex_task<'a, 'b, 'c>(
data_source_a: &'a DataSourceA,
data_source_b: &'b DataSourceB,
config_c: &'c ConfigC,
// ...可能还有其他参数
) -> Result<Output<'a, 'b, 'c>, Error> {
// 很多逻辑,同时用到了 data_source_a, data_source_b, config_c
// Output 结构体可能也需要持有这些不同生命周期的引用
}
当一个函数的参数列表和返回类型中充斥着多个独立的生命周期参数('a, 'b, 'c 等)时,这往往是一个信号:
- 职责过重:这个函数可能在做太多的事情,它需要同时关注和协调来自不同来源、具有不同生命周期的数据。这违反了单一职责。
- 耦合性高:函数与多个数据源的生命周期紧密耦合,任何一个数据源的生命周期管理发生变化,都可能影响到这个函数。
- 理解和维护困难:这样的函数签名和内部逻辑通常更难理解、测试和维护。生命周期的交互会变得非常复杂。 Rust 的编译器会诚实地将这种复杂性暴露给你(通过要求你显式标注这些生命周期)。这并非 Rust 的缺陷,而是它在引导你思考更清晰的设计。
若将这些参数都归纳到同一个结构体上的话:
struct User<'a>{
name:&'a str,
email:&'a str,
phone:&'a str
}
struct Address<'b>{
number:&'b str,
address:&'b str,
}
struct UserAddress<'a,'b>{
number:&'a str,
name:&'b str,
phone:&'b str
}
fn process_user_address(ua:UserAddress) {
// 实现逻辑。。。
}
同样是较为复杂,这种设计在语法上并没有问题,但是若出现n个引用的话这个结构体就有n中生命周期,那同样证明一点,即使将参数包装进结构体,如果这个结构体本身混合了来自完全不同上下文、生命周期差异巨大的引用('a 和 'b 如果代表了不应强行耦合的生命周期),那表明这个函数的权责过多了,rust语法就直接告诉你这个函数设计的太复杂了,需要解构。 所以,rust的结构思想上相当一部分需要你在设计上符合《代码整洁之道》所提出来的观点,就是函数单一职责化,代码易于阅读、理解和维护。由代码直接呈现意图,而不是通过注释。并且在前面的闭包学习中,可以的出来rust鼓励变量不可变性。这种“输入 -> 处理 -> 新输出”的流程非常接近函数式编程中纯函数的思想,即函数的输出仅由其输入决定,且不产生副作用。这对于构建健壮、可测试、易于并发的代码非常有益。 那么在这里如何应对这种复杂业务呢?很简单,就是采用Combination。 回到这个函数:
fn process_complex_task<'a, 'b, 'c>(
data_source_a: &'a DataSourceA,
data_source_b: &'b DataSourceB,
config_c: &'c ConfigC,
// ...可能还有其他参数
) -> Result<Output<'a, 'b, 'c>, Error> {
// 很多逻辑,同时用到了 data_source_a, data_source_b, config_c
}
对应实现:
fn step1_with_a<'a>(data_a: &'a DataSourceA) -> Intermediate1<'a> { /* ... */ }
fn step2_with_b<'b>(data_b: &'b DataSourceB, prev_result: Intermediate1<'_>) -> Intermediate2<'b> {
// 注意:prev_result 的生命周期需要与这里的操作兼容 /* ... */
}
fn step3_with_c<'c>(data_c: &'c ConfigC, prev_result: Intermediate2<'_>) -> FinalOutput<'c> { /* ... */ }
// 组合起来 (简单示例)
// let result = step1_with_a(source_a);
// let result = step2_with_b(source_b, result);
// let result = step3_with_c(config_c, result);
那么这里,rust本身的varible shadow就呈现出来了优势,这里从始至终都是表明在操作这个result,代码和上下文并没有歧义,而且这种方法还大量减少生命周期的标注了。唯一需要关注的地方就是函数的取名上了。(这里用ai就能解决了) 最后。如果这种例子还不够明显,那可以结合闭包,构造一个组合子。
为什么说学好组合子就学会了rust基本思想了
要真正理解 Rust 中组合子的强大以及它为何能体现 Rust 的核心思想,我们首先需要明确几个相关的函数式编程概念,特别是高阶函数 (Higher-Order Functions) 和 柯里化 (Currying) 的思想,并理解这一切是如何通过返回闭包的机制在 Rust 中实现组合的。
高阶函数
高阶函数,指的是那些可以接受其他函数作为参数,或者将函数作为返回值的函数。这是组合子模式的绝对基础,以下是这两点的例子。
- 接受函数作为参数:Rust 中大量使用了这一点。最典型的例子就是标准库中
IteratorTrait 的各种方法,如map,filter,for_each等。它们都接受一个闭包(一种匿名函数)作为参数,来定义对集合中每个元素的操作。
let numbers = vec![1, 2, 3, 4];
let doubled: Vec<_> = numbers.iter().map(|x| x * 2).collect();
// .map(|x| x * 2) 中的 map 就是一个高阶函数,它接受了闭包 |x| x * 2
类似地,Option 和 Result 的 map, and_then, map_err 等方法也是高阶函数。它们使得我们可以将操作逻辑“注入”到它们所封装的上下文中。 2. 返回函数作为结果:虽然 Rust 的组合子方法(如 map, filter)通常返回的是一个实现了特定 Trait(如 Iterator)的新结构体实例(例如 Map 类型或 Filter 类型),而不是直接返回一个裸的 impl Fn()。但这些返回的结构体内部通常封装了你传入的闭包,然后将其视作为上下文,那么这些操作,本质上也是函数作为结果,并且它们的核心行为(比如迭代行为)正是由这个被封装的闭包驱动的。从概念上讲,这非常接近于“返回一个配置好的新函数/行为”。
柯里化
柯里化是一种将接受多个参数的函数转换成一系列只接受单个参数的函数的技术。例如,一个函数 f(a, b, c) 可以被柯里化为 f'(a)(b)(c),其中 f'(a) 返回一个接受 b 的新函数,这个新函数再返回一个接受 c 的新函数。
- 在Haskell(fp语言)中,这一点十分的显然,因为其函数构造都是自动性的柯里化,比如:
add :: Int -> Int -> Int
add x y = x + y
main :: IO ()
main = do
let a = add 1 -- 这里固定x为1,并返回一个函数 a
let b = a 2 -- 这里固定y为2,由于参数满足,所以直接运算
print b -- 这里显示为3
- Rust 中的模拟柯里化:Rust 本身不像 Haskell 那样提供语言级别的自动柯里化。但是,我们可以通过手动让函数返回闭包来模拟柯里化或实现部分应用(Partial Application,即固定函数的部分参数,得到一个参数更少的新函数)。
fn add(x: i32) -> impl Fn(i32) -> i32 {
move |y: i32| x + y // 返回的闭包捕获了 x
}
fn main() {
let add_five = add(5); // 部分应用:固定了 x 为 5,返回函数 add_five
let result = add_five(3); // 这里传入 y,得到 8
println!("{}", result);
// 也可以链式调用,类似柯里化风格
println!("{}", add(10)(20)); // 输出 30
}
组合
理解了高阶函数和柯里化的思想后,我们就能明白 Rust 中组合的精髓:通过高阶函数接受行为(闭包),并返回经过配置的、新的行为(通常是封装了原闭包的新闭包,或实现了特定功能性 Trait 的结构体)。 这是函数式编程(FP)中函数组合的核心。你可以像搭积木一样,将简单的函数(或闭包)通过组合子“粘合”起来,形成更复杂、更强大的功能。
链式调用与数据流:Iterator 的链式调用是这种组合方式最直观的体现:
let data = vec!["hello", " ", "world", "!"];
let result: String = data.iter()
.filter(|s| !s.trim().is_empty()) // 组合了 filter 行为
.map(|s| s.to_uppercase()) // 组合了 map 行为
.collect(); // 最终执行并收集结果
// result 会是 "HELLOWORLD!"
自定义组合:我们甚至可以编写自己的简单组合子函数,它明确返回一个新的闭包:
// F: A -> B, G: B -> C => H: A -> C
fn compose<A, B, C, F, G>(f: F, g: G) -> impl Fn(A) -> C
where
F: Fn(A) -> B + Copy, // Copy 以便闭包可以多次使用 f, g
G: Fn(B) -> C + Copy,
{
move |x: A| g(f(x))
}
fn main() {
let add_one = |x: i32| x + 1;
let double = |x: i32| x * 2;
// h(x) = double(add_one(x))
let add_one_then_double = compose(add_one, double);
println!("(5 + 1) * 2 = {}", add_one_then_double(5)); // 输出 12
}
当你熟练运用 Rust 中的组合子时,你其实已经在不自觉地运用和理解 Rust 的许多核心设计理念:
- 所有权与借用:闭包如何捕获其环境 (
Fn,FnMut,FnOnce) 深受所有权和借用规则的制约。正确使用组合子需要你理解这些规则,以确保数据安全传递和有效访问。 - Trait 与泛型:组合子大量依赖 Trait(如
Iterator,Future,Fn系列)和泛型来实现其通用性和可组合性。impl Trait的使用也随处可见。这是 Rust 实现“抽象归纳”的核心机制。 - 零成本抽象:Rust 的迭代器及其组合子是其“零成本抽象”理念的典范。尽管你写的是高度抽象的链式调用,编译器往往能将其优化成与手写循环几乎一样高效的代码。理解这一点能让你放心地使用这些高级特性。并且这些高级特性直接显性的从代码上描述整个运算逻辑。这个也符合代码即注释的原则。
- 声明式编程风格:组合子使得代码更倾向于声明“做什么”而非命令式地指示“如何一步步做”。这通常使代码更简洁、更易读、更易于推理。
- 错误处理范式:
Option和Result的组合子是 Rust 中优雅、健壮的错误处理方式的基石,它们鼓励你显式处理潜在的失败,同时保持代码的流畅性。 - 数据流与不可变性:组合子模式通常鼓励对数据进行不可变的转换,即每个步骤都基于前一个步骤的输出产生新的输出,而不是原地修改。这与函数式编程思想一致,有助于减少副作用,提升代码的可靠性。由于rust的所有权特性,这种创建并不会产生大量的变量冗余。 因此,当你深入理解并能熟练运用各种组合子(无论是标准库提供的还是生态中的,如
nom解析器组合子、futures组合子等)时,你不仅仅是学会了一些 API。更重要的是,你掌握了一种用 Rust 的方式思考问题、组织代码、管理数据流和处理错误的思维模式。这种思维模式正是 Rust 设计哲学中“组合优于继承”、“强类型抽象”、“内存安全”和“高效表达”等核心思想的体现。
总结
通过学习与实践发现,rust与fp的关系非常紧密,fp的一些特性思想非常符合rust的设计思维,并且以此为基础的设计为后续的自动化测试留下了更大的施展空间(比如quick check for rust),因此,了解fp的特性十分有助于编写高质量的rust代码,但这不意味着oop在rust上没有用武之地,很多的业务性顶层设计都需要使用oop来进行分解,但是具体设计交由fp实现,这样就可以将现代工具进行融合,并且以此提升代码质量和程序的鲁棒性。