一、继承基础:先搭个架子
继承的概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许我们在保持原有类特性的基础上进行扩展,增加方法(成员函数)和属性(成员变量),这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的函数层次的复用,继承是类设计层次的复用
最基础的继承,就是一个 person 类作为基类,student 和 teacher 分别派生:
#include <iostream>
#include <string>
using namespace std;
class person
{
public:
void identity()
{
cout << "void identity()" << _name << endl;
}
protected:
string _name; // 姓名
string _address; // 地址
string _tel; // 电话
};
class student : public person
{
public:
void study() { }
protected:
int _stuid; // 学号
};
class teacher : public person
{
public:
void teaching() { }
protected:
string title; // 职称
};
int main()
{
student s;
teacher t;
s.identity(); // 子类对象调用父类方法
t.identity();
return 0;
}这个例子很直观:子类自动继承了父类的公有和保护成员,identity() 也能正常调用。但这里有一个容易被忽略的点——访问权限。
| 继承方式 | 父类 public 成员在子类中 | 父类 protected 成员在子类中 |
|---|---|---|
| public | public | protected |
| protected | protected | protected |
| private | private | private |
日常开发里 90% 以上都是 public 继承,后两种知道就行,实际用得很少。
二、模板 + 继承:用容器适配一个栈
下面这个例子把继承和模板揉在一起——用 vector 或 list 作为底层容器,通过继承来适配一个简易栈:
#include <iostream>
#include <vector>
#include <list>
using namespace std;
// 通过宏切换底层容器:vector 或 list
// #define container std::vector
#define container std::list
template<class t>
class stack : public container<t>
{
public:
void push(const t& x)
{
// 必须显式指定父类作用域,否则编译器找不到 push_back
container<t>::push_back(x);
}
void pop()
{
container<t>::pop_back();
}
const t& top()
{
return container<t>::back();
}
bool empty()
{
return container<t>::empty();
}
};
int main()
{
box::stack<int> st; // 假设 stack 定义在 box 命名空间内
st.push(1);
st.push(2);
st.push(3);
while (!st.empty())
{
cout << st.top() << " ";
st.pop();
}
// 输出:3 2 1
return 0;
}这里有两个关键点:
第一,为什么要写 container<t>::push_back(x) 而不是直接 push_back(x)?
这跟 c++ 的"按需实例化"机制有关。编译器在解析模板时,不会立刻去父类(依赖模板参数的基类)里查找名字。你不加作用域限定,编译器就不知道 push_back 从哪来,直接报错。这是模板继承里最容易踩的坑。
第二,通过宏切换 vector 和 list。 用宏控制底层容器,同一个栈模板可以适配不同数据结构,灵活性一下就上来了。但宏只是编译期文本替换,没有类型检查,生产环境建议用模板参数来指定容器类型。
三、切片赋值:子类对象能赋给父类,反过来不行
class person
{
protected:
string _name;
string _sex;
int _age;
};
class student : public person
{
public:
int _no; // 学号
};
int main()
{
student sobj;
// ✅ 子类对象可以赋值给基类的指针或引用
person* pp = &sobj;
person& rp = sobj;
// ✅ 子类对象赋值给基类对象——通过拷贝构造完成
person pobj = sobj;
// ❌ 基类对象不能赋值给子类对象,编译直接报错
// sobj = pobj;
return 0;
}这段代码展示了 c++ 继承里一个非常重要的特性——切片(slicing)。
当你把 student 对象赋给 person 对象时,编译器只拷贝 person 的那部分成员,student 独有的成员(比如 _no)会被"切掉"丢弃。这在 c++98 里是完全合法的,但也是很多隐蔽 bug 的根源——你以为传的是子类对象,实际上函数只拿到了父类的那一半。
反过来就不行了:sobj = pobj 会编译报错,因为基类对象天然缺了子类特有的那一部分,编译器不知道怎么填补。
四、同名成员隐藏:子类变量遮住了父类的
class person
{
protected:
string _name;
int _num; // 身份证号(父类)
};
class student : public person
{
public:
void print()
{
cout << _name << endl; // 没问题,_name 只在父类定义
cout << person::_num << endl; // 访问父类的 _num
cout << _num << endl; // 访问子类的 _num
}
protected:
int _num; // 学号(子类),和父类的 _num 同名
};
int main()
{
student s1;
s1.print();
return 0;
}输出结果:
xiaoming
111
999
子类和父类有同名成员变量(_num),子类会隐藏父类的同名成员。你直接写 _num,编译器默认找的是子类自己的那个。想访问父类的版本,必须显式加作用域:person::_num。
这个规则也适用于成员函数,下面的例子会进一步说明。
五、函数隐藏 vs 重载:90% 的人都会搞混
来看一个经典的面试题:
class a
{
public:
void fun()
{
cout << "func()" << endl;
}
};
class b : public a
{
public:
void fun(int i)
{
cout << "func(int i)" << i << endl;
}
};
int main()
{
b b;
b.fun(10); // ✅ 调用 b::fun(int)
// b.fun(); // ❌ 编译报错!父类的 fun() 被隐藏了
return 0;
}问:a 和 b 中的两个 fun 构成什么关系?
很多人第一反应是"重载"——毕竟参数列表不同嘛。但答案是:隐藏(hide)。
为什么不是重载?因为重载要求两个函数在同一个作用域里。而父类和子类分属不同作用域,子类的 fun(int) 把父类的 fun() 给隐藏了。编译器在 b 的作用域里找到了 fun 这个名字,就不再往父类里找了,哪怕参数不匹配也不会回退。
想调用父类的版本,同样需要显式指定:
int main()
{
b b;
b.a::fun(); // ✅ 显式调用父类的 fun()
return 0;
}一句话总结:只要函数名相同,子类就会隐藏父类的所有同名函数,管你参数一不一样。
六、六大默认成员函数:子类到底继承了什么?
这是 c++ 继承里最核心的一块——构造函数、拷贝构造、赋值重载、析构函数在继承链里到底怎么运作?
class person
{
public:
person(const char* name = "peter")
: _name(name)
{
cout << "person()" << endl;
}
person(const person& p)
: _name(p._name)
{
cout << "person(const person& p)" << endl;
}
person& operator=(const person& p)
{
cout << "person operator=(const person& p)" << endl;
if (this != &p)
_name = p._name;
return *this;
}
~person()
{
cout << "~person()" << endl;
}
protected:
string _name;
};
class student : public person
{
public:
student(const char* name, int num, const char* address)
: person(name) // 显式调用父类构造函数
, _num(num)
, _address(address)
{
_ptr = new int(666);
}
// 拷贝构造
student(const student& x)
: person(x) // 切片:把子类对象当父类引用传进去
, _num(x._num)
, _address(x._address)
{
_ptr = x._ptr; // 浅拷贝(演示用,实际需要深拷贝)
}
// 赋值重载
student& operator=(const student& s)
{
if (this != &s)
{
person::operator=(s); // 必须显式调用,否则被隐藏
_num = s._num;
_address = s._address;
}
return *this;
}
// 析构函数
~student()
{
person::~person(); // 显式调用父类析构
}
protected:
int _num;
string _address;
int* _ptr;
};
int main()
{
student s("xiaoming", 12, "chengdu");
student s2(s); // 拷贝构造
student s3("lisi", 12, "meiguo");
s = s3; // 赋值重载
return 0;
}这段代码里藏了很多细节,逐个拆开看:
6.1 构造函数
子类构造函数必须在初始化列表中调用父类构造函数。如果不写,编译器会偷偷帮你调父类的默认构造函数——但如果父类没有默认构造函数,编译直接报错。
student 的构造里写的是 : person(name),这就是显式调用父类的带参构造。
6.2 拷贝构造
初始化列表里 : person(x) 这行很有意思——x 是 const student& 类型,但 person 的拷贝构造接收 const person&。编译器在这里做了一次切片:只取 student 对象中属于 person 的那部分来调用父类拷贝构造。
6.3 赋值运算符重载
子类和父类的 operator= 构成隐藏关系。所以子类内部想调用父类的赋值重载,必须写成:
person::operator=(s);
直接写 operator=(s) 会递归调用自己,直接栈溢出。
6.4 析构函数
同样构成隐藏。子类析构里显式调 person::~person() 来释放父类资源。不过这里要注意:真正的 c++ 运行时会自动调用父类析构,子类析构执行完后,父类析构会被自动触发。显式调用反而可能导致父类析构被执行两次,引发问题。但因为这里只是打印日志做演示,所以无伤大雅——实际写代码时,不要显式调用父类析构函数。
七个关键结论(速查表)
| 场景 | 结论 |
|---|---|
| 子类调用父类非虚函数 | 用 父类名::函数名() 显式调用 |
| 子类对象赋给父类指针/引用 | ✅ 合法(切片) |
| 父类对象赋给子类对象 | ❌ 编译报错 |
| 同名成员变量 | 子类隐藏父类,访问父类需加作用域 |
| 同名成员函数(参数不同) | 隐藏,不是重载! |
| 赋值重载在父子间 | 构成隐藏,调用需加 父类名::operator=() |
| 模板类继承模板基类 | 访问基类成员必须加 基类<t>:: 限定 |
写在最后
c++ 的继承机制,初看就是"子类能访问父类的东西",但真到了同名隐藏、切片赋值、模板继承这些细节上,稍不注意就会写出 bug。我的建议是:别光看,一定要动手敲。 把上面这些例子复制到 vs 里跑一遍,逐行设断点看调用栈,理解会深很多。
到此这篇关于c++ 继承从入门到踩坑:这些隐藏规则很多人都会搞混的文章就介绍到这了,更多相关c++ 继承入门内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论