c++ 常用接口设计
《Effective C++》全文读书笔记_51CTO博客_effective c++笔记
Effective C++笔记(1-4章,条款1-25)_帮我把effice c++ 的笔记-CSDN博客
设计模式精要:提升代码复用与可维护性-CSDN博客
核心设计原则回顾 (作为基础)
SOLID 原则:
S - 单一职责原则: 一个类应该只有一个引起它变化的原因。
O - 开闭原则: 对扩展开放,对修改关闭。
L - 里氏替换原则: 子类必须能够完全替代其父类。
I - 接口隔离原则: 客户端不应该被迫依赖于它不使用的接口。
D - 依赖倒置原则: 依赖于抽象(接口),而不是具体实现。
RAII: 资源获取即初始化。这是C++管理资源的生命线,将资源生命周期与对象生命周期绑定。
高内聚,低耦合: 模块内部元素紧密相关,模块之间依赖尽可能少。
常用设计案例与实践技巧
案例 1: PIMPL (Pointer to IMPLementation) - 编译防火墙与接口稳定性
问题: 头文件中的私有成员会导致实现细节暴露。当修改私有成员时,所有包含该头文件的代码都需要重新编译,这在大型项目中非常耗时。
解决方案: 使用一个不透明的指针,将类的实现细节完全隐藏在一个单独的类中,头文件中只包含接口和一个指向实现的指针。
代码示例:
cpp
// Widget.h - 稳定接口,不暴露任何实现细节
#include <memory>
class Widget {
public:
Widget(); // 构造函数
~Widget(); // 析构函数必须声明,因为Impl是不完整类型
// 公开接口
void doSomething();
int getValue() const;
// 禁止拷贝(示例,也可实现拷贝语义)
Widget(const Widget&) = delete;
Widget& operator=(const Widget&) = delete;
private:
// 前置声明实现类
struct Impl;
// 使用唯一指针管理实现对象
std::unique_ptr<Impl> pImpl;
};
cpp
// Widget.cpp - 实现细节在这里
#include "Widget.h"
#include <vector>
#include <string>
// 定义实现类
struct Widget::Impl {
// 这里可以包含任何复杂的、经常变动的实现细节
std::vector<int> complexData;
std::string name;
void privateHelperFunction() { /* ... */ }
};
// 构造函数需要构造Impl对象
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
// 析构函数必须在Impl定义后看到其完整类型,因此放在.cpp中
// 但使用默认析构函数即可,unique_ptr会自动删除Impl对象
Widget::~Widget() = default;
// 接口实现,通过pImpl访问具体数据
void Widget::doSomething() {
pImpl->privateHelperFunction();
pImpl->complexData.push_back(42);
}
int Widget::getValue() const {
return pImpl->complexData.empty() ? 0 : pImpl->complexData.back();
}
优点:
二进制兼容性: 修改 Impl 的结构不会改变 Widget 类的大小和布局,头文件不变,客户端无需重新编译。
信息隐藏: 头文件极其简洁,只暴露公共接口,完美实现了信息隐藏。
编译速度: 减少头文件依赖,显著提升编译速度。
案例 2: 工厂模式与依赖倒置 - 创建灵活对象
问题: 客户端代码直接 new 一个具体类,导致紧密耦合。如果想替换一种实现(例如,SqlDatabase 换为 MockDatabase),需要修改所有客户端代码。
解决方案: 定义一个抽象接口(纯虚类),然后通过一个工厂函数(或工厂类)来返回具体实现的对象。客户端只依赖于抽象接口。
代码示例:
cpp
// IDatabase.h - 抽象接口
#include <string>
class IDatabase {
public:
virtual ~IDatabase() = default; // 基类析构函数必须为virtual
virtual bool connect(const std::string& connectionString) = 0;
virtual bool query(const std::string& sql) = 0;
// ... 其他数据库操作
};
cpp
// DatabaseFactory.h
#include "IDatabase.h"
#include <memory>
// 工厂函数返回抽象接口的智能指针
std::unique_ptr<IDatabase> createDatabase(const std::string& dbType);
// 可以扩展为注册模式的工厂,更灵活
cpp
// DatabaseFactory.cpp
#include "DatabaseFactory.h"
#include "SqlDatabase.h" // 具体实现A
#include "MockDatabase.h" // 具体实现B
std::unique_ptr<IDatabase> createDatabase(const std::string& dbType) {
if (dbType == "SQL") {
return std::make_unique<SqlDatabase>();
} else if (dbType == "MOCK") {
return std::make_unique<MockDatabase>();
}
throw std::runtime_error("Unknown database type: " + dbType);
}
cpp
// Client.cpp - 客户端代码
#include "IDatabase.h"
#include "DatabaseFactory.h"
void clientCode() {
// 客户端只依赖于IDatabase抽象接口和工厂
auto db = createDatabase("MOCK"); // 轻松切换类型,只需修改配置字符串
db->connect("...");
db->query("SELECT ...");
// db 离开作用域后自动释放资源
}
优点:
解耦: 客户端与具体实现类完全解耦。
可扩展: 添加新的数据库类型(如 OracleDatabase)无需修改客户端和工厂逻辑(尤其是在使用注册模式时)。
可测试: 可以轻松注入 MockDatabase 进行单元测试。
案例 3: RAII 与资源管理 - 构建异常安全的代码
问题: 手动管理资源(如内存、文件句柄、锁)容易导致泄漏,尤其是在异常发生时。
解决方案: 将资源封装在对象中,在构造函数中获取资源,在析构函数中释放资源。利用栈对象生命周期自动管理资源。
代码示例(自定义文件句柄管理):
cpp
// FileHandle.h
#include <cstdio>
class FileHandle {
public:
// 显式构造函数,接管已有的FILE*或通过文件名打开
explicit FileHandle(const char* filename, const char* mode = "r");
explicit FileHandle(FILE* f) : file(f) {} // 接管所有权
// 禁止拷贝
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;
// 支持移动语义
FileHandle(FileHandle&& other) noexcept : file(other.file) {
other.file = nullptr;
}
FileHandle& operator=(FileHandle&& other) noexcept {
if (this != &other) {
close();
file = other.file;
other.file = nullptr;
}
return *this;
}
~FileHandle() { close(); }
// 显式释放资源,并可检查是否有效
void close();
bool isOpen() const { return file != nullptr; }
// 提供访问原始资源的接口(必要时)
FILE* get() const { return file; }
// 常用的文件操作可以封装为成员函数,更安全
size_t read(void* buffer, size_t size, size_t count);
size_t write(const void* buffer, size_t size, size_t count);
// ...
private:
FILE* file = nullptr;
};
cpp
// FileHandle.cpp
#include "FileHandle.h"
#include <stdexcept>
FileHandle::FileHandle(const char* filename, const char* mode) {
file = std::fopen(filename, mode);
if (!file) {
throw std::runtime_error("Failed to open file");
}
}
void FileHandle::close() {
if (file) {
std::fclose(file);
file = nullptr;
}
}
// ... 其他成员函数实现
使用方式:
cpp
void processFile() {
try {
FileHandle fh("data.txt", "w"); // 资源在构造时获取
fh.write(data, sizeof(Data), 1, fh.get());
// 即使这里抛出异常,fh的析构函数也会被调用,文件会被安全关闭
someRiskyOperation();
} catch (const std::exception& e) {
// 处理异常,无需担心文件泄露
}
// 离开作用域,文件自动关闭
}
优点:
异常安全: 保证资源在任何执行路径下都能被正确释放。
无需手动管理: 避免了忘记调用 close/delete 的问题。
清晰的所有权语义: 明确表示了资源的所有权归属。
总结表格
设计模式 解决的核心问题 关键实现手段 带来的好处
PIMPL 编译依赖、接口稳定、信息隐藏 不透明指针 std::unique_ptr<Impl> 减少编译时间,二进制兼容,完美信息隐藏
工厂模式 对象创建与使用的耦合 抽象接口 + 工厂函数返回智能指针 解耦,提高灵活性,便于测试和扩展
RAII 资源泄漏,尤其是异常安全 将资源生命周期绑定到对象生命周期 自动资源管理,强异常安全保证
策略模式 算法在运行时需要灵活切换 将算法抽象为接口,通过组合注入 符合开闭原则,算法可独立变化
这些案例是构建现代、高效、稳定C++程序的基石。熟练掌握它们,并理解其背后的设计哲学,你的C++代码质量将迈上一个新的台阶。
https://zhuanlan.zhihu.com/p/338227526
对于很多出入门C++ 的程序员来说,大部门新手都是在用别人封装好的库函数,却没有尝试过自己封装一个自己的库提供给别人用。在公司里也见过一些新同事对于库的封装手足无措,不知道怎么将层级抽象化。这里提供一下我自己的见解。
我们知道,C++的三大特性:继承,多态,封装。在抽象一个功能库的时候,就是运用到了这三大核心思路。先说说在C++头文件接口设计中秉承的思路:
隔离用户操作与底层逻辑
这个其实就是要对你的底层代码逻辑做好抽象,尽量不要暴露你的代码逻辑,比如在opencv里面,对图像的操作大部分是通过cv::Mat这个矩阵类来实现的,这个类提供了很多操作图像的接口,使得用户可以不用直接接触像素操作,非常方便。举个简单的例子:
class Complex{
public:
Complex& operator+(const Complex& com );
Complex& operator-(const Complex& com );
Complex& operator*(const Complex& com );
Complex& operator/(const Complex& com );
private:
double real_;
double imaginary_;
};
通过这样简单的封装,用户可以直接使用+-*/四种运算符进行复数的运算,而数据成员则是被private隐藏了,用户看不见。这不仅是形式上的需要,更是为了我们程序员的身心健康着想。试想,一旦我们在接口中暴露了数据成员,那么一定有用户做出一些超出你设计意图之外的操作,为了防止这些骚操作不把程序crash掉,你要增加很多的异常处理。更有可能的是有些异常是你预想不到的。
那么这样是否就完美了呢?显然不是。如果把上述代码作为一个接口文件发布出去,用户依然能清清楚楚看到你的private成员,于是你就“暴露”了你的实现。我们要把接口的用户当成十恶不赦的蠢货,就要把成员再次隐藏起来。这时候就可以用到两种处理方式
1)PImp手法
所谓PImp是非常常见的隐藏真实数据成员的技巧,核心思路就是用另一个类包装了所要隐藏的真实成员,在接口类中保存这个类的指针。看代码:
//header complex.h
class ComplexImpl;
class Complex{
public:
Complex& operator+(const Complex& com );
Complex& operator-(const Complex& com );
Complex& operator*(const Complex& com );
Complex& operator/(const Complex& com );
private:
ComplexImpl* pimpl_;
};
在接口文件中声明一个ComplexImpl*,然后在另一个头文件compleximpl.h中定义这个类
//header compleximpl.h
class ComplexImpl{
public:
ComplexImpl& operator+(const ComplexImpl& com );
ComplexImpl& operator-(const ComplexImpl& com );
ComplexImpl& operator*(const ComplexImpl& com );
ComplexImpl& operator/(const ComplexImpl& com );
private:
double real_;
double imaginary_;
};
可以发现,这个ComplexImpl的接口基本没有什么变化(其实只是因为这个类功能太简单,在复杂的类里面,是需要很多private的内部函数去抽象出更多实现细节),然后在complex.cpp中,只要
#include "complex.h"
#include "compleximpl.h"
包含了ComplexImpl的实现,那么所有对于Complex的实现都可以通过ComplexImpl这个中介去操作。详细做法百度还有一大堆,就不细说了。
2)抽象基类
虽然使用了pimp手法,我们隐藏掉了复数的两个成员,但是头文件依然暴露出了新的一个ComplexImpl*指针,那有没有办法连这个指针也不要呢?
这时候就是抽象基类发挥作用的时候了。看代码:
class Complex{
public:
static std::unique_ptr<Complex> Create();
virtual Complex& operator+(const Complex& com ) = 0;
virtual Complex& operator-(const Complex& com ) = 0;
virtual Complex& operator*(const Complex& com ) = 0;
virtual Complex& operator/(const Complex& com ) = 0;
};
将要暴露出去的接口都设置为纯虚函数,通过 工厂方法Create来获取Complex指针,Create返回的是继承实现了集体功能的内部类;
//Complex类功能的内部实现类
class ComplexImpl : public Complex{
public:
virtual Complex& operator+(const Complex& com ) override;
virtual Complex& operator-(const Complex& com ) override;
virtual Complex& operator*(const Complex& com ) override;
virtual Complex& operator/(const Complex& com ) override;
private:
double real_;
double imaginary_;
}
至于Create函数也很简单:
std::unique_ptr<Complex> Complex::Create()
{
return std::make_unique<ComplexImpl>();
}
这样,我们完完全全将Complex类的实现细节全部封装隐藏起来了,用户一点都不知道里面的数据结构是什么;
当然,对于Complex这样的类来说,用户是有获取他的实部虚部这样的需求的,也很简单,再加上两个Get方法就可以达到目的。
2.减少编译依赖,简化参数结构
减少编译依赖,一言蔽之,就是不要再头文件里include太多其他头文件,尽可能使用指针或引用来代替。
有些接口需要用户设置的参数,尽量傻瓜化,不必寻求这些参数结构也可以在内部实现中通用。
就比如说,一个渲染字体的接口,如果内部使用到了opencv的一些方法,用户层应该怎么设置参数呢?
struct FontConfig{
int line_with;
int font_style;
int scale; //比重因子
int r;
int g;
int b;
double weight; //权重
}
void Render(const FontConfig& config) //内部实现
{
cv::Scaler color(config.r, config.g, config.b);
cv::putText(...color);
// ...
}
类似这种代码,其内部实现需要的结构是 cv::Scaler 这个结构,但是我们不能在接口文件中出现,一旦出现了,那也就毫无封装可言,你必须在接口里包含opencv的一堆头文件才能保证编译通过。因此适当的转换是有用且必要的。