继承
在面向对象编程语言中,都有三大特性:封装,继承和多态。
今天我们就来研究一下,C++中的继承。
概念
继承是在面向对象的编程中,把一些相同或者相近的属性给抽象出来,以此达到代码的复用功能,大大的提高了程序的开发效率。具体的讲就是子类拥有了父类的所有成员。
通俗的讲就是在写程序时,有时候需要定义一个人类,好不容易写完,写全一个人的类,现在又要写一个学生类,那么没有继承就需要把人类有的属性在学生类中写一遍,再加上学生类特有的属性。想一想,我们为什么不把人这个类复用起来呢?人类中的属性学生类中也都有,所以我们可以用学生类来继承人类,那么人类就是学生类的父类也可以叫基类,子类就是学生类也叫派生类。
代码举例:
// 父类
class Person
{
public:
void fun()
{
cout << name <<endl;
}
protected:
string name;
};
// 子类
class Student : public Person // 继承关系
{
private:
string num;
};
- 继承方式
重点来了。
继承中分为三种继承方式公有继承(public)、保护继承(protected)、私有继承(private)。
继承之间有什么区别呢?
公有继承:是访问权限在父类中是什么权限在子类中也是什么权限,是继承过来了父类的成员变量和成员函数,而没有改变权限。(权限稍后解释)
保护继承:保护继承就是在父类中的比保护权限大的成员变量和成员函数都变成保护权限。而比保护权限小的不变。怎么理解呢?
父类中public修饰的成员保护继承后也将变成保护权限,而父类中保护成员到子类中则还是protected,私有成员被继承后,在子类中是不可见成员,但在子类中确实存在。
私有继承:私有继承就是把父类的成员继承过来后,父类成员变成了私有属性,只能被类的内部访问,不能被外部访问。父类中私有成员变为不可见。
我么来总体总结为一副图:
访问限定符(权限是我自己起的,便于理解!哈哈!)
访问限定符有三种:公有,保护,私有。你没有看错,和继承方式中 三个一样。
在这里就说说各自的功能,公有限定符,可以被类外的对像直接访问,保护限定符,可以被继承的类进行访问,但是不能被类外访问。私有限定符,只能在类的内部进行访问,被继承后变为不见。
赋值兼容性规则
在公有继承中会出现把子类对象赋值给父类,这样的做法是完全可以的。我们可以称为切割。
1)不能把父类对象赋值给子类对象。
2)能把子类对象赋值给父类对象。(因为在赋值时候会形成临时变量,而临时变量具有常性,所以会切割一份和父类一样的,赋值给父类)
我们用一张图理解。
在上图中,父类就是成员函数和成员变量一定包含在子类中,而子类中的成员函数和成员变量不一定全都在父类中,所以就子类可以赋值给父类,而父类不能赋值给子类。
还有两点:
1)父类的指针或者引用可以指向子类
2)而子类的指针或者引用不能指向父类(非要指向可以强转,但后果可能崩掉)
隐藏
隐藏是在继承中,子类和父类有相同的名称,当子类继承父类后,父类中与子类相同命名的成员变量会隐藏起来。意思就是假设父子类中都有个变量a,如果在子类访问,会直接访问到子类中的那个成员变量a,而不是父类中的a这个成员变量。
如果要在子类中访问父类的a,那么必要要加上父类的域,例如:person::a;
对于函数而言:对于成员函数来说的,如果子类和父类都有同名的函数,那么在继承过程中,子类会对父类函数进行隐藏,如果要访问父类中同名函数就要加上域名。
在这中可以定义同名变量或者同名函数是因为每个类都有自己独立的作用域。
这里要注意:重载是在同一作用域而言,而重定义或者叫隐藏,实在不同的作用域中的。
关系重、载重定义、重写三个不同后面比较解释
子类(派生类)中的默认六个成员函数
在C++中的类中有六个默认成员函数,分别为:
- 构造函数
- 拷贝构造函数
- 析构函数
- 赋值操作符重载
- 取地址操作符重载
const修饰的取地址操作符重载
在继承中这六个默认函数是编译系统自动合成的。
换句话说就是子类继承了父类那么要怎么写子类的六个默认成员函数呢?
我们这里用代码来体现:
// 父类
class Person
{
public:
// 父类构造函数
Person(const char* _name)
:name(_name)
{}
// 父类的拷贝构造函数
Person(const Person& n)
:name(n.name)
{}
// 父类赋值运算符重载
Person& operator=(const Person& p)
{
// 一定要先判断是否是自己本身
if (this != &p)
{
name = p.name;
}
return *this;
}
void fun()
{
cout << name <<endl;
}
protected:
string name;
};
// 子类
class Student : public Person // 继承关系
{
public:
// 子类构造函数
Student(const char* _name, int _num)
//在这里直接调用了父类的构造函数,构造顺序按照成员数据定义顺序而构造
//在子类中的初始化顺序是先初始化父类成员再初始化子类中。
:Person(_name),num(_num)
{}
// 子类的拷贝构造函数
Student(const Student& s)
// 这里就采用了切割,可以把子类对象赋值给父类对象
:Person(s),num(s.num) // 注意这里必须用初始化列表
{}
Student& operator=(const Student& s)
{
if (this != &s)
{
// 这里调用了父类中的赋值运算符重载
Person::operator=(s);
num = s.num;
}
return *this;
}
private:
int num;
};
还有两个默认函数,一般用系统默认即可。
菱形继承
单继承与多继承
在C++中支持多继承,意思就是一个子类可以有多个父类。
单继承就像上面Person类和Student类。
而多继承我们举个例子来说明。
// 父类1
class Teacher
{
public:
void fun()
{
cout << name <<endl;
}
protected:
string name;
};
// 父类2
class Student
{
public:
void function()
{
cout<<studnum<<endl;
}
private:
string studnum
};
// 子类
class Assistant : public Teacher, public Student
{
public:
void function()
{
cout<<Assnum<<endl;
}
private:
string Assnum;
};
如图:
菱形继承
在多继承中虽然能集成各种的类型,但是有些类具有相同的成员变量,比如:
在这样的继承中Tearcher类和Student类继承Person类,而Assistant类又继承了Teacher和Student。当Person类中有一个name成员变量,被Teacher和Student继承,又被Assistant继承,那么在Assistant中访问name就不知访问哪个父类中的name变量了。
菱形继承带来的问题
1)存在二义性
2)数据冗余空间浪费
怎么解决菱形继承呢?
这里我们就引出了一个关键字 virtual
如果出现了菱形继承,那么我们为了解决上面的两个问题,我们可以给中间继承的两个类加上virtual。这样就形成了虚继承的概念。
看代码演示:
// 爷爷类(自己发明的!^_^)
class Person
{
public:
void fun()
{
cout << name <<endl;
}
protected:
string name;
};
// 父类1
class Teacher : virtual public Person // 这里就是虚继承
{
public:
void fun1()
{
cout << Teachnum <<endl;
}
protected:
string Teachnum;
};
// 父类2
class Student : virtual public Person // 虚继承
{
public:
void function()
{
cout<<studnum<<endl;
}
private:
string studnum
};
// 子类
class Assistant : public Teacher, public Student
{
public:
void function()
{
cout<<Assnum<<endl;
}
private:
string Assnum;
};
在上上面的菱形继承中,Person类的name成员变量被Assistant类继承了两次,所以里面就会有两个name,那么我们加上virtual关键字后,结构就会改变。
咱们可以这样理解:
上面这里是在vs中的虚继承存出,是用偏移量进行记录name成员变量。
注意:虽然菱形继承可以用虚继承来处理,但是虚继承在性能开销也是比较大的。所以尽可能不要有菱形继承。
还有一个重点,实现一个不能被继承的类
在实现一个类中,如果要不被继承那么我们就可以在该类的构造函数中将它私有,但是又会有问题,如果私有后,那么该类自己也不能在类外进行创建对象,私有成员不能在类外被访问,但是可以在类里面访问,所以我们可以在类里面提供公有的函数来进行创建对象,但是类里的成员函数是属于对象的,而我们要通过该成员函数进行创建对象,这样就很矛盾。那么就要定义成静态成员函数,这样该函数就不属于对象,而是本类,可以通过类名来调用。那么总结一下就是:
- 不能被继承的类的构造函数一定要是私有的。
- 要同过静态的成员函数进行调用构造函数实例化对象
具体我们用代码实现一下:
class Demo
{
public:
// 用静态函数辅助构造函数
static Demo* GetDemo(int a)
{
// 这个是在堆上开辟的
return new Demo(a);
}
static Demo GetDemo(int a)
{
// 这是在栈上开辟的,并且是一个匿名对象
return Demo(a);
}
private:
// 将构造函数进行私有化,被子类继承为不可见
Demo(int _a):a(_a){}
int a;
};
继承中的注意点
1)继承中,基类的友元是不能被派生类继承的,也就是说,父类的友元不能访问子类中的保护和私有。
2)静态static,在继承中父类定义了静态成员,那么在整个继承中只有一个这样的成员,无论派生出多少子类,都只有一个static成员实例。
多态
在了解多态前我们先要了解一下虚函数。
什么是虚函数。
虚函数–在类的成员函数前加上关键字virtual就形成了虚函数。(要注意这里的虚函数与上面的虚继承没有一点关系,这个要分清)
为什么会有虚函数这样的函数存在呢?这个就是为了后面的多态。
虚函数还有一个特点虚函数重写:继承中父类中虚函数与子类中的虚函数相同,什么叫相同,就是函数名,返回值,参数,和修饰词virtual都要一样,但是函数体内容可以不一样,这样就叫子类虚函数对父类虚函数的重写也可以叫覆盖。
注意:有虚函数的类中,会多开辟一个指针的字节,去指向虚表。虚表就是因为虚函数的重写而有的存储方式。这样可以减少类的每个对象多开辟出额外的空间。
我们来总结一下虚函数:
- 派生类重写基类中的虚函数,需要函数名,返回值,参数都相同(除过协变)协变:就是基类的返回值是基类的指针或者引用,派生类返回值是派生类的指针或者引用,也构成重写。
- 如果在基类中定义了虚函数,那么在派生类中也保持虚函数的特性。
- 只有类的成员函数才能定义成虚函数。
- 静态成员函数不能定义成虚函数。
- 如果在类外定义虚函数,虚函数关键字必须在声明上,而类外的虚函数不需要加关键字。
- 构造函数不能为虚函数,operator=可以成虚函数,但是建议不要虚函数,容易产生混淆。
- 不要在构造函数和虚构函数中调用虚函数,因为在构造函数中对象还没有定义出来,容易发生未定义行为。
- 如果在继承中,最好把父类的析构函数定义成虚函数。这样可以构成多态,析构的时候是按照对象进行析构,如果定义成person* a = new Student;这样的话就会在虚表中查找相应的析构函数。如果不构成多态,那么会按照类型调用析构,上面的就会去调用父类的析构函数,但是new出来的对象是子类,会造成内存泄漏。
多态
有了虚函数那么我们再来看多态。
多态是在继承中一个函数有多种形态。先说满足多态的条件
1. 必须在继承中,存在虚函数,并且有虚函数重写
2. 必须是父类的指针或者引用调用重写的虚函数。
多态就是一个函数有多种形态,换句话就是当父类的指针或者引用指向父类时就调用的是父类中重写的虚函数,当指向子类时就调用的是子类中重写的虚函数。
举例子说明:
// 父类
class Person
{
public:
virtual void function()
{
cout << "父类" <<endl;
}
protected:
string name;
};
// 子类
class Student : public Person // 继承关系
{
virtual void function()
{
cout << "子类" <<endl;
}
private:
string num;
};
// 调用的函数
void fun(Person& p)
{
p.function();
}
int main()
{
Person p;
Student s;
fun(p);
fun(s);
}
运行结果:
这样就形成了多态。形成多态后,函数参数只与对象有关,与类型无关,传过去的是子类对象就调用子类重写的虚函数,传父类对象就调用父类重写的虚函数。
纯虚函数
什么是纯虚函数,在成员虚函数的形参后面加上=0,成员函数就为纯虚函数。
包含纯虚函数的类叫抽象类(接口类),抽象类不能实例出对象。纯虚函数在子类中重写了纯虚函数,才能实例出对象。如果不重写纯虚函数,那么子类也会变成抽象类,实例不出对象。
代码体现:
class A
{
public:
// 纯虚函数
virtual void fun() = 0;
protected:
int a;
};
什么时候定义成纯虚函数?当想让子类必须重写你的纯虚函时,可以定义成纯虚函数。