Go笔记4-Go面向对象编程1

面向对象的特性

提到面向对象,我们第一反应会想到C++,Java,Python,C#一众语言。在学习时我们也会多多少少地去类比这些传统OOP语言。Go语言相较之下设计非常简洁而又优雅。它在语言层面上做了巨大的革新,放弃了传统OOP当中耳熟能详的概念,比如继承、虚函数、构造函数、析构函数、隐藏this指针等。整个类型系统通过接口自动变形来串联起来,更加简洁。这也可以侧面看出来为什么称Go是”更好的C语言”

类型系统

Go语言编程一书当中说道:

一个典型的类型系统通常包含如下基本内容: - 基础类型,如byte、 int、 bool、 float等; - 复合类型,如数组、结构体、指针等; - 可以指向任意对象的类型(Any类型); - 值语义和引用语义; - 面向对象,即所有具备面向对象特征(比如成员方法)的类型; - 接口

并且拿了Java作为讲解类型系统的范例,介绍了一下Java的两种类型系统,这里将不再比较赘述。

然而Go语言当中有一个十分灵巧的特性,任何类型都可以“增加”新方法。多数类型为值类型;实现接口时,是基于实现接口的模型(函数实现),而不是定义的接口所在包或者名称(这一点Java就做的比较死板),实现接口也不是继承方式,而是函数模型实现。任何类型都可以由Any类型引用。Any类型就是空接口,也就是interface{}。

方法和函数

在对Go面向对象类型系统学习之前,我们还要对另外一个概念“方法”进行学习。在学习Go语言过程中,我也逐渐发现了《Go 语言编程》一书对Go语言的诠释从函数这一部分开始就有所缺失,诠释不完善。比如方法这个概念,多返回值的命名这一概念,仅仅在示例中一笔带过,没有进一步进行讨论。除此以外,缺失了包的概念以及对类型定义形式的最起码的解释。因此后面我将会对比多部书籍进行Go语言后面部分的学习。这一节为将缺失的环节进行补充。

方法的简介

首先我会简单介绍一下方法的形式,后面我们在学习Go语言的结构体类型、接口的时候会重点学习,本节将会认识一下方法的形式还有方法和函数的区别。

学过Java和C++的人都知道,OOP语言当中针对一个对象可以为对象本身写一个与它相关的行为,这叫做方法(C++中仍然叫做函数,不过是类当中的函数),它与函数的区别是,一个函数是一个计算规则的最小单位,它本身没有属主,是一个独立存在的计算规则单位。比如各种初等数学函数,他们仅仅与规则相关,而没有任何所属物。在C++当中,函数可以独立存在,也可以成为一个类的所属函数,也就成了方法,只是在C++中,他依然叫做函数。在Java中,没有函数这一概念,或者说,所有的函数都是有属主的,这些函数就成了方法。要想定义与主人无关的函数,那这个方法就“挂靠”在一个类下面,但是定义为静态方法,这就成了与上下文与属主无关的纯函数。

上面这段话啰嗦来啰嗦去,有用吗?有。Go语言提供了一个机会让“函数”和“方法”和平共处,并且共处地十分和谐,还能一目了然他们的关系。在《学习Go语言》一书中,没有像我这样啰嗦了这么一大段来说明函数和方法的关系,而是用了一句话 直截了当地概括了他们的关系。

方法就是有接收者的函数。

我们简单来看一下函数签名是什么样:

type Integer int  //这是一个简单的类型的定义,后面会说到,暂时这么一看

func plus(x Integer, y Integer) Integer {
    return x + y
}

这是纯函数的做法,也就是一个C语言式的做法。

如果我为Integer这个新的类型添加一个方法来做呢?让它变得更加面向对象,也就是“方法”的做法也就是如下:

type Integer int
func (x Integer) plus(y Integer) Integer {
    return x + y
}

我们可以看到,方法比函数多了一点东西,

func (x Integer) plus(y Integer) Integer

在关键字func和方法名中间用括号将x Integer括起来,x Integer在这里,不但是函数参数,而且还是这个方法的类型,或者叫方法接受者,这样一来,在Java、C++、C#中的隐藏指针this就被显性的当作参数名来使用,简洁明了,设计非常之巧妙,还将方法和函数在同一个语言中和谐的并存,但是这里有两种形式,一种是普通形式,一种是指针形式。

我们定义了一个Integer类型,并为这个类型添加了与这个类型相关联的方法,这个“方法”就可以操作这个类型。就像C++一样。

以上的函数和方法的例子,可以写在用一个源文件里,写一个main函数,并输出测试一下:

package main

import (
    "fmt"
)

func main() {
    fmt.Println(plus(1, 6))
    var a Integer = 3
    fmt.Println(a.plus(7))
}

type Integer int

func plus(x Integer, y Integer) Integer {
    return x + y
}

func (x Integer) plus(y Integer) Integer {
    return x + y
}

这段测试也如我们意料那样,输出

7
10

这一节当中实际上搀和了一些新的概念,比如定义类型。实际上这里还有一个值传递或引用传递的问题,会在以后的章节中再讨论。

关于包这个问题,在《Go语言编程》一书中前几章连提都没有提(翻了一下后面也没有找到),对于学习者来说这是语言类书籍作者的重大失误。

我们来看包的概念和使用。

包是函数和数据的集合。我们用package关键字定义一个包。文件名不需要与包名一致,在定义包名时,是使用小写字符。Go的包可以由多个文件组成,只要使用相同的package [name]这一行。

并且在编译时,要让你定义的包都存于你当前的\$GOPATH中,以确保Go编译器能够在\$GOPATH路径变量中寻找到这个包。

关于包,还有一个很重要的规则约定。import语句在导入包时,实际上是导入包的路径。详细一点来说,当我们导入一个包(这个名称可能是一个路径),例如fmt包,我们会写作import “fmt”,实际上Go的编译器会去到\$GOPATH/src目录下去寻找fmt目录,从而在这个目录下的所有同包名(package 后面名称相同)的go文件去寻找引用的标识符。如果找不到,则会抛出undefined符号错误。如果是在\$GOPATH/src目录中的更次级目录,那就需要有目录分隔符分割目录名。

在使用这个包当中的内容时,要使用package中定义的内容定义的包名,而不是路径名。我们可以进行一个最佳实践去学习这几个麻烦的包访问规则。我们在上面练习了两数相加的范例,依然使用这个范例来演示。

首先我们在某一个\$GOPATH/src下新创建一个mymath目录,在mymath目录中创建一个simple目录,在simple目录中创建一个math.go文件,把我们上面的练习代码复制进来并且保存,并写对包名。

// mymath project mymath.go
package mymath

type Integer int

func Plus(x Integer, y Integer) Integer {
    return x + y
}

func (x Integer) Plus(y Integer) Integer {
    return x + y
}

如此一来,我们已经创建好了一个简单的包,名叫mymath。

我们在LiteIDE中创建一个新的项目和GO文件(要保证LiteIDE和GO编译器能正确访问到\$GOPATH变量)并写以下测试代码:

// Syntax project main.go
package main

import (
    "fmt"
    "mymath/simple"   //我们可以看到,引入的包名实际上是相对路径
)

func main() {
    fmt.Println(mymath.Plus(1, 6))      //在使用时依然是使用package mymath定义的包名mymath定义的包名
    var a mymath.Integer = 3
    fmt.Println(a.Plus(7))
}

另外,如果一些包的包名起名字起的太奇怪,或者太长或者…… 反正不喜欢,或者不能表达这个包的作用时,还可以为这个包名起一个临时的别名。用法是

import [alias] "mymath/simple"

如果是多个导入包?见代码:

// Syntax project main.go
package main

import (
    p "fmt"
    m "mymath/simple"
)

func main() {
    p.Println(m.Plus(1, 6))
    var a m.Integer = 3
    p.Println(a.Plus(7))
}

这两个导入包分别用一个简单的名字代替了,为了体现简单所以我用一个字母代替了,未成年人请勿模仿。

访问控制和标识符

Go的命名是有特定的约定的,这个约定直接影响着代码的逻辑。 - 在一个包内的函数名类型名等命名影响是否可以导出到其他包使用,这取决于这个名称开头是 - 否为大写,只有大写的命名才允许导出到其它包中使用。 - 这意味着在其他的包当中,如果你的包有一个函数名或者类型名开头是小写,那么你就相当于 - 为这个函数或者类型设定为private,不允许包外使用。

值类型和引用类型

Go语言中大多数类型都基于值类型 其中包括: - 基本类型:如byte, int, bool, float32, float64和string等。 - 复合类型:如数组(array)、结构体(struct)和指针(pointer)等。

其中数组类型更加特别一些,它的行为与我们所了解的C系语言不一样。C的数组变量的变量名为首位指针,作为数组引用,因此数组变量赋值给其他变量时,实际上传递的是数组(或者某位置上的)的引用。数组还是那个数组,内存位置还是那个位置。而Go不一样,赋值时是将值完全复制。光说不练假把式,写个例子来直观表现一下:

import (
    "fmt"
)

func main() {
    //定义一个数组
    var a = [3]int{1, 2, 3}
    //然后赋值
    var b = a
    //为其中一个元素重新赋值
    b[1] = 100
    //结果是影响了一个数组还是两个数组呢?
    fmt.Println(a, b)
}

我们拿来看一下结果:

[1 2 3] [1 100 3]

很明显,Go中的数组赋值实际上是完整复制,而不是引用赋值。

当然,要实现C语言的引用赋值也不是麻烦事。Go当中也有取引用符”&“和指针变量符”*“比如:

var a = [3]int{1, 2, 3}
//通过取a的引用地址,此时b变量为指针变量,引用a
var b = &a

b[1] = 100
//打印时,*b将指针变量引向正确的数据区,打印数组
fmt.Println(a, *b)

执行结果为

[1 100 3] [1 100 3]

注意:

这里看到代码汇总有一句跟上面范例当中一样了不?
对,就是b[1] = 100,
变量b的类型不是[3]int,而是*[3]int类型
下面这一句fmt.Println(a, *b)就用了*b,是作为指针变量去解引用使用数组值。
那么实际上这里也应当用(*b)[1]指针指向数组的形式来解析b的引用,但是使用b[1]
运行也是对的,我也不知道为什么b[1]直接使用也是对的,书上也没有写是什么原理导致这个问题。
难不成是语法糖?

Go除了数组外,还有数组切片,map,channel(goroutine)通信量,和interface,他们看起来像是引用类型,但是他们实际上都是值类型。map和channel本身就是指针,因此他们赋值时复制指针而不是数据内容,因此他们依然是值类型。这一点是跟C、C++、Java、C#是有相同点的,但是依然要小心使用。另外,接口、结构体、指针等话题后面还会更多的提到。

结构体

Go的结构体可以看作跟C语言结构体差不多的东西。但是结构体不是类,Go语言当中也没有类这个概念,所以可以看出来,Go语言并不是真正的面向对象语言。然而实现并不是必须有类和对象的概念才能做面向对象编程。在Go中,一切引用类型之间的关系只有组合关系,没有继承关系。定义一个结构体类型也是非常简单的

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

并且为Rect这个类型“附着”一个方法:

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

初始化的方式也有好几种:

import (
    "fmt"
)

func main() {
    rect1 := new(Rect)
    rect2 := &Rect{}
    rect3 := &Rect{0, 0, 200, 300}
    rect4 := &Rect{width: 200, height: 300, x: 100, y: 100}

    fmt.Println(*rect1)
    fmt.Println(*rect2)
    fmt.Println(*rect3, (*rect3).Area())
    fmt.Println(*rect4, rect4.Area())
}

运行结果为:

{0 0 0 0}
{0 0 0 0}
{0 0 200 300} 60000
{100 100 200 300} 60000

因为不是面向对象语言,所以没有构造函数,我们甚至可以“捏造”一个构造函数:

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

那么上面就多了一种初始化方式:

rect5 := NewRect(1, 2, 200, 300)

方法接受者

在方法那一篇说了一下,方法的接受者参数有两种形式,一种是普通形式,另一种是指针形式。

注:我把这一部分放在这里,是因为有些东西要把类的概念说明白,才能继续介绍方法接受者参数有关问题。在介绍组合的时候会用到这部分讲解的内容。

上面方法间接一节说到方法的形式,可以为方法接受者定义为(a Integer)plus(b Integer)这种形式,这种普通形式实际上是对结构体的整个实例的副本进行操作,并非在这个实例本身进行操作。 但是如果我需要对实例进行操作呢?那么就不能使用这种普通形式,而是指针式方法接受者。也就是 (a *Integer)plus(b Integer),此时Go语言得知方法接受者实际上是个指针,在使用时便会自动解引用,你觉得需要(&a).吗?不用了!直接a.就可以访问指针所指对象的成员了。同样的道理,数组的访问也是如此,一旦判断你打算通过指针变量去访问成员,Go会自动为我们解引用,这就是为什么上面有一个例子提出了一个疑问,使用*b[1]和b[1]同样都可以访问数组成员。想必这是Go带来的一个指针的安全特性。 展示一个小栗子来验证我们的说法:

// method
package main

import (
    "fmt"
)

type Book struct {
    Title  string
    Author string
}

func (b *Book) print() {
    fmt.Print("\tTitle:", b.Title, "\n\tAuthor:", b.Author)
}

//这里的方法接收者使用了指针,可以像C/C++,Java那样操作实例本身
func (b *Book) setTitle(title string) {
    b.Title = title
    fmt.Println("setTitle:", b.Title, "\t", b.Author)
}

//这里不使用指针,用普通类型的方式作为接收者
//如此一来原来的实例只是参与方法中计算的一员,而不是针对这个
//这个实例操作的方法了
func (b Book) setAuthor(author string) {
    b.Author = author
    fmt.Println("setAuthor:", b.Title, "\t", b.Author)
}

func main() {
    var book Book = Book{Title: "未知书名", Author: "佚名"}
    book.setTitle("Scala从入门到疯人院")
    book.setAuthor("火云邪神")
    book.print()
}

运行结果为:

setTitle: Scala从入门到疯人院   佚名
setAuthor: Scala从入门到疯人院      火云邪神
    Title:Scala从入门到疯人院
    Author:佚名

内嵌结构体/组合/匿名字段

我尚且称之为组合,因为内嵌结构体匿名字段这个名字有点长。

Go的组合很有意思,组合可以实现类似面向对象语言的继承特性。(注意,这里是重头戏)一个结构体类型在另一个结构体类型中作为一个成员的的时候,使用外层的结构体可以直接暴露并直接访问使用内部结构体的所有导出的方法和成员变量。什么意思呢?

我们来写一下代码:

//首先先写一个基础结构体
type Base struct {
    Name string
}

为这个结构体附加方法:

func (base *Base) ComputeLen() {
    fmt.Println("base", len(base.Name))
}

现在我们有了一个有方法的结构体了,然后再写一个组合的结构体:

type House struct {
    Base
    Layers int
}

直接把Base作为一个成员写进这个结构体,此时这个House结构体相当于组合了Base,它拥有了Base的方法和成员变量。也就是说,原本我们要访问ComputeLen方法,本来应该这么访问

    h := House{Layers: 30}
    h.Base.ComputeLen()

其实也可以直接访问:

    h := House{Layers: 30}
    h.ComputeLen()

这就是Go语言通过组合的方式提供了“继承”的功能。不但有“继承”,还可以重写方法,比如当前这个例子,我们重写ComputeLen方法:

func (house *House) ComputeLen() {
    house.Base.ComputeLen()
    fmt.Println("house", len(house.Name)+house.Layers)
}

此时,h.ComputeLen()调用的方法就不是h.Base.ComputeLen()方法了,而是重写过的方法,而重写过程中访问组合来的方法,还是通过h.Base.ComputeLen()来调用。如此一来,省掉了Java、C++使用的this指针和super关键字,还让组合的结构体之间的关系十分透明而优雅。另外,指针成员方式也可以提供继承。不过需要外部赋予一个实例的指针。

type House struct {
    *Base
    Layers int
}

如果组合的结构体之间遇到同名的成员,内部的成员仍然还是作为间接的访问访问到的,而直接访问仍然是外部的成员。

接口

虽然关键字和Java的接口一样,而且定义很相像,但是这个接口不同于Java的接口,它不是需要强制实现的,而是契约式实现的,或者说是具体实现的一个“投影”。Java、C#的接口,C++的纯虚类,都是这种强制实现的接口。这种接口在我开发Android应用时就已经十分清楚这种痛苦。要使用接口,那可能是我在需要有解除高度耦合的代码,并且设计可替换模块的时候,或者设计多种行为策略的时候,需要高度的远见性,才能设计出可能将来会持续合理的接口。甚至有时会因为相同的行为定义很多重复角色的接口,但是必须要在不同的包下,有时有些可能完全可以互通的行为,解耦之后需要编写桥接类或者适配器类才能使用对方,这就造成了Java或者C#这类语言在编写业务时,因为模式带来的大量的冗余。因此,Java、C#等语言的接口被称作是“侵入式”的。

Go非侵入式接口

在Go语言中,一个类只要实现了接口要求的所有函数,我们就说这个类实现了该接口,而不需要去显式地使用implement或者:等符号告知编译器实现某接口。Go的接口实现为接口制造的是“投影”,而不是“行为模型”。因此也不用去考虑绘制像Java那样的巨大的类、接口继承树图,而是一种更扁平的思考方式。因此在实现类的时候,不用再费心考虑接口如何拆分方法更合理。接口就可以按照使用来造型,而不用费尽心思像算命一样去预测未来地那样规划。除此以外,自由的定义接口还可以降低包之间的耦合度,只要”投影相同”,就可以使用。

说了这么多,接口怎么用呢?看一个例子就知道了

//1.先定义具体的结构
type Bike struct {
    Name string
}
//添加方法
func (b *Bike) Run() {
    fmt.Println(b.Name)
}
//定义接口
type Brand interface {
    Run()
}


func main() {
    var bike Bike = Bike{Name: "Gakki"}
    bike.Run()
    //那么只要接口的方法都已经完全被结构体所实现,那么这个接口无论什么名称,还是在哪个包下,那么它的是一定被实现的,所以结构体的实例可以被直接“造型”为接口实例
    var br Brand = new(Bike)
    br.Run()
}

接口赋值

赋值有两种情况:

我们看一个例子:

func main() {
    var a Integer = 1
    var b LessAdder = a
    fmt.Println("Hello :", b)
}

type Integer int
//实现了LessAdder的两个方法
func (a Integer) Less(b Integer) bool {
    return a < b
}

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

type LessAdder interface {
    Less(b Integer) bool
    Add(b Integer)
}

注意:这里方法的所属结构(a Integer)不能含有指针,具体为什么,尚且不清楚。
《Go语言编程》一书当中,出现了
func (a *Integer) Add(b Integer) {
    *a += b
}
这种形式,但是我在Go 1.6.2的经过测试,这种形式已经不能使用了。
否则会报告如下错误:
cannot use a (type Integer) as type LessAdder in assignment:
Integer does not implement LessAdder (Add method has pointer receiver)

在上一节的范例中,有一个结构体是

type Bike struct {
    Name string
}

这个结构体有一个方法:

func (b Bike) Run() {
    fmt.Println(b.Name)
}

它和

func (b *Bike) Run() {
    fmt.Println((*b).Name)
}

甚至

func (b *Bike) Run() {
    fmt.Println(b.Name)
}

这三种方式都是等同的。

Bike结构的Run()方法,这三种形式编译运行是等同的效果。

但是!请注意:

如果我们需要用switch (type)来对对象进行实例类型判断,就只能用

func (b Bike) Run() {
    fmt.Println(b.Name)
}

这种方式再对实现了接口的类型进行判断,否则会编译失败。

类型判断

比如我们把上面的例子进行如下修改:(为了便于阅读,特意修改了一下顺序)

package main

import (
    "fmt"
)

type Brand interface {
    Run()
}

type Bike struct {
    Name string
}
//这个方法,就不能在方法属主上包含有指针了,(b *Bike)就是错误的
func (b Bike) Run() {
    fmt.Println(b.Name)
}

func main() {
    var bike Brand = Bike{Name: "Gakki"}
    //通过.(type)语法对实例进行判断。不过可惜,现在只能用switch
    switch c := bike.(type) {
    case Brand:
        fmt.Println("Brand 类型", c)
        c.Run()
    }
}

switch [实例].(type)只能以switch配合type使用,这一点并不如java中的instanceof关键字返回布尔类型更灵活。

接口组合

接口和结构体一样,也可以进行组合,而且也有相同的组合特点。前面介绍结构体的时候已经介绍过,我们现在修改上面的例子来体现:

// interface project main.go
package main

import (
    "fmt"
)

type MyAge interface {
    ShowAge()
}

type Brand interface {
    Run()
}

type BrandAndAge interface {
    MyAge
    Brand
}

type Bike struct {
    Name string
    Age  int
}

func (b Bike) Run() {
    fmt.Println(b.Name)
}

func (b Bike) ShowAge() {
    fmt.Println(b.Age)
}

func main() {
    var bike Brand = Bike{Name: "Gakki", Age: 7}
    switch c := bike.(type) {
    case BrandAndAge:
        fmt.Println("BrandAndAge 类型", c)
        c.Run()
        c.ShowAge()
    }
}

最后的执行结果为:

BrandAndAge 类型 {Gakki 7}
Gakki
7

Any类型

类似于Java中的Object类型,Go中有一个interface{}作为任意类型的名称。可以在类型定义中使用,也可以在传参时使用。比如刚刚看过去的上面的那个例子,

var bike Brand = ...

就可以修改为:

var bike interface{} = Bike{Name: "Gakki", Age: 7}

后面可以通过类型判断了解到这个实例有什么行为方法可以使用。作为函数参数时,还可以作为任意类型传入。比C语言中的(void *)表示要好得多。C语言中因为void是无类型,但是无类型指针却表示的是任意类型,且可以造型为任意类型。这着实让人蛋疼了一把。

面向对象的特性先介绍到这里。我们可以看到,Go语言的类型系统的基本特性,实际上就一种特性:组合。接口的特性其实也只有两种:组合和“造影实现”。“造影实现”这个词是我编的,因为Go语言完全不需要你预先设计接口并且让一个类去实现,而是实现和接口设计完全分离,他们之间的关联关系仅仅是方法的模型一致,即实现了方法。这一点上,他的思想要比Java和复杂的C++的虚类型系统简直好太多,因为在Java和C++中,开发者需要有深厚的技术经验和业务经验来“算命”,“算”好了接口去设计,然而Go中并不这么做。Go在这一点点可以“无为而治”之。接口真正成为一个结构体的“影子”,而不是像Java那样的“帽子”,Java对象没有这个“帽子”,实现了接口的行为,接口也不会承认。

下一次将开始更加重头的重头戏,Go语言的并发编程

后注:Go语言编程一书没有提到`方法接受者`和`包`这个事情,在后面的博文里读者也会发现我搜集了其他相关书籍资料,这本书在面向对象章节缺失较多。方法接受者特性这一段也是我在看了一些资料后,才补上的。在继续学习包的有关机制时,才意识到书的作者真是马虎大意,缺了特别多重要的语言性质的介绍和说明。有时候真是不能只抱着一本书看,否则真是缺漏很多东西,还好在学习期间发现有诸多问题,去找官方资料和第三方资料,才得以补充。