跳转到主内容作用域、绑定和名字可见性 | 物联网民工

作用域、绑定和名字可见性

C 程序员理解作用域,通常先想到名字可见性:

void handle(void)
{
    int offset = 0;

    {
        int len = 16;
        offset += len;
    }

    /* len 不可见,offset 仍然可见 */
}

Rust 的块作用域也有这个规则:

fn handle() {
    let mut offset = 0;

    {
        let len = 16;
        offset += len;
    }

    // len 不可见,offset 仍然可见
}

但 Rust 的作用域不只决定名字能不能用。它还会影响资源什么时候释放、绑定能不能被遮蔽、引用能不能继续存在,以及哪些名字能成为模块外的 API。

先抓住四件事:

  • { ... } 会创建新的块作用域
  • let 创建的是绑定,不只是“变量声明”
  • shadowing 是新绑定遮蔽旧绑定,不是重新赋值
  • 作用域结束会触发资源释放

块作用域和 C 很像

C 里的局部变量只在块内可见:

if (ready) {
    int code = read_code();
    handle_code(code);
}

/* code 不可见 */

Rust 也一样:

if ready {
    let code = read_code();
    handle_code(code);
}

// code 不可见

这点不用神秘化。函数体、if、while、loop、普通 { ... } 都会形成作用域。

手动加一个块也很常见:

let header_len = {
    let fixed = 4;
    let options = 8;
    fixed + options
};

这个块既限制了 fixed 和 options 的可见范围,也产生了一个值给 header_len。

let 是绑定,不只是声明变量

C 的变量声明会创建一个对象名:

int len = 10;

Rust 更常用“绑定”这个词:

let len = 10;

第一层可以理解成:名字 len 绑定到一个值。

默认绑定不可变:

let len = 10;
len = 11; // 编译错误

需要重新赋值时,用 mut:

let mut len = 10;
len = 11;

这和 C 的默认可变不同。Rust 让“这个名字后面会不会重新指向另一个值”在绑定处就能看出来。

shadowing 是新绑定,不是重新赋值

C 同一个块里不能重复定义同名变量:

int len = 10;
int len = 20; // 错误

Rust 允许 shadowing:

let len = "1024";
let len: usize = len.parse().unwrap();

第二个 len 是新的绑定,它遮蔽了第一个 len。这不是重新赋值。

它适合处理“同一个业务概念,经过一步转换后类型变了”的场景:

let path = "./config.toml";
let path = std::path::Path::new(path);

也可以用来把可变过程收束成不可变结果:

let mut checksum = 0_u32;
for byte in data {
    checksum += *byte as u32;
}
let checksum = checksum;

最后一行创建了新的不可变绑定。后面的代码不能再改 checksum。

作用域结束会释放资源

C 里普通局部变量离开作用域会失效,但堆内存和文件句柄通常要手工释放:

FILE *fp = fopen(path, "r");
if (!fp) {
    return -1;
}

/* ... */

fclose(fp);

Rust 里很多资源由值自己管理。作用域结束时会自动调用 Drop:

fn read_config(path: &str) -> std::io::Result<String> {
    let text = std::fs::read_to_string(path)?;
    Ok(text)
}

String、Vec、文件对象、锁守卫等类型离开作用域时,会释放自己拥有的资源。

可以手动缩短作用域:

{
    let file = std::fs::File::open(path)?;
    // 使用 file
} // file 在这里关闭

// 后面不再持有 file

这不是完整所有权理论,但先建立一个直觉:Rust 的作用域同时是名字边界,也是资源边界。

内层可以借用外层,但不能反过来

C 里返回局部变量地址是典型错误:

int *bad(void)
{
    int value = 10;
    return &value;
}

Rust 会在编译期拦住这类引用逃出作用域的问题。

第一层先看这个形状:

let value = 10;
let r = &value;
println!("{r}");

r 可以使用,是因为 value 还活着。

但这种形状不行:

let r;
{
    let value = 10;
    r = &value;
}
println!("{r}"); // 编译错误

value 在内层作用域结束时已经失效,外层不能继续使用指向它的引用。

这一点后面会在生命周期里系统讲。现在只要记住:Rust 的作用域会参与检查“引用不能比被引用的数据活得更久”。

名字可见性不等于 API 可见性

C 里文件作用域的 static 常用来限制符号只在当前 .c 文件内可见:

static int parse_line(const char *line);

Rust 的模块可见性用 pub 表达。默认情况下,模块里的项对外不可见:

fn parse_line(line: &str) -> usize {
    line.len()
}

要暴露出去,写 pub:

pub fn parse_line(line: &str) -> usize {
    line.len()
}

第一层可以这样理解:

不写 pub -> 当前模块内部使用
写 pub   -> 允许从模块外访问

完整的 crate、module、pub(crate)、pub(super)、use 规则更适合放在模块组织里理解。这里先区分两个问题:

  • 作用域:一个名字在代码块里能不能被看见
  • 可见性:一个模块里的项能不能作为 API 暴露出去

static 是全局存储,不是普通局部变量

C 的文件作用域变量:

static int counter;

它有静态存储期,生命周期覆盖整个程序运行期。

Rust 也有 static:

static APP_NAME: &str = "iot-tool";

static 有固定存储位置,适合少量全局常量或真正需要全局地址的对象。

但不要把 static mut 当成 C 全局变量的直接替代:

static mut COUNTER: u32 = 0;

访问可变全局状态涉及并发和数据竞争,通常需要 unsafe 或同步类型。普通配置常量优先用 const,普通状态优先放进结构体或明确的 owner 里。

最小迁移表

C block scope              -> Rust block scope
C local variable           -> Rust let binding
C mutable local            -> Rust let mut
C same-scope redeclare     -> Rust shadowing creates a new binding
C stack variable out of scope -> Rust binding no longer usable
C manual cleanup           -> Rust Drop at scope end
C return local address     -> Rust rejected by borrow checking
C file-static function     -> Rust private item by default
C exported function        -> Rust pub fn
C static storage object    -> Rust static

Rust 的作用域不是一个孤立语法点。它同时影响名字、资源和 API 边界。C 里很多要靠注释和习惯维护的范围约定,Rust 会尽量变成编译器能检查的边界。

继续阅读