More Effective C++ 条款(1-11)总结

More Effective C++ 条款(1-11)总结


基础议题

条款1:仔细区别pointers和references

  • 如果有一个变量,其目的是用来指向(代表)另一个对象,但是也有可能它不指向(代表)这个变量,那么应该使用pointer,因为可将pointer设为null,反之设计不允许变量为null,那么使用reference
  • 以下这是有害的行为,其结果不可预期(C++对此没有定义),编译器可以产生任何可能的输出
    char *pc = 0;       // 将 pointer 设定为null
    char& rc = *pc;     // 让 refercence 代表 null pointer 的 解引值
  • 没有null reference, 使用reference可能比pointers更有效率,在使用reference之前不需要测试其有效性
    void printDouble(const double& rd)
    {
        cout << rd; // 不需要测试rd,它
    } // 肯定指向一个double值
    //相反,指针则应该总是被测试,防止其为空:
    void printDouble(const double *pd)
    {
        if (pd) // 检查是否为NULL
        {
            cout << *pd;
        }
    }
  • pointers可以被重新赋值,指向另一个对象,reference却总是指向(代表)它最初获得的哪个对象
  • 实现某些操作符。如operator[],操作符应返回某种“能够被当作assignment赋值对象”

总结

当你知道你需要指向某个东西,而且绝不会改变指向其他东西,或是当你实现一个操作符而其语法需求无法由 pointers达成,你就应该选择 reference。任何其他时候,请采用 pointers

条款2:最好使用C++转型操作符

  • 旧式的C转型方式,它几乎允许你将任何类型转换为任何其他类型,这是十分拙劣的

旧式转型存在的问题:

  • 例如将pointer-to-const-object转型为一个pointer-to-non-const-object(只改变对象的常量性),和将一个pointer-to-base-class-object转型为一个pointer-to-derived-class-object(完全改变一个对象的类型),其间有很大的差异。但是传统的C转型动作对此并无区分
  • 难以辨识,旧式转型由一小对小括号加上一个对象名称(标识符)组成,而小括号和对象名称在C++的任何地方都有可能被使用

static_cast:

  • static_cast基本上拥有与 C 旧式转型相同的威力与意义,以及相同的限制(如不能将struct转型为int)。
  • 不能移除表达式的常量性,由const_cast专司其职
  • 其他新式 C++ 转型操作符适用于更集中(范围更狭窄)的目的
    (type) expression               //  原先 C 的转型写码形式
    static_cast<type>(expression)   //  使用 C++ 转型操作符

const_cast:

  • const_cast用来改变表达式的常量性(constness)变易性(volatileness),使用const_cast,便是对人类(编译器)强调,通过这个转型操作符,你唯一打算改变的是某物的常量性或变易性。这项意愿将由编译器贯彻执行。如果将const_cast应用于上述以外的用途,那么转型动作会被拒绝
#include <iostream>
using namespace std;

class Widget {};
class SpecialWidget : public Widget {};
void update(SpecialWidget* psw);
SpecialWidget sw;                           // sw是个 non-const 对象
const SpecialWidget& csw = sw;              // csw 确实一个代表sw的 reference
                                            // 并视之为一个const对象

update(&csw);                               // 错误!不能及那个const SpecialWidget*
                                            // 传给一个需要SpecialWidget* 的函数

update(const_cast<SpecialWidget*>(&csw));   // 可!&csw的常量性被去除了

update((SpecialWidget*)&csw);               // 可!但较难识别 C 旧式转型语法
  • const_cast最常见的用途就是将某个对象的常量性去除掉

dynamic_cast:

  • 用来转型继承体系重“安全的向下转型或跨系转型动作”。也就是说你可以利用dynamic_cast,将“指向base ckass objectspointersreferences”转型为“指向derived(或sibling base)class objectspointersreferences”,并得知转型是否成功。如果转型失败,会以一个null指针或一个exception(当转型对象是reference)表现出来:
Widget *pw;

update(dynamic_cast<SpecialWidget*>(pw));           // 很好,传给update()一个指针,指向pw所指的
                                                    // pw所指的SpecialWidget--如果pw
                                                    // 真的指向这样的东西;否则传过去的
                                                    // 将是一个 null 指针
void updateViaRef(SpecialWidegt& rsw);
updateViaRef(dynamic_cast<SpecialWidegt&>(*pw));    // 很好,传给updateViaRef()的是
                                                    // pw所指的SpecialWidget--如果
                                                    // pw真的指向这样的东西;否则
                                                    // 抛出一个exception
  • dynamic_cast只能用来协助你巡航于继承体系之中。它无法应用在缺乏虚函数(请看条款24)的类型身上,也不能改变类型的常量性(constness)
  • 如果不想为一个不涉及继承机制的类型执行转型动作,可使用static_cast;要改变常量性(constness),则必须使用const_cast

reinterpret_cast:

  • 最后一个转型操作符是reinterpret_cast。这个操作符的转换结果几乎总是与编译平台息息相关。所以reinterpret_cast不具移植性
  • reinterpret_cast的最常用用途是转换"函数指针"类型。
typedef void (*FuncPtr)();      // FuncPtr是个指针,指向某个函数
                                // 后者无须任何自变量,返回值为voids
FuncPtr funcPtrArray[10];       // funcPtrArray 是个数组
                                // 内有10个FuncPtrs

假设由于某种原因,希望将以下函数的一个指针放进funcPtrArray中

int doSomething();

如果没有转型,不可能办到,因为doSomething的类型与funcPtrArray所能接受的不同。funcPtrArray内各函数指针所指函数的返回值是void,但doSomething的返回值却是int

funcPtrArray[0] = &doSomething;                             //错误!类型不符
funcPtrArray[0] = reinterpret_cast<FuncPtr>(&doSomething);  //这样便可通过编译

某些情况下这样的转型可能会导致不正确的结果(如条款31),所以你应该尽量避免将函数指针转型。

补充:

  • More Effective C++没有过多的对reinterpret_cast操作符进行解释,但我觉得应该对它进行更多说明,因为它实在是太强大了,也应该对使用规则做出足够多的说明
  • reinterpret_cast通过重新解释底层位模式在类型之间进行转换。它将expression的二进制序列解释成new_type,函数指针可以转成void*再转回来。reinterpret_cast很强大,强大到可以随便转型。因为他是编译器面向二进制的转型,但安全性需要考虑。当其他转型操作符能满足需求时,reinterpret_cast最好别用。
  • 更多了解可看cpp reference reinterpret_cast

总结:

在程序中使用新式转型法,比较容易被解析(不论是对人类还是对工具而言),编译器也因此得以诊断转型错误(那是旧式转型法侦测不到的)。这些都是促使我们舍弃C旧式转型语法的重要因素

条款3:绝对不要以多态(polymorphically)方式处理数组

假设你有一个class BST及一个继承自BST的class BalancedBST;

class BST {};
class BalancedBST : public BST {};

现在考虑有个函数,用来打印BSTs数组中的每一个BST的内容

void printBSTArray(ostream& s, const BST array[], int numElements)
{
    for (int i = 0 ; i < numElements; ++i)
    {
        s << array[i];      // 假设BST objects 有一个
                            // operator<< 可用
    }
}

当你将一个由BST对象组成的数组传给此函数,没问题:

BST BSTArray[10];
printBSTArray(cout, BSTArray, 10);      // 运行良好

然而如果你将一个BalancedBST对象所组成的数组交给printBSTArray函数,会发生什么事?

BalancedBST bBSTArray[10];
printBSTArrat(cout, bBSTArray, 10);     // 可以正常运行吗?
  • 此时就会发生错误,因为array[i]代表的时*(array+i),编译器会认为数组中的每个元素时BST对象,所以array和array+i之间的距离一定是i*sizeof(BST)
  • 然后当传入由BalancedBST对象组成的数组,编译器会被误导。它仍假设数组中每一元素的大小是BST的大小,但其实每一元素的大小是BalancedBST的大小。因此当BalancedBST的大小不等于BST的大小时,会产生未定义的行为
  • 当尝试通过一个·base class·指针,删除一个由derived class objects组成的数组,上述的问题还会再次出现,下面是你可能做出的错误尝试

void deleteArray(ostream& os,BST array[])
{
	os << "Delete array,at address" << 
		static_cast<void*>(array) << 'n';
	delete []array;
}

编译器看到这样的句子

delete[] array;

会产生类似这样的代码,问题也就跟之前一样出现了

for(int i = the number of elements in the array-1; i >= 0; --i)
{
    array[i].BST::~BST();       // 调用array[i]的 destructor
}

总结:

  • 多态和指针算术不能混用,数组对象几乎总是涉及指针的算术运算,数组和多态不要混用

条款4:非必要不提供default constructor

后续看过条款43,再回头来补充

总结:

  • 添加无意义的default constructors,也会影响classes的效率。如果class constructors可以确保对象的所有字段都会被正确地初始化,为测试行为所付出的时间和空间代价都可以免除。如果default constructors无法提供这种保证,那么最好避免让default constructors出现。虽然这可能会对classes的使用方式带来某种限制,但同时也带啦一种保证:当你真的使用了这样的classes,你可以预期它们所产生的对象会被完全地初始化,实现上亦富有效率

操作符

条款5:对定制的“类型转换函数”保持警觉

在你从未打算也未预期的情况下,此类函数可能会被调用,而其结果可能是不正确、不直观的程序行为,很难调试。

  • 假如有以下这段代码,假设你忘记为Rational写一个operator<<,那么你或许会认为以下打印动作不会成功,因为没有适当的operator<<可以调用。但是你错了,编译器面对下述动作,发现不存在任何operator<<可以接受一个Rational,但它会想尽办法(包括找出一系列可接受的隐式类型转换)让函数调用成功。
class Rational {
public:
	Rational(int a = 0, int b = 1);
	operator double() const;		// 定义了一个将类转化为double的转换函数
                                    // 将Rational 转换为double
private:
	float val;

    Rational r(1, 2);
    double d = 0.5 * r;
    cout << d << "\n";              // 0.25
    cout << r;                      // 0.5
};
  • "可被接受的转换程序定义"十分复杂,但本例中你的编译器发现,只要调用Rational::operator double,将r隐式转换为double,调用动作便能成功。解决办法就是以功能对等的另一个函数取代类型转换操作符,不妨以一个名为asDouble的函数取代operator double:
  • 避免隐式类型转换带来的问题,使用关键词explicit。这个特性之所以被导入,就是为了解决隐式类型转换带来的问题。只要将constructors声明为explicit,编译器便不能因隐式类型转换的需要而调用它们。
template<class T>
class Array{
    public:
    explicit Array(int size);       //  注意,使用"explicit"
}

条款6:区别increment/decrement操作符的前置(prefix)和后置(postfix)形式

  • 前置式返回一个reference,后置式返回一个const对象
  • increment操作符的前置式意义"increment and fetch"(累加然后取出),后置式意义"fetch and increment"(取出然后累加)

前置式:

// 前置式:累加然后取出(increment and fetch)
UPInt& UPInt::operator++()
{
	*this += 1;
	return *this;
}

后置式:

// 后置式:取出然后累加(fetch and increment)
const UPInt UPInt::operator++(int)
{
	UPInt oldValue = *this;
	++(*this);
	return oldValue;
}

请注意后置式操作符并未动用其参数。是的,其参数的唯一目的只是为了区别前置式和后置式而已。ints并不允许连续两次使用后置式increment操作符。因此下列代码无法运行。

int i = 3;
i++++;              // 错误!后置式返回const对象,因operator++为非const函数,所以无法执行第二次后置式increment操作
++++i;              // 合法!前置式返回reference,i值前置式increment两次,i = 5
++++++++i;          // 合法!前置式返回reference,i值前置式increment四次,i = 7
++i;                // 调用 i.operator++();
i++;                // 调用 i.operator++(0);
--i;                // 调用 i.operator--();
i--;                // 调用 i.operator--(0);

总结:

  • 后置式increment函数,该函数必须产生一个临时对象,作为返回值之用。效率不如前置式。游戏引擎架构中说:前置式效率更好,但会打乱流水线

条款7:千万不要重载&&, || 和,操作符

当你重载&&,||操作符时,你正从根本层面改变整个游戏规则,因为从此"函数调用"语义会取代"骤死式 语义"
如果你将operator&&重载,下面这个式子:

if (expression1 && expression2) { }

会被编译器视为以下两者之一

if (expression1.operator&&(expression2))    //  假设operator&&是个 member function
if (operator&&(expression1, expression2))   //  假设operator&&是个 global function

虽然看起来没什么太大改变,但是"函数调用"语义和所谓的"骤死式"语义有两个重大的区别。第一,当函数调用动作被执行,所有参数值都必须评估完成,当调用操作符operator&&operator||时,两个参数都已评估完成。没有骤死式语义。第二,C++语言规范并未明确定义函数调用做东中各参数的评估顺序,所以没办法知道expression1expression2哪个会先被评估。这与骤死式评估法形成鲜明的对比,后者总是由左向右评估其自变量。

for (int i = 0, j = strlen(s) - 1; i < j ; ++i, --j)

表达式如果包含逗号,那么逗号左侧会先被评估,然后逗号的右侧再被评估;最后,整个逗号表达式的结果以逗号右侧的值为代表。面对上述循环的最后一个成分,编译器首先评估++i,然后是–j,而整个逗号表达式的结果是–j的返回值

  • 如果将操作符写成一个non-member funcion,你绝对无法保证左侧表达式一定比右侧表达式更早被评估,因为两个表达式都被当做函数调用时的自变量,传递给该操作符函数,而你无法控制一个函数的自变量评估顺序。所以non-member做法不可行。
  • 剩下可能的做法是写成一个member function。但即便如此也不能保证逗号操作符的左操作数会先被评估,因为编译器并不强迫做这样的事情。因此,你"不能将逗号操作符重载,并保证其行为像它应该有的那样"。所以不要轻易地将他重载。

总结:

  • 如果你没有什么好理由将某个操作符重载,就不要去做。面对&&,||,,实在难有什么好理由,因为不管你多么努力,就是无法令其行为像它们应有的行为一样。

条款8:了解各种不同意义的new和delete

先说明new operatoroperator new之间的差异。(此处所说的new operator,即某些C++教程如C++ Primer所谓的new expression)

string* ps = new string("Memory Management");

它的动作分为两方面。第一,它分配足够的内存,用来放置某类型的对象。第二,它调用一个constructor(对象的构造函数),为刚才分配的内存中的那个对象设定初值。new operator总是做这两件事,无论如何你不能改变其行为。

你能够改变的是用来容纳对象的那块内存的分配行为。new operator调用某个函数,执行必要的内存分配动作,你可以重写或重载那个函数,改变其行为。这个函数的名称叫做operator new

函数operator new通常声明如下:

void* operator new(size_t size);

其返回值是void*。此函数返回一个指针,指向一块原始的、未设初值的内存(如果你喜欢,可以写一个新版的operator new,在其返回内存指针之前先将那块内存设定初值。只不过这种行为颇为罕见就是了)

void *rawMemory = operator new(sizeof(string));

这里的operator new将返回指针,指向一块足够容纳一个string对象的内存

malloc一样,operator new的唯一任务就是分配内存。它不知道什么是constructorsoperator new只负责内存分配。取得operator new返回的内存并将之转换为一个对象,是new operator的责任

Placement new

有时候你真的会想直接调用一个constructor,偶尔你会有一些分配好的原始内存,你需要在上面构建对象。有一个特殊版本的operator new,称为placement new,允许你那么做

如果你希望将对象产生于heap,请使用new operator。它不但会分配内存而且为该对象调用一个constructor。如果你只是打算分配内存,请调用operator new,那就没有任何constructor会被调用。如果你打算在heap objects产生时自己决定内存分配方式,请写一个operator new,并使用new operator,它将会自动调用你缩写的operator new
如果你打算在已分配(并拥有指针)的内存中构造对象,请使用placement new

删除和内存释放

如果你只打算处理原始的、未定初值的内存,应该完全回避new operatordelete operator,改调用operator new取得内存并以operator delete归还给系统:

void *buffer = operator new (50 * sizeof(char));        // 分配足够的内存,放置50个chars;没有调用任何ctors
operator delete(buffer);                                // 释放内存,没有调用任何dtors

如果你使用placement new,在某内存块中产生对象,你应该避免对那块内存使用delete operator。因为delete operator会调用operator delete,但是该内存包含的对象最初并非是由operator new分配得来的。毕竟placement new只是返回它所接收的指针而已。

总结:

new operatordelete operator都是内建操作符,无法为你所控制,但是它们所调用的内存分配/释放函数则不然。你可以修改它们完成任务的方式,至于它们的任务,已经被语言规范固定死了。


异常

条款9:利用destructors避免泄漏资源

为什么要使用exceptions

  • 如果一个函数利用"设定状态变量"的方式或是利用"返回错误码"的方式发出一个异常信号,无法保证此函数的调用者会检查那个变量或检验那个错误码
  • C程序员唯有以setjmplongjmp才能近似这样的行为。但是longjmp在C++中有一个严重缺陷:当它调整栈(stack)的时候,无法调用局部(local)对象的destructors

关于setjmp和longjmp介绍可看这两篇:

将"一定得执行的清理代码"移到对象的destructor内是个比较好的做法

void processAdoptions(istream& dataSource)
{
    while (dataSource)  {
        auto_ptr<ALA> pa(readALA(dataSource));
        pa->processAdoption();
    }
}

上述代码
把资源封装在对象内,通常便可以在exceptions出现时避免泄露资源
但如果exceptions是在你正在取得资源的过程中抛出的,例如在一个"正在抓取资源"的class constructor内,会发生什么事呢?如果exceptions实在此类资源的自动析构过程中抛出的,又会发生什么事呢?此情况瞎constructor是否需要特殊设计?是的,它们需要,你可以在条款10和条款11中学到这些技术。

总结

条款9就是推荐用RAII的思想去管理和释放资源,避免 exceptions造成资源的泄漏。其次,条款中的例子auto_ptr,标准库中有所实现,对应auto_ptr,其在c++11引进,c++17中就被遗弃,故条款中的例子也可替换成std::unique_ptr

条款10:在constructor内阻止资源泄漏

假设有这样的设计

BookEntry::BookEntry(const string& name,
                    const string& address,
                    const string& imageFileName,
                    const string& audioClipFileName) : theName(name), theAddress(address),
                    theImage(0), theAudioClip(0)
{
    if (imageFileName != "") {
        theImage = new Image(imageFileName);
    }
    if (audioClipFileName != "") {
        theAudioClip = new AudioClip(audioClipFileName);
    }

    BookEntry::~BookEntry()
    {
        delete theImage;W
        delete theAudioClip;
    }
}

看起来一切都很好,但其实不好,当程序执行BookEntry constructor的以下部分,如果有个exception被抛出,会发生什么事?

if (audioClipFileName != "") {
    theAudioClip = new AudioClip(audioClipFileName);
}

当有exceptionBookEntry constructor内抛出,就会被传播到正在产生BookEntry object的那一端。控制权因而被移除BookEntry constructor之外,theImage不会被BookEntry destructor删除,进而发生内存泄漏

C++只会析构已构造完成的对象。对象只有在其constructor执行完毕才算是完全构造妥当。所以如果程序打算产生一个局部性的BookEntry object b:

void testBookEntryClass()
{
    BookEntry b("Addison-Wesley Publishing Company", "One Jacob Way, Reading, MA 01867");
}

exception在b的构造过程中被抛出,b的destructor就不会被调用。
将b分配于heap中,并在exception出现时调用delete:

void testBookEntryClass()
{
    BookEntry *pb = 0;
    try {
        pb = new BookEntry("Addison-Wesley Publishing Company", "One Jacob Way, Reading, MA 01867");
        // ...
    }
    catch (...) {           // 捕获所有的exceptions
        delete pb;          // 当exception被抛出,删除pb
        throw;              // 将exception传给调用者
    }
    delete pb;              // 正常情况下删除pb
}

所分配的Image object依然会泄漏,同理,除非new动作成功,否则上述那个assignment复制动作并不会施加于pb身上,那么pb会为nullptr,而其内部theImage就不会被destructors删除
由于C++不自动清理那些"构造期间抛出exceptions"的对象,所以必须自身对constructors进行设计,修改如下

    try {
        if (imageFileName != "")
            theImage = new Image(imageFileName);
    }
    catch (...) {
        delete theImage;
        throw;
        // ...
    }

接下来将theImagetheAudioClip都变成常量指针,此时又该如何考虑呢:

Image* const theImage;
AudioClip* const theAudioClip;

须构造函数列表初始化

  • 一种办法是通过member function,将指针数据在函数内部完成try/catch
  • 还算"完美"地解决了问题,但是概念上应该由constructor完成的动作现在却散布于数个函数中,造成维护上的困扰
// theImage 首先被初始化,所以即使初始化失败亦无须担心
// 资源泄漏问题。因此本函数不必处理任何 exceptions
Image* BookEntry::initImage(const string& imageFileName)
{
    if (imageFileName != "")    return new Image(imageFileName);
    else return 0;
}

// theAudioClip 第二个被初始化,所以如果在它初始化期间有
// exception 被抛出,它必须确定将 theImage 的资源释放掉
// 这就是为什么本函数使用 try...catch的原因
AudioClip* BookEntry::initAudioClip(const string& audioClipFileName)
{
    try {
        if (audioClipFileName != "")
            return new AudioClip(audioClipFileName);
        else 
            return 0;
    }
    catch (...) {
        delete theImage;
        throw;
    }
}
  • 一种办法参考条款9使用有RAII特性的smart pointer
  • 完美的解决方案,原书中对这种做法极力推崇
// auto_ptr 在 c++17 中淘汰
const auto_ptr<Image> theImage;
const auto_ptr<AudioClip> theAudioClip;
// 可改成
const std::unique_ptr<Image> theImage;
const std::unique_ptr<AudioClip> theAudioClip;

总结:

简单来说,条款10在条款9的结论上,讨论了 exception在constructors中出现的情况以及应对措施

如果你以auto_ptr对象来取代pointer class members,你便对你的constructors做了强化工事,免除了"exceptions"出现时发生资源泄露的危机,不在需要在destructors内晴子动手释放资源,并允许const member pointers得以和non-const member pointers有着一样优雅的处理方式

条款11:禁止异常(exception)流出destructors之外

两种情况下destructor会被调用

  • 第一种情况是当对象在正常状态下被销毁,当它离开了它的生存空间(scope)或是被明确地删除
  • 第二种情况是当对象被exception处理机制————也就是exception传播过程中的stack-unwinding(栈展开机制)一一销毁

destructors被调用时,可能有一个以上的exception正在作用之中
当如果控制权基于exceptions的因素离开destructors,而此时正有另一个exceptions正在destructor中处于作用状态,C++会调用terminate函数将程序结束。
举个例子

class Session {
public:
    Session();
    ~Session();
private:
    static void logCreation(Session* objAddr);
    static void logDestruction(Session* objAddr);
}
Session::~Session()
{
    logDestruction(this);
}

这看起来很好,但是如果考虑一下logDestruction抛出一个exception。这个exception并不会被Session捕捉,所以会传播到destructors的调用端,但是如果这个destructors本身是因其他某个exception而被调用的,terminate函数便会被自动调用

一个有效的解决办法是:

Session::~Session()
{
    try {
        logDestruction(this);
    }
    catch (...) { }
}

try/catch阻止了logDestruction所抛出的exceptions传出Session destructor之外,此时,如果一个Session object因为栈展开(stack unwinding)而被销毁,则terminate并不会被调用

总结:

有两个好理由支持我们"全力阻止exceptions传出des之外"

  • 它可以避免terminate函数在exceptions传播郭恒的栈展开机制中被调用
  • 它可以协助确保destructors完成其应该完成的所有事情
    每个理由本身的条件都足以让人信服,但集合在一起却又引起过重的负担。如果你认为能省悟,请看Herb Sutter的文章,尤其是标题为《Destructors That Throw and Why They’re Evil》的那篇(发表于C++ Report,1997/9,11,12)
  • 0
    点赞
  • 0
    收藏
  • 打赏
    打赏
  • 0
    评论

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

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:黑客帝国 设计师:我叫白小胖 返回首页
评论

打赏作者

shadow_lr

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值