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)。
文章结构:
- 什么是数据抽象?它与面向对象有何区别?
- 数据抽象所需的语言设施
- 数据抽象的例子
什么是数据抽象
数据抽象(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 数组也是连续的。
C++ 尽管定义了了 Vector3 这个抽象,它的内存布局并没有改变,Planet 的布局和 C planet 一模一样,Planet[] 的布局也和 C 数组一样。
另一方面,C++ 的 inline 函数在这里也起了巨大作用,我们可以放心地调用 Vector3::operator+=() 等操作符,编译器会生成和 C 一样高效的代码。
不是每个编程语言都能做到在提升抽象的时候不影响性能,来看看 Java 的内存布局。
如果我们用 class Vector3、class Planet、Planet[] 的方式写一个 Java 版的 N-body 程序,内存布局将会是:
这样大大降低了 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++的重要抽象手段,适合封装“数据”,它的语义简单,容易使用。数据抽象能简化代码书写,减少偶然错误。
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 分别是复数的实部和虚部。)
而如果我们在 Java 里干同样的事情,layout 大不一样,memory locality 也差很多:
Java 里边每个 object 都有 header,至少有两个 word 的开销。对比 Java 和 C++,可见 C++ 的对象模型要紧凑得多。
待续
下一篇文章我会谈与值语义紧密相关的数据抽象(data abstraction),解释为什么它是与面向对象并列的一种编程范式,为什么支持面向对象的编程语言不一定支持数据抽象。C++在最初的时候是以 data abstraction 为卖点,不过随着时间的流逝,现在似乎很多人只知 Object-Oriented,不知 data abstraction 了。C++ 的强大之处在于“抽象”不以性能损失为代价,下一篇文章我们将看到具体例子。
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 的性能方面有两个弱点。
- 使用一种 little language (现在流行叫 DSL)来配置格式。固然有利于紧凑性和灵活性,但损失了一点点效率。每次打印一个整数都要先解析 "%d" 字符串,大多数情况下不是问题,某些场合需要自己写整数到字符串的转换。
- 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.450
123.45 # default cout format
123.450 # our format
123.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 略有了解的人会知道它用了多重继承和虚拟继承,简单地画个类图如下,是典型的菱形继承:
如果加深一点了解,会发现 iostream 现在是模板化的,同时支持窄字符和宽字符。下图是现在的继承体系,同时画出了 fstreams 和 stringstreams。图中方框的第二行是模板的具现化类型,也就是我们代码里常用的具体类型(通过 typedef 定义)。
这个继承体系糅合了面向对象与泛型编程,但可惜它两方面都不讨好。
再进一步加深了解,发现还有一个平行的 streambuf 继承体系,fstream 和 stringstream 的不同之处主要就在于它们使用了不同的 streambuf 具体类型。
再把这两个继承体系画到一幅图里:
注意到 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 原则,这些地方继承的目的是为了复用基类的代码,下图中我把违规的继承关系用红线标出。
在现有的继承体系中,合理的有:
- 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:尽可能以组合代替继承。)
用组合替换继承之后的体系:
注意到在新的设计中,只有真正的 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。我做了一个简单的性能测试,结果如下。
从上表看出,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 file
class 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,轻松做到线程安全。