Go语言对象模型 之 闭包与函数变量

闭包的实现

很多编程语言都提供了闭包这一特性,那什么是闭包呢?形象一点来描述就是“一个有状态的函数”。对于普通的函数来讲,如果只使用局部变量,那么函数是不会有状态的。局部变量分配在运行时栈上,即使是像stringslice这样的引用类型,指针也是存储在栈上的。当函数返回后,栈帧也随之销毁,所有局部变量不复存在。

闭包不仅仅是一种语法特性,在实现上也有其特殊之处。它“捕获”外层函数的局部变量,在外层函数返回之后,被捕获的变量不会销毁,而且也再不能被直接访问,只能通过闭包函数来使用,从而将一个函数和一组变量打包在一起,成为一个对象,也就是闭包对象。

Go语言实现了闭包,但是没有将其称为闭包,而是称为匿名函数。我们来看如下示例:

示例1

func getCounter() func(int) int {
    c := 0
    return func(i int) int {
        c += i
        return c
    }
}

以上代码中,内层函数捕获了外层getCounter中的变量c,每次调用getCounter都会分配一个新的变量,互不影响。那么Go语言到底是如何把一个变量和一个函数打包在一起,成为一个闭包对象的呢?下面我们来看一下运行时中和闭包相关的数据结构。

runtime.funcval

type funcval struct {
 fn uintptr
 // variable-size, fn-specific data here
}

通过结构名称可以看出,该结构就是用来实现“函数变量”的。其实Go语言中没有“函数指针”,而是将“闭包”和“函数指针”统一实现为“函数变量”,底层数据结构就是runtime.funcval

我们看一下上面的数据结构,其中的fn字段存储的是闭包函数的地址,而根据下面的注释能够知道这个结构的大小是不确定的,实际上编译器会把闭包函数捕获外层函数的“捕获列表”追加到fn字段之后,至于捕获列表中存储的是值还是地址,需要根据实际情况而定。

示例1中,闭包函数对变量c进行了修改,所以捕获列表中实际存储的是c的地址,而且c也要相应的在堆上分配。示例1中代码实际上会被编译器转化为下面这个样子:

// 在funcval尾部追加捕获列表
type funcval1 struct {
    runtime.funcval
    c *int
}

// 闭包函数被编译为独立的函数,通过this指针访问捕获列表
func getCounter.func1(this *runtime.funcval, i int) int {
    f1 := (*funcval1)(this)
    *f1.c += i
    return *f1.c
}

// 所有闭包和函数变量都是*runtime.funcval,只是捕获列表不同
func getCounter() *runtime.funcval {
    c := new(int)
    f := new(funcval1)
    f.fn = &getCounter.func1
    f.c = c
    return &f.funcval
}

// 调用闭包函数时
f := getCounter()
f.fn(f, 10)

以上代码无法通过编译,只是用做展示。实际上,在Go语言中闭包函数的this指针是通过指定的寄存器来传递的,不是像普通的函数参数那样通过栈传递。在目前最常见的amd64平台上,是通过寄存器RDX来传递的。我们对上面的代码稍作修改:

// 闭包函数被编译为独立的函数,RDX寄存器用作this指针
func getCounter.func1(i int) int {
    f1 := (*funcval1)(RDX)
    *f1.c += i
    return *f1.c
}

// 调用闭包函数时
f := getCounter()
RDX = f
f.fn(10)

捕获值?捕获地址?

关于闭包捕获外层变量最容易让人疑惑的地方,就是捕获值还是捕获地址。下面我们用几个示例来演示何时捕获值,何时捕获地址,同时提出一种检验方法。

检验方法

// 助手函数
func helper(arg int) {
    addr := uintptr(unsafe.Pointer(&arg))
    fmt.Printf(“post: %#x\n”, addr)
}

// 为外层函数添加一个占位参数
func getCounter(dummy int) func(int) int {
    c := 0

    // 添加如下代码块
    addr := uintptr(unsafe.Pointer(&dummy))
    fmt.Printf(“pre:  %#x\n”, addr)
    addr = uintptr(unsafe.Pointer(&c))
    fmt.Printf(“addr: %#x\n”, addr)
    helper(0)

    return func(i int) int {
        c += i
        return c
    }
}

上述检验方法的思路是:1)运行时栈向下增长;2)函数的参数通过运行时栈传递;3)函数的局部变量在运行时栈上分配,且在参数之后。

可以对上述代码的输出结果做如下推论:1)如果c在栈上分配,那么pre > addr > post;2)如果c在堆上分配,那么addr > pre或者addr < post。

相应的:1)c在栈上分配时,闭包捕获值;2)c在堆上分配时,闭包捕获地址。反推:如果闭包需要捕获地址,那么会促使c在堆上分配。

那么什么时候需要捕获地址呢?我们用一种直观的方法按步分析:1)被捕获的变量出现在两个或更多个函数的作用域中,包括外层函数和一个或多个闭包函数;2)如果在任何一个作用域中,被捕获变量都不曾被修改过(外层函数中赋初始值不算修改),那么所有闭包函数捕获值即可;3)如果被捕获变量在任何一个作用域内被修改,那么变量需要分配在堆上,所有闭包捕获地址。

示例1中的变量c在闭包函数中被修改,所以需要捕获地址,相应的也要在堆上分配。下面再来举几个例子:

捕获值

// 变量不曾被修改,捕获值
func getf() (f1, f2 func() int) {
    a := 10
    f1 := func() int {
        return a
    }
    f2 := func() int {
        return a
    }
    return f1, f2
}

捕获地址

// 变量在外层函数被修改,堆分配,捕获地址
func getf() func() int {
    a := 10
    f := func() int {
        return a
    }
    a = 20
    return f
}

捕获循环变量地址

// 循环变量在外层函数被修改,堆分配,捕获地址
func getfs(c int) (fs []func() int) {
    for i := 0; i < c; i++ {
        f := func() int {
            return i
        }
        fs = append(fs, f)
    }
    return
}

函数变量

受C语言中函数指针的影响,我们容易先入为主的认为Go语言中的函数变量就是函数指针,实则不然。Go语言中的函数变量并不是函数指针,其在实现上与上面所讲的闭包完全一致,使用相同的数据结构runtime.funcval,调用方式也完全相同,通过RDX传递funcval地址。实际上在Go语言中闭包并不是一种独特的类型,它属于函数变量的一种实现,就是有捕获列表的函数变量而已。有没有捕获列表,或者有什么样的捕获列表,不会影响函数变量的类型,函数变量的类型只跟函数原型有关,这点和普通具名函数一样。且看如下示例:

// 具名函数
func add(a, b int) int {
    return a + b
}

f := add
// 函数变量
// f := new(runtime.funcval)
// f.fn = &add

f(10, 20)
// 调用操作
// RDX = f
// f.fn(10, 20)

因为add函数没有捕获任何变量,所以实际上根本用不着通过RDX传递funcval指针,直接传递add函数地址更简洁高效。之所以这样做,为的是使调用者不必关心调用的到底是一个闭包对象还是一个普通函数。为了达成统一,函数指针迁就了闭包,一致采用闭包的方式来实现。

调用者在调用函数变量时,只需提前把RDX赋好值,然后从funcval中取出函数地址直接调用即可,至于有没有捕获列表,那是被调用函数(闭包函数)应该关心的事。

打印地址

上面我们检验变量是在栈上还是堆上分配时,使用了打印地址并比较的方法。在打印地址前为什么要将其转换为uintptr,而不是使用fmt.Printf(“%p”, &a)直接打印呢?

因为Go语言的编译器有像逃逸分析这类的高级特性,在代码中传递指针类型会引发一些意想不到的副作用,尤其是fmt.Printf这样重度依赖类型断言的函数,能带来更多的惊喜。

// 变量在闭包函数中被修改,捕获地址
func getf() func() int {
    a := 0
    return func() int {
        a++
        return a
    }
}

f1 := getf()
f2 := getf()
fmt.Printf(“%p, %p\n”, f1, f2)

以上代码打印出的两个地址竟然是相等的,不可思议。按道理每次分配新的funcval,地址是不可能相等的。其实fmt.Printf识别出f1f2是函数变量后,把funcval.fn给打印出来了,打印的是函数的真实地址,而不是funcval的地址。

如果我们要查看funcval的地址,可以对代码稍作修改:

f1 := getf()
f2 := getf()
a1 := *(*uintptr)(unsafe.Pointer(&f1))
a2 := *(*uintptr)(unsafe.Pointer(&f2))
fmt.Printf(“%#x, %#x\n”, a1, a2)

相等性

Go语言不支持比较两个函数变量是否相等,只能判断是否为nil,这是为什么?

怎么样才能算两个函数变量是相等的?首先,funcval.fn也就是函数地址要相等;其次,如果没有捕获列表,那么可以认为它们是相等的,否则要求捕获列表也是相等的。捕获列表中可能包含引用类型,因为Go语言在设计上不支持引用类型的相等性比较,所以无法保证捕获列表是可比较的。

即使像struct一样,只在不包含引用类型时可比较,又会引入另一个问题:如果在相等性比较时考虑捕获列表,就相当于说捕获列表属于函数变量类型的一部分,而不再是像之前提到的那样只考虑函数原型,进而有着不同捕获列表的函数变量属于不同类型不能通用,函数变量也就失去了存在的意义。

对于无捕获列表的函数变量,编译器会静态生成funcval,运行时就不用动态分配了,如下所示:

// 没有捕获列表
func getf() func() int {
    return func() int {
        return 10
    }
}

f1 := getf()
f2 := getf()
a1 := *(*uintptr)(unsafe.Pointer(&f1))
a2 := *(*uintptr)(unsafe.Pointer(&f2))
fmt.Printf(“%#x, %#x\n”, a1, a2)

因为编译器静态生成funcvalgetf()每次返回相同的地址,所以上述代码打印的两个地址应该是相等的。

小结

本文分析了函数变量的底层结构,探讨了捕获列表何时捕获值、何时捕获地址。最后作为总结,让我们手动构造一个函数变量:

// 变量在闭包函数中被修改,捕获地址
func getf() func() int {
    a := 0
    return func() int {
        a++
        return a
    }
}

type funcval struct {
    fn uintptr
    aa *int
}

// 利用逃逸分析实现堆分配
func newfv() *funcval {
    return new(funcval)
}

func newi() *int {
    return new(int)
}

// 通过f1取得函数实际地址
f1 := getf()
var f2 func() int
fv := newfv()
fv.aa = newi()
fv.fn = **(**uintptr)(unsafe.Pointer(&f1))
*(**funcval)(unsafe.Pointer(&f2)) = fv
fmt.Printf(“%d, %d\n”, f1(), f2())

上例中利用编译器的逃逸分析确保funcvalint在堆上分配,这样就可以安全的传递函数变量f2而不用担心内存访问异常了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,980评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,178评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,868评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,498评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,492评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,521评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,910评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,569评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,793评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,559评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,639评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,342评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,931评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,904评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,144评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,833评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,350评论 2 342

推荐阅读更多精彩内容