面向对象 – 类型系统

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

  • 基础类型,如 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 语言中,你可以给任意类型(包括内置类型,但不包括指针类型)添加相应的方法,例如:

type Integer int // Go 中没有 Integer 类型,这个是自定义的一个类型,它的类型为 int,听着有点拗口,但这正是 Go 中给内置类型添加方法的方式

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 语言基础上添加的一个语法糖,接下来解释一下为什么可以这么理解。

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

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 语言包的代码,这可以达到事半功倍的效果。

值语义和引用语义

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

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 语言中的地位非常重要。

发表评论

电子邮件地址不会被公开。 必填项已用*标注