Skip to content

Part II: 関数型スタイルのプログラミング

本章では、関数型プログラミングの核心となるテクニックを学びます。イミュータブルなデータ操作、高階関数、そして Bind(flatMap 相当)による複雑なデータ変換を習得します。


第3章: イミュータブルなデータ操作

3.1 イミュータブルとは

イミュータブル(不変)とは、一度作成されたデータが変更されないことを意味します。データを「変更」する代わりに、新しいデータを「作成」します。

uml diagram

3.2 Seq の基本操作

ソースファイル: app/csharp/src/Ch03/ImmutableOperations.cs

要素の追加

LanguageExt の Seq<T> はデフォルトでイミュータブルです。要素を追加すると新しい Seq が作成されます。

using LanguageExt;
using static LanguageExt.Prelude;

var appleBook = Seq("Apple", "Book");
var appleBookMango = appleBook.Add("Mango");

// 元の Seq は変わらない
Debug.Assert(appleBook.Count == 2);
// 新しい Seq が作成される
Debug.Assert(appleBookMango.Count == 3);
Debug.Assert(appleBookMango.SequenceEqual(Seq("Apple", "Book", "Mango")));

スライス操作

/// <summary>
/// Seq の先頭 n 個の要素を取得
/// </summary>
public static Seq<T> FirstN<T>(Seq<T> seq, int n) =>
    seq.Take(n);

/// <summary>
/// Seq の末尾 n 個の要素を取得
/// </summary>
public static Seq<T> LastN<T>(Seq<T> seq, int n)
{
    var skipCount = Math.Max(0, seq.Count - n);
    return seq.Skip(skipCount);
}

/// <summary>
/// Seq の最初の 2 要素を取得
/// </summary>
public static Seq<T> FirstTwo<T>(Seq<T> seq) =>
    FirstN(seq, 2);

/// <summary>
/// Seq の最後の 2 要素を取得
/// </summary>
public static Seq<T> LastTwo<T>(Seq<T> seq) =>
    LastN(seq, 2);

uml diagram

3.3 Seq の変換例

/// <summary>
/// 最初の 2 要素を末尾に移動
/// </summary>
public static Seq<T> MoveFirstTwoToEnd<T>(Seq<T> seq)
{
    var first = FirstTwo(seq);
    var rest = seq.Skip(2);
    return rest.Concat(first);
}

// 使用例
Debug.Assert(MoveFirstTwoToEnd(Seq("a", "b", "c")).SequenceEqual(Seq("c", "a", "b")));

/// <summary>
/// 最後の要素の前に新しい要素を挿入
/// </summary>
public static Seq<T> InsertBeforeLast<T>(Seq<T> seq, T element)
{
    var withoutLast = seq.Take(seq.Count - 1);
    var last = LastN(seq, 1);
    return withoutLast.Add(element).Concat(last);
}

Debug.Assert(InsertBeforeLast(Seq("a", "b"), "c").SequenceEqual(Seq("a", "c", "b")));

3.4 旅程の再計画

旅行の計画変更をイミュータブルに行う例です。

/// <summary>
/// 指定した都市の前に新しい都市を挿入
/// </summary>
public static Seq<string> Replan(Seq<string> plan, string newCity, string beforeCity)
{
    var index = plan.ToList().FindIndex(c => c == beforeCity);
    if (index < 0) index = plan.Count;

    var citiesBefore = plan.Take(index);
    var citiesAfter = plan.Skip(index);
    return citiesBefore.Add(newCity).Concat(citiesAfter);
}

var planA = Seq("Paris", "Berlin", "Kraków");
var planB = Replan(planA, "Vienna", "Kraków");

Debug.Assert(planB.SequenceEqual(Seq("Paris", "Berlin", "Vienna", "Kraków")));
Debug.Assert(planA.SequenceEqual(Seq("Paris", "Berlin", "Kraków"))); // 元の計画は変わらない!

uml diagram

3.5 String と Seq の類似性

String と Seq は似た操作ができます。

操作 C# Seq C# String
結合 Concat または Add + または String.Concat
切り出し Skip, Take Substring
サイズ Count Length
// Seq の操作
var abSeq = Seq("a", "b");
var cdSeq = Seq("c", "d");
var abcdSeq = abSeq.Concat(cdSeq);
Debug.Assert(abcdSeq.SequenceEqual(Seq("a", "b", "c", "d")));

// String の操作
var abStr = "ab";
var cdStr = "cd";
var abcdStr = abStr + cdStr;
Debug.Assert(abcdStr == "abcd");

3.6 名前の省略

/// <summary>
/// 名前を省略形に変換(例: "Alonzo Church" -> "A. Church")
/// </summary>
public static string Abbreviate(string name)
{
    var initial = name.Substring(0, 1);
    var separatorIndex = name.IndexOf(' ');
    if (separatorIndex < 0)
        return name;

    var lastName = name.Substring(separatorIndex + 1);
    return $"{initial}. {lastName}";
}

Debug.Assert(Abbreviate("Alonzo Church") == "A. Church");
Debug.Assert(Abbreviate("A. Church") == "A. Church");

第4章: 関数を値として扱う

4.1 高階関数とは

高階関数(Higher-Order Function)とは、以下のいずれかを満たす関数です:

  1. 関数を引数として受け取る
  2. 関数を戻り値として返す

uml diagram

4.2 関数を引数として渡す

ソースファイル: app/csharp/src/Ch04/HigherOrderFunctions.cs

OrderBy - ソート基準を関数で指定

public static int WordScore(string word) =>
    word.Replace("a", "").Length;

var words = Seq("rust", "java");
var sortedWords = toSeq(words.OrderBy(WordScore));

Debug.Assert(sortedWords.SequenceEqual(Seq("java", "rust")));
// java: 2文字 (j, v), rust: 4文字 (r, u, s, t)

uml diagram

Map - 各要素を変換

public static int Len(string s) => s.Length;

var lengths = Seq("scala", "rust", "ada").Map(Len);
Debug.Assert(lengths.SequenceEqual(Seq(5, 4, 3)));

public static int Double(int i) => i * 2;
var doubles = Seq(5, 1, 2, 4, 0).Map(Double);
Debug.Assert(doubles.SequenceEqual(Seq(10, 2, 4, 8, 0)));

uml diagram

Filter - 条件に合う要素を抽出

public static bool IsOdd(int i) => i % 2 == 1;

var odds = Seq(5, 1, 2, 4, 0).Filter(IsOdd);
Debug.Assert(odds.SequenceEqual(Seq(5, 1)));

var large = Seq(5, 1, 2, 4, 0).Filter(i => i > 4);
Debug.Assert(large.SequenceEqual(Seq(5)));

Fold - 畳み込み

var sum = Seq(5, 1, 2, 4, 100).Fold(0, (acc, i) => acc + i);
Debug.Assert(sum == 112);

var maximum = Seq(5, 1, 2, 4, 15).Fold(int.MinValue, (maxVal, i) =>
    i > maxVal ? i : maxVal);
Debug.Assert(maximum == 15);

uml diagram

4.3 レコード型とパターン

ソースファイル: app/csharp/src/Ch04/HigherOrderFunctions.cs

/// <summary>
/// プログラミング言語を表すレコード型
/// </summary>
public record ProgrammingLanguage(string Name, int Year);

var javaLang = new ProgrammingLanguage("Java", 1995);
var scalaLang = new ProgrammingLanguage("Scala", 2004);

var languages = Seq(javaLang, scalaLang);

// フィールドにアクセス
var names = languages.Map(lang => lang.Name);
Debug.Assert(names.SequenceEqual(Seq("Java", "Scala")));

// 条件でフィルタ
var young = languages.Filter(lang => lang.Year > 2000);
Debug.Assert(young.SequenceEqual(Seq(scalaLang)));

4.4 関数を返す関数

/// <summary>
/// n より大きいかを判定する関数を返す
/// </summary>
public static Func<int, bool> LargerThan(int n) =>
    i => i > n;

var large = Seq(5, 1, 2, 4, 0).Filter(LargerThan(4));
Debug.Assert(large.SequenceEqual(Seq(5)));

var medium = Seq(5, 1, 2, 4, 0).Filter(LargerThan(1));
Debug.Assert(medium.SequenceEqual(Seq(5, 2, 4)));

uml diagram

4.5 カリー化と部分適用

LanguageExt の curry 関数を使ってカリー化できます。

using static LanguageExt.Prelude;

// カリー化された加算関数
public static Func<int, Func<int, int>> AddCurried =>
    curry<int, int, int>((a, b) => a + b);

// 部分適用: 5を足す関数
public static Func<int, int> AddFive => AddCurried(5);

Debug.Assert(AddFive(3) == 8);
Debug.Assert(AddFive(10) == 15);

uml diagram

4.6 ワードスコアリングの例

複数のスコアリングロジックを組み合わせる例です。

public static int WordScore(string word) => word.Replace("a", "").Length;
public static int Bonus(string word) => word.Contains('c') ? 5 : 0;
public static int Penalty(string word) => word.Contains('s') ? 7 : 0;

var words = Seq("ada", "haskell", "scala", "java", "rust");

/// <summary>
/// 指定したスコア関数でワードをランキング
/// </summary>
public static Seq<string> RankedWords(Seq<string> words, Func<string, int> scoreFn) =>
    toSeq(words.OrderByDescending(scoreFn));

// 基本スコアでランキング
var ranking1 = RankedWords(words, WordScore);
Debug.Assert(ranking1.Head == "haskell");

// ボーナス付きスコアでランキング
var ranking2 = RankedWords(words, w => WordScore(w) + Bonus(w));
// scala: 3 + 5 = 8, haskell: 7 + 0 = 7
Debug.Assert(ranking2.Head == "scala");

// ボーナスとペナルティ付きスコアでランキング
var ranking3 = RankedWords(words, w => WordScore(w) + Bonus(w) - Penalty(w));
// java: 2, scala: 1, ada: 0, haskell: 0, rust: -3
Debug.Assert(ranking3.Head == "java");

第5章: Bind とネスト構造

5.1 Flatten と Bind

ソースファイル: app/csharp/src/Ch05/FlatMapOperations.cs

Bind - ネストした Seq を平坦化

public record Book(string Title, Seq<string> Authors);

var books = Seq(
    new Book("FP in Scala", Seq("Chiusano", "Bjarnason")),
    new Book("The Hobbit", Seq("Tolkien"))
);

// Map だけだとネストする
var authorSeqs = books.Map(b => b.Authors);
// Seq<Seq<string>>: [ ["Chiusano", "Bjarnason"], ["Tolkien"] ]

// Bind で平坦化しながら変換
var authors = books.Bind(b => b.Authors);
// Seq<string>: ["Chiusano", "Bjarnason", "Tolkien"]

uml diagram

Bind = Map + Flatten

C# の LanguageExt における Bind は Scala の flatMap に相当します。

// Map して Flatten
var authors1 = books.Map(b => b.Authors).Bind(x => x);

// Bind で同じことを1行で
var authors2 = books.Bind(b => b.Authors);

Debug.Assert(authors1.SequenceEqual(authors2));
// ["Chiusano", "Bjarnason", "Tolkien"]

5.2 Bind によるリストサイズの変化

// 要素数が増える
var duplicate = Seq(1, 2, 3).Bind(i => Seq(i, i + 10));
// [1, 11, 2, 12, 3, 13] - 6要素

// 要素数が同じ
var doubled = Seq(1, 2, 3).Bind(i => Seq(i * 2));
// [2, 4, 6] - 3要素

// 要素数が減る(フィルタリング効果)
var evens = Seq(1, 2, 3).Bind(i =>
    i % 2 == 0 ? Seq(i) : Empty);
// [2] - 1要素

uml diagram

5.3 ネストした Bind

複数の Seq を組み合わせる場合、Bind をネストします。

public record Movie(string Title);

public static Seq<Movie> BookAdaptations(string author) =>
    author == "Tolkien"
        ? Seq(new Movie("An Unexpected Journey"), new Movie("The Desolation of Smaug"))
        : Empty;

var books = Seq(
    new Book("FP in Scala", Seq("Chiusano", "Bjarnason")),
    new Book("The Hobbit", Seq("Tolkien"))
);

// ネストした Bind
var recommendations = books.Bind(book =>
    book.Authors.Bind(author =>
        BookAdaptations(author).Map(movie =>
            $"You may like {movie.Title}, because you liked {author}'s {book.Title}")));

// 結果:
// [ "You may like An Unexpected Journey, because you liked Tolkien's The Hobbit"
//   "You may like The Desolation of Smaug, because you liked Tolkien's The Hobbit" ]

uml diagram

5.4 LINQ クエリ式

C# の LINQ クエリ式は、ネストした Bind を読みやすく書けます。

// Bind/Map 版
var c1 = books.Bind(book =>
    book.Authors.Bind(author =>
        BookAdaptations(author).Map(movie =>
            $"You may like {movie.Title}, because you liked {author}'s {book.Title}")));

// LINQ クエリ式版(等価)
var c2 = (from book in books
          from author in book.Authors
          from movie in BookAdaptations(author)
          select $"You may like {movie.Title}, because you liked {author}'s {book.Title}").ToSeq();

Debug.Assert(c1.SequenceEqual(c2));

uml diagram

5.5 円内の点の判定

LINQ クエリ式でフィルタリングも行う例です。

public record Point(int X, int Y);

var points = Seq(new Point(5, 2), new Point(1, 1));
var radiuses = Seq(2, 1);

public static bool IsInside(Point point, int radius) =>
    radius * radius >= point.X * point.X + point.Y * point.Y;

// 全組み合わせを生成
var allCombinations = (from r in radiuses
                       from point in points
                       select $"Point({point.X},{point.Y}) is within a radius of {r}: {IsInside(point, r)}").ToSeq();

// 結果:
// [ "Point(5,2) is within a radius of 2: false"
//   "Point(1,1) is within a radius of 2: true"
//   "Point(5,2) is within a radius of 1: false"
//   "Point(1,1) is within a radius of 1: false" ]

where によるフィルタリング

// where を使ったフィルタリング
var insidePoints = (from r in radiuses
                    from point in points
                    where IsInside(point, r)
                    select $"Point({point.X},{point.Y}) is within a radius of {r}").ToSeq();

// 結果: [ "Point(1,1) is within a radius of 2" ]

uml diagram

5.6 LINQ の柔軟性

C# の LINQ クエリ式は、SeqIEnumerable、その他の LINQ 対応型で使えます。

// Seq を使った LINQ
var seqResult = (from a in Seq(1, 2)
                 from b in Seq(2, 1)
                 select a * b).ToSeq();
// [2, 1, 4, 2] - 重複あり、順序あり

// HashSet への変換
var hashSetResult = new System.Collections.Generic.HashSet<int>(seqResult);
// {1, 2, 4} - 重複なし

まとめ

Part II で学んだこと

uml diagram

キーポイント

主要概念 キー操作
第3章 イミュータブル Concat, Add, Skip, Take
第4章 高階関数 Map, Filter, Fold, OrderBy
第5章 平坦化 Bind, LINQ クエリ式

C#/LanguageExt と Scala/F# の対応

操作 C# (LanguageExt) Scala F#
平坦化 Bind(x => x) flatten List.concat
flatMap Bind flatMap List.collect
内包表記 LINQ from ... select for { x <- xs } yield ... [ for x in xs do ... ]
畳み込み Fold foldLeft List.fold
フィルタ Filter filter List.filter

重要な法則

  1. イミュータブルデータ: 元のデータは変更せず、新しいデータを作成する
  2. 関数は値: 関数を引数として渡したり、戻り値として返したりできる
  3. Bind パターン: ネストした構造を平坦化しながら変換する
  4. LINQ クエリ式: Bind/Map の糖衣構文として使える
  5. メソッドチェーン: . で関数を連鎖させて読みやすくする

次のステップ

Part III では、以下のトピックを学びます:

  • Option<T> 型による安全なエラーハンドリング
  • Either<L, R> / Result 型と複合的なエラー処理

演習問題

問題 1: イミュータブルな操作

以下の関数を実装してください。Seq の中央に要素を挿入する関数です。

public static Seq<T> InsertAtMiddle<T>(Seq<T> seq, T element) => ???

// 期待される動作
Debug.Assert(InsertAtMiddle(Seq("a", "b", "c", "d"), "X").SequenceEqual(Seq("a", "b", "X", "c", "d")));
Debug.Assert(InsertAtMiddle(Seq("a", "b"), "X").SequenceEqual(Seq("a", "X", "b")));
解答
public static Seq<T> InsertAtMiddle<T>(Seq<T> seq, T element)
{
    var middle = seq.Count / 2;
    var before = seq.Take(middle);
    var after = seq.Skip(middle);
    return before.Add(element).Concat(after);
}

問題 2: 高階関数

以下の関数を実装してください。条件を満たす要素の数をカウントする関数です。

public static int CountWhere<T>(Seq<T> seq, Func<T, bool> predicate) => ???

// 期待される動作
Debug.Assert(CountWhere(Seq(1, 2, 3, 4, 5), i => i > 3) == 2);
Debug.Assert(CountWhere(Seq("a", "bb", "ccc"), s => s.Length > 1) == 2);
解答
public static int CountWhere<T>(Seq<T> seq, Func<T, bool> predicate) =>
    seq.Filter(predicate).Count;

// または Fold を使って
public static int CountWhere2<T>(Seq<T> seq, Func<T, bool> predicate) =>
    seq.Fold(0, (count, elem) => predicate(elem) ? count + 1 : count);

問題 3: LINQ クエリ式

以下のネストした Bind を LINQ クエリ式で書き換えてください。

var result = Seq(1, 2).Bind(x =>
    Seq(10, 20).Bind(y =>
        Seq(100, 200).Map(z =>
            x + y + z)));
解答
var result = (from x in Seq(1, 2)
              from y in Seq(10, 20)
              from z in Seq(100, 200)
              select x + y + z).ToSeq();

// 結果: [111, 211, 121, 221, 112, 212, 122, 222]

問題 4: Bind によるフィルタリング

LINQ クエリ式の where を使わずに、Bind だけで偶数のみを抽出するコードを書いてください。

var numbers = Seq(1, 2, 3, 4, 5, 6);
// 偶数のみを抽出: Seq(2, 4, 6)
解答
var evenNumbers = numbers.Bind(n =>
    n % 2 == 0 ? Seq(n) : Empty);

// または関数に分離
Seq<int> EvenFilter(int n) =>
    n % 2 == 0 ? Seq(n) : Empty;

var evenNumbers2 = numbers.Bind(EvenFilter);

問題 5: メソッドチェーンで書き換え

以下のコードをメソッドチェーンを使って書き換えてください。

var result = CountWhere(
    Filter(
        Map(Seq(1, -2, 3, -4, 5), x => x * 2),
        x => x > 0),
    x => true);
解答
var result = Seq(1, -2, 3, -4, 5)
    .Map(x => x * 2)
    .Filter(x => x > 0)
    .Count;

// 結果: 3 (2, 6, 10 の3つ)
メソッドチェーンを使うと、データの変換過程が上から下に読めるようになります。