Effective C++ 49,50
最后更新于:2022-04-01 15:50:05
49.熟悉标准库。
C++标准库很大。
首先标准库中函数很多,为了避免名字冲突,使用命名空间std。而之前的库函数都存放于< .h>中,现在成为伪标准库。而不能直接将这些头文件全部直接添加命名空间,标准委员会只能重新创建了不带.h的头文件。对于C中头文件采用同样的方法,但是每个名字前添加一个c,如C中的<string.h>变成了<cstring>。旧的c++头文件是官方反对使用的,但旧的c头文件不是,为了保持对C的兼容性。如 <string.h>是旧的C头文件,对应的是基于char*的字符串处理函数,<string>是包含了std的C++头文件,对应的是新的string类,而<cstring>是C头文件的std版本,但这里没有string类的旧c++版本,因为官方不推荐这样做。
第二,库中几乎都是模版。如iostream,操作字符流,流类实际就是类模版,在实例化流类的时候指定字符类型。即使是string,其实也是一个模版,类型参数限定了每个string类中的字符类型。string的类型声明是:
~~~
typedef basic_string<char, char_traits<char>, allocator<char> >
string;
~~~
这里的字符类型与之前iostream中的字符类型是一个意思,它不是指char,指的是一个流中的字符,这里字符类型确定其参数,字符类型指的是字符集,因为不同的字符集在实现的细节上不同,如特殊的文件结束字符,拷贝他们的数组的最有效方式等,这些特征在标准中被称为traits。然后还有string对象执行动态内存分配的方法,使用一个Allocator参数,而Allocator类型的对象被用来分配和释放string对象的内存,其是一个内存管理器。不要手动的声明标准库中的任何部分。
Iostream,和传统的Iostream相比,它已经被模版化,继承层次结构也进行了修改,增加了抛出异常的能力,支持string(通过stringstream)和国际化(通过locales)。新的Iostream可以将string和文件当作流,还可以对流的行为做更广泛的控制,包括缓存和初始化。
容器,标准库中提供了一下的高效实现,vector,list,queue,stack,deque,map,set和bitset。string是容器,对容器的任何操作都适用于string。标准库的实现是高效的。使用容器可以消除动态分配内存造成的内存泄漏。
算法,标准库中提供了大量的简易方法,称为algorithm,实际为函数模版,其中大多数适用于库中所有容器以及内建数组。算法将容器的内容作为序列,每个算法可以应用与一个容器中所有值对应的一个序列,或者一个子序列。标准算法有,for_each 为序列中的每个元素调用某个函数, find 在序列中查找包含某个值的第一个位置, count_if 计算序列中满足某个判定为真的元素的数量,equal 判断两个序列包含的元素的值是否完全相等,search 在一个序列中找出某个子序列的起始位置,copy 拷贝一个序列到另一个, unique 在序列中删除重复值, rotate 旋转序列中的值,sort 对序列中的值排序,等等。和容器操作一样,算法也有性能保证。
对国际化的支持。提供有助于开发出国际化软件的特性。支持国际化的最主要的构件是facets 和 locales 。facets描述的是对一种文化要处理哪些特性,包括排序规则(即某些地区字符集中的字符应该如何排序),日期和时间应该如何表示,数字和货币值应该如何表示,怎样将信息表示符映射成明确的语言信息,等等。locales 将多组facets捆绑在一起,facets是指前面说到的那些特性中的一个,而locales 表示多个facets组成的一个对于某个国家的规则,若一个locales表示美国人是如何解决前面几个问题的。
对于数字处理的支持。C++库中为复数类和专门针对数值编程而设计的特殊数组提供了模版。如valarray 类型的对象可以用来保存可以任意混叠的元素,
诊断支持,标准库支持三种报错方式:C的断言,错误号,例外 exception 。例外先派生出 logic_error 和 runtime_error ,然后再有这两个类派生出具体的错误类型,logic_error 表示软件中的逻辑错误,理论上可以通过更仔细的程序设计来防止。runtime_error 类型的例外为运行时才能发现的错误。
标准库中的容器和算法这部分一般被称为标准模版库 STL 。STL是标准库中最具创新的部分,它的体系结构具有扩展性,按照STL中的规范,可以进行很多扩展。
50.提高对C++的认识。
C++设计时的首要目标:与C的兼容性,效率,和传统开发工具及环境的兼容性,解决真实问题的可应用性(即面向对象)。
以上目标阐明了C++语言中大量的实现细节。如,为什么隐式生成的拷贝构造函数和赋值运算符要像现在这样工作,尤其是指针产生的浅拷贝问题?因为这是C对struct的拷贝和赋值的方式,要与C兼容。为什么析构函数不自动声明为virtual,为什么实现细节必须出现在类的定义中?因为不这样做会带来性能上的损失,效率很重要。为什么C++不能检测非局部静态对象间的初始化依赖关系?因为C++支持单独编译(即,分开编译源模块,然后将多个目标文件链接起来,形成可执行程序),依赖现有的链接器,不会程序数据打交道,所以c++编译器几乎不可能知道整个程序的一切情况。为什么C++不让程序员从一些繁杂事务如内存管理和低级指针操作中解脱出来?因为一些程序员需要这些处理能力,一个真正的程序员的需要自关重要。
Effective C++ 45-48
最后更新于:2022-04-01 15:50:03
45。弄清c++在幕后为你所写,所调用的函数。
如果设置一个空类,c++编译器会声明以下函数:拷贝构造函数,赋值运算符,析构函数,一对取地址运算符函数(const和非const)。而如果你没有声明任何构造函数的话,编译器会为你声明一个缺省构造函数。这些函数都是公有的。
编译器生成的缺省构造函数和析构函数实际上什么也不做,生成的析构函数一般是非虚构的,除非继承了一个具有虚析构函数的基类。缺省取地址符只是返回对象的地址,即return this。而拷贝构造函数和赋值运算符,对类的非静态数据成员进行“以成员为单位”逐一拷贝构造或赋值,也就是浅拷贝。
当类中有引用时,默认的拷贝函数无法实现,编译器会报错,有常量也是,有指针是,会发生浅拷贝但是运行上没有错误。对于含有指针,引用和const成员的类需要自己定义赋值运算符和复制构造函数。而如果将派生类中的赋值运算符或拷贝构造函数声明为private,编译器也会拒绝为这个派生类生成相应的赋值运算符和拷贝构造函数。
46.宁可编译和链接时出错,也不要在运行时出错。
当通过编译和链接后,只有极少数情况会让C++抛出异常,如内存耗尽,运行时错误和C++没什么关系。C++没有运行时检测,要尽量避免运行时错误。
对于运行时错误,在一个运行中没有错误,并不表示其就是正确的了,因为每次程序运行的状态都不一样。
而避免运行时错误的一般方法是对设计做一些小小的改动,就可以在编译期间消除可能产生的运行时错误。一般设计在程序中增加新的数据类型,以在编译时检测数据的安全性。
对于一个日期类,有构造函数:Date(int day,int month,int year);实现这个类面临的问题是对day和month进行合法性检测,如果不进行检测,由于其内部逻辑可能会导致一些运行时错误。一种简单的方法是使用枚举
~~~
enum Month {Jan = 1,Feb = 2,....,Dec =12};
~~~
而构造函数改为:
~~~
Date(int day,Month month,int year);
~~~
但是这样做没有多大好处,因为枚举类型不用初始化,即直接 Date d(1,Month m,2014),能通过编译,但是运行时出错。
即想免除运行时检查,又要保证足够的安全性,选择使用一个类来实现month。
~~~
class Month{
public:
static const Month Jan(){return 1;}//这里其实是调用隐式构造函数,其实返回值为 Month(1);
//....
static const Month Dec(){return 12;}//使用静态函数,返回一个常量,防止随意改动
int toInt() const
{return number;}
private:
Month (int n):number(n){}
const int number;
};
~~~
这里调用类的静态成员返回对应的Month,而构造函数隐藏,防止用户自己去创建新的month。但即使有了这样的类,用户还是可以指定一个非法的month,如下:
~~~
Month* m;
Data(1, *m ,2014);
~~~
消除所有的运行时检测是不切实际的。但将检查由运行时转移到编译或链接时一直值得努力的目标,这样做,会使程序更小,更快,更可靠。
47.确保非局部静态对象在使用前被初始化。
使用对象前一定要初始化。
非局部静态对象是指 : 定义在全局或名字命名空间内,或在一个类中被声明为static,或在一个文件范围内被定义为static。就是值全部的对象,去掉非静态 的局部变量 和函数内的静态变量。
当类依赖与这些非局部静态对象时,如在 一个文件中有一个全局对象theCountry, 在另外一个文件中有一个对象theCity,对city的初始化依赖与country的初始化。而程序的正确运行依赖于它们的初始化顺序。但确定非局部静态对象初始化的正确顺序很困难,在多个编译单元中确保每个这样的对象初始化是很困难的,尤其当程序变得更加复杂增加更多的这种非局部静态对象的情况下。
单一模式,将每个非局部静态对象转移到函数中,声明其为static,其次,让函数返回这个对象的引用。这样用户就可以通过函数调用来指明对象,即用函数内部的static对象来取代非局部静态对象。因为对于函数的静态对象什么时候被初始化,c++明确的指出了。这样的另一个好处是如果这个模拟非局部静态对象从没被调用,也就永远没有对象构造和销毁的开销。简单的例子:
~~~
class country{....};
country& theCountry(){
static country tc;//定义和初始化theCountry
return tc;//返回它的引用。
}
~~~
48.重视编译器警告。
一般程序员都会忽略编译器警告,毕竟没有出错。要理解编译器的各种警告的含义。书上举个例子:
~~~
class A{
public:
virtual void f() const{cout<<"fA";}
};
class B:public A{
public:
virtual void f(){cout<<"fB";}
};
~~~
书上说有编译器在这里会出一个 B::f() hides virtual A:::f()的错误,即A中声明的f函数并没有在B中重新定义,但是被B中新声明的非const的f函数给隐藏了。但是这样没有实现多态,对于使用声明为A的指针的B的对象指向f函数的话,会调用A中的f函数。但是我用的vs2012中并没有提示这条警告。
Effective C++ 43,44
最后更新于:2022-04-01 15:50:00
43.明智地使用多继承。
多继承带来了极大的复杂性。最基本的一条就是二义性。
当派生类为多继承时,其多个基类有同名的成员时,就会出现二义性。通常要明确其使用哪个成员的。显式地限制修饰成员不仅很笨拙,而且会带来限制。当显式地用一个类名来修饰一个虚函数时,函数就会被固定,而不再具有虚拟的特性。对于虚函数,若两个基类拥有一个同名同参的虚函数,当派生类没有重新定义虚函数时(可以只声明),直接调用这个同名函数会出二义性错误,需要指明其类。而当派生类中重新定义了这个函数,这是不可能的,因为一个类只允许有唯一同参同名的函数(其实不对,函数体声不声明const,还是不同的,一个用于正常对象,一个用于const对象)。而对于派生类中重新定义虚函数,其实是在派生类中重新创建了一个函数,在这里要重新定义两个虚函数是不行的,因为重定义一个函数就是在创建一个函数,而一个函数里不能有两个同名同参的函数。
当不修改虚函数时,只要用指明基类的方式去调用基类的函数即可,当需要重新定义一个虚函数时,即对于派生类只保留一个虚函数时,不用关心其保留哪个虚函数,只要正常的重新声明定义即可。
而当需要重新定义多个虚函数,且派生类要用到这多个虚函数时,一种所谓的巧妙的方法解决二义性,就是在存在二义性的两个基类下再进行派生,在这两个派生类中给其定义各自的新名字,而函数体为内联的调用基类的函数。而多重继承的派生类多重继承与这两个中间类,就将两个原本冲突的基类函数变成了两个不冲突的基类函数。仅仅为重新定义一个虚函数,而不得不引入新的类。
~~~
class A{
public:
virtual void fun(){cout<<"A"<<endl;}
};
class B{
public:
virtual void fun(){cout<<"B"<<endl;}
};
class AuxA:public A{
public:
virtual void Afun() = 0;
virtual void fun(){return Afun();}
};
class AuxB:public B{
public:
virtual void Bfun() = 0;
virtual void fun(){return Bfun();}
};
class C:public AuxA,public AuxB{
public:
virtual void Afun(){cout<<"A in C"<<endl;}
virtual void Bfun(){cout<<"B in C"<<endl;}
};
class D:public A,public B{
};
int main(){
D* d = new D();//对于正常的情况,
A* aaa =d;//当其使用基类的指针时,
aaa->fun();//就会调用基类的虚函数,而不会发生冲突
B* bbb = d;//完全没有体现到多态
bbb->fun();
//d->fun();//而直接调用会二义性。
C* c =new C();
//c->fun();//改名后原来的这个函数还是二义性的。
A* aa = c;
aa->fun();//输出 A in C
B* bb = c;//输出 B in C
bb->fun();
AuxA *a = c;
AuxB * b = c;
a->fun();//输出 A in C
b->fun();//输出 B in C
~~~
这样就同个改名而在派生类中重新定义了两个基类的同名函数。
而除了二义性,还经常碰到的问题就是菱形继承,也就是一个基类被继承多次,但是否应该保存多个拷贝的问题。一般来说都是只拥有一个这样的基类,即将其声明为虚基类。
但这样也是有问题的,首先程序开发这设计一个基类A派生了多个基类BC,但是在其定义BC时无法知道以后是否有人会多继承BC,而后人想要修改BC的定义使其虚继承于类A又是很难做到的,一般ABC都是只读的库函数,而D由库的用户开发。另一方面,如果A声明为BC的虚基类,这在大部分情况下会给用户带来空间和时间上的额外消耗。
而对于虚基类,若A为非虚基类,则D的对象在内存中的分配通常占用连续的内存单元,而若A为虚基类,会包含一个指针指向虚基类数据成员的函数单元。这里对于A派生BC,而D继承与B和C,则D的内存中有两个A,而如果是虚基类,D中有两个指向A的指针。
所以考虑这些,进行高效的类设计时,若涉及到MI 多继承,作为库的设计者就要具有超凡的远见。
向虚基类传递构造函数参数。对于单继承中,派生类在成员初始化列表中对基类传递参数,由于是单继承的,这些参数可以逐层的向上传递。但虚基类的构造函数就不同了,它的参数由继承结构中最底层的派生类的成员初始化列表来指定,如果有新类增加到继承结构中,可能要修改执行初始化的类。避免这个问题的办法是消除对虚基类传递构造函数参数的需要,最简单的就是java中的解决方法,即避免在虚基类中放入数据成员,java中的虚基类 接口禁止包含数据。
虚函数的优先度。当虚函数在多重继承中涉及到虚基类时,会有优先度的问题,仍然以ABCD为例,A中有虚函数void fun(),C中重定义了fun,但B和D中没有,调用D的指针fun时,若A不是虚基类,则是正常的情况,发生二义性错误。但是当A是虚基类时,就可以说C中重定义的fun的优先度高于最初的A中的定义也是B中的fun,则此时会无二义性的解析为调用 C::fun()函数。
当对于原 B类继承于A类,现今需要新加入一个新类C继承与A,但是你发现其与B类用许多相似的地方,但C又不是一个B,所以你决定让C 由B实现,便让C私有继承于B,同时继承A,同时修改了一下B中的虚函数,实现了多继承。而另一种做法是将BC的共同点放在一个新的类D中,改变继承结构,使D继承于A,而BC继承于D,这样就只有单继承了。表面上看来,多继承没有添加一个新的类,没有改变原有的继承结构,它只是在原有的类B的基础上增加了一些新的虚函数,这样看似增加了大量的功能,而只增加了一点点复杂性。但事实让,引入多继承就会带来许多麻烦。
MI是复杂的,但也是有用的,需要明智的去使用。
44.说你想说的,理解你想说的。
简单来说,理解面向对象构件在c++中的含义,而不是只去记忆c++的语言规则。对c++理解越深,越能清晰的考虑问题。
Effective C++ 38-42
最后更新于:2022-04-01 15:49:58
38.绝不要重新定义继承而来的缺省参数值。
重新定义函数缺省参数值意味着重新定义函数,而非虚函数不能重新定义,所以将就考虑不能重新定义虚函数的缺省参数值的原因:虚函数是动态绑定的而缺省参数值是静态绑定的。
静态类型是指程序中声明的类型,而动态类型是指实际对象的类型,举个栗子:
~~~
class A{
public:
virtual void fun(int a=0) const{cout<<a<<endl;}
};
class B:public A{
public:
virtual void fun(int a =2)const{cout<<a<<endl;}
};
int main(){
B* pb = new B();//pb的静态类型为 B*
A* pa = pb;//pa 的静态类型 为 A*,
//但是一个指针的静态类型不一定为其动态类型,如pa它的动态类型却是B类型的对象,这是由动态绑定实现的
pb->fun();
pa->fun();
~~~
虚函数是动态绑定的,但缺省参数值是静态绑定的,即对于pb,pa调用的虚函数,其使用的默认参数值都为静态绑定的,pa绑定的是 A类中的 a= 0,而pb绑定的是B类中的 a=2,两者不同,虽然函数都是调用B中动态绑定的虚函数,但是默认参数不同,输出结果也不同。
39.避免 ”向下转换“ 继承层次。
从一个基类指针到一个派生类指针称为向下转换,一般使用static_cast将基类指针强制转换为派生类指针。向下转换难看,容易导致错误,且难以理解,升级和维护。
向下转换的消除:使用虚函数调用来代替。第一种方法很简单理解,但是对于一些类不适用,如基类范围太大导致7有些派生类不应该有这个函数的功能,所以要将这些类的每个虚函数称为一个空操作,或者作为一个纯虚函数,并默认实现返回错误的操作。第二个方法是加强类型约束,使得指针的声明类型和你所知道的真的指针类型相同。即对于用到这些向下转换时,通过一些设定,滤去那些不拥有真正指针类型的指针,只留下需要进行操作的指针并以其真实的类型来调用其函数。
如果遇到必须要转换的情况,也不要使用static_cast,而是使用安全的用于多态的向下转换 dynamic_cast,当对一个指针使用dynamic_cast时,先尝试转换,如果成功返回一个合法的指针,否则返回空指针。
40.通过分层来体现”有一个“或”用..来实现“。
使某个类的对象成为另一个类的数据成员,从而实现将一个类构筑在另一个类之上,这个称为分层 Layering,也被称为构成,包含或嵌入。
对于有一个的概念很容易理解,对于一个 Person, 有 Name,Address,Phone等属性,但是不能说Person是一个Name。
对于"用..来实现”,其实就是调用其它类的对象作为类的主要数据成员,使用这个类的的函数接口来实现新的类中的功能与接口。
41.区分模版和继承。
根据依赖的类的用途来区分,如过依赖的类是类的行为,则为继承,如果依赖的类是类所操作的对象类型,则是模版。如,企鹅类依赖于鸟类,鸟类中的接口决定的是企鹅类中的行为,即两者是继承关系,而当实现一个集合时,集合类依赖与类T,是由于类T为集合类的进行操作的对象,这是模版。模版的实现会假设类可以调用T的构造析构赋值等函数,模版的特性是类模版的行为在任何地方都不依赖于T,行为不依赖于类型。
当对象的类型不影响类中函数的行为时,就使用模版来生成这样一组类。
当对象的类型影响类中函数的行为时,就用继承来得到这样一组类。
42.明智地使用私有继承。
私有继承不为 “是一个” 的关系。如果两个类之间的继承关系为私有继承,编译器一般不会将派生类对象转换为基类对象。私有继承时,基类的公有和protected 类型的成员都变成派生类的私有成员。私有继承意味着”用...实现“,私有继承纯粹是一种实现 技术。私有继承只是继承实现,而忽略接口。私有继承在 软件 ”设计“过程中毫无意义,只是在软件”实现“时才有用。
对于分层,也有 用...实现的含义,对于分层与私有继承,尽可能使用分层,必要时才使用私有继承。而建议使用私有继承在用到保护成员和有虚函数介入时。
对于一个基类,只作为其他类的实现来使用,使用分层作为其他类的私有成员,但其不是抽象类,导致其可能被其他人随意调用导致出错。这是就需要使用到私有继承,对于这种具有实现但是只能用于特定用途的基类,将其接口都改为protected类型,而正确使用它的类不用分层而使用私有继承来安全的使用基类。
对于模版,其为C++中最有用的组成部分之一,但是,实例化一个模版,就可能实例化实现这个模版的代码,如构成set<int> 和set<double>的代码是完全分开的两份代码,模版会导致代码膨胀。改进的方法:创建一个通用类,储存对象的void*指针。创建另一组类来保证类型安全使用通用类。以实现栈stack为例,先构建一个stack的通用类:
~~~
class GenericStack{
protected://实现类使用私有继承继承这个通用类,所以将接口保护起来
GenericStack();
~GenericStack();
void push(void* object);//使用指针
void* pop();
bool empty() const;
private:
struct StackNode{
void *data;
StackNode *next;
//在stack中使用指针来传递数据和保存数据,则节点析构时不用释放void指针指向的内存。
StackNode(void *newData,StackNode *nextNode)
:data(newData),next(nextNode){}
};
StackNode *top;
GenericStack(const GenericStack&);//防止拷贝和赋值
GenericStack& operator=(const GenericStack&);
};
~~~
而要实现stack的具体类通过私有继承这个类来实现功能,而且可以使用模版来完美的完成这个工作:
~~~
template <class T>
class Stack:private GenericStack{
public:
void push(T* objectPtr){GenericStack::push(objectPtr);}
T* pop(){return static_cast<T*>(GenericStack::pop());}
bool empty() const {return GenericStack::empty();}
};
~~~
这里使用私有继承,将通用类GenericStatck作为其实现,而其接口函数都是内联函数,几乎没有消耗,而且使用模版实现了类型安全的判断。对于创建任意类型的stack只要重新编译这个三个简单的内联函数即可,而不是通用类中复杂的实现,极大的降低了程序的开销。
这样的代码是令人惊叹的,近乎完美的。首先使用了模版,编译器会根据你的需要来自动生成所有的接口。因为使用模版,这些类是类型安全的,类型错误会在编译期间就能发现。因为GenericStack的成员函数是保护类型,用户不可能绕过接口类来调用它。因为这个模版的接口成员函数都被隐式的声明为内联,使用这些类时不会带来运行开销,生成代码就想用户直接使用GenericStack来编写的一样。因为GenericStack是使用void*指针,操作栈的代码只需要一份,而不同类型只要简单的编译类模版中的简单的内联函数就行。简而言之,这个设计使代码达到了最高的效率和最高的类型安全。
Effective C++ 35,36,37
最后更新于:2022-04-01 15:49:56
35.使公有继承体现 “是一个” 的含义。
共有继承意味着 “是一个”。如 class B:public A; 说明类型B的每一个对象都是一个类型A的对象,A比B具有更广泛的概念,而B表示一个更特定的概念。
在C++中任何一个参数为基类的函数都可以实际取一个派生类的对象,只有共有继承会如此,对于共有继承,如AB,若有两个函数 一个函数为 void fun1(A &a);另一个函数为void fun2(B& b);则对于AB的两个对象a,和b,对于 fun1(a)和fun2(b和fun1(b))都是正确的,fun2(a)是错误的。注意只有共有继承才有这个特性,对于私有继承会与此不同。而且这是说 B的对象 "是一个“ A的对象,但是B的数组并不是一个A的数组。
使用公有继承经常遇到的问题是对基类适用的规则并不适用于派生类,但公有继承又要求对基类对象适用的任何东西都适用于派生类对象,使用公有继承会导致一些错误的设计。
如对于企鹅与鸟,鸟是基类,其有嘴,翅膀等数据成员,还有一个飞的成员函数virtual void fly();一开始你认为鸟有一些属性,且鸟会飞,然后你有认为企鹅公有继承于鸟,即企鹅是一种鸟,但是问题出现了,企鹅不会飞。让企鹅直接公有继承于鸟类,这是一个错误的设计,所以你想去改进它,在依然使用公有继承的前提下。
1.世上有很多鸟不会飞,于是你将鸟类分成了两种,FlyingBird 和NoFlyingBird,分成会飞和不会飞两种鸟类,这两种鸟类都公有继承于鸟类,而企鹅公有继承于NoFlyingBird。
2.企鹅中依然有fly()这个函数,但是重新定义了这个fly函数,使之产生一个运行时错误,使企鹅是鸟,企鹅能飞,但是让企鹅飞的这个操作是错误的。这是一个运行时才能检测的错误。
当利用一些知识和常识设计一些类并使用公有继承时,但是公有继承却没那么有效,因为最关键的问题是基类中的规则要同样适用于派生类对象,而我们想要用继承实现的对象却有两者不同的规则。而对于这样的情况,一般要用”有一个“ 和”用。。。来实现“这两种关系来实现。
36.区分接口继承和实现继承。
首先,接口是放在public中给外部调用的,而实现是隐藏在private中的内部逻辑。对于类的继承,有时希望派生类只继承成员函数的接口,有时派生类同时继承函数的接口和实现,且允许派生类改写实现,有时派生类同时继承类的接口与实现,但是不允许修改任何东西。
纯虚函数必须要在具体实现类中重新声明,它们在抽象类中往往没有定义,定义纯虚函数的目的在于使派生类仅仅 继承函数的接口,也就是第一种情况,这种情况很容易理解。但是纯虚函数其实是可以提供定义的。
对于第二种和第三种情况,继承函数的接口和实现,一般使用虚函数来实现。而需要改进的地方是,对于一个基类中的虚函数,其有一定的实现,而派生类可以继承这样的接口和实现,既可以直接继承基类中这个接口,也可以重写这个接口的实现。这样很科学,但是又要一个问题,当一个新的派生类继承这个基类时,由于这个类中使用虚函数做接口,导致新的程序员忘记了重新声明这个虚函数并给予新的实现逻辑而去错误的使用虚函数中的默认逻辑而造成了错误。为了提供更安全的基类,使用纯虚函数做接口,让纯虚函数有自己缺省实现,在派生类继承时,直接调用基类纯虚函数的实现:
~~~
class A{
public:
virtual void fun() const = 0;
};
void A::fun() const{
cout<<"Class A"<<endl;
}
class B:public A{
public:
virtual void fun() const;
};
void B::fun() const{
A::fun();
}
~~~
如上所示,使用一个纯虚函数,但是带有缺省实现,而派生类继承时就必须重新声明这个纯虚函数,而对于要调用基类的缺省实现时,除了上面直接调用基类的这个纯虚函数外,还可以通过在基类中的protected中设置一个默认的实现函数,如 void defaultFun() const;而派生类会继承这个默认实现,然后在派生类的重新定义的虚函数中调用这个默认的实现函数即可。
这个情况其实就是第二种情况,继承函数的接口和实现,且能够修改实现。一般使用虚函数,但是使用带默认操作的纯虚函数会更加安全。安全是一个很重要的问题,如果不考虑安全性,很多在Effective C++这本书中讨论的问题都是没有意义的,因为如果你明白之前程序的设定,就知道哪些事情该做,哪些事情不该做,就不会去犯一些错误,但是对于一个程序的开发,不是有一个人完成的。当你理解自己的设定时,别人却不知道,维护你代码的人随意的做一些他们认为应该可以做到的安全的事,却由于你之前考虑的不周全而使这些行为极度不安全。所以要认真考虑安全性的问题,写出尽可能完美安全的代码。
对于第三种情况,声明非虚函数,目的在于使派生类继承函数的接口和强制性实现,又由于不应该在派生类中重新声明和定义基类的非虚函数,所以不会修改非虚函数的实现的。
所以,要理解纯虚函数,简单虚函数和非虚函数声明和功能上的区别。不用担心虚函数的效率问题,因为这真的是小问题,所有基类都应该虚函数。一些函数不应该在派生类中重新定义就要将其定义为非虚函数。
37.决不要重新定义继承而来的非虚函数。
首先,对于重新定义继承的非虚函数,称为对这个函数的隐藏,这是一种不常用的东西,正是因为有这个设定,绝不重新定义继承而来的非虚函数。
这样做的原因也是很容易理解的,也是多态的优点:
~~~
class A{
public:
void fun() const{
cout<<"Class A"<<endl;
}
};
class B:public A{
public:
void fun() const{
cout<<"Class B"<<endl;
}
};
int main(){
B* b = new B();
A* a = b;
b->fun();
a->fun();
~~~
对于以上代码,对同一个对象,也就是b指向的对象,当将其转换为基类指针后,由于其为静态绑定的,其所指向的函数不同,获得了不同的结果,而多态时动态绑定,指向的函数通过虚指针指向相同的地址。
结论是,对于类B的对象,其重新定义的函数fun()被调用时,其行为是不确定的,而决定因素与对象本身没有关系,而取决于指向它的指针的声明类型,引用也会和指针表现出这样的异常行为,这样的行为是不合理的。
而从理论上来考虑,对于公有继承意味着 ”是一个“,对于B中重新定义了A中的实现后,B就不”是一个“ A了。
Effective C++ 34
最后更新于:2022-04-01 15:49:54
34.将文件间的编译依赖性降到最低。
对于一个大型程序,其结构是错综复杂的,当你对一个类进行一些改动时,修改的不是接口,而是类的实现,即只是一些细节部分,但重新生成程序时,所有用到这个类的的文件都要重新编译。这里题目指的是这个意思。但实际上,我在vs2012实践了一下,对于类B与类A相关联,类B的实现依赖于类A,若类A的实现发生了改变,并不会影响B,即生成时,编译器只会去重新编译A,而对于依赖于A的用户程序,并不会像其所说那样全部重新编译。好吧,我这里总算是明白其所说的修改其实现的意思了。
修改类的实现: 类的接口只是类中提供给外部的函数, 而类的实现是指类实现接口函数所需要的内部逻辑和数据结构,如一些私有的函数,以及一些私有的成员数据。修改这些类实现的,对于实现函数的修改就必须修改函数的声明,而数据成员的修改就是数据成员的类型以及数量的修改。当进行这些修改时,就必定会导致调用这个类的用户程序都要重新编译。
一般的解决方法是将实现与接口分开,即对于本来使用的一个类,将其转换为两个类,一个类为接口类,供用户程序调用,一个类为实现类,有具体的实现逻辑以及实现所需的数据成员,且接口类能够指向对应的实现类,对于实现逻辑的更改不会影响用户程序,因为用户程序只与接口类连接,而隐藏了实现逻辑修改造成的影响,只有当接口改变时,才需要重新编译。分离的关键是,对类定义的依赖 被 对类声明的依赖取代,降低编译依赖性,将 提供类定义 即#include 指令 的任务由原来的函数声明头文件转交给包含函数调用的用户文件。
即不在头文件中包含其他头文件,除非缺少它们就不能编译,而一个一个地声明所需要的类,让使用这个头文件的用户自己通过include去包含这些头文件,以使用户代码通过编译。
实现这种接口与实现分离,在c++中一般有两种方法,一种是 将一个对象的实现隐藏在指针的背后,即用一个指针指向某个不确定的实现。这样的类称为句柄类或信封类。而指向的实现类称为 主体类或者信件类。句柄类,即接口只是将所有函数调用转移到对应的主体类中,有主题类也就是实现类来真正完成工作。接口中要将原来的实现需要的数据成员转换为函数,而去调用实现类中的数据成员 来实现功能,即接口中使用函数来实现对实现类中的数据成员实现传递和返回。
假如简单实现两个类,A ,B,C,A中放一个int,b中放一个doubel,C中放两者之和,写出代码如下:
classA.h:
~~~
#pragma once
class ClassA{
public:
public:
int a;
ClassA(int x){a = x;}
ClassA(){a = 0;}
int getA() const{return a;};
};
~~~
ClassB.h:
~~~
class ClassB{
public:
double b;
double getB() const{return b;}
ClassB(double x){b = x;}
ClassB(){b = 0;}
};
~~~
ClassC.h,即接口:
~~~
//这个头文件就是所谓的接口,如此将接口与实现分离后,只有修改接口时,才会导致使用该接口的用户程序重新编译
class ClassA;//只声明,在接口中只要知道有这些类,而在实现中才去include这些头文件
class ClassB;
class ClassCImpl;
class ClassC{
public:
ClassC(const ClassA& xa,const ClassB& xb);
virtual ~ClassC();
int getA() const;//函数来返回实现类中的数据成员
double getB() const;
double getC() const;
private:
ClassCImpl *impl;//使用指针来指向实现类
//int aaa;//在接口中任意进行修改,就要重新编译其与其用户程序
};
~~~
ClassC.cpp,接口的函数,调用 实现类中的函数进行返回。
~~~
//这里也是对于接口的实现,修改这里的数据,不会导致其他程序的重新编译
#include "ClassC.h"//这是接口ClassC的函数具体实现
#include "ClassCImpl.h"//要包含实现类的定义,且实现类中与ClassC中有一样的成员函数
ClassC::ClassC(const ClassA& xa,const ClassB& xb){
impl = new ClassCImpl(xa,xb);
}
ClassC::~ClassC(){
delete impl;
}
int ClassC::getA() const{
return impl->getA();
}
double ClassC::getB() const{
return impl->getB();
}
double ClassC::getC() const{
return impl->getC();
}
~~~
ClassCImpl ,实现类的定义:
~~~
#include "ClassA.h"
#include "ClassB.h"
class ClassCImpl{
public:
ClassCImpl(const ClassA& xa,const ClassB& xb);
int getA() const;//函数实现接口中函数
double getB() const;
double getC() const;
private:
ClassA A;
ClassB B;
ClassB C;
};
~~~
ClassCImpl.cpp,实现类的简单的操作:
~~~
#include "ClassCImpl.h"//要包含实现类的定义,且实现类中与ClassC中有一样的成员函数
ClassCImpl::ClassCImpl(const ClassA& xa,const ClassB& xb){
A = xa;
B = xb;
C = (B.getB() + A.getA());
}
int ClassCImpl::getA() const{
return A.getA();
}
double ClassCImpl::getB() const{
return B.getB();
}
double ClassCImpl::getC() const{
return C.getB();
}
~~~
这样就实现了接口与实现的分离,在ClassC中定义接口,在接口固定的情况下,在接口实现类ClassCImpl中进行任意的修改,编译器都只会重新编译实现类,而不会全部重新编译。这是使用句柄类实现的接口与实现分离。
另外一种方法成为协议类,即是这个类成为特殊类型的抽象基类。协议类只是为派生类确定接口,它没有数据成员,没有构造函数,有一个虚析构函数,有一些纯虚函数,这些纯虚函数组成了接口。
协议类的用户通过一个类似构造函数的的函数来创建新的对象,而这个构造函数所在的类就是隐藏在后的派生类。这种函数一般称为工厂函数,返回一个指针,指向支持协议类接口的派生类的动态分配对象。这个工厂函数与协议类解密相连,所以一般将它声明为协议类的静态成员。若重新声明一个ClassD,完成之前的功能,,但是为一个协议类,有:
ClassD.h:
~~~
//这个为协议类
class ClassA;//只声明,在接口中只要知道有这些类,而在实现中才去include这些头文件
class ClassB;
class ClassD{
public:
virtual ~ClassD(){}
virtual int getA() const = 0;//函数来返回实现类中的数据成员
virtual double getB() const = 0;
virtual double getD() const = 0;
static ClassD* makeClassD(const ClassA& xa,const ClassB& xb);//这里使用静态成员来返回
};
~~~
再写一个派生类来实现CLassD的功能,RealClassD.h:
~~~
#include "ClassA.h"
#include "ClassB.h"
#include "ClassD.h"
class RealClassD:public ClassD{
public:
RealClassD(const ClassA& xa,const ClassB& xb):A(xa),B(xb),D(B.getB() + A.getA()){}
virtual ~RealClassD(){}
int getA() const;
double getB() const ;
double getD() const;
private:
ClassA A;
ClassB B;
ClassB D;
};
~~~
而在这个派生类定义中,顺带实现ClassD中的返回指针的makeClassD的函数。这里:先从协议类中继承接口规范,然后在实现中实现接口中的函数。
~~~
#include "RealClassD.h"
int RealClassD::getA() const{
return A.getA();
}
double RealClassD::getB() const{
return B.getB();
};
double RealClassD::getD() const{
return D.getB();
}
ClassD* ClassD::makeClassD(const ClassA& xa,const ClassB& xb){
return new RealClassD(xa,xb);
}
~~~
而在需要使用的地方,如此调用这个函数来指向需要的接口:
~~~
ClassD* dd = ClassD::makeClassD(a,b);
cout<<dd->getD()<<endl;
~~~
dd的指针动态绑定到返回的派生类对象,而在派生类中修改其实现的成员只要重新编译派生类的cpp就行了。
句柄类和协议类分离了接口与实现,从而降低了文件间的依赖性,当一定程度上有时间和空间上的消耗。对于一个程序转变为产品时,要用具体的类来取代句柄类和协议类。
Effective C++ 29-33
最后更新于:2022-04-01 15:49:51
29.避免返回内部数据的句柄。
即使声明一个类的对象为const,不能进行修改,在获得其数据的句柄也就是地址的情况下,还是可以强行修改的。
~~~
class A{
public:
int n;
A(int x):n(x){}
operator int*() const;
};
inline A::operator int*()const{
return const_cast<int*>(&n);
}
int main(){
const A a(1);
A& b = const_cast<A&>(a);
b.n = 2;
cout<<a.n;
~~~
使用const_cast, 再给常对象起一个别名就可以修改这个 本来不能修改的常量对象了。这种方法是无法避免的,常对象注定只是表面上的,最终还是可以修改的,因为人们可以通过各种方式获得其内存,然后强制修改内存上的内容。
不考虑这种残忍的方法,在重载int* 操作符时,由于函数体是const,n就成了 const int n,所以&n获得的是一个const int*,而返回值应该是一个int*,不是常量指针,所以又使用了const_cast,返回了一个指针,这个情况不科学,换另外一种情况A内保存了一个指针 data,指向储存的数据,对于const,表示指针是常量不能改变,但指针指向的内容可以改变,即 data为 int * const data ,则data可以作为int * 当作返回值传递给调用者,而调用者即可以随意修改data指向的私有数据。
则对于const对象的的 T * 操作符,如果选择申请一个新的内存来存放数据,并返回这块新数据的指针的话,速度较慢,且内存容易泄漏。而最好的方法应该是使这个指向const对象返回的指针为const指针,这样即安全有快速:
~~~
inline A::operator int *const() const{
return data;
}
~~~
指针并不是返回内部数据句柄的唯一途径,引用也会。解决方法一样,对于const对象就应该返回const的引用,对与普通对象才返回普通的引用。
而并不是只有const成员函数需要担心返回句柄的问题,对于非const成员,必须注意,句柄的合法性失效的时间与它所对应的对象完全相同。
30.避免这样的成员函数:其返回值是指向成员的非const指针或引用,但成员的访问级比这个函数低。
这是数据封装所必须的,随意返回被封装的数据的句柄就给了类外部随意修改对象的数据成员的能力,这样是不应该的。
但这种 错误很常见,因为程序员喜欢用引用来传递 来提高效率。传递指针也是如此。
类中的成员函数指针:
~~~
typedef void (A::*AmFun)();//AmFun为一个指向A中无参无返回值的函数指针
~~~
这里不能使用 typedef void (* fun)() 来指向类A中这个无参无返回值的函数,因为两者类型是不同的,成员函数指针的类型是要有类名称作为前缀的。
也要避免使用成员函数指针将类内封装的函数传递出去。
31.千万不要返回局部对象的引用,也不要返回函数内部new初始化的指针的引用。
局部对象在离开其作用域后就会被系统销毁,而new初始化的指针 是堆中内存,要么在函数的最后内存被释放了,要么没有释放导致内存泄漏。这是比较简单易懂的道理。
前者容易理解,对于后者,有些人说那我们每次调用函数后都规定必须释放这些内存呗,但这显然是不正确的,如果 operator + 返回的是new出内存的指针,对于只进行一次的操作很容易记住要delete指针,但对于多次操作: a+b+c+d+e+f+f 呢,要如何记录并释放这些内存?
32.尽可能的推迟变量的定义。
尽管c中要求将所有的声明都放在前面。但c++中不这么做,目的是减少消耗,定义变量就要调用其构造函数和析构函数,如果这个变量最终未用到,就会造成资源的浪费。
尽可能的减少消耗,如使用复制构造函数,而不是先缺省构造,再赋值。
33.明智的使用内联。
函数有压栈出栈的消耗,宏不安全可靠,而内联函数就非常好。内联函数的优点不只如此。为了处理那些没有函数调用的代码,编译器优化程序进行了专门的设计,也会对内联函数进行一定的优化。
内联函数的代价。增加整个目标代码的体积,程序体积大,计算机内存有限的话,即使有虚拟内存,但程序运行时还是会浪费许多时间在页面调度中,即引发抖动,过多的内联还会降低指令高速缓存的命中率。
内联 inline指令是对编译器的提示,如register一样,事实上大多数编译器会拒绝内联复杂的函数,如包含循环和递归的函数。而且即使最简单的虚函数,编译器也无法内联,其还是会将其放在内存中,并使用虚表中指针指向虚函数。
内联函数一般都放在头文件中,被外联的内联函数会造成的一种错误:如果两个cpp共享同一个头文件,这个头文件中有 inline fun(),如果内联正常,则一切正常,内联代码直接插入在调用的地方,如果内联失败,则在对应cpp中要加载并定义fun函数,而导致两个cpp中包含两个同样的fun函数,当两个目标文件链接时,编译器会为程序中有两个fun函数而报错。旧标准中的解决方法是对于未内联的内联函数,编译器会把它当作声明为static的函数处理,但这样在每个文件中都产生开销。
当程序中要获得一个内联函数的地址时,编译器还要为此生成一个函数体,在旧的规则中每个取内联函数地址的被编译单元会各自生成内联函数的静态拷贝,而新规则下,只会生成一个唯一的内联函数的外部拷贝。
编译器有时会生成构造函数和析构函数的外部拷贝,通过获得这些指针来方便的构造和析构类的对象数组。对于在类内定义的函数都是内联函数。但是对于一个类的构造函数,其并不是只有你所编写的构造函数中的内容,它在编译时会被加入一些代码,如检测是在堆中还是栈中创建对象,对类中成员进行初始化,调用基类的构造函数对基类进行初始化等。这样会使构造函数实际的内容比你想象中还要多,导致无法内联,析构函数也一样。
程序员必须预先估计声明内联函数带来的方面影响。如一个程序库中声明一个内联函数,而对这个内联函数进行升级时,要将所有使用该程序库的用户程序重新编译,而如果这个函数不是内联函数,只要重新链接即可。而如果这个函数的程序库是动态链接的,程序库的修改对用户来说完全是透明的。
内联函数中的静态对象常常出现违违反直觉的行为,如果函数中包含静态对象,要避免将它声明为内联函数。
大多数调试器遇上内联函数无能为力,无法在一个不存在的函数里设置断点。
慎重的使用内联。
Effective C++ 26,27,28
最后更新于:2022-04-01 15:49:49
26.当心潜在的二义性。
一些潜在的二义性的例子:
~~~
class A{
public:
A(const B&);
};
class B{
public:
operator A() const;
};
void f(const A&);
~~~
一般情况下,这样写不会出错,但当调用f函数传入一个 B的对象b时,就会发生二义性错误,b既可以通过A的构造函数获得一个A的对象,也可以通过B的类型转换运算符来将b变成一个A的对象再使用,而编译器不知道应该使用哪种方法。这是一个潜在的二义性,其一般情况下正常无误。
还有更简单一点的二义性,当两个函数重载时,如 f(int ) f(char);,而传入一个double类型的参数d时,就会发生二义性,编译器不知道应该将d转换为char类型还是int类型。
多继承中充满了二义性的可能。如多个基类具有同名的函数或数据成员,就必须指明成员的基类来消除二义性,即使一些成员在其所在的基类中是私有的,即派生类无法访问,但这还是有二义性的错误。为什么消除 对类成员的引用产生的二义性时不考虑访问权限:改变类成员的访问权限不应该改变程序的含义,即当类定义好之后,改变成员的访问属性会导致一些访问出错或这依旧正常,这是正确的,而如果改变访问属性改变了程序的含义(如A 类B类派生 出C类,c中一个函数调用两个基类中同名的成员,如果 消除二义性时考虑了访问权限, 当A中成员为公有,b中成员为私有时,c中函数调用a中成员,而改变了访问权限,a中为私有,b中为共有,就会导致c中函数调用b中成员,改变了程序的含义,但是对于程序猿,却一无所知。如果保护考虑的话,会出现访问出错,会提示程序员进行修改) 所以最好还是显示的消除二义性。
27.如果不想使用隐式生成的函数就要显式的禁止它。
对于这些不想使用的函数禁止它,原因很简单,为了之后更容易更新和维护,当你现在在写这个类时,你没有禁止这些不应该使用的函数,你记得这些事情,所以你自己不会犯这些错误,但当过了很长一段时间,或者其他人接替了你的工作来对你的代码进行升级和维护时,这个没有禁止的功能就很有可能出现在一些地方让你措手不及。所以最好还是禁止这些不应该使用的函数。
对于类中一些函数,编译器会隐式的自动生成,如缺省的无参构造函数,赋值 =,复制构造函数等,要禁止这些函数,一个简单的方法就是声明这些函数为private ,显示声明防止编译器自动生成,而private防止其他人调用。
但这样还是不安全,成员函数和 友元函数还是可以调用这些私有函数。这样就只去声明这些函数,而不去定义函数体,当其他函数调用时,编译器就会在链接时报错。
28.划分全局名字空间。
使用命名空间来解决命名冲突。对命名空间之前有过一些研究:[c++命名空间](http://blog.csdn.net/luo_xianming/article/details/22818957),命名空间是可以嵌套的。
命名空间的使用方式有三种, using namespace xxx;之后所有的xxx命名空间的成员都可以直接访问。 using xxx::成员 ,对于xxx命名空间中的这个成员可以直接使用。xxx::成员,在一个地方使用xxx命名空间中的这个成员。
可以使用struct来近似实现namespace , 先创建一个结构用以保存全局符号名,然后将这些全局符号名作为静态成员放入结构中。访问时加上struct的名作为前缀 ,与命名空间类似。
~~~
struct sdm{
static const double VERSION ;
class A{
public:
void f(){cout<<"SDM"<<endl;}
};
};
const double sdm::VERSION = 1.0;
class A{
public:
void f(){cout<<"GLOBEL"<<endl;}
};
int main(){
cout<<sdm::VERSION;
sdm::A sdma;
A a;
~~~
对于类型名,可以显式地去掉空间引用:
~~~
typedef sdm::A A;
~~~
对于函数,一般返回一个函数的常指针:
~~~
sdm::A& (* const getA)()= sdm::getA;
sdm::A& (& getA)() = sdm::getA;//返回函数的引用
~~~
Effective C++ 24,25
最后更新于:2022-04-01 15:49:47
24.在函数重载和设定参数缺省值间要慎重选择。
获得一种类型的数据的最小值或最大值,对于c中,一般使用在<linits.h>中定义的各种宏如INT_MIN 来进行表示,但是这样无法进行泛型编程,即对应如何一种类型T返回对应类型的最小或最大值。而在c++中一般如此获得
~~~
std::numeric_limits<T>::min()
~~~
c++在<limits>中定义了类模版numeric_limits,用来返回对应类型的最小最大值,这是一个很有用的东西。
然后继续讨论函数重载与参数缺省值,如以下情况:
~~~
int fun(){
return 1;
}
int fun(int a){
return a;
}
int fun(int a=1,int b = 0){
return a+b;
}
~~~
对于第3个函数,当没有参数和只有一个参数时会与前两个函数冲突,但是对于第三个函数,即有默认值的情况下,其能直接具有全部三个函数的功能,使用默认值的函数其效果更好且功能更多。
但是有时找不到一个好的缺省值。当对5个以内的值求和时,可以设每个参数的默认值为0,但是当对5个以内的值进行求平均数时,要获得传入参数的个数,无法通过函数的参数来实现,所以只能重载5个函数,即只有一个,两个,3,4,5个函数的所有情况。
另一种必须使用重载函数的情况是:想完成一项特殊的任务,但算法取决于给定的输入值。就是说函数由于输入参数不同进行操作不同的这类函数要重载,如类的构造函数。
25.避免对指针和数字类型的重载。
如函数 void f(int x);和void f(int * p);这两个函数的,重载是会出错,简单来说对于实参为0,即0是什么?0即是指针有是int,但事实上在编译器中运行
~~~
void f(int* x){
cout<<"int_ptr"<<endl;
}
void f(int x){
cout<<"int"<<endl;
}
int main(){
f(NULL);
~~~
结果为 输出 int ,即0是一个int。人们认为一个调用具有多义性时,编译器却不这么干。
NULL的类型是就是int,要使其调用 f(int*)这个函数,就必须如此做
~~~
f(static_cast<int*>(NULL));
~~~
但是如果将NULL定义为UL,即无符号整数
~~~
#define NULL 0UL
~~~
在调用f(NULL)又会报错,重载不明确,NULL即可以是int也可以是int*
但是如果又将f(int)函数改为
~~~
void f(unsigned long x);
~~~
又会正确,因为NULL是unsigned long,而f函数中参数也是。
而如果我们需要一个任何地方都可以使用的任意类型的NULL指针,就必须设计一个产生NULL指针对象的类:
~~~
#ifdef NULL
#undef NULL
#endif
class NullClass{
public:
template<class T> //模版
operator T*()const{return 0;}//返回一个T*的null指针。
};
const NullClass NULL;//NULL的常量
~~~
然后再次使用 f(NULL)的时候,就会调用隐式的类型转换,获得一个对象T类型的null指针。但是这样还不够,改进:
首先我们只需要一个NullClass对象,所以给这个类一个名字是没有必要的,定义一个匿名类并使NULL成为这种类型。
其次,我们想让NULL可以转换为如何类型的指针,那就要能够处理成员指针(指 指向类中函数的指针),要再定义一个成员模版,将类C与所有类型T转换为类型 T C::*。
最后要防止用户取NULL的地址,NULL要表现的像指针一样,但其值不是指向真正的0,所以要对用户隐藏。
改进后如下:
~~~
class {
public:
template<class T> //模版
operator T*()const{return 0;}//返回一个T*的null指针。
template<class C,class T>
operator T C::*() const{return 0;}//转换任意类型的null成员指针
private:
void operator&() const ;//隐藏NULL的地址
}NULL; //只有一个名字为NULL的对象
~~~
Effective C++ 18-23
最后更新于:2022-04-01 15:49:45
18.争取使类的接口完整并且最小。
类的用户接口是指使用这个类的程序员所能访问得到的接口,典型的接口里只有函数存在,封装好类的数据成员。
完整是指接口中包含所有 合理的操作的函数。最小是指函数尽可能少且功能不重复。
接口中的函数要少的原因:接口中函数越多,越让其他人难以理解,函数多了会让人混淆。函数多了难以维护,更难维护与升级。长的类定义会导致长的头文件,浪费大量编译时间。
19.分清成员函数,非成员函数和友元函数
成员函数可以是虚函数,即可以实现动态绑定,而非成员函数不行。
关于类一些操作符重载的思考
如 operator+ 进行加法的重载,假设在有理数 Rational 类中有成员函数 +法的操作,则对于一下三个运算:
~~~
result = R1 + R2;
result = R1 + 2;
result = 2 + R1;
~~~
先补充一个前提,Rational的构造函数的声明为Rational(int numerator = 0,int denominator = 1)。则对于第一行的运算,就是为 R1.operator+(R2),即调用R1中的成员函数,而对于第二行的运算也是正确的,这里将2隐式转换称了Rational类(编译器会对每个函数的每个参数执行隐式类型转换),在没有声明explicit的情况下,即这行编译器是如此解释的 R1.operator+( Rational(2))。而对于第三行的运算是出错的,因为编译器是如此理解这行的操作 2.operator+(R1),在对于整数2找不到对应的operator+函数后,这里并不会隐式将2转换为有理数。编译器又去寻找非成员的全局的operator+( 2,R1),结果也没有找到,最后搜索失败。
而当将operator+ 作为友元函数时,对于第三行,编译器就如此理解 operator+( 2,R1),2 作为函数的参数,编译器也会尝试隐式转换为有理数类。
但是尽量不要使用友元函数,而使用全局函数,最好能够调用类中的共有接口来实现操作符运算。因为使用友元函数会带来很多麻烦,简单点来说,类是为了封装的,友元会降低类的封装性。
20.避免public接口出现数据成员。
简单来说,public中只有函数,对数据成员的读写只通过函数来实现,这样,可以通过设置函数来实现数据成员的 不可读写,只读,只写,可读写。
复杂一点,实现功能分离 functional abstraction ,如果使用函数来实现对数据成员的访问,当更改对数据成员的设定时,只要在这些函数中进行一些修改,添加一些代码,就可以实现效果,且对用户隐藏这些细节。
21.尽可能使用const。
const除了声明常量外,在类中常用于使实参为常量即声明参数为const,以及使函数不改变类内成员的值即声明函数为const,以及让函数的返回值为常量。
让函数的返回值为常量,减少用户出错的几率。如对operator+的重载,如果返回值不是一个常量,对于这个式子 (a+b) = c,就容易阻止这种无聊的错误。
声明函数为const,其实是指函数重载,为const对象调用,而若一个函数只有const的版本,就会发生将普通对象转换为 const对象,而对于const函数中对const的对象的操作,不能改变const对象中一般数据成员的值 即const函数不能改变对象的成员的值。 但是const函数中对数据成员不进行改变又不是绝对的,如果有指针的数据成员,在函数中不改变指针,但可以去改变指针指向的内容的值, 这样的函数也可以通过编译器的检测,但这和我们的为const的定义相违背。另一种conceptual constness 的观点认为const成员函数可以修改它所在对象的一些数据,在用户不发觉的情况下,使用关键字mutable。或则可以强制一点,使用const_cast声明一个局部的this指针,然后强制修改这个指针指向的内容的值,这样是可以通过的。
22.尽量传引用,不要传值。
对于传值,函数的形参是通过实参的拷贝来初始化,函数的调用者是函数返回值的拷贝。通过值来传递一个对象,具体含义是由这个对象的类的拷贝构造函数定义的。而这样既浪费空间,又浪费时间。
使用传引用,没有新的对象创建,传递的一直都是引用,则没有构造函数和析构函数的调用。
传引用的另一个优点是 避免了 切割问题 slicing problem。 当一个派生类的对象被当作基类的对象进行传递时,派生类对象会由 基类的构造函数使用 派生类对象转换得到的基类对象 作为参数 赋值一个新的 完整的基类对象,其派生类中所具有的行为特性会被切割掉,只是一个简单的基类对象。而如果使用了传引用,传给函数中使用的依然是这个对象本身,而这个对象有多态,有派生类的功能。
对于传引用。一般都是使用指针来实现的,而对于一些较小的对象,如int,传值其实会比传引用更加高效。
23.必须返回一个对象时,不要试图返回一个引用。
传引用的一个严重的错误,传递一个并不存在的对象的引用。举例,对于操作符重载中的 operator= , 其返回值为 const T,返回对象的原因:
如果返回为引用,则这个别名的原名是谁,在哪里?实现对于+ ,其返回的对象不是两个参数,而是在函数中新建的对象,而这个对象不能在栈中,因为其在函数结束后就会被释放,而这个别名指向一块已经释放的内存这是不正确的。这个对象也不能在堆中,对象不能是动态建立的,因为在调用+后,并没有储存这个别名并释放这个别名所在的内存,这是内存泄漏。所以,不能返回一个引用,所以必须返回一个对象,即使这个对象返回到调用处要 先在函数中进行一次构造和析构,再在调用出进行一次构造,在使用后又要调用一次析构函数,即使花费不低,但只能使用返回对象。
Effective C++ 11-17
最后更新于:2022-04-01 15:49:42
11.为需要动态分配内存的类声明一个拷贝构造函数和一个赋值操作符。
显然,由于动态内存分配,绝对会有深浅拷贝的问题,要重写拷贝构造函数,使其为深拷贝,才能实现真正意义上的拷贝。这是我理解的关于要声明拷贝构造函数的原因。
而对于赋值操作符,类似的道理。
~~~
A b = a;
b = a;
~~~
对于上述两种形式,上面调用的是复制构造函数,而下面才是 赋值操作符=。赋值与复制很相似,缺省的操作都是将类的全部成员进行复制。
深拷贝主要的操作很简单,对于指针,动态申请一块内存来存放指针指向的数据,每个指针都指向自己的一块内存,而不是其他人的。
12.尽量使用初始化而不要在构造函数里赋值。
即尽量使用成员初始化列表,而不是使用赋值的方法。
首先对于const成员和引用,只能使用初始化列表来初始化。其次初始化列表效率更高,因为对象的创建分为两步:数据成员的初始化和执行被调用构造函数体内的动作。即使用赋值之前,先进行了数据成员的初始化,然后才是赋值。所以使用初始化列表效率更高。
但当有大量的固定类型的数据成员要在每个构造函数中以相同的方式初始化的时候,使用赋值会更加合理一点。
13。初始化列表中成员列出的顺序和它们在类中声明的顺序相同。
这是因为初始化列表的顺序并不影响初始化的顺序,初始化的顺序是有成员在类中声明的顺序决定的,而让其顺序相同是使程序看起来是按照初始化列表的顺序初始化。
而c++不使用初始化列表的顺序的原因是:对象的析构函数是按照 与成员在构造函数中创建的相反的顺序 创建的。则如果对象不是按照一种固定的顺序来初始化,编译器就要记录下每一个对象成员的初始化顺序,这将带来较大的开销。
14、确定基类有虚析构函数。
通过基类的指针去删除派生类的对象时,基类一定要有虚析构函数,不然 会有不可预测的后果。不使用虚析构函数,只调用基类的析构函数去删除派生类对象,这是无法做到,也是无法确定后果的。
构造函数调用是先基类后派生类,而析构函数的顺序是先派生类后基类。
当一个类不作为基类使用时,使用虚析构函数是一个坏主意。因为虚函数的对象会有一个虚指针指向虚表,会浪费空间来储存这个没有意义的指针。
纯虚函数在虚函数后加 =0 即可。
15.让operator = 返回 *this 的引用。
= 号可以连接起来,因为其返回值的原因,声明operator=的形式如下:
~~~
C& C:: operator= (const C&);
~~~
其输入和返回都是类对象的引用。以实现连续的赋值操作。返回值是 = 左边值的引用 即 *this的引用,因为右边即参数是const类型的。
函数的参数是 const类型的原因,是 = 右边的值经过计算会获得一个新的结果,而这个对象要用一个临时对象来储存,这个临时对象是const类型的,因为其作为函数的参数,且不能被函数修改。
16.在operator = 中对所有数据成员赋值。
对于深拷贝要自己写一个更加正确的 = 操作。
在涉及继承时,派生类的赋值运算必须处理基类的赋值。如果重写派生类的赋值运算,就必须同时显示的对基类部分进行赋值。
~~~
class A{
public:
int a;
A(int x):a(x){}
// A& operator=(const A& x){ a = x.a; return *this;}
};
class B:public A{
public:
int b;
B(int x):A(x),b(x){}
B& operator=(const B&);
};
B& B::operator=(const B& x){
if(this == &x)return *this;
// A::operator=(x);//调用基类的赋值函数要如此写
static_cast<A&>(*this) = x;//也可以强制转换为A类型然后在调用基类的默认赋值函数
b = x.b;
return *this;
}
~~~
拷贝构造函数也是如此,也要对基类的成员进行复制,只要在成员初始化列表中添加基类即可。
17.在operator=中检查给自己赋值的情况。
这是基于效率考虑的,在赋值的首部检测是给自己赋值,就立即返回,如16中函数所写的那样。这里除了类中自己成员的赋值,如果有基类,还要调用基类的赋值函数,会增加开销。
另一个原因是保证正确性。一个赋值运算符必须首先释放掉一个对象的资源,如有些指针指向了动态申请的空间,则赋值前一般要释放这些资源,然后在指向新的资源(如果在赋值开始,用些临时的指针来记录之前的所有指针指向的内存,然后在赋值后再将临时指针指向的内存全部释放。这还是不行,因为对这些指针如p,必须有 p = new p[]...来指向新申请的一块空间,而如果是同一个对象的话,这里就将原来的指针指向一个新的地址,且两者相同了,所以又需要一个新的临时指针来指向赋值对象的指针的值),也就是说为了使其给自己赋值,对与每个指针必须新建两个指针,一个储存左边的对象的原指针,一个储存右边的对象的原指针,这样的开销时极大极浪费的,也是没有必要的,为了一些不应该进行的为自己赋值要提前准备大量内存来储存数据,这也是不科学的。
所以最好的解决办法是检测是否为自己赋值,一般采用检测对象地址是否相等,c++中一般采取这种方法。对于java中,不好的做法是检测对象是否相等,即其全部值是否完全相等,较好的方法是根据对象的id来判断对象是否为同一个对象。
Effective C++ 10
最后更新于:2022-04-01 15:49:40
10.如果写了operator new,就要同时写operator delete。
为什么要写自己的operator new和delete,首先这不叫重载,这叫隐藏。 new只是用来申请空间,而构造函数是在申请的空间的基础上继续初始化。
为了效率。缺省的operator new 进行内存分配是并不仅仅分配一块所需大小的内存,因为delete释放内存时要知道指针所指向内容的大小,所以,new时会有一个块来储存内存块的大小,而delete时,会根据这个大小来判断删除内存大小。所以当一个类本身很小的时候,这样做,既浪费时间于内存大小的判断,也浪费空间于内存大小的保存。对于某些类较小,且需要一定数量的这些小对象来储存数据时,最好能写一个operator new 来节约空间与时间。
而由于自己的Operator new申请的内存块中没有保存内存块的大小,导致使用缺省的delete时,会导致不可预测的后果。所以若写了operator new ,就必须同时写operator delete。
一般解决方法是 申请一大块内存,作为内存池,将其分为若干块,每块大小正好为储存对象的大小。当前没有被使用时。
尝试将这种固定大小内存的分配器封装起来。
~~~
//内存池的实现。
class Pool{
public:
Pool (size_t n,int size);
void* alloc(size_t n);//为一个对象分配足够的内存
void free(void* p,size_t n);//将p指定的内存返回到内存池。
~Pool();//释放内存池中的全部资源
private:
void* block;
const int BLOCK_SIZE;//池内存放块的数量
void* list;
};
Pool::Pool(size_t n,int size):BLOCK_SIZE(size){
block = ::operator new(n*size);
int i;
for(i = 0;i<BLOCK_SIZE -1;i++){
*(unsigned int*)((unsigned int)block + n*i) =
(unsigned int)block + n*(1+i);
}
*(unsigned int*)((unsigned int)block + n*i) = 0;
list = block;
}
void* Pool::alloc(size_t n){
void* p = list;
if(p){//如果自由链表中还有元素,即还有空间
list = (void*)*(unsigned int *)list;
return p;
}else{
throw std::bad_alloc();
}
return p;
}
void Pool::free(void* p,size_t n){
if(0 == p)return;
*(unsigned int*)((unsigned int)p) = (unsigned int)list;
list = (void*)p;
}
Pool::~Pool(){
delete block;
}
class A{
public:
int a;
static void* operator new (size_t size);
static void operator delete (void* p,size_t size);
A(int x){a = x;}
private:
static Pool memPool;
};
Pool A::memPool(sizeof(A),10);
inline void* A::operator new(size_t size){
return memPool.alloc(size);
}
inline void A::operator delete(void* p,size_t size){
memPool.free(p,size);
}
int main(){
A* list[10];
for(int i = 0 ; i < 10;i++){
list[i] = new A(i);
}
int i = 0;
for(int i = 0 ; i < 10;i++){
delete list[i];
}
i = 1;
for(int i = 10 ; i < 20;i++){
list[i-10] = new A(i);
}
for(int i = 0 ; i < 10;i++){
delete list[i];
}
system("pause");
}
~~~
这是一个内存池的实现,结果感觉虽然实现了内存池的基本功能,但写的不好看。。。譬如一些问题没有解决,如果要求内存大于池的最大容量的处理,以及释放池内元素时,如果重复释放需要进行一些判断此块内存释放已释放。
忽略上面代码,简单分析一下内存池的基本功能:alloc 为对象申请空间的请求提供内存,而free释放对象现在所在的内存。
这里说的写了 operator new 就要写对应的operator delete,因为你在自己写的new中一定会定义一些其他的操作,使的数据的组织结构不是一个简单的new就实现的,可能有多块动态地址,或者可能像内存池中并没有实际的去申请新的空间,所以一定要根据自己写的new中的操作,设计对应的delete操作。
Effective C++ 8,9
最后更新于:2022-04-01 15:49:38
8.写operator new 和 operator delete 时要遵循常规。
operator new要与系统缺省的operator new 操作一致。即有正确的返回值,内存不够时要调用出错处理函数,处理好0字节内存请求的情况,避免隐藏标准形式的new。
new会多次的尝试分配内存,寄希望与每次失败后执行的出错处理函数能释放其他地方的内存以供使用。只有在出错处理函数指针为空的情况下,new才抛出异常。new在请求分配0字节内存时也要返回一个合法的指针,一般情况下,c++会将0字节认为为一个字节大小。
operator new会经常被派生类调用,所以要么在派生类中重载一个新的new,或者在基类中new函数中做一些判断,因为派生类的大小很可能与基类大小不同,而且sizeof()大小为0的情况下会返回值为1。
如果想要控制基于类的数组的内存分配,必须实现operator new[] 。
对于operator delete ,更加简单一点。对于空指针的删除永远是安全的。剩下的只要考虑增加继承支持就行了。
9.避免隐藏标准形式的new。
内部声明的名称会隐藏掉外部范围相同的名称。如果类中只重载了操作符new,如下:
~~~
static void* operator new (size_t size,new_handler p);//p为出错处理函数
~~~
而在新建对象时,
~~~
B *b1 = new (noMoreMemory)B;
B *b2 = new B;
~~~
前者正确,但后者就错误了,错误原因为此函数隐藏了 operator new,即使对于后者,其参数与函数参数不相符合,其错误提示即参数不符。
解决的方法有两种:
重载标准new调用方式operator new。
对每一个增加到operator new中的参数提供缺省值 如
~~~
static void* operator new (size_t size,new_handler p = 0);
~~~
Effective C++ 7
最后更新于:2022-04-01 15:49:36
7.预先准备好内存不够的情况。
new在无法完成内存分配请求时,会抛出异常,异常了要怎么办,这是一个很现实且以后绝对要碰到的问题。
在c中一般使用宏来分配内存并检测分配是否成功,c++中类似以下函数:
~~~
#define NEW(PTR,TYPE) \
try { (PTR) = new TYPE;} \
catch (std::bad_alloc& ){assert(0);}
~~~
catch 的std::bad_alloc为new 操作符中 不能满足内存分配请求时抛出的异常类型。
assert是在<assert.h>中宏,或者带命名空间的<cassert> 。宏检测传给它的表达式的值是否为0,如果是0,就会发出一条出错信息,并调用abort。assert在没有定义标准宏NDEBUG时,即只在调试状态中这么做,当产品发布后,即定义了NDEBUG后,assert什么也不做。
但使用宏是不够的,因为其只能进行简单的一个基本数据类型的内存分配,而对于数组以及有构造函数的数据类型无法使用。可以考虑重载operator new 。
而常用的简单的出错处理方法是,通过设置一个出错处理函数,使内存分配请求不能满足时,调用这个处理函数。因为operator new 操作符在不能满足请求时,会在抛出异常前调用客户指定的一个出错处理函数,这个函数称为new-handler函数。
指定出错处理函数要用到set_new_handler 函数,该函数位于头文件<new>中,其定义如下:
~~~
new_handler __cdecl set_new_handler(_In_opt_ new_handler)
_THROW0();
~~~
而new_handler的定义如下:
~~~
typedef void (__cdecl * new_handler) ();
~~~
new_handler为指向一个没有参数没有返回值的函数,而set_new_handler函数是一个输入参数为 operator new 分配内存失败时调用的出错处理函数的指针,返回值为set_new_handler函数没调用之前的已经在起作用的旧的函数处理函数的指针。使用方法如下:
~~~
void noMoreMemory(){
cerr<<"123";
abort();
}
int main(){
set_new_handler(noMoreMemory);
int *p = new int [ 0x1fffffff ];
~~~
真正在程序中,new_handler函数不是简单的检测出错然后停止程序,而是要去处理出错,即通过一些措施,使下次内存分配可以成功。一个好的new_handler函数从以下几个方向入手:
1.产生更多的可用内存。若在程序启动时预留一大块内存。然后失败时释放出这些内存供使用。
2.安装另一个不同版本的new_handler函数。通过一个新的可以获得资源的函数来获得更多资源,或者使用一些全局变量来改变自身的行为。
3.卸载new_handler。分配无法成功,直接返回抛出标准的std::bad_alloc异常。
4.抛出bad_alloc异常或bad_alloc的派生异常。
5.没有返回,即如上所示,调用abort或exit。
类中的内存分配出错可以有自己重载的类的new操作符和 set_new_handler来实现。而这些函数与重载都是类的静态成员。
首先,类中要有一个静态成员 currentHandler来保存当前使用的new_handler函数。
对于set_new_handler,它只是将类内的指针进行更换,以及返回旧的指针,而标准版本也是如此做的。
对于operator new ,类中的重载new操作符,只是简单的调用原有的new操作符操作,只是错误处理用上类中设置的新的出错处理函数,所以其做法只是用全局的set_new_handler()设置当前类中的currentHandler为处理函数,分配内存后或者异常报错前,恢复即可。
代码如下:
~~~
class A{
public:
static void* operator new (size_t size);
static new_handler set_new_handler(new_handler p);
private:
static new_handler currentHandler;
};
new_handler A::currentHandler;
new_handler A::set_new_handler(new_handler p){
new_handler t = currentHandler;
currentHandler = p;
return t;
}
void* A::operator new(size_t size){
new_handler globalHandler = std::set_new_handler(currentHandler);
void* memory;
try{
memory = ::operator new(size);//::operator new 表示为全局的new操作符。
}
catch(std::bad_alloc&){
std::set_new_handler(globalHandler);
throw;
}
std::set_new_handler(globalHandler);
return memory;
}
~~~
而要做到很好的代码重用,即对任何一个类都能轻松的实现以上代码实现的内容,可以创建一个混合风格的基类,这种基类允许子类继承它的某一特定功能,如这里的new操作符的出错处理。
混合风格?43.
Effective C++ 1-6
最后更新于:2022-04-01 15:49:33
1.尽量用const 和inline 而不用#define:
即尽量用编译器,而不是预处理。宏命令导致编译器永远看不到被声明为宏的符号名,而编译器的处理会进行一些错误判断,但预处理不会。
如果出错,调试无法找到宏声明的错误来源,因为这些符号不存在于符号列表中。
宏调用时会出错,如使用自加或自减操作符时。宏应该使用inline 内联函数代替。
在类中可以使用enium来代替宏,如下,在类中声明一个枚举,枚举中设置一些值,就可以当作常量在类的其他地方随意使用了。
~~~
class A{
public:
enum CHECKED { YES = 1,NO = 0};
int a;
A(int x,CHECKED = YES){}
}
~~~
2.尽量使用<iostream>而不是<stdio.h>
scanf和printf很轻巧高效,但cout<<和cin>>具有类型安全和扩展性。使用<<和>>操作符重载:
~~~
friend ostream& operator<<(ostream& s,const D& d);
~~~
但是iosteam的有些操作要比C stream的效率低,且可移植性低。
对于c++中是否在头文件后加.h,未加表示库中元素都在std命名空间中,加后表示元素为全局空间的。使用前者会避免名字冲突。
3.尽量使用new和delete 而不用malloc和free。
malloc和free在c中有用,但是无法用于c++中的类与对象,c++中构造函数和析构函数,可以动态的分配内存。
且malloc和free是库函数,而new和delete是操作符。
4.尽量使用c++风格的注释.。
c++风格为行尾注释语法使用//,而c风格的使用/* ... */。
5.对应的new和delete 要采用相同的形式。
要删除的是单个对象还是数组,需要告诉delete。出错的结果是不可预测的。
而当使用typedef来定义类型后,new这个类型,也要用相应形式的真正的类型来delete进行删除,如:
~~~
typedef string AddressLines[5];
~~~
要释放new的AddressLines对象,要用真正的形式,即delete [] p。
6.析构函数中对指针成员调用delete、
大多数情况下,执行动态内存分配的类都在构造函数中进行new分配内存,而在析构函数中使用delete来释放内存。
而当程序不断维护升级后,在其他成员函数中会不断的添加新的指针,所以一定要在析构函数中都进行删除。
指针要初始化,不然会在析构时出错。而删除空指针是安全的,因为它什么也没做。
前言
最后更新于:2022-04-01 15:49:31
> 原文出处:[Effective C++ 学习笔记](http://blog.csdn.net/column/details/effectivecplusplus.html)
作者:[luo_xianming](http://blog.csdn.net/luo_xianming)
**本系列文章经作者授权在看云整理发布,未经作者允许,请勿转载!**
# Effective C++ 学习笔记
> 学习一下《Effective C++》,并记录一下自己的思考与总结。