0
点赞
收藏
分享

微信扫一扫

Golang——面向对象编程(下)

女侠展昭 2022-04-25 阅读 66

目录

工厂模式

概念

示例

接口

隐式接口

类型

指针和接口

nil 和 non-nil

面向对象编程思想

面向对象编程三大特性

封装

继承

多态


工厂模式

概念

由于 Go 中缺少类和继承等 OOP 特性, 所以无法使用 Go 来实现经典的工厂方法模式。 不过, 我们仍然能实现模式的基础版本, 即简单工厂。

在本例中, 我们将使用工厂结构体来构建多种类型的武器。

首先, 我们来创建一个名为 i­Gun的接口, 其中将定义一支枪所需具备的所有方法。 然后是实现了 iGun 接口的 gun枪支结构体类型。 两种具体的枪支—— ak47musket火枪 ——两者都嵌入了枪支结构体, 且间接实现了所有的 i­Gun方法。

gun­Factory枪支工厂结构体将发挥工厂的作用, 即通过传入参数构建所需类型的枪支。 main.go 则扮演着客户端的角色。 其不会直接与 ak47musket进行互动, 而是依靠 gun­Factory来创建多种枪支的实例, 仅使用字符参数来控制生产。

示例

iGun.go: 产品接口

package main
​
type iGun interface {
    setName(name string)
    setPower(power int)
    getName() string
    getPower() int
}

gun.go: 具体产品

package main
​
type gun struct {
    name  string
    power int
}
​
func (g *gun) setName(name string) {
    g.name = name
}
​
func (g *gun) getName() string {
    return g.name
}
​
func (g *gun) setPower(power int) {
    g.power = power
}
​
func (g *gun) getPower() int {
    return g.power
}

ak47.go: 具体产品

package main
​
type ak47 struct {
    gun
}
​
func newAk47() iGun {
    return &ak47{
        gun: gun{
            name:  "AK47 gun",
            power: 4,
        },
    }
}

musket.go: 具体产品

package main
​
type musket struct {
    gun
}
​
func newMusket() iGun {
    return &musket{
        gun: gun{
            name:  "Musket gun",
            power: 1,
        },
    }
}

gunFactory.go: 工厂

package main
​
import "fmt"
​
func getGun(gunType string) (iGun, error) {
    if gunType == "ak47" {
        return newAk47(), nil
    }
    if gunType == "musket" {
        return newMusket(), nil
    }
    return nil, fmt.Errorf("Wrong gun type passed")
}

main.go: 客户端代码

package main
​
import "fmt"
​
func main() {
    ak47, _ := getGun("ak47")
    musket, _ := getGun("musket")
​
    printDetails(ak47)
    printDetails(musket)
}
​
func printDetails(g iGun) {
    fmt.Printf("Gun: %s", g.getName())
    fmt.Println()
    fmt.Printf("Power: %d", g.getPower())
    fmt.Println()
}

output.txt: 执行结果

Gun: AK47 gun
Power: 4
Gun: Musket gun
Power: 1

接口

此部分来源于Go 语言接口的原理 | Go 语言设计与实现

隐式接口

很多面向对象语言都有接口这一概念,例如 Java 和 C#。Java 的接口不仅可以定义方法签名,还可以定义变量,这些定义的变量可以直接在实现接口的类中使用,这里简单介绍一下 Java 中的接口:

public interface MyInterface {
    public String hello = "Hello";
    public void sayHello();
}

上述代码定义了一个必须实现的方法 sayHello 和一个会注入到实现类的变量 hello。在下面的代码中,MyInterfaceImpl 实现了 MyInterface 接口:

public class MyInterfaceImpl implements MyInterface {
    public void sayHello() {
        System.out.println(MyInterface.hello);
    }
}

Java 中的类必须通过上述方式显式地声明实现的接口,但是在 Go 语言中实现接口就不需要使用类似的方式。首先,我们简单了解一下在 Go 语言中如何定义接口。定义接口需要使用 interface 关键字,在接口中我们只能定义方法签名,不能包含成员变量,一个常见的 Go 语言接口是这样的:

type error interface {
    Error() string
}

如果一个类型需要实现 error 接口,那么它只需要实现 Error() string 方法,下面的 RPCError 结构体就是 error 接口的一个实现:

type RPCError struct {
    Code    int64
    Message string
}
​
func (e *RPCError) Error() string {
    return fmt.Sprintf("%s, code=%d", e.Message, e.Code)
}

细心的读者可能会发现上述代码根本就没有 error 接口的影子,这是为什么呢?Go 语言中接口的实现都是隐式的,我们只需要实现 Error() string 方法就实现了 error 接口。Go 语言实现接口的方式与 Java 完全不同:

  • 在 Java 中:实现接口需要显式地声明接口并实现所有方法;

  • 在 Go 中:实现接口的所有方法就隐式地实现了接口;

我们使用上述 RPCError 结构体时并不关心它实现了哪些接口,Go 语言只会在传递参数、返回参数以及变量赋值时才会对某个类型是否实现接口进行检查,这里举几个例子来演示发生接口类型检查的时机:

func main() {
    var rpcErr error = NewRPCError(400, "unknown err") // typecheck1
    err := AsErr(rpcErr) // typecheck2
    println(err)
}
​
func NewRPCError(code int64, msg string) error {
    return &RPCError{ // typecheck3
        Code:    code,
        Message: msg,
    }
}
​
func AsErr(err error) error {
    return err
}

Go 语言在编译期间对代码进行类型检查,上述代码总共触发了三次类型检查:

  1. *RPCError 类型的变量赋值给 error 类型的变量 rpcErr

  2. *RPCError 类型的变量 rpcErr 传递给签名中参数类型为 errorAsErr 函数;

  3. *RPCError 类型的变量从函数签名的返回值类型为 errorNewRPCError 函数中返回;

从类型检查的过程来看,编译器仅在需要时才检查类型,类型实现接口时只需要实现接口中的全部方法,不需要像 Java 等编程语言中一样显式声明。

类型

接口也是 Go 语言中的一种类型,它能够出现在变量的定义、函数的入参和返回值中并对它们进行约束,不过 Go 语言中有两种略微不同的接口,一种是带有一组方法的接口,另一种是不带任何方法的 interface{}

图 4-7 Go 语言中的两种接口

Go 语言使用 runtime.iface 表示第一种接口,使用 runtime.eface 表示第二种不包含任何方法的接口 interface{},两种接口虽然都使用 interface 声明,但是由于后者在 Go 语言中很常见,所以在实现时使用了特殊的类型。

需要注意的是,与 C 语言中的 void * 不同,interface{} 类型不是任意类型。如果我们将类型转换成了 interface{} 类型,变量在运行期间的类型也会发生变化,获取变量类型时会得到 interface{}

package main
​
func main() {
    type Test struct{}
    v := Test{}
    Print(v)
}
​
func Print(v interface{}) {
    println(v)
}

上述函数不接受任意类型的参数,只接受 interface{} 类型的值,在调用 Print 函数时会对参数 v 进行类型转换,将原来的 Test 类型转换成 interface{} 类型,本节会在后面介绍类型转换的实现原理。

指针和接口

在 Go 语言中同时使用指针和接口时会发生一些让人困惑的问题,接口在定义一组方法时没有对实现的接收者做限制,所以我们会看到某个类型实现接口的两种方式:

图 4-8 结构体和指针实现接口

这是因为结构体类型和指针类型是不同的,就像我们不能向一个接受指针的函数传递结构体一样,在实现接口时这两种类型也不能划等号。虽然两种类型不同,但是上图中的两种实现不可以同时存在,Go 语言的编译器会在结构体类型和指针类型都实现一个方法时报错 “method redeclared”。

Cat 结构体来说,它在实现接口时可以选择接受者的类型,即结构体或者结构体指针,在初始化时也可以初始化成结构体或者指针。下面的代码总结了如何使用结构体、结构体指针实现接口,以及如何使用结构体、结构体指针初始化变量。

type Cat struct {}
type Duck interface { ... }
​
func (c  Cat) Quack {}  // 使用结构体实现接口
func (c *Cat) Quack {}  // 使用结构体指针实现接口
​
var d Duck = Cat{}      // 使用结构体初始化变量
var d Duck = &Cat{}     // 使用结构体指针初始化变量

实现接口的类型和初始化返回的类型两个维度共组成了四种情况,然而这四种情况不是都能通过编译器的检查:

结构体实现接口结构体指针实现接口
结构体初始化变量通过不通过
结构体指针初始化变量通过通过

四种中只有使用指针实现接口,使用结构体初始化变量无法通过编译,其他的三种情况都可以正常执行。当实现接口的类型和初始化变量时返回的类型时相同时,代码通过编译是理所应当的:

  • 方法接受者和初始化类型都是结构体;

  • 方法接受者和初始化类型都是结构体指针;

而剩下的两种方式为什么一种能够通过编译,另一种无法通过编译呢?我们先来看一下能够通过编译的情况,即方法的接受者是结构体,而初始化的变量是结构体指针:

type Cat struct{}
​
func (c Cat) Quack() {
    fmt.Println("meow")
}
​
func main() {
    var c Duck = &Cat{}
    c.Quack()
}

作为指针的 &Cat{} 变量能够隐式地获取到指向的结构体,所以能在结构体上调用 WalkQuack 方法。我们可以将这里的调用理解成 C 语言中的 d->Walk()d->Speak(),它们都会先获取指向的结构体再执行对应的方法。

但是如果我们将上述代码中方法的接受者和初始化的类型进行交换,代码就无法通过编译了:

type Duck interface {
    Quack()
}
​
type Cat struct{}
​
func (c *Cat) Quack() {
    fmt.Println("meow")
}
​
func main() {
    var c Duck = Cat{}
    c.Quack()
}
​
$ go build interface.go
./interface.go:20:6: cannot use Cat literal (type Cat) as type Duck in assignment:
    Cat does not implement Duck (Quack method has pointer receiver)

编译器会提醒我们:Cat 类型没有实现 Duck 接口,Quack 方法的接受者是指针。这两个报错对于刚刚接触 Go 语言的开发者比较难以理解,如果我们想要搞清楚这个问题,首先要知道 Go 语言在传递参数时都是传值的。

图 4-9 实现接口的接受者类型

如上图所示,无论上述代码中初始化的变量 cCat{} 还是 &Cat{},使用 c.Quack() 调用方法时都会发生值拷贝:

  • 如上图左侧,对于 &Cat{} 来说,这意味着拷贝一个新的 &Cat{} 指针,这个指针与原来的指针指向一个相同并且唯一的结构体,所以编译器可以隐式的对变量解引用(dereference)获取指针指向的结构体;

  • 如上图右侧,对于 Cat{} 来说,这意味着 Quack 方法会接受一个全新的 Cat{},因为方法的参数是 *Cat,编译器不会无中生有创建一个新的指针;即使编译器可以创建新指针,这个指针指向的也不是最初调用该方法的结构体;

上面的分析解释了指针类型的现象,当我们使用指针实现接口时,只有指针类型的变量才会实现该接口;当我们使用结构体实现接口时,指针类型和结构体类型都会实现该接口。当然这并不意味着我们应该一律使用结构体实现接口,这个问题在实际工程中也没那么重要,在这里我们只想解释现象背后的原因。

nil 和 non-nil

我们可以通过一个例子理解Go 语言的接口类型不是任意类型这一句话,下面的代码在 main 函数中初始化了一个 *TestStruct 类型的变量,由于指针的零值是 nil,所以变量 s 在初始化之后也是 nil

package main
​
type TestStruct struct{}
​
func NilOrNot(v interface{}) bool {
    return v == nil
}
​
func main() {
    var s *TestStruct
    fmt.Println(s == nil)      // #=> true
    fmt.Println(NilOrNot(s))   // #=> false
}
​
$ go run main.go
true
false

我们简单总结一下上述代码执行的结果:

  • 将上述变量与 nil 比较会返回 true

  • 将上述变量传入 NilOrNot 方法并与 nil 比较会返回 false

出现上述现象的原因是 —— 调用 NilOrNot 函数时发生了隐式的类型转换,除了向方法传入参数之外,变量的赋值也会触发隐式类型转换。在类型转换时,*TestStruct 类型会转换成 interface{} 类型,转换后的变量不仅包含转换前的变量,还包含变量的类型信息 TestStruct,所以转换后的变量与 nil 不相等。

面向对象编程思想

我们在前面去定义一个结构体时候,实际上就是把一类事物的共有的属性(字段)和行为(方法)提取出来,形成一个物理模型(结构体)。这种研究问题的方法称为抽象

面向对象编程三大特性

封装

封装(encapsulation)就是把抽象出的字段和对字段的操作封装在一起,数据被保护在内部,程序的其它包只有通过被授权的操作(方法),才能对字段进行操作

封装的优点

  1. 隐藏实现细节

  2. 可以对数据进行验证

如何体现封装

  1. 对结构体中的属性进行封装

  2. 通过方法 实现封装

封装的实现步骤

  1. 将结构体、字段(属性)的首字母小写(不能导出了,其它包不能使用,类似 private)

  2. 给结构体所在包提供一个工厂模式的函数,首字母大写。类似一个构造函数

  3. 提供一个首字母大写的 Set 方法(类似其它语言的 public),用于对属性判断并赋值

    func (var 结构体类型名) SetXxx(参数列表) (返回值列表) {
        //加入数据验证的业务逻辑var.字段 = 参数
    }

  4. 提供一个首字母大写的 Get 方法(类似其它语言的 public),用于获取属性的值

    func (var 结构体类型名) GetXxx() {
        return var.age;
    }

继承

  1. 当多个结构体存在相同的属性(字段)和方法时,可以从这些结构体(primarySchoolStudents,collegeStudents)中抽象出结构体(比如Student),在该结构体中定义这些相同的属性和方法。

  2. 其它的结构体不需要重新定义这些属性(字段)和方法,只需嵌套一个 Student 匿名结构体即可。

  3. 在 Golang 中,如果一个 struct 嵌套了另一个匿名结构体,那么这个结构体可以直接访问匿名结构体的字段和方法,从而实现了继承特性。

详细说明

  1. 结构体可以使用嵌套匿名结构体所有的字段和方法,即:首字母大写或者小写的字段、方法, 都可以使用。

  2. 结构体匿名结构体有相同的字段或者方法时,编译器采用就近访问原则访问,如希望访问匿名结构体的字段和方法,可以通过匿名结构体名来区分

  3. 结构体嵌入两个(或多个)匿名结构体,如两个匿名结构体有相同的字段和方法(同时结构体本身没有同名的字段和方法),在访问时,就必须明确指定匿名结构体名字,否则编译报错。

  4. 上面一条也就是多重继承

  5. 如果一个 struct 嵌套了一个有名结构体,这种模式就是组合,如果是组合关系,那么在访问组合的结构体的字段或方法时,必须带上结构体的名字

  6. 嵌套匿名结构体后,也可以在创建结构体变量(实例)时,直接指定各个匿名结构体字段的

多态

变量(实例)具有多种形态。面向对象的第三大特征,在 Go 语言,多态特征是通过接口实现的。可以按照统一的接口来调用不同的实现。这时接口变量就呈现不同的形态。

举报

相关推荐

0 条评论