本章思维导图:
注:本章思维导图对应的.xmind
和.png
文件都已同步导入至资源
在C++面向对象编写代码的过程中,我们难免会遇到类似的情况:
- 我们需要编写一个
student
类和一个teacher
类- 这两个类有部分成员是相同的,例如名字
_name
,电话_phone
,但是又会有一些成员是不同的,例如student
有学号st_id
,teacher
有职工编号te_id
。- 如果我们像以前一样直接对这两个类进行编写,就可能会导致有大量代码是重复的,使代码冗余,降低可读性
class Student { std::string _name; int age; long long st_id; }; class Teacher { std::string _name; int age; long long te_id; };
为了解决这个问题,C++提供了一种设计模式——继承。
student
类和teacher
类共有的_name
、_phone
等成员抽象成另一个具体的类person
,再让student
类和teacher
类对其进行继承,这样就可以解决代码冗余问题了。Person
这些被继承的类为基类或者父类;称继承了其他类的类为派生类或子类class Person
{
protected:
std::string _name;
int age;
};
class Student : public Person
{
long long st_id;
};
class Teacher : public Person
{
long long te_id;
};
现在,我们对继承有了较为初步的了解,可以进行总结:
- 继承使面向对象程序进行代码复用的重要手段
- 继承可以在不改变原有类的基础上,对原有类进行扩展,生成新的类
- 不同于以往对函数的复用,继承是对类的复用
定义继承的基本格式为:派生类 : 继承方式 基类
public
、保护继承protected
、私有继承private
类成员/继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
基类的public成员 | 派生类的public乘员 | 派生类的protected成员 | 派生类的private成员 |
基类的protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
基类的private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
通过对上述表格的分析,我们应该注意以下几点:
虽然基类的private成员继承之后在派生类并不可见,但他同样会被派生类继承,不要以为它不会被继承。我们可以进行调试分析:
#include
#include
class Person
{
private:
std::string _name;
int age;
};
class Student : public Person
{
long long st_id;
};
int main()
{
Student st;
return 0;
}
可以发现,派生类确实继承了被private修饰的基类成员
在学习继承制前,我们通常认为访问限定符protected
和private
的作用是一样的,但是通过上面的学习,我们发现了二者的区别:
private
修饰的成员在被继承之后就会不可见,无法使用;protected
修饰的成员在被继承之后在派生类同样可见。#include
#include class Person { private: std::string _name; int age; }; class Student : public Person { public: void func() { std::cout << age << std::endl; } protected: long long st_id; }; //编译时报错:“Person::age”: 无法访问 private 成员(在“Person”类中声明)·
通过这样的分析,我们应该在类和对象层面更加清楚**private
表示私有,二protected
表示保护的含义**
如果不明确指明继承方式,则:
private
public
可以进行验证:
#include
class A
{
public:
int _a = 1;
};
class B
{
public:
int _b = 1;
};
struct C1 : A {}; //等价于 struct C1 : public A {};
class C2 : B {}; //等价于 class C2 : private B {};
int main()
{
C1 c1;
C2 c2;
std::cout << c1._a << std::endl;
std::cout << c2._b << std::endl;
return 0;
}
//编译时报错:“B::_b”不可访问,因为“C2”使用“private”从“B”继承
虽然class
和struct
定义的类尤其默认的继承方式,但是在实际使用过程中建议明确指明继承方式
以前我们说,如果两个不同类型的变量进行赋值,那么在赋值的过程中就会产生临时变量:
int a = 1; double b = 2.0; b = a; //在这个过程中会产生临时变量,b得到的就是临时变量的值
而由于临时变量具有常性,因此这种写法就是错误的:
int a = 1; double& b = a; //临时变量具有常性,而b为非const引用,发生了权限放大,故错误 //正确的写法为:const double& b = a;
但是到了继承,上面的说法就不那么正确了,我们来看下面的代码:
class Person
{
protected:
std::string _name;
int _age;
int _sex;
};
class Student : public Person
{
protected:
long long st_id;
};
int main()
{
Student student;
Person& person = student; // ???
return 0;
}
大家认为这段代码是否可以编译通过,也就是说Person& person = student;
这句代码是否正确?
答案是确实可以通过编译,这是有小伙伴就会疑惑了:Person
和Student
两个是不同类型,这个过程难道不会产生临时变量吗?
这就涉及到了我们要讨论的主题:基类和派生类的赋值兼容转换
基类和派生类的赋值兼容转换通常只在public继承的情况下讨论
赋值兼容转换又叫做切割或者切片
基类和派生类发生赋值兼容转换有以下三种情形:
派生类对象赋值给基类对象:
Student student; Person person = student;
派生类对象赋值给基类对象的引用:
Student student; Person person& = student;
派生类对象的地址赋值给基类对象的指针:
Student student; Person* person = &student;
class Person
{
protected:
std::string _name;
int age;
int sex;
int _a = 1;
};
class Student : public Person
{
public:
void func()
{
std::cout << _a << std::endl;
}
protected:
long long st_id;
int _a = 2;
};
int main()
{
Student student;
student.func();
return 0;
}
上述的代码中,派生类Student
继承了基类Person
,那么问题来了:
Student
类和Person
类可以定义同名成员吗?
当然可以!因为Person
和Student
是两个不同的类域,作用域不同,当然可以定义同名成员了
那么
std::cout << _a << std::endl;
这句访问的又是哪个_a
呢?
答案是派生类的_a
。这是因为:子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问
::
。例如:std::cout << Person::_a << std::endl;
class Person
{
public:
void func()
{
std::cout << _a << std::endl;
}
protected:
std::string _name;
int age;
int sex;
int _a = 1;
};
class Student : public Person
{
public:
void func(int b)
{
std::cout << _a << std::endl;
}
protected:
long long st_id;
int _a = 2;
};
//问:这两个func()函数是构成重载关系还是隐藏关系??
答案是隐藏关系,因为这两个func()函数满足函数名相同的条件
有小伙伴就疑惑了:为什么不是重载关系?
我们需要回顾以下函数重载的定义:
在同一作用域下,如果两个函数名称相同,但是参数不同,那这两个函数就构成重载
Person
和Student
是两个不同的类域,怎么会构成重载关系?
这里我们只以下面的两个类为基础,讨论四类默认成员函数:构造函数、拷贝构造、赋值运算符重载、析构函数
class Person
{
public:
Person(const std::string name = std::string(), const int age = 18, const int sex = 1)
: _name(name)
, _age(age)
, _sex(sex)
{
std::cout << "Person()" << std::endl;
}
Person(const Person& p)
: _name(p._name)
, _age(p._age)
, _sex(p._sex)
{
std::cout << "Person(const Person& p)" << std::endl;
}
Person& operator= (const Person& p)
{
std::cout << "operator= (const Person& p)" << std::endl;
if (this != &p)
{
_name = p._name;
}
return *this;
}
~Person()
{
std::cout << "~Person()" << std::endl;
}
protected:
std::string _name;
int _age = 18;
int _sex = 1;
};
class Student : public Person
{
public:
protected:
long long _st_id;
};
int main()
{
Student st;
return 0;
}
output:
Person()
~Person()
说明了,派生类的默认构造函数和默认析构函数都会自动调用基类的构造和析构
int main()
{
Student st1;
Student st2(st1);
return 0;
}
output:
Person()
Person(const Person& p)
~Person()
~Person()
说明了,派生类的默认拷贝构造会自动调用基类的拷贝构造
int main()
{
Student st1, st2;
st1 = st2;
return 0;
}
output:
Person()
Person()
operator= (const Person& p)
~Person()
~Person()
说明了,派生类的默认赋值运算符重载会自动调用基类的赋值运算符重载
实际上,我们可以将派生类的成员分成三部分:基类成员、内置类型成员、自定义类型成员
继承相较于我们以前学的类和对象,可以说就是多了基类那一部分
当调用派生类的默认成员函数时,对于基类成员都会调用对应基类的默认成员函数来处理
当实现派生类的构造函数时,就算不显示调用基类的构造,系统也会自动调用基类的构造:
class Student : public Person
{
public:
Student(long long st_id = 111)
: _st_id(st_id)
{
std::cout << "Student()" << std::endl;
}
protected:
long long _st_id;
};
int main()
{
Student st;
return 0;
}
output:
Person()
Student()
~Person()
如果需要显示的调用基类的构造函数,应该这样写:
Student(long long st_id = 111)
: _st_id(st_id)
, Person("lwj", 18)
{
std::cout << "Student()" << std::endl;
}
特别注意:
Person("lwj", 18)
如果放在初始化列表,那就使显式调用基类的构造函数;如果放在函数体内,那就使创建一个基类的匿名对象
当实现派生类的拷贝构造时,如果没有显式调用基类的拷贝构造,那么系统就会自动调用基类的构造
class Student : public Person
{
public:
Student(long long st_id = 111)
: _st_id(st_id)
, Person("lwj", 18)
{
std::cout << "Student()" << std::endl;
}
Student(const Student& s)
: _st_id(s._st_id)
{
std::cout << "Student(const Student& s)" << std::endl;
}
protected:
long long _st_id;
};
int main()
{
Student st1;
Student st2(st1);
return 0;
}
output:
Person()
Student()
Person() //系统自动调用了基类的构造
Student(const Student& s)
~Person()
~Person()
也可以像显示调用构造函数一样显式调用基类的拷贝构造:
Student(const Student& s)
: Person(s)
, _st_id(s._st_id)
{
std::cout << "Student(const Student& s)" << std::endl;
}
在实现派生类的赋值运算符重载时,如果没有显式调用基类的赋值运算符重载,系统也不会自动调用基类的赋值运算符重载
class Student : public Person
{
public:
Student(long long st_id = 111)
: _st_id(st_id)
, Person("lwj", 18)
{
std::cout << "Student()" << std::endl;
}
Student& operator= (const Student& s)
{
std::cout << "operator= (const Student& s)" << std::endl;
if (this != &s)
{
_st_id = s._st_id;
}
return *this;
}
protected:
long long _st_id;
};
int main()
{
Student st1, st2;
st1 = st2;
return 0;
}
output:
Person()
Student()
Person()
Student()
operator= (const Student& s)
~Person()
~Person()
因此,在实现派生类的赋值运算符重载时,必须显示调用基类的赋值运算符重载:
Student& operator= (const Student& s)
{
std::cout << "operator= (const Student& s)" << std::endl;
if (this != &s)
{
Person::operator=(s); //由于基类和派生类的赋值运算符重载构成隐藏,因此要用 :: 指定类域
_st_id = s._st_id;
}
return *this;
}
在实现派生类的析构函数时,不要显式调用基类的析构函数,系统会在派生类的析构完成后自动调用基类的析构
注意:
- 和前面的默认成员函数不同,在实现派生类的析构时,基类的析构不能显式调用
- 这是因为,如果显示调用了基类的析构,就会导致基类成员的资源先被清理,如果此时派生类成员还访问了基类成员指向的资源就,就会导致野指针问题
- 因此,必须保证析构顺序为先子后父,保证数据访问的安全
class Student : public Person
{
public:
Student(long long st_id = 111)
: _st_id(st_id)
, Person("lwj", 18)
{
std::cout << "Student()" << std::endl;
}
~Student()
{
std::cout << "~Student()" << std::endl;
}
protected:
long long _st_id;
};
int main()
{
Student st1;
return 0;
}
output:
Person()
Student()
~Student()
~Person()
友元关系不能被继承,即基类的友元函数不能直接访问派生类的private/protected
成员
class Student;
class Person
{
public:
friend void func(Person& p, Student& s);
protected:
std::string _name;
int _age = 18;
int _sex = 1;
}
class Student : public Person
{
protected:
long long _st_id = 1;
};
void func(Person& p, Student& s)
{
std::cout << p._name << std::endl;
std::cout << s._st_id << std::endl;
}
//编译报错:“Student::_st_id”: 无法访问 protected 成员(在“Student”类中声明)
解决方法就是将func()
函数也变为派生类的友元
在学习静态成员的时候,我们就说:
- 静态成员变量实际上就是一个受类域和访问限定符限制的全局变量
- 静态成员变量并不属于某一个单独的类对象,而是整个类所有
- 静态成员变量并不存储在类对象中,而是存储在静态区
因此,如果基类有一个静态成员变量,那么无论他派生了多少个类,这个静态成员变量也只会有一个
class Person
{
public:
Person() { ++_count; }
protected:
std::string _name; // 姓名
public:
static int _count; // 统计人的个数。
};
int Person::_count = 0;
class Student : public Person
{
protected:
int _stuNum; // 学号
};
class Graduate : public Student
{
protected:
std::string _seminarCourse; // 研究科目
};
void TestPerson()
{
Student s1;
Student s2;
Student s3;
Graduate s4;
std::cout << " 人数 :" << Person::_count << std::endl;
Student::_count = 0;
std::cout << " 人数 :" << Person::_count << std::endl;
}
int main()
{
TestPerson();
return 0;
}
output:
4
0
单继承:指一个派生类只有一个直属的基类
我们此前将的所有继承都是单继承,这里不再过多赘述
多继承:指一个派生类有多个直属基类
看起来C++支持多继承是一种理所应当的行为,但其实,多继承为C++带来了非常严重的后果。那就是多继承可能会导致菱形继承。
从上面的类模型图中我们可以发现菱形继承的问题:Assistant
类继承了两份Person
类。这样就会导致数据的冗余和二义性
我们可以用下面的示例代码进行分析:
class A
{
public:
int _a;
};
class B : public A
{
public:
int _b;
};
class C : public A
{
public:
int _c;
};
class D : public B, public C
{
public:
int _d;
};
int main()
{
D dd;
dd.B::_a = 1; //由于D类继承了两份A类,因此访问A类成员时,就需要指明类域
dd.C::_a = 2; //由于D类继承了两份A类,因此访问A类成员时,就需要指明类域
dd._b = 3;
dd._c = 4;
dd._d = 5;
return 0;
}
我们打开内存窗口进行调试:
我们发现,类A
确实被继承了两份,这样的结果肯定不是我们想要的。为了避免数据的冗余和二义性,我们应该做到类D
继承类B
和类C
时只会继承一份A
,为了达成这一目的,就需要用到虚继承
针对上面的代码,我们可以在发生菱形继承处添加virtual
关键字,让继承变为虚继承,从而达到A
类只被继承一份的目的:
class A
{
public:
int _a;
};
class B : virtual public A //添加virtual关键字,使继承变为虚继承
{
public:
int _b;
};
class C : virtual public A //添加virtual关键字,使继承变为虚继承
{
public:
int _c;
};
class D : public B, public C
{
public:
int _d;
};
int main()
{
D dd;
dd.B::_a = 1;
dd.C::_a = 2;
dd._b = 3;
dd._c = 4;
dd._d = 5;
return 0;
}
同样打开内存窗口进行调试:
可以看到虚继承后,类B
和类C
继承的类A
被放在了最下方的公共区域,这样类D
就只包含了一份类A
,也就避免了数据冗余和二义性
这时有细心的小伙伴又发现了一个问题:
类
B
的48 7b 5f 00
和类C
的54 7b 5f 00
又是什么呢?
首先我们应该会读这两串数据:VS是小端机,即低位数据存在地位地址,高位数据存在高位地址,因此这两串数据实际上应该是00 5f 7b 48
和00 5f 7b 54
这看起来是两串地址,我们不妨进行搜索:
可以看到00 5f 7b 48
指向的区域中,存储着一个数据00 00 00 14
;00 5f 7b 54
存储着一个数据00 00 00 0c
。那存储的这两个数据代表的又是什么?
我们不妨再进行一次尝试:将48 7b 5f 00
的地址0x003EFDF4
加上00 00 00 14
(对应十进制20),将54 7b 5f 00
的地址0x003EFDFC
加上00 00 00 0c
(对应十进制12)。可以发现,得到的结果都是地址0x00eEFE08
也就是公共区域类A
的地址
我们称地址48 7b 5f 00
和地址54 7b 5f 00
指向的数据00 00 00 14
和00 00 00 0c
成为偏移量,又将偏移量存在的区域称为虚基表,正是由于虚基表的存在,发生虚继承的类才能找到他们公共成员
从上面的分析我们可以看出,如果使用了多继承,就可能发生菱形继承。而菱形继承的地城结构是十分复杂的,非常难以控制,因此在日常编写代码的过程中,尽量不要使用多继承,以避免菱形继承的出现
继承的大致结构是这样的:
class A
{
protected:
int _a = 1;
};
class B : public A
{
protected:
int _b = 2;
};
组合的大致结构是这样的:
class A
{
protected:
int _a = 1;
};
class B
{
protected:
int _b = 2;
A _a;
};
可以看到继承和组合都实现了对类的复用,那么这二者的区别是什么?我们应该在什么场景使用它们?
一般认为:
- public继承是一种
is-a
关系,即派生类是一个特殊的父类。如果两个类的关系可以用is-a
关联,那就可以用public继承。例如Person
和Student
就是经典的public继承关系**- 组合是一种
has-a
关系,即假设B组合了A,那么就说B对象有一个A对象。例如汽车和轮胎就是经典的组合关系
在继承和组合都可以使用的情况下,为了达到低耦合,高内聚的要求,更推荐使用组合
- 继承是一种“白箱复用”,也就是说被继承的基类除
privete
成员,其他成员在派生类中都是可见的,这在一定程度上破坏了基类的封装,也使基类和派生类产生了较大的耦合度,也就是说由于派生类可以使用基类的大部分成员,基类的改变很可能会影响到派生类,这降低了代码的可维护性- 组合是一种“黑箱复用”,也就是说假设B组合了A,B只能访问到A的
public
成员,这大大降低了类B和类A的耦合度,也就提高了代码的可维护性
有些情况下必须要使用继承,例如多态的实现,这会在下一章详细说明
本篇完
如果错误,欢迎大家指出