【C++】——继承
C++的三大特性——封装、继承和多态。每一个类的设计其实就是一种封装的体现,这里我们来了解C++的又一特性——继承。
一.继承
1.什么是继承
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要手段,它允许我们在保持原有类特性的基础上进行扩展,增加方法(成员函数)和属性(成员变量),以这种方式产生的新类,称为子类/派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。
假设现在存在有两个类——student类和teacher类
class Student
{
public:
void identity()
{
// ...
}
void study()
{
// ...
}
protected:
string _name = "peter"; // 姓名
string _address; // 地址
string _tel; // 电话
int _age = 18; // 年龄
int _stuid; // 学号
};
class Teacher
{
public:
void identity()
{
// ...
}
void teaching()
{
//...
}
protected:
string _name = "张三"; // 姓名
int _age = 18; // 年龄
string _address; // 地址
string _tel; // 电话
string _title; // 职称
};
对于这两个类来说,它们有很强的相似性,比如:它们都有姓名,年龄,地址,电话这四个成员变量,也都有一个identity方法。
所以这两个类设计的非常冗余,我们可以找出这两个类的共同点,设计出一个父类,然后通过继承的方式,产生出两个新类。
//父类
class person
{
public:
void identiry()
{
// ...
}
protected:
string _name;
int _age;
string _address;
string _tel;
};
//子类
class student : public person
{
public:
void study()
{
// ...
}
protected:
int _id;//学号
};
//子类
class teacher : public person
{
public:
void teaching()
{
// ...
}
protected:
int _title;//职称
};
我们首先设计出一个person类,该类包含了原来student类和teacher类的公共属性,然后我们用student类和teacher类继承person类,然后在增加自己的专属属性,这样就设计出了与原先功能相同的两个类。
2.继承格式
在上面person叫做父类/基类,student和teacher都是子类/派生类。
继承方式——public(公有继承)、protected(保护继承)、private(私有继承),继承方式的不同会导致父类在子类的访问权限不同。
我们在刚才类的设计中看到了成员变量不再用private限制,而是protected,它的作用对该类来说与private的作用相同,都是只能在类内访问,类外不可访问。但是它的真正的作用主要在继承来体现。
简单来说,用private修饰的成员变量不论什么继承方式在子类都不可见,而protected成员采用public或者protected继承方式在子类是可见的。
3.继承基类成员访问方式的变化
1、对于基类的private成员来说, 无论采用哪种继承方式,在子类都是不可见的。这里的不可见指的是基类的private成员还是被继承到了子类中,不过在子类中是不可以访问的。
2、基类的private成员在子类是不可见的,如果基类的成员不想在子类外被访问,而在子类内部可以访问,那就应该将基类的成员定义成protected。
3、除了基类的private成员,其他成员的访问权限 == Min(基类的访问权限 ,继承方式)。
4.使用关键字class定义的类默认的继承方式是private,struct默认是public,但是建议显式写出来继承方式。
5.在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用 protetced/private继承,因为protetced/private继承下来的成员都只能在派⽣类的类⾥⾯使⽤,实际中扩展维护性不强。
4.继承类模板
我们之前在实现stack的时候是以适配器的模式来实现的,这种方式其实是一种组合——has-a。我们在这里也可以采用继承的方式来实现stack,继承是一种is-a。
namespace xsc
{
template<class T>
class stack : public std::vector<T>
{
public:
void push(const T& x)
{
vector<T>::push_back(x);
//push_back(x);
}
void pop()
{
vector<T>::pop_back();
}
const T& top()
{
return vector<T>::back();
}
bool empty()
{
return vector<T>::empty();
}
};
}
当我们继承的是一个类模板时,如果在子类中需要调用父类的接口,需要显式调用——指定类域。如果不指定的话,会报错——找不到push_back()标识符,这源于类模板的按需实例化。
xsc::stack<int> st;
st.push(1);
当我们运行到第一句代码时,编译器会实例化出stack<int>这个类,以及vector<int>这个类,但是编译器采取的是按需实例化,你不调用就不会实例化。
我们调用stack的push时,编译器会实例化stack的push,但是到push内部时又调用了push_back,编译器首先会在stack<int>类中搜索,发现搜索不到,然后就会去vector<int>里面搜索,但是vector<int>的push_back并没有调用,所以还没有实例化,所以就找不到该函数。
所以我们要显式的调用父类的接口——指定类域。
void push(const T& x)
{
vector<T>::push_back(x);
}
4.1宏和继承
我们可以借助宏替换,以及继承类模板的方式,来实现不同底层的stack:
#define CONTAINER std::vector<T>
//#define CONTAINER std::list<T>
//#define CONTAINER std::deque<T>
namespace xsc
{
template<class T>
class stack : public CONTAINER
{
public:
void push(const T& x)
{
CONTAINER::push_back(x);
//push_back(x);
}
void pop()
{
CONTAINER::pop_back();
}
const T& top()
{
return CONTAINER::back();
}
bool empty()
{
return CONTAINER::empty();
}
};
}
我们先前适配器模式是采用一个模板参数container,来接收不同的容器,以达到不同底层的stack。
4.2按需实例化
编译器为了提高编译效率,它首先会对调用的函数或者类进行实例化,如果没有调用的则不会主动去实例化。
template<typename T>
class A
{
public:
void add(const T& x)
{
x.func();
}
};
int main()
{
A<int> aa;
return 0;
}
对于上面的类模板来说,当我们定义一个aa对象时,编译器会实例化其构造函数,但不会实例化它的add函数,因为我们没有调用。此时add函数中有一个未声明的func,但编译程序并不会出错。
但这里面的函数必须是依赖模板参数的,如果只是一个简单的函数,编译器还是会检查出来的。
void add(const T& x)
{
func();
}
二.赋值兼容规则
1、public继承的子类对象可以赋值给父类的对象/指针/引用。这里有个形象的说法叫切片或者切割。意思就是当子类赋值给父类时,会将子类中继承父类的那部分切割出来,给父类对象。
class Person { public: string _name; string _sex; int _age; }; class Student : public Person { public: int _No; }; int main() { Student sobj; Person pobj = sobj; Person* pp = &sobj; Person& rp = sobj; return 0; }
需要注意的是,父类对象引用子类对象时,期间没有发生类型转换,而是一种特殊的机制,rp改变,sobj也会改变。对于赋值来说,pobj只是sobj中父类成员的拷贝,互相不影响。
为什么说子类对象给父类对象的引用不是类型转换呢?
因为如果是类型转换的话,期间会产生临时变量,而临时变量具有常性,需要用const Person& 来接收
int i = 0; //double& j = i; const double& j = i;
2、父类对象不可以赋值给子类对象
也不可以通过强转来实现。
3.父类对象的指针或者引用可以通过强制类型转换赋值给子类的指针或者引用。但是必须是父类的指针指向子类对象时才是安全的。这里父类如果是多态类型,可以使用RTTI(Run-Time Type information)的dynamic_cast来进行识别后进行安全的转换。
Person* pp; //Student* ps1 = pp; //直接转换是不行的 Student* ps1 = dynamic_cast<Student*>(pp);
需要注意,这样转换的前提是父类得是多态类型。
三.继承中的作用域
1、在继承体系中,基类和派生类都有独立的作用域
2、派生类和基类中有同名成员,派生类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏。
class person { protected: int num = 1; string name = "张三"; }; class student :public person { public: void print() { cout << num << endl; cout << person::num << endl; } protected: int num = 999; string name = "李四"; };
person类和student类都有相同的数据成员,此时就构成了隐藏关系,如果在派生类中访问num,则会隐藏掉person中的num,默认访问派生类的,如果想要使用基类的,那就要显式调用,指定类名。
class A { public: void func() { cout << "A::func()" << endl; } }; class B : public A { public: void func() { cout << "B::func()" << endl; } };
对于这两个类来说,它们都有func函数,但是其也构成隐藏关系,所以调用b中的func时,是调不到a中的func的,想要调用必须指定类名显式调用。
A a; B b; b.func(); //调用B类的 b.A::func();//调用A类的
3.需要注意的是,如果是成员函数的隐藏,只要函数名相同就构成隐藏
我们看到,即使基类中的同名函数与派生类的参数不同,派生类的对象依旧无法直接调到。
4.在实践中,在继承体系中最好不要定义出同名成员
3.1A类和B类中的两个func构成什么关系()
A 重载 B 隐藏 C 没关系
3.2 下面程序的编译运行结构是什么()
A 编译报错 B 运行报错 C 正常运行
class A
{
public:
void fun()
{
cout << "func()" << endl;
}
};
class B : public A
{
public:
void fun(int i)
{
cout << "func(int i)" << i << endl;
}
};
int main()
{
B b;
b.fun(10);
b.fun();
return 0;
};
B A
四.派生类的默认成员函数
默认成员函数的意思是,我们不显示写,编译器会自动生成的函数。
1.四种常见的默认成员函数
1.1 派生类的构造函数必须调用基类的构造函数来初始化派生类对象中基类的那一部分成员。如果基类没有默认构造函数,则需要在派生类构造函数的参数初始化列表里显式调用。
class person { public: person() :_name("jake") ,_age(18) {} protected: string _name; int _age; }; class student : public person { public: student() :_NO("001") {} protected: string _NO; };
对于上面这个继承体系来说,其实我们都没有必要显式写构造函数,不论是对基类还是对派生类来说,默认生成的就够了:
因为对于student类来说,如果不显示写的话,生成的默认构造函数对自定义类型会调用它的默认构造,内置类型是否初始化是不确定的,我们可以通过给内类缺省值来解决,然后他还会调用基类的默认构造。
但是为了解释这个语法,我们还得写出来,此时的基类是有默认构造的,当其没有默认构造时,我们得显式调用:
派生类中的初始化顺序为:先调用基类的构造函数来始化基类成员,再初始化派生类成员。
1.2 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化
student(const student& s) :person(s) ,_NO(s._NO) {}
对于拷贝构造来说,只有类中涉及到了需要深拷贝的资源,才需要自己实现拷贝构造,否则默认生成的就可以达到要求。
这里形参是student类对象的引用,我们可以用它直接赋值给person类的拷贝构造是因为赋值兼容规则,它会将s对象中的基类部分赋值给待初始化的student对象的基类部分。
1.3 派生类的operator=必须调用基类的operator=完成基类的复制。需要注意的是,这里派生类与基类的operator=构成了隐藏,所以调用时需要显示调用
student& operator=(const student& s) { if (this != &s) { person::operator=(s); _NO = s._NO; } return *this; }
严格来说,只有涉及到了需要深拷贝的资源时,才需要显式写赋值运算符重载
1.4 派生类的析构函数在被调用完成之后会自动调用基类的析构函数。因为这里析构函数要遵循后构造的先析构,在调用派生类的构造函数时,先调用了基类的构造函数进行初始化,后初始化派生类成员,所以析构顺序要与之相反。
~person() { cout << "~person" << endl; } ~student() { person::~person(); cout << "~student" << endl; }
那么为什么我们一个对象会调用两次基类的析构呢?
同一个对象析构两次就可能有风险,所以我们不要再派生类的析构函数中显式调用基类的构造函数,因为其会在释放完派生类的资源后自动调用基类的析构。
严格地说,只有涉及到需要深拷贝的资源时,才需要显式实现析构函数。
总结,对于派生类来说,其内部可能有三种成员——内置类型、自定义类型、基类类型
而构造函数对于内置类型的初始化是不确定的,但是可以借助类内初始化解决;
自定义类型会调用它的默认构造
基类类型,我们可以将派生类中的基类部分看成一个整体,这个整体需要调用基类的构造函数来初始化。
2.定义一个不可以被继承的类
法一:将基类的构造函数私有,派生类对象需要调用基类的构造函数完成初始化,当将其置为私有时,派生类就不能访问,就无法用来产生派生类了。
法二:C++11新增了一个关键字final,用final修饰基类,表示其无法被继承
五.继承和友元
友元关系不能继承,也就是说基类的友元函数不能访问派生类的保护和私有成员。
简单的来说,你爸爸的朋友不一定就你的朋友。
class person
{
friend void print(const person& p, const student& s);
protected:
string _name;
int _age;
};
class student :public person
{
protected:
string _NO;
};
void print(const person& p, const student& s)
{
cout << p._name << " " << p._age << endl;
cout << s._NO;
}
对于这段代码来说,有两个问题:
1、print函数在person类声明为友元时,studen还没有声明,person不认识student,而编译器检查到一个不认识的类型时就会向上搜索,但是上面也没有studen的定义,所以此时就会报错
解决方法:前置声明,在person类的前面先给出student类的声明,告诉编译器,这个类型是有的,你不要给我报错。
//前置声明
class student;
2、print函数是person类的友元函数,可以访问person类的保护和私有成员,但友元关系不能继承,所以print函数并不能访问student类的保护和私有成员。
解决方法:将print函数也声明为student类的友元函数
class student :public person
{
friend void print(const person& p, const student& s);
protected:
string _NO;
};
六.继承和静态成员
如果基类定义了static静态成员,那么在整个继承体系中都只有一个这样的成员,不论产生了多少个派生类,都只有一个。
class A
{
public:
static int _d;
string _name;
};
int A::_d = 0;
class B :public A
{
public:
string _gender;
};
int main()
{
A a;
B b;
//对于非静态成员来说,每个基类/派生类的对象都有一份属于自己的
//地址不同
cout << &a._name << endl;
cout << &b._name << endl << endl;
//对于静态成员来说,基类和其所有的派生类中都只有一份,由其产生的对象也都公有那一份
//地址相同
cout << &a._d << endl;
cout << &b._d << endl;
return 0;
}
当我们要访问该静态变量时,一般都采用类名来方法,如果基类中静态成员是公有的话,那么派生类的类名也可以访问
cout << A::_d << endl;
cout << B::_d << endl;
如果是公用的话,用对象名也可以访问
cout << a._d << endl;
cout << b._d << endl
七.多继承以及菱形继承
1.继承模式
单继承:派生类只有一个直接基类
多继承:派生类有两个及以上的直接基类
对于多继承来说,派生类对象在内存中的分布为:先继承的基类的部分在前面,后继承的在后面,最后面是派生类自己的成员。
菱形继承: 菱形继承一般出现在多继承中,它有数据冗余,二义性的问题。
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; // 主修课程
};
数据冗余:
我们看这个Assistant类来说,它采用多继承的方式继承了Student类和Teacher类,而Student类和Teacher类又都继承了Person这个类,所以在Assistant这个类中,有两份Person的成员,这就造成了数据冗余的问题。
二义性:
因为Assistant分别继承了Student和Teacher,而它们又继承了Person,所以Assistant内部有两份_name。当我们直接调用它时,编译器并不明确_name是从哪个类继承来的_name。
Assistant as; as._name = "bird";
我们可以通过指定类名的方式,来避免二义性得问题:
as.Student::_name = "bird"; as.Teacher::_name = "fly";
虽然二义性可以避免,但是数据冗余的问题菱形继承是无法解决的
在C++标准库中,也有菱形继承的案例: 在流相关的库中,这个涉及就是一种菱形继承,但其中经过一些其他的涉及,避免了数据冗余和二义性的问题——虚继承。
2.虚继承
在多继承出现以后,菱形继承的问题也浮出水面。为了解决菱形继承的问题,我们可以采取虚继承的方式,来避免数据冗余和二义性的问题。
虚继承的关键字是virtual,当我们发现某个派生类中出现了菱形继承的问题,我们首先找到在派生类中造成数据冗余的那个基类,然后给继承这个基类的类采取虚继承。
对上面的继承体系可以采取下面的虚继承方式:
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; // 主修课程
};
在这个继承体系中,在最终的派生类Assistant中,基类Person在Assistant中出现了数据冗余,所以我们就在继承这个类时,采取虚继承。
判断,下面的继承体系中,virtual应该加在哪里?
判断在哪里采取虚继承的方法是:哪个类产生了数据冗余和二义性,在继承这个类时就采取虚继承。
从图中分析, 数据冗余和二义性的问题会出现在E类中,而冗余的数据全都来自A类,所以我们在继承A类时,需要采取虚继承的方式。
当我们采用虚继承的方式解决菱形继承的问题时,这个派生类在内存中的存储方式已经改变了,
系统会将虚继承的那部分内容,放在派生类对象的末尾,这只是简单的理解,实际是非常复杂的。所以在使用继承体系时,不要设计出菱形继承。
7.3多继承中的指针偏移
C
我们刚才已经说到了,在继承体系中,先继承的在内存中数据存放在前面——低地址处,后继承的紧接着放,最后面是派生类自己的数据成员。
八.继承和组合
- public继承是一种is-a的关系。也就是说,每个派生类对象都是一个基类对象,它具有基类对象的属性和行为。对于我们先前采用继承vector的模式来实现stack来说,每一个stack对象其实就是一个vector对象。
- 组合是一种has-a的关系。假设B组合了A,那么每个B对象中都有一个A对象。stl库中stack和queue采用的适配器模式就是一种组合,假设默认采用vector作为底层容器,那么每一个stack对象内部都有一个vector对象。
继承允许我们根据基类的实现来定义派生类的实现。通过这种方式生成的派生类的复用通常称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对派生类可见。继承一定程度上破化了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖性很强,耦合度高。
对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求:被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之前没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类的封装性。
在测试中,也有白盒测试和黑盒测试两种:
白盒测试:要了解底层的逻辑,根据底层的设计来进行测试
黑河测试:不需要了解底层是如何实现的,从功能的角度来进行测试
在面向对象程序设计中,我们通常希望一个程序具有低耦合高内聚的特点。
低耦合意味着不同模块之前的关联不紧密,一个的改变不会影响另外一个;
高内聚意味着同一个模块之间的关联是紧密的。
完!