在 C++ 语言的世界里,面向对象编程就像一座宏伟的大厦,继承和多态则是这座大厦中最为关键的基石与精妙的结构。它们不仅赋予了代码强大的复用能力和灵活的扩展性,还让程序能够以更贴近现实世界的方式来组织和表达。从简单的类结构,到复杂的继承体系以及多态特性的实现,其中涉及的诸多语法规则和设计理念,既充满挑战,又蕴含着无限的魅力。接下来,就让我们一同深入探索 C++ 面向对象编程中继承和多态的奥秘,揭开这些语法背后的神秘面纱。
目录
struct 和 class
共同点
为空时大小都是1字节,表占位;
计算结构体或者类的大小方法都是一样的;
包含的成员类型相同;
不同点
默认权限修饰符:class为private
struct为public
继承
概念
继承是从已有的类创建新类的过程
在c++类中,如类B继承与类A,则类A叫做基类或者父类,则类B叫做派生类或者子类;
规则
class<派生类名>:<继承方式1><基类名1>, <继承方式2><基类名2> ....
{
派生类实体;
}
当一个类派生自基类,该基类可以被继承为public、protected或private几种类型。继承类型是通过上面讲解的访问修饰符access-specifier 来指定的。
我们几乎不使用 protected或private继承,通常使用public继承。当使用不同类型的继承时,遵循以下几个规则:
公有继承(public):当一个类派生自公有基类时,基类的公有成员也是派生类的公有成员,基类的保护成员也是派生类的保护成员,基类
的私有成员不能直接被派生类访问,但是可以通过调用基类的公有和保护成员来访问。
保护继承(protected):当一个类派生自保护基类时,基类的公有和保护成员将成为派生类的保护成员。
私有继承(private):当一个类派生自私有基类时,基类的公有和保护成员将成为派生类的私有成员。
注意
一个父类的指针或者引用指向一个子类对象;
#include <iostream>
using namespace std;
class A
{
public:
A();
A(int a, int b, int c);
void show();
void get_c()
{
cout << c << endl;
}
int a;
protected:
int b;
private:
int c;
};
//如果是共有继承,那么成员变量在基类中是是么权限
//在派生类中就是是么权限
//在基类中为私有权限的成员变量,不可以直接在派生类中访问
//必须通过基类的共有或者保护成员做间接访问
class B : public A
{
public:
B(int a, int b, int c, double d);
void show();
void get_b()
{
cout << b << endl;
}
int a;
double d;
};
A::A() : a(0), b(0), c(0)
{
cout << "A--默认构造" << endl;
}
A::A(int a, int b, int c) : a(a), b(b), c(c)
{
cout << "A--有参构造" << endl;
}
B::B(int a, int b, int c, double d) : A(a, b, c), d(d)
{
cout << "B--有参构造" << endl;
}
void A::show()
{
cout << "A--show()" << endl;
cout << "A = " << a << "\tB = " << b << "\tC = " << c << endl;
}
void B::show()
{
A::show();
cout << "B--show()" << endl;
get_c();
cout << "A = " << a << "\tB = " << b << "\tD = " << d << endl;
}
int main(int argc, const char *argv[])
{
cout << sizeof (A) << endl;
cout << sizeof (B) << endl;
//当创建我们的派生类对象时,由于发生了继承
//所以我们的派生类对象时,含有基类的内存大小
//会调用基类的构造函数完成对应内存的初始化
B b(11, 22, 33, 44);
cout << b.a << endl;
b.get_b();
b.get_c();
cout << "===================" << endl;
b.show();
cout << "===================" << endl;
//一个父类的指针或者引用指向一个子类对象
A *pb = new B(100, 200, 300, 400);
cout << "===================" << endl;
pb->show();
cout << "===================" << endl;
A &ra = b;
ra.show();
return 0;
}
无法被继承的成员
基类的构造函数、拷贝构造函数、析构函数;
基类的重载运算符;
基类的友元函数与友元;
继承之后的成员函数的隐藏
父类与子类有同名函数,子类对象调用的时候总是调用子类的函数,此时父类成员函数被隐藏;
一般子类继承过来的函数不适合子类,或者需要扩展,则需要隐藏继承而来的父类函数;
函数返回值与参数可相同可不同,只需要函数名保持一致;
构造函数调用顺序
构造一个子类时
1.先调用父类构造函数;
2.再调用成员对象构造函数;
3.最后调用自身构造函数;
技巧:
1.构造函数的调用顺序与继承顺序和组合顺序一致;
2.继承类的每一个成员与组合类的每一个成员都要参与到初始化;
3.初始化列表只用来判断对应的成员变量以何种方式调用对应的构造函数;
4.最后一步调用自己;
析构函数调用顺序
与构造函数相反;
#include <iostream>
using namespace std;
class A
{
public:
A() : a(0)
{
cout << "A--默认" << endl;
}
A(int a) : a(a)
{
cout << "A--有参" << endl;
}
~A()
{
cout << "~A--析构" << endl;
}
private:
int a;
};
class B : public A
{
public:
B() : A(), b(0)
{
cout << "B--默认" << endl;
}
B(int a, int b) : A(a), b(b)
{
cout << "B--有参" << endl;
}
~B()
{
cout << "~B--析构" << endl;
}
private:
int b;
};
class C
{
public:
C() : c(0)
{
cout << "C--默认" << endl;
}
C(int c) : c(c)
{
cout << "C--有参" << endl;
}
~C()
{
cout << "~C--析构" << endl;
}
private:
int c;
};
//针对于继承顺序而言
//构造函数的调用顺序只和继承时的声明顺序有关
class D : public B, public C
{
public:
D() : B(), C(), d(0)
{
cout << "D--默认" << endl;
}
//初始化列表不影响调用构函数的顺序
//指影响调用哪一个构造函数
D(int a, int b, int c, int d)
: a(), b(10, 10), C(c), B(a, b), c(a), d(d)
{
cout << "D--有参" << endl;
}
~D()
{
cout << "~D--析构" << endl;
}
private:
//继承的构造函数会优先于成员变量的构造函数先行调用
//成员变量的调用顺序构造函数只跟成员变量的声明顺序有关
A a;
C c;
B b;
int d;
};
int main(int argc, const char *argv[])
{
D(10, 20, 30, 40);
return 0;
}
组合
通过对现有对象进行拼装,产生新的具有更复杂的功能;
与继承的区别
若在逻辑上B是A的“一种”(a kind of),则允许B继承A的功能。如男人(Man)是人(Human)的一种,男孩(Boy)是男人的一种。那么类Man可以从类Human派生,类Boy可以从类Man 派生。
若在逻辑上A是B的“一部分”(a part of),则不允许B继承A的功能,而是要用A和其它东西组合出B。例如眼(Eye)、鼻(Nose)、口(Mouth)、耳(Ear)是头(Head)的一部分,所以类Head应该由类Eye、Nose、Mouth、Ear组合而成,不是派生而成。
#include <iostream>
using namespace std;
class A
{
public:
A() : a(0)
{
}
void getA()
{
cout << a << endl;
}
private:
int a;
};
class B : public A
{
};
//在工程中,一般情况下,组合用的多
class C
{
public:
void getA()
{
a.getA();
}
private:
A a;
};
int main(int argc, const char *argv[])
{
B b;
b.getA();
C c;
c.getA();
return 0;
}
多继承与多重继承
多继承:一个派生类有多个基类;
多重继承:一个派生类作为其他派生类的基类;
多重继承菱形继承:
数据冗余问题:
当D类继承与B类与C类,B类与C类有共同的基类时,在创建D类的对象时,A的构造函数将会调用两次,相当于创建两个A类对象;
二义性问题:
A类中成员变量,可以通过B和C去访问,此时会存在两个同种含义的变量;
解决方案:
使用虚继承;
多态的分类
多态的表现:
对于同一个行为对于不同的对象,有不同的表现;
在c++中,一般针对一个行为只会有一个名称,是对类的行为在做抽象,主要作用在于统一行为的接口,提高方法的通用性;
静态多态与动态多态
静态多态
基于函数重载与泛型编程实现的;
动态多态
基于虚函数实现的;
必须通过基类对象的指针或引用调用函数;
被调用的函数必须是虚函数,且派生类需要重写基类的虚函数;
虚函数与动态多态的实现
虚函数
1.使用virtual 关键字声明的函数,就是虚函数,是动态多态实现的基础;
2.非类的成员函数不能定义为虚函数;
3.类的静态成员函数不能被定义为虚函数;
4.构造函数不能被定义为虚函数;
5.基类的某一成员是虚函数,派生类的同名函数也是虚函数;
动态多态
实现:
1.创建两个类,并且是继承关系;
2.基类中的函数是虚函数;
3.派生类继承基类,并且重写基类的虚函数;
4.通过基类的指针或者引用访问派生类得对象;
重写
1.不同的作用域(分别位于派生类和基类);
2.函数名相同;(析构)
3.参数相同;
4.返回值相同;(协变)
5.基类必须有virtual关键字,不能有static;
6.重写函数的访问权限限定符可以不同;
例外:
1.析构:对于析构函数而言,不需要函数名相同,直接在析构函数名前加virtual即可实现函数重写;
如果不构成多态,那么指针什么类型就会调用什么类型的析构函数,这也就导致:如果派生类的析构函数中有资源要释放,而这里却没有释放掉,就会导致内存泄漏问题;
2.协变:重写的虚函数,返回值可以不同,但是返回值必须是父,子类的指针或引用类型,即为协变;
注意:
1.如下第84行。
2.如果发生动态多态,那么就调用派生类的函数,如果没有发生动态多态,则调用基类的函数;
3.观察转换类型是否是指针或者引用类型;如果是则以该类型的方式解释重写;如果不是,则不能用重写的方式来调用函数;
eg:A *pa = new B; pa->func() ; 遵循重写
B b; (A)b . func(); 不遵循重写
#include <iostream>
using namespace std;
class Person
{
public:
//虚函数的写法
//对于析构函数而言,只需要在前方加上virtual关键字
//即可构成函数重写
virtual ~Person();
virtual void buyTicket();
//发生了函数重载
virtual Person *eatFans();
virtual Person &eatFans(int);
};
Person::~Person()
{
cout << "~Person" << endl;
}
void Person::buyTicket()
{
cout << "100% Ticket" << endl;
}
Person *Person::eatFans()
{
cout << "1 Wan" << endl;
return this;
}
Person &Person::eatFans(int)
{
cout << "1 Wan" << endl;
return *this;
}
class Student : public Person
{
public:
//当基类中的同名函数成为虚函数时
//派生类的同名函数也是虚函数
virtual ~Student();
virtual void buyTicket();
virtual Student *eatFans();
//重载
virtual Student &eatFans(int);
};
Student::~Student()
{
cout << "~Student" << endl;
}
void Student::buyTicket()
{
cout << "80% Ticket" << endl;
}
Student *Student::eatFans()
{
cout << "2 Wan" << endl;
return this;
}
Student &Student::eatFans(int)
{
cout << "2 Wan" << endl;
return *this;
}
int main(int argc, const char *argv[])
{
Person *p = new Student;
p->buyTicket();
delete p;
p = nullptr;
cout << "=====================" << endl;
Student s;
Person &rp = s;
rp.buyTicket(); //调用派生类(重写后的函数)
Person &rp2 = rp.eatFans(10); //调用父类函数,用什么类型的指针接收,就调用该类型的函数
rp2.buyTicket();
cout << "=====================" << endl;
return 0;
}
说明符final与override
final
指定某个虚函数不能在子类中被重写,或者某个类不能被子类继承;
只用声明时表明即可
#include <iostream>
using namespace std;
class Person
{
public:
virtual void buyTicket() final;
};
void Person::buyTicket()
{
cout << "100% Ticket" << endl;
}
//通过使用final说明符,禁止无限继承
class Student final : public Person
{
public:
//通过使用final说明符,禁止虚函数无限制重写
// void buyTicket();
};
int main(int argc, const char *argv[])
{
return 0;
}
override
检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编辑器报错只用声明时即可;
#include <iostream>
using namespace std;
class A
{
public:
virtual void Afunc()
{
}
virtual void Bfunc()
{
}
virtual void Cfunc()
{
}
};
class B : public A
{
public:
virtual void Afunc() override
{
}
virtual void Bfunc() override
{
}
virtual void Cfunc() override
{
}
virtual void Dfunc()
{
}
};
class C : public B
{
public:
virtual void Afunc() override
{
}
virtual void Bfunc() override
{
}
//override说明符,只需要在类中声明即可
//不需要在类外定义时写
virtual void Cfunc() override;
};
void C::Cfunc()
{
}
int main(int argc, const char *argv[])
{
A *pa = new C;
pa->Cfunc();
return 0;
}
重写(覆盖)、重载、隐藏
重写与覆盖区别
子类对象:子类对象在调用的时候,总是调用子类实现的成员函数;
父类对象:
重写:用父类的指针或引用指向子类对象的时候,覆盖的情况下,父类指针或引用调用的函数是子类实现的函数;
隐藏:用父类的指针或引用指向子类对象的时候,隐藏的情况下,父类指针或引用调用的函数是父类实现的函数;
多态原理与虚函数表
空的类是会占用内存空间的,而且大小是1,原因是C++要求每个实例在内存中都有独一无二的地址。
类内部的成员变量:
普通的变量:是要占用内存的,但是要注意对齐原则(这点和struct类型很相似)。
static修饰的静态变量:不占用内容,原因是编译器将其放在全局变量区。
类内部的成员函数:
普通函数:不占用内存。
虚函数:要占用4个字节(32位系统)或8个字节(64位系统),用来指定虚函数的虚拟函数表的入口地址。所以一个类的虚函数所占用的地址是不变的,和虚函数的个数是没有关系的。
C++编译系统中,数据和函数是分开存放的(函数放在代码区;数据主要放在栈区或堆区,静态/全局区以及文字常量区也有),实例化不同对象时,只给数据分配空间,各个对象调用函数时都都跳转到(内联函数例外)找到函数在代码区的入口执行,可以节省拷贝多份代码的空间
数据主要放在栈区或堆区,有可能是堆,也有可能是栈。这取决于实例化对象的方式:
A a1 = new A(); //堆
A a2; //栈
类的静态成员变量编译时被分配到静态/全局区,因此静态成员变量是属于类的,所有对象共用一份,不计入类的内存空间。
内联函数(声明和定义都要加inline)也是存放在代码区,在编译阶段,编译器会用内联函数的代码替换掉函数,避免了函数跳转和保护现场的开销。不要将成员函数的这种存储方式和inline(内联)函数的概念混淆。不要误以为用inline声明(或默认为inline)的成员函数,其代码段占用对象的存储空间,而不用inline声明的成员函数,其代码段不占用对象的存储空间。不论是否用inline声明(或默认为inline),成员函数的代码段都不占用对象的存储空间。用inline声明的作用是在编译时期,将函数的代码段复制插人到函数调用点,而若不用inline声明,在调用该函数时,流程转去函数代码段的入口地址,在执行完该函数代码段后,流程返回函数调用点。inline与成员函数是否占用对象的存储空间无关
虚函数表
实际上对于定义了虚函数的类对象来说,有一个隐藏的虚函数表指针,指向一个虚函数表,这个虚函数表中存放着虚函数的地址;虚函数表属于类,不属于对象;
多态的原理
由于动态绑定,因此当父类的指针或者引用去调用虚函数时,会到运行时具体的对象的虚函数表中进行寻找对应的虚函数进行调用,若为父类类型,则调用父类虚函数,若为派生类类型,则调用派生类虚函数;
#include <iostream>
using namespace std;
using Func = void (*)();
//typedef void (*Func)();
class A
{
public:
void showA()
{
cout << "A ShowA" << endl;
}
virtual void showB()
{
cout << "A ShowB" << endl;
}
virtual void showC()
{
cout << "A ShowC" << endl;
}
private:
int a;
};
class B : public A
{
public:
virtual void showB()
{
cout << "B ShowB" << endl;
}
virtual void showD()
{
cout << "B ShowD" << endl;
}
private:
int b;
};
int main(int argc, const char *argv[])
{
A *pa = new B;
cout << "虚函数表指针的地址: " << (long *)pa << endl;
cout << "虚函数表的地址: " << (long *)*(long *)pa << endl;
cout << "虚函数表的第一块地址: " << (long *)*(long *)pa << endl;
cout << "虚函数表的第二块地址: " << (long *)*((long*)pa) + 1 << endl;
cout << "第一个虚函数的地址: " << (long*)*(long*)*(long*)pa << endl;
cout << "第二个虚函数的地址: " << (long*)*((long*)*(long*)pa + 1) << endl;
Func func = (Func)*(long *)*(long*)pa;
func();
Func func1 = (Func)*((long*)*(long*)pa + 2);
func1();
return 0;
}
抽象类与纯虚函数
抽象类的特点及作用
包含纯虚函数的类称为抽象类;
1.抽象中只声明函数的接口,不能有具体的实现;
2.抽象类不能创建对象,但是可以定义指针和引用;
3.派生类继承抽象类,并且必须要实现基类中的所有纯虚函数,否则派生类也是抽象类;
通过对 C++ 中继承和多态以及相关语法的全面探讨,我们领略到了面向对象编程的强大与精妙。从类的继承关系构建出的层次分明的代码结构,到多态特性让程序展现出的灵活多样的行为,这些概念和语法规则相互交织,共同构建了高效、可维护且富有表现力的软件系统。无论是对于初学者还是有经验的开发者,不断深入理解和熟练运用这些知识,都能在 C++ 编程的道路上走得更远。希望大家在今后的编程实践中,能够充分发挥继承和多态的威力,编写出更加优秀的代码。