《More Effective C++》——读书笔记

一、基础议题(Basics)

1、仔细区别 pointers【指针】 和 references

二者之间的区别是:在任何情况下都不能用指向空值的引用,而指针则可以;指针可以被重新赋值以指向另一个不同的对象,但是引用则总是指向在初始化时被指定的对象,以后不能改变

在以下情况下使用指针:一是存在不指向任何对象的可能性;二是需要能够在不同的时刻指向不同的对象

在以下情况使用引用:总是指向一个对象且一旦指向一个对象之后就不会改变指向;重载某个操作符时,使用指针会造成语义误解

2、最好使用 C++ 转型操作符

为解决 C 旧式转型的缺点(允许将任何类型转为任何类型,且难以辨识),C++ 导入 4 个新的转型操作符(cast operators):

static_cast , const_cast , dynamic_cast , reinterpret_cast:分别是常规类型转换,去常量转换,继承转换,函数指针转换

static_cast:功能上基本上与C风格的类型转换一样强大,含义也一样但是不能把struct转换成int类型或者把double类型转换成指针类型另外,它不
能从表达式中去除const属性。用来针对一个继承体系做向下的安全转换,目标类型必须为指针或者引用。基类中要有虚函数,否则会编译出错;static_cast则没有这个限制。原因是:存在虚函数,说明它有想要让基类指针或引用指向派生类对象的情况,此时转换才有意义。由于运行时类型检查需要运行时类型信息,而这个信息存储在类的虚函数表中,只有定义了虚函数的类才有虚函数表。必须保证源类型跟目标类型本来就是一致的,否则返回 null 指针。这个函数使用的是RTTI机制,所以编译器必须打开这个选项才能编译。

const_cast:用于类型转换掉表达式的const或volatileness属性但是不能用它来完成修改这两个属性之外的事情

dynamic_cast:用于安全地沿着类的继承关系向下类型转换失败的转换将返回空指针或者抛出异常

reinterpret_cast:这个操作符被用于的类型转换的转换结果时实现时定义因此,使用它的代码很难移植最普通的用途就是在函数指针之间进行转换

#include 
using namespace std;
struct B
{
    virtual void print(){}//想要使用 dynamic_cast ,基类中必须有虚函数
};
struct D : B
{
    void print(){}
};
int fun(){}
int main()
{
    int i = static_cast<int>(3.14); //i == 3
const int j = 10;
    int *pj = const_cast<int*>(&j);
    //int *pj = (int*)(&j);     //等同于上面
    *pj = 20;
    //虽然 *pj的地址和 j 的地址是一样的,但是值却不一样。
    cout<<*pj<<endl;    //20
    cout<<j<<endl;      //10
B *b;
    dynamic_cast<D*>(b);
typedef void (*FunPtr)();
    reinterpret_cast<FunPtr>(&fun);     //尽量避免使用
}

3、绝对不要以多态方式处理数组

#include 
using namespace std;
struct B
{
    virtual void print() const{cout<<"base print()"<<endl;}
};
struct D : B
{
    void print() const{cout<<"derived print()"<<endl;}
    int id;  //如果没有此句,执行将正确,因为基类对象和子类对象长度相同  
};
int fun(const B array[],int size)
{
    for(int i = 0;i<size;++i)
    {
        array[i].print();
    }
}
int main()
{
    B barray[5];
    fun(barray,5);
    D darray[5];
    fun(darray,5);
}

array[i] 其实是一个指针算术表达式的简写,它代表的其实是 *(array+i),array是一个指向数组起始处的指针。在 for 里遍历 array 时,必须要知道每个元素之间相差多少内存,而编译器则根据传入参数来计算得知为 sizeof(B),而如果传入的是派生类数组对象,它依然认为是 sizeof(B),除非正好派生类大小正好与基类相同,否则运行时会出现错误。但是如果我们设计软件的时候,不要让具体类继承具体类的话,就不太可能犯这种错误。(理由是,一个类的父类一般都会是一个抽象类,抽象类不存在数组)

4、避免无用的 default constructors

没有缺省构造函数造成的问题:通常不可能建立对象数组,对于使用非堆数组,可以在定义时提供必要的参数。另一种方法是使用指针数组,但是必须删除数组里的每个指针指向的对象,而且还增加了内存分配量。

提供无意义的缺省构造函数会影响类的工作效率,成员函数必须测试所有的部分是否都被正确的初始化。

二、操作符(Operators)

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

定义类似功能的函数,而抛弃隐式类型转换,使得类型转换必须显示调用。例如 String类没有定义对Char*的隐式转换,而是用c_str函数来实施这个转换。拥有单个参数(或除第一个参数外都有默认值的多参数)构造函数的类,很容易被隐式类型转换,最好加上 explicit 防止隐式类型转换。

6、区别 increment/decrement 操作符的前置和后置形式

#include 
using namespace std;
class A
{
    public:
        A(int i):id(i){}
        A& operator++()
        {
            this->id += 1;
            return *this;
        }
        //返回值为 const ,以避免 a++++这种形式
        //因为第二个 operator++ 所改变的对象是第一个 operator++ 返回的对象
        //最终结果其实也只是累加了一次,a++++ 也还是相当于 a++,这是违反直觉的
        const A operator++(int)
        {
            A a = *this;
            this->id += 1;
            return a;
        }
        int id;
};
int main()
{
    A a(3);
    cout<<++a.id<<endl; //++++a;   也是允许的,但 a++++ 不允许。
    cout<<a.id<<endl;
    cout<<a++.id<<endl;
    cout<<a.id<<endl;
}

后置operator++(int) 的叠加是不允许的,原因有两个:一是与内建类型行为不一致(内建类型支持前置叠加);二是其效果跟调用一次 operator++(int) 效果一样,这是违反直觉的。另外,后置式操作符使用 operator++(int),参数的唯一目的只是为了区别前置式和后置式而已,当函数被调用时,编译器传递一个0作为int参数的值传递给该函数。
处置用户定制类型时,尽可能使用前置式,因为后置式会产生一个临时对象。

7、千万不要重载 &&, || 和 , 操作符

int *pi = NULL;
if(pi != 0 && cout<<*pi<

上面的代码不会报错,虽然 pi 是空指针,但 && 符号采用"骤死式"评估方式,如果 pi == 0 的话,不会执行后面的语句。

不要重载这些操作符,是因为我们无法控制表达式的求解优先级,不能真正模仿这些运算符。操作符重载的目的是使程序更容易阅读,书写和理解,而不是来迷惑其他人。如果没有一个好理由重载操作符,就不要重载。而对于&&,||和“,”,很难找到一个好理由。

8、了解各种不同意义的 new 和 delete

new 操作符的执行过程:

  1. 调用operator new分配内存 ;//这一步可以使用 operator new 或 placement new 重载。
  2. 调用构造函数生成类对象;
  3. 返回相应指针。

函数 operator new 通常声明如下:

void * operator new(size_t size);  //第一个参数必须为 size_t,表示需要分配多少内存。

返回值为void型指针,表示这个指针指向的内存中的数据的类型要由用户来指定。比如内存分配函数malloc函数返回的指针就是void *型,用户在使用这个指针的时候,要进行强制类型转换,如(int *)malloc(1024)。任何类型的指针都可以直接赋给 void * 变量,而不必强制转换。如果函数的参数可以为任意类型的指针,则可以声明为 void * 了。

void 有两个地方可以使用,第一是函数返回值,第二是作为无参函数的参数。(因为在C语言中,可以给无参函数传任意类型的参数,而且C语言中,没有指定函数返回值时,默认返回为 int 值)

#include 
using namespace std;
class User
{
    public:
    void * operator new(size_t size)
    {
        std::cout<<"size: "<<size<<std::endl;
    }
    void * operator new(size_t size,std::string str)
    {
        std::cout<<"size: "<<size <<"\nname: " << str<< std::endl;
    }
    int id;
};
int main()
{
    User* user1 = new User;
    User* user2 = new ("JIM")User;
    void *pi = operator new(sizeof(int));
    int i = 3;
    int *p = &i;
    pi = p;
    cout<<*(int*)pi<<endl;
}

三、异常(Exceptions)

9、利用 destructors 避免泄漏资源

#include 
#include 
void exception_fun()
{
    throw std::runtime_error("runtime_error");
}
void fun()
{
    int *pi = new int[10000];
    std::cout<<pi<<std::endl;
    try
    {
        exception_fun();    //如果此处抛出异常而未处理,则无法执行 delete 语句,造成内存泄漏。
    }
    catch(std::runtime_error& error)
    {
        delete pi;
        throw;
    }
    delete pi;
}
main()
{
    for(;;)
    {
        try { fun(); } catch(std::runtime_error& error) { }
    }
}

一个函数在堆里申请内存到释放内存的过程中,如果发生异常,如果自己不处理而只交给调用程序处理,则可能由于未调用 delete 导致内存泄漏。上面的方法可以解决这一问题,不过这样的代码使人看起来心烦且难于维护,而且必须写双份的 delete 语句。函数返回时局部对象总是释放(调用其析构函数),无论函数是如何退出的。(仅有的一种例外是当调用 longjmp 时,而 longjmp 这个缺点也是C++最初支持异常处理的原因)

所以这里使用智能指针或类似于智能指针的对象是比较好的办法:

  • auto_ptr; — 基本被弃用.
  • shared_ptr; — 共享指针, 引用计数为零就销毁对象空间.
  • weak_ptr; — weak_ptr是用来解决shared_ptr相互引用时的死锁问题. 弱引用不会增加引用计数.
  • unique_ptr; — unique_ptr 是一个独享所有权的智能指针,它提供了严格意义上的所有权.
#include 
#include 
void exception_fun()
{
    throw std::runtime_error("runtime_error");
}
void fun()
{
    int *pi = new int[10000];
    std::auto_ptr<int> ap(pi);    //用 auto_ptr 包装一下
    std::cout<<pi<<std::endl;
    exception_fun();
}
main()
{
    for(;;)
    {
        try { fun(); } catch(std::runtime_error& error) { }
    }
}

上面的代码看起来简洁多了,因为 auto_ptr 会在离开作用域时调用其析构函数,析构函数中会做 delete 动作。

10、在 constructors 内阻止资源泄漏

这一条讲得其实是捕获构造函数里的异常的重要性。

**堆栈辗转开解(stack-unwinding):**如果一个函数中出现异常,在函数内即通过 try…catch 捕捉的话,可以继续往下执行;如果不捕捉就会抛出(或通过 throw 显式抛出)到外层函数,则当前函数会终止运行,释放当前函数内的局部对象(局部对象的析构函数就自然被调用了),外层函数如果也没有捕捉到的话,会再次抛出到更外层的函数,该外层函数也会退出,释放其局部对象……如此一直循环下去,直到找到匹配的 catch 子句,如果找到 main 函数中仍找不到,则退出程序。

#include 
#include 
#include 
class B
{
    public:
        B(const int userid_,const std::string& username_ = "",const std::string address_ = ""):
        userid(userid_),
        username(0),
        address(0)
        {
            username = new std::string(username_);
            throw std::runtime_error("runtime_error");  //构造函数里抛出异常的话,由于对象没有构造完成,不会执行析构函数
            address = new std::string(address_);
        }
        ~B()    //此例中不会执行,会导致内存泄漏
        {
            delete username;
            delete address;
            std::cout<<"~B()"<<std::endl;
        }
    private:
        int userid;
        std::string* username;
        std::string* address;
};
main()
{
    try { B b(1); } catch(std::runtime_error& error) { }
}

C++拒绝为没有完成构造函数的对象调用析构函数,原因是避免开销,因为只有在每个对象里加一些字节来记录构造函数执行了多少步,它会使对象变大,且减慢析构函数的运行速度。

一般建议不要在构造函数里做过多的资源分配,而应该把这些操作放在一个类似于 init 的成员函数中去完成。这样当 init 成员函数抛出异常时,如果对象是在栈上,析构函数仍会被调用(异常会自动销毁局部对象,调用局部对象的析构函数,见下面),如果是在堆上,需要在捕获异常之后 delete 对象来调用析构函数。

11、禁止异常流出 destructors 之外

这一条讲得其实是捕获析构函数里的异常的重要性。第一是防止程序调用 terminate 终止(这里有个名词叫:堆栈辗转开解 stack-unwinding);第二是析构函数内如果发生异常,则异常后面的代码将不执行,无法确保我们完成我们想做的清理工作。

之前我们知道,析构函数被调用,会发生在对象被删除时,如栈对象超出作用域或堆对象被显式 delete (还有继承体系中,virtual 基类析构函数会在子类对象析构时调用)。除此之外,在异常传递的堆栈辗转开解(stack-unwinding)过程中,异常处理系统也会删除局部对象,从而调用局部对象的析构函数,而此时如果该析构函数也抛出异常,C++程序是无法同时处理两个异常的,就会调用 terminate()终止程序(会立即终止,连局部对象也不释放)。另外,如果异常被抛出,析构函数可能未执行完毕,导致一些清理工作不能完成。

所以不建议在析构函数中抛出异常,如果异常不可避免,则应在析构函数内捕获,而不应当抛出。 场景再现如下:

#include 
struct T
{
    T()
    {
        pi = new int;
        std::cout<<"T()"<<std::endl;
    }
    void init(){throw("init() throw");}
    ~T()
    {
        std::cout<<"~T() begin"<<std::endl;
        throw("~T() throw");
        delete pi;
        std::cout<<"~T() end"<<std::endl;
    }
    int *pi;
};
void fun()
{
    try{
        T t;
        t.init();
    }catch(...){}
//下面也会引发 terminate
    /*
    try
    {
        int *p2 = new int[1000000000000L];
    }catch(std::bad_alloc&)
    {
        std::cout<<"bad_alloc"<
}
void terminate_handler()
{
    std::cout<<"my terminate_handler()"<<std::endl;
}
int main()
{
    std::set_terminate(terminate_handler);
    fun();
}

12、了解 "抛出一个 exception ” 与 “传递一个参数” 或 “调用一个虚函数”之间的差异

抛出异常对象,到 catch 中,有点类似函数调用,但是它有几点特殊性:


#include 
void fun1(void)
{
        int i = 3;
        throw i;
}
void fun2(void)
{
        static int i = 10;
        int *pi = &i;
        throw pi; //pi指向的对象是静态的,所以才能抛出指针
}
main()
{
        try {
                fun1();
        }
        catch (int d)
        {
                std::cout << d << std::endl;
        }
        try {
                fun2();
        }
        catch (const void* v)
        {
                std::cout << *(int*)v << std::endl;
        }
}

如果抛出的是 int 对象的异常,是不能用 double 类型接收的,这一点跟普通函数传参不一样。异常处理中,支持的类型转换只有两种,一种是上面例子中演示的从"有型指针"转为"无型指针",所以用 const void* 可以捕捉任何指针类型的 exception。另一种是继承体系中的类转换,可见下一条款的例子。
另外,它跟虚拟函数有什么不同呢?异常处理可以出现多个 catch 子句,而匹配方式是按先后顺序来匹配的(所以如 exception 异常一定要写在 runtime_error异常的后面,如果反过来的话,runtime_error异常语句永远不会执行),而虚函数则是根据虚函数表来的。

13、以 by reference 方式捕捉 exceptions

class B
{
public:
	B(int id_):id(id_){}
	B(const B& b){id = b.id;std::cout<<"copy"<<std::endl;}
	int id;
};
 
void fun(void)
{
	static B b(3);  //这里是静态对象
	throw &b;   //只有该对象是静态对象或全局对象时,才能以指针形式抛出
}
 
main()
{
try{
    fun();
 }
catch(B* b)    //这里以指针形式接收
 {
     std::cout<<b->id<<std::endl;    //输出3
 }
}

用指针方式来捕捉异常,上面的例子效率很高,没有产生临时对象。但是这种方式只能运用于全局或静态的对象(如果是 new 出来的堆中的对象也可以,但是该何时释放呢?)身上,否则的话由于对象离开作用域被销毁,catch中的指针指向不复存在的对象。接下来看看对象方式和指针方式:

class B
{
public:
    B(){}
    B(const B& b){std::cout<<"B copy"<<std::endl;}
    virtual void print(void){std::cout<<"print():B"<<std::endl;}
};
 
class D : public B
{
public:
    D():B(){}
    D(const D& d){std::cout<<"D copy"<<std::endl;}
    virtual void print(void){std::cout<<"print():D"<<std::endl;}
};
 
void fun(void)
{
	D d;
	throw d;
}
 
main()
{
    try{
        fun();
    }catch(B b) //注意这里
    {
        b.print();
    }
}

上面的例子会输出:

可是如果把 catch(B b) 改成 catch(B& b) 的话,则会输出:

该条款的目的就是告诉我们,请尽量使用引用方式来捕捉异常,它可以避免 new 对象的删除问题,也可以正确处理继承关系的多态问题,还可以减少异常对象的复制次数。

14、明智运用 exception specifications

C++提供了一种异常规范,即在函数后面指定要抛出的异常类型,可以指定多个:

#include 
void fun(void) throw(int,double);    //必须这样声明,而不能是 void fun(void);
void fun(void) throw(int,double)    //说明可能抛出 int 和 double 异常
{
    int i = 3;
    throw i;
}
main()
{
    try{
        fun();
    }catch(int d)
    {
        std::cout<<d<<std::endl;
    }
}

15、了解异常处理的成本

大致的意思是,异常的开销还是比较大的,只有在确实需要用它的地方才去用。

四、效率(Efficiency)

16、谨记 80-20 法则

大致的意思是说,程序中80%的性能压力可能会集中在20%左右的代码处。那怎么找出这20%的代码来进行优化呢?可以通过Profiler分析程序等工具来测试,而不要凭感觉或经验来判断。

17、考虑使用 lazy evaluation(缓式评估)

懒惰计算法的含义是拖延计算的时间,等到需要时才进行计算其作用为:能避免不需要的对象拷贝,通过使用operator[]区分出读写操作,避免不需要的数据库读取操作,避免不需要的数字操作但是,如果计算都是重要的,懒惰计算法可能会减慢速度并增加内存的使用

18、分期摊还预期的计算成本

  • over-eager evaluation, 如果程序常常用到某个计算, 设计一份数据结构以便能够及有效率地处理需求

  • (caching)利用告诉缓存暂存使用频率高的内容.

  • caching是分期摊还预期计算成本的一种做法. 预先取出是另一种做法. ◦系统调用往往比进程内的函数调用慢.

  • 较快的速度往往导致较大的内存, 空间交换时间.

  • 较大对象比较不容易塞入虚内存分页(virtual memory page)或缓存分页(cache page). ◦对象变大可能会降低性能, 因为换页活动会增加,
    缓存命中率(cache hit rate)会降低.

19、了解临时对象的来源

C++真正所谓的临时对象是不可见的——只要产生一个 non-heap object 而没有为它命名,就产生了一个临时对象。它一般产生于两个地方:

一是函数参数的隐式类型转换,

二是函数返回对象时。

任何时候,只要你看到一个 reference-to-const参数,就极可能会有一个临时对象被产生出来绑定至该参数上;任何时候,只要你看到函数返回一个对象,就会产生临时对象(并于稍后销毁)。

20、协助完成“返回值优化(RVO)”

不要在一个函数里返回一个局部对象的地址,因为它离开函数体后就析构了。不过在GCC下可以正常运行,无论是否打开优化;而在VS2010中如果关闭优化,就会看到效果。

这个条款想说的是:const Test fun(){ return Test(); } 比 const Test fun(){Test test; return test; } 好,更能使编译器进行优化。
不过现在看来,在经过编译器优化之后,这两个好像已经没有什么区别了。

21、利用重载技术避免隐式类型转换

#include 
using namespace std;
struct B
{
    B(int id_):id(id_){}
    int id;
};
const B operator+(const B& b1,const B& b2)
{
    return B(b1.id + b2.id);
}
//const B operator+(const B& b1,int i)    //如果重载此方法,就不会产生临时对象了
//{
//  return B(b1.id + i);
//}
int main()
{
    B b1(3),b2(7);
    B b3 = b1+ b2;
    B b4 = b1 + 6;    //会把 6 先转换成B对象,产生临时对象
}

22、考虑以操作符复合形式(op=)取代其独身形式(op)

使用 operator+= 的实现来实现 operator= ,其它如果 operator*=、operator-= 等类似。

#include 
class B
{
    public:
        B(int id_):id(id_){}
        B& operator+=(const B& b)
        {
            id +=  b.id;
            return *this;
        }
        int print_id(){std::cout<<id<<std::endl;}
    private:
        int id;
};
B operator+(const B& b1,const B& b2) //不用声明为 B 的 friend 函数,而且只需要维护 operator+= 即可。
{
    return const_cast<B&>(b1) += b2; //这里要去掉b1的const属性,才能带入operator+= 中的 this 中
}
int main()
{
    B b1(3),b2(7),b3(100);
    (b1+b2).print_id(); //10    这里进行 operator+ 操作,会改变 b1 的值,这个不应该吧
    b1.print_id();      //10
    b3+=b1;
    b3.print_id();      //110
}

23、考虑使用其它程序库

提供类似功能的程序库,可能在效率、扩充性、移植性和类型安全方面有着不同的表现。比如说 iostream 和 stdio 库,所以选用不同的库可能会大幅改善程序性能。

24、了解 virtual functions、multiple inheritance、virtual base classes、runtime type identification 的成本

在使用虚函数时,大部分编译器会使用所谓的 virtual tables 和 virtual table pointers ,通常简写为 vtbls 和 vptrs 。vtbl 通常是由 “函数指针” 架构而成的数组,每一个声明(或继承)虚函数的类都有一个 vtbl ,而其中的条目就是该 class 的各个虚函数实现体的指针。
虚函数的第一个成本:必须为每个拥有虚函数的类耗费一个 vtbl 空间,其大小视虚函数的个数(包括继承而来的)而定。不过,一个类只会有一个 vtbl 空间,所以一般占用空间不是很大。

不要将虚函数声明为 inline ,因为虚函数是运行时绑定的,而 inline 是编译时展开的,即使你对虚函数使用 inline ,编译器也通常会忽略。
虚函数的第二个成本:必须为每个拥有虚函数的类的对象,付出一个指针的代价,即 vptr ,它是一个隐藏的 data member,用来指向所属类的 vtbl。

调用一个虚函数的成本,基本上和通过一个函数指针调用函数相同,虚函数本身并不构成性能上的瓶颈。

虚函数的第三个成本:事实上等于放弃了 inline。(如果虚函数是通过对象被调用,倒是可以 inline,不过一般都是通过对象的指针或引用调用的)

  • 虚函数真正的运行时期成本发生在和inlining互动的时候. 虚函数不应该inline. ◦因为inline函数需要在编译器将函数本体拷贝, 而virtual意味着等待, 直到运行期才知道运行谁.

  • 多重继承往往导致虚基类的需求(virtual base class), 会形成更复杂和特殊的虚表.

  • 一个类只需一份RTTI信息(运行时类型识别), 当某种类型至少拥有一个虚函数, 才能保证检验该对象的动态类型.

    • RTTI的设计理念根据类的虚表(vtbl)来实现的.

    • RTTI的空间成本只需在每个类的虚表(vtbl)内增加一个条目, 即一个类型信息(type_info)对象空间.

#include 
struct B1 { virtual void fun1(){} int id;};
struct B2 { virtual void fun2(){} };
struct B3 { virtual void fun3(){} };
struct D : virtual B1, virtual B2, virtual B3 {virtual void fun(){}  void fun1(){}  void fun2(){}   void fun3(){}};
int main()
{
    std::cout<<sizeof(B1)<<std::endl;   //8
    std::cout<<sizeof(B2)<<std::endl;   //4
    std::cout<<sizeof(B3)<<std::endl;   //4
    std::cout<<sizeof(D)<<std::endl;    //16
}
//D 中只包含了三个 vptr ,D和B1共享了一个。

五、技术(Techniques,Idioms,Patterns)

25、将 constructor 和 non-member functions 虚化

这里所谓的虚拟构造函数,并不是真的指在构造函数前面加上 virtual 修饰符,而是指能够根据传入不同的参数建立不同继承关系类型的对象。
被派生类重定义的虚函数可以与基类的虚函数具有不同的返回类型。所以所谓的虚拟复制构造函数,可以在基类里声明一个 virtual B* clone() const = 0 的纯虚函数,在子类中实现 virtual D* clone() const {return new D(*this);}

同样的,非成员函数虚化,这里也并不是指使用 virtual 来修饰非成员函数。比如下面这个输出 list 中多态对象的属性:

#include 
#include 
#include 
using namespace std;
class B
{
    public:
        B(string str):value(str){}
        virtual ostream& print(ostream& s) const = 0;
    protected:
        string value;
};
 
class D1 : public B
{
    public:
        D1(int id_):B("protect value"),id(id_){}    //子类构造函数中,要先调用基类构造函数初始化基类
        ostream& print(ostream& s) const{cout<<value<<"\t"<<id;;return s;}  //如果基类虚函数是 const 方法,则这里也必须使用 const 修饰
    private:
        int id;
};
 
class D2 : public B
{
    public:
        D2(int id_):B("protect value"),id(id_){}    //子类构造函数中,要先调用基类构造函数初始化基类
        ostream& print(ostream& s) const{cout<<value<<"\t"<<id;return s;}
    private:
        int id;
};
 
ostream& operator<<(ostream& s,const B& b)
{
    return b.print(s);
}
 
int main()
{
    list<B*> lt;
    D1 d1(1);
    D2 d2(2);
    lt.push_back(&d1);
    lt.push_back(&d2);
    list<B*>::iterator it = lt.begin();   
   while(it != lt.end())
    {
        cout<<*(*it)<<endl;     //D1   D2
        it++;
    }
}

在这里,即使给每一个继承类单独实现友元的 operator<< 方法,也不能实现动态绑定,只会调用基类的方法。那么,在基类里写 operator<< 用 virtual 修饰不就行了吗?遗憾的,虚函数不能是友元。

26、限制某个 class 所能产生的对象数量

只有一个对象:使用单一模式,将类的构造函数声明为private,再声明一个静态函数,该函数中有一个类的静态对象不将该静态对象放在类中原因是放在函数中时,执行函数时才建立对象,并且对象初始化时间确定的,即第一次执行该函数时另外,该函数不能声明为内联,如果内联可能造成程序的静态对象拷贝超过一个限制对象个数:建立一个基类,构造函数中计数加一,若超过最大值则抛出异常;析构函数中计数减一

编程点滴:

将模板类的定义和实现放在一个文件中,否则将造成引用未定义错误(血的教训);

静态数据成员需要先声明再初始化;

用常量值作初始化的有序类型的const静态数据成员是一个常量表达式(可以作为数组定义的维数);

构造函数中抛出异常,将导致静态数组成员重新初始化

27、要求(或禁止)对象产生于 heap 中

析构函数私有,有一个致命问题:妨碍了继承和组合(内含)。

#include 
#include 
using namespace std;
class B1    //禁止对象产生于 heap 中
{
    public:
        B1(){cout<<"B1"<<endl;};
    private:
        void* operator new(size_t size);
        void* operator new[](size_t size);
        void operator delete(void* ptr);
        void operator delete[](void* ptr);
};
class B2    //要求对象产生于 heap 中
{
    public:
        B2(){cout<<"B2"<<endl;};
        void destroy(){delete this;}//模拟的析构函数
    private:
        ~B2(){}
};
int main()
{
    //B1* b1  = new B1; //Error!
    B1 b1;
    //B2 b2;    //Error
    B2* b2 = new B2;
    b2->destroy();
} 

28、Smart Pointer(智能指针)

  • 智能指针是一个看起来, 用起来, 感觉起来都像内建指针, 但是提供了更多机能的一种对象.

    • 资源管理;
    • 自动的重复写码工作.
  • 以智能指针取代C++内建指针:

    • 构造和析构: 何时被产生以及何时被销毁.
    • 赋值和复制(Assignment and Copying), 复制和赋值其所指对象, 执行所谓的深拷贝(deep copy).
    • 解引用(Dereferencing): 智能指针有权决定所指之物发生了什么事情.
    • 采用lazy fetching方法.
  • 远程过程调用(remote procedure calls, RPC).

  • 只能指针的构造, 赋值和析构

    • 只有当确定要将对象所有权传递给函数的某个参数时, 才应该以by value方式传递auto_ptrs.
  • 实现解引操作符(Dereferencing Operators):

    • 返回引用值.
  • 测试智能指针是否为null:

    • 提供一个隐式类型转换操作符来进行测试.
  • 将智能指针(smart pointers) 转换为内建指针(Dumb Pointers).

    • 不要提供对内建指针的隐式转换操作符, 除非不得已.
  • 智能指针(Smart Pointers)和继承有关的类型转换

    • 每个只能指针有个隐式类型转换操作符, 用来转换至另一个只能指针类.
    • 函数调用的自变量匹配规则;
    • 隐式类型转换函数;
    • template函数的暗自实例化;
    • 成员函数模板(member function templates)等技术.
  • 智能指针与const:

    • const用来修饰被指之物, 或是指针本身, 或是两者都可以. 智能指针也具有同样的弹性.
    • 对于智能指针只有一个地方可以放置const: 只能放置与指针身上, 不能置于所指的对象.
    • non-const转换至const是安全的, 从const转换至non-const则不安全.
  • 自己实现的智能指针不容易实现, 了解和维护.

29、Reference counting(引用计数)

使用引用计数后,对象自己拥有自己,当没有人再使用它时,它自己自动销毁自己因此,引用计数是个简单的垃圾回收体系

在基类中调用delete this将导致派生类的对象被销毁

写时拷贝:与其它对象共享一个值直到写操作时才拥有自己的拷贝它是Lazy原则的特例

精彩的类层次结构:
RCObject类提供计数操作;
StringValue包含指向数据的指针并继承RCObject的计数操作;
RCPtr是一个灵巧指针,封装了本属于String的一些计数操作

30、Proxy classes(替身类、代理类)

  • 凡是用来代表(象征)其他对象的对象, 常被称为proxy object(替身对象), 替身对象的类称为代理类.

    • 二维数组是观念上并不存在的一维数组.
  • 读取动作是所谓的右值运用(rvalue usage); 写动作是所谓的左值运用(lvalue usages).

  • 返回字符串中字符的proxy, 而不返回字符本身.

  • 对于一个proxy, 只有3间事情可做:

    • 产生它;
    • 以它作为赋值动作的目标(接收端).
    • 以其他方式使用它.
  • Proxy 类很适合用来区分operator[]的左值运用和右值运用.

  • 对proxy取址所获得的指针类型和对真是对象取址所获得的指针类型不同.

  • 用户将proxy传递给接受引用到非const对象的函数.

  • ploxies难以完全取代真正对象的最后一个原因在于隐式类型转换.

  • proxy 对象是一种临时对象, 需要被产生和被销毁.

  • 类的身份从与真实对象合作转移到与替身对象(proxies)合作, 往往会造成类语义的改变, 因为proxy 对象所展现的行为常常和真正的行为有些隐微差异.

31、让函数根据一个以上的对象类型来决定如何虚化

  • 面向对象函数调用机制(mutil-method): 根据所希望的多个参数而虚化的函数; — C++暂时不支持.

  • 消息派分(message dispatch): 虚函数调用动作.

  • 虚函数+RTTI(运行时期类型辨识):

    • 虚函数可以实现single dispatch, 利用typeid操作符来获取一个类的类型参数值.
  • 虚函数被发明的主要原因:

    • 把生产及维护"以类型为行事基准的函数"的负荷, 从程序员转移给编译器.
  • 只用虚函数:

    • 将double dispatching以两个single dispatches(两个分离的虚函数调用)实现出来:
      • 一个用来决定第一对象的动态类型.
      • 另一个用来决定第二对象的动态类型.
    • 编译器必须根据此函数所获得的自变量的静态类型(被声明时的类型), 才能解析出哪一组函数被调用.

六、杂项讨论(Miscellany)

32、在未来时态下发展程序

要用语言提供的特性来强迫程序符合设计,而不要指望使用者去遵守约定。比如禁止继承,禁止复制,要求类的实例只能创建在堆中等等。处理每个类的赋值和拷贝构造函数,如果这些函数是难以实现的,则声明它们为私有。

所提供的类的操作和函数有自然的语法和直观的语义,和内建类型(如 int)的行为保持一致。

尽可能写可移植性的代码,只有在性能极其重要时不可移植的结构才是可取的。

多为未来的需求考虑,尽可能完善类的设计。

33、将非尾端类设计为抽象类

如果有一个实体类公有继承自另一个实体类,应该将两个类的继承层次改为三个类的继承层次,通过创造一个新的抽象类并将其它两个实体类都从它继承因此,设计类层次的一般规则是:非尾端类应该是抽象类在处理外来的类库,可能不得不违反这个规则

编程点滴:抽象类的派生类不能是抽象类;实现纯虚函数一般不常见,但对纯虚析构函数,它必须实现

34、如何在同一个程序中结合 C++ 和 C

  • 结合C++和C程序需要考虑的问题:

    • 名称重整(name mangling):
      • 名称重整(name mangling)是C++中的一种程序, 为每个函数编出独一无二的名称.
      • 绝不要重整其他语言编写函数的名称.
      • 压制名称重整(name mangling), 必须在C++中使用extern “C” { … }指令. — 进行C连接.
      • 不同编译器以不同的方法进行重整名称.
    • static的初始化:
      • 在main之前执行的代码: static class对象, 全局对象, namespace内的对象, 文件范围(file scope)内的对象, 其构造函数都在main函数之前执行.
  • 动态内存分配:

    • C++中使用new和delete, C中使用malloc和free.
  • 数据结构的兼容性:

    • structs可以安全地在C++和C之间往返.
  • 在同一程序中混用C++和C, 应该记住以下几个简单规则:

    • 确定C++和C编译器产出兼容的目标文件(object file).
    • 将双方都使用的函数声明为extern “C”.
    • 如果可能, 尽量在C++中撰写main.
    • 总是以delete删除new返回的内存, 总是以free释放malloc返回的内存.
    • 将两个语言间的数据结构传递限制于C所能了解的形式; C++structs如果内涵非虚函数, 倒是不受此限制.

35、让自己习惯于标准 C++ 语言

  • 新的语言特性:

    • RTTI, 命名空间(namespace), bool, 关键字mutable, 关键字explicit, enums作为重载函数的自变量所引发的类型晋升转换, 在类中为const static成员变量设定初值.
  • STL(standard template library) — C++标准程序库中最大的组成部分.

    • 迭代器(iterators)是一种行为类似指针的对象, 针对STL 容器而定义.

你可能感兴趣的:(读书笔记,effective)