简介

面向对象编程的主要目的之一是提供可重用的代码。开发新项目时,重用经过测试的代码比重新编写代码要好得多,可以节省时间,避免在程序中引入错误。
传统的 C 函数库通过提供了一些函数的可重用性,如 strlen()、rand() 等函数。但函数库基本是不会提供源代码的,这就意味着无法根据自己特定的需求队函数进行扩展或修改,只能根据函数库的情况修改自己的程序。而且即使厂商提供了源代码,修改时也有一定的风险,如修改了函数的工作方式或改变了库函数之间的关系。
C++类提供了更高层次的重用性。目前,厂商提供类库,类库由类声明和实现构成。因为类包含数据表示和操作方法,因此提供了比函数库更完整的代码。通常,类库是以源代码的方式提供的,这意味着可以对其进行修改以满足需求。不过,C++提供了比修改代码更好的方法来扩展和修改类 —— 类继承。
继承能够从已有的类派生出新的类,而派生类继承了原有类(称为基类)的特征,包括方法。正如继承财产比自己白手起家更容易一样,通过继承派生的类通常比设计新类要容易得多。下面是通过继承可以完成的一些工作:

  • 在已有类的基础上添加新功能;
  • 给类添加数据;
  • 修改类方法的实现。

当然,可以通过复制原有类的代码,然后对其进行修改来完成上述工作,但继承机制只需要提供新加的特性,而不需要访问源代码就可以派生出类。因此,如果类库中只提供了类方法的头文件和编译后的代码,依旧可以使用继承机制根据苦衷的类派生出新的类。这样可以在不公开代码实现的情况下将自己的类分享给别人,同时允许他们在类中添加新特性。
从一个类派生出另一个类时,原有的类被称为基类,继承的类称为派生类。

一个简单的基类

首先声明一个简单的基类 —— Person:

  1. class Person {
  2. private:
  3. std::string name;
  4. long authenciation_id;
  5. public:
  6. Person(const std::string str, long id);
  7. ~Person();
  8. void doSomeThings() const;
  9. void walk() const;
  10. };
  11. Person::Person(const std::string str, long id) {
  12. name = str;
  13. authenciation_id = id;
  14. }
  15. Person::~Person() {
  16. std::cout << name << " destructor." << std::endl;
  17. }
  18. void Person::doSomeThings() const {
  19. std::cout << name << " do some things.\n";
  20. }
  21. void Person::walk() const {
  22. std::cout << name << " walk.\n";
  23. }

派生一个类

从 Person 类派生一个 Student 类:

  1. class Student:public Person {
  2. };

其中,冒号指出派生类 Student 的基类是 Person 类,public 表名 Person 是一个公有基类。派生类对象包含基类对象,使用公有派生可以让基类的公有成员称为派生类的公有成员;基类的私有部分也将称为派生类的一部分,但是只能通过基类的公有方法和保护方法来访问。
Student 对象将具有以下特征:

  • 派生类对象存储了基类的数据成员(派生类继承了基类的实现);
  • 派生类对象可以使用基类的方法(派生类继承了基类的接口)。

因此,Student 对象可以存储 name 和 authenciation_id,还可以使用 doSomeThings() 和 walk() 方法。
image.png
派生类中需要添加什么?

  • 派生类需要自己的构造函数;
  • 派生类需要根据需要添加额外的数据成员和成员函数。

在这个例子中,Student 类将添加两个额外的数据成员 —— 学号(stu_id)和学院(college),以及一个额外的成员函数 —— study()。

  1. class Student:public Person {
  2. private:
  3. long stu_id; // 学号
  4. std::string college; // 学院
  5. public:
  6. Student(const std::string name, long id, const std::string col, long s_id);
  7. Student(const Person & person, std::string col, long s_id);
  8. ~Student();
  9. void study() const;
  10. };

派生类的构造函数

派生类的构造函数必须给新成员和继承的成员提供数据。在第一个 Student 的构造函数中,每个成员对应一个形参;在第二个构造函数中使用了一个 Person 的参数。
派生类不能直接访问基类的私有成员,必须通过基类方法进行访问。例如,Student 的构造函数不能直接设置继承自 Person 的成员(name 和 authenciation_id),必须使用基类的公有方法来访问私有的基类成员。也就是说,派生类构造函数必须使用基类的构造函数。但是在穿件派生类对象时,程序首先创建基类对象,也就是说,在程序进入派生类构造函数之前,基类对象就已经创建完毕,因此以下代码不能初始化派生类对象中的基类成员:

  1. Student::Student(const std::string name, long id, const std::string col, long s_id) {
  2. Person(name, id);
  3. college = col;
  4. stu_id = s_id;
  5. }

在初始化类内的 const 成员与类引用成员时也遇到相似的问题,是采用 C++ 提供的成员初始化列表来解决的。对于想要创建派生类对象之前初始化基类对象的问题,同样采用成员初始化列表来完成这个工作:

  1. Student::Student(const Person & person, std::string col, long s_id) : Person(name, id){
  2. college = col;
  3. stu_id = s_id;
  4. }

其中 :Person_(_name, id_)_是成员初始化列表,它调用对应的 Person 构造函数。

Q:如果不调用基类的构造函数会怎么样?
A:如果不用成员初始化列表调用基类的构造函数,将会自动调用基类的默认构造函数,也就是说和下面的代码等价:

  1. Student::Student(const Person & person, std::string col, long s_id) : Person(){
  2. college = col;
  3. stu_id = s_id;
  4. }

对于有默认构造函数的基类来说是可以这样写的,但是由于我们声明的 Person 类没有定义不接受参数的默认构造函数,因此这里会报错。

接下来看一下第二个构造函数的实现代码:

  1. Student::Student(const Person & person, std::string col, long s_id) : Person(person){
  2. college = col;
  3. stu_id = s_id;
  4. }

这里讲 Person 对象传递给 Person 构造函数,将调用基类的复制构造函数,虽然基类没有定义复制构造函数,但是编译器会自动生成一个,因为这个类没有使用动态内存分配,因此不必担心会有问题。

派生类的构造函数的要点如下:

  • 首先初始化基类对象;
  • 派生类构造函数应通过成员初始化列表将基类信息传递给基类构造函数;
  • 派生类构造函数需要初始化派生类新增的数据成员。

创建派生类对象时,程序首先调用基类构造函数,然后再调用派生类构造函数。基类构造函数负责初始化继承自基类的数据成员;派生类构造函数主要初始化新增的数据成员。释放对象的顺序与创建对象的顺序相反,即先执行派生类的析构函数,然后在执行基类的析构函数。

使用派生类

int main() {
    using std::cout;
    using std::endl;
    Person p1 = Person("YouKa", 20220506);
    p1.doSomeThings();
    p1.walk();

    Student s1 = Student("MoZi", 20220507, "Computer", 202264);
    // 派生类调用基类方法
    s1.doSomeThings();
    s1.walk();
    // 派生类调用新增的成员函数
    s1.study();

    Student s2 = Student(p1, "Music", 202265);
    s2.doSomeThings();
    s2.walk();
    s2.study();
    return 0;
}
YouKa do some things.
YouKa walk.
MoZi do some things.
MoZi walk.
MoZi study in Computer
YouKa do some things.
YouKa walk.
YouKa study in Music
YouKa destructor.
YouKa destructor.
MoZi destructor.
MoZi destructor.
YouKa destructor.

派生类和基类的关系

  • 派生类对象可以使用基类的公有方法;
  • 基类指针可以指向派生类对象(重点!);
  • 基类引用可以指向派生类对象。

需要注意的是,基类指针或引用虽然可以指向派生类对象,但是依旧只能调用基类的公有方法,不能调用派生类的新增的公有方法。
通常,C++要求引用和指针类型与赋给的类型向匹配,但是这一规则对继承来说是个例外。然而这种例外是单向的,即不可以用派生类指针或引用指向基类对象。

注:这里的指向表示的是隐式赋值,依旧可以将基类对象强制类型转换为派生类对象,并赋值给派生类指针或引用。

这个规则是有道理的。例如,如果允许基类引用指向派生类对象,则可以使用基类引用为派生类对象调用基类的方法,因为派生类继承了基类的方法,所以这样做不会出现问题。如果可以将基类对象隐式赋给派生类引用,派生类引用调用派生类的方法时就会出现问题。

基类的引用或指针允许指向派生类对象会出现一些有趣的结果:

  • 如果某个函数的参数是基类的引用或者指针,则该函数可以接收基类对象,也可以接受派生类对象;
  • 学习虚函数之后,可以调用派生类重写后的函数。

    继承:is-a 关系

    派生类和基类之间的特殊关系是基于C++继承的底层模型的。实际上,C++有3种继承方式:公有继承、保护继承和私有继承。其中公有继承是最常用的方式,它建立一种 is-a 关系,即派生类对象也是一个基类对象,可以对基类对象执行的任何操作,也可以对派生类对象执行。因为派生类可以添加新特性,所以这种关系被称为 is-a-kind-of(是一种)关系更准确,但专业术语是 is-a。

来看一些不符 is-a 关系的例子。
公有继承不建立 has-a 关系,例如午餐可能包括水果,但通常午餐并不是水果,所以不能通过从 Fruit 类派生出 Lunch 类来在午餐中添加水果,在午餐中添加水果的正确方法是将其作为一种 has-a 关系:午餐中有水果。因此最容易的方式是将 Fruit 类对象作为 Lunch 类的数据成员。
公有继承不建立 is-like-a 关系,例如,荷花像一把把伞,但是荷花并不是伞,因此,不应从 Umbrella 类中派生 Lotus 类。继承可以在基类的基础上添加属性,但不能删除基类的属性。在有些情况下,可以设计一个包含共有特征的类,然后以 is-a 或 has-a 的关系,在这个类的基础上定义相关的类。
公有继承不建立 is-implemented-as-a(作为…来实现)的关系。例如,可以使用数组来实现栈,但从 Array 类派生出 Stack 类是不合适的,因为栈不是数组。另外,可以用其他方式实现栈,例如链表。正确的方法是,通过让栈包含一个私有的 Array 对象成员来隐藏数组实现。
公有继承不建立 use-a 关系。例如,计算机可以使用打印机,但从 Computer 类派生出 Printer 类是没有意义的。正确方法是,可以通过友元函数或类来处理 Printer 对象和 Computer 对象之间的通信。
在 C++ 中完全可以使用公有继承来建立 has-a、is-implemented-as-a 或 use-a 关系,然而这样做通常会导致编程方面的问题。因此,还是坚持使用 is-a 关系吧。