从Go Receiver看:方法的函数式视角
表象下的同构
目录
很多东西对外表现是不同的,但本质上它们是同构的。比如 Go 里的 receiver,Scala 里的部分应用,Java 里的方法引用,Rust 里的 self。一个像是工程语言里的方法语法,一个像是函数式编程里的抽象技巧,一个又像是 Java 为了兼容面向对象和 lambda 做出来的折中设计。
但它们其实可以被同一个问题串起来:点号左边的那个东西,到底是不是参数?
面向对象语法通常会把它藏起来,让我们写成:
c.Add(3)
函数式视角则会更愿意把它摊开:
Add(c, 3)
从 Go 的 receiver 开始
先看一个很普通的 Go 例子。
type Counter struct {
n int
}
func (c Counter) Add(x int) int {
return c.n + x
}
正常会这样调用:
c := Counter{n: 10}
fmt.Println(c.Add(3)) // 13
这行代码看起来是“对象调用方法”。但 Go 的 receiver 并没有 Java 那种隐式 this 的感觉,它在函数定义里是显式定义的:
func (c Counter) Add(x int) int
这个 c Counter 很有意思。它不在普通参数列表里,但它又确实是这个方法运行时需要的一个值。receiver 其实就是一个被语法特殊照顾的参数。
所以 c.Add(3) 也可以这么读:把 c 作为 receiver 传给 Add,再把 3 作为普通参数传进去。
点号左边的 c 并不神秘。它只是站在了一个比较特殊的位置上。
method value:receiver 已绑定
Go 里有个机制值得停一下看:method value。
f := c.Add
fmt.Println(f(3)) // 13
这里的 f 是什么?
从调用方式看,它已经不像一个带 receiver 的方法了,而像:
func(int) int
因为取 c.Add 的时候,c 已经跟这个函数值绑在一起了。后面再调用 f,只需要传 x。
这就像提前填好了一个参数。只不过 Go 在这里提前填的不是普通参数,而是 receiver。
再细一点,对于值接收者,method value 创建时会把当时那份 receiver 一起带进函数值里。这个感觉有点像闭包捕获变量,只是这里捕获的是 receiver 这一位。
c := Counter{n: 10}
f := c.Add
c.n = 20
fmt.Println(f(1)) // 11
fmt.Println(c.Add(1)) // 21
这个例子能看出一点 Go 自己的味道:f := c.Add 不是每次调用时再去读当前的 c,而是在创建 method value 的那一刻,就已经带着当时那份 receiver 了。
它不是一个悬浮在空中的数学函数。它有具体的求值时机,也有具体的拷贝语义。
method expression:receiver 未绑定
反过来,如果不是从某个具体实例上取方法,而是从类型上取方法,就会得到 method expression。
f := Counter.Add
fmt.Println(f(c, 3)) // 13
这时 f 的形状就变了。它不再是:
func(int) int
而是:
func(Counter, int) int
receiver 被放回了参数列表里,而且站在第一个位置。
这两个写法并排放着看,Go 方法的函数形状就出来了。
c.Add // receiver 已经绑定,得到 func(int) int
Counter.Add // receiver 尚未绑定,得到 func(Counter, int) int
前者像是“已经把点号左边那个参数填好了”,后者则是“先别急,把点号左边那个参数也交给调用者传”。
走到这里,再谈部分应用就不突兀了。
部分应用
在 FP 里,函数不一定要一次把参数都给齐。一个多参数函数,可以先给它一部分参数,让它变成一个“还差几个参数”的新函数。这个过程一般叫 partial application,中文常译作部分应用。
先不看计算结果,只看函数类型:
(A, B) => C
// 固定 A 以后
B => C
(A, B, C) => D
// 固定 A 以后
(B, C) => D
(A, B, C) => D
// 固定 A 和 B 以后
C => D
(A, B, C) => D
// 只固定中间的 B
(A, C) => D
部分应用像是:原来有几个空位,现在先填掉几个,剩下的空位组成一个新函数。
Scala 里可以写得很直接。比如一个三参数函数:
def volume(width: Int, height: Int, depth: Int): Int =
width * height * depth
val withWidth10: (Int, Int) => Int =
volume(10, _, _)
val withBase10x20: Int => Int =
volume(10, 20, _)
val withHeight20: (Int, Int) => Int =
volume(_, 20, _)
volume 本来需要三个参数:
(Int, Int, Int) => Int
固定 width,还剩 height 和 depth:
(Int, Int) => Int
固定 width 和 height,只剩 depth:
Int => Int
只固定中间的 height,则还剩 width 和 depth:
(Int, Int) => Int
这些都是部分应用。它不在乎你固定的是第几个参数,也不要求你一次只固定一个参数。重点是:先填掉一部分参数,得到一个还能继续调用的新函数。
再把 Go 的 receiver 放进来:
f := c.Add
Counter.Add 原本可以从函数视角理解成:
func(Counter, int) int
现在 c.Add 把 Counter 这个 receiver 固定住,于是剩下:
func(int) int
Go 的 method value 很像是对 receiver 这一位做了一次部分应用。
这不是说 Go 支持 Scala 那种通用的部分应用语法。你不能随便拿一个普通函数,然后用占位符固定它的第一个参数或第二个参数。Go 没把这件事做成一个普遍的 FP 特性。
但在 receiver 这里,Go 确实给了一个很接近的东西。它没给自己贴 FP 标签,可视角换过来,结构就在那里。
部分应用不是柯里化
还是用刚才的 volume 举例。部分应用面对的是一个多参数函数,然后先填掉其中一部分参数:
def volume(width: Int, height: Int, depth: Int): Int =
width * height * depth
val withBase10x20: Int => Int =
volume(10, 20, _)
volume 的形状原本是:
(Int, Int, Int) => Int
部分应用以后,width 和 height 被填掉,只剩 depth:
Int => Int
柯里化处理的是另一件事:它把多参数函数拆成一串单参数函数。还是同一个 volume,换成柯里化写法会变成:
def volumeCurried(width: Int)(height: Int)(depth: Int): Int =
width * height * depth
它的形状不是:
(Int, Int, Int) => Int
而更接近:
Int => Int => Int => Int
调用时也是一层一层往里走:
val width10 = volumeCurried(10)
// Int => Int => Int
val base10x20 = width10(20)
// Int => Int
val result = base10x20(30)
// 6000
两者的区别:
部分应用:先填掉一部分参数,得到一个参数更少的函数
柯里化:先改变函数形态,把多参数函数拆成单参数函数链
它们经常一起用,所以容易混在一起。但不是同一个动作。
Go 的 method value 更像前者:receiver 被提前填好了,于是方法变成了一个参数更少的函数。
Scala:实例方法转成函数
不过只拿 add(10, _) 举例还不够贴近 Go。那讲的是普通参数,receiver 的问题还是要落到实例方法上。
class Counter(val n: Int) {
def add(x: Int): Int = n + x
}
val c = new Counter(10)
val f: Int => Int = c.add
println(f(3)) // 13
这里 c.add 被放进了一个函数类型 Int => Int。c 这个实例已经跟着函数值一起走了,后面调用 f 的时候,只需要再传入 x。
如果用 Scala 2 里更显式的写法,大概会看到:
val f = c.add _
这里的 _ 差不多是在说:先别急着调用 add,把这个方法转成一个函数值。这个过程通常叫 eta-expansion。
c.add 里,实例 c 已经被绑定了,留下来的函数形状是:
Int => Int
如果实例不提前绑定,就把它写进函数参数里:
val g: (Counter, Int) => Int =
(counter, x) => counter.add(x)
println(g(c, 3)) // 13
如果喜欢占位符,也可以写得更短:
val g: (Counter, Int) => Int =
_.add(_)
这个对照就很接近 Go 了:
Scala: c.add // 实例已绑定,近似 Int => Int
Scala: _.add(_) // 实例未绑定,近似 (Counter, Int) => Int
实例对象本身就是方法的“函数表现形式”的一部分。你把实例提前塞进去,它就是一个少一个参数的函数;不提前塞进去,它就要作为函数的第一个参数出现。
Java 的方法引用也在处理类似的问题,只是它把这层转换单独做成了 :: 语法。
Java 方法引用里的 this
Java 8 里先看绑定实例的方法引用。
比如:
String s = "hello";
Function<Integer, Character> f = s::charAt;
System.out.println(f.apply(1)); // e
s::charAt 里,s 已经固定住了,所以函数只需要一个 Integer 参数。写成函数签名,大概是:
Integer -> Character
如果不绑定某个具体字符串,就写成:
BiFunction<String, Integer, Character> f = String::charAt;
System.out.println(f.apply("hello", 1)); // e
这时 String::charAt 需要调用者传入一个 String,再传入下标。函数签名就变成:
(String, Integer) -> Character
拿它和 Go 对一下,关系就很直观。
Go: c.Add // receiver 已绑定
Go: Counter.Add // receiver 未绑定
Java: s::charAt // this 已绑定
Java: String::charAt // this 未绑定
Java 比较绕的地方在于,方法引用自己不能孤零零地站着。它需要一个目标函数式接口来接住,比如 Function 或 BiFunction。这也很 Java:想引入函数式能力,又必须把它塞进原有的类型系统和面向对象模型里。
所以 Java 里的方法引用经常给人一种“带着脚镣跳舞”的感觉。它能做,但是有限制。
Rust 里的 self 更显眼
Rust 这边反而更直白。它把 self 摆到了明面上。
struct Counter {
n: i32,
}
impl Counter {
fn add(&self, x: i32) -> i32 {
self.n + x
}
}
调用时是这样:
let c = Counter { n: 10 };
println!("{}", c.add(3)); // 13
但如果把方法作为函数项来看,Counter::add 的形状会直接露出来:
let f: fn(&Counter, i32) -> i32 = Counter::add;
println!("{}", f(&c, 3)); // 13
&self 进入函数签名以后,就是:
fn(&Counter, i32) -> i32
这和前面那些例子接上了:方法调用语法看起来是 object.method(arg),展开以后,都可以看成 method(object, arg)。
Rust 更狠的地方在于,它不只是把 receiver 当成参数,还把 receiver 的所有权和借用方式也放进类型里:
self
&self
&mut self
这三种写法不是语法装饰,而是完全不同的语义。你是拿走这个值,还是只读借用,还是可变借用,都会进入编译器的检查。
这样一来,Rust 的 self 就比 Go 的 receiver 更重。Go 的 receiver 更像是方法语法的一部分,Rust 的 self 则直接牵住了所有权系统。
回到 Go:克制,但不贫瘠
我挺喜欢 Go 的一个地方是,它经常克制表达。
它不会告诉你:“这里有高阶函数,这里有部分应用,这里有函数式抽象。”它只是给你一些很简单的机制:函数是一等值,方法可以取出来,receiver 可以绑定,也可以不绑定。
然后你真的需要时,会发现这些机制够用了。
比如你可以把某个实例的方法塞进回调里:
type Logger struct {
prefix string
}
func (l Logger) Print(msg string) {
fmt.Println(l.prefix + msg)
}
func Run(callback func(string)) {
callback("started")
}
logger := Logger{prefix: "[job] "}
Run(logger.Print)
logger.Print 在这里就是一个 func(string)。它把 logger 这个 receiver 先绑定住,然后交给 Run 调用。
用起来很普通,但那层函数形状还在。
再看指针接收者:
type Counter struct {
n int
}
func (c *Counter) Inc(x int) {
c.n += x
}
c := Counter{n: 10}
f := c.Inc
f(3)
fmt.Println(c.n) // 13
这里 f := c.Inc 绑定的就不是一个普通的值拷贝感觉了。因为 Inc 是指针接收者,f 后续操作的是同一个 Counter。
对应的 method expression 也要写得更明确:
f := (*Counter).Inc
f(&c, 3)
它的形状是:
func(*Counter, int)
你可以从 FP 角度说它像部分应用,但语言不是活在概念里的,语言最终还是要在工程里落地。
点号左边的那个参数
如果把 Go、Scala、Java、Rust 放在一起看,会发现很多语法差异背后,其实是在处理同一个问题:方法里的那个“主体”到底怎么进入函数?
Scala 会说,方法可以转成函数,实例可以被绑定进去,参数也可以固定、柯里化和组合。
Java 会说,我有方法引用,但你得给我一个函数式接口来承接它。
Rust 会说,self 是参数,而且它的所有权和借用方式必须讲清楚。
Go 则会说,receiver 写在函数名前面;你要是取 c.Add,那 receiver 就绑定好了;你要是取 Counter.Add,那 receiver 就作为第一个参数传。
它们的语法各不相同,但那个核心问题一直在那里:
点号左边的那个东西,到底是特殊的对象,还是一个特殊位置的参数?
我的理解是,两种说法都对。面向对象视角里,它是对象,是调用的主体;函数式视角里,它是参数,是可以绑定、传递、展开的一部分。
Go 的 receiver 有意思的地方就在这里。它不像 Java 那样把 this 藏得很深,也不像 Scala 那样把函数抽象推到台前,更不像 Rust 那样把 self 和所有权系统绑得那么紧。它站在一个很朴素的位置上:足够简单,但又能让你看到方法和函数之间的桥。
很多时候,理解一个语言特性,不一定要记更多术语,而是换一个摆放方式。
把点号左边的东西放回参数列表里,很多事情就清楚了。