Skip to content

Visitor in Rust: Enums and Open Behaviour

Rust enums often replace a Visitor hierarchy when the data variants are known and new operations are common. Traits address a different requirement: accepting new implementations through an agreed behaviour. This lesson compares both choices with C++ Visitor rather than treating either as a universal replacement.

Requirements: preserve meaning across operations

Process an ordered collection containing signed readings and missing readings with a reason. A reading of zero is present; a negative reading is also valid. Produce a signed 64-bit sum, a missing count, ordered labels and a separate count of zero readings. Missing readings contribute nothing to the sum but must remain visible in the report. Empty input yields zero counts and an empty label string.

The fixture uses millidegrees as integers and makes no sensor calls. A collection large enough to overflow the accumulator is outside this example's contract; a production API needs checked accumulation or an explicit bound. Neither language's integer overflow is a substitute for a domain policy.

First assume two known data variants and allow new operations. Then change the requirement: third-party types need to supply display text without becoming one of those variants. That does not automatically grant them a meaningful numeric reading.

C++ Visitor: dispatch to a known overload family

Save as visitor.cpp. Objects and visitors remain on the stack; the collection borrows them only while they are alive.

#include <array>
#include <cassert>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <limits>
#include <string>
#include <utility>

struct Value;
struct Missing;
struct Visitor {
    virtual ~Visitor() = default;
    virtual void visit(const Value&) = 0;
    virtual void visit(const Missing&) = 0;
};
struct Item {
    virtual ~Item() = default;
    virtual void accept(Visitor&) const = 0;
};
struct Value final : Item {
    explicit Value(int reading) : reading(reading) {}
    int reading;
    void accept(Visitor& visitor) const override { visitor.visit(*this); }
};
struct Missing final : Item {
    explicit Missing(std::string reason) : reason(std::move(reason)) {}
    std::string reason;
    void accept(Visitor& visitor) const override { visitor.visit(*this); }
};
struct Report final : Visitor {
    std::int64_t sum = 0;
    std::size_t missing = 0;
    std::string labels;
    void append(const std::string& text) {
        if (!labels.empty()) labels += '|';
        labels += text;
    }
    void visit(const Value& value) override {
        sum += value.reading;
        append("value=" + std::to_string(value.reading));
    }
    void visit(const Missing& value) override {
        ++missing;
        append("missing=" + value.reason);
    }
};
struct ZeroCount final : Visitor {
    std::size_t count = 0;
    void visit(const Value& value) override { count += value.reading == 0; }
    void visit(const Missing&) override {}
};
// A separate display interface accepts new types, not arbitrary Visitor variants.
struct Describe {
    virtual ~Describe() = default;
    virtual std::string describe() const = 0;
};
struct Custom final : Describe {
    explicit Custom(std::string label) : label(std::move(label)) {}
    std::string label;
    std::string describe() const override { return "custom=" + label; }
};

int main() {
    Value zero(0), negative(-500), positive(46700);
    Missing missing("offline");
    std::array<const Item*, 4> items{&zero, &negative, &missing, &positive};
    Report report;
    ZeroCount zeros;
    for (const Item* item : items) {
        item->accept(report);
        item->accept(zeros);
    }
    assert(report.sum == 46200 && report.missing == 1 && zeros.count == 1);
    assert(report.labels == "value=0|value=-500|missing=offline|value=46700");
    std::cout << "sum=" << report.sum << ", missing=" << report.missing << '\n';
    std::cout << "labels=" << report.labels << '\n';
    std::cout << "zero=" << zeros.count << '\n';
    Custom custom("Pi 4B");
    const Describe& display = custom;
    assert(display.describe() == "custom=Pi 4B");
    std::cout << "open=" << display.describe() << '\n';
    Report empty;
    assert(empty.sum == 0 && empty.missing == 0 && empty.labels.empty());
    Value smallest(std::numeric_limits<int>::min());
    Value largest(std::numeric_limits<int>::max());
    Report extremes;
    smallest.accept(extremes);
    largest.accept(extremes);
    assert(extremes.sum == -1);
    Missing blank("");
    Report blank_reason;
    blank.accept(blank_reason);
    assert(blank_reason.missing == 1 && blank_reason.labels == "missing=");
}
1
2
3
4
g++ -std=c++20 -Wall -Wextra -Wpedantic -O0 visitor.cpp -o visitor
./visitor
g++ -std=c++20 -Wall -Wextra -Wpedantic -O2 visitor.cpp -o visitor
./visitor

Both complete examples produce:

1
2
3
4
sum=46200, missing=1
labels=value=0|value=-500|missing=offline|value=46700
zero=1
open=custom=Pi 4B

The virtual accept call chooses the element's implementation. Inside it, the concrete type of *this selects the visitor overload, whose virtual call chooses the operation's implementation. This two-stage dispatch is the conventional Visitor mechanism, not runtime overload selection by a single call. See the C++ virtual function rules.

Adding ZeroCount did not change Value or Missing. Adding a new kind of Item is different: to visit its distinct payload, extend Visitor's overload family and update operations. Simply subclassing Item does not add a new overload to an already compiled Visitor interface. Mapping a new type onto an existing overload is possible only if that mapping preserves the required semantics.

Rust Visitor alternative: a closed enum and explicit operations

Create a dependency-free Rust 2024 binary project:

cargo new --edition 2024 visitor_demo
cd visitor_demo

Replace src/main.rs:

#[derive(Debug)]
enum Item {
    Value(i32),
    Missing(String),
}

#[derive(Debug, PartialEq, Eq)]
struct Report {
    sum: i64,
    missing: usize,
    labels: String,
}

fn report(items: &[Item]) -> Report {
    let mut result = Report {
        sum: 0,
        missing: 0,
        labels: String::new(),
    };
    let mut labels = Vec::new();
    for item in items {
        match item {
            Item::Value(value) => {
                result.sum += i64::from(*value);
                labels.push(format!("value={value}"));
            }
            Item::Missing(reason) => {
                result.missing += 1;
                labels.push(format!("missing={reason}"));
            }
        }
    }
    result.labels = labels.join("|");
    result
}

fn zero_count(items: &[Item]) -> usize {
    items
        .iter()
        .filter(|item| match item {
            Item::Value(value) => *value == 0,
            Item::Missing(_) => false,
        })
        .count()
}

trait Describe {
    fn describe(&self) -> String;
}

struct Custom {
    label: String,
}
impl Describe for Custom {
    fn describe(&self) -> String {
        format!("custom={}", self.label)
    }
}

fn display(value: &dyn Describe) -> String {
    value.describe()
}

fn main() {
    let items = [
        Item::Value(0),
        Item::Value(-500),
        Item::Missing("offline".into()),
        Item::Value(46700),
    ];
    let result = report(&items);
    println!("sum={}, missing={}", result.sum, result.missing);
    println!("labels={}", result.labels);
    println!("zero={}", zero_count(&items));
    let custom = Custom {
        label: "Pi 4B".into(),
    };
    println!("open={}", display(&custom));
}

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

    #[test]
    fn zero_and_missing_have_different_meanings() {
        let items = [Item::Value(0), Item::Missing("offline".into())];
        assert_eq!(report(&items).sum, 0);
        assert_eq!(report(&items).missing, 1);
        assert_eq!(zero_count(&items), 1);
    }

    #[test]
    fn signed_values_are_accumulated_without_i32_overflow() {
        let items = [
            Item::Value(i32::MIN),
            Item::Value(i32::MAX),
            Item::Value(-500),
        ];
        assert_eq!(report(&items).sum, -501);
        assert_eq!(zero_count(&items), 0);
    }

    #[test]
    fn labels_preserve_input_order_and_blank_reason() {
        let items = [Item::Missing(String::new()), Item::Value(0)];
        assert_eq!(report(&items).labels, "missing=|value=0");
        assert_eq!(report(&items).missing, 1);
    }

    #[test]
    fn empty_input_has_no_phantom_reading() {
        assert_eq!(
            report(&[]),
            Report {
                sum: 0,
                missing: 0,
                labels: String::new()
            }
        );
        assert_eq!(zero_count(&[]), 0);
    }

    #[test]
    fn display_borrows_an_independently_owned_custom_type() {
        let custom = Custom {
            label: "Pi 4B".into(),
        };
        assert_eq!(display(&custom), "custom=Pi 4B");
        assert_eq!(custom.label, "Pi 4B");
    }
}
1
2
3
4
5
6
cargo fmt --check
cargo check --offline
cargo test --offline
cargo test --offline --release
cargo run --offline --quiet
cargo run --offline --release --quiet

Each function borrows the same collection. No operation consumes its missing-reason strings. The enum names the known variants, while match branches on them. A new independent operation can be another function; it need not be placed inside the enum's implementation.

Exhaustive matching catches unhandled variants, not incorrect handling. A wildcard deliberately provides fallback behaviour and may hide the need to reconsider a new variant. Tests still have to distinguish zero from missing and check labels, order and totals. Rust can also implement a Visitor trait with per-variant methods when that organisation is useful; the enum alternative does not forbid the pattern.

The C++ Report visitor accumulates across calls; create a fresh visitor for an independent report. Rust report creates fresh result state on each call. Reusing a visitor deliberately accumulates another traversal, whereas calling report again independently does not. The matched fixture compares one traversal, not every possible stateful API policy.

Changed requirement: open implementations versus open operations

The separate trait accepts a Custom that owns different fields, through a borrowed dyn Describe interface. Adding another local implementation need not modify display. An external crate can implement this public behaviour for its own type if the exposed API and Rust's coherence rules permit it. This example is not a plugin loader or a stable binary ABI.

However, display knows only the promised describe method. It cannot extract arbitrary numeric payloads from an unknown implementation to invent a new aggregation operation. Extend the behaviour contract, require another suitable trait, or explicitly convert into validated domain data. Do not downcast everything merely to disguise a closed set as an open interface.

Likewise, adding Pending to Item requires decisions in report and zero_count. Should it count as missing, remain a distinct status, or reject reporting? Define that meaning first, then update each operation. Compilation finding a missing arm is useful evidence that work remains, not a decision about the correct business rule.

Intentional failures: incomplete handling is not an extension

This independent Rust program adds a variant without updating its operation:

enum Item {
    Value(i32),
    Missing,
    Pending,
}
fn score(item: &Item) -> i64 {
    match item {
        Item::Value(value) => i64::from(*value),
        Item::Missing => 0,
    }
}
fn main() {
    let _ = score(&Item::Pending);
}

Repair by specifying Pending's semantics and adding its arm. A wildcard that silently scores everything unknown as zero may compile but has not answered that design question.

This independent C++ program omits a required visitor overload:

struct Value {};
struct Missing {};
struct Visitor {
    virtual ~Visitor() = default;
    virtual void visit(const Value&) = 0;
    virtual void visit(const Missing&) = 0;
};
struct Incomplete : Visitor {
    void visit(const Value&) override {}
};
int main() {
    Incomplete operation;
}

It cannot instantiate the abstract operation. Add the missing override with the intended meaning. The same obligation appears when a new required overload is added to a larger Visitor interface.

Selection criteria

Expected change Candidate Cost that remains
More operations over known variants Enum plus exhaustive functions; C++ Visitor Every new operation needs meaningful handling of every variant
More implementations of one behaviour Trait or virtual behaviour interface New operations cannot assume unpromised payloads
Both variants and operations change frequently Reconsider the domain boundary or explicit conversion Neither basic approach removes both extension costs
New enum variants across a public library boundary Consider non-exhaustive API design Callers need a deliberate fallback and versioning policy

These are extension and ownership choices. The fixture measures no dispatch speed, allocation cost or advantage of one language.

Exercises and verification

  1. Add an operation counting present values without changing the data definitions. Confirm that zero and negative values both count, but Missing does not.
  2. Add Pending to the full Rust enum without changing the operations. Observe the compiler rejection, then explicitly label it pending, count it as missing, and exclude it from zero_count.
  3. Add another Describe implementation with different fields. Test it through the existing display function without changing that function or Item.
  4. Change the missing branch to contribute one to the zero count. Keep the zero-versus-missing test and confirm that this compiles but fails its contract.
  5. Explain what must change in the C++ overload family when Pending carries a distinct payload. Compare that with adding a new operation over the unchanged family.

The exact programs were verified on Raspberry Pi 4B with rustc/cargo 1.99.0, GCC 14.2.0 and kernel 6.18.50+rpt-rpi-v8. Rust passed five tests in debug and release, exact-source formatting and both output checks. The added operation/implementation and explicit Pending repair each passed six tests in both profiles. Adding Pending without updating matches was rejected with E0004; deliberately treating missing as zero compiled but failed the contract test. C++20 passed normal and additional-operation output/assertions at -O0 and -O2, with assertions enabled. Both the incomplete visitor and the required new Pending overload rejected unchanged abstract operations. These assertions cover fixtures, not every possible collection or extension.

Next: Observer, events and shutdown, callback ownership, unsubscribe and delivery contracts.

Previous: Factory and Builder · Course overview

Donate