Skip to content

Rust Borrowing vs C++ const: References and Lifetimes

Rust shared references and C++ const references both restrict operations through an access path, but they are not the same contract. Compare who may still change the object, which state may change through a read-like method, and who keeps borrowed storage alive before translating a C++ API into Rust.

Requirement: inspect a reading without copying its owner

Continue after values, ownership and RAII, with the borrowing and lifetime lessons available for review. Use Rust edition 2024 and a C++20 compiler; neither program reads hardware or uses threads.

An owner stores a fixture reading. Inspection returns a copied scalar and increments a diagnostic counter, but must not change the reading. Updating the reading is a separate operation. A label can be viewed without duplicating its text, provided its owner stays valid. These are deliberately single-threaded requirements, not a sensor-driver interface.

C++ const describes an access path

Save this complete program as main.cpp:

#include <cassert>
#include <iostream>
#include <string>
#include <string_view>

class Sensor {
    int reading;
    mutable unsigned hits = 0;

public:
    explicit Sensor(int value) : reading(value) {}
    int inspect() const { ++hits; return reading; }
    void set(int value) { reading = value; }
    unsigned inspections() const { return hits; }
};

std::string_view label(const std::string& owner) {
    return owner;
}

int main() {
    Sensor sensor(46700);
    const Sensor& view = sensor;
    const int before = view.inspect();
    sensor.set(-500);
    const int after = view.inspect();
    assert(before == 46700 && after == -500);
    assert(sensor.inspections() == 2);
    std::cout << "before=" << before << ", after=" << after << '\n';
    std::cout << "inspections=" << sensor.inspections() << '\n';

    std::string owner = "Pi 4B";
    const auto borrowed = label(owner);
    assert(borrowed.size() == 5);
    std::cout << "label bytes=" << borrowed.size() << '\n';

    int plain = 0;
    const int& observed = plain;
    plain = 1;
    assert(observed == 1);
    std::cout << "after borrow=" << plain << '\n';

    const Sensor fixed(0);
    assert(fixed.inspect() == 0);
    assert(fixed.inspections() == 1);
}

Run both assertion-enabled builds:

1
2
3
4
g++ -std=c++20 -Wall -Wextra -Wpedantic -O0 main.cpp -o const_debug
./const_debug
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 main.cpp -o const_optimized
./const_optimized

A const access path does not turn an originally non-const object into a permanently frozen object. Updating sensor through its non-const owner and then using view is allowed here. A genuinely const object cannot have its ordinary reading member modified; mutable permits the diagnostic field to change even in fixed. Neither property is thread synchronisation. See the C++ working draft's cv qualifiers; the example uses established C++20 mechanisms, not newer draft-only features.

The string_view contract describes a view of character storage, not ownership of that storage. The returned label remains valid here because owner lives and is not changed before the view is used. Its signature does not encode this caller obligation as a Rust lifetime relationship. Do not keep a view across owner destruction or a mutation that invalidates its storage.

Rust separates ordinary borrowing from interior mutation

Create a package:

cargo new borrow_compare --edition 2024
cd borrow_compare

Replace src/main.rs with this complete program:

use std::cell::Cell;

struct Sensor {
    reading: i32,
    hits: Cell<u32>,
}

impl Sensor {
    fn new(reading: i32) -> Self {
        Self {
            reading,
            hits: Cell::new(0),
        }
    }

    fn inspect(&self) -> i32 {
        self.hits.set(self.hits.get() + 1);
        self.reading
    }

    fn set(&mut self, reading: i32) {
        self.reading = reading;
    }
}

fn label(owner: &str) -> &str {
    owner
}

fn main() {
    let mut sensor = Sensor::new(46_700);
    let before = sensor.inspect();
    sensor.set(-500);
    let after = sensor.inspect();
    assert_eq!((before, after), (46_700, -500));
    println!("before={before}, after={after}");
    println!("inspections={}", sensor.hits.get());

    let owner = String::from("Pi 4B");
    let borrowed = label(&owner);
    println!("label bytes={}", borrowed.len());

    let mut plain = 0;
    let observed = &plain;
    assert_eq!(*observed, 0);
    // The shared borrow's final use is above, not after this assignment.
    plain = 1;
    println!("after borrow={plain}");
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn inspection_changes_only_the_counter() {
        let sensor = Sensor::new(46_700);
        let first = &sensor;
        let second = &sensor;
        assert_eq!(first.inspect(), 46_700);
        assert_eq!(second.inspect(), 46_700);
        assert_eq!(sensor.reading, 46_700);
        assert_eq!(sensor.hits.get(), 2);
    }

    #[test]
    fn updates_preserve_zero_and_negative_values() {
        let mut sensor = Sensor::new(0);
        assert_eq!(sensor.inspect(), 0);
        sensor.set(-500);
        assert_eq!(sensor.inspect(), -500);
    }

    #[test]
    fn returned_view_borrows_existing_storage() {
        let owner = String::from("Pi 4B");
        let borrowed = label(&owner);
        assert_eq!(borrowed, "Pi 4B");
        assert_eq!(borrowed.as_ptr(), owner.as_ptr());
    }

    #[test]
    fn empty_and_unicode_labels_are_byte_sequences() {
        assert_eq!(label("").len(), 0);
        assert_eq!(label("π").len(), 2);
        assert_eq!(label("π").chars().count(), 1);
    }
}

Run these checks with the exact source:

1
2
3
4
5
6
cargo check
cargo fmt --check
cargo test
cargo test --release
cargo run --quiet
cargo run --release --quiet

Four tests should pass. Both working programs produce:

1
2
3
4
before=46700, after=-500
inspections=2
label bytes=5
after borrow=1

Equal output does not mean equal permitted access patterns. Rust ends the shared borrow of plain before assignment because observed is no longer used; the C++ program uses its const reference after assignment. Moving the Rust assertion after assignment changes whether the program is accepted.

Why &T is not simply const T&

Rust reference types distinguish shared and mutable references. While an ordinary shared borrow must remain usable, you cannot take conflicting exclusive access to its data in safe Rust. Mutable references provide exclusive access subject to reborrowing and the operations the borrow checker permits; C++ non-const references do not impose that same general exclusion rule.

The interior-mutability rules allow designated state to change through a shared reference. Here, Cell holds a copied diagnostic integer; no reference to its inner integer escapes. Cell is not Sync, so this type is not automatically suitable for shared cross-thread inspection. C++ mutable also does not make simultaneous increments safe. The counters use small bounded fixtures; a production counter additionally needs an overflow policy.

Concern C++ working example Rust working example
Read-like method const member function &self receiver
Diagnostic change mutable member Cell inside the value
Reading update Non-const access path &mut self receiver
Borrowed text string_view with caller lifetime obligations &str tied to the input borrow
Thread sharing Requires a separate data-race/synchronisation contract Cell prevents Sync; choose a suitable design if sharing is needed

These are comparisons of specific APIs, not substitutes for their full language rules. Neither const nor a lifetime annotation is a physical measurement or an application correctness proof.

Reject a conflicting access, not just an unwanted output

Save the normal Rust source and temporarily replace it with:

1
2
3
4
5
6
fn main() {
    let mut reading = 46_700;
    let observed = &reading;
    reading = -500;
    println!("{observed}");
}

cargo check rejects the assignment with E0506 on the verified compiler, because the borrow is used afterward. Repair it by using observed before assigning reading, then take a new borrow if needed. This is not a demand for an unnecessary clone.

Compile this separate invalid C++ source as failure.cpp:

1
2
3
4
5
int main() {
    int reading = 46700;
    const int& observed = reading;
    observed = -500;
}

It fails because assignment through this const reference is forbidden. Assignment through reading is a different access path and is allowed for this non-const object. Do not use const_cast to mutate a genuinely const object: a cast does not make that operation well-defined.

Change the requirement: keep a label after the owner changes

Borrowing is suitable when the caller can preserve the owner and its storage. If a report must keep the old label after its source is replaced or destroyed, give the report an owned String/std::string instead of a borrowed view. This is an explicit snapshot requirement, not “clone whenever the compiler complains.”

Rust's label signature uses lifetime elision: the one input-reference lifetime is assigned to the output reference. Writing the same relationship explicitly does not prolong the owner's life. A function cannot return a borrowed view of its local String. A C++ string_view of local character storage likewise does not become owning because the function returns it, although such a program may compile. Do not execute a dangling-view demonstration to investigate it.

For a snapshot, copy while the source is valid, mutate or destroy the source, then test that the report retains its original label. Prefer a borrow for immediate inspection and an owned value for independently retained data. Shared ownership introduces another lifetime policy and should not be the default replacement for either.

Exercises and verification

  1. Move the Rust observed assertion after assignment. It must fail compilation; then use the value before mutation and verify the repaired program.
  2. Try returning a view of a local String. Rust must reject the returned borrow; do not run the analogous invalid C++ view.
  3. Borrow owner as &str, change owner, and use the old view afterward. Rust must reject the conflicting borrow. Obtain an owned snapshot before mutation to satisfy the changed requirement.
  4. Remove Cell/counter changes and the C++ mutable counter when diagnostics are unnecessary. A completely read-only inspection contract is simpler; update the tests to match rather than pretending the old contract remains.
  5. Explain why a shared reference permitting diagnostic changes is neither an exclusive borrow nor a thread-safe counter.

On October 10, 2026, the authorised Raspberry Pi 4B verified the extracted programs with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust/Cargo 1.99.0 and GCC g++ 14.2.0. Rust check/formatting, four debug/release tests and exact outputs passed. C++20 passed at -O0 and -O2 with assertions enabled. The borrow-before-mutation repair and owned snapshots passed; the C++ snapshot retained its label after clearing the owner in both builds. Rust rejected live-borrow assignment, a returned local borrow and owner mutation with E0506, E0515 and E0502. C++ rejected assignment through the const reference. No undefined behaviour, concurrent counter access or hardware input was executed.

Continue with traits, inheritance, generics and dispatch. Follow only the published entries in the course overview.

Previous: values and RAII · Next: traits and dispatch · Course overview

Donate