继承是面向对象程序设计中使代码可以复用的手段,可以让程序员在原有类的属性上扩展功能。
我们大部分同学实际上都很熟悉继承的基础知识了,所以本篇文章我只记录一些易错点和难点。
继承的方式及访问限定符
下面就是一个Student类继承一个Person类的小例子:
class Person
{
public: //此处public是访问限定符
string _name;
}
class Student : public Person //此处public是继承的方式
{
public:
size_t _id;
}
访问限定符和继承方式都有以下三种:
- public
- protected
- private
对于父类中不同访问限定符修饰的成员以及子类用不同的继承方式继承都会使父类中的成员在子类中的访问方式发生变化。
而我们要记住的就是:基类中成员的访问方式是有规则的-->是访问限定符和继承方式中较小的那个。而权限大小为:public>protected>private。
而基类中成员的访问方式是public或protected时,在派生类中的访问方式就是min(访问限定符,继承方式);而基类中成员的访问方式为private时,无论什么继承方式,在派生类中都不可见。
注意:实际工作中,我们一般都只使用public,很少使用protected和private继承。
默认继承方式
我们在使用class关键字时默认继承方式为private,在使用struct关键字时默认继承方式为public。
class Person
{
public:
string _name;
}
class Student : Person //此处默认为private继承方式
{
public:
size_t _id;
}
class Person
{
public:
string _name;
}
struct Student : Person //此处默认为public继承方式
{
public:
size_t _id;
}
基类派生类对象赋值转换
派生类对象可以赋值给基类的对象,指针及引用,在这个过程中,会发生基类和派生类对象之间的赋值转换。
就以下面的Person和Student类为例,
class Person
{
public:
string _name;
string _sex;
int _age;
};
class Student : public Person
{
private:
int _id;
};
我们可以做以下赋值操作:
Student s;
Person p = s; //将派生类对象赋值给基类对象
Person* ptr = &s;//将派生类对象赋值给基类指针
Person& ref = s; //将派生类对象赋值给基类引用
而对于这种做法,我们给他取个名字叫做切片:
派生类对象给基类对象赋值:
派生类对象给基类指针赋值:
派生类对象给基类引用赋值:
我们需要注意的是:基类对象不能给派生类对象赋值,基类的指针可以通过强转赋值给派生类的指针,但是基类的指针必须是指向派生类的对象才是安全的。
继承的作用域
在继承体系中,基类和派生类都有自己独立的作用域,若基类和派生类中有同名成员,子类将屏蔽父类对同名成员的直接访问,我们称之为隐藏或者重定义。
例如:
class Person
{
protected:
int _num = 1;
};
class Student : public Person
{
public:
void fun()
{
cout << _num << endl;
}
protected:
int _num = 2;
};
int main()
{
Student s;
s.fun(); //2
return 0;
}
若此时我们想访问父类当中的成员,可以加上作用域限定符:
void fun()
{
cout << Person::_num << endl; //想要访问父类当中的_num
}
注意:如果是成员函数的隐藏只要函数名相同就构成隐藏。
例如下面的代码,直接调用的话调用的都是子类的fun函数,若想调用父类的fun函数,就要加上作用域限定符:
class Person
{
public:
void fun(int x)
{
cout << x << endl;
}
};
class Student : public Person
{
public:
void fun(double x)
{
cout << x << endl;
}
};
int main()
{
Student s;
s.fun(1.1); //直接调用子类当中的成员函数fun
s.Person::fun(2); //指定调用父类当中的成员函数fun
s.Person::fun(3.3)//这里也是指定调用的父类当中的成员函数fun
return 0;
}
注意:这里我们一定要注意此时子类和父类中的fun函数构成的不是重载,而是重写,函数重载是指两个同名函数在同一作用域中,而此时两个fun函数并没有在同一个作用域中,因为子类和父类都有独立的作用域。
派生类的默认成员函数
和最普通的类一样,派生类的默认成员函数还是那6个:
- 构造函数
- 析构函数
- 拷贝构造
- 赋值重载
- 取地址重载
- const对象取地址重载(最后两个很少让我们自己实现)
接下来我们看看派生类中的默认成员函数和普通类的默认成员函数不同的地方:
我们先创建一个Person类为基类:
class Person
{
public:
Person(const string name="Lxj")
:_name(name)
{
cout << "Person(const string name)" << 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;
}
private:
string _name;
};
我们再创建一个派生类Student,其中Student类的默认成员函数逻辑如下:
class Student:public Person
{
public:
Student(const string& name,int stuid)
:Person(name) //调用基类的构造函数初始化基类的那些成员
,_stuid(stuid)
{
cout << "Student(const string& name,int stuid)" << endl;
}
Student(const Student& s)
:Person(s) //调用基类的拷贝构造函数初始化基类的那些成员
, _stuid(s._stuid)
{
cout << "Student(const Student& s)" << endl;
}
Student& operator=(const Student& s)
{
cout << "Student& operator=(const Student& s)" << endl;
if (&s != this)
{
Person::operator=(s); //调用基类的operator=函数给基类的成员赋值
_stuid = s._stuid;
}
return *this;
}
~Student() //派生类的析构函数会在调用完派生类的自己的析构函数后,自动调用基类的析构函数
{ //注意是先调用派生类的析构函数,再自动调用基类的析构函数
cout << "~Student()" << endl;
}
private:
int _stuid;
};
int main()
{
Student s("Lxj",20);
return 0;
}
概括一下派生类和普通类的默认成员函数的不同之处:
- 派生类的构造函数在调用时,会自动调用基类的构造函数初始化基类的那一部分成员,如果基类当中没有默认构造函数,则必须在派生类构造函数的初始化列表中显示调用基类的构造函数。
- 派生类的拷贝构造函数必须调用基类的拷贝构造函数完成基类成员的拷贝构造
- 派生类的赋值运算符重载函数必须调用基类的赋值运算符重载函数完成基类成员的赋值
- 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员
- 派生类对象初始化时,会先调用基类的构造函数再调用派生类的构造函数
- 派生类对象在析构时,会先调用派生类的析构函数再调用基类的析构函数
在编写派生类的默认成员函数时,需要注意几点:
- 派生类和基类的赋值运算符重载函数因为函数名相同构成隐藏,因此在派生类中调用基类的赋值运算符重载函数的时候,需要使用作用域限定符进行指定调用。
- 由于C++的多态,任何类的析构函数的函数名都会统一被处理为destructor()。因此,派生类和基类的析构函数也会因为函数名相同构成隐藏,若时我们需要在抹些地方调用基类的析构函数,需要我们手动在前面加上作用域。
- 在派生类的写拷贝构造函数和operator=中调用基类的拷贝构造函数和基类的operator=中传参的方式是一种切片行为,都是将派生类对象直接赋值给基类的引用。
提示:
基类的构造函数,拷贝构造函数,赋值运算符重载函数,我们都是在派生类中自行调用的,而基类的构造函数是派生类的析构函数在调用过派生类自己的析构函数后自动调用的,我们如果在派生类的析构函数中还手动调用基类的析构函数的话,会导致基类的成员被析构两次,也就是内存越界的问题。
编译器为什么这样做呢?
是因为创建派生类对象的时候是先创建基类的成员再创建派生类的成员,编译器为了保证析构的时候能先析构派生类的成员再析构基类的成员,也就做出了这样先调用派生类析构函数,再自动调用基类的析构函数的行为。
继承与友元
友元关系不能继承,也就是说基类的友元函数可以访问基类的私有和保护成员,但是不能访问派生类的私有和保护成员。
例如:下面的Display函数就无法访问Student中的_id,只能访问Student中的_name。
class Person
{
public:
//声明Display是Person的友元
friend void Display(const Person& p, const Student& s);
protected:
string _name; //姓名
};
class Student : public Person
{
protected:
int _id; //学号
};
void Display(const Person& p, const Student& s)
{
cout << p._name << endl; //可以访问
cout << s._id << endl; //无法访问
}
int main()
{
Person p;
Student s;
Display(p, s);
return 0;
}
如果想访问派生类中的对象,那就只能在派生类中再进行友元生明。
继承与静态成员
若基类当中定义了一个静态成员变量,则在整个继承体系中就只有一个该静态成员。无论派生出多少个子类,都只有一个static成员实例。
例如我们在下面的基类Person中定义了静态成员变量_count,尽管Person中又继承了Student和Graduate,但是在整个继承体系中只有一个该静态成员变量。
下面的代码中,我们在基类Person的构造和拷贝构造中设置_count进行自增,那么我们就可以根据_count来获取该时刻已经实例化的Person,Student以及Graduate对象的总个数。
class Person
{
public:
Person()
{
_count++;
}
Person(const Person& p)
{
_count++;
}
protected:
string _name; //姓名
public:
static int _count; //人的个数。
};
int Person::_count = 0; //静态成员变量在类外进行初始化
class Student : public Person
{
protected:
int _stuNum; //学号
};
class Graduate : public Person
{
protected:
string _rCourse; //研究科目
};
int main()
{
Student s1;
Student s2(s1);
Student s3;
Graduate s4;
cout << Person::_count << endl; //4
cout << Student::_count << endl; //4
//通过打印_count地址证明两个类的_count是同一个变量
cout << &Person::_count << endl; //00007FF60B3C34C4
cout << &Student::_count << endl; //00007FF60B3C34C4
return 0;
}
继承的方式
继承方式主要分为两类,单继承和多继承。
单继承
我们上面举得例子都是单继承,即:一个子类只有一个直接父类时称这种继承关系为单继承。
例如下面的例子就是单继承:
多继承
多继承即:一个派生类有两个或两个以上的基类时就称这种继承关系为多继承。
如下面的例子就是多继承:
菱形继承
菱形继承其实多继承的一种,为什么把它单独拿出来说一说呢?是因为它其实很特殊。
例如下面的例子,就是一种菱形继承:
我们通过上面的构造模型就可以看出来,菱形继承的方式存在数据冗余和二义性的问题。
例如,我们将上面Assistant类实例化出一个对象后,访问成员时就会出现二义性问题。
#include <iostream>
#include <string>
using namespace std;
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 a;
a._name = "peter"; //二义性:无法明确知道要访问哪一个_name
return 0;
}
编译器就会报出这样的错误:
因为Assistant继承了Student类和Teacher类,而这两个类又都继承了Person,因此Student类和Teacher类中都有_name成员,若直接访问Assistant对象的_name成员就会出现访问不明确的问题。
解决办法
方法一(我们不建议):
就是既然我们直接访问的时候会出现访问不明确的问题,那么我们就给其加上访问限定符。如:
//显示指定访问哪个父类的成员
a.Student::_name = "张三同学";
a.Teacher::_name = "罗翔老师";
但这种方法虽然能够解决二义性的问题,但是仍然不能解决数据冗余的问题,因为Person中的成员在Assistant中还是存在两份,即存在两个_name,而我们只是想要一个。
方法二(virtual虚拟继承):
为了解决菱形继承出现的问题,C++特地出了虚拟继承机制。如前面我们举得菱形继承的例子:
代码如下,给中间的两个继承派生类加上virtual关键字即可:
#include <iostream>
#include <string>
using namespace std;
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 a;
a._name = "peter"; //此时就没有二义性了,可以直接访问
return 0;
}
此时我们就可以直接访问Assistant中的_name成员了,解决了二义性的问题。
菱形虚拟继承的原理
我们先通过测试代码来看看不使用虚拟继承时,D类对象中各成员在内存中的分布情况。
测试代码:
#include <iostream>
using namespace std;
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;
}
上面的测试代码中各个类的关系是这样的:
运行起来后,我们通过内存窗口查看D的实例化对象中各个成员在内存中的分布情况:
我们可以看出,D类对象内的成员在内存中的分布为:
我们从上图中就可以看出,A类的成员_a被继承了两次,分别在B类中一次,C类中一次。也就导致了D类对象中有两个_a成员。
我们接下来再看看使用虚拟继承时,D类对象中各个成员的分布:
我们可以看到D类对象中的成员_a被放到了最后,原来存放两个_a成员的地方,现在是两个指针,这两个指针叫做虚基表指针,他们分别指向一个虚基表。
虚基表中包含两个数据,第一个数据是多态的虚表预留的存偏移量的为止(这里我们不用关心),第二个数据就是当前类对象距离公共虚基类的偏移量(公共虚基类就是指我们这里的A类)。也就是通过地址相加最后就能找到我们公共虚基类的成员。
示意图:
注:
我们若是将D类对象赋值给B类或者C类对象,就需要通过虚基表的第二个数据找到公共虚基类A的成员,得到切片后B类对象在内存中仍然保持着这种A类成员在最下面,B类C类有个指针指向A类成员的分布情况。
继承的总结与反思
C++语法很复杂,我们本章介绍的多继承就是一个难点。有了多继承,才有了菱形继承,顺水推舟就出了菱形虚拟继承,菱形虚拟继承的底层实现相当复杂,所以一般不建议设计出菱形继承,否则代码在复杂度和性能上都容易出问题,当菱形继承出问题时难以分析,并且会有一定的效率影响。
多继承可以认为是C++的缺陷之一,后面很多OO语言都没有多继承,例如Java。
继承与组合
继承是is-a的关系,也就是说每一个派生类对象同时都是一个基类对象;而组合是一种has-a的关系,若B类组合了A类,那么每个B对象都有一个A对象。
例如车类和奔驰类就是is-a的关系,他们之间适合使用继承:
class Car
{
protected:
string _colour; //颜色
string _num; //车牌号
};
class Benz : public Car
{
public:
void Drive()
{
cout << "I'm Benz" << endl;
}
};
而车和轮胎就是has-a的关系,他们之间适合使用组合:
class Tire
{
public:
string _brand; //品牌
size_t _size; //尺寸
};
class Car
{
protected:
string _colour; //颜色
string _num; //车牌号
Tire _t; //轮胎
};
注意:如果两个类之间既可以理解为is-a的关系,又可以理解为has-a的关系,我们应优先使用组合。
原因:
- 继承允许我们根据基类的实现来定义派生类的实现,这种通过生成派生类的复用通常被称为“白箱复用”。这里的“白箱”是相对于可视性来说的。在继承方式中,基类的内部细节对于派生类来说是可见的,继承也就一定程度上破坏了基类的封装,基类的改变对派生类有很大的影响,派生类和基类之间依赖性强,耦合度高。
- 组合是除了继承外的另一种复用选择,使用组合要求被组合的对象有良好定义的接口,对象只以“黑箱”的形式出现,这种复用风格被称之为“黑箱复用”,因为对象的内部细节是不可见的,对象只以”黑箱“的形式出现,组合类之间没有很强的依赖关系,耦合度低,优先使用对象组合有助于保持对每个嘞被封装。
- 实际中尽量多使用组合,因为组合的耦合度低,代码维护性好。不过继承也是有用武之地的,有的时候就适合使用继承,而且实现多态也必须要继承。若是类之间的关系既可以使用继承,也可以使用组合,优先使用组合。
以上就是有关继承的全部知识点,希望能对大家有所帮助!