C++Projects

Project: Building a Modular Plugin System with Templates, CRTP & Concepts — Phase 3 Capstone

TT
TopicTrick Team
Project: Building a Modular Plugin System with Templates, CRTP & Concepts — Phase 3 Capstone

Project: Building a Modular Plugin System with Templates, CRTP & Concepts — Phase 3 Capstone


Table of Contents


Plugin System Architecture


Step 1: The IPlugin Abstract Interface

cpp
// include/iplugin.hpp
#pragma once
#include <string>
#include <string_view>
#include <memory>
#include <any>

// Plugin metadata — every plugin must provide this:
struct PluginInfo {
    std::string name;
    std::string version;
    std::string description;
    std::string author;
};

// The contract every plugin must fulfill:
class IPlugin {
public:
    virtual ~IPlugin() = default;
    
    // Lifecycle hooks:
    virtual bool initialize(const std::any& config) = 0; // Called after loading
    virtual void shutdown()  = 0;   // Called before unloading
    
    // Core operation:
    virtual void execute() = 0;
    
    // Metadata (static would be better, but virtual enables runtime query):
    virtual PluginInfo info() const = 0;
    
    // Event handling (optional — base provides empty default):
    virtual void on_event(std::string_view event_type, const std::any& data) {}
};

// Factory function type — exported from shared libraries:
using PluginFactory = std::unique_ptr<IPlugin>(*)();

// The C linkage function name every plugin .so/.dll must export:
extern "C" std::unique_ptr<IPlugin> create_plugin();

Step 2: Generic PluginRegistry<T>

cpp
// include/plugin_registry.hpp
#pragma once
#include "iplugin.hpp"
#include <vector>
#include <memory>
#include <string_view>
#include <algorithm>
#include <print>

template<typename T>
requires std::derived_from<T, IPlugin> // T must be-an IPlugin
class PluginRegistry {
    std::vector<std::unique_ptr<T>> plugins_;
    
public:
    // Register a plugin (takes ownership):
    void register_plugin(std::unique_ptr<T> plugin) {
        if (!plugin) throw std::invalid_argument("Null plugin");
        std::println("[Registry] Registered: {} v{}",
                     plugin->info().name, plugin->info().version);
        plugins_.push_back(std::move(plugin));
    }
    
    // Find by name:
    T* find(std::string_view name) const {
        auto it = std::ranges::find_if(plugins_, [name](const auto& p) {
            return p->info().name == name;
        });
        return it != plugins_.end() ? it->get() : nullptr;
    }
    
    // Execute all plugins:
    void run_all() {
        for (auto& plugin : plugins_) plugin->execute();
    }
    
    // Broadcast event to all plugins:
    void broadcast(std::string_view event, const std::any& data = {}) {
        for (auto& plugin : plugins_) plugin->on_event(event, data);
    }
    
    // Unregister by name:
    bool unregister(std::string_view name) {
        auto it = std::ranges::find_if(plugins_, [name](const auto& p) {
            return p->info().name == name;
        });
        if (it == plugins_.end()) return false;
        (*it)->shutdown();
        plugins_.erase(it);
        return true;
    }
    
    [[nodiscard]] size_t size() const { return plugins_.size(); }
    bool empty()             const { return plugins_.empty(); }
    
    // Range-based for support:
    auto begin() { return plugins_.begin(); }
    auto end()   { return plugins_.end();   }
};

Step 3: CRTP Plugin Base for Zero-Overhead Helpers

cpp
// include/plugin_base.hpp
#pragma once
#include "iplugin.hpp"

// CRTP base: provides default implementations without virtual dispatch:
template<typename Derived>
class PluginBase : public IPlugin {
public:
    // Default initialize (derived can override for custom config parsing):
    bool initialize(const std::any&) override { return true; }
    
    // Default shutdown (derived overrides if cleanup needed):
    void shutdown() override {}
    
    // Default event handler (no-op):
    void on_event(std::string_view, const std::any&) override {}
    
    // CRTP helper: self() gives typed access to Derived
    Derived& self() { return static_cast<Derived&>(*this); }
    const Derived& self() const { return static_cast<const Derived&>(*this); }
    
    // Static factory — avoid raw new:
    static std::unique_ptr<Derived> create() {
        return std::make_unique<Derived>();
    }
};

Step 4: Concept Constraints on Registration

cpp
// include/plugin_concepts.hpp
#pragma once
#include "iplugin.hpp"
#include <concepts>

// A plugin must provide static metadata (checked at compile time):
template<typename T>
concept HasStaticMetadata = requires {
    { T::plugin_name()    } -> std::convertible_to<std::string_view>;
    { T::plugin_version() } -> std::convertible_to<std::string_view>;
    { T::plugin_author()  } -> std::convertible_to<std::string_view>;
};

// A plugin must have a no-argument constructor:
template<typename T>
concept DefaultConstructiblePlugin =
    std::derived_from<T, IPlugin> &&
    std::default_initializable<T>;

// Full plugin constraint:
template<typename T>
concept ValidPlugin =
    DefaultConstructiblePlugin<T> &&
    HasStaticMetadata<T>;

// Constrained registration function:
template<ValidPlugin T>
void register_plugin_checked(PluginRegistry<IPlugin>& registry) {
    auto plugin = std::make_unique<T>();
    
    // Static metadata verification at compile time:
    static_assert(T::plugin_name().length() > 0, "Plugin name cannot be empty");
    
    std::println("[Concept-checked] Registering: {}", T::plugin_name());
    registry.register_plugin(std::move(plugin));
}

Step 5: Dynamic Loading with dlopen/LoadLibrary

cpp
// include/plugin_loader.hpp
#pragma once
#include "iplugin.hpp"
#include <string>
#include <memory>
#include <stdexcept>

#ifdef _WIN32
  #include <windows.h>
  #define PLUGIN_HANDLE HMODULE
  #define LOAD_LIBRARY(path) LoadLibraryA(path)
  #define GET_SYMBOL(lib, sym) GetProcAddress(lib, sym)
  #define CLOSE_LIBRARY(lib) FreeLibrary(lib)
#else
  #include <dlfcn.h>
  #define PLUGIN_HANDLE void*
  #define LOAD_LIBRARY(path) dlopen(path, RTLD_LAZY | RTLD_LOCAL)
  #define GET_SYMBOL(lib, sym) dlsym(lib, sym)
  #define CLOSE_LIBRARY(lib) dlclose(lib)
#endif

class DynamicPlugin {
    PLUGIN_HANDLE          handle_ = nullptr;
    std::unique_ptr<IPlugin> plugin_;
    std::string            path_;
    
public:
    explicit DynamicPlugin(const std::string& so_path) : path_(so_path) {
        handle_ = LOAD_LIBRARY(so_path.c_str());
        if (!handle_) {
#ifdef _WIN32
            throw std::runtime_error("LoadLibrary failed: " + so_path);
#else
            throw std::runtime_error(std::string("dlopen failed: ") + dlerror());
#endif
        }
        
        auto factory = reinterpret_cast<PluginFactory>(
            GET_SYMBOL(handle_, "create_plugin")
        );
        if (!factory) throw std::runtime_error("create_plugin symbol not found");
        
        plugin_ = factory();
        if (!plugin_) throw std::runtime_error("create_plugin returned null");
    }
    
    ~DynamicPlugin() {
        if (plugin_) {
            plugin_->shutdown();
            plugin_.reset(); // Destroy before closing library
        }
        if (handle_) CLOSE_LIBRARY(handle_);
    }
    
    IPlugin* get() { return plugin_.get(); }
    const std::string& path() const { return path_; }
};

Complete Plugin Implementation Example

cpp
// plugins/analytics/analytics_plugin.cpp
#include <iplugin.hpp>
#include <plugin_base.hpp>
#include <print>
#include <chrono>

class AnalyticsPlugin : public PluginBase<AnalyticsPlugin> {
    size_t event_count_ = 0;
    std::chrono::steady_clock::time_point start_;
    
public:
    // Static metadata — satisfies HasStaticMetadata concept:
    static constexpr std::string_view plugin_name()    { return "Analytics"; }
    static constexpr std::string_view plugin_version() { return "2.1.0"; }
    static constexpr std::string_view plugin_author()  { return "TopicTrick Team"; }
    
    PluginInfo info() const override {
        return {std::string(plugin_name()), std::string(plugin_version()),
                "Collects and reports analytics data", std::string(plugin_author())};
    }
    
    bool initialize(const std::any& config) override {
        start_ = std::chrono::steady_clock::now();
        std::println("[Analytics] Initialized");
        return true;
    }
    
    void execute() override {
        auto elapsed = std::chrono::steady_clock::now() - start_;
        auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count();
        std::println("[Analytics] {} events in {}ms", event_count_, ms);
    }
    
    void on_event(std::string_view event, const std::any&) override {
        ++event_count_;
        std::println("[Analytics] Event: {}", event);
    }
    
    void shutdown() override {
        std::println("[Analytics] Shutdown — total events: {}", event_count_);
    }
};

// C-linkage factory — required for dynamic loading:
extern "C" std::unique_ptr<IPlugin> create_plugin() {
    return std::make_unique<AnalyticsPlugin>();
}

Extension Challenges

  1. Plugin versioning: Add minimum_host_version to PluginInfo and reject plugins requiring a newer API than the host provides
  2. Dependency injection: Add register_service<T>(std::shared_ptr<T>) to the registry and allow plugins to declare dependencies via requires<T>()
  3. Priority ordering: Add an int priority() to the plugin interface and sort the execution order automatically
  4. Config file: Parse a plugins.toml file to determine which plugins to load and what config to pass initialize()

Phase 3 Reflection

Phase 3 ConceptUsed In Plugin System
Abstract interfaces (Module 11)IPlugin pure virtual contract
CRTP static polymorphism (Module 11)PluginBase<Derived> zero-overhead helpers
unique_ptr ownership (Module 6)Registry owns plugins via unique_ptr<T>
std::move semantics (Module 3)Plugin transferred into registry on registration
Templates + PluginRegistry<T> (Module 13)Generic registry works for any IPlugin subtype
Concepts (Module 19)ValidPlugin constrains registration
std::ranges::find_if (Module 15)Finding plugins by name
Variadic compile-time checksstatic_assert on plugin names

Proceed to Phase 4: Multithreading & Atomics →

Frequently Asked Questions

Q: How do C++ templates enable a flexible plugin system? Templates allow you to define a generic plugin interface at compile time — a base class or concept that every plugin must satisfy — while the plugin registry instantiates and stores concrete types. Using a factory pattern with std::function<std::unique_ptr<IPlugin>()> as the registration value lets plugins self-register at startup via static initialisation, and the registry stores them in a std::unordered_map<std::string, FactoryFn>. This gives you type-safe registration with dynamic dispatch at runtime.

Q: What is the difference between a compile-time plugin system (templates) and a runtime plugin system (shared libraries)? A compile-time plugin system links all plugins into the main binary — it is fast (no dynamic dispatch overhead beyond virtual calls), type-safe, and easy to debug, but requires recompilation to add plugins. A runtime system loads .so/.dll files via dlopen/LoadLibrary, enabling plugins to be added without recompiling the host, but requires a stable C ABI, careful symbol versioning, and cross-platform handling. Embedded and game engines often use compile-time plugins; extensible desktop tools use runtime loading.

Q: How do you ensure plugins cannot crash the host application in a C++ plugin system? For compile-time plugins, enforce the interface contract through concepts (requires expressions in C++20) so plugin authors get clear compile errors rather than runtime crashes. Validate plugin output before using it. For runtime plugins, run them in a separate process and communicate via IPC — a crashing plugin then does not take down the host. Within-process runtime plugins are inherently risky; use exception boundaries (try/catch at the plugin call site) as a minimum safety net, and document that plugins must not throw across the ABI boundary.


Part of the C++ Mastery Course — 30 modules from modern C++ basics to expert systems engineering.