【c++】“谁想继承我的花呗-.-“继承的学习(上)

简介: 【c++】“谁想继承我的花呗-.-“继承的学习(上)

前言



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


一、继承的语法



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;       //学号
};
int main()
{
  Student s;
  s.Print();
  return 0;
}


我们可以看到上面的代码中student类并没有print函数以及name和age变量,但是在调用的时候竟然能调用父类的成员函数:


023ead20bf154271800e8484ae36d08e.png


为什么能成功调用呢?因为student继承了person,所以继承是完成了复用。父类也可以叫做基类,子类也可以叫做派生类。但是在继承中还分三种继承方式,公有继承,私有继承,保护继承,下面我们来看一下继承的规则:

0cc36f8637da4b0f856982af34616bd4.png

护,私有成员还是私有。其实规律就是类成员的继承方式是根据权限小的那个继承方式继承的。比如保护继承中公有成员还是保护(因为保护的权限小于公有的权限), 保护成员还是保护,私有成员还是私有因为私有的权限小于保护,而私有继承由于权限已经是最小的所以成员都是私有的,并且私有成员在派生类中不可见。


总结:


1. 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的 不可见是指基类的私

有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。

2. 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。 可以看出保护成员限定符是因继承才出现的。

3. 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他

成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public >protected> private。

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

5. 在实际运用中一般使用都是 public 继承,几乎很少使用 protetced/private 继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。


下面我们演示一下这三种方式:

class Person
{
public:
  void Print()
  {
    cout << "name:" << _name << endl;
    cout << "age:" << _age << endl;
  }
protected:
  string _name = "peter";
private:
  int _age = 18;
};
class Student :public Person
{
public:
  void func()
  {
    cout << "_name:" << _name << endl;
    //cout << "_age" << _age << endl;
  }
protected:
  int _stuid;       //学号
};
int main()
{
  Student s;
  s.Print();
  s.func();
  return 0;
}


我们发现受保护的成员派生类可以调用,而私有成员不可以访问:

75cc3d83fe7748a697ae53e7d77c35fe.png6f16e7d39ff24132a1d67b1fee04def5.png


我们可以看到保护和私有在当前类没有区别,在派生类就不一样了,私有在派生类不可见,而保护是在子类是可见的那么在什么时候我们会定义私有呢?当我们不想被子类继承就可以定义为私有。那我们将成员设为私有有什么办法可以在派生类中使用呢?当然可以,我们只需要在子类中调用父类的函数即可,如下:

class Person
{
public:
  void Print()
  {
    cout << "name:" << _name << endl;
    cout << "age:" << _age << endl;
  }
private:
  string _name = "peter";
  int _age = 18;
};
class Student :public Person
{
public:
  void func()
  {
    Print();
  }
protected:
  int _stuid;       //学号
};
int main()
{
  Student s;
  s.Print();
  s.func();
  return 0;
}

b85ff876b30945a8916d591e02b79050.png


当然,我们的继承方式可以像类中默认权限一样可以不写,不写默认是私有继承,如下图:

81e85abdee1d4654882435d5e9cc729a.png533e8ca553754dd1a260e69c714988f1.png


同样的,struct的默认权限为公有,struct的默认继承权限也是公有的。


二、基类和派生类对象赋值转换



1.例子


我们先看一下下面的代码:


int main()
{
  int i = 5;
  double d = i;
  return 0;
}


我们之前说过,像这样的两个类型不相同的赋值一定会发生隐式类型转换。int类型的i先给一个double类型的临时变量,再将临时变量给double d这个值。那么如果我们将一个子类给一个父类对象会发生什么呢?

int main()
{
  Student s;
  Person p = s;
  return 0;
}


在公有继承下,子类可以赋值给父类,这里是天然的,不存在类型转换发生。因为在公有继承下,子类是一个特殊的父类,那么子类会有可能比父类多出来变量或对象,那该怎么解决呢?


4d248dbb737249568a0de4842a7d6e46.png


其实就是把子类中父类的那部分切出来然后给父类,下面我们来验证一下:

88252a237f3a4a3ba2f5b059f46eefe0.png


我们不能直接将d给int &是因为这里发生了隐式类型转换,而临时变量是具有常性的所以我们加个const就解决了:

a3bfb0b2b17b44a5a4e2881c86937a1a.png


那么如果是父类和子类呢?我们试试:

29d9094f7a3f4e01bbb297a2bb6ae021.png


我们能看到父类的引用能直接引用子类并且没有报错说"非常量限定",这就说明子类到父类的没有隐式类型转换,这也就证明了我们刚刚说的子类赋值给父类是天然的,不存在类型转换。那么子类可以赋值给父类,能把父类赋值给子类吗?

7f911713f90b474cae28b0d6a0c52a72.png


我们可以看到是不能的,下面我们总结一下:

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


基类对象不能赋值给派生类对象。

基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(Run - Time Type Information)的dynamic_cast 来进行识别后进行安全转换。


2.继承中的作用域


1. 在继承体系中 基类和 派生类都有 独立的作用域。

2. 子类和父类中有同名成员, 子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以 使用 基类 :: 基类成员 显示访问)

3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。

4. 注意在实际中在 继承体系里面最好 不要定义同名的成员。


下面我们来看一下基类和派生类中的同名成员:

class Person
{
protected:
  string _name = "小李子"; // 姓名
  int _num = 111;          //身份证号
};
class Student : public Person
{
public:
  void Print()
  {
    cout << " 姓名:" << _name << endl;
    cout << " 身份证号:" << Person::_num << endl;
    cout << " 学号:" << _num << endl;
  }
protected:
  int _num = 999; // 学号
};
void Test()
{
  Student s1;
  s1.Print();
};
int main()
{
  Test();
  return 0;
}


我们可以看到person中有一个_num变量,student中也有一个同名的_num变量,在这种情况下我们如何知道要调用的是哪个变量呢?当我们想用父类的变量的时候我们需要在前面加上域作用限定符,子类的话直接用变量名即可,像上面代码这种情况就是父类的num和子类的num构成了隐藏。

cca263ede6c54c88854c3286d6d9a4d1.png


当我们在子类中将域作用限定符拿掉,会自动调用子类中的同名变量num。

子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。

我们接着往下看:

class A
{
public:
  void fun()
  {
    cout << "func()" << endl;
  }
};
class B : public A
{
public:
  void fun(int i)
  {
    A::fun();
    cout << "func(int i)->" << i << endl;
  }
};
int main()
{
  B b;
  b.fun(10);
  return 0;
}


A中的fun和B中的fun是什么关系呢?参数不同是函数重载吗?不是!因为重载是在同一个作用域,这里都不是一个作用域肯定不是函数重载了,那么是隐藏吗?答案是是的,因为成员函数只要函数名相同那就构成隐藏,隐藏默认调用本类的成员函数,想要调用父类的需要加域作用限定符。


3.派生类的默认成员

1.派生类的构造函数

class Person
{
public:
  Person(const char* name = "peter")
    : _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; // 姓名
};
class student :public Person
{
protected:
  int _num;   //学号
};
int main()
{
  student s;
  return 0;
}


我们看上面的代码,子类什么函数也没实现,现在我们创建一个子类对象,然后看看什么结果:

ade5f65d034240c4a0485f41bebd938a.png 我们发现我们创建子类的对象,竟然调用的是父类的构造函数和析构函数,刚刚我们没写子类的构造函数,现在我们写一个看是什么结果:


c4ea1e14f00444a798cddb9b8e1deb4d.png

我们发现居然报错非法的成员初始化,我们继承父类是有string _name的这是为什么呢?其实c++规定,在子类中初始化父类的成员要用父类的构造函数初始化,也就是说子类的归子类管,父类的归父类管,所以正确的构造函数应该是这样:

student(const char* name,int num)
    :Person(name)
    ,_num(num)
  {
    cout << "student(const char* name)" << endl;
  }


e03bc366f42b4b378c7462c73ed19559.png

上面是我们写了用父类的构造函数,如果我们不写会调用谁呢?:

5b7044f5ed034dccb8e6bfb4a5e29135.pngfd9a924204914447a299de118f6902de.png


我们在调试的时候发现,当我们没有显式调用父类的构造函数的时候编译器也会默认去初始化列表调用父类的构造函数,也就是说我们不写也可以完成任务。如果父类没有默认的构造该怎么办?那我们就必须显式的去调用了。


下面我们解释一下派生类的默认成员函数:


6个默认成员函数, “ 默认 ”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?


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

2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。

3. 派生类的operator=必须要调用基类的operator=完成基类的复制。

4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。

5. 派生类对象初始化先调用基类构造再调派生类构造。

6. 派生类对象析构清理先调用派生类析构再调基类的析构。

7. 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲解)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。


有了上面的知识我们再来实现一下子类的拷贝构造函数:

student(const student& s)
    :Person(s)
    ,_num(s._num)
  {
    cout << "student(const student& s)" << endl;
  }


84a82b60a35342f4b4193821f7639a15.png


与刚才的构造函数不同的是,我们不写父类的构造函数是不会调用父类的构造函数的:

student(const student& s)
    //:Person(s)
     :_num(s._num)
  {
    cout << "student(const student& s)" << endl;
  }

1ebe5d4af3f54d02ad95b4aeab13aa6b.png


所以:派生类的拷贝构造函数必须调用基类的拷贝构造函数完成基类的拷贝初始化。

下面我们再写一个赋值重载:

student& operator=(const student& s)
  {
    if (this != &s)
    {
      operator=(s);
      _num = s._num;
    }
    return *this;
  }


c8c4ee6ebcf740b5b4dca3a286e4643b.png

我们发现出错了,栈溢出了,这是为什么呢?其实回想我们刚刚讲的知识,成员函数只要函数名相同就构成隐藏,也就是说子类和父类的operator=构成隐藏了,我们默认调用的是我们自己的赋值重载,而我们本意是想调用父类的赋值重载,所以我们修改一下:

student& operator=(const student& s)
  {
    if (this != &s)
    {
      Person::operator=(s);
      _num = s._num;
    }
    return *this;
  }

955f0e5a1ccc4c608a864fc77434fbe7.png

这次我们发现成功赋值了,并且确实调用了父类的赋值。

下面我们实现一下析构函数,对于析构函数我们也像刚刚的思想一样:父类的东西让父类析构,然后子类再析构:

~student()
  {
    ~Person();
    cout << "~student()" << endl;
  }

然而当我们写出来却发现编译不过去:


2ae8354cf135409689364073345ff013.png


这是为什么呢?因为每个类的析构函数都会被编译器处理为destructor(这个单词就是析构的意思),也就是说父类和子类的析构函数名字是一样的,又构成隐藏了,刚刚默认调用我们子类自己的析构所以出错了,下面我们修改一下:

~student()
  {
    Person::~Person();
    cout << "~student()" << endl;
  }


290c696368c0467fb2dd19396ab5597c.png

并且我们只保留一个对象来观察,下面我们来看看运行结果吧

738aabfbed564dabbf9ee38e6f6c95b0.png


这里怎么先调用了父类的析构,又调用了子类的析构又调用了父类的析构,我们就一个对象怎么调用多了一次父类的析构呢?按理说只有一个父类一个子类才对,这是怎么回事呢?我们先检查一下哪里多调用了:

c20a2222fcf1485e96eef761ebd2cd74.png


我们发现在子类的析构中将父类的调用代码注释掉就没了那个多余的父类析构,这是什么原因呢?其实这是因为子类中析构函数不要显示的调用父类的析构,因为会自动调用父类的析构,为什么要这样做呢?因为要保证先后顺序,我们都知道,先声明的对象后析构,如下图:

1affa44ab6f14a87b66c06e74ac66ab2.png

所以要满足这样的规则我们就不能在析构函数中显式的调用父类的析构函数,因为如果我们显式调用那么就不能保证先构造的后析构的顺序了。所以:子类析构函数完成时,会自动调用父类的析构函数,保证先析构子再析构父。如下图:

930f81702daa40fa866822ef174ce226.png


对于为什么先析构子在析构父还有一个主要的原因,由于子类继承父类可能会比父类多出成员,一旦子类中有一个父类的指针,指针指向一段空间,一旦将父类析构了那么这个指针就变成野指针了,子类中用这个指针指向任意的成员都会报错,所以为了安全性而言也要先调用子类的析构再调用父类的析构。

目录
相关文章
|
3月前
|
算法 C语言 C++
C++语言学习指南:从新手到高手,一文带你领略系统编程的巅峰技艺!
【8月更文挑战第22天】C++由Bjarne Stroustrup于1985年创立,凭借卓越性能与灵活性,在系统编程、游戏开发等领域占据重要地位。它继承了C语言的高效性,并引入面向对象编程,使代码更模块化易管理。C++支持基本语法如变量声明与控制结构;通过`iostream`库实现输入输出;利用类与对象实现面向对象编程;提供模板增强代码复用性;具备异常处理机制确保程序健壮性;C++11引入现代化特性简化编程;标准模板库(STL)支持高效编程;多线程支持利用多核优势。虽然学习曲线陡峭,但掌握后可开启高性能编程大门。随着新标准如C++20的发展,C++持续演进,提供更多开发可能性。
74 0
|
13天前
|
编译器 C语言 C++
配置C++的学习环境
【10月更文挑战第18天】如果想要学习C++语言,那就需要配置必要的环境和相关的软件,才可以帮助自己更好的掌握语法知识。 一、本地环境设置 如果您想要设置 C++ 语言环境,您需要确保电脑上有以下两款可用的软件,文本编辑器和 C++ 编译器。 二、文本编辑器 通过编辑器创建的文件通常称为源文件,源文件包含程序源代码。 C++ 程序的源文件通常使用扩展名 .cpp、.cp 或 .c。 在开始编程之前,请确保您有一个文本编辑器,且有足够的经验来编写一个计算机程序,然后把它保存在一个文件中,编译并执行它。 Visual Studio Code:虽然它是一个通用的文本编辑器,但它有很多插
|
1月前
|
安全 程序员 编译器
【C++篇】继承之韵:解构编程奥义,领略面向对象的至高法则
【C++篇】继承之韵:解构编程奥义,领略面向对象的至高法则
77 11
|
1月前
|
C++
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
C++番外篇——对于继承中子类与父类对象同时定义其析构顺序的探究
51 1
|
1月前
|
C++
C++番外篇——虚拟继承解决数据冗余和二义性的原理
C++番外篇——虚拟继承解决数据冗余和二义性的原理
36 1
|
1月前
|
Java 编译器 C++
c++学习,和友元函数
本文讨论了C++中的友元函数、继承规则、运算符重载以及内存管理的重要性,并提到了指针在C++中的强大功能和使用时需要注意的问题。
17 1
|
27天前
|
安全 编译器 程序员
C++的忠实粉丝-继承的热情(1)
C++的忠实粉丝-继承的热情(1)
15 0
|
1月前
|
编译器 C++
C++入门11——详解C++继承(菱形继承与虚拟继承)-2
C++入门11——详解C++继承(菱形继承与虚拟继承)-2
27 0
|
1月前
|
程序员 C++
C++入门11——详解C++继承(菱形继承与虚拟继承)-1
C++入门11——详解C++继承(菱形继承与虚拟继承)-1
31 0
|
2月前
|
C++
C++(二十)继承
本文介绍了C++中的继承特性,包括公有、保护和私有继承,并解释了虚继承的作用。通过示例展示了派生类如何从基类继承属性和方法,并保持自身的独特性。此外,还详细说明了派生类构造函数的语法格式及构造顺序,提供了具体的代码示例帮助理解。