接口(interface
)与类(class
)一样,都是面向对象编程的重要组成部分,Go
语言中虽然没有类的概念,不过Go
支持接口。
接口
Go
语言的接口与其他编程语言的接口有什么不同呢?下面我们来探究一下!
定义
什么是接口?简单来说接口就是一个方法集,这个方法集描述了其他数据类型实现该接口的方法,接口内只能定义方法,且这些方法没有具体地实现,也就说这些方法只有方法名与方法签名而已,没有方法体。
创建
Go
接口创建与创建结构体的类似,创建接口使用interface
关键字:
type Goods interface {
GetPrice() float64
GetNum() int
}
创建了接口后,就可以使用该接口类型定义变量了:
var goods Goods
log.Println(goods) //nil
接口类型变量的默认值是nil
,直接调用的话会报错:
goods.GetPrice() //panic
接口的实现
在其他编程语言中(比如在Java
),如果我们要实现一个接口,要在类名后使用implements
指定所要实现的接口,并且需要实现接口中的每一个方法:
public class Book implements Goods{
public int GetNum(){
//...
}
public float GetPrice(){
//..
}
}
Go接口采用的是隐式实现,也就说在Go语言中,要实现某个接口不用指定所要实现的接口,只要该类型拥有拥有某个接口的所有方法时,我们就说该类型就实现了这个接口:
type Book struct {
Name string
Price float64
Num int
}
func (b *Book) GetPrice() float64 {
return b.Price
}
func (b *Book) GetNum() int {
return b.Num
}
type Phone struct {
Brand string
Discount float64
Price float64
Num int
}
func (p *Phone) GetPrice() float64 {
return p.Price * p.Discount
}
func (p *Phone) GetNum() int {
return p.Num
}
上面的示例代码中,我们创建Book
和Phone
两个类型分别用于表示图书类与手机类商品,这两个类型拥有了Goods
接口的方法,因此Book
和Phone
都实现了Goods
接口。
将实现了该接口的类型实例赋给接口类型变量,这时候再调用方法,就不会报错了:
var goods Goods = Phone{Brand: "华为", Price: 6000, Num: 1, Discount: 0.8}
fmt.Println(goods.GetPrice())
接口的好处
使用接口的好处在于,对于某类有共同行为的数据类型,可以通过接口描述其共同行为,但不同类型的行为可以有不同的实现逻辑,下面我们通过一个示例来讲解一下:
package main
import "fmt"
type Cart struct {
Goods []Goods
}
func (c *Cart) Add(g Goods) {
c.Goods = append(c.Goods, g)
}
func (c *Cart) TotalPrice() float64 {
//计算购物车物品价格...
var totalPrice float64
for _, g := range c.Goods {
totalPrice += float64(g.GetNum()) * g.GetPrice()
}
return totalPrice
}
func main() {
//图书类商品
b1 := Book{Name: "Go从入门到精通", Price: 50, Num: 2}
b2 := Book{Name: "Go Action", Price: 60, Num: 1}
//手机
p1 := Phone{Brand: "华为", Price: 6000, Num: 1, Discount: 0.8}
c := &Cart{}
c.Add(&b1) //添加到购物车
c.Add(&b2)
c.Add(&p1)
fmt.Println(c.TotalPrice())//计算价格
}
在上面的例子中,我们希望计算购物车内商品的总价格,虽然商品是多种多样的,但计算总价格的逻辑里只关心商品的价格和数量,因为将其抽象为Goods
接口,只有实现了该接口的商品才允许被添加到购物车中,在计算购物车价格时,也不用管不同商品的价格计算逻辑,只需要获得该商品的价格与数量。
接口值
前面我们说过,接口变量的默认值是nil
,实际上这是把整个接口类型的变量当作一个整体,再细究起来,一个接口类型变量由两个部分组成:一个具体的类型和该类型的值,当一个接口类型变量的值为nil
时,表示其动态类型和动态类型的值都是nil
,如下图所示:
此时接口类型变量与nil
进行比较是相等的:
var goods Goods
if goods == nil{
fmt.Println("goods equal nil")
}
接下来,我们看看下面这段代码:
var phone *Phone
var goods Goods
goods = phone
if goods == nil{
fmt.Println("goods equal nil")
}else{
fmt.Println("goods don't equal nil")
}
fmt.Println(goods.GetNum()) //调用方法
这段代码的运行结果:
goods don't equal nil
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x20 pc=0x1089780]
这时候你可能会觉得奇怪,goods
并不等于nil
,为什么调用里面的方法会引发panic
呢?
其实上面代码中将phone
赋给goods
后,由于phone
类型为nil
,因此这个时候goods
的动态类型为phone
,但是该类型的值为nil
,goods
的值如下图所示:
如果对phone
进行初始化:
var phone *Phone = &Phone{Brand: "华为", Price: 6000, Num: 1, Discount: 0.8}
那么此时goods
变量的动态类型和动态类型的值都不为nil
,如下图所示:
接口的嵌套
接口可以嵌套组合成为更复杂的接口,比如我们有以下两个接口:
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
如果我们拥有一个同时拥有Read()
和Write()
方法的接口,可能会这样做:
type ReaderWriter interface {
Read(p []byte) (n int, err error)
Write(p []byte) (n int, err error)
}
实际上更好的做法是将简单的接口组合为更复杂的接口,达到代码复用的效果:
type ReadWriter interface {
Reader
Writer
}
嵌套的同时,也可以继续增加方法:
type File interface {
Reader
Writer
Close()
}
空接口
如同用struct{}
来表示一个空结构体一样,interface{}
表示一个空接口,空接口是最简单的接口,任何类型都默认实现了空接口,这意味着可以把任何类型赋予一个空接口变量:
package main
func PrintAny(args ...interface{}) {
//打印...
}
func main() {
//将字符串赋给空接口类型的变量
s1 := "s2"
i1 := 10
PrintAny(s1, i1)
//将整数赋给空接口类型的变量
i2 := 10
PrintAny(i2)
}
上面代码中,我们自己定义了一个打印函数,该函数可以接收一个或多个空接口类型的参数,实际上,Go的fmt
标准包提供的打印函数都是这么做的:
func Println(a ...any) (n int, err error) {
return Fprintln(os.Stdout, a...)
}
fmt.Println()
打印函数的any
就是空接口的一个别名,其定义如下:
type any = interface{}
类型断言
一个接口可以有多种不同的实现,当我们想判断某个接口类型的变量的动态类型是哪种类型时,也称为类型断言,其表达式语法如下:
x.(T)
该表达式也有返回值:
v := x.(T)
x.(T)
断方要分两种情况来看:
第一种情况x
是一个接口类型变量,T
是一个具体类型变量,用于判断某个接口变量当前的具体类型。
比如我们想判断当某个Goods
类型的变量是不是Book
时:
book := Book{Name: "Go从入门到精通", Price: 50, Num: 2}
var goods Goods = &book
v := goods.(*Book)
fmt.Println(v.GetNum())
断言的返回值v
在断言成功后,就获得了断言类型对象的值,比如上面的例子中,
if v == b {
fmt.Println("v==b")
}
如果断言类型错误,会引发panic
的错误:
goods.(*Phone)//panic
x.(T)
表达式的第二个返回值可以来判断断言是否成功,这样就不会引发panic
。
if v,ok := goodf.(*Phone);ok{
}
第二种情况是x
仍然是一个接口的变量,T
是另外一个接口类型变量,用于判断某个类型是否实现了另外一个接口:
var w io.Writer
w = os.Stdout //io.File
rw := w.(io.ReadWriter)
fmt.Println(rw)
上面的代码中,w
是io.Writer
接口变量,因此只包含该接口的方法,后面执行rw := w.(io.ReadWriter)
后,其返回rw
就拥有了io.ReadWriter
接口的方法。
类型分支
很多情况下,我们要进行类型断言时,经常会这么做:
var x interface{}
x = 1
if x == nil {
return "nil"
} else if _, ok := x.(int); ok {
return fmt.Sprintf("%d", x)
} else if _, ok := x.(uint); ok {
return fmt.Sprintf("%d", x)
} else if b, ok := x.(bool); ok {
if b {
return "TRUE"
}
return "FALSE"
} else if s, ok := x.(string); ok {
return sqlQuoteString(s)
} else {
panic(fmt.Sprintf("unexpected type %T: %v", x, x))
}
实际上Go支持更简洁的写法,就是用type-switch
语句。
type-Switch
是switch
语句的一种特殊用法,用于简化类型断言,其语法格式如下:
switch x.(type) {
case nil:
...
default:
}
因此上面的类型断言的例子代码可以改为:
var x interface{}
x = 1
switch x := x.(type) {
case nil:
return "nil"
case int, uint:
return fmt.Sprintf("%d", x)
case bool:
if b {
return "TRUE"
}
return "FALSE"
case string:
return sqlQuoteString(x)
default:
panic(fmt.Sprintf("unexpected type %T: %v", x, x))
}
接口使用建议
-
尽量使用标准库提供的接口,比如
error
,fmt.Stringer
等接口 -
接口内不要定义太多的方法,因为太多方法很难实现,我们看到大多数标准库的接口都只有一个方法,比如Reader,error,Writer
-
只有当两个以上类型有共同的行为时,才将其行为抽象为接口,而不是在开发每个类型前就先写好接口
小结
接口是Go语言编程中比较重要的部分,无论是标准库还是其他优秀开源库,处处都可以看到接口的使用。
好了,总结一下,在这篇文章中,我们主要讲了以下几个点:
- 接口的定义与创建
- 如何实现一个接口
- 接口值是什么
- 空接口的使用、类型断言与类型分支
- 使用接口的一点建议