目录
前言
在代码编写中,如果一段代码重复多次 被调用,那么我们会将其封装为一个函数,提高代码复用性,例如交换函数swap;同样的,对于类的成员函数或成员变量,如果在多个类中重复出现,那么我们可以提取公共数据,封装为一个基类,使其它类来继承基类。
一、继承概念
1. 继承概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
2. 继承定义格式
例如:
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "peter"; // 姓名
int _age = 18; // 年龄
};
class Student : public Person
{
protected:
int _stuid; // 学号
};
class Teacher : public Person
{
protected:
int _jobid; // 工号
};
int main()
{
Student s;
Teacher t;
s.Print();
t.Print();
return 0;
}
3. 继承关系和访问限定符
4. 继承基类成员访问方式的变化
类成员 / 继承方式 | public继承 | protected继承 | private继承 |
基类的public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
基类的protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
基类的private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
不可见:在语法上限制访问,类里面和类外面都不能使用 (父类的私有成员不管什么继承都不可以使用) 。它跟private不同,private在类外不能使用,类里面可以使用。
子类继承父类的成员变量和成员函数,但是因为成员函数不在类内部,这似乎也叫不了继承
总结:
- 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
- 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
- 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。
- 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
- 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
二、基类和派生类对象赋值转换
我们知道,不同类型的变量直接进行赋值会发生类型转换,类型转换有强制类型转换和隐式类型转换。
父类和子类之间是不是也可以进行相互转换呢?
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "peter"; // 姓名
int _age = 18; // 年龄
};
class Student : public Person
{
protected:
int _stuid; // 学号
};
class Teacher : public Person
{
protected:
int _jobid; // 工号
};
int main()
{
int i = 0;
double d = i;
Person p;
Student s;
p = s;
//s = p; 父不能给子,因为子有的变量可能多于父,变量数量都不一致,不能赋值
//语法方面禁止了父向子传递
}
父类不能类型转换赋值给子类(称为向下转换),因为子类有的变量可能多于父类,变量数量不一致,不能完成赋值。如果显示的强制类型转换也不可以,在这里C++语法方面直接禁止了父向子的传递 ,只允许子向父传递
对于内置类型,类型转换时会产生临时变量,而对于父类与子类之间,它们的类型转换不产生临时变量,这种类型转换被称为赋值兼容(切片,切割),因为子一定含有父的特征,将子类中父类的那一部分切下拷贝赋值给父类变量即可
问:如何证明不产生中间变量?
使用引用!如果有临时变量,那么需要使用const修饰的引用
int i = 0;
double& d = i; 错误
Student s;
Person& p = s; 正确
此时父类p是子类s中父类那一部分切片的别名
- 派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
- 基类对象不能赋值给派生类对象
- 基类的指针可以通过强制类型转换赋值给派生类的指针。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(Run-Time Type Information)的dynamic_cast 来进行识别后进行安全转换。
Student sobj;
// 1.子类对象可以赋值给父类对象/指针/引用
Person pobj = sobj;
Person* pp = &sobj;
Person& rp = sobj;
//2.基类对象不能赋值给派生类对象
sobj = pobj;
// 3.基类的指针可以通过强制类型转换赋值给派生类的指针
pp = &sobj;
Student * ps1 = (Student*)pp; // 这种情况转换时可以的。
ps1->_No = 10;
pp = &pobj;
Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问题
ps2->_No = 10;
三、继承中的作用域
- 在继承体系中基类和派生类都有独立的作用域。
- 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)
- 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
- 注意在实际中在继承体系里面最好不要定义同名的成员。
class Person
{
public:
void fun()
{
cout << "Person::func()" << endl;
}
protected:
string _name = "小李子"; // 姓名
int _num = 111; // 身份证号
};
// 隐藏/重定义:子类和父类有同名成员,子类的成员隐藏了父类的成员
class Student : public Person
{
public:
void fun()
{
cout << "Student::func()" << endl;
}
void Print()
{
cout << " 姓名:" << _name << endl;
cout << _num << endl;
如果要使用父类变量,就指定类域
cout << Person::_num << endl;
}
protected:
int _num = 999; // 学号
};
小知识:
- 若在函数内输出变量,编译器优先在函数内寻找、其次是类成员变量、如果有继承就在父类成员找、最后是全局
- 重载要在同一个作用域,底层使用了函数名修饰规则,不然找地址的时候区分不开函数。隐藏是在父子类域中,只要函数名相同就形成隐藏
四、派生类的默认成员函数
class Person
{
public:
//Person(const char* name = "peter")
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;
delete _pstr;
}
protected:
string _name; // 姓名
};
class Student : public Person
{
public:
// 先父后子
Student(const char* name = "张三", int id = 0)
:_name(name) 报错
,_id(0)
{}
protected:
int _id;
};
//输出
Person()
~Person()
语法规定:
- 派生类不能在初始化列表初始化从基类继承的成员变量(初始化列表初始化顺序和编写顺序无关,只和成员变量声明顺序有关,由于继承的变量在子类成员变量之前,所以先初始化继承的变量)
- 派生类会在初始化列表自动调用基类的默认构造函数,如果基类没有默认构造,那么就会报错,我们可以显示调用基类的构造函数解决问题,编写语规则就像定义了匿名对象
Student(const char* name = "张三", int id = 0)
:Person(name) //最好写前面
,_id(0)
{}
- 对于派生类的拷贝构造,默认行为:如果子类没有显式定义拷贝构造函数或赋值运算符重载函数,编译器会生成默认实现,并自动调用父类的对应函数。显式定义时:如果子类显式定义了拷贝构造函数或赋值运算符重载函数,必须手动调用父类的对应函数,否则父类的部分不会被正确处理。
- 析构函数特殊,由于多态的原因,析构函数的函数名被特殊处理了,统一处理成destructor,所以派生类的析构隐藏了基类的析构,所以在调用父类的析构时还需要加上类名::。
但是我们不能在子类析构函数处显示调用父类的析构函数
如果你在子类的析构函数中显式调用父类的析构函数,例如:
class Base {
public:
~Base() {
std::cout << "Base destructor" << std::endl;
}
};
class Derived : public Base {
public:
~Derived() {
std::cout << "Derived destructor" << std::endl;
Base::~Base(); // 显式调用父类析构函数
}
};
int main() {
Derived d; // 对象 d 离开作用域时会调用析构函数
return 0;
}
输出:
Derived destructor
Base destructor
Base destructor
这里,Base
的析构函数被调用了两次:
-
第一次是在
Derived
的析构函数中显式调用的。 -
第二次是在
Derived
的析构函数执行完毕后,编译器自动调用的。
这种重复调用会导致未定义行为,通常会导致程序崩溃。
-
析构函数的作用是释放对象占用的资源。如果父类的析构函数被调用两次,可能会导致资源被重复释放(例如,释放已经释放的内存),从而引发严重错误。
-
C++ 的析构机制已经确保了父类的析构函数会被自动调用,因此不需要(也不应该)显式调用父类的析构函数。
由于子类实例化对象时,先调用父类的构造函数,再调用子类的构造,那么在析构时,要先析构子类,再析构父类。因为子类可能会用到父类,显示调用父类析构,无法保证先子后父,所以子类析构函数完成后,自动调用父类析构,这样就保证了析构先子后父。
例如此情况,先析构父再析构子就发生错误了,因为_pstr是父类的
~Student()
{
Person::~Person();
cout << *_pstr << endl;
delete _ptr;
}
class Person
{
public:
//Person(const char* name = "peter")
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;
delete _pstr;
}
protected:
string _name; // 姓名
string* _pstr = new string("111111111");
};
class Student : public Person
{
public:
// 先父后子
Student(const char* name = "张三", int id = 0)
:Person(name)
,_id(0)
{}
Student(const Student& s)
:Person(s) 这里传子类s是可以的,上转型为p
,_id(s._id)
{}
Student& operator=(const Student& s)
{
if (this != &s)
{
这里如果写为operator=会发生隐藏,造成死循环
Person::operator=(s);
_id = s._id;
}
return *this;
}
~Student()
{
//Person::~Person();
cout << *_pstr << endl;
delete _ptr;
}
protected:
int _id;
int* _ptr = new int;
};
总结:
- 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
- 派生类的operator=必须要调用基类的operator=完成基类的复制。
- 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
- 派生类对象初始化先调用基类构造再调派生类构造。
- 派生类对象析构清理先调用派生类析构再调基类的析构
五、继承与友元
友元关系不能继承,即父类的友元不能被子类继承。如果也想使用父类声明的友元,那么再子类也声明以此即可
六、继承与静态成员
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例。
静态成员属于父类和派生类,在派生类中不会单独拷贝一份,派生类继承的是使用权。
class Person
{
public:
Person()
{}
//protected:
string _name; // 姓名
public:
static int _count; // 统计人的个数。
};
int Person::_count = 0;
class Student : public Person
{
protected:
int _stuNum; // 学号
};
class Graduate : public Student
{
protected:
string _seminarCourse; // 研究科目
};
int main()
{
Person p;
Student s;
cout << &p._name << endl;
cout << &s._name << endl;
cout << &p._count<< endl;
cout << &s._count << endl;
cout << &Person::_count << endl;
cout << &Student::_count << endl;
return 0;
}
举例:求父类和子类总共实例化多少对象
子类构造函数默认生成,在默认生成的构造函数中又默认调用父类默认构造,所以不需要写子类的构造函数
class Person
{
public:
Person() { ++_count; }
protected:
string _name; // 姓名
public:
static int _count; // 统计人的个数。
};
int Person::_count = 0;
class Student : public Person
{
protected:
int _stuNum; // 学号
};
class Graduate : public Student
{
protected:
string _seminarCourse; // 研究科目
};
void TestPerson()
{
Student s1;
Student s2;
Student s3;
Graduate s4;
cout << " 人数 :" << Person::_count << endl;
Student::_count = 0;
cout << " 人数 :" << Person::_count << endl;
}
七、菱形继承及菱形虚拟继承
1. 菱形继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承,用 ',' 分割。
对于派生类是多继承的情况,派生类的成员变量在内存空间的顺序为先继承的变量、后继承的变量、自身变量
有多继承就会出现菱形继承,菱形继承是多继承的一种特殊情况(不规则也属于菱形继承,只要有公共的父类,三角形、五边形等等) 继承的变量所在空间地址是相邻的
那么菱形继承就会引起一些问题,即数据冗余,例如Student类继承了Person的_name,而Teacher也继承了Person的_name,最终Assistant继承了两类的_name,这不仅会造成数据冗余,还会造成二义性(可以指定类域访问,但是数据冗余问题无法解决)
class Person
{
public:
string _name; // 姓名
int _age;
};
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 as;
as.Student::_age = 18;
as.Teacher::_age = 30;
as._age = 19; 错误,因为二义性无法明确知道访问的是哪一个
return 0;
}
2. 虚继承
C++3.0对于菱形继承的二义性,提出了虚继承的解决方案:将腰部的B、C类虚继承。
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 d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
return 0;
}
使用虚拟继承:
class A
{
public:
int _a;
};
class B : virtual public A
{
public:
int _b;
};
class C : virtual 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;
}
在继承了B、C类的D类内部增加一个变量空间(在头部或尾部)用来专门存储_a,原本的B、C类存_a的空间改为存储一个指针信息,该指针指向一个表,称为虚基表,表的内容是单个或多个偏移量,是存放指针空间地址与D内部新增的_a空间地址之间的偏移量。
虚基表可以减小D类对象所占的内存空间,且可以存储多个偏移量信息。
问:有的同学可能认为直接在存指针的地方存偏移量不就好了吗?
其实这是格局小了,如果需要存两个偏移量,那么B、C类每一处都要写两个偏移量,而如果我们将偏移量写进表内,那么当D实例化多个对象,我们只需要使每个对象的指针指向的虚基表地址相同,因为类相同,那么偏移量也是相同的,所以可以公用虚基表,这就高效的利用了空间
问:为什么要有偏移量,不能直接到D类内存最后一块直接访问吗?
答:是为了统一上转型对象以及本类对象访问_a的方式,这就是都存偏移量的意义
首先,B、C类在虚继承之后内存结构也会发生改变,内存结构与D类一致,即首地址存虚基表地址,在B类内存最后存放_a
这种情况是为了保障上转型对象能够访问_a的情况
B类指针
B* ptr = &b;
ptr->_a++;
上转型指针
ptr = &d;
ptr->_a++;
在这种情况编译器区分不了ptr是什么类的指针,编译器做的是根据首地址处存储的地址找到偏移量,再根据当前位置的地址加上偏移量去找_a
汇编指令:根据当前位置加上偏移量,取出值进行++,再放回去
例题1 :求p1 p2 p3之间的关系
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
int main() {
Derive d;
Base1* p1 = &d;
Base2* p2 = &d;
Derive* p3 = &d;
return 0;
}
这就是多继承的切片问题:多继承时,先继承的类的变量在内存空间的前面,后继承的类的变量在内存空间的后面,所以 p1 == p3 != p2
例题2:A的构造函数被调用了几次? 打印顺序是什么?
class A {
public:
A(const char* s)
{
cout << s << endl;
}
~A()
{}
};
class B :virtual public A
{
public:
B(const char* sa, const char* sb)
:A(sa)
{
cout << sb << endl;
}
};
class C :virtual public A
{
public:
C(const char* sa, const char* sb)
:A(sa)
{
cout << sb << endl;
}
};
class D :public B, public C
{
public:
D(const char* sa, const char* sb, const char* sc, const char* sd)
:B(sa, sb)
,C(sa, sc)
,A(sa)
{
cout << sd << endl;
}
};
int main()
{
D* p = new D("class A", "class B", "class C", "class D");
delete p;
//B b("class A", "class B");
return 0;
}
因为是虚继承,A构造次数闭眼选1次,打印顺序:ABCD,由于A先构造,所以A先输出,其次继承B、C所以再调用B、C的构造函数,最后调用D的构造函数
问:既然A只构造一次,那么B、C、D类都能分别去掉调用A构造的操作吗?
答:这是不可以的,会报错,因为B、C、D类都可能分别被单独使用,所以虚继承最好不使用,它会增大代码编写的复杂性。多继承还是可以使用的
3. 虚继承解决了内存冗余问题吗
总体是解决了,虽然菱形继承时D的大小为20字节,虚继承时D的大小为24字节,但是虚继承还算时解决了内存冗余问题,它付出了两个指针8字节的代价,解决了问题,但是回报很低,总大小增加了4,这是对于a字节很少的情况,如果A类的a所占空间很大,那么虚继承就是“盈利”的
八、继承和组合
-
public继承是一种is-a的关系(学生是一个人)。也就是说每个派生类对象都是一个基类对象。
-
组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。
-
如果既适用继承又适用组合,优先使用对象组合,而不是类继承 。如果只适合继承,那就使用继承
-
继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用 (white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。 继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高,因为除了私有成员,其他成员随便用。
-
对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低,因为除了公有成员,其他的都不能用。优先使用对象组合有助于你保持每个类被封装。
-
实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适 合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。
is-a:植物——花
has-a:轮胎——车
class C
{
//...
public:
20个
protected:
80个
};
//继承
class D : public C
{
100个都能用
};
//组合
class E
{
private:
C _cc;
只能用20个
};
总结
很多人说 C++ 语法复杂,其实多继承就是一个体现。有了多继承 ,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。多继承谨慎使用,避免搞出菱形继承。 多继承可以认为是C++ 的缺陷之一,很多后来的 OO(面向对象) 语言都没有多继承,如 Java
面向对象三大特性之一的继承内容基本不难,依赖类和对象阶段基本知识(如六大默认构造函数),下节我们学习面向对象三大特性的最后一个——多态。
最后,如果小帅的本文哪里有错误,还请大家指出,请在评论区留言(ps:抱大佬的腿),新手创作,实属不易,如果满意,还请给个免费的赞,三连也不是不可以(流口水幻想)嘿!那我们下期再见喽,拜拜!