最近由于工作的需要,让我重拾C++编程语言;重温了Bjarne Stroustrup的大作《The C++ Programming Language》,仍然还有很多东西不是十分明白,但还是希望能够把所学的经验总结下来。虽然这本书不适合C++语言初学者,但是书中的很多细节都能让一个有经验的程序开发人员获益匪浅。
其中关于本书的第二部分“抽象机制”(Abstraction Mechanisms)在精读了三遍之后,我个人觉得作者在写这部分的时候有一个主线就是“如何能让开发人员有效地设计和开发类(一个类或类层次结构)”。围绕这个主线,看下本书第二部分的目录结构,也会大致猜出为什么作者要把章节的顺序安排成这个样子。
类层次结构的基础当然是如何有效地定制一个类;第十章第三节做了如下的描述:
引用
1. 构造函数【与析构函数】(方括号部分是我自己加的)
2. 一组类成员查看函数(const标记)
3. 一组类成员操作函数,当然这部分也包括运算符重载,使得操作起来感觉更自然
4. 一组隐式定义的函数,可以使定义类自由地复制(拷贝构造函数和拷贝赋值操作)
5. 一个与该定义相关的异常类,用于通过异常类报告或者处理出错的情况
有效地这样的一个描述针对设计一个类显然具有典型意义,但是类定制与类的设计并不仅限于此。在没有考虑更深的类层次结果设计之前,上述的每一步都值得探究一番。
1. 构造函数【与析构函数】
除了考虑默认构造函数,成员应该如何初始化以及如何销毁之外,应该还需要考虑这个类实例对象的建立方式与对应的销毁方式。那么到底有哪些建立及相应的销毁方式呢?Bjarne Stroustrup给出如下几种方式:
引用
A. 一个命名的自动对象,每次程序执行到其声明时建立、程序离开它所出现的块时销毁;
B. 一个堆对象,通过new建立,通过delete销毁(注意:一定要成对使用,如果在调用new时,有中括号,相应地delete必须加中括号。简单地讲,用delete销毁数组时,会有内存泄漏产生,那么用delete[]销毁一个非数组对象时,更会招致内存泄漏或“不确定性为”更大的麻烦。
C. 一个非静态成员对象,作为另一个类对象成员,在它作为成员的那个对象建立或销毁时,它随之被建立和销毁
D. 一个数组元素,在它作为元素的那个数组被建立和销毁的时候建立和销毁;
E. 一个局部静态对象,在程序执行第一次遇到它的声明时建立一次,在程序终止时销毁一次;(注意,STATIC关键字的语义,一旦静态变量被创建,其生命周期与程序生命周期相同)
F. 一个全局对象、名字空间对象,类静态对象,它们只在“程序开始时”建立一次,在程序终止时销毁一次。(这里需要引起注意的是,静态变量的跨编译单元的初始化次序是未定义的,因此《Effective C++》给出的建议是用局部静态对象替换非局部静态对象。)
G. 一个临时对象,作为表达式求值的一部分被建立,在它所出现的那个完整表达式的最后被销毁。通常情况下,我们尽量避免临时对象的产生,要知道浪费在临时对象的创建与销毁的开销对于那些性能要求严格的应用程序来说,的确不是什么好的主意。
H. 一个在分配操作中由所提供的参数控制,在通过用户提供的函数获得的存储里放置的对象。
I. 一个union成员,它不能有构造函数和析构函数。
在进行详细设计时,必须考虑到类对象与类对象之间的耦合关系或者依赖关系,因为这些关系在很大程度上决定了这种类建立或者销毁的方式。
针对A方式来说,这种方式能够帮助我们设计管理资源的类,因为通过这种方式即使在抛出异常的情况下,也能释放掉类管理的资源。而利用A方式的技巧,C++赋予了一个非常好听的名字“资源获得即初始化”(Resource Acquisition Is Initialization: RAII)。当然方式的利用方式的具体表现形式C++提供了大致两种:
i. 创建auto_ptr, std::tr1::shared_ptr<T>;
ii. 创建管理资源对应的类,如:
class File_ptr{
FILE* p;
public:
File_ptr(const char* n, const char *a) { p = fopen(n,a); }
File_ptr(FILE *pp) { p = pp; }
~File_ptr(){ fclose(p); }
operator FILE*() { return p; } //函数调用
};
一个类的构造函数创建时,还需要进一步考虑如何初始化成员变量和初始化的次序;建议使用成员初始化列表,为什么?这里的主要原因是因为拷贝构造函数和拷贝复制操作的语义引起的;简单地看下面的代码:
#include <iostream>
#include <string>
#include <list>
using namespace std;
class PhoneNumber { ... };
class A {
public:
A(const string& name, const string& address, const list<PhoneNumber>& phones);
private:
string theName;
string theAddress;
list<PhoneNumber> thePhones;
int numTimesConsulted;
}
A::A(const string& name, const string& address, const list<PhoneNumber>& phones)
{
theName = name;
theAddress = address;
thePhones = phones;
numTimesConsulted = 0;
}
上面的这个构造函数定义简直再熟悉不过,当然A对象会带有你期望初始化的值,但不是最佳做法。为什么呢?实际上,在A构造函数内,theName, theAddress和thePhones都不是被初始化,而是被赋值。初始化发生的时间更早,发生于这些成员的默认构造函数调用之时。所以这些成员先是通过默认构造函数创建并初始化,然后通过A构造函数的实参进行拷贝操作完成赋值操作的。如果通过成员初始化列表的方式来进行的话,就等同于直接将实参传递给各成员的构造函数进行创建并初始化的工作。因此,通常情况下,后者的效率远远高于前者。
在成员初始化列表中的成员初始化的次序可以任意指定,但是构造函数不会理会这个你指定的次序,而总是按照类变量在其类声明中的次序依次进行。
在上面的描述中,我提及到了默认构造函数,好吧,C++真的是帮助开发人员做了许多内部工作。如果任何一个类在声明时并未构造任何实际的构造函数的话,C++会替我们创建一个编译器产生的无参构造函数,这个构造函数就被称为默认构造函数。一旦类声明中包含有其他带有参数的构造函数,默认函数就不会被创建了。这个原因很简单,编译器通过读取类声明,知道它如果再帮你产生这样一个默认构造函数无异于画蛇添足。除了默认构造函数外,C++编译器还会帮我们默认创建拷贝构造函数,拷贝赋值操作和析构函数,如果这些都没有在类声明中声明的话。针对拷贝构造函数、拷贝赋值操作和析构函数,我会在下面讲解中更详细的描述我的总结。
拷贝构造函数与拷贝赋值操作
class Order {
public:
Order(); // default contructor
Order(const Order& _o); // copy constructor
Order& operator=(const Order& _o); //copy assignment operator
...
};
Order o1; // call default constructor
Order o2(o1); // call copy constructor
o1 = o2; // call copy assignment operator
Order o3 = o2; // call copy constructor
幸运的是,这两者还是可以区别开来的,虽然看上去感觉很容易迷惑。如果一个新对象被定义(例如上述语句中的o3),一定会有个构造函数被调用,不可能调用赋值操作,如果没有新对象被定义(例如上述语句中o1=o2),就不会有构造函数被调用,那么当然就是赋值操作被调用。
拷贝构造函数在C++语言中绝对需要引起注意,因为这个函数如果稍微不加注意,便会引起不必要的麻烦。通常编译器产生的拷贝构造函数和拷贝赋值操作都是浅拷贝。所谓浅拷贝的语义是memwise copy。简单点说,会复制类对象中的每一个成员。如果类声明中,只是包含一些简单的内置类型,如int,double等,浅拷贝的语义是正确的。但是一旦类中涉及指针变量或者引用变量,浅拷贝的语义就是简单地拷贝指针的内容而不会拷贝指针(引用)所指向(引用)的内容。
看起来浅拷贝的语义是正确的!难道这种语义针对指针成员变量或者引用成员变量,会引起什么问题吗?当然会引起问题,而且引起的问题不小。看下面的代码:
class Name{
const char *s;
// ..
};
class Table {
Name *p;
size_t sz;
public:
Table(size_t s=15) { p = new Name[sz=s]; }
~Table() {delete[] p;}
Name* lookup(const char *);
bool insert(Name*);
};
void h()
{
Table t1;
Table t2 = t1;
Table t3;
t3 = t2;
}
观察上述代码,t2=t1调用了Table的拷贝构造函数,由于p是一个Name指针,所以t2当中的p会指向t1对象中p所指向的Name(默认为15个大小的数组),这个数组中的内容不会被拷贝两份,当t1对象和t2对象相继被销毁时,Table的析构函数会相继被调用两次,那么delete[]就会再次试图销毁已经被销毁的Name数组。这就会使程序运行到一个不确定的行为,后果很严重。所以,如果类声明中包含了指针(引用)成员变量时,建议开发人员视情况自己定义拷贝构造函数与拷贝赋值操作。
有时候我们对C++编译器默认产生这些构造函数的帮助实在是盛情难却,但的确它们的产生又给程序造成了影响。当然,没有冒犯编译器大人的意思,那么请用private修饰拷贝构造函数和拷贝赋值操作吧,这样就表示你给编译器一个明显的信号,告诉它不要为我的类产生这两个函数了。注意,上面的这句话确切说是有点错误的。不是编译器不自动产生,还是会自动产生的,但是通过private修饰,可以成功地组织别人调用它)。当然这么做,也不绝对安全,为什么呢?因为一些friend类或者成员函数仍然可以访问这个类的私有成员。不过,到现在为止,还没有什么其他更好的办法。如果你看到了更好的办法,一定要告诉我!
析构函数
析构函数相对来说注意的问题相对来说简单,总结呢主要有两个:
1. 多态基类声明析构函数时,一定要加virtual修饰
2. 析构函数中一定要捕获所有异常,不要让异常逃离析构函数
3. 针对构造函数和析构函数,不要在调用过程中使用virtual函数。
上述三个注意事项请参考《Effective C++》
分享到:
相关推荐
在Java编程语言中,创建字符串缓存类是一个常见的优化策略,尤其在处理大量字符串操作时。这是因为Java中的字符串是不可变的,每次对字符串进行修改都会生成一个新的对象,这可能会导致内存消耗增加和性能下降。为了...
一旦有了WSDL文件,我们就可以在ABAP中导入它,创建一个对应的IF服务接口(IF_SERVICE_INTERFACE)。 在ABAP开发工作台中,我们可以使用服务消费向导(Service Consumption Wizard)来简化这个过程。该向导会分析...
在编程领域,尤其是在Java语言中,创建一个存储若干随机整数的文本文件是常见的任务,这涉及到文件操作、随机数生成以及用户输入等基础知识。在这个课题中,我们主要关注以下几个关键知识点: 1. **文件操作**:在...
设计一个产品类 Product ,允许通过如下方式来创建产品对象: 通过指定产品名创建; 通过指定产品名和产品价格创建; 通过指定产品名、产品价格、出厂日期(对象成员)创建; Product 还应该包含如下属性:生产厂家...
1. **创建新线程类**:创建一个继承自CWinThread的类,比如`CDialogThread`。在这个类中,我们需要重写`InitInstance()`函数来完成新线程的初始化工作,包括创建对话框实例。 2. **对话框类**:定义一个非模态对话...
当你创建一个新的类并直接继承`Thread`类时,你可以重写`run()`方法来定义线程执行的代码。例如: ```java public class MyThread extends Thread { public void run() { // 这里编写线程执行的代码 } } ``` ...
我们可以通过实现`Runnable`接口创建一个代表反转任务的类,并将其提交到线程池进行执行。 以下是一个简单的实现步骤: 1. 创建一个`FileReverser`类,实现`Runnable`接口。该类包含一个`File`对象,表示需要处理...
- **实现Runnable接口**:创建一个实现了Runnable接口的类,然后将其实例传递给Thread的构造器,同样可以启动新线程。这种方式更灵活,因为Java不支持多重继承,但可以与其他接口一起使用。 - **实现Callable接口...
1. 定义接口:首先,我们需要在DLL中定义一个基类,这个基类包含一组虚函数,这些函数构成了接口。基类的声明不应包含任何成员变量,因为这可能导致大小不一致的问题。 2. 导出类:将基类和派生类声明为`__declspec...
3. **创建类(Class)**: 包创建完成后,接下来是创建Java类。在刚创建的包上右键,再次选择`New`,然后选择`Class`。在弹出的窗口中,输入类名,同样遵循驼峰命名法,例如`MyFirstClass`。你可以勾选“public ...
在C#中,为了方便地管理和操作SQL Server数据库,我们可以创建一个数据库连接的公共类,封装常用的操作方法。这个公共类可以大大提高代码的复用性,减少重复的工作,使得项目更加整洁高效。 首先,我们需要引入`...
然而,继承Thread类的方式有一个缺点,即Java不支持多重继承,这意味着如果一个类已经继承了另一个类,就不能再继承Thread类,这限制了类的继承结构。 接下来是实现Runnable接口的方式。通过实现Runnable接口来创建...
最后,“使用抽象类”是创建类层次结构的一种方式。抽象类可以包含抽象方法(没有实现的方法),并且不能被实例化。子类必须提供抽象方法的实现。例如: ```csharp abstract class AbstractClass { public ...
文章强调了一个关键概念——使用一个特定的C++类来简化服务程序的开发过程。这种方法的核心优势在于,开发者只需要重写几个基本类中的虚拟函数就能实现自己的服务程序。此外,文中提供了三个示例源代码: - **NT...
当一个类实现了`Cloneable`接口,并且重写了`Object`类中的`clone()`方法,那么这个类的对象就可以被复制。`clone()`方法会创建与当前对象具有相同属性的新对象,这是浅拷贝,如果对象内部有引用类型,只复制引用,...
1. 创建一个新的QT项目,选择"Shared Library"模板,比如命名为`dll`。 2. 在`dll.pro`文件中配置相应的编译选项,确保库是动态链接的。 3. 在头文件中定义你需要导出的类和方法,使用`__declspec(dllexport)`关键字...
以上代码定义了一个`MenuConfig`类来存储菜单的结构,然后提供了一个`CreateMenu`方法,它接受配置和父菜单项作为参数,动态创建菜单并递归处理子菜单。这样,我们就可以根据运行时的配置动态构建复杂多层的菜单。 ...
这涉及到启动程序,然后选择“新建”以创建一个新的模型文件,定义模型的基本信息,如模型名称、作者和描述。 2. **选择模型类型**: 面向对象模型通常选择为"Object Model"或"Class Model"。这会初始化一个空的...
首先,我们需要创建一个DLL项目。在Visual Studio中,选择"新建项目",然后在模板中找到"C#类库"项目。给项目命名,例如"myDLL",并点击"创建"。在新创建的类库项目中,我们可以编写我们要封装的公共方法或类。 ...
首先,我们需要创建Thread类的一个实例,并传递一个委托(Delegate)到构造函数。这个委托指向线程需要执行的方法。例如: ```vb Dim newThread As New Thread(AddressOf MyThreadProc) ``` 其中`MyThreadProc`...