C++抽象类的定义与使用
C++抽象类的定义
什么是抽象类
在C++中,抽象类是一种特殊的类,它不能被实例化,即不能直接创建对象。抽象类的主要目的是为其他类提供一个通用的基类,它定义了一组接口,但部分或全部接口可能没有具体的实现。抽象类存在的意义在于为派生类提供一个统一的框架,使得派生类可以根据自身需求来实现这些接口。
例如,在一个图形绘制的程序中,我们可能有一个基类 Shape
,它代表所有图形的抽象概念。Shape
类可能包含一些诸如计算面积、周长等的方法声明,但由于不同图形(如圆形、矩形、三角形)计算面积和周长的方式不同,所以在 Shape
类中这些方法无法给出具体实现。此时,Shape
类就可以定义为抽象类。
抽象类的定义方式
在C++中,通过在类中定义至少一个纯虚函数来使该类成为抽象类。纯虚函数是一种特殊的虚函数,它只有声明,没有定义(实现),其声明形式为在虚函数声明的末尾加上 = 0
。
下面是一个简单的抽象类定义示例:
class Shape {
public:
// 纯虚函数,计算面积
virtual double area() const = 0;
// 纯虚函数,计算周长
virtual double perimeter() const = 0;
};
在上述代码中,Shape
类包含两个纯虚函数 area
和 perimeter
,这使得 Shape
类成为一个抽象类。任何试图直接创建 Shape
对象的操作都会导致编译错误。
抽象类的特点
- 不能实例化:如前面所述,抽象类不能直接创建对象。例如,以下代码会导致编译错误:
Shape s; // 错误,Shape是抽象类,不能实例化
- 可以包含数据成员和非纯虚函数:抽象类并非只能包含纯虚函数,它可以拥有数据成员和普通的虚函数或非虚函数。例如:
class Shape {
private:
std::string name;
public:
Shape(const std::string& n) : name(n) {}
std::string getName() const {
return name;
}
// 纯虚函数,计算面积
virtual double area() const = 0;
// 纯虚函数,计算周长
virtual double perimeter() const = 0;
};
在这个改进的 Shape
类中,我们添加了一个私有数据成员 name
用于存储图形的名称,以及一个非虚函数 getName
用于获取该名称。
- 作为基类被继承:抽象类主要的用途是作为基类被其他类继承。派生类必须实现抽象类中的所有纯虚函数,否则派生类也会成为抽象类。
C++抽象类的使用
通过派生类实现抽象类的接口
当一个类继承自抽象类时,它必须实现抽象类中的所有纯虚函数,否则该派生类也会成为抽象类。以 Shape
抽象类为例,我们可以定义 Circle
(圆形)和 Rectangle
(矩形)两个派生类来实现 Shape
类中的纯虚函数。
- 定义
Circle
类:
#include <iostream>
#include <cmath>
class Shape {
public:
virtual double area() const = 0;
virtual double perimeter() const = 0;
};
class Circle : public Shape {
private:
double radius;
public:
Circle(double r) : radius(r) {}
double area() const override {
return M_PI * radius * radius;
}
double perimeter() const override {
return 2 * M_PI * radius;
}
};
在 Circle
类中,我们实现了 Shape
类中的 area
和 perimeter
纯虚函数。override
关键字用于显式表明该函数是重写基类的虚函数,这有助于避免因函数签名错误导致的意外情况。
- 定义
Rectangle
类:
class Rectangle : public Shape {
private:
double width;
double height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
double area() const override {
return width * height;
}
double perimeter() const override {
return 2 * (width + height);
}
};
同样,Rectangle
类也实现了 Shape
类中的纯虚函数。
使用抽象类指针和引用
由于抽象类不能实例化对象,我们通常使用抽象类的指针或引用指向派生类对象,通过这种方式来实现多态性。多态性使得我们可以根据对象的实际类型来调用合适的函数实现。
以下是一个使用抽象类指针的示例:
int main() {
Shape* shapes[2];
shapes[0] = new Circle(5.0);
shapes[1] = new Rectangle(4.0, 6.0);
for (int i = 0; i < 2; ++i) {
std::cout << "Shape " << i + 1 << ": " << std::endl;
std::cout << " Area: " << shapes[i]->area() << std::endl;
std::cout << " Perimeter: " << shapes[i]->perimeter() << std::endl;
delete shapes[i];
}
return 0;
}
在上述代码中,我们创建了一个 Shape
类型的指针数组 shapes
,然后分别将 Circle
和 Rectangle
对象的地址赋给数组元素。通过遍历这个指针数组,我们调用 area
和 perimeter
函数,实际调用的是每个派生类中重写的具体实现。
使用抽象类引用的方式类似,以下是一个简单示例:
void printShapeInfo(const Shape& shape) {
std::cout << "Area: " << shape.area() << std::endl;
std::cout << "Perimeter: " << shape.perimeter() << std::endl;
}
int main() {
Circle circle(3.0);
Rectangle rectangle(2.0, 5.0);
printShapeInfo(circle);
printShapeInfo(rectangle);
return 0;
}
在这个示例中,printShapeInfo
函数接受一个 Shape
类型的常量引用,通过传递不同派生类的对象,函数可以根据对象的实际类型调用相应的 area
和 perimeter
函数实现。
抽象类在设计模式中的应用
- 模板方法模式:模板方法模式是一种行为型设计模式,它在一个方法中定义一个算法的骨架,而将一些步骤延迟到子类中实现。抽象类在模板方法模式中扮演着重要角色,它定义了算法的整体框架,包含一些抽象方法(由子类实现)和具体方法。
例如,假设我们有一个制作饮料的程序,制作饮料的一般步骤包括烧水、泡制饮料、倒入杯子等。不同的饮料(如咖啡和茶)泡制的方式不同,但烧水和倒入杯子的步骤基本相同。我们可以使用模板方法模式来设计这个程序。
首先定义一个抽象类 Beverage
:
class Beverage {
public:
// 模板方法,定义制作饮料的整体流程
void prepareRecipe() {
boilWater();
brew();
pourInCup();
if (customerWantsCondiments()) {
addCondiments();
}
}
private:
void boilWater() {
std::cout << "Boiling water" << std::endl;
}
void pourInCup() {
std::cout << "Pouring into cup" << std::endl;
}
// 抽象方法,由子类实现
virtual void brew() = 0;
virtual void addCondiments() = 0;
// 钩子方法,子类可以选择重写
virtual bool customerWantsCondiments() {
return true;
}
};
然后定义 Coffee
和 Tea
两个派生类:
class Coffee : public Beverage {
public:
void brew() override {
std::cout << "Brewing coffee grounds" << std::endl;
}
void addCondiments() override {
std::cout << "Adding sugar and milk" << std::endl;
}
bool customerWantsCondiments() override {
char answer;
std::cout << "Would you like milk and sugar with your coffee (y/n)? ";
std::cin >> answer;
return (answer == 'y' || answer == 'Y');
}
};
class Tea : public Beverage {
public:
void brew() override {
std::cout << "Steeping the tea" << std::endl;
}
void addCondiments() override {
std::cout << "Adding lemon" << std::endl;
}
bool customerWantsCondiments() override {
char answer;
std::cout << "Would you like lemon with your tea (y/n)? ";
std::cin >> answer;
return (answer == 'y' || answer == 'Y');
}
};
在 main
函数中可以这样使用:
int main() {
Coffee coffee;
Tea tea;
std::cout << "Making coffee..." << std::endl;
coffee.prepareRecipe();
std::cout << "\nMaking tea..." << std::endl;
tea.prepareRecipe();
return 0;
}
在这个例子中,Beverage
抽象类定义了制作饮料的模板方法 prepareRecipe
,其中 brew
和 addCondiments
是抽象方法,由 Coffee
和 Tea
子类实现。customerWantsCondiments
是一个钩子方法,子类可以根据需要重写来决定是否添加调料。
- 策略模式:策略模式定义了一系列算法,将每个算法封装起来,并使它们可以相互替换。抽象类在策略模式中通常作为所有具体策略类的基类,定义了一个通用的接口。
例如,假设我们有一个排序程序,可以使用不同的排序算法(如冒泡排序、快速排序)。我们可以使用策略模式来设计这个程序。
首先定义一个抽象类 SortStrategy
:
class SortStrategy {
public:
virtual void sort(int* arr, int n) = 0;
};
然后定义具体的排序策略类,如 BubbleSortStrategy
和 QuickSortStrategy
:
class BubbleSortStrategy : public SortStrategy {
public:
void sort(int* arr, int n) override {
for (int i = 0; i < n - 1; ++i) {
for (int j = 0; j < n - i - 1; ++j) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
};
class QuickSortStrategy : public SortStrategy {
private:
int partition(int* arr, int low, int high) {
int pivot = arr[high];
int i = low - 1;
for (int j = low; j < high; ++j) {
if (arr[j] <= pivot) {
i++;
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
int temp = arr[i + 1];
arr[i + 1] = arr[high];
arr[high] = temp;
return i + 1;
}
void quickSort(int* arr, int low, int high) {
if (low < high) {
int pi = partition(arr, low, high);
quickSort(arr, low, pi - 1);
quickSort(arr, pi + 1, high);
}
}
public:
void sort(int* arr, int n) override {
quickSort(arr, 0, n - 1);
}
};
接着定义一个 SortContext
类,它使用 SortStrategy
来进行排序:
class SortContext {
private:
SortStrategy* strategy;
public:
SortContext(SortStrategy* s) : strategy(s) {}
void sortArray(int* arr, int n) {
strategy->sort(arr, n);
}
};
在 main
函数中可以这样使用:
int main() {
int arr[] = {64, 34, 25, 12, 22, 11, 90};
int n = sizeof(arr) / sizeof(arr[0]);
SortStrategy* bubbleSort = new BubbleSortStrategy();
SortContext context1(bubbleSort);
context1.sortArray(arr, n);
std::cout << "Sorted array using bubble sort: ";
for (int i = 0; i < n; ++i) {
std::cout << arr[i] << " ";
}
std::cout << std::endl;
SortStrategy* quickSort = new QuickSortStrategy();
SortContext context2(quickSort);
context2.sortArray(arr, n);
std::cout << "Sorted array using quick sort: ";
for (int i = 0; i < n; ++i) {
std::cout << arr[i] << " ";
}
std::cout << std::endl;
delete bubbleSort;
delete quickSort;
return 0;
}
在这个例子中,SortStrategy
抽象类定义了排序的接口 sort
,具体的排序算法类 BubbleSortStrategy
和 QuickSortStrategy
继承自 SortStrategy
并实现了 sort
方法。SortContext
类通过组合 SortStrategy
对象来选择具体的排序策略进行排序。
抽象类与接口的关系
接口的概念
在C++中,虽然没有像Java或C#那样明确的接口关键字,但我们可以通过抽象类来模拟接口的概念。接口本质上是一种特殊的抽象类型,它只包含方法的声明,不包含方法的实现,并且所有方法默认都是纯虚函数。接口的主要目的是定义一组行为规范,实现接口的类必须提供这些行为的具体实现。
用抽象类模拟接口
我们可以通过定义一个只包含纯虚函数的抽象类来模拟接口。例如,假设我们有一个 Drawable
接口,它定义了图形绘制的相关操作:
class Drawable {
public:
virtual void draw() const = 0;
virtual void resize(int newWidth, int newHeight) = 0;
};
任何想要实现图形绘制功能的类都必须继承自 Drawable
并实现其中的纯虚函数。比如 Button
类和 Panel
类:
class Button : public Drawable {
public:
void draw() const override {
std::cout << "Drawing a button" << std::endl;
}
void resize(int newWidth, int newHeight) override {
std::cout << "Resizing button to " << newWidth << " x " << newHeight << std::endl;
}
};
class Panel : public Drawable {
public:
void draw() const override {
std::cout << "Drawing a panel" << std::endl;
}
void resize(int newWidth, int newHeight) override {
std::cout << "Resizing panel to " << newWidth << " x " << newHeight << std::endl;
}
};
通过这种方式,我们可以将 Drawable
看作是一个接口,Button
和 Panel
类实现了这个接口所定义的行为。
抽象类与接口的区别
- 抽象类可以包含数据成员和非纯虚函数:如前面所述,抽象类可以有数据成员和非纯虚函数,这些数据成员和非纯虚函数可以为派生类提供一些通用的实现或状态存储。而接口通常只包含纯虚函数,不包含数据成员和非纯虚函数。
- 继承关系:一个类只能继承自一个抽象类,但可以实现多个接口。这使得接口在实现多继承方面更加灵活。例如,一个
SmartPhone
类可能既需要实现Drawable
接口用于图形绘制,又需要实现Connectable
接口用于网络连接等功能。 - 设计目的:抽象类更多地用于表示一种 “is - a” 的关系,即派生类是抽象类的一种具体类型。而接口更多地用于表示一种 “can - do” 的关系,即实现接口的类能够执行接口定义的操作。
抽象类的注意事项
析构函数的设计
当抽象类作为基类时,通常应该将其析构函数声明为虚函数。这是因为当通过基类指针删除派生类对象时,如果基类的析构函数不是虚函数,那么只会调用基类的析构函数,而不会调用派生类的析构函数,这可能导致内存泄漏。
例如,考虑以下代码:
class Shape {
public:
virtual double area() const = 0;
// 非虚析构函数,这是错误的做法
~Shape() {
std::cout << "Shape destructor" << std::endl;
}
};
class Circle : public Shape {
private:
double* radiusPtr;
public:
Circle(double r) {
radiusPtr = new double(r);
}
double area() const override {
return M_PI * *radiusPtr * *radiusPtr;
}
~Circle() {
std::cout << "Circle destructor" << std::endl;
delete radiusPtr;
}
};
在 main
函数中如果这样使用:
int main() {
Shape* shape = new Circle(5.0);
delete shape;
return 0;
}
由于 Shape
类的析构函数不是虚函数,当 delete shape
执行时,只会调用 Shape
类的析构函数,而不会调用 Circle
类的析构函数,这就导致 Circle
类中分配的 radiusPtr
内存没有被释放,从而产生内存泄漏。
正确的做法是将 Shape
类的析构函数声明为虚函数:
class Shape {
public:
virtual double area() const = 0;
// 虚析构函数
virtual ~Shape() {
std::cout << "Shape destructor" << std::endl;
}
};
这样,当通过 Shape
指针删除 Circle
对象时,会先调用 Circle
类的析构函数,再调用 Shape
类的析构函数,确保内存正确释放。
纯虚函数的重写要求
派生类必须实现抽象类中的所有纯虚函数,否则派生类也会成为抽象类。在重写纯虚函数时,函数的签名(包括参数列表和返回类型)必须与基类中的纯虚函数完全一致。另外,使用 override
关键字可以增强代码的可读性和可维护性,同时有助于编译器检测重写是否正确。
例如,以下代码中 Rectangle
类重写 Shape
类的纯虚函数时,函数签名必须匹配:
class Shape {
public:
virtual double area() const = 0;
virtual double perimeter() const = 0;
};
class Rectangle : public Shape {
private:
double width;
double height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
// 正确重写area函数
double area() const override {
return width * height;
}
// 错误重写perimeter函数,返回类型不匹配
int perimeter() const override {
return 2 * (width + height);
}
};
在上述代码中,Rectangle
类对 perimeter
函数的重写是错误的,因为返回类型与基类中的纯虚函数 perimeter
不一致,这会导致编译错误。
抽象类与多重继承
在C++中,一个类可以从多个抽象类继承,这在某些情况下可以提供更大的灵活性,但也会带来一些复杂性,如菱形继承问题。
例如,假设我们有两个抽象类 Printable
和 Serializable
:
class Printable {
public:
virtual void print() const = 0;
};
class Serializable {
public:
virtual void serialize() const = 0;
};
一个 Document
类可以同时继承自这两个抽象类:
class Document : public Printable, public Serializable {
public:
void print() const override {
std::cout << "Printing document" << std::endl;
}
void serialize() const override {
std::cout << "Serializing document" << std::endl;
}
};
然而,如果多个基类中有同名的成员,可能会导致命名冲突。例如,如果 Printable
和 Serializable
都有一个名为 id
的成员,那么在 Document
类中访问 id
时就需要明确指定是从哪个基类继承的 id
,如 Printable::id
或 Serializable::id
。此外,菱形继承可能会导致数据冗余和歧义等问题,需要通过虚继承等技术来解决。
在使用多重继承抽象类时,需要谨慎设计,权衡其带来的灵活性和复杂性,以确保代码的可维护性和正确性。