前言
继承 是面向对象语言(OOP)的三大特性之一,主要体现的是代码的复用!本博客将介绍 C++ 的继承机制!
目录
1、继承的概念
1.1 继承的概念
什么是继承?继承家产?显然不是!我们先来看看定义:
继承 是 面向对象 代码设计时 代码复用 的重要手段,它允许程序员在保持父类特性的基础上进行扩展,这样产生的新类称为 派生类。
• 被继承的类称为:父类 / 基类
• 继承的类称为:子类 / 派生类
• 继承的本质是 代码的复用
OK,我们先来个栗子先来见一见:
class Father// 父类/基类
{
public:
void getMeony()
{
cout << "getMeony : " << _meony << endl;
}
int _meony = 1000000;// 100W
};
// 子类/派生类
class Son : public Father // 继承
{
private:
string _name = "张三";
};
其中Son 就是子类,Father 就是父类!
子类虽然没有钱,但是父类有啊,子类直接继承父类就可以使用了:
1.2 继承的定义格式
继承的格式很简单, 子类 :继承方式 父类
所以上面的例子就是:Son 继承了 Father 继承方式是 public
注意:C++ 的继承符是 :而 Java 中的是 extends
1.3 继承方式与权限
• 继承方式 有三种:公有public、保护protected、私有private
• 访问限定符 有三种:公有public、保护protected、私有private
关于后者,我们当时在介绍类和对象的时候说过private和protected暂时认为是一样的,后续继承了作区分,待会会介绍他两的区别!
访问限定符为公有的成员,在类外是可以访问的;访问限定符是私有的成员在类外是不可访问的!
继承的方式有三种,类中的访问限定符也有三种,每一种继承方式继承,子类会根据父类的访问限定符和继承方式,对父类的成员访问受到相应的权限限制!
什么意思呢?就是子类想访问访问父类的成员,要看继承方式和父类访问限定符!
继承的方式有三种,类中的访问限定符也有三种,他两组合一起就是9种情况:
这个表格总结一下就是:
• 父类种的私有成员,无论哪一种方式继承下去,都是不可见的!这里的不可见,不是子类没有,只是语法限制,子类/类外中不可以访问!
• 父类的成员方法要想在子类中使用(类外不能访问),需要将访问限定符设为 protected!
• 这个表格也不要记,只要记住 子类可访问的权限 = min (访问限定符,继承方式)
• 继承方式可以不写,class 默认不写是private 、struct 默认不写是 public
OK,验证一下:
class Peron
{
public:
string _name = "father";
protected:
string _home = "200平";
private:
int private_money = 10000;
};
class Student : public Peron
{
public:
void Print()
{
// public 在类外可以访问
cout << "name: " << _name << "home:" << _home << endl;
_name = "cp";
// protected 之允许在类里面访问
_home = "500平";
cout << "name: " << _name << "home:" << _home << endl;
}
};
Student stu;
stu.Print();
cout << "--------------------" << endl;
stu._name = "cpd";
stu.Print();
此时是public继承,所以子类不可访问 private的成员,protected 可以在子类访问:
如果是,protected 方式继承 此时子类也是只能访问 public 和 protected 的成员!
验证父类中的私有属性在子类中存在但是不可见:
解决初阶正在类和对象是遗留的 private 和 protected 的区别?
介绍到这里,应该很清楚了!他两都是直接使用对象在类外不可访问!
protected 的用途是,如果期望父类的一些字段之允许在子类中使用,就可以用 protected 限制private 是对象在类外/子类中都是无法直接访问的,但是可以间接访问!
就是我们以前常玩的多加一层的原理,啥意思?类外访问不了,那类里面的成员可以访问吧!可以在类外调用可用的方法去访问他们!
我么可以演示一下,间接访问:
1.4 继承的作用和实际例子
上面说了,继承是为了复用父类的代码而在此基础上更好了开发!
我们至今学过的复用有:函数复用、模版复用
• 函数的复用 就是将很多模块中的共用的代码提取到一个函数中,减少代码的冗余!
• 模版的复用 就是减少了类似逻辑的函数/类出现多次,例如我们的整数栈和浮点数栈,就一个类型不同,所以就写成了模版!
这里继承也是类似的,提高代码的复用、减少冗余的!只不过他是在类层次的复用!就是将很多个类中的共有的一些成员提取出来构成一个类,这个类就是父类!子类中就是个各类种不同的那部分!
举个例子:你们学校现在要开发一个学校的管理系统,首先得对学校的人进行,先描述即构建类、然后在通过某种数据结构在组织起来!但是学校的人有很多,学生/老师/保洁/宿管等!这写人本质就是类,他们中的一些字段是重复的,例如:姓名、年龄、性别等,有一部分是不同的、例如学号/工号等!
所以就可以将,共有的这部分属性分装成一个公共的父类,每个类以后都不用写这部分的属性了,直接继承下来就有了,他们只需要把自己特殊的那些属性在他们自己的类中就加上即可!
继承的实际例子
继承其实在类和对象中是很常用的,我这里举一个我们最熟悉的 iostream 流
这张图就是类的继承关系图!这里你可能发现了iostream 继承了两个类,是的!这是多继承,后面了会介绍~!
2、 继承的作用域
在继承中,子类和父类是独立的作用域。那当子类和父类有同样名字的字段时,优先使用谁的?其实这种情况有个专门的名字叫做 隐藏!
2.1 隐藏
当子类和父类中的成员名字相同时,此时子类和父类中的同名成员构成隐藏或重定义!
class Person
{
public:
void Print()
{
cout << _name << endl;
}
protected:
string _name = "father";
};
class Student : public Person
{
public:
string _name = "son";
};
此时的_name 父子类中都有,构成隐藏!
我们发现当父子类的成员构成隐藏时,子类的优先使用(局部优先)!
其实不止成员属性可以构成隐藏,成员方法也是构成隐藏的:
class Person
{
public:
void Print()
{
cout << _name << endl;
}
protected:
string _name = "father";
};
class Student : public Person
{
public:
void Print(int n)
{
cout << _name << endl;
}
string _name = "son";
};
此时的 Print 也是可以构成隐藏的!
那我就想访问的父类的成员属性或者方法,该如何访问呢?
刚上面说了,父类和子类时不同的域!所以,可以通过指定作用域访问
注意:这里很多伙伴会把这里的Print会当成函数的重载!不是的,函数的重载是在同一作用域,这里的父子类是不同的作用域!
3、父子类对象之间的赋值兼容
子类的对象可以赋值给父类对象,但是父类对象不可以赋值给子类对象!
这个可以这么理解:儿子以后可以当父亲,但是父亲不能在当儿子了!
子类对象 赋值给 父类对象时,会触发切片/切割机制(编译器完成)!
3.1 对象切片
当子类对象 赋值给 父类对象时,会将子类对象中父类对象的那部分 赋值给 父类对象!这个过程形象的称为 对象切片!
其实不止可以将 子类对象 赋值给 父类对象,还可以 赋值给 父类对象的引用和指针!
其实,可以将父类继承的那部分当成一个 结构体/类 对象!
注意:上述的切片赋值的过程是编译器完成的,没有中间的临时变量!
4、派生类的默认成员函数
每一个类都有6个默认的成员函数,这里的默认就是我们不写编译器自动生成!
因为 子类/派生类 是继承了父类的成员,所以子类的6大成员函数生成时 需要考虑父类的那部分!
这里先说结论在验证!
1、子类在构造时,必须掉用父类的默认构造去初始化子类中父类的那部分
2、当父类没有提供默认构造时,则必须在子类的 初始化列表 显示调用
3、子类在拷贝构造/赋值拷贝时,必须去调用父类的拷贝构造/赋值拷贝 去拷贝父类的那部分
4、子类对象调用完析构函数,会去调用父类的析构函数去清理父类的那部分资源(自动调用)
总结:把子类中父类的那部分当成一个父类型的成员变量即可
class Person
{
public:
Person()
{
cout << "Person()" << endl;
}
~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person
{
public:
Student()
{
cout << "Student" << endl;
}
~Student()
{
cout << "~Student" << endl;
}
};
这里是父类有默认构造的场景,父类没有默认的构造,就需要子类在初始化列表手动的显示调用:
类似的拷贝构造和赋值拷贝也是一样的:
class Person
{
public:
Person(const string &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;
}
~Person()
{
cout << "~Person()" << endl;
}
protected:
string _name = "cp";
};
class Student : public Person
{
public:
Student(const string& name, const int num)
:Person(name)// 此处需要显示的调用
,_num(num)
{
cout << "Student" << endl;
}
Student(const Student& s)
:Person(s)
,_num(s._num)
{
cout << "Student(const Student& s)" << endl;
}
Student& operator=(const Student& s)
{
cout << "Student& operator=(const Student& s)" << endl;
if (this != &s)
{
Person::operator=(s);
_num = s._num;
}
return *this;
}
~Student()
{
cout << "~Student" << endl;
}
protected:
int _num = 0;
};
int main()
{
Student s1("cpdd", 1);
Student s2("haha", 22);
Student s3(s2);
s2 = s1;
return 0;
}
上面说了,构造时可以手动的显示在子类中调用父类的构造!而析构时是在子类调用完之后再去调用父类的,那如何保证他们的顺序呢?是不是可以在子类的析构中现实的调用父类的析构呢?
我们发现在子类中显示的调用就会发生重复析构的问题!原因很简单,就是本来是子类析构后会自动调用父类的析构,这里有重复显示的析构了一次,这是未定义行为很危险!所以,不允许显示调用父类的析构!
注意:后面的一些场景析构函数会进行重写、重写的条件之一就是函数名相同,所以编译器会对父子类的析构函数统一处理为 destructor 所以父类析构不加 virtual 的情况下,父子类的析构默认和operator=一样,构成隐藏!
析构函数必须设为 虚函数,这是一个高频面试题,同时也是 多态 中的相关知识,后面会介绍!
5、继承与友元和静态成员
5.1 继承与友元
直接先说结论:友元关系不能被继承
很好理解,父亲的朋友不是我的朋友
class Base
{
friend void Print();
private:
static const int a = 10;
};
class Derived : public Base
{
private:
static const int b = 20;
};
void Print()
{
cout << Base::a << endl;
cout << Derived::b << endl;// error 友元不可继承 所以这里 error
}
int main()
{
Print();
return 0;
}
如果想让 Print
函数也能访问子类中的私有成员,则需要 将其也声明为子类的友元函数
5.2 继承与静态成员
还是先说结论:静态成员属于类,只有唯一的一份、和是否继承没关系
什么意思呢?就是静态的成员变量你可以继承,但是继承下去的和父类是同一个即父子共享!
我们可以利用这个特性写一个检测父类创建了多少个对象的 demo
class Base
{
friend void Print();
public:
Base()
{ ++num; }
protected:
static int num;
};
int Base::num = 0;
class Derived : public Base
{
public:
friend void Print();
Derived() { ++num; };
};
void Print()
{
cout << Base::num << endl;
}
int main()
{
Derived d1;
Derived d2;
Derived d3;
Base b1;
Print();
return 0;
}
此时创建了三个子类对象,子类对象在构造时会去掉父类的默认构造,所以这里就是6个,最后Base 父类又自己创建了一个就是7个!
6、菱形继承
6.1 继承方式
C++继承的方式不只是有前面介绍的单继承;还有多继承!
单继承:一个子类只能有一个直接的父类,此时这个子类被称为单继承
多继承:一个子类有两个或者两个以上的直接父类,此时的子类被称为多继承
• 在多继承时,哪一个父类先被初始化?
谁先被声明谁先就被初始化,与继承的顺序无关
这里举一个简单的多继承的例子:
class Teacher1
{
public:
protected:
string _name = "cp"; // 保护成员,可以在派生类中访问
};
class Teacher2
{
public:
protected:
int _age = 20; // 保护成员,可以在派生类中访问
};
class Student : public Teacher1, public Teacher2
{
public:
void Print()
{
cout << _name << " " << _age << " " << _id << endl; // 访问从基类继承的成员和自身的成员
}
private:
int _id = 9; // 定义在Student类中的私有成员
};
int main()
{
Student s;
s.Print(); // 调用Print方法打印信息
return 0;
}
注意:多继承时每一个基类用 , 隔开
此时的学生类就是多继承!这样一个类就可以继承不同类的更多成员了,带来了巨大的便捷性!但便捷的同时也带来了一个大坑:菱形继承!
6.2 菱形继承
菱形继承(也称为 钻石继承)是多继承的一种特殊情况,指的是,一个子类继承了两个及以上的父类,而这两个及以上的父类又继承自同一个父类!
关系图如下:
此时D类直接继承了B和C类,而B和C类同时继承自A类!这种继承关系就构成了菱形继承!
A类 被称为 间接基类,B和C类称为 直接基类
菱形继承的缺点是,会带来最后一个子类(这里就是D类)的 数据冗余 和 数据的二义性 !
数据冗余:子类中继承下来的一些数据会存在多份
数据二义性:两个或以上的直接基类,都继承自同一基类,不同直接基类各有一份间接基类的数据,然后不同直接基类被一个子类同时继承,此时该子类中的某些属性因为存在多份,而导致的调用时编译器不明确要使用从哪一个直接基类中继承下来的数据!
还是拿一个图说话:
转换成代码就是:
class A
{
public:
protected:
string _name = "A";
};
class B : public A
{
public:
protected:
int _id = 9;
};
class C : public A
{
public:
protected:
int age = 20;
};
class D : public B, public C
{
public:
};
此时如果我想在D类中写一个Print 方法,此时_name会出现调用不明确:
如果要想解决数据访问时的二义性,只需要指定类域即可!
但这种方式只是暂时解决了数据访问时的二义性,并没有实际的解决数据冗余,于是 C++ 使用虚继承来解决了这个问题!
6.2 虚继承
虚继承 是指在菱形继承 腰部 的类,在继承公共的基类时,在腰部继承公共类的前面加上关键字 virtual 之后的子类即使多继承了腰部的类,此时它的内部只有一份腰部类的成员!
这样说有点抽象,来个图:
class A
{
public:
protected:
string _name = "A";
};
class B : virtual public A
{
public:
protected:
int _id = 9;
};
class C : virtual public A
{
public:
protected:
int _age = 20;
};
class D : public B, public C
{
public:
void Print()
{
cout << _name << " " << _age << " " << _id << endl;
}
};
此时就不会出现 数据二义性 和 数据冗余 的问题了!
注意:这里的 腰部 指的是 直接基类,在继承一个公共的间接基类的那个位置
被 virtual 修饰的类 共同继承 的基类被称为 虚基类!
6.3 虚继承原理
对于一般 菱形继承 的类结构图如下:
可以看到,在子类D中,有两份A的数据,这也是导致数据冗余和二义性的原因!
虚继承之后,把这公共继承的基类的数据,提取出来放到了最后面,原先存放冗余数据的位置存放一个叫做虚基表指针:
现在的问题是,将 冗余的数据 提取出来弄成了一份,但是如何访问呢?
访问这个公共的数据就是虚基表了!当冗余的数据提走后,原来存放冗余数据的位置 现在存储一个指针,称为虚基表指针;虚基表指针指向一张虚基表(一段内存空间),在虚基表内部存在着一个偏移量,找到这个偏移量,然后 虚基表指针+偏移量 就可以找到公共的成员 A的成员了!
我们可以看看内存:
为了方便在内存中观察,我这里把四个类修改一下
class A
{
public:
int _a = 1;
};
class B : virtual public A
{
public:
int _b = 9;
};
class C : virtual public A
{
public:
int _c = 20;
};
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;
}
我们可以先看到,当在修改B中的_a和C中的_a时,他们是一个:
内存中的通过虚基表访问共有的基类的数据的过程如下:
注意:
1、虚继承之后,对于BC类本身,他们创造出来的对象也是通过虚基表指针+偏移量的方式访问的
2、同一类的对象,他们的类结构和偏移量都是一样的,所以,同一类的所有对象共用一份虚基表
3、虚继承的这套设计确实牛逼,但是现实中不要使用菱形继承!
7、继承与组合
复用代码不仅可以通过继承,组合也是可以的
公有继承:是一种 is-a 的关系,每一个子类都是一个特殊的父类对象
组合:是一种 has-a 的关系,假设B组合了A,每个B对象中都有一个A
//父类
class A {};
//继承
class B : public A
{
//直接继承,直接使用
};
//组合
class C
{
private:
A _aa; //创建 A 对象,使用成员及方法
}
继承 可以直接将基类的成员继承到派生类中,让派生类直接访问
组合 可以通过对成员变量的访问,来间接访问其他类的成员
实际项目中,更推荐使用 组合 的方式,这样可以做到 解耦,避免因父类的改动而直接影响到子类
继承到这里就差不多介绍完了,你可能以为啥这么费力的设计出一个继承,组合不复用代码更好吗?其实继承的另一个用途是拿他去实现多态!
OK,本期分享就到这里,我是 cp 我们下期再见~!