资讯

精准传达 • 有效沟通

从品牌网站建设到网络营销策划,从策略到执行的一站式服务

gopl反射1

Go 语言提供了一种机制,在编译时不知道类型的情况下,可更新变量、在运行时查看值、调用方法以及直接对它们的布局进行操作,这种机制称为反射(reflection)。

在延川等地区,都构建了全面的区域性战略布局,加强发展的系统性、市场前瞻性、产品创新能力,以专注、极致的服务理念,为客户提供成都网站制作、成都做网站 网站设计制作定制网站建设,公司网站建设,企业网站建设,品牌网站制作,网络营销推广,外贸网站制作,延川网站建设费用合理。

本篇各章节的主要内容:

  1. 为什么使用反射:不使用反射会有哪些限制
  2. reflect 包的两个主要类型:通过反射获取基础类型的值
  3. 递归地获取组合类型的值
  4. 示例,编码 S 表达式:以上内容的综合运用

关于反射的文章,下面这篇也不错的,条条理比较清晰,可以参考。
Go语言基础之反射:https://www.liwenzhou.com/posts/Go/13_reflect/

为什么使用反射

有时候我们需要编写一个函数,一个有能力统一处理各种值类型的函数。而这些类型可能无法共享同一个接口,也可能布局未知,还有可能这个类型在设计函数的时候还不存在。甚至这个类型会同时存在以上多个或全部的问题。

格式化函数

一个熟悉的例子是 fmt.Printf 中的格式化逻辑,它可以输出任意类型的任意值,包括用户自定义的类型。下面尝试写一个与 fmt.Sprint 类似的函数,只接收一个值然后返回字符串,函数名就称为 Sprint。
先用一个类型分支来判断这个参数是否定义了 String 方法,如果有就调用它。然后添加一些 switch 分支来判断参数的动态类型是否是基本类型,再对每种类型采用不同的格式化操作:

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 "???"
    }
}

到此,还没有用到反射。
对于复合数据类型,也可以添加更多的分支。但是比如数组,不用的长度就是不一样的类型,所以这样的类型有无限多。另外还有自定义命名的类型。当我们无法透视一个未知类型的布局时,这段代码就无法继续,现在就需要反射了。

reflect.Type 和 reflect.Value

反射功能由 reflect 包提供,它定义了两个重要的类型:

  • Type :
  • Value :

Type

reflect.Type 是一个接口,每个 Type 表示 Go 语言的一个类型。
reflect.TypeOf 函数接受 interface{} 参数,以 reflect.Type 的形式返回动态类型:

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

因为 reflect.TypeOf 返回一个接口值对应的动态类型,所以它返回的总是具体类型而不是接口类型:

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

因为输出一个接口值的动态类型在调试和日志中很常用,所以 fmt.Printf 提供了一个简单的方式 %T,内部的实现就是 reflect.TypeOf:

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

Value

reflect.Value 是一个结构体类型,可以包含一个任意类型的值。
reflect.ValueOf 函数接受 interface{} 参数,将接口的动态值以 reflect.Value 的形式返回。与 reflect.TypeOf 类似,reflect.Value 返回的结果也是具体类型,不过也可以是一个接口值:

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

reflect.Value 也满足 fmt.Stringer,但除非 Value 包含的是一个字符串,否则 String 方法的结果仅仅暴露类型。通常,需要 fmt 包的 %v 功能,它会对 reflect.Value 进行特殊处理。

Value 结构体的方法
调用 Value 的 Type 方法会把它的类型以 reflect.Type 方式返回:

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

reflect.ValueOf 的逆操作是 reflect.Value.Interface 方法。它返回一个 interface{},即空接口值,与 reflect.Value 包含同一个具体值:

v := reflect.ValueOf(3) // a reflect.Value
x := v.Interface()      // an interface{}
i := x.(int)            // an int
fmt.Printf("%d\n", i)   // "3"

reflect.Value 和 interface{} 都可以包含任意的值。二者的区别是空接口隐藏了值的布局信息、内置操作和相关方法,所以除非知道它的动态类型,并用一个类型断言来滲透进去(就如上面的代码那样),否则对所包含的值能做的事情很少。作为对比,Value 有很多方法可以用来分析所包含的值,而不用知道它的类型。

格式化函数

使用反射的技术,第二次尝试写一个通用的格式化函数,这次名称叫: fotmat.Any。
不用类型分支,这里用 reflec.Value 的 Kind 方法来区分不同的类型。尽管有无限种类型,但类型的分类(kind)只有少数几种:

  • 基础类型:
    • bool
    • String
    • 各种数字类型
  • 聚合类型:
    • Array
    • Struct
  • 引用类型
    • chan
    • Func
    • Ptr
    • Slice
    • Map
  • 接口类型:
    • interface

最后还有一个 Invalid 类型,表示它们还没有任何的值。(reflect.Value 的零值就属于 Invalid 类型。)

package format

import (
    "reflect"
    "strconv"
)

// Any 把任何值格式化为一个字符串
func Any(value interface{}) string {
    return formatAtom(reflect.ValueOf(value))
}

// formatAtom 格式化一个值,且不分析它的内部结构
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)
    // ... 浮点数和负数的分支省略了...
    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)。对于聚合类型和接口,只输出值的类型。对于引用类型,输出类型和以十六进制表示的引用地址。这个结构仍然不够理想,下一节会继续改进。
因为 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:一个递归的值显示器

接下来改善组合类型的显示。这次不再实现一个 fmt.Sprint,而是实现一个称为 Display 的调试工具函数,这个函数对给定的一个复杂值x,输出这个复杂值的完整结构,并对找到的每个元素标上这个元素的路径。
应当尽量避免在包的 API 里暴露反射的相关内容,之后将定义一个未导出的函数 display 来做真正的递归处理,再暴露 Display,而 Display 则只是一个简单的封装:

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

在 display 中,使用之前定义的 formatAtom 函数来输出基础值,直接就把这个函数搬过来了。使用 reflect. Value 的一些方法来递归展示复杂类型的每个组成部分。当递归深入是,path 字符串会增长,表示是如何达到当前值的。
上两节的示例都是在模拟实现 fmt.Sprint,结构都是通过 strconv 包转成字符串然后返回的。这里就直接使用 fmt 包简化了部分逻辑:

package display

import (
    "fmt"
    "reflect"
    "strconv"
)

// formatAtom 格式化一个值,且不分析它的内部结构
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)
    // ... 浮点数和负数的分支省略了...
    case reflect.Bool:
        if v.Bool() {
            return "true"
        }
        return "false"
    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"
    }
}

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

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: // 基本类型、通道、函数
        fmt.Printf("%s = %s\n", path, formatAtom(v))
    }
}

分析类型分支

接下来对 display 函数里的类型分支逐一进行分析。

slice与数组
两者的逻辑一致。Len 方法返回元素的个数,Index(i) 会返回第 i 个元素,返回的元素的类型为 reflect.Value(如果i越界会崩溃)。这两个方法与内置的 len(a) 和 a[i] 序列操作类型。在每个序列上递归调用了 display 函数,只是在路径后追加了 "[i]"。
尽管 reflect.Value 有很多方法,但对于每个值,只有少量的方法可以安全调用。比如,Index 方法可以在 Slice、Arrar、String 类型的值上安全调用,但对于其他类型则会崩溃。

结构体
NumField 方法可以报告结构中的字段数,Field(i) 会返回第 i 个字段,返回的字段类型为 reflect.Value。字段列表包括了从匿名字段中做了类型提升的字段。
v.Field(i) 是第i个字段的值,v.Type().Field(i) 就是第i个字段的名称,然后再 .name 就是名称的字符串类型。

map
MapKeys 方法返回一个元素类型为 reflect.Value 的 slice,每个元素都是一个 map 的 key。与平常遍历 map 的结果类似,顺序是不固定的。MapIndex(key) 返回 key 对应的值。这里还是忽略了一些情形,map 的 key 也可能是超出 formatAtom 能处理的合法类型,比如数组、结构体、接口都可以是合法的key。这还需要再修改一点代码,这里就没有做。

指针
Elem 方法返回指针指向的变量,同样也是以 reflect.Value 类型返回。这个方法在指针是 nil 时也能正确处理,但返回的结果属于 Invalid 类型,所以用了 IsNil 来显式检测空指针,方便输出一条合适的消息。为了避免歧义,在路径前加了 * 外边再套一层圆括号。

接口
再次使用 IsNil 来判断接口是否为空。然后用 v.Elem() 获取接口的动态值。再打印出对应的类型的值。

实际使用

现在 Display 已经完成了,马上就来实际使用一下。使用下面的这样一个复杂的结构体来进行验证:

package main

import "gopl/ch22/display"

type Movie struct {
    Title, Subtitle string
    Year            int
    Color           bool
    Actor           map[string]string
    Oscars          []string
    Sequel          *string
}

func main() {
    strangelove := Movie{
        Title:    "Dr. Strangelove",
        Subtitle: "How I Learned to Stop Worrying and Love the Bomb",
        Year:     1964,
        Color:    false,
        Actor: map[string]string{
            "Dr. Strangelove":            "Peter Sellers",
            "Grp. Capt. Lionel Mandrake": "Peter Sellers",
            "Pres. Merkin Muffley":       "Peter Sellers",
            "Gen. Buck Turgidson":        "George C. Scott",
            "Brig. Gen. Jack D. Ripper":  "Sterling Hayden",
            `Maj. T.J. "King" Kong`:      "Slim Pickens",
        },

        Oscars: []string{
            "Best Actor (Nomin.)",
            "Best Adapted Screenplay (Nomin.)",
            "Best Director (Nomin.)",
            "Best Picture (Nomin.)",
        },
    }

    display.Display("strangelove", strangelove)
}

执行后输出如下:

PS G:\Steed\Documents\Go\src\gopl\ch22\desplay_demo> go run main.go
Display strangelove (main.Movie):
strangelove.Title = "Dr. Strangelove"
strangelove.Subtitle = "How I Learned to Stop Worrying and Love the Bomb"
strangelove.Year = 1964
strangelove.Color = false
strangelove.Actor["Gen. Buck Turgidson"] = "George C. Scott"
strangelove.Actor["Brig. Gen. Jack D. Ripper"] = "Sterling Hayden"
strangelove.Actor["Maj. T.J. \"King\" Kong"] = "Slim Pickens"
strangelove.Actor["Dr. Strangelove"] = "Peter Sellers"
strangelove.Actor["Grp. Capt. Lionel Mandrake"] = "Peter Sellers"
strangelove.Actor["Pres. Merkin Muffley"] = "Peter Sellers"
strangelove.Oscars[0] = "Best Actor (Nomin.)"
strangelove.Oscars[1] = "Best Adapted Screenplay (Nomin.)"
strangelove.Oscars[2] = "Best Director (Nomin.)"
strangelove.Oscars[3] = "Best Picture (Nomin.)"
strangelove.Sequel = nil
PS G:\Steed\Documents\Go\src\gopl\ch22\desplay_demo>

调用标准库的内部结构
还可以使用 Display 来显示标准库类型的内部结构,比如: *os.File:

display.Display("os.Stderr", os.Stderr)

注意,即使是非导出的字段在反射下也是可见的。
还可以把 Display 作用在 reflect.Value 上,并且观察它如何遍历 *os.File 的类型描述符的内部结构:

display.Display("rV", reflect.ValueOf(os.Stderr))

调用指针
这里注意如下两个例子的差异:

var i interface{} = 3

display.Display("i", i)
// 输出:
// Display i (int):
// i = 3

display.Display("&i", &i)
// 输出:
// Display &i (*interface {}):
// (*&i).type = int
// (*&i).value = 3

在第一个例子中,Display 调用 reflect.ValueOf(i),返回值的类型为 int。
在第二个例子中,Display 调用 reflect.ValueOf(&i),返回的类型为 Ptr,并且是一个指向i的指针。在 Display 的 Ptr 分支中,会调用 Elem 方法,返回一个代表变量 i 的 Value,其类型为 Interface。类似这种间接获得的 Value 可以代表任何值,包括这里的接口。这是 display 函数递归调用自己,输出接口的动态类型和动态值。

循环引用

在当前的这个实现中,Display 在对象图中存在循环引用时不会自行终止。比如出差一个首尾相连的链表:

// 一个指向自己的结构体
type Cycle struct{ Value int; Tail *Cycle }
var c Cycle
c = Cycle{42, &c}
display.Display("c", c)

执行后会输出一个持续增长的展开式:

Display c (main.Cycle):
c.Value = 42
(*c.Tail).Value = 42
(*(*c.Tail).Tail).Value = 42
(*(*(*c.Tail).Tail).Tail).Value = 42
(*(*(*(*c.Tail).Tail).Tail).Tail).Value = 42

很多 Go 程序都会包含一些循环引用的数据。让 Display 支持这类成环的数据结构需要些技巧,需要额外记录迄今访问的路径,相应会带来成本。
一个通用的解决方案需要 unsafe 语言特性,在之后的 unsafe 包的示例中,会有对循环引用的处理。

还有一个相对比较容易实现的思路,限制递归的层数。这个不是那么通用,也不是很完美。但是不需要借助 unsafe 就可以实现。

循环引用在 fmt.Sprint 中不构成一个大问题,因为它很少尝试输出整个结构体。比如,当遇到一个指针时,就只简单地输出指针的数字值,这样就不是引用了。但如果遇到一个 slice 或 map 包含自身,它还是会卡住,只是不值得为了这种罕见的案例而去承担处理循环引用的成本。

示例:编码 S表达式

Display 现在可以作为一个显示结构化数据的调试工具,只要再稍加修改,就可以用它来对任意 Go 对象进行编码或编排,使之成为适用于进程间通信的消息。
Go 的标准库已经支持了各种格式,包括:JSON、XML、ASN.1。另外还有一种广泛使用的格式是 Lisp 语言中的 S表达式。与其他格式不同的是 S表达式还没被 Go 标准库支持,主要是因为它没有一个公认的标准规范。
接下来就要定义一个包用于将任意的 Go 对象编码为 S表达式,它需要支持以下的结构:

42          integer
"hello"     string (带有Go风格的引号)
foo         symbol (未用引号括起来的名字)
(1 2 3)     list   (括号包起来的0个或多个元素)

布尔值一般用符号 t 表示真,用空列表 () 或者符号 nil 表示假,但为了简化,这里的实现直接忽略了布尔值。通道和函数也被忽略了,因为它们的状态对于反射来说是不透明的。这里的实现还忽略了实数、复数和接口。(部分实现可以后续进行添加完善。)

编码方式

将 Go 语言的类型编码为S表达式的方法如下:

  • 整数和字符串以显而易见的方式编码
  • 空值编码为nil符号
  • 数组和slice被编码为列表
  • 结构体编码为一个字段绑定(field binding)的列表,每个字段绑定都是一个包含两个元素的列表。
  • map也编码为键值对的列表。按照传统,S表达式使用形式为 (key . value) 的单个结构单元(cons cell)来表示key/value对。但是为了简化解码过程,示例的实现中是没有加 "." 的。

编码器实现

编码用单个递归调用函数 encode 来实现。它的结构上域上一节的 Display 在本质上是一致的:

package sexpr

import (
    "bytes"
    "fmt"
    "reflect"
)

func encode(buf *bytes.Buffer, v reflect.Value) error {
    switch v.Kind() {
    case reflect.Invalid:
        buf.WriteString("nil")
    case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
        fmt.Fprintf(buf, "%d", v.Int())
    case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
        fmt.Fprintf(buf, "%d", v.Uint())
    case reflect.String:
        fmt.Fprintf(buf, "%q", v.String())
    case reflect.Ptr:
        return encode(buf, v.Elem())
    case reflect.Array, reflect.Slice: // (value ...)
        buf.WriteByte('(')
        for i := 0; i < v.Len(); i++ {
            if i > 0 {
                buf.WriteByte(' ')
            }
            if err := encode(buf, v.Index(i)); err != nil {
                return err
            }
        }
        buf.WriteByte(')')
    case reflect.Struct: // ((name value) ...)
        buf.WriteByte('(')
        for i := 0; i < v.NumField(); i++ {
            if i > 0 {
                buf.WriteByte(' ')
            }
            fmt.Fprintf(buf, "(%s ", v.Type().Field(i).Name)
            if err := encode(buf, v.Field(i)); err != nil {
                return err
            }
            buf.WriteByte(')')
        }
        buf.WriteByte(')')
    case reflect.Map: // ((key value) ...)
        buf.WriteByte('(')
        for i, key := range v.MapKeys() {
            if i > 0 {
                buf.WriteByte(' ')
            }
            buf.WriteByte('(')
            if err := encode(buf, key); err != nil {
                return err
            }
            buf.WriteByte(' ')
            if err := encode(buf, v.MapIndex(key)); err != nil {
                return err
            }
            buf.WriteByte(')')
        }
        buf.WriteByte(')')
    default: // float, complex, bool, chan, func, interface
        return fmt.Errorf("unsupported type: %s", v.Type())
    }
    return nil
}

// Marshal 把 Go 的值编码为 S 表达式的形式
func Marshal(v interface{}) ([]byte, error) {
    buf := new(bytes.Buffer)
    if err := encode(buf, reflect.ValueOf(v)); err != nil {
        return nil, err
    }
    return buf.Bytes(), nil
}

Marshal 函数把上面的编码器封装成一个 API,它类似于其他 encoding/... 包里的 API。
继续用上一节验证 Display 的结构体来应用到这里:

package main

import (
    "fmt"
    "gopl/ch22/sexpr"
    "os"
)

type Movie struct {
    Title, Subtitle string
    Year            int
    // Color           bool
    Actor           map[string]string
    Oscars          []string
    Sequel          *string
}

func main() {
    strangelove := Movie{
        Title:    "Dr. Strangelove",
        Subtitle: "How I Learned to Stop Worrying and Love the Bomb",
        Year:     1964,
        // Color:    false,
        Actor: map[string]string{
            "Dr. Strangelove":            "Peter Sellers",
            "Grp. Capt. Lionel Mandrake": "Peter Sellers",
            "Pres. Merkin Muffley":       "Peter Sellers",
            "Gen. Buck Turgidson":        "George C. Scott",
            "Brig. Gen. Jack D. Ripper":  "Sterling Hayden",
            `Maj. T.J. "King" Kong`:      "Slim Pickens",
        },

        Oscars: []string{
            "Best Actor (Nomin.)",
            "Best Adapted Screenplay (Nomin.)",
            "Best Director (Nomin.)",
            "Best Picture (Nomin.)",
        },
    }

    b, err := sexpr.Marshal(strangelove)
    if err != nil {
        fmt.Fprintf(os.Stderr, "sexpr.Marshal err: %v", err)
    }
    fmt.Println(string(b))
}

由于现在不支持布尔值,所以会返回错误:

PS H:\Go\src\gopl\ch22\sexpr_demo> go run main.go
sexpr.Marshal err: unsupported type: bool[]

去掉结构体和数据中的Color字段后就正常了:

PS H:\Go\src\gopl\ch22\sexpr_demo> go run main.go
((Title "Dr. Strangelove") (Subtitle "How I Learned to Stop Worrying and Love the Bomb") (Year 1964) (Actor (("Dr. Strangelove" "Peter Sellers") ("Grp. Capt. Lionel Mandrake" "Peter Sellers") ("Pres. Merkin Muffley" "Peter Sellers") ("Gen. Buck Turgidson" "George C. Scott") ("Brig. Gen. Jack D. Ripper" "Sterling Hayden") ("Maj. T.J. \"King\" Kong" "Slim Pickens"))) (Oscars ("Best Actor (Nomin.)" "Best Adapted Screenplay (Nomin.)" "Best Director (Nomin.)" "Best Picture (Nomin.)")) (Sequel nil))
PS H:\Go\src\gopl\ch22\sexpr_demo>

输出的内容非常紧凑,不适合阅读,不过作为格式化的编码已经实现了。如果要输出一个带缩进和换行的美化的格式,要重新实现一个 encode 函数。
与 fmt.Print、json.Marshal、Display 这些一样,sexpr.Marshal 在遇到循环引用的数据时也会无限循环。

接下来还可以继续实现一个解碼器。不过在那之前,还要先了解一下如何用反射来更新程序中的变量。都在下一篇里。


文章标题:gopl反射1
URL网址:http://cdkjz.cn/article/jodjes.html
多年建站经验

多一份参考,总有益处

联系快上网,免费获得专属《策划方案》及报价

咨询相关问题或预约面谈,可以通过以下方式与我们联系

大客户专线   成都:13518219792   座机:028-86922220