3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C++のパイプライン設計:Visitor・variant・コンセプトで型安全に組み立てる

3
Posted at

C++のパイプライン設計:Visitor・variant・コンセプトで型安全に組み立てる

パイプライン処理を実装するとき、「各ステップの入出力の型をどう統一するか」という問題に直面します。この記事では、その解決策として Visitorパターンstd::variantコンセプト の3つのアプローチを解説します。


パイプラインの型統一という問題

パイプラインの各ステップの入出力が同じ型であれば、ステップを動的に追加・削除できて安全です。しかし「データの種類が複数ある」場合はどうすればよいでしょうか?

例えば「数値データ」と「テキストデータ」を同じパイプラインで扱いたいとします:

struct NumberData { std::vector<int> values; };
struct TextData   { std::string text; };

これらを「Data として統一的に扱いたい」というのが今回のテーマです。


アプローチ① Visitorパターン

発想

  • データは「自分を Visitor に渡す(accept)」だけ
  • 処理は Visitor として分離する

これにより「データと処理を分離」でき、処理を追加するたびにデータクラスを修正しなくて済みます。

実装

#include <iostream>
#include <vector>
#include <memory>

// 前方宣言
struct NumberData;
struct TextData;

// Visitor インターフェース
struct StepVisitor {
    virtual void visit(NumberData& d) = 0;
    virtual void visit(TextData& d) = 0;
    virtual ~StepVisitor() = default;
};

// データの基底クラス
struct Data {
    virtual void accept(StepVisitor& v) = 0;
    virtual ~Data() = default;
};

// 具体的なデータ
struct NumberData : public Data {
    std::vector<int> values;
    NumberData(std::vector<int> v) : values(v) {}
    void accept(StepVisitor& v) override { v.visit(*this); }
};

struct TextData : public Data {
    std::string text;
    TextData(std::string t) : text(t) {}
    void accept(StepVisitor& v) override { v.visit(*this); }
};

// 具体的な処理
struct DoubleStep : public StepVisitor {
    void visit(NumberData& d) override {
        for (int& x : d.values) x *= 2;
    }
    void visit(TextData&) override { /* 何もしない */ }
};

struct UpperStep : public StepVisitor {
    void visit(NumberData&) override { /* 何もしない */ }
    void visit(TextData& d) override {
        for (char& c : d.text) c = toupper(c);
    }
};

int main() {
    // Data* として統一的に扱える
    std::vector<std::unique_ptr<Data>> items;
    items.push_back(std::make_unique<NumberData>(
        std::vector<int>{1, 2, 3, 4, 5}));
    items.push_back(std::make_unique<TextData>("hello"));

    // ステップも動的に追加できる
    std::vector<std::unique_ptr<StepVisitor>> steps;
    steps.push_back(std::make_unique<DoubleStep>());
    steps.push_back(std::make_unique<UpperStep>());

    for (auto& step : steps)
        for (auto& item : items)
            item->accept(*step);

    auto& nums = static_cast<NumberData&>(*items[0]);
    for (int v : nums.values) std::cout << v << " ";  // → 2 4 6 8 10

    auto& text = static_cast<TextData&>(*items[1]);
    std::cout << text.text;  // → HELLO
}

なぜ visit を型ごとに分けるのか?

visit(Data& d) で1つにまとめればいいのでは?」と思うかもしれません。できますが、中で dynamic_cast が必要になります:

// ❌ dynamic_cast を使う方法:型チェックが実行時になる
struct DoubleStep : public StepVisitor {
    void visit(Data& d) override {
        if (auto* n = dynamic_cast<NumberData*>(&d)) {
            for (int& x : n->values) x *= 2;
        }
        // 処理し忘れてもコンパイルエラーにならない!
    }
};

型ごとに visit を分けることで、処理し忘れをコンパイル時に検出できます。


アプローチ② std::variant + std::visit

発想

std::variant は「いくつかの型のうち、どれか1つを持つ」型安全な共用体です。Visitorパターンよりシンプルに書けます。

std::variant の基本

#include <variant>

// int か float か string のどれかを持つ
std::variant<int, float, std::string> v;

v = 42;      // int を入れる
v = "hello"; // string に切り替える

// 安全に取り出せる
if (std::holds_alternative<std::string>(v)) {
    std::cout << std::get<std::string>(v);  // → "hello"
}

overloaded パターンでスッキリ書く

型ごとの処理は overloaded というヘルパーを使うとラムダでまとめられます:

// overloaded:複数のラムダを1つの Visitor にまとめるヘルパー
template<typename... Ts>
struct overloaded : Ts... {
    using Ts::operator()...;
};

パイプラインに組み込む

#include <variant>
#include <vector>
#include <functional>
#include <iostream>

struct NumberData { std::vector<int> values; };
struct TextData   { std::string text; };

using Data = std::variant<NumberData, TextData>;
using Step = std::function<Data(Data)>;  // Data → Data で統一!

template<typename... Ts>
struct overloaded : Ts... { using Ts::operator()...; };

int main() {
    Step double_step = [](Data d) {
        std::visit(overloaded{
            [](NumberData& data) {
                for (int& x : data.values) x *= 2;
            },
            [](TextData&) {}
        }, d);
        return d;
    };

    Step upper_step = [](Data d) {
        std::visit(overloaded{
            [](NumberData&) {},
            [](TextData& data) {
                for (char& c : data.text) c = toupper(c);
            }
        }, d);
        return d;
    };

    // パイプラインに動的に追加できる
    std::vector<Step> pipeline = {double_step, upper_step};

    Data d = NumberData{{1, 2, 3, 4, 5}};
    for (auto& step : pipeline)
        d = step(d);

    std::visit(overloaded{
        [](NumberData& data) {
            for (int v : data.values) std::cout << v << " ";  // → 2 4 6 8 10
        },
        [](TextData& data) { std::cout << data.text; }
    }, d);
}

アプローチ③ C++20 コンセプト

発想

コンセプトは「型への制約をコンパイル時に表現する」仕組みです。「このステップは必ず process メソッドを持っていなければならない」という制約を明示できます。

基本の書き方

#include <concepts>

// 「T は << で出力できる」というコンセプト
template<typename T>
concept Printable = requires(T t) {
    { std::cout << t };
};

// 制約を適用する
template<Printable T>
void print(T value) {
    std::cout << value;
}

制約を満たさない型を渡すと、分かりやすいコンパイルエラーになります。

パイプラインへの適用

#include <concepts>
#include <vector>
#include <functional>
#include <iostream>

// 「process(T) → T を持つ」コンセプト
template<typename Step, typename T>
concept IsPipelineStep = requires(Step s, T t) {
    { s.process(t) } -> std::same_as<T>;
};

// コンセプトで制約したパイプライン
template<typename T>
class Pipeline {
    std::vector<std::function<T(T)>> steps;

public:
    // IsPipelineStep を満たす型だけ追加できる
    template<IsPipelineStep<T> Step>
    Pipeline& add(Step s) {
        steps.push_back([s](T input) mutable {
            return s.process(input);
        });
        return *this;
    }

    T run(T input) {
        for (auto& step : steps)
            input = step(input);
        return input;
    }
};

// ステップの実装
struct FilterEven {
    std::vector<int> process(std::vector<int> data) {
        std::vector<int> result;
        for (int x : data)
            if (x % 2 == 0) result.push_back(x);
        return result;
    }
};

struct DoubleValues {
    std::vector<int> process(std::vector<int> data) {
        for (int& x : data) x *= 2;
        return data;
    }
};

// ❌ コンセプトを満たさないステップ
struct BadStep {
    void process() {}  // 引数も戻り値も違う
};

int main() {
    Pipeline<std::vector<int>> p;
    p.add(FilterEven{})    // ✅ OK
     .add(DoubleValues{}); // ✅ OK
    // p.add(BadStep{});   // ❌ コンパイルエラー!

    auto result = p.run({1, 2, 3, 4, 5});
    for (int v : result) std::cout << v << " ";  // → 4 8
}

標準ライブラリのコンセプト

よく使うコンセプトは標準で用意されています:

#include <concepts>

std::integral<T>            // 整数型
std::floating_point<T>      // 浮動小数点型
std::copyable<T>            // コピーできる型
std::movable<T>             // ムーブできる型
std::invocable<F, Args...>  // 呼び出せる型
std::ranges::range<T>       // レンジ(イテレータを持つ)型

3つのアプローチの比較

Visitorパターン std::variant コンセプト
データ型の追加 全Visitorの修正が必要 variant に追加するだけ 制約を満たせばOK
処理の追加 新しいVisitorを作るだけ visit のラムダを追加 新しいクラスを作るだけ
処理し忘れ コンパイルエラー ✅ コンパイルエラー ✅ コンパイルエラー ✅
dynamic_cast 不要 不要 不要
Javaとの親和性 高い 低い 中程度
パフォーマンス 仮想関数のオーバーヘッド コンパイル時解決で速い コンパイル時解決で速い
向いているケース データ型固定・処理が増える データ型も処理も変わる 型制約を明示したい

まとめ

パイプラインの型安全性を高める方法として3つのアプローチを紹介しました。

  • Visitorパターン:Javaのインターフェースに慣れている方に親しみやすい。データと処理を明確に分離できる
  • std::variant + std::visit:よりC++らしい書き方。ラムダで処理をまとめやすい
  • コンセプト:型への制約を明示したいときに強力。エラーメッセージも分かりやすい

実際のコードでは、これらを組み合わせて使うことも多いです。まずはVisitorパターンで設計を固め、パフォーマンスが必要になったら std::variant に切り替える、という使い分けも有効です。


参考

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?