C++ 继承机制详解
面向对象编程的三大特性之一是继承。封装屏蔽了底层细节,而继承则建立了类之间的层次关系。今天我们来深入聊聊继承在 C++ 中的具体表现,特别是关于私有成员的可见性、模板栈的实现细节、同名隐藏与重载的区别,以及派生类默认成员函数的调用顺序。
继承的基本定义与访问权限
假设我们有一个 Person 基类,包含姓名、地址等共性信息;而 Student 和 Teacher 作为子类,分别拥有学号和职称。我们可以这样设计:
class Person {
public:
// 身份认证逻辑
void identity() { cout << "void identity()" << _name << endl; }
protected:
string _name = "张三";
string _address;
string _tel;
private:
int _age = 18;
};
class Student : public Person {
public:
void study() { /* ... */ }
protected:
int _stuid;
};
class Teacher : public Person {
public:
void teaching() { /* ... */ }
protected:
string title;
};
这里有几个关键点需要注意:
- 私有成员的可见性:父类的
private成员在子类中是不可见的。但这并不意味着它们没有被继承。实际上,它们存在于子类对象内存中,只是语法上禁止直接访问。如果尝试在子类中访问_age,编译器会报错。 - 保护成员的作用:如果希望成员在类外不可见,但在子类中可访问,应使用
protected。这个限定符正是为了继承场景设计的。 - 有效访问权限:总结来说,父类成员在子类的访问方式取决于
Min(成员在父类的访问限定符,继承方式)。即public > protected > private。 - 默认继承方式:使用
class时默认为private继承,使用struct时默认为public继承。实践中绝大多数情况使用public继承。
基于继承的类模板实现栈
之前我们用容器适配器实现了栈,现在可以用继承的方式来实现。这涉及到模板实例化和作用域解析的问题。
如果在 push 成员函数中不指定类域会发生什么?当实例化 Keda::stack<int> st 时,编译器会自动实例化 vector<int>。如果在 push_back(x) 前不加限定,编译器向上查找时会找不到标识符,因为 push_back 属于基类模板成员。
正确的做法是指定作用域:
#include <vector>
using namespace std;
namespace Keda {
template<class T>
class stack : public std::vector<T> {
public:
void push(const T& x) {
vector<T>::push_back(x); // 显式指定基类作用域
}
void pop() {
vector<T>::pop_back();
}
const T& top() {
return vector<T>::back();
}
bool empty() {
return vector<T>::empty();
}
};
}
int main() {
Keda::stack<int> st;
st.push(1);
st.push(2);
while (!st.empty()) {
cout << st.top() << " ";
st.pop();
}
return 0;
}
这里涉及模板的按需实例化。编译器只有在真正用到某个地方(如 pop())时才会实例化对应的代码。如果想通过宏来灵活切换底层容器(如 vector, list, deque),可以在预处理阶段替换 CONTAINER 宏,这样编译后就是具体的容器类型,不会存在未定义的标识符问题。
赋值兼容与对象切片
在公有继承条件下,存在一些转换规则:
- 子类转父类:可以将子类对象的一部分'切割'出来拷贝给父类对象,或者赋值给父类指针/引用。这被称为对象切片(Slicing)。例如,将
Student对象传给Person引用,只会保留Person部分的数据。 - 父类转子类:父类对象不能直接赋值给子类对象。父类指针解引用强制转换为子类指针仅在指针实际指向子类对象时才安全。
继承中的作用域与名称隐藏
当子类与父类拥有同名成员时,会发生**隐藏(Hiding)**现象。这与函数重载不同,重载要求在同一作用域内参数不同,而隐藏发生在不同作用域。
#include <iostream>
#include <string>
using namespace std;
class Person {
protected:
string _name = "Keda";
int _num = 111;
};
class Student : public Person {
public:
void Print() {
cout << _num << endl; // 打印 999,子类成员屏蔽了父类
// cout << Person::_num << endl; // 若要访问父类需指定作用域
}
protected:
int _num = 999;
};
int main() {
Student s;
s.Print();
return 0;
}
上面代码输出 999。子类成员 _num 直接屏蔽了父类同名的 _num。如果想访问父类的,必须使用作用域解析运算符 Person::_num。
对于函数也是如此。如果基类和派生类都有同名函数,无论参数是否相同,派生类都会隐藏基类函数。这会导致调用时只找到派生类版本,若调用被隐藏的基类函数且未指定作用域,编译器会报错。
派生类的默认成员函数
派生类通常不需要手动编写所有默认成员函数,但理解其背后的行为至关重要。
1. 构造函数
子类构造函数必须初始化父类的那部分成员。如果父类没有默认构造函数,子类必须在初始化列表中显式调用父类构造函数。
// 初始化列表顺序:先基类,再子类成员
Student(const char* name, int num, const char* address)
: Person(name), _num(num), _address(address) {}
内置类型默认构造不初始化,自定义类型会调用其默认构造。继承自父类的成员被视为一个整体,优先调用父类构造。
2. 拷贝构造函数
对于自定义类型,拷贝构造会递归调用父类的拷贝构造。如果资源管理需要深拷贝,必须自行实现,否则默认生成的浅拷贝可能导致问题。
Student(const Student& s) : Person(s), _num(s._num) {
cout << "Student Copy Constructor" << endl;
}
3. 赋值运算符重载
子类的 operator= 会隐藏父类的 operator=。因此,在实现时必须显式调用父类的赋值操作,否则父类成员不会被正确更新。
Student& operator=(const Student& s) {
if (this != &s) {
Person::operator=(s); // 显式调用基类赋值
_num = s._num;
}
return *this;
}
4. 析构函数
这是最容易出错的地方。析构顺序是先子类后父类。虽然子类析构函数默认生成,但如果父类有虚析构函数或动态分配资源,必须确保父类析构函数能被正确调用。
规定如下:不需要显示调用,子类析构完成后会自动调用父类析构。这保证了清理顺序:先清理派生类成员,再清理基类成员。这与构造顺序(先基类后子类)正好相反。
核心总结
- 私有成员:继承了,但在子类中不可见(权限问题)。
- 对象大小:不一定比父类大,可能只是方法重写。
- 重载 vs 隐藏:重载在同作用域参数不同;隐藏在不同作用域同名即可,派生类会屏蔽基类。
- 构造顺序:先初始化父类,再初始化子类。
- 析构顺序:先析构子类,再析构父类。
理解这些机制,能帮你避免很多 C++ 继承相关的坑。


