C++ 继承进阶梳理:权限、转换、隐藏和菱形继承

C++ 继承进阶梳理:权限、转换、隐藏和菱形继承

前言

我一开始学 C++ 继承时,很容易把它理解成"子类可以复用父类代码"。这个理解不能说错,但只记到这里会有两个问题。

第一个问题是代码设计上容易乱用继承。只要看到两个类有相同成员,就想抽一个基类出来,最后类之间的关系看起来很热闹,真正维护时却互相牵着走。

第二个问题是语法细节会突然变多:publicprotectedprivate 继承到底怎么影响成员访问?派生类对象为什么可以赋给基类指针?同名函数为什么不是重载而是隐藏?菱形继承为什么会出现两份基类成员?

这篇文章就围绕一个主线来整理:

text 复制代码
继承不是单纯少写几行代码,而是在表达一种类型关系。

只要这条线抓住,后面的权限、转换、对象模型和组合选择都会顺很多。

文章目录

一、先判断继承表达的关系

继承最自然的用法,是表达 is-a 关系:

text 复制代码
Student 是一种 Person。
Teacher 是一种 Person。

也就是说,派生类对象本身也是一种基类对象。这样设计时,基类负责放共同接口和共同状态,派生类负责扩展自己的特殊能力。

先看一个很小的例子:

cpp 复制代码
#include <iostream>
#include <string>
#include <utility>

class CampusMember {
public:
    explicit CampusMember(std::string name)
        : name_(std::move(name)) {}

    void checkIn() const {
        std::cout << name_ << " check in\n";
    }

protected:
    std::string name_;
};

class Student : public CampusMember {
public:
    Student(std::string name, int number)
        : CampusMember(std::move(name)), number_(number) {}

    void study() const {
        std::cout << name_ << " study, number = " << number_ << '\n';
    }

private:
    int number_;
};

int main() {
    Student student("Jack", 1001);

    student.checkIn();
    student.study();
}

编译运行:

bash 复制代码
g++ -std=c++17 -Wall -Wextra -pedantic inherit_basic.cpp -o inherit_basic
./inherit_basic

输出为:

text 复制代码
Jack check in
Jack study, number = 1001

这里我觉得要注意两点。

第一,Student : public CampusMember 不是在说"把 CampusMember 的代码复制一份到 Student 里",而是在说 Student 是一种 CampusMember。所以 Student 可以使用 CampusMember 的公共接口 checkIn()

第二,派生类构造函数初始化列表中的 CampusMember(std::move(name)),是在初始化派生类对象里的基类部分。一个 Student 对象并不是凭空只有 Student 自己的成员,它里面还有一块 CampusMember 子对象。

二、继承方式会改变基类成员的访问级别

继承定义一般写成这样:

cpp 复制代码
class Student : public CampusMember {
};

中间的 public 就是继承方式。它会影响基类的 publicprotected 成员,到了派生类之后还能以什么级别继续被使用。

可以先记这张表:

基类成员原来的权限 public 继承后 protected 继承后 private 继承后
public 仍然是 public 变成 protected 变成 private
protected 仍然是 protected 仍然是 protected 变成 private
private 派生类不可直接访问 派生类不可直接访问 派生类不可直接访问

这张表也可以压成一句话:

text 复制代码
基类 public/protected 成员在派生类中的访问级别,取二者中更严格的那个。

如果把访问限制从宽到严排一下:

text 复制代码
public > protected > private

例如基类里的 public 成员,遇到 protected 继承后,就会变成 protected;遇到 private 继承后,就会变成 private

这里容易忽略的是:基类的 private 成员也在派生类对象里面,只是派生类语法上不能直接访问它。它不是没被继承,而是被基类自己封装起来了。

所以如果一个成员不希望外部访问,但确实需要派生类使用,可以放到 protected。不过 protected 也不能乱用。它会让派生类知道基类内部细节,基类以后改动时,派生类更容易被影响。

还有两个小规则值得顺手记一下:

cpp 复制代码
class Derived : Base {
    // class 默认 private 继承
};

struct DerivedStruct : Base {
    // struct 默认 public 继承
};

虽然有默认值,但实际写代码时最好显式写出继承方式。尤其是 C++ 基础阶段,少让读代码的人猜。

三、派生类到基类转换:能切上去,不能随便切回来

public 继承还有一个很重要的行为:派生类对象可以赋给基类指针或基类引用。

因为 Student 是一种 CampusMember,所以这段代码是合理的:

cpp 复制代码
Student student("Jack", 1001);

CampusMember* memberPtr = &student;
CampusMember& memberRef = student;

基类指针和引用指向的是派生类对象中的"基类那一部分"。这个过程常被形象地叫作切片或切割。

不过这里要区分三种情况:

写法 是否可以 我怎么理解
派生类对象地址赋给基类指针 可以 指向派生类对象里的基类子对象
派生类对象绑定到基类引用 可以 引用派生类对象里的基类子对象
派生类对象赋给基类对象 可以,但会切片 只拷贝基类部分,派生类自己的成员被丢掉
基类对象直接赋给派生类对象 不可以 基类对象不一定有派生类那部分成员

示例:

cpp 复制代码
CampusMember baseCopy = student;

这行能编译,但 baseCopy 已经是一个独立的 CampusMember 对象,只保留了 CampusMember 那部分数据。Student 自己的 number_ 不会跟着过去。

反过来就不能直接写:

cpp 复制代码
// student = baseCopy; // 编译失败:基类对象不能直接赋给派生类对象

基类指针或引用如果要向下转回派生类,也不是随便强转就安全。只有当这个基类指针本来就指向某个派生类对象时,转回对应派生类才有意义。后面学多态时会遇到 dynamic_cast,它就是用来在运行期做更安全的识别。

这部分我会这样记:

text 复制代码
向上转型自然成立,向下转型必须确认对象真实类型。

四、作用域隐藏:同名函数不一定是重载

继承体系中,基类和派生类各有自己的作用域。如果派生类里定义了和基类同名的成员,派生类成员会隐藏基类同名成员。

最容易误解的是成员函数。只要函数名相同,就会形成隐藏,不要求参数列表相同。

下面这段代码用 using Worker::print; 把基类同名函数引入派生类作用域,这样两个 print 才能一起参与调用选择:

cpp 复制代码
#include <iostream>

class Worker {
public:
    void print() const {
        std::cout << "Worker::print()\n";
    }
};

class Intern : public Worker {
public:
    using Worker::print;

    void print(int id) const {
        std::cout << "Intern::print(" << id << ")\n";
    }
};

int main() {
    Intern intern;

    intern.print();
    intern.print(7);
}

输出为:

text 复制代码
Worker::print()
Intern::print(7)

如果去掉 using Worker::print;Intern::print(int) 会隐藏 Worker::print()。这时 intern.print() 找不到可调用的无参版本,代码会编译失败。

也可以在派生类成员函数里显式指定基类作用域:

cpp 复制代码
Worker::print();

但更重要的是设计上尽量避免在继承体系里写容易混淆的同名成员。代码能通过作用域限定解决,不代表读起来舒服。

五、派生类默认成员函数:基类部分也要被正确处理

派生类对象里包含基类子对象,所以构造、拷贝、赋值和析构都要处理两部分:

text 复制代码
基类部分
派生类自己的部分

看一个能直接观察顺序的例子:

cpp 复制代码
#include <iostream>
#include <string>
#include <utility>

class Person {
public:
    explicit Person(std::string name = "unknown")
        : name_(std::move(name)) {
        std::cout << "Person()\n";
    }

    Person(const Person& other)
        : name_(other.name_) {
        std::cout << "Person(const Person&)\n";
    }

    Person& operator=(const Person& other) {
        std::cout << "Person::operator=()\n";
        if (this != &other) {
            name_ = other.name_;
        }
        return *this;
    }

    ~Person() {
        std::cout << "~Person()\n";
    }

protected:
    std::string name_;
};

class Student : public Person {
public:
    Student(std::string name, int number)
        : Person(std::move(name)), number_(number) {
        std::cout << "Student()\n";
    }

    Student(const Student& other)
        : Person(other), number_(other.number_) {
        std::cout << "Student(const Student&)\n";
    }

    Student& operator=(const Student& other) {
        std::cout << "Student::operator=()\n";
        if (this != &other) {
            Person::operator=(other);
            number_ = other.number_;
        }
        return *this;
    }

    ~Student() {
        std::cout << "~Student()\n";
    }

private:
    int number_;
};

int main() {
    Student first("Jack", 1001);
    Student second(first);
    Student third("Rose", 1002);

    first = third;
}

这段代码里有几处值得专门看:

  1. 派生类构造函数要先调用基类构造函数,再初始化自己的成员。
  2. 派生类拷贝构造函数要调用基类拷贝构造函数,完成基类部分的拷贝。
  3. 派生类赋值运算符要显式调用 Person::operator=(other),否则只处理派生类自己的成员是不完整的。
  4. 析构时顺序反过来,先析构派生类部分,再自动析构基类部分。

运行时能看到大致顺序:

text 复制代码
Person()
Student()
Person(const Person&)
Student(const Student&)
Person()
Student()
Student::operator=()
Person::operator=()
~Student()
~Person()
~Student()
~Person()
~Student()
~Person()

如果某个类不希望被继续继承,C++11 之后可以直接用 final

cpp 复制代码
class Token final {
};

// class SessionToken : public Token {}; // 编译失败

早期也可以把基类构造函数私有化来阻止外部派生类正常构造,但现在 final 更直接,表达意图也更清楚。

六、继承类模板时,名字查找要更明确

课件里还有一个细节我以前容易略过:如果基类本身依赖模板参数,派生类模板里直接写基类成员名,编译器不一定会去依赖基类里找。

下面这个例子里,StackLike<T> 继承自 BufferBase<T>。在 push()top() 里访问基类成员时,我用了 this->

cpp 复制代码
#include <iostream>
#include <vector>

template <class T>
class BufferBase {
protected:
    void append(const T& value) {
        data_.push_back(value);
    }

    const T& back() const {
        return data_.back();
    }

private:
    std::vector<T> data_;
};

template <class T>
class StackLike : public BufferBase<T> {
public:
    void push(const T& value) {
        this->append(value);
    }

    const T& top() const {
        return this->back();
    }
};

int main() {
    StackLike<int> stack;
    stack.push(10);
    stack.push(20);

    std::cout << stack.top() << '\n';
}

输出为:

text 复制代码
20

这里的关键不是 StackLike 写得多像真正的栈,而是 this->append(value) 这个写法。因为 BufferBase<T> 依赖模板参数 T,编译器在模板定义阶段不会主动假设 append 一定来自这个依赖基类。

常见写法有两种:

cpp 复制代码
this->append(value);
BufferBase<T>::append(value);

我更喜欢先记 this->,因为它读起来像"从当前对象的继承体系里找这个成员"。

顺便提醒一句:标准库的 std::stack 不是继承 std::vector 实现的,而是容器适配器。也就是说,哪怕某些代码可以用继承写出来,设计上也不一定应该继承。这个判断会在最后的"继承和组合"里再收回来。

七、友元和静态成员:一个不继承,一个全体系共享

继承里还有两个容易被混在一起的小点。

第一个是友元关系不能继承。某个函数是基类的友元,只代表它能访问基类的私有或保护成员,不代表它自动获得派生类的私有或保护成员访问权。

cpp 复制代码
class Student;

class Person {
    friend void display(const Person& person, const Student& student);

protected:
    std::string name_;
};

class Student : public Person {
protected:
    int number_;
};

void display(const Person& person, const Student& student) {
    (void)person.name_;
    // (void)student.number_; // 编译失败:友元关系不会从 Person 继承到 Student
}

如果 display 确实需要访问 Student 的保护成员,就要在 Student 里也声明它为友元。不要把"能看基类内部"理解成"能看整个继承体系内部"。

第二个是静态成员。基类里的 static 成员在整个继承体系里只有一份,不会因为派生出很多类就复制很多份。

cpp 复制代码
#include <iostream>
#include <string>

class Person {
public:
    std::string name_;
    static int count_;
};

int Person::count_ = 0;

class Student : public Person {
};

int main() {
    Person person;
    Student student;

    std::cout << std::boolalpha;
    std::cout << (&person.name_ == &student.name_) << '\n';
    std::cout << (&Person::count_ == &Student::count_) << '\n';
}

输出为:

text 复制代码
false
true

普通成员属于每个对象,所以 person.name_student.name_ 地址不同。静态成员属于类这一层,Person::count_Student::count_ 指向同一份数据。

八、多继承和菱形继承:语法能写,不代表值得设计

单继承很好理解:一个派生类只有一个直接基类。

text 复制代码
Student -> Person

多继承则是一个派生类同时继承多个直接基类:

text 复制代码
Assistant -> Student
Assistant -> Teacher

多继承本身是 C++ 支持的语法,但它会让对象模型和访问规则复杂很多。先看一个地址偏移的例子:

cpp 复制代码
#include <iostream>

class Base1 {
public:
    int b1 = 1;
};

class Base2 {
public:
    int b2 = 2;
};

class Derived : public Base1, public Base2 {
public:
    int d = 3;
};

int main() {
    Derived object;

    Base1* p1 = &object;
    Base2* p2 = &object;
    Derived* p3 = &object;

    std::cout << static_cast<void*>(p1) << '\n';
    std::cout << static_cast<void*>(p2) << '\n';
    std::cout << static_cast<void*>(p3) << '\n';
}

在常见实现里,Base1 子对象通常放在前面,所以 p1p3 打印出来的地址可能一样;Base2 子对象在对象内部有偏移,所以 p2 可能是另一个地址。

这里不能死记某个固定地址关系,因为对象布局属于实现细节。但它能说明一个事实:多继承下,一个派生类对象内部可能有多个基类子对象,基类指针指向的不是同一个位置。

更麻烦的是菱形继承:

text 复制代码
        Person
       /      \
  Student    Teacher
       \      /
      Assistant

如果 StudentTeacher 都普通继承 Person,那么 Assistant 里面会有两份 Person 子对象:一份来自 Student,一份来自 Teacher

这会带来两个问题:

  1. 数据冗余:同一个 Assistant 对象里有两份 Person::name_
  2. 访问二义性:直接访问 assistant.name_ 时,编译器不知道你要哪一份。

只能显式指定路径:

cpp 复制代码
assistant.Student::name_ = "left";
assistant.Teacher::name_ = "right";

这个写法能消除二义性,但没有消除两份数据本身。

九、虚继承解决共享基类问题,但成本也更高

虚继承就是为了解决菱形继承中公共基类重复的问题。写法是在中间层继承公共基类时加上 virtual

cpp 复制代码
#include <iostream>
#include <string>
#include <utility>

class Person {
public:
    explicit Person(std::string name)
        : name_(std::move(name)) {}

    std::string name_;
};

class Student : virtual public Person {
public:
    Student(std::string name, int number)
        : Person(std::move(name)), number_(number) {}

protected:
    int number_;
};

class Teacher : virtual public Person {
public:
    Teacher(std::string name, int id)
        : Person(std::move(name)), id_(id) {}

protected:
    int id_;
};

class Assistant : public Student, public Teacher {
public:
    Assistant(std::string name, int number, int id)
        : Person(name), Student(name, number), Teacher(name, id) {}
};

int main() {
    Assistant assistant("Alex", 1001, 2001);
    assistant.name_ = "Alex Lee";

    std::cout << assistant.name_ << '\n';
}

输出为:

text 复制代码
Alex Lee

加了虚继承后,Assistant 里只保留一份共享的 Person 子对象,所以可以直接访问 assistant.name_

这里有个构造规则很关键:虚基类由最派生类负责初始化。上面 Assistant 的初始化列表里写了 Person(name),这才是最终那一份共享 Person 的初始化。Student(name, number)Teacher(name, id) 里也写了 Person(...),但当它们作为 Assistant 的中间基类时,这部分不会决定最终虚基类的初始化。

这也是为什么我不太想把虚继承当成常规设计工具。它能解决菱形继承问题,但对象布局、构造顺序、理解成本都会上来。基础阶段先知道它解决什么问题,比急着研究底层实现更有用。

十、继承和组合怎么选

最后还是要回到文章开头那条线:继承表达 is-a,组合表达 has-a

如果一个类"是一种"另一个类,继承比较自然:

text 复制代码
Student 是一种 Person。
BMW 是一种 Car。

如果一个类"拥有"另一个类,组合更自然:

text 复制代码
Car 拥有 Tire。
Computer 拥有 Disk。

组合示例:

cpp 复制代码
#include <iostream>
#include <string>
#include <utility>

class Tire {
public:
    explicit Tire(std::string brand)
        : brand_(std::move(brand)) {}

    void print() const {
        std::cout << brand_ << " tire\n";
    }

private:
    std::string brand_;
};

class Car {
public:
    explicit Car(std::string tireBrand)
        : tire_(std::move(tireBrand)) {}

    void checkTire() const {
        tire_.print();
    }

private:
    Tire tire_;
};

int main() {
    Car car("Michelin");
    car.checkTire();
}

输出为:

text 复制代码
Michelin tire

继承和组合都能复用代码,但它们暴露的信息不一样。

方式 关系 复用特点 维护感受
继承 is-a 派生类基于基类实现扩展 派生类更容易受基类变化影响
组合 has-a 通过成员对象完成功能组装 类之间依赖更清楚,封装更好

课件里提到继承有时被称为白箱复用,因为派生类能看到基类的部分内部细节;组合更像黑箱复用,组合类只通过对象接口使用能力,不需要知道内部实现。

所以我现在更愿意这样判断:

  1. 如果关系明确是 is-a,并且后面可能需要多态,继承是合理选择。
  2. 如果关系只是 has-a,用组合。
  3. 如果继承和组合都能做,优先想组合,除非继承能更准确表达类型关系。

这句话不是教条,但很适合防止"看见重复代码就继承"的冲动。

十一、复习时抓住这几句话

这篇继承内容比较多,我复习时不会从语法表开始背,而会先用几句话把主干搭起来。

第一,public 继承表达 is-a 关系。派生类对象可以当作基类对象使用,所以能自然转换成基类指针或引用。

第二,访问权限不是"继承后都能用"。基类 private 成员派生类不能直接访问;public/protected/private 继承方式会继续收紧基类成员在派生类里的访问级别。

第三,派生类对象里有基类子对象。构造时先基类再派生,析构时先派生再基类;拷贝构造和赋值运算符也要处理基类部分。

第四,同名成员会隐藏基类同名成员,成员函数只要名字相同就会隐藏,不要求参数列表相同。需要时用 Base::nameusing Base::name

第五,友元关系不会继承,静态成员在整个继承体系中共享一份。

第六,多继承会让对象模型变复杂,菱形继承会带来数据冗余和访问二义性。虚继承能解决共享基类问题,但理解和实现成本都更高,设计时尽量别主动走到这里。

最后还是那句我最想记住的话:

text 复制代码
继承不是为了把代码拿过来用,而是为了把类型关系表达清楚。

如果只是"拥有一个对象",组合通常更稳;如果确实是"一种对象",再去使用继承,后面的多态、接口复用和对象转换才会更自然。

相关推荐
(Charon)1 天前
【C++】多线程死锁:产生原因、复现与解决方法
开发语言·c++·算法
oier_Asad.Chen1 天前
【洛谷题解/AcWing题解/真题】洛谷P2330【SCOI2005】繁忙的都市(Kruskal算法的再探究))
c++·算法·贪心算法·图论·最小生成树·kruskal
我想走路带风1 天前
各自缓冲及实现
c++
阿米亚波1 天前
【C/C++包管理器】vcpkg(by microsoft)
c语言·c++·git·vscode·microsoft·github·vcpkg
SNAKEpc121381 天前
OpenGL(十一)- 变换管线
c语言·c++·算法·矩阵·图形渲染
牢姐与蒯1 天前
c++高级数据结构之图(基本概念,存储结构,BFS,DFS,最小生成树)
数据结构·c++·
c238561 天前
MySQL 基础用法(下):查询进阶与核心特性
android·c语言·c++·mysql
码匠许师傅1 天前
【C++ 面试真题】 C++ 中的 static 有什么作用?
c++·面试
郭涤生1 天前
C++ 零拷贝(Zero-Copy)
linux·开发语言·c++
乐观勇敢坚强的老彭1 天前
计算机内存中的堆和栈
c++