java.util.functionパッケージを覚えるとJavaは楽しくなる!
開発チームの下田です。
突然ですが、java.util.functionパッケージをご存知でしょうか?
lambda式を引数に取るメソッドには必ず登場します。
雰囲気で乗り切ってしまえるのでスルーしがちですが、覚えて使いこなすとJavaがもっと楽しくなります。
よくあるlambdaの登場シーン
itemsというListを受け取り、値ごとにListのindexをグループして返す関数を考えてみます。
[1,2,3,1,2]
と入力があったとき
{
1: [0,3],
2: [1,4],
3: [2]
}
と返すようなイメージです。
lambdaを使わない場合
Map#put、Map#getというMapインターフェースの基本的なメソッドを使った場合の実装がこちらです。
import java.util.*;
public class Main {
public static void main(String[] args) throws Exception {
// [1,2,3,1,2]のリストを渡して、グループしたMapが帰ってくる
group(List.of(1,2,3,1,2));
}
static Map<Integer, List<Integer>> group(List<Integer> items) {
Map<Integer, List<Integer>> grouped = new HashMap<>();
for(var i = 0; i < items.size(); i++) {
var item = items.get(i);
// リストが既にあるかチェックし
if(grouped.get(item) == null) {
// 無かった場合はArrayListのインスタンスを作成する
grouped.put(item, new ArrayList<>());
}
// キーに対応するリストに追加する
grouped.get(item).add(i);
}
return grouped;
}
}
computeIfAbsentを使う
こういうシーンの場合、Map#computeIfAbsentを使うと簡潔に書けます。
import java.util.*;
public class Main {
public static void main(String[] args) throws Exception {
group(List.of(1,2,3,1,2));
}
static Map<Integer, List<Integer>> group(List<Integer> items) {
Map<Integer, List<Integer>> grouped = new HashMap<>();
for(var i = 0; i < items.size(); i++) {
var item = items.get(i);
grouped.computeIfAbsent(item, key -> new ArrayList<>()).add(i);
}
return grouped;
}
}
computeIfAbsentのドキュメントを見てみましょう。
https://docs.oracle.com/javase/jp/17/docs/api/java.base/java/util/Map.html#computeIfAbsent(K,java.util.function.Function)
指定されたキーがまだ値に関連付けられていない(またはnullにマップされている)場合、指定されたマッピング関数を使用してその値の計算を試行し、nullでない場合はそれをこのマップに入力します。
ちょっと分かりづらい文章です。
少しずつ読み解いていきましょう。
指定されたキーがまだ値に関連付けられていない(またはnullにマップされている)場合、
Mapに値が存在していない場合です。
if(grouped.get(item) == null) {
// 関連付けられていない
}
それをこのマップに入力します。
最後の入力というのは、putして値を設定することを指しています。
指定されたマッピング関数を使用してその値の計算を試行し、
何をputするのかというと、java.util.function.Functionインターフェース型の引数のmappingFunctionを実行した結果です。
先程のサンプルコードだと
key -> new ArrayList()
としてました。
このlamda式を実行すると、新しくArrayListのインスタンスが生成されます。
Javaでは新しいインスタンスを生成する際には、必ずnewを呼び出す必要があります。今回のケースだと、まだMapのキーに関連付けられているリストがない場合はnewする必要がありますが、すでに関連付けられているリストがある場合は新しいインスタンスを生成する必要はありません。このような状況に応じて実行する必要がある式には、lambda式を使用します。lambda式を使用する場合、引数にはjava.util.functionパッケージ内のインターフェースを使用します。
Function
Map#computeIfAbsentのシグニチャをもう少し見てみましょう。
V computeIfAbsent(K key, Function<? super K,? extends V> mappingFunction)
mappingFuncitionの型は
Function<? super K,? extends V>
となっています。
前置きが長くなってしまいましたが、java.uitl.function.Functionとは一体何なのか追っていきたいと思います。
java.uitl.function.Function
まずはドキュメントを眺めてみます。
https://docs.oracle.com/javase/jp/17/docs/api/java.base/java/util/function/Function.html
引数を1つ取って、結果を生成するlamda式を代入するものだということが、なんとなく読み取れます。
これはjsでいうところのfunctionやrubyでいうところのブロックのようなものだと考えていただいて問題ありません。
jsで引数を1つ取って、結果を生成するfunctionを書いたことがある方は多いのではないでしょうか。こんな感じです。
const keisho = function(name) {
return name + "様"
}
console.log(keisho("太郎"))
// →太郎様
Javaで同様のコードを書くと、このようになります。
java.util.function.Function<String, String> keisho = (name) -> name + "様";
System.out.println(keisho.apply("太郎"));
// →太郎様
jsのfunctionとjavaのFunctionを比較しながら見ていきます。
関数呼び出し専用のメソッドがある
jsの場合は()
でメソッド呼び出しとなりますが、Javaにはそんな仕様は存在していません。あるのは普通のメソッドで、FunctionインターフェースにはR apply(T t)というメソッドがあります。
引数の型
Javaにはすべての戻り値・引数に型があります。R apply(T t)
は戻り値の型がR、引数の型がTです。つまりFunction型の変数宣言したときのジェネリクス型の1つめがR、2つめがTとなります。
java.util.function.Function<String, String>
というのは、次のようなことを指しています。
- 1つ引数を取って、1つ値を返す(Function)
- 引数の型はString(R、ジェネリクスの1つ目)
- 戻り値の型はString(T、ジェネリクスの2つ目)
mappingFunction
V computeIfAbsent(K key, Function<? super K,? extends V> mappingFunction)
のmappingFunctionは次のことを指しています。
- 1つ引数を取って、1つ型を返すFunction型
- 引数の型は
? super K
つまりMapのKeyの型 - 戻り値の型は
? extends V
つまりMapのValueの型
? super K
や? extends V
は境界ワイルドカード型のジェネリクスです。? super K
はKおよびKのスーパークラスを表す下限付きワイルドカード型、? extends V
はVおよびVを継承している上限付きワイルドカード型です。
引数の型はMapのキーの値を受け取れる必要があり、戻り値はMapにputできる必要があります。ラムダ式を書くときはあまり気にしませんが、メソッド参照する場合は既存のメソッドが正確に同じとは限らないので、便利な仕様になっています。
Map<ArrayList, List>#computeIfAbsent
には
Function<List, ArrayList>
という型のFunctionを受け取ることができます。
- Mapのキーは
ArrayList
、なのでFunctionの引数の型のList
に代入することができる。 - Mapの値は
List
なので、Functionの戻り値の型のArrayList
をputすることができる。
というわけです。
java.util.functionsパッケージ
引数の型と戻り値の型によってインターフェース名の雰囲気が変わります
- 参照型の引数を1つ、参照型の戻り値を返すFunction
- 参照型の引数を1つ、intの戻り値を返すToIntFunction
- 参照型の引数を1つ、booleanの戻り値を返すPredicate
- 参照型の引数を1つ、戻り値を返さないConsumer
- 参照型の引数を1つ、引数と同じ型の戻り値を返すUnaryOperator
Javaはプリミティブ型をジェネリクスで表すことができないので、1つ受け取り戻り値を生成するものでも、各々の型に合わせた名前がつけられています。
ジェネリクスで表せるものはFunction、ジェネリクスで表せるものが引数でプリミティブ型が戻り値のものはToHogeFunction、booleanだけは特別でPredicateという感じです。
また戻り値がない場合、引数がない場合も特別な名前がついています。
引数の数が2つの場合はBiFunctionになります。3つ受け取るTriFunctionはありません。これはBiFunctionを2つ組み合わせると、引数を3つ受け取るメソッドは実現できるためです。
まとめ
java.util.functionパッケージをマスターすると、Javaはもっと楽しくなります。
例えばめぐる式二分探索法のisOkメソッドをindexを受け取ってbooleanで返すIntPredecate型の引数で受け取れるようにしてみたり、Segment Tree等のデータ構造を自分で実装してみると楽しいです。Javaは名前は面倒くさいですが実はかなり自由度が高い言語で、その割にキッチリ型がハマるので、イメージ通りに型が設計できると全能感が味わえます。
そんなそんなことしなくても前述のMapやStreamAPIでlambda式が登場する機会は多く、functionパッケージを知っておくとエラーが怖くなくなります。覚えておいて損はないです。
リストをグループする処理をMapで実装する方法をお題としましたが、CollectionからMapにグルーピングする処理はStreamAPIを使ったほうが簡潔に書けます。終端処理でgroupingByを使用します。呪文のように見えるStreamAPIのシグニチャも、functionsパッケージのインターフェースを抑えておくと読めるようになってるんじゃないかと思います。
※ ラクーングループでは一緒に言語仕様を(Javaに限らず)語ってくれる方を大募集中です!