文章目录
须知
💬 欢迎讨论:如果你在学习过程中有任何问题或想法,欢迎在评论区留言,我们一起交流学习。你的支持是我继续创作的动力!
👍 点赞、收藏与分享:觉得这篇文章对你有帮助吗?别忘了点赞、收藏并分享给更多的小伙伴哦!你们的支持是我不断进步的动力!
🚀 分享给更多人:如果你觉得这篇文章对你有帮助,欢迎分享给更多对C++感兴趣的朋友,让我们一起进步!
《深入剖析C++继承:从基础到进阶的完整指南》
1. C++继承前言与背景
1.1 前言
C++是一个功能强大的面向对象编程语言,它不仅支持过程式编程,还在此基础上提供了许多用于构建复杂软件系统的面向对象特性。继承是C++中最为核心的概念之一,它允许我们通过现有的类(基类)创建新的类(派生类),从而实现代码的重用和扩展。继承是面向对象编程的三个基本特性之一(另外两个是封装和多态),在设计模式、软件架构和大型系统开发中起着至关重要的作用。
理解和应用C++继承的概念对于编写高效、可维护和可扩展的代码至关重要。C++的继承不仅仅是一个简单的“类与类之间的关系”,它涉及到如何组织和管理对象之间的共享数据、方法以及如何利用多态实现代码的灵活性。因此,C++继承的深入理解对程序员来说是必须的,它能够帮助开发者更好地设计类的层次结构,提升软件系统的复用性和扩展性。
1.2 背景
继承源于面向对象编程的基本思想,即通过创建类的层次结构,模拟现实世界中的事物关系。在现实世界中,物体之间往往存在父子关系、包含关系、继承关系等。C++的继承机制正是通过类与类之间的继承关系来模拟这些现实中的关系。继承使得开发者能够从一个基类派生出多个派生类,从而共享基类的行为,并在需要时对其进行扩展或修改。
在C++中,继承是通过class
关键字和访问修饰符(如public
、protected
、private
)来实现的,基类(父类)提供了一些公有和保护成员,派生类(子类)可以继承这些成员。继承还允许派生类重写基类的方法(方法重写),并能够通过虚函数实现运行时多态性,这是C++继承特性的重要组成部分。
C++继承的强大之处在于它不仅仅支持单一继承,还支持多继承,这使得它可以适应更复杂的类关系。通过使用虚拟继承,C++避免了传统多继承中可能出现的“钻石继承”问题。此外,C++继承支持访问控制(如public
、protected
和private
继承),从而为开发者提供了灵活的类设计和组织结构的能力。
然而,C++继承的设计和使用也存在一些挑战,特别是在多继承和虚继承的场景下。理解如何合理使用继承关系,避免继承层次过深,避免继承滥用,是程序员需要掌握的关键技能。
C++继承的关键要点:
- 代码重用:继承使得子类能够复用父类的属性和方法,减少重复代码。
- 扩展性:通过继承,子类可以扩展或修改父类的行为,从而实现系统的扩展。
- 多态性:继承和虚函数的结合使得C++能够实现运行时多态,从而使代码更加灵活和动态。
- 多继承与虚继承:C++支持多继承和虚继承,这为开发者提供了强大的功能,但也增加了代码设计的复杂度。
- 访问控制:C++提供了不同的继承访问权限(
public
、protected
、private
),允许开发者控制基类成员的访问权限,确保程序设计的封装性和安全性。
2.引言:C++继承的核心意义
继承是面向对象编程的一个关键特性,它能够使得代码更加简洁、可扩展和易维护。在C++中,继承不仅仅是类之间的关系,更是构建复杂系统的基石。通过继承,我们可以在一个类中共享另一个类的功能,而不需要重复编写相同的代码。
在这篇博客中,我们将深入探讨C++中的继承,包括其基础概念、应用场景、常见问题以及一些进阶技巧。通过示例和图示,您将能够更好地理解继承的各个方面,并能够在项目中有效运用。
3.继承基本概念与定义
3.1 什么是C++继承——从基本概念开始
3.1.1 示例代码:
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
#include<string>
using namespace std;
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;
}
输出:
name:peter
age:18
name:peter
age:18
在上面的代码中,Student类继承了Person类,因此可以访问Person类中的Print()方法。
3.2 继承的定义
继承在 C++ 中的定义主要通过以下格式实现:
class 子类名 : 继承方式 基类名 {
// 子类的成员
};
继承语法:
- 使用
public
、private
或protected
访问权限来决定继承的可访问性。public
继承:基类的公有成员在派生类中仍然是公有的。protected
继承:基类的公有成员在派生类中变为受保护的。private
继承:基类的公有成员在派生类中变为私有的。
示例代码:
class Teacher : public Person {
protected:int _jobid; // 工号
};int main() {Student s;Teacher t;s.Print();t.Print();return 0;
}
派生类Student和Teacher都继承基类(父类)Person类的成员方法函数Print(),通过s.Print()和t.Print()输出 Student
和 Teacher
对象的姓名和年龄。
4. 继承中的访问权限
4.1 基类成员在派生类中的访问权限
基类的 public
、protected
和 private
成员在派生类中的访问权限取决于继承方式。下面是不同继承方式下的访问权限表:
从表中可以看出基类的private成员在派生类(子类)始终不可见,而基类的public成员和protected成员的是否能被访问取决于本身成员的访问权限与继承方式,两个取继承方式最坎坷的一个。
注意:1-> 如果需要基类的某个成员在派生类中可访问但不希望类外部访问,则可以将其设置为 protected
,这样可以更好地控制访问权限。
4.2 基类和派生类对象赋值转换
在C++中,基类和派生类对象的赋值转换是一个比较常见的操作场景。通常情况下,派生类对象可以赋值给基类对象,或者通过基类的指针或引用来操作派生类对象。这种转换机制使得C++在继承结构中实现了多态和代码复用。但需要注意的是,基类对象不能直接赋值给派生类对象。
4.2.1 派生类对象赋值给基类对象
派生类对象包含了基类的成员,因此派生类对象赋值给基类对象时将属于基类对象的那一部分赋值给基类对象。这里有个形象的说法叫切片(切割)。寓意把派生类中父类那部分切来赋值过去。
示例代码:
#include<iostream>
#include<string>
using namespace std;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;//error// 3.基类的指针可以通过强制类型转换赋值给派生类的指针pp = &sobj;Student * ps1 = (Student*)pp; // 这种情况转换时可以的。ps1->_No = 10;pp = &pobj;Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问题ps2->_No = 10;
}
4.2.2 基类指针与引用转换
派生类对象可以赋值给基类的指针或引用,这是实现多态的重要前提条件。通过基类指针或引用,程序可以在运行时动态绑定到派生类的成员函数。这种方式允许我们在不需要修改代码的情况下扩展程序的功能。
示例代码:
#include<iostream>
#include<string>
using namespace std;class Person
{
public:virtual void Print()//具有二义性,所以添加了Virtual{cout << "Person: " << _name << endl;}
protected:string _name="Jack";
};class Student:public Person
{
public:void Print()override{cout << "Student: " << _age <<" name:"<<_name << endl;}
private:int _age=20;
};void PrintPersonInfo(Person& p) {p.Print(); // 基类引用调用虚函数,实现多态
}int main() {Student s;PrintPersonInfo(s); // 输出 "Student: 20, name: Jack"return 0;
}
在这个例子中,我们通过基类 Person
的引用调用 Student
类中的 Print()
函数,实现了运行时多态。派生类对象 s
被传递给基类引用 p
,并正确调用了 Student
类的重写函数 Print()
。
4.2.3 强制类型转换
在某些特殊情况下,基类指针或引用可能需要转换为派生类的指针或引用。C++ 提供了
dynamic_cast
、static_cast
等多种类型转换方式。在继承关系中,使用dynamic_cast
进行安全的类型转换尤为重要,特别是在处理多态时。
Person* pp = new Student(); // 基类指针指向派生类对象
Student* dc = dynamic_cast<Student*>(pp); // 安全的向下转换
if (dc)
{
dc->Print();
}
else {
cout << "Type conversion failed!" << endl;
}
dynamic_cast
在运行时进行类型检查,确保转换是安全的。如果转换失败,将返回 nullptr
,从而避免越界访问的风险。
5. 继承中的作用域与成员访问
5.1 作用域的独立性与同名成员的隐藏
在继承关系中,基类与派生类各自拥有独立的作用域。如果派生类中定义了与基类成员同名的变量或函数,基类的同名成员将被隐藏,这种现象称为隐藏(Hiding)。也叫重定义同名成员在派生类中会覆盖基类中的成员,导致基类成员无法被直接访问。
示例代码:
#include<iostream>
#include<string>
using namespace std;// 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;}protected:int _num = 999; // 学号};int main(){Student s;s.Print();return 0;}
输出:
姓名:小李子
身份证号:111
学号:999
从输出结果可以看出,Student
类中定义了一个 _num
变量,它隐藏了基类 Person
中的同名变量。为了访问基类的 _num
,我们使用了 Person::_num
来显式地指定访问基类中的成员。这样可以避免由于成员同名而导致的混淆。
实际开发中不建议写同名的变量名或函数名。
5.1.1 函数的隐藏
在C++中,函数隐藏指的是子类中定义的一个与父类中已有的成员函数具有相同名称和参数列表的函数,导致父类的函数在子类中被“隐藏”或“遮蔽”的现象。这种情况通常发生在子类中定义了一个与父类中同名的函数时,父类的函数就不再可见或无法被直接调用,除非通过特定方式(如使用作用域解析符
::
)显式访问。
示例代码:
class Teacher
{
public:void Print()const{cout << "Teacher:" << _tel << endl;}
private:string _tel="123456678";
};class Student:public Teacher
{
public:void Print()const{cout << "Student:" << _tel<<" age:"<<age<<" _name:"<<_name << endl;}
private:string _tel = "0123456789";int age = 18;string _name = "Mark";
};int main()
{Student s;s.Print();return 0;
}
输出:
Student:0123456789 age:18 _name:Mark
从结果可以看出: 派生类Student中的_tel="012356789"隐藏父类Teacher中的_tel="123456678",若大家强制想访问父类Teacher,可以使用Teacher::_tel。
与函数重载区别:
重载作用于同一个作用域,而隐藏作用于不同的作用域,因此隐藏不构成重载(Overloading)
构成函数隐藏的条件不是很苛刻,只需要函数名或变量名相同即可。
5.2 派生类的默认成员函数
在 C++ 中,当我们不显式定义类的构造函数、拷贝构造函数、赋值运算符和析构函数时,编译器会自动为我们生成这些函数。这些自动生成的函数在派生类中也会涉及到对基类成员的操作,因此在继承体系中了解这些默认成员函数的调用规则非常重要。
5.2.1 构造函数的调用顺序
派生类对象构造过程中,基类的对象会首先调用构造函数进行初始化,其次派生类再调用构造函数进行初始化。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
示例代码:
class Teacher
{
public:Teacher(const string& name):_name(name){cout << "Teacher constructor called!" << endl;}
private:string _name;
};class Student :public Teacher
{
public:Student(const string& name, int id):Teacher(name)//使用匿名构造函数完成初始化,_id(id){cout << "Student constructor called!" << endl;}
private:int _id;
};int main()
{Student s("Bob", 20241128);return 0;
}
输出:
Teacher constructor called!
Student constructor called!
从结果可以看出,先调用父类的构造,然后再调用派生类的构造函数。这种调用顺序确保基类的成员在派生类构造之前就已经被正确初始化。
5.2.2 拷贝构造函数与赋值运算符的调用
当派生类对象被拷贝时,基类的拷贝构造函数会先被调用,然后才是派生类的拷贝构造函数。同样,赋值运算符的调用顺序也遵循这一规则:基类的赋值运算符会先于派生类的赋值运算符被调用。
示例代码:
class Teacher
{
public:Teacher(const string& name):_name(name){}//拷贝构造函数Teacher(const Teacher& t){_name = t._name;cout << "Teacher copy constructor called!" << endl;}//赋值运算符重载Teacher& operator=(const Teacher& t){_name = t._name;cout << "Teacher assignment operator called!" << endl;return *this;}
protected:string _name;
};class Student :public Teacher
{
public:Student(const string& name, int id):Teacher(name)//使用匿名构造函数完成初始化, _id(id){}//拷贝构造函数Student(const Student& s):Teacher(s)//基类没有默认构造函数,则派生类的构造函数必须在初始化列表中显式调用基类的构造函数。{_id = s._id;cout << "Student copy constructor called!" << endl;}//赋值运算符重载Student& operator=(const Student& s){Teacher::operator=(s);//先调用基类的赋值运算符重载_id = s._id;cout << "Student assignment operator called!" << endl;return *this;}protected:int _id;
};int main()
{Student s1("Bob", 20241128);Student s2=s1;//拷贝构造Student s3("Jack", 2345);s1 = s3;//赋值运算符重载return 0;
}
输出:
Teacher copy constructor called!
Student copy constructor called!
Teacher assignment operator called!
Student assignment operator called!
从结果可以看出基类的拷贝构造和赋值运算符重载优先级优于派生类。为了保证派生类对象的完整性,派生类的拷贝构造函数和赋值运算符必须调用基类的相应函数,确保基类成员正确处理。
5.2.3 析构函数调用顺序
与构造函数的调用顺序相反,析构函数的调用顺序是先调用派生类的析构函数,然后再调用基类的析构函数。这确保了派生类的资源先被释放,然后基类的资源才能安全地释放。
示例代码:
class Person
{
public:Person(const int& age) : _age(age) {}~Person(){cout << "Person destructor called!" << endl;}
protected:int _age;
};class Student:public Person
{
public:Student(const int& age, const string& name):Person(age), _name(name){}~Student(){cout << "Student destructor called!" << endl;}
protected:string _name;
};int main()
{Student s(20, "Mark");return 0;
}
输出:
Student destructor called!
Person destructor called!
从结果可以看出,派生类Student先调用析构函数,进行类对象清理资源,然后再是基类Person调用析构函数,完成类对象资源清理,从而确保所有派生类的资源被正确释放。
5.2.4 虚析构函数
在继承体系中,若希望基类指针指向派生类对象,并通过该指针安全地释放对象,基类的析构函数应当定义为虚函数。否则,仅会调用基类的析构函数,导致派生类资源没有正确释放,从而引发内存泄漏。
示例代码:
考虑以下示例,展示了没有虚析构函数时会导致资源未释放的情况:
#include <iostream>
using namespace std;class Base {
public:Base() { cout << "Base Constructor" << endl; }~Base() { cout << "Base Destructor" << endl; } // 非虚析构函数
};class Derived : public Base {
public:Derived() { cout << "Derived Constructor" << endl; }~Derived() { cout << "Derived Destructor" << endl; } // 派生类析构函数
};int main() {Base* basePtr = new Derived();delete basePtr; // 只调用Base的析构函数,没有调用Derived的析构函数return 0;
}
输出:
Base Constructor
Derived Constructor
Base Destructor
解释:
如上所示,当delete basePtr
被调用时,基类的析构函数Base::~Base()
被调用,但派生类的析构函数Derived::~Derived()
没有被调用。这样,Derived
类中的资源(例如动态分配的内存、文件句柄等)就没有被正确释放,导致内存泄漏。
正确的做法是将基类的析构函数声明为虚函数:
#include <iostream>
using namespace std;class Base {
public:Base() { cout << "Base Constructor" << endl; }virtual ~Base() { cout << "Base Destructor" << endl; } // 虚析构函数
};class Derived : public Base {
public:Derived() { cout << "Derived Constructor" << endl; }~Derived() { cout << "Derived Destructor" << endl; } // 派生类析构函数
};int main() {Base* basePtr = new Derived();delete basePtr; // 会先调用Derived的析构函数,再调用Base的析构函数return 0;
}
输出:
Base Constructor
Derived Constructor
Derived Destructor
Base Destructor
解释:
在这个例子中,基类的析构函数被声明为虚函数,因此当delete basePtr
被调用时,程序会首先调用派生类的析构函数Derived::~Derived()
,然后再调用基类的析构函数Base::~Base()
,从而确保派生类资源得到正确释放。
总结:
- 虚析构函数:在继承体系中,基类的析构函数应当声明为虚函数,以确保派生类的析构函数能够在删除基类指针时被正确调用。
- 内存泄漏:如果基类的析构函数不是虚函数,那么派生类的析构函数不会被调用,可能会导致资源没有得到正确释放,从而引发内存泄漏。
最后
-
继承是面向对象编程的基础:继承允许通过基类创建派生类,实现代码重用、扩展和模块化设计,是面向对象编程的核心特性之一。
-
增强代码复用与扩展性:通过继承,派生类可以复用基类的代码,同时根据需要扩展或修改功能,提高代码的复用性和系统的灵活性。
-
虚继承解决多继承问题:C++支持多继承,但为了避免多继承中出现的“钻石继承”问题,虚继承机制确保基类的成员只会被继承一次,避免二义性和资源冲突。
-
虚函数和多态性实现动态行为:通过虚函数和多态性,C++使得基类指针或引用可以动态地调用派生类的实现,提高了代码的灵活性和可扩展性。
-
方法重写与函数隐藏的区别:方法重写是通过虚函数实现的多态性,而函数隐藏则是子类中定义的同名函数覆盖了父类中的函数,但不支持多态性。
-
析构函数必须为虚函数:当基类指针指向派生类对象时,析构函数必须声明为虚函数,以确保派生类的资源能够被正确释放,避免内存泄漏。
-
继承设计的最佳实践:继承应遵循简洁、清晰的设计原则,避免过深的继承层次和滥用多继承,确保类之间的关系符合“里氏替换原则”,从而提高代码的可维护性和可扩展性。
总结而言,C++继承是实现高效、灵活、可扩展的软件系统的核心工具,但继承的设计与使用应遵循一定的原则,避免复杂性和误用,从而提升代码质量和系统的可维护性。
路虽远,行则将至;事虽难,做则必成
亲爱的读者们,下一篇文章再会!!!