Zenn
🥕

C++でRustっぽいenumを実現する

2025/03/29に公開

Rustのenum

RustのenumはC++のenumと違い,値を格納できます.

enum class MyEnum
{
    A,
    B,
    C
};
struct Inner {
    a: usize,
    b: Vec<i32>,
}

enum Hoge {
    A,
    B(i32, String),
    C(Inner),
    D { c: i32, d: String },
}

C++の場合,std::variantならrustのenumと同様値を格納できますが,内部に格納された値の型を間違えると実行時エラーになります.

int main()
{
    std::variant<int, std::string> hoge2 = 1;
    // 実行時エラー
    std::cout << std::get<std::string>(hoge2) << std::endl;
}

また,同じ型を持つ項目があると少し面倒になります.(コードは省略)

それに対してRustの場合は,パターンマッチの漏れがないか,内部型をチェックせずにアクセスしていないか,といったことをコンパイル時点でチェックする機能もあります.

fn main() {
    let hoge1 = Hoge::A;
    let hoge2 = Hoge::B(1);

    match hoge1 {
        Hoge::A => println!("hoge1 is A"),
        Hoge::B(x) => println!("hoge1 is B: {}", x),
        Hoge::C(x) => println!("hoge1 is C: {}", x),
        Hoge::D(inner, s) => {
            println!("hoge1 is D: {} {}", inner.a, s);
            for i in inner.b {
                println!("inner.b: {}", i);
            }
        }
        Hoge::E { c, d } => {
            println!("hoge1 is E: {} {}", c, d);
        } // Hoge::E の分岐がないのでコンパイルエラーになる
    }

    // 内部の値にアクセスする前に分岐で取り出す必要がある
    if let Hoge::B(x) = hoge2 {
        // Bの場合の処理
        println!("hoge2 is B: {}", x);
    } else {
        // B以外だった場合の処理をelseで書くこともできる
        println!("hoge2 is not B");
    }

    // 分岐を使わずに直接アクセスしようとするとコンパイルエラーになる
    let Hoge::B(x) = hoge2; // コンパイルエラー
}

C++でも次のように記述すると,同様の機能を実装できます.

値を格納できるようにする

これは単純に,variantの内部に格納する専用の型を用意すれば良いです.
構造体の名前で区別することで,同じ値の項目もわかりやすく区別できます.

#include <variant>
#include <string>
#include <vector>
#include <iostream>

struct A
{
};
struct B
{
    int inner;
};
struct C
{
    int inner;
};
struct D
{
    std::vector<int> a;
    std::string b;
};
using Hoge = std::variant<A, B, C, D>;

int main()
{
    Hoge hoge1 = A{};
    Hoge hoge2 = B{1};
    Hoge hoge3 = C{2};

    std::cout << "hoge2: " << std::get<B>(hoge2).inner << std::endl;
    std::cout << "hoge3: " << std::get<C>(hoge3).inner << std::endl;

    return 0;
}

分岐の実装漏れを防ぐ

こちらが本題,かつ少々複雑です.
最初に結論を示すと,次のようになります.

#include <variant>
#include <string>
#include <vector>
#include <iostream>

template <typename...>
constexpr bool false_v = false;

struct Fugo
{
    int a;
    std::vector<char> b;
};
using Hoge = std::variant<int, std::string, size_t, Fugo>;

int main()
{
    Hoge hoge;

    std::visit(
        [](auto &&arg)
        {
            using T = std::decay_t<decltype(arg)>;
            if constexpr (std::is_same_v<T, int>)
            {
                std::cout << "int: " << arg << std::endl;
            }
            else if constexpr (std::is_same_v<T, std::string>)
            {
                std::cout << "string: " << arg << std::endl;
            }
            else if constexpr (std::is_same_v<T, size_t>)
            {
                std::cout << "size_t: " << arg << std::endl;
            }
            // 以下をコメントアウトするとコンパイルエラーになる
            // -----ここから-----
            else if constexpr (std::is_same_v<T, Fugo>)
            {
                std::cout << "Fugo: " << arg.a << std::endl;
                for (const auto &c : arg.b)
                {
                    std::cout << c;
                }
                std::cout << std::endl;
            }
            // -----ここまで-----
            else {
                static_assert(false_v<T>, "non-exhaustive visitor!");
            }

        },
        hoge);

    return 0;
}

std::visitの中身は「ジェネリックラムダ」で,引数の型で静的ディスパッチされます.
if constexpr文をstd::decay_t<decltype<arg>>とstd::is_same_vで判定することで,型による分岐をコンパイル時に判定できます.

また,最後のelse節では,どのif文でも分岐しなかった場合をstatic_assertでコンパイル時エラーにすることができます.

このときの注意点として,通常static_assertの判定がジェネリックラムダの型分岐より先なので,false_vというジェネリック型で型引数Tを受け取ることにより,型分岐が判定されるまでfalse_vの判定を遅延させる必要があります.

最後に

自分でもあまり理解できていませんが,とりあえず実装できたので良しとします.
ボイラープレートが多すぎて面倒なのは致し方ない……

Discussion

ログインするとコメントできます