Enums and variants: C++ and Rust

A std::variant and a Rust enum with data are both tagged unions: one value, a fixed set of alternatives, and a tag that says which one is live. The difference is when the tag is checked. C++ checks it in the accessor at run time and throws when the alternative is wrong; Rust checks it in the match at compile time and refuses the program that forgets an alternative.

The task in C++

The variant holds an integer or a string. The program prints the index of the alternative, asks whether it holds an integer, assigns a string and reads it back with std::get. The index changes when the active alternative changes, which is the tag made visible.

std::get<T> returns a reference and throws std::bad_variant_access when the active alternative is a different type. The access is checked, but only when it runs.

#include <iostream> #include <string> #include <variant> int main() { std::variant<int, std::string> value = 42; std::cout << "index " << value.index() << '\n'; std::cout << "holds int " << std::holds_alternative<int>(value) << '\n'; value = std::string("rust"); std::cout << "index " << value.index() << '\n'; std::cout << "as string " << std::get<std::string>(value) << '\n'; }

g++ -std=c++23 -Wall -Wextra variant.cpp -o variant
./variant

index 0
holds int 1
index 1
as string rust

The two indexes are zero and one, and the string is read after the assignment. Nothing here allocates except the string, and the variant itself is as large as its largest alternative plus the tag.

The same task in Rust

The enum declares two variants, one carrying an integer and one a string, and each match arm binds the payload of its variant. There is no index and no accessor: the match is the only way to read the value, and it must cover both variants.

The program matches the value, assigns the other variant and matches again. The two lines are the payloads, printed by the arms that own them.

enum Value { Number(i32), Text(String), } fn main() { let mut value = Value::Number(42); match &value { Value::Number(n) => println!("number {n}"), Value::Text(t) => println!("text {t}"), } value = Value::Text(String::from("rust")); match &value { Value::Number(n) => println!("number {n}"), Value::Text(t) => println!("text {t}"), } }

rustc --edition 2024 variant.rs
./variant

number 42
text rust

The concrete type is chosen at compile time and the match checks it. The lesson on enums with data builds the same type with a method and a recursive variant.

What differs and why

Who owns the value: the variant and the enum both own their payload, and both are moved when assigned. The C++ variant may hold a type with a throwing move constructor, which puts it in a valueless state; the Rust enum cannot, because a move is a byte copy and the type system knows the variant.

What the compiler rejects: C++ accepts any accessor and checks the tag at run time. Rust rejects a match that leaves a variant out (E0004) and rejects an access that does not check the tag (E0005), so the failure cannot happen at run time.

What happens at run time: the C++ accessor either returns the reference or throws, and the exception may escape main. The Rust match always runs one arm, and the only cost is the tag comparison the compiler generates.

The habit that breaks

The habit is to read a variant as if the tag did not matter. In C++ that compiles and throws when the guess is wrong; the program below aborts with an uncaught exception and the message names the accessor.

#include <iostream> #include <string> #include <variant> int main() { std::variant<int, std::string> value = 42; std::cout << "as string " << std::get<std::string>(value) << '\n'; }

g++ -std=c++23 -Wall -Wextra variant_wrong.cpp -o variant_wrong
./variant_wrong

terminate called after throwing an instance of 'std::bad_variant_access'
what(): std::get: wrong index for variant
status 134

The same guess in Rust is a refutable pattern in a let, and the compiler refuses it before the program exists. The fix is a match or an if let, both of which handle the other variant.

enum Value { Number(i32), Text(String), } fn main() { let value = Value::Number(42); let Value::Text(text) = value; println!("as string {text}"); }

rustc --edition 2024 variant_wrong.rs

error[E0005]: refutable pattern in local binding
--> variant_wrong.rs:8:9
|
8 | let Value::Text(text) = value;
| ^^^^^^^^^^^^^^^^^ pattern `Value::Number(_)` not covered
|
= note: `let` bindings require an "irrefutable pattern", like a `struct` or an `enum` with only one variant
= note: for more information, visit https://doc.rust-lang.org/book/ch19-02-refutability.html
note: `Value` defined here
--> variant_wrong.rs:1:6
|
1 | enum Value {
| ^^^^^
2 | Number(i32),
| ------ not covered
= note: the matched value is of type `Value`
help: you might want to use `let...else` to handle the variant that isn't matched
|
8 | let Value::Text(text) = value else { todo!() };
| ++++++++++++++++

error: aborting due to 1 previous error

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

The C++ lesson for this ground is std::variant, and the Rust lesson is Enums with data. The next page in this set is Null and Option.

The C++ programs were built with g++ 15.2.0 and the Rust programs with rustc 1.96.1, and both were run on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev

Banner image