《冒号课堂》 面向对象

大S计算机学院扛把子,John Ousterhout老爷子在Google为新书预热的讲座中说到:“从编程发明到现在已经80个年头,但软件设计仍像一种黑魔法。因为人们从未就好的软件设计标准达成共识。”而我自身的体会,最难修炼的也正是软件设计的功夫。对于机器学习,操作系统等难课,终归有大量入门课程与书籍。唯独软件设计不知该从何看起。去读书吧,缺乏实操经验无法深入理解;读源码吧,没有理论支撑又不能纲举目张。说到底,理论和实操,是软件设计修炼道路上齐驱并进的两架马车。

本书介绍了几种软件设计思想,从编程范式到值与引用,再到设计模式,重中之重是面向对象抽象。作者采用对话形式,把概念正过来倒过去,从不同角度对面向对象思想中的概念作了非常精彩的诠释,适合有一定编程经验,但对各种概念模糊的新手(是我)。本文只凭粗浅的理解总结一下面向对象和值与引用等四章的内容要点,以备未来之需。

封装与接口

首先介绍作者对抽象的观点。作者把抽象的定义为剔除问题无关部分,合并同类的行为,简单来说是作减法和除法。软件开发过程分为分析,设计和编码三个部分,而抽象程度递减。分析阶段的任务是理解并明确需求,着重于问题的定性;设计阶段对问题建模,并制定规范;而编码过程采用参数抽象和规范抽象两种方法。参数抽象是对模块参数的泛化,实际例子是函数和泛型的参数;规范抽象的精华在于语义的规范,有时通过文档实现,但最好通过语法强制实现。规范抽象根据开发者对模块先验条件和后验条件的不同态度分为契约式编程和防御式编程:契约式编程中开发者信任模块的客户,采用文档和assert断言使模块获得正确输入,而用防御式的开发者采取不信任的态度,主动检查程序输入,对非法输入抛出异常。在先验和后验条件间,由开发者负责维护函数/类状态的不变量。参数抽象和规范抽象这两种编码的抽象方法形成了下面五种抽象的基础:过程抽象(自定义函数),数据抽象(ADT),迭代抽象(迭代器),类型抽象(自定义类型)和多态抽象(继承/泛型编程)。后面四种是本书的主要内容。

数据抽象在具体编程上的体现是抽象数据类型(ADT)。抽象数据类型主要思想是接口与实现分离,客户使用接口而无需担心具体实现。比如客户使用Queue类型只需要知道enqueue,dequeue等接口,而无需关心底层用链表还是顺序表实现。虽然ADT非OOP独有,但ADT的思想是OOP的基础。OOP自底而上的进行开发,分析阶段以对象为中心(存在什么对象),设计阶段以接口为中心(对象间的关系),编码阶段以数据为中心(对象内部的数据结构)。以数据为中心意味着算法围绕数据,而非过程式编程的数据依赖算法。与ADT相对的是具体数据类型,它的主要功能是数据存储,这种类不需要抽象。

数据抽象是OOP思想的基础,OOP进而发展出三大特征:封装,继承与多态。首先介绍封装。封装的目的是信息隐藏,主要通过对模块的打包(以类或文件为单位)和访问控制等手段实现。访问控制应该尽可能选择最严格的控制符,未来需求变化时再放宽要求,这样做的波及范围会更小。有一个对访问控制的简化理解,即把成员变量全部声明为私有,并提供相应的getter/setter,一概命名为getXXX/setXXX。实际上getter/setter的设计大有讲究。第一,getter不能随意返回类的内部成员变量,否则客户将获得修改权限,破坏类的内部状态。返回值或引用应取决于该成员变量的语义。第二,引用类型作为setter参数,不能随意赋值给类成员变量,理由同上。第三,列表类型成员变量考虑提供addXXX, removeXXX, getXX(index)接口,而非一刀切的getter/setter。第四,列表内部元素的getter反而适合返回引用类型而非值类型,因为这里更在乎语义的同一性而非值。第五,对于布尔成员变量采用getXXX接口反而限定了只存在true/false两种取值,采用isXXX接口方便未来增加更多取值种类。第六,接口的命名不能反映内部实现,比如命名为computeAge暴露了实现是通过间接计算。

封装将模块非本质的信息隐藏起来,留出接口为用户提供服务。OOP中,接口一方面描述了类的本质行为特征,另一方面封装了变化,使具体实现的变化不能波及到客户。C++的头文件暴露了一部分实现,当该类私有成员(具体实现)发生变化,编译器也不得不重新编译其他包含该头文件的模块,成为C++广受诟病的缺点。应对变化是软件设计的难题之一,变化可能来自于重构,也可能来自于变化的需求。历史上,一个软件为细微变化付出巨额代价的典型例子是千年虫。开闭原则指导了应对变化的方式,即对扩展开放,对修改封闭。可重用性以应变能力为前提,作为客户可以通过接口,多态,继承,合成等技巧来拓展相关类,设计框架时也应考虑提供扩展点。为抽象类设计接口时,面向客户提供服务的设置public,面向子类提供服务的设置protected。实现类一般不额外提供面向子类的服务,因为不建议继承实现类。当可重用性与应变力出现矛盾需要取舍时,应当以后者为重。桥梁模式的目的也是分离接口与实现。与类比较,桥梁模式的优势在于支持运行时选择实现方式,灵活性更好。

继承与多态

继承分为接口继承和实现继承。纯粹的接口继承体现为继承接口类或纯抽象类,纯粹的实现继承体现为C++独有的私有继承。一般的继承即继承接口,又继承实现,称为类继承。实现继承一般可由合成与委托等办法实现。合成与委托比实现继承更好,因为实现继承可以访问protected成员,也可以覆盖基类的方法,导致子类可以修改父类实现,父类通过多态可以调用子类方法,形成一种强耦合的关系。所以实现继承具有侵入性,可能会破坏封装。而对于接口继承与实现继承之间的选择,我们更倾向接口继承。因为接口继承产生可重用的代码,实现继承消费代码的重用性。接口抽象是控制反转(IoC)的基础,即低层模块调用高层模块的接口,高层模块的执行顺序由低层模块决定。

类与类通过继承关系形成一个类族,概念上父类是子类的泛化,子类是父类的特例化。里氏替换原则(LSP)描述了子类型(subtype):程序类型A被替换成子类型B,不会影响程序的合理性和正确性。子类型是比子类更强的概念,LSP要求子类型的先验条件比父类更宽松(输入参数位于类族更上层),后验条件比父类更严格(返回值位于类族更下层);具有更多内涵,更少外延;集合上可以理解为用更多语言描述父类的一个子集。与对单个类进行规范抽象的接口不同,LSP是对整个类族进行规范抽象,要求子类向上兼容,祖先作出的承诺,子类只能加强,不能削弱。有时候根据常规概念来理解类族会产生错觉,进而设计出违反LSP的继承关系。比如Square概念上理解为一种特殊化的Rectangle,但是Square并不完全符合Rectangle的语义,比如在setWidth和setHeight后执行getArea,Rectangle返回widthheight,Square返回widthwidth或height*height,在语义上出现混淆。这个例子的合理设计是解除Square和Rectangle的继承,抽象出getArea接口,令Square和Rectangle分别实现它。

从类族的角度,覆盖基类方法的实现继承也正好违反了LSP。除了替换为合成和委托,另一个实现继承的最佳实践是非虚接口模式,即公有方法非虚化,虚函数私有化。因为公有方法代表类族的接口,为了防止子类覆盖/修改(可能导致违反LSP)而禁止多态,私有方法是具体行为的实现,才允许子类修改。总结下来是继承应保持外静内动,对外的静是不能修改接口,内部的动是实现的变化,体现了实现继承的接口与实现的职责分离,同时保障了LSP。非虚接口模式进一步拓展便是模版方法模式,父类固定骨架,保留扩展点(如beforeXXX, afterXXX),通过多态的回调实现控制反转。跟模版方法模式很相似的是策略模式。不过策略模式的关注点在于功能分割,比如把登陆类中密码的加密和存储分割出去,以委托的形式存在登陆类中。而模版方法模式的侧重点在于不同的行为模式。

抽象类型是类族中最重要的组成部分,具体类型是创建对象的模版,抽象类型是创建类型的模版,两者以多态的方式连接。所以抽象类型是不可实例化的,而具体类型是不建议作为数据类型。Java或C#中,抽象类型是interface接口类,缺点是不能定义实现;C++中,抽象类型是至少含有一个纯虚函数的抽象类,缺点是不能多重继承。而不妨引入Ruby和Python支持的mixin类,避免了这两个缺点,既可以定义实现,又可以多重继承。利用mixin类,只需实现一个纯虚的接口,可自动获得额外基于此接口的方法,比如实现compare函数,自动获得6个比较运算符;C++实现一个隐藏复制构造函数的NonCopyable的类,子类私有继承便自动获得不可复制的特性,也有一点mixin的味道。总结一下,抽象类型描述整个类族的本质与行为,公有方法是整个类族的接口。具体类型通过继承重用这些接口,封装确保了每个具体类型实现接口的规范抽象。多态是两者的纽带,具体类型实际以抽象类型赋予的身份在各个场合发挥作用。封装使类获得公民身份,继承使类获得家庭身份,多态使类获得社会身份。

值与引用

值与引用是学习编程语言的重要知识点。值与引用的本质区别在于数据存储的位置:值类型的数据存在变量本身中,引用类型的数据存在变量之外,变量中存的是数据的索引,比如内存地址等。一个关于值和引用的常见误解是把值和引用分别对应于栈变量和堆变量,实际上两种类型没有严格的对应,除了堆上的对象需要引用类型存储。对值赋值进行复制操作,对引用赋值只是改变指针指向的位置,所以C++中对象需要考虑复制操作(复制构造函数和赋值运算符)。C++和C#支持引用类型传递参数,但是Java只支持值类型传递参数。但是Java中对象只有引用类型,所以Java的参数传递的是引用类型的值。除了语法上的理解,值与引用还有语义的理解。值和引用语义上的本质区别在于值的对象是独立的,客户不在乎同一性;而引用的对象是可以共享的,但同一性十分重要:不同内存地址的引用尽管内部数据相同,也是不同对象。对象可否相互替代是检验语义上属于值与引用的法则:如果不同地址的对象语义上可以相互替代且不影响正确性,则该对象是值语义。所以值和引用需要的相等运算符是不同的,前者比较数据是否一致,后者比较内存地址是否一致。这一点在Java的String类型得到体现,==判断字符串的引用,equals判断字符串的值。但是值和引用的语义并不总是与语法对应。有时语义上属于值,而引用的语法呈现,所以Java增加了不可变类型来模拟值,C++也有const关键字来保证常量不可变性。

尾声

除技术内容外,作者也在书中讨论了大量有关工程师学习成长的观点。我最喜欢的一个观点是工程师要有客户意识。想起我本科的毕业论文花了大量篇幅描述技术细节和实现过程,自以为写的很不错。结果导师删去了这些内容的大半,要求我回去增加篇幅,分析最后的结果。我不满地问,我花了大量时间实现,结果只是一个是与非的问题,难道实现过程不才是最重要的吗?导师解释说:“作为工程师,实现过程当然是最艰辛的。但你要站在客户的角度介绍结果:你的客户并不关心你如何实现,只关心你有没有做出结果,做出怎样的结果。”这是我本科阶段最重要的一课。