面向对象三大特性--继承
一、继承的概念及定义
(一)概念
继承是⾯向对象程序设计使代码可以复用的最重要的⼿段,它允许我们在保持原有类特性的基础上进⾏扩展,增加⽅法(成员函数)和属性(成员变量),这样产⽣新的类,称派⽣类。
(二)继承格式
1、继承方式
我们前面对类的成员有三种限制方式,这里也就对应了三种继承方式
2、格式写法
3、派生类继承后访问方式的变化
1、通过表格可以发现,如果是
private
成员,那么无论哪种继承方式都不可以访问到这个权限。
2、此外,struct
和class
这两个关键字在继承时也有差距,struct
默认继承方式为公有,而class
默认继承方式为私有。
我们如果将权限的大小定义为
public > protected > private
, 那么其余访问方式变化就是将大于该继承方式的权限降到继承方式的权限即可。
(三)普通类继承
这里用到的是继承最基本的语法,采用public
继承,那么除了父类的private
变量不可访问以外,成员的权限保持不变。
class Person
{
public:
void Print()
{
cout << _name << endl;
cout << _age << endl;
}
protected:
string _name = "张三"; // 姓名
private:
int _age = 18; // 年龄
};
class Student : public Person
{
public:
void func()
{
Print();
}
protected:
int _stunum; // 学号
};
(四)类模板继承
在之前我们实现stack
时,采用的是新建了一个容器类型,在这里我们亦可以采用继承的方式来实现。
需要注意的是,派生类在继承时,如果需要访问父类的成员函数,需要指定类域,模板的成员函数采用的是按需实例化。
namespace wgm
{
template<class T>
class stack : public std::vector<T>
{
public:
void push(const T& x)
{
// 基类是类模板时,需要指定⼀下类域,
// 否则编译报错:error C3861: “push_back”: 找不到标识符
// 因为stack<int>实例化时,也实例化vector<int>了
// 但是模版是按需实例化,push_back等成员函数未实例化,所以找不到
vector<T>::push_back(x);
}
void pop()
{
vector<T>::pop_back();
}
const T& top()
{
return vector<T>::back();
}
bool empty()
{
return vector<T>::empty();
}
};
}
二、基类和派生类的转换
(一)基类转换派生类
1、基类对象不能赋值给派⽣类对象。
2、基类的指针或者引⽤可以通过强制类型转换赋值给派⽣类的指针或者引⽤。但是必须是基类的指针
是指向派⽣类对象时才是安全的。这⾥基类如果是多态类型,可以使⽤RTTI(Run-Time Type Information)
的dynamic_cast
来进⾏识别后进⾏安全转换。
(二)派生类转换基类
1、public继承的派⽣类对象 可以赋值给基类的指针/基类的引⽤。这⾥有个形象的说法叫切⽚或者切
割。寓意把派⽣类中基类那部分切出来,基类指针或引⽤指向的是派⽣类中切出来的基类那部分。
值得注意的是,之前在隐式类型转换时会生成临时变量,因此在应用时需要加上
const
,而在切片时不会生成中间的临时变量。
class Person
{
protected:
string _name; // 姓名
string _sex; // 性别
public:
int _age = 18; // 年龄
};
class Student : public Person
{
public:
int _No; // 学号
};
int main()
{
string s1 = "11111";
const string& s2 = "11111";
Student sobj;
// 赋值兼容转换,特殊处理
// 1.派生类对象可以赋值给基类的指针/引用
Person* pp = &sobj;
Person& rp = sobj;
rp._age++;
return 0;
}
接下来通过下面的例子发现,继承后的基类私有变量虽然访问不到,但是我们可以发现它在派生类的对象中依旧占据相应的空间,而经过赋值兼容转换变量的大小为基类的大小。
接下来更加深层的来了解赋值兼容,发现基类的指针或引用在调用重名函数的时候,调用的是父类的函数,而派生类调用时因为隐藏的特点,派生类对象调用的是派生类的函数。
class A
{
public:
void func()
{
cout << "A::func()" << endl;
}
protected:
int _a;
int _b;
private:
int _c;
};
class B : public A
{
public:
void func()
{
cout << "B::func()" << endl;
}
public:
int _d;
};
int main() {
B obj_b;
A* ptr_a = &obj_b;
A& ref_a = obj_b;
obj_b.func();
ptr_a->func();
ref_a.func();
return 0;
}
2、子类的变量可以复制给父类。
Person pobj = sobj;
三、几个重要细节
(一)继承与作用域
1、作用域
在继承体系中基类和派⽣类都有独⽴的作⽤域。
2、隐藏
派⽣类和基类中有同名成员,派⽣类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏(在派⽣类成员函数中,可以使⽤ 基类::基类成员 显⽰访问)
需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
(二)继承与友元
在继承时,友元关系是不接受继承的。所以如果友元函数需要访问派生类的成员,需要重新声明友元。
(三)继承与静态成员
在继承后,静态成员变量始终只有基类在定义的这一份。通过下面的代码可以发现,我们可以用类域加静态变量的方式来访问静态变量,但是打印的地址是同一份。
class Person
{
public:
string _name;
static int _count;
};
int Person::_count = 0;
class Student : public Person
{
protected:
int _stuNum;
};
int main()
{
Person p;
Student s;
cout << &p._count << endl;
cout << &s._count << endl;
cout << Person::_count << endl;
cout << Student::_count << endl;
return 0;
}
四、继承中派生类的构造函数
- 派⽣类的构造函数必须调⽤基类的构造函数初始化基类的那⼀部分成员。基类没有默认的构造函数必须在派⽣类构造函数的初始化列表阶段显⽰调⽤。
- 派⽣类的拷⻉构造函数必须调⽤基类的拷⻉构造完成基类的拷⻉初始化。
- 派⽣类的
operator=
必须要调⽤基类的operator=
。需要注意的是派⽣类的operator=
隐藏了基类的operator=
,所以指定基类作⽤域显⽰调⽤基类的operator=
- 派⽣类的析构函数会在被调⽤完成后⾃动调⽤基类的析构函数清理基类成员。才能保证先清理派⽣类成员再清理基类成员。因为多态中⼀些场景析构函数需要构成重写。,那么编译器会对析构函数名进⾏特殊处理,处理成
destructor()
,所以基类析构函数不加virtual
的情况下,派⽣类析构函数和基类析构函数构成隐藏关系。
class Person
{
public:
Person(const char* name)
: _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;
}
// destructor()
~Person()
{
cout << "~Person()" << endl;
}
protected:
string _name; // 姓名
};
class Student : public Person
{
public:
Student(int num, const char* address, const char* name)
:_num(num)
, _address(address)
, Person(name)
{
cout << "Student()" << endl;
}
Student(const Student& s)
: Person(s)
, _num(s._num)
, _address(s._address)
{
cout << "Student(const Student& s)" << endl;
}
Student& operator = (const Student& s)
{
cout << "Student& operator= (const Student& s)" << endl;
if (this != &s)
{
_num = s._num;
_address = s._address;
Person::operator=(s);
}
return *this;
}
// destructor()
~Student()
{
// 不需要写,子类析构函数结束后,会自动调用父类析构
//Person::~Person();
cout << "~Student()" << endl;
}
protected:
int _num; //学号
string _address;
};
五、多继承与菱形继承
(一)多继承
单继承:⼀个派⽣类只有⼀个直接基类时称为单继承
多继承:⼀个派⽣类有两个或以上直接基类时称为多继承
多继承的指针偏移问题
多继承对象在内存中的模型是,先继承的基类在前⾯,后⾯继承的基类在后⾯,派⽣类成员在放到最后⾯
通过上面的例子,我们可以清晰的认识到基类在派生类的储存情况。
(二)菱形继承
菱形继承:菱形继承是多继承的⼀种特殊情况,有数据冗余和⼆义性的问题
class Person
{
public:
string _name; // 姓名
};
class Student : public Person
{
protected:
int _num; //学号
};
class Teacher : public Person
{
protected:
int _id; // 职工编号
};
//给类加上 virtual 关键字,解决菱形继承造成的二义性和数据冗余。
//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 obj;
obj.Student::_name = "张三";
obj.Teacher::_name = "李四";
return 0;
}
通过调试窗口,可以发现我们在继承时同时继承了来自Person
和来自Teacher
的_name
我们在写代码时无法处理这个二义性,同时也形成了数据冗余。
(三)虚继承
为了解决这个现象,我们只需要在继承同一个基类成员的派生类加上一个
virtual
关键字,底层会自行加工,使得我们后面访问的_name
只是一份数据。
class Person
{
public:
string _name; // 姓名
};
//给类加上 virtual 关键字,解决菱形继承造成的二义性和数据冗余。
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 obj;
obj.Student::_name = "张三";
obj.Teacher::_name = "李四";
return 0;
}
下列窗口显示出来的_name
实则是同一份数据,最开始指定类域Student::
初始化_name
为张三
我们通过Teacher::
修改数据为李四,那么数据被修改为李四。
切记,尽量不用使用菱形继承,因为
virtual
关键字在解决问题的同时造成了效率的降低,代价有点大。
六、继承和组合
继承 | 组合 | |
---|---|---|
定义 | public 继承是⼀种is-a 的关系。也就是说每个派⽣类对象都是⼀个基类对象。 | 组合是⼀种has-a 的关系。假设B组合了A,每个B对象中都有⼀个A对象。 |
复用方式 | 白箱复用:在继承⽅式中,基类的内部细节对派⽣类可⻅ | ⿊箱复⽤:通过调用对象的接口实现,对象的内部细节是不可⻅的 |
耦合度 | 继承⼀定程度破坏了基类的封装,基类的改变,对派⽣类有很⼤的影响。派⽣类和基类间的依赖关系很强,耦合度高 | 组合类之间没有很强的依赖关系,耦合度低。 |
我们可以发现,组合的好处要大于继承,在两种都可以的情况下,优先使⽤组合,⽽不是继承。实际尽量多去⽤组合,组合的耦合度低,代码维护性好。
结束语:
感谢一直以来支持的朋友,支持一路走来披荆斩棘的道友,或许不识,一路同行!