Go 部落格

反射定律

Rob Pike
2011年9月6日

引言

計算中的反射是指程式能夠檢查自身結構的能力,特別是透過型別;它是一種超程式設計形式。它也是一個主要的混淆來源。

在本文中,我們試圖透過解釋反射在 Go 中的工作原理來澄清問題。每種語言的反射模型都不同(許多語言根本不支援反射),但本文是關於 Go 的,因此在本文的其餘部分中,“反射”一詞應理解為“Go 中的反射”。

2022年1月補充說明:這篇博文寫於2011年,早於 Go 中的引數多型(又名泛型)。儘管該語言的這一發展並沒有使文章中的任何重要內容變得不正確,但為了避免混淆熟悉現代 Go 的人,文章在一些地方進行了調整。

型別和介面

因為反射建立在型別系統之上,所以我們從回顧 Go 中的型別開始。

Go 是靜態型別的。每個變數都有一個靜態型別,即在編譯時已知並固定的一種型別:intfloat32*MyType[]byte 等。如果宣告

type MyInt int

var i int
var j MyInt

那麼 i 的型別是 intj 的型別是 MyInt。變數 ij 具有不同的靜態型別,儘管它們具有相同的底層型別,但在沒有轉換的情況下不能相互賦值。

一類重要的型別是介面型別,它表示固定的方法集。(在討論反射時,我們可以忽略介面定義在多型程式碼中作為約束的使用。)介面變數可以儲存任何具體(非介面)值,只要該值實現了介面的方法。一個著名的例子是 io.Readerio.Writer,來自 io 包ReaderWriter 型別。

// Reader is the interface that wraps the basic Read method.
type Reader interface {
    Read(p []byte) (n int, err error)
}

// Writer is the interface that wraps the basic Write method.
type Writer interface {
    Write(p []byte) (n int, err error)
}

任何實現了具有此簽名的 Read(或 Write)方法的型別都被認為實現了 io.Reader(或 io.Writer)。出於本文討論的目的,這意味著型別為 io.Reader 的變數可以持有任何其型別具有 Read 方法的值。

var r io.Reader
r = os.Stdin
r = bufio.NewReader(r)
r = new(bytes.Buffer)
// and so on

重要的是要明確,無論 r 可能持有何種具體值,r 的型別始終是 io.Reader:Go 是靜態型別的,r 的靜態型別是 io.Reader

一個非常重要的介面型別示例是空介面

interface{}

或其等價別名,

any

它表示空方法集,任何值都滿足它,因為每個值都具有零個或更多方法。

有些人說 Go 的介面是動態型別的,但這具有誤導性。它們是靜態型別的:介面型別的變數總是具有相同的靜態型別,即使在執行時儲存在介面變數中的值可能會改變型別,該值也始終會滿足該介面。

我們需要精確地說明所有這些,因為反射和介面密切相關。

介面的表示

Russ Cox 寫了一篇 詳細的部落格文章,介紹了 Go 中介面值的表示。此處無需重複完整的故事,但有必要進行一個簡化的總結。

介面型別的變數儲存一對:賦給變數的具體值及其型別描述符。更精確地說,該值是實現介面的底層具體資料項,型別描述了該項的完整型別。例如,在

var r io.Reader
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
    return nil, err
}
r = tty

之後,r 示意性地包含 (值, 型別) 對,(tty, *os.File)。請注意,型別 *os.File 實現了除 Read 之外的其他方法;儘管介面值只提供了對 Read 方法的訪問,但內部的值攜帶了關於該值的所有型別資訊。這就是為什麼我們可以做這樣的事情

var w io.Writer
w = r.(io.Writer)

此賦值中的表示式是型別斷言;它斷言的是 r 內部的項也實現了 io.Writer,因此我們可以將其賦值給 w。賦值後,w 將包含對 (tty, *os.File)。這與 r 中持有的對相同。介面的靜態型別決定了可以使用介面變數呼叫哪些方法,即使內部的具體值可能具有更大的方法集。

繼續,我們可以這樣做

var empty interface{}
empty = w

我們的空介面值 empty 將再次包含相同的對,(tty, *os.File)。這很方便:空介面可以儲存任何值,幷包含我們可能需要的所有關於該值的資訊。

(我們在這裡不需要型別斷言,因為靜態已知 w 滿足空介面。在我們將值從 Reader 移動到 Writer 的示例中,我們需要明確並使用型別斷言,因為 Writer 的方法不是 Reader 方法的子集。)

一個重要的細節是,介面變數內部的對總是具有 (值, 具體型別) 的形式,不能具有 (值, 介面型別) 的形式。介面不持有介面值。

現在我們準備好進行反射了。

反射的第一定律

1. 反射從介面值到反射物件。

在基本層面,反射只是一種檢查儲存在介面變數中的型別和值對的機制。首先,我們需要了解 reflect 包 中的兩種型別:TypeValue。這兩種型別提供了對介面變數內容的訪問,兩個簡單的函式,稱為 reflect.TypeOfreflect.ValueOf,從介面值中檢索 reflect.Typereflect.Value 片段。(此外,從 reflect.Value 很容易獲取相應的 reflect.Type,但我們現在將 ValueType 概念分開。)

讓我們從 TypeOf 開始

package main

import (
    "fmt"
    "reflect"
)

func main() {
    var x float64 = 3.4
    fmt.Println("type:", reflect.TypeOf(x))
}

這個程式列印

type: float64

你可能會想這裡哪裡有介面,因為程式看起來像是將 float64 變數 x 傳遞給 reflect.TypeOf,而不是介面值。但它在那裡;正如 godoc 報告reflect.TypeOf 的簽名包含一個空介面

// TypeOf returns the reflection Type of the value in the interface{}.
func TypeOf(i interface{}) Type

當我們呼叫 reflect.TypeOf(x) 時,x 首先被儲存在一個空介面中,然後作為引數傳遞;reflect.TypeOf 解包該空介面以恢復型別資訊。

reflect.ValueOf 函式當然會恢復值(從現在開始我們將省略樣板程式碼,只關注可執行程式碼)

var x float64 = 3.4
fmt.Println("value:", reflect.ValueOf(x).String())

列印

value: <float64 Value>

(我們顯式呼叫 String 方法,因為預設情況下 fmt 包會深入到 reflect.Value 中顯示內部的具體值。而 String 方法不會。)

reflect.Typereflect.Value 都有許多方法,允許我們檢查和操作它們。一個重要的例子是 Value 有一個 Type 方法,返回 reflect.ValueType。另一個是 TypeValue 都有一個 Kind 方法,返回一個常量,指示儲存的專案型別:UintFloat64Slice 等。此外,Value 上的方法(如 IntFloat)允許我們獲取儲存在其中的值(作為 int64float64

var x float64 = 3.4
v := reflect.ValueOf(x)
fmt.Println("type:", v.Type())
fmt.Println("kind is float64:", v.Kind() == reflect.Float64)
fmt.Println("value:", v.Float())

列印

type: float64
kind is float64: true
value: 3.4

還有像 SetIntSetFloat 這樣的方法,但要使用它們,我們需要理解可設定性,這是反射第三定律的主題,下面將討論。

反射庫有一些值得單獨指出的特性。首先,為了保持 API 簡單,Value 的“getter”和“setter”方法對可以容納該值的最大型別進行操作:例如,所有有符號整數都是 int64。也就是說,ValueInt 方法返回一個 int64,而 SetInt 值接受一個 int64;可能需要轉換為實際涉及的型別

var x uint8 = 'x'
v := reflect.ValueOf(x)
fmt.Println("type:", v.Type())                            // uint8.
fmt.Println("kind is uint8: ", v.Kind() == reflect.Uint8) // true.
x = uint8(v.Uint())                                       // v.Uint returns a uint64.

第二個特性是,反射物件的 Kind 描述的是底層型別,而不是靜態型別。如果一個反射物件包含一個使用者定義的整數型別的值,如

type MyInt int
var x MyInt = 7
v := reflect.ValueOf(x)

vKind 仍然是 reflect.Int,即使 x 的靜態型別是 MyInt,而不是 int。換句話說,Kind 無法區分 intMyInt,儘管 Type 可以。

反射的第二定律

2. 反射從反射物件到介面值。

就像物理反射一樣,Go 中的反射會產生它的逆過程。

給定一個 reflect.Value,我們可以使用 Interface 方法恢復一個介面值;實際上,該方法將型別和值資訊打包回介面表示並返回結果

// Interface returns v's value as an interface{}.
func (v Value) Interface() interface{}

因此我們可以說

y := v.Interface().(float64) // y will have type float64.
fmt.Println(y)

來列印由反射物件 v 表示的 float64 值。

但是,我們可以做得更好。fmt.Printlnfmt.Printf 等的引數都作為空介面值傳遞,然後由 fmt 包內部解包,就像我們之前在示例中所做的那樣。因此,正確列印 reflect.Value 內容所需要做的就是將 Interface 方法的結果傳遞給格式化列印例程

fmt.Println(v.Interface())

(自從本文首次撰寫以來,fmt 包發生了一個更改,使其會自動解包 reflect.Value,因此我們只需說

fmt.Println(v)

即可獲得相同的結果,但為了清晰起見,我們在此處保留 .Interface() 呼叫。)

由於我們的值是 float64,我們甚至可以使用浮點格式,如果需要的話

fmt.Printf("value is %7.1e\n", v.Interface())

在這種情況下得到

3.4e+00

同樣,無需將 v.Interface() 的結果進行型別斷言為 float64;空介面值內部包含具體值的型別資訊,Printf 會將其恢復。

簡而言之,Interface 方法是 ValueOf 函式的逆操作,只是其結果始終是靜態型別 interface{}

重申:反射從介面值到反射物件,再返回。

反射的第三定律

3. 要修改反射物件,該值必須是可設定的。

第三定律是最微妙和令人困惑的,但如果我們從基本原理開始,就很容易理解。

這是一些不起作用的程式碼,但值得研究。

var x float64 = 3.4
v := reflect.ValueOf(x)
v.SetFloat(7.1) // Error: will panic.

如果你執行這段程式碼,它會因為一條神秘的訊息而崩潰

panic: reflect.Value.SetFloat using unaddressable value

問題不在於值 7.1 不可定址;而在於 v 不可設定。可設定性是反射 Value 的一個屬性,並非所有反射 Value 都具有此屬性。

ValueCanSet 方法報告 Value 的可設定性;在我們的例子中,

var x float64 = 3.4
v := reflect.ValueOf(x)
fmt.Println("settability of v:", v.CanSet())

列印

settability of v: false

在不可設定的 Value 上呼叫 Set 方法是錯誤的。但是什麼是可設定性?

可設定性有點像可定址性,但更嚴格。它是指反射物件可以修改用於建立反射物件的實際儲存的屬性。可設定性取決於反射物件是否持有原始項。當我們說

var x float64 = 3.4
v := reflect.ValueOf(x)

我們將 x 的副本傳遞給 reflect.ValueOf,因此作為 reflect.ValueOf 引數建立的介面值是 x 的副本,而不是 x 本身。因此,如果語句

v.SetFloat(7.1)

被允許成功,它不會更新 x,儘管 v 看起來是從 x 建立的。相反,它會更新儲存在反射值內部的 x 的副本,而 x 本身將不受影響。這將是令人困惑和無用的,因此它是非法的,可設定性就是用來避免這個問題的屬性。

如果這看起來很奇怪,那也不是。這實際上是一種熟悉的狀況,只是披著不尋常的外衣。想想把 x 傳遞給一個函式

f(x)

我們不期望 f 能夠修改 x,因為我們傳遞的是 x 值的一個副本,而不是 x 本身。如果希望 f 直接修改 x,我們必須將 x 的地址(即 x 的指標)傳遞給我們的函式

f(&x)

這既簡單又熟悉,反射的工作方式也相同。如果我們要透過反射修改 x,我們必須向反射庫提供我們想要修改的值的指標。

我們來這樣做。首先,我們像往常一樣初始化 x,然後建立一個指向它的反射值,稱為 p

var x float64 = 3.4
p := reflect.ValueOf(&x) // Note: take the address of x.
fmt.Println("type of p:", p.Type())
fmt.Println("settability of p:", p.CanSet())

到目前為止的輸出是

type of p: *float64
settability of p: false

反射物件 p 不可設定,但我們不想設定 p,而是(實際上)*p。為了獲取 p 指向的值,我們呼叫 ValueElem 方法,它透過指標進行間接引用,並將結果儲存到一個名為 v 的反射 Value

v := p.Elem()
fmt.Println("settability of v:", v.CanSet())

現在 v 是一個可設定的反射物件,正如輸出所示,

settability of v: true

由於它代表 x,我們終於可以使用 v.SetFloat 修改 x 的值了

v.SetFloat(7.1)
fmt.Println(v.Interface())
fmt.Println(x)

輸出,正如預期的那樣,是

7.1
7.1

反射可能很難理解,但它所做的事情與語言完全相同,儘管是透過反射 TypesValues 來進行的,這可能會掩蓋正在發生的事情。請記住,反射 Values 需要某物的地址才能修改它們所代表的內容。

結構體

在我們前面的例子中,v 本身不是指標,它只是從指標派生出來的。這種情況常見的發生方式是當使用反射修改結構體的欄位時。只要我們擁有結構體的地址,我們就可以修改它的欄位。

這是一個簡單的示例,它分析一個結構體值 t。我們使用結構體的地址建立反射物件,因為稍後我們將要修改它。然後我們將 typeOfT 設定為其型別,並使用直接的方法呼叫遍歷欄位(有關詳細資訊,請參閱 reflect 包)。請注意,我們從結構體型別中提取欄位名稱,但欄位本身是常規的 reflect.Value 物件。

type T struct {
    A int
    B string
}
t := T{23, "skidoo"}
s := reflect.ValueOf(&t).Elem()
typeOfT := s.Type()
for i := 0; i < s.NumField(); i++ {
    f := s.Field(i)
    fmt.Printf("%d: %s %s = %v\n", i,
        typeOfT.Field(i).Name, f.Type(), f.Interface())
}

這個程式的輸出是

0: A int = 23
1: B string = skidoo

這裡順帶提到了關於可設定性的一點:T 的欄位名是大寫(匯出)的,因為結構體中只有匯出欄位才是可設定的。

由於 s 包含一個可設定的反射物件,我們可以修改結構體的欄位。

s.Field(0).SetInt(77)
s.Field(1).SetString("Sunset Strip")
fmt.Println("t is now", t)

結果如下

t is now {77 Sunset Strip}

如果我們修改程式,使 s 是從 t 而不是 &t 建立的,那麼對 SetIntSetString 的呼叫將會失敗,因為 t 的欄位將不可設定。

結論

再此重申反射定律

  • 反射從介面值到反射物件。

  • 反射從反射物件到介面值。

  • 要修改反射物件,該值必須是可設定的。

一旦你理解了這些定律,Go 中的反射就會變得更容易使用,儘管它仍然很微妙。它是一個強大的工具,應謹慎使用,除非絕對必要,否則應避免使用。

關於反射還有很多我們沒有涉及到的內容——在通道上傳送和接收,分配記憶體,使用切片和對映,呼叫方法和函式——但這篇博文已經足夠長了。我們將在以後的文章中介紹其中一些主題。

下一篇文章: Go image 包
上一篇文章: 兩次 Go 演講:《Go 中的詞法掃描》和《Cuddle:一個 App Engine 演示》
部落格索引