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 に切り替える、という使い分けも有効です。