从Go Receiver看:方法的函数式视角

作者:lanran 发布于:2026年6月12日

表象下的同构

目录 10 节
  1. 从 Go 的 receiver 开始
  2. method value:receiver 已绑定
  3. method expression:receiver 未绑定
  4. 部分应用
  5. 部分应用不是柯里化
  6. Scala:实例方法转成函数
  7. Java 方法引用里的 this
  8. Rust 里的 self 更显眼
  9. 回到 Go:克制,但不贫瘠
  10. 点号左边的那个参数

很多东西对外表现是不同的,但本质上它们是同构的。比如 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,还剩 heightdepth

(Int, Int) => Int

固定 widthheight,只剩 depth

Int => Int

只固定中间的 height,则还剩 widthdepth

(Int, Int) => Int

这些都是部分应用。它不在乎你固定的是第几个参数,也不要求你一次只固定一个参数。重点是:先填掉一部分参数,得到一个还能继续调用的新函数。

再把 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 确实给了一个很接近的东西。它没给自己贴 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

部分应用以后,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

两者的区别:

部分应用:先填掉一部分参数,得到一个参数更少的函数
柯里化:先改变函数形态,把多参数函数拆成单参数函数链

它们经常一起用,所以容易混在一起。但不是同一个动作。

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 => 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(_)

这个对照就很接近 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 比较绕的地方在于,方法引用自己不能孤零零地站着。它需要一个目标函数式接口来接住,比如 FunctionBiFunction。这也很 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 和所有权系统绑得那么紧。它站在一个很朴素的位置上:足够简单,但又能让你看到方法和函数之间的桥。

很多时候,理解一个语言特性,不一定要记更多术语,而是换一个摆放方式。

把点号左边的东西放回参数列表里,很多事情就清楚了。