《Go 語言程式設計》讀書筆記(十)反射

KevinYan發表於2020-01-17

Go語言提供了一種機制在執行時更新變數和檢查它們的值、呼叫它們的方法和它們支援的操作,但是在編譯時並不知道這些變數的具體型別。這種機制被稱為反射。反射也可以讓我們將型別本身作為第一類的值型別處理。

在本章,我們將探討Go語言的反射特性,看看它可以給語言增加哪些表達力,以及在兩個至關重要的API是如何用反射機制的:一個是fmt包提供的字串格式功能,另一個是類似encoding/jsonencoding/xml提供的針對特定協議的編解碼功能。反射是一個複雜的內省技術,不應該隨意使用,因此,儘管上面這些包內部都是用反射技術實現的,但是它們自己的API都沒有公開反射相關的介面。

為何需要反射

有時候我們需要編寫一個函式能夠處理任何型別,一個大家熟悉的例子是fmt.Fprintf函式提供的字串格式化處理邏輯,它可以對任意型別的值格式化並列印,甚至支援使用者自定義的型別。讓我們也來嘗試實現一個類似功能的函式。為了簡單起見,我們的函式只接收一個引數,然後返回和fmt.Sprint類似的格式化後的字串。我們實現的函式名也叫Sprint

我們使用了switch型別分支首先來測試輸入引數是否實現了String方法,如果是的話就使用該方法。然後繼續增加型別測試分支,檢查是否是基於string、int、bool等基礎型別的動態型別,並在每種情況下執行相應的格式化操作。

func Sprint(x interface{}) string {
    type stringer interface {
        String() string
    }
    switch x := x.(type) {
    case stringer:
        return x.String()
    case string:
        return x
    case int:
        return strconv.Itoa(x)
    // ...similar cases for int16, uint32, and so on...
    case bool:
        if x {
            return "true"
        }
        return "false"
    default:
        // array, chan, func, map, pointer, slice, struct
        return "???"
    }
}

但是我們如何處理其它類似[]float64map[string][]string等型別呢?我們當然可以新增更多的測試分支,但是這些組合型別的數目基本是無窮的。還有如何處理url.Values等命名的型別呢?雖然型別分支可以識別出底層的基礎型別是map[string][]string,但是它並不匹配url.Values型別,因為它們是兩種不同的型別,而且switch型別分支也不可能包含每個類似url.Values的型別,這會導致對這些庫的迴圈依賴。

沒有一種方法來檢查未知型別的表示方式,我們被卡住了。這就是我們為何需要反射的原因

reflect.Type和reflect.Value

反射是由reflect包提供支援. 它定義了兩個重要的型別, Type 和 Value. 一個 Type 表示一個Go型別. 它是一個介面, 有許多方法來區分型別和檢查它們的元件, 例如一個結構體的成員或一個函式的引數等. 唯一能反映 reflect.Type 實現的是介面的型別描述資訊, 同樣的實體標識了動態型別的介面值.

函式 reflect.TypeOf 接受任意的 interface{} 型別, 並返回對應動態型別的reflect.Type:

t := reflect.TypeOf(3)  // a reflect.Type
fmt.Println(t.String()) // "int"
fmt.Println(t)          // "int"

其中 TypeOf(3) 呼叫將值 3 作為 interface{} 型別引數傳入。將一個具體的值轉為介面型別會有一個隱式的介面轉換操作, 它會建立一個包含兩個資訊的介面值: 運算元的動態型別(這裡是int)和它的動態的值(這裡是3)。

因為 reflect.TypeOf 返回的是一個動態型別的介面值, 它總是返回具體的型別. 因此, 下面的程式碼將列印 "*os.File" 而不是 "io.Writer". 稍後, 我們將看到 reflect.Type 是具有識別介面型別的表達方式功能的.

var w io.Writer = os.Stdout
fmt.Println(reflect.TypeOf(w)) // "*os.File"

要注意的是 reflect.Type 介面是滿足 fmt.Stringer 介面的. 因為列印動態型別值對於除錯和日誌是有幫助的, fmt.Printf 提供了一個簡短的 %T 標誌引數, 內部使用 reflect.TypeOf 的結果輸出:

fmt.Printf("%T\n", 3) // "int"

reflect 包中另一個重要的型別是 Value. 一個 reflect.Value 可以持有一個任意型別的值. 函式 reflect.ValueOf 接受任意的 interface{} 型別, 並返回對應動態型別的reflect.Value. 和 reflect.TypeOf 類似, reflect.ValueOf 返回的結果也是對於具體的型別, 但是 reflect.Value 也可以持有一個介面值.

v := reflect.ValueOf(3) // a reflect.Value
fmt.Println(v)          // "3"
fmt.Printf("%v\n", v)   // "3"
fmt.Println(v.String()) // NOTE: "<int Value>"

和 reflect.Type 類似, reflect.Value 也滿足 fmt.Stringer 介面, 但是除非 Value 持有的是字串, 否則 String 只是返回具體的型別. 使用 fmt 包的 %v 標誌引數, 將使用 reflect.Values 的結果格式化.

呼叫 Value 的 Type 方法將返回具體型別所對應的 reflect.Type:

t := v.Type()           // a reflect.Type
fmt.Println(t.String()) // "int"

一個 reflect.Value 和 interface{} 都能儲存任意的值. 所不同的是, 一個空的介面隱藏了值對應的表示方式和所有的公開的方法, 因此只有我們知道具體的動態型別才能使用型別斷言來訪問內部的值(就像上面那樣), 對於內部值並沒有特別可做的事情. 相比之下, 一個 reflect.Value 則有很多方法來檢查其內容, 無論它的具體型別是什麼. 讓我們再次嘗試實現我們的格式化函式 format.Any.

我們使用 reflect.Value 的 Kind 方法來替代之前的型別 switch. 雖然還是有無窮多的型別, 但是它們的kinds型別卻是有限的: Bool, String 和 所有數字型別的基礎型別; Array 和 Struct 對應的聚合型別; Chan, Func, Ptr, Slice, 和 Map 對應的引用類似; 介面型別; 還有表示空值的無效型別. (空的 reflect.Value 對應 Invalid 無效型別.)

package format

import (
    "reflect"
    "strconv"
)

// Any formats any value as a string.
func Any(value interface{}) string {
    return formatAtom(reflect.ValueOf(value))
}

// formatAtom formats a value without inspecting its internal structure.
func formatAtom(v reflect.Value) string {
    switch v.Kind() {
    case reflect.Invalid:
        return "invalid"
    case reflect.Int, reflect.Int8, reflect.Int16,
        reflect.Int32, reflect.Int64:
        return strconv.FormatInt(v.Int(), 10)
    case reflect.Uint, reflect.Uint8, reflect.Uint16,
        reflect.Uint32, reflect.Uint64, reflect.Uintptr:
        return strconv.FormatUint(v.Uint(), 10)
    // ...floating-point and complex cases omitted for brevity...
    case reflect.Bool:
        return strconv.FormatBool(v.Bool())
    case reflect.String:
        return strconv.Quote(v.String())
    case reflect.Chan, reflect.Func, reflect.Ptr, reflect.Slice, reflect.Map:
        return v.Type().String() + " 0x" +
            strconv.FormatUint(uint64(v.Pointer()), 16)
    default: // reflect.Array, reflect.Struct, reflect.Interface
        return v.Type().String() + " value"
    }
}

到目前為止, 我們的函式將每個值視作一個不可分割沒有內部結構的, 因此它叫 formatAtom. 對於聚合型別(結構體和陣列)只是列印型別的值, 對於引用型別(channels, functions, pointers, slices, 和 maps), 用十六進位制列印型別的引用地址. 雖然還不夠理想, 但是依然是一個重大的進步, 並且 Kind 只關心底層表示, format.Any 也支援新命名的型別. 例如:

var x int64 = 1
var d time.Duration = 1 * time.Nanosecond
fmt.Println(format.Any(x))                  // "1"
fmt.Println(format.Any(d))                  // "1"
fmt.Println(format.Any([]int64{x}))         // "[]int64 0x8202b87b0"
fmt.Println(format.Any([]time.Duration{d})) // "[]time.Duration 0x8202b87e0"

反射訪問聚合型別

接下來,讓我們看看如何改善聚合資料型別的顯示。我們像構建一個用於除錯用的Display函式,給定一個聚合型別x,列印這個值對應的完整的結構,同時記錄每個發現的每個元素的路徑。

在可能的情況下,你應該避免在一個包中暴露和反射相關的介面。我們將定義一個未匯出的display函式用於遞迴處理工作,匯出的是Display函式,它只是display函式簡單的包裝以接受interface{}型別的引數:

func Display(name string, x interface{}) {
    fmt.Printf("Display %s (%T):\n", name, x)
    display(name, reflect.ValueOf(x))
}

在display函式中,我們使用了前面定義的列印基礎型別——基本型別、函式和chan等——元素值的formatAtom函式,但是我們會使用reflect.Value的方法來遞迴顯示聚合型別的每一個成員或元素。

func display(path string, v reflect.Value) {
    switch v.Kind() {
    case reflect.Invalid:
        fmt.Printf("%s = invalid\n", path)
    case reflect.Slice, reflect.Array:
        for i := 0; i < v.Len(); i++ {
            display(fmt.Sprintf("%s[%d]", path, i), v.Index(i))
        }
    case reflect.Struct:
        for i := 0; i < v.NumField(); i++ {
            fieldPath := fmt.Sprintf("%s.%s", path, v.Type().Field(i).Name)
            display(fieldPath, v.Field(i))
        }
    case reflect.Map:
        for _, key := range v.MapKeys() {
            display(fmt.Sprintf("%s[%s]", path,
                formatAtom(key)), v.MapIndex(key))
        }
    case reflect.Ptr:
        if v.IsNil() {
            fmt.Printf("%s = nil\n", path)
        } else {
            display(fmt.Sprintf("(*%s)", path), v.Elem())
        }
    case reflect.Interface:
        if v.IsNil() {
            fmt.Printf("%s = nil\n", path)
        } else {
            fmt.Printf("%s.type = %s\n", path, v.Elem().Type())
            display(path+".value", v.Elem())
        }
    default: // basic types, channels, funcs
        fmt.Printf("%s = %s\n", path, formatAtom(v))
    }
}

讓我們針對不同型別分別討論。

Slice和陣列: 兩種的處理邏輯是一樣的。Len方法返回slice或陣列值中的元素個數,Index(i)活動索引i對應的元素,返回的也是一個reflect.Value型別的值;如果索引i超出範圍的話將導致panic異常,這些行為和陣列或slice型別內建的len(a)a[i]等操作類似。display針對序列中的每個元素遞迴呼叫自身處理,我們通過在遞迴處理時向path附加“[i]”來表示訪問路徑。

雖然reflect.Value型別帶有很多方法,但是隻有少數的方法對任意值都是可以安全呼叫的。例如,Index方法只能對Slice、陣列或字串型別的值呼叫,其它型別如果呼叫將導致panic異常。

結構體: NumField方法報告結構體中成員的數量,Field(i)reflect.Value型別返回第i個成員的值。成員列表包含了匿名成員在內的全部成員。通過在path新增“.f”來表示成員路徑,我們必須獲得結構體對應的reflect.Type型別資訊,包含結構體型別和第i個成員的名字。要注意的是,結構體中未匯出的成員對反射也是可見的。

Maps: MapKeys方法返回一個reflect.Value型別的slice,每一個都對應map的可以。和往常一樣,遍歷map時順序是隨機的。MapIndex(key)返回map中key對應的value。我們向path新增“[key]”來表示訪問路徑。

指標: Elem方法返回指標指向的變數,還是reflect.Value型別。即使指標是nil,這個操作也是安全的,在這種情況下指標是Invalid無效型別,但是我們可以用IsNil方法來顯式地測試一個空指標,這樣我們可以列印更合適的資訊。我們在path前面新增“*”,並用括弧包含以避免歧義。

介面: 再一次,我們使用IsNil方法來測試介面是否是nil,如果不是,我們可以呼叫v.Elem()來獲取介面對應的動態值,並且列印對應的型別和值。

獲取結構體成員標籤

我們使用構體成員標籤用於設定對應JSON對應的名字。其中json成員標籤讓我們可以選擇成員的名字和抑制零值成員的輸出。在本節,我們將看到如果通過反射機制獲取成員標籤。

結構體型別的 reflect.Value的reflect.Type的Field方法將返回一個reflect.StructField,裡面含有每個成員的名字、型別和可選的成員標籤等資訊。其中成員標籤資訊對應reflect.StructTag型別的字串,並且它提供了Get方法用於解析和根據特定key提取子串,例如下面的http:”…”形式的子串。

下面的search函式是一個HTTP請求處理函式。它定義了一個匿名結構體型別的變數,用結構體的每個成員表示HTTP請求的引數。其中結構體成員標籤指明瞭對於請求引數的名字,為了減少URL的長度這些引數名通常都是神祕的縮略詞。Unpack將請求引數填充到合適的結構體成員中,這樣我們可以方便地通過合適的型別類來訪問這些引數。

import "gopl.io/ch12/params"

// search implements the /search URL endpoint.
func search(resp http.ResponseWriter, req *http.Request) {
    var data struct {
        Labels     []string `http:"l"`
        MaxResults int      `http:"max"`
        Exact      bool     `http:"x"`
    }
    data.MaxResults = 10 // set default
    if err := params.Unpack(req, &data); err != nil {
        http.Error(resp, err.Error(), http.StatusBadRequest) // 400
        return
    }

    // ...rest of handler...
    fmt.Fprintf(resp, "Search: %+v\n", data)
}
// Unpack populates the fields of the struct pointed to by ptr
// from the HTTP request parameters in req.
func Unpack(req *http.Request, ptr interface{}) error {
    if err := req.ParseForm(); err != nil {
        return err
    }

    // Build map of fields keyed by effective name.
    fields := make(map[string]reflect.Value)
    v := reflect.ValueOf(ptr).Elem() // the struct variable
    for i := 0; i < v.NumField(); i++ {
        fieldInfo := v.Type().Field(i) // a reflect.StructField
        tag := fieldInfo.Tag           // a reflect.StructTag
        name := tag.Get("http")
        if name == "" {
            name = strings.ToLower(fieldInfo.Name)
        }
        fields[name] = v.Field(i)
    }

    // Update struct field for each parameter in the request.
    for name, values := range req.Form {
        f := fields[name]
        if !f.IsValid() {
            continue // ignore unrecognized HTTP parameters
        }
        for _, value := range values {
            if f.Kind() == reflect.Slice {
                elem := reflect.New(f.Type().Elem()).Elem()
                if err := populate(elem, value); err != nil {
                    return fmt.Errorf("%s: %v", name, err)
                }
                f.Set(reflect.Append(f, elem))
            } else {
                if err := populate(f, value); err != nil {
                    return fmt.Errorf("%s: %v", name, err)
                }
            }
        }
    }
    return nil
}

注:我們可以通過呼叫reflect.ValueOf(&x).Elem(),來獲取任意變數x對應的可取地址的Value。

顯示型別的方法集

reflect.Type和reflect.Value都提供了一個Method方法。每次t.Method(i)呼叫將一個reflect.Method的例項,對應一個用於描述一個方法的名稱和型別的結構體。每次v.Method(i)方法呼叫都返回一個reflect.Value以表示對應的值,也就是說一個方法是繫結到它的接收者的。使用reflect.Value.Call方法,將可以呼叫一個Func型別的Value,但是下面這個例子中只用到了它的型別。

我們的最後一個例子是使用reflect.Type來列印任意值的型別和列舉它的方法:

func Print(x interface{}) {
    v := reflect.ValueOf(x)
    t := v.Type()
    fmt.Printf("type %s\n", t)

    for i := 0; i < v.NumMethod(); i++ {
        methType := v.Method(i).Type()
        fmt.Printf("func (%s) %s%s\n", t, t.Method(i).Name,
            strings.TrimPrefix(methType.String(), "func"))
    }
}

下面是屬於time.Duration*strings.Replacer兩個型別的方法:

methods.Print(time.Hour)
// Output:
// type time.Duration
// func (time.Duration) Hours() float64
// func (time.Duration) Minutes() float64
// func (time.Duration) Nanoseconds() int64
// func (time.Duration) Seconds() float64
// func (time.Duration) String() string

methods.Print(new(strings.Replacer))
// Output:
// type *strings.Replacer
// func (*strings.Replacer) Replace(string) string
// func (*strings.Replacer) WriteString(io.Writer, string) (int, error)

反射的使用建議

通過反射可以實現哪些功能。反射是一個強大並富有表達力的工具,但是它應該被小心地使用。

基於反射的程式碼是比較脆弱的,對於每一個會導致編譯器報告型別錯誤的問題,在反射中都有與之相對應的問題,不同的是編譯器會在構建時馬上報告錯誤,而反射則是在真正執行到的時候才會丟擲panic異常,可能是寫完程式碼很久之後的時候了,而且程式也可能執行了很長的時間。絕大多數使用反射的程式都需要非常小心地檢查每個reflect.Value對於值的型別、是否可取地址,還有是否可以被修改等。

避免這種因反射而導致的脆弱性的問題的最好方法是將所有的反射相關的使用控制在包的內部,如果可能的話避免在包的API中直接暴露reflect.Value型別,這樣可以限制一些非法輸入。如果無法做到這一點,在每個有風險的操作前應做額外的型別檢查。以標準庫中的程式碼為例,當fmt.Printf收到一個非法的運算元是,它並不會丟擲panic異常,而是列印相關的錯誤資訊。程式雖然還有BUG,但是會更加容易診斷。

fmt.Printf("%d %s\n", "hello", 42) // "%!d(string=hello) %!s(int=42)"

反射同樣降低了程式的安全性,還影響了自動化重構和分析工具的準確性,因為它們無法識別執行時才能確認的型別資訊。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

公眾號:網管叨bi叨 | Golang、PHP、Laravel、Docker等學習經驗分享

相關文章