Go 部落格

反射定律

羅布·派克
2011 年 9 月 6 日

簡介

電腦中的反射是指程式檢視自身結構的能力,特別是透過類型;這是一種 meta 程式設計。但這也容易造成混淆。

本文將說明 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 內部的成對資料相同。即使內部的具體值可能包含更大量的 method,但介面靜態類型仍會決定介面變數可以呼叫哪些 method。

持續進行,我們可以這樣寫

var empty interface{}
empty = w

而我們的空介面值 empty 會再次包含相同的成對資料,(tty, *os.File)。這很方便:空介面可以保存任何值,並包含我們可能需要的所有與那個值相關的資訊。

(我們在此處不需類型斷言,原因是因為 w 靜態地符合空介面。在我們將值從 Reader 移至 Writer 的範例中,我們需要明確化並使用類型斷言,因為 Writer 的 method 並非 Reader 的子集。)

一個重要的細節為,介面變數內部的成對資料總是採用 (value, concrete type) 形式,而非 (value, interface type) 形式。介面不保存介面值。

現在我們已準備好反射。

反射的第一條法則

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

在基本層級,反射僅是我們用來檢視儲存在介面變數內部之類型和值成對資料的機制。若要開始,我們需要了解 package reflect 中的兩種類型:類型。這兩種類型提供存取介面變數內部資訊的方式,而兩個簡單函式 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 method,因為 fmt 套件預設會深入 reflect.Value,以顯示其內部的具體值。`String` method 就不會這麼做。)

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 套件會在內部像我們在前面範例中示範的一樣,將這些引數解開。因此,只要將 Interface 方法的結果傳遞給格式化列印常式,就能夠正確列印出 reflect.Value 的內容

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 的屬性,並非所有反射 Values 都有此屬性。

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,然後建立一個指向 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 設定為其類型,並使用直接的方法呼叫(有關詳細資訊,請參閱 package 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}

如果我們將程式碼改為從 t (而非 &t) 建立 s,則呼叫 SetIntSetString 會失敗,因為 t 的欄位無法設定。

結論

以下再次說明反射定律

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

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

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

一旦了解這些定律,在 Go 中使用反射會容易許多,雖然它仍然很精妙。它是一個強大的工具,應謹慎使用,並且在非必要時避免使用。

還有許多與反射相關的主題我們尚未探討,像是頻道發送和接收、配置記憶體、使用切片和映射、呼叫方法和函式,但這篇文章已經夠長了。我們將在後續文章中探討這些主題。

下一篇文章:Go 影像套件
前一篇:兩場 Go 演講:「Go 中的詞彙掃描」和「Cuddle:App Engine 範例展示」
部落格首頁