前言
继承是类复用的重要方式,学习面向对象语言时学习继承是必不可少的,在c++中继承机制一种较为复杂的机制,下面让我们一起来认识一下c++中的继承。
目录
1.继承的概念和定义
1.1继承的概念
1.2 继承的定义
2.基类和派生类之间的转换
3.继承中的作用域
4.派生类的默认成员操作
5.继承与友元,继承与静态成员之间的关系
5.1继承与友元友元
5.2继承与静态成员
6.多继承和菱形继承
6.1单继承
6.2多继承
6.3菱形继承
6.4虚拟解决数据冗余的和二义性的原理:
7.总结
实际上继承是一种类之间代码复用的方式,继承这种机制,它允许程序员在保持原有类特性不变的情况下,扩展新类的特性。增加新类的功能。这样产生的类叫做派生类。继承程序面向对象程序设计的层次,由简单到复杂的认知过程。继承是类设计层次的复用。
例如:
#include
using namespace std;
class person
{
public:
void print()
{
cout << "person" << endl;
}
int _a;
};
class student :public person//通过public的方式继承基类person
{
private:
int _b;
};
int main()
{
student s;
s.print();
return 0;
}
student类继承基类person,实际上student派生类继承了基类person的成员变量和成员函数。运行上面的程序我们可以看到继承的成员函数,打开监视窗口我们可以看到student继承的成员变量,如下图:
下面的base是基类,也叫作父类,derived 是派生类,也叫作子类。
继承基类成员访问方式的变化:
类成员/继承方式 | public继承 | protected | private |
基类的public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
基类的protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
基类的private成员 | 在派生类不可访问不可见 | 在派生类不可访问不可见 | 在派生类不可访问不可见 |
总结:
1. 如果基类的成员是private的,无论以何种方式继承都是不可见的,这里的不可见是指基类的private成员在派生类中无论是类里面还是类外面都是不可见的。
2.基类的中private成员在派生类中不可见,如果想要在派生类里面被访问,但是在派生类外面不可见,需要将基类的成员定义为protected。例如:
class base { protected: int _a = 1; int _b = 2; void print() { cout << "base" << endl; } }; class derived :public base { public: void print() { _a = 90;//改变从基类继承的成员变量 _b = 30; cout << _a << endl; cout << _b << endl; print();//调用继承基类的成员函数 } private: int _num = 0; int _a = 0; };
3.基类的私有成员在派生类中都是不可见的,而其他成员则是由基类成员的访问限定符和继承方式决定的,取决于访问限定符小的那个,public>protected>private。
4.建议使用public继承,且基类成员最好为public。因为protected继承和private继承的可维护性不强。
5.使用class关键字默认继承方式为private,使用struct关键字默认继承方式为public。建议显示的写出继承方式。
派生类对象可以赋值给基类的对象/指针/引用。 这里有个形象的叫法叫做切片。就是将父类原有的那部分切出来赋值给父类。例如:
基类对象不可以赋值给派生类的对象。但是基类的指针或者引用可以通过强制转换类型赋值给派生类的指针或者引用 ,但是前提是基类的指针是指向派生类才是安全的。这里的基类如果是多态类型,可以使用RTTI的dynamic cast进行识别后,进行安全转换。
例如:
class base
{
protected:
int _a = 1;
int _b = 2;
void print()
{
cout << "base" << endl;
}
};
class derived :public base
{
public:
void print1()
{
_a = 90;//改变从基类继承的成员变量
_b = 30;
cout << _a << endl;
cout << _b << endl;
print();//调用继承基类的成员函数
}
private:
int _num = 0;
int _c = 0;
};
int main()
{
derived d1;
base b1;
//下面这种情况的转换是可行的
b1 = d1;//将派生类赋值给基类
derived *d2 ;
//d2 = b1;//errror
base* b2 = &d1;
d2 = (derived*)b2;//基类的指针可以强制转换类型赋值给派生类的指针
//下面这种类型的转换,程序运行是指针会越界
base d3;
base* d4 = &d3;
derived* d3 = (derived*)d4;
return 0;
}
1.在继承体系中,派生类和基类都有自己独立的作用域。
2.派生类和基类有同名成员时,派生类会屏蔽基类,对同名成员进行直接访问,这种情况叫做隐藏,也就重定义。在子类成员函数中可以使用基类类名+::来显示访问基类成员(如果是成员函数的隐藏,在派生类中只要成员函数的函数名相同就构成隐藏)。
3.在实际使用中不建议派生类和基类具有同名成员。
如下:
class person { protected: int _a = 0; string _s1 = "dadadadada"; int print() { cout << _s1 << endl; cout << _a << endl; } }; class student :public person { public: void print() { cout << _a << endl;//当派生类和基类有同名成员时,派生类成员会屏蔽基类,对同名成员进行访问 cout << _s1 << endl; } private: int _a = 90; }; int main() { student s1; s1.print();//当派生类和基类有同名函数时,派生类成员会屏蔽基类,对同名函数进行调用 return 0; }
注意:person中的print函数和student中的print函数不够成函数重载,因为函数重载是在同一个作用域中的同名但是参数不同的函数才构成函数重载。
如果想要访问基类中的同名成员要基类名::的方式访问如下:
class student :public person { public: void print() { person::print();//访问基类中的同名函数 cout << _a << endl;//当派生类和基类有同名成员时,派生类成员会屏蔽基类,对同名成员进行访问 cout << _s1 << endl; cout << person::_a << endl;//访问基类中的同名变量 } private: int _a = 90; };
类中的六个默认成员函数如果我们不写编译器会自动生成默认的,那么在派生类中是怎么样的呢?
1.在派生类中,实例化对象的时候,系统会自动调基类的默认构造函数,用来初始化继承基类的成员变量,而其他的成员变量要调用派生类自己的构造函数进行初始化,如果继承的基类没有默认的构造函数就必须在派生类的构造函数中显示的调用基类的构造函数对成员进行初始化。例如:
class person { protected: person()//默认构造函数 :_a(0) ,_s1("hahaha") { } int _a = 0; string _s1 = "dadadadada"; }; class student :public person { public: student() :_a(20) { } private: int _a = 90; }; int main() { student s1;//类实例化处对象——调用它自己的构造函数去初始化成员变量,同时会自动调用基类的构造函数初始化继承基类的成员变量 return 0; }
如果基类没有默认的构造函数就要显示调用基类的构造函数:
class student :public person { public: student(const char*name = "") :_a(20) ,person(name)//显示调用基类的构造函数 { } private: int _a = 90; };
2.在派生类中的拷贝构造函数必须调用基类的拷贝构造函数来完成基类的拷贝构造初始化。例如:
3.派生类中的operator=必须调用基类的operator=完成基本的赋值。
例如:
student& operator=(const student& s) { operator=(s);//调用基类的operator= _a = s._a; return *this; }
注意要是直接这样写程序会死循环,因为这里有函数的隐藏,子类和父类中都有一个operator=函数,需要指定函数的作用域。
4.派生类对象实例化的时候先调用基类的构造函数,再调用派生类的构造函数。
例如:
class person { protected: person(const char*ch = "")//默认构造函数 :_a(0) ,_s1(ch) { cout << "person" << endl; } int _a = 0; string _s1 = "dadadadada"; }; class student :public person { public: student(const char*name = "") :_a(20) { cout << "student" << endl; } private: int _a = 90; }; int main() { student s1;//类实例化处对象——调用它自己的构造函数去初始化成员变量,同时会自动调用基类的构造函数初始化继承基类的成员变量 return 0; }
5.派生类对象的销毁需要显示的调用基类的析构函数,再去调用自己的析构函数吗?我们可以试一试:
class person { protected: ~person() { _a = 0; cout << "~person" << endl; } }; class student :public person { public: ~student() { person::~person();//显示的调用基类的析构函数 cout << "~student" << endl; } private: int _a = 90; }; int main() { student s1; return 0; }
如果我们显示的调用基类的析构函数,
我们发现基类的析构函数被调用了两次,这样显然是不对的,那么析构的顺序应该是怎么样的呢?我们知道先定义的对象会后析构,因为对象是在栈上的这与栈后进先出的特性有关,其实,派生类也满足后定义的先析构,所以会先析构派生类自己的成员,然后再去调用基类的析构函数进行析构,所以派生类的对象析构的时候不需要显示调用基类的析构函数,编译器会自动调用。
注意:虽然基类和派生类的析构函数,函数名不同,但是派生类的析构函数会隐藏基类的析构函数,因为对于析构函数编译器会做特殊的处理,将析构函数名处理为:destrutor().所以它们构成隐藏。
友元分为友元函数和友元类,详见: 友元函数详解
这里主要是要说明友元关系是不能继承的,也就是说基类友元不能访问派生类private和protected成员例如:
class student;
class person
{
friend void print(const person& p, const student& s);
private:
int _id ;
int _telephone;
};
class student:public person
{
private:
char _addr[10];
};
void print(const person& p, const student& s)
{
cout << p._id << " " << p._telephone << endl;
cout << s._addr << endl;
}
int main()
{
person p;
student s;
print(p,s);
return 0;
}
如果想要访问的话就要在基类中重新声明友元。
基类的定义的静态成员,则在整个体系中只有一个这样的成员,无论派生出多少个子类。都只有一个static成员。类中的静态成员详见:类中的静态成员
例如:
class person
{
public:
int _id ;
int _telephone;
static int _stc;
};
int person:: _stc = 0;//初始化静态成员变量_stc
class student:public person
{
private:
char _addr[10];
};
int main()
{
person p;
student s;
student s1;
//给不同对象的静态成员赋不同的值
person::_stc = 2;
p._stc = 3;
s._stc++;
s1._stc = 10;
//打印不同对象中的stc
cout << person::_stc << endl;;
cout << p._stc << endl;
cout << s._stc << endl;
cout << s1._stc << endl;
return 0;
}
你会发现即使给不同对象的静态成员赋不同的值 ,最后输出的结果还是一样的。
打开监视也可以看见不同对象中存放的成员变量_stc的地址也是相同的,不管是基类对象还是派生类对象。如图:
菱形继承其实是多继承的一种特殊情况,如果子类继承的父类,它们也继承过相同的基类,那么这种继承方式就叫做菱形继承,如图: 注意菱形继承不只有上图所示的这种情况。
请想想这种菱形继承有什么坏处吗?答案是显而易见的,它会造成数据冗余和二义性的问题。
例如:
class people
{
public:
int _age;
int _sex;
};
class person :public people//继承people类
{
public:
int _id;
int _telephone;
};
class student :public people//继承people类
{
private:
char _addr[10];
};
class super:public student,public person//继承student类和person类
{
private:
int _value;
};
int main()
{
super s;
return 0;
}
上面这种情况就属于菱形继承,如果打开监视我们会看到:s中有两份people类的成员(一份来自于student类的继承,一份来自person类的继承)。如图:
如何解决这个问题呢?这就要需要在上面student类和person类在继承people类时采用虚继承,即可解决这个问题,关键字是virtual。需要注意的是虚继承不要在其他地方使用。例如:
class people
{
public:
int _age;
int _sex;
};
class person :virtual public people//继承people类
{
public:
int _id;
int _telephone;
};
class student :virtual public people//继承people类
{
private:
char _addr[10];
};
class super:public student,public person//继承student类和person类
{
private:
int _value;
};
int main()
{
super s;
return 0;
}
为了研究虚拟继承的原理,我们给出一个简单的菱形继承的继承体系,在借助内存窗口观察对象成员的模型。如下: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
{
private:
int _d;
};
int main()
{
D d1;
return 0;
}
运行这个程序,打开内存,借助内存窗口来观察对象模型,下面是菱形继承的派生类对象在内存中的模型:
从这里可以看出d对象中保存着两个_a,数据冗余,且在d1对象访问_a 时有二义性。
下面是菱形虚拟继承的派生类对象在内存中的模型:
可以看到原来存放继承B,C类中成员_a的位置都存着一个地址,当我们去这个地址()中找,会发现这段地址所存的数据是,从此原来B中的成员变量_a到现在D中的新的成员变量_a,地址的偏移量。同理C的原来的成员_a所保存的地址处也被替换成一个地址,这个地址所对应的空间存放着原来的C类继承的成员变量_a到现在D中的新的成员变量_a,地址的偏移量。为什么要在B类和C类存放成员变量_a的地址处,存放指针保存到成员_a地址处的偏移量呢?
因为这样如果就可以保证将d类的对象赋值给B,C类对象时也可以访问到成员_a,如:
建议:不到万不得已的情况下不要使用菱形继承。
很多人都说c++语法复杂,其实多继承就是一个体现,有了多继承就有菱形继承,有了菱形继承,就需要菱形虚拟继承来,底层实现就会很复杂。所以一般不建议设计多继承,一定不要设计菱形继承,否则在复杂度和效率上就会有问题。
多继承可以认为是c++的缺陷之一,后来的很多面向对象的语言都没有多继承,如Java。
组合和继承
public继承是一种is-a的关系。也就是说每一个子类对象都是一个基类对象。
组合是一种has-a的关系,假设B组合了A,每个B对象中就有一个A对象。
继承允许你根据基类的实现来定义派生类的实现,这种通过生成派生类的复用通常被称为白箱复用 。术语“白箱”是相对可视性而言的:在继承方式中,基类的内部细节对子类可见,继承在一定程度上是通过破坏基类的封装,基类的改变,对派生类有很大影响。派生类和基类之间的耦合度很高。
组合对象是类继承之外的一种复用方式的选择,新的更复杂的功能可以通过组装或者组合对象来实现。对象组合要求被组合的对象具有良好的定义接口。这种复用方式叫做黑箱复用,因为对象内部的细节不可见的,对象只以黑箱的形式出现,组合之间没有很强的关联关系,耦合度低,内聚度高。优先使用组合可以保持每个类封装的完整性。
到底是选择继承来复用代码还是选择组合来复用代码,是要根据实际情况来决定的,如果对象之间更符合is-a关系,优先使用继承,如果对象间更符合has-a关系有优先使用组合,如果对象间没有很强的关系,优先使用组合。
组合的耦合度低,代码易于维护,不过继承也不是一无是处,有些关系更适合继承那就用继承。