青训营X豆包MarsCode 技术训练营 - Golang 语法解析 - 5 | 豆包MarsCode AI 刷题

108 阅读15分钟

本文记录训练营语法基础课部分的相关内容,对于课程中讲的不够充分的地方,结合了go入门指南中的详细介绍进行了补充。笔记同步更新在我的博客

本篇讲述了golang中接口相关的内容。

概念

Go 语言不是一种 “传统” 的面向对象编程语言:它里面没有类和继承的概念。

但是 Go 语言里有非常灵活的 接口 概念,通过它可以实现很多面向对象的特性。接口提供了一种方式来 说明 对象的行为:如果谁能搞定这件事,它就可以用在这儿。

接口定义了一组方法(方法集),但是这些方法不包含(实现)代码:它们没有被实现(它们是抽象的)。接口里也不能包含变量。

type Namer interface {
    Method1(param_list) return_type
    Method2(param_list) return_type
    ...
}

在 Go 语言中接口可以有值,一个接口类型的变量或一个 接口值 :var ai Namerai 是一个多字(multiword)数据结构,它的值是 nil。它本质上是一个指针,虽然不完全是一回事。ai指向两个域,receiverreceiver所实现的method table

指向接口值的指针是非法的,它们不仅一点用也没有,还会导致代码错误。

类型(比如结构体)可以实现某个接口的方法集;这个实现可以描述为,该类型的变量上的每一个具体方法所组成的集合,包含了该接口的方法集。实现了 Namer 接口的类型的变量可以赋值给 ai(即 receiver 的值),方法表指针(method table ptr)就指向了当前的方法实现。当另一个实现了 Namer 接口的类型的变量被赋给 aireceiver 的值和方法表指针也会相应改变。

实现规则

  1. 类型不需要显式声明它实现了某个接口:接口被隐式地实现。多个类型可以实现同一个接口
  2. 实现某个接口的类型(除了实现接口方法外)可以有其他的方法
  3. 一个类型可以实现多个接口
  4. 接口类型可以包含一个实例的引用, 该实例的类型实现了此接口(接口是动态类型)
  5. 即使接口在类型之后才定义,二者处于不同的包中,被单独编译:只要类型实现了接口中的方法,它就实现了此接口。

例子

例子一

package main

import "fmt"

type Shaper interface {
	Area() float32
}

type Square struct {
	side float32
}

func (sq *Square) Area() float32 {
	return sq.side * sq.side
}

func main() {
	sq1 := new(Square)
	sq1.side = 5

	var areaIntf Shaper
	areaIntf = sq1
	// shorter,without separate declaration:
	// areaIntf := Shaper(sq1)
	// or even:
	// areaIntf := sq1
	fmt.Printf("The square has area: %f\n", areaIntf.Area())
}

例子二

io 包里有一个接口类型 Reader:

type Reader interface {
    Read(p []byte) (n int, err error)
}
var r io.Reader
	r = os.Stdin
	r = bufio.NewReader(r)
	r = new(bytes.Buffer)
	f,_ := os.Open("test.txt")
	r = bufio.NewReader(f)

上面 r 右边的类型都实现了 Read() 方法,并且有相同的方法签名,r 的静态类型是 io.Reader

有的时候,也会以一种稍微不同的方式来使用接口这个词:从某个类型的角度来看,它的接口指的是:它的所有导出方法,只不过没有显式地为这些导出方法额外定一个接口而已。

嵌套接口

一个接口可以包含一个或多个其他的接口,这相当于直接将这些内嵌接口的方法列举在外层接口中一样。

type ReadWrite interface {
    Read(b Buffer) bool
    Write(b Buffer) bool
}
type Lock interface {
    Lock()
    Unlock()
}
type File interface {
    ReadWrite
    Lock
    Close()
}

类型断言

一个接口类型的变量 varI 中可以包含任何类型的值,必须有一种方式来检测它的 动态 类型,即运行时在变量中存储的值的实际类型。在执行过程中动态类型可能会有所不同,但是它总是可以分配给接口变量本身的类型。通常我们可以使用 类型断言 来测试在某个时刻 varI 是否包含类型 T 的值:

v := varI.(T)

更加防御性的编程

if _, ok := varI.(T); ok {  // checked type assertion
    Process(v)
    return
}
// varI is not of type T

如果转换合法,v 是 varI 转换到类型 T 的值,ok 会是 true;否则 v 是类型 T 的零值,ok 是 false,也没有运行时错误发生。

typeswitch

switch t := areaIntf.(type) {
	case *Square:
		fmt.Printf("Type Square %T with value %v\n", t, t)
	case *Circle:
		fmt.Printf("Type Circle %T with value %v\n", t, t)
	case nil:
		fmt.Printf("nil value: nothing to check?\n")
	default:
		fmt.Printf("Unexpected type %T\n", t)
}

测试类型是否实现了接口

使用相同的语法测试某个类型是否实现接口

type Stringer interface {
    String() string
}
if sv, ok := v.(Stringer); ok {
    fmt.Printf("v implements String(): %s\n", sv.String()) // note: sv, not v
}

引用类型

作用于变量上的方法实际上是不区分变量到底是指针还是值的。当碰到接口类型值时,这会变得有点复杂,原因是接口变量中存储的具体值是不可寻址。

package main
import (
	"fmt"
)
type List []int
func (l List) Len() int {
	return len(l)
}
func (l *List) Append(val int) {
	*l = append(*l, val)
}
type Appender interface {
	Append(int)
}
func CountInto(a Appender, start, end int) {
	for i := start; i <= end; i++ {
		a.Append(i)
	}
}
type Lener interface {
	Len() int
}
func LongEnough(l Lener) bool {
	return l.Len()*10 > 42
}
func main() {
	// A bare value
	var lst List
	
	// CountInto(lst, 1, 10)
	// compiler error:
	// cannot use lst (type List) as type Appender in argument to CountInto:
	//       List does not implement Appender (Append method has pointer receiver)
	
	if LongEnough(lst) { // VALID:Identical receiver type
		fmt.Printf("- lst is long enough\n")
	}
	// A pointer value
	plst := new(List)
	CountInto(plst, 1, 10) //VALID:Identical receiver type
	if LongEnough(plst) {
		// VALID: a *List can be dereferenced for the receiver
		fmt.Printf("- plst is long enough\n")
	}
}

讨论

在 lst 上调用 CountInto 时会导致一个编译器错误,因为 CountInto 需要一个 Appender,而它的方法 Append 只定义在指针上。 在 lst 上调用 LongEnough 是可以的,因为 Len 定义在值上。

在 plst 上调用 CountInto 是可以的,因为 CountInto 需要一个 Appender,并且它的方法 Append 定义在指针上。 在 plst 上调用 LongEnough 也是可以的,因为指针会被自动解引用。

总结

在接口上调用方法时,必须有和方法定义时相同的接收者类型或者是可以从具体类型 P 直接可以辨识的:

  • 指针方法可以通过指针调用
  • 值方法可以通过值调用
  • 接收者是值的方法可以通过指针调用,因为指针会首先被解引用
  • 接收者是指针的方法不可以通过值调用,因为存储在接口中的值没有地址

将一个值赋值给一个接口时,编译器会确保所有可能的接口方法都可以在此值上被调用,因此不正确的赋值在编译期就会失败。Go 语言中,方法集(Method Set)定义了一个类型可以调用哪些方法。方法接收者的类型决定了哪些方法属于类型的方法集。

Go 语言规范定义了接口方法集的调用规则:

  • 类型 T 的可调用方法集包含接受者为 *T 或 T 的所有方法集
  • 类型 *T 的可调用方法集包含接受者为 *T 的所有方法
  • 类型 *T 的可调用方法集不包含接受者为 T 的方法

Go中的动态类型

在经典的面向对象语言(像 C++,Java 和 C#)中数据和方法被封装为  的概念:类包含它们两者,并且不能剥离。

Go 没有类:数据(结构体或更一般的类型)和方法是一种松耦合的正交关系。

Go 中的接口跟 Java/C# 类似:都是必须提供一个指定方法集的实现。但是更加灵活通用:任何提供了接口方法实现代码的类型都隐式地实现了该接口,而不用显式地声明。

和其它语言相比,Go 是唯一结合了接口值,静态类型检查(是否该类型实现了某个接口),运行时动态转换的语言,并且不需要显式地声明类型是否满足某个接口。该特性允许我们在不改变已有的代码的情况下定义和使用新接口。

接收一个(或多个)接口类型作为参数的函数,其实参可以是任何实现了该接口的类型的变量。 实现了某个接口的类型可以被传给任何以此接口为参数的函数 。

类似于 Python 和 Ruby 这类动态语言中的 动态类型(duck typing);这意味着对象可以根据提供的方法被处理(例如,作为参数传递给函数),而忽略它们的实际类型:它们能做什么比它们是什么更重要。

动态方法调用

像 Python,Ruby 这类语言,动态类型是延迟绑定的(在运行时进行):方法只是用参数和变量简单地调用,然后在运行时才解析(它们很可能有像 responds_to 这样的方法来检查对象是否可以响应某个方法,但是这也意味着更大的编码量和更多的测试工作)

Go 的实现与此相反,通常需要编译器静态检查的支持:当变量被赋值给一个接口类型的变量时,编译器会检查其是否实现了该接口的所有函数。如果方法调用作用于像 interface{} 这样的“泛型”上,你可以通过类型断言来检查变量是否实现了相应接口。

接口提取

提取接口 是非常有用的设计模式,可以减少需要的类型和方法数量,而且不需要像传统的基于类的面向对象语言那样维护整个的类层次结构。

Go 接口可以让开发者找出自己写的程序中的类型。假设有一些拥有共同行为的对象,并且开发者想要抽象出这些行为,这时就可以创建一个接口来使用。

所以你不用提前设计出所有的接口;整个设计可以持续演进,而不用废弃之前的决定。类型要实现某个接口,它本身不用改变,你只需要在这个类型上实现新的方法。

函数重载

 Go 语言中函数重载可以用可变参数 ...T 作为函数最后一个参数来实现。如果我们把 T 换为空接口,那么可以知道任何类型的变量都是满足 T (空接口)类型的,这样就允许我们传递任何数量任何类型的参数给函数,即重载的实际含义。

函数 fmt.Printf 就是这样做的:

fmt.Printf(format string, a ...interface{}) (n int, errno error)

1这个函数通过枚举 slice 类型的实参动态确定所有参数的类型。并查看每个类型是否实现了 String() 方法,如果是就用于产生输出信息。

接口的继承

当一个类型包含(内嵌)另一个类型(实现了一个或多个接口)的指针时,这个类型就可以使用(另一个类型)所有的接口方法。

type Task struct {
	Command string
	*log.Logger
}

这个类型的工厂方法像这样:

func NewTask(command string, logger *log.Logger) *Task {
	return &Task{command, logger}
}

当 log.Logger 实现了 Log() 方法后,Task 的实例 task 就可以调用该方法:

task.Log()

类型可以通过继承多个接口来提供像 多重继承 一样的特性:

type ReaderWriter struct {
	*io.Reader
	*io.Writer
}

上面概述的原理被应用于整个 Go 包,多态用得越多,代码就相对越少。这被认为是 Go 编程中的重要的最佳实践。

面向对象

我们总结一下前面看到的:Go 没有类,而是松耦合的类型、方法对接口的实现。

OO 语言最重要的三个方面分别是:封装,继承和多态。在 Go 中它们是怎样表现的呢?

  • 封装(数据隐藏):和别的 OO 语言有 4 个或更多的访问层次相比,Go 把它简化为了 2 层):
    1. 包范围内的:通过标识符首字母小写,对象 只在它所在的包内可见- 可导出的:
    2. 通过标识符首字母大写,对象 对所在包以外也可见

类型只拥有自己所在包中定义的方法。

  • 继承:用组合实现:内嵌一个(或多个)包含想要的行为(字段和方法)的类型;多重继承可以通过内嵌多个类型实现
  • 多态:用接口实现:某个类型的实例可以赋给它所实现的任意接口类型的变量。类型和接口是松耦合的,并且多重继承可以通过实现多个接口实现。Go 接口不是 Java 和 C# 接口的变体,而且接口间是不相关的,并且是大规模编程和可适应的演进型设计的关键。

高价函数

通常你在应用中定义了一个结构体,那么你也可能需要这个结构体的(指针)对象集合,比如:

type Any interface{}
type Car struct {
	Model        string
	Manufacturer string
	BuildYear    int
	// ...
}

type Cars []*Car

然后我们就可以使用高阶函数,实际上也就是把函数作为定义所需方法(其他函数)的参数。

// cars.go
package main

import (
	"fmt"
)

// 定义一个空接口 Any,表示可以是任意类型
type Any interface{}

// 定义一个结构体 Car,表示汽车的属性
type Car struct {
	Model        string // 车型
	Manufacturer string // 制造商
	BuildYear    int    // 生产年份
	// ...
}

// 定义一个类型 Cars,表示指向 Car 的指针切片
type Cars []*Car

func main() {
	// 创建一些汽车对象
	ford := &Car{"Fiesta", "Ford", 2008}
	bmw := &Car{"XL 450", "BMW", 2011}
	merc := &Car{"D600", "Mercedes", 2009}
	bmw2 := &Car{"X 800", "BMW", 2008}
	
	// 查询操作
	allCars := Cars([]*Car{ford, bmw, merc, bmw2})
	allNewBMWs := allCars.FindAll(func(car *Car) bool {
		return (car.Manufacturer == "BMW") && (car.BuildYear > 2010)
	})
	fmt.Println("AllCars: ", allCars)
	fmt.Println("New BMWs: ", allNewBMWs)
	
	// 定义制造商列表
	manufacturers := []string{"Ford", "Aston Martin", "Land Rover", "BMW", "Jaguar"}
	sortedAppender, sortedCars := MakeSortedAppender(manufacturers)
	allCars.Process(sortedAppender)
	fmt.Println("Map sortedCars: ", sortedCars)
	BMWCount := len(sortedCars["BMW"])
	fmt.Println("We have ", BMWCount, " BMWs")
}

// 用给定的函数 f 处理所有汽车
func (cs Cars) Process(f func(car *Car)) {
	for _, c := range cs {
		f(c)
	}
}

// 查找所有满足给定条件的汽车
func (cs Cars) FindAll(f func(car *Car) bool) Cars {
	cars := make([]*Car, 0)

	cs.Process(func(c *Car) {
		if f(c) {
			cars = append(cars, c)
		}
	})
	return cars
}

// 处理汽车并创建新的数据
func (cs Cars) Map(f func(car *Car) Any) []Any {
	result := make([]Any, len(cs))
	ix := 0
	cs.Process(func(c *Car) {
		result[ix] = f(c)
		ix++
	})
	return result
}

// 创建一个函数,用于将汽车分类到指定的制造商中
func MakeSortedAppender(manufacturers []string) (func(car *Car), map[string]Cars) {
	// 准备一个存储排序后的汽车的映射
	sortedCars := make(map[string]Cars)

	for _, m := range manufacturers {
		sortedCars[m] = make([]*Car, 0)
	}
	sortedCars["Default"] = make([]*Car, 0)

	// 准备添加器函数:
	appender := func(c *Car) {
		if _, ok := sortedCars[c.Manufacturer]; ok {
			sortedCars[c.Manufacturer] = append(sortedCars[c.Manufacturer], c)
		} else {
			sortedCars["Default"] = append(sortedCars["Default"], c)
		}
	}
	return appender, sortedCars
}

空接口

空接口或者最小接口 不包含任何方法,它对实现不做任何要求:

type Any interface {}

可以给一个空接口类型的变量 var val interface {} 赋任何类型的值。

通用类型容器

给空接口定一个别名类型 Elementtype Element interface{},然后定义一个容器类型的结构体 Vector,它包含一个 Element 类型元素的切片:

type Vector struct {
	a []Element
}

Vector 里能放任何类型的变量,因为任何类型都实现了空接口,实际上 Vector 里放的每个元素可以是不同类型的变量。我们为它定义一个 At() 方法用于返回第 i 个元素:

func (p *Vector) At(i int) Element {
	return p.a[i]
}

再定一个 Set() 方法用于设置第 i 个元素的值:

func (p *Vector) Set(i int, e Element) {
	p.a[i] = e
}

Vector 中存储的所有元素都是 Element 类型,要得到它们的原始类型(unboxing:拆箱)需要用到类型断言。类型断言总是在运行时才执行,因此它会产生运行时错误。

数据复制

假设你有一个 myType 类型的数据切片,你想将切片中的数据复制到一个空接口切片中,类似:

var dataSlice []myType = FuncReturnSlice()
var interfaceSlice []interface{} = dataSlice

可惜不能这么做,编译时会出错:cannot use dataSlice (type []myType) as type []interface { } in assignment

原因是它们俩在内存中的布局是不一样的。必须使用 for-range 语句来一个一个显式地赋值:

var dataSlice []myType = FuncReturnSlice()
var interfaceSlice []interface{} = make([]interface{}, len(dataSlice))
for i, d := range dataSlice {
    interfaceSlice[i] = d
}

接口赋值

一个接口的值可以赋值给另一个接口变量,只要底层类型实现了必要的方法。这个转换是在运行时进行检查的,转换失败会导致一个运行时错误:这是 Go 语言动态的一面,可以拿它和 Ruby 和 Python 这些动态语言相比较。

var ai AbsInterface // declares method Abs()
type SqrInterface interface {
    Sqr() float
}
var si SqrInterface
pp := new(Point) // say *Point implements Abs, Sqr
var empty interface{}

那么下面的语句和类型断言是合法的:

empty = pp                // everything satisfies empty
ai = empty.(AbsInterface) // underlying value pp implements Abs()
// (runtime failure otherwise)
si = ai.(SqrInterface) // *Point has Sqr() even though AbsInterface doesn’t
empty = si             // *Point implements empty set
// Note: statically checkable so type assertion not necessary.

下面是函数调用的一个例子:

type myPrintInterface interface {
	print()
}

func f3(x myInterface) {
	x.(myPrintInterface).print() // type assertion to myPrintInterface
}

x 转换为 myPrintInterface 类型是完全动态的:只要 x 的底层类型(动态类型)定义了 print 方法这个调用就可以正常运行(译注:若 x 的底层类型未定义 print 方法,此处类型断言会导致 panic,最佳实践应该为 if mpi, ok := x.(myPrintInterface); ok { mpi.print() }

模板编程

package main

import "fmt"

type Node struct {
	le   *Node
	data interface{}
	ri   *Node
}

func NewNode(left, right *Node) *Node {
	return &Node{left, nil, right}
}

func (n *Node) SetData(data interface{}) {
	n.data = data
}

func main() {
	root := NewNode(nil, nil)
	root.SetData("root node")
	// make child (leaf) nodes:
	a := NewNode(nil, nil)
	a.SetData("left node")
	b := NewNode(nil, nil)
	b.SetData("right node")
	root.le = a
	root.ri = b
	fmt.Printf("%v\n", root) // Output: &{0x125275f0 root node 0x125275e0}
}