0%

C++ Primer - 第 7 章 类

  1. 类的基本思想是数据抽象(data abstraction)和封装(encapsulation)。数据抽象是一种依赖于接口(interface)和实现(implementation)分离的编程(以及设计)技术。

  2. 作为一个设计良好的类,既要有直观且易于使用的接口,也必须具备高效的实现过程。

  3. 定义在类内部的函数是隐式的 inline 函数。

  4. 尽管所有成员都必须在类的内部声明,但是成员函数体可以定义在类内也可以定义在类外。

  5. 成员函数通过一个名为 this 的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化 this。例如,如果调用

    1
    total.isbn()

    则编译器负责把 total 的地址传递给 isbn 的隐式形参 this,可以等价地认为编译器将该调用重写成了如下的形式:

    1
    2
    // 伪代码,用于说明调用成员函数的实际执行过程
    Sales_data::isbn(&total)

    在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无须通过成员访问运算符来做到这一点,因为 this 所指的正是这个对象。任何对类成员的直接访问都被看作 this 的隐式引用,也就是说,当 isbn 使用 bookNo 时,它隐式地使用 this 指向的成员,就像我们书写了 this->bookNo 一样。

  6. 任何自定义名为 this 的参数或变量的行为都是非法的。我们可以在成员函数体内部使用 this,因此尽管没有必要,但我们还是能把 isbn 定义成如下的形式:

    1
    2
    3
    4
    std::string isbn() const
    {
    return this->bookNo;
    }

    因为 this 的目的总是指向“这个”对象,所以 this 是一个常量指针,我们不允许改变 this 中保存的地址

  7. 紧随参数列表之后的 const 关键字的作用是修改隐式 this 指针的类型。默认情况下,this 的类型是指向类类型非常量版本的常量指针。尽管 this 是隐式的,但它仍然需要遵循初始化规则,意味着(在默认情况下)我们不能把 this 绑定到一个常量对象上。这一情况也就使得我们不能在一个常量对象上调用普通的成员函数。把 this 设置为指向常量的指针有助于提高函数的灵活性。把 const 关键字放在成员函数的参数列表之后表示 this 是一个指向常量的指针。像这样使用 const 的成员函数被称作常量成员函数(const member function)。常量成员函数不能改变调用它的对象的内容。

    常量对象,以及常量对象的引用或指针都只能调用常量成员函数。

  8. 编译器分两步处理类:首先编译成员的声明,然后才轮到成员函数体(如果有的话)。因此,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。

  9. 如果成员被声明成常量成员函数,那么它的定义也必须在参数列表后明确指定 const 属性。同时,类外部定义的成员的名字必须包含它所属的类名。

  10. 可以定义一个返回 this 对象的函数:

    1
    2
    3
    4
    5
    6
    Sales_data& Sales_data::combine(const Sales_data &rhs)
    {
    units_sold += rhs.units_sold; // 把 rhs 的成员加到 this 对象的成员上
    revenue += rhs.revenue;
    return *this; // 返回调用该函数的对象
    }
  11. 一般来说,当我们定义的函数类似于某个内置运算符时,应该令该函数的行为尽量模仿这个运算符。如前所述,我们无须使用隐式的 this 指针访问函数调用者的某个具体成员,而是需要把调用函数的对象当成一个整体来访问:

    1
    return *this;  // 返回调用该函数的对象

    其中,return 语句解引用 this 指针以获得执行该函数的对象。

  12. 如果函数在概念上属于类但是不定义在类中,则它一般应与类声明(而非定义)在同一个头文件内。在这种方式下,用户使用接口的任何部分都只需要引入一个文件。

  13. IO 类属于不能被拷贝的类型,因此我们只能通过引用来传递它们,而且读取和写入的操作会改变流的内容。

  14. 默认情况下,拷贝类的对象其实拷贝的是对象的数据成员。

  15. 每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数(constructor)。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数

  16. 构造函数的名字和类名相同。和其他函数不一样的是,构造函数没有返回类型:除此之外类似于其他的函数,构造函数也有一个(可能为空的)参数列表和一个(可能为空的)函数体。类可以包含多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。

  17. 不同于其他成员函数,构造函数不能被声明成 const。当我们创建类的一个 const 对象时,直到构造函数完成初始化过程,对象才能真正取得其“常量”属性。因此,构造函数在 const 对象的构造过程中可以向其写值。

  18. 类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数(default constructor)。默认构造函数无须任何实参。如果我们的类没有显式地定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。

  19. 编译器创建的构造函数又被称为合成的默认构造函数(synthesized default constructor)。对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:

    • 如果存在类内的初始值,用它来初始化成员;
    • 否则,默认初始化该成员。

  20. 对于一个普通的类来说,必须定义它自己的默认构造函数,原因有三:

    • 编译器只有在发现类不包含任何构造函数的情况下才会替我们生成一个默认的构造函数。 一旦我们定义了一些其他的构造函数,那么除非我们再定义一个默认的构造函数,否则类将没有默认构造函数。这条规则的依据是,如果一个类在某种情况下需要控制对象初始化,那么该类很可能在所有情况下都需要控制;
    • 对于某些类来说,合成的默认构造函数可能执行错误的操作。如果定义在块中的内置类型或复合类型(比如数组和指针)的对象被默认初始化,则它们的值将是未定义的。该准则同样适用于默认初始化的内置类型成员。因此,含有内置类型或复合类型成员的类应该在类的内部初始化这些成员,或者定义一个自己的默认构造函数。否则,用户在创建类的对象时就可能得到未定义的值。

      WARNING:如果类包含有内置类型或者复合类型的成员,则只有当这些成员全都被赋予了类内的初始值时,这个类才适合于使用合成的默认构造函数。

    • 有的时候编译器不能为某些类合成默认的构造函数。例如,如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。对于这样的类来说,我们必须自定义默认构造函数,否则该类将没有可用的默认构造函数。

  21. 在 C++11 新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上 = default 来要求编译器生成构造函数。其中,= default 既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果 = default 在类的内部,则默认构造函数是内联的;如果它在类的外部,则该成员默认情况下不是内联的

  22. WARNING:如果你的编译器不支持类内初始值,那么你的默认构造函数就应该使用构造函数初始值列表来初始化类的每个成员。

  23. 类的构造函数形参列表后的冒号以及冒号和花括号之间的代码被称为构造函数初始值列表。构造函数初始值列表(constructor initialize list)负责为新创建的对象的一个或几个数据成员赋初值。构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来的(或者在花括号内的)成员初始值。不同成员的初始化通过逗号分隔开来。当某个数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化。

  24. 通常情况下,构造函数使用类内初始值不失为一种好的选择,因为只要这样的初始值存在我们就能确保为成员赋予了一个正确的值。不过,如果你的编译器不支持类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。

    最佳实践:构造函数不应该轻易覆盖掉类内的初始值,除非新赋的值与原值不同。如果你不能使用类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。

  25. 没有出现在构造函数初始值列表中的成员将通过相应的类内初始值(如果存在的话)初始化,或者执行默认初始化。

  26. 对象在几种情况下会被拷贝,如我们初始化变量以及以值的方式传递或返回一个对象。当我们使用了赋值运算符时会发生对象的赋值操作。当对象不再存在时执行销毁的操作,比如一个局部对象会在创建它的块结束时被销毁,当 vector 对象(或者数组)销毁时存储在其中的对象也会被销毁。如果我们不主动定义这些操作,则编译器将替我们合成它们。一般来说,编译器生成的版本将对对象的每个成员执行拷贝、赋值和销毁操作。

  27. 尽管编译器能替我们合成拷贝、赋值和销毁的操作,但是必须要清楚的一点是,对于某些类来说合成的版本无法正常工作。特别是,当类需要分配类对象之外的资源时,合成的版本常常会失效。不过值得注意的是,很多需要动态内存的类能(而且应该)使用 vector 对象或者 string 对象管理必要的存储空间。使用 vector 或者 string 的类能避免分配和释放内存带来的复杂性。进一步讲,如果类包含 vector 或者 string 成员,则其拷贝、赋值和销毁的合成版本能够正常工作。当我们对含有vector成员的对象执行拷贝或者赋值操作时,vector类会设法拷贝或者赋值成员中的元素。当这样的对象被销毁时,将销毁 vector 对象,也就是依次销毁 vector 中的每一个元素。这一点与 string 是非常类似的。

  28. 在 C++ 语言中,我们使用访问说明符(access specifiers)加强类的封装性:

    • 定义在 public 说明符之后的成员在整个程序内可被访问,public 成员定义类的接口;
    • 定义在 private 说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private 部分封装了(即隐藏了)类的实现细节。

  29. 一个类可以包含 0 个或多个访问说明符,而且对于某个访问说明符能出现多少次也没有严格限定。每个访问说明符指定了接下来的成员的访问级别,其有效范围直到出现下一个访问说明符或者到达类的结尾处为止。

  30. 类可以在它的第一个访问说明符之前定义成员,对这种成员的访问权限依赖于类定义的方式。如果我们使用 struct 关键字,则定义在第一个访问说明符之前的成员是 public 的:相反,如果我们使用 class 关键字,则这些成员是private的。出于统一编程风格的考虑,当我们希望定义的类的所有成员是 public 的时,使用 struct;反之,如果希望成员是 private 的,使用 class

    WARNING:使用 class 和 struct 定义类唯一的区别就是默认的访问权限。

  31. 类可以允许其他类或者函数访问它的非公有成员,方法是令其他类或者函数成为它的友元(friend)。如果类想把一个函数作为它的友元,只需要增加一条以 friend 关键字开始的函数声明语句即可。

  32. 友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。

    Tip:一般来说,最好在类定义开始或结束前的位置集中声明友元。

  33. 友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行次声明。为了使友元对类的用户可见,我们通常把友元的声明与类本身放置在同一个头文件中(类的外部)。

    许多编译器并未强制限定友元函数必须在使用之前在类的外部声明。

    一些编译器允许在尚无友元函数的初始声明的情况下就调用它。不过即使你的编译器支持这种行为,最好还是提供一个独立的函数声明。这样即使你更换了一个有这种强制要求的编译器,也不必改变代码。

  34. 除了定义数据和函数成员之外,类还可以自定义某种类型在类中的别名。由类定义的类型名字和其他成员一样存在访问限制,可以是 public 或者 private 中的一种。用来定义类型的成员必须先定义后使用,这一点与普通成员有所区别,因此,类型成员通常出现在类开始的地方。

  35. 定义在类内部的成员函数是自动 inline 的。 我们可以在类的内部把 inline 作为声明的一部分显式地声明成员函数,同样的,也能在类的外部用 inline 关键字修饰函数的定义。虽然我们无须在声明和定义的地方同时说明 inline,但这么做其实是合法的。不过,最好只在类外部定义的地方说明 inline,这样可以使类更容易理解。

  36. 和非成员函数一样,成员函数也可以被重载,只要函数之间在参数的数量和/或类型上有所区别就行,编译器根据实参的类型决定运行哪个版本的函数。

  37. 有时(但并不频繁)会发生这样一种情况,我们希望能修改类的某个数据成员,即使是在一个 const 成员函数内。可以通过在变量的声明中加入 mutable 关键字做到这一点。一个可变数据成员(mutable data member)永远不会是 const,即使它是 const 对象的成员。因此,一个 const 成员函数可以改变一个可变成员的值。

  38. 当我们初始化类类型的成员时,需要为构造函数传递一个符合成员类型的实参。类内初始值必须使用 = 的初始化形式或者花括号括起来的直接初始化形式。

  39. 返回引用的函数是左值的,意味着这些函数返回的是对象本身而非对象的副本。(成员函数返回调用成员函数的对象本身似乎很有用——博主注)

  40. 一个 const 成员函数如果以引用的形式返回 *this,那么它的返回类型将是常量引用。

  41. 通过区分成员函数是否是 const 的,我们可以对其进行重载。因为非常量版本的函数对于常量对象是不可用的,所以我们只能在一个常量对象上调用 const 成员函数。另一方面,虽然可以在非常量对象上调用常量版本或非常量版本,但显然此时非常量版本是一个更好的匹配。

  42. 普通成员函数调用常成员函数时,this 指针将临时隐式地从指向非常量的指针转换成指向常量的指针。

  43. 在实践中,设计良好的 C++ 代码常常包含大量小函数,通过调用这些函数,可以完成一组其他函数的“实际”工作。

  44. 即使两个类的成员列表完全一致,它们也是不同的类型。对于一个类来说,它的成员和其他任何类(或者任何其他作用域)的成员都不是一回事儿。

  45. 我们可以把类名作为类型的名字使用,从而直接指向类类型。或者,我们也可以把类名跟在关键字 classstruct 后面:

    1
    2
    Sales_data iteml;        // 默认初始化 sales_data 类型的对象
    class Sales_data item1; // 一条等价的声明

    上面这两种使用类类型的方式是等价的,其中第二种方式从 C 语言继承而来,并且在 C++ 语言中也是合法的。

  46. 我们也能仅仅声明类而暂时不定义它:

    1
    class Screen;  // Screen 类的声明

    这种声明有时被称作前向声明(forward declaration),它向程序中引入了名字 Screen 并且指明 Screen 是一种类类型。对于类型 Screen 来说,在它声明之后定义之前是一个不完全类型(incomplete type),不完全类型只能在非常有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数

  47. 一个类的成员类型不能是该类自己。然而,一旦一个类的名字出现后,它就被认为是声明过了(但尚未定义),因此类允许包含指向它自身类型的引用或指针

    1
    2
    3
    4
    5
    6
    class Link screen
    {
    Screen window;
    Link_screen *next;
    Link_screen *prev;
    };
  48. 定义一对类 X 和 Y,其中 X 包含一个指向 Y 的指针,而 Y 包含一个类型为 X 的对象:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Y;

    class X
    {
    Y *py;
    };

    class Y
    {
    X x;
    };
  49. 如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非公有成员在内的所有成员。

  50. 必须要注意的一点是,友元关系不存在传递性。(不能由“A 是 B 的朋友,B 是 C 的朋友”这一事实推导出“A 是 C 的朋友”——博主注)

    每个类负责控制自己的友元类或友元函数。

  51. 当把一个成员函数声明成友元时,我们必须明确指出该成员函数属于哪个类:

    1
    2
    3
    4
    5
    6
    class Screen
    {
    // Window_mgr::clear 必须在 Screen 类之前被声明
    friend void Window_mgr::clear(ScreenIndex);
    // Screen 类的剩余部分
    };

    要想令某个成员函数作为友元,我们必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。

  52. 尽管重载函数的名字相同,但它们仍然是不同的函数。因此,如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明。

  53. 类和非成员函数的声明不是必须在它们的友元声明之前。当一个名字第一次出现在一个友元声明中时,我们隐式地假定该名字在当前作用域中是可见的。然而,友元本身不一定真的声明在当前作用域中。甚至就算在类的内部定义该函数,我们也必须在类的外部提供相应的声明从而使得函数可见。换句话说,即使我们仅仅是用声明友元的类的成员调用该友元函数,它也必须是被声明过的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    struct X
    {
    friend void f() { /* 友元函数可以定义在类的内部 */ }
    X() { f(); } // 错误:f还没有被声明
    void g();
    void h();
    };
    void X::g() { return f(); } // 错误:f 还没有被声明
    void f(); // 声明那个定义在 X 中的函数
    void X::h() { return f(); } // 正确:现在 f 的声明在作用城中了

    关于这段代码最重要的是理解友元声明的作用是影响访问权限,它本身并非普通意义上的声明

  54. 一个类就是一个作用域的事实能够很好地解释为什么当我们在类的外部定义成员函数时必须同时提供类名和函数名。在类的外部,成员的名字被隐藏起来了。一旦遇到了类名,定义的剩余部分就在类的作用域之内了,这里的剩余部分包括参数列表和函数体。结果就是,我们可以直接使用类的其他成员而无须再次授权了。

  55. 通常情况下,名字查找(name lookup)(寻找与所用名字最匹配的声明的过程)的过程比较直截了当:

    • 首先,在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明
    • 如果没找到,继续查找外层作用域;
    • 如果最终没有找到匹配的声明,则程序报错。

    对于定义在类内部的成员函数来说,解析其中名字的方式与上述的查找规则有所区别。类的定义分两步处理:

    • 首先,编译成员的声明;
    • 直到类全部可见后才编译函数体。

    按照这种两阶段的方式处理类可以简化类代码的组织方式。因为成员函数体直到整个类可见后才会被处理,所以它能使用类中定义的任何名字。 相反,如果函数的定义和成员的声明被同时处理,那么我们将不得不在成员函数中只使用那些已经出现的名字。

  56. 这种两阶段的处理方式只适用于成员函数中使用的名字。声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见。如果某个成员的声明使用了类中尚未出现的名字,则编译器将会在定义该类的作用域中继续查找。 例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    typedef double Money;
    string bal;
    class Account
    {
    public:
    Money balance() { return bal; }
    private:
    Money bal;
    // ...
    };

    当编译器看到 balance 函数的声明语句时,它将在 Account 类的范围内寻找对 Money 的声明。编译器只考虑 Account 中在使用 Money 前出现的声明,因为没找到匹配的成员,所以编译器会接着到 Account 的外层作用域中查找。在这个例子中,编译器会找到 Money 的 typedef 语句,该类型被用作 balance 函数的返回类型以及数据成员 bal 的类型。另一方面,balance 函数体在整个类可见后才被处理,因此,该函数的 return 语句返回名为 bal 的成员,而非外层作用域的 string 对象

  57. 一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    typedef double Money;
    class Account
    {
    public:
    Money balance() { return bal; } // 使用外层作用城的 Money
    private:
    typedef double Money; // 错误:不能重新定义 Money
    Money bal;
    // ...
    };

    需要特别注意的是,即使 Account 中定义的 Money 类型与外层作用域一致,上述代码仍然是错误的
    尽管重新定义类型名字是一种错误的行为,但是编译器并不为此负责。一些编译器仍将顺利通过这样的代码,而忽略代码有错的事实。

    Tip:类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后。

  58. 成员函数中使用的名字按照如下方式解析:

    • 首先,在成员函数内查找该名字的声明。和前面一样,只有在函数使用之前出现的声明才被考虑;
    • 如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑;
    • 如果类内也没找到该名字的声明,在成员函数定义之前的作用域内继续查找。

    一般来说,不建议使用其他成员的名字作为某个成员函数的参数。

    尽管类的成员可能被隐蔽,但我们仍然可以通过加上类的名字或显式地使用 this 指针来强制访问成员。

  59. 如果编译器在函数和类的作用域中都没有找到名字,它将接着在外围的作用域中查找。 如果我们需要的是外层作用域中的名字,可以显式地通过作用域运算符来进行请求。

  60. 当成员定义在类的外部时,名字查找的第三步不仅要考虑类定义之前的全局作用域中的声明,还需要考虑在成员函数定义之前的全局作用域中的声明。

  61. 如果没有在构造函数的初始值列表中显式地初始化成员,则该成员将在构造函数体之前执行默认初始化。

  62. 有时我们可以忽略数据成员初始化和赋值之间的差异,但并非总能这样。如果成员是 const 或者是引用的话,必须将其初始化。类似的,当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化。

  63. 随着构造函数体一开始执行,初始化就完成了。我们初始化 const 或者引用类型的数据成员的唯一机会就是通过构造函数初始值。

    如果成员是 const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。

  64. 建议:使用构造函数初始值。 在很多类中,初始化和赋值的区别事关底层效率问题:前者直接初始化数据成员,后者则先初始化再赋值。除了效率问题外更重要的是,一些数据成员必须被初始化。建议读者养成使用构造函数初始值的习惯,这样能避免某些意想不到的编译错误,特别是遇到有的类含有需要构造函数初始值的成员时。

  65. 在构造函数初始值中每个成员只能出现一次。构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。成员的初始化顺序与它们在类定义中的出现顺序一致:第一个成员先被初始化,然后第二个,以此类推。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。

  66. 有的编译器具备一项比较友好的功能,即当构造函数初始值列表中的数据成员顺序与这些成员声明的顺序不符时会生成一条警告信息。

    最佳实践:最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用某些成员初始化其他成员。
    如果可能的话,最好用构造函数的参数作为成员的初始值,而尽量避免使用同一个对象的其他成员。这样的好处是我们可以不必考虑成员的初始化顺序。

  67. 如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。

  68. 一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数。和其他构造函数一样,一一个委托构造函数也有一个成员初始值的列表和一个函数体。 在委托构造函数内,成员初始值列表只有一个唯一的入口,就是类名本身。和其他成员初始值一样,类名后面紧跟圆括号括起来的参数列表,参数列表必须与类中另外一个构造函数匹配。

  69. 当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行,然后控制权才会交还给委托者的函数体。

  70. 当对象被默认初始化或值初始化时自动执行默认构造函数。默认初始化在以下情况下发生:

    • 当我们在块作用域内不使用任何初始值定义一个非静态变量;
    • 当一个类本身含有类类型的成员且使用合成的默认构造函数时;
    • 当类类型的成员没有在构造函数初始值列表中显式地初始化时。

    值初始化在以下情况下发生:

    • 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时;
    • 当我们不使用初始值定义一个局部静态变量时;
    • 当我们通过书写形如 T()的表达式显式地请求值初始化时,其中 T 是类型名(vector 的一个构造函数只接受一个实参用于说明 vector 大小,它就是使用一个这种形式的实参来对它的元素初始化器进行值初始化)。

    类必须包含一个默认构造函数以便在上述情况下使用,其中的大多数情况非常容易判断。不那么明显的一种情况是类的某些数据成员缺少默认构造函数。

    最佳实践:在实际中,如果定义了其他构造函数,那么最好也提供一个默认构造函数。

  71. 如果想定义一个使用默认构造函数进行初始化的对象,正确的方法是去掉对象名之后的空的括号对。

  72. 如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数(converting constructor)。

    能通过一个实参调用的构造函数定义了一条从构造函数的参数类型向类类型隐式转换的规则。

  73. 编译器只会自动地执行一步类型转换。例如,因为下面的代码隐式地使用了两种转换规则,所以它是错误的:

    1
    2
    3
    4
    // 错误:需要用户定义的两种转换:
    //(1)把“9-999-99999-9”转换成 string
    //(2)再把这个(临时的)string 转换成 sales_data.
    item.combine("9-999-99999-9");

    如果我们想完成上述调用,可以显式地把字符串转换成 string 或者 sales_data 对象:

    1
    2
    3
    4
    // 正确:显式地转换成 string,隐式地转换成 Sales_data:
    item.combine(string("9-999-99999-9"));
    // 正确:隐式地转换成 string,显式地转换成 sales_data
    item.combine(Sales_data("9-999-99999-9"));
  74. 在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为 explicit 加以阻止:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class Sales_data
    {
    public:
    Sales_data() = default;
    Sales_data(const std::string &S, unsigned n, double p):
    bookNo(s), units_sold(n), revenue(p * n) {}
    explicit Sales_data(const std::string &s): bookNo(s) {}
    explicit Sales_data(std::istream&);
    // 其他成员与之前的版本一致
    };

    此时,没有任何构造函数能用于隐式地创建 Sales_data 对象,之前的两种用法都无法通过编译:

    1
    2
    item.combine(null_book);  // 错误:string 构造函数是 explicit 的
    item.combine(cin); // 错误:istream 构造函数是 explicit 的

    关键字 explicit 只对一个实参的构造函数有效。需要多个实参的构造函数不能用于执行隐式转换,所以无须将这些构造函数指定为 explicit 的。只能在类内声明构造函数时使用 explicit 关键字,在类外部定义时不应重复。

  75. 发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=。此时,我们只能使用直接初始化而不能使用 explicit 构造函数。

    当我们用 explicit 关键字声明构造函数时,它将只能以直接初始化的形式使用。而且,编译器将不会在自动转换过程中使用该构造函数。

  76. 尽管编译器不会将 explicit 的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显式地强制进行转换:

    1
    2
    3
    4
    // 正确:实参是一个显式构造的 Sales_data 对象
    item.combine(Sales_data(null__book));
    // 正确:static_cast 可以使用 explicit 的构造函数
    item.combine(static cast<Sales_data>(cin));
  77. 聚合类(aggregate class)使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:

    • 所有成员都是 public 的;
    • 没有定义任何构造函数;
    • 没有类内初始值;
    • 没有基类,也没有 virtual 函数。

  78. 可以使用一个花括号括起来的成员初始值列表来初始化聚合类的数据成员。初始值的顺序必须与声明的顺序一致,也就是说,第一个成员的初始值要放在第一个,然后是第二个,以此类推。与初始化数组元素的规则一样,如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化。初始值列表的元素个数绝对不能超过类的成员数量。

  79. 值得注意的是,显式地初始化类的对象的成员存在三个明显的缺点:

    • 要求类的所有成员都是 public 的;
    • 将正确初始化每个对象的每个成员的重任交给了类的用户(而非类的作者)。因为用户很容易忘掉某个初始值,或者提供一个不恰当的初始值,所以这样的初始化过程冗长乏味且容易出错;
    • 添加或删除一个成员之后,所有的初始化语句都需要更新。

  80. constexpr 函数的参数和返回值必须是字面值类型。除了算术类型、引用和指针外,某些类也是字面值类型。和其他类不同,字面值类型的类可能含有 constexpr 函数成员。这样的成员必须符合 constexpr 函数的所有要求,它们是隐式 const 的。数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类:

    • 数据成员都必须是字面值类型;
    • 类必须至少含有一个 constexpr 构造函数;
    • 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的 constexpr 构造函数;
    • 类必须使用析构函数的默认定义,该成员负责销毁类的对象。

  81. 尽管构造函数不能是 const 的,但是字面值常量类的构造函数可以是 constexpr 函数。事实上,必须至少提供一个 constexpr 构造函数。

  82. constexpr 构造函数可以声明成 =default 的形式(或者是删除函数的形式)。否则,constexpr 构造函数就必须既符合构造函数的要求(意味着不能包含返回语句),又符合 constexpr 函数的要求(意味着它能拥有的唯一可执行语句就是返回语句。综合这两点可知,constexpr 构造函数体一般来说应该是空的。我们通过前置关键字 constexpr 就可以声明一个 constexpr 构造函数了。

  83. constexpr 构造函数必须初始化所有数据成员,初始值或者使用 constexpr 构造函数,或者是一条常量表达式。constexpr 构造函数用于生成 constexpr 对象以及 constexpr 函数的参数或返回类型。

  84. 我们通过在成员的声明之前加上关键字 static 使得其与类关联在一起。和其他成员一样,静态成员可以是 public 的或 private 的。静态数据成员的类型可以是常量、引用、指针、类类型等。类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。静态成员函数也不与任何对象绑定在一起,它们不包含 this 指针。作为结果,静态成员函数不能声明成 const 的,而且我们也不能在 static 函数体内使用 this 指针。这一限制既适用于 this 的显式使用,也对调用非静态成员的隐式使用有效。

  85. 我们可以通过作用域运算符直接访问类的静态成员。虽然静态成员不属于类的某个对象,但是我们仍然可以使用类的对象、引用或者指针来访问静态成员。

  86. 和其他的成员函数一样,我们既可以在类的内部也可以在类的外部定义静态成员函数。当在类的外部定义静态成员时,不能重复 static 关键字,该关键字只出现在类内部的声明语句。

  87. 因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。这意味着静态数据成员不是由类的构造函数初始化的。而且一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。和其他对象一样,一个静态数据成员只能定义一次。类似于全局变量,静态数据成员定义在任何函数之外。因此一旦它被定义,就将一直存在于程序的整个生命周期中。定义静态数据成员的方式和在类的外部定义成员函数类似,需要指定对象的类型名,然后是类名、作用域运算符以及成员自己的名字。

  88. Tip:要想确保对象只定义一次,最好的办法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。

  89. 通常情况下,类的静态成员不应该在类的内部初始化。然而,我们可以为静态成员提供 const 整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的 constexpr。初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能用在所有适合于常量表达式的地方。例如,我们可以用一个初始化了的静态数据成员指定数组成员的维度

  90. 如果某个静态成员的应用场景仅限于编译器可以替换它的值的情况,则一个初始化的 constconstexpr static 不需要分别定义。相反,如果我们将它用于值不能替换的场景中,则该成员必须有一条定义语句。如果在类的内部提供了一个初始值,则成员的定义不能再指定一个初始值了

    1
    2
    // 一个不带初始值的静态成员的定义
    constexpr int Account::period; // 初始值在类的定义内提供

    最佳实践:即使一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。

  91. 静态成员独立于任何对象,因此,在某些非静态数据成员可能非法的场合,静态成员却可以正常地使用。举个例子,静态数据成员可以是不完全类型。特别的,静态数据成员的类型可以就是它所属的类类型。而非静态数据成员则受到限制,只能声明成它所属类的指针或引用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class Bar
    {
    public:
    // ...
    private:
    static Bar mem1; // 正确:静态成员可以是不完全类型
    Bar *mem2; // 正确:指针成员可以是不完全类型
    Bar mem3; // 错误:数据成员必须是完全类型
    };
  92. 静态成员和普通成员的另外一个区别是我们可以使用静态成员作为默认实参

    1
    2
    3
    4
    5
    6
    7
    8
    class Screen
    {
    public:
    // bkground 表示一个在类中稍后定义的静态成员
    Screen &clear(char = bkground);
    private:
    static const char bkground;
    }

    非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分,这么做的结果是无法真正提供一个对象以便从中获取成员的值,最终将引发错误。

  93. 带有类内初始化表达式的静态数据成员必须具有不可变的常量整型类型,或必须被指定为“内联”。

  94. 一些关键术语:

    • 常量成员函数(const member function) 一个成员函数,在其中不能修改对象的普通(即既不是 static 也不是 mutable)数据成员。const 成员的 this 指针是指向常量的指针,通过区分函数是否是 const 可以进行重载。

    • 转换构造函数(converting constructor) 可以用一个实参调用的非显式构造函数。这样的函数隐式地将参数类型转换成类类型。

    • 委托构造函数(delegating constructor) 委托构造的数的初始值列表只有一个入口,指定类的另一个构造函数执行初始化操作。

    • 显式构造函数(explicit constructor) 可以用一个单独的实参调用但是不能用于隐式转换的构造函数。通过在构造函数的声明之前加上 explicit 关键字就可以将其声明成显式构造函数。

    • 不完全类型(incomplete type) 已经声明但是尚未定义的类型。不完全类型不能用于定义变量或者类的成员,但是用不完全类型定义指针或者引用是合法的

    • 成员函数(member function) 类的函数成员。普通的成员函数通过隐式的 this 指针与类的对象绑定在一起:静态成员函数不与对象绑定在一起也没有 this 指针。成员函数可以重载,此时隐式的 this 指针参与函数匹配的过程。

    • 可变数据成员(mutable data member) 这种成员永远不是 const,即使它属于 const 对象。const 函数内可以修改可变数据成员。

    • 合成默认构造函数(synthesized default constructor) 对于没有显式地定义任何构造函数的类,编译器为其创建(合成)的默认构造函数。合成默认构造函数检查类的数据成员,如果提供了类内初始值,就用它执行初始化操作;否则就对数据成员执行默认初始化。当某个数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化。

    • = default 一种语法形式,位于类内部默认构造函数声明语句的参数列表之后,要求编译器生成构造函数,而不管类是否已经有了其他构造函数。


Thank you for your donate!