gopl 反射2

本篇各章節的主要內容:

  1. 使用 reflect.Value 來設置值:通過 Elem() 方法獲取指針對應的值,然後就可以修改值
  2. 示例,解碼 S 表達式:之前內容的綜合運用
  3. 訪問結構體成員標籤:像JSON反序列化那樣,使用反射獲取成員標籤,並填充結構體的字段
  4. 顯示類型的方法:通過一個簡單的示例,獲取任意值的類型,並枚舉它的方法,還可以調用這些方法
  5. 注意事項:慎用反射,原因有三

使用 reflect.Value 來設置值

到目前爲止,反射只是用來解析變量值。本節的重點是改變值。

可尋址的值(canAddr)

reflect.Value 的值,有些是可尋址的,有些是不可尋址的。通過 reflect.ValueOf(x) 返回的 reflect.Value 都是不可尋址的。但是通過指針提領得來的 reflect.Value 是可尋址的。可以通過調用 reflect.ValueOf(&x).Elem() 來獲得任意變量 x 可尋址的 reflect.Value 值。
可以通過變量的 CanAddr 方法來詢問 reflect.Value 變量是否可尋址:

x := 2                   // value   type    variable?
a := reflect.ValueOf(2)  // 2       int     no
b := reflect.ValueOf(x)  // 2       int     no
c := reflect.ValueOf(&x) // &x      *int    no
d := c.Elem()            // 2       int     yes (x)

fmt.Println(a.CanAddr()) // "false"
fmt.Println(b.CanAddr()) // "false"
fmt.Println(c.CanAddr()) // "false"
fmt.Println(d.CanAddr()) // "true"

更新變量(Set)

從一個可尋址的 reflect.Value() 獲取變量需要三步:

  1. 調用 Addr(),返回一個 Value,其中包含一個指向變量的指針
  2. 在這個 Value 上調用 interface(),返回一個包含這個指針的 interface{} 值
  3. 如果知道變量的類型,使用類型斷言把空接口轉換爲一個普通指針

之後,就可以通過這個指針來更新變量了:

x := 2
d := reflect.ValueOf(&x).Elem()   // d代表變量x
px := d.Addr().Interface().(*int) // px := &x
*px = 3                           // x = 3
fmt.Println(x)                    // "3"

還有一個方法,可以直接通過可尋址的 reflect.Value 來更新變量,不用通過指針,而是直接調用 reflect.Value.Set 方法:

d.Set(reflect.ValueOf(4))
fmt.Println(x) // "4"

注意事項

如果類型不匹配會導致程序崩潰:

d.Set(reflect.ValueOf(int64(5))) // panic: int64 不可賦值給 int

在一個不可尋址的 reflect.Value 上調用 Set 方法也會使程序崩潰:

x := 2
b := reflect.ValueOf(x)
b.Set(reflect.ValueOf(3)) // panic: 在不可尋址的值上使用 Set 方法

另外還提供了一些爲基本類型特化的 Set 變種:SetInt、SetUint、SetString、SetFloat等:

d := reflect.ValueOf(&x).Elem()
d.SetInt(3)
fmt.Println(x) // "3"

這些方法還有一定的容錯性。比如 SetInt 方法,任意有符號整型,甚至是底層類型是有符號整型的命名類型,都可以執行成功。如果值太大了,會無提示地截斷它。但是在指向 interface{} 變量的 reflect.Value 上調用 SetInt 會崩潰(儘管使用 Set 是沒有問題的):

x := 1
rx := reflect.ValueOf(&x).Elem()
rx.SetInt(2)                     // OK, x = 2
rx.Set(reflect.ValueOf(3))       // OK, x = 3
rx.SetString("hello")            // panic: string 不能賦值給 int
rx.Set(reflect.ValueOf("hello")) // panic: string 不能賦值給 int

var y interface{}
ry := reflect.ValueOf(&y).Elem()
ry.SetInt(2)                     // panic: 在指向空接口的 Value 上調用 SetInt
ry.Set(reflect.ValueOf(3))       // OK, y = int(3)
ry.SetString("hello")            // panic: 在指向空接口的 Value 上調用 SetString
ry.Set(reflect.ValueOf("hello")) // OK, y = "hello"

可修改的值(CanSet)

另外,反射可以越過 Go 言語的導出規則,讀取到未導出的成員。但是利用反射不能修改未導出的成員:

stdout := reflect.ValueOf(os.Stdout).Elem() // *os.Stdout, 一個 os.File 變量
fmt.Println(stdout.Type())                  // "os.File"
fd := stdout.FieldByName("fd")
fmt.Println(fd.Int()) // "1" ,獲取到了未導出的成員的值
fd.SetInt(2)          // panic: unexported field ,嘗試修改則會崩潰

一個可尋址的 reflect.Value 會記錄它是否是通過遍歷一個未導出的字段來獲得的,如果是這樣則不允許修改。
所以在更新變量前用 CanAddr 來檢查不能保證正確。CanSet 方法才能正確地報告一個 reflect.Value 是否可尋址且可更改:

fmt.Println(fd.CanAddr(), fd.CanSet()) // "true false"

示例:解碼 S 表達式

本節要爲 S 表達式編碼實現一個簡單的 Unmarshal 函數(解碼器)。一個健壯的和通用的實現比這裏的例子需要更多的代碼,這裏精簡了很多,只支持 S 表達式有限的子集,並且沒有優雅地處理錯誤。代碼的目的是闡釋反射,而不是語法分析。

詞法分析器

詞法分析器 lexer 使用 text\/scanner 包提供的掃描器 Scanner 類型來把輸入流分解成一系列的標記(token),包括註釋、標識符、字符串字面量和數字字面量。掃描器的 Scan 方法將提前掃描並返回下一個標記(類型爲 rune)。大部分標記(比如'(')都只包含單個rune,但 text\/scanner 包也可以支持由多個字符組成的記號。調用 Scan 會返回標記的類型,調用 TokenText 則會返回標記的文本。
因爲每個解析器可能需要多次使用當前的記號,但是 Scan 會一直向前掃描,所以把掃描器封裝到一個 lexer 輔助類型中,其中保存了 Scan 最近返回的標記:

type lexer struct {
    scan  scanner.Scanner
    token rune // 當前標記
}

func (lex *lexer) next()        { lex.token = lex.scan.Scan() }
func (lex *lexer) text() string { return lex.scan.TokenText() }

func (lex *lexer) consume(want rune) {
    if lex.token != want { // 注意: 錯誤處理不是這篇的重點,簡單粗暴的處理了
        panic(fmt.Sprintf("got %q, want %q", lex.text(), want))
    }
    lex.next()
}

函數實現

分析器有兩個主要的函數。
一個是read,它讀取從當前標記開始的 S 表達式,並更新由可尋址的 reflect.Value 類型的變量 v 指向的變量:

func read(lex *lexer, v reflect.Value) {
    switch lex.token {
    case scanner.Ident:
        // 僅有的有標識符是 “nil” 和結構體的字段名
        if lex.text() == "nil" {
            v.Set(reflect.Zero(v.Type()))
            lex.next()
            return
        }
    case scanner.String:
        s, _ := strconv.Unquote(lex.text()) // 注意:錯誤被忽略
        v.SetString(s)
        lex.next()
        return
    case scanner.Int:
        i, _ := strconv.Atoi(lex.text()) // 注意:錯誤被忽略
        v.SetInt(int64(i))
        lex.next()
        return
    case '(':
        lex.next()
        readList(lex, v)
        lex.next() // consume ')'
        return
    }
    panic(fmt.Sprintf("unexpected token %q", lex.text()))
}

S 表達式爲兩個不同的目的使用標識符:結構體的字段名和指針的 nil 值。read 函數只處理後一種情況。當它遇到 scanner.Ident 的值爲 “nil” 時,通過 reflect.Zero 函數把 v 設置爲其類型的零值。對於其他標識符,則應該產生一個錯誤(這裏則是採用簡單粗暴的方法,直接忽略了)。

還有一個是 readList 函數。一個 '(' 標記代表一個列表的開始,readList 函數可把列表解碼爲多種類型:map、結構體、切片或者數組,具體類型根據傳入待填充變量的類型決定。對於每種類型都會循環解析內容直到遇到匹配的右括號 ')',這個是用 endList 函數來檢測的。
比較有趣的地方是遞歸。最簡單的例子是處理數組,在遇到 ')' 之前,使用 Index 方法來獲得數組的一個元素,再遞歸調用 read 來填充數據。切片的流程與數組類似,不同之處是先創建每一個元素變量,再填充,最後追加到切片中。
結構體和map在循環的每一輪中都必須解析一個關於(key value)的子列表。對於結構體,key 是用來定位字段的符號。與數組類似,通過 FieldByName 函數來獲得結構體對應字段的變量,再遞歸調用 read 來填充。對於 map,key 可以是任何類型。與切片類似,先創建新變量,再遞歸地填充,最後再把新的鍵值對添加到 map中:

func readList(lex *lexer, v reflect.Value) {
    switch v.Kind() {
    case reflect.Array: // (item ...)
        for i := 0; !endList(lex); i++ {
            read(lex, v.Index(i))
        }

    case reflect.Slice: // (item ...)
        for !endList(lex) {
            item := reflect.New(v.Type().Elem()).Elem()
            read(lex, item)
            v.Set(reflect.Append(v, item))
        }

    case reflect.Struct: // ((name value) ...)
        for !endList(lex) {
            lex.consume('(')
            if lex.token != scanner.Ident {
                panic(fmt.Sprintf("got token %q, want field name", lex.text()))
            }
            name := lex.text()
            lex.next()
            read(lex, v.FieldByName(name))
            lex.consume(')')
        }

    case reflect.Map: // ((key value) ...)
        v.Set(reflect.MakeMap(v.Type()))
        for !endList(lex) {
            lex.consume('(')
            key := reflect.New(v.Type().Key()).Elem()
            read(lex, key)
            value := reflect.New(v.Type().Elem()).Elem()
            read(lex, value)
            v.SetMapIndex(key, value)
            lex.consume(')')
        }

    default:
        panic(fmt.Sprintf("cannot decode list into %v", v.Type()))
    }
}

func endList(lex *lexer) bool {
    switch lex.token {
    case scanner.EOF:
        panic("end of file")
    case ')':
        return true
    }
    return false
}

封裝解析器

最後,把解析器封裝成如下所示的一個導出的函數 Unmarshal,隱藏了實現中多個不完美的地方,比如解析過程中遇到錯誤會崩潰,因此使用了一個延遲調用來從崩潰中恢復,並且返回錯誤消息:

// Unmarshal 解析 S 表達式數據並且填充到非 nil 指針 out 指向的變量
func Unmarshal(data []byte, out interface{}) (err error) {
    lex := &lexer{scan: scanner.Scanner{Mode: scanner.GoTokens}}
    lex.scan.Init(bytes.NewReader(data))
    lex.next() // 獲取第一個標記
    defer func() {
        // 注意: 錯誤處理不是這篇的重點,簡單粗暴的處理了
        if x := recover(); x != nil {
            err = fmt.Errorf("error at %s: %v", lex.scan.Position, x)
        }
    }()
    read(lex, reflect.ValueOf(out).Elem())
    return nil
}

一個具備用於生產環境的質量的實現對任何的輸入都不應當崩潰,而且應當對每次錯誤詳細報告信息,可能的話,應當包含行號或者偏移量。通過這個示例有助於瞭解 encoding/json 這類包的底層機制,以及如何使用反射來填充數據結構。

訪問結構體字段標籤

這裏的“成員”和“字段”兩個詞有點混用,但都是同一個意思。
可以使用結構體成員標籤(field tag)在進行JSON反序列化的時候對應JSON中字段的名字。json 成員標籤讓我們可以選擇其他的字段名以及忽略輸出的空字段。這小節將通過反射機制獲取結構體字段的標籤,然後填充字段的值,就和JSON反序列化一樣,目標和結果是一樣的,只是獲取的數據源不同。
有一個 Web 服務應用的場景,在 Web 服務器中,絕大部分 HTTP 處理函數的第一件事就是提取請求參數到局部變量中。這裏將定義一個工具函數 params.Unpack,使用結構體成員標籤直接將參數填充到結構體對應的字段中。因爲 URL 的長度有限,所以參數的名稱一般比較短,含義也比較模糊。這需要通過成員標籤將結構體的字段和參數名稱對應上。

在HTTP處理函數中使用

首先,展示這個工具函數的用法。就是假設已經實現了這個 params.Unpack 函數,下面的 search 函數就是一個 HTTP 處理函數,它定義了一個結構體變量 data,data 也定義了成員標籤來對應請求參數的名字。Unpack 函數從請求中提取數據來填充這個結構體,這樣不僅可以更方便的訪問,還避免了手動轉換類型:

package main

import (
    "fmt"
    "net/http"
)

import "gopl/ch12/params"

// search 用於處理 /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 // 設置默認值
    if err := params.Unpack(req, &data); err != nil {
        http.Error(resp, err.Error(), http.StatusBadRequest) // 400
        return
    }

    // ...其他處理代碼...
    fmt.Fprintf(resp, "Search: %+v\n", data)
}

// 這裏還缺少一個 main 函數,最後會補上

工具函數 Unpack 的實現

下面的 Unpack 函數做了三件事情:
一、調用 req.ParseForm() 來解析請求。在這之後,req.Form 就有了所有的請求參數,這個方法對 HTTP 的 GET 和 POST 請求都適用。
二、Unpack 函數構造了一個從每個有效字段名到對應字段變量的映射。在字段有標籤時,有效字段名與實際字段名可以不同。reflect.Type 的 Field 方法會返回一個 reflect.StructField 類型,這個類型提供了每個字段的名稱、類型以及一個可選的標籤。它的 Tag 字段類型爲 reflect.StructTag,底層類型爲字符串,提供了一個 Get 方法用於解析和提取對於一個特定 key 的子串,比如上面示例中結構體字段後面的 http:"max" 這種形式的字段標籤。
三、Unpack 遍歷 HTTP 參數中的所有 key\/value 對,並且更新對應的結構體字段。同一個參數可以出現多次。如果對應的字段是切片,則參數所有的值都會追加到切片裏。否則,這個字段會被多次覆蓋,只有最後一次的值纔有效。

Unpack 函數的代碼如下:

// Unpack 從 HTTP 請求 req 的參數中提取數據填充到 ptr 指向的結構體的各個字段
func Unpack(req *http.Request, ptr interface{}) error {
    if err := req.ParseForm(); err != nil {
        return err
    }

    // 創建字段映射表,key 爲有效名稱
    fields := make(map[string]reflect.Value)
    v := reflect.ValueOf(ptr).Elem() // reflect.ValueOf(&x).Elem() 獲得任意變量 x 可尋址的值,用於設置值。
    for i := 0; i < v.NumField(); i++ {
        fieldInfo := v.Type().Field(i) // a reflect.StructField,提供了每個字段的名稱、類型以及一個可選的標籤
        tag := fieldInfo.Tag           // a reflect.Structtag,底層類型爲字符串,提供了一個 Get 方法,下一行就用到了
        name := tag.Get("http")        // Get 方法用於解析和提取對於一個特定 key 的子串
        if name == "" {
            name = strings.ToLower(fieldInfo.Name)
        }
        fields[name] = v.Field(i)
    }

    // 對請求中的每個參數更新結構體中對應的字段
    for name, values := range req.Form {
        f := fields[name]
        if !f.IsValid() {
            continue // 忽略不能識別的 HTTP 參數
        }
        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
}

這裏還調用了一個 populate 函數,負責從單個 HTTP 請求參數值填充單個字段 v (或者切片字段中的單個元素)。目前,它僅支持字符串、有符號整數和布爾值。要支持其他類型可以再添加:

func populate(v reflect.Value, value string) error {
    switch v.Kind() {
    case reflect.String:
        v.SetString(value)

    case reflect.Int:
        i, err := strconv.ParseInt(value, 10, 64)
        if err != nil {
            return err
        }
        v.SetInt(i)

    case reflect.Bool:
        b, err := strconv.ParseBool(value)
        if err != nil {
            return err
        }
        v.SetBool(b)

    default:
        return fmt.Errorf("unsupported kind %s", v.Type())
    }
    return nil
}

執行效果

接着把 search 處理程序添加到一個 Web 服務器中,直接在 search 所在的 main 包的命令源碼文件中添加下面的 main 函數:

func main() {
    fmt.Println("http://localhost:8000/search")                                 // Search: {Labels:[] MaxResults:10 Exact:false}
    fmt.Println("http://localhost:8000/search?l=golang&l=gopl")                 // Search: {Labels:[golang gopl] MaxResults:10 Exact:false}
    fmt.Println("http://localhost:8000/search?l=gopl&x=1")                      // Search: {Labels:[gopl] MaxResults:10 Exact:true}
    fmt.Println("http://localhost:8000/search?x=true&max=100&max=200&l=golang") // Search: {Labels:[golang] MaxResults:200 Exact:true}
    fmt.Println("http://localhost:8000/search?q=hello")                         // Search: {Labels:[] MaxResults:10 Exact:false}  # 不存在的參數會忽略
    fmt.Println("http://localhost:8000/search?x=123")                           // x: strconv.ParseBool: parsing "123": invalid syntax  # x 提供的參數解析錯誤
    fmt.Println("http://localhost:8000/search?max=lots")                        // max: strconv.ParseInt: parsing "lots": invalid syntax  # max 提供的參數解析錯誤
    http.HandleFunc("/search", search)
    log.Fatal(http.ListenAndServe(":8000", nil))
}

這裏提供了幾個示例以及輸出的結果,直接使用瀏覽器,輸入URL就能返回對應的結果。

顯示類型的方法

通過反射的 reflect.Type 來獲取一個任意值的類型並枚舉它的方法。下面的例子是把類型和方法都打印出來:

package methods

import (
    "fmt"
    "reflect"
    "strings"
)

// Print 輸出值 x 的所有方法
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"))
    }
}

reflect.Type 和 reflect.Value 都有一個叫作 Method 的方法:

  • 每個 t.Method(i) 都會返回一個 reflect.Method 類型的實例,這個結構類型描述了這個方法的名稱和類型。
  • 每個 v.Method(i) 都會返回一個 reflect.Value,代表一個方法值,即一個已經綁定接收者的方法。

下面是兩個示例測試,展示以及驗證上面的函數:

package methods_test

import (
    "strings"
    "time"

    "gopl/ch12/methods"
)

func ExamplePrintDuration() {
    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) Round(time.Duration) time.Duration
    // func (time.Duration) Seconds() float64
    // func (time.Duration) String() string
    // func (time.Duration) Truncate(time.Duration) time.Duration
}

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

另外還有一個 reflect.Value.Call 方法,可以調用 Func 類型的 Value,這裏沒有演示。

注意事項

還有很多反射API,這裏的示例展示了反射能做哪些事情。
反射是一個功能和表達能力都很強大的工具,但是要慎用,主要有三個原因。

代碼脆弱

基於反射的代碼是很脆弱的。一般編譯器在編譯時就能報告錯誤,但是反射錯誤則要等到執行時纔會以崩潰的方式來報告。這可能是等待程序運行很久以後纔會發生。
比如,嘗試讀取一個字符串然後填充一個 Int 類型的變量,那麼調用 reflect.Value.SetString 就會崩潰。很多使用反射的程序都會有類似的風險。所以對每一個 reflect.Value 都需要仔細檢查它的類型、是否可尋址、是否可設置。
要回避這種缺陷的最好的辦法就是確保反射的使用完整的封裝在包裏,並且如果可能,在包的 API 中避免使用 reflect.Value,儘量使用特定的類型來確保輸入是合法的值。如果做不到,那就需要在每個危險的操作前都做額外的動態檢查。比如標準庫的 fmt.Printf 可以作爲一個示例,當遇到操作數類型不合適時,它不會崩潰,而是輸出一條描述性的錯誤消息。這儘管仍然會有 bug,但定位起來就簡單多了:

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

反射還降低了自動重構和分析工具的安全性與準確度,因爲它們無法檢測到類型的信息。

難理解、難維護

類型也算是某種形式的文檔,而反射的相關操作則無法做靜態類型檢查,所以大量使用反射的代碼是很難理解的。對應接收 interface{} 或者reflect.Value 的函數,一定要寫清楚期望的參數類型和其他限制條件。

運行慢

基於反射的函數會比爲特定類型優化的函數慢一到兩個數量級。在一個典型的程序中,大部分函數與整體性能無關,所以爲了讓程序更清晰可以使用反射。比如測試就和適合使用反射,因爲大部分測試都使用小數據集。但對性能關鍵路徑上的函數,最好避免使用反射。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章