继承的概念及定义
继承的概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保
持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象
程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继
承是类设计层次的复用。
例如下面的代码中就是使用了继承的语法知识。
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; // 工号
};
继承后父类的Person的成员(成员函数+成员变量)都会变成子类的一部分。这里体现出了
Student和Teacher复用了Person的成员。下面我们使用监视窗口查看Student和Teacher对象,可
以看到变量的复用。调用Print可以看到成员函数的复用。
继承定义
下面我们看到Person是父类,也称作基类。Student是子类,也称作派生类
继承关系和访问限定符
继承基类成员访问方式的变化
一共九种的访问方式,太多了,但是我们可以看到一个规律只要是基类中的成员是private,继承下来在子类中都是不可见的,而除了私有以外,我们发现都是可见的。基类的其他成员在子类中的访问方式==min(成员在基类的访问限定符,继承方式)public>protected>private
不可见是什么呢?就是在父类中可以正常访问成员变量,而继承下来的子类在语法上限制访问,不管是在类外面还是在类里面都是不能使用的。
struct默认继承方式是public,而class 默认继承方式是private;
class person
{
protected:
string _name = "zhangsan";
};
class student:person//默认继承方式是private
{
public:
void print()
{
cout << _name << endl;
}
};
struct person
{
string _name = "zhangsan";
};
class student :person//默认继承方式是public
{
public:
void print()
{
cout << _name << endl;
}
};
基类和派生类对象赋值转换
派生类对象可以赋值给基类的对象、基类的指针以及基类的引用,因为在这个过程中,会发生基类和派生类对象之间的赋值转换.
对于下面基类和派生类
class Person
{
protected:
string _name; // 姓名
string _sex;//性别
int _age; // 年龄
};
class Student : public Person
{
public:
int _No; // 学号
};
赋值转换
void test()
{
Student s;
Person p = s;//派生类的对象的值赋给基类
Person& rp = s;//派生类的对象的值赋给基类的引用
Person* ptr = &s;//派生类的指针赋给基类的指针
}
int main()
{
test();
return 0;
}
派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片
或者切割。寓意把派生类中父类那部分切来赋值过去。
派生类对象把值赋给基类
派生类对象把只给基类的引用,使得rp变成派生类中父类的成员变量,改变rp也就会改变派生类中父类的成员变量。
派生类的指针赋给基类的指针,该父类的指针指向派生类中父类的成员变量。
继承中的作用域
每个子类和父类都有其特地的作用域,子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。
class Person
{
protected:
string _name = "小李子"; // 姓名
int _num = 111; // 身份证号
};
class Student : public Person
{
public:
void Print()
{
cout << " 姓名:" << _name << endl;
cout << " 学号:" << _num << endl;
}
protected:
int _num = 999; // 学号
};
在这边我们会发现它会优先打印子类的_num,我们想要打印父类的_num的时候就可以指定访问。
cout << " 身份证号:" << Person::_num << endl;
cout << " 学号:" << _num << endl;
如果成员函数重名呢?
class A {
public:
void fun()
{
cout << "func()" << endl;
}
};
class B : public A {
public:
void fun(int i)
{
A::fun();
cout << "func(int i)->" << i << endl;
}
};
void Test()
{
B b;
b.fun(10);
};
我们发现这个是函数重载吗?还是构成隐藏呢?根据我们之前学的定义是函数名相同,类型不同的话就构成函数重载。
void Test()
{
B b;
b.fun(10);//不能直接写成b.func()他会优先调用自己的fun函数。
b.A::fun();
};
特别注意: 代码当中,父类中的fun和子类中的fun不是构成函数重载,因为函数重载要求两个函数在同一作用域,而此时这两个fun函数并不在同一作用域。为了避免类似问题,实际在继承体系当中最好不要定义同名的成员。
派生类的默认成员函数
默认成员函数,即我们不写编译器会自动生成的函数,类当中的默认成员函数有以下六个:
基类的默认成员函数:
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)
{
cout << "Student& operator= (const Student& s)" << endl;
if (this != &s)
{
Person::operator =(s);//调用父类的运算符重载来初始化父类的成员变量
_num = s._num;
}
return *this;
}
~Student()
{
cout << "~Student()" << endl;//这边就不要调用父类的析构了,我们编译器会自动调用父类的析构,我们保证先析构子类的,然后在析构父类的
}
protected:
int _num; //学号
};
派生类与普通类的默认成员函数的不同之处概括为以下几点:
派生类的构造函数被调用时,会自动调用基类的构造函数初始化基类的那一部分成员,如果基类当中没有默认的构造函数,则必须在派生类构造函数的初始化列表当中显示调用基类的构造函数。
派生类的拷贝构造函数必须调用基类的拷贝构造函数完成基类成员的拷贝构造。
派生类的赋值运算符重载函数必须调用基类的赋值运算符重载函数完成基类成员的赋值。
派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。
派生类对象初始化时,会先调用基类的构造函数再调用派生类的构造函数。
派生类对象在析构时,会先调用派生类的析构函数再调用基类的析构函数。
在编写派生类的默认成员函数时,需要注意以下几点
派生类和基类的赋值运算符重载函数因为函数名相同构成隐藏,因此在派生类当中调用基类的赋值运算符重载函数时,需要使用作用域限定符进行指定调用。
由于多态的某些原因,任何类的析构函数名都会被统一处理为destructor();。因此,派生类和基类的析构函数也会因为函数名相同构成隐藏,若是我们需要在某处调用基类的析构函数,那么就要使用作用域限定符进行指定调用。
在派生类的拷贝构造函数和operator=当中调用基类的拷贝构造函数和operator=的传参方式是一个切片行为,都是将派生类对象直接赋值给基类的引用。
注意:
我们在构造函数,拷贝构造,赋值运算符重载中都可以显示调用,但是在析构函数中不能显示调用,基类的析构函数是当派生类的析构函数被调用后由编译器自动调用的,我们若是自行调用基类的构造函数就会导致基类被析构多次的问题。
我们知道,创建派生类对象时是先创建的基类成员再创建的派生类成员,编译器为了保证析构时先析构派生类成员再析构基类成员的顺序析构,所以编译器会在派生类的析构函数被调用后自动调用基类的析构函数。
继承与友元
class Student;
class Person
{
public:
friend void Display(const Person& p, const Student& s);
protected:
string _name; // 姓名
};
class Student : public Person
{
protected:
int _stuNum; // 学号
};
void Display(const Person& p, const Student& s) {
cout << p._name << endl;
cout << s._stuNum << endl;
}
我们不可以继承父类的里面的,友元,要想在派生类也想访问,只能在派生类中声明。友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员
class Student;
class Person
{
public:
friend void Display(const Person& p, const Student& s);
protected:
string _name; // 姓名
};
class Student : public Person
{
friend void Display(const Person& p, const Student& s);
protected:
int _stuNum; // 学号
};
void Display(const Person& p, const Student& s) {
cout << p._name << endl;
cout << s._stuNum << endl;
}
继承与静态成员
若基类当中定义了一个static静态成员变量,则在整个继承体系里面只有一个该静态成员。无论派生出多少个子类,都只有一个static成员实例。
例如,在基类Person当中定义了静态成员变量_count,尽管Person又继承了派生类Student和Graduate,但在整个继承体系里面只有一个该静态成员。
我们若是在基类Person的构造函数和拷贝构造函数当中设置_count进行自增,那么我们就可以随时通过_count来获取该时刻已经实例化的Person、Student以及Graduate对象的总个数
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;
}
我们派生类继承的是父类的静态成员的使用权,从头到尾只有一个静态成员。
cout << &Person::_count << endl; //00F1F320
cout<< &Student::_count << endl; //00F1F320
复杂的菱形继承及菱形虚拟继承
单继承:一个子类只有一个直接父类时称这个继承关系为单继承;
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
菱形继承:菱形继承是多继承的一种特殊情况。
从菱形继承的模型构造就可以看出,菱形继承的继承方式存在数据冗余和二义性的问题。
数据冗余
• 定义:同一份数据在系统中被重复存储多次。如上图有俩份A类的数据
二义性
• 定义:同一操作可能对应多个冲突的语义,导致无法明确执行。在B的变量中有A,在C中也有A.
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 at;
at._name = "张三";//就会存在二义性的问题
return 0;
}
解决二义性的问题的方法:指定访问可以解决
int main()
{
Assistant at;
at.Student::_name = "张三";//就会存在二义性的问题
at.Teacher::_name = "王丽";
return 0;
}
但是如何解决数据冗余的问题呢?因为在Assistant的对象在Person成员始终会存在两份
菱形虚拟继承
为了解决菱形继承的二义性和数据冗余问题,出现了虚拟继承。如前面说到的菱形继承关系,在Student和Teacher继承Person是使用虚拟继承,即可解决问题。
在使用菱形虚拟继承的之前,我们可以先看看之前他们的的情况做个对比
class A {
public:
int _a;
};
// class B : public A
class B : public A {
public:
int _b;
};
// class C : 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;
}
使用菱形继承之后内存窗口的变化
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的好像带最后一个位置去了,然后本来虚拟继承之前B中_a,C中_a的位置怎么好像变成指针了。
这俩个指针是啥呢?我们可以多开几个内存窗口查看一下。
我们发现指向指向的第一个位置是0,在第一个_b中我们指针指向的下一个位置是14,在16进制中14就表示20的意思,怎么得来的呢?
我们用_a的地址减去上面指针的地址所得到的偏移量就是20,转化为16进制就是14.同理另一个计算所得到的偏移量是12,16进制就是c.
如下图是菱形虚拟继承的内存对象成员模型:这里可以分析出D对象中将A放到的了对象组成的最下
面,这个A同时属于B和C,那么B和C如何去找到公共的A呢?这里是通过了B和C的两个指针,指
向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量
可以找到下面的A。
我们若是将D类对象赋值给B类对象,在这个切片过程中,就需要通过虚基表中的第二个数据找到公共虚基类A的成员,得到切片后该B类对象在内存中仍然保持这种分布情况。
B b=d;
b ._a=1;
b._b=2;
总结: