继承和多态

继承、封装、多态是面向对象编程的三大基本特性,前面章节通过学习类和对象的使用我们已经掌握了封装的概念,而继承允许子类继承父类的属性和方法从而实现代码的复用和扩展;多态则是指在不同对象上调用相同的函数接口能表现出不同的行为。这篇笔记我们对C++中如何实现继承和多态进行介绍。

继承

C++中,子类可以通过: 继承修饰符 父类名的方式实现类继承。下面例子代码中,子类Dog继承父类Animal

#include <iostream>
#include <string>

class Animal {
public:
  Animal(std::string name) : name(name) {}
  std::string name;
  void eat() { std::cout << name << " is eating." << std::endl; }
};

class Dog : public Animal {
public:
  Dog(std::string name) : Animal(name) {}
  void bark() { std::cout << name << ": Woof! Woof!" << std::endl; }
};

int main() {
  Dog spike = Dog("Spike");
  spike.bark();
  spike.eat();
  return 0;
}

和其它OOP编程语言不同,C++的继承可以指定修饰符。public继承的表现和其它语言类似,但C++还支持protected继承和private继承。

继承方式 父类成员的访问权限在子类中的表现
public public → public, protected → protected
protected public/protected → protected
private public/protected → private

至于私有成员在子类中无法直接访问,但可以通过父类的公有方法间接访问。这里我们不要理解错了,私有成员并不是不继承,只是在C++语法层面被访问控制修饰符“隐藏”了,不能直接访问,从底层的内存分配角度观察,它实际上还是继承到了子类中的。

子类调用父类构造函数

C++中子类调用父类构造函数需要在子类构造函数的初始化列表中进行,之前代码就是这样实现的。

class Dog : public Animal {
public:
  Dog(std::string name) : Animal(name) {}
  void bark() { std::cout << name << ": Woof! Woof!" << std::endl; }
};

父类构造函数只能在子类构造函数的初始化列表中调用,不能在子类的函数体中调用,这是C++的语法规定,构造函数只能在对象构造的那一刻调用,而这发生在进入构造函数体之前,也就是在初始化列表中。不过不用担心,即使你需要在子类对父类构造参数进行一些加工也是可以实现的,我们将其包装为一个函数或是使用C++11的Lambda都可以实现,下面例子演示了这一写法。

class Dog : public Animal {
  std::string calcName(std::string name) { return "Mr. " + name; }

public:
  Dog(std::string name) : Animal(calcName(name)) {}
  void bark() { std::cout << name << ": Woof! Woof!" << std::endl; }
};

子类调用父类成员

C++中,子类访问父类的成员时需要使用作用域解析运算符::,不过如果子类没有同名变量时它是可以省略的,因此我们之前的代码在子类Dog中访问父类Animalname成员变量时就将其省略了,如果明确写出,其实它可以写作Animal::name

class Dog : public Animal {
public:
  Dog(std::string name) : Animal(name) {}
  void bark() { std::cout << Animal::name << ": Woof! Woof!" << std::endl; }
};

禁止继承

C++中提供了final关键字可以用于修饰一个类,表达该类禁止被继承。这个机制主要用于编译期检查,提高代码的安全性和可读性。如果一个类确实不应被继承,推荐添加final

class Animal final {
public:
  Animal(std::string name) : name(name) {}
  std::string name;
  void eat() { std::cout << name << " is eating." << std::endl; }
};

如果代码中一个类试图继承Animal,编译器将抛出错误。

多继承

C++支持多继承,一个类可以继承多个类,和单继承类似,如果多继承中成员标识符有冲突,我们在子类中访问成员时需要明确写出作用域解析运算符::。实际开发中,我们应该少用多继承,虽然C++提供了这样的功能,但后来的经验告诉我们这绝不是个好用的功能,尤其是要尽力避免菱形继承。

菱形继承会产生二义性问题,举例来说,基类动物有name成员变量,猫类和狗类都继承动物类,现在我们又定义了宠物类同时继承猫类和狗类,此时宠物类包含了两个继承自基类的name成员变量,这显然从逻辑上就产生了歧义。我们应该尽量避免这种代码的出现。

多态

C++中多态其实可以分为两种:

静态多态:函数重载和运算符重载属于静态多态,它们实现了函数名的复用

动态多态:基于派生类和虚函数可以实现动态多态,这种多态之所以叫动态多态是因为行为的差异在运行时体现

重写虚函数

函数重载和运算符重载前面我们已经介绍过了,这里我们主要关注动态多态。动态多态通常结合虚函数使用,虚函数允许我们通过指针或引用调用派生类中重写的函数而不是基类中的函数,从而表现出运行时多态。虚函数使用virtual关键字声明,派生类中重写时则需要使用override声明。下面例子演示了这一过程,Cat类和Dog类都派生自Animal类,它们都重写了虚函数bark。随后我们调用时,虽然操作的对象类型被声明为Animal,但它们表现出了不同的行为。

#include <iostream>
#include <string>

class Animal {
public:
  Animal(std::string name) : name(name) {}
  std::string name;
  void eat() { std::cout << name << " is eating." << std::endl; }
  virtual void bark() {};
};

class Dog : public Animal {
public:
  Dog(std::string name) : Animal(name) {}
  void bark() override {
    std::cout << Animal::name << ": Woof! Woof!" << std::endl;
  }
};

class Cat : public Animal {
public:
  Cat(std::string name) : Animal(name) {}
  void bark() override { std::cout << Animal::name << ": Meow!" << std::endl; }
};

int main() {
  Dog dog("Spike");
  Animal &spike = dog;
  spike.bark();

  Cat cat("Tom");
  Animal &tom = cat;
  tom.bark();

  return 0;
}

成员函数的重写需要满足一些条件:

  1. 函数名必须完全相同
  2. 参数列表必须完全相同
  3. 返回类型必须相同
  4. 基类的成员函数必须是虚函数
  5. 访问权限必须匹配

满足以上条件时才能实现重写。

注意:override关键字是C++11引入的特性,即使不加程序也没有问题,override关键字对程序实际的逻辑没有影响,它的存在意义是用于告知编译器该函数重写了虚函数,如果编译器检查发现实际情况不是这样就抛出错误,这样可以在编译阶段避免意外的函数隐藏等潜在Bug,因此建议涉及重写虚函数时都添加上该关键字。

补充:在C++11之前,没有override关键字,默认情况下也是可以"重写成功"的,但仍需要满足重写的条件。在MFC中我们可能还遇到过这样的写法:

class CMyWnd : public CWnd {
    virtual BOOL PreCreateWindow(CREATESTRUCT& cs);
};

MFC中使用virtual关键字标注了子类中用于重写的成员函数。这不是错误,但virtual是冗余的。MFC起源于上世纪九十年代,那时还没有override关键字,所以微软的文档中使用virtual关键字来标注重写意图,即使现在,MFC文档和遗留代码仍保持了这个风格以兼容旧项目,但在现代C++中,这是过时实践

虚函数是怎么实现的?实际上,编译器在处理虚函数时会生成一张虚函数表vtable,对象中会有一个指向vtable的指针叫作vptr。在运行时,该表会动态绑定真正应该执行的函数地址,当通过基类指针或引用调用虚函数时,程序会通过vptr查找vtable中对应函数的地址并调用,这样多态就实现了。需要注意的是,虚函数的开销主要来自于虚函数表指针vptr和间接函数调用的性能损失。每个包含虚函数的类都会有一个虚函数表,且每个该类的对象都会包含一个指向虚函数表的指针(通常占用一个指针大小的内存)。

禁止进一步重写

在子类重写虚函数的成员函数上标注final,表示这个停止这个函数的进一步重写。

class Dog : public Animal {
public:
    Dog(std::string name) : Animal(name) {}

    void bark() final {
        std::cout << Animal::name << ": Woof! Woof!" << std::endl;
    }
};

有些人这里也会写作override final,实际上这不符合final的设计初衷,final已经隐含了重写的语义,我们按照C++ Core Guidelines的C.128条中建议,每个虚函数声明只写三者之一virtualoverridefinal,对于“重写且为最终覆写”的函数,按准则只写final

抽象类和纯虚函数

之前代码其实还是有一些瑕疵,我们的基类Animal中的bark()包含了一个空的函数体,它根本没有意义。实际上,除了虚函数,C++还有一种没有实现的虚函数,被称为纯虚函数。纯虚函数没有函数体,它必须在派生类中重写,包含一个或多个纯虚函数的类称为抽象类,抽象类不能实例化。下面例子代码中,Animal是一个抽象类,它包含的bark()成员函数没有实现,是一个纯虚函数。

#include <iostream>
#include <string>

class Animal {
public:
  Animal(std::string name) : name(name) {}
  std::string name;
  void eat() { std::cout << name << " is eating." << std::endl; }
  virtual void bark() = 0;
};

class Dog : public Animal {
public:
  Dog(std::string name) : Animal(name) {}
  void bark() override {
    std::cout << Animal::name << ": Woof! Woof!" << std::endl;
  }
};

class Cat : public Animal {
public:
  Cat(std::string name) : Animal(name) {}
  void bark() override { std::cout << Animal::name << ": Meow!" << std::endl; }
};

int main() {
  Dog dog("Spike");
  Animal &spike = dog;
  spike.bark();

  Cat cat("Tom");
  Animal &tom = cat;
  tom.bark();

  return 0;
}

虚析构函数和纯虚析构函数

在使用多态时,如果子类有属性在堆区申请了内存,使用父类指针或引用时无法调用到子类的析构函数。这种情况下,我们需要在父类中添加虚析构函数或纯虚析构函数。下面例子代码中,父类Animal中添加了虚析构函数,当delete spike释放对象时,我们可以看到子类的析构函数被调用了。

#include <iostream>
#include <string>

class Animal {
public:
  Animal(std::string name) : name(name) {}
  std::string name;
  void eat() { std::cout << name << " is eating." << std::endl; }
  virtual void bark() = 0;
  virtual ~Animal() {};
};

class Dog : public Animal {
public:
  Dog(std::string name) : Animal(name) {}
  void bark() override {
    std::cout << Animal::name << ": Woof! Woof!" << std::endl;
  }
  ~Dog() { std::cout << "Dog destructor called for " << name << std::endl; }
};

int main() {
  Animal *spike = new Dog("Spike");
  spike->bark();
  spike->eat();
  delete spike;
  return 0;
}

至于纯虚析构函数,它是一种特殊的纯虚函数,其语法略显奇怪,纯虚析构函数的使用目的是使一个类成为抽象类同时确保派生类的析构函数能被正确调用。最为诡异的是虽然名义上是纯虚函数,但析构函数一定要有定义,因为在析构对象时仍然需要调用它,所以纯虚析构函数也要有定义,否则程序链接时会报错。总而言之这也是C++中一个比较怪异的语法,它的存在有其历史原因,我们记住即可。下面例子我们继续改造之前的代码,Animal的虚析构函数被我们改为了纯虚析构函数。

class Animal {
public:
  Animal(std::string name) : name(name) {}
  std::string name;
  void eat() { std::cout << name << " is eating." << std::endl; }
  virtual void bark() = 0;
  virtual ~Animal() = 0;
};

Animal::~Animal() {}

注意代码的最后一行,纯虚析构函数的定义我们将其放在了类的外部。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。