方法的函数式视角

表象下的同构

1,018 words 8 min read
目录 9 节
  1. 从 Go 的 receiver 开始
  2. method value:receiver 已绑定
  3. method expression:receiver 未绑定
  4. 部分应用
  5. 部分应用不是柯里化
  6. Scala:实例方法转成函数
  7. Java 方法引用里的 this
  8. Rust 里的 self
  9. 结语

很多东西对外表现是不同的,但本质上它们是同构的。

面向对象语法里的方法调用通常是:

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.AddCounter 这个 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

部分应用以后,widthheight 被填掉,只剩 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 => Intc 这个实例已经跟着函数值一起走了,后面调用 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 的语法各不相同,但核心问题是相同的:

点号左边的那个东西,到底是特殊的对象,还是一个特殊位置的参数?

面向对象视角里,它是对象,是调用的主体;函数式视角里,它是参数,是可以绑定、传递、展开的一部分。