一、继承的概念及定义
(一)概念
继承是面向对象程序设计使代码可以复用的最重要的手段,它允许我们在保持原有类特性的基础上进行扩展,增加方法 (成员函数) 和属性 (成员变量),这样产生新的类,称派生类。
(二)继承格式
1、继承方式
我们前面对类的成员有三种限制方式,这里也就对应了三种继承方式。
2、格式写法
3、派生类继承后访问方式的变化
1、通过表格可以发现,如果是
private成员,那么无论哪种继承方式都不可以访问到这个权限。 2、此外,struct和class这两个关键字在继承时也有差距,struct默认继承方式为公有,而class默认继承方式为私有。
我们如果将权限的大小定义为
public > protected > private,那么其余访问方式变化就是将大于该继承方式的权限降到继承方式的权限即可。
(三)普通类继承
这里用到的是继承最基本的语法,采用 public 继承,那么除了父类的 private 变量不可访问以外,成员的权限保持不变。
class Person {
public:
void Print() {
cout << _name << endl;
cout << _age << endl;
}
protected:
string _name = "张三"; // 姓名
private:
int _age = 18; // 年龄
};
class Student : public Person {
public:
void func() {
Print();
}
protected:
int _stunum; // 学号
};
(四)类模板继承
在之前我们实现 stack 时,采用的是新建了一个容器类型,在这里我们亦可以采用继承的方式来实现。
需要注意的是,派生类在继承时,如果需要访问父类的成员函数,需要指定类域,模板的成员函数采用的是按需实例化。
namespace wgm {
template<class T>
class stack : public std::vector<T> {
public:
void push(const T& x) {
// 基类是类模板时,需要指定一下类域,
// 否则编译报错:error C3861: 'push_back': 找不到标识符
// 因为 stack<int> 实例化时,也实例化 vector<int> 了
// 但是模版是按需实例化,push_back 等成员函数未实例化,所以找不到 vector<T>::push_back(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();
}
};
}
二、基类和派生类的转换
(一)基类转换派生类
1、基类对象不能赋值给派生类对象。
2、基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用 RTTI(Run-Time Type Information) 的 dynamic_cast 来进行识别后进行安全转换。
(二)派生类转换基类
1、public 继承的派生类对象 可以赋值给基类的指针/基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中基类那部分切出来,基类指针或引用指向的是派生类中切出来的基类那部分。
值得注意的是,之前在隐式类型转换时会生成临时变量,因此在应用时需要加上
const,而在切片时不会生成中间的临时变量。
class Person {
protected:
string _name; // 姓名
string _sex; // 性别
public:
int _age = 18; // 年龄
};
class Student : public Person {
public:
int _No; // 学号
};
int main() {
string s1 = "11111";
const string& s2 = "11111";
Student sobj; // 赋值兼容转换,特殊处理
// 1.派生类对象可以赋值给基类的指针/引用
Person* pp = &sobj;
Person& rp = sobj;
rp._age++;
return 0;
}
接下来通过下面的例子发现,继承后的基类私有变量虽然访问不到,但是我们可以发现它在派生类的对象中依旧占据相应的空间,而经过赋值兼容转换变量的大小为基类的大小。
接下来更加深层的来了解赋值兼容,发现基类的指针或引用在调用重名函数的时候,调用的是父类的函数,而派生类调用时因为隐藏的特点,派生类对象调用的是派生类的函数。
class A {
public:
void func() {
cout << "A::func()" << endl;
}
protected:
int _a;
int _b;
private:
int _c;
};
class B : public A {
public:
void func() {
cout << "B::func()" << endl;
}
public:
int _d;
};
int main() {
B obj_b;
A* ptr_a = &obj_b;
A& ref_a = obj_b;
obj_b.func();
ptr_a->func();
ref_a.func();
return 0;
}
2、子类的变量可以复制给父类。
Person pobj = sobj;
三、几个重要细节
(一)继承与作用域
1、作用域
在继承体系中基类和派生类都有独立的作用域。
2、隐藏
派生类和基类中有同名成员,派生类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏(在派生类成员函数中,可以使用基类::基类成员 显式访问)
需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
(二)继承与友元
在继承时,友元关系是不接受继承的。所以如果友元函数需要访问派生类的成员,需要重新声明友元。
(三)继承与静态成员
在继承后,静态成员变量始终只有基类在定义的这一份。通过下面的代码可以发现,我们可以用类域加静态变量的方式来访问静态变量,但是打印的地址是同一份。
class Person {
public:
string _name;
static int _count;
};
int Person::_count = 0;
class Student : public Person {
protected:
int _stuNum;
};
int main() {
Person p;
Student s;
cout << &p._count << endl;
cout << &s._count << endl;
cout << Person::_count << endl;
cout << Student::_count << endl;
return 0;
}
四、继承中派生类的构造函数
派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。基类没有默认的构造函数必须在派生类构造函数的初始化列表阶段显示调用。派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。派生类的
operator=必须要调用基类的operator=。需要注意的是派生类的operator=隐藏了基类的operator=,所以指定基类作用域显示调用基类的operator=派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。才能保证先清理派生类成员再清理基类成员。因为多态中一些场景析构函数需要构成重写。,那么编译器会对析构函数名进行特殊处理,处理成destructor(),所以基类析构函数不加virtual的情况下,派生类析构函数和基类析构函数构成隐藏关系。
class Person {
public:
Person(const char* name) : _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;
}
// destructor()
~Person() {
cout << "~Person()" << endl;
}
protected:
string _name; // 姓名
};
class Student : public Person {
public:
Student(int num, const char* address, const char* name) : _num(num), _address(address), Person(name) {
cout << "Student()" << endl;
}
Student(const Student& s) : Person(s), _num(s._num), _address(s._address) {
cout << "Student(const Student& s)" << endl;
}
Student& operator=(const Student& s) {
cout << "Student& operator= (const Student& s)" << endl;
if (this != &s) {
_num = s._num;
_address = s._address;
Person::operator=(s);
}
return *;
}
~() {
cout << << endl;
}
:
_num;
string _address;
};
五、多继承与菱形继承
(一)多继承
单继承:一个派生类只有一个直接基类时称为单继承 多继承:一个派生类有两个或以上直接基类时称为多继承
多继承的指针偏移问题
多继承对象在内存中的模型是,先继承的基类在前面,后面继承的基类在后面,派生类成员在放到最后面
通过上面的例子,我们可以清晰的认识到基类在派生类的储存情况。
(二)菱形继承
菱形继承:菱形继承是多继承的一种特殊情况,有数据冗余和二义性的问题
class Person {
public:
string _name; // 姓名
};
class Student : public Person {
protected:
int _num; // 学号
};
class Teacher : public Person {
protected:
int _id; // 职工编号
};
// 给类加上 virtual 关键字,解决菱形继承造成的二义性和数据冗余。
// class Student : virtual public Person
// {
// protected:
// int _num; // 学号
// };
//
// class Teacher : virtual public Person
// {
// protected:
// int _id; // 职工编号
// };
class Assistant : public Student, public Teacher {
protected:
string _majorCourse; // 主修课程
};
int main() {
Assistant obj;
obj.Student::_name = "张三";
obj.Teacher::_name = "李四";
return 0;
}
通过调试窗口,可以发现我们在继承时同时继承了来自 Person 和来自 Teacher 的 _name 我们在写代码时无法处理这个二义性,同时也形成了数据冗余。
(三)虚继承
为了解决这个现象,我们只需要在继承同一个基类成员的派生类加上一个
virtual关键字,底层会自行加工,使得我们后面访问的_name只是一份数据。
class Person {
public:
string _name; // 姓名
};
// 给类加上 virtual 关键字,解决菱形继承造成的二义性和数据冗余。
class Student : virtual public Person {
protected:
int _num; // 学号
};
class Teacher : virtual public Person {
protected:
int _id; // 职工编号
};
class Assistant : public Student, public Teacher {
protected:
string _majorCourse; // 主修课程
};
int main() {
Assistant obj;
obj.Student::_name = "张三";
obj.Teacher::_name = "李四";
return 0;
}
下列窗口显示出来的 _name 实则是同一份数据,最开始指定类域 Student:: 初始化 _name 为张三
我们通过 Teacher:: 修改数据为李四,那么数据被修改为李四。
切记,尽量不用使用菱形继承,因为
virtual关键字在解决问题的同时造成了效率的降低,代价有点大。
六、继承和组合
| 继承 | 组合 | |
|---|---|---|
| 定义 | public 继承是一种 is-a 的关系。也就是说每个派生类对象都是一个基类对象。 | 组合是一种 has-a 的关系。假设 B 组合了 A,每个 B 对象中都有一个 A 对象。 |
| 复用方式 | 白箱复用:在继承方式中,基类的内部细节对派生类可见 | 黑箱复用:通过调用对象的接口实现,对象的内部细节是不可见的 |
| 耦合度 | 继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高 | 组合类之间没有很强的依赖关系,耦合度低。 |
我们可以发现,组合的好处要大于继承,在两种都可以的情况下,优先使用组合,而不是继承。实际尽量多用组合,组合的耦合度低,代码维护性好。


