Go第七篇之规范的接口

 

接口本身是调用方和实现方均需要遵守的一种协议,大家按照统一的方法命名参数类型和数量来协调逻辑处理的过程。
Go 语言中使用组合实现对象特性的描述。对象的内部使用结构体内嵌组合对象应该具有的特性,对外通过接口暴露能使用的特性。
Go 语言的接口设计是非侵入式的,接口编写者无须知道接口被哪些类型实现。而接口实现者只需知道实现的是什么样子的接口,但无须指明实现哪一个接口。编译器知道最终编译时使用哪个类型实现哪个接口,或者接口应该由谁来实现。

其它编程语言中的接口

接口是一种较为常见的特性,很多语言都有接口特性。C/C++、C# 语言中的接口都可以多重派生实现接口组合;在苹果的 Objective C 中与接口类似的功能被称为 Protocol,这种叫法比接口更形象、具体。

非侵入式设计是 Go 语言设计师经过多年的大项目经验总结出来的设计之道。只有让接口和实现者真正解耦,编译速度才能真正提高,项目之间的耦合度也会降低不少。

Go语言结构声明(定义)

接口是双方约定的一种合作协议。接口实现者不需要关心接口会被怎样使用,调用者也不需要关心接口的实现细节。接口是一种类型,也是一种抽象结构,不会暴露所含数据的格式、类型及结构。

接口声明的格式

每个接口类型由数个方法组成。接口的形式代码如下:

  1. type 接口类型名 interface{
  2. 方法名1( 参数列表1 ) 返回值列表1
  3. 方法名2( 参数列表2 ) 返回值列表2
  4. }

对各个部分的说明:

  • 接口类型名:使用 type 将接口定义为自定义的类型名。Go 语言的接口在命名时,一般会在单词后面添加 er,如有写操作的接口叫 Writer,有字符串功能的接口叫 Stringer,有关闭功能的接口叫 Closer 等。
  • 方法名:当方法名首字母是大写时,且这个接口类型名首字母也是大写时,这个方法可以被接口所在的包(package)之外的代码访问。
  • 参数列表、返回值列表:参数列表和返回值列表中的参数变量名可以被忽略,例如:
  1. type writer interface{
  2. Write([]byte) error
  3. }

开发中常见的接口及写法

Go 语言提供的很多包中都有接口,例如 io 包中提供的 Writer 接口:

  1. type Writer interface {
  2. Write(p []byte) (n int, err error)
  3. }

这个接口可以调用 Write() 方法写入一个字节数组([]byte),返回值告知写入字节数(n int)和可能发生的错误(err error)。

类似的,还有将一个对象以字符串形式展现的接口,只要实现了这个接口的类型,在调用 String() 方法时,都可以获得对象对应的字符串。在 fmt 包中定义如下:

  1. type Stringer interface {
  2. String() string
  3. }

Stringer 接口在 Go 语言中的使用频率非常高,功能类似于 Java 或者 C# 语言里的 ToString 的操作。

Go 语言的每个接口中的方法数量不会很多。Go 语言希望通过一个接口精准描述它自己的功能,而通过多个接口的嵌入和组合的方式将简单的接口扩展为复杂的接口。本章后面的小节中会介绍如何使用组合来扩充接口。

 

Go语言实现接口的条件

接口定义后,需要实现接口,调用方才能正确编译通过并使用接口。接口的实现需要遵循两条规则才能让接口可用。

接口被实现的条件一:接口的方法与实现接口的类型方法格式一致

在类型中添加与接口签名一致的方法就可以实现该方法。签名包括方法中的名称、参数列表、返回参数列表。也就是说,只要实现接口类型中的方法的名称、参数列表、返回参数列表中的任意一项与接口要实现的方法不一致,那么接口的这个方法就不会被实现。

为了抽象数据写入的过程,定义 DataWriter 接口来描述数据写入需要实现的方法,接口中的 WriteData() 方法表示将数据写入,写入方无须关心写入到哪里。实现接口的类型实现 WriteData 方法时,会具体编写将数据写入到什么结构中。这里使用file结构体实现 DataWriter 接口的 WriteData 方法,方法内部只是打印一个日志,表示有数据写入,详细实现过程请参考下面的代码。

数据写入器的抽象:

  1. package main
  2. import (
  3. "fmt"
  4. )
  5. // 定义一个数据写入器
  6. type DataWriter interface {
  7. WriteData(data interface{}) error
  8. }
  9. // 定义文件结构,用于实现DataWriter
  10. type file struct {
  11. }
  12. // 实现DataWriter接口的WriteData方法
  13. func (d *file) WriteData(data interface{}) error {
  14. // 模拟写入数据
  15. fmt.Println("WriteData:", data)
  16. return nil
  17. }
  18. func main() {
  19. // 实例化file
  20. f := new(file)
  21. // 声明一个DataWriter的接口
  22. var writer DataWriter
  23. // 将接口赋值f,也就是*file类型
  24. writer = f
  25. // 使用DataWriter接口进行数据写入
  26. writer.WriteData("data")
  27. }

代码说明如下:

  • 第 8 行,定义 DataWriter 接口。这个接口只有一个方法,即 WriteData(),输入一个 interface{} 类型的 data,返回一个 error 结构表示可能发生的错误。
  • 第 17 行,file 的 WriteData() 方法使用指针接收器。输入一个 interface{} 类型的 data,返回 error。
  • 第 27 行,实例化 file 赋值给 f,f 的类型为 *file。
  • 第 30 行,声明 DataWriter 类型的 writer 接口变量。
  • 第 33 行,将 *file 类型的 f 赋值给 DataWriter 接口的 writer,虽然两个变量类型不一致。但是 writer 是一个接口,且 f 已经完全实现了 DataWriter() 的所有方法,因此赋值是成功的。
  • 第 36 行,DataWriter 接口类型的 writer 使用 WriteData() 方法写入一个字符串。


运行代码,输出如下:
WriteData: data

本例中调用及实现关系如下图所示。


图:WriteWriter的实现过程


当类型无法实现接口时,编译器会报错,下面列出常见的几种接口无法实现的错误。

1) 函数名不一致导致的报错

在以上代码的基础上尝试修改部分代码,造成编译错误,通过编译器的报错理解如何实现接口的方法。首先,修改 file 结构的 WriteData() 方法名,将这个方法签名(第17行)修改为:

  1. func (d *file) WriteDataX(data interface{}) error {

编译代码,报错:

cannot use f (type *file) as type DataWriter in assignment:
        *file does not implement DataWriter (missing WriteData method)

报错的位置在第 33 行。报错含义是:不能将 f 变量(类型*file)视为 DataWriter 进行赋值。原因:*file 类型未实现 DataWriter 接口(丢失 WriteData 方法)。

WriteDataX 方法的签名本身是合法的。但编译器扫描到第 33 行代码时,发现尝试将 *file 类型赋值给 DataWriter 时,需要检查 *file 类型是否完全实现了 DataWriter 接口。显然,编译器因为没有找到 DataWriter 需要的 WriteData() 方法而报错。

2) 实现接口的方法签名不一致导致的报错

将修改的代码恢复后,再尝试修改 WriteData() 方法,把 data 参数的类型从 interface{} 修改为 int 类型,代码如下:

  1. func (d *file) WriteData(data int) error {

编译代码,报错:

cannot use f (type *file) as type DataWriter in assignment:
        *file does not implement DataWriter (wrong type for WriteData method)
                have WriteData(int) error
                want WriteData(interface {}) error

这次未实现 DataWriter 的理由变为(错误的 WriteData() 方法类型)发现 WriteData(int)error,期望 WriteData(interface{})error。

这种方式的报错就是由实现者的方法签名与接口的方法签名不一致导致的。

接口被实现的条件二:接口中所有方法均被实现

当一个接口中有多个方法时,只有这些方法都被实现了,接口才能被正确编译并使用。

在本节开头的代码中,为 DataWriter中 添加一个方法,代码如下:

  1. // 定义一个数据写入器
  2. type DataWriter interface {
  3. WriteData(data interface{}) error
  4. // 能否写入
  5. CanWrite() bool
  6. }

新增 CanWrite() 方法,返回 bool。此时再次编译代码,报错:

cannot use f (type *file) as type DataWriter in assignment:
        *file does not implement DataWriter (missing CanWrite method)

需要在 file 中实现 CanWrite() 方法才能正常使用 DataWriter()。

Go 语言的接口实现是隐式的,无须让实现接口的类型写出实现了哪些接口。这个设计被称为非侵入式设计。

实现者在编写方法时,无法预测未来哪些方法会变为接口。一旦某个接口创建出来,要求旧的代码来实现这个接口时,就需要修改旧的代码的派生部分,这一般会造成雪崩式的重新编译。

提示

传统的派生式接口及类关系构建的模式,让类型间拥有强耦合的父子关系。这种关系一般会以“类派生图”的方式进行。经常可以看到大型软件极为复杂的派生树。随着系统的功能不断增加,这棵“派生树”会变得越来越复杂。

对于 Go 语言来说,非侵入式设计让实现者的所有类型均是平行的、组合的。如何组合则留到使用者编译时再确认。因此,使用 GO 语言时,不需要同时也不可能有“类派生图”,开发者唯一需要关注的就是“我需要什么?”,以及“我能实现什么?”。

Go语言类型与接口的关系

类型和接口之间有一对多和多对一的关系,下面将列举出这些常见的概念,以方便读者理解接口与类型在复杂环境下的实现关系。

一个类型可以实现多个接口

一个类型可以同时实现多个接口,而接口间彼此独立,不知道对方的实现。

网络上的两个程序通过一个双向的通信连接实现数据的交换,连接的一端称为一个 Socket。Socket 能够同时读取和写入数据,这个特性与文件类似。因此,开发中把文件和 Socket 都具备的读写特性抽象为独立的读写器概念。

Socket 和文件一样,在使用完毕后,也需要对资源进行释放。

把 Socket 能够写入数据和需要关闭的特性使用接口来描述,请参考下面的代码:

  1. type Socket struct {
  2. }
  3. func (s *Socket) Write(p []byte) (n int, err error) {
  4. return 0, nil
  5. }
  6. func (s *Socket) Close() error {
  7. return nil
  8. }

Socket 结构的 Write() 方法实现了 io.Writer 接口:

  1. type Writer interface {
  2. Write(p []byte) (n int, err error)
  3. }

同时,Socket 结构也实现了 io.Closer 接口:

  1. type Closer interface {
  2. Close() error
  3. }

使用 Socket 实现的 Writer 接口的代码,无须了解 Writer 接口的实现者是否具备 Closer 接口的特性。同样,使用 Closer 接口的代码也并不知道 Socket 已经实现了 Writer 接口,如下图所示。


图:接口的使用和实现过程


在代码中使用Socket结构实现的Writer接口和Closer接口代码如下:

  1. // 使用io.Writer的代码, 并不知道Socket和io.Closer的存在
  2. func usingWriter( writer io.Writer){
  3. writer.Write( nil )
  4. }
  5. // 使用io.Closer, 并不知道Socket和io.Writer的存在
  6. func usingCloser( closer io.Closer) {
  7. closer.Close()
  8. }
  9. func main() {
  10. // 实例化Socket
  11. s := new(Socket)
  12. usingWriter(s)
  13. usingCloser(s)
  14. }

usingWriter() 和 usingCloser() 完全独立,互相不知道对方的存在,也不知道自己使用的接口是 Socket 实现的。

多个类型可以实现相同的接口

一个接口的方法,不一定需要由一个类型完全实现,接口的方法可以通过在类型中嵌入其他类型或者结构体来实现。也就是说,使用者并不关心某个接口的方法是通过一个类型完全实现的,还是通过多个结构嵌入到一个结构体中拼凑起来共同实现的。

Service 接口定义了两个方法:一个是开启服务的方法(Start()),一个是输出日志的方法(Log())。使用 GameService 结构体来实现 Service,GameService 自己的结构只能实现 Start() 方法,而 Service 接口中的 Log() 方法已经被一个能输出日志的日志器(Logger)实现了,无须再进行 GameService 封装,或者重新实现一遍。所以,选择将 Logger 嵌入到 GameService 能最大程度地避免代码冗余,简化代码结构。详细实现过程如下:

  1. // 一个服务需要满足能够开启和写日志的功能
  2. type Service interface {
  3. Start() // 开启服务
  4. Log(string) // 日志输出
  5. }
  6. // 日志器
  7. type Logger struct {
  8. }
  9. // 实现Service的Log()方法
  10. func (g *Logger) Log(l string) {
  11. }
  12. // 游戏服务
  13. type GameService struct {
  14. Logger // 嵌入日志器
  15. }
  16. // 实现Service的Start()方法
  17. func (g *GameService) Start() {
  18. }

代码说明如下:

  • 第 2 行,定义服务接口,一个服务需要实现 Start() 方法和日志方法。
  • 第 8 行,定义能输出日志的日志器结构。
  • 第 12 行,为 Logger 添加 Log() 方法,同时实现 Service 的 Log() 方法。
  • 第 17 行,定义 GameService 结构。
  • 第 18 行,在 GameService 中嵌入 Logger 日志器,以实现日志功能。
  • 第 22 行,GameService 的 Start() 方法实现了 Service 的 Start() 方法。


此时,实例化 GameService,并将实例赋给 Service,代码如下:

  1. var s Service = new(GameService)
  2. s.Start()
  3. s.Log(“hello”)

s 就可以使用 Start() 方法和 Log() 方法,其中,Start() 由 GameService 实现,Log() 方法由 Logger 实现。

 

 

示例:Go语言实现日志系统

日志可以用于查看和分析应用程序的运行状态。日志一般可以支持输出多种形式,如命令行、文件、网络等。

本例将搭建一个支持多种写入器的日志系统,可以自由扩展多种日志写入设备。

日志对外接口

本例中定义一个日志写入器接口(LogWriter),要求写入设备必须遵守这个接口协议才能被日志器(Logger)注册。日志器有一个写入器的注册方法(Logger 的 RegisterWriter() 方法)。

日志器还有一个 Log() 方法,进行日志的输出,这个函数会将日志写入到所有已经注册的日志写入器(LogWriter)中,详细代码实现请参考下面的代码。

  1. package main
  2.  
  3. // 声明日志写入器接口
  4. type LogWriter interface {
  5. Write(data interface{}) error
  6. }
  7.  
  8. // 日志器
  9. type Logger struct {
  10. // 这个日志器用到的日志写入器
  11. writerList []LogWriter
  12. }
  13.  
  14. // 注册一个日志写入器
  15. func (l *Logger) RegisterWriter(writer LogWriter) {
  16. l.writerList = append(l.writerList, writer)
  17. }
  18.  
  19. // 将一个data类型的数据写入日志
  20. func (l *Logger) Log(data interface{}) {
  21.  
  22. // 遍历所有注册的写入器
  23. for _, writer := range l.writerList {
  24.  
  25. // 将日志输出到每一个写入器中
  26. writer.Write(data)
  27. }
  28. }
  29.  
  30. // 创建日志器的实例
  31. func NewLogger() *Logger {
  32. return &Logger{}
  33. }

代码说明如下:
第 4 行,声明日志写入器接口。这个接口可以被外部使用。日志的输出可以有多种设备,这个写入器就是用来实现一个日志的输出设备。
第 9 行,声明日志器结构。日志器使用 writeList 记录输出到哪些设备上。
第 15 行,使用日志器方法 RegisterWriter() 将一个日志写入器(LogWriter)注册到日志器(Logger)中。注册的意思就是将日志写入器的接口添加到 writeList 中。
第 20 行,日志器的 Log() 方法可以将 interface{} 类型的 data 写入到注册过的日志写入器中。
第 23 行,遍历日志器拥有的所有日志写入器。
第 26 行,将本次日志的内容写入日志写入器。
第 31 行,创建日志器的实例。

这个例子中,为了最大程度地展示接口的用法,仅仅只是将数据直接写入日志写入器中。复杂一些的日志器还可以将日期、级别等信息合并到数据中一并写入日志。

文件写入器

文件写入器(fileWriter)是众多日志写入器(LogWriter)中的一种。文件写入器的功能是根据一个文件名创建日志文件(fileWriter 的 SetFile 方法)。在有日志写入时,将日志写入文件中。

文件写入器代码:

  1. package main
  2.  
  3. import (
  4. "errors"
  5. "fmt"
  6. "os"
  7. )
  8.  
  9. // 声明文件写入器
  10. type fileWriter struct {
  11. file *os.File
  12. }
  13.  
  14. // 设置文件写入器写入的文件名
  15. func (f *fileWriter) SetFile(filename string) (err error) {
  16.  
  17. // 如果文件已经打开, 关闭前一个文件
  18. if f.file != nil {
  19. f.file.Close()
  20. }
  21.  
  22. // 创建一个文件并保存文件句柄
  23. f.file, err = os.Create(filename)
  24.  
  25. // 如果创建的过程出现错误, 则返回错误
  26. return err
  27. }
  28.  
  29. // 实现LogWriter的Write()方法
  30. func (f *fileWriter) Write(data interface{}) error {
  31.  
  32. // 日志文件可能没有创建成功
  33. if f.file == nil {
  34.  
  35. // 日志文件没有准备好
  36. return errors.New("file not created")
  37. }
  38.  
  39. // 将数据序列化为字符串
  40. str := fmt.Sprintf("%v\n", data)
  41.  
  42. // 将数据以字节数组写入文件中
  43. _, err := f.file.Write([]byte(str))
  44.  
  45. return err
  46. }
  47.  
  48. // 创建文件写入器实例
  49. func newFileWriter() *fileWriter {
  50. return &fileWriter{}
  51. }

代码说明如下:

  • 第 10 行,声明文件写入器,在结构体中保存一个文件句柄,以方便每次写入时操作。
  • 第 15 行,文件写入器通过文件名创建文件,这里通过 SetFile 的参数提供一个文件名,并创建文件。
  • 第 18 行,考虑到 SetFile() 方法可以被多次调用(函数可重入性),假设之前已经调用过 SetFile() 后再次调用,此时的 f.file 不为空,就需要关闭之前的文件,重新创建新的文件。
  • 第 23 行,根据文件名创建文件,如果发生错误,通过 SetFile 的返回值返回。
  • 第 30 行,fileWriter 的 Write() 方法实现了 LogWriter 接口的 Write() 方法。
  • 第 33 行,如果文件没有准备好,文件句柄为 nil,此时使用 errors 包的 New() 函数返回一个错误对象,包含一个字符串“file not created”。
  • 第 40 行,通过 Write() 方法传入的 data 参数是 interface{} 类型,而 f.file 的 Write() 方法需要的是 []byte 类型。使用 fmt.Sprintf 将 data 转换为字符串,这里使用的格式化参数是%v,意思是将 data 按其本来的值转换为字符串。
  • 第 43 行,通过 f.file 的 Write() 方法,将 str 字符串转换为 []byte 字节数组,再写入到文件中。如果发生错误,则返回。


在操作文件时,会出现文件无法创建、无法写入等错误。开发中尽量不要忽略这些底层报出的错误,应该处理可能发生的所有错误。

文件使用完后,要注意使用 os.File 的 Close() 方法进行及时关闭,否则文件再次访问时会因为其属性出现无法读取、无法写入等错误。

提示

一个完备的文件写入器会提供多种写入文件的模式,例子中使用的模式是将日志添加到日志文件的尾部。随着文件越来越大,文件的访问效率和查看便利性也会大大降低。此时,就需要另外一种写入模式:滚动写入文件。

滚动写入文件模式也是将日志添加到文件的尾部,但当文件达到设定的期望大小时,会自动开启一个新的文件继续写入文件,最终将获得多个日志文件。

日志文件名不仅可以按照文件大小进行分割,还可以按照日期范围进行分割。在到达设定的日期范围,如每天、每小时的周期范围时,日志器会自动创建新的日志文件。这种日志文件创建方法也能方便开发者按日志查看日志。

命令行写入器

在 UNIX 的思想中,一切皆文件。文件包括内存、磁盘、网络和命令行等。这种抽象方法方便我们访问这些看不见摸不着的虚拟资源。命令行在 Go 中也是一种文件,os.Stdout 对应标准输出,一般表示屏幕,也就是命令行,也可以被重定向为打印机或者磁盘文件;os.Stderr 对应标准错误输出,一般将错误输出到日志中,不过大多数情况,os.Stdout 会与 os.Stderr 合并输出;os.Stdin 对应标准输入,一般表示键盘。os.Stdout、os.Stderr、os.Stdin 都是 *os.File 类型,和文件一样实现了 io.Writer 接口的 Write() 方法。

下面的代码展示如何将命令行抽象为日志写入器:

  1. package main
  2.  
  3. import (
  4. "fmt"
  5. "os"
  6. )
  7.  
  8. // 命令行写入器
  9. type consoleWriter struct {
  10. }
  11.  
  12. // 实现LogWriter的Write()方法
  13. func (f *consoleWriter) Write(data interface{}) error {
  14.  
  15. // 将数据序列化为字符串
  16. str := fmt.Sprintf("%v\n", data)
  17.  
  18. // 将数据以字节数组写入命令行中
  19. _, err := os.Stdout.Write([]byte(str))
  20.  
  21. return err
  22. }
  23.  
  24. // 创建命令行写入器实例
  25. func newConsoleWriter() *consoleWriter {
  26. return &consoleWriter{}
  27. }

代码说明如下:

  • 第 9 行,声明 consoleWriter 结构,以实现命令行写入器。
  • 第 13 行,consoleWriter 的 Write() 方法实现了日志写入接口(LogWriter)的 Write() 方法。
  • 第 16 行,与 fileWriter 类似,这里也将 data 通过 fmt.Sprintf 序列化为字符串。
  • 第 19 行,与 fileWriter 类似,这里也将 str 字符串转换为字节数组并写入标准输出 os.Stdout。写入后的内容就会显示在命令行中。
  • 第 25 行,创建命令行写入器的实例。


除了命令行写入器(consoleWriter)和文件写入器(fileWriter),读者还可以自行使用 net 包中的 Socket 封装实现网络写入器 socketWriter,让日志可以写入远程的服务器中或者可以跨进程进行日志保存和分析。

使用日志

在程序中使用日志器一般会先通过代码创建日志器(Logger),为日志器添加输出设备(fileWriter、consoleWriter等)。这些设备中有一部分需要一些参数设定,如文件日志写入器需要提供文件名(fileWriter 的 SetFile() 方法)。

下面代码中展示了使用日志器的过程:

  1. package main
  2.  
  3. import "fmt"
  4.  
  5. // 创建日志器
  6. func createLogger() *Logger {
  7.  
  8. // 创建日志器
  9. l := NewLogger()
  10.  
  11. // 创建命令行写入器
  12. cw := newConsoleWriter()
  13.  
  14. // 注册命令行写入器到日志器中
  15. l.RegisterWriter(cw)
  16.  
  17. // 创建文件写入器
  18. fw := newFileWriter()
  19.  
  20. // 设置文件名
  21. if err := fw.SetFile("log.log"); err != nil {
  22. fmt.Println(err)
  23. }
  24.  
  25. // 注册文件写入器到日志器中
  26. l.RegisterWriter(fw)
  27.  
  28. return l
  29. }
  30.  
  31. func main() {
  32.  
  33. // 准备日志器
  34. l := createLogger()
  35.  
  36. // 写一个日志
  37. l.Log("hello")
  38. }

代码说明如下:

  • 第 6 行,一个创建日志的过程。这个过程一般隐藏在系统初始化中。程序启动时初始化一次。
  • 第 9 行,创建一个日志器的实例,后面的代码会使用到它。
  • 第 12 行,创建一个命令行写入器。如果全局有很多日志器,命令行写入器可以被共享,全局只会有一份。
  • 第 18 行,创建一个文件写入器。一个程序的日志一般只有一个,因此不同的日志器也应该共享一个文件写入器。
  • 第 21 行,创建好的文件写入器需要初始化写入的文件,通过文件名确定写入的文件。设置的过程可能会发生错误,发生错误时会输出错误信息。
  • 第 26 行,将文件写入器注册到日志器中。
  • 第 34 行,在程序一开始创建日志器。
  • 第 37 行,往创建好的日志器中写入日志。


编译整个代码并运行,输出如下:
hello

同时,当前目录的 log.log 文件中也会出现 hello 字符。

提示

Go 语言的 log 包实现了一个小型的日志系统。这个日志系统可以在创建日志器时选择输出设备、日志前缀及 flag,函数定义如下:

 
  1. func New(out io.Writer, prefix string, flag int) *Logger {
  2. return &Logger{out: out, prefix: prefix, flag: flag}
  3. }

在 flag 中,还可以定制日志中是否输出日期、日期精度和详细文件名等。

这个日志器在编写时,也最大程度地保证了输出的效率,如果读者对日志器的编写比较感兴趣,可以在 log 包的基础上进行扩展,形成方便自己使用的日志库。

 

 

Go语言排序

排序是常见的算法之一,也是常见的面试题之一,程序员对各种排序算法也是津津乐道。实际使用中,语言的类库会为我们提供健壮、高性能的排序算法库,开发者在了解排序算法基本原理的基础上,应该避免“造轮子”,直接使用已有的排序算法库,以缩短开发周期,提高开发效率。

Go语言中在排序时,需要使用者通过 sort.Interface 接口提供数据的一些特性和操作方法。接口定义代码如下:

  1. type Interface interface {
  2. // 获取元素数量
  3. Len() int
  4. // 小于比较
  5. Less(i, j int) bool
  6. // 交换元素
  7. Swap(i, j int)
  8. }

代码说明如下:

  • 第 3 行,排序算法需要实现者提供需要排序的数据元素数量。
  • 第 6 行,排序需要通过比较元素之间的关系才能做出具体的操作。Less() 方法需要提供两个给定索引(i 和 j)对应元素的小于比较(数值的 < 操作)的结果。参数的 i、j 传入的是元素的索引。将传入的 i、j 索引对应的元素按小于关系进行比较,完成后把结果通过 Less() 方法的返回值返回。
  • 第 9 行,排序的过程就是不停地交换元素。Swap() 方法需要实现者通过传入 i、j 索引找到元素,并交换元素的值。


这个接口需要实现者实现的方法就是排序的经典操作:数量(Len)、比较(Less)、交换(Swap)。

使用sort.Interface接口进行排序

对一系列字符串进行排序时,使用字符串切片([]string)承载多个字符串。使用 type 关键字,将字符串切片([]string)定义为自定义类型 MyStringList。为了让 sort 包能识别 MyStringList,能够对 MyStringList 进行排序,就必须让 MyStringList 实现 sort.Interface 接口。

下面是对字符串排序的详细代码(代码1):

  1. package main
  2.  
  3. import (
  4. "fmt"
  5. "sort"
  6. )
  7.  
  8. // 将[]string定义为MyStringList类型
  9. type MyStringList []string
  10.  
  11. // 实现sort.Interface接口的获取元素数量方法
  12. func (m MyStringList) Len() int {
  13. return len(m)
  14. }
  15.  
  16. // 实现sort.Interface接口的比较元素方法
  17. func (m MyStringList) Less(i, j int) bool {
  18. return m[i] < m[j]
  19. }
  20.  
  21. // 实现sort.Interface接口的交换元素方法
  22. func (m MyStringList) Swap(i, j int) {
  23. m[i], m[j] = m[j], m[i]
  24. }
  25.  
  26. func main() {
  27.  
  28. // 准备一个内容被打乱顺序的字符串切片
  29. names := MyStringList{
  30. "3. Triple Kill",
  31. "5. Penta Kill",
  32. "2. Double Kill",
  33. "4. Quadra Kill",
  34. "1. First Blood",
  35. }
  36.  
  37. // 使用sort包进行排序
  38. sort.Sort(names)
  39.  
  40. // 遍历打印结果
  41. for _, v := range names {
  42. fmt.Printf("%s\n", v)
  43. }
  44.  
  45. }

代码输出结果:
1. First Blood
2. Double Kill
3. Triple Kill
4. Quadra Kill
5. Penta Kill

代码说明如下:

  • 第 9 行,接口实现不受限于结构体,任何类型都可以实现接口。要排序的字符串切片 []string 是系统定制好的类型,无法让这个类型去实现 sort.Interface 排序接口。因此,需要将 []string 定义为自定义的类型。
  • 第 12 行,实现获取元素数量的 Len() 方法,返回字符串切片的元素数量。
  • 第 17 行,实现比较元素的 Less() 方法,直接取 m 切片的 i 和 j 元素值进行小于比较,并返回比较结果。
  • 第 22 行,实现交换元素的 Swap() 方法,这里使用 Go 语言的多变量赋值特性实现元素交换。
  • 第 29 行,由于将 []string 定义成 MyStringList 类型,字符串切片初始化的过程等效于下面的写法:
    1. names := []string {
    2. "3. Triple Kill",
    3. "5. Penta Kill",
    4. "2. Double Kill",
    5. "4. Quadra Kill",
    6. "1. First Blood",
    7. }
    names := []string {
        "3. Triple Kill",
        "5. Penta Kill",
        "2. Double Kill",
        "4. Quadra Kill",
        "1. First Blood",
    }
  • 第 38 行,使用 sort 包的 Sort() 函数,将 names(MyStringList类型)进行排序。排序时,sort 包会通过 MyStringList 实现的 Len()、Less()、Swap() 这 3 个方法进行数据获取和修改。
  • 第 41 行,遍历排序好的字符串切片,并打印结果。

常见类型的便捷排序

通过实现 sort.Interface 接口的排序过程具有很强的可定制性,可以根据被排序对象比较复杂的特性进行定制。例如,需要多种排序逻辑的需求就适合使用 sort.Interface 接口进行排序。但大部分情况中,只需要对字符串、整型等进行快速排序。Go 语言中提供了一些固定模式的封装以方便开发者迅速对内容进行排序。

1) 字符串切片的便捷排序

sort 包中有一个 StringSlice 类型,定义如下:

  1. type StringSlice []string
  2.  
  3. func (p StringSlice) Len() int { return len(p) }
  4. func (p StringSlice) Less(i, j int) bool { return p[i] < p[j] }
  5. func (p StringSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
  6.  
  7. // Sort is a convenience method.
  8. func (p StringSlice) Sort() { Sort(p) }

sort 包中的 StringSlice 的代码与 MyStringList 的实现代码几乎一样。因此,只需要使用 sort 包的 StringSlice 就可以更简单快速地进行字符串排序。将代码1中的排序代码简化后如下所示:

  1. names := sort.StringSlice{
  2. "3. Triple Kill",
  3. "5. Penta Kill",
  4. "2. Double Kill",
  5. "4. Quadra Kill",
  6. "1. First Blood",
  7. }
  8.  
  9. sort.Sort(names)

简化后,只要两句代码就实现了字符串排序的功能。

2) 对整型切片进行排序

除了字符串可以使用 sort 包进行便捷排序外,还可以使用 sort.IntSlice 进行整型切片的排序。sort.IntSlice 的定义如下:

  1. type IntSlice []int
  2.  
  3. func (p IntSlice) Len() int { return len(p) }
  4. func (p IntSlice) Less(i, j int) bool { return p[i] < p[j] }
  5. func (p IntSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
  6.  
  7. // Sort is a convenience method.
  8. func (p IntSlice) Sort() { Sort(p) }

sort 包在 sort.Interface 对各类型的封装上还有更进一步的简化,下面使用 sort.Strings 继续对代码1进行简化,代码如下:

  1. names := []string{
  2. "3. Triple Kill",
  3. "5. Penta Kill",
  4. "2. Double Kill",
  5. "4. Quadra Kill",
  6. "1. First Blood",
  7. }
  8.  
  9. sort.Strings(names)
  10.  
  11. // 遍历打印结果
  12. for _, v := range names {
  13. fmt.Printf("%s\n", v)
  14. }

代码说明如下:

  • 第 1 行,需要排序的字符串切片。
  • 第 9 行,使用 sort.Strings 直接对字符串切片进行排序。

3) sort包内建的类型排序接口一览

Go 语言中的 sort 包中定义了一些常见类型的排序方法,如下表所示。

sort 包中内建的类型排序接口
类  型实现 sort.lnterface 的类型直接排序方法说  明
字符串(String) StringSlice sort.Strings(a [] string) 字符 ASCII 值升序
整型(int) IntSlice sort.Ints(a []int) 数值升序
双精度浮点(float64) Float64Slice sort.Float64s(a []float64) 数值升序


编程中经常用到的 int32、int64、float32、bool 类型并没有由 sort 包实现,使用时依然需要开发者自己编写。

对结构体数据进行排序

除了基本类型的排序,也可以对结构体进行排序。结构体比基本类型更为复杂,排序时不能像数值和字符串一样拥有一些固定的单一原则。结构体的多个字段在排序中可能会存在多种排序的规则,例如,结构体中的名字按字母升序排列,数值按从小到大的顺序排序。一般在多种规则同时存在时,需要确定规则的优先度,如先按名字排序,再按年龄排序等。

1) 完整实现sort.Interface进行结构体排序

将一批英雄名单使用结构体定义,英雄名单的结构体中定义了英雄的名字和分类。排序时要求按照英雄的分类进行排序,相同分类的情况下按名字进行排序,详细代码实现过程如下。

结构体排序代码(代码2):

  1. package main
  2.  
  3. import (
  4. "fmt"
  5. "sort"
  6. )
  7.  
  8. // 声明英雄的分类
  9. type HeroKind int
  10.  
  11. // 定义HeroKind常量, 类似于枚举
  12. const (
  13. None HeroKind = iota
  14. Tank
  15. Assassin
  16. Mage
  17. )
  18.  
  19. // 定义英雄名单的结构
  20. type Hero struct {
  21. Name string // 英雄的名字
  22. Kind HeroKind // 英雄的种类
  23. }
  24.  
  25. // 将英雄指针的切片定义为Heros类型
  26. type Heros []*Hero
  27.  
  28. // 实现sort.Interface接口取元素数量方法
  29. func (s Heros) Len() int {
  30. return len(s)
  31. }
  32.  
  33. // 实现sort.Interface接口比较元素方法
  34. func (s Heros) Less(i, j int) bool {
  35.  
  36. // 如果英雄的分类不一致时, 优先对分类进行排序
  37. if s[i].Kind != s[j].Kind {
  38. return s[i].Kind < s[j].Kind
  39. }
  40.  
  41. // 默认按英雄名字字符升序排列
  42. return s[i].Name < s[j].Name
  43. }
  44.  
  45. // 实现sort.Interface接口交换元素方法
  46. func (s Heros) Swap(i, j int) {
  47. s[i], s[j] = s[j], s[i]
  48. }
  49.  
  50. func main() {
  51.  
  52. // 准备英雄列表
  53. heros := Heros{
  54. &Hero{"吕布", Tank},
  55. &Hero{"李白", Assassin},
  56. &Hero{"妲己", Mage},
  57. &Hero{"貂蝉", Assassin},
  58. &Hero{"关羽", Tank},
  59. &Hero{"诸葛亮", Mage},
  60. }
  61.  
  62. // 使用sort包进行排序
  63. sort.Sort(heros)
  64.  
  65. // 遍历英雄列表打印排序结果
  66. for _, v := range heros {
  67. fmt.Printf("%+v\n", v)
  68. }
  69. }

代码输出如下:
&{Name:关羽 Kind:1}
&{Name:吕布 Kind:1}
&{Name:李白 Kind:2}
&{Name:貂蝉 Kind:2}
&{Name:妲己 Kind:3}
&{Name:诸葛亮 Kind:3}

代码说明如下:

  • 第 9 行,将 int 声明为 HeroKind 英雄类型,后面会将这个类型当做枚举来使用。
  • 第 13 行,定义一些英雄类型常量,可以理解为枚举的值。
  • 第 26 行,为了方便实现 sort.Interface 接口,将 []*Hero 定义为 Heros 类型。
  • 第 29 行,Heros 类型实现了 sort.Interface 的 Len() 方法,返回英雄的数量。
  • 第 34 行,Heros 类型实现了 sort.Interface 的 Less() 方法,根据英雄字段的比较结果决定如何排序。
  • 第 37 行,当英雄的分类不一致时,优先按分类的枚举数值从小到大排序。
  • 第 42 行,英雄分类相等的情况下,默认根据英雄的名字字符升序排序。
  • 第 46 行,Heros 类型实现了 sort.Interface 的 Swap() 方法,交换英雄元素的位置。
  • 第 53~60 行,准备一系列英雄数据。
  • 第 63 行,使用 sort 包进行排序。
  • 第 66 行,遍历所有排序完成的英雄数据。

2) 使用sort.Slice进行切片元素排序

从 Go 1.8 开始,Go 语言在 sort 包中提供了 sort.Slice() 函数进行更为简便的排序方法。sort.Slice() 函数只要求传入需要排序的数据,以及一个排序时对元素的回调函数,类型为 func(i,j int)bool,sort.Slice() 函数的定义如下:

  1. func Slice(slice interface{}, less func(i, j int) bool)

使用 sort.Slice() 函数,对代码2重新优化的完整代码如下:

  1. package main
  2.  
  3. import (
  4. "fmt"
  5. "sort"
  6. )
  7.  
  8. type HeroKind int
  9.  
  10. const (
  11. None = iota
  12. Tank
  13. Assassin
  14. Mage
  15. )
  16.  
  17. type Hero struct {
  18. Name string
  19. Kind HeroKind
  20. }
  21.  
  22. func main() {
  23.  
  24. heros := []*Hero{
  25. {"吕布", Tank},
  26. {"李白", Assassin},
  27. {"妲己", Mage},
  28. {"貂蝉", Assassin},
  29. {"关羽", Tank},
  30. {"诸葛亮", Mage},
  31. }
  32.  
  33. sort.Slice(heros, func(i, j int) bool {
  34. if heros[i].Kind != heros[j].Kind {
  35. return heros[i].Kind < heros[j].Kind
  36. }
  37.  
  38. return heros[i].Name < heros[j].Name
  39. })
  40.  
  41. for _, v := range heros {
  42. fmt.Printf("%+v\n", v)
  43. }
  44. }

第 33 行到第 39 行加粗部分是新添加的 sort.Slice() 及回调函数部分。对比前面的代码,这里去掉了 Heros 及接口实现部分的代码。

使用 sort.Slice() 不仅可以完成结构体切片排序,还可以对各种切片类型进行自定义排序。

 

Go语言接口的嵌套组合 

Go 语言中,不仅结构体与结构体之间可以嵌套,接口与接口间也可以通过嵌套创造出新的接口。

接口与接口嵌套组合而成了新接口,只要接口的所有方法被实现,则这个接口中的所有嵌套接口的方法均可以被调用。

系统包中的接口嵌套组合

Go 语言的 io 包中定义了写入器(Writer)、关闭器(Closer)和写入关闭器(WriteCloser)3个接口,代码如下:

  1. type Writer interface {
  2. Write(p []byte) (n int, err error)
  3. }
  4. type Closer interface {
  5. Close() error
  6. }
  7. type WriteCloser interface {
  8. Writer
  9. Closer
  10. }

代码说明如下:

  • 第 1 行定义了写入器(Writer),如这个接口较为常用,常用于 I/O 设备的数据写入。
  • 第 5 行定义了关闭器(Closer),如有非托管内存资源的对象,需要用关闭的方法来实现资源释放。
  • 第 9 行定义了写入关闭器(WriteCloser),这个接口由 Writer 和 Closer 两个接口嵌入。也就是说,WriteCloser 同时拥有了 Writer 和 Closer 的特性。

在代码中使用接口嵌套组合

在代码中使用 io.Writer、io.Closer 和 io.WriteCloser 这 3 个接口时,只需要按照接口实现的规则实现 io.Writer 接口和 io.Closer 接口即可。而 io.WriteCloser 接口在使用时,编译器会根据接口的实现者确认它们是否同时实现了 io.Writer 和 io.Closer 接口,详细实现代码如下:

  1. package main
  2. import (
  3. "io"
  4. )
  5. // 声明一个设备结构
  6. type device struct {
  7. }
  8. // 实现io.Writer的Write()方法
  9. func (d *device) Write(p []byte) (n int, err error) {
  10. return 0, nil
  11. }
  12. // 实现io.Closer的Close()方法
  13. func (d *device) Close() error {
  14. return nil
  15. }
  16. func main() {
  17. // 声明写入关闭器, 并赋予device的实例
  18. var wc io.WriteCloser = new(device)
  19. // 写入数据
  20. wc.Write(nil)
  21. // 关闭设备
  22. wc.Close()
  23. // 声明写入器, 并赋予device的新实例
  24. var writeOnly io.Writer = new(device)
  25. // 写入数据
  26. writeOnly.Write(nil)
  27. }

代码说明如下:

  • 第 8 行定义了 device 结构体,用来模拟一个虚拟设备,这个结构会实现前面提到的 3 种接口。
  • 第 12 行,实现了 io.Writer 的 Write() 方法。
  • 第 17 行,实现了 io.Closer 的 Close() 方法。
  • 第 24 行,对 device 实例化,由于 device 实现了 io.WriteCloser 的所有嵌入接口,因此 device 指针就会被隐式转换为 io.WriteCloser 接口。
  • 第 27 行,调用了 wc(io.WriteCloser接口)的 Write() 方法,由于 wc 被赋值 *device,因此最终会调用 device 的 Write() 方法。
  • 第 30 行,与 27 行类似,最终调用 device 的 Close() 方法。
  • 第 33 行,再次创建一个 device 的实例,writeOnly 是一个 io.Writer 接口,这个接口只有 Write() 方法。
  • 第 36 行,writeOnly 只能调用 Write() 方法,没有 Close() 方法。


为了整理思路,将上面的实现、调用关系使用图方式来展现,参见图 1 和图 2。

1) io.WriteCloser的实现及调用过程如图 1 所示。


图1:io.WriteCloser 的实现及调用过程


2) io.Writer 的实现调用过程如图 2 所示。


图2:io.Write 的实现及调用过程


给 io.WriteCloser 或 io.Writer 更换不同的实现者,可以动态地切换实现代码。

 

 

Go语言接口和类型之间的转换

Go 语言中使用接口断言(type assertions)将接口转换成另外一个接口,也可以将接口转换为另外的类型。接口的转换在开发中非常常见,使用也非常频繁。

类型断言的格式

类型断言的基本格式如下:

  1. t := i.(T)

其中,i 代表接口变量,T 代表转换的目标类型,t 代表转换后的变量。

如果 i 没有完全实现 T 接口的方法,这个语句将会触发宕机。触发宕机不是很友好,因此上面的语句还有一种写法:

  1. t,ok := i.(T)

这种写法下,如果发生接口未实现时,将会把 ok 置为 false,t 置为 T 类型的 0 值。正常实现时,ok 为 true。这里 ok 可以被认为是:i 接口是否实现 T 类型的结果。

将接口转换为其他接口

实现某个接口的类型同时实现了另外一个接口,此时可以在两个接口间转换。

鸟和猪具有不同的特性,鸟可以飞,猪不能飞,但两种动物都可以行走。如果使用结构体实现鸟和猪,让它们具备自己特性的 Fly() 和 Walk() 方法就让鸟和猪各自实现了飞行动物接口(Flyer)和行走动物接口(Walker)。

将鸟和猪的实例创建后,被保存到 interface{} 类型的 map 中。interface{} 类型表示空接口,意思就是这种接口可以保存为任意类型。对保存有鸟或猪的实例的 interface{} 变量进行断言操作,如果断言对象是断言指定的类型,则返回转换为断言对象类型的接口;如果不是指定的断言类型时,断言的第二个参数将返回 false。例如下面的代码:

  1. var obj interface = new(bird)
  2. f, isFlyer := obj.(Flyer)

代码中,new(bird) 产生 *bird 类型的 bird 实例,这个实例被保存在 interface{} 类型的 obj 变量中。使用 obj.(Flyer) 类型断言,将 obj 转换为 Flyer 接口。f 为转换成功时的 Flyer 接口类型,isFlyer 表示是否转换成功,类型就是 bool。

下面是详细的代码(代码1):

  1. package main
  2. import "fmt"
  3. // 定义飞行动物接口
  4. type Flyer interface {
  5. Fly()
  6. }
  7. // 定义行走动物接口
  8. type Walker interface {
  9. Walk()
  10. }
  11. // 定义鸟类
  12. type bird struct {
  13. }
  14. // 实现飞行动物接口
  15. func (b *bird) Fly() {
  16. fmt.Println("bird: fly")
  17. }
  18. // 为鸟添加Walk()方法, 实现行走动物接口
  19. func (b *bird) Walk() {
  20. fmt.Println("bird: walk")
  21. }
  22. // 定义猪
  23. type pig struct {
  24. }
  25. // 为猪添加Walk()方法, 实现行走动物接口
  26. func (p *pig) Walk() {
  27. fmt.Println("pig: walk")
  28. }
  29. func main() {
  30. // 创建动物的名字到实例的映射
  31. animals := map[string]interface{}{
  32. "bird": new(bird),
  33. "pig": new(pig),
  34. }
  35. // 遍历映射
  36. for name, obj := range animals {
  37. // 判断对象是否为飞行动物
  38. f, isFlyer := obj.(Flyer)
  39. // 判断对象是否为行走动物
  40. w, isWalker := obj.(Walker)
  41. fmt.Printf("name: %s isFlyer: %v isWalker: %v\n", name, isFlyer, isWalker)
  42. // 如果是飞行动物则调用飞行动物接口
  43. if isFlyer {
  44. f.Fly()
  45. }
  46. // 如果是行走动物则调用行走动物接口
  47. if isWalker {
  48. w.Walk()
  49. }
  50. }
  51. }

代码说明如下:

  • 第 6 行定义了飞行动物的接口。
  • 第 11 行定义了行走动物的接口。
  • 第 16 和 30 行分别定义了鸟和猪两个对象,并分别实现了飞行动物和行走动物接口。
  • 第 41 行是一个 map,映射对象名字和对象实例,实例是鸟和猪。
  • 第 47 行开始遍历 map,obj 为 interface{} 接口类型。
  • 第 50 行中,使用类型断言获得 f,类型为 Flyer 及 isFlyer 的断言成功的判定。
  • 第 52 行中,使用类型断言获得 w,类型为 Walker 及 isWalker 的断言成功的判定。
  • 第 57 和 62 行,根据飞行动物和行走动物两者是否断言成功,调用其接口。


代码输出如下:
name: pig isFlyer: false isWalker: true
pig: walk
name: bird isFlyer: true isWalker: true
bird: fly
bird: walk

将接口转换为其他类型

在代码 1 中,可以实现将接口转换为普通的指针类型。例如将 Walker 接口转换为 *pig 类型,请参考下面的代码:

  1. p1 := new(pig)
  2. var a Walker = p1
  3. p2 := a.(*pig)
  4. fmt.Printf("p1=%p p2=%p", p1, p2)

对代码的说明如下:

  • 第 3 行,由于 pig 实现了 Walker 接口,因此可以被隐式转换为 Walker 接口类型保存于 a 中。
  • 第 4 行,由于 a 中保存的本来就是 *pig 本体,因此可以转换为 *pig 类型。
  • 第 6 行,对比发现,p1 和 p2 指针是相同的。


如果尝试将上面这段代码中的 Walker 类型的 a 转换为 *bird 类型,将会发出运行时错误,请参考下面的代码:

  1. p1 := new(pig)
  2. var a Walker = p1
  3. p2 := a.(*bird)

运行时报错:

panic: interface conversion: main.Walker is *main.pig, not *main.bird

报错意思是:接口转换时,main.Walker 接口的内部保存的是 *main.pig,而不是 *main.bird。

因此,接口在转换为其他类型时,接口内保存的实例对应的类型指针,必须是要转换的对应的类型指针。

总结

接口和其他类型的转换可以在 Go 语言中自由进行,前提是已经完全实现。

接口断言类似于流程控制中的 if。但大量类型断言出现时,应使用更为高效的类型分支 switch 特性。

 

Go语言空接口类型 

空接口是接口类型的特殊形式,空接口没有任何方法,因此任何类型都无须实现空接口。从实现的角度看,任何值都满足这个接口的需求。因此空接口类型可以保存任何值,也可以从空接口中取出原值。

提示

空接口类型类似于 C# 或 Java 语言中的 Object、C语言中的 void*、C++ 中的 std::any。在泛型和模板出现前,空接口是一种非常灵活的数据抽象保存和使用的方法。

空接口的内部实现保存了对象的类型和指针。使用空接口保存一个数据的过程会比直接用数据对应类型的变量保存稍慢。因此在开发中,应在需要的地方使用空接口,而不是在所有地方使用空接口。

将值保存到空接口

空接口的赋值如下:

  1. var any interface{}
  2. any = 1
  3. fmt.Println(any)
  4. any = "hello"
  5. fmt.Println(any)
  6. any = false
  7. fmt.Println(any)

代码输出如下:
1
hello
false

对代码的说明:

  • 第 1 行,声明 any 为 interface{} 类型的变量。
  • 第 3 行,为 any 赋值一个整型 1。
  • 第 4 行,打印 any 的值,提供给 fmt.Println 的类型依然是 interface{}。
  • 第 6 行,为 any 赋值一个字符串 hello。此时 any 内部保存了一个字符串。但类型依然是 interface{}。
  • 第 9 行,赋值布尔值。

从空接口获取值

保存到空接口的值,如果直接取出指定类型的值时,会发生编译错误,代码如下:

  1. // 声明a变量, 类型int, 初始值为1
  2. var a int = 1
  3. // 声明i变量, 类型为interface{}, 初始值为a, 此时i的值变为1
  4. var i interface{} = a
  5. // 声明b变量, 尝试赋值i
  6. var b int = i

第8行代码编译报错:

cannot use i (type interface {}) as type int in assignment: need type assertion

编译器告诉我们,不能将i变量视为int类型赋值给b。

在代码第 15 行中,将 a 的值赋值给 i 时,虽然 i 在赋值完成后的内部值为 int,但 i 还是一个 interface{} 类型的变量。类似于无论集装箱装的是茶叶还是烟草,集装箱依然是金属做的,不会因为所装物的类型改变而改变。

为了让第 8 行的操作能够完成,编译器提示我们得使用 type assertion,意思就是类型断言。

使用类型断言修改第 8 行代码如下:

  1. var b int = i.(int)

修改后,代码可以编译通过,并且 b 可以获得 i 变量保存的 a 变量的值:1。

空接口的值比较

空接口在保存不同的值后,可以和其他变量值一样使用==进行比较操作。空接口的比较有以下几种特性。

1) 类型不同的空接口间的比较结果不相同

保存有类型不同的值的空接口进行比较时,Go 语言会优先比较值的类型。因此类型不同,比较结果也是不相同的,代码如下:

  1. // a保存整型
  2. var a interface{} = 100
  3. // b保存字符串
  4. var b interface{} = "hi"
  5. // 两个空接口不相等
  6. fmt.Println(a == b)

代码输出如下:
false

2) 不能比较空接口中的动态值

当接口中保存有动态类型的值时,运行时将触发错误,代码如下:

  1. // c保存包含10的整型切片
  2. var c interface{} = []int{10}
  3. // d保存包含20的整型切片
  4. var d interface{} = []int{20}
  5. // 这里会发生崩溃
  6. fmt.Println(c == d)

代码运行到第8行时发生崩溃:

panic: runtime error: comparing uncomparable type []int

这是一个运行时错误,提示 []int 是不可比较的类型。下表中列举出了类型及比较的几种情况。

 

类型的可比较性
类  型 说  明
map 宕机错误,不可比较
切片([]T) 宕机错误,不可比较
通道(channel) 可比较,必须由同一个 make 生成,也就是同一个通道才会是 true,否则为 false
数组([容量]T) 可比较,编译期知道两个数组是否一致
结构体 可比较,可以逐个比较结构体的值
函数 可比较

 

 

示例:使用空接口实现可以保存任意值的字典

空接口可以保存任何类型这个特性可以方便地用于容器的设计。下面例子使用 map 和 interface{} 实现了一个字典。字典在其他语言中的功能和 map 类似,可以将任意类型的值做成键值对保存,然后进行找回、遍历操作。详细实现过程请参考下面的代码。

  1. package main
  2.  
  3. import "fmt"
  4.  
  5. // 字典结构
  6. type Dictionary struct {
  7. data map[interface{}]interface{} // 键值都为interface{}类型
  8. }
  9.  
  10. // 根据键获取值
  11. func (d *Dictionary) Get(key interface{}) interface{} {
  12. return d.data[key]
  13. }
  14.  
  15. // 设置键值
  16. func (d *Dictionary) Set(key interface{}, value interface{}) {
  17.  
  18. d.data[key] = value
  19. }
  20.  
  21. // 遍历所有的键值,如果回调返回值为false,停止遍历
  22. func (d *Dictionary) Visit(callback func(k, v interface{}) bool) {
  23.  
  24. if callback == nil {
  25. return
  26. }
  27.  
  28. for k, v := range d.data {
  29. if !callback(k, v) {
  30. return
  31. }
  32. }
  33. }
  34.  
  35. // 清空所有的数据
  36. func (d *Dictionary) Clear() {
  37. d.data = make(map[interface{}]interface{})
  38. }
  39.  
  40. // 创建一个字典
  41. func NewDictionary() *Dictionary {
  42. d := &Dictionary{}
  43.  
  44. // 初始化map
  45. d.Clear()
  46. return d
  47. }
  48.  
  49. func main() {
  50.  
  51. // 创建字典实例
  52. dict := NewDictionary()
  53.  
  54. // 添加游戏数据
  55. dict.Set("My Factory", 60)
  56. dict.Set("Terra Craft", 36)
  57. dict.Set("Don't Hungry", 24)
  58.  
  59. // 获取值及打印值
  60. favorite := dict.Get("Terra Craft")
  61. fmt.Println("favorite:", favorite)
  62.  
  63. // 遍历所有的字典元素
  64. dict.Visit(func(key, value interface{}) bool {
  65.  
  66. // 将值转为int类型,并判断是否大于40
  67. if value.(int) > 40 {
  68.  
  69. // 输出很贵
  70. fmt.Println(key, "is expensive")
  71. return true
  72. }
  73.  
  74. // 默认都是输出很便宜
  75. fmt.Println(key, "is cheap")
  76.  
  77. return true
  78. })
  79. }

值设置和获取

字典内部拥有一个 data 字段,其类型为 map。这个 map 的键和值都是 interface{} 类型,也就是实现任意类型关联任意类型。字典的值设置和获取通过 Set() 和 Get() 两个方法来完成,参数都是 interface{}。详细实现代码如下:

  1. // 字典结构
  2. type Dictionary struct {
  3. data map[interface{}]interface{} // 键值都为interface{}类型
  4. }
  5.  
  6. // 根据键获取值
  7. func (d *Dictionary) Get(key interface{}) interface{} {
  8. return d.data[key]
  9. }
  10.  
  11. // 设置键值
  12. func (d *Dictionary) Set(key interface{}, value interface{}) {
  13. d.data[key] = value
  14. }

代码说明如下:

  • 第 3 行,Dictionary 的内部实现是一个键值均为 interface{} 类型的 map,map 也具备与 Dictionary 一致的功能。
  • 第 8 行,通过 map 直接获取值,如果键不存在,将返回 nil。
  • 第 13 行,通过 map 设置键值。

遍历字段的所有键值关联数据

每个容器都有遍历操作。遍历时,需要提供一个回调返回需要遍历的数据。为了方便在必要时终止遍历操作,可以将回调的返回值设置为 bool 类型,外部逻辑在回调中不需要遍历时直接返回 false 即可终止遍历。

Dictionary 的 Visit() 方法需要传入回调函数,回调函数的类型为 func(k,v interface{})bool。每次遍历时获得的键值关联数据通过回调函数的 k 和 v 参数返回。Visit 的详细实现请参考下面的代码:

  1. // 遍历所有的键值, 如果回调返回值为false, 停止遍历
  2. func (d *Dictionary) Visit(callback func(k, v interface{}) bool) {
  3.  
  4. if callback == nil {
  5. return
  6. }
  7.  
  8. for k, v := range d.data {
  9. if !callback(k, v) {
  10. return
  11. }
  12. }
  13. }

代码说明如下:

  • 第 2 行,定义回调,类型为 func(k,v interface{})bool,意思是返回键值数据(k、v)。bool 表示遍历流程控制,返回 true 时继续遍历,返回 false 时终止遍历。
  • 第 4 行,当 callback 为空时,退出遍历,避免后续代码访问空的 callback 而导致的崩溃。
  • 第 8 行,遍历字典结构的 data 成员,也就是遍历 map 的所有元素。
  • 第 9 行,根据 callback 的返回值,决定是否继续遍历。

初始化和清除

字典结构包含有 map,需要在创建 Dictionary 实例时初始化 map。这个过程通过 Dictionary 的 Clear() 方法完成。在 NewDictionary 中调用 Clear() 方法避免了 map 初始化过程的代码重复问题。请参考下面的代码:

  1. // 清空所有的数据
  2. func (d *Dictionary) Clear() {
  3. d.data = make(map[interface{}]interface{})
  4. }
  5.  
  6. // 创建一个字典
  7. func NewDictionary() *Dictionary {
  8. d := &Dictionary{}
  9.  
  10. // 初始化map
  11. d.Clear()
  12. return d
  13. }

代码说明如下:

  • 第 3 行,map 没有独立的复位内部元素的操作,需要复位元素时,使用 make 创建新的实例。Go 语言的垃圾回收是并行的,不用担心 map 清除的效率问题。
  • 第 7 行,实例化一个 Dictionary。
  • 第 11 行,在初始化时调用 Clear 进行 map 初始化操作。

使用字典

字典实现完成后,需要经过一个测试过程,查看这个字典是否存在问题。

将一些字符串和数值组合放入到字典中,然后再从字典中根据键查询出对应的值,接着再遍历一个字典中所有的元素。详细实现过程请参考下面的代码:

  1. func main() {
  2.  
  3. // 创建字典实例
  4. dict := NewDictionary()
  5.  
  6. // 添加游戏数据
  7. dict.Set("My Factory", 60)
  8. dict.Set("Terra Craft", 36)
  9. dict.Set("Don't Hungry", 24)
  10.  
  11. // 获取值及打印值
  12. favorite := dict.Get("Terra Craft")
  13. fmt.Println("favorite:", favorite)
  14.  
  15. // 遍历所有的字典元素
  16. dict.Visit(func(key, value interface{}) bool {
  17.  
  18. // 将值转为int类型, 并判断是否大于40
  19. if value.(int) > 40 {
  20.  
  21. // 输出“很贵”
  22. fmt.Println(key, "is expensive")
  23. return true
  24. }
  25.  
  26. // 默认都是输出“很便宜”
  27. fmt.Println(key, "is cheap")
  28.  
  29. return true
  30. })
  31. }

代码说明如下:

  • 第 4 行创建字典的实例。
  • 第 7~9 行,将 3 组键值对通过字典的 Set() 方法设置到字典中。
  • 第 12 行,根据字符串键查找值,将结果保存在 favorite 中。
  • 第 13 行,打印 favorite 的值。
  • 第 16 行,遍历字典的所有键值对。遍历的返回数据通过回调提供,key 是键,value 是值。
  • 第 19 行,遍历返回的 key 和 value 的类型都是 interface{},这里确认 value 只有 int 类型,所以将 value 转换为 int 类型判断是否大于 40。
  • 第 23 和 29 行,继续遍历,返回 true
  • 第 23 行,打印键。


运行代码,输出如下:
favorite: 36
My Factory is expensive
Terra Craft is cheap
Don't Hungry is cheap

 

Go语言类型分支

Go 语言的 switch 不仅可以像其他语言一样实现数值、字符串的判断,还有一种特殊的用途——判断一个接口内保存或实现的类型。

类型断言的书写格式

switch 实现类型分支时的写法格式如下:

  1. switch 接口变量.(type) {
  2. case 类型1:
  3. // 变量是类型1时的处理
  4. case 类型2:
  5. // 变量是类型2时的处理
  6. default:
  7. // 变量不是所有case中列举的类型时的处理
  8. }

对各个部分的说明:

  • 接口变量:表示需要判断的接口类型的变量。
  • 类型1、类型2……:表示接口变量可能具有的类型列表,满足时,会指定 case 对应的分支进行处理。

使用类型分支判断基本类型

下面的例子将一个 interface{} 类型的参数传给 printType() 函数,通过 switch 判断 v 的类型,然后打印对应类型的提示,代码如下:

  1. package main
  2. import (
  3. "fmt"
  4. )
  5. func printType(v interface{}) {
  6. switch v.(type) {
  7. case int:
  8. fmt.Println(v, "is int")
  9. case string:
  10. fmt.Println(v, "is string")
  11. case bool:
  12. fmt.Println(v, "is bool")
  13. }
  14. }
  15. func main() {
  16. printType(1024)
  17. printType("pig")
  18. printType(true)
  19. }

代码输出如下:
1024 is int
pig is string
true is bool

代码第 9 行中,v.(type) 就是类型分支的典型写法。通过这个写法,在 switch 的每个 case 中写的将是各种类型分支。

代码经过 switch 时,会判断 v 这个 interface{} 的具体类型从而进行类型分支跳转。

switch 的 default 也是可以使用的,功能和其他的 switch 一致。

使用类型分支判断接口类型

多个接口进行类型断言时,可以使用类型分支简化判断过程。

现在电子支付逐渐成为人们普遍使用的支付方式,电子支付相比现金支付具备很多优点。例如,电子支付能够刷脸支付,而现金支付容易被偷等。使用类型分支可以方便地判断一种支付方法具备哪些特性,具体请参考下面的代码。

电子支付和现金支付:

  1. package main
  2. import "fmt"
  3. // 电子支付方式
  4. type Alipay struct {
  5. }
  6. // 为Alipay添加CanUseFaceID()方法, 表示电子支付方式支持刷脸
  7. func (a *Alipay) CanUseFaceID() {
  8. }
  9. // 现金支付方式
  10. type Cash struct {
  11. }
  12. // 为Cash添加Stolen()方法, 表示现金支付方式会出现偷窃情况
  13. func (a *Cash) Stolen() {
  14. }
  15. // 具备刷脸特性的接口
  16. type CantainCanUseFaceID interface {
  17. CanUseFaceID()
  18. }
  19. // 具备被偷特性的接口
  20. type ContainStolen interface {
  21. Stolen()
  22. }
  23. // 打印支付方式具备的特点
  24. func print(payMethod interface{}) {
  25. switch payMethod.(type) {
  26. case CantainCanUseFaceID: // 可以刷脸
  27. fmt.Printf("%T can use faceid\n", payMethod)
  28. case ContainStolen: // 可能被偷
  29. fmt.Printf("%T may be stolen\n", payMethod)
  30. }
  31. }
  32. func main() {
  33. // 使用电子支付判断
  34. print(new(Alipay))
  35. // 使用现金判断
  36. print(new(Cash))
  37. }

代码说明如下:

  • 第 6~19 行,分别定义 Alipay 和 Cash 结构,并为它们添加具备各自特点的方法。
  • 第 22~29 行,定义两种特性,即刷脸和被偷。
  • 第 32 行,传入支付方式的接口。
  • 第 33 行,使用类型分支进行支付方法的特性判断。
  • 第 34~37 行,分别对刷脸和被偷的特性进行打印。


运行代码,输出如下:
*main.Alipay can use faceid
*main.Cash may be stolen

 

posted @ 2019-01-17 14:05  丶无根生  阅读(1270)  评论(0编辑  收藏  举报