Effective C++ 学习笔记(一)

Effective C++ 学习笔记(一)

      当我读完Scott Meyers编写的《Effective C++》以后,我特别想再读一遍,因为这是我看过的关于C++非常好的书籍。这本书介绍了C++编程中需要注意的问题(对于一位合格的C++程序员非常关键的问题),作者以精简的语言进行了描述。第二遍阅读的时候,我打算以笔记的形势记录下来。该书一共分为八个部分:

  • 让自己习惯C++
  • 构造/析构/赋值运算
  • 资源管理
  • *设计与声明
  • 实现
  • 继承与面向对象的设计
  • 模板与泛型编程
  • 定制new和delete
  • 杂项讨论

一、让自己习惯C++

1、视C++为一个语言联邦

      今天的C++已经是个多重范型编程语言,一个同时支持过程形式、面向对象形式、函数形式、泛型形式、元编程形式的语言。我们可以把C++视为一个由相关语言组成的联邦而非单一语言。为了理解C++,你必须认识其主要次语言。总共有四个:

  • C。 C++是以C为基础的,区块、语句、预处理器、内置数据类型、数组、指针等都来自C。
  • Object-Oriented C++。这部分是C with Classes所诉求的:classes、封装、继承、多态、virtual函数(动态绑定)……等等。这一部分是面向对象之古典守则在C++上最直接的实施。
  • Template C++。这是C++的泛编程部分,Template相关考虑和设计已经弥漫整个C++。由于Template威力强大,它带来了崭新的编程范型,也就是所谓的模板元编程(TMP)。
  • STL。STL是个template模板程序库,它对容器、迭代器、算法以及函数对象的规约有极佳的紧密配合与协调。

2、尽量以const、enum、inline替换#define

      当你写出这样的代码时:

#define ASPECT_RATIO 1.653

      记号ASPECT_RATIO也许从未被编译器看见;也许在编译器开始处理源码之前它就被预处理器移走了。如果ASPECT_RATIO被定义在一个非你写的头文件中,你肯定对它来自何处毫无概念,于是你讲因为跟踪它而浪费时间。解决之道是以一个常量替换上述的宏(#define):

const double AspectRatio = 1.653;

      此外对于浮点常量而言,使用const可能比使用#define导致较小的代码量,因为预处理器“盲目地将ASPECT_RATIO名称替换为1.653”可能导致目标码出现多份1.653,若用const常量则不会出现这种情况。
      注意class专属常量。为了将常量的作用域限制在class内,你必须让它成为class的一个成员;而为了确保此常量至多只有一份实体,你必须让它成为一个static成员:

class GamePlayer
{
private:
    static const int NumTurns = 5;   //常量声明式
    int scores[NumTurns];
    ...
}

      上面的NumTurns是声明式而非定义式,通常C++要求你对你使用的任何东西提供一个定义式。但如果它是个class专属常量又是static同时为整数类型且不取其地址时,则不需要提供定义式。如果坚持使用定义式,用如下方式提供:

const int GamePlayer::NumTurns;   //由于声明时已经提供初值,因此定义时不再提供初值。定义应该放在实现文件而非头文件。

      但是,当类型不是int型时,编译器不支持“in-class初始值设定”,这时初始值应放在定义式。如下:

//static class 常量声明位于头文件
class CostEstimate
{
private:
    static const double FudgeFactor;
    ...
};
//static class 常量定义位于实现文件
const double CostEstimate::FudgeFactor = 9.78;

      另外一个常见的#define误用情况是以它实现宏,宏看起来像函数,但不会招致函数调用带来的额外开销。例如:

#define CALL_MAX(a,b) f((a)>(b)?(a):(b)

int a = 5, b = 0;
CALL_MAX(++a,b);      //a被累加两次
CALL_MAX(++a,b+10);   //a被累加一次

      a的累加次数竟然取决于“它被拿来和谁比较”,当和小于自己的数比较时累加两次,当和大于自己的数比较时累加一次。我们可以用template inline 函数来获得宏带来的效率以及一般函数的所有可预料行为和安全性。

template<typename T>
inline void callWithMax(const T& a, const T& b) //采用pass by reference-to-const
{
    f(a > b ? a : b);
}

小结:
- 对于单纯常量,最好以const对象或者enums替换#defines
- 对于形似函数的宏,最好改用inline函数替换#define

3、关于const

      const可以用的地方非常多,在类外修饰global或namespace作用域的常量,修饰文件、函数、或区块作用域中被声明为static的对象,也可以修饰类内部的static和non-static成员变量。对于指针,可以指出指针自身、指针所指物,或者两者都是const。

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可以和函数返回值、各参数、函数自身(如果是成员函数)产生关联。
      令函数产生一个常量值,往往可以降低因客户而造成的意外,而又不至于放弃安全性和高效性。列如:

class Rational {...};
const Rational operator* (const Rational& lhs, const Rational& rhs);

      这里返回const对象可以禁止下面这种现象发生

Rational a, b, c;
...
(a*b) = c;

      可能有人会说我不写这样的函数,但是许多程序员会无意识的那么做,例如:

if(a*b = c)...    //不小心将==写出=

const成员函数
      将const实施于成员函数的目的是为了确认该成员函数可作用于const对象身上。这类成员函数非常重要,有两个理由。第一,它们使类接口比较容易理解,这是因为,得知哪个函数可以改动对象内容而哪个函数不行。第二,它们使“操作const对象”成为可能。这对编写高效的代码非常关键,因为改善C++程序效率的一个根本办法是以pass by reference-to-const方式传递对象,而此技术的前提是我们有const成员函数可用来处理取得的const对象。

注意:
(1)const修饰的成员函数不能修改任何的成员变量(mutable修饰的变量除外)
(2)const成员函数不能调用非const成员函数,因为非const成员函数可以会修改成员变量

      成员函数如果是const意味着什么?有两个流行概念:bitwise constness(又称physical constness)和logical constness。bitwise const这一阵营的人相信,成员函数只有在不改变对象之任何成员变量(static除外)时才可以说是const。也就是说它不能改变对象的任何一个bit。logical consteness 这一派主张,一个const成员函数可以修改它所处理的对象内的某些bits,但只有在客户端侦测不出的情况下才得如此。
       对于bitwise const阵营来说,好处是很容易侦测违反点:编译器值需要寻找成员变量的赋值动作即可。不幸的是,许多成员函数虽然不十足具备const性质但仍能通过bitwise测试。例如:

class CTextBlock
{
public:
    ...
    //bitwise const声明,但其实不恰当
    char& operator[](std::size_t position) const 
    {
        return pText[position];
    }
private:
    char* pText;
};

      请注意,operator[]实现代码并不更改pText。于是编译器很开心地为operator[]产出目标码。但是看看它允许发生的事情:

const CTextBlock cctb("Hello"); //声明一个常量对象
char* pc = &cctb[0];  //调用const operator[]取得一个指针,指向cctb的数据
*pc = "J";  //cctb变为"Jello"

      这种情况导出所谓的logical constness。这一派主张,一个const函数可以修改它所处理对象的某些内容。例如你的CTextBlock class有可能高速缓存文本区块的长度以便应付查询:

class CTextBlock
{
public:
    ...
    std::size_t length() const;
private:
    char* pText;
    std::size_t textLength;
    bool LengthIsValid;
};
std::size_t CTtextBlock::length() const
{
    if(!LengthIsValid)
    {
        //错误,在const成员函数内不能赋值给textLength和LengthIsValid
        textLength = std::strlen(pText); 
        lengthIsValid = true;
    }
    return textLength;
}

      上面的代码编译会出错,因为length()函数会修改textLength和LengthIsValid的值。编译器坚持bitwise constness,不同意修改其值。不过解决办法很简单:利用C++的mutable关键字。mutable会释放掉non-static成员变量的bitwise constness约束。如下:

class CTextBlock
{
public:
    ...
    std::size_t length() const;
private:
    char* pText;
    mutable std::size_t textLength;
    mutable bool LengthIsValid;
};
std::size_t CTtextBlock::length() const
{
    if(!LengthIsValid)
    {
        textLength = std::strlen(pText); 
        lengthIsValid = true;
    }
    return textLength;
}

小结:

  • 将某些东西声明为const可以帮助编译器侦测出错误用法。const可被实施于任何作用域内的对象、函数参数、函数返回类型、成员函数本体。
  • 编译器强制实施bitwise constness,但你编写程序时应该使用“概念上的常量性”。

    4、使用对象前先初始化

    关于“将对象初始化”这事,C++似乎反复无常。如果你这么写:

int x;

      在某些语境下x保证被初始化为0,有些语境则不能保证。通常如果你使用C part of C++而且初始化可能招致运行期成本(博主不太懂),那么就不保证初始化。一但进入non-C part of C++,规则就会有变化。这就解释了为什么array(来自C part of C++)不保证其内容被初始化,而vector(来自STL part of C++)确由此保证。关于初始化我们应该:永远在使用对象之前先将它初始化。对于任何成员的内置类型必须手工完成初始化,内置类型以外的则由构造函数完成。

注意赋值和初始化的区别:

class PhoneNumber {...};
class ABEntry
{
public:
    ABEntry(const string& name, const string& address, const list& phones);
private:
    string theName;
    string theAddress;
    list thePhones;
    int numTimesConsulted;
};

ABEntry::ABEntry(const string& name, const string& address, const list& phones)
{
    theName = name;
    theAddress = address;
    thePhones = phones;
    numTimesConsulted = 0;
}

      虽然这样写没有错,但这不是好的C++做法。C++规定,对象的成员变量的初始化动作发生在构造函数本体之前。在ABEntry构造函数内,theName、theAddress和thePhones都不是初始化,而是赋值。但对于怒骂TimesConsulted不为真,因为它属于内置类型,不保证一定在你所看到的那个赋值动作的时间点之前获得初始值。ABEntry构造函数较好的写法是使用成员初始化列,这种方式效率往往更高。如下:

ABEntry::ABEntry(const string& name, const string& address, const list& phones)
:theName(name),
  theAddress(address),
  thePhones(phones),
  numTimesConsulted(0)
{}

不同编译单元内的“non-local static对象”的初始化次序:

      假设有一个FileSystem class,它让互联网上的文件看起来像在本机。由于这个class使世界看起来像个单一文件系统,你可能会产生一个特殊的对象,位于global或namespace作用域内,象征单一文件系统:

class FileSystem
{
public:
    ...
    std::size_t numDisks() const;
    ...
};
extern FileSystem tfs;

      现在假设某些客户建立了一个class用以处理文件系统内的目录,很自然他们的类会用上theFileSystem对象:

class Directory
{
public:
    Directory(params);
    ...
};
Directory::Directory(params)
{
    ...
    std::size_t disks = tfs.numDisks();
}

//假设客户决定创建一个Directory对象用来放置临时文件:
Directory tempDir(params);

      现在,初始化次序的重要性显示出来了:除非tfs在tempDir之前初始化,否则tempDir的构造函数会用到尚未初始化的tfs。但是tfs和tempDir是不同的人在不同的时间于不同的源码文件建立起来的,它们是定义于不同编译单元的non-local static对象。但是C++对于不同的编译单元内的“non-local static对象”的初始化相对次序没有明确的定义。
      为了解决上述问题,我们需要将每个non-local static对象搬到自己的专属函数内(该对象在函数内被声明为static)。这些函数返回一个reference指向它所含的对象。然后用户直接调用这些函数,而不需要指涉这些对象。这样做是因为:C++保证,在函数内的local static对象会在“该函数被调用期间”“首次遇上该对象之定义式”时被初始化。所以如果你以“函数调用(返回一个reference指向local static对象)”替换“直接访问non-local static对象”,你就获得了保证,保证你所获得的那个reference将指向一个历经初始化的对象。修改代码如下:

class FileSystem{...};         //同前
FileSystem& tfs()              //这个函数替换tfs对象
{
    static FileSystem fs;
    return fs;
}
class Directory{...}           //同前
Directory::Directory(params)
{
    ...
    std::size_t disks = tfs().numDisks();
    ...
}

Directory& tempDir()
{
    static Directory td;
    return td;
}

小结:

  • 对于内置对象类型进行手工初始化,因为C++不保证初始化它们
  • 构造函数最好使用成员初值列,初值列列出的成员变量的排列次序应该和它们在class中的声明次序相同。
  • 为了免除“跨编译单元之初始化次序”问题,请以local static对象替换non-local static对象。

你可能感兴趣的:(C++)