Functions

A function is declared with fn, a name in snake_case, a parameter list in which every parameter has a type, and, after ->, the type of the result. The order of functions in a file does not matter: main can call a function that is defined further down, without a forward declaration or a header.

fn main() {
    let cents = cost_cents(350, 2700);
    println!("350 kWh at €0.2700/kWh: {cents} cents");
}

// Price in ten-thousandths of a euro per kWh; result in cents.
fn cost_cents(kwh: u64, price_per_kwh: u64) -> u64 {
    kwh * price_per_kwh / 100
}
350 kWh at €0.2700/kWh: 9450 cents

The money is kept in whole numbers, as on Variables, Types and Mutability: the price €0.2700 is 2700 ten-thousandths of a euro, and 350 × 2700 / 100 is 9450 cents. Parameter types are never inferred, even where the compiler could guess them. That is deliberate: the signature is a contract that callers and the compiler can check without reading the body, and an error inside one function cannot change what other functions are allowed to pass in. Inside the body, local variables are inferred as usual (see type inference).

Tail expressions and return

Rust is expression-oriented (see Control Flow and Pattern Matching): a block { … } has a value, the value of its last expression. A tail expression is that last expression of a block or function body, written without a semicolon; its value becomes the value of the block, and for a function body, the return value. kwh * price_per_kwh / 100 above is one. A semicolon turns an expression into a statement and throws its value away, so one stray semicolon changes what the function returns:

fn cost_cents(kwh: u64, price_per_kwh: u64) -> u64 {
    kwh * price_per_kwh / 100;
}

fn main() {
    println!("{}", cost_cents(350, 2700));
}
example.rs:1:48: error[E0308]: mismatched types: expected `u64`, found `()`

The full message explains what happened and how to fix it:

error[E0308]: mismatched types
 --> example.rs:1:48
  |
1 | fn cost_cents(kwh: u64, price_per_kwh: u64) -> u64 {
  |    ----------                                  ^^^ expected `u64`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
2 |     kwh * price_per_kwh / 100;
  |                              - help: remove this semicolon to return this value

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0308`.

(), pronounced "unit", is the type with exactly one value, also written (). It is what a block without a tail expression produces, and the return type of a function that declares none: fn log_line(…) means fn log_line(…) -> (). It plays the role of void in C and of Python's implicit None, but it is an ordinary value with a type.

Use return to leave a function before its end, for example after checking a special case. To return several values, return a tuple and take it apart with let:

fn euros_and_cents(cents: u64) -> (u64, u64) {
    (cents / 100, cents % 100)
}

fn describe(kwh: u64) -> String {
    if kwh == 0 {
        return String::from("no usage");
    }
    let (euros, cents) = euros_and_cents(kwh * 2700 / 100);
    format!("{kwh} kWh, €{euros}.{cents:02}")
}

fn log_line(meter: &str, kwh: u64) {
    println!("{meter}: {}", describe(kwh));
}

fn main() {
    log_line("M-30004", 350);
    log_line("M-30007", 0);
    let nothing: () = log_line("M-30011", 12);
    println!("log_line returned {nothing:?}");
}
M-30004: 350 kWh, €94.50
M-30007: no usage
M-30011: 12 kWh, €3.24
log_line returned ()

log_line takes the meter as &str, a borrowed piece of text, rather than an owned String: it only needs to read it. Whether a parameter takes a value or borrows it is one of the most important choices in a Rust signature, explained on Ownership and Borrowing and References.

Modules and privacy

A module is a named container for functions, types, constants and other modules, declared with mod. Modules form a tree inside a crate, and each module is a privacy boundary: an item is private to the module it is defined in (and that module's children) unless it is marked pub. A private helper is an implementation detail that the rest of the program cannot call, so you can change it without breaking anything outside the module.

mod tariff {
    /// Fixed price in ten-thousandths of a euro per kWh (€0.2700).
    pub const FIXED_PRICE: u64 = 2700;

    pub fn cost_cents(kwh: u64) -> u64 {
        round_to_cents(kwh * FIXED_PRICE)
    }

    fn round_to_cents(ten_thousandths: u64) -> u64 {
        (ten_thousandths + 50) / 100
    }
}

fn main() {
    println!("price: {} ten-thousandths of a euro per kWh", tariff::FIXED_PRICE);
    println!("350 kWh: {} cents", tariff::cost_cents(350));
    println!("rounded: {} cents", tariff::round_to_cents(945_049));
}
example.rs:17:43: error[E0603]: function `round_to_cents` is private: private function

main may use FIXED_PRICE and cost_cents, but not round_to_cents; the error also points at the private definition. Remove the last line and the program prints the price and 9450 cents. The privacy check happens at compile time, so it costs nothing when the program runs.

Visibility comes in a few steps:

Written as Visible in
(nothing) the module itself and its child modules
pub(super) also the parent module
pub(crate) the whole crate, but not other crates that use it
pub everywhere the module itself can be reached, including other crates when the path is public all the way down

Privacy also applies to the fields of a struct: a pub struct Meter with a private field can only be created and changed through the functions its module offers, which lets the module guarantee rules such as "a reading never goes down". That is how Rust does encapsulation; structs and their methods are on Structs, Enums and Methods.

Paths: crate, self and super

Items in modules are named by paths with :: between the parts, like folders in a file path. A path can start in three places:

  • crate:: starts at the root of the current crate (an absolute path, like / in a file system);
  • self:: or just a name starts in the current module;
  • super:: starts in the parent module, like ...
mod billing {
    pub mod tariff {
        pub fn price(kind: &str) -> u64 {
            match kind {
                "fixed" => 2700,
                _ => super::VARIABLE_PRICE,
            }
        }
    }

    pub mod invoice {
        use super::tariff;

        pub fn total_cents(kind: &str, kwh: u64) -> u64 {
            (kwh * tariff::price(kind) + 50) / 100
        }
    }

    const VARIABLE_PRICE: u64 = 2600; // the variable tariff, €0.2600
}

use billing::invoice;

fn main() {
    println!("fixed: {} cents", invoice::total_cents("fixed", 350));
    println!("other: {} cents", crate::billing::invoice::total_cents("variable", 350));
    println!("price: {}", billing::tariff::price("fixed"));
}
fixed: 9450 cents
other: 9100 cents
price: 2700

Two details are worth noticing. tariff reads its parent's private constant VARIABLE_PRICE through super::: a child module can see everything in its ancestors, but not the other way round. And for main to reach billing::tariff::price, both the module tariff and the function must be pub; a public function in a private module is still unreachable from outside. Both prices are Kestrel's tariffs on this site: €0.2700 fixed and €0.2600 variable.

use declarations

A use declaration creates a short local name for an item that lives at a longer path, for the rest of the module it appears in. It does not load, copy or include anything; the compiler already knows every item of the crate and of its dependencies. Braces import several items from one place, and as gives an item another name, which helps when two imports would clash:

use std::collections::BTreeMap;

mod tariff {
    pub const FIXED_PRICE: u64 = 2700;

    pub fn cost_cents(kwh: u64) -> u64 {
        (kwh * FIXED_PRICE + 50) / 100
    }
}

use tariff::{FIXED_PRICE, cost_cents as fixed_cost};

fn main() {
    let mut usage = BTreeMap::new();
    usage.insert("h0042", 412);
    usage.insert("h0017", 350);

    println!("price {FIXED_PRICE}");
    for (home, kwh) in &usage {
        println!("{home}: {} cents", fixed_cost(*kwh));
    }

    let mut homes = Vec::new();       // Vec and String need no use: they are in the prelude
    for home in usage.keys() {
        homes.push(home.to_string());
    }
    println!("{homes:?}");
}
price 2700
h0017: 9450 cents
h0042: 11124 cents
["h0017", "h0042"]

The standard library is the crate std, and every Rust program can use it without declaring a dependency. A small part of it, the prelude, is imported into every module automatically: Vec, String, Option with Some and None, Result with Ok and Err, Box, common traits such as Clone, and since Rust 1.80 the function size_of. Everything else needs a path or a use. Forget it, and the compiler usually suggests the right line:

fn main() {
    let mut usage = BTreeMap::new();
    usage.insert("h0017", 350);
    println!("{usage:?}");
}
example.rs:2:21: error[E0433]: cannot find type `BTreeMap` in this scope: use of undeclared type `BTreeMap`
help: consider importing this struct
  |
1 + use std::collections::BTreeMap;
  |

By convention, functions are imported through their module (use crate::tariff; and then tariff::cost_cents(…)), so a call shows where the function comes from, while types are imported by their own name (use std::collections::BTreeMap;). A pub use re-exports an item: a library can keep its internal module layout and still offer a short public path such as kestrel_readings::Reading.

Modules in separate files

So far every module was written inline, in braces. In a real project each module usually has its own file. The line mod tariff;, with a semicolon instead of a body, tells the compiler to load the module's contents from tariff.rs next to the file that declares it. The files of a Cargo project live in src/; the next example creates a project with the cargo command, writes two files and runs the program:

cargo new -q --vcs none kestrel-readings
cd kestrel-readings
cat > src/tariff.rs <<'EOF'
/// Fixed price in ten-thousandths of a euro per kWh (€0.2700).
pub const FIXED_PRICE: u64 = 2700;

pub fn cost_cents(kwh: u64) -> u64 {
    (kwh * FIXED_PRICE + 50) / 100
}
EOF
cat > src/main.rs <<'EOF'
mod tariff;

fn main() {
    let cents = tariff::cost_cents(350);
    println!("350 kWh: €{}.{:02}", cents / 100, cents % 100);
}
EOF
cargo run
   Compiling kestrel-readings v0.1.0 (/home/amara/kestrel-readings)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.24s
     Running `target/debug/kestrel-readings`
350 kWh: €94.50

The file tariff.rs contains the module's body only: no mod tariff { … } around it. A file is never a module on its own; it becomes one because a mod line somewhere declares it, and a file that no mod line mentions is simply not compiled. Submodules go into a folder named after the parent: mod rates; inside tariff.rs loads src/tariff/rates.rs. An older layout puts the parent module in src/tariff/mod.rs instead of src/tariff.rs; both work, and you will meet both.

kestrel-readings/
├── Cargo.toml
└── src/
    ├── main.rs          crate root: mod tariff;
    ├── tariff.rs        the module tariff: mod rates;
    └── tariff/
        └── rates.rs     the module tariff::rates

Crates and packages

A crate is the unit the Rust compiler builds in one go: one tree of modules, starting at a crate root file, compiled into one program or one library. rustc never sees individual modules separately; it gets the crate root and follows the mod lines from there. There are two kinds:

  • A binary crate compiles to an executable program. It must have a main function; its root in a Cargo project is src/main.rs.
  • A library crate has no main; it offers its pub items to other crates. Its root is src/lib.rs, and Cargo compiles it into an .rlib file that only the Rust compiler reads. When Rust programmers say "a crate" without more context, they usually mean a library, such as one from crates.io.

A Cargo package is a folder with a Cargo.toml that describes how to build one or more crates: at most one library crate and any number of binary crates. A common layout puts the real work in the library and keeps main.rs small, so that tests and other programs can use the same code. Inside the binary, the library is used under the package's name, with hyphens replaced by underscores:

cargo new -q --vcs none kestrel-readings
cd kestrel-readings
cat > src/tariff.rs <<'EOF'
/// Fixed price in ten-thousandths of a euro per kWh (€0.2700).
pub const FIXED_PRICE: u64 = 2700;

pub fn cost_cents(kwh: u64) -> u64 {
    (kwh * FIXED_PRICE + 50) / 100
}
EOF
cat > src/lib.rs <<'EOF'
//! Shared code for Kestrel's reading tools.
pub mod tariff;

/// Formats an amount in cents as euros: 9450 -> "€94.50".
pub fn euros(cents: u64) -> String {
    format!("€{}.{:02}", cents / 100, cents % 100)
}
EOF
cat > src/main.rs <<'EOF'
use kestrel_readings::{euros, tariff};

fn main() {
    println!("350 kWh: {}", euros(tariff::cost_cents(350)));
}
EOF
cargo build -q
ls target/debug
./target/debug/kestrel-readings
build
deps
examples
incremental
kestrel-readings
kestrel-readings.d
libkestrel_readings.d
libkestrel_readings.rlib
350 kWh: €94.50

One package, two crates: libkestrel_readings.rlib is the library crate, kestrel-readings the program. The module tariff now hangs under lib.rs, and main.rs reaches it only through the library's public items, exactly as an outside user would. The -q option keeps Cargo quiet. Comments that start with /// and //! are documentation comments; they are covered with cargo doc on Testing, Docs and Tooling.

The package kestrel-readings, described by its Cargo.toml, contains two crates: a library crate with root src/lib.rs and a module tariff in src/tariff.rs, and a binary crate with root src/main.rs that uses the library. The package also depends on a second package, p1crc, through a path dependency; p1crc contains one library crate. package kestrel-readings Cargo.toml library crate src/lib.rs mod tariff src/tariff.rs binary crate src/main.rs fn main() uses the library package p1crc library crate src/lib.rs path dependency
A package holds crates; a crate holds a tree of modules. A dependency is always on another package's library crate.

Path dependencies

Code shared between several projects goes into its own library package, and the projects that need it list it under [dependencies] in their Cargo.toml. Most dependencies come from the public registry crates.io, which is where supply-chain questions start (see The Crate Ecosystem and Supply-Chain Safety). A path dependency points to a folder on disk instead: nothing is downloaded, which makes it the normal choice for packages that live in the same repository.

In this example a developer on Kestrel's platform team keeps the CRC-16/ARC checksum of P1 telegrams (explained on Smart Meters and the P1 Port) in a small library package, p1crc, and uses it from a command-line tool:

[package]
name = "readings-cli"
version = "0.1.0"
edition = "2024"

[dependencies]
p1crc = { path = "../p1crc" }
cargo new -q --vcs none --lib p1crc
cat > p1crc/src/lib.rs <<'EOF'
//! CRC-16/ARC, the checksum at the end of a P1 telegram.

/// Reflected polynomial 0xA001, start value 0, no final XOR.
pub fn crc16_arc(data: &[u8]) -> u16 {
    let mut crc: u16 = 0;
    for &byte in data {
        crc ^= u16::from(byte);
        for _ in 0..8 {
            if crc & 1 != 0 {
                crc = (crc >> 1) ^ 0xA001;
            } else {
                crc >>= 1;
            }
        }
    }
    crc
}
EOF
cargo new -q --vcs none readings-cli
cd readings-cli
echo 'p1crc = { path = "../p1crc" }' >> Cargo.toml
cat > src/main.rs <<'EOF'
fn main() {
    let crc = p1crc::crc16_arc(b"123456789");
    println!("check value: {crc:04X}");
}
EOF
cargo generate-lockfile -q
cat Cargo.lock
cargo run
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4

[[package]]
name = "p1crc"
version = "0.1.0"

[[package]]
name = "readings-cli"
version = "0.1.0"
dependencies = [
 "p1crc",
]
   Compiling p1crc v0.1.0 (/home/amara/p1crc)
   Compiling readings-cli v0.1.0 (/home/amara/readings-cli)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.28s
     Running `target/debug/readings-cli`
check value: BB3D

cargo new ends every Cargo.toml with an empty [dependencies] section, so the echo line appends the dependency in the right place, giving the file shown above. The program calls p1crc::crc16_arc by its full path, without a use: a dependency's name is available as the start of a path in every module. It prints BB3D, the published check value of CRC-16/ARC. Cargo compiled the dependency first, and recorded both packages in Cargo.lock, the file that pins the exact versions of everything a build uses. For crates.io dependencies it also stores checksums; why you commit it is explained on The Crate Ecosystem and Supply-Chain Safety.

Organizing a program

A few habits keep a growing Rust program easy to find your way in:

  • Put logic in a library crate and keep main.rs to reading arguments, calling the library and printing results. The library can then be tested without starting the program.
  • Make a module per subject (tariff, telegram, report), not per kind of thing (functions, structs).
  • Start private and make items pub only when another module needs them. Every pub item in a library is a promise to its users that is hard to take back; Cargo's versioning rules treat removing one as a breaking change (see semantic versioning).
  • Keep functions short and give them a signature that says what they need: a borrowed &str or &[u32] when they only read, an owned value when they keep it. The next pages explain why.