【Rust深入浅出-7】函数与闭包
第一章Hello World!
第二章 变量和基本数据类型
第三章 运算符
第四章 类型转换
第五章 拓展数据类型
第六章 控制流
第七章 函数与闭包
文章目录
前言
Rust深入浅出教程第7章《函数与方法》
本章节将介绍Rust如何声明函数,如何返回值和官方库函数等等
本章会不可避免的涉及到一些有关生命周期和所有权等等尚未讲解的知识,但在本文中,没学过应该对理解内容影响不是很大,而且先以函数为切入点,引入一部分相关的概念也有助于学习它们。
先提一下函数和方法的区别,函数是封装到一块的具有一定功能和目的性的代码,方法是专属于对象、枚举等实例的函数,只能由它自己调用。本文中统一称函数,而函数又可分为传统函数和匿名函数(闭包)。
传统函数
传统函数本质上是一种函数指针,调用函数时,访问指针指向的函数地址
声明函数
★ 格式:fn 函数名(参数:类型) -> 返回类型 { todo!(); }
示例代码
fn say_hi(name: &str) -> () {
println!("hello, {}!", name);
return ();
}
say_hi("ruster");
fn是声明函数的关键字,say_hi是函数名,传入一个名为name的&str,函数打印 hello, name!,返回值为空单元()
★ 命名规则
- 由小写字母、自然数和短下划线组成
- 由字母开头
- 其他语言习惯中的大写驼峰命名法应写成蛇形命名法:AddUser -> add_user
函数参数
★ 入参必须指明参数类型
入参变量针对其所有权有3中形式:引用,可变引用和转移
1.引用(&)
入参时加&(继承了Copy的可以不加,如: i32等最最基础的类型),都是引用,不会夺走原变量的所有权,相当于只读
2. 可变引用(&mut)
变量可写,函数拿到了读写权,对于继承了Copy的类型,修改时需要用星号(*)解引用
参数的类型声明应加上前缀 &mut
let mut a = 1;
fn cg_a(a: &mut i32) {
*a = 2;
}
cg_a(&mut a);
println!("a: {a}");
输出
a: 2
对于没有Copy的,如vec数组等,可以不用*解引用,编译器会默认解除借用的
let mut v = vec![1,2,3];
fn cg_v(v: &mut Vec<i32>) {
v[0] = 9;
}
cg_v(&mut v);
println!("v: {v}");
输出
v: [9, 2, 3]
- 转移(move)
将原变量的所有权转交给函数,仅限于对无Copy的类型,以Vec数组为例:
不可变的Vec数组
let v = vec![1,2,3];
fn cg_v( v:Vec<i32>) {
let _v = v;
}
cg_v(v);
// println!("v: {:?}", v);
可变的Vec数组
#[allow(unused)]
let mut v = vec![1,2,3];
fn cg_v(mut v:Vec<i32>) {
v[0] = 9;
}
cg_v(v);
// println!("v: {:?}", v);
对于上面的2段示例代码,如果 println 那行留着不注释掉,都会报错,因为原Vec数组(v)的所有权已经被转移给调用它的函数(cg_v)了。即使函数内没有读写操作,从被调用的那一刻起,原变量就已经死去。
函数返回值
返回一个值,有两种方式:return语句 和 表达式
★ return语句:return 变量/表达式;
表达式返回一个值,表达式可以是一个常量,变量,变量计算式
fn check_num_eq_2(n: i32) -> bool{
if n.eq(&2) {
return true; //return 语句返回一个值
}
false //表达式返回一个值,表达式可以是一个常量,变量,变量计算式
}
println!("{}", check_num_eq_2(3));
也许此刻你正在奇怪,为什么要搞出两个返回方法,它们有什么区别吗?这涉及到rust变量的生命周期与所有权的概念,本章不作讲解。
当函数内没有return或未经过return时,编译器会以作为一行作为返回,此时若最后一行不是表达式,而是带 ; 号的非return普通语句,将会返回 ()
你只需要牢记,返回表示式只用于最后一行(流的终点) 进行返回,如果需要提前返回必须使用 return语句。
严谨地说,原则上任何函数都有返回值,Rust没有void,不允许任何“空”的存在,取而代之的是unit (),不占任何字节,但通常在不需要返回值的时候可以省略掉不写,即上面第一段示例代码的 -> () 和 return (); 都可以省略不写。
有个没有返回的例外是,返回类型为 !(Never),这样的函数称为发散函数
fn loooop() -> ! {
loop {
println!("siu~");
}
}
loooop(); //无限打印siu~
fn dead_end() -> ! {
panic!("下班走人!");
}
dead_end(); //程序以panic终止,抛出"下班走人"
发散函数常常用于处理不得不退出程序的异常和无限循环,不常用
匿名函数
Rust通过**闭包(Closure)**来创建匿名函数,我们先来简单了解一下Rust闭包的概念。
概念
Rust有着严格生命周期与所有权机制,之前的fn函数是访问不到属于调用者域的变量的,而闭包是针对这个问题实现的一种可以捕获调用域变量的匿名函数。由于绝大多数闭包用于另一个函数内被即刻调用,有时也称为内联函数。
闭包匿名函数
函数声明
闭包无需声明参数类型和返回(无需,但是可以声明),只需用管道符包住参数,未标注类型时,编译器会自动去根据上下文推断类型
★ 格式:
|param| {
todo!();
}
当todo!()仅有一个表达式时,括号甚至都可以省去,直接写成一行(这其实就是一个闭包作为内联函数的用法)
|param| println!("{}", param);
★ 自由闭包
如果一个闭包不是即时调用的内联函数,我们应该如何自由调用它呢?
我们可以将匿名函数赋给一个变量,就能以函数的形式调用这个变量,有点JS箭头函数的意味
let say_hi = || { println!("hello") };
say_hi();
★ 捕获外部变量
闭包可以直接获取外部环境的变量,可以不再通过入参的形式获取所需量,如:
let name = "rust";
let say_hi = || { println!("hello, {}", name) };
say_hi();
捕获时采取的具体方式有3中:和传统函数入参是一个道理,应用场景也是一样,不再赘述
- 借用(&)
- 可变借用(&mut)
- 移动(move)
这是关于对比3种捕获的示例代码:
//借用
let name = String::from("rust");
let say_hi = || {println!("hi, {}", name);};
say_hi();
println!("{}", name);
//可变
let name = String::new();
let mut name = name.add("rust");
let mut say_hi = || {
println!("hi, {}", name);
name="evan".to_owned();
};
say_hi();
println!("{}", name);
//move
let name = String::new();
let mut name = name.add("rust");
let say_hi = move || {
println!("hi, {}",name);
name = "evan".to_string();
name
};
let new_name = say_hi();
println!("new_name: {}",new_name);
// println!("name: {}", name); //error!
第一段代码是借用,只读不能写
第二段代码是可变借用,可以修改原变量的值
第三段代码是转移,原变量name自函数被调用起就死了,转移到函数内,并赋值给了new_name返回出来
闭包的优势
闭包有什么好处?为什么要使用闭包?
相信经过上面的几段示例和普通函数的对比,我们心里已经隐隐有了一个答案。
★ 让代码更简单
闭包相比普通函数省略了很多细枝末节,这使得我们代码简洁不少,代码更紧凑,书写的效率大大提升。
很多时候我们可能只需要调用一次函数,我们直接使用内联闭包还可以省去不少的篇幅
★ 高可维护性
传统函数在实际使用中,如果在一段代码,一个文件甚至多个文件内被多次调用,后续内部实现有变动,或者入参调整,再或者函数改个名什么的,由于传统函数是显式声明的,我们不得不去每一处地方手动修改一下,即便你可以借助代码编辑器批量修改,即便你可以通过优秀的代码设计的架构能力避免这个问题,但维护所耗费的时间和精力成本代价都是不小的。
相比之下,灵活的闭包,不用显式声明一些细节,因此可维护性就高得多。
当然了,简洁的小代价就是代码可读性的降低,不过随着对Rust语言和业务的熟悉程度,通常这只可能会在前期造成效率较低,中后期基本不会有这样的顾虑,综合来说,肯定是利大于弊。
闭包的缺陷
凡事都有两面性,闭包也不例外,缺点也是有的。
除了之前提到过的可读性差,闭包还存在其他的缺点
- 类型推断隐患
未闭包标注类型时,编译器通过上下文和捕获去推断具体类型,虽然Rust编译器的静态检查能力能强大,但终究有力不能及的情况,可能会出现无法推断类型或推断出的类型不正确 - 所有权隐患
在执行捕获的过程中,可能因所有权操作不当引发所有权问题 - 内存安全隐患
闭包捕获的行为,需要消耗额外的内存,增加了开销,如果捕获的行为较多,可能加重程序运行时的内存负担和造成内存泄漏 - 不适合递归
函数递归是指在函数内部调用函数自己,而闭包没有名字,就没法在自身内部调用自己,如果要实现闭包递归,需要借助Box指针(指针以后会讲,先当作一个存储其他类型的对象就行):
普通递归
fn factorial(n: u64) -> u64 {
match n {
0 => 1,
_ => n * factorial(n - 1),
}
}
let result = factorial(10);
println!("{}", result);
闭包递归
use std::boxed::Box;
use std::ops::FnOnce;
fn apply_to_number<F>(n: u64, f: F) -> u64
where
F: FnOnce(u64) -> u64,
{
f(n)
}
let factorial = |n| {
let mut f: Box<dyn FnBox(u64) -> u64> = Box::new(|_| 1);
for i in (1..=n).rev() {
let old_f = f;
f = Box::new(move |x| i * old_f(x));
}
f(1)
};
let result = apply_to_number(10, factorial);
println!("{}", result);
在这个例子里,实现相同的递归,闭包的代码篇幅是普通函数的整整2倍,还额外造成了不必要的内存开销,整个代码也变得复杂了,况且这段代码并不需要捕获外部变量,这已经违背了我们使用闭包的初衷了,吃力不讨好。
因此,当我们需要复杂、稳定、静态地执行一些操作,并且不需要访问或修改外部状态,那么使用普通函数而不是闭包可能会更合理、更安全、更高效。
高阶函数
在Rust中,函数本身也算是一种数据类型,高阶函数将函数和返回作为参数
绑定函数给变量
普通函数->变量
在复用函数时,为了提高可维护性,通常将函数绑定到一个变量上
fn cryout() {
println!("siu~!");
}
let action = cryout;
action();
action的类型,编译器会根据这里的上下文自动推断出为 fn() cryout
你也可以手动标注 let action: fn cryout() = cryout;
你也可以专门为fn cryout这样的函数的类型起个名字,作为一个函数类型:type 类型名 = fn(参数类型)->返回类型
我这里指定这种没参数又没返回值的函数类型都叫做Shout
fn cryout() {
println!("siu~!");
}
type Shout = fn();
let action: Shout = cryout;
action();
闭包->变量
类似的,我们也可以将闭包绑定到变量上,但是注意了,存在一个特例:
示例:下面是两段代码,分别绑定了2个闭包,区别是第2个闭包捕获了外部变量
type Calc = fn(i32,i32)->i32;
let fx: Calc = |a,b|{a+b};
type Calc = fn(i32,i32)->i32;
let x = 1;
let fx: Calc = |a,b|{a+b+x};
拖到你的IDE里去看看,欸?第一段正常,第二段报错了,怎么回事?
看一下编译器给我们的提示:
mismatched types expected fn pointer
fn(i32, i32) -> i32
found closure[closure@src\main.rs:63:9: 63:19]
closures can only be coerced tofn
types if they do not capture any variables
意思是返回的类型不匹配,期待的是函数指针fn,却返回了闭包closures,只有闭包不捕获外部变量是才能被强制转换为函数指针。
原来是闭包本身并没有实现Fn特性,虽然在概念上,函数是闭包的超集,但闭包和函数并不能混为一谈,当闭包执行了它的捕获特性时,它不能再被视为普通函数
用Box存储函数
主要用于闭包,先卖个关子,具体的讲解文末会涉及到,这是一个很重要的难点。
★ 示例代码
let num: i32 = 10;
let gbc:Box<dyn Fn(i32,i32)->i32> = Box::new(move |a:i32,b:i32|a+b+num);
println!("{:?}", gbc(1,2));
此外,将函数赋值给变量时,不会将函数的所有权交给这个变量,依然能直接调用原函数,rust的函数,本质上是一种指针,指向了函数的地址,调用函数时是采取借用的方式。
函数作为参数
示例: 实现一个运算函数,接收两个整数和一个执行具体运算的函数作为参数。
type Calc = fn(i32,i32)->i32;
fn calc(a:i32, b:i32, fx: Calc) -> i32{
fx(a,b)
}
fn add(a: i32, b: i32) ->i32 {
a + b
}
println!("do calc add: 1+2 = {}", calc(1, 2, add));
复习一下闭包,用闭包简化上面的代码:
示例:
type Calc = fn(i32,i32)->i32;
let calc = |a:i32, b:i32, fx: Calc| fx(a,b);
println!("do calc add: 1+2 = {}", calc(1, 2, |a,b|a+b));
函数作为返回
示例: 实现一个获取具体运算函数的函数,传入函数代号,返回相应的具体运算函数,具体运算函数要为结果加上10,规定10不能写在表达式里,并将10绑定到变量上
fn get_calc_base_10(i: i32) -> fn(i32,i32)->i32 {
fn add(a:i32, b:i32) -> i32{
let num: i32 = 10;
a + b + num
}
fn minus(a:i32, b:i32) -> i32{
let num: i32 = 10;
a - b + num
}
match i {
1 => {
return add;
},
_ => {
return minus;
}
}
}
let add = get_calc_base_10(1);
println!("1 + 2 = {}", add(1,2));
返回闭包
对于刚刚的代码,尝试用闭包进行重构:
fn get_calc_base_10(i: i32) -> fn(i32,i32)->i32 {
match i {
1 => {
let num: i32 = 10;
|a:i32,b:i32|a+b+num
},
_ => {
let num: i32 = 10;
|a:i32,b:i32|a-b+num
}
}
}
报错了,提示类型不对,而且没有成功捕获到外部变量。
怎么回事?我们之前自定义函数类型时,不是能把闭包绑定到类型为fnXXX的变量上吗?
首先,有同学可能想起来了,之前讲过函数和闭包不是一个东西,只有闭包没有捕获行为时可以被强制转换为函数,因为我们需要为其继承Fn特性:impl Fn(i32,i32)->i32
其次,之前也讲了,函数调用默认是借用变量,我们没有资格将其抛出并转交给其他变量,因此我们需要获取捕获量的所有权:move |a,b| a + b + num
因此,根据这两点,我们修改代码为这个版本的:
fn get_calc_base_10(i: i32) -> impl Fn(i32,i32)->i32 {
match i {
1 => {
let num: i32 = 10;
move |a:i32,b:i32|a+b+num
},
_ => {
let num: i32 = 10;
move |a:i32,b:i32|a-b+num
}
}
}
很遗憾!又报错了!上面两个问题解决了,却出现了一个新的问题
看看编译器给我们提示了什么:
match
arms have incompatible types expected closure[closure@src\main.rs:66:17: 66:35]
found closure[closure@src\main.rs:70:17: 70:35]
no two closures, even if identical, have the same type
consider boxing your closure and/or using it as a trait object
意思是,两个闭包是不可能拥有相同的类型的,即便它们的入参和返回都一样。这是为什么?
如果你使用的IDE是VsCode,你可以写一个正确的函数,只返回一个有捕获行为的闭包,然后将鼠标悬停在这个函数上,看悬浮提示,你会发现最顶上一行写着tmp,看到这个我们心里大概有谱了。
闭包类型不一样的根本原因是因为,闭包是每次调用外层函数时,临时创建的函数作用域对象,所以即使两个闭包的参数和返回类型都相同,它们也不是同一个对象,而是不同的实例。
这下彻底弄明白什么是闭包的本质了,解决办法编译器也告诉我们了,就是利用Box指针来存储闭包:
consider boxing your closure and/or using it as a trait object
fn get_calc_base_10(i: i32) -> Box<dyn Fn(i32,i32)->i32> {
match i {
1 => {
let num: i32 = 10;
Box::new(move |a:i32,b:i32|a+b+num)
},
_ => {
let num: i32 = 10;
Box::new(move |a:i32,b:i32|a-b+num)
}
}
}
至此,代码完美的正确运行
总结
本章我们详细展开了函数,学习了普通函数,闭包和高阶函数。在使用函数时,应牢牢把关好变量的生命周期和所有权。我们着重深入学习了闭包的各种特性和难点,尤其是闭包作为返回的闭包的类型。
相信没有Rust或C/C++基础的朋友在阅读本章时,会不可避免的感觉晦涩难懂,的确,函数与Rust核心特性生命周期和所有权牵扯较深,而后者则是Rust中非常重要且难以理解的重点,下一章,将采用尽可能容易理解的言语来讲解这个重中之重。
可以先去下下章,学习一下Trait的知识,它相当于方法的抽象接口,比较简单,是和函数配套的。
下一章 生命周期与所有权(暂待)