Go高階特性 15 | 執行時反射:字串和結構體之間轉換

Swenson1992發表於2021-02-28

反射是什麼?

和 Java 語言一樣,Go 語言也有執行時反射,這為我們提供了一種可以在執行時操作任意型別物件的能力。比如檢視一個介面變數的具體型別、看看一個結構體有多少欄位、修改某個欄位的值等。

Go 語言是靜態編譯類語言,比如在定義一個變數的時候,已經知道了它是什麼型別,那麼為什麼還需要反射呢?這是因為有些事情只有在執行時才知道。比如定義了一個函式,它有一個interface{}型別的引數,這也就意味著呼叫者可以傳遞任何型別的引數給這個函式。在這種情況下,如果想知道呼叫者傳遞的是什麼型別的引數,就需要用到反射。如果想知道一個結構體有哪些欄位和方法,也需要反射。

還是以常用的函式 fmt.Println 為例,如下所示:

func Println(a ...interface{}) (n int, err error) {
   return Fprintln(os.Stdout, a...)
}

例子中 fmt.Println 的原始碼有一個可變引數,型別為 interface{},這意味著可以傳遞零個或者多個任意型別引數給它,都能被正確列印。

reflect.Value 和 reflect.Type

在 Go 語言的反射定義中,任何介面都由兩部分組成:介面的具體型別,以及具體型別對應的值。比如 var i int = 3,因為 interface{} 可以表示任何型別,所以變數 i 可以轉為 interface{}。你可以把變數 i 當成一個介面,那麼這個變數在 Go 反射中的表示就是 <Value,Type>。其中 Value 為變數的值,即 3,而 Type 為變數的型別,即 int。

小提示:interface{} 是空介面,可以表示任何型別,也就是說你可以把任何型別轉換為空介面,它通常用於反射、型別斷言,以減少重複程式碼,簡化程式設計。

在 Go 反射中,標準庫提供了兩種型別 reflect.Value 和 reflect.Type 來分別表示變數的值和型別,並且提供了兩個函式 reflect.ValueOf 和 reflect.TypeOf 分別獲取任意物件的 reflect.Value 和 reflect.Type。

用下面的程式碼進行演示:

func main() {
   i:=3
   iv:=reflect.ValueOf(i)
   it:=reflect.TypeOf(i)
   fmt.Println(iv,it)//3 int
}

程式碼定義了一個 int 型別的變數 i,它的值為 3,然後通過 reflect.ValueOf 和 reflect.TypeOf 函式就可以獲得變數 i 對應的 reflect.Value 和 reflect.Type。通過 fmt.Println 函式列印後,可以看到結果是 3 int,這也可以證明 reflect.Value 表示的是變數的值,reflect.Type 表示的是變數的型別。

reflect.Value

reflect.Value 可以通過函式 reflect.ValueOf 獲得,下面我將為你介紹它的結構和用法。

結構體定義

在 Go 語言中,reflect.Value 被定義為一個 struct 結構體,它的定義如下面的程式碼所示:

type Value struct {
   typ *rtype
   ptr unsafe.Pointer
   flag
}

發現 reflect.Value 結構體的欄位都是私有的,也就是說,只能使用 reflect.Value 的方法。現在看看它有哪些常用方法,如下所示:

//針對具體型別的系列方法
//以下是用於獲取對應的值
Bool
Bytes
Complex
Float
Int
String
Uint
CanSet //是否可以修改對應的值
以下是用於修改對應的值
Set
SetBool
SetBytes
SetComplex
SetFloat
SetInt
SetString
Elem //獲取指標指向的值,一般用於修改對應的值
//以下Field系列方法用於獲取struct型別中的欄位
Field
FieldByIndex
FieldByName
FieldByNameFunc
Interface //獲取對應的原始型別
IsNil //值是否為nil
IsZero //值是否是零值
Kind //獲取對應的型別類別,比如Array、Slice、Map等
//獲取對應的方法
Method
MethodByName
NumField //獲取struct型別中欄位的數量
NumMethod//型別上方法集的數量
Type//獲取對應的reflect.Type

看著比較多,其實就三類:

  • 一類用於獲取和修改對應的值;
  • 一類和 struct 型別的欄位有關,用於獲取對應的欄位;
  • 一類和型別上的方法集有關,用於獲取對應的方法。

下面通過幾個例子講解如何使用它們。

獲取原始型別

在上面的例子中,通過 reflect.ValueOf 函式把任意型別的物件轉為一個 reflect.Value,而如果想逆向轉回來也可以,reflect.Value 提供了 Inteface 方法,如下面的程式碼所示:

func main() {
   i:=3
   //int to reflect.Value
   iv:=reflect.ValueOf(i)
   //reflect.Value to int
   i1:=iv.Interface().(int)
   fmt.Println(i1)
}

這是 reflect.Value 和 int 型別互轉,換成其他型別也可以。

修改對應的值

已經定義的變數可以通過反射在執行時修改,比如上面的示例 i=3,修改為 4,如下所示:

func main() {
   i:=3
   ipv:=reflect.ValueOf(&i)
   ipv.Elem().SetInt(4)
   fmt.Println(i)
}

這樣就通過反射修改了一個變數。因為 reflect.ValueOf 函式返回的是一份值的拷貝,所以我們要傳入變數的指標才可以。 因為傳遞的是一個指標,所以需要呼叫 Elem 方法找到這個指標指向的值,這樣才能修改。 最後就可以使用 SetInt 方法修改值了。

要修改一個變數的值,有幾個關鍵點:傳遞指標(可定址),通過 Elem 方法獲取指向的值,才可以保證值可以被修改,reflect.Value 提供了 CanSet 方法判斷是否可以修改該變數。

那麼如何修改 struct 結構體欄位的值呢?參考變數的修改方式,可總結出以下步驟:

  1. 傳遞一個 struct 結構體的指標,獲取對應的 reflect.Value;
  2. 通過 Elem 方法獲取指標指向的值;
  3. 通過 Field 方法獲取要修改的欄位;
  4. 通過 Set 系列方法修改成對應的值。

執行下面的程式碼,會發現變數 p 中的 Name 欄位已經被修改為張三了。

func main() {
   p:=person{Name: "Golang",Age: 20}
   ppv:=reflect.ValueOf(&p)
   ppv.Elem().Field(0).SetString("張三")
   fmt.Println(p)
}
type person struct {
   Name string
   Age int
}

最後再來總結一下通過反射修改一個值的規則。

  1. 可被定址,通俗地講就是要向 reflect.ValueOf 函式傳遞一個指標作為引數。
  2. 如果要修改 struct 結構體欄位值的話,該欄位需要是可匯出的,而不是私有的,也就是該欄位的首字母為大寫。
  3. 記得使用 Elem 方法獲得指標指向的值,這樣才能呼叫 Set 系列方法進行修改。

記住以上規則,就可以在程式執行時通過反射修改一個變數或欄位的值。

獲取對應的底層型別

底層型別是什麼意思呢?其實對應的主要是基礎型別,比如介面、結構體、指標……因為可以通過 type 關鍵字宣告很多新的型別。比如在上面的例子中,變數 p 的實際型別是 person,但是 person 對應的底層型別是 struct 這個結構體型別,而 &p 對應的則是指標型別。通過下面的程式碼進行驗證:

func main() {
   p:=person{Name: "Golang",Age: 20}
   ppv:=reflect.ValueOf(&p)
   fmt.Println(ppv.Kind())
   pv:=reflect.ValueOf(p)
   fmt.Println(pv.Kind())
}

執行以上程式碼,可以看到如下列印輸出:

ptr
struct

Kind 方法返回一個 Kind 型別的值,它是一個常量,有以下可供使用的值:

type Kind uint
const (
   Invalid Kind = iota
   Bool
   Int
   Int8
   Int16
   Int32
   Int64
   Uint
   Uint8
   Uint16
   Uint32
   Uint64
   Uintptr
   Float32
   Float64
   Complex64
   Complex128
   Array
   Chan
   Func
   Interface
   Map
   Ptr
   Slice
   String
   Struct
   UnsafePointer
)

從以上原始碼定義的 Kind 常量列表可以看到,已經包含了 Go 語言的所有底層型別。

reflect.Type

reflect.Value 可以用於與值有關的操作中,而如果是和變數型別本身有關的操作,則最好使用 reflect.Type,比如要獲取結構體對應的欄位名稱或方法。

要反射獲取一個變數的 reflect.Type,可以通過函式 reflect.TypeOf。

介面定義

和 reflect.Value 不同,reflect.Type 是一個介面,而不是一個結構體,所以也只能使用它的方法。

以下列出來的 reflect.Type 介面常用的方法。從這個列表來看,大部分都和 reflect.Value 的方法功能相同。

type Type interface {

   Implements(u Type) bool
   AssignableTo(u Type) bool
   ConvertibleTo(u Type) bool
   Comparable() bool

   //以下這些方法和Value結構體的功能相同
   Kind() Kind

   Method(int) Method
   MethodByName(string) (Method, bool)
   NumMethod() int
   Elem() Type
   Field(i int) StructField
   FieldByIndex(index []int) StructField
   FieldByName(name string) (StructField, bool)
   FieldByNameFunc(match func(string) bool) (StructField, bool)
   NumField() int
}

其中幾個特有的方法如下:

  • Implements 方法用於判斷是否實現了介面 u;
  • AssignableTo 方法用於判斷是否可以賦值給型別 u,其實就是是否可以使用 =,即賦值運算子;
  • ConvertibleTo 方法用於判斷是否可以轉換成型別 u,其實就是是否可以進行型別轉換;
  • Comparable 方法用於判斷該型別是否是可比較的,其實就是是否可以使用關係運算子進行比較。

同樣會通過一些示例來講解 reflect.Type 的使用。

遍歷結構體的欄位和方法

採用上面示例中的 person 結構體進行演示,不過需要修改一下,為它增加一個方法 String,如下所示:

func (p person) String() string{
   return fmt.Sprintf("Name is %s,Age is %d",p.Name,p.Age)
}

新增一個 String 方法,返回對應的字串資訊,這樣 person 這個 struct 結構體也實現了 fmt.Stringer 介面。

可以通過 NumField 方法獲取結構體欄位的數量,然後使用 for 迴圈,通過 Field 方法就可以遍歷結構體的欄位,並列印出欄位名稱。同理,遍歷結構體的方法也是同樣的思路,程式碼也類似,如下所示:

func main() {
   p:=person{Name: "Golang",Age: 20}
   pt:=reflect.TypeOf(p)
   //遍歷person的欄位
   for i:=0;i<pt.NumField();i++{
      fmt.Println("欄位:",pt.Field(i).Name)
   }
   //遍歷person的方法
   for i:=0;i<pt.NumMethod();i++{
      fmt.Println("方法:",pt.Method(i).Name)
   }
}

執行這個程式碼,可以看到如下結果:

欄位: Name
欄位: Age
方法: String

這正好和結構體 person 中定義的一致,說明遍歷成功。

小技巧:可以通過 FieldByName 方法獲取指定的欄位,也可以通過 MethodByName 方法獲取指定的方法,這在需要獲取某個特定的欄位或者方法時非常高效,而不是使用遍歷。

是否實現某介面

通過 reflect.Type 還可以判斷是否實現了某介面。以 person 結構體為例,判斷它是否實現了介面 fmt.Stringer 和 io.Writer,如下面的程式碼所示:

func main() {
   p:=person{Name: "Golang",Age: 20}
   pt:=reflect.TypeOf(p)
   stringerType:=reflect.TypeOf((*fmt.Stringer)(nil)).Elem()
   writerType:=reflect.TypeOf((*io.Writer)(nil)).Elem()
   fmt.Println("是否實現了fmt.Stringer:",pt.Implements(stringerType))
   fmt.Println("是否實現了io.Writer:",pt.Implements(writerType))
}

小提示:儘可能通過型別斷言的方式判斷是否實現了某介面,而不是通過反射。

這個示例通過 Implements 方法來判斷是否實現了 fmt.Stringer 和 io.Writer 介面,執行它,可以看到如下結果:

是否實現了fmt.Stringer: true
是否實現了io.Writer: false

因為結構體 person 只實現了 fmt.Stringer 介面,沒有實現 io.Writer 介面,所以和驗證的結果一致。

字串和結構體互轉

在字串和結構體互轉的場景中,使用最多的就是 JSON 和 struct 互轉。

JSON 和 Struct 互轉

Go 語言的標準庫有一個 json 包,通過它可以把 JSON 字串轉為一個 struct 結構體,也可以把一個 struct 結構體轉為一個 json 字串。下面以 person 這個結構體為例,講解 JSON 和 struct 的相互轉換。如下面的程式碼所示:

func main() {
   p:=person{Name: "張三",Age: 20}
   //struct to json
   jsonB,err:=json.Marshal(p)
   if err==nil {
      fmt.Println(string(jsonB))
   }
   //json to struct
   respJSON:="{\"Name\":\"李四\",\"Age\":40}"
   json.Unmarshal([]byte(respJSON),&p)
   fmt.Println(p)
}

這個示例使用 Go 語言提供的 json 標準包做的演示。通過 json.Marshal 函式,你可以把一個 struct 轉為 JSON 字串。通過 json.Unmarshal 函式,你可以把一個 JSON 字串轉為 struct。

執行以上程式碼,會看到如下結果輸出:

{“Name”:”張三”,”Age”:20}
Name is 李四,Age is 40

仔細觀察以上列印出的 JSON 字串,你會發現 JSON 字串的 Key 和 struct 結構體的欄位名稱一樣,比如示例中的 Name 和 Age。那麼是否可以改變它們呢?比如改成小寫的 name 和 age,並且欄位的名稱還是大寫的 Name 和 Age。當然可以,要達到這個目的就需要用到 struct tag 的功能了。

Struct Tag

顧名思義,struct tag 是一個新增在 struct 欄位上的標記,使用它進行輔助,可以完成一些額外的操作,比如 json 和 struct 互轉。在上面的示例中,如果想把輸出的 json 字串的 Key 改為小寫的 name 和 age,可以通過為 struct 欄位新增 tag 的方式,示例程式碼如下:

type person struct {
   Name string `json:"name"`
   Age int `json:"age"`
}

為 struct 欄位新增 tag 的方法很簡單,只需要在欄位後面通過反引號把一個鍵值對包住即可,比如以上示例中的 json:”name”。其中冒號前的 json 是一個 Key,可以通過這個 Key 獲取冒號後對應的 name。

小提示:json 作為 Key,是 Go 語言自帶的 json 包解析 JSON 的一種約定,它會通過 json 這個 Key 找到對應的值,用於 JSON 的 Key 值。

通過 struct tag 指定了可以使用 name 和 age 作為 json 的 Key,程式碼就可以修改成如下所示:

respJSON:="{\"name\":\"李四\",\"age\":40}"

沒錯,JSON 字串也可以使用小寫的 name 和 age 了。現在再執行這段程式碼,看到如下結果:

{“name”:”張三”,”age”:20}
Name is 李四,Age is 40

輸出的 JSON 字串的 Key 是小寫的 name 和 age,並且小寫的 name 和 age JSON 字串也可以轉為 person 結構體。

相信已經發現,struct tag 是整個 JSON 和 struct 互轉的關鍵,這個 tag 就像是為 struct 欄位起的別名,那麼 json 包是如何獲得這個 tag 的呢?這就需要反射了。來看下面的程式碼:

//遍歷person欄位中key為json的tag
for i:=0;i<pt.NumField();i++{
   sf:=pt.Field(i)
   fmt.Printf("欄位%s上,json tag為%s\n",sf.Name,sf.Tag.Get("json"))
}

要想獲得欄位上的 tag,就要先反射獲得對應的欄位,可以通過 Field 方法做到。該方法返回一個 StructField 結構體,它有一個欄位是 Tag,存有欄位的所有 tag。示例中要獲得 Key 為 json 的 tag,所以只需要呼叫 sf.Tag.Get(“json”) 即可。

結構體的欄位可以有多個 tag,用於不同的場景,比如 json 轉換、bson 轉換、orm 解析等。如果有多個 tag,要使用空格分隔。採用不同的 Key 可以獲得不同的 tag,如下面的程式碼所示:

//遍歷person欄位中key為json、bson的tag
for i:=0;i<pt.NumField();i++{
   sf:=pt.Field(i)
   fmt.Printf("欄位%s上,json tag為%s\n",sf.Name,sf.Tag.Get("json"))
   fmt.Printf("欄位%s上,bson tag為%s\n",sf.Name,sf.Tag.Get("bson"))
}
type person struct {
   Name string `json:"name" bson:"b_name"`
   Age int `json:"age" bson:"b_name"`
}

執行程式碼,可以看到如下結果:

欄位Name上,key為json的tag為name
欄位Name上,key為bson的tag為b_name
欄位Age上,key為json的tag為age
欄位Age上,key為bson的tag為b_name

可以看到,通過不同的 Key,使用 Get 方法就可以獲得自定義的不同的 tag。

實現 Struct 轉 JSON

相信理解了什麼是 struct tag,下面通過一個 struct 轉 json 的例子演示它的使用:

func main() {
   p:=person{Name: "Golang",Age: 20}
   pv:=reflect.ValueOf(p)
   pt:=reflect.TypeOf(p)
   //自己實現的struct to json
   jsonBuilder:=strings.Builder{}
   jsonBuilder.WriteString("{")
   num:=pt.NumField()
   for i:=0;i<num;i++{
      jsonTag:=pt.Field(i).Tag.Get("json") //獲取json tag
      jsonBuilder.WriteString("\""+jsonTag+"\"")
      jsonBuilder.WriteString(":")
      //獲取欄位的值
      jsonBuilder.WriteString(fmt.Sprintf("\"%v\"",pv.Field(i)))
      if i<num-1{
         jsonBuilder.WriteString(",")
      }
   }
   jsonBuilder.WriteString("}")
   fmt.Println(jsonBuilder.String())//列印json字串
}

這是一個比較簡單的 struct 轉 json 示例,但是已經可以很好地演示 struct 的使用。在上述示例中,自定義的 jsonBuilder 負責 json 字串的拼接,通過 for 迴圈把每一個欄位拼接成 json 字串。執行以上程式碼,可以看到如下列印結果:

{“name”:”Golang”,”age”:”20”}

json 字串的轉換隻是 struct tag 的一個應用場景,完全可以把 struct tag 當成結構體中欄位的後設資料配置,使用它來做想做的任何事情,比如 orm 對映、xml 轉換、生成 swagger 文件等。

反射定律

反射是計算機語言中程式檢視其自身結構的一種方法,它屬於超程式設計的一種形式。反射靈活、強大,但也存在不安全。它可以繞過編譯器的很多靜態檢查,如果過多使用便會造成混亂。為了幫助開發者更好地理解反射,Go 語言的作者在部落格上總結了反射的三大定律。

  1. 任何介面值 interface{} 都可以反射出反射物件,也就是 reflect.Value 和 reflect.Type,通過函式 reflect.ValueOf 和 reflect.TypeOf 獲得。
  2. 反射物件也可以還原為 interface{} 變數,也就是第 1 條定律的可逆性,通過 reflect.Value 結構體的 Interface 方法獲得。
  3. 要修改反射的物件,該值必須可設定,也就是可定址,參考上節課修改變數的值那一節的內容理解。

小提示:任何型別的變數都可以轉換為空介面 intferface{},所以第 1 條定律中函式 reflect.ValueOf 和 reflect.TypeOf 的引數就是 interface{},表示可以把任何型別的變數轉換為反射物件。在第 2 條定律中,reflect.Value 結構體的 Interface 方法返回的值也是 interface{},表示可以把反射物件還原為對應的型別變數。

一旦理解了這三大定律,就可以更好地理解和使用 Go 語言反射。

總結

在反射中,reflect.Value 對應的是變數的值,如果需要進行和變數的值有關的操作,應該優先使用 reflect.Value,比如獲取變數的值、修改變數的值等。reflect.Type 對應的是變數的型別,如果需要進行和變數的型別本身有關的操作,應該優先使用 reflect.Type,比如獲取結構體內的欄位、型別擁有的方法集等。

此外要再次強調:反射雖然很強大,可以簡化程式設計、減少重複程式碼,但是過度使用會讓程式碼變得複雜混亂。所以除非非常必要,否則儘可能少地使用它們。

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

相關文章