CppGuide社区 CppGuide社区
首页
  • 最新谷歌C++风格指南(含C++17/20)
  • C++17详解
  • C++20完全指南
  • C++23快速入门
  • C++语言面试问题集锦
  • 🔥C/C++后端开发常见面试题解析 (opens new window)
  • 网络编程面试题 (opens new window)
  • 网络编程面试题 答案详解 (opens new window)
  • 聊聊WebServer作面试项目那些事儿 (opens new window)
  • 字节跳动面试官现身说 (opens new window)
  • 技术简历指南 (opens new window)
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 从零用C语言写一个Redis
  • Windows 10系统编程
  • Go语言特性

    • Go开发实用指南
    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
  • Rust编程

    • Rust编程指南
  • 数据库

    • SQL零基础指南
    • MySQL开发与调试指南
  • Linux内核

    • 心中的内核 —— 在阅读内核代码之前先理解内核
    • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
    • TCP源码实现超详细注释版.pdf (opens new window)
GitHub (opens new window)
首页
  • 最新谷歌C++风格指南(含C++17/20)
  • C++17详解
  • C++20完全指南
  • C++23快速入门
  • C++语言面试问题集锦
  • 🔥C/C++后端开发常见面试题解析 (opens new window)
  • 网络编程面试题 (opens new window)
  • 网络编程面试题 答案详解 (opens new window)
  • 聊聊WebServer作面试项目那些事儿 (opens new window)
  • 字节跳动面试官现身说 (opens new window)
  • 技术简历指南 (opens new window)
  • 🔥交易系统开发岗位求职与面试指南 (opens new window)
  • 第1章 高频C++11重难点知识解析
  • 第2章 Linux GDB高级调试指南
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 高性能网络通信协议设计精要
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 后端服务重要模块设计探索
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 源码分析系列

    • leveldb源码分析
    • libevent源码分析
    • Memcached源码分析
    • TeamTalk源码分析
    • 优质源码分享 (opens new window)
    • 🔥远程控制软件gh0st源码分析
  • 从零手写C++项目系列

    • C++游戏编程入门(零基础学C++)
    • 🔥使用C++17从零开发一个调试器 (opens new window)
    • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
    • 🔥使用C++从零写一个C语言编译器 (opens new window)
    • 从零用C语言写一个Redis
  • Windows 10系统编程
  • Go语言特性

    • Go开发实用指南
    • Go系统接口编程
    • 高效Go并发编程
    • Go性能调优
    • Go项目架构设计
  • Go项目实战

    • 使用Go从零开发一个数据库
    • 🔥使用Go从零开发一个编译器 (opens new window)
    • 🔥使用Go从零开发一个解释器 (opens new window)
    • 🔥用Go从零写一个编排器(类Kubernetes) (opens new window)
  • Rust编程

    • Rust编程指南
  • 数据库

    • SQL零基础指南
    • MySQL开发与调试指南
  • Linux内核

    • 心中的内核 —— 在阅读内核代码之前先理解内核
    • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
    • TCP源码实现超详细注释版.pdf (opens new window)
GitHub (opens new window)
  • Go开发实用指南 说明
  • 第1章 Go项目如何组织
  • 第2章 字符串处理
  • 第3章 处理日期和时间
  • 第4章 使用数组、切片和映射
  • 第5章 使用类型、结构体和接口
    • 创建新类型
    • 基于现有类型创建新类型
      • 实现步骤……
    • 创建类型安全的枚举
      • 实现步骤……
    • 创建结构体类型
      • 实现步骤……
    • 扩展类型
      • 扩展基础类型
      • 实现步骤……
      • 工作原理……
    • 初始化结构体
      • 实现步骤……
    • 定义接口
      • 接口作为契约
      • 如何实现……
    • 工厂模式(Factories)
      • 如何实现……
    • 在使用接口的地方定义接口
      • 如何实现……
      • 工作原理……
    • 将函数用作接口
      • 如何实现……
      • 工作原理……
    • 在运行时发现数据类型的能力——测试“实现”关系
      • 如何操作……
      • 它的工作原理……
    • 测试接口值是否为已知类型之一
      • 如何操作……
    • 在开发过程中确保某个类型实现了接口
      • 如何操作……
    • 决定方法使用指针接收器还是值接收器
      • 如何操作……
      • 它的工作原理……
    • 多态容器
      • 如何操作……
      • 工作原理……
    • 通过接口间接访问对象的部分内容
      • 操作方法……
    • 从嵌入结构体访问被嵌入结构体
      • 操作方法……
    • 检查接口是否为nil
      • 操作方法……
      • 工作原理……
  • 第6章 使用泛型
  • 第7章 并发
  • 第8章 错误与恐慌(panic)
  • 第9章 context包
  • 第10章 处理大量数据
  • 第11章 处理JSON数据
  • 第12章 进程
  • 第13章 网络编程
  • 第14章 流式输入/输出
  • 第15章 数据库
  • 第16章 日志记录
  • 第17章 测试、基准测试和性能分析
目录

第5章 使用类型、结构体和接口

# 第5章 使用类型、结构体和接口

Go是一种强类型语言。这意味着程序中的每个值都必须使用一组预定义的基本类型来定义。类型系统的规则决定了可以对这些值执行哪些操作,以及不同类型的值如何相互作用。Go的类型系统采用了一种简单的方法,它只允许在不同兼容类型的值之间进行显式转换。

Go也是一种静态类型语言,这意味着值的类型在编译时会被显式声明和检查,而不是在运行时检查。这与Python或JavaScript等脚本语言不同。

在本章中,我们将探讨Go类型系统的一些特性,定义新类型、结构体和接口,并思考如何有效地利用它们来实现一些常见的模式。本章包含以下方法:

  • 创建新类型
  • 使用组合扩展类型
  • 初始化结构体
  • 使用接口
  • 工厂模式
  • 多态容器

# 创建新类型

定义新类型有几个原因。一个重要的原因是确保类型安全。类型安全可确保操作接收到正确类型的数据。一个类型安全的程序不会出现类型错误,将程序中可能出现的错误仅限制为逻辑错误。

创建新类型的其他原因还包括:

  • 可以通过嵌入一个类型,在多个不同类型中共享该类型的方法和数据字段。
  • 在本章后面的内容中,我们将介绍接口。可以为新类型定义一组方法,以实现给定的接口,这使你能够在不同的上下文中使用该类型。

# 基于现有类型创建新类型

创建新类型可以让你强制执行类型安全规则,并添加特定于该类型的方法。

# 实现步骤……

使用以下语法基于现有类型创建新类型:

type <新类型名称> <现有类型名称>
1

例如,以下声明定义了一个新的数据类型Duration,它是一个无符号64位整数:

type Duration uint64
1

Go标准库就是这样定义time.Duration的。现在,要调用time.Sleep(d Duration)函数,你必须使用time.Duration类型的值,或者将一个数值显式转换为time.Duration类型的值。

警告
当你从现有类型创建新类型时,即使现有类型定义了方法,新类型也不会继承这些方法。

# 创建类型安全的枚举

在这个方法中,我们将使用新类型定义一组常量(枚举)。

# 实现步骤……

  1. 定义一个新类型:
type Direction int
1
  1. 使用新类型创建一系列表示枚举值的常量。对于数值常量,可以使用iota来生成递增的数字:
const (
    DirectionLeft Direction = iota
    DirectionRight
)
1
2
3
4
  1. 在期望使用这个新类型的函数或数据元素中使用该新类型:
func SetDirection(dir Direction) {...}
func main() {
    SetDirection(DirectionLeft)
    SetDirection(Direction(0))
   ...
}
1
2
3
4
5
6
提示
这并不能阻止有人调用SetDirection(Direction(3)),而这是一个无效的值。这通常只在从用户输入或第三方来源读取枚举值时才会成为问题。在这种情况下,你应该对输入进行验证。

# 创建结构体类型

Go语言中的结构体(struct)是字段的集合。定义结构体是为了将相关的数据字段组合在一起,形成一条记录。本方法展示了如何在程序中创建新的结构体类型。

# 实现步骤……

使用以下语法创建结构体类型:

type 新类型名称 struct {
    // 字段列表
}
1
2
3

例如:

type User struct {
    Username string
    Password string
}
1
2
3
4

# 扩展类型

Go通过嵌入实现类型组合,通过使用接口实现结构类型。让我们先来看看这些概念的含义。

当你将一个现有类型嵌入到另一个类型中时,为被嵌入类型定义的方法和数据字段会成为嵌入类型的方法和数据字段。如果你使用过面向对象语言,这可能看起来与类继承类似,但有一个关键区别:如果类A派生自类B,那么A是B的一种,这意味着在需要B的任何地方,都可以用A的实例来替代。而在组合中,如果A嵌入了B,A和B是不同的类型,在需要B的地方不能使用A。

提示
Go语言中没有类型继承。Go选择了组合而不是继承。主要原因是组合组件以构建更复杂的组件更加简单。面向对象语言中大多数继承的用例都可以通过使用组合、接口和结构类型进行重新架构。我在这里特意使用了“重新架构”这个词:不要试图通过模仿继承将现有的面向对象程序移植到Go语言中。相反,应该使用组合和接口重新设计和重构它们,使其成为符合Go语言习惯的程序。

接下来的方法将介绍如何做到这一点。

# 扩展基础类型

首先,我们将看看如何扩展基础类型,以便在新类型中共享其数据元素和方法。

# 实现步骤……

假设你有一些数据字段和功能在多个数据类型之间共享。那么你可以创建一个基础数据类型,并将其嵌入到多个其他数据类型中,以共享公共部分:

type Common struct {
    commonField int
}

func (a Common) CommonMethod() {}

type A struct {
    Common
    aField int
}

func (a A) AMethod() {}

type B struct {
    Common
    bField int
}

func (b B) BMethod() {}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

在前面的代码片段中,每个结构体的字段和方法如下:

类型 字段 方法
Common commonField CommonMethod
A commonField, aField CommonMethod, AMethod
B commonField, bField CommonMethod, BMethod

# 工作原理……

在上一节中,我们使用结构体嵌入来共享公共数据元素和功能。下面的示例展示了两个结构体Customer和Product,它们共享相同的Metadata结构体。Metadata包含记录的唯一标识符、创建日期和修改日期:

type Metadata struct {
    ID         string
    CreatedAt  time.Time
    ModifiedAt time.Time
}

// New初始化元数据字段
func (m *Metadata) New() {
    m.ID = uuid.New().String()
    m.CreatedAt = time.Now()
    m.ModifiedAt = m.CreatedAt
}

// Customer.New()使用提升后的Metadata.New()方法。
// 调用Customer.New()将初始化Customer.Metadata,但不会修改Customer特定的字段。
type Customer struct {
    Metadata
    Name string
}

// Product.New(string)遮蔽了Metadata.New()方法。
// 你不能调用Product.New(),但可以调用Product.New(string)或Product.Metadata.New()
type Product struct {
    Metadata
    SKU string
}

func (p *Product) New(sku string) {
    // 初始化产品的元数据部分
    p.Metadata.New()
    p.SKU = sku
}

func main() {
    c := Customer{}
    c.New() // 初始化客户元数据
    p := Product{}
    p.New("sku") // 初始化产品元数据和SKU
    // p.New() // 编译错误:p.New()需要一个字符串参数
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

嵌入不是继承。被嵌入结构体方法的接收者不是定义的结构体的副本。在前面的代码片段中,当我们调用c.New()时,Metadata.New()方法得到的接收者是*Metadata的实例,而不是*Customer的实例。

# 初始化结构体

本方法展示了如何使用结构体字面量来初始化包含嵌入结构体的复杂数据结构。

# 实现步骤……

Go语言保证所有声明的变量都会被初始化为其零值。如果你有一个复杂的数据结构,应该用默认值或非零指针组件进行初始化,那么零值初始化就不太有用了。对于这种情况,可以使用类似构造函数的函数来创建结构体的新实例。约定俗成的做法是为类型X编写一个NewX函数,该函数初始化X或*X的实例并返回它。

这里,NewIndex函数创建一个新的已初始化的Index类型实例:

type Index struct {
    index map[string]any
    name  string
}

func NewIndex(name string) *Index {
    return &Index{
        index: make(map[string]any),
        name:  name,
    }
}

func (index *Index) Name() string { return index.name }
func (index *Index) Add(key string, value any) {
    index.index[key] = value
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

另外,请注意Index.name和Index.index字段没有导出。因此,只能使用Index的导出方法来访问它们。这种模式有助于防止数据字段被意外修改。

# 定义接口

Go语言使用“结构类型”。如果类型T定义了接口I的所有方法,那么T就实现了I。这会让熟悉使用标称类型(nominative typing)语言(如Java,在Java中你必须显式指定组成类型的名称)的开发者感到困惑。

Go语言的接口只是方法集。当一个数据类型定义了一组方法时,它也会自动实现所有包含其方法子集的接口。例如,如果数据类型A定义了一个func (A) F()方法,那么A也实现了interface { func F() }和interface{}接口。如果接口A是接口B的子集,那么实现接口B的数据类型可以在任何需要A的地方使用。

# 接口作为契约

接口可以用作“规范”,或者像一个“契约”,定义了实现应该满足的某些函数。

# 如何实现……

定义一个或一组接口,来指定对象预期的行为。当期望同一接口有多种不同实现时,这种方式很适用。例如,标准库database/driver(SQL驱动包)定义了一组接口,不同的数据库驱动都应该实现这些接口。

例如,下面的代码片段定义了一个用于存储文件的存储后端:

type Storage interface {
    Create(name string, reader io.Reader) error
    Read(name string) (io.ReadCloser, error)
    Update(name string, reader io.Reader) error
    Delete(name string) error
}
1
2
3
4
5
6

你可以使用实现了Storage接口的对象实例,将数据存储在不同的后端,比如文件系统或某些网络存储系统中。

在很多情况下,用于声明这类接口方法的数据类型本身依赖于实际的实现。在这种情况下,就需要一个接口体系。标准库database/driver包采用了这种方法。例如,考虑以下认证提供程序接口:

// Authenticator使用特定实现的凭证创建特定实现的会话
type Authenticator interface {
    Login(credentials Credentials) (Session, error)
}
// Credentials包含用于对用户进行后端认证的凭证
type Credentials interface {
    Serialize() []byte
    Type() string
}
// CredentialParse实现从[]byte输入中解析特定后端的凭证
type CredentialParser interface {
    Parse([]byte) (Credentials, error)
}
// 特定后端的会话用于识别用户并提供关闭会话的方法
type Session interface {
    UserID() string
    Close() 
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 工厂模式(Factories)

本节展示一种经常用于支持可扩展结构的方法,比如数据库驱动。在这种情况下,导入特定的数据库驱动包会自动将驱动“注册”到一个工厂中。

# 如何实现……

  1. 定义一个或一组接口,指定实现应该具有的行为。
  2. 创建一个注册表(映射)和一个用于注册实现的函数。
  3. 每个不同的实现都使用init()函数将自身注册到注册表中。
  4. 使用主包导入要包含在程序中的实现。

让我们使用上一节的Authenticator示例来实现一个认证框架。我们将允许Authenticator框架有不同的实现。

首先,定义一个工厂接口和一个用于保存所有已注册实现的映射:

package auth

type AuthenticatorFactory interface { 
    NewInstance() Authenticator
}

var registry = map[string]AuthenticatorFactory{}
1
2
3
4
5
6
7

然后,声明一个导出的Register函数:

func RegisterAuthenticator(name string, factory AuthenticatorFactory) {
    registry[name] = factory
}
1
2
3

为了动态创建认证器实例,我们需要一个如下所示的函数:

func NewInstance(authType string) Authenticator {
    // 使用选定的工厂创建一个新实例。
    // 如果给定的authType未注册,这将导致程序崩溃
    return registry[authType].NewInstance()
}
1
2
3
4
5

实现可以使用init()函数注册它们自己的工厂:

type factory struct{}

func (factory) NewInstance() auth.Authenticator {
    // 创建并返回一个新的数据库认证器实例
}

func init() {
    auth.RegisterAuthenticator("dbauthenticator", factory{})
}
1
2
3
4
5
6
7
8
9

最后,你必须将这些部分整合起来。Go构建系统只会包含那些可从main()函数访问的代码直接或间接使用的包,而实现并没有被直接引用。我们必须确保导入了这些包,这样实现就会被注册。所以,在main函数中导入它们:

package main
import (
    _ "import/path/of/the/implementation"
   ...
)
1
2
3
4
5

前面的导入会将实现包包含在程序中。由于该包被包含在程序中,它的init()函数会在程序初始化期间被调用,它提供的认证器类型也会被注册。

# 在使用接口的地方定义接口

结构类型系统(Structural typing)允许你在需要使用接口时进行定义,而不是预先定义一个导出的接口。这有时会与“鸭子类型(duck-typing)”(如果某个东西走路像鸭子,叫声也像鸭子,那它就是鸭子)混淆。区别在于,鸭子类型是指在运行时通过查看类型结构的子集来确定数据类型的兼容性,而结构类型是指在编译时查看类型的结构。本方法展示如何根据需要定义接口。

# 如何实现……

假设你有如下代码:

type A struct {
    ...
    options  map[string]any
}

func (a A) GetOptions() map[string]any {
    return a.options
}

type B struct {
   ...
    options map[string]any
}

func (b B) GetOptions() map[string]any {
    return b.options
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

如果你想编写一个函数,对类型为A或B(或任何有options的类型)的变量的options进行操作,你可以直接在那里定义一个接口:

type withOptions interface {
    GetOptions() map[string]any
}

func ProcessOptions(item withOptions) {
    for key, value := range item.GetOptions() {
       ...
    }
}
1
2
3
4
5
6
7
8
9

# 工作原理……

记住,Go语言使用结构类型系统。所以,你可以创建一个指定一组方法的接口,任何声明了这些方法的数据类型都会自动实现该接口。因此,你可以临时创建这样的接口,并编写接受这些接口实例的函数,以便处理大量潜在的数据类型。

如果你使用的是命名类型语言,你必须指定这些类型实现你的接口。但在Go语言中并非如此。

这也意味着,如果你有一个接口A和另一个接口B,A声明的方法与B相同,那么任何实现了A的类型也实现了B。换句话说,如果你因为导入某个接口会导致循环依赖,或者该接口没有被所在包导出而无法导入它,你可以在当前包中简单地定义一个等效的接口。

# 将函数用作接口

有时,你可能会遇到需要接口但实际只有函数的情况。本方法展示如何定义一个新的函数数据类型,使其也能实现接口。

# 如何实现……

如果你需要实现一个没有任何数据元素的单方法接口,你可以基于空结构体定义一个新类型,并为该类型声明一个方法来实现该接口。或者,你可以直接将函数本身用作该接口的实现。以下内容摘自标准库net/http包:

// 一个只有单个函数的接口
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

// 定义一个与接口方法签名匹配的新函数类型
type HandlerFunc func(ResponseWriter, *Request)

// 为函数类型实现该方法
func (h HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    h(w, r) // 调用底层函数
}
1
2
3
4
5
6
7
8
9
10
11
12

在这里,只要需要Handler接口的实现,你就可以使用HandlerFunc类型的函数。

# 工作原理……

Go语言的类型系统将函数类型视为其他任何定义的类型。因此,你可以为函数类型声明方法。当你为函数类型声明方法时,该函数类型会自动实现所有定义了这些方法(全部或部分)的接口。

让我们通过一个例子来分析这个说法。我们可以声明一个新的空类型来实现Handler接口:

type MyHandler struct{}
func (MyHandler) ServeHTTP(w ResponseWriter, r *Request) {...}
1
2

通过这个声明,在任何需要Handler的地方都可以使用MyHandler的实例。然而,注意到MyHandler没有数据元素,只有一个方法。所以,我们定义一个函数类型:

type MyHandler func(ResponseWriter, *Request)
1

现在MyHandler是一个新的命名类型。这与将MyHandler声明为结构体没有太大区别,但在这种情况下,MyHandler是一个具有固定签名的函数。

由于MyHandler是一个命名类型,我们可以为它定义方法:

func (h MyHandler) ServeHTTP(w ResponseWriter, r *Request) {
    h(w, r)
}
1
2
3

因为MyHandler现在定义了ServeHTTP方法,所以它实现了Handler接口。然而,MyHandler是一个函数类型,所以h实际上是一个与ServeHTTP具有相同签名的函数。因此,h(w,r)调用有效,并且MyHandler可以在需要Handler的地方使用。

# 在运行时发现数据类型的能力——测试“实现”关系

接口提供了一种调用底层数据对象方法的方式。如果同一个接口由许多不同的类型实现,你可以使用一个函数,通过它们的公共接口来操作不同的数据类型。然而,很多时候,你需要访问存储在接口中的底层对象。Go语言提供了几种机制来实现这一点。我们将介绍类型断言(type-assertion)和类型切换(type-switch)。

# 如何操作……

使用接口(interfaces)和类型断言(type assertions)来探索一个类型所提供的不同方法。请记住,接口是一个方法集。实现了接口中所定义方法的类型,会自动实现该接口。

可以使用以下模式来判断一个数据类型是否有某个方法:

func f(rd io.Reader) {
    // rd也是io.Writer吗?
    if wr, ok := rd.(io.Writer); ok {
        // 是的,rd是一个io.Writer,wr就是那个写入器。
        ...
    }
    
    // rd有ReadLine() (string,error)这个函数吗?
    // 在这里定义一个接口
    type hasReadLine interface {
    	ReadLine() (string,error)
    }
    
    // 然后看看rd是否实现了它:
    if readLine, ok := rd.(hasReadLine); ok {
        // 是的,你可以使用readLine:
        line, err: = readLine.ReadLine()
        ...
    }
    
    // 你甚至可以内联定义匿名接口:
    if readLine, ok := rd.(interface{ReadLine()(string,error)}); ok {
    	line, err := readLine.ReadLine()
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

# 它的工作原理……

类型断言有两种形式。以下形式用于测试intf接口变量是否包含concreteValue类型的具体值:

value, ok := intf.(concreteValue)
1

如果接口包含该类型的值,那么value现在就拥有这个值,并且ok会变为true。

第二种形式用于测试intf接口中包含的具体值是否也实现了otherIntf接口:

value, ok := intf.(otherIntf)
1

如果intf中包含的值也拥有otherIntf所声明的方法,那么value现在就是一个otherIntf类型的接口值,它包含与intf相同的具体值,并且ok被设置为true 。

使用第二种形式,你可以测试一个接口变量是否实现了你所需要的方法。

你可能认为可以使用反射(Reflection)来做同样的事情。反射是一种在运行时发现类型的字段和方法名称的方法。但它并不是一种检查类型等价性的高效或简便的方法。

# 测试接口值是否为已知类型之一

类型开关(type-switch)用于测试接口值是否为已知的具体类型,或者它是否实现了某个特定接口。本方法展示了如何使用它。

# 如何操作……

如果你需要针对多种类型检查一个接口,那么使用类型开关而不是一系列的类型断言。

以下示例使用interface{}来对两个值进行相加操作。这两个值可以都是int类型,也可以都是float64类型。该函数还提供了一种覆盖加法行为的方式:如果值有兼容的Add方法,就调用该方法:

// a和b必须具有相同的类型。它们可以是int、float64,或者
// 具有Add方法的其他类型
func Add(a, b interface{}) interface{} {
    // 类型开关:
    // 在这种形式中,匹配的case块将声明一个具有正确类型的aValue
    switch aValue := a.(type) {
        case int:
            // 这里,aValue是一个int
            // b必须是一个int!
            bValue:=b. (int)
            return aValue+bValue
        
        case float64:
            // 这里,aValue是一个float64
            // b必须是一个float64!
            bValu e:= b. (float64)
            return aValue+bValue
        
        case interface { Add(interface{}) interface{} }:
            // 这里,aValue是一个interface {Add{interface{}) interface{}}
            return aValue.Add(b)
        
        default:
            // 这里,aValue未定义
            // 这是一个未处理的情况
            return nil
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

注意,当case匹配时,类型开关用于提取接口中包含的值的方式。只有当case列出单个类型,并且case不是default情况时,这种方式才有效。对于那些情况,变量根本未定义,你只能使用接口进行操作 。

# 在开发过程中确保某个类型实现了接口

在项目的开发阶段,接口类型可能会快速变化,比如添加新方法,或者通过更改参数类型或返回类型来修改现有方法的签名。开发人员如何确保这些接口的某些实现不会因这些变化而出现问题呢?

# 如何操作……

假设你的团队定义了以下接口:

type Car interface { 
    Move(int, int)
}
1
2
3

再假设你使用以下结构体实现了该接口:

type RaceCar struct {
	X, Y int
}

func (r *RaceCar) Move(dx, dy int) {
    r.X += dx
    r.Y += dy
}
1
2
3
4
5
6
7
8

然而,在后续开发中发现,并非所有汽车都能成功移动,因此接口的签名更改为以下内容:

type Car interface {
	Move(int, int) error
}
1
2
3

由于这个更改,RaceCar不再实现Car接口。很多时候,这个错误会在编译时被捕获,但并非总是如此。例如,如果将*RaceCar的实例传递给需要any类型的函数,编译会成功,但如果通过类型断言将该参数转换为Car或*RaceCar,运行时就会引发恐慌:

rc := item. (Car)
1

假设你声明如下内容:

var _ Car = &RaceCar{}
1

对Car接口的任何修改,只要导致*RaceCar不再实现Car接口,都会引发编译错误。

所以,一般来说:声明一个具有接口类型的空白变量,并将具体类型赋值给它:

type I interface {...}
type Implem struct { ... }

// 如果Implem或I发生了某些变化,导致Implem
// 不再实现接口I,这将给出一个编译时错误
var _ I = Implem{}

// 与上述相同,但这确保*Implem实现I
var _ I = &Implem{}
1
2
3
4
5
6
7
8
9

如果有任何变化导致该类型不再实现该接口,就会引发编译错误。

# 决定方法使用指针接收器还是值接收器

在本方法中,我们将探讨如何在方法的指针接收器和值接收器之间做出选择。

# 如何操作……

一般来说,选择一种,而不是两者都用。这有两个原因:

  • 代码的一致性。
  • 混合使用值接收器和指针接收器可能会导致数据竞争(data races)。

如果一个方法会修改接收器对象,那么使用指针接收器。如果一个方法不会修改接收器对象,或者该方法依赖于获取接收器对象的副本,那么你可以使用值接收器。如果你正在实现一个不可变类型,在大多数情况下,应该使用值接收器。

如果你的结构体很大,使用指针接收器将减少复制开销。对于什么样的结构体可以被认为是大结构体,有不同的判断准则。如果不确定,可以编写一个基准测试并进行测量。

# 它的工作原理……

对于类型T,如果你使用值接收器声明一个方法,那么该方法同时为T和*T声明。该方法获取的是接收器的副本,而不是指向它的指针,所以对接收器所做的任何修改都不会反映到用于调用该方法的对象上。

例如,以下方法在修改一个字段的同时返回原始对象的副本:

type Action struct {
	Option string
}

// 返回a的副本,并设置给定的选项。原始的a不会被修改。
func (a Action) WithOption(option string) Action {
    a.Option = option
    return a
}

func main() {
    x: = Action{
    	Option:"a",
    }
    y: = x.WithOption("b")
    
    fmt.Println(x.Option, y.Option) // 输出:a b
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

值接收器会创建原始对象的浅拷贝。如果接收器结构体包含映射(maps)、切片(slices)或指向其他对象的指针,那么只会复制映射头部、切片头部或指针,而不会复制所指向对象的内容。这意味着,即使在以下示例中方法使用了值接收器,对映射的修改也会同时反映在原始对象和副本中:

type T struct {
	m map[string]int
}

func (t T) add(k string, v int) {
	t.m[k] = v
}

func main() {
    t: = T {
    	m: make(map[string]int,
    }
    t.add("a",1)
                
    fmt.Println(t) // [a:1]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

要注意这对切片操作的影响。切片是一个三元组(指针、长度、容量),当你传递值接收器时,复制的就是这个三元组:

type T struct {
	s []string
}

func (t T) set(i int, s string) {
	t.s[i] = s
}

func (t T) add(s string) {
	t.s = append(t.s,s)
}

func main() {
    t:=T {
    s: []string{"a","b"},
	}
    
    fmt.Println(t.s) // [a, b]

    // 设置值接收器中包含的切片元素在这里是可见的
    t.set(0,"x")
    fmt.Println(t.s) // [x, b]

    // 向值接收器中包含的切片追加元素在这里是不可见的
    // 追加后的切片头部设置在t的副本中,原始的t永远不会看到这个更新
    t.add("y")
    fmt.Println(t.s) // [x, b]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

指针接收器的使用则更加直接。该方法始终获取调用它的对象的指针。在前面的示例中,使用指针接收器声明add方法的行为符合预期:

func (t *T) add(s string) {
    t.s = append(t.s,s)
}

...

t.add("y")
fmt.Println(t.s) // [x, b, y]
1
2
3
4
5
6
7
8

在本节开头,我还提到混合使用指针接收器和值接收器会导致数据竞争。下面说明它是如何发生的。

请记住,当一个goroutine从一个正在被另一个goroutine并发修改的变量中读取数据时,就会发生数据竞争。考虑以下示例,其中Version方法使用了值接收器,这会导致创建T的副本:

type T struct {
	X int
}

func (t T) Version() int  {return 1}

func (t *T) SetValue(x int) {t.X=x}

func main() {
    t: = T{}
    
    go func () {
    	t.SetValue(1) // 写入t.X
    }()
    
    ver := t.Version() // 复制t,读取t.X
    
    ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

调用t.Version()会创建变量t的副本,在t.X被修改的同时并发读取它,从而导致竞争。如果t.Version显式地从t.X读取数据,这种竞争会更加明显。无法保证该读取操作能看到goroutine中写入操作的结果。

# 多态容器

在本文中,容器是一种存储多个对象的数据结构。本节的原理同样适用于单个对象。换句话说,当你有一个多态变量或结构体字段时,也可以使用相同的思路。

# 如何操作……

  1. 定义一个接口,其中包含所有将存储在容器中的数据类型所共有的方法。
  2. 使用该接口声明容器类型。
  3. 将实际对象的实例放入容器中。
  4. 当你从容器中检索对象时,你既可以通过接口来操作对象,也可以进行类型断言,获取实际类型或其他接口,然后进行相应操作。

# 工作原理……

下面是一个使用Shape(形状)对象的简单示例。Shape对象是可以绘制在图像上并能四处移动的元素。

type Shape interface {
    Draw(image.Image)
    Move(dx, dy int)
}
1
2
3
4

Shape有多个实现:

type Rectangle struct {
    rect  image.Rectangle
    color color.Color
}

func (r *Rectangle) Draw(target image.Image) {...}
func (r *Rectangle) Move(dx, dy int) {...}

type Circle struct {
    center image.Point
    color  color.Color
}

func (c *Circle) Draw(target image.Image) {...}
func (c *Circle) Move(dx, dy int) {...}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

*Rectangle和*Circle都实现了Shape接口(注意,Rectangle和Circle本身没有实现)。现在,我们可以操作一个Shape类型的切片:

func Draw(target image.Image, shapes []Shape) {
    for _, shape := range shapes {
        shape.Draw(target)
    }
}
1
2
3
4
5

这个shapes切片看起来如下:

img 图5.1 - 接口变量切片

由于每个接口都包含一个指向实际形状的指针,因此也可以使用接口来调用修改对象的方法:

func Move(dx, dy int, shapes []Shape) {
    for _, shape := range shapes {
        shape.Move(dx, dy)
    }
}
1
2
3
4
5

# 通过接口间接访问对象的部分内容

在使用接口时,很多情况下需要访问接口底层的对象。这可以通过类型断言(type - assertion)来实现,即测试接口的值是否满足给定类型,如果满足,则获取该值。

# 操作方法……

使用类型断言或类型切换(type switch)来测试接口中包含的对象类型:

func f(shape Shape) {
    if rect, ok := shape.(*Rectangle); ok {
        // shape包含一个*Rectangle,rect现在指向它
    }
    
    switch actualShape := shape.(type) {
    case *Circle:
        // shape是一个*Circle,actualShape是一个*Circle变量
        
    case *Rectangle:
        // shape是一个*Rectangle,actualShape是一个*Rectangle变量
        
    default:
        // shape既不是圆也不是矩形。这里未定义actualShape
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 从嵌入结构体访问被嵌入结构体

在像Java或C++这样的面向对象语言中,有抽象方法(abstract method)或虚方法(virtual method)的概念,以及类型继承。这个特性的一个影响是,如果你调用基类base的方法M,那么在运行时执行的方法是为运行时实际对象声明的M方法的实现。换句话说,你可以调用一个会被其他声明覆盖的方法,而你并不知道实际调用的是哪个方法。

在Go语言中也有实现类似功能的方法。本方法将展示如何实现。

# 操作方法……

假设你需要编写一个循环链表数据结构,其元素将是嵌入了一个基结构体的结构体:

type ListNodeHeader struct {
    next Node
    prev Node
    list *List
}
1
2
3
4
5

链表本身如下:

type List struct {
    first Node
}
1
2
3

所以,链表指向第一个节点,这个节点是链表中的任意一个节点,每个节点指向下一个节点,最后一个节点再指回第一个节点。

我们需要一个Node接口来定义维护链表的机制。当然,Node接口将由ListNodeHeader实现,因此也会被链表中的所有节点实现:

type Node interface {
   ...
}
1
2
3

链表的使用者应该嵌入ListHeader来实现链表节点:

type ByteSliceElement struct {
    ListNodeHeader
    Payload []byte
}

type StringElement struct {
    ListNodeHeader
    Payload string
}
1
2
3
4
5
6
7
8
9

现在,难点在于实现Node接口。假设你想在这个链表中插入一个ByteSliceElement。由于ByteSliceElement嵌入了ListNodeHeader,它拥有ListNodeHeader的所有方法,因此实现了Node接口。然而,如果不知道实际插入的对象,我们就无法为ListNodeHeader编写插入(Insert)方法。

实现这个功能的一种方法是使用以下模式:

type Node interface {
    Insert(list *List, this Node)
    getHeader() *ListNodeHeader
}

func (header *ListNodeHeader) getHeader() *ListNodeHeader {
    return header
}

func (header *ListNodeHeader) Insert(list *List, this Node) {
    // 如果链表为空,这是唯一的节点
    if list.first == nil {
        list.first = this
        header.next = this
        header.prev = this
        return
    }
    
    header.next = list.first
    header.prev = list.first.getHeader().prev
    header.prev.getHeader().next = this
    header.next.getHeader().prev = this
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

这里涉及几个操作。首先,Insert方法获取了要插入节点的两种视图。如果要插入的节点是*ByteSliceElement,那么它会得到一个Node类型的this,同时也会得到嵌入在ByteSliceElement中的*ListNodeHeader作为接收者。通过这个接收者,它可以调整ByteSliceElement的成员,使其指向前一个和后一个节点。

然而,它无法访问Node的prev和next成员。

一种选择是像这里展示的:在Node接口中声明一个未导出的方法,该方法将从给定节点返回ListNodeHeader。另一种选择是在接口中添加getNext/setNext和getPrev/setPrev方法。

现在你实现了两件事:第一,这个包外部的任何使用这个链表结构的用户都必须嵌入ListNodeHeader来实现链表节点。接口中有一个未导出的方法。在不同的包中无法实现这样的接口。唯一的方法是嵌入一个已经实现了该接口的结构体。

第二,你有了一个多态的容器数据结构,其机制由一个基结构体管理。

# 检查接口是否为nil

你可能会想这为什么会是个问题。毕竟,不是直接与nil比较就可以了吗?并非总是如此。

一个接口包含两个值:接口中所包含值的类型,以及指向该值的指针。只有当这两个值都为nil时,接口才为nil。在某些情况下,接口可能指向一个类型不为nil但值为nil的对象,这会使接口不为nil。

你不能轻易检查这种情况。你必须避免创建值为nil的接口。

# 操作方法……

避免将可能为nil的变量指针转换为接口:

type myerror struct{}
func (myerror) Error() string { return "" }

func main() {
    var x *myerror
    var y error
    y = x // 避免这样做
    
    if y != nil {
        // y不为nil!
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

而是显式检查接口值是否为nil,如下所示:

var y error
if x != nil {
    y = x
}
1
2
3
4

或者,使用值类型的错误而不是指针类型。以下代码完全避免了这个问题:

var x myerror
1

x不可能为nil。

# 工作原理……

正如我前面解释的,一个接口包含两个值:类型和值。你要避免创建的是包含类型不为nil但值为nil的接口。

在下面的声明之后,y接口为nil,因为它的类型和值都是nil:

var y error
1

在下面的赋值之后,存储在y中的类型现在是x的类型,而值为nil。因此,y不再为nil:

y = x
1

这也适用于函数返回:

func f() error {
    var x *myerror
    return x
}
1
2
3
4

f函数永远不会返回nil。

上次更新: 2025/06/17, 19:51:40
第4章 使用数组、切片和映射
第6章 使用泛型

← 第4章 使用数组、切片和映射 第6章 使用泛型→

最近更新
01
第二章 关键字static及其不同用法
03-27
02
第一章 auto与类型推导
03-27
03
第四章 Lambda函数
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式