本节书摘来自异步社区出版社《C++面向对象高效编程(第2版)》一书中的第4章,第4.1节,作者: 【美】Kayshav Dattatri,更多章节内容可以访问云栖社区“异步社区”公众号查看。
4.1 什么是初始化
C++面向对象高效编程(第2版)
在函数内部创建一个基本数据类型,或在类中创建此类型的数据成员时(如C++中的char),该基本类型中包含的值是什么?
其中包含的是预定义的内容,还是未定义的无用单元?
这些都是在学习新语言或新范式(如OOP)时,应该真正关心的问题。举例说明,TPerson类如下所示,该类用于机动车辆注册系统、员工数据库等。
class TPerson {
public:
TPerson() { /* 无代码 */ } // ① 默认构造函数
TPerson(const char name[], const char theAddress[],
unsigned long theSSN, unsigned long theBirthDate);
TPerson& operator=(const TPerson& source);
TPerson(const TPerson& source);
~TPerson();
void SetName(const char theNewName[]);
void Print() const;
// 为简化起见,省略一些细节
private:
char* _name;
unsigned long _ssn; // 美国社会安全号码
unsigned long _birthDate;
char* _address;
};```
以下是TPerson类的一种典型用法:
main()
{
int i; // 局部变量
int j = 10;
i = 20;
TPerson alien; // TPerson类的对象
alien.Print();
}`
这就引出了许多问题:
(1)定义局部变量i时,i中的值是什么?
(2)TPerson类的对象alien中的数据成员_ssn、_name和_birthDate中的值是什么?
根据C++(和C)中的定义,i中的值是未定义的。该值就是在创建i的内存区域中所包含的值(在运行时栈上),没人知道是什么。换言之,变量i未初始化。另一方面,我们用10创建了j,变量j中的值即为10。在该程序中,j中包含的值是已定义的,变量j即代表初始值10。
初始化是在创建变量(或常量)时,向变量储存已知值的过程。这意味着该变量在被创建(无论以何种方式)时即获得一个值。进一步而言,在初始化期间,为变量储存值(即初始值,如示例中的10)时,我们并未覆盖该变量中的任何值。换言之,初始化并不用于擦除变量中的任何现有值。初始化只是在变量被创建的同时,为其储存一个已知值。
以上代码保证了在使用j时,j中就已储存了值10。当然,变量i也可用,但在为其赋值20之前,包含在i中的值是未知的。而且,当我们将20赋值给i的同时,正在擦除其中包含的任何值(即使是未知的)。这就是赋值与初始化的不同。
赋值一定会擦除变量中的现有值,变量中的原始值在该步骤中丢失。初始化是在创建变量的同时便为其储存一个值。由于被初始化的变量,在初始化步骤开始前并不存在,因此该步骤并未丢失任何值。任何变量只可初始化一次,但可以赋值任意多次。这是初始化和赋值的根本区别。 如果在给i赋值前使用i(如数组下标),将无法预知结果。而使用j则很安全,因为我们知道它确切的值。使用已初始化的变量更加安全,因为它的行为可预知。在后面的章节中,我们将把该原则扩展到对象上。
4.1.1 使用构造函数初始化
之前主要讨论的是main()中的局部变量。不过,我们更感兴趣的是对象。对象alien的数据成员中所包含的值是什么?我们并不知道,因为并未用合适的值初始化它们。
C++:
除非类的构造函数显式初始化对象的数据成员中的值,否则该值是未定义的。这与上面的变量i类似。i和_ssn数据成员(或其他数据成员)之间的唯一区别是:前者是main()内部的一个局部变量,而后者是类内部的数据成员。默认情况下,C++编译器不会初始化对象的任何数据成员。这项工作由实现者负责让构造函数完成。即使是类的默认构造函数,也不会为对象的数据成员储存任何预定义的值。
为什么初始化数据成员如此重要?
假设我们实现了TPerson类的Print()成员函数的代码,如下所示:
TPerson::Print() const
{
cout << “Name is” << _name << “ SSN: ” << _ssn << endl;
// ... 更多
}
在用对象alien调用Print()时,你能否猜到Print()会输出什么?绝对不能。_name和_ssn字符指针都并未初始化,我们不知道它们包含的内存地址是什么。这样通过cout调用插入操作符(insertion operator)(<<)可能会导致各种无法预知的输出。如果我们能肯定已正确初始化指针_name,那么,调用插入操作符输出的内容才可预知。不仅如此,除非正确地初始化对象的所有数据成员,否则对象的行为都无法准确地预知,而且使用这样的对象也不安全。类的设计者和实现者必须保证类对象的行为正确,无论以何种方式创建对象,该对象的行为都应该是已知的。如果无法履行这样的承诺,就违反了实现者与客户之间的契约。记住,一旦创建了类的对象,客户便可通过该对象调用它所属类的任何成员函数。实现者不能强制执行规则来限制访问成员函数,而且实现者也不能假定客户通过对象调用成员函数的顺序。因此,黄金法则如下:
hand 一定要记住,用合适的值初始化对象的所有数据成员。
所谓合适的值,指的是类的每个成员函数都能清楚解析的值。类的任何成员函数都必须能理解数据成员中的值,并且根据该值作出判断。
这看起来容易,但实际上并非如此。初始化数据成员不是简单的问题。在构造函数内部执行初始化时,可能会调用其他成员函数,如果这些成员函数使用尚未初始化的数据成员,后果将不堪设想。构造函数必须确保,在它内部被调用的任何成员函数都能运行正常。这意味着构造函数在调用其他成员函数之前,必须在所有数据成员中都储存了适当的值。在某些情况下,为了完成对象的初始化,该构造函数必须依赖于其他成员函数的结果。但是,如果这些成员函数试图使用尚未初始化的数据成员,程序会无法控制。实现者必须特别注意,一旦出现这种棘手的情形,可能不得不重新组织代码。解决方案之一是:使用非成员函数(静态或全局函数),避免访问数据成员。
警告:
在某些情况下,当为对象调用构造函数时,构造函数可以判断新对象不会使用哪些数据成员(基于传递给构造函数的参数)。鉴于此,实现者可能会选择不初始化某些数据成员(因为它们不会在对象中使用)。这样做可以接受,但实现者作出这样的假设时必须十分谨慎。如果将来需要修改类的实现,还需额外注意对未初始化数据成员的假设。无论数据成员在对象中使用与否,初始化对象的所有数据成员也许会更加容易些。否则,应使用类的相关知识,并提供详细的文档和关于假设的断言。
Smalltalk:
就初始化而言,Smalltalk和C++迥然不同。在Smalltalk中,一旦创建了对象,就保证已正确地初始化所有的实例成员。如果通过默认创建方法创建对象的所有实例成员,这些实例成员均包含一个预定义值nil。注意,nil是已知值。这是一种特殊的情况,意味着实例成员未初始化。在C++中,没有这样的初始化,实现者必须要显式地进行初始化。在Smalltalk中,类对象的创建将由它的元类(metaclass)控制。
Eiffel:
在Eiffel中,用make操作创建对象,这与C++中的构造函数非常类似。该方法用已知值初始化所有的基本类型(如整数和字符),所有的整数数据成员都设置为0;布尔类型设置为false;所有类引用设置为void 1。既然Eiffel不支持基本类型和引用之外的其他类型,那么这种初始化方案就要考虑到对象的所有类型。如果默认的make不合适,为了处理初始化,实现者可以为类特别定义make操作(带访问保护)。还需注意,仅有对象的声明并不能创建一个新对象,必须通过对象名调用make方法,才能在运行时创建该对象。用!!前缀表明创建新对象(!!objectname.make)。如果调用make的语句前未加!!前缀,make将重置现有对象中的值。
了解以上知识后,现在我们来完成TPerson类的构造函数。应该用此构造函数替换116页的内联实现(①)。
TPerson::TPerson()
{
// 赋值样式的构造函数,不推荐使用,
// 但仍比没有代码好。
_name = 0;
_address = 0;
_ssn = 0;
_birthData = 0;
}```
这非常容易,我们只需为数据成员赋特殊的值(distinct value)。注意,我们选择的值在普通的TPerson类对象中并不存在,真实的人名、社会安全号码等不可能是0。这告诉成员函数,在客户试图使用TPerson类时,TPerson类对象并不代表真实的人。对于指针,0是一个用于区别指针是否合法的值,因为合法的指针不会是0地址。特殊的值,即在一般情况下绝不会出现的值,必须谨慎选择。在某些情况下,−1便可作为整数的特殊值。例如,用整数代表数组的下标,此时将 −1作为特殊的值就特别合适。数组的下标不可能是负数(除非是用户定义的类),因此 −1表明无效下标。
但是,如果某个数据成员是常量会怎样?我们无法给const数据成员赋值,只能在创建对象时初始化该数据成员。举例说明,假设我们将_`birthDate`数据成员改为`const`。换言之,必须保证人的出生日期无论何时都不变。一旦创建了`TPerson`类对象,该对象中除了出生日期,其他的数据成员都可以修改。当然,以上所示的构造函数并不正确,我们稍后将修正它。新的`TPerson`类声明如下:
class TPerson {
public:
TPerson(unsigned long theBirthDate);
TPerson(const char name[], const char theAddress[],
unsigned long theSSN, unsigned long theBirthDate);
TPerson& operator=(const TPerson& source);
TPerson(const TPerson& source);
~TPerson();
void SetName(const char NewName[]);
void Print() const;
// 为简化起见,省略一些细节
private:
char* _name; // 作为字符数组
unsigned long _ssn;
const unsigned long _birthDate;
char* _address; // 作为字符数组
};`
_birthDate成为一个const,我们再也无法给_birthDate字段赋值,只能初始化它。为满足这样的要求,C++提供了如下初始化语法(为构造函数),如下所示:
TPerson::TPerson(unsigned long theBirthDate) : _birthDate(theBirthDate)
// 初始化阶段从这里开始
{ // 构造函数的赋值阶段从这里开始
name = 0;
ssn = 0;
_address = 0;
}```
构造函数右括号后的:表明初始化序列的开始。在:之后、构造函数的左花括号{(执行构造函数——构造函数体的开始)之前的操作就是初始化。实际上,构造函数的这个阶段称为初始化阶段。左花括号{表明构造函数的赋值阶段开始。需要初始化的元素(该例中为_birthDate)后紧跟圆括号(),括号中的值即是用于初始化的值。这看起来像是函数调用。仅为了理解这样的语法,我们可以假设()中的元素赋值给括号左边的元素。该例中,我们用参数theBirthDate的值初始化const数据成员_birthDate。如果类包含更多的const元素,可以在逗号分离列表中按顺序初始化它们。如果在构造函数中忘记初始化类的const数据成员,编译器在无法正确初始化成员时会直接报错。
这种初始化语法还有更深层的含义。如果对象包含另一个类的对象作为数据成员(即内嵌对象),如何为这样的内嵌对象2调用成员函数?这也由初始化语法来完成。例如,如果TCar是一个类(用于汽车经销):
class TCar {
private:
unsigned _weight; // 汽车的重量,英镑。
short _driveType; // 四轮驱动或两轮驱动
TPerson _owner; // 谁拥有这辆车?
// 省略不重要的细节
public:
TCar(const char name[], const char address[],
unsigned long ssn, unsigned long ownerBirthDate,
unsigned weight = 900, short driveType = 2);
};`
TCar的构造函数可以写成:
TCar::TCar(const char name[], const char address[], unsigned long ssn,
unsigned long ownerBirthDate, unsigned weight /* =
900 */, short driveType /* = 2 */)
// 提供汽车户主名、地址、社会安全号码和出生日期
// 通过调用合适的构造函数,初始化TPerson类的_owner对象
: _owner(name, address, ssn, ownerBirthDate)
// 初始化_owner数据成员
{
// 为简洁起见,此处省略构造函数的代码
}```
在该例中(如图4-1所示),每个TCar类对象都有一个TPerson类的内嵌对象_owner(它有自己的数据成员_name、_ssn、_birthDate和_address)。参见图4-1。
警告:
在本书中,为了让读者易于理解对象的结构,对于所涉及的对象将以图的形式提供概念上(或逻辑上)的布局。但是,这并不意味着编译器实际上遵循所示的布局,编译器实现方面的细节将在第13章中介绍。这些图用于给读者显示实现下的整体效果。绝大多数程序员都无需知道对象实际的字节布局。
![image](https://yqfile.alicdn.com/d616b9c50c3b840e27fd74288f103cd3a5a114e0.png)
图4-1
注意:
汽车可以由个人、公司或银行所拥有。当设计真正的应用程序时,就必须考虑类似的问题。然而,在该例中,重点在于理解初始化的细节,而不是TCar类。因此,我们假设汽车只属于个人。
以上的初始化代码表明,用name、ssn、ownerBirthDate等参数初始化_owner数据成员。我们之前介绍过,对象的初始化由构造函数来完成。实质上,我们正在为_owner数据成员调用构造函数。因为_owner的类型是TPerson,所以调用的是TPerson类的构造函数。
这种初始化语法是初始化const数据成员的唯一选择,而且,这也是初始化内嵌对象的唯一办法。在后续章节中将介绍,初始化语法也是初始化基类的唯一语法。但是该语法并不局限于此,它可用于初始化任何数据成员。例如,我们可重写TPerson类的构造函数:
TPerson::TPerson (unsigned long theBirthDate) :
_ssn(0) ,
_name(0),
_birthDate(theBirthDate),
_address(0)
{ / 构造函数体中无代码 / }`
以上代码在初始化阶段便完成了所有工作,赋值阶段无需做任何事情。当处理基本类型时,选择这样的初始化方式还是其他方式,只是样式的选择或偏好问题。但是,在某些情况下,如后续章节所述,采用初始化样式效率更高(带内嵌对象时)。
样式:
无论何时,如果可以在初始化语法和赋值两种样式之间作选择,应选择初始化样式。假设在最初的实现中,被初始化的某成员是基本数据类型,而在后来的实现中将该成员改为类对象(参见下一页TDate类的用法),如果选择初始化样式,则无需修改代码。如果我们为对象使用赋值语法,要记住,在开始赋值前,可能已经通过该对象调用了构造函数,而且我们可能已经使用构造函数初始化了该对象(因此,无需再进行赋值操作)。
但是,在初始化阶段不一定就能完成所有的工作。在很多类中,许多操作都必须在所有数据成员被完全初始化之前执行。这些操作涉及计算不同的值或调用不同的函数(成员函数和非成员函数)。在构造函数中,可能要按照预定义的顺序执行一些步骤,这些步骤只能在赋值阶段完成。当某数据成员依赖于另一个被初始化的值时,甚至更加复杂。因此,尽可能地使用初始化语法,但也不能过分依赖它。无论如何,最终的目标是构造出完整且正确的对象。
警告:
在面向过程编程中,依赖于某个特殊函数来初始化很常见。该函数通常称为Initialize(或Init),用于在程序启动后完成应用程序(或模块)中的初始化工作。在面向过程编程中,以这样的方式初始化很合适。但是,在面向对象编程(OOP)中不要用这种方式初始化。完全初始化对象的正确(且唯一)方法是,在构造函数中进行。类的设计者不应该要求客户调用initialize()方法来初始化对象,这很可能导致错误,因为很容易忘记调用Initialize()。因此,对这种方式的初始化应避而远之。只有当对象依赖于另一个对象进行初始化,且另一个对象尚未创建时才需要采取这种方式初始化。在包含虚基类的复杂继承层次中会出现这种情况。
回到TPerson类,用数字表示日期非常不方便。当然,你可以使用儒略日(julian date),但那更适用于机器,而我们倾向于用更简单的格式来表示日期(如6/11/95)。如果用这样的格式比较日期是否相等,会非常方便。为了让日期对用户更加友好,我们用另一个TDate类来表示日期,这是另一个简单的抽象。这种情况下,我们对TDate类的接口更感兴趣,实现的问题反而不太重要。使用诸如TDate这样的类,使得接口更易于理解,而且简化了实现。对于这样的类需要考虑诸多设计因素,详见第11章。
class TDate {
public:
enum EMonth { eJan = 1, eFeb, eMar, eApr, eMay, eJun, eJul,
eAug, eSep, eOct, eNov, eDec };
// 简单的构造函数
TDate(unsigned day, EMonth mon, unsigned year);
TDate(const char date[]); // 数据作为字符串传入
TDate(); // 用操作系统日期设置日期
unsigned GetYear() const;
EMonth GetMonth() const;
unsigned GetDay() const; // 月份的天数
// 便捷函数(_convenience function_)
void AddToYear(int increment); // increment可为负
void AddToMonth(int increment); // increment可为负
void AddToDay(int increment); // increment可为负
// 比较操作符
bool operator==(const TDate& second) const;
bool operator!=(const TDate& second) const;
TString GetDate() const; // 返回字符串表示
private:
short _year; // 一些实现数据
short _day;
EMonth _month;
};```
无需过多关注类的细节,我们将在后续章节中讨论所有的相关问题。如下所示,虽然我们对TPerson类作了改进,但无需对TPerson类的构造函数实现作任何改动(除非将参数_brithDate的类型改为const char[]):
class TPerson {
public:
TPerson(const char birthDate[]);
TPerson(const char name[], const char theAddress[],
unsigned long theSSN, const char birthDate[]);
TPerson& operator=(const TPerson& source);
TPerson(const TPerson& source);
~TPerson();
void SetName(const char theNewName[]);
void Print() const;
// 为简化起见,省略细节
private:
char* _name;
unsigned long _ssn;
const TDate _birthDate;
char* _address;
};`
新的TPerson类对象概念上的布局,如图4-2所示。从现在开始,该TPerson类将用于所有的示例中。
图4-2
4.1.2 使用内嵌对象必须遵守的规则
(1)如果类的构造函数中包含其他类的对象,那么必须在构造函数的初始化阶段,为它所使用的所有内嵌对象调用合适的构造函数。
(2)如果实现者在调用内嵌对象的构造函数时失败,编译器将设法为内嵌对象调用默认构造函数(如果有可用且可访问的默认构造函数)。
(3)如果(1)和(2)都不成功,则构造函数的实现是错误的(导致编译时错误)。
(4)每个内嵌对象的析构函数,将由包含该对象的类的析构函数自动调用,无需程序员干预。
1如前面的章节所述,Eiffel中的引用与C++中的指针非常类似。在Eiffel中,对象只能包含对其他对象的引用。
2内嵌对象就是另一个对象的数据成员(如TCar的_owner)。
本文仅用于学习和交流目的,不代表异步社区观点。非商业转载请注明作译者、出处,并保留本文的原始链接。