C++的面向对象语言设施相比其他现代语言可算得上“简陋”,而且与语言的其他部分(better C、数据抽象、泛型)融合度较差(见电子工业出版社出版的《C++ Primer(第4版)(评注版)》第15章)。在C++中进行面向对象编程会遇到其他语言中不存在的问题,其本质原因是C++ class是值语义(关注于数据的值本身,而不是其在内存中的位置,赋值操作通常会进行值的复制,而不是引用的传递),而非对象语义(关注于对象的身份,而不仅仅是其值,赋值通常是引用的传递,即两个变量引用同一个对象)。
11.1 朴实的C++设计
作者去年8月(本节内容写于2008年底,“去年”指的是2007年)入职,培训了4个月,12月进入现在这个部门,到现在工作正好一年了。工作内容是软件开发,具体地说,用C++开发一个网络应用(TCP not web),这是作者他们的外汇交易系统的一个部件。这半年来,和一两位同事合作把原有的一个C++程序重写了一遍,并增加了很多新功能,重写后的代码不长,不到15000行(经过多年演化,2012年的代码量是23000行。期间交付了20多个大小版本,有两三次重大功能更新),代码质量与性能大大提高。实际上,重写只花了三个月,9月作者他们交付了第一个版本,实现了原来的主要功能,吞吐量提高4倍。后面这三个月作者他们在增加新功能,并准备交付第二个版本。这个项目让作者对C++的使用有了新的体会,那就是“实用当头,朴实为贵,好用才是王道”。
C++是一门(最)复杂的编程语言,语言虽复杂,不代表一定要用复杂的方式来使用它。对于一个金融交易系统,正确性是首要的,价格/数量/交割日期弄错了就会赔钱。在编写代码时,作者他们特别注意把代码写得尽量简单直白,让人一看就懂。为了控制代码的复杂度,作者他们采用了基于对象的风格,也就是具体类加全局函数,把C++程序写得如C语言一般清晰,同时使用一些C++特性和库来减少代码。
项目中基本没有用到面向对象,或者说没有用到继承和多态的那种面向对象(不一定非得有基类和派生类的设计才是好设计)。引入基类和派生类,或许能带来灵活性,但是代码就不如原来透彻了。在不需要这种灵活性的场合,为什么要付出这样的代价呢?作者宁愿花一天时间把几千行C代码弄懂,也不愿在几十个类组成继承体系里绕来绕去浪费脑力。定义并使用清晰一致的接口很重要,但“接口”不一定非得是抽象基类,一个类的成员函数就是它的接口。如果看头文件就能明白这个类在干什么、该怎么用固然很好,但如果不明白,打开实现文件,东西都在那儿摆着呢,一望而知。没必要非得用个抽象的接口类把使用者和实现隔开,再把实现隐藏起来,这除了让查找并理解代码变麻烦之外没有任何好处。一个进程内部的解耦意义不大(其实,有人一句话道破真相:“但凡你在某个地方切断联系,那么你必然会在另一个地方重新产生联系。”(http://www.iteye.com/topic/947017));相反,函数调用是最直接有效的通信方式。或许采用接口类/实现类的一个可能的好处时依赖注入(DI,Dependency Injection,一种软件设计模式,用于管理一个对象依赖于其他对象的方式,在依赖注入中,依赖关系不是由被依赖的对象自己创建或管理,而是由外部的容器(通常是一个框架或者手动配置的容器)来注入),便于单元测试。经过权衡比较,作者他们发现针对各个类写测试的意义不大。另外,如果用白盒测试,那么功能代码和测试代码就得同步更新,会增加不少工作量,碍手碍脚。
程序里边有一处用到了继承,因为它能简化设计。这是一个strategy,涉及一个基类和三四个派生类,所有的类都没有数据成员,只有虚函数。这几个类的代码加起来不到200行。这个设计不是一开始就有的,而是在项目进行了一大半的时候,作者他们发现代码里有若干处针对请求类型的switch-case,于是提炼出了一个strategy,把好几处switch-case替换为了strategy对象的虚函数调用,从而简化了代码。这里作者他们是把OO纯粹当做函数指针表来用的。
程序里还有几处用了模板,甚至为了简化与第三方库的交互而动用了type traits(C++编程语言中的一种技术,用于在编译时检查和操纵类型信息,C++标准库中的<type_traits>
头文件提供了一组内置的type traits,包括:is_integral<T>
检查类型T是否是整数类型(包括整数、字符和布尔类型);is_floating_point<T>
检查类型T是否是浮点类型等),这都是为了简化代码,少敲键盘。这些代码都藏在一个角落里,对外只暴露出一个全局函数的接口,使用者不会被其困扰。
项目里,我们唯一仰赖的C++特性是确定性析构,即一个对象在离开其作用域之后会保证调用析构函数。作者他们利用这点大大简化了代码,并确保资源和内存的回收。在作者看来,确定性析构是C++区别其他主流开发语言(Java/C#/C/动态脚本语言)的最主要特性。
为了确保正确性,作者他们另外用Java写了一个测试夹具(test harness)来测试他们这个C++程序。这个测试夹具模拟了所有与作者他们这个C++程序打交道的其他程序,能够测试各种正常或异常的情况。基本上任何代码改动和bug修复都在这个夹具中有体现。如果要新加一个功能,会有对应的测试用例来验证其行为。如果发现了一个bug,先往夹具里加一个或几个能复现bug的测试用例,然后修复代码,让测试通过。作者他们积累了几百个测试用例,这些用例表示了他们对程序行为的预期,是一份可以运行的文档。每次代码改动提交之前,作者他们都会执行一遍测试,以防低级错误发生(见本书9.7的详细论述和第七章的例子)。
作者他们让每个类有明确的职责范围,一个类代表一个概念,不能像个杂货铺一样什么都装。在增加或修改功能的时候,仔细考虑在哪儿下手才最合理。必要时可以动大手脚,而不是每次都选择最简单的修补方式,那样只会让代码越来越臭,积重难返,重蹈上一个版本的覆辙。有时作者他们会提炼出一个新的类,把原来分散在多个类里的代码集中到一起,从而优化结构。作者他们有测试夹具保障,并不担心修改会破坏什么。
设计不是一开始就形成的,而是随着项目进展逐步演化出来的。作者他们的设计是基于类的,而不是基于类的继承体系的。作者他们是在写应用,不是在写框架,在C++里用那么多继承对他们没好处。一开始作者他们只有三四个类,实现了基本的报价功能,然后增加了一个类,实现了下单功能。这时作者他们把报价和下单的共同数据结构提炼成一个新的类,作为原来两个类的成员(而不是基类!),并把解析客户输入的代码移到这个类里。作者他们的原则是,可以有特别简单的类,但不宜有特别复杂的类,更不能有“大怪兽”。一个类太大,作者他们就看看能不能把它拆成两个,把责任分开。两个类有共同的代码逻辑,他们会考虑提炼出一个工具类来用,输入数据的验证就是这么提炼出来的一个类。勿以善小而不为,应始终让代码保持清晰易懂。
让代码保持清晰,给作者他们带来了显而易见的好处。错误更容易暴露,在发布前每多修复一个错误,发布后就少一次半夜被从被窝里叫醒查错的机会。
不要因为某个技术流行而去用它,除非它确实能降低程序的复杂性。毕竟,软件开发的首要技术使命是控制复杂度(《代码大全(第2版)》[CC2e]第5.2节),防止脑袋爆掉。对于继承要特别小心,这条“贼船”上去就下不来,除非你是继承boost::noncopyable。在讲解面向对象的书里,总会举一些用继承的精巧的例子,比如矩形、正方形、圆形继承自形状,飞机和麻雀继承自“能飞的”,这不意味着继承处处适用。作者认为在C++这样需要自己管理内存和对象生命期的语言里,大规模使用面向对象、继承、多态多是自讨苦吃。还不如用C语言的思路来设计,在局部用一用继承来代替函数指针表。而GoF(Gang of Four,指四位著名的计算机科学家和软件设计模式的提倡者,即Erich Gamma、Richard Helm、Ralph Johnson、John Vlissides)的《设计模式》与其说是常见问题的解决方案,不如说是绕过(work around)C++语言限制的技巧。当然,也是一些人挂在嘴边用来忽悠别人或麻痹自己的灵丹妙药。
11.2 程序库的二进制兼容性
本节主要讨论Linux x86/x86-64(x86和x86-64的主要区别在于原始的x86架构是32位的,它支持32位的寻址空间和32位的寄存器,而x86-64是一种64位扩展,支持更大的内存寻址范围和更多的寄存器)平台,偶尔会举Windows作为反面教材。
C++程序员有不同的角色,比如有主要编写应用程序的(application),也有主要编写程序库的(library),有的程序员或许还身兼多职。如果公司的规模比较大,会出现更细致和明确的分工。比如有的团队专门负责一两个公用的library;有的团队负责某个application,并使用了前一个团队的library。
举一个具体的例子。假设你负责一个图形库,这个图形库功能很强大,且经过了充分测试,于是在公司内慢慢推广开来。目前已经有三三十个内部项目用到了你的图形库,大家日子过得挺好。前几天,公司新买了一批大屏幕显示器(分辨率为2560×1600像素),不巧你的图形库不能支持这么高的分辨率(这其实不怪你,因为在你当年编写这个库的时候,市面上显示器的最高分辨率是1920×1200像素)。
结果用到了你的图形库的应用程序在2560×1600分辨率下不能正常工作,你该怎么办?你可以发布一个新版的图形库,并要求那二三十个项目组用你的新库重新编译他们的程序,然后让他们重新发布应用程序。或者,你提供一个新的库文件,直接替换现有的库文件,应用程序的可执行文件保持不变。
这两种做法各有优劣。第一种做法声势浩大,凡是用到你的库的团队都要经历一个release cycle。后一种做法似乎节省人力,但是有风险:如果新的库文件和原有的应用程序可执行文件不兼容怎么办?
所以,作为C++程序员,只要工作涉及二进制的程序库(特别是动态库),都需要了解二进制兼容性方面的知识。
C/C++的二进制兼容性(binary compatibility)有多重含义,本文主要在“库文件单独升级,现有可执行文件是否受影响”这个意义下讨论,作者称之为library(主要是shared library,即动态链接库)的ABI(application binary interface)。至于编译器与操作系统的ABI见第10章。
11.2.1 什么是二进制兼容性
在解释这个定义之前,先看看Unix和C语言的一个历史问题:open()的flags参数的取值。open(2)函数的原型如下,其中flags的取值有三个:O_RDONLY、O_WRONLY、O_RDWR。
与人们通常的直觉相反,这几个常数值不满足按位或(bitwise-OR)的关系,即(O_RDONLY | O_WRONLY) != O_RDWR。如果你想以读写方式打开文件,必须用O_RDWR,而不能用(O_RDONLY | O_WRONLY)。为什么?因为O_RDONLY、O_WRONLY、O_RDWR的值分别是0、1、2。它们不满足按位或。
那么为什么Unix/C语言从诞生到现在一直没有纠正这个小小的缺陷?比方说把O_RDONLY、O_WRONLY、O_RDWR分别定义为1、2、3,这样(O_RDONLY | O_WRONLY)== O_RDWR,符合直觉。而且这三个值都是宏定义,也不需要修改现有的源代码,只需要改改系统的头文件就行了。
这么做会破坏二进制兼容性。对于已经编译好的可执行文件,它调用open(2)的参数是写死的,更改头文件并不能影响已经编译好的可执行文件。比方说这个可执行文件会调用open(path, 1)来写文件,而在新规定中,这表示读文件,程序就错乱了。
以上这个例子说明,如果以shared library方式提供函数库,那么头文件和库文件不能轻易修改,否则容易破坏已有的二进制可执行文件,或者其他用到这个shared library的library。
操作系统的system call可以看成Kernel与User space的interface,kernel在这个意义下也可以当成shared library,你可以把内核从2.6.30升级到2.6.35,而不需要重新编译所有用户态的程序。
本章所指的“二进制兼容性”是在升级(也可能是bug fix)库文件的时候,不必重新编译使用了这个库的可执行文件或其他库文件,并且程序的功能不被破坏。见QT FAQ(指QT(跨平台C++图形用户界面库)的常见问题解答(Frequently Asked Questions))的有关条款。
在Windows有臭名昭著的DLL Hell(指在Windows操作系统中,由于动态链接库(DLL)的版本冲突或者覆盖等问题导致的软件不兼容和不稳定的现象,后来微软出了SxS,可以管理DLL的多个版本,情况才好转了)问题,比如MFC(Microsoft Foundation Class库,是微软开发的一套用于Windows应用程序开发的类库,MFC库包含了常用的图形界面控件、消息处理机制、文件操作、网络通信等功能,大大简化了Windows应用程序的开发过程)有一堆DLL:mfc40.dll、mfc42.dll、mfc71.dll、mfc80.dll、mfc90.dll等,这其实是动态链接库的本质问题,怪不到MFC头上。
11.2.2 有哪些情况会破坏库的ABI
到底如何判断一个改动是不是二进制兼容呢?这跟C++的实现方式直接相关,虽然C++标准没有规定C++的ABI,但是几乎所有主流平台都有明文或事实上的ABI标准。比方说ARM(一种微处理器的架构和指令集架构)有EABI,Intel Itanium(Intel开发的一种64位微处理器架构和指令集架构)有Itanium ABI,x86-64(也称为x64或AMD64,是一种64位微处理器架构和指令集架构)有仿Itanium的ABI,SPARC(Scalable Processor ARChitecture,SPARC是由Sun Microsystems(现在是Oracle公司的一部分)开发的一种RISC(精简指令集计算机)处理器架构)和MIPS(Microprocessor without Interlocked Pipeline Stages,MIPS是由美国斯坦福大学的研究团队开发的一种RISC(精简指令集计算机)处理器架构,广泛应用于嵌入式系统)也都有明文规定的ABI,等等。x86是个例外,它只有事实上的ABI,比如Windows就是Visual C++,Linux是G++(G++的ABI还有多个版本,目前最新的是G++3.4的版本),x86平台上的Intel的C++编译器也得按照Visual C++或G++的ABI来生成代码,否则就不能与系统的其他部件兼容。
C++编译器ABI的主要内容包括以下几个方面:
1.函数参数传递方式,比如x86-64用寄存器来传函数的前4个整数参数;
2.虚函数的调用方式,通常是vptr/vtbl机制,然后用vtbl[offset]来调用;
3.struct和class的内存布局,通过偏移量来访问数据成员;
4.name mangling;
5.RTTI和异常处理的实现(以下本文不考虑异常处理)。
C/C++通过头文件暴露出动态库的使用方法(主要是函数调用和对象布局),这个“使用方法”主要是给编译器看的,编译器会据此生成二进制代码,然后在运行的时候通过装载器(loader)把可执行文件和动态库绑到一起。如何判断一个改动是不是二进制兼容,主要就是看头文件暴露的这份“使用说明”能否与新版本的动态库的实际使用方法兼容。因为新的库必然有新的头文件,但是现有的二进制可执行文件还是按旧的头文件中的“使用说明”来调用动态库。
先说修改动态库导致二进制不兼容的例子。比如原来动态库里定义了non-virtual函数void foo(int),新版的库把参数改成了double。那么现有的可执行文件就无法启动,会发生undefined symbol错误,因为这两个函数的mangled name不同。但是对于virtual函数foo(int),修改其参数类型并不会导致加载错误,而是会发生诡异的运行时错误。因为虚函数的决议(resolution)是靠偏移量,并不是靠符号名。
再举一些源代码兼容但是二进制代码不兼容的例子:
1.给函数增加默认参数,现有的可执行文件无法传这个额外的参数。
2.增加虚函数,会造成vtbl里的排列变化(不要考虑“只在末尾增加”这种取巧行为,因为你的class可能已被继承)。
3.增加默认模板类型参数,比方说Foo<T>
改为Foo<T, Alloc=alloc<T>>
,这会改变name mangling。
4.改变enum的值,把enum Color { Red = 3 };改为Red = 4。这会造成错位。当然,由于enum自动排列取值,添加enum项也是不安全的(在末尾添加除外)。
给class Bar增加数据成员,造成sizeof(Bar)变大(sizeof是编译时计算的),以及内部数据成员的offset变化,这是不是安全的?通常不是安全的,但也有例外:
1.如果客户代码里有new Bar,那么肯定不安全,因为new的字节数不够装下新Bar对象。相反,如果library通过factory返回Bar *(并通过factory来销毁对象)或者直接返回shared_ptr<Bar>
,客户端不需要用到sizeof(Bar),那么可能是安全的。
2.如果客户代码里有Bar *pBar; pBar->memberA = xx;,那么肯定不安全,因为memberA的新Bar的偏移可能会变。相反,如果只通过成员函数来访问对象的数据成员,客户端不需要用到data member的offsets,那么可能是安全的。
3.如果客户调用pBar->setMemberA(xx);,而Bar::setMemberA()是个inline function,那么肯定不安全,因为偏移量已经被inline到客户的二进制代码里了。如果setMemberA()是outline function,其实现位于shared library中,会随着Bar的更新而更新,那么可能是安全的。
那么只使用header-only的库文件是不是安全呢?不一定。如果你的程序用了boost 1.36.0,而你依赖的某个library在编译时用的是1.33.1,那么你的程序和这个library就不能正常工作。因为1.36.0和1.33.1的boost::function的模板参数类型的个数不一样,后者多了一个allocator。
这里有一份黑名单,列在这里的肯定是二进制不兼容的,没有列出的也可能是二进制不兼容的,见KDE(一个开源的桌面环境,为Linux和其他类Unix系统提供图形化用户界面(GUI))的文档(http://techbase.kde.org/Policies/Binary_Compatibility_Issues_With_C%2B%2B)。
11.2.3 那些做法多半是安全的
前面作者说“不能轻易修改”,暗示有些改动多半是安全的,这里有一份白名单,欢迎添加更多内容:
1.增加新的class。
2.增加non-virtual成员函数或static成员函数。
3.修改数据成员的名称,因为生产的二进制代码是按偏移量来访问的。当然,这会造成源码级的不兼容。
4.还有很多,不一一举例了。
只要库改动不影响现有的可执行文件的二进制代码的正确性,那么就是安全的,我们就可以先部署新的库,让现有的二进制程序受益。
11.2.4 反面教材:COM(Component Object Model(组件对象模型),它是一种微软提出的软件组件技术,用于实现跨平台、跨语言的对象通信)
在C++中以虚函数作为接口基本上就跟二进制兼容性说“bye-bye”了。具体地说,以只包含虚函数的class(称为interface class)作为程序库的接口,这样的接口是僵硬的,一旦发布,无法修改。
另外,Windows下,Visual C++编译的时候要选择Release或Debug模式,而且Debug模式编译出来的library通常不能在Release binary中使用(反之亦然)这也是因为两种模式下的CRT(C Runtime Library)二进制不兼容(指在使用不同版本的C运行时库(C Runtime Library,即动态库)编译的程序之间,由于库的版本不同而导致的兼容性问题)(主要是内存分配方面,Debug有自己的簿记(bookkeeping))。Linux就没有这个麻烦,可以混用。
11.2.5 解决办法
采用静态链接
这里的静态链接不是指使用静态库(.a),而是指完全从源码编译出可执行文件(10.5.3)。在分布式系统里,采用静态链接也带来部署上的好处,只要把可执行文件放到机器上就能运行,不用考虑它依赖的libraries。目前muduo就是采用静态链接。
通过动态库的版本管理来控制兼容性
这需要非常小心地检查每次改动的二进制兼容性并做好发布计划,比如1.0.x版本系列之间做到二进制兼容,1.1.x版本之间做到二进制兼容,而1.0.x和1.1.x不必二进制兼容。《程序员的自我修养》[LLL]讲了.so文件的命名与二进制兼容性相关的话题,值得一读。
用pimpl(Pointer to Implementation)技法,编译器防火墙
在头文件中只暴露non-virtual接口,并且class的大小固定为sizeof(Impl*),这样可以随意更新库文件而不影响可执行文件。具体做法见11.4。当然,这么做又多了一道间接性,可能有一定的性能损失。另见《Exceptional C++》的有关条款和《C++编程规范》[CCS,条款43]。
11.3 避免使用虚函数作为库的接口
作为C++动态库的作者,应当避免使用虚函数作为库的接口。这么做会给保持二进制兼容性带来很大麻烦,不得不增加很多不必要的interfaces,最终重蹈COM的覆辙。
本节主要讨论Linux x86/x86-64平台,下面会继续举Windows/COM作为反面教材。本节是11.2“程序库的二进制兼容性”的延续,在初次发表11.2内容的时候,作者原本以为大家都对“以C++虚函数作为接口”的害处达成了共识,因此就写得比较简略,但现在看来情况并非如此,作者还得展开谈一谈。
“接口”有广义和狭义之分,本节用中文“接口”表示广义的接口,即一个库的代码界面;用英文interface表示狭义的接口,即只包含virtual function的class,这种class通常没有data member,在Java里有一个专门的关键字interface来表示它。
11.3.1 C++程序库的作者的生存环境
假设你是一个shared library的维护者,你的library被公司另外的两三个团队使用了。你发现了一个安全漏洞,或者某个会导致crash的bug需要紧急修复,那么你修复之后,能不能直接部署library的二进制文件?有没有破坏二进制兼容性?会不会破坏别人团队已经编译好的投入生产环境的可执行文件?是不是要强迫别的团队重新编译链接,把可执行文件也发布新版本?会不会打乱别人的release cycle?这些都是工程开发中经常要遇到的问题。
如果你打算新写一个C++ library,那么通常要做以下几个决策:
1.以什么方式发布?动态库还是静态库(本节不考虑源代码发布这种情况,这其实和静态库类似)?
2.以什么方式暴露库的接口?可选的做法有:以全局(含namespace级别)函数为接口、以class的non-virtual成员函数为接口、以virtual函数为接口。
Java程序员不需要考虑这么多,直接写class成员函数就行,最多考虑一下要不要给method或class标上final。也不必考虑什么动态库、静态库,都是.jar文件。
在作出上面两个决策之前,我们考虑两个基本假设:
1.代码会有bug,库也不例外。将来可能会发布bug fixes。
2.会有新的功能需求。写代码不是一锤子买卖,总是会有新的需求冒出来,需要程序员往库里增加东西。这是好事情,让程序员不丢饭碗。
也就是说,在设计库的时候必须要考虑将来如何升级。如果你的代码第一次发布的时候就已经做到完美,将来不需要任何修改,那么怎么都行,也就不必继续阅读本节内容了。
基于以上两个基本假设来做决定。第一个决定很好做,如果需要hot fix,那么只能用动态库;否则,在分布式系统中使用静态库更容易部署,这在前面已经谈过。“动态库比静态库节约内存”这种优势在今天看来已不太重要。
下面假定你或者你的老板选择以动态库方式发布,即发布.so或.dll文件,来看看第二个决定怎么做。再说一句,如果你能够以静态库方式发布,后面的麻烦都不会遇到。
第二个决定不那么容易做,关键问题是,要选择一种可扩展的(extensible)接口风格,让库的升级变得更轻松。“升级”有两层意思:
1.对于bug fix only的升级,二进制库文件的替换应该兼容现有的二进制可执行文件。二进制兼容性方面的问题已经在前面谈过,这里从略。
2.对于新增功能的升级,应该对客户代码友好。升级库之后,客户端使用新功能的代价应该比较小。只需要包含新的头文件(这一步可以省略,如果新功能已经加入原有的头文件中),然后编写新代码即可。而且,不要在客户代码中留下垃圾,后面我们会谈到什么是垃圾。
在讨论虚函数接口的弊端之前,我们先看看虚函数做接口的常见用法。
11.3.2 虚函数作为库的接口的两大用途
虚函数作为接口大致有这么两种用法:
1.调用。也就是库提供一个什么功能(比如绘图Graphics),以虚函数为接口方式暴露给客户端代码。客户端代码一般不需要继承这个interface,而是直接调用其member function。这么做据说是有利于接口和实现分离,作者认为纯属多此一举、自欺欺人。
2.回调。也就是事件通知,比如网络库的“连接建立”、“数据到达”、“连接断开”等等。客户端代码一般会继承这个interface,然后把对象实体注册到库里边,等库来回调自己。一般来说客户端不会自己去调用这些member function,除非是为了写单元测试模拟库的行为。
3.混合。一个class既可以被客户端代码继承用作回调,又可以被客户端直接调用。说实话作者没有看出这么做的好处,但实际中某些面向对象的C++库就是这么设计的。
对于“回调”方式,现代C++有更好的做法,即boost::function+boost::bind。muduo的回调即采用这种新方法(11.5)。以下不考虑以虚函数为回调的过时做法。
对于“调用”方式,这里举一个虚构的图形库来说明问题。这个库的功能是画线、画矩形、画圆弧:
这里略去了很多与本文主题无关的细节,比如Graphics的构造与析构、draw*()函数应该是public、Graphic应该不允许复制,还比如Graphics可能会用pure virtual functions等等,这些都不影响本文的讨论。
这个Graphics库的使用很简单,客户端看起来是这个样子:
似乎一切都很好,阳光明媚,符合“面向对象的原则”,但是一旦考虑升级,前景立刻变得昏暗。
11.3.3 虚函数作为接口的弊端
以虚函数作为接口在二进制兼容性方面有本质困难:“一旦发布,不能修改”。
假如作者需要给Graphics增加几个绘图函数,同时保持二进制兼容性。这几个新函数的坐标以浮点数表示,作者理想中的接口是:
受C++二进制兼容性方面的限制,我们不能这么做。其本质问题在于C++以vtable[offset]方式实现虚函数调用,而offset又是根据虚函数声明的位置隐式确定的,这造成了脆弱性。作者增加了drawLine(double x0, double y0, double x1, double y1),造成vtable的排列发生了变化,现有的二进制可执行文件无法再用旧的offset调用到正确的函数。
怎么办呢?有一种危险且丑陋的做法,即把新的虚函数放到interface的末尾:
这么做很丑陋,因为新的drawLine(double x0, double y0, double x1, double y1)函数没有和原来的drawLine()函数待在一起,造成了阅读上的不变。这么做同时很危险,因为Graphics如果被继承,那么新增虚函数会改变派生类中的vtable offset变化,同样不是二进制兼容的。
另外有两种似乎安全的做法,这也是COM采用的办法:
1.通过链式继承来扩展现有的interface,例如从Graphics派生出Graphics2。
将来如果继续增加功能,那么还会有class Graphic3 : public Graphics2,以及class Graphic4 : public Graphics3等等。这么做和前面的做法一样丑陋,因为新的drawLine(double x0, double y0, double x1, double y1)函数位于派生Graphics2 interface中,没有和原来的drawLine()待在一起,造成了割裂。
2.通过多重继承来扩展现有的interface,例如定义一个与Graphics class有同样成员的Graphics2,再让实现同时继承这两个interface。
这种带版本的interface的做法在COM使用者的眼中看起来是很正常的(比如IXMLDOMDocument(Microsoft XML Core Services (MSXML)库中的一个接口,用于处理XML文档)、IXMLDOMDocument2、IXMLDOMDocument3,又比如ITaskbarList(Windows操作系统中的一个接口,可以用于与任务栏进行交互和操作,它是Windows Shell提供的一个接口,属于Shell Lightweight Utility API(轻量级Shell实用工具API,是一组由Windows Shell提供的API,用于与Windows操作系统的Shell进行交互和操作))、ITaskbarList2、ITaskbarList3、ITaskbarList4等等),这解决了二进制兼容性的问题,客户端源代码也不受影响。
在作者看来带版本的interface实在是很丑陋,因为每次改动都引入了新的interface class,会造成日后客户端代码难以管理。比如,如果新版应用程序的代码使用了Graphics3的功能,要不要把现有代码中出现的Graphics2都替换掉?
1.如果不替换,一个程序同时依赖多个版本的Graphics,一直背着历史包袱。依赖的Graphics版本越积越多,将来如何管理得过来?
2.如果要替换,为什么不相干的代码(现有的运行得好好的使用Graphics2的代码)也会因为别处用到了Graphics3而被修改?
这种两难境地纯粹是“以虚函数为库的接口”造成的。如果我们能直接原地扩充class Graphics,就不会有这些麻烦事,见11.4“动态库接口的推荐做法”。
11.3.4 假如Linux系统调用以COM接口方式实现
或许上面这个Graphics的例子太简单,没有让“以虚函数为接口”的缺点充分暴露出来,下面让我们看一个真实的案例:Linux Kernel。
Linux kernel从0.01的67个系统调用(http://lxr.linux.no/linux-old+v0.01/include/unistd.h#L60)发展到2.6.37的340个系统调用(http://lxr.linux.no/linux+v2.6.37.3/arch/x86/include/asm/unistd_32.h),kernel interface一直在扩充,而且保持良好的兼容性,它保持兼容性的办法很土,就是给每个system call赋予一个终身不变的数字代号,等于把虚函数表的排列固定下来。打开前面的两个链接,你就能看到fork()在Linux 0.01和Linux 2.6.37里的代号都是2(系统调用的编号跟硬件平台有关,这里我们看的是x86 32-bit平台)。
试想加入Linus当初选择用COM接口的链式继承风格来描述,将会是怎样一种壮观的景象?为了避免扰乱视线,请移步观看近百层继承的代码:https://gist.github.com/867174(先后关系与版本号不一定100%准确,作者是用git blame(一个git命令,用于查看指定文件的每一行是由谁所提交的,以及提交的时间和提交者的名字)去查的,现在列出的代码只从0.01到2.5.31,相信已经足以展现COM接口方式的弊端)。
不要误认为“接口一旦发布就不能更改”是天经地义的,那不过是“以C++虚函数为接口”的固有弊端,如果跳出这个框框去思考,其实C++库的接口很容易做得更好。为什么不能改?还不是因为用了C++虚函数作为接口。Java的interface可以添加新函数,C语言的库也可以添加新的全局函数,C++ class也可以添加新non-virtual成员函数和namespace级别的non-member函数,这些都不需要继承出来新interface就能扩充原有接口。偏偏COM的interface不能原地扩充,只能通过继承来workaround,产生一堆带版本的interfaces。有人说COM是二进制兼容性的正面例子,作者深不以为然。COM确实以一种最丑陋的方式做到了“二进制兼容”。脆弱和僵硬就是以C++虚函数为接口的宿命。
相反,Linux系统调用的编号以编译器常数方式固定下来,万年不变,轻而易举地解决了这个问题。在其他面向对象语言(Java/C#)中,作者也没有见过每改动一次就给interface递增版本号的诡异做法。
还是应了《The Zen of Python》中的那句话:“Explicit is better than implicit, Flat is better than nested.”
11.3.5 Java是如何应对的
Java实际上把C/C++的linking这一步骤推迟到class loading的时候来做。就不存在“不能增加虚函数”,“不能修改data member”等问题(在C/C++中,是通过已经固定的offset来获取虚函数和data member的)。在Java中用面向interface编程远比C++更通用和自然,也没有上面提到的“僵硬的接口”问题。
11.4 动态库接口的推荐做法
取决于动态库的使用范围,有两类做法。
其一,如果动态库的使用范围比较窄,比如本团队内部的两三个程序在用,用户都是受控的,要发布新版本也比较容易协调,那么不用太费事,只要做好发布的版本管理就行了。再在可执行文件中使用rpath(runpath,是一种指定可执行文件运行时搜索动态库的路径的机制,这样可执行文件在运行时会根据rpath设置去搜索并加载所需的动态库)把库的完整路径确定下来。
比如现在Graphics库发布了1.1.0和1.2.0两个版本,这两个版本可以不必是二进制兼容的。用户的代码从1.1.0升级到1.2.0的时候要重新编译一下,反正他们要用新功能都是要重新编译代码的。如果要原地打补丁,那么1.1.1应该和1.1.0二进制兼容,而1.2.1应该和1.2.0兼容。如果要加入新的功能,而新的功能与1.2.0不兼容,那么应该发布到1.3.0版本。
为了便于检查二进制兼容性,可考虑把库的代码的暴露情况分辨清楚。muduo的头文件和class就有意识地分为用户可见和用户不可见两部分(第六章)。对于用户可见的部分,升级时要注意二进制兼容性,选用合理的版本号;对于用户不可见的部分,在升级库的时候就不必在意。另外muduo本身设计来是以源文件方式发布的,在二进制兼容性方面没有做太多的考虑。
其二,如果库的使用范围很广,用户很多,各家的release cycle不尽相同,那么推荐pimpl技法[CCS,条款43],并考虑多采用non-member non-friend function in namespace[EC3,条款23][CCS,条款44和57]作为接口。这里以前面的Graphics为例,说明pimpl的基本手法。
1.暴露的接口里边不要有虚函数,要显式声明构造函数、析构函数,并且不能inline,原因见10.3.2。另外sizeof(Graphics) == sizeof(Graphics::Impl*)。
// graphics.h
class Graphics
{
public:Graphics(); // outline ctor~Graphics(); // outline dtorvoid drawLine(int x0, int y0, int x1, int y1);void drawLine(Point p0, Point p1);void drawRectangle(int x0, int y0, int x1, int y1);void drawRectangle(Point p0, Point p1);void drawArc(int x, int y, int r);void drawArc(Point p, int r);private:class Impl; // 头文件只放声明boost::scoped_ptr<Impl> impl;
};
2.在库的实现中把调用转发(forward)给Graphics::Impl,这部分代码位于.so/.dll中,随库的升级一起变化。
// graphics.cc
#include <graphics.h>class Graphics::Impl
{
public:void drawLine(int x0, int y0, int x1, int y1);void drawLine(Point p0, Point p1);void drawRectangle(int x0, int y0, int x1, int y1);void drawRectangle(Point p0, Point p1);void drawArc(int x, int y, int r);void drawArc(Point p, int r);
};Graphics::Graphics() : impl(new Impl)
{
}Graphics::~Graphics()
{
}void Graphics::drawLine(int x0, int y0, int x1, int y1)
{impl->drawLine(x0, y0, x1, y1);
}void Graphics::drawLine(Point p0, Point p1)
{impl->drawLine(p0, p1);
}// ...
3.如果要加入新的功能,不必通过继承来扩展,可以原地修改,且很容易保持二进制兼容性。先动头文件:
然后再实现文件里增加forward,这么做不会破坏二进制兼容性,因为增加non-virtual函数不影响现有的可执行文件。
采用pimpl多了一道explicit forward的手续,带来的好处是可扩展性与二进制兼容性,这通常是划算的。pimpl扮演了编译器防火墙的作用。
pimpl不仅C++语言可以用,C语言的库同样可以用,一样带来二进制兼容性的好处,比如libevent2中的event_base是个opaque pointer,客户端看不到其成员,都是通过libevent的函数和它打交道,这样库的版本升级比较容易做到二进制兼容。
为什么non-virtual函数比virtual函数更健壮?因为virtual function是bind-by-vtable-offset,而non-virtual function是bind-by-name。加载器(loader,指的是操作系统的动态链接库加载器)会在程序启动时做决议(resolution),通过mangled name把可执行文件和动态库链接到一起。就像使用Internet域名比使用IP地址更能适应变化一样。
万一要跨语言怎么办?很简单,暴露C语言的接口。Java有JNI(Java Native Interface,它的主要目的是在Java虚拟机(JVM)中调用和被本地代码调用,从而提供了Java程序与其他编程语言(如C、C++)的集成能力)可以调用C语言的代码;Python/Perl/Ruby等的解释器都是C语言编写的,使用C函数也不在话下。C函数是Linux下的万能接口。
本节只谈了使用class为接口,其实用free function有时候更好(比如muduo/base/Timestamp.h除了定义class Timestamp外,还定义了muduo::timeDifference()等free function),这也是C++比Java等纯面向对象语言优越的地方。
11.5 以boost::function和boost::bind取代虚函数
本节的中心思想是“面向对象的继承就像一条贼船,上去就下不来了”,而借助boost::function和boost::bind,大多数情况下,你都不用上“贼船”。
boost::function和boost::bind已经纳入了std::tr1(Technical Report 1,是C++标准库中的一个扩展,它是对C++03标准之后提出的一些建议的实现,TR1是C++社区为标准库引入新功能而制定的技术报告,它为标准化过程提供了一个实验性的阶段,以便在将来的C++标准中被正式采纳),这或许是C++11最值得期待的功能,它将彻底改变C++库的设计方式,以及应用程序的编写方式。
Scott Meyers的[EC3,条款35],提到了以boost::function和boost::bind取代虚函数的做法,另见孟岩的《function/bind的救赎(上)》(http:😕/blog.csdn.net/myan/archive/2010/10/09/5928531.aspx)、《回复几个问题》(http:😕/blog.csdn.net/myan/archive/2010/09/14/5884695.aspx)中的“四个半抽象”,这里谈谈作者自己使用的感受。
作者对面向对象的“继承”和“多态”的态度是能不用就不用,因为很难纠正错误。如果有一棵类型继承树(class hierarchy),人们在一开始设计时就得考虑各个class在树上的位置。随着时间的推衍,原来正确的决定有可能变成错误的。但是更正这个错误的代价可能很高。要想把这个class在继承树上从一个节点挪到另一个节点,可能要触及所有用到这个class的客户代码,所有用到其各层基类的客户代码,以及从这个class派生出来的全部class的代码。简直是牵一发而动全身,在C++缺乏良好重构工具的语言下,有时候只好保留错误,用些wrapper或者adapter来掩盖之。久而久之,设计越来越烂,最后只好推倒重来(Linux在2007年炮轰C++时说:“(C++面向对象)导致低效的抽象编程模型,可能在两年之后你会注意到有些抽象效果不怎么样,但是所有代码已经依赖于围绕它设计的‘漂亮’对象模型了,如果不重写应用程序,就无法改成。”,译文引自http://blog.csdn.net/turingbook/article/details/1775488)。解决办法之一就是不采用基于继承的设计,而是写一些容易使用也容易修改的具体类。
总之,继承和虚函数是万恶之源,这条“贼船”上去就不容易下来。不过还好,在C++里我们有别的办法:以boost::function和boost::bind取代虚函数。
用“继承树”这种方式来建模,确实是基于概念分类的思想。“分类”似乎是西方哲学一早就有的思想,影响深远,这种思想估计可以上溯到古希腊时期。
1.比如电影,可以分为科幻片、爱情片、伦理片、战争片、灾难片、恐怖片等等。
2.比如生物,按小学知识可以分为动物和植物,动物又可以分为有脊椎动物和无脊椎动物,有脊椎动物又分为鱼类、两栖类、爬行类、鸟类和哺乳类等。
3.又比如技术书籍分为电子类、通信类、计算机类等等,计算机书籍又可分为编程语言、操作系统、数据结构、数据库、网络技术等等。
这种分类法或许是早期面向对象方法的模仿对象。这种思考方式的本质困难在于:某些物体很难准确分类,似乎有不止一个分类适合它。而且不同的人看法可能不同,比如一部科幻悬疑片到底是科幻的成分重还是悬疑的成分重,到底该归入哪一类。
在编程方面,情况更糟,因为这个“物体x”是变化的,一开始分入A类可能是合理的(x “is-a” A),随着功能演化,分入B类或许更合适(x is more like a B),但是这种改动对现有代码的代价已经太高了(特别对于C++)。
在传统的面向对象语言中,可以用继承多个interfaces来缓解分错类的代价,使得一物多用。但是某些语言限制了基类只能有一个,在新增类型时可能会遇到麻烦,见星巴克卖鸳鸯奶茶的例子(http://www.cnblogs.com/Solstice/archive/2011/04/22/2024791.html)。
现代编程语言这一步走得更远,Ruby的duck typing和Google Go的无继承(http://golang.org/doc/go_lang_faq.html#inheritance)都可以看做以tag取代分类(层次化的类型)的代表。一个object只要提供了相应的operations,就能当做某种东西来用,不需要显式地继承或实现某个接口。这确实是一种进步。
对于C++的四种范式(面向过程(PP,Procedural Programming)、面向对象(OOP,Object-Oriented Programming)、泛型编程(GP,Generic Programming)、函数式编程(FP,Functional Programming)),作者现在基本只把它当better C和data abstraction来用。OO和GP可以在非常小的范围内使用,只要暴露的接口是object based(甚至global function)就行。
以上谈了设计层面,再来说一说实现层面。
在传统的C++程序中,事件回调是通过虚函数进行的。网络库往往会定义一个或几个抽象基类(Handler class),其中声明了一些(纯)虚函数,如onConnect()、onDisconnect()、onMessage()、onTimer()等等。使用者需要继承这些基类,并覆写(override)这些虚函数,以获得事件回调通知。由于C++的动态绑定只能通过指针或引用实现,使用者必须把派生类(MyHandler)对象的指针或引用隐式转换为基类(Handler)的指针或引用,再注册到网络库中。MyHandler对象通常是动态创建的,位于堆上,用完后需要delete。网络库调用基类的虚函数,通过动态绑定机制实际调用的是用户在派生类中override的虚函数,这也是各种OO framework的通行做法。这种方式在Java这种纯面向对象语言中是正当做法(Java 8也有新的Closure语法,C#从一诞生就有delegate(委托,委托是一种引用类型,它可以用于封装具有相同参数列表和返回类型的方法,声明的委托可以像方法一样被调用))。但是在C++这种非CG语言中,使用虚函数作为事件回调接口有其本质困难,即如何管理派生类对象的生命期。在这种接口风格中,MyHandler对象的所有权和生命期很模糊,到底谁(用户还是网络库)有权力释放它呢?有的网络库甚至出现了delete this;这种代码,让人捏一把汗:如果才能保证此刻程序的其他地方没有保存着这个即将销毁的对象的指针呢?另外,如果网络库需要自己创建MyHandler对象(比方说需要为每个TCP连接创建一个MyHandler对象),那么就得定义另外一个抽象基类HandlerFactory,用户要从它派生出MyHandlerFactory,再把后者的指针或引用注册到网络库中。以上这些都是面向对象编程的常规思路,或许大家已经习以为常。
在现代C++中(指2005年TR1之后,不是最新的C++11),事件回调有了新的推荐做法,即boost::function + boost::bind(即std::tr1::function + std::tr1::bind,也是最新C++11中的std::function + std::bind),这种方式的一个明显优点是不必担心对象的生存期。muduo正是用boost::function来表示事件回调的,包括TCP网络编程的三个半IO事件和定时器事件等。用户代码可以传入签名相同的全局函数,也可以借助boost::bind把对象的成员函数传给网络库作为事件回调的接受方。这种接口方式对用户代码的class类型没有限制(不必从特定的基类派生),对成员函数名也没有限制,只对函数签名有部分限制。这样自然也解决了空悬指针的难题,因为传给网络库的都是具有值语义的boost::function对象。从这个意义上说,muduo不是一个面向对象的库,而是一个基于对象的库。因为muduo暴露的接口都是一个个的具体类,完全没有虚函数(无论是调用还是回调)。
言归正传,说说boost::function和boost::bind取代虚函数的具体做法。
11.5.1 基本用途
boost::function就像C#里的delegate,可以指向任何函数,包括成员函数。当用bind把某个成员函数绑到某个对象上时,我们得到了一个closure(闭包,它允许函数捕获并引用在其定义范围外部的变量)。例如:
class Foo
{
public:void methodA();void methodInt(int a);void methodString(const string &str);
};class Bar
{
public:void methodB();
};boost::function<void()> f1; // 无参数,无返回值Foo foo;
f1 = boost::bind(&Foo::methodA, &foo);
f1(); // 调用foo.methodA();Bar bar;
f1 = boost::bind(&Bar::methodB, &bar);
f1(); // 调用bar.methodB();f1 = boost::bind(&Foo::methodInt, &foo, 42);
f1(); // 调用foo.methodInt(42);
// 由于f1是无参数的,因此此处bind的时候一定要传一个int参数,如上面的42f1 = boost::bind(&Foo::methodString, &foo, "hello");
f1(); // 调用foo.methodString("hello");
// 注意,bind拷贝的是实参类型(const char *),不是形参类型(string)
// 这里形参中的string对象的构造发生在调用f1的时候,而非bind的时候,
// 因此要留意bind的实参(const char *)的生命期,它应该不短于f1的生命期。
// 必要时可通过bind(&Foo::methodString, &foo, string(aTempBuf))来保证安全boost::function<void(int)> f2; // int参数,无返回值
f2 = boost::bind(&Foo::methodInt, &foo, _1);
f2(53); // 调用foo.methodInt(53);
如果没有boost::bind,那么boost::function就什么都不是;而有了bind,“同一个类的不同对象可以delegate给不同的实现(指的是不同对象的function分别bind到了不同的函数上),从而实现不同的行为”(孟岩),简直就无敌了。
11.5.2 对程序库的影响
程序库的设计不应该给使用者带来不必要的限制(耦合),而继承是第二强的一种耦合(最强耦合的是友元(应该是由于友元直接深入到类的内部,提供了细粒度的访问权限))。如果一个程序库限制其使用者必须从某个class派生,那么作者觉得这是一个糟糕的设计。不巧的是,目前不少C++程序库就是这么做的。
例1:线程库
常规OO设计
写一个Thread base class,含有(纯)虚函数Thread::fun(),然后应用程序派生一个derived class,覆写run()。程序里的每一种线程对应一个Thread的派生类。例如Java的Thread class可以这么用。
缺点:如果一个class的三个method需要在三个不同的线程中执行,就得写helper class(es)并玩一些OO把戏(直接派生三个derived class,分别覆写run不就可以吗?)。
基于boost::function的设计
令Thread是一个具体类,其构造函数接受ThreadCallback对象。应用程序只需提供一个能转换为ThreadCallback的对象(可以是函数),即可创建一份Thread实体,然后调用Thread::start()即可。Java的Thread也可以这么用,传入一个Runnable对象。C#的Thread只支持这一种用法,构造函数的参数是delegate ThreadStrat(是一个在C#中定义的委托类型,用于定义可以在新线程上执行的方法的签名)。boost::thread也只支持这种用法。
// 一个基于boost::function的Thread class基本结构
class Thread
{
public:typedef boost::function<void()> ThreadCallback;Thread(ThreadCallback cb) : cb_(cb){ }void start(){/* some magic to call run() in new created thread */}private:void run(){cb_();}ThreadCallback cb_;// ...
};
使用方式:
class Foo // 不需要继承
{
public:void runInThread();void runInAnotherThread(int);
};Foo foo;
Thread thread1(boost::bind(&Foo::runInThread, &foo));
Thread thread2(boost::bind(&Foo::runInAnotherThread, &foo, 43));
thread1.start(); // 在两个线程中分别运行两个成员函数
thread2.start();
例2:网络库
以boost::function作为桥梁,NetServer class(网络库的一部分)对其使用者没有任何类型上的限制,只对成员函数的参数和返回类型有限制。使用者(如下例的EchoService)也完全不知道NetServer的存在,只要在main()里把两者装配到一起,程序就跑起来了(本小节内容写得比较早,那会儿作者还没有开始写muduo,所以该例子与现在的代码有些脱节)。
// network library
class Connection;
class NetServer : boost::noncopyable
{
public:typedef boost::function<void (Connection *)> ConnectionCallback;typedef boost::function<void (Connection *, const void *, int len)> MessageCallback;NetServer(uint16_t port);~NetServer();void registerConnectionCallback(const ConnectionCallback &);void registerMessageCallback(const MessageCallback &);void sendMessage(Connection *, const void *buf, int len);private:// ...
};
// user code
class EchoService
{
public:// 符合NetServer::sendMessage的原型typedef boost::function<void (Connection *, const void *, int)> SendMessageCallback;// sendMsgCb是网络库提供的函数,用于发送数据EchoService(const SendMessageCallback &sendMsgCb) : sendMessageCb_(sendMsgCb) // 保存boost::function{ }// 符合NetServer::MessageCallback的原型void onMessage(Connection *conn, const void *buf, int size){// %.*s中,点后面的数字表示字符串最多输出的字符数,*表示通过传递参数来指定数字printf("Received Mes from Connection %d: %.*s\n", conn->id(), size, (const char *)buf);sendMessageCb_(conn, buf, size); // echo back}// 符合NetServer::ConnectionCallback的原型void onConnection(Connection *conn){printf("Connection from %s:%d is %s\n", conn->ipAddr(), conn->port(), conn->connected() ? "UP" : "DOWN");}private:SendMessageCallback sendMessageCb_;
};// 扮演上帝的角色,把各部件拼起来
int main()
{NetServer server(7);EchoService echo(bind(&NetServer::sendMessage, &server, _1, _2, _3));server.registerMessageCallback(bind(&EchoService::onMessage, &echo, _1, _2, _3));server.registerConnectionCallback(bind(&EchoService::onConnection, &echo, _1));server.run();
}
11.5.3 对面向对象程序设计的影响
一直以来,作者对面向对象都有一种厌恶感,叠床架屋,绕来绕去的,一拳拳打在棉花上,不解决实际问题。面向对象的三要素是封装、继承和多态。作者认为封装是根本的,继承和多态则是可有可无的。用class来表示concept,这是根本的;至于继承和多态,其耦合性太强,往往不划算。
继承和多态不仅规定了函数的名称、参数、返回类型,还规定了类的继承关系。在现代的OO编程语言里,借助反射(它允许程序在运行时动态地获取和操作对象的信息(如类、属性、方法等),而不需要在编译时明确知道这些信息)和attribute/annotation(属性/注解,它们是在源代码中添加的特殊标记或标签,用于为类、方法、字段或其他代码元素增加额外的信息。这些信息可以用于编译器、IDE或其他工具在编译时或运行时进行处理和使用),已经大大放宽了限制。举例来说,JUnit(一个用于编写和运行单元测试的Java测试框架) 3.x是用反射,找出派生类里的名字符合void test*()的函数来执行的,这里就没继承什么事,只是对函数的名称有部分限制(继承是全面限制,一字不差)。至于JUnit 4.x和NUnit(一个开源的单元测试框架,它是用C#开发的,在.NET平台上运行,NUnit提供了一套简单易用的API,用于编写和运行单元测试) 2.x则更进一步,以annotation/attribute来标明test case,更没继承什么事了。
作者的猜测是,当初提出面向对象的时候,closure还没有一个通用的实现,所以它没能算作基本的抽象工具之一。现在既然closure已经这么方便了,或许我们应该重新审视面向对象设计,至少不要那么滥用继承。
自从找到了boost::function+boost::bind这对“神兵利器”,不用再考虑class之间的继承关系,只需要基于对象的设计(object-based),拳拳到肉,程序写起来顿时顺手了很多。
对面向对象设计模式的影响
既然虚函数能用closure代替,那么很多OO设计模式,尤其是行为模式(Behavioral Design Patterns,一种面向对象设计模式,它关注对象之间的责任分配和算法的抽象,行为模式有助于定义对象之间的协作方式,使系统更加灵活、可扩展,并且更容易维护),就失去了存在的必要。另外,既然没有继承体系,那么很多创建型模式似乎也没啥用了(比如Factory Method(工厂模式中,有一个抽象的工厂类,它声明了一个创建对象的接口,即Factory Method,但并不实现具体的创建过程,具体的对象创建由其子类来完成,每个子类实现工厂接口的方法以返回不同类型的对象)可以用boost::function<Base *()>替代)。
最明显的是Strategy(策略模式,是一种行为型设计模式,它定义了一系列算法,将每个算法封装起来,派生出不同的具体策略类,策略模式一般还会有一个环境类,其中可以保存不同的具体策略类的对象的基类指针或引用,保存的过程相当于set了环境,这样客户端相同的代码可以在不同的环境中调用不同的派生类算法),不用累赘的Strategy基类和ConcreteStrategyA、ConcreteStrategyB等派生类,一个boost::function成员就能解决问题。另外一个例子是Command模式(命令模式,一种行为型设计模式,它将请求封装成一个对象,使得可以用不同的请求参数化客户端对象,并且能够对请求进行排队、记录请求日志、以及支持可撤销的操作,命令模式的核心思想是将请求的发送者(调用者)和接收者解耦,通过一个命令对象来封装请求,这种封装使得我们可以对请求进行参数化、队列化、保存请求历史、以及支持撤销操作),有了boost::function,函数调用可以直接变成对象,似乎就没Command什么事了。同样的道理,Template Method(模板方法模式,是一种行为型设计模式,它定义了一个算法的骨架,将一些步骤延迟到子类中,模板方法使得子类可以在不改变算法结构的情况下重定义算法中的某些步骤)可以不必使用基类与继承,只要传入几个boost::function对象,在原来调用虚函数的地方换成调用boost::function对象就能解决问题。
在《设计模式》这本书中提到了23个模式,在作者看来其更多的是弥补了C++这种静态类型语言在动态性方面的不足。在动态语言中,由于语言内置了一等公民的类型和函数(“一等公民”指类型和函数可以像普通变量一样使用(赋值,传参),既可以用一个变量表示一个类型,通过该变量构造其代表的类型的对象;也可以用一个变量表示一个函数,通过该变量调用其代表的函数),这使得很多模式失去了存在的必要(http://norvig.com/design-patterns)。或许它们解决了面向对象中的常见问题,不过要是作者的程序里连面向对象(指继承和多态)都不用,那似乎也不用叨扰面向对象设计模式了。
或许基于closure的编程将作为一种新的编程范式(paradigm)而流行起来。
依赖注入与单元测试
前面的EchoService可算是依赖注入的例子。EchoService需要一个什么东西来发送消息,它对这个“东西”的要求只是函数原型满足SendMessageCallback,而并不关心数据到底发到网络上还是发到控制台。在正常使用的时候,数据应该发给网络;而在做单元测试的时候,数据应该发给某个DataSink(数据汇,通常用于描述一个系统、流程或组件,它负责接收和存储数据,但不进行进一步的处理,常见的数据汇的例子:数据库、文件系统、消息队列、网络端点、缓存)。
按照面向对象的思路,先写一个AbstractDataSink interface,包含sendMessage()这个虚函数,然后派生出两个class:NetDataSink和MockDataSink,前面那个干活用,后面那个单元测试用。EchoService的构造函数应该以AbstractDataSink *为参数,这样就实现了所谓的接口与实现分离。
作者认为这么做纯粹是多此一举,因为直接传入一个SendMessageCallback对象就能解决问题。在单元测试的时候,可以boost::bind()到MockServer上,或某个全局函数上,完全不用继承和虚函数,也不会影响现有的设计。
什么时候使用继承
如果是指OO中的public继承,即为了接口与实现分离,那么作者只会在派生类的数目和功能完全确定的情况下使用。换句话说,不为将来的扩展考虑,这时候面向对象或许是一种不错的描述方法。一旦要考虑扩展,什么办法都没用,还不如把程序写简单点,将来好大改或重写。
如果是功能继承,那么作者会考虑继承boost::noncopyable或boost::enable_shared_from_this,第一章讲到了enable_shared_from_this在实现多线程安全的对象回调时的妙用(在对象本身的一个方法里调用bind,且bind的目标是该对象本身的另一个方法时,可用智能指针代替类本身的指针,这样智能指针就会被bind保存,从而保证调用该被bind的方法时对象还是存在的)。
例如,IO multiplexing在不同的操作系统下有不同的推荐实现,最通用的select()、POSIX的poll()、Linux的epoll()、FreeBSD的kqueue()等,数目固定,功能也完全确定,不用考虑扩展。那么设计一个NetLoop base class加若干具体classes就是不错的解决办法。换句话说,用多态来代替switch-case以达到简化代码的目的。
基于接口的设计
这个问题来自那个经典的讨论:不会飞的企鹅(Penguin)究竟应不应该继承自鸟(Bird),如果Bird定义了virtual function fly()的话。讨论的结果是,把具体的行为提出来,作为interface,比如Flyable(能飞的)、Runnable(能跑的),然后让企鹅实现Runnable,麻雀实现Flyable和Runnable(其实麻雀只能双脚跳,不能跑,这里不做深究)。
进一步的讨论表明,interface的粒度应足够小,或许包含一个method就够了,那么interface实际上退化成了给类型打的标签(tag)。在这种情况下,完全可以使用boost::function来代替(代替interface),比如:
class Penguin // 企鹅能游泳,也能跑
{
public:void run();void swim();
};class Sparrow // 麻雀能飞,也能跑
{
public:void fly();void run();
};// 以boost::function作为接口
typedef boost::function<void ()> FlyCallback;
typedef boost::function<void ()> RunCallback;
typedef boost::function<void ()> SwimCallback;// 一个既用到run,也用到fly的客户class
class Foo
{
public:Foo(FlyCallback flyCb, RunCallback runCb) : flyCb_(flyCb), runCb_(runCb){ }private:FlyCallback flyCb_;RunCallback runCb_;
};// 一个既用到run,也用到swim的客户class
class Bar
{
public:Bar(SwimCallback swimCb, RunCallback runCb) : swimCb_(swimCb), runCb_(runCb){ }private:SwimCallback swimCb_;RunCallback runCb_;
};int main()
{Sparrow s;Penguin p;// 装配起来,Foo要麻雀,Bar要企鹅Foo foo(bind(&Sparrow::fly, &s), bind(&Sparrow::run, &s));Bar bar(bind(&Penguin::swim, &p), bind(&Penguin::run, &p));
}
11.6 iostream的用途与局限
本节主要考虑x86 Linux平台,不考虑跨平台的可移植性,也不考虑国际化(i18n,即internationalization的简写,其中i和n分别表示开头和结尾的字符,18表示i和n之间的字符数,不包括i和n),但是要考虑32-bit和64-bit的兼容性。本节以stdio指代C语言的scanf/printf系列格式化输入输出函数。本节提及的“C语言”(包括库函数和线程安全性),指的是Linux下gcc+glibc这一套编译器和库的具体实现,也可以认为是符合POSIX.1-2001的实现。本节要注意区分“编程初学者”和“C++初学者”,二者含义不同。
C++ iostream的主要作用是让初学者有一个方便的命令行输入输出试验环境,在真实的项目中很少用到iostream,因此不必把精力花在深究iostream的格式化与manipulator(格式操作符,如setprecision(n)(设置浮点数输出的精度为n,即有效数字位数为n))上。iostream的设计初衷是提供一个可扩展的类型安全的IO机制,但是后来莫名其妙地加上了locale(一个特定地理区域的标识,它定义了一组语言、文化和地区的设置。它主要用于确定如何正确地显示日期、时间、货币和数字格式等信息)和facet(C++标准库中用于处理不同数据类型的特殊函数对象,它们用于为特定的数据类型提供定制的操作和行为。例如,std::num_get和std::num_put是用于处理数字的facet,std::time_get和std::time_put是用于处理时间的facet)等累赘。其整个设计复杂不堪,多重+虚拟继承的结构也很“巴洛克”(指风格华丽、复杂、夸张),性能方面几无亮点。iostream在实际项目中的用处非常有限,为此投入过多的学习精力实在不值。
11.6.1 stdio格式化输入输出的缺点
对编程初学者不友好
看看下面这段简单的输入输出代码,这是C语言教学的基本示例:
#include <stdio.h>int main()
{int i;short s;float f;double d;char name[80];scanf("%d %hd %f %lf %s", &i, &s, &f, &d, name);printf("%d %d %f %f %s\n", i, s, f, d, name);
}
注意到其中:
1.输入和输出用的格式字符串不一样。输入short要用%hd,输出用%d;输入double要用%lf,输出用%f。
2.输入的参数不统一。对于i、s、f、d等变量,在传入scanf()的时候要取地址(&);而对于字符数组name,则不用取地址。读者可以试一试如何用几句话向刚开始学编程的初学者解释上面两条背后的原因(涉及传递函数不定参数时的类型转换、函数调用栈的内存布局、指针的意义、字符数组退化为字符指针等等)。如果一开始解释不清,只好告诉初学者“这是规定”,弄得人一头雾水。
3.缓冲区溢出的危险。上面的例子在读入name的时候没有指定大小,这是用C语言编程的安全漏洞的主要来源。应该在一开始就强调正确的做法,避免养成错误的习惯。
正确而安全的做法如下所示(http:😕/www.stroustrup.com/new_learning.pdf):
int main()
{const int max_name = 80;char name[max_name];char fmt[10];sprintf(fmt, "%%%ds", max_name - 1);scanf(fmt, name);printf("%s\n", name);
}
这个动态构造格式化字符串的做法恐怕更难向初学者解释。
安全性(security)
C语言的安全性问题近十几年来引起了广泛的注意,C99增加了snprintf()等能够指定输出缓冲区大小的函数,输出方面的安全性问题已经得到解决;输入方面似乎没有太大进展,还要靠程序员自己动手。
考虑一个简单的编程任务:从文件或标准输入读入一行字符串,行的长度不确定。作者发现竟然没有哪个C语言标准库能完成这个任务,除非自己动手。
首先,gets()是错误的,因为它不能指定缓冲区的长度。
其次,fgets()也有问题。它能指定缓冲区的长度,所以是安全的。但是程序必须预设一个长度的最大值,这不满足题目要求“行的长度不确定”。另外,程序无法判断fgets()到底读了多少个字节。为什么?考虑一个文件的内容是9个字节的字符串“Chen\000Shuo”,注意中间出现了’\0’字符,如果用fgets()来读取,客户端如何知道“\000Shuo”也是输入的一部分?毕竟strlen()只返回4,而且整个字符串里没有’\n’字符。
最后,可以用glibc定义的getline(3)函数来读取不定长的“行”。这个函数能正确处理各种情况,不过它返回的是malloc()分配的内存,要求调用端自己free()。
类型安全(type-safety)
如果printf()的整数参数类型是int、long等内置类型,那么printf()的格式化字符串很容易写。但是如果参数类型是系统头文件里typedef的类型呢?
如果你想在程序中用printf()来打印日志,你能一眼看出下面这些类型该用“%d”、“%ld”、“%lld”中的哪一个来输出吗?你的选择是否同时兼容32-bit和64-bit平台?
1.clock_t。这是clock(3)的返回类型。clock()函数返回自程序开始执行到当前位置为止经过的时钟计时单元数,时钟计时单元的具体时间由操作系统和硬件决定,通常以毫秒或微秒为单位。我们可以前后调用两次clock来获取期间经过的时钟计时单元数,从而计算这段程序的执行时间,通常系统会定义CLOCK_PER_SEC常量,该常量的含义是每秒的时钟计时单元数,用来将时间单位从时钟计时单元转换为我们熟悉的秒。
2.dev_t。这是mknod(3)的参数类型。mknod函数用于创建文件节点,文件节点是文件系统中的一种数据结构,用于表示文件的相关信息和属性,mknod函数可用来创建普通文件、设备文件、管道文件、UNIX域套接字文件等。在实际开发中,一般会使用更高级的函数(如open、mkfifo函数等)来创建文件节点。当创建字符设备或块设备节点时,需要指定设备号作为参数,设备号的类型是dev_t。
3.in_addr_t、in_port_t。这是struct sockaddr_in的成员类型。sockaddr_in类型的sin_port成员的类型是in_port_t,用来表示端口号;sockaddr_in类型的sin_addr成员是一个struct in_addr类型,in_addr类型的s_addr成员的类型是in_addr_t,用来表示IP地址。使用时,这两个成员中的值都要转换为网络字节序。
4.nfds_t。这是poll(2)的参数类型。poll函数有一个参数,类型为指向struct pollfd结构体的指针,即一个struct pollfd数组,每个struct pollfd结构体描述了一个文件描述符和其要监视的事件。poll函数需要一个nfds_t类型的参数来描述传入的struct pollfd结构体数组中的元素数量。
4.off_t。这是lseek(2)的参数类型,麻烦的是,这个类型与宏定义_FILE_OFFSET_BITS有关。lseek函数用于在文件中定位读写的位置,定位位置时需要偏移量,偏移量的类型是off_t。在一些操作系统中,文件的大小可能超过off_t类型的表示范围,导致使用lseek函数时可能无法定位和设置文件指针,为了解决这个问题,引入了_FILE_OFFSET_BITS宏,可将该宏设为32或64,决定了使用lseek函数时的文件偏移量类型(即off_t的类型),默认,_FILE_OFFSET_BITS宏的值为32,即off_t类型为32位。我们可以手动在代码中定义_FILE_OFFSET_BITS宏的值为64,或在编译命令中添加选项-D_FILE_OFFSET_BITS=64。如果在代码中手动定义该宏,建议将宏定义放在所有头文件的包含之前,以确保该宏在所有相关的类型定义之前生效,该宏会影响off_t类型的定义,如果宏定义在包含头文件之后,可能导致已经包含的头文件中对off_t的定义不符合预期。
5.pid_t、uid_t、gid_t。这是getpid(2)/getuid(2)/getgid(2)的返回类型。
6.ptrdiff_t。ptrdiff_t是一种整数类型,表示指针之间的差值,printf()专门定义了“t”前缀来支持ptrdiff_t类型(即使用“%td”格式符打印)。
7.size_t、ssize_t。size_t是一种无符号整型数据类型,用于表示内存中对象的大小或内存块的长度。ssize_t与size_t类似,但ssize_t是有符号类型,可以用负数来表示出错情况。这两个类型到处都在用。printf()为此专门定义了“z”前缀来支持这两个类型(即使用“%zu”或“%zd”来打印)。
8.socklen_t。这是bind(2)和connect(2)的参数类型,用来表示套接字地址结构的长度。
9.time_t。time_t是用于表示UNIX时间戳的数据类型,这是time(2)(获取当前UNIX时间戳)的返回类型,也是gettimeofday(2)和clock_gettime(2)的结构体参数的成员类型。gettimeofday函数有一个timeval类型的指针参数,用来存储获取到的当前时间,timeval结构中有一个time_t类型的成员tv_sec,用来存储当前的UNIX时间戳的秒数。clock_gettime函数用来获取指定时钟的当前时间,它有一个clockid_t类型的参数用来指定时钟类型,如CLOCK_REALTIME表示的时钟是系统实时时间,即UNIX时间戳,获取到的时间存到它的另一个timespec指针类型的参数中,timespec类型有一个类型为time_t的tv_sec成员,用来存储获取到的时间的秒数。
如果在C程序里要正确打印以上类型的整数,恐怕要费一番脑筋。《The Linux Programming Interface》的作者建议(3.6.2节)先统一转换为long类型,再用“%ld”来打印;对于某些类型仍然需要特殊处理,比如off_t的类型可能是long long。
另外,int64_t在32-bit和64-bit平台上是不同的类型(可能在32位系统中是long long,在64位系统中是long),为此,如果程序要打印int64_t变量,需要包含<inttypes.h>头文件,并且使用PRId64宏(该宏用于格式化输出int64_t类型的整数,在64位系统中,PRId64宏的定义可能是"ld",即输出long类型):
#include <stdio.h>
#define __STDC_FORMAT_MACROS
#include <inttypes.h>int main()
{int64_t x = 100;printf("%" PRId64 "\n", x);printf("%06" PRId64 "\n", x); // 如果整数的位数小于6,则在左侧补0,如果大于等于6,则正常显示
}
muduo的Timestamp class使用了PRId64。Google C++编码规范也提到了64-bit兼容性。
这些问题在C++里都不存在,在这方面iostream是个进步。
C stdio在类型安全方面还有一个缺点,即格式化字符串与参数类型不匹配会造成难以发现的bug,不过现在的编译器已经能够检测很多这种错误(使用-Wall编译选项(启用所有的警告信息,包括一些可能导致程序错误或潜在问题的警告)):
int main()
{double d = 100.0;// warning: format '%d' expects type 'int', but argument 2 has type 'double'printf("%d\n", d);short s;// warning: format '%d' expects type 'int*', but argument 2 has type 'short int*'scanf("%d", &s);size_t sz = 1;// no warningprintf("zd\n", sz);
}
不可扩展
C stdio的另外一个缺点是无法支持自定义的类型,比如我写了一个Date class,我无法像打印int那样用printf()来直接打印Date对象。
struct Date
{int year, month, day;
};Date date;
printf("%D", &date); // WRONG,printf的格式化输出中也没有%D这个选项
glibc放宽了这个限制,允许用户调用register_printf_function(3)注册自己的类型。当然,前提是与现有的格式字符不冲突(这其实大大限制了这个功能的用处,现实中也几乎没有人真的去使用它)(http://www.gnu.org/s/hello/manual/libc/Printf-Extension-Example.html)(http://en.wikipedia.org/wiki/Printf#Custom_format_placeholders)。
性能
C stdio的性能方面有两个弱点:
1.使用一种little language(现在流行叫DSL(Domain Specific Language,特定领域语言,它是一种用于解决特定问题领域的编程语言,它的语法和语义都针对该特定领域进行了优化,使得在该领域内编写代码更加简洁和易于理解))来配置格式。这固然有利于紧凑性和灵活性,但损失了一点点效率。每次打印一个整数都要先解析“%d”字符串,大多数情况下这不是问题,某些场合(指的是对性能比较敏感的场合)则需要自己写整数到字符串的转换。
2.C locale的负担。locale指的是不同语种对“什么是空白”、“什么是字母”,“什么是小数点”有不同的定义(德语中小数点是逗号,不是句点)。C语言的printf()、scanf()、isspace()、isalpha()(用于判断一个字符是否是字母)、ispunct()(用于判断一个字符是否为标点符号)、strtod()(用于将字符串转换为双精度浮点数)等等函数都和locale有关,而且可以在运行时动态更改locale。就算是程序只使用默认的“C” locale,仍然要为这个灵活性付出代价。
11.6.2 iostream的设计初衷
iostream的设计初衷包括克服C stdio的缺点,提供一个高效的可扩展的类型安全的IO机制。“可扩展”有两层意思:一是可以扩展到用户自定义类型,二是通过继承iostream来定义自己的stream。本文把前一种称为“类型可扩展”,把后一种称为“功能可扩展”。
类型可扩展和类型安全
“类型可扩展”和“类型安全”都是通过函数重载来实现的。
iostream对初学者很友好,用iostream重写与前面同样功能的代码:
#include <iostream>
#include <string>
using namespace std;int main()
{int i;short s;float f;double d;string name;cin >> i >> s >> f >> d >> name;cout << i << " " << s << " " << f << " " << d << " " << name << endl;
}
这段代码恐怕比scanf/printf版本容易解释得多,而且没有安全性(security)方面的问题。
我们自己的类型也可以融入iostream,使用起来与built-in类型没有区别。这主要得力于C++可以定义non-member functions/operators。
#include <ostream> // 是不是太重量级了?class Date
{
public:Date(int year, int month, int day) : year_(year), month_(month), day_(day){ }void writeTo(std::ostream &os) const{os << year_ << '-' << month_ << '-' << day_;}private:int year_, month_, day_;
};std::ostream &operator<<(std::ostream &os, const Date &date)
{date.writeTo(os);return os;
}int main()
{Date date(2011, 4, 3);std::cout << date << std::endl;
}
iostream凭借这两点(类型安全和类型可扩展),基本克服了stdio在使用上的不便与不安全。如果iostream止步于此,那它将是一个非常便利的库,可惜它前进了另一步。
iostream的演变大致可分为三个阶段。第一阶段是Bjarne Stroustrup在CFront 1.0(C++的第一个编译器)里实现的streams库(http://www.softwarepreservation.org/projects/c_plus_plus/cfront/release_1.0/src/cfront/incl/stream.h/view)。这个库符合前述“类型安全、可扩展、高效”等特征,Bjarne发明了用移位操作符(<<和>>)做I/O的办法,istream和ostream都是具体类,也没有manipulator。第二阶段,Jerry Schwarz设计了“经典”iostream,在CFront 2.0中他的设计大部分得以体现。他发明了manipulator,实现手法是以函数指针参数来重载输入输出操作符(此处的函数指针指的是manipulator,如std::setw()等,具体实现以后用到了再查吧);他还采用多重继承和虚拟继承手法,设计了现在我们看到的ios菱形继承体系;此外,istream有了基类ios,也有了派生类ifstream和istrstream,ostream也是如此。第三阶段,在C++标准化的过程中,iostream有大幅更新,Nathan Myers设计了Locale/Facet体系,iostream被模板化以适应宽窄两种字符,以及以stringstream替换strstream等。
11.6.3 iostream与标准库其他组件的交互
“值语义”与“对象语义”
不同于标准库其他class的“值语义(value semntics)”,iostream是“对象语义(object semantics)”(对象语义在其他面向对象的语言里通常叫做“引用语义(reference semantics)”。为了避免与C++的“引用”类型冲突,作者这里用“对象语义”这个术语),即iostream是non-copyable。这是正确的,因为如果fstream代表一个打开的文件的话,拷贝一个fstream对象意味着什么呢?表示打开了两个文件吗?如果销毁一个fstream对象,它会关闭文件句柄,那么另一个fstream对象副本会因此受影响吗?
iostream禁止拷贝,利用对象的生命期来明确管理资源(如文件),很自然地就避免了这些问题。这就是RAII,一种重要且独特的C++编程手法。
C++同时支持“数据抽象(data abstraction)”和“面向对象编程(object-oriented)”,其实主要就是“值语义”与“对象语义”的区别,这是一个比较大的主题,见11.7。
std::string
iostream可以与std::stream配合得很好。但是有一个问题:谁依赖谁?
std::string的operator<<和operator>>是如何声明的?注意operator<<是个二元操作符,它的参数是std::ostream和std::string。<string>
头文件在声明这两个operator的时候要不要#include <iostream>
?
iostream和std::string都可以单独include来使用,显然iostream头文件里不会定义std::string的<<和>>操作。但是,如果<string>
要#include <iostream>
,岂不是让string的用户被迫也用了iostream?编译iostream头文件可是相当慢啊(因为iostream是template,其实现代码都放到了头文件中)。
标准库的解决办法是定义<iosfwd>
头文件,其中包含istream和ostream等的前向声明(forward declarations),这样<string>
头文件在定义输入输出操作符时就可以不必包含<iostream>
,只需要包含简短地多的<iosfwd>
,避免引入不必要的依赖。我们自己写程序也可借此学习如何支持可选的功能。
另外值得注意的是,istream::getline()成员函数(把读到的行存在C风格字符串中)的参数类型是char *,因为<istream>
没有包含<string>
,而我们常用的std::getline()函数(把读到的行存在string中)是个non-member function,定义在<string>
里边。
std::complex
标准库的复数类std::complex的情况比较复杂。<complex>
头文件会自动包含<sstream>
,后者会包含<istream>
和<ostream>
,这是个不小的负担。问题是,为什么这么实现?
它的operator>>操作比string复杂得多,如何应对格式不正确的情况?输入字符串不会遇到格式不正确,但是输入一个复数则可能遇到各种问题,比如数字的格式不对等。有谁会真的在产品项目里用operator>>来读入字符方式表示的复数,这样的代码的健壮性如何保证?基于同样的理由,作者认为产品代码中应该避免用istream来读取带格式的内容,后面也不再谈istream格式化输入的缺点,它已经落选。
它的operator<<也很奇怪,它不是直接使用参数ostream &os对象来输出,而是先构造ostringstream,输出到该string stream,再把结果字符串输出到ostream。简化后的代码如下:
template<typename T>
std::ostream &operator<<(std::ostream &os, const std::complex<T> &x)
{std::ostringstream s;s << '(' << x.real() << ',' << x.imag() << ')';return os << s.str();
}
注意到ostringstream会用到动态分配内存。也就是说,每输出一个complex对象就会分配一次内存,效率堪忧。
根据以上分析,作者认为iostream和complex配合得不好,但是它们耦合得更紧密(与string/iostream相比),这可能是个不得已的技术限制吧(complex是class template,其operator<<必须在头文件中定义(因为要在编译单元内具现化模板,如果不在头文件中定义模板,而在.c文件中定义模板,那会由于在链接期出现链接错误,此处假设所有用到此模板的用户代码都只包含了.h文件,要想不出现链接错误,需要在一个编译单元中或隐式或显示地具现化模板,但这要求在这个具现化模板的编译单元中具现化出所有其他编译单元里想要链接的具现化后的模板,这是不可能的,除非我们能知道其他编译单元里具体出现了哪些具现化,这也是一种控制能出现哪些具现化的手段,因为没有具现化的会在链接期报错),而这个定义又用到了ostringstream,不得已包含了sstream的实现)。
如果程序要对complex做IO,从效率和健壮性方面考虑,建议不要使用iostream。
11.6.4 iostream在使用方面的缺点
在简单使用iostream的时候,它确实比stdio方便,但是深入一点就会发现,二者可说各擅胜场。下面谈一谈iostream在使用方面的缺点。
格式化输出很繁琐
iostream采用manipulator来格式化,如果我想按照2010-04-03的格式输出前面定义的Date class,那么代码要改成:
由上图可见,每次设置完以上两个manipulator后只对下一次输出有效。
假如用stdio,会简短得多,因为printf采用了一种表达能力较强的小语言来描述输出格式。
实用小语言来描述格式还带来了另外一个好处:外部可配置。
外部可配置性
能不能用外部的配置文件来定义程序中日期的格式?在C stdio中很好办,把格式字符串“%d-%02d-%02d”保存到配置里就行。但是iostream呢?它的格式是写死在代码里的,灵活性大打折扣。
再举一个例子,程序的message的多语言化。
const char *name = "Shuo Chen";
int age = 29;
// 此处1$的含义是,取后面变量中的第1个,即name
printf("My name is %1$s, Iam %2$d years old.\n", name, age);
cout << "My name is " << name << ", I am " << age << "years old." << endl;
对于stdio,要让这段程序支持中文的话,把代码中的“My name is …”,替换为我叫%1$s,今年%2$d岁。\n
即可。也可以把这段提示语做成资源文件,在运行时读入。而对于iostream,恐怕没有这么方便,因为代码是支离破碎的。
C stdio的格式化字符串体现了重要的“数据就是代码”的思想,这种“数据”与“代码”之间的相互转换是程序灵活性的根源,远比OO更为灵活。
stream的状态
如果我想用十六进制方式输出一个整数x,那么可以用hex操控符,但是这会改变ostream的状态。比如说:
int x = 8888;
cout << hex << showbase << x << endl; // print 0x22b8
cout << 123 << endl; // print 0x7b
这段代码会把123也按照十六进制方式输出,这恐怕不是我们想要的。
再举一个例子,setprecision()也会造成持续影响:
double d = 123.45;
// 输出宽度为8,如果不足8个字符,则在左边补充空格,保留小数点后3位
printf("%8.3f\n", d);
cout << d << endl;
// 输出规则同printf,但此处的setw效果不会影响后续输出,而fixed和setprecision会影响
// setprecision默认会设置输出的有效数字位数,但有了fixed,setprecision变为设置小数点后保留几位,多余位数四舍五入
cout << setw(8) << fixed << setprecision(3) << d << endl;
cout << d << endl;
输出是:
可见代码中的setprecision()影响了后续输出的精度。注意setw()不会造成影响,它只对下一个输出有效。
这说明,如果使用manipulator来控制格式,需要时刻小心以防影响了后续代码;而使用C stdio就没有这个问题,它是“上下文无关的”。
知识的通用性
在C语言之外,有其他很多语言也支持printf()风格的格式化,例如Java、Perl、Ruby等等(http://en.wikipedia.org/wiki/Printf#Programming_languages_with_printf)。学会printf()的格式化方法,这个知识还可以用到其他语言中。但是C++ iostream“只此一家,别无分店”。反正都是格式化输出,学习stdio投资回报率更高。
基于这点考虑,作者认为不必深究iostream的格式化方法,只需要用好它最基本的类型安全输出即可。在真的需要格式化的场合,可以考虑snprintf()打印到栈上缓冲,再用ostream输出。
线程安全与原子性
iostream的另外一个问题是线程安全性。POSIX.1-2001明确要求stdio函数是线程安全的(http://www.kernel.org/doc/man-pages/online/pages/man7/pthreads.7.html),而且还提供了flockfile(3)/funlockfile(3)之类的函数来明确控制FILE *的加锁与解锁(如多线程下,我们希望一个线程内的一系列的stdio调用原子地执行,或单线程下要更高效地调用多个stdio函数(即单次加锁代替每个stdio函数内部加锁,此时可调用不加锁版本的stdio函数)时,就可使用flockfile函数)。
iostream在线程安全方面没有保证,就算单个operator<<是线程安全的,也不能保证原子性。因为cout << a << b;是两次函数调用,相当于cout.operator<<(a).operator<<(b)。两次调用中间可能会被打断进行上下文切换,造成输出内容不连续,插入了其他线程打印的字符。而fprintf(stdout, “%s %d”, a, b);是一次函数调用,而且是线程安全的,打印的内容不会受其他线程影响。因此,iostream并不适合在多线程程序中做logging。
iostream的局限
根据以上分析,我们可以归纳iostream的局限:
1.输入方面,istream不适合输入带格式的数据,因为“纠错”能力不强,进一步的分析请见孟岩写的《契约思想的一个反面案例》,孟岩说“复杂的设计必然带来复杂的使用规则,而面对复杂的使用规则,用户是可以投票的,那就是:你做你的,我不用!”可谓鞭辟入里。如果要用istream,作者推荐的做法是用std::getline()读入一行数据到std::string,然后用正则表达式来判断内容正误,并做分组,最后用strtod()/strtol()之类的函数做类型转换。这样似乎更容易写出健壮的程序。
2.输出方面,ostream的格式化输出非常烦琐,而且写死在代码里,不如stdio的小语言那么灵活通用。建议只用作简单的无格式输出。
3.log方面,由于ostream没有办法在多线程程序中保证一行输出的完整性,建议不要直接用它来写log。如果是简单的单线程程序,输出数据量较少的情况下可以酌情使用。产品代码应该用成熟的logging库,见第5章。
4.in-memory格式化方面,由于ostringstream会动态分配内存,它不适合性能要求较高的场合。
5.文件IO方面,如果用作文本文件的输入或输出,fstream有上述缺点(线程安全与原子性);如果用作二进制数据的输入输出,那么自己简单封装一个File class似乎更好用,也不必为用不到的功能付出代价(后文还有具体例子)。ifstream的一个用处是在程序启动时读入简单的文本配置文件。但如果配置文件是其他文本格式的(XML或JSON),那么用相应的库来读,也用不到ifstream。
6.性能方面,iostream没有兑现“高效性”诺言(在线ACM/ICPC(ACM是美国计算机协会,全称为Association for Computing Machinery;ICPC是国际大学生程序设计竞赛,全称为International Collegiate Programming Contest;ACM/ICPC是由ACM组织的年度性竞赛)判题网站上,如果一个简单的偏重IO的题目发生超时错误,那么把其中iostream的输入输出换成stdio,有时就能过关。另外可以先试试调用cin.sync_with_stdio(false);(在C++中,默认标准输入流cin和stdio是同步的,意味着它们会共享输入缓冲区,这样的设计可以方便使用cin和stdio的输入操作,但是在某些情况下会降低程序的性能,通过取消cin和stdio之间的同步,使得cin和stdio使用各自独立的输入缓冲区,从而提高程序的性能))。iostream在某些场合比stdio快,在某些场合比stdio慢,对于性能要求较高的场合,我们应该自己实现字符串转换(见后文的代码与测试)。
既然有这么多局限,iostream在实际项目中的应用就大为受限了,在这上面投入太多的精力实在不值得。说实话,作者没有见过哪个C++产品代码使用iostream来作为输入输出设施。Google的C++编程规范也对stream的使用做了明确的限制。
11.6.5 iostream在设计方面的缺点
iostream的设计有相当多的WTFs(http://www.osnews.com/story/19266/WTFs_m),stackoverflow有人抱怨说:“If you had to judge by today’s softwre engineering standards, would C++'s IOStream still be considered well-designed?(http://stackoverflow.com/questions/2753060/who-architected-designed-cs-iostream-and-would-it-still-be-considered-will)”
面向对象的设计
iostream是个面向对象的IO类库,本节简单介绍它的继承体系。对iostream略有过了解的人会知道它用了多重继承和虚拟继承,简单地画个类图如下(见图11-1),这是典型的菱形继承。
如果加深一点了解,会发现iostream现在是模板化的,同时支持窄字符和宽字符。图11-2是现在的继承体系,同时画出了fstream(s)和stringstream(s)。图11-2中方框的第二三行是模板的具现化类型,即我们代码里常用的具体类型(通过typedef定义)。这个继承体系糅合了面向对象与泛型编程,但可惜它两方面都不讨好。
再进一步加深了解,发现还有一个平行的streambuf继承体系(见图11-3),fstream和stringstream的主要区别在于使用了不同的streambuf派生类型。
再把这两个继承体系画到一幅图里,如图11-4所示。
注意到basic_ios持有了streambuf的指针;而fstream(s)和stringstream(s)则分别包含filebuf和stringbuf的对象。看上去有点像Bridge模式(一种结构型设计模式,它用于解决在多维度变化的情况下,用继承造成的类数量爆炸问题,如有一个咖啡基类,有两个变化维度,分别是咖啡类型和容量,如果咖啡类型有两种,分别是美式和拿铁,容量现在也有两种,分别是小杯和大杯,此时一种继承方式是派生出四个类,分别是小杯美式、大杯美式、小杯拿铁、大杯拿铁,如果以后某个变化维度再次增加,如容量维度新增了中杯,就会出现6个派生类,如果咖啡类型有n种,容量有m种,最终会有n×m个派生类。在bridge模式中,我们可以定义一个桥接类,再定义一个咖啡类型基类和容量基类,对于每种咖啡类型,都继承自咖啡基类,对于每种容量,都继承自容量基类,然后桥接类中保存咖啡类型基类和容量基类的指针或引用,桥接类在构造时需要传入一个咖啡类型派生类的指针或引用和一个容量派生类的指针或引用,然后在桥接类中处理咖啡的类型和容量,这样就把派生类的数量降低到了m+n)。
看了这样“巴洛克”的设计,有没有人还打算在自己的项目中通过继承iostream来实现自己的stream,以实现功能扩展呢?
面向对象方面的设计缺陷
本节我们分析一下iostream的设计违反了哪些OO准则。
我们知道,面向对象中的public继承需要满足Liskov替换原则(面向对象设计中的五个基本原则之一,也称为LSP(Liskov Substitution Principle),即子类对象能够替换父类对象并且程序逻辑不产生任何变化,更具体地说,如果S是T的子类,那么在所有T类型的对象出现的地方,都可以用S类型的对象替换而不会产生错误或违反程序逻辑),继承非为复用,乃为被复用(含义是继承不是为了复用父类的代码,而是为了复用用到该继承体系的代码,用到该继承体系的代码只需用父类的指针或引用就可以正常运行所有派生类)(《Effective C++中文版(第3版)》[EC3,条款32]:确保你的public继承模塑出is-a关系。《C++编程规范》[CCS,条款37]:public继承意味着可替换性)。在程序里需要用到ostream的地方(例如operator<<),传入ofstream或ostringstream都能按预期工作,这是OO继承强调的“可替换性”,派生类的对象可以替换基类对象,从而被客户端代码operator<<复用。
iostream的继承体系多次违反了Liskov原则,这些地方继承的目的是为了复用基类的代码,图11-5中作者把违规的继承关系用虚线标出。
在现有的继承体系中(见图11-5),合理的有(按英语语法,这里的is-a应该写作is-an,此处从简):
1.ifstream is-a istream。
2.istringstream is-a istream。
3.ofstream is-a ostream。
4.ostringstream is-a ostream。
5.fstream is-a iostream。
6.stringstream is-a iostream。
作者认为不怎么合理的有:
1.ios继承ios_base。有没有那种情况下函数期待ios_base对象,但是客户可以传入一个ios对象替代之?如果没有,这里用public继承是不是违反OO原则?
2.istream继承ios。有没有哪种情况下函数期待ios对象,但是客户可以传入一个istream对象替代之?如果没有,这里用public继承是不是违反OO原则?
3.ostream继承ios。有没有哪种情况下函数期待ios对象,但是客户可以传入一个ostream对象替代之?如果没有,这里用public继承是不是违反OO原则?
4.iostream多重继承istream和ostream。为什么iostream要同时继承两个non-interface class?这是接口继承还是实现继承?是不是可以用组合(composition)(指将多个不同的对象或组件结合在一起,创建一个新的对象或组件,这种方式可以通过将一个对象的实例作为另一个对象的成员来实现,或者通过在一个对象内部调用另一个对象的方法来实现,整体对象包含部分对象,并且整体对象对部分对象的生命周期有控制权,部分对象的存在依赖于整体对象的存在,是一种比较强的表示整体和部分之间的关联关系;而聚合(Aggregation)关系中,整体对象可以包含部分对象,但部分对象的生命周期不受整体对象的控制,聚合是一种比较弱的表示整体和部分之间的关联关系)来替代(见《Effective C++中文版(第3版)》[EC3,条款38]:通过组合模塑出has-a或“以某物实现”。《C++编程规范》[CSS,条款34]:尽可能以组合代替继承)?
用组合替代继承之后的体系如图11-6所示。
在上图这样的类图中,黑色菱形表示组合关系,白色菱形表示聚合关系,白色箭头表示继承关系。
注意到在新的设计中,只有真正的is-a关系采用了public继承,其他均以组合来代替,组合关系以黑色菱形箭头表示。新的设计没有使用虚拟继承或多重继承。
其中iostream的新实现值得一提,代码结构如下:
class istream;
class ostream;class iostream
{
public:istream &get_istream();ostream &get_ostream();virtual ~iostream();// ...
};
这样一来,在需要iostream对象表现得像istream的地方,调用get_istream()函数返回一个istream的引用;在需要iostream对象表现得像ostream的地方,调用get_ostream()函数返回一个ostream的引用。功能不受影响,而且代码更清晰,istream和ostream也不必使用虚拟继承了(作者非常怀疑iostream class的真正价值,一个东西即可读又可写,说明他是一个sophisticated IO对象(指具有高级功能和复杂性的输入/输出对象,这些对象通常提供了更多的功能和抽象,以方便处理不同类型的数据和更复杂的输入输出需求),为什么还用这么厚的OO封装?)。
阳春的(指阳春白雪,高深而不通俗)locale
iostream的故事还不止这些,它还包含一套阳春的locale/facet实现,这套实践中没人用的东西进一步增加了iostream的复杂度,而且不可避免地影响其性能。Nathan Myers正是其始作俑者。
ostream自身定义的针对整数和浮点数的operator<<成员函数的函数体是:
ostream &ostream::operator<<(int val) // 或double val
{// 使用num_put把数字格式化为字符串并写入输出流,num_put是一个locale facet// locale facet是与特定地区(locale)相关的本地化信息的组件// ostreambuf_iterator(*this)是输出流的迭代器,用于指定数据的写入位置// *this是输出流本身// fill()是填充字符,用于填充字段的空白位置// val是要写入的数// .failed()是检查put函数的返回值,看是否写入失败bool failed = use_facet<num_put>(getloc()).put(ostreambuf_iterator(*this), *this, fill(), val).failed();// ...
它会调用num_put::put(),后者会去调用num_put::do_put(),而do_put()是个虚函数,没办法inline。iostream在性能方面的不足恐怕部分来自于此。这个虚函数白白浪费了把template的实现放到头文件应得的好处,编译和运行速度都快不起来。这就是作者说iostream在泛型方面不讨好的原因。
作者没有深入挖掘其中的细节,感兴趣的读者可以移步观看facet的继承体系:http://gcc.gnu.org/onlinedocs/libstdc++/libstdc+±html-USERS-4.4/a00431.html。
据此分析,作者不认为以iostream为基础的上层程序库(比方说那些克服iostream格式化方面的缺点的库)有多大的实用价值。
臆造抽象
孟岩评价“iostream最大的缺点是臆造抽象”,作者非常赞同他的观点。
这个评价同样适用于Java那一套“叠床架屋”的InputStream、OutputStream、Reader、Writer继承体系,.NET也搞了这么一套繁文缛节。
乍看之下,用input stream表示一个可以“读”的数据流,用output stream表示一个可以“写”的数据流,屏蔽底层细节,面向接口编程,“符合面向对象原则”,似乎是一件美妙的事情。但是,真实的世界要残酷得多。
IO是个极度复杂的东西,就拿最常见的memory stream、file stream、socket stream来说,它们之间的差异极大:
1.是单向IO还是双向IO。只读或者只写?还是既可读又可写?
2.顺序访问还是随机访问。可不可以seek?可不可以退回n字节?
3.文本数据还是二进制数据。输入数据格式有误怎么办?如何编写健壮的处理输入的代码?
4.有无缓冲。write 500字节是否能保证完全写入?有没有可能只写入了300字节?余下200字节怎么办?
5.是否阻塞。会不会返回EWOULDBLOCK错误?
6.有哪些出错的情况。这是最难的,memory stream几乎不可能出错,file stream和socket stream的出错情况完全不同。socket stream可能遇到对方断开连接,file stream可能遇到超出磁盘配额。
根据以上列举的初步分析,作者不认为有办法设计一个公共的基类把各方面的情况都考虑周全。各种IO设施之间共性太小,差异太大,例外太多。如果硬要用面向对象来建模,基类要么太瘦(只放共性,这个基类包含的interface functions没多大用),要么太肥(把各种IO设施的特性都包含进来,这个基类包含的interface functions很多,但是不是每一个都能调用)。
一个基类设计得好,大家才愿意去继承它。比如Runnable是个很好的抽象,有不计其数的实现。InputStream/OutputStream(Java中的)好歹也有若干个实现(见图11-7)。反观istream/ostream(C++中的),只有标准库提供的两套默认实现,在项目中极少有人会去继承并扩展他,是不是说明istream/ostream这一套抽象不怎么好使呢?
当然,假如Java有C++那样强大的template机制,图11-7中的继承体系能简化不少。
若要在C语言里解决这个问题,通常的办法是用一个int表示IO对象(file或PIPE或socket)(作者的意思应该是类似文件描述符),然后配以read()/write()/lseek()/fcntl()等一系列全局函数,程序员自己搭配组合。这个做法作者认为比面向对象的方案要简洁高效。
iostream在性能方面没有比stdio高多少,在健壮性方面多半不如stdio,在灵活性方面受制于本身的复杂设计而难以让使用者自行扩展。目前看起来只适合一些简单的、要求不高的应用,但是又不得不为它的复杂设计付出运行时代价,总之,其定位有点不上不下。
在实际的项目中,我们可以提炼出一些简单高效的strip-down版本,在获得便利性的同时避免付出不必要的代价。
11.6.6 一个300行的memory buffer output stream
作者认为以operator<<来输出数据非常适合logging(见第5章),因此写了一个简单的muduo::LogStream class。代码不到300行,完全独立于iostream,位于muduo/base/LogStream.{h,cc}。
这个LogStream做到了类型安全和类型可扩展,效率也较高。它不支持定制格式化、不支持locale/facet、没有继承、buffer也没有继承与虚函数、没有动态分配内存、buffer大小固定。简单地说,适合logging以及简单的字符串转换。这基本上是Bjarne在1984年写的ostream的翻版。
LogStream的接口定义如下:
class Buffer;class LogStream : boost::noncopyable
{typedef LogStream self;
public:self &operator<<(bool);self &operator<<(short);self &operator<<(unsigned short);self &operator<<(int);self &operator<<(unsigned int);self &operator<<(long);self &operator<<(unsigned long);self &operator<<(long long);self &operator<<(unsigned long long);self &operator<<(const void *);self &operator<<(float);self &operator<<(double);// self &operator<<(long double);self &operator<<(char);// self &operator<<(signed char);// self &operator<<(unsigned char);self &operator<<(const char *);self &operator<<(const string &);void append(const char *data, int len);const Buffer &buffer() const { return buffer_; }void resetBuffer() { buffer_.reset(); }
private:Buffer buffer_;
};
LogStream本身不是线程安全的,它不适合做线程间的共享对象。正确的使用方式是每条log消息构造一个LogStream,用完就扔。LogStream的成本极低,这么做不会有什么性能损失。
整数到字符串的高效转换
muduo::LogStream的整数转换是自己写的,用的是Matthew Wilson的算法,见12.3 “带符号整数的除法与余数”。这个算法比stdio和iostream都要快。
浮点数到字符串的高效转换
目前muduo::LogStream的浮点数格式化采用的是snprintf()。所以从性能上与stdio持平,比ostream快一些。
浮点数到字符串的转换是个复杂的话题,这个领域20年以来没有什么进展(目前的实现大都基于David M. Gay在1990年的工作:《Correctly Rounded Binary-Decimal(二进制-十进制) and Decimal-Binary Conversion》,代码:http://netlib.org/fp/),直到2010年才有突破。
Florian Loitsch发明了新的更快的算法Grisu3,他的论文《Printing floating-point numbers quickly and accurately with integeers》发表在PLDI 2010,代码见Google V8引擎以及http://code.google.com/p/double-conversion/。有兴趣的读者可以阅读这篇博客(http://www.serpentine.com/blog/2011/06/29/here-be-dragons-advances-in-problems-you-didnt-even-know-you-had/)。
将来muduo::LogStream可能会改用Grisu3算法实现浮点数转换。
性能对比
由于muduo::LogStream抛掉了很多负担,因此可以预见它的性能好于ostringstream和stdio。作者做了一个简单的性能测试,结果如表11-1和表11-2所示。表11-1和表11-2中的数字是打印1000000次的用时,以毫秒为单位,越小越好。
从表11-1和表11-2看出,ostringstream有时候比snprintf()快,有时候比它慢,muduo::LogStream比他们两个都快得多(double类型除外)。
泛型编程
其他程序库如何使用LogStream作为输出呢?办法很简单,用模板。
前面我们定义了Date class针对std::ostream的operator<<,只要稍作修改就能同时适用于std::ostream和LosStream。而且Date的头文件不再需要#include <ostream>
,降低了耦合。
格式化
muduo::LogStream本身不支持格式化,不过我们很容易为它做扩展,定义一个简单的Fmt class就行,而且不影响stream的状态。
class Fmt : boost::noncopyable
{
public:template<typename T>Fmt(const char *fmt, T val){// BOOST_STATIC_ASSERT在编译时静态断言// boost::is_arithmetic用来检查类型T是否是算术类型(可进行算术操作的数据类型)// 算术类型包括整型(包括字符和布尔类型)和浮点型BOOST_STATIC_ASSERT(boost::is_arithmetic<T>::value == true);length_ = snprintf(buf_, sizeof buf_, fmt, val);}const char *data() const { return buf_; }int length() const { return length_; }private:char buf_[32];int length_;
};inline LogStream &operator<<(LogStream &os, const Fmt &fmt)
{os.append(fmt.data(), fmt.length());return os;
}
使用方法:
LogStream os;
double x = 19.82;
int y = 43;
os << Fmt("%8.3f", x) << Fmt("%4d", y);
11.6.7 现实的C++程序如何做文件IO
下面举三个例子,Google Protobuf Compiler(也称为protoc,可将.proto文件(Protobuf定义的数据结构)转换为特定编程语言的代码文件,即protoc是生成各种编程语言的序列化和反序列化代码的工具)、Google leveldb(一个高性能、持久化的键值存储库,它的核心思想是通过有序的键值对来存储数据,其中键和值都是任意字节序列,在LevelDB中,数据是按照键的字典序进行排序的,这使得我们可以非常快速地进行范围查找和遍历操作)、Kyoto Cabinet(一个开源的键值存储数据库,由日本电信公司NTT开发,它采用了哈希表和B+树的混合索引结构,具有快速的读取和写入性能)。
Google Protobuf Compiler
Google Protobuf是一种高效的网络传输格式,它用一种协议描述语言来定义消息格式,并且自动生成序列化代码。Protobuf Compiler是这种“协议描述语言”的编译器,它读入协议文件.proto,编译生成C++、Java、Python代码。proto文件是个文本文件,然而Protobuf Compiler并没有使用ifstream来读取它,而是使用了自己的FileInputStream来读取文件。
大致代码流程如下(https://github.com/protocolbuffers/protobuf):
1.ZeroCopyInputStream是一个抽象基类。
2.FileInputStream继承并实现了ZeroCopyInputStream。
3.Tokenizer是词法分析器,它把proto文件分解为一个个字元(token)。Tokenizer的构造函数以ZeroCopyInputStream为参数,从该stream读入文本。
4.Parser是语法分析器,它把proto文件解析为语法树,以FileDescriptorProto表示。Parser的构造函数以Tokenizer为参数从它读入字元。
由此可见,即便是读取文本文件,C++程序也不一定要用ifstream。
Google leveldb
Google leveldb是一个高效的持久化key-value db(https://github.com/google/leveldb)。它定义了三个精简的interface用于文件输入输出:
1.SequentialFile。
2.RandomAccessFile。
3.WritableFile。
接口函数如下:
struct Slice {const char *data_;size_t size_;
};// A file abstraction for reading sequentially through a file
class SequentialFile
{
public:SequentialFile() { }virtual ~SequentialFile();// 用=0定义纯虚函数,使SequentialFile成为抽象基类virtual Status Read(size_t n, Slice *result, char *scratch) = 0;virtual Status Skip(uint64_t n) = 0;
};// A file abstraction for randomly reading the contents of a file.
class RandomAccessFile
{
public:RandomAccessFile() { }virtual ~RandomAccessFile();virtual Status Read(uint64_t offset, size_t n, Slice *result, char *scratch) const = 0;
};// A file abstraction for sequential writing. The implementation
// must provide buffering since callers may append small fragments
// at a time to the file.(实现必须提供缓冲功能,因为调用者可能会一次性向文件中追加多个小片段)
class WritableFile
{
public:WritableFile() { }virtual ~WriteableFile();virtual Status Append(const Slice &data) = 0;virtual Status Close() = 0;virtual Status Flush() = 0;virtual Status Sync() = 0;
};
leveldb明确区分input和output,并进一步把input分为sequential和random access,然后提炼出了三个简单的接口,每个接口只有屈指可数的几个函数。这几个接口在各个平台下的实现也非常简单明了(https://github.com/google/leveldb),一看就懂。
注意这三个接口使用了虚函数,作者认为这是正当的,因为一次IO往往伴随着系统调用和context switch(切换进程、线程,等待IO完成),虚函数的开销比起context switch来可以忽略不计。相反,iostream每次operator<<()就调用虚函数,似乎不太明智。
Kyoto Cabinet
Kyoto Cabinet也是一个key-value db,是前几年流行的Tokyo Cabinet(由日本NTT实验室开发的一种高性能、轻量级的键值存储库,它支持多种存储引擎,包括哈希表、B+树和固定大小数组等。这使得它可以适应各种类型和规模的数据集)的升级版。它采用了与leveldb不同的文件抽象。KC定义了一个File class,同时包含了读写操作,这是一个fat interface。在具体实现方面,它没有使用虚函数,而是采用#ifdef来区分不同的平台,等于把两份独立的代码写到了同一个文件中。
相比之下,Google leveldb的做法更高明一些。
小结
在C++项目中,自己写个File class,把项目用到的文件IO功能简单封装一下(以RAII手法封装FILE *或者file descriptor都可以,视情况而定),通常就能满足需要。记得把拷贝构造和赋值操作符禁用,在析构函数里释放资源,避免泄露内部的handle,这样就能自动避免很多C语言文件操作的常见错误。
如果要用stream方式做logging,可以抛开繁重的iostream,自己写一个简单的LogStream,重载几个operator<<操作符,用起来一样方便;而且可以用stack buffer(函数调用栈的内存),轻松做到线程安全与高效,见第5章。
11.7 值语义与数据抽象
本文是11.6 “iostream的用途与局限”的后续,在11.6.3 “iostream与标准库其他组件的交互”中,作者简单提到了iostream对象和C++标准库中的其他对象(主要是容器和string)具有不同的语义,主要体现在iostream不能拷贝或赋值。下面具体谈一谈作者对这个问题的理解。
本文的“对象”定义较为宽泛:a region of memory that has a type,在这个定义下,int、double、bool变量都是对象。
11.7.1 什么是值语义
值语义(value semantics)指的是对象的拷贝与原对象无关(http://www.boost.org/doc/libs/1_51_0/doc/html/any/reference.html),就像拷贝int一样。C++的内置类型(bool/int/double/char)都是值语义,标准库里的complex<>、pair<>、vector<>、map<>、string等等类型也都是值语意,拷贝之后就与原对象脱离关系。Java语言的primitive types(基本类型)也是值语义。
与值语义对应的是“对象语义(object semantics)”,或者叫做引用语义(reference semantics),由于“引用”一词在C++里有特殊含义,所以作者在本文中使用“对象语义”这个术语。对象语义指的是面向对象意义下的对象,对象拷贝是禁止的。例如muduo里的Thread是对象语义,拷贝Thread是无意义的,也是被禁止的:因为Thread代表线程,拷贝一个Thread对象并不能让系统增加一个一模一样的线程。
同样的道理,拷贝一个Employee对象是没有意义的,一个雇员不会变成两个雇员,他也不会领两份薪水。拷贝TcpConnection对象也没有意义,系统中只有一个TCP连接,拷贝TcpConnection对象不会让我们拥有两个连接。Printer也是不能拷贝的,系统只连接了一个打印机,拷贝Printer并不能凭空增加打印机。凡此总总,面向对象意义下的“对象”是non-copyable。
Java中的class对象都是对象语义/引用语义。
ArrayList<Integer> a = new ArrayList<Integer>();
ArrayList<Integer> b = a;
那么a和b指向的是同一个ArrayList对象,修改a同时也会影响b。
值语义与immutable无关。Java有value object一说,按PoEAA 486(Patterns of Enterprise Application Architecture,即《模式语言》一书的486页)的定义,它实际上是immutable object,例如String、Integer、BigInteger、joda.time.DateTime等等(因为Java没有办法实现真正的值语义class,只好用immutable object来模拟)。尽管immutable object有其自身的用处,但不是本文的主题。muduo中的Date、Timestamp也都是immutable的。
C++中的值语义对象也可以是mutable,比如complex<>、pair<>、vector<>、map<>、string都是可以修改的。muduo的InetAddress和Buffer都具有值语义,它们都是可以修改的。muduo的InetAddress和Buffer都具有值语义,它们都是可以修改的。
值语义的对象不一定是POD,例如string就不是POD,但它是值语义的。
值语义的对象不一定小,例如vector<int>
的元素可多可少,但它始终是值语义的。当然,很多值语义的对象都是小的,例如complex<>、muduo::Date、muduo::Timestamp。