C++ 接口与插件设计:从工厂注册到 Qt 动态插件

适用环境:核心示例使用 C++17;动态插件部分使用 Qt 6 API。前置知识:类、虚函数、智能指针与基本容器。

学习目标:分清接口、工厂、注册表和动态加载的职责,并能设计一个可扩展的功能入口。完整 C++ 示例给出预期输出,未在本机编译;Qt 部分明确作为结构示意,不是完整插件工程。

1. 扩展功能为什么容易牵动大量代码

假设一个工具最初只支持一种文本格式化方式。新增第二种格式时,如果在菜单、业务逻辑和文件保存中分别写类型判断,第三种格式加入后就需要再次修改这些位置。

这种耦合的根源,是调用方同时知道了"要做什么"和"具体由谁来做"。

接口设计可以先把共同能力提取出来。调用方请求格式化文本,具体实现决定怎样转换。创建对象的责任再交给工厂,管理可用功能的责任交给注册表。

复制代码
调用方 → 注册表按 ID 查找工厂 → 创建接口对象 → 调用具体实现

这条链可以全部发生在一个可执行文件中。只有再加入动态库发现、加载和生命周期管理,才构成动态插件体系。

2. 四个概念分别负责什么

概念 主要职责 不自动解决的问题
抽象接口 规定调用者能够使用的能力 实例从哪里来
工厂 创建满足接口的对象 功能如何被发现和选择
注册表 建立稳定 ID 与工厂的对应关系 动态库如何加载
插件加载器 加载动态模块并取得入口对象 业务接口本身是否合理

如果只是需要增加几个内部实现,编译进程序的注册表已经可能足够。动态插件适用于需要独立部署、可选安装或运行时发现扩展的场景,同时也会带来版本兼容、依赖检查和卸载管理成本。

3. 先设计一个小而明确的接口

本文用两种消息格式化方式举例:普通文本和方括号包裹。

接口只接收文本并返回格式化结果,不负责菜单、数据库、输出路径或文件保存。这样,示例可以专注于"替换一种实现",而不会把多个模块的职责混在一起。

真实接口除了函数签名,还应明确:

  • 输入是否允许为空,是否允许超长。
  • 输出由值返回还是借用对象内部存储。
  • 失败使用异常、状态码还是结果对象。
  • 对象能否被多个线程同时调用。
  • 对象由谁创建、谁销毁。

函数签名是接口的一部分,行为约定同样是接口的一部分。

4. 完整的工厂注册示例

下面可以作为独立的 main.cpp。注册在启动阶段完成,示例没有提供并发注册能力。

cpp 复制代码
#include <functional>
#include <iostream>
#include <memory>
#include <string>
#include <unordered_map>
#include <utility>

class IMessageFormatter
{
public:
    virtual ~IMessageFormatter() = default;
    virtual std::string format(const std::string& text) const = 0;
};

class PlainFormatter final : public IMessageFormatter
{
public:
    std::string format(const std::string& text) const override
    {
        return text;
    }
};

class BracketFormatter final : public IMessageFormatter
{
public:
    std::string format(const std::string& text) const override
    {
        return "[" + text + "]";
    }
};

class FormatterRegistry
{
public:
    using Factory = std::function<std::unique_ptr<IMessageFormatter>()>;

    bool registerFactory(std::string id, Factory factory)
    {
        if (id.empty() || !factory)
        {
            return false;
        }

        return factories_.emplace(std::move(id), std::move(factory)).second;
    }

    std::unique_ptr<IMessageFormatter> create(const std::string& id) const
    {
        const auto it = factories_.find(id);
        if (it == factories_.end())
        {
            return nullptr;
        }
        return it->second();
    }

private:
    std::unordered_map<std::string, Factory> factories_;
};

int main()
{
    FormatterRegistry registry;

    const bool plainRegistered = registry.registerFactory("plain", []
    {
        return std::make_unique<PlainFormatter>();
    });
    const bool bracketRegistered = registry.registerFactory("bracket", []
    {
        return std::make_unique<BracketFormatter>();
    });

    if (!plainRegistered || !bracketRegistered)
    {
        std::cerr << "registration failed\n";
        return 1;
    }

    auto formatter = registry.create("bracket");
    if (!formatter)
    {
        std::cerr << "formatter not available\n";
        return 1;
    }

    std::cout << formatter->format("Hello") << '\n';
}

预期输出:

复制代码
[Hello]

新增 PrefixFormatter 时,通常只需要实现接口并在组合入口注册新工厂。调用业务仍通过 IMessageFormatter 工作。

4.1 为什么注册工厂而不是直接保存全局实例

工厂允许按需创建对象,每个调用方拿到独立实例。实例状态、生命周期和并发关系通常更容易说明。

共享单实例也有适用场景,例如无状态且支持并发的服务,但应显式约定共享所有权与线程安全,不能仅因为"少创建一个对象"就采用全局指针。

4.2 为什么 ID 不应该直接使用界面显示名称

稳定 ID 用于配置、查找与协议,例如 bracket。显示名称可以翻译、改文案或加入说明。

如果用界面名称作为唯一查找键,用户切换语言或产品改名就可能使旧配置失效。实际工程中可以在 ID 之外附加显示名称、描述、配置能力等元数据。

4.3 重复注册和创建失败如何处理

示例中的重复 ID 注册返回 false,保留原工厂;未知 ID 创建返回空指针。工厂自身也可能返回空对象,因此调用方始终检查创建结果。

示例没有把所有异常统一转换为状态码。比如分配失败仍可能抛异常。完整应用应在适当边界建立统一错误策略,而不是用空的 catch (...) 隐藏原因。

5. 从注册表走向动态插件

动态插件把具体实现放到独立动态库中。主程序需要知道接口,但不需要在业务代码中直接包含每个具体类。

Qt 应用扩展可以使用自定义接口,通过 Q_DECLARE_INTERFACE 声明接口标识;实现类使用 Q_INTERFACES 声明接口,并通过 Q_PLUGIN_METADATA 提供插件元数据。接口标识应稳定,且不应把不兼容接口冒充为同一个版本。Qt 官方文档:创建插件

以下三个代码块是结构示意,需要分别放入共享接口头文件、插件工程和主程序。构建时还要配置 Qt Core、moc 和动态模块目标。

共享接口头文件:

cpp 复制代码
#pragma once
#include <QString>
#include <QtPlugin>

class ITextPlugin
{
public:
    virtual ~ITextPlugin() = default;
    virtual QString transform(const QString& text) const = 0;
};

#define TextPluginIid "org.example.ITextPlugin/1.0"
Q_DECLARE_INTERFACE(ITextPlugin, TextPluginIid)

插件实现类:

cpp 复制代码
#include <QObject>
#include "ITextPlugin.h"

class BracketPlugin final : public QObject, public ITextPlugin
{
    Q_OBJECT
    Q_PLUGIN_METADATA(IID TextPluginIid)
    Q_INTERFACES(ITextPlugin)

public:
    QString transform(const QString& text) const override
    {
        return QStringLiteral("[") + text + QStringLiteral("]");
    }
};

主程序中的加载流程:

cpp 复制代码
// 结构片段:pluginPath 来自已确定的插件文件路径。
QPluginLoader loader(pluginPath);
QObject* root = loader.instance();
if (!root)
{
    qWarning() << loader.errorString();
    return;
}

ITextPlugin* plugin = qobject_cast<ITextPlugin*>(root);
if (!plugin)
{
    qWarning() << "Plugin interface mismatch";
    return;
}

qDebug() << plugin->transform(QStringLiteral("Hello"));
// 本例在此作用域内完成调用,不把 plugin 指针交给长期持有者。

QPluginLoader::instance() 提供插件根对象,失败原因可以通过 errorString() 获取。显式卸载前必须确保没有对象或调用仍依赖插件;根对象的销毁与卸载由 Qt 插件机制管理,不应把取得的根对象随意装进一个独立的默认删除智能指针。Qt 官方文档:QPluginLoader

真实应用通常将加载器和插件访问关系一起放入管理器中,明确控制卸载时机,而不是加载后只留一个裸接口指针。

6. 源码接口相同为什么还可能加载失败

动态库边界还存在 ABI,也就是应用程序二进制接口。调用方和插件不仅要在源码层面"看起来一致",还要对对象布局、符号、调用约定和运行库等保持兼容。

常见问题包括:

  • 主程序与插件的架构不匹配,例如一个是 32 位,一个是 64 位。
  • 插件依赖的其他动态库缺失,或依赖库缺少所需导出符号。
  • 编译工具链、运行库或构建配置不兼容。
  • 接口已经改变,但某个模块仍使用旧二进制文件。

因此,在公开的 C++ 插件接口中增加虚函数,不应默认认为旧插件还能直接使用。可以选择统一重新构建,或建立新的接口版本与兼容适配层。

需要跨工具链长期兼容时,也可以考虑带显式版本和创建/销毁函数的 C 接口。它能减少某些 C++ ABI 耦合,但仍需约定数据布局、调用约定、错误传播和内存释放规则。

7. 注册成功不等于功能可用

一个扩展功能通常有几个不同状态:

复制代码
发现模块 → 加载模块 → 接口匹配 → 初始化 → 注册能力 → 执行请求

某些框架会调整初始化与注册的先后顺序,但每个阶段仍应有明确成功条件。

例如注册表已经存在某个 ID,只能说明有相应入口,不代表运行资源、授权条件或执行环境一定满足。界面展示与执行入口应遵守相同的可用性规则,不能只在菜单层隐藏选项,却让直接调用绕过限制。

失败退出也需要对称处理:注册了一半失败时,应撤销本次已经建立的状态;卸载模块前应移除相关工厂,并保证没有仍然依赖该模块的实例或回调。

8. 哪些设计容易过度

并非所有功能都需要动态插件。如果功能只有一种实现,长期不会被替换,直接调用普通类可能更清晰。

有替换需求但不需要独立部署,可以先使用接口和静态注册。确认确有动态部署价值后,再增加加载、版本和生命周期管理。

同样,不应把"插件"理解为可以任意访问主程序所有内部对象。接口越依赖内部细节,独立扩展和版本升级就越困难。

9. 阅读陌生框架时从哪里入手

沿着一次功能创建追踪五个位置:

  1. 抽象接口:调用方能够要求什么?
  2. 派生实现:实际行为在哪里?
  3. 注册入口:何时把实现接入系统?
  4. 创建与调用处:按什么 ID 选择,失败怎样返回?
  5. 销毁或卸载处:谁负责清理,是否仍有引用?

这个方法比先阅读整个管理器的所有函数更容易建立完整认识。

10. 自检题

  1. 使用了注册表,是否就一定是动态插件?
  2. 工厂返回 unique_ptr,表达了怎样的所有权?
  3. 为什么配置中适合保存稳定 ID,而不是翻译后的名称?
  4. 动态库已经加载,为什么还要检查接口是否匹配?
  5. 为什么不能在仍有接口指针被调用时卸载插件?

参考答案

  1. 不是。注册表可以完全由编译进可执行文件的实现构成。
  2. 创建结果的独占所有权交给调用方;对象销毁时按接口的析构约定清理。
  3. ID 不随展示文案变化,能够保持配置和查找规则稳定。
  4. 能加载只是二进制模块进入进程,不代表它提供了调用方要求的业务接口。
  5. 对象实现、虚函数地址或回调可能位于插件模块中,卸载后继续使用会使相关访问失效。

建议练习:增加一个带固定前缀的格式化器,验证调用方无需判断具体类型;再尝试注册重复 ID,观察原功能是否保持不变。

相关推荐
大都督会赢的2 小时前
C++第一节课
开发语言·c++
UIU1142 小时前
P1233 [ICPC 2001 Taejon R] 木棍加工
数据结构·c++·算法·动态规划
小王C语言3 小时前
Qt常用控件:容器类控件,标题分组框、标签页(浏览器标签)
开发语言·qt
论文复现现场3 小时前
WRF、FEA 能用 L40S 加速吗?求解器检查、断点恢复与长期运行
java·数据库·算法·wrf·有限元分析·gpu计算·断点续算
TMedical3 小时前
MedPlanning 截骨模拟矫形功能
c++·图像处理·人工智能·qt·健康医疗
qq_406981033 小时前
共享变量一 关于extern与const
开发语言·c++
Decay__4 小时前
04_MySQL DBA 日常运维手册 —— 会话管理、锁与阻塞、事务监控、慢查询与日志巡检
运维·数据库·sql·mysql·架构
心梦缘-雪雁5 小时前
SQL Server 查询数据存在哪个表里
数据库
chari克里6 小时前
QT5.15.2 + msvc2022部署全流程说明(windows可联网环境)
开发语言·c++·qt