[golang] 类型,方法及接口
[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 部分约定以类型首字母小写命名,而不用 this
或 self
。以下是一个使用示例:
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
实例访问 ID
,Name
字段以及调用 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.Reader
,io.Closer
,json.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。业务调用的时候,同时打印些日志。所以这里能够用上前面创建的数据源和日志函数。但我们并不想显式依赖 SimpleDataStore
和 LogOutput
,考虑到真实需求场景下,后续可能会换一种数据源及日志输出服务。
因此,这里只需要定义好接口来表明业务中需要什么,一个数据源,一个日志打印服务:
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)
}
到此,LoggerAdapter
和 SimpleDataStore
刚好就满足接口的需要了。但如果从这两类型自身的度角来看,他们是完全不知情的,因为其身上并没有像其他语言一样,通过一些什么标识语法之类的,来声明支持了某个接口。这便是解耦。
外部依赖准备就绪,下面看业务逻辑。
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⏎
以上。