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

C++ 抽象基类深入解析

2021-12-056.3k 阅读

C++ 抽象基类的基本概念

抽象基类的定义

在 C++ 中,抽象基类是一种特殊的基类,它不能被实例化,主要目的是为派生类提供一个通用的接口。抽象基类至少包含一个纯虚函数。纯虚函数是在基类中声明的虚函数,它在基类中没有实现,只是定义了函数的原型,要求派生类必须重写该函数。

例如,定义一个抽象基类 Shape,它包含一个纯虚函数 draw

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

在上述代码中,Shape 类是一个抽象基类,因为它包含了纯虚函数 draw= 0 表示该函数是纯虚函数。

抽象基类的作用

  1. 定义通用接口:抽象基类为一系列相关的派生类定义了一个共同的接口。所有从抽象基类派生的类都必须实现抽象基类中的纯虚函数,这样就保证了这些派生类具有相同的对外接口。例如,对于上面定义的 Shape 抽象基类,任何从它派生的具体形状类(如 CircleRectangle 等)都必须实现 draw 函数,从而使得这些形状类都具有 draw 这个通用的绘制接口。
  2. 实现多态性:抽象基类是实现多态的重要基础。通过使用指向抽象基类的指针或引用,可以在运行时根据对象的实际类型来调用相应派生类的函数。这使得程序能够根据不同的情况动态地选择合适的行为,提高了程序的灵活性和可扩展性。

抽象基类与纯虚函数的深入理解

纯虚函数的特性

  1. 无实现体:纯虚函数在基类中只有函数声明,没有函数体。如前面 Shape 类中的 draw 函数,它在 Shape 类中没有具体的实现代码。
  2. 强制重写:派生类必须重写抽象基类中的纯虚函数,否则派生类也会成为抽象类。例如,定义一个从 Shape 派生的 Circle 类:
class Circle : public Shape {
private:
    double radius;
public:
    Circle(double r) : radius(r) {}
    void draw() const override {
        std::cout << "Drawing a circle with radius " << radius << std::endl;
    }
};

Circle 类中,重写了 draw 函数,这样 Circle 类就不再是抽象类,可以被实例化。如果 Circle 类没有重写 draw 函数,那么 Circle 类也将是抽象类,不能被实例化。

  1. 可在派生类中调用基类纯虚函数:虽然纯虚函数在基类中没有实现,但在派生类中可以通过 BaseClass::pureVirtualFunction() 的方式调用基类的纯虚函数。不过,这种情况通常用于在派生类的实现中需要复用基类纯虚函数的一些公共逻辑(尽管基类纯虚函数一般没有实现逻辑,但在某些设计场景下可能有部分初始化或公共操作)。例如:
class Shape {
public:
    virtual void draw() const = 0;
    virtual void init() const {
        std::cout << "Initializing shape" << std::endl;
    }
};

class Rectangle : public Shape {
private:
    double width, height;
public:
    Rectangle(double w, double h) : width(w), height(h) {}
    void draw() const override {
        Shape::init();
        std::cout << "Drawing a rectangle with width " << width << " and height " << height << std::endl;
    }
};

在上述代码中,Rectangle 类的 draw 函数先调用了 Shape 类的 init 函数,然后再执行自身的绘制逻辑。

抽象基类的内存布局

抽象基类虽然不能被实例化,但它依然影响派生类的内存布局。抽象基类中的纯虚函数通常通过虚函数表(vtable)来实现多态。当一个类包含虚函数(包括纯虚函数)时,编译器会为该类生成一个虚函数表,每个对象会包含一个指向虚函数表的指针(vptr)。

对于抽象基类,虚函数表中纯虚函数的位置会被标记为未定义或指向一个特殊的处理函数(通常是在运行时抛出异常,表示调用了未实现的纯虚函数)。当派生类重写了纯虚函数后,派生类的虚函数表中相应位置会被替换为派生类中该函数的实际实现地址。

例如,对于前面的 Shape 抽象基类和 Circle 派生类:

#include <iostream>

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

class Circle : public Shape {
private:
    double radius;
public:
    Circle(double r) : radius(r) {}
    void draw() const override {
        std::cout << "Drawing a circle with radius " << radius << std::endl;
    }
};

int main() {
    Shape* shapePtr;
    Circle circle(5.0);
    shapePtr = &circle;
    shapePtr->draw();
    return 0;
}

main 函数中,shapePtr 是一个指向 Shape 抽象基类的指针,它实际指向一个 Circle 对象。当调用 shapePtr->draw() 时,通过虚函数表机制,会找到 Circle 类中 draw 函数的实际实现并执行。这一过程涉及到对象的内存布局中 vptr 指针指向的虚函数表的查找和函数调用。

抽象基类的继承与多态实现

多重继承下的抽象基类

在 C++ 中,一个类可以从多个基类继承,当涉及到抽象基类时,多重继承会带来一些特殊的情况。例如,假设有两个抽象基类 AB,以及一个派生类 CAB 继承:

class A {
public:
    virtual void funcA() const = 0;
};

class B {
public:
    virtual void funcB() const = 0;
};

class C : public A, public B {
public:
    void funcA() const override {
        std::cout << "Implementing funcA in C" << std::endl;
    }
    void funcB() const override {
        std::cout << "Implementing funcB in C" << std::endl;
    }
};

在上述代码中,C 类必须同时实现 A 类的 funcA 函数和 B 类的 funcB 函数,否则 C 类将是抽象类。多重继承下的抽象基类可能会导致菱形继承问题,即一个派生类从多个路径继承同一个基类。例如:

class D {
public:
    virtual void funcD() const = 0;
};

class E : public D {
public:
    void funcD() const override {
        std::cout << "Implementing funcD in E" << std::endl;
    }
};

class F : public D {
public:
    void funcD() const override {
        std::cout << "Implementing funcD in F" << std::endl;
    }
};

class G : public E, public F {
public:
    // 如果不重写funcD,G将是抽象类,因为从E和F继承的funcD会有歧义
    void funcD() const override {
        std::cout << "Implementing funcD in G" << std::endl;
    }
};

在这个例子中,G 类从 EF 继承,而 EF 又都从 D 继承。如果 G 类不明确重写 funcD,会出现歧义,因为编译器不知道应该调用 E 类还是 F 类中重写的 funcD。通过在 G 类中再次重写 funcD,可以解决这个问题。

抽象基类与运行时多态

运行时多态是 C++ 中非常重要的特性,而抽象基类在其中扮演着关键角色。通过使用指向抽象基类的指针或引用,程序可以在运行时根据对象的实际类型来调用相应派生类的函数。

例如,有一个抽象基类 Animal,包含纯虚函数 makeSound,以及派生类 DogCat

class Animal {
public:
    virtual void makeSound() const = 0;
};

class Dog : public Animal {
public:
    void makeSound() const override {
        std::cout << "Woof!" << std::endl;
    }
};

class Cat : public Animal {
public:
    void makeSound() const override {
        std::cout << "Meow!" << std::endl;
    }
};

void makeAnimalSound(const Animal& animal) {
    animal.makeSound();
}

int main() {
    Dog dog;
    Cat cat;
    makeAnimalSound(dog);
    makeAnimalSound(cat);
    return 0;
}

在上述代码中,makeAnimalSound 函数接受一个 Animal 类的引用。当传入 Dog 对象时,会调用 Dog 类的 makeSound 函数,输出 “Woof!”;当传入 Cat 对象时,会调用 Cat 类的 makeSound 函数,输出 “Meow!”。这就是运行时多态的体现,通过抽象基类 Animal 的接口,根据实际对象的类型动态地调用相应的函数。

抽象基类在设计模式中的应用

抽象基类与策略模式

策略模式是一种行为设计模式,它定义了一系列算法,将每个算法封装起来,并使它们可以相互替换。抽象基类在策略模式中用于定义算法的通用接口。

例如,假设有一个计算运费的场景,不同的运输方式有不同的计算运费算法。可以定义一个抽象基类 ShippingStrategy 作为策略的抽象:

class ShippingStrategy {
public:
    virtual double calculateShippingCost(double weight) const = 0;
};

class GroundShipping : public ShippingStrategy {
public:
    double calculateShippingCost(double weight) const override {
        return weight * 0.5;
    }
};

class AirShipping : public ShippingStrategy {
public:
    double calculateShippingCost(double weight) const override {
        return weight * 1.2;
    }
};

class Order {
private:
    double weight;
    const ShippingStrategy* shippingStrategy;
public:
    Order(double w, const ShippingStrategy* s) : weight(w), shippingStrategy(s) {}
    double calculateTotalCost() const {
        return shippingStrategy->calculateShippingCost(weight);
    }
};

在上述代码中,ShippingStrategy 是一个抽象基类,定义了计算运费的接口 calculateShippingCostGroundShippingAirShipping 是具体的策略类,继承自 ShippingStrategy 并实现了 calculateShippingCost 函数。Order 类使用 ShippingStrategy 来计算运费,通过在构造函数中传入不同的 ShippingStrategy 对象,可以动态地选择不同的运费计算策略。

抽象基类与模板方法模式

模板方法模式定义了一个操作中的算法骨架,而将一些步骤延迟到子类中。抽象基类在模板方法模式中扮演着定义算法骨架的角色。

例如,假设有一个制作咖啡和茶的场景,制作过程有一些通用步骤和一些需要根据具体饮品定制的步骤。可以定义一个抽象基类 Beverage

class Beverage {
public:
    void prepareRecipe() {
        boilWater();
        brew();
        pourInCup();
        addCondiments();
    }
private:
    void boilWater() const {
        std::cout << "Boiling water" << std::endl;
    }
    void pourInCup() const {
        std::cout << "Pouring into cup" << std::endl;
    }
    virtual void brew() const = 0;
    virtual void addCondiments() const = 0;
};

class Coffee : public Beverage {
public:
    void brew() const override {
        std::cout << "Brewing coffee" << std::endl;
    }
    void addCondiments() const override {
        std::cout << "Adding sugar and milk to coffee" << std::endl;
    }
};

class Tea : public Beverage {
public:
    void brew() const override {
        std::cout << "Steeping tea" << std::endl;
    }
    void addCondiments() const override {
        std::cout << "Adding lemon to tea" << std::endl;
    }
};

在上述代码中,Beverage 类定义了制作饮品的通用算法骨架 prepareRecipe,其中 boilWaterpourInCup 是通用步骤,而 brewaddCondiments 是需要子类定制的步骤,定义为纯虚函数。CoffeeTea 类继承自 Beverage 并实现了 brewaddCondiments 函数,从而完成具体饮品的制作过程。

抽象基类的优缺点

优点

  1. 提高代码的可维护性和可扩展性:通过抽象基类定义通用接口,使得派生类具有一致的行为。当需要修改或扩展功能时,只需要在派生类中进行相应的修改,而不会影响到其他不相关的类。例如,在前面的 Shape 抽象基类的例子中,如果需要为形状添加一个新的功能,如计算面积,只需要在抽象基类中添加一个纯虚函数 calculateArea,然后在各个派生类中实现该函数即可,对现有代码的影响较小。
  2. 实现多态性:抽象基类是实现运行时多态的基础,使得程序能够根据对象的实际类型动态地选择合适的行为。这大大提高了程序的灵活性,能够适应不同的应用场景。如前面 Animal 抽象基类的例子,通过多态可以方便地处理不同类型的动物对象,而不需要为每种动物单独编写处理函数。
  3. 促进代码复用:抽象基类可以包含一些通用的成员函数和数据成员,这些成员可以被派生类复用。例如,在 Beverage 抽象基类中,boilWaterpourInCup 函数可以被 CoffeeTea 等派生类复用,减少了代码的重复。

缺点

  1. 增加了代码的复杂性:使用抽象基类需要对 C++ 的多态、虚函数等概念有深入的理解,否则容易出现错误。特别是在多重继承和复杂的继承层次结构中,抽象基类的使用可能会导致代码难以理解和维护。例如,在多重继承下的抽象基类可能会出现菱形继承问题,需要开发者仔细处理。
  2. 运行时开销:由于抽象基类通常依赖虚函数表来实现多态,这会带来一定的运行时开销。每个包含虚函数(包括纯虚函数)的对象都需要额外的空间来存储指向虚函数表的指针(vptr),并且在调用虚函数时需要通过虚函数表进行查找,这比直接调用普通函数的效率要低。
  3. 限制了对象的创建:抽象基类不能被实例化,这在某些情况下可能会带来不便。如果在程序设计中不小心将一些本应该可以实例化的类定义成了抽象基类,可能会导致设计上的不合理。

抽象基类使用的注意事项

抽象基类成员函数的访问控制

在抽象基类中,成员函数的访问控制需要谨慎考虑。对于纯虚函数,通常将其声明为 public,这样派生类可以重写该函数并提供外部可访问的接口。但对于一些辅助函数或只希望在派生类内部使用的函数,可以将其声明为 protected

例如,在 Shape 抽象基类中,如果有一个用于计算形状某些内部属性的辅助函数 calculateInternalProperty,可以将其声明为 protected

class Shape {
public:
    virtual void draw() const = 0;
protected:
    virtual double calculateInternalProperty() const {
        // 这里可以有一些通用的计算逻辑
        return 0.0;
    }
};

class Square : public Shape {
private:
    double sideLength;
public:
    Square(double s) : sideLength(s) {}
    void draw() const override {
        std::cout << "Drawing a square with side length " << sideLength << std::endl;
    }
    double calculateArea() const {
        double internalProp = calculateInternalProperty();
        // 使用internalProp进行面积计算
        return sideLength * sideLength;
    }
};

在上述代码中,calculateInternalProperty 函数被声明为 protectedSquare 类可以调用它来辅助计算面积,但外部代码无法直接访问该函数。

抽象基类的构造函数和析构函数

抽象基类可以有构造函数和析构函数。构造函数用于初始化抽象基类的成员变量,虽然抽象基类不能被实例化,但在派生类的构造过程中会调用抽象基类的构造函数。

例如:

class AbstractClass {
protected:
    int value;
public:
    AbstractClass(int v) : value(v) {}
    virtual void pureFunction() const = 0;
    virtual ~AbstractClass() {
        std::cout << "Destroying AbstractClass" << std::endl;
    }
};

class ConcreteClass : public AbstractClass {
public:
    ConcreteClass(int v) : AbstractClass(v) {}
    void pureFunction() const override {
        std::cout << "Implementing pureFunction in ConcreteClass with value " << value << std::endl;
    }
    ~ConcreteClass() {
        std::cout << "Destroying ConcreteClass" << std::endl;
    }
};

在上述代码中,AbstractClass 有构造函数和析构函数。ConcreteClass 在构造时会先调用 AbstractClass 的构造函数来初始化 value 成员变量。在析构时,会先调用 ConcreteClass 的析构函数,然后再调用 AbstractClass 的析构函数。

需要注意的是,如果抽象基类的析构函数不是虚函数,在通过指向抽象基类的指针删除派生类对象时,可能会导致内存泄漏。因为此时只会调用抽象基类的析构函数,而不会调用派生类的析构函数。所以,通常建议将抽象基类的析构函数声明为虚函数。

避免抽象基类的滥用

虽然抽象基类在程序设计中有很多优点,但也需要避免滥用。不要为了使用抽象基类而强行将一些没有明显共性的类抽象到一个基类中。如果抽象基类中只有很少的通用成员或接口,或者派生类之间的差异过大,使用抽象基类可能会使代码变得复杂而不实用。

例如,假设有一个 Car 类和一个 Tree 类,它们之间没有明显的共性,如果强行将它们抽象到一个基类中,可能会导致设计不合理。在设计抽象基类时,应该确保抽象基类确实能够提取出一系列相关类的共同特征和行为,并且这种抽象能够为程序带来实际的好处,如提高代码的复用性、可维护性和扩展性等。

通过对 C++ 抽象基类的深入解析,我们了解了其基本概念、与纯虚函数的关系、在继承和多态中的作用、在设计模式中的应用以及使用时的优缺点和注意事项。合理使用抽象基类能够极大地提升 C++ 程序的设计质量和可维护性,是 C++ 开发者必须掌握的重要技术之一。