C++で学ぶ関数型プログラミング:純粋関数からモナドまで
C++はオブジェクト指向言語というイメージが強いですが、C++11以降は関数型プログラミングの要素が積極的に取り込まれています。実は前回の記事で扱ったパイプライン処理も、関数型の発想そのものでした。この記事では、関数型プログラミングの核となる概念をC++のコードで解説します。
関数型プログラミングとは?
一言でいうと「関数を中心に組み立てるプログラミングスタイル」です。
オブジェクト指向が「データと処理をクラスにまとめる」という発想なのに対し、関数型は「データを関数に流し込んで変換し続ける」という発想です。まさにパイプラインそのものです。
核となる概念は4つです:
① 純粋関数 ← 関数型の土台
↓
② 高階関数 ← 関数を値として扱う
↓
③ イミュータブル ← データを変えない
↓
④ モナド ← 文脈付きでステップをつなぐ
① 純粋関数(Pure Function)
同じ入力には必ず同じ出力を返し、副作用を持たない関数のことです。
// ✅ 純粋関数:同じ入力 → 必ず同じ出力
int add(int a, int b) {
return a + b; // 外の状態に一切触らない
}
// ❌ 純粋でない関数:外の状態に依存する
int counter = 0;
int increment() {
return ++counter; // 呼ぶたびに結果が変わる
}
// ❌ 純粋でない関数:副作用がある
void print_and_add(int a, int b) {
std::cout << a + b; // 画面に出力する=副作用
}
「副作用」とは、関数の外の世界に影響を与えること(画面出力・ファイル書き込み・グローバル変数の変更など)です。
純粋関数はテストしやすく、並列実行しても安全で、パイプラインのステップとして安心して使えます。
② 高階関数(Higher-Order Function)
関数を引数に取ったり、関数を返したりする関数のことです。
#include <vector>
#include <functional>
// 関数を引数に取る
std::vector<int> my_filter(std::vector<int> data,
std::function<bool(int)> predicate) {
std::vector<int> result;
for (int x : data)
if (predicate(x)) result.push_back(x);
return result;
}
// 関数を返す
auto make_multiplier(int factor) {
return [factor](int x){ return x * factor; };
}
int main() {
std::vector<int> data = {1, 2, 3, 4, 5};
// 関数を渡す
auto evens = my_filter(data, [](int x){ return x % 2 == 0; });
// 関数を受け取る
auto triple = make_multiplier(3);
std::cout << triple(5); // → 15
}
代表的な高階関数が map・filter・reduce の3つで、C++では以下に対応します:
| 関数型の概念 | C++での対応 |
|---|---|
| map |
std::transform / views::transform
|
| filter |
std::copy_if / views::filter
|
| reduce |
std::accumulate(C++11〜)/ std::reduce(C++17〜) |
std::accumulate と std::reduce はどちらも「複数の値を1つに畳み込む」関数型の reduce に対応します。違いは std::reduce が並列実行に対応している点です:
#include <numeric>
std::vector<int> data = {1, 2, 3, 4, 5};
// std::accumulate:順番通りに処理(C++11〜)
int sum = std::accumulate(data.begin(), data.end(), 0);
// → 15
// std::reduce:並列実行可能版(C++17〜)
// 結果は同じだが、大量データで高速になる場合がある
int sum2 = std::reduce(data.begin(), data.end(), 0);
// → 15
③ イミュータブル(Immutable)
一度作ったデータを変更しないという原則です。
// ❌ ミュータブル:元のデータを書き換える
void double_values(std::vector<int>& data) {
for (int& x : data) x *= 2;
}
// ✅ イミュータブル:コピーを変更して返す
std::vector<int> double_values(std::vector<int> data) {
for (int& x : data) x *= 2;
return data;
}
データを変更せず新しいデータを作って返すことで、元のデータが壊れる心配がなくなります。C++20 Ranges も元のコレクションを変更しません。これもイミュータブルの発想です。
C++では const を積極的に使うことがイミュータブルに近い考え方です:
const std::vector<int> data = {1, 2, 3, 4, 5}; // 変更禁止
④ モナド(Monad)
「値をコンテナに包んで、文脈を保ったままパイプラインをつなぐ仕組み」です。
std::optional:値があるかないか
#include <optional>
#include <string>
// 失敗するかもしれない関数
std::optional<int> parse_int(const std::string& s) {
try { return std::stoi(s); }
catch (...) { return std::nullopt; }
}
std::optional<int> double_if_even(int n) {
if (n % 2 == 0) return n * 2;
return std::nullopt;
}
Cling(JupyterLab + ROOT)で試す場合
std::stoi が動かない場合があります。その場合は std::istringstream で代替できます:
#include <string>
#include <sstream>
#include <optional>
std::optional<int> parse_int(const std::string& s) {
std::istringstream iss(s);
int result;
if (iss >> result) return result;
return std::nullopt;
}
and_then なしだとネスト地獄になります:
// ❌ ネストが深くなる
auto a = parse_int("4");
if (a) {
auto b = double_if_even(*a);
if (b) {
std::cout << *b; // → 8
}
}
C++23の and_then を使うとパイプラインのように書けます:
// ✅ パイプラインのようにつながる
auto result = parse_int("4")
.and_then(double_if_even);
if (result) std::cout << *result; // → 8
どこかで nullopt になったら、そこで止まって後の処理はスキップされます:
parse_int("abc") // → nullopt(変換失敗)
.and_then(double_if_even); // → スキップされる → nullopt
transform と and_then の使い分け
// transform:失敗しない変換(T を返す関数を渡す)
auto result = parse_int("4")
.transform([](int n){ return n * 2; }); // → optional(8)
// and_then:失敗するかもしれない変換(optional<T> を返す関数を渡す)
auto result2 = parse_int("4")
.and_then(double_if_even); // → optional(8)
| 渡す関数の戻り値 | 用途 | |
|---|---|---|
transform |
T(普通の値) |
失敗しない変換 |
and_then |
optional<T> |
失敗するかもしれない変換 |
std::expected:成功か失敗か(理由付き)
optional の「値がない」をもっと詳しく「なぜ失敗したか」まで持てるようにしたものです。以下のコードはClingでは動きません(後述):
#include <expected>
#include <string>
#include <iostream>
std::expected<int, std::string> parse_int(const std::string& s) {
try { return std::stoi(s); }
catch (...) { return std::unexpected("数値に変換できません: " + s); }
}
auto result = parse_int("abc");
if (result) {
std::cout << *result;
} else {
std::cout << result.error(); // → "数値に変換できません: abc"
}
Cling(JupyterLab + ROOT)で試す場合
Clingは現時点で std::expected に未対応です。
std::optional + std::cerr でエラー理由を出力する形で代替できます:
#include <sstream>
#include <optional>
std::optional<int> parse_int(const std::string& s) {
std::istringstream iss(s);
int result;
if (iss >> result) return result;
std::cerr << "数値に変換できません: " << s << "\n";
return std::nullopt;
}
std::future:値はまだ来ていない
時間のかかる処理を別スレッドで動かして、結果を後で受け取ります:
Cling(JupyterLab + ROOT)で試す場合
std::future と std::async はスレッド関連の共有ライブラリが必要なため、Clingでは動きません。VSCode + devcontainer など通常のコンパイル環境で確認してください。
#include <future>
#include <iostream>
int heavy_calc() {
// 時間のかかる処理...
return 42;
}
int main() {
// 別スレッドで実行開始
std::future<int> f = std::async(std::launch::async, heavy_calc);
// 計算中に別の処理ができる
std::cout << "計算中...\n";
// 結果が必要になったとき待つ
int result = f.get(); // ← ここで待つ
std::cout << result; // → 42
}
future::get() は失敗したとき例外を投げるので try-catch が必要です:
try {
int result = f.get();
std::cout << result;
} catch (const std::exception& e) {
std::cout << "エラー: " << e.what();
}
future を expected に変換してしまえば、あとは and_then でパイプラインとして扱えます:
template<typename T>
std::expected<T, std::string> get_result(std::future<T>& f) {
try {
return f.get();
} catch (const std::exception& e) {
return std::unexpected(e.what());
}
}
// あとは expected としてつなげる
auto result = get_result(f)
.and_then(process)
.and_then(save);
4つのモナドまとめ
| モナド | 包んでいる文脈 | つなぐ方法 |
|---|---|---|
optional<T> |
あるかないか | and_then |
expected<T,E> |
成功か失敗か | and_then |
vector<T> |
複数の値 | views::transform |
future<T> |
まだ来ていない | ラムダ or expected に変換 |
パイプラインとの関係
通常のパイプライン: 値 → [関数] → 値 → [関数] → 値
モナドのパイプライン:[値] → [関数] → [値] → [関数] → [値]
↑コンテナに包まれている
モナドは「失敗・複数・非同期」などの文脈を保ったままパイプラインをつなぐ仕組みです。
まとめ
| 概念 | 一言で | C++での体現 |
|---|---|---|
| 純粋関数 | 副作用なし・同じ入力→同じ出力 |
const 関数、ラムダ |
| 高階関数 | 関数を値として扱う |
std::function、ラムダ、カリー化 |
| イミュータブル | データを変えない |
const、値渡し、Ranges |
| モナド | 文脈付きでステップをつなぐ |
optional、expected、future
|
C++は知らず知らずのうちに関数型プログラミングを取り込んでいます。パイプライン処理を学ぶことで、これらの概念が自然につながって見えてきます。