Command Palette

Search for a command to run...

[Advanced Java] Functional Interface trong Java: Supplier, Consumer, Function và Predicate

Functional interface là interface có đúng một abstract method, và đó là toàn bộ định nghĩa. Nó quan trọng vì lambda không có type của riêng nó: lambda được convert sang một functional interface, nên interface một abstract method là thứ duy nhất mà lambda có thể gán vào.

Package java.util.function cung cấp sẵn những shape mà nếu không có bạn sẽ phải viết lại mãi. Nhìn vào thì thấy một bức tường 43 cái tên, và phần lớn tutorial trình bày nó như một danh sách để học thuộc. Nó không phải danh sách. Nó là một cái lưới nhỏ, và khi đã định vị được một cái tên trên lưới thì phần còn lại suy ra được chứ không cần nhớ.

Bốn card thể hiện bốn shape cốt lõi: không có gì vào ra một giá trị, một giá trị vào không ra gì, một giá trị vào một giá trị ra, một giá trị vào một boolean ra

Mọi chương trình, mọi con số và mọi thông báo compile bên dưới đều được tạo ra bằng cách compile và chạy code trên OpenJDK 21.0.6 (arm64). Số lượng interface được đọc trực tiếp từ module image của chính JDK đó chứ không lấy từ trí nhớ.

Mọi functional interface đều là một ô trên cùng một lưới

Hai câu hỏi mô tả trọn vẹn một shape: abstract method duy nhất nhận bao nhiêu argument, và nó trả về cái gì. Trả lời được cả hai thì cái tên tự hiện ra.

Lưới ba nhân ba, các hàng là không, một và hai argument, các cột là không trả về gì, trả về một giá trị, trả về boolean

Bốn ô được dùng hằng ngày:

import java.util.function.*;

public class Shapes {
    public static void main(String[] args) {
        Supplier<String> today = () -> "2026-10-17";
        Consumer<String> log = s -> System.out.println("[log] " + s);
        Function<String, Integer> length = String::length;
        Predicate<String> blank = String::isBlank;

        System.out.println(today.get());
        log.accept("order created");
        System.out.println(length.apply("functional"));
        System.out.println(blank.test("   ") + " " + blank.test(" x "));
    }
}
2026-10-17
[log] order created
10
true false

Phần còn lại của lưới vẫn là bốn ý đó, chỉ đổi số lượng argument hoặc đổi type:

import java.util.function.*;

public class Shapes2 {
    public static void main(String[] args) {
        UnaryOperator<String> trim = String::trim;
        BinaryOperator<Integer> max = Integer::max;
        BiFunction<String, Integer, String> repeat = String::repeat;
        BiConsumer<String, Integer> show = (k, v) -> System.out.println(k + "=" + v);
        BiPredicate<String, Integer> fits = (s, n) -> s.length() <= n;
        Runnable task = () -> System.out.println("ran");

        System.out.println("[" + trim.apply("  padded  ") + "]");
        System.out.println(max.apply(2, 3));
        System.out.println(repeat.apply("ab", 3));
        show.accept("retries", 3);
        System.out.println(fits.test("abc", 3));
        task.run();
    }
}
[padded]
3
ababab
retries=3
true
ran

Đó là chín reference shape — những shape mà type parameter là object bình thường. Để ý mỗi họ dùng một tên method khác nhau, và điều đó là cố ý: get, accept, applytest cho bạn biết shape ngay tại chỗ gọi, kể cả khi tên biến không nói gì.

InterfaceMethodSignatureDùng khi
Supplier<T>get() -> Tmột giá trị cần được tạo ra sau, hoặc không tạo ra lần nào
Consumer<T>acceptT -> voidlàm gì đó với một giá trị và không trả về gì
Function<T,R>applyT -> Rbiến một giá trị thành một giá trị khác
Predicate<T>testT -> booleanhỏi một giá trị câu hỏi có hoặc không
UnaryOperator<T>applyT -> Ttype ra trùng type vào
BiConsumer<T,U>accept(T, U) -> voidhai giá trị vào, không có gì ra
BiFunction<T,U,R>apply(T, U) -> Rhai giá trị vào, một giá trị thứ ba ra
BiPredicate<T,U>test(T, U) -> booleanhai giá trị vào, một câu trả lời có hoặc không
BinaryOperator<T>apply(T, T) -> Thai giá trị cùng type gộp thành một

UnaryOperator<T>BinaryOperator<T> không thêm gì ngoài một cái tên hẹp hơn: chúng extend Function<T,T>BiFunction<T,T,T> rồi khai báo đúng không abstract method nào của riêng mình. Reflection trên OpenJDK 21.0.6 xác nhận điều đó — UnaryOperator.class.getDeclaredMethods() chỉ báo một thành viên không phải synthetic, là static identity.

Góc không nhận gì và không trả gì không có mặt trong java.util.functionjava.lang.Runnable đã chiếm chỗ đó từ Java 1.0. Java 8 gắn thêm @FunctionalInterface cho nó, và nó làm target cho lambda y như mọi interface khác.

Đếm package này trên JDK đó bằng cách liệt kê /modules/java.base/java/util/function trong filesystem jrt: cho ra 43 type, tất cả đều là interface, tất cả đều có đúng một abstract method, tất cả đều được gắn @FunctionalInterface. Chín cái là reference shape ở trên. 34 cái còn lại là primitive specialisation của chính chín shape đó, và phần dưới sẽ nói kỹ.

Annotation @FunctionalInterface chỉ là một kiểm tra lúc compile

Annotation không làm cho một interface trở thành functional. Quy tắc một abstract method mới làm việc đó, và compiler áp dụng quy tắc ấy dù bạn có viết annotation hay không:

interface Rule {                    // no @FunctionalInterface anywhere
    boolean holds(int n);
}

public class NoAnnotation {
    public static void main(String[] args) {
        Rule even = n -> n % 2 == 0;
        System.out.println(even.holds(4));
        System.out.println(Rule.class.isAnnotationPresent(FunctionalInterface.class));
    }
}
true
false

Lambda compile được, interface là functional, mà annotation thì không có. Cái annotation mua về là một hàng rào ở phía khai báo: nó làm hỏng build ngay khi interface thôi không còn functional, nên đồng nghiệp thêm abstract method thứ hai sẽ biết ngay thay vì làm vỡ mọi chỗ gọi.

@FunctionalInterface
interface TwoJobs {
    void start();
    void stop();
}
E1.java:1: error: Unexpected @FunctionalInterface annotation
@FunctionalInterface
^
  TwoJobs is not a functional interface
    multiple non-overriding abstract methods found in interface TwoJobs
1 error

Interface không có abstract method nào cũng hỏng y như vậy, chỉ khác dòng thứ hai:

E2.java:1: error: Unexpected @FunctionalInterface annotation
@FunctionalInterface
^
  Marker is not a functional interface
    no abstract method found in interface Marker
1 error

Và gắn nó lên một class cũng thế:

E3.java:1: error: Unexpected @FunctionalInterface annotation
@FunctionalInterface
^
  NotAnInterface is not a functional interface
1 error
@FunctionalInterfaceKhông có
Lambda dùng được khôngđược, nếu có một abstract methodđược, nếu có một abstract method
Thêm abstract method thứ haibuild hỏng ngay tại khai báobuild hỏng tại từng lambda
Hành vi lúc runtimegiống hệtgiống hệt
RetentionRUNTIME, reflection đọc đượckhông có gì để đọc

JDK áp dụng nó không đồng đều, và chính điều đó nói lên nhiều thứ. Cả 43 interface trong java.util.function đều có, java.lang.Runnable, java.util.concurrent.Callablejava.util.Comparator cũng vậy. Nhưng java.lang.Iterable, java.lang.AutoCloseablejava.util.concurrent.Executor mỗi cái có đúng một abstract method — iterator, close, execute — và theo đúng quy tắc của ngôn ngữ thì chúng là functional interface, nhưng không mang annotation. Annotation là một lời tuyên bố ý định: type này sinh ra để được implement bằng lambda. Iterable thì không.

Những method nào không tính là abstract method

Có bốn loại thành viên nằm được trong một functional interface mà không tiêu mất cái slot abstract duy nhất.

Method default, staticprivate đều có body, nên chúng không abstract và không bao giờ bị tính:

@FunctionalInterface
interface Validator<T> {
    boolean validate(T value);

    default Validator<T> andAlso(Validator<T> other) {
        return v -> validate(v) && other.validate(v);
    }

    static <T> Validator<T> alwaysTrue() {
        return v -> true;
    }

    private String tag() { return "validator"; }

    default String describe() { return tag() + " with one abstract method"; }
}

Loại thứ tư mới là thứ làm người ta bất ngờ: một method public abstract override một method public của java.lang.Object cũng không bị tính. Mọi class implement đều đã thừa hưởng phần cài đặt từ Object, nên không còn gì để lambda phải cung cấp. Interface dưới đây khai báo bốn abstract method mà vẫn là functional:

@FunctionalInterface
interface Renderer {
    String render();
    @Override String toString();
    @Override int hashCode();
    @Override boolean equals(Object other);
}

public class Sam {
    public static void main(String[] args) {
        Renderer r = () -> "rendered";
        System.out.println(r.render());

        Validator<String> notEmpty = s -> !s.isEmpty();
        Validator<String> under8 = s -> s.length() < 8;
        System.out.println(notEmpty.andAlso(under8).validate("hello"));
        System.out.println(notEmpty.andAlso(under8).validate("hello world"));
        System.out.println(Validator.<String>alwaysTrue().validate(""));
        System.out.println(notEmpty.describe());
    }
}
rendered
true
false
true
validator with one abstract method

Đây không phải chuyện lạ giữ lại để hỏi đố. Chính JDK dùng nó: reflection trên OpenJDK 21.0.6 cho thấy java.util.Comparator khai báo hai abstract method, compare(Object, Object)equals(Object), trong khi vẫn mang @FunctionalInterface. Cái equals khai báo lại nằm đó để ghi rõ rằng equality của một comparator có ý nghĩa chặt hơn của Object; nó không tốn gì của interface cả.

Quy tắc này hẹp hơn câu "mọi method mà Object có". Nó chỉ áp dụng cho những method public trong Object. clone()protected, nên khai báo lại nó là một abstract method thứ hai thật sự:

@FunctionalInterface
interface Copyable {
    void apply();
    Object clone();
}
E5.java:1: error: Unexpected @FunctionalInterface annotation
@FunctionalInterface
^
  Copyable is not a functional interface
    multiple non-overriding abstract methods found in interface Copyable
1 error
Thành viênCó tiêu mất slot abstract duy nhất không?
method defaultkhông
method statickhông
method private hoặc private statickhông
public abstract override equals, hashCode, toStringkhông
public abstract override clone hoặc finalize — chúng là protected trong Object
một abstract method thứ hai không liên quan
abstract method thừa hưởng từ superinterface với cùng signaturekhông — vẫn là một method đó

andThencompose: cùng hai function, hai kết quả

Function mang hai default method để dán hai function thành một. Chúng chỉ khác nhau ở chỗ argument đi vào đầu nào, và dùng nhầm là một bug im lặng chứ không phải lỗi compile, vì cả hai đều trả về Function đúng type.

Hai pipeline dựng từ cùng hai function theo thứ tự ngược nhau, một cái ra 40 và một cái ra 31

import java.util.function.*;

public class Compose {
    public static void main(String[] args) {
        Function<Integer, Integer> plus1 = n -> n + 1;
        Function<Integer, Integer> times10 = n -> n * 10;

        System.out.println("plus1.andThen(times10).apply(3) = " + plus1.andThen(times10).apply(3));
        System.out.println("plus1.compose(times10).apply(3) = " + plus1.compose(times10).apply(3));
        System.out.println("mirror image: "
                + (plus1.andThen(times10).apply(3) == times10.compose(plus1).apply(3)));

        Function<String, Integer> len = String::length;
        Function<Integer, String> stars = n -> "*".repeat(n);
        System.out.println(len.andThen(stars).apply("hello"));
        System.out.println(Function.<String>identity().andThen(String::toUpperCase).apply("id"));
    }
}
plus1.andThen(times10).apply(3) = 40
plus1.compose(times10).apply(3) = 31
mirror image: true
*****
ID

Đọc f.andThen(g) là "f, rồi tới g" — từ trái sang phải, đúng thứ tự viết ra. Đọc f.compose(g) theo lối toán học viết composition — argument đi vào g trước rồi kết quả được đưa cho f, nên code đọc từ trong ra ngoài. f.andThen(g)g.compose(f) dựng ra cùng một pipeline, đó là lý do dòng thứ ba in ra true.

f.andThen(g)f.compose(g)
Chạy trướcfg
Thứ tự đọctrái sang phảiphải sang trái
Tương đương vớig.compose(f)g.andThen(f)
Signature trên Function<T,R>nhận Function<? super R, ? extends V>nhận Function<? super V, ? extends T>
Có trênFunction, UnaryOperator, Consumer (chỉ andThen)Function, UnaryOperator

andThen là chỗ type được phép đổi: len.andThen(stars) biến một Function<String,Integer> và một Function<Integer,String> thành Function<String,String>. Function.identity() trả về function đưa lại chính argument của nó, là phần tử trung hòa của cả hai phép trên và chủ yếu hữu ích khi làm chỗ trống trong một chuỗi dựng theo điều kiện.

Có một điểm bất đối xứng đáng nhớ: UnaryOperator<T> thừa hưởng andThencompose từ Function<T,T>, và những method đó trả về Function chứ không phải UnaryOperator. Ghép hai UnaryOperator lại sẽ nhận về Function<T,T>, nên một biến khai báo UnaryOperator<String> sẽ không nhận kết quả đó nếu không bọc thêm một lambda.

Các combinator của PredicateConsumer

Predicate là shape giàu nhất, với ba default method và hai static factory:

import java.util.function.*;

public class Preds {
    public static void main(String[] args) {
        Predicate<String> notEmpty = s -> !s.isEmpty();
        Predicate<String> under6 = s -> s.length() < 6;

        System.out.println(notEmpty.and(under6).test("java"));
        System.out.println(notEmpty.and(under6).test("javascript"));
        System.out.println(notEmpty.or(under6).test(""));
        System.out.println(notEmpty.negate().test(""));
        System.out.println(Predicate.isEqual("java").test("java"));
        System.out.println(Predicate.not(under6).test("javascript"));

        // isEqual delegates to equals() and tolerates a null target
        Predicate<Object> isNull = Predicate.isEqual(null);
        System.out.println(isNull.test(null) + " " + isNull.test("x"));

        // and/or short-circuit, so the second predicate is never called here
        Predicate<String> boom = s -> { throw new IllegalStateException("never runs"); };
        System.out.println(under6.negate().and(boom).test("java"));
    }
}
true
false
true
true
true
true
true false
false

Hai dòng cuối mới là chỗ đáng để ý. Predicate.isEqual(null) tạo ra một predicate chỉ đúng với null, vì factory kiểm tra target null rồi trả về Objects::isNull thay vì gọi equals trên hư không. Còn and short-circuit y hệt toán tử logic AND: under6.negate() là false với "java", nên boom không bao giờ được gọi và IllegalStateException không bao giờ xảy ra.

Thành viênTừCho bạn cái gì
p.and(q)Java 8AND có short-circuit, bỏ qua q khi p là false
p.or(q)Java 8OR có short-circuit, bỏ qua q khi p là true
p.negate()Java 8phủ định logic của p
Predicate.isEqual(target)Java 8target.equals(x), và x == null khi target là null
Predicate.not(p)Java 11giống p.negate(), nhưng dùng được với method reference

Predicate.not tồn tại vì negate() cần một receiver đã có type Predicate, mà một method reference trần thì chưa có type cho tới khi được gán. not(String::isBlank) compile được; String::isBlank.negate() thì không. Compile đúng file đó với --release 10 báo cannot find symbol: method not(Predicate<String>), tức là nó thuộc Java 11.

Consumer chỉ có một combinator, và nó hành xử khác Function.andThen theo một cách đáng quan tâm:

import java.util.*;
import java.util.function.*;

public class Cons {
    public static void main(String[] args) {
        List<String> log = new ArrayList<>();
        Consumer<String> record = log::add;
        Consumer<String> shout = s -> log.add(s.toUpperCase());

        record.andThen(shout).accept("ping");
        System.out.println(log);

        // andThen returns void, so a throw in the first consumer skips the second
        Consumer<String> bad = s -> { throw new IllegalStateException("first"); };
        try {
            bad.andThen(record).accept("pong");
        } catch (IllegalStateException e) {
            System.out.println("threw: " + e.getMessage() + ", log still " + log);
        }
    }
}
[ping, PING]
threw: first, log still [ping, PING]

Consumer.andThen không nối kết quả — làm gì có kết quả — nó chạy cả hai consumer trên cùng một input, theo thứ tự. Nếu cái thứ nhất ném exception thì cái thứ hai không chạy, đúng hành vi của hai câu lệnh viết liền nhau, và nên biết trước điều đó trước khi dựng một audit trail bằng nó.

Primitive specialisation và phần boxing chúng loại bỏ

34 interface còn lại trong package tồn tại vì một lý do: generic không nhận int, nên Function<Integer, Integer> phải box lúc vào và unbox lúc ra. Các specialisation thay signature Object đã bị erase bằng một signature primitive.

Tên một primitive specialisation được tách thành các đoạn prefix có màu, bên cạnh là chi phí allocate của boxing đo được

Quy tắc đặt tên có ba mảnh, và nắm được chúng hơn hẳn việc học thuộc 34 cái tên:

MảnhÝ nghĩaVí dụSignature
prefix Int / Long / Doubleargument mang primitive đóIntPredicateint -> boolean
prefix ToInt / ToLong / ToDoublegiá trị trả về mang primitive đóToIntFunction<T>T -> int
cả hai, theo thứ tựprimitive vào, primitive raIntToLongFunctionint -> long
prefix Objcó một object argument đứng trướcObjIntConsumer<T>(T, int) -> void
UnaryOperator không cần prefix thứ haicùng primitive vào và raIntUnaryOperatorint -> int

Từ quy tắc này rút ra hai hệ quả. Chỉ int, longdouble có specialisation — không có ByteFunction, không có CharPredicate, không có FloatUnaryOperator, và code làm việc với những type đó hoặc phải box hoặc phải widen. Và giá trị trả về boolean không cần prefix ToBoolean nào cả, vì Predicate đã trả về boolean rồi; specialisation boolean duy nhất là BooleanSupplier, cho trường hợp không có argument mà Predicate không diễn tả được.

Tên method cũng đổi theo type trả về: IntFunction.apply trả về một object, còn ToIntFunction.applyAsInt, IntSupplier.getAsIntBooleanSupplier.getAsBoolean đều tự đổi tên để giá trị primitive trả về nhìn thấy được ngay tại chỗ gọi.

Cái giá của việc không specialise nhìn thấy được trong bytecode. Hai method dưới đây làm cùng một việc:

import java.util.function.*;

public class Bytes {
    static int boxedCall(Function<Integer, Integer> f, int n) { return f.apply(n); }
    static int primCall(IntUnaryOperator f, int n) { return f.applyAsInt(n); }
}
  static int boxedCall(java.util.function.Function<java.lang.Integer, java.lang.Integer>, int);
    Code:
       0: aload_0
       1: iload_1
       2: invokestatic  #7    // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
       5: invokeinterface #13,  2 // InterfaceMethod java/util/function/Function.apply:(Ljava/lang/Object;)Ljava/lang/Object;
      10: checkcast     #8    // class java/lang/Integer
      13: invokevirtual #19   // Method java/lang/Integer.intValue:()I
      16: ireturn

  static int primCall(java.util.function.IntUnaryOperator, int);
    Code:
       0: aload_0
       1: iload_1
       2: invokeinterface #23,  2 // InterfaceMethod java/util/function/IntUnaryOperator.applyAsInt:(I)I
       7: ireturn

Bốn instruction thừa: một valueOf để box, một checkcast vì erasure để lại method của interface trả về Object, và một intValue để unbox. valueOf mới là cái allocate.

Phần allocate đó đếm được chứ không cần đo thời gian. com.sun.management.ThreadMXBean.getThreadAllocatedBytes báo một thread đã allocate bao nhiêu byte, nên đọc nó ở hai đầu vòng lặp là ra con số chính xác:

import com.sun.management.ThreadMXBean;
import java.lang.management.ManagementFactory;
import java.util.function.*;

public class Boxing {
    static final ThreadMXBean BEAN = (ThreadMXBean) ManagementFactory.getThreadMXBean();
    static final int N = 1_000_000;
    static long sink;

    static long allocated(Runnable body) {
        long id = Thread.currentThread().threadId();
        long before = BEAN.getThreadAllocatedBytes(id);
        body.run();
        return BEAN.getThreadAllocatedBytes(id) - before;
    }

    public static void main(String[] args) {
        Function<Integer, Integer> boxed = n -> n + 1;
        IntUnaryOperator prim = n -> n + 1;

        long a = allocated(() -> { for (int i = 0; i < N; i++) sink += boxed.apply(1000 + i); });
        long b = allocated(() -> { for (int i = 0; i < N; i++) sink += prim.applyAsInt(1000 + i); });

        System.out.println("Function<Integer,Integer> : " + a + " bytes");
        System.out.println("IntUnaryOperator          : " + b + " bytes");
        System.out.println("per boxed call            : " + a / N + " bytes");
    }
}

Chạy nó với java -Xint để tắt JIT, như vậy con số không phụ thuộc vào việc optimiser hôm nay muốn làm gì:

Function<Integer,Integer> : 32000168 bytes
IntUnaryOperator          : 0 bytes
per boxed call            : 32 bytes

Ba mươi hai byte mỗi lần gọi, chạy lại lần nào cũng ra đúng con số đó: hai object Integer mỗi cái mười sáu byte — một cho argument đã box, một cho kết quả đã box. Bản primitive không allocate gì cả, vì chẳng có gì để allocate.

Muốn chắc 32 byte đó đúng là boxing thì kiểm tra rất dễ, vì Integer.valueOf trả về instance có sẵn trong cache cho giá trị trong khoảng -128..127 và allocate khi ra ngoài khoảng đó:

import com.sun.management.ThreadMXBean;
import java.lang.management.ManagementFactory;
import java.util.function.*;

public class Cache {
    static final ThreadMXBean BEAN = (ThreadMXBean) ManagementFactory.getThreadMXBean();
    static final int N = 1_000_000;
    static long sink;

    static long allocated(Runnable r) {
        long id = Thread.currentThread().threadId();
        long b = BEAN.getThreadAllocatedBytes(id);
        r.run();
        return BEAN.getThreadAllocatedBytes(id) - b;
    }

    public static void main(String[] args) {
        Function<Integer, Integer> f = n -> n;
        System.out.println("values 0..126, inside the Integer cache : "
                + allocated(() -> { for (int i = 0; i < N; i++) sink += f.apply(i % 127); }) + " bytes");
        System.out.println("values 1000..1126, outside it           : "
                + allocated(() -> { for (int i = 0; i < N; i++) sink += f.apply(1000 + i % 127); }) + " bytes");
    }
}
values 0..126, inside the Integer cache : 168 bytes
values 1000..1126, outside it           : 16000000 bytes

Cùng một vòng lặp, cùng một function, cùng số lần gọi. Khác biệt duy nhất là giá trị có nằm trong cache hay không, và khác biệt đó đúng bằng mười sáu byte mỗi lần gọi.

⚠️ Không có gì ở đây là lý do để mặc định bỏ qua Function. Khi bật JIT, escape analysis xóa được một phần lớn số allocate này, và một lambda boxed gọi vài nghìn lần không phải vấn đề đáng đặt tên. Các specialisation xứng đáng chỗ đứng trong vòng lặp số học nóng và trong stream pipeline chạy qua hàng triệu element — đúng những chỗ JDK dùng chúng.

Tự viết functional interface khi shape của JDK không vừa

Có ba tình huống 43 shape kia không phủ hết, và cả ba đều là lý do chính đáng để khai báo một interface của riêng mình.

Một cái tên có nghĩa. Function<Integer, Long> không nói gì về con số đó là gì. Một interface một method với tên thật ghi rõ ý định và cho parameter một cái tên hiện ra trong IDE:

@FunctionalInterface
interface PricingRule {
    long priceInCents(int quantity);
}

Nhiều hơn hai parameter. JDK dừng ở hai, nên shape ba argument phải tự khai báo. Thêm andThen của riêng mình chỉ tốn vài dòng và làm nó ghép được:

@FunctionalInterface
interface TriFunction<A, B, C, R> {
    R apply(A a, B b, C c);

    default <V> TriFunction<A, B, C, V> andThen(Function<? super R, ? extends V> after) {
        Objects.requireNonNull(after);
        return (a, b, c) -> after.apply(apply(a, b, c));
    }
}

Một checked exception. Đây là trường hợp hay gặp nhất, và là chỗ cần mô tả cách chữa một cách thành thật. Function.apply không khai báo checked exception nào, nên một method reference ném checked exception thì không vừa:

A2.java:7: error: incompatible thrown types IOException in functional expression
        Function<Path, String> read = Files::readString;
                                      ^
1 error

Cách chữa là một functional interface có mệnh đề throws. Nó chạy hoàn hảo chừng nào code của chính bạn là bên gọi:

@FunctionalInterface
interface ThrowingFunction<T, R, E extends Exception> {
    R apply(T t) throws E;
}

Rắc rối bắt đầu khi một method của JDK đòi một Function thật, và lối thoát duy nhất là một adapter biến checked exception thành unchecked:

import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.function.*;

public class Own {
    static <T, R, E extends Exception> Function<T, R> unchecked(ThrowingFunction<T, R, E> f) {
        return t -> {
            try {
                return f.apply(t);
            } catch (RuntimeException e) {
                throw e;
            } catch (Exception e) {
                throw e instanceof IOException io
                        ? new UncheckedIOException(io)
                        : new IllegalStateException(e);
            }
        };
    }

    public static void main(String[] args) throws IOException {
        Path p = Files.createTempFile("fi", ".txt");
        Files.writeString(p, "line one\nline two\n");

        ThrowingFunction<Path, String, IOException> read = Files::readString;
        System.out.println(read.apply(p).lines().count() + " lines read");

        Function<Path, String> adapted = unchecked(Files::readString);
        System.out.println(adapted.apply(p).strip().replace("\n", " | "));

        try {
            adapted.apply(Path.of("/no/such/file.txt"));
        } catch (UncheckedIOException e) {
            System.out.println("caught " + e.getClass().getSimpleName()
                    + ", cause " + e.getCause().getClass().getName());
        }

        PricingRule flat = qty -> 250L * qty;
        System.out.println("3 units = " + flat.priceInCents(3) + " cents");

        TriFunction<String, String, String, String> path =
                (a, b, c) -> a + "/" + b + "/" + c;
        System.out.println(path.andThen(String::toUpperCase).apply("api", "v2", "orders"));

        Files.deleteIfExists(p);
    }
}
2 lines read
line one | line two
caught UncheckedIOException, cause java.nio.file.NoSuchFileException
3 units = 750 cents
API/V2/ORDERS

Hãy gọi đúng tên nó: một workaround, kèm ba cái giá mà bạn đang chấp nhận.

Thứ nhất là checked type biến mất khỏi signature. adapted chỉ là một Function<Path,String> bình thường và không có gì báo cho bên gọi biết nó có thể hỏng; cái compiler đáng lẽ bắt họ xử lý IOException bây giờ không nói gì nữa.

Thứ hai là exception về tới nơi trong trạng thái bị bọc, nên chỗ gọi phải catch cái wrapper rồi mở ra, và stack trace có thêm một frame chẳng mang ý nghĩa gì.

Thứ ba lộ ra ngay lúc viết adapter. Để type parameter thành wildcard thay vì một E có tên thì wrapper nhìn gọn hơn nhưng không compile được:

Wrap.java:13: error: exception IOException is never thrown in body of corresponding try statement
            } catch (IOException e) {
              ^
Wrap.java:12: error: unreported exception CAP#1; must be caught or declared to be thrown
                return f.apply(t);
                              ^
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Exception from capture of ? extends Exception
2 errors

Type sau capture không phải IOException, nên với compiler thì catch (IOException e) là code chết. Adapter buộc phải catch Exception rồi phân loại lúc runtime, tức là đánh đổi thêm một mảnh an toàn tĩnh nữa. Nếu thao tác đó thật sự có thể hỏng, một vòng lặp bình thường với try bao quanh thường là câu trả lời tốt hơn.

Những cái bẫy sống sót qua code review

Hai overload cùng shape là ambiguous. Lambda không có type của riêng nó, nên compiler không thể dựa vào nó để chọn giữa hai overload mà parameter là hai functional interface giống hệt nhau về cấu trúc:

import java.util.function.*;

public class A1 {
    static void run(Supplier<String> s) { System.out.println("supplier " + s.get()); }
    static void run(Callable s)         { System.out.println("callable"); }

    interface Callable { String call(); }

    public static void main(String[] args) {
        run(() -> "hello");
    }
}
A1.java:10: error: reference to run is ambiguous
        run(() -> "hello");
        ^
  both method run(Supplier<String>) in A1 and method run(Callable) in A1 match
1 error

Cast lambda — run((Supplier<String>) () -> "hello") — thì hết lỗi, nhưng cách chữa thật sự là đừng công bố hai overload cùng shape. Để ý overload khác shape thì không sao: một overload Runnable và một overload Supplier<String> sống chung thoải mái, vì lambda có body void chỉ vừa cái thứ nhất còn lambda trả về giá trị chỉ vừa cái thứ hai.

Một Consumer âm thầm vứt giá trị trả về. Body lambda chỉ gồm một lời gọi method là một statement expression, tức là nó tương thích void, tức là nó compile được thành Consumer và ném kết quả đi:

import java.util.*;
import java.util.function.*;

public class Traps {
    public static void main(String[] args) {
        Consumer<String> shout = s -> s.toUpperCase();
        shout.accept("ada");
        System.out.println("nothing happened");

        Set<String> seen = new LinkedHashSet<>();
        Consumer<String> record = s -> seen.add(s);
        record.accept("x");
        record.accept("x");
        System.out.println(seen + " (the second add returned false and nobody saw it)");

        List<String> queue = new ArrayList<>(List.of("a"));
        Supplier<String> size = () -> "size=" + queue.size();
        queue.add("b");
        queue.add("c");
        System.out.println(size.get());

        int[] calls = { 0 };
        Supplier<Integer> counter = () -> ++calls[0];
        System.out.println(counter.get() + " " + counter.get() + " " + counter.get());
        System.out.println("the body ran " + calls[0] + " times");

        Map<String, String> cache = new HashMap<>(Map.of("k", "cached"));
        System.out.println(cache.getOrDefault("k", expensive("eager")));
        System.out.println(cache.computeIfAbsent("k", key -> expensive("lazy")));
    }

    static String expensive(String tag) {
        System.out.println("  [expensive(" + tag + ") ran]");
        return "computed";
    }
}
nothing happened
[x] (the second add returned false and nobody saw it)
size=3
1 2 3
the body ran 3 times
  [expensive(eager) ran]
cached
cached

s -> s.toUpperCase() compile được, chạy được, và không làm gì cả, vì String là immutable và chuỗi mới đi vào hư không. s -> seen.add(s) vứt mất boolean đáng lẽ báo cho bạn biết element đã có sẵn. Compiler chỉ phản đối khi body không phải statement expression:

A3.java:5: error: incompatible types: lambda body is not compatible with a void functional interface
        Consumer<String> c = s -> s.length() + 1;
                                             ^
    (consider using a block lambda body, or use a statement expression instead)

Supplier là một công thức, không phải một giá trị. Nó capture một reference rồi đọc xuyên qua reference đó lúc get() được gọi, nên size.get() in ra size=3 chứ không phải size=1 — list đã lớn lên trong khoảng giữa lúc lambda được tạo và lúc nó được gọi. Và body chạy lại ở mọi lần gọi chứ không phải một lần: counter.get() in ra 1 2 3 và bộ đếm lên tới 3. Supplier không memoise; muốn vậy thì tính giá trị một lần rồi giữ lại.

Chính sự lười đó là toàn bộ ý nghĩa của shape này, và hai dòng cuối cho thấy vì sao. getOrDefault("k", expensive("eager")) tính argument thứ hai trước cả khi lời gọi diễn ra, nên expensive vẫn chạy dù key đã có sẵn và giá trị bị vứt đi. computeIfAbsent("k", key -> ...) thì không gọi lambda lần nào. Cũng chính sự phân biệt đó là lý do Optional.orElseGet nhận một Supplier còn Optional.orElse nhận một giá trị bình thường.

FAQ

Functional interface trong Java là gì?

Là interface có đúng một abstract method. Method duy nhất đó là thứ mà lambda hoặc method reference cung cấp body, nên functional interface là loại type duy nhất mà một lambda có thể convert sang. Method default, staticprivate không tính vào giới hạn, và những khai báo lại public abstract của equals, hashCode hay toString cũng không, vì mọi class implement đều đã thừa hưởng chúng từ java.lang.Object.

Có bắt buộc phải có @FunctionalInterface để dùng lambda không?

Không. Annotation là một khẳng định lúc compile chứ không phải điều kiện bắt buộc: interface Rule ở trên không có annotation, mà Rule even = n -> n % 2 == 0; vẫn compile và chạy được. Cái annotation thêm vào là một build hỏng ngay tại khai báo khi abstract method thứ hai xuất hiện, thay vì build hỏng ở từng lambda đã dùng nó. JDK áp dụng nó có chọn lọc — Runnable, CallableComparator thì có, còn Iterable, AutoCloseableExecutor là functional interface mà không có, vì chúng không sinh ra để viết thành lambda.

Một functional interface có thể có nhiều hơn một abstract method không?

Nó khai báo được nhiều hơn một, miễn là sau khi trừ các trường hợp miễn trừ chỉ còn lại đúng một. java.util.Comparator khai báo compareequals, và vẫn là functional interface trên OpenJDK 21.0.6, vì equals(Object) override một method public của Object. Miễn trừ này chỉ áp dụng cho method public của Object: khai báo Object clone() — vốn là protected trong Object — là một abstract method thứ hai thật sự và cho ra multiple non-overriding abstract methods found in interface Copyable.

andThencompose khác nhau ở đâu?

Ở thứ tự hai function chạy. f.andThen(g) chạy f trước rồi đưa kết quả cho g; f.compose(g) chạy g trước rồi đưa kết quả cho f. Với plus1times10 cùng input là 3, plus1.andThen(times10) cho 40 còn plus1.compose(times10) cho 31. Chúng là ảnh gương của nhau: f.andThen(g)g.compose(f) dựng ra cùng một pipeline. Consumer chỉ có andThen, và nó chạy cả hai consumer trên cùng một input chứ không nối kết quả.

Khi nào nên dùng IntFunction thay cho Function<Integer, R>?

Khi số lần gọi đủ nhiều để phần allocate trở nên đáng kể. Đo trên OpenJDK 21.0.6 với java -Xint, một triệu lần gọi qua Function<Integer,Integer> allocate 32.000.168 byte — hai object Integer 16 byte mỗi lần gọi, một cho argument và một cho kết quả — trong khi IntUnaryOperator allocate bằng không. Trong code ứng dụng bình thường khác biệt đó không nhìn thấy được, và escape analysis của JIT cũng đã xóa phần lớn. Trong một vòng lặp số học nóng, hoặc một stream chạy qua hàng triệu element, đó đúng là khác biệt mà các specialisation sinh ra để mua về.

Tại sao Java không có TriFunction?

JDK dừng ở hai argument vì tổ hợp sẽ vượt tầm kiểm soát: ba mức arity nhân với các reference shape và các specialisation int, long, double sẽ đẩy package vượt xa con số 43 hiện tại, cho một shape hiếm gặp trong thực tế. Tự viết chỉ tốn bốn dòng, và một function ba argument thường là dấu hiệu rằng hai trong ba argument đó nên nằm chung trong một record.

Làm sao viết lambda ném checked exception?

Với functional interface của JDK thì không được — Files::readString dùng như một Function cho ra incompatible thrown types IOException in functional expression. Hãy khai báo interface của riêng bạn với mệnh đề throws và dùng nó ở nơi bạn kiểm soát được API gọi. Ở nơi không kiểm soát được, một adapter catch rồi bọc lại là cách chữa thông thường, và cái giá là mất checked type trong signature, thêm một lớp wrapper quanh exception, và mất luôn khả năng viết catch (IOException e) ngay trong adapter khi type của exception là một type variable.

Kết luận

Bốn mươi ba interface là rất nhiều để nhớ và gần như không có gì để hiểu. Chúng là chín shape, định nghĩa bởi bao nhiêu argument đi vào và cái gì đi ra, cộng thêm 34 primitive specialisation máy móc mà tên gọi suy ra được từ ba prefix. Một abstract method là toàn bộ hợp đồng; @FunctionalInterface chỉ kiểm tra nó, còn default, static, private và những method override Object đều miễn phí — đó là cách Comparator vừa khai báo được equals vừa vẫn làm target cho lambda.

Phần còn lại là composition và chi phí. andThen đọc xuôi còn compose đọc ngược, và compiler sẽ không bắt được lỗi nhầm vì cả hai đều hợp lệ về type. Predicate.andor có short-circuit, Consumer.andThen tỏa ra chứ không nối lại, và Supplier là một công thức chạy lại ở mọi lần get() rồi đọc reference nó capture ngay tại thời điểm đó chứ không phải lúc được viết ra. Chỗ nào shape của JDK không vừa — một checked exception, ba parameter, một cái tên mang ý nghĩa — thì tự khai báo rẻ hơn và thường sáng hơn là bẻ cong một Function cho vừa.

Bài tiếp theo trong series: Optional — nó thật sự dùng để làm gì, vì sao get() là method gần như không bao giờ nên gọi, map, flatMapfilter, khác biệt giữa orElseorElseGet mà cái bẫy cuối cùng của bài này đã hé ra, và những chỗ Optional không thuộc về.

Bài viết liên quan

[Advanced Java] Stream API trong Java: map, filter, reduce và collect

Stream API của Java trên OpenJDK 21: pipeline gồm source, intermediate và terminal, tính lazy được chứng minh bằng trace println xen kẽ, map, filter, cả ba overload của reduce, collect cùng bộ Collectors, primitive stream và chi phí allocation của boxing, và các bẫy quanh peek, findAny, lambda có state cùng parallelStream.

[Advanced Java] Set trong Java: HashSet, LinkedHashSet và TreeSet

HashSet, LinkedHashSet và TreeSet trên OpenJDK 21: Set thực sự đảm bảo điều gì, HashMap ẩn bên trong HashSet, các lookup của NavigableSet, hai quy tắc khác nhau để quyết định duplicate, compareTo mâu thuẫn với equals, ClassCastException và null trong TreeSet, EnumSet, và vì sao removeAll có thể thành quadratic.

[Advanced Java] Design Pattern phổ biến trong Java: Singleton, Factory, Builder, Observer và Strategy

Năm design pattern trong Java trên OpenJDK 21, mỗi pattern đều được chỉ ra ngay bên trong JDK và đều kèm trường hợp nó gây hại: initialization của singleton và cuộc đua mà null check không đồng bộ thua, Integer.valueOf cùng cache của nó, staged builder được compiler kiểm tra, listener gây leak và listener làm dừng cả lượt broadcast, và Comparator chính là strategy type bạn vẫn dùng hằng ngày.

[Advanced Java] Class Collections trong Java: algorithm, wrapper và factory

java.util.Collections trên OpenJDK 21 sắp xếp theo đúng bản chất từng nhóm method: các algorithm ghi đè tại chỗ cùng mẹo insertion point của binarySearch, nCopies trả về một reference lặp n lần, ba wrapper unmodifiable, synchronized và checked vốn là view chứ không phải bản copy, checkedList bắt heap pollution ngay lúc insert, và các immutable factory đã thay thế phân nửa số method cũ.