Effective C++ 读书笔记 Item1-Item4

目录

守则01:把C++看做一个语言的集合,而不是单一的语言

守则02:尽量使用const, enum, inline, 减少宏变量#define的使用

守则03: 尽可能使用const关键字

守则04: 在使用前保证对象是初始化的


看完C++ Primer,最近吃了安利开始啃Scott Meyers的Effective C++第三版,书中干货不少,非常值得深思借鉴。但是也有点太多了(→_→),脑子实在装不下,就想写成博客一样的读书笔记同时分享给大家,再也是为了第二次读的时候,能给自己留一点印象。一共有55篇,希望自己可以坚持下来。

守则01:把C++看做一个语言的集合,而不是单一的语言

"C++ is a federation of languages"

早期的C++只是叫"C with classes",但发展到今天已经成为一个多重泛型编程语言(Multi-paradigm programming language),它具有4种“子语言”:

  • C
  • 面向对象的C++
  • 模板C++
  • STL

高效的C++编程守则取决于你所使用的“子语言”

例如:

  • 在C中,一般使用值传递 (Pass by value)
  • 在面向对象的C++和模板C++中,使用常量引用传递 (Pass by const reference)更加高效
  • 对于STL,因为迭代器是基于指针构造而成,直接使用值传递即可

在不同的"子语言"中需要你用不同的策略,而非自始至终单一的方法,记住这一点对于理解学习C++这样功能强大的语言十分有帮助。


守则02:尽量使用const, enum, inline, 减少宏变量#define的使用

①或者说,尽量多用编译器,少用预处理器

"Prefer the compiler to the preprocessor"

例如:

#define A 1.653   

在上面这个语句中,字符串'A'是不会被编译器看到的,而编译器看到的是'1.653',这就会导致在调试过程中,编译器的错误信息只显示'1.653'而不是'A',让你不知从何下手。

解决方法:定义全局常量

const double A = 1.653;

使用全局常量还有一个好处:预处理器只会把每次对'A'的引用变成'1.653'而不管其是否已经存在,这就导致多次引用'A'会造成多个重复对象出现在目标代码中(Object code),造成资源浪费。

②当定义或声明全局变量时,常数指针和类的常数需要另加考虑

  • 对于指针

对于指针要把指针本身和它指向的数据都定义为const,例如

const char* const myWord = "Hello";

在C++中可以更方便地使用std::string这样基于char*类型的推广,例如

const std::string myWord("Hello");
  • 对于类的常数

声明为类的私有静态成员,这样既保证变量只能被这个类的对象接触到,又不会生成多个拷贝

class Player{
  private:
    static const int numPlayer = 5;
........

注意,因为此处是类的成员声明范围内,所以上面只是变量的声明和初始化,而并非定义,因此如果想获取变量的地址,需要在别处另加定义。这个定义不能有任何赋值语句,因为在类内已经规定为const:

const int Player::numPlayer;

 

③枚举技巧(the enum hack):

试想当你在一个类内声明某变量,但你的编译器不允许在声明时赋值初始化,同时接下来的某个语句却需要用到这个变量的具体数值,例如:

int noPlayer;
int scores[noPlayer];

此时编译器便会报错,需要在编译期间noPlayer有具体数值,这个时候就需要使用如下技巧:

enum {noPlayer = 5};
int scores[noPlayer];

这样编译器就能顺利通过,因为enum可以被当成int类型来使用

但注意enum类型在内存中没有实体,无法取得enum类型的地址,因此这个方法更相当于取一个本地的#define数值

 

④对于#define的宏函数,尽量使用inline修饰的函数来代替#define

inline关键字用来建议编译器把某频繁调用的函数当做内联函数,即在每次函数调用时,直接把函数代码放在函数调用语句的地址,减少堆栈浪费。

如果为了减少堆栈资源的使用,把某个频繁调用的函数规定为宏,例如用a和b的最大值来调用某函数f:

#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增加了两次 

解决方法:

template
inline void callMax(const T& a, const T& b){
    f(a>b ? a:b);
}

这样既保证了堆栈不会枯竭,又让代码更加美观

总结:

对于单纯常量,最好以const对象或enums替换  #define

对于形似函数的宏,最好改用inline函数替代    #define

 


守则03: 尽可能使用const关键字

"Use const whenever possible"

①指针与const:

如果要定义某指针或数据为常量不允许改变:

const char* p;              //数据是const,数据不允许被改变
char* const p;              //指针是const,指针不允许被改变
const char* const p;        //数据与指针都是const,数据与指针都不可以被改变

记忆法: const在星号左边修饰数据,const在星号右边修饰指针

以及如下两个语句的功能是相同的,不需要对此产生困惑:

const char* pw;             //都表示指向常量char的指针
char const* pw;

②迭代器与const

迭代器在功能上相当于指向某类型T的指针 T*

因此,如果想定义某迭代器指向一个常数,使用const iterator是不可以的,这样只相当于定义一个迭代器为一个常量(T* const),例如:

const std::vector::iterator it = v.begin(); //注意,此声明只表示迭代器本身是常量        
*it = 10;                                        //编译通过,迭代器是常量,但数据可以被修改
++it;                                            //编译失败!因为const迭代器不允许被改变!

解决方法,使用const_iterator:

std::vector::const_iterator it = v.begin();  //使用了const_iterator类型
*it = 10;                                         //编译失败,数据不允许被改变!
++it;                                             //编译通过,迭代器本身可以被改变

STL的iterator也是类似的,如果你希望指针本身是常量,可以声明const iterator; 如果你希望指针指向的对象是常量,请使用const_iterator

std::vector vec;
 
// iter acts like a T* const
const std::vector::iterator iter = vec.begin();
*iter = 10;                              // OK, changes what iter points to
++iter;                                  // error! iter is const
 
//cIter acts like a const T*
std::vector::const_iterator cIter = vec.begin();
*cIter = 10;                             // error! *cIter is const
++cIter;                                 // fine, changes cIter

③尽量使用const可以帮助调试

试想如下情形:

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

在某处使用此乘法操作符时,误把比较操作符"=="打成了赋值操作符"=":

Rational a,b,c;
if(a*b = c){......}                       

但编译器在此并不会报错,因为只有当a,b,c是C++自有类型(比如int)才会报错,对于用户自定义的类,编译器会认为此操作是将一个Rational赋值给另一个Rational

这就会导致不正确的结果却没有任何编译器错误信息,给调试增加麻烦

解决方法: 将该操作符定义为返回const,这样对其赋值将会是非法操作

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

④类的成员函数与const

给成员函数使用const关键字是非常重要的,它可以让接口更加直观,直接告诉用户这个函数是不是只读(Read only),会不会改变某变量。

更重要的是,用const修饰的对象只能调用const修饰的成员函数,因为不被const修饰的成员函数可能会修改其他成员数据,打破const关键字的限制。

因此,需要同时声明有const和没有const的成员函数,例如:

const char& operator[](size_t pos) const;
char& operator[](size_t pos);

对于某自定义的类Text:

Text t("Hello");
const Text ct("Hello");

std::cout<

⑤成员函数的常量性(Constness)

C++标准对成员函数"常量性"的规定是数据常量性(bitwise constness),即不允许常量对象的成员数据被修改。C++编译器对此的检测也十分简单粗暴,只检查该成员函数有没有给成员数据的赋值操作。

但如下情形,即使修改了某个数据,也可以通过编译器的检测:

const Text ct("Hello");        //构造某常量对象
char* pc = &ct[0];             //取其指针
*pc = 'K';                     //通过指针修改常量对象,编译不会报错,结果为"Kello"

数据常量性还有另一个局限性,例如:

class Text{
  public:
    std::sizt_t length() const;
  private:
    char* pText;
    std::size_t length;
    bool lengthValid;
....
};

std::size_t Text::length() const{
  if(!lengthValid){                      //做某些错误检测
    length = std::strlen(pText);         
    lengthValid = true;                   
  }

  return length;                         //这行才是代码核心
}

在这段代码中,length()函数要做某些错误检测,因此可能会修改成员数据。即使真正的功能核心只是返回字符长度,编译器依然认为你可能会修改某些成员数据而报错。

因此,更好的方法是逻辑常量性(Logical constness),即允许某些数据被修改,只要这些改动不会反映在外,例如,以上问题可以用mutable关键字来解决:

mutable std::size_t length;
mutable bool lengthValid;

这样成员函数length()就可以顺利通过编译。

此外注意,除mutable之外,静态成员(static)也可以被const成员函数修改。

⑥在定义常量与非常量成员函数时,避免代码重复

可能大家会有所困惑,既然两个版本的成员函数都要有,为什么又要避免重复?

其实在这里指的是函数的实现要避免重复。试想某函数既要检查边界范围,又要记录读取历史,还要检查数据完整性,这样的代码复制一遍,既不显得美观,又增加了代码维护的难度和编译时间。因此,我们可以使用非常量的函数来调用常量函数。

const char& operator[](std::size_t pos) const{....}
char& operator[](std::size_t pos){
  return
    const_cast(                              //const_cast去掉const关键字,并转换为char&
      static_cast(*this)[position];    //给当前变量加上const关键字,才可以调用const操作符
  );
}

为了避免无限递归调用当前非常量的操作符,我们需要将(*this)转换为const Text&类型才能保证安全调用const的操作符,最后去掉const关键字再将其返回,巧妙避免了代码的大段复制。

但注意,如果使用相反的方法,用const函数来调用non-const函数,就可能会有未知结果,因为这样相当于non-const函数接触到了const对象的数据,就可能导致常量数据被改变。


守则04: 在使用前保证对象是初始化的

“Make sure objects are initialized before used”

读取未初始化对象的后果:读取未被初始化的值会导致不明确的行为。在某些平台上,仅仅只是读取未初始化的值就可能让程序终止,更可能的情况是读入一些“半随机”bits,污染了正在进行读取动作的那个对象, 最终导致不可预知的程序行为,以及许多令人不愉快的调试过程

①自有类型(built-in type)的初始化

C++的自有类型继承于C,因此不能保证此类型的变量在定义时被初始化。使用未初始化的数据可能会导致程序不正常运作,因此在定义变量的时候,需要对其进行初始化,例如将如下代码:

int x;
double d;

改为:

int x=0;
double d;
std::cin>>d;

②类的初始化

对于用户自定义的类,我们需要构造函数(constructor)来完成此类的初始化,例如:

class Coordinate{
  private:
    int x;
    double y;
    const std::list& num;

  public:
    Coordinate(const int& _x, const int& _y, const std::list& _num);
};

//以下构造函数为成员x, y, num赋值来完成对象的初始化
Coordinate::Coordinate(const int& _x, const int& _y, const std::list& _num){
    x = _x;
    y = _y;
    num = _num;
}

可能这是一个易于记忆的方法,但并不是最好的方法,因为此构造函数并没有真正完成“初始化”,只不过是做了“赋值”的操作。而C++规定,在进入构造函数之前,如果用户没有规定初始化过程,C++将自动调用各成员对应类型的默认构造函数。

这样一来,此构造函数就相当于先调用了C++的默认构造函数,又做了一次赋值操作覆盖掉了先前的结果,造成了浪费。

解决方法:使用初始化列表(initialization list),C++就不必额外调用默认构造函数了

Coordinate::Coordinate(const int& _x, const int& _y, const std::list& _num):
x(_x), y(_y), num(_num) {}

另外,构造函数是可以被重载(overload)的,对于这个我们自己定义的类,还需要一个没有参数输入的默认构造函数,因此我们可以定义:

Coordinate::Coordinate():x(0), y(0), num() {}
//num()调用了std::list类型的默认构造函数

③某些初始化是语法必要的

例如在定义引用(reference)和常量(const)时,不将其初始化会导致编译器报错

const int a;                //报错,需要初始化!
int& b;                     //报错,需要初始化!

//现在对其进行初始化:

const int a = 3;            //编译通过

int c = 3;
int& b = c;                 //编译通过!

④数据初始化的顺序

在继承关系中,基类(base class)总是先被初始化。

在同一类中,成员数据的初始化顺序与其声明顺序是一致的,而不是初始化列表的顺序。因此,为了代码一致性,要保证初始化列表的顺序与成员数据声明的顺序是一样的。

class myClass{
  private:
    int a;
    int b;
    int c;
  public:
    myClass(int _a, int _b, int _c);
};

//注意,即使初始化列表是c->b->a的顺序,真正的初始化顺序还是按照a->b->c
myClass::myClass(int _a, int _b, int _c): c(_c), a(_a), b(_b) {}

⑤初始化非本地静态对象

现在还有一种特殊情况,尤其是在大型项目中比较普遍:在两个编译单元中,分别包含至少一个非本地静态对象,当这些对象发生互动时,它们的初始化顺序是不确定的,所以直接使用这些变量,就会给程序的运行带来风险。

先简要解释一下概念,

编译单元(translation unit): 可以让编译器生成代码的基本单元,一般一个源代码文件就是一个编译单元。

非本地静态对象(non-local static object): 静态对象可以是在全局范围定义的变量,在名空间范围定义的变量,函数范围内定义为static的变量,类的范围内定义为static的变量,而除了函数中的静态对象是本地的,其他都是非本地的。

此外注意,静态对象存在于程序的开始到结束,所以它不是基于堆(heap)或者栈(stack)的。初始化的静态对象存在于.data中,未初始化的则存在于.bss中。

回到问题,现有以下服务器代码:

class Server{...};     
extern Server server;                 //在全局范围声明外部对象server,供外部使用

又有某客户端:

class Client{...};
Client::Client(...){
    number = server.number;
}

Client client;                       //在全局范围定义client对象,自动调用了Client类的构造函数

以上问题在于,定义对象client自动调用了Client类的构造函数,此时需要读取对象server的数据,但全局变量的不可控性让我们不能保证对象server在此时被读取时是初始化的。试想如果还有对象client1, client2等等不同的用户读写,我们不能保证当前server的数据是我们想要的。

解决方法: 将全局变量变为本地静态变量

使用一个函数,只用来定义一个本地静态变量并返回它的引用。因为C++规定在本地范围(函数范围)内定义某静态对象时,当此函数被调用,该静态变量一定会被初始化。

class Server{...};

Server& server(){                         //将直接的声明改为一个函数
    static Server server;
    return server;
}

 

class Client{...};

Client::client(){                        //客户端构造函数通过函数访问服务器数据
    number = server().number;
}

Client& client(){                        //同样将客户端的声明改为一个函数
    static Client client;
    return client;
}

总结:

  • 对于自由类型,要保证在定义时手动初始化
  • 在定义构造函数时,要用初始化列表,避免使用在函数体内的赋值初始化。在使用初始化列表时,为了保持代码一致性,初始化列表中变量的顺序要与其声明顺序相同
  • 当不同的编译单元产生互动时,要将其中非本地的静态变量变为本地的静态变量才能保证安全的读写

 

你可能感兴趣的:(C++学习笔记,C++,effective,C++)