【もうforはいらない】関数型コレクション操作をマスターしよう

manipulate-collections-using-functional-programming

プログラミングによく登場する、配列やコレクションの操作。forループで実装する人が多いでしょう。

実は、forを使わずにできる便利なコレクション操作方法があります。関数型プログラミングをベースとしたコレクション操作です。昨今では、たいていのプログラミング言語で提供されています。

関数型コレクション操作、初めはとっつきにくいかも知れません。でも慣れると、すっきりと分かりやすいエレガントなプログラムを書けます。

目次

まずはサンプルプログラム

関数型コレクション操作。まずは実際のコードでイメージを掴みましょう。Javaのサンプルを見てみます。

名前、年齢、年収、を持つPersonレコード。このコレクションを操作しましょう。

public record Person (
    String name,      // 名前
    int age,          // 年齢
    int annualIncome  // 年収
) {
}

forを使った普通のプログラム

「40歳以上の人の、平均年収を求める」プログラムを書きます。まずはforループで実装。

// 合計年収
double totalIncome = 0;
// カウント数
int count = 0;

// 人員のコレクションで繰り返し
for (Person person : persons) {
    // 40歳以上なら
    if (person.age() >= 40) {
        // 合計年収に追加
        totalIncome += person.annualIncome();
        // カウント増加
        count++;
    }
}

// 平均年収を求める
double averageIncome = totalIncome / count;

System.out.println("40歳以上の平均年収は: " + averageIncome + " 円です。");

普通のプログラムですね。

関数型コレクション操作のプログラム

これを、関数型コレクション操作で書き直してみます。

// 40歳以上の人の、平均年収を求める
OptionalDouble averageIncome = persons.stream()
    .filter(person -> person.age() >= 40)
    .mapToInt(Person::annualIncome)
    .average();

System.out.println("40歳以上の平均年収は: " + averageIncome.getAsDouble() + " 円です。");

すごくシンプルになりました。驚くべきことに、必要な計算部分は、たったの1step。(4行ありますが読みやすいよう改行しているだけなので実質1step)

しかし…forループのときと違う、不思議な書き方をしています。ドットでつながってたり、filterメソッドの中に条件式みたいなのがあったり。

一体何をしているのでしょうか?

コレクション操作とは何なのか?

まず、そもそもコレクション操作とは何なのか?をモデル化して考えます。

表に見立てたコレクション操作

Personレコードの配列、というと、こんな表に見立てられますね。

表のイメージ

横1行が、ひとつのPersonオブジェクト。これが6個分のコレクションになっているイメージ。

コレクション操作=この表を加工すること、にあたります。

この表から、「40歳以上の人の、平均年収」を求める手順を整理しましょう。

コレクションの3つの操作

まず、「40歳以上の人」を絞り込みます。

40歳以上の人を絞り込む

次に、「年収」の部分を取り出します。

年収の部分を取り出す

最後に、年収の平均を求めます。

年収の平均を求める

これで、「40歳以上の人の、平均年収」が求まりました。

この3つの操作を、それぞれ、filtermapreduceといいます。コレクション操作は、だいたいこの3パターンの操作で完結します。

操作内容
filter条件にあう行を絞り込む
map特定の列を取り出す
reduce列を集約して1つの値にする
コレクション操作

filterとmapは、イメージつきやすいですね。reduce(減らす)はなかなか不思議な命名。要素を減らして1つにする、といった意味合いがあります。

プログラミング言語ごとのコレクション操作方法

コレクション操作とは、filter、map、reduceの組み合わせで実現できるわけですが、プログラミング言語によって実現方法がずいぶん異なります。

いろいろな言語での実装方法を見ていきましょう。

Java

Java

StreamAPIが使えます。先ほど出てきたサンプルプログラムですね。

// 40歳以上の人の、平均年収
OptionalDouble average = persons.stream()
    .filter(person -> person.age() >= 40)
    .mapToInt(Person::annualIncome)
    .average();

C++

C++

STLのalgorithmヘッダが使えます。filterやmapといった単語が直接出てきませんが、似たような実装ができます。

#include <algorithm>
//...
std::vector<double> incomesOfPeopleOver40;

// 40歳以上の人の年収をコピー
std::copy_if(people.begin(), people.end(), std::back_inserter(incomesOver40),
    [](const Person& p) { return p.age >= 40; });
// 40歳以上の人の年収合計
double totalIncome = std::accumulate(incomesOver40.begin(), incomesOver40.end(), 0.0);
// 40歳以上の人の平均年収
double averageIncome = incomesOver40.size() != 0 ? totalIncome / incomesOver40.size() : 0;

C#

LINQが使えます。LINQは、WhereといったSQLに似た用語を使っていますね。

// 40歳以上の人の、平均年収
decimal averageIncome = persons
    .Where(p => p.Age >= 40)
    .Average(p => p.AnnualIncome);

Python

リスト内包表記が使えます。他の言語にはない、Python独特の書き方です。

# 40歳以上の人の年収を抽出
incomes_over_40 = [person.annualIncome for person in people if person.age >= 40]
# 平均年収を計算
average_income = sum(incomes_over_40) / len(incomes_over_40) if incomes_over_40 else 0

Pandasというデータ解析ライブラリを使うと、更に柔軟な書き方ができます。慣れは必要ですね。

import pandas as pd

data = {
    'name': ['A', 'B', 'C', 'D', 'E'],
    'age': [35, 42, 45, 38, 50],
    'annualIncome': [50000, 55000, 60000, 48000, 65000]
}
df = pd.DataFrame(data)

# 40歳以上の人の平均年収を計算
average_income_over_40 = df[df['age'] >= 40]['annualIncome'].mean()

JavaScript

配列に対して直接filter、map、reduceが使えます。スッキリとして分かりやすいですね。

const incomesOver40 = persons
    .filter(person => person.age >= 40)
    .map(person => person.annualIncome);

const totalIncome = incomesOver40.reduce((acc, income) => acc + income, 0);

const averageIncom = over40Incomes.length === 0 ? 0 : totalIncome / incomesOver40.length;

Kotlin

コレクションに対して直接filter、map、reduceが使えます

val totalIncomeOver40 = persons.filter { it.age >= 40 }.sumByDouble { it.annualIncome }
val countOfOver40 = persons.count { it.age >= 40 }

val averageIncome = if (countOfOver40 != 0) totalIncomeOver40 / countOfOver40 else 0.0

Swift

配列に対して直接filter、map、reduceが使えます

let personsOver40 = persons.filter { $0.age >= 40 }
let totalIncome = personsOver40.reduce(0) { $0 + $1.annualIncome }

let averageIncome: Double
if !personsOver40.isEmpty {
    averageIncome = totalIncome / Double(personsOver40.count)
} else {
    averageIncome = 0.0
}

Go

標準ではfilterやmapのような処理をサポートしていません。Goは、シンプルさと読みやすさを重視する言語なので、複雑な構文を避ける方向にあります。

go-funkといったライブラリを使って実装する手もありますが、ここではGoの思想に則り、紹介は省略します。

Rust

iterを使います。Rustは他の言語にない変わった書き方をします。

let (sum, count) = people.iter()
    .filter(|p| p.age >= 40)
    .fold((0.0, 0u32), |(sum, count), person| {
        (sum + person.annual_income, count + 1)
    });

if count > 0 {
    let average = sum / count as f64;
}

「関数型プログラミング」の何がおいしいの?

どの言語も、ちょっと不思議な書き方ですね。これらいずれも「関数型プログラミング」がベースとなっています。

では、そもそも「関数型」とは何なのか?なにがおいしいのでしょう?

関数に関数を入力するのが「関数型」

関数型とは何なのか。先ほどのJavaの例でいうと、注目すべきはこの部分。

.filter(person -> person.age() >= 40)

.filter()はメソッドっぽい。ではperson -> person.age() >= 40は?

これ実は、

  • person を入力として
  • person.age() >= 40 の結果(true or false)を返す

という関数を意味します。「40歳以上であるかを判定」する関数ですね。filterというメソッドに、「40歳以上であるかを判定」関数を入力する。という構造なのです。

このような、関数の引数に、関数を渡せる仕掛けを「関数型」と呼びます。

※関数を渡せるような関数のことを「高階関数」と呼びます。

関心ごとをうまく分離する

「40歳以上の人」のように、なにかの条件で絞り込む処理。これは2つの関心ごとに分解できます。

  1. 「何かの判定」をもとに、コレクションのデータを絞り込む
  2. 絞り込むための「判定条件」

コレクションを絞り込む条件は、場合によって「40歳以上」「年収1000万以上」など様々です。しかし「コレクションを絞り込む」という操作自体は変わりません。

ならば、

  • コレクション絞り込み処理は、あらかじめ言語側で準備する
  • 実装時は、判定条件の関数だけ作る

とすれば、新しく作るのは2番目の「判定条件の関数」だけでよいわけです。関数型プログラミングは、この2つの関心ごとをうまく分離することができます。

関数型は「宣言型」

さて、前述の例だと、

// 40歳以上の人の、平均年収
OptionalDouble average = persons.stream()
    .filter(person -> person.age() >= 40)
    .mapToInt(Person::annualIncome)
    .average();

必要な計算が、たった1stepで実現できるのは。コレクションを絞り込むとか、列を選択するとかはもう実装されていて、あとは条件を渡すだけ、で済むからですね。

そして、1stepで済むということはつまり、データを与えた時点で、欲しい結果が決定するわけです。

達成までの手続きを記述するのでなく、達成の目的を宣言する。このようなプログラミングを宣言型プログラミングといいます。

目的を宣言するという書き方により、やることが明解になり、また手続きを書く中で誤ることも少なくなるわけです。

データベース操作と似ている

この関数型コレクション操作、「表のようなイメージ」「絞り込む」「取り出す」といった時点で、こう感じた人もいるでしょう。

これって、データベースと同じなんじゃ…?

その通り、関数型コレクション操作は、SQLのSELECTと、やりたいことは同じです。

データベースに慣れ親しんだ人なら、関数型コレクション操作も違和感ないでしょう。SQLも宣言型言語ですしね。

データベースに慣れ親しんでいない人なら、SQLを少し学んでみましょう。宣言型言語の雰囲気が掴めます。

関数型プログラミングの注意点

すっきり書ける関数型コレクション操作。ただし使う上では注意点があります。

「参照透過性」が成り立つようにすること

参照透過性とは、同じ入力が与えられれば出力も必ず同じになるという性質のことです。関数型プログラミングでは重要となる概念。

年齢を入力して、40歳以上か判定する」。これは参照透過性があります。38歳なら必ずfalseと決まりますね。これは関数型の入力に使えます。

年齢を入力して、40歳以上か判定する(ただし10人まで)」。これは参照透過性がありません。ループの途中で条件が変わるからですね。

つまり、forループの途中で状態が変わるような場合は、関数型は使えません

このような場合、おとなしくforループ使う手もあります。

でも、コレクションの並び順に依存するような条件を考えているなら、たぶんその条件を見直したほうがよいです。

エラー可能性のある関数を入力しないこと

関数型コレクション操作のような宣言型操作に、エラーを起こす可能性のある関数を使ってはいけません

例えば、ファイル読み込み、ネットワーク通信。こういった失敗可能性のある処理を、関数に入れないようにしましょう。

まとめ

近年のプログラミング言語技術は、関数型や宣言型のエッセンスを含むものが多くあります。

関数型コレクション操作は、関数型プログラミングのよい教材となります。積極的に活用して、シンプルでエレガントなコードを書きましょう。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次