8. 面向对象与结构体
Go 语言常被认为没有面向对象,但事实远非如此。与传统的面向对象的语言相比,它只是没有传统的类(class)和继承(inheritance),却通过结构体(struct)、方法(method) 和接口(interface) 提供了一套更简洁、更灵活的面向对象编程(OOP)范式。本章节中将深入探讨 Go 语言中的面向对象的理念、结构体以及组合,帮助你更好地理解 Go 语言中的面向对象。
8.1. 面向对象
传统面向对象语言,如 Java、C++,以类(class)为核心,其中类是某一批对象的抽象,可以把类理解成某种概念。而对象才是一个具体存在的实体。举个简单的例子,我们每一个人,可以用姓名,年龄,性别来抽象,这就可以被定义为一个类,而对于一个具体的人,如“姓名:小明;年龄:25;性别:男”这便是一个实体。
在传统的面向对象语言中,提出了面向对象的三要素,包括封装、继承和多态。针对面向对象的三要素,在 Go 中,则选择了一条与传统的面向对象不同的道路:
- 封装:传统的面向对象是通过类(class)和关键字 private,public 以及 protected 来控制可见性;而 Go 语言则是通过包(package)和标识符首字母大小写来控制可见性(大写公开,小写私有)。
- 继承:传统的面向对象是通过子类 extends 父类类实现继承;而在 Go 语言中,则不直接支持,而是推荐使用组合(composition)和嵌入(embedding)。
- 多态:传统的面向对象是通过接口实现,但是接口是用过 implements 显式实现的;而在 Go 语言中,也是通过接口实现,但接口是隐式满足的。
8.2. 结构体
在 Go 语言中,没有关键字 class 来组织数据,而是通过结构体(struct)来组织数据。结构体是一组字段的集合,可以拥有方法,也可以嵌入其他结构体。
8.2.1. 定义和实例化
在 Go 语言中,使用关键字 type 和 关键字 struct 定义一个结构体,以结构体 Person 为例,代码如下:
type Person struct {
Name string
Age int
Gender string
}
在 Go 语言中,没有了传统面向对象的 public,private 等关键字来控制类对外的可见性,取而代之的是通过首字母大小写来控制包内的结构体以及结构体内的字段的对外可见性。在这里,我们都设首字母大写以允许被其他包访问。有了结构体后,我们需要实例化,在 Go 语言中有 4 种实例化方式,代码如下:
// 方式1:零值初始化
p1 := Person{}
// 方式2:字段赋值
p2 := Person{Name: "张三", Age: 30, Gender: "男"}
// 方式3:按顺序赋值
p3 := Person{"李四", 25, "男"}
// 方式4:new 返回指针
p4 := new(Person)
p4.Name = "王五"
8.2.2. 方法:接收者与值/指针语义
定义完结构体后,我们还可以给结构体定义方法,方法定义语法为:
```func (接收者变量 类型) 方法名(参数) 返回值`
其中,接收者有两种,分别是:
- 值接收者:方法内对接收者的修改不影响原值
- 指针接收者:方法内可修改原值,且避免拷贝开销
针对上面的 Person 结构体,我们设计了两个方法,分别是 Greet() 方法和 SetAge() 方法,Greet() 方法使用值接收者,而 SetAge() 方法使用指针接收者,代码如下
func (p Person) Greet() string { // 值接收者
return "Hello, " + p.Name
}
func (p *Person) SetAge(age int) { // 指针接收者
p.Age = age
}
如果需要修改接收者,或结构体较大,使用指针接收者;否则值接收者更安全(不可变)。
8.2.3. 匿名字段与嵌入
结构体允许只写类型而不写字段名,称为匿名字段,或者嵌入字段。如为 Person 结构体再定义一个匿名字段,而这个匿名字段是另一个结构体 Address,代码如下:
type Address struct {
City string
Street string
}
type Person struct {
Name string
Age int
Gender string
Address // 匿名字段
}
8.3. 组合
在传统面向对象的编程中,通过继承实现代码的复用,而在 Go 语言中并没有直接通过子类继承父类,而是通过组合来达到代码的复用。
8.3.1. 结构体嵌入实现组合
嵌入一个类型,实际上就是组合该类型的功能。外层结构体将自动获得嵌入类型的所有字段和方法,同时嵌入类型的字段和方法会被提升到外层结构体。还是以上面的代码为例,有结构体 Address 的代码如下:
type Address struct {
City string
Street string
}
func (address Address) Output() {
fmt.Printf("city: %s, street: %s\n", address.City, address.Street)
}
此时,在结构体 Person 中嵌入结构体 Address,代码如下:
type Person struct {
Name string
Age int
Gender string
Address // 匿名字段
}
这个时候要实例化 Person,我们采用字段赋值,代码如下:
person := Person{Name: "张三", Age: 30, Gender: "男"}
但是对于结构体 Address 中的字段,在上面的实例化过程中我们并没有赋值,针对结构体 Address 中的字段和方法,可以使用下面的代码赋值或者调用:
func main() {
person := Person{Name: "张三", Age: 30, Gender: "男"}
person.Address.City = "上海"
person.Address.Street = "xxxx"
person.Address.Output()
}
由于 Go 语言中的嵌入的字段和方法会直接提升到外层的结构体中,也就是会被提升到结构体 Person 中,因此,直接使用实例 person 调用即可,代码如下:
func main() {
person := Person{Name: "张三", Age: 30, Gender: "男"}
// 直接操作结构体 Address 中的字段
person.City = "上海"
person.Street = "xxxx"
// 直接操作结构体 Address 的方法
person.Output()
}
8.3.2. 多重嵌入与冲突处理
Go 也支持嵌入多个类型,但若两个嵌入类型有同名方法或字段,会引发冲突,需要显式指定,如下代码:
type A struct { X int }
type B struct { X int }
type C struct {
A
B
}
c := C{}
// ambiguous selector c.X
c.X = 1
当结构体 A 和结构体 B 同时嵌入到结构体 C 中,且结构体 A 和结构体 B 中有同名的字段,如上述代码中的 X,此时,如果以 c.X 去调用字段 X,程序会报错:ambiguous selector c.X,这是因为程序不知道调用的是结构体 A 中的字段 X 还是结构体 B 中的字段 X,此时,需要指明是结构体 A 中的字段或者结构体 B 中的字段:c.A.X 或者 c.B.X。
8.4. 结构体的可见性
前面介绍到在 Go 语言中,没有了传统面向对象的 public,private 等关键字来控制类对外的可见性,取而代之的是通过首字母大小写来控制包内的结构体以及结构体内的字段的对外可见性。在上述代码中,我们都设首字母大写以允许被其他包访问,且上面的代码都是写在同一个 .go 文件中。
在这里我们会提前使用到包的概念,这个在后面章节中会详细介绍到,假设我们将上面的代码分别放到不同的包中,先初始化 Module,在文件夹 person 下执行:go mod init test,会在文件夹下生成文件 go.mod。
此时建立如下的文件夹结构,如下
|-- go.mod
|-- person
| |-- person.go
|-- address
| |-- address.go
|-- main.go
其中,main.go 的代码如下:
package main
import (
"test/person"
)
func main() {
p := person.Person{Name: "张三", Age: 30, Gender: "男"}
// 直接操作结构体 Address 中的字段
p.City = "上海"
p.Street = "xxxx"
// 直接操作结构体 Address 的方法
p.Output()
}
person 文件夹下 person.go 的代码如下:
package person
import "test/address"
type Person struct {
Name string
Age int
Gender string
address.Address // 匿名字段
}
address 文件夹下 address.go 的代码如下:
package address
import "fmt"
type Address struct {
City string
Street string
}
func (address Address) Output() {
fmt.Printf("city: %s, street: %s\n", address.City, address.Street)
}
此时,运行 go run main.go 就能执行。注意到结构体 Address 和结构体 Person 都是采用的首字母大写,其他的包都能看到。
8.4.1. 结构体可见
当结构体可见时,字段或者方法均可见的情况,上面已经介绍了,下面我们看下字段或者方法不可见的情况。以 Address 中的 City 字段为例,将其首字母改为小写,如下:
package address
import "fmt"
type Address struct {
city string
Street string
}
func (address Address) Output() {
fmt.Printf("city: %s, street: %s\n", address.City, address.Street)
}
此时,外面的包将不可见,会报如下的错误:main.go:11:4: p.city undefined (cannot refer to unexported field city)。这是因为 city 字段对外不可见,有两种办法,一种就是像上面代码一样将首字母改成大写,另一种就是增加对外的修改函数,如下:
func (address *Address) SetCity(city string) {
address.city = city
}
注意这里的 SetCity(city string) 首字母大写。同时修改 main() 函数:
func main() {
p := person.Person{Name: "张三", Age: 30, Gender: "男"}
// 直接操作结构体 Address 中的字段
p.SetCity("上海")
p.Street = "xxxx"
// 直接操作结构体 Address 的方法
p.Output()
}
方法也是类似,首字母小写为外面的包不可见,这里就不再赘述。
8.4.2. 结构体不可见
当结构体不可见时,即结构体的首字母小写,如下代码:
type address struct {
city string
Street string
}
那么此时不管字段对外可见或者不可见,整体都不可见。
8.5. 本章小结
Go 语言的面向对象并非照搬传统,而是以更简约、高效的方式呈现。采用了结构体+方法的方式实现面向对象,并通过首字母的大写或者小写实现对外的可见或者不可见。另外,通过组合+嵌入的方式实现与传统面向对象继承的效果。掌握这些概念,你将能充分发挥 Go 的设计优势,写出简洁、健壮且可维护的代码。