Chapter 10

なぜmapやreduceやfilterなのか〜前編

とっくり
とっくり
2022.06.14に更新

関数型プログラミングといえばmap、reduceという風潮ありますね。forループをやめてmapやreduceや再帰を使えみたいな。

今回の記事では、関数型プログラミングの中でmap(やreduceやfilterなど例の有名な関数たち)が果たす役割について、僕なりに「こう位置づけてみたら学習意欲が多少湧くんじゃないか」という説明をしてみたいと思います。

あくまでも僕なりの説明です。関数型の偉い人は違うことを言うと思います。

学校で習う数学にはバグがない

中学で習う関数です。

f(x, y) = x^2 + 2xy + y^2  ("^2"は2乗)

という関数にたとえばx = 99, y = 201を代入した値を計算するとき

x^2 + 2xy + y^2
 = (x + y)^2
 = (99 + 201)^2
 = 90000

のように、式を変形してから代入するというテクニックが使えます。

もちろんこの式変形はxとyがどんな実数のときでも成り立ち、特定の値だとうまく行かない、なんてバグはありません。

割り算を含むような式では、「0で割るのは未定義」といったアサーション条件もきっちり定義されています。

数学で習ったたくさんの式たちは、どれをどう組み合わせてもバグがないのです。

プログラミングをしていて、たくさん作ったクラスやメソッドのどれをどう組み合わせてもバグがない状態なんて、ちょっと考えられませんよね。

バグの少ないプログラムを書きたい

こんなことを考えてみましょう。

バグのない関数の組み合わせだけで全部の処理が書けるだろうか?

「関数の組み合わせ」と言うのは、

関数Aの返り値を関数Bの引数として渡す

という意味です。四則演算もれっきとした関数です。Scalaなんかでは"+"とか"-"もちゃんと標準ライブラリのメソッドとして実装されています。

バグのない関数同士の組み合わせであれば、理論上バグがないはずです。

これが、「できるはず!書こう!」というのが関数型プログラミングのアプローチです。

FizzBuzzで考えてみる?

1から100までの整数をコンソールに出力する。
ただし、3の倍数の場合は"Fizz"、
5の倍数の場合は"Buzz"、
3と5両方の倍数の場合は"FizzBuzz"を、整数のかわりに出力する。

例のアレですね。ちょっとJavaで書いてみます。

public class FizzBuzz {
  public static void main(String[] args) {
    for (var i = 1; i <= 100; i++) {
      if (i % 15 == 0) {
        System.out.println("FizzBuzz");
      } else if (i % 3 == 0) {
        System.out.println("Fizz");
      } else if (i % 5 == 0) {
        System.out.println("Buzz");
      } else {
        System.out.println(i);
      }
    }
  }
}

はい。まあ実行してないけど多分バグはないでしょう。

しかし、バグがないことの保証はできてません。テストがないので。

純粋関数を切り出してみる

単体テストできるように純粋関数を切り出しましょう。

  1. 引数が3の倍数の場合は"Fizz"、5の倍数の場合は"Buzz"、3と5両方の倍数の場合は"FizzBuzz"、それ以外は整数の10進表現の文字列を返す関数 fizzBuzz(int) をつくる
  2. 1から100まで繰り返し(1)の引数に与えて呼んで、返り値をコンソールに出力する
public class FizzBuzz {
  static String fizzBuzz(int i) {
    if (i % 15 == 0) {
      return "FizzBuzz";
    } else if (i % 3 == 0) {
      return "Fizz";
    } else if (i % 5 == 0) {
      return "Buzz";
    } else {
      return "" + i;
    }
  }

  public static void main(String[] args) {
    for (var i = 0; i <= 100; i++) {
      System.out.println(fizzBuzz(i));
    }
  }
}

fizzBuzz(int)は純粋関数なので、簡単にテストが書けてバグがないことを保証できそうです。

import static FizzBuzz.*;
 :
assertEquals("1", fizzBuzz(1));
assertEquals("2", fizzBuzz(2));
assertEquals("Fizz", fizzBuzz(3));
assertEquals("4", fizzBuzz(4));
assertEquals("Buzz", fizzBuzz(5));
 :
assertEquals("FizzBuzz", fizzBuzz(15));
 :

/*
 * バグがないことを保証するテストの書き方については別の話になるので割愛。
 * まあ書けたと思いねえ。
 */

このくらい単純な話ならこれで十分じゃんかという気もしますが、上記(2)の部分は、テストができません。たとえばforループの i <= 100 のところを i < 100 にしてしまうとか、i++ を i-- にするなど間違いが入る可能性があるのに、テストできないのです。

(余談ですが古いプログラマは0との比較のほうが速いという理由で100から0まで--するループを書きがちで、若いプログラマが気づかずにハマることがあります。)

手続き型プログラミングの枠内では、ループのテストができません。

mapの出番!

Java8で、関数型プログラミングのコンセプトを持つStream APIが導入されました。このうち java.util.stream.IntStream を使うと、

m から n までの引数を繰り返し引数に与えてなにかの関数を呼んで、返り値をまた別の関数に渡す

ということができます。つまり、上記(2)のうちほとんどの部分が標準ライブラリの関数で実現できるのです。

これを使うとmain関数はこうなります。

public static void main(String[] args) {
  IntStream.rangeClosed(1, 100)   // <--- 1から100までを引数に・・・
    .mapToObj(FizzBuzz::fizzBuzz) // <--- fizzBuzz関数を呼んだ返り値を・・・
    .forEach(System.out::println);// <--- コンソール出力関数に渡す
}

これは、カッコよくなって嬉しい!のではなく、バグのないことが保証された関数の組み合わせだけになって嬉しい!のです。

System.out::printlnは副作用を持つ関数なので意図通り動くかどうかは実行環境に依存しますが、そこ以外はバグがないことが保証されています。

Javaさんが
「ループってよく使うわりにテストできないじゃん?だからループの代わりに使える仕組み、作ってテストしておいてあげたから!」
と言ってくれてるかのようです。ありがたく使わせてもらいましょう。

テスト!テスト!

さらに、Stream APIを使ったおかげで、テスト可能な部分が広がりました。

Stream<String> fizzBuzzStream(int from, int to) {
  return IntStream.rangeClosed(from, to)
    .mapToObj(FizzBuzz::fizzBuzz);
}

public static void main(String[] args) {
  fizzBuzzStream(1, 100).forEach(System.out::println);
}

テストコードはこんな風です。AssertJのcontainsが便利です。

var list = fizzBuzzStream(1, 100).collect(Collectors.toList());
assertThat(list).contains("1", "2", "Fizz", "4", "Buzz", "6",
               ...,"13", "14","FizzBuzz", "16",
               ...,"97", "98", "Fizz", "Buzz");

/*
 * 100まで律儀にテストする必要があるかどうかはまた別の、テストの作り方の話で。
 * t-wadaさんの本とか読むといいよ!
 */

これで、rangeClosedrangeを間違えるみたいな可能性もなくなり、バグはほぼないと保証できるようになりました。

メモリも安心

「仮にfizzBuzzStream(1, 10000000)とかやった場合に、すごく長い配列が作られてメモリの無駄になるのでは?」

という不安は感じなくて大丈夫です。IntStreamは内部に配列をもつわけではなく、イテレータでうまいことやってforループと同じように省メモリで実行するようになっています。collect(Collectors.toList()) というListに展開するメソッドがあり、これを呼ぶとガッとメモリを食って配列に展開します。

なんかモヤモヤする

ここまで読んで、なにかモヤモヤするものを感じないでしょうか。

  • 言うてもforループなんかで間違えなくない?
  • IntStream.rangeClosed 呼ぶとかってforループより面倒くさくない?
  • そのmapToObjとか用意されてるAPI全部覚えなきゃ使いこなせなくない?

ですよね。

実は一番書きたかったのはその話なのですが、長くなってきたので、次の記事にわけました。