Skip to main contentScope, Bindings, and Visibility | IoT Worker

Scope, Bindings, and Visibility

C programmers usually think of scope first as name visibility:

void handle(void)
{
    int offset = 0;

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

    /* len is not visible, offset is still visible */
}

Rust block scope has the same basic rule:

fn handle() {
    let mut offset = 0;

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

    // len is not visible, offset is still visible
}

But Rust scope is not only about whether a name can be used. It also affects when resources are released, whether a binding can be shadowed, whether a reference can continue to exist, and which names become part of a module’s public API.

Start with four rules:

  • { ... } creates a block scope
  • let creates a binding, not just a “variable declaration”
  • shadowing creates a new binding that hides the old one
  • leaving a scope runs resource cleanup

Block scope is close to C

Local variables in C are visible only inside their block:

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

/* code is not visible */

Rust is similar:

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

// code is not visible

Function bodies, if, while, loop, and plain { ... } blocks all create scopes.

A manual block is also common:

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

The block limits the visibility of fixed and options, and it also produces a value for header_len.

let creates a binding

C declarations create object names:

int len = 10;

Rust usually says “binding”:

let len = 10;

At the first layer, the name len is bound to a value.

Bindings are immutable by default:

let len = 10;
len = 11; // compile error

Use mut for reassignment:

let mut len = 10;
len = 11;

This differs from C’s mutable-by-default local variables. Rust makes it visible at the binding site whether the name may be reassigned later.

Shadowing is a new binding, not reassignment

C does not allow redeclaring the same name in the same block:

int len = 10;
int len = 20; // error

Rust allows shadowing:

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

The second len is a new binding that hides the first one. It is not reassignment.

This is useful when the same business concept changes type:

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

It can also turn a mutable calculation into an immutable result:

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

The last line creates a new immutable binding. Later code cannot change checksum.

Leaving a scope releases resources

In C, ordinary locals go out of scope, but heap memory and file handles usually need manual cleanup:

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

/* ... */

fclose(fp);

In Rust, many resources are managed by values themselves. When the scope ends, Drop runs:

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

String, Vec, file objects, lock guards, and similar types release their owned resources when they leave scope.

You can shorten a scope manually:

{
    let file = std::fs::File::open(path)?;
    // use file
} // file is closed here

// file is no longer held

This is not full ownership theory yet. The first useful intuition is: Rust scope is both a name boundary and a resource boundary.

Inner scopes can borrow outer values, not the other way around

Returning the address of a local variable is a classic C bug:

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

Rust rejects references that escape the scope of the data they refer to.

This shape works:

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

r can be used because value is still alive.

This shape does not work:

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

value is gone when the inner scope ends, so the outer scope cannot keep using a reference to it.

Lifetimes deserve their own article. For now, keep this rule: Rust scope participates in checking that a reference does not outlive the data it points to.

Name visibility is not API visibility

C often uses file-scope static to limit a symbol to the current .c file:

static int parse_line(const char *line);

Rust module visibility uses pub. By default, items are private to their module:

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

To expose it, write pub:

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

The first mapping is:

no pub -> internal to the current module
pub    -> accessible from outside the module

Full crate, module, pub(crate), pub(super), and use rules are easier to understand as module organization. For now, separate two questions:

  • scope: whether a name can be seen inside a code block
  • visibility: whether an item can be exposed as module API

static is global storage, not an ordinary local

C file-scope variables:

static int counter;

They have static storage duration and live for the whole program.

Rust has static too:

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

static has a fixed storage location. It is suitable for a small number of global constants or objects that genuinely need a global address.

Do not treat static mut as a direct replacement for C globals:

static mut COUNTER: u32 = 0;

Mutable global state touches concurrency and data races, and usually requires unsafe or synchronization types. Ordinary configuration constants should usually start as const; ordinary state should usually live inside a struct or an explicit owner.

Minimal migration table

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 scope is not an isolated syntax point. It affects names, resources, and API boundaries. Many range rules C maintains through comments and conventions become compiler-checkable boundaries in Rust.

Continue Reading