2. 构造/析构/赋值运算 摘录

几乎你写的每一个class都会有一个或多个构造函数、一个析构函数、一个copy assignment操作符。如果这些函数犯错,会导致影响深远且令人不愉快的后果,遍及你的整个class。

条款05: 了解C++默默编写并调用那些函数

当C++处理类时候,如果你自己没有声明构造和析构函数,赋值运算符,编译器就会为它声明。

class Empty {};

// 编译器就会声明
class Empty
{
    Empty() {...};
    Empty(const Empty&) {...};
    ~Empty(0 {...};

    Empty& operator=(const Empty& rhs) {...}
};

至于copy构造和copy assignment操作符,编译器创建的版本只是单纯地将来源对象的每一个non-static成员变量拷贝到目标对象。

template <tyepname T>
class NamedObjected
{
public:
    NamedObjected(const char* name, const T& value);
    NamedObjected(const std::string name, const T& value);
    ...
private:
    std::string ameValue;
    T objectValue;
};

如果程序员声明了一个构造函数,编译器于是不再为它创建default构造函数。

NamedObjected<int> no1("Smallest Prime Number", 2);
NamedObjected<int> no2(no1);        // 调用copy构造函数

no2.nameValue的初始化调用方式是调用string的copy构造函数并以no1.nameValue为实参。

一般而言,只有编译器生成的代码合法且有适当机会证明它有意义,其表现才会如线索所说。

template <tyepname T>
class NamedObjected
{
public:
    NamedObjected(const std::string& name, const T& value);
    ...
private:
    std::string& ameValue;
    const T objectValue;
};

现在考虑会发生事情:

std::string newDog("Persephone");
std::string oldDog("Satch");


NamedObjected<int> p("newDog", 2);
NamedObjected<int> s("oldDog", 36);

p = s;

赋值之前,不论p.nameVale和s.nameValue都指向string对象(当然不是同一个)。赋值之后,p.nameVale指向s.nameValue的string?C++并不允许"让reference改指向不同对象"。面对这个难题,C++的响应是拒绝编译这一行的赋值动作。

如果你打算在一个内含reference成员的class内支持赋值操作,你必须直接定义copy assignment操作符。面对内含const成员的class,编译器的反应也是一样的。更改const成员是不合法的。最后的一种情况:如果某个base class将copy assignment操作符声明为private,编译器将拒绝为其derived class生成一个copy assignment操作符。比较编译器为derived class所生的copy assignment操作符想象中可用处理base class成分。

请记住:

编译器可以暗自为class创建default构造函数、copy构造、copy assignment操作符以及析构函数。

条款06:若不想使用编译器自动生成的函数,就该明确拒绝

class HomeForSale {...};


HomeForSale h1;
HomeForSale h2;
HomeForSale h3(h1);    // 不应该通过编译
h1 = h2;               // 不应该通过编译

对于一个独一无二的东西?你怎么可以去复制它呢?

所有编译器产出的函数都是public。为阻止这些函数被创建出来,你得自行声明它们,但这里并没有需求使你必须将它们声明为public。因此你可以将copy构造函数和copy assignment草祖父声明为private。藉由明确声明一个成员函数,你阻止了编译器暗自创建其专属版本。

一般而言,上述做法并不绝对安全,因为member函数和friend函数还是可以调用你的private函数。除非你够聪明,不去定义它们,那么如果某些人不慎调用任何一个,会获得一个连接错误(linkage error)。

将成员函数声明为private且故意不去实现它们,这一技术能解决上述问题。

class HomeForSale
{
public:
    ...
private:
    ...
    HomeForSale(const HomeForSale&);            // 只有声明,不实现
    HomeForSale& operator=(const HomeForSale&);
...};

当客户企图拷贝HomeForSale对象,编译器会阻扰他。将连接期错误移至编译器是可能的(而且那是好事,比较越早侦测出来错误越好)。

class Uncopyable
{
protected:
    Uncopyable() {}
    ~Uncopyable() {}

private:
    Uncopybale(const Uncopyable&);
    Uncopyable& operator=(const Uncopyable&);
};

class HomeForSale : private Uncopyable
{
...        // class 不再声明copy构造函数或copy assign操作符
};

为求阻止HomeForSale对象被拷贝,我们唯一需要做的是继承Uncopyable。

如果任何人--甚至是member函数或friend函数--尝试拷贝HomeForSale对象,编译器便试着生成一个copy构造函数和一个copy assignment操作符,这些函数的“编译器生成版”会尝试调用其base class的对应兄弟,那些调用会被编译器拒绝,因为其base class的拷贝函数是private。

Uncopyable class的实现和运用颇为微妙,包括不一定得以public继承它,以及Uncopyable的析构函数不一定得是virtual。

请记住:

为驳回编译器自动(暗自)提供的机能,可将相应的成员函数声明为private并且不予实现。使用像Uncopyable这样的base class也是一种做法。

条款07: 为多态基类声明virtual析构函数

有许多做法可以记录时间。

class TimeKeeper
{
public:
    TimeKeeper();
    ~TimeKeeper();
    ...
};

class AtomicClock : public TimeKeeper {...};
class WaterClock : public TimeKeeper {...};
class WristWatch : public TimeKeeper {...};

// 设计一个factory函数,返回指针指向一个计时对象
TimeKeeper* getTimeKeeper();


TimeKeeper* ptk = getTimeKeeper();
...
delete ptk;        // 行为错误

为遵守factory函数的规矩,将getTimeKeeper()返回的对象必须位于heap。因此未来避免泄露内存和其他资源,将fatcory函数返回的每一个对象适当地delete很重要。

C++明白指出,当derived class对象经由一个base class指针被删除,而该base class带着一个non-virtual析构函数,其结果未定义--实际执行时通常发生的对象的derived成分没被销毁。然而其base class成分通常会被销毁,于是造成一个诡异的“局部销毁”对象,这可是形成资源泄露、败坏数据结构以及在调试器上浪费许多时间的绝佳途径。

消除这个问题的做法很简单:给base class一个virtual析构函数。此后删除derived class对象就会如同你想要的那般。它会删除整个derived class对象。

class TimeKeeper
{
public:
    TimeKeeper();
    virtual ~TimeKeeper();
    ...
};


TimeKeeper* ptk = getTimeKeeper();
...
delete ptk;        // 行为正确

virtual函数的目的是允许derived class的实现得以客制化。例如TimeKeeper就可能拥有一个virtual getCurrentTime,它在不同的derived classes中有不同的实现码。任何class只要带有virtual函数都几乎确定应该有一个virtual析构函数。

如果class补焊virtual函数,通常表示它并不意图被用作一个base class。当class不企图被当做base class,令其析构函数为virtual往往是一个馊主意。

欲出现virtual函数,对象必须携带某些信息,主要用来运行期决定哪一个virtual函数该被调用。这份信息通常是由一个所谓的vptr(virtual table pointer)指针指出。vptr指向一个由函数指针构成的数组,称为vtabl(virtual table);每一个带有virtual函数的class都有一个相应的vtbl。实际上被调用的函数取决于该对象的vptr所指的那个vtbl--编译器在其中寻找适当的函数指针。

C++的对象(含有虚函数)也不再和C内的相同声明有着一样的机构。

目前,只有当class内含至少一个virtual函数,才为它声明virtual析构函数。

即使class完全不带virtual函数,被non-virtual析构函数问题伤到也是可能的。

举个例子:标准string不含任何virtual函数,但有时候程序员会错误地把它当做base class:

class SpecialString : public std::string {...};


SpecialString* pss = new SpecialString("Impending Doom");
std::string* ps;
...
ps = pss;
...
delete ps;    // 未有定义!现实中的*ps的SpecilalString资源会泄露。

咋看似乎无害,但如果你在程序任意某处无意间将一个pointer-to-SpecialString转换为pointer-to-string,然后将转换所的的string指针delete掉,你立刻被流放到行为不明确的情形下。

相同的分析适用于任何不带virtual析构函数的class,包括所有STL容器如vector,list,set,tr1::unordered map等等。如果你曾经企图继承一个标准容器或任何其他“带有non-virtual的析构函数”的class,拒绝诱惑吧。

目前C++提供的final可以组织类被继承。

有时候令class带有一个pure virtual析构函数,可能颇为便利。还记得么,pure virtual函数导致abstract classes -- 也就是不能被实例化(instantiated)的class。

有时候你希望拥有抽象class,但手上没有任何pure virtual函数,怎么办?为你希望成为抽象的那个class声明一个pure virtual析构函数。

class AWOV        // AWOV = "Abstract w/o Virtual"
{
public:
    virtual ~AWOV() = 0;
};

这个class有一个pure virtual函数,所以它是一个抽象class,又由于它有个virtual析构函数,所以你不需要担心析构函数的问题。然而这里面有一份窍门,你必须为这个pure virtual析构函数提供一份定义。

AWOV::~AWOV {...} // pure virtual析构函数的定义

析构函数的运作方式是,最深层派生(most derived)的那个class其析构函数最先被调用,然后是其每一个base class的析构函数被调用。所以你必须为AWOV类的析构函数提供一份定义。

给base classes一个virtual析构函数,这个规则只适用于polymorphic(带多态性质的)base classes身上。这种base classes的设计目的是为了用来通过base class接口处理derived class对象。

并非所有的base classes的设计目的都是为了多态用途。

请记住:

polymorphic(带多态性质的)base classes应该声明一个virtual析构函数。如果class带有任何virtual函数,它就应该拥有一个virtual析构函数。

Classes的设计目的如果不是为了作为base class使用,或不是为了具备多态性,就不该声明virtual析构函数。

条款08: 别让异常逃离析构函数

只要析构函数吐出异常,即使并非使用容器或arrays,程序也可能过早结束或出现不明确行为。是的,C++不喜欢析构函数吐出异常。

class DBConnection
{
public:
    ...
    static DBConnection create();    // 这个函数返回DBConnection对象
            

    void close();                    // 关闭联机,失败则抛出异常
};

为确保客户不忘记在DBConnection对象身上调用close(),一个合理的想法是创建一个用来管理DBConnection资源的class,并在其析构函数中调用close。这儿只需要考虑它们的析构函数长相就够了:

class DBCnn              // 这个class用来管理DBConnection对象
{
public:
    ...
    ~DBConn()            // 确保数据库连接总是被关闭
    {
        db.close();
    }
private:
    DBConnection db;
};

如果调用close成功,一切都很美好。但如果该调用导致异常,DBConn析构函数会传播该异常,也就是允许它离开这个析构函数,这会造成问题,因为那就是抛出了难以驾驭的麻烦。

两个办法避免这一问题。DBConn的析构函数可以:

        1. 如果close抛出异常就结束程序。通常通过调用abort完成;

DBConn::~DBConn()
{
    try  {db.close();}
    catch (...) 
    {
        // 制作运作记录,记下对close的调用失败
        std::abort();
    }
}

        如果程序遭遇一个“于析构期间发生的错误”后无法继续执行,“强迫结束程序”是个合理选项。毕竟它可以阻止异常从析构函数传播出去(那会导致不明确的行为)。

        2. 吞下因调用close而发生的异常

DBConn::~DBConn()
{
    try {db.close();}
    catch(...) 
    {
        // 制作运作记录,记下对close的调用失败
    }
}

这些办法都没有什么吸引力,问题在于两者都无法对“导致close抛出异常”的情况作出反应。

一个较佳策略是重新设计DBConn接口,使其客户有机会对可能出现的问题作出反应。

class DBConn
{
public:
    ...
    void close()
    {
        db.close();
        close = true;
    }

    ~DBConn()
    {
        if (!closed)
        {
            try {db.close()}
            catch (...) {// 制造运转记录,记下对close的调用失败...}
        }
    }

private:
    DBConnection db;
    bool closed;
};

把调用close的责任从DBConn析构函数转移到DBConn客户手上(但DBConn析构函数仍然内含一个“双保险”调用)可能会给你“肆无忌惮转移负担”的印象。这可能违反条款18忠告:让接口容易被正确使用。实际上这两项污名都不成立。如果某个操作可能在失败时抛出异常,而又存在某种需要必须处理该异常,那么这个异常必须来自析构函数以外的某个函数。因为析构函数吐出异常就是危险,总会带来“过早结束程序”或“发生不明确行为”的风险。

请记住:

析构函数绝不要吐出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或结束程序。

如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数(而非在析构函数中)执行该操作。

条款09: 绝不在构造和析构过程调用virtual函数

假设设计一个股市交易的类继承体系:

class Transaction
{
public:
    Transaction();
    virtual void logTransaction () const = 0; // 做出一份因类型不同而不同的日志记录
};

Transaction::Transaction
{
    ...
    logTansaction();
}

class BuyTransaction:public Transaction
{
public:
    virtual void logTransaction() const;
    ...
};

class SellTransaction:public Transaction
{
public:
    virtual void logTransaction() const;
    ...
};

base class构造期间virtual函数就不会下降到derived classes阶层。取而代之的是,对象的作为就像隶属于base类型一样。非正式的说法或许比较传神:在base class构造期间,virtual函数不是virtual函数。

由于base class的构造函数的执行更早于derived class构造函数,当base class构造函数执行时,derived class的成员变量尚未初始化。如果此期间调用virtual函数下降至derived classes阶层,要知道derived class的函数几乎必然取用local成员变量,而那些成员变量还未初始化。这将是一张通往不明确行为和彻夜调试大会的直达车票。要求使用对象内部未初始化的成分是危险的代名词。

还有比上述更根本的原因:在derived class对象的base class构造期间,对象的类型是base class而不是derived class。不只virtual函数会被编译器解析至base class,若使用运行期类型信息,也会把对象视为base class。

相同的道理也适用于析构函数。一旦derived class析构开始执行,对象内部derived class成员变量便呈现出未定义值。

但是侦测“构造函数或析构函数运行期间是否调用virtual函数”并不总是这般轻松。如果Transaction有多个构造函数,每个都需要执行某些相同工作,那么避免代码重用的一个优秀做法是把共同的初始化代码放进一个初始化init内。

class Transaction
{
public:
    Transaction () {init();}
    virtual void logTransaction() const = 0;

private:
    void init()
    {
        ...
        logTransaction();        // 这里调用virtual函数
    }
};

上述代码暗中为害。因为init调用了虚函数。相当于构造函数调用了虚函数。留下你百思不得其解为什么建立一个derived class对象时会调用错误版本的logTransaction。唯一能避免此问题的做法就是:确定你的构造函数和析构函数都没有(在创建和销毁期间)调用virtual函数,而它们调用的所有函数也都服从同一约束。

但你如何确保每次一有Tansaction继承体系上的对象被创建,就会有适当版本的logTransaction被调用呢?很显然,在Transaction构造函数内对着对象调用virtual函数是一种错误做法。

其他方案可以解决这个问题。一种是在class Transcation内将logTransaction函数改为non-virtual,然后要求derived class构造函数传递必要信息给Transaction构造函数,而后那个构造函数就可以安全地调用non-virtual logTransaction。如下代码:

class Transaction
{
public:
    explicit Transaction(const std::string& logInfo);
    void logTansaction(const std::string& logInfo);
    ...
};

Transaction::Transaction(const std::string& logInfo)
{
    ...
    logTransaction(logInfo);
}


class ButTransaction : public Transaction
{
public:
    ButTransaction(parameters) : Transaction(createLogString(parameters)) {...}
    ...
private:
    static std::string createLogString(parameters);
};

换句话说由于你无法使用virtual函数从base classes向下调用,在构造期间,你可以藉由“令derived classes”将必要的构造信息向上传递至base class构造函数替换之加以弥补。

比起在成员初值列内基于base class所需数据,利用辅助函数创建一个值给base class构造函数往往比较方便(也比较可读)。令函数为static,也就不可能意外指向“初期未成熟之BuyTransaction”对象内尚未初始化的成员变量。这很重要,正是因为“那些成员变量处于未定义状态”,所以“在base class的构造函数和析构函数调用的virtual函数不可下降至derived classes”。

请记住:

不要在构造和析构期间调用virtual函数,因为这类调用从不下降至derived class。(比起当前执行构造函数和析构函数的那层)。

条款10:另operator=返回一个reference to *this

为了实现“连锁赋值”,赋值操作符必须返回一个reference指向操作符的左侧。此协议不仅适用于以上的标准赋值形式,也适用于所有赋值相关运算,如+=;

class Widget
{
public:
    ...
};

Widget& operator=(const Widget& rhs)
{
    ...
    return *this;
}

请记住:

令赋值操作符返回一个reference to *this。

条款11: 在operator=中处理自我赋值

class Widget {...};

Widget w;
...
w = w;          // 赋值给自己

a[i] = a[j];    // 如果i = j,潜在的自我赋值
*px = *py;      // 如果px和py指向同一个对象,潜在的自我赋值  

这看起来蠢,但并非不可能。由别名(aliasing)带来的自我赋值结果:所谓“别名”就是一个以上的方法指向某对象。一般而言如果某段代码操作pointers或refrences而它们被用来“指向多个相同类型的对象”,就需要考虑这些对象是否为同一个。实际上两个对象只要来自同一个继承体系,它们甚至不需声明为相同类型就可能造成“别名”,因为一个base class的reference或pointer可以指向一个derived class对象。

class Base {...};
class Derived : public Base {...};
void doSomenthing(const Base& rb, Derived& pd);

下面operator=实现代码,表面上看起来合理,但自我赋值出现时并不安全:

Widget& Widget::operator=(const Widget& rhs)
{
    delete pb;
    pb = new Bitmap(*rhs.pb);
    return *this;
}

这里的自我赋值问题是,rhs和*this可能是同一个对象。那么delete就不只是删除当前对象的bitmap,它也销毁了rhs的bitmap。发现自己持有一个指针指向一个已被删除的对象。

欲组织这种错误,传统做法是藉由operator=最前面的一个证同测试(identity test)达到自我赋值的检验目的。

Widget& Widget::operator=(const Widget& rhs)
{
    if (this == &rhs) return *this;    // 证同测试

    delete pb;
    pb = new Bitmap(*rhs.pb);
    return *this;
}

旧版本不仅不具备自我赋值的安全性,也不具备异常安全性,这个新版本仍然存在异常方面的麻烦。更明确的说,如果new Bitmap导致异常(不论是因为分配内存不足或因为Bitmap的copy构造函数抛出异常),Widget最终会持有一个指针指向一块被删除的Bitmap。

令人高兴地是,让operator具备“异常安全性”往往自动获得“自我赋值安全”的回报。例如以下代码,我们只需要注意在复制pb所指东西之前别删除pb:

Widget& Widget::operator=(const Widget& rhs)
{
    Bitmap* pOrig = pb;              // 记住原先的pb
    pb = new Bitmap(*rhs.pb);        // 令pb指向*pb的一个复件
    delete pOrig;                    // 删除原先的pb
    return *this;
}

如果你很关心效率,可以把“证同测试”再次放回函数起始处。然而这样做之前先问问自己,你估计自我赋值的发生频率有多高?因为这项测试也需要成本。它会使代码变大一些(包括原始代码和目标代码)并导入一个新的控制流分支,而两者都会降低执行速度。Prefetching'/caching、pipeline等指令都会受到影响。

在operator=函数内手工排布语句的一个替代方案是,使用所谓的copy and swap技术。

class Widget
{
...
void swap(Widget& rhs);
...
};

Widget& Widget::operator=(const Widget& rhs)
{
    Widget temp(rhs);
    swap(temp);
    return *this;
}

Widget& Widget::operator=(Widget rhs)
{
    swap(rhs);
    return *this;
}

对于传值的做法,我认为它为了伶俐巧妙的修补而牺牲了清晰性,然而将”copying动作“从函数本体内移至”函数参数构造阶段”却可令编译器生成更高效的代码。

请记住:

确保当前对象自我赋值时operator=有良好的行为。其中技术包括比较“来源对象”和“目标对象”的地址、精心周到的语句顺序、以及copy-and-swap。

确定任何函数如果操作一个以上的对象,而其中多个对象是同一个对象时,其行为仍然正确。

条款12: 赋值对象时勿忘其每一个成分

考虑一个class用来表现顾客,使得外界对它们的调用会被log下来;

void logCall(const std::string& funcName);

class Customer
{
public:
    ...
    Customer(const Customer& rhs);
    Customer& operator=(const Customer& rhs);
    ...
private:
    std::string name;
};


Customer::Customer(const Customer& rhs)
:name(rhs.name)
{
    logCall("Customer copy constructor");
}

Customer& Customer::operator=(const Customer& rhs)
{
    logCall("Customer copy assignment operator");
    name = rhs.name;
    return *this;
}

这一切都很好,直到另一个变量加入战局。

class Data {...};

class Customer
{
public:
    ...
    Customer(const Customer& rhs);
    Customer& operator=(const Customer& rhs);
    ...
private:
    std::string name;
    Data lastTansaction;
};

如果你写的代码不完全,编译器也不会告诉你。结论很明显:如果你为class添加了一个成员变量,你必须同时修改coping函数。(constructor和operator)

一旦发生继承,可能会造成此一主题最暗中肆虐的一个潜藏的危机,如下代码;

class PriorityCustomer : public Customer
{
public:
    ...
    PriorityCustomer(const PriorityCustomer& rhs);
    PriorityCustomer& operator=(const PriorityCustomer& rhs);

private:
    int priority;
};

PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
:priority(rhs.priority)
{
    logCall("PriorityCustomer copy construct");
}

PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
    logCall("PriorityCustomer capy assignment operator");
    priority = rhs.priority;
    return *this;
}

PriorityCustomer的copy构造函数并没有指定实参传给其base class构造函数。因此PriorityCustomer对象的Customer成分会被不带实参之Customer构造函数初始化。default构造函数将针对name和lastTansaction执行缺省的初始化动作。

任何时候只要你承担起为derived class撰写coping函数的重任时,必须得小心地复制其base成分。那些成分往往是private,所以你无法直接访问它们,你应该让derived class的coping函数调用相应的base class函数。

PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
: Customer(rhs), priority(rhs.priority)
{
    logCall("PriorityCustomer copy construct");
}

PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
    logCall("PriorityCustomer capy assignment operator");
    Customer::operator=(rhs);
    priority = rhs.priority;
    return *this;
}

当你编写一个coping函数,请确保(1)复制所有local成员变量,(2)调用所有base class内适当的coping函数。

零copy assignment操作符调用copy构造函数是不合理的,因为这就像试图构造一个已经存在的对象。这件事是很荒谬的。

令copy构造函数调用copy assignment操作符--同样无意义。构造函数用来初始化新对象,而assignmnet操作符只施行于已初始化对象身上。对一个尚未构造好的对象赋值,就好像在一个尚未初始化的对象身上做“只对已初始化对象才有意义”的事一样,无聊。

如果你发现你的copy构造和copy assignment操作符有着相近的代码、消除重复代码的做法是,建立一个新的成员函数给两者调用。这样的函数往往是private而且常被命名为init。

请记住:

Coping函数应该确保复制“对象内的所有成员变量”及”所有base class成分”。

不要尝试以某个coping函数实现另一个coping函数。应当将相同的机能放进第三个函数中。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值