目录
1.继承的概念
概念
之前谈到过的函数模板与类模板都是为了能够使代码复用起来,继承机制是面向类设计的复用手段,它允许程序员在保有原有类特性的基础上进行扩展,在其上增加功能,这样产生的类便不用再写冗余代码了。
先写个父类:
class Person
{
public:
void Print()
{
cout<<"name:"<<_name<<endl;
cout<<"age:"<<_name<<endl;
}
protected:
string _name="Unknown";
int _age=18;
private:
int secret=100;
};
再写两个子类来继承它,
class Student:public Person
{
protected:
int _stuid;//学号
};
class Teacher :public Person
{
protected:
int _jobid;//工号
};
- 子类会继承父类的所有成员(成员函数+成员变量)
- 子类只需写自己独有的成员,其他都可以复用父类,减少了代码冗余
查看调式信息:
全部继承了下来
继承的定义
继承格式
我们称原有类(被继承者)为父类或者基类,新的类(继承者)为派生类或者子类。
继承关系与访问限定符的组合关系
在介绍类的时候我们谈到了访问限定符,继承的关系和访问限一样:
不同的父类成员访问限定符与不同的继承关系在子类的访问限定符中的呈现:
类成员/继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
父类的public成员 | 子类的public成员 | 子类的protected成员 | 子类的private成员 |
父类的protected成员 | 子类的protected成员 | 子类的protected成员 | 子类的private成员 |
父类的private成员 | 在子类中不可见 | 在子类中不可见 | 在子类中不可见 |
总结:
-
父类的private成员在子类中无论以何方式继承,都是不可见的。子类会继承父类的所有成员,但是在语法上限制了子类,使子类在类内类外都不可访问父类的private成员。
如果父类的public成员函数访问了父类private成员,那么子类调用父类的成员函数就可以访问父类的private成员变量了。
-
父类的protected和private成员,在类外都不能访问,他们的区别在于:protected可以让子类访问,private则不行。这里可以看出保护成员限定符是因继承才出现的。
-
除了基类的private的成员,子类都不可见。基类的所有其他成员在子类的访问方式都是以**Min(访问限定符,继承关系)**的方式进行访问的,public>protected>private。
-
继承关系可以缺省, 与访问限定符一样,class的默认继承关系是private,struct的默认继承关系是public,建议每次写继承都带上继承关系。
-
一般public的继承关系使用的最多,不建议使用protected/private继承,因为这样类的成员只能在子类中使用。
父类与子类的赋值转换
现有父类与子类如下 :
class Person
{
public:
string _name;
int _age;
string _gender;
};
class Student:public Person
{
public:
int _stuid;//学号
};
- ✔ 子类可以给父类对象/父类指针/父类引用 赋值
int main()
{
Student s;
s._name = "Alice";
s._age = 25;
s._gender = "female";
//子类对象给父类对象赋值
Person p=s;
return 0;
}
这里更形象的做法叫做切片或者切割,把子类中父类的那部分切过来赋值给父类。
子类对象可以给父类的指针和引用进行赋值,这里不是类型转换,而是语法天然的行为(否则类型转换会产生临时变量,需要加const才行):
Person *pp=&s;
Person& rp=s;
- 父类的指针将以其对父类的访问方式来访问子类对象;
- 父类的引用将变为子类对象中所有父类部分的别名。
测试结果:
但是这里需要注意,如果子类是private继承,那么在给父类赋值时会面临访问权限放大的情况,此时子类给父类赋值就会失败!
- ❌ 父类对象不可以给子类对象赋值,强制类型转换也不行。
-
父类的指针可以通过强制类型转换赋值给子类的指针。
因为子类指针访问父类对象会有越界的风险,所以必须是在父类的指针指向子类对象时才是安全的。
//父类对象指针/引用 赋值给子类的对象和引用(强转)
Student s;
Person* pp1 = &s;//父类的指针原本就指向一个子类对象
Person& rp1 = s;
Student* ps1 = (Student*)pp1;
Student& rs1 = (Student&)rp1;
2. 继承的作用域
当我们在两个作用域中有两个同名的变量时,编译器会根据就近原则来选择变量:
下面代码中,编译器将有限选择局部变量a输出:
当子类与父类中有同名的成员变量或者同名的函数名时,编译器又当如何进行选择呢?
定义:
- 子类与父类是彼此独立的作用域;
- 子类和父类中有同名的成员变量,子类成员将屏蔽父类成员,可直接对同名成员进行访问,这种情况称之为隐藏或者重定义,若要访问父类同名成员须显式带上父类作用域。
class Person
{
public:
string _name;
int _age;
string _gender;
int _num = 10;
};
class Student1:public Person
{
public:
int _num=100;//与父类成员重名
int parent_num=Person::_num;
};
int main()
{
Student1 s;
cout << s._num << endl;//子类对象屏蔽父类同名成员,直接访问
cout << s.Person::_num << endl;//访问父类同名成员须带上作用域
return 0;
}
- 父类与子类的成员函数只需同名,就会将父类的同名函数隐藏。
子类与父类的同名成员函数不会构成重载,函数重载的前提是在同一作用域。子类会将父类的同名函数隐藏,于是子类对象可直接访问自己的成员函数,若需访问父类的同名成员函数必须显式加上作用域:
class A
{
public:
void func()
{
cout << "func()" << endl;
}
};
class B:public A
{
public:
void func(int i)
{
cout << "func(int i)" << endl;
A::func();//两个func并不是重载关系,父类一定要带上作用域
}
};
int main()
{
B b;
b.func(10);
b.A::func();
return 0;
}
非同名则可不用显式添加父类作用域。
- 所以在继承的体系里最好不要定义同名的成员。
3. 子类的默认成员函数
子类有两派成员变量,分别是继承自父类的成员变量以及子类新增的成员变量。
- 所以子类的默认构造函数和默认析构函数做两件事:
- 针对继承自父类的成员,子类会调用父类的构造和析构函数。
- 针对子类自己新增的成员,需调用子类自身的默认构造和析构函数(内置类型不处理,自定义类型的类会调用类自身的构造和析构函数)——这里的表现和普通类的默认构造和析构一样。
- 子类的默认拷贝构造和默认赋值重载
- 继承自父类的成员使用父类的拷贝构造和赋值重载
- 子类自己的成员,使用编译器默认的拷贝构造和赋值重载(内置类型完成浅拷贝,自定义类型调用自身的拷贝和赋值)——这点和普通类的默认拷贝和赋值重载一样。
实验代码
class Person
{
public:
Person(string name = "Alice"): _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;
}
~Person()
{
cout << "~Person()" << endl;
}
string _name;
};
class Stu :public Person
{
public:
//我们不写,使用编译器默认生成的
int _num=100;//内置类型
string _address="Queen Avenue";
};
int main()
{
Stu s1;
Stu s2(s1);
return 0;
}
总结:继承下来的调用父类处理,自己的成员按照普通类的基本原则。
什么时候需要自己写默认成员函数?
- 如果父类没有默认的构造函数,那需要在子类的构造函数的初始化列表中调用父类的构造函数来初始化继承自父类的成员;
实验:
class Person
{
public:
//父类没有默认构造函数,只有带参的构造
Person(string name ) : _name(name)
{
cout << "Person()" << endl;
}
//拷贝构造
Person(const Person& p) : _name(p._name)
{
cout << "Person(const Person& p)" << endl;
}
string _name;
};
class Stu :public Person
{
public:
//在子类的初始化列表中调用带参的父类构造函数来初始化 _name
Stu(string name="Alice",int num = 100, string address = "Queen Avenue")
:Person(name),//父类的带参构造
_num(num),
_address(address)
{
}
int _num;//内置类型
string _address;
};
int main()
{
Stu s1;
return 0;
}
- 如果子类需要深拷贝,需自己实现拷贝构造。而且在初始化列表中,另外需要调用父类的拷贝构造来执行父类成员的拷贝工作,参数为子类的拷贝对象,可自动完成切片:
//子类的拷贝构造
Stu(const Stu& s)
:_num(s._num),
_address(s._address),
Person(s)//调用父类的拷贝,子类自动完成切片
{}
🚩注意:如果父类有默认的构造,且这里不调用父类的拷贝构造的话,也不会出错,但是子类的拷贝就不会拷贝父类的成员了。
- 与拷贝构造一样,子类的赋值重载函数也另需带上父类的构造函数,总之就是一句话:谁的成员,由谁处理
Stu& operator=(const Stu& s)
{
if (this != &s)
{
_num = s._num;
_address = s._address;
Person::operator=(s);//父类的赋值重载,子类对象将切片
}
return *this;
}
- 如果子类有资源要释放,需要写析构函数;
~Stu()
{
//释放资源
}
🚩注意这里虽然没有同名的情况,但如果出现子类调用父类的析构的情况仍然需要添加父类作用域:
原因:
任何类的析构函数名都会被统一处理为
destructor();
。
因此,子类和父类的析构函数也会因为函数名相同构成隐藏,若是我们需要在某处调用父类的析构函数,那么就要使用作用域限定符进行指定调用。
由于在调用子类析构函数后,会自动调用父类析构函数,所以不需要我们主动调用父类析构函数。
父类与子类调用构造与析构的顺序:
4. 继承与友元
友元关系不能继承,父类的友元函数不能访问子类的私有和保护成员
父亲的朋友不是儿子的朋友
5. 继承与静态成员
如果在父类定义了static静态成员,那么这个静态成员将贯穿整个继承体系,且无论派生出多少子类,都只有这一个静态成员。
我们利用静态成员变量可以做一个计数器,每当调用父类的构造函数(构造+拷贝构造)计数器就会+1,从而统计父类以及子类,甚至子类的子类…实例化对象的个数:
class Person
{
public:
Person()
{
_count++;
}
Person(const Person& p)
{
_name = p._name;
_count++;
}
protected:
string _name;
public:
static int _count;
};
int Person::_count = 0;
class Student :public Person
{
protected:
int _num;
};
class Graduate :public Student
{
protected:
int _year;
};
int main()
{
Person p;
Student s;
Graduate g;
cout << Person::_count << endl;
cout << Student::_count << endl;
cout << Graduate::_count << endl;
cout <<&Person::_count << endl;
cout <<&Student::_count << endl;
cout <<&Graduate::_count << endl;
}
如何让一个类不被继承
-
让类的构造函数私有化,子类在实例对象时便不能调用父类的构造,便无法继承。
-
使用final关键字。
6. 继承方式(菱形继承,虚拟继承)
单继承:一脉单传
多继承:一个子类有两个以上的直接父类
菱形继承:多继承的特殊情况
菱形继承的问题就在于他会继承冗余的数据,同时会造成数据的二义性:
见下面代码
class Person
{
public:
string _name;
};
class Student:public Person
{
public:
int _Stuid;//学号
};
class Teacher :public Person
{
public:
int _Jobid;//工号
};
class Assistant :public Student, public Teacher
{
public:
int _courseid;//课程
};
int main()
{
Assistant a;
//二义性,编译器不知道此时的_name继承自哪一个父类
a._name="Peter";
//显式指定父类,但是数据冗余问题仍然存在
a.Student::_name = "Dr.Peter";
a.Teacher::_name = "Mr.Peter";
return 0;
}
在Assistant的对象中的Person成员会有两份。所以我们需要显式的指定访问哪一个父类成员。
注意:继承的先后顺序与内存中的排布顺序一致。
虚拟继承
即使解决了访问的歧义性,数据冗余的情况依然存在。
虚拟继承可以帮助我们解决菱形继承产生的问题,如在上例代码中,在Student 和 Teacher 中在继承 Person 时使用虚拟继承,即可解决问题。但绝大多数情况下尽量避免使用菱形继承与虚拟继承。
虚拟继承的用法,在中间类的继承关系前加上 virtual
为了了解虚拟继承的原理,我们通过一个例子来讲解:
首先简化一下实验代码:
//虚拟继承
class A
{
public:
int _a;
};
// class B : public A
class B : virtual public A
{
public:
int _b;
};
// class C : public A
class C : virtual 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类中的B类和C类的成员的第一个地址是指针,这个指针为虚基表指针,分别指向了B类和C类的虚基表。
我们可以打开另外两个内存窗口2和3来分别输入虚基表指针,看看虚基表里面存放了哪些信息。
虚基表的第一个位置为全零是为之后多态虚表存偏移量预留的位置,后面一个位置存放了当前类距离其父类A成员的偏移量:
_b 距离 _a 有 54=20个字节,转换为16进制为 0x14*
_c 距离 _a 有 34=12个字节,转换为16进制为 0x0c*
我们通过两张图来说明以下虚拟继承前后子类的内存状态:
- 🚩无虚拟继承
- 🚩虚拟继承
到这里你可能会不禁发问,我要这个偏移量干什么呢?
考虑到这种场景1:
B b=d;
C c=d;
当子类给父类赋值时(切片),为了能够赋值A类的对象,就需要找到A类的成员对象,偏移量就起到了寻址的作用。
场景2:
B* pb=&d;
pb->a=10;
B类型指针访问D类对象,B是有权限访问d内的A类成员变量的,这时候就需要B能去找到A的地址->虚基表的偏移量。
注意:B、C本身的内部结构也是:虚基表指针->B/C类成员->A类成员
7. 继承的总结
1.尽量少使用多继承和不用菱形继承,有了菱形继承就会有虚拟继承。底层结构就变得复杂。
2.继承与组合
-
public继承是is-a的关系,即每个子类都是父类的对象
-
组合:一个类中包含其他类,是一种has-a的关系,每个car中都有一个tire对象
优先使用组合,而不是类继承。
继承允许根据父类的实现来定义子类的实现,父类的内容及细节对于子类来说是相对可见的,通过子类实现类的复用我们称为白箱复用。这一定程度上破坏了父类的封装(封装的目的之一就是减少耦合),每当对父类的公有和保护成员进行修改,便会对子类产生影响,子类与父类是强关联性的,耦合度高。
组合是类的另一种复用选择,这种复用风格称为黑箱复用,因为被包含的类的细节是不可见的,他只需提供公有接口给到外层类即可,组合类之间没有强依赖性,耦合度低,优先使用组合有助于类的封装。
不过该使用继承还是应当使用,比如多态就必须在继承的基础上实现。
青山不改 绿水长流