有人说C++程序员可以分为两类,读过《Effective C++》的和没读过的。无论此书是否对得起这样的称呼,毫无疑问的是当我阅读完世界C++大师Scott Meyers成名之作之后,收获之丰,难以言表。无论是从objected-oriented方面、template方面还是内存管理方面,对我的整个C++观的塑造和改变都是无与伦比的。
在国际上,本书所引起的反响,波及整个计算机技术的出版领域,余音至今未绝。几乎在所有C++书籍的推荐名单上,《Effective C++》都会位居前列。作者高超的技术把握力、独特的视角、独具匠心的内容组织,都受到极大的推崇和仿效。
本书不是读完一遍就可以束之高阁的快餐读物,也不是用以解决手边问题的参考手册,而是需要您去反复阅读体会的。因此,我选择以此博客来记录对本书第一遍阅读后的理解,供日后参考。相信每次阅读都会有不一样的感悟。毫无疑问的是,本书一定会陪伴我整个cpp生涯。
分享一句书中的话:真正称得上库程序者,必然稳健强固。
一、让自己习惯C++
1、视C++为一个联邦语言
一开始C++只是C加上一些面向对象特性。C++最初的名称是C With CLasses。今天的C++已经是个多重泛型编程语言,一个支持过程形式、面向对象形式、函数形式、泛型形式、元编程形式的语言。将C++视为一个由相关语言组成的联邦而非单一语言有助于理解该语言:
- C part of C++;
- Object-Oriented C++;
- Template C++;
- STL。
无论如何请记住:C++高效编程守则视状况而变化,取决于你使用C++的哪个部分。
2、尽量以const、enum、inline替换#define
当我们使用#define PI 3.14159265
语句进行宏替换时,记号名称PI
可能也许从未被编译器看见。于是当你运用此常量但获得一个编译错误信息时,可能会因为错误信息提到的是3.14159265而不是PI
而感到困惑。解决办法就是以一个常量替换上述宏(#define):
const double PI = 3.14159265;
值得注意的是如果要定义一个常量的char *
-based字符串,必须写const
两次:
const char * const authorName = "Scott Meyers";
当然使用string
对象通常比使用char *
-based更合时宜:
const std::string authorName("Scott Meyers");
第二个值得注意的是class专属常量:为确保常量的作用域限制于class内且至多只有一份实体,必须让它成为一个static
成员:
class GamePlayer {
private:
static const int NumTurns = 5;
......
}
//旧式编译器也许不支持上述语法,就需要将NumTurns在类外定义:
const double GamePlayer::NumTurns = 5;
当编译器不支持在类内初始化static const
类型的变量而你必须使用这个变量时(例如用该变量初始化数组),可以使用enum hack技术代替static const
方法:
class GamePlayer {
private:
enum { NumTurns = 5 };
int scores[NumTurns];
......
}
使用enum hack好处如下:
- 行为更似#define而非const(因为const可以获取指针,而enum hack无法获取指针);
- 许多代码使用此技术,当看到该用法时需要认识。enum hack是模板元编程的基础。
另一个常见的#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);
}
总结:
- const的好处在可以追踪报错及作用域控制,但能够被选取地址;
- enum hack不仅可以追踪报错及作用域控制,还不可被选取地址;
- inline不仅可以拥有宏函数的效率,还不会出现意想不到的错误。
3、尽可能使用const
const
语法虽然变化多端,但并不高深莫测。如果关键字const
出现在星号左边,表示被指物是常量;如果出现在星号右边,表示指针自身是常量;如果出现在星号两边,表示被指物和指针两者都是常量。
如果希望迭代器所指的东西不可被改动(即希望STL迭代器模拟一个const T*
指针),你需要的是const_iterator
。在C++11标准之下,可以使用cbegin()
和cend()
函数代替传统的begin()
和end()
函数直接返回const_iterator
迭代器,而非iterator
。
令函数返回一个常量值,往往可以降低因客户错误而造成的意外,而又不至于翻译器安全性和高效性。举个例子:
class Rational {......};
const Rational operator* (const Rational& lhs, const Rational& rhs);
int main() {
......
if (a * b = c) //其实只是想做一个比较(==)动作,但是少键入了一个=
......
}
将operator*
的回传值声明为const
可以预防那个“没意思的赋值动作”。至于const
参数,除非你有需要改动参数或local对象,否则请将他们声明为const
。
在成员函数后加const
,const
会修饰this
指针指向的对象,这就保证调用这个const
成员函数的对象在内部不会发生改变。非const
对象调用非const
成员函数;const
对象调用const
成员函数。
在哲学界(手动滑稽),有两个流行概念:bitwise constness(又称physical constness)和logical constness。bitwise constness阵营的人相信,成员函数只有在不更改对象的任何成员变量(static除外)时才可以说是const
。也就是说它不更改对象内的任何一个bit。
C++选择bitwise constness定义常量性。因此,const
成员函数内,不可改变该对象的任何non-static成员变量。但这种方式存在缺陷:如果成员变量为指针类型,const
成员函数只能保证指针的值不变,不能去报指针指向的内容不发生改变。
所谓的logical constness,这一派主张一个const
成员函数可以修改它所处理的对象内的某些bits,但只有在客户端侦测不出的情况下才得如此。
有些数据(例如一个bool类型的flag标志诸如此类)的修改对用户而言可接受,但是编译器坚持bitwise constness。在这种情况下就需要使用mutable
关键字释放non-static成员变量的bitwise constness约束!
class CTextBlock {
private:
mutable std::size_t textLength;
mutable bool LengthIsValid;
......
}
上面的这些成员变量可以被改变,即使实在cosnt成员函数内。
如果一个类内的const成员函数和与之对应的非const类型的成员函数,所实现的功能近似相同,为了减少编译时间,避免代码膨胀等问题,尽可能使用const
版本的函数实现non-const
版本的函数!
class TextBlock {
public:
const char& operator[] (std::size_t position) const {
......
}
char& operator[] (std::size_t position) {
//将op[]返回值的const转除,为*this加上const从而调用const op[]
return const_cast<char&>(static_cast<const TextBlock&>(*this)[position]);
}
}
4、确定对象被使用前已先被初始化
永远在使用对象前现将它初始化。对于任何无成员的内置类型,你必须手工完成此事。至于内置类型以外的任何其他东西,初始责任落在构造函数身上,规则很简单:确保每一个构造函数都将对象的每一个人员初始化。这个规则很容易奉行,重要的是别混淆了赋值(assignment)和初始化(initialization)!
class PhoneNumber {......};
class ABEntry { //Address Book Entry
private:
std::string theNames;
std::string TheAddress;
std::list<PhoneNumber> thePhones;
int numTimesConsulted;
public:
ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& phones){
theName = name; //这些都是赋值而非初始化
theAddress = address;
thePhones = phones;
numTimesConsulted = 0;
}
}
C++规定,对象的成员变量的初始化动作发生在进入构造函数本体之前。初始化发生的时间更早,发生于这些成员的default构造函数
被自动调用之时(比进入ABEntry构造函数
本体的时间更早)。构造函数的一个较佳写法是使用所谓的member initialization list(成员初始化列表)替换赋值动作。
ABEntry::ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& phones)
:theName(name),
theAddress(address),
thePhones(phones),
numTimesConsulted(0)
{ }
使用构造函数的初始化列表比在构造函数内赋值更佳且效率更高。因为给予赋值的那种方式会先调用default构造函数
为theName, theAddress和thePhones设置初始值,然后再在执行ABEntry构造函数
时,对这些变量赋新值。那么这些变量第一次调用的default构造函数
所做的一切都浪费了,使用成员初始化列表就可以避免这个问题。对于大多数类型而言,比起先调用default构造函数
再调用copy assignment操作符,单只调用一次copy构造函数
是比较高效的,有时甚至高效的多。对于内置类型如numTimesConsulted,其初始化和赋值的成本相同,但为了一致性,最好也通过成员初始化列表来初始化。如果成员变量是const
或是references
,它们就一定需要初值,不能被赋值。为避免需要记住成员变量何时必须在成员初始化列表中初始化,何时不需要,最简单的方法就是:总是使用成员初始化列表。
C++有着十分固定的“成员初始化次序”:base classes更早于其derived classes被初始化,二class的成员变量总是以 其声明次序被初始化,即使它们在成员初始化列表中以不同次序出现,也不会有任何影响。因此,在成员初始化列表中,最好总是以其声明次序为次序!
如果你对上面内容已经熟稔于心,那么我们还需要了解:“不同编译单元内定义的non-local static
对象”的初始化次序。
函数内的static
对象成为local static
对象,其他static
对象成为non-local static
对象。程序结束时static
对象会被自动销毁,也就是它们的析构函数会在main()
结束时被自动调用。
所谓编译单元基本上等同于*.cpp
加上*.h
(同一.cpp + .h文件)。
不同编译单元的non-local static
对象的初始化顺序并无明确定义,因此在上述对象在初始化过程中相互引用可能会出现问题——因为一个non-local static
对象用到的其他编译单元的non-local static
对象可能尚未被初始化。
幸运的是一个小设计便可消除这个问题:将每个non-local static
对象搬到自己的专属函数内,这些函数返回一个reference
指向其所含对象,然后用户调用这些函数,而不是直接指涉这些对象。换言之,non-local static
对象被local static
对象替换了,这是Singleton
模式的一个常见实现手法。
Directory& tempDir() {
static Directory td; //第一次调用该函数才会执行该语句,之后调用函数也不会再重复执行。
return td;
}
这种做法还可以减少没使用过的static
对象的构造、析构开销。这种单纯的函数是成为inline
函数的绝佳人选,尤其是它们被频繁调用的话。担任为了防止多线程中的不确定性,最好在开线程前就调用此函数!
二、构造/析构/赋值运算
5、了解C++默默编写并调用哪些函数
C++编译器会自动生成:
- 默认构造函数;
- 拷贝构造函数;
- 析构函数;
- operator =(copy assignment)操作符。
只有上述函数被调用时才会被编译器创建。
default构造函数
和析构函数
主要是给编译器一个地方用来放置“藏身幕后”的代码,像是调用base classes和non-static成员变量的构造函数和析构函数。注意编译器产生的析构函数是个non-virtual的,除非这个class的base class自身声明有virtual析构函数。
至于copy构造函数和copy assignment操作符,编译器创建的版本只是单纯的将来源对象的每一个non-static成员变量拷贝到目标对象。
template<typename T>
class NamedObject {
private:
std::string nameValue;
T objectValue;
......
}
NameObject<int> no2(no1); //调用自动生成的coy构造函数
标准的string
有copy构造函数
,所以no2.nameValue
的初始化方式是调用string
的copy构造函数
并以no1.nameValue
为实参。另一个成员是int类型,那是个内置类型,所以no2.objectValue
会以“拷贝no1.objectValue
内的每一个bits”来完成初始化。
编译器自动生成的copy assignment操作符
,其行为基本上与copy构造函数
如出一辙,但是当含有引用成员变量,const
成员变量或是基类的copy assignment操作符
为private
类型时,编译器不会自动生成copy assignment操作符
。
6、若不想使用编译器自动生成的函数,就该明确拒绝
如果你不声明copy构造函数
或copy assignment操作符
,编译器可能为你产出一份,于是你的class支持copying操作。如果你声明它们,你的class还是支持copying操作,但是如果你的目的是阻止copying操作怎么办呢?
所有编译器产出的函数都是public
的。为阻止这些函数被创建出来,你得自行声明它们。将这些函数声明为private
,可以阻止编译器暗自创建其专属版本的同时,还能阻止别人调用它。一般而言这个做法并不绝对安全,因为member函数和friend函数还是可以调用你的private
函数。除非你只声明这些函数而不定义它们。
将函数声明为private
却不实现该函数即可达到阻止编译器自动创建且使用时报错的目的。
class HomeForSale {
public:
......
private:
......
HomeForSale(const HomeForSale&); //只有声明
HomeForSale& operator= (const HomeForSale&);
};
有了上述class定义,当客户企图拷贝HomeForSale
对象,编译器会阻挠他。但是如果你不慎在member函数或friend函数之内这么做,轮到的是连接器发出抱怨。将连接期的错误移至编译期是可能的,只要将copy构造函数
和copy assignment操作符
声明为private
就可以办到,但不是在HomeForSale
自身,而是在一个专门为了阻止copying动作而设计的base class内:
class Uncopyable {
protected:
Uncopyable() {}
~Uncopyable() {}
private:
Uncopyable(const Uncopyable&); //阻止copying
Uncopyable& operator= (const Uncopyable&);
};
// 为求阻止HomeForSale对象被拷贝,我们唯一需要做的就是继承Uncopyable
class HomeForSale: private Uncopyable {
// 不再声明copy构造函数或copy assignment操作符
......
};
7、为多态基类声明virtual析构函数
工厂函数内部都是通过动态申请的方式创建一个派生类的对象,因此该对象位于堆段。为了避免内存泄露,使用完后,必须将工厂函数返回的指针对象释放(可以通过智能指针shared_ptr
解决)。由一个含有非虚析构函数的基类指针销毁派生类对象时会出现派生类对象中派生类成分未被销毁的情况,因为派生类的析构函数很可能并未执行(具体是否执行完全随机)。消除这个问题的做法非常简单:给基类的析构函数声明为virtual析构函数
即可解决。
任何class只要带有virtual
函数都几乎确定应该也有一个virtual析构函数
。如果class不含virtual
函数,通常表示它并不意图被用做一个base class。当class不企图被当做base class,令析构函数为virtual
函数往往是个馊主意。凡是含有虚函数的class,都会增加一个指针vptr(virtual table pointer,虚表指针)用来指向一个由函数指针构成的数组vtbl(virtual table,虚函数表)。vptr指针长度为四个字节(具体大小跟编译器和计算机架构有关),而且放置于class的最前端内存处,同时存在字节对齐的问题。因此,使用虚函数会导致class占用的内存增加,可移植性降低。
如果不希望带non-virtual析构函数
的class被其他class继承,可以使用C++11引入的final
关键字,阻止继承。
将析构函数声明为纯虚函数可以一举两得:使当前类成为抽象类且不需要在提供额外的纯虚函数!但谨记,仍要为析构函数实现一份定义,否则派生类在调用基类析构函数时会因为基类没有定义析构函数而报错!
8、别让异常逃离析构函数
如果析构函数能够抛出异常,那么C++编译器在delet []
一组数据,调用一组析构函数时,就有可能同时抛出一个或一个以上的异常。但是对C++而言,在两个异常同时存在的情况下,程序若不是结束执行就是导致不明确行为。当然,容器或array并非遇上麻烦的必要条件,只要析构函数突出异常,即使并非使用容器或array,程序也可能过早结束或出现不明确行为。
因此,将类似数据库的close
的行为专门移交给一个成员函数并交给用户调用,让析构函数尽可能少做可能抛出异常的事情。给用户给自己调用close函数并不会增加负担,反而还提供了处理异常的机会。析构函数close只是双保险,但异常发生时必须退出程序或吞下异常。
9、绝不在构造和析构过程中调用virtual函数
你不该在构造函数和析构函数期间调用virtual
函数,因为这样的调用不会带来你预想的结果,就算有你也不会高兴!
由于base class构造函数的执行更早于derived class构造函数,当base class构造函数执行时derived class的成员变量尚未初始化。如果再次期间调virtual
函数下降至derived class阶层,要知道derived class的函数计划必然取用local成员变量,而那些成员变量尚未初始化。这将是一张通往不明确行为和彻夜调试大会的直通车票。
其实还有比上述理由更根本的原因:在derived class对象的base class构造期间,对象的类型是base class而不是derived class。不只virtual
函数会被编译器解析至base class,若使用运行期类型信息,也会把对象视为base class类型。
相同的道理也适用于析构函数。一旦derived class析构函数开始执行,对象内的derived class成员变量便呈现未定义值,所以C++视它们仿佛不再存在。进入base class析构函数后对象就成为一个base class 对象,而C++的任何部分包括virual
函数、dynamic_cast
s等等也就那么看待它。
在构造和析构期间不要调用vitual函数,因为这类调用从不下降至derived class!
10、令operator =返回一个reference to *this
关于复制,有趣的是你可以把它们携程连锁形式x = y = z = 15;
,赋值采用右结合律,所以上述连锁赋值被解析为x = (y = (z = 15));
。
为了实现“连锁赋值”,赋值操作符必须返回一个reference指向操作符的左侧实参。这是classes实现赋值操作符时一个遵循的协议:
class Widget {
public:
Widget & operator = (const Widget &rhs) {
......
return * this;
}
};
这个协议不仅适用于以上标准赋值形式,也适用于所有赋值相关运算符。
11、在operator=中处理“自我赋值”
持续更新中......
网友评论