Golang 挑戰:編寫函式 walk(x interface{}, fn func(string)),引數為結構體 x,並對 x 中的所有字串欄位呼叫 fn 函式。難度級別:遞迴。

slowlydance2me發表於2023-03-29

經過一段時間的學習與實踐,針對 Golang 語言基礎的 TDD 驅動開發測試訓練已經告一段落,接下來會在此基礎上繼續使用 TDD 來構建應用程式 。

博主前一部分的程式碼Github先貼下面?以供參考

https://github.com/slowlydance2me/My_Golang_Study.git

2023-03-29 今日更新

golang 挑戰:編寫函式 walk(x interface{}, fn func(string)),引數為結構體 x,並對 x 中的所有字串欄位呼叫 fn 函式。難度級別:遞迴。

為此,我們需要使用 反射

計算中的反射提供了程式檢查自身結構體的能力,特別是透過型別,這是超程式設計的一種形式。這也是造成困惑的一個重要原因。

什麼是 interface

由於函式使用已知的型別,例如 stringint 以及我們自己定義的型別,如 BankAccount,我們享受到了 Go 為我們提供的型別安全。
這意味著我們可以免費獲得一些檔案,如果你試圖向函式傳遞錯誤的型別,編譯器就會報錯。
但是,你可能會遇到這樣的情況,即你不知道要編寫的函式引數在編譯時是什麼型別的。
Go 允許我們使用型別 interface{} 來解決這個問題,你可以將其視為 任意 型別。
所以 walk(x interface{}, fn func(string))引數可以接收任何的值。

那麼為什麼不透過將所有引數都定義為 interface 型別來得到真正靈活的函式呢?

  • 作為函式的使用者,使用 interface 將失去對型別安全的檢查。如果你想傳入 string 型別的 Foo.bar 但是傳入的是 int 型別的 Foo.baz,編譯器將無法通知你這個錯誤。你也搞不清楚函式允許傳遞什麼型別的引數。知道一個函式接收什麼型別,例如 UserService,是非常有用的。
  •  作為這樣一個函式的作者,你必須檢查傳入的 所有 引數,並嘗試斷定引數型別以及如何處理它們。這是透過 反射 實現的。這種方式可能相當笨拙且難以閱讀,而且一般效能比較差(因為程式必須在執行時執行檢查)。
簡而言之,除非真的需要否則不要使用反射。
 
如果你想實現函式的多型性,請考慮是否可以圍繞介面(不是 interface 型別,這裡容易讓人困惑)設計它,以便使用者可以用多種型別來呼叫你的函式,這些型別實現了函式工作所需要的任何方法。
我們的函式需要能夠處理很多不同的東西。和往常一樣,我們將採用迭代的方法,為我們想要支援的每一件新事物編寫測試,並一路進行重構,直到完成。

首先編寫測試

我們想用一個 struct 來呼叫我們的函式,這個 struct 中有一個字串欄位(x),然後我們可以監視傳入的函式(fn),看看它是否被呼叫。

func TestWalk(t *testing.T) {

    expected := "Chris"
    var got []string

    x := struct {
        Name string
    }{expected}

    walk(x, func(input string) {
        got = append(got, input)
    })

    if len(got) != 1 {
        t.Errorf("wrong number of function calls, got %d want %d", len(got), 1)
    }
}
  • 我們想儲存一個字串切片(got),字串透過 walk 傳遞到 fn。在前面的章節中,通常我們會專門為函式或方法呼叫指定型別,但在這種情況下,我們可以傳遞一個匿名函式給 fn,它會隱藏 got
  •  我們使用帶有 string 型別的 Name 欄位的匿名 struct,以此得到最簡單的實現路徑。
  •  最後呼叫 walk 並傳入 x 引數,現在只檢查 got 的長度,一旦有了基本的可以執行的程式,我們的斷言就會更加具體。

嘗試執行測試

./reflection_test.go:21:2: undefined: walk

為測試的執行編寫最小量的程式碼,並檢查測試的失敗輸出

我們需要定義 walk 函式

func walk(x interface{}, fn func(input string)) {

}

再次嘗試執行測試

=== RUN TestWalk
--- FAIL: TestWalk (0.00s)
reflection_test.go:19: wrong number of function calls, got 0 want 1
FAIL

編寫足夠的程式碼使測試透過

我們可以使用任意的字串呼叫 fn 函式來使測試透過。

func walk(x interface{}, fn func(input string)) {
    fn("I still can't believe South Korea beat Germany 2-0 to put them last in their group")
}

現在測試應該透過了。接下來我們需要做的是對我們的 fn 是如何被呼叫的做一個更具體的斷言。

首先編寫測試

在之前的測試中新增以下程式碼,檢查傳入 fn 函式的字串是否正確。

if got[0] != expected {
    t.Errorf("got '%s', want '%s'", got[0], expected)
}

嘗試執行測試

=== RUN TestWalk
--- FAIL: TestWalk (0.00s)
reflection_test.go:23: got 'I still can't believe South Korea beat Germany 2-0 to put them last in their group', want 'Chris'
FAIL

編寫足夠的程式碼使測試透過

func walk(x interface{}, fn func(input string)) {
    val := reflect.ValueOf(x)
    field := val.Field(0)
    fn(field.String())
}

這段程式碼 非常不安全,也非常幼稚,但請記住,當我們處於「紅色」狀態(測試失敗)時,我們的目標是編寫儘可能少的程式碼。然後我們編寫更多的測試來解決我們的問題。

我們需要使用反射來檢視 x 並嘗試檢視它的屬性。
反射包有一個函式 ValueOf,該函式值返回一個給定變數的 Value。這為我們提供了檢查值的方法,包括我們在下一行中使用的欄位。
然後我們對傳入的值做了一些非常樂觀的假設:
  •  我們只看第一個也是唯一的欄位,可能根本就沒有欄位會引起 panic
  •  然後我們呼叫 String()它以字串的形式返回底層值,但是我們知道,如果這個欄位不是字串,程式就會出錯。

重構

我們的程式碼在簡單的測試中可以透過,但是我們也知道程式碼有很多缺點。
我們將編寫一些測試,在這些測試中我們傳入不同的值並檢查 fn 呼叫的字串陣列。
 我們應該將我們的測試重構到一個基於表的測試中,以便更容易地繼續測試新的場景。
func TestWalk(t *testing.T) {

    cases := []struct{
        Name string
        Input interface{}
        ExpectedCalls []string
    } {
        {
            "Struct with one string field",
            struct {
                Name string
            }{ "Chris"},
            []string{"Chris"},
        },
    }

    for _, test := range cases {
        t.Run(test.Name, func(t *testing.T) {
            var got []string
            walk(test.Input, func(input string) {
                got = append(got, input)
            })

            if !reflect.DeepEqual(got, test.ExpectedCalls) {
                t.Errorf("got %v, want %v", got, test.ExpectedCalls)
            }
        })
    }
}

現在,我們可以很容易地新增一個場景,看看如果有多個字串欄位會發生什麼。

首先編寫測試

為測試用例新增以下場景。.

{
    "Struct with two string fields",
    struct {
        Name string
        City string
    }{"Chris", "London"},
    []string{"Chris", "London"},
}

嘗試執行測試

=== RUN TestWalk/Struct_with_two_string_fields
--- FAIL: TestWalk/Struct_with_two_string_fields (0.00s)
reflection_test.go:40: got [Chris], want [Chris London]

編寫足夠的程式碼使測試透過

func walk(x interface{}, fn func(input string)) {
    val := reflect.ValueOf(x)

    for i:=0; i<val.NumField(); i++ {
        field := val.Field(i)
        fn(field.String())
    }
}

value 有一個方法 NumField,它返回值中的欄位數。這讓我們遍歷欄位並呼叫 fn 透過我們的測試。

重構

這裡似乎沒有任何明顯的重構可以改程序式碼,讓我們繼續。
walk 的另一個缺點是它假設每個欄位都是 string 讓我們為這個場景編寫一個測試。

首先編寫測試

新增一下測試用例

{
    "Struct with non string field",
    struct {
        Name string
        Age  int
    }{"Chris", 33},
    []string{"Chris"},
},

嘗試執行測試

=== RUN TestWalk/Struct_with_non_string_field
--- FAIL: TestWalk/Struct_with_non_string_field (0.00s)
reflection_test.go:46: got [Chris <int Value>], want [Chris]

 

編寫足夠的程式碼使測試透過

我們需要檢查欄位的型別是 string
func walk(x interface{}, fn func(input string)) {
    val := reflect.ValueOf(x)

    for i := 0; i < val.NumField(); i++ {
        field := val.Field(i)

        if field.Kind() == reflect.String {
            fn(field.String())
        }
    }
}

我們可以透過檢查它的 Kind 來實現這個功能。

重構

現在看起來程式碼已經足夠合理了。
下一個場景是,如果它不是一個「平的」struct 怎麼辦?換句話說,如果我們有一個包含巢狀欄位的 struct會發生什麼?

首先編寫測試

我們臨時使用匿名結構體語法為我們的測試宣告型別,所以我們可以繼續這樣做

{
    "Nested fields",
    struct {
        Name string
        Profile struct {
            Age  int
            City string
        }
    }{"Chris", struct {
        Age  int
        City string
    }{33, "London"}},
    []string{"Chris", "London"},
},
但我們可以看到,當你得到內部匿名結構時語法會有點混亂。這裡有一個建議可以使它的語法更好
讓我們透過為這個場景建立一個已知型別並在測試中引用它來重構它。有一點間接的地方是,我們測試的一些程式碼在測試之外,但是讀者應該能夠透過觀察初始化來推斷 struct 的結構。
在測試檔案中新增以下型別宣告
type Person struct {
    Name    string
    Profile Profile
}

type Profile struct {
    Age  int
    City string
}

現在我們將這些新增到測試用例中,它提高了程式碼的可讀性

{
    "Nested fields",
    Person{
        "Chris",
        Profile{33, "London"},
    },
    []string{"Chris", "London"},
},

嘗試執行測試

 

=== RUN TestWalk/Nested_fields
--- FAIL: TestWalk/Nested_fields (0.00s)
reflection_test.go:54: got [Chris], want [Chris London]

這個問題是我們只在型別層次結構的第一級上迭代欄位導致的。

.編寫足夠的程式碼使測試透過

func walk(x interface{}, fn func(input string)) {
    val := reflect.ValueOf(x)

    for i := 0; i < val.NumField(); i++ {
        field := val.Field(i)

        if field.Kind() == reflect.String {
            fn(field.String())
        }

        if field.Kind() == reflect.Struct {
            walk(field.Interface(), fn)
        }
    }
}

解決方法很簡單,我們再次檢查它的 Kind 如果它碰巧是一個 struct 我們就在內部 struct 上再次呼叫 walk

重構

func walk(x interface{}, fn func(input string)) {
    val := reflect.ValueOf(x)

    for i := 0; i < val.NumField(); i++ {
        field := val.Field(i)

        switch field.Kind() {
        case reflect.String:
            fn(field.String())
        case reflect.Struct:
            walk(field.Interface(), fn)
        }
    }
}
當你多次對相同的值進行比較時,通常情況下,將程式碼重構為 switch 會提高可讀性,使程式碼更易於擴充套件。
 
如果傳遞進來的結構的值是一個指標呢?

首先編寫測試

新增這個測試用例

 
{
    "Pointers to things",
    &Person{
        "Chris",
        Profile{33, "London"},
    },
    []string{"Chris", "London"},
},

嘗試執行測試

=== RUN TestWalk/Pointers_to_things
panic: reflect: call of reflect.Value.NumField on ptr Value [recovered]
panic: reflect: call of reflect.Value.NumField on ptr Value

 

編寫足夠的程式碼使測試透過

func walk(x interface{}, fn func(input string)) {
    val := reflect.ValueOf(x)

    if val.Kind() == reflect.Ptr {
        val = val.Elem()
    }

    for i := 0; i < val.NumField(); i++ {
        field := val.Field(i)

        switch field.Kind() {
        case reflect.String:
            fn(field.String())
        case reflect.Struct:
            walk(field.Interface(), fn)
        }
    }
}

指標型別的 Value 不能使用 NumField 方法,在執行此方法前需要呼叫 Elem() 提取底層值

 

重構

讓我們封裝一個獲得 reflect.Value 的功能,將 interface{} 傳入函式並返回這個值

func walk(x interface{}, fn func(input string)) {
    val := getValue(x)

    for i := 0; i < val.NumField(); i++ {
        field := val.Field(i)

        switch field.Kind() {
        case reflect.String:
            fn(field.String())
        case reflect.Struct:
            walk(field.Interface(), fn)
        }
    }
}

func getValue(x interface{}) reflect.Value {
    val := reflect.ValueOf(x)

    if val.Kind() == reflect.Ptr {
        val = val.Elem()
    }

    return val
}
這實際上增加了更多的程式碼,但我覺得抽象層是沒有問題的
  •  
    得到 xreflect.Value,這樣我就可以檢查它,我不在乎怎麼做。
  •  
    遍歷欄位,根據其型別執行任何需要執行的操作。
 接下來我們需要覆蓋切片。

首先編寫測試

{
    "Slices",
    []Profile {
        {33, "London"},
        {34, "Reykjavík"},
    },
    []string{"London", "Reykjavík"},
},

嘗試執行測試

=== RUN TestWalk/Slices
panic: reflect: call of reflect.Value.NumField on slice Value [recovered]
panic: reflect: call of reflect.Value.NumField on slice Value

為測試的執行編寫最小量的程式碼,並檢查測試的失敗輸出

這與前面的指標場景類似,我們試圖在 reflect.Value 中呼叫 NumField。但它沒有,因為它不是結構體。

編寫足夠的程式碼使測試透過

func walk(x interface{}, fn func(input string)) {
    val := getValue(x)

    if val.Kind() == reflect.Slice {
        for i:=0; i< val.Len(); i++ {
            walk(val.Index(i).Interface(), fn)
        }
        return
    }

    for i := 0; i < val.NumField(); i++ {
        field := val.Field(i)

        switch field.Kind() {
        case reflect.String:
            fn(field.String())
        case reflect.Struct:
            walk(field.Interface(), fn)
        }
    }
}

重構

這招很管用,但很噁心。不過不用擔心,我們有測試支援的工作程式碼,所以我們可以隨意修改我們喜歡的程式碼。
如果你抽象地想一下,我們想要針對下面的物件呼叫 walk
  •  
    結構體中的每個欄位
  •  
    切片中的每一項
我們目前的程式碼可以做到這一點,但反射用得不太好。我們只是在一開始檢查它是否是切片(透過 return 來停止執行剩餘的程式碼),如果不是,我們就假設它是 struct
讓我們重新編寫程式碼,先檢查型別,再執行我們的邏輯程式碼。
func walk(x interface{}, fn func(input string)) {
    val := getValue(x)

    switch val.Kind() {
    case reflect.Struct:
        for i:=0; i<val.NumField(); i++ {
            walk(val.Field(i).Interface(), fn)
        }
    case reflect.Slice:
        for i:=0; i<val.Len(); i++ {
            walk(val.Index(i).Interface(), fn)
        }
    case reflect.String:
        fn(val.String())
    }
}
看起來好多了!如果是 struct 或切片,我們會遍歷它的值,並對每個值呼叫 walk 函式。如果是 reflect.String,我們就呼叫 fn
不過,對我來說,感覺還可以更好。這裡有遍歷欄位、值,然後呼叫 walk的重複操作,但概念上它們是相同的。
func walk(x interface{}, fn func(input string)) {
    val := getValue(x)

    numberOfValues := 0
    var getField func(int) reflect.Value

    switch val.Kind() {
    case reflect.String:
        fn(val.String())
    case reflect.Struct:
        numberOfValues = val.NumField()
        getField = val.Field
    case reflect.Slice:
        numberOfValues = val.Len()
        getField = val.Index
    }

    for i:=0; i< numberOfValues; i++ {
        walk(getField(i).Interface(), fn)
    }
}
如果 value 是一個 reflect.String,我們就像平常一樣呼叫 fn
否則,我們的 switch 將根據型別提取兩個內容
  •  
    有多少欄位
  •  
    如何提取 ValueFieldIndex
一旦確定了這些東西,我們就可以遍歷 numberOfValues,使用 getField 函式的結果呼叫 walk 函式。
 
現在我們已經完成了,處理陣列應該很簡單了。

首先編寫測試

新增以下程式碼到測試用例中:

{
    "Arrays",
    [2]Profile {
        {33, "London"},
        {34, "Reykjavík"},
    },
    []string{"London", "Reykjavík"},
},

=== RUN TestWalk/Arrays
--- FAIL: TestWalk/Arrays (0.00s)
reflection_test.go:78: got [], want [London Reykjavík]

編寫足夠的程式碼使測試透過

陣列的處理方式與切片處理方式相同,因此只需用逗號將其新增到測試用例中

func walk(x interface{}, fn func(input string)) {
    val := getValue(x)

    numberOfValues := 0
    var getField func(int) reflect.Value

    switch val.Kind() {
    case reflect.String:
        fn(val.String())
    case reflect.Struct:
        numberOfValues = val.NumField()
        getField = val.Field
    case reflect.Slice, reflect.Array:
        numberOfValues = val.Len()
        getField = val.Index
    }

    for i:=0; i< numberOfValues; i++ {
        walk(getField(i).Interface(), fn)
    }
}

我們想處理的最後一個型別是 map

 首先編寫測試

{
    "Maps",
    map[string]string{
        "Foo": "Bar",
        "Baz": "Boz",
    },
    []string{"Bar", "Boz"},
},

嘗試執行測試

=== RUN TestWalk/Maps
--- FAIL: TestWalk/Maps (0.00s)
reflection_test.go:86: got [], want [Bar Boz]

編寫足夠的程式碼使測試透過

如果你抽象地想一下你會發現 mapstruct 很相似,只是編譯時的鍵是未知的。

func walk(x interface{}, fn func(input string)) {
    val := getValue(x)

    numberOfValues := 0
    var getField func(int) reflect.Value

    switch val.Kind() {
    case reflect.String:
        fn(val.String())
    case reflect.Struct:
        numberOfValues = val.NumField()
        getField = val.Field
    case reflect.Slice, reflect.Array:
        numberOfValues = val.Len()
        getField = val.Index
    case reflect.Map:
        for _, key := range val.MapKeys() {
            walk(val.MapIndex(key).Interface(), fn)
        }
    }

    for i:=0; i< numberOfValues; i++ {
        walk(getField(i).Interface(), fn)
    }
}

然而透過設計,你無法透過索引從 map 中獲取值。它只能透過 來完成,這樣就打破了我們的抽象,該死。

重構

你現在感覺怎麼樣?這在當時可能是一個很好的抽象,但現在程式碼感覺有點不穩定。
這沒問題! 重構是一段旅程,有時我們會犯錯誤。TDD 的一個主要觀點是它給了我們嘗試這些東西的自由。
 透過步步為營的原則就不會發生不可逆轉的局面。讓我們把它恢復到重構之前的狀態。
func walk(x interface{}, fn func(input string)) {
    val := getValue(x)

    walkValue := func(value reflect.Value) {
        walk(value.Interface(), fn)
    }

    switch val.Kind() {
    case reflect.String:
        fn(val.String())
    case reflect.Struct:
        for i := 0; i< val.NumField(); i++ {
            walkValue(val.Field(i))
        }
    case reflect.Slice, reflect.Array:
        for i:= 0; i<val.Len(); i++ {
            walkValue(val.Index(i))
        }
    case reflect.Map:
        for _, key := range val.MapKeys() {
            walkValue(val.MapIndex(key))
        }
    }
}

我們已經介紹了 walkValue,它依照「Don't repeat yourself」的原則在 switch 中呼叫 walk 函式,這樣它們就只需要從 val 中提取 reflect.Value 即可。

最後一個問題

記住,Go 中的 map 不能保證順序一致。因此,你的測試有時會失敗,因為我們斷言對 fn 的呼叫是以特定的順序完成的。
為瞭解決這個問題,我們需要將帶有 map
t.Run("with maps", func(t *testing.T) {
    aMap := map[string]string{
        "Foo": "Bar",
        "Baz": "Boz",
    }

    var got []string
    walk(aMap, func(input string) {
        got = append(got, input)
    })

    assertContains(t, got, "Bar")
    assertContains(t, got, "Boz")
})

下面是 assertContains 是如何定義的

func assertContains(t *testing.T, haystack []string, needle string)  {
    contains := false
    for _, x := range haystack {
        if x == needle {
            contains = true
        }
    }
    if !contains {
        t.Errorf("expected %+v to contain '%s' but it didnt", haystack, needle)
    }
}

大功告成!

 

總結

  •  介紹了 reflect 包中的一些概念。
  •  使用遞迴遍歷任意資料結構。
  •  在回顧中做了一個糟糕的重構,但不用對此感到太沮喪。透過迭代地進行測試,這並不是什麼大問題。

相關文章