C++中抽象类和接口的区别介绍
前言
先说一个前提:C++ 没有interface这个关键字。Java、C# 里的接口是一种独立的语言构造,而 C++ 里的「接口」只是一种编码约定——通常指「只包含纯虚函数、没有数据成员、没有非静态成员函数实现」的类。它仍然是类,仍然遵循 C++ 的继承规则,只是被人为地限制了形态。
常见误解有两个。第一,把「含有纯虚函数的类」直接等同于接口。抽象类(abstract class)只需要有一个纯虚函数就成立,它完全可以同时拥有数据成员、普通成员函数、构造函数;这种「半成品基类」是模板方法模式的基础,和只描述契约的接口不是一回事。第二,以为抽象类不能有构造函数。抽象类不能被直接实例化,但它的构造函数会被派生类调用,用来初始化基类子对象。
本文先讲抽象类的语言规则,再讲接口在 C++ 里如何约定,最后对比两者的设计取舍与常见错误。示例以 C++17 为基准。
一、抽象类的语言规则
只要类里声明了至少一个纯虚函数(pure virtual function),这个类就是抽象类,不能用它创建对象。纯虚函数的写法是在声明末尾加= 0:
// 适用于 C++17
#include <string>
#include <utility>
class Shape {
public:
virtual ~Shape() = default; // 多态基类必须有虚析构
virtual double area() const = 0;
virtual double perimeter() const = 0;
void set_name(std::string n) { name_ = std::move(n); }
const std::string& name() const { return name_; }
private:
std::string name_; // 可以有数据成员
};Shape是抽象类:它有数据成员、有普通成员函数、有析构函数,但只要有纯虚函数,就不能写Shape s;。派生类必须实现所有纯虚函数,否则它自己也是抽象类。
纯虚函数有一个容易被忽略的特性:它可以有函数体。语法上写成virtual void f() = 0;之后,仍然可以在类外定义void Shape::f() { ... },派生类通过Shape::f()显式调用。这个函数体不会让类变成可实例化的,但它可以给派生类提供一份公共实现。这一点的实用价值不大,知道即可,不要用它替代普通的虚函数。
另一个细节是「抽象」不一定要靠纯虚函数实现。把构造函数声明为protected,类同样无法被外部实例化,但它不是标准意义上的抽象类,编译器不会阻止你在成员函数或友元里创建它。真正的抽象类判定只看纯虚函数。
二、接口在 C++ 里的写法
C++ 的接口约定通常长这样:全部是纯虚函数、没有数据成员、析构函数为虚且可以为默认、不提供任何实现。命名上有的项目加I前缀,有的用Interface后缀,标准库本身不采用这种命名(例如std::ostream不是纯接口)。
// 适用于 C++17
class Drawable {
public:
virtual ~Drawable() = default;
virtual void draw() const = 0;
};
class Serializable {
public:
virtual ~Serializable() = default;
virtual std::string serialize() const = 0;
};一个具体类可以同时实现多个这样的接口:
// 适用于 C++17
#include <string>
class Circle : public Shape, public Drawable {
public:
explicit Circle(double r) : radius_(r) {}
double area() const override { return 3.141592653589793 * radius_ * radius_; }
double perimeter() const override { return 2.0 * 3.141592653589793 * radius_; }
void draw() const override { /* 绘制逻辑 */ }
private:
double radius_;
};这里用了多继承,但两个基类都没有数据成员,只有虚函数,因此不存在「菱形继承」的状态重复问题,只需要注意虚函数表指针的布局由实现决定。override关键字让编译器检查函数确实覆盖了基类的虚函数,写错签名时直接报错,而不是悄悄定义一个新函数。
接口的析构函数必须是虚的,原因和抽象类一样:通过基类指针delete派生对象时,如果析构函数不是虚的,行为是未定义(undefined behavior, UB)。接口通常会被std::unique_ptr<Interface>持有,这条规则没有例外。
三、两者的实质区别
| 维度 | 抽象类 | 接口(约定) |
|---|
| 语言构造 | 有纯虚函数即为抽象类 | 无专门关键字,靠约定 |
| 数据成员 | 可以有 | 约定为无 |
| 普通成员函数 | 可以有,常作为公共实现 | 约定为无 |
| 构造函数 | 可以有,供派生类调用 | 通常不需要 |
| 继承数量 | 一般单继承 | 常被多继承实现 |
| 表达的关系 | 「是一种」+ 复用实现 | 「能做什么」的契约 |
| 演化成本 | 加纯虚函数会破坏所有派生类 | 同样会破坏实现者 |
最关键的区别不在语法,而在设计意图。抽象类表达的是「这是一类东西,共享一部分实现」,它把共性上提到基类,派生类复用;接口表达的是「这个类型承诺提供哪些操作」,它不关心实现,也不携带状态。一个类只能有一个「主要身份」,所以抽象类通常单继承;但一个类可以承诺很多种能力,所以接口常被多继承。
从演化角度看,两者都很脆弱:往基类里加一个纯虚函数,所有已有派生类都会变成抽象类,编译失败。需要可演化的接口时,常见的做法是提供带默认实现的非纯虚函数,或者用「接口 + 版本号」的方式新开一个接口,而不是修改旧的。C++ 没有 Java 那种default方法,但非纯虚函数可以达到类似效果,代价是接口不再「纯」。
四、完整示例:多态调用与对象管理
下面用抽象类加接口演示一个完整的使用场景:一组图形既能计算面积,也能被绘制,统一存放在std::vector<std::unique_ptr<Shape>>中。
// 适用于 C++17
#include <iostream>
#include <memory>
#include <string>
#include <vector>
class Shape {
public:
virtual ~Shape() = default;
virtual double area() const = 0;
virtual std::string name() const = 0;
};
class Drawable {
public:
virtual ~Drawable() = default;
virtual void draw() const = 0;
};
class Circle : public Shape, public Drawable {
public:
explicit Circle(double r) : radius_(r) {}
double area() const override { return 3.141592653589793 * radius_ * radius_; }
std::string name() const override { return "circle"; }
void draw() const override { std::cout << "draw circle\n"; }
private:
double radius_;
};
class Rectangle : public Shape {
public:
Rectangle(double w, double h) : width_(w), height_(h) {}
double area() const override { return width_ * height_; }
std::string name() const override { return "rectangle"; }
private:
double width_;
double height_;
};
int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(1.0));
shapes.push_back(std::make_unique<Rectangle>(2.0, 3.0));
for (const auto& s : shapes) {
std::cout << s->name() << ' ' << s->area() << '\n';
// 运行期查询是否实现某个接口
if (auto* d = dynamic_cast<Drawable*>(s.get())) {
d->draw();
}
}
return 0;
}这段代码的关键点:std::make_unique<Circle>返回unique_ptr<Circle>,放入vector<unique_ptr<Shape>>时发生隐式向上转换,这是安全的;Shape的虚析构保证delete时调用Circle的析构;dynamic_cast<Drawable*>在运行期检查对象是否真的实现了该接口,失败返回空指针。dynamic_cast要求基类是多态的(至少有一个虚函数),这里满足。
dynamic_cast有运行期开销,且依赖 RTTI(run-time type information)。如果项目关闭了 RTTI,就只能改用访问者模式或给接口加一个类型标识函数。不要把dynamic_cast当成常规分支手段,接口设计得当时,大多数场景可以直接通过基类指针调用。
对象切片(object slicing)是另一个必须避开的坑:如果把Circle按值赋给Shape类型的变量(假设Shape可实例化),派生部分会被切掉,只剩基类子对象。抽象类不能被实例化,天然避免了这种赋值,但把派生类按值传给「接受基类引用」的函数不会有问题——按引用或指针传递才保留多态。
常见坑点
坑点一:多态基类的析构函数不是虚函数。
❌class Shape { public: ~Shape() {} virtual double area() const = 0; };,通过基类指针delete是 UB。
✅ 写virtual ~Shape() = default;。
坑点二:以为抽象类不能有构造函数。
❌ 为了「让基类可构造」,把纯虚函数改成普通虚函数并给出空实现。
✅ 抽象类可以有构造函数,派生类构造时自动调用它初始化基类子对象。
坑点三:在构造函数或析构函数里调用纯虚函数。
❌ 基类构造函数调用area(),此时派生类部分尚未构造,行为是 UB。
✅ 构造完成后通过外部函数调用,或把需要多态的行为推迟到初始化结束。
坑点四:把抽象类按值传递。
❌void f(Shape s);,要么编译失败(抽象类),要么在可实例化的基类上发生对象切片。
✅ 用const Shape&或Shape*/std::unique_ptr<Shape>。
坑点五:接口里放了数据成员。
❌ 名为IDrawable的接口里塞了int cached_id_;,所有实现者都被迫携带这份状态。
✅ 接口只保留纯虚函数;需要状态时放到抽象类或实现类里。
坑点六:以为 C++ 有interface关键字。
❌ 写interface IShape { ... };,编译失败。
✅ 用class IShape { public: virtual ~IShape() = default; virtual ... = 0; };。
坑点七:多继承时忘记虚继承导致数据重复。
❌ 两个基类都继承自同一个有数据成员的基类,派生类里出现两份基类子对象。
✅ 接口不携带数据,通常不会触发;若确实需要菱形结构,用virtual继承并明确初始化。
坑点八:覆盖函数签名写错却没有override。
❌ 基类virtual void draw() const;,派生类写成void draw();,没有覆盖,调用时仍走基类版本。
✅ 所有覆盖都加override,让编译器检查签名。
总结
| 要点 | 抽象类 | 接口 |
|---|
| 判定 | 含纯虚函数 | 约定:全纯虚、无状态 |
| 状态 | 可携带 | 不携带 |
| 实现复用 | 可以 | 不提供 |
| 继承 | 通常单继承 | 常多继承 |
| 析构 | 必须虚 | 必须虚 |
| 适用 | 共享实现的类层次 | 描述能力的契约 |
抽象类与接口的区别,本质上是「继承实现」与「继承契约」的区别。C++ 用同一套语法同时支持两者,所以更需要设计者自己想清楚:这个基类是为了复用代码,还是为了规定能力。前者应当是抽象类,允许有状态和实现;后者应当是纯接口,保持无状态、全虚函数、虚析构。把这条界线划清,多继承与dynamic_cast的使用就会自然收敛。