Your Interface Has Two Channels
Your Interface Has Two Channels
你的接口有两个通道
This code would easily pass a cursory review: 这段代码很容易通过粗略的审查:
const response = await fetch('https://example.com/flags.json')
const flags = await response.json()
startServer(flags)
Then one day the endpoint returns a 500, flags becomes { error: 'Internal Server Error' }, no key matches a real option, and the server silently starts with every default.
然后有一天,端点返回了 500 错误,flags 变成了 { error: 'Internal Server Error' },没有任何键匹配到真实的选项,服务器便在静默中以所有默认值启动了。
fetch doesn’t reject on HTTP errors. It resolves either way, and nothing in the interface tells you to check response.ok. The bug isn’t that you decided to skip error handling. You never realized there was a decision.
fetch 在遇到 HTTP 错误时不会拒绝(reject)。无论如何它都会解析(resolve),而且接口中没有任何内容提示你需要检查 response.ok。这个 Bug 并不是因为你决定跳过错误处理,而是因为你根本没意识到这里存在一个需要决策的点。
Everyone has used an interface that threw them into the Pit of Despair like this. I’ve hit the bottom enough times to notice the pattern. 每个人都曾使用过像这样将他们推入“绝望深渊”的接口。我跌入谷底的次数足够多,足以让我注意到其中的模式。
For each concern an interface exposes, it either forces you to confront it or allows you to inadvertently ignore it. Ignoring a confronted concern is an intentional decision, but ignoring an unknown one commits you to assumptions you didn’t know you made. That signaling determines how the interface fails: by decision or by accident. 对于接口暴露的每一个关注点(concern),它要么强迫你面对,要么允许你无意中忽略它。忽略一个被明确提出的关注点是有意的决定,但忽略一个未知的关注点则会让你陷入自己都未曾察觉的假设中。这种信号传递方式决定了接口如何失败:是基于决策的失败,还是意外的失败。
Once you see interfaces this way, many familiar design questions become the same. Throw or return an error value? Required parameter or default? Object or union type? Each asks how loudly the interface should signal a concern. Soon you’ll have principles for answering. 一旦你以这种方式看待接口,许多熟悉的各种设计问题就变得殊途同归了。是抛出异常还是返回错误值?是必选参数还是默认参数?是对象还是联合类型?每一个问题都在询问:接口应该以多大的强度来提示一个关注点。很快,你就会形成一套回答这些问题的原则。
Concern signaling
关注点信号传递
I’m borrowing the signaling terminology from telecommunications. 我借用了电信领域的信号传递术语。
In-band signaling means control information travels in the same channel as data. Out-of-band signaling uses a separate channel for control information. 带内信号(In-band signaling)意味着控制信息与数据在同一通道中传输。带外信号(Out-of-band signaling)则使用独立的通道来传输控制信息。
The distinction maps cleanly onto interface concerns. Every interface has the same two channels, and each of its concerns travels on one of them: the channel the user must confront to use the interface at all, or the channel off to the side that they can miss. 这种区别可以清晰地映射到接口的关注点上。每个接口都有同样的两个通道,它的每一个关注点都会通过其中之一传输:要么是用户为了使用接口必须面对的通道,要么是位于一侧、用户可能会错过的通道。
Error handling
错误处理
Consider a function that returns a union of success and failure. The caller cannot use the function without being aware of the possibility of an error. 考虑一个返回成功与失败联合类型的函数。调用者如果不意识到错误的可能性,就无法使用该函数。
For example, returning Rust’s Result<T, E> type forces the caller to explicitly handle the error:
例如,返回 Rust 的 Result<T, E> 类型会强制调用者显式处理错误:
fn parse_config(raw: &str) -> Result<Config, ParseError> { ... }
// Trying to use the result without unwrapping would trigger a type error.
// If the caller decides to ignore the error, then it's intentional.
let result = parse_config(raw);
match result {
Ok(config) => start_server(config),
Err(e) => eprintln!("{e}"),
}
In this case the error is in-band. Confronting it is inseparable from using the interface. 在这种情况下,错误是“带内”的。面对错误与使用接口是不可分割的。
Now consider a function that returns Config and throws on failure. The caller can use the Config directly because the exception requires no acknowledgment.
现在考虑一个返回 Config 并在失败时抛出异常的函数。调用者可以直接使用 Config,因为异常不需要显式确认。
For example, throwing JavaScript’s Error allows the caller to proceed without confronting the error:
例如,抛出 JavaScript 的 Error 允许调用者在不面对错误的情况下继续执行:
/** @throws Error for invalid configs. */
function parseConfig(raw: string): Config {
// ...
}
// The caller may inadvertently ignore the error if they did not read the
// function documentation and are unaware it can throw.
const config = parseConfig(raw)
startServer(config)
In this case the error is out-of-band. Confronting it requires discipline, and a caller can slip past it without knowing it exists. 在这种情况下,错误是“带外”的。面对它需要自律,而调用者可能会在不知其存在的情况下忽略它。
A function that throws a checked exception moves the error back in-band. The caller is forced to explicitly catch or propagate the error. 抛出受检异常(checked exception)的函数将错误移回了“带内”。调用者被迫显式捕获或传播该错误。
For example, Java’s throws keyword makes error handling in-band:
例如,Java 的 throws 关键字使错误处理成为带内处理:
Config parseConfig(String raw) throws ParseException {
// ...
}
// The caller is forced to handle the checked exception. This won't compile
// without either catching or declaring `throws ParseException`.
void start(String raw) throws ParseException {
Config config = parseConfig(raw);
startServer(config);
}
However, a concern that’s more in-band than it deserves backfires because acknowledgment becomes a reflex. Java programmers infamously silence checked exceptions using empty catch blocks, unchecked rethrows, or throws Exception clauses.
然而,如果一个关注点被过度“带内化”,反而会适得其反,因为确认过程会变成一种机械反射。Java 程序员经常通过空的 catch 块、未受检的重新抛出或 throws Exception 子句来“静默”处理受检异常,这已是臭名昭著。
That’s worse than out-of-band. The code only looks like it confronted the concern. 这比带外处理更糟糕。代码看起来只是“好像”面对了关注点。
Rust’s success suggests Java’s failure was ergonomics, not confrontation. Result forces the same acknowledgment, but it’s pleasant to handle or propagate.
Rust 的成功表明 Java 的失败在于工程学(人体工程学),而非对抗性。Result 强制要求同样的确认,但处理或传播它却很愉悦。
Checked exceptions also reveal that the channel carrying the data is not the same as the channel that carries the concern. A checked exception travels outside the return value, yet the concern is in-band. The inverse mismatch exists too: in-band data does not imply in-band concerns. 受检异常也揭示了携带数据的通道与携带关注点的通道并非同一个。受检异常在返回值之外传输,但关注点却是带内的。反之亦然:带内数据并不意味着带内关注点。
For example, C-style -1 sentinel values are in-band data-wise, but out-of-band concern-wise because the user could overlook checking for the sentinel:
例如,C 语言风格的 -1 哨兵值在数据层面是带内的,但在关注点层面却是带外的,因为用户可能会忽略对哨兵值的检查:
// `open` signals failure via a -1 return value. The caller may not check for -1
// if they're unaware it's a possible return value.
int fd = open("config.json", O_RDONLY);
// Undefined behavior if `fd == -1`.
read(fd, buf, sizeof(buf));
Naming
命名
Names can move concerns in-band or out-of-band. 命名可以将关注点移入带内或移出带外。
For example, Java’s HashSet has no guaranteed iteration order, but the name only describes the implementation, not the ordering property. The iteration order can coincidentally match insertion order for small sets, so a user could depend on it without realizing:
例如,Java 的 HashSet 不保证迭代顺序,但其名称仅描述了实现方式,而非排序属性。对于小型集合,迭代顺序可能巧合地与插入顺序一致,因此用户可能会在未意识到的情况下依赖它:
// May print in insertion order for small sets, tempting the user to depend on
// an ordering that is not guaranteed.
HashSet<Integer> set = new HashSet<>(List.of(3, 1, 4, 1, 5));
for (int value : set) {
System.out.println(value);
}
Java’s TreeSet moves the ordering concern in-band. The name signals a tree structure, which strongly implies sorted iteration, so the user can infer that ordering is part of the contract:
Java 的 TreeSet 将排序关注点移到了带内。其名称暗示了树结构,这强烈暗示了有序迭代,因此用户可以推断出排序是契约的一部分:
// The name hints at sorted order. The user is more likely to recognize that
// ordering is a deliberate property.
TreeSet<Integer> set = new TreeSet<>(List.of(3, 1, 4, 1, 5));
for (int value : set) {
System.out.println(value);
}
Union types
联合类型
Unions are the usual tool for making illegal states unrepresentable, and that moves concerns in-band as a byproduct because each variant is a case the user must consider. 联合类型是使非法状态无法表示的常用工具,作为副产品,它将关注点移到了带内,因为每一个变体都是用户必须考虑的情况。
But legality and signaling are independent. A union can move a concern in-band even whe… 但合法性与信号传递是独立的。联合类型可以将关注点移入带内,即使在……