Effective C++属于pratice实践类的书籍. 主要传授工程中碰到的问题以及积累的经验. 看这类书是帮你快速成长的重要手段.
正如Scott Meyers在前言中所说的:
学习程序语言语法是一回事; 学习如何以某种语言设计并实现高效程序则是另一回事. 掌握一般性的设计策略和带有具体细节的特定语法特性是我的忠告.
同时我也比较赞同刘未鹏大神所说, 学习一个新事物, 你要问三个问题.
1 它的本质是什么? 2 它的第一原则是什么? 3 它的知识结构是什么?
看完Effective后, 希望能够得到这三个答案.
C++由C, Objected-Oriented C++, Template C++和STL四种次语言组成, 每种次语言都有其守则和范例, 守则都倾向简单, 直观易懂和容易记住. 你需要在四种次语言中进行切换.
举个例子, 传递参数使用pass-by-value还是pass-by-reference, C内置类型往往使用值传递, 包括指针; 而迭代器和函数对象基于C指针所以也使用值传递, 但是Object-Oriented C++中的自定义对象使用pass-by-reference-to-const, 从而避免频繁的调用构造函数和析构函数.
本章没有办法举个实际的例子.
回到前面的问题, 可以对问题3做出一部分解答. 这个应该是C++的知识结构了, 由C, OO C++, 模板以及STL四部分组成. 个人认为C语言为了精简而存在大量的语言缺陷.后面的每个条目中我会记下所有的陷阱问题.
P13文中提到包含#define ASPECT_RATIO 1.653
的代码中, 可能出现一个编译错误信息, 该错误信息包括1.653, 而不包含ASPECT_RATIO, 此外, 如果define语句在其它的头文件中, 将带来更多的麻烦. 现代编译器不太出现1.653, 往往只是说明变量类型而已, error: invalid conversion from ‘int’ to ‘int\*’
, 同时gcc编译器选项-g中ASPECT_RATIO往往也不会被替换为1.653. 所以宏使用起来还是很方便的. 当然考虑到其它编译器可能效果不同, 最后还是使用const常量比较好.
见define.cpp
常用字符串允许赋值为char*-based指针, 从而导致段错误
代码如下:
char *p = "test";
*p = 'A';
编译warning: warning: deprecated conversion from string constant to ‘char*’
运行error: Segmentation fault (core dumped)
缺陷改正方法: 使用const char* 来保存test信息. 如果p唯一指向此字符串, 使用const pointer即可.
见const\_pointer.cpp
类内部的const static变量仅仅是声明而已, 可以使用, 但是不同取地址以及赋值给引用. 作用类似于define. 只有在cpp文件中定义此变量后, 才会分配内存空间.
更多看详情
// *.h
class GamePlayer {
private:
static const int NumTurns = 5; //声明式
int scores[NumTurns]; //使用该常量
};
// *.cpp
const int GamePlayer::NumTurns; //定义式
随想: C++的本质是什么? C++是一个编译语言, 编译器负责了变量声明以及变量定义, 定义时涉及静态内存分配, 甚至还有模板元编程. 那么哪些是在运行时才知道的呢?
见const\_member.cpp
错误代码如下: undefined reference to
Test::a'`
struct Test {
static const int LEN = 5;
int a[LEN];
};
// const int Test::LEN;
int main() {
const int *p = &Test::LEN;
return 0;
}
解释: 编译过程中所有的Test::LEN都编译为常量了, 没有没有内存分配. 对于PYTHON这种动态语言, 由于所有的标识符可以会被其它程序加载并改变, 所以没有这种常量类型.
注: 某些编译器不支持类内const static变量声明初始化, 此时使用enum替代即可.
宏函数缺点: 存在副作用, ++/--等操作被复制, 导致运行多次.
#define f(a, b) ((a) > (b) ? (a) : (b))
int a = 2;
f(++a, 10); // a = 3
f(++a, 1); // a = 4
作用:
帮助编译器侦测出错误信息
P18提到, operator*相乘应该返回const值, 避免被赋值, 由于内置类型的返回值被赋值是非法的.
问: compare函数应该返回const类型吗? 应该其值不应该被赋值
见const\_return.cpp
-
编译器使用bitwise constness(即不修改变量的每个bit), 而实际中使用logical constness, 使用mutable来实现修改
-
const重载可以保证const对象和非const对象的正确调用, 副作用是代码的重复性.
见const\_function.cpp
C parts of C++, 仅函数外内置类型会初始化, OO/STL parts of C++, 自定义类型会调用构造函数, 容器会调用元素的函数或者值初始化.
如果有多个构造函数, 可以将那些"赋值表现像初始化一样好"的成员变量改用赋值操作, 从而将这些赋值操作移到某个函数中去.
"成员初始化次序"固定, 先初始化base类, 然后是derived类, class的成员变量按声明顺序进行初始化.
重点: 定义于不同编译单元内的non-local static对象(不包括全局作用域static变量), 无法保证初始化顺序. 常用方法是使用单例singleton模式.使用全局函数的local static对象
见local\_static.cpp
问题: 何时编译器不会生成默认构造函数, 析构函数, 复制拷贝函数以及赋值函数?
当没有生成对象, 复制或者赋值时; 当base类的相应函数为私有时会报错, 当存在不可赋值的成员,如引用\ostream\const常量等值时, 系统报错; 当自定义了上述函数时, 编译器就不管了
问题: 何时编译器会去生成这些函数?
一旦创建了对象就会生成构造函数和析构函数, 使用初始化或者赋值就会生成复制或赋值函数.
见silent\_function.cpp
通过Item5中提到了基类private复制和赋值构造方法实现拒绝复制
见uncopyable.cpp
需求1: 当基类拥有一个或者多个virtual函数时, 即作为多态使用时, 一定要定义虚析构函数
方法: 使用valgrind
来测试局部销毁, 参见partial\_delete.cpp
, 顺便提到了对齐问题
需求2: 当一个基类需要定义为虚基类时, 没有虚函数可以设置为纯虚函数的话, 可以设置纯虚析构函数并且给出一个定义.
见pure\_consturctor.cpp
情景: 如果有一个vector<Widget> v
, 当它被析构时, 若某个Widget析构报错异常而程序没有终止, 此时大量资源没有析构, 程序行为未定义; 若一个Derived对象析构时出现错误, 报异常, 子类可能没有析构, 此时出现错误. 那么如何处理此类问题呢?
方法: 提供一个接口函数来执行有异常的代码, 并在析构函数中吞下它们或者终止程序.
见exception\_destructor.cpp
在base class构造期间, virtual函数不是virtual函数. 存在两种解释: 一种是Base类构造函数中, Derived类的成员还没有初始化, 所以调用派生类的虚函数不合适. 另一方面在Base类时, this即表示Base类.
具体见link\_function.cpp
如果希望在基类中做一些和派生类有关的事情, 通过传参给构造函数来实现.
没什么好说的, 随众吧, 为了支持连锁调用.
当类中有指针类数据时, 自我赋值时可能删除自己, 导致无法正常赋值.
见identity\_test.cpp
解决办法:
- 避免在构造函数中使用资源的副本, 而不是引用它.
- 使用"自我赋值安全性"和"异常安全性"都好的copy-swap或者手工写法
手工写法精巧实用, 喜欢这个
见identity\_cure.cpp
一旦你自定义了copy复制和赋值函数以及构造函数, 你就承担了初始化, 赋值以及拷贝的重任, 当你添加了新的成员变量时, 你需要主动添加它们, 当然将所有的赋值操作放在一个私有的成员函数里面是极好的; 当我们有了继承类时, 主动调用这些自定义copy方法吧. 赋值构造函数甚至根本就不会调用子类赋值构造函数的哦.
看一下子类为调用父类operator=的情况:
见assign_fail.cpp
所谓资源管理就是, 一旦用了它, 将来必须还给系统.资源包括内存, fd, mutex, 图形界面中的字型和笔刷, 数据库连接以及网络socket.
忘记归还资源的几种可能: 忘记delete; 异常; 提前的return.
解决方法: 把资源放进对象内, 我们便可依赖C++的"析构函数自动调用机制"确保资源被释放. 从而将手动资源归还变成了自动化(诱人的三个字啊)
相对于不同的情形, 处理手段不同
-
单一区块或函数内的资源分配, 使用智能指针
auto\_ptr
. 例如std::auto_ptr\<Investment\> pInv(createInvenstment())
- 获得资源后立即放到管理对象中 RAII(Resouce Acquisition Is Initialization), 不要被其它代码获取到这份资源, 如果需要获得这份资源只能通过拷贝
- 管理对象利用析构函数确保资源被释放
- 注: 防止多个
auto\_ptr
指向同一对象, 导致多次归还资源. 而多个auto\_ptr
之间进行复制将导致右操作数置NULL, 从而使左操作数获得资源的唯一拥有权. STL要求元素具有赋值功能, 所以auto_ptr不可以使用
-
非环状引用下大区域范围使用智慧指针
shared\_ptr
注意: 由于auto\_ptr
与shared\_ptr
都使用delete而不是delete[], 所以不要出现下面的例子
std::auto_ptr<std::string> aps(new std::string[10]);
例子见smart\_ptr.cpp
当一个RAII对象被复制时, 会发生什么事?
-
禁止复制, 将拷贝构造函数和赋值构造函数声明为私有不实现.(也可以通过private 继承uncopyable基类)
-
底层使用shared_ptr来保存资源, 并且给智慧指针赋予自定义删除器
令: auto_ptr采用转移底部资源的拥有权, 深度拷贝使用复制底部资源
见mutex.cpp
例子
个人意见: 获得原始资源可能出现多次释放资源的错误, 所以避免隐式的转换. 使用get做显示转换即可.
见smart\_get.cpp
小例子
delete[]的解释: 动态对象数组中保存了"数组大小"的记录.
不要对数组形式使用
typedef
动作.
typedef std::string Address[4]
std::string *pal = new Address;
// delete pal; // 容易误用此方法
delete [] pal;
以独立语句将newed对象放入智能指针中, 防止被异常打断, 从而资源泄露.
见newed\_exception.cpp
设计接口的第一要务是考虑用户可能做出什么样的错误. 例如
Date(int month, int day, int year)
的代码可能输入错误次序或者传递无效的月份或天数; 在防范"不值得拥有的代码"上, 类型系统是你的主要同盟国.
如何避免接口错误? 让你的程序自身提供一致接口, 同时模仿并与优秀的代码接口保持一致, 例如STL容器.
任何接口如果要求客户必须记得做某些事情, 就是有着"不正确使用"的倾向, 因为客户可能会忘记做那件事.使用shared_ptr而不是普通pointer可以避免程序员忘记delete或者多次delete.
class设计即内置type类型设计
注: 本节是对Item20~Item25的总结.
通过reference-to-const来避免拷贝函数和构造函数的开销. const保证实参不会被修改. reference的本质是指针值传递, 所以内置类型直接使用值传递pass-by-value即可.
缓存器: 内置类型可以被放进缓存器, 而某些包含内置类型的光杆对象却不可以.
可以合理假设返回value的只有内置类型, STL迭代器和函数对象.
operator\*
这种操作如果返回stack上数据将会造成段错误; 如果返回heap上数据, 将会造成内存泄露; 如果使用static变量将无法满足多次调用*的情况, 而且无法满足多线程的需求.
见static\_return.cpp
成员变量的封装性与"成员变量的内容改变时所破坏的代码数量"成反比. 所以protected变量和private变量都不要使用可以保证封装性和访问数据的一致性.
C++标准程序库的组装方式, 将一些public调用函数的工具类放在同一namespace但不同头文件中.从而降低依赖性.
对象内数据的封装性: 越少函数可以访问数据, 封装性就越低.
无法何时如果你可以避免friend函数就该避免, 因为像真实世界一样, 朋友带来的麻烦往往多过其价值.
注: 暂时不看. 等看完C++ templates再说
效率: 尽量在需要明确初值时去定义变量. 这样可以省去调用default构造函数. 在循环中使用n组析构函数或者n个赋值函数+更大的作用域污染两种选择面前, 除非特别讲究效率的代码, 选择小作用域的代码更利于维护和升级.
C++规则的设计目标之一是, 保证"类型错误"绝不可能发生. 理论上如果你的程序很"干净的"通过编译, 就表示它并不企图在任何对象身上执行任何不安全, 无意义以及愚蠢荒谬的操作.
注意: 单一对象可能拥有一个以上的地址, 我们应该避免做出"对象在C++中如何如何布局"的假设, 当然更不应该依次假设为基础执行任何转型动作. 例如将对象地址转型为char*指针然后进行指针算术. 几乎总是会导致无定义以及平台相关性. 这个世界有许多悲惨的程序员, 他们历经千辛万苦才学到这堂课.
dynamic转型效率往往低下, 所以尽量避免使用.
见static\_cast.cpp
返回Reference, 指针和迭代器统统都是所谓的handles, 而返回一个"代表对象内部数据"的handle, 随之而来的便是"降低对象封装性"的风险. 即使调用const成员函数却造成对象状态被更改.
返回handle的后果是"handle比起所指对象更加的长寿"
见handle\_dangling.cpp
异常安全函数: 即使发生异常, 保证资源不泄露, 任何数据结构不被破坏. 使用智能指针保证内存, metex等资源不出问题. 使用copy-and-swap或者精巧代码保证原始数据结构不被破坏.
异常安全分为基本型(异常抛出后程序状态有效但是不一定不变), 强烈保证(程序状态不变, 往往不现实), 不抛保证(但是可能调用了abort等函数). 函数提供的"异常安全保证"通常最高只等于其所调用的各个函数中的最弱者.
inlining的坏处: 代码膨胀(大量本体替换导致代码膨胀); 降低告诉缓存装置的命中率, 由于无法复用一个page里面的代码; inline只是一个建议而已; 所有调用inline函数的代码需要随着inline函数的改变而重新编译; 不利于调试.
大多数inline在编译期完成.
不适合inline的场景: 循环语句, 构造函数和析构函数. 模板也要小心.
一个程序往往将80%的执行时间花费在20%的代码上头. 作为一个开发者, 找出其中20%的代码, 并竭力为之瘦身.
class中如果可以用object reference或object pointers完成的任务, 就不要使用objects, 从而降低编译依存关系. 尽量以class声明式替换class定义式. 希望类的变化不会影响到Person类
两种方式
- 相依于声明式, 而不是定义式; Person(仅需一个PersonImpl前置声明即可)和PersonImpl类.
- 使用抽象基类, 抽象基类编译时采用动态内存分配, 所以没有依赖性
代价: 引入虚函数的开销, 无法使用inline方法(由于所有函数实现必须放在cpp文件中, 否则在Person.h中引入其它头文件的话又引入了其它类的实现, 导致Person被污染)
见Person和PersonImpl的两种实现.
如果你了解C++各种特性的意义, 你会发现它不再是一项用来划分语言特性的仪典, 而是可以让你通过它说出你对软件系统的想法.
public继承就是"is-a"的关系. 把这个规则牢牢烙印在心中. 适合base class的情况一定适合derived class对象.
某些领域里面的直觉不一定会帮助你. 比如正方形是一种长方形. 矩形(宽度可独立于其高度被外界修改)却不可行施行在正方形身上(宽度总是和高度一样).
遮掩继承函数将会违反is-a的设计思想. 即使你是想避免疏远的类也是不应该的.再远的亲戚也是亲戚.
使用using或者转交函数来避免加载所有函数.
见hide\_fun.cpp
pure virtual 函数提供一个接口, 无法提供实现; impure virtual提供接口和一份默认实现, 鼓励用户重写; (前两者的中间情况: 实现pure virtual函数, 既保证了虚基类无法实例化, 又可以防止子类错误继承实现.或者书写一个protected方法) non-virtual函数一般不要修改其实现.
见`pure_implementaion.cpp
有点难: 暂时记下了, 使用策略, NVI等模式.
is-a的特性, 如果重新定义了"不变形凌驾于特异性"的non-virtual函数, 那么子类将表现不一致.
virtual函数系动态绑定, 缺省参数却是静态绑定.
见virtual\_default\_para.cpp
通过组合has-a或者重写某个类来实现一个新的类
当class间关系是private时, 编译器不会自动将派生类对象转换为基类对象. Private继承意味着implemented-in-terms-of, 只能通过基类的接口来重塑. 两者没有任何观念上的关系.
尽可能使用复合, 必要时才使用private继承.
优点:
- 复合无需继承相应的函数接口
- 复合中使用pimpl, 可以使编译依存性降到最低.
- private继承优点: 可以访问protected成员, 以及重新定义virtual方法.
注: EBO问题, private继承的基类大小为0, 但是复合的基类大小为char, 而成员基类对象可能为int(使用对齐)
函数重载先确定最佳匹配而后才检验其可用性, 因而private和public的两个函数也可以造成二义性.
virtual继承代价较大.
有一种多重继承的实现方式, public继承Person类, private继承Person中pimpl用到的Person_Info类(由于新类需要重写virtual方法).
-
建议引入C++标准的c文件 引入C中
.h
文件时,size\_t
等变量存在global作用域::
中; 而引入C++的c\*
文件时, 变量在std::中. -
C++中不要使用动态内存作为异常, 容易造成内存泄露. 参考http://stackoverflow.com/questions/23590266/how-does-the-memory-leak-happen