概要
java.util.function パッケージには Function、Consumer、Supplier、Predicate をはじめとする汎用の関数型インターフェースが揃っています。Stream API を使い始めると自然に触れることになりますが、「Function と UnaryOperator の違いは何か」「Consumer をどこで使うのか」「BiFunction はいつ出番があるのか」といった疑問が実務では頻繁に出てきます。この記事では、各インターフェースの入力・出力の型パターンを整理したうえで、商品データの変換・フィルタリング・割引計算・ログ出力といった業務に近い場面での使い方を示します。Java 17 では record と組み合わせることでデータ変換パイプラインが読みやすくなり、Java 21 では sealed interface による型安全な操作定義という選択肢も加わります。各インターフェースの関係を把握しておくと、Stream の中間操作や終端操作に渡すラムダ式の設計が格段にしやすくなります。
使いどころ
データベースから取得した Entity を画面表示用の DTO へ変換する Function<Entity, Dto> を定義し、Stream.map に渡す
バッチ処理の各ステップで処理結果をログ出力する Consumer<StepResult> を定義し、forEach や peek で適用する
商品検索の絞り込み条件を Predicate<Product> として組み立て、and/or/negate で動的にフィルタを構成する
コード例
import java.util.List;
import java.util.function.*;
public class FunctionInterfaceDemo {
record Product(String name, int price) {}
public static void main(String[] args) {
Function<String, Integer> strLength = String::length;
Function<Integer, String> intToStr = n -> "文字数: " + n;
var combined = strLength.andThen(intToStr);
System.out.println(combined.apply("Java")); // 文字数: 4
var products = List.of(
new Product("ノートPC", 80000),
new Product("マウス", 3000),
new Product("キーボード", 12000)
);
Consumer<Product> printProduct = p ->
System.out.println(p.name() + ": " + p.price() + "円");
products.forEach(printProduct);
Supplier<List<String>> defaults = () -> List.of("未設定");
System.out.println(defaults.get()); // [未設定]
Predicate<Product> isExpensive = p -> p.price() >= 10000;
Predicate<Product> isCheap = isExpensive.negate();
System.out.println("高額商品:");
products.stream()
.filter(isExpensive)
.map(Product::name)
.forEach(name -> System.out.println(" " + name));
System.out.println("低価格商品:");
products.stream()
.filter(isCheap)
.map(Product::name)
.forEach(name -> System.out.println(" " + name));
BiFunction<Product, Double, Integer> discounted =
(p, rate) -> (int) (p.price() * (1.0 - rate));
for (var product : products) {
System.out.println(product.name()
+ " 10%引き: " + discounted.apply(product, 0.1) + "円");
}
UnaryOperator<String> toUpper = String::toUpperCase;
System.out.println(toUpper.apply("hello")); // HELLO
}
}Version Coverage
var でラムダ変数の型推論が可能(キャスト構文が必要)。record と組み合わせることで、データの定義と変換パイプラインをコンパクトに書ける。
// Java 17: record + Function でデータ変換パイプライン
record Product(String name, int price) {}
var products = List.of(
new Product("ノートPC", 80000),
new Product("マウス", 3000)
);
Function<Product, String> toLabel =
p -> "[" + p.name() + "] \u00a5" + p.price();
products.stream().map(toLabel).forEach(System.out::println);Library Comparison
注意点
Function<T, T> と UnaryOperator<T> は機能的に同じだが、UnaryOperator を使うと入出力が同じ型であることを型名で明示できる
Consumer は副作用を持つ処理に使うため、Stream の中間操作 peek に渡す場合はデバッグ用途に限定すべき。本番コードで peek に業務ロジックを入れると処理順序の保証が難しくなる
Supplier の遅延評価は get() を呼ぶまで実行されないため、重い初期化処理を Supplier に包むとパフォーマンス改善に使えるが、スレッドセーフではない点に注意
BiFunction<T, U, R> の型パラメータが3つになるため、複雑なラムダ式ではローカル変数に型付きで分離した方が可読性が上がる
FAQ
入出力が同じ型なら UnaryOperator を使うと意図が明確になります。型が異なる変換には Function を使います。機能的な違いはありません。
デバッグ目的なら問題ありませんが、業務ロジックを peek に入れると遅延評価で実行されない可能性があります。副作用を伴う処理は forEach で行うのが安全です。
重い初期化処理の遅延評価、デフォルト値の生成(Optional.orElseGet)、ファクトリの抽象化などで使います。get() を呼ぶまで処理が実行されない点がポイントです。