《随笔二十三》—— “ 【Effective C++ 中文第三版】 提炼总结一”

目录

术语

《第一章》让自己习惯C++

条款01:视C++为一个语言联邦

条款02:尽量以const, enum, inline替换#define

条款03:尽可能使用const

* 条款04:确定对象被使用前已先被初始化

《第二章》构造/析构/赋值运算

条款05: 了解C++默默编写并调用哪些函数

条款06:若不想使用编译器自动生成的函数,就该明确拒绝

条款07:为多态基类声明virtual析构函数

*条款08:别让异常逃离析构函数

条款09:绝不再构造函数和析构函数中调用 virtual函数

条款10:令 operate=返回一个reference to *this 

条款11:在operate=中处理“自我赋值”

条款12:复制对象时勿忘其每一个成分

条款13: 以对象管理资源

*条款14:在资源管理类中小心copying行为

条款15:在资源管理类中提供对原始资源的访问

条款16:成对使用new和delete时要采取相同形式

条款17:以独立语句将newed的对象置入智能指针

*条款18:让接口容易被正确使用,不容易被误用

         条款19:设计class犹如设计type

条款20:宁以pass-by-reference-to-const 替换 pass-by-value 

 


标题上带有 “ * ” 的 label , 表明本人还没有完全搞明白。


术语


● 所谓声明式(declaration)是告诉编译器某个函数或变量的名称和类型(type) ,但略去细节:

std:: size_t numDigits(int number); // 函数声明式

本书中函数的签名指的是该函数的参数类型、个数、顺序;和返回类型,说白了就是该函数的函数原型。 所以上述的函数的签名是 std::size_t (int), 但需要注意的是C++ 标准对函数的签名官方定义并不包括函数的返回类型, 本书这样写, 是为了有帮助。

● explicit 关键字 , 这可阻止只对一个实参的构造函数有效,需要多个实参的构造函数不能用于隐式转换。但它们仍可被用来进行强制显式类型转换(explicit type conversions) 一般来说,把具有可能隐式转换的构造函数声明为explicit 更好, 因为这样可以避免非预期的类型转换。 如果需要转换的时候,可以选择强制显式类型转换, 可以使用static_cast< 类名>.

class B
{
public:
	explicit B(int x = 0, bool b = true) //默认构造函数
	{

	}
};
void doSomething(B obj)
{

}
int main()
{
	B obj1;
	doSomething(28); //错误, 该构造函数有explicit关键字,不能进行隐式转换
	doSomething(static_cast<B>(28)); //正确, 强制类型转换。
    B obj(25, true); // 直接初始化

	B obj2 = obj1; //错误
	B obj3(obj2);
	system("pause");
	return 0;
}

注意:构造函数在类外定义时, explicit 关键字不用写。

 注意 : 发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=)。此时我们只能使用直接初始化而不能使用explicit 构造函数(看上述的主函数)

所以当我们使用explicit 关键字声明构造函数时,它将只能以直接初始化的形式使用。 而且,编译器将不会在自动转换过程中使用该构造函数。

 

● copy构造函数被用来“ 以同型对象初始化自我对象” ,  copy assignment 操作符被用来“从另一个同型对象中拷贝其值到自我对象”,看一个示例代码:

考虑以下代码:

该函数的参数是以值传递 的,  所以在上述调用中 awidget被复制到w体内。这个复制动作由widget的copy构造函数完成。Pass-by-value意味“调用copy构造函数”。通过值传递用户自定义类型通常是个坏主意, Pass-by-reference-to-const往往是比较好的选择。

 


《第一章》让自己习惯C++


条款01:视C++为一个语言联邦


● C++中主要的次语言:

(1)C,C++以C语言为基础,在此基础上添加了许多特有功能。
(2)Object-Oriented C++,C++是一种面向对象编程的语言,其中封装、继承、多态等是面向对象
编程的最直接的实施。
(3)Template C++,C++的泛型编程,很大程度解决了代码复用的功能。
(4)STL,C++的标准模板库,实现了各种容器以及算法,使用起来更加高效。

●  假如说我们传递一个实参的值,并不想在函数中改变它的值,对内置类型而言用值传递比 引用传递或const引用传递更好?这是为什么呢?

 因为引用本质是一个指针(可以理解'引用'为'指针'的一个语法糖),指针在64位机器上为64位,所以如果你一个整型int为32位的话,那么如果你按值传递要传递为32位,那么按引用传递却要传64位。也就是说: 按引用传递不管你传递什么都是64位,而按值传递的话传递的大小为你数据所占的大小,而通常内置类型(如char,int,float等)的的大小都是小于64位的,所以内置类型参数的传递都按值传递比按引用传递要高效一点

 


条款02:尽量以const, enum, inline替换#define


#define ASPECT RATIO 1.653

●  记号名称 ASPECT RATIO 也许从未被编译器看见; 也许在编译器开始处理源码之前它会被预处理替换成 1.653。于是记号名称ASPECT-RATIO有可能没进入记号表(symbol table)内。 有人问记号表是什么?

 

记号表就是编译器在编译你的代码的时候会把你代码中所有的变量名/函数名等记录在一个表中。 但是宏名字是不会进入记号表的,因为宏只是简单的文本替换,C++预处理把你用到宏的地方简单的全部替换成宏的定义而已; 比如这里ASPECT_RATIO在进入真正的编译阶段之前被C++预处理被替换为1.653,而C++编译器是看不到ASPECT_RATIO的,只看到1.653这个数字;


整个C++源代码生成机器码(二进制码)的过程为:

  • *.cpp文件 --- > C++预处理-->C++编译器--> C++代码生成器-->*.obj文件, 这个我们称为"编译阶段";
  • 所有的*.obj 文件 --> C++连接器-->*.exe可执行文件 , 这个我们称为"连接阶段"

 

"编译阶段": 

  • 一个.cpp文件
  •  -->C++预处理(替换各种宏/#include)
  •  -->C++编译器(模板实例化/词法分析/语法分析/语义分析)
  •  -->C++代码生成器(中间代码优化/机器码生成)
  •   -->一个.obj文件


"连接阶段": 所有的*.obj 文件 --> C++连接器-->一个.exe可执行文件

比如: 你在某个A.cpp文件调用了一个函数,这个函数只有声明没有实现,实现在另外一个B.cpp文件中,你编译这个A.cpp文件是没有问题的,但是你如果B.cpp文件忘记弄进工程里了, 那么你会在"连接阶段"获得报错,说找不到这个函数的实现,这就是连接错误。

 

这些知识都是在一门叫<<编译原理>>的课程里面的,这是计算机系的核心课程之一,有事件都可以学习一下。

 

 

计算机系四大核心课程:  

  • 离散数学(包括算法和数据结构),
  • 编译原理,
  • 操作系统原理和数据库原理; 


这四大课程都是教你怎么构造基础软件的,虽然通常在工作中你基本可能自己去写一个编译器或操作系统或数据库,但是理解原理是一个软件工程师的基本素养。所以都可以应该学习一下。

 

●  所以说 #define定义的缺点:用#define定义的宏变量,可能并没有进入记号表中,如果出错后期排查不好追踪。

解决办法:用常量定义代替宏定义。作为语言常量,定义的常量一定会被编译器看到并进入记号表。

 

● 用常量替换#define的两种情况:

  • 定义常量指针时(通常放在头文件中,以便其它的源码包含),不但要把指针声明为const,也要把指针所指向的内容也声明为const。比如你在一个头文件中定义一个指向常量的常量指针:
const char *const authorName = "huang";

写成下面这样更好:

const std::string authorName ("huang");
  • 为了将常量的作用域限制在class的内部, 首先该常量必须是类的成员,并且该常量最多只能有一个实体,所以必须是个static 成员。(primer 271页有介绍)

  • 注意: 通常即使一个常量静态成员在类内部被初始化了,也应该在类外定义一下该成员,避免以后修改,造成错误。 定义该成员时,应该定义在实现文件中。
  • 注意: #define 无法创建一个class 的专属常量,也没有 private #define 这样的定义、
  • 注意: 常量静态成员只有整型(int short char long long long)才能在类内提供初始值, 比如 浮点型(float double long double) 是不可以在类内提供初始值的, 必须在类外重新定义该成员并且提供初始值。 如果不是只是普通的常量成员是可以在类内提供初始值的,不管是整型还是浮点型或者其它。

我们还可以这样在类内定义一个数组:

  • enum 常量 的行为某方面说比较像#define而不像const, 有时候这正是你想要的。例如取一个const的地址是合法的, 但取一个enum的地址就不合法, 而取一个#define的地址通常也不合法。 如果你不想让别人用一个 pointer或reference指向你的某个整数常量, enum可以帮助你实现这个要求。

可以用下列的template inline 函数来替代:

重点:

  • 1)对于单纯常量,最好用const对象或enum代替#define
  • (2)对于形似函数的宏macro,最好用template inline函数替换#define .

 


条款03:尽可能使用const



指针的类型


●   从语法的角度看,你只要把指针声明语句里的指针名字去掉,剩下的部分就是这个指针的类型。这是指针本身所具有的类型。让我们看看例一中各个指针的类型:

int *ptr; //指针的类型是int *  
char *ptr; //指针的类型是char *  
int **ptr; //指针的类型是 int **  
int (*ptr)[3]; //指针的类型是 int(*)[3]  
int *(*ptr)[4]; //指针的类型是 int *(*)[4]  

指针所指向的类型


●  当你通过指针来访问指针所指向的内存区时,指针所指向的类型决定了编译器将把那片内存区里的内容当做什么来看待。 从语法上看,你只须把指针声明语句中的指针名字和名字左边的指针声明符*去掉,剩下的就是指针所指向的类型。例如:

int *ptr; //指针所指向的类型是int  
char *ptr; //指针所指向的的类型是char  
int **ptr; //指针所指向的的类型是 int *  
int (*ptr)[3]; //指针所指向的的类型是 int()[3]  
int *(*ptr)[4]; //指针所指向的的类型是 int *()[4]  

这里有一篇详解指针的博客,点击 这里 开始浏览。


 

● 为了给用户一个一目了然的接口,一看就知道哪些成员函数可以操纵const对象而哪些不能,作者建议在类中明确将那些不改变对象的成员函数声明为const函数,虽然const成员函数可以使用非const成员变量,但是遵守这一原则会给客户带来极大的便利。

下面是书中的代码:

int main()
{
	std::vector<int>vec{ 0,1,2,34 };
	const std::vector<int>::iterator iter = vec.begin(); // 迭代器不可以改变
	*iter = 10;
	++iter; // 错误, iter是const的

	std::vector<int>::const_iterator cIter = vec.begin(); // 迭代器所指对象不可改变
	*cIter = 10; // 错误, *cIter是const
	++cIter;

	std::vector<int>::iterator vvec = vec.begin(); // 普通迭代器
	*vvec = 10;  //正确
	++vvec;
	system("pause");
	return 0;
}

但是不需要像上面那样繁琐, 可以使用C++11 语法中的auto关键字和 begin 和 end 或者 cbegin 和 cend 成员使用, 让编译器自己判断该迭代器是什么类型。

 

● 首先介绍一下什么是Logic Constness和Bitwise Constness,Logic Constness指的是function 后的const修饰,我们只知道这个function被const修饰了,但是类的任何成员数据并没有被const,Bitwise Constness指的是变量(指针,或者引用等,基本上任何的二进制储存值)被const修饰。 点击 这里 浏览详细介绍。

 

● 本条款也提到const成员函数的重要性,原因之一就是只有const函数才能用来操纵const对象。有的时候会遇到在const函数中更改非const成员变量的情况,如果必须在const 成员函数中修改某些数据的值,正确的方法是:声明这些数据成员时添加前辍 mutable 限定符,这样即使在const 成员函数中也能修改成员数据的值。一个 mutable 成员数据永远不会是const(前提是该成员变量是非const成员),即使该成员数据是 const对象的成员。

 

还有一种情况就是为了防止代码重复,比如两个函数实现了同样的功能只是类型不同而已,这样就会导致两段几乎相同的代码段,这无疑会增加编译时间、维护和代码膨胀等风险。在本原则的有关叙述中,作者采用了强制类型转换来解决之,虽然作者本身在大多数情况下并不提倡做法。

因为const成员函数不更改对象,这就防止了由于误操作而带来的问题,因为最好用非const成员函数去调用const的实现,说白了就是直接return这个const成员函数,只不过需要对作为这个return的表达式的const成员函数进行一下强制类型转换使其成为非const型的。所以,在这里不得不提一下纯粹的C++的强制类型转换。

  • 关键在static_cast<typename>(value)是纯粹的C++强制类型转换的关键词和用法,它的使用频率是最高的。
  • const_cast<typename>(value)是用来消除const属性时用的。不过它不能用于基本类型。
  • reinterpret_cast<typename>(value)它用于无关类型之间的转换。
  • dynamic_cast<typename>(value)用于父子类指针之间的转换。

在C++语言中只有这4中强制类型转换。

注意:  令一个类的非const的成员函数调用该类的另一个const成员函数是一个避免代码重复的安全方法, 如果反过来, 如果让该类的const成员函数调用一个该类的非const成员函数, 是一种非常不推荐 的做法, 因为想让这样的代码通过编译器,你必须使用一个const—_cast 将 *this 身上的const 性质解除掉。  非const 成员函数本来就可以对该类对象做任何的操作,所以在其中调用一个const成员函数并不会带来什么风险。

 

记住: 当const和non-const成员函数有着实质等价的实现时,令non-const版本调用const版本可避免代码重复(利用强制类型转换).

 


* 条款04:确定对象被使用前已先被初始化


●   本条款写的是: 永远要在使用某一个对象之前, 先进行初始化。 如果是内置类型, 手动初始化。 如果是自定义类型, 使用构造函数初始化该类实例的每一个成员。

●  记住: C++ 规定, 对象的成员变量的初始化动作发生在进入构造函数本体之前。说白了就是成员初始化发生的时间更早,发生于这些成员的Default 构造函数被自动调用之时, “ { }” 函数体中的是赋值, 而不是初始化。 那么对于大多数类来说,这个Default 构造函数将按照以下规则来初始化类的成员数据:

  • 如果存在类内的初始值, 用它来初始化成员数据。
  • 如果没有, 默认初始化该成员, 此时该数据成员被赋予了“默认值”, 该默认值到底是什么由变量的类型决定,同时定义变量的位置也对此有影响。

提示:定义于函数体内的内置类型的对象如果没有初始化,则其值未定义。如果定义于任何函数体之外的内置类型变量被初始化为0 . 类的对象如果没有显式地初始化,则其值由类确定。

  •  记住: 如果某一个类的成员数据是const 或者是 reference,又或者是某一个类类型的变量,  那么它们必须用构造函数的初始化列表初始化, 不能在函数体中赋值。
  •  记住: 一个类的成员数据的初始化顺序是这些成员在类内依次被声明的顺序。所以避免有时候会发生错误, 所以建议在构造函数初始化这些成员的顺序,是这些成员被声明的顺序。
  •  记住:为免除“跨编译单元之初始化次序不确定”问题,请以local static对象替换non-local 'static对象。

《第二章》构造/析构/赋值运算



条款05: 了解C++默默编写并调用哪些函数


● 注意:编译器产出的析构函数是个非virtual (见条款7) , 除非这个类的基类自身声明有virtual析构函数(这种情况下这个函数的虚属性; 主要来自于它的基类).

● 如果一个类中有 引用成员数据、 const成员数据、指针成员数据,你必须重载 “ = ” 运算符, 这样能够避免不必要的错误。

 一般情况下父类中如果有copy赋值操作符,在子类中编译器是不会再给自动生成copy赋值操作符,直接使用父类的就好了,因为编译器认为子类的copy赋值操作符是要能够处理父类的赋值操作的。所以如果你此时把父类的copy赋值操作符设置为private的,那么你就没有copy赋值操作符可用了,除非你自己在子类中写一个。

 


条款06:若不想使用编译器自动生成的函数,就该明确拒绝


●   这是《Effective C++》中第6个原则,在某些情况下你不想让某些类的对象被拷贝,那么在这种情况下即使你不写copy构造函数和copy赋值操作符编译器也会为你生成,于是你的类还是支持拷贝操作。  如果你声明它们 , 你的类还是支持copying.  有什么方法即可以阻止编译器生成的copy构造函数和copy赋值操作符,  又可以不让别人调用你手写的copy构造函数和copy赋值操作符呢。

解决方法是:  自定义手写它们,并且把 copy构造函数和copy赋值操作符 都声明为private, 并且没有定义。  这样即使是 member 函数 和 friend 函数调用它们,也会出现错误。 

class TextBlook
{
public: 
	explicit TextBlook(int x)
	{
		m_field = x;
	}
private:
	TextBlook(const TextBlook&);
	TextBlook &operator=(const TextBlook&);
	int m_field;
};
int main()
{
	TextBlook myTextBlook(55);
	TextBlook mmTextBlook(60);

	myTextBlook = mmTextBlook;  // Error
	TextBlook mTextBlook(myTextBlook); //Error

	system("pause");
	return 0;
}

看运行后的截图:

 

●  那么该条款还介绍了一个方法来阻止copying 操作, 那就是专门声明一个类 ,在该类中把 copy 构造函数 和 copying 操作符声明为private,  然后你想要阻止copying 操作的那个类 继承 你刚才声明的类。

class Uncopyable
{
protected:
	Uncopyable() {}  // 允许 derived 对象构造和析构
	~Uncopyable() {}

private:
	Uncopyable(const Uncopyable&);  // 阻止copyiing
	Uncopyable &operator=(const Uncopyable&);
};

class TextBlook :private Uncopyable
{
public: 
	explicit TextBlook(int x)
	{
		m_field = x;
	}
private:
	int m_field;
};
int main()
{
	TextBlook myTextBlook(55);
	TextBlook mmTextBlook(60);

	myTextBlook = mmTextBlook;  // Error
	TextBlook mTextBlook(myTextBlook); //Error

	system("pause");
	return 0;
}

看运行后的截图:

 注意:  这样甚至是 member 函数 或 friend 函数 尝试拷贝TextBlook 对象时, 编译器会试着生成一个 copy 构造函数 和一个copy assignment  操作符,  那么此时 这些函数的 “ 编译器生成版 ” 会尝试调用其 基类的对应  copy 构造函数 和一个copy assignment  操作符, 那么此时会发生错误, 上面的运行截图就可以看出, 因为基类的拷贝函数是 private。

 

注意:  记得 TextBlook  类 继承 基类Uncopyable 要私有继承, 而且Uncopyable  类中的  copy 构造函数 和一个copy assignment  操作符必须要是私有的。 这样 不管是在类外 还是在 派生类中都不可以访问这两个函数。  这样我们既可以阻止编译器生成的版本, 就可以阻止别人调用自己手写的版本。

 

但是本人在看C++ primer 这本书的时候,C++11 在新标准下可以通过将拷贝构造函数和拷贝赋值运算符定义为删除的函数(deleted function )来阻止拷贝。删除的函数是这样一种函数: 我们虽然声明了它们, 但不能以任何方式使用它们。在函数的参数列表后面加上 =delete 来指出我们希望将它定义为删除的:

下面看一个示例:

class NoCopy 
{
public:
	NoCopy() = default; // 使用合成的默认构造函数
	NoCopy(const NoCopy&) = delete; // 阻止拷贝
	NoCopy &operator=(const NoCopy&) = delete; //阻止赋值
	~NoCopy() = default; //使用合成的析构函数
	// other members
};
int main()
{
	NoCopy myNoCopy;
	NoCopy mmNoCopy;
	NoCopy ttNoCopy = myNoCopy; // 错误
	ttNoCopy = myNoCopy;  // 错误
	system("pause");
	return 0;
}

运行截图为:

从运行截图中可以看出, 当我们将拷贝构造函数和拷贝赋值运算符定义为删除的函数(deleted function )来阻止拷贝时, 在主调函数中我们来拷贝 和 赋值 相应的操作,发生了错误。

 

所以说当我们想 “ 即可以阻止编译器生成的copy构造函数和copy赋值操作符,  又可以不让别人调用你手写的copy构造函数和copy赋值操作符呢 ” 的时候,  将相应的 copy构造函数和copy赋值操作符 第一次声明的参数列表的后面添加一个 “ =delete ”, 来表示该函数是删除的,以便禁止试图使用该函数的操作。

 

所以说我们不需要学习本书上建议的方法, 因为本人觉得这本书已经过时了 ,但是也没有最新的经典的书籍,只能看这本。 不明白的可以直接看C++ primer 第 449 页。

 


 

条款07:为多态基类声明virtual析构函数


●   这是《Effective C++》中第7条原则,其内容是在具有多态用途的父类中应该使用virtual析构函数。

首先要知道啥是多态。多态的一种体现就是通过父类的指针指向不同的子类来实现不同的功能,从而达到接口重用的目的。在这种情况下用作多态的父类往往具有至少一个virtual成员函数留给子类来实现。

好了,现在铺垫完毕了,来说正题,为啥要有一个virtual析构函数呢?那是因为如果没有这样一个virtual析构函数的话,子类的析构函数就不会被调用,那么对象的子类部分不会被析构,那么就会造成资源的泄露。现在来看下面的例子:

class Animal
{
public:
	void sleep()
	{
		cout << "调用的是基类中的sleep" << endl;
	}
	virtual void breathe()
	{
		cout << "调用的是基类中的breathe" << endl;
	}
	 ~Animal()
	{
		cout << "基类析构函数被调用!" << endl;
	}

};
class Fish :public Animal
{
public:
	void breathe()
	{
		cout << "调用的是派生类中的breathe" << endl;
	}
	void sleep()
	{
		cout << "调用的是子类中的sleep" << endl;
	}
	~Fish()
	{
		cout << "子类析构函数被调用!" << endl;
	}
};


int main()
{
	
	{
		Animal *an = new Fish;
		an->breathe();
		an->sleep();
		
		delete an;
	}

	system("pause");
	return 0;
}

输出结果为:
调用的是派生类中的breathe
调用的是基类中的sleep
基类析构函数被调用!

从这个结果可以看到父类的析构函数执行了,说明对象的父类部分所占资源已经被释放,但是子类的析构函数并未调用这说明对象中子类部分所占资源并未得到释放。

记住:因为C++明白指出, 当derived class对象经由一个base class指针被删除, 而该base class的析构函数没有virtual(系统生成的析构函数也没有virtual性质),其结果未有定义,实际执行时通常发生的是对象的derived成分没被销毁。

但是如果在父类中加上一个virtual析构函数的话就不一样了。基类中的析构函数中加上virtual 关键字后:

这说明对象的子类部分所占资源也被释放掉了。

 

记住:  任何class只要带有virtual函数都几乎确定应该也有一个 virtual析构函数。如果说 一个class不含virtual函数,通常表示它并不准备被用做一个base class。在这种情况下不应该把其析构函数设为virtual的。为啥呢?这与C++中virtual本身的实现机制有关,因为这样的类的对象必须要携带一个表,这个表叫vtbl (虚函数表),所以本来没必要多带这么个表,但是你非要多出一个来占个空间,这样就会使该类的体积增大。所以不准备被继承的类是没有必要设置virtual析构函数的。

 

什么是虚函数表?

当将基类中的成员函数  声明为 virtual 时, 编译器在编译的时候 发现 基类中有虚函数, 此时编译器会为每个包含虚函数的类创建一个vtbl (虚函数表), vptr(虚表指针) 指向一个由函数指针构成的一维数组, 在这个数组中存放每个虚函数的地址。

那么每一个带有virtual函数的class都有一个相应的vtbl (虚函数表)。当对象调用某virtual函数时,实际被调用的函数取决于该对象的vptr(虚表指针)所指的那个vtbl (虚函数表)—— 编译器在其中寻找适当的函数指针。 

记住:  因此无端地将所有classes的析构函数声明为virtual, 和永远不去声明一样是错误的。许多人的心得是: 只有当class内含至少一个virtual函数,才为它声明virtual析构函数。

 

● 不要去继承 所有的STL容器(例如: vector 、list)和 string、还有不带虚析构函数的类, 否则的话, 会造成内存泄漏。

 

● 在这里在介绍一种情况,当你希望在抽象类中把析构函数设为virtual的时候(说明该抽象类用多态的用途, 如果没有, 就不需要把抽象类中的析构函数声明为virtual),应该把析构函数设为纯virtual函数,并且给与空的实现,类似于下面这样:

class Animal
{
public:
	
	virtual~Animal() = 0;
	

};
Animal::~Animal()
{

}

为啥要这样做呢?因为析构函数调用顺序是从最深层派生的类的析构函数最先被调用,然后逐层调用父类的析构函数,所以如果这个纯virtual函数没有实现的话,编译器就会报错。

这个原则简而言之就是,只有作为多态用途的父类才有必要使用virtual析构函数,其他的就是画蛇添足。如果一个类用作基类, 它是它并不作为多态的用途(例如STL容器), 那么他就不需要有virtual 析构函数。

 

最后记住:

  • polymorphic (带多态性质的) base classes应该声明一个virtual析构函数。如果 class带有任何virtual函数,它就应该拥有一个virtual析构函数。
  • Classes的设计目的如果不是作为base classes使用,或不是为了具备多态性 (polymorphically) ,就不该声明virtual析构函数。

 


*条款08:别让异常逃离析构函数


●      C++ 并不禁止析构函数抛出异常, 但是如果你的析构函数必须执行一项操作,该操作可能会在失败时抛出异常,      该怎么办呢?

对付这种情况通常有两种简单粗暴的手段:

  • 在析构函数内发现异常,立刻捕捉到并且结束整个程序(利用 try捕获到异常,在catch中利用abort()函数可以强行关闭程序),这样我们就可以阻止该异常传播出去, 导致未知行为。
  • 在析构函数中发现异常,立刻捕捉到并将其扼杀,掩人耳目,继续执行程序。

其中第一种手段比第二种手段要好,这是为啥呢?因为方法1直接结束程序,其结果是可预料的,不会造成太大破坏。而方法2你这个异常是终止了,但是程序中其他部分与这个功能相关的势必会造成影响,也许还会因此带来其他异常的连锁反应,这个就不好办了。

 

不过书中还给出了一个不错的解决方案是,再创建一个类用来处理异常,在这个类中有一个成员函数专门用来处理原来的类中的异常。而这个成员函数是调用原类中的异常处理来完成的,这实际上就是变相的让原类自己处理异常,这是第一道关卡。然后异常处理类的析构函数中也有一份处理异常的代码,这部分是异常处理类自己的,这是第二道关卡。这个就是双保险,如果说在第二道关卡仍然不能有效处理异常,那没办法了,只能强行关闭程序了。

 

●  如果某个操作可能在失败时抛出异常,而又在某种需要必须处理该异常,那么这个异常必须来自析构函数以外的某个函数。因为析构函数抛出异常,容器引发未定义的行为。

所以最后记住:

  • 析构函数绝对不要抛出异常。如果一个被析构函数调用的函数可能抛出异常,析构函数应该捕捉任何异常,然后吞下它们(不传播)或结束程序。
  • 如果客户需要对某个操作函数运行期间抛出的异常做出反应,那么class应该提供一个普通函数(而非在析构函数中)执行该操作。

 


条款09:绝不再构造函数和析构函数中调用 virtual函数


●   这是《Effective C++》中第9条原则,我在看这个原则的时候,也是被这个作者逗乐了,  代码例子给的是纯虚函数,  它解释的时候几乎按照virtual 函数 来解释的。  

 

●  那么作者首先写的是不要在构造函数中调用纯 virtual函数的原因: 这个原因我个人觉得 纯虚函数又没有定义,你调用它干嘛?就算是个普通函数或者virtual 函数 你没定义,然后你又去调用 ,不报错才怪。你换个非纯虚函数,再给这个函数定义一下。 不就是可以在构造函数和析构函数中调用了吗。 不过调用的时机一定要对。 即调用的时机一定要是该类初始化数据成员之后,否则, 你调用该函数有可能出现未知行为。 因为该类的数据成员没被初始化,你调用它干嘛。
 

下面给一个示例代码, 该代码表明在构造函数和析构函数中调用虚函数的例子:

class A
{
	int aa;
public:
	A(int a)
	{
		aa = a;
		cout << "调用的是基类中的构造函数!\n" << endl;
		func1(); //调用的是基类的构造函数
		
	}
	virtual void func1()
	{
		cout << "调用基类中的func1 函数!" << aa << endl;
	}
	virtual ~A()
	{
		cout << "调用的是基类中的virtual析构函数!" << endl;
	}

};

class B : public A
{
	int bb;
public:
	B(int a,int b):A(b),bb(a)
	{
		cout << "调用的是派生类中B的构造函数!\n" << endl;
		func1(); // 构造函数访问虚函数,调用的是派生类中的该函数
		A::func1(); // 显式调用基类中的该函数
	}
	virtual void func1()
	{
		cout << "调用派生类中B的func1 函数!" << bb << endl;
	}
	~B() //析构函数访问虚函数
	{
		func1();  // 调用的是派生类中的该函数
		A::func1();// 显式调用基类中的该函数
		cout << "调用的是派生类类中的析构函数!" << endl;
	}
};

int main()
{
	{
		A *myA = new B(10, 20);
		delete myA;
	}

	system("pause");
	return 0;
}


输出结果为:

调用的是基类中的构造函数!

调用基类中的func1 函数!20
调用的是派生类中B的构造函数!

调用派生类中B的func1 函数!10
调用基类中的func1 函数!20

调用派生类中B的func1 函数!10
调用基类中的func1 函数!20

调用的是派生类类中的析构函数!
调用的是基类中中的析构函数!

可以把该示例代码复制到编译器中, 该代码编译器没有任何报错。具体细节自己领悟。 理解该原则只要把当 子类创建对象的时候构造函数初始化顺序搞明白了, 就行了。

 

●  所以需要注意的是: 不要在基类的构造函数和析构函数中调用纯虚函数(因为它们没有定义,也不能定义)。

 


条款10:令 operate=返回一个reference to *this 


●  为了能实现“连续赋值”,赋值操作符必须返回一个引用指向操作符的左侧实参(因为返回一个引用,该引用可用作左值)。所以可以放在 赋值运算符的左边, 如果不按引用返回, 可以编译通过, 但是可能达不到你预期的结果。
 

●   像 *= /= -=   这样跟所有赋值有关的运算符, 重载这些运算符的时候都应该返回 引用。

 


条款11:在operate=中处理“自我赋值”


●   这是本书中的第11条原则, 讲的是 当某个类的实例相互赋值时, 避免 “ 自我赋值 ”。

先看一个程序示例,看看 “ 避免自我赋值 ” 的重要性:

class HasPtr 
{
public:
	HasPtr(const std::string &s = std::string()) :ps(new std::string(s)), i(0) { }
	
	HasPtr(const HasPtr &p) :ps(new std::string(*p.ps)), i(p.i) { }
	
	~HasPtr() { delete ps; }

	HasPtr& operator=(const HasPtr &rhs);
private:
	std::string *ps;
	int i;
};

HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
	delete ps; //释放对象指向的string
   // 如果 rhs 和 *this 是同一个对象,我们将从已释放的内存中拷贝数据
	ps = new string(*(rhs.ps));
	i = rhs.i;
	return *this;
}

int main()
{
	HasPtr ttHasPtr;
	ttHasPtr = ttHasPtr; // 这样赋值给自己
	system("pause");
	return 0;
}

从该程序我们可以看出 rhs 和 operator= 函数的 *this 隐藏指针指向的是同一个对象, 即“ ttHasPtr  ”。 当我们执行  “  ttHasPtr = ttHasPtr ” 该操作时, delete ps 会释放 *this 和 rhs 指向的 string。 接下来, 当我们在 new 表达式中试图拷贝 (*rhs.ps )时, 就会访问一个指向无效内存的指针, 其行为和结果都是未定义的。

 

所以通过本节我个人总结如下:

当某个类中的数据成员中只有普通的数据成员, 没有指针数据成员的时候, 避免“ 自我赋值 ” 的方法就是在你的 拷贝赋值运算符函数的函数体开始处 写上如下代码就可以了:

if(this == &该函数的形参) // 如果相互赋值的对象地址是一样的, 就返回
{
  return *this;
}

 

当某个类中的数据成员不只有普通的数据成员, 还有指针成员时, 避免 “ 自我赋值 ” 的方法, 该作者提供了一下方法:

class Bitmap { };
class Widget 
{
public:
	Widget &operator=(const Widget& rhs); 
private:
	Bitmap *pb;
};
Widget& Widget::operator=(const Widget& rhs)
{
	// 重点在这
	Bitmap *pOrig = pb; // 先保存原来 pb 的位置
	pb = new Bitmap(*rhs.pb); //令 pb指向 *pb的一个副本
	delete pOrig; // 删除原来保存的 pb
	return *this;
}

如果 “ pb = new Bitmap(*rhs.pb); ” 该代码 抛出异常,  pb 还是指向原来的地方, 该代码还可以处理 “ 自我赋值 ”。 

 

本人在C++ primer 还学习到一个方法,看以下代码:

class HasPtr 
{
public:
	HasPtr(const std::string &s = std::string()) :ps(new std::string(s)), i(0) { }
	
	HasPtr(const HasPtr &p) :ps(new std::string(*p.ps)), i(p.i) { }
	
	~HasPtr() { delete ps; }

	HasPtr& operator=(const HasPtr &rhs);
private:
	std::string *ps;
	int i;
};
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
	auto newd = new string(*rhs.ps); // 先用一个临时的newd 拷贝底层的string
	delete ps; // 释放旧内存
	ps = newd;  // 从右侧对象拷贝数据到本对象
	i = rhs.i;
	return *this; // 返回本对象
}

当编写一个赋值运算符时,一个好的编写方法:

  • 是先将右侧运算对象拷贝到一个局部临时对象中。当拷贝完成后, 销毁左侧运算对象的现有成员就是安全的了。一旦左侧运算对象的资源被销毁, 就只剩下将数据从临时对象拷贝到左侧运算对象的成员中了。

 

●   该作者还提供了 更好的方法是 copy and swap 方法( 该方法在条款 29 详细介绍).


条款12:复制对象时勿忘其每一个成分


●  这个原则是《Effective C++》中第12个原则。   

首先说的是当我们在原来的某个类中添加了一个数据成员,你必须同时修改 拷贝构造函数、以及该类的所有的构造函数、还有该类重载的赋值运算符。

如果该类还有派生类, 那么派生类要在本类的拷贝构造函数中 或者 本类的重载的赋值运算符中 调用基类中的相应函数。只要我们为派生类中 写 拷贝构造函数 和 重载赋值运算符, 那么就应该复制其 base class  成分。

class Date { }; 
void logCall(const std::string& funcName);
class Customer {
public:
	Customer(const Customer& rhs);
	Customer& operator=(const Customer& rhs);
private:
	std::string name;
	Date lastTransaction;
};
class PriorityCustomer : public Customer   // a derived class
{ 
public:
	PriorityCustomer(const PriorityCustomer& rhs);
	PriorityCustomer& operator=(const PriorityCustomer& rhs);
private:
	int priority;
};
PriorityCustomer::PriorityCustomer(const PriorityCustomer& rhs)
	: Customer(rhs), // 调用 base class 的 copy  构造函数
	priority(rhs.priority)
{
	logCall("PriorityCustomer copy constructor");
}
PriorityCustomer& PriorityCustomer::operator=(const PriorityCustomer& rhs)
{
	logCall("PriorityCustomer copy assignment operator");
	Customer::operator=(rhs); // 对 base class 成分进行赋值操作
	priority = rhs.priority;
	return *this;
}

总之,当编写一个拷贝函数时,必须确保:

(1)复制所有的local成员变量;
(2)调用所有基类内的适当的拷贝函数。

●  注意: 有时候两个copying 函数(指定的是 拷贝构造函数 或 重载赋值运算符)的实现代码相同, 这时候你可能会以避免代码重载, 让 这个copying 函数  调用另一个 copying 函数,但是让一个copying 函数调用另一个copying 函数是错误的实现方法 ——  不该令copy assignment 操作符调用copy构造函数。也不该令 copy构造函数调用copy assignment操作符。

所以作者提出如果有两个copying 函数的实现代码相同 重新写一个公共的函数,名为init (在private 中), 在需要时调用。

 


条款13: 以对象管理资源


● 我们都知道,当new一个东西之后,必须delete它。但是问题可能出现在在new和delete之间:比如说delete使用在循环中, 并且被break 或者 goto 语句提前退出函数, 或者提早的执行return 语句,  这样的现象并没有执行到delete 语句, 这样会造成内存泄漏。

一种比较好的作法是通过对象来管理:因为当对象的声明周期结束以后,管理对象会调用析构函数,而在析构函数中delete,这样的作法就靠谱多了。

在标准C++中,定义了2种管理内存资源的对象:auto_ptr和share_ptr。这两个指针都会在资源使用结束后自动销毁它们,而不用你管。我们先介绍它们。(他们需要 #include <memory> ). 


auto_ptr

特别强势的一个指针,独占资源,不允许其他智能指针插手,否则就退出管理,自己不管了。

特性: auto_ptr不支持STL容器,因为容器要求“可复制”,但auto_ptr只能有一个掌握管理权。另外,auto_ptr也不能用于数组,因为内部实现的时候,用的是delete,而不是delete[]。

auto_ptr<int> boss1(new int(10)); // 这个时候boss1对这个资源进行管理
// 这个时候boss2要插手进来,boss1很生气,所以把管理权扔给了boss2,自己就不管事了。
//所以boss2持有这个资源,而boss1不再持有(持有的是null)
auto_ptr<int> boss2(boss1); 
//这种情况下,两者都是持有资源的,因为new执行了两次,虽然内存空间里的初始值是一样的,但地址并不同,所以不算相同的资源。
auto_ptr<int> boss1(new int(10)); 
auto_ptr<int> boss2(new int(10)); 
int *p = new int(10);
2.auto_ptr<int> boss1(p);
3.auto_ptr<int> boss2(p);
执行到第三句时,弹出assertion failed,程序崩溃,
这是由于p指向同一块资源,当boss2访问时,知道p的资源已经被占用了,则将发生assertion failed。

原始指针和智能指针的混用特别烂,一定要避免

但是注意的是: auto_ptr 这个智能指针已经被C++11抛弃了, 正确的应该使用 unique_ptr 智能指针, 它们有类似的地方。unique_ptr 在 C++ primer 中有介绍。

 


shared_ptr 

由此可以得出,因为auto_ptr不允许复制,所以容器就不能使用它。相比之下,使用shared_ptr 可以相互赋值和初始化,也可以用于STL容器。

shared_ptr叫“引用计数型指针”,它与auto_ptr的不同之处在于它能记录到底有多少个对象指向某内存资源,但是它无法解决环状引用,就是两个没用的指针互指。

	std::shared_ptr<int> pt1 (new int (1));
	std:: shared_ptr<int> pt2 (pt1);
	pt1= pt2;  // 正确

 


注意:不能在动态数组上使用这两个指针,因为它们的析构函数中使用的是delete而不是delete[]. 但是有意思的是如果这样仍然可以编译成功。—— 这个是这本书给出的概念。

但是在C++ primer 上已经提出, 可以使用 unique_ptr 和 shared_ptr  这两种 智能指针来管理 new 分配的动态数组。

下面这个程序就是用unique_ptr  智能指针来管理动态数组:

int main()
{
	std::unique_ptr<int[]> up(new int[10]); // up 指向一个包含10个未初始化int 的数组
 
	//因为pp指向一个数组,当pp销毁它管理的指针时,它会自动使用delete[]
	std::unique_ptr<int[]> pp(new int[6]{1,2,3,4,5,6}); // 初始化列表初始化
	for (size_t i = 0; i != 6; ++i)
	{
		cout << pp[i] << " ";
	}
	cout << endl;
	system("pause");
	return 0;
}
  • 与 unique_ptr  不同, shared_ptr 不直接支持管理动态数组。如果希望使用 shared_ptr  管理一个动态数组, 必须提供自己定义的删除器。
  • 下面这个程序使用 shared_ptr 智能指针 来管理动态数组:
int main()
{
	//使用 shared_ptr 来管理动态数组,必须提供一个删除器
	std::shared_ptr<int> sp(new int[3]{1,2,3}, [](int *p) { delete[] p; });
 
	// shared_ptr 未定义下标运算符,并且不支持指针的算法运算
	for (size_t i = 0; i != 3; ++i)
	{
		cout << *(sp.get() + i) << " "; // 所以这里使用 get 获取一个内置指针
	}
	sp.reset(); // 该函数调用我们提供的 lambda 释放数组, 它使用delete[] p
	cout << endl;
	system("pause");
	return 0;
}
 
输出结果为:
 
1 2 3

个人觉得 “ Effective C++ 中文第三版 ” 这本书有点过时了,  本人还是遵守C++11 的新标准。

unique_ptr 和 shared_ptr  智能指针来管理动态数组在C++ primer 中的第425 页。点击 这里 可以查看我的笔记。

 

本条款重点强调的是 “ 以智能指针来管理资源”。


记住:

  • 为防止资源泄漏, 请使用智能指针(分别为 unique_ptr 和 shared_ptr 、weak_ptr )类, 它们在构造函数中获得资源并在析构函数中释放资源。
  • 两个常被使用的RAll classes分别是shared_ptr  unique_ptr 。前者通常是较佳选择, 因为可以执行两个该对象互相copying ,也可以使用另一个智能指针对象初始化另一个。一个 unique_ptr  只能指向一个给定的对象。

 


*条款14:在资源管理类中小心copying行为


本条款我理解的是,虽然标题写的 跟资源管理类有关系, 但是我觉得这只是作者举的一个例子。 实际想说的是当某一个类中的数据成员是一个RAII 对象时, 那么在赋值和拷贝的时候,应该注意的问题。你可以有以下选择:

  • 我们可以禁止拷贝 和 赋值 RAII 对象 , 即在该类的private 区域 手写拷贝构造函数 和 重载赋值运算符。并且不给予实现。

对RAII 对象使用 shared_ptr 智能指针。有时候我们想保留资源,直到它最后一个shared_ptr 被销毁 。这种情况拷贝RAII 对象时, 应该递增 shared_ptr  的引用计数。shared_ptr 便可以做到。

  • 如果说当 shared_ptr 的引用计数为0时,你不想delete 所指向的资源。那么你可以为 shared_ptr 提供一个 “删除器”,该 “ 删除器 ” 是一个可调用对象 ( 可调用对象指的就是 某个函数、函数指针、lambda 表达式、重载了函数调用运算符的类)。当引用计数为0时,可调用对象就会被调用。

注意的是: 当我们 拷贝 或者 赋值一个 RAII 对象时, 应该也要拷贝其所指向的资源。 也就是要进行 “深拷贝 ”, 避免 “浅拷贝”。

还有的时候我们可能希望一个RAII 对象只能指向一个给定对象。即使在RAll 对象被拷贝依然如此。那么可以使用 unique_ptr 智能指针(虽然本书中建议使用的 是auto_ptr , 但是在 C++ primer 这本书中已经指出,auto_ptr 已经被C++11 抛弃,已经过时了, 建议使用 unique_ptr)。 unique_ptr 智能指针不支持直接的拷贝和赋值操作 , 但是可以调用 release 函数  和 reset 函数 ( 是 unique_ptr 独有的操作)将一个 unique_ptr指针的所有权从一个 (非const)unique_ptr 转移到另一个 unique_ptr 上。

 

最后总结一下:

  • 拷贝RAII对象必须一并复制它所管理的资源,所以资源copying行为决定RAII对象的copying行为
  • 拷贝而常见的RAII class copying行为是:禁止copying 行为,施行引用计数法(shared_ptr思想),或者是转移底部资源(unique_ptr 思想). 

最后想说的是看不懂第三章,可以去看 C++ primer 第十二章 ,专门讲解的就是智能指针和动态数组。看完后,再来看本书的第三章。

 


条款15:在资源管理类中提供对原始资源的访问


本条款中说到了 shared_ptr 和 unique_ptr 的 get() 函数 ( 说明一下 本书中提到的智能指针 auto_ptr 已经被C++11 所遗弃,用unique_ptr 指针来代替,unique_ptr  比它有更好的性能),关于get () 函数可以看C++primer 第12 章, 这章专门讲的是智能指针的事。在 414 页对 get ()有详解。



条款16:成对使用new和delete时要采取相同形式


● 当使用new的时候通常有两件事情发生(即通过new动态生成一个对象):

  • 内存被分配出来(通过调用operator new函数);
  • 针对此内存会有一个或多个构造函数被调用。

同样的,当使用delete的时候,也有两件事情发生:
 

  • 针对此内存会有一个或多个析构函数被调用;
  • 内存被释放(通过调用operator delete函数)

new不同对象的空间可以想象成以下实现:

总之,如果调用new时使用了[ ],那么对应的调用delete时也必须使用[ ]; 如果调用new时没使用[ ],那么也不应该在对应delete时调用[ ]。

与此相伴的一个问题是:如果一个人typedef了一个数组,那么在new和delete时就要小心了:

typedef int ARRAY[4];

 

那么就得:

	int* pArray = new ARRAY;
	delete[] pArray;

总之,new和delete的“[]”要成对使用。不过为了避免上述的错误, 还是不要typedef 一个数组。

 


条款17:以独立语句将newed的对象置入智能指针


直接看例子:

int priority()
{
	// Statemets
	return  0;
}
void process(std::shared_ptr<int> pw, int priority)
{

}
int main()
{
	process(std::shared_ptr<int>(new int(42)), priority());
	system("pause");
	return 0;
}

我们虽然不可以向 process 传递一个内置指针,但是可以传递一个  shared_ptr , 这个 shared_ptr 是内置指针显式构造的。但是上面的程序可能发生内存泄漏。

在调用 process 函数之前会做三件事:

  • 调用 priority 函数
  • 执行 “ new int(42) ”
  • 调用 std::sharder_ptr 的构造函数

但是编译器并没有强制性的一定要按实参的顺序初始化。 我们必须在调用 std:: shared_ptr构造函数之前执行 “new int(42) ”表达式,因为它的结果为 表达式作为参数传递给tr1 :: shared_ptr构造函数,但 priority 调用可以排在 第一,第二或第三。 如果编译器选择执行它(可能允许它们生成更高效的代码),我们最终会得到以下操作序列:

  • 执行 “ new int(42) ”
  • 调用 priority 函数
  • 调用 std::sharder_ptr 的构造函数

但是考虑一下,如果对 priority的调用产生异常,将会发生什么。在这种情况下,从“ new int(42)”返回的指针将丢失,因为它不会存储在std::shared_ptr中,我们希望它可以防止资源泄漏。调用 process 时可能会出现泄漏,因为在此期间可能会发生异常 。

 

避免此类问题的方法很简单: 使用单独的语句创建 “ new int(42)” 并将其存储在智能指针中,然后将智能指针传递给process :

std::shared_ptr<int> pp(new int(42));
process(pp, priority()); //这样调用就不会造成内存泄漏

 

 

总结: 使用独立的语句将new出来的对象放在智能指针中,这样更加安全。不然的话一旦有异常抛出,可能导致难以察觉的资源泄漏。

 

 


*条款18:让接口容易被正确使用,不容易被误用


●  “最好的情况,就是如果客户企图使用某个接口而却没有获得预期的行为,这个代码就不应该通过编译;如果代码通过了编译,它的结果就该是客户想要的。”
所以在接口设计时,应该从用户的角度出发,考虑用户会犯什么错误:

假如设计的是一个日期类:

class Date
{
public:
	Date(int m, int d, int y):day(d),month(m),year(y){}
private:
	
	int month;
	int day;
	int year;
};

那么用户在调用构造函数时,可能会犯两种错误:

 

  • 把年月日的顺序输乱了。
  • 可能会输入一个不合法的日期,比如2月30号之类的。

对于这些错误,最好的办法就是定义新的数据类型来取代原来的type:

struct Day
{
	//将构造函数声明为explict,可以避免发生隐式类型转化
	explicit Day(int d):val(d){}
	int val;
};
 
struct Month
{
	
	explicit Month(int m):val(m){}
	int val;
};
 
struct Year
{
	
	explicit Year(int y):val(y){}
	int val;
};

 
class Date
{
public:
	Date(const Month m, const Day d, const Year y):month(m),day(d),year(y){}

private:
	Month month;
	Day day;
	Year year;
};

此时,用户只能这样输入:

Date d1(Month(9),Day(8), Year(2012)); 

这至少避免了参数次序的问题。对于这些参数的取值范围,我们可以使用枚举类型。但是由于枚举类型可以被当做int来使用,所以并不是安全的。比较安全的办法是预先定义所有有效的month:
 

class Month
{
public:
	static Month Jan(){return Month(1);}
	static Month Feb(){return Month(2);}
 
private:
	explicit Month(int m):month(m){}
	int month;
};

这样的话,你只能这样调用:

 Date d1(Month::Jan(),Day(8),Year(2012));

那么出错的机会就大大降低了。总之就是时刻提醒用户知道自己传的参数是什么。


预防用户不正确使用的另一个办法是,让编译器对不正确的行为予以阻止,常见的方法是加上const,比如初学者常常这样写:

if(a = b * c){…}

很明显,初学者想表达的意思是比较两者是否相等,但代码却变成了赋值。这时如果在运算符重载时用const作为返回值,像这样:

const Object operator* (const Object& a, const Object& b);

注意这里的返回值是const,这时编译器就会识别出赋值运算符的不恰当了。

 

书上还提到很重要的一点,就是“尽量令你的自定义类型的行为与内置类型行为一致”,比如说你重载乘号运算符,但里面做的却是加法。自定义类型同时也要形成统一的风格,比如长度,不要有的类型用size表示,有的类型用length表示,这会使得哪怕是一个程序老手也会犯糊涂。虽然有些IDE插件能够自动去寻找相应的方法名,但“不一致性对开发人员造成的心理和精神上的摩擦与争执,没有任何一个IDE可以完全抹除”。

 

总结:

  • 好的接口容易被正确使用,不容易被误用;
  • 促进正确使用的方法包括接口一致性和与内置类型的行为兼容性。
  • 防止“ 误用 ”的方法包括创建新类型、限制对类型的操作、限制对象值和消除客户资源管理责任。

条款19:设计class犹如设计type


●  设计好的class,使之像设计type一样,就是说要使自己设计的类像系统预定义的类那样好用,这对设计思想提出了较高的要求。

要设计高效的class,需要回答以下的问题:

如何创建和销毁新类型的对象?

  • 这需要考虑的是你的构造函数、析构函数的设计。如果类中包含指针,那么还得考虑如何应对指针带来的种种恐怖的问题:比如内存泄露,重复删除指针等等。如果类中有类类型对象,需要在构造函数初始化列表初始化。

对象初始化与对象赋值有何不同?

  • 初始化是构造函数和拷贝构造函数的事(这时候对象还没有产生),但对象的赋值却不同,因为此时等号左边的对象已经存在了。 所以需要知道它们区别在哪, 在合适的地方使用。

类对象是以值传递的方式(一般按const 引用传递)意味着什么?

  • 会调用相应的拷贝构造函数,要注意是否需要一个深拷贝。

您的新类型的合法值有哪些限制?

  • 通常是在构造函数中、重载的赋值操作符函数中、setter 函数中 ,对成员变量的范围给出限定(进行错误检查工作),警惕不安全的输入值!这样来告诉 类对象所能接受的值,不能接受哪些值,如何让用户容易使用,不容易犯错。

你的新type需要配合某个继承图系吗?

就是判断自己设计的class是否需要继承或被继承,是基类还是派生类。如果是基类的话,要注意是否允许这个基类生成对象(是否需要利用纯虚函数设计成抽象类),以及要将析构函数前面加上virtual。

新type需要哪种的类型转换?

  • 说的是你的新type 跟其它类型需要有转换吗?  如果需要有隐式转换,可以专门编写一个类型转换函数 或者 编写一个 带有一个参数的构造函数( 没有 explicit 关键字)。
  • 如果需要显式转换?  那么也可以编写一个类型转换函数, 但是注意的是:  不能编写一个参数就可以调用的类型转换操作符函数 或非显式构造函数。

什么样的运算符和函数对新类型有意义的?

  • 该类中哪些函数需要声明为成员函数,哪些不需要(可以是friend). 

什么成员函数应该被禁止?

  • 是说哪些函数对外公开,哪些函数对内使用,这就是private,public和protected的功能啦,protected只有在有继承关系的类中使用才能发挥它真正的力量,普通的类用private和public就足够了。

谁有权访问你的新类型成员?

  • 说的是类的成员哪些成员可以给自己类使用,也可以给派生类使用,还有的只能给自己使用。根据需要把这些成员声明为 public、private、 protected 其中一个。 如果需要的话, 还可以使用友元类、友元函数。

你的新类型的“未声明接口”是什么?

????????????

你的新Type 需不需要声明为 Type template .

  • 你是否要定义很多类型?如果是,你还不去写个泛型类。否则,你只写一个type就好了;

 你真的需要 重新定义 一个新type吗?

  • 如果您只是为了向现有类添加功能而定义一个新的派生类,那么您最好通过定义一个或多个非成员函数或模板来更好的达到目标。

 


条款20:宁以pass-by-reference-to-const 替换 pass-by-value 


●   在默认情况下, C++ 都是以值传递的方式传递对象到函数。  除非你另外指定传递方式,否则函数参数将用实际参数的副本初始化,函数调用方将返回函数返回的值的副本。这些副本由该对象的拷贝构造函数生成的。这会使传递值成为一个高代价的操作。

举书上的例子:

class Person
{
private:
	string name;
	string address;
public:
	Person() {}
	virtual ~Person() {}
};

class Student : public Person
{
private:
	string schoolName;
	string schoolAddress;
public:
	Student() {}
	~Student() {}
};

void PrintStudent(Student ss) // 以值传递对象
{

}
int main()
{
	Student s;
	PrintStudent(s);

	system("pause");
	return 0;
}

形参ss就会把实参s完完整整地复制一遍, 此时会调用 Student 对象的构造函数。 但是这不是全部,Student对象中有两个string对象,因此每次构造Student对象时,您还必须构造两个string对象。Student对象还继承自Person对象,因此每次构造Student对象时,您也必须构造Person对象。Person对象内部也有两个string 对象,因此每个Person构造也要两个string 构造。最终的结果是,按值传递Student对象会导致对Student复制构造函数的一次调用,对Person复制构造函数的一次调用,以及对字符串复制构造函数的四次调用。当销毁Student对象的副本时,每个构造函数调用都与析构函数调用匹配,因此按值传递Student的总成本是6个构造函数和6个析构函数!

 

那有什么办法可以在传递对象的时候,避免这些构造 和 析构 的操作就好了:

正确的方法可以是: 对象 以 reference-to-const  的方法传递,  或者是 指针的形式传递。这种传递方式的效率高得多: 没有任何构造函数或析构函数被调用, 因为没有任何新对象被创建。

 

●   那么以 引用 的形式传递对象可以避免 对象切割问题。当派生类对象作为基类对象传递(按值)时,它会被视为一个基类对象。调用的是基类拷贝构造函数,使派生类对象中新添加的成员(包括数据成员和成员函数)能被“ 切掉 ”。你只剩下一个简单的基类对象——这并不奇怪,因为它是由基类构造函数创建的。这几乎不是你想要的。

 

看书中的例子:

class Window 
{
public:
	std::string name() const; // return name of window
	virtual void display() const; // draw window and contents
};
class WindowWithScrollBars : public Window
{
public:
		virtual void display() const;
};

void printNameAndDisplay(Window w) 
{ 
	std::cout << w.name();
	w.display();
}
int main()
{
	WindowWithScrollBars wwsb;
	printNameAndDisplay(wwsb); // 调用的是基类中的该函数
	system("pause");
	return 0;
}

当你调用上述函数并传递它一个WindowWithScrollBars 对象,会发生什么事呢?

参数 w 会被构造成为一个window对象; 因为它是以 passed by value 的方式。 作为一个Window对象,所有让wwsb像WindowWithScrollBars 对象一样的专门信息(数据)都将被切掉。在PrintNameDisplay中,无论传递给函数的对象是什么类型,w都将像类Window的对象一样工作(因为它是类Window的对象)。特别是,在printNameAndDisplay内部显示的调用将始终调用Window::display,而不是WindowWithScrollBars::display。

如果你深入了解c++编译器的底层,会发现引用通常是作为指针实现的,因此通过引用传递某些内容通常意味着是在传递指针。所以两者看上去不同,但底层的原理却是相同的,就是对一个对象的地址进行操作是,传引用的本质是传对象的地址。传对象的地址是对原有对象直接操作,而不是它复本,所以这其中没有任何构造函数发生,是一种非常好的传参方法。

那么防止对象在传递的过程中被切割,应该使用 引用的方式传递。这样 传递给形参是什么类型, 它将表现的是什么类型。

因为本质传的是地址,所以不存在对象的切割。

其实也可以简单的理解成,如果不加引用或指针,那么形参就会复制实参,但形参是基类的,它没有实参多出来的那部分,所以它就不能复制了,只能丢弃了;但如果加了引用或指针,那么无论是什么类型的,都是保存实参的地址,地址是个好东西啊,它不会发生切割,不会丢弃。

总结一下:

pass-by-reference-to-const的优点:一是可以节省资源复制的时间和空间,二是可以避免切割,可以触发多态,在运行时决定调用谁的虚函数。

那么是不是pass-by-reference-to-const一定好呢?答案显示是否定的。一是任何事物都不是绝对的,二是C++默认是pass-by-value,自然有它的道理。

可以这样解释,因为pass-by-reference传的是地址,在32位机上,就是4字节,但如果参数是一个字节的char或者bool,那么这时候用char&或bool&来传引用就不划来了。一般地,当参数是基本类型时,还是用pass-by-value的效率更高,这个忠告也适用于STL的迭代器和函数对象。

对于复杂的类型,还是建议pass-by-reference-to-const,即使这个类很小。但事实上,一个类看似很小,比如只有一个指针,但这个指针也许指向的是一个庞然大物,在深拷贝的时候,所花费的代价还是很可观的。另一方面,谁也不能保证类的内容总是一成不变的,很有可能需要扩充,这样原本看似很小的类,过一段时间就成了“胖子”了。

 

最后总结一下:

  • 尽量以pass-by-reference-to-const来代替pass-by-value,前者通常比较高效,并可以避免切割
  • 以上规则并不适用于内置类型、STL迭代器和函数对象,对它们而言,pass-by-value是更好的选择

 


第一部分完结...

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值