Skip to content

函数

对于一些重复执行的代码,可以将其定义为一个函数,方便调用。函数是封装代码逻辑的基本单位。我们main.rs文件中的main()函数就是我们见到的第一个函数

1.函数的定义与使用

函数定义的基本语法如下:

rust
fn 函数名() {
	// 函数体
}
  • rust中,函数使用fn关键字定义,后面跟着函数名,rust中的函数名收到约束,必须使用snake_case蛇式命名方式
  • 函数一共分为三个部分,函数名、参数列表、函数体
    • ()可以接收若干个参数,称之为参数列表
    • {}中间的内容称之为函数体

示例

rust
// 定义函数
fn another_function() {
    println!("Another function.");
}

fn main() {
	// 使用函数
    another_function();
}

2.函数的参数

在函数的()内部,可以接受若干个参数,称为参数列表,里面的参数称之为形参

  • 如果需要,()中可以放置任意多的参数
  • 每个参数以参数: 类型的形式声明
  • 在函数签名中,必须声明每个参数的类型
rust
fn add(num1:u8, num2:u8) {
    println!("两数之和为:{}",num1+num2);
}

fn main() {
    add(3,5);
}

3.返回值

函数可以返回一个值,需要在参数列表后指定返回类型:

rust
fn 函数名(参数1: 类型1, 参数2: 类型2) -> 返回类型 {
	// 函数体
}

使用 -> 指定返回类型,这样函数调用时就可以返回单元类型以外的类型了。

例如:

rust
fn add(a: i32, b: i32) -> i32 {
	a + b
}

函数体{ }可以视为一个块表达式,{ }内最后一个表达式就是它的返回值,因此add函数会返回a + b 作为结果。

函数调用时可以直接把返回值赋值给一个变量,注意变量的类型要和函数返回值的类型一致

rust
fn main() {
    let sum = add(100, 200);
    
    println!("两数之和为:{}", sum);
}

3.1 return

虽然 Rust 支持隐式返回,但有时需要在函数中间提前返回,此时就需要return关键字。

示例:

rust
fn check_positive(x: i32) -> i32 {
    if x <= 0 {
        return 0;  // 提前返回
    }
    
    x * 2  // 正常返回
}

将要返回的值放在return关键字后面,就可以把这个值作为函数返回值,并终止函数。return 关键字可以在函数的任何位置提前返回值并退出函数。

4.参数模式匹配

函数参数可以使用模式匹配直接解构:

rust
// 解构元组参数
fn print_point((x, y): (i32, i32)) {
    println!("坐标: ({}, {})", x, y);
}

// 解构数组的前几个元素
fn print_first_two([first, second, ..]: [i32; 5]) {
    println!("前两个元素: {} 和 {}", first, second);
}

你看不懂以上内容也没关系,后续会有专门的章节降级模式匹配,包括函数参数中的模式匹配。此处你只需要知道函数参数是支持模式匹配的即可。

5.发散函数!

发散函数是永远不会正常返回的函数,返回类型是 !

例如:

rust
fn infinite_loop() -> ! {
    loop {
        println!("永远运行...");
    }
}

函数 infinite_loop 一旦进入就永远不会退出,因为内部是一个死循环loop。最后一个表达式loop返回的!作为函数返回值。此时 infinite_loop 就称为发散函数。

由于!可以转为任何类型,当然也就可以转化为单元类型(),因此省略返回值也可以:

rust
fn infinite_loop() {
    loop {
        println!("永远运行...");
    }
}

panic!表示一个错误,而exit表示退出整个程序,这两个也会返回!类型。

对于一些有确定返回类型的函数,可以基于!的类型转化特性,很自然的进行报错处理:

rust
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不是一个发散函数,因为它是有可能会返回正常值的。

发散函数是指永远不会正常返回,它包括两种情况:

  1. 永远不返回,比如loop死循环
  2. 只能非正常的返回,比如只调用panic!exit
rust
fn foo() -> ! {
    panic!("This call never returns.");
}

以上就是一个发散函数。


6.函数屏蔽

Rust 中,作用域不仅能决定变量的生存周期,同时也决定了名字解析的优先级。这意味着一个同名的定义,可能会在局部范围内“屏蔽”掉外部的同名函数或变量。变量能被 shadowing,函数同样可以。

最简单的例子就是在函数内部重新定义一个同名函数:

rust
fn greet() {
    println!("全局函数:你好!");
}

fn main() {
    fn greet() {
        println!("局部函数:嗨!");
    }

    greet(); // 调用的是局部定义,而不是全局的
}

名字解析遵循“就近原则”,于是局部函数把全局的 greet 暂时遮挡住了。等离开 main 的作用域,全局的 greet 又会恢复可见。

7.编译期常量函数 CTFE

编译期常量函数(CTFE, Compile-Time Function Evaluation),让我们可以让某些函数在 编译阶段 就被执行,结果嵌入最终的程序中,而不是等到运行时去计算。

通过 const fn 关键字可以定义一个CTFE函数:

rust
const fn func(arg: type) -> type {}

当传给func函数的参数是在编译期就可以确定的,那么func函数的结果也会在编译期提前计算,提高运行时效率。

示例:

rust
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不代表它一定会在编译期完成计算。这还取决于传入的参数,例如:

rust
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常量XY分别接收。由于字面量和常量在编译期都可以确定,所以XY不会报错。

后续定义了一个变量num,把它作为参数传给func,用Z接收。这行代码报错,无法编译,因为参数num不是一个编译期求值的变量。那么func就不会在编译期求值,而是在运行时调用。对于const Z常量

8.函数栈帧

8.1 内存模型

在讲解更加复杂的类型之前,先简单了解一下Rust程序是在怎样的一个内存环境执行的,这将有利于你理解后续的很多概念。

8.2 函数栈帧

不知你有没有思考过,你的程序运行在计算的什么位置?你声明的变量被放在了哪里?

程序运行在操作系统上,操作系统会给程序分配内存,允许程序运行时存储一些数据。这样一段内存称为进程地址空间,而进程地址空间有一部分会划分给用户,称为用户空间,另一部分由OS自己保留,称为内核空间

一个程序保存的所有变量,二进制指令,都存在这个用户空间中,它又被分为四个区域进行管理:

  • 代码段:存储可执行代码和只读常量
  • 数据段:存储全局变量和静态数据
  • 堆区:用于动态内存管理,堆区内存往高处增长
  • 栈区:大部分局部变量,栈区内存往低处增长

本博客主要讨论堆区与栈区,其余区域不在讨论范围内。

当你写一个main函数,它作为程序的入口,程序一定会先执行这个函数,那么就要在栈区为这个main函数分配一部分空间。

rust
fn main() {
    let a = 10;
    let b = 20;
}

以上代码声明了ab两个变量,在栈区中,程序行为如下:

在这里插入图片描述

首先程序会在栈区给main预先开辟一段内存。每当用户创建一个变量,就在main函数空间的顶部新增一个空间存放数据。

如图,从左到右分别是代码执行到不同语句,栈区的内存视图。

如果发生了函数的嵌套,此时栈区就会在顶部新开一段函数空间。

rust
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分配好内存,定义了ab两个变量。随后调用func,于是操作系统又要在栈区给func开一段新的空间,放到栈顶。像这样一个函数在栈区分配到的一块空间,称为一个函数栈帧。每个函数声明的变量会放在自己的栈帧内部,year变量自然也会在func函数的栈帧内部。

func调用完毕,此时操作系统就会把属于func的栈帧回收,包括内部的变量,方便以后给其它函数使用,这样就实现了内存可复用。随后回到了main函数栈帧。

现在考虑一个问题,有没有可能函数内部定义的变量太多,函数的栈帧不够大?比如说func函数要声明yearmonthday三个变量,结果空间不够了,比如:

在这里插入图片描述

这会导致func的函数栈帧扩容吗?OS是如何处理的?

答案是这个情况根本不可能发生!因为所有定义在栈区的变量,要求在编译期可以确定大小的类型。

比如说func函数要yearmonthday三个变量:

rust
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 动态大小类型

既然我们刚刚提到:编译器会在编译期就帮每个函数算好需要多少内存,所以所有能放在栈区的变量,必须是在编译期能确定大小的。

那么问题来了:有没有一些变量,它的大小在编译期根本没法确定?

最常见的例子就是字符串:

rust
fn main() {
    let s = String::from("world"); // 运行时动态分配,大小不固定
}

String表示的是一个可变、可扩展的字符串,运行中可以通过 push_str 一大段文字,长度完全不确定。 这种类型称为动态大小类型(DST, Dynamically Sized Type)

对于这种大小无法预知的情况,Rust 无法把它直接放到栈帧里。于是就需要用到另一个区域:堆区

堆区可以自由存储任意大小的数据,程序可以自由申请指定大小的空间,并自由向空间内存储任意数据。而且对于一块已经申请过的堆区内存,还可以对它进行扩容的操作,这样就可以把动态大小类型存到堆区里了。

但是我们编写的大部分代码,都在函数里,函数都在栈区执行。如何在栈区访问到堆区的内存?这就需要通过指针。

在这里插入图片描述

如图所示,栈区的变量s它通过一个地址,指向了堆区的字符串"world",这样就可以在栈区访问到堆区的数据了。

变量s本质上不是一个字符串,而是一个栈区的指针,在这个指针内部存储了栈区数据的相关信息,比如说字符串的长度以及字符串所在的地址,刚刚的图片实际上是简化版,实际情况如下:

在这里插入图片描述

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

MIT Licensed