Using Classes

Learn how to use class wrappers for cleaner, object-oriented plugin APIs in C++.

Using Classes in C++

Classes in Plugify provide a more convenient, type-safe, and RAII-compliant way to work with complex objects exported by plugins. Instead of manually managing raw pointers and calling constructor/destructor functions, you can use generated C++ class wrappers that handle resource management automatically.

Why Use Classes?

When a plugin exports functions that create and destroy objects (like Kv1Create and Kv1Destroy), you could call these functions directly:

// Manual approach - error-prone
void* kv = test_keyvalues::Kv1Create("Config");
test_keyvalues::Kv1SetName(kv, "ServerConfig");
auto name = test_keyvalues::Kv1GetName(kv);
test_keyvalues::Kv1Destroy(kv);  // Easy to forget!

However, this approach has several problems:

  1. Resource Leaks: If you forget to call Kv1Destroy(), the resource leaks
  2. Exception Unsafety: If an exception is thrown, destructor won't be called
  3. No Type Safety: Raw void* pointers provide no compile-time type checking
  4. Verbose: You must manually pass the handle to every function call
  5. Error-Prone: Easy to use the handle after it's been destroyed

Classes solve all these problems by using C++ RAII (Resource Acquisition Is Initialization):

// RAII approach - automatic, safe, exception-safe
test_keyvalues::KeyValues kv("Config");
kv.SetName("ServerConfig");
auto name = kv.GetName();
// Automatically destroyed when kv goes out of scope!

How Classes Work

The generator analyzes your plugin manifest and creates C++ class wrappers for objects that have both constructor and destructor functions.

Manifest Definition

{
  "fileVersion": 1,
  "version": 1,
  "versionName": "1.0",
  "friendlyName": "Test KeyValues Plugin",
  "description": "Example plugin with KeyValues class",
  "createdBy": "Untrusted Modders Team",
  "createdByURL": "https://github.com/untrustedmodders",
  "docsURL": "https://github.com/untrustedmodders/plugify/wiki",
  "downloadURL": "https://github.com/untrustedmodders/plugify-module-cpp",
  "updateURL": "https://raw.githubusercontent.com/untrustedmodders/plugify-module-cpp/main/.github/plugify-module-cpp.json",
  "exportedMethods": [
    {
      "name": "Kv1Create",
      "paramTypes": ["string"],
      "retType": {
        "type": "ptr64",
        "ref": "kv1"
      },
      "funcName": "Kv1Create"
    },
    {
      "name": "Kv1Destroy",
      "paramTypes": [
        {
          "type": "ptr64",
          "ref": "kv1"
        }
      ],
      "funcName": "Kv1Destroy"
    },
    {
      "name": "Kv1GetName",
      "paramTypes": [
        {
          "type": "ptr64",
          "ref": "kv1"
        }
      ],
      "retType": "string",
      "funcName": "Kv1GetName"
    },
    {
      "name": "Kv1SetName",
      "paramTypes": [
        {
          "type": "ptr64",
          "ref": "kv1"
        },
        "string"
      ],
      "funcName": "Kv1SetName"
    },
    {
      "name": "Kv1FindKey",
      "paramTypes": [
        {
          "type": "ptr64",
          "ref": "kv1"
        },
        "string"
      ],
      "retType": {
        "type": "ptr64",
        "ref": "kv1",
        "owner": false
      },
      "funcName": "Kv1FindKey"
    },
    {
      "name": "Kv1AddSubKey",
      "paramTypes": [
        {
          "type": "ptr64",
          "ref": "kv1"
        },
        {
          "type": "ptr64",
          "ref": "kv1",
          "owner": true
        }
      ],
      "funcName": "Kv1AddSubKey"
    }
  ],
  "dependencies": [],
  "entryPoint": "test_keyvalues"
}

Key Manifest Features for Classes

  1. Reference Name (ref): All related functions use the same ref value (e.g., "kv1") to group them together
  2. Constructor Pattern: A function that returns {"type": "ptr64", "ref": "kv1"} becomes the class constructor
  3. Destructor Pattern: A function that takes {"type": "ptr64", "ref": "kv1"} and returns nothing becomes the destructor
  4. Ownership (owner): Controls whether the class takes ownership of a parameter or return value

Generated Class

From the manifest above, the generator creates a KeyValues class:

namespace test_keyvalues {

  enum class Ownership : bool { Borrowed, Owned };

  /**
   * @brief RAII wrapper for KeyValues handle.
   */
  class KeyValues final {
  public:
    KeyValues() = default;

    /**
     * @brief Creates a new KeyValues instance
     * @param setName (string): The name to assign to this KeyValues instance
     */
    explicit KeyValues(const plg::string& setName)
      : KeyValues(Kv1Create(setName), Ownership::Owned) {}

    ~KeyValues() {
      destroy();
    }

    // Prevent copying (would cause double-free)
    KeyValues(const KeyValues&) = delete;
    KeyValues& operator=(const KeyValues&) = delete;

    // Allow moving (transfer ownership)
    KeyValues(KeyValues&& other) noexcept
      : _handle(other._handle)
      , _ownership(other._ownership) {
      other.nullify();
    }

    KeyValues& operator=(KeyValues&& other) noexcept {
      if (this != &other) {
        destroy();
        _handle = other._handle;
        _ownership = other._ownership;
        other.nullify();
      }
      return *this;
    }

    // Construct from raw handle with ownership control
    KeyValues(void* handle, Ownership ownership)
      : _handle(handle), _ownership(ownership) {}

    // Get raw handle without transferring ownership
    [[nodiscard]] auto get() const noexcept { return _handle; }

    // Transfer ownership to caller (releases internal handle)
    [[nodiscard]] auto release() noexcept {
      auto handle = _handle;
      nullify();
      return handle;
    }

    // Destroy resource and clear handle
    void reset() noexcept {
      destroy();
      nullify();
    }

    // Swap with another instance
    void swap(KeyValues& other) noexcept {
      using std::swap;
      swap(_handle, other._handle);
      swap(_ownership, other._ownership);
    }

    friend void swap(KeyValues& lhs, KeyValues& rhs) noexcept {
      lhs.swap(rhs);
    }

    // Check if handle is valid
    explicit operator bool() const noexcept {
      return _handle != nullptr;
    }

    // Comparison operators
    [[nodiscard]] auto operator<=>(const KeyValues& other) const noexcept {
      return _handle <=> other._handle;
    }

    [[nodiscard]] bool operator==(const KeyValues& other) const noexcept {
      return _handle == other._handle;
    }

    // Bound methods (automatically check handle validity)
    plg::string GetName() {
      if (_handle == nullptr)
        throw std::runtime_error("KeyValues: Empty handle");
      return Kv1GetName(_handle);
    }

    void SetName(const plg::string& name) {
      if (_handle == nullptr)
        throw std::runtime_error("KeyValues: Empty handle");
      Kv1SetName(_handle, name);
    }

    KeyValues FindKey(const plg::string& keyName) {
      if (_handle == nullptr)
        throw std::runtime_error("KeyValues: Empty handle");
      // Borrowed ownership - caller doesn't own the returned object
      return KeyValues(Kv1FindKey(_handle, keyName), Ownership::Borrowed);
    }

    void AddSubKey(KeyValues&& subKey) {
      if (_handle == nullptr)
        throw std::runtime_error("KeyValues: Empty handle");
      // Takes ownership - subKey is moved and released
      Kv1AddSubKey(_handle, subKey.release());
    }

  private:
    void destroy() const noexcept {
      if (_handle != nullptr && _ownership == Ownership::Owned) {
        Kv1Destroy(_handle);
      }
    }

    void nullify() noexcept {
      _handle = nullptr;
      _ownership = Ownership::Borrowed;
    }

    void* _handle{nullptr};
    Ownership _ownership{Ownership::Borrowed};
  };

} // namespace test_keyvalues

Utility Methods

Every generated class includes these utility methods:

get()

Returns the raw handle without transferring ownership. Use this when you need to pass the handle to C-style functions that don't take ownership:

test_keyvalues::KeyValues kv("Config");
void* raw_handle = kv.get();
// kv still owns the handle - will destroy it when kv goes out of scope

release()

Transfers ownership to the caller and clears the internal handle. Use this when a function takes ownership of the object:

test_keyvalues::KeyValues subKey("SubSection");
parent.AddSubKey(std::move(subKey));  // AddSubKey calls release() internally

// After this call:
// - parent now owns the subKey resource
// - subKey object is moved and its handle is released
// - When parent is destroyed, it will also destroy the subKey

reset()

Destroys the managed resource (if owned) and clears the handle:

test_keyvalues::KeyValues kv("Config");
kv.reset();  // Destroys the resource immediately
// kv is now empty (handle == nullptr)

swap()

Exchanges the state with another instance:

test_keyvalues::KeyValues kv1("Config1");
test_keyvalues::KeyValues kv2("Config2");
kv1.swap(kv2);  // Now kv1 has Config2, kv2 has Config1

// Or using std::swap:
std::swap(kv1, kv2);

operator bool()

Checks if the handle is valid (not null):

test_keyvalues::KeyValues kv("Config");
if (kv) {
    // Handle is valid
    kv.SetName("NewName");
}

test_keyvalues::KeyValues empty;
if (!empty) {
    // Handle is null
}

Comparison Operators

Compare instances by their handle values:

test_keyvalues::KeyValues kv1("Config1");
test_keyvalues::KeyValues kv2("Config2");

if (kv1 == kv2) {  // Compare by handle
    // Same underlying object
}

if (kv1 != kv2) {
    // Different objects
}

// Spaceship operator for ordering
if (kv1 < kv2) {
    // kv1's handle is less than kv2's
}

Resource Management with RAII

C++ classes use RAII (Resource Acquisition Is Initialization) for automatic resource management. This is the most deterministic approach among all languages supported by Plugify.

Automatic Cleanup

Resources are automatically destroyed when objects go out of scope:

void processConfig() {
    test_keyvalues::KeyValues kv("ServerConfig");
    kv.SetName("Production");
    // Resource is automatically destroyed when kv goes out of scope
} // Destructor called here - Kv1Destroy() is invoked automatically

Exception Safety

RAII guarantees cleanup even when exceptions are thrown:

void riskyOperation() {
    test_keyvalues::KeyValues kv("Config");

    performDatabaseOperation();  // Might throw
    performNetworkOperation();   // Might throw
    performFileOperation();      // Might throw

    // If ANY of the above throws, kv's destructor is still called
    // Resource is guaranteed to be cleaned up!
} // Destructor called even if exception was thrown

This is superior to other languages:

  • JavaScript: No deterministic cleanup, must use try-finally
  • Go: Must remember to use defer kv.Close()
  • C#: Must use using statement for deterministic cleanup
  • Python: Must use with statement or manually call __exit__()
  • C++: Automatic - no special syntax required!

Move Semantics

C++ classes support move semantics for efficient ownership transfer:

test_keyvalues::KeyValues createConfig() {
    test_keyvalues::KeyValues kv("Config");
    kv.SetName("ServerConfig");
    return kv;  // Move - no copy, ownership transferred to caller
}

void useConfig() {
    test_keyvalues::KeyValues config = createConfig();  // Move construction
    // config now owns the resource
} // Resource destroyed here

Moving is efficient and safe:

  • No copying: Move constructor transfers ownership without copying
  • No double-free: Moved-from object is left in a valid but empty state
  • Zero overhead: Compilers optimize moves to be as fast as passing pointers

Copy Prevention

Classes prevent copying to avoid double-free errors:

test_keyvalues::KeyValues kv1("Config");
test_keyvalues::KeyValues kv2 = kv1;  // ERROR: Copy constructor is deleted

void func(test_keyvalues::KeyValues kv);  // ERROR: Would copy
func(kv1);  // Won't compile

// Use move instead:
test_keyvalues::KeyValues kv2 = std::move(kv1);  // OK - ownership transferred
void func2(test_keyvalues::KeyValues&& kv);  // OK - takes rvalue reference
func2(std::move(kv1));  // OK

// Or pass by reference:
void func3(test_keyvalues::KeyValues& kv);  // OK - no ownership transfer
func3(kv1);  // OK

Ownership Semantics

Ownership determines who is responsible for destroying an object. The generated classes track ownership using the Ownership enum:

enum class Ownership : bool { Borrowed, Owned };

Owned Resources

When you create an object using the constructor, the class owns the resource and will destroy it:

test_keyvalues::KeyValues kv("Config");
// kv owns the resource (_ownership == Ownership::Owned)
// Destructor will call Kv1Destroy() when kv goes out of scope

Borrowed Resources

When a method returns a pointer that you don't own (marked with "owner": false in the manifest), the class creates a borrowed instance:

test_keyvalues::KeyValues parent("Parent");
auto child = parent.FindKey("ChildKey");
// child is borrowed (_ownership == Ownership::Borrowed)
// child's destructor will NOT call Kv1Destroy()
// parent owns the actual child resource

Important: Borrowed objects must not outlive the object they were borrowed from:

test_keyvalues::KeyValues* dangling;
{
    test_keyvalues::KeyValues parent("Parent");
    auto child = parent.FindKey("Child");
    dangling = &child;  // DANGEROUS!
} // parent is destroyed here, taking child with it
// dangling now points to a destroyed object - undefined behavior!

Ownership Transfer

Some methods take ownership of objects passed to them (marked with "owner": true in the manifest). These methods accept rvalue references and call release():

test_keyvalues::KeyValues parent("Parent");
test_keyvalues::KeyValues child("Child");

parent.AddSubKey(std::move(child));
// AddSubKey calls child.release() internally
// parent now owns child's resource
// child is now empty (handle == nullptr)

// WRONG: Don't use child after moving it
child.SetName("NewName");  // Throws: Empty handle!

// WRONG: Don't move from an lvalue without std::move
parent.AddSubKey(child);  // Won't compile - needs rvalue reference

Manual Ownership Control

You can manually control ownership using the KeyValues(void*, Ownership) constructor:

void* raw_handle = getRawHandleFromSomewhere();

// Create owned wrapper - will destroy when going out of scope
test_keyvalues::KeyValues owned(raw_handle, test_keyvalues::Ownership::Owned);

// Create borrowed wrapper - won't destroy
test_keyvalues::KeyValues borrowed(raw_handle, test_keyvalues::Ownership::Borrowed);

Plugin Lifecycle and Global Objects

Critical Warning About Global/Static Objects

If you store class instances in global variables, static variables, or class fields, you must manually destroy them in pluginEnd() to prevent crashes during plugin unload:

// DANGEROUS: Global object
test_keyvalues::KeyValues g_config;

PLUGIN_EXPORT void pluginStart() {
    g_config = test_keyvalues::KeyValues("GlobalConfig");
    // ... use config
}

PLUGIN_EXPORT void pluginEnd() {
    // CRITICAL: Destroy global objects BEFORE plugin unloads!
    g_config.reset();  // Or g_config = test_keyvalues::KeyValues{};
}

Why is this necessary?

C++ destructors for global objects run after the plugin has unloaded:

  1. pluginEnd() is called
  2. Your plugin's shared library is unloaded from memory
  3. Global destructors are called
  4. Global destructor tries to call Kv1Destroy() - but the function is gone!
  5. Crash with segmentation fault or access violation

Solutions

Option 1: Avoid global objects (Recommended)

Use local variables with RAII instead:

PLUGIN_EXPORT void pluginStart() {
    test_keyvalues::KeyValues config("Config");
    // Use config...
} // Automatically destroyed before plugin unloads

Option 2: Manual cleanup in pluginEnd()

If you must use globals, always clean them up:

test_keyvalues::KeyValues g_config;

PLUGIN_EXPORT void pluginEnd() {
    g_config.reset();  // Destroy NOW, not later
}

Option 3: Use smart pointers

#include <memory>

std::unique_ptr<test_keyvalues::KeyValues> g_config;

PLUGIN_EXPORT void pluginStart() {
    g_config = std::make_unique<test_keyvalues::KeyValues>("Config");
}

PLUGIN_EXPORT void pluginEnd() {
    g_config.reset();  // Destroy and set to nullptr
}

Option 4: Use std::optional

#include <optional>

std::optional<test_keyvalues::KeyValues> g_config;

PLUGIN_EXPORT void pluginStart() {
    g_config.emplace("Config");
}

PLUGIN_EXPORT void pluginEnd() {
    g_config.reset();  // Destroy the contained object
}

Class Fields

The same rules apply to class fields:

class MyPlugin {
public:
    void start() {
        m_config = test_keyvalues::KeyValues("Config");
    }

    void stop() {
        // CRITICAL: Destroy field objects!
        m_config.reset();
    }

private:
    test_keyvalues::KeyValues m_config;
};

Complete Example

Here's a complete example demonstrating all the concepts:

#include <plugify/plugify.hpp>
#include <test_keyvalues.hpp>
#include <iostream>

class ConfigManager {
public:
    void Initialize() {
        // Create owned object
        m_rootConfig = test_keyvalues::KeyValues("ServerConfig");
        m_rootConfig.SetName("Production");

        // Create subkeys and transfer ownership
        auto database = test_keyvalues::KeyValues("Database");
        database.SetName("PostgreSQL");
        m_rootConfig.AddSubKey(std::move(database));
        // database is now empty - ownership transferred

        auto caching = test_keyvalues::KeyValues("Caching");
        caching.SetName("Redis");
        m_rootConfig.AddSubKey(std::move(caching));
    }

    void ProcessConfig() {
        // Find returns borrowed reference
        auto dbConfig = m_rootConfig.FindKey("Database");
        if (dbConfig) {
            std::cout << "Database: " << dbConfig.GetName() << std::endl;
            // dbConfig is borrowed - m_rootConfig still owns it
        }
        // dbConfig destroyed here, but doesn't call Kv1Destroy (borrowed)
    }

    void UpdateConfig() {
        // Create temporary owned object
        auto networking = test_keyvalues::KeyValues("Networking");
        networking.SetName("HTTP/2");

        // Transfer ownership to root config
        m_rootConfig.AddSubKey(std::move(networking));
        // networking is now empty

    } // Temporary objects safely destroyed (if any)

    void Cleanup() {
        // CRITICAL: Must call before plugin unloads!
        m_rootConfig.reset();
    }

private:
    test_keyvalues::KeyValues m_rootConfig;
};

ConfigManager g_manager;

PLUGIN_EXPORT void pluginStart() {
    try {
        g_manager.Initialize();
        g_manager.ProcessConfig();
        g_manager.UpdateConfig();
    } catch (const std::exception& e) {
        std::cerr << "Error: " << e.what() << std::endl;
        // Even if exception is thrown, objects are cleaned up
    }
}

PLUGIN_EXPORT void pluginEnd() {
    // CRITICAL: Clean up before unload!
    g_manager.Cleanup();
}

Best Practices

  1. Prefer local variables: Use RAII with local variables for automatic cleanup
    void process() {
        test_keyvalues::KeyValues kv("Config");
        // Use kv...
    } // Automatically destroyed
    
  2. Use std::move for ownership transfer: Always use std::move() when transferring ownership
    parent.AddSubKey(std::move(child));  // Correct
    parent.AddSubKey(child);  // Won't compile
    
  3. Check validity before use: Use operator bool() to check if handle is valid
    auto child = parent.FindKey("Child");
    if (child) {
        child.SetName("NewName");
    }
    
  4. Clean up global objects: Always call reset() in pluginEnd() for global/static objects
    PLUGIN_EXPORT void pluginEnd() {
        g_config.reset();
    }
    
  5. Don't use moved-from objects: After moving, objects are empty
    test_keyvalues::KeyValues kv("Config");
    auto kv2 = std::move(kv);
    // kv is now empty - don't use it!
    
  6. Use const references for read-only access: Avoid unnecessary ownership transfers
    void readConfig(const test_keyvalues::KeyValues& kv) {
        auto name = kv.GetName();
    }
    
  7. Use smart pointers for optional ownership: When ownership is dynamic, use std::unique_ptr
    std::unique_ptr<test_keyvalues::KeyValues> maybeConfig;
    if (needConfig) {
        maybeConfig = std::make_unique<test_keyvalues::KeyValues>("Config");
    }
    
  8. Leverage exception safety: RAII provides automatic cleanup on exceptions
    void riskyOperation() {
        test_keyvalues::KeyValues kv("Config");
        mightThrow();  // kv is still destroyed even if this throws
    }
    

Troubleshooting

"Empty handle" Exception

Problem: You get std::runtime_error: KeyValues: Empty handle when calling methods.

Causes:

  • Using a default-constructed object without initializing it
  • Using an object after calling reset()
  • Using an object after moving it with std::move()

Solution: Always check validity before use:

test_keyvalues::KeyValues kv;  // Empty
if (!kv) {
    kv = test_keyvalues::KeyValues("Config");  // Initialize
}
kv.SetName("NewName");  // Now safe

Crash on Plugin Unload

Problem: Application crashes with segmentation fault when plugin unloads.

Cause: Global/static objects are destroyed after the plugin unloads, trying to call functions that no longer exist.

Solution: Always clean up global objects in pluginEnd():

PLUGIN_EXPORT void pluginEnd() {
    g_config.reset();
    g_manager.Cleanup();
}

Double-Free or Use-After-Free

Problem: Crash or undefined behavior when using objects.

Causes:

  • Trying to copy objects (copy constructor is deleted, so this won't compile)
  • Using borrowed objects after the owning object is destroyed
  • Using moved-from objects

Solution: Follow ownership rules:

// Good: Use borrowed objects within owner's lifetime
{
    test_keyvalues::KeyValues parent("Parent");
    auto child = parent.FindKey("Child");
    if (child) {
        child.SetName("NewName");
    }
} // Both destroyed safely

// Bad: Borrowed object outlives owner
test_keyvalues::KeyValues* dangling;
{
    test_keyvalues::KeyValues parent("Parent");
    auto child = parent.FindKey("Child");
    dangling = &child;
} // parent destroyed, child is now dangling!
// Using dangling is undefined behavior

Cannot Copy Objects

Problem: Compiler error when trying to copy objects.

Cause: Copy constructor and copy assignment are deleted to prevent double-free.

Solution: Use move semantics or pass by reference:

// Wrong: Can't copy
test_keyvalues::KeyValues kv1("Config");
test_keyvalues::KeyValues kv2 = kv1;  // ERROR

// Correct: Move
test_keyvalues::KeyValues kv2 = std::move(kv1);  // OK

// Correct: Pass by reference
void processConfig(const test_keyvalues::KeyValues& kv) { }
processConfig(kv1);  // OK

Using Moved-From Objects

Problem: Methods throw "Empty handle" after moving an object.

Cause: After moving, the source object's handle is set to nullptr.

Solution: Don't use objects after moving them:

test_keyvalues::KeyValues child("Child");
parent.AddSubKey(std::move(child));
// child is now empty!

// Wrong: Using moved-from object
child.SetName("NewName");  // Throws: Empty handle

// Correct: Create new object if needed
child = test_keyvalues::KeyValues("NewChild");
child.SetName("NewName");  // OK now

Memory Leaks

Problem: Memory usage grows over time.

Cause: Creating owned objects without destroying them.

Solution: Let RAII handle cleanup automatically:

// Bad: Manual management
void* handle = test_keyvalues::Kv1Create("Config");
// ... forget to call Kv1Destroy() ... LEAK!

// Good: RAII handles it
void process() {
    test_keyvalues::KeyValues kv("Config");
    // ... use kv ...
} // Automatically destroyed - no leak

// Good: Explicit cleanup if needed
test_keyvalues::KeyValues kv("Config");
// ... use kv ...
kv.reset();  // Explicit cleanup

Advantages of C++ RAII

C++ classes with RAII provide the best resource management among all Plugify languages:

  1. Automatic cleanup: No special syntax required (unlike Go's defer, C#'s using)
  2. Deterministic: Destructors run immediately when objects go out of scope
  3. Exception safe: Resources are cleaned up even when exceptions are thrown
  4. Zero overhead: Move semantics provide efficient ownership transfer
  5. Compile-time safety: Copy prevention and type checking catch errors early
  6. Idiomatic: RAII is the standard C++ pattern for resource management

This makes C++ the most reliable and efficient language for working with Plugify classes.