Effective C++ 阅读笔记

发布于 2023-04-17  535 次阅读


条款1-4 让自己习惯C++

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

C,Object-Oriented C++,Template C++,STL 不同次语言间高效编程守则会发生改变

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

宁可以编译器替换预处理器

//不推荐,错误提示信息不够清晰,不方便debug
#define ASPECT_RATIO 1.653
//用常量替换宏,推荐
const double AspectRatio = 1.653

特殊情况:

1 定义常量指针

//双const,防止指针指向被更改,防止内容被更改
const char* const authorName = "Scott Meyers";
//string更适合这样的定义
const std::string authorName("Scott Meyers");

2 class专属常量

将常量的作用域(scope)限制于class内。

class GamePlayer{
private:
    static const int NumTurns; //常量声明式
    int scores[NumTurns];
}

对整数型class专属static常量只要调用时不取地址便可以这样声明,否则:

const int GamePlayer::NumTurns; //提供定义式,放在实现文件而非头文件

另外#define无法创建一个class专属常量,因为#define不重视作用域(scope),也不提供任何封装性

也可以将初值放在定义式

class CostEstimate{
private:
    static const double FudgeFactor; //static class 常量声明
};
const double CostEstimate::FudgeFactor = 1.35;

若编译器不支持static class常量完成初值设定,如int scores[NumTurns],可以使用“the enum hack”

class GamePlayer{
private:
    enum{ NumTurns = 5 };
    int scores[NumTurns];
}

宏,即#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);
}

条款03 尽可能使用const

const即指定语义约束,设置某值不可更改,便可以获得编译器的协助。

const出现在星号左侧,表示被指物为常量,右侧表示指针自身是常量。

char greeting[] = "Hello";
char* p = greeting;
const char* p = greeting; //non-const pointer,const data
char* const p = greeting; //const pointer,non-const data
const char* const p = greeting; // const pointer,const data

迭代器与const

const修饰迭代器表示迭代器不得指向别的东西,如果不希望改变迭代器指向的东西,应该使用const_iterator

const最具威力的用法是面对函数声明时的应用,在一个函数声明式内,const可以和函数返回值、各参数、函数自身(如果是成员函数)产生关联。

对函数返回值设置为常值可以降低客户错误而造成的意外又不至于放弃安全性和高效性。

const成员函数

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

class TextBlock{
public:
    const char& operator[](std::size_t position) const{...}; //后一个const才是声明const成员函数,const对象才会调用此函数
    char& operator[](std::size_t position){...};//返回引用以便更改

成员函数的const意味着什么?bitwise const(physical constness) or logical constness

bitwise const即const成员函数代表不更改对象任何成员变量(任何一个bit)。然而有些成员函数不十足具备const性质却能通过bitwise测试。

class CTextBlock{
public:
    char& operator[](std::size_t position) const{return pText[position];}
private:
    char* pText;

operator[]返回一个引用,从而可以修改对象内部值,但是函数本身不修改任何值。

const CtextBlock cctb("Hello");
char* pc = &cctb[0];
*pc = 'T';

这种情况到处所谓的logical constness。这派主张一个const成员可以修改他所处理的对象内的某些bits,但只有在客户端侦测不出的情况下才得如此。

class CTextBlock {
public:
    std::size_t length()const;
private:
    char* pText;
    //最近一次计算的文本区块长度。
    std::size_t textLength;
    //目前的长度是否有效。
    bool lengthisValid;
    std::size_t CTextBlock::length()const +
    if (!lengthIsValid){
        textLength = std::strlen(pText);//错误!在const成员函数内不能赋值给textLength和lengthIsValid
        lengthIsValid = true;
}
    return textlength;
}

以上实现显然不是bitwise const,所以无法通过编译器const检查,解决方法是mutable。mutable用来释放掉non-static成员变量的bitwise constness约束。

class CTextBlock {
public:
    std::size_t length()const;
private:
    char* pText;
    mutable std::size_t textLength;//这些成员变量可能总是会被更改,即使在const成员函数内。
    mutable bool lengthIsValid;
};
std::size_t CTextBlock::length() const
{
    if (!lengthIsValid){//现在,可以这样,也可以这样
        textLength = std::strlen(pText);
        lengthIsValid = true;
        }
        return textLength;
}

在const和non-const成员中避免重复

对于“bitwise-constness非我所欲”的问题,mutable是个解决办法,但它不能解决所有的const相关难题。举个例子,假设TextBlock(和CTextBlock)内的operator[]不单只是返回一个reference指向某字符,也执行边界检验(bounds checking)、志记访问信息(logged access info)、甚至可能进行数据完善性检验。把所有这些同时放进const和non-const operator[]中,导致这样的怪物。

为了避免重复代码,我们可以使用常量性转除(cast away constness)。

本例中const operator[]完全做掉了 non-const 版本该做的一切,唯一的不同是其返回类型多了一个 const 资格修饰。这种情况下如果将返回值的 const 转除是安全的,因为不论谁调用 non-const operator[]都一定首先有个 non-const 对象,否则就不能够调用non-const函数。所以令non-const operator[]调用其const一即使过程中需要一个转型动作。

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

内置类型应在使用前手工完成初始化。非内置类型初始化责任落在构造函数(constructors)身上。规则很简单:确保每一个构造函数都将对象的每一个成员初始化。不要混淆了赋值(assignment)和初始化(initialization)。

这会导致ABEntry对象带有你期望(你指定)的值,但不是最佳做法。C++ 规定,对象的成员变量的初始化动作发生在进入构造函数本体之前。在ABEntry构造函数内,theName,theAddress和thePhones都不是被初始化,而是被赋值。初始化的发生时间更早,发生于这些成员的 default构造函数被自动调用之时(比进入ABEntry构造函数本体的时间更早)。但这对 numTimesConsulted不为真,因为它属于内置类型,不保证一定在你所看到的那个赋值动作的时间点之前获得初值。

ABEntry构造函数的一个较佳写法是,使用所谓的member initialization list(成员初值列)替换赋值动作:

C++ 有着十分固定的“成员初始化次序”。是的,次序总是相同:base classes 更早于其derived classes被初始化,而class的成员变量总是以其声明次序被初始化。回头看看 ABEntry,其 theName 成员永远最先被初始化,然后是theAddress,再来是thePhones,最后是numTimesConsulted。即使它们在成员初值列中以不同的次序出现(很不幸那是合法的),也不会有任何影响。为避免你或你的检阅者迷惑,并避免某些可能存在的晦涩错误,当你在成员初值列中条列各个成员时,最好总是以其声明次序为次序。

译注:上述所谓晦涩错误,指的是两个成员变量的初始化带有次序性。例如初始化array 时需要指定大小,因此代表大小的那个成员变量必须先有初值。

不同编译单元内定义之non-local static对象

函数内的static对象称为local static对象(因为它们对函数而言是local),其他 static 对象称为 non-local static 对象。程序结束时 static 对象会被自动销毁,也就是它们的析构函数会在main()结束时被自动调用。

C++ 对“定义于不同的编译单元内的non-local static 对象"的初始化相对次序并无明确定义,为了避免non-local static对象未初始化,需要将每个non-local static对象搬到自己的专属函数内(该对象在此函数内被声明为static)。这些函数返回一个reference指向它所含的对象。然后用户调用这些函数,而不直接指涉这些对象。换句话说,non-local static 对象被local static对象替换了。

条款5-12 构造/析构/赋值运算

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

创建一个空类,如果你自己没声明,编译器就会为它声明(编译器版本的)一个copy构造函数、一个 copy assignment操作符和一个析构函数。此外如果你没有声明任何构造函数,编译器也会为你声明一个default构造函数。所有这些函数都是public且inline.

编译器生成的copy构造函数必须以no1.namevalue和no1.objectvalue为初值设定 no2.namevalue和 no2.objectValue。两者之中,nameValue 的类型是string,而标准 string 有个 copy 构造函数,所以 no2.nameValue 的初始化方式是调用string 的 copy 构造函数并以 no1.namevalue 为实参。另一个成员NamedObject<int>::objectValue的类型是int(因为对此 template具现体而言T 是int),那是个内置类型,所以 no2.objectValue 会以"拷贝 no1.objectValue 内的每一个bits"来完成初始化。

编译器为NamedObject<int>所生的copy assignment操作符,其行为基本上与copy构造函数如出一辙,但一般而言只有当生出的代码合法且有适当机会证明它有意义(见下页),其表现才会如我先前所说。万一两个条件有一个不符合,编译器会拒绝为class生出operator=。

如果你打算在一个"内含 reference 成员"的 class 内支持赋值操作(assignment),你必须自己定义 copy assignment操作符。面对“内含 const成员”(如本例之 objectValue)的classes,编译器的反应也一样。更改 const 成员是不合法的,所以编译器不知道如何在它自己生成的赋值函数内面对它们。最后还有一种情况:如果某个base classes 将 copy assignment操作符声明为private,编译器将拒绝为其derived classes 生成一个copy assignment 操作符。毕竟编译器为derived classes 所生的copy assignment操作符想象中可以处理 base class 成分(见条款12),但它们当然无法调用derived class 无权调用的成员函数。编译器两手一摊,无能为力。

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

手动声明为private的copy构造函数或copy assignment以阻止自动生成的相应函数,使class不被复制。

或者继承一个Uncopyable类

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

通常可以在基类中实现一个factory函数同于返回创建的派生类对象(GDAL中就有这样的机制-CreateFeature等)。而且这个返回值一定是基类的指针,如果经由这个基类指针进行delete就有可能发生局部销毁(只释放了基类的成员变量)。首先是使用这种factory函数时,如果明确派生类类型就应该立马进行转换成派生类指针。之外就是基类的析构函数应该是虚函数,这样才能通过基类指针正确调用派生类的析构函数。

无端地将所有 classes 的析构函数声明为 virtual,就像从未声明它们为virtual一样,都是错误的。许多人的心得是:只有当class内含至少一个virtual函数,才为它声明virtual析构函数。

即使class完全不带virtual函数,被"non-virtual析构函数问题"给咬伤还是有可能的。举个例子,标准string不含任何virtual函数,但有时候程序员会错误地把它当做。

另外抽象类的纯虚析构函数最好再提供一个空实现的函数定义,因为析构函数是从最深层派生类(most derived)向上依次调用析构函数。

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

当 vector v 被销毁,它有责任销毁其内含的所有 widgets。假设 v 内含十个Widgets,而在析构第一个元素期间,有个异常被抛出。其他九个widgets还是应该被销毁(否则它们保存的任何资源都会发生泄漏),因此v应该调用它们各个析构函数。但假设在那些调用期间,第二个widget析构函数又抛出异常。现在有两个同时作用的异常,这对C++而言太多了。在两个异常同时存在的情况下,程序若不是结束执行就是导致不明确行为。本例中它会导致不明确的行为。使用标准程序库的任何其他容器(如list,set)或TR1的任何容器(见条款54)或甚至array,也会出现相同情况。容器或array并非遇上麻烦的必要条件,只要析构函数吐出异常,即使并非使用容器或 arrays,程序也可能过早结束或出现不明确行为。是的,C++ 不喜欢析构函数吐出异常!

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

derived class对象内的base class成分会在derived class 自身成分被构造之前先构造妥当。Transaction构造函数的最后一行调用 virtual 函数logTransaction,这正是引发惊奇的起点。这时候被调用的logTransaction是Transaction 内的版本,不是BuyTransaction内的版本——即使目前即将建立的对象类型是 BuyTransaction。是的,base class 构造期间 virtual 函数绝不会下降到derived classes 阶层。取而代之的是,对象的作为就像隶属base类型一样。非正式的说法或许比较传神:在base class构造期间,virtual函数不是virtual函数。

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

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

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

潜在的自我赋值,比如数组下标一样的时候,指针指向内容一样的时候,基类和派生类指针指向同一对象的时候,如果是我们自己实现的类,且重载=时有一些危险操作,比如先释放自己的成员变量(数组),再去复制赋值对象的,如果=左右是同一对象就会出问题,因此应在重载=时做检查

这样修改具有了“自我赋值安全性”,但还不具备“异常安全性”。如果"new Bitmap"导致异常(不论是因为分配时内存不足或因为Bitmap的copy构造函数抛出异常),widget最终会持有一个指针指向一块被删除的Bitmap。这样的指针有害。你无法安全地删除它们,甚至无法安全地读取它们。唯一能对它们做的安全事情是付出许多调试能量找出错误的起源。这样修改:

现在,如果 "newBitmap"抛出异常,pb(及其栖身的那个widget)保持原状。即使没有证同测试(identity test),这段代码还是能够处理自我赋值,因为我们对原bitmap 做了一份复件、删除原 bitmap、然后指向新制造的那个复件。它或许不是处理“自我赋值”的最高效办法,但它行得通。

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

如果你声明自己的copying函数,意思就是告诉编译器你并不喜欢缺省实现中的某些行为。编译器仿佛被冒犯似的,会以一种奇怪的方式回敬:当你的实现代码几乎必然出错时却不告诉你。

注意拷贝构造函数也推荐直接给成员变量赋值

当新添加一个成员变量(lastTransaction),这时候既有的copying函数执行的是局部拷贝(partial copy):它们的确复制了顾客的name,但没有复制新添加的lastTransaction。大多数编译器对此不出任何怨言——即使在最高警告级别中(见条款53)。这是编译器对“你自己写出copying 函数"的复仇行为:既然你拒绝它们为你写出copying函数,如果你的代码不完全,它们也不告诉你。结论很明显:如果你为class添加一个成员变量,你必须同时修改copying函数。(你也需要修改class的所有构造函数(见条款4和条款45)以及任何非标准形式的operator=(条款10有个例子)。如果你忘记,编译器不太可能提醒你。)

在继承时,派生类的copying函数也要注意不能只复制派生类中声明的成员变量而忘记基类中声明的成员变量。正确写法:

条款13-19 资源管理

条款13 以对象管理资源

假设我们使用一个用来塑模投资行为(例如股票、债券等等)的程序库,其中各式各样的投资类型继承自一个 root class Investment。进一步假设,这个程序库系通过一个工厂函数createInvestment(factory function,见条款7)供应我们某特定的Investment对象。一如以上注释所言,createInvestment 的调用端使用了函数返回的对象后,有责任删除之。现在考虑有个f函数履行了这个责任,但是由于各种原因,比如过早的return,循环过早的退出,异常(尤其是后续维护,被修改,被别人修改)很可能导致f函数并未被成功执行。

为确保 createInvestment返回的资源总是被释放,我们需要将资源放进对象内,当控制流离开f,该对象的析构函数会自动释放那些资源。实际上这正是隐身于本条款背后的半边想法:把资源放进对象内,我们便可倚赖C++的"析构函数自动调用机制"确保资源被释放。

许多资源被动态分配于 heap 内而后被用于单一区块或函数内。它们应该在控制流离开那个区块或函数时被释放。标准程序库提供的 auto_ptr正是针对这种形势而设计的特制产品。auto_ptr是个“类指针(pointer-like)对象”,也就是所谓“智能指针”,其析构函数自动对其所指对象调用 delete。下面示范如何使用auto_ptr以避免f函数潜在的资源泄漏可能性:

■ 获得资源后立刻放进管理对象(managing object)内。以上代码中createInvestment返回的资源被当做其管理者 auto_ptr的初值。实际上“以对象管理资源”的观念常被称为“资源取得时机便是初始化时机”(Resource Acquisition Is Initialization;RAII),因为我们几乎总是在获得一笔资源后于同一语句内以它初始化某个管理对象。有时候获得的资源被拿来赋值(而非初始化)某个管理对象,但不论哪一种做法,每一笔资源都在获得的同时立刻被放进管理对象中。

■ 管理对象(managing object)运用析构函数确保资源被释放。不论控制流如何离开区块,一旦对象被销毁(例如当对象离开作用域)其析构函数自然会被自动调用,于是资源被释放。如果资源释放动作可能导致抛出异常,事情变得有点棘手,但条款8已经能够解决这个问题,所以这里我们也就不多操心了。

由于 auto ptr 被销毁时会自动删除它所指之物,所以一定要注意别让多个auto_ptr同时指向同一对象。如果真是那样,对象会被删除一次以上,而那会使你的程序搭上驶向“未定义行为”的快速列车上。为了预防这个问题,auto_ptrs 有一个不寻常的性质:若通过copy构造函数或copy assignment操作符复制它们,它们会变成null,而复制所得的指针将取得资源的唯一拥有权!

auto_ptr类似于C++ 11后的unique_ptr

auto_ptr 的替代方案是"引用计数型智慧指针"(reference-counting smart pointer;RCSP)。所谓RCSP也是个智能指针,持续追踪共有多少对象指向某笔资源,并在无人指向它时自动删除该资源。RCSPs提供的行为类似垃圾回收(garbage collection),不同的是RCSPs无法打破环状引用(cycles of references,例如两个其实已经没被使用的对象彼此互指,因而好像还处在"被使用"状态)。

auto_ptr和 shared_ptr 两者都在其析构函数内做 delete 而不是delete[]动作(条款16对两者的不同有些描述)。那意味在动态分配而得的array 身上使用auto_ptr或shared_ptr是个馊主意。尽管如此,可叹的是,那么做仍能通过编译:

//馊主意!会用上错误的delete形式。
std::auto_ptr<std::string> aps(new std::string[10]);
//相同问题。
std::trl:shared_ptr<int> spi(new int[1024]);

你或许会惊讶地发现,并没有特别针对"C++ 动态分配数组"而设计的类似auto_ptr或trl::shared_ptr那样的东西,甚至TR1中也没有。那是因为vector 和 string 几乎总是可以取代动态分配而得的数组。如果你还是认为拥有针对数组而设计、类似 auto ptr和trl::shared ptr那样的classes较好,看看Boost吧(见条款 55)。在那儿你会很高兴地发现 boost::scoped_array 和 boost::shared_array classes,它们都提供你要的行为。

条款14:在资源管理类中小心coping行为

例如,假设我们使用C API函数处理类型为 Mutex 的互斥器对象(mutex objects),共有lock和unlock两函数可用:

//锁定pm所指的互斥器.
void lock(Mutex* pm);
//将互斥器解除锁定.
void unlock(Mutex* pm);

为确保绝不会忘记将一个被锁住的Mutex解锁,你可能会希望建立一个 class 用来管理机锁。这样的class的基本结构由RAII守则支配,也就是"资源在构造期间获得,在析构期间释放”:

class Lock {
public:
    explicit Lock(Mutex* pm)
    : mutexPtr(pm)
    { lock(mutexPtr);}//获得资源
    //释放资源
    ~Lock(){ unlock(mutexPtr);}private:
    Mutex *mutexPtr;
}

这很好,但如果Lock对象被复制,会发生什么事?

//锁定m
Lock mll(&m);
//将ml1复制到ml2身上。这会发生什么事?
Lock ml2(mll);

这是某个一般化问题的特定例子。那个一般化问题是每一位RAII class作者一定需要面对的:"当一个RAII对象被复制,会发生什么事?"大多数时候你会选择以下两种可能:

■ 禁止复制。许多时候允许RAII对象被复制并不合理。对一个像Lock这样的class

这是有可能的,因为很少能够合理拥有"同步化基础器物"(synchronization primitives)的复件(副本)。如果复制动作对RAII class并不合理,你便应该禁止之。条款6告诉你怎么做:将copying操作声明为private。

■ 对底层资源祭出“引用计数法”(reference-count)。有时候我们希望保有资源,

直到它的最后一个使用者(某对象)被销毁。这种情况下复制RAII对象时,应该将资源的“被引用数”递增。tr1::shared_ptr便是如此。

shared_ptr引用次数为0时是删除所指物,因此需要指定所谓的删除器,修改为我们需要的解锁。Lock Class没必要再去声明析构函数了

条款15:在资源管理类中提供对原始资源的访问

这时候你需要一个函数可将RAII class对象(本例为tr1::shared_ptr)转换为其所内含之原始资源(本例为底部之Investment*)。有两个做法可以达成目标:显式转换和隐式转换。

trl::shared_ptr和auto_ptr都提供一个get成员函数,用来执行显式转换,也就是它会返回智能指针内部的原始指针(的复件):int days = daysHeld(pInv.get());

就像(几乎)所有智能指针一样,trl::shared_ptr和auto_ptr也重载了指针取值(pointer dereferencing) 操作符(operator->和 operator*),它们允许隐式转换至底部原始指针:

显式转换和隐式转换

是否该提供一个显式转换函数(例如 get 成员函数)将RAII class转换为其底部资源,或是应该提供隐式转换,答案主要取决于RAII class被设计执行的特定工作,以及它被使用的情况。最佳设计很可能是坚持条款18的忠告:"让接口容易被正确使用,不易被误用"。通常显式转换函数如qet是比较受欢迎的路子,因为它将"非故意之类型转换"的可能性最小化了。然而有时候,隐式类型转换所带来的"自然用法"也会引发天秤倾斜。

届ける言葉を今は育ててる
最后更新于 2023-05-25