【C++进阶:继承中】

简介: 派生类对象可以赋值给基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。这是在 public 继承的前提,因为如果是 public 继承,那么继承下来的成员的访问限定符是不变的;如果是 protected 或 private 继承,那么继承下来的成员的访问限定符可能会改变,进而导致不支持子类对父类赋值,因为可能存在类型转换。

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

#include<iostream>
using namespace std;

class Person
{
public:
    /*void f()
    {}*/
protected:
    string _name;
    string _sex;
    int _age;
};
//class Student : protected Person
class Student : public Person
{
public:
    int _No;
};

int main()
{
    Person p;
    Student s;

    p = s;//父类对象 = 子类对象
    Person* ptr = &s;//父类指针 = 子类指针
    Person& ref = s;//父类引用 = 子类

    //s = p;//子类对象 = 父类对象,err
            //子类指针 = 父类指针,ok,但是最好用dynamic_cast,因为这样才是安全的,这里的安全指的是它会去识别父类的指针,如果是指向父类,这个转换就失败,如果是指向子类,这个转换就成功。
            //引用同指针

    return 0;
}
  • 我们都知道同类型的对象赋值是可以的,那父子类呢 ❓

    首先我们得知道父子类为啥要支持赋值呢,究其原因是它们之间存在一些强关联的关系,子类几乎包含父类的成员。

  • 派生类对象可以赋值给基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。这是在 public 继承的前提,因为如果是 public 继承,那么继承下来的成员的访问限定符是不变的;如果是 protected 或 private 继承,那么继承下来的成员的访问限定符可能会改变,进而导致不支持子类对父类赋值,因为可能存在类型转换。

    ![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/508d584ab7764517bc2dfdb93cafbe97.png)
    
  • 基类对象不能赋值给派生类对象。
  • 但是基类的指针可以通过强制类型转换赋值给派生类的指针。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用 RTTI(Run-Time Type Information) 的 dynamic_cast 来进行识别后进行安全转换。(ps:这个后面会谈,这里先了解一下)。
  • 这就完了 ?当然不是,这里是需要和下面的派生类的默认成员函数一起理解。

二、继承中的作用域

#include<iostream>
#include<string>
using namespace std;

class Person
{
protected:
    string _name = "dancebit";
    int _num = 111;
};

class Student : public Person
{
public:
    void Print()
    {
        cout << "姓名: " << _name << endl;
        cout << _num << endl;
        cout << Person::_num << endl;
    }
protected:
    int _num = 999;
};

class A
{
public:
    void fun()
    {
        cout << "fun()" << endl;
    }
};

class B : public A
{
public:
    void fun(int i)
    {
        cout << "fun(int i)" << endl;
    }
};

void Test1()
{
    Student s;
    s.Print();
}

void Test2()
{
    B b;
    b.fun(10);
    //b.fun();
    b.A::fun();
}

int main()
{
    //Test1();
    Test2();

    return 0;
}
  • 在继承体系中基类和派生类都有独立的作用域,这意味着可以定义同名的成员,就像 STL 中 list 有 push_back,vector 也有 push_back。

    同时也就意味着现在有一个矛盾点是 Student 里有 2 个 _num,我们在访问 _num 时是访问基类的还是派生类的 ❓

      根据我们之前知道的全局变量和局部变量相同,局部优先的特性,我们可以猜测是这里是派生类优先。它们都有一个特性,那就是优先在自己的作用域查找。

  • Test1() 中,当派生类和基类有同名成员变量时,派生类成员变量会屏蔽基类成员变量,以此不能直接访问基类成员变量。这种情况叫做隐藏或重定义。如果想访问基类成员变量需要指定基类成员变量所在的作用域。
  • Test2() 中,当派生类和基类有同名成员函数 fun,但是参数不同时,它们之间存在什么关系 ❓

      首先 A 类的 fun 和 B 类的 fun 一定不构成函数重载,因为以前说过函数重载必须是在同一作用域,而我们刚说基类和派生类是不同的作用域。

      这里规定对于成员函数,构成隐藏的关系只需要函数名相同即可,而不用关心参数、返回值。所以这里 A 类的 fun 和 B 类的 fun 构成的就是隐藏,如果想访问基类中的 fun 需要指定其所在作用域。

  • 注意在实际中继承体系里面最好不要定义同名的成员变量和函数。这里其实也是 C++ 在设计时不好和复杂的地方,但是你也不能说如果同名就报错。就像北京有一个叫张三的,贵州也有一个叫张三的,这当然没有问题;但是同一个家庭不能大哥叫张三,二哥也叫张三,因为你要访问张三,你就要指定一个规则,如默认张三就是大哥、小张三就是二哥。C++ 中不能完全禁止同名的成员,因为一定会存在同名的隐藏关系,本章以及多态会碰到这样的场景。

三、派生类的默认成员函数

在这里插入图片描述

我们之前在类和对象部分中学习了 6 个默认成员函数,“ 默认 ” 的意思就是指我们不写,编译器会帮我们自动生成。那么在派生类中,这几个成员函数是怎么生成的呢。

✔ 测试用例一:

#include<iostream>
#include<string>
using namespace std;

class Person
{
public:
    //Person(const char* name = "dancebit")
    Person(const char* name)
        : _name(name)
    {
        cout << "Person(const char* name = \"dancebit\")" << endl;
    }
    Person(const Person& p)
        : _name(p._name)
    {
        cout << "Person(const Person& p)" << endl;
    }
    Person& operator=(const Person& p)
    {
        cout << "Person& operator=(cconst Person& p)" << endl;
        if (this != &p)
            _name = p._name;

        return *this;
    }
    ~Person()
    {
        cout << "~Person()" << endl;
    }
protected:
    string _name;
    int a;
};
class Student : public Person
{
public:
    Student(const char* name, int id, const char* address)//推荐
        //: _name(name)//err,父类继承下来是一个整体
        : Person(name)
        , _id(id)
        , _address(address)
    {}
    
    //Student(const char* name, int id, const char* address)//不推荐
    //    : _id(id)//初始化列表阶段会先调用父类的默认构造
    //    , _address(address)
    //{}
private:
    int _id;
    string _address;
};

int main()
{
    //Student s1;
    Student s2("DANCEBIT", 1, "China");

    return 0;
}
  • 对于子类的构造函数,我们不写,编译器会默认生成。它针对 a) 内置类型成员不处理,除非声明时给了缺省值; b) 自定义类型成员,调用它的默认构造函数; c) 继承的父类成员作为一个整体,调用父类的默认构造函数;
  • 父类里写了默认构造函数、子类里没写构造函数,我们只定义了 Studnet 的对象,没有定义 Person 的对象,但是这里却调用了 Person 的构造和析构,这里是子类里默认生成的构造函数调用的,同时也看到了这里设计时没有把继承下来的父类成员混淆到自己的内置和自定义类型成员中。这里继承下来的父类成员会作为一个整体调用它的默认构造函数;内置类型不处理 (除非声明时给了缺省值);自定义类型会调用它的默认构造函数。注意严格来说是先处理父类继承下来的,内置类型和自定义类型可以认为是平等的。

    在这里插入图片描述

    在这里插入图片描述

  • 如果父类没有默认构造函数,那么想对父类的成员进行初始化,使用子类默认生成的构造函数是不行的,因为子类默认生成的构造函数要去调用父类的默认构造函数,而父类没有默认构造函数,所以需要自己实现子类构造函数 (Student s1 + 子类全缺省默认构造函数 || Student s2("DANCEBIT", 1, "China") + 子类全缺省默认构造函数/构造函数)。要注意父类是作为一个整体,调用父类的构造函数初始化,对于构造函数我们自己实现是有价值的。
  • 如果父类使用默认生成的构造函数 (注意测试时需要将拷贝构造一起注释掉,因为拷贝构造也是构造),子类的构造函数不调用父类,当然也调用不了父类,它是在子类的初始化列表中调用的,可以看到父类的 _name 依然能初始化,因为 _name 是 string 类型的,它会去调用 string 的默认构造函数初始化。这里对于编译器默认生成的或无参的构造函数在子类就不能显示的初始化了,但是对于全缺省的依然可以显示的初始化。
  • 如何设计出一个不能被继承的类 ❓

    在这里插入图片描述

      构造函数设计成私有,就可以认为这个类不能被继承了,因为子类要初始化父类继承下来的成员一定要去调用父类的构造函数,而构造函数私有则意味着父类的构造函数在子类中不可见,这里就可以看到 private 还是有点使用的价值的,但也只是在 C++98 中,因为在 C++98 中如果想做到一个不能被继承的类,只能将构造函数私有,但是这样不定义子类对象和不调用父类的构造函数是不会报错的,注意可能是由于编译器检查严格的原因,就算不定义子类对象,在子类中显示的调用了父类的构造函数也会报错,所以你会发现 C++98 这种方式不够彻底和直观。

      在多态中,我们会介绍 C++11 中的关键字 final 用于替代 C++98 中的方式。

✔ 测试用例二:

#include<iostream>
#include<string>
using namespace std;

class Person
{
public:
    Person(const char* name = "dancebit")
        : _name(name)
    {
        cout << "Person(const char* name = \"dancebit\")" << endl;
    }
    Person(const Person& p)
        : _name(p._name)
    {
        cout << "Person(const Person& p)" << endl;
    }
    Person& operator=(const Person& p)
    {
        cout << "Person& operator=(cconst Person& p)" << endl;
        if (this != &p)
            _name = p._name;

        return *this;
    }
    ~Person()
    {
        cout << "~Person()" << endl;
    }
protected:
    string _name;
    int a;
};
class Student : public Person
{
public:
    Student(const char* name, int id, const char* address)
        : Person(name)
        , _id(id)
        , _address(address)
    {}
    Student(const Student& s)
        //: Person(s)//切片行为
        //, _id(s._id)
        //, _address(s._address)

        : _id(s._id)//不显示的调用父类的拷贝构造
        , _address(s._address)
    {}

private:
    int _id;
    string _address;
};

int main()
{
    Student s1("DANCEBIT", 1, "China");

    Student s2(s1);

    return 0;
}
  • 对于子类的拷贝构造,我们不写,编译器会默认生成。它针对 a) 内置类型成员完成值拷贝; b) 自定义类型成员,调用它的拷贝构造; c) 继承的父类成员作为一个整体,调用父类的拷贝构造;

    在这里插入图片描述

    在这里插入图片描述

  • 子类写了拷贝构造,子类就要显示的调用父类的拷贝构造,这里把子类对象里父类的那一部分取出来,本质就是切片行为 (这里把子类对象 s2 传给父类的引用,而父类仅仅使用了 _name)。

    但是实际上这里的拷贝构造没必要自己实现,因为这里一般情况下默认的拷贝构造就足够了,但是如果子类中有一个指针指向一块动态开辟的空间,存在深浅拷贝问题时就需要自己实现。

  • 如果子类中的拷贝构造不显示的调用父类的拷贝构造,那么便不会调用父类的拷贝构造,而是调用了默认的构造函数,因为拷贝构造也是构造,构造函数规定在初始化列表阶段,如果你不调用自定义类型,那就调用它的默认构造。

    ![在这里插入图片描述](https://ucc.alicdn.com/images/user-upload-01/3054bad8c53f44c987a32f23e33cef56.png)
    

✔ 测试用例三:

#include<iostream>
#include<string>
using namespace std;

class Person
{
public:
    Person(const char* name = "dancebit")
        : _name(name)
    {
        cout << "Person(const char* name = \"dancebit\")" << endl;
    }
    Person(const Person& p)
        : _name(p._name)
    {
        cout << "Person(const Person& p)" << endl;
    }
    Person& operator=(const Person& p)
    {
        cout << "Person& operator=(cconst Person& p)" << endl;
        if (this != &p)
            _name = p._name;

        return *this;
    }
    ~Person()
    {
        cout << "~Person()" << endl;
    }
protected:
    string _name;
    int a;
};

class Student : public Person
{
public:
    Student(const char* name, int id, const char* address)
        : Person(name)
        , _id(id)
        , _address(address)
    {}
    Student& operator=(const Student& s)
    {
        if (this != &s)
        {
            _id = s._id;
            _address = s._address;
            //operator=(s);//切片行为,err
            Person::operator=(s);//切片行为
        }
        return *this;
    }
private:
    int _id;
    string _address;
};

int main()
{
    Student s1("DANCEBIT", 1, "贵阳市");
    Student s2("DanceBit", 2, "北京市");
    s1 = s2;

    return 0;
}
  • 对于子类的赋值重载,我们不写,编译器会默认生成。我们都知道赋值重载和拷贝构造很类似,它针对 a) 内置类型成员完成赋值重载; b) 自定义类型成员,调用它的赋值重载; c) 继承的父类成员作为一个整体,调用父类的赋值重载;

    在这里插入图片描述

    在这里插入图片描述

&emsp;&emsp;对于子类的赋值重载,默认生成的也够用了。但涉及深浅拷贝问题时就需要自己写。

 
  • 如果显示的在子类中写拷贝赋值,父类部分只能使用切片,但是我们发现出现死循环最后导致栈溢出了,原因是父子类有同名的成员函数 operator=,构成隐藏关系,所以我们上面说 C++ 不敢设计成同名成员函数就报错,这里就可以看到场景了,所以这里的解决方法是指定父类的域。如果子类中的拷贝赋值不显示调用父类的拷贝赋值,便不会调用,此时赋值是不完整的。

    我们之前说 double d = 1.1,int i = d,它们是相近类型,完成隐式类型转换,而这里为什么子类对象给父类对象这样的一个切片行为是一种天然行为,不存在类型转换 ❓

      两者不能混淆的原因是,如果存在类型转换,其中会产生临时变量 (d ➡ 临时变量 ➡ i),也就意味着 int& i = d 是不行的,const int& i = d 是行的,因为 d 具有常性;而后者如果理解成存在隐式类型转换,Person& ref = s 那么 ref 引用的就是临时变量,但是这里也没有 const,所以我们期望的并不是引用临时变量,而是引用或指针指向的是子类对象当中切割出来的父类的那一部分,所以后者是一种天然行为。

✔ 测试用例四:

#include<iostream>
#include<string>
using namespace std;

class Person
{
public:
    Person(const char* name = "dancebit")
        : _name(name)
    {
        cout << "Person(const char* name = \"dancebit\")" << endl;
    }
    Person(const Person& p)
        : _name(p._name)
    {
        cout << "Person(const Person& p)" << endl;
    }
    Person& operator=(const Person& p)
    {
        cout << "Person& operator=(cconst Person& p)" << endl;
        if (this != &p)
            _name = p._name;

        return *this;
    }
    ~Person()
    {
        cout << "~Person()" << endl;
    }
protected:
    string _name;
    int a;
};

class Student : public Person
{
public:
    Student(const char* name, int id, const char* address)//推荐
        //: _name(name)//err,父类继承下来是一个整体
        : Person(name)
        , _id(id)
        , _address(address)
    {}
    ~Student()
    {
        //~Person();//err
        //Person::~Person();//析构+1

        //清理自己的资源
        //...
    }
private:
    int _id;
    string _address;
};

int main()
{
    Student s1("DANCEBIT", 1, "贵阳市");

    Student s2(s1);

    Student s3("DanceBit", 2, "北京市");
    s2 = s3;

    return 0;
}
  • 对于析构函数,它和构造函数类似,我们不写,编译器会默认生成,它针对 a) 内置类型不处理;b) 自定义类型调用它的析构函数;c) 父类作为一个整体调用父类的析构函数;

    在这里插入图片描述

  • 如果显示的写, 发现,~Person(); 会报错,这里很多书上都没解释清楚,书上说:子类的析构函数和父类的析构函数构成隐藏关系,所以要指定域。但其实你会发现与上面说的父子类的函数名相同就构造隐藏关系矛盾,这里父子类的析构函数名并不相同。其实本质是因为编译器会对析构函数名做特殊处理,所有类的析构函数名都会被处理成统一名字 destructor(),所以父子类都是 destructor() 就构成隐藏关系。所以说为什么有很多人说 C++ 难学,如果没有一本好书或学习的路径不对 (就像 Primer 如果按着顺序学下去,很容易从入门到放弃)。

    为什么编译器要把所有类的析构函数名统一成 destructor ❓

      因为析构函数要构成多态重写,重写是多态的一个条件,它要求函数名相同, 若不构成重写的话,在某些情况下会出现资源泄漏的问题,具体细节在多态在谈。

    这里就算指定域了,我们发现本应该析构 3 次的,却析构了 6 次 ❓

    在这里插入图片描述

  我们这里只有 3 个对象,但是却析构了 6 次,因为 Person::~Person(); 所以每一个对象里调用了两次析构,所以注释掉它即可。

  • 调试发现子类的析构函数在执行结束后,会再自动调用父类的析构函数 ❓

    C++ 中的对象要保证先定义的后析构,可以想象一下父子对象在栈中的结构,所以这里是先构造父对象,再构造子对象,先析构子对象,再析构父对象。如果显示的写,并显示的调用,就有可能不符合这种特性,所以干脆这里规范不要自己显示调用了。注意如果有对为什么先构造父对象有疑问,可以理解为,初始化列表中出现的先后顺序不重要,重要的是声明的顺序,你可以认为编译器在界定时是认为父类的声明顺序是在最前面的。

    在这里插入图片描述

💨小结:

  • 无论是构造、拷贝构造、赋值,它们里面父类的那部分类似于自定义类型成员,你要去初始化、拷贝、赋值操作时,你都要调用父类对应的函数去完成。对于调用方:因为有初始化列表的存在,所以构造函数里可以不用显示的调用父类;拷贝构造不显示调用父类的话,那么完成的就是构造;赋值不显示调用父类的话,那么便不会赋值;析构不显示调用是规范。
  • 取地址重载就没必要考虑的那么复杂了,它不需要调用父类的,而是取自己的,最后返回。

四、继承与友元

✔ 测试用例一:

#include<iostream>
#include<string>
using namespace std;

class Student;
class Person
{
public:
    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 _stuNum; 
};
void Display(const Person& p, const Student& s) {
    cout << p._name << endl;
    cout << s._stuNum << endl;
}
void main()
{
    Person p;
    Student s;
    Display(p, s);
}
  • 友元关系不能被继承,也就是说基类友元不能访问子类私有和保护成员。解决方法就是在派生类中写友元。

五、继承与静态成员

#include<iostream>
#include<string>
using namespace std;
class Person
{
public:
    Person() { ++_count; }
protected:
    string _name; 
public:
    static int _count; 
};
int Person::_count = 0;
class Student : public Person
{
protected:
    int _stuNum; 
};
class Graduate : public Student
{
protected:
    string _seminarCourse; 
};
int main()
{
    Student s1;
    Student s2;
    Student s3;
    Graduate s4;
    
    cout << "人数:" << Person::_count << endl;
    
    Student::_count = 0;
    cout << "人数:" << Person::_count << endl;

    cout << "人数:" << Student::_count << endl;
    cout << "人数:" << Graduate::_count << endl;
    cout << "人数:" << s1._count << endl;
    cout << "人数:" << s4._count << endl;

    return 0;
}
  • 基类定义了 static 静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个 static 成员实例 。
  • 基类的静态成员是属于整个继承体系的类,属于这些类的所有对象。
相关文章
|
6天前
|
安全 前端开发 Java
【C++】从零开始认识继承二)
在我们日常的编程中,继承的应用场景有很多。它可以帮助我们节省大量的时间和精力,避免重复造轮子的尴尬。同时,它也让我们的代码更加模块化,易于维护和扩展。可以说,继承技术是C++的灵魂。
14 1
|
6天前
|
安全 程序员 编译器
【C++】从零开始认识继承(一)
在我们日常的编程中,继承的应用场景有很多。它可以帮助我们节省大量的时间和精力,避免重复造轮子的尴尬。同时,它也让我们的代码更加模块化,易于维护和扩展。可以说,继承技术是C++的灵魂。
24 3
【C++】从零开始认识继承(一)
|
6天前
|
存储 编译器 C++
C++中的继承
C++中的继承
12 0
|
6天前
|
设计模式 算法 编译器
【C++入门到精通】特殊类的设计 |只能在堆 ( 栈 ) 上创建对象的类 |禁止拷贝和继承的类 [ C++入门 ]
【C++入门到精通】特殊类的设计 |只能在堆 ( 栈 ) 上创建对象的类 |禁止拷贝和继承的类 [ C++入门 ]
13 0
|
6天前
|
安全 程序员 编译器
【C++】继承(定义、菱形继承、虚拟继承)
【C++】继承(定义、菱形继承、虚拟继承)
15 1
|
6天前
|
安全 编译器 程序员
[C++基础]-继承
[C++基础]-继承
|
6天前
|
C++ 芯片
【期末不挂科-C++考前速过系列P4】大二C++实验作业-继承和派生(3道代码题)【解析,注释】
【期末不挂科-C++考前速过系列P4】大二C++实验作业-继承和派生(3道代码题)【解析,注释】
|
6天前
|
编译器 C++
【C++进阶】引用 & 函数提高
【C++进阶】引用 & 函数提高
|
6天前
|
安全 Java 程序员
【C++笔记】从零开始认识继承
在编程中,继承是C++的核心特性,它允许类复用和扩展已有功能。继承自一个基类的派生类可以拥有基类的属性和方法,同时添加自己的特性。继承的起源是为了解决代码重复,提高模块化和可维护性。继承关系中的类形成层次结构,基类定义共性,派生类则根据需求添加特有功能。在继承时,需要注意成员函数的隐藏、作用域以及默认成员函数(的处理。此外,继承不支持友元关系的继承,静态成员在整个继承体系中是唯一的。虽然多继承和菱形继承可以提供复杂的设计,但它们可能导致二义性、数据冗余和性能问题,因此在实际编程中应谨慎使用。
18 1
【C++笔记】从零开始认识继承
|
6天前
|
设计模式 编译器 数据安全/隐私保护
C++ 多级继承与多重继承:代码组织与灵活性的平衡
C++的多级和多重继承允许类从多个基类继承,促进代码重用和组织。优点包括代码效率和灵活性,但复杂性、菱形继承问题(导致命名冲突和歧义)以及对基类修改的脆弱性是潜在缺点。建议使用接口继承或组合来避免菱形继承。访问控制规则遵循公有、私有和受保护继承的原则。在使用这些继承形式时,需谨慎权衡优缺点。
25 1