三大反射定律

2024-01-11 17:32:12 浏览数 (3)

简介

计算机中的反射是指程序通过类型等手段检查其自身结构的能力,它是元编程的一种形式,同时也是一种很容易引起混淆的概念。

在本文中,我们将阐述Go语言中的反射是如何工作的。每种语言的反射模型都是不同的(尽管也有许多语言不支持反射),但本文是关于Go的,所以在之后提到的“反射”就是单指“Go中的反射”。

2022年新增:这篇博客文章是在2011年编写的,早于Go语言中的参数化多态(即泛型)的引入。尽管由于语言中的这一发展,文章中的内容并没有变得不正确,但在一些地方进行了调整,以避免让熟悉现代Go的人感到困惑。

类型与接口

因为反射是建立在类型系统之上的,所以我们先回顾下Go中的类型。

Go是静态类型。每个变量都是有一个静态的类型,这意味着在编译时变量的类型是已知的、确定的:intfloat32*MyType[]byte或者其它的。以下面为例:

代码语言:go复制
type MyInt int 

var i int
var j MyInt

其中i的类型为intj的类型为MyInt。变量ij都拥有明确的类型,尽管它们拥有相同的底层类型,但在不经转换的情况下,二者并不等同。

Go中一种重要的类型是接口类型,它表示一组固定的方法集合。(在讨论反射时,我们可以忽略将接口定义用作多态代码中的约束。)只要实现了接口的方法,那接口变量可以存储任何具体(非接口)值。一个广为人知的例子是io.Readerio.Writer,来自io包的ReaderWriter类型:

代码语言:go复制
// Reader is the interface that wraps the basic Read method.
type Reader interface {
    Read(p []byte) (n int, err error)
}

// Writer is the interface that wraps the basic Write method.
type Writer interface {
    Write(p []byte) (n int, err error)
}

任何实现了具有这个签名的Read(或Write)方法的类型都被认为实现了io.Reader(或io.Writer)。在本讨论中,这意味着io.Reader类型的变量可以保存任何具有Read方法的值:

代码语言:go复制
var r io.Reader
r = os.Stdin
r = bufio.NewReader(r)
r = new(bytes.Buffer)
// and so on

需要明确的是,无论r可能包含什么具体的值,r的类型始终是io.Reader:Go是静态类型的,r的静态类型是io.Reader

空接口是一个非常重要的接口示例:

代码语言:go复制
interface{}

或者它的别名:

代码语言:go复制
any

它表示空方法集。因为每一个值都有零个或多个方法,所以它适用于任何值。

有人说Go的接口是动态类型的,这是错误的。Go的接口是静态类型:接口类型的变量始终具有相同的静态类型,即使在运行时接口变量存储的值可能会更改类型,但这个值始终满足接口。

我们需要明确的是,反射和接口密切相关。

接口定义

Russ Cox已经发布过一篇Go语言接口变量定义的文章。在这里没必要重复整个故事,但是简单总结一下还是可以的。

接口类型的变量是一个键值对:赋值给变量的具体值和该值的类型描述。更准确地说,值是实现接口的底层具体数据,类型则是该数据项的完整类型。例如:

代码语言:go复制
var r io.Reader
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
    return nil, err
}
r = tty

概况的说,r包含一个变量(tty)、类型(*os.File)。需要注意的是,类型*os.File实现了除Read之外的其他方法;即使接口值仅提供对Read方法的访问,其内部的值仍然携带有关该值的所有类型信息。这就是为什么我们可以做到下面的原因:

代码语言:go复制
var w io.Writer
w = r.(io.Writer)

这是一个断言:它断言r中的项也实现了io.Writer,因此我们可以将其分配给w。在赋值之后,w将包含对 (tty,*os.File) 这对的引用。这与r中保存的是相同的一对。接口的静态类型确定可以使用接口变量调用哪些方法,即使内部的具体值具有更大的方法集。

继续,我们这样:

代码语言:go复制
var empty interface{}
empty = w

此时,我们的空接口变量empty也包含(tty,*os.File)。这很方便:空接口可以保存任何值,并包含我们可能需要的有关该值的所有信息。

(在这里我们不需要类型断言,因为我们知道w满足空接口。在我们将值从Reader移动到Writer的示例中,我们需要明确并使用类型断言,因为Writer的方法不是Reader的子集。)

一个重要的细节是接口变量中的对总是具有 (value, concrete type) 的形式,而不能具有 (value, interface type) 的形式。接口不保存接口值。

现在我们来介绍反射。

第一条反射定律:由interface变量反射成反射对象

在基本层面上,反射只是一种检查存储在接口变量中的类型和值对的机制。为了入门,我们需要了解reflect 包中的两种类型:Type 和 Value。这两种类型提供对接口变量内容的访问,并且两个简单的函数,称为reflect.TypeOfreflect.ValueOf,从接口值中检索reflect.Typereflect.Value部分。 (此外,从reflect.Value很容易转到相应的reflect.Type,但让我们现在将ValueType的概念分开。)

我们先来看看TypeOf

代码语言:go复制
package main

import (
    "fmt"
    "reflect"
)

func main() {
    var x float64 = 3.4
    fmt.Println("type:", reflect.TypeOf(x))
}

输出:

代码语言:bash复制
type: float64

你可能想知道这里的接口在哪里,因为代码看起来好像将float64类型的变量x传递给了reflect.TypeOf,而不是接口。但实际上接口是存在的;正如godoc 报告的那样,reflect.TypeOf的签名包括一个空接口:

代码语言:go复制
// TypeOf returns the reflection Type of the value in the interface{}.
func TypeOf(i interface{}) Type

当我们调用reflect.TypeOf(x)时,首先将x存储在一个空接口中,然后将该空接口作为参数传递;reflect.TypeOf解包这个空接口以恢复类型信息。

reflect.ValueOf函数当然是用于恢复值的(从现在开始,我们将省略样板代码,重点关注可执行代码):

代码语言:go复制
var x float64 = 3.4
fmt.Println("value:", reflect.ValueOf(x).String())

结果:

代码语言:bash复制
value: <float64 Value>

(我们显式调用String方法,因为默认情况下fmt包会深入到reflect.Value中显示其中的具体值。String方法不会这样做。)

reflect.Typereflect.Value都有许多方法,让我们能够检查和操作它们。一个重要的例子是Value具有Type方法,该方法返回reflect.Value的类型。另一个例子是TypeValue都有一个Kind方法,该方法返回一个常量,指示存储的项目的类型:UintFloat64Slice等等。此外,Value上的类似IntFloat的方法允许我们获取存储在其中的值(作为int64float64):

代码语言:go复制
var x float64 = 3.4
v := reflect.ValueOf(x)
fmt.Println("type:", v.Type())
fmt.Println("kind is float64:", v.Kind() == reflect.Float64)
fmt.Println("value:", v.Float())

结果:

代码语言:bash复制
type: float64
kind is float64: true
value: 3.4

还有一些方法,如 SetIntSetFloat,但要使用它们,我们需要理解设置的可能性,这是下面讨论的反射的第三个定律的主题。

反射库有一些值得注意的特性。首先,为了保持 API 的简单性,Value 的“getter”和“setter”方法操作可以容纳值的最大类型:例如,对于所有有符号整数,Int 方法返回一个 int64,而 SetInt 方法接受一个 int64;可能需要转换为实际涉及的类型:

代码语言:go复制
var x uint8 = 'x'
v := reflect.ValueOf(x)
fmt.Println("type:", v.Type())                            // uint8.
fmt.Println("kind is uint8: ", v.Kind() == reflect.Uint8) // true.
x = uint8(v.Uint())                                       // v.Uint returns a uint64.

第二个属性,反射对象的 Kind 描述底层类型,而不是静态类型。如果反射对象包含用户定义的整数类型的值,如下所示:

代码语言:go复制
type MyInt int
var x MyInt = 7
v := reflect.ValueOf(x)

换言之,vKind仍然是reflect.Int,即使x的静态类型是MyInt而不是int。换句话说,Kind无法区分intMyInt,尽管Type可以。

第二条反射定律:由反射对象反射成interfac变量

就像物理反射一样,Go中的反射也有自己的逆过程。

在给定reflect.Value的情况下,我们可以使用Interface方法恢复一个接口值;实际上,该方法将类型和值信息重新打包成一个接口表示,并返回结果:

代码语言:go复制
// Interface returns v's value as an interface{}.
func (v Value) Interface() interface{}

因此我们可以说:

代码语言:go复制
y := v.Interface().(float64) // y will have type float64.
fmt.Println(y)

打印由反射对象v表示的float64值。

然而,我们甚至可以做得更好。fmt.Printlnfmt.Printf等函数的参数都作为空接口值传递,然后由fmt包在内部解包,就像我们在之前的示例中所做的那样。因此,要正确打印 reflect.Value的内容,只需将Interface方法的结果传递给格式化打印例程即可:

代码语言:go复制
fmt.Println(v.Interface())

(自从这篇文章首次写作以来,fmt包进行了更改,以便自动解包像这样的reflect.Value,因此我们可以简单地写成

代码语言:go复制
fmt.Println(v)

来获得相同的结果,但为了清晰起见,我们将在这里保留.Interface()调用。)

因为我们的值是float64,所以我们也可进行浮点型格式输出:

代码语言:go复制
fmt.Printf("value is %7.1en", v.Interface())

结果:

代码语言:bash复制
3.4e 00

再次强调,无需对v.Interface()的结果进行类型断言为float64;空接口值内部包含了具体值的类型信息,Printf将恢复它。

简而言之,Interface方法是ValueOf函数的反函数,不过其结果总是静态类型为interface{}

总结一下:反射从接口值到反射对象,再到接口值。

第三条反射定律:要修改反射对象,其值必须可被设置

第三定律是最微妙和令人困惑的,但如果我们从第一原则开始理解,就会变得容易理解。

以下是一些代码,它虽然无法工作,但值得研究。

代码语言:go复制
var x float64 = 3.4
v := reflect.ValueOf(x)
v.SetFloat(7.1) // Error: will panic.

如果执行上面的代码,将会引发一个含糊不清的错误信息:

代码语言:bash复制
panic: reflect.Value.SetFloat using unaddressable value

问题不在于值7.1是不可寻址的,而是在于v不可设置。Settability(可设置性)是反射Value的属性,并非所有的反射Value都具有可设置性。

ValueCanSet方法报告了一个Value的可设置性;在我们的情况下,

代码语言:go复制
var x float64 = 3.4
v := reflect.ValueOf(x)
fmt.Println("settability of v:", v.CanSet())

结果:

代码语言:bash复制
settability of v: false

在不可设置的值上调用Set方法是一个错误。但是什么是可设置性呢?

可设置性有点像可寻址性,但更加严格。这是一个反映对象是否能够修改创建反射对象时使用的实际存储的属性。当我们说:

代码语言:go复制
var x float64 = 3.4
v := reflect.ValueOf(x)

我们将x的副本传递给reflect.ValueOf,因此作为reflect.ValueOf参数创建的接口值是x的副本,而不是x本身。因此,如果语句:

代码语言:go复制
v.SetFloat(7.1)

允许此类操作成功执行的话,它不会更新x,即使v看起来是从x创建的。相反,它会更新存储在反射值内部的x的副本,而x本身则不受影响。这将是令人困惑和无用的,因此它是非法的,可设置性是用于避免此问题的属性。

如果这看起来很奇怪,实际上并不是。这实际上是一个在不同形式中熟悉的情况。想象一下将x传递给函数:

代码语言:go复制
f(x)

我们不希望f能够修改x,因为我们传递的是x值的副本,而不是x本身。如果我们希望f直接修改x,我们必须将x的地址传递给我们的函数(即,x的指针):

代码语言:go复制
f(&x)

这很简单和熟悉,反射也是这样工作的。如果我们想通过反射修改x,我们必须给反射库一个指向我们要修改的值的指针。

让我们来做这件事。首先,我们像往常一样初始化x,然后创建一个指向它的反射值,称为p

代码语言:go复制
var x float64 = 3.4
p := reflect.ValueOf(&x) // 注意:取 x 的地址。
fmt.Println("type of p:", p.Type())
fmt.Println("settability of p:", p.CanSet())

到目前为止的输出是

代码语言:bash复制
type of p: *float64
settability of p: false

反射对象p不能被设置,但我们想要设置的不是p,而是(实际上*p。为了获取p指向的内容,我们调用ValueElem方法,通过指针进行间接引用,并将结果保存在一个名为v的反射值中:

代码语言:go复制
v := p.Elem()
fmt.Println("settability of v:", v.CanSet())

现在,v是一个可设置的反射对象,如输出所示,

代码语言:bash复制
settability of v: true

由于它代表的是x,我们最终可以使用v.SetFloat修改x的值:

代码语言:go复制
v.SetFloat(7.1)
fmt.Println(v.Interface())
fmt.Println(x)

预期的输出是

代码语言:bash复制
7.1
7.1

反射可能难以理解,但它确实执行的是语言本身所做的操作,尽管是通过可能掩盖正在进行的操作的反射类型和值。只需记住,反射值需要某个东西的地址,以便修改它们所代表的内容。

结构体

在前面的示例中,v本身不是指针,只是从指针派生而来。当使用反射来修改结构的字段时,这种情况经常发生。只要我们有结构的地址,我们就可以修改它的字段。

下面是一个简单的例子,分析了一个结构值t。我们使用结构的地址创建了反射对象,因为我们将来会修改它。然后,我们将typeOfT设置为其类型,并使用直接的方法调用(详见reflect 包的详细信息)迭代字段。请注意,我们从结构类型中提取字段的名称,但字段本身是普通的reflect.Value对象。

代码语言:go复制
type T struct {
    A int
    B string
}
t := T{23, "skidoo"}
s := reflect.ValueOf(&t).Elem()
typeOfT := s.Type()
for i := 0; i < s.NumField(); i   {
    f := s.Field(i)
    fmt.Printf("%d: %s %s = %vn", i,
        typeOfT.Field(i).Name, f.Type(), f.Interface())
}

该程序的输出是

代码语言:bash复制
0: A int = 23
1: B string = skidoo

这里还有一个关于可设置性的点:T的字段名称是大写的(已导出),因为只有结构的已导出字段是可设置的。

由于s包含一个可设置的反射对象,我们可以修改结构的字段。

代码语言:go复制
s.Field(0).SetInt(77)
s.Field(1).SetString("Sunset Strip")
fmt.Println("t is now", t)

这是结果:

代码语言:bash复制
t is now {77 Sunset Strip}

如果我们修改程序,使s是由t而不是&t创建的,那么对SetIntSetString的调用将失败,因为t的字段将不可设置。

最后

这里我们再重复下反射的定律:

  • 由interface变量反射成反射对象
  • 由反射对象反射成interfac变量
  • 要修改反射对象,其值必须可被设置

一旦理解了 Go 中的这些反射法则,反射就变得更容易使用,尽管它仍然很微妙。它是一个强大的工具,应该谨慎使用,除非绝对必要。

还有很多关于反射的内容我们还没有涉及到,比如在通道上发送和接收数据,内存分配,使用切片和映射,调用方法和函数等,但这篇文章已经足够长了。我们将在以后的文章中涵盖其中的一些主题。


声明:本作品采用署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)进行许可,使用时请注明出处。


0 人点赞