目录
前言
一、继承概念
1. 继承概念
2. 继承定义格式
3. 继承关系和访问限定符
4. 继承基类成员访问方式的变化
二、基类和派生类对象赋值转换
三、继承中的作用域
四、派生类的默认成员函数
五、继承与友元
六、继承与静态成员
七、菱形继承及菱形虚拟继承
1. 菱形继承
2. 虚继承
总结
前言
在代码编写中,如果一段代码重复多次 被调用,那么我们会将其封装为一个函数,提高代码复用性,例如交换函数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,所以派生类的析构隐藏了基类的析构,所以在调用父类的析构时还需要加上类名::
- 由于子类实例化对象时,先调用父类的构造函数,再调用子类的构造,那么在析构时,要先析构子类,再析构父类,因为子类可能会用到父类。 显示调用父类析构,无法保证先子后父,所以子类析构函数完成后,自动调用父类析构,这样就保证了析构先子后父
例如此情况,先析构父再析构子就发生错误了,因为_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)
{}
// 10:45继续
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;
};
总结:
1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函
数,则必须在派生类构造函数的初始化列表阶段显示调用。
2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
3. 派生类的operator=必须要调用基类的operator=完成基类的复制。
4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类
对象先清理派生类成员再清理基类成员的顺序。
5. 派生类对象初始化先调用基类构造再调派生类构造。
6. 派生类对象析构清理先调用派生类析构再调基类的析构
五、继承与友元
友元关系不能继承,即父类的友元不能被子类继承
如果也想使用父类声明的友元,那么再子类也声明以此即可
六、继承与静态成员
基类定义了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对于菱形继承的二义性,提出了虚继承的解决方案
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
汇编指令:
根据当前位置加上偏移量,取出值进行++,再放回去
总结
面向对象三大特性之一的继承内容基本不难,依赖类和对象阶段基本知识(如六大默认构造函数),下节我们学习面向对象三大特性的最后一个——多态。
最后,如果小帅的本文哪里有错误,还请大家指出,请在评论区留言(ps:抱大佬的腿),新手创作,实属不易,如果满意,还请给个免费的赞,三连也不是不可以(流口水幻想)嘿!那我们下期再见喽,拜拜!