More Effective C++

本文深入探讨C++编程的高效策略与实践,涵盖智能指针管理、资源泄漏预防、异常处理、类型转换优化、多态应用及引用计数等关键主题,旨在提升代码质量和性能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

More Effective C++

基础

仔细区别 pointers 和 references

  • 引用不可以为空(必须指向某个对象),而指针可以为空。
    • 引用带来的好处:不需要检查空引用,也就省了相应的处理代码
    • 指针在使用前则需要判断是否为空,对应两种处理逻辑。
  • 引用必须要初始化,而指针不是必要的(但最好初始化)。
  • 指针可以被重复赋值,指向另一个对象;引用总是指向它最初获得的那个对象。
  • 重载某个操作符(例如operator[])的时候,必须返回某种“能够被当做assignment赋值对象”的东西:引用。若使用指针会造成语义误解。

  • 总结:
    • 使用指针:
      • 一是存在不指向任何对象的可能性;
      • 二是需要能够在不同的时刻指向不同的对象。
    • 使用引用:
      • 总是指向一个对象且一旦指向一个对象之后就不会改变指向;
      • 重载某个操作符时。

  • 另附其他款项:
    • 相同点:都是地址的概念。指针指向一块内存,它的内容是所指内存的地址;引用是某块内存的别名。
    • 不同点:
      • 指针是一个实体,而引用仅是个别名;
      • 引用没有 const指针有 const,const 的指针不可变
      • 指针可以有多级,但是引用只能是一级(int **p;合法 而 int &&a是不合法的)
      • ”sizeof引用”得到的是所指向的变量(对象)的大小而”sizeof指针”得到的是指针本身的大小

  • 另附引用和指针的说明
  • 从编译的角度来讲,程序在编译时分别将指针和引用添加到符号表上,
  • 符号表中记录的是变量名及变量所对应地址
    • 指针变量在符号表上对应的地址值为指针变量的地址值,
    • 而引用在符号表上对应的地址值为引用对象的地址值(与实参名字不同,地址相同)。
  • 符号表生成之后就不会再改,
    • 因此指针可以改变其指向的对象(指针变量中的值可以改),
    • 而引用对象则不能修改。

尽量使用C++风格的类型转换

  • static_cast(常规类型转换):
    • 功能上基本上与C风格的类型转换一样强大,限制也一样。
    • 限制
      • 不能把struct转换成int类型,或者把double类型转换成指针类型。
      • 它不能从表达式中去除const属性
    • 使用方式形如:
      • static_cast<type>(expression); // 如:int d = static_cast<int>(3.14);
  • const_cast(去常量转换):
    • 用来改变表达式中的常量性(constness)或变易性(volatileness)。
    • 但是不能用它来完成修改这两个属性之外的事情。
  • dynamic_cast(继承转换):
    • 用来执行继承体系中 “安全的向下转型或跨系转型动作”,用dynamic_cast把指向基类的指针或引用转换成指向其派生类或其兄弟类的指针或引用 。
    • 失败的转换将返回空指针或者抛出异常(当对引用进行类型转换时)。
    • 想要使用 dynamic_cast ,基类中必须有虚函数。也就是必须要涉及继承机制
    • 用来针对一个继承体系做向下的安全转换,目标类型必须为指针或者引用。
    • 不能用它来转换掉constness
    • 基类中要有虚函数,否则会编译出错;static_cast没有这个限制。

基类中必须要存在虚函数的原因是:

  • 存在虚函数,说明它有想要让基类指针或引用指向派生类对象的情况,此时转换才有意义。
  • 由于运行时类型检查需要运行时类型信息,而这个信息存储在类的虚函数表中,只有定义了虚函数的类才有虚函数表
  • 必须保证源类型跟目标类型本来就是一致的,否则返回 null 指针。这个函数使用的是RTTI机制,所以编译器必须打开这个选项才能编译。

  • reinterpret_cast(函数指针转换):
    • 最常用的用途是转换函数指针类型。不具有移植性。

M3 不要对数组使用多态

  • 多态和指针算术不能混合在一起使用,所以数组和多态也不能用在一起。
  • 数组中各元素的内存地址是数组的起始地址加上之前各个元素的大小得到的,如果各元素大小不一,那么编译器将不能正确地定位元素,从而产生错误。
  • 比如,array[i] 其实是一个指针算术表达式的简写,它代表的其实是 *(array+i),array是一个指向数组起始处的指针。在 for 里遍历 array 时,必须要知道每个元素之间相差多少内存,而编译器则根据传入参数来计算得知为 sizeof(Base),而如果传入的是派生类数组对象,它依然认为是 sizeof(Base),除非正好派生类大小正好与基类相同,否则运行时会出现错误。

附加说明

  • 因为数组需要根据指针算法来算出各个元素所占空间,以及各元素与数组首地址的距离。
  • 子类派生自父类,通常来讲子类对象比父类对象要大,而所谓多态就是用父类的指针或者引用去操纵子类的对象,但是数组并不具备多态性质,它的类型一旦指定就不能动态变化,因此原来用来容纳父类对象的数组却用来装载子类对象显然是装不下的,这就会导致各种各样莫名其妙的问题。
  • 另外,即使不是父子类关系,你也不要用原本设定装载类A对象的数组去装载类B对象,因为没法保证两个类对象在任何时刻都完全一样大,也没法保证你不会出错。
  • 再有,这也会导致在动态内存分配时的内存泄露问题。

M4 避免无用的缺省构造函数

  • 没有缺省构造函数造成的问题:
    • 通常不可能建立对象数组,对于使用非堆数组,可以在定义时提供必要的参数。
    • 另一种方法是使用指针数组,但是必须删除数组里的每个指针指向的对象,而且还增加了内存分配量。
  • 提供无意义的缺省构造函数会影响类的工作效率,成员函数必须测试所有的部分是否都被正确的初始化。

运算符

M5 谨慎定义类型转换函数

  • 有两种函数允许编译器进行这些的转换:
    • 单参数构造函数(single-argument constructors),是指只用一个参数即可以调用的构造函数。该函数可以是只定义了一个参数,也可以是虽定义了多个参数但第一个参数以后的所有参数都有缺省值。(所以IDE会提示添加explicit)
    • 隐式类型转换运算符。

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

M6 自增和自减操作符前缀与后缀形式的区别

  • 前缀式:operator++()
  • 后缀式:operator++(int) 多一个int类型参数,唯一目的只是为了区别前置式和后置式,当函数被调用时,编译器传递一个0作为int参数的值传递给该函数。
  • 前缀形式返回一个引用,后缀形式返回一个const类型

  • 后缀式返回const对象原因:
    • 使该类的行为和int一致,而int不允许连续两次自增后缀运算(内建类型支持前置叠加);
    • 连续两次运算实际只增一次,和直觉不符。因为第二个 operator++ 所改变的对象是第一个 operator++ 返回的对象,最终结果其实也只是累加了一次,a++++ 也还是相当于 a++,违反直觉。

  • 前缀比后缀效率更高,因为后缀要产生一个临时对象,作为返回值使用,这既需要构造也需要析构;而前缀只返回引用。处置用户定制类型时,尽可能使用前缀式。

M7 不要重载“&&”,“||”, 或“,”

  • 对于以上操作符来说,计算的顺序是从左到右,返回最右边表达式的值。
  • 如果重载的话,不能保证其计算顺序和基本类型相同。我们无法控制表达式的求解优先级,不能真正模仿这些运算符。
  • 操作符重载的目的是使程序更容易阅读,书写和理解,而不是来迷惑其他人。如果没有一个好理由重载操作符,就不要重载。

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

  • new操作符(new operator)和new操作(operator new)的区别
    • new operator:完成的功能分成两部分
      • 第一部分是分配足够的内存以便容纳所需类型的对象(调用operator new)。
      • 第二部分是它调用构造函数初始化内存中的对象(调用constructor)。
      • new操作符总是做这两件事情,你不能以任何方式改变它的行为。
    • operator new: 操作符operator new将返回一个指针,指向一块足够容纳一个string类型对象的内存
      • 定义为:void * operator new(size_t size);
      • 返回值类型是void*,因为这个函数返回一个未经处理(raw)的指针,未初始化的内存
  • new操作符还有一个特殊形式(placement new)
    • 有时有一些已经被分配但是尚未处理的(raw)内存,你需要在这些内存中构造一个对象。
    • 使用: new (buffer) Widget(widgetSize);// buffer是地址

  • 对于delete来说,应该和new保持一致,怎样分配内存,就应该采用相应的办法释放内存。
    • 调用析构函数
    • 释放内存

异常

M9 使用析构函数防止资源泄漏

对指针说再见。必须得承认:你永远都不会喜欢使用指针。

  • 使用指针时,如果在delete指针之前产生异常,将会导致不能删除指针,从而产生资源泄漏。
  • 解决办法:
    • 使用try-catch语句。
    int *pi = new int[10000];
    std::cout<<pi<<std::endl;
    try{
        func();//若此处异常而未处理,无法执行delete语句,造成内存泄漏
    }catch(std::runtime_error& error){ //捕捉所有exceptions
        delete pi;//当抛出exception时,执行该语句,避免泄露
        throw;//将exception传给调用端
    }
    delete pi;//若没有抛出exception,也要避免资源泄露
    
    • 最好的方法:智能指针。使用对象封装资源,如使用auto_ptr,使得资源能够自动被释放。
    int *pi = new int[10000];
    std::auto_ptr<int> ap(pi);//用 auto_ptr包装一下
    std::cout<<pi<<std::endl;
    func();
    

M10 在构造函数中防止资源泄漏

  • 类中存在指针时,如果构造函数中出现异常,C++拒绝为没有完成构造函数的对象调用析构函数,原因是避免开销,那么异常将导致以前初始化的其它指针成员不能删除,从而产生资源泄漏。
  • 解决办法是在构造函数中考虑异常处理,产生异常时释放已分配的资源,最好的方法是使用对象封装资源。一般建议不要在构造函数里做过多的资源分配。

M11 禁止异常流出 destructors 之外

  • 禁止异常传递到析构函数外的两个原因:
    • 能够在异常传递的堆栈辗转开解(stack-unwinding)的过程中,防止程序调用terminate终止。
    • 它能帮助确保析构函数总能完成我们希望它做的所有事情。而如果发生异常,则异常后面的代码将不执行,无法确保完成我们想做的清理工作。
  • 解决方法:如果异常不可避免,则应在析构函数内捕获,而不应当抛出。在析构函数中使用try-catch块屏蔽所有异常。
  • 附加说明:
    • 如果某一个异常发生,某对象的析构函数被调用,而此时析构发生了异常并流出了函数之外,则函数会被立即terminate掉(函数外有catch也不能拯救)

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

  • 相同点:
    • 传递函数参数与传递异常的途径可以是传值、传递引用或传递指针
  • 不同点:
    • 调用函数时,程序的控制权最终还会返回到函数的调用处,抛出一个异常时,控制权永远不会回到抛出异常的地方。
    • 异常对象在传递时总是被复制。当通过传值方式捕获时,异常对象被复制两次。对象作为参数传递给函数时不一定需要被复制。
    • 对象作为异常被抛出与作为参数传递给函数相比,前者类型转换比后者少(前者只有两种转换形式:继承类与基类的转换,类型化指针到无类型指针的转换)。
    • catch子句进行异常类型匹配的顺序是它们在源代码中出现的顺序,第一个类型匹配成功的被执行;当一个对象调用一个虚函数时,被选择的函数位于与对象类型匹配最佳的类里,即使该类不是在源代码的最前头。

M13 以 by reference 方式捕捉 exceptions

  • 捕获异常的三种方式:
  • 指针。
    • 用指针方式来捕捉异常,效率最高,在搬移异常信息的时候不需复制对象。但是这种方式只能运用于全局或静态的对象,否则的话由于局部对象离开作用域被销毁,catch中的指针指向不复存在的对象。
    • 若采用抛出一个指针的做法,指向一个新的堆对象,建立在堆中的对象必须删除,而对于不是建立在堆中的对象,删除它会造成不可预测的后果,因此将面临一个难题:对象建立在堆中还是不在堆中?到底是该删除还是不删除?(对于栈上的对象不能delete)
  • 传值。可以解决指针中是否需要删除的问题。但会有新的问题:
    • 异常对象被抛出时系统将对异常对象拷贝两次;
    • 而且它会产生对象切割,即派生类的异常对象被作为基类异常对象捕获时,它的派生类行为就被切割掉了,这样产生的对象实际上是基类对象。
  • 引用。完美解决以上问题。不像指针那样,它不会发生对象删除问题,不难捕捉标准的exception。和传值也不一样,没有切割问题,异常对象只会复制一次。

M14 明智运用异常规范(exception specifications)

  • 如果一个异常未在已列出的异常规格当中,特殊函数unexpected会被调用。而unexpected的默认行为是调用terminate,而terminate的默认行为是调用abort,所以程序如果违反exception specification,默认结果是终止程序。
  • 避免调用unexpected函数的三种办法:
    • 避免在带有类型参数的模板内使用异常规格。因为我们没有办法知道某种模板类型参数抛出什么样的异常,所以不可能为一个模板提供一个有意义的异常规格。
    • 如果在一个函数内调用其它没有异常规格的函数时应该去除这个函数的异常规格。
    • 处理系统可能抛出的异常。可以将所有的 unexpected异常都被替换为自定义的异常对象,或者替换unexpected函数,使其重新抛出当前异常,这样异常将被替换为 bad_exception,从而代替原来的异常继续传递。

M15 了解异常处理的系统开销

效率

M16 牢记80-20准则(80-20 rule)

  • 80-20准则说的是: 软件整体的性能取决于代码组成中的一小部分
    • 大约20%的代码使用了80%的程序资源;
    • 大约20%的代码耗用了大约80%的运行时间;
    • 大约20%的代码使用了80%的内存;
    • 大约20%的代码执行80%的磁盘访问;
    • 80%的维护投入于大约20%的代码上;
    • 通过无数台机器、操作系统和应用程序上的实验这条准则已经被再三地验证过。80-20准则不只是一条好记的惯用语,它更是一条有关系统性能的指导方针,它有着广泛的适用性和坚实的实验基础。

M17 考虑使用lazy evaluation(懒惰计算法)

  • 引用计数
  • 区别对待读取和写入
  • Lazy Expression Evaluation(懒惰表达式计算)

M18 分期摊还期望的计算

  • 过度热情计算法:在要求你做某些事情以前就完成它们。

M19 理解临时对象的来源

  • 临时对象产生的两种条件:
    • 为了使函数成功调用而进行隐式类型转换时。
    • 函数返回对象时。
  • 临时对象是有开销的,
  • 因此要尽可能去消除它们,然而更重要的是训练自己寻找可能建立临时对象的地方
  • 在任何时候只要见到常量引用参数,就存在建立临时对象而绑定在参数上的可能性,
  • 在任何时候只要见到函数返回对象,就会有一个临时对象被建立(以后被释放)

M20 协助完成返回值优化

  • C++规则允许编译器优化不出现的临时对象(temporary objects out of existence)。
    const Rational operator*(const Rational& lhs, const Rational& rhs) {
    	return Rational(
    		lhs.numerator() * rhs.numerator(),
    		lhs.denominator() * rhs.denominator());
    }
    
  • 虽然直观上依旧存在临时变量, 但是 对于如下式子是临时变量是会被优化掉的Rational c = a * b;

M21 通过重载避免隐式类型转换

class UPInt;
const UPInt operator+(const UPInt& lhs, const UPInt& rhs);
UPInt upi3 = upi1 + upi2;
upi3 = upi1 + 10;
upi3 = 10 + upi2;
  • 以上代码也能成功运行。方法是通过建立临时对象把整形数10转换为UPInts.
  • 让编译器完成这种类型转换是确实是很方便,但是建立临时对象进行类型转换工作是有开销的,而我们不想承担这种开销。
  • 我们可以重载消除这种转换
  • const UPInt operator+(const UPInt& lhs, int rhs);
  • const UPInt operator+(int lhs, const UPInt& rhs);
  • 但是这种事绝对错误的!!! const UPInt operator+(int lhs, int rhs); // 错误!

  • 没有必要实现大量的重载函数,除非你有理由确信程序使用重载函数以后其整体效率会有显著的提高。

M22 考虑用运算符的赋值形式(op=)取代其单独形式(op)

  • 要确保操作符的复合形式和其独身形式之间的自然关系能够存在,一个好方法就是以复合形式为基础来实现独身形式。
    Rational& operator+=(const Rational& rhs);//(1)
    const Ratinal operator+(const Rational& lhs,const Rational& rhs)  
    {//(2)
        return Rational(lhs)+=rhs;  
    }  
    
  • 此例中operator+=是从头做起的,而operator+是调用前者以供应它们所需的机能。如果采用这种设计,那么这些操作符之中就只有复合形式才需要维护。 主要有以下好处:
    • 一般而言,复合操作符比独身版本效率高,因为独身版本通常必须返回一个新对象,故必须要负担一个临时对象的构造和析构成本。至于复合版本则是直接将结果写入其左端自变量,所以不需要产生一个临时对象来放置返回值。
    • 允许客户在效率和便利之间作取舍。
      Rational a,b,c,d,result;  
      //(1)独身版本:便利。用到三个临时对象。
      result = a+b+c+d;   
      //(2)复合版本:效率。都不需要临时对象。 
      result=a;  
      result+=b;  
      result+=c;  
      result+=d;  
      
  • 前者较易撰写、调试、维护。后者效率较高。
  • 独身版本的两个实现形式:
    template<class T>  
    const T operator+(const T& lhs,const T& rhs)  
    {  
        return T(lhs) += rhs; //产生临时对象
    }    
    //version 2
    template<class T>  
    const T operator+(const T& lhs,const T& rhs)  
    {  
        T result(lhs);//产生命名对象
        return result+= rhs;  
    }  
    
  • 第一个版本和第二版本几乎一样,但是却有个重要的差异,第二版本中含有一个命名对象result,第一版本中拥有返回值优化,使得编译器具有最佳的效果。
  • 匿名对象总是比命名对象更容易消除,所以当你面临命名对象和临时对象的抉择时,最好是选择临时对象。它绝不会比其命名对象耗用更多成本,反倒是极有可能降低成本。

M23 考虑使用其它程序库

  • stdio 比 iosteam要快

M24 理解虚拟函数、多继承、虚基类和RTTI所需的代价


  • 使用虚函数,会使用所谓的 virtual tables 和 virtual table pointers ,通常简写为 vtbls 和 vptrs 。
  • vtbl通常是一个函数指针的数组或链表,每一个声明或继承虚函数的类都有自己的vtbl,其中的每一个元素就是该类的各个虚函数的指针。

class C1 {
public:
C1();
virtual ~C1();
virtual void f1();
virtual int f2(char c) const;
virtual void f3(const string& s);
void f4() const;
...
};

class C2: public C1 {
public:
C2(); // 非虚函数
virtual ~C2(); // 重定义函数
virtual void f1(); // 重定义函数
virtual void f5(char *str); // 新的虚函数
...
};

C1 虚表C2 虚表

  • 虚函数所需的代价:
    • 必须为每个包含虚函数的类的virtual table留出空间;
    • 每个包含虚函数的类的对象里,必须为额外的指针付出代价;
    • 实际上放弃了使用内联函数虚函数是运行时绑定的,而 inline 是编译时展开的,即使你对虚函数使用 inline ,编译器也通常会忽略。

  • 多继承问题:
    • 在单个对象里有多个vptr(一个基类对应一个),”找出对象内的vptrs”会变得比较复杂,它和虚基类一样,会增加对象体积的大小。
    • 在non-virtual base的情况下,如果派生类对于基类有多条继承路径,那么派生类会有不止一个基类部分, 让基类为virtual可以消除基类的数据成员在每一个子类复制滋生。
    • 然而虚基类也可能导致另一成本: 其实现做法常常利用指针,指向”virtual base class”部分,因此对象内可能出现一个(或多个)这样的指针。例如多重继承的”菱形”结构。
  • RTTI(运行时类型识别)能让我们在运行时找到对象和类的有关信息,所以肯定有某个地方存储了这些信息让我们查询,这些信息被存储在类型为type_info的对象里。通常,RTTI被设计为在类的vbtl上实现。
    虚函数性质

技巧

M25 将构造函数和非成员函数虚拟化

  • 这里所谓的虚拟构造函数,并不是真的指在构造函数前面加上 virtual 修饰符,而是指能够根据传入不同的参数建立不同继承关系类型的对象。

    class NLComponent {   // 抽象基类,其中内含至少一个纯虚函数
    public:
    ...
    };
    
    class TextBlock: public NLComponent{ // 没有内含任何纯虚函数
    public:
    ...
    };
    
    class Graphic: public NLComponent{ // 没有内含任何纯虚函数
    public:
    ...
    };
    
    class NewsLetter { // 一份实时通信是由一系列的NLComponent对象构成的
    public:
    NewsLetter(istream& str);//NewsLetter拥有一个istream为自变量
    //的构造函数,函数由stream读取数据以便产生必要的核心数据结构
    ...
    private:
    //从str读取下一个NLComponent的数据,产生组件,并返回一个指针指向它!
    static NLComponent* readComponent(istream &str);
    list<NLComponent *> components;
    };
    
    NewsLetter::NewsLetter(istream &str)
    {
        while(str)
        {//将readComponent返回的指针加到Component list尾端
            components.push_back(readComponent(str));
        }
    }
    
  • 由以上代码可知:

    • NLComponent的Constructor并没有虚拟化,他只是通过readComponent读取istream产生不同类型的组件,返回组件的指针,之后将指针以基类指针(NLComponent*)类型存储起来,用来在以后的调用可以实现多态,这样就是Virtual Constructor
    • NewsLetter类的readComponent函数根据输入的字符串不同产生不同的对象。它产生新对象,所以行为好像constructor,但它能够产生不同类型的对象,所以称为一个virtual constructor。所谓virtual constructor是指能够根据输入给它的数据的不同而产生不同类型的对象。!!!!
  • virtual copy constructor:所谓virtual copy constructor,它会返回一个指针,指向其调用者(某对象)的一个新副本。

    class NLComponent{ 
     public:
     // 声明virtual copy constructor
     virtual NLComponent *clone() const = 0;
    };
    
     class TextBlock:public NLComponent{ 
     public: 
     virtual TextBlock* clone() const 
     { return new TextBlock(*this);}
     }; 
     class Graphic:public NLComponent { 
     public: 
     virtual Graphic* clone() const 
     {return new Graphic(*this);} 
     }; 
    
  • 由代码可知,类的virtual copy constructor只是调用真正的copy constructor而已。并且执行的动作和效果完全一致。

  • 当子类重新定义其基类的一个虚函数时,不需要一定得声明与原本相同的返回类型。如果函数的返回类型是一个指向基类的指针(或引用),那么子类的函数可以返回一个指针(或引用),指向该基类的一个子类。

    • 这也就是为什么即使NLComponent的clone函数返回类型是NLComponent*,TextBlock的clone函数却可以返回TextBlock*,而Graphic的clone函数可以返回Graphic*的原因。
  • 将non-member functions虚化

    • 非成员函数虚化,这里也并不是指使用 virtual 来修饰非成员函数。而使写一个虚函数做实际工作,再写一个什么都不做的非虚函数,只负责调用虚函数。
    #include<iostream>
    using namespace std;
    
    class NLComponent{
    public:
        virtual ostream& print(ostream& s) const = 0;
    };
    class TextBlock:public NLComponent{
    public:
        virtual ostream& print(ostream& s) const
        {
            s << "TextBlock";
            return s;
        }
    };
    class Graphic : public NLComponent{
    public:
        virtual ostream& print(ostream& s) const
        {
            s << "Graphic";
            return s;
        }
    };
    inline ostream& operator<<(ostream& s, const NLComponent& c)
    {
        return c.print(s);
    }
    
    int main(){
        TextBlock tx;
        Graphic gc;
        cout << tx << endl;
        cout << gc << endl;
        return 0;
    }
    
    • 述代码可知:声明一个虚函数(print)作为打印之用,并在TextBlock和Graphic中定义它。定义一个operator<<的non-member function,展现出类似print虚函数一般的行为。

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

  • 函数封装法:使用一个函数来统一管理类的构造,使用static防止重复构造, 只适用于一个数量.
  • 类封装法: 使用一个类来统一管理某一个类的构造和销毁,类似接口,适用于任意数量限制
    • 建立一个基类,构造函数和复制构造函数中计数加1,若超过最大值则抛出异常;析构函数中计数减1。

  • 不要对包括局部静态变量的非成员函数使用内联
  • 带有private构造函数的类不能作为基类使用, 也不能嵌入到其它对象中
    • 设计伪构造函数(调用私有的构造函数,返回指针),但是依旧不能实现派生,不过可以通过这样完成包含
  • 这个思想同样可以扩充为,
    • 将类的某个行为定义为私有函数
    • 类添加某非成员函数为友元函数,使用友元函数来进行操作
    • 友元函数类似于接口-接口函数
    • 也可扩充为类接口 - 接口类

M27 要求或禁止在堆中产生对象

  • 栈上的对象肯定调用构造方法和析构方法(离开作用域的时候),因此,要求对象只能产生于heap之中,也就是禁止栈上产生对象,解决办法有两种:
    • 将所有的构造方法声明为private,
    • 将析构方法声明为private。
  • 将构造方法或者析构方法声明为private,将导致两个问题:阻止了继承和内含(组合)。
    class UPNumber { ... }; //将析构函数或构造函数声明为private
    //继承
    class NonNegativeUPNumber: public UPNumber { ... }; // 错误! 构造函数或析构函数不能通过编译
    class Asset {
    private:
        UPNumber value;//内含
        ...            // 错误! 构造函数或析构函数不能通过编译
    };
    
  • 解决方法:
    • 对于继承,可以将父类的构造函数和析构函数放大访问权限,析构函数为protected,构造函数保持其public。
    • 对于内含一个对象,修改为内含一个指针,指向对象。

  • 判断某个对象是否位于heap内
    • 没有合适办法能判断一个对象是否在堆中,我们为什么要判断对象是否在堆上?真实的需求是,判断执行delete是否安全。
    • 判断一个对象是否可以安全用delete删除,只需在operator new中将其指针加入一个列表,然后根据此列表判断指针是否在其中,如果在,执行delete就是安全的,否则不安全。

  • 如何禁止对象产生于heap之中
    • 在堆上创建对象,必定调用operator new分配内存,因此将operator new声明为private就好了。为了统一访问层级,可以将operator delete一同设为private。(但是仍然不能判断其是否在堆中)。


M28 智慧(smart)指针

  • 智能指针和普通指针的区别

    • 智能指针实际上是对普通指针加了一层封装机制,
      • 这样的一层封装机制的目的是为了使得智能指针可以方便的管理一个对象的生命期。
      • 简单来说,智能指针就是模拟指针动作的类。智能指针从模板生成,因为要与内建指针类似,必须是强类型的;模板参数确定指向对象的类型。
    • 在C++中,如果使用普通指针来创建一个指向某个对象的指针,那么在使用完这个对象之后我们需要自己删除它,但是由于程序员忘记删除,或在删除前抛出异常,导致没有执行删除操作,则会产生内存泄露
    • 在这个时候,智能指针的出现实际上就是为了可以方便的控制对象的生命期,在智能指针中,一个对象什么时候和在什么条件下要被析构或者是删除是受智能指针本身决定的,用户并不需要管理。
  • 测试智能指针是否为NULL有两种方案:

    • 一种是使用类型转换,将其转换为void*,但是这样将导致类型不安全,因为不同类型的智能指针之间将能够互相比较;
    • 另一种是重载operator!,这种方案只能使用!ptr这种方式检测。
  • 智能指针的继承类到基类的类型转换:

    • 使用模板成员函数。这将使得内建指针所有可以转换的类型也可以在智能指针中进行转换,但是对于间接继承的情况,必须用dynamic_cast指定其要转换的类型是直接基类还是间接基类。
  • 间接基类: 多层派生关系下,最顶层的派生类和最底层的基类的关系为间接派生

M29 引用计数

  • 使用引用计数后,对象自己拥有自己,当没有人再使用它时,它自己自动销毁自己。因此,引用计数是个简单的垃圾回收体系。
  • 此计数有两个动机:
    • 第一为了简化堆对象周边的簿记工作。
    • 第二是为了实现一种常识,所有等值对象共享同一实值,不仅节省内存,也使程序速度加快。
  • 引用计数的最重要功能是对象共享。当有许多对象有相同的值时,将该值存储多次是一件愚蠢的事。因此让所有等值对象共享一份实值即可满足要求,这样既节省内存空间,也让速度加快(构造、析构对象费时)。
  • 我们需要追踪引用计数的对象有多少个对象共享它。如果有一个共享对象修改做出修改时,我们不能改变引用计数对象,因为还有其他共享对象需要它。这时引用计数器开始派上用场了。这也是引用计数不得不添加的开销。亦即我们需要存储所共享的对象,也需要保存该对象的引用次数,两者是一个耦合关系。
  • 引用计数的成本:每一个拥有引用计数能力的实值都携带一个引用计数器,大部分操作都需要查验或处理这个计数器,对象的实值因而需要更多内存,我们需要执行更多代码。
  • 引用计数的优点:引用计数是个优化技术,其适用前提是对象常常共享实值,在这种情况下它可节省你的空间和时间。以下是引用计数改善效率的最适当时机:
    • 相对多数的对象共享相对少量的实值的时候。”对象/实值”数量比越高,引用计数带来的利益越大。
    • 对象实值产生或销毁成本很高,或是他们使用很多内存的时候。

  • 附加:其实就是C++ 11标准中的shared_ptr

M30 代理类

  • 建议看一下book
  • 可以用两个类来实现二维数组:Array1D是一个一维数组,而Array2D则是一个Array1D的一维数组。Array1D的实例扮演的是一个在概念上不存在的一维数组,它是一个代理类。
  • 代理类最神奇的功能是区分通过operator[]进行的是读操作还是写操作,它的思想是对于operator[]操作,返回的不是真正的对象,而是一个 proxy类,这个代理类记录了对象的信息。
    • 将它作为赋值操作的目标时,proxy类扮演的是左值;
    • 用其它方式使用它时,proxy类扮演的是右值。
    • 用赋值操作符来实现左值操作,用隐式类型转换来实现右值操作。
  • 用proxy类区分operator[]作左值还是右值的局限性:
    • 要实现proxy类和原类型的无缝替代,必须声明原类型的一整套操作符;
    • 另外,使用proxy类还有隐式类型转换的所有缺点。

M31 让函数根据一个以上的对象来决定怎么虚拟



  • 假设正在编写一个小游戏,游戏的背景是发生在太空,有宇宙飞船、太空船和小行星,它们可能会互相碰撞,而且其碰撞的规则不同,如何用C++代码处理物体间的碰撞。
  • 代码的框架如下:
    class GameObject{...};
    class SpaceShip:public GameObject{...};
    class SpaceStation:public GameObject{...};
    class Asteroid:public GameObject{...};
    
    void checkForCollision(GameObject& obj1,GameObject& obj2)
    {
        if(theyJustCollided(obj1,obj2))
        {
            processCollision(obj1,obj2);
        }
        else
        {
            ...
        }
    }
    
  • 当调用processCollision()时,obj1和obj2的碰撞结果取决于obj1和obj2的真实类型,但我们只知道它们是GameObject对象 (碰撞结果取决于发生碰撞的类型)。相当于我们需要一种作用在多个对象上的虚函数。这类型问题,在C++中被称为二重调度问题.
  • 参考: 二重调度问题

M32 在未来时态下发展程序

  • 身为开发人员,需要接受事情总是改变的事实,所以我们应该尽量写出可移植的代码、可应对系统改变的代码。
  • 提供完整的 class,即使某些功能暂时用不到,但当新的需求进来,你不太需要回头去修改那些类。
  • 设计你的接口,使有利于共同操作行为,阻止共同的错误。让类能够轻易的被正确的使用,难以被错误的使用。
  • 尽量使你的代码一般化。

M33 将非尾端类设计为抽象类

  • 一般性的法则: 继承体系中的非尾端类应该是抽象类。坚持这个法则,有利于整个软件的可靠度、健壮度、精巧度、扩充度。

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

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

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

  • 学习 C++ 标准程序库,不仅可以增加你的只是,知道如何包装完整的组件运用于自己的软件上面,也可以使你学习如何更有效的运用 C++ 特性,并对如何设计更好的程序库有所体会。

Time 2019/10/12

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值