【Go】深入理解函數
概念
在計算機程序設計中,函數其實是一種抽象概念,是一種編程接口;通過抽象,能夠實現將複雜的系統分解成各種包裝了複雜算法的不透明接口,方便彼此相互調用,實現分層、擴展性、便利性等等。
具體來講,函數一般是指一段獨立的、可重複利用的程序邏輯片段,用來方便其他函數調用;英文名稱是function
,有時候也稱爲method
、routine
。
編譯器最終將函數編譯爲機器指令,保存在可執行文件中。
在進程的內存空間中,一個函數只不過是一段包含機器指令的連續內存區域;僅僅從結構上來講,和數組沒什麼區別。
在Go
語言中,函數(function
)是一等公民(first-class citizen
),不僅僅是代碼片段,也是一種數據類型;和其他數據類型一樣有自己的類型信息。
函數類型
函數類型的定義有多處,它們是等價的。
在runtime/type.go
源文件中定義如下:
type functype struct {
typ _type
inCount uint16
outCount uint16
}
在reflect/type.go
和internal/reflectlite/type.go
源文件中定義如下:
// funcType represents a function type.
//
// A *rtype for each in and out parameter is stored in an array that
// directly follows the funcType (and possibly its uncommonType). So
// a function type with one method, one input, and one output is:
//
// struct {
// funcType
// uncommonType
// [2]*rtype // [0] is in, [1] is out
// }
type funcType struct {
rtype
inCount uint16
outCount uint16 // top bit is set if last input parameter is ...
}
從funcType
結構體的註釋中可以看到,函數類型的信息其實非常複雜。
其實完整的函數類型定義如下僞代碼所示:
type funcType struct {
rtype // 基礎類型信息
inCount uint16 // 參數數量
outCount uint16 // 返回值數量
uncommon uncommonType // 方法信息
inTypes [inCount]*rtype // 參數類型列表
outTypes [outCount]*rtype // 返回值類型列表
methods [uncommon.mcount]method // 方法列表
}
uncommonType
和method
定義在reflect/type.go
源文件中,用於存儲和解析類型的方法信息。
type uncommonType struct {
pkgPath nameOff // 包路徑名稱偏移量
mcount uint16 // 方法的數量
xcount uint16 // 公共導出方法的數量
moff uint32 // methods相對本對象起始地址的偏移量
_ uint32 // unused
}
// 非接口類型的方法
type method struct {
name nameOff // 方法名稱偏移量
mtyp typeOff // 方法類型偏移量
ifn textOff // 通過接口調用時的地址偏移量;接口類型本文不介紹
tfn textOff // 直接類型調用時的地址偏移量
}
type nameOff int32 // offset to a name
type typeOff int32 // offset to an *rtype
type textOff int32 // offset from top of text section
-
nameOff
是相對.rodata
節起始地址的偏移量。 -
typeOff
是相對.rodata
節起始地址的偏移量。 -
textOff
是相對.text
節起始地址的偏移量。 -
關於
reflect.name
的介紹,請閱讀 內存中的整數 。
函數類型結構分佈示意圖
完整的函數類型信息結構分佈如下圖所示:
每一種函數都有自己的類型信息,只不過有的函數簡單,有的函數複雜,並不是每一種函數類型包含上圖中的所有字段。
簡單的函數類型信息結構分佈可能如下圖所示:
或者
當然,函數也可能有參數無返回值,函數還可能無參數有返回值,它們的類型信息結構還會有一點點不同,想象一下,不過只是一種簡化的結構罷了。
通過本文的內存分析,我們將會了解函數類型的每一個細節。
環境
OS : Ubuntu 20.04.2 LTS; x86_64
Go : go version go1.16.2 linux/amd64
聲明
操作系統、處理器架構、Go
版本不同,均有可能造成相同的源碼編譯後運行時的寄存器值、內存地址、數據結構等存在差異。
本文僅包含 64
位系統架構下的 64
位可執行程序的研究分析。
本文僅保證學習過程中的分析數據在當前環境下的準確有效性。
本文僅討論普通函數和聲明的函數類型,不討論接口、實現、閉包等知識點。
代碼清單
package main
import (
"errors"
"fmt"
"reflect"
)
// 聲明函數類型
type calc func(a, b int) (sum int)
// 私有的方法 -> package scope
//go:noinline
func (f calc) foo(a, b int) int {
return f(a, b) + 1
}
// Ree 公共導出的方法 -> public scope
//go:noinline
func (f calc) Ree(a, b int) int {
return f(a, b) - 1
}
func main() {
// 普通函數
Print(fmt.Printf)
// 函數類型實例
var add calc = func(a, b int) (sum int) {
return a + b
}
fmt.Println(add.foo(1, 2))
fmt.Println(add.Ree(1, 2))
Print(add)
// 匿名函數
Print(func() {
fmt.Println("hello anonymous function")
})
// 方法;閉包
f := errors.New("hello error").Error
Print(f)
}
//go:noinline
func Print(i interface{}) {
v := reflect.ValueOf(i)
fmt.Println("類型", v.Type().String())
fmt.Println("地址", v)
fmt.Println()
}
運行效果
以上代碼清單,主要打印輸出了四個函數的類型和內存地址。
編譯並運行,輸出如下:
在本文的內存分析過程中,存在許多通過偏移量計算內存地址的操作。
主要涉及到 .text
和 .rodata
兩個 section,在本程序中它們的信息如下:
普通函數
以fmt.Printf
這個常用的函數爲例,研究普通函數的類型信息。
從上面的運行輸出結果可以看到,fmt.Printf
函數類型的字符串表示形式爲:
func(string, ...interface {}) (int, error)
動態調試
在Print
函數入口處設置斷點,查看fmt.Printf
函數的類型信息。
將fmt.Printf
函數的類型信息繪製成圖表如下:
-
rtype.size = 8
-
rtype.ptrdata = 8
-
rtype.hash = 0xd9fb8597
-
rtype.tflag = 2 =
reflect.tflagExtraStar
-
rtype.align = 8
-
rtype.fieldAlign = 8
-
rtype.kind = 0x33
-
rtype.equal = 0 = nil
-
rtype.str = 0x00005c90 =>
*func(string, ...interface {}) (int, error)
-
rtype.ptrToThis = 0
-
funcType.inCount = 2
-
funcType.outCount = 0x8002
-
funcType.inTypes = [0x4a4860, 0x4a2f80]
-
funcType.outTypes = [0x4a41e0, 0x4a9860]
指針常量
函數對象的大小是8
字節(rtype.size
),而且包含8
字節的指針數據(rtype.ptrdata
),所以我們可以將函數對象視爲指針。
也就是說fmt.Printf
其實是一個指針,只不過這個指針是一個不可變的常量。這與C/C++
是一致的,函數名稱就是一個指針常量。
類型名稱
rtype.tflag = 2 = reflect.tflagExtraStar
fmt.Printf
函數有自己的數據類型,但是該類型並沒有名稱。
數據類別
數據類別(Kind)的計算方法如下:
const kindMask = (1 << 5) - 1
func (t *rtype) Kind() Kind { return Kind(t.kind & kindMask) }
0x33 & 31 = 19 = reflect.Func
可變參數
fmt.Printf
函數的參數數量(funcType.inCount
)是2
,返回值數量也是2
,可funcType.outCount
值爲什麼是0x8002
?
原因是funcType.outCount
字段不但需要記錄函數返回值的數量,還需要標記函數最後一個參數是否是可變參數類型;如果是,將funcType.outCount
字段值的最高位設置爲1
。
在reflect/type.go
源文件中,判斷可變參數的方法如下:
func (t *rtype) IsVariadic() bool {
if t.Kind() != Func {
panic("reflect: IsVariadic of non-func type " + t.String())
}
tt := (*funcType)(unsafe.Pointer(t))
return tt.outCount&(1<<15) != 0
}
返回值數量的計算方式是:
outCount := funcType.outCount & (1<<15 - 1)
令人好奇的是,可變參數標記怎麼沒有保存在funcType.outCount
字段中。
參數與返回值類型
在fmt.Printf
函數定義中,參數和返回值的類型依次是:
-
string
-
...interface{}
-
int
-
error
在內存的函數類型信息中,保存的是參數和返回值的類型指針;通過這些指針查看它們的類型信息如下:
通過內存數據可以看到,fmt.Printf
函數的參數和返回值的數據類別(Kind)如下:
-
reflect.String
-
reflect.Slice
-
reflect.Int
-
reflect.Interface
關於整數及其類型的詳細介紹,請閱讀 內存中的整數 。
關於字符串及其類型的詳細介紹,請閱讀 內存中的字符串 。
在Go
語言中,error
比較特殊,它既是一個關鍵字,又是一個接口定義。關於接口類型,之後將發佈專題文章進行深入解析,暫不介紹。
關於slice
,內存中的 slice 一文曾對 []int
進行了詳細介紹 。
很明顯,fmt.Printf
函數的第二個參數不是[]int
,通過內存數據來看一看具體是什麼類型的slice
。
通過上圖可以看到,編譯器將源碼中的可變參數類型...interface{}
編譯爲[]interface {}
,從而把可變參數變成一個參數。
這種處理可變參數的方式,和Java
語言非常相似。
通過對fmt.Printf
函數的類型深入分析和了解,我們就很容易理解反射包(reflect
)中函數相關的接口了;有興趣的話可以去看一看源碼實現,相信對比fmt.Printf
函數的類型信息,是比較簡單的。
type Type interface {
...... // 省略無關接口
IsVariadic() bool
NumIn() int
NumOut() int
In(i int) Type
Out(i int) Type
...... // 省略無關接口
}
聲明的函數類型
在Go
語言中,通過type
關鍵字可以定義任何數據類型,非常非常地強悍。
在本文的代碼清單中,我們就使用type
關鍵字定義了calc
類型,這明顯是一個函數類型。
type calc func (a, b int) (sum int)
這種類型與fmt.Printf
函數類型有什麼區別嗎?使用上述相同的方法,我們來深入研究下。
動態調試
從內存數據可以看出,calc
類型的add
變量指向一個匿名函數,該匿名函數被編譯器命名爲main.main.func1
。
calc
的類型信息非常複雜,共128
個字節,整理成圖表如下:
-
rtype.size = 8
-
rtype.ptrdata = 8
-
rtype.hash = 0x405feca1
-
rtype.tflag = 7 =
reflect.tflagUncommon | reflect.tflagExtraStar | reflect.tflagNamed
-
rtype.align = 8
-
rtype.fieldAlign = 8
-
rtype.kind = 0x33
-
rtype.equal = 0 = nil
-
rtype.str = 0x00002253 =>
*main.calc
-
rtype.ptrToThis = 0x0000ec60
-
funcType.inCount = 2
-
funcType.outCount = 1
-
funcType.inTypes = [0x4a41e0, 0x4a41e0]
-
funcType.outTypes = [0x4a41e0]
-
uncommonType.pkgPath = 0x0000034c =>
main
-
uncommonType.mcount = 2
-
uncommonType.xcount = 1
-
uncommonType.moff = 0x48
-
method[0].name = 0x000001a8 =>
Ree
-
method[0].mtyp = 0xffffffff
-
method[0].ifn = 0x00098240
-
method[0].tfn = 0x00098240
-
method[1].name = 0x000001f6 =>
foo
-
method[1].mtyp = 0xffffffff
-
method[1].ifn = 0x000981e0
-
method[1].tfn = 0x000981e0
類型名稱
rtype.tflag
字段包含reflect.tflagNamed
標記,表示該類型是有名稱的。
calc
類型的名稱爲calc
,獲取方式定義在reflect/type.go
源文件中:
func (t *rtype) hasName() bool {
return t.tflag&tflagNamed != 0
}
func (t *rtype) Name() string {
if !t.hasName() {
return ""
}
s := t.String()
i := len(s) - 1
for i >= 0 && s[i] != '.' {
i--
}
return s[i+1:]
}
func (t *rtype) String() string {
s := t.nameOff(t.str).name()
if t.tflag&tflagExtraStar != 0 {
return s[1:]
}
return s
}
類型指針
rtype.ptrToThis = 0x0000ec60
該值是相對程序 .rodata
section 的偏移量。在本程序中,.rodata
section 的起始地址是 0x49a000
。
calc
類型的指針類型爲*calc
,類型信息保存在地址 0x49a000+0x0000ec60
處。關於指針類型本文不再進一步介紹。
參數和返回值
calc
類型有2
個參數和1
個返回值,而且都是int
類型(信息保存在0x4a41e0
地址處)。
類型方法
方法本質上就是函數。
在 A Tour of Go (https://tour.golang.org/methods/1) 中,對函數的定義爲:
A method is a function with a special receiver argument.
calc
是函數類型,函數類型居然能擁有自己的方法,確實是巧妙的設計。
calc
類型的rtype.tflag
字段包含reflect.tflagUncommon
標記,表示其類型信息中包含uncommonType
數據。
uncommonType
對象的大小是 16
字節,calc
類型共有3
個參數和返回值,3
個類型指針佔 24
個字節,所以 [mcount]method
相對uncommonType
對象的偏移是 16 + 24 = 40
字節。
通過計算得到如下結果:
calc
類型的Ree
方法,被重命名爲main.calc.Ree
,內存地址是0x00098240 + 0x401000 = 0x499240
。它是一個導出函數,所以reflect.name.bytes[0] = 1
。
calc
類型的foo
方法,被重命名爲main.calc.foo
,內存地址是0x000981e0 + 0x401000 = 0x4991e0
。
從內存分析結果可以看到,如果一種數據類型定義了多個方法,而且有的是名稱以大寫字母開頭公共導出方法,有的是名稱以小寫字母開頭導私有方法,那麼編譯器將公共的導出方法信息排序在前,私有方法信息排序在後,然後保存其數據類型信息中。而且這個結論可以在reflect/type.go
源碼文件中定義的兩個方法得到印證:
func (t *uncommonType) methods() []method {
if t.mcount == 0 {
return nil
}
return (*[1 << 16]method)(add(unsafe.Pointer(t), uintptr(t.moff), "t.mcount > 0"))[:t.mcount:t.mcount]
}
func (t *uncommonType) exportedMethods() []method {
if t.xcount == 0 {
return nil
}
return (*[1 << 16]method)(add(unsafe.Pointer(t), uintptr(t.moff), "t.xcount > 0"))[:t.xcount:t.xcount]
}
在本例中還可以看到,無論是Ree
方法,還是foo
方法,它們對應的method.mtyp
字段值都是0xffffffff
,也就是 -1
。
從runtime/type.go
源文件中resolveTypeOff
函數的註釋可以瞭解到,-1
表示沒有對應的類型信息。
也就是說,calc
類型的Ree
和foo
方法雖然也是函數,但是他們沒有對應的函數類型信息。
所以,Go
編譯器並沒有爲每一個函數都生成對應的類型信息,只是在需要的時候纔會生成,或者是運行時(runtime
)根據需要生成。
匿名函數
代碼清單中,第三次調用main.Print
函數輸出了一個匿名函數的類型信息。這個匿名函數沒有形成閉包,所以相對比較簡單。
將其內存數據整理成圖表如下:
該函數沒有參數、返回值、方法,所以其類型信息非常非常的簡單。相信已經不需要進一步介紹了。
總結
通過一步步的內存分析,對Go
語言的函數進行了深入的瞭解,學習了很多知識,解開了許多疑惑,相信在實際開發中必定能遊刃有餘,避免一些小坑。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/j0YYBnGsbEcD-WrKRDRSAw