C++ 继承(1):从代码复用、访问控制到对象切片与名字隐藏

头像

🔥 星恒随风: 个人主页
❄️ 个人专栏: 《指针合集》 《C语言基础》 《数据结构》 《机器学习导论》 《前端基础》 《python基础》 《C++从入门到入土》
✨ 数据即知识,压缩即智能

文章目录

前言

继承是 C++ 面向对象部分非常重要的一块内容。

在学习继承之前,我们已经接触过函数复用、模板复用和 STL。它们解决的都是“相同代码不要重复写”的问题,而继承解决的是类层面的复用。

例如,学生和老师都具有姓名、年龄、电话和地址,也都需要进行身份认证。如果分别在 StudentTeacher 中定义这些成员,不但代码重复,后续修改起来也比较麻烦。

继承允许我们把公共部分抽取到一个基类中,再让不同的派生类在此基础上继续扩展。

不过,继承并不只是把一个类的代码复制给另一个类。它还涉及:

  • 基类和派生类的关系
  • publicprotectedprivate 三种继承方式
  • 基类成员在派生类中的访问权限
  • 派生类对象向基类的转换
  • 对象切片
  • 同名成员隐藏
  • 类模板继承中的名字查找

接下来就让我们从对象模型和使用场景出发,一步一步理解。


一、为什么需要继承?

1.1 没有继承时的问题

假设我们需要设计学生类和教师类。

#include <iostream>
#include <string>
using namespace std;

class Student
{
public:
    void identity()
    {
        cout << "学生身份认证:" << _name << endl;
    }

    void study()
    {
        cout << _name << " 正在学习" << endl;
    }

protected:
    string _name;
    string _address;
    string _telephone;
    int _age = 18;

    int _studentId = 0;
};

class Teacher
{
public:
    void identity()
    {
        cout << "教师身份认证:" << _name << endl;
    }

    void teach()
    {
        cout << _name << " 正在授课" << endl;
    }

protected:
    string _name;
    string _address;
    string _telephone;
    int _age = 30;

    string _title;
};

这两个类中存在大量重复内容:

_name
_address
_telephone
_age
identity()

重复代码短期看起来没什么,但项目规模扩大以后,问题会慢慢出现。

例如,学校要求身份认证时同时打印电话号码,那么两个类都要修改。如果还有管理员、保安、实验员等类型,就需要在更多地方重复修改。

并且我们可以看到这里两个类很多地方显得很冗余,此时我们就可以用继承来对我们的代码进行一个复用,使得整个项目更加耦合

在这里插入图片描述

1.2 抽取公共基类

可以把公共成员放进 Person 类中:

#include <iostream>
#include <string>
using namespace std;

class Person
{
public:
    void identity() const
    {
        cout << "身份认证:" << _name << endl;
    }

protected:
    string _name;
    string _address;
    string _telephone;
    int _age = 0;
};

class Student : public Person
{
public:
    void study() const
    {
        cout << _name << " 正在学习" << endl;
    }

protected:
    int _studentId = 0;
};

class Teacher : public Person
{
public:
    void teach() const
    {
        cout << _name << " 正在授课" << endl;
    }

protected:
    string _title;
};

此时:

Person
├── Student
└── Teacher

Person 是基类,也叫父类。

StudentTeacher 是派生类,也叫子类。

派生类会保留基类部分,同时增加自己独有的数据和行为。

1.3 继承表达的是 is-a 关系

公有继承通常表示一种 is-a 关系。

Student is a Person
Teacher is a Person

也就是:

学生是一种人
教师也是一种人

如果一个关系读成“某某拥有某某”更自然,它通常不是继承,而是组合。

例如:

Car has a Tire
汽车拥有轮胎

此时更适合把 Tire 作为 Car 的成员,而不是让 Car 继承 Tire


二、继承的基本语法

2.1 定义格式

C++ 中继承的基本格式如下:

class 派生类名 : 继承方式 基类名
{
    // 派生类自己的成员
};

例如:

class Student : public Person
{
public:
    void study()
    {
        cout << "学习" << endl;
    }

private:
    int _studentId = 0;
};

其中:

Student:派生类
public:继承方式
Person:基类

在这里插入图片描述

2.2 三种继承方式

C++ 支持三种继承方式:

class A : public Base
{
};

class B : protected Base
{
};

class C : private Base
{
};

分别是:

public 继承
protected 继承
private 继承

实际开发中,最常见的是 public 继承。

protectedprivate 继承更多是在实现层面复用基类,并不公开表达“派生类也是一种基类”。

2.3 class 和 struct 的默认继承方式

如果没有明确写继承方式:

class Student : Person
{
};

class 默认使用 private 继承。

而:

struct Student : Person
{
};

struct 默认使用 public 继承。

虽然语法允许省略,但更推荐把继承方式写清楚:

class Student : public Person
{
};

读代码时不需要再回忆默认规则。


三、基类成员在派生类中的访问权限

这是继承中最容易混淆的一部分。
这里的主要规则就是二者相比取权限更小的那个。

需要同时区分两个概念:

基类成员原来的访问权限
派生类使用的继承方式

在这里插入图片描述

3.1 public 继承

class Derived : public Base
{
};

权限变化如下:

基类成员权限在派生类中的权限
publicpublic
protectedprotected
private不可直接访问

例如:

class Person
{
public:
    void identity()
    {
    }

protected:
    string _name;

private:
    int _age = 0;
};

class Student : public Person
{
public:
    void print()
    {
        identity();       // 可以访问基类 public 成员
        cout << _name;    // 可以访问基类 protected 成员

        // cout << _age;  // 错误:不能直接访问基类 private 成员
    }
};

3.2 protected 继承

class Derived : protected Base
{
};

权限变化如下:

基类成员权限在派生类中的权限
publicprotected
protectedprotected
private不可直接访问

基类原来的公有接口被收紧成了保护接口,只能在派生类及其后续派生类中使用。

3.3 private 继承

class Derived : private Base
{
};

权限变化如下:

基类成员权限在派生类中的权限
publicprivate
protectedprivate
private不可直接访问

基类中的公有成员和保护成员,都会变成派生类的私有成员。

3.4 一张表记住三种继承方式

基类成员public 继承protected 继承private 继承
publicpublicprotectedprivate
protectedprotectedprotectedprivate
private不可直接访问不可直接访问不可直接访问

可以把规则简单理解为:

继承方式决定了基类可访问成员在派生类中的最高公开程度。

3.5 private 成员到底有没有被继承?

基类的 private 成员依然存在于派生类对象的基类子对象中,只是派生类不能直接通过名字访问它。

例如:

class Person
{
public:
    void setAge(int age)
    {
        _age = age;
    }

    int getAge() const
    {
        return _age;
    }

private:
    int _age = 0;
};

class Student : public Person
{
};

虽然 Student 不能直接写:

_age = 20;

但它仍然可以通过基类提供的公有接口操作这部分数据:

Student student;

student.setAge(20);
cout << student.getAge() << endl;

因此,“不可见”不等于“对象中不存在”。

在这里插入图片描述


四、public、protected 和 private 应该怎么选?

4.1 类外需要访问:public

对外提供的稳定接口通常放在 public 区域。

class Person
{
public:
    void setName(const string& name)
    {
        _name = name;
    }

    const string& getName() const
    {
        return _name;
    }

private:
    string _name;
};

4.2 派生类需要访问:protected

有些数据不希望类外直接修改,但确实需要让派生类使用,可以放在 protected 区域。

class Person
{
protected:
    string _name;
};

不过 protected 也不要使用过多。

派生类直接依赖基类的数据布局,会增加两个类之间的耦合。很多时候,基类提供受控的 protected 成员函数,会比直接暴露成员变量更稳妥。

class Person
{
protected:
    const string& name() const
    {
        return _name;
    }

private:
    string _name;
};

4.3 只允许本类使用:private

普通成员变量通常优先放在 private 区域。

class Person
{
private:
    string _name;
    int _age = 0;
};

这样可以减少外部代码对内部实现的依赖。不过一般在实际工程中我们都不会这样使用


五、派生类对象向基类转换

5.1 派生类对象包含基类子对象

假设有下面两个类:

class Person
{
public:
    string _name;
    int _age = 0;
};

class Student : public Person
{
public:
    int _studentId = 0;
};

一个 Student 对象可以粗略理解为:

Student 对象
├── Person 基类部分
│   ├── _name
│   └── _age
└── Student 自己的部分
    └── _studentId

因此,在可访问的 public 继承中,派生类对象可以隐式转换成基类指针或基类引用。

在这里插入图片描述

5.2 转换成基类指针

Student student;

Person* personPtr = &student;

personPtr 指向的是 student 对象中的 Person 基类子对象。

此时可以访问 Person 提供的成员:

personPtr->_name = "小明";
personPtr->_age = 18;

但不能通过 Person* 访问学生独有的成员:

// personPtr->_studentId = 1001;  // 错误

5.3 转换成基类引用

Student student;

Person& personRef = student;

引用同样绑定到 Student 对象中的基类部分。

这种从派生类型向基类型的转换,通常称为向上转换:

upcasting

public 继承中,向上转换是自然且安全的。

5.4 基类对象不能自动变成派生类对象

下面的写法是错误的:

Person person;

// Student student = person;  // 编译错误

原因是 Person 对象中只有基类数据,并没有学生的学号等派生类数据。

编译器无法凭空补齐这些内容。


六、对象切片到底是什么?

“派生类转基类”经常和“对象切片”放在一起讲,但这两个概念不能完全画等号。
在这里插入图片描述

6.1 指针和引用转换不会切片

Student student;

Person* ptr = &student;
Person& ref = student;

这里没有创建新的 Person 对象。

ptrref 仍然指向原来的 Student 对象,只是通过基类视角访问它。

因此,这种情况不发生对象切片。

6.2 按值赋给基类对象会切片

Student student;
student._name = "小明";
student._studentId = 1001;

Person person = student;

这里会新建一个独立的 Person 对象,只复制 Student 中的基类部分。

学生独有的 _studentId 不会进入 person

可以理解为:

Student
├── Person 部分      ──────复制──────> Person 对象
└── Student 独有部分                    被舍弃

这才是真正的对象切片。

6.3 为什么函数参数常用引用?

假设函数写成按值传递:

void printPerson(Person person)
{
}

传入 Student 时会发生一次切片:

Student student;
printPerson(student);

如果只是读取,更适合写成:

void printPerson(const Person& person)
{
}

这样既避免拷贝,也不会切掉派生类部分。


七、继承体系中的作用域和名字隐藏

7.1 基类和派生类有独立作用域

基类和派生类不是同一个作用域。

如果派生类定义了和基类同名的成员,派生类成员会隐藏基类成员。

#include <iostream>
using namespace std;

class Person
{
protected:
    int _number = 111;
};

class Student : public Person
{
public:
    void print() const
    {
        cout << "基类编号:" << Person::_number << endl;
        cout << "学生学号:" << _number << endl;
    }

protected:
    int _number = 999;
};

Student 的作用域中:

_number

默认找到的是:

Student::_number

想访问基类版本,需要明确指定类域:

Person::_number

在这里插入图片描述

7.2 函数名相同也会构成隐藏

看下面的代码:

class Base
{
public:
    void func()
    {
        cout << "Base::func()" << endl;
    }
};

class Derived : public Base
{
public:
    void func(int value)
    {
        cout << "Derived::func(int): " << value << endl;
    }
};

调用:

Derived derived;

derived.func(10);
derived.func();

第二行会编译失败。

虽然两个函数的参数不同,但它们不在同一个作用域中,因此不构成重载。

Derived::func(int) 会隐藏基类中所有名为 func 的成员。

7.3 使用类域访问基类函数

可以明确指定基类作用域:

derived.Base::func();

也可以在派生类中使用 using,把基类重载引入派生类作用域:

class Derived : public Base
{
public:
    using Base::func;

    void func(int value)
    {
        cout << "Derived::func(int): " << value << endl;
    }
};

此时:

Derived derived;

derived.func();
derived.func(10);

两种调用都可以正常工作。

7.4 隐藏、重载和重写不要混淆

概念所处作用域主要条件
重载同一作用域函数名相同,参数列表不同
隐藏基类与派生类不同作用域派生类出现同名成员
重写基类与派生类基类虚函数与派生类函数满足重写条件

当前只需要先分清重载和隐藏。

重写与动态多态有关,可以放到后面的多态章节继续学习。


八、继承类模板时为什么经常要写 this->?

继承类模板时,会遇到一个比较特别的名字查找问题。

8.1 一个简单例子

#include <vector>
using namespace std;

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

private:
    vector<T> _data;
};

template<class T>
class LoggingBuffer : public Buffer<T>
{
public:
    void push(const T& value)
    {
        append(value);
    }
};

在某些编译器和标准规则下:

append(value);

可能无法被直接找到。

原因是:

Buffer<T>

属于依赖模板参数的基类。编译器第一次解析 LoggingBuffer 模板时,还不能确定基类中一定存在 append

8.2 使用 this->

可以写成:

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

this-> 告诉编译器:

这个名字属于当前对象,请等模板真正实例化时再查找。

8.3 使用基类类域

也可以明确指定基类:

template<class T>
class LoggingBuffer : public Buffer<T>
{
public:
    void push(const T& value)
    {
        Buffer<T>::append(value);
    }
};

实际代码中,this-> 通常更自然。


九、public 继承并不等于单纯复用代码

继承最容易被误用的地方,是只看到“能少写代码”,却忽略了类型关系。

假设为了复用 vector 的尾插接口,写出:

template<class T>
class Stack : public vector<T>
{
};

这样确实复用了 vector,但也产生了一个问题:

Stack is a vector?
栈是一种可以随机访问、任意插入和遍历的 vector?

显然并不准确。

栈只允许访问栈顶,而 vector 暴露了大量不符合栈语义的接口:

Stack<int> stack;

stack.push_back(1);
stack.insert(stack.begin(), 2);
stack[0] = 100;

这会破坏栈本应有的接口约束。

更合适的方式是组合:

template<class T>
class Stack
{
public:
    void push(const T& value)
    {
        _data.push_back(value);
    }

    void pop()
    {
        _data.pop_back();
    }

    T& top()
    {
        return _data.back();
    }

    bool empty() const
    {
        return _data.empty();
    }

private:
    vector<T> _data;
};

继承不只是实现复用,它还会建立类型关系。

只有当派生类确实可以当作基类使用时,public 继承才比较合适。

在这里插入图片描述


十、常见问题总结

10.1 派生类能直接访问基类 private 成员吗?

不能。

private 成员存在于基类子对象中,但派生类不能直接通过名字访问,只能通过基类提供的接口操作。

10.2 protected 和 private 有什么区别?

private 只能由当前类及其友元访问。

protected 除了当前类及其友元,还可以由派生类访问。

10.3 为什么实际中最常见的是 public 继承?

因为公有继承能够保留基类的公开接口,并表达稳定的 is-a 类型关系。

10.4 class 不写继承方式时默认是什么?

默认是 private 继承。

10.5 struct 不写继承方式时默认是什么?

默认是 public 继承。

10.6 派生类对象赋给基类引用会切片吗?

不会。

引用仍然绑定原来的派生类对象,只是以基类视角访问。

10.7 派生类对象赋给基类对象会切片吗?

会。

新基类对象只保留派生对象中的基类部分。

10.8 基类和派生类中的同名函数一定是重载吗?

不是。

重载要求函数处在同一作用域。基类和派生类中的同名函数通常构成隐藏关系。


Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐