《C++编程惯用法——高级程序员常用方法和技巧》——2.1 构造函数

简介:

本节书摘来自异步社区出版社《C++编程惯用法——高级程序员常用方法和技巧》一书中的第2章,第2.1节,作者: 【美】Robert B. Murray ,更多章节内容可以访问云栖社区“异步社区”公众号查看。

2.1 构造函数

构造函数中有着比我们所看见的还要多的细节。除了程序员所编写的代码之外,构造函数还可以调用其他的构造函数来初始化对象中的基类对象和数据成员。即使程序中并没有明确的调用,编译器也可以向代码中插入构造函数的调用代码(例如:当程序中有着隐式的数据转换时)。在本节中,我们将关注一些和构造函数相关的常见问题,这些问题中的部分会导致程序的执行速度变慢,另外一些则可能会导致程序中bug的产生。

在阅读本章时,你最好能够时刻记住“初始化”和“赋值”这两者之间的区别(详情参见下面的“回顾”)。你同时最好也不要被“缺省构造函数”(不需要任何参数就可以被调用的构造函数,它要么是在声明时就没有参数,要么就是在声明时它的所有参数都被赋予了缺省的值)和“缺省的复制构造函数”(由编译器自动合成的复制构造函数)这些术语给迷惑住。

回顾:初始化和赋值

在C++中,当一个新对象被创建时,会有初始化操作出现;而赋值是用来修改一个已经存在的对象的值的(此时没有任何新对象被创建)。

Thing t = x;     //初始化(有新对象被创建)
t = x;        //赋值(已有对象的值被改变)

初始化出现在构造函数中,而赋值出现在operator=操作符函数中。

C++中还有着一种特殊的初始化:用同一个类产生的另外一个对象的值来为将要被创建的对象进行初始化。执行这样的初始化操作的构造函数也被称为复制构造函数,它们通常都有着如下的格式:X::X(const X&)。

//一个复数类:
class Complex{
//此处忽略细节
public:
   Complex(double,double);
   Complex(const Complex&);    //复制构造函数
};

如果我们没有在类中声明一个复制构造函数,那么编译器就会为我们合成一个。这个缺省的复制构造函数会将新对象中的每个数据成员初始化为源对象中相应成员的值。

当我们使用传值的方式来调用函数时,编译器会自动产生调用复制构造函数的代码。被生成的临时对象在函数返回时将会(通过调用析构函数)被摧毁:

double abs(Complex);        //使用传值(而不是传址)的方式向函数传递参数
Complex c(0.0,1.0);
double d = abs(c);

在上面的代码中,我们通过调用Complex::Complex(const Complex&)传递了c的一个拷贝给abs。当abs返回时,这个拷贝将会被Complex的析构函数(如果有的话)所摧毁。
2.1.1 缺省的复制构造函数的行为是否符合我们的要求?
当我们编写了一个新类时,请注意这两点:缺省的复制构造函数和赋值操作的行为是否符合我们的预期要求。如果不是的话,我们就将不得不声明和定义我们所需要的这两个函数。

通常(但不是所有的)情况下,当对象的所有状态都存储在对象中时,缺省构造函数的所作所为都和我们所预期的一样。例如,我们来看看复数类Complex的实现,它的对象中用两个double存储了复数的状态:

class Complex {
private:
   double real;
   double imag;
public:
   Complex(double r,double i):real(r),imag(i){}
//此处忽略细节
};

由于我们没有明确声明复制构造函数,因此编译器将会为我们合成一个缺省的复制构造函数。它的行为就是复制这两个数据成员,而这正是我们这个类所期望的操作。

在另外一方面,假设我们有一个类String,在其中有一个char*的数据成员,我们用它来指向String类对象所代表的字符串:

//在String.h中:
class String {
private:
  char*data;
public:
  String (const char*cp="");
  ~String(){ delete [] data;}
};
//在String.c中:
String::String(const char* cp)
:data(new char[strlen(cp)+1]) {
  strcpy(data,cp);
}

在上面的例子中,String类的缺省复制构造函数将会仅仅对那个指针进行复制,最终导致两个String对象指向同一块内存。这种结果并不是我们期望的,一旦第一个String对象被摧毁,那么被指向的内存也将同时被释放,这时剩下来的那个对象将发现,它所拥有的指针成员指向的是已经被释放了的内存!

如果缺省的行为和我们预期的不一样,我们就必须明确地声明和定义一个复制构造函数:

//在String.h中:
class String {
private:
  char*data;
public:
  String(const char*="");
  string(const String&);
  ~String(){delete[]data;}
};

//在String.c中:
String::String (const String& s)
:data (new char[strlen(s.data)+1]){
  strcpy (data,s.data);
}

这样我们就可以确保每个String都将拥有一份数据的私有拷贝。

在某些情况下,即使有新对象被创建时,由于某些特殊的操作的缘故,我们也不能使用缺省的复制构造函数。假设我们有一个类File,我们用它来表示一个文件描述符(也就是一个用于文件I/O操作的句柄)。有些应用程序可能需要知道在某个给定的时间内到底存在着多少个File的对象(这样做可能是为了避免打开的文件数超过允许的文件描述符上限)。我们可以很容易做到这一点:只要让File维护一个当前存在的File对象个数的计数器就可以了。在File的构造函数中我们会对这个计数器进行递增,在析构函数中对计数器则是递减:

//在File.h中:
class File{
  static int open_files;
//此处忽略细节
public:
  File(const String&filename, const String& modes);
  File(const File&);
  ~File();
  static int existing(){return open_files;}
};
//在File.c中:
int File::open_files = 0;
File::File(const String& filename, const String& modes){
  ++open_files;
  //此处忽略细节
}

File::~File(){
  --open_files;
  //此处忽略细节
}

静态成员函数File::existing()将返回现有的File对象的计数。

为使这样一个方案生效,每个File构造函数都必须更新计数器。缺省复制构造函数不做这个工作,所以我们必须自己来写:

File::File(const File&f){
  ++open_files;
  //此处忽略细节
}

上面代码中的静态函数File::existing()将会返回当前存在的File对象的个数。

对于缺省复制构造函数是否能够工作这个问题,我们并没有一个通用的规则。一种从经验中得到的方法就是:对那些包含指针的类要“另眼相待”。如果被指向的对象是“属于”该产生的对象,那么缺省的复制构造函数就有可能是错误的,因为它只是简单地复制了指针而不是指针所指向的对象。

2.1.2 复制构造函数不可忽略

即使我们的代码从来不会去调用复制构造函数,我们也不能忽略掉它。请记住,那些使用我们所编写的类的用户可能会去调用类的复制构造函数(他们有可能是通过创建新对象来显式地调用它,也可能是通过用传值方式向函数传递参数来隐式地调用它)。如果确实因为某些原因,使得为类实现复制构造函数变得非常困难,那么请把它声明为私用的,并且不要为它提供任何的定义:

class Cant_be_copied {
private:
  Cant_be_copied(const Cant_be_copied&);//没有定义体
//此处忽略细节
};

一旦我们这样做了,我们至少可以确信那些无意间调用到复制构造函数的代码将会被编译器(用户代码)或者连接器(类成员或者友元代码)给找出来。虽然这样做并不是很好,但比起那些默默地执行编译器合成的缺省复制构造函数的错误代码来说,它要好得多了。

2.1.3 类成员的初始化

当类中的某个数据成员本身也是一个类对象时,我们应该避免用赋值操作来为该成员进行初始化:

class Employee {
private:
   String name;
public:
   Employee(const String&);
};
//效率不高的构造函数:
Employee::Employee(const String& n){
   name = n;
}

虽然这样做构造函数也能得到正确的结果,但它的效率却不能达到它本来应该达到的标准。当一个新的Employee对象被创建时,成员name先将会被String的缺省构造函数所初始化,然后在Employee的构造函数中,它的值又会因为赋值操作而再一次改变。这是两个不同的步骤,不过我们可以把它合并到一个步骤中去:我们可以通过使用初始化语法(initialization syntax)来显式地为name进行初始化(详情参见下面的回顾):

Employee::Employee(const String& n)
: name(n) {
}

回顾:成员初始化

缺省情况下,在构造函数的函数体被执行前,对象中的所有成员都已经被它们的缺省构造函数所初始化了。那些没有构造函数的成员则将拥有一个未定义的初始值。

编写构造函数的人可以对这种行为进行更改,方法是:在构造函数定义中的参数列表结束的括号后面增添一个冒号以及一个初始体(initializer)列表。每个初始体都包括一个名字以及一个参数列表,这其中的名字就是要创建的类中成员或其基类的名字:

class String {
public:
     String();
     String(const String&);
     //此处忽略细节
};
  
class Employee {
private:
     String name;
public:
     Employee(const String&);
};
 
Employee::Employee(const String& nm)
: name(nm) {
//…
}

下面的代码会告诉编译器只使用一次函数调用来初始化name这个成员:

String::String(nm);

那些内建类型的成员也可以用这种语法来进行初始化,此时的参数列表必须是一个用来指定初始值的简单表达式。
现在这个构造函数只需要进行一次关于String的操作(也就是一次初始化),而原来的那个则需要两次(一次初始化以及一次赋值)。在我的计算机上面,对Employee的构造函数进行这样的改变可以获得大概30%的效率提升。

当我们在编写构造函数的定义时,请在写完正式的参数列表后停下来一会,想一想有多少成员可以使用构造函数的初始化语法。通常我们都可以发现所有的成员都可以用这种方法来进行初始化,而当我们写到构造函数的函数体时,将会发现其实我们什么也不需要做!这是一个好的信号:我们的构造函数已经为我们类的成员们选择了一个正确的初始值。

不是类对象的成员
初始化语法同样也可以用来对不是类对象的成员进行初始化:

class Employee {
private:   
   String name;
   int  salary;
public:
   Employee(const String&,int);
};

Employee::Employee(const String& nm, int sal)
:name(nm),salary (sal) {
}

由于内建类型没有构造函数,使用初始化语法来对整型成员salary进行初始化并不能获得比赋值更高的效率,但这样做(使用同样的方法来为所有的数据成员进行初始化)会使得代码的可读性更高。

成员初始化的顺序
C++中规定,一个类中成员的初始化顺序和它们在类中被声明的顺序(而不是构造函数定义中的顺序)必须是一致的。通常情况下,这种顺序问题都不会有什么影响,但在某些场合下,它将导致产生问题——例如:某个成员的初始化过程中使用了另外成员的值。

让我们来考虑一个改进后的Employee类,它包括Employee的名字和身份认证代码:

class Employee {
private:
   String name;
   long  id;
public:
   Employee(const char*name);
   Employee(long id);
//此处忽略细节
};

我们编写了两个构造函数,一个以职工的名字作为参数,一个以职工的身份认证代码作为参数。每个构造函数都会从一个职工花名册中查找参数中没有给出的那份信息。

下面是我们对构造函数定义的一种(不正确的)尝试:

extern String lookup_employee(long);
extern long lookup_employee(const String&);

Employee::Employee (const char*n)
:name(n), id(lookup_employee(name)){
}

Employee::Employee(long i)
:id(i),name(lookup_employee (id)) {//运行时错误
}

上面的第二个构造函数可以顺利地通过编译,但却不能正常工作。因为在类声明中,成员name出现在成员id的前面,这确保它每次都会被第一个初始化——即便是我们在构造函数的定义中把id的初始化代码写在它的前面!这意味着在第二个构造函数中,lookup_employee实际上在id被初始化前就被调用——这时它的参数也就将是一个毫无意义的随机数值。在此例中,这个问题很容易被解决:我们可以使用外界传递过来的参数,而不是类中的成员:

Employee::Employee (long i)
: id(i),name(lookup_employee(i)) { //OK
}

但不是每次我们都可以这么容易地解决问题;在某些情况下,我们可能不得不对类的定义进行重新整理。如果成员的顺序意义重大的话,我们最好在头文件中把它记录下来,这样在随后的对类的声明进行重新排列过程中,我们并不会导致某些构造函数的工作出现问题[1]。

编译器忽略掉构造函数中的初始体顺序这个现象看起来好像有违我们的直觉,但这却是C++要求“对象的析构过程必须和其创建过程相反”得到的结果。如果构造函数中指定了一个特殊的构造顺序,那么析构函数将不得不去查询构造函数的定义,以获得如果对成员进行析构的顺序。由于构造函数和析构函数可以在不同的文件中定义,这就将给编译器的实现者造成一个难题。更糟糕的是,一个类可以有两个或者更多的构造函数,对于这些构造函数的定义,我们并不能保证它们中的成员出现的实现都是一致的。然而,我们可以确保类的声明在所有的文件中都将有效,并且不同的文件中出现的声明都是一致的(否则就将造成整个程序的行为无法预期);所以我们使用类的声明来解决成员的构造和析构顺序。

类成员的引用
如果在类中的某个非静态数据成员是一个引用的话,因为所有的引用都必须被明确地初始化,所以我们必须在该类的每个构造函数中都使用初始化语法:

class Payroll_entry {
private:
   Employee& emp;
public:
   Payroll_erxtry(Emplyee&);
};

Payroll_entry::Payroll_entry(Emplyee& e)
{ //编译错误:“emp”必须被初始化
}

此时我们必须问自己,为什么我们要将emp声明为Employee&,而不是Employee*呢?除了语法上的不同之外,将emp声明为引用而不是指针将会在两方面对它的使用造成限制:

我们必须在创建emp时就为它绑定一个职工(并不存在着一个为“0”或者为“null”的引用);
一旦我们将它和一个职工绑定后,emp就不可能再被用来绑定到另外一个职工上面。
通过将emp声明为引用,如果我们的代码中试图违反上面的两条规则,编译器就将用编译期的错误来帮我们找到它们。使用引用而不是指针从编译器那能够获得的只是编译期的检测,它们实际产生的代码其实并无区别

相关文章
|
28天前
|
安全 算法 C++
【C/C++ 泛型编程 应用篇】C++ 如何通过Type traits处理弱枚举和强枚举
【C/C++ 泛型编程 应用篇】C++ 如何通过Type traits处理弱枚举和强枚举
46 3
|
30天前
|
安全 算法 编译器
【C++ 泛型编程 进阶篇】深入探究C++模板参数推导:从基础到高级
【C++ 泛型编程 进阶篇】深入探究C++模板参数推导:从基础到高级
246 3
|
30天前
|
算法 编译器 数据库
【C++ 泛型编程 高级篇】使用SFINAE和if constexpr灵活处理类型进行条件编译
【C++ 泛型编程 高级篇】使用SFINAE和if constexpr灵活处理类型进行条件编译
246 0
|
30天前
|
设计模式 程序员 C++
【C++ 泛型编程 高级篇】C++模板元编程:使用模板特化 灵活提取嵌套类型与多容器兼容性
【C++ 泛型编程 高级篇】C++模板元编程:使用模板特化 灵活提取嵌套类型与多容器兼容性
258 2
|
7天前
|
编译器 C++
C++编程之美:探索初始化之源、静态之恒、友情之桥与匿名之韵
C++编程之美:探索初始化之源、静态之恒、友情之桥与匿名之韵
19 0
|
27天前
|
编译器 C语言 C++
【c++】类和对象(三)构造函数和析构函数
朋友们大家好,本篇文章我们带来类和对象重要的部分,构造函数和析构函数
|
29天前
|
存储 移动开发 安全
【C/C++ 口语】C++ 编程常见接口发音一览(不断更新)
【C/C++ 口语】C++ 编程常见接口发音一览(不断更新)
22 0
|
29天前
|
算法 编译器 C++
【C++ 模板编程 基础知识】C++ 模板类部分特例化的参数顺序
【C++ 模板编程 基础知识】C++ 模板类部分特例化的参数顺序
21 0
|
29天前
|
机器学习/深度学习 人工智能 算法
【C++ 职业方向】C++ 职业方向探索:工作职责、编程技能与MBTI人格匹配
【C++ 职业方向】C++ 职业方向探索:工作职责、编程技能与MBTI人格匹配
163 1
|
29天前
|
安全 编译器 程序员
【C++ 泛型编程 高级篇】C++ 编程深掘:静态成员函数检查的艺术与实践
【C++ 泛型编程 高级篇】C++ 编程深掘:静态成员函数检查的艺术与实践
63 0

热门文章

最新文章