0%

Effective C++笔记(一)

写在前面

拜读Meyers大师的经典书籍《Effective C++》,拨云见日,收获良多,心中很多疑惑迎刃而解。这本书提到的很多C++编程原则对于指导编写高效稳定的C++程序至关重要,值得反复品味,佐以实践。现在脑子记性太差,只好把大师的谆谆教诲搬运过来,时常翻阅。

明确术语

明确术语严格的意义,是保证正确理解的前提。

  • 声明(declaration)

    声明,是告诉编译器某个东西的名称和类型,但略去细节。下面都是声明:

    1
    2
    3
    4
    5
    extern int x;	//对象声明
    std::size_t numDigits(int num); //函数声明
    class Widget; //类声明
    template<typename T> //模板声明
    class GraphNode;
  • 定义(definition)

    定义,是提供编译器一些声明所遗漏的细节。对于对象,定义是编译器为此对象拨发内存的地点;对于函数,定义提供了函数体实现;对于类,定义列出它们的成员。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    int x;	//对象定义
    std::size_t numDigits(int num) { //函数定义
    //...
    return x;
    }
    class Widget { //类定义
    public:
    Widget();
    ~Widget();
    //...
    };
    template<typename T> //模板定义
    class GraphNode {
    public:
    GraphNode();
    ~GraphNode();
    //...
    };
  • 初始化(initialization)

    初始化,是赋予对象初值的过程。对于自定义类对象,初始化由构造函数执行。深入理解这句话的含义,自定义类对象的初始化要么是由Default构造函数,要么是由Copy构造函数完成的,没有其它方式;对已经存在的对象使用=是赋值而非初始化,调用的是Copy赋值操作符。

    • Default构造函数是可以通过不带任何实参调用的,其要么没有参数,要么每个参数都有缺省值:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      class A {
      public:
      A(); //default构造函数
      };
      class B {
      public:
      explicit B(int x=0, bool b=true); //default构造函数
      //声明为explicit防止被用来执行隐式类型转换
      };
      class C {
      public:
      explicit C(int x); //不是Default构造函数
      };

      总是将可以通过一个参数调用的构造函数声明为explicit,可以禁止其被用来执行隐式类型转换,这是需要遵循的好的做法。

    • Copy构造函数被用来以同类型对象初始化自身对象;Copy assignment操作符被用来从另一个同类型对象中拷贝其值到自身对象:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      class Widget {
      public:
      Widget(); de //default构造函数
      Widget(const Widget& rhs); //copy构造函数
      Widget& operator=(const Widget& rhs); //copy assignment操作符
      };
      Widget w1; //调用Default构造
      Widget w2(w1); //调用copy构造
      w1 = w2; //调用copy assignment操作符
      Widget w3 = w2;//调用copy构造

      区分=调用的是“copy构造”还是“copy赋值”的关键是有没有一个新对象被定义;如果有,一定会有构造函数被调用,否则就是赋值操作被调用。

    • copy构造函数定义一个对象如何passed by value(以值传递),示例代码:

      1
      2
      3
      4
      bool hasAcceptableQuality(Widget w);
      //...
      Widget sample;
      if(hasAcceptableQuality(sample)) ...

      参数sample以by value方式传递给hasAcceptableQuality,这个过程发生了复制,复制由Widget的copy构造函数完成。pass-by-value意味着调用Copy构造函数,以值传递自定义类型对象通常不可取,pass-by-reference-to-const往往是比较好的选择。

条款01:视C++为一个语言联邦

将C++视为一个语言联邦能帮助理解一些准则及其适用范围,可以将C++看作4个联邦:

  • C。当以C++内的C成分工作时,编程准则映照出C的局限,没有模板,没有异常,没有重载。
  • Object-Oriented C++。这部分是面向对象的,类、封装、继承、多态、动态绑定等面向对象的直接实现。
  • Template C++。这是C++泛型编程部分,我比较薄弱的地方。
  • STL。容器、迭代器、算法等一系列高效程序库,配合template工作。

条款02:尽量以const,enum,inline 替换 #define

由于#define属于预处理器,其定义的字面量可能没有以symbol的方式进入编译输出的符号表;这会增加debug难度,你可能在调试时看到一个数字,但不知道来自何处。

解决方法是以常量替换宏

1
2
3
#define RATIO 1.653
//将define替换为常量
const double Ratio = 1.653;
  • 定义常量指针

    在头文件内定义常量指针,有必要将指针而不止是指针所指内容声明为const,例如定义常量的char*字符串:

    1
    2
    3
    const char* const authorName = "Scott Meyers";
    //更好的定义,使用string对象
    const std::string authorName("Scott Meyers");
  • 类静态常量

    编译器会要求对类的静态常量成员既要提供声明,也要提供定义,只需遵循下面的做法就可以了:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    //类头文件内
    class CostEstimate {
    private:
    static const double Factor; //static类常量声明
    ...
    };

    //类实现文件内
    const double CostEstimate::Factor = 1.35; //static类常量定义
  • 类编译时需要类常量值

    例如类编译时使用一个类静态常量声明数组大小,而编译器不允许静态整数类常量进行类内初值设定,可以改用“the enum hack”补偿做法。其基础是,一个枚举类型的数值可以充当int被使用。

    1
    2
    3
    4
    5
    6
    class GamePlayer {
    private:
    enum {NumTurns = 5}; //令enum类型NumTurns成为5的一个记号名称
    int scores[NumTurns];
    ...
    };

使用template inline函数替换形似函数的宏

1
2
3
4
5
6
#define CALL_WITH_MAX(a,b) f((a) > (b) ? (a) : (b))
//替换为
template<typename T>
inline void callWithMax(const T& a, const T& b) {
f(a > b ? a : b); //不知道T是什么,采用pass by reference to const
}

有了const、enum和inline,对#define的需求降低,记住:

  • 对于单纯常量,以const对象或enum替换#define
  • 对于形似函数的宏,改用inline模板函数替换#define

条款03:尽可能使用const

首先来区分const与指针搞在一起时,到底限定的是指针本身、指针所指内容,或者两者都是const:

1
2
3
4
5
char greeting[] = "Hello";
char* p = greeting; //non-const pointer, non-const data
const char* p = greeting; //non-const pointer, const data
char* const p = greeting; //const pointer, non-const data
const char* const p = greeting; //const pointer, const data

区分的要点:

  • 关键字const出现在星号侧,表示被指内容是常量;
  • 关键字const出现在星号侧,表示指针自身是常量;
  • 关键字const出现在星号侧,表示被指内容和指针本身都是常量;
  • const与STL迭代器

    STL迭代器以指针为基础,所以其作用就像个T*指针;声明迭代器为const与声明指针为const一样T* const,表示迭代器不得指向其他对象,但它指向的对象的内容是可变的。如果希望迭代器指向的对象不可被改动,则需要的是const_iterator

1
2
3
4
5
6
7
8
std::vector<int> vec;
const std::vector<int>::iterator iter=vec.begin(); //iter like T* const
*iter = 10; //OK
++iter //Wrong, iter is const

std::vector<int>::const_iterator cIter=vec.begin(); //iter like const T*
*cIter = 10; //Wrong, *cIter is const
++cIter; //OK
  • 令函数返回常量值,可以减少错误使用造成的意外

  • 声明函数参数为const,除非需要改动参数或local对象

  • const成员函数

    将成员函数声明为const,基于两个理由:

    1. 是class接口容易理解,容易得知哪个函数可以改动对象而哪个函数不行;
    2. 使操作const对象称为可能;

    有一个容易忽略的事实:2个成员函数如果只是常量性不同,是可以被重载的。也就是说,在这种重载情况下,通过类的const对象会调用具有const性的成员函数,非const对象则调用非const成员函数。

  • 通过mutable解除non-static成员变量的bitwise constness约束

    如果编译器要求bitwise constness,但是类的某些成员变量有事可以被修改的,可以将其声明为mutable

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class CTextBlock {
    public:
    std::size_t length() const;
    private:
    char* pText;
    mutable std::size_t textLength;
    mutable bool isLengthValid;
    //mutable成员变量可能总是会被更改,即使在const成员函数内
    };
    std::size_t CTextBlock::length() const {
    if (!isLengthValid) {
    textLength = std::strlen(pText);
    isLengthValid = true;
    }
    return textLength;
    }
  • 在const和non-const成员函数内避免重复

    如果const和non-const成员函数都需要执行一段功能相同的代码,比如边界检查、日志记录等,则需要考虑消除重复代码。

    这里,避免代码重复的安全做法是:令non-const成员函数调用其const兄弟。这个过程需要转型操作,具体的代码参考如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class TextBlock {
    public:
    const char& operator[](std::size_t position) const //const成员函数
    {
    //...
    return text[position];
    }
    char& operator[](std::size_t position) //调用const op[]消除代码重复
    {
    return const_cast<char&>( //移除op[]返回值的const
    static_cast<const TextBlock&>(*this)//为*this加上const
    [position]); //调用const op[]
    }
    };

    这里共有两次转型:

    • 第一次static_cast,为*this添加const,将其原始类型TextBlock&转型为const TextBlock&,使其可以调用operator[]的const版本;
    • 第二次const_cast,则是从const operator[]的返回值中移除const,这里只能使用const_cast没有其他选择;

    注意,反向调用——令const版本调用non-const版本以避免重复——是一种错误行为,千万不可给自己找麻烦!

记住:

  • 将某些东西声明为const可以帮助编译器侦测出错误用法。const可以施加于任何作用域的对象、函数参数、函数返回值、成员函数。
  • 编译器强制实施bitwise constness,但编写程序时应该使用概念上的常量性conceptual constness。
  • 当const和non-const成员函数有实质等价的实现时,令non-const调用const版本可以避免代码重复。

条款04:确定对象使用前已先被初始化

这个条款听起来好像是废话,但是你确定你真的理解C++的初始化吗?

其实这里的规则很简单:永远在使用对象之前将它初始化。对于内置类型,手动进行初始化;而对于内置类型以外的其他任何东西,初始化由构造函数完成,并确保每一个构造函数都将对象的每一个成员初始化

这个规则很容易奉行,重要是别混淆了赋值和初始化。我们经常使用的做法是在构造函数体内对成员变量进行赋值,注意这里是赋值而不是初始化。因为C++规定,对象的成员变量的初始化动作发生在进入构造函数本体之前;在构造函数体内的操作都不是初始化,而是赋值。构造函数的较佳写法是使用成员初始化列表替换赋值动作,举例如下:

1
2
3
4
5
6
7
ABEntry::ABEntry(const string& name, const string& address, const list<PhoneNumber>& phones)
: theName(name), //这些都是初始化
theAddress(address),
thePhones(phones),
theCompany(),
numTimesConsulted(0)
{} //构造函数本体不必有任何操作

采用成员初始化列表的构造函数效率更高:在构造函数体内赋值,会首先调用成员变量的Default构造函数设初值,然后再立刻对他们赋新值,Default构造函数的操作都浪费了;成员初始化列表避免了这一问题,传入的实参直接作为各成员变量构造函数的实参进行初始化。对大多数类型而言,比起先调用Default构造函数再调用copy赋值操作符,单只调用一次copy构造函数是高效的多的。

为了避免在成员初始化列中遗漏某些成员,规定总是在成员初始化列表中列出所有成员变量,并且总是使用成员初始化列表进行初始化。另外,C++成员变量是以其声明的顺序被初始化,所以务必保证成员在初始化列表中出现的顺序总是与其声明顺序一致

另外,C++对定义与不同编译单元内的non-local static对象的初始化相对顺序并未明确定义。解决这个问题的做法是,将每个non-local static对象搬到自己的专属函数内,这些函数返回一个reference指向它所含的对象;用户调用这些函数,而不直接使用对象。因为C++保证函数内的local static对象会在函数被调用期间,首次遇上该对象之定义时被初始化,这通过函数调用获得的reference一定是指向一个初始化过的对象。有点类似与单例模式的某种实现方法。

记住:

  • 为内置对象进行手动初始化,C++不保证初始化它们;
  • 构造函数使用成员初始化列表,而不要在构造函数体内使用赋值操作;初始化列表的成员变量顺序,应该与它们在类中的声明顺序相同;
  • 为免除跨编译单元初始化次序问题,以local static 对象替换non-local static对象。