深入Go语言2——反射与unsafe
又到了一周一次的总结篇了,本周学到的知识可以用海量来表示了,毕竟这也是近一个月以来既没有考试也没有面试的一周,所以就一直在做项目、改简历。当然,较大模块的内容还是会在整理之后单独来记录。以后的事以后再聊,还是先来看看本周都学了什么吧。
反射
在计算机科学中,反射(英语:reflection)是指计算机程序在运行时(runtime)可以访问、检测和修改它本身状态或行为的一种能力。用比喻来说,反射就是程序在运行的时候能够“观察”并且修改自己的行为。(来自wikipedia)
反射是程序审查自身结构的能力,并能对程序做出一定的修改。
对于人来说,审查自身或过往事情的能力,叫 “反思” 或 “反省”。
Go 中的反射包:reflect介绍
同 Java 语言一样,Go 语言也有运行时反射,这为我们提供了一种可以在运行时操作任意类型对象的能力。比如查看一个接口变量的具体类型、看看一个结构体又多少字段、修改某个字段的值等。
Go语言是静态编译类语言,比如在定义一个变量的时候,已经知道了它是什么类型,那么为什么还需要反射呢?这是因为有些事情只有在运行时才知道。比如你定义了一个函数,它有一个 interface{}类型的参数,这也就意味着调用者可以传递任何类型的参数给这个函数。在这种情况下,如果你想知道调用者传递的是什么类型的参数,就需要用到反射。如果你想知道一个结构体有哪些字段和方法,也需要反射。
Go 中的反射是建立在类型系统之上,它与空接口 interface{} 密切相关。
每个 interface{} 类型的变量包含一对值 (type,value),type 表示变量的类型信息,value 表示变量的值信息。
所以 nil != nil
- 获取 2 种类型信息的方法:
reflect.TypeOf()
获取类型信息,返回 Type 类型;
reflect.ValueOf()
获取数据信息,返回 Value 类型。
- 2 个方法部分源码:
1 | // ValueOf用来获取输入参数接口中的数据的值,如果接口为空则返回0 |
通过 reflect.TypeOf()
和 reflect.ValueOf()
,经过中间变量 interface{}
,把一个普通的变量转换为反射包中类型对象: Type 和 Value 2 个类型,然后再用 reflect 包中的方法对它们进行各种操作。
步骤:Go 变量 -> interface{} -> 反射包的反射类型对象
反射包 reflect 中所有方法基本都是围绕 Type 和 Value 这 2 个类型设计和操作。
reflect 简单使用
从上面可以看出 TypeOf() 返回的是一个反射包中的 Type 类型,ValueOf() 返回的是一个反射包中的 Value 类型。
float 反射实例
1 | package main |
上面的例子,reflect 包中 reflect.TypeOf()
返回 Type 和 reflect.ValueOf()
返回 Value 类型 都有一个 Kind()
方法,Kind()
返回一个底层的数据类型,如 Unit,Float64,Slice, Int 等。
reflect.ValueOf() 返回的 Value 类型:
- 它有一个 Type() 方法,返回的是 reflect.Value 的 Type
- 它有获取 Value 类型值的方法
- 如果我们知道是
float
类型,所以直接用Float()
方法。 - 如果不知道具体类型呢?由上面例子可知用
Interface()
方法,然后在进行类型断言v.Interface().(float64)
来判断获取值
- 如果我们知道是
v.Kind() 和 v.Type() 区别:
- 在 Go 中,可以用 type 关键字定义自定义类型,
Kind()
方法返回底层类型。 - 比如还有结构体,指针等类型用 type 定义的,那么
Kind()
方法就可以获取这些类型的底层类型。
struct 反射实例
1 | package main |
获取 struct 信息的一些方法:
NumField()
获取结构体字段数量Field(i)
可以通过 i 字段索引来获取结构体字段信息,比如 Field(i).Name 获取字段名FieldByName(name)
通过 name 获取字段信息
三大定理
在 Go 官方博客文章 laws-of-reflection 中,叙述了反射的 3 定律:
- 第一定律:从
interface{}
变量可以反射出反射对象; - 第二定律:从反射对象可以获取
interface{}
变量; - 第三定律:要修改反射对象,其值必须可设置;
第一定律
反射的第一定律是我们能将 Go 语言的 interface{}
变量转换成反射对象。为什么是从 interface{}
变量到反射对象?
当我们执行
reflect.ValueOf(1)
时,虽然看起来是获取了基本类型int
对应的反射类型,但是由于reflect.TypeOf
、reflect.ValueOf
两个方法的入参都是interface{}
类型,所以在方法执行的过程中发生了类型转换。因为Go 语言的函数调用都是值传递的,所以变量会在函数调用时进行类型转换。基本类型
int
会转换成interface{}
类型。
第二定律
反射的第二定律是我们可以从反射对象可以获取 interface{}
变量。既然能够将接口类型的变量转换成反射对象,那么一定需要其他方法将反射对象还原成接口类型的变量,reflect
中的 reflect.Value.Interface
就能完成这项工作:
不过调用 reflect.Value.Interface
方法只能获得 interface{}
类型的变量,如果想要将其还原成最原始的状态还需要经过如下所示的显式类型转换:
1 | v := reflect.ValueOf(1) |
从反射对象到接口值的过程是从接口值到反射对象的镜面过程,两个过程都需要经历两次转换:
- 从接口值到反射对象:
- 从基本类型到接口类型的类型转换;
- 从接口类型到反射对象的转换;
- 从反射对象到接口值:
- 反射对象转换成接口类型;
- 通过显式类型转换变成原始类型;
第三定律
Go 语言反射的最后一条法则是与值是否可以被更改有关,如果我们想要更新一个 reflect.Value
,那么它持有的值一定是可以被更新的。
看一个例子:
1 | Copyvar x float64 = 3.4 |
这个问题并不是 7.1 不可寻址,而是这个 x 不可设置。
可设置性是反射值的一个属性,并不是所有的反射值有这个属性。
Value 的 CanSet
方法可以获取值是否可设置,如:
1 | Copyvar x float64 = 3.4 |
output:
1 | Copysettability of v: false |
为什么有可设置性?
因为 reflect.ValueOf(x) 这个 x 传递的是一个原数据的副本,上面代码
v.SetFloat(7.1)
如果设置成功,那么更新的是副本值,原始值 x 并没有更新。这就会造成原值和新值的混乱,可设置属性就是避免这个问题。
那怎么办?
传递的是一个副本,而不是值本身。如果希望能直接修改 x,那么必须把 x 的地址传递给函数,即指向 x 的指针:
1 | Copyvar x float64 = 3.4 |
output:
1 | Copytype of p: |
还是 false
,为什么?
反射对象 p 不可设置,它并不是我们要设置的 p,它实际上是 p。为了得到 p 所指向的东西,我们需要调用 Value 的 Elem
方法,通过指针进行简介*寻址,然后将结果保存在一个名为 v 的反射 Value 中:
1 | Copyv := p.Elem() |
现在 v 是一个可设置的反射对象,输出:
1 | Copysettability of v: true |
然后我们可以用 v.SetFloat()
设置值:
1 | Copyv.SetFloat(7.1) |
output:
1 | Copy7.1 |
说明:请记住,修改反射值需要值的地址,以便修改他们的真正值。
优缺点
优点
- 可以根据条件灵活的调用函数。最大一个优点就是灵活。
比如函数参数的数据类型不确定,这时可以根据反射来判断数据类型,在调用适当的函数。
还有比如根据某些条件来调用哪个函数。
需要根据动态需要来调用函数,可以用反射。
使用反射的 2 个典型场景:1、操作数据库的 ORM 框架 ,2、依赖注入
缺点
- 用反射编写的代码比较难以阅读和理解
- 反射是在运行时才执行,所以编译期间比较难以发现错误
- 反射对性能的影响,比一般正常运行代码慢一到两个数量级。
不安全但高效的 unsafe
Go的设计者为了编写方便、提高效率且降低复杂度,将其设计成一门强类型的静态语言。强类型意味着一旦定义了,类型就不能改变;静态意味着在运行前就做了类型检查。同时出于安全考虑,Go语言是不允许两个指针类型进行转换的。
我们一般使用 *T
作为一个指针类型,表示一个指向类型 T
变量的指针。基于安全考虑,两个不同的指针类型不能相互转换,比如 int 不能转为 float64。
go官方是不推荐使用unsafe的操作因为它是不安全的,它绕过了golang的内存安全原则,容易使你的程序出现莫名其妙的问题,不利于程序的扩展与维护。但是在很多地方却是很实用。在一些go底层的包中unsafe包被很频繁的使用。
unsafe.Pointer
1 | package unsafe |
官方中定义了四个描述:
- 任何类型的指针都可以被转化为Pointer
- Pointer可以被转化为任何类型的指针
- uintptr可以被转化为Pointer
- Pointer可以被转化为uintptr
uintptr 指针类型
uintptr 也是一种指针类型,它足够大,可以表示任何指针。
1 | type uintptr uintptr |
既然已经有了 unsafe.Pointer
,为什么还要设计 uintptr
类型呢?
通常Pointer
不能参与指针运算,比如你要在某个指针地址上加上一个偏移量,Pointer
是不能做这个运算的,那么谁可以呢?这里要靠 uintptr
类型了,只有将 Pointer
类型先转换成 uintptr
类型,做完地址加减法运算后,再转换成 Pointer
类型,通过*操作达到取值、修改值的目的。
uintptr
是 Go 语言的内置类型,是能存储指针的整型, uintptr
的底层类型是int,它和 unsafe.Pointer
可相互转换。
uintptr
和 unsafe.Pointer
的区别就是:
unsafe.Pointer
只是单纯的通用指针类型,用于转换不同类型指针,它不可以参与指针运算;- 而
uintptr
是用于指针运算的,GC 不把uintptr
当指针,也就是说uintptr
无法持有对象,uintptr
类型的目标会被回收; unsafe.Pointer
可以和 普通指针 进行相互转换;unsafe.Pointer
可以和uintptr
进行相互转换。
slice 为何如此高效
slice
是 Go
语言十分重要的数据类型,它承载着很多使命,从语言层面来看是 Go
语言的内置数据类型,从数据结构来看是动态长度的顺序链表,由于 Go
不能直接操作内存(通过系统调用可以实现,但是语言本身并不支持),往往 slice
也可以用来帮助开发者申请大块内存实现缓冲、缓存等功能。
数组
在讲 slice的原理之前,我先来介绍一下数组。几乎所有的编程语言里都存在数组,Go也不例外。那么为什么 Go语言除了数组之外又设计了 slice 呢?要想解答这个问题,我们先来了解数组的局限性。
一个数组由两部分构成:数组的大小和数组内的元素类型。
1 | // 数组结构伪代码表示 |
一旦一个数组被声明,它的大小和内部元素就不能改变,你不能随意地向数组添加任意多个元素。这是数组的第一个限制。
既然数组的大小是固定的,如果需要使用数组存储大量的数据,就需要提前指定一个合适的大小,比如 100000,代码如下所示:
1 | a10:= [100000]string{"随便寻个地方"} |
这样虽然可以解决问题,但又带来了另外的问题,那就是内存占用。因为在Go语言中,函数间的传参是值传递的,数组作为参数在各个函数之间被传递的时候,同样的内容就会被一遍遍地复制,这就会造成大量的内存浪费,这是数组的第二个限制。
slice
在上面,我们已经了解到了数组的限制,为了解决这些限制,Go 语言创造了 slice,也就是切片。
切片是对数组的抽象和封装,它的底层是一个数组,存储所有的元素,但是它可以动态地添加元素,容量不足时还可以自动扩容,你完全可以把切片理解为动态数组。在Go语言中,除了长度固定的类型需要使用数组外,大多数情况下都是使用切片。
动态扩容
通过内置的 append
方法,可以向一个切片中追加任意多个元素,这就可以解决数组的第一个限制了。
当通过 append
追加元素时,如果切片的容量不够,append
函数会自动扩容。
append
自动扩容的原理是新创建一个底层数组,把原来切片内的元素拷贝到新数组中,然后再返回一个指向新数组的切片。
数据结构
1 | // runtime/slice.go |
底层数组是可以被多个 slice 同时指向的,因此对一个 slice 的元素进行操作是有可能影响到其他 slice 的。
切片的本质就是 sliceHeader
,又因为函数的参数是值传递,所以传递的是 SliceHteader
的副本、而不是底层数组的副本。这时候切片的优势就体现出来了,因为 SiceHteader
的副本内存占用非常少,即使是一个非常大的切片(底层数组有很多元素),也顶多占用24字节的内存,这就解决了大数组在传参时内存浪费的问题。
SliceHeader 的三个字段的类型分别是 uintptr、int 和 int,在64位的机器上,这三个字段最多也就是 int64 类型,一个int64 占8字节,三个int64 占24字节肉存。
高效的原因
如果从集合类型的角度考虑,数组、切片和 map 都是集合类型,因为它们都可以存放元素,但是数组和切片的取值和赋值操作要更高效,因为它们是连续的内存操作,通过索引就可以快速地找到元素存储的地址。
进一步对比,在数组和切片中,切片又更高效,因为它在赋值、函数传参的时候,并不会把所有的元素都复制一遍,而只是复制 SliceHleader
的三个字段就可以了,共用的还是同一个底层数组。
切片的高效还体现在 for tange
循环中,因为循环得到的临时变量也是个值拷贝,所以在遍历大的数组时,切片的效率更高。
总结
本周所学基础知识就是上面的三大块内容,额,了解 Go 语言的底层设计就会发现其设计的巧妙性,初学数组和切片时认为单独设计这两个明明差不多的东西有点多此一举。看了它的底层逻辑,有了解了其设计哲学才发现好像是有道理的。
其实可以回答一个面试官很爱问的问题——为什么要学习 Go 这门语言?
其实这个问题我已经不止一次地在博客中写了,最开始学习的原因是因为舍友说这门语言有着严格的语法和结构要求,所以每个人写出来的代码都没有太大的区别,所以出于规划自己的代码风格以及缩小自己与大佬的差距,我选择学习Go 语言。
在开始做 Go 语言项目时,发现这个语言要比 C++ 或者 Python 好用很多,简单的语法、高并发等特性吸引着我去继续做项目。
现在也已经做了不少关于 Go 语言开发的内容,代码量也有几万行了,也开始关注其设计哲学和底层代码,更是被其严谨性所折服。所有设计出来的东西好像都是必须要有的,既不会多出一些无关紧要的设计,也没有什么必不可少的功能。
不过 Go 语言确实不适合用来刷题,不仅仅是在设计输入输出的时候很麻烦,实现一些功能也是不方便。仅代表个人观点。
参考文献
- 反射:
- unsafe:
- slice: