Go语言的接口实现了鸭子类型吗?- 谈协变和抗变
上篇文章讲了非入侵式接口,以及如何在没有原生支持非入侵接口的语言中用适配器模式模拟实现它,即最后的例子(C++):
在最后这行实现中,我们可以注意到一点,实现类(T)的Size方法的返回,并不需要和接口类Container的Size一样返回int,从语法上说,只要能隐式转换即可,而从设计上说,只要业务含义无损即可,因为适配器只是简单地做了一层转接,而不涉及语法上的精确匹配。例如,T的Size如果返回short(一个小容器),由于short可以无损转int,且含义都是数值量,那么显然是匹配Container的
但是,如果我们换成Go语言,要想原生地将一个Container接口和这样一个T直接匹配是不行的,因为接口签名不同,Go对于接口匹配的规范,是要求签名严格相同
一直以来,对于Go的非入侵接口设计,很多资料将其描述为“鸭子类型”,但Go的官方文档spec倒是没出现duck相关词汇,我个人更倾向于它就是一个普通接口语法的非入侵式改造
当然,鸭子类型这个概念本身就没有特别严谨的定义,如果从“一个实现类只要实现了一个接口的所有方法,就等于实现了这个接口”这个逻辑来说,非入侵接口倒是体现了这种思想,不过,鸭子类型在我看来更贴近使用层面,即“一个东西只要在需求范围内使用起来(有存取、调用交互)跟鸭子一样,那么就是鸭子,但不需要纠结于其和鸭子长得是否一样”,从这个角度说,Go的接口匹配要更严格一些
举个例子,Go程序员基本都用过net库开发网络程序,对Listener接口应该比较熟悉:
Listener表示一个通用的监听对象,它可以通过Accept方法调用来返回一个通用的Conn对象(Conn也是接口),这没有任何问题
然后,假设你开发的是一个TCP服务器,那么Listener的对象实际是一个TCPListener,它的Accept方法自然也是这样:
看到这个,有强迫症的人就可能觉得不太对劲了,Accept返回一个Conn连接对象,这没有问题,但是,TCPListener的Accept显然只能返回一个TCP连接,既然不可能返回其他类型的连接,那么似乎改成这样更加精确:
实际上,TCPListener是有这样一个方法的,只是名字不同:
而Accept的实现大家也很容易想到,直接内部调用AcceptTCP就行了,相当于自己给自己做了个适配器(不过官方代码是将一份代码拷贝了两次,可能是不想白白做一次嵌套调用吧)
看到这里,我们的问题就是:为什么Go不能使用这个“精确版”的Accept接口方法呢?
从语法角度,这个问题很显然,因为方法签名不同,它会导致TCPListener没有实现Listener接口
但是我们更进一步问:如果不考虑Go的语法限制,而是我们来设计语法,“精确版”是否合理?是否能实现?
如果你从使用角度来看,那么它是合理的。为什么呢?Listener的Accept的含义是:调用我,返回一个Conn,那么精确版Accept满足这个要求了吗?答案是满足了,因为*TCPConn本身就是一个Conn,从OOP角度看他俩就是“is”的关系,不冲突,所以返回*TCPConn,不就是返回了一个Conn吗,完全满足要求
那么能实现吗?也是可以的,虽然这个需求会让编译器和运行时更加复杂,但无论是静态分析还是运行时的处理,都算不上特别难,解决办法也不唯一
这种设计可能存在的问题,一个是会让接口适配更加灵活,理解难度高些;另一个,对于允许隐式转换类型的语言有隐患(比如C++的long转int有损),但是,Go没有非接口类型的隐式转换(强类型语言),所以不存在这个问题
返回值如此,参数也是类似,只不过关系需要反过来:
这个例子中,TCPSender是一个接口,接收一个TCP连接作为参数,在其上做一些发送工作,那么如果我们有一个XXXSender的类型,它可以在任何连接上做同样的工作,是否就实现了TCPSender接口呢?
答案也是肯定的,分析一下这个接口,Send方法需要传入一个TCP连接来工作,而实现类的Send方法需要传入一个Conn接口来工作,一个TCP连接自然也是一个Conn接口可以引用的对象,所以没有逻辑问题,当你用一个TCPSender接口来操作XXXSender对象的时候,接收*TCPConn并传入给下层,自然隐式转换的(当然,这个例子不是真实的Go代码,Go不支持这种)
抽象地总结一下,就是:如果一个实现类的方法的所有返回类型可以无损转为接口的对应方法的对应返回类型,同时接口的所有参数类型可以无损转为实现类的对应方法的对应参数类型,那么从使用角度说,这个实现类就是实现了这个接口(尽管方法签名可能不同)
而这,就是我们一般意义上的协变和抗变概念(也叫顺变和逆变)
需要注解一下的是,协变抗变从字面意思,一开始是指派生类到基类(自然)和基类到派生类(动态)的转换,只不过这个概念太平凡了,现在一般是说上面论述的这种套一层的情况
显然Go的接口并不直接支持自动的协变抗变,它要求方法签名完全匹配,C++和Java也不行,在这方面有比较完美支持的语言,叫C#
很多相关资料会用Java的基类和派生类的数组转换来讨论这个问题,其实是上面说的情况的特化版本:
这里B是D的派生类,那么显然一个D对象可以自然地转B(也就是用B的变量引用),而反过来如果一个D的变量去引用原本用B的变量引用的对象,则需要运行时检查,看它是不是D,这个是大家都懂的OOP理论
那么它俩的数组能互相转换吗:
答案是都不行,虽然你可以用强转的办法来赋值,但是总会有运行时的问题:
但是,如果将读写颠倒过来,就可以了:
换句话说,如果你做了这类数组转换后,只从里面读数据,那么派生类的数组转为基类数组是ok的,反之,如果转换后你只写不读,那么基类数组转派生类数组是安全的,如果读写都有,那么两个方向都无法安全转换,总会出问题
结合上面讲的接口内容,将数组看做是这种接口:
一个支持安全的协变转换(T作为返回类型),另一个则支持抗变(T作为参数类型),这样看就比较清楚了