[golang] 类型,方法及接口

Type

常见的就是通过 type 定义结构体,除此之外,还可对原始值类型及各种复合类型进行别名设置。

type Person struct {
	Name string
	Age  int
}

type Score int

type Convert func(string) Score

一些概念

  • abstract type: 定义一种类型做什么,但不包含具体怎样做
  • concrete type: 定义一种类型做什么及如何做

Methods

跟类型绑定的方法,与普通函数声明差不多,只是多出了个 receiver 部分。receiver 部分约定以类型首字母小写命名,而不用 thisself。以下是一个使用示例:

type Person struct {
	Name string
	Age  int
}

func (p Person) String() string {
	return fmt.Sprintf("name: %s, age:%d", p.Name, p.Age)
}

func main() {
	p := Person{
		Name: "foo",
		Age:  11,
	}
	fmt.Println(p.String())
}

指针 receiver

像普通函数一样,使用指针作为入参标识函数中需要对入参进行修改,这里 methods 也一样。以下情况 methods 需要使用指针入参:

  • 如果方法要修改 receiver
  • 如果方法中需要处理 nil 空实例

其他情况,比如不需要在修改 receiver 时,可使用值类型而不是指针类型。

但,最佳实践是这样的:一个方法是否使用值类型,还要取决于这个类型上是不是已经定义了其他使用了指针作为 receiver 的方法,如果有,应该保持一致,这个类型上所有方法都统一使用指针作为 receiver。

type Counter struct {
	total      int
	lastUpdate time.Time
}

func (c *Counter) Increment() {
	c.total++
	c.lastUpdate = time.Now()
}

func (c Counter) String() string {
	return fmt.Sprintf("current value %d, last updte: %s", c.total, c.lastUpdate)
}

func main() {
	var c Counter
	fmt.Println(c.String())
	c.Increment()
	fmt.Println(c.String())
}

注意这里指针类型的 receiver,在调用其方法时,本质上是做了次转换,由指针类型转成值类型后再调用的,即 c.Increment() 实际上为 (&c).Increment()

作为参数传递给其他函数时,注意值传递时,函数修改的是副本。所以在函数中调用 receiver 方法时,对原值无影响。

func updateRight(p *Counter) {
	p.Increment()
	fmt.Println("right ", p.String())
}

func updateWrong(c Counter) {
	c.Increment()
	fmt.Println("wrong ", c.String())
}

func main() {
	var c Counter
	updateWrong(c)
	fmt.Println(c.String())
	updateRight(&c)
	fmt.Println(c.String())
}

输出:

wrong  current value 1, last updte: ...
current value 0, last updte: ...
right  current value 1, last updte: ...
current value 1, last updte: ...

getter & setter

不建议编写 getter 及 setter 方法,Go 鼓励直接访问结构体中字段,而将方法留给业务逻辑。一些例外的情况是一次调用需要更新多个字段,或者方法不只是用来单纯更新某个字段的操作。

处理 nil

如果是值类型的 nil 尝试从其身上调用方法时会直接 panic,如果是指针类型的 receiver,则取决于 方法中是否有针对 nil 的处理逻辑。

以下二叉树示例展示了 nil 的处理。

type IntTree struct {
	val         int
	left, right *IntTree
}

func (it *IntTree) Insert(val int) *IntTree {
	if it == nil {
		return &IntTree{val: val}
	}
	if val < it.val {
		it.left = it.left.Insert(val)
	} else if val > it.val {
		it.right = it.right.Insert(val)
	}
	return it
}

func (it *IntTree) Contains(val int) bool {
	switch {
	case it == nil:
		return false
	case val < it.val:
		return it.left.Contains(val)
	case val > it.val:
		return it.right.Contains(val)
	default:
		return true
	}
}

func main() {
	var it *IntTree

	it = it.Insert(1)
	it = it.Insert(2)
	it = it.Insert(3)
	fmt.Println(it.Contains(2)) // true
	fmt.Println(it.Contains(4)) // false
}

注意到上在 Contains 方法并不需要修改实例,但还是声明的指针入参。这里是出于要判断 nil 的考虑,毕竟值类型是无法区分 nil 的。

方法也是函数

在所有需要函数类型的地方,方法也适用。当将方法作为值赋值或传参时,称为方法值(method value)。

type Adder struct {
	i int
}

func (a Adder) AddTo(i int) int {
	return a.i + i
}

func main() {
	myAdder := Adder{
		i: 1,
	}
	f := myAdder.AddTo
	f2 := Adder.AddTo
	fmt.Println(f(1), f2(myAdder, 2)) // 2 3
}

如上面示例所展示,甚至可以直接从结构体类型创建方法(此时叫 method expression),只不在调用的时候需要指定 receiver,其完整签名变成了 func(Adder, int) int

类型定义并不会继承

除了对内置类型可进行 type 操作,对于用户自定义的类型也是一样的。但 Go 中通过 type 定义的新类型,和原类型之间并不存在继承操作,原类型上定义的方法在新类型上也不会有。两种类型的值甚至不通用,必需经过类型转换才能赋值。


type Adder struct {
	i int
}

func (a Adder) AddTo(i int) int {
	return a.i + i
}

type AdderAlias Adder

func main() {
	myAdder := Adder{
		i: 1,
	}
	var myAdder2 AdderAlias

	myAdder2 = myAdder // 🚨 cannot use myAdder (variable of type Adder) as AdderAlias value in assignmentcompilerIncompatibleAssign
	
	myAdder2 = AdderAlias(myAdder) // ✅

	f2 := AdderAlias.AddTo // 🚨 AdderAlias.AddTo undefined (type AdderAlias has no field or method AddTo)compilerMissingFieldOrMethod

	fmt.Println( f2(myAdder2, 2)) // 2 3
}

使用 iota 来声明枚举

Go 中原生不支持枚举,但可通过 iota 来变相实现。

首先基于 int 定义一个枚举中要使用的类型,然后使用 const 声明一组变量代表枚举值,同时将第一个赋值为 iota:

const (
		Uncategorized MailCategory = iota
		Persional
		Spam
		Social
		Ad
	)
	fmt.Println(Uncategorized, Persional, Spam) // 0 1 2

当编译器遇到 iota 时,会将后续变更设置成同类型并递增赋值。即使第一个值为 0,第二个为 1... 直到新的 const 语句,都会重置成 0。

通过 Composit 来进行代码复用

Go 中没有继承,鼓励通过 composition 及 promition 来进行代码复用。具体操作如下:

type Employee struct {
	Name string
	ID   string
}

func (e Employee) Description() string {
	return fmt.Sprintf("%s (%s)", e.Name, e.ID)
}

type Manager struct {
	Employee
	Reports []Employee
}

func main() {
	mgr := Manager{
		Employee: Employee{
			Name: "foo",
			ID:   "xxx",
		},
		Reports: []Employee{},
	}

	fmt.Println(mgr.ID)            // xxx
	fmt.Println(mgr.Description()) // foo (xxx)

}

其中 Manager 结构体中有个 Employee 类型,没有指定名字,此时形成了一个嵌入字段 (embedded field),嵌入字段身上的字段及方法会完全暴露(promopted)给宿主结构体,这样 Manager 就包含了 Employee 身上的字段及方法了,所以可以通过 Manager 实例访问 IDName 字段以及调用 Description 方法。

如果宿主刚好有同名字段或方法,对应嵌入字段的同名对象会被覆盖,只能通过显式指定嵌入对象的类型来访问,譬如:

	type Manager struct {
		Employee
		Reports []Employee
+		ID      string
	}

	func main() {
		mgr := Manager{
			Employee: Employee{
				Name: "foo",
				ID:   "xxx",
			},
			Reports: []Employee{},
+			ID:      "yyy",
		}
	
		fmt.Println(mgr.ID)            // yyy
+		fmt.Println(mgr.Employee.ID)   // xxx
		fmt.Println(mgr.Description()) // foo (xxx)
	
	}

接口/Interface

下面是来自 fmt 包里的 Stringer 接口,通过其声明的形式来看接口的定义:

type Stringer interface {
	Strign() string
}

接口名一般以 er 结尾,除了上面的 Stringer 还比如 io.Readerio.Closerjson.Mashler ...

和其他语言中接口不同,Go 中类型无需显式声明实现了某接口,接口是隐式实现的。即,如果类型的方法列表中包含了某接口的所有方法,所该类型实现了该接口。此时该类型就能赋值给这种接口类型的变量。

type Logic interface {
	Process(data string) string
}

type LogicProvider struct{}

func (lp LogicProvider) Process(data string) string {
	//
}

type Client struct {
	L Logic
}

func main() {
	c := Client{
		L: LogicProvider{},
	}
	fmt.Println(c.L.Process("xxx"))
}

接口指明了调用方法需要什么,上面 client 中使用了 Logic 接口,而 LogicProvider 对接口是无感知的。

接口的内嵌

与结构体一样,接口也可内嵌到另一个接口:

type Reader interface {
	Read(p []byte) (n int, err error)
}

type Closer interface {
	Close() error
}

type ReadCloser interface {
	Reader
	Closer
}

接口入,类型出

函数应该满足 "Accept interface, return structs", 使用接口作为函数入参的约束,一是更加灵活,二是更加能表明函数的意图。

返回接口使得后续变更更困难,比如接口中增加字段,所有地方都需要更新,而如果返回结构体,则没有这个问题。

接口与 nil

接口的底层实现是一对指针,一个指向类型一个指向值, 使得接口为 nil 需要类型和值均为 nil。只要类型不 nil 则接口就不 nil 了(一个变量不可能没有类型,所以如果值不空,那类型必然不空)。请看以下示例:

func main() {
	var s *string
	fmt.Println(s == nil) // true

	var i interface{}
	fmt.Println(i == nil) // true

	i = s
	fmt.Println(i == nil) // false
}

接口为空表示无法调用其方法。但接口不为空也并不代表就能调成功,因为 Go 中空实例也是可以正常调用的,前提是实例的方法正确处理了 nil 的情况,否则会 panic。

既然接口的值可能为空,但此时接口又不空,就需要用到反射来判断接口的值是否为空。此处先不涉及。

空接口

静态强类型语言中少不了需要这么种变量,它可以存放任意类型的值,Go 中空接口便可以满足。

var i interface{}

	i = 1
	i = "foo"
	i = struct {
		Name string
		Age  int
	}{
		Name: "bar",
		Age:  22,
	}

空接口表示变量可接受所有实现了零的类型,而 Go 中所有类型都有零值,所以可将任意类型赋值给空接口。空接口会用在一些特殊的场景,比如接收来自 JSON 中的外来数据时。

func main() {
	data := map[string]interface{}{}
	contens, err := ioutil.ReadFile("./data.json")
	json.Unmarshal(contens, &data)
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
	fmt.Println(data)
}

因为 Go 不支持泛型,当用户自定义的类型想支持多种类型时,就可以用空接口来实现。

type LinkedList struct {
	Value interface{}
	Next  *LinkedList
}

func (ll *LinkedList) Insert(pos int, val interface{}) *LinkedList {
	if ll == nil || pos == 0 {
		return &LinkedList{
			Value: val,
			Next:  ll,
		}
	}
	ll.Next = ll.Next.Insert(pos-1, val)
	return ll
}

但实战中尽量避免使用 interface{} ,因为这样会丢失 Go 强类型的优势。

类型断言及类型切换

interface{} 中再把值还原回来有两种方式,类型断言(type assertion)和类型切换(type switch)。

先看类型断言:

type MyInt int

func main() {
	var i interface{}
	var num MyInt=20
	i=num
	num2 :=i.(MyInt) // 20
	num3:=i.(string) // 🚨 panic: interface conversion: interface {} is main.MyInt, not string
	fmt.Println(num2,num3)
}

如上所示,如果在进行类型断言时,指定了错误的类型会直接 panic,良好的代码需要进行错误处理,again,comma ok 形式应用起来:

num3,ok:=i.(string)
if ok{
	fmt.Println(num3)
}

建议始终进行错误处理,这样能保证代码的健壮性,防止后期不经意的修改导致的错误。

类型断言与类型转换的区分

类型断言与类型强转(type cast)还不一样,

  • 前者只能应用于接口,运行时进行
  • 后者可用于接口或具体类型,编译时进行

当接口可对应多种类型时,则使用类型切换(type switch)来转换。

type MyInt int

func doThings(i interface{}) {
	switch i := i.(type) {
	case nil:
		fmt.Println("type of nil")
	case MyInt, int:
		fmt.Println("type of int")
	case io.Reader:
		fmt.Println("type of io.Reader")
	case string:
		fmt.Println("type of string")
	default:
		fmt.Println("unknown type, stay as interface{}", i)
	}
}

func main() {
	num := 1
	var i interface{}
	i = num
	doThings(i)
}

注意:类型切换只是用在你明确知道它可能的类型,如果不知道,正确的做法是使用反射。

隐式接口利于依赖注入

Go 的这种隐式接口实现,对依赖注入很友好。下面通过构建一个 web 应用来说明。

先编写一个打印日志的函数:

func LogOutput(message string) {
	fmt.Println(message)
}

同时创建一个数据源,从里面获取用户数据,并且创建一个工具方法来获取一些假数据:

type SimpleDataStore struct {
	userData map[string]string
}

func (sds SimpleDataStore) GetUserNameById(userId string) (string, bool) {
	name, ok := sds.userData[userId]
	return name, ok
}

func NewSimpleDataStore() SimpleDataStore {
	return SimpleDataStore{
		userData: map[string]string{
			"1": "foo",
			"2": "bar",
			"3": "baz",
		},
	}
}

业务逻辑是这样的,从数据源根据 ID 查询用户, say hello and goodbye。业务调用的时候,同时打印些日志。所以这里能够用上前面创建的数据源和日志函数。但我们并不想显式依赖 SimpleDataStoreLogOutput,考虑到真实需求场景下,后续可能会换一种数据源及日志输出服务。

因此,这里只需要定义好接口来表明业务中需要什么,一个数据源,一个日志打印服务:

type DataStore interface {
	GetUserNameById(userId string) (string, bool)
}

type Logger interface {
	Log(message string)
}

注意到前面定义的日志打印函数,并不直接满足这里的接口定义,所以定义一个函数类型,在这个类型上再定义一个满足接口的方法:

type LoggerAdapter func(message string)

func (la LoggerAdapter) Log(message string) {
	la(message)
}

到此,LoggerAdapterSimpleDataStore 刚好就满足接口的需要了。但如果从这两类型自身的度角来看,他们是完全不知情的,因为其身上并没有像其他语言一样,通过一些什么标识语法之类的,来声明支持了某个接口。这便是解耦。

外部依赖准备就绪,下面看业务逻辑。

type SimpleLogic struct {
	l  Logger
	ds DataStore
}

func (sl SimpleLogic) SayHello(userId string) (string, error) {
	sl.l.Log("in SayHello for user:" + userId)
	name, ok := sl.ds.GetUserNameById(userId)
	if !ok {
		return "", errors.New("unkown user")
	}
	return "hello ," + name, nil
}

func (sl SimpleLogic) SayGoodbye(userId string) (string, error) {
	sl.l.Log("in SayGoodbye for user:" + userId)
	name, ok := sl.ds.GetUserNameById(userId)
	if !ok {
		return "", errors.New("unkown user")
	}
	return "goodbye," + name, nil
}

同样,业务逻辑里也没有规定依赖的具体类型,只是两个接口。当我们需要实例的时候,只需要传入满足接口的参数即可,并没有强制必需是某个类型:

func NewSimpleLogic(l Logger, ds DataStore) SimpleLogic {
	return SimpleLogic{
		l:  l,
		ds: ds,
	}
}

最后 Controlle 中的逻辑,其中包含上面定义的业务逻辑,但这里也不固定和 SimpleLogic 这一类型具体绑定,还是通过定义一个接口来表明我们需要什么:

type Logic interface {
	SayHello(userId string) (string, error)
}

type Controller struct {
	l     Logger
	logic Logic
}

然后是 Controller 中的具体逻辑,同样地,也创建一个工具方法来生成新的实例:

func (c Controller) HandleGreeting(w http.ResponseWriter, r *http.Request) {
	c.l.Log("in controller HandleGreeting")
	userId := r.URL.Query().Get("user_id")
	message, err := c.logic.SayHello(userId)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		w.Write([]byte(err.Error()))
		return
	}
	w.Write([]byte(message))
}

func NewController(l Logger, logic Logic) Controller {
	return Controller{
		l:     l,
		logic: logic,
	}
}

可以看到这里工具方法践行了前面提到的最佳实践:接口入类型出。入参为接口,返回具体类型。

main 函数的逻辑:

func main() {
	l := LoggerAdapter(LogOutput)
	ds := NewSimpleDataStore()
	logic := NewSimpleLogic(l, ds)
	c := NewController(l, logic)
	http.HandleFunc("/hello", c.HandleGreeting)
	http.ListenAndServe(":8080", nil)
}

这里主函数中,是全程序里唯一知道所有具体类型的地方,如果后期换实现,只需要改主函数即可。

测试:

$ curl "localhost:8080/hello?user_id=1"
hello ,foo⏎
$ curl "localhost:8080/hello?user_id=4"
unkown user⏎

以上。