在设计基类时,有时候基类只代表一个笼统的分类概念,自身没法写出有意义的默认代码。比如圆形和矩形都有明确的面积公式,可一个不知道具体形状的 “图形(Shape)” 基类,到底应该怎么计算面积呢?
与其在基类的虚函数里硬写一段占位代码,不如让基类只规定函数格式。在 C++ 中,我们可以把这类函数声明为 “纯虚函数(Pure Virtual Function)”,并把包含它的类称为 “抽象类(Abstract Class)”。
纯虚函数与抽象类的定义
在 C++ 中,如果我们想把基类中的虚函数声明为纯虚函数,只需要去掉它的函数体花括号 {},并在声明末尾加上 = 0 就可以了。
语法:
class 基类名 {
public:
virtual 返回值类型 函数名(参数列表) = 0;
};说明:
这里的 = 0 是声明纯虚函数的固定语法,小伙伴们不要把它误解成 “把函数赋值为 0”。它只是一个特殊标记,表示该函数在基类中只规定接口格式,真正的函数体必须留给派生类去编写。如果成员函数本身带有 const 修饰,那么 = 0 要固定写在 const 的后面,比如 virtual double getArea() const = 0;。
只要一个类中包含纯虚函数,这个类就称为 “抽象类”。对于抽象类来说,它具有以下 2 个重要特点:
- 不能直接创建对象:因为抽象类里的纯虚函数还没有具体实现代码,如果直接创建对象(如
Shape s;),调用该函数时程序根本不知道应该执行什么,所以编译器禁止直接创建抽象类对象。 - 可以定义引用或指针:虽然不能创建抽象类自身的对象,但我们依然可以定义抽象类的引用(如
Shape&)或指针(如Shape*)去指向派生类对象,这是实现多态最常用的做法。
派生类实现纯虚函数
抽象基类把纯虚函数的接口规定好之后,继承它的派生类就可以根据自身特点,通过函数重写把纯虚函数的具体代码补齐。只有当派生类实现了基类中所有的纯虚函数后,该派生类才能正常创建对象。下面我们来看一个例子。
示例 1:定义并实现纯虚函数
#include <iostream>
// 抽象图形基类
class Shape {
public:
virtual double getArea() = 0;
};
// 圆形类:实现纯虚函数
class Circle : public Shape {
public:
double radius;
Circle(double r) : radius(r) {}
double getArea() {
return 3.14 * radius * radius;
}
};
// 矩形类:实现纯虚函数
class Rectangle : public Shape {
public:
double width;
double height;
Rectangle(double w, double h) : width(w), height(h) {}
double getArea() {
return width * height;
}
};
void printArea(Shape& shape) {
std::cout << "图形面积:" << shape.getArea() << '\n';
}
int main() {
Circle circle(5.0);
Rectangle rect(4.0, 6.0);
printArea(circle);
printArea(rect);
return 0;
}运行结果如下。
图形面积:78.5
图形面积:24分析:
在这个例子中,我们将 Shape 基类中的 getArea() 声明成了纯虚函数,不需要在基类里硬写一句无意义的 return 0.0;。此时 Shape 是一个抽象类,专门用来规定所有具体图形都必须提供 getArea() 这项功能。
派生类 Circle 和 Rectangle 继承 Shape 后,各自补齐了 getArea() 的面积计算公式,因此它们不再是抽象类,可以正常创建出 circle 和 rect 对象。
而对于抽象类 Shape 本身,我们只能像 printArea(Shape& shape) 那样使用它的引用或指针。如果尝试直接创建 Shape 对象,编译器就会报错:
Shape s; // 编译报错:抽象类不能直接创建对象那如果某个派生类继承了抽象类,却漏掉了里面的纯虚函数没有实现,会发生什么呢?我们可以看一下下面这个例子。
示例 2:未实现纯虚函数报错
#include <iostream>
class Shape {
public:
virtual double getArea() = 0;
};
class Triangle : public Shape {
public:
// 未实现基类的纯虚函数 getArea()
};
int main() {
Triangle tri; // 编译报错
return 0;
}运行结果如下。
(报错)error: cannot declare variable 'tri' to be of abstract type 'Triangle'分析:
从报错信息可以看到,编译器提示不能将变量 tri 声明为抽象类型 Triangle。这是因为 Triangle 继承了抽象类 Shape,却没有实现基类的纯虚函数 getArea()。
此时相当于 Triangle 内部依然保留着未完成的纯虚函数,所以 Triangle 自己也会变成抽象类,执行 Triangle tri; 创建对象时自然就会编译报错。
注意 如果一个抽象基类中包含多个纯虚函数,派生类必须把这些纯虚函数全部实现出来,才能成为可正常创建对象的具体类。只要漏掉其中任何一个,该派生类本身就依然是抽象类。
抽象类中可以定义普通成员吗?
初学抽象类时,有些小伙伴可能会误以为 “抽象类里面只能写 = 0 的纯虚函数”。实际上并非如此,抽象类内部同样可以定义普通的成员变量、普通成员函数,甚至还可以定义构造函数。
也就是说,如果某项属性或操作对所有派生类来说都是一样的,我们可以直接把它写在抽象基类中供子类复用。只有那些各个派生类做法不同的功能,才需要声明为纯虚函数。请看下面例子。
示例 3:抽象类包含普通成员
#include <iostream>
#include <string>
class Appliance {
public:
std::string brand;
Appliance(std::string b) : brand(b) {}
// 普通成员函数:所有家电共用的操作
void powerOn() {
std::cout << brand << "已接通电源\n";
}
// 纯虚函数:交由派生类各自实现
virtual void work() = 0;
};
class Washer : public Appliance {
public:
Washer(std::string b) : Appliance(b) {}
void work() {
std::cout << brand << "洗衣机开始洗涤衣物\n";
}
};
int main() {
Washer washer("海尔");
Appliance& app = washer;
app.powerOn();
app.work();
return 0;
}运行结果如下。
海尔已接通电源
海尔洗衣机开始洗涤衣物分析:
在上面代码中,所有家电共有的品牌属性 brand 和通电操作 powerOn() 都直接写在了抽象基类 Appliance 中,只有具体工作方式不同的 work() 声明为了纯虚函数。派生类 Washer 只需要把 work() 实现出来,就能直接复用基类的公共成员。
可能有些小伙伴会问:前面不是刚说过抽象类不能创建对象吗,为什么 Appliance 里还能定义构造函数呢?
其实,这个构造函数并不是用来单独创建 Appliance 对象的,而是专门留给子类 Washer 在初始化列表中调用的(即 Appliance(b)),用来完成基类成员变量 brand 的初始化。
常见问题
1. 在实际开发中,什么时候使用普通基类,什么时候使用抽象类?
普通基类和抽象类都可以作为多态的基类,我们可以根据下面两点来判断:
普通基类:当基类本身代表一个可以独立存在的具体事物(如普通订单 Order),并且它的虚函数也有合理的默认做法时,写成普通虚函数即可。
抽象类:当基类只是一个笼统的分类概念(如图形 Shape、家电 Appliance),没法写出合理的默认实现,并且希望强制所有子类都按统一格式实现某个函数时,就将该函数声明为纯虚函数,把基类设计成抽象类。
2. C++ 中没有 interface 关键字,应该怎么定义 “接口”?
Java、C# 等语言专门提供了 interface 关键字来定义接口,而 C++ 中没有这个关键字。在实际开发中,如果一个类不定义普通成员变量,而是把业务函数全部声明为 = 0 的纯虚函数,这样的抽象类在 C++ 里就充当了纯粹的 “接口类”。
