[关闭]
@taqikema 2018-01-04T12:37:40.000000Z 字数 4804 阅读 1112

第7章 类

C++Primer 学习记录



7.1 定义抽象数据类型

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

  2. 默认情况下,this的类型是指向类类型非常量版本的常量指针(顶层 const),因此不能在常量对象上调用一个普通的成员函数(即将普通指针指向常量对象)。而如果想要声明常量成员函数,就在参数列表后面加上 const,表示 this指针是一个指向常量的指针。常量对象、引用即指针都只能调用常成员函数。

  3. 编译器首先编译成员的声明,然后才轮到函数体。所以在函数体中可以随意使用类中出现的其他成员而不用在乎出现的先后次序。
  4. 一些函数在概念上属于类但是不定义在类中,则该函数的声明应与类在同一个头文件内。这样,用户使用接口的任何部分都只需要引入一个文件。

  5. 在自定义或重载与输出有关的函数时,应尽量减少对格式的控制,这样可以增强该函数的适用性,由用户自行决定是否换行或进行其它格式控制。

  6. 如果一个类没有声明任何构造函数时,编译器会自动生成默认构造函数。然而某些类不能依赖于合成的默认构造函数。

    • 一旦定义了其他的构造函数,除非自己定义一个默认函数,否则此类将没有构造函数。
    • 合成的默认构造函数可能执行错误的操作。当默认初始化类的内置类型的数据成员时,如果在类内没有初始值,则执行默认初始化后这些成员变量的值是未定义的。
    1. class A{
    2. int i; // 错误,默认初始化时 i的值是未定义的
    3. };
    4. class B{
    5. int i = 0; // 正确
    6. };
    • 编译器不能为某些类合成默认的构造函数。例如,如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。
  7. C++11新标准中,可以通过在参数列表后面写上=default来要求编译器生成默认构造函数。
  8. 当某个数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化。所以如果此时内置类型的数据成员没有使用类内初始化,也被初始列表忽略时,该对象在使用该构造函数构造后,这些成员变量将会是未初始化的。

7.2 访问控制与封装

  1. 使用 class和 struct都可以定义一个类,不同的是 class的默认访问权限和继承保护级别是 private的,而 struct是 public的。
  2. 友元。
    • 友元声明只能出现在类定义的内部,但是在类内出现具体位置不限,不过最好还是在类定义开始前集中声明友元。
    • 友元不是类的成员,也不受它所在的区域访问控制级别的约束。
    • 友元仅仅指定访问权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,就必须在友元声明之外再专门对函数进行一次声明。
    • 为了使友元对用户可见,通常把友元函数自己的声明与类本身放置在同一个头文件中(类的外部)。

7.3 类的其他特性

  1. 在类中,除了定义数据和函数成员之外,还可以自定义某种类型在类中的别名,也存在访问权限。用来定义类型的成员必须先定义后使用,这一点与普通成员有所不同。

    1. class Screen {
    2. public:
    3. // 在类中定义一个类型
    4. using pos = std::string::size_type;
    5. };
  2. 定义在类内部的成员函数自动是 inline的,但是也可以在类外部定义时说明 inline,以此来显式指定 inline函数。但是在类外部进行函数定义,既可以在头文件中,也可以在源文件中。考虑到 inline函数可以多次定义但每个定义必须相同的特点,在类外显示指定的 inline函数应该与相应的类定义在同一个头文件中

  3. 可变数据成员。有时会遇到需要修改类的某个数据成员,即使该对象是 const对象或是在 const成员函数内。可以在该变量的声明中加入 mutable关键字,来实现这一目的。

  4. 类内初始值必须以符号=或者{}表示,不能使用()

  5. 通过区分成员函数是否是 const的,可以对其重载。常量对象只能调用 const函数,而非常量对象会优先调用 普通函数。

  6. 不完全类型,一个类在声明之后定义之前的状态。不完全类型只能在非常有限的场景下使用:定义指向这种类型的指针或引用,声明(但不能定义)以不完全类型作为参数或返回值的函数。

  7. 因为只有完成类的定义,编译器才能知道存储该类型的对象需要多少空间,所以,一个类型的对象只有当类完全定义过了,才能被声明成这种类型。也就表明了一个类的成员类型不能是该类自己,但可以是自身类型的指针或引用

  8. 友元类的声明和程序设计较为简单。比如,B是 A的友元类。

    1. // A.h
    2. friend class B;
    3. // B.h
    4. \#include "A.h"

但是令类 B的成员函数是 A的友元,程序的设计就没那么简单了。既要满足声明和定义的彼此依赖关系,又要时刻注意防止头文件循环包含

  1. // 循环包含的例子
  2. // A.h
  3. #include "B.h"
  4. // B.h
  5. #include "A.h"
  1. // B的成员函数是 A的友元的设计范例
  2. // A.h
  3. #include "B.h"
  4. // B.h
  5. class A;
  6. // B.cpp
  7. #include "A.h"

上面的代码中,类 A需要使用类 B的公有接口或数据,所以要在 .h文件中包含 B.h文件。而为了定义是 A的友元的类 B的成员函数,也需要 A的完整定义。所以在 B.cpp文件中需要包含 A.h文件。


7.4 类的作用域

  1. 类外定义的函数,参数列表和函数体是在类的作用域之内的,而返回类型中使用的名字是位于类的作用域之外的。所以返回类型必须明确指定它是哪个类的成员。

  2. 普通作用域的名字查找过程:

    • 在名字所在的块中寻找声明语句,只考虑在名字的使用之前出现的声明。
    • 没找到,则继续查找外层作用域。
    • 如果最终没有找到匹配的函数声明,则程序报错。
  3. 成员函数中出现的名字的查找过程:
    • 首先,在成员函数内查找该名字的声明。此时,只有函数使用之前出现的声明才被考虑。
    • 如果成员函数内没有找到,则在类内继续查找,这时类内所有成员都可以考虑。
    • 如果类内也没找到该名字的声明,在成员函数定义之前的作用域内继续查找。对于类外定义的成员函数,此时不仅要考虑类定义之前的全局作用域,还需要考虑函数定义之前的全局作用域中的声明。如果使用分离式编译,则在 .cpp文件内、成员函数定义之前的名字的声明也会被考虑在内。
  4. 注意,上述查找过程只适用于成员函数定义时出现的名字,而函数声明时,返回类型或参数列表中使用的名字(通常是类型别名),都必须在使用前确保可见。如果成员的声明中使用了类中尚未出现的名字,则编译器将会在定义该类的作用域中继续查找。

    1. typedef double Money;
    2. string bal;
    3. class Account {
    4. public:
    5. // 下面的 Money是外层作用域的,而 bal是类内作用域的
    6. Money balance() { return bal; }
    7. private:
    8. Money bal;
  5. 在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字。前提使用过该名字,所以如果在类的开始处,重新定义了该名字,则类中使用的将是类内作用域中定义的版本。

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

7.5 构造函数再探

  1. 如果没有在构造函数初始值列表中显式地初始化成员,则该成员在构造函数体执行之前执行默认初始化。随着构造函数体一开始执行,初始化就完成了。因此,如果成员是 const、引用,或者属于某种未提供默认构造函数的类类型,就必须通过构造函数初始值列表来为这些成员提供初始值。

  2. 构造函数初始值列表只说明用于初始化成员的值,而不限定成员的初始化顺序。成员的初始化顺序与它们在类定义中的出现顺序一致。下面的代码中看似会先初始化 j,再初始化 i。实则不然。因此,最好令构造函数初始值的顺序与成员函数的声明顺序一致,且避免用某些成员的值初始化其他成员。

    1. class X {
    2. int i;
    3. int j;
    4. public:
    5. // 未定义的:i在 j之前被初始化
    6. X(int val) : j(val), i(j) {}
    7. };
  3. 如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认构造函数。

  4. 委托构造函数,使用它所属类的其他构造函数执行它自己的初始化过程,或者说把自己的一些(或全部)职责委托给了其他构造函数。形式如下,注意成员初始值列表中只能有一个唯一入口,就是类名本身。

    1. class X {
    2. public:
    3. X(int j) : i(j) {}
    4. X() : X(0) {} // 委托构造函数
    5. private:
    6. int i;
    7. };
  5. 构造函数存在委托关系时,程序的执行顺序:受委托构造函数的初始值列表和函数体被依次执行,先执行完受委托构造函数的函数体后,控制权才会交还给委托构造函数的函数体。
    构造函数的执行顺序.png

  6. X obj(); // 定义了一个函数而非对象
    X obj; // 定义了一个对象
  7. 能通过一个实参调用的构造函数定义了一条从构造函数的参数类型向类类型隐式转换的规则。其中,隐式转换的形参只能是值拷贝或常量引用的形式,而不能是普通引用,这是因为编译器会隐式地调用相应的构造函数来生成一个临时对象,而临时对象是不能传递给一个普通引用的。不过,只允许一步类类型转换
  8. 关键字 explicit,可以抑制构造函数定义的隐式转换。不过,只对一个实参的构造函数有效,并且只能出现在类内声明处。对于 explicit构造函数,只能使用直接初始化,而不能使用拷贝初始化。

    1. class X {
    2. public:
    3. explicit X(int j) : i(j) {}
    4. X() : X(0) {} // 委托构造函数
    5. private:
    6. int i;
    7. };
    8. X item(10); // 直接初始化
    9. X item2 = 10; // 错误
  9. 聚合类。需要满足以下条件:

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

可以提供一个花括号括起来的成员初始化列表来初始化聚合类的数据成员。初始值的顺序必须与声明的顺序一致。

  1. struct Data {
  2. int ival;
  3. string s;
  4. };
  5. Data val_1 = { 0, "Anna" };
  6. // 错误,不能使用 const char*初始化 int
  7. Data val_2 = { "Anna", 0 };
  1. 一个字面值常量类至少应该含有一个 constexpr构造函数。constexpr构造函数必须既符合构造函数的要求(没有返回语句),又要符合 constexpr函数的要求(所能拥有的唯一可执行语句就是返回语句),因此,constexpr构造函数函数体一般为空。

7.6 类的静态成员

  1. 静态成员不属于任何对象,不包含 this指针。因此,静态成员函数不能声明成 const的。
  2. 静态成员通常都只在类内声明,类外定义。并且 static关键字只能出现在类内声明语句中。
    • 非字面值常量类型的静态数据成员必须在类外进行初始化。
    • 能进行类内初始化的静态数据成员必须是字面值常量,包括算术类型、引用、指针等。其中,vector、string不是字面值类型。
    • 即使进行了类内的静态常量初始化,也最好在类外定义一下该变量,只是不能再次指定初始值。
  3. 静态数据成员可以是不完全类型。即,静态数据成员的类型可以就是它所属的类类型,而非静态数据成员只能是所属类的引用或指针。

    1. class Bar {
    2. private:
    3. static Bar mem1; // 正确,静态成员可以是不完全类型
    4. Bar *mem2; // 正确,指针成员可以是不完全类型
    5. Bar mem3; // 错误,数据成员必须完全类型
    6. };
  4. 静态成员可以作为默认实参,而非静态成员则不可以。

    1. class Screen {
    2. public:
    3. // bkground表示一个在类中稍后定义的静态成员
    4. Screen& clear(cahr = bkground);
    5. private:
    6. static const char bkground;
    7. };
添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注