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
- Step 2: Generic
PluginRegistry<T> - Step 3: CRTP Plugin Base for Zero-Overhead Helpers
- Step 4: Concept Constraints on Registration
- Step 5: Dynamic Loading with dlopen/LoadLibrary
- Step 6: Plugin Metadata and Discovery
- Step 7: Hot-Reload Support
- Complete Plugin Implementation Example
- Extension Challenges
- Phase 3 Reflection
Plugin System Architecture
Step 1: The IPlugin Abstract Interface
// 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>
// 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
// 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
// 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
// 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
// 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
- Plugin versioning: Add
minimum_host_versiontoPluginInfoand reject plugins requiring a newer API than the host provides - Dependency injection: Add
register_service<T>(std::shared_ptr<T>)to the registry and allow plugins to declare dependencies viarequires<T>() - Priority ordering: Add an
int priority()to the plugin interface and sort the execution order automatically - Config file: Parse a
plugins.tomlfile to determine which plugins to load and what config to passinitialize()
Phase 3 Reflection
| Phase 3 Concept | Used 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 checks | static_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.
