Effective C++ 读书笔记(一)
1、让自己习惯C++
条款 01 :视C++为一个语言联邦
C++的四个层次:
- C:C++是在C语言的基础上发展而来的
- Object-Oriented C++:这是C++中不同于C的部分,这里主要指面向对象。
- Template C++:C++中的泛型编程。
- STL:这是一个标准模板库,它用模板实现了很多容器、迭代器和算法,使用STL往往事半功倍。
条款 02: 尽量const、enum、inline替换#define
-
const好处
- define直接常量替换,出现编译错误不易定位(不知道常量是哪个变量)
- define没有作用域,const有作用域提供了封装性
-
enum好处:
- 提供了封装性
- 编译器肯定不会分配额外内存空间(其实const也不会)
-
inline的好处:
-
define宏函数容易造成误用(下面有个例子)
//define误用举例 #define MAX(a, b) a > b ? a : b int a = 5, b = 0; MAX(++a, b) //a++调用2次 MAX(++a, b+10) //a++调用一次
-
-
注意:
- 对于单纯的常量,最好以const对象或enums替换#define
- 对于形似函数的宏,最好改成内联函数
条款 03 :尽可能使用const
-
const修饰的变量不允许改变
-
注意指针常量与常量指针,stl中的迭代器类似指针(T* const point,指向的元素可以修改)
-
const成员函数
-
可以确认类中哪些成员函数可以修改数据成员
-
const对象只能调用const 对象成员函数,非const对象既可以调用普通成员函数也可以调用const成员函数(这是因为this指针可以转化为const this,但是const this 不能转化为非 const this)
-
一个函数是不是const是可以被重载的
-
更改了指针所指物的成员对象不算是const,但是如果只有指针属于对象,则成函数为bitwise const 不会发生编译器异议
-
用mutable关键字修饰的成员变量,将永远处于可变状态, 哪怕是在一个const函数中
-
如果const和非const成员功能类似,用非const版本调用const版本,避免代码复制;
class CTextBlock{ public: const char& operator[](std::size_t position)const { ... return pText[position]; } char& operator[](std::size_t position) { return const_cast<char&>(static_cast<const CTextBlock&>(*this)[position]); } char * pText; int length;
-
条款 04 : 确定对象使用前已被初始化
- 有些情况下会初始化为0 ,有时候不会被初始化
- 内置类型,手工初始化
- 内置以外的类型,构造函数初始化
- 构造函数体内的是赋值,初始化列表中才是初始化
- 初始化顺序要和声明顺序一致
- 初始化的效率高于赋值
- 赋值是先定义变量,在定义的时候已经调用的变量的默认构造函数之后是用了赋值操作符;
- 初始化时直接调用了拷贝构造函数
- const、引用、基类传参(因为基类先于派生类初始化)、对象成员必须在初始化列表中
- 函数体内的static对象是local static对象,其他static对象是non-local static对象
- 定义在不同编译单元内的non-local static对象”初始化次序无明确
- static对象只有一份拷贝,且只初始化一次(类似于单例模式)使用local static对象,首次使用时初始化,返回其引用即可(local static声明周期是整个程序),以后再使用无需再次初始化。
- 总结:
- 手动初始化non-member对象
- 使用初始化列表初始化member对象。
- 消除初始化次序的不确定性。
关于编译单元:
在C++中,非局部静态对象(即全局或文件作用域的静态对象)的初始化次序在不同的编译单元(通常是不同的源文件)之间是未定义的。这意味着,如果你在一个编译单元中依赖于另一个编译单元中的全局静态对象的初始化结果,你的程序可能会遇到未定义行为,因为那些对象的初始化次序是不确定的。
为了避免这个问题,可以使用局部静态对象(即在函数内部声明的静态对象)。局部静态对象在它们首次被访问时才会被初始化,并且初始化是线程安全的(在C++11及更高版本中)。由于局部静态对象的初始化是在它们被首次访问的点上明确发生的,因此不存在跨编译单元的初始化次序问题。
2、 构造/析构/赋值运算
条款05 :了解C++默认编写并调用哪些函数
空类经过编译器处理后会有默认构造函数、复制构造函数、赋值操作符和析构函数。这些函数都是public且inline
- 默认构造函数,由它来调用基类和non-static成员变量的构造函数
- 析构函数是否是虚函数,继承基类,如果没基类,那么默认是non-virtual,析构函数会调用基类和non-static成员变量的析构函数。
- 复制构造函数和赋值操作符中,给成员变量初始化或赋值,会调用成员变量的赋值构造函数和赋值操作符。他们都是浅拷贝
- 赋值操作符,有些情况下编译器是不会合成的,例如
- 两个成员变量,一个是引用:初始化后不能更改,一个是常量:也是初始化后不能更改,因此不可以用赋值更改变量,此时编译器不会合成
- 基类的赋值操作是private的,派生类不会生成赋值运算符
条款 06 :若不想使用编译器自动生成的函数,就该明确拒绝
-
房子是个类,天下没有一样的房子,所以拷贝与赋值都不能使用,将其设置为私有(只声明不定义)就可阻止使用这两个函数
注意:普通调用会在编译阶段出错(private),友元和成员函数可以访问错误会发生在链接阶段(没有定义),错误出现越早越好,可以用继承来实现
class Uncopyable{ { protected: Uncopyable(){} ~Uncopyable(){}; private: Uncopyable(const Uncopyable&); Uncopyable& operator=(const Uncopyable&); }
其他类来继承就行了
这样继承的类中如果生产对应的拷贝与赋值构造函数,就会调用基类对应的函数,会发生编译错误
条款 07 :为多态基类声明为virtual析构函数
-
创建有层次的类时,将基类的析构函数声明为虚函数
原因:当基类指针(引用)指向子类对象时,如果析构对象通过delete 指针的方式,只会调用基类的析构函数,不会调用子类的析构函数。可能会造成内存泄漏
-
但是当一个类不做基类时,不要将析构函数弄成虚函数,因为调用过程中会多一步指针操作,同时对象也多了一个虚函数指针,
-
一个类不含虚函数,不适合做基类,STL中的容器没有虚析构函数,一个类中至少有个虚函数,析构函数才将弄为虚函数
-
一个类含有纯虚函数,抽象类不能被实例化
class AWOV { public: virtual ~AWOV()=0; }; AWOV::~AWOV(){}//这一步是必要的
如果把这个当做是基类,会有问题,析构函数只有声明没有定义,析构函数从派生类到基类的调用时,会发生链接错误。因此需要定义(空定义)
条款 08 :别让异常逃离析构函数
-
析构函数可以抛出异常,但是不建议这么做;例如:
容器销毁会调用析构函数,如果抛出异常,剩下的元素没有被销毁,会造成内存泄漏。如果继续销毁,会存在两个异常,两个异常会导致不明确的行为
-
有时候又必须在析构函数中执行一些动作,这些动作可能会导致异常,如果调用这些动作不成功会抛出异常,使得异常传播。解决方法如下:
-
动作函数抛出错误,就终止程序,调用abort函数
~DBConn()//析构函数关闭连接 { try{ db.close(); } catch(……) { //记录下对close调用的失败 std::abort();//退出 } }
-
吞下这个异常,它会压制某些失败动作的重要信息。比较好的是重新设计接口,使得客户能对可能的异常做出反应。
~DBConn()//析构函数关闭连接 { try{ db.close(); } catch(……) { //记录下对close调用的失败 } }
-
条款 09 : 绝不再构造和析构函数中调用virtual函数
人话版本:
- 对象的初始化状态
- 在构造函数执行期间,派生类对象的成员变量尚未完全初始化。如果此时通过基类构造函数调用virtual函数,并且该调用试图访问派生类的成员变量或方法,那么可能会访问到尚未初始化的数据,导致未定义行为。
- 类似地,在析构函数执行期间,派生类对象的成员变量可能已经开始被销毁,其状态已经是未定义的。此时调用virtual函数同样可能导致问题。
- 虚函数表的未正确设置
- 在C++中,虚函数通常是通过虚函数表(vtable)来实现的。在对象构造过程中,虚函数表可能还没有被正确设置以指向派生类的虚函数实现。因此,在构造函数中调用virtual函数可能会调用到错误的函数实现。
- 同理,在析构函数执行时,虚函数表可能已经开始被清理或修改,此时调用virtual函数同样可能遇到问题。
- C++语言的规范
- 从C++语言规范的角度来看,构造函数和析构函数中的virtual函数调用并不会“下降”到派生类。这意味着,即使在构造函数或析构函数中调用了virtual函数,实际上调用的也将是基类中的版本,而不是派生类中重写的版本。这通常与程序员的预期不符,可能导致难以调试的错误。
- 潜在的运行时错误
- 在构造和析构期间调用virtual函数可能会增加运行时错误的风险。例如,如果派生类的虚函数实现依赖于某些在构造函数或析构函数中尚未初始化或已被销毁的成员变量,那么这些实现可能会失败或产生不可预测的结果。
- 设计上的考虑
- 从设计角度来看,构造函数和析构函数的主要职责是初始化和清理对象的资源。它们不应该承担与对象业务逻辑相关的任务,这些任务应该由其他成员函数来处理。因此,将virtual函数调用放在构造函数或析构函数中可能违背了这一设计原则。
书中版本:
-
这类调用从不下降至子类(当前执行的构造函数与析构函数的那一层),此时无法呈现多态的性质。例如:
//父类 class Transaction{ public: Transaction(); virtual void logTransaction()const//virtual function { //log the Transaction std::cout<<"This is Transaction logTransaction"<<std::endl; } }; Transaction::Transaction() { logTransaction();//called in Ctor }
//子类 class BuyTransaction:public Transaction{ public: virtual void logTransaction()const { std::cout<<"This is BuyTransaction logTransaction"<<std::endl; } }; class SellTransaction:public Transaction{ public: virtual void logTransaction()const { std::cout<<"This is SellTransaction logTransaction"<<std::endl; } };
当有个对象:BuyTransaction b 时,会输出父类的函数内容,这是因为基类先构造,在基类构造期间,不会下降到派生类去调用派生类的虚函数,所以调用的是基类的虚函数,此时不表现出多态的性质。
解决方法:将父类的那个函数设置成非虚函数,从derived class构造函数传递参数给base class构造函数
#include<iostream> class Transaction{ public: explicit Transaction(const std::string& parameter); void logTransaction(const std::string& parameter)const//no-virtual function { //log the Transaction std::cout<<"This is "<<parameter<<" logTransaction"<<std::endl; } }; Transaction::Transaction(const std::string& parameter) { logTransaction(parameter);//called in Ctor } class BuyTransaction:public Transaction{ public: BuyTransaction() :Transaction(CreatPamameter()) { } private: static std::string CreatPamameter() { return "BuyTransaction"; } }; class SellTransaction:public Transaction{ public: SellTransaction() :Transaction(CreatPamameter()) { } private: static std::string CreatPamameter() { return "SellTransaction"; } }; int main() { BuyTransaction b; SellTransaction s; return 0; }
-
当构造派生类对象时,先调用基类的构造函数,此时派生类还没有被构造出来,所以调用的是基类的虚函数。
而析构时,派生类已经析构掉了,所以基类析构时仍调用的是基类的虚函数。错!
实际上,无论派生类有没有被构造出来,还是已经析构了。在构造、析构函数中一定只会调用本类中的虚函数。 因为在函数进入构造、析构函数时,一定会把虚指针填充为当前类虚表的首地址
条款10 :令operator=返回一个reference to *this
- 为了实现连锁赋值,操作符必须返回一个reference指向操作符左侧的实参。其实,如果operator=不返回一个引用,返回一个临时对象,照样可以实现连锁赋值(但是这个临时对象会调用一个拷贝构造函数)
- 与之类似的有+=、-=等改变左侧操作符的运损,就当做是个协议,我们都去遵守吧
条款11 :在operator=中实现“自我赋值”
-
如果自己管理资源,可能会“在停止使用资源之前意外释放了它”
class Widget { public: Widget& operator=(const Widget& rhs) { delete p;//如果p之前就已经释放掉了,再次释放会被报错 p=new int(ths.p); return *this; } int *p; };
防止以上的方法就是“证同测试”,判断当前判断是不是赋值
class Widget { public: Widget& operator=(const Widget& rhs) { if(this==&rhs)//证同测试 return *this; delete p; p=new int(rhs.p); return *this; } int *p; };
-
还有一个方案是copy与swap技术,用来解决异常安全问题,条款29 详细说明
如果是引用传递
class Widget { public: void swap(const Widget& rhs);//交换rhs和this Widget& operator=(const Widget& rhs) { Widget tmp(rhs);//赋值一份数据 swap(tmp)//交换 return *this;//临时变量会自动销毁 } int *p; };
如果是值传递,则不需要新建临时变量,直接使用函数参数即可
class Widget { public: void swap(const Widget& rhs);//交换rhs和this Widget& operator=(const Widget rhs) { swap(rhs) return *this; } int *p; };
条款 12 : 复制对象时勿忘其每一个部分
- 一旦给类添加变量,自己写的copying函数(拷贝与赋值构造函数)也要修改,因为编译器不会提醒你;
- 在派生类层次中,派生类中的构造函数没有初始化的基类部分是通过默认构造函数初始化的(没有就会报错)但是在赋值操作符中,不会调用基类的默认构造函数。因为赋值操作只是给对象赋值,不是初始化,因此不会调用基类的构造函数(重要)
- 赋值操作符与拷贝构造函数不能相互调用,因为拷贝构造函数是构造一个不存在的对象,而操作符是给一个存在的对象重新赋值。如果你发现拷贝构造和拷贝赋值的重复代码很多,应该去建立一个新的成员函数给两者使用,并且一般命名为init().