C++继承:掌握面向对象编程的核心技巧

前言:

继承让我们可以在已有类的基础上,快速构建出功能更加丰富的类,实现代码复用,同时表达“是一种”这样的层次关系。可以说,不会用继承,就无法真正驾驭 C++ 面向对象编程。

1. 为什么需要继承?


假设正在开发一个学校管理系统,需要表示“学生”和“教师”。两个类都有姓名、年龄、ID 等属性,都有“显示信息”的行为。如果各自写一遍,代码高度重复,而且以后要统一添加“联系方式”字段时,就必须修改多个类。

继承的思路是:抽象出共性,放在一个基类中;特有部分,交给派生类自己处理。例如“人”就是基类,学生和教师都是“人”的派生类。这样,基类中的代码只需要写一次。

2. 基本语法

// 基类
class Person {
public:
    std::string name;
    int age;
    
    void showInfo() {
        std::cout << "姓名:" << name << ",年龄:" << age;
    }
};

// 派生类,继承自 Person
class Student : public Person {   // public 继承
public:
    std::string studentID;        // 派生类特有的成员
    void study() {
        std::cout << name << " 在学习。\n";
    }
};


这里 class Student : public Person 的意思是:Student 以 public 方式 继承 Person。继承方式决定了基类成员在派生类中的访问权限。

继承方式如下:

基类成员权限  public 继承 protected 继承 private 继承
public     仍为 public变为 protected  变为 private
protected 仍为 protected变为 protected变为 private
private不可直接访问不可直接访问不可直接访问

实战中 99% 的情况都使用 public 继承,表达“Student 是一种 Person”。

3. 访问权限回顾与 protected

private:只有本类的成员函数和友元可以访问。

protected:本类、派生类的成员函数和友元可以访问,但类的使用者不能访问。

public:所有人都能访问。

正因为基类的 private 成员在派生类中“不可直接访问”,所以我们通常会把派生类可能需要用到的内部成员设为 protected,而不是直接设为 public(破坏封装)。

class Person {
protected:
    std::string id;   // 派生类可以访问,外部不能直接访问
public:
    Person(const std::string& id) : id(id) {}
};

class Student : public Person {
public:
    Student(const std::string& id, const std::string& sid)
        : Person(id), studentID(sid) {}
    void showID() {
        std::cout << "身份证:" << id << ",学号:" << studentID << "\n";
    }
private:
    std::string studentID;
};


注意:派生类的构造函数必须在初始化列表中显式调用基类的构造函数,否则会使用基类的默认构造函数。

4. 构造与析构顺序

当创建派生类对象时,构造函数的调用顺序是:

1.基类构造函数(先构造基类部分)

2.派生类自己的成员对象(按声明顺序)

3.派生类构造函数体

析构时顺序完全相反:先析构派生类,再析构基类部分。这是递归的,如果有多层继承,就沿着继承链逐层向上构造、向下析构。

class A {
public:
    A() { std::cout << "A 构造\n"; }
    ~A() { std::cout << "A 析构\n"; }
};
class B : public A {
public:
    B() { std::cout << "B 构造\n"; }
    ~B() { std::cout << "B 析构\n"; }
};

int main() {
    B obj;
    return 0;
}
// 输出:A 构造  B 构造  B 析构  A 析构


5. 名字隐藏与重定义


如果派生类定义了一个和基类同名的函数(或同名变量),会隐藏基类中所有同名函数(包括同名的重载版本)。

class Base {
public:
    void func() { std::cout << "Base::func()\n"; }
    void func(int x) { std::cout << "Base::func(int)\n"; }
};

class Derived : public Base {
public:
    void func() { std::cout << "Derived::func()\n"; } // 隐藏了基类的所有 func
};

Derived d;
d.func();     // OK,调用派生类版本
// d.func(10); // 编译错误!Base::func(int) 被隐藏了


如果想在派生类中同时保留基类的重载版本,需要用 using Base::func; 引入名字。

class Derived : public Base {
public:
    using Base::func; // 把基类的 func 重载纳入作用域
    void func() { std::cout << "Derived::func()\n"; }
};


这个点很容易踩坑。

6. 多态与虚函数

6.1 静态绑定与动态绑定

看一个场景:

class Animal {
public:
    void speak() { std::cout << "动物叫\n"; }
};
class Dog : public Animal {
public:
    void speak() { std::cout << "汪汪\n"; }
};

int main() {
    Animal* a = new Dog();
    a->speak();   // 输出??
    delete a;
}


输出结果是“动物叫”,而不是我们期望的“汪汪”。因为编译器在编译时根据指针的静态类型(Animal*)确定了调用哪个函数,这就是静态绑定,没有实现多态。

要使它能根据对象的实际类型调用正确的函数,必须使用虚函数:

class Animal {
public:
    virtual void speak() { std::cout << "动物叫\n"; }
};
class Dog : public Animal {
public:
    void speak() override { std::cout << "汪汪\n"; }
};


这样再执行 a->speak(),就会输出“汪汪”。virtual 关键字告诉编译器:这个函数是动态绑定的,运行时根据对象的真实类型决定调用哪个版本。

6.2 override 和 final

override:显式声明这个函数意图重写基类的虚函数。如果函数签名不匹配,编译器会报错(非常推荐加上,避免粗心失误)。

final:可以修饰虚函数或类,表示禁止被进一步重写,或禁止被继承。

class Cat : public Animal {
public:
    void speak() override final { std::cout << "喵喵\n"; }
    // 该类不能再被重写 speak
};

class Kitten : public Cat {
public:
    // void speak() override; // 编译错误!Cat::speak 标记为 final
};


7. 纯虚函数与抽象类


如果一个基类中的虚函数无法给出有意义的实现,我们可以把它声明为纯虚函数,类就变成抽象类。抽象类不能直接创建对象,只能作为基类派生出具体类。

class Shape {
public:
    virtual double area() const = 0;  // 纯虚函数
    virtual ~Shape() {}               // 虚析构函数(重要!)
};

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


为什么要将基类的析构函数设为虚函数?
当通过基类指针删除派生类对象时,如果析构函数不是虚的,则只会调用基类的析构函数,派生类部分可能无法正确释放资源。所以,只要一个类可能被继承,就应该将它的析构函数声明为虚函数(即使为空)。

8. 继承与组合:is-a 与 has-a


面向对象设计中有一个重要的准则:多用组合,少用继承。

继承表达的是“是一种”关系:Dog 是一种 Animal,所以用继承。

组合表达的是“有一个”关系:Car 有一个 Engine,所以把 Engine 作为 Car 的成员对象。

如果不加区分地滥用继承,会导致耦合过强,代码难以维护。比如,想让 Bird 具有飞翔的能力,如果直接用继承,可能会写出 FlyingBird 类,但随着需求变化(鸡也是鸟但不能飞,飞机不是鸟但能飞),继承层次会变得混乱。此时更好的方式是将“飞翔”抽象成一个接口(纯抽象类),如 IFlyable,然后让需要飞翔的类继承这个接口。这实际上是一种组合设计——实现多个接口。

9. 多重继承


C++ 支持从多个基类同时继承(多重继承),语法如下:

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


多重继承容易引发菱形继承问题(两个基类有共同的祖父类,派生类会包含两份祖父部分),C++ 提供了虚继承来解决,但整体较复杂。多数项目会通过接口(纯抽象类)来规避多重继承的复杂性。

10. 常见误区与注意事项


忘记虚析构函数
凡是作为基类的类,析构函数应该写成 virtual ~Base() {} 或 virtual ~Base() = default;,否则通过基类指针删除派生对象会导致资源泄漏。

隐藏而非重写
派生类定义了与基类同名的非虚函数,不会产生多态,只是隐藏。一定要区分“重定义(隐藏)”和“重写(覆盖)”。

重写时缺少 override
不写 override,函数签名稍有不同就会变成隐藏而非重写,且编译器不提示错误,排查困难。养成重写时加 override 的好习惯。

protected 的使用
不要为了派生类方便把不该暴露的成员设为 public,可以设为 protected 对外隐藏,但也要考虑 protected 破坏封装的程度,尽量保持数据成员的 private,通过 protected 成员函数提供受控访问。

派生类对象切片
如果把派生类对象赋值给基类对象(不是指针或引用),会发生对象切片,只保留基类部分,派生类特有的部分被切掉,且多态失效。

11. 小结


1.继承是实现代码复用和多态的基础,表达“is-a”关系。

2.派生类以 public 继承基类,可以访问 public 和 protected 成员,不能直接访问 private 成员。

3.构造顺序:基类 → 派生类成员 → 派生类构造体;析构顺序相反。

4.通过 virtual 虚函数实现多态,用基类指针/引用调用派生类重写的函数。

5.重写虚函数请加 override,基类析构函数务必为虚函数。

6.纯虚函数让类变成抽象类,抽象类不能实例化。

7.优先使用组合,继承只在确实满足“is-a”关系时使用。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值