Go语言的类型系统(type system)

在讲解Go语言面向对象内容之前,需要说明下Go语言的代码是以包结构来组织的,且如果标示符(变量名,函数名,自定义类型等)如果以大写字母开头那么这些标示符是可以导出的,可以在任何导入了定义该标示符的包的包中直接使用。Go语言中的面向对象和C++,Java中的面向对象不同,因为Go语言不支持继承,Go语言只支持聚合。

第3章 面向对象编程

在第2章中,我们详细介绍了Go语言顺序编程的相关特性,通过与C语言的对比我们了解了为什么Go语言被称为“更好的C语言”,本章我们将介绍Go语言对于面向对象编程(OOP,Object Oriented Programming)思想的支持。相应地,本章在介绍Go语言的面向对象编程特性的过程中, 对比对象会自然切换为比较典型的现有面向对象编程语言:C++、Java和C#。

为了加深读者对Go语言面向对象特性的理解,本章中我们会提及C++、Java和C#语言的一些特性和例子。如果读者之前没有接触过这些语言,阅读本章并不会有明显的障碍。但如果之前深入了解过这几门语言或者其他的面向对象语言,那么你将会更清晰地理解Go语言相对于C++流派的面向对象体系的众多革新之处。

对于面向对象编程的支持Go 语言设计得非常简洁而优雅。简洁之处在于,Go语言并没有沿袭传统面向对象编程中的诸多概念,比如继承、虚函数、构造函数和析构函数、隐藏的this指针等。优雅之处在于,Go语言对面向对象编程的支持是语言类型系统中的天然组成部分。整个类型系统通过接口串联,浑然一体。我们在本章中将一一解释这些特性。

3.1 类型系统

很少有编程类的书谈及类型系统(type system)这个话题,实际上类型系统才是一门编程语言的地基,它的地位至关重要。因此,这里我们将从类型系统入手介绍Go语言的面向对象编程特性。

顾名思义,类型系统是指一个语言的类型体系结构。一个典型的类型系统通常包含如下基本内容:

  • 基础类型,如byte、int、bool、float等;
  • 复合类型,如数组、结构体、指针等;
  • 可以指向任意对象的类型(Any类型);
  • 值语义和引用语义;
  • 面向对象,即所有具备面向对象特征(比如成员方法)的类型;
  • 接口。

类型系统描述的是这些内容在一个语言中如何被关联。因为Java语言自诞生以来被称为最纯正的面向对象语言,所以我们就先以Java语言为例讲一讲类型系统。

在Java语言中,存在两套完全独立的类型系统:一套是值类型系统,主要是基本类型,如byte、 int、boolean、char、double等,这些类型基于值语义;一套是以Object类型为根的对象类型系统,这些类型可以定义成员变量和成员方法,可以有虚函数,基于引用语义,只允许在堆上创建 (通过使用关键字new)。Java语言中的Any类型就是整个对象类型系统的根——java.lang.Object类型,只有对象类型系统中的实例才可以被Any类型引用。值类型想要被Any类型引用,需要装箱(boxing)过程,比如int类型需要装箱成为Integer类型。另外,只有对象类型系统中的类型才可以实现接口,具体方法是让该类型从要实现的接口继承。

相比之下,Go语言中的大多数类型都是值语义,并且都可以包含对应的操作方法。在需要的时候,你可以给任何类型(包括内置类型)“增加”新方法。而在实现某个接口时,无需从该接口继承(事实上,Go语言根本就不支持面向对象思想中的继承语法),只需要实现该接口要求的所有方法即可。任何类型都可以被Any类型引用。Any类型就是空接口,即interface{}。

接下来我们对Go语言类型系统的特点逐一进行讲解。

3.1.1 为类型添加方法

在Go语言中,你可以给任意类型(包括内置类型,但不包括指针类型)添加相应的方法, 例如:

type Integer int
func (a Integer) Less(b Integer) bool {
    return a < b
}

在这个例子中,我们定义了一个新类型Integer,它和int没有本质不同,只是它为内置的int类型增加了个新方法Less()。

这样实现了Integer后,就可以让整型像一个普通的类一样使用:

func main() {
var a Integer = 1
if a.Less(2) {
     fmt.Println(a, "Less 2")
   }
}

在学其他语言(尤其是C++语言)的时候,很多初学者对面向对象的概念感觉很神秘,不知道那些继承和多态到底是怎么发生的。不过,如果读者曾经深入了解过C++的对象模型,或者完整阅读过《深度探索C++对象模型》这本书,就会理解C++等语言中的面向对象都只是相当于在C语言基础上添加的一个语法糖,接下来解释一下为什么可以这么理解。

上面的这个Integer例子如果不使用Go语言的面向对象特性,而使用之前我们介绍的面向过程方式实现的话,相应的实现细节将如下所示:

64 第 3 章 面向对象编程
type Integer int

func Integer_Less(a Integer, b Integer) bool {
    return a < b
}

func main() {
    var a Integer = 1
    if Integer_Less(a, 2) { 
         fmt.Println(a, “Less 2”)
    }
}

在Go语言中,面向对象的神秘面纱被剥得一干二净。对比下面的两段代码:

func (a Integer) Less(b Integer) bool {	         // 面向对象
    return a < b
}

func Integer_Less(a Integer, b Integer) bool {   // 面 向 过 程
    return a < b
}

a.Less(2)	                                // 面向对象的用法
Integer_Less(a, 2)	                        // 面向过程的用法

可以看出,面向对象只是换了一种语法形式来表达。C++语言的面向对象之所以让有些人迷惑的一大原因就在于其隐藏的this指针。一旦把隐藏的this指针显露出来,大家看到的就是一个面向过程编程。感兴趣的读者可以去查阅《深度探索C++对象模型》这本书,看看C++语言是如何对应到C语言的。而Java和C#其实都是遵循着C++语言的惯例而设计的,它们的成员方法中都带有一个隐藏的this指针。如果读者了解Python语法,就会知道Python的成员方法中会有一个self 参数,它和this指针的作用是完全一样的。

我们对于一些事物的不理解或者畏惧,原因都在于这些事情所有意无意带有的绚丽外衣和神秘面纱。只要揭开这一层直达本质,就会发现一切其实都很简单。

“在Go语言中没有隐藏的this指针”这句话的含义是:

  • 方法施加的目标(也就是“对象”)显式传递,没有被隐藏起来;
  • 方法施加的目标(也就是“对象”)不需要非得是指针,也不用非得叫this。我们对比Java语言的代码:
class Integer {
    private int val;
    public boolean Less(Integer b) {
        return this.val< b.val;
    }
}

对于这段Java代码,初学者可能会比较难以理解其背后的机制,以及this到底从何而来。这主要是因为Integer类的Less()方法隐藏了第一个参数Integer* this。如果将其翻译成C代码,会更清晰:

struct Integer {
    int val;
};
bool Integer_Less(Integer* this, Integer* b) {
    return this->val < b->val;
}

Go语言中的面向对象最为直观,也无需支付额外的成本。如果要求对象必须以指针传递, 这有时会是个额外成本,因为对象有时很小(比如4字节),用指针传递并不划算。

只有在你需要修改对象的时候,才必须用指针。它不是Go语言的约束,而是一种自然约束。举个例子:

func (a *Integer) Add(b Integer) {
    *a += b
}

这里为Integer类型增加了Add()方法。由于Add()方法需要修改对象的值,所以需要用指针引用。调用如下:

func main() {
    var a Integer = 1 
    a.Add(2)
        fmt.Println("a =", a)
}

运行该程序,得到的结果是:a=3。如果你实现成员方法时传入的不是指针而是值(即传入Integer,而非*Integer),如下所示:

func (a Integer) Add(b Integer) {
     a += b
}

那么运行程序得到的结果是a=1,也就是维持原来的值。读者可以亲自动手尝试一下。

究其原因,是因为Go语言和C语言一样,类型都是基于值传递的。要想修改变量的值,只能传递指针。

Go  语言包经常使用此功能,比如http包中关于HTTP头部信息的Header类型(参见

$GOROOT/src/pkg/http/header.go)就是通过Go内置的map类型赋予新的语义来实现的。下面是Header类型实现的部分代码:

// Header类型用于表达HTTP头部的键值对信息
type Header map[string][]string
// Add()方法用于添加一个键值对到HTTP头部
// 如果该键已存在,则会将值添加到已存在的值后面
func (h Header) Add(key, value string) {
    textproto.MIMEHeader(h).Add(key, value)
}
// Set()方法用于设置某个键对应的值,如果该键已存在,则替换已存在的值
func (h Header) Set(key, value string) {
    textproto.MIMEHeader(h).Set(key, value)
}
// 还有更多其他方法

Header类型其实就是一个map,但通过为map起一个Header别名并增加了一系列方法,它就变成了一个全新的类型,但这个新类型又完全拥有map的功能。是不是很酷?

Go 语言包里还有很多类似的例子,这里就不一一列举了。Go 语言毕竟还是一门比较新的语言,学习资源相比 C++/Java/C#自然会略显缺乏。其实Go语言包的实现代码非常精致耐读,是学习Go语言编程的最佳示例。大家在学习和工作中一定要记得时常翻看 Go 语言包的代码,这可以达到事半功倍的效果。

3.1.2 值语义和引用语义

值语义和引用语义的差别在于赋值,比如下面的例子:

b = a
b.Modify()

如果b的修改不会影响a的值,那么此类型属于值类型。如果会影响a的值,那么此类型是引用类型。

Go语言中的大多数类型都基于值语义,包括:

  • 基本类型,如byte、int、bool、float32、float64和string等;
  • 复合类型,如数组(array)、结构体(struct)和指针(pointer)等。

Go语言中类型的值语义表现得非常彻底。我们之所以这么说,是因为数组。

如果读者之前学过C语言,就会知道C语言中的数组比较特别。通过函数传递一个数组的时候基于引用语义,但是在结构体中定义数组变量的时候基于值语义(表现在为结构体赋值的时候, 该数组会被完整地复制)。

Go语言中的数组和基本类型没有区别,是很纯粹的值类型,例如:

var a = [3]int{1, 2, 3}
var b = a 
b[1]++
fmt.Println(a, b)

该程序的运行结果如下:

[1 2 3] [1 3 3]。

这表明b=a赋值语句是数组内容的完整复制。要想表达引用,需要用指针:

var a = [3]int{1, 2, 3}
var b = &a 
b[1]++
fmt.Println(a, *b)

该程序的运行结果如下:

[1 3 3] [1 3 3]

这表明b=&a赋值语句是数组内容的引用。变量b的类型不是[3]int,而是*[3]int类型。

Go语言中有4个类型比较特别,看起来像引用类型,如下所示。

  • 数组切片:指向数组(array)的一个区间。
  • map:极其常见的数据结构,提供键值查询能力。
  • channel:执行体(goroutine)间的通信设施。
  • 接口(interface):对一组满足某个契约的类型的抽象。

但是这并不影响我们将Go语言类型看做值语义。下面我们来看看这4个类型。数组切片本质上是一个区间,你可以大致将[]T表示为:

type slice struct { 
    first *T
    len int 
    cap int
}

因为数组切片内部是指向数组的指针,所以可以改变所指向的数组元素并不奇怪。数组切片类型本身的赋值仍然是值语义。

map本质上是一个字典指针,你可以大致将map[K]V表示为:

type Map_K_V struct {
// …
}

type map[K]V struct { 
    impl *Map_K_V
}

基于指针,我们完全可以自定义一个引用类型,如:

type IntegerRef struct {
   impl *int
}

channel和map类似,本质上是一个指针。将它们设计为引用类型而不是统一的值类型的原因是,完整复制一个channel或map并不是常规需求。

同样,接口具备引用语义,是因为内部维持了两个指针,示意为:

type interface struct { 
    data *void
    itab *Itab
}

接口在Go语言中的地位非常重要。关于接口的内部实现细节,在后面的高阶话题中我们再细细剖析。

3.1.3 结构体

Go语言的结构体(struct)和其他语言的类(class)有同等的地位,但Go语言放弃了包括继承在内的大量面向对象特性,只保留了组合(composition)这个最基础的特性。

组合甚至不能算面向对象特性,因为在C语言这样的过程式编程语言中,也有结构体,也有组合。组合只是形成复合类型的基础。

上面我们说到,所有的Go语言类型(指针类型除外)都可以有自己的方法。在这个背景下,

Go语言的结构体只是很普通的复合类型,平淡无奇。例如,我们要定义一个矩形类型:

type Rect struct { 
    x, y float64
    width, height float64
}

然后我们定义成员方法Area()来计算矩形的面积:

func (r *Rect) Area() float64 {
     return r.width * r.height
}

可以看出, Go语言中结构体的使用方式与C语言并没有明显不同。

3.2 初始化

在定义了Rect类型后,该如何创建并初始化Rect类型的对象实例呢?这可以通过如下几种方法实现:

rect1 := new(Rect) 
rect2 := &Rect{}
rect3 := &Rect{0, 0, 100, 200}
rect4 := &Rect{width: 100, height: 200}

在Go语言中,未进行显式初始化的变量都会被初始化为该类型的零值,例如bool类型的零值为false,int类型的零值为0,string类型的零值为空字符串。

在Go语言中没有构造函数的概念,对象的创建通常交由一个全局的创建函数来完成,以

NewXXX来命名,表示“构造函数”:

func NewRect(x, y, width, height float64) *Rect {
    return &Rect{x, y, width, height}
}

这一切非常自然,开发者也不需要分析在使用了new之后到底背后发生了多少事情。在Go 语言中,一切要发生的事情都直接可以看到。

3.3 匿名组合

确切地说,Go语言也提供了继承,但是采用了组合的文法,所以我们将其称为匿名组合:

type Base struct { 
    Name string
}

func (base *Base) Foo() { ... }
func (base *Base) Bar() { ... }

type Foo struct { 
    Base...
}

func (foo *Foo) Bar() { 
   foo.Base.Bar()
   ...
}

以上代码定义了一个Base类(实现了Foo()和Bar()两个成员方法),然后定义了一个Foo类,该类从Base类“继承”并改写了Bar()方法(该方法实现时先调用了基类的Bar()方法)。

在“派生类”Foo没有改写“基类”Base的成员方法时,相应的方法就被“继承”,例如在上面的例子中,调用foo.Foo()和调用foo.Base.Foo()效果一致。

与其他语言不同,Go语言很清晰地告诉你类的内存布局是怎样的。此外,在Go语言中你还可以随心所欲地修改内存布局,如:

type Foo struct {
    ... // 其他成员
    Base
}

这段代码从语义上来说,和上面给的例子并无不同,但内存布局发生了改变。“基类”Base 的数据放在了“派生类”Foo的最后。

另外,在Go语言中,你还可以以指针方式从一个类型“派生”:

type Foo struct {
    *Base
    ...
}

这段Go代码仍然有“派生”的效果,只是Foo创建实例的时候,需要外部提供一个Base类   实例的指针。

在C++ 语言中其实也有类似的功能,那就是虚基类,但是它非常让人难以理解,一般C++的开发者都会遗忘这个特性。相比之下,Go语言以一种非常容易理解的方式提供了一些原本期望用虚基类才能解决的设计难题。

在Go语言官方网站提供的Effective Go中曾提到匿名组合的一个小价值,值得在这里再提一下。首先我们可以定义如下的类型,它匿名组合了一个log.Logger指针:

type Job struct { 
    Command string
    *log.Logger
}

在合适的赋值后,我们在Job类型的所有成员方法中可以很舒适地借用所有log.Logger提供的方法。比如如下的写法:

func (job *Job)Start() { 
    job.Log("starting now...")
    ... // 做一些事
    job.Log("started.")
}

对于Job的实现者来说,他甚至根本就不用意识到log.Logger类型的存在,这就是匿名组合的魅力所在。在实际工作中,只有合理利用才能最大发挥这个功能的价值。

需要注意的是,不管是非匿名的类型组合还是匿名组合,被组合的类型所包含的方法虽然都升级成了外部这个组合类型的方法,但其实它们被组合方法调用时接收者并没有改变。比如上面这个Job例子,即使组合后调用的方式变成了job.Log(…),但Log函数的接收者仍然是

log.Logger指针,因此在Log中不可能访问到job的其他成员方法和变量。

这其实也很容易理解,毕竟被组合的类型并不知道自己会被什么类型组合,当然就没法在实现方法时去使用那个未知的“组合者”的功能了。

另外,我们必须关注一下接口组合中的名字冲突问题,比如如下的组合:

type X struct { 
    Name string
}
type Y struct { 
    X
    Name string
}

组合的类型和被组合的类型都包含一个Name成员,会不会有问题呢?答案是否定的。所有的Y类型的Name成员的访问都只会访问到最外层的那个Name变量,X.Name变量相当于被隐藏起来了。

那么下面这样的场景呢:

type Logger struct { 
    Level int
}
type Y struct {
    *Logger 
    Name string
    *log.Logger
}

显然这里会有问题。因为之前已经提到过,匿名组合类型相当于以其类型名称(去掉包名部分) 作为成员变量的名字。按此规则,Y类型中就相当于存在两个名为Logger的成员,虽然类型不同。因此,我们预期会收到编译错误。

有意思的是,这个编译错误并不是一定会发生的。假如这两个Logger在定义后再也没有被用过,那么编译器将直接忽略掉这个冲突问题,直至开发者开始使用其中的某个Logger。

3.4 可见性

Go语言对关键字的增加非常吝啬,其中没有private、protected、public这样的关键字。要使某个符号对其他包(package)可见(即可以访问),需要将该符号定义为以大写字母开头,如:

type Rect struct { 
    X, Y float64
    Width, Height float64
}

这样,Rect类型的成员变量就全部被导出了,可以被所有其他引用了Rect所在包的代码访问到。成员方法的可访问性遵循同样的规则,例如:

func (r *Rect) area() float64 {
    return r.Width * r.Height
}

这样,Rect的area()方法只能在该类型所在的包内使用。

需要注意的一点是,Go语言中符号的可访问性是包一级的而不是类型一级的。在上面的例子中,尽管area()是Rect的内部方法,但同一个包中的其他类型也都可以访问到它。这样的可访问性控制很粗旷,很特别,但是非常实用。如果Go语言符号的可访问性是类型一级的,少不了还要加上friend这样的关键字,以表示两个类是朋友关系,可以访问彼此的私有成员。

作者:

喜欢围棋和编程。

 
发布于 分类 编程标签

发表评论

电子邮件地址不会被公开。