補講A モダンな書き方

ここに記した内容は,応用であり,実践しなければならないものではありません. 特に,この授業内では,ここに記す書き方をしたからといって加点するわけではありませんし, 実践しなかったからといって減点することもありません.

ただし,近年のプログラム(Javaだけでなく,PythonやJavaScriptなど)には,似たような書き方や考え方が導入されています. モダンなライブラリなどを利用する場合や,ソースコードを読む場合には,これらの書き方に習熟しておく必要があります. そのため,プログラムが得意な人は以下のような書き方に挑戦してみると良いでしょう.

この講で利用するプログラム

補講A モダンな書き方のサブセクション

Optional: nullを使わない

背景

クイックソートの考案者であるアントニー・ホーア(Antony Hoare)が 2009年の QCon London で null の発明は10億ドルにも相当する誤りであった と発言しています.

nullがあると,NullPointerException が発生する可能性があります. そもそも null を使わなければ,NullPointerException が発生することはありません.

近年のプログラミング言語では,nullを許容しない変数を定義することも可能です. 例えば,Kotlin では,次のプログラムの1行目のように,デフォルトでは null を代入するとコンパイルエラーとなります. null を代入するには,型名に ? を付け,null許容型として扱う必要があります.

var str1: String = null;  // コンパイルエラー
var str2: String? = null; // OK

Javaではnullを許容しない変数は宣言できません. その代わりに Optional という型を利用します.

Optional

概要

Optionalとは,nullかもしれない値を扱うためのラッパ型です. mapifPresentメソッドなどで値が存在するときのみに処理を実行できます.

作成

String someString = // 文字列を代入する.
Optional<String> optional = Optional.of(someString);

ただし,Optional.ofnullを渡すと NullPointerException が投げられます. nullかもしれない変数をもとに Optional型の変数を作成するには,ofNullable を用います.

String nullString = null;
Optional<String> optional = Optional.ofNullable(nullString);

利用方法

あるディレクトリが保持するファイル・ディレクトリ一覧を取得する.

Optional を利用しない場合
File fileOrDirectory = // ファイルもしくはディレクトリ
File[] entries = fileOrDirectory.listFiles();
if(entries != null) {
    for(Integer i = 0; i < entries.length; i++) {
        System.out.printf("%d: %s%n", i, entries[i].getName());
    }
}
Optional を利用する場合
File fileOrDirectory = // ファイルもしくはディレクトリ
Optional<File[]> entries = Optional.ofNullable(fileOrDirectory.listFiles());
    // fileOrDirectory がファイルを指す場合,null が返される.
    // https://docs.oracle.com/javase/jp/8/docs/api/java/io/File.html#listFiles--
entries.ifPresent(entries -> { // entries の中身が null でない場合にのみ,下の for 文が実行される.
    for(Integer i = 0; i < entries.length; i++) { // files は entries の中身である File[] 型.
        System.out.printf("%d: %s%n", i, entries[i].getName());
    }
})

以上のものをもっと簡略化して書くと次のようになる.

File fileOrDirectory = // ファイルもしくはディレクトリ
Optional<File[]> entries = Optional.ofNullable(fileOrDirectory.listFiles());
entries.ifPresent(files -> IntStream.range(0, files.length)
    .forEach(i -> System.out.printf("%d: %s%n", i, files[i])));

参考資料

Stream: ループを使わない

背景

不吉な匂い(Code Smell)という,バグではないものの,バグの温床となり得る場所を指す言葉があります. リファクタリング 第二版 という本で,不吉な匂いにループが新たに追加されています. forwhileなどのループはバグの温床になり得るので避けるべきと言われているのです. なぜなら,1行に書くべきことは1つと言われながらも,for文は1行に初期化式,継続条件,反復式の3つの式が存在しています. また,配列などの長さを超えてのアクセスは,インデックスで要素にアクセスしているために起こります.

Java 8 から Stream API と呼ばれるデータ処理を行うためのAPIが導入されており,これにより for などのループを置き換えることが可能になります. この Stream API とメソッド定義を簡略化して書けるラムダ式を用いることにより,より簡潔に分かりやすく書けるようになります. そして,この Stream API は Java だけでなく,昨今のプログラム言語で数多くサポートされています.

Stream API

第0講 基礎文法 例題で示した 1以上100未満の奇数一覧を出力するプログラムを例に挙げます.

元のプログラムのは次の通りです.

for(Integer i = 1; i < 100; i++){
  // iを割った余りが1であれば,奇数.
  if(i % 2 == 1){ // i % 2 != 0 の条件でも可.
    System.out.print(i);
    System.out.print(" ");
  }
}

上のプログラムを Stream API で書き直したものが下のものです. メソッドの返り値を変数に代入せず,そのままメソッド呼び出しを続けている点に注意して読んでください(メソッドチェイニング (Method chaining)) と呼びます). Terminal で jshell を実行し,以下のプログラムをコピー&ペーストして実行してみましょう.

System.out.println(                     // 得られた文字列を出力する.
  IntStream.range(1, 100)               // 1以上,100未満の各値に対して,続く処理を適用する.
    .filter(i -> i % 2 == 1)            // iを2で割った余りが1のもの(奇数)のみ残す.
    .mapToObj(i -> Integer.toString(i)) // Integer 型を String 型に変換する.
    .collect(Collectors.joining(" "))); // 全ての文字列を空白区切りで連結させる

Stream APIは上記のように,繰り返しの中での様々な処理を関数を渡すことにより,副作用の少ないプログラムを書くことを目指します. 元のプログラムに比べての下のプログラムのメリットは次の通りです.

  • プログラムの副作用が少ない.
    • 副作用とは元のプログラムで i に何度も値が再代入されている点です.
      • 再代入は,現在の i の値が何であるかを把握するために労力を要するので,ない方が望ましいわけです.
  • forの継続条件である i < 100<<= であるべきか,i の初期値が 1 で良いのかと悩む必要はありません.
    • 問題文に記されている通り,以上,未満を表すのが IntStream.range メソッドです.
      • 以上,以下であれば,IntStream.rangeClosed を使いましょう.
  • System.out.print は最後に一度だけ実行される(文字列の出力は一般的にオーバーヘッドが大きい).
  • 各行で何を行っているのかはメソッドを見ると大まかにわかります.
    • filterは不要な値を削除する,mapmapToObj)は値を変換している,collectは集めている,とおおまかな処理内容が推測できます.
    • 元のプログラムは if の条件で何が起こるかを把握しなければプログラムの内容を読み解けません.

Streamの利用方法

Stream の生成法

  • ArrayList<K>から Stream<K> を入手する.
    • list.stream()
  • 配列(array)から Stream を入手する.
    • Arrays.stream(array)
  • その他
    • Stream.iterate(seed, prev -> prev + step)
    • 上のように,一番最初の値(seed)と seed から prev + step を順に適用した数値を返します.

Stream でできること

filter

Stream の要素を削除します. 引数(Predicate)は要素を受け取り Boolean を返します. 返り値がfalseのものはこのStreamから削除されます(元のListや配列から削除されるわけではありません).

map

Streamの各要素に対して,値の変換を適用します. 引数(Function)は,値を受け取り,変換後の値を返します(元のListや配列の要素が変換されるわけではありません).

forEach

Streamの各要素に対して,処理を行います. 引数(Consumer)は,値を受け取り,何も返しません.

collect

Map 型の Stream

Map はキーと対応するバリューのペアを格納します. そのため,そのまま Stream の実体を取得できません. 次のように,3つの方法で取得します(Map<K, V>型の変数mapから取得します.KVStringIntegerなどと読み替えてください)

  • map.keySet().stream()
    • Stream<K> が返されます.
  • map.values().stream()
    • Stream<V> が返されます.
  • map.entrySet().stream()
    • Stream<Map.Entry<K, V>> が返されます.

forEach のみが必要であれば,mapに対して直接 forEach を呼び出すことも可能です. この場合,map.forEach((k, v) -> ...) のように,キーとバリューの両方を受け取ります.

Streamの適用例

奇数の一覧の別解

第0講 基礎文法 例題で示した 1以上100未満の 奇数一覧の別解.

System.out.println(
    IntStream.iterate(1, prev -> prev + 2) // 無限 stream を取得する.
        .takeWhile(value -> value <= 100)  // takeWhile は Java 11 から利用できる.
        // .limit(50)                      // 最初から50個の要素のみ取得する.Java 8だと takeWhile が使えないため.
        .mapToObj(value -> Integer.toString(value))
        .collect(Collectors.joining(" ")));

総和を求める.

第0講 練習問題3を Stream で解くと次のようなプログラムになります.

Integer sum(Integer from, Integer max) {
    return IntStream.rangeClosed(from, max) // from以上,max以下の範囲の各値に対して
      .sum();                               // 全てを合計する.
}

ArgsPrinter

第1講 例題 ArgsPrinter を Stream で解くと次のようなプログラムになります. argsのインデックス番号とその値を出力したいが,Arrays.stream を用いるとインデックス番号が取得できない. そのため,IntStream.range を用いる.

IntStream.rangeClosed(0, args.length)
    .forEach(i -> System.out.printf("%d: %s%n", i, args[i]));

モンテカルロ法による $\pi$の計算

第2講 練習問題4

Double pi(Integer loopCount) {
    return 4d * IntStream.range(0, loopCount)  // 0以上,loopCount以下繰り返す.
        .mapToDouble(i -> calculateDistance()) // 乱数で得た座標と原点の距離を計算する.
        .filter(length -> length < 1d)         // 得られた距離が 1 より小さいもののみにする.
        .count();                              // 数を数える.
}

Double calculateDistance() {
    Double x = Math.random();
    Double y = Math.random();
    return Math.sqrt(x * x + y * y);
}

参考資料

ラムダ式

概要

ラムダ式は,特定のメソッドを簡略化して書けるような文法で,Java 8から導入されました. 例えば,Strema API を用いて指定された数までの FizzBuzz を出力するプログラムを例に挙げて説明します. 以下のプログラムの runメソッドの filterメソッド,map メソッドに渡している v -> v % 2 == 1i -> Integer.toString() がラムダ式と呼ばれる書き方です.

public class OddNumbersStream {
    void run(String[] args) {
        return IntStream.rangeClosed(1, 50)
            .filter(v -> v % 2 == 1)
            .mapToObj(number -> Integer.toString(number))
            .collect(Collectors.joining(" "));
    }
    public static void main(String[] args) {
        new OddNumbersStream().run(args);
    }
}

これは,以下のようなメソッドが定義されており,その定義を省略して書いています.

    void run(String[] args) {
        return IntStream.
            .filter(int test(int v) {
                return v % 2 == 1;
            })
            .mapToObj(String apply(int number) {
                return Integer.toString(number);
            })
            .collect(Collectors.joining(" "));
    }

ラムダ式では,-> の前に引数を,後ろにメソッドの中身を書きます. メソッド名,引数の型,返り値の型が省略されています. また,-> の後ろのメソッドの中身が1つの式の場合はメソッド定義の波括弧({})とreturnが省略可能です.

書き方

( 変数1,  変数2, ...) -> { メソッドの中身 }

ただし,以下のものが省略可能です.

  • ->の前の型,
  • ->の前の括弧(()),ただし,メソッド引数が1つだけの場合.
    • 引数が0個,2つ以上の場合は,省略不可.
  • 波括弧({})とreturn.ただし,メソッドの中身が1つの式の場合のみ.

ラムダ式の書き方いろいろ

省略しない場合

Function<Integer, String> fizzbuzzer = (Integer number) -> {
    if(number % 15 == 0)     return "FizzBuzz";
    else if(number % 3 == 0) return "Fizz";
    else if(number % 5 == 0) return "Buzz";
    return Integer.toString(number);
};

引数の型を省略

Function<Integer, String> fizzbuzzer = (number) -> {
    if(number % 15 == 0)     return "FizzBuzz";
    else if(number % 3 == 0) return "Fizz";
    else if(number % 5 == 0) return "Buzz";
    return Integer.toString(number);
};

引数の括弧を省略

Function<Integer, String> fizzbuzzer = number -> {
    if(number % 15 == 0)     return "FizzBuzz";
    else if(number % 3 == 0) return "Fizz";
    else if(number % 5 == 0) return "Buzz";
    return Integer.toString(number);
};

メソッドの切り出し

Function<Integer, String> fizzbuzzer = number -> {
    return fizzbuzz(number);
};

String fizzbuzz(Integer number) {
    if(number % 15 == 0)     return "FizzBuzz";
    else if(number % 3 == 0) return "Fizz";
    else if(number % 5 == 0) return "Buzz";
    return Integer.toString(number);
}

波括弧とreturnの省略

Function<Integer, String> fizzbuzzer = number -> fizzbuzz(number);

String fizzbuzz(Integer number) {
    if(number % 15 == 0)     return "FizzBuzz";
    else if(number % 3 == 0) return "Fizz";
    else if(number % 5 == 0) return "Buzz";
    return Integer.toString(number);
}

このような書き方は,これまでの Java の文法とは大きく異なるので,初見だと面食らうかもしれません. しかし,書き方が違うだけで行っていることは従来からと同じです. 慣れるまでは何を意味しているのかを確認しながら読み解いてください.

ラムダ式は,実際にはクラスの定義を行い,その定義したクラスの実体を作成しています. そのため,オブジェクト指向を学んだ後に復習することをお勧めします.

参考資料

練習問題

1. nullチェック

与えられた String 型変数が null ならば空文字を返し, そうでなければその値そのものを返すメソッドpurifyNullを作成してください. ただし,purifyNullメソッド内ではif文やswitch文を使ってはいけません. クラス名は PurifyNullDemo としてください.

public class PurifyNullDemo {
  public void run() {
    demo("string"); // "string" が出力される.
    demo("null");   // "null" が出力される.
    demo(null);     // "" が出力される.
  }
  void demo(String givenString) {
    String purifiedString = purifyNull(givenString);
    System.out.printf("\"%s\" => \"%s\"%n", givenString, purifiedString);
  }
  String purifyNull(String givenString) {
    // if文を使わず givenString が null のときに空文字を返す.
  }
}

2. 乱数値100個の統計(Stream)

第03講 練習問題 1 乱数値100個の統計を Stream を用いて書き直してください. クラス名は StatsValuesStream としてください.