改善C++程式的效率

Improving C++ Program Performance
(Dr. Dobb's Journal October 1999/10)
作者:Stanley Lippman
译者:陈崴
侯捷注:本文系北京《程序员》杂志 2001/12 的文章。
承译者陈崴先生与《程序员》杂志负责人蒋涛先生答允,
转载於此,以飨台湾读者,非常感谢。

未得陈崴先生与蒋涛先生二人之同意,任何人请勿将此文再做转载。


--------------------------------------------------------------------------------

译注:以下是本文所采用的几个术语:

instance:实体。

class, object, data member, member function, constructor, destructor, template 等术语,皆保留不译。


--------------------------------------------------------------------------------

对 C++ 程序员而言,认知什麽情况下不要定义 copy constructors, copy-assignment operators,  destructors,其重要性就像认知这些特殊member functions 什麽时候是必要的,一样重要。举个例子,考虑表格一的测试结果:

程式版本 未最佳化 (Nonoptimized) 最佳化 (Optimized)
基本型式 28.59 15.22
修改 #1 22.25 (22%) 12.27 (19%)
修改 #2 15.88 (44%) 9.50 (37%)
整合(#1 和 #2) 12.97 (54%) 7.51 (51%)

表格一:执行时间的测试结果

「基本型式」栏所呈现的,是一个拥有整组特殊化 member functions(copy constructor, copy-assignment operator, destructor)的程式的执行时间。在此基本型式中,没有任何一个 class member functions 或任何它所支援的 friend functions 被宣告为 inline。是的,不使用 inline、三个特殊 member functions 全员到齐,并且相当程度地符合普遍流行的 C++ 编程规则(例如 Taligent's Guide to Designing Programs, Addison-Wesley, 1994, ISBN 0-201-40888-0)。

为了继续讨论,我们假设这个程式的效率太差,而你的工作就是要使它快些。你该怎麽做?

常见的加速策略有三种:

1. 将目前所用的演算法以更有效率的演算法取代(但这样的演算法往往更复杂,例如以快速排序法 quicksort 取代气泡排序法 bubble sort)。

2. 将目前所用的资料结构以更有效率的资料结构取代(但这样的资料结构往往更复杂,例如以红黑树 red-black tree 取代二元树 binary tree)。

3. 将目前所用的程式码以更有效率的程式码取代(这种情况下所增加的效率往往伴之以控制流程和程式码可读性的减少)。

许多情况下,诉诸上述三个策略中的任何一个,其实都是非必要的。通常,检阅并挑出不适当的 C++ 编程手法,不但简单而且效果不错。所谓不适当的编程习惯,其中一个就是非必要地定义 copy constructor, copy-assignment operator 和 destructor。列表一表现的是一个基本型式的Vector class 和其应用程式(译注:此处Vector 并不是指 C++ 标准程式库所提供的容器,而是指数学中的三度空间向量),第一次修改只是简单地将每一个 friend function和 member function改为 inline。第二次修改移除了 copy constructor, copy-assignment operator 和 destructor 的定义。第三次修改则是第一次修改和第二次修改的结合。本文所列的这些程式都通过 SGI 7.2 版编译器,时间的量测以 timex 指令完成。


列表一
class Vector {   
    friend Vector
           operator+( const Vector&, const Vector& );
    friend Vector
           operator-( const Vector&, const Vector& );
public:
    Vector( double x=0.0,
             double y=0.0, double z=0.0 );
    Vector( const Vector& );
    Vector& operator=( const Vector& );
    ~Vector();
    bool operator==( const Vector& );
    Vector operator/( double );

    double mag();
    double dot( const Vector& );
    Vector cross( const Vector& );
    void normalize();

    double x() const;
    double y() const;
    double z() const;

    void x( double newx );
    void y( double newy );
    void z( double newz );

private:
    double _x, _y, _z;
};
Vector::Vector( double x, double y, double z )
    { _x = x; _y = y; _z = z; }
Vector::Vector( const Vector &rhs )
    { _x = rhs._x; _y = rhs._y; _z = rhs._z; }
Vector::~Vector()
    {_x = 0.0; _y = 0.0; _z = 0.0; }
Vector& Vector::operator=( const Vector &rhs )
{
    _x = rhs._x; _y = rhs._y; _z = rhs._z;
    return *this;
}
Vector
operator+( const Vector &lhs, const Vector &rhs )
{
    Vector result;

    result._x = lhs._x + rhs._x;
    result._y = lhs._y + rhs._y;
    result._z = lhs._z + rhs._z;

    return result;
}
Vector
operator-( const Vector &lhs, const Vector &rhs )
{
    Vector result;

    result._x = lhs._x - rhs._x;
    result._y = lhs._y - rhs._y;
    result._z = lhs._z - rhs._z;

    return result;
}
#include <math.h>
double Vector::mag()
    { return sqrt( _x*_x + _y*_y +_z*_z ); }
Vector Vector::cross( const Vector &rhs )
{
    Vector result;

    result._x = _y * rhs._z - rhs._y * _z;
    result._y = _z * rhs._x - rhs._z * _x;
    result._z = _x * rhs._y - rhs._z * _y;

    return result;
}
void Vector::normalize()
{
    double d = mag();
    _x /= d; _y /= d; _z /= d;
}
double Vector::dot( const Vector &rhs )
    { return _x*rhs._x + _y*rhs._y + _z*rhs._z; }
bool Vector::operator==( const Vector &rhs )
{
    return _x == rhs._x &&
            _y == rhs._y && _z == rhs._z;
}
Vector
Vector::
operator /( double val )
{
    Vector result;

    if ( val != 0 ) {
        result._x = _x / val;
        result._y = _y / val;
        result._z = _z / val;
    }
    return  result;
}
double Vector::x() const { return _x; }
double Vector::y() const { return _y; }
double Vector::z() const { return _z; }

void Vector::x( double newx ) {  _x = newx; }
void Vector::y( double newy ) {  _y = newy; }
void Vector::z( double newz ) {  _z = newz; }

#include <vector>
int main()
{
    Vector a( 0.231, 2.4745, 0.023 ),
           b( 1.475, 4.8916, -1.23 );
    vector< Vector > vv;
    for ( int ix = 0; ix < 2000000; ++ix )
    {
        Vector c( a.mag(), b.dot(a), ix );

        vv.push_back( a.cross( c ));   
        vv.push_back( a + c );
        vv.push_back( a - c );
        vv.push_back( a / c.mag() );
        vv.push_back( a.cross( c ));

        if ( c == a )
             vv.push_back( b );
        else vv.push_back( a );

        c.normalize();
        vv.push_back( c );
    }
}
第一次修改:加上 Inline
第一个修改动作就是将 Vector class 的 member functions 和 friend functions 加以「inline 化」。这种改变真是太容易了:只要为每一个你想修改的目标加上关键字 inline 即可。一下子你就增加了大约 20%的效率。一般而言,一个应用程式从完全没有「inlining 化」转变为适当「inlining化」,估计可获得 20% ~ 25% 的效率提升。

如果 inlining 带来程式效率的提升,而你又不需要明显的努力,为什麽许多编程标准手册都不赞成「inlining 化」?是的,它的主要缺点就是,你必须重新编译「内含 inline 函式」的每一个档案。对於一个应用於大型专案的 class library 而言,重新编译的成本相当昂贵。

当你在 C++ 中针对一个被广泛使用的 class,增加或移除其 data member 时,或是当你修改某个既存的 data member 的型别时,同样的问题也会浮现。为了完全解决重新编译的问题,你需要转移到另一个物件模型(object model)上,例如 COM 所支援的那个物件模式(可叁考 Essential COM, by Don Box, Addison-Wesley Longman, 1998, ISBN 0-201-63446-5;以及 Inside the C++ Object Model, by Stanley Lippman, Addison-Wesley, 1996, ISBN 0-201-83454-5)。

专案编程标准手册中禁止大家使用 inlining,其实是一种误导。但是从另一个角度看,也并不是每一个 C++ 函式都应该成为 inline。inlining 的另一个潜在缺点是程式的膨胀问题。一般而言只有不大的函式(例如Vector 的 member functions)才适合被 inline 化。实际运用时究竟该如何取舍,有待你的明智判断。

第二次修改:移除非必要的 Member Functions
第二个修改是移除Vector class 之中非必要的 copy constructor, copy-assignment operator 和 destructor。(为了这项修改,我让所有的 member functions 成为 noninline。在第三次修改中,我会将两者整合起来)。这项改变甚至比加上 inline 更为直接易懂;你只要删除三个函式的宣告和定义就好。其结果是几近 40% 的效率提升。

这样的结果往往带给程序员极大的惊讶,但如果你了解编译器底部的行为,你就不会惊讶。

何时需要明显的 Copy Operators?
预设情况下,一个 class object 是以 "memberwise copy"(成员逐一拷贝)的方式被初始化或被赋值。观念上,以 Vector class 为例,首先是 x 座标,然後是 y座标,然後是 z-座标被轮番拷贝。就好像你所写的函式码,请看列表一。

然而,实际上,编译器并不是以上述方式来拷贝 objects:

它并不会产生一个明显的(explicit)copy constructor 和 copy-assignment operator。
它不会轮番拷贝每一个代表座标值的 data member,而是直接对整个 object 的所有资料做一次 "bitwise copy"(位元逐一拷贝),就像 C 的行为那样。编译器并不会为你产生预设的 memberwise functions。我们所写的实作码(列表一)和编译器暗中的行为,两者差别非常大,明显反应於效率表现上。
这对所有情况都是真的吗?不,一般而言,编译器会在四种情况下为你合成出一个 copy constructor 和一个 copy-assignment operator:

当 class 内含一个 member class object,而後者有一个相应的 copy constructor 和/或 copy-assignment operator。
当 class 衍生自一个或多个 base classes,而後者有一个相应的 copy constructor 和/或 copy-assignment operator。
当 class 宣告或继承了一个虚拟函式(virtual function)。
当 class 直接或间接衍生自一个 virtual base class。
C++ Standard 将这样的 classes 称为 nontrivial。换句话说,前述的Vector class 是 trivial,也就是说它不符合上述四种情况。(更详细的讨论,请看我的书籍 Inside the C++ Object Model。)

那麽,是不是说,你绝不应该定义一个 copy constructor 和 copy-assignment operator 呢?当然不是。某些情况下为它们提供一个显式定义是必要的。最常见的必要情况就是当你的 class 内含一个或多个 pointer members,而後者在 class object 寿命期间,不是被初始化,就是被设值为某一块 heap记忆体位址,那块记忆体随後在 destructor 内被删除。C++ 编程的基本观点之一就是对此情势有所认知。如果你需要更从容而且带实例的讨论,请看 C++ Primer, 第三版, by Stanley Lippman and Josée Lajoie, Addison-Wesley Longman, 1998, ISBN 0-201-82470-1.)(译注:也可以阅读 Effective C++ 2/e,条款11:「如果 classes 内部动态配置记忆体,请为此 class 宣告一个 copy constructor 和一个 copy-assignment operator)

面对一个「所有 data members 都以 by value方式存在」的 class(例如Vector class),编译器预设的初始化动作和拷贝动作会比我们自己撰写的动作更有效率。这对於程式和程式员而言,都是很少量的工作。对程式员而言,你的任务就是辨识什麽情况下需要自己实作一个显式 copy constructor 和 copy-assignment operator。

何时需要一个显式(explicit)Destructor?
同样道理,面对一个「所有 data members 都以 by value方式存在」的 class(例如Vector class 的三个座标值),destructor 是没有必要的。是的,并非每个 class 都需要 destructor,纵使你已经为此 class 提供了一个或多个 constructors。Destructors 的任务主要是撤回被  constructor 索取的资源,或是 class object 寿命期间索取的资源。例如释放一个多工互斥器锁定(mutual exclusion lock),或是删除「operator new; 配置而得的记忆体」,请叁考 C++ Primer。

为什麽它不是一个被推荐的编程手法?
如果「不提供显式 copy constructor 和 copy-assignment operator 和 destructor」就可以改善你的程式效率,而又不需要你多做什麽努力,为什麽许多编程标准手册都建议我们尽量提供一个显式版本呢?这个议题,我相信,牵涉到一个台面下的可能性,那就是,做为一个 C++ 程式员,你是否获得足够的信赖去发挥你的判断?(缩小范围地说, inlining 议题也是同样的情况。)要知道,万一它们(那些特殊的member functions)必须存在,而你却没有提供其实体,会导致严重的程式错误。

该如何选择?办法之一是允许你自由选择,前提是「做出正确选择之前的所有必要资讯」都必须齐备。办法之二是很单纯地墨守成规 ─ 是的,通常这些(编程标准手册上的)规则在 80% - 90% 的情况下都适用。两种解法之间的抉择关系到你究竟如何管理一个软件专案。

在Vector class 中,提供非必要的 copy constructor, copy-assignment operator, destructor 的显式实体,带来的结果并非不正确,而是效率不彰。认知此一事实,有助於你调整你的实作码而不必整个重新翻修一遍。

致谢
Josée Lajoie 检阅了本文初稿,并提供许多深刻的看法和建议。

你可能感兴趣的:(C++)