天天看点

《C++面向对象高效编程(第2版)》——4.1 什么是初始化

本节书摘来自异步社区出版社《c++面向对象高效编程(第2版)》一书中的第4章,第4.1节,作者: 【美】kayshav dattatri,更多章节内容可以访问云栖社区“异步社区”公众号查看。

c++面向对象高效编程(第2版)

在函数内部创建一个基本数据类型,或在类中创建此类型的数据成员时(如c++中的char),该基本类型中包含的值是什么?

其中包含的是预定义的内容,还是未定义的无用单元?

这些都是在学习新语言或新范式(如oop)时,应该真正关心的问题。举例说明,tperson类如下所示,该类用于机动车辆注册系统、员工数据库等。

main()

{

  int i;   // 局部变量

  int j = 10;

  i = 20;

  tperson alien; // tperson类的对象

  alien.print();

}<code>`</code>

这就引出了许多问题:

(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则很安全,因为我们知道它确切的值。使用已初始化的变量更加安全,因为它的行为可预知。在后面的章节中,我们将把该原则扩展到对象上。

之前主要讨论的是main()中的局部变量。不过,我们更感兴趣的是对象。对象alien的数据成员中所包含的值是什么?我们并不知道,因为并未用合适的值初始化它们。

c++:

除非类的构造函数显式初始化对象的数据成员中的值,否则该值是未定义的。这与上面的变量i类似。i和_ssn数据成员(或其他数据成员)之间的唯一区别是:前者是main()内部的一个局部变量,而后者是类内部的数据成员。默认情况下,c++编译器不会初始化对象的任何数据成员。这项工作由实现者负责让构造函数完成。即使是类的默认构造函数,也不会为对象的数据成员储存任何预定义的值。

为什么初始化数据成员如此重要?

假设我们实现了tperson类的print()成员函数的代码,如下所示:

tperson::print() const

    cout &lt;&lt; “name is” &lt;&lt; _name &lt;&lt; “ ssn: ” &lt;&lt; _ssn &lt;&lt; endl;

    // ... 更多

}

在用对象alien调用print()时,你能否猜到print()会输出什么?绝对不能。_name和_ssn字符指针都并未初始化,我们不知道它们包含的内存地址是什么。这样通过cout调用插入操作符(insertion operator)(&lt;&lt;)可能会导致各种无法预知的输出。如果我们能肯定已正确初始化指针_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页的内联实现(①)。

class tperson {

    public:

      tperson(unsigned long thebirthdate);

      tperson(const char name[], const char theaddress[],

            unsigned long thessn, unsigned long thebirthdate);

      tperson&amp; operator=(const tperson&amp; source);

      tperson(const tperson&amp; source);

      ~tperson();

      void setname(const char newname[]);

      void print() const;

      // 为简化起见,省略一些细节

    private:

      char*     _name;   // 作为字符数组

      unsigned long   _ssn;

      const unsigned long  _birthdate;

      char*     _address;  // 作为字符数组

};<code>`</code>

_birthdate成为一个const,我们再也无法给_birthdate字段赋值,只能初始化它。为满足这样的要求,c++提供了如下初始化语法(为构造函数),如下所示:

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的构造函数可以写成:

tperson::tperson (unsigned long thebirthdate) :

    _ssn(0) ,

    _name(0),

    _birthdate(thebirthdate),

    _address(0)

    { / 构造函数体中无代码 / }<code>`</code>

以上代码在初始化阶段便完成了所有工作,赋值阶段无需做任何事情。当处理基本类型时,选择这样的初始化方式还是其他方式,只是样式的选择或偏好问题。但是,在某些情况下,如后续章节所述,采用初始化样式效率更高(带内嵌对象时)。

样式:

无论何时,如果可以在初始化语法和赋值两种样式之间作选择,应选择初始化样式。假设在最初的实现中,被初始化的某成员是基本数据类型,而在后来的实现中将该成员改为类对象(参见下一页tdate类的用法),如果选择初始化样式,则无需修改代码。如果我们为对象使用赋值语法,要记住,在开始赋值前,可能已经通过该对象调用了构造函数,而且我们可能已经使用构造函数初始化了该对象(因此,无需再进行赋值操作)。

但是,在初始化阶段不一定就能完成所有的工作。在很多类中,许多操作都必须在所有数据成员被完全初始化之前执行。这些操作涉及计算不同的值或调用不同的函数(成员函数和非成员函数)。在构造函数中,可能要按照预定义的顺序执行一些步骤,这些步骤只能在赋值阶段完成。当某数据成员依赖于另一个被初始化的值时,甚至更加复杂。因此,尽可能地使用初始化语法,但也不能过分依赖它。无论如何,最终的目标是构造出完整且正确的对象。

在面向过程编程中,依赖于某个特殊函数来初始化很常见。该函数通常称为initialize(或init),用于在程序启动后完成应用程序(或模块)中的初始化工作。在面向过程编程中,以这样的方式初始化很合适。但是,在面向对象编程(oop)中不要用这种方式初始化。完全初始化对象的正确(且唯一)方法是,在构造函数中进行。类的设计者不应该要求客户调用initialize()方法来初始化对象,这很可能导致错误,因为很容易忘记调用initialize()。因此,对这种方式的初始化应避而远之。只有当对象依赖于另一个对象进行初始化,且另一个对象尚未创建时才需要采取这种方式初始化。在包含虚基类的复杂继承层次中会出现这种情况。

回到tperson类,用数字表示日期非常不方便。当然,你可以使用儒略日(julian date),但那更适用于机器,而我们倾向于用更简单的格式来表示日期(如6/11/95)。如果用这样的格式比较日期是否相等,会非常方便。为了让日期对用户更加友好,我们用另一个tdate类来表示日期,这是另一个简单的抽象。这种情况下,我们对tdate类的接口更感兴趣,实现的问题反而不太重要。使用诸如tdate这样的类,使得接口更易于理解,而且简化了实现。对于这样的类需要考虑诸多设计因素,详见第11章。

        tperson(const char birthdate[]);

        tperson(const char name[], const char theaddress[],

        unsigned long thessn, const char birthdate[]);

        tperson&amp;   operator=(const tperson&amp; source);

        tperson(const tperson&amp; source);

        ~tperson();

        void setname(const char thenewname[]);

        void print() const;

        // 为简化起见,省略细节

        char*       _name;

        unsigned long  _ssn;

        const tdate    _birthdate;

        char*       _address;

新的tperson类对象概念上的布局,如图4-2所示。从现在开始,该tperson类将用于所有的示例中。

《C++面向对象高效编程(第2版)》——4.1 什么是初始化

图4-2

(1)如果类的构造函数中包含其他类的对象,那么必须在构造函数的初始化阶段,为它所使用的所有内嵌对象调用合适的构造函数。

(2)如果实现者在调用内嵌对象的构造函数时失败,编译器将设法为内嵌对象调用默认构造函数(如果有可用且可访问的默认构造函数)。

(3)如果(1)和(2)都不成功,则构造函数的实现是错误的(导致编译时错误)。

(4)每个内嵌对象的析构函数,将由包含该对象的类的析构函数自动调用,无需程序员干预。

1如前面的章节所述,eiffel中的引用与c++中的指针非常类似。在eiffel中,对象只能包含对其他对象的引用。

2内嵌对象就是另一个对象的数据成员(如tcar的_owner)。

本文仅用于学习和交流目的,不代表异步社区观点。非商业转载请注明作译者、出处,并保留本文的原始链接。

继续阅读