文章目录:
什么是继承?
☄️继承是面向对象三个基本特征之一,继承可以使子类具有父类的属性和方法或者重新定义、追加属性和方法。继承允许我们用另一个类来定义一个类,使得创建和维护一个程序变得简单,达到了复用代码功能和提高执行效率。
继承机制是面向对象程序设计使代码可以复用的手段,允许在保持原有类特性的基础上进行扩展功能,产生的新的类称为派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。继承是类设计层次的复用。
以下是一个最基本类的继承例子:
class Person
{
protected:
string _name = "lin"; // 姓名
string _ID = " "; //身份证号
int _age = 17; //年龄
};
class Student :public Person
{
protected:
int _stuid; //学号
};
class Teacher :public Person
{
protected:
int _jobid; //工号
};
int main()
{
Student s;
Teacher t;
return 0;
}
下面通过监视窗口查看一下上述代码:
🌏通过监视窗口可以看到Person类的成员分别被Student类和Teacher类继承了下来。
继承的定义方式:
继承之后的访问权限
🛸一个派生类继承了基类的所有方法,下列情况除外:
- 基类的构造函数、析构函数、赋值运算符重载和拷贝构造函数。
- 基类的友元函数不能被继承。
类成员/继承方式 | public | protected继承 | private继承 |
---|---|---|---|
基类public成员 | 派生类public成员 | 派生类protected成员 | 派生类private成员 |
基类protected成员 | 派生类protected成员 | 派生类protected成员 | 派生类private成员 |
基类private成员 | 派生类中不可见 | 派生类中不可见 | 派生类中不可见 |
对于上述表格总结一下:
1️⃣ 基类的私有成员在子类中不可见。基类其它成员在子类的访问方式==Min(取访问限定符小的那个),成员在基类的访问限定符及继承方式:public > protected > private。
2️⃣ 基类private成员在派生类是不可见的。即基类的私有成员被继承到了派生类中,但语法限制派生类对象不管是在类里面还是类外面都不能够访问。
3️⃣若基类成员不想在类外被访问,但需要能在派生类访问,定义为protected。
4️⃣class的默认访问方式是private,struct的默认访问方式是public,最好显示写出继承方式。在实际运用中一般都是public继承,很少用private/protected继承。
基类和派生类对象赋值转换
在public继承下:
- 派生类对象可以赋值给基类的对象 / 基类的指针 / 基类的引用,这种方法叫做切片,将派生类中基类那部分赋值给基类。
- 基类对象不能赋值给派生类对象。
- 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。必须是基类的指针指向派生类对象才是安全的。若基类是多态类型,可以使用RTTI(Run-Time Type Information)的dynamic_cast 进行识别后进行安全转换。
🛸基类和派生类赋值代码详解:
//赋值兼容规则
class Person
{
protected:
string _name = "Alex";
string _telNumber = "666";
int _age = 17;
};
class Student :public Person
{
public:
int _stuid;
};
int main()
{
Student s;
// 派生类对象可以赋值给 基类对象/指针/引用
Person p = s;
Person* pPtr = &s; //基类指针指向派生类对象,ptr只能访问继承的基类成员
Person& pRef = s; //派生类对象包含基类部分成员别名
// 基类对象不能赋值给派生类对象
//s = p; //error
// 基类的指针可以通过强制类型转换赋值给派生类的指针
pPtr = &s;
Student* pstu1 = (Student*)pPtr; // 这种情况转换可以赋值
pstu1->_stuid = 40;
pPtr = &p;
Student* pstu2 = (Student*)pPtr; // 这种情况转换时虽可以,但是存在越界访问的问题
pstu2->_stuid = 37;
return 0;
}
继承时类的作用域
1️⃣ 在继承体系中基类和派生类都具有独立的作用域。
2️⃣ 若派生类和基类中有同名成员,派生类的成员将屏蔽基类对同名成员的直接访问,这叫做隐藏(重定义),若想要访问基类中被隐藏的成员,可以加上域作用限定符,指定调用基类的成员,则可以访问。
3️⃣ 派生类和基类的成员函数只要函数名相同就构成隐藏。所以在继承体系里我们最好不要定义同名成员。
隐藏是指派生类的函数或成员屏蔽了基类中与其同名的函数或者成员,构成隐藏的规则:
- 若派生类函数与基类函数同名,但是参数不同。无论是否有virtual,基类的函数将被隐藏。
- 若派生类函数与基类函数同名,并且参数相同,基类函数无virtual,则基类的函数被隐藏。
1、基类与派生类成员变量构成隐藏关系
➡️ 基类与子类的_num成员函数名相同,则构成隐藏关系,派生类的_num成员隐藏了基类的_num成员。若想要访问基类中构成隐藏的成员,需要加访问限定符指定,否则访问的就是派生类的。由此看出基类和派生类有相同名字的成员很容易混淆。
class Person
{
protected:
string _name = "张三";
int _num = 17; //年龄
};
class Student :public Person
{
public:
void Print()
{
cout << "姓名:" << _name << endl;
cout << "年龄:" << Person::_num<< endl; //这里想要访问基类的成员,我们需要加上域作用限定符指定我们的访问
cout << "学号:" << _num << endl;
}
private:
int _num = 77;
};
int main()
{
Student s;
s.Print();
return 0;
}
2、基类与派生类成员函数构成隐藏关系
➡️ 这里我们需要注意的一点A中的func和B中的func不构成重载关系,因为它们没有在同一个作用域中,它们的func成员函数名相同,所以构成了隐藏关系,若不明确指定,调用func时将调用的是派生类的func。
class A
{
public:
void func()
{
cout << "func()" << endl;
}
};
class B :public A
{
public:
void func(int i)
{
A::func();
cout << "func(int i): " << i << endl;
}
};
int main()
{
B b;
b.func(7);
return 0;
}
🤖 总结一下:
派生类将继承的基类的同名的成员变量和同名函数隐藏起来,通过派生类访问只能访问到派生类的成员变量和函数。若想要访问基类的成员和函数需要加上基类的作用域指定。在允许的情况下,我们尽量不要将基类和派生类的成员或者函数设计同名,避免混淆。
继承与友元的关系
友元关系不能被继承,即基类友元不能访问子类私有和保护成员。
派生类的默认成员函数
每个类中,若不实现特定的默认函数,类中会自动生成这些函数,即类的6个默认成员函数。它们是特殊的成员函数,若我们不去实现,编译器会自动生成。
- 派生类的构造函数
🛸派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。若基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。派生类对象初始化先调用基类的构造函数在调用派生类构造函数。
class Person
{
public:
Person(const char* name = "leo")
:_name(name)
{
cout << "Person(const char* name )" << endl;
}
protected:
string _name;
};
class Student :public Person
{
public:
//调用基类构造函数初始化继承的基类的那部分,然后初始化自己成员
Student(const char* name,int stuid)
:Person(name)
,_stuid(stuid)
{
cout << "Student(const char* name,int stuid)" << endl;
}
protected:
int _stuid;
};
int main()
{
Student s1("张三",46);
return 0;
}
- 派生类的拷贝构造函数和赋值运算符重载
🛸派生类的拷贝构造函数必须调用基类的拷贝构造函数完成基类的拷贝初始化。派生类的operator=必须调用基类的operator=完成基类的赋值。派生类operator=调用基类赋值运算符重载时,需要指定类域,因为基类与派生类赋值运算符构成隐藏关系。
class Person
{
public:
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;
}
protected:
string _name;
};
class Student :public Person
{
public:
Student(const Student& s)
:Person(s) // s传递给person& s 这是一个切片行为
, _stuid(s._stuid)
{
cout << "Student(const Student& s)" << endl;
}
Student& operator=(Student& s)
{
if (this != &s)
{
// 这里调用基类的赋值函数需要指定作用域,因为派生类的operator=与基类的
// 构成隐藏,不指定就调用不到基类,导致栈溢出
Person::operator=(s);
_stuid = s._stuid;
}
cout << " Student& operator=(Student& s)" << endl;
return *this;
}
protected:
int _stuid;
};
- 派生类的析构函数
🛸因为多态的原因,析构函数需要构成重写,重写的条件即函数名相同,因此编译器对任何类的析构函数名统一处理成destructor(),基类函数不加virtual的情况下,编译器认为派生类和基类的析构函数构成隐藏。则派生类的析构函数被调用完后自动调用基类的析构函数清理基类的资源。这样就保证了对象先清理派生类的资源再清理基类资源的顺序。
class Person
{
public:
~Person()
{
cout << "~Person()" << endl;
}
protected:
string _name;
};
class Student :public Person
{
public:
~Student()
{
//Person::~Person(); //这里想要调用需要指定是基类的,
cout << "~Student()" << endl;
}
protected:
int _stuid;
};
继承与静态成员关系
🪂基类定义了static静态成员,则整个继承体系里就只有这一个这样的成员。
以下代码定义了三个类,Student类继承Person类,Graduate类继承Student类。在Person类我们定义了一个公有的静态成员变量。程序的目的是统计定义了多少个对象,每定义一个对象,都要调用Person的构造函数初始化,所以Person类的构造函数对静态成员变量进行++统计次数。
class Person
{
public:
Person()
{
++_count;
}
protected:
string _name;
public:
static int _count;
};
int Person::_count = 0;
class Student :public Person
{
protected:
int _stuid;
};
class Graduate :public Student
{
protected:
string _researchProjects;
};
int main()
{
Student s1;
Student s2;
Student s3;
Graduate g1;
cout << " 对象个数:" << Person::_count << endl;
Student::_count = 0;
cout << " 对象个数:" << Person::_count << endl;
Graduate g2;
Student s4;
cout << " 对象个数:" << Person::_count << endl;
Student::_count = 0;
cout << " 对象个数:" << Person::_count << endl;
return 0;
}
程序运行结果:
总结:
- 基类和派生类共享该基类的静态成员变量内存。
- 父类的static成员变量和函数在派生类可用,但是受到访问限定符的限制,即基类的private里面的就不可以访问。派生类和基类中的static变量是共用空间的,所以用static变量进行引用计数时需要注意。
- static函数没有虚函数,因为static函数是一个受访问限定符限制的全局函数,全局函数在编译时就已经确定地址了,虚函数是在运行时确定地址,所以static函数不能够是虚函数。
菱形继承和菱形虚拟继承
基本的菱形继承即两个派生类继承同一个基类,两个派生类又作为基本继承给到同一个派生类。这样的继承就称为菱形继承。
❗菱形继承所产生的问题:菱形继承有数据冗余和二义性问题。Graduate类继承了两个基类,而这两个基类又继承了同一个基类,即在Graduate类对象中有两份Person的成员。
菱形继承存在二义性和数据冗余代码的演示:
class Person
{
public:
string _name;
};
class Student :public Person
{
protected:
int _stuid;
};
class Teacher :public Person
{
protected:
int _jobid;
};
class Graduate :public Student, public Teacher
{
protected:
string _course;
};
int main()
{
Graduate g;
//g._name = "jack"; //存在二义性,编译器无法明确访问哪一个_name;
//解决二义性问题需要指定访问哪个类域的成员。但是数据冗余问题依旧存在
g.Student::_name = "Alex";
g.Teacher::_name = "Chry";
return 0;
}
通过监视窗口查看上述代码:
❓那c++是如何解决菱形继承带来的二义性和数据冗余问题的呢?
⏩虚拟继承可以解决菱形继承所带来的问题。即在Teacher类和Student类继承Person的地方加上virtual,即可解决问题。如下:
class Person
{
public:
string _name;
};
class Student :virtual public Person
{
protected:
int _stuid;
};
class Teacher :virtual public Person
{
protected:
int _jobid;
};
class Graduate :public Student, public Teacher
{
protected:
string _course;
};
int main()
{
Graduate g;
g._name = "jack";
g.Student::_name = "Alex";
g.Teacher::_name = "Chry";
return 0;
}
菱形虚拟继承是怎样解决菱形继承的问题?
❓c++编译器是如何通过虚继承来解决数据冗余和二义性的?我们用下面简化的代码来演示:
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的成员分别存了指针,这里通过B和C的两个指针,这两个指针就是虚机表指针,指向的两个表叫做虚机表。虚机表里面存储了偏移量,偏移量即与公有基类成员的相对距离。编译器用偏移量就可以找到下面存储的A。
✔️将B和C里面公有的A的成员不存储与它们其中一个,而是单独存储一份。它们共用这一份A,这样就解决了菱形继承的数据冗余和二义性。
总结:菱形虚拟继承底层结构复杂,一般不建议设计出菱形继承,容易出错,而且效率上也不能得到保证。由于菱形继承复杂,所以在后面的一些语言中,如JAVA中就没有设计出菱形继承的语法。
组合与继承
c++程序开发中,设计一个独立的类非常容易,设计几个类相关联却相对较难。几个类相关联可以考虑使用继承或者组合。组合是 has-a 的关系,继承是 is-a 的关系。
// Car 和 Bicycle 构成 is-a 关系 ---- 自行车是车的一种,拥有车的属性
class Car
{
protected:
string _color = "绿色";
double _price;
string _carNumber;
};
class Bicycle :public Car
{
public:
void Drive() { cout << "运动 - 好开" << endl; }
};
// Tire 和 Car 构成 has-a 关系 ---- 轮胎是属于车的,车上有轮胎
class Tire
{
protected:
string _brand; //品牌
int _diameterSize; //直径大小
};
class Car
{
protected:
string _color = "绿色";
double _price;
string _carNumber;
Tire _t; // 轮胎
};
🎯继承可以根据基类的实现来定义派生类的实现。通过生成派生类的复用通常被称作"白箱复用"。即在继承方式中,基类的内部成员和细节对于派生类可见。继承在一定程度上破坏了类的封装性。派生类与基类的耦合度很高,基类的改变对于派生类影响较大。
🎯组合是类继承之外的另外一种复用选择。很多复杂的功能可以通过组合对象来得到。对象组合要求被组合对象有良好定义的接口。这种复用通常被成为"黑箱复用",被组合对象的内部细节不可见。组合相较于继承之间没有太强的依赖关系,耦合度低。
🎯在组合和继承都可以选择的情况下,优先使用对象组合。组合的耦合度低,代码可维护性高。只有在适合用继承的时候才用继承。