同学们好!在这节课里,我们将一起学习Go语言的面向对象语法。时至今日,面向对象编程早已不再是什么时髦的编程理念和奇技淫巧,它几乎成为一切现代编程语言的标配。平心而论,过度的面向对象往往会使人为的复杂性徒增几个数量级。无论是C++还是Java在这方面都表现得差强人意。Go语言对此做了深刻地反思。我们真的需要那么复杂吗?或者说我们真的需要把它设计得那么复杂吗?我们为未必会出现的未来所付出的代价是否已经超出了现实问题本身?


面向对象的三大特性,封装、继承和多态,在Go语言中是如何体现的?C++语言有一套繁复的访问控制规则,而Go语言则从另一个层面对访问控制给出了全新的定义。接口乃抽象之源。执着于极简主义的Go语言在抛弃了那么多之后居然保留了接口,这使得Go语言也和其它一切面向对象语言一样,具备强大且灵活的抽象建模能力。


作为面向对象的第一大特性,封装是指在内部包装和隐藏对外部公开的抽象接口的实现细节。封装可被视作一种保护机制,防止内部数据和代码被外部代码随意访问。一切对内部数据和代码的访问,都必须通过严格定义的接口进行。封装最大的价值在于内部实现的任何修改,都不会影响仅依赖于接口的外部代码。恰到好处的封装令我们的程序代码更加安全,也更易于理解和维护。


与C++和Java语言类似,Go语言也有类的概念,但却没有class关键字。Go语言通过为结构体绑定方法来表达类的语义。为结构体绑定方法与定义函数,在语法层面并没有本质性的差别,只是在func关键字和方法名(函数名)之间插入一个类(结构体)类型的对象(变量)或指针。对象和指针的区别在于该方法所接收到的是调用对象的副本还是地址。如果是副本,方法只能读取调用对象但却无法修改它,而如果是指针,方法既能读取调用对象,也能修改它。


下面我们通过一个Class工程,体验一下Go语言中类的用法:

我们不妨使用面向对象的语言描述这段代码。这里我们定义了一个Student类表示学生,该类包含四个属性,name、age、gender和score,分别表示学生的姓名、年龄、性别和学分。接着我们为这个Student类定义了三个方法。Who方法用于自我介绍,Eat方法用于进食,Learn方法用于学习。Who方法被定义在类对象上,方法只能得到调用对象的副本,对副本对象学分属性的修改并不能影响到调用对象。Eat方法和Learn方法被定义在类指针上,方法获得的是调用对象的地址。因此Learn方法成功地修改了调用对象的学分属性。Eat方法虽然无意于对调用对象做任何修改,但仅就效率而言,指针传递也要优于对象复制。熟悉C++语言的开发者对this指针一定不会感到陌生,类方法中的类指针其实就是显式的this指针。Learn方法索性将其命名为this,当然这并不是强制的。


作为面向对象第二大特性,继承就是通过让子类继承基类的属性和方法,使子类对象具有基类对象的特征和行为。继承最大的价值在于使复用既有代码变得非常容易,极大地缩短开发周期,降低开发成本。


Go语言没有提供专门针对继承的语法结构和关键字,但可以通过结构体嵌套近似地表达继承语义。只要在结构体B中包含了结构体A,我们就说子类B继承了基类A,或基类A派生了子类B。基类的所有属性和方法都可被视为子类的属性和方法。子类如果有和基类同名的属性和方法,默认选择子类版本,除非通过基类类名显式指明。


下面的Inherit工程演示了在Go语言中使用继承的方法:

这段代码描述了由三个类组成的继承结构。基类Human表示一般意义的人。它包含三个属性,name、age和gender,表示人的姓名、年龄和性别。作为人,他具有自我介绍和进食两种行为,分别用Who方法和Eat方法表示。学生是一种特殊的人。表示学生的Student类从表示人的Human类中派生,是Human类的子类,除了拥有Human类的一切属性和方法外,还具备自己特殊的属性score,表示学分。同时Student类提供了自己特有的Who方法和额外的Learn方法,分别用于自我介绍和学习。教师是另一种特殊的人。表示教师的Teacher类是表示人的Human类的另一个子类,除了拥有Human类的一切属性和方法外,还具备自己特殊的属性salary,表示工资。同时Teacher类提供了自己特有的Who方法和额外的Teach方法,分别用于自我介绍和教学。在这个继承结构中,我们把学生和教师作为人的共有属性,如name、age、gender,和方法,如Who、Eat,抽象出来,形成一个公共基类Human,而在Student和Teacher这两个子类中只需着意刻画其各自拥有的特殊属性,如score、salary,和方法,如Learn、Teach。同时对基类中已有但子类可以表达得更好的成员,如Who,也可以给出全新的定义。当然借助基类的类名,通过子类对象访问这些成员在基类中的原始定义也是可行的。


访问控制体现了一种内外有别的访问能力的限制。同处一个逻辑单元内部的代码,相互访问总是被允许的,但当这些代码被从单元之外访问时,哪些能访问,哪些不能访问,往往需要加以限定。这对于维护每个逻辑单元的完整性、一致性和自洽性,有着十分重要的意义。


与C++和Java语言在类级别上设置繁复的访问控制规则不同,Go语言的访问控制更多体现在跨包访问上。在Go语言中,类和类的成员,包括属性和方法,以其名字的首字母大小写确定其可被访问的范围。名字首字母大写的类和类成员,既能被包内的代码访问,也能被包外的代码访问。名字首字母小写的类和类成员,只能被包内的代码访问,不能被包外的代码访问。


下面我们通过一个Scope工程验证访问控制在跨包访问上的应用。首先我们在工程目录下创建一个包目录,名为Math,在该目录下创建一个Go文件,calculator.go,隶属于math包,其中只有一个类Calculator,表示计算器,该类的Add和Sub方法,分别完成对两个整数的加法和减法计算:

位于main包中的main方法,以跨包方式访问,位于math包中的Calculator类及其成员:

在这段代码中,math包中计算器类的类名Calculator,属性名X和Y,方法名Add和Sub,全部采用首字母大写的形式,故可被身处math包之外的main函数访问。我们可以尝试一下,把其中的某些命名改为小写字母开头,看看会有什么结果?


有关接口,自古以来就存在广义和狭义两种解释。广义的接口是指一个类的方法集合,是一种针对对象行为的逻辑抽象。狭义的接口是一种特定的语法结构,其中只包含方法的特征,却没有方法的实现,这些方法可以被不同的类实现,进而表现出不同的行为,完成不同的操作,满足不同的需求。Go语言的接口主要面向两种应用场景,一种是泛型,另一种是多态。有关多态,我们将在稍后为大家介绍,这里首先关注泛型。


接口是一种独立的数据类型,即接口类型。它的语法形式是在interface关键字的后面用一对花括号括起一系列方法的签名。所谓方法签名就是只有方法名、形参表和返回类型表,但没有方法体的方法。方法名前面甚至不需要func关键字。接口中也可以不包含任何方法,即所谓空接口。空接口类型的变量可以接收其它任何类型的数据。这种用法有点类似于在C/C++语言中,用void*类型的指针接收任意类型数据的地址。Go语言借助空接口实现了泛型。一个空接口类型的数组或切片可以存放不同类型的元素,而带有空接口形参的函数则可以接收任意类型的实参。这些都体现了Go语言对泛型概念的支持。


下面我们创建一个名为Interface的工程,体验一下在Go语言中如何利用空接口实现泛型编程:

在这段代码中,我们首先定义并初始化了四个变量,a、b、c、d,其类型分别为,整型、字符串型、布尔型、双精度浮点数切片型。之后将它们对应赋值给四个空接口型变量,e、f、g、h。通过对这四个空接口变量的类型和值的打印输出,我们不难发现,作为空接口类型的变量,不但可以接收其它任何类型的数据,而且还保留了这些数据的原始类型信息和值。i是一个空接口切片,其中包含了四个元素,分别用整型、字符串型、布尔型、双精度浮点数切片型的数据a、b、c、d初始化。在基于范围的for循环中依次打印其中每个元素的类型和值。我们看到和之前单个空接口变量一样的输出。foo函数带有一个空接口类型的形参,它可以结合任意类型的实参,并在开关分支结构中,执行与其实际类型相对应的操作。


多态的本义是指为不同数据类型的实体提供统一的接口,当通过该接口访问其中的方法时,不同数据类型的实体会表现出不同的行为特征。Go语言的多态是建立在接口和类的语法基础之上的。


在Go语言中利用接口和类实现多态的过程分为四个步骤。首先定义一个接口,其中包含方法的签名。其次定义一个类,实现接口中的方法。再次用接口类型的变量接收类类型的对象。最后通过接口类型的变量调用其中的方法,实际被执行的是定义在类中的方法。


在下面的Polymorphism工程中,我们将实际编写一段包含多态特性的代码:

在这段代码中,我们首先定义了一个名为Shape的接口,表示图形,其中只有一个方法,Draw,表示绘制。从概念层面讲,任何图形都应该是可以绘制的,但作为一个一般化的图形,它无法真正完成绘制的动作。接口最适合表达这种抽象性的概念。Rectangle和Circle是两个具体类,分别表示矩形和圆形,它们除了拥有自己特有的属性外,还实现了Shape接口中的Draw方法。因为矩形和圆形都是具体的几何图形,在具足一切属性的前提下,它们的Draw方法完全可以胜任实际的绘制动作。在main函数中定义了Rectangle和Circle两个类类型的对象,并将其作为参数传递给函数draw。函数draw通过Shape接口类型的参数接收这两个对象,并调用其中的Draw方法,完成具体的图形绘制。这里需要强调的是,函数draw并不知道从参数传入的具体是什么对象,它只知道是一个类型为Shape的图形,然后调用Shape接口中的Draw方法,激发图形的绘制行为,而实际被执行的其实是Rectangle类或Circle类中的Draw方法。令同一种类型,表现出不同的外观,这就是多态的本质。


谢谢大家,我们下节课再见!