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.
Both complete examples produce:
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:
Replace src/main.rs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 | |
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:
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:
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¶
- Add an operation counting present values without changing the data definitions. Confirm that zero and negative values both count, but Missing does not.
- 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.
- Add another Describe implementation with different fields. Test it through the existing display function without changing that function or Item.
- 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.
- 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.