欢迎光临
我们一直在努力

【C++修炼】面向对象—继承

继承

1. 继承的概念与定义

1.1 概念

        面向对象最经典的三大特性:封装、继承、多态。

        回顾封装:

  • 类的设计就是一种简单的封装。方法与属性一起封装在类里面,想给类外直接访问的设为 public(公有),不想给类外直接访问的设为 private(私有)或 protected(保护)。
  • 迭代器的设计也是一种经典的封装。例如 string/vector/list/deque,上层使用上,都可借相同方式(iterator)访问容器中的元素,底层实现却天差地别。iterator 可以是原生指针(顺序表等连续结构可用char*/int*等,虽然标准库实现string/vector并非原生指针,仍然另外封装了一个类),可以是封装了链表结点的类类型指针(链表结点Node*),也可以是封装了4个指针的类类型指针(例如deque的cur、first、last、node)。迭代器底层的复杂细节与结构差异被屏蔽,用户可以只关心使用。

        什么是继承继承(inheritance)是⾯向对象编程的重要概念之一,是代码复⽤最重要的⼿段。它允许在保持原有类(基类/父类)特性的基础上进⾏扩展(增加⽅法/成员函数,或属性/成员变量),产⽣新的类(派生类/子类)。继承呈现了⾯向对象程序设计的层次结构,体现了由简单到复杂的认知过程。前面我们接触的更多是函数层次的复⽤,继承是类的设计层次的复⽤。

代码示例 1.1.1

class Student
{
public:
void identity()
{
// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证
}
void study()
{
// 学习
}
protected:
string _name = "peter"; // 姓名
string _address; // 地址
string _tel; // 电话
int _age = 18; // 年龄

int _student_id; // 学号
};

class Teacher
{
public:
void identity()
{
// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证
}
void teaching()
{
// 授课
}
protected:
string _name = "peter"; // 姓名
string _address; // 地址
string _tel; // 电话
int _age = 18; // 年龄

string _title; // 职称
};

        例如示例中定义了学生 Student 和教师 Teacher,这两个的设计是非常冗余的。共同的成员变量(姓名_name、地址_address、电话_tel、年龄_age等),共同的方法(身份认证 identity)。当然,它们也有各自独有的成员,学生独有的成员是成员函数学习 study 和成员变量学号 _student_id,老师独有的成员是成员函数授课 teaching 和成员变量职称 _title

        从继承的角度来看,学校的主要人员无非是学生和老师,Student 和 Teacher 的共同成员即校内人员的基础信息。因此,我们其实可以提取共同部分以复用。Person 类作为基类或者说父类。Student 和 Teacher 作为 Person 类扩展延申出来的派生类或者说子类。

代码示例 1.1.2

#include <iostream>
#include <string>
using namespace std;

// 父类 Person
class Person
{
public:
void identity()
{
// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证
cout << "void identity()" << _name << endl;
}
protected:
string _name = "peter"; // 姓名
string _address; // 地址
string _tel; // 电话
int _age = 18; // 年龄
};

// 父类有的成员子类都有,子类有的父类没有,其他子类有的当前子类也没有
// 子类 Student
class Student : public Person
{
public:
void study()
{
// 学习
}
protected:
int _student_id; // 学号
};
// 子类 Teacher
class Teacher : public Person
{
public:
void teaching()
{
// 授课
}
protected:
string _title; // 职称
};

int main()
{
Student s;
Teacher t;
s.identity();
t.identity();
return 0;
}

运行结果:

        公共的成员都被放到了 Person 类中,Student teacher 内部虽然没有定义这些成员却可以调用。因为 Student teacher 继承自 Person,子类可以复⽤父类的成员,示例1.1.1和1.1.2两种写法其实是完全一致的,但示例1.1.2使用继承避免了冗余设计,子类只需要关心如何在父类的基础之上扩展自己独有的成员变量或方法。

1.2 定义格式

        继承的语法格式如图,类名之后跟冒号 :,接继承方式与父类的类名,表示当前类 Student 继承自父类 Person

        继承方式分为3种:公有 public、保护 protected 和私有 private

        父类成员可以被3种访问限定符修饰,子类继承父类也有3种继承方式。子类可以复用父类的成员,那么子类中复用的这些父类成员的访问限定是什么?例如父类中的 public 成员,被子类继承后,在子类中仍然是 public,还是会被继承方式影响?

        上述问题其实对应有9种情况(3种访问限定 * 3种继承方式)。

  • 1、对表格进行总结,我们会发现,父类的私有 private 成员在子类全都不可⻅。对于父类的其他成员,子类中的访问权限==Min(访问限定符,继承⽅式)。权限级别:公有(public)> 保护(protected)> 私有(private)。

        因此,父类的公有成员被继承后,其权限可能是公有、保护或私有。父类的保护成员被继承后,其权限只能是保护或私有。具体取决于继承方式是否进一步“缩小”了访问权限。

  • 2、父类的私有 private 成员在子类全都不可⻅。这里的“不可⻅”是语法限制,子类对象不管类内还是类外,都不能直接访问父类的私有成员。父类的私有成员实际仍被继承到了子类(例如子类对象的大小依然包含这些私有成员的内存空间)。

        这里极易产生误解,需要澄清:父类的私有成员被私有继承后,子类依然继承的这些“不可见”成员不是公有,不是保护,也不是私有。

        我们需要回归类的封装本质。在普通类中,自己定义的私有成员允许自己内部访问,但拒绝类外访问。然而,父类和子类本质是不同的类,继承机制打破了这种常规的封装隔阂,在公有、保护、私有之外,衍生出了这种特殊的“不可见”状态。这些“不可见”的成员属于子类,但绝不是公有、保护或私有。因为只要是自己的成员,即使是私有,子类都理应拥有类内部的访问权,但不管类内还是类外都无法访问“不可见”成员。因此,称呼这些“不可见”成员,无论公有成员、保护成员还是私有成员都不妥当,不如“不可见成员”更显严谨与贴切。

  • 3、对于父类的私有 private 成员,子类对象不管是类外还是类内都无法直接访问。对于父类的保护 protected 成员,子类对象可以在类内部直接访问,仍然不能在类外访问。

        之前介绍访问限定符时,我们说过 protected 成员和 private 成员区别不大。到了继承,protected 成员的区别就显现出来了。父类的成员,如果不想被类外访问,只能定义为保护 protected 或私有 private,但定义为私有 private 在子类中就不可见,这时就可以定义为保护 protected。可见,保护限定符 protected 正是因为继承而出现的。

  • 4、和访问限定符一样,继承方式也可以不显式写出。关键字 class 定义的子类,默认私有继承 private;关键字 struct 定义的子类,默认公有继承 public。仍然推荐显式写出继承⽅式。

        现在,除了成员默认的访问权限不同(class成员默认为私有,struct成员默认为公有),class 和 struct 定义的类的区别又多了一个。

代码示例 1.2.1

// 子类 Student
// 可不显式写出继承方式,class 默认私有继承
class Student : Person
{
public:
void study() { // … }
protected:
int _student_id;
};

// 子类 Teacher
// struct 默认公有继承
struct Teacher : Person
{
public:
void teaching() { // … }
protected:
string _title;
};

        实践中常用 public 继承,⼏乎不使⽤ protected/private 继承,也不提倡使⽤,因为 protected/private 继承的成员只能在子类内部使⽤,甚至无法使用,工程实践中扩展维护性不强。

        在后续实践中,继承方式推荐公有 public 继承,子类继承后不会改变成员原有的访问权限。父类中不想被类外访问的成员,推荐用 protected 成员代替 private 成员,避免子类继承后“不可见”。

1.3 继承类模板

        继承是代码复用的重要手段,前面我们接触的更多是函数层次的复⽤,继承是类的设计层次的复⽤。其实,类模板和继承一样,同样是类在设计层次的重要复用手段,这两种手段是同等重要的。

        那么,继承和类模板在实践中有什么区别呢?下面示例是之前模拟实现的标准库中的栈。

代码示例 1.3.1

namespace david
{
template<class T, class Container = std::deque<T>>
class stack
{
public:
stack() {};

// 压栈
void push(const T& x) { _con.push_back(x); }

// 出栈
void pop() { _con.pop_back(); }

// 获取栈顶元素
T& top() { return _con.back(); }

const T& top() const { return _con.back(); }

// 返回栈中元素个数
size_t size() const { return _con.size(); }

// 判空
bool empty() const { return _con.empty(); }

private:
Container _con; // 底层容器
};
}

        类模板最重要的复用方式之一,是采取类模板+适配器模式。通过模板参数 Container,用户可选择任意合适的容器/数据结构,将其转换为栈,例如 vector 顺序表、list 链表或 deque 双端队列。通过 Stack 适配器便可复用其他容器的代码实现,改造转换成栈,不管是实现顺序栈还是实现链式栈,传递具体的容器类型参数即可。

        那么继承机制下的复用呢?通常可以指定某个容器类,作为父类,让子类 Stack 继承其成员函数和成员变量,同样可以复用容器的代码实现。

代码示例 1.3.2

namespace david
{
// 使用宏方便替换容器
// #define CONTAINER std::list
// #define CONTAINER std::deque
#define CONTAINER std::vector
// 子类可以为类模板,模板参数T为元素类型
// 父类也可以为类模板,模板参数T可用于指定父类的具体类型
template<class T>
class stack : public std::CONTAINER<T> // stack继承自标准库的vector
{
// Stack看似没有成员变量,其实有一份继承自父类vector的成员变量
public:
// 压栈
void push(const T& x)
{
// 子类可以直接调用父类的成员函数
// push_back(x);

/*
父类是类模板,则注意要指定成员函数的类域,
否则编译报错:error C3861: “push_back”: 找不到标识符
*/
CONTAINER<T>::push_back(x);
}

// 出栈
void pop() { CONTAINER<T>::pop_back(); }

// 获取栈顶元素
T& top() { return CONTAINER<T>::back(); }

const T& top() const { return CONTAINER<T>::back(); }

// 返回栈中元素个数
size_t size() const { return CONTAINER<T>::size(); }

// 判空
bool empty() const { return CONTAINER<T>::empty(); }
};
}
/* 注意:这里子类 Stack 虽然为模板,重点仍是继承,在类的设计层面,复用了其他容器的设计实现
栈。模板参数 T 为元素类型,不参与设计层面的复用,仅作为 Stack 的使用层面的复用 */

        值得说明,父类是类模板时,为什么需要指定类域调用成员函数?容易被误解的是模板按需实例化,父类的成员函数未被实例化,或者子类无法继承。

        实际上,实例化一个子类对象 stack<int>,必定会实例化一个父类对象 vector<int>。编译器在实例化父类对象时,会生成该类的完整定义,包括其所有的成员变量和成员函数,子类也会将其完全继承。所以,不存在上述情况。而且,假使真的没有实例化,报错应该是“未声明的标识符”,而不是“找不到标识符”。

        我们知道,编译器正常是,按序向外层作用域逐级查找:

  • 当前块作用域(如函数内部、{ } 内部)
  • 当前类作用域(如果在类的成员函数中)
  • 外层类作用域(如果是嵌套类)
  • 命名空间作用域
  • 全局作用域
  •         如果一个类不是模板,或者模板参数确定 vector<int>,编译器的查找机制不会改变,立刻进行名称查找和类型检查,查不到则直接报错。但由于模板参数不确定,编译器会推迟模板的查找与检查,先只看看语法对不对,等模板参数确定再说。因此,模板有两个查找阶段:第一阶段——模板定义时,第二阶段——模板实例化时。

    • 第一阶段,Stack<T> 未实例化,模板参数不确定,T 和 vector<T> 为未知类型。push_back 为非限定名称(没有包含作用域解析运算符::的标识符),C++标准规定默认不在父类中查找非限定名称,因此导致查找机制被改变。子类继承了成员函数,成员函数的定义在父类,编译器正常会按继承关系查找过去,但 vector<T> 作为父类,编译器拒绝并跳过了在父类中查找 push_back
    • 第二阶段,当模板参数确定后 T = int,编译器才会进行依赖名称的查找(依赖模板的变量、函数等)。但按照标准,非限定名称不会回溯到父类中查找。因此,模板相关的名称,如果采用非限定名称无法被自动查找到。

            我们需要明确告诉编译器:第一阶段去父类 vector<T> 中查找,或等到第二阶段实例化后再找。如何告诉编译器呢?采用限定名称(指明作用域)即可,例如 vector<T>:: 或 this->。还可以使用 using std::vector<T>::push_back;,在子类作用域中显式引入,将 push_back 变成子类作用域中的非限定名称。

            注:我们知道,对于特定类型,可以进行模板的特化和特殊处理。因此,vector<T> 完全有可能在特化版本实现了一个完全不同的 push_back,甚至可以直接不实现,这是区别于其他 vector<T> 的,这也是C++标准推迟模板的查找并拒绝在模板定义阶段查找父类的原因之一。

    2. 父类和子类的赋值兼容转换问题

    • 公有继承的前提下,子类对象 可以赋值给 父类的对象 / 父类的指针 / 父类的引⽤。形象的说法叫切⽚,意为把子类中属于父类那部分切来赋值过去。
    • 父类对象不能赋值给子类对象(强制类型转换也不行,可切割但无法填充)。

            公有继承不会影响继承的成员的访问权限,因此必须用公有继承。
            子类对象赋值给父类对象,父类对象会拷贝得到子类中属于父类那部分;子类对象赋值给父类指针,父类指针会指向子类对象的起始地址,但该指针只会访问父类那部分;子类对象赋值给父类引用,该引用只会给子类中属于父类那部分起别名。

      代码示例 2.1

      #include <iostream>
      #include <string>
      using namespace std;

      // 父类
      class Person
      {
      protected:
      string _name; // 姓名
      string _sex; // 性别
      int _age; // 年龄
      };

      // 子类
      class Student : public Person
      {
      public:
      int _No; // 学号
      };

      int main()
      {
      Student sobj;
      // 1、子类对象可以赋值给父类的指针/引⽤
      Person* pp = &sobj;
      Person& rp = sobj;
      // 子类对象可以赋值给父类对象是通过调⽤后⾯会讲解的父类的拷⻉构造完成的
      Person pobj = sobj;

      // sobj = pobj; // 2、父类对象不能赋值给子类对象,这⾥会编译报错
      // error C2679:
      // 二元“=”: 没有找到接受“Person”类型的右操作数的运算符(或没有可接受的转换)
      return 0;
      }

              需要特别说明,不同类型的赋值需要(强制)类型转换,可能产生临时对象,但这里没有。所谓切割,是直接将子类当中属于父类的那一部分交给了指针或引用。可以说,子类对象其实本身就是一个父类对象

              可以用下面这个示例说明。

      代码示例 2.2

      int a = 10;
      const double& ref = a;
      // double& ref = a;

              a int 类型,正常用 double& 引用会报错:error C2440:“初始化”: 无法从“int”转换为“double &”。因为,a 会强制类型转换,产生 double 类型的临时变量,再将其赋值给 ref,而临时变量具有常性,需要 const 引用,所以会报错。

              再来看 Person& rp = sobjsobj Student 类,rp Person 类的引用,却并非常引用,说明这其中并没有产生临时对象,进一步说明子类赋值给父类并不会发生类型转换。

      • 父类对象不可以赋值给子类对象,但父类的指针或引⽤可以通过强制类型转换赋值给子类的指针或引用,注意,强转必须是父类的指针指向子类对象才是安全的(否则仍属于填充情况,可切割但不可填充)。如果父类是多态类型,可使⽤ RTTI (Run-TimeType Information) 的 dynamic_cast 识别进行安全转换(ps:这个在后⾯类型转换章节再专⻔讲解,这⾥先提⼀下)。

              父类对象只能是父类对象,但父类的指针或引⽤可能指向父类,也可能指向子类,自然也可以赋值给子类,所以单独对指针和引用放开了口子,同时,为了安全,便于判断其是否指向子类对象,可以使用 dynamic_cast(指向父类对象会失败,指向子类对象则成功)。

      代码示例 2.3

      #include <iostream>
      #include <string>
      using namespace std;

      // 父类
      class Person
      {
      // 多态涉及到虚函数,后面章节再详细说明,此处仅演示说明
      virtual void func()
      {
      // …
      }
      protected:
      string _name;
      string _sex;
      int _age;
      };

      // 子类
      class Student : public Person
      {
      public:
      int _No;
      };

      int main()
      {
      Student sobj;
      Person* pp = &sobj;
      // 父类指针/引用可以强制类型转换赋值给子类指针/引用
      Student* ps1 = dynamic_cast<Student*>(pp); // 必须是多态类型才可以使用
      cout << ps1 << endl;
      // 如果指向父类对象,则强制类型转换失败
      Person pobj;
      Person* pp2 = &pobj;
      Student* ps2 = dynamic_cast<Student*>(pp2); // pp2指向父类对象
      cout << ps2 << endl;

      return 0;
      }

      运行结果(转换失败,ps2 全为0):

      3. 继承的作用域问题

      3.1 隐藏规则

    • 在继承体系中,父类和子类仍然视为不同的类,拥有独⽴作⽤域(属于不同类域)。
    • 子类与父类如果有同名成员,将屏蔽父类同名成员,访问子类同名成员,该情况叫隐藏,可⽤作用域限定符 :: 显式访问父类同名成员。
    • 需要注意,如果是成员函数名称相同即构成隐藏。
    • 实践中,继承体系⾥最好不要定义同名的成员。
    •         注:隐藏不光是成员变量与成员变量,成员函数与成员函数,成员变量与成员函数名称相同也构成隐藏。

      代码示例 3.1.1

      #include <iostream>
      #include <string>
      using namespace std;

      // 父类
      class Person
      {
      public:
      string _name = "Person's _name";
      };

      // 子类
      class Student : public Person
      {
      public:
      void Func()
      {
      cout << _name << endl; // 父类的同名成员默认被隐藏
      cout << Student::_name << endl; // 默认访问子类的同名成员
      cout << Person::_name << endl; // 只能指明父类的类域,显式访问
      }

      // 子类继承了一份父类的成员变量 Person::_name
      // 同时,子类另外定义了一份自己的 Student::_name
      // 相当于子类有两份 _name
      string _name = "Student's _name";
      };

      int main()
      {
      // Student(); 是匿名对象
      // 相当于有名对象 Student stu(); 去掉了对象名称 stu,同时对象生命周期只有这一行代码
      // 访问静态函数可以指明类域直接访问,如 Student::Func();
      // 访问非静态函数,必须通过对象(有名或匿名对象)访问(因为非静态函数需要this指针)
      Student().Func();
      return 0;
      }

      运行结果:

      代码示例 3.1.2

      #include <iostream>
      using namespace std;

      class A
      {
      public:
      void fun()
      {
      cout << "func()" << endl;
      }
      };

      class B : public A
      {
      public:
      int fun = 10;
      };
      int main()
      {
      B b;
      // void fun() 被隐藏,默认访问子类的 fun(变量)
      cout << b.fun << endl;
      // 只能显式访问父类的 fun
      b.A::fun();

      // 子类的 fun 是变量,不是函数,会报错
      // b.fun();

      return 0;
      };

      运行结果:

      将 fun 变量当成 fun 函数,会报错:

      3.2 练习—笔试选择题

              (1)、A 类和 B 类中的两个 fun 函数构成什么关系?()

              A. 重载                    B. 隐藏                   C. 没关系

      代码示例 3.2.1

      class A
      {
      public:
      void fun()
      {
      cout << "func()" << endl;
      }
      };

      class B : public A
      {
      public:
      void fun(int i)
      {
      cout << "func(int i)" << i << endl;
      }
      };

              正确答案:B. 隐藏。

              题解:

      • A. 重载,函数重载要求在同一作用域,虽然同名且形参列表不同,但父类和子类作用域各自独立,不构成重载条件。A 错误。
      • B. 隐藏,首先,父类和子类之间,其次,名称相同,故构成隐藏条件。B 正确。

              (2)、下面程序的编译运行结果是什么?()

              A. 编译报错                    B. 运行报错                   C. 正常运行

      代码示例 3.2.2

      #include <iostream>
      using namespace std;

      class A
      {
      public:
      void fun()
      {
      cout << "func()" << endl;
      }
      };
      class B : public A
      {
      public:
      void fun(int i)
      {
      cout << "func(int i)" << i << endl;
      }
      };

      int main()
      {
      B b;
      b.fun(10);
      b.fun();
      return 0;
      };

              正确答案:A. 编译报错。

              题解:首先确定构成隐藏,虽然子类继承自父类,实际确实有两个 fun 函数,但父类的 fun 被隐藏,调用函数不匹配时不会再去父类中找。因此,通过 b 对象调用的都是子类的 fun 函数,必须传参。b.fun(10) 正常调用,b.fun() 没有传参则编译报错。A. 正确,B 和 C 错误。

              注:b.func(); 改为 b.A::func(); 即可,指明类域 A:: 显式访问无参函数。

      4. 子类的默认成员函数

      4.1 四个常见默认成员函数

              前面介绍过,类有六个默认成员函数(C++11新增两个,暂不介绍):

    • 构造函数
    • 析构函数
    • 拷贝构造函数
    • 赋值运算符重载
    • 取地址运算符重载(普通对象)
    • 取地址运算符重载(const对象)
    •         一般关注前4个即可。在继承体系下,父类的默认成员函数规则与普通类是一致的,那么子类的默认成员函数有哪些行为?

      子类的构造函数
      • 子类的构造函数必须调用父类的构造函数初始化继承自父类的成员。如果父类有默认构造,则自动调用默认构造;如果父类没有默认构造,则必须在子类构造函数的初始化列表调⽤其他父类构造。
      • 子类对象的初始化,先调父类构造初始化父类部分,再调子类构造。

              我们不想写,子类没有构造,编译器默认生成的构造的行为:

    • 和普通类一样,内置类型,初始化不确定。
    • 和普通类一样,自定义类型,调用它自己的默认构造,没有则报错。
    • 子类中,父类部分看作一个整体(父类对象),调用父类的默认构造初始化 (显式写,即无参构造和全缺省构造,编译器默认生成的不行)。父类对象的初始化则回归1、2点。
    •         注1:默认构造函数:无需传参的构造函数。默认构造分3种:1.全缺省构造 (所有形参都有缺省值);2.无参构造 (零形参);3.不写构造(类中1个构造函数都没有),编译器默认自动生成的构造。

              注2:因为子类没显式写构造,靠编译器生成,无法在初始化列表调用其他父类构造初始化,因此父类必须有默认构造,否则报错。什么情况没有默认构造?既没有实现无参构造,也没有实现全缺省构造,但实现了其他带参构造,编译器就不会自动生成默认构造。

      代码示例 4.1.1

      #include <iostream>
      #include <string>
      using namespace std;

      // 父类
      class Person
      {
      public:
      // 四大默认成员函数:构造、析构、拷贝构造、赋值重载
      // 默认构造(全缺省构造)
      Person(const char* name = "XXXXX")
      : _name(name)
      {
      cout << "Person()" << endl;
      }
      // 拷贝构造
      Person(const Person& p)
      : _name(p._name)
      {
      cout << "Person(const Person& p)" << endl;
      }
      // 赋值重载
      Person& operator=(const Person& p)
      {
      cout << "Person operator=(const Person& p)" << endl;
      if (this != &p)
      _name = p._name;
      return *this;
      }
      // 析构
      ~Person()
      {
      cout << "~Person()" << endl;
      }
      protected:
      string _name; // 姓名(自定义类型)
      };

      // 子类
      class Student : public Person
      {
      public:
      // 不写构造,编译器默认自动生成默认构造

      protected:
      int _num; // 学号(内置类型)
      string _address; // 地址(自定义类型)
      };

      int main()
      {
      // 1、定义 Student 对象 s,同时也在调用 Student 的默认构造进行初始化
      // 2、Student 没有构造,使用编译器默认生成的默认构造
      // 3、先初始化父类部分,将其视为1个父类对象,调用父类的默认构造初始化
      // 调用 Person 的全缺省构造,按正常的构造规则即可
      // 内置类型初始化不确定,自定义类型调它的默认构造
      // 4、再初始化子类自己的成员,按正常构造规则即可
      Student s;
      return 0;
      };

              我们显式写子类的构造函数,不能直接单独初始化父类成员,必须将其看作1个父类对象,调用父类的构造函数去初始化。

              注:构造函数的初始化列表是成员变量初始化的地方,构造函数内部再赋值是在初始化列表之后,严格来说已经是初始化之后。

      代码示例 4.1.2

      #include <iostream>
      #include <string>
      using namespace std;

      class Person
      {
      public:
      // 默认构造
      Person(const char* name = "XXXXX")
      : _name(name)
      {
      cout << "Person()" << endl;
      }
      // 拷贝构造
      Person(const Person& p)
      : _name(p._name)
      {
      cout << "Person(const Person& p)" << endl;
      }
      // 赋值重载
      Person& operator=(const Person& p)
      {
      cout << "Person operator=(const Person& p)" << endl;
      if (this != &p)
      _name = p._name;
      return *this;
      }
      // 析构
      ~Person()
      {
      cout << "~Person()" << endl;
      }
      protected:
      string _name;
      };

      class Student : public Person
      {
      public:
      // 显式写子类构造,不可以单独初始化父类成员 _name,错误行为
      //Student(const char* name, int n, const char* addr)
      // : _name(name), _num(n), _address(addr)
      //{ }

      // 必须调用父类构造
      Student(const char* name, int n, const char* addr)
      : Person(name), _num(n), _address(addr)
      { }

      void Print()
      {
      cout << "_name: " << _name << endl;
      cout << "_num: " << _num << endl;
      cout << "_address: " << _address << endl;
      }
      protected:
      int _num;
      string _address;
      };

      int main()
      {
      Student s("Peter", 10, "America");
      s.Print();
      return 0;
      }

      运行结果:

              如示例,在初始化列表显式调用构造 Person(name),就像定义匿名对象的形式。

              另外,该运行结果同样反证了一件事,子类中父类部分被视作1个父类对象。因为,子类对象 s 生命周期结束时,调用了父类的析构 ~Person,从初始化到销毁,父类部分都作为整体来操作。因此,子类对象通常可看作:内置类型成员+自定义类型成员+父类对象。

      子类的拷贝构造
      • 子类的拷⻉构造必须调⽤父类的拷⻉构造初始化父类整体。

              子类分为3部分:内置类型、自定义类型、父类。

              如果我们不写,编译器默认生成的拷贝构造的行为:

    • 对于内置类型,完成值拷贝。
    • 对于自定义类型,调用它的拷贝构造。
    • 对于父类整体,调用父类的拷贝构造。
    •         因此,严格来说 Student 压根不需要写拷贝构造,默认生成的就够用。内置类型可以直接值拷贝,自定义类型 string 可以调用它的拷贝构造,父类可以调用 Person 的拷贝构造。

              只有3种情况,需要自己手动实现:

    • Student 中有需要深拷贝的资源。
    • Student 的自定义类型成员,有深拷贝资源,且该自定义类型没有拷贝构造。该成员只能使用编译器默认生成的拷贝构造,默认行为是浅拷贝。
    • Student 的父类成员,有深拷贝资源,且父类没有拷贝构造。类似2,其实父类和自定义类型可看作一种情况。
    •         例如下面示例,我们在 Student 中添加1个动态数组。

      代码示例 4.1.3

      #include <iostream>
      #include <string>
      using namespace std;

      class Person
      {
      public:
      // 默认构造
      Person(const char* name = "XXXXX")
      : _name(name)
      {
      cout << "Person()" << endl;
      }
      // 拷贝构造
      Person(const Person& p)
      : _name(p._name)
      {
      cout << "Person(const Person& p)" << endl;
      }
      // 赋值重载
      Person& operator=(const Person& p)
      {
      cout << "Person operator=(const Person& p)" << endl;
      if (this != &p)
      _name = p._name;
      return *this;
      }
      // 析构
      ~Person()
      {
      cout << "~Person()" << endl;
      }
      protected:
      string _name;
      };

      class Student : public Person
      {
      public:
      Student(const char* name, int n, const char* addr)
      : Person(name), _num(n), _address(addr)
      {
      _arr = new int[_size] { 1, 2, 3, 4, 5 };
      }

      // 显式实现子类的拷贝构造
      Student(const Student& s)
      : Person(s)
      , _num(s._num)
      , _address(s._address)
      {
      // 深拷贝
      int* tmp = new int[s._size];
      for (int i = 0; i < s._size; i++) tmp[i] = s._arr[i];
      _arr = tmp;
      _size = s._size;
      }

      void Print()
      {
      cout << "_name: " << _name << endl;
      cout << "_num: " << _num << endl;
      cout << "_address: " << _address << endl;
      for (int i = 0; i < 5; i++) cout << _arr[i] << " ";
      cout << "\\n";
      }
      protected:
      int _num;
      string _address;
      // 如果存在资源申请,则需要显式实现拷贝构造
      int* _arr; // 动态数组
      int _size = 5; // 数组长度,默认初始化为5
      };

      int main()
      {
      Student s("Peter", 10, "America");
      Student s2 = s;
      s2.Print();
      return 0;
      }

      运行结果:

              需注意,初始化列表中,有这么一条 Person(s)s 的类型是 const Student&,子类的常引用。此处就是子类构造的规则,在初始化列表处显式调用父类构造,拷贝构造也是构造。再看父类的拷贝构造,Person(const Person& p),我们发现 s 传参给了 p。这其实是前面父类和子类的赋值兼容转换问题s 是子类对象的引用,形参 是父类对象的引用,子类兼容父类,子类可以直接赋值给父类,因为子类可以切割,只保留父类部分用于赋值。

              值得说明:

      • 子类的构造函数需要调用父类的构造函数,有默认构造则调用默认构造,没有则需在初始化列表显式调用其他构造。这里的构造自然也包括拷贝构造。如果删掉初始化列表中的 Person(s),不显式调用父类的拷贝构造,这里容易有误区:误认为该调用父类的拷贝构造。如果不显式调用还会调用,那何必写。实际会调用默认构造。其一,但凡调用了构造,如果有父类默认构造则调用默认构造;其二,拷贝构造需要切割子类,编译器不会擅自进行兼容转换强行调用;其三,显式写了子类的拷贝构造,但不调用父类的拷贝构造,不限定拷贝初始化,自然无需关注父类部分的初始化结果,采用默认构造即可。为什么编译器默认生成的拷贝构造却会自动调用父类的拷贝构造?因为编译器的角度是子类对象整体,拷贝整个 Student 对象,对子类的3部分:内置类型、自定义类型、父类,分别拷贝然后初始化。有拷贝,也有初始化,自然要调用父类的拷贝构造。

              所以,哪怕子类的拷贝构造没有显式调用 Person(s),也会调用默认构造。如果 Person 没有默认构造,则会报错。

              

      子类的赋值重载
      • 子类的 operator= 必须调⽤父类的 operator= 对父类部分赋值。另外注意,父类和子类的 operator= 构成隐藏。因此,调⽤父类的 operator= 需指定父类类域。

              同理,和拷贝构造一致。严格来说 Student 不需要自己写赋值重载。

              如果我们不写,编译器默认生成的赋值重载,和拷贝构造行为类似:

    • 对于内置类型,完成值拷贝。
    • 对于自定义类型,调用它的赋值重载。
    • 对于父类整体,调用父类的赋值重载。
    •         当然,可以显式写赋值重载,需对子类3部分:内置类型、自定义类型、父类,分别进行赋值。父类部分需显式调用父类的赋值重载。注意,赋值重载是运算符重载,不是构造函数,不遵循子类构造的规则。如果显式写了子类的赋值重载,却不调用父类的赋值重载对父类部分赋值,父类部分值不变。

      代码示例 4.1.4

      #include <iostream>
      #include <string>
      using namespace std;

      class Person
      {
      public:
      // 默认构造
      Person(const char* name = "XXXXX")
      : _name(name)
      {
      cout << "Person()" << endl;
      }
      // 拷贝构造
      Person(const Person& p)
      : _name(p._name)
      {
      cout << "Person(const Person& p)" << endl;
      }
      // 赋值重载
      Person& operator=(const Person& p)
      {
      cout << "Person operator=(const Person& p)" << endl;
      if (this != &p)
      _name = p._name;
      return *this;
      }
      // 析构
      ~Person()
      {
      cout << "~Person()" << endl;
      }
      protected:
      string _name;
      };

      class Student : public Person
      {
      public:
      Student(const char* name, int n, const char* addr)
      : Person(name), _num(n), _address(addr)
      {
      _arr = new int[_size] { 1, 2, 3, 4, 5 };
      }

      Student(const Student& s)
      : Person(s), _num(s._num), _address(s._address)
      {
      int* tmp = new int[s._size];
      for (int i = 0; i < s._size; i++) tmp[i] = s._arr[i];
      _arr = tmp;
      _size = s._size;
      }

      // 显式实现子类的赋值重载
      Student& operator=(const Student& s)
      {
      if (this != &s)
      {
      // 深拷贝
      int* tmp = new int[s._size];
      for (int i = 0; i < s._size; i++) tmp[i] = s._arr[i];
      _arr = tmp;
      _size = s._size;

      // 父类部分调用父类的赋值重载
      Person::operator=(s); // 注意父类的operator=需要显式调用

      // 内置类型进行值拷贝
      _num = s._num;

      // 自定义类型调用它的赋值重载
      _address = s._address;
      }
      return *this;
      }

      void Print()
      {
      cout << "_name: " << _name << endl;
      cout << "_num: " << _num << endl;
      cout << "_address: " << _address << endl;
      for (int i = 0; i < 5; i++) cout << _arr[i] << " ";
      cout << "\\n";
      }
      protected:
      int _num;
      string _address;
      int* _arr;
      int _size = 5;
      };

      int main()
      {
      Student s("Peter", 10, "America");
      Student s2("David", 20, "China");
      s2.Print();
      s2 = s; // 赋值
      s2.Print();
      return 0;
      }

      运行结果:

      子类的析构函数
      • 子类析构被调⽤完成,再调⽤父类析构清理父类部分,即先调子再调父(构造是先调父再调子)。这样才能保证子类对象先清理子类成员,再清理父类成员

              同理,严格来说 Student 不需要自己写析构。

              如果我们不写,编译器默认生成的析构:

    • 内置类型,没有资源处理。
    • 自定义类型,调用它的析构。
    • 父类部分,调用父类的析构。
    •         其他都好说,资源释放是 delete/delete[] 相关的事。问题是,子类的析构如何清理父类成员?也需要显式调用父类的析构吗?

      代码示例 4.1.5

      // 如果有需要释放的资源,可显式实现子类的析构
      ~Student()
      {
      cout << "~Student()" << endl;
      // 内置类型 _num、_size 不需要管
      // 自定义类型 _address 调用它的析构,不需要管
      // 动态数组 _arr,需要手动释放
      delete[] _arr;
      // 显式调用父类析构,清理父类成员
      ~Person();
      }

      ~Person(); 编译报错:

      • 因为一些特殊原因 (和多态有关),编译器会将析构的函数名称特殊处理成 destructor(),所以在父类的析构不加 virtual 的情况下,子类的析构函数和父类的析构函数构成隐藏

              注1:析构是可以手动调用/显式调用的。

              注2:多态中⼀些场景下,析构函数需要构成“重写”,满足“重写”的条件之⼀就是函数名相同 (后面多态章节会讲解)。

              因此,析构函数实际上都会被处理成 destructor()~Person~Student 实际名称都是 destructor,所以构成隐藏关系。调用父类的析构则必须指定父类类域。

      代码示例 4.1.6

      #include <iostream>
      #include <string>
      using namespace std;

      class Person
      {
      public:
      // 默认构造
      Person(const char* name = "XXXXX")
      : _name(name)
      {
      cout << "Person()" << endl;
      }
      // 拷贝构造
      Person(const Person& p)
      : _name(p._name)
      {
      cout << "Person(const Person& p)" << endl;
      }
      // 赋值重载
      Person& operator=(const Person& p)
      {
      cout << "Person operator=(const Person& p)" << endl;
      if (this != &p)
      _name = p._name;
      return *this;
      }
      // 析构
      ~Person()
      {
      cout << "~Person()" << endl;
      }
      protected:
      string _name;
      };

      class Student : public Person
      {
      public:
      Student(const char* name, int n, const char* addr)
      : Person(name), _num(n), _address(addr)
      {
      cout << "Student(const char* name, int n, const char* addr)" << endl;
      _arr = new int[_size] { 1, 2, 3, 4, 5 };
      }

      // 显式实现子类的析构
      ~Student()
      {
      cout << "~Student()" << endl;
      delete[] _arr;
      // 显式调用父类析构,需指定父类的作用域
      Person::~Person();
      }
      protected:
      int _num;
      string _address;
      int* _arr;
      int _size = 5;
      };

      int main()
      {
      Student s("Peter", 10, "America");
      return 0;
      }

      运行结果:

              我们发现,运行结果不对。首先,子类对象初始化,先父再子,先调用 Person 构造,再调用 Student 构造,结果吻合。再是子类对象的清理,先子再父,先调用 ~Student 析构,再调用 ~Person 析构,结果吻合。但最后一行多调用了一个 ~Person 析构。

              和构造/拷贝构造/赋值重载不同,不管写不写,子类析构结束后自动调用父类析构。虽然我们已经显式调用了,但子类析构结束后,仍然再调了一次。因此,我们不应该显式调用父类的析构。为什么要这样规定呢?在类和对象章节,我们说过“后定义的先析构”,所以,构造函数定义并初始化时是先父后子,相应地,析构清理时要先子后父,如果由用户显式调用析构,就要取决于用户是否按标准写。

      总结

             

      • 可以显式调用:构造、拷贝构造、赋值重载。可在初始化列表或函数体内显式调用。
      • 自动调用:析构。父类析构自动调用,不需要显式调用。
      • 通常需要自己写:子类的构造函数。方便传参,根据需求进行初始化。       
      • 一般不需要自己写:子类的析构/拷贝构造/赋值重载。编译器默认生成的一般就够用。除非存在资源申请,但没有深拷贝。通常认为,析构、拷贝构造、赋值重载三者一体。但凡需要显式实现其中之一,必定需要实现另外两个。

              为便于直接复用父类的默认成员函数,通常,将子类中父类成员整体视为1个父类对象进行处理。

        4.2 实现不能被继承的类

                如何实现一个不能被继承的类?即这个类无法成为父类,被其他类继承。

      • 构造函数私有化。根据前面的语法规则,父类的私有成员在子类不可见。子类构造必定调⽤父类构造。所以父类构造私有化,子类就无法调⽤,就不能实例化出子类对象。
      • C++11新增 final 关键字,final 修饰的类叫最终类,最终类无法被继承。
      • 代码示例 4.2.1

        #include <iostream>
        using namespace std;

        class Base
        {
        public:
        // 成员函数
        void func5()
        {
        cout << "Base::func5" << endl;
        }

        protected:
        // 成员变量
        int a = 1;

        private:
        // C++98 的⽅法:构造函数私有化
        // 子类的构造需要调用父类的默认构造(无参/全缺省/编译器默认生成)
        // 如果没有默认构造,子类必须在初始化列表显式调用带参构造
        Base() { }
        };

        class Derive : public Base
        {
        public:
        // 成员函数
        void func4() { cout << "Derive::func4" << endl; }
        protected:
        // 成员变量
        int b = 2;
        };

        int main()
        {
        Base b;
        Derive d;
        return 0;
        }

        C++98 方法,编译报错:

                该方法原理就是禁用掉 Base 父类的默认构造,使 Derive 子类的构造无法调用。如C2280这个错误,所谓“尝试引用已删除的函数”,意思是 Derive 尝试调用父类的默认构造,但 Base 的默认构造是私有成员无法访问,由于已经存在构造函数,编译器默认生成的那个默认构造无法生成,所以报错。

        • 问题一:Base 自己也无法在类外访问默认构造了。所以有了C2248,因为定义 b 对象调用 Base 的默认构造,但它是私有成员。破解方法也很简单,默认构造用不了,就只能实现带参构造。
        • 问题二:方法效果不明显。如果不定义子类对象 Derive d;,不调用构造,编译器就不会报错,但事实存在继承关系 class Derive : public Base
        • 问题三:这个无法继承是可以解决的。根据语法规则,子类无法调用默认构造才是导致 Base 类无法被继承的原因。我们可以根据规则,让子类显式调用其他构造解决。所以 Base 需要存在其他构造函数可以调用,其次子类也必须自己实现构造,方便在初始化列表显式调用,不能靠编译器生成的默认构造。

        代码示例 4.2.2

        #include <iostream>
        using namespace std;

        class Base
        {
        public:
        // 解决方法:Base 不能使用默认构造,只能靠带参构造初始化
        Base(int x)
        : a(x)
        {}
        void func5() { cout << "Base::func5" << endl; }

        protected:
        int a = 1;

        private:
        // C++98 的⽅法:构造函数私有化
        Base() { }
        };

        class Derive : public Base // 事实存在继承关系,不定义子类对象就没事
        {
        public:
        // 子类可以通过显式调用 Base 的其他构造,绕开无法继承的问题
        // 显式实现子类的构造,显式调用 Base 的带参构造初始化
        Derive(int x = 10, int y = 20)
        : Base(x), b(y)
        { }
        void func4() { cout << "Derive::func4" << endl; }

        protected:
        // 成员变量
        int b = 2;
        };

        int main()
        {
        Base b(10); // 只能靠带参构造初始化,必须传参
        Derive d; // 无法调用默认构造,可显式调用其他构造,绕开C++98方法的限制
        return 0;
        }

                C++98是从子类构造的原理入手,但禁掉了父类的默认构造,子类还可以显式调用其他构造。而且,该方法只是禁止定义子类对象,继承关系仍然可以存在。

                更好的解决办法是,使用C++11新增的 final 关键字。无法被绕开,同时,定义继承关系时就会直接报错。因为,最终类无法被继承是硬性的语法规则。

        代码示例 4.2.3

        #include <iostream>
        using namespace std;

        // C++11的方法:final 修饰 Base 成为最终类,最终类无法被继承
        class Base final
        {
        public:
        // 成员函数
        Base() // 默认构造
        { }
        Base(int x) // 带参构造
        : a(x)
        { }
        void func5() { cout << "Base::func5" << endl; }

        protected:
        // 成员变量
        int a = 1;
        };

        class Derive : public Base // 定义继承关系时就会报错,直接拒绝了继承关系
        {
        public:
        // 无法绕开,调不了父类的默认构造,也无法显式调用父类的其他构造。
        Derive(int x = 10, int y = 20)
        : Base(x), b(y)
        { }
        void func4() { cout << "Derive::func4" << endl; }

        protected:
        // 成员变量
        int b = 2;
        };

        int main()
        {
        Base b;
        Derive d; // 无法调用父类的构造,不能实例化
        return 0;
        }

        编译报错:

        5. 继承与友元

                友元关系不能继承,也就是说父类的友元不能访问子类的私有成员/保护成员 。

        代码示例 5.1

        #include <iostream>
        #include <string>
        using namespace std;

        // Display 函数使用了 Student 类型的形参,但 Student 的定义在友元声明之后
        class Student; // 前置声明

        class Person
        {
        // 友元声明:友元函数 Display 可访问 Person 的私有或保护成员
        friend void Display(const Person& p, const Student& s);
        protected:
        string _name; // 姓名
        };

        class Student : public Person
        {
        // 若想让 Display 访问子类私有/保护成员,必须再次进行友元声明
        // friend void Display(const Person& p, const Student& s);
        protected:
        int _stuNum; // 学号
        };

        void Display(const Person& p, const Student& s)
        {
        // Display 是父类的友元,但不是子类的友元,父类的友元子类不能继承
        // 输出父类成员
        cout << "Person: " << p._name << endl;
        // 输出子类成员,失败
        cout << "Student: " << s._stuNum << endl;
        }

        int main()
        {
        Person p;
        Student s;
        // 编译报错,error C2248 :
        // “Student::_stuNum”: 无法访问 protected 成员(在“Student”类中声明)
        // 解决⽅案:再在 Student 子类中声明 Display 为友元即可
        Display(p, s);

        return 0;
        }

        6. 继承与静态成员

        • 父类定义 static 静态成员,在整个继承体系里,该成员只存在1份,⽆论派⽣出多少个子类,都只有1个实例。

                即静态成员可以被子类继承,但不会生成新的实例。普通成员是父类有1份,子类继承下来有1份,这两份不是同一个,而是子类对象构造时,调用父类的构造函数,为子类另外定义并初始化了一份。静态成员则是父类和所有子类,包括子类的子类,共用一份,都是同一个实例。

        代码示例 6.1

        #include <iostream>
        #include <string>
        using namespace std;

        class Person
        {
        public:
        string _name;
        static int _count;
        };

        // 静态成员变量被所有 Person 类的对象共用,不走构造函数与初始化列表,必须类外初始化
        int Person::_count = 66;

        // 子类 Student,继承父类 Person 成员
        // _name 普通成员有两份,父类和子类各一份
        // _count 静态成员被继承,但父类和子类共有1份
        class Student : public Person
        {
        protected:
        int _stuNum;
        };

        int main()
        {
        // 注意区分:
        // 1、所有 Person 对象,p1、p2共用静态成员变量 _count
        // 2、父类和子类也共用静态成员变量 _count
        // 即所有父类对象和子类对象,p1、p2、s1、s2都共用静态成员变量 _count
        Person p1;
        Person p2;
        Student s1;
        Student s2;

        // 打印父类的普通成员 _name 的地址,查看结果:父类和子类各有一份,地址不同
        cout << &p1._name << endl; // 单独地址
        cout << &s1._name << endl; // 单独地址
        cout << "普通成员各有一份,地址不同\\n" << endl;

        // 打印父类的静态成员 _count 的地址,查看结果:父类和子类共用一份,地址相同
        // 1、对象之间共用静态成员
        cout << &p1._count << endl; // 同一地址
        cout << &p2._count << endl; // 同一地址
        // 2、父类和子类之间共用静态成员
        cout << &s1._count << endl; // 同一地址
        cout << &s2._count << endl; // 同一地址
        cout << "静态成员共用一份,地址相同\\n" << endl;

        // 从⽗类的类域,和子类的类域,都可以访问到同一静态成员(静态成员变量 _count)
        // _count 是公有成员变量,可直接在类外访问
        cout << Person::_count << endl;
        cout << Student::_count << endl;
        Person::_count = 88;
        // 也可以通过对象调用,不过一般静态成员还是指定类域调用
        cout << p1._count << endl;
        cout << s1._count << endl;
        cout << "父类类域,子类类域,皆可访问\\n" << endl;

        return 0;
        }

        运行结果:

        7. 多继承及其菱形继承问题

        7.1 继承模型

        • 单继承:子类只有⼀个直接父类的继承关系。
        • 多继承:子类同时有多个直接父类的继承关系。多继承的子类对象,其成员在内存中的存储模型是:先继承的直接父类在前,后继承的直接父类在后,子类自己的成员在最后。

                类代表一种事物或特征,继承的目的是为了复用类的设计。从原始需求的角度考虑,多继承是一个很合理的想法,一个类型可能同时具有多个类型的特征。现实生活中,例如人可能有多种身份,文学家、哲学家、数学家、物理学家等。又例如番茄、玉米、黄瓜、南瓜、辣椒、茄子等,通常作为蔬菜用于厨房里炒菜,但植物学则界定为水果。它们同时拥有水果和蔬菜的特征,从模拟现实的角度,我们当然希望复用水果类的设计和蔬菜类的设计,让子类直接继承。

                多继承本身是很好的想法,但容易导致菱形继承问题。

        • 菱形继承多继承的⼀种特殊情况,子类继承了多个直接父类,这些父类很可能同时继承自某个共同的父类。菱形继承的问题是数据冗余和⼆义性。Assistant 对象中 Person 成员会有2份,1份来自 Student,1份来自 Teacher,例如 _name

                父类和子类的同名成员有隐藏机制,默认隐藏父类的同名成员。但父类和父类之间呢?默认隐藏哪个?所以无法构成隐藏,直接调用则会造成调用不明。解决办法是指明类域,访问哪个类的同名成员就指定哪个类。二义性可以解决,但数据冗余无法避免,因为实际情况是 Assistant 助教只需要一份姓名,每多定义1个 Assistant 对象就冗余一份数据,如果再给 Person 添加更多重复特征呢?例如性别、年龄、住址、民族、国家等,这是极大的冗余浪费。

                有多继承就存在菱形继承,像Java直接不⽀持多继承,就规避掉了这些问题。实践中通常建议,尽量不要设计出菱形继承这样的复杂模型。

        代码示例 7.1.1

        #include <iostream>
        #include <string>
        using namespace std;

        // Person 人员信息有:姓名
        // 子类有:Student、Teacher、Assistant
        // 直接子类有:Student、Teacher
        class Person
        {
        public:
        string _name; // 姓名
        };

        // Student 学生信息有:姓名、学号
        // 单继承
        // 直接子类:Assistant
        // 直接父类:Person
        class Student : public Person
        {
        protected:
        int _num; // 学号
        };

        // Teacher 教师信息有:姓名、职工编号
        // 单继承
        // 直接子类:Assistant
        // 直接父类:Person
        class Teacher : public Person
        {
        protected:
        int _id; // 职⼯编号
        };

        // Assistant (学生)助教信息有:姓名、学号、职工编号、课程名
        // 多继承
        // 父类有:Person、Student、Teacher
        // 直接父类有:Student、Teacher
        class Assistant : public Student, public Teacher // 逗号","分割,同时继承多个父类
        {
        protected:
        string _course; // 课程名
        };

        int main()
        {
        // 编译报错:error C2385: 对“_name”的访问不明确
        Assistant a;
        a._name = "peter"; // 无法确定哪个父类的 _name
        // 需要指明类域,显式访问对应父类成员,但数据冗余问题仍然⽆法解决
        a.Student::_name = "xxx";
        a.Teacher::_name = "yyy";

        return 0;
        }

                标准库中存在1个比较经典的菱形继承,就是IO流的设计。ios_base 类存放一些基础信息,由输入输出流 ios (Input Output Stream) 继承。同时,ios 分别被输入流 istream、输出流 ostream 继承,像 cin 标准输入就是 istream 类型,像 cout 标准输出就是 ostream 类型。

                为了同时具有输入流 istream 和输出流 ostream 的特征,标准库实现了 iostream 的类,它的两个直接父类 istream 和 ostream 具有共同父类 ios。这就是1个经典的菱形继承,所以 ios 的信息在 iostream 中会同时有两份,这就造成了冗余和浪费。

        7.2 虚继承

        什么是虚继承?

                标准库为了解决 iostream 的数据冗余与二义性问题,使用了虚继承。

        • 虚继承:继承存在冗余的类时,在父类和继承方式前加上 virtual 关键字。可以解决数据冗余和二义性。

                C++语法复杂,多继承就是⼀个体现。有多继承,就有菱形继承;有菱形继承,就有菱形虚拟继承。从用的角度,虚继承确实解决了菱形继承的痛点,但底层实现复杂,性能有一些损失。多继承可以认为是C++的缺陷之⼀,后面不少编程语⾔都没有多继承,如Java。

        代码示例 7.2.1

        #include <iostream>
        #include <string>
        using namespace std;

        class Person
        {
        public:
        string _name; // 姓名
        };

        // Person 的成员存在冗余和二义性就虚继承 Person
        // 虚继承不会影响 Student 和 Teacher 的使用
        // 只是因为底层有性能损失,真正影响的是 Assistant
        // 注意,Assistant 同时继承了 Student 和 Teacher,所以它们两个都必须使用 virtual
        // Student 虚继承 Person
        class Student : virtual public Person
        {
        protected:
        int _num; // 学号
        };
        // Teacher 虚继承 Person
        class Teacher : virtual public Person
        {
        protected:
        int _id; // 职⼯编号
        };

        class Assistant : public Student, public Teacher
        {
        protected:
        string _course; // 课程名
        };

        int main()
        {
        // 使用虚继承可以解决数据冗余和二义性
        Assistant a;
        a._name = "XXX"; // 只有一份,不会冗余且不会调用不明
        a.Student::_name = "YYY";
        a.Teacher::_name = "ZZZ";
        // 3个 _name 都是同一个

        return 0;
        }

        IOS库继承图(菱形虚拟继承)

                basic_iosbasic_ostreambasic_istream等,其实就是 iosostreamistream。和 string 一样,都是 typedef 重命名过的,typedef basic_string<char> string;。因为编码和字符集的原因,通常需要封装一层类模板,适用不同字符类型。

        代码示例 7.2.2

        // 标准库中 istream 和 ostream 的源码实现,父类 ios 都是被虚继承
        template<class CharT, class Traits = std::char_traits<CharT>>
        class basic_ostream : virtual public std::basic_ios<CharT, Traits>
        {};

        template<class CharT, class Traits = std::char_traits<CharT>>
        class basic_istream : virtual public std::basic_ios<CharT, Traits>
        {};

        更广义的菱形继承问题定义

        菱形虚拟继承导致的底层存储结构差异

                Person 导致冗余,使用虚继承 Student Teacher 中的 Person 只能保留一份,不能放在任何一方内,应将其提取出来放到公共区域。底层实现时通常会放到整个 Assistant 对象的最下面。Person 属于所有类 Assistant/Student/Teacher。同时,这里还需要引入”虚基类表“的概念。正因为其底层的复杂性,多继承可以用,但不建议用菱形继承。

        代码示例 7.2.3

        #include <iostream>
        #include <string>
        using namespace std;

        class Person
        {
        public:
        // 带参构造
        Person(const char* name)
        :_name(name)
        { }

        string _name; // 姓名
        };

        class Student : virtual public Person
        {
        public:
        // 带参构造
        Student(const char* name, int num)
        : Person(name), _num(num)
        { }

        protected:
        int _num; // 学号
        };

        class Teacher : virtual public Person
        {
        public:
        // 带参构造
        Teacher(const char* name, int id)
        : Person(name), _id(id)
        { }

        protected:
        int _id; // 职⼯编号
        };

        // 不要用菱形继承
        class Assistant : public Student, public Teacher
        {
        public:
        // 带参构造
        Assistant(const char* name1, const char* name2, const char* name3)
        :Person(name3), Student(name1, 1), Teacher(name2, 2), _course("Math")
        { }

        protected:
        string _course; // 课程名
        };

        int main()
        {
        // 思考一下:a 中的 _name 是AAA、BBB、CCC中的哪一个?
        Assistant a("AAA", "BBB", "CCC");

        return 0;
        }

                说明:

      • a 对象会调用 Assistant 的带参构造。由于虚继承,Person 被提取出来作为公共部分,既不能属于 Student,也不能属于 Teacher。我们可以将它当作 Assistant 的另一个父类看待,因此需要在 Assistant 的构造函数中对其单独初始化。
      • 父类先被初始化,同时 Person 作为 Student Teacher 的父类应该先于它们被初始化。Student 先被 Assistant 继承则先于 Teacher 初始化,Assistant 自己的成员在最后。
      • 先初始化共有父类 Person"CCC" 传参给 name3name3 传参给 Person 的构造函数,最终 _name 初始化为 "CCC"
      • 再初始化 Student"AAA" 传参给 name1name1 传参给 Student 的构造函数。注意,Person 作为公共部分不属于 Student。虽然它显式调用 Person 构造,但实际不会走。最终只有 _num 学号被初始化为1。
      • 再初始化 Teacher。同理,最终只有 _id 职工编号被初始化为2。
      • 最后,Assistant 自己的成员 _course 被初始化为 "Math"a 中的 _name PersonStudentTeacherAssistant 所有的类共有,其值为 "CCC"
      • 7.3 多继承的指针偏移问题

                下面说法正确的是 ( )?

                A. p1 == p2 == p3        B. p1 < p2 < p3       C. p1 == p3 != p2       D. p1 != p2 != p3

        代码示例 7.3.1

        class Base1
        {
        public:
        int _b1;
        };

        class Base2
        {
        public:
        int _b2;
        };

        class Derive : public Base1, public Base2
        {
        public:
        int _d;
        };

        int main()
        {
        Derive d;
        Base1* p1 = &d;
        Base2* p2 = &d;
        Derive* p3 = &d;

        return 0;
        }

                题意分析:这里是多继承的正确用法,没有共有父类,不存在菱形继承问题。Base1 Base2 Derive 的直接父类。因此,Derive 对象存在3个部分:Base1 对象、Base2 对象和 Derive 自身的成员。在 main 函数中,定义了1个子类对象 d,父类指针 Base1*、父类指针 Base2* 和子类指针 Derive* 都指向 d。问这3个指针的关系,考察的是继承的赋值兼容转换问题,多继承下如何切割子类。

                解析:正确答案C. p1 == p3 != p2。我们可以推断 d 的存储结构:两个父类在上,Derive 自身的成员 _d 在下;两个父类中 Base1 先继承在上,Base2 后继承在下。Base1 对象和 Derive 对象首地址相同,Base2 对象在中间首地址不同。p3指向子类对象,值为 Derive 首地址;p1指向父类对象,切割 Base1,值为 Base1 首地址;p2指向父类对象,切割 Base2,值为 Base2 首地址。因此,p1 == p3,p2 和它们都不相同 (比p1/p3大4个字节,隔了一个int变量_b1)。故选C。

        8. 继承和组合

                继承组合是面向对象设计中两种核心的代码复用机制。一些语言 (如 Java) 不支持多继承,但通过接口可以间接支持,本质上也是以组合思维弥补多继承的缺失。

        • 继承是 is-a 关系:子类对象是一个父类对象。例如 Student is-a Person——每个学生对象同时也是一个人员对象,自然继承 Person 的全部特征。
        • 组合表达 has-a 关系:一个类对象拥有另一个类对象。例如 Stack has-a vectorQueue has-a list——栈组合了 vector,即栈对象内部持有一个 vector 对象,借此获得其接口或成员。(注:适配器与容器的关系也可视为 is-a)
        • 继承是白箱复用(white-box reuse):子类在父类基础上扩展,父类的内部细节 (公有public或保护protected成员) 对子类直接可见。这意味着父类的实现细节“暴露”给了子类,父类的任何改动都可能波及子类,封装性被削弱,耦合度高
        • 组合是黑箱复用(black-box reuse):一个类通过持有另一个类的对象来获得功能,只需关注被组合对象对外暴露的公有接口 (public成员),无需了解其内部实现。封装性得以保持,耦合度低

                软件工程中,代码交付后仍需应对Bug修复和功能迭代,可维护性至关重要。实际项目中,类被组织在不同模块中,模块通过编译链接生成静态库或动态库。真正需要维护的,本质上是模块之间的依赖关系(即类与类之间的关系)。

                模块间耦合度越低,可维护性越好。 举例说明:

        • 员工A负责的模块对外暴露100个接口,员工B的模块需要使用A的模块。一旦A的模块出现Bug或接口变更,B必须逐一排查受影响的调用点——依赖关系强,耦合度高,维护代价大。而员工C负责的模块同样有100个内部接口,但只对外暴露其中5个核心接口,其他内部接口被完全封装,调用关系和底层细节被屏蔽。B使用C的模块时只需关注这5个接口,只要核心接口不变,C内部如何重构都不影响B——依赖关系弱,耦合度低,维护代价小。

                由此引出软件工程的经典设计原则:低耦合,高内聚

        • 低耦合:模块之间的依赖关系尽可能弱——对外暴露的接口 (公有public成员) 越少越好。
        • 高内聚:模块内部各个类之间的关联尽可能紧密——功能相关性弱的部分应拆分到其他模块。

                实践中建议优先使用组合而非继承。但并非绝对:语义上确实是 is-a 关系时,应当使用继承;需要实现多态时,必须依赖继承 (因为多态基于虚函数和公有继承);既适合继承也适合组合时 (如Stack和vector),优先选择组合。

        代码示例 8.1

        // 轮胎和车更符合 has-a 的关系,组合/拥有
        // 轮胎
        class Tire
        {
        protected:
        string _brand = "Michelin"; // 品牌(米其林)
        size_t _size = 18; // 尺寸(18)
        };
        // 车
        class Car
        {
        protected:
        string _colour = "White"; // 颜色
        string _num = "ABC888"; // 车牌号
        // 车里面有轮胎。组合轮胎对象:复用 Tire 成员
        Tire _t1;
        Tire _t2;
        Tire _t3;
        Tire _t4;
        };

        // 宝马/奔驰和车更符合 is-a 的关系
        class BMW : public Car // 宝马 is—a 车,宝马是一辆车
        {
        public:
        void Drive()
        {
        cout << "操控性好" << endl;
        }
        };
        class Benz : public Car // 奔驰 is-a 车,奔驰是一辆车
        {
        public:
        void Drive()
        {
        cout << "舒适度高" << endl;
        }
        };

        // Stack 和 vector 的关系,既符合 is-a,也符合 has-a,推荐用组合
        template<class T>
        class vector
        { };

        // 栈是一个特殊的、后进先出的数组。Stack 继承父类 vector
        template<class T>
        class Stack : public vector<T>
        { };

        // 栈拥有一个数组。Stack 组合一个 vector 对象
        template<class T>
        class Stack
        {
        public:
        vector<T> _v;
        };

        赞(0)
        未经允许不得转载:171主机测评 » 【C++修炼】面向对象—继承
        分享到: 更多 (0)

        评论 抢沙发

        • 昵称 (必填)
        • 邮箱 (必填)
        • 网址