如你所知,封装、继承、多态和抽象是面向对象编程的4个基本特征,本文描述Golang语言是如何实现这些特征的。

1 Golang的面向对象类型

Golang实现面向对象的两个关键类型是struct和interface,其中struct类似C++的普通类类型,interface则对应抽象类类型。与C++采用public/protected/private指示成员和方法的可见性不同,Golang采用大小写标识可见性,即大写字母开头的成员/方法对外可见,小写开头的则属于类的私有成员,外部不可以直接访问。此外,Golang与C++在类类型的定义上还有一个重要区别,那就是Golang在struct内只需要声明类的成员变量,而不需要在类定义体内声明或定义所有的方法,方法定义都在struct之外完成。好了,我们开始正文。

2 Golang的面向对象实现

2.1 封装

学生有姓名、年龄和专业等属性,于是我们定义一个Student类型如下:

type Student struct {
	name  string
	age   int
	major string
}

学生可以跟大家打招呼:

func (s Student) SayHi() {
	fmt.Printf("Hi, I am %s aged %d, and my major is %s\n", s.name, s.age, s.major)
}

在函数定义的func关键字后面加上我们定义的Student类型变量定义,这个函数就成为了Student的方法。类图表示如下:

encapsulate

值得注意的是,在Golang内,除slice、map、channel和显示的指针类型属于引用类型外,其它类型都属于值类型,前者作为函数入参传递时,函数对参数的修改会影响调用对象,而后者作为入参时,函数体内会生成调用对象的拷贝,函数对入参的修改不会影响调用对象。因此,如果我们要给Student类定义一个“构造函数”,我们希望的是这个函数的入参可以被赋值到Student的成员内,则该“构造函数”应该使用指针类型对象定义:

func (s *Student) Init(name string, age int, major string) {
	s.name = name
	s.age = age
	s.major = major
}

我们来测试一下:

s := Student{}
s.Init("pirlo", 21, "cs")
s.SayHi()

输出结果:

$ go run test_encapsulate.go 
Hi, I am pirlo aged 21, and my major is cs.

我们定义的学生类型,属性都是私有的,方法都是公有的,还记得么,私有或公有都是通过属性或方法的首字母大小写决定的。那我们现在来试一下公有属性和私有方法吧。
比如,我想让专业(major)这个属性成为公有属性:

type Student struct {
	name  string
	age   int
	Major string
}

func main() {
	s := Student{}
	s.Init("pirlo", 21, "cs")
	s.SayHi()

	s.Major = "finance"
	s.SayHi()
}

先调用构造函数设置专业为cs,再通过显示赋值的方式修改专业为finance:

$ go run test_encapsulate.go 
Hi, I am pirlo aged 21, and my major is cs.
Hi, I am pirlo aged 21, and my major is finance.

但是如果我们试图修改私有属性:

s.age = 22

编译器会告诉你:

$ go run test_encapsulate.go 
# command-line-arguments
./test_encapsulate.go:15: s.age undefined (cannot refer to unexported field or method age)

括号的注释说明了不能引用未导出/未公开的属性或方法。

小结一下,Golang通过struct定义类的属性,通过在func定义时传入类对象的方式定义类的方法,其中属性和方法的公有/私有属性是通过首字母的大小写决定的。

2.2 继承

与C++、Java等完整支持面向对象的语言不同,Golang没有显式的继承,而是通过组合实现继承。
我们先定义一个基类Person,提供姓名和年龄两个属性,以及SayHi一个方法(Init类似于构造函数):

type Person struct {
	name string
	age  int
}

func (p *Person) Init(name string, age int) {
	p.name = name
	p.age = age
}

func (p Person) SayHi() {
	fmt.Printf("Hi, I am %s, %d years old.\n", p.name, p.age)
}

然后,我们通过组合的方式继承这个基类,实现Employee子类:

type Employee struct {
	Person
	company string
}

func (e *Employee) Init(name string, age int, company string) {
	e.Person.Init(name, age)
	e.company = company
}

func (e Employee) Work() {
	fmt.Printf("I'm working %s.\n", e.company)
}

Employee组合了Person这个成员,除此之外它还拥有自己的成员company,即所属公司,雇员除了是一个Person之外,还需要工作,因此我们定义了Work这个方法。好了,我们再测试一下:

func main() {
	p := oo.Person{}
	p.Init("pirlo", 21)
	p.SayHi()

	e := oo.Employee{}
	e.Init("kaka", 22, "milan")
	e.SayHi()
	e.Work()
}
$ go run test_inherit.go 
Hi, I am pirlo, 21 years old.
Hi, I am kaka, 22 years old.
I'm working in milan.

雇员kaka可以像pirlo一样说话,与此同时,他还可以在milan工作,类图表示如下:

inherit

小结一下,Golang没有完整实现继承,而是通过组合的方式实现。组合类(子类)可以直接调用被组合类(基类)的公有方法,访问基类的公有属性,子类也可以定义自己的属性,以及实现自己特有的方法。Golang的设计哲学之一就是简洁,通过大小写区分成员/方法的公有/私有属性,通过组合的方式实现继承,都是简洁哲学的体现。

2.3 抽象

抽象的反义词是具体,在面向对象编程中,抽象的意思是将共同的属性和方法抽象出来形成一个不可以被实例化的类型,在Java里面,这是通过abstract和interface实现的,其中前者可以包含属性,后者则是纯粹的方法集合;C++通过在类内定义纯虚函数使得该类成为一个抽象类。
Golang的interface类型定义的也是一个抽象的基类,它是一组方法的集合,任何完整实现这些方法的类型都被称为该接口的实现。由于抽象与多态是相辅相成的,或者说抽象的目的就是为了实现多态,我们将在下一节给出实例说明Golang的抽象和多态的实现。

2.4 多态

基类指针可以指向任意派生类的对象,并在运行时绑定最终调用的方法的过程被称为多态。多态是运行时特性,而继承则是编译时特征,也就是说,继承关系在编译时就已经确定了,而多态则可以实现运行时的动态绑定。
小狗和小鸟都是动物,它们都会移动,也都会叫唤。我们把它们共同的方法提炼出来定义一个抽象的接口:

type Animal interface {
	Move()
	Shout()
}

虽然小狗和小鸟都会移动,但小狗是用四条腿爬行,小鸟是用翅膀飞行,虽然它们都会叫唤,但是叫唤的方式也不一样:

type Dog struct {
}

func (dog Dog) Move() {
	fmt.Println("A dog moves with its legs.")
}

func (dog Dog) Shout() {
	fmt.Println("wang wang wang.")
}

type Bird struct {
}

func (bird Bird) Move() {
	fmt.Println("A bird flys with its wings.")
}

func (bird Bird) Shout() {
	fmt.Println("A bird shouts.")
}

类图表示如下:

polymorphism

那么,运行时的多态是怎么实现的呢?

func main() {
	var animal oo.Animal

	animal = oo.Dog{}
	animal.Move()
	animal.Shout()

	animal = oo.Bird{}
	animal.Move()
	animal.Shout()
}

如前文所述,基类指针可以指向任意派生类的对象,并在运行时动态绑定最终使用的方法。这里指针是广义上的概念,在C++中是真实的指针,在Java和Golang里面,则可以是一个接口类型的对象。在上面的代码中,我们定义了一个Animal类型的对象,并分别指向Dog和Bird类型的具体对象,并调用Move和Shout方法,它们的运行效果如下:

$ go run test_polymorphism.go 
A dog moves with its legs.
wang wang wang.
A bird flys with its wings.
A bird shouts.

可以看到,通过定义抽象的接口,以及实现接口方法的具体类型的方式,Golang实现了运行时的动态绑定,这就是所谓的抽象与多态。