1 基本概念
面向对象语言有以下三个特性:封装,继承,多态。本文将从这三个方面展开讲解,首先介绍一些基本的概念,然后分别介绍着三个特性。
1.1 虚函数
1.1.1 概念
虚函数允许在子类中重新定义基类中定义的函数,使得通过基类指针或引用调用的函数在运行时根据实际对象类型来确定。这种机制称为动态绑定或者运行时多态。在基类中通过在函数声明前增加virtual
关键字,将函数声明为虚函数。
1.1.2 实现原理
虚函数的实现原理涉及虚函数表(Virtual Table
,简称vtable
)和虚函数指针(Virtual Pointer
,简称vptr
)。
- 虚函数表是一个函数指针数组,每个包含虚函数的类都有一个虚函数表。虚函数表中存储了该类的虚函数的地址。在派生类中,虚函数表会重载基类的虚函数地址,以指向派生类的对应实现。
- 虚函数指针是一个指向虚函数表的指针。每个包含虚函数的类对象都有一个隐藏的虚函数指针,通常称为
vptr
。当一个对象被创建时,vptr
被初始化为指向该对象所属类的虚函数表。
当通过基类指针或引用调用虚函数时,程序会通过对象的vptr
查找虚函数表,并根据表中存储的函数指针调用正确的函数实现。这样实现了在运行时根据对象的实际类型调用对应的函数。
示例代码
#include <iostream>
class Base {
public:
virtual void show() {
std::cout << "Base class show function" << std::endl;
}
};
class Derived : public Base {
public:
void show() override {
std::cout << "Derived class show function" << std::endl;
}
};
int main() {
Base* b = new Derived();
b->show(); // 动态绑定,调用 Derived::show
delete b;
return 0;
}
在上述示例代码中,
- 类定义和对象创建:
- 当定义
Base
类时,编译器会创建一个虚函数表Base_vtable
,其中包含指向Base::show
的指针。 - 当定义
Derived
类时,编译器会创建一个虚函数表Derived_vtable
,其中包含指向Derived::show
的指针。
- 当定义
- 对象初始化:
- 当创建
Derived
对象时,编译器会在对象中添加一个隐藏的vptr
,并将其初始化为指向Derived_vtable
。
- 当创建
- 函数调用:
- 当调用
b->show()
时,编译器生成代码,通过b
对象的vptr
访问虚函数表,并调用表中对应的函数指针。由于b
指向一个Derived
对象,其vptr
指向Derived_vtable
,因此调用的是Derived::show
。
- 当调用
1.1.3 总结
虚函数的实现主要依赖于虚函数表(vtable
)和虚函数指针(vptr
)。虚函数表存储了类的虚函数地址,而虚函数指针则指向对象所属类的虚函数表。在运行时,通过虚函数指针查找并调用虚函数表中的函数地址,实现了动态绑定和多态性。这种机制使得在面向对象编程中可以灵活地实现接口重载和运行时行为的改变。
1.2 纯虚函数
纯虚函数是一个在基类中声明但不在该基类中实现的虚函数。它的声明方式是在函数声明的末尾添加 = 0
。纯虚函数使得基类变成抽象类,无法实例化,需要在派生类中实现这些函数才能创建对象。纯虚函数的主要目的是强制派生类提供自己的实现,以便实现多态性。
以下是一个示例代码展示纯虚函数的定义和使用:
#include <iostream>
// 定义一个抽象基类 Shape
class Shape {
public:
// 纯虚函数
virtual void draw() = 0;
};
// 定义一个派生类 Circle 继承自 Shape
class Circle : public Shape {
public:
// 实现纯虚函数 draw
void draw() override {
std::cout << "Drawing a Circle" << std::endl;
}
};
int main() {
// Shape shape; // 错误:无法实例化抽象类
Circle circle;
circle.draw(); // 输出:Drawing a Circle
return 0;
}
在这个示例中:
Shape
类是一个抽象基类,包含一个纯虚函数draw
。Circle
类继承自Shape
,并实现了纯虚函数draw
。- 尝试实例化
Shape
类会导致编译错误,因为它是抽象类。 - 实例化
Circle
类并调用其draw
方法将会输出Drawing a Circle
。
通过这种方式,纯虚函数确保了所有派生类都必须提供具体的实现,从而实现了多态性的要求。
1.3 抽象类
抽象类是不能直接实例化的类,通常用于作为其他类的基类。抽象类至少包含一个纯虚函数,它定义了一种接口要求,强制所有派生类实现这些函数。抽象类本身提供了一些公共功能和接口定义,但无法创建其对象。
抽象类的主要作用包括:
- 定义接口:通过纯虚函数,抽象类可以定义派生类必须实现的接口。
- 提供部分实现:抽象类可以提供一些默认实现,这些实现可以在派生类中继承和使用。
- 实现多态性:通过基类指针或引用调用派生类的实现,实现多态性。
以下是一个抽象类的示例代码:
#include <iostream>
// 定义一个抽象基类 Animal
class Animal {
public:
// 纯虚函数
virtual void makeSound() = 0;
// 普通成员函数
void sleep() {
std::cout << "Sleeping" << std::endl;
}
};
// 定义一个派生类 Dog 继承自 Animal
class Dog : public Animal {
public:
// 实现纯虚函数 makeSound
void makeSound() override {
std::cout << "Woof" << std::endl;
}
};
int main() {
// Animal animal; // 错误:无法实例化抽象类
Dog dog;
dog.makeSound(); // 输出:Woof
dog.sleep(); // 输出:Sleeping
return 0;
}
在这个示例中:
Animal
类是一个抽象基类,因为它包含一个纯虚函数makeSound
。Dog
类继承自Animal
,并实现了纯虚函数makeSound
。- 尝试实例化
Animal
类会导致编译错误,因为它是抽象类。 - 实例化
Dog
类并调用其makeSound
方法将会输出Woof
,调用sleep
方法将会输出Sleeping
。
通过这种方式,抽象类为派生类提供了一个统一的接口,同时也可以提供一些公共功能的实现。
1.4 虚析构函数
虚析构函数是一种特殊的析构函数,用于在基类中声明为虚函数。它的主要目的是确保当通过基类指针删除一个派生类对象时,能够正确调用派生类的析构函数,从而防止资源泄漏。
当我们使用基类指针删除一个派生类对象时,如果基类的析构函数不是虚函数,则只会调用基类的析构函数,而不会调用派生类的析构函数。这可能导致派生类中分配的资源没有被正确释放。因此,为了确保析构函数的正确调用,我们需要将基类的析构函数声明为虚函数。
以下是一个示例代码展示虚析构函数的使用:
#include <iostream>
class Base {
public:
Base() {
std::cout << "Base constructor" << std::endl;
}
// 虚析构函数
virtual ~Base() {
std::cout << "Base destructor" << std::endl;
}
};
class Derived : public Base {
public:
Derived() {
std::cout << "Derived constructor" << std::endl;
}
~Derived() override {
std::cout << "Derived destructor" << std::endl;
}
};
int main() {
Base* obj = new Derived();
delete obj; // 通过基类指针删除派生类对象
return 0;
}
在这个示例中:
Base
类有一个虚析构函数~Base()
。Derived
类继承自Base
,并实现了自己的析构函数~Derived()
。- 在
main
函数中,我们通过基类指针obj
指向一个派生类对象Derived
。 - 当我们通过基类指针删除这个对象时,会正确调用派生类的析构函数
~Derived()
,然后调用基类的析构函数~Base()
。
输出结果将会是:
Base constructor
Derived constructor
Derived destructor
Base destructor
这表明当 delete obj
时,首先调用派生类的析构函数,然后调用基类的析构函数,从而确保资源正确释放。这就是虚析构函数的作用和重要性。
2 封装
把客观事务抽象成类,并且类可以把自己的数据和方法只让可信的类或者对象进行操作,对不可信的对象进行信息隐藏。
3 继承
3.1 基本概念
继承指的是某种类型对象获得另一种类型对象属性或者方法。
示例代码:
class Base {
public:
int value;
void foo() {
std::cout << "Base::foo" << std::endl;
}
};
class Derived : public Base {
// 其他成员
};
- Derived类继承了Base类的value属性和foo方法;
- Derived类内部也可以定义自己的属性和方法。
3.2 继承的访问控制权限
上述示例代码中,Base类的属性和方法都被声明为public,即公有访问。关于访问控制的权限,下面将详细介绍。
C++提供了三个访问修饰符: public 、 private 和 protected 。这些修饰符决定了类中的成员对外部代码的可⻅性和访问权限。
- public 修饰符⽤于指定类中的成员可以被类的外部代码访问。公有成员可以被类外部的任何代码(包括类的实例)访问。
- private 修饰符⽤于指定类中的成员只能被类的内部代码访问。私有成员对外部代码是不可⻅的,只有类内部的成员函数可以访问私有成员。
- protected 修饰符⽤于指定类中的成员可以被类的派⽣类访问。受保护成员对外部代码是不可⻅的,但可以在派⽣类中被访问。
3.3 多重继承
一个类可以从多个基类继承属性和行为。多重继承可能会导致菱形继承问题。
3.3.1 菱形继承
在C++中,菱形继承问题是指当一个类继承了两个直接基类,而这两个基类又继承自同一个基类时,会导致二义性和冗余问题。
考虑以下继承关系:
class Base {
public:
int value;
void foo() {
std::cout << "Base::foo" << std::endl;
}
};
class Derived1 : public Base {
// 其他成员
};
class Derived2 : public Base {
// 其他成员
};
class Derived3 : public Derived1, public Derived2 {
// 其他成员
};
在这个例子中,Derived3通过Derived1和Derived2继承了Base。这就引发了两个主要问题:
- 数据成员的二义性:
Base类中的value成员在Derived3中会有两份拷贝:一份来自Derived1,一份来自Derived2。这样,当我们试图访问value时,就会产生二义性。 - 方法的二义性:
同样,Base类中的foo方法在Derived3中会有两份拷贝,这也会导致二义性。
解决方案:虚继承
C++ 提供了虚继承(virtual inheritance)来解决这些问题。通过虚继承,基类的数据成员和方法在派生类中只会保留一份实例。以下是如何使用虚继承来解决上述问题的示例:
class Base {
public:
int value;
void foo() {
std::cout << "Base::foo" << std::endl;
}
};
class Derived1 : virtual public Base {
// 其他成员
};
class Derived2 : virtual public Base {
// 其他成员
};
class Derived3 : public Derived1, public Derived2 {
// 其他成员
};
在这个例子中,Derived1和Derived2都虚继承了Base。这意味着Base的成员(包括数据成员和方法)在Derived3中只有一份实例。下面是一个示例代码:
#include <iostream>
class Base {
public:
int value;
void foo() {
std::cout << "Base::foo" << std::endl;
}
};
class Derived1 : virtual public Base {
// 其他成员
};
class Derived2 : virtual public Base {
// 其他成员
};
class Derived3 : public Derived1, public Derived2 {
public:
void bar() {
value = 10; // 无二义性
foo(); // 无二义性
}
};
int main() {
Derived3 d3;
d3.bar(); // 输出: Base::foo
std::cout << d3.value << std::endl; // 输出: 10
return 0;
}
总结
通过虚继承,C++可以有效地解决菱形继承带来的二义性问题。使用虚继承时,基类的数据成员和方法在派生类中只会保留一份实例,从而避免了冗余和二义性。
4 多态
多态指的是相同的操作作用于不同的对象时,会产生不同的效果。
实现多态有两种常见的方式:
- 函数重写(override)
- 函数重载(overload)
4.1 函数重写(Override)
函数重写是指在派生类中重新定义基类中的虚函数。重写的函数必须具有与基类中虚函数相同的名称、返回类型和参数列表。使用关键字override
可以显式指示函数是重写基类中的虚函数,从而提高代码的可读性和安全性。如果基类中的函数发生变化(例如参数列表改变),编译器会提示错误,从而避免潜在的错误。
示例代码
#include <iostream>
class Base {
public:
virtual void display() const {
std::cout << "Base display" << std::endl;
}
virtual ~Base() = default; // 虚析构函数
};
class Derived : public Base {
public:
void display() const override { // 使用 override 关键字
std::cout << "Derived display" << std::endl;
}
};
int main() {
Base* b = new Derived();
b->display(); // 输出: Derived display
delete b;
return 0;
}
在这个例子中,Derived
类重写了Base
类的display
函数,并使用override
关键字来显式表明这是一个重写操作。
4.2 函数重载(Overload)
函数重载是指在同一个作用域中定义多个同名但参数列表不同的函数。重载的函数可以有不同的参数类型、参数数量或参数顺序。编译器通过函数调用时传递的参数来确定调用哪个重载函数。函数重载不涉及继承关系,只在同一个类中或者同一个作用域内进行。
示例代码
#include <iostream>
class Printer {
public:
void print(int i) {
std::cout << "Printing int: " << i << std::endl;
}
void print(double f) {
std::cout << "Printing double: " << f << std::endl;
}
void print(const std::string& s) {
std::cout << "Printing string: " << s << std::endl;
}
};
int main() {
Printer p;
p.print(10); // 输出: Printing int: 10
p.print(3.14); // 输出: Printing double: 3.14
p.print("Hello World"); // 输出: Printing string: Hello World
return 0;
}
在这个例子中,Printer
类中定义了三个同名的print
函数,但它们的参数列表不同,因此它们是函数重载。
4.3 总结
- 函数重写(Override):
- 发生在继承关系中。
- 派生类重新定义基类中的虚函数。
- 使用
override
关键字显式表明重写操作。 - 函数签名必须与基类中的虚函数一致。
- 函数重载(Overload):
- 发生在同一个作用域中(同一个类或同一个函数内)。
- 定义多个同名但参数列表不同的函数。
- 不涉及继承关系。
- 函数签名(参数类型、数量或顺序)必须不同。