【C++杂货铺】继承由浅入深详细总结(上)

简介: 【C++杂货铺】继承由浅入深详细总结

一、继承的概念及定义

1.1 继承的概念

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

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;//学号
};
class Teacher : public Person
{
protected:
  int _jobid;//工号
};
int main()
{
  Student s;
  Teacher t;
  s.Print();
  t.Print();
  return 0;
}

继承后父类 Person 的成员(成员函数 + 成员变量)都会变成子类的一部分。这里体现出了 Student 和 Teacher 复用了 Person 的成员。Student 除了继承了父类的成员外,它还有一个自己特有的属性 _stuid,表示学生的学号;Teacher 除了继承了父类的成员外,它也有一个自己特有的属性 _jobid,表示老师的工号。

1.2 继承定义

1.2.1 定义格式

继承的定义格式如下图所示,其中 Person 是父类,也称作基类。Student 是子类,也称作派生类。

1.2.2 继承方式和访问限定符

1.2.3 继承基类成员访问方式的变化

类成员/继承方式 public 继承 protected 继承 private 继承
基类的 public 成员 派生类的 public 成员 派生类的 protected 成员 派生类的 private 成员
基类的 protected 成员 派生类的 protected 成员 派生类的 protected 成员 派生类的 private 成员
基类的 private 成员 在派生类中不可见 在派生类中不可见 在派生类中不可见

总结

  • 基类 private 成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象,不管在类里面还是类外面都不能去访问它。
  • 基类的 private 成员在派生类中是不能被访问的,如果基类成员不想在类外面直接被访问,但是需要在派生类中能访问,就定义为 protected。可以看出保护成员限定符是因为继承才出现的。
  • 对上面的表格进行总结会发现,基类的私有成员在子类中都是不可见的。基类的其他成员在子类中的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。
  • 使用关键字 class 时默认的继承方式是 private,使用 struct 时默认的继承方式是 public,不过最好显示的写出继承方式。
  • 在实际运用中一般使用都是 public 继承,几乎很少使用 protected 和 private 继承,也不提倡使用 protected 和 private 继承,因为 protected 和 private 继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。

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

  • 派生类对象可以赋值给基类的对象基类的指针基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
  • 基类对象不能赋值给派生类对象。
  • 基类指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用 RTTI (Run - Time Type Information)的 dynamic cast 来进行识别后进行安全转换。

小Tips:这种将一个子类对象赋值给父类对象也叫做向上转换。将一个父类对象赋值给子类对象也叫做向下转换,是不被允许的。将一个子类对象赋值给父类对象,这种类型转换是不会产生中间的临时变量的。

class Person
{
protected:
    string _name; // 姓名
    string _sex;  // 性别
    int _age; // 年龄
};
class Student : public Person
{
public:
    int _No; // 学号
};
void Test()
{
    Student sobj;
    // 1.子类对象可以赋值给父类对象/指针/引用
    Person pobj = sobj;
    Person* pp = &sobj;
    Person& rp = sobj;
  //2.基类对象不能赋值给派生类对象
    //sobj = pobj;
}

小Tips:rp 此时就是子类对象中那部分父类成员的别名,并没有产生中间的临时变量。同理 pp 也指向子类对象 sobj,但是指针的类型决定了它能访问到的成员变量,因为 pp 是一个父类指针,因此 pp 就只能访问到子类对象中父类那部分成员变量。

小Tips:可以通过 rp 去修改其指向的子类对象中父类的那部分成员变量。

class Person
{
protected:
    string _name; // 姓名
    string _sex;  // 性别
    int _age; // 年龄
};
class Student : public Person
{
public:
    int _No; // 学号
};
void Test()
{
    Student sobj;
    // 1.子类对象可以赋值给父类对象/指针/引用
    Person pobj = sobj;
    Person* pp = &sobj;
    Person& rp = sobj;
  //2.基类对象不能赋值给派生类对象
    //sobj = pobj;
    //3.基类的指针可以通过强制类型转换赋值给派生类的指针
    pp = &sobj;
    Student * ps1 = (Student*)pp; // 这种情况转换时可以的。
    ps1->_No = 10;
    pp = &pobj;
    Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问题
    ps2->_No = 10;
}

小Tipsps2->_No = 10; 会造成越界访问,而 ps1->_No = 10; 不会造成越界访问,原因在于 pp 指针的初始指向,以及指针的类型决定了该指针能访问到的内存空间。以上面的为例,pp 指针最初指向的是一个子类对象 sobj,并且 pp 指针是一个父类指针,这就决定了 pp 指针只能访问到子类对象中继承自父类的那部分成员变量,接着将 pp 指针进行强制类型转换,将它赋值给一个子类指针 ps1,此时 ps1 还是指向子类对象 sobj,但是 ps1 的类型却变成了 Student,这就决定了 ps1 可以访问到 sobj 中的所有成员变量(继承自父类的和子类特有的);而第二次类型转换,pp 指针作为一个父类指针,最初指向一个父类对象 pobj 这是没有任何问题的,接下来将 pp 指针进行强制类型转换,赋值给一个子类指针 ps2 ,此时 ps2 存的还是父类对象 pobj 的地址,指向 pobj,但它的类型是子类 Student,类型决定了它的访问范围,按说它可以访问到一个子类对象中的所有成员,但是它指向一个父类对象,该父类对象中就没有子类中的成员变量 _No,虽然它的类型决定了它可以访问到该成员变量,但是父类对象中没有,最终就会导致越界访问的问题。

三、继承中的作用域

  • 在继承体系中基类和派生类都有独立的作用域。
  • 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫做隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)
  • 需要注意的是,如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
  • 注意在实际继承里面,最好不要定义同名的成员。

小Tips:隐藏实际上是符合就近原则的,即对于一个变量,编译器默认先在当前成员函数的局部域去搜索,没找到接下来去当前成员函数所在的类域搜索。

// Student的_num和Person的_num构成隐藏关系,可以看出这样代码虽然能跑,但是非常容易混淆
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;//没有指定,先在局部域中找,局部域中没有 _num,接下来去当前的类域中找
    }
protected:
    int _num = 999; // 学号
};

// B中的fun和A中的fun不是构成重载,因为不是在同一作用域
// B中的fun和A中的fun构成隐藏,成员函数满足函数名相同就构成隐藏。
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;
    }
};
void Test()
{
    B b;
    //b.fun()//这样是不行的
    b.A::func()//
    b.fun(10);
};

小Tips:函数重载的前提是同一个作用域。重载底层使用了函数名修饰规则,在同一个作用域的同名函数如果不使用函数名修饰规则,编译器就无法区分这两个同名函数,而对于不同作用域的两个同名函数,编译器就直接根据域的查找规则就能进行区分。总结:父子类域中只要函数名相同就构成隐藏。如上面的代码所示,b.fun() 是不被允许的,编译器看到子类对象调用 fun 函数,会现在子类中进行查找,找到了但是发现少传一个参数,编译器会报错。一个子类对象如果想去调用父类中被隐藏(重定义)的函数,可以通过指定类域的方式去调用

四、派生类中的默认成员函数

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

  • 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
  • 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
  • 派生类的 operator= 必须要调用基类的 operator= 完成基类的复制。
  • 派生类的析构函数会在被调用完成后,自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
  • 派生类对象初始化先调用基类构造再调用派生类构造。
  • 派生类对象析构清理先调用派生类析构再调用基类的析构。
  • 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同。那么编译器会对析构函数名进行特殊处理,处理成 destrutor(),所以父类函数不加 virtual 的情况下,子类析构函数和父类析构函数构成隐藏关系。

4.1 默认构造函数

//默认构造函数
class Person
{
public:
    Person(const char* name = "Peter", const char* sex = "男")
        :_name(name)
        ,_sex(sex)
    {
        cout << "Person()" << endl;
    }
protected:
    string _name;//姓名
    string _sex;
};
class Student : public Person
{
public:
    Student(const char* name = "张三", const char* sex = "男", int num = 0)
        :_num(num)
        ,Person(name, sex)
        //, _name(name)//这样写是错的
    {
        cout << "Student()" << endl;
    }
protected:
    int _num;//学号
};
void Test()
{
    Student s;
}

小Tips:不能在派生类构造函数的初始化列表中去初始化某一个单独的基类成员变量,即 _name(name) 是不允许的,只能进行整体初始化,像 Person(name, sex) 这样。 其次会先去调用基类的构造函数,说明继承自基类的成员变量一定是声明在派生类成员变量的前面的。

4.2 拷贝构造函数

//拷贝构造函数
class Person
{
public:
    //默认构造函数
    Person(const char* name = "Peter", const char* sex = "男")
        :_name(name)
        ,_sex(sex)
    {
        cout << "Person()" << endl;
    }
    //拷贝构造函数
    Person(const Person& p)
        :_name(p._name)
        ,_sex(p._sex)
    {
        cout << "Person(const Person& p)" << endl;
    }
protected:
    string _name;//姓名
    string _sex;//性别
};
class Student : public Person
{
public:
    //默认构造函数
    Student(const char* name = "张三", const char* sex = "男", int num = 0)
        :_num(num)
        ,Person(name, sex)
        //, _name(name)
    {
        cout << "Student()" << endl;
    }
    //拷贝构造函数
    Student(const Student& s)
        :Person(s)
        , _num(s._num)
    {
        cout << "Student(const Student& s)" << endl;
    }
protected:
    int _num;//学号
};
void Test()
{
    Student s("张伟", "男", 213);//调用默认构造函数
    Student s1(s);//调用拷贝构造函数
    Student s2;
    s2 = s1;//调用赋值运算符重载
    //Person p = s1;
}

小Tips:派生类的拷贝构造函数也必须调用基类的构造函数,像这样 Person(s),这里用到了我们上面提到的一个知识点,即一个派生类对象可以赋值给一个基类的引用,该引用是派生类对象中基类那部分成员变量的别名。这里如果不写 Person(s),虽然这里是拷贝构造函数,但是编译器默认会去调用基类的默认构造函数,并不会去调用基类的拷贝构造函数。

4.3 赋值运算符重载函数

//赋值运算符重载
class Person
{
public:
    //默认构造函数
    Person(const char* name = "Peter", const char* sex = "男")
        :_name(name)
        ,_sex(sex)
    {
        cout << "Person()" << endl;
    }
    //拷贝构造函数
    Person(const Person& p)
        :_name(p._name)
        ,_sex(p._sex)
    {
        cout << "Person(const Person& p)" << endl;
    }
    //赋值运算符重载
    Person& operator=(const Person& p)
    {
        if (this != &p)
        {
            cout << "Person& operator=(const Person& p)" << endl;
            _name = p._name;
            _sex = p._sex;
        }
        return *this;
    }
protected:
    string _name;//姓名
    string _sex;//性别
};
class Student : public Person
{
public:
    //默认构造函数
    Student(const char* name = "张三", const char* sex = "男", int num = 0)
        :_num(num)
        ,Person(name, sex)
        //, _name(name)
    {
        cout << "Student()" << endl;
    }
    //拷贝构造函数
    Student(const Student& s)
        :Person(s)
        , _num(s._num)
    {
        cout << "Student(const Student& s)" << endl;
    }
    //赋值运算符重载
    Student& operator=(const Student& s)//与父类的赋值运算符重载构成隐藏
    {
        if (this != &s)
        {
            cout << "Student& operator=(const Student& s)" << endl;
            Person::operator=(s);
            _num = s._num;
        }
        return *this;
    }
protected:
    int _num;//学号
};
void Test()
{
    Student s("张伟", "男", 213);//调用默认构造函数
    Student s1(s);//调用拷贝构造函数
    Student s2;
    s2 = s1;//调用赋值运算符重载
}

小Tips:对于赋值运算符重载需要注意,因为赋值运算符重载的函数名都是 operator=,因此父类的赋值运算符重载函数和子类的赋值运算符重载函数构成隐藏(重定义)关系。所以在子类的赋值运算符重载函数中要想调用父类的赋值运算符重载函数需要指定类域,像这样 Person::operator=(s);,告诉编译器这里调用的是父类中的赋值运算符重载函数,如果不指定类域,编译器默认会去调用子类自己的赋值运算符重载,这就会产生无穷递归,导致栈溢出。

4.4 析构函数

class Person
{
public:
    //默认构造函数
    Person(const char* name = "Peter", const char* sex = "男")
        :_name(name)
        ,_sex(sex)
    {
        cout << "Person()" << endl;
    }
    //拷贝构造函数
    Person(const Person& p)
        :_name(p._name)
        ,_sex(p._sex)
    {
        cout << "Person(const Person& p)" << endl;
    }
    //赋值运算符重载
    Person& operator=(const Person& p)
    {
        if (this != &p)
        {
            cout << "Person& operator=(const Person& p)" << endl;
            _name = p._name;
            _sex = p._sex;
        }
        return *this;
    }
    //析构函数
    ~Person()
    {
        cout << "~Person()" << endl;
    }
protected:
    string _name;//姓名
    string _sex;//性别
};
class Student : public Person
{
public:
    //默认构造函数
    Student(const char* name = "张三", const char* sex = "男", int num = 0)
        :_num(num)
        ,Person(name, sex)
        //, _name(name)
    {
        cout << "Student()" << endl;
    }
    //拷贝构造函数
    Student(const Student& s)
        :Person(s)
        , _num(s._num)
    {
        cout << "Student(const Student& s)" << endl;
    }
    //赋值运算符重载
    Student& operator=(const Student& s)//与父类的赋值运算符重载构成隐藏
    {
        if (this != &s)
        {
            cout << "Student& operator=(const Student& s)" << endl;
            Person::operator=(s);
            _num = s._num;
        }
        return *this;
    }
    //析构函数
    ~Student()
    {
        cout << "~Student()" << endl;
        //Person::~Person();//显示调用会导致析构两次的问题
    }
protected:
    int _num;//学号
};
void Test()
{
    Student s("张伟", "男", 213);//调用默认构造函数
    Student s1(s);//调用拷贝构造函数
    Student s2;
    s2 = s1;//调用赋值运算符重载
    Person p = s1;//直接调用父类的拷贝构造函数
}

小Tips:对于析构函数来说,为了保证析构的顺序(对于一个子类对象来说,它里面的父类部分先调用构造,子类部分后调构造,从栈帧的创建顺序来说,后构造的要先析构,因此需要先去析构清理子类的资源,再去调用父类的析构函数,清理子类对象中父类中的那部分资源),编译器会自动去调用父类的析构函数,因此无需我们自己显示去调用父类的析构函数。其次,由于后面多态的原因,析构函数的函数名被特殊处理了,统一处理成 destructor,因此父类的析构函数与子类的析构函数本质上构成隐藏(重定义)关系,如果想要在子类的析构函数中显示调用父类的析构函数,需要指定类域,和赋值运算符重载函数一样。但是注意!注意!根本不需要我们自己在子类的析构函数中去显示调用父类的析构函数,即使我们显式调用了,编译器还是会去自动调用父类的析构函数,这就会导致子类中父类的那部分资源被释放了两次,这就会产生问题。先析构子类特有的成员变量还有一个原因,即子类的析构函数中可以使用父类中的成员变量,如果先调用父类的析构函数,那么在子类的析构函数中就无法再使用父类中的成员变量。而在父类的析构函数中是不可能调用子类的成员变量,因此先调用子类的析构函数是没有任何问题的。

五、继承与友元

友元关系不能继承,也就是说基类中声明的友元函数不能访问子类中的私有和保护成员。

//友元关系不能继承
class Student;//先声明
class Person
{
public:
    friend void Display(const Person& p, const Student& s);
protected:
    string _name = "Peter"; // 姓名
};
class Student : public Person
{
protected:
    int _stuNum = 1111; // 学号
};
void Display(const Person& p, const Student& s)
{
    cout << p._name << endl;
    //cout << s._stuNum << endl;//并不是子类的友元,因此不能在函数中访问子类的成员变量
    cout << s._name << endl;
}
void Test()
{
    Person p;
    Student s;
    Display(p, s);
}

小Tips:如上面的代码所示,Display 函数仅仅是父类 Person 的友元函数,并不是子类 Student 的友元函数,因此在 Display 函数中只能调用到父类中的成员变量,并不能调用子类中的成员变量,即在 Display 函数中 s._stuNum 是不被允许的。也可以调用一个子类对象中父类的那部分成员变量。

六、继承与静态成员变量

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

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; // 研究科目
};
void Test()
{
    Student s1;
    Student s2;
    Student s3;
    Graduate s4;
    cout << "人数 :" << Person::_count << endl;
    Student::_count = 0;
    cout << "人数 :" << Person::_count << endl;
}

小Tips:静态成员属于父类和派生类(他俩共享),在派生类中不会单独拷贝一份,子类继承的是静态成员的使用权。上面这段代码用一个静态成员变量 _count 来统计创建出来的父类对象和子类对象的个数,只需要在父类的构造函数中执行 ++_count 即可,因为创建子类对象的过程中会去调用父类的构造函数。

七、复杂的菱形继承及菱形虚拟继承

单继承:一个子类只有一个直接父类时称这个继承关系为单继承。

多继承:一个子类有两个或两个以上的直接父类时称这个继承关系为多继承。

菱形继承:菱形继承是多继承的一种特殊情况。


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