目录
2.用 consts, enums 和 inlines 取代 #defines
32.确保 public inheritance 模拟 "is-a"
52.如果编写了 placement new,就要编写 placement delete
本文前言
看一百遍这篇文章都不如看次原文,尤其是最后几个,很深入,强烈建议看原文。
介紹 | Effective C++ (gitbooks.io)
1.将C++视为语言联合体
不要将C++作为一种单独的语言看待,将它看作是多个子语言结合的整体:
C语言:C++依然是基于C的,像数组、指针之类的内容就是来自C,同时C++也对部分内容做了升级。
Object-Oriented C++:这部分就是C++的面向对象的部分。
Template C++:这是C++的泛型编程部分
STL:一个非常特殊的模板库
2.用 consts, enums 和 inlines 取代 #defines
1.因为 #define 根本就没有被看作是语言本身的一部分,所以如果用#define定义常量,可能会被预处理器消除,导致编译器根本看不见这个常量,使这个常量被忽略。
2.#define不能提供任何形式的封装,它不考虑作用范围会大片影响代码(除非在后面某处存在 #undefed )
3.#define 指令的另一个普遍的(不好的)用法是实现看来像函数,但不会引起一个函数调用的开销的 macros(宏)。
4.尽量减少 preprocessor(预处理器)(特别是 #define)的使用,但还不能完全消除。#include 依然是基本要素,而 #ifdef/#ifndef 也很重要。
3.只要可能就用 const
在类的外部,你可以将它用于全局或命名空间范围的常量,以及那些在文件、函数或模块范围内被声明为静态的对象。
在类的内部,你可以将它用于静态和非静态数据成员上。const在函数声明中,既可以修饰参数,也可以修饰返回值,还可以修饰这个函数本身
1.一个函数返回一个 constant value(常量值),常常可以在不放弃安全和效率的前提下尽可能减少客户的错误造成的影响。
2.成员函数被声明为 const 的目的是标明这个成员函数可能会被 const 对象调用。
首先,它使一个类的接口更容易被理解。知道哪个函数可以改变对象而哪个不可以是很重要的。
其次,它们可以和 const 对象一起工作。提升性能的基本方法就是以传引用给 const 的方式传递一个对象(这个只有在 const 成员函数和作为操作结果的被 const 修饰的对象存在时才行)
3.对于指针,你可以指定这个指针本身是 const,或者它所指向的数据是 const,或者两者都是,或者都不是:
//窍门是看const跟着谁,const后面跟类型说明这个数不能改,const后面跟着指针名说明这个指针本身不能改
const char *p = greeting; //能改的指针指向不能改的数据
char * const p = greeting; //不能改的指针指向能改的数据
const char * const p = greeting; //不能改的指针指向不能改的数据
4.当 const 和 non-const 成员函数具有本质上相同的实现的时候,使用 non-const 版本调用 const 版本可以避免代码重复
4.确保对象在使用前被初始化
C++的部分内容(比如C部分的)不能保证初始化,所以最好总是在使用之前初始化一遍
对于内建类型的非成员对象,需要你手动赋值。除此之外的几乎全部情况,则是使用构造函数。我们要确保所有的构造函数都初始化了对象中的每一样东西。
4.1初始化和赋值的区别
首先要注意,初始化和赋值不是一回事:
一个对象的数据成员在进入构造函数的函数体之前就被初始化了。
如果你是在构造函数内部一个一个将参数赋值给数据成员那就不是初始化,而是初始化后又赋值。在进入构造函数的函数体之前,它们的缺省的构造函数已经被自动调用(不包括内建类型)
所以要用初始化列表代替赋值,对于大部分类型来说,这样的效率高的多,因为只调用了一次拷贝构造函数而不是先调用缺省构造函数再调用拷贝赋值运算符。
(对于内建类型,初始化和赋值是一样的,但为了统一性,最好是经由成员初始化)
TestClass::TestClass(const string& name, const string& address,
const list<PhoneNumber>& phones)
: theName(name),
theAddress(address),
thePhones(phones)
{
//函数体
}
类可能有多个构造函数,可以将赋值(而不是初始化)的部分移到一个单独的私有的函数中供所有构造函数调用。
C++的初始化顺序是先基类后子类,按照数据成员声明的顺序初始化,所以初始化列表的顺序应与声明顺序一致(不一致也不会报错)
4.2转换单元中的非局部静态变量
除此之外,还要定义在不同转换单元中的非局部静态对象的初始化的顺序:
一个转换单元是可以形成一个单独的目标文件的源代码。基本上是一个单独的源文件,再加上它全部的 #include 文件。
如果一个转换单元的用到了另一个转换单元的静态对象,不能确保有没有初始化。
我们应将每一个非局部静态对象移到它自己的函数中,在那里它被声明为静态。这些函数返回它所包含的对象的引用。
因为 C++ 保证局部静态对象的初始化发生在因为调用那个函数而第一次遇到那个对象的定义时候。而且如果你从不调用这样一个函数,就不会初始化和销毁这个对象(又省了一些性能)
5.C++偷偷加上和调用的内容
如果你不声明的话,C++会自己为你生成拷贝构造函数,拷贝赋值运算符,缺省构造函数和析构函数。
缺省构造函数和析构函数主要是放置诸如基类和非静态数据成员的构造函数和析构函数的调用。
拷贝构造函数和拷贝赋值运算符,只是简单地从源对象拷贝每一个非静态数据成员到目标对象。
6.如果不想要加上的内容就拒绝
如果我们不希望有拷贝构造函数和拷贝赋值运算符,那就将它们声明为私有的并且不提供实现。
7.在多态基类中将析构函数声明为虚拟
有时候我们会用一个基类的指针指向它的派生类对象,而C++ 规定:当一个派生类对象通过使用一个指向带有非虚拟析构函数的基类的指针被删除,则结果是未定义的。运行时比较典型的后果是这个对象的派生部分不会被析构。
将析构函数声明为虚拟就可以避免这种行为(不会作为基类的没必要这么写)
8.防止因为异常而离开析构函数
在析构函数中抛出异常可能会导致析构不完全。我们可以设计一个接口来捕获异常并且在一个非析构函数中处理它,避免异常打断析构。
9.绝不要在构造或析构期间调用虚拟函数
基类构造期间,虚拟函数从来不会向下匹配到派生类。取而代之的是,那个对象的行为好像它就是基类型。非正式地讲,基类构造期间,虚拟函数被禁止。
在一个派生类对象的基类构造期间,对象的类型是基类的类型。不仅虚拟函数会解析到基类,而且用到运行时类型信息的语言构件也会将那个对象视为基类类型。
10.让赋值运算符返回一个引向 *this 的引用
在为自己的类实现赋值运算符的时候,返回一个指向自己的指针。
这只是一个被所有的内建类型和标准库中的类型所遵守的惯例。
11.在 operator= 中处理自赋值
当一个对象赋值给自己的时候就会发生自赋值(如果是两个指针指向的东西相同也会发生自赋值)
我们需要确保operator=行为良好,一般在自己的类中operator=时都会把自己原本的内容删除,然后用新的指针创造对象,但是如果发生了自赋值,那么就会导致对象持有一个指向被删除内容的指针。
所以要妥善处理operator=,确保在自赋值的时候不会出现问题。
12.拷贝一个对象的所有组成部分
当你写一个拷贝函数,需要保证拷贝所有本地数据成员以及调用了所有基类中的适当的拷贝函数。
class Parent {
private:
int parentData;
public:
// 拷贝构造函数
Parent(const Parent& other) : parentData(other.parentData) { }
// 拷贝赋值运算符
Parent& operator=(const Parent& other) {
if (this != &other) {
parentData = other.parentData; // 自己需要实现的
}
return *this;
}
};
class Child : Parent {
private:
int childData;
public:
// 子类的拷贝构造函数(像是初始化列表一样使用)
Child(const Child& other) : Parent(other), childData(other.childData) { }
// 子类的拷贝赋值运算符
Child& operator=(const Child& other) {
if (this != &other) {
Parent::operator=(other); // 调用父类的拷贝赋值运算符
childData = other.childData; // 自己需要实现的
}
return *this;
}
};
如果你发现你的拷贝构造函数和拷贝赋值运算符有相似的代码,通过创建第三个供两者调用的private成员函数来消除重复。
13.使用对象管理资源
将资源放到一个对象的内部,我们可以依赖 C++ 的自动地调用析构函数来确保资源被释放。
许多资源都是动态分配到堆上的,并在一个单独的块或函数内使用,而且应该在控制流程离开那个块或函数的时候释放。
我们可以用智能指针或者引用计数智能指针确保一个资源在离开控制流程时被释放(因为它们会自动析构)
14.谨慎考虑资源管理类的拷贝行为
有些不是基于堆的资源就需要使用资源管理类(比如一个加锁的类,构造加锁,析构解锁),那么这些类就可能面临拷贝的情况,有多种根据类的性质的解决方案:
1.禁止拷贝:很多时候拷贝没有实际意义(比如加锁的类)
2.对底层的资源引用计数:有时需要保持一个资源直到最后一个使用它的对象销毁
3.拷贝底层的资源。有时你希望拥有一个资源的多个副本,前提是你需要一个资源管理类确保当你使用完它之后,每一副本都会被释放。在这种情况下,拷贝一个资源管理对象也要同时拷贝被它隐藏的资源(即深拷贝)
4.传递底层资源的所有权。有时你可能希望确保只有一个 RAII 对象引用一个裸资源,而当这个 RAII 对象被拷贝的时候,资源的所有权从被拷贝的对象传递到拷贝对象。
15.在资源管理类中准备访问裸资源
理想情况是所有的资源访问全通过资源管理类,但是很多API直接涉及资源,我们必须绕过资源管理类。之前提到很多资源管理类是包含一个智能的指针,内部包裹裸指针(资源)。我们需要把智能指针内部的裸指针暴露出来
显式转换:tr1::shared_ptr 和 auto_ptr 都提供一个 get 成员函数进行显示转换,也就是说,返回一个智能指针对象内部的裸指针(或它的一个副本)
隐式转换:tr1::shared_ptr 和 auto_ptr 也都重载了指针解引用操作符(operator-> 和 operator*),而这样就允许隐式转换到底层的裸指针
通常,显式转换更安全,隐式转换更方便
16.使用相同形式的 new 和 delete
用了new就用delete,new的时候用了中括号来声明数组,delete的时候也用中括号删除数组。
17.在一个独立的语句中将 new 出来的对象存入智能指针
如果一个参数的位置应该是智能指针,那么不要new一个对象来传入原始指针。C++无法保证执行的顺序,可能会泄露,用一个单独的语句在上面将其存入智能指针再传参。
18.使接口易于正确使用,而难以错误使用
可以通过引入新的类型来预防,让你的类型的行为与内建类型保持一致
任何一个要求客户记住某些事情的接口都是有错误使用倾向的,因为客户可能忘记做那些事情。
预防错误的方法还包括创建新的类型,限定类型的操作,约束对象的值,以及消除客户的资源管理职责
tr1::shared_ptr 支持自定义 deleter。这可以防止 cross-DLL 问题,能用于自动解锁互斥体
19.视类设计为类型设计
当设计类的时候应注意这些问题:
1.新类型的对象应该如何创建和销毁?设计你的类的构造函数和析构函数,以及内存分配和回收的函数
2.对象的初始化和对象的赋值应该有什么不同,设计你的构造函数和你的赋值运算符的行为。
3.以值传递对于新类型的对象意味着什么(拷贝构造函数定义了一个新类型的传值如何实现)
4.你的新类型的合法值的限定条件是什么。
5.新类型是否适合放进一个继承图表中。尤其考虑继承与被继承时的virtual问题
6.新类型允许哪种类型转换。
如果希望允许 T1 类型的对象隐式转型为 T2 类型的对象,要么在 T1 类中写一个类型转换函数,要么在 T2 类中写一个非显式的构造函数。而且它们都要能够以单一参数调用。
如果你希望仅仅允许显式转换,就要写执行这个转换的函数,而且还需要避免使它们的类型转换运算符或非显式构造函数能够以一个参数调用。
7.对于新类型哪些运算符和函数有意义,决定应该为类声明哪些函数。其中一些是成员函数,另一些不是。
8.不应该被接受的标准函数声明为private
9.设置类的成员的访问等级(public,private等)
10.什么是你的新类型的 "undeclared interface",对于性能考虑,异常安全以及资源使用应该提供哪些保证
11.如果你的需求更需要一个类家族,那就定义一个类模板
12.能继承就不要开新类
20.用传引用给 const 取代传值
传值在很多情况会引起拷贝,传一个 const 的引用更节省性能。
还可以避免切断问题,当一个派生类作为基类传递的时候,会切断派生类中独有的部分只剩下基类的内容被传递。
但是内建类型及 STL 中的迭代器和函数对象类型传值更合适。
21.当你必须返回一个对象时不要试图返回一个引用
虽然值会引起拷贝导致性能的消耗,但是返回引用是很危险的。
如果你返回一个在栈上存储的对象,那当这个函数结束的时候就已经被删除,你只会得到一个错误。
如果是在堆上存储的,那就要承担删除的责任。
如果是返回一个局部的 static 就要确保是在单线程的环境中。
22.将数据成员声明为 private
用 private 更加安全(还可以保证访问的语法一致性),而且还可以在访问修改的时候提供一些额外的操作。提供更多弹性。
23.用非成员非友元函数取代成员函数
非成员非友元函数能提供更强的封装性,因为它不会增加能访问数据成员的成员函数的个数。
24.当类型转换应用于所有参数时,声明为非成员函数
简单举例就是一个数值类,它理应跟别的数值进行计算,那么它就会有一个返回数值类并且接受一个数值类对象作为参数的方法。
class Rational {
public:
const Rational operator*(const Rational& rhs) const;
};
result = oneHalf * 2; // 可以
result = 2 * oneHalf; // 错误
这两个式子就只有一个可以运行,因为第一个式子的2作为参数被隐式转化了,但是第二个式子转不了。
所以我们应该将这个放到类外面,并且让它接受两个参数
const Rational operator*(const Rational& lhs, const Rational& rhs);
25.考虑支持不抛异常的 swap
首先,如果 swap 的缺省实现为你提供了可接受的性能,那就不要管
其次,如果 swap 的缺省实现效率不足,那就:
1. 提供一个能高效地交换你的类型的两个对象的值的 public 的 swap 成员函数。这个函数应该永远不会抛出异常(因为你的需要交换的数据一般是 private 的)。
2. 在你的类或模板所在的同一个 namespace 中提供一个非成员的 swap。用它调用你的 swap 成员函数(因为 std 认可完全特化而不认可添加新的模板等,所以你无法为 std 中的 swap 添加特化版本,至于在同一命名空间是因为可以确保在使用时的优先级比 std 的 swap 高)。
3. 如果你写了一个类(不是类模板),就为你的类特化 std::swap。用它也调用你的 swap 成员函数。
最后,如果你调用 swap,请确保在你的函数中包含一个 using declaration 使 std::swap 可见,然后在调用 swap 时不使用任何 namespace 限定条件。
不让 swap 的成员版本抛出异常。是因为 swap 的非常重要的应用之一是为类(以及类模板)提供强大的异常安全保证。
第 29 将提供所有的细节,但是这基于 swap 的成员版本绝不会抛出异常的假设。这一强制约束仅仅应用在成员版本上!它不能够应用在非成员版本上,因为 swap 的缺省版本基于拷贝构造和拷贝赋值,而在通常情况下,这两个函数都允许抛出异常。
如果你写了一个 swap 的自定义版本,那么,典型情况下你是为了提供一个更有效率的交换值的方法,你也要保证这个方法不会抛出异常。
作为一个一般规则,这两种 swap 的特型将紧密地结合在一起,因为高效的交换几乎总是基于内建类型的操作,而对内建类型的操作绝不会抛出异常。
26.只要有可能就推迟变量定义
你不仅应该推迟一个变量的定义直到你不得不用它之前的最后一刻,而且应该试图推迟它的定义直到你得到了它的初始化参数。
对于循环(我们在循环中要用到一个变量,可以在循坏外定义也可以在每次循环定义),除非你确信以下两点:(1)赋值比构造函数/析构函数对成本更低(2)你正在涉及你的代码中的性能敏感的部分。否则,你应该默认在循环内声明对象。
27.将强制类型转换减少到最少
首先要注意到 C++ 的强制类型转换比 C# 这类的语言更危险。
关于写法方面:
有两种没什么区别的通俗写法:
(T)expression; 和 T(expression)
还有四种新写法:
const_cast<T>(expression)
dynamic_cast<T>(expression)
reinterpret_cast<T>(expression)
static_cast<T>(expression)
-
const_cast 一般用于强制消除对象的常量性。
-
dynamic_cast 主要用于执行“安全的向下转型”,也就是说,要确定一个对象是否是一个继承体系中的一个特定类型。它是唯一不能用旧风格语法执行的强制转型。也是唯一可能有重大运行时代价的强制转型。在性能敏感的代码中,你应该特别警惕 dynamic_casts。
-
reinterpret_cast 是特意用于底层的强制转型,导致实现依赖的结果,例如,将一个指针转型为一个整数。这样的强制转型在底层代码以外应该极为罕见。
-
static_cast 可以被用于强制隐型转换。它还可以用于很多这样的转换的反向转换(例如基类指针转型为派生类指针),但是它不能将一个 const 对象转型为 non-const 对象。(只有 const_cast 可以)
为什么要避免:
强制类型转换很多时候会造成错误(尤其是指针,可能会地址偏移),再比如希望调用一下父类的一个虚函数,如果使用强制类型转换后再调用,那么就会产生一个新的临时的基类对象(包含了拷贝自子类对象的内容),如果调用的虚函数对对象产生了修改,这个修改只会作用于临时对象。
要做的:
避免强制转型的随时应用,如果需要强制转型,开发一个没有强制转型的侯选方案。
如果必须要强制转型,将它隐藏在一个函数中。客户可以用调用那个函数来代替在他们自己的代码中加入强制转型。
尽量使用新写法而不是老写法。
28.避免返回对象内部构件的“句柄”
意思是不要返回一个类内部的成员,比如用一个 public 方法返回一个 private 的引用。
除了会破坏封装以外,你的 const 的作用可能会被打破,比如有一个 const 的对象,但是调用这个对象的某个方法会返回这个对象的内部的一个数据的引用,如果我们修改这个数据,就可以作用在 const 对象上(指针和迭代器同理)。
还有可能会造成空悬,比如你用一个指针试图存储返回的结果,但是这个返回的是个临时对象(临时对象只会在这一句代码存在,结束会立刻销毁),那这个指针就不能正常工作(意思是不要让对句柄的持有比整个对象的生命周期还要长)。
29.争取异常安全的代码
当一个异常被抛出,异常安全的函数应该确保:没有资源泄露并且不允许数据结构恶化
异常安全函数提供下述三种保证之一:
-
基本保证,允诺如果一个异常被抛出,程序中剩下的每一件东西都处于合法状态。没有对象或数据结构被破坏,而且所有的对象都处于内部调和状态(所有的类不变量都被满足)。
-
强力保证,允诺如果一个异常被抛出,程序的状态不会发生变化。调用这样的函数在感觉上是极其微弱的,如果它们成功了,它们就完全成功,如果它们失败了,程序的状态就像它们从没有被调用过一样。
与提供强力保证的函数工作比与只提供基本保证的函数工作更加容易,因为调用提供强力保证的函数之后,仅有两种可能的程序状态:像预期一样成功执行了函数,或者继续保持函数被调用时当时的状态。与之相比,如果调用只提供基本保证的函数引发了异常,程序可能存在于任何合法的状态。
-
不抛出保证,允诺决不抛出异常,因为它们只做它们答应要做的。所有对内建类型的操作都是不抛出的。这是异常安全代码中必不可少的基础构件。
不抛出是最好的,但是很难做到(比如stl的容器就很容易抛出异常),一般是基本或强力保证
有一种被叫做 copy and swap 的策略可以提供强力保证,它要求先将要操作的对象复制一份,然后对复制品进行操作,如果所有操作完成还没有抛异常,就和原版进行交换。
这样可以确保如果在操作过程中抛出异常,不会出现只操作了一半的情况。
30.理解 inline 化的介入和排除
将大部分 inline 限制在小的,调用频繁的函数上。这使得程序调试和二进制升级更加容易,最小化潜在的代码膨胀,并最大化提高程序速度的几率。
不要仅仅因为函数模板出现在头文件中,就将它声明为 inline。
31.最小化文件之间的编译依赖
如果一个类中包含大量的实现细节并且被多个文件使用,那么当你修改这个类的时候,可能就会导致大量文件被重新编译连接。
我们可以只提供类的定义而不实现,但是当我们使用这个类时,编译器需要知道这个类所占的内存,如果不提供实现,编译器就不知道该如何分配内存,我们可以提供一个指向实现的指针给这个类(即为一个类装需要的数据成员另一个类只装一个指向前一个类对象的指针)
最小化编译依赖的精髓:只要能实现,就让你的头文件独立自足,如果不能,就依赖其它文件中的声明,而不是定义。所以:
-
当对象的引用和指针可以做到时就避免使用对象。仅需一个类型的声明,你就可以定义到这个类型的引用或指针。而定义一个类型的对象必须要存在这个类型的定义。
-
只要你能做到,就用对类声明的依赖替代对类定义的依赖。注意你声明一个使用一个类的函数时绝对不需要有这个类的定义,即使这个函数通过传值方式传递或返回这个类:
class Date; // class declaration Date today(); // fine — no definition void clearAppointments(Date d); // of Date is needed
通过将提供类定义的责任从你的声明函数的头文件转移到客户的包含函数调用的文件,就消除了客户对他们并不真的需要的类型的依赖。
-
为声明和定义分别提供头文件,头文件需要成对出现:一个用于声明,另一个用于定义。这些文件必须保持一致。如果一个声明在一个地方被改变了,它必须在两处都被改变。
(可以将我们使用的只包含定义的类看做是Interface或者直接用Interface)
32.确保 public inheritance 模拟 "is-a"
适用于 base classes 的每一件事也适用于 derived classes,因为每一个 derived class object 都是一个 base class object。
33.避免覆盖“通过继承得到的名字”
就像我们在不同级别作用域可以用相同名字的类型不同的变量,在子作用域会用子作用域内声明的变量覆盖父作用域的变量起到作用一样
当我们用子类继承父类的时候,相当于是在一个包含父类内容的作用域嵌入一个包含子类声明的内容的作用域,然后从子类作用域开始往上找。
假如说你在父类和子类都写了相同名字的函数(注意不是虚函数),它会先在子类找,如果没有才去父类找。
而且需要注意的是,只要子类有这个函数,哪怕调用的格式不对,它也不会去找父类中格式正确的函数,比如在父类这个函数有个参数而子类没有,但是名字相同,不能通过子类对象调用父类的函数(因为名字相同)
想避免这点需要在子类声明的时候写上希望保留的父类函数,这样的话,如果函数格式相同,还是调用子类的,如果不同,就可以找到父类的使用了:
class Base {
public:
virtual void mf1() = 0;
virtual void mf1(int);
void mf3();
void mf3(double);
...
};
class Derived: public Base {
public:
using Base::mf1;
using Base::mf3;
virtual void mf1();
void mf3();
};
//这样想要通过子类对象调用mf3(3.14)也可以使用了
34: 区分接口继承和实现继承
人话:
纯虚拟函数的好处是所有子类必须重新实现,坏处是有时候我们有的子类直接用父类的方法就行,有的子类需要重新实现,纯虚拟函数没这么灵活。
我们可以用一个纯虚拟函数和一个protected的非虚拟函数,需要重新实现的直接重新写,不需要的可以重新实现的时候调用protected函数。
非人话但是需要看:
接口继承与实现继承不同。在公开继承下,派生类总是继承基类接口。
纯虚拟函数指定仅有接口被继承。
简单虚拟函数指定接口继承加上缺省实现继承。
非虚拟函数指定接口继承加上强制实现继承。
35.考虑可选的虚拟函数的替代方法
-
使用非虚拟接口惯用法,这是用公有非虚拟成员函数包装可访问权限较小的虚拟函数的模板方法模式的一种形式。
-
用函数指针数据成员代替虚拟函数,一种策略模式的显而易见的形式。
-
用数据成员代替虚拟函数,这样就允许使用兼容于你所需要的东西的任何可调用实体。这也是策略模式的一种形式。
-
用另外一个继承体系中的虚拟函数代替单独一个继承体系中的虚拟函数。这是策略模式的习以为常的实现。
将一个机能从一个成员函数中移到类之外的某个函数中的一个危害是非成员函数没有访问类的非公有成员的途径。
36.绝不要重定义一个通过继承得到的非虚拟函数
如题,因为会违反32提到的is-a规则
37.绝不要重定义一个函数的通过继承得到的缺省参数值
当你在父类中写一个虚函数并且使用了默认参数值的话
class Shape {
enum ShapeColor { Red, Green, Blue };
virtual void draw(ShapeColor color = Red) const;
};
然后你又试图在子类中重写这个方法,并且改变了这个默认参数的值。
当你用父类变量装子类对象调用这个方法的时候,使用的参数仍然是父类中的默认参数,因为这种默认参数是静态绑定的。它的值随着你的变量声明时候的类型变化。
38.通过复合模拟 "有一个"或 "是根据……实现的"
这个就是讨论类的继承和包含关系了,复合的意思就是一个类包含另一个类
39.谨慎使用私有继承
私有继承:子类继承了父类的公有和保护成员,但是这些成员在子类中的访问权限被限制为私有。而且编译器通常不会将一个派生类对象转型为一个基类对象(也就是不能自动转类型了,比如使用父类参数的函数不能接受子类对象了,父类装子类也不行了)
只要你能就用复合,只有在绝对必要的时候才用私有继承(主要是当保护成员和/或虚拟函数掺和进来的时候,或者你发现一个类很适合偷过来用,但是继承它又不太合适就用私有继承)
-
私有继承意味着“是根据……实现的”。它通常比复合更低级,但当一个派生类需要访问保护基类成员或需要重定义继承来的虚拟函数时它就是合理的。
-
与复合不同,私有继承能使空基优化有效。这对于致力于最小化对象大小的库开发者来说可能是很重要的。
空基优化:c++为了确保每个对象有独特的地址,即使是一个什么都没有的空类对象也会占一个字节。如果用一个有内容的类继承这个空类,那这个空类本身就不会占据内存了
40.谨慎使用多继承
-
多继承比单继承更复杂。它能导致新的歧义问题和对虚拟继承的需要。
-
虚拟继承增加了大小和速度成本,以及初始化和赋值的复杂度。当虚拟基类没有数据时它是最适用的。
-
多继承有合理的用途。一种方案涉及组合从一个接口类的公有继承和从一个有助于实现的类的 私有继承。
41.理解隐式接口和编译期多态
-
类和模板都支持接口和多态。
-
对于类,接口是显式的并以函数识别特征为中心的。多态性通过虚拟函数出现在运行期。
-
对于模板参数,接口是隐式的并基于合法表达式。多态性通过模板实例化和函数重载解析出现在编译期。
42.理解 typename 的两个含义
-
在声明模板参数时,class 和 typename 是可互换的。
-
用 typename 去标识嵌套依赖类型名(就是泛型类内部包含某个类型,当你试图使用这个类型时),在基类列表中或在一个成员初始化列表中作为一个基类标识符时除外。
43.了解如何访问模板化基类中的名字
假如说我们让一个类继承一个模板类,然后在子类内部试图调用模板类内部的方法的话,会发现不允许这么做。
因为编译器认为你有可能会为这个模板类提供特化,它的特化版本不一定有这个方法。所以如果你想调用,就要向编译器保证会一直有这个方法。
可以经由 "this->" 前缀,经由 using declarations,或经由一个显式基类限定引用基类模板中的名字(这个一般最不建议,因为如果调用的是虚方法,就不会识别到子类重写的了)。
44.从模板中分离出参数无关的代码
-
模板产生多个 classes 和多个 functions,所以一些不依赖于模板参数的模板代码会引起膨胀。
-
非类型模板参数引起的膨胀常常可以通过用函数参数或类数据成员替换模板参数而消除。
-
类型参数引起的膨胀可以通过让具有相同的二进制表示的实例化类型共享实现而减少。
45.用成员函数模板 接受“所有兼容类型”
-
使用成员函数模板生成接受所有兼容类型的函数(比如实现智能指针的隐式转化)。
-
如果你为泛型化拷贝构造或泛型化赋值声明了成员模板,你依然需要声明常规拷贝构造函数和拷贝赋值运算符。
46.需要类型转换时在模板内定义非成员函数
在模板函数对实参推演生成新函数的过程中从不考虑隐式类型转换函数(也就是说不能指望编译器会自动转换类型以符合形参)
在写一个类模板时,而这个类模板提供了一些函数,这些函数指涉到支持所有参数的隐式类型转换的模板的时候,把这些函数定义为类模板内部的友元。
这样相当于在这个泛型类被填入具体类型使用的时候,连带函数也一起生成好了,而调用已经生成好了的函数可以隐式转换实参。
47.为类型信息使用特征类
traits允许你在编译过程中得到关于一个类型的信息。traits 不是 C++ 中的一个关键字或预定义结构;它们是一项技术和 C++ 程序员遵守的惯例。建立这项技术的要求之一是它在内建类型上必须和在用户定义类型上一样有效。
设计和实现一个 traits class:
-
识别你想让它可用的关于类型的一些信息(例如,对于迭代器来说,就是它们的迭代器种类)。
-
选择一个名字标识这个信息(例如,iterator_category)。
-
提供一个模板)和一系列特化(例如,iterator_traits),它们包含你要支持的类型的信息。
如何使用一个 traits class 了:
-
创建一套重载的 "worker" 函数或者函数模板(例如,doAdvance),它们在一个形参上不同。与传递的 traits 信息一致地实现每一个函数。
-
创建一个 "master" 函数或者函数模板(例如,advance)调用这些 workers,传递通过一个 traits class 提供的信息。
48.感受模板元编程
-
模板元编程能将工作从运行时转移到编译时,这样就能够更早察觉错误并提高运行时性能。
-
TMP 能用于在 policy choices 的组合的基础上生成自定义代码,也能用于避免为特殊类型生成不适当的代码。
私货:很邪门的写法,但是似乎性能会提升很多,而且是全方位的提升(似乎)。建议去看原文。这里贴一段模拟循环计算阶乘的代码。
template<unsigned n>
struct Factorial {
enum { value = n * Factorial<n - 1>::value };
};
template<>
struct Factorial<0> {
enum { value = 1 };
};
int main()
{
std::cout << Factorial<5>::value;
std::cout << endl;
std::cout << Factorial<10>::value;
}
49.了解 new-handler 的行为
-
set_new_handler 允许你指定一个当内存分配请求不能被满足时可以被调用的函数。
-
可以通过替换 global new-handler 来使每个类都能做出独特响应,当然记得要切回去。
-
nothrow new 作用有限,因为它仅适用于内存分配,随后的 constructor 调用可能依然会抛出 exceptions。
50: 领会何时替换 new 和 delete 才有意义
-
为了监测使用错误
-
为了收集有关动态分配内存的使用的统计数据
-
为了提升分配和回收的速度。通用目的的分配器通常(虽然不总是)比自定义版本慢很多,特别是如果自定义版本是为某种特定类型的 objects 专门设计的。类专用分配器是固定大小分配器的一种典范应用。如果你的程序是单线程的,而你的编译器缺省的内存管理例程是线程安全的,通过编写非线程安全分配器你可以获得相当的速度提升。当然,在得出 operator new 和 operator delete 对速度提升有价值的结论之前,确实测定你的程序以保证这些函数是真正的瓶颈。
-
为了减少缺省内存管理的空间成本。通用目的的内存管理器通常(虽然不总是)不仅比自定义版本慢,而且还经常使用更多的内存。这是因为它们经常为每个已分配区块招致某些成本。针对小对象调谐的分配器从根本上消除了这样的成本。
-
为了调整缺省分配器不适当的排列对齐。在 x86 架构上,当 doubles 按照八字节对齐时访问速度是最快的。有些随编译器提供的 operator news 不能保证 doubles 的动态分配按照八字节对齐。在这种情况下,用保证按照八字节对齐的 operator new 替换掉缺省版本,可以使程序性能得到较大提升。
-
为了聚集相关的 objects,使它们彼此靠近。如果你知道特定的数据结构通常会在一起使用,而且你想将在这些数据上工作时的页错误频率降到最低,那么为这些数据结构创建一个独立的堆以便让它们尽可能地聚集在不多的几个页上就是有意义的。new 和 delete 的 placement versions 使得完成这样的聚集成为可能。
-
为了获得不同寻常的行为。有时你想让 operators new 和 delete 做一些编译器装备版本没有提供的事情。例如,你可能想在共享内存中分配和回收区块,但是只能通过一个 C API 来管理那片内存。编写 new 的 delete 的自定义版本允许你用 C++ 衣服来遮住那个 C API。作为另一个例子,你可以编写一个自定义的 operator delete 用 zeros 复写被回收的内存以提高应用程序数据的安全性。
51.编写 new 和 delete 时要遵守惯例
-
operator new 应该包含一个设法分配内存的无限循环,如果它不能满足一个内存请求,应该调用 new-handler,还应该处理零字节请求。类专用版本应该处理对比预期更大的区块的请求。
-
operator delete 如果收到一个空指针应该什么都不做。类专用版本应该处理比预期更大的区块。
52.如果编写了 placement new,就要编写 placement delete
-
在编写一个 operator new 的 placement 版本时,确保同时编写 operator delete 的相应的 placement 版本。否则,你的程序可能会发生微妙的,断续的内存泄漏
-
当你声明 new 和 delete 的 placement 版本时,确保不会无意中覆盖这些函数的常规版本。