Skip to content

Latest commit

 

History

History
694 lines (509 loc) · 31.1 KB

File metadata and controls

694 lines (509 loc) · 31.1 KB

六、初始化和清理

第 4 章通过将一个典型的 C 库的所有分散组件封装到一个结构中(一种抽象数据类型,从现在开始称为,对库的使用做了重大改进。

这不仅提供了库组件的单一统一入口点,而且还隐藏了类名中的函数名。在 第五章 中,介绍了访问控制(实现隐藏 )。这为类设计者提供了一种建立明确界限的方法,以确定允许客户端程序员操作什么,什么是不允许的。这意味着数据类型操作的内部机制是在类的设计者的控制和判断之下,客户程序员很清楚他们可以并且应该注意哪些成员。

封装和访问控制一起在提高库的易用性方面迈出了重要的一步。他们提供的“新数据类型的概念在某些方面比来自 C 的现有内置数据类型要好。C++ 编译器现在可以为该数据类型提供类型检查保证,从而确保使用该数据类型时的安全级别。

然而,说到安全,编译器能为我们做的比 C 提供的多得多。在这一章和以后的章节中,你将会看到 C++ 中设计的附加特性,这些特性使你程序中的错误几乎跳出来抓住你,有时甚至在你编译程序之前,但通常是以编译器警告和错误的形式。出于这个原因,您很快就会习惯这种听起来不太可能的情况,即编译的 C++ 程序通常第一次就能正确运行。

其中两个安全问题是初始化和清理。当程序员忘记初始化或清理变量时,很大一部分 C 错误就发生了。对于 C 库来说,尤其是这样,当客户程序员不知道如何初始化一个struct,或者甚至不知道他们必须如何初始化。

image 注意库通常不包含初始化函数,所以客户端程序员被迫手工初始化struct

清理是一个特殊的问题,因为 C 程序员习惯于在完成后忘记变量,所以库的struct可能需要的任何清理经常被错过。

在 C++ 中,初始化和清理的概念对于方便库的使用和消除当客户端程序员忘记执行这些活动时出现的许多微妙的错误是必不可少的。本章分析了 C++ 中有助于保证正确初始化和清理的特性。

用构造器保证初始化

前面定义的StashStack类都有一个名为initialize()的函数,它的名字暗示了在以任何其他方式使用对象之前应该调用它。不幸的是,这意味着客户端程序员必须确保正确的初始化。客户端程序员在匆忙让您的惊人的库解决他们的问题时,很容易错过初始化这样的细节。在 C++ 中,初始化太重要了,不能留给客户端程序员。类设计者可以通过提供一个叫做构造器的特殊函数来保证每个对象的初始化。如果一个类有一个构造器,编译器会在一个对象被创建的时候,在客户程序员得到这个对象之前,自动调用这个构造器。客户端程序员甚至不能选择构造器调用;它由编译器在定义对象时执行。

下一个挑战是如何命名这个函数。有两个问题。首先,您使用的任何名称都有可能与您希望用作该类成员的名称发生冲突。第二是因为编译器负责调用构造器,所以它必须总是知道要调用哪个函数。C++ 设计者选择的解决方案似乎是最简单和最符合逻辑的:构造器的名称与类名相同。初始化时自动调用这样的函数是有意义的。

下面是一个带有构造器的简单类:

class X {
  int i;
public:
  X();  // Constructor
};

现在,当一个对象被定义时,比如:

void f() {
  X a;
  // ...
}

同样的事情发生,就好像a是一个int;为该对象分配存储空间。但是当程序到达定义了a的序列点(执行点)时,构造器被自动调用。也就是说,编译器在定义的时候悄悄地为对象a插入对X::X()的调用。像任何成员函数一样,构造器的第一个(" secret" )参数是this指针——调用它的对象的地址。然而,在构造器的情况下,this指向一个未初始化的内存块,正确初始化这个内存是构造器的工作。

像任何函数一样,构造器可以有参数,允许您指定如何创建对象,赋予它初始化值,等等。构造器参数为您提供了一种方法来保证对象的所有部分都被初始化为适当的值。例如,如果一个名为Tree的类有一个构造器,它采用一个整数参数来表示树的高度,那么您必须创建一个树对象,如下所示:

Tree t(12);  // 12-foot tree

如果Tree(int)是你唯一的构造器,编译器不会让你用其他方式创建对象。

image 注意我们将在下一章看到多重构造器和调用构造器的不同方式。

这就是构造器的全部内容。这是一个特别命名的函数,在对象创建时,编译器会自动为每个对象调用它。尽管它很简单,但它非常有价值,因为它消除了一大类问题,使代码更容易编写和阅读。例如,在前面的代码片段中,您看不到对某个initialize()函数的显式函数调用,该函数在概念上与定义是分开的。在 C++ 中,定义和初始化是统一的概念——不能缺一不可。

构造器和析构函数都是非常不常见的函数类型:它们没有返回值。这与void返回值明显不同,在后者中,函数不返回任何内容,但您仍然可以选择将它设置为其他内容。构造器和析构函数不返回任何东西,你没有选择。将一个对象带入和带出程序的行为是特殊的,像出生和死亡,编译器总是自己调用函数,以确保它们发生。如果有返回值,并且您可以选择自己的返回值,编译器就必须知道如何处理返回值,否则客户端程序员就必须显式调用构造器和析构函数,这就消除了它们的安全性。

用析构函数保证清理

作为一名 C 程序员,你经常会想到初始化的重要性,但是很少会想到清理。毕竟清理一个int需要做什么?忘了它吧。然而,对于库来说,一旦你使用完一个对象,仅仅让它“T3”释放“T4”就不那么安全了。如果它修改了一些硬件,或者在屏幕上放了一些东西,或者在堆上分配了存储空间,那该怎么办呢?如果你只是忘记了它,你的对象就永远不会在离开这个世界时达到终结。在 C++ 中,清理和初始化一样重要,因此用析构函数来保证。

析构函数的语法类似于构造器的语法:类名用作函数名。但是,析构函数通过前导波浪符号()与构造器区分开来。此外,析构函数从来没有任何参数,因为析构从来不需要任何选项。下面是析构函数的声明:

class Y {
public:
  ∼Y();
};

当对象超出范围时,编译器会自动调用析构函数。您可以通过对象的定义点看到构造器在哪里被调用,但是析构函数调用的唯一证据是对象周围范围的右括号。然而析构函数仍然被调用,即使你使用goto跳出一个作用域。(goto仍然存在于 C++ 中,是为了向后兼容 C ,以备不时之需。)你应该注意到,由标准的 C 库函数setjmp()longjmp()实现的非局部 goto ,不会导致析构函数被调用。

image 注意这是规范,即使你的编译器没有这样实现。依赖规范中没有的特性意味着你的代码是不可移植的。

清单 6-1 展示了到目前为止你所看到的构造器和析构函数的特性。

清单 6-1 。构造器和析构函数

//: C06:Constructor1.cpp
// Demonstrates features of constructors & destructors
#include <iostream>
using namespace std;

class Tree {
  int height;
public:
  Tree(int initialHeight);  // Constructor
  ∼Tree();                  // Destructor
  void grow(int years);
  void printsize();
};

Tree::Tree(int initialHeight) {
  height = initialHeight;
}

Tree::∼Tree() {
  cout << "inside Tree destructor" << endl;
  printsize();
}

void Tree::grow(int years) {
  height += years;
}
void Tree::printsize() {
  cout << "Tree height is " << height << endl;
}

int main() {
  cout << "before opening brace" << endl;
  {
    Tree t(12);
    cout << "after Tree creation" << endl;
    t.printsize();
    t.grow(4);
    cout << "before closing brace" << endl;
  }
  cout << "after closing brace" << endl;
} ///:∼

下面是这个程序的输出:

before opening brace
after Tree creation
Tree height is 12
before closing brace
inside Tree destructor
Tree height is 16
after closing brace

您可以看到析构函数在包围它的作用域的右括号处被自动调用。

消除定义块

C 中,你必须在一个程序块的开始定义所有的变量,在左括号之后。这在编程语言中并不少见,给出的理由通常是“T3”良好的编程风格同时,每次需要一个新的变量时,返回到块的开头似乎不太方便。此外,当变量定义接近其使用点时,代码可读性更好。

也许这些争论是文体上的。然而,在 C++ 中,强制在作用域的开始定义所有对象有一个严重的问题。如果构造器存在,则必须在创建对象时调用它。但是,如果构造器有一个或多个初始化参数,你怎么知道在作用域的开始会有初始化信息呢?在一般的编程情况下,你不会。因为 C 没有private的概念,所以这种定义和初始化的分离是没有问题的。然而,C++ 保证当一个对象被创建时,它同时被初始化。这可以确保没有未初始化的对象在系统中运行。 C 不在乎;事实上, C 鼓励了这种做法,它要求你在必须拥有初始化信息之前,在一个块的开始定义变量。

一般来说,在获得构造器的初始化信息之前,C++ 不允许创建对象。正因为如此,如果你必须在作用域的开始定义变量,这种语言是不可行的。事实上,这种语言的风格似乎鼓励对一个对象的定义尽可能接近它的使用点。在 C++ 中,任何适用于“对象”的规则都会自动引用内置类型的对象。这意味着任何内置类型的类对象或变量也可以在作用域中的任何点定义。这也意味着您可以等到有了变量的信息后再定义它,这样您就可以同时定义和初始化了。参见清单 6-2 中的示例。

清单 6-2 。在任何地方定义变量

//: C06:DefineInitialize.cpp
// Demonstrates that you can define variables anywhere
#include "../require.h"       // To be INCLUDED from Header FILE in *[Chapter 3](03.html)*
#include <iostream>
#include <string>
using namespace std;

class G {
  int i;
public:
  G(int ii);
};

G::G(int ii) { i = ii; }

int main() {
  cout << "initialization value? ";
  int retval = 0;
  cin >> retval;
  require(retval != 0);
  int y = retval + 3;
  G g(y);
} ///:∼

你可以看到一些代码被执行;然后retval被定义、初始化,并用于捕获用户输入;然后定义yg。另一方面, C 不允许在除了作用域开头的任何地方定义变量。

一般来说,您应该在尽可能靠近变量使用点的地方定义变量,并且在定义变量时总是初始化它们。

image 注意这是对内置类型的风格建议,初始化是可选的。

这是一个安全问题。通过减少变量在该范围内可用的时间,您就减少了它在该范围的其他部分被误用的机会。此外,可读性也得到了提高,因为读者不必为了知道变量的类型而来回跳转到范围的开头。

对于循环

在 C++ 中,你经常会看到在for表达式中定义了一个for循环计数器,比如:

for(int j = 0; j < 100; j++) {
  cout << "j = " << j << endl;
}
for(int i = 0; i < 100; i++)
  cout << "i = " << i << endl;

上面的语句是重要的特例,给新 C++ 程序员造成了困惑。

变量ij直接在for表达式中定义(,这在 C 中是做不到的)。然后它们可用于for回路。这是一个非常方便的语法,因为上下文消除了关于ij的目的的所有疑问,所以为了清晰起见,你不需要使用像i_loop_counter这样笨拙的名字。

然而,如果你期望变量ij的生命期超出for循环的范围,可能会导致一些混乱——它们没有超出。

第 3 章 指出whileswitch语句也允许在它们的控制表达式中定义对象,尽管这种用法似乎远不如for循环重要。

注意隐藏封闭范围内变量的局部变量。通常,对嵌套变量和该作用域的全局变量使用相同的名称会引起混淆,并且容易出错。

较小的望远镜是好设计的标志,至少对我来说是这样。如果你的一个功能有几个页面,也许你想用这个功能做太多的事情。更细粒度的函数不仅更有用,而且也更容易发现 bug。

存储分配

现在,变量可以在作用域中的任何点定义,所以看起来变量的存储可能直到它的定义点才被定义。实际上,编译器更有可能遵循 C 中的惯例,在一个作用域的左括号处为该作用域分配所有存储空间。这无关紧要,因为作为一名程序员,在它被定义之前,你不能访问它。虽然存储是在块的开始分配的,但是构造器调用直到定义对象的序列点才发生,因为标识符直到那时才可用。编译器甚至检查以确保你没有把对象定义(和构造器调用)放在序列点只能有条件地通过它的地方,比如在一个switch语句中或者一个goto可以跳过它的地方。取消注释清单 6-3 中的语句会产生一个警告或错误。T9】

清单 6-3 。C++ 中不允许跳过构造器

//: C06:Nojump.cpp
// Demonstrates that you can't jump past constructors in C++

class X {
public:
  X();
};

X::X() {}

void f(int i) {
  if(i < 10) {
   //! goto jump1; // Error: goto bypasses init
  }
  X x1;  // Constructor called here
 jump1:
  switch(i) {
    case 1 :
      X x2;    // Constructor called here
      break;
  //! case 2 : // Error: case bypasses init
      X x3;    // Constructor called here
      break;
  }
}

int main() {
  f(9);
  f(11);
}///:∼

在这段代码中,gotoswitch都有可能跳过调用构造器的序列点。即使构造器没有被调用,这个对象也会在作用域内,所以编译器会给出一个错误消息。这再次保证了一个对象不能被创建,除非它也被初始化。

当然,这里讨论的所有存储分配都发生在堆栈上。编译器通过向下移动堆栈指针来分配存储空间(一个相对术语,它可能表示实际堆栈指针值的增加或减少,这取决于您的计算机)。也可以使用new在堆上分配对象,这将在第十三章的中进一步探讨。

用构造器和析构函数存放

前几章的例子有明显的映射到构造器和析构函数的函数:initialize()cleanup()清单 6-4 显示了使用构造器和析构函数的Stash头。

清单 6-4 。使用构造器和析构函数隐藏头

//: C06:Stash2.h
// Demonstrates Stash header file with constructors & destructors
#ifndef STASH2_H
#define STASH2_H

class Stash {
  int size;      // Size of each space
  int quantity;  // Number of storage spaces
  int next;      // Next empty space
  // Dynamically allocated array of bytes:
  unsigned char* storage;
  void inflate(int increase);
public:
  Stash(int size);
  ∼Stash();
  int add(void* element);
  void* fetch(int index);
  int count();
};
#endif           // STASH2_H //
/:∼

唯一改变的成员函数定义是initialize()cleanup(),它们被一个构造器和析构函数所取代(参见清单 6-5 )。

清单 6-5 。用构造器&析构函数实现 Stash

//: C06:Stash2.cpp {O}
// Demonstrates implementation of Stash
// with constructors & destructors
#include "Stash2.h"    // To be INCLUDED from Header FILE above
#include "../require.h"
#include <iostream>
#include <cassert>
using namespace std;
const int increment = 100;

Stash::Stash(int sz) {
  size = sz;
  quantity = 0;
  storage = 0;
  next = 0;
}

int Stash::add(void* element) {
  if(next >= quantity) // Enough space left?
    inflate(increment);
  // Copy element into storage,
  // starting at next empty space:
  int startBytes = (next * size);
  unsigned char* e = (unsigned char*)element;
  for(int i = 0; i < size; i++)
    storage[startBytes + i] = e[i];
  next++;
  return(next - 1);    // Index number
}

void* Stash::fetch(int index) {
  require(0 <= index, "Stash::fetch (-)index");
  if(index >= next)
    return 0; // To indicate the end
  // Produce pointer to desired element:
  return &(storage[index * size]);
}

int Stash::count() {
return next;           // Number of elements in CStash
}

void Stash::inflate(int increase) {
  require(increase > 0,
    "Stash::inflate zero or negative increase");
  int newQuantity = (quantity + increase);
  int newBytes = (newQuantity * size);
  int oldBytes = (quantity * size);
  unsigned char* b = new unsigned char[newBytes];
  for(int i = 0; i < oldBytes; i++)
    b[i] = storage[i]; // Copy old to new
  delete [](storage);  // Old storage
  storage = b; // Point to new memory
  quantity = newQuantity;
}

Stash::∼Stash() {
  if(storage != 0) {
    cout << "freeing storage" << endl;
    delete []storage;
  }
} ///:∼

你可以看到require.h函数被用来监视程序员的错误,而不是assert()。失败的assert()的输出没有require.h功能的输出有用。

因为inflate()是私有的,所以require()可能失败的唯一原因是其他成员函数之一意外地传递了一个不正确的值给inflate()。如果您确定这不可能发生,您可以考虑移除require(),但是您可能要记住,在类稳定之前,总有可能会有新的代码被添加到类中,从而导致错误。require()的成本很低(并且可以使用预处理器自动移除)并且代码健壮性的价值很高。

注意清单 6-6 中Stash对象的定义是如何在需要它们之前出现的,以及初始化是如何作为构造器参数列表中定义的一部分出现的。

清单 6-6 。测试存储(带构造器&析构函数)

//: C06:Stash2Test.cpp

//{L} Stash2

// Demonstrates testing of Stash
// (with constructors & destructors)
#include "Stash2.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>

using namespace std;

int main() {
  Stash intStash(sizeof(int));
  for(int i = 0; i < 100; i++)
    int Stash.add(&i);
  for(int j = 0; j < intStash.count(); j++)
    cout << "intStash.fetch(" << j << ") = "
         << *(int*) intStash.fetch(j)
         << endl;
  const int bufsize = 80;
  Stash stringStash(sizeof(char) * bufsize);
  ifstream in("Stash2Test.cpp");
  assure(in, " Stash2Test.cpp");
  string line;
  while(getline(in, line))
    stringStash.add((char*)line.c_str());
  int k = 0;
  char* cp;
  while((cp = (char*)stringStash.fetch(k++))!=0)
    cout << "stringStash.fetch(" << k << ") = "
         << cp << endl;
} ///:∼

还要注意cleanup()调用是如何被消除的,但是当intStashstringStash超出范围时,析构函数仍然会被自动调用。

Stash例子中需要注意的一点是:我非常小心地只使用内置类型;也就是那些没有析构函数的。如果你试图将类对象复制到Stash中,你会遇到各种各样的问题,而且它不会正常工作。标准 C++ 库实际上可以将对象的正确副本复制到它的容器中,但是这是一个相当混乱和复杂的过程。在下面的Stack例子中(清单 6-7 ,你会看到指针被用来回避这个问题。

带有构造器和析构函数的堆栈

用构造器和析构函数重新实现链表(在Stack内部)显示了构造器和析构函数如何灵活地与newdelete一起工作。清单 6-7 包含了修改后的头文件。

清单 6-7 。带有构造器/析构函数的堆栈

//: C06:Stack3.h
// Demonstrates the modified header file

#ifndef STACK3_H
#define STACK3_H

class Stack {
  struct Link {
    void* data;
    Link* next;
    Link(void* dat, Link* nxt);
    ∼Link();
  }* head;
public:
  Stack();
  ∼Stack();
  void push(void* dat);
  void* peek();
  void* pop();
};
#endif // STACK3_H ///:∼

不仅Stack有构造器和析构函数,嵌套的struct Link也有,正如你在清单 6-8 中看到的。

清单 6-8 。用构造器/析构函数实现堆栈

//: C06:Stack3.cpp {O}
// Demonstrates implementation of Stack
// with constructors/destructors
#include "Stack3.h"   // To be INCLUDED from Header FILE above
#include "../require.h"
using namespace std;

Stack::Link::Link(void* dat, Link* nxt) {
  data = dat;
  next = nxt;
}

Stack::Link::∼Link() { }

Stack::Stack() { head = 0; }

void Stack::push(void* dat) {
  head = new Link(dat, head);
}

void* Stack::peek() {
  require(head != 0, "Stack empty");
  return head->data;
}

void* Stack::pop() {
  if(head == 0) return 0;
  void* result = head->data;
  Link* oldHead = head;
  head = head->next;
  delete oldHead;
  return result;
}

Stack::∼Stack() {
  require(head == 0, "Stack not empty");
} ///:∼

Link::Link()构造器简单地初始化了datanext指针,所以在Stack::push()中的行

head = new Link(dat, head);

不仅分配了一个新的链接(使用关键字 new创建动态对象,在第 4 章中介绍),而且它还巧妙地初始化了那个链接的指针。

你可能想知道为什么Link的析构函数不做任何事情——特别是,为什么它不做deletedata指针?有两个问题。在第四章的中,引入了Stack,指出如果一个void指针指向一个对象,你就不能正确地delete(断言将在第十三章中被证明)。但是另外,如果Link析构函数删除了data指针,pop()最终会返回一个指向被删除对象的指针,这肯定是一个 bug。这有时被称为所有权的问题:LinkStack只保存指针,但不负责清理它们。这意味着你必须非常小心,你知道谁是负责人。例如,如果你不pop()delete所有Stack上的指针,它们不会被Stack的析构函数自动清除。这可能是一个棘手的问题,并导致内存泄漏,所以知道谁负责清理对象可以决定一个成功的程序和一个有错误的程序之间的区别;这就是为什么如果Stack对象在销毁时不是空的,那么Stack::∼Stack()会打印一条错误消息。

因为Link对象的分配和清理隐藏在Stack中——这是底层实现的一部分——你看不到它在测试程序中发生,尽管你负责删除从pop()返回的指针。参见清单 6-9

清单 6-9 。测试堆栈(带有构造器/析构函数)

//: C06:Stack3Test.cpp

//{L} Stack3

//{T} Stack3Test.cpp

// Demonstrates testing of Stack
// (with constructors/destructors)

#include "Stack3.h"
#include "../require.h"
#include <fstream>
#include <iostream>
#include <string>

using namespace std;

int main(int argc, char* argv[]) {
  requireArgs(argc, 1); // File name is argument
  ifstream in(argv[1]);
  assure(in, argv[1]);
  Stack textlines;
  string line;
  // Read file and store lines in the stack:
  while(getline(in, line))
    textlines.push(new string(line));
  // Pop the lines from the stack and print them:
  string* s;
  while((s = (string*) textlines.pop()) != 0) {
    cout << *s << endl;
    delete s;
  }
} ///:∼

在这种情况下,textlines中的所有行都被弹出并删除,但如果没有,您会得到一条require()消息,这意味着存在内存泄漏。

聚合初始化

一个集合就像它听起来的那样:一堆聚集在一起的东西。该定义包括混合类型的集合,如struct s 和class es。数组是单一类型的集合。

初始化聚合可能容易出错且繁琐。在 C++ 中,称为聚合初始化的东西使它更加安全。当你创建一个聚集的对象时,你所要做的就是赋值,初始化将由编译器负责。这种赋值有几种形式,取决于您正在处理的聚合类型,但是在所有情况下,赋值中的元素都必须用花括号括起来。对于内置类型的数组,这非常简单。

int a[5] = { 1, 2, 3, 4, 5 };

如果你试图给出比数组元素更多的初始化器,编译器会给出一个错误信息。但是如果你给更少的初始值,比如:,会发生什么呢

int b[6] = {0};

这里,编译器将对第一个数组元素使用第一个初始化器,然后对所有没有初始化器的元素使用零。注意,如果你定义了一个没有初始化列表的数组,这种初始化行为不会发生。因此,上面的表达式是一种简洁的将数组初始化为零的方法,不需要使用for循环,也没有任何一个减一错误的可能性。(取决于编译器,它也可能比for循环更高效。)

数组的第二种简写方式是自动计数,让编译器根据初始化器的数量来确定数组的大小,比如:

int c[] = { 1, 2, 3, 4 };

现在,如果你决定向数组中添加另一个元素,你只需添加另一个初始化器。如果您可以设置您的代码,使其只需要在一个地方进行更改,那么您就减少了修改过程中出错的机会。但是如何确定数组的大小呢?表达式(sizeof () / sizeof (*c)) ( 整个数组的大小除以第一个元素的大小)的作用是,如果数组的大小发生变化,它不需要改变,例如:

for(int i = 0; i < (sizeof (c) / sizeof (*c)); i++)
  c[i]++;

因为结构也是聚合,所以它们可以用类似的方式初始化。因为一个 C 样式struct有它的所有成员public,它们可以被直接赋值,比如:

struct X {
  int i;
  float f;
  char c;
};

X x1 = { 1, 2.2, 'c' };

如果有这样的对象数组,可以通过对每个对象使用一组嵌套的花括号来初始化它们,例如:

X x2[3] = { {1, 1.1, 'a'}, {2, 2.2, 'b'} };

这里,第三个对象被初始化为零。

如果任何一个数据成员是private ( ,这是在 C++ 中设计良好的类的典型情况),或者即使一切都是public,但是有一个构造器,事情就不同了。在上面的例子中,初始化器被直接分配给集合的元素,但是构造器是一种通过正式接口强制初始化的方式。这里,必须调用构造器来执行初始化。所以如果你有一个看起来像是的struct

struct Y {
  float f;
  int i;
  Y(int a);
};

您必须指示构造器调用。最好的方法是显式的,比如:

Y y1[] = { Y(1), Y(2), Y(3) };

您得到三个对象和三个构造器调用。任何时候你有一个构造器,不管是有所有成员publicstruct还是有数据成员privateclass,所有的初始化都必须通过构造器,即使你使用的是聚合初始化。

清单 6-10 显示了第二个显示多个构造器参数的例子。

清单 6-10 。使用多个构造器参数(带聚合初始化)

//: C06:Multiarg.cpp
// Demonstrates use of multiple constructor arguments
// (with aggregate initialization)
#include <iostream>
using namespace std;

class Z {
  int i, j;
public:
  Z(int ii, int jj);
  void print();
};

Z::Z(int ii, int jj) {
  i = ii;
  j = jj;
}

void Z::print() {
  cout << "i = " << i << ", j = " << j << endl;
}

int main() {
  Z zz[] = { Z(1,2), Z(3,4), Z(5,6), Z(7,8) };
  for(int i = 0; i < (sizeof (zz) / sizeof (*zz)); i++)
    zz[i].print();
} ///:∼

注意,看起来像是为数组中的每个对象调用了一个显式的构造器。

默认构造器

默认构造器是一个可以不带参数调用的函数。默认的构造器被用来创建一个“普通对象”,但是当编译器被告知创建一个对象但是没有给出任何细节的时候也很重要。例如,如果你取先前定义的struct Y并在如下定义中使用它:

Y y2[2] = { Y(1) };

编译器会抱怨找不到默认的构造器。数组中的第二个对象希望创建时没有参数,这就是编译器寻找默认构造器的地方。事实上,如果您简单地定义一组Y对象,例如:

Y y3[7];

编译器会抱怨,因为它必须有一个默认的构造器来初始化数组中的每个对象。

如果像这样创建一个单独的对象,也会出现同样的问题:

Y y4;

记住,如果你有一个构造器,编译器会确保构造总是发生,不管情况如何。

默认的构造器是如此重要的,以至于如果(只有如果)一个结构(struct class)没有构造器,编译器会自动为你创建一个。所以清单 6-11 中的代码是有效的。

清单 6-11 。生成自动默认构造器

//: C06:AutoDefaultConstructor.cpp

// Demonstrates automatically-generated default constructor

class V {
  int i;  // private
}; // No constructor

int main() {

  V v, v2[10];

}
 ///:∼

然而,如果定义了任何构造器,并且没有默认的构造器,那么上面的V实例将会产生编译时错误。

您可能认为编译器合成的构造器应该进行一些智能初始化,比如将对象的所有内存设置为零。但这并不会— 增加额外的开销,但不在程序员的控制之内。如果你想把内存初始化为零,你必须自己写默认的构造器。

虽然编译器会为你创建一个默认的构造器,但是编译器合成的构造器的行为很少是你想要的。您应该将此功能视为安全网,但要谨慎使用。一般来说,你应该显式定义你的构造器,不要让编译器替你做。

审查会议

  1. C++ 提供的看似复杂的机制应该给你一个强烈的暗示,告诉你在语言中初始化和清理的重要性。
  2. C++ 设计者对 CC的生产率的第一个观察是,很大一部分编程问题是由变量的不正确初始化引起的。这种类型的错误很难发现,类似的问题也适用于不适当的清理。
  3. 因为构造器和析构函数允许您“保证”正确的初始化和清理(编译器不允许在没有正确的构造器和析构函数调用的情况下创建和销毁对象),所以您可以获得完全的控制和安全。
  4. 聚合初始化也以类似的方式包含在内——它防止你使用内置类型的聚合犯典型的初始化错误,并使你的代码更加简洁。
  5. 在 C++ 中,编码过程中的安全性是一个大问题。初始化和清理是其中重要的一部分,但是随着这本书的进展,你也会看到其他的安全问题。