Skip to content

Rust Traits vs C++ Interfaces: Generics and Dispatch

A Rust trait is not a class containing inherited fields, and a C++ concept is not a virtual base class. Start with the required behaviour, then separately choose whether a caller knows the concrete type, whether different types share a collection, and who owns their storage.

Requirement: read through one behavioural contract

After borrowing and const, review traits and dispatch. Use Rust edition 2024 and C++20, with no external libraries.

A source returns a measured integer or absence. The caller must preserve zero and negative values, support a concrete source in a generic function, and aggregate a borrowed mixture of source types without taking ownership. The concrete types should not need identical fields. This is a fixture policy, not a claim about a physical sensor or an error-reporting protocol; absence is not a substitute for an I/O error.

C++: a concept and a virtual interface solve different problems

Save this complete program as main.cpp:

#include <array>
#include <cassert>
#include <concepts>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <optional>
#include <utility>

struct Fixed {
    int value;
    std::optional<int> read() const { return value; }
};

struct Missing {
    std::optional<int> read() const { return std::nullopt; }
};

template<class T>
concept Readable = requires(const T& value) {
    { value.read() } -> std::same_as<std::optional<int>>;
};

template<Readable T>
std::optional<int> generic_read(const T& source) {
    return source.read();
}

struct Source {
    virtual std::optional<int> read() const = 0;
    virtual ~Source() = default;
};

template<Readable T>
class Adapter final : public Source {
    T value;

public:
    explicit Adapter(T source) : value(std::move(source)) {}
    std::optional<int> read() const override { return value.read(); }
};

struct Report {
    std::size_t present = 0;
    std::size_t missing = 0;
    std::int64_t sum = 0;
};

template<std::size_t N>
Report collect(const std::array<const Source*, N>& sources) {
    Report report;
    for (const auto* source : sources) {
        const auto value = source->read();
        if (value) {
            ++report.present;
            report.sum += *value;
        } else {
            ++report.missing;
        }
    }
    return report;
}

auto make_fixed(int value) { return Fixed{value}; }

int main() {
    Fixed concrete{46700};
    Adapter<Fixed> fixed(concrete);
    Adapter<Missing> missing(Missing{});
    Adapter<Fixed> negative(Fixed{-500});
    std::array<const Source*, 3> sources{&fixed, &missing, &negative};
    const auto report = collect(sources);
    assert(generic_read(concrete) == 46700);
    assert(fixed.read() == generic_read(concrete));
    assert(report.present == 2 && report.missing == 1 && report.sum == 46200);
    assert(!generic_read(Missing{}));
    assert(generic_read(make_fixed(0)) == 0);
    const auto empty = collect(std::array<const Source*, 0>{});
    assert(empty.present == 0 && empty.missing == 0 && empty.sum == 0);
    std::cout << "generic=" << *generic_read(concrete) << '\n';
    std::cout << "dynamic=" << *sources[0]->read() << '\n';
    std::cout << "present=" << report.present << ", missing=" << report.missing
              << ", sum=" << report.sum << '\n';
    std::cout << "factory=" << *generic_read(make_fixed(0)) << '\n';
}

Compile/run with g++ -std=c++20 -Wall -Wextra -Wpedantic -O0 main.cpp -o dispatch_debug, then repeat with -O2 and a different executable name. Keep assertions enabled; do not add -DNDEBUG.

Readable checks an expression requirement for template arguments. Fixed and Missing satisfy it without inheriting from Source. Adapter is an explicit composition bridge to a virtual interface for the mixed collection. It is not a requirement for every C++ design; a source could implement that interface directly when appropriate.

The C++ constraint rules govern template acceptance, while virtual functions provide dynamic calls through a base interface. Neither declaration proves the behavioural meaning of absence or a reading's units. All pointers in this fixture are non-null and refer to live stack objects; the function assumes that contract. These pointers do not own the objects.

Rust: one trait can serve generic and erased callers

Create a package:

cargo new dispatch_compare --edition 2024
cd dispatch_compare

Replace src/main.rs with this complete program:

trait Source {
    fn read(&self) -> Option<i32>;
}

struct Fixed(i32);
struct Missing;

impl Source for Fixed {
    fn read(&self) -> Option<i32> {
        Some(self.0)
    }
}

impl Source for Missing {
    fn read(&self) -> Option<i32> {
        None
    }
}

fn generic_read<T: Source>(source: &T) -> Option<i32> {
    source.read()
}

fn dynamic_read(source: &dyn Source) -> Option<i32> {
    source.read()
}

fn collect(sources: &[&dyn Source]) -> (usize, usize, i64) {
    let mut present = 0;
    let mut missing = 0;
    let mut sum = 0_i64;
    for source in sources {
        match source.read() {
            Some(value) => {
                present += 1;
                sum += i64::from(value);
            }
            None => missing += 1,
        }
    }
    (present, missing, sum)
}

fn make_fixed(value: i32) -> impl Source {
    Fixed(value)
}

fn main() {
    let fixed = Fixed(46_700);
    let missing = Missing;
    let negative = Fixed(-500);
    let sources: [&dyn Source; 3] = [&fixed, &missing, &negative];
    let (present, absent, sum) = collect(&sources);
    println!("generic={}", generic_read(&fixed).expect("present fixture"));
    println!("dynamic={}", dynamic_read(&fixed).expect("present fixture"));
    println!("present={present}, missing={absent}, sum={sum}");
    println!("factory={}", make_fixed(0).read().expect("zero fixture"));
}

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

    #[test]
    fn generic_and_dynamic_calls_agree() {
        let source = Fixed(46_700);
        assert_eq!(generic_read(&source), Some(46_700));
        assert_eq!(dynamic_read(&source), generic_read(&source));
    }

    #[test]
    fn zero_and_missing_are_distinct() {
        assert_eq!(generic_read(&Fixed(0)), Some(0));
        assert_eq!(dynamic_read(&Missing), None);
        assert_eq!(make_fixed(0).read(), Some(0));
    }

    #[test]
    fn mixed_collection_accounts_for_every_source() {
        let first = Fixed(46_700);
        let second = Fixed(-500);
        assert_eq!(collect(&[&first, &Missing, &second]), (2, 1, 46_200));
        assert_eq!(first.0, 46_700);
    }

    #[test]
    fn empty_collection_has_no_readings() {
        assert_eq!(collect(&[]), (0, 0, 0));
    }
}

Run cargo check, cargo fmt --check, cargo test and cargo test --release, then compare cargo run --quiet with cargo run --release --quiet. Four tests should pass. Both programs print:

1
2
3
4
generic=46700
dynamic=46700
present=2, missing=1, sum=46200
factory=0

The trait declaration specifies behaviour, not a shared base-class data layout. Implementations attach it to each concrete type. An unrelated type with a method named read does not automatically implement Source. Traits can also have associated types/constants and default methods, but this trait stays small and dyn-compatible.

The trait-object contract permits the borrowed mixed collection to call implementations dynamically. &dyn Source is not a heap allocation and does not transfer ownership. Choosing Box would add ownership and allocation requirements; it is not implied by choosing dynamic dispatch. Borrowing still constrains how long sources remain usable.

The sum is intentionally exercised with three small fixtures. General collection sizes and arbitrary values require an explicit overflow policy; dispatch does not supply one.

Change the requirement: choose either concrete type at runtime

make_fixed hides one concrete return type in Rust. Return-position impl Trait is not a dynamic union of every implementation. A runtime branch returning Fixed on one path and Missing on another does not establish one hidden concrete type.

Save the normal source and temporarily replace it with this invalid program:

trait Source {
    fn read(&self) -> Option<i32>;
}
struct Fixed;
struct Missing;
impl Source for Fixed {
    fn read(&self) -> Option<i32> {
        Some(0)
    }
}
impl Source for Missing {
    fn read(&self) -> Option<i32> {
        None
    }
}
fn choose(present: bool) -> impl Source {
    if present { Fixed } else { Missing }
}
fn main() {
    let _ = choose(true);
}

cargo check rejects incompatible branch types with E0308 on the verified compiler. A C++ deduced auto return also does not combine unrelated return types. Compile this independent failure.cpp:

struct Fixed {};
struct Missing {};
auto choose(bool present) {
    if (present) {
        return Fixed{};
    }
    return Missing{};
}
int main() {
    auto value = choose(true);
    (void)value;
}

For runtime selection, choose a concrete enum/std::variant if the set is closed, or an owning erased interface if callers need independently owned values from an open implementation set. For a Rust Box repair, both branches must wrap their concrete value and return that common boxed interface type. A C++ unique_ptr repair likewise needs live owned derived implementations, not a pointer to a local object. The following design lessons compare these alternatives; an enum is not an inferior trait object.

Selection criteria and exercises

Requirement Candidate Remaining obligation
Concrete type selected by the caller Generic trait bound / constrained template Specify semantic requirements beyond the signatures
Borrowed heterogeneous sources &dyn Trait / virtual base reference or pointer Preserve ownership/lifetime; C++ pointers must also meet the non-null contract here
Hide one concrete result impl Trait / deduced auto return Each return path must produce its required concrete type
Closed alternatives selected at runtime Enum / variant Handle each state and preserve its data
Independently owned open alternatives Box / unique_ptr to a virtual interface Define allocation, failure and destruction contracts

Rust coherence limits which trait implementations can exist; C++ template specialisation and overload selection have their own rules. A supertrait is a requirement to implement another trait, not inherited member storage. Const generics are not a direct replacement for every C++ template or compile-time facility. Revisit the generics and trait implementation lessons before treating similarly named mechanisms as equivalent.

Exercises:

  1. Add a new source with a different field layout, then include it in the mixed collection. The report must count it without copying the original owners.
  2. Remove the Rust implementation but leave an inherent read method. A Source-bound call must fail: method spelling alone is insufficient.
  3. Repair the failing return with an owning interface, then test both present and missing selections. Explain why this changes the ownership contract from the earlier borrowed array.
  4. Replace the open source family with a closed enum/variant when requirements fix the alternatives. Identify which code changes when adding a variant versus adding an operation.
  5. Explain why a virtual call does not require heap allocation, and why no timing or code-size claim follows from these tests.

Verification and next step

On October 10, 2026, the authorised Raspberry Pi 4B verified the extracted sources 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. The additional-source variant passed five tests; the owned Box selection passed assertions for both alternatives. C++20 fixtures and the unique_ptr-owned repair passed at -O0 and -O2 with assertions enabled and matching output. Rust rejected incompatible opaque return types and an inherent-method-only type with E0308/E0277; C++ rejected inconsistent auto return deduction. These are selected behavioural/compiler tests, not performance or ABI-layout measurements.

Next planned: error results, C++ exceptions and panic boundaries. Follow the published links in the course overview.

Previous: borrowing and const · Course overview

Donate