阅读目录
一、类的定义和初始化和成员方法
类的定义和初始化
Go语言中没有“类”的概念,也不支持“类”的继承等面向对象的概念。Go语言中通过结构体的内嵌再配合接口比面向对象具有更高的扩展性和灵活性。比如要定义一个学生类,可以这么做:
1 2 3 4 5 6 |
|
类名为 Student
,并且包含了 id
、name
、male
、score
四个属性,Go 语言中也不支持构造函数、析构函数,取而代之地,可以通过定义形如 NewXXX
这样的全局函数(首字母大写)作为类的初始化函数:
1 2 3 |
|
在这个函数中,我们通过传入的属性字段对 Student
类进行初始化并返回一个指向该类的指针,除此之外,还可以初始化指定字段:
1 2 3 |
|
我们可以在 main()
函数中调用这个 NewStudent
函数对 Student
类进行初始化
1 2 3 |
|
定义成员方法
值方法
Go 语言不支持 class
这样的代码块,要为 Go 类定义成员方法,需要在 func
和方法名之间声明方法所属的类型(有的地方将其称之为接收者声明),以 Student
类为例,要为其定义获取 name
值的方法,可以这么做
1 2 3 |
|
这样一来,我们就可以在初始化 Student
类后,通过 GetName()
方法获取 name
值
1 2 |
|
我们通过在函数签名中增加接收者声明(s Student)的方式定义了函数所归属的类型,这个时候,函数就不再是普通的函数,而是类的成员方法了
指针方法
在类的成员方法中,可以通过声明的类型变量来访问类的属性和其他方法。GetName
是一个只读方法,如果我们要在外部通过 Student
类暴露的方法设置 name
值,可以这么做
1 2 3 |
|
这里的方法声明和前面 GetXXX
方法声明不太一样,Student
类型设置成了指针类型:
1 |
|
在 GetXXX
方法中,由于不需要对类的成员变量进行修改,所以不需要传入指针,而 SetXXX
方法需要在函数内部修改成员变量的值,并且该修改要作用到该函数作用域以外,所以需要传入指针类型
我们可以把接收者类型为指针的成员方法叫做指针方法,把接收者类型为非指针的成员方法叫做值方法,二者的区别在于值方法传入的结构体变量是值类型(类型本身为指针类型除外),因此传入函数内部的是外部传入结构体实例的值拷贝,修改不会作用到外部传入的结构体实例。
接下来,我们可以在 main
函数中初始化 Student
类之后,通过 SetName
方法修改 name
值,然后再通过 GetName
将其打印出来:
1 2 3 |
|
Go 版__str__ 方法实现
python默认调用类的__str__()方法以字符串格式打印类的实例,Go 语言也有类似的机制,只不过这个方法名是 String
,以上面这个 Student
类型为例,我们为其编写 String
方法如下:
1 2 3 4 5 6 7 8 9 10 |
|
二、通过组合实现类的继承和方法重写
要实现面向对象编程,就必须实现面向对象编程的三大特性:封装、继承和多态
封装
在上面已经详细介绍过:将函数定义为归属某个自定义类型,这就等同于实现了类的成员方法,如果这个自定义类型是基于结构体的,那么结构体的字段可以看做是类的属性
继承
Go 虽然没有直接提供继承相关的语法实现,但是我们通过组合的方式间接实现类似功能,所谓组合,就是将一个类型嵌入到另一个类型,从而构建新的类型结构。以一个 Animal
结构体类型作为示例
Animal
结构体类型,它有一个属性 Name
用于表示该动物的名称,以及三个成员方法,分别用来获取动物叫声、喜欢的食物和动物的名称
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
如果我们要定义一个继承自该类型的子类 Dog
,可以这么做:
1 2 3 |
|
在 Dog
结构体类型中,嵌入了 Animal
这个类型,这样一来,我们就可以在 Dog
实例上访问所有 Animal
类型包含的属性和方法
1 2 3 4 5 6 7 8 |
|
这就相当于通过组合实现了类与类之间的继承功能
多态
通过在子类中定义同名方法来覆盖父类方法的实现,在面向对象编程中这一术语叫做方法重写,比如在上述 Dog
类型中,我们可以重写 Call
方法和 FavorFood
方法的实现如下
1 2 3 4 5 6 7 |
|
我们再执行 main
函数时,直接在 Dog
实例上调用 Call
方法或 FavorFood
方法时,调用的就是 Dog
类中定义的方法而不是 Animal
中定义的方法,当然,你可以可以像这样继续调用父类 Animal
中的方法
1 2 3 4 |
|
多继承同名方法冲突处理
需要注意组合的不同类型之间包含同名方法,比如 Animal
和 Pet
都包含了 GetName
方法,如果子类 Dog
没有重写该方法,直接在 Dog
实例上调用的话会报错
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
|
执行上述代码会报错:
1 2 |
|
除非你显式指定调用哪个父类的方法:
1 |
|
继承指针类型的属性和方法
在 Go 语言中,你还可以以指针方式继承某个类型的属性和方法
1 2 3 4 |
|
这种情况下,除了传入 Animal
实例的时候要传入指针引用之外,其它调用无需修改
1 2 3 4 5 6 7 8 9 10 11 |
|
通过组合实现类之间的继承时,由于结构体实例本身是值类型,如果传入值字面量的话,实际上传入的是结构体实例的副本,对内存耗费更大,所以组合指针类型性能更好
为组合类型设置别名
前面的示例调用父类方法时都直接引用的是组合类型(父类)的类型字面量,其实,我们还可以像基本类型一样,为其设置别名,方便引用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
三、类属性和成员方法的可见性
类属性和成员方法可见性概述
所谓可见性,其实是一种访问控制策略,用于表示对应属性和方法是否可以在类以外的地方显式调用,
Java 和 PHP 都提供了三个关键字来修饰属性和方法的可见性,分别是 private
、protected
和 public
,分别表示只能在类的内部可见、在子类中可见(对 Java 而言在同一包内亦可见)、以及完全对外公开。
Go 语言不是典型的面向对象编程语言,并且语言本身的设计哲学也非常简单,惜字(关键字)如金,没有提供上面这三个关键字,也没有提供以类为维度管理属性和方法可见性的机制,但是 Go 语言确实有可见性的概念,只不过这个可见性是基于包这个维度的。
Go 语言的类属性和成员方法可见性设置
在 Go 语言中,无论是变量、函数还是类属性和成员方法,它们的可见性都是以包为维度的,而不是类似传统面向编程那样,类属性和成员方法的可见性封装在所属的类中,然后通过 private
、protected
和 public
这些关键字来修饰其可见性
Go 语言没有提供这些关键字,不管是变量、函数,还是自定义类的属性和成员方法,它们的可见性都是根据其首字母的大小写来决定的,如果变量名、属性名、函数名或方法名首字母大写,就可以在包外直接访问这些变量、属性、函数和方法,否则只能在包内访问,因此 Go 语言类属性和成员方法的可见性都是包一级的,而不是类一级的。
根据上面介绍的包特性及可见性将上文编写的 Animal
、Pet
、Dog
类放到同一级目录下的 animal
包中,然后在 03-compose.go
文件中调用这两个类。
首先,我们在当前目录下创建一个 animal
子目录,然后在这个子目录下创建源文件 animal.go
用于存放 Animal
类代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
然后,我们在同一级目录下创建 pet.go
用于保存 Pet
类源码:
1 2 3 4 5 6 7 8 9 |
|
接下来,我们在 animal
目录下新建 dog.go
用于存放继承了 Animal
和 Pet
类的 Dog
类源码:package animal
1 2 3 4 5 6 7 8 9 10 11 12 |
|
这里,由于 Dog
类需要在 animal
包以外的地方进行初始化,所以需要将其属性名首字母都都替换成大写字母。
最后,我们 03-compose.go
文件中导入 animal
包,然后调用该包下的 Animal
、Pet
、Dog
类如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
这里,注意到我们在通过 import
导入 animal
包时,使用了 .
作为前缀,表示在接下来调用该包中的变量、函数、类属性和成员方法时,无需使用包名前缀 animal.
引用,以免和 main
函数中的 animal
变量名冲突
通过私有化属性提升代码的安全性
如果你觉得直接暴露这三个类的所有属性可以被任意修改,不够安全,还可以通过定义构造函数来封装它们的初始化过程,然后把属性名首字母小写进行私有化
animal.go package animal type Animal struct { name string } func NewAnimal(name string) Animal { return Animal{name: name} } func (a Animal) Call() string { return "动物的叫声..." } func (a Animal) FavorFood() string { return "爱吃的食物..." } func (a Animal) GetName() string { return a.name } pet.go package animal type Pet struct { name string } func NewPet(name string) Pet { return Pet{name: name} } func (p Pet) GetName() string { return p.name } dog.go package animal type Dog struct { animal *Animal pet Pet } func NewDog(animal *Animal, pet Pet) Dog { return Dog{animal: animal, pet: pet} } func (d Dog) FavorFood() string { return d.animal.FavorFood() + "骨头" } func (d Dog) Call() string { return d.animal.Call() + "汪汪汪" } func (d Dog) GetName() string { return d.pet.GetName() } func (d Dog) GetName() string { return d.pet.GetName() }
因为这些属性名首字母都变成小写了,对应属性变成私有的了,只能在 animal
包内可见。同理,如果 GetName
、Call
或者 FavorFood
任意一个方法首字母小写,那么这里调用也会报错,提示找不到该成员方法。
要完成这些类的初始化,现在需要调用它们的构造函数来实现:
package main import ( "fmt" . "go-tutorial/chapter04/animal" ) func main() { animal := NewAnimal("中华田园犬") pet := NewPet("宠物狗") dog := NewDog(&animal, pet) fmt.Println(dog.GetName()) fmt.Println(dog.Call()) fmt.Println(dog.FavorFood()) }
四、接口定义及实现
如果说 goroutine 和 channel 是支撑起 Go 语言并发模型的基石,那么接口就是 Go 语言整个类型系统的基石。Go 语言的接口不单单只是接口,下面就让我们一步步来探索 Go 语言的接口特性。
Go 语言的接口实现
在 Go 语言中,类对接口的实现和子类对父类的继承一样,并没有提供类似 implement
这种关键字显式声明该类实现了哪个接口,一个类只要实现了某个接口要求的所有方法,我们就说这个类实现了该接口。
例如,我们定义了一个 File
类,并实现了 Read()
、Write()
、Seek()
、Close()
四个方法:
type File struct { // ... } func (f *File) Read(buf []byte) (n int, err error) func (f *File) Write(buf []byte) (n int, err error) func (f *File) Seek(off int64, whence int) (pos int64, err error) func (f *File) Close() error
假设我们有如下接口(Go 语言通过关键字 interface
来声明接口,以示和结构体类型的区别,花括号内包含的是待实现的方法集合):
type IFile interface { Read(buf []byte) (n int, err error) Write(buf []byte) (n int, err error) Seek(off int64, whence int) (pos int64, err error) Close() error } type IReader interface { Read(buf []byte) (n int, err error) } type IWriter interface { Write(buf []byte) (n int, err error) } type ICloser interface { Close() error }
尽管 File
类并没有显式实现这些接口,甚至根本不知道这些接口的存在,但是我们说 File
类实现了这些接口,因为 File
类实现了上述所有接口声明的方法。当一个类的成员方法集合包含了某个接口声明的所有方法,换句话说,如果一个接口的方法集合是某个类成员方法集合的子集,我们就认为该类实现了这个接口。
因为类与接口的实现关系不是通过显式声明,而是系统根据两者的方法集合进行判断。这样做有两个好处
- 其一,Go 语言的标准库不需要绘制类库的继承/实现树图,在 Go 语言中,类的继承树并无意义,你只需要知道这个类实现了哪些方法,每个方法是干什么的就足够了。
- 其二,定义接口的时候,只需要关心自己应该提供哪些方法即可,不用再纠结接口需要拆得多细才合理,也不需要为了实现某个接口而引入接口所在的包,接口由使用方按需定义,不用事先设计,也不用考虑之前是否有其他模块定义过类似接口。
通过组合实现接口继承
Go 语言也支持类似的「接口继承」特性,其实现和类的继承一样,是通过组合来完成的。在 Go 语言中,我们可以这样通过接口组合来实现接口继承,就像类的组合一样:
type A interface { Foo() } type B interface { A Bar() } 然后我们定义一个类 T 实现接口 B: type T struct {} func (t T) Foo() { fmt.Println("call Foo function from interface A.") } func (t T) Bar() { fmt.Println("call Bar function from interface B.") }
不过,在 Go 语言中,又与传统的接口继承有些不同,因为接口实现不是强制的,是根据类实现的方法来动态判定的,比如我们上面的 T
类可以只实现 Foo
方法,也可以只实现 Bar
方法,也可以都不实现。如果只实现了 Foo
方法,则 T
实现了接口 A
;如果只实现了 Bar
方法,则既没有实现接口 A
也没有实现接口 B
,只有两个方法都实现了系统才会判定实现了接口 B
。
可以认为接口组合是匿名类型组合(没有显式为组合类型设置对应的属性名称)的一个特定场景,只不过接口只包含方法,而不包含任何属性。Go 语言底层很多包就是基于接口组合实现的,比如 io 里面的 Reader
、Writer
、ReadWriter
这些接口:
// 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) } // ReadWriter is the interface that groups the basic Read and Write methods. type ReadWriter interface { Reader Writer }
五、接口赋值
接口赋值概述
和其他编程语言一样,Go 接口不支持直接实例化,因为它只是一个契约而已,只能通过具体的类来实现接口声明的所有方法。不同之处在于,Go 接口支持赋值操作,从而快速实现接口与实现类的映射,与之相比,Java、PHP 要实现接口与实现类的映射,只能基于 IoC 容器通过依赖注入实现,要复杂的多。
接口赋值在 Go 语言中分为如下两种情况:
- 将实现接口的类实例赋值给接口;
- 将一个接口赋值给另一个接口。
下面我们通过代码实例逐个介绍对应的实现和注意事项。
将类实例赋值给接口
将类实例赋值给接口,这要求该实例对应的类实现了接口声明的所有方法,这个是自然,否则也就不能算作实现该接口了。
只包含值方法
以上面定义过的 Integer
类型为例进行演示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
按照 Go 语言的约定,Integer
类型实现了 Math
接口。然后我们可以这样将 Integer
类型的实例 a
直接赋值给 Math
接口类型的变量 m
1 2 3 |
|
对于值方法而言,进行接口赋值时传递 a
实例的指针引用也是可以的:
1 2 3 |
|
因为对于非指针方法,Go 底层会自动生成一个与之对应的指针成员方法:
1 2 3 4 5 6 7 |
|
包含指针方法
如果 Integer
类型中包含了归属于指针的实现方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
那么在做接口赋值时,就只能传递指针类型的变量了:
1 2 3 4 |
|
因为 Integer
类型不包含指针方法(参考前面介绍的值方法与指针方法区别),所以此时只有 *Integer
类型实现了 Math
接口,如果我们直接将 a
的值类型赋值给 m
,编译时会报错
1 2 |
|
如果 Integer
类中实现接口的成员方法都是值方法,则进行接口赋值时,传递类实例的值类型或者指针类型均可,否则只能传递指针类型实例,从代码性能角度来说,值拷贝需要消耗更多的内存空间,统一使用指针类型代码性能会更好
将接口赋值给接口
在 Go 语言中,只要两个接口拥有相同的方法列表(与顺序无关),那么它们就是等同的,可以相互赋值。不过,这里有一个前提,那就是接口变量持有的是基于对应实现类的实例值,所以接口与接口间的赋值是基于类实例与接口间的赋值的。
完全对等
下面我们来编写对应的示例代码,这是第一个接口 Number1
:
1 2 3 4 5 |
|
这是第二个接口 Number2
:
1 2 3 4 5 |
|
这里我们定义了两个接口,一个叫 Number1
,一个叫 Number2
,两者都定义三个相同的方法,只是顺序不同而已。在 Go 语言中,这两个接口实际上并无区别,因为:
- 任何实现了
Number1
接口的类,也实现了Number2
; - 任何实现了
Number1
接口的类实例都可以赋值给Number2
,反之亦然; - 在任何地方使用
Number1
接口与使用Number2
并无差异。
接下来我们定义一个实现了这两个接口的类 Number
:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
那么下面这些赋值代码都是合法的,会编译通过:
1 2 3 |
|
方法子集
此外,接口赋值并不要求两个接口完全等价(方法完全相同)。如果接口 A 的方法列表是接口 B 的方法列表的子集,那么接口 B 也可以赋值给接口 A。例如,假设 Number2
接口定义如下
1 2 3 4 5 6 |
|
要让 Number
类继续保持实现这两个接口,需要在 Number
类定义中新增一个 Add
方法实现(这里定义了一个指针方法):
1 2 3 |
|
接下来,将上面的接口赋值语句改写如下即可:
1 2 3 |
|
这样一来,就实现了接口赋值,但是反过来不行:
1 2 3 |
|
因为 Number1
接口中没有声明 Add
方法,或者换句话说,实现了 Number2
接口的类肯定实现了 Number1
,但是实现了 Number1
接口的类不一定实现了 Number2
。这句话是不是似曾相识?没错,这一点和 Java、PHP 中子类实例可以直接赋值给父类变量,而父类实例不能直接赋值给子类变量有异曲同工之妙,我们在学习新知识时要善于通过这种类比来降低学习成本,提高学习效率
六、类型断言
Go 语言设计地非常简单,,通过类型断言运算符 .(type)
来实现,其中 type
对应的就是要断言的类型。下面,我们来看下具体的使用示例
接口类型断言
以面介绍的 Number
类、Number1
和 Number2
接口为例,在 Go 语言中,要断言 Number2
接口类型实例 num2
是否也是 Number1
接口类型(即 num2
是否实现了 Number1
接口,本质上则是 num1
是否实现了 Number1
接口),可以这么做:
1 2 3 4 5 |
|
通过 num2.(Number1)
这个表达式断言 num2
是否是 Number1
类型的实例,如果是,ok
值为 true
,然后执行 if
语句块中的代码;否则 ok
值为 false
,不执行 if
语句块中的代码。
需要注意的是,类型断言是否成功要在运行期才能够确定,它不像接口赋值,编译器只需要通过静态类型检查即可判断赋值是否可行。
结构体类型断言
结构体类型断言实现语法和接口类型断言一样,在之前定义的 Animal
、Dog
类为例,它们都位于 animal
包中,由于类型断言语法 .
左侧的变量类型必须是接口类型,所以我们需要新增一个 IAnimal
接口(首字母大写的接口才能在包外可见,这一点和类名、方法名、函数名、变量名、属性名一样):
1 2 3 4 5 |
|
这样一来,Animal
和 Dog
类就都实现了 IAnimal
接口,要查询 IAnimal
接口类型的实例是否是 Dog
结构体类型,可以这么做:
1 2 3 4 5 6 7 8 |
|
如果 ianimal
变量是 Dog
类型,则 ok
值为 true
,执行 if
语句块中的代码;否则 ok
值为 false
。
需要注意的是,在 Go 语言结构体类型断言时,子类的实例并不归属于父类,即使子类和父类属性名和成员方法列表完全一致,因为类与类之间的「继承」是通过组合实现的,并不是 Java/PHP 中的那种父子继承关系,这是新手需要注意的地方。同理,父类实现了某个接口,不代表组合类它的子类也实现了这个接口。
比如,我们把上述代码中的 ianimal.(Dog)
替换成 ianimal.(Animal)
,则查询结果的 ok
值为 false
。当然,由于 Dog
实现了 IAnimal
接口,所以接口类型断言 ianimal.(IAnimal)
也会成功,但是如果 Dog
没有实现该接口,则断言失败,即使父类 Animal
实现了这个接口也不行。
所以,这里使用父子类来称呼,完全是为了方便大家对比理解,实际上已经和传统的面向对象编程中的父子类完全不是一个概念了,其本质原因就是 Go 使用了组合而非继承来构建类与类之间的关联和层次关系。
基于反射动态断言类型
此外,还可以基于反射在运行时动态进行类型断言,使用 reflect
包提供的 TypeOf
函数即可实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
因此,如果要获取 ianimal
的实际类型,可以通过 reflect.TypeOf(ianimal)
获取:
1 2 3 4 |
|
对于基本数据类型,比如 int
、string
、bool
这些,不必通过反射,直接使用 variable.(type)
表达式即可获取 variable
变量对应的类型值:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
Go 语言 fmt
标准库中的 Println()
函数底层就是基于类型断言将传入参数值转化为字符串进行打印的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
|
其中 arg
对应的是外部传入的每个待打印参数值。interface{}
表示空接口类型,在 Go 语言中,空接口可以表示任意类型,关于空接口以及它与反射结合来实现更复杂的类型功能,将是我们下面重点探讨的内容
七、空接口、反射和泛型
空接口的引入
熟悉 Java 的同学应该都知道,在这个号称血统最纯正的面向对象编程语言中,「万事万物皆对象」,并且所有类都继承自祖宗类「Object」,所以 Object
类型变量可以指向任何类的实例。
Go 语言打破了传统面向对象编程中类与类之间继承的概念,而是通过组合实现方法和属性的复用,所以不存在类似的继承关系树,也就没有所谓的祖宗类,而且类与接口之间也不再通过 implements
关键字强制绑定实现关系,所以 Go 语言的面向对象编程非常灵活。
在 Go 语言中,类与接口的实现关系是通过类所实现的方法在编译期推断出来的,如果我们定义一个空接口的话,那么显然所有的类都实现了这个接口,反过来,我们也可以通过空接口来指向任意类型,从而实现类似 Java 中 Object
类所承担的功能,而且显然 Go 的空接口实现更加简洁,通过一个简单的字面量即可完成:
1 |
|
需要注意的是空接口和接口零值不是一个概念,前者是 interface{}
,后者是 nil
空接口的基本使用
指向任意类型变量
我们可以将其指向基本类型:
1 2 3 |
|
也可以将其指向复合类型:
1 2 3 4 5 6 |
|
声明任意类型参数
空接口最典型的使用场景就是用于声明函数支持任意类型的参数,比如 Go 语言标准库 fmt 中的打印函数就是这样实现的:
1 2 3 |
|
实现更灵活的类型断言
类型断言运算符 .
左侧的变量必须是接口类型,而空接口可以表示任何类型,所以我们可以基于空接口将其他类型变量转化为空接口类型,这样,就不必单独引入 IAnimal
接口了
1 2 3 4 5 6 7 8 9 |
|
反射
很多现代高级编程语言都提供了对反射的支持,通过反射,你可以在运行时动态获取变量的类型和结构信息,然后基于这些信息做一些非常灵活的工作,一个非常典型的反射应用场景就是 IoC 容器。
Go 也支持反射功能,并且专门提供了一个 reflect
包用于提供反射相关的 API,
reflect
包提供的两个最常用、最重要的类型就是 reflect.Type
和 reflect.Value
。前者用于表示变量的类型,后者用于存储任何类型的值,分别可以通过 reflect.TypeOf
和 reflect.ValueOf
函数获取。
使用示例
下面我们来看一个简单的反射使用示例。以前面编写的 Dog
类为例,我们可以这样在运行时通过反射获取其类型:
1 2 3 4 5 6 7 |
|
执行这段代码,打印结果是:
1 |
|
如果你想要获取 dog
值的结构体信息,并且动态调用其成员方法,使用反射的话需要先获取对应的 reflect.Value
类型值:
1 2 |
|
当然,Dog
类中不包含指针方法的话,也可以返回 dog
值对应的 reflect.Value
类型值:
1 |
|
接下来,我们通过如下反射代码分别批量获取 dog
实例的所有属性和成员方法,并打印其名称、类型、值以及调用结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
执行上述代码,对应的打印结果如下:
可以看到,即便我们不知道 Dog
类的属性类型、成员方法细节时,依然可以通过反射来动态获取和调用,非常灵活
我们可以通过反射获取变量的所有未知结构信息,以结构体为例(基本类型只有类型和值,更加简单),包括其属性、成员方法的名称和类型,值和可见性,还可以动态修改属性值以及调用成员方法。
不过这种灵活是有代价的,因为所有这些解析工作都是在运行时而非编译期间进行,所以势必对程序性能带来负面影响,而且可以看到,反射代码的可读性和可维护性比起正常调用差很多,最后,反射代码出错不能在构建时被捕获,而是在运行时以恐慌的形式报告,这意味着反射错误有可能使你的程序崩溃。
所以,如果有其他更好解决方案的话,尽量不要使用反射。
基于空接口和反射实现泛型
空接口 interface{}
本身可以表示任何类型,因此它其实就是一个泛型了,不过这个泛型太泛了,我们必须结合反射在运行时对实际传入的参数做类型检查,让泛型变得可控,从而确保程序的健壮性,否则很容易因为传递进来的参数类型不合法导致程序崩溃。
下面我们通过一个自定义容器类型的实现来演示如何基于空接口和反射来实现泛型:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
|
如果我们试图添加其他类型元素到容器
1 2 3 |
|
或者存储返回结果的变量类型与容器内元素类型不符:
1 2 3 |
|
都会报错
空结构体
有的时候你可能会看到空的结构体类型定义:
1 |
|
表示没有任何属性和成员方法的空结构体,该类型的实例值只有一个,那就是 struct{}{}
,这个值在 Go 程序中永远只会存一份,并且占据的内存空间是 0
,当我们在并发编程中,将通道(channel)作为传递简单信号的介质时,使用 struct{}
类型来声明最好不过