1.继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序在保持原有类特性的基础上进行扩展,增加功能,这样产生的新类,称为派生类
2.继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用
3.被继承的类叫做基类(父类),继承的类叫做派生类(子类)
如果我们需要描述一个学校里面的学生和教职工,有姓名、性别、专业/部门、学号/工号等信息,该怎么做呢?
把学生和教职工中共有的信息拿出来创建一个
Person
类,新建两个类Student
和Teacher
,继承Person类
,它们就有了Person类中的数据成员和成员函数,实现代码复用如下图:
// 基类 class Person { // _name 姓名 // _age 性别 }; // 派生类 class Student : public Person { // _stuID 学号 // _major 专业 }; // 派生类 class Teacher : public Person { // _teaID 工号 // _department 部门 };
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
举例:class Student : public Person { };
1.定义格式
2.继承关系与访问限定符
如何给基类成员设置合适的访问限定符:
- 基类成员想让它被所有人访问,就设置成 public
- 基类成员不想让它在类外被直接访问,但需要在派生类中被访问,就设置成 protected
- 基类成员不想让它在类外被直接访问,也不想让它在派生类中被访问,就设置成 private
3.继承基类成员访问方式的变化
继承方式 | **基类public成员 ** | **基类protected成员 ** | 基类private成员 |
---|---|---|---|
公有继承(public) | 成为派生类的公用成员 | 成为派生类的保护成员 | 在派生类中不可见 |
保护继承(protected) | 成为派生类的保护成员 | 成为派生类的保护成员 | 在派生类中不可见 |
私有继承(private) | 成为派生类的私有成员 | 成为派生类的私有成员 | 在派生类中不可见 |
总结:
- 基类的private成员无论以什么继承都是不可见的—这里的不可见指:继承的成员还是在派生类的内存空间中,但是你不能访问它,只能通过调用继承下来的基类公有和保护成员函数来访问
- 基类的public、protected成员什么继承都可以
1.基类与派生类对象的赋值转换是建立在共有继承(public)基础上的
2.派生类对象可以赋值给基类对象 、基类对象指针 、基类对象引用。这里有个形象的说法叫切片,子类赋值父类也称作向上转型
举例:
切片总结:类型看左边父类,去掉子类私有成员
#include
#include using namespace std; // 基类 class Person { protected: string _name; //姓名 string _sex; //性别 int _age; //年龄 }; // 派生类 class Student : public Person { protected: int id; //学号 }; int main() { Student stu; // 1.派生类对象赋值给基类对象、基类指针、基类引用,进行向上转型 Person per = stu; Person* ptr = &stu; Person& ref = stu; // 2.基类对象不能赋值给派生类对象,强转也不可以 // stu = per; // stu =(Student)per; // 3.基类的指针可以通过强制类型转换赋值给派生类的指针,但最好用dynamic_cast(安全的向下转型)进行转换,这样才是安全的 return 0; }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
总结:
- 在继承体系中基类和派生类都有独立的作用域—区分函数重载与隐藏的关键
- 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义(在子类成员函数中,可以使用 基类::基类成员 显式访问)
- 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏
- 注意在实际中在继承体系里面最好不要定义同名的成员变量和成员函数
// 当派生类和基类有同名成员时,派生类会隐藏基类成员,可以看出这样代码虽然能跑,但是非常容易混淆
// 基类
class Person
{
protected:
int _num = 10; //序号
};
// 派生类
class Student : public Person
{
public:
void print()
{
cout << _num << endl; //打印的是派生类的_num
cout << Person::_num << endl; //显式访问 打印基类的_num
}
protected:
int _num = 20; //序号
};
int main()
{
Student stu;
stu.print();//隐藏了基类的_num,使用派生类的_num
//这里打印的是20 10
return 0;
}
// B中的fun和A中的fun不构成函数重载,因为在不同作用域
// B中的fun和A中的fun构成隐藏关系,在继承中,只要函数名相同就构成隐藏
// 基类
class A
{
public:
void fun()
{
cout << "fun()" << endl;
}
};
// 派生类
class B : public A
{
public:
void fun(int i)
{
cout << "fun(int i)" << endl;
}
};
int main()
{
B b;
b.A::fun(); // 调用基类的fun(),打印:fun()
b.fun(1); // 调用派生类的fun(),打印:fun(int i)
return 0;
}
1.派生类的构造函数:
- 对于继承的基类成员 ------------> 把它作为一个整体,先自动调用基类的默认构造函数初始化
- 对于类中的内置类型成员 ------> 不处理(除非声明时给了缺省值)
- 对于类中的自定义类型成员 —> 自动调用它的默认构造函数(不要参数就可以调用的,比如无参构造函数或全缺省构造函数)
2.派生类的拷贝构造函数:
- 对于继承的基类成员 ------------> 把它作为一个整体,先自动调用基类的拷贝构造函数来完成拷贝初始化
- 对于类中的内置类型成员 ------> 值拷贝
- 对于类中的自定义类型成员 —> 自动调用它的拷贝构造函数来完成拷贝初始化
3.派生类的赋值重载函数:
- 对于继承的基类成员 ------------> 把它作为一个整体,调用基类的拷贝赋值函数来完成赋值初始化
- 对于类中的内置类型成员 ------> 值拷贝
- 对于类中的自定义类型成员 —> 调用它的赋值重载函数来完成赋值初始化
4.派生类的析构函数:
- 对于类中的内置类型成员 ------> 不处理
- 对于类中的自定义类型成员 —> 调用它的析构函数完成清理工作
- 对于继承的基类成员 ------------> 把它作为一个整体,派生类的析构函数调用完成后,会自动调用基类的析构函数完成清理工作
5.派生类的取地址重载函数:
- 函数里不用调用基类的函数,取自己的地址就行了
派生类默认成员函数举例代码:
// 基类
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;
}
// 析构函数
~Person()
{
cout << "~Person()" << endl;
}
protected:
string _name; //姓名
};
// 派生类
class Student : public Person
{
public:
// 自己实现派生类的构造函数
// 需要注意的是,继承的基类成员是作为一个整体,调用基类的构造函数进行初始化
Student(const char* name, int id)
:Person(name) // 显示调用基类的构造函数
,_id(id)
{}
// 如果我们要自己实现派生类的拷贝构造,就要像下面这样写
// 但一般编译器默认生成的就可以,如果派生类中存在深拷贝,才需要自己实现
Student(const Student& s)
:Person(s) // 必须显示调用基类的拷贝构造(这里会发生切片)
,_id(s._id)
{}
// 自己实现派生类的赋值重载函数
Student& operator=(const Student& s)
{
if (this != &s)
{
_id = s._id;
Person::operator=(s); // 必须显示调用基类的赋值重载(这里会发生切片)
}
return *this;
}
~Student()
{
// 先清理自己的资源……
} // 结束后会自动调用父类的析构函数
protected:
int _id; //学号
};
int main()
{
Student stu1("张三", 1); // 调用构造函数
Student stu2(stu1); // 调用拷贝构造函数
Student stu3("李四", 2);
stu1 = stu3; // 调用重载赋值函数
return 0;
}
对于代码的理解:
总结:
派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员
细节:构造函数初始化列表阶段会自动调用基类的默认构造函数,如果基类没有不传参的默认构造函数,则必须在派生类构造函数的初始化列表阶段显示调用,否则会报错
派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的那一部分成员的拷贝初始化
细节:如果你要自己实现派生类的拷贝构造函数,就必须在实现的派生类拷贝构造函数的初始化列表中显示调用基类的拷贝构造,如果你自己不显示调用,初始化列表阶段会自动调用基类的默认构造函数(因为拷贝构造和构造都是构造函数,而编译器只会自动调用不传参的默认构造),所以就不会去调用基类的拷贝构造了,导致无法正常完成拷贝工作
派生类的==operator=( )==必须要显示调用基类的 基类::operator=( ) 完成基类的复制
派生类的析构函数会在被调用完成后,会自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员,再清理基类成员的顺序
派生类的析构函数和基类的析构函数构成隐藏关系––因为编译期会对析构函数名做特殊处理,所有类的析构函数名都会被处理成统一的名字: destructor()
为什么编译器会做这个处理呢?-- 因为析构函数要构成多态的重写(重写有个要求就是函数名要相同),子类的析构函数在执行结束后会自动调用父类的析构函数,因为创建派生类对象时,先创建初始化了基类成员,再创建初始化了派生类成员。所以派生类对象析构清理先调用派生类析构函数清理派生类成员后,再调用基类析构函数清理基类成员
派生类对象初始化先调用基类构造再调派生类构造
友元不属于成员函数,友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员
// 声明派生类
class Student;
// 基类
class Person
{
public:
friend void Display(const Person& p, const Student& s); // 友元函数
protected:
string _name; // 姓名
};
// 派生类
class Student : public Person
{
protected:
int _id; // 学号
};
void Display(const Person& p, const Student& s)
{
cout << p._name << endl; // 访问基类的保护成员
cout << s._id << endl; // 不能访问派生类的保护成员,因为友元关系不能继承下来,protected成员需要友元函数才可以访问
}
int main()
{
Person per;
Student stu;
Display(per, stu);
return 0;
}
- 基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例
- 静态成员不会被继承下来,为该基类和它下面的派生类的所有对象共享
// 基类
class Person
{
public:
Person() { ++_count; }
public:
static int _count; // 统计人的个数
protected:
string _name; // 姓名
};
int Person::_count = 0; // 定义静态数据成员(必须在类外定义)
// 派生类
class Student : public Person
{
protected:
int _id; // 学号
};
int main()
{
Person p;
Student s;
cout << "共创建了" << Person::_count << "个对象" << endl; // 使用基类域访问
cout << "共创建了" << Student::_count << "个对象" << endl; // 使用派生类域访问
cout << "共创建了" << p._count << "个对象" << endl; // 使用基类对象访问
cout << "共创建了" << s._count << "个对象" << endl; // 使用派生类对象访问
return 0;
}
1.单继承:一个子类只有一个直接父类时称这个继承关系为单继承
2.多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
3.菱形继承:菱形继承是多继承的一种特殊情况
菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。 在Assistant的对象中Person成员会有两份
class Person
{
public:
string _name; // 姓名
};
class Student : public Person
{
protected:
int _num; //学号
};
class Teacher : public Person
{
protected:
int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:
string _majorCourse; // 主修课程
};
int main()
{
// 这样会有二义性无法明确知道访问的是哪一个
Assistant a;
a._name = "张三"; // error:对_name访问不明确
// 需要显示指定访问的是哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
a.Student::_name = "张三-学生";
a.Teacher::_name = "张三-老师";
return 0;
}
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和Teacher的继承Person时使用virtual虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用
实际使用中,很少设计菱形继承,也不建议大家使用菱形继承
class Person
{
public:
string _name; // 姓名
};
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 a; // 虚拟继承后,Assistant的对象中就只有一份Person成员了
// 下面三种访问方式访问到的都是同一个成员
a.Student::_name = "张三-学生";
a.Teacher::_name = "张三-老师";
a._name = "张三";
return 0;
}
为了研究虚拟继承原理,我们给出了一个简化的菱形继承继承体系,再借助内存窗口观察对象成员的模型
class A
{
public:
int _a;
};
class B : virtual public A
// class B : public A
{
public:
int _b;
};
class C : virtual public A
// class C : public A
{
public:
int _c;
};
class D : public B, public C
{
public:
int _d;
};
int main()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}
虚拟继承底层剖析:
通过调试,观察内存窗口可看到:
注:请看上面代码(A中_a,B中 _b,C中 _c,D中 _d)
总结:
上面代码中菱形继承体系的B和C是virtul虚拟继承的A,D没有虚拟继承( class B : virtual public A、class C : virtual public A ),所以只有B和C才通过偏移量去找虚基类「虚基类A的成员_a」,而D是不需要通过偏移量去找「虚基类 A 的成员 _a」
对于虚拟继承的疑问
:
可能有小伙伴还会有疑问为什么不把偏移量直接存到虚基表指针的那个位置,而是需要通过虚基表指针去找偏移量呢?这是因为虚基表是一个表,不止存放偏移量,还要存其它东西(记录虚基表指针和虚函数指针之间的相对位置)
可能有小伙伴会有疑问为什么D对象中的B和C部分要去找属于自己的A?那么大家看看当下面的赋值发生时,d是不是要去找出B或C成员中的A才能切片赋值过去?
D d; B b; B* p1 = &d; // B对象指针 -> D对象,把D对象切片给B对象指针 p1->_a; // 指针访问虚基类A的成员_a B* p2 = &b; // B对象指针 -> B对象 p2->_a; // 指针访问虚基类A的成员_a // 指针是无法识别自己指向的是哪个类的对象,即可能指向自己,也可能指向子类,比如上面代码,B对象和D对象中虚基类成员_a的偏移量是不一样的,所以也只能通过偏移量来计算_a的位置
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
B或C的对象、对象指针、对象引用访问继承的虚基类 A 的对象中的成员 _a,都要取偏移量计算 _a 的位置
对于虚拟继承的思考
:
虚拟继承,解决了菱形继承数据冗余和二义性问题,但是同时,对象模型更复杂了,其次访问虚基类成员也付出了一定的效率代价
- 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题
- 多继承可以认为是C++的缺陷之一,很多后来的语言都没有多继承,如Java
扩展:类继承与对象组合
继承与组合概念:
面向对象系统中功能复用的两种最常用技术是类继承 和对象组合 :优先使用对象组合,而不是类组合
public 继承是一种 is-a(继承)关系。也就是说每个派生类对象都是一个基类对象(例如,教师是人,学生是人;哺乳动物是动物,狗是哺乳动物,因此,狗是动物,等等)
class B : public A
组合是一种 has-a (组合)关系。假设 B 组合了 A,每个 B 对象中都有一个 A 对象
class D // q
{
protected:
A a; // 轮胎
B b; // 车身
};
扩展:耦合和内聚
耦合和内聚: