《Effective C++》简明笔记-上

在学习算法导论的过程中,我深深地震撼于自己笔下C++代码的丑陋。于是我决定捧起这本《Effective C++》。本来打算看完这本书,写一篇完整的笔记博文,但是刚刚看到一半,我已经跃跃欲试地想动手改善我的代码了。所以,我将写完的这部分笔记整理成单独的一篇博文。

1. 视C++为一个语言联盟。

  • C++ 包括 C & OO C++ & Template C++ & STL

2. 使用 const,enum,inline 代替#define。

3. 尽可能使用 const

  • const 修饰指针的不同含义
    char* const p1 = "hello"; // 固定指针:不能使p2指向其他对象
    const char* p2 = "hello"; // 固定数据:不能修改p2指向的对象
  • const 修饰函数时的不同含义
    class Text
    {
    public:
        const std::size_t length() const; 
        // 返回文本的长度
        // 第一个 const 表示 函数返回一个常量,不可作为左值使用
        // 第二个 const 表示 函数不修改 Text 类中的数据成员(除了 static 和 mutable 修饰过的)
    private:
        char* data;
    };

    当类的实例被声明为 const 时,只能调用被第二个 const 修饰过的函数。

4. 保证使用对象前进行初始化

  • 内置数据类型不进行初始化,所有对象类型都有默认初始化函数。
  • 在构造函数中对类成员赋值并不是真正意义的初始化,进入构造函数体时,对象成员都已经调用过默认初始化函数了。应当使用初始值列进行初始化。
    class Person
    {
    public:
        Person():
          name(), // 调用 string 类默认构造函数
          sex_isMale(true) // 内置类型,必须初始化
        {};
        Person(const std::string& tname, const bool& isMale):
          name(tname), // 调用 string 类的复制构造函数
          sex_isMale(isMale)
        {};
    private:
        std::string name;
        bool sex_isMale;
    };
  • 不同编译单元的 non-local static 对象(如全局对象)的初始化顺序不可控,将对象放在一个全局函数中,并将对象其声明为静态成员。第一次调用该函数时必定会初始化该对象。

5. 了解C++默默做的事

  • 如果没有声明任何构造函数,则编译器自动为类实现默认构造函数。
  • 如果你没有实现,编译器会自动为类实现复制构造函数,复制运算符(operator=)函数,析构函数。
  • 如果类中包含引用类型的成员 或 const 成员,则编译器不会实现复制运算符函数。因为更改 引用 或 const 成员是不允许的。

6. 如果不想使用编译器自动生成函数,就该明确拒绝

  • 将不想使用(如果你不声明,编译器就会自动生成)的函数 声明 为 private,并且不实现它(防止友元类调用)。
  • 声明基类,并在基类中将不想使用的函数声明为 private,且不实现。继承基类的派生类,编译器不会自动生成相应函数。
    class Uncopyble
    {
    protected:
        Uncopyble(){}
        ~Uncopyble(){}
    private:
        // 声明但不实现复制构造函数,其派生类无法调用基类的复制构造函数(由于private)
        // 因此编译器无法自动生成派生类的复制构造函数(默认的逻辑上,该函数应当调用基类的复制构造函数)
        Uncopyble(const Uncopyble&); 
        // 复制操作符函数同理
        Uncopyble& operator=(const Uncopyble&); 
    };

7. 为多态基类声明virtual析构函数

  • 如果基类的析构函数不是虚函数,那么通过基类指针引用的派生类对象,在其销毁时,只能销毁积累部分,而不能销毁派生类部分。

8. 不让异常逃离析构函数

  • 析构函数往往并不由类的使用者亲自调用,因此在析构函数中抛出的异常难以捕捉。
  • 如果在对象的销毁阶段确实可能抛出异常(比如,由于网络原因,关闭远程数据库失败),应该另外实现一个使用者亲自调用的销毁函数如close(),在该函数中抛出异常,以此给用户以机会处理异常。在析构函数中,检查用户是否调用了销毁函数:如果用户已经调用过,则不再调用该函数;如果用户未曾调用过,则调用该函数,在出现异常的情况下,并吞下异常或直接停止程序(用户没有立场抱怨,因为我们已经给了他机会)。

9. 不在构造函数或析构函数中调用virtual函数

  • 派生类初始化时,先对基类部分初始化,然后才是派生部分。基类的构造函数运行时,派生类还不存在,此时调用虚函数并试图完成派生类中相应地逻辑:如果该虚函数有实现,就仅仅调用虚函数而不是派生类中的函数;如果没有定义虚函数,会出现连接错误。
  • 析构函数同理。

10. 令 operator= 返回一个对 this 的引用

  • 这样就可以使用连等式来赋值了。

11. 在 operator= 中处理自我赋值

  • 在 operator= 中需要考虑参数就是自身的情况,也要注意语句顺序,以保证“异常安全性”。

12. 复制对象时不要忘了对象的每一个部分

  • 如果自己实现复制构造函数和复制运算符函数(而不使用编译器自动生成的版本),一定要记得将所有的成员都复制过来,编译器不会因为这是个复制构造函数或operater=而帮你检查。
  • 如果你在派生类中自己实现以上两种函数,一定要记得显式地调用基类的相应函数,编译器不会帮你调用。
    class Person{
    public:
        Person(){}
        Person(const std::string& tname):name(tname){}
    private:
        std::string name;
    };
    class Citizen:public Person{
    public:
        Citizen():Person(),married(false){}
        Citizen(Citizen& pcitizen):
          Person(pcitizen), // 显式调用基类的复制构造函数, 
                            // 注意传入的是pcitizen而不是pcitizen.name,
                            // 因为调用的是基类的复制构造函数而不是构造函数,
                            // 而且基类的private也不允许你这样做
          married(pcitizen.married){} // 派生类部分的初始化
    private:
        bool married;
    };

13. 以对象管理资源

  • 所谓资源,往往是由 new 运算符产生的,由指针控制和管理的对象和数组。它们通常分配在堆(而不是栈)上,所以程序流程发生变化时,这些对象和数组不能自动销毁(而分配在栈上的对象是可以的),需要手动销毁。
  • RAII:对象的取得时机就是最好的初始化时机,两种常用的RAII对象(智能指针):std::auto_ptr<T>和std::tr1::shared_ptr<T>,前者的复制方案为“转让所有权”,后者的复制方案为“计数器”。
  • 一个RAII对象示例
    class FontHandle;
    
    class Font{
    public:
        Font(FontHandle* ft):
          f(ft){}
        ~Font(){delete f;}
    ...
    private: FontHandle* f; };

    Font类的实例并不分配在堆上,但其指针成员 f 指向的对象 *f 分配在堆上。当流程变化时,Font 实例被正常销毁,其析构函数被调用,析构函数中将指针成员指向的对象销毁。这就保证了 *f 没有泄露。

14. 在资源管理器中小心 copying 行为

  • 资源管理器的资源:即指针指向的对象,由资源管理器维护。当自己实现智能指针对象时,考虑一下四种 copying 行为。
    • 禁止复制
    • 引用计数(如shared_ptr,需用到类的静态成员)
    • 深度复制
    • 转让所有权(如auto_ptr)
  • 考虑着四种 copying 行为的目的就是,避免在析构函数中多次试图销毁指针所指对象,或者完全不销毁。

15. 在资源管理器中提供对原始资源的访问

  • 往往对 RAII 对象实现 operator-> 和 operator* 以实现对资源对象内部成员的访问。
  • 实现显式转换函数,如 Font.get() 返回资源对象。
  • 实现隐式转换函数,如 Font::operator FontHandle() 返回资源对象。此时,Font 对象 可 隐式转换为 FontHandle 对象,但也会带来部分风险。
    class Font{
    public:
        Font(FontHandle* ft):
          f(ft){}
        ~Font(){delete f;}
        operator FontHandle(){return *f;}
        FontHandle get(){return *f;}
        ...
    private:
        FontHandle* f;
    };

16. 成对使用 new 与 delete 时采取相同的形式

  • 事实上,编译器中实现了两种指针,指向单个变量/对象 的 和指向变量/对象 数组的。使用 new 和 delete 时应当采取对应的形式。
    std::string* s1 = new std::string("hello");
    std::string* s2 = new std::string[100];
    ...
    delete s1;
    delete [] s2;

17. 以独立语句将 newed 对象置入智能指针中

  • 考虑这样做:
    Font f1(new FontHandle);

    独立语句的含义是:不将该语句拆开,也不将其合并到其他语句中,这样可以确保资源不被泄露,如:

    // 不将其拆开
    FontHandle* fh1 = new FontHandle;
    ... // 发生异常怎么办?
    Font f1(fh1);
    
    // 不将其合并
    AnotherFunction(Font(new FontHandle), otherParameters /*发生异常怎么办?*/);

18. 让接口易于使用,难于误用

  • 让接口易于使用,一般来说,就是尽量保持与内置类型(甚至STL)同样的行为。比如,你应当为 operator+ 函数返回 const 值,以免用户对计算结果进行赋值操作,内置类型不允许(对 int 型变量,语句 a+b=c 不能通过编译,所以你的类型也应该尽量保持同样的性质,除非你有更好的理由);又比如,对象的主要组成部分如果是一个数组,那么数组的长度的成员名最好使用 size 而不是 length,因为 STL 也这么做了。
  • 让借口难于误用,包括在类中限制成员的值(比如 Month 类型不可能表示 13 月),限制类型上的操作,在工厂函数中返回智能指针。

19. 设计 class 犹如 设计 type

20. 用 pass-by-reference-const 替换 pass-by-value

  • 为函数传递参数时,使用使用 const 引用传递变量。在定义函数时:
    class Person{...};
    class Citizen:public Person{...};
    
    bool validatePerson(Person psn){...} // 值传递,尽量不要这样做
    bool validatePerson(const Person& psn){...} // const引用传递
  • 使用const引用类型传递函数参数的好处在于:
    • 免去不必要的构造开销:如果使用值传递,实参到形参的过程调用了类型的复制构造函数,而引用不会。
    • 避免不必要的割裂对象:如果函数的参数类型是基类,而函数中又调用了派生类中的某种逻辑(即调用了基类中的虚函数),那么值传递的后果就是,形参仅仅是个基类对象,虚函数也仅仅就调用了虚函数自己(而不是派生类中的函数)。
    • 对于C++内置类型和STL迭代器,使用值传递,以保持一致性。

21. 必须返回对象时,不要试图返回 reference

  • 考虑一个有理数类:
    class Rational{
    public:
        Rational(int numerator=0, int denominator=1):n(numerator),d(denominator){}
    private:
        int n, d;
    };
  •  任何有理数可用分数表示, n 和 d 分别为分子和分母,他们都是 int 型的。现在考虑为该类实现乘法,我们希望它能像内置类型一样工作。

    Rational x = 2;
    Rational y(1,3);
    Rational z = x*y*y; // z等于2/9
  • 我们可能会令函数返回引用类型(尤其是意识到20条中关于值传递的种种劣迹后):
    class Rational{
        ... 
    private:
        // 错误的代码
        friend const &Rational operator* (const Rational& lhs, const Rational& rhs){
            Rational result(lhs.n*rhs.n, lhs.d*lhs.d);
            return result;
        }
        ...
    };

     result对象在 operator* 函数结束后就销毁了,但我们返回了它的引用!这个引用指向 result 对象原先的位置(编译器往往用指针实现引用),而且该位置在栈上!不仅无效,而且危险。

  • 我们也可能用new运算符建立一个新的对象(以防止在函数结束后被销毁),并返回该对象的引用:
    // 错误的代码
    friend const &Rational operator* (const Rational& lhs, const Rational& rhs){
            Rational* result = new Rational(lhs.n*rhs.n, lhs.d*lhs.d);
            return *result;
        }

     这次,*result 对象不会因为函数结束而销毁了,它分配在堆上。但问题是,谁来负责销毁它?尤其是上文 z=x*y*y 中,由 y*y 计算而得到的临时变量,几乎不可能正常销毁。

  • 正确的做法是:
    // 正确的代码
    friend const Rational operator* (const Rational& lhs, const Rational& rhs){
        return Rational(lhs.n*rhs.n, lhs.d*lhs.d);
    }

    虽然产生了构造消耗,但这是值得的。返回的对象 z 分配在栈上,也就是说会在适当的时候销毁,而原先函数中的临时变量也正常销毁了。

22. 将成员变量声明为 private

23. 以 non-member 和 non-friend 函数替换 non-member 函数

  • 类的 public 方法越多,其封装性就越差,内部实现弹性就越小。设计类的时候应由其细心。对于一些便利函数(这些函数往往只调用函数的其他 public 方法),可考虑将其放置在类外。C++允许函数单独出现在类外,即使在C#等语言中,也可以使其出现在 工具 对象中。
  • 将类外的函数与类声明在同一个命名空间中是不错的选择。

24. 如果函数的所有参数都需要类型转换,采用 non-member 函数

  • 第21条中的代码已经体现出这一条的意思了。这一条大致就是希望 Rational 对象能像其他内置对象一样,直接参与运算。比如,希望这样:
    Rational x(2,5);
    Rational y = x*2;
    Rational z = 2*x;
    • 首先,Rational 构造函数没有使用 explicit 修饰,这意味着 x*2 可以正常计算,因为这会调用 x.operator*(Rational& a),而整数 2 会隐式转换成 Rational 对象。(等等,在第21条中我们好像没有定义,x.operator*(Rational& a)函数?对,这是因为其中的代码已经遵循了本条的忠告,定义了 non-member 函数。)
    • 如果在 Rational 中定义了x.operator*(Rational& a),那么计算 z 时会遇到困难,因为系统会试图调用 Int32.operator*(Rational& a),这根本没有定义。所以,我们在代码中并没有定义成员函数,而是定义了友元函数 Rational operator*(Rational& a, Rational& b),正如在第21条的代码中显示的那样。

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

  • std::swap 函数采取复制构造的方法,效率比较低。
    namespace std{
        template <typename T>
        void swap(T& a, T& b){
            T temp(a);
            a = b;
            b = a;
        }
    }
  • 为自己的类实现 swap 方法并 特化 std::swap

    class Person{
    private:
        void* photo;
    };
    
    namespace std{
        template <> // 特化std::swap方法
        void swap<Person>(Person& a, Person& b){
            std::swap(a.photo, b.photo);
        }
    }

    当自己的类较大时,可在类中定义swap方法,并在 std::swap<YourClass> 中调用该方法。

26. 尽量延后变量定义式的时间

  • 仅当变量第一次具有“具有明显意义的初值”时,才定义变量,以避免不必要的构造开销。避免这样做:
    std::string s; // 调用默认构造函数
    ...  // 如果发生异常呢,如果含有某个return语句呢?第一次调用构造函数的开销被浪费了
    s = "Hello"; // 再一次调用构造函数,第一次调用构造函数的开销依然被浪费了

     应当这样做:

    std::string s("Hello"); // “hello”是具有明显意义的初值,只调用了一次构造函数 

27. 尽量少做转型动作

  • 四种转型动作
    • const_cast:消除对象的常量性
    • dynamic_cast:动态转换,开销较大。使用的场合往往是:想要在派生类上执行派生类的某个函数,但是手头上只有基类的指针指向该对象。
    • reinterpret_cast:依赖于编译器的低级转型
    • static_cast:强迫隐式转换,类似于C风格的转换,例如将int转换为double等
  • 不要试图在派生类的成员函数中,通过dynamic_cast将(*this)转换为基类对象,并调用基类成员函数。
    class Person{
    public:
        void showMessage(){}
    };
    class Citizen:public Person{
    public:
        void showMessage(){
            dynamic_cast<Person>(*this).showMessage(); // 错误,这样转型得到的并不是期望的“基类对象
        }
    };

     而应当这样做:

    Person::showMessage(); // 这就对了

28. 避免返回 handles 指向对象内部部分

  • handle 包括指针,引用,迭代器,用来获取某个对象,以前被翻译成句柄。
  • 在函数的方法中返回对象内部成员的 handle 可能遭致这样的风险:返回的 handle 比对象本身更长寿,当对象销毁后,handle 所指向的区域就是不确定的。
  • string 和 vector 类型的 operator[] 就返回了对象内部成员的 handle ,这只是例外。

29. 为“异常安全”而作的努力是值得的

  • 函数异常安全类型:
    • 基本承诺:如果异常抛出,程序内的所有元素仍然在有效状态下,没有任何元素受到损坏(如释放了指针指向资源却没有为其指定新的资源,该指针通向了不确定性)。
    • 强烈保障:如果异常抛出,程序内的所有元素保持函数调用前的状态。
    • 不throw异常:承诺绝不抛出异常。
    • 一个函数异常安全的程度取决于所调用函数中异常安全程度最弱的。
  • copy & swap 策略:为对象的数据制造一份副本,并对副本进行修改。如果发生异常,抛弃副本并返回;如果成功,则将对象数据与副本数据做 swap 操作,swap 操作承诺绝不抛出异常。
posted @ 2013-03-07 15:04 一叶斋主人 阅读(...) 评论(...) 编辑 收藏