C++接口继承和实现继承
C++的继承
继承的概念是从父哪里得到了什么,儿子继承了父亲的财产,这偏重于特性的继承和代码的复用。而C++类的继承的更关注的应该是对象概念的泛化和实现,即父类是一般性,子类是特化,每一个子类都拥有父类一样的特性。C++中继承的关系表示的是is a的关系,即Derive is a kind of Base,里氏替换原则正是基于这一点,任何用父类作为参数的地方,均应该能用子类实现替换。
在实际开发过程中,我们们常常能够见到两种关于继承使用的范式:
一种是基类定义接口,子类重写实现接口
// 接口类(抽象基类)
class Drawable {
public:
virtual void draw() const = 0; // 纯虚函数
virtual ~Drawable() = default; // 虚析构函数
};
// 实现类
class Circle : public Drawable {
public:
void draw() const override {
std::cout << "Drawing Circle\n";
}
};
另一种是,将公共逻辑提升到基类中,子类可复用基类函数
// 带实现的基类
class Shape {
protected:
std::string color;
public:
explicit Shape(std::string c) : color(std::move(c)) {}
void printInfo() const {
std::cout << "Color: " << color << "\n";
}
};
// 派生类,可以直接使用基类的printInfo函数
class Square : public Shape {
public:
Square(){}
};
这两种继承范式在实际中往往并未分离,而是糅合在一起。这里其实引出了我们今天要探讨的话题:接口继承和实现继承
接口继承(Interface Inheritance)
接口继承强调的是提供接口而不是具体实现。在接口继承中,基类仅声明成员函数(通常是纯虚函数),而不提供这些函数的具体实现。派生类需要实现基类中声明的这些函数。
- 基类提供一个接口(声明),派生类实现这个接口。
- 基类中的成员函数通常是纯虚函数(即没有实现)。
- 接口继承是用来定义行为的规范,而不是实现。
// 定义接口类
class IShape {
public:
virtual ~IShape() = default;
virtual void draw() const = 0; // 纯虚函数,派生类必须实现
virtual double area() const = 0; // 纯虚函数,派生类必须实现
};
实现继承(Implementation Inheritance)
实现继承指的是派生类不仅继承了基类的接口,还继承了基类的实现。在这种情况下,基类提供了成员函数的实现,派生类可以选择使用基类的实现,或者重写这些实现。
- 基类提供了实现,派生类继承这些实现。
- 如果派生类需要改变实现,可以重写基类的方法(覆盖)。
- 实现继承通常用于共享代码,而不是仅仅共享接口。
// 基类提供实现
class Shape {
public:
virtual ~Shape() = default;
virtual void draw() const {
std::cout << "Drawing a Shape" << std::endl;
}
virtual double area() const = 0; // 仍然是纯虚函数,要求派生类实现
};
使用场景
接口继承:接口继承通常用于定义API、插件架构、以及需要保证一致性的系统。例如,GUI框架中,Drawable 接口可以由不同的图形对象(如 Circle, Rectangle 等)实现。
实现继承:实现继承通常用于减少代码重复,尤其是在多个类共享相似行为时。比如,Shape 基类可以提供一些通用方法(如 draw()),而子类可以重用或覆盖这些方法。
C++和Java的对比
从Java转到C++的程序员,对这两种形态可能并不陌生,这不就是Java的Interface和Extends吗?可以看如下对比
特性 | C++ | Java |
接口定义 | 含纯虚函数的抽象类 |
关键字 |
实现继承 | 普通类继承(可含具体实现) |
继承类 |
接口继承 | 继承纯虚类 |
实现接口 |
多重继承 | 支持(类/抽象类均可) | 类单继承,接口多实现 |
默认实现 | 基类可直接提供 | Java 8+ 接口支持 方法 |
抽象方法声明 |
|
关键字 |
构造方法继承 | 不自动继承,需显式调用 | 子类必须调用父类构造器 |
类继承(extends
)表达 "是什么"(is-a)的层次关系
class FileInputStream extends InputStream { /*...*/ } // 是输入流的一种
接口实现(implements
)表达 "能做什么"(can-do)的能力描述 java复制
class ArrayList implements List, Serializable { /*...*/ } // 具备列表能力和序列化能力
C++多接口继承
行为组合(Behavior Composition)
当你希望将一个类的不同职责分离到多个接口中,并允许类根据需要实现这些接口时,可以使用多个接口继承。例如,一个图形系统中的类可能既是“可绘制”的(Drawable),也是“可移动”的(Movable),这种设计可以让你灵活组合不同的行为。
class Drawable {
public:
virtual void draw() const = 0;
};
class Movable {
public:
virtual void move() = 0;
};
class Printable{
public:
virtual void print() = 0;
}
class Shape : public Drawable, public Movable, public Printable {
public:
void draw() const override {
std::cout << "Drawing shape" << std::endl;
}
void move() override {
std::cout << "Moving shape" << std::endl;
}
void pring() override {
std::cout << "Print shape" << std::endl;
}
};
我们来讨论一下这种多接口继承风格的代码,这种风格的代码在C++中似乎比较少见,其优缺点如下:
优点:
模块化和解耦:
- 继承多个接口可以将不同功能模块的行为进行分离,使得每个接口关注一个单独的责任或行为。这样,你可以在类中实现不同的功能,而不需要继承包含多种功能的庞大类。
- 例如,一个 Drawable 接口和一个 Movable 接口可以分别定义绘制和移动行为,Shape 类继承这两个接口,既能绘制也能移动。
提高灵活性和可扩展性:
- 通过多个接口继承,你可以轻松添加新的行为,而不需要改变现有类的结构。例如,增加一个 Resizable 接口,使得某些类能够支持调整大小,而不必修改已有的基类实现。
缺点:
多重继承带来的复杂性:
- C++ 允许类继承多个接口,但多个接口继承也可能导致一些复杂性,尤其是当接口中有相同名称的成员函数时,可能会引发二义性问题(比如多个接口中有相同的函数签名)。C++ 的虚继承和命名空间等机制可以解决一些问题,但可能会使得代码更加复杂。
难以维护:
- 继承多个接口的类可能会变得非常复杂,尤其是当接口数量增多时,类的行为可能会变得难以跟踪和管理。每个接口都定义了某种行为,组合多个接口时,可能会导致类的设计变得难以理解和维护。
潜在的内存开销:
- 在一些情况下,多个接口继承可能会增加内存开销,因为每个接口可能会引入虚表(vtable)和相关的虚函数表项。
更优雅的组合方式
组合优于继承:如果多个接口并不是直接相关,考虑使用组合而不是继承。可以将多个接口作为类的成员,类通过委托的方式实现其行为。组合在很多情况下比继承更加灵活且易于管理。
class Drawable {
public:
virtual void draw() const = 0;
};
class Movable {
public:
virtual void move() = 0;
};
class Shape {
private:
Drawable* drawable;
Movable* movable;
public:
Shape(Drawable* d, Movable* m) : drawable(d), movable(m) {}
void draw() const {
drawable->draw();
}
void move() {
movable->move();
}
};
总结
- 在 C++ 中,继承多个接口可以提供很好的灵活性和模块化,符合接口隔离原则(ISP),尤其适用于行为组合和解耦设计。
- 然而,过度使用多重接口继承可能会增加代码复杂性,导致维护困难。因此,应谨慎使用,特别是接口数量较多时。
- 在某些情况下,组合可能是更好的选择,尤其是在不同功能之间并没有强耦合关系时。总之,继承多个接口的风格可以在合适的场景下带来灵活性和可扩展性,但需要在设计中保持清晰的结构和合理的接口层次。