MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C++面向对象编程继承与多态

2023-06-043.7k 阅读

C++ 面向对象编程之继承

继承的基本概念

在 C++ 中,继承是一种重要的面向对象编程特性,它允许我们基于现有的类创建新的类。新创建的类称为派生类(或子类),而现有的类称为基类(或父类)。派生类继承了基类的成员(包括数据成员和成员函数),并且可以在此基础上添加新的成员或重写基类的成员。

继承的主要优点在于代码复用。通过继承,我们可以避免在多个类中重复编写相同的代码,从而提高开发效率和代码的可维护性。例如,假设我们有一个 Animal 类,包含一些通用的属性和行为,如 nameeat 方法。如果我们要创建 DogCat 类,它们都具有 Animal 类的共性,那么可以让 DogCat 类继承自 Animal 类,而不需要在 DogCat 类中重新实现这些共性。

继承的语法

在 C++ 中,定义派生类的语法如下:

class 派生类名 : 继承方式 基类名 {
    // 派生类成员声明
};

其中,继承方式可以是 publicprivateprotected。下面分别介绍这三种继承方式:

  1. public 继承:在 public 继承中,基类的 public 成员在派生类中仍然是 public 的,基类的 protected 成员在派生类中仍然是 protected 的,而基类的 private 成员在派生类中是不可访问的。

示例代码:

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

class Dog : public Animal {
public:
    void bark() {
        std::cout << name << " is barking." << std::endl;
    }
};

在上述代码中,Dog 类通过 public 继承自 Animal 类。因此,Dog 类可以访问 Animal 类的 public 成员 nameeat 方法。

  1. private 继承:在 private 继承中,基类的 publicprotected 成员在派生类中都变为 private 成员,而基类的 private 成员在派生类中仍然是不可访问的。

示例代码:

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

class Dog : private Animal {
public:
    void bark() {
        eat(); // 可以调用,因为在 private 继承下,eat 方法在派生类中变为 private 方法
        std::cout << name << " is barking." << std::endl;
    }
};

在这个例子中,Dog 类通过 private 继承自 Animal 类。虽然 Dog 类可以访问 Animal 类的 public 成员 nameeat 方法,但这些成员在 Dog 类中变为 private 成员,外部代码无法直接访问。

  1. protected 继承:在 protected 继承中,基类的 public 成员在派生类中变为 protected 成员,基类的 protected 成员在派生类中仍然是 protected 成员,而基类的 private 成员在派生类中是不可访问的。

示例代码:

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

class Dog : protected Animal {
public:
    void bark() {
        eat(); // 可以调用,因为在 protected 继承下,eat 方法在派生类中变为 protected 方法
        std::cout << name << " is barking." << std::endl;
    }
};

在该示例中,Dog 类通过 protected 继承自 Animal 类。Animal 类的 public 成员 nameeat 方法在 Dog 类中变为 protected 成员,派生类的成员函数可以访问这些成员,但外部代码无法直接访问。

基类和派生类的构造函数与析构函数

  1. 构造函数:当创建派生类对象时,首先会调用基类的构造函数,然后再调用派生类的构造函数。这是因为派生类对象包含了基类的部分,需要先初始化基类部分。

示例代码:

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

class Dog : public Animal {
public:
    Dog(const std::string& n) : Animal(n) {
        std::cout << "Dog constructor called." << std::endl;
    }
    void bark() {
        std::cout << name << " is barking." << std::endl;
    }
};

在上述代码中,Dog 类的构造函数通过 Animal(n) 调用了 Animal 类的构造函数,先初始化 Animal 类的 name 成员,然后再执行 Dog 类构造函数的其他部分。

  1. 析构函数:与构造函数相反,当销毁派生类对象时,首先会调用派生类的析构函数,然后再调用基类的析构函数。这是为了确保在释放派生类特有的资源后,再释放基类的资源。

示例代码:

class Animal {
public:
    ~Animal() {
        std::cout << "Animal destructor called." << std::endl;
    }
};

class Dog : public Animal {
public:
    ~Dog() {
        std::cout << "Dog destructor called." << std::endl;
    }
};

在这个例子中,当 Dog 对象被销毁时,会先调用 Dog 类的析构函数,输出 “Dog destructor called.”,然后再调用 Animal 类的析构函数,输出 “Animal destructor called.”。

继承中的访问控制

  1. 访问基类的成员:在派生类中,根据继承方式的不同,可以访问基类的不同成员。如前面所述,public 继承下可以访问基类的 publicprotected 成员,private 继承下只能在派生类内部访问基类的 publicprotected 成员(它们在派生类中变为 private),protected 继承下派生类及其派生类可以访问基类的 publicprotected 成员(它们在派生类中变为 protected)。

  2. 隐藏基类成员:如果派生类中定义了与基类成员同名的成员(函数或变量),那么基类中的同名成员会被隐藏。即使它们的参数列表不同,基类的同名成员也会被隐藏。

示例代码:

class Animal {
public:
    void eat() {
        std::cout << "Animal eats." << std::endl;
    }
};

class Dog : public Animal {
public:
    void eat(int amount) {
        std::cout << "Dog eats " << amount << " units." << std::endl;
    }
};

在上述代码中,Dog 类定义了一个与 Animal 类中 eat 方法同名但参数列表不同的 eat 方法。此时,Animal 类的 eat 方法在 Dog 类中被隐藏。如果我们创建一个 Dog 对象并调用 eat 方法,只有 Dog 类的 eat(int amount) 方法会被调用。如果要调用 Animal 类的 eat 方法,可以使用作用域解析运算符 ::,如 dog.Animal::eat();

C++ 面向对象编程之多态

多态的基本概念

多态是面向对象编程的另一个重要特性,它允许我们以统一的方式处理不同类型的对象。多态通过基类指针或引用调用虚函数来实现。当我们使用基类指针或引用调用虚函数时,实际调用的是派生类中重写的虚函数,而不是基类中的虚函数,这使得程序能够根据对象的实际类型来决定执行哪个函数版本,从而实现动态绑定。

多态性提高了程序的灵活性和可扩展性。例如,我们有一个 Shape 基类,包含一个 draw 虚函数。然后我们有 CircleRectangle 等派生类,它们重写了 draw 函数以实现各自的绘制逻辑。我们可以使用 Shape 指针或引用的数组来存储不同类型的 Shape 对象,并通过调用 draw 函数来绘制这些对象,而无需关心它们的具体类型。

虚函数和纯虚函数

  1. 虚函数:在基类中使用 virtual 关键字声明的函数称为虚函数。派生类可以重写这些虚函数以提供特定的实现。

示例代码:

class Shape {
public:
    virtual void draw() {
        std::cout << "Drawing a shape." << std::endl;
    }
};

class Circle : public Shape {
public:
    void draw() override {
        std::cout << "Drawing a circle." << std::endl;
    }
};

class Rectangle : public Shape {
public:
    void draw() override {
        std::cout << "Drawing a rectangle." << std::endl;
    }
};

在上述代码中,Shape 类的 draw 函数被声明为虚函数。CircleRectangle 类重写了 draw 函数,并使用 override 关键字来显式表明这是对基类虚函数的重写。override 关键字是 C++11 引入的,它有助于编译器检测重写错误,如果派生类中声明的函数并非真正重写基类的虚函数,编译器会报错。

  1. 纯虚函数:纯虚函数是一种特殊的虚函数,它在基类中没有具体的实现,其声明格式为在函数声明后加上 = 0。包含纯虚函数的类称为抽象类,抽象类不能直接实例化对象,只能作为基类被其他类继承。

示例代码:

class Shape {
public:
    virtual void draw() = 0;
};

class Circle : public Shape {
public:
    void draw() override {
        std::cout << "Drawing a circle." << std::endl;
    }
};

class Rectangle : public Shape {
public:
    void draw() override {
        std::cout << "Drawing a rectangle." << std::endl;
    }
};

在这个例子中,Shape 类的 draw 函数是纯虚函数,因此 Shape 类是抽象类。CircleRectangle 类必须重写 draw 函数,否则它们也会成为抽象类。

动态绑定和静态绑定

  1. 静态绑定:静态绑定是指在编译时就确定函数调用的绑定方式。对于非虚函数,函数调用在编译时根据对象的类型来确定。例如:
class Animal {
public:
    void eat() {
        std::cout << "Animal eats." << std::endl;
    }
};

class Dog : public Animal {
public:
    void eat() {
        std::cout << "Dog eats." << std::endl;
    }
};

int main() {
    Dog dog;
    Animal* animalPtr = &dog;
    animalPtr->eat(); // 调用 Animal::eat(),因为 eat 不是虚函数,静态绑定
    return 0;
}

在上述代码中,虽然 animalPtr 指向一个 Dog 对象,但由于 eat 方法不是虚函数,所以调用的是 Animal 类的 eat 方法,这就是静态绑定。

  1. 动态绑定:动态绑定是指在运行时根据对象的实际类型来确定函数调用的绑定方式。对于虚函数,通过基类指针或引用调用时,会在运行时根据对象的实际类型来决定调用哪个派生类的虚函数版本。

示例代码:

class Animal {
public:
    virtual void eat() {
        std::cout << "Animal eats." << std::endl;
    }
};

class Dog : public Animal {
public:
    void eat() override {
        std::cout << "Dog eats." << std::endl;
    }
};

int main() {
    Dog dog;
    Animal* animalPtr = &dog;
    animalPtr->eat(); // 调用 Dog::eat(),因为 eat 是虚函数,动态绑定
    return 0;
}

在这个例子中,Animal 类的 eat 方法是虚函数,通过 animalPtr 调用 eat 方法时,会根据 animalPtr 实际指向的对象类型(这里是 Dog 对象)来调用 Dog 类的 eat 方法,这就是动态绑定。

多态的实现机制

在 C++ 中,多态的实现主要依赖于虚函数表(vtable)和虚函数表指针(vptr)。

  1. 虚函数表:每个包含虚函数的类都有一个虚函数表。虚函数表是一个数组,其中存储了类中虚函数的地址。当一个类继承自另一个包含虚函数的类时,派生类的虚函数表会继承基类的虚函数表,并根据需要重写虚函数的地址。

  2. 虚函数表指针:每个对象都有一个虚函数表指针,它指向该对象所属类的虚函数表。当通过基类指针或引用调用虚函数时,程序会首先通过虚函数表指针找到虚函数表,然后根据虚函数在表中的索引找到实际要调用的虚函数地址,并执行该函数。

示例代码:

class Base {
public:
    virtual void func1() {
        std::cout << "Base::func1" << std::endl;
    }
    virtual void func2() {
        std::cout << "Base::func2" << std::endl;
    }
};

class Derived : public Base {
public:
    void func1() override {
        std::cout << "Derived::func1" << std::endl;
    }
};

在上述代码中,Base 类有两个虚函数 func1func2,它会有一个虚函数表,表中存储了 func1func2 的地址。Derived 类继承自 Base 类,它的虚函数表继承了 Base 类的虚函数表,并将 func1 的地址替换为 Derived::func1 的地址。当创建 Derived 对象时,对象的虚函数表指针指向 Derived 类的虚函数表。当通过 Base 指针或引用调用 func1 时,会根据虚函数表指针找到 Derived 类的虚函数表,并调用 Derived::func1

多态的应用场景

  1. 图形绘制系统:如前面提到的 Shape 类及其派生类 CircleRectangle 等。通过多态,可以方便地管理和绘制不同类型的图形,提高代码的可维护性和扩展性。例如:
#include <vector>
#include <iostream>

class Shape {
public:
    virtual void draw() = 0;
};

class Circle : public Shape {
public:
    void draw() override {
        std::cout << "Drawing a circle." << std::endl;
    }
};

class Rectangle : public Shape {
public:
    void draw() override {
        std::cout << "Drawing a rectangle." << std::endl;
    }
};

int main() {
    std::vector<Shape*> shapes;
    shapes.push_back(new Circle());
    shapes.push_back(new Rectangle());

    for (Shape* shape : shapes) {
        shape->draw();
    }

    for (Shape* shape : shapes) {
        delete shape;
    }

    return 0;
}

在这个例子中,我们使用 Shape 指针的向量来存储不同类型的 Shape 对象,并通过调用 draw 函数来绘制它们,无需关心对象的具体类型。

  1. 游戏开发中的角色系统:假设有一个 Character 基类,包含 attack 虚函数。然后有 WarriorMage 等派生类,它们重写 attack 函数以实现不同的攻击方式。通过多态,可以方便地管理不同角色的攻击行为。

示例代码:

class Character {
public:
    virtual void attack() = 0;
};

class Warrior : public Character {
public:
    void attack() override {
        std::cout << "Warrior attacks with sword." << std::endl;
    }
};

class Mage : public Character {
public:
    void attack() override {
        std::cout << "Mage casts a spell." << std::endl;
    }
};

在游戏中,可以根据角色的实际类型调用相应的 attack 函数,实现多样化的游戏玩法。

  1. 插件系统:在一些大型软件中,插件系统可以利用多态来实现动态加载不同的插件。例如,有一个 Plugin 基类,包含 execute 纯虚函数。每个插件类继承自 Plugin 类并实现 execute 函数。主程序可以通过 Plugin 指针来调用不同插件的 execute 函数,实现插件的动态加载和执行。

示例代码:

class Plugin {
public:
    virtual void execute() = 0;
    virtual ~Plugin() {}
};

class Plugin1 : public Plugin {
public:
    void execute() override {
        std::cout << "Plugin1 is executing." << std::endl;
    }
};

class Plugin2 : public Plugin {
public:
    void execute() override {
        std::cout << "Plugin2 is executing." << std::endl;
    }
};

在主程序中,可以根据配置文件或用户选择动态创建相应的插件对象,并通过 Plugin 指针调用 execute 函数,实现插件的灵活加载和运行。

多态与指针和引用

  1. 通过指针实现多态:通过基类指针指向派生类对象,并调用虚函数,就可以实现多态。例如:
class Base {
public:
    virtual void print() {
        std::cout << "Base" << std::endl;
    }
};

class Derived : public Base {
public:
    void print() override {
        std::cout << "Derived" << std::endl;
    }
};

int main() {
    Base* basePtr = new Derived();
    basePtr->print(); // 调用 Derived::print()
    delete basePtr;
    return 0;
}

在上述代码中,basePtrBase 类型的指针,但指向了 Derived 对象,调用 print 虚函数时,实际执行的是 Derived 类的 print 函数。

  1. 通过引用实现多态:与指针类似,通过基类引用绑定派生类对象,并调用虚函数也能实现多态。

示例代码:

class Base {
public:
    virtual void print() {
        std::cout << "Base" << std::endl;
    }
};

class Derived : public Base {
public:
    void print() override {
        std::cout << "Derived" << std::endl;
    }
};

int main() {
    Derived derivedObj;
    Base& baseRef = derivedObj;
    baseRef.print(); // 调用 Derived::print()
    return 0;
}

在这个例子中,baseRefBase 类型的引用,绑定到 Derived 对象,调用 print 虚函数时,同样执行的是 Derived 类的 print 函数。

需要注意的是,如果直接通过派生类对象调用虚函数,不会体现多态性,因为此时函数调用在编译时就已经确定。例如:

class Base {
public:
    virtual void print() {
        std::cout << "Base" << std::endl;
    }
};

class Derived : public Base {
public:
    void print() override {
        std::cout << "Derived" << std::endl;
    }
};

int main() {
    Derived derivedObj;
    derivedObj.print(); // 调用 Derived::print(),但不是通过多态实现
    return 0;
}

在这种情况下,由于 derivedObjDerived 类型的对象,编译器在编译时就知道要调用 Derived 类的 print 函数,而不是通过运行时的动态绑定。

多态与函数重载和隐藏

  1. 多态与函数重载的区别:函数重载是指在同一个类中定义多个同名但参数列表不同的函数,函数重载是在编译时根据参数列表来确定调用哪个函数,属于静态绑定。而多态是通过基类指针或引用调用虚函数,在运行时根据对象的实际类型来确定调用哪个函数版本,属于动态绑定。

示例代码:

class Math {
public:
    int add(int a, int b) {
        return a + b;
    }
    double add(double a, double b) {
        return a + b;
    }
};

class Base {
public:
    virtual void print() {
        std::cout << "Base" << std::endl;
    }
};

class Derived : public Base {
public:
    void print() override {
        std::cout << "Derived" << std::endl;
    }
};

在上述代码中,Math 类的 add 函数是函数重载的例子,而 BaseDerived 类的 print 函数体现了多态。

  1. 多态与函数隐藏的关系:当派生类定义了与基类同名但参数列表不同的函数时,基类的同名函数会被隐藏,这与多态不同。多态要求函数签名(包括返回类型、函数名和参数列表)完全相同,并且通过基类指针或引用调用虚函数来实现动态绑定。如果派生类函数与基类函数同名但参数列表不同,即使基类函数是虚函数,也不会构成多态,而是函数隐藏。

示例代码:

class Base {
public:
    virtual void func(int a) {
        std::cout << "Base::func(int)" << std::endl;
    }
};

class Derived : public Base {
public:
    void func(double b) {
        std::cout << "Derived::func(double)" << std::endl;
    }
};

在这个例子中,Derived 类的 func(double b) 函数隐藏了 Base 类的 func(int a) 函数,通过 Derived 对象调用 func 函数时,只会调用 Derived 类的 func(double b) 函数,而不会体现多态。如果要调用 Base 类的 func(int a) 函数,需要使用作用域解析运算符 ::,如 derivedObj.Base::func(10);

通过深入理解继承与多态这两个 C++ 面向对象编程的重要特性,开发者可以编写出更加灵活、可维护和可扩展的代码,在处理复杂的软件系统时能够更加高效地组织和管理代码结构。无论是构建大型应用程序、游戏开发还是其他领域,继承与多态都发挥着至关重要的作用。同时,在实际应用中,需要注意继承方式、访问控制、虚函数的正确使用以及多态实现过程中的细节,以避免出现潜在的错误和问题。