《Java 编程入门》12.1 Lambda 与函数式接口

从匿名内部类写搜索条件的冗长代码出发,讲清函数式接口的唯一抽象方法、Lambda 的目标类型与方法引用四种写法,再说明变量捕获与 this 的差异;最后把 Predicate、BookFormatter、Supplier 用到 library 的搜索、格式化与异常延迟创建上,并给出编译错误与真实输出。

本节目标:能把一个「只有一段行为不同」的重复代码改写成 Lambda,理解函数式接口、目标类型、方法引用和变量捕获的规则,并能在 library 里用 Predicate、BookFormatter、Supplier 表达搜索、格式化和延迟创建。
适用版本:JDK 25 LTS(Temurin 25.0.5);Lambda 与方法引用语法在 JDK 21 上没有差异。

上一节留下的问题

第 11 章末尾,LibraryService.search 已经接收一个 Predicate<Book>:

public List<Book> search(Predicate<Book> criteria) {
    return books.findAll().stream().filter(criteria).toList();
}

但如果没有 Lambda,调用方只能写匿名内部类:

List<Book> recent = service.search(new Predicate<Book>() {
    @Override
    public boolean test(Book book) {
        return book.year() > 2015;
    }
});

五行代码只表达了一个条件:book.year() > 2015。如果还要按作者、按标题、按年份区间搜索,每个条件都套一层同样的壳。排序也一样:

books.sort(new Comparator<Book>() {
    @Override
    public int compare(Book left, Book right) {
        return left.title().compareTo(right.title());
    }
});

问题不在于匿名类做不到,而在于它把「真正想传的行为」埋在了样板代码里。Lambda 要解决的就是这件事:把一段行为当成参数传进去,语法上只保留这段行为本身。

函数式接口:只有一个抽象方法

Lambda 不能凭空存在,它必须对应一个函数式接口(functional interface):有且只有一个抽象方法的接口。Predicate<T> 就是这样一个接口:抽象方法 test(T) 负责判断,and、or、negate 是默认方法,isEqual 是静态工厂。

default 方法和 static 方法不算抽象方法,所以 Predicate 仍然只有一个抽象方法 test。@FunctionalInterface 是给编译器的声明:如果接口里出现第二个抽象方法,直接报错。

@FunctionalInterface
interface TwoMethods {
    void first();
    void second();
}
错误: 意外的 @FunctionalInterface 批注
@FunctionalInterface
^
  TwoMethods 不是函数接口
    在 接口 TwoMethods 中找到多个非覆盖抽象方法

JDK 自带一组常用的函数式接口,都在 java.util.function 里:

接口抽象方法用途
Predicate<T>boolean test(T)条件判断
Function<T, R>R apply(T)类型转换
Consumer<T>void accept(T)消费一个值
Supplier<T>T get()延迟提供一个值
UnaryOperator<T>T apply(T)同类型转换
BiFunction<T, U, R>R apply(T, U)两个输入一个输出

library 里也可以定义自己的函数式接口。比如「把一本书格式化成一行文本」:

@FunctionalInterface
public interface BookFormatter {
    String format(Book book);

    default BookFormatter withPrefix(String prefix) {
        return book -> prefix + format(book);
    }
}

withPrefix 返回一个新的 BookFormatter,内部调用当前实例的 format。默认方法让函数式接口可以组合,而不破坏「只有一个抽象方法」的约束。

Lambda 的语法与目标类型

Lambda 的基本形状是「参数列表 -> 方法体」:

Predicate<Book> recent = book -> book.year() > 2015;
Function<Book, String> titleOf = book -> book.title();
Supplier<Book> factory = () -> new Book(
        "978-7-111-54742-6", "Effective Java", "Joshua Bloch", 2018);
Consumer<Book> printer = book -> System.out.println(book.title());

规则很简单:

  • 参数只有一个时可以省略括号,如 book -> ...;没有参数时写 ()。
  • 方法体是单个表达式时可以不写 return 和花括号。
  • 方法体有多条语句时用 { ... },有返回值就要写 return。
  • 参数类型可以省略,编译器从目标类型推断;也可以显式写 (Book book) -> ...。

「目标类型」是关键。同一个 Lambda 可以赋给不同的函数式接口,只要参数和返回值匹配:

Predicate<String> isEmpty = value -> value.isEmpty();
Function<String, Boolean> same = value -> value.isEmpty();

两行代码的 Lambda 体一模一样,但目标类型不同,编译器生成的接口实现也不同。反过来,没有目标类型就写不出来:

var unknown = value -> value.isEmpty(); // 编译错误:需要目标类型

var 在这里帮不上忙,因为编译器不知道 value 是什么类型,也不知道要实现哪个接口。

方法引用:Lambda 的简写

当 Lambda 只是「直接调用一个已有方法」时,可以用方法引用(method reference)进一步缩短。常见的有四种:

Function<String, Integer> parse = Integer::parseInt;        // 静态方法
Function<Book, String> title = Book::title;                  // 实例方法,未绑定
Consumer<String> println = System.out::println;              // 实例方法,已绑定
Supplier<Book> factory = () -> new Book("978-7-111-54742-6",
        "Effective Java", "Joshua Bloch", 2018);             // 构造器可用 Book::new

Book::title 看起来像静态方法,实际是「未绑定的实例方法引用」:调用时第一个参数当作接收者,所以它匹配 Function<Book, String>。System.out::println 则是「已绑定的实例方法引用」,接收者 System.out 已经确定,剩下的参数才由函数式接口提供。

方法引用的可读性通常更好,但不要硬套。带条件的逻辑、需要多个语句的逻辑,还是用 Lambda 更清楚。

变量捕获与 this

Lambda 可以读取外层局部变量,但这个变量必须是事实上的 final(effectively final):声明后没有再被赋值。

int count = 0;
Runnable task = () -> System.out.println(count++);
错误: 从lambda 表达式引用的本地变量必须是最终变量或实际上的最终变量
        Runnable task = () -> System.out.println(count++);
                                                 ^

原因在于 Lambda 捕获的是变量的值,而不是变量本身。如果允许修改,多个线程或多次调用之间就无法保证看到同一个值。需要可变计数时,用 AtomicInteger 或数组这类容器,而不是修改局部变量。

另一个容易忽略的差异是 this。匿名内部类有自己的 this,Lambda 没有:

class Shadow {
    String value = "outer";

    Runnable anonymous() {
        return new Runnable() {
            String value = "anonymous";

            @Override
            public void run() {
                System.out.println("anonymous -> " + value);
            }
        };
    }

    Runnable lambda() {
        return () -> System.out.println("lambda    -> " + value);
    }
}

本机 JDK 25 输出:

anonymous -> anonymous
lambda    -> outer

匿名类里的 value 解析到匿名类自己的字段;Lambda 里的 value 解析到外层 Shadow 的字段,因为 Lambda 不引入新的作用域。这个差异在写回调时很重要:Lambda 里的 this 就是创建它的那个对象。

在 library 里落地

搜索是最直接的应用。把条件写成 Predicate<Book>,服务层只负责过滤:

public List<Book> search(Predicate<Book> criteria) {
    return books.findAll().stream().filter(criteria).toList();
}

调用方可以组合条件:

Predicate<Book> byBloch = book -> book.author().equals("Joshua Bloch");
Predicate<Book> after2010 = book -> book.year() > 2010;
List<Book> result = service.search(byBloch.and(after2010));

and、or、negate 都是 Predicate 的默认方法,组合出来的仍然是 Predicate<Book>。如果某个条件只是调用一个现有方法,方法引用会更短:

List<Book> titled = service.search(book -> book.title().startsWith("Java"));

格式化则用自定义的 BookFormatter:

BookFormatter formatter = book -> book.title() + " / " + book.author();
System.out.println(formatter.withPrefix("[书] ").format(DemoData.BOOKS.get(0)));

输出:

[书] Effective Java / Joshua Bloch

Supplier<LibraryException> 用来延迟创建异常。只有真正要抛的时候才构造对象,异常消息里的参数也能在那一刻求值:

Supplier<LibraryException> missing =
        () -> new BookNotFoundException("978-7-000-00000-0");
System.out.println(missing.get().getMessage());

输出:

找不到 ISBN 为 978-7-000-00000-0 的图书

完整跑一遍:

LibraryService service = DemoData.service();

Predicate<Book> byBloch = book -> book.author().equals("Joshua Bloch");
List<Book> blochBooks = service.search(byBloch);
System.out.println("count = " + blochBooks.size());

Predicate<Book> after2010 = book -> book.year() > 2010;
System.out.println("and   = " + service.search(byBloch.and(after2010)).stream()
        .map(Book::title).toList());

BookFormatter formatter = book -> book.title() + " / " + book.author();
System.out.println(formatter.withPrefix("[书] ").format(DemoData.BOOKS.get(0)));

Function<Book, String> titleOf = Book::title;
System.out.println("mapped = " + titleOf.apply(DemoData.BOOKS.get(0)));

本机 JDK 25 输出:

count = 2
and   = [Effective Java]
[书] Effective Java / Joshua Bloch
mapped = Effective Java

count = 2 是因为示例数据里 Joshua Bloch 有两本书;and 又把 2005 年的《Java Puzzlers》过滤掉,只剩 2018 年的《Effective Java》。

常见错误与边界

错误一:修改被捕获的局部变量

前面已经看到,count++ 会直接编译失败。即使变量在 Lambda 创建后不再修改,只要编译器无法证明它事实上 final,也会拒绝。正确做法是把可变状态放进对象字段,或用 AtomicInteger:

AtomicInteger calls = new AtomicInteger();
Runnable task = () -> calls.incrementAndGet();
task.run();
System.out.println(calls.get());

输出 1。容器本身没有变,变的是容器里的值。

错误二:把 Lambda 用在非函数式接口上

Object value = book -> book.title();

Object 不是函数式接口,Lambda 没有目标类型,编译失败。必须显式指定一个函数式接口:

Function<Book, String> title = book -> book.title();
Object value = title;

第二行把函数对象赋给 Object 是合法的,因为此时 Lambda 已经通过 Function<Book, String> 完成了类型检查。

错误三:重载让 Lambda 产生歧义

@FunctionalInterface
interface Action {
    void run();
}

class OverloadDemo {
    static void run(Runnable task) { }
    static void run(Action task) { }

    void demo() {
        run(() -> System.out.println("done"));
    }
}
错误: 对run的引用不明确
        run(() -> System.out.println("done"));
        ^
  OverloadDemo 中的方法 run(Runnable) 和 OverloadDemo 中的方法 run(Action) 都匹配

Runnable 和 Action 的抽象方法签名都是 void run(),Lambda 同时满足两者,编译器无法判断该选哪一个。注意如果其中一个接口的方法有返回值(比如 Callable<String>),() -> "done" 只满足有返回值的那一个,就不会歧义——歧义发生在两个 Lambda 形状相同的重载之间。解决办法是显式写类型转换 run((Runnable) () -> System.out.println("done")),或者改方法名。日常 API 设计里,尽量避免让两个重载只靠函数式接口区分。

边界:受检异常与函数式接口

Runnable.run() 不能抛受检异常,Callable.call() 可以。把一段会抛 IOException 的逻辑塞进 Runnable,就必须在 Lambda 体里自己 try/catch。第 13 章会系统讲受检异常;现在只需记住:函数式接口的方法签名决定了 Lambda 能不能抛异常。

小结

  • 函数式接口是只有一个抽象方法的接口,default 和 static 方法不计数;@FunctionalInterface 让编译器替你守住这条规则。
  • Lambda 的写法是「参数 -> 方法体」,参数类型通常省略,由目标类型推断;没有目标类型就写不出来。
  • 方法引用是「只调用一个现有方法」时的简写,分静态、未绑定实例、已绑定实例和构造器四类。
  • Lambda 捕获的局部变量必须事实 final;Lambda 不引入新作用域,this 指向创建它的对象。
  • Predicate、Function、Consumer、Supplier 覆盖了判断、转换、消费、提供四类常见行为,library 的搜索和格式化可以直接复用。
  • 受检异常能否抛出由函数式接口的方法签名决定,Runnable 和 Callable 的差异就是例子。

Lambda 让「传一段行为」变短了,但如果要对集合做多次过滤、映射、排序、统计,逐个写 Lambda 仍然会堆成嵌套循环。下一节的 Stream 管道把这些步骤串成一条可读的流水线。

阅读导航:上一节:11.3 类型擦除与运行时真相 · 下一节:12.2 Stream 管道 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发