陈硕的cpp博客

来源:互联网 发布:vue ui框架 知乎 编辑:程序博客网 时间:2024/04/29 17:29
置顶随笔

[置顶]新书预告:《Linux C++ 多线程服务端编程》(暂定名)

看完了 W. Richard Stevens 的传世经典《UNIX 网络编程》, 能照着例子用 Sockets API 编写 echo 服务, 却仍然对稍微复杂一点的网络编程任务感到无从下手? 书中示例代码把业务逻辑和 Sockets 调用混在一起,似乎不利于将来扩展?
  • 程序在本机测试正常,放到网络运行上就经常出现数据收不全的情况?
  • TCP 协议真的有所谓的“粘包问题”吗?该如何设计打包拆包的协议?又该如何编码实现才不会掉到陷阱里?
  • 带外数据(OOB)、信号驱动IO这些高级特性到底有没有用?
  • 网络协议格式该怎么设计?发送 C struct 会有对齐方面的问题吗?对方不用 C/C++ 怎么通信? 将来服务端软件升级,需要在协议中增加一个字段,现有的客户端就必须强制升级?
  • 要处理几千上万的并发连接,似乎书上讲的传统 fork() 模型应付不过来,该用哪种并发模型呢? 试试 select、poll、epoll 这种 IO 复用模型吧,又感觉非阻塞IO陷阱重重,怎么程序的 CPU 使用率一直是100%?
  • 要不改用现成的 libevent 网络库吧,怎么查询一下数据库就把其他连接上的请求给耽误了? 再用个线程池吧。万一发回响应的时候对方已经断开连接了怎么办?会不会串话?
  • 读过《UNIX 环境高级编程》,想用多线程来发挥多核 CPU 的效率, 但对程序该用哪种多线程模型感到一头雾水? 有没有值得推荐的适用面广的多线程 IO 模型? 互斥器、条件变量、读写锁、信号量这些底层同步原语哪些该用哪些不该用? 有没有更高级的同步设施能简化开发? 《UNIX 网络编程(第二卷)》介绍的那些琳琅满目的IPC机制到底用哪个才能兼顾开发效率与可伸缩性?
网络编程和多线程编程的基础打得差不多,开始实际做项目了,更多问题扑面而来:
  • 网上听人说服务端开发要做到 7x24 运行,为了防止内存碎片连动态内存分配都不能用, 那岂不是连 C++ STL 也一并禁用了?硬件的可靠性高到值得去这么做吗?
  • 传闻服务端开发主要通过日志来查错,那么日志里该写些什么?日志是写给谁看的?怎样写日志才不会影响性能?
  • 分布式系统跟单机多进程到底有什么本质区别?心跳协议为什么是必须的,该如何实现?
  • C++ 的大型工程该如何管理?库的接口如何设计才能保证升级的时候不破坏二进制兼容性?

这本《Linux C++ 多线程服务端编程》中,作者凭借多年的工程实践经验试图解答以上疑问。当然,内容还远不止这些……


本书配套页面: http://chenshuo.com/book ,将不定期更新。

posted @ 2012-09-21 07:20 陈硕 阅读(1624) | 评论 (8) | 编辑 收藏

2012年9月21日

新书预告:《Linux C++ 多线程服务端编程》(暂定名)

看完了 W. Richard Stevens 的传世经典《UNIX 网络编程》, 能照着例子用 Sockets API 编写 echo 服务, 却仍然对稍微复杂一点的网络编程任务感到无从下手? 书中示例代码把业务逻辑和 Sockets 调用混在一起,似乎不利于将来扩展?
  • 程序在本机测试正常,放到网络运行上就经常出现数据收不全的情况?
  • TCP 协议真的有所谓的“粘包问题”吗?该如何设计打包拆包的协议?又该如何编码实现才不会掉到陷阱里?
  • 带外数据(OOB)、信号驱动IO这些高级特性到底有没有用?
  • 网络协议格式该怎么设计?发送 C struct 会有对齐方面的问题吗?对方不用 C/C++ 怎么通信? 将来服务端软件升级,需要在协议中增加一个字段,现有的客户端就必须强制升级?
  • 要处理几千上万的并发连接,似乎书上讲的传统 fork() 模型应付不过来,该用哪种并发模型呢? 试试 select、poll、epoll 这种 IO 复用模型吧,又感觉非阻塞IO陷阱重重,怎么程序的 CPU 使用率一直是100%?
  • 要不改用现成的 libevent 网络库吧,怎么查询一下数据库就把其他连接上的请求给耽误了? 再用个线程池吧。万一发回响应的时候对方已经断开连接了怎么办?会不会串话?
  • 读过《UNIX 环境高级编程》,想用多线程来发挥多核 CPU 的效率, 但对程序该用哪种多线程模型感到一头雾水? 有没有值得推荐的适用面广的多线程 IO 模型? 互斥器、条件变量、读写锁、信号量这些底层同步原语哪些该用哪些不该用? 有没有更高级的同步设施能简化开发? 《UNIX 网络编程(第二卷)》介绍的那些琳琅满目的IPC机制到底用哪个才能兼顾开发效率与可伸缩性?
网络编程和多线程编程的基础打得差不多,开始实际做项目了,更多问题扑面而来:
  • 网上听人说服务端开发要做到 7x24 运行,为了防止内存碎片连动态内存分配都不能用, 那岂不是连 C++ STL 也一并禁用了?硬件的可靠性高到值得去这么做吗?
  • 传闻服务端开发主要通过日志来查错,那么日志里该写些什么?日志是写给谁看的?怎样写日志才不会影响性能?
  • 分布式系统跟单机多进程到底有什么本质区别?心跳协议为什么是必须的,该如何实现?
  • C++ 的大型工程该如何管理?库的接口如何设计才能保证升级的时候不破坏二进制兼容性?

这本《Linux C++ 多线程服务端编程》中,作者凭借多年的工程实践经验试图解答以上疑问。当然,内容还远不止这些……


本书配套页面: http://chenshuo.com/book ,将不定期更新。

posted @ 2012-09-21 07:20 陈硕 阅读(1624) | 评论 (8) | 编辑 收藏

2012年7月6日

从《C++ Primer 第四版》入手学习 C++

《C++ Primer 第4版 评注版》即将出版,这是序言。PDF 版见:

https://github.com/downloads/chenshuo/documents/LearnCpp.pdf

从《C++ Primer 第四版》入手学习 C++

为什么要学习C++?

2009 年本书作者 Stan Lippman 先生来华参加上海祝成科技举办的C++技术大会,他表示人们现在还用C++的惟一理由是其性能。相比之下,Java/C#/Python等语言更加易学易用并且开发工具丰富,它们的开发效率都高于C++。但C++目前仍然是运行最快的语言[1],如果你的应用领域确实在乎这个性能,那么 C++ 是不二之选。

这里略举几个例子[2]。对于手持设备而言,提高运行效率意味着完成相同的任务需要更少的电能,从而延长设备的操作时间,增强用户体验。对于嵌入式[3]设备而言,提高运行效率意味着:实现相同的功能可以选用较低档的处理器和较少的存储器,降低单个设备的成本;如果设备销量大到一定的规模,可以弥补C++开发的成本。对于分布式系统而言,提高10%的性能就意味着节约10%的机器和能源。如果系统大到一定的规模(数千台服务器),值得用程序员的时间去换取机器的时间和数量,可以降低总体成本。另外,对于某些延迟敏感的应用(游戏[4],金融交易),通常不能容忍垃圾收集(GC)带来的不确定延时,而C++可以自动并精确地控制对象销毁和内存释放时机[5]。我曾经不止一次见到,出于性能原因,用C++重写现有的Java或C#程序。

C++之父Bjarne Stroustrup把C++定位于偏重系统编程(system programming) [6]的通用程序设计语言,开发信息基础架构(infrastructure)是C++的重要用途之一[7]。Herb Sutter总结道[8],C++注重运行效率(efficiency)、灵活性(flexibility)[9]和抽象能力(abstraction),并为此付出了生产力(productivity)方面的代价[10]。用本书作者的话来说,C++ is about efficient programming with abstractions。C++的核心价值在于能写出“运行效率不打折扣的抽象[11]”。

要想发挥C++的性能优势,程序员需要对语言本身及各种操作的代价有深入的了解[12],特别要避免不必要的对象创建[13]。例如下面这个函数如果漏写了&,功能还是正确的,但性能将会大打折扣。编译器和单元测试都无法帮我们查出此类错误,程序员自己在编码时须得小心在意。

inline int find_longest(const std::vector<std::string>& words)
{
  // std::max_element(words.begin(), words.end(), LengthCompare());
}

在现代CPU体系结构下,C++ 的性能优势很大程度上得益于对内存布局(memory layout )的精确控制,从而优化内存访问的局部性[14](locality of reference)并充分利用内存阶层(memory hierarchy)提速[15],这一点优势在近期内不会被基于GC的语言赶上[16]

C++的协作性不如C、Java、Python,开源项目也比这几个语言少得多,因此在TIOBE语言流行榜中节节下滑。但是据我所知,很多企业内部使用C++来构建自己的分布式系统基础架构,并且有替换Java开源实现的趋势。

学习C++只需要读一本大部头

C++不是特性(features)最丰富的语言,却是最复杂的语言,诸多语言特性相互干扰,使其复杂度成倍增加。鉴于其学习难度和知识点之间的关联性,恐怕不能用“粗粗看看语法,就撸起袖子开干,边查Google边学习[17]”这种方式来学习C++,那样很容易掉到陷阱里或养成坏的编程习惯。如果想成为专业C++开发者,全面而深入地了解这门复杂语言及其标准库,你需要一本系统而权威的书,这样的书必定会是一本八九百页的大部头[18]

兼具系统性和权威性[19]的C++教材有两本,C++之父Bjarne Stroustrup的代表作《The C++ Programming Language》和Stan Lippman的这本《C++ Primer》。侯捷先生评价道:“泰山北斗已现,又何必案牍劳形于墨瀚书海之中!这两本书都从C++盘古开天以来,一路改版,斩将擎旗,追奔逐北,成就一生荣光[20]。”

从实用的角度,这两本书读一本即可,因为它们覆盖的C++知识点相差无几。就我个人的阅读体验而言,Primer更易读一些,我十年前深入学习C++正是用的《C++ Primer第三版》。这次借评注的机会仔细阅读了《C++ Primer第四版》,感觉像在读一本完全不同的新书。第四版内容组织及文字表达比第三版进步很多[21],第三版可谓“事无巨细、面面俱到”,第四版重点突出详略得当,甚至篇幅也缩短了,这多半归功于新加盟的作者Barbara Moo。

《C++ Primer 第四版》讲什么?适合谁读?

这是一本C++语言的教程,不是编程教程。本书不讲八皇后问题、Huffman编码、汉诺塔、约瑟夫环、大整数运算等等经典编程例题,本书的例子和习题往往都跟C++本身直接相关。本书的主要内容是精解C++语法(syntax)与语意(semantics),并介绍C++标准库的大部分内容(含STL)。“这本书在全世界C++教学领域的突出和重要,已经无须我再赘言[22]。”

本书适合C++语言的初学者,但不适合编程初学者。换言之,这本书可以是你的第一本C++ 书,但恐怕不能作为第一本编程书。如果你不知道什么是变量、赋值、分支、条件、循环、函数,你需要一本更加初级的书[23],本书第1章可用作自测题。

如果你已经学过一门编程语言,并且打算成为专业C++开发者,从《C++ Primer 第四版》入手不会让你走弯路。值得特别说明的是,学习本书不需要事先具备C语言知识。相反,这本书教你编写真正的C++程序,而不是披着C++ 外衣的C程序。

《C++ Primer 第四版》的定位是语言教材,不是语言规格书,它并没有面面俱到地谈到C++的每一个角落,而是重点讲解C++程序员日常工作中真正有用的、必须掌握的语言设施和标准库[24]。本书的作者一点也不炫耀自己的知识和技巧,虽然他们有十足的资本[25]。这本书用语非常严谨(没有那些似是而非的比喻),用词平和,讲解细致,读起来并不枯燥。特别是如果你已经有一定的编程经验,在阅读时不妨思考如何用C++来更好地完成以往的编程任务。

尽管本书篇幅近900页,其内容还是十分紧凑,很多地方读一个句子就值得写一小段代码去验证。为了节省篇幅,本书经常修改前文代码中的一两行,来说明新的知识点,值得把每一行代码敲到机器中去验证。习题当然也不能轻易放过。

《C++ Primer 第四版》体现了现代C++教学与编程理念:在现成的高质量类库上构建自己的程序,而不是什么都从头自己写。这本书在第三章介绍了string和vector这两个常用的类,立刻就能写出很多有用的程序。但作者不是一次性把string的上百个成员函数一一列举,而是有选择地讲解了最常用的那几个函数。

《C++ Primer 第四版》的代码示例质量很高,不是那种随手写的玩具代码。第10.4.2节实现了带禁用词的单词计数,第10.6利用标准库容器简洁地实现了基于倒排索引思路的文本检索,第15.9节又用面向对象方法扩充了文本检索的功能,支持布尔查询。值得一提的是,这本书讲解继承和多态时举的例子符合Liskov替换原则,是正宗的面向对象。相反,某些教材以复用基类代码为目的,常以“人、学生、老师、教授”或“雇员、经理、销售、合同工”为例,这是误用了面向对象的“复用”。

《C++ Primer 第四版》出版于2005年,遵循2003年的C++语言标准[26]。C++新标准已于2011年定案(称为C++11),本书不涉及TR1[27]和C++11,这并不意味着这本书过时了[28]。相反,这本书里沉淀的都是当前广泛使用的C++编程实践,学习它可谓正当时。评注版也不会越俎代庖地介绍这些新内容,但是会指出哪些语言设施已在新标准中废弃,避免读者浪费精力。

《C++ Primer 第四版》是平台中立的,并不针对特定的编译器或操作系统。目前最主流的C++编译器有两个, GNU G++和微软Visual C++。实际上,这两个编译器阵营基本上“模塑[29]”了C++语言的行为。理论上讲, C++语言的行为是由C++标准规定的。但是 C++不像其他很多语言有“官方参考实现[30]”,因此C++的行为实际上是由语言标准、几大主流编译器、现有不计其数的C++产品代码共同确定的,三者相互制约。C++编译器不光要尽可能符合标准,同时也要遵循目标平台的成文或不成文规范和约定,例如高效地利用硬件资源、兼容操作系统提供的C语言接口等等。在C++标准没有明文规定的地方,C++编译器也不能随心所欲自由发挥。学习C++的要点之一是明白哪些行为是由标准保证的,哪些是由实现(软硬件平台和编译器)保证的[31],哪些是编译器自由实现,没有保证的;换言之,明白哪些程序行为是可依赖的。从学习的角度,我建议如果有条件不妨两个编译器都用[32],相互比照,避免把编译器和平台特定的行为误解为C++语言规定的行为。尽管不是每个人都需要写跨平台的代码,但也大可不必自我限定在编译器的某个特定版本,毕竟编译器是会升级的。

本着“练从难处练,用从易处用”的精神,我建议在命令行下编译运行本书的示例代码,并尽量少用调试器。另外,值得了解C++的编译链接模型[33],这样才能不被实际开发中遇到的编译错误或链接错误绊住手脚。(C++不像现代语言那样有完善的模块(module)和包(package)设施,它从C语言继承了头文件、源文件、库文件等古老的模块化机制,这套机制相对较为脆弱,需要花一定时间学习规范的做法,避免误用。)

就学习C++语言本身而言,我认为有几个练习非常值得一做。这不是“重复发明轮子”,而是必要的编程练习,帮助你熟悉掌握这门语言。是写一个复数类或者大整数类[34],实现基本的运算,熟悉封装与数据抽象。是写一个字符串类,熟悉内存管理与拷贝控制。是写一个简化的vector<T>类模板,熟悉基本的模板编程,你的这个vector应该能放入int和string等元素类型。是写一个表达式计算器,实现一个节点类的继承体系(右图),体会面向对象编程。前三个练习是写独立的值语义的类,第四个练习是对象语义,同时要考虑类与类之间的关系。

表达式计算器能把四则运算式3+2*4解析为左图的表达式树[35],对根节点调用calculate()虚函数就能算出表达式的值。做完之后还可以再扩充功能,比如支持三角函数和变量。


在写完面向对象版的表达式树之后,还可以略微尝试泛型编程。比如把类的继承体系简化为下图,然后用BinaryNode<std::plus<double> >和BinaryNode<std:: multiplies<double> >来具现化BinaryNode<T>类模板,通过控制模板参数的类型来实现不同的运算。


在表达式树这个例子中,节点对象是动态创建的,值得思考:如何才能安全地、不重不漏地释放内存。本书第15.8节的Handle可供参考。(C++的面向对象基础设施相对于现代的语言而言显得很简陋,现在C++也不再以“支持面向对象”为卖点了。)

C++难学吗?“能够靠读书看文章读代码做练习学会的东西没什么门槛,智力正常的人只要愿意花功夫,都不难达到(不错)的程度。[36]” C++好书很多,不过优秀的C++开源代码很少,而且风格迥异[37]。我这里按个人口味和经验列几个供读者参考阅读:Google的protobuf、leveldb、PCRE的C++ 封装,我自己写的muduo网络库。这些代码都不长,功能明确,阅读难度不大。如果有时间,还可以读一读Chromium中的基础库源码。在读Google开源的C++代码时要连注释一起细读。我不建议一开始就读STL或Boost的源码,因为编写通用C++模板库和编写C++应用程序的知识体系相差很大。 另外可以考虑读一些优秀的C或Java开源项目,并思考是否可以用C++更好地实现或封装之(特别是资源管理方面能否避免手动清理)。

继续前进

我能够随手列出十几本C++好书,但是从实用角度出发,这里只举两三本必读的书。读过《C++ Primer》和这几本书之后,想必读者已能自行识别C++图书的优劣,可以根据项目需要加以钻研。

第一本是《Effective C++ 第三版》[38]。学习语法是一回事,高效地运用这门语言是另一回事。C++是一个遍布陷阱的语言,吸取专家经验尤为重要,既能快速提高眼界,又能避免重蹈覆辙。《C++ Primer》加上这本书包含的C++知识足以应付日常应用程序开发。

我假定读者一定会阅读这本书,因此在评注中不引用《Effective C++ 第三版》的任何章节。

《Effective C++ 第三版》的内容也反映了C++用法的进步。第二版建议“总是让基类拥有虚析构函数”,第三版改为“为多态基类声明虚析构函数”。因为在C++中,“继承”不光只有面向对象这一种用途,即C++的继承不一定是为了覆写(override)基类的虚函数。第二版花了很多笔墨介绍浅拷贝与深拷贝,以及对指针成员变量的处理[39]。第三版则提议,对于多数class而言,要么直接禁用拷贝构造函数和赋值操作符,要么通过选用合适的成员变量类型[40],使得编译器默认生成的这两个成员函数就能正常工作。

什么是C++编程中最重要的编程技法(idiom)?我认为是“用对象来管理资源”,即RAII。资源包括动态分配的内存[41],也包括打开的文件、TCP网络连接、数据库连接、互斥锁等等。借助RAII,我们可以把资源管理和对象生命期管理等同起来,而对象生命期管理在现代C++里根本不是困难(见注5),只需要花几天时间熟悉几个智能指针[42]的基本用法即可。学会了这三招两式,现代的C++程序中可以完全不写delete,也不必为指针或内存错误操心。现代C++程序里出现资源和内存泄漏的惟一可能是循环引用,一旦发现,也很容易修正设计和代码。这方面的详细内容请参考《Effective C++ 第三版》第3章资源管理。

C++是目前惟一能实现自动化资源管理的语言,C语言完全靠手工释放资源,而其他基于垃圾收集的语言只能自动清理内存,而不能自动清理其他资源[43](网络连接,数据库连接等等)。

除了智能指针,TR1中的bind/function也十分值得投入精力去学一学[44]。让你从一个崭新的视角,重新审视类与类之间的关系。Stephan T. Lavavej有一套PPT介绍TR1的这几个主要部件[45]

第二本书,如果读者还是在校学生,已经学过数据结构课程[46],可以考虑读一读《泛型编程与STL》[47];如果已经工作,学完《C++ Primer》立刻就要参加C++项目开发,那么我推荐阅读《C++编程规范》[48]

泛型编程有一套自己的术语,如concept、model、refinement等等,理解这套术语才能阅读泛型程序库的文档。即便不掌握泛型编程作为一种程序设计方法,也要掌握C++中以泛型思维设计出来的标准容器库和算法库(STL)。坊间面向对象的书琳琅满目,学习机会也很多,而泛型编程只有这么一本,读之可以开拓视野,并且加深对STL的理解(特别是迭代器[49])和应用。

C++模板是一种强大的抽象手段,我不赞同每个人都把精力花在钻研艰深的模板语法和技巧。从实用角度,能在应用程序中写写简单的函数模板和类模板即可(以type traits为限),不是每个人都要去写公用的模板库。

由于C++语言过于庞大复杂,我见过的开发团队都对其剪裁使用[50]。往往团队越大,项目成立时间越早,剪裁得越厉害,也越接近C。制定一份好的编程规范相当不容易。规范定得太紧(比如定为团队成员知识能力的交集),程序员束手束脚,限制了生产力,对程序员个人发展也不利[51]。规范定得太松(定为团队成员知识能力的并集),项目内代码风格迥异,学习交流协作成本上升,恐怕对生产力也不利。由两位顶级专家合写的《C++编程规范》一书可谓是现代C++编程规范的范本。

《C++编程规范》同时也是专家经验一类的书,这本书篇幅比《Effective C++ 第三版》短小,条款数目却多了近一倍,可谓言简意赅。有的条款看了就明白,照做即可:

·         第1条,以高警告级别编译代码,确保编译器无警告。

·         第31条,避免写出依赖于函数实参求值顺序的代码。C++操作符的优先级、结合性与表达式的求值顺序是无关的。裘宗燕老师写的《C/C++ 语言中表达式的求值》[52]一文对此有明确的说明。

·         第35条,避免继承“并非设计作为基类使用”的class。

·         第43条,明智地使用pimpl。这是编写C++动态链接库的必备手法,可以最大限度地提高二进制兼容性。

·         第56条,尽量提供不会失败的swap()函数。有了swap()函数,我们在自定义赋值操作符时就不必检查自赋值了。

·         第59条,不要在头文件中或#include之前写using。

·         第73条,以by value方式抛出异常,以by reference方式捕捉异常。

·         第76条,优先考虑vector,其次再选择适当的容器。

·         第79条,容器内只可存放value和smart pointer。

有的条款则需要相当的设计与编码经验才能解其中三昧:

·         第5条,为每个物体(entity)分配一个内聚任务。

·         第6条,正确性、简单性、清晰性居首。

·         第8、9条,不要过早优化;不要过早劣化。

·         第22条,将依赖关系最小化。避免循环依赖。

·         第32条,搞清楚你写的是哪一种class。明白value class、base class、trait class、policy class、exception class各有其作用,写法也不尽相同。

·         第33条,尽可能写小型class,避免写出大怪兽。

·         第37条,public继承意味着可替换性。继承非为复用,乃为被复用。

·         第57条,将class类型及其非成员函数接口放入同一个namespace。

值得一提的是,《C++编程规范》是出发点,但不是一份终极规范。例如Google的C++编程规范[53]和LLVM编程规范[54]都明确禁用异常,这跟这本书的推荐做法正好相反。

评注版使用说明

评注版采用大开本印刷,在保留原书板式的前提下,对原书进行了重新分页,评注的文字与正文左右分栏并列排版。本书已依据原书2010年第11次印刷的版本进行了全面修订。为了节省篇幅,原书每章末尾的小结和术语表还有书末的索引都没有印在评注版中,而是做成PDF供读者下载,这也方便读者检索。评注的目的是帮助初次学习C++的读者快速深入掌握这门语言的核心知识,澄清一些概念、比较与其他语言的不同、补充实践中的注意事项等等。评注的内容约占全书篇幅的15%,大致比例是三分评、七分注,并有一些补白的内容[55]。如果读者拿不定主意是否购买,可以先翻一翻第5章。我在评注中不谈C++11[56],但会略微涉及TR1,因为TR1已经投入实用。

为了不打断读者阅读的思路,评注中不会给URL链接,评注中偶尔会引用《C++编程规范》的条款,以[CCS]标明,这些条款的标题已在前文列出。另外评注中出现的soXXXXXX表示http://stackoverflow.com/questions/XXXXXX 网址。

网上资源

代码下载:http://www.informit.com/store/product.aspx?isbn=0201721481
豆瓣页面:http://book.douban.com/subject/10944985/
术语表与索引PDF下载:http://chenshuo.com/cp4/
本文电子版发布于https://github.com/chenshuo/documents/downloads/LearnCpp.pdf,方便读者访问脚注中的网站。

我的联系方式:giantchen_AT_gmail.com                    http://weibo.com/giantchen

 

陈硕

2012年3月

中国·香港

 

评注者简介 :

陈硕,北京师范大学硕士,擅长 C++ 多线程网络编程和实时分布式系统架构。现任职于香港某跨国金融公司 IT 部门,从事实时外汇交易系统开发。编写了开源 C++ 网络库 muduo; 参与翻译了《代码大全(第二版)》和《C++ 编程规范(繁体版)》;2009 年在上海 C++ 技术大会做技术演讲《当析构函数遇到多线程》,同时担任 Stanley Lippman 先生的口译员;2010 年在珠三角技术沙龙做技术演讲《分布式系统的工程化开发方法》;2012年在“我们的开源项目”深圳站做《Muduo 网络库:现代非阻塞C++网络编程》演讲。

 



[1] 见编程语言性能对比网站 http://shootout.alioth.debian.org/ 和Google 员工写的语言性能对比论文

   https://days2011.scala-lang.org/sites/days2011/files/ws3-1-Hundt.pdf

[2] C++之父Bjarne Stroustrup维护的C++用户列表:http://www2.research.att.com/~bs/applications.html

[3] 初窥C++在嵌入式系统中的应用,请见http://aristeia.com/TalkNotes/MISRA_Day_2010.pdf

[4] Milo Yip在《C++强大背后》提到大部分游戏引擎(如Unreal/Source)及中间件(如Havok/FMOD)是C++实现的。http://www.cnblogs.com/miloyip/archive/2010/09/17/behind_cplusplus.html

[5] 孟岩《垃圾收集机制批判》:C++利用智能指针达成的效果是,一旦某对象不再被引用,系统刻不容缓,立刻回收内存。这通常发生在关键任务完成后的清理(clean up)时期,不会影响关键任务的实时性,同时,内存里所有的对象都是有用的,绝对没有垃圾空占内存。http://blog.csdn.net/myan/article/details/1906

[6] 有人半开玩笑地说“所谓系统编程,就是那些CPU时间比程序员的时间更重要的工作。”

[7] 《Software Development for Infrastructure》 http://www2.research.att.com/~bs/Computer-Jan12.pdf

[8] Herb Sutter在C++ and Beyond 2011会议上的开场演讲《Why C++?》
   http://channel9.msdn.com/posts/C-and-Beyond-2011-Herb-Sutter-Why-C

[9] 这里的灵活性指的是编译器不阻止你干你想干的事情,比如为了追求运行效率而实现即时编译(just-in-time compilation)。

[10] 我曾向Stan Lippman介绍目前我在Linux下的工作环境(编辑器、编译器、调试器),他表示这跟他在1970年代的工作环境相差无几,可见C++在开发工具方面的落后。另外C++的编译运行调试周期也比现代的语言长,这多少影响了工作效率。

[11] 可参考Ulrich Drepper在《Stop Underutilizing Your Computer》中举的SIMD例子。
   http://www.redhat.com/f/pdf/summit/udrepper_945_stop_underutilizing.pdf

[12]《Technical Report on C++ Performance》 http://www.open-std.org/jtc1/sc22/wg21/docs/18015.html

[13] 可参考Scott Meyers的《Effective C++ in an Embedded Environment》

   http://www.artima.com/shop/effective_cpp_in_an_embedded_environment

[14] 我们知道std::list的任一位置插入是O(1)操作,而std::vector的任一位置插入是O(N)操作,但由于std::vector的元素布局更加紧凑(compact),很多时候std::vector的随机插入性能甚至会高于std::list。见http://ecn.channel9.msdn.com/events/GoingNative12/GN12Cpp11Style.pdf 这也佐证std::vector是首选容器。

[15] 可参考Scott Meyers的技术报告《CPU Caches and Why You Care》和任何一本现代的计算机体系结构教材 http://aristeia.com/TalkNotes/ACCU2011_CPUCaches.pdf

[16] Bjarne Stroustrup有一篇论文《Abstraction and the C++ machine model》对比了C++和Java的对象内存布局。 http://www2.research.att.com/~bs/abstraction-and-machine.pdf

[17] 语出孟岩《快速掌握一个语言最常用的50%》 http://blog.csdn.net/myan/article/details/3144661

[18] 同样篇幅的Java/C#/Python教材可以从语言、标准库一路讲到多线程、网络编程、图形编程。

[19] “权威”的意思是说你不用担心作者讲错了,能达到这个水准的C++图书作者全世界也屈指可数。

[20] 侯捷《大道之行也——C++ Primer 3/e译序》 http://jjhou.boolan.com/cpp-primer-foreword.pdf

[21] Bjarne Stroustrup在《Programming--- Principles and Practice Using C++》的参考文献中引用了本书,并特别注明 use only the 4th edition.

[22] 侯捷《C++ Primer 4/e译序》

[23] 如果没有时间精读注21中提到的那本大部头,短小精干的《Accelerated C++》亦是上佳之选。另外如果想从C语言入手,我推荐裘宗燕老师的《从问题到程序:程序设计与C语言引论(第2版)》

[24] 本书把iostream的格式化输出放到附录,彻底不谈locale/facet,可谓匠心独运。

[25] Stan Lippman曾说:Virtual base class support wanders off into the Byzantine... The material is simply too esoteric to warrant discussion...

[26] 基本等同于1998年的初版C++标准,修正了编译器作者关心的一些问题,与普通程序员基本无关。

[27] TR1是2005年C++标准库的一次扩充,增加了智能指针、bind/function、哈希表、正则表达式等。

[28]作者正在编写《C++ Primer 第五版》,会包含C++11的内容。

[29] G++统治了Linux平台,并且能用在很多Unix平台上;Visual C++统治了Windows平台。其他C++编译器的行为通常要向它们靠拢,例如Intel C++在Linux上要兼容G++,而在Windows上要兼容Visual C++。

[30] 曾经是Cfront,本书作者正是其主要开发者。http://www.softwarepreservation.org/projects/c_plus_plus

[31] 包括C++标准有规定,但编译器拒绝遵循的。http://stackoverflow.com/questions/3931312/value-initialization-and-non-pod-types

[32] G++ 是免费的,可使用较新的4.x版,最好32-bit和64-bit一起用,因为服务端已经普及64位。微软也有免费的编译器,可考虑Visual C++ 2010 Express,建议不要用老掉牙的Visual C++ 6.0作为学习平台。

[33] 可参考陈硕写的《C++工程实践经验谈》中的“C++编译模型精要”一节。

[34] 大整数类可以以std::vector<int>为成员变量,避免手动资源管理。

[35] “解析”可以用数据结构课程介绍的逆波兰表达式方法,也可以用编译原理中介绍的递归下降法,还可以用专门的Packrat算法。可参考http://www.relisoft.com/book/lang/poly/3tree.html

[36]  孟岩《技术路线的选择重要但不具有决定性》 http://blog.csdn.net/myan/article/details/3247071

[37] 从代码风格上往往能判断项目成型的时代。

[38] Scott Meyers著,侯捷译,电子工业出版社。

[39] Andrew Koenig的《Teaching C++ Badly: Introduce Constructors and Destructors at the Same Time》

http://drdobbs.com/blogs/cpp/229500116

[40] 能自动管理资源的string、vector、shared_ptr等等,这样多数class连析构函数都不必写。

[41] “分配内存”包括在堆(heap)上创建对象。

[42] 包括TR1中的shared_ptr、weak_ptr,还有更简单的boost::scoped_ptr。

[43] Java 7有try-with-resources语句,Python有with语句,C#有using语句,可以自动清理栈上的资源,但对生命期大于局部作用域的资源无能为力,需要程序员手工管理。

[44] 孟岩《function/bind的救赎(上)》http://blog.csdn.net/myan/article/details/5928531

[45] http://blogs.msdn.com/b/vcblog/archive/2008/02/22/tr1-slide-decks.aspx

[46] 最好再学一点基础的离散数学。

[47] Matthew Austern著,侯捷译,中国电力出版社。

[48] Herb Sutter等著,刘基诚译,人民邮电出版社。(这本书繁体版由侯捷先生和我翻译。)

[49] 侯捷先生的《芝麻开门:从Iterator谈起》 http://jjhou.boolan.com/programmer-3-traits.pdf

[50] 孟岩《编程语言的层次观点——兼谈C++的剪裁方案》 http://blog.csdn.net/myan/article/details/1920

[51] 一个人通常不会在一个团队工作一辈子,其他团队可能有不同的C++剪裁使用方式,程序员要有“一桶水”的本事,才能应付不同形状大小的水碗。

[52] http://www.math.pku.edu.cn/teachers/qiuzy/technotes/expression2009.pdf

[53] http://google-styleguide.googlecode.com/svn/trunk/cppguide.xml#Exceptions

[54] http://llvm.org/docs/CodingStandards.html#ci_rtti_exceptions

[55] 第10章绘制了数据结构示意图,第11章补充lower_bound 和 upper_bound的示例。

[56] 从Scott Meyers的讲义可以快速学习C++11 http://www.artima.com/shop/overview_of_the_new_cpp

posted @ 2012-07-06 09:00 陈硕 阅读(1210) | 评论 (3) | 编辑 收藏

2012年7月1日

《Muduo 网络库:现代非阻塞C++网络编程》演讲

2012年6月30日下午将在深圳做《Muduo 网络库:现代非阻塞C++网络编程》演讲,

这是PPT:

http://www.slideshare.net/chenshuo/muduo-network-library

演讲视频:

http://v.youku.com/v_show/id_XNDIyNDc5MDMy.html

http://youtu.be/YDnCAs894Bg 


活动介绍:

http://ouropensource.51qiangzuo.com/

posted @ 2012-07-01 23:55 陈硕 阅读(1659) | 评论 (29) | 编辑 收藏

2012年6月6日

发布一个适合服务端C++程序的高效日志库

PPT 见 http://www.slideshare.net/chenshuo/efficient-logging-in-multithreaded-c-server/

2012年6月30日在深圳的简短演讲:

http://v.youku.com/v_show/id_XNDIyMjUwMDYw.html

http://www.youtube.com/watch?v=KM_eQ6uRYdU

 


代码位于 muduo 网络库中的 muduo/base

https://github.com/chenshuo/muduo

muduo 0.5.0 也包含了这个日志库 http://code.google.com/p/muduo/

posted @ 2012-06-06 21:27 陈硕 阅读(1558) | 评论 (5) | 编辑 收藏

2012年4月20日

C++ 工程实践(12):C++ 编译链接模型精要

《C++ 工程实践》新增第15节“C++ 编译链接模型精要”  


PDF 下载: https://github.com/downloads/chenshuo/documents/CppPractice.pdf  

posted @ 2012-04-20 08:18 陈硕 阅读(1343) | 评论 (1) | 编辑 收藏

2012年4月1日

C++ 工程实践(11):用 STL algorithm 秒杀几道算法面试题

《C++ 工程实践》新增第14节“用 STL algorithm 秒杀几道算法面试题” 


PDF 下载: https://github.com/downloads/chenshuo/documents/CppPractice.pdf 

posted @ 2012-04-01 10:08 陈硕 阅读(1644) | 评论 (0) | 编辑 收藏

2012年3月17日

C++ 工程实践(10):再探std::string

本文总结了std::string的三种常见实现方式。 


全文见 https://github.com/downloads/chenshuo/documents/CppPractice.pdf 第13节。 

posted @ 2012-03-17 16:58 陈硕 阅读(1419) | 评论 (3) | 编辑 收藏

2011年8月22日

C++ 工程实践(9):数据抽象

陈硕 (giantchen_AT_gmail)
http://blog.csdn.net/Solstice  http://weibo.com/giantchen
陈硕关于 C++ 工程实践的系列文章: http://blog.csdn.net/Solstice/category/802325.aspx
排版正常的版本: http://www.cnblogs.com/Solstice/category/287661.html
陈硕博客文章合集下载: http://blog.csdn.net/Solstice/archive/2011/02/24/6206154.aspx
本作品采用“Creative Commons 署名-非商业性使用-禁止演绎 3.0 Unported 许可协议(cc by-nc-nd)”进行许可。http://creativecommons.org/licenses/by-nc-nd/3.0/

前一篇文章谈了值语义,这篇文章谈一谈与之密切相关的数据抽象(data abstraction)。

文章结构:

  1. 什么是数据抽象?它与面向对象有何区别?
  2. 数据抽象所需的语言设施
  3. 数据抽象的例子

什么是数据抽象

数据抽象(data abstraction)是与面向对象(object-oriented)并列的一种编程范式(programming paradigm)。说“数据抽象”或许显得陌生,它的另外一个名字“抽象数据类型/abstract data type/ADT”想必如雷贯耳。

“支持数据抽象”一直是C++语言的设计目标,Bjarne Stroustrup 在他的《The C++ Programming Language》第二版(1991年出版)中写道[2nd]:

The C++ programming language is designed to

  • be a better C
  • support data abstraction
  • support object-oriented programming

这本书第三版(1997年出版)[3rd] 增加了一条:

C++ is a general-purpose programming language with a bias towards systems programming that

  • is a better C,
  • supports data abstraction,
  • supports object-oriented programming, and
  • supports generic programming.

在 http://www.softwarepreservation.org/projects/c_plus_plus/index.html#cfront 可以找到 C++ 的早期文献,其中有一篇 Bjarne Stroustrup 在 1984 年写的 《Data Abstraction in C++》http://www.softwarepreservation.org/projects/c_plus_plus/cfront/release_e/doc/DataAbstraction.pdf 。在这个页面还能找到 Bjarne 写的关于 C++ 操作符重载和复数运算的文章,作为数据抽象的详解与范例。可见 C++ 早期是以数据抽象为卖点的,支持数据抽象是C++相对于C的一大优势。

作为语言的设计者,Bjarne 把数据抽象作为C++的四个子语言之一。这个观点不是普遍接受的,比如作为语言的使用者,Scott Meyers 在《Effective C++ 第三版》中把 C++ 分为四个子语言:C、Object-Oriented C++、Template C++、STL。在 Scott Meyers 的分类法中,就没有出现数据抽象,而是归入了 object-oriented C++。

 

那么到底什么是数据抽象?

简单的说,数据抽象是用来描述数据结构的。数据抽象就是 ADT。一个 ADT 主要表现为它支持的一些操作,比方说 stack.push、stack.pop,这些操作应该具有明确的时间和空间复杂度。另外,一个 ADT 可以隐藏其实现细节,比方说 stack 既可以用动态数组实现,又可以用链表实现。

按照这个定义,数据抽象和基于对象(object-based)很像,那么它们的区别在哪里?语义不同。ADT 通常是值语义,而 object-based 是对象语言。(这两种语义的定义见前文《C++ 工程实践(8):值语义》)。ADT class 是可以拷贝的,拷贝之后的 instance 与原 instance 脱离关系。

比方说 stack a; a.push(10); stack b = a; b.pop(); 这时候 a 里仍然有元素 10。

 

C++ 标准库中的数据抽象

C++ 标准库里  complex<> 、pair<>、vector<>、list<>、map<>、set<>、string、stack、queue 都是数据抽象的例子。vector 是动态数组,它的主要操作有 push_back()、size()、begin()、end() 等等,这些操作不仅含义清晰,而且计算复杂度都是常数。类似的,list 是链表,map 是有序关联数组,set 是有序集合、stack 是 FILO 栈、queue是 FIFO 队列。“动态数组”、“链表”、“有序集合”、“关联数组”、“栈”、“队列”都是定义明确(操作、复杂度)的抽象数据类型。

 

数据抽象与面向对象的区别

本文把 data abstraction、object-based、object-oriented 视为三个编程范式。这种细致的分类或许有助于理解区分它们之间的差别。

庸俗地讲,面向对象(object-oriented)有三大特征:封装、继承、多态。而基于对象(object-based)则只有封装,没有继承和多态,即只有具体类,没有抽象接口。它们两个都是对象语义。

面向对象真正核心的思想是消息传递(messaging),“封装继承多态”只是表象。这一点孟岩 http://blog.csdn.net/myan/article/details/5928531 和王益http://cxwangyi.wordpress.com/2011/06/19/%E6%9D%82%E8%B0%88%E7%8E%B0%E4%BB%A3%E9%AB%98%E7%BA%A7%E7%BC%96%E7%A8%8B%E8%AF%AD%E8%A8%80/ 都有精彩的论述,陈硕不再赘言。

数据抽象与它们两个的界限在于“语义”,数据抽象不是对象语义,而是值语义。比方说 muduo 里的 TcpConnection 和 Buffer 都是具体类,但前者是基于对象的(object-based),而后者是数据抽象。

类似的,muduo::Date、muduo::Timestamp 都是数据抽象。尽管这两个 classes 简单到只有一个 int/long 数据成员,但是它们各自定义了一套操作(operation),并隐藏了内部数据,从而让它从 data aggregation 变成了 data abstraction。

数据抽象是针对“数据”的,这意味着 ADT class 应该可以拷贝,只要把数据复制一份就行了。如果一个 class 代表了其他资源(文件、员工、打印机、账号),那么它就是 object-based 或 object-oriented,而不是数据抽象。

ADT class 可以作为 Object-based/object-oriented class 的成员,但反过来不成立,因为这样一来 ADS class 的拷贝就失去意义了。

 

数据抽象所需的语言设施

不是每个语言都支持数据抽象,下面简要列出“数据抽象”所需的语言设施。

支持数据聚合

数据聚合 data aggregation,或者 value aggregates。即定义 C-style struct,把有关数据放到同一个 struct 里。FORTRAN77没有这个能力,FORTRAN77 无法实现 ADT。这种数据聚合 struct 是 ADT 的基础,struct List、struct HashTable 等能把链表和哈希表结构的数据放到一起,而不是用几个零散的变量来表示它。

全局函数与重载

例如我定义了 complex,那么我可以同时定义 complex sin(const complex& x); 和 complex exp(const complex& x); 等等全局函数来实现复数的三角函数和指数运算。sin 和 exp 不是 complex 的成员,而是全局函数 double sin(double) 和 double exp(double) 的重载。这样能让 double a = sin(b); 和 complex a = sin(b); 具有相同的代码形式,而不必写成 complex a = b.sin();。

C 语言可以定义全局函数,但是不能与已有的函数重名,也就没有重载。Java 没有全局函数,而且 Math class 是封闭的,并不能往其中添加 sin(Complex)。

成员函数与 private 数据

数据也可以声明为 private,防止外界意外修改。不是每个 ADT 都适合把数据声明为 private,例如 complex、point、pair<> 这样的 ADT 使用 public data 更加合理。

要能够在 struct 里定义操作,而不是只能用全局函数来操作 struct。比方说 vector 有 push_back() 操作,push_back 是 vector 的一部分,它必须直接修改 vector 的 private data members,因此无法定义为全局函数。

这两点其实就是定义 class,现在的语言都能直接支持,C 语言除外。

拷贝控制(copy control)

copy control 是拷贝 stack a; stack b = a; 和赋值 stack b; b = a; 的合称。

当拷贝一个 ADT 时会发生什么?比方说拷贝一个 stack,是不是应该把它的每个元素按值拷贝到新 stack?

如果语言支持显示控制对象的生命期(比方说C++的确定性析构),而 ADT 用到了动态分配的内存,那么 copy control 更为重要,不然如何防止访问已经失效的对象?

由于 C++ class 是值语义,copy control 是实现深拷贝的必要手段。而且 ADT 用到的资源只涉及动态分配的内存,所以深拷贝是可行的。相反,object-based 编程风格中的 class 往往代表某样真实的事物(Employee、Account、File 等等),深拷贝无意义。

C 语言没有 copy control,也没有办法防止拷贝,一切要靠程序员自己小心在意。FILE* 可以随意拷贝,但是只要关闭其中一个 copy,其他 copies 也都失效了,跟空悬指针一般。整个 C 语言对待资源(malloc 得到的内存,open() 打开的文件,socket() 打开的连接)都是这样,用整数或指针来代表(即“句柄”)。而整数和指针类型的“句柄”是可以随意拷贝的,很容易就造成重复释放、遗漏释放、使用已经释放的资源等等常见错误。这方面 C++ 是一个显著的进步,boost::noncopyable 是 boost 里最值得推广的库。

操作符重载

如果要写动态数组,我们希望能像使用内置数组一样使用它,比如支持下标操作。C++可以重载 operator[] 来做到这一点。

如果要写复数,我们系统能像使用内置的 double 一样使用它,比如支持加减乘除。C++ 可以重载 operator+ 等操作符来做到这一点。

如果要写日期时间,我们希望它能直接用大于小于号来比较先后,用 == 来判断是否相等。C++ 可以重载 operator< 等操作符来做到这一点。

这要求语言能重载成员与全局操作符。操作符重载是 C++ 与生俱来的特性,1984 年的 CFront E 就支持操作符重载,并且提供了一个 complex class,这个 class 与目前标准库的 complex<> 在使用上无区别。

如果没有操作符重载,那么用户定义的ADT与内置类型用起来就不一样(想想有的语言要区分 == 和 equals,代码写起来实在很累赘)。Java 里有 BigInteger,但是 BigInteger 用起来和普通 int/long 大不相同:

    public static BigInteger mean(BigInteger x, BigInteger y) {        BigInteger two = BigInteger.valueOf(2);        return x.add(y).divide(two);    }    public static long mean(long x, long y) {        return (x + y) / 2;    }

当然,操作符重载容易被滥用,因为这样显得很酷。我认为只在 ADT 表示一个“数值”的时候才适合重载加减乘除,其他情况下用具名函数为好,因此 muduo::Timestamp 只重载了关系操作符,没有重载加减操作符。另外一个理由见《C++ 工程实践(3):采用有利于版本管理的代码格式》。

效率无损

“抽象”不代表低效。在 C++ 中,提高抽象的层次并不会降低效率。不然的话,人们宁可在低层次上编程,而不愿使用更便利的抽象,数据抽象也就失去了市场。后面我们将看到一个具体的例子。

模板与泛型

如果我写了一个 int vector,那么我不想为 doule 和 string 再实现一遍同样的代码。我应该把 vector 写成 template,然后用不同的类型来具现化它,从而得到 vector<int>、vector<double>、vector<complex>、vector<string> 等等具体类型。

不是每个 ADT 都需要这种泛型能力,一个 Date class 就没必要让用户指定该用哪种类型的整数,int32_t 足够了。

 

根据上面的要求,不是每个面向对象语言都能原生支持数据抽象,也说明数据抽象不是面向对象的子集。

数据抽象的例子

下面我们看看数值模拟 N-body 问题的两个程序,前一个用 C 语言,后一个是 C++ 的。这个例子来自编程语言的性能对比网站 http://shootout.alioth.debian.org/gp4/benchmark.php?test=nbody&lang=all。

两个程序使用了相同的算法。

C 语言版,完整代码见 https://gist.github.com/1158889#file_nbody.c,下面是代码骨干。planet 保存与行星位置、速度、质量,位置和速度各有三个分量,程序模拟几大行星在三维空间中受引力支配的运动。

struct planet{  double x, y, z;  double vx, vy, vz;  double mass;};void advance(int nbodies, struct planet *bodies, double dt){  for (int i = 0; i < nbodies; i++)  {    struct planet *p1 = &(bodies[i]);    for (int j = i + 1; j < nbodies; j++)    {      struct planet *p2 = &(bodies[j]);      double dx = p1->x - p2->x;      double dy = p1->y - p2->y;      double dz = p1->z - p2->z;      double distance_squared = dx * dx + dy * dy + dz * dz;      double distance = sqrt(distance_squared);      double mag = dt / (distance * distance_squared);      p1->vx -= dx * p2->mass * mag;      p1->vy -= dy * p2->mass * mag;      p1->vz -= dz * p2->mass * mag;      p2->vx += dx * p1->mass * mag;      p2->vy += dy * p1->mass * mag;      p2->vz += dz * p1->mass * mag;    }  }  for (int i = 0; i < nbodies; i++)  {    struct planet * p = &(bodies[i]);    p->x += dt * p->vx;    p->y += dt * p->vy;    p->z += dt * p->vz;  }}

其中最核心的算法是 advance() 函数实现的数值积分,它根据各个星球之间的距离和引力,算出加速度,再修正速度,然后更新星球的位置。这个 naive 算法的复杂度是 O(N^2)。

C++ 数据抽象版,完整代码见 https://gist.github.com/1158889#file_nbody.cc,下面是代码骨架。

首先定义 Vector3 这个抽象,代表三维向量,它既可以是位置,有可以是速度。本处略去了 Vector3 的操作符重载,Vector3 支持常见的向量加减乘除运算。

然后定义 Planet 这个抽象,代表一个行星,它有两个 Vector3 成员:位置和速度。

需要说明的是,按照语义,Vector3 是数据抽象,而 Planet 是 object-based.

struct Vector3{  Vector3(double x, double y, double z)    : x(x), y(y), z(z)  {  }  double x;  double y;  double z;};struct Planet{  Planet(const Vector3& position, const Vector3& velocity, double mass)    : position(position), velocity(velocity), mass(mass)  {  }  Vector3 position;  Vector3 velocity;  const double mass;};

相同功能的 advance() 代码简短得多,而且更容易验证其正确性。(想想如果把 C 语言版的 advance() 中的 vx、vy、vz、dx、dy、dz 写错位了,这种错误较难发现。)

void advance(int nbodies, Planet* bodies, double delta_time){  for (Planet* p1 = bodies; p1 != bodies + nbodies; ++p1)  {    for (Planet* p2 = p1 + 1; p2 != bodies + nbodies; ++p2)    {      Vector3 difference = p1->position - p2->position;      double distance_squared = magnitude_squared(difference);      double distance = std::sqrt(distance_squared);      double magnitude = delta_time / (distance * distance_squared);      p1->velocity -= difference * p2->mass * magnitude;      p2->velocity += difference * p1->mass * magnitude;    }  }  for (Planet* p = bodies; p != bodies + nbodies; ++p)  {    p->position += delta_time * p->velocity;  }}

性能上,尽管 C++ 使用了更高层的抽象 Vector3,但它的性能和 C 语言一样快。看看 memory layout 就会明白:

C struct 的成员是连续存储的,struct 数组也是连续的。

value3

C++ 尽管定义了了 Vector3 这个抽象,它的内存布局并没有改变,Planet 的布局和 C planet 一模一样,Planet[] 的布局也和 C 数组一样。

另一方面,C++ 的 inline 函数在这里也起了巨大作用,我们可以放心地调用 Vector3::operator+=() 等操作符,编译器会生成和 C 一样高效的代码。

不是每个编程语言都能做到在提升抽象的时候不影响性能,来看看 Java 的内存布局。

如果我们用 class Vector3、class Planet、Planet[] 的方式写一个 Java 版的 N-body 程序,内存布局将会是:

value4

这样大大降低了 memory locality,有兴趣的读者可以对比 Java 和 C++ 的实现效率。

注:这里的 N-body 算法只为比较语言之间的性能与编程的便利性,真正科研中用到的 N-body 算法会使用更高级和底层的优化,复杂度是O(N log N),在大规模模拟时其运行速度也比本 naive 算法快得多。

更多的例子

  • Date 与 Timestamp,这两个 class 的“数据”都是整数,各定义了一套操作,用于表达日期与时间这两个概念。
  • BigInteger,它本身就是一个“数”。如果用 C++ 实现 BigInteger,那么阶乘函数写出来十分自然,下面第二个函数是 Java 语言的版本。
BigInteger factorial(int n){    BigInteger result(1);    for (int i = 1; i <= n; ++i) {        result *= i;    }    return result;}public static BigInteger factorial(int n) {    BigInteger result = BigInteger.ONE;    for (int i = 1; i <= n; ++i) {        result = result.multiply(BigInteger.valueOf(i));    }    return result;}

高精度运算库 gmp 有一套高质量的 C++ 封装 http://gmplib.org/manual/C_002b_002b-Interface-General.html#C_002b_002b-Interface-General

  • 图形学中的三维齐次坐标 Vector4 和对应的 4x4 变换矩阵 Matrix4,例如 http://www.ogre3d.org/docs/api/html/classOgre_1_1Matrix4.html
  • 金融领域中经常成对出现的“买入价/卖出价”,可以封装为 BidOffer struct,这个 struct 的成员可以有 mid() “中间价”,spread() “买卖差价”,加减操作符,等等。

小结

数据抽象是C++的重要抽象手段,适合封装“数据”,它的语义简单,容易使用。数据抽象能简化代码书写,减少偶然错误。

posted @ 2011-08-22 00:19 陈硕 阅读(1770) | 评论 (0) | 编辑 收藏

2011年8月16日

C++ 工程实践(8):值语义

陈硕 (giantchen_AT_gmail)
http://blog.csdn.net/Solstice  http://weibo.com/giantchen
陈硕关于 C++ 工程实践的系列文章: http://blog.csdn.net/Solstice/category/802325.aspx
排版正常的版本: http://www.cnblogs.com/Solstice/category/287661.html
陈硕博客文章合集下载: http://blog.csdn.net/Solstice/archive/2011/02/24/6206154.aspx
本作品采用“Creative Commons 署名-非商业性使用-禁止演绎 3.0 Unported 许可协议(cc by-nc-nd)”进行许可。http://creativecommons.org/licenses/by-nc-nd/3.0/

本文是前一篇《C++ 工程实践(7):iostream 的用途与局限》的后续,在这篇文章的“iostream 与标准库其他组件的交互”一节,我简单地提到iostream的对象和C++标准库中的其他对象(主要是容器和string)具有不同的语义,主要体现在iostream不能拷贝或赋值。今天全面谈一谈我对这个问题的理解。

本文的“对象”定义较为宽泛,a region of memory that has a type,在这个定义下,int、double、bool 变量都是对象。

什么是值语义

值语义(value sematics)指的是对象的拷贝与原对象无关,就像拷贝 int 一样。C++ 的内置类型(bool/int/double/char)都是值语义,标准库里的 complex<> 、pair<>、vector<>、map<>、string 等等类型也都是值语意,拷贝之后就与原对象脱离关系。Java 语言的 primitive types 也是值语义。

与值语义对应的是“对象语义/object sematics”,或者叫做引用语义(reference sematics),由于“引用”一词在 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)的定义,它实际上是 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 都具有值语义,它们都是可以修改的。

值语义的对象不一定是 POD,例如 string 就不是 POD,但它是值语义的。

值语义的对象不一定小,例如 vector<int> 的元素可多可少,但它始终是值语义的。当然,很多值语义的对象都是小的,例如complex<>、muduo::Date、muduo::Timestamp。

值语义与生命期

值语义的一个巨大好处是生命期管理很简单,就跟 int 一样——你不需要操心 int 的生命期。值语义的对象要么是 stack object,或者直接作为其他 object 的成员,因此我们不用担心它的生命期(一个函数使用自己stack上的对象,一个成员函数使用自己的数据成员对象)。相反,对象语义的 object 由于不能拷贝,我们只能通过指针或引用来使用它。

一旦使用指针和引用来操作对象,那么就要担心所指的对象是否已被释放,这一度是 C++ 程序 bug 的一大来源。此外,由于 C++ 只能通过指针或引用来获得多态性,那么在C++里从事基于继承和多态的面向对象编程有其本质的困难——资源管理。

考虑一个简单的对象建模——家长与子女:a Parent has a Child, a Child knows his/her Parent。在 Java 里边很好写,不用担心内存泄漏,也不用担心空悬指针:

public class Parent{    private Child myChild;}    public class Child{    private Parent myParent;}

只要正确初始化 myChild 和 myParent,那么 Java 程序员就不用担心出现访问错误。一个 handle 是否有效,只需要判断其是否 non null。

在 C++ 里边就要为资源管理费一番脑筋:Parent 和 Child 都代表的是真人,肯定是不能拷贝的,因此具有对象语义。Parent 是直接持有 Child 吗?抑或 Parent 和 Child 通过指针互指?Child 的生命期由 Parent 控制吗?如果还有 ParentClub 和 School 两个 class,分别代表家长俱乐部和学校:ParentClub has many Parent(s),School has many Child(ren),那么如何保证它们始终持有有效的 Parent 对象和 Child 对象?何时才能安全地释放 Parent 和 Child ?

直接但是易错的写法:

class Child;class Parent : boost::noncopyable{ private:  Child* myChild;};class Child : boost::noncopyable{ private:  Parent* myParent;};

如果直接使用指针作为成员,那么如何确保指针的有效性?如何防止出现空悬指针?Child 和 Parent 由谁负责释放?在释放某个 Parent 对象的时候,如何确保程序中没有指向它的指针?在释放某个 Child 对象的时候,如何确保程序中没有指向它的指针?

这一系列问题一度是C++面向对象编程头疼的问题,不过现在有了 smart pointer,我们可以借助 smart pointer 把对象语义转换为值语义,从而轻松解决对象生命期:让 Parent 持有 Child 的 smart pointer,同时让 Child 持有 Parent 的 smart pointer,这样始终引用对方的时候就不用担心出现空悬指针。当然,其中一个 smart pointer 应该是 weak reference,否则会出现循环引用,导致内存泄漏。到底哪一个是 weak reference,则取决于具体应用场景。

如果 Parent 拥有 Child,Child 的生命期由其 Parent 控制,Child 的生命期小于 Parent,那么代码就比较简单:

class Parent;class Child : boost::noncopyable{ public:  explicit Child(Parent* myParent_)    : myParent(myParent_)  {  } private:  Parent* myParent;};class Parent : boost::noncopyable{ public:  Parent()    : myChild(new Child(this))  {  } private:  boost::scoped_ptr<Child> myChild;};

在上面这个设计中,Child 的指针不能泄露给外界,否则仍然有可能出现空悬指针。

如果 Parent 与 Child 的生命期相互独立,就要麻烦一些:

class Parent;typedef boost::shared_ptr<Parent> ParentPtr;class Child : boost::noncopyable{ public:  explicit Child(const ParentPtr& myParent_)    : myParent(myParent_)  {  } private:  boost::weak_ptr<Parent> myParent;};typedef boost::shared_ptr<Child> ChildPtr;class Parent : public boost::enable_shared_from_this<Parent>,               private boost::noncopyable{ public:  Parent()  {  }  void addChild()  {    myChild.reset(new Child(shared_from_this()));  } private:  ChildPtr myChild;};int main(){  ParentPtr p(new Parent);  p->addChild();}

上面这个 shared_ptr+weak_ptr 的做法似乎有点小题大做。

考虑一个稍微复杂一点的对象模型:a Child has parents: mom and dad; a Parent has one or more Child(ren); a Parent knows his/her spouser. 这个对象模型用 Java 表述一点都不复杂,垃圾收集会帮我们搞定对象生命期。

public class Parent{    private Parent mySpouser;    private ArrayList<Child> myChildren;}public class Child{    private Parent myMom;    private Parent myDad;}

如果用 C++ 来实现,如何才能避免出现空悬指针,同时避免出现内存泄漏呢?借助 shared_ptr 把裸指针转换为值语义,我们就不用担心这两个问题了:

class Parent;typedef boost::shared_ptr<Parent> ParentPtr;class Child : boost::noncopyable{ public:  explicit Child(const ParentPtr& myMom_,                 const ParentPtr& myDad_)    : myMom(myMom_),      myDad(myDad_)  {  } private:  boost::weak_ptr<Parent> myMom;  boost::weak_ptr<Parent> myDad;};typedef boost::shared_ptr<Child> ChildPtr;class Parent : boost::noncopyable{ public:  Parent()  {  }  void setSpouser(const ParentPtr& spouser)  {    mySpouser = spouser;  }  void addChild(const ChildPtr& child)  {    myChildren.push_back(child);  } private:  boost::weak_ptr<Parent> mySpouser;  std::vector<ChildPtr> myChildren;};int main(){  ParentPtr mom(new Parent);  ParentPtr dad(new Parent);  mom->setSpouser(dad);  dad->setSpouser(mom);  {    ChildPtr child(new Child(mom, dad));    mom->addChild(child);    dad->addChild(child);  }  {    ChildPtr child(new Child(mom, dad));    mom->addChild(child);    dad->addChild(child);  }}

如果不使用 smart pointer,用 C++ 做面向对象编程将会困难重重。

值语义与标准库

C++ 要求凡是能放入标准容器的类型必须具有值语义。准确地说:type 必须是 SGIAssignable concept 的 model。但是,由 于C++ 编译器会为 class 默认提供 copy constructor 和 assignment operator,因此除非明确禁止,否则 class 总是可以作为标准库的元素类型——尽管程序可以编译通过,但是隐藏了资源管理方面的 bug。

因此,在写一个 class 的时候,先让它继承 boost::noncopyable,几乎总是正确的。

在现代 C++ 中,一般不需要自己编写 copy constructor 或 assignment operator,因为只要每个数据成员都具有值语义的话,编译器自动生成的 member-wise copying&assigning 就能正常工作;如果以 smart ptr 为成员来持有其他对象,那么就能自动启用或禁用 copying&assigning。例外:编写 HashMap 这类底层库时还是需要自己实现 copy control。

值语义与C++语言

C++ 的 class 本质上是值语义的,这才会出现 object slicing 这种语言独有的问题,也才会需要程序员注意 pass-by-value 和 pass-by-const-reference 的取舍。在其他面向对象编程语言中,这都不需要费脑筋。

值语义是C++语言的三大约束之一,C++ 的设计初衷是让用户定义的类型(class)能像内置类型(int)一样工作,具有同等的地位。为此C++做了以下设计(妥协):

  • class 的 layout 与 C struct 一样,没有额外的开销。定义一个“只包含一个 int 成员的 class ”的对象开销和定义一个 int 一样。
  • 甚至 class data member 都默认是 uninitialized,因为函数局部的 int 是 uninitialized。
  • class 可以在 stack 上创建,也可以在 heap 上创建。因为 int 可以是 stack variable。
  • class 的数组就是一个个 class 对象挨着,没有额外的 indirection。因为 int 数组就是这样。
  • 编译器会为 class 默认生成 copy constructor 和 assignment operator。其他语言没有 copy constructor 一说,也不允许重载 assignment operator。C++ 的对象默认是可以拷贝的,这是一个尴尬的特性。
  • 当 class type 传入函数时,默认是 make a copy (除非参数声明为 reference)。因为把 int 传入函数时是 make a copy。
  • 当函数返回一个 class type 时,只能通过 make a copy(C++ 不得不定义 RVO 来解决性能问题)。因为函数返回 int 时是 make a copy。
  • 以 class type 为成员时,数据成员是嵌入的。例如 pair<complex<double>, size_t> 的 layout 就是 complex<double> 挨着 size_t。

这些设计带来了性能上的好处,原因是 memory locality。比方说我们在 C++ 里定义 complex<double> class,array of complex<double>, vector<complex<double> >,它们的 layout 分别是:(re 和 im 分别是复数的实部和虚部。)

value1

而如果我们在 Java 里干同样的事情,layout 大不一样,memory locality 也差很多:

value2

Java 里边每个 object 都有 header,至少有两个 word 的开销。对比 Java 和 C++,可见 C++ 的对象模型要紧凑得多。

待续

下一篇文章我会谈与值语义紧密相关的数据抽象(data abstraction),解释为什么它是与面向对象并列的一种编程范式,为什么支持面向对象的编程语言不一定支持数据抽象。C++在最初的时候是以 data abstraction 为卖点,不过随着时间的流逝,现在似乎很多人只知 Object-Oriented,不知 data abstraction 了。C++ 的强大之处在于“抽象”不以性能损失为代价,下一篇文章我们将看到具体例子。

posted @ 2011-08-16 21:13 陈硕 阅读(1545) | 评论 (4) | 编辑 收藏

2011年7月17日

C++ 工程实践(7):iostream 的用途与局限

陈硕 (giantchen_AT_gmail)

http://blog.csdn.net/Solstice  http://weibo.com/giantchen

陈硕关于 C++ 工程实践的系列文章: http://blog.csdn.net/Solstice/category/802325.aspx

陈硕博客文章合集下载: http://blog.csdn.net/Solstice/archive/2011/02/24/6206154.aspx

本作品采用“Creative Commons 署名-非商业性使用-禁止演绎 3.0 Unported 许可协议(cc by-nc-nd)”进行许可。http://creativecommons.org/licenses/by-nc-nd/3.0/

本文主要考虑 x86 Linux 平台,不考虑跨平台的可移植性,也不考虑国际化(i18n),但是要考虑 32-bit 和 64-bit 的兼容性。本文以 stdio 指代 C 语言的 scanf/printf 系列格式化输入输出函数。本文注意区分“编程初学者”和“C++初学者”,二者含义不同。

摘要:C++ iostream 的主要作用是让初学者有一个方便的命令行输入输出试验环境,在真实的项目中很少用到 iostream,因此不必把精力花在深究 iostream 的格式化与 manipulator。iostream 的设计初衷是提供一个可扩展的类型安全的 IO 机制,但是后来莫名其妙地加入了 locale 和 facet 等累赘。其整个设计复杂不堪,多重+虚拟继承的结构也很巴洛克,性能方面几无亮点。iostream 在实际项目中的用处非常有限,为此投入过多学习精力实在不值。

stdio 格式化输入输出的缺点

1. 对编程初学者不友好

看看下面这段简单的输入输出代码。

#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", i, s, f, d, name);}

注意到其中

  • 输入和输出用的格式字符串不一样。输入 short 要用 %hd,输出用 %d;输入 double 要用 %lf,输出用 %f。
  • 输入的参数不统一。对于 i、s、f、d 等变量,在传入 scanf() 的时候要取地址(&),而对于 name,则不用取地址。

读者可以试一试如何用几句话向刚开始学编程的初学者解释上面两条背后原因(涉及到传递函数不定参数时的类型转换,函数调用栈的内存布局,指针的意义,字符数组退化为字符指针等等),如果一开始解释不清,只好告诉学生“这是规定”。

  • 缓冲区溢出的危险。上面的例子在读入 name 的时候没有指定大小,这是用 C 语言编程的安全漏洞的主要来源。应该在一开始就强调正确的做法,避免养成错误的习惯。正确而安全的做法如 Bjarne Stroustrup 在《Learning Standard C++ as a New Language》所示:
#include <stdio.h>int main(){  const int max = 80;  char name[max];  char fmt[10];  sprintf(fmt, "%%%ds", max - 1);  scanf(fmt, name);  printf("%s\n", name);}

这个动态构造格式化字符串的做法恐怕更难向初学者解释。

2. 安全性(security)

C 语言的安全性问题近十几年来引起了广泛的注意,C99 增加了 snprintf() 等能够指定输出缓冲区大小的函数,输出方面的安全性问题已经得到解决;输入方面似乎没有太大进展,还要靠程序员自己动手。

考虑一个简单的编程任务:从文件或标准输入读入一行字符串,行的长度不确定。我发现没有哪个 C 语言标准库函数能完成这个任务,除非 roll your own。

首先,gets() 是错误的,因为不能指定缓冲区的长度。

其次,fgets() 也有问题。它能指定缓冲区的长度,所以是安全的。但是程序必须预设一个长度的最大值,这不满足题目要求“行的长度不确定”。另外,程序无法判断 fgets() 到底读了多少个字节。为什么?考虑一个文件的内容是 9 个字节的字符串 "Chen\000Shuo",注意中间出现了 '\0' 字符,如果用 fgets() 来读取,客户端如何知道 "\000Shuo" 也是输入的一部分?毕竟 strlen() 只返回 4,而且整个字符串里没有 '\n' 字符。

最后,可以用 glibc 定义的 getline(3) 函数来读取不定长的“行”。这个函数能正确处理各种情况,不过它返回的是 malloc() 分配的内存,要求调用端自己 free()。

3. 类型安全(type-safe)

如果 printf() 的整数参数类型是 int、long 等标准类型, 那么 printf() 的格式化字符串很容易写。但是如果参数类型是 typedef 的类型呢?

如果你想在程序中用 printf 来打印日志,你能一眼看出下面这些类型该用 "%d" "%ld" "%lld" 中的哪一个来输出?你的选择是否同时兼容 32-bit 和 64-bit 平台?

  • clock_t。这是 clock(3) 的返回类型
  • dev_t。这是 mknod(3) 的参数类型
  • in_addr_t、in_port_t。这是 struct sockaddr_in 的成员类型
  • nfds_t。这是 poll(2) 的参数类型
  • off_t。这是 lseek(2) 的参数类型,麻烦的是,这个类型与宏定义 _FILE_OFFSET_BITS 有关。
  • pid_t、uid_t、gid_t。这是 getpid(2) getuid(2) getgid(2) 的返回类型
  • ptrdiff_t。printf() 专门定义了 "t" 前缀来支持这一类型(即使用 "%td" 来打印)。
  • size_t、ssize_t。这两个类型到处都在用。printf() 为此专门定义了 "z" 前缀来支持这两个类型(即使用 "%zu" 或 "%zd" 来打印)。
  • socklen_t。这是 bind(2) 和 connect(2) 的参数类型
  • time_t。这是 time(2) 的返回类型,也是 gettimeofday(2) 和 clock_gettime(2) 的输出结构体的成员类型

如果在 C 程序里要正确打印以上类型的整数,恐怕要费一番脑筋。《The Linux Programming Interface》的作者建议(3.6.2节)先统一转换为 long 类型再用 "%ld" 来打印;对于某些类型仍然需要特殊处理,比如 off_t 的类型可能是 long long。

还有,int64_t 在 32-bit 和 64-bit 平台上是不同的类型,为此,如果程序要打印 int64_t 变量,需要包含 <inttypes.h> 头文件,并且使用 PRId64 宏:

#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);}

muduo 的 Timestamp 使用了 PRId64 http://code.google.com/p/muduo/source/browse/trunk/muduo/base/Timestamp.cc#25

Google C++ 编码规范也提到了 64-bit 兼容性: http://google-styleguide.googlecode.com/svn/trunk/cppguide.xml#64-bit_Portability

这些问题在 C++ 里都不存在,在这方面 iostream 是个进步。

C stdio 在类型安全方面原本还有一个缺点,即格式化字符串与参数类型不匹配会造成难以发现的 bug,不过现在的编译器已经能够检测很多这种错误:

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 warning  printf("%zd\n", sz);}

4. 不可扩展?

C stdio 的另外一个缺点是无法支持自定义的类型,比如我写了一个 Date class,我无法像打印 int 那样用 printf 来直接打印 Date 对象。

struct Date{  int year, month, day;};Date date;printf("%D", &date);  // WRONG

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

5. 性能

C stdio 的性能方面有两个弱点。

  1. 使用一种 little language (现在流行叫 DSL)来配置格式。固然有利于紧凑性和灵活性,但损失了一点点效率。每次打印一个整数都要先解析 "%d" 字符串,大多数情况下不是问题,某些场合需要自己写整数到字符串的转换。
  2. C locale 的负担。locale 指的是不同语种对“什么是空白”、“什么是字母”,“什么是小数点”有不同的定义(德语里边小数点是逗号,不是句点)。C 语言的 printf()、scanf()、isspace()、isalpha()、ispunct()、strtod() 等等函数都和 locale 有关,而且可以在运行时动态更改。就算是程序只使用默认的 "C" locale,任然要为这个灵活性付出代价。

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;  // 输出 2011-4-3}

iostream 凭借这两点(类型安全和类型可扩展),基本克服了 stdio 在使用上的不便与不安全。如果 iostream 止步于此,那它将是一个非常便利的库,可惜它前进了另外一步。

iostream 与标准库其他组件的交互

不同于标准库其他 class 的“值语意”,iostream 是“对象语意”,即 iostream 是 non-copyable。这是正确的,因为如果 fstream 代表一个文件的话,拷贝一个 fstream 对象意味着什么呢?表示打开了两个文件吗?如果销毁一个 fstream 对象,它会关闭文件句柄,那么另一个 fstream copy 对象会因此受影响吗?

C++ 同时支持“数据抽象”和“面向对象编程”,其实主要就是“值语意”与“对象语意”的区别,我发现不是每个人都清楚这一点,这里多说几句。标准库里的 complex<> 、pair<>、vector<>、 string 等等都是值语意,拷贝之后就与原对象脱离关系,就跟拷贝一个 int 一样。而我们自己写的 Employee class、TcpConnection class 通常是对象语意,拷贝一个 Employee 对象是没有意义的,一个雇员不会变成两个雇员,他也不会领两份薪水。拷贝 TcpConnection 对象也没有意义,系统里边只有一个 TCP 连接,拷贝 TcpConnection  对象不会让我们拥有两个连接。因此如果在 C++ 里做面向对象编程,写的 class 通常应该禁用 copy constructor 和 assignment operator,比如可以继承 boost::noncopyable。对象语意的类型不能直接作为标准容器库的成员。另一方面,如果要写一个图形程序,其中用到三维空间的向量,那么我们可以写 Vector3D class,它应该是值语意的,允许拷贝,并且可以用作标准容器库的成员,例如 vector<Vector3D> 表示一条三维的折线。

C stdio 的另外一个缺点是 FILE* 可以随意拷贝,但是只要关闭其中一个 copy,其他 copies 也都失效了,跟空悬指针一般。这其实不光是 C stdio 的缺点,整个 C 语言对待资源(malloc 得到的内存,open() 打开的文件,socket() 打开的连接)都是这样,用整数或指针来代表(即“句柄”)。而整数和指针类型的“句柄”是可以随意拷贝的,很容易就造成重复释放、遗漏释放、使用已经释放的资源等等常见错误。这是因为 C 语言错误地让“对象语言”的东西变成了值语意。

iostream 禁止拷贝,利用对象的生命期来明确管理资源(如文件),很自然地就避免了 C 语言易犯的错误。这就是 RAII,一种重要且独特的 C++ 编程手法。

std::string

iostream 可以与 string 配合得很好。但是有一个问题:谁依赖谁?

std::string 的 operator << 和 operator >> 是如何声明的?"string" 头文件在声明这两个 operators 的时候要不要 include "iostream" ?

iostream 和 string 都可以单独 include 来使用,显然 iostream 头文件里不会定义 string 的 << 和 >> 操作。但是,如果"string"要include "iostream",岂不是让 string 的用户被迫也用了 iostream?编译 iostream 头文件可是相当的慢啊(因为 iostream 是 template,其实现代码都放到了头文件中)。

标准库的解决办法是定义 iosfwd 头文件,其中包含 istream 和 ostream 等的前向声明 (forward declarations),这样 "string" 头文件在定义输入输出操作符时就可以不必包含 "iostream",只需要包含简短得多的 "iosfwd"。我们自己写程序也可借此学习如何支持可选的功能。

值得注意的是,istream::getline() 成员函数的参数类型是 char*,因为 "istream" 没有包含 "string",而我们常用的 std::getline() 函数是个 non-member function,定义在 "string" 里边。

std::complex

标准库的复数类 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 是 template,其 operator<< 必须在头文件中定义,而这个定义又用到了 ostringstream,不得已包含了 iostream 的实现)。

如果程序要对 complex 做 IO,从效率和健壮性方面考虑,建议不要使用 iostream。

iostream 在使用方面的缺点

在简单使用 iostream 的时候,它确实比 stdio 方便,但是深入一点就会发现,二者可说各擅胜场。下面谈一谈 iostream 在使用方面的缺点。

1. 格式化输出很繁琐

iostream 采用 manipulator 来格式化,如果我想按照 2010-04-03 的格式输出前面定义的 Date class,那么代码要改成:

--- 02-02.cc    2011-07-16 16:40:05.000000000 +0800+++ 04-01.cc    2011-07-16 17:10:27.000000000 +0800@@ -1,4 +1,5 @@ #include <iostream>+#include <iomanip> class Date {@@ -10,7 +11,9 @@   void writeTo(std::ostream& os) const   {-    os << year_ << '-' << month_ << '-' << day_;+    os << year_ << '-'+       << std::setw(2) << std::setfill('0') << month_ << '-'+       << std::setw(2) << std::setfill('0') << day_;   }  private:

假如用 stdio,会简短得多,因为 printf 采用了一种表达能力较强的小语言来描述输出格式。

--- 04-01.cc    2011-07-16 17:03:22.000000000 +0800+++ 04-02.cc    2011-07-16 17:04:21.000000000 +0800@@ -1,5 +1,5 @@ #include <iostream>-#include <iomanip>+#include <stdio.h> class Date {@@ -11,9 +11,9 @@   void writeTo(std::ostream& os) const   {-    os << year_ << '-' << month_ << '-' << day_;+    char buf[32];+    snprintf(buf, sizeof buf, "%d-%02d-%02d", year_, month_, day_);+    os << buf;   }  private:

使用小语言来描述格式还带来另外一个好处:外部可配置。

2. 外部可配置性

比方说,我想用一个外部的配置文件来定义日期的格式。C stdio 很好办,把格式字符串 "%d-%02d-%02d"保存到配置里就行。但是 iostream 呢?它的格式是写死在代码里的,灵活性大打折扣。

再举一个例子,程序的 message 的多语言化。

  const char* name = "Shuo Chen";  int age = 29;  printf("My name is %1$s, I am %2$d years old.\n", name, age);  cout << "My name is " << name << ", I am " << age << " years old." << endl;
对于 stdio,要让这段程序支持中文的话,把代码中的"My name is %1$s, I am %2$d years old.\n",

替换为 "我叫%1$s,今年%2$d岁。\n" 即可。也可以把这段提示语做成资源文件,在运行时读入。而对于 iostream,恐怕没有这么方便,因为代码是支离破碎的。

C stdio 的格式化字符串体现了重要的“数据就是代码”的思想,这种“数据”与“代码”之间的相互转换是程序灵活性的根源,远比 OO 更为灵活。

3. stream 的状态

如果我想用 16 进制方式输出一个整数 x,那么可以用 hex 操控符,但是这会改变 ostream 的状态。比如说

  int x = 8888;  cout << hex << showbase << x << endl;  // forgot to reset state  cout << 123 << endl;

这这段代码会把 123 也按照 16 进制方式输出,这恐怕不是我们想要的。

再举一个例子,setprecision() 也会造成持续影响:

  double d = 123.45;  printf("%8.3f\n", d);  cout << d << endl;  cout << setw(8) << fixed << setprecision(3) << d << endl;  cout << d << endl;

输出是:

$ ./a.out 123.450123.45    # default cout format 123.450  # our format123.450   # side effects

可见代码中的 setprecision() 影响了后续输出的精度。注意 setw() 不会造成影响,它只对下一个输出有效。

这说明,如果使用 manipulator 来控制格式,需要时刻小心防止影响了后续代码。而使用 C stdio 就没有这个问题,它是“上下文无关的”。

4. 知识的通用性

在 C 语言之外,有其他很多语言也支持 printf() 风格的格式化,例如 Java、Perl、Ruby 等等 (http://en.wikipedia.org/wiki/Printf#Programming_languages_with_printf)。学会 printf() 的格式化方法,这个知识还可以用到其他语言中。但是 C++ iostream 只此一家别无分店,反正都是格式化输出,stdio 的投资回报率更高。

基于这点考虑,我认为不必深究 iostream 的格式化方法,只需要用好它最基本的类型安全输出即可。在真的需要格式化的场合,可以考虑 snprintf() 打印到栈上缓冲,再用 ostream 输出。

5. 线程安全与原子性

iostream 的另外一个问题是线程安全性。stdio 的函数是线程安全的,而且 C 语言还提供了 flockfile(3)/funlockfile(3) 之类的函数来明确控制 FILE* 的加锁与解锁。

iostream 在线程安全方面没有保证,就算单个 operator<< 是线程安全的,也不能保证原子性。因为 cout << a << b; 是两次函数调用,相当于 cout.operator<<(a).operator<<(b)。两次调用中间可能会被打断进行上下文切换,造成输出内容不连续,插入了其他线程打印的字符。

而 fprintf(stdout, "%s %d", a, b); 是一次函数调用,而且是线程安全的,打印的内容不会受其他线程影响。

因此,iostream 并不适合在多线程程序中做 logging。

iostream 的局限

根据以上分析,我们可以归纳 iostream 的局限:

  • 输入方面,istream 不适合输入带格式的数据,因为“纠错”能力不强,进一步的分析请见孟岩写的《契约思想的一个反面案例》,孟岩说“复杂的设计必然带来复杂的使用规则,而面对复杂的使用规则,用户是可以投票的,那就是你做你的,我不用!”可谓鞭辟入里。如果要用 istream,我推荐的做法是用 getline() 读入一行数据,然后用正则表达式来判断内容正误,并做分组,然后用 strtod/strtol 之类的函数做类型转换。这样似乎更容易写出健壮的程序。
  • 输出方面,ostream 的格式化输出非常繁琐,而且写死在代码里,不如 stdio 的小语言那么灵活通用。建议只用作简单的无格式输出。
  • log 方面,由于 ostream 没有办法在多线程程序中保证一行输出的完整性,建议不要直接用它来写 log。如果是简单的单线程程序,输出数据量较少的情况下可以酌情使用。当然,产品代码应该用成熟的 logging 库,而不要用其它东西来凑合。
  • in-memory 格式化方面,由于 ostringstream 会动态分配内存,它不适合性能要求较高的场合。
  • 文件 IO 方面,如果用作文本文件的输入输出,(i|o)fstream 有上述的缺点;如果用作二进制数据输入输出,那么自己简单封装一个 File class 似乎更好用,也不必为用不到的功能付出代价(后文还有具体例子)。ifstream 的一个用处是在程序启动时读入简单的文本配置文件。如果配置文件是其他文本格式(XML 或 JSON),那么用相应的库来读,也用不到 ifstream。
  • 性能方面,iostream 没有兑现“高效性”诺言。iostream 在某些场合比 stdio 快,在某些场合比 stdio 慢,对于性能要求较高的场合,我们应该自己实现字符串转换(见后文的代码与测试)。iostream 性能方面的一个注脚:在线 ACM/ICPC 判题网站上,如果一个简单的题目发生超时错误,那么把其中 iostream 的输入输出换成 stdio,有时就能过关。

既然有这么多局限,iostream 在实际项目中的应用就大为受限了,在这上面投入太多的精力实在不值得。说实话,我没有见过哪个 C++ 产品代码使用 iostream 来作为输入输出设施。http://google-styleguide.googlecode.com/svn/trunk/cppguide.xml#Streams 

iostream 在设计方面的缺点

iostream 的设计有相当多的 WTFs,stackoverflow 有人吐槽说“If you had to judge by today's software engineering standards, would C++'s IOStreams still be considered well-designed?”http://stackoverflow.com/questions/2753060/who-architected-designed-cs-iostreams-and-would-it-still-be-considered-well 。

面向对象的设计

iostream 是个面向对象的 IO 类库,本节简单介绍它的继承体系。

对 iostream 略有了解的人会知道它用了多重继承和虚拟继承,简单地画个类图如下,是典型的菱形继承:

simple

如果加深一点了解,会发现 iostream 现在是模板化的,同时支持窄字符和宽字符。下图是现在的继承体系,同时画出了 fstreams 和 stringstreams。图中方框的第二行是模板的具现化类型,也就是我们代码里常用的具体类型(通过 typedef 定义)。

 

ios

这个继承体系糅合了面向对象与泛型编程,但可惜它两方面都不讨好。

再进一步加深了解,发现还有一个平行的 streambuf 继承体系,fstream 和 stringstream 的不同之处主要就在于它们使用了不同的 streambuf 具体类型。

 

buf

再把这两个继承体系画到一幅图里:

full

注意到 basic_ios 持有了 streambuf 的指针;而 fstreams 和 stringstreams 则分别包含 filebuf 和 stringbuf 的对象。看上去有点像 Bridge 模式。

 

看了这样巴洛克的设计,有没有人还打算在自己的项目中想通过继承 iostream 来实现自己的 stream,以实现功能扩展么?

面向对象方面的设计缺陷

本节我们分析一下 iostream 的设计违反了哪些 OO 准则。

我们知道,面向对象中的 public 继承需要满足 Liskov 替换原则。(见《Effective C++ 第3版》条款32:确保你的 public 继承模塑出 is-a 关系。《C++ 编程规范》条款 37:public 继承意味可替换性。继承非为复用,乃为被复用。)

在程序里需要用到 ostream 的地方(例如 operator<< ),我传入 ofstream 或 ostringstream 都应该能按预期工作,这就是 OO 继承强调的“可替换性”,派生类的对象可以替换基类对象,从而被 operator<< 复用。

iostream 的继承体系多次违反了 Liskov 原则,这些地方继承的目的是为了复用基类的代码,下图中我把违规的继承关系用红线标出。

correct

在现有的继承体系中,合理的有:

  • ifstream is-a istream
  • istringstream is-a istream
  • ofstream is-a ostream
  • ostringstream is-a ostream
  • fstream is-a iostream
  • stringstream is-a iostream

我认为不怎么合理的有:

  • ios 继承 ios_base,有没有哪种情况下程序代码期待 ios_base 对象,但是客户可以传入一个 ios 对象替代之?如果没有,这里用 public 继承是不是违反 OO 原则?
  • istream 继承 ios,有没有哪种情况下程序代码期待 ios 对象,但是客户可以传入一个 istream 对象替代之?如果没有,这里用 public 继承是不是违反 OO 原则?
  • ostream 继承 ios,有没有哪种情况下程序代码期待 ios 对象,但是客户可以传入一个 ostream 对象替代之?如果没有,这里用 public 继承是不是违反 OO 原则?
  • iostream 多重继承 istream 和 ostream。为什么 iostream 要同时继承两个 non-interface class?这是接口继承还是实现继承?是不是可以用组合(composition)来替代?(见《Effective C++ 第3版》条款38:通过组合模塑出has-a 或“以某物实现”。《C++ 编程规范》条款 34:尽可能以组合代替继承。)

用组合替换继承之后的体系:

myown

注意到在新的设计中,只有真正的 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 的引用。功能不受影响,而且代码更清晰。(虽然我非常怀疑 iostream 的真正价值,一个东西既可读又可写,说明是个 sophisticated IO 对象,为什么还用这么厚的 OO 封装?)

阳春的 locale

iostream 的故事还不止这些,它还包含一套阳春的 locale/facet 实现,这套实践中没人用的东西进一步增加了 iostream 的复杂度,而且不可避免地影响其性能。Nathan Myers 正是始作俑者http://www.cantrip.org/locale.html 。

ostream 自身定义的针对整数和浮点数的 operator<< 成员函数的函数体是:

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 的实现放到头文件应得的好处,编译和运行速度都快不起来。

我没有深入挖掘其中的细节,感兴趣的同学可以移步观看 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 来说,它们之间的差异极大:

  • 是单向 IO 还是双向 IO。只读或者只写?还是既可读又可写?
  • 顺序访问还是随机访问。可不可以 seek?可不可以退回 n 字节?
  • 文本数据还是二进制数据。格式有误怎么办?如何编写健壮的处理输入的代码?
  • 有无缓冲。write 500 字节是否能保证完全写入?有没有可能只写入了 300 字节?余下 200 字节怎么办?
  • 是否阻塞。会不会返回 EWOULDBLOCK 错误?
  • 有哪些出错的情况。这是最难的,memory stream 几乎不可能出错,file stream 和 socket stream 的出错情况完全不同。socket stream 可能遇到对方断开连接,file stream 可能遇到超出磁盘配额。

根据以上列举的初步分析,我不认为有办法设计一个公共的基类把各方面的情况都考虑周全。各种 IO 设施之间共性太小,差异太大,例外太多。如果硬要用面向对象来建模,基类要么太瘦(只放共性,这个基类包含的 interface functions 没多大用),要么太肥(把各种 IO 设施的特性都包含进来,这个基类包含的 interface functions 很多,但是不是每一个都能调用)。

C 语言对此的解决办法是用一个 int 表示 IO 对象(file 或 PIPE 或 socket),然后配以 read()/write()/lseek()/fcntl() 等一系列全局函数,程序员自己搭配组合。这个做法我认为比面向对象的方案要简洁高效。

iostream 在性能方面没有比 stdio 高多少,在健壮性方面多半不如 stdio,在灵活性方面受制于本身的复杂设计而难以让使用者自行扩展。目前看起来只适合一些简单的要求不高的应用,但是又不得不为它的复杂设计付出运行时代价,总之其定位有点不上不下。

在实际的项目中,我们可以提炼出一些简单高效的 strip-down 版本,在获得便利性的同时避免付出不必要的代价。

一个 300 行的 memory buffer output stream

我认为以 operator<< 来输出数据非常适合 logging,因此写了一个简单的 LogStream。代码不到 300行,完全独立于 iostream。

  • 接口 https://github.com/chenshuo/recipes/blob/master/logging/LogStream.h
  • 实现 https://github.com/chenshuo/recipes/blob/master/logging/LogStream.cc
  • 单元测试 https://github.com/chenshuo/recipes/blob/master/logging/LogStream_test.cc
  • 性能测试 https://github.com/chenshuo/recipes/blob/master/logging/LogStream_bench.cc

这个 LogStream 做到了类型安全和类型可扩展。它不支持定制格式化、不支持 locale/facet、没有继承、buffer 也没有继承与虚函数、没有动态分配内存、buffer 大小固定。简单地说,适合 logging 以及简单的字符串转换。

LogStream 的接口定义是

class LogStream : boost::noncopyable{  typedef LogStream self; public:  typedef detail::FixedBuffer Buffer;  LogStream();  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&);  const Buffer& buffer() const { return buffer_; }  void resetBuffer() { buffer_.reset(); } private:  Buffer buffer_;};

LogStream 本身不是线程安全的,它不适合做全局对象。正确的使用方式是每条 log 消息构造一个 LogStream,用完就扔。LogStream 的成本极低,这么做不会有什么性能损失。

目前这个 logging 库还在开发之中,只完成了 LogStream 这一部分。将来可能改用动态分配的 buffer,这样方便在线程之间传递数据。

整数到字符串的高效转换

muduo::LogStream 的整数转换是自己写的,用的是 Matthew Wilson 的算法,见 http://blog.csdn.net/solstice/article/details/5139302 。这个算法比 stdio 和 iostream 都要快。

浮点数到字符串的高效转换

目前 muduo::LogStream 的浮点数格式化采用的是 snprintf() 所以从性能上与 stdio 持平,比 ostream 快一些。

浮点数到字符串的转换是个复杂的话题,这个领域 20 年以来没有什么进展(目前的实现大都基于 David M. Gay 在 1990 年的工作《Correctly Rounded Binary-Decimal and Decimal-Binary Conversions》,代码http://netlib.org/fp/),直到 2010 年才有突破。

Florian Loitsch 发明了新的更快的算法 Grisu3,他的论文《Printing floating-point numbers quickly and accurately with integers》发表在 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。我做了一个简单的性能测试,结果如下。

benchmark

从上表看出,ostreamstream 有时候比 snprintf 快,有时候比它慢,muduo::LogStream 比它们两个都快得多(double 类型除外)。

泛型编程

其他程序库如何使用 LogStream 作为输出呢?办法很简单,用模板。

前面我们定义了 Date class 针对 std::ostream 的 operator<<,只要稍作修改就能同时适用于 std::ostream 和 LogStream。而且 Date 的头文件不再需要 include <ostream>,降低了耦合。

 class Date {  public:   Date(int year, int month, int day)     : year_(year), month_(month), day_(day)   {   }-  void writeTo(std::ostream& os) const+  template<typename OStream>+  void writeTo(OStream& os) const   {     char buf[32];     snprintf(buf, sizeof buf, "%d-%02d-%02d", year_, month_, day_);     os << buf;   }  private:   int year_, month_, day_; };-std::ostream& operator<<(std::ostream& os, const Date& date)+template<typename OStream>+OStream& operator<<(OStream& os, const Date& date) {   date.writeTo(os);   return os; }

现实的 C++ 程序如何做文件 IO

举两个例子, Kyoto Cabinet 和 Google leveldb。

Google leveldb

Google leveldb 是一个高效的持久化 key-value db。

它定义了三个精简的 interface:

  • SequentialFile http://code.google.com/p/leveldb/source/browse/trunk/include/leveldb/env.h#154
  • RandomAccessFile http://code.google.com/p/leveldb/source/browse/trunk/include/leveldb/env.h#178
  • WritableFile http://code.google.com/p/leveldb/source/browse/trunk/include/leveldb/env.h#197

接口函数如下

struct Slice {  const char* data_;  size_t size_;};// A file abstraction for reading sequentially through a fileclass SequentialFile { public:  SequentialFile() { }  virtual ~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 ~WritableFile();  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,然后提炼出了三个简单的接口,每个接口只有屈指可数的几个函数。这几个接口在各个平台下的实现也非常简单明了(http://code.google.com/p/leveldb/source/browse/trunk/util/env_posix.cc#35 http://code.google.com/p/leveldb/source/browse/trunk/util/env_chromium.cc#176),一看就懂。

注意这三个接口使用了虚函数,我认为这是正当的,因为一次 IO 往往伴随着 context switch,虚函数的开销比起 context switch 来可以忽略不计。相反,iostream 每次 operator<<() 就调用虚函数,我认为不太明智。

Kyoto Cabinet

Kyoto Cabinet 也是一个 key-value db,是前几年流行的 Tokyo Cabinet 的升级版。它采用了与 leveldb 不同的文件抽象。

KC 定义了一个 File class,同时包含了读写操作,这是个 fat interface。http://fallabs.com/kyotocabinet/api/classkyotocabinet_1_1File.html

在具体实现方面,它没有使用虚函数,而是采用 #ifdef 来区分不同的平台(见 http://code.google.com/p/read-taobao-code/source/browse/trunk/tair/src/storage/kdb/kyotocabinet/kcfile.cc),等于把两份独立的代码写到了同一个文件里边。

相比之下,Google leveldb 的做法更高明一些。

小结

在 C++ 项目里边自己写个 File class,把项目用到的文件 IO 功能简单封装一下(以 RAII 手法封装 FILE* 或者 file descriptor 都可以,视情况而定),通常就能满足需要。记得把拷贝构造和赋值操作符禁用,在析构函数里释放资源,避免泄露内部的 handle,这样就能自动避免很多 C 语言文件操作的常见错误。

如果要用 stream 方式做 logging,可以抛开繁重的 iostream 自己写一个简单的 LogStream,重载几个 operator<<,用起来一样方便;而且可以用 stack buffer,轻松做到线程安全。

posted @ 2011-07-17 15:06 陈硕 阅读(4144) | 评论 (10) | 编辑 收藏

仅列出标题  下一页