突破编程_C++_基础教程(继承与多态)
1 继承
继承是面向对象编程的一个基本概念,它允许一个类(派生类、子类)继承另一个类(基类、父类)的属性和方法。继承可以减少代码冗余,提高代码重用性,并且有助于创建更复杂的类结构。
1.1 继承的基本用法
要在派生类中继承基类,只需在派生类定义的时候列出基类的名称,并指定继承方式(公有、保护或私有):
class BaseClass
{
// 基类的成员
};
class DerivedClass : public BaseClass
{
// 派生类的成员
};
在上面代码中,Derived 类公有地继承了 Base 类。这意味着 Base 类中的公有成员和保护成员在 Derived 类中保持原有的访问权限(公有和保护),而 Base 类中的私有成员在 Derived 类中是不可访问的。
派生类是基类的一个特殊化版本。这意味着派生类拥有基类所有的属性和方法,同时还可以定义自己的新属性和方法。这种关系通常被称为 “IS-A” 关系,即派生类 “是一种” 基类。如下为样例代码:
class BaseClass
{
public:
int getVal()
{
return m_val;
}
protected:
int m_val = 0;
};
class DerivedClass : public BaseClass
{
public:
void derivedFunc()
{
m_val = 2; //OK:拥有基类的属性
m_derivedVal = getVal(); //OK:拥有基类的方法
}
private:
int m_derivedVal = 1;
};
在上面代码中,继承类 DerivedClass 的成员函数 derivedFunc() 可以使用基类的属性和方法。
1.2 继承的类型
C++支持三种继承方式:
公有继承(Public Inheritance)
当使用公有继承时,基类的公有成员和保护成员在派生类中保持原有的访问权限,而基类的私有成员在派生类中是不可访问的。这是最常见的继承方式。如下为样例代码:
class BaseClass
{
public:
int getVal1()
{
return m_val1;
}
protected:
int m_val1 = 0;
private:
int m_val2 = 0;
};
class DerivedClass : public BaseClass //公有继承
{
public:
DerivedClass()
{
m_val2 = 1; //错误:基类的私有成员在派生类中是不可访问的
}
};
DerivedClass derivedObj;
int val1 = derivedObj.getVal1(); //OK:公有继承下,基类的公有成员在派生类中是公有的,外部可以访问
derivedObj.m_val1 = 1; //错误:公有继承下,基类的保护成员在派生类中是保护的,外部不可以访问
保护继承(Protected Inheritance)
在保护继承中,基类的公有成员和保护成员在派生类中变为保护成员,而基类的私有成员在派生类中是不可访问的。如下为样例代码:
class BaseClass
{
public:
int getVal1()
{
return m_val1;
}
protected:
int m_val1 = 0;
};
class DerivedClass : protected BaseClass //保护继承
{
};
DerivedClass derivedObj;
int val1 = derivedObj.getVal1(); //错误:保护继承下,基类的公有成员在派生类中是保护的,外部不可以访问
私有继承(Private Inheritance)
在私有继承中,基类的公有成员和保护成员在派生类中变为私有成员,而基类的私有成员在派生类中是不可访问的。如下为样例代码:
class BaseClass
{
public:
int getVal1()
{
return m_val1;
}
protected:
int m_val1 = 0;
};
class DerivedClass : private BaseClass //私有继承
{
};
DerivedClass derivedObj;
int val1 = derivedObj.getVal1(); //错误:私有继承下,基类的公有成员在派生类中是私有的,外部不可以访问
继承方式比较
继承方式 | 基类公有成员 | 基类保护成员 | 基类私有成员 |
---|---|---|---|
公有继承 | 继承为公有成员 | 继承为保护成员 | 不继承 |
保护继承 | 继承为保护成员 | 继承为保护成员 | 不继承 |
私有继承 | 继承为私有成员 | 继承为私有成员 | 不继承 |
1.3 派生类的构造函数和析构函数
在派生类对象初始化时,派生类的构造函数需要调用基类的构造函数来初始化基类部分。这可以通过成员初始化列表实现。在销毁派生类对象时,派生类的析构函数会在派生类部分析构之后自动调用基类的析构函数。
注意:创建对象时,先调用基类的构造函数,再调用派生类的构造函数;销毁对象时,先调用派生类的析构函数,再调用基类的析构函数。
1.3.1 派生类的构造函数
派生类的构造函数负责初始化从基类继承的成员和派生类自己新增的成员。在没有显式的指定基类构造函数的情况下,派生类的构造函数会默认调用基类的无参构造函数来初始化基类部分。如下为样例代码:
#include <iostream>
class BaseClass
{
public:
BaseClass()
{
printf("call BaseClass()\n");
}
BaseClass(int val)
{
printf("call BaseClass(int val)\n");
}
};
class DerivedClass : public BaseClass
{
public:
DerivedClass()
{
printf("call DerivedClass()\n");
}
DerivedClass(int val)
{
printf("call DerivedClass(int val)\n");
}
};
int main() {
DerivedClass derivedObj(2);
return 0;
}
上面代码的输出为:
call BaseClass()
call DerivedClass(int val)
在这个例子中,DerivedClass 类继承自 BaseClass 类。DerivedClass 类与 BaseClass 类包含两个构造函数:无参构造函数以及有参数构造函数。在 main 函数中,创建 DerivedClass 类的对象时,由于传入了参数 2 ,所以调用的是其有参数构造函数,但是因为没有显式的指定基类构造函数,所以会默认的调用基类的无参构造函数(即使基类也有一个相似的有参数构造函数)。
如果需要调用基类的有参数构造函数,则需要做如下修改(这也是常用的调用方式):
#include <iostream>
class BaseClass
{
public:
BaseClass()
{
printf("call BaseClass()\n");
}
BaseClass(int val)
{
printf("call BaseClass(int val)\n");
}
};
class DerivedClass : public BaseClass
{
public:
DerivedClass()
{
printf("call DerivedClass()\n");
}
DerivedClass(int val) : BaseClass(val) //这里显式的调用了基类的有参数构造函数
{
printf("call DerivedClass(int val)\n");
}
};
int main() {
DerivedClass derivedObj(2);
return 0;
}
上面代码的输出为:
call BaseClass(int val)
call DerivedClass(int val)
1.3.2 派生类的析构函数
在C++中,派生类的析构函数用于执行派生类对象在销毁时所需的清理工作。当派生类对象被销毁时,它的析构函数首先执行,然后是它的基类的析构函数。这是为了确保派生类中的成员在基类之前被正确释放。
如果没有在派生类中定义析构函数,编译器会自动生成一个默认的析构函数。这个默认的析构函数会调用基类的析构函数(如果基类有定义的话)。如下为样例代码:
#include <iostream>
class BaseClass
{
public:
BaseClass() {}
~BaseClass()
{
printf("call ~BaseClass()\n");
}
};
class DerivedClass : public BaseClass
{
public:
DerivedClass() {}
~DerivedClass()
{
printf("call ~DerivedClass()\n");
}
};
int main()
{
{
DerivedClass derivedObj;
}
return 0;
}
上面代码的输出为:
call ~DerivedClass()
call ~BaseClass()
在上面代码中,当 DerivedClass 类的对象被销毁时(离开作用域后被销毁),首先会调用 DerivedClass 类的析构函数,然后是 BaseClass 类的析构函数。这样确保了派生类中的资源在基类之前被正确释放。
通常,如果在派生类中分配了动态内存(例如使用 new),则应该在派生类的析构函数中释放这些内存。如果基类也分配了动态内存,那么基类的析构函数应该负责释放这些内存。这样,当派生类对象被销毁时,所有的资源都会被正确清理。
1.4 多重继承
多重继承是指一个类可以继承自多个基类。这意味着一个类可以获取多个基类的属性和方法。然而,多重继承也带来了一些复杂性和潜在的问题,如菱形继承、二义性调用和初始化顺序等。
多重继承的简单使用如下:
class BaseClass1
{
protected:
int m_baseClass1Val;
};
class BaseClass2
{
protected:
int m_baseClass2Val;
};
class DerivedClass : public BaseClass1, public BaseClass2
{
public:
void derivedFunc()
{
m_baseClass1Val = 1; // 拥有 BaseClass1 基类的成员变量
m_baseClass2Val = 2; // 拥有 BaseClass2 基类的成员变量
}
};
注意这里的 public、 protected 或 private 关键字决定了基类成员在派生类中的访问权限。
1.4.1 菱形继承
菱形继承( Diamond Inheritance )是C++多重继承中的一个特定情况,它指的是当一个类从两个或更多的类继承,而这些类又有一个共同的基类时,形成的继承结构图看起来像一个菱形。这种情况下,基类在继承层次中被共享,可能会导致一些问题,特别是当基类包含数据成员时。
在C++中,菱形继承的主要问题在于基类数据成员的多份拷贝。如下为样例代码:
class BaseClass
{
protected:
int m_baseVal;
};
class BaseLeftClass : public BaseClass
{
};
class BaseRightClass : public BaseClass
{
};
class DerivedClass : public BaseLeftClass, public BaseRightClass
{
};
在这个例子中,DerivedClass 类通过 BaseLeftClass 和 BaseRightClass 间接地继承了两次 BaseClass 类。如果没有特殊处理,BaseClass 类中的成员变量 m_baseVal 将在 DerivedClass 类中存在两份拷贝:一份来自 BaseLeftClass,一份来自 BaseRightClass。这不仅浪费空间,而且可能导致逻辑错误,因为通过 BaseRightClass 和 BaseRightClass 访问到的成员变量 m_baseVal 可能是不同的。
为了解决这个问题,C++ 提供了虚继承(Virtual Inheritance)。虚继承确保在继承层次中,无论基类出现多少次,都只会存在一份基类子对象的拷贝。使用虚继承,上面的例子可以改写为:
class BaseClass
{
protected:
int m_baseVal;
};
class BaseLeftClass : virtual public BaseClass
{
};
class BaseRightClass : virtual public BaseClass
{
};
class DerivedClass : public BaseLeftClass, public BaseRightClass
{
};
通过使用 virtual public 的虚继承方式,保证了在 DerivedClass 类中只有一个 BaseClass 子对象的拷贝。这消除了数据冗余,并确保了通过 BaseLeftClass 和 BaseRightClass 访问到的 value 是同一个。
1.4.2 二义性调用
二义性调用发生在多重继承的情况下,当派生类试图调用从多个基类继承而来的同名成员(如方法或属性)时,编译器无法确定应该使用哪个基类的成员。
这种情况通常发生在以下场景中:
(1)当派生类继承自两个基类,并且这两个基类都有一个同名的成员函数。
(3)当派生类继承自一个基类,并且这个基类继承自另外两个也拥有同名成员函数的基类。
如果派生类试图调用这个同名的成员函数,编译器将无法确定应该使用哪个基类的版本,因此会报错。如下为样例代码:
#include <iostream>
class BaseClass1
{
public:
void func() {};
};
class BaseClass2
{
public:
void func() {};
};
class DerivedClass : public BaseClass1, public BaseClass2
{
};
int main()
{
DerivedClass* obj = new DerivedClass;
obj->func(); // 错误:二义性调用
delete obj;
obj = nullptr;
return 0;
}
在上面代码中,DerivedClass 类从 BaseClass1 和 BaseClass2 继承,而 BaseClass1 和 BaseClass2 都有一个名为 func 的成员函数。因此,当试图在 DerivedClass 类的对象上调用 func 时,编译器不知道应该调用 BaseClass1::func 还是 BaseClass2::func,从而产生了二义性。使用作用域解析运算符 (::)可以解决上面的二义性调用的问题:
obj->BaseClass1::func();
2 多态
多态( Polymorphism )是面向对象编程的三大基本特性之一,它允许我们使用相同的接口来表示不同类型的对象。在 C++ 中,多态通常通过虚函数( virtual functions )和指针或引用来实现。
2.1 虚函数
虚函数是 C++ 中实现多态的关键机制。通过在基类的成员函数前加上 virtual 关键字,可以将其声明为虚函数。当派生类重写( override )这个虚函数时,就可以通过基类指针或引用来调用派生类的实现,这就是所谓的动态绑定或运行时多态。与之相对应的是静态绑定,即在使用父类指针或引用调用子类对象的成员函数时,如果没有使用虚函数,则会进行静态绑定,从而只能调用父类的成员函数,无法调用子类特有的成员函数。
虚函数的原理主要涉及虚函数表和虚函数指针:
(1)虚函数表
当一个类含有至少一个虚函数时,编译器会为这个类创建一个虚函数表( vtable ),并在每个该类的对象中嵌入一个指向这个虚函数表的指针(通常被称为 vptr )。
虚函数表是一个函数指针数组,其中每个元素都是指向类中定义的虚函数的指针。每个类,包括它的所有派生类,都会有自己的虚函数表。当派生类重写基类的虚函数时,派生类的虚函数表会包含指向这些重写函数的指针。
(2)虚函数指针
虚函数指针( virtual function pointer ,简称为 vptr )是一个隐藏的成员变量,它存在于包含至少一个虚函数的类的对象中。 vptr 指向一个虚函数表( vtable ),该表包含了类中所有虚函数的地址。虚函数表是一个函数指针数组,每个元素都指向一个虚函数的实现。
vptr 的存在是实现多态性的关键,它允许程序在运行时动态地确定应该调用哪个类的虚函数实现。当通过基类指针或引用调用一个虚函数时,实际调用的是 vptr 所指向的虚函数表中的函数。
虚函数的具体工作原理如下:
(1)定义虚函数:在基类中声明一个或多个虚函数。
(2)创建虚函数表:编译器为包含虚函数的类创建一个虚函数表。这个表是一个函数指针数组,每个元素指向类中的一个虚函数。
(3)初始化虚函数指针:当创建类的对象时,编译器会为该对象的虚函数指针成员变量分配内存,并初始化虚函数指针以指向该类的虚函数表。
(4)动态绑定:当通过基类指针或引用调用虚函数时,程序会查找虚函数指针所指向的虚函数表,并调用表中对应的函数。这个查找过程是在运行时进行的,因此被称为动态绑定。
注意点:
虚函数表与虚函数指针都是由是编译器实现,对程序员是透明的。
虚函数表与虚函数指针的存在对内存与性能有一定影响,因为当创建类的对象时,编译器会为该对象的虚函数指针成员变量分配内存,并且每次调用虚函数都需要查找虚函数表。然而,这种性能开销通常是可以接受的,因为它提供了强大的多态性支持。
2.2 多态的使用
C++中的多态主要有两种类型:静态多态和动态多态。
静态多态
也称为编译时多态或早绑定( Early Binding )。这主要通过函数重载( Function Overloading )和模板( Templates )来实现。在编译时,编译器就能确定应该调用哪个函数。
动态多态
也称为运行时多态或晚绑定( Late Binding )。这是通过虚函数( Virtual Functions )和继承来实现的。动态多态也被称为函数重写( Function Overriding )。在运行时,程序根据对象的实际类型来确定应该调用哪个函数。
如下为样例代码:
#include <iostream>
class BaseClass
{
public:
void overloadFunc()
{
printf("BaseClass overloadFunc()\n");
}
virtual void overrideFunc()
{
printf("BaseClass overrideFunc()\n");
}
};
class DerivedClass : public BaseClass
{
public:
void overloadFunc() // 函数重载
{
printf("DerivedClass overloadFunc()\n");
}
void overrideFunc() // 函数重写
{
printf("DerivedClass overrideFunc()\n");
}
};
int main()
{
BaseClass* obj = new DerivedClass;
obj->overloadFunc(); // 调用基类的函数
((DerivedClass*)obj)->overloadFunc(); // 强制类型转换后调用继承类的重载函数
obj->overrideFunc(); // 调用继承类的重写函数
delete obj;
obj = nullptr;
return 0;
}
上面代码的输出为:
BaseClass overloadFunc()
DerivedClass overloadFunc()
DerivedClass overrideFunc()
在上面代码中, BaseClass 是一个基类,它有一个成员函数 overloadFunc() 以及一个虚函数 overrideFunc()。 DerivedClass 是 BaseClass 的派生类,它重载了 overloadFunc() 函数,并且重写了 overrideFunc() 函数。在 main 函数中,使用 BaseClass 基类的指针变量创建了一个 DerivedClass 派生类的对象,并调用其 overloadFunc() 以及 overrideFunc(),注意使用强制类型转换后才能真正调用到继承类的重载函数,而重写函数则可以直接调用。
2.3 多态与构造函数、析构函数
构造函数不能被定义为虚函数:
原因主要有以下几点:
(1)执行时机:构造函数在对象创建时立即执行,它是对象初始化的一部分。而虚函数是在运行时通过动态绑定来确定的,这需要对象的虚函数表已经初始化。在构造函数执行时,虚函数表尚未建立,因此无法执行虚函数。
(2)对象完整性:在构造函数执行期间,对象还没有完全构造完成。如果构造函数是虚函数,那么当派生类的构造函数被调用时,它的基类部分可能还没有完全初始化。这会导致对象的状态不一致,进而引发错误。
(3)初始化顺序:在继承层次中,派生类构造函数在调用自身之前会首先调用基类的构造函数。如果基类构造函数是虚函数,则会去查找派生类的重写虚函数,从而导致初始化顺序的混乱。
建议将基类的析构函数定义为虚函数:
将析构函数定义为虚函数是一个重要的代码构建技术点,特别是在设计基类时。这是因为在多态性的场景中,当使用基类指针或引用指向派生类对象时,如果没有虚析构函数,可能会导致派生类对象的析构过程不完整,从而引发资源泄漏和其他问题。
当基类的析构函数不是虚函数时,如果通过基类指针删除派生类对象,只会调用基类的析构函数,而不会调用派生类的析构函数。这会导致派生类对象中的资源(如动态分配的内存)没有被正确释放,从而产生资源泄漏。
通过将基类的析构函数声明为虚函数,可以确保当使用基类指针或引用删除派生类对象时,派生类的析构函数也会被调用。这是多态性的一个关键方面,它允许在运行时确定应该调用哪个类的析构函数。
如下为样例代码:
#include <iostream>
class BaseClass
{
public:
// 将析构函数声明为虚函数
virtual ~BaseClass()
{
printf("virtual ~BaseClass() \n");
}
};
class DerivedClass : public BaseClass
{
public:
~DerivedClass()
{
printf("virtual ~DerivedClass() \n");
}
};
int main()
{
BaseClass* obj = new DerivedClass;
delete obj;
obj = nullptr;
// 如果 BaseClass 的析构函数不是虚函数,这里只会调用 BaseClass 的析构函数,
// 而不会调用 DerivedClass 的析构函数,导致资源泄漏。
// 如果 BaseClass 的析构函数是虚函数,则会先调用 DerivedClass 的析构函数,
// 然后调用 BaseClass 的析构函数,确保资源被正确释放。
return 0;
}
上面代码的输出为:
virtual ~DerivedClass()
virtual ~BaseClass()
在上面代码中,BaseClass 类的析构函数被声明为虚函数。当通过基类指针 obj 删除派生类 DerivedClass 的对象时,由于析构函数是虚函数,所以会先调用 DerivedClass 的析构函数,然后再调用 BaseClass 的析构函数。这样,派生类中的资源能够被正确释放,避免了资源泄漏。
因此,通常建议在设计基类时将析构函数定义为虚函数,以确保在删除派生类对象时能够正确地调用析构函数链。