🌠 作者:@阿亮joy.
🎆专栏:《吃透西嘎嘎》
🎇 座右铭:每个优秀的人都有一段沉默的时光,那段时光是付出了很多努力却得不到结果的日子,我们把它叫做扎根
目录
- 👉继承的概念及定义👈
- 继承的概念
- 继承的定义
- 1. 定义格式
- 2. 继承基类成员访问方式的变化
- 👉基类和派生类对象赋值转换👈
- 👉继承中的作用域👈
- 👉派生类的默认成员函数👈
- 构造函数
- 拷贝构造
- 赋值运算符重载
- 析构函数
- 取地址重载
- 👉继承与友元👈
- 👉继承与静态成员👈
- 👉复杂的菱形继承及菱形虚拟继承👈
- 单继承、多继承和菱形继承
- 菱形继承的二义性和数据冗余
- 虚拟继承的原理
- 👉继承的总结和反思👈
- 👉笔试面试题👈
- 👉总结👈
👉继承的概念及定义👈
继承的概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段。它允许程序员在保持原有类特性的基础上进行扩展以增加功能,这样产生新的类称为派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
#include <iostream>
using namespace std;
#include <string>
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;
s.Print();
Teacher t;
t.Print();
return 0;
}
继承后,父类的 Person 的成员(成员函数+成员变量)都会变成子类的一部分。这里就体现出了 Student 和 Teacher 复用了 Person 的成员。我们使用监视窗口查看 Student 和 Teacher 对象,可以看到变量的复用,调用 Print 函数可以看到成员函数的复用。
继承的定义
1. 定义格式
从下图,我们可以看到 Person 是父类,也称作基类。Student 是子类,也称作派生类。
注:子类对象不一定比父类对象对象大,因为有可能子类只是改写父类的方法而已,并没有增加其自身的数据成员,则大小一样。
2. 继承基类成员访问方式的变化
总结:
- 基类 private 成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
- 基类 private 成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在 派生类中能访问,就定义为 protected。可以看出保护成员限定符是因继承才出现的。
- 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式等于 min(成员在基类的访问限定符,继承方式),且 public > protected > private。
- 使用关键字 class 的默认的继承方式是 private,使用struct 的默认的继承方式是 public,不过最好显式写出继承方式。
- 在实际运用中一般使用都是 public 继承,几乎很少使用 protetced 或者 private 继承,也不提倡使用protetced 或者 private继承。因为 protetced 和 private 继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
- protected 和 private 修饰的成员变量和成员函数在类外都不能访问。
子类无法访问父类 private 修饰的成员
class 默认是 private 继承
私有成员变量和函数在类外都不能访问。
👉基类和派生类对象赋值转换👈
- 派生类对象可以赋值给基类的对象 / 基类的指针 / 基类的引用,该过程不存在类型转换,也就是不会产生临时变量(临时变量具有常性)。这里有个形象的说法叫切片或者切割寓意把派生类中父类那部分切来赋值过去。
- 基类对象不能赋值给派生类对象。
- 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用,但是必须是基类的指针是指向派生类对象时才是安全的。基类如果是多态类型,可以使用 RTTI(RunTime Type Information) 的dynamic_cast 来进行识别后进行安全转换。
- dynamic_cast 是将一个基类对象指针(或引用)转换到继承类指针,dynamic_cast 会根据基类指针是否真正指向继承类指针来做相应处理。
class Person
{
protected:
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public:
int _No; // 学号
};
int main()
{
// 中间不存在类型转换,不会产生临时变量
// 子类对象可以赋值给父类对象/指针/引用
Student s;
Person p = s;
Person& rp = s;
Person* pp = &s;
// 中间存在类型转换,会产生临时变量,临时变量具有常性
int i = 1;
double d = 2.2;
i = d;
const int& ri = d;
return 0;
}
👉继承中的作用域👈
- 在继承体系中基类和派生类都有独立的作用域。
- 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏, 也叫重定义。在子类成员函数中,可以使用基类::基类成员的方式显式访问。
- 需要注意的是:如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
- 注意:在实际开发中,在继承体系里面最好不要定义同名的成员。
class Person
{
protected:
string _name = "小李子"; // 姓名
int _num = 111; // 身份证号
};
class Student : public Person
{
public:
void Print()
{
cout << " 姓名:" << _name << endl;
cout << " 身份证号:" << Person::_num << endl;
cout << " 学号:" << _num << endl;
}
protected:
int _num = 999; // 学号
};
int main()
{
Student s1;
s1.Print();
return 0;
};
注:当子类和父类存在同名成员时,默认是访问子类的成员(就近原则)。如果想要访问父类的成员,就要指定父类的作用域了。
// 题目1
class A
{
public:
void fun()
{
cout << "A::func()" << endl;
}
};
class B : public A
{
public:
void fun(int i)
{
cout << "B::func(int i)->" << i << endl;
}
};
void Test()
{
B b;
b.fun(10);
}
// 题目二
class A
{
public:
void fun()
{
cout << "A::func()" << endl;
}
};
class B : public A
{
public:
void fun(int i)
{
cout << "B::func(int i)->" << i << endl;
}
};
void Test()
{
B b;
b.fun();
}
题目一和题目二中的父类和子类的 func 函数构成隐藏(重定义),并不是构成重载。函数构成重载的前提条件是在同一作用域,而父类和子类的作用域不是同一个作用域。题目一的运行结果是B::func(int i)->10
,而题目二的运行结果是编译报错。
题目一运行结果
题目二运行结果
出现这样的结果是因为父类和子类的 fun 函数形成了隐藏,如果想要调用父类的 fun 函数需要指定作用域。
一道选择题
关于同名隐藏的说法正确的是( )
A. 同一个类中,不能存在相同名称的成员函数。
B. 在基类和子类中,可以存在相同名称但参数列表不同的函数,他们形成重载。
C. 在基类和子类中,不能存在函数原型完全相同的函数,因为编译时会报错。
D. 成员函数可以同名,只要参数类型不同即可,成员变量不能同名,即使类型不同。
答案:D
解析:A 选项:可以存在,如函数重载。B、C 选项:基类与子类函数名字相同,参数不同,形成的是隐藏,可以共存。D 选项:成员函数在同一个类里面同名,此时构成了重载,但变量一定不能同名,故正确。
👉派生类的默认成员函数👈
6 个默认成员函数,默认的意思就是指我们不写,编译器会变我们自动生成一个。那么在派生类中,这几个成员函数是如何生成的呢?
简单回顾一下默认成员函数的行为,编译器生成的构造函数和析构函数对于内置类型不处理,对于自定义类型调用该类型的构造函数和析构函数;而编译器生成的拷贝构造和赋值运算符重载对于内置类型完成值拷贝,对于自定义类型调用该类型的拷贝构造和赋值运算符重载。
构造函数
派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员变量。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。 派生类对象初始化先构造基类再构造派生类。
拷贝构造
派生类的拷贝构造函数必须调用基类的拷贝构造完成基类成员变量的拷贝初始化。
赋值运算符重载
派生类的赋值运算符重载必须要调用基类的赋值运算符重载完成基类成员变量的赋值。需要特别注意的是:因为子类和父类的赋值运算符重载构成隐藏关系,所以调用基类的赋值运算符重载必须指定作用域,否则会调用派生类的赋值运算符重载,从而形成死递归,造成栈溢出。
析构函数
析构函数也和上面的三个默认成员函数一样,父类成员调用父类的析构函数处理,子类成员由子类自己处理。但是像下面的写法是错误的!!!
上面的写法是无法通过编译的,原因是后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同。那么编译器会对析构函数名进行特殊处理,处理成 destrutor(),所以父类析构函数不加 virtual 的情况下,子类析构函数和父类析构函数构成隐藏关系。所以我们要调用父类的析构函数,就需要指定作用域了。
指定父类的作用域调用父类的析构函数后,我们把代码运行起来,就会看到父类的析构函数的调用多了一倍。这是什么原因呢?
其实是因为子类的析构函数会在被调用完成后自动调用父类的析构函数清理父类成员,这样才能保证子类对象先清理子类成员再清理父类成员的顺序。所以我们不需要在子类的析构函数中调用父类的析构函数,否则有可能无法保证先析构子类后析构父类的顺序。
为什么要先析构子类后析构父类呢?因为父类是先于子类定义的,需要先构造父类再构造子类,而类构函数调用一般按照构造函数调用的相反顺序进行调用。但是要注意 static 对象的存在, 因为 static 改变了对象的生存周期,需要等待程序结束时才会析构释放对象。所以需要先调用子类的析构再去调用父类的析构。
注:多次析构可能会报错!!!
取地址重载
注意:取地址重载分为普通对象的取地址重载和 const
对象的取地址重载,这两个函数都不需要调用父类的取地址重载,而取地址重载一般也不需要自己写,编译器默认生成的就够用了。
完整代码
#include <iostream>
using namespace std;
#include <string>
class Person
{
public:
Person(const char* name = "peter")
: _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 num)
: Person(name)
, _num(num)
{
cout << "Student()" << endl;
}
Student(const Student& s)
: Person(s) // 切片
, _num(s._num)
{
cout << "Student(const Student& s)" << endl;
}
Student& operator=(const Student& s)
{
if (this != &s)
{
Person::operator=(s); // 切片
_num = s._num;
cout << "Student& operator=(const Student& s)" << endl;
}
return *this;
}
// 子类的析构函数和父类的析构函数构成隐藏
// 由于多态的需要,编译器会将析构函数处理成destrutor()
// 父类的析构函数不需要显式调用,它会被自动调用以确保先清理子类成员再处理父类成员
~Student()
{
//Person::~Person();
// 处理子类的成员
cout << "~Student()" << endl;
}
// 一般不需要自己写
//Student* operator&()
//{
// return this;
//}
protected:
int _num; //学号
};
int main()
{
Student s1("Joy", 19);
cout << &s1 << endl;
Student s2(s1);
Student s3("Paige", 20);
s3 = s1;
return 0;
}
👉继承与友元👈
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员。
如果想让友元函数也能访问子类的protected
和private
的成员,那么需要在子类中声明一下友元函数。
注:友元函数需要慎用,可能会破号封装性!
👉继承与静态成员👈
基类定义了 static 静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个 static 成员实例。静态成员是存储在静态区的,生命周期是程序的整个运行时间!注:静态成员变量一定要在类外进行初始化。
因为静态成员只有一个,所以父类和子类访问的静态成员都是同一个静态成员,其地址是相同的!
因为父类对象和子类对象都要调用父类的构造函数,所以在父类的构造函数里让静态成员变量_count++
,这样就能知道父类对象和子类对象的总和了。
#include <iostream>
using namespace std;
#include <string>
class Person
{
public:
Person()
{
++_count;
}
protected:
string _name; // 姓名
public:
static int _count; // 统计人的个数。
};
int Person::_count = 0;
class Student : public Person
{
protected:
int _stuNum; // 学号
};
int main()
{
Person p1;
Person p2;
Person p3;
Student s1;
Student s2;
cout << "人数:" << Person::_count << endl;
return 0;
}
👉复杂的菱形继承及菱形虚拟继承👈
单继承、多继承和菱形继承
单继承:一个子类只有一个直接父类时称这个继承关系为单继承。
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承。
菱形继承:菱形继承是多继承的一种特殊情况。
菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。在 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; // 主修课程
};
菱形继承的二义性和数据冗余
二义性问题可以通过指定作用域来解决,但是这样也还是没有解决掉数据冗余的问题,同时还会调用两次父类Person
的拷贝函数。
那如何彻底解决数据冗余的问题呢?虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在 Student 和 Teacher的继承 Person 时使用虚拟继承即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用。
#include <iostream>
using namespace std;
#include <string>
// 菱形虚拟继承
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 at;
// 菱形虚拟继承解决了二义性和数据冗余的问题
at.Student::_name = "张三";
at.Teacher::_name = "张老师";
cout << at.Student::_name << endl;
cout << at._name << endl;
return 0;
}
中间腰部两个类Student
和Teacher
虚拟继承父类Person
就能够解决掉二义性和数据冗余的问题了。
开发时,我们要尽量避免使用菱形继承。不过,标准库中也使用了菱形继承。
虚拟继承的原理
为了研究虚拟继承原理,我们给出了一个简化的菱形继承继承体系,再借助内存窗口观察对象成员的模型(注:监控窗口看到的模型可能不是实际的模型)。
我们通过对比菱形继承和菱形虚拟继承的,来探索菱形虚拟继承的原理。
class A
{
public:
int _a;
};
//class B : public A
class B : virtual public A
{
public:
int _b;
};
//class C : public A
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;
}
菱形继承的对象模型
菱形虚拟继承的对象模型
下图是菱形虚拟继承的内存对象成员模型:这里可以分析出 D 对象中将 A 放到了对象组成的最下面,这个 A 同时属于 B 和 C。那么 B 和 C 如何去找到公共的 A 呢?这里是通过了 B 和 C 的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。通过虚基表中存的偏移量可以找到下面的 A。
下图是上面的 Person 关系菱形虚拟继承的原理解释:
现在我们知道了类型虚拟继承的模型,那么我们再深入了解一下 B 对象的模型是怎么样的以及其大小是多少。
菱形虚拟继承为了解决二义性和数据冗余的问题,还是付出了一些代价的。不过,这个代价还是在可接受的范围之内的。
👉继承的总结和反思👈
- 很多人说 C++ 语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
- 多继承可以认为是 C++ 的缺陷之一,很多后来的面向对象语言都没有多继承,如 Java。
- 继承和组合
- public 继承是一种 is-a 的关系。也就是说每个派生类对象都是一个基类对象。
- 组合是一种 has-a 的关系。假设 B组合了 A,每个B对象中都有一个 A 对象。
- 优先使用对象组合,而不是类继承 。
- 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。白箱是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响(如:基类增删或删减成员变量)。派生类和基类间的依赖关系很强,耦合度高。
- 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以黑箱的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
- 实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承。比如要实现多态,就必须要继承。类之间的关系可以用继承,也可以用组合,那么就用组合。比如:栈
stack
的底层容器是vector / deque / list
,即可以用继承的方式实现,也可以用组合的方式实现,而我们选择组合的方式来实现。因为组合的耦合度低。- 组合无法访问类的
protected
成员,而继承可以访问父类的protected
成员。- is - a 关系:人和学生、植物和玫瑰花等等。
- has - a 关系:轮胎和车等等。
- 高内聚低耦合,是判断软件设计好坏的标准。这个概念主要用于程序的面向对象的设计,主要看类的内聚性是否高,耦合度是否低。目的是使程序模块的可重用性、移植性大大增强。
👉笔试面试题👈
如何定义一个无法被继承的类?
- 父类构造函数私有,子类对象实例化时无法调用父类的构造函数(C++ 98)
- 关键字 final 修饰的类,表示该类是最终类,不能被继承(C++ 11)
多继承中指针偏移问题?下面说法正确的是( )
- A:p1 == p2 == p3
- B:p1 < p2 < p3
- C:p1 == p3 != p2
- D: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;
}
答案:C
解析:位于左边的父类先继承,且位于低地址!
- 什么是菱形继承?菱形继承的问题是什么?
- 什么是菱形虚拟继承?如何解决数据冗余和二义性的
- 继承和组合的区别?什么时候用继承?什么时候用组合?
👉总结👈
本篇博客主要讲解继承的概念和定义、基类和派生类对象的赋值转换、继承中的作用域、派生类的默认成员函数、继承与友元、继承与静态成员、菱形继承以及基础和组合的区别等等。那么以上就是本篇博客的全部内容了,如果大家觉得有收获的话,可以点个三连支持一下!谢谢大家!💖💝❣️