方法的函数式视角
表象下的同构
目录
很多东西对外表现是不同的,但本质上它们是同构的。
面向对象语法里的方法调用通常是:
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。
对于值接收者,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
它有具体的求值时机,也有具体的拷贝语义。
method expression:receiver 未绑定
反过来,如果不是从某个具体实例上取方法,而是从类型上取方法,就会得到 method expression。
f := Counter.Add
fmt.Println(f(c, 3)) // 13
这时 f 的签名就变了。它不再是:
func(int) int
而是:
func(Counter, int) int
receiver 被放回了参数列表里,而且在第一个位置。
c.Add // receiver 已经绑定,得到 func(int) int
Counter.Add // receiver 尚未绑定,得到 func(Counter, int) int
这种因为绑定实例导致函数签名发生变化很像fp里的部分应用。
部分应用
在 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, _)
这些都是部分应用。它不在乎你传入参数的顺序和数量。重点是:先“捕获”一部分参数,得到一个还能继续调用的新函数。
再把 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 确实给了一个很接近的东西。
部分应用不是柯里化
还是用刚才的 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
两者的区别:
部分应用:先传入一部分参数,得到一个参数更少的函数
柯里化:先改变函数形态,把多参数函数拆成单参数函数链
Scala:实例方法转成函数
同样的转换在Scala里也存在。
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(_)
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
Java: s::charAt // this 已绑定
Java: String::charAt // this 未绑定
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
这三种写法是完全不同的语义。你是拿走这个值,还是只读借用,还是可变借用,都被编译器严格检查。
结语
尽管 Go、Scala、Java、Rust 的语法各不相同,但核心问题是相同的:
点号左边的那个东西,到底是特殊的对象,还是一个特殊位置的参数?
面向对象视角里,它是对象,是调用的主体;函数式视角里,它是参数,是可以绑定、传递、展开的一部分。