行为传递:Java 与 Go 的两条路线
编程里的行为传递
很多时候,我们只是想把一段行为传进去。
比如:
- 收到一个请求时做什么?
- 遍历一个集合时怎么处理元素?
- 某个事件发生时执行哪段逻辑?
这听起来像函数式编程里的问题:函数是一等值,所以行为可以直接传递。
但 Java 和 Go 都需要结合自身的类型系统。它们都要回答一个很现实的问题:我怎么把一段行为,放进已有的类型系统里传递?
Java 和 Go 都给了答案,而且很有意思的是,它们解决的是同一个问题,但走的是两条相反的路。
Java 是“接口像函数”。 Go 是“函数像接口实现”。
Java:接口函数化
Java 8 以后可以写 lambda。
比如:
@FunctionalInterface
interface Handler {
void handle(String name);
}
static void execute(Handler h) {
h.handle("lanran");
}
execute(name -> System.out.println("hello " + name));
这里的 name -> System.out.println(...) 看起来像一个函数。
但在 Java 里,lambda 不能脱离目标类型单独存在。它必须被某个类型接住。
这个类型就是函数式接口。
Handler 只有一个抽象方法:
void handle(String name);
所以编译器知道,下面这个 lambda:
name -> System.out.println("hello " + name)
可以被适配成 Handler。
换句话说:
lambda -> target type -> functional interface instance
Java 的中心仍然是接口。
不是函数自己突然变成了一个自由的值,而是接口先定义出“行为的形状”,lambda 再被放进这个形状里。
这也是为什么 Java 里会有很多这样的接口:
Runnable
Consumer<T>
Function<T, R>
Predicate<T>
Supplier<T>
它们看起来像函数类型,其实仍然是接口。
比如:
Function<String, Integer> length = s -> s.length();
Integer n = length.apply("hello");
这里的函数签名大概是:
String -> Integer
但 Java 不会真的给你一个裸的函数类型。它给的是:
Function<String, Integer>
然后通过 apply 调用。
Java 的函数式接口就像是:只要一个接口足够像函数,就允许 lambda 进来。
Go:函数类型接口化
Go 的路线完全不同。
Go 没有 Java 那种 lambda target type 机制。Go 也没有 @FunctionalInterface。
但 Go 有一个很特别的能力:定义的函数类型(defined function type)可以定义方法。
这个机制在标准库里最经典的例子,就是 net/http。
http.Handler 大概长这样:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
也就是说,一个 HTTP handler 只要能响应:
ServeHTTP(w, r)
就可以被当作 handler。
但很多时候,我们只是想写一个函数:
func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hello")
}
它的签名和 ServeHTTP 需要的参数是一样的,只是它本身还不是一个 http.Handler。
这时候 http.HandlerFunc 出场了。
它本质上就是一个函数类型:
type HandlerFunc func(ResponseWriter, *Request)
然后它给自己补了 ServeHTTP 方法:
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
这样 HandlerFunc 就满足了 Handler 接口。
关键链路是:
function -> defined function type -> method set -> interface
HandlerFunc 的底层是函数,但它已经是一个命名类型。它有了名字,就可以定义方法;它有了 ServeHTTP,就满足了 Handler。
Go 这边的感觉是: 函数先变成一种类型,这种类型再通过方法集实现接口。
这也是 Go 的隐式接口出现的地方。你不需要写 implements Handler,方法集对得上,它就是。
于是你可以这样写:
http.Handle("/hello", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hello")
}))
很多时候甚至还会被封装得更自然:
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hello")
})
HandleFunc 只是把这个适配过程再包得顺手一点。
这里的核心仍然是:函数类型 + 方法 = 接口适配器。
当 net/http 把它当成 Handler 调用 ServeHTTP 时,它转身执行函数本身。没有继承,也没有显式实现声明,只是在函数和接口之间做了一个小的桥接。
为什么不直接传函数?
这里还有一个很自然的问题:Go 明明可以直接把函数作为参数,为什么还要绕一圈接口?
比如:
func execute(f func(string)) {
f("lanran")
}
如果只是一次性的 callback,这当然够用。
但 http.Handler 不是普通 callback。它抽象的不是“一段函数”,而是一个能处理 HTTP 请求的角色。
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
它真正想表达的是:
我不关心你内部是什么。
只要你能 ServeHTTP,你就是一个 HTTP handler。
这个东西可以是函数:
http.HandlerFunc(fn)
也可以是带状态的结构体:
type Server struct {
db *DB
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// use s.db
}
还可以是中间件、路由器、文件服务器、测试里的 mock。
比如中间件通常会写成:
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Println(r.URL.Path)
next.ServeHTTP(w, r)
})
}
如果 net/http 只接受 func(ResponseWriter, *Request),它当然也能工作,但抽象会被压扁成“回调函数”。而 Handler 这个接口表达的是一个更稳定的能力边界。
所以根本答案不是“Go 为了融入类型系统,所以包装一下函数”。
更准确地说:
func(...) 表达的是一段可调用代码。
Handler 表达的是一种处理 HTTP 请求的能力。
HandlerFunc 让普通函数也能扮演这种能力。
HandlerFunc 的价值就在这里。它不是为了绕,而是为了让最轻的函数写法也能进入更大的组件抽象里。
两种适配方向
Java 和 Go 都在解决“如何传递行为”这个问题。
但方向刚好相反。
| 维度 | Java 函数式接口 | Go 函数类型实现接口 |
|---|---|---|
| 中心 | 接口 | 方法集 |
| 核心机制 | lambda 适配目标接口 | 函数类型定义方法 |
| 类型系统味道 | 名义类型 + target typing | 结构化接口 + 隐式满足 |
| 函数是否能有方法 | lambda 本身不能 | 定义的函数类型可以 |
| 适配方向 | 接口接纳函数 | 函数类型满足接口 |
| 典型例子 | Runnable / Consumer / Function | http.HandlerFunc |
Java 是:
interface Handler {
void handle(...)
}
// lambda -> Handler
Go 是:
type HandlerFunc func(...)
func (f HandlerFunc) Handle(...) {
f(...)
}
// HandlerFunc -> Handler
Java 的接口像一个函数槽位。只要接口只有一个抽象方法,lambda 就可以填进去。
Go 的函数类型像一个可以长方法的值。只要方法集对得上,它就可以进入接口世界。
一个有趣的反差
Java 的函数式接口,是把接口压缩成一个函数形状。
Go 的 HandlerFunc,是把函数抬升成一个有方法集的类型。
前者像是:接口降维成函数来用。
后者像是:函数升维成接口实现来用。
这两个说法不一定严谨到能进语言规范,但很适合理解它们的设计气质。
Java 很在意接口作为抽象边界。即使引入 lambda,它也没有抛开接口,而是让 lambda 服务于接口。
Go 很在意方法集和隐式接口。即使是一段函数,只要你给它一个命名类型,再补上方法,它也可以自然地进入接口系统。
所以两者表面上都能写:把一段行为传进去。
但背后的模型完全不同。
回到那个问题
Java 和 Go 都是在解决同一个问题:怎么把一段行为放进已有的抽象里传递。
但它们不是在同一个维度上做这件事。
Java 是从接口侧开放入口:这个接口只有一个抽象方法,所以 lambda 可以进来。
Go 是从类型侧补齐能力:这个函数类型有某个方法,所以它满足接口。
一个让接口变得像函数。
一个让函数类型变得像接口实现。
这也是这两个语言很有意思的地方:它们都想让行为变成值,但都没有完全离开自己的类型系统。
看起来都只是传一个函数,实际上背后是两套语言哲学在轻轻握手。