【在主画面加入捷径】
       
【选择语系】
繁中 简中

[Golang] 程序设计教学:建立类 (Class) 和物件 (Object)

【赞助商连结】

    传统的程序式程序设计 (procedural programming) 或是指令式程序设计 (imperative programming) 学到函式大概就算学完基本概念。不过,近年来,面向对象程序设计 (object-oriented programming) 是程序设计主流的模式 (paradigm),即使 C 这种非面向对象的语言,我们也会用结构和函式仿真物件的特性。本文将介绍如何在 Go 撰写面向对象程序。

    五分钟的面向对象概论

    由于面向对象是程序设计主流的模式 (paradigm),很多语言都直接在语法机制中支援面向对象,然而,每个语言支援的面向对象特性略有不同,像 C++ 的物件系统相当完整,而 Perl 的原生物件系统则相对原始。面向对象在理论上是和语言无关的,但在实务上却受到不同语言特性 (features) 的影响。学习面向对象时,除了学习在某个特定语言下的实现方式外,更应该学习其抽象层次的思维,有时候,暂时放下实现细节,从更高的视角看物件及物件间讯息的流动,对于学习面向对象有相当的帮助。

    面向对象是一种将程序代码以更高的层次组织起来的方法。大部分的面向对象以类 (class) 为基础,透过类可产生实际的物件 (object) 或实例 (instance) ,类和物件就像是饼干模子和饼干的关系,透过同一个模子可以产生很多片饼干。物件拥有属性 (field) 和方法 (method),属性是其内在状态,而方法是其外在行为。透过物件,状态和方法是连动的,比起传统的程序式程序设计,更容易组织程序代码。

    许多面向对象语言支援封装 (encapsulation),透过封装,程序设计者可以决定物件的那些部分要对外公开,那些部分仅由内部使用,封装不仅限于静态的数据,决定物件应该对外公开的行为也是封装。当多个物件间互动时,封装可使得程序代码容易维护,反之,过度暴露物件的内在属性和细部行为会使得程序代码相互纠结,难以调试。

    物件间可以透过组合 (composition) 再利用程序代码。物件的属性不一定要是基本类型,也可以是其他物件。组合是透过有… (has-a) 关系建立物件间的关连。例如,汽车物件有引擎物件,而引擎物件本身又有许多的状态和行为。继承 (inheritance) 是另一个再利用程序代码的方式,透过继承,子类 (child class) 可以再利用父类 (parent class) 的状态和行为。继承是透过是… (is-a) 关系建立物件间的关连。例如,研究生物件是学生物件的特例。然而,过度滥用继承,容易使程序代码间高度相依,造成程序难以维护。可参考组合胜过继承 (composition over inheritance) 这个指导原则来设计自己的项目。

    透过多态 (polymorphism) 使用物件,不需要在意物件的实现,只需依照其公开接口使用即可。例如,我们想要开车,不论驾驶 Honda 汽车或是 Ford 汽车,由于汽车的仪表板都大同小异,都可以执行开车这项行为,而不需在意不同厂牌的汽车的内部差异。多态有许多种形式,如:

    • 特定多态 (ad hoc polymorphism):
      • 函数重载 (functional overloading):同名而不同参数类型的方法 (method)
      • 运算符重载 (operator overloading) : 对不同类型的物件使用相同运算符 (operator)
    • 泛型 (generics):对不同类型使用相同实现
    • 子类型 (Subtyping):不同子类共享相同的公开接口,不同语言有不同的继承机制

    以面向对象实现程序,需要从宏观的角度来思考,不仅要设计单一物件的公开行为,还有物件间如何互动,以达到良好且易于维护的程序代码结构。除了阅读本教程或其他程序设计的书籍以学习如何实现物件外,可阅读关于 面向对象分析及设计 (object-oriented analysis and design) 或是设计模式 (design pattern) 的书籍,以增进对面向对象的了解。

    [Update on 2018/05/20] 严格来说,Go 只能撰写基于物件的程序 (object-based programming),无法撰写面向对象程序 (object-oriented programming),因为 Go 仅支援一部分的面向对象特性,像是 Go 不支援继承。

    由于 Go 的设计思维,以 Go 实现基于物件的程序时,会和 Java 或 Python 等相对传统的物件系统略有不同,本文会在相关处提及相同及相异处,供读者参考。

    建立物件 (Object)

    在一些程序语言中,会有为了建立物件使用特定的构造函数 (constructor),而 Go 没有引入额外的新语法,直接以函式建立物件即可:

    package main
     
    import (
        "log"
    )
    
    // `X` and `Y` are public fields.
    type Point struct {
        X float64
        Y float64
    }
     
    // Use an ordinary function as constructor
    func NewPoint(x float64, y float64) *Point {
        p := new(Point)
     
        p.X = x
        p.Y = y
     
        return p
    }
     
    func main() {
        p := NewPoint(3, 4)
     
        if !(p.X == 3.0) {
            log.Fatal("Wrong value")
        }
     
        if !(p.Y == 4.0) {
            log.Fatal("Wrong value")
        }
    }

    在我们的 Point 物件 p 中,我们直接存取 p 的属性 XY,这在面向对象上不是好的习惯,因为我们无法控管属性,物件可能会产生预期外的行为,比较好的方法,是将属性隐藏在物件内部,由公开方法去存取。我们在后文中会讨论。

    虽然大部分的 Go 物件都使用结构,但其实 Go 物件内部可用其他的类型,如下例:

    type Vector []float64
     
    func NewVector(args ...float64) Vector {
        return args
    }
     
    func WithSize(s int) Vector {
        v := make([]float64, s)
     
        return v
    }

    由此例可知,Go 不限定建构函式的语法,我们可以视需求使用多个建构函式。

    撰写方法 (Method)

    在面向对象程序中,我们很少直接操作属性 (field),通常会将属性私有化,再加入相对应的公开方法 (method)。我们将先前的 Point 物件改写如下:

    package main
     
    import (
        "log"
    )
    
    // `x` and `y` are private fields.
    type Point struct {
        x float64
        y float64
    }
     
    func NewPoint(x float64, y float64) *Point {
        p := new(Point)
     
        p.SetX(x)
        p.SetY(y)
     
        return p
    }
     
    // The getter of x
    func (p *Point) X() float64 {
        return p.x
    }
     
    // The getter of y
    func (p *Point) Y() float64 {
        return p.y
    }
     
    // The setter of x
    func (p *Point) SetX(x float64) {
        p.x = x
    }
     
    // The setter of y
    func (p *Point) SetY(y float64) {
        p.y = y
    }
     
    func main() {
        p := NewPoint(0, 0)
     
        if !(p.X() == 0) {
            log.Fatal("Wrong value")
        }
     
        if !(p.Y() == 0) {
            log.Fatal("Wrong value")
        }
     
        p.SetX(3)
        p.SetY(4)
     
        if !(p.X() == 3.0) {
            log.Fatal("Wrong value")
        }
     
        if !(p.Y() == 4.0) {
            log.Fatal("Wrong value")
        }
    }

    在 Go 语言中,没有 thisself 这种代表物件的关键字,而是由程序设计者自订代表物件的变量,在本例中,我们用 p 表示物件本身。透过这种带有物件的函式声明后,函式会和物件连动;在面向对象中,将这种和物件连动的函式称为方法 (method)。

    虽然在这个例子中,暂时无法直接看出使用方法的好处,比起直接操作属性,透过私有属性搭配公开方法带来许多的益处。例如,如果我们希望 Point 在建立之后是唯读的,我们只要将 SetXSetY 改为私有方法即可;或者,我们希望限定 Point 所在的范围为 0.0 至 1000.0,我们可以在 SetXSetY 中检查参数是否符合我们的要求。

    静态方法 (Static Method)

    有些读者学过 Java 或 C#,可能有听过过静态方法 (static method)。这是因为 Java 和 C# 直接将面向对象的概念融入其语法中,然而,为了要让某些方法在不建立物件时即可使用,所使用的一种补偿性的语法机制。由于 Go 语言没有将面向对象的概念直接加在语法中,不需要用这种语法,直接用顶层函式即可。

    例如:我们撰写一个计算两点间长度的函式:

    package main
     
    import (
        "log"
        "math"
    )
     
    type Point struct {
        x float64
        y float64
    }
     
    func NewPoint(x float64, y float64) *Point {
        p := new(Point)
     
        p.SetX(x)
        p.SetY(y)
     
        return p
    }
     
    func (p *Point) X() float64 {
        return p.x
    }
     
    func (p *Point) Y() float64 {
        return p.y
    }
     
    func (p *Point) SetX(x float64) {
        p.x = x
    }
     
    func (p *Point) SetY(y float64) {
        p.y = y
    }
    
    // Use an ordinary function as static method.
    func Dist(p1 *Point, p2 *Point) float64 {
        xSqr := math.Pow(p1.X()-p2.X(), 2)
        ySqr := math.Pow(p1.Y()-p2.Y(), 2)
     
        return math.Sqrt(xSqr + ySqr)
    }
     
    func main() {
        p1 := NewPoint(0, 0)
        p2 := NewPoint(3.0, 4.0)
     
        if !(Dist(p1, p2) == 5.0) {
            log.Fatal("Wrong value")
        }
    }

    或许有读者会担心,使用过多的顶层函式会造成全局空间的污染和冲突;实际上不需担心,虽然我们目前将物件和主程序写在一起,实务上,物件会写在独立的套件 (package) 中,藉由套件即可大幅减低命名空间冲突的议题。

    使用嵌入 (Embedding) 取代继承 (Inheritance)

    继承 (inheritance) 是一种重用程序代码的方式,透过从父类 (parent class) 继承程序代码,子类 (child class) 可以少写一些程序代码。此外,对于静态类型语言来说,继承也是实现多态 (polymorphism) 的方式。然而,Go 语言却刻意地拿掉继承,这是出自于其他语言的经验。

    继承虽然好用,但也引起许多的问题。像是 C++ 相对自由,可以直接使用多重继承,但这项特性会引来菱型继承 (diamond inheritance) 的议题,Java 和 C# 刻意把这个机制去掉,改以接口 (interface) 进行有限制的多重继承。从过往经验可知过度地使用继承,会增加程序代码的复杂度,使得项目难以维护。出自于工程上的考量,Go 舍去继承这个语法特性。

    为了补偿没有继承的缺失,Go 加入了嵌入 (embedding) 这个新的语法特性,透过嵌入,也可以达到程序代码共享的功能。

    例如,我们扩展 Point 类至三维空间:

    package main
     
    import (
        "log"
    )
     
    type Point struct {
        x float64
        y float64
    }
     
    func NewPoint(x float64, y float64) *Point {
        p := new(Point)
     
        p.SetX(x)
        p.SetY(y)
     
        return p
    }
     
    func (p *Point) GetX() float64 {
        return p.x
    }
     
    func (p *Point) GetY() float64 {
        return p.y
    }
     
    func (p *Point) SetX(x float64) {
        p.x = x
    }
     
    func (p *Point) SetY(y float64) {
        p.y = y
    }
     
    type Point3D struct {
        // Point is embedded
        Point
        z float64
    }
     
    func NewPoint3D(x float64, y float64, z float64) *Point3D {
        p := new(Point3D)
     
        p.SetX(x)
        p.SetY(y)
        p.SetZ(z)
     
        return p
    }
     
    func (p *Point3D) GetZ() float64 {
        return p.z
    }
     
    func (p *Point3D) SetZ(z float64) {
        p.z = z
    }
     
    func main() {
        p := NewPoint3D(1, 2, 3)
     
        // GetX method is from Point
        if !(p.GetX() == 1) {
            log.Fatal("Wrong value")
        }
     
        // GetY method is from Point
        if !(p.GetY() == 2) {
            log.Fatal("Wrong value")
        }
     
        // GetZ method is from Point3D
        if !(p.GetZ() == 3) {
            log.Fatal("Wrong value")
        }
    }
    

    在本例中,我们重用了 Point 的方法,再加入 Point3D 特有的方法。

    然而,Point 和 Point3D 两者在类关系上却是不相干的独立物件。在以下例子中,我们想将 Point3D 加入 Point 物件组成的切片,而引发程序的错误:

    // Declare Point and Point3D as above.
     
    func main() {
        points := make([]*Point, 0)
     
        p1 := NewPoint(3, 4)
        p2 := NewPoint3D(1, 2, 3)
     
        // Error!
        points = append(points, p1, p2)
    }

    在 Go 语言中,需要使用接口 (interface) 来解决这个议题,这就是我们下一篇文章所要探讨的主题。

    嵌入指针

    除了嵌入其他结构外,结构也可以嵌入指针。我们将上例改写如下:

    package main
     
    import (
        "log"
    )
     
    type Point struct {
        x float64
        y float64
    }
     
    func NewPoint(x float64, y float64) *Point {
        p := new(Point)
     
        p.SetX(x)
        p.SetY(y)
     
        return p
    }
     
    func (p *Point) X() float64 {
        return p.x
    }
     
    func (p *Point) Y() float64 {
        return p.y
    }
     
    func (p *Point) SetX(x float64) {
        p.x = x
    }
     
    func (p *Point) SetY(y float64) {
        p.y = y
    }
     
    type Point3D struct {
        // Point is embedded as a pointer
        *Point
        z float64
    }
     
    func NewPoint3D(x float64, y float64, z float64) *Point3D {
        p := new(Point3D)
     
        // Forward promotion
        p.Point = NewPoint(x, y)
     
        // Forward promotion
        p.Point.SetX(x)
        p.Point.SetY(y)
     
        p.SetZ(z)
     
        return p
    }
     
    func (p *Point3D) Z() float64 {
        return p.z
    }
     
    func (p *Point3D) SetZ(z float64) {
        p.z = z
    }
     
    func main() {
        p := NewPoint3D(1, 2, 3)
     
        // GetX method is from Point
        if !(p.X() == 1) {
            log.Fatal("Wrong value")
        }
     
        // GetY method is from Point
        if !(p.Y() == 2) {
            log.Fatal("Wrong value")
        }
     
        // GetZ method is from Point3D
        if !(p.Z() == 3) {
            log.Fatal("Wrong value")
        }
    }

    同样地,仍然不能透过嵌入指楆让类型直接互通,而需要透过接口。

    结语

    在本文中,我们介绍了 Golang 的物件系统。相较于 C++ 或 Java 或 C#,Golang 的物件系统相对比较轻量,尽量不使用新的保留字,而用现用的语法来实现物件的特性。Golang 的物件系统刻意拿掉继承,改用嵌入来重用程序代码,但嵌入无法解决子类的问题,这个问题要等到我们下一篇讲到的接口才有解。