1. はじめに
理論から実践に移るとき、当然こう聞きたくなる:「.NETとC#で仕事してる自分に、なんでこんなFPの手法が必要なの?」
確かに、C#はF#やHaskellのような純粋関数型言語じゃない。でも3.0以降、そしてC# 14に至るまで、FP的な道具がたくさん追加されていて、コードの質や表現力をぐっと上げてくれるんだ。
特に効果的な場面:
- コレクション操作 — LINQ、Map/Reduce、フィルタ、集約、ソートなどデータ操作の「魔法」。
- 純粋関数 — 状態や副作用が減るのでバグが減り、デバッグが楽になる。
- 高階関数 — 再利用しやすい汎用コンポーネントが書ける。
- 不変性(イミュータビリティ)と並行処理 — 並列・非同期の安全性確保に重要。
- 関数の合成 — 複雑なビジネスロジックを簡潔でテストしやすくする。
表: C#におけるOOPアプローチとFPアプローチの比較
| タスク | 命令型(OOP/従来) | 関数型(FP) |
|---|---|---|
| リストのフィルタ | |
|
| リストの変換 | |
|
| 条件による検索 | |
|
| 集計 | |
|
| キャッシュ | |
|
2. LINQ: C#で最もFP的な部分
「関数型プログラミング」はリストやフィルタ、.Where、.Select、.Aggregateみたいなものだと思うかもしれないが、その通り!LINQはC#におけるFPの結晶みたいなものだよ。
LINQの仕組みを思い出そう
LINQは、ラムダなどの関数を受け取るメソッドチェーンでコレクションを扱う。例:
var numbers = new List<int> { 1, 2, 3, 4, 5 };
// 偶数だけ取り出して2倍にする
var result = numbers
.Where(x => x % 2 == 0)
.Select(x => x * 2);
foreach (var number in result)
Console.WriteLine(number);
何が起きているか?
- .Where — 高階関数:関数(x => x % 2 == 0)を受け取り、別のコレクションを返す。
- .Select — これも関数を受け取る。
- 元のコレクションは変えずに新しい結果を得ている。
このスタイルは読みやすく拡張しやすい(.OrderBy、.Take、.Distinctなどをつなげられる)。
注意! ラムダはその場でデリゲートを作る便利な方法だ。LINQはC#でFPが使えるからこそ成り立つんだ。
図: コレクションの関数処理の流れ
コレクション --> Where(x => bool) --> Select(x => y) --> 新しい結果
3. 関数の合成とデータ処理パイプライン
FPでは合成がよく使われる:小さな関数をつなげて複雑な処理を作る。
例: 文字列処理のパイプライン
状態を変更するスタイル(OOP):
string s = " hello world ";
s = s.Trim();
s = s.ToUpper();
s = s + "!";
Console.WriteLine(s); // HELLO WORLD!
より「関数型」に:
Func<string, string> trim = x => x.Trim();
Func<string, string> upper = x => x.ToUpper();
Func<string, string> addBang = x => x + "!";
// 関数の合成 — 順に適用する
Func<string, string> pipeline = x => addBang(upper(trim(x)));
Console.WriteLine(pipeline(" hello world ")); // HELLO WORLD!
単純な合成コンビネータ:
Func<T, R> Compose<T, U, R>(Func<T, U> f, Func<U, R> g) =>
x => g(f(x));
// Composeでpipelineを作る:
var pipeline2 = Compose(trim, upper);
pipeline2 = Compose(pipeline2, addBang);
Console.WriteLine(pipeline2(" hello again ")); // HELLO AGAIN!
4. イミュータビリティでバグを防ぐ
イミュータビリティはFPの基礎。データ構造を変更せず新しいものを返す。特にマルチスレッドな場面で重要。
例: 「悪い」ケース(ミュータブル)
List<int> numbers = new List<int> { 1, 2, 3 };
numbers[0] = 42;
例: 「良い」ケース(関数的)
var numbers = new List<int> { 1, 2, 3 };
var newNumbers = numbers.Select((x, i) => i == 0 ? 42 : x).ToList();
現代のC#にはImmutableList<T>など、名前空間 System.Collections.Immutableの型がある:
using System.Collections.Immutable;
var immutableNumbers = ImmutableList.Create(1, 2, 3);
var changed = immutableNumbers.SetItem(0, 42); // 新しいリストを返す!
5. 実務での高階関数
高階関数は条件分岐の多いコードを書かずに汎用部品を作る手段。
例: ユーザー用の汎用フィルタ
class User
{
public string Name { get; set; }
public int Age { get; set; }
}
var users = new List<User>
{
new User { Name = "ヴァーシャ", Age = 26 },
new User { Name = "キャーチャ", Age = 17 },
new User { Name = "リョーシャ", Age = 35 }
};
List<User> FilterUsers(List<User> source, Predicate<User> predicate)
{
return source.Where(u => predicate(u)).ToList();
}
// 使い方:
var adults = FilterUsers(users, u => u.Age >= 18);
var longNames = FilterUsers(users, u => u.Name.Length > 3);
6. Pattern matching と switch 式
最近のC#ではパターンマッチングがよく使われる:switch式が複雑なifの連鎖を置き換えることが多い。
object value = 123;
string description = value switch
{
int i when i > 100 => "大きな数",
string s when s.Length > 3 => "長い文字列",
null => "空の値",
_ => "不明"
};
Console.WriteLine(description); // 大きな数
7. メモ化: 関数結果のキャッシュ
メモ化は同じ引数に対する関数の結果をキャッシュすること。C#では自分で簡単に実装できる。
Func<int, int> SlowFib = null; // 再帰的なフィボナッチ
var cache = new Dictionary<int, int>();
SlowFib = n =>
{
if (cache.ContainsKey(n))
return cache[n];
if (n <= 1)
cache[n] = n;
else
cache[n] = SlowFib(n - 1) + SlowFib(n - 2);
return cache[n];
};
Console.WriteLine(SlowFib(40)); // びっくりするほど速い!
8. カリー化と部分適用
部分適用は関数の一部の引数を固定すること。C#ではラムダで手軽にできる。
Func<int, int, int> add = (a, b) => a + b;
// 1つ目の引数を固定
Func<int, int> add10 = b => add(10, b);
Console.WriteLine(add10(5)); // 15
Console.WriteLine(add10(100)); // 110
9. 関数を使った宣言的スタイル
命令型:
var result = new List<int>();
foreach (var n in numbers)
{
if (n > 0)
result.Add(n * n);
}
宣言的:
var result = numbers
.Where(n => n > 0)
.Select(n => n * n)
.ToList();
10. 実践課題
「学生向けタスクマネージャ」でタスクをフィルタするモジュールを実装してみよう。
モデル:
class StudentTask
{
public string Title { get; set; }
public bool IsCompleted { get; set; }
public int Priority { get; set; }
}
初期データ:
var tasks = new List<StudentTask>
{
new StudentTask { Title = "宿題をやる", IsCompleted = false, Priority = 2 },
new StudentTask { Title = "コーヒーを飲む", IsCompleted = true, Priority = 3 },
new StudentTask { Title = "講義を見る", IsCompleted = false, Priority = 1 }
};
汎用フィルタ:
List<StudentTask> FilterTasks(
List<StudentTask> all,
Predicate<StudentTask> predicate)
{
return all.Where(t => predicate(t)).ToList();
}
// 未完了かつ優先度 > 1 のタスクを探す
var importantTasks = FilterTasks(tasks, t => !t.IsCompleted && t.Priority > 1);
// 結果を表示
foreach (var task in importantTasks)
Console.WriteLine(task.Title);
述語のコンビネータ:
Predicate<StudentTask> IsActive = t => !t.IsCompleted;
Predicate<StudentTask> IsHighPriority = t => t.Priority > 1;
// 複数条件を組み合わせる、パターン1
var specialTasks = FilterTasks(tasks, t => IsActive(t) && IsHighPriority(t));
// パターン2: 2つの述語を合成する関数コンビネータ
Predicate<StudentTask> And(Predicate<StudentTask> a, Predicate<StudentTask> b) => t => a(t) && b(t);
var specialTasks2 = FilterTasks(tasks, And(IsActive, IsHighPriority));
11. C#でFPを使う際の注意点とありがちなミス
まず、C#は強い型付けの言語だ。特に関数がデリゲートや複雑なラムダを返すときは型を明示する必要があることがある。型推論が効かずコンパイルエラーになることがあるので気をつけて。
次に、副作用のある関数を純粋関数が期待される場所に渡さないこと。外部状態の変更は予測可能性を壊す。関数はできるだけスコープ外の状態を「ミュート」しないようにしよう。
さらに、closureで外側の変数をキャプチャするときは注意。特に非同期やマルチスレッドで、ラムダに渡した変数の値が後で変わると意図しないバグになることがある。
最後に、過度なFPスタイルはチームにとって読みづらくなることがある。習熟していないチームにはむやみに使わず、本当に簡潔に保守しやすくなる場面で使おう。
GO TO FULL VERSION