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

C++ 类继承深入解析

2022-09-265.7k 阅读

类继承基础概念

在 C++ 中,类继承是一种重要的特性,它允许创建一个新类(称为派生类或子类),该类基于已有的类(称为基类或父类)。通过继承,派生类可以获得基类的成员(包括数据成员和成员函数),并且可以根据需要添加新的成员或修改继承来的成员。

假设有一个基类 Animal,它包含一些通用的属性和行为,例如 namespeak 函数:

class Animal {
public:
    std::string name;
    Animal(const std::string& n) : name(n) {}
    void speak() const {
        std::cout << name << " makes a sound." << std::endl;
    }
};

现在我们可以创建一个 Dog 类,它继承自 Animal 类:

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

在上述代码中,Dog 类通过 public 关键字从 Animal 类继承。这意味着 Dog 类可以访问 Animal 类的 public 成员。Dog 类不仅拥有 Animal 类的 name 成员和 speak 函数,还添加了自己特有的 bark 函数。

继承访问控制

public 继承

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

class Base {
public:
    int public_member;
protected:
    int protected_member;
private:
    int private_member;
};

class Derived : public Base {
public:
    void access_members() {
        public_member = 10; // 合法,public 成员在派生类中仍为 public
        protected_member = 20; // 合法,protected 成员在派生类中仍为 protected
        // private_member = 30; // 非法,private 成员在派生类中不可访问
    }
};

protected 继承

当使用 protected 继承时,基类的 publicprotected 成员在派生类中都变成 protected 成员,而 private 成员仍然不可访问。例如:

class Base {
public:
    int public_member;
protected:
    int protected_member;
private:
    int private_member;
};

class Derived : protected Base {
public:
    void access_members() {
        public_member = 10; // 合法,public 成员在派生类中变为 protected
        protected_member = 20; // 合法,protected 成员在派生类中仍为 protected
        // private_member = 30; // 非法,private 成员在派生类中不可访问
    }
};

class FurtherDerived : public Derived {
public:
    void access_grandparent_members() {
        // public_member = 10; // 非法,public 成员在 Derived 中变为 protected
        protected_member = 20; // 合法,protected 成员在 Derived 中仍为 protected
    }
};

private 继承

private 继承中,基类的 publicprotected 成员在派生类中都变成 private 成员,private 成员依旧不可访问。例如:

class Base {
public:
    int public_member;
protected:
    int protected_member;
private:
    int private_member;
};

class Derived : private Base {
public:
    void access_members() {
        public_member = 10; // 合法,public 成员在派生类中变为 private
        protected_member = 20; // 合法,protected 成员在派生类中变为 private
        // private_member = 30; // 非法,private 成员在派生类中不可访问
    }
};

class FurtherDerived : public Derived {
public:
    void access_grandparent_members() {
        // public_member = 10; // 非法,public 成员在 Derived 中变为 private
        // protected_member = 20; // 非法,protected 成员在 Derived 中变为 private
    }
};

继承中的构造函数和析构函数

基类构造函数的调用

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

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

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

在创建 Derived 对象时,会先输出 Base constructor called.,然后输出 Derived constructor called.

如果基类有带参数的构造函数,派生类构造函数必须显式调用基类的构造函数来传递参数。例如:

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

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

Derived 构造函数的初始化列表中,通过 Base(v) 调用了基类的构造函数,并传递了参数 v

析构函数的调用顺序

析构函数的调用顺序与构造函数相反。当派生类对象被销毁时,首先调用派生类的析构函数,然后调用基类的析构函数。这确保了派生类对象的资源先被释放,然后再释放基类对象的资源。例如,在上面的代码示例中,当 Derived 对象被销毁时,会先输出 Derived destructor called.,然后输出 Base destructor called.

多态性与虚函数

静态联编和动态联编

在 C++ 中,函数调用的绑定方式有两种:静态联编和动态联编。静态联编是在编译时确定要调用的函数,而动态联编是在运行时根据对象的实际类型来确定要调用的函数。

对于普通函数调用,C++ 采用静态联编。例如:

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

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

void call_print(Base& obj) {
    obj.print();
}

int main() {
    Base base;
    Derived derived;
    call_print(base); // 输出 "Base print."
    call_print(derived); // 输出 "Base print.",因为静态联编
    return 0;
}

在上述代码中,call_print 函数接受一个 Base 类的引用,无论传入的是 Base 对象还是 Derived 对象,都调用 Base 类的 print 函数,这是因为函数调用在编译时就确定了。

虚函数与动态联编

为了实现动态联编,我们需要使用虚函数。虚函数是在基类中声明的函数,使用 virtual 关键字修饰。派生类可以重写(override)虚函数。例如:

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

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

void call_print(Base& obj) {
    obj.print();
}

int main() {
    Base base;
    Derived derived;
    call_print(base); // 输出 "Base print."
    call_print(derived); // 输出 "Derived print.",因为动态联编
    return 0;
}

在上述代码中,Base 类的 print 函数被声明为虚函数,Derived 类重写了该函数。在 call_print 函数中,通过 Base 类引用调用 print 函数时,会根据对象的实际类型(运行时确定)来调用相应的 print 函数,实现了动态联编。

纯虚函数和抽象类

纯虚函数是在基类中声明但没有实现的虚函数,其声明形式为在函数声明后加上 = 0。包含纯虚函数的类称为抽象类。抽象类不能直接创建对象,只能作为基类被派生类继承。例如:

class Shape {
public:
    virtual double area() const = 0; // 纯虚函数
};

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

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

在上述代码中,Shape 类是一个抽象类,因为它包含纯虚函数 areaCircleRectangle 类继承自 Shape 类,并实现了 area 函数,从而可以创建对象。

多重继承

多重继承的概念

多重继承允许一个派生类从多个基类继承成员。语法上,派生类在继承列表中列出多个基类,用逗号分隔。例如:

class A {
public:
    void func_a() {
        std::cout << "Function from class A." << std::endl;
    }
};

class B {
public:
    void func_b() {
        std::cout << "Function from class B." << std::endl;
    }
};

class C : public A, public B {
public:
    void func_c() {
        std::cout << "Function from class C." << std::endl;
    }
};

在上述代码中,C 类从 A 类和 B 类多重继承,因此 C 类对象可以调用 func_afunc_bfunc_c 函数。

菱形继承问题

多重继承可能会导致菱形继承问题。假设有以下继承关系:

class A {
public:
    int value;
};

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

class D : public B, public C {};

在上述继承结构中,D 类从 B 类和 C 类继承,而 B 类和 C 类又都从 A 类继承。这就形成了一个菱形结构。此时,D 类对象中会包含两份 A 类的成员,这可能导致命名冲突和内存浪费。例如,当访问 value 成员时,会出现歧义:

D d;
// d.value = 10; // 编译错误,因为有两份 A::value

虚继承解决菱形继承问题

为了解决菱形继承问题,可以使用虚继承。在继承时使用 virtual 关键字修饰基类。例如:

class A {
public:
    int value;
};

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

class D : public B, public C {};

通过虚继承,B 类和 C 类共享一份 A 类的子对象,从而避免了多重副本的问题。此时,D 类对象中只有一份 A 类的 value 成员,可以正常访问:

D d;
d.value = 10; // 合法

继承与模板

模板类的继承

模板类也可以进行继承。例如,我们可以定义一个模板基类 BaseTemplate,然后派生出 DerivedTemplate

template <typename T>
class BaseTemplate {
public:
    T data;
    BaseTemplate(const T& d) : data(d) {}
    void print_data() const {
        std::cout << "BaseTemplate data: " << data << std::endl;
    }
};

template <typename T>
class DerivedTemplate : public BaseTemplate<T> {
public:
    DerivedTemplate(const T& d) : BaseTemplate<T>(d) {}
    void additional_function() const {
        std::cout << "This is an additional function in DerivedTemplate." << std::endl;
    }
};

在上述代码中,DerivedTemplate 继承自 BaseTemplate,并根据模板参数 T 实例化。

继承中的模板参数

在继承关系中,派生类可以使用基类的模板参数,也可以定义自己的模板参数。例如:

template <typename T1, typename T2>
class BaseWithTwoTemplates {
public:
    T1 data1;
    T2 data2;
    BaseWithTwoTemplates(const T1& d1, const T2& d2) : data1(d1), data2(d2) {}
    void print_data() const {
        std::cout << "BaseWithTwoTemplates data1: " << data1 << ", data2: " << data2 << std::endl;
    }
};

template <typename T1, typename T2, typename T3>
class DerivedWithThreeTemplates : public BaseWithTwoTemplates<T1, T2> {
public:
    T3 data3;
    DerivedWithThreeTemplates(const T1& d1, const T2& d2, const T3& d3)
        : BaseWithTwoTemplates<T1, T2>(d1, d2), data3(d3) {}
    void print_all_data() const {
        this->print_data();
        std::cout << "DerivedWithThreeTemplates data3: " << data3 << std::endl;
    }
};

在上述代码中,DerivedWithThreeTemplates 继承自 BaseWithTwoTemplates,并添加了一个新的模板参数 T3 和相应的数据成员 data3

继承中的类型转换

向上转型

向上转型是指将派生类对象转换为基类对象或基类引用。这是安全的,因为派生类对象包含基类对象的所有成员。例如:

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

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

void call_base_function(Base& obj) {
    obj.base_function();
}

int main() {
    Derived derived;
    Base& base_ref = derived; // 向上转型
    call_base_function(derived); // 也可以直接传入派生类对象,自动向上转型
    return 0;
}

在上述代码中,将 Derived 对象转换为 Base 引用,从而可以调用 Base 类的函数。

向下转型

向下转型是指将基类对象或基类引用转换为派生类对象或派生类引用。这通常是不安全的,因为基类对象可能不是实际的派生类对象。在 C++ 中,可以使用 dynamic_cast 进行安全的向下转型。例如:

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

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

void call_derived_function(Base& obj) {
    Derived* derived_ptr = dynamic_cast<Derived*>(&obj);
    if (derived_ptr) {
        derived_ptr->derived_specific_function();
    } else {
        std::cout << "Object is not of type Derived." << std::endl;
    }
}

int main() {
    Base base;
    Derived derived;
    call_derived_function(base); // 输出 "Object is not of type Derived."
    call_derived_function(derived); // 输出 "Derived specific function."
    return 0;
}

在上述代码中,dynamic_cast 尝试将 Base 引用转换为 Derived 指针,如果转换成功(对象实际是 Derived 类型),则可以调用 Derived 类特有的函数。如果转换失败,dynamic_cast 返回 nullptr

继承的设计原则

里氏替换原则

里氏替换原则是面向对象设计中的一个重要原则,它指出:如果对每一个类型为 T1 的对象 o1,都有类型为 T2 的对象 o2,使得以 T1 定义的所有程序 P 在所有的对象 o1 都替换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。

在 C++ 继承中,这意味着派生类对象应该能够替换基类对象,而不会影响程序的正确性。例如,在前面关于虚函数和动态联编的例子中,Derived 类对象可以替换 Base 类对象传递给 call_print 函数,并且程序行为符合预期。

组合优于继承

虽然继承是一种强大的特性,但在某些情况下,组合可能是更好的选择。组合是指一个类包含另一个类的对象作为成员。例如,假设我们有一个 Engine 类和一个 Car 类,Car 类需要使用 Engine 类的功能。我们可以通过组合来实现:

class Engine {
public:
    void start() {
        std::cout << "Engine started." << std::endl;
    }
    void stop() {
        std::cout << "Engine stopped." << std::endl;
    }
};

class Car {
public:
    Engine engine;
    void start_car() {
        engine.start();
    }
    void stop_car() {
        engine.stop();
    }
};

相比之下,如果使用继承,可能会导致不必要的复杂性和紧密耦合。组合更加灵活,因为 Car 类可以轻松地更换 Engine 类型,而不需要修改 Car 类的继承结构。

接口继承与实现继承

在 C++ 中,继承可以分为接口继承和实现继承。接口继承是指派生类只继承基类的函数声明(通常通过纯虚函数实现),而不继承实现。实现继承是指派生类继承基类的函数实现。

例如,Shape 类与 CircleRectangle 类的关系就是接口继承,Shape 类定义了 area 函数的接口,但没有实现,CircleRectangle 类实现了该接口。而在一些情况下,如 Dog 类继承 Animal 类,Dog 类既继承了 Animal 类的接口(如 speak 函数),也继承了部分实现(如果 Animal 类的 speak 函数有默认实现)。在设计时,应该明确区分接口继承和实现继承,以提高代码的可维护性和扩展性。

通过深入理解 C++ 类继承的各个方面,包括基础概念、访问控制、构造与析构函数、多态性、多重继承、继承与模板、类型转换以及设计原则等,开发者能够更加灵活和高效地使用继承特性,构建出健壮、可维护的 C++ 程序。在实际应用中,需要根据具体的需求和场景,合理选择继承方式,并遵循相关的设计原则,以避免潜在的问题和复杂性。