Interfaces
Go在类型和接口上的思考是:
Type System
Go的类型系统是比较容易和C++/Java混淆的,特别是习惯于类体系和虚函数的思路后,很容易想在Go走这个路子,可惜是走不通的。而interface因为太过于简单,而且和C++/Java中的概念差异不是特别明显,所以这个章节专门分析Go的类型系统。
先看一个典型的问题Is it possible to call overridden method from parent struct in golang?,代码如下所示:
package main
import (
"fmt"
)
type A struct {
}
func (a *A) Foo() {
fmt.Println("A.Foo()")
}
func (a *A) Bar() {
a.Foo()
}
type B struct {
A
}
func (b *B) Foo() {
fmt.Println("B.Foo()")
}
func main() {
b := B{A: A{}}
b.Bar()
}
本质上它是一个模板方法模式(TemplateMethodPattern),A的Bar调用了虚函数Foo,期待子类重写虚函数Foo,这是典型的C++/Java解决问题的思路。
我们借用模板方法模式(TemplateMethodPattern)中的例子,考虑实现一个跨平台编译器,提供给用户使用的函数是crossCompile
,而这个函数调用了两个模板方法collectSource
和compileToTarget
:
public abstract class CrossCompiler {
public final void crossCompile() {
collectSource();
compileToTarget();
}
//Template methods
protected abstract void collectSource();
protected abstract void compileToTarget();
}
C++版,不用OOAD思维参考C++: CrossCompiler use StateMachine,代码如下所示:
// g++ compiler.cpp -o compiler && ./compiler
#include <stdio.h>
void beforeCompile() {
printf("Before compile\n");
}
void afterCompile() {
printf("After compile\n");
}
void collectSource(bool isIPhone) {
if (isIPhone) {
printf("IPhone: Collect source\n");
} else {
printf("Android: Collect source\n");
}
}
void compileToTarget(bool isIPhone) {
if (isIPhone) {
printf("IPhone: Compile to target\n");
} else {
printf("Android: Compile to target\n");
}
}
void IDEBuild(bool isIPhone) {
beforeCompile();
collectSource(isIPhone);
compileToTarget(isIPhone);
afterCompile();
}
int main(int argc, char** argv) {
IDEBuild(true);
//IDEBuild(false);
return 0;
}
C++版本使用OOAD思维,可以参考C++: CrossCompiler,代码如下所示:
// g++ compiler.cpp -o compiler && ./compiler
#include <stdio.h>
class CrossCompiler {
public:
void crossCompile() {
beforeCompile();
collectSource();
compileToTarget();
afterCompile();
}
private:
void beforeCompile() {
printf("Before compile\n");
}
void afterCompile() {
printf("After compile\n");
}
// Template methods.
public:
virtual void collectSource() = 0;
virtual void compileToTarget() = 0;
};
class IPhoneCompiler : public CrossCompiler {
public:
void collectSource() {
printf("IPhone: Collect source\n");
}
void compileToTarget() {
printf("IPhone: Compile to target\n");
}
};
class AndroidCompiler : public CrossCompiler {
public:
void collectSource() {
printf("Android: Collect source\n");
}
void compileToTarget() {
printf("Android: Compile to target\n");
}
};
void IDEBuild(CrossCompiler* compiler) {
compiler->crossCompile();
}
int main(int argc, char** argv) {
IDEBuild(new IPhoneCompiler());
//IDEBuild(new AndroidCompiler());
return 0;
}
我们可以针对不同的平台实现这个编译器,比如Android和iPhone:
public class IPhoneCompiler extends CrossCompiler {
protected void collectSource() {
//anything specific to this class
}
protected void compileToTarget() {
//iphone specific compilation
}
}
public class AndroidCompiler extends CrossCompiler {
protected void collectSource() {
//anything specific to this class
}
protected void compileToTarget() {
//android specific compilation
}
}
在C++/Java中能够完美的工作,但是在Go中,使用结构体嵌套只能这么实现,让IPhoneCompiler和AndroidCompiler内嵌CrossCompiler,参考Go: TemplateMethod,代码如下所示:
package main
import (
"fmt"
)
type CrossCompiler struct {
}
func (v CrossCompiler) crossCompile() {
v.collectSource()
v.compileToTarget()
}
func (v CrossCompiler) collectSource() {
fmt.Println("CrossCompiler.collectSource")
}
func (v CrossCompiler) compileToTarget() {
fmt.Println("CrossCompiler.compileToTarget")
}
type IPhoneCompiler struct {
CrossCompiler
}
func (v IPhoneCompiler) collectSource() {
fmt.Println("IPhoneCompiler.collectSource")
}
func (v IPhoneCompiler) compileToTarget() {
fmt.Println("IPhoneCompiler.compileToTarget")
}
type AndroidCompiler struct {
CrossCompiler
}
func (v AndroidCompiler) collectSource() {
fmt.Println("AndroidCompiler.collectSource")
}
func (v AndroidCompiler) compileToTarget() {
fmt.Println("AndroidCompiler.compileToTarget")
}
func main() {
iPhone := IPhoneCompiler{}
iPhone.crossCompile()
}
执行结果却让人手足无措:
# Expect
IPhoneCompiler.collectSource
IPhoneCompiler.compileToTarget
# Output
CrossCompiler.collectSource
CrossCompiler.compileToTarget
Go并没有支持类继承体系和多态,Go是面向对象却不是一般所理解的那种面向对象,用老子的话说“道可道,非常道”。
实际上在OOAD中,除了类继承之外,还有另外一个解决问题的思路就是组合Composition,面向对象设计原则中有个很重要的就是The Composite Reuse Principle (CRP),Favor delegation over inheritance as a reuse mechanism
,重用机制应该优先使用组合(代理)而不是类继承。类继承会丧失灵活性,而且访问的范围比组合要大;组合有很高的灵活性,另外组合使用另外对象的接口,所以能获得最小的信息。
C++如何使用组合代替继承实现模板方法?可以考虑让CrossCompiler使用其他的类提供的服务,或者说使用接口,比如CrossCompiler
依赖于ICompiler
:
public interface ICompiler {
//Template methods
protected abstract void collectSource();
protected abstract void compileToTarget();
}
public abstract class CrossCompiler {
public ICompiler compiler;
public final void crossCompile() {
compiler.collectSource();
compiler.compileToTarget();
}
}
C++版本可以参考C++: CrossCompiler use Composition,代码如下所示:
// g++ compiler.cpp -o compiler && ./compiler
#include <stdio.h>
class ICompiler {
// Template methods.
public:
virtual void collectSource() = 0;
virtual void compileToTarget() = 0;
};
class CrossCompiler {
public:
CrossCompiler(ICompiler* compiler) : c(compiler) {
}
void crossCompile() {
beforeCompile();
c->collectSource();
c->compileToTarget();
afterCompile();
}
private:
void beforeCompile() {
printf("Before compile\n");
}
void afterCompile() {
printf("After compile\n");
}
ICompiler* c;
};
class IPhoneCompiler : public ICompiler {
public:
void collectSource() {
printf("IPhone: Collect source\n");
}
void compileToTarget() {
printf("IPhone: Compile to target\n");
}
};
class AndroidCompiler : public ICompiler {
public:
void collectSource() {
printf("Android: Collect source\n");
}
void compileToTarget() {
printf("Android: Compile to target\n");
}
};
void IDEBuild(CrossCompiler* compiler) {
compiler->crossCompile();
}
int main(int argc, char** argv) {
IDEBuild(new CrossCompiler(new IPhoneCompiler()));
//IDEBuild(new CrossCompiler(new AndroidCompiler()));
return 0;
}
我们可以针对不同的平台实现这个ICompiler
,比如Android和iPhone。这样从继承的类体系,变成了更灵活的接口的组合,以及对象直接服务的调用:
public class IPhoneCompiler implements ICompiler {
protected void collectSource() {
//anything specific to this class
}
protected void compileToTarget() {
//iphone specific compilation
}
}
public class AndroidCompiler implements ICompiler {
protected void collectSource() {
//anything specific to this class
}
protected void compileToTarget() {
//android specific compilation
}
}
在Go中,推荐用组合和接口,小的接口,大的对象。这样有利于只获得自己应该获取的信息,或者不会获得太多自己不需要的信息和函数,参考Clients should not be forced to depend on methods they do not use. –Robert C. Martin,以及The bigger the interface, the weaker the abstraction, Rob Pike。关于面向对象的原则在Go中的体现,参考Go: SOLID或中文版Go: SOLID。
先看如何使用Go的思路实现前面的例子,跨平台编译器,Go Composition: Compiler,代码如下所示:
package main
import (
"fmt"
)
type SourceCollector interface {
collectSource()
}
type TargetCompiler interface {
compileToTarget()
}
type CrossCompiler struct {
collector SourceCollector
compiler TargetCompiler
}
func (v CrossCompiler) crossCompile() {
v.collector.collectSource()
v.compiler.compileToTarget()
}
type IPhoneCompiler struct {
}
func (v IPhoneCompiler) collectSource() {
fmt.Println("IPhoneCompiler.collectSource")
}
func (v IPhoneCompiler) compileToTarget() {
fmt.Println("IPhoneCompiler.compileToTarget")
}
type AndroidCompiler struct {
}
func (v AndroidCompiler) collectSource() {
fmt.Println("AndroidCompiler.collectSource")
}
func (v AndroidCompiler) compileToTarget() {
fmt.Println("AndroidCompiler.compileToTarget")
}
func main() {
iPhone := IPhoneCompiler{}
compiler := CrossCompiler{iPhone, iPhone}
compiler.crossCompile()
}
这个方案中,将两个模板方法定义成了两个接口,CrossCompiler
使用了这两个接口,因为本质上C++/Java将它的函数定义为抽象函数,意思也是不知道这个函数如何实现。而IPhoneCompiler
和AndroidCompiler
并没有继承关系,而它们两个实现了这两个接口,供CrossCompiler
使用;也就是它们之间的关系,从之前的强制绑定,变成了组合。
type SourceCollector interface {
collectSource()
}
type TargetCompiler interface {
compileToTarget()
}
type CrossCompiler struct {
collector SourceCollector
compiler TargetCompiler
}
func (v CrossCompiler) crossCompile() {
v.collector.collectSource()
v.compiler.compileToTarget()
}
Rob Pike在Go Language: Small and implicit中描述Go的类型和接口,第29页说:
- Objects implicitly satisfy interfaces. A type satisfies an interface simply by implementing its methods. There is no "implements" declaration; interfaces are satisfied implicitly. 这种隐式的实现接口,实际中还是很灵活的,我们在Refector时可以将对象改成接口,缩小所依赖的接口时,能够不改变其他地方的代码。比如如果一个函数
foo(f *os.File)
,最初依赖于os.File
,但实际上可能只是依赖于io.Reader
就可以方便做UTest,那么可以直接修改成foo(r io.Reader)
所有地方都不用修改,特别是这个接口是新增的自定义接口时就更明显。 - In Go, interfaces are usually small: one or two or even zero methods. 在Go中接口都比较小,非常小,只有一两个函数;但是对象却会比较大,会使用很多的接口。这种方式能够以最灵活的方式重用代码,而且保持接口的有效性和最小化,也就是接口隔离。
隐式实现接口有个很好的作用,就是两个类似的模块实现同样的服务时,可以无缝的提供服务,甚至可以同时提供服务。比如改进现有模块时,比如两个不同的算法。更厉害的时,两个模块创建的私有接口,如果它们签名一样,也是可以互通的,其实签名一样就是一样的接口,无所谓是不是私有的了。这个非常强大,可以允许不同的模块在不同的时刻升级,这对于提供服务的服务器太重要了。
比较被严重误认为是继承的,莫过于是Go的内嵌Embeding,因为Embeding本质上还是组合不是继承,参考Embeding is still composition。
Embeding在UTest的Mocking中可以显著减少需要Mock的函数,比如Mocking net.Conn,如果只需要mock Read和Write两个函数,就可以通过内嵌net.Conn来实现,这样loopBack也实现了整个net.Conn接口,不必每个接口全部写一遍:
type loopBack struct {
net.Conn
buf bytes.Buffer
}
func (c *loopBack) Read(b []byte) (int, error) {
return c.buf.Read(b)
}
func (c *loopBack) Write(b []byte) (int, error) {
return c.buf.Write(b)
}
Embeding只是将内嵌的数据和函数自动全部代理了一遍而已,本质上还是使用这个内嵌对象的服务。Outer内嵌了Inner,和Outer继承Inner的区别在于:内嵌Inner是不知道自己被内嵌,调用Inner的函数,并不会对Outer有任何影响,Outer内嵌Inner只是自动将Inner的数据和方法代理了一遍,但是本质上Inner的东西还不是Outer的东西;对于继承,调用Inner的函数有可能会改变Outer的数据,因为Outer继承Inner,那么Outer就是Inner,二者的依赖是更紧密的。
如果很难理解为何Embeding不是继承,本质上是没有区分继承和组合的区别,可以参考Composition not inheritance,Go选择组合不选择继承是深思熟虑的决定,面向对象的继承、虚函数、多态和类树被过度使用了。类继承树需要前期就设计好,而往往系统在演化时发现类继承树需要变更,我们无法在前期就精确设计出完美的类继承树;Go的接口和组合,在接口变更时,只需要变更最直接的调用层,而没有类子树需要变更。
The designs are nothing like hierarchical, subtype-inherited methods. They are looser (even ad hoc), organic, decoupled, independent, and therefore scalable.
组合比继承有个很关键的优势是正交性orthogonal
,详细参考正交性。
Orthogonal
真水无香,真的牛逼不用装。——来自网络
软件是一门科学也是艺术,换句话说软件是工程。科学的意思是逻辑、数学、二进制,比较偏基础的理论都是需要数学的,比如C的结构化编程是有论证的,那些关键字和逻辑是够用的。实际上Go的GC也是有数学证明的,还有一些网络传输算法,又比如奠定一个新领域的论文比如Google的论文。艺术的意思是,大部分时候都用不到严密的论证,有很多种不同的路,还需要看自己的品味或者叫偏见,特别容易引起口水仗和争论,从好的方面说,好的软件或代码,是能被感觉到很好的。
由于大部分时候软件开发是要靠经验的,特别是国内填鸭式教育培养了对于数学的莫名的仇恨(“莫名”主要是早就把该忘的不该忘记的都忘记了),所以在代码中强调数学,会激发起大家心中一种特别的鄙视和怀疑,而这种鄙视和怀疑应该是以葱白和畏惧为基础——大部分时候在代码中吹数学都会被认为是装逼。而Orthogonal(正交性)则不择不扣的是个数学术语,是线性代数(就是矩阵那个玩意儿)中用来描述两个向量相关性的,在平面中就是两个线条的垂直。比如下图:
Vectors A and B are orthogonal to each other.
旁白:妮玛,两个线条垂直能和代码有个毛线关系,八竿子打不着关系吧,请继续吹。
先请看Go关于Orthogonal相关的描述,可能还不止这些地方:
Composition not inheritance Object-oriented programming provides a powerful insight: that the behavior of data can be generalized independently of the representation of that data. The model works best when the behavior (method set) is fixed, but once you subclass a type and add a method, the behaviors are no longer identical. If instead the set of behaviors is fixed, such as in Go's statically defined interfaces, the uniformity of behavior enables data and programs to be composed uniformly, orthogonally, and safely.
JSON-RPC: a tale of interfaces In an inheritance-oriented language like Java or C++, the obvious path would be to generalize the RPC class, and create JsonRPC and GobRPC subclasses. However, this approach becomes tricky if you want to make a further generalization orthogonal to that hierarchy.
实际上Orthogonal并不是只有Go才提,参考Orthogonal Software。实际上很多软件设计都会提正交性,比如OOAD里面也有不少地方用这个描述。我们先从实际的例子出发吧,关于线程一般Java、Python、C#等语言,会定义个线程的类Thread,可能包含以下的方法管理线程:
var thread = new Thread(thread_main_function);
thread.Start();
thread.Interrupt();
thread.Join();
thread.Stop();
如果把goroutine也看成是Go的线程,那么实际上Go并没有提供上面的方法,而是提供了几种不同的机制来管理线程:
-
go
关键字启动goroutine。 -
sync.WaitGroup
等待线程退出。 -
chan
也可以用来同步,比如等goroutine启动或退出,或者传递退出信息给goroutine。 -
context
也可以用来管理goroutine,参考Context。
s := make(chan bool, 0)
q := make(chan bool, 0)
go func() {
s <- true // goroutine started.
for {
select {
case <-q:
return
default:
// do something.
}
}
} ()
<- s // wait for goroutine started.
time.Sleep(10)
q <- true // notify goroutine quit.
注意上面只是例子,实际中推荐用Context管理goroutine。
如果把goroutine看成一个向量,把sync看成一个向量,把chan看成一个向量,这些向量都不相关,也就是它们是正交的。
再举在Orthogonal Software的例子,将对象存储到TEXT或XML文件,可以直接写对象的序列化函数:
def read_dictionary(file)
if File.extname(file) == ".xml"
# read and return definitions in XML from file
else
# read and return definitions in text from file
end
end
这个的坏处包括:
- 逻辑代码和序列化代码混合在一起,随处可见序列化代码,非常难以维护。
- 如果要新增序列化的机制比如将对象序列化存储到网络就很费劲了。
- 假设TEXT要支持JSON格式,或者INI格式呢?
如果改进下这个例子,将存储分离:
class Dictionary
def self.instance(file)
if File.extname(file) == ".xml"
XMLDictionary.new(file)
else
TextDictionary.new(file)
end
end
end
class TextDictionary < Dictionary
def write
# write text to @file using the @definitions hash
end
def read
# read text from @file and populate the @definitions hash
end
end
如果把Dictionay看成一个向量,把存储方式看成一个向量,再把JSON或INI格式看成一个向量,他们实际上是可以不相关的。
再看一个例子,考虑上面JSON-RPC: a tale of interfaces的修改,实际上是将序列化的部分,从*gob.Encoder
变成了接口ServerCodec
,然后实现了jsonCodec和gobCodec两种Codec,所以RPC和ServerCodec是正交的。非正交的做法,就是从RPC继承两个类jsonRPC和gobRPC,这样RPC和Codec是耦合的并不是不相关的。
Orthogonal不相关到底有什么好说的?
- 数学中不相关的两个向量,可以作为空间的基,比如平面上就是x和y轴,从向量看就是两个向量,这两个不相关的向量x和y可以组合出平面的任意向量,平面任一点都可以用x和y表示;如果向量不正交,有些区域就不能用这两个向量表达,有些点就不能表达。这个在接口设计上就是:正交的接口,能让用户灵活组合出能解决各种问题的调用方式,不相关的向量可以张成整个向量空间;同样的如果不正交,有时候就发现自己想要的功能无法通过现有接口实现,必须修改接口的定义。
- 比如goroutine的例子,我们可以用sync或chan达到自己想要的控制goroutine的方式。比如context也是组合了chan、timeout、value等接口提供的一个比较明确的功能库。这些语言级别的正交的元素,可以组合成非常多样和丰富的库。比如有时候我们需要等goroutine启动,有时候不用;有时候甚至不需要管理goroutine,有时候需要主动通知goroutine退出;有时候我们需要等goroutine出错后处理。
- 比如序列化TEXT或XML的例子,可以将对象的逻辑完全和存储分离,避免对象的逻辑中随处可见存储对象的代码,维护性可以极大的提升。另外,两个向量的耦合还可以理解,如果是多个向量的耦合就难以实现,比如要将对象序列化为支持注释的JSON先存储到网络有问题再存储为TEXT文件,同时如果是程序升级则存储为XML文件,这种复杂的逻辑实际上需要很灵活的组合,本质上就是空间的多个向量的组合表达出空间的新向量(新功能)。
- 当对象出现了自己不该有的特性和方法,会造成巨大的维护成本。比如如果TEXT和XML机制耦合在一起,那么维护TEXT协议时,要理解XML的协议,改动TEXT时竟然造成XML挂掉了。使用时如果出现自己不用的函数也是一种坏味道,比如
Copy(src, dst io.ReadWriter)
就有问题,因为src明显不会用到Write
而dst不会用到Read
,所以改成Copy(src io.Reader, dst io.Writer)
才是合理的。
由此可见,Orthogonal是接口设计中非常关键的要素,我们需要从概念上考虑接口,尽量提供正交的接口和函数。比如io.Reader
、io.Writer
和io.Closer
是正交的,因为有时候我们需要的新向量是读写那么可以使用io.ReadWriter
,这实际上是两个接口的组合。
我们如何才能实现Orthogonal的接口呢?特别对于公共库,这个非常关键,直接决定了我们是否能提供好用的库,还是很烂的不知道怎么用的库。有几个建议:
- 好用的公共库,使用者可以通过IDE的提示就知道怎么用,不应该提供多个不同的路径实现一个功能,会造成很大的困扰。比如Android的通讯录,超级多的完全不同的类可以用,实际上就是非常难用。
- 必须要有完善的文档。完全通过代码就能表达Why和How,是不可能的。就算是Go的标准库,也是大量的注释,如果一个公共库没有文档和注释,会非常的难用和维护。
- 一定要先写Example,一定要提供UTest完全覆盖。没有Example的公共库是不知道接口设计是否合理的,没有人有能力直接设计一个合理的库,只有从使用者角度分析才能知道什么是合理,Example就是使用者角度;标准库有大量的Example。UTest也是一种使用,不过是内部使用,也很必要。
如果上面数学上有不严谨的请原谅我,我数学很渣。
Links
由于简书限制了文章字数,只好分成不同章节:
- Overview 为何Go有时候也叫Golang?为何要选择Go作为服务器开发的语言?是冲动?还是骚动?Go的重要里程碑和事件,当年吹的那些牛逼,都实现了哪些?
- Could Not Recover 君可知,有什么panic是无法recover的?包括超过系统线程限制,以及map的竞争写。当然一般都能recover,比如Slice越界、nil指针、除零、写关闭的chan等。
- Errors 为什么Go2的草稿3个有2个是关于错误处理的?好的错误处理应该怎么做?错误和异常机制的差别是什么?错误处理和日志如何配合?
- Logger 为什么标准库的Logger是完全不够用的?怎么做日志切割和轮转?怎么在混成一坨的服务器日志中找到某个连接的日志?甚至连接中的流的日志?怎么做到简洁又够用?
- Interfaces 什么是面向对象的SOLID原则?为何Go更符合SOLID?为何接口组合比继承多态更具有正交性?Go类型系统如何做到looser, organic, decoupled, independent, and therefore scalable?一般软件中如果出现数学,要么真的牛逼要么装逼。正交性这个数学概念在Go中频繁出现,是神仙还是妖怪?为何接口设计要考虑正交性?
- Modules 如何避免依赖地狱(Dependency Hell)?小小的版本号为何会带来大灾难?Go为什么推出了GOPATH、Vendor还要搞module和vgo?新建了16个仓库做测试,碰到了9个坑,搞清楚了gopath和vendor如何迁移,以及vgo with vendor如何使用(毕竟生产环境不能每次都去外网下载)。
- Concurrency & Control 服务器中的并发处理难在哪里?为什么说Go并发处理优势占领了云计算开发语言市场?什么是C10K、C10M问题?如何管理goroutine的取消、超时和关联取消?为何Go1.7专门将context放到了标准库?context如何使用,以及问题在哪里?
- Engineering Go在工程化上的优势是什么?为什么说Go是一门面向工程的语言?覆盖率要到多少比较合适?什么叫代码可测性?为什么良好的库必须先写Example?
- Go2 Transition Go2会像Python3不兼容Python2那样作吗?C和C++的语言演进可以有什么不同的收获?Go2怎么思考语言升级的问题?
- SRS & Others Go在流媒体服务器中的使用。Go的GC靠谱吗?Twitter说相当的靠谱,有图有真相。为何Go的声明语法是那样?C的又是怎样?是拍的大腿,还是拍的脑袋?