Effective C++ (暂停更新)

目录
  1. 基础知识
    1. C++ 模板特化
    2. C++ 11 default
    3. 嵌套从属类型
    4. 友元函数
    5. C++ 的迭代器类型
    6. typeid
  2. 《Effective C++》 笔记
    1. 1: 将 C++ 看成联邦语言
    2. 2: 尽量使用 const, enum, inline, template 代替宏定义
    3. 3: 多用 const
    4. 4: 确保所有的变量在使用前都初始化了
    5. 5: 了解 C++ 默默编写并调用了哪些函数
    6. 6: 若不想使用编译器自动生成的函数,就该明确拒绝
    7. 7: 为多态墓类声明 virtual 析构函数
    8. 8: 到让异常逃离析构函数
    9. 9: 不要再构造和析构过程中调用 virtual 函数
    10. 10: 让 opterator= 返回一个 refrence to *this
    11. 11: 在 operator= 中处理自我复制
    12. 12: 复制对象时勿忘其每一个成员
    13. 13: 以对象管理资源
    14. 14: 在资源管理类中小心 copy 行为
    15. 15: 在资源管理类中提供对原始资源的访问
    16. 16: 使用成对的 new 和 delete 时要采取相同形式
    17. 17: 以独立语句将 newd 对象置入智能指针
    18. 18: 让接口容易被正确的使用,不容易被错误使用
    19. 19: 设计 class 犹如设计 type
    20. 20: 宁以 pass-by-refrence-to-const 替换 pass-by-value
    21. 21: 必须返回对象时,别妄想返回其 refrence
    22. 22: 将成员都声明为 private
    23. 23: 宁以 non-member, non-friend 替换 member 函数
    24. 24: 如果有大量的参数需要类型转换,请使用 non-member 函数
    25. 25: 考虑写出一个不抛异常的 swap 函数
    26. 26: 尽可能延后变量定义式的出现时间
    27. 27: 尽量少做转型动作
    28. 28: 避免返回 handles 指向对象内部成分
    29. 29: 为 “异常安全” 而努力是值得的
    30. 30: 透彻了解 inlining 的里里外外
    31. 31: 将文件间的编译依赖关系降至最低
    32. 32: 确定你的 public 继承服从 is a 的关系
    33. 33: 避免覆盖继承带来的名称
    34. 34: 区分接口继承和实现继承
    35. 35: 考虑虚函数以外的其他选择
    36. 36: 绝不重新定义继承而来的 non-virtual 函数
    37. 37: 绝不重新定义继承而来的默认参数值
    38. 38: Model has-a or is-implemented-in-terms-of through composition
    39. 39: 明智而审慎的使用 private 继承
    40. 40: 明智而审慎的使用多重继承
    41. 41: 了解隐式接口和编译器多态
    42. 42: 了解 typename 的双重意义
    43. 43: 学习处理模板化基类内的名称
    44. 44: 将与参数无关的代码抽离 templates
    45. 45: 运用成员函数模板,接受所有兼容类型
    46. 46: 需要类型转换时请为模板定义非成员函数
    47. 47: 请用 traits classes 表现类型信息
    48. 48: 认识 template 元编程 ( TMP - template metaprogramming )
    49. 49: 了解 new-handler 的行为
    50. 50: 了解 new 和 delete 的合理替换时机
    51. 51: 编写 new 和 delete 时需固守常规
    52. 52: 写了 placement new 也要写 placement delete
    53. 53: 不要轻易忽视编译器警告
    54. 54: 让自己熟悉 TR1 在内的标准程序库
    55. 55: 让自己熟悉 bootst
  3. 其他
    1. string 并不是一个类
    2. RAII ( Resource Acquisition Is Initialization ) 获取资源即初始化
基础知识

此处仅会记录我在学习过程中遇到的一些自己在某个阶段容易弄混的概念,因此这是一个并不完备的知识点集合,也是一个看上去有点混乱的集合。

C++ 模板特化

首先摘抄一段模板的定义:“模板就是实现代码重用机制的一种工具,它可以实现类型参数化,即把类型定义为参数, 从而实现了真正的代码可重用性。模版可以分为两类,一个是函数模版,另外一个是类模版。” From: C++ 模板 全特化与偏特化

那么什么是模板的特化?模板特化是模板的一个特殊情况,比如说,你之前已经定义了一个模板,但是在定义的模板中并不支持某种特殊情况,那么这个时候可能我们需要对模板再这种特殊情况做特殊说明。

举个例子,比如你先定义了一个求两个参数是否相等的模板,如下所示:

template<typename T>
bool EQ(T a, T b){
    return a == b;
}

但你知道这个函数并不能处理 T == float 的情况,因此,此时我们需要对 float 类型做一个特殊的处理,如下所示:

template<>
bool EQ(float a, float b)
{
 return ( abs(a-b) < 10e-3 );
}

而模板分为函数模板和类模板,模板的特化分为全特化和偏特化,其中类模板支持全特化和偏特化,而函数模板仅支持全特化。

接下来介绍一下全特化和偏特化。全特化即需要制定模板中所有模板参数的具体类型,而片特化只需要制定其中的一部分。如下所示:

存在模板
template<typename T1, typename T2>
class A{
public:
    void func1( T1 a, T2 b ){
        ...
    }
    ...
};

template<typename T1, typename T2>
void func1( T1 a, T2 b ){
    ...
}

// 全特化
template<>
class A<float, int>{
    void func1( float a, int b ){
        ...
    }
    ...
}

template<>
void func1( float a, int b ){
    ...
}

// 偏特化
template<T>
class A<float, T>{
    void func1( float a, T b ){
        ...
    }
    ...
}

此外,还找到一篇感觉讲解比较全的文章:深入理解特化与偏特化

C++ 11 default

原文: https://blog.csdn.net/fengbingchun/article/details/52475155

在 C+11 中, 对于 defaulted 函数, 编译器会为其自动生成默认的函数定义体, 从而获得更高的代码执行效率, 也可免除程序员手动定义该函数的工作量。

C++ 的类有四类特殊成员函数,它们分别是:默认构造函数 / 析构函数 / 拷贝构造函数 / 拷贝赋值运算符。 这些类的特殊成员函数负责创建、初始化、销毁,或者拷贝类的对象。如果程序员没有显式地为一个类定义某个特殊成员函数,而又需要用到该特殊成员函数时,则编译器会隐式的为这个类生成一个默认的特殊成员函数。 当存在用户自定义的特殊成员函数时,编译器将不会隐式的自动生成默认特殊成员函数,而需要程序员手动编写,加大了程序员的工作量。并且手动编写的特殊成员函数的代码执行效率比编译器自动生成的特殊成员函数低。

C++11 标准引入了一个新特性: defaulted 函数。程序员只需在函数声明后加上 "=default", 就可将该函数声明为 defaulted 函数, 编译器将为显式声明的 defaulted 函数自动生成函数体。

defaulted 函数特性仅适用于类的特殊成员函数,且该特殊成员函数没有默认参数。

defaulted 函数既可以在类体里(inline)定义,也可以在类体外(out-of-line)定义。

如下所示:

class Foo
{
	Foo(int x);         // Custom constructor
	Foo() = default;    // The compiler will now provide a default constructor for class Foo as well
}
嵌套从属类型

顾名思义也就是一个定义于一个 class 内的 class ,如下所示, B 即是 A 的嵌套从属类型:

class A{
    class B{
        xxx
    };
};
友元函数

以下的解释摘自: http://c.biancheng.net/view/169.html

私有成员只能在类的成员函数内部访问,如果想在别处访问对象的私有成员,只能通过类提供的接口(成员函数)间接地进行。这固然能够带来数据隐藏的好处,利于将来程序的扩充,但也会增加程序书写的麻烦。

C++ 是从结构化的C语言发展而来的,需要照顾结构化设计程序员的习惯,所以在对私有成员可访问范围的问题上不可限制太死。

C++ 设计者认为, 如果有的程序员真的非常怕麻烦,就是想在类的成员函数外部直接访问对象的私有成员,那还是做一点妥协以满足他们的愿望为好,这也算是眼前利益和长远利益的折中。因此,C++ 就有了友元(friend)的概念。打个比方,这相当于是说:朋友是值得信任的,所以可以对他们公开一些自己的隐私。

友元分为两种:友元函数和友元类。

下面是一个友元函数的例子,其中函数 A.func1 和全局函数 func3 为 B 的友元函数,可以访问 B 中默认的 private 数据/接口。

class A{
public:
    void func1( void );
};

class B{
private:
    int p_member;
    int func2( void );

    friend void A::func1(void);
    friend void func3(void);
};

void A::func1( void ){
    B       b;
    int     tmp1;

    tmp1 = b.p_member;
    b.func2();
}

void func3(void){
    B       b;
    int     tmp1;

    tmp1 = b.p_member;
    b.func2();
}

下面是一个友元类的例子,其中类 A 为 B 的友元类, A 中所有的接口都可以访问 B 中的 private 数据/接口。

class A{
public:
    void func1( void );
};

class B{
private:
    int p_member;
    int func2( void );

    friend class A;
};

void A::func1( void ){
    B       b;
    int     tmp1;

    tmp1 = b.p_member;
    b.func2();
}
C++ 的迭代器类型

比较专业的描述请参考:https://zh.cppreference.com/w/cpp/iterator, 下面是我自己的一些记录。

C++ 有五类迭代器, Input / Output / forward / bidirectional / random_access, 迭代器的分类的依据并不是迭代器的类型,而是迭代器所支持的操作。换句话说,某个类型只要支持相应的操作,就可以作为迭代器使用。 Input 迭代器只能向前移动,而且 client 只能读取该类迭代器的内容; Output 迭代器也只能向前移动,而且 Client 只能向里面写入东西; Forward 迭代器也只能向前移动,不过可以反复读取和修改当前内容; Bidirectional 迭代器则可以进行双向移动,而且拥有 Forward 迭代器的所有功能; Random_Access 迭代器则更加强大,可以向前或者向后移动任意单位的距离,当然企业拥有 Bidirectional 的所有功能。

针对该五类迭代器 C++ 为其定义了专用的 TAG STRUCT 加以确认,如下所示:

struct input_iterator_tag {I;
struct output iterator tag {I;
struct forward_iterator_tag: public input_iterator_tag { };
struct bidirectional_iterator_tag: public forward_iterator_tag { };
struct random_access_iterator_tag: public bidirectional iterator_tag {I;
typeid
《Effective C++》 笔记

这里记录的是我阅读 《Effective C++》这本书所做的一些笔记,下面目录的排版也按照原文的章节顺序排列, 其中 1-4 点为 让自己习惯 C++ 部分的内容, 其中 5-12 点为 设计与声明 部分的内容, 其中 13-17 点为 让自己习惯 C++ 部分的内容, 其中 26-31 点为 实现 部分的内容, 其中 32-40 点为 集成与面向对象设计 部分的内容, 其中 41-48 点为 模板与泛型编程 部分的内容, 其中 49-52 点为 定制 new 与 delete 部分的内容, 其中 53-55 点为 杂项讨论 部分的内容。

1: 将 C++ 看成联邦语言

C++ 大体上可看坐 C, C with class, Temeplate, STL 的一个集合
2: 尽量使用 const, enum, inline, template 代替宏定义

主要有以下三个问题:
1. 因为宏定义如果出现为题编译其很难定位问题点
2. 其次宏定义是全局性的无法进行区域化
3. 最后我们在设计宏定义表达式的时候很容易出问题
3: 多用 const

const 语法虽然变化多端,但并不莫测高深。如果关键字 const 出现在星号左边,表示被指物是常量;如果出现在星号右边,表示指针自身是常量;如果出现在星号两边,表示被指物和指针两者都是常量。
const 修饰的函数,返回的值也应该是 const 类型的
如果一定要在 const 函数中修改成员,那么可以使用 mutable 修饰该成员。
4: 确保所有的变量在使用前都初始化了

C part of C++ 类的变量不会确保初始化, non-C part of C++ 会确保初始化
但你很难确保你记住这些规则,最好的方法就是在使用前先初始化所有变量。
因为成员的初始化时更具申明的顺序来的,而不是根据初始化列表的顺序来的,这有时候会造成困惑,为了解决这种困惑,你应该尽量保证初始化列表中的顺序与类成员申明顺序一致。
static 对象时再程序退出的时候才会被销毁。(即静态变量的析构函数时程序退出时才会调用)
static 成员因为可能分布在程序中的任何位置,因此编译器要推断其合理的初始化顺序是几乎不可能的,因此一个消除这个问题的方法是将 non-local static 对象放到一个自己的专属函数内。 这些函数返回一个支详该对象的 refrence 中。换句话说也就是 non-local static 被 local static 代替了。而 local static 对象会在“该函数被调用期间”“首次遇到该对象之定义式” 时被初始化。 而这样又带来一个好处,如果该对象不被访问到,那么该变量的初始化永远不会被调用到(节省初始化资源消耗)。
如果 static 对象在多线程中访问,有可能会产生初始化竞态,从而导致不可知的程序错误,消除这种问题的一种方式是在对象单线程状态时,逐个调用一下每个静态函数的 refrence-returning 函数。
5: 了解 C++ 默默编写并调用了哪些函数

如果你定义了一个类,即使你不对下列函数进行申明,但只要有人尝试调用它们,编译器会为你声明它们。这些函数有 default 构造函数, copy 构造函数, 析构函数, copy assignment 拷贝复制函数,如下所示:
class Empty{
public:
    Empty();
    Empty(const Empty &);
    ~Empty();
    Empty & opterator=( const Empty & );
}

non-static refrence 和 non-static const 对象都不能进行拷贝复制。
main.cpp:6:7: error: non-static reference member ‘std::string& NameObject<int>::nameValue’, cannot use default assignment operator
main.cpp:6:7: error: non-static const member ‘const int NameObject<int>::objectValue’, cannot use default assignment operator

non-static refrence 和 non-static const 只能在初始化列表进行初始化。
6: 若不想使用编译器自动生成的函数,就该明确拒绝

将拷贝函数和拷贝复制函数申明为私有函数并不足以在所有条件下禁用编译自动生成的函数,因为友元函数和成员函数还是可以调用到。
而解决办法就是,在申明为似有函数的基础上,不去实现此类函数。
当然也可以去继承下面的类:
class Uncopyable{
protect:
    Uncopyable();
    ~Uncopyable();
private:
    Uncopyable(const Uncopyable &);
    Uncopyable &operator=(const Uncopyable &);

}
7: 为多态墓类声明 virtual 析构函数

可能你将 new 的派生类给基类的指针,而在使用之后 delete 基类指针。此时如果基类析构函数为非虚函数,那么将会调用基类的析构函数,而不是派生类的析构函数。
但是如果你将任何函数的析构函数都定义为虚函数,这会造成不必要的开销,因为一旦一个类中存在一个虚函数,则该类对象会存在一个析函数标指针。这将导致不必要的内存开销。
此外 C++ 的很多内建类(比如 std::string )的基类的析构函数都不是虚函数,因此如果你有类派生自这些内建类,千万要注意妥善的使用 delete。
8: 到让异常逃离析构函数

如果你有一个装在对象的容器,再容器销毁的时候,该容器中所有的对象的析构函数都应该被调用,而此时如果有异常发生,那么系统再同一时刻出现两个异常会导致导致意想不到的问题。
而最好的办法是,不要让异常暴漏到析构函数之外。对此有两种操作:
1. 捕获该异常,打印报错信息,退出程序
2. 捕获该异常,打印报错信息,不让之继续传播(吞下异常)
如果你在运行某个操作时候可能发生异常,那么最好的办法是,在析构函数之外简历一个操作函数,确保在析构之前该函数被调用,并将异常处理放在该函数内。
9: 不要再构造和析构过程中调用 virtual 函数

因为假设你的基类的构造函数内有调用虚函数,那么此时在你申明一个派生类的时候,构造函数内调用的虚函数并不是派生类里面的虚函数,而是基类里面的虚函数。这可能不是你意料中的。
10: 让 opterator= 返回一个 refrence to *this

这是实现连锁赋值的必要条件 ( a=b=c ),也是 C++ 内建库的通用规则
该规则同样适用于 +=, -=, *= 等等
11: 在 operator= 中处理自我复制

首先你得识别哪些是自我复制。第一种情况是指针和引用之间的复制,比如 *a = *b;, ref_a = *b, a[n] = a[m] ....
如果拷贝构造和拷贝赋值是系统生成的,一般没有什么问题(虽然这种自我复制仅仅是在浪费资源)。
但是一旦这两个函数是你自己编写的,那么很有可能因为你的疏忽导致非常严重的后果。比如下面这种情况:
A &opterator=(const A &a){
    delete memp;
    memp = new mMem( *a.memp ); // a.memp 可能已经被自己释放
    return *this;
}

另外还一种情况时,当你的函数操作多个对象,而多个对象可能是同意对象的时候,要确保其行为依旧正确。
12: 复制对象时勿忘其每一个成员

第一种情况是你编写了自己的拷贝函数,很完美,但后续你可能又为该类增加了一些成员,但在拷贝的时候又忘记了,像下面这种情况:
class A{
    int b;
    int c; 后面添加的
public:
    A( A &a ){
        b = a.b
    }
}

还有一种情况是你继承了类 A, 但是忘记在你的拷贝函数中执行 A 的拷贝函数了。如下所示:
class B : public A{
    int d;
public:
    B( B &b )
        :A( b )    // 千万不要忘记这一行
    {
        d = b,d

        A( b );    // 或者这一行
    }

    B & operator=( const B &b ){
        d = b,d
        
        A::operator=( b )    // 也不要忘记这一行
    }
}

另外,记住,不要在拷贝复制函数里面调用拷贝函数,也不要在拷贝函数里面调用拷贝复制函数,这会有意想不到的问题。 比较好又比较通用的做法是,创建一个 private 通用函数 ( 比如为 init ) ,然后让拷贝构造函数和拷贝函数都调用这个函数 ( init );
13: 以对象管理资源

为了防止内存泄漏,(也就是你申请的指针忘记释放),C++ 准备了两种机制帮助你解决这一担忧,但这两种机制都有自己的限制,你需要仔细权衡。
一个是 auto_ptr 一个是 tr1::share_ptr
其中 auto_ptr 不能有多个 auto_ptr 指向同一指针。因为 auto_ptr 会在离开作用域,释放资源的时候调用析构函数 delete 掉申请的内存,如果有两个 auto_ptr 指向同一个指针,则会导致内存释放多次的异常情况。
但这并不意味着你不可以将 auto_ptr A 赋值给 auto_ptr B, 在你赋值之后 auto_ptr A 指向的地址将会被设置为 NULL。
另外一个就是引用计数型智慧指针 - RCSP - refrence-counting smart pointer -- tr1::share_ptr
share_ptr 会对指针的引用进行计数,当引用变为 0 的时候就会释放指向的内存,但也有该指针处理不了的情况,比如,两个 share_ptr 存在互指的情况。
只要注意以上几点,share_ptr 还是能大大解放生产力的。
14: 在资源管理类中小心 copy 行为

在某些时候我们可能需要自己创建资源管理类,为了确保资源被合理的利用,我们必须保证资源不会被提前释放或者泄露。因此这本书总结了几个原则:
1. 不允许拷贝(可以参考第六点),不会被拷贝,资源就不会存在管理资源泄露的风险
2. 如果允许多个对象持有一个资源,那么我们可能需要统计资源的使用情况(比如计数),当资源计数为 0 的时候自动将资源释放掉。需要特别注意的是,复制的时候你要带上公共的资源管理区域。
3. 如果你允许对资源进行复制,那么你需要确保对你管理的资源进行深度复制。
4. 如果你需要实现类似 auto_ptr 的功能,即统一时刻只允许一个对象持有资源,那么你需要妥善的做好资源的转移工作。
15: 在资源管理类中提供对原始资源的访问

你可能经常使用智能指针来管理你的内存资源,但可能有时候你会要使用到原始资源的部分。那么智能指针针对这种情况提供了 get() 方法。 如果你想更加自然的使用,也可以使用含有隐式转换的 ->*
或者使用隐式转换
class A{
public:
    ...
    operator A() const{
        return origin_point;
    }
    ...
}
    

但使用这种方法很容易导致指针的滥用,而智能指针又不能统计到这些资源的使用情况,这导致的情况就是,指针可能被释放,但还在其他地方被调用。
因此如果遇到这种使用场景,那么最好的办法还是使用 get(), 至少你在使用的时候知道自己是获取了一个智能指针的原始资源。
当然,如果你自己创建了一个资源管理类,你可能也需要考虑到这方面。
16: 使用成对的 new 和 delete 时要采取相同形式

因为编译器存在的差异,你使用 new [] 创建的对象可能并不能使用 delete 进行删除,因为 newdelete 是配对的。 而 new [] 是和 delete [] 配对的。
而 typedef 定义式很容易掩盖原来的类型,因此在编写代码的时候很容易忘记这一点,因此我们不建议使用 typedef 来对数组数据类型来做 alias。
另外一个方面,因为如果你在 class 内部使用指针规划成既可以指向数组,又可以指向普通对象,这时也要非常注意,在析构的时候需要妥善的做出处理。
相反 C++ STL 中提供了非常多的特性来处理数组的情况,比如 stringvector ·
17: 以独立语句将 newd 对象置入智能指针

假如有一个这样的两个函数:int A();, void B( std::tr1::share_ptr<C> c, int a )
然后我们需要完成这样一个操作: B( std::tr1::share_ptr<C>( new C ), A() )
但因为不同编译器的实现方式不同,new C, std::tr1::share_ptr<C> c = , A() 的调用顺序可能不一样
加入 A() 如果存在异常的话,那么就有可能导致 new C 泄漏
处理这种情况最妥善的办法就是将一二步骤独立出来,将上述表达式分成两个部分处理,如下所示:
std::tr1::share_ptr c( new C );
B( c, A() );
    
18: 让接口容易被正确的使用,不容易被错误使用

原文给的是一个关于设置日期的例子,如:Data( int year, int day, int month ), 而这个接口中参数类型都为 int 类型,因此调用的时候很容易写混,而且很容易也很越界。 我们当然可以在这个函数内部进行边界检查,而且这一般也是必要的,但是这一般要到运行时才发现。而如果这些参数是动态生成,测试程序覆盖不全面,有可能还会导致潜在的缺陷。
因此,一个比较好的方式是我们为每一个参数都设计一个独特的类型,让用户在编译阶段就将问题发现出来。
比如我们可以将这个接口设计成这样:Data( const Year &. const Day &, const Month & )。这从一方面规避了参数顺序弄混的问题:
而针对 Month 这种只有有限个数值的类型,我们甚至可以将数值封装成函数。进而将数值限定到固定的值域中,这种方法主要用于代替 enum, 因为 enum 实际上是一个整形,可以从整形强制转换得到。 方式如下:
class Month{
public:
    static Month Jan(){ return Month(1); };
    static Month Feb(){ return Month(2); };
    ...
    static Month Dec(){ return Month(12); };
    int getInt( void ) const{
        return month;
    }
private:
    int month;
    explicit Month( int val ):
        month( val ){
        
    };
}
// 可以直接这样调用
Date( Month::Jan(), Day(30), Year(2015) );
    

当然我们上面只是我们处理数据的一种方式,在 Day 和 Year 中可能还是得加参数检查才行。

另外一个让我们的代码更意使用的一点是,尽量保证代码风格一致,当然这离不开程序员对代码领域的熟练度。举几个很通用的例子:
1. STL 中的容器,同意义的操作基本上接口名称都一致,比如: push_back, size() ...
2. 在标准库字符串处理库中,靠前的参数为输出参数,靠后的参数为输入参数, 比如: strcpy( dest, source ), snprintf( buf, len, patten, params... )
当然使用正确的变量名和函数名称也是非常重要的。

最后,如果你有一个接口,这个接口会创建个对象,并返回其指针,那么怎么去释放该对象是你需要仔细考虑的问题。 而文中给出的答案就是,返回类型为 tr1::share_ptr<T>。 这至少会让调用者意识到,该指针需要而且可能会被回收。
19: 设计 class 犹如设计 type

在你设计一个 class 的时候,你要意识到,重载,操作符,内存的分配和归还,对象的初始化和终结都是需要你考虑的,因此按照原文的说法:“你要带着和语言设计者当初设计原始内存时一样谨慎的态度来设计你的 class”。
下面是一个不完全的检查清单:
  • 对象应该如何被创建和销毁?
  • 对象的初始化和对象的赋值应该有什么样的差别?这可能会涉及到:构造函数,赋值函数,拷贝复制函数
  • 对象应该是以 passed by value 来传递还是 passed by refrence of const 来传递。
  • 对象中属性的值域,函数入参的边界检查是否都准备妥善?
  • 是是否继承了其他的 class, 你是否有遵守你继承 class 的规范 ( virtual, non-virtual )? 你的 class 是否会被其他 class 继承?如果可能被继承你是否要考虑你的函数申明(比如析构函数)?
  • 你新的 type 需要怎样的转换? 如果你需要,那么你是否有编写类型转换函数?比如你的构造函数可能被标注为 explicit
  • 你的 class 应该具备那些操作符,和函数?你需要仔细思考这些操作符/函数的意义。
  • 编译器默认给你生成的函数是你需要的吗?
  • 什么是新 class 的"未声明接口" (undeclared interface)? 它对效率、异常安全性〈见条款 29) 以及资源运用(例如多任务锁定和动态内存)提供何种保证? 你在这些方面提供的保证将为你的 class 实现代码加上相应的约束条件。-- 原文复制
  • 的新 class 有多么一般化? 或许你其实并非定义一个新 class, 而是定义一整个 class 家族。果真如此你就不该定义一个新 class, 而是应该定义一个新的 class template。-- 原文复制
  • 真的需要一个新 class 吗? 如果只是定义新的 derived class 以便为既有的 class 添加机能,那么说不定单纯定义一或多个 non-member 函数或 templates. 更能够达到目标。-- 原文复制
20: 宁以 pass-by-refrence-to-const 替换 pass-by-value

原因很简单,就是 pass-by-refrence-to-const 在底层的实现为指针的传递,而 pass-by-value 则是需要通过拷贝构造函数生成一个新的对象进行传递。
原因之一是:在大部分场景下你的 class 执行一次拷贝构造函数的开销往往是要远远大于指针的传递的,而且别忘了你在离开函数作用域的时候还需要调用析构函数
原因之二是:如果你有一个派生类对象,而你想把这个对象传递给一个基类对象类型的函数,这是,在传递参数时调用的就是基类的拷贝构造函数,而最后构造出来的对象可能已经和你之前传递进去的那个对象相距甚远了。
但是这也有一些例外的情况,比如 int 类型。
21: 必须返回对象时,别妄想返回其 refrence

这里我们先用反正法。你可能可以以下面的方式返回 refrence, 但都会在一些场景存在问题。如下所示:
// 1
T &func(){
    T a;
    return a;    
}
// 该函数返回值早已经释放,其行为是未定义的

// 2
T &func(){
    T *a = new T;
    return *a;
}
// 该函数会存在两个问题
// a. 该地址由谁释放
// b. 存在 T a = func(b) * func(c) * func(d); 这种场景,会存在内存泄露


// 3
T &func(){
    static T a;
    // or private T a in your class
    return a;
}
// 假如 a 和 b 都是该函数返回的 refrence, 在这种场景下会存在问题 ( a*b == c*d )
    
22: 将成员都声明为 private

1. 首先声明为 private 是为了类的封装性,如果被设置成 public, 那么成员将会在任何时间出现任何值,这将大大增加误用和异常的概率。
2. 其次大部分情况下成员的读和写是不同角色操作的,这时候我们可能需要为成员设置只读,只写。读写来管理成员的访问权限。
3. 如果我们将成员以函数的形式暴露出去,即便以后表达该成员的意义存在变化(比如说之前只需要一个成员表达,后续可能需要通过一系列函数进行推导), 那么对于使用者而言,都不需要做任何其他的操作。(比如说一个图形应用项目初期,你可能只需要展示一张静态的背景图片,而到后期,你可能需要根据不同的条件现实不同的背景图片)
4. 如果你的类会有一大批继承者,那么这个时候如果你需要修改或者取消一个成员,想想这会牵扯多大的改动???我想这应该是你不愿意看到的。
另外,对成员标注成 protected 在继承情况下的效用其实和 public 是一样的,因此给你的选择是将你的成员函数设置成 private 吧。
23: 宁以 non-member, non-friend 替换 member 函数

注意,并不是完全抛弃成员函数,按照之前的规则, class 不能抛出成员变量,然后 class 又不能从 class 中抛出接口函数,这个 class 就与世隔绝了。
这里的观点主要是说明,如果一个成员,或者一个过程,已经被抛出一次了,那么你应该尽量少的在额外的接口涉及到这些成员或者过程。
这里主要涉及的是 class 的封装性,一旦一个成员或者过程被从越多的接口暴露,那么用户就会有更多的途径来访问这些封装的资源,这主要会有两个问题:
1. 如果以后你要修改这些资源,你会要花更多的尽力去检查你的改动是否合理(因为分支变多了)
2. 因为你从多个角度为一个资源提供了访问接口,此时 class 的封装性会大大降低,这也会限制 class 的可扩展性
而文中提供的方法即是将那些必要的逻辑放到 class 的 namespace 空间中。
而文中列举的例子如下:
namespace A_SPACE{

class A{
    ...
    void B(){

    };
    void C(){

    };
    void D(){

    };
    ...
    // F 并不明智
    void F(){
        B();
        C();
        D();
    }
}

// 这个 F_... 更明智
void F_inspace( A &a ){
    a.B();
    a.C();
    a.D();
}

}
    

但我觉得这一跳有点牵强,因为大部分的其他语言都不允许在 class 之外建立函数。虽然以上两点是实际开发中会遇到的问题,但是对于熟悉 java 开发者,这可能有点做过头了。
24: 如果有大量的参数需要类型转换,请使用 non-member 函数

虽然该条规则从表面上看是一个普世规则,但是这本书中提到的例子却是一个特例,而我又没有更多这方面的经验,因此先将该例子标注在这里,以后遇到此类问题,万一还记得的话,再完善之。
虽然在构造函数一般推荐标注为显示的( explicit ), 但是在数值计算领域却是一个特例,因为隐式转换经常会让你的表达式看起来非常的简洁美观,当然也更方便开发去运用。
但在实际设计类的时候也很容易想到面向对象的原则,但这个原则在实现操作符号的时候可能存在一个问题,例子如下:
class A{
    A( int val );
    
    ...
    A operation*( A &t );
    ...

}

int a = 1;
A b( 2 );
A c = a*t; // 成功
A d = t*a; // 失败,因为编译器找不到 int::operation*( A ) 的方法,也找不到 ::operation( T t, A a ) 的方法
    

因此,这个时候我们用 A 的非成员函数( non-membere ) 可以非常轻易的解决这个问题,如下:
A operation( int val, A a ){
    ...
};

A operation( A a, int val ){
    ...
};

// 或者使用模板,前提条件是 A 要实现可能的从 T 到对象 A 的构造函数
template< typename T >
A operation( T val, A a ){
    ...
};

A operation( A a, T val ){
    ...
};

    
25: 考虑写出一个不抛异常的 swap 函数

swap 即置换两个对象,意思是将两个对象的值赋予对方。默认情况下该动作是由标准程序库提供的 swap 算法完成的。其典型的实现如下所示:
namespace std{
    template<typename T>
    void swap( T& a, T& b ){
        T temp(a);          // 该处用到了赋值函数
        a = b;              // 该处用到了拷贝赋值
        b = temp;           // ...
    }

}
    

你可能注意到,默认 swap 函数会直接调用 copy 和 copy assignment 函数进行交换。但在你的应用场景中很可能你的 class 只有极少数需要交换的属性,那使用系统默认的方法可能导致你的 swap 过程浪费过多的系统资源, 甚至你还要承受 temp 对象的构造与析构过程。此时你可能想要编写一个定制化的 swap 函数。此时你需要做的事情如下所示。
  1. 提供一个 public swap 成员函数,让之高效的置换你的类型的两个对象的值。
  2. 在你的 class 或者 template 所在的命名空间提供一个非成员( non-member ) swap 并令之调用上述 swap 成员函数。
  3. 如果你调用 swap, 可以使用一个 using namespace_name::swap 来暴漏你定义的方法,之后你可以在当前的作用域中不加 namespace 的方式来调用该 swap 函数。

最后看起来可能想这样:
narnespace WidgetStuff {
    ...

    template
    class Widget { ... };

    ...

    template
    void swap( Widget<T>& a ,
                Widget<T>& b){
        a.swap(b) ;
    }
}

template<typename T>
void doSomething(T& objl, T& obj2)
{
using std:: swap; // 令 std::swap 在此函数内可用

...
swap(objl, obj2);
...

}

    
26: 尽可能延后变量定义式的出现时间

该条规则比较容易理解,原文如下:
只要你定义了一个变量而其类型带有一个构造函数或析构函数,那么当程序的控制流 (control flow) 到达这个变量定义式时,你便得承受构造成本:当这个变量离开其作用域时,你便得承受析构成本。 即使这个变量最终并未被使用,仍需耗费这些成本,所以你应该尽可能避免这种情形。
PS: 但对于传统 C 语言开发者可能会遇到一个问题,如果你用的编译器是 C99 以前的编译器(比如 C89 ),那么如果你要考虑 C/C++ 代码的可复用性,你必须将所有的变量定义在其他逻辑操作之前。
27: 尽量少做转型动作

C++ 规则的设计目标之一是,保证"类型错误"绝不可能发生。理论上如果你的程序很"干净地"通过编译,就表示它并不企图在任何对象身上执行任何不安全、无意义、愚蠢荒谬的操作。这是一个极具价值的保证,可别草率地放弃它。 -- 原文
不幸的是,转型 (casts) 破坏了类型系统 (type system) 。那可能导致任何种类的麻烦,有些容易辨识,有些非常隐晦。如果你来自 C / Java / C# 阵营,请特别注意, 因为那些语言中的转型 ( casting ) 比较必要而无法避免,也比较不危险(与 C++ 相较)。但 C++ 不是 C ,也不是 Java 或 C#。在 C++ 中转型是一个你会想带着极大尊重去亲近的一个特性。
C++ 支持 C 的转型风格,如 (T)expression ;也支持函数转型风格,如 T(expression)。 这两种形式并无太大的区别,纯粹是小括号摆放位置不同。
同时 C++ 还支持四种新的转型方式( C++11 引入 ): const_cast<T>(expression), dynamic_cast<T>(expression), reinterpret_cast<T>(expression), static_cast<T>(expression)
canst_cast 通常被用来将对象的常量性转除( cast away the constness )。它也是唯一有此能力的 C++-style 转型操作符。
dynamic_cast 主要用来执行 "安全向下转型" ( safe downcasting ),也就是用来决定某对象是否归属继承体系中的某个类型。 它是唯一无法由旧式语法执行的动作,也是唯一可能耗费重大运行成本的转型动作(稍后细谈〉。
reinterpret_cast 意图执行低级转型,实际动作(及结果)可能取决于编译器,这也就表示它不可移植,例如将一个 pointer to int 转型为一个 int。 这一类转型在低级代码以外很少见。本书只使用一次,那是在讨论如何针对原始内存 (raw memory) 写出一个调试用的分配器 (debugging allocator) 时,见条款 50。
static_cast 用来强迫隐式转换 (implicit conversions) ,例如将 non-const 对象转为 const 对象(就像条款 3 所为) ,或将 int 转为 double 等等。 它也可以用来执行上述多种转换的反向转换, 例如将 void* 指针转为 typed* 指针,将 pointer-to-base 转为 pointer-to-derived。 但它无挂将 const 转为 non-canst (这个动作只有 canst cast 才办得到)。

为什么要搞这么复杂?老式转型不香吗?原因其一在于将在开发过程中我们会将转型进行人为的分类,从而在类型转换出问题的时候更加方便定位问题; 原因之二是方便编译器诊断错误(比如,你不能在无意识的情况下将一个 const 类型转化为 non-const 类型, 除非你使用 const_cast )。
程序员往往会存在一个误区,认为转型只是告诉编译器如何处理代码,但实际上其实不是。举一个很简单的例子, int 和 double 在内存中的表示方式完全不同,因此编译器在编译的过程中会为止生成一个转换过程,如下所示:
int i = 0;
double y = static_cast<T>(i);
    

或许这个例子非常的明显,但是下面这两个例子嘞?派生类指向的地址可不能直接赋值给基类指针(往往会存在偏移)。
// 这个嘞
class base{...};
class derived: public base{...};
derived d;
base *b = &b;  // err, 因为基类中可能存在虚函数

// 上面那个不够明显那么这个嘞
// 这个嘞, 不会 base1 和 base2 的指针都是一个吧
class base1{...};
class base2{...};
class derived: public base{...};
derived d;
base1 *b1 = &b; // err
base2 *b2 = &b; // err
    

下面是原文中一段发人深省的一段话:“但请注意,我说的是有时候需要一个偏移量。对象的布局方式和它们的地址计算方式随编译器的不同而不同, 那意味"由于知道对象如何布局"而设计的转型,在某一平台行得通,在其他平台并不一定行得通。这个世界有许多悲惨的程序员,他们历经千辛万苦才学到这堂课。”

有时候我们还会写一些误认为正确的代码,比如下面这样:
class A{
public:
    int a;
    virtual void set_a(void){
        a = 10;
    }

};

class B: public A{
public:
    int a;

    void set_a(void){
        a = 1;
        static_cast<A>(*this).set_a();
        std::cout << a << std::endl; // err; variable a still equal 1
    }
};

// 如果上面不明显,那么下面这样写嘞?
class B: public A{
public:
    int a;

    void set_a(void){
        A ca = static_cast<A>(*this);
        ca.set_a();
        std::cout << a << std::endl;
        std::cout << ca.a << std::endl;
    }
};
    


在探究 dynamic_cast设计意涵之前值得注意的是, dynamic_cast 的许多实现版本执行速度相当慢。 例如有一个很普遍的版本基于 "class 名称之字符串比较" 来确定转换地址的版本, 如果你在四层深的单继承体系内的某个对象身上执行 dynamic cast, 每一次 dynamic cast 可能会耗用多达四次的 strcmp 调用,用以比较 class 名称。-- 原文
但是有时候你可能手里只有一个指向派生类的基类地址指针,但此时你可能想要访问派生类的某些特性(这时你可能马上想到了 dynamic_cast )。慢着,你其实有两种方式可以避免使用 dynamic_cast :
方式一:使用容器,并在其中存储直接指向派生类的指针(通常是智能指针),当然,你可能无法在一个容器内存储所有的派生类指针;
方式二:在基类中为所有可能从基类访问派生类的方法都建立一个虚函数 ( virtual function );

当然,我们可能无法完全不用转型,但优良的 C++ 代码总时很少使用转型的。
28: 避免返回 handles 指向对象内部成分

该规则没有上一条那么复杂,仅仅是因为,如何你返回了指向对象内部成分的 handler ( refrence / point / iterator ) 之后, 外部的函数可能通过一些特殊的方法修改到内部的成员,而这会破坏类的封装性。 还有一个原因是,有可能在外部通过 handler 访问内部数据的时候,内部数据被释放了,从而可能导致程序进入不可知的状态,甚至崩溃。
一个稍微可以缓解外部修改内部函数的方法即使在在返回 handler 的函数,对返回参数加上 const 进行修饰如下所示:
class A{
public:
    T value;
    ...
    T &func_return_value( void ) const{

    }; // 危险,返回的 T 有可能被 修改

};

class A{
public:
    T value;
    ...
    const T &func_return_value( void ) const{

    }; // 稍微安全,去除 handler 的写权限, 如果是指针,有可能被强转

};
    

但除非万不得已,还是不建议将类的内部成员暴漏出来。
29: 为 “异常安全” 而努力是值得的

任何使用动态内存的东西(例如所有的 STL 容器),如果无法找到够的内存以满足需求,通常都会抛出一个 bad_alloc 的异常。
异常安全的三个保证:
基本承诺:如果抛出异常,程序内的任何事物任保持在有效状态下。没有任何对象或数据结构会因此而败坏,所有对象都处于一种内部前后一致的状态(例如所有的 class 约束条件都继续获得满足)。
强烈保证:如果异常被抛出,程序状态不改变。调用这样的函数需有这样的认知:如果函数成功,就是完全成功,如果函数失败,程序会回复到 “调用函数之前” 的状态。
不抛掷异常 ( nothrow ) 保证:承诺绝不抛出异常,因为它们总是能够完成它们原先承诺的功能。作用于内置类型(例如 ints ,指针等等 ) 身上的所有操作都提供 nothrow 保证。这是异常安全码中一个必不可少的关键基础材料。
接下来是一些帮助我们应付开发过程中防止异常发生的例子:
1. 内存的释放可以使用 share_ptr, 在指针赋值的过程中,上一个存在 share_ptr 实例中的对象将会被释放;
2. lock_guard 如果你用到了互斥锁, lock_guard 会帮助你在异常发生的时候确保你的锁被释放;
3. copy - and - swap, 但我觉得 copy - modify - swap 更加合适点,这几乎是异常安全强烈保证的绝对可行的一个方案,但问题就是有些耗资源。
最后,虽然你会小心翼翼的确保你的程序不出问题,但难免你会松懈,但你需要记住一点,你的程序异常安全做得有多好,依赖于你做得最不安全的那部分代码有多安全。
30: 透彻了解 inlining 的里里外外

1. inline 像函数,动作像函数,但是比宏好用得多,可以调用它们又不需蒙受函数调用所招致的额外开销。--原文
2. 编译器通常会分析 inline 的语义从而免除调用,浓缩代码。
3. inline 的会增加目标代码大小,一台内存有限的机器上,过度热衷 inlining 会造成程序体积太大(对可用空间而言)。即使拥有虚内存, inline 造成的代码膨胀亦会导致额外的换页行为 ( paging ), 降低指令高速缓存装置的击中率 (instruction cache hit rate) ,以及伴随这些而来的效率损失。换个角度说, 如果 inline 函数的本体很小,编译器针对 "函数本体" 所产出的码可能比针对"函数调用"所产出的码更小。 果真如此, 将函数 inlining 确实可能导致较小的目标码(object code) 和较高的指令高速缓存装置击中率!
4. inline 可被隐喻提出(将函数写在 class 定义式内),也可被明确提出(在函数定义式前 + inline 修饰符)。
5. inline 通常放置在头文件中,因为编译器在编译阶段会进行 inlining( 将函数替换成被调用的本体 ), 此时编译器必须知道本体长什么样子。 Templates 通常也被置于头文件内,因为它一旦被使用,编译器为了将它具现化,需要知道它长什么样子。
6. Template 的具现化与 inlining 无关。如果你正在写一个 template, 而你认为所有根据此 template 具现出来的函数都应该 inlined, 请将此 template 声明为 inline
7. 并不是所有申明为 inline 的函数都会被 inlining, 比如包含复杂调用的(循环/递归),或者大部分虚函数。这些叙述整合起来的意思就是: 一个表面上看似 inline 的函数是否真是 inline, 取决于你的建置环境,主要取决于编译器。幸运的是大多数编译器提供了一个诊断级别: 如果它们无法将你要求的函数 inline 化,会给你一个警告信息(见条款 53) 。
8. 有时候虽然编译器有意愿 inlining 某个函数,还是可能为该函数生成一个函数本体。比如被函数指针指向的一个 inline 函数。
9. 将构造和析构函数设置成 inline 是不合适的,因为编译器在构造和析构的时候可能做一些你没有显示定义的过程。
10. 最后一个建议是:大多数 inlining 限制在小型、被频繁调用的函数身上。这可使日后的调试过程和二进制升级 (binary upgradability) 更容易,也可使潜在的代码膨胀问题最小化,使程序的速度提升机会最大化。
31: 将文件间的编译依赖关系降至最低

将该条目列举再这里的主要原因时加快你的编译速度,避免你改动了一个 class 结果导致大量的代码需要进行重新建构。
而避免这一现象出现的手段即使,防止易改动的头文件在大量的文件中被引用。
而解决这个问题的方法有这么几个:
1. 前置声明,但这一方法只适用于你的类只使用了 refrence 和 指针的场景。
2. 程序库头文件应当 “完全而且仅有声明式” 的形式存在。这种作物无论是否涉及 template 都适用。
个人在项目中还没有遇到项目大到会影响编译心情的问题,因此对这条感触不深,感觉一味的追求这种效率,对工程进度反而时一种弊端。
32: 确定你的 public 继承服从 is a 的关系

如果你令 class D ("Derived门以 public 形式继承 class B ("Base") ,你便是告诉 C++ 编译器(以及你的代码读者)说,每一个类型为 D 的对象同时也是一个类 型为 B 的对象,反之不成立。 说人话:每个学生都是人,但并不是每个人都是学生。
33: 避免覆盖继承带来的名称

有时候你会有意无意的覆盖调基类中的函数,像下面这种情况,
#include <iostream>
using namespace std;

class A{
public:
    int a;
    virtual void func1(void) = 0;
    virtual void func1( int val ){
        a = 2;
    }
};

class B: public A{
public:
    using A::func1;
    virtual void func1(void){
        a = 3;
    }
};

int main( int argc, char *argv[] ){
    B b;

    b.func1();
    b.func1( 0 );
    return 0;
}
    

如果你忘记加上 using A::func1;, 那 b.func1( 0 ) 就会调用失败。而且无论 func1 是虚函数还是非虚函数都会出现这种现象。
而其原因就是 C++ 中以作用域为基础的 “名称遮掩规则” ,因此基类中的 func1 被派生类的 func1 遮蔽住了。
但我觉得这不是一个很大的问题,因为如果你真的遇到了这个问题,编译器在编译阶段就会给你像下面这样的提醒,但前提是你需要知道产生这个错误的原因,否则你可能会深深陷入抱怨 C++ 的心境了。
> g++ -o test main.cpp&& ./test
main.cpp: In function ‘int main(int, char**)’:
main.cpp:30:12: error: no matching function for call to ‘B::func1(int)’
   30 |     b.func1( 0 );
      |     ~~~~~~~^~~~~
main.cpp:18:18: note: candidate: ‘virtual void B::func1()’
   18 |     virtual void func1(void){
      |                  ^~~~~
main.cpp:18:18: note:   candidate expects 0 arguments, 1 provided
    

当然,有时候基类可能为我们准备了非常多的重载函数,但在派生类中我们可能并不需要,并不想要继承这么多函数,那使用上面的方法可能就显得不妥了,而自己实现一遍似乎也是非常低效的。 此时此刻,我们需要用到一个名为 "转交函数" 的技术( forwarding function )。如下所示:
#include <iostream>
using namespace std;

class A{
public:
    int a;

    A():a(0){
    }
    virtual void func1(void){
        a = 1;
    };
    virtual void func1( int val ){
        a = 2;
    }
    virtual void func1( double val ){
        a = 3;
    }
};

class B: public A{
public:
    // using A::func1;
    virtual void func1(void){
        A::func1( 0.1 );        // forwarding function
        // a == 3;
    }
};

int main( int argc, char *argv[] ){
    B b;
    b.func1();
    return 0;
}
    
34: 区分接口继承和实现继承

类的继承可拆分为两个维度,继承接口和继承实现。在设计类的时候我们需要清楚的考虑好我们自己的需求:
如果我们仅仅想要派生类集成接口,那么我们可以将该函数设计为纯虚函数;
如果我们想要派生类继承接口,但必要的时候也可以提供默认函数供调用。那么此时我们可以有两种方案。 第一种是提供一个纯虚函数,另外提供一个 protected 的默认非虚函数,如果用户想要调用默认实现,则可以直接调用; 第二种是提供一个纯虚函数,并且提供一个纯虚函数,并为之提供一个默认的实现(是的,你没看错,纯虚函数也可以被实现,编译器不会被报错,只是调用该实现的时候一定要制定类名)。
(其实是有三种方案,还有一种是将接口定义为一个非纯虚的虚函数,只不过这种很容易被误调用)
最后,如果我们不想要派生类重新实现某个函数,我们可以将该函数设置为非虚函数。(当然你可以重写非虚函数,但这会造成你用基类指针调用派生类时产生相当可怕的灾难)。
其实最后一项还体现在变量上,如果在基类和派生类中同时申明了同名变量,这时使用基类指针访问到的是基类的变量,而派生类 hander 访问到的是派生类的变量。 而对于这一点,很可惜,我们没有什么可应付的技术。
35: 考虑虚函数以外的其他选择

虚函数主要用于,派生类可能需要依照自身特性来改变基类默认行为的场合,而这个需求有几种替代方案,有时不妨考虑一下:
方案一:使用非虚 public 函数调用 private 的虚函数,基类依据自身场景看是否需要重写虚函数的默认实现,这种方法主要用于可能需要在虚函数提供的功能前后执行某些调用过程, 比如权限访问控制,锁控制,日志记录等场景。 当然虚函数也不一定是要 private 的,但为了防止某些代码越界调用,破坏系统的整体设计,最好还是设计成 private。
方案二: 使用函数指针替换虚函数,基类可在构造函数中依具体情况设置默认实现,或者接受派生类的替换,甚至在运行时可动态切换方法;
方案三: 使用 tr::function 替换虚函数,该方案是方案二的增强版本,该方案可兼容存在隐式转换的各种函数,如:(普通的函数指针可达不到此效果)
#include <iostream>
#include <string>
#include <tr1/functional>

using namespace std;

int func1( const char *str ){
    cout << str << endl;
    return 1;
}

short func2( const char *str ){
    cout << str << endl;
    return 2;
}

short func3( string str ){
    cout << str << endl;
    return 3;
}


int main( int argc, char *argv[] ){

    tr1::function<int (const char *)> f1;

    f1 = func1;
    cout << "test func1 ret -> " << f1( "Enter func1" ) << endl;

    f1 = func2;
    cout << "test func2 ret -> " << f1( "Enter func2" ) << endl;

    f1 = func3;
    cout << "test func3 ret -> " << f1( "Enter func3" ) << endl;

}
    

方案四: 如果一个方法需要派生类重新定义,那么是否有可能该方法并不属于该类,或者可以单独抽象成另外一个方法类? 该方案即是将方法做成一个单独的类,之后再将该方法类申明为原来那个类的成员,派生类可以将该成员替换成自己的实现。 (我能想到的一个例子是 java 中的线程类)
这里需要注意的是,将功能从类中迁移到类外的一个缺点是方法不能再访问到类内部的成员了。
36: 绝不重新定义继承而来的 non-virtual 函数

这一点很好理解,像下面这个例子,当你以基类指针访问派生类对象的一个非虚( non-virtual )函数时候,你可能以为你访问的是派生类的非虚函数,而你实际上访问的是基类的非虚函数, 原因是因为非虚函数都是静态绑定的,在编译的时候就已经计算好调用的函数地址,而从基类指针进行访问的函数,很明显会被编译器识别为基类对象的某个函数,
class A{
public:
    void func1(void){
        // do something
    }
};
class B: public A{
public:
    void func1( void ){
        // do other thing
    }
};
    
37: 绝不重新定义继承而来的默认参数值

这一点与 36 点类似,默认值是静态绑定的,像下面这个例子, 当你以基类指针去调用派生类对象的某个拥有默认参数的虚函数的时候,调用的过程是派生类的,但给的默认参数是基类的, 我想这应该不是你想看到的效果。
class A{
public:
    virtual void func1( int val = 1 ){
        cout << "A def count: " << val << endl;
    }
};
class B: public A{
public:
    virtual void func1( int val = 2 ){
        cout << "B def count: " << val << endl;
    }
};
B b;
A *a;
a = &b;
a->func1();

// 输出:
//    B def count: 1
    
38: Model has-a or is-implemented-in-terms-of through composition

我觉得中文翻译太晦涩了,因此在此处我保留英文的原文
A 继承 B, 可能意味着 A 是一种 B ( 规则 32 ), 但也可能表示 A 包含 B ( has-a ), 比如每个人都有一个地址,但我们不能说人是一个地址。
另一个概念是, A 是由 B 实现出来的 ( is-implemented-in-terms-of ), 比如我们可以使用链表实现一个集合(请查看原文)
39: 明智而审慎的使用 private 继承

对于 private 继承,编译器并不会自动的将一个派生类对象转换为基类对象;
而且基类内所有 public 和 protected 的属性都将变成 private 属性,并且无法访问基类中 private 成员;
一般而言, private 继承是非必须的,因为这可以通过在类中定义某个类的变量来实现,如下所示,其中 B 和 C 都能达到同样的效果:
class A{
public:
    void func1( void ) {
        printf( "A func1" );
    }
};

class B: private A{
public:
    void func4( void ) {
        func1();
    }
};

class C{
    A a;
public:
    void func4( void ) {
        a.func1();
    }
};
    

当然可能如果基类中存在虚函数,并且你需要继承并实现该类,这可能是你使用 private 的一个好理由,但是也并不是没有解决方法。 同样,如果你想调用 protected 属性方法,也可通过这种方式进行操作,如下所示:
class C{
public:
    virtual void func5( void ) = 0;
protected:
    int var_1;
};

class D{
    class E: public C{
    public:
        virtual void func5( void ) {
            printf( "func5\n" );
            var_1 = 1;
        }
        int get_var_1(void){
            return var_1;
        }
    };

    E e;
public:
    void func6(){
        e.func5();
        printf( "var_1 = %d\n", e.get_var_1() );
    }
};
    

最后一个可能成为我们使用 private 的一个情况可能是处于内存空间考虑,因为对于无成员函数的类,如果使用上述定义的方法, 编译器势必要给目标类分配空间,而如果使用 private 继承的话就可节省这一两个字节的空间。
我个人的观点是最好不使用 private 继承,我的原因是 private 继承并没有带来 public 继承那么大的有点, 而且继承之后内部的属性和方法与派生类的属性方法交织使用的时候,其实给其他合作者造成理解上的麻烦。
40: 明智而审慎的使用多重继承

多重继承是复杂的,但并不是不可用的,一个典型的例子是 IOFile InputFile OutputFile File
多重继承一定程度上会增加代码的复杂度,增加理解代码的难度。而且一般能用多重继承能解决的问题我们也能找到单继承的方案,因此在使用多重继承前先确定问自己一个问题 “这是否是必须的”。
如果多继承出现钻石型继承的情况下,其公共基类的派生类最好以虚继承的方式继承基类,但使用虚基类一个会使类消耗的内存变大,另一个问题是,访问基类的函数代价便高(变慢)。
虚继承在以下几个模式是比较有合理性的:
1. public 继承缝合怪: A 继承 B 和 C , A 即使 B, 也是 C。(比如 A 是一年级学生, B 是人, C 是学生)
2. public + private 缝合怪: A 是 B, 但是 A 具有 C 技能。
3. private 缝合怪: A 拥有 B 和 C 的技能(或者特性)
41: 了解隐式接口和编译器多态

classes 和 templates 都支持接口 ( interfaces ) 和多态 ( polymorphism )
对 classes 而言接口是显示的 ( explicit ), 以函数签名为中心。多态则是通过 virtual 函数发生于运行期。
对 template 而言接口是隐式的 ( implicit ), 奠基于有效的表达式。多态则是通过 template 俱现化和函数重载解析( function overloading resolution )发生于编译期间。
42: 了解 typename 的双重意义
`
在声明 template 参数的时候, typename 和 class 是完全等价的。
typename 和 class 并不完全等价的,比如,当你的 template 中存在一个嵌套从属类型名称,就必须在紧挨该名称前加上关键字 typename。 但有个例外是, typename 不可出现在成员初始化列表 ( member initialization list ) 中作为基类修饰符。比如:
template <typename C>
class C: public Base<C>::A{         // 不需要
public:
    explicit C( int x ):
        Base<C>::A( x ){            // 不需要

        typename Base<C>::A temp;   // 需要
    }
};

还有一个使用 typename 的例子是你在使用标准模板库的某些模板类的时候( 比如 std::iterator_traits ) ,如下所示:
template <typename T>
void func1( T t ){
    typename std::iterator_traits<T>::value_type temp( *t );
    
    // 另外, typename 和 typedef 是可以并排使用的,比如你想简化上述定义式
    typedef typename std::iterator_traits<T>::value_type T_value_type;

    T_value_type temp1( *t );
    T_value_type temp2( *t );
}
43: 学习处理模板化基类内的名称

本章主要介绍使用模板过程中比较复杂的一种情况,即当你要继承一个模板类并调用基模板类中的方法时出现的一些情况。如下所示:
什么时候你有可能需要用到本章的这种方案,例子之一是,可能你有类似 A 的 n 种类( 比如 A1, A2 ... An ), 但你想使用一个模板 B 了来统一管理这些类似的类。 A1, A2 ... An 有可能并不是由 A 派生而来,而仅仅是拥有 A 中类似的几个同名函数/变量。
在上面的条件下,有可能你还需要构建一个额外的日志类型 C 来管理和记录模板类 B 的使用情况, 此时你在 C 中可能需要调用到模板 B 中的一些内部方法/属性。 此时你有三种方法可以调用(如下方代码所示)。但第三种方法会有一定限制(请查看下方代码详情了解)。
#include <iostream>

using namespace std;

class A{
public:
    void func1( void ){
        printf( "A:func1\n" );
    }
    void func2( void ){
        printf( "A:func2\n" );
    }
};
class A1:public A{};
class A2:public A{};

template< typename T >
class B{
public:
    T t;
    virtual void show_func1( void ){
        t.func1();
    }
    virtual void show_func2( void ){
        t.func2();
    }
};

template< typename T >
class C: public B<T>{
public:
    using B<T>::t;
    virtual void show_func1( void ){
        printf( "C refector -> " );
        t.func1();
    }
    virtual void show_func2( void ){
        printf( "C refector -> " );
        t.func2();
    }


#if 0
    // method 2
    void record_show_func1( void ){
        this->show_func1();
    }
    // print: C refector -> A:func1

    void record_show_func2( void ){
        this->show_func2();
    }
    // print: C refector -> A:func2
#endif
#if 0
    // method 2
    using B<T>::show_func1;
    using B<T>::show_func2;
    void record_show_func1( void ){
        show_func1();
    }
    // print: C refector -> A:func1

    void record_show_func2( void ){
        show_func2();
    }
    // print: C refector -> A:func2
#endif
#if 0
    // method 3
    void record_show_func1( void ){
        B<T>::show_func1();
    }
    // print: A:func1

    void record_show_func2( void ){
        B<T>::show_func2();
    }
    // print: A:func2
#endif
};

int main( int argc, char *argv[] ){

    B<A1> ba;
    ba.show_func1();    // print: A:func1
    B<A2> ba;
    ba.show_func1();    // print: A:func1

    C<A> ca;
    ca.record_show_func1();
    ca.record_show_func2();


    return 0;
}
44: 将与参数无关的代码抽离 templates

对于模板定义中的每个函数,编译器在实例化的时候会因为输入的模板参数值(类型模板参数或者非类型模板参数)的不同,而生成不同的函数代码块, 如果你在乎最终生成的可执行程序大小,那么那些与模板参数无关的代码逻辑最好独立出来,再在模板中引用该独立出来的函数。

因非类型模板参数 (non-type template parameters) 而造成的代码膨胀,往往可消除,做法是以函数参数或 class 成员变量替换 template 参数。

类型参数(type parameters) 而造成的代码膨胀,往往可降低,做法是让带有完全相同二进制表述 (binary representations) 的具现类型 (instantiation types)共享实现码。

我的测试代码如下所示,膨胀部分只与模板参数涉及到的部分相关。像带模板参数的那个例子,增加 func2 / func3 实际并不会增加代码量(除非你调用过这些代码),因为编译器会优化掉你不调用的代码。 但在函数中携带进模板参数,则会导致编译器实例化出调用数量那么多的函数,而在非模板那个例子中则不会出现这个现象(那还用说吗?):

#include <iostream>

using namespace std;

#if 0
template< int L >
class A{
    int size;
public:
    void func1( void ){
        size = L;
        printf( "func1 :%d", L );
    }
    void func2(void){
        printf( "func2" );
    }
    void func3(void){
        printf( "func3" );
    }
};

int main( int argc, char *argv[] ){

    A<__LINE__> a0; A<__LINE__> a1; A<__LINE__> a2; A<__LINE__> a3;
    A<__LINE__> a4; A<__LINE__> a5; A<__LINE__> a6; A<__LINE__> a7;
    A<__LINE__> a8; A<__LINE__> a9;
    a0.func1();
    a1.func1();
    a2.func1();
    a3.func1();
    a4.func1();
    a5.func1();
    a6.func1();
    a7.func1();
    a8.func1();
    a9.func1();

    return 0;
}
#else
class B{
    int size;
public:
    void func1( int len ){
        size = len;
        printf( "func1 :%d", len );
    }
    void func2(void){
        printf( "func2" );
    }
    void func3(void){
        printf( "func3" );
    }
};

int main( int argc, char *argv[] ){

    B b0, b1, b2,b3,b4,b5,b6,b7,b8,b9;
    b0.func1( 0 );
    b1.func1( 1 );
    b2.func1( 2 );
    b3.func1( 3 );
    b4.func1( 4 );
    b5.func1( 5 );
    b6.func1( 6 );
    b7.func1( 7 );
    b8.func1( 8 );
    b9.func1( 9 );

    return 0;
}
#endif

PS: 下面例子在我的电脑上编译出来的结果如下所示:

// 带模板参数的例子
> g++ -o test main.cpp && ls -l test
-rwxr-xr-x 1 mojies mojies 21064 Jun 19 03:46 test

// 带不带模板参数的例子
> g++ -o test main.cpp && ls -l test
-rwxr-xr-x 1 mojies mojies 20968 Jun 19 03:46 test
45: 运用成员函数模板,接受所有兼容类型

有的时候你有一个基类 ( A ) 和一个派生类 ( B ), 而且你还有一个模板类 ( C ), 你想要将 C<B> 自由的转换成 C<A>, 比如 share_ptr<B> 可以自动的赋值给 share_ptr<A>。 你如何处理呢?

本文 ( 也是 share_ptr ) 提供的方法是使用 泛化的拷贝构造函数泛化的拷贝赋值函数 这两个形式来处理的。如下所示:

template<typename T>
class share_ptr{
    T *held_ptr;
public:
    // 泛化的拷贝构造函数
    template<class Y>
    share_ptr( Y* p ){
        ...
        held_ptr = p;
        ...
    }
    share_ptr( share_ptr<Y> const &Y ){
        ...
        held_ptr = Y.get();     // 注意: 这个手法会让编译器自动的帮我们检查 Y 和 T 之间的转换是否合法
        ...
    }
    ...

    shared-ptr& operator=(shared_ptr const& r); 
    // 泛化的拷贝赋值函数
    template<class Y>
    shared-ptr& operator=(shared_ptr<Y> const& r);
    ...
};

但是请记住: member templates 并不改变语言规则,而语言规则说,如果程序需要一个 copy 构造函数,你却没有声明它,编译器会为你暗自生成一个。 在 class 内声明泛化 copy 构造函数(是个 member template ) 并不会阻止编译器生成它们自己的 copy 构造函数( 一个 non-template ), 所以如果你想要控制 copy 构造的方方面面, 你必须同时声明泛化 copy 构造函数和 "正常的" copy 构造函数。相同规则也适用于赋值 ( assignment) 操作。

当然,这里说的成员模板函数并不仅仅是只能用于这两种场景,因此请更具你自己的情况合理使用这条规则。

46: 需要类型转换时请为模板定义非成员函数

理解作者说的这一点,你需要先有两个概念。第一个概念是友元函数,即友元函数一般不是类的内部函数。其次 template 实参推导过程中并不考虑采纳 "通过构造函数而产生的" 隐式类型转换。如下所示, 虽然我们可以接受一个模板类 A 可以通过单个 int 类型数据创建,并且也拥有一个 A<T> operator*(const A<T> & v1 ) 的成员函数,但编译器无法将 v1 推导出来。 ( method1 和 method2 都是有问题的, 而且 method2 还无法访问内部私有成员 )。

#include <iostream>

using namespace std;

template<typename T>
class A{
    T a;
    T b;
public:
    A(T val):
        a( val ),
        b( val ){
    }
    A(T val1, T val2):
        a( val1 ),
        b( val2 ){
    }
    // method1
    const A<T> operator*( const A<T> &v2 ){
        return A<T>( a * v2.a, a * v2.b );
    }
};

// method2
const A<T> operator*( A<T> &v1, A<T> &v2 ){
    return A<T>( v1.a * v2.a, v1.b * v2.b );
}

int main( int argc, char *argv[] ){
    A<int> a1( 1,2 );
    A<int> a2( 1,2 );

    auto a3 = a1*a2;        // 执行成功
    auto a4 = a1*1;         // 执行失败

    return 0;
}

但是可能你在实验上叙的功能时想, method2 无法访问到 private 数据,是不是可以将 method2 声明为 A<T> 的友元函数, 所以你就像下面这样试了一下,发现编译过了。但你收获了一个链接失败的提示。

template<typename T>
class A{
public:
    friend A<T> operator*( const A<T> &v1, const A<T> &v2 );
};

template<typename T>
A<T> operator*( const A<T> &v1, const A<T> &v2 ){
    return A<T>( v1.a * v2.a, v1.b * v2.b );
}

链接错误信息:

> g++ --std=c++17 -o test main.cpp && ./test
main.cpp:19:56: warning: friend declaration ‘A<T> operator*(const A<T>&, const A<T>&)’ declares a non-template function [-Wnon-template-friend]
   19 |         A<T> operator*( const A<T> &v1, const A<T> &v2 );
      |                                                        ^
main.cpp:19:56: note: (if this is not what you intended, make sure the function template has already been declared and add ‘<>’ after the function name here)
/usr/bin/ld: /tmp/ccV0tDQv.o: in function `main':
main.cpp:(.text+0x6a): undefined reference to `operator*(A<int> const&, A<int> const&)'
collect2: error: ld returned 1 exit status

即便你把 operator*<T> 提到 class A 之前都没用。 为什么? 我也不清楚,作者也没有讲清楚。记住吧!

解决办法是在 A 内部将 friend 函数申明好(非类内函数定义在类内????),至于长的有点不习惯,那么文中给出的方法是把函数主题迁移到外部即可。 最后整个函数看起来长这样:

#include <iostream>

using namespace std;

template<typename T> class A;
template<typename T>
const A<T> ____M( const A<T> v1, const A<T> v2 ){
    return A<T>( v1.a * v2.a, v1.b * v2.b );
}

template<typename T>
class A{
public:
    T a;
    T b;
    A(T val):
        a( val ),
        b( val ){
    }
    A(T val1, T val2):
        a( val1 ),
        b( val2 ){
    }
    friend const A<T> operator*( const A<T> &v1, const A<T> &v2 ){
        return ____M( v1, v2 );
    }
};

int main( int argc, char *argv[] ){
    A<int> a1( 1,2 );
    A<int> a2( 1,2 );

    auto a3 = a1*1;        // 执行成功

    return 0;
}

因此如果你想在你的模板类内使用隐式转换,那么或许你可以参考这样一个例子。

47: 请用 traits classes 表现类型信息

Traits 并不是 C++ 关键字或一个预先定义好的构件;它们是一种技术,也是一个 C++ 程序员共同遵守的协议。

原文介绍了一个用 traits 来处理不同容器的迭代器 avdance 函数实现的例子(该函数接受一个迭代器,和一个 int 参数,根据 int 参数前进或者后退到某个位置)。 而其实这项技术不仅仅是可以用于这种场景。该技术可以使用在多个类,拥有同一行为,但是行为有细微差别的处理过程的地方。 比如说:所有的动物都可以走,但有的你需要用两只脚走路的算法(人/鸟),有的你需要用四只脚走路的算法(猫猫狗狗),有的则可能需要用八只脚走路的算法(螃蟹/章鱼); 或者所有建筑设计都需要建模,但是桥梁的建模和立式建筑使用的建模工具和方法肯定都不一样。 下面是一个例子:

首先我们的需要准备一组 TAG 为的是给不同的类打标签:

struct TAG_A{};
struct TAG_B{};
struct TAG_C{};
struct TAG_D{}: public TAG_A;
 

然后我们需要给我们的类打上不同的 TAG, 如下所示:

class A{
public:
    class do_somthing{
    public:
        typedef TAG_A TAG;
    }
};

class A1{
public:
    class do_somthing{
    public:
        typedef TAG_A TAG;
    }
};


class B{
public:
    class do_somthing{
    public:
        typedef TAG_B TAG;
    }
};
...

然后我们为不同的 TAG 设计不同的模板函数,如下所示:

template< typename T >
void do_tag_work( T t, TAG_A ){
    // do tag A process
}

template< typename T >
void do_tag_work( T t, TAG_B ){
    // do tag B process
}
...

然后我们还需要为这些 TAG 准备以下对应的 traits 以用于查找某个类型的 TAG, 如下所示:

    
template<typename T>
struct tag_traits{
    typedef typename T::TAG tag_category;
    ...
};
  
// 指针的偏特化版本
template<typename T>
struct tag_traits<t*>{
    typedef typename T::TAG tag_category;
    ...
};

最后我们就可以为这些拥有有同样 TAG 的类提供统一类型处理函数了,如下所示: (这里运用编译器的匹配重写函数的技术,编译器会寻找匹配类型的同名函数来调用,而 typename tag_traits<T>::tag_category() 就是用于区分使用不同函数的决定条件)

template<typename T>
void tag_work( T t ){
    do_tag_work( t,
        typename tag_traits<T>::tag_category() )
}

Traits 广泛用于标准程序库。其中当然有上述讨论的 iterator traits, 除了供应 iterator_category 还供应另四份迭代器相关信息(其中最有用的是 value_type, 见条款 42) 。 此外还有 char_traits 用来保存字符类型的相关信息,以及 numeric limits 用来保存数值类型的相关信息,例如某数值类型可表现之最小值和最大值等等。 命名为 numeric limits 有点让人惊讶,因为 traits classes 的名称常以 "traits" 结束,但 numeric limits 却没有遵守这种风格。

TRl (条款 54 )导入许多新的 traits classes 用以提供类型信息,包括 is_fundamental <T> (判断 T 是否为内置类型) , is_array<T> (判断 T 是否为数组类型) ,以及 is base of<T1, T2> ( Tl 和 T2 相同,抑或 Tl 是 T2 的 base class) 。 总计 TRl 一共为标准 C++ 添加了 50 个以上的 traits classes。

48: 认识 template 元编程 ( TMP - template metaprogramming )

Template metaprogramming ( TMP, 模板元编程 ) 是编写 template-based C++ 程序并执行于编译期的过程。 如果这没有带给你异乎寻常的印象,你一定是没有足够认真地思考它。 TMP 有两个伟大的效力。 第一,它让某些事情更容易。如果没有它,那些事情将是困难的,甚至不可能的。 第二,由于 template metaprograms 执行于 C++ 编译期,因此可将工作从运行期转移到编译期。这导致的一个结果是,某些错误原本通常在运行期才能侦测到,现在可在编译期找出来。 另一个结果是,使用 TMP 的 C++ 程序可能在每一方面都更高效:较小的可执行文件、较短的运行期、较少的内存需求。 然而将工作从运行期移转至编译期的另一个结果是,编译时间变长了。 ( 元模板编程能提效和缩小代码的原因是因为 C++ 将一些判断部分和一些能在编译初期就能计算出来的结果在编译期间处理了)。

TMP 已被证明是个"图灵完全" (Turing-complete) 机器,意思是它的威力大到足以计算任何事物。使用 TMP 你可以声明变量、执行循环、编写及调用函数...... 下面是一个利用模板实现函数递归的实现,计算某个某个数字的阶乘的值:

template<typename T>
struct Factorial {
    enum { value = n * Factorial<n-1>::value };
}

而你在使用的时候 ( = Factorial<10> ),该值会直接在编译期间给出,而不是在你每次程序执行到此处的时候计算而得。(正如原文中问到的,这是否有给你汗毛直立的感觉???我没有汗毛直立的感觉,但我有醍醐灌顶的感觉)

下面是原文提到的其他三个例子,我觉得非常有意义,记录下来:

  • 第一个例子是单位检验的例子,比如速速度/时间和质量之间的关系,在某些情况下,你必须验证填入公式中的对象是符合公式要求的,但这个工作如果在程序运行期间处理,将产生大量无畏的计算资源消耗,但如果将这些检验用模板的方式, 放在编译期间,那么这些消耗将直接省下。
  • 第二个例子是矩阵优化,因为矩阵的计算过程中总是面对的大块内存的申请/拷贝/释放之类的操作,而如果使用高级模板编程,这些中间产生的内存消耗可能可以直接消除。(我没有此类经验,我相信很多人应该也有此类疑问,究竟是哪种场景?以后再补充吧!)
  • 可以生成定制的设计模式。这个很好理解,因为设计模式就是一个固定的模板,而这恰恰是模板编程的长项。

但是模板编程还是有缺点的,其一是理解起来费劲,不直观,熟练运用也得花上许多经验;其二是因为没有什么调试工具。

49: 了解 new-handler 的行为

C++ 中存在一个机制,用于处理 new 过程中内存申请异常的情况。如下所示:

namespace std{
    typedef void (*new_handler)();
    new_handler set_new_handler( new_handler p ) throw();

};

调用 set_new_handler() 的时候该函数会返回一个 new_handler 的函数指针,该返回的函数指针为之前处理 new 分配内存异常的处理函数。 因此在适当的时机,你最好将该返回的 new_handler 通过 set_new_handler() 返回给系统。

对此,本书给广大读者在实现 new_handler 的过程中提供几条建议:

  • 让更多的内存被使用
    我觉得应该解释为妥善的释放无用内存,从而使下一次的 new 能成功执行。 原文中建议的在一开始申请大块内存,后面释放给自己的 APP 使用的做法是一个恶劣竞争的例子,我觉得不可取,而且即便使用了,程序释放的内存也可能被其他程序占用,因此也不是一个万无一失的方法。
  • 安装另外一个 new_handler
    可能你的程序中存在多个 new_handler ,而且其中可能存在可以释放并得到足够空间的 new_handler , 此时你可以注册成你知道的那个。 ( new_handler 成为了 gc handler 挺好的 )
  • 卸除 new_handler
    在必要的时候要执行 set_new_handler( null ), 因为其他 new 被调用的时候可能并不想要你把异常拦截下来。(也许它已经写好完美无缺的 try catch 代码块了)
  • 抛出 bad_alloc 异常
    即让异常传递到内存申请的地方
  • 不返回
    及在必要的时候调用 abort() 或者 exit() 退出程序。

有时候你或许希望针对不同的类使用不同的内存分配失败处理过程, C++ 并没有支持 class 的专属 new_handler, 但是其实也不需要,因为你可以自己实现出这种行为。 其做法就是为你的 class 实现 operator new, 在 operator new 中先调用 set_new_handler 设置类自己专属的 new_handler, 然后再调用官方的 new 去申请内存,当然你得考虑退出的时候需要将原来的 new_handler 还原。因此这里你需要用到 13 小节提到的技术(也就是你需要设计一个资源管理器, 在构造的时候设置成指派的 new_handler ,在析构的时候设置成老的 new_handle )。如下所示:

class HandleGuard{
public:
    explicit HandleGuard( std::new_handle p):
        p_handle{p}{};
    ~HandleGuard(){
        std::set_new_handler( p_handle );
    }
private:
    std::new_handle p_handle;
    HandleGuard(const HandleGuard&);
    HandleGuard &operator=(const HandleGuard&);
}

void class_a_handler( void ){
    ...
}

class A{
    ...
public:
    static void *operator new(std::size_t size) throw( std::bad_alloc );
    ...
}

void *A::operator new( std::size_t size ) throw ( std::bad_alloc ){
    HandleGuard guard( std::set_new_handler( class_a_handler ) );
    return ::operator new( size );
}

但上面的设置真的没问题吗?我不确定,在 new 期间执行的过程是否为原子的,我没有验证过,如果在其他线程恰好也在 new, 并且替换成其他 new_handle 会怎么样勒? 或者在本线程 set_new_handler 之后,其他线程执行 new 的时候出现申请异常了会不会出问题?这是否会对我们自己设计的 new_handle 提出要求? 我不敢轻易使用这条建议。或者说保守一点的情况是,仅仅为自己的进程设计唯一的一个原子性的 new_handle

最后原文还针对 std::nothorw 做了一番论述, nothrow 即告知 new 在申请内存失败的时候不报告异常而直接返回 NULL, 这是为了照顾以前编写的一些代码。 但是即便你在 new 一个对象的时候因为申明了 nothrow 而不产生异常,但你不能保证在对象执行构造函数阶段不会产生异常,因此该功能在你不了解透彻的情况下可能给你造成一些不必要的麻烦。

50: 了解 new 和 delete 的合理替换时机

一般人也不会自己去设计 new 和 delete, 一般会出于一下几个目的: 1. 用于检测运用上的错误; 2. 为了强化效能; 3. 为了收集使用上的统计数据;4. 使用更高级的内存使用方式(共享内存)

对于第二点,又可能还有几个原因:增加分配和归还内存的速度,降低默认内存管理器带来的额外空间开销,弥补默认分配器中欠缺齐位的考虑,为了将相关对象集中存放(从而减少页错误)。

对于强化效能这里可能需要补充一点,因为编译器自带的 operator new / detele 需要权衡各种场景的性能,因此对于某些特定的场景,可能并不是最高效的。因此,如果你非常熟悉你的数据存储特性, 这时你可以利用自定义的 new / delete 去提升能效,降低内存消耗,在某些场景甚至可以优化 50% 的内存消耗。

但在自定义 new / delete 的时候还是需要注意一些问题,比如数据对其的问题(在某些平台,对不对其的数据做运算可能导致程序(甚至系统)崩溃)。

还一个可取的方法是利用其他人已经写好的,成熟的代码,比如说 Boost 程序库中的 Pool 就是一个很好的实现,在 ”大量分配小型对象“ 的场景非常有帮助。

51: 编写 new 和 delete 时需固守常规

operator new 的返回值十分单纯。如果它有能力供应客户申请的内存,就返回一个指针指向那块内存。如果没有那个能力, 就遵循条款 49 的规则,并抛出一个 bad_alloc 异常。

然而其实也不是非常单纯,因为 operator new 实际上不只一次尝试分配内存,并在每次失败后调用 new-handling 函数。 这里假设 new-handling 函数也许能够做某些动作将某些内存释放出来。 只有当指向 new-handling 函数的指针是 null, operator new 才会抛出异常。

奇怪的是 C++ 规定, 即使客户要求 0 bytes, operator new 也得返回一个合法指针。实现也很简单,当客户请求 0 byte 时,返回一个指向 1 byte 空间的地址。

许多人没有意识到 operator new 成员函数会被 derived classes 继承。 针对 class X 而设计的 operator new, 其行为很典型地只为大小刚好为 sizeof(X) 的对象而设计。 然而一旦被继承下去, 有可能 base class 的 operator new 被调用用以分配 derived class 对象,那将造成非常严重的后果。 处理此情势的最佳做法是将 "内存申请量错误" 的调用行为改来标准 operator new,像这样:

void* Base::operator new(std::size_t size) throw(std::bad_alloc){
    if (size != sizeof(Base))
        return ::operator new (size);
    ...
}

如果你打算实现 class 独有的 aπays 内存分配行为,那么你需要实现 operator new 的 array 兄弟版 operator new[]。 如果你决定写个 operator new[],记住唯一需要做的一件事就是分配一块未加工内存 (raw memory) 。 因为你无法对 array 之内迄今尚未存在的元素对象做任何事情。实际上你甚至无法计算这个 array 将含多少个元素对象。 原因之一是因为你不知道每个对象的具体大小,因为基类的 operator new[] 可能被派生类调用。因此你不能假设数组的元素个数为 申请的大小 / sizeof(BaseClass)

而设计对应的 delete 比 new 要简单的多,唯一要注意的是即使 delete 传的参数值是 nullptr 也必须是合法的。 而对于设计类成员的 operator delete 函数则需要检查一下大小,如果大小与 sizeof(ClassName) 不一致,那么你需要将该指针转移给 ::operator delete 处理。

如果你的 base classes 遗漏 virtual 析构函数, operator delete 可能无法正确运作。因为你的派生类在析构时传递给 operator delete 的 size_t 可能是不正确的, 这是"让你的 base classes 拥有 virtual 析构函数"的一个够好的理由(条款7)

52: 写了 placement new 也要写 placement delete

当你执行 A *a = new A; 的时候,你至少必须意识到两个问题:首先 new 的时候可能会失败产生异常,其次在内存分配之后会调用 A 的构造函数,这里也可能产生异常。 骑在第二个阶段产生异常之后,你是无法去处理第一节阶段申请的内存的,因此,这个工作交给了 C++ 编译器,他会帮你实现该部分异常处理。

而编译器采取的策略是 匹配与你 new 相同类型签名的 delete 函数,然后调用该函数去释放内存。如果没有匹配到怎么办?那这里会存在内存泄露。

或许你会想, new 和 delete 会有什么问题?我实现了 operator new, operator delete, operator new[], operator detele[], 还不够?ようち

首先,编译器默认情况下在 global 作用域内提供有以下形式的 operator new:

void *operator new( std::size_t ) throw( std::bad_alloc );          // normal new
void *operator new( std::size_t, void * ) throw();                  // placement new
void *operator new( std::size_t, const std::nothrow_t& ) throw();   // nothrow new ( 见 49 )


void operator delete (void* ptr) throw();                           // ordinary
void operator delete (void* ptr, void* ) throw();                   // placement
void operator delete (void* ptr, const std::nothrow_t& ) throw();   // nothrow

placement newplacement delete 是什么鬼? 不知道你以前有没有见过这种用法:A *a = new(std::nothrow) A;, 对于这种情况,在 new 阶段调用的是 nothrow new 版本,在构造函数发生异常要释放内存时调用的是 nothorw delete 版本,而在最后 delete a, 的时候调用的是 normal delete 版本。

而实际上你也可以这么用,其作用为在之前已经开辟的空间再次执行 A 的初始化过程(调用构造函数),其调用的是以上案例的 placement newplacement delete 两个版本:

A a1;
...
A *a2 = new(&a1) A;
...

但我们能做的远不止于此,比如说我们想在 new 的时候传送一个对象进去,以方便记录 new 的行为。那么我们是可以这么做的:

class Logger{
    ...
public:
    void log_info( std::string msg );
    ...
}
class A{
public:
    static void *operator new( std::size_t, Logger & ) noexcept;
    static void *operator delete(void *, Logger & ) noexcept;
    static void *operator delete(void * ) noexcept;
}

// 一下是应用部分的代码
Logger log1( TAG )

A *a = new( log1 ) A;

当然,为了方便使用,其实你还可以利用继承,或者模板等手法,建立起你的通用 new / delete 方法。(继承的时候不要忘了使用 using 申明基类相关函数的可见性,防止被重载函数覆盖)

53: 不要轻易忽视编译器警告

在实际的开发经验中,反复的验证了该点,相信不用看作者的解释大家也都能理解。 我个人的习惯是消除代码中的每一个 warning, 当然在面对跨平台代码会有点困难,但通过编译工具上的处理,机会能做到消除每一个 warning。 退而求其次的做法是,自己必须知道每一个 warning 产生的原因。

54: 让自己熟悉 TR1 在内的标准程序库

本章主要介绍在当 2006 年前 C++ 的情况,但是后续出的 C++11 已经把很多 tr1 中的功能添加到 std 标准库里面了,因此这里我仅仅做一份清单,与原文会有出入。

  • STL ( Stadard Template Library ) 标准模板库
    容器 ( containers ): array( C++11 ), deque, forward_list( C++11 ), list, map, queue, set, stack, unordered_map( C++11 ), unorder_set( C++11 ), vector
    迭代器( iterator ): 见前文
    算法( algorithm ): for_each, find, find_end, count, copy, move( C++11 ), sort, is_sorted, min, max ... 详见 ( https://cplusplus.com/reference/algorithm/?kw=algorithm )
    函数对象( function object ): less( functional ), greater( functional )
    容器适配器( container adapter ): stack( stack ), priority_queue( queue )
    函数适配器( function object adatpers ): mem_func( functional ), not1( functional )
  • iostream
  • 国际化
    wchar_t
    wstring
  • 数值处理
    complex
    valarray
  • 异常
  • 智能指针
    auto_ptr
    share_ptr
    unique_ptr
    weak_ptr
  • function
  • bind
  • 正则表达式
  • Tunples 元组
  • reference_wrapper
  • 随机数
  • 数学函数
  • 模板编程技术
    traits
    result_of

以后有时间在补充吧!

55: 让自己熟悉 bootst

boost 官网 https://www.boost.org/

作者是非常推荐 C++ 开发者来这里逛逛的,很多 C++ 的标准都是从这里发酵的,因此你也能接触到最前沿的知识。但我自认为我现在还没达到这个 level, 我摘抄翻译了官网的介绍,有兴趣的同学可以自己去查看原文以及逛逛这个网站。

Boost 提供免费的经过同行评审的可移植 C++ 源库。

我们重视库与 C++ 标准库的良好配合。 Boost libraries 旨在提供能被广泛使用到各种应用场景,并且有效的功能。 Boost license 鼓励所有的用户以最小的限制使用 Boost lib。

我们的目标是建立 “现有实践” 并提供参考实现,以便 Boost 库适合最终的标准化。 从库技术报告 (TR1) 中包含的 10 个 Boost 库开始,到自 2011 年以来 C++ 标准的每个版本,C++ 标准委员会一直依赖 Boost 作为标准 C++ 库添加的宝贵来源 .

其他
string 并不是一个类

string 是 basic_string<char> 的一个 typedef。

RAII ( Resource Acquisition Is Initialization ) 获取资源即初始化

C++ 中管理资源的一种技术,即在构造函数中创建资源,然后析构函数中创建资源

这种技术有个好处,就是,就算发生了异常, C++ 也能够保证资源的释放。

常见的 RAII 类有,std::lock_guard, std::auto_ptr, std::tr1::share_ptr ...

用户也可以创建自己的 RAII 类。

原创文章,版权所有,转载请获得作者本人允许并注明出处
我是留白;我是留白;我是留白;
posted @ 2022-04-23 09:34  Mojies  阅读(85)  评论(0编辑  收藏  举报