8

我正在尝试在 C++ 中创建一个非常开放的插件框架,在我看来,我已经想出了一个方法来做到这一点,但是一个唠叨的想法一直告诉我,我正在做的事情非常非常错误,它要么不起作用,要么会导致问题。

我的框架设计包含一个调用每个插件init功能的内核。然后 init 函数转而使用内核的registerPluginandregisterFunction获取唯一的 id,然后分别注册插件希望使用该 id 访问的每个函数。

函数 registerPlugin 返回唯一的 id。函数 registerFunction 接受该 id、函数名称和通用函数指针,如下所示:

bool registerFunction(int plugin_id, string function_name, plugin_function func){}

plugin_function 在哪里

typedef void (*plugin_function)();

然后内核获取函数指针并将其放入带有function_nameand的映射中plugin_id。所有注册其函数的插件都必须将该函数强制转换为 type plugin_function

为了检索函数,不同的插件调用内核的

plugin_function getFunction(string plugin_name, string function_name);

然后该插件必须将其plugin_function转换为其原始类型,以便可以使用它。.h通过访问概述插件提供的所有功能的文件,它(理论上)知道正确的类型是什么。插件,by,被实现为动态库。

这是完成允许不同插件相互连接的任务的聪明方法吗?或者这是一种疯狂且非常糟糕的编程技术?如果是,请指出正确的方法来完成此任务。

编辑:如果需要任何澄清,请询问并将提供。

4

7 回答 7

19

函数指针是奇怪的生物。它们不一定与数据指针的大小相同,因此不能安全地void*来回转换。但是,C++(和 C)规范允许将任何函数指针安全地转换为另一个函数指针类型(尽管如果您想要定义的行为,您必须稍后将其转换回早期的类型,然后再调用它)。这类似于安全地void*来回转换任何数据指针的能力。

指向方法的指针是真正麻烦的地方:方法指针可能比普通函数指针大,这取决于编译器,应用程序是 32 位还是 64 位等。但更有趣的是,即使在相同的编译器/平台,并非所有方法指针的大小都相同:指向虚函数的方法指针可能比普通方法指针大;如果涉及多重继承(例如菱形模式中的虚拟继承),则方法指针可以更大。这也因编译器和平台而异。这也是很难创建函数对象(包装任意方法以及自由函数)的原因,尤其是在没有在堆上分配内存的情况下(使用模板巫术可能的)。

因此,通过在您的界面中使用函数指针,插件作者将方法指针传回您的框架变得不切实际,即使他们使用的是相同的编译器。这可能是一个可接受的约束;稍后会详细介绍。

由于无法保证从一个编译器到下一个编译器的函数指针大小相同,因此通过注册函数指针,您将插件作者限制为实现与编译器具有相同大小的函数指针的编译器。这在实践中并不一定那么糟糕,因为函数指针大小在编译器版本之间往往是稳定的(甚至对于多个编译器来说可能是相同的)。

当你想调用函数指针指向的函数时,真正的问题就开始出现了。如果您不知道函数的真实签名,则根本无法安全地调用该函数(您得到从“不工作”到分段错误的糟糕结果)。因此,插件作者将被进一步限制为仅注册void不带参数的函数。

更糟的是:函数调用在汇编程序级别的实际工作方式不仅仅取决于签名和函数指针大小。还有调用约定,处理异常的方式(抛出异常时堆栈需要正确展开),以及函数指针字节的实际解释(如果它大于数据指针,额外的字节做什么)表示?以什么顺序?)。此时,插件作者几乎只能使用与您相同的编译器(和版本!),并且需要注意匹配调用约定和异常处理选项(例如,使用 MSVC++ 编译器,异常处理仅使用该/EHsc选项显式启用),并且仅使用具有您定义的确切签名的普通函数指针。

到目前为止,所有限制可以认为是合理的,如果有一点限制的话。但我们还没有完成。

如果你加入std::string(或 STL 的几乎任何部分),事情会变得更糟,因为即使使用相同的编译器(和版本),也有几个不同的标志/宏控制 STL;这些标志会影响表示字符串对象的字节的大小和含义。实际上,这就像在单独的文件中有两个不同的结构声明,每个文件都具有相同的名称,并希望它们可以互换;显然,这是行不通的。一个示例标志是_HAS_ITERATOR_DEBUGGING. 请注意,这些选项甚至可以在调试和发布模式之间更改!这些类型的错误并不总是立即/一致地表现出来,而且很难追踪。

您还必须非常小心跨模块的动态内存管理,因为new在一个项目中的定义可能与new在另一个项目中不同(例如,它可能被重载)。删除时,您可能有一个指向带有虚拟析构函数的接口的指针,这意味着vtable正确delete的对象需要它,并且不同的编译器都以vtable不同的方式实现这些东西。通常,您希望分配对象的模块是解除分配对象的模块;更具体地说,您希望释放对象的代码在与分配它的代码完全相同的条件下编译。这是一个原因std::shared_ptr可以在构造时采用“删除器”参数 - 因为即使使用相同的编译器和标志(shared_ptr在模块之间共享 s 的唯一保证安全的方式),new并且delete可能在任何地方都不同,shared_ptr可以被破坏。使用删除器,创建共享指针的代码也控制它最终如何被销毁。(我只是把这一段扔进去了;你似乎没有跨模块边界共享对象。)

所有这些都是 C++ 没有标准二进制接口 ( ABI ) 的结果;这是一个免费的游戏,很容易在脚上开枪(有时没有意识到)。

那么,还有希望吗?完全正确!您可以改为向您的插件公开 C API,并让您的插件也公开 C API。这非常好,因为 C API 几乎可以与任何语言进行互操作。您不必担心异常,除了确保它们不会在插件函数之上冒泡(这是作者所关心的),并且无论编译器/选项如何它都是稳定的(假设您不通过 STL 容器之类的)。只有一种标准调用约定 ( cdecl),这是声明的函数的默认值extern "C"void*实际上,在同一平台上的所有编译器中都是相同的(例如 x64 上的 8 个字节)。

您(和插件作者)仍然可以用 C++ 编写代码,只要两者之间的所有外部通信都使用 C API(即为了互操作而伪装成 C 模块)。

C 函数指针在实践中也可能在编译器之间兼容,但如果您不想依赖它,您可以让插件注册一个函数名称( const char*) 而不是地址,然后您可以自己提取地址,例如LoadLibrary使用GetProcAddress对于 Windows(类似地,Linux 和 Mac OS X 有dlopendlsym)。这是有效的,因为使用 .声明的函数禁用了名称修饰extern "C"

请注意,没有直接的方法可以将注册函数限制为单一原型类型(否则,正如我所说,您无法正确调用它们)。如果您需要为插件函数提供特定参数(或返回值),则需要分别注册和调用具有不同原型的不同函数(尽管您可以将所有函数指针折叠为一个通用函数指针在内部输入,并且只在最后一分钟回退)。

最后,虽然您不能直接支持方法指针(在 C API 中甚至不存在,但即使使用 C++ API 也具有可变大小,因此不容易存储),您可以允许插件提供“用户- data" 注册函数时的不透明指针,每当它被调用时都会传递给函数;这为插件作者提供了一种简单的方法来编写方法周围的函数包装器并存储对象以将方法应用到 user-data 参数中。user-data 参数还可以用于插件作者想要的任何其他内容,这使您的插件系统更易于交互和扩展。另一个示例用途是使用包装器和存储在用户数据中的额外参数来适应不同的函数原型。

这些建议导致了类似这样的代码(对于 Windows——其他平台的代码非常相似):

// Shared header
extern "C" {
    typedef void (*plugin_function)(void*);

    bool registerFunction(int plugin_id, const char* function_name, void* user_data);
}

// Your plugin registration code
hModule = LoadLibrary(pluginDLLPath);

// Your plugin function registration code
auto pluginFunc = (plugin_function)GetProcAddress(hModule, function_name);
// Store pluginFunc and user_data in a map keyed to function_name

// Calling a plugin function
pluginFunc(user_data);

// Declaring a plugin function
extern "C" void aPluginFunction(void*);
class Foo { void doSomething() { } };

// Defining a plugin function
void aPluginFunction(void* user_data)
{
    static_cast<Foo*>(user_data)->doSomething();
}

很抱歉这个回复的长度;其中大部分可以总结为“C++ 标准没有扩展到互操作;使用 C 代替,因为它至少具有事实上的标准。”


注意:有时在插件将在完全相同的情况下编译的假设下,设计一个普通的 C++ API(带有函数指针或接口或任何你最喜欢的东西)是最简单的;如果您希望自己开发所有插件(即 DLL 是项目核心的一部分),这是合理的。如果您的项目是开源的,这也可以工作,在这种情况下,每个人都可以独立选择一个有凝聚力的环境,在该环境下编译项目和插件——但这使得除了作为源代码之外很难分发插件。


更新:正如 ern0 在评论中指出的那样,可以抽象模块互操作的细节(通过 C API),以便主项目和插件都处理更简单的 C++ API。以下是这种实现的概述:

// iplugin.h -- shared between the project and all the plugins
class IPlugin {
public:
    virtual void register() { }
    virtual void initialize() = 0;

    // Your application-specific functionality here:
    virtual void onCheeseburgerEatenEvent() { }
};

// C API:
extern "C" {
    // Returns the number of plugins in this module
    int getPluginCount();

    // Called to register the nth plugin of this module.
    // A user-data pointer is expected in return (may be null).
    void* registerPlugin(int pluginIndex);

    // Called to initialize the nth plugin of this module
    void initializePlugin(int pluginIndex, void* userData);

    void onCheeseBurgerEatenEvent(int pluginIndex, void* userData);
}


// pluginimplementation.h -- plugin authors inherit from this abstract base class
#include "iplugin.h"
class PluginImplementation {
public:
    PluginImplementation();
};


// pluginimplementation.cpp -- implements C API of plugin too
#include <vector>

struct LocalPluginRegistry {
    static std::vector<PluginImplementation*> plugins;
};

PluginImplementation::PluginImplementation() {
    LocalPluginRegistry::plugins.push_back(this);
}

extern "C" {
    int getPluginCount() {
        return static_cast<int>(LocalPluginRegistry::plugins.size());
    }

    void* registerPlugin(int pluginIndex) {
        auto plugin = LocalPluginRegistry::plugins[pluginIndex];
        plugin->register();
        return (void*)plugin;
    }

    void initializePlugin(int pluginIndex, void* userData) {
        auto plugin = static_cast<PluginImplementation*>(userData);
        plugin->initialize();
    }

    void onCheeseBurgerEatenEvent(int pluginIndex, void* userData) {
        auto plugin = static_cast<PluginImplementation*>(userData);
        plugin->onCheeseBurgerEatenEvent();
    }
}


// To declare a plugin in the DLL, just make a static instance:
class SomePlugin : public PluginImplementation {
    virtual void initialize() {  }
};
SomePlugin plugin;    // Will be created when the DLL is first loaded by a process


// plugin.h -- part of the main project source only
#include "iplugin.h"
#include <string>
#include <vector>
#include <windows.h>

class PluginRegistry;

class Plugin : public IPlugin {
public:
    Plugin(PluginRegistry* registry, int index, int moduleIndex)
        : registry(registry), index(index), moduleIndex(moduleIndex)
    {
    }

    virtual void register();
    virtual void initialize();

    virtual void onCheeseBurgerEatenEvent();

private:
    PluginRegistry* registry;
    int index;
    int moduleIndex;
    void* userData;
};

class PluginRegistry {
public:
    registerPluginsInModule(std::string const& modulePath);
    ~PluginRegistry();

public:
    std::vector<Plugin*> plugins;

private:
    extern "C" {
        typedef int (*getPluginCountFunc)();
        typedef void* (*registerPluginFunc)(int);
        typedef void (*initializePluginFunc)(int, void*);
        typedef void (*onCheeseBurgerEatenEventFunc)(int, void*);
    }

    struct Module {
        getPluginCountFunc getPluginCount;
        registerPluginFunc registerPlugin;
        initializePluginFunc initializePlugin;
        onCheeseBurgerEatenEventFunc onCheeseBurgerEatenEvent;

        HMODULE handle;
    };

    friend class Plugin;
    std::vector<Module> registeredModules;
}


// plugin.cpp
void Plugin::register() {
    auto func = registry->registeredModules[moduleIndex].registerPlugin;
    userData = func(index);
}

void Plugin::initialize() {
    auto func = registry->registeredModules[moduleIndex].initializePlugin;
    func(index, userData);
}

void Plugin::onCheeseBurgerEatenEvent() {
    auto func = registry->registeredModules[moduleIndex].onCheeseBurgerEatenEvent;
    func(index, userData);
}

PluginRegistry::registerPluginsInModule(std::string const& modulePath) {
    // For Windows:
    HMODULE handle = LoadLibrary(modulePath.c_str());

    Module module;
    module.handle = handle;
    module.getPluginCount = (getPluginCountFunc)GetProcAddr(handle, "getPluginCount");
    module.registerPlugin = (registerPluginFunc)GetProcAddr(handle, "registerPlugin");
    module.initializePlugin = (initializePluginFunc)GetProcAddr(handle, "initializePlugin");
    module.onCheeseBurgerEatenEvent = (onCheeseBurgerEatenEventFunc)GetProcAddr(handle, "onCheeseBurgerEatenEvent");

    int moduleIndex = registeredModules.size();
    registeredModules.push_back(module);

    int pluginCount = module.getPluginCount();
    for (int i = 0; i < pluginCount; ++i) {
        auto plugin = new Plugin(this, i, moduleIndex);
        plugins.push_back(plugin);
    }
}

PluginRegistry::~PluginRegistry() {
    for (auto it = plugins.begin(); it != plugins.end(); ++it) {
        delete *it;
    }

    for (auto it = registeredModules.begin(); it != registeredModules.end(); ++it) {
        FreeLibrary(it->handle);
    }
}



// When discovering plugins (e.g. by loading all DLLs in a "plugins" folder):
PluginRegistry registry;
registry.registerPluginsInModule("plugins/cheeseburgerwatcher.dll");
for (auto it = registry.plugins.begin(); it != registry.plugins.end(); ++it) {
    (*it)->register();
}
for (auto it = registry.plugins.begin(); it != registry.plugins.end(); ++it) {
    (*it)->initialize();
}

// And then, when a cheeseburger is actually eaten:
for (auto it = registry.plugins.begin(); it != registry.plugins.end(); ++it) {
    auto plugin = *it;
    plugin->onCheeseBurgerEatenEvent();
}

这具有使用 C API 以实现兼容性的好处,但也为用 C++ 编写的插件(以及主要项目代码,即 C++)提供了更高级别的抽象。请注意,它允许在单个 DLL 中定义多个插件。您还可以通过使用宏来消除一些重复的函数名称,但对于这个简单的示例,我选择不这样做。


顺便说一下,所有这些都假设插件没有相互依赖关系——如果插件 A 影响插件 B(或被插件 B 需要),您需要设计一种安全的方法来根据需要注入/构造依赖关系,因为无法保证插件将按什么顺序加载(或初始化)。在这种情况下,两步过程会很有效:加载并注册所有插件;在注册每个插件期间,让他们注册他们提供的任何服务。在初始化过程中,通过查看注册的服务表,根据需要构造请求的服务。这确保了所有插件提供的所有服务在尝试使用它们之前都已注册,无论插件以何种顺序注册或初始化。

于 2013-02-11T07:10:50.840 回答
2

您采取的方法总体上是理智的,但我看到了一些可能的改进。

  • 您的内核应该使用传统的调用约定(cdecl,或者如果您在 Windows 上可能是 stdcall)导出 C 函数,以注册插件和函数。如果您使用 C++ 函数,那么您将强制所有插件作者使用您使用的相同编译器和编译器版本,因为 C++ 函数名称修改、STL 实现和调用约定等许多事情都是编译器特定的。

  • 插件应该只导出像内核这样的 C 函数。

  • 从定义getFunction看来,每个插件都有一个名称,其他插件可以使用该名称来获取其功能。这不是一种安全的做法,两个开发人员可以创建两个具有相同名称的不同插件,因此当一个插件通过名称请求其他插件时,它可能会得到一个与预期不同的插件。更好的解决方案是让插件具有公共GUID。这个 GUID 可以出现在每个插件的头文件中,以便其他插件可以引用它。

  • 您尚未实施版本控制。理想情况下,您希望对内核进行版本控制,因为将来您总是会更改它。当插件向内核注册时,它会传递编译它所针对的内核 API 的版本。然后内核可以决定是否可以加载插件。例如,如果内核版本 1 收到需要内核版本 2 的插件的注册请求,那么您遇到了问题,解决该问题的最佳方法是不允许插件加载,因为它可能需要内核中不存在的功能旧版本。相反的情况也是可能的,内核 v2 可能希望也可能不希望加载为内核 v1 创建的插件,如果允许,它可能需要适应旧的 API。

  • 我不确定我是否喜欢插件能够定位另一个插件并直接调用其函数的想法,因为这会破坏封装。对我来说,如果插件向内核宣传它们的功能似乎更好,这样其他插件就可以通过功能找到他们需要的服务,而不是通过名称或 GUID 来寻址其他插件。

  • 请注意,任何分配内存的插件都需要为该内存提供释放函数。每个插件可能使用不同的运行时库,因此插件分配的内存可能不为其他插件或内核所知。在同一个模块中分配和释放可以避免问题。

于 2013-02-17T08:42:26.017 回答
1

C++ 没有ABI。所以你想做的有一个限制:插件和你的框架必须由相同的编译器和链接器在相同的操作系统中使用相同的参数进行编译和链接。如果实现是二进制分发形式的互操作,那是没有意义的,因为为框架开发的每个插件都必须准备许多版本,这些版本针对不同的编译器在不同的操作系统上。所以分发源代码会比这更实用,这就是 GNU 的方式(下载 src,配置和制作)

COM 是一种选择,但它过于复杂和过时。或在 .Net 运行时托管 C++。但它们只在 ms os 上。如果您想要一个通用的解决方案,我建议您更改为另一种语言。

于 2013-02-11T05:16:30.657 回答
1

正如 jean 所提到的,由于没有标准的 C++ ABI 和标准的名称修改约定,因此您必须使用相同的编译器和链接器来编译东西。如果你想要一个共享库/dll 类型的插件,你必须使用 C-ish。

如果所有内容都将使用相同的编译器和链接器进行编译,您可能还需要考虑 std::function。

typedef std::function<void ()> plugin_function;

std::map<std::string, plugin_function> fncMap;

void register_func(std::string name, plugin_function fnc)
{
   fncMap[name] = fnc;
}

void call(std::string name)
{
   auto it = fncMap.find(name);
   if (it != fncMap.end())
      (it->second)();   // it->second is a function object
}


///////////////

void func()
{
   std::cout << "plain" << std::endl;
}

class T
{
public:
   void method()
   {
     std::cout << "method" << std::endl;
   }

   void method2(int i)
   {
     std::cout << "method2 : " << i << std::endl;
   }
};


T t; // of course "t" needs to outlive the map, you could just as well use shared_ptr

register_func("plain", func);
register_func("method", std::bind(&T::method, &t));
register_func("method2_5", std::bind(&T::method2, &t, 5));
register_func("method2_15", std::bind(&T::method2, &t, 15));

call("plain");
call("method");
call("method2_5");
call("method2_15");

您还可以使用带有参数的插件函数。这将使用 std::bind 的占位符,但很快你会发现它在 boost::bind 后面有些欠缺。Boost bind 有很好的文档和示例。

于 2013-02-12T20:15:54.553 回答
0

您没有理由不这样做。在 C++ 中使用这种类型的指针是最好的,因为它只是一个普通的指针。我知道没有流行的编译器会做任何像不像普通指针那样制作函数指针那样脑残的事情。有人会做出如此可怕的事情,这超出了理性的范围。

Vst 插件标准以类似的方式运行。它只是在 .dll 中使用函数指针,并且没有直接调用类的方法。Vst 是一个非常流行的标准,在 Windows 上人们几乎可以使用任何编译器来执行 Vst 插件,包括基于 Pascal 且与 C++ 无关的 Delphi。

所以我会完全按照你个人的建议去做。对于常见的知名插件,我不会使用字符串名称,而是使用可以更快查找的整数索引。

另一种方法是使用接口,但如果您的想法已经基于函数指针,我认为没有理由。

如果你使用接口,那么从其他语言调用函数就不是那么容易了。你可以从 Delphi 中做到这一点,但 .NET 呢?

例如,根据您的函数指针样式建议,您可以使用 .NET 来制作其中一个插件。显然,您需要在程序中托管 Mono 来加载它,但只是出于假设目的,它说明了它的简单性。

此外,当您使用接口时,您必须进行引用计数,这很讨厌。像您建议的那样将您的逻辑粘贴在函数指针中,然后将控件包装在一些 C++ 类中,以便为您执行调用和处理。然后其他人可以使用其他语言制作插件,例如 Delphi Pascal、Free Pascal、C、其他 C++ 编译器等...

但与往常一样,无论您做什么,编译器之间的异常处理仍然是一个问题,因此您必须考虑错误处理。最好的方法是插件自己的方法捕获自己的插件异常并将错误代码返回给内核等......

于 2013-02-15T17:51:44.230 回答
0

有了上面所有出色的答案,我只想补充一点,这种做法实际上分布广泛。在我的实践中,我在商业项目和免费软件/开源项目中都看到了它。

所以 - 是的,这是一个很好且经过验证的架构。

于 2013-02-16T21:14:37.337 回答
0

您不需要手动注册函数。真的吗?真的。

您可以使用插件接口的代理实现,其中每个函数按需从共享库中加载其原始文件,并透明地调用它。到达该接口定义的代理对象的任何人都可以调用这些函数。它们将按需加载。

如果插件是单例,则根本不需要手动绑定(否则必须首先选择正确的实例)。

新插件的开发者的想法是首先描述接口,然后有一个生成器为共享库的实现生成一个存根,另外还有一个具有相同签名但具有按需自动加载的插件代理类然后在客户端软件中使用。两者都应该实现相同的接口(在 C++ 中是纯抽象类)。

于 2013-02-17T15:04:41.327 回答