继承的概念及定义
在C++中,继承是一种面向对象编程的重要概念,它允许一个类(称为子类或派生类)从另一个类(称为父类、基类或超类)那里继承属性和行为。继承是实现代码重用、构建层次结构以及实现多态性的基础。
在C++中,继承通过以下方式定义:
class BaseClass { // 基类的成员和方法 }; class DerivedClass : public BaseClass { // 派生类的成员和方法 };
在上面的代码中,DerivedClass
继承了 BaseClass
。继承关系通过 public
、protected
或 private
关键字来定义。这些关键字决定了基类成员在派生类中的可访问性:
public
继承:基类的public
成员在派生类中保持为public
,基类的protected
成员在派生类中保持为protected
,基类的private
成员在派生类中不可访问。protected
继承:基类的public
和protected
成员在派生类中保持为protected
,基类的private
成员在派生类中不可访问。private
继承:基类的public
和protected
成员在派生类中变为private
,基类的private
成员在派生类中不可访问。
类成员/继承方式 | public继承 | protected继承 | private继承 |
基类的public成员 | 派生类的public成员 | 派生类的protected 成员 | 派生类的private 成员 |
基类的protected 成员 | 派生类的protected 成员 | 派生类的protected 成员 | 派生类的private 成员 |
基类的private成 员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可 见 |
总结:
- 基类
private
成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。 - 基类
private
成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected
。可以看出保护成员限定符是因继承才出现的。 - 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式
== Min
(成员在基类的访问限定符,继承方式),public
>protected
>private
。 - 使用关键字
class
时默认的继承方式是private
,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。 - 在实际运用中一般使用都是public继承,几乎很少使用
protetced/private
继承,也不提倡使用protetced/private
继承,因为protetced/private
继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强
通过继承,派生类获得了基类的属性和方法。派生类可以扩展基类的功能,添加新的成员和方法,也可以覆盖基类的虚函数以实现多态性。
以下是一个简单的示例:
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
和Teacher
对象,可以看到变量的复用。调用Print
可以看到成员函数的复用。
基类和派生类对象赋值转换
- 派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
- 基类对象不能赋值给派生类对象。
- 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用
RTTI(RunTime Type Information)
的dynamic_cast
来进行识别后进行安全转换。
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; }
在进行基类和派生类对象之间的赋值转换时,需要注意以下几个重要的问题:
- 切片问题: 将派生类对象赋值给基类对象时会发生切片,派生类特有的成员和属性会丢失。要确保赋值的对象类型是正确的,以避免意外的数据丢失。
- 虚函数和多态性: 如果在基类和派生类中使用了虚函数,并且派生类对这些虚函数进行了重写,赋值给基类对象或者通过基类指针/引用访问时,应该确保多态性仍然起作用,派生类的重写函数会被调用。
- 动态类型转换: 如果需要将基类指针转换为派生类指针,可以使用
dynamic_cast
进行类型转换。这会在运行时检查类型转换的合法性,并在转换失败时返回空指针。但是,这并不适用于不带虚函数的基类,也不适用于多重继承的情况。 - 对象生命周期: 赋值转换可能会涉及到对象的生命周期管理。如果派生类对象通过基类指针被赋值给其他对象或者在函数参数传递中,需要确保对象在使用完毕后不会导致悬空指针或内存泄漏。
- 继承关系和设计: 在使用继承时,应该考虑对象之间的逻辑关系和设计。基类和派生类之间的赋值转换应该符合对象在继承体系中的含义和用途。
总之,基类和派生类之间的赋值转换需要谨慎处理,确保代码的逻辑正确性和数据完整性。在进行类型转换时,始终要考虑继承关系、多态性、类型检查以及对象的生命周期管理。如果不确定如何进行赋值转换,可以借助类型转换操作符、dynamic_cast
或其他技术来确保安全和正确的转换。
继承中的作用域
- 在继承体系中基类和派生类都有独立的作用域。
- 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用
基类::基类成员
显示访问) - 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
- 注意在实际中在继承体系里面最好不要定义同名的成员。
// Student的_num和Person的_num构成隐藏关系,可以看出这样代码虽然能跑,但是非常容易混淆 class Person { protected : string _name = "zhangsan"; // 姓名 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(); };
当子类和父类中存在同名成员(包括成员函数和成员变量)时,在调用这些同名成员时需要注意以下几点:
- 成员函数重写(覆盖): 如果子类中的成员函数与父类中的成员函数同名且参数列表也相同(即重写/覆盖),那么在通过子类对象调用这个函数时,会调用子类的版本而不是父类的版本。这是实现多态性的一种方式。
- 隐藏成员函数: 如果子类中定义了与父类中的成员函数同名但参数列表不同的函数,那么父类的函数会被隐藏,除非使用作用域解析运算符
::
显式地指定调用父类的函数,如下面的代码所示。 - 成员变量: 如果子类和父类有同名的成员变量,子类对象中的同名变量会隐藏父类中的同名变量。但通过子类对象调用成员函数时,仍然可以访问父类的成员变量,除非在子类中定义了同名的成员变量,此时子类的成员变量会隐藏父类的,所以上面在子类中定义得
Print
函数中调用父类的同名成员变量时,则需要加上域,否则访问的就是子类本身的成员变量。
下面是一个简单的示例,展示了隐藏成员函数的情况:
// 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(10); };
派生类的默认成员函数
- 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
- 派生类的
operator=
必须要调用基类的operator=
完成基类的复制。 - 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
- 派生类对象初始化先调用基类构造再调派生类构造。
- 派生类对象析构清理先调用派生类析构再调基类的析构。
- 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同。那么编译器会对析构函数名进行特殊处理,处理成
destrutor()
,所以父类析构函数不加virtual
的情况下,子类析构函数和父类析构函数构成隐藏关系
#include <iostream> #include <string> using namespace std; 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 { public: Student(const char* name, 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);//调用基类的`operator=`完成基类的复制 _num = s._num; } return *this; } ~Student() //不用再调父类的析构,会自动调用的 { cout << "~Student()" << endl; } protected: int _num; // 学号 }; void Test() { Student s1("jack", 18); // 创建一个学生对象 s1 Student s2(s1); // 使用拷贝构造函数创建学生对象 s2 Student s3("rose", 17); // 创建另一个学生对象 s3 s1 = s3; // 使用赋值操作符将 s3 赋值给 s1 } int main() { Test(); return 0; }