基础议题:指针、引用、类型转换、arrays、constructors
条款1:仔细区分指针和引用
- 引用在某种程度上相当于常量指针,因为其必须给定初始化值,并不能改变指向,所以设为nullptr自然就没有意义了
- 在使用语法上引用和原始类型保持一致,而指针使用去引用符*和-->来分别获取原始对象和成员
- 值得注意的是被引用的对象(包括简单类型)必须是左值(?)
- 没有null reference意味着references可能更有效率,因为不需要测试其有效性
当你考虑不指向任何对象或者在不同时间内指向不同对象的可能性时使用指针,而当你确定总是会代表某个确切对象,并且不能再改变指向,则使用引用
另外引用在函数传较大对象的过程中使用较多,在必须返回某种能够作为左值时使
用引用更加清晰,如数组的[]操作符
条款2:最好使用c++转型操作符、
- 旧时转型使用括号语法,难以辨识具体转型类型
- c++中引入了static_cast、const_cast、dynamic_cast、reinterperet_cast;
- static_cast和c旧式转型基本一致,改变某个变量的静态类型
- const_cast改变constness和volatileness
- dynamic_cast:只能用于集成体系中,将指向基类的指针或者引用转化为指向继承类的指针或者引用,注意改变的是静态类型,如果相应的静态类型和动态类型不一致可能会产生不一致的效果(?);另外一个用途在条款27中讲述
- reinterperet_cast,这个操作符基本和编译平台有关,所以一般不具有移植性;一般用于函数指针的转型操作
tips:使用新式转型的好处:
- 严谨语义和易辨识度
- 编译器容易做转型错误
- 让转型变得丑陋未尝不是一件好事
条款3:绝对不要以多态的方式处理数组
- 继承最重要的性质:多态(运行时多态),也就是用基类指针和引用去处理派生类对象
- 如果对数组使用多态,由于数组的引用是一个叫做“指针算术表达式”的东西,每一次对其中元素的引用都必须按照指定类型大小来改变当前指针的位置,这就牵扯到了如何确定这个大小,而对于编译器而言这个大小就是指针静态类型的大小,而实际存储的确实派生类对象,这就会造成溢出
- 多态和指针算术不能混用,所以数组和多态不能混用
在条款33中,提出“具体类不要继承自另外一个具体类”
条款4:非必要不提供default constructor
- 含义:使得类没有任何信息提供就能初始化
- 不使用默认构造函数的影响:1.无法定义对象数组;2.使用指针数组,但是内存成本较高;3使用placement new预分配空间,操作不便;4. 不适合template-based container classes,比如容器模型是建立的对象数组之上的;5. 虚基类的初始化会因为没有默认构造函数而一层一层传递初值,很繁琐
- 但是我们任然有足够的理由在不必要的时候不使用它,因为它会使没有初值的对象存在进一步我们就需要在其他成员函数中测试当前对象是否有意义,这提高了程序的运行成本。而如果不使用默认构造函数,则程序的运行在预期之中,实现上就更加有效率了。
操作符
条款5:对定制的类型转换函数保持警觉
- 允许编译器执行隐式类型转换的害处大于好处。所以不要提供他们,除非确实需要
- 没有一个转换程序能够内含一个以上的“用户定制行为”
条款6:区分前置式后置式的++,--
- 前置式(++k)返回一个引用,对调用对象进行自增后返回,返回的是传入的对象,所以用引用;后置式(k++)返回一个const 对象,后置式是改变对象的内容并返回原有对象的内容,此时返回的是一个局部环境下构建的新对象,所以要返回对象。const的限制是避免对象本身被进一步修改。
- 因为上面的构造方法,我们一般认为前置式效率更佳
- 为了防止前置式和后置式在加法操作上的不一致,我们的原则一般是:后置式操作符的实现以其前置式为基础,如此一来便能够保证一致性行为。
条款7:千万不要重载&&、||和,操作符
- c++对于真假值表达式采取骤死式的评估方式,因此重载比较运算符可能从根本上改变程序的行为,破坏这一规则;
- 另外,函数调用的语义和所谓的骤死式语义有着重大的区别。第一当函数调用动作发生时,所有参数的评估工作已经完成,并且评估的顺序没有被明确规定。
- 逗号操作符拥有其内建类型的行为:表达式如果包含逗号,那么逗号左侧会被优先评估;整个表达式的值以右边的值为代表。而这些行为在你重载之后都会发生改变。
- 将操作符重载函数作为non-member函数将会导致无法确定左右的评估顺序。member函数也有同样的问题
条款8:了解各种意义的new和delete
- new expression:内建的操作符,主要干两件事,1.分配足够的内存;2.调用constructor构造对象,初始化。
- 函数void* operator new(size_t size):分配内存,可以重载的函数;
- placement new:在已经分配好的内存上面构建对象,语法上类似带有两个参数new operator,new (void*) xxxx(size);头文件include <new>
- delete用法和new一样,注意如果使用placement new,应该避免对那块内存使用delete operator。
异常
条款9:利用destructors避免内存泄露
- 将资源封装在对象内部,避免因为exception的出现而造成资源泄露
条款10:在constructor中避免内存泄漏
- c++只会析构已经构造完成的对象
- const 成员变量只能通过初始化列表进行初始化
- 如果你以auto_ptr对象取代pointer class members,你便是对你的cosntructors做了强化,免除了资源泄露,再需要destructors内亲手释放那个资源,并允许const members pointers得以和non-const members pointers有着一样优雅的处理方式。
条款11:禁止异常流出destructors之外
- 这样做可以避免terminate函数在exception传播过程中的栈展开(stack-unwinding)机制中被调用;它可以协助确保destructors完成其应该完成的所有事情
条款12:了解“抛出一个异常”和“传递一个参数”或“调用一个虚函数”之间的区别
- exceptions总是会被复制(包括以by reference-to-non-const,但是指针不会),如果以by value方式被捕捉,他们甚至会被复制两次。至于传递给函数参数的对象不一定得复制。
- “被抛出异常”的对象,其被允许类型转换动作,比“被传递到函数去”的对象少
- catch子句以其“出现于源代码的顺序”被编译器检验对比,其中第一个匹配成功者便执行;而当我们以某对象调用一个虚函数,被选中执行的是那个“与对象最佳吻合”的函数,不论他是不是源代码所列的第一个。
条款13:以by reference方式捕捉exceptions
- by pointer会出现抛出异常时,离开当前执行域时会发生对象删除问题
- by value会复制对象两次,会出现切割问题,无法调用派生类的虚函数
- 使用by reference可以有效解决上述问题,所以尽量使用引用
条款14:明智的使用exception specifications
- 语法:void myfunc() throw();只捕捉指定类型的异常,如果函数抛出一个未列出的异常,unexpected会被调用;
- unexpected默认会导致程序abort,大量资源可能泄露
- 局部性检验,只对当前函数执行抛出的异常进行检验。所以不应该将template和其混用
- 在回调函数中,极易可能与主调函数的异常具体化冲突,如果前者和后者指定了异常类型,则没事,但是大多数时候很难保证;
- typedef中不能有异常具体化,而宏可以
- 调用的程序库可能产生未指定异常
- 修改unexpected函数以将未预期异常转化为预期的
- 当一个高层的调用者已经准备好要处理发生的exception时,unexcepted函数却会被调用
条款15:了解异常处理的成本
- 成本1:为了在运行时处理异常,需要大量的簿记工作
- 大部分编译器允许你决定是否加入exception的支持能力
- 成本2:try语句块,代码大概膨胀5%-%10,这个成本和exception specification差不多
- 成本3:根据82法则,抛出异常不会对程序产生大量的冲击,但是性能损失也十分可观
效率
分为两部分:高效的数据结构和算法;高效的语言实现(结合c++语法特性)
条款16:二八法则
- 定义:一个80%程序资源、执行时间、内存、磁盘访问、维护成本都归咎于20%的代码
- 意义:你所产生的代码80%的时间不会影响系统的性能,这个法则也暗示如果你的软件出现性能上的劣势,那你的问题可能不仅仅出现在当前的瓶颈中,可能有更大的提升空间。而如何找出这个空间所在是一个值得思考的问题
- 可行之道:完全根据观察或实验识别那20%的关键所在;使用程序分析器(可以从总体上获取程序运行每一个区间的性能表现)
- 关注语句或函数被调用的频繁度(比如内存分配相关,系统调用等)
- 需要可重制(实际场景中运用的数据)数据来不断检测程序的问题
条款17:lazy evaluation(对应于eager evaluation)
- 策略1:引用计数,保证了数据共享机制的正常运行,比如写时复制
- 策略2:读写区分,比如读写锁机制,数据库的主从机制
- 策略3:lazy fetching,加载大对象数据时,不进行IO操作,而只是获取一个数据外壳,只有需要访问相关数据的时候,才采取行动,比如数据库。设计一个类,为其各个数据保留一个指针字段,并初始化为nullptr,那么在任何成员函数中都可能被赋值(包括mutable),所以使用mutable(高于const)保证它的可变性,或者使用下列语法
const string& ConstTest::field1() const{
ConstTest *const fakeThis=const_cast<ConstTest* const>(this);
if(fieldValue==0){
赋值操作;IO操作
}
}
- 表达式缓式评估:比如矩阵运算,可以延缓运算,而将表达式相关信息暂时保存,如果后续只需要获取部分信息,则可以有效避免大量运算
条款18:分期摊还预期的计算成本(over-eager evaluation)
- 如果你预期会经常运用某个操作,那么可以通过降低每次计算的平均成本,办法是设计一份数据结构以便能够高效的运行该业务。
- 其中有效的策略是保存可能会用到的数据,也就是caching
- 还有一种策略:prefetching,运用的哲学是locality of reference, 比如系统设计中的磁盘缓存、指令和数据的内存缓存等
- 一种重要用例是:在知道预期会使用更大的内存情况下,一次性分配较大的内存,而不是多吃少餐。这就提醒我们要尽量减少系统调用。这是一种用内存换时间的典型例子。
- 存在问题:prefetching在内存不足的情况下可能加剧内存分配的困难程度,因为很难找到合适大小的虚内存分页。此时就需要大量的换页操作,并且这也导致的缓存命中的困难程度(如果缓存可用的话,相反一次性获取多个对象的内存反而能提高花缓存命中)
条款19:临时对象
- 当产生non-heap object没有被命名时,则会产生临时对象;
- 临时对象通常发生于两种情况:一是隐式类型转换,二是当函数返回对象的时候;
- 特殊情况:当向一个string传递c_str时,如果是by value或者reference-to-const方式则转换会发生,而普通引用不能指向一个右值,也就是不能指向一个临时对象,所以这里的转换既不能发生,也不合法
- 有时候,使用复合单操作数运算符比双操作数运算符更能避免产生返回临时对象
条款20:返回值优化
- 对于返回对象的情况,c++提供一种优化的方式,在返回语句中创建的对象不做为临时对象,而是直接构造于其调用空间中
- 为了返回值优化而将非构造类型的变量运算符重载没有必要,这会改变原本的语义特性
条款21:利用重载技术避免隐式类型转化
- 为了返回值优化而将非构造类型的变量运算符重载没有必要,这会改变原本的语义特性
条款22:考虑以操作符复合形式取代独身形式
- 复合形式可以使用返回值优化或者抹去返回值以避免创建临时对象
- 当你面临在临时对象和命名对象之间选择时,尽量选择前者
- 身为一个程序设计者应该提供两种方式,一种是最自然的实现,另一种是高效的实现,作为软件开发者应该尽量选择后者
条款23:考虑使用其他程序库
- 根据性能,内存消耗,IO访问等优化选择第三方程序库
条款24:virtual functions、multiple inheritance、base classes、RTTI
- 虚函数的实现机制:编译期提供虚表和虚表指针这两个数据结构维护动态调用所必需的的信息
- 虚表是建立在类上的,也就是一个类(包括派生类)一个虚表用于存储虚函数指针信息,这个虚表存储在第一个包含non-inline,non-pure函数定义的目标文件中;派生类拥有自己的虚表,并且继承基类的函数指针,同时覆盖同名的函数指针。如果派生类继承自多个基类,则维护多个虚表,相应对象也维护多个虚指针。
- 每一个包含虚函数类的对象(动态类型)都会维护一个vptr(指向虚表的指针而不是虚函数),vptr在寻找对象类所在的表时会产生一个offset adjustment。
- 虚函数的调用并不构成性能瓶颈
- 多重继承使用vitrual base classes来避免数据成员的复制,而其底层也是使用虚类指针的方式实现的
- RTTI:我们有义务让包含虚函数的类都能获取其对象的类型信息,而这个信息一般也被维护在虚表里面
- 虚表机制(编译器提供的服务)在一些场景中应该被回避,如对象的持久化,进程间搬移对象;
- 动态绑定的调用过程是这样的,首先,基类指针被赋值为派生类对象的地址,那么就可以找到指向这个类的虚函数的隐含指针,然后通过该虚函数的名字就可以在这个虚函数表中找到对应的虚函数的地址。然后进行调用就可以了;
- 虚函数的内存模型
技术
条款25:将构造器和非成员函数虚化
- 虚化构造器:用于返回一个基类指针的generator,或者用于copy当前对象并返回其基类指针的copy函数,这种函数一般会调用真正的复制构造器,与之保持功能一致性。
- 这种机制利用晚些时候加入的宽松点:重写的虚函数不必保持返回类型一致性,而返回具有继承关系的指针和引用是设计合理的应用
- 非策成员函数虚化:写一个虚函数做实际工作,再写一个什么也不做的非虚函数,只负责调用虚函数,为了避免函数调用的成本可以将非虚函数inline。
条款26:限制某个class所能产生的对象数量
- 如何产生零个或者一个对象:零个对象的做法是将class的构造函数(两个)放到private中;一个对象的做法是在零个基础上使用友元函数包含static对象的创建方式保证只有一个对象被创建
- 友元函数首先是一个全局函数,然后加入了类的访问权限。而全局性往往使我们深恶痛疾的,所以我们可以将友元函数改为静态成员函数(public)也能完成一个问题,但是这又造成一个问题:;类的静态对象会从一开始就被创建,而函数的静态对象只有当函数被调用时才创建,前者违背了c++的哲学基础:你不应该为你不使用的东西付出代价。
- 另外,class static对于初始化时机的把握也不是很明确,因为不同编译单元内的static无法保证初始化顺序
- static和Inline的互动:inline非成员函数存在内部连接,而带有内部连接的函数可能会在程序中复制,而相应的local static object也会被复制,这样就会产生多个static对象的副本,所以应该内联带有静态局部变量的非成员函数
- 限制数量的另外一种通用方式是:计数限制,然而又出现一个新的问题,存在继承关系的计数可能会违背我们的初衷,所以使用条款33的避免对一个具体类继承可以帮到我们。
- 然后对象计数限制可能以三种不同的形式呈现出来:(1)它自己(2)派生类的基类成分(3)内嵌于较大对象中,这些形式把追踪对象的意义搞混乱了。对于第一种限制可以使用private构造函数实现,因为private构造函数避免了被继承和内嵌;可以使用静态成员函数的方式(使用new而不是static)实现创建多个对象,同时避免了混乱的情况,同时为了为了避免泄露,我们将使用智能指针;将限制计数和这种情况结合就能实现对象的生生灭灭。
- 如此一来,问题大致得到了很好的解决,但是如果存在多个需要被计数的类,这样的逻辑就无法避免出现代码的重复。我们可以通过设计一个私有继承的方式,将计数的实现过程封装成一个基类。考虑到每一个类型都得有一个单独的计数器,所以static变量在多个类型之间不能共享,c++中的template可以帮助避免这样的问题。关于设计,我们需要注意以下几个问题:1. 保持私有继承,因为这是has-a的关系;2. 计数器的值应该对外暴露出来,所以子类的相关数据仍然保持私有,而在派生类中使用using base<derived>::objectCount;3. 关于计数的两个成员变量objectCount和maxCount,前者在定义(申明的时候不初始化,不分配空间)的时候默认初始化为零,后者应该使用和类型绑定的初始化方式,因为maxCount应该针对不同类型发生变化,这样的事情应该扔给用户去做 。
条款27:要求或者禁止对象产生于heap之中
- 考虑这个问题的初衷:保证对象是可以被“delete this”的;保证某一类型的对象绝对不会发生内存泄露,也就是对象绝对不会被分配在heap上。
- heap-based objects:限制new以外隐式构造和析构的使用-->限制constructor和destructors的访问级别--->限制destructors并为外界提供一个显示的析构封装;存在问题:继承(protected)和包含(object-->pointer),未完待续
- 上述的解决方案能够阶段性的解决当前类型的约束问题,但是不能保证派生类对象的父类部分一定是在heap上分配的,因为析构函数对子类仍然是可见的。这样我们可以使用子类标识来检测父类的构造是否是通过new产生的(调用operator new的时候改变标志位)进而得知当前对象的子类部分是否是heap-based;但是对于对象数组又会失效,并且在构造时通过位设立检测内存是否被分配于堆上并不可靠,有时候多个对象的建立会重排内存分配和构造的顺序(一个对象的内存分配可能会在另一个对象的构造之前);进一步考虑堆内存分配的顺序(从低地址-->高地址),这样的标准不具有一致性,并且static变量也可能是个干扰,内存被分配在三个地方的标准就难以掌控了;
- 改变思路:我们的初衷是想判断delete是否安全,而不是堆内存的判断(后者比前者更加模糊),而前者可以通过判断指针是否由new返回。在此基础上,我们给类型添加一个new的构造列表,并在operator new里面将每一个对象的首地址添加进去。而delete所需要做的就是从这个集合中删除相应的entry。更进一步:我们需要使用abstract mixin base class来封装heap track 的工作,具体基类的申明和实现如下:
//declaration
class HeapTracked{
public:
class MissingAddress{};
virtual ~HeapTracked()=0;
static void *operator new(size_t size);
static void *operator delete(void *ptr);
bool isOnHeap() const;
private:
typedef const void* RawAddress;
static list<RawAddress> addresses;
};
//definition
void* HeapTracked::operator new(size_t size){
void *memptr=::operator new(size);
addresses.push_front(memptr);
return memptr;
}
void HeapTracked::operator delete(void* ptr){
list<RawAddress>::iterator it=find(addresses.begin(),addresses.end(),ptr);
if(it!=addresses.end()){
addresses.erase(it);
::operator delete(ptr);
}else{
throw MissingAddress();
}
}
bool HeapTracked::isOnHeap() const{
const void *rawAddress=dynamic_cast<const void*>(this);
list<RawAddress>::iterator it=find(addresses.begin(),addresses.end(),rawAddress);
return it!=addresses.end();
}
// how to use it
class Asset: public HeapTracked{
};
void inventoryAsset(const Asset *ap){
if(ap->isOnHeap()){}
else{}
}
- dynamic_cast是找出某个对象占用内存的起始点,一般转型为const(或者volatile)void *,但是它只适用于对象有至少一个虚函数的情况,也就是存在多态的继承关系,对于普通类型int等不适用。
- 禁止对象产生于heap之中有三个含义:(1)对象被直接实例化(2)对象被实例化为derived class objects内的“base 成分”(3)对象被内嵌在其他对象之中
- 解决办法:将operator new私有化,存在问题是派生类没有办法heap-based,可以重写派生类的new方法,但是无法避免子类对自己的调用(?),关于内嵌问题,私有化的operator new就足以解决。
- 派生类使用new时,先用operator new开辟堆上内存,然后分别使用父类和自己的构造函数在该内存空间中完成初始化(?)
条款28:智能指针
- 使用智能指针你可以获得如下行为的控制权
- 构造和析构,通过引用计数管理资源
- 复制和赋值,你可以决定深复制还是浅复制等
- 解引用,你可以决定解引用时发生的行为,比如lazy fetching
- 智能指针的实现:智能指针由template产生出来,表明其实际类型,智能指针重载=,->,*等运算符
- 智能指针与RPC:将远程对象封装在本地智能指针里面,能够有效的控制远程调用的时机
- 构造:确定一个目标物,然后将成员变量指定为它,如果没有目标物就默认为零
- 复制构造、赋值和析构:在c++里面,auto_ptr规定同一对象只能拥有唯一的控制者,所以赋值操作会改变目标对象的控制权,而将删除原对象和将原指针置空。因此,我们千万不能按照pass-by-value的方式传递参数(因为这样复制构造会将原智能指针置空,并且在函数结束时原对象也会被销毁,返回后的任何引用都会导致灾难性的结果)。毫无疑问任何智能指针的传值都应该按照pass-by-reference-to-const 的方式。
- 当smart pointer对象被复制,或是作为赋值动作的来源端时,它们会被修改
- 解引用:此处返回一个对象的引用,这样可以赋予其多态属性,如果是lazy fetching则应该产生一个新对象,至于新对象和远程对象的拷贝可以延迟满足。
- 测试smart pointer是否为null:使用隐式类型转换操作符
operator void*();
,这样就能使用常规的逻辑判断了。但是这种重载会导致任何类型的判断都能通过编译,所以可以选择只重载!操作符 - smart-dumb:可以使用隐式类型转化符:
operator T*(){return pointee;}
但是此时的dumb又直接暴露在用户面前了,如果涉及到引用计数,这个问题就更加严重了。所以,尽量不要提供这种隐式类型转换。 - smart pointers的继承:可以使用隐式类型转换可以实现转换,但是太过于繁琐而且不安全。所以采用一种新的语言特性:将non virtual member function声明为templates,将多种类型的隐式类型转换用函数模板替代。
nvmf是一项先进的技术,它涵盖了以下四种重量级的要素:
- 函数调用的自变量匹配规则
- 隐式类型转换函数
- template functions的暗自实例化
- 成员函数模板等技术
- smart和const:const 天生支持对smart pointer指向的常量化,而对其指向对象的常量化是通过改变模板参数的常量性实现的。但是这样的改变并没有增加智能指针的弹性,常量指针(智能)和非常量指针(智能)并不能实现转换。另外,考虑到类型转换涉及到常量性和继承的单向性类似,即non-const->const是安全的,反之不安全。所以可以利用这种性质,令每一个smart pointer-to-T class 公开继承smart pointer-to-const-T class。实现逻辑如下:
\\base class
template<class T>
class SmartPtrToConst{
...
functions
...
protected:
union{
const T* constPointee;
T* pointee;
}
}
\\ derived class
template<class T>
class SmartPtr: public SmartPtrToConst{
没有data members
}
- 注意:两个class的members必须自己决定使用哪一个指针,编译器不能帮你决定。这个可以使用重载(???)
条款29:引用计数
- 引用计数的作用:1. 简化heap对象的簿记工作。记录对象的拥有者,在拥有权发生转移(而不是增加)时,这显得很困难;2. 节省空间,一处存储,多处使用。
- 实现:在private中构建一个struct包含引用计数器和引用值-->重写构造函数和赋值函数,使得他们支持引用计数
//declarations
class String{
public:
String(const char* initValue="");
String(const String&);
String& operator=(const String&);
~String();
const char& operator[]const(int);
char& operator[](int);
private:
struct StringValue{
int refCount;
char* data;
StringValue(const char*);
~StringValue();
}
StringValue* value;
};
//definitions for StringValue
String::StringValue::StringValue(const char * initValue):refCount(1){
data=new char[strlen(initValue)+1];
strcpy(data,initValue);
}
String::StringValue::~StringValue(){
delete [] data;
}
//definitions for String
String::String(const char * initValue = ""):value(new StringValue(initValue)){//这里本也可以实现真正的按字面量共享爱过你,但是
//太过精细反而会降低效率
}
String::String(const String & rhs):value(rhs.value){
++value->refCount;
}
String& operator=(const String& rhs){
if(value==rhs.value){//这个并不是按照字符串值进行比较,而是按照已创建对象之间是否有共有关系
return *this
}
if(--value->refCount==0) {
delete value;//检查当前对象状态,如果计数为零则当前指针有责任释放
}
value=rhs.value;//指向新的对象,并更新对象的引用计数
++value->refCount++;
return *this;
}
String::~String(){
if(--value->refCount==0) delete value;
}
const char& String::operator[](int index) const{
return value->data[index];
}
//区分写操作和读操作(copy-on-write)
char& String::operator[](int index){
if(value->refCount>1) {
--value->refCount;
value=new StringValue(value->data);
}
return value->data[index];
}
- 写时复制:通过对具体的写操作,也就是[]操作符的重载,检查当前计数是否为1,如果处于共享状态,则需要额外复制一份。另外这也带来一个问题,返回的可写单元是没有办法被引用计数的,所以它的改写会产生连锁效应,进而使得整个共享变得不可控。解决办法有:降低共享的实值个数,在StringValue中加入一个可否共享的标志位flag,对于被额外引用的对象禁止共享。如果读写无法有效区分,那么禁止共享的对象个数会比较多,如果区分开来那么这种机制是相当有效的。
-
reference-counting base class(RCObject):设计架构
- 注意事项:
- RCObect的赋值操作从来都不会发生,因为在一个有reference counting的系统中,实值对象并不会被赋值,只会改变引用次数;
- 这里的实值对象是专属于应用对象的,所以将其定义为嵌套类,而让一个嵌套类继承自另一个类,而后者与外围类无关,这种继承关系将越来越司空见惯;
- 如何自动操作(引用计数):使用模板智能指针而不是常规指针来操作实值对象,而关于写时复制,引用计数(构造、赋值、析构),深度复制等操作都被其自动处理。
- 但是在以下语句时需要注意:
pointee=new T(*pointee);
当你使用智能指针的init产生副本时,它会调用实值对象的copy constructor,而我们知道默认的copy constructor只会对成员指针执行浅复制,所以我们需要为所有内含指针的类重写copy constructor。- 对于应用对象,我们不用单独重写copy 和assignment,因为默认的就足够了
-
将reference counting泛化:
- 差异:RCPtr是直接指向实值,而后者是通过CountHolder实现;后者重载了operator->和operator*,这样一来non-const access就会触发写时机制(!!!,const函数的神奇)
什么时候适合用引用计数 ?
- 考虑其成本:计数器的创建和更新(CPU)、额外分配的内存(memory)、复杂的底层开发(开发成本)
- 适用于对象常常共享实值的情况:相对多数的对象共享相对少量的实值;对象实值创建和销毁的成本较高(内存和CPU)
- 其他考虑:自我引用和循环依赖的数据结构可能会导致对象的引用次数总是大于零;“不确定谁被允许删除什么东西”的问题也可以通过引用计数解决,而这个通常是许多程序员入坑的主要因素。
- 实值对象为new所得: 这和智能指针的要求是一样的,而应用对象负有确知实值对象共享性并将其实例化的责任。
条款30:proxy classes(替身类和代理类)
- 使用proxy class实现多维数组:二维数组的行可以用一个一维的对象代替,并重载[]运算符;
- 区分operator[]的读写操作:1. 使用代理类使得被替换的概念在操作上更加有弹性(能够支持更多精细的控制)2. 在reference counted string的基础上,我们讨论更复杂的情形:
读-->右值引用-->operator[]返回代理对象-->赋值操作operator[]
写-->左值引用-->operator[]返回代理对象-->其他方式使用如隐式类型转化char()
具体实现如下:
class String{
public:
...
class CharProxy{
public:
CharProxy(String& str,int index);
CharProxy& operator=(const CharProxy& rhs);
CharProxy& operator=(char c);
operator char() const;
private:
String& theString;
int charIndex;
}
const CharProxy operator[](int index)const;
CharProxy operator[](int index);
...
friend class CharProxy;
private:
RCPtr<StringValue> value;
...
};
//使用代理对象延缓请求
const String::CharProxy String::operator[](int index)const{
return CharProxy(const_cast<string&>(*this),index);
}
String::CharProxy String::operator[](int index){
return CharProxy(*this,index);
}
//根据具体操作决定使用哪种方式访问
String::CharProxy::CharProxy(String& str,int index):theString(str),charIndex(index){}
String::CharProxy::operator char() const{
return theString.value->data[charIndex];
}
String::CharProxy& String::CharProxy::operator=(const CharProxy& rhs){
if(theString.value->isShared()){
theString.value=new StringValue(theString.value->data);//智能指针的隐式构造
}
thsString.value->data[charIndex]=rhs.theString.value->data[rhs.charIndex];
return theString.value->data[rhs.charIndex];
}
- 注意:赋值函数要求CharProxy拥有对String.value的私有访问权,所以将CharProxy声明为friend类。
- 压抑隐式转换:使用代理对象可能在某些场景下造成多次隐式转换,而这是不被支持的,所以可以达到压抑隐式转换的目的,但是事实上更好的做法是使用explicit。
- 限制:尽管代理对象不能完成一些原生对象的诸功能(虽然可以重载),但是有明确的目标进而使用它仍然可以帮助我们解决大部分问题。
条款31:让函数根据一个以上的对象类型来决定如何虚化
- 注意到以类型为行事基准建立的函数在C语言中给人的影响是:难以维护,难以扩展,于是在c++里面引入了虚函数,将繁杂的类型判断逻辑交给编译器。而RTTI实现double dispatching无疑将我们再次推回了那个时代。
- 使用RTTI的if-else-if等来实现,代码的复用性和扩展性不强
- 使用虚函数加重载:在基类中扩充多个类型参数的虚函数,然后分别由子类继承他们,存在同样的问题——没有办法动态扩展
- 自行仿真虚函数表格:
class GameObject{
public:
virtual void collide(GameObject& OtherObject)=0;
};
class SpaceShip:public GameObject{
public:
virtual void collide(GameObject& otherObject){
processCollision(otherOject,*this);
}
private:
static void processCollision(GameObject& o1,GameObject& o2){
lookup(typeid(o1).name()),typeid(o2).name())
}
};
namespace{
void ShipStation(GameObject&){
// the process for collision;
}
void StaionShip(GameObject& g1,GameObject& g2){
return ShipStation(g2,g1);
}
typedef void (*HitFuncPtr)(GameObject&,GameObject&);
typedef map<pair<string,string>,HitFuncPtr> HitMap;
HitMap* initHitMap();
HitFuncPtr lookup(HitMap* m);
}
//封装
class CollisionMap{
public:
void ShipStation(GameObject&){
// the process for collision;
}
void StaionShip(GameObject& g1,GameObject& g2){
return ShipStation(g2,g1);
}
void addEntry(const string& class1,const string& class2, HitFuncPtr HitFunc,bool symmetric=true);
void remove(const string& class1,const string& class2);
HitMap* initHitMap();
void processCollision(GameObject& o1,GameObject& o2){
HitFuncPtr HitFunc= lookup(typeid(o1).name()),typeid(o2).name());
(*HitFunc)(o1,o2);
}
private:
HitFuncPtr lookup(HitMap* m);
typedef void (*HitFuncPtr)(GameObject&,GameObject&);
typedef map<pair<string,string>,HitFuncPtr> HitMap;
};
- 注意:
- 匿名namespace表明其内容为该文件私有(类似于static的功能)
- 对称的碰撞函数
- lookup里面对于map的定义使用static的智能指针,便于回收;
- 这里的CollisionMap的类应该是单例的
- 数组和指针类型的判断:查看定义式中表示的单个元素的意义,比如p[10]表示单个指针指向的内容,所以它是一个指针数组,如果是这样p,它则表示一个指向指针的指针,可以用以表示二维动态数组,(p)[10]此时指每一个指针都指向一个包含十个元素的数组;
杂项讨论
条款32:在未来时态下发展程序
- 好的软件对于变化应该有良好的适应能力,设计时应尽量使用用户思维
- 如果派生不会发生就应该从语法上阻止它,而不只是警告
- 如果copying和assignment对于某个class没有意义,及应该private
- 在不使用虚函数时尽量保持谨慎
- 努力让class的操作符和函数拥有自然的语法和语义
- 写出可移植性的代码
- 尽量采用封装性质,使你的实现细目private
- 使用namespaces和static对象或者函数
- 尽量避免设计出virtual base classes,因为这种类必须被其每一个derived class初始化
- 避免使用RTTI设计出if-then-else的繁琐语句
- 提供完整的classes,设计你的接口有利于共同的操作行为,阻止共同的错误
- 泛化代码
条款33:将非尾端类设计成抽象类
- 提供抽象类的一个理由:抽取出对于多个具体类(通常大于或者等于两个)有用的信息,可以将被抽取的抽象性封装成一个抽象类和继承自该抽象类的具体类(保证抽象性的具体实现),并对外隐藏某些接口(protected,既然是隐藏就不用使用virtual声明了,并且抽象类的纯虚函数一般选择析构函数)(避免派生类之间发生不必要的联系,比如异形赋值)。
可以理解此处的继承关系保存了封装,复用等特性,牺牲了多态的特性
- 设计抽象类的原则:概念本身有用,对于一个或者多个继承类有用,如果当前并不知道这种概念能不能泛化的运用于多个概念,则提前设计抽象类是没有意义的
- 当我们万不得已需要继承自一个具体类时(比如第三方库),需要注意刚开始出现异形赋值的问题和多态数组问题
将需要封装的接口放到protected里面,比如赋值运算符重载,可以避免多态数组的问题
- 一般性的原则:继承体系中的non-leaf类应该是抽象类。如果使用外界供应的程序库,你或许可以对此法则做一些变通;但是如果代码完全掌控在你手中,坚持这个原则,可以提升整个软件的可靠度、健壮度、精巧度、扩充度。
条款34:如何在同一个程序中结合c和c++
名称重整
- c++编译器会将函数名称重整,获得一个全局独一无二的名称,这为重载,多态提供了实现的可能,实则是对连接器的一个退让(一般意义上重载并不能被链接器兼容);
- 问题:在封闭的c++环境里面无需顾虑,但是当你包含一个c库,对cku中函数的调用被一如既往的翻译成重整后的名称,而此时连接器就会找不到库函数。
- 解决办法:要压抑重整,需要使用extern “C”指令包含调用函数的声明;
//几种实现语法:
extern "C" void myfunc();
extern "C"{
void myfunc1();
void myfunc2();
......
}
//被c和c++同时使用的语法,考虑
#ifdef _cplusplus
extern "C"{
#endif
....
#ifdef _cplusplus
}
#endif
- 不同的编译器重整方式也不一样
statics的初始化
- static class对象、全局对象、namespace内的对象、文件范围内的对象总是在main之前初始化,main之后销毁;
- 在main内static initialization,static destruction,所以尽量为程序编辑main程序
动态内存分配
- 因为new/delete ,malloc/free是严格一一匹配的,所以在遇到像strdup这样的函数时(不知道来自哪个库)时,就会遇到问题。所以编译器的不同也会带来移植性的问题,尽量少使用非标准平台库。
数据结构的兼容性
- 在c和c++之间对数据结构做双向交流,应该是安全的——前提是那些结构的定义式能够在c和c++中都能编译。而c++的struct加入非虚函数并不改变内存布局,所以仍然能满足兼容性要求
- 确定你的c++和c编译器产生兼容的目标文件
条款35: 让自己习惯于标准c++
- 支持c标准函数库
- 支持strings
- 支持国别(字符集、时间、货币等)
- IO
- 支持数值应用(complex)
- STL
- 注意:上述程序库基本都是建立在泛型的基础上的,包括string