前言

本文是阅读《Effective C++ 改善程序与设计的55个具体做法(第三版)》的心得笔记,文章也会按照原书的顺序依次记录各个条款。

让自己习惯C++

条款01:视C++为一个语言联邦

C++在发展中逐渐成为多种次语言的集合:

  • C语言:C++的基础,区块、语句、预处理器、内置数据类型、数组和指针等特性都来自C;
  • Object-Oriented C++:面向对象编程的特性,主要包括类、封装、继承、多态和virtual函数(动态绑定)等;
  • Template C++:泛型编程(generic programing)的特性,带来了模板元编程(template metaprogramming,TMP)
  • STL(Standard Template Library,标准模板库):template程序库,其封装了容器(containers)、迭代器(iterators)、算法(algorithms)和函数对象(function objects)等

条款02:尽量以const,enum,inline替换 #define

除了担任控制编译外,尽量减少在c++源代码中使用#define。即尽量以编译器替换预处理器,好处有以下几点:

  • #define定义的宏名称会在编译预处理时被替换掉,因此该宏名也不会记入符号表(symbol table),在编译出错时,调试起来可能会让你感到困惑;
  • 使用const常量代替#define宏可以减少编译的code size,因为const常量只有一份,而宏在替换后有多份;
  • #define并不重视作用域(scope),一旦宏被定义,就在其后的编译过程中有效,除非在某处被#undef,因此#define不具备封装性;
  • #define定义的宏函数,要格外注意定义格式和调用格式,要为每个实参都加上小括号,即便如此,因为是将实参进行无脑的替换,在有些情况下还是有可能出错的,如:
#define  CALL_WITH_MAX(a,b)   f((a)>(b) ? (a) :(b))
int a = 5, b = 0;
CALL_WITH_MAX(++a,b); // a累加二次
CALL_WITH_MAX(++a,b+10); // a累加一次

​ 一个好的替代方式就是使用template inline函数,如下:

template<typename T>
inline void callWithMax(const T& a, const T& b){
f(a > b ? a : b);
}

​ 好处也是显而易见,不必在函数体内为参数加上括号,不必担心上述情况的多次计算问题,函数本身的作用域和封装性等优点也是#define所不具有的。

条款03:尽可能使用const

const给被修饰的对象施加一个语义约束,该约束告诉编译器和程序员被修饰的对象应当是保持不变的。编译器会强制执行该约束,对不满足该约束的操作会报Error,这可以帮助程序员在编译期间就发现一些隐藏的bug。如果某个值确实应当是不变的,那么你就应当尽可能的使用const。

const 与 指针

关于const指针的用法,遵循以下规则:与类型位置无关,仅看const与星号的相对位置。如果const在星号左侧,表示指针所指内容为常量;如果const在星号右侧,表示指针本身是常量;如果出现在星号两侧,表示被指内容和指针都是常量。

const 与 STL迭代器

关于const STL迭代器的用法,如下:

std::vector<int> vec;
const std::vector<int>::iterator iter = vec.begin(); // 迭代器本身是常量,其所指内容可变
*iter = 10; // 正确
iter++; // 错误
std::vector<int>::const_iterator cIter = vec.begin(); // 迭代器所指内容为常量,迭代器可变
*cIter = 10; // 错误
cIter++; // 正确

const 与 函数返回值

令函数返回一个常数值,往往可以降低因客户错误而造成的意外,而又不至于放弃安全性和高效性。如:

class Rational { ... };
const Rational operator* (const Rational& lhs, const Rational& rhs);

// 上述函数返回常数值,当遇到下面这种弱智问题是会直接编译报错,而不是在运行期再发现
Rational a, b, c;
if (a * b = c){ // 本意想打 ==
...
}
// 如果a 和 b 是内置数据类型如int,这里肯定也是直接编译报错的,
// 所以好的用户自定义类型应当在各种行为上与内置数据类型兼容。

const 与 函数入参

如上所述,把理应不变的入参约束为const,可以尽早的在编译期就发现问题。

const 成员函数

有以下几个规则:

  • const 成员函数不可以修改对象内容,即可以用来规定类的哪些方法是具有只读属性的,比如一些Get方法;

  • const 成员函数为操作const对象提供手段。怎么理解呢?const对象本身应当是不变的,这时const对象调用了一个非const成员函数就显得很奇怪,因为默认非const成员函数是可以修改对象内容的。所以,如果对象是常量,则只能调用其const 成员函数

  • 两个成员函数如果只是常量性不同,也是可以被重载的。

  • 但其实const 成员函数并不能严格的检测到不可以修改对象内容的行为,比如一些Get方法返回了成员的非const引用时,只读属性就会失控。如下:

class CTextBlock {
public:
CTextBlock(const char* text) {
pText = (char*)malloc(128);
if (pText && strlen(text) + 1 <= 128) {
memset(pText, 0, 128);
memcpy(pText, text, strlen(text));
}
}
char& operator[](std::size_t position) const {
return pText[position];
}
private:
char* pText;
};
int main() {
const CTextBlock cctb("Hello");
std::cout << cctb[0]; // 输出 H
char c = cctb[0];
c = 'J';
std::cout << cctb[0]; // 输出 H,虽然返回的是引用,但赋值操作后已经是深拷贝了
cctb[0] = 'J';
std::cout << cctb[0]; // 输出 J,直接对引用操作
char* pc = &cctb[0];
*pc = 'K';
std::cout << cctb[0]; // 输出 K,用指针指向引用的地址

return 0;
}
  • 如果真的需要在const 成员函数中修改对象的成员(当然你也知道这些修改操作是合理的)的时候,可以给需要修改的成员加上mutable(可变的)关键字,mutable会释放掉非static成员变量的常量性约束。

如何避免const 成员函数重载导致的代码重复

如前所述,两个成员函数如果只是常量性不同,也是可以被重载的。即便函数体内的操作是相同的,你也需要为此重复两次,如下:

class TextBlock{
public:
...
const char& operator[](std::size_t position) const {
... // 边界检查
... // log数据访问
... // 检查数据完整性
return text[position];
}
char& operator[](std::size_t position) {
... // 边界检查
... // log数据访问
... // 检查数据完整性
return text[position];
}
private:
std::string text;
};

这显然是不太合理的,尤其是在函数体比较长的时候,显得非常蠢(再对函数体封装一层,然后调用也不够优雅),那如何规避呢?可以通过让非const成员函数调用其同名(重载)的const成员函数的方式来解决。如下:

class TextBlock{
public:
...
const char& operator[](std::size_t position) const {
... // 边界检查
... // log数据访问
... // 检查数据完整性
return text[position];
}
char& operator[](std::size_t position) {
return const_cast<char&>(static_cast<const TextBlock&>(*this)[position]);
}
private:
std::string text;
};

需要两次转型动作,static_cast*this对象转成const对象,以使其能调用const 成员函数,返回的const char&返回值再通过const_cast移除const。

条款04:确定对象被使用前已先被初始化

对无任何成员的内置数据类型,手动完成初始化;对类而言,初始化的责任落在了构造函数,规则也简单,就是确保每个构造函数都对每一个成员完成初始化。

注意区别赋值和初始化

在构造函数的函数体内的用=为成员赋值的操作并不是初始化。初始化动作发生在进入函数体之前,若成员也为非内置类型,则它们的初始化动作发生的更早,在这些成员的default构造函数被自动调用之时。因此,这种构造函数的写法虽然也能让成员被置上期望的值,但效率不高。default构造所做的工作是被浪费掉的。推荐使用成员初始化列表的方式进行初始化,可以避免这一问题。

为了避免遗漏一些成员导致其并未被初始化的情况,规定总是在初值列表中列出所有成员变量及基类(但个人习惯是在声明时已经给了初值(C++11支持)的成员变量,就不再初始化列表中再次列出了)。

如果成员变量是const或者references,则必须通过成员初始化列表的方式进行初始化。

成员初始化次序

  • 基类总是比派生类先被初始化;
  • 成员变量总是以其声明次序(而不是初始化列表中的顺序)被初始化,但最好让两者保持一致,避免不必要的误解;

不同编译单元内的non-local static 对象的初始化次序

static 对象的寿命在其被构造出来直到程序结束为止,也就是说它们的析构函数会在main()结束时才被自动调用。static 对象包括:

  • local static 对象(局部静态变量):函数内的定义的static 对象,其作用域仅限在函数内,故为local;
  • non-local static 对象:包括global static 对象,定义于namespace作用域,class作用域,file作用域内的static 对象;

而所谓的编译单元(translation unit)是只产出单一目标文件(single object file)的源码集合。通常就是单一的源文件加上其#include包含的头文件。

这里的问题是对于定义在不同的编译单元内non-local static 对象,C++是无法保证其初始化的顺序的。所以如果某个编译单元内的某个non-local static 对象的初始化动作依赖于另一编译单元内的某个non-local static 对象,就可能会出问题。

解决的办法就是:将每个non-local static 对象都移动到自己的专属函数中(non-local static变 local static),然后让这些函数返回local static 对象的引用。然后让用户调用函数来使用这个local static 对象,而不是直接访问non-local static 对象。其实这也是单子(Singleton)模式的一种常见实现方法。

C++可以保证,函数内的local static 对象会在该函数被调用期间首次遇到该对象的定义式时被初始化。这不仅可以解决初始化次序的问题,还可以在该函数没有被调用时,节约掉对象的构造和析构的成本。bravo!

但是,即便使用上述方法,在多线程环境下,还是会有不确定性。一个解决办法就是:在程序的单线程启动阶段,手动调用所有的reference-returning 函数,以消除与初始化有关的竞速问题(race conditions)。

构造/析构/赋值运算

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

如果自己没有声明构造函数析构函数copy构造函数copy赋值操作符,那么编译器会为你声明default版本的,且这些函数都是public且inline的。这些default函数只有在其需要被调用时,才会被创建出来。

其中关于default构造函数default析构函数,其作用主要是为编译器提供一个地方来放置“幕后”代码,如调用基类或者非static成员变量的构造函数和析构函数。此外,default析构函数通常是非virtual的,除非该类的基类有声明virtual析构函数,这样该类的析构函数会继承基类的虚属性。

对于defaultcopy构造函数copy赋值操作符,只是单纯地将源对象的每一个非static成员变量拷贝到目标对象。但只有当拷贝动作合法时,编译器才会这样做。

比如,对于一个含有引用成员或者const成员的类,单纯的拷贝操作显然不合法,因此编译器为赋值动作生成默认的copy赋值操作符,需要你自己声明并定义它。

再比如,某个基类将copy赋值操作符声明为private,那么编译器就会拒绝为其派生类生成default的copy赋值操作符。因为从逻辑上去推,派生类的copy赋值操作符是要负责处理基类成员的赋值动作的,而基类成员大概率是private的,这时就应当通过调用基类的copy赋值操作符来完成,而派生类是无权访问基类的private成员函数的。这时编译器只能怒摔啤酒,大喊一声“焯”!(doge)

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

就比如有些时候,我们不希望对象可以被复制,即希望无法调用对象的copy构造函数和copy赋值操作符。这里有两种实现方式:

  • 可以将copy构造函数和copy赋值操作符声明为private,且不去定义它们(只声明不实现)。这样,除了类自己的成员函数或者友元函数,其他人是没有权限访问到它们的。即便是成员函数或者友元函数试图去调用,也会因为没有具体的实现而出现来链接错误。
  • 上面方法的确定就是等到链接期间再发现错误也许太晚了,尤其是对于体量很大的项目而言。下面这种方法就可以将报错提前到编译期,即设计一个Uncopyable的基类:
class Uncopyable {
protected:
Uncopyable(){} // 允许derived对象构造和析构
~Uncopyable(){}
private:
Uncopyable(const Uncopyable&); // 但禁止拷贝
Uncopyable& operator=(const Uncopyable&);
};

class A: private Uncopyable{
...
};

条款05中描述,此时编译器会拒绝为派生类A生成default的copy构造函数和copy赋值操作符,从而避免了不和预期的复制动作的发生。

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

当用基类指针指向派生类对象,此时若基类的析构函数不是virtual的,则调用delete去析构时,只会调用基类的析构函数,派生的析构函数不会被调用,若派生类析构函数中存在释放资源的操作,则会导致内存泄漏。这与多态的实现原理动态绑定有关,只有virtual函数是动态绑定的,具体可以看这里

总而言之,用于多态目的的基类的析构函数一定要使用虚析构函数。用于多态目的的基类,通常除析构函数外,还有其他virtual成员函数。但也不是所有基类都是用于多态目的的,比如上面条款06中提到的Uncopyable类,因此它们不需要虚析构函数。

但反过来,有virtual函数但不做为基类的类通常都是没有(设计)意义的,而且在代码空间上也是浪费的。virtual函数的动态绑定是通过vptr(virtual table pointer,虚表指针)指向vtbl(virtual table,虚表)中的函数指针(函数入口地址)在运行时决定的。维护虚表指针和虚表自然需要额外的空间,对于不需要实现多态而言的类而言,这都是不必要的浪费。

最好不要尝试继承任何STL容器,因为STL容器的析构函数是non-virtual的。

原文中有提到,C++没有提供类似Java的final关键字,现在C++11已经提供了,用来禁用类的继承。

如果想构造一个抽象类(含有纯虚函数的类,不能被实体化)的基类,但没有合适的作为纯虚函数的成员函数,可以将析构函数声明为一个纯虚析构函数。但是和一般的纯虚函数不同,纯虚析构函数需要提供函数实现(函数体为空),即便抽象类没有实体,但其派生类的析构函数会尝试调用基类的纯虚析构函数,如果不提供实现,就会使得析构过程出现异常,表现为链接时报错。一个示例如下:

class A{
public:
A(){}
virtual ~A()=0;
};
A::~A(){}

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

最好不要在析构函数中抛出(throw)异常,这可能会导致程序过早结束或出现不明确行为。理由如下:

  • 如果析构函数抛出异常,则异常点之后的程序不会执行(严格的说是try代码块中异常点之后的程序在异常抛出后不会执行,catch代码块以及try-catch之后的代码还会继续执行),如果析构函数在异常点之后执行了某些必要的动作比如释放某些资源,则这些动作不会执行,会造成诸如资源泄漏的问题。
  • 通常异常发生时,c++的机制会调用已经构造对象的析构函数来释放资源,此时若析构函数本身也抛出异常,则前一个异常尚未处理,又有新的异常,会造成程序崩溃的问题。

但如果析构函数中需要执行一个动作,而这个动作可能会抛出异常,该怎么办?

那就是把异常完全封装在析构函数内部,决不让异常抛出函数之外。这是一种非常简单,也非常有效的方法。

看一下书中的例子:

// 一个负责数据库连接的类
class DBConnection {
public:
...
static DBConnection create(); // 这个函数返回DBConnection对象
void close(); // 这个函数会关闭连接,如果关闭失败则抛出异常
};

// 创建一个用来管理DBConnection对象资源的类,在其析构函数中调用close
class DBConn {
public:
...
~DBConn() { // 初衷是在DBConn生命周期结束时,自动的关闭连接
db.close();
}
private:
DBConnection db;
};

// 客户调用
{
DBConn dbc(DBConnection::create()); // 创建一个DBConnection对象并交给DBConn对象管理
... // 通过DBConn的接口使用DBConnection对象
} // 在代码块结束点,DBConn对象被销毁时,自动为DBConnection对象调用close

在上述代码中,DBConn类的析构函数中的db.close()可能会抛出异常,但没有做任何对异常的处理,因此会导致异常的传播,即允许异常逃离这个析构函数。有两种方法可以避免异常的传播:

// 异常处理方式1:记录并中止
DBConn::~DBConn(){
try { db.close(); }
catch(...) {
记录异常情况;
std::abort();
}
}
// 异常处理方式2:仅记录,程序继续执行
DBConn::~DBConn(){
try { db.close(); }
catch(...) {
记录异常情况;
}
}

选择哪一种取决于你对该异常可能造成的恶劣影响的判断。但无论选择哪一种,对于客户而言,都是不够友好的,因为客户无法对异常做出响应。因此一个更好的策略就是DBCoon类也提供一个close函数供客户使用,给客户一个机会自己处理异常。同时在析构函数中也对DBConnection做一个双保险的close处理,当客户不准备或者忘记自己关闭连接时,则可以依赖析构函数自动的关闭连接。但是如果再发生因为抛出异常未被响应而导致的问题,我们就不用背锅了。

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

~DBConn() {
if (!closed) {
try { db.close(); } // 客户未关闭连接时
catch (...) {
记录异常情况;
... // 中止或放行
}
}
}
private:
DBConnection db;
bool closed;
};

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

先说构造过程:首先,在构造派生类对象时,基类的构造函数会先于派生类的构造函数被执行。在派生类对象的基类构造期间,派生类的成员变量还未被初始化,此时对象的类型是基类的类型,而不是派生类的类型。这时,virtual函数,dynamic_cast等也是如此看到它的,那么基类构造函数中的virtual函数就会被编译器解析至(resolve to)基类,即动态绑定到基类。而你实现virtual函数的目的,大部分情况下,是希望其会基于派生类的不同而绑定到不同的派生类。这就与你的预期不符。然而C++这样的处理是合理的,因为派生类的函数大概率是会访问派生类自己的成员变量的,如果此时将virtual函数绑定到派生类,则就是允许对象使用未初始化完成的成员变量,这是危险的未定义行为,所以C++不允许这样做。

析构过程也同理,基类的析构函数在派生类析构函数后被执行。一旦派生类的析构函数开始执行,便可以认为对象中属于析构函数的成员变量已不可用,那就不能将这个对象再当作派生类的类型。所以进入基类的析构函数后,对象就会成为一个基类类型的对象,C++的任何部分包括virtual函数,dynamic_cast等等都是这么看到它的。

一个bad case如下:

class Transaction { // 股票交易的基类
public:
Transaction();
virtual void logTransation() const = 0; // 记录交易的创建,设计初衷是希望基于交易类型不同而记录不同的内容
...
};

Transaction::Transcation() { // 构造函数的实现
...
logTransation();
}

class BuyTransaction: public Transaction { // 买入交易的派生类
public:
virtual void logTransation() const; // 记录买入交易
...
};

class SellTransation: public Transaction {
public:
irtual void logTransation() const; // 记录卖出交易
...
};

上述代码中,基类的构造函数中直接调用了一个virtual函数(会被认为是基类的函数),这在一些编译器中可能会给出warning,即便不给出,由于logTransation是纯虚函数,且没有具体的实现,则在链接时也会因为找不到具体的实现而报错。

而如果将logTransation函数隐藏在一个init的私有函数中,就有可能骗过编译器和链接器,如下:

class Transaction { // 股票交易的基类
public:
Transaction() {
init(); // 构造函数中调用非虚函数
}
virtual void logTransation() const = 0;
...
private:
void init() {
...
logTransation(); // 但是这里还是调用了virtual函数
}
};

这时,虽然能编译通过,但在运行时动态绑定仍然将logTransation视为基类的纯虚函数,此时程序大概率会中止运行并报错。

但是如果在此基础上,logTransation函数改成正常的虚函数,并给出实现。那么程序就能“正常”运行,只不过你看到的日志记录结果并不正常,对象创建时并没有按照你预期的,调用派生类的logTransation函数实现,而是调用了基类的logTransation函数实现。

那么上述例子正确的写法如下:

class Transaction { // 股票交易的基类
public:
explicit Transaction(const std::string& logInfo);
void logTransation(const std::string& logInfo) const; // 非虚函数
...
};

Transaction::Transcation(const std::string& logInfo) { // 构造函数的实现
...
logTransation(logInfo); // 现在是调用非虚函数
}

class BuyTransaction: public Transaction { // 买入交易的派生类
public:
BuyTransaction(parameters)
: Transaction(createLogString(parameters)){ // 将log信息传递给基类的构造函数
...
}
...
private:
static std::string createLogString(parameters);
};

即用将派生类必要的构造信息向上传递基类的构造函数来弥补无法将virtual函数在构造函数向下调用

值得注意的是,上例中用的createLogString函数是static的,这也限定了该函数只能访问那些已经在类外初始化过的static成员变量,而不能访问在构造函数被调用前(createLogString被调用的时机)还处在未定义状态的成员变量。

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

赋值采用右结合律,因此一个连续赋值x = y = z = 15;会被解析为x = (y = (z = 15));,即将15赋值给z,然后将z赋值给y,然后将y赋值给x。为了让自定义的类的赋值操作符operator=也能实现连续赋值,则必须返回一个reference to *this,这个规则(虽非强制,但被所有内置类型和STL类型共同遵守,所以没有好的理由就不要标新立异了)对所有涉及赋值的运算符,如+=-=*=等都适用。

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

虽然自我赋值看起来有点蠢,但是它是合法的,而且很多时候,自我赋值是隐藏在数组的循环遍历赋值(a[i] = a[j])或者对象的指针之间的赋值操作(*px = *py)中。

当自我赋值确有可能出现时,则要关注operator=的实现是否有资源管理的陷阱。一个bad case如下:

class Bitmap {...};
class Widget {
public:
Widget& operator=(const Widget& rhs);
private:
Bitmap* pb; // Bitmap类对象的指针,指向一个从堆中分配的对象
};

// 一个不安全的operator=的实现版本
Widget& Widget::operator=(const Widget& rhs){
delete pb; // 销毁当前对象的Bitmap对象成员
pb = new Bitmap(*rhs.pb); // 使用rhs的Bitmap对象成员拷贝构造新的副本
return *this; // 见条款10
}

在上面的operator=的实现中,当出现自我赋值时(即rhs*this是同一对象),便会导致pb指针指向了一个已经被销毁的对象。为解决这个问题,一个传统方法就是增加认同测试(如果自我赋值的概率很小,则会降低代码执行效率,当然如果自我赋值概率很大,则会提高代码执行效率),如下:

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

delete pb; // 销毁当前对象的Bitmap对象成员
pb = new Bitmap(*rhs.pb); // 使用rhs的Bitmap对象成员拷贝构造新的副本
return *this; // 见条款10
}

但是这样做只能解决自我赋值安全性问题,不能解决异常安全性问题,即当new Bitmap操作因内存不足或Bitmap的拷贝构造函数抛出异常等原因而出现异常时,pb指针仍然是指向一个被销毁的对象。可以通过如下的方式满足异常安全性:

Widget& Widget::operator=(const Widget& rhs){
Bitmap* pOrig = pb; // 记住原来的pb
pb = new Bitmap(*rhs.pb); // 使用rhs的Bitmap对象成员拷贝构造新的副本
delete pOrig; // 销毁原来的pb
return *this; // 见条款10
}

现在当new Bitmap操作抛出异常时,后续代码将不会被执行,因而pb及其所在的Widget对象将维持原状。同时因为是先构建副本,再销毁原对象,所以上述实现也具备自我赋值的安全性。同样的,关于效率问题的考虑,则取决于自我赋值发生的概率,若概率较高,则可以考虑加上认同测试。

上述实现的一个标准替代方案(常见而且够好的operator=的写法)就是copy and swap技术(详见条款29)。这是一个强调异常安全性的技术,其关键在于修改对象数据的副本,然后在一个不抛异常的函数中将修改的数据和原件置换

void Widget::Swap(MapWidget& rhs) {
std::swap(rhs.pb,pb);
}
Widget& Widget::operator=(const Widget& rhs) {
Widget temp(rhs); // 构造副本,若rhs为传值而不是传引用,实际已经是原对象的副本,这一句可以省略
Swap(temp);
return *this;
}

条款12:复制对象时勿忘其每一个成分

当你决定自己实现copy函数(包括拷贝构造函数和拷贝赋值操作符),而不是让编译器生成default版本(其会将被拷贝对象的所有成员都做一份拷贝),那么你就接过了这一重任,所以要确保:

  • 拷贝所有local成员变量:若你遗漏了某些成员的拷贝,编译器也不会提示你,这可能会埋下一些隐患。当你新增了类的成员时,一定要想着同时也要修改copy函数;
  • 派生类的copy函数要调用所有基类的相应的copy函数,一个实现如下:
class Customer {
public:
Customer(std::string name, float money): name_(name), money_(money) {}
private:
std::string name_;
float money_;
};

class VipCustomer: public Customer {
public:
using Customer::Customer; // 继承构造函数,C++11
VipCustomer(const VipCustomer& rhs)
:priority_(rhs.priority_), Customer(rhs) {} // 调用基类的拷贝构造函数
VipCustomer& operator=(const VipCustomer& rhs) {
Customer::operator=(rhs); // 调用基类的copy赋值操作符
priority_ = rhs.priority_;
return *this;
}
void setPriority(int priority) { priority_ = priority; };
private:
int priority_; // 上面使用了继承构造函数,则子类独有成员可以通过单独set函数赋值
};

  • 不要为了避免代码重复,而让copy构造函数调用copy赋值操作符,反之也不行。合理的消除重复代码的做法是,重新封装一个private类型的init函数供两者调用。