golang学习随便记8-接口

接口

接口是对其他类型行为的概括和抽象,一个类型实现了某种接口,就是对使用方的一种承诺(因为它遵守了接口约定)。和常见的 OOP 语言不同,golang的接口是隐式实现的,一个类型并不会显式声明它实现了哪种接口,而是直接提供接口所必需的方法。这种方式让我们可以不改变已有类型的实现,就可以为它添加新的接口,对于不能修改的包中的类型,这一点有它的作用——感觉这和golang没有继承有关,因为有继承的 OOP ,会继承创建子类,同时去实现某个接口。golang的类型可以聚合各种接口,只要这个类型实现了有关接口方法(遵守了约定)。

接口是约定

func Fprintf(w io.Writer, format string, args ...interface{}) (int, error)

func Printf(format string, args ...interface{}) (int, error) {
	return Fprintf(os.Stdout, format, args...)
}

func Sprintf(format string, args ...interface{}) string {
	var buf bytes.Buffer
	Fprintf(&buf, format, args...)
	return buf.String()
}

FprintfPrintfSprintf 都包含格式化部分,所以,通过接口机制来避免重复代码。Printf 直接调用 Fprintf,只是“文件”是标准输出。Sprintf 也调用 Fprintf,“文件”是字节缓冲区。这里,标准输出和字节缓冲区都可以作为“文件”,原因是 Fprintf 的第一个参数其实并非真正的文件,而是 io.Writer 接口类型,从而,凡是实现了 io.Writer 接口的类型,都可以作为 Fprintf 的“文件”。(实际golang标准库fmt包里的实现比这个要复杂

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

io.Writer 定义为接口类型,它包含 Write 方法,参数为一个字节slice p,返回表示写了多少个字节的整数n和错误err 。换句话说,我们的类型,只要实现了该 Write 方法,就认为遵守了 io.Writer 接口约定,从而,可以作为 fmt.Fprintf 的第一个实参。这实现了一种类型替换为另一种类型,即 可取代性(substitutability)。golang没有子类是父类的兼容类型这样的可取代性,但至少同样包含实现相同接口的两个类型在接口函数上的可取代性。

type ByteCounter int

func (c *ByteCounter) Write(p []byte) (int, error) {
	*c += ByteCounter(len(p))
	return len(p), nil
}

上面的代码中,定义了类型 ByteCounter (其实就是整数),然后给该类型创建方法 Write,使得Write 方法的签名和 io.Writer 中的Write方法一致(这表示遵守接口约定)。这样,我们就可以让 ByteCounter 类型的变量作为 fmt.Fprintf 的第一个实参了:

	var c ByteCounter
	c.Write([]byte("hello"))            // 直接调用 Write 方法
	fmt.Println(c)
	c = 0
	var name = "Dolly"
	fmt.Fprintf(&c, "hello, %s", name)    // 间接调用 Write 方法
	fmt.Println(c)

上面的代码中,fmt.Fprintf(&c, "hello, %s", name) 包含了间接调用 c 的 Write 方法,和直接调用 Write 方法 c.Write([]byte("hello")) 相比,差别是前者包含了格式化的功能(虽然这里没啥用)。两者都实现了 c 的值被修改(因为 Write 内部修改 c 的值)。

几乎对任何类型,我们都可以用 Fprintf 和 Fprintln 输出它的字符串形式的表示,原因是存在 fmt.Stringer 接口,并且通常类型都会实现这个接口(其实就是实现String()方法):

type Stringer interface {
    String() string
}

接口类型

前面的接口类型中,都只有一个方法,但接口中可以有多个方法。如果接口有多个方法,那么实现接口时,必须实现所有的方法。

接口可以像嵌套结构体那样嵌套接口,这样可以避免重复书写接口方法。可以混合书写,如下面的3种写法等价

type ReadWriter interface {
	Reader
	Writer
}

type ReadWriter interface {
	Read(p []byte) (n int, err error)
	Write(p []byte) (n int, err error)
}

type ReadWriter interface {
	Read(p []byte) (n int, err error)
	Writer
}

实现接口

一个类型实现了接口的所有方法才算实现了这个接口,并且此时,称该类型“是一个”XXX接口类型,如 *bytes.Buffer is an io.Writer

对于接口类型的变量,所有实现该接口的类型值(可以是一个包含前者的接口类型)都可以赋值给它,但没有实现该接口的类型不行。

var w io.Writer
w = os.Stdout
w = new(bytes.Buffer)
w = time.Second        // 编译错误

var rwc io.ReadWriteCloser
rwc = os.Stdout
rwc = new(bytes.Buffer)

w = rwc
rwc = w                // 编译错误

上面的代码,可以简单理解为“子类是父类的兼容类型,但父类不是子类的兼容类型”,无非golang里面的子类概念是实现接口的类型或者包含前一个接口的接口类型。

接口的方法中,接收者 T *T 不能等价,尽管调用方法上编译器会帮我们隐式实现转换。例如

type IntSet struct { /* ... */ }
func (*IntSet) String() string

var s IntSet
var _ = s.String()			// OK: s 被编译器隐式转换为 &s
var _ fmt.Stringer = &s		// OK: &s 是指针接收者,是接口的兼容类型
var _ fmt.Stringer = s		// 编译错误 是 &s 拥有 String()方法,而不是 s

将一个类型赋值给兼容的接口类型变量,接口类型变量只能调用它自身接口范围内的方法,并不能调用来源类型的方法(“父类不是子类的兼容类型"),如下

os.Stdout.Write([]byte("hello"))
os.Stdout.Close()

var w io.Writer
w = os.Stdout				// 尽管 w 指向 os.Stdout,但可调用方法集合是 io.Writer 范围
w.Write([]byte("hello"))	// OK: io.Writer 有 Write 方法
w.Close()					// 编译错误: io.Writer 没有 Close 方法

子类是父类的兼容类型,对于空接口类型 interface{} ,它是任何类型的兼容类型(因为不需要实现任何方法),从而任何类型的变量或值都可以赋值给空接口类型的变量,有点 void * 的意思。这也是王垠大神吐槽的一个点,因为它确实会破坏类型检查。

注:Go1.18 引入了泛型,接口的概念从“方法的集合”迁移到“类型的集合”(可以参考博文Go 1.18 泛型全面讲解:一篇讲清泛型的全部 - 掘金 (juejin.cn))。事实上,用类型的集合来理解接口反而自然,此时 interface{} 代表的就是任何类型,Go1.18 中可以用 any 代替 interface{}

接口值

一个接口类型的值包括:一个具体类型和该类型的一个,分别称为接口的动态类型动态值

golang是静态类型的语言,所以,类型只是编译时的概念,类型本身不是一个值,和C#(Java不熟悉,应该一样)那样的语言不一样,C#类型也是内存中的一个东西,类型的静态方法就是这个母体的方法,类型的静态变量则有单例模式的效果,实例都有指向这个母体的指针。

下面的代码演示了接口赋值过程中类型的动态性

	var w io.Writer
	fmt.Printf("%T\t%v\n", w, w) // <nil>   <nil>
	w = os.Stdout
	fmt.Printf("%T\t%v\n", w, w) // *os.File        &{0xc00007a280}
	w = new(bytes.Buffer)
	fmt.Printf("%T\t%v\n", w, w) // *bytes.Buffer
	w = nil
	fmt.Printf("%T\t%v\n", w, w) // <nil>   <nil>

通过接口类型的变量调用其对应具体类型的方法具有间接性,编译时无法知道一个接口值的动态类型会是什么,所以需要“动态分发”(有那么点多态的意思)。例如,w = os.Stdoutw.Write([]byte("hello"))  中,编译器必须生成一段代码从类型描述符得到 Write 方法的地址,再用该地址间接调用该方法,调用的接收者就是接口值的动态值 os.Stdout。当 w = new(bytes.Buffer),动态类型是*bytes.Buffer,动态值则是一个指向新分配缓冲区的指针,再调用 w.Write([]byte("hello")),方法的接收者是缓冲区的地址。接口值为nil,则不能调用任何方法。

上面的图,应该这样理解:运行时,接口类型的变量w是对某种底层类型变量fd的“打包”,接口类型变量用指针(w的值部分)指向底层类型变量 fd (接口类型变量w中登记的类型信息*os.File,是对底层类型变量fd的一种约束,即只能打包兼容的类型,fd 是某种 *os.File)

 接口值可以用 == 和 != 进行比较,也可以和nil比较两个接口值相等的含义是同为nil或者不仅动态类型完全一致,而且动态值也相等(差不多就是PHP === 的意思)。接口值可以比较,所以,接口值可以充当 map 的键,也可以作为 switch 语句的操作数。

两个接口值,如果动态类型完全一致,但动态值不可比较(如slice),则这两个接口值不可比较(会 panic)。接口类型“什么都往里塞”的特点,使得接口值的比较必须小心,即只对所含动态值可比较的场合进行。

接口值为空(nil)和接口的动态值为空(nil)不是一回事!可以认为前者是“真没东西”(没指定类型约束),而后者是“有东西,动态指向了某个类型,但东西是空的,该类型对应动态值为空”(指定了类型约束,但指针为空指针,即没有指向任何底层变量)。下面的代码演示了接口的动态值为空和接口为空不同

 

const debug = true

func main() {
	var buf *bytes.Buffer
	if debug {
		buf = new(bytes.Buffer)
	}
	f(buf)
	if debug {
		// ...
	}
	fmt.Println(buf)
}

func f(out io.Writer) {
	if out != nil {
		fmt.Printf("%V\n", out)            // &{[] %!V(int=0) %!V(bytes.readOp=0)}
		out.Write([]byte("done!\n"))		// debug = false, panic!
	}
}

 当 debug 为 false 时,out 就动态指向了 *bytes.Buffer 类型,但该类型的动态值为空(空指针),对空指针取引用值引发 panic。对于某些类型,比如 *os.File,空接收值是合法的,但对于 *bytes.Buffer 不行,这里 panic,不是方法无法调用,是方法被调用了,但在调用中尝试访问缓冲区时崩溃了。

问题是出在给 out 参数传入了一个具体类型T的参数(类型 T,值 nil),此时,out 就可能出现 <T, nil> 的情况,解决办法是将 buf 声明为 io.Writer 这样的接口类型(默认动态类型nil,动态值nil),从而传入out时不存在转换,即从 <T, nil> 改成了 <nil, nil>

对于接口值的理解,可以对比 PHP 变量类型的动态性、VB Variant 或者 C语言的 Union。在 C 语言中, void * 指针声明和具体类型指针 p = NULL 含义不同,它差不多对应 <nil, nil> 和 <T, nil>

用 sort.Interface 排序

golang sort包的排序是针对接口sort.Interface的,从而它没有和具体的序列类型或元素类型绑定。

package sort

type Inteface interface {
	Len() int
	Less(i, j int) bool		// i, j 是序列元素的下标
	Swap(i, j int)
}

 凡是实现了该接口(3个方法)的类型,都可以直接调用 sort.Sort() 就地排序。

 这个是应用层面的,我们暂时略过。

http.Handler 接口

type Handler interface {
	ServeHTTP(w ResponseWriter, r *Request)
}

应用,略过

error接口

所有 error 类型,其实都是实现了 error 接口的类型,只要实现返回错误消息的 Error() 方法

type error interface {
    Error() string
}
// -----------------------------------------------------
package errors

// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
func New(text string) error {
	return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
	s string
}

func (e *errorString) Error() string {
	return e.s
}

errors 包中实现 error 接口时,用errorString这个结构体包裹字符串,是为了给后面可能的更改留下余地,而接收者是 *errorString 而不是 errorString,确保了每次 errors.New("EOF") 得到的值是不一样的(因为字符串分配地址不一样): errors.New("EOF") == errors.New("EOF") // false

通常,errors.New 不会直接被使用,而是更容易格式化的 fmt.Errorf 被使用

表达式求值例子

参见测试一章

类型断言

判断接口类型变量是不是某个具体类型 T, x is T ?

	var w io.Writer
	w = os.Stdout
	f := w.(*os.File)
	c := w.(*bytes.Buffer)			// panic,接口持有 *os.File 而非 *bytes.Buffer

上面的代码中,接口类型的变量 w 断言的类型是一个具体类型 *os.File,所以,就回检查 w 的动态类型是否是 *os.File,检查成功(动态类型的确是 *os.File),类型断言的结果为 w 的动态值。后面一句断言则引发 panic。

判断接口类型变量是否满足另一个接口 I, x  s.t.  I ?

	var w io.Writer
	w = os.Stdout
	rw := w.(io.ReadWriter)			// 成功: *os.File 有 Read 和 Write 方法
	w = new(ByteCount)
	rw = w.(io.ReadWriter)			// panic: *ByteCount 没有 Read 方法

上面的代码中,一开始 w 赋值为 os.Stdout,它是满足 io.ReadWriter 接口的(此时动态值不会提取出来,结果仍然是一个接口值,接口值的类型和值也没有变更),但后来赋值为 *ByteCount,它因为没有 Read 方法,不满足 io.ReadWriter 接口,会引发 panic

类型断言失败时崩溃并不能让它实用,实际实用时,类型断言通常出现在需要两个结果的赋值表达式中,此时,断言失败不会崩溃,只是用来指示是否成功的第二个布尔值为 false

var w io.Writer = os.Stdout
f, ok := w.(*os.File)            // os.Stdout, true
b, ok := w.(*bytes.Buffer)       // nil, false

有时候,断言完会覆盖掉原来的变量

if w, ok := w.(*os.File); ok {
    // ...
}

类型分支

接口有两种风格:第一种风格,强调各种类型的共性,即都满足这个接口定义的方法(隐藏了各个具体类型的布局和各自特有功能)。第二种风格,把接口当作一堆类型的联合(union)使用,强调满足这个接口的那些具体类型,而不是接口约定的方法(往往没有接口方法),也不注重信息隐藏,称为可识别联合(discriminated union)。前者相当于 OOP 的 子类型多态 (subtype polymorphism),后者相当于 Ad hoc polymorphism (这玩意不好翻译,“即时多态”可能更好理解)

func sqlQuote(x interface{}) string {
	if x == nil {
		return "NULL"
	} 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))
	}
}

上面的函数用于对数据库查询SQL语句中的参数值添加引号。变量 x 的类型是 interface {} ,它的作用就是 Ad hoc 多态,把各种类型联合在一起。用 golang 的switch ... case 语句,可以改写如下

func sqlQuote(x interface{}) string {
	switch x := x.(type) {
	case nil:
		return "NULL"
	case int, uint:
		return fmt.Sprintf("%d", x)
	case bool:
		if x {
			return "TRUE"
		}
		return "FALSE"
	case string:
		return sqlQuoteString(s)			// 该函数这里未给出
	default:
		panic(fmt.Sprintf("unexpected type %T: %v", x, x))
	}
}

作者的一些建议

golang 的接口用法有和其他语言相同的地方,也有不同的地方。相同的使用经验有:

接口可以实现解耦,即接口定义文件和具体实现接口的类型在不同的包中。

接口定义时,应该尽量“小”,即仅仅定义你需要的——让实现接口的类型更容易实现,约定更容易达成。

不同的是,golang的接口通常都是被动创建的,即开发过程中,发现有两个或者多个类型满足某些特点(例如类似的方法),则可以创建接口,抽象出公共部分,去掉实现细节。如果一开始就创建一堆接口,容易出现每个接口只有一个实现类型的情况,太多这种情况就是滥用接口了。(这才是上层抽象存在的本来含义)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值