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

C++类的继承与派生机制

2024-08-014.5k 阅读

C++ 类的继承与派生机制

继承的基本概念

在 C++ 中,继承是一种重要的特性,它允许我们基于已有的类创建新的类。被继承的类称为基类(base class)或父类,新创建的类称为派生类(derived class)或子类。通过继承,派生类可以获得基类的成员(包括数据成员和成员函数),并可以在此基础上添加新的成员或重写基类的成员。

继承的语法形式如下:

class DerivedClass : access-specifier BaseClass {
    // 派生类的成员声明
};

其中,access - specifier 是访问说明符,用于指定基类成员在派生类中的访问权限,常见的访问说明符有 publicprivateprotected

访问说明符对继承的影响

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

示例代码:

class Base {
public:
    int publicData;
protected:
    int protectedData;
private:
    int privateData;
};

class PublicDerived : public Base {
public:
    void accessBaseMembers() {
        publicData = 10; // 合法,public 成员在派生类中仍为 public
        protectedData = 20; // 合法,protected 成员在派生类中仍为 protected
        // privateData = 30; // 非法,private 成员在派生类中不可访问
    }
};
  1. private 继承 当使用 private 继承时,基类的 publicprotected 成员在派生类中都变为 private 成员,而 private 成员仍然不可访问。

示例代码:

class PrivateDerived : private Base {
public:
    void accessBaseMembers() {
        publicData = 10; // 合法,public 成员在派生类中变为 private
        protectedData = 20; // 合法,protected 成员在派生类中变为 private
        // privateData = 30; // 非法,private 成员在派生类中不可访问
    }
};
  1. protected 继承 在 protected 继承下,基类的 publicprotected 成员在派生类中都变为 protected 成员,private 成员依旧不可访问。

示例代码:

class ProtectedDerived : protected Base {
public:
    void accessBaseMembers() {
        publicData = 10; // 合法,public 成员在派生类中变为 protected
        protectedData = 20; // 合法,protected 成员在派生类中仍为 protected
        // privateData = 30; // 非法,private 成员在派生类中不可访问
    }
};

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

  1. 构造函数 派生类的构造函数需要负责初始化从基类继承过来的成员以及自身新增的成员。在派生类构造函数的初始化列表中,需要调用基类的构造函数来初始化基类部分。

示例代码:

class Base {
public:
    Base(int value) : baseData(value) {
        std::cout << "Base constructor called." << std::endl;
    }
    ~Base() {
        std::cout << "Base destructor called." << std::endl;
    }
private:
    int baseData;
};

class Derived : public Base {
public:
    Derived(int baseValue, int derivedValue) : Base(baseValue), derivedData(derivedValue) {
        std::cout << "Derived constructor called." << std::endl;
    }
    ~Derived() {
        std::cout << "Derived destructor called." << std::endl;
    }
private:
    int derivedData;
};

在上述代码中,Derived 类的构造函数通过初始化列表调用了 Base 类的构造函数来初始化 baseData,然后再初始化自身的 derivedData

  1. 析构函数 析构函数的调用顺序与构造函数相反。当派生类对象被销毁时,首先调用派生类的析构函数,然后调用基类的析构函数。

重写与多态

  1. 重写(Override) 在派生类中,可以重新定义基类中已经存在的虚函数(使用 virtual 关键字声明的函数),这称为函数重写。重写的函数必须与基类中的虚函数具有相同的函数名、参数列表和返回类型(协变返回类型除外)。

示例代码:

class Shape {
public:
    virtual double area() const {
        return 0.0;
    }
};

class Circle : public Shape {
public:
    Circle(double radius) : r(radius) {}
    double area() const override {
        return 3.14159 * r * r;
    }
private:
    double r;
};

class Rectangle : public Shape {
public:
    Rectangle(double width, double height) : w(width), h(height) {}
    double area() const override {
        return w * h;
    }
private:
    double w, h;
};

在这个例子中,CircleRectangle 类都重写了 Shape 类的 area 虚函数。

  1. 多态(Polymorphism) 多态是指通过基类指针或引用调用虚函数时,实际调用的是派生类中重写的函数,而不是基类中的函数。这使得程序能够根据对象的实际类型来决定执行哪个函数版本。

示例代码:

void printArea(const Shape& shape) {
    std::cout << "Area: " << shape.area() << std::endl;
}

int main() {
    Circle c(5.0);
    Rectangle r(4.0, 6.0);

    printArea(c);
    printArea(r);

    return 0;
}

main 函数中,printArea 函数接受一个 Shape 类的引用,通过这个引用调用 area 函数时,实际调用的是 CircleRectangle 类中重写的 area 函数,从而实现了多态。

多重继承

  1. 多重继承的概念 多重继承允许一个派生类从多个基类继承成员。语法形式如下:
class DerivedClass : access - specifier1 BaseClass1, access - specifier2 BaseClass2 {
    // 派生类的成员声明
};
  1. 多重继承的问题与解决方案 多重继承虽然提供了强大的功能,但也带来了一些问题,其中最典型的是菱形继承问题。

菱形继承问题: 当一个派生类从多个基类继承,而这些基类又继承自同一个基类时,就会出现菱形继承结构。在这种情况下,派生类中会存在多个相同基类的副本,导致数据冗余和访问歧义。

示例代码:

class A {
public:
    int data;
};

class B : public A {};
class C : public A {};

class D : public B, public C {
public:
    void accessData() {
        // 这里访问 data 会出现歧义,因为 B 和 C 都继承自 A
        // data = 10; // 错误,不明确访问哪个 A 中的 data
    }
};

解决方案 - 虚继承: 虚继承可以解决菱形继承问题。通过在继承时使用 virtual 关键字,使得从不同路径继承过来的基类共享同一个基类子对象,避免了数据冗余和访问歧义。

示例代码:

class A {
public:
    int data;
};

class B : virtual public A {};
class C : virtual public A {};

class D : public B, public C {
public:
    void accessData() {
        data = 10; // 现在可以明确访问共享的 A 中的 data
    }
};

在这个改进后的代码中,BC 都虚继承自 A,这样 D 中只存在一份 A 的子对象,避免了菱形继承带来的问题。

继承与模板

  1. 模板类的继承 模板类也可以进行继承。当一个模板类从另一个模板类继承时,需要注意模板参数的传递和处理。

示例代码:

template <typename T>
class BaseTemplate {
public:
    T value;
    BaseTemplate(T val) : value(val) {}
};

template <typename T>
class DerivedTemplate : public BaseTemplate<T> {
public:
    DerivedTemplate(T val) : BaseTemplate<T>(val) {}
    void printValue() {
        std::cout << "Value: " << this->value << std::endl;
    }
};

在这个例子中,DerivedTemplate 模板类继承自 BaseTemplate 模板类,并通过模板参数 T 保持一致性。

  1. 继承与模板元编程 继承在模板元编程中也有重要应用。通过继承和模板特化,可以实现编译时的计算和类型处理。

示例代码:

template <int N>
struct Factorial {
    static const int value = N * Factorial<N - 1>::value;
};

template <>
struct Factorial<0> {
    static const int value = 1;
};

在这个模板元编程的例子中,Factorial 模板类通过递归继承实现了编译时计算阶乘的功能。

继承体系中的类型转换

  1. 向上转换(Upcasting) 向上转换是指将派生类对象或指针转换为基类对象或指针。这种转换是安全的,因为派生类对象包含了基类对象的所有成员。

示例代码:

class Base {
public:
    void baseFunction() {
        std::cout << "Base function called." << std::endl;
    }
};

class Derived : public Base {
public:
    void derivedFunction() {
        std::cout << "Derived function called." << std::endl;
    }
};

int main() {
    Derived d;
    Base* basePtr = &d; // 向上转换,将派生类指针转换为基类指针
    basePtr->baseFunction();

    return 0;
}
  1. 向下转换(Downcasting) 向下转换是指将基类指针或引用转换为派生类指针或引用。这种转换需要格外小心,因为基类对象可能实际上并不是派生类对象,此时进行向下转换会导致未定义行为。

在 C++ 中,可以使用 dynamic_cast 进行安全的向下转换。dynamic_cast 在运行时检查转换是否安全,如果不安全则返回 nullptr(对于指针)或抛出 std::bad_cast 异常(对于引用)。

示例代码:

int main() {
    Base* basePtr = new Derived();
    Derived* derivedPtr = dynamic_cast<Derived*>(basePtr);
    if (derivedPtr) {
        derivedPtr->derivedFunction();
    } else {
        std::cout << "Downcast failed." << std::endl;
    }

    delete basePtr;
    return 0;
}

继承与代码维护和扩展性

  1. 代码维护 合理使用继承可以提高代码的可维护性。通过将公共的代码放在基类中,派生类只需关注自身特有的功能,这样当基类的代码需要修改时,所有派生类都会自动受益。但如果继承结构设计不合理,例如过度继承或继承层次过深,可能会导致代码维护困难。

  2. 扩展性 继承为代码的扩展性提供了便利。当需要添加新的功能时,可以通过创建新的派生类来实现,而无需修改基类和其他现有派生类的代码。这符合开闭原则(Open - Closed Principle),即软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。

继承与设计模式

  1. 继承在设计模式中的应用 许多设计模式都依赖于继承和多态来实现其功能。例如,在策略模式(Strategy Pattern)中,通过继承和重写基类的虚函数来实现不同的算法策略。

示例代码:

class Strategy {
public:
    virtual void execute() const = 0;
};

class ConcreteStrategyA : public Strategy {
public:
    void execute() const override {
        std::cout << "Executing Strategy A." << std::endl;
    }
};

class ConcreteStrategyB : public Strategy {
public:
    void execute() const override {
        std::cout << "Executing Strategy B." << std::endl;
    }
};

class Context {
public:
    Context(const Strategy* s) : strategy(s) {}
    void executeStrategy() const {
        strategy->execute();
    }
private:
    const Strategy* strategy;
};

在这个策略模式的示例中,ConcreteStrategyAConcreteStrategyB 继承自 Strategy 类,并重写了 execute 虚函数,Context 类通过组合 Strategy 类的对象来实现不同的策略执行。

  1. 继承与组合的选择 在设计中,除了继承,组合(Composition)也是一种重要的代码复用方式。组合是指一个类包含其他类的对象作为成员。与继承相比,组合更加灵活,因为它不会破坏封装性,并且可以在运行时动态改变包含的对象。

一般来说,如果两个类之间是 “is - a” 的关系,适合使用继承;如果是 “has - a” 的关系,适合使用组合。例如,“Circle is - a Shape”,适合用继承;而 “Car has - a Engine”,适合用组合。

继承的性能考虑

  1. 空间开销 继承会带来一定的空间开销。在派生类对象中,除了自身的成员,还需要存储从基类继承过来的成员。特别是在多重继承和菱形继承的情况下,可能会导致空间浪费。虚继承虽然解决了菱形继承的问题,但也会引入额外的空间开销,用于存储虚基类指针等信息。

  2. 时间开销 函数调用的时间开销在继承中也需要考虑。当通过基类指针或引用调用虚函数时,由于需要在运行时确定实际调用的函数版本,会带来一定的时间开销。这涉及到虚函数表(vtable)的查找过程。相比之下,非虚函数的调用在编译时就可以确定,执行效率更高。但虚函数带来的多态性是实现面向对象编程灵活性的重要手段,因此需要在性能和灵活性之间进行权衡。

在实际编程中,对于性能敏感的代码段,可以尽量减少虚函数的使用,或者通过优化虚函数表的布局等方式来提高性能。同时,在设计继承结构时,要充分考虑空间和时间开销,以确保程序的整体性能。

继承与异常处理

  1. 异常在继承体系中的传播 当派生类的成员函数抛出异常时,异常会按照调用栈向上传播。如果派生类函数重写了基类的虚函数,并且在重写函数中抛出异常,这个异常也会在通过基类指针或引用调用该函数时被正确捕获。

示例代码:

class Base {
public:
    virtual void virtualFunction() {
        try {
            // 可能抛出异常的代码
        } catch (const std::exception& e) {
            // 处理异常
        }
    }
};

class Derived : public Base {
public:
    void virtualFunction() override {
        throw std::runtime_error("Derived class exception");
    }
};

int main() {
    Base* basePtr = new Derived();
    try {
        basePtr->virtualFunction();
    } catch (const std::runtime_error& e) {
        std::cout << "Caught runtime error: " << e.what() << std::endl;
    }

    delete basePtr;
    return 0;
}

在这个例子中,Derived 类的 virtualFunction 抛出的异常被 main 函数中的 try - catch 块捕获。

  1. 异常安全的继承设计 在设计继承体系时,需要考虑异常安全。派生类的构造函数和析构函数应该确保在异常发生时,对象处于一致的状态。例如,构造函数在部分初始化成功后抛出异常,应该能够正确清理已经初始化的资源。析构函数不应该抛出异常,因为这可能导致程序崩溃。

同时,当重写基类的虚函数时,如果基类函数有异常规范(虽然 C++11 已弃用异常规范,但在旧代码中仍可能存在),派生类重写函数的异常规范应该与基类函数兼容,即派生类函数不能抛出比基类函数更多类型的异常。

总结

C++ 的继承与派生机制是面向对象编程的核心特性之一。它提供了代码复用、多态等强大功能,使得我们能够构建复杂而灵活的软件系统。然而,在使用继承时,需要充分考虑访问权限、构造函数和析构函数的调用顺序、重写与多态的实现、多重继承的问题及解决方案、继承与模板的结合、类型转换、代码维护和扩展性、设计模式中的应用、性能考虑以及异常处理等多个方面。只有合理设计继承结构,才能编写出高效、健壮且易于维护的 C++ 代码。通过不断实践和深入理解这些知识,开发者能够更好地利用 C++ 的继承机制来解决实际编程中的各种问题。