C#で関数型プログラミングを始める方法|初心者がつまずく理由と実践パターン

はじめに

C#はオブジェクト指向言語として知られていますが、ラムダ式、LINQ、record型、パターンマッチングなど、関数型プログラミングに適した機能も数多く備えています。そのため、言語を乗り換えなくても、既存のC#コードへ関数型の考え方を段階的に取り入れられます。

ただし、関数型プログラミングは、単にLINQやラムダ式を使うことではありません。重要なのは、データを直接書き換えず、入力から出力を作る小さな関数を組み合わせ、副作用を管理しやすい場所へ分離することです。

この記事では、C#で関数型プログラミングを始めたい初心者に向けて、基本的な考え方、つまずきやすい理由、主要な言語機能、実践パターン、設計上の注意点をコード例とともに解説します。

1. C#で関数型プログラミングを始める前に知っておきたい基本

1-1. 関数型プログラミングとは何か

関数型プログラミングとは、処理を「状態を順番に変更する命令」ではなく、「入力を受け取り、出力を返す関数」の組み合わせとして表現する考え方です。

特に重視されるのは、次のような性質です。

  • 関数を変数や引数として扱う

  • 元のデータを変更せず、新しい値を作る

  • 同じ入力に対して同じ結果を返す

  • 外部状態を変更する副作用を減らす

  • 小さな関数を組み合わせて複雑な処理を作る

たとえば、商品の税込価格を計算する処理は、次のように書けます。

C#
static decimal AddTax(decimal price, decimal taxRate)
{
return price * (1 + taxRate);
}

この関数は、引数だけを使って結果を計算し、外部の変数やデータベースを変更しません。同じpricetaxRateを渡せば、常に同じ値を返します。このような関数は「純粋関数」と呼ばれます。

1-2. C#でも関数型の書き方ができる理由

C#は純粋な関数型言語ではありませんが、複数のプログラミングスタイルを組み合わせられるマルチパラダイム言語です。

C#には、関数型プログラミングを支える次のような機能があります。

  • ラムダ式

  • FuncAction

  • LINQ

  • record型

  • readonlyinit

  • パターンマッチング

  • switch式

  • 拡張メソッド

たとえば、数値の一覧から偶数だけを選び、それぞれを2倍する処理は、LINQを使って宣言的に記述できます。

C#
var numbers = new[] { 1, 2, 3, 4, 5 };

var result = numbers
.Where(number => number % 2 == 0)
.Select(number => number * 2);

このコードは「変数をどの順番で変更するか」ではなく、「どのデータを選び、どのように変換するか」を表しています。

1-3. オブジェクト指向と関数型プログラミングの違い

オブジェクト指向では、データと処理をオブジェクトにまとめ、オブジェクトの状態をメソッドによって変更する設計がよく使われます。

C#
public class BankAccount
{
public decimal Balance { get; private set; }

public void Deposit(decimal amount)
{
Balance += amount;
}
}

一方、関数型の考え方では、現在の状態を直接変更せず、新しい状態を返します。

C#
public record BankAccount(decimal Balance);

static BankAccount Deposit(BankAccount account, decimal amount)
{
return account with
{
Balance = account.Balance + amount
};
}

どちらか一方が常に優れているわけではありません。C#の実務では、オブジェクト指向でシステム全体の構造を作り、計算やデータ変換には関数型の考え方を取り入れる方法が現実的です。

1-4. 初心者が「C# 関数型」で検索する主な目的

「C# 関数型」と検索する人の目的は、大きく分けると次のようなものです。

  • LINQやラムダ式を正しく理解したい

  • 可読性の高いコードを書きたい

  • 状態変更によるバグを減らしたい

  • テストしやすい設計を学びたい

  • F#などの関数型言語を学ぶ前に概念を知りたい

  • 既存のC#プロジェクトを改善したい

関数型プログラミングは、特別なライブラリを導入しなければ始められないものではありません。まずは、小さなメソッドを純粋関数にすることや、コレクションの変換をLINQで表現することから始められます。

1-5. C#で関数型を学ぶメリット

C#で関数型の考え方を学ぶ主なメリットは、コードの予測可能性が高まることです。

外部状態を変更しない関数は、入力と出力だけを確認すれば動作を理解できます。テスト時にデータベースやファイルを準備する必要も少なくなります。

また、不変データを中心に設計すると、「どこで値が変更されたのかわからない」という問題を減らせます。並列処理や非同期処理でも、共有状態の変更が少ないコードは競合を起こしにくくなります。

関数型の考え方は、次のような場面で特に役立ちます。

  • 金額や料金の計算

  • 入力値の検証

  • データの抽出・並べ替え・集計

  • APIレスポンスの変換

  • ビジネスルールの実装

  • 状態に応じた処理の分岐

2. 初心者がC#の関数型プログラミングでつまずく理由

2-1. 「関数を値として扱う」という考え方に慣れていない

C#の初心者は、メソッドを「呼び出すもの」として覚えることが多いため、関数そのものを変数に代入したり、別のメソッドへ渡したりする書き方に戸惑います。

C#
Func<int, int> doubleNumber = number => number * 2;

var result = doubleNumber(5);
Console.WriteLine(result); // 10

doubleNumberには計算結果ではなく、「整数を受け取って整数を返す処理」が格納されています。

関数を引数として受け取ることもできます。

C#
static int Calculate(int value, Func<int, int> operation)
{
return operation(value);
}

var result = Calculate(5, number => number * 3);

関数を値として扱えるようになると、処理の一部を外部から差し替えたり、小さな処理を組み合わせたりできるようになります。

2-2. ラムダ式・Func・Action・delegateの違いがわかりにくい

これらは似ていますが、役割が異なります。

ラムダ式は、匿名関数を簡潔に書くための構文です。

C#
number => number * 2

Funcは、戻り値がある処理を表す標準のデリゲート型です。

C#
Func<int, int> doubleNumber = number => number * 2;

Actionは、戻り値がない処理を表します。

C#
Action<string> print = message => Console.WriteLine(message);

delegateは、メソッドを参照する型を定義するための仕組みです。

C#
public delegate decimal PriceCalculator(decimal price);

初心者は、まずFuncActionを理解し、独自の型名や明確な意味が必要になった段階でカスタムデリゲートを検討するとよいでしょう。

2-3. LINQを単なる便利構文として覚えてしまう

LINQは、ループを短く書くためだけの機能ではありません。データに対する処理を、「抽出」「変換」「集計」などの操作として表現するための仕組みです。

C#
var activeUserNames = users
.Where(user => user.IsActive)
.OrderBy(user => user.Name)
.Select(user => user.Name)
.ToList();

このコードからは、次の処理が読み取れます。

  1. 有効なユーザーを選ぶ

  2. 名前順に並べる

  3. 名前だけを取り出す

  4. リストとして確定する

LINQを関数型の視点で理解するには、各操作が「元のコレクションを変更せず、新しい結果を返す」ことに注目する必要があります。

2-4. 破壊的変更と不変性の違いが理解しづらい

破壊的変更とは、既存のオブジェクトや変数の状態を直接書き換えることです。

C#
user.Name = "Alice";

不変性を意識した設計では、元の値を残し、新しい値を作ります。

C#
public record User(string Name, int Age);

var updatedUser = user with
{
Name = "Alice"
};

不変性を使うと、変更前と変更後の状態を明確に区別できます。ただし、C#のすべてのオブジェクトが自動的に不変になるわけではありません。record型のプロパティが可変コレクションを持っていれば、その中身は変更できてしまいます。

2-5. 副作用を減らす設計のイメージが湧かない

副作用とは、戻り値を返すこと以外に、外部へ影響を与える処理です。

代表的な副作用には、次のものがあります。

  • ファイルの書き込み

  • データベースの更新

  • HTTP通信

  • 画面への出力

  • 外部変数の変更

  • 現在時刻や乱数への依存

副作用を完全になくすことはできません。実務では、計算部分と副作用を伴う部分を分離します。

C#
static decimal CalculateTotal(IEnumerable<decimal> prices)
{
return prices.Sum();
}

var prices = await repository.GetPricesAsync();
var total = CalculateTotal(prices);
await repository.SaveTotalAsync(total);

CalculateTotalは純粋な計算として独立しています。データの取得と保存は外側で行うため、計算ロジックだけを簡単にテストできます。

2-6. 関数型を無理に使いすぎてコードが読みにくくなる

関数型プログラミングを学び始めると、すべての処理をラムダ式やLINQの連結で書きたくなることがあります。

しかし、長すぎる式は、短い命令型コードより理解しづらくなる場合があります。

C#
var result = users
.Where(user => user.IsActive)
.Select(user => new
{
User = user,
Score = user.Orders
.Where(order => order.IsCompleted)
.SelectMany(order => order.Items)
.Where(item => item.Price > 0)
.Sum(item => item.Price * item.Quantity)
})
.Where(value => value.Score >= 10000)
.OrderByDescending(value => value.Score)
.ToList();

処理に名前を付けて分割すれば、意図が伝わりやすくなります。

C#
static decimal CalculateCompletedOrderTotal(User user)
{
return user.Orders
.Where(order => order.IsCompleted)
.SelectMany(order => order.Items)
.Sum(item => item.Price * item.Quantity);
}

関数型らしい短さより、チーム全体が理解できる可読性を優先することが重要です。

3. C#で関数型プログラミングを支える主要機能

3-1. ラムダ式

ラムダ式は、名前のない関数を簡潔に記述する構文です。

C#
Func<int, int> square = number => number * number;

引数が複数ある場合は、丸括弧で囲みます。

C#
Func<int, int, int> add = (left, right) => left + right;

複数行の処理も記述できます。

C#
Func<int, string> describe = number =>
{
var category = number >= 0 ? "正の数またはゼロ" : "負の数";
return $"{number}は{category}です";
};

ただし、複数行のラムダ式が長くなる場合は、通常のメソッドとして切り出したほうが読みやすくなります。

3-2. FuncとAction

Funcは、最後の型引数が戻り値の型になります。

C#
Func<string, int> getLength = text => text.Length;
Func<int, int, int> multiply = (left, right) => left * right;

Actionは戻り値を持ちません。

C#
Action<string> log = message => Console.WriteLine(message);

関数型の中心となるのは、値を返すFuncです。Actionは画面出力や保存処理など、副作用を持つ処理に使われることが多いため、純粋な計算とは分けて扱うと設計が明確になります。

3-3. LINQ

LINQは、コレクションやデータソースに対して、共通の形式で問い合わせや変換を行う機能です。

よく使うメソッドには、次のようなものがあります。

  • Where:条件に一致する要素を抽出する

  • Select:要素を別の値へ変換する

  • SelectMany:入れ子のコレクションを平らにする

  • OrderBy:並べ替える

  • GroupBy:グループ化する

  • Aggregate:複数の値を一つにまとめる

  • Any:条件に一致する要素があるか調べる

  • All:すべての要素が条件を満たすか調べる

C#
var total = orders
.Where(order => order.IsCompleted)
.Select(order => order.Amount)
.Sum();

LINQには遅延実行される操作があります。WhereSelectだけでは、その時点で列挙が実行されないことがあります。結果を確定したい場合は、ToListToArrayを使います。

3-4. record型

record型は、値として扱うデータを表現しやすい型です。

C#
public record Product(string Name, decimal Price);

通常のクラスとは異なり、プロパティの値が同じrecord同士は値ベースで比較できます。

C#
var first = new Product("Keyboard", 5000);
var second = new Product("Keyboard", 5000);

Console.WriteLine(first == second); // True

with式を使うと、元の値を残しながら、一部だけ変更した新しい値を作れます。

C#
var discounted = first with
{
Price = 4500
};

3-5. initアクセサとreadonly

initアクセサを使うと、オブジェクトの初期化時だけプロパティへ値を設定できます。

C#
public class User
{
public required string Name { get; init; }
public int Age { get; init; }
}

作成後に代入しようとすると、コンパイルエラーになります。

C#
var user = new User
{
Name = "Alice",
Age = 25
};

// user.Age = 26; // コンパイルエラー

readonlyは、フィールドの再代入を制限するときに使います。

C#
public class TaxService
{
private readonly decimal _taxRate;

public TaxService(decimal taxRate)
{
_taxRate = taxRate;
}
}

ただし、readonlyは参照先オブジェクトの内部まで不変にするものではありません。可変なList<T>readonlyフィールドに入れた場合でも、リストの要素は追加できます。

3-6. パターンマッチング

パターンマッチングを使うと、型や値、プロパティの状態に応じた処理を簡潔に書けます。

C#
static string Describe(object value)
{
return value switch
{
int number when number > 0 => "正の整数",
int 0 => "ゼロ",
int => "負の整数",
string { Length: 0 } => "空文字",
string text => $"文字列: {text}",
null => "null",
_ => "その他"
};
}

複雑なifや型変換を減らし、入力と出力の対応を一覧として表現できる点がメリットです。

3-7. switch式

switch式は、条件に応じて値を返す処理に適しています。

C#
static decimal GetDiscountRate(CustomerRank rank)
{
return rank switch
{
CustomerRank.Gold => 0.15m,
CustomerRank.Silver => 0.10m,
CustomerRank.Bronze => 0.05m,
_ => 0m
};
}

従来のswitch文と比べて、変数の再代入やbreakが不要です。入力から出力への変換として読めるため、純粋関数と相性がよい構文です。

3-8. 拡張メソッド

拡張メソッドを使うと、既存の型を変更せずに操作を追加できます。

C#
public static class EnumerableExtensions
{
public static IEnumerable<T> WhereNotNull<T>(
this IEnumerable<T?> source)
where T : class
{
return source
.Where(item => item is not null)
.Select(item => item!);
}
}

利用側では、通常のインスタンスメソッドのように呼び出せます。

C#
var names = new string?[] { "Alice", null, "Bob" };

var validNames = names.WhereNotNull();

小さな変換処理を拡張メソッドとして定義すると、LINQの処理へ自然に組み込めます。ただし、用途が限定的なメソッドまで大量に追加すると、処理の定義場所がわかりにくくなるため注意が必要です。

4. C#で最初に覚えるべき関数型の基本パターン

4-1. ループをLINQのSelect・Where・Aggregateに置き換える

命令型のループでは、結果用のコレクションを作り、条件分岐と追加処理を順番に書きます。

C#
var result = new List<int>();

foreach (var number in numbers)
{
if (number % 2 == 0)
{
result.Add(number * 2);
}
}

LINQでは、抽出と変換を分けて表現できます。

C#
var result = numbers
.Where(number => number % 2 == 0)
.Select(number => number * 2)
.ToList();

合計や連結など、複数の値を一つにまとめる処理にはAggregateを利用できます。

C#
var total = numbers.Aggregate(
0,
(current, number) => current + number);

単純な合計であればSumのほうが意図は明確です。Aggregateは、標準の集計メソッドで表現しにくい処理に使います。

4-2. 条件分岐をswitch式で整理する

複数の条件に応じて値を返す処理は、switch式で整理できます。

C#
static string GetShippingMessage(decimal amount, bool isMember)
{
return (amount, isMember) switch
{
(>= 5000m, _) => "送料無料",
(>= 3000m, true) => "会員特典で送料無料",
_ => "送料がかかります"
};
}

タプルとパターンマッチングを組み合わせることで、複数の入力条件を一覧として表せます。

4-3. 状態変更を避けて新しい値を返す

可変クラスのプロパティを直接変更すると、変更前の値が失われます。

C#
order.Status = OrderStatus.Completed;

record型とwith式を使えば、新しい状態を返せます。

C#
public record Order(
int Id,
OrderStatus Status,
decimal Amount);

static Order Complete(Order order)
{
return order with
{
Status = OrderStatus.Completed
};
}

この設計では、呼び出し側が変更前と変更後の両方を保持できます。

4-4. メソッドを小さな純粋関数に分ける

一つのメソッドで入力確認、計算、保存、通知まで行うと、テストが複雑になります。

処理を役割ごとに分けると、中心となるロジックを純粋関数にできます。

C#
static decimal CalculateSubtotal(
IEnumerable<OrderItem> items)
{
return items.Sum(item => item.Price * item.Quantity);
}

static decimal ApplyDiscount(
decimal subtotal,
decimal discountRate)
{
return subtotal * (1 - discountRate);
}

小さな関数には、処理内容を示す名前を付けます。関数の中身を読まなくても、処理の流れが理解できる状態が理想です。

4-5. nullを直接扱わず安全な戻り値にする

nullを返すメソッドは、呼び出し側が確認を忘れるとNullReferenceExceptionの原因になります。

簡単なケースでは、Nullable参照型を有効にし、戻り値がnullになり得ることを型で示します。

C#
static User? FindUser(
IEnumerable<User> users,
int id)
{
return users.FirstOrDefault(user => user.Id == id);
}

呼び出し側ではパターンマッチングを利用できます。

C#
var message = FindUser(users, userId) switch
{
{ } user => $"ユーザー名: {user.Name}",
null => "ユーザーが見つかりません"
};

より厳密に扱いたい場合は、値の有無を表す独自のOption<T>型を導入する方法もあります。

C#
public abstract record Option<T>
{
public sealed record Some(T Value) : Option<T>;
public sealed record None : Option<T>;
}

これにより、値が存在する場合と存在しない場合の両方を明示的に処理できます。

4-6. 例外に頼りすぎずResult型の考え方を使う

例外は、想定外の障害を通知する仕組みとして有効です。しかし、入力エラーや業務上の失敗まで例外で表現すると、正常系の分岐がわかりにくくなります。

そこで、成功と失敗を値として返すResult<T>型を使う方法があります。

C#
public abstract record Result<T>
{
public sealed record Success(T Value) : Result<T>;
public sealed record Failure(string Error) : Result<T>;
}

年齢を検証する関数は、次のように書けます。

C#
static Result<int> ValidateAge(int age)
{
return age switch
{
< 0 => new Result<int>.Failure(
"年齢は0以上で入力してください"),
> 150 => new Result<int>.Failure(
"年齢の値が大きすぎます"),
_ => new Result<int>.Success(age)
};
}

呼び出し側は、成功と失敗をswitch式で処理できます。

C#
var message = ValidateAge(age) switch
{
Result<int>.Success(var value)
=> $"登録可能です: {value}歳",
Result<int>.Failure(var error)
=> $"入力エラー: {error}",
_ => throw new InvalidOperationException()
};

5. 実践例で学ぶC#の関数型プログラミング

5-1. 命令型コードを関数型コードに書き換える例

まず、命令型で書かれた売上集計を見てみましょう。

C#
var total = 0m;

foreach (var order in orders)
{
if (order.IsCompleted)
{
total += order.Amount;
}
}

LINQを使うと、処理の目的を直接表現できます。

C#
var total = orders
.Where(order => order.IsCompleted)
.Sum(order => order.Amount);

命令型コードでは、「変数を作る」「繰り返す」「条件を確認する」「加算する」という手順を記述しています。関数型のコードでは、「完了した注文の金額を合計する」という目的を記述しています。

5-2. Listの加工をLINQで書く

商品リストから、在庫がある商品だけを価格順で取得し、表示用データへ変換する例です。

C#
public record Product(
int Id,
string Name,
decimal Price,
int Stock);

public record ProductView(
int Id,
string DisplayName);

var views = products
.Where(product => product.Stock > 0)
.OrderBy(product => product.Price)
.Select(product => new ProductView(
product.Id,
$"{product.Name} - {product.Price:N0}円"))
.ToList();

元のproductsは変更されません。抽出、並べ替え、変換という操作が段階ごとに表現されています。

5-3. 入力チェックを純粋関数で分離する

ユーザー登録処理の中で入力チェックと保存を同時に行うと、テストにデータベースが必要になります。

入力チェックだけを純粋関数として分離します。

C#
public record RegistrationInput(
string Name,
string Email);

static IReadOnlyList<string> Validate(
RegistrationInput input)
{
var errors = new List<string>();

if (string.IsNullOrWhiteSpace(input.Name))
{
errors.Add("名前を入力してください");
}

if (string.IsNullOrWhiteSpace(input.Email))
{
errors.Add("メールアドレスを入力してください");
}
else if (!input.Email.Contains('@'))
{
errors.Add(
"メールアドレスの形式が正しくありません");
}

return errors;
}

さらに、各検証を小さな関数へ分けられます。

C#
static IEnumerable<string> ValidateName(string name)
{
return string.IsNullOrWhiteSpace(name)
? ["名前を入力してください"]
: [];
}

static IEnumerable<string> ValidateEmail(string email)
{
if (string.IsNullOrWhiteSpace(email))
{
return ["メールアドレスを入力してください"];
}

return email.Contains('@')
? []
: ["メールアドレスの形式が正しくありません"];
}

static IReadOnlyList<string> Validate(
RegistrationInput input)
{
return ValidateName(input.Name)
.Concat(ValidateEmail(input.Email))
.ToList();
}

保存処理は、検証に成功した後で実行します。

C#
var errors = Validate(input);

if (errors.Count == 0)
{
await repository.SaveAsync(input);
}

5-4. recordで不変データを表現する

注文データをrecordで表現します。

C#
public record OrderItem(
string ProductName,
decimal UnitPrice,
int Quantity);

public record Order(
int Id,
IReadOnlyList<OrderItem> Items,
OrderStatus Status);

注文を完了状態にする関数は、新しい注文を返します。

C#
static Order CompleteOrder(Order order)
{
return order.Status switch
{
OrderStatus.Pending => order with
{
Status = OrderStatus.Completed
},
_ => order
};
}

ただし、IReadOnlyList<T>は、参照先が常に不変であることを保証する型ではありません。強い不変性が必要な場合は、イミュータブルコレクションの利用も検討します。

5-5. パターンマッチングで条件分岐を読みやすくする

商品の在庫状況を判定する例です。

C#
public record Product(
string Name,
int Stock,
bool IsDiscontinued);

static string GetStockMessage(Product product)
{
return product switch
{
{ IsDiscontinued: true }
=> "販売終了",
{ Stock: <= 0 }
=> "在庫切れ",
{ Stock: <= 5 }
=> "残りわずか",
_ => "在庫あり"
};
}

条件を上から順に読めるため、ネストしたif文より判定ルールを把握しやすくなります。

5-6. 副作用のある処理とない処理を分ける

請求処理では、データ取得、金額計算、保存、メール送信などが発生します。

まず、計算部分を純粋関数にします。

C#
public record InvoiceItem(
decimal UnitPrice,
int Quantity);

static decimal CalculateInvoiceTotal(
IEnumerable<InvoiceItem> items,
decimal taxRate)
{
var subtotal = items.Sum(
item => item.UnitPrice * item.Quantity);

return subtotal * (1 + taxRate);
}

副作用はアプリケーションの外側で実行します。

C#
var items = await repository.GetInvoiceItemsAsync(orderId);

var total = CalculateInvoiceTotal(items, 0.10m);

await repository.SaveInvoiceAsync(orderId, total);
await mailService.SendInvoiceAsync(orderId, total);

この構造では、金額計算のテストにデータベースやメールサーバーは必要ありません。

5-7. 小さな関数を組み合わせて処理を作る

文字列を正規化して検証する処理を、小さな関数へ分けます。

C#
static string TrimText(string value)
{
return value.Trim();
}

static string NormalizeCase(string value)
{
return value.ToLowerInvariant();
}

static bool IsValidUserName(string value)
{
return value.Length is >= 3 and <= 20;
}

関数を順番に適用します。

C#
static Result<string> NormalizeUserName(string input)
{
var normalized = NormalizeCase(TrimText(input));

return IsValidUserName(normalized)
? new Result<string>.Success(normalized)
: new Result<string>.Failure(
"ユーザー名は3文字以上20文字以下で入力してください");
}

それぞれの関数は役割が一つなので、単体テストしやすく、別の処理でも再利用できます。

6. C#で関数型プログラミングを使うときの設計ポイント

6-1. すべてを関数型にしようとしない

C#で関数型プログラミングを活用する目的は、関数型らしいコードを書くことではなく、変更しやすく安全なコードを作ることです。

UIの状態管理やフレームワークのライフサイクルなど、可変状態を扱うほうが自然な場面もあります。単純なループを無理にAggregateへ変換すると、かえって意図が伝わりにくくなることもあります。

純粋関数が適している計算や変換には関数型を使い、状態を持つ必要がある部分ではオブジェクト指向を使うという判断が現実的です。

6-2. ドメインロジックは副作用を減らす

料金計算、割引判定、利用資格の確認など、ビジネス上重要なロジックは、可能な限り外部環境に依存させないようにします。

避けたい例は、計算関数の内部で現在時刻を直接取得するコードです。

C#
static bool IsExpired(DateTime expiresAt)
{
return expiresAt < DateTime.UtcNow;
}

現在時刻を引数として受け取れば、結果を再現しやすくなります。

C#
static bool IsExpired(
DateTime expiresAt,
DateTime currentTime)
{
return expiresAt < currentTime;
}

テストでは任意の時刻を渡せるため、期限の前後を安定して確認できます。

6-3. I/OやDBアクセスは外側に寄せる

関数型設計では、システムの中心に純粋なロジックを置き、その外側にデータベース、HTTP通信、ファイル操作などを配置する考え方が有効です。

C#
public async Task<Result<decimal>> CalculateOrderTotalAsync(
int orderId)
{
var order = await repository.FindAsync(orderId);

if (order is null)
{
return new Result<decimal>.Failure(
"注文が見つかりません");
}

var total = CalculateTotal(order.Items);

return new Result<decimal>.Success(total);
}

CalculateTotalを純粋関数として独立させることで、DBアクセスの詳細と計算ルールを分離できます。

6-4. 可変状態を持つクラスとの付き合い方

既存のC#プロジェクトでは、状態を変更するクラスを完全に排除することは現実的ではありません。

重要なのは、可変状態を無制限に公開しないことです。

C#
public class ShoppingCart
{
private readonly List<CartItem> _items = [];

public IReadOnlyList<CartItem> Items => _items;

public void Add(CartItem item)
{
_items.Add(item);
}
}

さらに関数型に近づけるなら、変更後のカートを返します。

C#
public record ShoppingCart(
IReadOnlyList<CartItem> Items);

static ShoppingCart AddItem(
ShoppingCart cart,
CartItem item)
{
return cart with
{
Items = cart.Items.Append(item).ToList()
};
}

データ量、更新頻度、パフォーマンス要件を考慮し、適切な方式を選びます。

6-5. テストしやすいコードにする考え方

純粋関数のテストでは、入力と期待する出力だけを用意します。

C#
[Fact]
public void ApplyDiscount_ReturnsDiscountedPrice()
{
var result = ApplyDiscount(1000m, 0.20m);

Assert.Equal(800m, result);
}

モックや複雑な初期化が不要なので、テストが短くなります。

テストしやすい関数には、次の特徴があります。

  • 外部の可変状態を参照しない

  • 現在時刻や乱数を引数で受け取る

  • データベースへ直接アクセスしない

  • 結果を戻り値として返す

  • 一つの関数が一つの役割を持つ

6-6. チーム開発で読みやすさを保つルール

関数型の記述に慣れている人と、慣れていない人が同じコードを読むことを想定する必要があります。

チームでは、次のようなルールを決めると読みやすさを保てます。

  • LINQの一つの処理を長くしすぎない

  • 複雑なラムダ式は名前付きメソッドへ切り出す

  • 副作用を伴う処理をSelectWhereに入れない

  • 独自のOptionResultの使い方を統一する

  • ToListなどで列挙を確定する場所を明確にする

  • パフォーマンス上の理由があるコードには意図を残す

高度な抽象化を導入する前に、コードレビューで理解できる表現になっているかを確認することが重要です。

7. C#関数型プログラミングのよくある失敗例

7-1. LINQを長くつなげすぎる

LINQは便利ですが、何十行にもわたって連結すると、途中のデータ構造がわかりにくくなります。

処理の意味が変わる箇所で、名前付きの変数やメソッドに分割します。

C#
var completedOrders = orders
.Where(order => order.IsCompleted);

var customerTotals = completedOrders
.GroupBy(order => order.CustomerId)
.Select(group => new
{
CustomerId = group.Key,
Total = group.Sum(order => order.Amount)
});

var highValueCustomers = customerTotals
.Where(customer => customer.Total >= 100000m)
.ToList();

変数を増やすことが、必ずしも悪いわけではありません。処理の段階に適切な名前が付けば、意図を理解しやすくなります。

7-2. ラムダ式を複雑にしすぎる

次のようなラムダ式は、条件や計算が詰め込まれています。

C#
var prices = products.Select(product =>
product.IsMemberOnly && customer.IsMember
? product.Price * 0.8m +
(product.Category == "Food" ? 100m : 0m)
: product.Price);

計算を名前付きメソッドに分けます。

C#
static decimal CalculatePrice(
Product product,
Customer customer)
{
if (product.IsMemberOnly && customer.IsMember)
{
var discounted = product.Price * 0.8m;
return product.Category == "Food"
? discounted + 100m
: discounted;
}

return product.Price;
}

var prices = products.Select(
product => CalculatePrice(product, customer));

コードの短さより、ルールの意味が明確であることを優先します。

7-3. 副作用のある処理をLINQ内に混ぜる

次のコードは、Selectの中でログ出力を行っています。

C#
var results = users
.Select(user =>
{
Console.WriteLine($"処理中: {user.Name}");
return ConvertUser(user);
})
.ToList();

LINQには遅延実行があるため、いつログが出力されるのかがわかりにくくなります。また、同じ列挙を複数回実行すると、ログも複数回出力される可能性があります。

変換と出力を分けます。

C#
var results = users
.Select(ConvertUser)
.ToList();

foreach (var user in users)
{
Console.WriteLine($"処理完了: {user.Name}");
}

WhereSelectは、入力から新しい値を作る処理に集中させるのが基本です。

7-4. 不変性を意識せずrecordを使う

record型を使っても、内部に可変オブジェクトを持てば完全な不変データにはなりません。

C#
public record Team(List<string> Members);

次の操作では、record自体を作り直さずに内容を変更できます。

C#
team.Members.Add("Alice");

不変性を強めるには、外部から変更しにくいコレクションを利用し、追加操作で新しい値を返します。

C#
public record Team(
IReadOnlyList<string> Members);

static Team AddMember(Team team, string member)
{
return team with
{
Members = team.Members
.Append(member)
.ToList()
};
}

7-5. 関数型らしさを優先して可読性を落とす

関数合成や高階関数を使えば、処理を抽象化できます。しかし、抽象化の目的が説明できない場合は、単純なメソッドのほうが適切です。

次のような処理は、無理に一つの式へまとめる必要はありません。

C#
var normalized = input.Trim().ToLowerInvariant();

if (normalized.Length < 3)
{
return new Result<string>.Failure(
"3文字以上で入力してください");
}

return new Result<string>.Success(normalized);

関数型プログラミングは、コードを難しくするためのものではありません。データの流れやルールを明確にするために使います。

7-6. パフォーマンスを確認せず抽象化しすぎる

不変データやLINQは安全性と可読性を高めますが、使い方によってはオブジェクト生成や列挙回数が増えます。

次のようなコードでは、同じデータを複数回列挙しています。

C#
var activeUsers = users.Where(user => user.IsActive);

var count = activeUsers.Count();
var names = activeUsers.Select(user => user.Name).ToList();

データソースがデータベースクエリや重い計算の場合、意図しない複数回実行につながる可能性があります。

必要に応じて一度リストへ変換します。

C#
var activeUsers = users
.Where(user => user.IsActive)
.ToList();

var count = activeUsers.Count;
var names = activeUsers
.Select(user => user.Name)
.ToList();

最初から最適化しすぎる必要はありませんが、処理時間やメモリ使用量が重要な部分は計測して判断します。

8. C#で関数型プログラミングを学ぶおすすめの順番

8-1. まずラムダ式とFuncを理解する

最初に、関数を変数へ代入し、引数として渡せることを理解します。

C#
Func<int, bool> isEven =
number => number % 2 == 0;

Func<int, int> doubleNumber =
number => number * 2;

次に、関数を受け取るメソッドを書いてみます。

C#
static IEnumerable<int> Transform(
IEnumerable<int> values,
Func<int, int> operation)
{
return values.Select(operation);
}

この段階では、高度な用語よりも、処理を値として渡せる感覚を身につけることが大切です。

8-2. 次にLINQでデータ変換に慣れる

ラムダ式を理解したら、WhereSelectOrderByGroupByAggregateの順に学ぶとよいでしょう。

練習では、次のような処理をLINQで書き換えます。

  • 偶数だけを抽出する

  • 商品価格を税込価格へ変換する

  • 名前順に並べ替える

  • カテゴリーごとにグループ化する

  • 注文金額を合計する

LINQメソッドを暗記するだけでなく、元のコレクションが変更されていないことも確認します。

8-3. recordと不変性を学ぶ

次に、状態を直接書き換える方法と、新しい値を返す方法の違いを学びます。

C#
public record User(
string Name,
string Email);

var updated = user with
{
Email = "new@example.com"
};

不変性を学ぶ際は、record型だけでなく、内部のコレクションや参照型が変更可能かどうかも確認する必要があります。

8-4. パターンマッチングを使って条件分岐を改善する

値や型に応じた処理は、switch式へ置き換える練習をします。

C#
static string GetMessage(Result<int> result)
{
return result switch
{
Result<int>.Success(var value)
=> $"成功: {value}",
Result<int>.Failure(var error)
=> $"失敗: {error}",
_ => "不明な結果"
};
}

すべての条件分岐をswitch式にする必要はありません。値を返す分岐や、状態の組み合わせを一覧化したい場面から使うのが効果的です。

8-5. 純粋関数と副作用の分離を実践する

次に、既存のメソッドから計算部分を抜き出します。

たとえば、データベースから注文を取得し、合計を計算し、保存するメソッドがあるなら、合計計算だけを独立した関数にします。

C#
static decimal CalculateTotal(
IEnumerable<OrderItem> items)
{
return items.Sum(
item => item.UnitPrice * item.Quantity);
}

副作用の分離は、C#で関数型プログラミングを実務へ取り入れるうえで特に重要なステップです。

8-6. 小さな実務コードから段階的に導入する

最初からシステム全体を関数型へ変更する必要はありません。

まずは、次のような範囲から導入します。

  • 入力値の検証

  • DTOからドメイン型への変換

  • 金額や日付の計算

  • コレクションの抽出と集計

  • ステータスに応じた表示文言の生成

影響範囲の小さい処理で効果を確認し、チーム内で書き方を共有しながら範囲を広げます。

9. C#の関数型プログラミングに関するよくある質問

9-1. C#は関数型言語なのか

C#は、純粋な関数型言語ではありません。基本的にはオブジェクト指向を中心としながら、手続き型や関数型の機能も利用できるマルチパラダイム言語です。

変数の再代入、可変オブジェクト、副作用のあるメソッドも自由に使えます。一方で、ラムダ式、LINQ、record型、パターンマッチングなどを使えば、関数型の考え方を取り入れたコードを書けます。

そのため、C#では「すべてを純粋関数で書く」のではなく、「適した部分へ関数型の設計を導入する」という使い方が一般的です。

9-2. F#を学ばないと関数型は理解できないのか

F#を学ばなくても、C#だけで関数型プログラミングの基本は理解できます。

C#でも、次の考え方を実践できます。

  • 高階関数

  • 純粋関数

  • 不変データ

  • パターンマッチング

  • 関数の合成

  • 副作用の分離

ただし、F#は関数型を中心に設計された言語なので、不変性や型による安全性をより自然に学べます。C#で基本を理解した後にF#へ触れると、関数型の考え方を深める助けになります。

9-3. LINQを使えば関数型プログラミングと言えるのか

LINQを使うだけで、コード全体が関数型になるわけではありません。

たとえば、LINQの中で外部変数を変更すると、副作用を含む処理になります。

C#
var total = 0m;

var values = orders
.Select(order =>
{
total += order.Amount;
return order.Amount;
})
.ToList();

関数型の考え方では、外部変数を変更せず、集計結果を戻り値として受け取ります。

C#
var total = orders.Sum(order => order.Amount);

LINQは関数型プログラミングを支える重要な機能ですが、純粋関数、不変性、副作用の管理と合わせて使うことが大切です。

9-4. 関数型プログラミングは実務で使えるのか

C#の実務でも、関数型プログラミングの考え方は幅広く利用できます。

特に、次の処理と相性がよい傾向があります。

  • 業務ルールの判定

  • 金額計算

  • データ変換

  • 入力検証

  • 集計処理

  • APIレスポンスの整形

  • 状態遷移

一方、データベース更新、HTTP通信、画面操作などには副作用があります。これらをなくすのではなく、純粋なロジックから分離して管理します。

実務では、オブジェクト指向と関数型を組み合わせることで、既存のC#資産を活用しながら、テストしやすく予測可能なコードを作れます。

9-5. 初心者はどこまで関数型を覚えればよいのか

初心者が最初に身につけたいのは、次の範囲です。

  • ラムダ式を読んで書ける

  • Funcの型引数を理解できる

  • LINQで抽出、変換、並べ替え、集計ができる

  • 入力から出力を返す小さな関数を作れる

  • 外部変数の変更を減らせる

  • record型とwith式を使える

  • switch式で値を返せる

  • 副作用のある処理を計算処理から分離できる

モナドや圏論などの高度な概念を、最初から学ぶ必要はありません。まずは、日常的なC#コードを少しずつ安全で読みやすい形へ改善することが重要です。

まとめ

C#は純粋な関数型言語ではありませんが、ラムダ式、Func、LINQ、record型、パターンマッチング、switch式などを活用することで、関数型プログラミングの考え方を実践できます。

C#で関数型プログラミングを始める際は、難しい理論を先に覚えるより、次の流れで学ぶと理解しやすくなります。

  1. ラムダ式とFuncで関数を値として扱う

  2. LINQでデータの抽出と変換に慣れる

  3. record型とwith式で状態変更を減らす

  4. switch式とパターンマッチングで分岐を整理する

  5. 計算処理を純粋関数として分離する

  6. DBアクセスやI/Oなどの副作用を外側に寄せる

重要なのは、すべてのコードを無理に関数型へ変えることではありません。状態変更によるバグを減らしたい部分、テストを簡単にしたい部分、データの流れを明確にしたい部分へ段階的に導入することが、C#で関数型プログラミングを活用する最も現実的な方法です。