【c++】万字长文,浅析c++继承特性

简介: 【c++】万字长文,浅析c++继承特性

1. 继承的概念和定义

1.1 概念

继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。

这种概念在现实生活中也是广泛存在,比如我们自己:

我们最基本的身份是一个人,然后以此为基础,衍生出来了各种各样的身份,

如: 学生,子女 …… 但是都是继承了人的这个身份。

1.2 定义

下面我们看到Person是父类,也称作基类(base class)。Student是子类,也称作派生类(Derived class)。

父类:

class Person
{
public:
  void Print()
  {
    cout << "name:" << _name << endl;
    cout << "age:" << _age << endl;
  }
protected:
    string _name = "peter"; // 姓名
    int _age = 18; // 年龄
};

子类:

class Student : public Person
{
  protected:
  int _stuid; // 学号
};

在Student 子类中,我们继承了父类 Person 中_name,_age 数据。

继承后父类的Person的成员(成员函数+成员变量)都会变成子类的一部分。这里体现出了Student和Teacher复用了Person的成员。

1.2.1 定义格式

继承方式精辟的总结就是:小小取小

基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。

注意:是会被子类继承,但是子类无论在类内还是类外都无法访问。

基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。

可以看出保护成员限定符是因继承才出现的。


使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式


在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承。

2.基类和派生类对象赋值转换(##)

派生类(子类)对象 可以赋值给 基类(父类)的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。

就是把子类中父类的那一部分切割下来,然后调用父类对象的拷贝构造或者拷贝赋值函数,对父类对象进行赋值。

注意:

  1. 基类对象不能赋值给派生类对象
// 子类对象可以赋值给父类对象/指针/引用
  Student s;
  Person p1;
  Person p2 = s;
  Person* p3 = &s;
  Person& p4= s;
//父类对象不能给子类对象赋值
  s=p1;


3. 继承中的变量和函数隐藏(#)

  1. 在继承体系中基类和派生类都有独立的作用域。
  2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,
    也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)
  3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
  4. 注意在实际中在继承体系里面最好不要定义同名的成员。
class A {
public:
  int val = 10;
  void fun()
  {
    cout << "A::fun()" << endl;
  }
};
class B : public A
{
public:
  int val = 20;
  void fun()
  {
    cout << "B::fun()" << endl;
  }
};
int main()
{
  A a;
  B b;
  cout<<a.val<< " "<<b.val<<endl;    //    10     20
  a.fun();    //打印出  "A::fun()"
  b.fun();    //打印出  "B::fun()"
  //作为子类 ,也可以显示调用父类的数据
  b.A::a;   //打印 10
  b.A::fun();   //   "A::fun()"
  return 0;
}

fun()函数构成的不是函数重载,因为他们不在同一个作用域中,父类作用域和子类是不同的作用域,他们构成了重定义。

4.派生类的默认成员函数(###)

在每个类中,如果我们不去定义,那么编译器会自动生成的默认函数。那么衍生类的默认函数该如何呢?

  1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认
    的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
  1. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
  2. 派生类的operator=必须要调用基类的operator=完成基类的复制

我们来一一验证

看如下代码:

class  Person {
public:
  Person() {
    cout << "Person()" << endl;
  }
  Person(const string& n, int a)
    :name(n)
    , age(a)
  {}
  Person(const Person& p)
  {
    cout << "Person(const Person& p)" << endl;
  }
  Person& operator=(const Person& p) {
    cout << "operator=(const Person& p)" << endl;
    return *this;
  }
  ~Person()
  {
    cout << "~Person()" << endl;
  }
  string name="张三";
  int age=18;
};
class  Student : public  Person
{
public:
  Student(int id = 123456)
    :id(123456)
  {}
  int id;
};

子类我们并没有实现构造函数,拷贝赋值函数等,但

来看:

Student s;
Student s1 = s;

定义了两个Student 类,看调试结果:

编译器调用了父类的默认构造函数,和拷贝构造函数。

再看:

  Student s1;
  Student s2;
  s1 = s2;

子类会调用父类的拷贝赋值函数,同时在析构时,也会去调用 父类的析构函数。

我们也可以显示调用父类的赋值等函数

class  Student : public  Person
{
public:
  Student(int id = 123456)
    :id(123456)
  {}
  Student(const string& s, int a, int num)
    :Person(s, a)
    , id(num)
  {}
  Student(const Student& s)
    :Person(s)//切片后在调用父类的拷贝构造
    ,id(s.id)
  {}
  Student operator=(const Student& s)
  {
    Person::operator=(s);
    id = s.id;
  }
  ~Student()
  {
    Person::~Person();
);
  }
  int id;
};

但是需要注意的一点,是析构函数,按照其它默认函数的方法,我们这样定义子类的构造函数

~Student()
  {
     ~Person();
     _id=0;
  }

但是,这样写是错误的

编译器识别不了~Person() 函数,为什么呢?

每个类的析构函数会被处理为 Destuctor(),所以父类和子类的析构函数构成隐藏,编译器无法识别。

那么,我们还可以加上作用域符::

~Student()
  {
     Person::~Person();
     _id=0;
  }

可是,这么写正确嘛,我们运行一下:

可见,这里调用了两次析构函数 ,为什么呢?

因为子类的析构函数会自动调用父类的析构函数,无论你是否已经显示调用了父类的析构函数。

所以:

  1. 子类的析构会自动调用父类的析构函数,如果我们自己显示调用,会造成父类资源多次释放,在涉及动态资源开辟时会报错。
  2. 无需我们显示调用父类的析构函数,因为编译器要保证析构的顺序。
  1. 析构时是先析构子类区别于父类的资源,在调用父类的析构函数析构父类。
  2. 构造时先父类再子类,析构时先子类再父类,符合栈的规则(都是定义在栈区上的变量)

5.友元函数和静态成员

5.1.友元函数

可以用一句话来说————父亲的朋友不是我的朋友

也就是说,父类的友元函数不会继承给子类。

也就不多演示了。

5.2.静态成员

基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子

类,都只有一个static成员。

class  Person {
public:
  static int count;
public:
  Person() {
    cout << "Person()" << endl;
    count++;
  }
  Person(const string& n, int a)
    :name(n)
    , age(a)
  {
    count++;
  }
  Person(const Person& p)
  {
    cout << "Person(const Person& p)" << endl;
    count++;
  }
}
int Person::count = 0;// 类外对静态变量进行初始化

基于静态成员变量的特性,我们可以实现这样一个功能:

对整个继承体系的对象创建计数

Person p1;
Person P2;
Person s3=s2;
Student s1;
Student s2;
Student s3=s2;
cout<<Person::count<<endl;

我们创建了六个对象,试运行一遍看结果:

结果正确。

6.菱形继承(###)

在当初设计继承时,我们的c++之父链接: 本贾尼大佬在设计这一块时,都没有想到这一个大坑,为此我们的本大佬也是花费了很多精力才解决了这一个困难。

回到菱形继承上来,首先引入的概念是多继承:

根据我们的现实生活经验,一个事物可能同时兼具两种甚至多种属性,

比如:我们,同时有着学生和子女的属性,又如,我们是学生的同时,也是老师的助手或者助教。

但是Student类和Teacher类都继承自Person 类,所以他们构成了菱形继承。

代码:

class Person{....};
class Student :public Person
{....};
class Teacher :public Person
{....};
class Assistant :public Student public Teacher
{....};

所以,我们的本大佬就设计出了多重继承的方式,非常贴合现实,也是非常实用的,但是也有菱形继承这一个大坑。

从上图可以看出,菱形继承有数据冗余和二义性的问题:

Student 类继承了Person 类中的 _name=“小张” ,_age =18;

Teacher 类继承了Person 类中的 _name=“老张”,_age=18;

我们可以看到,_age 出现了数据冗余的问题,我们只需一份_age 数据,而这里出现了两份,当我们的父类Person 类中的数据较多时 ,会造成极大的空间浪费。


那么二义性呢,你可能会这么想,当我是学生时同学喜欢称呼我叫小张,当我是老师是,同事叫我老张,不同场景不同称呼啊,没有错误呀。


但是计算机喜欢一个能直接表示你的身份和属性的名字,也就是我们现实生活中我们身份证上的名字,计算机不关心你在家时的小名,和你在学校里的外号,他只需要记录你的真实信息。


接下我们看代码:

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; // 主修课程
};
int main()
{
  Assistant a;
  a._name = "张三";
  return 0;
}

由于二义性的问题,对_name 的访问不明确,我们还可以用类访问限定符::

int main()
{
  Assistant a;
  a.Student::_name = "张三";
  return 0;
}

们调试来看:

可以看到,我们对Person类中的_name cg==成功赋值了,解决了二义性的问题,但是_name 也存在着数据冗余,如何解决,

就需要看我们本贾尼大佬专门设计出的虚继承了。

7.虚拟继承(#####)

虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和Teacher的继承Person时使用虚拟继承,即可解决问题。

需要注意的是,虚拟继承不要在其他地方去使用。(虚拟继承是专门设计出来解决菱形继承稳定的)。

那么是如何解决问题的呢,我们来看本贾尼大佬及其聪明的才智吧:

监视窗口已经看不到虚继承真实的面目,我们需要借助内存窗口来进行调试,

看如下代码:

这是不加上虚继承的代码,

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;
};
int main()
{
  D d;
  d.B::_a = 1;
  d.B::_b = 2;
  d.C::_a = 3;
  d.C::_c = 4;
  d._d = 5;
  return 0;
}

还是存在着数据冗余。

我们来看虚继承,关键字::virtual

class A {
public:
  int _a;
};
class B : virtual public A
{
public:
  int _b;
};
class C : virtual  public A
{
public:
  int _c;
};
class D : virtual public B ,virtual public C
{
public:
  int _d;
};
int main()
{
  D d;
  d.B::_a = 1;
  d.B::_b = 2;
  d.C::_a = 3;
  d.C::_c = 4;
  d._d = 5;
  return 0;
}

是不是有点不一样了,我们从内存窗口来看更多的细节:

我们可以看到,编译器把父类A 摘出来了,将基类里的数据单独存储在一个空间,但是它又同时属于B和C,那么我们如何找到公共的A 呢?

我们可以看到 B类,C类 中存储的自己的数据,在首地址处还存储了一个地址,名为虚机表地址指向了一块表,叫做虚机表:该表的第二个地址是一个偏移量,由图可得,其大小为该类的地址到我们的基类 A 的相对距离。 我们就可以通过这个偏移量找到A。

同时表上还有一个位置,那里的知识涉及了多态,我们稍后再说。

可以看到,只有一份 A的数据 ,且不需要加上类访问限定符 便可以访问公共的A ,完美解决了 数据冗余 和 二义性 的问题。

但是,又有同学问了:你说你解决了数据冗余的问题,我怎么一对比,你虚继承后需要的空间还比原来的要大呢?

这个是因为,我们的基类A 中现在只有一个int 类型,数据量比较小,所以会出现 内存还比原来大的情况 。

重点::当我们定义多个D对象时,D d1,D d2时,每一个对象都指向同一块虚机表,所以虚机表的空间消耗可以忽略。

这些消耗 是 解决数据冗余和二义性的必备要素。

总结

  1. 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
  2. 多继承可以认为是C++的缺陷之一,很多后来的OO(面向对象)语言都没有多继承,如Java。

继承和组合(##)

public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。

组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。


is-a 的意思就是 子类(学生)就是一个父类(人) 白箱复用(white-box reuse)


has-a 的意思是 我在我的域中使用你 ,你是我的一部分,比如我使用我的鼻子,在STL库中,每个容器中的iterator迭代器 就是属于这样的关系,黑箱复

用(black-box reuse)。


实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。

面试题

最后还有一道面试题 ,来测试一下你是否真真搞懂了继承啊:

class A{
public:
  A(char *s) { cout << s << endl; }
  ~A(){}
};
class B :virtual public A
{
public:
  B(char *s1, char*s2) :A(s1) { cout << s2 << endl; }
};
class C :virtual public A
{
public:
  C(char *s1, char*s2) :A(s1) { cout << s2 << endl; }
};
class D : public B, public C
{
public:
  D(char *s1, char *s2, char *s3, char *s4) :B(s1, s2), C(s1, s3), A(s1)
  {
    cout << s4 << endl;
  }
};
int main() {
  D *p = new D("class A", "class B", "class C", "class D");
  delete p;
  return 0;
}

答案是 “class A”, “class B”, “class C”, "class D“

分割线:

别偷看哦。

####################################################

解析:我们知道,类对象初始化的时候,其初始化顺序不由初始化列表控制,而是根据定义时候的顺序进行初始化,而且根据继承规则,先构造父类数据,在构造子类变量,我们知道,D类先继承了B类,再继承C类,所以,先初始化B类 ,B又是先初始化A类,然后再是B类,随后调用C类,由于父类A 类在初始化时只能被初始化一次,所以C类不会调用A类,最后便是D类

所以 结果是 A B C D。

结语

本次的博客就到这了。

我是Tom-猫,

如果觉得有帮助的话,记得

一键三连哦ヾ(≧▽≦*)o。

咱们下期再见。

相关文章
|
2月前
|
编译器 程序员 定位技术
C++ 20新特性之Concepts
在C++ 20之前,我们在编写泛型代码时,模板参数的约束往往通过复杂的SFINAE(Substitution Failure Is Not An Error)策略或繁琐的Traits类来实现。这不仅难以阅读,也非常容易出错,导致很多程序员在提及泛型编程时,总是心有余悸、脊背发凉。 在没有引入Concepts之前,我们只能依靠经验和技巧来解读编译器给出的错误信息,很容易陷入“类型迷路”。这就好比在没有GPS导航的年代,我们依靠复杂的地图和模糊的方向指示去一个陌生的地点,很容易迷路。而Concepts的引入,就像是给C++的模板系统安装了一个GPS导航仪
113 59
|
2月前
|
安全 程序员 编译器
【C++篇】继承之韵:解构编程奥义,领略面向对象的至高法则
【C++篇】继承之韵:解构编程奥义,领略面向对象的至高法则
86 11
|
2月前
|
C++
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
54 1
|
2月前
|
C++
C++番外篇——虚拟继承解决数据冗余和二义性的原理
C++番外篇——虚拟继承解决数据冗余和二义性的原理
42 1
|
2月前
|
存储 编译器 C++
【C++】面向对象编程的三大特性:深入解析多态机制(三)
【C++】面向对象编程的三大特性:深入解析多态机制
|
2月前
|
存储 编译器 C++
【C++】面向对象编程的三大特性:深入解析多态机制(二)
【C++】面向对象编程的三大特性:深入解析多态机制
|
2月前
|
编译器 C++
【C++】面向对象编程的三大特性:深入解析多态机制(一)
【C++】面向对象编程的三大特性:深入解析多态机制
|
2月前
|
C++
C++ 20新特性之结构化绑定
在C++ 20出现之前,当我们需要访问一个结构体或类的多个成员时,通常使用.或->操作符。对于复杂的数据结构,这种访问方式往往会显得冗长,也难以理解。C++ 20中引入的结构化绑定允许我们直接从一个聚合类型(比如:tuple、struct、class等)中提取出多个成员,并为它们分别命名。这一特性大大简化了对复杂数据结构的访问方式,使代码更加清晰、易读。
37 0
|
2月前
|
安全 编译器 程序员
C++的忠实粉丝-继承的热情(1)
C++的忠实粉丝-继承的热情(1)
20 0
|
2月前
|
编译器 C++
C++入门11——详解C++继承(菱形继承与虚拟继承)-2
C++入门11——详解C++继承(菱形继承与虚拟继承)-2
32 0