面向对象编程三大特点:封装、继承、多态。

1. 构造函数

Go中结构体实现封装。

Go不支持构造器。如果某类型的零值不可用,需要提供NewT(parameters)函数,用来初始化T类型的变量。按照Go的惯例,应该把创建T类型变量的函数命名为NewT(parameters),若一个包中只含有一种类型,则函数名为New(parameters)。

包含NewT()函数的包的结构体应该首字母小写,以使结构体对外不可引用,只能通过NewT()创建结构体。相应的,结构体内所有字段也应该小写,被隐藏,方法要根据实际情况确认。

// oop/employee/employee.go
package employee
import "fmt"

type employee struct {
        firstName string
        lastName string
        totalLeaves int
        leavesTaken int
}

func New(firstName string, lastName string, totalLeaves int, leavesTaken int) employee{
        e := employee{firstName, lastName, totalLeaves, leavesTaken}
        return e
}

func (e employee) LeavesRemaining(){
        fmt.Printf("%s %s has %d leaves remaining", e.firstName, e.lastName, e.totalLeaves-e.leavesTaken)
}

//oop/main.go
package main
import "oop/employee"

func main(){
        /*
        e := employee.Employee{
                FirstName: "wang",
                LastName: "qing",
                TotalLeaves: 30,
                LeavesTaken: 20,
        }
        */
        e := employee.New("wang", "qing", 30, 20)
        e.LeavesRemaining()
}

2. 继承

Go不支持继承,但他支持组合(composition)。组合的一般含义定义为“合并在一起”。

一般通过嵌套结构体进行组合,特别是匿名结构体。

3. 多态

Go通过接口来实现多态。在Go中,一个类型如果定义了接口所声明的全部方法,那该类型就实现了该接口。

所有实现了接口的类型,都可以把它的值保存在一个接口类型的变量中。在 Go 中,我们使用接口的这种特性来实现多态

4.面向接口

golang中面向对象编程更多的体现为面向接口。

接口 的作用其实就是为不同层级的模块提供了一个定义好的中间层,上游不再需要依赖下游的具体实现,充分地对上下游进行了解耦。

它为我们的程序提供了非常强的灵活性,想要构建一个稳定、健壮的 Go 语言项目,不使用接口是完全无法做到的。

单元测试是一个项目保证工程质量最有效并且投资回报率最高的方法之一,作为静态语言的 Go,想要写出覆盖率足够(最少覆盖核心逻辑)的单元测试本身就比较困难,因为我们不能像动态语言一样随意修改函数和方法的行为,而接口就成了我们的救命稻草,写出抽象良好的接口并通过接口隔离依赖能够帮助我们有效地提升项目的质量和可测试性。

如下代码其实就不是一个设计良好的代码,它不仅在 init 函数中隐式地初始化了 grpc 连接这种全局变量,而且没有将 ListPosts 通过接口的方式暴露出去,这会让依赖 ListPosts 的上层模块难以测试。

package post

var client *grpc.ClientConn

func init() {
    var err error
    client, err = grpc.Dial(...)
    if err != nil {
        panic(err)
    }
}

func ListPosts() ([]*Post, error) {
    posts, err := client.ListPosts(...)
    if err != nil {
        return []*Post{}, err
    }
    
    return posts, nil
}

可以使用下面的代码改写原有的逻辑,使得同样地逻辑变得更容易测试和维护:

package post

type Service interface {
    ListPosts() ([]*Post, error)
}

type service struct {
    conn *grpc.ClientConn
}

func NewService(conn *grpc.ClientConn) Service {
    return &service{
        conn: conn,
    }
}

func (s *service) ListPosts() ([]*Post, error) {
    posts, err := s.conn.ListPosts(...)
    if err != nil {
        return []*Post{}, err
    }
    
    return posts, nil
}
  1. 通过接口 Service 暴露对外的 ListPosts 方法;
  2. 使用 NewService 函数初始化 Service 接口的实现并通过私有的结构体 service 持有 grpc 连接;
  3. ListPosts 不再依赖全局变量,而是依赖接口体 service 持有的连接;

当我们使用这种方式重构代码之后,就可以在 main 函数中显式的初始化 grpc 连接、创建 Service 接口的实现并调用 ListPosts 方法:

package main

import ...

func main() {
    conn, err = grpc.Dial(...)
    if err != nil {
        panic(err)
    }
    
    svc := post.NewService(conn)
    posts, err := svc.ListPosts()
    if err != nil {
        panic(err)
    }
    
    fmt.Println(posts)
}

这种使用接口组织代码的方式在 Go 语言中非常常见,我们应该在代码中尽可能地使用这种思想和模式对外提供功能:

  1. 使用大写的 Service 对外暴露方法;
  2. 使用小写的 service 实现接口中定义的方法;
  3. 通过 NewService 函数初始化 Service 接口;

当我们使用上述方法组织代码之后,其实就对不同模块的依赖进行了解耦,也正遵循了软件设计中经常被提到的一句话 — 『依赖接口,不要依赖实现』,也就是面向接口编程

5. 接口型函数

定义一个函数类型 F,并且实现接口 A 的方法,然后在这个方法中调用自己。这是 Go 语言中将其他函数(参数返回值定义与 F 一致)转换为接口 A 的常用技巧。

// A Getter loads data for a key.
type Getter interface {
    Get(key string) ([]byte, error)
}

// A GetterFunc implements Getter with a function.
type GetterFunc func(key string) ([]byte, error)

// Get implements Getter interface function
func (f GetterFunc) Get(key string) ([]byte, error) {
    return f(key)
}

这里定义了一个接口 Getter,只包含一个方法 Get(key string) ([]byte, error),紧接着定义了一个函数类型 GetterFunc,GetterFunc 参数和返回值与 Getter 中 Get 方法是一致的。而且 GetterFunc 还定义了 Get 方式,并在 Get 方法中调用自己,这样就实现了接口 Getter。所以 GetterFunc 是一个实现了接口的函数类型,简称为接口型函数。

 

参考:

1.Go 系列教程(Golang tutorial series) go语言中文网

2. 如何写出优雅的 Go 语言代码

3. Go 接口型函数的使用场景    七天用Go从零实现系列