⚙️

『なっとく! 関数型プログラミング』に学ぶ関数型プログラミング

に公開
1

サマリー

関数型プログラミングの入門書『なっとく!関数型プログラミング』の内容をもとに、関数型プログラミングについて考えます。中でも本記事では、三部構成の本書のうち第一部(Part1)の内容を取り上げ、「関数型プログラミングとはイミュータブルな値を操作する純粋関数を使うプログラミングである」という著者の主張を読み解きます。

『なっとく!関数型プログラミング』とは

翔泳社から出版されているこちらの書籍です。関数型プログラミングの分厚い入門書として評価の高い本です。この記事では本書の内容を中心に関数型プログラミングについて概観していきます。想定読者は関数型プログラミングに初めて体系的に触れる/触れようとしているプログラマです。
https://amzn.asia/d/eXxZULw

関数型プログラミングって?

それでは始めていきます。まずは定義から。関数型プログラミングについて、著者のMichat Ptachta(ミハエル・プワッチャ)はこう述べています。

関数型プログラミングとはイミュータブルな値を操作する純粋関数を使うプログラミングである

「イミュータブルな値」も「純粋関数」もなんのことやらさっぱりです。順番に著者の理路を辿っていきましょう。

関数

本書で著者は、関数とは何かから話を始めます。個人的に目を開かされる内容だったので、いきなりですが寄り道して関数について書きます。

シグネチャと本体

関数にはさまざまなものがあり得ますが、ざっくりと共通点として「入力として何らかの値を受け取り、何かを実行し、おそらく出力として値を返す」ものと言えます。

そうした中で、著者が強調するのが「シグネチャ」という概念です。

Javaを使った例で説明します。例えばこうです。

public static int add(int a, int b){ // シグネチャ
    return a + b; // 本体(シグネチャの実装)
}

英単語のシグネチャ(signature)には、署名やサイン、「行動や外観の特徴」という意味があります。関数名や引数とその型、返り値とその型といった公開されている情報 = 「シグネチャ」は、関数本体の振る舞いを示すものだというわけです。

コードが嘘をつくとき

その上で著者はこう言います。「プログラマが直面する最も難しい問題のいくつかは、コードが想定外の何かを行ったときに発生する。こうした問題は、シグネチャと本体の内容の食い違いに関連していることが多い。」どういうことでしょうか。

著者が実際に示しているコードの一部を例示します。

public static int add(int a, int b){
    return a + b;
}

public static char getFirstCharacter(String s){
    return s.charAt(0);
}

public static int divide(int a, int b){
    return a / b;
}

このうちシグネチャが嘘をつかないのは最初の1つだけです。その他は空のStringや0除算によって例外を返してしまいます。常にシグネチャと本体を一致させ、「嘘をつかない」関数のみでプログラムを構成すること、それが本書の焦点です。

命令型と宣言型

このまま次に進んでも良いのですが、本書に繰り返し登場するもう一つの概念セットについても触れておきます。命令型と宣言型です。

ここでも著者のコードで説明します。単語を引数にとり、その文字数をスコアとして返す単語ゲーム用関数を考えます。命令型で記述すると次のようになります。

public static int calculateScore(String word){
    int score = 0;
    for(char c: word.toCharArray()){
        score++
    }
    return score;
}

命令型プログラミングが焦点を合わせるのは、結果をどのように計算すべきか、すなわち特定の順序で定義された特定の手順──アルゴリズム──です。

これに対し、宣言型で記述された関数は次のようになります。

public static int wordScore(String word){
    return word.length()
}

宣言型プログラミングが焦点を合わせるのは、「どのように行うかではなく、何を行う必要があるか」です。文字数のカウントが、どのような手続きで行われるのかについてはどうでもよい(単にlengthを使えばいい)、という立場と言い換えられます。

著者はまた、関数名がcalsulateScoreからwordScoreに変わったこと、つまり動詞から名詞にかわったことについても強調します。JVMやCPUといったプログラムにおける命令型の内部構造を、宣言型のアプローチによって「隠蔽」しているのだ、というのがその主張です。この点はさらに掘り下げたいところですが、テーマから逸れるため別稿にゆずることにします。

純粋関数

いよいよ関数型プログラミングの定義を構成するひとつ目の概念、「純粋関数」の話です。

著者は純粋関数について、以下の3つのルールを満たすものである、とします。

  1. 関数の戻り値は常に1つだけ。
  2. 関数はその引数にのみ基づいて戻り値を計算する。
  3. 関数は既存の値を変更しない。

こうした純粋関数の威力について説明するために、またしても著者のコードを引用します

命令型のコーディング

今回は少し込み入った要件を実装します。課題は「現在のカートの内容に基づいて割引率を計算できる『ショッピングカート機能』を作成すること」です。

ショッピングカート機能の要件は以下です。

  1. カードには(Stringとしてモデル化された)どのアイテムでも追加できる。
  2. カートに本が追加されている場合、割引率は5%である。
  3. カートに本が追加されていない場合、割引率は0%%である。
  4. カート内のアイテムにはいつでもアクセスできる。

これをJavaのクラスをつかって実装したのが以下です。

public class ShoppingCart {
    private List<String> items = new ArrayList<>();
    private boolean bookAdded = false;

    public void addItem(String item) {
        items.add(item);
        if(item.equals("Book")){
            bookAdded = true;
        }
    }

    public int getDiscountPercentage() {
        if(bookAdded) {
            return 5;
        } else {
            return 0;
        }
    }

    public List<String> getItems() {
        return items;
    }
}

よさそうなコードですが、特定の手順で操作すると意図しない動作をするバグが潜んでいます。

値を返さない関数、既存の値の変更、引数以外のものを使った戻り値の計算がおこなわれており、これらをすべて頭に入れた上でこの関数を扱うのは非常に困難です。

関数型のコーディング

詳細は書籍を見てもらうとして、著者が関数型に書き換えたのが以下のコードです。

public class ShoppingCart {
    public static int getDiscountPercentage(List<String> items) {
        if(items.contains("Book")) {
            return 5;
        } else {
            return 0;
        }
    }
}

きれいさっぱりコードが刈り込まれています。itemsとbookAddedというふたつの状態(時間と共に変化する値)は消えました。使い方はこうです。

List<String> items = new ArrayList<>();
items.add("Apple");
System.out.println(ShoppingCart.getDiscountPercentage(items));
→ 0

items.add("Book");
System.out.println(ShoppingCart.getDiscountPercentage(items));
→ 5

状態の管理はArrayListクラスに移譲されており、ShoppingCartはシグネチャに書いてあることの他には特に何もしません。

数学とプログラミングにおける関数のちがい──大いなる力には大いなる責任が伴う

ここで著者は数学とプログラミングにおける関数を比較することも行なっています。著者によれば、数学関数は常に純粋関数です。たとえばf(x) = x * 95 / 100という関数は、「常に値を1つだけ返す」「引数にのみ基づいて戻り値を計算する」「既存の値を変更しない」の3つの特徴をそなえています。

これに対しプログラミングにおける関数は、ほとんどの主流言語で、「ほぼすべての場所で、何かを変更したり追加したり」することが可能です。これはプログラミングにおける関数が数学関数に劣っているというより、「プログラミング言語がたいてい数学よりもはるかに強力で弾力性がある」からと言えます。

しかしながら、「大いなる力には大いなる責任が伴う」と著者が述べる通り、この強力さを野放しにするとかえって逆効果になる──ソフトウェアが管理不可能に成長してしまう──ことがあります。したがって、関数型でプログラミングしようとするプログラマは、関数を数学関数に近づける努力が求められる、というわけです。

イミュータブルな値

それでは関数型プログラミングの定義を構成するもうひとつの概念、「イミュータブルな値」を見ていきたいと思います。著者に言わせれば「純粋関数とイミュータブルな値は、この2つの概念だけで関数型プログラミングを定義できるほど強く結びついて」います。

ミュータブルな値

ミュータブル(mutable)とは、「変わりやすい、すぐに変わる」といった意味の英単語です。これに否定の接頭辞がついたイミュータブル(immutable)には、「変更不可能な、不変の」といった意味があります。

先ほどの意図しない挙動を引き起こすShoppingCartのコードには、ミュータブルな状態がふたつ(items, bookAdded)ありました。関数型のアプローチに変更する過程で、著者はこのふたつをクラスから追い出しました。

可変性は危険である

ミュータブルな値には危険性があると著者はいいます。その具体的な例が以下のコードです

static List<string> replan(List<String> plan, String newCity, String beforeCity) {
    int newCityIndex = plan.indexOf(beforeCity);
    plan.add(newCityIndex, newCity);
    return plan;
}

これは、都市の名称をならべたリストの任意の場所の前に追加の旅行先を挿入した結果のリストを返す関数です。一見問題なさそうに見えますが、これにもシグネチャの嘘があります。引数に渡したリストの内容も変更してしまうのです。

System.out.println("Plan A: + planA);
→ Plan A: [Paris, Berlin, Kraków]

List<String> planB = replan(planA, "Vienna", "Kraków");
→ Plan B: [Paris, Berlin, Vienna, Kraków]

System.out.println("Plan A: + planA);
→ Plan A: [Paris, Berlin, Vienna, Kraków]

コピーを使って可変性に対抗する

コピーを使うことで、既存の値を変更してしまう問題に対抗することができます。例えばreplan関数を次のように変更します。

static List<string> replan(List<String> plan, String newCity, String beforeCity) {
    int newCityIndex = plan.indexOf(beforeCity);
    List<String> replanned = new ArrayList<>(plan) // コピーを作成
    replanned.add(newCityIndex, newCity); // コピーの値を操作
    return replanned; // コピーを返す
}

これで可変性の罠は避けられました。関数が「既存の値を変更しない」ようになったことで、純粋関数に変わったのです。

System.out.println("Plan A: + planA);
→ Plan A: [Paris, Berlin, Kraków]

List<String> planB = replan(planA, "Vienna", "Kraków");
→ Plan B: [Paris, Berlin, Vienna, Kraków]

System.out.println("Plan A: + planA);
→ Plan A: [Paris, Berlin, Kraków]

イミュータブルな値を使う

さらにここで登場するのが関数型言語であるScalaです。ScalaのListはイミュータブルなAPIを備えています。つまり、Listにどのような操作を行なっても、元の値は変更されないということです。

これを踏まえて先ほどのreplan関数をScalaで書き直したのが以下のコードになります。

def replan(plan: List[String], newCity: String, beforeCity: String): List[String] = {
    val beforeCityIndex = plan.indexOf(beforeCity)
    val citiesBefore = plan.slice(0, beforeCityIndex)
    val citiesAfter = plan.slice(beforeCityInde, plan.size)
    citiesBefore.appended(newCity).appendedAll(citiesAfter) // Scalaではメソッド本体にある最後の式がメソッドの戻り値になる
}

最終出力のあともplanの値は変わらないことが保証されているので、引数planの値を好きなように操作しています。値自体がミュータブルであることによって、より安全に純粋関数を組むことができるのです。

まとめ

関数型プログラミングとはイミュータブルな値を操作する純粋関数を使うプログラミングである

以上、著者による関数の説明に始まり、「純粋関数」と「イミュータブルな値」について見てきました。純粋関数とイミュータブルな値は相互に補完し合う関係にあり、「常に値を1つだけ返す」「引数にのみ基づいて戻り値を計算する」「既存の値を変更しない」という(著者の言う)純粋関数の三つの条件を満たす上で、イミュータブルな値はある種の前提条件になります。Scalaのような関数型言語にはイミュータブルな結果を保証するAPIが豊富に存在し、関数型プログラミングを強力にサポートしてくれます。

しかし、関数型のアプローチは関数型言語でなければ使えないわけではありません。関数型プログラミングのためのAPIは多かれ少なかれ主要言語の多くでサポートされていますし、さらに言えばそれは、普段のプログラミングにも十分取り入れられる「思考法」に近いものでもあります。ぜひ命令型ではないプログラミングの奥深さに挑戦してみてください。

最後に。本書は骨太な良い本なのでぜひ読んでみてください!
https://amzn.asia/d/eXxZULw

Discussion