Reading 20: 回调与图形用户界面(Callbacks and Graphical User Interfaces)
Reading 20: 回调与图形用户界面(Callbacks and Graphical User Interfaces)
说明:本讲 sp22 原版使用 TypeScript,本笔记按用户要求提供 Java 代码示例;类型/API 与 sp21(6.031 Java 版)原文保持一致。
概述
本讲讨论回调(callback):由客户端(client)提供给模块(module)、由模块在合适的时机去调用的函数。这与我们熟悉的控制流方向恰好相反——平时总是客户端向下调用模块提供的函数,而回调是客户端把一段代码「交出去」,让实现者来调用它。课程用图形用户界面(GUI)作为主要语境:GUI 里的一种回调叫监听器(listener),用来响应用户产生的输入事件。回调背后的更大思想是一等函数(first-class functions):把函数当成数据一样传递、返回、存储。
它与三大目标的关系是:Ready for change 最明显——回调让客户端自己提供「事件发生时该做什么」,行为不必被硬编码进实现;Easy to understand,比起「写一个巨大的输入循环处理系统里所有可能的事件」,让每个模块负责自己的事件显然更清晰;Safe from bugs,因为那个「无所不知、无所不控」的巨型分派函数是最容易出错的代码。本讲同时埋下了后面几讲的伏笔:异步回调意味着控制权不在你手里,因此必然引出并发(Reading 21)、锁与线程安全(Reading 23)以及异步编程(Reading 22)的问题。
核心概念与设计原则详解
回调(Callback)
- 定义与目的:回调是客户端提供给模块、供模块调用的函数。它把「事件发生时做什么」这一决策从实现者手里交给客户端,直接服务于可修改性(行为可插拔)与易理解性(每个模块只管自己的事件)。
- 直观解释(”它是什么?”):课程给的类比是给银行打电话。普通函数调用像你主动打电话到银行问余额,银行查到后告诉你,你挂断——你是客户端,银行是你调用的模块。如果银行很慢,你被要求「别挂,稍等」,这就是阻塞式调用。但有些任务太慢,银行不愿意让你一直等,于是问你一个回拨电话(callback number),承诺将来某个不可预测的时刻打回来——这就是异步回调。
- 关键规则与最佳实践:
- 回调有两种调用模式:一次性回答(如查询余额)与被反复触发(如账户盗刷提醒——对应监听器模式)。
- 用回调意味着控制权反转(inversion of control):实现者决定何时调用你的代码,你必须按规格假设「它可能在任何时刻被调用」。
- 回调函数的规格必须写清:调用次数(至多一次 / 每次事件一次)、调用时机(同步还是异步)、参数含义与取值范围。
- 把回调参数放在参数列表的末尾(
setTimeout把回调放第一位是课程明确批评的「不幸设计」,因为在单行 lambda 写法下难以分辨参数边界)。
同步回调与异步回调(Synchronous vs Asynchronous Callbacks)
- 定义与目的:同步回调只在接收它的那个调用执行期间被调用,调用返回后不再使用;异步回调会被模块保存下来,在接收它的函数已经返回之后的某个时刻再调用。区分的目的是让「时间」这个维度显式化,避免写出依赖错误时序的代码。
- 直观解释(”它是什么?”):Reading 16 里传给
map/filter/reduce的函数就是同步回调——它在map执行完之前被逐元素调用,map一返回,它的使命就结束了。而「三秒后响铃」的定时器回调是异步的:登记完就立刻返回,三秒后才被叫醒。 - 关键规则与最佳实践:
- 同步回调的异常可以用包围调用点的
try/catch捕获;异步回调的异常不能——登记回调的那行代码早已返回,异常发生时没有任何栈帧在处理它。 - 同步回调的副作用在调用返回时保证已发生;异步回调的副作用只保证「将来会发生」,代码不能依赖它已经发生。
- 规格里必须写明首次调用是同步还是异步(例如 countdown 规格:第一次同步调用,之后每次计时跳动异步调用)。
- 异步回调可以被调用零次、一次或多次,取决于规格;客户端的代码必须对「还没发生」有正确假设。
- 同步回调的异常可以用包围调用点的
一等函数与函数对象(First-Class Functions & Functional Objects)
- 定义与目的:一等函数指函数可以像其他值一样被传递、返回、存入变量与数据结构。它是实现回调的语言前提:如果函数不能作为值传递,就无法把「一段代码」交给模块。
- 直观解释(”它是什么?”):语言里很多东西不是一等公民:访问控制(
public/private不能当参数)、while循环、if语句都无法被单独引用或在运行时操纵。而在一等函数的语言里,函数本身就是可以拿在手上的东西。 - 关键规则与最佳实践:
- Java 里唯一的类型化一等值是原始值与对象引用;函数必须搭在对象上(方法)。
- 于是 Java 用函数对象(functional object)实现一等函数:一个「目的是表示某个函数」的对象,其规格由一个单抽象方法接口(Single Abstract Method, SAM interface)给出。
- 课程里已经出现过多次:传给
Thread的Runnable(即void run())、传给有序集合的Comparator<T>(即int compare(T,T))、传给按钮的ActionListener(即void actionPerformed(ActionEvent))、传给HttpServer的HttpHandler(即void handle(HttpExchange))。 - 一等函数的谱系可以追溯到 Lisp(MIT 的 John McCarthy)与更早的 λ 演算(Alonzo Church),这也是 lambda 一词的来源。
Lambda 表达式(Lambda Expressions)
- 定义与目的:lambda 是创建函数对象的简洁语法,它让回调的书写成本降到接近「就地写一段代码」,从而鼓励使用回调(可修改性)而不牺牲可读性。
- 直观解释(”它是什么?”):
new Thread(new Runnable() { public void run() { ... } })与new Thread(() -> { ... })表达的是同一件事,后者只是把「创建一个只为了装这段代码的对象」这件事的噪声去掉了。 - 关键规则与最佳实践:
- 这里没有魔法:Java 仍然没有一等函数。lambda 只在编译器能验证两件事时可用:(1)能推断出函数对象的类型(例如
Thread构造器接受Runnable);(2)该类型是函数式接口——只有一个抽象方法的接口。 - 因此
Runnable、Comparator、ActionListener、自定义的NumberListener都能写成 lambda;而含两个抽象方法的接口不行。 - 方法引用(如
System.out::println)是更进一步的简写,含义是「把这个已有方法当作函数对象」。 - 短小的 lambda 提升可读性;语句过多、逻辑复杂的 lambda 应当抽成命名方法或命名类,否则会打断周围代码的阅读。
- 这里没有魔法:Java 仍然没有一等函数。lambda 只在编译器能验证两件事时可用:(1)能推断出函数对象的类型(例如
监听器模式 / 发布-订阅(Listener Pattern / Publish-Subscribe)
- 定义与目的:监听器模式描述「事件源产生离散事件流,一个或多个监听者订阅这个流并提供事件发生时被调用的函数」这一结构。它让 GUI 库组件(按钮、滚动条、文本框、菜单)各自自包含地处理自己的输入,是实现「模块化 GUI」的关键。
- 直观解释(”它是什么?”):想象一家出版社(事件源)与许多订户(监听器):出版社有新刊就发给所有订户,订户各自决定怎么读;出版社不需要知道订户是谁、要做什么。课程称之为 Listener pattern,也叫 Publish-Subscribe。
- 关键规则与最佳实践:
- 一次典型的注册:
playButton.addActionListener(回调)。这里JButton是事件源,事件是「按钮被按下」,监听器是那个ActionListener实例,被调用的函数是actionPerformed。 - 事件往往带附加信息,既可能打包成一个事件对象(
ActionEvent、MouseEvent、HttpExchange),也可能直接作为回调参数传入。 - 事件发生时,事件源把事件分发给所有已订阅的监听器,逐个调用它们的回调方法。
- 不只按钮有事件:
JButton在按下时发 action 事件(无论鼠标还是键盘)、JList在选择变化时发选择事件、JTextField在文本变化时发变更事件;HTML 中<button>的click事件是它收到mousedown与mouseup之后合成的。 - 事件源通常提供
addXxxListener/removeXxxListener(Java)或addEventListener/on(TypeScript、Node)来注册与注销。
- 一次典型的注册:
事件循环与事件队列(Event Loop & Event Queue)
- 定义与目的:事件循环是 GUI 与 JavaScript 运行时的核心:一个 FIFO 的事件队列存放各种来源到达的事件,事件循环反复从队列取出事件并调用相应回调。理解它才能理解「为什么监听器必须快速返回」。
- 直观解释(”它是什么?”):事件循环像一位只有一个窗口的银行柜员:柜台前有一条队伍(事件队列),柜员每次只服务一位客户(调用一个回调),服务完必须叫下一位。如果某位客户赖着不走(回调长时间不返回),后面所有人都在等待——用户界面就「卡住」了,鼠标变成转圈的沙漏或风车。
- 关键规则与最佳实践:
- Java 的 GUI 库在创建第一个 GUI 对象时就会自动创建一个事件处理线程(Swing 中即事件分派线程 event dispatch thread, EDT),它与程序的
main线程不同,负责事件循环并调用监听器。 - 在 Java 里这个循环被隐藏在工具包内部(常常运行在独立的线程上),监听器「看起来像是被魔法调用的」。
- 所有 GUI 代码都跑在事件循环上,因此所有代码都必须及时回到事件循环。
- 定时器到期、GUI 的鼠标/键盘事件、文件与网络 I/O 完成,都是事件的来源,都要经这条队列排队。
- 事件循环的存在意味着:GUI 程序在你看不到的地方已经是并发的——这直接引出 Reading 21。
- Java 的 GUI 库在创建第一个 GUI 对象时就会自动创建一个事件处理线程(Swing 中即事件分派线程 event dispatch thread, EDT),它与程序的
控制反转(Inversion of Control, IoC,”Don’t call us, we’ll call you”)
- 定义与目的:控制反转指「谁调用谁」的决定权从调用方转移到被调用方:客户端不再主导控制流,而是把代码交给模块,由模块决定调用时机。它服务于可修改性与易理解性,但也把「时序责任」转移给了程序员。
- 直观解释(”它是什么?”):课程练习的标题就是那句好莱坞名言「别打给我,我会打给你」。注册监听器之后,你的代码什么时候被执行不由你决定,而由事件源与事件循环决定。
- 关键规则与最佳实践:
- 在客户端视角,这段关系是:客户端创建回调函数 → 传给实现者 → 事件发生时实现者调用它。要能明确回答「客户端是谁、那段代码是什么、模块是谁」。
- 控制反转让时序不再可预测:回调可能在你没预期的时候发生(用户点得快、网络来得慢、定时器先到)。
- 因此回调里不要假设任何关于「其他回调是否已经跑过」「共享对象处于什么状态」的隐含条件。
- 控制反转是后续 Web 服务器(路由处理器)、异步计算(Reading 22)、并发(Reading 21/23)等所有「回调式系统」的共同前提。
回调中的共享可变状态与再入(Shared Mutable State & Reentrancy)
- 定义与目的:这是本讲最重要、也最容易踩坑的部分。实现者要维护「监听器集合」与「计数/状态」这些可变的 rep;当回调在遍历这个集合的过程中反过来修改它(再入,reentrancy),或当回调由另一个线程调用时,共享可变状态就会出问题。
- 直观解释(”它是什么?”):
callListeners()正在「挨个给订户打电话」,某个订户在电话里说「把我从名单上划掉」——如果名单正在被遍历,这个划掉动作会让遍历器失效。又或者,你一边在遍历名单一边另一个线程在往名单里加人,同样会出问题。 - 关键规则与最佳实践:
- 用防御性拷贝(defensive copy)让遍历发生在一个独立的集合对象上:
for (NumberListener l : new HashSet<>(listeners)),这样addNumberListener/removeNumberListener在回调内被调用也不会破坏遍历(否则会抛ConcurrentModificationException)。 - 让事件源的 rep 从设计之初就为线程安全做准备:课程给出的方案是把所有公共方法都标
synchronized,采用监视器模式(monitor pattern),rep 由对象锁保护。(锁与监视器模式的系统讲解属于 Reading 23。) - 在监听器规格里说明:监听器不应注册/注销监听器,或者说明实现支持这种再入行为;二者必须与实现一致。
- 监听器里的长耗时计算要交给后台线程(Java 中如
SwingWorker、或自建Thread),并把界面更新切回事件分派线程(SwingUtilities.invokeLater),不要阻塞事件循环。(补充说明:SwingWorker与SwingUtilities.invokeLater是 Java Swing 生态的标准做法,课程原文以setTimeout与「使用并发」的方式表达同一思想。)
- 用防御性拷贝(defensive copy)让遍历发生在一个独立的集合对象上:
阻塞事件循环与忙等待(Blocking the Event Loop / Busy-Waiting)
- 定义与目的:事件循环是单线程的服务者,任何「占着控制流不还」的代码都会让它停摆。认识这一点是为了让 GUI 保持响应性(responsiveness),这是可用性层面的硬性要求。
- 直观解释(”它是什么?”):
busyWait(milliseconds)死盯着时钟自旋到时间到达,期间事件在队列里越堆越多、一个都不处理;等它终于返回,堆积的定时器回调全部迟到触发。这种「自旋等待外部变化」的写法叫忙等待,在事件循环运行时里是严重的代码坏味道。 - 关键规则与最佳实践:
- 监听器必须快速运行、快速返回,通常在几毫秒以内。
- 需要等待外部变化时,应当注册一个由该变化触发的回调(如定时器),而不是自旋检查。
- 监听器里做长时间计算(例如一次画一百万个图形)会让整页冻结:鼠标点不动、滚动无效、按键没反应。
- 若监听器确实要长时间计算,必须使用并发(Reading 21 起的主题)。
- 判断标准很简单:问「这个回调要跑多久?」,超过几毫秒就要重新设计。
代码示例与对比分析
场景 1:GUI 的输入处理——写一个集中式的巨型输入循环 vs 让每个组件自己注册回调
❌ 错误代码
// 错误:把所有输入分派逻辑写在一个无限循环里(伪代码风格,真实 GUI 里这样写会毁掉模块化)
public static void mainLoop(JButton playButton, JButton stopButton, JSlider volumeSlider) {
while (true) {
MouseEvent click = readMouseClick(); // 假设存在这样一个底层调用
int x = click.getX(), y = click.getY();
if (playButton.contains(x, y)) {
playSound();
} else if (stopButton.contains(x, y)) {
stopSound();
} else if (volumeSlider.contains(x, y)) {
volumeSlider.setValue(volumeSlider.getValueFromPosition(x, y));
} else if (/* ... 系统里还有几十个组件 ... */ false) {
// ...
}
}
}
【错误代码的问题】
- 不模块化:
mainLoop必须知道系统里每一个组件的存在、位置与语义。新增一个按钮就要改这个函数,违反「对修改封闭」的期望,属于可修改性的直接损失。 - 责任错位:按钮本该「自包含地处理自己的输入」(它最清楚自己被点中意味着什么),现在却要由外部代码去猜。
- 无法复用:这段逻辑只适用于这个特定的界面;GUI 库组件(滚动条、文本框、菜单)本来是通用的,一旦要求外部循环分派,通用性就没了。
- 不可维护:成千上万行
else if是典型的「无所不知、无所不控的巨兽」,既不易读也极易出错(漏判、顺序错、坐标判断写错)。
✅ 正确代码
// 正确:每个组件自己处理输入,只把「按下时做什么」作为回调交出去
JFrame frame = new JFrame("Sound Player");
JButton playButton = new JButton("Play");
JButton stopButton = new JButton("Stop");
// 匿名类写法(sp21 原文风格)
playButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent event) {
playSound();
}
});
// lambda 写法(Java 8+,同一个函数对象,更简洁)
stopButton.addActionListener(event -> stopSound());
frame.setLayout(new FlowLayout());
frame.add(playButton);
frame.add(stopButton);
frame.pack();
frame.setVisible(true);
// 框架内部的输入事件循环负责把鼠标/键盘事件分派到正确的组件,
// 再由组件调用我们注册的回调 —— 我们不再写任何分派代码
【为什么这样更好】 分派逻辑被下沉到 GUI 工具包内部,客户端只声明「这个按钮被按下时做什么」。新增按钮不需要改动任何既有代码,只需为新按钮注册回调——这正是「Ready for change」。按钮作为一个自包含组件,自己处理鼠标与键盘输入,客户端代码不依赖坐标或输入设备细节。
【代码对比解说】 两种写法的根本差异是依赖方向。巨型循环要求「中心知道所有组件」,是自上而下的强耦合;监听器模式让「组件知道自己的行为」,中心只需要把输入事件广播到组件树。前者的复杂度随组件数线性(甚至超线性)增长在一个函数里;后者把复杂度分散到各自独立的注册点。代价是控制流变得「不可见」——监听器看起来像被魔法调用,阅读代码时需要知道事件循环的存在。
【设计原则透视】 这是抽象边界的选择:GUI 组件把「输入设备细节」封装起来,只暴露一个「被激活」的抽象事件(action event),客户端不必依赖它的表示(坐标、绘制方式)。它同时体现了可修改性的核心手段——把「变化的部分」(行为)以参数(回调)的形式注入,而不是写在实现里。可以说,回调就是「把策略作为一等值传递」,与 Reading 8(不可变性)中「把不变的部分固定、把变化的部分参数化」是同一思路的两种体现。
场景 2:定时回调——忘记递减导致无限循环 vs 递归式倒计时
❌ 错误代码
/** 每 1000 毫秒调用一次 callback,但 ticks 从不递减 —— 会永远打印同一个值。 */
public static void countdown(int ticks, IntConsumer callback) {
final int millisecondsPerTick = 1000;
// javax.swing.Timer 是 Java 中与 sp22 的 setTimeout 对应的定时器回调机制,
// 它的监听器在事件分派线程上被异步调用
Timer timer = new Timer(millisecondsPerTick, null);
timer.addActionListener(event -> {
callback.accept(ticks); // ticks 永远不变
if (ticks > 0) {
timer.restart(); // 条件永远为真(当 ticks > 0 时),永不停止
}
});
timer.setRepeats(false);
timer.start();
callback.accept(ticks); // 规格要求第一次同步调用
}
【错误代码的问题】
- 永远不终止:
ticks从不变化,if (ticks > 0)恒为真,回调每秒触发一次直到进程结束——既浪费资源也违反规格(应当在 0 处停下)。 - 难以察觉:程序不崩溃、不报错,只是「一直在响」;若在单元测试里运行,测试会直接挂死(也说明回调式代码的测试需要考虑终止性)。
- 递减位置敏感:即使补上
--ticks,放错位置也会出错——例如在调用callback之前递减,会漏掉最后的 0;放在if判断之后太晚,则又会多跑一轮。位置决定了「最后一次回调能否打印 0」。
✅ 正确代码
/**
* Start a timer that ticks once per second until it expires.
*
* @param ticks duration of the timer in seconds, must be an integer >= 0
* @param callback callback function, initially called synchronously, then called
* again after each tick of the timer, each time passing the number
* of seconds left until the timer expires.
* ticksLeft must be an integer in [0, ticks].
*/
public static void countdown(int ticks, IntConsumer callback) {
if (ticks > 0) {
// javax.swing.Timer:延迟 1000 毫秒后异步调用一次监听器
Timer timer = new Timer(1000, null);
timer.setRepeats(false);
timer.addActionListener(event -> countdown(ticks - 1, callback));
timer.start();
}
callback.accept(ticks); // 第一次是同步调用:在 countdown 返回之前发生
}
【为什么这样更好】 采用递归式设计:每次计时到点就调用 countdown(ticks - 1, callback),把「剩余秒数」变成新的参数,而不是去修改一个被闭包捕获的可变变量。递归天然地保证了终止(ticks 降到 0 时不再登记定时器),也精确实现了规格要求「第一次同步调用,之后每次异步调用,值依次为 ticks, …, 0」。
【代码对比解说】 这里有两个关键教学点。第一,代数式的状态传递优于可变状态的就地修改:countdown(ticks - 1, ...) 把状态放在参数里,杜绝了「忘记递减」这类错误;可变版本则要求程序员记住在正确的位置、正确的时机改它。第二,规格明确区分了「同步的第一次」与「异步的后续」——callback.accept(ticks) 在 countdown 返回前执行,因此输出顺序中会先出现 ticks,再出现随后的递减值。这种「先同步一次再异步若干次」的模式在真实的进度回调里非常常见。
【设计原则透视】 这体现了规格的完整性:调用次数、调用时机、参数取值范围都被写进 @param callback 的说明里,客户端的实现才有依据。也预演了 Reading 21 的核心教训——异步回调意味着「共享可变状态」会在不可预测的时刻被读写;此处通过「不共享可变状态」(把状态作为参数传递)回避了该风险,是并发设计的第一个技巧:优先消除共享可变状态。
场景 3:事件源的实现——直接遍历监听器集合 vs 防御性拷贝(再入 bug)
❌ 错误代码
public class Counter {
private BigInteger number = BigInteger.ZERO;
private Set<NumberListener> listeners = new HashSet<>();
public interface NumberListener {
/** Called when the counter changes.
* @param number the new number */
void numberReached(BigInteger number);
}
public synchronized void increment() {
number = number.add(BigInteger.ONE);
callListeners();
}
public synchronized void addNumberListener(NumberListener listener) {
listeners.add(listener);
}
public synchronized void removeNumberListener(NumberListener listener) {
listeners.remove(listener);
}
// 错误:直接在 listeners 字段上迭代;若某个监听器在回调里 remove 自己,迭代器立刻失效
private void callListeners() {
for (NumberListener listener : listeners) {
listener.numberReached(number);
}
}
}
【错误代码的问题】
- 再入导致
ConcurrentModificationException:客户端若写成「打印后把自己注销」(counter.removeNumberListener(this)),HashSet的迭代器会在下次next()时抛出ConcurrentModificationException,程序中断。 - 部分监听器可能收不到通知:迭代被中断意味着排在其后的监听器不会被调用,事件被静默地漏发。
- 锁不能救它:Java 的锁是可重入的(reentrant),同一个线程再次进入
synchronized removeNumberListener不会被阻塞——所以这不是死锁问题,synchronized解决不了它。这是学生最容易误判的地方。 - 在别的线程里更糟:如果计数器由后台线程持续递增,主线程同时注册/注销监听器,问题会以
ConcurrentModificationException或更隐蔽的数据竞争形式出现,且难以复现。
✅ 正确代码
public class Counter {
private BigInteger number = BigInteger.ZERO;
private final Set<NumberListener> listeners = new HashSet<>();
// Abstraction function
// AF(number, listeners) = a counter currently at `number`
// that sends events to the `listeners` whenever it changes
// Rep invariant
// true
// Thread safety argument
// uses the monitor pattern -- the rep is guarded by this object's lock,
// acquired on entering every public method
public interface NumberListener {
/** Called when the counter changes.
* @param number the new number */
void numberReached(BigInteger number);
}
public synchronized BigInteger number() { return number; }
public synchronized void increment() {
number = number.add(BigInteger.ONE);
callListeners();
}
public synchronized void addNumberListener(NumberListener listener) {
listeners.add(listener);
}
public synchronized void removeNumberListener(NumberListener listener) {
listeners.remove(listener);
}
// 正确:在集合的防御性拷贝上迭代,回调中的注册/注销不会破坏本次遍历
private void callListeners() {
for (NumberListener listener : new HashSet<>(listeners)) {
listener.numberReached(number);
}
}
}
【为什么这样更好】 new HashSet<>(listeners) 产生一个独立的快照集合:遍历发生在快照上,addNumberListener/removeNumberListener 即便在回调内被调用,修改的也是原集合,不会让正在迭代的迭代器失效。语义上这还带来一个明确的规定:本次事件只通知「事件开始时已注册」的监听器,本次新注册的监听器从下一次事件起生效。这是可测试、可写进规格的行为。
【代码对比解说】 这个 bug 的隐蔽性在于「单线程、无锁竞争」也会发生:它纯粹是回调再入(回调在实现者的临界区内反调实现者的公共方法)造成的。修复方式有两种取向:一是让遍历基于快照(本例),二是禁止监听器在回调里注册/注销(在规格里禁止)。前者更宽容、更常用;后者更简单但把责任推给客户端,一旦客户端违反就是 bug。课程选的是前者,并用「防御性拷贝」这个老朋友来实现——同一技巧在 Reading 8(不可变性)里用来保护 rep 不被泄露,这里用来保护遍历过程不被并发修改,思想完全一致。
【设计原则透视】 与 AF/RI 显式关联:注释里写出了 AF(number, listeners) 与 RI(此处平凡为 true,因为 HashSet 与 BigInteger 自身没有额外的约束)。与线程安全论证显式关联:所有公共方法 synchronized,rep 由对象锁保护,属于监视器模式(monitor pattern);而 callListeners() 是私有方法、只在持锁的公共方法内被调用,因此不需要再同步。(锁、监视器模式与线程安全论证的完整讨论属于 Reading 23。)特别注意:监听器本身在锁内被调用,若监听器又去获取别的锁,可能引发死锁——这属于 Reading 23「锁的顺序」要处理的问题。
场景 4:回调的异常处理——以为能在登记处捕获异步回调的异常 vs 明确区分同步/异步
❌ 错误代码
static void alwaysThrows() {
throw new RuntimeException("boom!");
}
// 错误:以为把登记调用包在 try 里就能捕获回调抛出的异常
public static void main(String[] args) {
try {
// javax.swing.Timer:登记回调后立刻返回,一秒后才由事件循环调用
Timer timer = new Timer(1000, null);
timer.setRepeats(false);
timer.addActionListener(event -> alwaysThrows());
timer.start();
} catch (RuntimeException e) {
System.out.println("caught: " + e.getMessage()); // 永远不会执行
}
System.out.println("main returns");
}
【错误代码的问题】
- catch 块永远不执行:
addActionListener/start只是登记回调,异常要等到一秒后事件循环调用回调时才抛出,那时try所在的栈帧早已退栈。 - 异常落进事件循环:它会由事件分派线程向上传播,而不是回到你的
main;程序可能打印栈迹、也可能静默地继续运行后面的 GUI 代码,行为难以预料。 - 错误的安全感:这段代码读起来「已经处理了异常」,实际上没有任何处理,属于典型的假防御。
✅ 正确代码
static void alwaysThrows() {
throw new RuntimeException("boom!");
}
public static void main(String[] args) {
Timer timer = new Timer(1000, null);
timer.setRepeats(false);
timer.addActionListener(event -> {
try {
alwaysThrows(); // 同步回调:可以在调用点周围捕获
} catch (RuntimeException e) {
System.out.println("caught in listener: " + e.getMessage());
}
});
timer.start();
System.out.println("main returns"); // 立刻打印,不等一秒
}
【为什么这样更好】 异常处理必须发生在异常实际抛出的那个调用栈里:同步回调的调用点在 main 的 try 内(如 list.forEach(alwaysThrows) 的情形),可以被外层捕获;异步回调的调用点在事件循环里,唯一能捕获它的地方是回调自身的方法体(或事件循环提供的统一异常处理钩子)。
【代码对比解说】 这条规则可以推广为一个判断方法:问「这行代码执行时,回调函数在栈上吗?」。list.forEach(f) 会立刻调用 f,所以 f 的异常会沿 forEach 向上传回调用者;timer.start() 与 addEventListener 不会调用回调,因此包住它们的 try 毫无作用。另外注意 main 会立刻返回:异步的 main 不会等回调——如果进程因为 main 返回而退出,回调可能永远没有机会执行(这也是「回调可能被调用零次」的现实来源)。
【设计原则透视】 这仍然是规格边界问题:回调接口的规格必须说明调用发生在哪个执行上下文(哪个线程、同步还是异步),调用者才能正确放置异常处理与同步措施。它也直接指向 Reading 21:异步回调的执行上下文往往是另一个线程,因此「回调里访问的对象」变成了共享可变状态。
场景 5(补充):匿名类 vs lambda——同一函数对象的两种写法
❌ 错误代码
// 错误:同一个监听器实现被 Ctrl-C/Ctrl-V 了三份,修改时必须同时改三处
playButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent event) { player.play(); }
});
stopButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent event) { player.play(); } // 复制粘贴来的
});
pauseButton.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent event) { player.play(); } // 又一份
});
【错误代码的问题】
- 违反 DRY:同一段逻辑有三份副本,任何修改都必须同步三处,遗漏一处就是 bug。
- 噪声淹没了意图:三处
new ActionListener() { public void actionPerformed(...) }的样板代码让「按下时播放」这个真正的意图不显眼。 - 可读性下降:读者需要越过语法噪声才能看清「谁在按下时做什么」。
✅ 正确代码
// 正确:把可复用的行为提取成一个具名方法对象,需要时用方法引用或 lambda 注入
ActionListener play = event -> player.play();
playButton.addActionListener(play);
stopButton.addActionListener(play);
pauseButton.addActionListener(play);
// 只在一个地方使用的一次性行为,就地写 lambda 即可
exitButton.addActionListener(event -> System.exit(0));
【为什么这样更好】 用「一个具名的函数对象」表达被复用的行为,既消除重复,又让三个注册点各自只有一行、意图一目了然;一次性行为用就地 lambda,读起来最直接。这也和 Reading 20 的结论呼应:回调让行为不必硬编码进实现,那么行为本身也应该像数据一样被合理地组织、命名与复用。
【代码对比解说】 匿名类与 lambda 表达的是同一个对象,取舍在于信息密度与可读性:匿名类适合需要(在 Java 8 之前)明确写出接口与方法的场合,或需要多条语句且有状态的长实现;lambda 适合短小的一次性实现。真正的坏味道不是「用匿名类」或「用 lambda」,而是复制粘贴——那说明这段行为应当被提取、命名与复用。
【设计原则透视】 这里对应 Reading 4(代码评审)与 DRY 原则:代码应当只在一个地方表达一个知识。也体现函数对象的价值:既然函数是对象,它就可以被赋给变量、被命名(ActionListener play)、被传递多次——这正是「一等函数」带来的组织能力。
与其他设计原则的关联
- Reading 16(Map/Filter/Reduce):本讲明确以它作为「同步回调」的先例——传给
map/filter/reduce的函数就是一等函数与回调的第一次亮相。两讲的对比(同步 vs 异步)是本讲的核心时间维度。 - Reading 12(接口、泛型、枚举):函数对象由单抽象方法接口(
Runnable、Comparator<T>、ActionListener、NumberListener)定义,lambda 只能用于这类函数式接口;泛型还会出现在事件源的类型签名里(如Comparator<Dog>)。 - Reading 6/7(规格说明与设计规格):回调参数的规格必须写清调用次数、时机(同步/异步)、参数范围与线程;「监听器必须快速返回」也是一条应当写进规格的性能约定。
- Reading 8(不可变性)与 Reading 11(AF/RI):
callListeners()的防御性拷贝与 Reading 8 中「保护 rep 不被泄露」是同一技巧;Counter的 AF/RI/线程安全论证是 Reading 11 报告格式的直接应用。 - Reading 9(避免调试):忙等待与大段阻塞代码让 GUI 变得不可调试;「避免使用全局可变状态」在本讲体现为「不要在回调之间共享可变状态」。
- Reading 21(并发):本讲直接为之铺垫——GUI 工具包自动创建的事件处理线程、
HttpServer创建的网络线程都意味着程序已经并发;回调可能在任何时刻、在另一个线程上被调用,「共享可变状态」的风险由此而来。事件处理系统的交错(interleaving)是 Reading 21 的第一个案例。 - Reading 22(异步编程)与 Reading 23(锁):异步回调的异常处理、
Promise/CompletableFuture式的组合,是 Reading 22 的主题;本讲中Counter的synchronized与「监听器在锁内被调用」的死锁风险,将在 Reading 23 系统展开。 - Reading 25(Socket 与网络):sp21 的
HttpServer例子与本讲共用「回调处理输入事件」的骨架:路由处理器HttpHandler.handle(HttpExchange)与按钮监听器是同一个模式的两个实例。
关键要点
- 回调 = 客户端交给模块调用的代码;它把控制流方向反转(IoC),因此必须按规格处理「何时被调用、被调用几次、在哪个线程被调用」。
- 区分同步与异步回调:同步回调的异常可在调用点周围捕获、副作用保证已发生;异步回调两者都不成立,只能在回调自身内部处理异常。
- 在 Java 里用单抽象方法接口 + lambda/方法引用来表达一等函数;短小的就地 lambda 与具名复用的函数对象各得其所,避免复制粘贴。
- 监听器必须快速返回:它是事件循环上的一环,长耗时应交给后台线程/并发,绝不要忙等待。
- 事件源的实现要防再入、防并发:遍历监听器集合时用防御性拷贝;从设计之初就写出线程安全论证(监视器模式)。
常见陷阱与注意事项
- 在监听器里做长耗时计算或忙等待 → 事件循环被阻塞,界面冻结、定时器回调迟到,用户看到沙漏/风车光标。
- 以为把
addEventListener/addActionListener/start包在try里能捕获回调异常 → 异步回调的异常不在那条调用栈上,catch永不执行;正确做法是在回调体内捕获。 - 在回调中注销监听器(再入)却让实现直接在集合字段上迭代 →
ConcurrentModificationException,并且排在其后的监听器收不到事件;用防御性拷贝(new HashSet<>(listeners))解决。 - 以为给方法加
synchronized就能修复再入 bug → Java 锁可重入,同一线程重入不会阻塞,问题依然存在;同步解决的是跨线程问题,不是再入问题。 - 在回调之间共享可变状态(例如一个共享的
List/计数器字段) → 回调可能由事件线程调用、可能被重复调用,状态会以不可预测的顺序被修改;优先用参数传递状态(如countdown(ticks - 1, callback))。 - 监听器环与监听器泄漏:
JTextField与JSlider互相更新对方的监听器若由代码修改触发事件,就可能无限循环(注意规格中「事件仅在用户操作时发出」,并避免在回调里触发自己监听的事件);另一方面,长生命周期的HttpServer/Timer持有短生命周期对象的回调引用却忘记注销,会让这些对象无法被回收。
思考题(带答案)
问题 1:下面两段代码都让「一秒后抛出异常」的回调执行。为什么只有第二段的 catch 能生效?请从「回调执行时栈上有什么」的角度解释,并说明这对「回调的规格」提出了什么要求。
// 第一段:同步回调
try { List.of(1, 2, 3).forEach(n -> { throw new RuntimeException("boom"); }); }
catch (RuntimeException e) { System.out.println("caught"); }
// 第二段:异步回调
try { timer.addActionListener(event -> { throw new RuntimeException("boom"); }); timer.start(); }
catch (RuntimeException e) { System.out.println("caught"); }
答案:关键在于回调函数被调用时,哪些栈帧还在。forEach 是同步的:它在执行过程中调用回调,此时 try 所在的 main/当前方法的栈帧仍然活着,异常沿 forEach → 回调 的调用栈向上传播,能被外层的 catch 捕获。addActionListener/start 是异步的:它们只是把回调对象登记进去并立刻返回,一秒后由事件分派线程的事件循环调用回调,那时登记处所在的栈帧早已退栈,异常只会在事件循环的栈上传播,catch 自然不生效——唯一能捕获它的地方是回调自己的方法体(或事件循环的统一异常处理钩子)。因此回调的规格必须说明:调用是同步还是异步、在哪个线程上发生、可能被调用多少次;否则客户端无法知道异常处理该放在哪里、共享状态需要怎样保护。这也解释了为什么「回调让控制流不再属于你」是本讲反复强调的结论。
问题 2:Counter 的实现里,所有公共方法都标了 synchronized,callListeners() 却仍然可能抛出 ConcurrentModificationException。请解释原因,并给出修复方案,说明修复后「一次事件通知哪些监听器」的语义。
答案:原因是再入(reentrancy):increment() 持锁调用 callListeners(),后者正在用 HashSet 的迭代器遍历 listeners;某个监听器在自己的回调里调用 counter.removeNumberListener(this)(或 addNumberListener),这会在同一个线程里直接修改 listeners,从而使正在使用的迭代器失效,下一次 next() 抛 ConcurrentModificationException。锁救不了它,因为 Java 的 synchronized 锁是可重入的:同一线程再次获取同一把锁会立即成功,不会阻塞,所以再入照样发生。修复方案是在一份防御性拷贝上迭代:for (NumberListener listener : new HashSet<>(listeners)) { listener.numberReached(number); }。这样回调中的注册/注销修改的是原集合,遍历使用的是独立快照,不会失效。由此得到的明确语义是:一次事件只通知该事件开始时(快照建立时)已注册的监听器;在本次回调中新注册的监听器从下一次事件开始生效,已被移除的监听器在本次事件中仍会被通知(如果它出现在快照里且尚未被访问)。这个语义应当写进 Counter 的规格说明,让客户端可以依赖它。
问题 3:GUI 程序里为什么说「回调自然引出并发」?请结合 Java Swing 的事件处理线程与 sp21 中 HttpServer 的行为说明,并指出由此带来的两个具体的共享可变状态风险。
答案:因为回调的调用方不再是你写的代码,而是事件源与事件循环,而事件循环通常运行在另一个线程上。Java 的 GUI 库在创建第一个 GUI 对象时就会自动创建一个事件处理线程(事件分派线程),它与 main 线程是两个线程,负责读取鼠标/键盘事件并调用监听器;同样地,HttpServer 会创建新线程来监听连接、解析 HTTP 请求并调用路由处理器回调。因此即使程序员没有显式写 new Thread(...),程序也已经是并发的:main 线程与事件线程同时在运行同一个程序、共享同一片堆内存。由此产生的两个具体风险是:(1)共享可变对象的竞态——如果 main 线程与事件线程都读写同一个数据结构(例如同时往一个 ArrayList 里添加元素、或同时更新同一个计数器字段),交错执行可能导致数据丢失、ConcurrentModificationException 或更隐蔽的不一致状态;(2)再入与锁内调用回调——实现者常常在持锁的状态下调用监听器(如 synchronized increment() 内调用 callListeners()),监听器又在回调里回到同一对象,这既可能触发上面的再入 bug,也可能因为监听器去获取另一把锁而形成锁顺序问题,进而在 Reading 23 的语境下引发死锁。这两个风险正是 Reading 21 要正式引入的主题:共享内存模型、交错执行与竞态条件。
