跳转到主内容函数、参数和返回值 | 物联网民工

函数、参数和返回值

C 程序员看 Rust 函数,第一眼会觉得只是语法换了位置:

int add(int a, int b)
{
    return a + b;
}

Rust 写成:

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

这两个函数做的是同一件事,但 Rust 函数有几个早期就要适应的习惯:

  • 参数名在前,类型在后
  • 返回类型写在 -> 后
  • 函数末尾表达式可以作为返回值
  • return 更多用于早返回
  • 没有返回值的函数返回 ()
  • C 的输出参数和返回码,在 Rust 里通常会变成返回值和 Result

这些都不是所有权的完整理论,但会直接影响你怎么写第一个库函数。

参数名在前,类型在后

C 的函数参数是类型在前:

int checksum(const uint8_t *data, size_t len);

Rust 是名字在前、类型在后:

fn checksum(data: &[u8]) -> u32 {
    data.iter().map(|b| *b as u32).sum()
}

这里先不用展开 slice 的完整规则,只抓住第一层对应关系:

const uint8_t *data, size_t len -> data: &[u8]

C 里靠两个参数表达“指针加长度”。Rust 里用一个 slice 参数表达“这是一段连续数据的只读视图”。

如果函数只需要一个整数,写法也很直接:

fn retry_delay_ms(retry: u32) -> u32 {
    retry * 100
}

参数类型是函数契约的一部分。Rust 不会像 C 一样靠隐式转换帮你把各种整数混过去。

返回类型写在箭头后面

C 的返回类型写在函数名前:

uint32_t checksum(const uint8_t *data, size_t len);

Rust 的返回类型写在参数列表后:

fn checksum(data: &[u8]) -> u32 {
    data.iter().map(|b| *b as u32).sum()
}

没有返回值时,Rust 可以省略 -> ():

fn log_packet(len: usize) {
    eprintln!("packet len={len}");
}

完整写出来是:

fn log_packet(len: usize) -> () {
    eprintln!("packet len={len}");
}

() 可以先理解成“没有有用返回值”。它不是 C 的 void 完全等价物,但在第一层函数使用里,基本承担同一个位置。

末尾表达式就是返回值

C 里通常显式 return:

int add(int a, int b)
{
    return a + b;
}

Rust 可以写:

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

末尾没有分号的表达式就是返回值。

如果加了分号,就变成语句,返回 ():

fn add(a: i32, b: i32) -> i32 {
    a + b; // 编译错误:这里实际返回 ()
}

这条规则和前面讲过的“代码块可以产生值”是一致的。函数体也是一个块,块的末尾表达式可以成为函数返回值。

return 常用于早返回

Rust 也有 return:

fn first_byte(data: &[u8]) -> Option<u8> {
    if data.is_empty() {
        return None;
    }

    Some(data[0])
}

这里 return 用在错误或特殊路径,主路径留在函数末尾。

C 里也常这么写:

int first_byte(const uint8_t *data, size_t len, uint8_t *out)
{
    if (len == 0 || out == NULL) {
        return -1;
    }

    *out = data[0];
    return 0;
}

Rust 的差异在于:如果“没有值”是合法结果,可以直接返回 Option:

fn first_byte(data: &[u8]) -> Option<u8> {
    data.first().copied()
}

输出参数和返回码不一定要保留成原来的形状。

输出参数通常变成返回值

C 里为了返回多个东西,常用输出参数:

int parse_header(const uint8_t *data, size_t len, struct Header *out);

Rust 里更常见的是直接返回结果:

fn parse_header(data: &[u8]) -> Result<Header, ParseError> {
    if data.len() < 4 {
        return Err(ParseError::TooShort);
    }

    Ok(Header {
        version: data[0],
        length: u16::from_be_bytes([data[1], data[2]]),
    })
}

对应关系可以先这样看:

C 返回码 + out 参数 -> Rust Result<返回值, 错误>

这不只是语法变化。Rust 调用方拿到的类型会提醒他:这里可能失败,成功时才有 Header。

如果函数确实要写入调用方提供的 buffer,才使用可变参数:

fn fill_header(out: &mut [u8]) -> Result<usize, EncodeError> {
    if out.len() < 4 {
        return Err(EncodeError::TooSmall);
    }

    out[0] = 1;
    out[1] = 0;
    out[2] = 4;
    out[3] = 0;
    Ok(4)
}

这对应 C 里的 uint8_t *out, size_t capacity,但 Rust 类型把“可写 slice”表达出来了。

函数参数默认按值传递

C 的参数也是按值传递:

void set_retry(int retry)
{
    retry = 0;
}

修改 retry 不会改调用方变量。

Rust 也一样:

fn set_retry(mut retry: i32) {
    retry = 0;
}

这里的 mut retry 只表示函数内部这个参数绑定可以改,不表示调用方的变量会被改。

如果要修改调用方提供的数据,C 用指针:

void reset_retry(int *retry)
{
    *retry = 0;
}

Rust 第一层对应是可变引用:

fn reset_retry(retry: &mut i32) {
    *retry = 0;
}

调用时也要显式借出可变引用:

let mut retry = 3;
reset_retry(&mut retry);

这部分后面会单独讲借用。现在先记住:参数绑定上的 mut 和 &mut 不是一回事。

mut retry: i32  -> 函数内部可以改这个局部参数
retry: &mut i32 -> 函数可以改调用方传进来的那个值

函数可以放在后面,但模块边界要另讲

C 调用函数前通常需要声明:

int add(int a, int b);

int main(void)
{
    return add(1, 2);
}

Rust 在同一个模块里不需要提前声明:

fn main() {
    println!("{}", add(1, 2));
}

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

但这不等于 Rust 没有接口边界。跨文件、跨模块、跨 crate 时,会涉及 mod、pub、use 和 package 组织。那是模块和文件组织的问题,不要和单个函数语法混在一起。

最小迁移表

C int add(int a, int b)       -> Rust fn add(a: i32, b: i32) -> i32
C void log(int x)             -> Rust fn log(x: i32)
C return expr                 -> Rust expr 或 return expr
C 末尾 return                 -> Rust 末尾表达式
C 返回码 + out 参数           -> Rust Result<T, E>
C T *out                      -> Rust &mut T / &mut [T]
C const uint8_t *p, size_t n  -> Rust &[u8]
C 参数内部可改                -> Rust mut 参数绑定
C 修改调用方变量              -> Rust &mut 参数
C 函数提前声明                -> Rust 同模块内不需要原型声明

Rust 函数的第一层并不复杂。真正重要的是:函数签名会更明确地表达数据是输入、输出、可变输出,还是可能失败的结果。C 里靠命名和注释维护的约定,Rust 会尽量放进类型里。

继续阅读