函数
对于一些重复执行的代码,可以将其定义为一个函数,方便调用。函数是封装代码逻辑的基本单位。我们main.rs文件中的main()函数就是我们见到的第一个函数
1.函数的定义与使用
函数定义的基本语法如下:
fn 函数名() {
// 函数体
}rust中,函数使用fn关键字定义,后面跟着函数名,rust中的函数名收到约束,必须使用snake_case蛇式命名方式- 函数一共分为三个部分,函数名、参数列表、函数体
()可以接收若干个参数,称之为参数列表{}中间的内容称之为函数体
示例
// 定义函数
fn another_function() {
println!("Another function.");
}
fn main() {
// 使用函数
another_function();
}2.函数的参数
在函数的()内部,可以接受若干个参数,称为参数列表,里面的参数称之为形参
- 如果需要,
()中可以放置任意多的参数 - 每个参数以
参数: 类型的形式声明 - 在函数签名中,必须声明每个参数的类型
fn add(num1:u8, num2:u8) {
println!("两数之和为:{}",num1+num2);
}
fn main() {
add(3,5);
}3.返回值
函数可以返回一个值,需要在参数列表后指定返回类型:
fn 函数名(参数1: 类型1, 参数2: 类型2) -> 返回类型 {
// 函数体
}使用 -> 指定返回类型,这样函数调用时就可以返回单元类型以外的类型了。
例如:
fn add(a: i32, b: i32) -> i32 {
a + b
}函数体{ }可以视为一个块表达式,{ }内最后一个表达式就是它的返回值,因此add函数会返回a + b 作为结果。
函数调用时可以直接把返回值赋值给一个变量,注意变量的类型要和函数返回值的类型一致
fn main() {
let sum = add(100, 200);
println!("两数之和为:{}", sum);
}3.1 return
虽然 Rust 支持隐式返回,但有时需要在函数中间提前返回,此时就需要return关键字。
示例:
fn check_positive(x: i32) -> i32 {
if x <= 0 {
return 0; // 提前返回
}
x * 2 // 正常返回
}将要返回的值放在return关键字后面,就可以把这个值作为函数返回值,并终止函数。return 关键字可以在函数的任何位置提前返回值并退出函数。
4.参数模式匹配
函数参数可以使用模式匹配直接解构:
// 解构元组参数
fn print_point((x, y): (i32, i32)) {
println!("坐标: ({}, {})", x, y);
}
// 解构数组的前几个元素
fn print_first_two([first, second, ..]: [i32; 5]) {
println!("前两个元素: {} 和 {}", first, second);
}你看不懂以上内容也没关系,后续会有专门的章节降级模式匹配,包括函数参数中的模式匹配。此处你只需要知道函数参数是支持模式匹配的即可。
5.发散函数!
发散函数是永远不会正常返回的函数,返回类型是 !。
例如:
fn infinite_loop() -> ! {
loop {
println!("永远运行...");
}
}函数 infinite_loop 一旦进入就永远不会退出,因为内部是一个死循环loop。最后一个表达式loop返回的!作为函数返回值。此时 infinite_loop 就称为发散函数。
由于!可以转为任何类型,当然也就可以转化为单元类型(),因此省略返回值也可以:
fn infinite_loop() {
loop {
println!("永远运行...");
}
}panic!表示一个错误,而exit表示退出整个程序,这两个也会返回!类型。
对于一些有确定返回类型的函数,可以基于!的类型转化特性,很自然的进行报错处理:
fn safe_divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("除零错误") // ! 类型,但兼容 i32
} else {
a / b // i32 类型
}
// 整个 if 表达式的类型是 i32
}函数safe_divide执行一个安全的除法,返回一个i32,这就要保证除数不为0。
通过if判断,如果b == 0就直接panic!,虽然函数要求返回一个i32,但是panic!依然可以作为函数的返回值,这得益于!的转化特性。
不过要注意,safe_divide不是一个发散函数,因为它是有可能会返回正常值的。
发散函数是指永远不会正常返回,它包括两种情况:
- 永远不返回,比如
loop死循环 - 只能非正常的返回,比如只调用
panic!和exit
fn foo() -> ! {
panic!("This call never returns.");
}以上就是一个发散函数。
6.函数屏蔽
在 Rust 中,作用域不仅能决定变量的生存周期,同时也决定了名字解析的优先级。这意味着一个同名的定义,可能会在局部范围内“屏蔽”掉外部的同名函数或变量。变量能被 shadowing,函数同样可以。
最简单的例子就是在函数内部重新定义一个同名函数:
fn greet() {
println!("全局函数:你好!");
}
fn main() {
fn greet() {
println!("局部函数:嗨!");
}
greet(); // 调用的是局部定义,而不是全局的
}名字解析遵循“就近原则”,于是局部函数把全局的 greet 暂时遮挡住了。等离开 main 的作用域,全局的 greet 又会恢复可见。
7.编译期常量函数 CTFE
编译期常量函数(CTFE, Compile-Time Function Evaluation),让我们可以让某些函数在 编译阶段 就被执行,结果嵌入最终的程序中,而不是等到运行时去计算。
通过 const fn 关键字可以定义一个CTFE函数:
const fn func(arg: type) -> type {}当传给func函数的参数是在编译期就可以确定的,那么func函数的结果也会在编译期提前计算,提高运行时效率。
示例:
const fn square(x: i32) -> i32 {
x * x
}
const SIZE: usize = square(4) as usize;
fn main() {
let arr = [0; SIZE]; // 长度在编译期就确定为 16
println!("数组长度是 {}", arr.len());
}square(4) 的结果在编译阶段就已经计算完毕,因此 arr 的大小在编译器眼中是一个确定的常量。
但是要注意,CTFE不代表它一定会在编译期完成计算。这还取决于传入的参数,例如:
const fn func(num: i32) -> i32 {
num * 10
}
const C_NUM: i32 = 2;
const X: i32 = func(10);
const Y: i32 = func(C_NUM);
let num = 1;
const Z: i32 = func(num);以上代码定义了一个func,它被const修饰,可以在编译期完成计算。
定义了一个常量C_NUM,它在编译期就可以完成计算。随后分别把字面量10和常量C_NUM作为参数传入func,再用const常量X和Y分别接收。由于字面量和常量在编译期都可以确定,所以X和Y不会报错。
后续定义了一个变量num,把它作为参数传给func,用Z接收。这行代码报错,无法编译,因为参数num不是一个编译期求值的变量。那么func就不会在编译期求值,而是在运行时调用。对于const Z常量
8.函数栈帧
8.1 内存模型
在讲解更加复杂的类型之前,先简单了解一下Rust程序是在怎样的一个内存环境执行的,这将有利于你理解后续的很多概念。
8.2 函数栈帧
不知你有没有思考过,你的程序运行在计算的什么位置?你声明的变量被放在了哪里?
程序运行在操作系统上,操作系统会给程序分配内存,允许程序运行时存储一些数据。这样一段内存称为进程地址空间,而进程地址空间有一部分会划分给用户,称为用户空间,另一部分由OS自己保留,称为内核空间。
一个程序保存的所有变量,二进制指令,都存在这个用户空间中,它又被分为四个区域进行管理:
- 代码段:存储可执行代码和只读常量
- 数据段:存储全局变量和静态数据
- 堆区:用于动态内存管理,堆区内存往高处增长
- 栈区:大部分局部变量,栈区内存往低处增长
本博客主要讨论堆区与栈区,其余区域不在讨论范围内。
当你写一个main函数,它作为程序的入口,程序一定会先执行这个函数,那么就要在栈区为这个main函数分配一部分空间。
fn main() {
let a = 10;
let b = 20;
}以上代码声明了a和b两个变量,在栈区中,程序行为如下:

首先程序会在栈区给main预先开辟一段内存。每当用户创建一个变量,就在main函数空间的顶部新增一个空间存放数据。
如图,从左到右分别是代码执行到不同语句,栈区的内存视图。
如果发生了函数的嵌套,此时栈区就会在顶部新开一段函数空间。
fn func() {
let year = 2025;
println!("Hello {}!", year);
}
fn main() {
let a = 10;
let b = 20;
func();
println!("Hello over...");
}以上代码,在main函数中调用了func函数,输出Hello 2025!,调用结束后再输出Hello over...。
在栈区它的内存视图如下:

起初先给main分配好内存,定义了a和b两个变量。随后调用func,于是操作系统又要在栈区给func开一段新的空间,放到栈顶。像这样一个函数在栈区分配到的一块空间,称为一个函数栈帧。每个函数声明的变量会放在自己的栈帧内部,year变量自然也会在func函数的栈帧内部。
当func调用完毕,此时操作系统就会把属于func的栈帧回收,包括内部的变量,方便以后给其它函数使用,这样就实现了内存可复用。随后回到了main函数栈帧。
现在考虑一个问题,有没有可能函数内部定义的变量太多,函数的栈帧不够大?比如说func函数要声明year、month、day三个变量,结果空间不够了,比如:

这会导致func的函数栈帧扩容吗?OS是如何处理的?
答案是这个情况根本不可能发生!因为所有定义在栈区的变量,要求在编译期可以确定大小的类型。
比如说func函数要year、month、day三个变量:
fn func() {
let year = 2025; // i32 需要 8byte
let month = 12; // i32 需要 8byte
let day = 25; // i32 需要 8byte
}由于三个变量都是i32,编译期就可以确定func至少需要8 + 8 + 8 = 24 byte 的内存,至少也会分配这么多内存给func的栈帧。
在编译期间,Rust的编译器就会计算每个函数所需的空间,后续调用的时候,直接让OS给这个函数分配指定大小的栈帧即可。
8.3 动态大小类型
既然我们刚刚提到:编译器会在编译期就帮每个函数算好需要多少内存,所以所有能放在栈区的变量,必须是在编译期能确定大小的。
那么问题来了:有没有一些变量,它的大小在编译期根本没法确定?
最常见的例子就是字符串:
fn main() {
let s = String::from("world"); // 运行时动态分配,大小不固定
}String表示的是一个可变、可扩展的字符串,运行中可以通过 push_str 一大段文字,长度完全不确定。 这种类型称为动态大小类型(DST, Dynamically Sized Type)。
对于这种大小无法预知的情况,Rust 无法把它直接放到栈帧里。于是就需要用到另一个区域:堆区。
堆区可以自由存储任意大小的数据,程序可以自由申请指定大小的空间,并自由向空间内存储任意数据。而且对于一块已经申请过的堆区内存,还可以对它进行扩容的操作,这样就可以把动态大小类型存到堆区里了。
但是我们编写的大部分代码,都在函数里,函数都在栈区执行。如何在栈区访问到堆区的内存?这就需要通过指针。

如图所示,栈区的变量s它通过一个地址,指向了堆区的字符串"world",这样就可以在栈区访问到堆区的数据了。
变量s本质上不是一个字符串,而是一个栈区的指针,在这个指针内部存储了栈区数据的相关信息,比如说字符串的长度以及字符串所在的地址,刚刚的图片实际上是简化版,实际情况如下:

在栈区中,s是一个指针,指针内部存储了ptr和len,分别表示堆区数据的地址以及字符串长度。像这样携带有其它相关数据的指针,称为胖指针。
