面向对象三大特性:
-
封装
-
继承
-
多态
通过之前的学习,我们对封装已经有了一定了解:
- C++ 的 Stack 类设计和 C 的 Stack 设计对比:封装的更好,访问限定符和类的设计,使用更规范。
- 迭代器设计:封装了容器底层结构,提供了同一的访问容器的方式。如果没有迭代器,容器暴露底层结构,访问元素更复杂,使用成本高。
- stack、queue、priority_queue 的设计:适配器模式
继承
概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用
比如学生类和老师类,学生类和老师类中同样有姓名,年龄,地址等成员变量。学生类还有学号,老师类有工号这些不同的成员变量。
如果单独设计这两个类,对于两个角色都有的数据和方法,设计重复了。为了增加复用,这里就可以引出继承这个概念。
像这样,上面的类叫做父类/基类,下面的类叫做子类/派生类。父类实现共有的成员,子类实现各自独有的成员。
子类会继承父类的成员,比如,这里的子类 Student
就有了 4 个成员变量:string _name; int _age; string _address; string _stuid
定义
语法:
继承方式与访问限定符:
继承方式和访问限定符可以组成 9 种不同的情况:
类成员\继承方式 | public 继承 | protected 继承 | private 继承 |
---|---|---|---|
基类的 public 成员 | 派生类的 public 成员 | 派生类的 protected 成员 | 派生类的 private 成员 |
基类的 protected 成员 | 派生类的 protected 成员 | 派生类的 protected 成员 | 派生类的 private 成员 |
基类的 private 成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
总结:
- 基类的 private 成员在派生类中不可见
- 基类的其他成员在子类中的访问方式就是 min(成员在基类中的访问权限,继承方式),public > protected > private
- 不可见:基类的成员被继承到了派生类中,但在派生类里面还是不能访问。
protected 和 private 在继承中有了区别:
- protected 和 private 成员对于基类来说是一样的:类外不可访问,类里面可以访问
- 基类的 protected 和 private 成员对于派生类:private 成员不可访问,protected 成员可以访问
注意:
- 使用关键字 class 时默认的继承方式是 private,使用 struct 时默认的继承方式是 public,不过最好显式写出继承方式。
- 在实际中一般都是使用 public 继承,几乎很少使用 protected/private 继承,基类中的成员一般都是 public 和 protected
- 如果派生类中我们手动地使用访问限定符来控制,则权限按照显式的访问限定符的来。
基类和派生类对象赋值转换
派生类对象可以赋值给基类的对象/基类的指针/基类的引用。这个过程也可以叫切片或切割,意为把派生类中基类那部分切下来赋值过去。
注意:这种赋值建立在 public 继承的基础之上。因为其他继承会使派生类的基类部分成员的权限缩小,无法赋值给高权限的基类成员。
例子:
先设计一个基类和一个派生类
class Person
{
protected:
string _name;
string _sex;
int _age;
};
class Student : public Person
{
public:
string _stuid;
};
赋值
int main()
{
Person p;
Student s;
p = s;
Person& rp = s;
Person* pp = &s;
return 0;
}
👆注意:
- 这种赋值的两个对象虽然类型不同,但是不会发生类型转换,中间也不存在临时对象,它们就是一种天然的赋值。
- 引用
rp
就是将派生类对象s
中基类的部分切割出来取了个别名rp
- 指针
pp
指向的是派生类对象s
中基类的部分。或者说,pp
指向的空间只有Person
类对象的大小
打印它们的地址:
cout << &s << endl << &rp << endl << pp << endl;
//结果:
//000000075D8FF790
//000000075D8FF790
//000000075D8FF790
可以看到是一样的。
- 基类对象不可以赋值给派生类对象。
继承中的作用域
- 在继承体系中,基类和派生类都有独立的作用域
- 如果基类和派生类中有同名成员,派生类成员将屏蔽对基类部分同名成员的直接访问,这种情况叫做隐藏,也叫做重定义(在派生类成员函数中,也可以使用
::
对基类部分的成员显式访问)
例子:
class Person
{
protected:
string _name = "张三";
int _num = 111; //基类中的_num
};
class Student : public Person
{
public:
void Print()
{
cout << "姓名:" << _name << endl;
cout << "派生类学号:" << _num << endl;
cout << "基类学号:" << Person::_num << endl; //显式访问
}
protected:
int _num = 999;
};
void test3()
{
Student s;
s.Print();
}
//结果:
//姓名:张三
//派生类学号:999
//基类学号:111
👆:类似于局部优先,直接访问的是派生类独有的 _num
成员
对于重名函数也是如此:
class A
{
public:
void fun()
{
cout << "A::func()" << endl;
}
};
class B : public A
{
public:
void fun()
{
cout << "B::func()" << endl;
}
};
void test4()
{
B b;
b.fun(); //优先调用派生类特有的
b.A::fun(); //指定调用基类的
}
//结果:
//B::func()
//A::func()
注意🕳:
class A
{
public:
void fun()
{
cout << "A::func()" << endl;
}
};
class B : public A
{
public:
void fun(int i)
{
cout << "B::func()" << endl;
}
};
👆:这两个 fun
函数虽然参数类型不同,但不是函数重载,因为函数重载要求在同一作用域。
-
对于派生类中和基类中同名的成员函数,只要函数名相同就构成隐藏
-
实际中在继承体系里最好不要定义同名的成员
派生类的默认成员函数
派生类构造函数原则:
- 调用基类构造函数初始化继承自基类的成员
- 自己再初始化自己的成员
析构、拷贝构造、赋值重载也类似。
例子:
先在 Person
类中写下默认构造、拷贝构造、赋值重载、析构四个成员函数。
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 operatpr=(const Person& p)" << endl;
if (this != &p)
_name = p._name;
return *this;
}
~Person()
{
cout << "~Person" << endl;
}
protected:
string _name;
};
class Student : public Person
{
public:
protected:
int _num;
};
void test5()
{
Student s;
}
//结果:
//Person()
//~Person
通过结果可以发现,只是创建一个 Student
类型的对象会一起调用它的基类 Person
的构造函数和析构函数。
默认构造:
Student(const char* name = "", int num = 0, const char* addrss = "")
: Person(name)
, _num(num)
{}
在 Student
类的初始化列表中,可以通过 Person("李四")
来初始化基类部分的成员。
也就是说,基类成员在派生类中也要当成一个整体,这里的Person
类看起来就像是 Student
类的一个成员变量。
拷贝构造:
基类成员需要调用基类的拷贝构造,直接传入 s
切片即可,然后派生类自己的成员自己初始化。
Student(const Student& s)
: Person(s) //传入s,基类中的拷贝构造引用接收可以切片
, _num(s._num)
{}
赋值重载:
同样需要调用基类的赋值重载。Person::operator=
传入 s
会被切片。
Student& operator=(const Student& s)
{
if (this != &s)
{
Person::operator=(s);
_num = s._num;
}
return *this;
}
析构函数:
注意:
- 基类和派生类的析构函数构成隐藏关系,由于多态的需要,析构函数名会被统一处理成
destructor
。 - 派生类析构函数完成后会自动调用基类析构函数,所以基类的析构不需要我们显式调用。
~Student()
{}
小题:如何设计一个不能被继承的类?
把基类的构造函数设成 private
。派生类就无法构造了:
class A
{
private:
A()
{}
};
class B : public A
{
};
void test6()
{
B b; //此处报错:无法引用 "B" 的默认构造函数 -- 它是已删除的函数
}
那么要单独创建一个 A
类的对象怎么办?
从 A
类内部提供一个静态成员函数即可:
class A
{
public:
static A CreateObj()
{
return A();
}
private:
A()
{}
};
void test6()
{
A a = A::CreateObj();
}
继承与友元
友元关系不能继承。
基类的友元不一定是派生类的友元。
class Student;
class Person
{
friend void Display(const Person& p, const Student& s);
protected:
string _name;
};
class Student : public Person
{
friend void Display(const Person& p, const Student& s);
protected:
int _num;
};
void Display(const Person& p, const Student& s)
{
cout << p._name << endl;
cout << s._num << endl;
}
👆:Display
必须同时声明为基类和派生类的友元才能访问这二者的保护成员。
继承与静态成员
基类定义的静态成员,则整个继承体系里面只有一个这样的成员。
例子:
统计一个继承体系共创建了多少个对象
class Person
{
public:
static int _count;
Person()
{
++_count;
}
protected:
string _name;
};
int Person::_count = 0;
class Student : public Person
{
protected:
int _stuNum;
};
class Graduate : public Student
{
protected:
string _seminarCourse;
};
void test7()
{
Student s1;
Student s2;
Student s3;
Graduate g1;
Graduate g2;
cout << "人数:" << Person::_count << endl;
}
//结果:人数:5
复杂的菱形继承及菱形虚拟继承
单继承:一个派生类只有一个直接基类的继承关系
多继承:一个派生类有多个直接基类的继承关系
菱形继承:多继承的一种特殊情况
菱形继承的问题:菱形继承有数据冗余和二义性问题。
冗余:在 Assistant 的对象中 Person 的成员会有 2 份
二义性:
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 _majorCoures;
};
void test8()
{
Assistant a;
a._name = "张三"; //此处报错:"Assistant::_name" 不明确
}
👆:不知道要访问从 Student
继承来的 _name
还是从 Teacher
继承来的 _name
。
这个其实很好解决,只要指定好类域即可:
a.Teacher::_name = "张三";
a.Student::_name = "李四";
虚拟继承可以解决菱形继承的数据冗余和二义性问题。
关键字:virtual
使用方法:在腰部,也就是在 Student
和 Teacher
继承 Person
时使用虚拟继承即可解决问题,注意不要在其他地方使用。
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 _majorCoures;
};
虚拟继承是如何解决问题的?
为了研究这个问题,我们先设计一个简单的菱形继承:
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;
};
在不使用虚拟继承的情况下,创建一个 D
类型的对象 d
并进行访问,通过内存窗口查看 d
对象内的数据:
👆:通过这张图可以看出,从 B
类 C
类继承下来的成员和 D
类独有的成员依次排列。A
类的数据有 2 份,出现冗余。
在使用了虚拟继承的情况下:
👆:
-
可以看到原本存放
A
类成员的地方变成了指针,我们叫它虚基表指针。A
类成员被单独存储到了最后。 -
虚基表指针指向的位置存放的是偏移量,表示该指针的位置到
A
类成员存储位置的距离。
到这为止,其实数据冗余和二义性的问题已经解决了,但是原来的 A
位置为什么还要搞个虚基表指针呢,直接空着不好吗?
这里就涉及到切片问题了:
如果要把派生类对象 d
赋值给基类对象,比如 C c = d;
d
会把 C
类部分切出来,然后赋值过去,但是从 C
类继承下来的 A
类成员找不到了,所以需要用虚基表指针找到偏移量然后根据偏移量去找 A
类成员。
建议:在实际中尽量不设计菱形继承