对于面向对象编程的支持 Go 语言设计得非常简洁而优雅。简洁之处在于,Go 语言并没有沿袭传统对象的诸多概念,比如继承、虚函数、构造函数和析构函数、隐藏的this指针等。优雅之处在于,Go 语言对面向对象编程的支持是语言类型系统中的天然组成部分。
类型系统
类型系统是一门语言的地基,是指一个语言的类型体系结构。一个典型的类型系统包含如下基本内容:
- 基础类型:如 byte、int、bool、float 等
- 复合类型:如数组、结构体、指针等
- 可以指向任何对象的类型(Any 类型)
- 面向对象,即所有具备面向对象特征(比如成员方法)的类型
- 接口
- 值语义和引用语义,值语义的意思是,进行赋值或者传参,其实会被复制一份。而引用语义,就是说赋值的其实是引用值,不会对内容进行赋值。
下面举个值语义的例子,如下所示,v2 的修改不会影响 v1 的值,那么就是值语义了。
var v1 = [3]int{1, 2, 3} var v2 = v1 v2[1]++ fmt.Println(v1, v2) // [1 2 3] [1 3 3]
再举个引用语义的例子,如下所示 v2 的修改会影响 v1 的值,那么就是引用语义了。
var v1 = []int{1, 2, 3} var v2 = v1 v2[1]++ fmt.Println(v1, v2) // [1 3 3] [1 3 3]
Java的类型系统
下面我们以 Java 语言为例来讲解一下类型系统,在 Java 中,有两套完全独立的类型系统:
- 一套是值类型系统,基于值语义,如byte、int、boolean、double等
- 一套是对象类型系统,基于引用语义,这些类型可以定义成员变量和成员方法,可以有虚函数等,只允许在堆上创建。
Java的Any类型就是整个对象类型系统的根——java.lang.Object类型。只有对象类型系统中的实例才能被Any类型所引用。值类型想要被Any类型引用,需要装箱(boxing),比如int类型需要装箱成Integer类型。另外,只有对象类型系统的类型才能实现接口,从要实现的接口进行继承即可。
Go语言的类型系统
下面说说Go语言的类型系统,Go语言中大多数类型都是值语义,并且可以包含对应的操作方法。在需要的时候,可以给任何类型(包括内置类型,但不包括指针类型)“增加”新方法。而在实现某个接口时,无需从该接口继承,只需要实现该接口要求的所有方法即可。任何类型都可以被空接口,也就是 interface{}
所引用,因此空接口就是Any类型。
上面提到在Go语言中大多数都是值语义,那么这些值语义包括
- 基本类型:如byte、int、bool、float32、float64和string
- 复合类型:如数组、结构体、指针等
其中尤其需要注意数组,在C语言中,通过函数传递一个数组的时候是基于引用语义的,但是在结构体中定义数组变量的时候又是值语义了,因为在为结构体赋值的时候,该数组会被完全复制。而Go语言的数组是完完全全的值类型,如下所示:
var v1 = [3]int{1, 2, 3}
var v2 = v1
v2[1]++
fmt.Println(v1, v2) // [1 2 3] [1 3 3]
Go语言中有4个类型比较特殊,看起来像引用类型:数组切片、map、channel、接口。
那么还有4个类型比较特殊,看起来像引用类型,分别是:数组切片、map、channel、接口。使用如下所示,v2的改变会影响v1,v4的改变会影响v3:
var v1 = []int{1, 2, 3}
var v2 = v1
v2[1]++
fmt.Println(v1, v2) // [1 3 3] [1 3 3]
var v3 = map[string]int{"mp": 1}
var v4 = v3
v4["mp"] = 3
fmt.Println(v3, v4) // map[mp:3] map[mp:3]
但是还是可以将Go语言类型系统看成完完全全的值语义。比如数组切片本质上是一个区间,可以将数组切片大致表示为如下所示,由于是一个结构体,所以实际在赋值或传参的时候还是值语义,也就是说数组切片类型本身的赋值还是值语义。但是由于该结构体中有一个 first *T
的指针,所以在赋值给新变量之后,对新变量的操作会同时影响旧变量,所以使得表面上看起来是引用类型了。
type slice struct {
first *T // for example: first *int
len int
cap int
}
同理map、channel、interface都是类似的。那么总的来说Go语言的类型系统是基于值语义的,将数组切片、map、channel、interface类型设计成引用类型而不是统一的值类型的原因是复制channel或map并不是常规需求。
Python的类型系统
个人觉得python就是基于引用语义的,因为python的所有变量其实都是引用值。
面向对象
类---结构体
Go语言中的结构体(struct)就等同于其他语言中的类,比如想要定一个矩阵类型的,并且含有计算面积的 Area()
方法的话,如下所示即可。
type Rect struct {
x, y float64
width, height float64
}
func (r *Rect)Area() float64 {
return r.width * r.height
}
上述中 struct 中的内容相当于是这个“类”的成员变量,后面的 Rect
或者 *Rect
写在函数名前面表示是 Rect 这个类型的成员方法。
从上面其实可以看到,Go语言的结构体只是很普通的复合类型,上述只是相当于给Go语言的类型绑定了一个方法而已。
另外在后面可以看到Go语言抛弃了包括继承在内的大量面向对象特性,保留了组合这个最基础的特性,组合是形成复合类型的基础。而组合又不算面向对象的特性,因为在C语言这样的过程式编程语言中,也有结构体,也有组合。
对象实例创建及初始化
上述定义 Rect 类型之后,可以通过如下几种方法来创建并初始化一个 Rect 类型的对象实例
rect1 := new(Rect)
rect2 := &Rect{}
rect3 := &Rect{0, 0, 100, 200}
rect4 := &Rect{width: 100, height: 200}
当然比较美观的方式是交由一个全局的创建函数来完成,以Newxxx来命名,表示“构造函数”,其实在Go语言中没有构造函数的概念,所以前面使用双引号引起来了。
func NewRect(x, y int, width, height float64) *Rect{
return &Rect{x, y, width, height}
}
Go的这种方式,使得对象实例在创建的过程中发生了什么都清晰明了,而不是像其他语言那样,在创建对象实例的时候,背后发生了很多看不到的事。
对象的调用
上面已经知道怎么定义一个类和创建对象实例了,那么下面讲一下怎么调用对象实例的成员变量和成员方法。如下代码所示
package main
import "fmt"
type Base struct {
Name string
}
func (base *Base) Foo() {
fmt.Println("base.Foo")
}
func (base *Base) Bar() {
fmt.Println("base.Bar")
}
func main() {
var base1 Base = Base{"multiparam"}
base1.Foo() // base.Foo
base1.Bar() // base.Bar
fmt.Println(base1.Name) // multiparam
var base2 *Base = &Base{"dawnguo"}
base2.Foo() // base.Foo
base2.Bar() // base.Bar
fmt.Println(base2.Name) // dawnguo
}
上面定义了一个 Base 类型,并实现了两个方法。在 main()
函数中,创建了并初始化了两个实例对象,一个实例对象是 Base 类型的,一个实例对象是 *Base 类型的,尽管这两个变量的类型不一样,但是都可以通过 .
调用相应的成员变量和成员方法。另外尽管 Base 的 Foo()
和Bar()
方法,前面都是 *Base,但是 Base 类型的仍旧可以调用 Foo()
方法或者 Bar()
方法,那么就相当于会进行自动转化一般。
那么函数名前面一定是要指针类型的嘛?当然不是了,只有在成员方法内需要修改对象的时候,前面才必须要用指针,因为这样传入的对象才能被修改。否则的话,由于结构体是值语义,传入的对象将会被复制一份,即使被修改,那么只有被复制的那份对象实例才会被修改。但是,一般情况下都是指针类型。
继承---匿名组合
实际上Go也提供了继承,只是采用的不是像其他语言那样从某个类继承的方式,而是采用组合的文法,我们称其为匿名组合,所以可以说Go抛弃了继承的特性,但是为了方便理解和对比,还是拿继承这个来说事。下面我们拿代码来说一下继承等概念
package main
type Base struct {
Name string
}
func (base *Base) Foo() {
println("base.Foo")
}
func (base *Base)Bar() {
println("base.Bar")
}
type Foo struct {
Name string
Base
}
func (foo *Foo) Bar() {
println("foo.Bar")
}
func main() {
foo := &Foo{"world", Base{"hello"}}
println(foo.Base.Name, foo.Name) // hello world
foo.Bar() // foo.Bar
foo.Foo() // base.Foo
foo.Base.Bar() // base.Bar
foo.Base.Foo() // base.Foo
}
上面的例子中,首先是定义了一个 Base 类,并且这个类中有两个成员方法 Foo()
和 Bar()
,然后又定义了一个 Foo 类,组合了 Base ,那么Base中相应的方法就相当于被“继承”了下来,但是之后 Foo 类改写了 Bar()
方法。所以使用 foo.Bar()
调用的是重写之后的 Bar()
方法,使用 foo.Base.Bar()
调用的是 Base 中的 Bar()
方法(这种方式相当于调用隐藏的方法),那么使用 foo.Foo()
和使用 foo.Base.Foo()
调用的效果是一样的。
同理,成员变量也会被“继承”下来,但是由于 Foo 中也有一个叫 Name 的成员变量,那么 Base 中的 Name 成员变量相当于被隐藏了。所以 foo.Name
调用的是 Foo 中的 Name, foo.Base.Name
调用的是 Base 中的 Name。那么我们将 Foo 改为如下所示,会更清楚一点
type Foo struct {
Author string
Base
}
func main() {
foo := &Foo{"chengxuguo", Base{"multiparam"}}
// multiparam chengxuguo multiparam
println(foo.Name, foo.Author, foo.Base.Name)
}
foo.Name
的值是 "multiparam"(相当于被继承下来了),foo.Author
的值是 “chengxuguo”,foo.Base.Name
的值是 “multiparam”。
之外,把上述的 Base 改成 *Base,组合的效果的是一样,只是在创建 Foo 实例的时候,需要传进去的是一个 Base 实例的指针。
type Foo struct {
Author string
*Base
}
另外Go语言的这种方式可以很清晰的告诉你,类的布局是怎么样的,比如下面的例子中,Base 的数据放到了 Foo 的最开始处,而语义上跟上面给的例子是一样的,只是内存布局发生了改变。
type Foo struct {
Base
Author string
}
匿名组合注意事项
-
匿名组合方法的调用本质
匿名组合中,被组合的类型所包含的方法虽然都升级成了外部这个组合类型的方法,那么被组合的方法被调用时,接受者并没有改变,还是被组合的类型。比如上面的例子中,
Foo()
被组合进来了,那么使用foo.Foo()
方式调用时,实际上这个函数的接收者(个人理解是指执行者)还是 Base 这个类型的,所以Foo()
函数只能访问 Base 类型中的东西。这个跟大部分面向对象语言是类似的,也就相当于继承只是把一个引用继承下来了,那么本体还是在父类中的。 -
名字冲突问题
匿名组合中,是把其类型名称(去掉包名部分)作为成员变量的名字。那么下面这个例子中, Y 类型中就相当于存在两个名为 Logger 的成员,所以会出错。
type Logger struct { } type Y struct { *Logger Name string *log.Logger }
非匿名组合---无继承效果
匿名组合相当于实现了“继承”的效果,那么非匿名组合也有“继承“的效果吗?显然不是。非匿名组合的话,就相当显示地指定被组合的类型的成员变量名,那么这个被组合的类型实际上成为了一个成员变量,相当于类型中包含了另一个类型,而没有“继承”的效果。如下代码所示:
package main
type Base struct {
Name string
}
func (base *Base) Foo() {
println("base.Foo")
}
func (base *Base) Bar() {
println("base.Bar")
}
type Foo struct {
Author string
BaseNew Base
}
func main() {
foo := &Foo{"chengxuguo", Base{"multiparam"}}
// multiparam chengxuguo multiparam
println(foo.BaseNew.Name, foo.Author, foo.BaseNew.Name)
foo.Bar()
foo.Foo()
foo.BaseNew.Bar()
foo.BaseNew.Foo()
}
Foo 中包含了一个 Base 类型的成员变量,那么这个成员变量就真的只是成员变量,Base 中的成员方法不会被“继承”,Base 中的成员变量也不会被”继承“,就好比一个类中把另一个类当成一个成员变量。所以上述方法会如下错误:
.\main.go:24:5: foo.Bar undefined (type *Foo has no field or method Bar)
.\main.go:25:5: foo.Foo undefined (type *Foo has no field or method Foo)
以上是作者看了许世伟的《Go语言编程》之后自己的总结
同理成员变量也是一样的,Base 中的成员变量会被继承下来,但是由于 Foo 中也有一个叫 Name 的成员变量,因此
可见性
Go 语言中没有 private、protected、public 这样的关键字,假如要想某个符号对其他包可见,需要将符号定义为以大写字母开头。如下,Rect 以及 Rect 的成员变量都可以在其他包中被访问到了(前提是其他包引用了 Rect 所在的包)。
type Rect struct {
X,Y float
Width, Height float64
}
成员方法的可访问性也是这样的,如下, area()
方法只能在其所在的包内被访问。
func (r *Rect)area() float64 {
return r.Width * r.Height
}
**另外 Go 语言中符号的可访问性是包一级的而不是类型一级的。**上面的 area()
方法虽然是 Rect 类型的成员方法,其他包访问不到这个方法,但是同个包内的其他类型其实都可以访问。
为什么Go中采用如此方式定义成员方法?
下面额外说说 Go 语言为什么采用上述那种方式来定义成员方法。C++等语言中的面向对象都只是相当于在 C 语言基础上添加了一个语法糖。下面我们就来阐述一下,下面我们以为 int 类型添加方法为例,面向对象的实现方式如下所示:
type Integer int
func (a Integer) Less(b Integer) bool {
return a < b
}
func main() {
var a Integer = 1
a.Less(2)
}
假如采用面向过程方式实现的话,如下所示
type Integer int
func Interger_Less(a Integer, b Integer) bool {
return a < b
}
func main() {
var a Integer = 1
Interger_Less(a, 2)
}
对面上述这两段代码,如下,我们可以发现面向对象只是换了一种语法形式表达了,从而使得 Go 语言的面向对象会很直接。
func Integer_Less(a Integer, b Integer) bool { // 面向过程的
return a < b
}
func (a Integer)Less(b Integer) bool { // 面向对象
return a < b
}
而 C++、Java等语言中的面向对象都有一个 this 指针 (python中的 self 和 this 指针效果一样),那么在 Go 语言中 this 指针其实被显露出来了,就是函数名前面的变量名。
另外这么实现有一个好处,就是方便给已知的类型添加新的方法,扩展已知类型的功能,如下代码所示。Header 其实是一个 map类型的,但是通过给 map 取一个别名就可以给 map 增加一些新的方法,那么它也成了一个全新的类型,但是这个类型又拥有 map 的功能。
type Header map[string][]string
func (h Header)Add(key, value string) {
......
}
这段内容可以参考参考,许世伟的《Go语言编程》,个人觉得许世伟在语言的思想上更为深厚。
语法糖(Syntactic sugar)指计算机语言中添加的某种语法,这种语法对语言的功能没有影响,但是更方便程序员使用。 语法糖让程序更加简洁,有更高的可读性。
有人说 C 语言虽然是面向过程的语言,但是 C 语言借用结构体也可以实现面向对象的效果。
接口
在讲解接口之前先来一些对接口赞美的摘记:
goroutine和channel支撑起了Go语言的并发模型的基石,让Go语言在如今集群化与多核化的时代成为一道极为亮丽的风景线。那么接口是Go语言整个类型系统的基石,让Go语言在基础编程哲学的探索上达到了前所未有的高度。
Go语言在编程哲学上是变格派,而不是改良派。这不是因为Go语言有goroutine和channel,更重要的是因为Go语言的类型系统,更是因为Go语言的接口。Go语言的编程哲学因为有接口而趋近完美。Go语言的接口不单单只是接口。
**接口是只包含方法,而不包含任何成员变量。**接口可以用于多态,将一个实现了接口的对象实例赋值给接口类型的变量,那么使用这个变量可以调用接口中的方法。下面举一个 Java 的例子。
接口在 Java 中使用的关键字是 interface,使用示例如下所示,为了实现一个接口,你需要从该接口继承。
interface IFoo {
void Bar();
}
class Foo implements IFoo {
// ...
}
IFoo foo = new Foo();
那么即使有另外一个接口 IFoo2 实现了与 IFoo 完全一样的接口方法,那么上面的 Foo 类也会被认为只实现了 IFoo 接口而没有实现 IFoo2 接口。这类接口被称为“侵入式接口”,“侵入式”主要表现为在实现类的时候,需要明确实现了某个接口。
但是 Go 语言中的接口并不是其他语言(Java、C++、C#等)中所提供的接口概念, Go 语言是非侵入式的,下面来说一下。
非侵入式接口
**在Go语言中,一个类只需要实现了接口要求的所有函数,我们就说这个类实现了这个该接口。**如下面代码所示,假如有以下这些接口
type IFile interface {
Read(buf []byte) (n int, err error)
Write(buf []byte) (n int, err error)
}
type IReader interface {
Read(buf []byte) (n int, err error)
}
type IWriter interface {
Write(buf []byte) (n int, err error)
}
之后定义了一个 File 类,虽然这个 File 类没有从这些接口继承,但是这个类实现了上述接口的方法,那么在 Go 语言中,还是相当于这个类实现了上述接口。
type File struct {
// ...
}
func (f *File)Read(buf []byte) (n int, err error) {}
func (f *File)Write(buf []byte) (n int, err error) {}
接口赋值
接口赋值在 Go 语言中分为两种情况:一种是将一个对象实例赋值给接口,第二种是将一个接口赋值给另一个接口。
将一个对象实例赋值给接口
对于将对象实例赋值给接口的话,那么就要求这个对象实例实现了接口要求的所有办法,并且对象实例中方法的类型和接口方法的类型也要一致。我们拿下面的代码做例子,我们为 Integer 添加了 Less()
方法(作者个人理解是 Less()
方法的类型是 Integer )、为 *Integer 添加了 Add()
方法,同时定义了一个 LessAdder 接口。
type Integer int
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)
}
那么我们可以创建一个对象实例并赋值给 LessAddr 接口,有以下这两种赋值方式,就这个例子来说,(1) 方法是可行的,(2) 方法是不可取的。
var a Integer = 1
var b LessAddr = &a (1)
var b LessAddr = a (2)
这是因为 Go 语言会根据 func (a Integer)Less(b Integer) bool
生成一个新的方法
func (a *Integer)Less(b Integer) bool {
return (*a).Less(b)
}
但是不能根据 func (a *Integer)Add(b Integer)
生成
func (a Integer)Add(b Integer) {
(&a).Add(b)
}
因为
(&a).Add(b)
改变的只是参数a
的值,对外部实际要操作的对象并无影响,这不符合方法的预期,所以不会自动生成。
所以对于 (1) 方式的赋值来说,接口中的方法都是 *Integer 类型的,对象实例中 *Integer 类型也存在 Less()
、 Add()
等方法,因为可以赋值,而对于 (2) 方式来说,接口中的方法都是 Integer 类型的,但是对象实例中 Integer 类型的只有 Less()
方法,所以赋值会失败。
上述作者本人将
Integer
或者*Integer
作为方法的类型,暂未得到书本上的确认,但是根据 demo 结果显示这么理解并无错误。比如下面代码中,将&a
赋值给b
,那么b
中的方法将会是*Integer
类型的,对象实例的方法也是*Integer
类型的,那么赋值是正确的。但是将a
赋值给b
,b
中的方法将是Integer
类型的,而对象实例中的方法又是*Integer
类型的,那么虽然方法名一致,但是类型不一致,赋值将会失败。package main type Integer int func (i *Integer) Add(j Integer) { *i += j } type Adder interface { Add(j Integer) } func main() { var a Integer = 1 var b Adder = &a // true // var b Adder = a // 出错 println(a, b) }
将一个接口赋值给另一个接口
一个接口赋值给另一个接口的话,这两种情况都可以:①这两个接口拥有相同的方法列表(次序无关),这种情况下,可以相互赋值;②接口 A 的方法列表是接口 B 的方法列表的子集,那么接口 B 可以赋值给接口 A。
下面举个例子,比如 one 包中实现了一个接口
package one
type ReadWriter interface {
Read(buf []byte) (n int, err error)
Write(buf []byte) (n int, err error)
}
two 包中实现了另一个接口
package two
type IStream interface {
Write(buf []byte) (n int, err error)
Read(buf []byte) (n int, err error)
}
这两个接口的接口名和所处的包以及方法列表的顺序都是不一样的,但是这两个接口之间可以相互赋值,也就是说任何 one.ReadWriter
接口对象都可赋值给 two.IStream
,反之亦然。如下面代码所示
var file1 two.IStream = new(File)
var file2 one.ReadWriter = file1
var file3 two.IStream = file2
针对第二种情况,假如有一个 Writer 接口
type Writer interface {
Write(buf []byte) (n int, err error)
}
那么下面这样的赋值是成立的
var file1 two.IStream = new(File)
var file2 Writer = file1
但是这样的赋值是不成立的
var file1 Writer = new(File)
var file2 two.IStream = file1
接口查询
**接口查询主要是查询接口指向的对象实例是否实现了某个接口。**接口查询的结果,要在运行期才能确定,不像接口赋值,在编译期就可以通过静态类型检查判断。
如下所示,就是判断 file 接口指向的对象实例是否实现了 two.IStream
接口,如果实现了的话则转为 two.IStream
类型,并返回 true 值。
file5, ok := file.(two.IStream)
下面是一段较为完成的示例,最终结果是输出 “hello”。
package main
type File struct {
}
func (file *File) Read(buf []byte) (n int, err error) {
return 0, nil
}
func (file *File) Write(buf []byte) (n int, err error) {
return 0, nil
}
func (file *File) Seek(off int64, whence int) (pos int64, err error) {
return 0, nil
}
func (file *File) Close() error {
return nil
}
type Writer interface {
Write(buf []byte) (n int, err error)
}
func main() {
var file1 Writer = new(File)
if file5, ok := file1.(*File); ok {
println("hello")
} else {
println("no", file5)
}
}
类型查询
**类型查询是指查询接口指向的对象实例的类型。**使用如下所示
var v1 interface{} = "hello,world"
switch v := v1.(type) {
case int:
fmt.Println("The type is int", v)
case string:
fmt.Println("The type is string", v)
default:
fmt.Println("Unknown type", v)
}
类型查询并不经常使用,更多是个补充,需要配合接口查询使用。
接口组合
像之前介绍的类型组合一样,Go语言同样支持接口组合。可以认为接口组合是类型匿名组合的一种特定场景,只不过接口只包含方法,而不包含任何成员变量。
有两个接口 Reader 和 Writer,如下所示
type Reader interface {
Read(buf []byte) (n int, err error)
}
type Writer interface {
Write(buf []byte) (n int, err error)
}
ReadWriter 接口组合了上述这两个接口,表示 ReadWriter 既能做 Reader 接口的事,又能做 Writer 接口所有的事情。如下所示,
type ReadWriter interface {
Reader
Writer
}
那么这种效果完全等同于下面这种。
type ReadWriter interface {
Read(buf []byte) (n int, err error)
Write(buf []byte) (n int, err error)
}
Go 语言包中还有类似的组合接口,比如 ReadWriteCloser、ReadWriteSeeker等。
Any类型
Go语言中任何对象实例都满足空接口interface{}
,所以interface{}
看起来像是可以指向任何对象的Any类型。如下
var v1 interface{} = 1
var v2 interface{} = "abc"
var v3 interface{} = &v2
var v4 interface{} = struct{X int}{1}
var v5 interface{} = &struct{X int}{1}