聊一聊 interface

什么是接口

在编程中我们经常会听到依赖倒置原则, 即:高层模块不应该依赖低层模块, 两者都应该依赖其抽象; 抽象不应该依赖细节; 细节应该依赖抽象。我们从下面的的一个例子具体解释下。在服务端开发中, 需要进行分层, 从而避免小的功能造成大面积修改。例如:server 层调用 dal 层/仓储层, 通常会这么实现:

type Service struct {
   d Dal
}
func NewService(d MysqlDal) Service {
   return Service{d: d}
}
func (s Service) List() []string {
   // biz logic
   return s.d.QueryData()
}

type MysqlDal struct {
}
func (d MysqlDal) QueryData() []string {
   // query from mysql
   return []string{"mysql"}
}

func main() {
   s := NewService(Dal{})
   fmt.Println(s.List())
}
复制代码

上面这种方式实现也没什么大问题「又不是不能跑」, 当我们的存储选型需要改造, 根据需要做出一下修改:重新创建一个 RedisDal, 用于从 redis 里获取数据, 由于 service 依赖了 dal 的实现细节, 因此改动时需要使用 RedisDal 替换 MysqlDal

func NewService(d RedisDal) Service {
   return Service{d: d}
}
func (s Service) List() []string {
   // biz logic
   return s.d.QueryData()
}

type RedisDal struct {
}
func (d RedisDal) QueryData() []string {
   // query from redis
   return []string{"redis"}
}

func main() {
   //s := NewService(Dal{})
   s := NewService(RedisDal{})
   fmt.Println(s.List())
}
复制代码

可以看到 service 并不关心数据是从 redis 来,还是 mysql 来。可以使用依赖倒置原则, 让 service 依赖 dal 层的接口即可, 改造后的代码如下, 这样我们无需更新 service 代码, 就可以实现存储替换。

screenshot-20230407-231214的副本.png

func NewService(d Dal) Service {
   return Service{d: d}
}
func (s Service) List() []string {
   // biz logic
   return s.d.QueryData()
}

type Dal interface {
   QueryData() []string
}

type RedisDal struct {
}
func (d RedisDal) QueryData() []string {
   // query from mysql
   return []string{"redis"}
}

func main() {
   s := NewService(RedisDal{}) // MysqlDal
   fmt.Println(s.List())
}
复制代码

接口类型

上节讲到, 使用接口可以有效的将高层模块和底层模块进行解耦。在 go 中, 可以使用 interface 关键字声明接口类型。在 1.8 版本之前, 接口类型既定义了方法的集合, 任何实现接口方法集合的类型都可以被转换成对应接口类型。在 1.8 之后, 为了对范型进行支持, 接口类型定义为类型的集合。即使用 interface 可以声明方法和类型,看下面的例子:

type BasicInterface interface { // 基础接口类型, 只包含方法的集合
   Add() 
}

type GeneralInterface interface { // 类型的集合, 只能用于类型参数中
   ~int | ~int32 
}
复制代码

接口使用

这里我们只讨论基础接口类型, 即只包含方法的集合。对于通用接口类型, 可以去范型中了解。定义一个接口之后, 需要实现对应的方法, 那么使用值接收和指针接收有什么区别呢?

type BasicInterface interface { // 基础接口类型, 只包含方法的集合
   Add()
}

type ImplA struct {
   Name string
}

func (i ImplA) Add() {
   i.Name = "add"
}

type ImplB struct {
   Name string
}

func (i *ImplB) Add() {
   i.Name = "add"
}

func main() {
   var a BasicInterface = &ImplA{"a"} // 方法调用会转为 *(&ImplA{"a"})
   var b BasicInterface = ImplA{"b"}
   var c BasicInterface = &ImplB{"c"}
   // var d BasicInterface = ImplB{} Cannot use 'ImplB{}' (type ImplB) as the type BasicInterface Type does not implement 'BasicInterface' as the 'Add' method has a pointer receiver
   a.Add()
   b.Add()
   c.Add()
   fmt.Println(a, b, c) // &{a} {b} &{add}
}
复制代码

从上面的例子我们可以看到, 当方法接收者为指针时, 无法将结构体值赋值给接口类型的变量。同时, 指针和非指针的区别在于: 函数调用时传递的指针 copy 还是值 copy, 上面的方法调用可以等价为下面的表述:

func AddA(i ImplA) {
   i.Name = "add"
}

func AddB(i *ImplB) {
   i.Name = "add"
}

func main() {
   a, b := &ImplA{"a"}, ImplA{"b"}
   c := &ImplB{"c"}
   //d := ImplB{"d"}
   AddA(*a)
   AddA(b)
   AddB(c)
   // AddB(d)  编译失败
   fmt.Println(a, b, c) // &{a} {b} &{add}
}
复制代码

在正式开发中, 如何确定使用值接收者还是指针接受者?具体还是看场景, 如果需要更新传入对象值那就必须使用指针; 从性能角度来看, 如果实现接口类型占用的空间比较大, 则可以使用指针, 可以避免方法调用导致的值拷贝。

类型断言与类型转换

接口类型表示一个类型的集合。 例如 BasicInterface, 任何实现 add 方法的类型都属于 BasicInterface 类型。那么如何将接口类型与真实类型相互转换呢?将接口类型转换为实际类型叫做类型断言「interface_value.(type)」, 将表达式转换为目标类型叫做类型转换「interface_type(value)」。从下面的 case 可以看出, 对于类型转换, 编译器会检查变量是否可以转换成目标类型; 对于类型断言, 只能作用于接口类型, 用于判断接口值的真实类型, 如果未使用安全断言, 则可能会发生 panic。

func main() {
   a, b := ImplA{Name: "a"}, ImplB{"b"}
   ia := BasicInterface(a) // 类型转换, 编译器会做检查
   // ib := BasicInterface(b)  Cannot convert an expression of the type 'ImplB' to the type 'BasicInterface'
   ib := BasicInterface(&b)
   // a = ib.(ImplA)  panic: interface conversion: main.BasicInterface is *main.ImplB, not main.ImplA
   // 安全断言
   if _, ok := ib.(ImplA); !ok {
      fmt.Println("can not convert ib to ImplA")
   }
   a = ia.(ImplA)
   switch ib.(type) { // 类型断言
   case ImplA:
      a = ia.(ImplA)
   case *ImplB:
      _ = ib.(*ImplB)
   }
}
复制代码

通常利用下面的表达式让编译器判断某个结构体是否实现了对应的接口

var _ BasicInterface = (*ImplA)(nil) // 隐式转换,等价于 var _ BasicInterface = (BasicInterface)((*ImplA)(nil))
复制代码

空接口

有一种接口比较特殊, 那就空接口。我们可以将任意类型的接口赋值给空接口。

func main() {
   var i interface{} // 空接口
   i = 4
   i = "string"
   i = ImplA{}
   i = ImplB{}
   var j BasicInterface = ImplA{}
   i = j
}
复制代码

接口赋值

在下面的例子中, i 为 nil, 当把值为 nil 的变量 a 赋值给 i 之后却变成了 non-nil。这里其实涉及到接口类型的实现, 对于接口类型变量 a 由两部分组成「动态类型和动态值」。因此, i 为一个接口类型变量, 赋值之后它是 non-nil 的, 里面包含两个属性「动态类型: *ImplA, 动态值: nil」

func main() {
   var a *ImplA
   fmt.Println(a == nil) // true
   var i BasicInterface
   fmt.Println(i == nil) // true
   i = a
   fmt.Println(i == nil) // false
}
复制代码

如下图所示, 当发生赋值时i=a, i 不再是 nil, 而是一个 type 为 *ImplA, value 为 nil 的接口值。

screenshot-20230407-231251.png

接口比较

对于接口类型值的比较, 其实比较的是接口对应的类型和值, 两者相同才返回 ture。如果接口体里有不可以比较类型, 则会发生 panic。

func main() {
   var x, y BasicInterface
   fmt.Println(x == y) // true
   x, y = ImplA{}, ImplA{}
   fmt.Println(x == y) // ture
   x, y = ImplA{}, ImplA{Name: "test"}
   fmt.Println(x == y) // false
   x, y = ImplA{}, &ImplB{}
   fmt.Println(x == y) // false
}
复制代码

总结

本文主要介绍了一下 go 中的基础接口类型, 主要讲下接口的声明、接口实现、接口比较以及类型断言。在正式开发中不要为了使用接口而定义接口。通常情况下, 对于服务分层, 层与层之间可以定义接口, 但在层的内部就无需再定义接口了。

猜你喜欢

转载自juejin.im/post/7219272135818461245