Skip to main contentFunctions, Parameters, and Return Values | IoT Worker

Functions, Parameters, and Return Values

For a C programmer, a Rust function first looks like the same idea with syntax moved around:

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

In Rust:

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

They do the same work, but Rust functions introduce a few early habits:

  • parameter names come before types
  • return types come after ->
  • the final expression can be the return value
  • return is often used for early exits
  • functions without useful return values return ()
  • C out parameters and return codes often become Rust return values and Result

This is not full ownership theory, but it immediately affects how you write a first library function.

Parameter names come before types

C puts the type first:

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

Rust puts the name first and the type second:

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

At the first layer, the mapping is:

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

C uses two parameters to express “pointer plus length.” Rust uses one slice parameter to express “a read-only view of contiguous data.”

For a simple integer parameter:

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

Parameter types are part of the function contract. Rust does not use C-style implicit conversions to let unrelated integer types flow through.

Return types come after the arrow

C puts the return type before the function name:

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

Rust puts it after the parameter list:

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

When there is no useful return value, Rust can omit -> ():

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

Written out fully:

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

For first-layer function usage, () occupies roughly the place where C programmers expect void.

The tail expression is the return value

C usually writes return explicitly:

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

Rust can write:

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

The final expression without a semicolon is the return value.

If you add a semicolon, it becomes a statement and evaluates to ():

fn add(a: i32, b: i32) -> i32 {
    a + b; // compile error: this actually returns ()
}

This is the same rule as block expressions. A function body is a block, and the block’s final expression can become the function result.

return is common for early exits

Rust still has return:

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

    Some(data[0])
}

Here return handles the special path, while the main path stays at the end.

C often writes the same shape with return codes and out parameters:

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;
}

In Rust, if “no value” is a valid outcome, return Option:

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

The output parameter and return code do not need to keep their C shape.

Out parameters often become return values

C often uses out parameters to return data:

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

Rust more often returns the result directly:

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]]),
    })
}

The first mapping is:

C return code + out parameter -> Rust Result<value, error>

This is not just syntax. The Rust caller sees in the type that the operation may fail, and that Header exists only on success.

If the function really must write into a caller-provided buffer, use a mutable parameter:

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)
}

This corresponds to C’s uint8_t *out, size_t capacity, but the Rust type says “writable slice” directly.

Function parameters are passed by value by default

C parameters are passed by value:

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

Changing retry does not change the caller’s variable.

Rust is the same:

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

Here mut retry only means the local parameter binding inside the function can change. It does not mean the caller’s variable changes.

To modify caller-provided data, C uses a pointer:

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

The first Rust mapping is a mutable reference:

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

The call site must explicitly pass a mutable reference:

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

Borrowing deserves its own article. For now, keep this distinction:

mut retry: i32  -> the local parameter binding can be changed
retry: &mut i32 -> the function can change the caller-provided value

Functions can appear later, but module boundaries are separate

C often needs a declaration before use:

int add(int a, int b);

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

Rust does not need a prototype in the same module:

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

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

This does not mean Rust has no interface boundaries. Across files, modules, and crates, mod, pub, use, and package organization matter. That belongs to module and file organization, not single-function syntax.

Minimal migration table

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 or return expr
C final return                -> Rust tail expression
C return code + out parameter -> Rust Result<T, E>
C T *out                      -> Rust &mut T / &mut [T]
C const uint8_t *p, size_t n  -> Rust &[u8]
C local parameter can change  -> Rust mut parameter binding
C modify caller variable      -> Rust &mut parameter
C forward declaration         -> Rust no prototype needed within one module

At the first layer, Rust functions are not complicated. The important shift is that the function signature more clearly says whether data is input, output, mutable output, or a fallible result. Conventions that C maintains through names and comments move into types.

Continue Reading