7.1 类的概念

我们之前都接触过类,类就是类型,我们所有的变量都有一个类型属性,这章的类是介绍我们怎么定义和使用自己所属的类型。

类是编程范式之一的面向对象程序设计(object-oriented programming)中最基础也是最重要的一种设计模式。

面向对象程序设计的核心思想是

  • 数据抽象
  • 继承
  • 动态绑定(多态性)

使用数据抽象,我们可以将类的接口与实现分离。 使用继承,可以定义相似的类型并对其相似关系建模。 使用动态绑定,可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象。

其中,类最基本的思想是

  • 数据抽象(data abstraction)
  • 封装(encapsulation)。

数据抽象是一种依赖于以下这两个部分分离的编程(以及设计)技术。:

  • 接口(interface)
  • 实现(implementation)

类的接口包括用户所能执行的操作。 类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数和其他类。

封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分。

所以为了实现数据抽象和封装,需要首先定义一个抽象数据类型(abstract data type),这里所说的抽象数据类型也就是我们自定义的类型,也就是类类型。

在抽象数据类型中,由类的设计者负责考虑类的实现过程;使用该类的程序员则只需要抽象地思考类型做了什么,而无须了解类型的工作细节。

7.2 类的定义和声明

7.21 类的定义

类的定义有两种形式:

  1. 类关键字 类名 (可选 类派生列表) 类体;

  2. 类关键字 (可选 类名) (可选 类派生列表) 类体 类对象列表;

第二种形式是类的定义与类对象的定义的结合形式。 类对象列表是由多个逗号分隔的对象名组成的复杂表达式,表示该类多个对象的定义。 第二种形式中,类名可以省略,但是如果省略后,该类必须要有类对象列表,不能定义一个无意义的无名类。而且省略类名的类之后就不能再定义该类的对象了。

类不是对象,所以类的定义中不能加各种类型修饰符和存储说明符。

每个类定义了唯一的类型。对于两个不同类名的类来说,即使它们类体里的代码完全一样,这两个类也是两个不同的类型。

7.211 类定义各部分的介绍

7.2111 类关键字

类关键字可以是关键字struct或者class,类关键字的区别在于对类体中成员的默认访问权限。

7.2112 类名

类名指的是该定义的类型的类型名,同一作用域中,不能有相同的类名。类名可以与其他变量名相同,但是之后该类就不能再定义自己的对象了(会被同名的变量所覆盖)。

7.2113 类派生列表

类派生列表(class derivation list)是用于类继承中的,表示该类所要继承的类。

7.21131 继承简述

通过继承(inheritance)联系在一起的类构成一种层次关系。 通常在层次关系的根部有一个基类(base class),其他类则直接或间接地从基类继承而来,这些继承得到的类称为派生类(derived class)。 一般来说,基类负责定义在层次关系中所有类共同拥有的成员,而每个派生类定义各自特有的成员。

注意被继承的类只能是类类型,不能是系统内置的类型(包括基本类型,复合类型、枚举、共用体等类型)。

一个类可以同时继承任意多个直接基类,这也叫作多重继承(multiple inheritance),多重继承的派生类继承了所有父类的属性。

通常情况,一个派生类会继承其每个直接基类的所有成员(包括直接基类所定义的类和类型别名),不存在只继承一部分这种行为。

派生类继承的成员也就是指在该派生类中包含这个继承成员的一个副本,这个副本与其原成员一模一样(也就是包含其原成员的各种限定符和说明符以及初始值和函数体内容等所有的代码)

7.21132 类派生列表的形式

类派生列表是指定义一个类时,该类所要继承的所有类的一个说明列表,所有需要继承的类必须要在该列表中出现。 类派生列表的形式为:

: (可选 virtual) (可选 访问说明符) 类名1, (可选 访问说明符) (可选 virtual) 类名2, (可选 virtual) (可选 访问说明符) 类名3, ···

一个类派生列表的类可以有多个,其中每个类前可以加访问说明符和关键字virtual(这两个的顺序可以任意),以表示定义的类对于该类的继承方式。

类派生列表里的每个类必须都要有定义,否则不能在类派生列表中使用。

一个类派生列表中不能有相同的类。 但是一个类可能会多次继承同一个类的成员。比如其有多个直接基类继承了同一个类,导致该类多次继承了这个类。

每个基类的继承方式中的访问说明符是用来控制该派生类对其继承成员的访问权限;关键字virtual是决定这个基类的该派生类的派生类是否会多次继承这个基类的成员。

7.21133 按继承关系的类分类

按照继承关系,所有的类都可以分为两种:

  • 基类
    • 直接基类
    • 间接基类
  • 派生类
    • 直接派生类
    • 间接派生类

基类

在一个类的定义中,类派生列表中所出现的所有的类被称为这个定义类的基类,且派生列表中所出现的每个类的基类以及这个基类的基类,一直到继承关系的源头的类,都被称为这个定义类的基类。

在一个定义类所有的基类中,出现在定义类的类派生列表中的类为该定义类的直接基类(direct base);其他的基类都为该定义类的间接基类(indirect base)。

派生类

一个定义类的基类都把这个定义类称为自己的派生类。

其中定义类的直接基类都称该定义类为自己的直接派生类;而定义类的其他基类都称该定义类为自己的间接派生类。

/* Base没有基类;有3个派生类,为Deri, Deri1, Deri2;
其中,直接派生类为Deri, Deri1。*/
class Base {};
/* Base1没有基类;有1个派生类,为Deri2;
其中,直接派生类为Deri2。*/
struct Base1 {};
/* Deri有1个基类,为Base;有2个派生类,为Deri1, Deri2;
其中,直接基类为Base;直接派生类为Deri1, Deri2。*/
struct Deri: private Base {};
/* Deri1有2个基类,为Base, Deri;有1个派生类,为Deri1;
其中,直接基类为Base, Deri;直接派生类为Deri1。*/
class Deri1: public virtual Base, virtual protected Deri {};
/* Deri2有3个基类,为Deri, Base1, Deri1;没有派生类;
其中,直接基类为Deri, Base1, Deri1。*/
struct Deri2: Deri, virtual Base1, Deri1 {};
7.2114 类体

类体是一个复合语句,类体中可以包含多种代码,但这些代码必须为下列形式的才行,否则会定义出错。

  1. 对象(包括函数)、类或者模板的声明或定义语句(包括类型别名的定义)。 对象(包括函数)、类或者模板的声明或定义语句(包括类型别名的定义)说明了这些实体都为该类的成员。
  2. 访问说明符。 访问说明符是用来决定该类对象对于其中成员的访问权限的。
  3. 友元声明语句。 友元声明语句是用来说明非该类成员的函数或类对于该类成员的访问权限的。
  4. using声明语句。 详见using声明语句
  5. static_assert声明语句(since c++11)。
  6. 类模板自动推导(since c++17)。
  7. using枚举对象声明语句(since c++20)。

其中对象(包括函数)的定义语句不能用auto类型来定义。

// 类Cls,包含一个int类数据成员和一个类Lo。
struct Cls
{ 
    int cls_ins;
    using Lo = long;
};
/* 类Cls2,包含一个访问说明符、
一个double类数据成员
、一个函数成员和
一个类Dou。
并定义了该类的两个对象ob1, ob2*/
class Cls2 
{
public: 
   double cls2_dou = 8; 
   void cls2_f();
   typedef double Dou;
} ob1, ob2;
/* 一个未命名的类,
包含一个友元类声明,
一个访问说明符、
、一个函数成员和
一个类Subcls。
定义了该类的两个对象ob3, ob4*/
struct
{
friend class Cls;
private: 
   void cls_f(){}
   class Subcls {};
} ob3, ob4;
/* 一个未命名的派生类,
继承于类Cls, Cls2。
包含所有继承类的所有成员和一个访问说明符。
并定义了该类对象ob5*/
class :Cls, Cls2 
{
friend void Cls2::cls2_f();
protected:
} ob5;
/* 派生类Decls,
继承于类Cls2。
包含所有继承类的所有成员。*/
class Decls: Cls2 {};

7.22 类的声明

7.221 类的声明形式

就像可以把函数的声明和定义分离开来一样,我们也能仅仅声明类而暂时不定义它。 类的声明形式和定义类似,只不过不用包含类体和类派生列表。

类关键字 类名;

class Screen; // Screen类的声明

一个类声明中的类关键字可以与该类定义中的类关键字不同,但是该类还是以其定义中的类关键字为主。也就是当一个类的声明与定义中的类关键字不一致,则按类的定义来。

7.222 不完全类型

这种声明有时被称作前向声明(forward declaration),它向程序中引入了类的标识符并且指明这个标识符是一种类类型。 对于该类型来说,在它声明之后定义之前是一个不完全类型(incomplete type),也就是说,此时我们已知它是一个类类型,但是不清楚它到底包含哪些成员。

要注意在某作用域不可见的类或既没定义也没声明的类在该作用域中不属于不完全类型,而属于不存在的类型,不存在的类型是根本无法使用的。

7.2221 不完全类型的使用

一个不完全类型的类型只能在以下几种情形使用,其他的情形一律不合法:

  1. 可以定义指向这种类型的指针或引用。
  2. 可以声明(但是不能定义)以不完全类型为类型的变量。
  3. 可以声明(但是不能定义)以不完全类型作为形参或者返回类型的函数。
  4. 可以声明(但是不能定义)以不完全类型为类型的静态数据成员。
  5. 可以当作友元用于友元声明。
  6. 可以用于定义该类的类型别名。

7.3 类以及类对象成员的使用简述

7.31 类的使用

7.311 类对象的创建

类就像普通的类型一样,可以用于定义或声明该类的对象,定义或声明类对象的形式有三种:

  • 类的定义 类对象名1 (可选 初始化), 类对象名2 (可选 初始化), ···;
  • (可选 存储说明符) class/struct 类名(可含类型修饰符) 类对象名 (可选 初始化);
  • (可选 存储说明符) 类名(可含类型修饰符) 类对象名 (可选 初始化);

第一种形式就是之前所描述的在定义时创建类对象; 第二种形式就是在类对象定义前加关键字class或者struct(这两个效果一样); 第三种形式和普通类型的对象定义形式一样。

// 类定义时创建类对象ob1, ob2
struct Cls
{} ob1, ob2;
//  加关键字创建类对象ob3, ob4
class Cls ob3;
struct Cls ob4;
//  普通创建类对象ob3, ob4
Cls ob5;

类对象的初始化就是调用类的构造函数来进行初始化工作,类对象的初始化形式有多种,具体形式以及调用规则详见7.313 类对象的初始化

7.312 类的调用

C++中的几乎所有类型(包括内置类型、类类型,共用体类型等)都支持类似于函数调用形式的使用,也就是将类当做函数,后面跟着圆括号包围的实参表,类的调用其实是隐式调用该类的构造函数,然后返回一个临时的该类对象。所以可以根据实参的不同调用相应的该类构造函数。 形式为:

类名 (实参表)/{实参表};

和函数不一样的是,类调用的实参表可以用花括号包含起来。

// 调用int类型创建了一个值为5的int类临时对象并拷贝给inte
int inte = int{5};
// 调用string类型创建了一个值为strings的string类临时对象并拷贝给str
string str = string("strings");
// 调用vector<int>类型创建了一个元素数为5的vector<int>类临时对象并拷贝给v1
vector<int> v1 = vector<int>{8,9,3,6,4};
// 一个含有多个构造函数的类Cls
struct Cls
{
   int ins;
   string str;
   double dou;
   Cls(int ins, string str, double dou): ins(ins), str(str), dou(dou) {}
   Cls(int ins): Cls(ins, "", 5.5) {}
   Cls(string str): Cls(3, str, 5.5) {}
};
// 调用Cls(int, string, double)
Cls obj = Cls{3,"str", 8.8};
// 调用Cls(int)
Cls obj2 = Cls{3};
// 调用Cls(string)
Cls obj3 = Cls("string");

7.313 类对象的初始化

类对象和普通的对象一样,有着直接初始化和拷贝初始化这两种初始化形式。之前我们说过直接初始化和拷贝初始化的形式的区别,简单来说,就是所有不用赋值运算符的初始化都是直接初始化。

对于类对象的直接初始化和拷贝初始化,它们的区别也就在与它们之间的工作原理:

  • 直接初始化: 当使用直接初始化时,我们实际上是要求编译器使用类似于普通函数匹配的方式,來选择与我们所提供的参数最匹配的构造函数。所以直接初始化时,所有构造函数都会是候选函数(包括拷贝和移动构造函数)。 类对象的直接初始化的形式和上面的类的调用相似,形式为:

    (可选 存储说明符) 类名(可含类型修饰符) 类对象名 (实参表)/{实参表};

    // 一个含有多个构造函数的类Cls
    struct Cls
    {
       int ins;
       string str;
       double dou;
       Cls(int ins, string str, double dou): ins(ins), str(str), dou(dou) {}
       Cls(int ins): Cls(ins, "", 5.5) {}
       Cls(string str): Cls(3, str, 5.5) {}
       Cls():ins(3), str("default"), dou(2.4) {}
    };
    // 调用Cls(int, string, double)
    Cls obj{3,"str", 8.8};
    // 调用Cls(int)
    Cls obj2{3};
    // 调用Cls(string)
    Cls obj3("string");
    // 调用Cls()
    Cls obj4;
    

    要注意,当需要类的默认构造函数来初始化类对象时,该类对象不能用空园括号()来初始化,会被编译器误认为为函数声明,而出错。可以使用空花括号{}以及不加括号来调用默认构造函数。 这种错误也会在调用其他构造函数时嵌套使用空园括号()初始化时发生,因此类对象的直接初始化最好使用花括号{}来进行,以免存在潜在错误。

    // 一个含有多个构造函数的类Cls
    using namespace std;
    struct Cls
    {
       int ins;
       string str;
       double dou;
       Cls(int ins, string str, double dou): ins(ins), str(str), dou(dou) {}
       Cls(int ins): Cls(ins, "", 5.5) {}
       Cls(string str): Cls(3, str, 5.5) {}
       Cls():ins(3), str("default"), dou(2.4) {}
    };
    // 正确,调用默认构造函数Cls()
    Cls obj{};
    // 正确,调用默认构造函数Cls()
    Cls obj2;
    // 错误,该语句会被认为为函数声明Cls(),而不是类对象的初始化。
    Cls obj3();
    // 错误,该语句会被认为为函数声明Cls(string),而不是类对象的初始化。
    Cls obj4(string());
    
  • 拷贝初始化: 当我们使用拷贝初始化时,我们是要求编译器将右侧的同类型运算对象拷贝到正在创建的对象中;如果右侧运算对象不是同类型的对象,则还要对其进行类型转换(其中可能会用到转换构造函数来执行转换)。 对于拷贝初始化,编译器会根据所要拷贝的初始值的左右值属性来决定应该调用拷贝还是移动构造函数,还有可能会跳过拷贝或者移动构造函数直接创建。

    拷贝初始化只会调用拷贝或者移动构造函数,不会调用其他的构造函数。

7.32 类对象成员的使用

对于一个类来说,当我们在该类定义外想访问其成员,有两种访问方式:

  • 我们可以通过该类对象、该类对象的引用或指针,用成员访问运算符来访问该类的所有可访问的数据和非构造函数成员。
  • 我们可以通过作用域运算符来访问该类的所有可访问的静态成员、类成员和构造函数。

当访问对象是重载函数时,遵循重载的调用规则。 访问的构造函数的调用就和类的调用一样,返回一个临时的该类对象。

struct Cls
{
  int ins;
  string str;
  double dou;
  typedef const int cint;
  struct Sub {};
  Cls(int ins, string str, double dou): ins(ins), str(str), dou(dou) {}
  Cls(int ins = 8): Cls(ins, "", 5.5) {}
  Cls(string str): Cls(3, str, 5.5) {}
  ~Cls() {};
};
// 创建一个Cls类的对象
Cls obj;
// 调用obj的ins成员
cout << obj.ins;
// 创建一个Cls类的类类型成员Sub的对象
Cls::Sub sub_obj = Cls::Sub();
// 创建一个Cls类的类类型成员cint的对象
const int c_ins = Cls::cint(9);
// 创建一个Cls类对象
Cls obj2 = Cls::Cls(3,"str",1.3);

7.4 类成员

所有在类中声明或定义的对象(包括函数)或类都是该类的成员,一个类的对象包含该类的所有成员,我们可以通过类或者类对象来对其成员进行操作。 一般来说,类成员的值由包含该成员的类对象决定。 无论是什么类型的访问控制,一个类的所有非继承成员之间都能有权限互相访问。

7.41 类成员分类

一个类的成员按类型可以分为三种:

  • 数据成员
    • 非静态数据成员
      • 可变数据成员
    • 静态数据成员
  • 函数成员
    • 非静态函数成员
      • 虚函数
    • 静态函数成员
    • 构造函数
    • 析构函数
  • 类类型成员
    • 嵌套类
    • 类型别名
  • 模板成员

数据成员指的是非函数的变量(可以是常量,引用等各种类型的变量)。 函数成员就是函数。 类类型成员指的是类类型或者是类型别名。 模板成员就是指为模板的成员,我们之后会在模板那一章介绍模板成员。

7.42 静态成员的概念

所有的成员都不能用除了static外的存储说明符(类类型成员也是类,所以不能用任何的存储说明符)。根据成员是否有static,所有成员可以分为:

  • 非静态成员
  • 静态成员

7.421 非静态成员介绍

非静态成员就是其声明或定义语句中没有static说明符的成员。

所有的非静态成员是与包含该成员的类对象相关联的,其值也是由该对象决定的。

7.422 静态成员介绍

静态成员是指其声明或定义语句中含有static说明符的成员。

所有的静态成员是与该类相关联的,而不是与类对象关联;静态成员的值是由定义类时对应定义语句中所给的初始值决定的。

7.423 非静态与静态成员的访问方式

非静态成员

当非静态成员作为访问者时:

  • 直接访问:其所属类的所有成员(包括自己)。
  • 间接访问:其所属类的所有成员(包括自己):
    • 通过所属类对象访问。
    • 通过作用域运算符访问。

当非静态成员作为被访问者时:

  • 直接被访问:其所属类的所有非静态成员(包括自己)。
  • 间接被访问:
    • 其所属类的所有非静态成员(包括自己):
      • 通过所属类对象被访问。
      • 通过作用域运算符被访问。
    • 其所属类的所有静态成员:
      • 通过所属类对象被访问。

静态成员

当静态成员作为访问者时:

  • 直接访问:其所属类的所有静态成员(包括自己)。
  • 间接访问:
    • 其所属类的所有静态成员(包括自己):
      • 通过所属类对象被访问。
      • 通过作用域运算符被访问。
    • 其所属类的所有非静态成员:
      • 通过所属类对象被访问。

当静态成员作为被访问者时:

  • 直接被访问:其所属类的所有成员(包括自己)。
  • 间接被访问:所有对象(包括自己):
    • 通过所属类对象被访问。
    • 通过作用域运算符被访问。
struct Cls
{
    // 类对象
    static Cls classObj;
    // 非静态数据成员
    int inte{35};
    // 非静态函数成员
    int getInt() { return 31; }
    // 静态数据成员
    static const int inte_st = 17;
    // 静态函数成员
    static int getInt_st() { return 39; }

    // 非静态成员被非静态成员访问
    int nostaticDataAccessed_viaDirectly_byNostaticData = inte * 2;
    void nostaticDataAccessed_viaClassObject_byNostaticFunc() { Cls clsObj; int obj{clsObj.inte*3}; }
    int nostaticFuncAccessed_viaOperScope_byNostaticData = Cls::getInt() * 4;
    // 静态成员被非静态成员访问
    void staticDataAccessed_viaDirectly_byNostaticFunc() { int obj{inte_st*2}; }
    int staticFuncAccessed_viaClassObject_byNostaticData = classObj.getInt_st() * 3;
    void staticFuncAccessed_viaOperScope_byNostaticFunc() { int obj{Cls::getInt_st()*4}; }
    // 非静态成员被静态成员访问
    static int nostaticDataAccessed_viaClassObject_byStaticData;
    static void nostaticFuncAccessed_viaClassObject_byStaticFunc() { Cls clsObj; int obj{clsObj.getInt()*4}; }
    // 静态成员被静态成员访问
    static void staticFuncAccessed_viaDirectly_byStaticFunc() { int obj{getInt_st()*2}; }
    static int staticFuncAccessed_viaClassObject_byStaticData;
    static void staticDataAccessed_viaOperScope_byStaticFunc() { int obj{Cls::inte_st*4}; }
};
Cls Cls::classObj{};
int Cls::nostaticDataAccessed_viaClassObject_byStaticData{classObj.inte*2};
int Cls::staticFuncAccessed_viaClassObject_byStaticData{classObj.getInt_st()*3};

7.43 类成员的声明与定义

除了非静态数据成员和静态constexpr数据成员,所有的成员既可以在类内定义,也可以在类外定义,不过如果在类外定义,则该成员必须要在类内声明过。

在类外进行的成员声明或定义语句时,要注意要在成员名之前加上所属类类名和作用域运算符,也就是类名::成员名,以表示是对类成员的声明或定义。

对于构造函数和析构函数来说,因为其成员名就是类名,所以在类外定义就是类名::类名或者类名::~类名

要注意在类外定义的函数成员要与其类内的声明严格一致(函数名、返回类型、每个形参类型都要相同(顶层const可省略,有无顶层const按照定义的来),包括各种类型修饰符都要一致),否则就会编译出错。

struct Cls
{
    // 静态数据成员const int类内定义
    static const int ins = 18;
    // 静态数据成员类内声明
    static const float flo;
    // 静态数据成员constexpr类内定义(只能在类内定义)
    static constexpr double sta_dou = -48.35;
    // 静态数据成员类内声明
    static double dou;
    // 非静态函数成员类内声明
    int add(int val, int val2);
    // 静态函数成员类内声明
    static void prints();
};
// 正确的静态数据成员定义
const float Cls::flo = 48.5;
// 错误定义:没有类名和作用域运算符,该定义语句不是类成员的定义。
double dou = 7.6;
// 正确的静态数据成员定义
double Cls::dou = 7.6;
// 错误定义:没有类名和作用域运算符,该定义语句不是类成员的定义。
int add(int val, int val2) {};
// 正确的非静态函数成员定义
int Cls::add(int val, int val2) {};
// 正确的静态函数成员定义
void Cls::prints() {}

static说明符只能在类内的声明或定义语句中出现。 所以静态成员的类外声明或定义语句中,除了不能有static,其他的和正常声明以及定义形式一样。

struct Cls
{
    static void prints();
    static double dou;
};
// 错误:static不能在类外。
static void Cls::prints() {};
static double Cls::dou = 78.6;
// 正确
void Cls::prints() {};
double Cls::dou = 78.6;

除了静态非constexpr数据成员可以在类外定义,其他的数据成员都不能在类外定义。

对于所有成员来说,类外和类内之间不能有重复定义。

7.44 数据成员介绍

根据成员是否有static,数据成员分为两种:

  • 非静态数据成员
  • 静态数据成员

注意:非静态数据成员不能为constexpr类型,否则编译器报错

对于非静态数据成员和静态constexpr数据成员来说,只能在类内定义; 而除了静态constexpr数据成员,其他静态数据成员类内类外都能定义,但大多数都在类外定义。

能在类内定义的静态数据成员必须是以下几种形式,其他的静态数据成员都不能在类内定义:

  1. const int类型,且初始值必须为常量表达式。
  2. constexpr类型,且初始值必须为常量表达式。
  3. 被inline修饰的任何类型,此时初始值可以是任意表达式。

7.441 数据成员初始化流程

在介绍数据成员初始化流程之前,我们需要了解两个概念:

  1. 类内初始值 类内初始值是指在数据成员定义语句中的初始值。类内初始值不能用有圆括号的直接初始化形式,但可以用其他形式的初始化。 一个数据成员的类内初始值可以是含有该成员所属的类成员的表达式,只要其结果能隐式转换成对应被初始化数据成员的类型。 但要注意类内初始值不能为以下成员,否则会出现未定义行为:
    • 自身。
    • 在自身初始化之后才初始化的其他数据成员。
  2. 构造函数 构造函数是指某些特殊的成员函数,这些函数都有一个特殊的初始值列表。 这些成员函数的任务是初始化类对象的所有非静态数据成员。 构造函数的调用是自动的,无论何时,只要类的对象被创建,就会执行该类构造函数。

数据成员的初始化流程分为两步:

  1. 静态数据成员初始化过程。
  2. 构造函数初始化过程(只有非静态数据成员才有)。
  • 静态数据成员初始化过程:

    这个过程是发生在类的定义过程中的。 只对所有静态数据成员进行初始化。

    首先,按照每个静态数据成员定义语句的先后顺序,根据其定义语句中的类内初始值,先对定义在类内的成员进行初始化,再对定义在类外的初始化。 对于没有类内初始值的成员,则不初始化,只当作声明(所以没有类内初始值的静态数据成员,是不能使用的)。

  • 构造函数初始化过程:

    这个过程是发生在类对象的定义过程中的。 只对所有非静态数据成员进行初始化。

    这个过程就是执行类对象定义时匹配的构造函数。在执行该构造函数的过程中,按照每个非静态数据成员在类中定义的出现顺序,从先到后,逐个对每个非静态数据成员都执行初始化。 具体来说,当初始化一个非静态数据成员时,如果该成员是在该构造函数的初始值列表中出现的成员,则用其后面紧跟的括号里的表达式的值来初始化该成员;否则用该成员的类内初始值来初始化;如果类内初始值也没有,则执行默认初始化,如果该成员连默认初始化都不行,则编译出错。

// 定义了各种拷贝控制成员的类Te
struct Te
{
    int ins;
    static string sta_str;
    string str;
    Te(int ins = 18, string str = "Te_str"): ins(ins), str(str) { cout << ins << " constructed!\n"; }
    Te(const Te& copy_obj): ins(copy_obj.ins), str(copy_obj.str)
    { cout << ins << " copyed constructed!\n"; }
    Te(const Te&& copy_obj): ins(copy_obj.ins), str(copy_obj.str)
    { cout << ins << " moved constructed!\n"; }
    Te& operator=(const Te& copy_obj)
    {
        ins = copy_obj.ins;
        str = copy_obj.str;
        cout << ins << " copyed operated!\n";
        return *this;
    };
    Te& operator=(const Te&& move_obj)
    {
        ins = move_obj.ins;
        str = move_obj.str;
        cout << ins << " moved operated!\n";
        return *this;
    }
    ~Te() { cout << ins << " deleted!\n"; }
};
string Te::sta_str = "Te_static_str";

// 用于初始化De的静态成员sta_dou,并提示初始化完成的时间。
double get_dou(int val) { cout << "static member initiated\n"; return 3.66 + val; }
// 测试数据成员的初始化流程。
/* 输出:
static member initiated
表明静态数据成员是在类定义时就初始化的。
*/
struct De
{
    int ins = ret();
    int ret() { cout << "ins initiated\n"; return 8;}
    Te te = {34, "De_str"};
    De(Te te = {23,"De_str"}): te(te) {}
    static const double sta_dou;
    static const int sta_ins = 30;
};
const double De::sta_dou = get_dou(sta_ins);

/* 输出:
33.66
表明定义在类内的静态数据成员sta_ins先初始化,
然后是定义在类外的静态数据成员sta_dou的初始化。
*/
cout << De::sta_dou << "\n";
/* 输出:
23 constructed!
ins initiated
23 copyed constructed! 
23 deleted!
23 deleted!
根据输出,我们可以看出,
在De的类对象de的创建过程中:
先执行De的构造函数,构造函数的局部变量te被创建;
然后在构造函数的执行过程中,按照非静态数据成员的出现顺序,
成员ins首先用类内初始值初始化,
然后是成员te用构造函数的初始值列表初始化而不用其类内初始值来初始化。
最后构造函数执行完毕,局部变量te被销毁。
最后程序结束前,对象de被销毁。
*/
De de;

7.45 函数成员介绍

根据成员是否有static,函数成员也可分为两种:

  • 非静态函数成员
  • 静态函数成员

非静态和静态函数成员也遵循之前所说的一般的成员规则:

  • 非静态函数成员可以直接访问所属类的成员(包括将该类其他成员用作默认实参,不过只有静态成员才能当默认实参)。
  • 静态函数成员不能直接访问所属类的成员,只能通过该类的对象才行。

函数成员也支持定义成已删除函数,不过已删除函数只能在类内定义。

定义在类内的函数成员默认为inline函数,类外的不算。 为了让定义在类外的函数成员也能是inline,我们可以在定义时显式指明inline函数。

函数成员是函数,所以也支持成员函数的重载,只要函数之间在参数的数量和/或类型上有所区别就行(所以静态和非静态之间也是可以重载的)。

常量成员函数之间以及与非常量成员函数之间都支持重载; 构造函数也支持重载; 析构函数不支持重载。

成员函数的函数匹配过程与非成员函数类似,会考虑类中所有同名的函数,包括私有、受保护和已删除的函数,对于考虑的同名函数的范围,分两种情况:

  • 如果该类自己没有定义同名的对象,则会考虑所有从其基类继承的同名函数(包括所有的直接和间接基类里的同名函数)。
  • 如果该类自己有定义同名的对象,则只会考虑该类自己定义的。

当调用类的成员函数时,编译器就会根据实参匹配最佳的函数。 匹配成功后,根据这个最佳函数的访问权限和是否为删除决定是否成功调用,如果无权限访问或者已删除,则会提示编译错误,否则就调用成功。

struct Cls
{
    void prints(int val) { cout << val << " int\n"; }
private:
    void prints(double val) { cout << val << " double\n"; }
};
Cls matchs;
// 错误:匹配的是void prints(double val),但是该函数无法访问
matchs.prints(3.6);
// 正确:匹配的是void prints(int val),\
输出3 int
matchs.prints(3);
struct Cls
{
    void prints(int val) {};
    static void prints(string val) {};
    void prints(int val) const {};
};
Cls no_const_obj;
const Cls const_obj;
// 调用void prints(int);
no_const_obj.prints(25);
// 调用void prints(int) const;
const_obj.prints(25);
// 调用static void prints(string);
const_obj.prints("good");

函数成员中的虚函数和析构函数将会在之后的继承和拷贝控制的部分进行介绍。

7.451 非静态函数成员的特性

7.4511 this形参

只用于非静态函数成员

对于非静态函数成员来说,它们和非静态数据成员一样,可以直接访问所属类的所有成员。非静态函数成员的这种直接访问性质,其实是由一个隐含的指针形参进行的。 非静态函数成员通过一个==名为this的指向所属类的非常量对象的常量指针==的隐式形参来访问调用该指针的那个对象,通过该对象对其他成员进行访问。

当我们调用一个函数成员时,是用该函数的对象的地址来初始化this。

struct Cls
{
    int ins;
    void prints();
};
Cls cls;
// 近似可理解为\
  Cls::prints(&cls)
cls.prints();

所以任何对类成员的直接访问都被看作是this的隐式引用。

struct Cls
{
    int ins;
    void prints()
    {
        // 等价于\
        cout << this->ins;
        cout << ins;
    }
};

对于所有的函数成员来说,this形参是隐式定义的,是每个函数成员形参表中的第一个形参。所以我们可以在函数内任何需要使用形参的地方使用this形参,不过要注意this是常量指针,遵循常量指针的规则。

任何在函数内自定义名为this的参数或变量的行为都是非法的。

7.4512 常量成员函数

只用于非静态函数成员

和普通类一样,我们定义的类类型也可以定义常量对象。 但是由于this是指向类类型的非常量对象的常量指针。所以根据底层const的规则,我们不能在一个常量对象上调用普通的非静态成员函数。

为了能在常量对象上调用非静态成员函数,我们可以将其函数的this形参设置为指向常量的常量指针。但是因为this是隐式的并且不会出现在参数列表中,所以我们只能用以下形式来指明该非静态函数成员的this为指向常量的常量指针。 形式为在非静态函数的声明或定义语句中,在函数的形参表之后紧跟一个关键字const。

形参表 const

像这样使用const的非静态函数成员被称作常量成员函数(const member function)。

如果函数在类外定义,则类内类外都需要加上关键字const,否则编译出错。

常量成员函数只能是非静态成员函数。

struct Cls
{
    int ins;
    void prints() {}
    void prints2() const {}
    // 常量成员函数的类内声明
    void prints3() const;
    // 常量成员函数的类内声明
    void prints4() const;
};
// 错误:类外必须要有const
void Cls::prints3() {}
// 正确
void Cls::prints3() const {}

const Cls cls;
// 错误:常量对象不能调用非常量成员函数的非静态成员函数。
cls.prints();
// 正确:常量对象可以调用常量成员函数
cls.prints2();

常量对象,以及常量对象的引用或指针可以调用各种除了非静态成员函数的其他成员,而对于非静态成员函数,只能调用其中的常量成员函数。

7.4513 引用成员函数

之前我们有过关于左值和右值方面的介绍。 我们可以在一个类对象上调用成员函数,而不管该对象是一个左值还是一个右值。

但有时我们不希望某些函数成员能在左值或者右值上调用,在此情况下,我们希望强制左侧运算对象(也就是隐式形参this指向的对象)是一个左值或者是一个右值。

此时我们可以用一个引用限定符(reference qualifier)来指明该函数成员的this只能指向某一种值,这种成员函数就叫做引用成员函数。

使用方式与声明或定义const成员函数类似,在函数的形参表之后紧跟一个引用限定符:

形参表 &/&&

引用限定符可以是&或&&,分别指出this可以指向一个左值或右值。也就是说,对于&限定的函数,我们只能将它用于左值;对于&&限定的函数,只能用于右值。

类似const限定符, 引用限定符只能用于非静态成员函数,如果函数在类外定义,则类内类外都需要加上对应的引用限定符,否则编译出错。

struct Cls
{
    // 普通函数,既能用于左值,也能用于右值。
    void Prints() { cout << "common\n"; }
    // 左值函数,只能用于左值
    void lPrints() &;
    // 右值函数,只能用于右值
    void rPrints() && { cout << "rvalue\n"; }
};
void Cls::lPrints() & { cout << "lvalue\n"; }

// obj为左值
Cls obj;
// 正确:输出common
obj.Prints();
// 正确:Cls()是一个临时对象,为右值。\
输出common
Cls().Prints();
// 正确:输出lvalue
obj.lPrints();
// 错误:rPrints不能用于左值
obj.rPrints();
// 错误:lPrints不能用于右值
Cls().lPrints();
// 正确:输出rvalue
Cls().rPrints();

同一个函数可以同时使用const和引用限定符。在此情况下,引用限定符必须跟随在const限定符之后,不能放在之前。

引用成员函数也像常量成员函数一样,支持重载,不同的引用限定符的同名函数可以作为重载函数。 而且,我们可以综合引用限定符和const来区分一个成员函数的重载版本。 编译器会根据调用对象的左值/右值属性还有常量属性来确定使用哪个重载版本。

struct Cls
{
    // 重载的引用成员函数Prints
    void Prints() &;
    void Prints() && { cout << "rvalue\n"; }
};
void Cls::Prints() & { cout << "lvalue\n"; }
Cls obj;
// 调用void Prints() &\
输出lvalue
obj.Prints();
// 调用void Prints() &&\
输出rvalue
Cls().Prints();

不过相同形参表的同名成员函数不能只通过有无引用限定符来区分重载版本。

所以如果一个成员函数有引用限定符,则具有相同参数列表的所有版本都必须要有引用限定符。

struct Cls
{
    void Prints() && { cout << "rvalue\n"; }
    // 错误:无法只通过有无引用限定符来区分重载版本
    void Prints() { cout << "common\n"; }
};
7.4514 可变数据成员

当一个类对象为常量时,其中所有的数据成员通常都不能被修改。

由于常量成员函数的this形参为指向常量的指针,所以也不能在常量成员函数中修改类的数据成员,即使该类对象为非常量也不行。

所以为了能修改常量对象的某些数据成员,以及在常量成员函数中修改类的某些数据成员,我们可以在所需要修改的数据成员的定义中使用mutable关键字。形式为:

mutable 定义语句

struct Cls
{
    int ins = 2;
    mutable int ins2 = 3;
    // 错误定义:常量成员函数不能修改非可变数据成员
    void amends() const { ins = 5; }
    // 正确定义
    void amends2() const { ins2 = 5; }
};
const Cls cls;
// 错误:常量对象不能修改非可变数据成员
cls.ins = 8;
// 正确:ins2的值修改为10
cls.ins2 = 10;
// 正确:ins2的值修改为5
cls.amends2();

mutable关键字不能用于静态数据成员或者const和constexpr成员。也就是可变数据成员必须不能是这几种类型。

7.452 构造函数

构造函数(constructor)是一种特殊的成员函数,之前我们简单介绍了一下,构造函数的任务是初始化类对象的所有非静态数据成员。 构造函数的调用是自动的,无论何时,只要类的对象被创建,就会执行该类构造函数。

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

所有的构造函数都不能有存储说明符,且也不能有类型限定符,如const,&等。

7.4521 构造函数分类

在c++中,根据构造函数创建对象的不同方式,共有三种类型的构造函数:

  • 普通构造函数
  • 拷贝构造函数
  • 移动构造函数

之后我们会详细讲解后两种构造函数的特点和使用。

7.4522 构造函数的定义

构造函数有着特殊的形式:

构造函数的定义形式:

类名 形参表 (可选 初始值列表) 函数体

构造函数的声明形式:

类名 形参表;

构造函数的名字必须要与其类名相同,构造函数没有返回类型也不能写返回类型,构造函数的函数体中一般不能含有返回语句,不过可以写空return语句。

和普通函数一样,构造函数也有一个(可能为空的)形参列表和一个(可能为空的)函数体。

初始值列表是构造函数特有的一部分,该部分负责为新创建的对象的某些非静态数据成员显式赋初始值。如果省略了初始值列表,则该类的所有非静态数据成员以其类内初始值初始化或默认初始化。

7.4523 初始值列表
7.45231 初始值列表的普通形式

初始值列表是指以冒号:开头的,后面为非静态数据成员的名字后紧跟对应的直接初始化形式的初始值的列表,每个数据成员之间用逗号,隔开,形式为:

: 成员名1 成员名1的直接初始化, 成员名2 成员名2的直接初始化, 成员名3 成员名3的直接初始化, ···

之前也说过,构造函数通过初始值列表,对列表中的每个成员用其后面的直接初始化来初始化该成员;对于没有出现在列表中的成员,则用其类内初始值初始化或者默认初始化。

struct Cls
{
    int ins;
    static const int c_ins = 15;
    string str = "good";
    double dou;
    /* str会用lstr的值而不是类内初始值"good";
    dou的初始值为ldou;
    ins被默认初始化;
    c_ins不归构造函数初始化*/
    Cls(string lstr, double ldou): str(lstr), dou{ld} {}
};

构造函数的形参名可以和成员名同名,虽然遵循作用域规则,但是初始值列表里的成员名不会被局部同名变量所隐藏。 所以初始值列表里中的成员的直接初始化的初始值可以是该构造函数的与该成员同名的形参。

struct Cls
{
    int ins;
    double dou;
    // 构造函数的形参与成员同名,但初始值列表中的成员不会被隐藏
    Cls(int ins, double dou): ins(ins), dou(dou) {}
};

int main()
{
    // 正确:成员ins被初始化为3,成员dou被初始化为-5.2
    Cls obj(3,-5.2);
    return 0;
}
7.45232 派生类构造函数的初始值列表

对于一个继承了其他类的派生类,如果想显式初始化继承的非静态成员,则必须要在自己的构造函数的初始值列表中调用这些继承的成员所属的类的构造函数来对这些成员进行初始化,不能直接初始化基类的成员。

要注意我们在初始值列表中只能调用直接基类的构造函数,也就是只能初始化直接基类所定义的非静态成员,不能调用间接基类的构造函数

但是对于所有从虚基类继承的非静态成员来说,我们必须要直接调用其虚基类的构造函数来初始化这些成员,就算该虚基类为间接基类也是如此(也就是说一个类直接控制其所有虚基类的初始化,而不是让其他基类来控制)。

如果我们在初始值列表中未明确某个虚基类或者直接基类的非静态成员的初始化,则对应的继承的非静态成员就会根据其对应的类的默认构造函数进行初始化,如果对应的类没有未删除的默认构造函数,则编译出错。

struct Ba
{
    int ba_int;
    double ba_dou;
    Ba(int ins = 30, double dou = 6.778): ba_int(ins), ba_dou(dou) {}
};
struct Ba2
{
    int ba2_int;
    double ba2_dou;
    Ba2(int ins = 3, double dou = 5.4): ba2_int(ins), ba2_dou(dou) {}
};
struct Der: Ba, Ba2
{
    int de_int;
    double de_dou;
    // 调用直接基类Ba的构造函数来显式初始化继承的成员ba_int和ba_dou。
    // 省略了继承的Ba2基类的成员的显式初始化,成员ba2_int和ba2_dou自动调用对应类的默认构造函数进行初始化。
    Der(int ba_int, double ba_dou, int ins, double dou):Ba{ba_int, ba_dou}, de_int(ins), de_dou(dou) {}
};
7.4524 构造函数的执行顺序

构造函数的运行是先执行初始值列表里的代码并初始化其他所有的非静态数据成员,初始化完毕后才是执行其后面的函数体。

7.45241 非继承类的构造函数

构造函数对该类定义的非静态数据成员初始化的顺序为这些成员的定义顺序,所以构造函数初始值列表中的成员顺序最好要与这些成员的定义顺序一致,否则会有警告信息。

7.45242 继承类的构造函数

对于派生类来说,基类部分的构造顺序与派生类列表中基类的出现顺序保持一致,而与派生类构造函数初始值列表中基类的顺序无关。

虚基类总是先于非虚基类构造,与它们在继承体系中的次序和位置无关。

具体来说:

  1. 派生类按照其派生类列表的顺序,从前往后一个个检查其直接基类以确定是否为虚函数以及确定这些类的基类中是否含有虚基类。 如果有,则还要检查是否之前执行过该虚基类同名的其他虚基类,如果是,则不执行该虚函数的构造函数并继续检查其中的基类,否则就先执行虚基类的对应构造函数,该虚基类也会按照这顺序检查并执行其直接基类中的虚基类的构造函数,以此类推,直到该派生类的所有直接基类检查完毕。
  2. 当派生类的直接基类中的所有虚基类都被检查完毕后,该派生类则继续按照其派生类列表的顺序,从前往后一个个执行非虚基类的直接基类的对应构造函数,这些直接基类也执行其非虚基类的直接基类的构造函数,以此类推,直到该派生类的所有基类的构造函数执行完毕。
  3. 最后,该派生类对自己所定义的非静态数据成员进行初始化(这些成员的初始化顺序也与其定义顺序一致)以及执行对应构造函数的函数体。

派生类构造函数初始值列表中基类构造函数的调用之间的顺序、与该派生类的成员初始化的顺序应该与派生类的构造顺序保持一致。否则会有警告信息。

struct Ba
{
    int ba_int;
    Ba(int ins = 30): ba_int(ins) { cout << "Ba\n"; }
};
struct Ba2
{
    int ba2_int;
    Ba2(int ins = 3): ba2_int(ins) { cout << "Ba2\n"; }
};
struct Der: Ba, Ba2
{
    int de_int;
    Der(int ba_int, int ins):Ba{ba_int}, de_int(ins) { cout << "Der\n"; }
};
struct Der2: Der, Ba2
{
    int de2_int;
    Der2(int de_int, int ba2_int, int ins): Der(16, de_int), Ba2(ba2_int), de2_int(ins) { cout << "Der2\n"; }
};
/* 输出
  Ba
  Ba2
  Der
  Ba2
  Der2
  这个也就是Der2的构造顺序
*/
Der2 obj(8,9,3);
7.4525 默认构造函数
7.45251 默认初始化

之前我们说过,对于类类型和共用体的默认初始化,是由该类型自己所决定的。而对于每种类类型和共用体来说,都是由一个特殊的构造函数来控制其默认初始化过程,这个函数叫做默认构造函数(default constructor)。

只有有默认构造函数且其不为已删除的类类型和共用体才能进行默认初始化,否则该类的对象必须显式初始化。

7.45252 默认构造函数的形式

我们可以自己定义默认构造函数,默认构造函数和普通构造函数一样,只不过该构造函数的形参表为空或者其形参全都有默认实参。 每种类型有且只有一个默认构造函数。

// 定义了一个Cls的默认构造函数
struct Cls
{ Cls() {} };
struct Cls2
{ 
    int ins;
    double dou;
    // 定义了一个Cls2的默认构造函数
    Cls2(int ins = 8, double dou = 6.3): ins(ins), dou(dou) {} 
};
7.45253 合成的默认构造函数

对于没有默认构造函数的类,编译器大多都会隐式生成一个默认构造函数,编译器创建的构造函数又被称为合成的默认构造函数(synthesized default constructor)。

但是,只有当该类同时满足以下三种情况时,编译器才会隐式生成一个默认构造函数:

  • 该类没有显式定义任何构造函数。
  • 该类自己定义的每个非静态数据成员都有类内初始值或者能默认初始化。
  • 该类继承的所有非静态数据成员对应的基类必须都要有非删除的默认构造函数。

合成的默认构造函数可以看成是形参表为空,并且省略了初始值列表的构造函数,所以合成的默认构造函数是用对应的类内初始值初始化或者默认初始化。

我们也可以用另一种形式来显式要求生成合成的默认构造函数,该形式就是在一个空形参的构造函数声明后紧跟一个赋值符=和关键字default

类名() = default;

要注意必须是为空形参才行。 default声明可以在类内也可以在类外,在类内的隐式声明为内联的。

和普通函数一样,所有的默认构造函数既可以定义在类的内部,也可以出现在类的外部。

只有该类同时满足之前所说的后两种情况时,才能生成合成的默认构造函数,否则编译出错。

// 隐式生成了一个合成的默认构造函数,\
该函数等价于:\
Cls(){}
struct Cls
{ };
struct Cls2
{ 
    int ins;
    double dou;
    // 显式生成了一个合成的默认构造函数,\
    该函数等价于:\
    Cls2(){}
    Cls2() = default; 
};
7.4526 委托构造函数

委托构造函数就是把自己的初始化过程委托给其他构造函数的构造函数。

和其他构造函数一样,一个委托构造函数也有一个成员初始值的列表和一个函数体。但是在委托构造函数内,其初始值列表有且只有一个成员,这个成员及其初始化也就是对该类其他的构造函数的调用。

委托构造函数的形式为:

类名 形参表: 类名 (实参表)/{实参表} 函数体

在委托构造函数的初始值列表中,只能有一个对其他构造函数的调用,通过这个调用,委托构造函数将初始化非静态数据成员的工作委托给了这个调用的构造函数。

当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行,最后才是执行该委托构造函数的函数体

struct Cls
{
   int ins;
   string str;
   double dou;
   Cls(int ins, string str, double dou): ins(ins), str(str), dou(dou) {}
   // 委托构造函数,将初始化工作委托给Cls(int, string, double)
   Cls(int ins, string str): Cls(ins, str, 5.5) {}
   // 委托构造函数,将初始化工作委托给Cls(int, string)
   Cls(string str): Cls(3, str) {}
};
// 调用Cls(int, string, double)
Cls obj = Cls{3,"str", 8.8};
// 调用Cls(int, string)
Cls obj2 = Cls{3, "str2"};
// 调用Cls(string)
Cls obj3 = Cls("string");
7.4527 重用基类的构造函数

在C++11新标准中,派生类能够重用其直接基类定义的构造函数。 也就是该派生类可以生成和其直接基类形参表,初始值列表,函数体都一样的构造函数(拷贝控制构造函数除外)。

一个类也只重用其直接基类的构造函数,不能重用其他的基类的,包括其虚基类。

7.45271 重用构造函数的形式

派生类重用基类构造函数的方式是提供一条注明了直接基类名的using声明语句,形式为:

using 直接基类名::直接基类名;

要用作用域运算符显式注明。

对于该直接基类的每个构造函数,按照这些构造函数的声明顺序,编译器会依次生成与之对应的派生类构造函数。换句话说,对于基类的每个构造函数(拷贝控制构造函数除外),编译器都在派生类中生成一个和其直接基类形参表,初始值列表,函数体都一样的构造函数。 这些编译器生成的构造函数形如:

派生类名 直接基类对应构造函数的形参表 直接基类对应构造函数的初始值列表 直接基类对应构造函数的函数体

#include <iostream>
// 直接基类
class Base
{
public:
    Base(const std::string& s) { std::cout << "this is base" << std::endl; }
    Base(const Base&, int i) { std::cout << "this is base" << std::endl; }
};

class Derive: public Base
{
public:
    /*
        生成以下构造函数:
        Derive(const std::string& s) {std::cout << "this is base" << std::endl;}
        Derive(const Base&, int i) { std::cout << "this is base" << std::endl; }
    */
    using Base::Base;
};

类不能重用合成的(包括显式用=default指定的合成函数)默认、拷贝和移动构造函数,如果派生类没有直接定义这些构造函数,则编译器将为派生类合成它们。 其中对于用户自定义的拷贝和移动构造函数,编译器会在派生类中生成一个函数体相同,但函数名及形参表都为派生类名的构造函数

注意:任何构造函数如果满足了已删除的条件,则重用该构造函数失效,编译器将会生成对应构造函数的已删除版本。

#include <iostream>

// 直接基类
class Base
{
public:
    Base() { std::cout << "default base" << std::endl; }
    Base(const Base&) { std::cout << "copy base" << std::endl; }
    Base(Base&&) noexcept { std::cout << "move base" << std::endl; }
};
// 直接基类
class Base2
{
public:
    Base2() = default;
    Base2(const Base2&) { std::cout << "copy base" << std::endl; }
    Base2(Base2&&) = default;
};

class Derive: public Base
{
public:
    /*
        生成以下构造函数:
        Derive() { std::cout << "default base" << std::endl; }
        Derive(const Derive&) { std::cout << "copy base" << std::endl; }
        Derive(Derive&&) { std::cout << "move base" << std::endl; }
    */
    using Base::Base;
};

class Derive2: public Base2
{
public:
    /*
        不会生成任何构造函数
    */
    using Base2::Base2;
    Derive2& operator=(Derive2&&) noexcept { std::cout << "move assignment" << std::endl; }
};

int main()
{
    // 输出default base
    Derive d1;
    // 输出copy base
    Derive d2 = d1;
    // 输出move base
    Derive d3 = std::move(d1);
    // 正常
    Derive2 dd1;
    // 错误,因为类自定义了移动赋值运算符,所以导致using不能重用拷贝构造函数,以及合成的拷贝构造函数为已删除。
    Derive2 dd2 = dd1;
    return 0;
}

直接基类的每个构造函数还包括该直接基类自己所重用的构造函数。

派生类可以重用多个直接基类的构造函数,只要写上对应基类的using声明就行。

要注意可能会有多个形参表一样的重用构造函数,此时当使用该重用构造函数时,会编译出错。

此时则要定义和该重用构造函数形参表相同的(可忽略顶层const),该派生类自己的构造函数,该派生类自己定义的构造函数会隐藏其他同形参表的重用构造函数。

struct Te0 
{ Te0(int val){} };
struct Te
{ Te(int val){} };
// 重用了Te, Te0的构造函数
struct Sub1: Te, Te0
{
    using Te::Te;
    using Te0::Te0;
};
// 错误:Sub1有两个同形参表的构造函数Sub1(int val)
Sub1 obj(3);

// 重用了Te, Te0的构造函数
struct Sub2: Te, Te0
{
    using Te::Te;
    using Te0::Te0;
    Sub2(int val): Te(val), Te0(val) {}
};
// 正确:Sub2定义的构造函数会隐藏其他同形参表的重用构造函数
Sub1 obj(3);

当使用这些重用构造函数时,派生类自己定义的非静态数据成员则会类内初始值初始化或者默认初始化。

struct Cls
{
    int ins;
    double dou;
    Cls(int ins, double dou): ins(ins), dou(dou) {}
    Cls(int ins = 5): Cls(ins,3.5) {}
}
struct Sub
{
    // 重用了Cls的构造函数Cls(int ins, double dou)和Cls(int ins = 5)
    using Cls::Cls;
}
// 正确
Sub obj(9,6.5);
Sub obj2(15);
7.45272 重用构造函数的特点
  1. using声明不会改变所重用构造函数的访问级别。所重用的构造函数的权限是它们第一次定义时所处的访问权限,与该using声明所属类的派生访问符和该using声明所在的位置无关。 例如,不管using声明出现在哪儿,基类的私有构造函数在派生类中还是一个私有构造函数;受保护的构造函数和公有构造函数也是同样的规则。
  2. 一个using声明语句不能指定explicitconstexpr。如果基类的构造函数是explicit或者constexpr,则重用的构造函数也拥有相同的属性。
  3. 重用的构造函数不会有任何默认实参。 当一个直接基类的构造函数含有默认实参,这些默认实参并不会被重用。相反,派生类将获得多个重用的构造函数,其中每个构造函数,从前向后,分別省略掉一个含有默汄实参的形参。 例如,如果基类有一个接受两个形参的构造函数,其中第二个形参含有默认实参,则派生类将获得两个构造函数:一个构造函数接受两个形参(没有默认实参),另一个构造函数只接受一个形参,它对应于基类中最左侧的没有默认实参的那个形参。
  4. 派生类不会重用合成的默认、合成的拷贝和合成的移动构造函数。如果派生类没有直接定义这些构造函数,则编译器将为派生类合成它们。
  5. 派生类自己定义的构造函数会隐藏其他具有相同形参表的重用构造函数。

7.453 虚函数

我们之前一直谈论过虚函数,它是实现类动态绑定思想中的基础。

类的任意非构造函数的非静态函数(包括运算符和析构函数)都能被指定为虚函数。 一个类可以有任意多个虚函数,且虚函数也可以是重载函数。

虚函数像其他函数成员一样,可以被继承,被继承的虚函数在其派生类中也是虚函数。

7.4531 定义虚函数的形式

指定虚函数的方式为在该函数成员的类内声明或定义语句之前加上关键字virtual,形式为:

virtual 函数的声明或定义语句

要注意关键字virtual只能出现在类的内部,所以虚函数在类外的定义不能加关键字virtual

所有被指定为虚函数的函数成员都必须要有定义,不能只有声明,就算不用到该函数也是如此。

struct Ba 
{
    // 定义在类内的虚函数prints
    virtual void prints() { cout << "Ba\n"; }
    // 虚函数ret的类内声明
    virtual int ret(int val);
};
// 虚函数ret的类外定义,注意不能有virtual
int Ba::ret(int val) { return val;}
7.4532 虚函数的覆盖

当该类的派生类自己定义了一个与该类的某个虚函数一致(函数名相同,返回类型和形参表都匹配)的函数时,我们就说这个派生类覆盖了该类的这个虚函数。

对于虚析构函数来说,不需要函数名相同就能覆盖其基类的虚析构函数。 也就是说,只要某个类定义了虚析构函数,那么该类的派生类的自己定义的析构函数就会自动覆盖该类的析构函数。

基类的所有虚函数都能被覆盖,就算该虚函数在基类为私有访问或者私有继承都行。而且覆盖其虚函数的函数的访问权限也可以是任意的。

对于重载虚函数来说,一个覆盖函数只能覆盖其对应的重载版本。

这个覆盖了基类某虚函数的派生类成员函数也被隐式地成为一个虚函数(所以也要遵循虚函数的规则),所以该派生类的派生类也可以覆盖这个函数。

因为覆盖虚函数的函数被隐式成为一个虚函数,所以无需用virtual关键字指明(但也可以加,作用一样)。

7.45321 覆盖条件

覆盖了基类某个虚函数的,由派生类所定义的函数必须与该虚函数的函数名相同,形参表相同(可以忽略顶层const),返回类型要一模一样。

返回类型有一个例外,该例外为: 如果基类的虚函数的返回类型是该基类类型的指针或者引用,则其派生类覆盖的函数的返回类型可以是该派生类类型的对应复合类型(也就是该派生类类型的指针或者引用)。

派生类不能定义除了该例外以外的,只有返回类型不同的函数,否则会编译出错。

struct Ba 
{
    virtual Ba* ret(int val) 
    { 
        static Ba bObj; 
        return &bObj; 
    } 
};
struct De:Ba
{
    // 覆盖了基类Ba的虚函数Ba* ret(int val)
    De* ret(const int val) 
    {
        static De dObj; 
        return &dObj; 
    }
};
7.45322 覆盖提示

之前说过,因为每个类就是一个作用域,所以派生类可以定义一个与基类中虚函数的名字相同但是形参列表不同的函数,这仍然是合法的行为,该函数并没有覆盖基类中的版本。

所以这就导致了我们本来想定义覆盖虚函数的函数,但是由于定义的形参表不对,导致并没有覆盖到虚函数,且我们也无法及时知道这个错误。

为了显式提示我们是否覆盖了虚函数,在 C++11新标准中我们可以在定义覆盖虚函数的函数时使用override关键字來提示是否正确定义了覆盖的函数。

override关键字出现在形参列表(包括任何const或引用修饰符)以及尾置返回类型之后,且和virtual关键字一样,只能出现在类内的函数声明或定义语句中。

使用形式为:

  1. 返回类型 函数名 形参表 (可选 const等修饰符) override (可选 函数体);

  2. auto 函数名 形参表 -> 返回类型 (可选 const等修饰符) override (可选 函数体);

如果我们使用override标记了某个函数,但该函数并没有覆盖已存在的虚函数;或者我们标记的函数没有对应的虚函数,此时编译器将报错。

struct Ba 
{ 
    virtual Ba* ret(int val) 
    { 
        static Ba bObj;
        return &bObj;
    } 
};
struct De:Ba
{
    // 覆盖函数的声明
    De* ret(const int val) override;
};
// 覆盖函数的类外定义,注意不能有override
De* De::ret(const int val)
{
    static De dObj; 
    return &dObj; 
}
7.45323 阻止覆盖

当我们定义的虚函数不想让后续的派生类来覆盖时,可以将其指定为final,被指定为final的虚函数不能被后续的派生类函数所覆盖。

override关键字一样,final关键字出现在形参列表(包括任何const或引用修饰符)以及尾置返回类型之后,且只能出现在类内的函数声明或定义语句中。

使用形式为:

  1. 返回类型 函数名 形参表 (可选 const等修饰符) final (可选 函数体);

  2. auto 函数名 形参表 -> 返回类型 (可选 const等修饰符) final (可选 函数体);

当同时使用overridefinal关键字时,这两个关键字的相对顺序可以任意。

struct Ba 
{
    // 指定Ba* ret(int val)不能被覆盖
    virtual Ba* ret(int val) final;
};
// 虚函数Ba* ret(int val)的类外定义,注意不能有final
Ba* Ba::ret(int val) 
{
    static Ba bObj;
    return &bObj;
}
struct De:Ba
{
    // 错误:Ba* ret(int val)不能被覆盖
    De* ret(const int val) override final
    {
        static De dObj; 
        return &dObj; 
    }
};
7.4533 虚函数的使用

虚函数是用于动态绑定的,如果一个基类定义了它自己的引用或指针,通过该引用或指针调用某个被覆盖的虚函数时,被调用的函数是该指针或引用的内存中的,所保存对象的类型中相匹配的那一个覆盖函数,这也就叫动态绑定。 具体来说,就是优先调用覆盖函数版本的对应函数,当没有覆盖函数版本时才会考虑基类的版本。

当一个类中有多个该虚函数的覆盖函数时,不管这些函数时该类自己定义的还是继承的,其基类通过引用或指针调用该虚函数时都会出现二义性。

7.45331 重载虚函数的使用

对于重载虚函数来说,通过该基类的引用或指针调用该重载虚函数时,覆盖函数的版本会替换掉原来的该基类版本,然后与其他非覆盖的该基类重载版本一起参与函数匹配。

struct Ba 
{
    // 重载虚函数prints
    virtual void prints(int val) 
    {cout << val << " intBa\n";}
    virtual void prints(double val) 
    {cout << val << " doubleBa\n";}
    virtual void prints(string val) 
    {cout << val << " stringBa\n";}
};
struct De:Ba
{
    // 覆盖了基类Ba的一个重载虚函数void prints(double val),\
    该覆盖函数将替代原虚函数参与函数匹配
    void prints(double val)
    {cout << val << " doubleDe\n";}
};
De obj;
Ba &r = obj;
// 匹配的是void prints(string val\
输出strs stringBa
r.prints("strs");
// 匹配的是派生类De的void prints(double val)\
输出3.5 doubleDe
r.prints(3.5);
// 匹配的是void prints(int val)\
输出5 intBa
r.prints(5);
7.45332 虚函数的访问权限

要注意,一个虚函数能否被访问是由其动态类型中的静态类型部分对于该虚函数的访问权限所决定的,与其他部分对于该虚函数的访问权限无关。

struct Ba 
{
    virtual void prints(string val) 
    {cout << val << " stringBa\n";}
};
// 公有继承Ba
struct De:Ba
{
private:
    // 覆盖的函数为私有函数
    void prints(string val) override
    {cout << val << " stringDe\n";}
};
// 私有继承Ba
class De2:Ba
{
public:
    // 覆盖的函数为公有函数
    void prints(string val) override
    {cout << val << " stringDe2\n";}
};
De obj;
De2 obj2;
De &dr = obj;
Ba &r = obj;
// 错误:obj2中的Ba部分为私有,不可访问,无法绑定到Ba上。
Ba &r2 = obj2;
// 错误:dr的类型为De,prints在De中为私有函数
dr.prints("strs");
// 正确:r的类型为Ba,prints在Ba中为公有函数,\
De类中的prints访问权限不会影响其动态使用,所以输出为\
strs stringDe
r.prints("strs");
// 错误:obj2中的Ba部分为私有,不可访问。
r2.prints("strs");
7.45333 调用指定版本的虚函数

在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。 这时,可以使用作用域运算符来实现这一目的。

使用形式为:

对象名.类名::虚函数名(实参表)

其中类名必须是这个对象名所属的类型以及这个类型的任意包含该虚函数的基类。

struct Ba 
{
    virtual void prints(string val) 
    {cout << val << " stringBa\n";}
};
struct De:Ba
{
    void prints(string val) override
    {cout << val << " stringDe\n";}
};
struct De2:De
{
    void prints(string val) override
    {cout << val << " stringDe2\n";}
};
De2 obj;
De *dr = &obj;
Ba &br = obj;
// 调用的是De2类的版本,输出为:\
strs stringDe2
dr->prints("strs");
// 调用的是Ba类的版本,输出为:\
strs stringBa
dr->Ba::prints("strs");
// 调用的是De2类的版本,输出为:\
strs stringDe2
br.prints("strs");
// 错误:br的类为Ba类,De是它的派生类类,\
所以不能调用De的版本
br.De::prints("strs");
// 调用的是Ba类的版本,输出为:\
strs stringBa
br.Ba::prints("strs");

如果一个派生类的虚函数需要调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本自身的调用,从而导致无限递归。

7.4534 虚函数的默认实参

和其他函数一样,虚函数也可以拥有默认实参。 调用的虚函数所使用的默认实参是由其调用对象的类型来决定的。 换句话说,如果我们通过基类的引用或指针调用虚函数,则使用的是基类中定义的默认实参, 即使该引用或指针的内存的对象为派生类中的函数版本也是如此。

所以如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致,防止调用的结果不符合预期。

7.454 覆盖重载的函数

之前说过,除了析构函数,其他所有的函数成员都能被重载。

根据类的作用域,所有派生类定义的成员会隐藏掉其所有基类的同名成员,这就导致我们在派生类定义的函数成员不能与从基类继承的函数成员重载。

为此,我们可以为想要重载的函数成员提供一条using声明语句,形式为:

using 基类名::重载函数名;

一条基类成员函数的using声明语句的作用是把该基类中的该函数对应的所有重载函数(不包括此基类继承的,但包括此基类用using声明语句所添加的)添加到这个调用派生类的作用域中。 此时,派生类自己定义的重载版本就可以与基类对应的重载版本一起重载了。

声明的基类不一定要是直接基类,可以是派生类的任何基类。

struct Ba0
{
    void pri(bool val) { cout << val << " Ba0\n"; }
    void pri(char val) { cout << val << " Ba0\n"; }
};
struct Ba: Ba0
{
    void pri(int val) { cout << val << " Ba\n"; }
    void pri(double val) { cout << val << " Ba\n"; }
    void pri(string val) { cout << val << " Ba\n"; }
};

struct De: Ba
{
    // 对基类Ba0的using声明。
    using Ba0::pri;
    void pri(int val) { cout << val << " De\n"; }
};

De ob;
// 正确:输出a Ba0
ob.pri('a');
// 正确:输出6 De
ob.pri(6);
// 错误:基类Ba的pri被隐藏。
ob.pri(string("good"));

using声明语句添加的函数的访问权限由该using声明语句所在位置的访问权限来决定的,并且这些访问权限的更改是永久的,是可继承的。

class Ba0
{
protected:
    void pri(bool val) { std::cout << val << " Ba0\n"; }
    void pri(char val) { std::cout << val << " Ba0\n"; }
};
class Ba: public Ba0
{
public:
    using Ba0::pri;
    void pri(int val) { std::cout << val << " Ba\n"; }
    void pri(double val) { std::cout << val << " Ba\n"; }
    void pri(std::string val) { std::cout << val << " Ba\n"; }
};

int main()
{
    Ba obj;
    // 正确:调用void pri(bool val)
    // 输出1 Ba0
    obj.pri(true);
    return 0;
}

要注意,using声明语句中基类的对应函数的每个重载版本要在派生类中都可访问,否则编译出错。

struct Ba
{
    void pri(int val) { cout << val << " Ba\n"; }
    void pri(double val) { cout << val << " Ba\n"; }
private:
    void pri(string val) { cout << val << " Ba\n"; }
};

struct De: Ba
{
    // 错误:类Ba中有一个pri不可访问
    using Ba::pri;
    void pri(int val) { cout << val << " De\n"; }
};

7.46 类类型成员介绍

类的类类型成员为以下两种:

  • 嵌套类
  • 类型别名

类的类类型成员和其他的类成员一样,其访问权限由其所属类对应的访问符决定。

所有可访问的类类型成员在类外只能用作用域运算符访问,不能用类对象访问。

和其他类成员一样,类类型成员的名字在外层类作用域中是可见的,在外层类作用域之外不可见。

类类型成员也是类,所以在类类型成员声明或定义后,外层类的其他成员可以按照类类型规则直接使用这些类类型成员(要注意只声明的类类型成员是不完全类型)。

struct Cls
{
    // 错误定义:要在类类型成员声明或定义后才能使用
    cst_int ins;
    // 类类型成员cst_int
    typedef const int cst_int;
    // 类类型成员Nest,为不完全类型
    struct Nest;
    // 类类型成员Nest2
    struct Nest2 {};
    // 正确定义
    cst_int ins2;
    // 错误定义:Nest为不完全类型
    Nest mem1;
    // 正确定义:静态成员的类型可以为不完全类型
    static Nest sta_mem1;
    // 正确定义
    Nest2 mem2;
    // 正确定义:可以声明含有不完全类型的函数
    Nest func();
    // 正确定义
    Nest2 func2(cst_int inss) {}
};

7.461 类型别名成员

类中的类类型成员可以是类型别名,它遵循类类型成员的一般规则。

类型别名成员与该外层类是独立的个体,它们成员之间的访问遵循类外的作用域规则与它们自己的访问权限。

struct Ty
{
    // 错误定义:ins是Pp的成员,与该Ty类无关。
    void pri() { cout << ins;}
};
struct Pp
{
    typedef Ty ok;
    int ins = 48;
    string str = "good";
};

7.462 嵌套类

一个类可以定义在另一个类的内部,前者称为嵌套类(nested class)或嵌套类型(nested type)。

嵌套类和正常的类类型一样,遵循各种类类型的规则。

嵌套类和其他类成员一样,可以在外层类内或外层类外定义该嵌套类,外层类外定义时必须要先在外层类内声明该类。

当外层类不为局部类时,嵌套类自己的成员可以在嵌套类外定义,不过其成员的类外定义必须要在全局作用域下定义,不能在其外层类类内定义

struct Outs
{
    int ins;
    static double sta_dou;
    // 嵌套类Nest的声明
    struct Nest;
    void pro() { }
};
// 嵌套类Nest的定义
struct Outs::Nest
{
    int nins;
    static string nsta_str;
    void prn();
};
// 嵌套类Nest的成员prn和nsta_str的定义
void Outs::Nest::prn() {}
string Outs::Nest::nsta_str = "nest";

外层类的其他成员不能直接访问嵌套类的类成员,这些成员访问嵌套类的类成员就和访问其他非嵌套类的类成员一样,要遵循嵌套类的访问规则。

class Outs
{
    // 嵌套类Nest
    class Nest
    {
        int nins = 8;
        static constexpr double ndou = 86.4;
    public:
        void n_pri() { cout << "Nest"; }
    };
    // 错误定义:不能直接访问嵌套类Nest的成员n_pri。
    void prints()
    { n_pri(); }
    // 错误定义:嵌套类Nest的成员nins不可访问。
    void prints2()
    { Nest n_obj; cout << n_obj.nins; }
    // 正确定义:嵌套类Nest的成员n_pri可访问。
    void prints2()
    { Nest n_obj2; n_obj2.n_pri(); }
};

嵌套类的类成员类似于外层类的静态成员: 嵌套类的所有类成员可以直接访问或者通过外层类的对象间接访问外层类的所有静态或非静态成员,而不会受到外层类的访问权限限制;而且如果其外层类也是一个嵌套类,则也可以这样访问外层类的外层类成员,以此类推,可以访问到该嵌套类的所有的外层类成员。

class Outs
{
    int ins = 6;
    // 嵌套类Nest
    class Nest
    {
        // 正确定义:可以直接访问外层类的静态成员。
        void n_pri() { cout << sta_dou; }
        // 嵌套类Nest的嵌套类SubNest
        class SubNest
        {
            // 正确定义:可以直接访问外层类的外层类的静态成员。
            void subn_pri() { cout << sta_dou; }
        };
    };
    static double sta_dou;
};
double Outs::sta_dou = 36.3;

7.5 类的作用域

我们要清楚,一个类就是一个作用域,类的作用域遵循作用域的规则。

在作用域内,类定义的成员也会隐藏掉外部的同名对象,所以类内想要访问类外的同名对象时,需要用到作用域运算符。

在类的外部,成员的名字被隐藏起来了,所以我们在类外定义类成员时,就需要用作用域运算符来显式指明该成员为类的成员。 一旦类成员名遇到了类名,定义的剩余部分就在类的作用域之内了,这里的剩余部分包括形参表、函数体和类体。 另一方面,函数的返回类型通常出现在函数名之前。因此当成员函数定义在类的外部时,返回类型中使用的名字都位于类的作用域之外。这时,返回类型必须指明它是哪个类的成员。

7.51 类作用域的关系

类相关的作用域的关系为:

  • 类的作用域包含该类自己定义的函数成员和嵌套类的作用域(继承的成员的作用域不在该类作用域内)。
  • 基类的作用域包含其所有派生类的作用域。

所以根据类作用域的关系可知: 如果一个类所继承的成员中有多个分别来自不同基类的成员,它们的名字都相同,且该派生类自己没有定义同名的成员。则我们不能直接调用这些同名成员,会出现二义性问题。我们只能通过作用域运算符显式指明我们所要调用的是哪一个类的成员。

struct Ba { string str = "ba";};
struct Ba2 { string str = "ba2";};
struct Ba3 { string str = "ba3";};
struct De: Ba, Ba2, Ba3 {};
De obj;
// 错误:继承的str数据成员有多个,无法知道该使用哪一个。
cout << obj.str;
// 正确:指定的是从Ba3继承的str数据成员。输出ba3
cout << obj.Ba3::str;

7.52 类的定义顺序

类的定义顺序和普通对象的定义顺序不一样,类是先编译所有成员的声明,直到类的成员全部可见后才编译其他部分。

以下为类的具体定义顺序:

  1. 先按照成员在类内的出现顺序,逐个编译其成员的类内声明部分,也就是:
    1. 当成员在类内为声明语句时,就编译该声明;
    2. 当成员在类内为定义语句时,除了静态数据成员,其他成员只编译该成员的声明部分;对于静态数据成员,则会对初始值进行名字查找和类型检查并执行该成员的初始化。
  2. 接着按照成员在类内的出现顺序,除了静态数据成员,对所有在类内定义的成员的初始化部分或者函数体进行名字查找和类型检查。 如果是类类型成员,则该成员也开始对其成员进行定义顺序的1,2步。 除了静态数据成员,所有在类内定义的成员的初始化部分或者函数体可以包含当前只有声明而无定义的对象。

    名字查找和类型检查完毕前,该定义类都为不完全类型,当完毕后,该类就不再是不完全类型了。

  3. 当在类外遇到某个属于该类成员的定义语句时: 进行名字查找和类型检查,此时的名字查找和类型检查还会检查该成员是否使用了未定义的对象(包括只有声明而无定义的对象),如果检查失败,则编译出错。 检查完毕后将该定义加入到对应的类成员中。 当是静态数据成员的定义语句时,则还会执行该成员的初始化。
  4. 当使用了该类的某成员时,进行名字查找和类型检查,此时的名字查找和类型检查还会检查该成员是否使用了未定义的对象(包括只有声明而无定义的对象),如果检查失败,则编译出错。

所以对于类成员来说,函数成员可以以其所属类作为形参或者返回类型;对于在类外定义的成员,不能使用该类的当前未定义对象(包括只有声明而无定义的对象)。

struct Inc
{
    static int sta_ins;
    static int sta_ins2;
    // 可以定义含Inc类型的函数
    // 正确定义:该函数包含了当前只有声明而无定义的对象。
    Inc fun(Inc obj) { cout << sta_ins; return Inc(); };
};
// 错误定义:使用了未定义的对象sta_ins
int Inc::sta_ins2 = sta_ins;
Inc obj;
// 错误:fun包含当前未定义的对象。
obj.fun(Inc());
struct Inc
{
    static int sta_ins;
    static int sta_ins2;
    // 可以定义含Inc类型的函数
    // 正确定义:该函数包含了当前只有声明而无定义的对象。
    Inc fun(Inc obj) { cout << sta_ins; return Inc(); };
};
// 正确定义:当前作用域有sta_ins的定义
int Inc::sta_ins2 = sta_ins;
int Inc::sta_ins = 8;
Inc obj;
// 正确:fun包含的对象sta_ins在当前调用点有定义。
obj.fun(Inc());

7.53 类的名字查找

类中的名字查找和在各种作用域里的名字查找类似。

类的名字查找也是在类型检查之前,所以当在某作用域中找到了名字,则不会再考虑外层作用域的同名对象。

所以根据上面的作用域关系,类成员定义中的名字查找顺序为:

  1. 对于出现在成员函数内的名字,先在该函数作用域内从调用点(包括该调用点)向上查找。
  2. 对于第1步没有找到的和在类中出现的名字,则在类的作用域中继续查找。 对于不是在静态数据成员的类内初始值中出现的名字,这时类的所有成员名都可以被考虑;对于出现在静态数据成员的类内初始值中的名字,则从调用点(包括该调用点)向上查找。
  3. 如果在类内也没找到该名字的声明,则在该类的所有直接基类的作用域内同时进行查找;如果都没找到,则在这些直接基类的直接基类再同时查找,以此类推,直至继承的源头都已经查找完毕。
  4. 如果在该类的所有基类中都没找到该名字的声明,则在该类的定义位置之前继续查找。

要注意在名字查找的第3步中,如果在多个直接基类中都找到了该名字,则会出现二义性错误。此时可以定义自己的对应成员来隐藏这些重复的成员或者使用作用域运算符显式指定特定的成员从而解决二义性错误。

string str = "out_strs";
struct Ba
{ 
    int ins = 8;
    string str = "inner_strs";
};
struct Ba2
{int ins = 8;};
struct De: Ba
{ double ins = 13.5; };
struct De2: De {};
struct De3: Ba, Ba2 {};

De2 obj;
De3 obj2;
// ins对象的查找顺序为De2 -> De,在De找到后就停止查找了
// 输出为13.5
cout << obj.ins << endl;
// ins对象的查找顺序为De2 -> De -> Ba,在Ba找到后就停止查找了
// 输出为inner_strs
cout << obj.str << endl;
// ins对象的查找顺序为De3 -> Ba/Ba2,在Ba和Ba2都找到了ins,所以出现二义性错误。
cout << obj2.ins << endl;

7.531 类函数成员的名字查找

对于类的函数成员来说,根据其函数成员是否为虚函数,它们的查找顺序会有一些不一样。

我们后面会讲到详细的静态和动态类型,现在我们先了解以下什么是静态和动态类型。 当我们使用存在继承关系的类型时,必须将一个变量或其他表达式的静态类型(static type)与该表达式表示对象的动态类型(dynamic type)区分开來。

  • 静态类型的表达式类型,在编译时总是已知的,它是变量声明时的类型或者表达式计算结果的类型。
  • 动态类型则是变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知。

以下是c++对于所有成员函数调用的执行顺序,假定我们调用p->mem()或者obj.mem()

  1. 首先确定p(或者obj)的静态类型。因为我们调用的是一个成员,所以该类型必然是类类型。
  2. 在p(或obj )的静态类型对应的类中查找mem。如果找不到,则依次在直接基类中不断查找直至到达继承链的顶端。如果找遍了该类及其基类仍然找不到,则编译器将报错。
  3. 一旦找到了mem,就进行常规的类型检查,以确认对于当前找到的mem来说,本次调用是否合法。 如果调用合法,则编译器将根据调用的是否是虚函数而产生不同的代码:
    • 如果mem是虚函数且我们是通过引用或指针进行的调用,则编译器产生的代码将在运行时确定到底该运行该虚函数的哪个版本,依据是对象的动态类型。
    • 反之,如果mem不是虚函数或者我们是通过对象(而非引用或指针)进行的调用,则编译器将产生一个常规函数调用,也就是只考虑该对象的静态类型中的函数。

7.6 访问控制

之前的介绍我们只讲述了怎样定义类的接口和各种接口所需要的数据,这也就是完成了数据抽象的方面。

7.61 封装的优点

但是我们还需要进行类的封装,封装的意思就是指对类的成员的访问权限进行控制,比如禁止在类作用域外访问某成员。 我们对类的非接口数据进行封装,可以有效的引导用户来使用接口。

封装有两个重要的优点:

  • 确保用户的代码不会无意间破坏封装对象的状态。因为不进行封装,用户就有可能会使用到非接口的数据,从而可能会导致数据出错。
  • 被封装的类的具体实现细节可以随时改变,而无须用户调整之前的代码。

7.62 访问说明符

c++是用一种叫做访问说明符(access specifiers)的关键字来进行封装的。

访问说明符作用于某些成员,对这些成员的访问权限作出控制。

7.621 访问说明符分类

c++的访问说明符一共有三种:

  • 关键字public
  • 关键字private
  • 关键字protected

这些访问说明符有两种使用方式:

  1. 在定义类的类体中使用,使用形式为:

    访问说明符:

每个访问符的作用范围从使用的位置开始,一直到遇到另一个访问符或者类体的末尾为止的所有成员。

  1. 在定义类的类派生列表中每个直接基类的前面使用,使用形式为:

    访问说明符 基类名

每个访问符的作用范围为其后紧跟的那个基类的所有成员。

struct Cls
{
// private访问符作用的对象为ins和dou
private:
    int ins;
    double dou;
// protected访问符作用的对象为str和flo
protected:
    string str;
    float flo;
// public访问符作用的对象为cha,bl和lng
public:
    char cha;
    bool bl;
    long lng;
};

struct Cls2 {};
struct Cls3 {};
// protected、public和private的访问符作用的对象分别为Cls2、Cls和Cls3类的所有成员。
struct Der: protected Cls2, public Cls, private Cls3 {};

7.622 访问说明符的访问权限

7.6221 类体中的访问说明符

公有访问(public):

  • 对于被控制成员所属类作用域外的对象来说:
    • 可以通过该类的对象或者作用域运算符来对这些被控制成员进行访问。
  • 对于被控制成员所属类作用域内的所有成员来说:
    • 该类自己定义的成员:
      • 可以通过该类的对象或者作用域运算符对这些被控制成员进行访问,还可以直接对其进行访问。
    • 该类派生类的成员:
      • 可以通过该类和该类派生类的对象以及作用域运算符对这些被控制成员进行访问,还可以直接对其进行访问。
// 公有访问:
struct Cls
{
    // 定义正确:Cls类自己定义的成员可以通过作用域运算符或者直接访问。
    void cPrint() { cout << Cls::ins << str; }
    // 定义正确:Cls类自己定义的成员可以通过Cls类的对象obj访问。
    void cPrint2() { Cls obj; cout << obj.ins << obj.str; }
public:
    static const int ins = 5;
    string str = "strs";
};
struct Der: Cls
{
    // 定义正确:Cls类的派生类Der的成员可以直接访问。
    void dPrint() { cout << ins << str; }
    // 定义正确:Cls类的派生类Der的成员可以通过Cls类的对象obj和Cls类作用域运算符访问。
    void dPrint2() { Cls obj; cout << Cls::ins << obj.str; }
    // 定义正确:Cls类的派生类Der的成员可以通过Der类的对象obj和Der类作用域运算符访问。
    void dPrint3() { Der obj; cout << Der::ins << obj.str; }
};
Cls obj;
// 定义正确:Cls类作用域外的对象可以通过Cls类的对象obj或者Cls类作用域运算符来访问成员。
decltype(Cls::str) tmp_str = "access data member";
cout << Cls::ins << obj.str << tmp_str; 

私有访问(private):

  • 对于被控制成员所属类作用域外的对象来说:
    • 不能对这些被控制成员进行访问。
  • 对于被控制成员所属类作用域内的所有成员来说:
    • 该类自己定义的成员:
      • 可以通过该类的对象或者作用域运算符对这些被控制成员进行访问,还可以直接对其进行访问。
    • 该类派生类的成员:
      • 不能对这些被控制成员进行访问。
// 私有访问:
struct Cls
{
    // 定义正确:Cls类自己定义的成员可以通过作用域运算符或者直接访问。
    void cPrint() { cout << Cls::ins << str; }
    // 定义正确:Cls类自己定义的成员可以通过Cls类的对象obj访问。
    void cPrint2() { Cls obj; cout << obj.ins << obj.str; }
private:
    static const int ins = 5;
    string str = "strs";
};
struct Der: Cls
{
    // 定义错误:Cls类的派生类Der的成员不能访问。
    void dPrint() { cout << ins << str; }
    // 定义错误:Cls类的派生类Der的成员不能访问。
    void dPrint2() { Cls obj; cout << Cls::ins << obj.str; }
    // 定义错误:Cls类的派生类Der的成员不能访问。
    void dPrint3() { Der obj; cout << Der::ins << obj.str; }
};
Cls obj;
// 定义错误:Cls类作用域外的对象不能访问成员。
decltype(Cls::str) tmp_str = "access data member";
cout << Cls::ins << obj.str << tmp_str;

受保护访问(protected):

  • 对于被控制成员所属类作用域外的对象来说:
    • 不能对这些被控制成员进行访问。
  • 对于被控制成员所属类作用域内的所有成员来说:
    • 该类自己定义的成员:
      • 可以通过该类的对象或者作用域运算符对这些被控制成员进行访问,还可以直接对其进行访问。
    • 该类派生类的成员:
      • 可以通过该类派生类的对象以及作用域运算符对这些被控制成员进行访问,还可以直接对其进行访问,但不能通过该类的对象对这些被控制成员进行访问。
// 受保护访问:
struct Cls
{
    // 定义正确:Cls类自己定义的成员可以通过作用域运算符或者直接访问。
    void cPrint() { cout << Cls::ins << str; }
    // 定义正确:Cls类自己定义的成员可以通过Cls类的对象obj访问。
    void cPrint2() { Cls obj; cout << obj.ins << obj.str; }
protected:
    static const int ins = 5;
    string str = "strs";
};
struct Der: Cls
{
    // 定义正确:Cls类的派生类Der的成员可以直接访问。
    void dPrint() { cout << ins << str; }
    // 定义错误:Cls类的派生类Der的成员可以通过Cls类作用域运算符访问,但不能通过Cls类的对象obj访问。
    void dPrint2() { Cls obj; cout << Cls::ins << obj.str; }
    // 定义正确:Cls类的派生类Der的成员可以通过Der类的对象obj和Der类作用域运算符访问。
    void dPrint3() { Der obj; cout << Der::ins << obj.str; }
};
Cls obj;
// 定义错误:Cls类作用域外的对象不能访问成员。
decltype(Cls::str) tmp_str = "access data member";
cout << Cls::ins << obj.str << tmp_str;
7.6222 类派生列表中的访问说明符

对于派生类来说,其直接基类的公有和受保护成员对于自己相关的访问权限和自己所定义的公有和受保护成员一样,所以其直接基类的公有和受保护成员可以看作为自己类所定义的公有和受保护成员。

所以类派生列表中的访问符不会影响该派生类自己所定义的成员对直接基类成员的访问权限,而派生类类体中的访问符也不会影响到继承自其直接基类的成员的访问权限

类派生列表中的访问说明符是对其后紧跟的那个直接基类的所有成员起作用的,对于派生类来说:

  • 公有继承(public): 无影响。
  • 私有继承(private): 将从对应的直接基类继承而来的公有和受保护成员的访问权限改为私有访问。
  • 受保护继承(protected): 将从对应的直接基类继承而来的公有成员的访问权限改为受保护访问。
struct Cls
{
private:
    int ins;
    static int sta_ins;
protected:
    double dou;
    static double sta_dou;
public:
    string str;
    static string sta_str;
};
/* 公有继承的作用类似于以下形式:
struct Der
{
protected:
    double dou;
    static double sta_dou;
public:
    string str;
    static string sta_str;
}*/
struct Der: public Cls {};

/* 私有继承的作用类似于以下形式:
struct Der
{
private:
    double dou;
    static double sta_dou;
private:
    string str;
    static string sta_str;
}*/
struct Der: private Cls {};

/* 受保护继承的作用类似于以下形式:
struct Der
{
protected:
    double dou;
    static double sta_dou;
protected:
    string str;
    static string sta_str;
}*/
struct Der2: protected Cls {};
7.6223 默认的访问权限

之前我们接触过一些简单的访问控制: 在类定义时所用的关键字structclass就是一种简单的访问控制。

关键字structclass在定义类时的作用相同,它们的区别也就是对于该定义类的成员默认访问权限:

  • 对于类体中所有的没有访问符控制的成员来说:
    • 关键字struct代表公有访问。
    • 关键字class代表私有访问。
  • 对于类派生列表中所有的没有访问符控制的直接基类来说:
    • 关键字struct代表公有继承。
    • 关键字class代表私有继承。

7.623 用using声明语句调整访问权限

我们有一种特殊的方法可以调整类成员的访问权限,也就是之前在7.454 覆盖重载的函数中提到的using声明语句。使用using声明语句可以用来调整成员的访问权限。

我们可以将该类的直接或间接基类中的任何可访问成员(例如,非私有成员)在using声明语句中显式指明出来。

形式为:

using 基类名::成员名;

给定基类中所有该定义类可访问的给定名字的成员的访问权限由该using声明语句所在位置的访问权限来决定的,并且对这些成员访问权限的更改是永久的,是可继承的。

struct Ba0
{ void bPrint() { cout << "Ba0\n"; } };
struct Ba: Ba0
{ 
    void b2Print() { cout << "Ba\n"; }
protected:
    void b2Print(int val) { cout << val << " Ba\n"; }
};
struct Der: Ba
{
    // 将Ba的两个b2Print成员的权限改为公有
    using Ba::b2Print;
private:
    // 将Ba0的bPrint成员的权限改为私有
    using Ba0::bPrint;
};
struct Der2: Der {};

Ba0 obj;
Ba obj2;
Der obj3;
Der2 obj4;
// 正确:Ba0类中的bPrint为公有
obj.bPrint();
// 错误:Der类中的bPrint被改为私有
obj3.bPrint();
// 错误:Ba类中的b2Print为受保护的
obj2.b2Print(6);
// 正确:Der类中的b2Print被改为公有
obj3.b2Print(6);
// 正确:Der2类继承来自Der类中的公有成员b2Print
obj4.b2Print(6);

using语句中,给定基类的所有给定名字的成员中不能含有定义类不可访问的成员,否则出错。

struct Cls
{
private:
    void prints() {}
};
// 错误:using语句中不能含有不可访问的成员
struct Cls2: Cls
{ using Cls::prints; }

一个类中不能有多个对同一个成员的using声明语句,否则就是重复声明。 但是可以有该类的基类中同名但不同作用域的多个成员的using声明语句。

struct Cls
{
    void prints() {}
    void ret() {}
};
struct Cls2: Cls { void ret() {} }

class Cls3: Cls2
{
    // 错误:Cls3类中不能有多个对同一成员prints的using声明语句。
    using Cls2::prints;
    using Cls::prints;
    // 正确:一个是基类Cls2定义的函数ret,一个是基类Cls定义的函数ret,两个函数作用域不一样。
    using Cls2::ret;
    using Cls::ret;
}

7.63 友元

我们可能会需要某些非该类的成员的函数或者其他类对该类进行一些操作,并希望这些函数或者类能够访问该类的非公有成员。 此时,我们可以将这些其他类或者函数声明为该类的友元。

要注意,友元不是该类的成员,它是该类作用域外的对象,所以不受该类访问控制的约束。

7.631 友元的声明形式

友元的声明形式为:

friend 类或者函数的声明语句

友元声明中的声明语句一定要与该友元的声明部分一模一样,否则就不是该友元的声明,无意义了。

友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。 不过一般来说,最好在类定义开始或结束前的位置集中声明友元。

void prints(int val) {}
struct Other {};
struct Cls
{
    // Cls类的友元函数声明
    friend void prints(int val);
    int ins;
    // Cls类的友元类声明
    friend struct Other;
};

7.632 友元声明的特点

友元声明不是真正的声明,它仅仅是指定了一个访问的权限,而非一个通常意义上的声明。

根据友元的不同,友元声明有两种区别:

  • 对于友元类声明来说,该友元必须是定义类所能访问到的(也就是必须要可见且能访问)。
  • 对于友元函数声明来说,该友元可以是在定义类的作用域中不存在的实体。 所以如果我们希望使用到某个友元函数,那么我们就必须要确保在使用位置所在的作用域中存在该友元函数的真正声明和定义,而不只是在定义类内的友元声明。
struct Cls
{
    // 正确:友元可以是不存在的。
    friend void prints();
};
// 错误:友元声明不是真正的声明,所以prints不存在。
prints();

友元函数还可以是其他类的函数成员,但是因为作用域运算符只能用于类的成员,所以要保证该友元函数在该其他类中有声明才行,否则出错。

struct Other
{
    void prints();
};
struct Cls
{
    // 错误:prints是类Other的成员,要用作用域运算符
    friend void prints();
    // 正确
    friend void Other::prints();
};

普通友元函数(不能是类类型或者成员函数)能定义在类的内部,且这样的函数是隐式内联的,形式为:

friend 函数的定义语句

要注意定义在类内部的友元函数并不是这个类的成员,只是这个类的友元函数,所以不要把该友元函数看成了类成员。

因为友元声明不是真正的声明,所以我们必须在类的外部提供相应的函数声明从而使得函数可见。

struct Other
{
    void prints2();
};
struct Cls
{
    // 错误:友元类不能在类内定义。
    friend struct Friend_cls {};
    // 错误:友元成员函数不能在类内定义。
    friend void Other::prints2() { cout << "memfunc_friend\n"; };
    // 正确
    friend void prints() { cout << "func_friend\n"; }
};
// 错误:友元声明不是真正的声明,所以prints未定义
prints();
// 声明了prints
void prints();
// 正确
prints();

友元不是其定义类的成员,所以没有传递性,也不能被继承。一个类的友元只单单作用于这个类

// prints函数只能访问Cls的成员,不能访问Cls2的成员
void prints() {};
class Cls { friend void prints(); };
class Cls2: Cls {};

7.633 友元的访问权限

友元能够像其定义类自己定义的成员一样,除了该类基类的私有成员外,可以对该类其他所有成员进行访问。

友元类的访问方式和其定义类的静态函数成员的访问方式一样:

  • 对于其定义类的非静态成员,友元只能通过该类的对象进行访问。
  • 对于其定义类的静态成员,友元可以直接访问、或者通过该类的对象和作用域运算符进行访问。
  • 对于其定义类的类类型成员,友元可以直接访问、或者通过作用域运算符进行访问。

7.7 继承详述

之前我们介绍过基类和派生类以及它们之间的关系。接下来会介绍一些关于继承方面的操作与特性。

7.71 阻止继承

通常情况下,每个类都可以将其他的类当成其基类,但有时我们会定义这样一种类,我们不希望其他类继承它。 为了实现这一目的,C++11新标准提供了一种防止继承发生的方法,即在定义该类时,在类名后跟一个关键字final,形式为:

class/struct 类名 final (可选 类派生列表) 类体 (可选 类对象列表);

关键字final只能在定义类时使用,不能用于类的声明,且该类在定义时不能省略类名。

被关键字final标记的类不能出现在类定义的派生列表中。

class NoDerived final { /* */ }; // NoDerived不能作为基类
class Base {/**/}; 
// Last是final的;我们不能继承Last 
class Last final : Base { /* */ }; // Last不能作为基类 
class Bad : NoDerived { /* */ }; // 错误:NoDerived 是 final 的 
class Bad2 : Last { /* */ }; //错误:Last是final的

7.72 继承之间的类型转换

之间我们讲述过算术类型之间的转换,通常情况下,类型之间的转换是发生在两种有关联的类中的。

派生类向基类的隐式转换

基类和其派生类之间,其实也是存在某种关联的,派生类继承其基类的所有成员,且派生类的作用域也是在其基类的作用域内的。 所以,对于一个派生类对象来说,该对象以及该对象的引用和指针都能隐式转换为其任意基类(包括其所有的直接和间接基类,或者虚基类)的对象以及该基类的引用或指针,前提是==该派生类对象的这个基类部分在使用的位置是可访问的(与这个基类部分的基类本身的成员是不是可访问的无关)==。

派生类可以隐式转换为其基类,但是反过来是不行的,基类不能隐式转换为其派生类。 虽然基类可以显式转换为派生类,但是只支持转换为派生类的引用与指针,且转换时可能会出现未定义行为。

对于函数匹配来说,这种转换为类类型转换,不管向哪个基类转换,其优先级别都一样,不存在转换成谁更好。

struct Ba{};
struct Ba2{};
struct De:Ba {};
struct De2: De, Ba2 {};
void prints(De val) {}
void prints(Ba val) {}

De2 obj;
De2 *Dptr = &obj;
// 正确:派生类De2对象转换成基类De的对象
De obj2 = obj;
// 正确:派生类De2对象的转换成基类Ba的引用
Ba &obj3 = obj;
// 正确:指向派生类De2对象的指针转换成指向基类Ba2的指针
Ba2 *obj4 = Dptr;
// 错误:二义性,De2转换成De和Ba一样好
prints(obj);

7.721 静态类型与动态类型

在派生类向基类的转换过程中,类为了实现多态性,而对转换作出了一种特殊的操作,这也就是动态绑定。为了实现动态绑定,于是在继承中就有了静态类型与动态类型的说法。

7.7211 静态与动态类型区别

当我们使用存在继承关系的类型时,必须将一个变量或其他表达式的静态类型(static type)与该表达式表示对象的动态类型(dynamic type)区分开來。

  • 静态类型的表达式类型,在编译时总是已知的,它是变量声明时的类型或者表达式计算结果的类型。
  • 动态类型则是变量或表达式表示的内存中的对象的类型。动态类型直到运行时才可知。

静态类型与动态类型主要是用于有继承关系的类的引用与指针。 对于既不是引用也不是指针的表达式,则它的动态类型永远为其静态类型。

要注意,动态类型是表达式的内存中的对象的类型,所以对于多个类的引用与指针连用来说,它们的动态类型都是其源头的那个表达式的类型

struct Ba { virtual void prints() { cout << "Ba\n"; } };
struct De:Ba { virtual void prints() { cout << "De\n"; } };
struct De2:De { virtual void prints() { cout << "De2\n"; } };
De2 obj;
De &r = obj;
// 动态类型为De2
Ba *p = &r;
// 调用的是De2类的,输出De2
p->prints();
7.7212 转换中的成员变化

当派生类对象转换为基类的对象以及该基类的引用或指针时,派生类对象的成员会根据其静态类型有所变化。所有非继承于其静态类型的成员都会被切掉(sliced down),也就是这些成员不再存在了,具体的可以分为两种。

被切掉的成员有两种情况:

  1. 当只是转换为基类的对象而非为指针或引用时,这种情况是拷贝、移动或赋值,基类的对象只会继承于该基类的成员的数据,其他的成员数据都不保存(包括各种同名成员)。 因为是拷贝、移动或赋值得到的数据,所以当该基类对象又显式转换成派生类时,非该基类成员的值是未定义的。
  2. 当转换为基类的引用或者指针时,会切掉除了所有覆盖该基类的虚函数外的其他所有非继承于该基类的成员(包括各种同名成员)。 但由于是引用或指针,原派生类对象的内存还在,所以当该基类对象又显式转换成派生类时,非该基类成员的值恢复为内存的对应成员的值。

一个其类型有继承关系的表达式不能用非静态类型所属的成员。 但如果该表达式为引用或指针,且其动态类型覆盖了静态类型的某些虚函数,则该表达式只能用这些虚函数的动态类型版本而无法使用静态类型版本。

struct Ba 
{ 
    virtual void prints() { cout << "Ba\n"; } int Bins = 48; 
};
struct De:Ba
{ 
    virtual void prints() { cout << "De\n"; } 
    int Dins = 30; 
    De(int bins, int dins): Dins(dins) 
    { this->Bins = bins; }
};

De dObj(-8,-3);
// dObj转换为基类Ba的对象
Ba bObj = dObj;
// dObj转换为基类Ba的对象的引用
Ba &r = dObj;
// bObj只是基类对象,转换会派生类De的对象的引用,非Ba成员的值未定义
De dr = static_cast<De&>(bObj);
// 而r是基类对象的引用,转换为派生类De的对象的引用,非Ba成员的值恢复为内存dObj的对应成员的值
De dr2 = static_cast<De&>(r);
// 输出De
dr.prints();
dr2.prints();
// Dins的值未定义
cout << dr.Dins << "\n";
// Dins的值恢复为内存dObj的Dins值,输出-3
cout << dr2.Dins << "\n";
7.7213 派生类向基类转换的可访问性

派生类向基类的转换是否可访问由使用该转换的位置决定,同时派生类的派生访问说明符也会有影响。

假定D继承自B:

  • 只有当D公有地继承B时,用户代码才能使用派生类向基类的转换;如果D继承B的方式是受保护的或者私有的,则用户代码不能使用该转换
  • 不论D以什么方式继承B, D的成员函数和友元都能使用派生类向基类的转换;派生类向其直接基类的类型转换对于派生类的成员和友元來说,永远是可访问的。
  • 如果D继承B的方式是公有的或者受保护的,则D的派生类的成员和友元可以使用D向B的类型转换;反之,如果D继承B的方式是私有的,则D的派生类的成员和友元不能使用该类型转换。
class Ba{};
class De: public Ba{};
class De2: protected Ba{ friend void test2(); };
class De3:Ba{ friend void test2(); };

De obj;
De2 obj2;
De3 obj3;
void test()
{
    // 以下三种定义语句都正确:obj中的Ba部分为公有继承,函数test是可以访问到其Ba部分的。
    Ba *ptr = &obj;
    Ba &r = obj;
    Ba bobj = obj;
    // 以下三种定义语句都错误:obj2中的Ba部分为受保护继承,函数test不能访问到其Ba部分。
    Ba *ptr2 = &obj2;
    Ba &r2 = obj2;
    Ba bobj2 = obj2;
    // 以下三种定义语句都错误:obj3中的Ba部分为私有继承,函数test不能访问到其Ba部分。
    Ba *ptr3 = &obj3;
    Ba &r3 = obj3;
    Ba bobj3 = obj3;
}

void test2()
{
    // 以下三种定义语句都正确:obj中的Ba部分为公有继承,函数test2是可以访问到其Ba部分的。
    Ba *ptr = &obj;
    Ba &r = obj;
    Ba bobj = obj;
    // 以下三种定义语句都正确:obj2中的Ba部分为受保护继承,但是函数test是De2的友元,所以可以访问到其Ba部分。
    Ba *ptr2 = &obj2;
    Ba &r2 = obj2;
    Ba bobj2 = obj2;
    // 以下三种定义语句都正确:虽然obj3中的Ba部分为私有继承,但是函数test是De3的友元,所以可以访问到其Ba部分。
    Ba *ptr3 = &obj3;
    Ba &r3 = obj3;
    Ba bobj3 = obj3;
}

总而言之,对于调用位置来说,如果基类的公有成员是可访问的,则其派生类就可以转换为该基类;反之则不行。

7.722 运行时类型识别

我们之前讲述过有关虚函数的知识,虚函数的作用是使其类通过引用或者指针来调用该函数时,执行动态绑定,即在运行时选择该函数的版本,所以动态绑定也叫做运行时绑定(run-time binding)。

虚函数的这种性质也是运行时类型识别(run-time type identification,RTTI)的功能的一部分,使用RTTI功能可以在一定程度上忽略相似类型的区别,而以统一的方式使用它们的对象。

RTTI功能还有另外两个部分:

  • dynamic_cast运算符,用于将基类的指针或引用安全地转换成派生类的指针或 引用。
  • typeid运算符,用于返回表达式的类型。

当我们将这两个运算符用于某种类型的指针或引用,并且该类型含有虚函数时,运算符将使用指针或引用所绑定对象的动态类型。

7.7221 dynamic_cast运算符

dynamic_cast运算符是显式类型转换的一种,可以用于基类与派生类的相互转换。

7.72211 dynamic_cast的使用形式

dynamic_cast运算符的使用形式只有以下三种,为:

  1. dynamic_cast<type*> (e)

  2. dynamic_cast<type&> (e)

  3. dynamic_cast<type&&> (e)

其中,type必须是类类型,e必须是一个表达式,且e中不能含有类型名。 在第一种形式中,e必须是一个有效的指针; 在第二种形式中,e必须是一个左值; 在第三种形式中,e不能是左值。

这三种形式情况下都返回指定的type相关类型,且都为左值。

7.72212 dynamic_cast的使用条件

在上面的所有形式中,e的类型必须符合以下三个条件中的任意一个,否则就会编译出错:

  • e的静态类型必须是目标type的公有继承派生类,此时e的静态类型和type类型都可以不含虚函数。
  • e的静态类型就是目标type的类型,此时e的静态类型和type类型都可以不含虚函数。
  • e的静态类型是目标type的公有继承基类,此时e的静态类型必须要含有虚函数,type类型可以不含虚函数。

在编译正确的情况下,e的动态类型还必须为type的公有继承派生类或者就是type的类型(此时e的动态类型和type类型都可以不含虚函数),否则类型转换失败。

如果一条dynamic_cast表达式的转换目标是指针类型并且失败了,则结果为0。如果转换目标是引用类型并且失败了,则dynamic_cast运算符将抛出一个名为std::bad_cast异常,该异常定义在typeinfo标准库头文件中。

因为转换成功的要求是e的动态类型必须为type的公有继承派生类或者就是type的类型,所以e的静态类型显式转换成其派生类型时,是安全的。 因为e的内存是它们的公有继承派生类,含有各种基类的成员,所以转换后,非该静态类型成员的值会变为内存中对应成员的值。

struct Ba 
{
    virtual void prints(string val) 
    {cout << val << " stringBa\n";}
};
struct De:Ba
{
    void prints(int val)
    {cout << val << " intDe\n";}
};
struct De2: De
{
    void prints(double val)
    {cout << val << " doubleDe2\n";}
};

De2 d2_obj;
De d_obj;
Ba *d2r = &d2_obj;
Ba &dr = d_obj;
/* d2r的静态类型为Ba,为De的公有基类,
因为Ba有虚函数,所以编译正确;
d2r的动态类型为De2,为De的公有派生类,所以转换成功。*/
De* ptr = dynamic_cast<De*>(d2r);
/* dr的静态类型为Ba,为De2的公有基类,
因为Ba有虚函数,所以编译正确;
dr的动态类型为De,不是De2的公有派生类,
所以转换失败,引发std::bad_cast异常*/
De2& rr = dynamic_cast<De2&>(dr);

因为dynamic_cast运算符返回的都是对应类型的指针或者引用,所以当存在动态绑定时仍会进行动态绑定。

struct Ba 
{
    virtual void prints(string val) 
    {cout << val << " stringBa\n";}
};
struct De:Ba
{
    void prints(string val) override
    {cout << val << " stringDe\n";}
};
struct De2: De
{
    void prints(string val) override
    {cout << val << " stringDe2\n";}
};
De2 d2_obj;
Ba *d2p = &d2_obj;
// 输出str stringDe2
d2p->prints("str");
// ptr的动态类型仍是De2类
De *ptr = dynamic_cast<De*>(d2p);
// 输出str stringDe2
ptr->prints("str");
7.7222 typeid运算符

typeid运算符(typeid operator) 有些类似于decltype说明符,它会说明给定表达式的类型。

7.72221 typeid运算符的使用形式

typeid运算符的使用形式为:

typeid(e)

e为右值,可以是:

  • 任意的非声明或定义表达式。
  • 类型名(包含类型修饰符)

typeid返回左值,该值为一个常量对象的引用,这个引用包含e表达式的计算结果的类型或者是e所给的类型。 这个引用的类型是标准库类型type_info或者type_info的公有派生类型。 type_info类定义在typeinfo头文件中。

对于e值的不同,typeid会有以下的特点:

  • 对于e表达式的计算结果的类型或者是e所给的类型,typeid会忽略其顶层const。
  • 当e表达式的计算结果为左值且计算结果的静态类型至少含有一个虚函数时,typeid所得的类型直到运行时才会求得,为该所得类型为e表达式计算结果的动态类型。此时,e表达式必须是一个有效值,否则,运行时typeid将抛出一个名为bad_typeid的异常。
  • 当e表达式不满足计算结果为左值且计算结果的静态类型至少含有一个虚函数的条件时,typeid所得类型将会是计算结果的静态类型,且无须对表达式求值也能知道其表达式的静态类型。

所以,当typeid作用于指针时(而非指针所指的对象),返回的结果是该指针的静态类型。

7.72222 type_info类

type_info类的精确定义随着编译器的不同而略有差异。 不过,C++标准规定type_info类必须定义在typeinfo头文件中,并且至少提供以下的操作:

操作 解释
t1 == t2 如果type_info的对象t1和t2是同一种类型,就返回true;否则返回false。
t1 != t2 如果type_info的对象t1和t2表示的是不同的类型,就返回true;否则返回false。
t.name() 返回一个字符串字面值,表示类型名字的可打印形式。类型名字的生成方式因系统而异。
t1.before(t2) 返回一个bool值,表示t1对象在编译器的储存顺序是否位于t2之前。所采用的顺序关系是依赖于编译器的。

type_info类没有默认构造函数,而且它的拷贝和移动构造函数以及赋值运算符都被定义成删除的。因此,我们无法定义或拷贝type_info类型的对象,也不能为type_info类型的对象赋值。 创建type_info对象的唯一途径 是使用typeid运算符。

type_info类一般是作为一个基类出现,所以它还应该提供一个公有的虚析构函数。当编译器希望提供额外的类型信息时,通常在type_info的派生类中完成。

type_info类的name成员函数返回一个字符串字面值,表示所得的类型名字。 对于某种给定的类型来说,name的返回值因编译器而异并且不一定与在程序中使用的名字一致。 对于name返回值的唯一要求是,类型不同则返回的字符串必须有所区别。

type_info类在不同的编译器上有所区别。有的编译器提供了额外的成员函数以提供程序中所用类型的额外信息。读者应该仔细阅读你所用编译器的使用手册,从而获取关于type_info的更多细节。

7.73 虚继承

之前说过,一个类可以有多个直接基类。

虽然派生类的类派生列表中不能有相同的直接基类,但是一个类可能会多次继承同一个类的成员。比如其有多个直接基类继承了同一个类,导致该类多次继承了这个类。

在默认情况下,派生类中含有继承链上每个类对应的子部分。如果某个类在派生过程中出现了多次,则派生类中将包含该类的多个相同部分。 这就会导致当使用这个包含多个部分的类的成员时,就会编译错误,提示出现二义性。

这种问题可以通过两种方法解决:

  • 通过作用域运算符显式指定哪一个类中的。
  • 使用虚继承。

在C++语言中,我们可以通过虚继承(virtual inheritance)的机制解决上述问题。 虚继承的目的是令某个类做出声明,承诺愿意共享它的基类。其中,共享的基类被称为虚基类(virtual base class)。 在某个类的虚基类会与其他同名的虚基类共享一个基类部分。

虚基类的作用为:

  • 对于某个类的所有基类来说,其中所有同名的虚基类在该派生类中只会出现一个该基类部分,而不会出现多个部分。
  • 所有同名的虚基类会共享这一个部分,所以这一个部分是属于任意一个同名虚基类的。

之前说过虚继承的使用方式,是在类派生列表中的某个需要声明为虚基类的类前加上关键字virtual,其中与派生访问说明符的顺序可以任意:

virtual 基类名

struct Base {};
// Der和Der2类虚继承Base类
struct Der: private virtual Base {};
struct Der2: virtual protected Base {};

被关键字virtual标记的类的这一个副本在该派生类以及该派生类的派生类中就成为了虚基类。 要注意是这个类的副本在该派生类的派生列表中被标记为虚基类,而不是该类被标记成了虚基类,其他继承该类的派生类如果没有显式标记其为虚基类,则该类在这些派生类中仍然不是虚基类。

struct Ba { string bStr = "ba"; };
// 虚继承Ba,Ba在该类中为虚基类
struct De: virtual Ba { string dStr = "de"; };
// 非虚继承Ba,所以Ba在该类中仍为普通基类
struct De2: Ba { string d2Str = "de2"; };
// 包含两个Ba的部分,一个Ba为虚基类,一个不是虚基类
struct De3: De, De2 { string d3Str = "de3"; };
De3 obj;
// 错误:二义性调用
cout << obj.bStr;

因为虚基类之间共享一个基类,所以就有效的解决了二义性问题。 不过要注意上面例子中,同一个基类的虚继承与普通继承混用的情况,当一个基类在某类中,既是有虚基类部分,又有非虚基类部分,则根据虚继承的性质,该派生类有非虚基类部分的数目加1个该基类的部分,还是会有二义性问题。所以尽量不要出现对同一个基类虚继承与普通继承混用的情况。

struct Ba { string bStr = "ba"; };
// 虚继承Ba,Ba在该类中为虚基类
struct De: virtual Ba { string dStr = "de"; };
// 也是虚继承Ba,Ba在该类中为虚基类
struct De2: virtual Ba { string d2Str = "de2"; };
// 只包含一个Ba的部分,两个虚基类共享同一个部分
struct De3: De, De2 { string d3Str = "de3"; };
De3 obj;
// 正确:输出ba
cout << obj.bStr;
struct Ba { string bStr = "ba"; };
// 虚继承Ba,Ba在该类中为虚基类
struct De: virtual Ba { string dStr = "de"; };
// 只包含一个Ba的部分,两个虚基类共享同一个部分
struct De2: De, virtual Ba { string d2Str = "de2"; };
// 正确:输出ba
cout << obj.bStr;

7.8 类类型转换

之前我们介绍过一些内置类型的转换规则,和内置类型一样,我们自定义的类型也可以定义相关的转换规则。

对于类类型转换(class-type conversions),有时也被称作用户定义的类型转换(user-defined conversions),有两种定义方法:

  • 转换构造函数(converting constructor)
  • 类型转换运算符

类类型的隐式转换也要遵循类型转换的规则: 编译器毎次只能执行一种类类型的隐式转换,否则转换失败。 但是类类型的隐式转换可以置于算术类型的隐式转换之前或之后,与其一起使用。

接下来我们就来逐个介绍这两种类类型转换方法。

7.81 转换构造函数

任何构造函数只要满足:第一个形参的类型是所要转换成该类类型的类型(该形参可以有默认实参),没有其他形参或者其他的形参都有默认实参。 那么该构造函数就是一个转换构造函数(converting constructor)。

所以有形参的默认构造函数也就是一个转换构造函数。

和普通构造函数一样,转换构造函数也能支持重载等操作。

struct Cls
{
    int ins;
    double dou;
    // 转换构造函数
    Cls(int int_val, double dou_val = 12): ins(int_val), dou(dou_val) {}
};

7.811 转换构造函数的使用

在任何需要该类型对象的地方,如果我们只给了一个可以转换成该类型的其他类型对象,隐式地将这个对象转换成该类型的对象(临时对象)。

具体来说,也就是编译器会按照所给对象的类型匹配将要转换的类型中的转换构造函数,然后根据所给对象的值调用该转换构造函数并生成一个对应类型的临时对象,该对象即是执行转换后的对象。

struct Cls
{
    int ins;
    // 转换构造函数
    Cls(int int_val): ins(int_val) {}
};
void prints(Cls other) { cout << other.ins; }

Cls obj(15);
// 输出15
cout << obj.ins;
// 调用Cls的转换构造函数,生成了一个成员ins为35的Cls对象,并将其赋值给obj。
obj = 35;
// 输出35
cout << obj.ins;
// 调用Cls的转换构造函数,生成了一个成员ins为65的Cls对象,并将其赋值给prints的形参other。\
输出65
prints(65);
struct Cls
{
    int ins;
    Cls(int int_val): ins(int_val) {}
};
struct Cls2
{
    Cls cls;
    Cls2(Cls obj): cls(obj) {}
};
Cls obj(3);
// 错误:该表达式需要的类型转换顺序为int->Cls->Cls2,\
但是编译器毎次只能执行一种类类型的隐式转换。
Cls2 obj2 = 8;
// 正确:该表达式需要的类型转换顺序为int->Cls->Cls2,\
int->Cls是显式转换。
Cls2 obj2 = Cls(8);

7.812 显式构造函数

当我们想阻止某个转换构造函数的隐式转换时,可以在该转换构造函数的声明或定义语句之前用关键字explicit进行声明,该函数也就叫做显式构造函数。

形式为:

explicit 转换构造函数的声明或定义语句

和声明静态函数一样,关键字explicit只能在类内声明或定义中使用,当转换构造函数在类外定义时不要加关键字explicit,否则出错。

struct Cls
{
    int ins;
    // 被声明为explicit的转换构造函数
    explicit Cls(int int_val);
};
Cls::Cls(int int_val): ins(int_val) {}

被声明为explicit的转换构造函数除了能用于显式转换,只能用于直接初始化,不能用于拷贝初始化,所以编译器将不会在自动转换过程中使用该转换构造函数。

#include <iostream>
using namespace std;
struct Cls
{
    int ins;
    // 被声明为explicit的转换构造函数
    explicit Cls(int int_val);
};
Cls::Cls(int int_val): ins(int_val) {}
int main()
{
    // 错误:不能进行隐式转换
    Cls obj = 15;
    // 正确:显式调用该转换构造函数进行转换。
    Cls obj2 = static_cast<Cls>(15);
    // 正确:显式直接初始化调用该转换构造函数
    Cls obj3 = Cls(15);
    return 0;
}

7.82 重载运算符简述

在介绍类型转换运算符之前,我们需要了解一点儿有关重载运算符(overloaded operator)的知识。

重载运算符本质上就是函数,可以声明或者定义。其名字由operator关键字后接表示要定义的运算符的符号组成。

大部分的重载运算符声明形式为:

  1. 返回类型 operator 运算符号 形参表 (可选 限定符);

  2. auto operator 运算符号 形参表 -> 返回类型 (可选 限定符);

比如,赋值运算符就是一个函数。 类似于任何其他函数,大部分的运算符函数也有一个返回类型和一个参数列表。

重载运算符的每个形参是表示运算符的每个运算对象,一个重载运算符的形参数量必须要与其运算对象数目相同。

和普通函数一样,相同运算符的重载运算符可以定义多个,也就是重载运算符的重载,其也要遵循函数重载的规则。

有些重载运算符只能是普通函数或者是成员函数,比如赋值运算符只能为成员函数;但对于另外一些重载运算符,它们既可以是普通函数,也可以是成员函数,比如算术运算符。

作为成员函数的重载运算符和普通成员函数一样,可以定义在类内或类外,也可以为静态、const、引用或者虚函数成员。

如果一个运算符是一个非静态成员函数,则其左侧的运算对象就绑定到隐式的this参数,其他的运算对象就按照顺序绑定到其他显式形参上。 比如,对于一个二元运算符如赋值运算符来说,左侧的运算对象绑定到隐式的this参数,其右侧运算对象就作为显式参数传递。

除了重载的函数调用运算符operator()之外,其他重 载运算符都不能含有默认实参。

struct Cls
{
    int ins = 5;
    // 赋值运算符:\
    为成员函数,\
    左侧运算对象为该类对象,\
    右侧运算对象为int对象,\
    返回赋值后的左侧运算对象
    Cls& operator=(int val) { this->ins = val; return *this; }
};
// 加法运算符:\
  为普通函数,\
  左侧运算对象为Cls类对象,\
  右侧运算对象为int对象,\
  返回执行加法后的int值。
int operator+(Cls& cls, int val) { return cls.ins + val; }

对于重载运算符,我们有两种调用方法:

  • 隐式调用: 当我们定义了重载运算符后,在这些重载运算符的可用范围内,当出现含有运算符的表达式时,就会根据运算符的运算对象的类型隐式调用匹配的重载运算符;比如,对于某个运算对象为类类型对象来说,编译器会首先自动调用该对象类型的对应非静态重载运算符。
  • 显式调用: 因为重载运算符也是函数,所以我们可以用调用函数的形式显式调用重载运算符,并在实参表中传递对应类型的实参。
struct Cls
{
    int ins = 5;
    Cls& operator=(int val) { this->ins = val; return *this; }
};
int operator+(Cls& cls, int val) { return cls.ins + val; }

Cls obj;
// 隐式调用成员函数Cls& operator=(int val)。
obj = 78;
// 显式调用成员函数Cls& operator=(int val)。
obj.operator=(30);
// 输出30
cout << obj.ins;
// 以下都为调用普通函数int operator+(Cls& cls, int val)。
// 隐式调用,输出45
cout << obj + 15;
// 显式调用,输出45
cout << operator+(obj, 15);

所以对于静态重载运算符来说,只能显式调用来使用该运算符函数。

7.83 类型转换运算符

类型转换运算符(conversion operator)是一种特殊重载运算符,它负责将所属类的对象转换成其他类型的对象(临时对象)。

它的使用形式与一般的重载运算符不同,声明形式为:

operator 类型名() (可选 类型限定符);

其中类型名是运算符指定的类型,它表示将要转换成的类型。该类型可以为除void之外的任意类型,只要该类型能作为函数的返冋类型就行。 所以,我们不允许转换成数组或者函数类型,但允许转换成指针(包括数组指针及函数指针)或者引用类型。

类型转换运算符不能写返回类型,且形参表必须为空,而且类型转换运算符必须定义成类的成员函数。 类型转换运算符的函数体中基本都要有return语句,其中return语句的表达式类型必须要能隐式转换成运算符所指定的类型。

类型转换运算符通常不应该改变待转换对象的内容,因此,类型转换运算符一般被定义成const成员。

和转换构造函数一样,类型转换运算符会在任何需要转换的地方自动执行隐式转换;也可以显式调用该运算符。

#include <iostream>
using namespace std;
struct Cls
{
    int ins;
    Cls(int int_val): ins(int_val) {}
    // 类型转换运算符
    operator int() const { return ins; }
};
void prints(int val) { cout << val; }

int main()
{
    Cls obj(61);
    // 隐式执行Cls->int的转换,输出61
    cout << obj;
    // 隐式执行Cls->int的转换,输出61
    prints(obj);
    // 显式执行Cls->int的转换,输出61
    cout << static_cast<int>(obj);
    // 显式执行Cls->int的转换,输出61
    cout << obj.operator int();
    // 显式执行Cls->int的转换,输出61
    prints(obj.operator int());
    return 0;
}

7.831 显式类型转换运算符

和显式构造函数一样,我们也能声明类型转换运算符为explicit的,该类型转换运算符也叫做显式类型转换运算符(explicit conversion operator)。

显式类型转换运算符的形式与显式构造函数一样,在该类型转换运算符的声明或定义语句之前用关键字explicit进行声明。

声明形式为:

explicit 类型转换运算符的声明

和显式构造函数一样,关键字explicit只能在类内中使用。

struct Cls
{
    int ins;
    Cls(int int_val): ins(int_val) {}
    // explicit的类型转换运算符
    explicit operator int() const;
};
Cls::operator int() const { return ins; }

和显式构造函数一样,除了bool显式类型转换运算符的某个特殊情况以外,编译器将不会再自动使用该类型的其他显式类型转换运算符来进行隐式转换。

#include <iostream>
using namespace std;
struct Cls
{
    int ins;
    Cls(int int_val): ins(int_val) {}
    // explicit的类型转换运算符
    explicit operator int() const { return ins; }
};
void prints(int val) { cout << val; }

int main()
{
    Cls obj(61);
    // 错误:不能隐式转换
    cout << obj;
    // 错误:不能隐式转换
    prints(obj);
    // 正确:显式执行Cls->int的转换,输出61
    cout << static_cast<int>(obj);
    // 正确:显式执行Cls->int的转换,输出61
    cout << obj.operator int();
    // 正确:显式执行Cls->int的转换,输出61
    prints(obj.operator int());
    return 0;
}

只有bool显式类型转换运算符才有一个特殊情况,情况为: 如果某类型定义了bool类型的显式类型转换运算符,当该类型对象:

  • 为逻辑运算符的运算对象。
  • 出现在条件表达式且该表达式中该对象只可以当作逻辑运算符的运算对象或者不当作任何运算对象。

    只有控制语句和条件运算符中的条件部分才能被称为条件表达式。

当需要转换时,编译器会自动调用该类型的bool显式类型转换运算符来进行隐式转换。

struct Cls
{
    int ins;
    Cls(int int_val): ins(int_val) {}
    // bool显式类型转换运算符
    explicit operator bool() const { return ins; }
};
Cls obj(15);
// 正确:隐式转换Cls->bool,输出为good
if (0 || obj) cout << "good";

7.84 类类型转换的匹配

转换构造函数和类型转换运算符都是函数,且都能进行重载,所以它们都遵循函数的匹配规则。

和其他成员函数一样,函数匹配的函数候选集中会有各种私有和已删除的函数。

注意当隐式转换时,(除了bool显式类型转换运算符的特殊情况)函数的候选集是不会有explicit的函数的。

7.841 类类型转换的二义性问题

所以在类类型转换时,要注意函数匹配的二义性问题。 对于类类型转换的匹配二义性问题,一般会出现在以下几种情况:

  • 两个不同类之间互相提供相同的类型转换: 例如,当A类定义了一个接受B类对象的转换构造函数,同时B类定义了一个转换目标是A类的类型转换运算符。
  • 类定义了多个转换规则,且对于某种调用来说有多种最佳相同匹配度的函数。
  • 调用重载的函数时,该实参类型需要转换,且有多个转换后的最佳相同匹配度的函数。

对于以上这些二义性问题,我们无法使用强制类型转换来解决二义性问题,因为强制类型转换本身也面临二义性,所以我们只能显式地调用某特定的类型转换运算符或者转换构造函数来消除这种二义性问题。

7.842 定义类类型转换的建议

要想正确地设计类的类类型转换,必须加倍小心。尤其是当类同时定义了类型转换运算符及重载运算符时特别容易产生二义性。

以下的经验规则可能对你有所帮助:

  • 不要令两个类执行相同的类型转换 例如:如果Foo类有一个接受Bar类对象的构造函数,则不要在Bar类中再定义转换目标是Foo类的类型转换运算符。
  • 避免转换目标是内置算术类型的类型转换。特别是当你已经定义了一个转换成算术类型的类型转换时,接下来就
    • 不要再定义接受算术类型的重载运算符。如果用户需要使用这样的运算符,则类型转换操作将转换你的类型的对象,然后使用内置的运算符。
    • 不要定义转换到多种算术类型的类型转换。让标准类型转换完成向其他算术类型转换的工作。

总之,除了可以定义bool显式类型转换运算符之外,我们应该尽量避免定义其他的类型转换函数并尽可能地限制那些“显然正确”的非显式构造函数。

7.9 拷贝控制

之前,我们学习了通过传递实参给普通构造函数来创建并初始化一个类的对象。 但是,除了这种方法,我们还可以定义类对象的其他初始化方法,如拷贝初始化等。 而且,我们还可以定义类对象被另一个同类型的对象赋值发生的行为以及对象被销毁时发生的行为。

以上这些操作统称为拷贝控制操作。

如果我们不主动定义这些操作,则编译器将替我们合成它们。一般来说,编译器生成的版本将对对象的每个成员执行拷贝、赋值和销毁操作。但是,对一些类来说,依赖这些操作的默认定义会导致很多问题,所以我们需要知道如何定义这些操作。

一个类可以通过定义五种特殊的成员函数来控制这些操作,根据这些操作的作用情景,可以分为三种类型:

  • 类对象的初始化
    • 拷贝构造函数(copy constructor)
    • 移动构造函数(move constructor)
  • 同类对象的赋值
    • 拷贝赋值运算符(copy-assignment operator)
    • 移动赋值运算符(move-assignment operator)
  • 类对象的销毁
    • 析构函数(destructor)

其中的赋值运算符函数只能定义为非静态函数成员。

这些函数和其他普通成员函数一样,都支持类内类外定义。

以上的除了析构函数不支持重载,其他函数都支持重载,只要符合重载条件就行。 以上的除了构造函数,其他函数都可以为虚函数,且都能有对应的虚函数限定符。

7.91 类对象初始化操作

接下来,我们需要学习其他的类对象的初始化方法。

  • 类对象的初始化
    • 拷贝构造函数(copy constructor)
    • 移动构造函数(move constructor)

和普通构造函数一样,我们依然只能用构造函数来初始化,只不过这两个构造函数的初始化方式是和普通构造函数不一样的。

这两个构造函数和其他的构造函数一样,可以有初始值列表以及函数体,还可以被定义为默认构造函数或者是explicit的构造函数。

struct Cls
{
    static Cls self_obj;
    // 定义了一个拷贝构造函数,这个拷贝构造函数也是默认构造函数
    Cls(Cls& = self_obj) { cout << "copy_constructed!\n"; };
};
Cls Cls::self_obj = {};

/* 在下面的操作中,拷贝构造函数被调用了三次,
前两次被当做默认构造函数使用,
分别用于self_obj和obj的初始化;
最后一次是当成拷贝构造函数使用,
用于obj2的初始化。
所以输出为:
copy_constructed!
copy_constructed!
copy_constructed!
*/
Cls obj;
Cls obj2 = obj;

7.911 拷贝构造函数

拷贝构造函数主要是将一个同类型的左值对象(当然也可以是右值)的数据复制到将要创建的对象中,使它们之间的数据一样。

7.9111 拷贝构造函数的形式

如果一个构造函数的第一个形参是自身类类型的左值引用(如果还需要复制右值数据,则要用左值常量引用),且没有其他形参或者其他的形参都有默认实参,则此构造函数就是拷贝构造函数。

struct Cls
{
    // 默认构造函数
    Cls () {};
    // 拷贝构造函数
    Cls (Cls &) {};
};

拷贝构造函数的第一个参数必须是一个引用类型,如果不是引用类型,则会出错。 因为防止出现调用永远也不会成功的情况:为了调用拷贝构造函数,我们必须拷贝它的实参,但为了拷贝实参,我们又需要调用拷贝构造函数,如此无限循环。

虽然我们可以定义一个接受非const引用的拷贝构造函数,但此参数几乎总是一个const的引用。

7.9112 拷贝构造函数的使用

在下列使用情景中,除了拷贝初始化以外,其他的只会调用其拷贝构造函数来进行操作:

  • 直接初始化的构造函数匹配中,拷贝构造函数是最佳匹配。
  • 拷贝初始化。
  • 将一个非临时对象作为实参传递给一个非引用类型的形参。
  • 从一个返回类型为非引用类型的函数返回一个非临时对象,该对象不能为该函数自己定义的非静态局部变量。
  • 用花括号列表初始化一个数组中的元素或者一个聚合类中的成员,花括号中的初始值都不能为临时对象。
  • 某些类类型还会对它们所分配的对象使用拷贝初始化。 例如,当我们用非临时对象初始化标准库容器或是调用其insert或push成员且使用非临时对象时,容器会对其元素进行拷贝初始化。

拷贝初始化不仅仅只会使用拷贝构造函数,编译器还会根据所要拷贝的初始值的左右值属性来决定应该调用拷贝还是移动构造函数。

7.912 移动构造函数

因为拷贝构造函数是复制初始值的数据,所以对于一些所需空间较大的类对象,这种操作就比较耗费空间了,尤其是初始值为临时对象时还需要复制。 于是就有了移动构造函数:移动构造函数和拷贝构造函数差不多,但是移动构造函数主要是对初始值为同类型的右值对象操作的。 因为初始值是右值对象,所以它们的数据即将被销毁,所以移动构造函数就可以直接接管该对象的数据,也就是将初始值数据直接移动到将要创建的对象中,而不是复制,这样也节约了资源。

7.9121 移动构造函数的形式

如果一个构造函数的第一个形参是自身类类型的右值引用,且没有其他形参或者其他的形参都有默认实参,则此构造函数就是移动构造函数。

struct Cls
{
    // 拷贝构造函数
    Cls (Cls &) {};
    // 移动构造函数
    Cls (Cls &&) {};
};

移动构造函数不像拷贝构造函数一样,一般不设为const引用,因为不管是不是常量右值引用,都能绑定对应类型的右值,而且设为const引用后还不能修改其值,所以不设为const引用。

移动构造函数因为是移动数据,所以定义移动构造函数时还必须确保移后源对象处于这样一个状态——销毁它是无害的,否则有可能出问题。 特别是,一旦资源完成移动后,源对象必须不再指向被移动的资源——这些资源的所有权已经归属新创建的对象。

与拷贝构造函数不同,移动构造函数不分配任何新内存,它只是接管给定的对象的内存。因此,移动操作通常不会抛出任何异常,所以我们在需要在移动构造函数中指明noexcept

我们必须在类内声明和类外定义中(如果定义在类外的话)都指定noexcept

7.9122 移动构造函数的使用

移动构造函数的使用情景差不多,只不过只会在初始值为右值时使用。 比如在拷贝初始化过程中,编译器会根据所要拷贝的初始值的左右值属性来决定应该调用拷贝还是移动构造函数。

如果一个类有一个拷贝构造函数但未定义移动构造函数,那么编译器就会调用拷贝构造函数来进行操作,但必须有某个拷贝构造函数的第一个形参为const左值引用,否则出错(因为只有常量引用才能绑定到右值上)。

7.913 派生类的拷贝/移动构造函数

拷贝/移动构造函数的执行顺序和普通构造函数的顺序一模一样。 对于在继承关系的类的拷贝控制成员,它们会调用每个基类自己对应的拷贝控制成员来执行对应基类部分的对应操作。

在默认情况下,使用拷贝/移动构造函数创建类对象时,派生类是用其对应基类的默认构造函数来初始化派生类对象的该基类部分。 所以如果我们想拷贝或者移动派生类对象的基类部分,则必须要在派生类的拷贝/移动构造函数的初始值列表中显式地使用基类的拷贝或者移动构造函数

7.914 构造函数的跳过

在拷贝初始化过程中(包括各种该类型的拷贝初始化,比如类对象、形参和返回值的拷贝等),如果其初始值为==同类型的临时对象==时,编译器会直接跳过移动/拷贝构造函数(也不会调用其他的构造函数),直接创建该类对象。

但是必须要满足一个条件才能执行跳过: 在这个调用点上,需要跳过的对应的拷贝或者移动构造函数必须是存在、可访问且未删除的,也就是调用点的实参所匹配的最佳构造函数必须可访问且未删除的。

struct Cls
{
    int ins;
    Cls() {}
    // 拷贝构造函数
    Cls(const Cls& other): ins(other.ins) { cout << "const_copy_constructed!\n"; };
    // 转换构造函数
    Cls(int val): ins(val) { cout << "convert_constructed!\n"; };
};
// Cls()生成了一个临时对象,所以拷贝初始化时Cls(const Cls& other)就被跳过了,不输出任何值
Cls obj = Cls();
// 先调用的是转换构造函数,然后生成一个临时对象,然后跳过拷贝构造函数\
输出\
convert_constructed!
Cls obj2 = 5;
struct Cls
{
    int ins;
    // 常量引用的拷贝构造函数
    Cls(const Cls& other): ins(other.ins) { cout << "const_copy_constructed!\n"; };
    Cls() {}
    // 转换构造函数
    Cls(int val): ins(val) { cout << "convert_constructed!\n"; };
private:
    // 移动构造函数
    Cls(Cls&& other): ins(other.ins) { cout << "move_constructed!\n"; };
};

const Cls obj;
// 正确:最佳匹配为Cls(const Cls& other),输出\
const_copy_constructed!
Cls obj2 = obj;
// 错误:最佳匹配为Cls(Cls&& other),但是无法访问,所以编译出错
Cls obj3 = Cls();
// 错误:先调用转换构造函数,然后生成一个临时对象,\
因为对应的移动构造函数Cls(Cls&& other)无法访问,所以编译出错
Cls obj4 = 8;

7.92 同类对象的赋值

接下来,我们需要学习同类对象的赋值方法。

  • 同类对象的赋值
    • 拷贝赋值运算符(copy-assignment operator)
    • 移动赋值运算符(move-assignment operator)

拷贝、移动赋值运算符和拷贝、移动构造函数的作用类似,只不过都是用于同类对象的赋值过程中的:

  • 拷贝赋值运算符: 主要作用于左值(右值也可以),将初始值数据复制到左侧的类对象中,使它们之间的数据一样。
  • 移动赋值运算符: 只作用于右值,将初始值数据移动到左侧的类对象中,使它们之间的数据一样。

这两个函数都是重载运算符中的赋值运算符,所以使用方法和正常的重载运算符一样。

为了与内置类型的赋值保持一致,我们定义自己的赋值运算符时,通常返回一个指向其左侧运算对象的引用。

7.921 拷贝赋值运算符

拷贝赋值运算符是一个右侧运算对象类型为自身类类型的左值引用(如果还需要复制右值数据,则要用左值常量引用)的赋值运算符成员函数。

声明形式为:

  1. 返回类型 operator= ((可含类型修饰符)类名&) (可选 限定符);

  2. auto operator= ((可含类型修饰符)类名&) -> 返回类型 (可选 限定符);

和拷贝构造函数一样,右侧运算对象类型几乎总是一个const的引用。

右侧运算对象类型如果不是引用,则可能在某些情况不会被判断成拷贝赋值运算符。

struct Cls
{
    // 拷贝赋值运算符
    Cls& operator=(const Cls &other) { cout << "copy_assignment!"; };
};
Cls obj, obj2;
// 将obj2的数据复制到obj
obj = obj2;

7.922 移动赋值运算符

移动赋值运算符是一个右侧运算对象类型为自身类类型的右值引用的赋值运算符成员函数。

声明形式为:

  1. 返回类型 operator= ((可含类型修饰符)类名&&) (可选 限定符);

  2. auto operator= ((可含类型修饰符)类名&&) -> 返回类型 (可选 限定符);

和移动构造函数一样,右侧运算对象类型几乎总是一个非const的引用。

与移动构造函数一样,如果我们的移动赋值运算符不抛出任何异常,我们就应该将它标记为noexcept

struct Cls
{
    // 拷贝赋值运算符
    Cls& operator=(const Cls &other) { cout << "copy_assignment!"; };
    // 移动赋值运算符
    Cls& operator=(Cls&& other) { cout << "move_assignment!"; };
};
Cls obj, obj2;
// 调用拷贝赋值运算符
obj = obj2;
// 调用移动赋值运算符
obj = Cls();

7.923 派生类的拷贝/移动赋值运算符

拷贝/移动赋值运算符的执行顺序和普通构造函数的顺序一模一样。 对于在继承关系的类的拷贝控制成员,它们会调用每个基类自己对应的拷贝控制成员来执行对应基类部分的对应操作。

在默认情况下,使用拷贝/移动赋值运算符来赋值或者移动类对象时,派生类是用其对应基类的默认构造函数来赋值或者移动派生类对象的该基类部分。

所以,与拷贝和移动构造函数一样,派生类的赋值运算符也必须显式地为其基类部分赋值

7.93 类对象的销毁

接下来,我们需要学习类对象的销毁方法。

  • 类对象的销毁
    • 析构函数(destructor)

7.931 析构函数

析构函数和构造函数可以说是一对兄弟,构造函数负责类对象的构建,而析构函数则负责类对象的销毁。

析构函数执行与构造函数相反的操作:释放类对象使用的资源,并销毁该对象的非静态数据成员。

析构函数是类的一个成员函数,名字由波浪号接类名构成。它没有返回值,且形参表必须为空,有一个函数体。 析构函数可以定义在类内或者类外。 析构函数的函数体中不能有除了空return语句外的其他类型的返回语句。

析构函数不能有存储说明符,且也不能有类型限定符,如const等。

声明形式为:

~类名();

struct Cls
{
    // 析构函数
    ~Cls();
};
Cls::~Cls() {};
7.9311 析构函数的执行

如同构造函数的执行过程中有一个初始化执行部分和函数体执行部分一样,析构函数也有一个函数体执行部分和一个析构执行部分。

和构造函数不一样的是,在一个析构函数中,不存在类似初始化列表的东西来控制每个成员的销毁方式,成员的销毁是隐式的,析构函数会自动调用对应成员自己的析构函数来执行销毁的。 内置类型没有析构函数,因此对于系统内置类型的成员来说,销毁该成员什么也不需要做。

所以析构函数对于一个指向动态内存的普通指针成员,是不会自动销毁该指针成员所指向的对象的。

析构函数的执行顺序为:

  1. 先执行函数体。
  2. 再按初始化顺序的逆序,逐个调用成员对应的析构函数来销毁该成员(对于内置类型就什么都不做)。
7.9312 析构函数的使用

析构函数的使用基本都是自动进行的,无论何时一个对象被销毁,就会自动调用其析构函数,如果该析构函数无法访问或者已删除,则会编译出错。

对于对象被销毁的情况,基本就是以下几种:

  • 变量在离开其作用域时被销毁。
  • 当一个对象被销毁时,其成员也被销毁。
  • 一个容器(无论是标准库容器还是数组)被销毁时,其元素也被销毁。
  • 对于动态分配的对象,当对指向它的指针应用delete运算符时被销毁。
  • 对于临时对象来说,除非该对象已被接管,否则当创建它的完整表达式结朿时它就会被销毁。

临时对象被接管的情况有以下几种:

  • 用于初始化同类型对象(不管是直接初始化还是拷贝初始化)。
  • 被常量左值引用或者右值引用绑定时。

我们也可以自己想普通函数调用一样调用类对象的析构函数来执行对象的销毁,但是该类的析构函数不能是合成的,且必须要可访问并为未删除的。

7.9313 虚析构函数

对于有继承关系的类,基类通常应该定义一个虚析构函数,这样就能动态分配继承体系中的对象了。

当我们delete一个动态分配的对象的指针时将执行析构函数。如果该指针指向继承体系中的某个类型,则有可能出现指针的静态类型与被删除对象的动态类型不符的情况。 所以这样的情况下,编译器就必须清楚它应该执行的是哪一个类的析构函数。和其他函数一样,我们通过在基类中将析构函数定义成虚函数以确保执行正确的析构函数版本。

和其他虚函数一样,析构函数的虚属性也会被继承。因此,无论派生类使用合成的析构函数还是定义自己的析构函数,都将是虚析构函数。只要基类的析构函数是虚函数,就能确保当我们delete基类指针时将运行正确的析构函数版本。

struct Ba
{
    string str = "str";
    virtual ~Ba() { cout << "Ba destructed!\n"; };
};
struct De: Ba
{
    int *p = new int{6};
    ~De() { delete p; cout << "De destructed!\n"; }
};

Ba* ptr = new De();
// 正确销毁了De类的对象,输出\
De destructed!\
Ba destructed!
delete ptr;

如果基类的析构函数不是虚函数,则delete一个指向派生类对象的基类指针将产生未定义的行为。

如果构造函数或析构函数调用了某个虚函数,则我们应该执行与构造函数或析构函数所属类型相对应的虚函数版本。

7.94 拷贝控制成员的执行顺序

拷贝/移动构造函数、拷贝/移动赋值运算符的执行顺序和普通构造函数的顺序一模一样;而析构函数的顺序与普通构造函数的顺序正好相反。

对于在继承关系的类的拷贝控制成员,它们会调用每个基类自己对应的拷贝控制成员来执行对应基类部分的对应操作。

以下为非析构函数的拷贝控制成员的执行顺序(析构函数的顺序与其全部相反,包括按其定义或派生顺序都相反):

  1. 先按其派生列表顺序逐个调用其虚基类的拷贝\移动构造函数或者拷贝\移动赋值运算符进行该虚基类的成员操作(同名虚基类就进行一次);每个虚基类也是如此做。
  2. 然后按其派生列表顺序逐个调用其非虚基类的直接基类的拷贝\移动构造函数或者拷贝\移动赋值运算符进行成员操作;每个类(包括虚基类)也是如此做。
  3. 最后按其非static成员的定义顺序逐个进行拷贝\移动;每个类(包括虚基类)也是如此做。

以下是各个拷贝控制成员的作用介绍:

  • 拷贝\移动构造函数: 拷贝\移动构造函数会将其初始值的非static成员逐个拷贝或移动到正在创建的对象中。
  • 拷贝\移动赋值运算符 拷贝\移动赋值运算符会将其会将右侧运算对象的每个非static成员赋值或移动左侧运算对象的对应成员。
  • 析构函数: 成员按初始化顺序的逆序销毁。

7.95 拷贝控制成员的合成版本

和默认构造函数一样,所有的拷贝控制成员都有编译器自动合成的版本,也分为隐式合成和显式合成。

所有拷贝控制成员的合成版本都有如下特性:

  1. 所有非析构函数的拷贝控制成员的合成版本会自动把赋值对象的每个非静态数据成员初始化(/赋值)到被赋值对象对应的成员中。
  2. 析构函数不管是不是合成版本,其都会在该对象销毁时自动调用非静态数据成员自己的析构函数。
  3. 析构函数的拷贝控制成员的函数体为空;非析构函数的拷贝控制成员的函数体只存在非静态数据成员的赋值语句。
  4. 如果其类型为字面值类型,则合成版本的构造函数成员都为constexpr函数,其他合成的拷贝控制成员则不为constexpr函数。

以下为各个拷贝控制成员的函数声明,假设类名为Cls,且含有某些非静态数据成员,则其合成的拷贝成员的定义一般如下所示:

#include <string>
// 假设Cls的成员结构如下
class Cls
{
    static constexpr int kIns = 48;
    int ins_;
    double dou_;
    std::string str_;

    /**
     *  以下为编译器合成的拷贝成员版本的可能定义形式
     *  // 合成的拷贝构造函数
     *  Cls(const Cls& obj): ins_(obj.ins_), dou_(obj.dou_), str_(obj.str_) {}
     *  // 合成的移动构造函数
     *  Cls(Cls&& obj): ins_(obj.ins_), dou_(obj.dou_), str_(obj.str_) {}
     *  // 合成的拷贝赋值运算符
     *  Cls& operator=(const Cls& obj)
        {
            ins_ = obj.ins_;
            dou_ = obj.dou_;
            str_ = obj.str_;
        }
     *  // 合成的移动赋值运算符
     *  Cls& operator=(Cls&& obj)
        {
            ins_ = obj.ins_;
            dou_ = obj.dou_;
            str_ = obj.str_;
        }
     *  // 合成的析构函数
     *  ~Cls() {}
     * 
     */
};

以下为合成拷贝控制成员的特性例子:

// 自定义的delete运算符函数
void operator delete(void * ptr) noexcept
{
    std::cout << "内存已释放\n";
}

// 含有动态内存分配的非静态数据成员
struct Cls
{
    double *pDou_ = new double{-12.55};
    ~Cls() { delete pDou_; }
};

class Base
{
    int ins_;
    double dou_;
    // 含有动态内存分配的非静态数据成员
    Cls obj;
public:
    // 输出非静态成员
    void prints() const
    {
        std::cout << ins_ << std::endl;
        std::cout << dou_ << std::endl << std::endl;
    }
    // 默认构造函数
    Base(): ins_(5), dou_(-8.2) {}
    // 构造函数
    Base(int ins, double dou): ins_(ins), dou_(dou) { std::cout << "调用普通构造函数\n"; }
    // 合成的拷贝构造函数
    Base(const Base&) = default;
    // 移动构造函数
    Base(Base&&) { std::cout << "调用移动构造函数\n"; }
    // 拷贝赋值运算符函数
    Base& operator=(const Base&)
    {
        std::cout << "调用拷贝赋值运算符函数\n";
        return *this;
    }
    // 移动赋值运算符函数
    Base& operator=(Base&&) = default;
    // 析构函数
    ~Base() { std::cout << "调用析构函数\n"; }
};

int main()
{
    // 由Base(int, double)构造
    Base obj(48,15.66);
    obj.prints();
    // 由合成的Base(const Base&)构造,非静态数据成员值与obj一样。
    Base obj2{obj};
    obj2.prints();
    // 由Base(Base&&)构造,非静态数据成员值未初始化。
    Base obj3{std::move(obj2)};
    obj3.prints();
    // 由Base(int, double)构造
    Base obj4(230,0.55);
    // 由Base& operator=(const Base&)构造,非静态数据成员值未初始化。
    obj = obj2;
    obj.prints();
    // 由合成的Base& operator=(Base&&)构造,非静态数据成员值与obj4一样。
    obj2 = std::move(obj4);
    obj2.prints();
    // 不管是不是合成版本的析构函数,结束时该析构函数会自动调用非静态数据成员自己的析构函数。
    return 0;
}
// 字面值类型Base,可以在使用常量表达式的地方调用。
class Base
{
    int ins_;
    double dou_;
public:
    // 输出非静态成员
    void prints() const
    {
        std::cout << ins_ << std::endl;
        std::cout << dou_ << std::endl << std::endl;
    }
    // 默认构造函数
    constexpr Base(): ins_(5), dou_(-8.2) {}
    // 构造函数
    constexpr Base(int ins, double dou): ins_(ins), dou_(dou) {}
};

int main()
{
    // 由constexpr Base(int, double)构造,可以在使用常量表达式的地方调用。
    constexpr Base obj(48,15.66);
    obj.prints();
    // 由合成的constexpr Base(const Base&)构造,可以在使用常量表达式的地方调用。
    constexpr Base obj2{obj};
    obj2.prints();
    // 由合成的constexpr Base(Base&&)构造,可以在使用常量表达式的地方调用。
    constexpr Base obj3{std::move(obj2)};
    obj3.prints();
    // obj4不为常量表达式
    Base obj4(230,0.55);
    // 由合成的Base& operator=(const Base&)构造,不可以在使用常量表达式的地方调用。
    obj4 = obj;
    obj4.prints();
    // obj5为常量表达式
    constexpr Base obj5(10,0.75);
    // 错误:合成的Base& operator=(const Base&)不能在使用常量表达式的地方调用。
    obj5 = obj;
    return 0;
}

7.951 隐式合成版本

对于除了移动构造函数和移动赋值运算符来说,如果某个类没有自己定义对应的拷贝控制成员,则编译器会为其定义一个。

而对于移动构造函数和移动赋值运算符来说,只有当一个类没有定义任何自己版本的拷贝控制成员,且类的每个非static数据成员都可以移动(对于类类型的成员,该类要有可用的移动构造函数;对于内置类型,则都能移动)时,编译器才会为它合成移动构造函数或移动赋值运算符。

7.952 显式合成版本

和普通构造函数一样,我们可以通过将拷贝控制成员定义为=default来显式地要求编译器生成合成的版本,default的关键字用法和普通构造函数的一样,可以在类内类外使用,类内为内联。

对于可以声明为default的拷贝控制成员的形式有具体的规定,否则声明出错:

  • 对于拷贝和移动构造函数来说,它们必须为: 形参表只能有一个形参,且该形参为自身类类型的对应引用,且不能有默认实参。
  • 对于拷贝和移动赋值运算符来说,它们必须为: 返回类型为自身类类型的非常量引用、右侧运算对象类型为自身类类型的对应引用,且不能有如const,&等的类型限定符,virtual等于虚函数有关的不算在内。
  • 对于析构函数来说,和定义普通的析构函数一样。
struct Cls
{
  // 显式合成的拷贝构造函数
  Cls(const Cls&) = default;
  // 显式合成的移动构造函数
  Cls(Cls&&);
  // 显式合成的拷贝赋值运算符
  Cls& operator=(const Cls&) = default;
  // 显式合成的移动赋值运算符
  Cls& operator=(Cls&&);
  // 显式合成的析构函数
  ~Cls();
};
Cls::Cls(Cls&&) = default;
Cls& Cls::operator=(Cls&&) = default;
Cls::~Cls() = default;

7.943 删除的合成版本

虽然编译器大部分会自动合成各种拷贝控制成员,但是对于有以下这些情况中的任意一个的类来说,编译器会将对应的合成成员定义为已删除的函数(隐式删除):

  • 拷贝构造函数:
    • 类的某个成员(包括继承的)的拷贝构造函数是删除的或不可访问的。
    • 类以及类的某个成员(包括继承的)的析构函数是删除的或不可访问的。
    • 类自己定义了移动构造函数或者移动赋值运算符。
  • 拷贝赋值运算符
    • 类的某个成员(包括继承的)的拷贝赋值运算符是删除的或不可访问的。
    • 类有一个const的或引用成员(包括继承的)。
    • 类自己定义了移动构造函数或者移动赋值运算符。
  • 移动构造函数
    • 有类成员(包括继承的)的移动构造函数被定义为删除的或是不可访问的。
    • 有类成员(包括继承的)定义了自己的拷贝构造函数且未定义移动构造函数。
    • 有类成员(包括继承的)未定义自己的拷贝构造函数且编译器不能为其合成移动构造函数。
    • 类以及类成员(包括继承的)的析构函数被定义为删除的或不可访问的。
    • =default显式要求该类生成移动操作,且编译器不能移动所有成员。
  • 移动赋值运算符
    • 有类成员(包括继承的)的移动赋值运算符被定义为删除的或是不可访问的。
    • 有类成员(包括继承的)定义了自己的拷贝赋值运算符且未定义移动赋值运算符。
    • 有类成员(包括继承的)未定义自己的拷贝赋值运算符且编译器不能为其合成移动赋值运算符。
    • 有类成员(包括继承的)是const的或是引用。
    • =default显式要求该类生成移动操作,且编译器不能移动所有成员。
  • 析构函数:
    • 类的某个成员(包括继承的)的析构函数是删除的或不可访问的(例如是private的)
  • 默认构造函数
    • 类某个成员(包括继承的)的默认构造函数是删除的或不可访问的。
    • 类以及类某个成员(包括继承的)的析构函数是删除的或不可访问的。
    • 类有一个引用成员,它没有类内初始化器。
    • 类有一个const成员,它没有类内初始化器且其类型未显式定义默认构造函数。

这些情况中的定义了某某函数的情况包括显式要求生成合成版本的情况。

本质上,这些规则的含义是:

  • 如果一个类有数据成员不能被默认构造、拷贝、赋值、移动或销毁,则对应的成员函数将被定义为已删除。
  • 如果一个类已经有移动成员函数,则该类的拷贝和赋值成员函数将被定义为已删除。
  • 如果一个类已经有拷贝和赋值成员函数,则对应的移动成员函数将不会存在。

以上删除的合成版本与第5章提到的已删除函数在函数重载方面有一些区别,删除的合成版本为隐式删除,因此不会参与到函数重载中,而显式用=delete指定的函数会参与到函数重载中。

struct Cls
{
  // 显式合成的拷贝构造函数
  Cls() = default;
  // 显式合成的拷贝构造函数
  Cls(const Cls&) = default;
  // 显式指定移动构造函数为delete
  Cls(Cls&&) = delete;
};

// 包含显式合成的拷贝构造函数
// 和移动构造函数的删除合成版本
class Derive: public Cls {};

Cls getCls()
{
    Cls obj;
    /*
        编译错误:
            Cls的移动构造函数是被显式定义为删除,会参与到函数重载中。
            编译器在函数匹配时会将Cls类的
            拷贝和移动构造函数都加入到可行函数集中,
            其中移动构造函数为最佳匹配,因此选择调用
            该函数。
            但移动构造函数已被指定为删除,所以调用出错。
    */
    return obj;
}

Derive getDerive()
{
    Derive obj;
    /*
        编译正确:
            Derive的移动构造函数的合成版本是被隐式定义为删除,不会参与到函数重载中。
            编译器在函数匹配时只会将Derive类的
            拷贝构造函数都加入到可行函数集中,因此选择调用
            拷贝构造函数,
            拷贝构造函数为正常的合成版本,因此调用正常。
    */
    return obj;
}

对于析构函数为已删除的类来说,有非常大的影响,因为析构函数被删除,就无法销毁此类型的对象: 所以对于一个删除了析构函数的类型,编译器将不允许定义该类型的变量或创建该类的临时对象;而且如果一个类的某成员的类型删除了析构函数,则我们也不能定义该类的变量或临时对象。 但我们可以动态分配这种类型的对象,不过就不能释放掉这些对象了。

7.96 三/五法则

C++语言并不要求我们定义所有这些操作:可以只定义其中一个或两个,而不必定义所有。 但是,这些操作通常应该被看作一个整体。通常,只需要其中一个操作,而不需要定义所有操作的情况是很少见的。

对于大多数类型的定义中,有三个基本操作就可以控制该类的拷贝操作:拷贝构造函数、拷贝赋值运算符和析构函数。

当我们决定一个类是否要定义它自己版本的拷贝控制成员时,我们应该用以下这几个原则来思考:

  1. 首先确定这个类是否需要一个析构函数。通常,对析构函数的需求要比对拷贝构造函数或赋值运算符的需求更为明显。 如果这个类需要一个析构函数,我们几乎可以肯定它也需要一个拷贝构造函数和一个拷贝赋值运算符。
  2. 如果一个类需要一个拷贝构造函数,几乎可以肯定它也需要一个拷贝赋值运算符。反之亦然,如果一个类需要一个拷贝赋值运算符,几乎可以肯定它也需要一个拷贝构造函数。 然而,无论是需要拷贝构造函数还是需要拷贝赋值运算符都不必然意味着也需要析构函数。
  3. 当某些类定义了自己的拷贝构造函数和赋值运算符时,如果它们的数据可以被移动,就可以考虑这些情况:类包含的数据太多,而且拷贝赋值需要的额外开销大。 在这种拷贝并非必要的情况下,定义移动构造函数和移动赋值运算符就可以避免此问题。
  4. 当我们定义一个基类时,最好定义自己的析构函数,而且要将该析构函数设定为虚函数。 因为这样就能动态分配继承体系中的对象,只要基类的析构函数是虚函数,就能确保当我们delete基类指针时将运行正确的析构函数版本。 不过此时该基类不一定需要需要赋值运算符或拷贝构造函数,要根据其他方面再来判断。

7.10 特殊的类类型

之前我们所看到的都是普通的类,但是C++中也有一些特殊的类,当一个类类型满足特殊类的条件时,它就为该特殊的类。

以下是几种特殊的类类型:

  • 聚合类
  • 字面值常量类
  • 局部类
  • 抽象基类

7.101 聚合类

聚合类(aggregate class)使得用户可以直接访问其成员,并且具有特殊的初始化语法 形式。

当一个类满足如下条件时,我们说它是聚合类:

  • 所有成员都是public的。
  • 没有定义任何构造函数。
  • 所有非静态数据成员都没有类内初始值。
  • 没有任何基类,也没有任何virtual函数。
// Cls类是一个聚合类
struct Cls
{
    static const int sta_ins = 35;
    int ins;
    static string sta_str;
    string str;
    static void prints() {}
    int ret() { return 35; }
};
string Cls::sta_str = "sta_str";

和普通类不一样的是,聚合类可以用一个花括号括起来的成员初始值列表,并用它来初始化聚合类的非静态数据成员。

直接初始化和拷贝初始化都行。

Cls obj{6,"str"};
// 输出6 str
cout << obj.ins << " " << obj.str;
Cls obj2 = {17,"str2"};
// 输出17 str2
cout << obj2.ins << " " << obj2.str;

初始化列表中的初始值类型必须要与其聚合类中的非静态数据成员一一对应,否则可能会出错。

// 错误:int变量不能用字符串初始化,\
string变量也不能用int初始化
Cls obj{"str", 6};

与初始化数组元素的规则一样,如果初始值列表中的元素个数少于类的非静态数据成员数量,则靠后的非静态数据成员被值初始化。 且初始值列表的元素个数绝对不能超过类的非静态数据成员数量。

7.102 字面值常量类

之前我们提到过:声明为constexpr的变量或者constexpr函数的参数和返回值都必须是字面值类型。

之前所说的算术类型、引用、指针和枚举类型都属于字面值类型,除此之外,类类型中的字面值常量类也属于字面值类型。

字面值常量类有两种:

  1. 所有非静态数据成员都是字面值类型的聚合类。
  2. 符合以下要求的普通类:
    • 所有非静态数据成员都必须是字面值类型。
    • 类必须至少含有一个constexpr构造函数。
    • 类必须使用合成的析构函数。
    • 如果一个非静态数据成员含有类内初始值,则:
      • 内置类型成员的初始值必须是一条常量表达式。
      • 类类型成员的初始值必须使用该成员类型自己的constexpr构造函数。
// Cls为所有非静态数据成员都是字面值类型的聚合类\
所以也就是字面值常量类。
struct Cls
{
    static const int sta_ins = 35;
    int ins;
    static string sta_str;
    static void prints() {}
    int ret() { return 35; }
};
string Cls::sta_str = "sta_str";
// 返回类型为Cls的constexpr函数
constexpr Cls ret() { return Cls{3}; }
// 类型为Cls的constexpr变量
constexpr Cls obj = ret();

7.1021 constexpr构造函数

构造函数可以声明成constexpr的函数,声明方式为在构造函数的声明或定义语句之前加上关键字constexpr

声明形式为:

constexpr 构造函数的声明语句

只有声明为constexpr的构造函数才能用于对应字面值类型的常量表达式构造,字面值类型中的普通构造函数不能用于常量表达式的构造。

#include <iostream>

// 字面值常量类
struct Base
{
    // constexpr构造函数,可用于常量表达式的构造
    constexpr Base(const int& val) {}
    // 非constexpr构造函数,不能用于常量表达式的构造
    Base(const double& val) { std::cout << "no constexpr\n"; }
};

int main()
{
    // 正确:constexpr构造函数可用于非常量的构造
    Base ba1{48};
    // 正确:constexpr构造函数可用于常量的构造
    const Base cba1{48};
    // 正确:constexpr构造函数可用于常量表达式的构造
    constexpr Base ceba1{48};
    // 正确:非constexpr构造函数可用于非常量的构造
    Base ba2{48.48};
    // 正确:非constexpr构造函数可用于常量的构造
    const Base cba2{48.48};
    // 错误:非constexpr构造函数不能用于常量的构造
    constexpr Base ceba2{48.48};
    return 0;
}

constexpr构造函数必须要在其初始值列表中显式初始化所有没有类内初始值的非静态数据成员(如果该类有继承关系时,要显式调用其所有虚基类以及直接基类的构造函数来初始化其继承的没有类内初始值的非静态数据成员)。

不管是constexpr构造函数的初始值列表中的初始值还是类内初始值,字面值常量类的每个非静态数据成员(包括继承的成员)的初始值只有两种选择:

  • 要不就使用该成员自己类的constexpr构造函数。
  • 要不就是一条常量表达式。

constexpr构造函数体必须为空。

和普通构造函数一样,constexpr构造函数可以用default关键字显式要求生成一个合成的constexpr构造函数。

// Cls为字面值常量类。
struct Cls
{
    static const int sta_ins = 35;
    int ins = 8;
    static string sta_str;
    double dou;
    static void prints() {}
    int ret() { return 35; }
    constexpr Cls(): dou(3.5) {}
};
string Cls::sta_str = "sta_str";
// 返回类型为Cls的constexpr函数
constexpr Cls ret() { return Cls(); }
// 类型为Cls的constexpr变量
constexpr Cls obj = ret();

7.103 局部类

类可以定义在某个函数的内部,我们称这样的类为局部类(local class)。

局部类定义的类型只在定义它的局部作用域内可见,也就是外层函数的作用域包含着局部类的作用域。

和嵌套类不同,局部类的成员受到严格限制。 局部类的限制有以下几种:

  • 局部类的所有成员(包括函数在内)都必须完整定义在类内,不能在类外定义。
  • 在局部类中不允许声明静态数据成员。
  • 局部类不能使用外层函数的非静态局部变量。

外层函数对局部类的访问特权和在局部类作用域外的其他对象一样,无特殊的访问特权,所以外层函数无法访问其局部类的私有和受保护成员(不过局部类可以将外层函数声明为友元)。

局部类内部的名字查找次序与其他类相似,遵循类的作用域规则。

可以在局部类的内部再嵌套一个类: 此时,嵌套类的定义可以出现在局部类之外。不过嵌套类必须定义在与局部类相同的作用域中(也就是外层函数的作用域内)。 局部类内的嵌套类也是一个局部类,必须遵循局部类的各种规定。 所以嵌套类的所有成员都必须定义在嵌套类内,不能在嵌套类外定义;不允许声明静态数据成员;不能使用外层函数的非静态局部变量。

void contains()
{
    // 局部类
    struct Local_cls
    {
        int ins;
        static void prints() { cout << "local\n"; }
        // 嵌套的局部类声明
        struct Nest;
    };
    // 嵌套的局部类定义
    struct Local_cls::Nest
    {
        int Nins;
        string str;
    };
}

7.104 抽象基类

抽象基类是在类继承关系中使用的一种特殊类,抽象基类一般是用于只负责定义接口形式的类。

在说明抽象基类之前,我们需要了解一下纯虚函数的概念。

7.1041 纯虚函数

纯虚函数是一种虚函数,只要一个虚函数在其声明语句之后书写一个赋值符=再加一个0就可以变为纯虚函数。所以=0只能用于虚函数。

声明形式为:

虚函数的声明 = 0;

其中,=0只能出现在虚函数的类内声明语句中。

所有的纯虚函数都无须定义,但我们也可以为其提供定义,不过定义语句必须在类的外部。也就是说,我们不能在类内为一个=0的函数提供函数体。

struct Cls
{
    // 纯虚函数的声明
    virtual void prints() = 0;
};
// 纯虚函数的定义
void Cls::prints() { cout << "pure func\n"; }

纯虚函数和虚函数一样,继承的纯虚函数在派生类中也是纯虚函数,不过当派生类用非纯虚函数形式的函数覆盖了某纯虚函数时,该纯虚函数在该派生类中就不再是纯虚函数了(也就变成了普通的虚函数了)。

7.1042 抽象基类的性质

所有含有纯虚函数成员(包括继承的成员)的类都是抽象基类(abstract base class)。

我们不能用任何方法来创建一个抽象基类的对象,不过可以创建指向抽象基类的指针或者引用。

struct Cls
{
    virtual void prints() = 0;
};
void Cls::prints() { cout << "pure func\n"; }

// 抽象基类Cls的派生类Der,该类覆盖了纯虚函数。
struct Der: Cls
{ void prints() override { cout << "overrided\n"; } };

// 错误:不能创建抽象基类Cls的对象
Cls obj;
Cls* p = new Cls();
// 正确:可以创建抽象基类Cls的指针,引用。
Der dobj;
Cls* p2 = new Der();
Cls& r = dobj;
// 输出overrided
p2->prints();
r.prints();