# Mastering Callbacks in C++
Introduction
Callbacks are one of the most powerful and flexible patterns in C++ programming, enabling loose coupling between components and supporting event-driven architectures. A callback is essentially a function (or any callable entity) that you pass to another function to be invoked later, often in response to specific events or conditions.
Think of callbacks like giving someone your phone number and saying "call me when the package arrives." You're providing a way for them to notify you later, without them needing to know anything about what you'll do when they call.
Understanding Callbacks: The Fundamentals
What Makes Something a Callback?
The term "callback" describes a role, not a specific type. Any callable entity can serve as a callback:
- Function pointers
- Lambda expressions
- Function objects (functors)
- std::function objects
- Member function pointers
Basic Callback Implementation: Function Pointers
Let's start with the simplest form - function pointers:
#include <iostream>
// Define a readable type alias
using MathOperation = int(*)(int, int);
// Our callback functions
int add(int a, int b) { return a + b; }
int multiply(int a, int b) { return a * b; }
// A function that accepts and uses a callback
int calculate(int x, int y, MathOperation operation) {
return operation(x, y); // Invoke the callback
}
int main() {
// Pass different callbacks to the same function
std::cout << "Addition: " << calculate(5, 3, add) << std::endl; // Output: 8
std::cout << "Multiplication: " << calculate(5, 3, multiply) << std::endl; // Output: 15
// You can also use function pointer syntax
MathOperation op = &add;
std::cout << "Using pointer: " << op(10, 5) << std::endl; // Output: 15
}
Modern C++ Approach: std::function
While function pointers work, std::function provides much more flexibility:
#include <functional>
#include <iostream>
void demonstrateStdFunction() {
// std::function can hold various callable types
std::function<int(int, int)> operation;
// Assign a lambda
operation = [](int a, int b) { return a - b; };
std::cout << "Lambda subtraction: " << operation(10, 3) << std::endl; // Output: 7
// Assign a function pointer
operation = add;
std::cout << "Function pointer: " << operation(10, 3) << std::endl; // Output: 13
// Assign a functor
struct Divider {
int operator()(int a, int b) const { return a / b; }
};
operation = Divider{};
std::cout << "Functor division: " << operation(10, 2) << std::endl; // Output: 5
}
Building an Event System: Step-by-Step Implementation
Now let's build something practical - a robust event system that demonstrates advanced callback patterns. We'll develop this incrementally to understand each design decision.
Step 1: Basic Event Class Structure
template<typename... Args>
class Event {
// We'll build this step by step
};
The template parameter pack Args... allows our event to work with any signature:
- Event<> - no arguments
- Event
- single integer argument - Event<const std::string&, double> - string and double arguments
Step 2: The PIMPL Idiom - Why We Need It
Before we continue, let's understand a crucial design pattern we'll use: PIMPL (Pointer to Implementation).
What is PIMPL?
PIMPL is a technique where you separate the interface from the implementation by using a pointer to an internal structure that holds the actual data and logic.
// Without PIMPL - problematic design
class BadEvent {
std::unordered_map<int, std::function<void()>> listeners;
std::mutex mutex;
int nextId = 1;
// Problem: If this object is destroyed, how do external
// Connection objects know it's gone?
};
// With PIMPL - better design
class GoodEvent {
struct Impl {
std::unordered_map<int, std::function<void()>> listeners;
std::mutex mutex;
int nextId = 1;
};
std::shared_ptr<Impl> impl; // Shared ownership
// Now Connection objects can hold weak_ptr<Impl> to safely check if Event still exists
};
Benefits of PIMPL in Our Context
- Lifetime Safety: Connection objects can safely check if the Event still exists
- Shared Ownership: Multiple objects can reference the same implementation
- Thread Safety: Proper synchronization around shared state
- Exception Safety: RAII-style cleanup
Step 3: Complete Event Implementation
#include <functional>
#include <unordered_map>
#include <mutex>
#include <atomic>
#include <memory>
#include <vector>
#include <iostream>
template<typename... Args>
class Event {
private:
// PIMPL: Implementation struct holds the actual data
struct Impl {
mutable std::mutex mutex; // Thread safety
std::unordered_map<int, std::function<void(Args...)>> listeners; // Callback storage
std::atomic<int> nextId{1}; // Thread-safe ID generation
void unsubscribe(int id) {
std::lock_guard<std::mutex> lock(mutex);
listeners.erase(id);
}
};
// Shared pointer to implementation - enables safe sharing
std::shared_ptr<Impl> impl = std::make_shared<Impl>();
public:
// Connection: RAII-style subscription management
class Connection {
private:
std::weak_ptr<Impl> impl_; // Weak reference - doesn't prevent Event destruction
int id_{0}; // Unique subscription identifier
public:
Connection() = default;
Connection(std::shared_ptr<Impl> impl, int id)
: impl_(impl), id_(id) {}
// Destructor automatically disconnects
~Connection() { disconnect(); }
// Manual disconnection
void disconnect() {
if (id_ == 0) return; // Already disconnected
// Try to lock the weak_ptr - fails if Event was destroyed
if (auto shared_impl = impl_.lock()) {
shared_impl->unsubscribe(id_);
}
id_ = 0;
impl_.reset();
}
// Move-only semantics (prevent accidental copies)
Connection(const Connection&) = delete;
Connection& operator=(const Connection&) = delete;
Connection(Connection&& other) noexcept
: impl_(std::move(other.impl_)), id_(other.id_) {
other.id_ = 0;
}
Connection& operator=(Connection&& other) noexcept {
if (this != &other) {
disconnect();
impl_ = std::move(other.impl_);
id_ = other.id_;
other.id_ = 0;
}
return *this;
}
// Check if connection is still active
bool is_connected() const {
return id_ != 0 && !impl_.expired();
}
};
// Primary subscription method
Connection subscribe(std::function<void(Args...)> callback) {
int id = impl->nextId.fetch_add(1); // Atomic increment
{
std::lock_guard<std::mutex> lock(impl->mutex);
impl->listeners.emplace(id, std::move(callback));
}
return Connection{impl, id};
}
// Template overload for direct callable objects (lambdas, function pointers, etc.)
template<typename Callable>
Connection subscribe(Callable&& callable) {
return subscribe(std::function<void(Args...)>(std::forward<Callable>(callable)));
}
// Emit event to all subscribers
void emit(Args... args) {
// Copy callbacks under lock, then call without holding lock
// This prevents deadlocks and reduces lock contention
std::vector<std::function<void(Args...)>> callbacks_copy;
{
std::lock_guard<std::mutex> lock(impl->mutex);
callbacks_copy.reserve(impl->listeners.size());
for (const auto& [id, callback] : impl->listeners) {
callbacks_copy.push_back(callback);
}
}
// Call callbacks without holding the lock
for (const auto& callback : callbacks_copy) {
if (callback) { // Safety check
try {
callback(args...);
} catch (...) {
// In production, you might want to log this error
// For now, we continue with other callbacks
}
}
}
}
// Convenience method: subscribe member functions with automatic lifetime management
template<typename T>
Connection subscribe_weak(std::weak_ptr<T> weak_obj, void (T::*member_func)(Args...)) {
return subscribe([weak_obj, member_func](Args... args) {
// Try to lock the weak pointer
if (auto shared_obj = weak_obj.lock()) {
// Object still exists, call the member function
(shared_obj.get()->*member_func)(args...);
}
// If object was destroyed, silently ignore the call
});
}
// Get current subscriber count (thread-safe)
size_t subscriber_count() const {
std::lock_guard<std::mutex> lock(impl->mutex);
return impl->listeners.size();
}
};
Practical Examples and Usage Patterns
Let's see our event system in action with real-world scenarios:
// Example classes for demonstration
void global_handler(int value) {
std::cout << "Global handler received: " << value << std::endl;
}
class Logger {
public:
void log_event(int value) {
std::cout << "Logger: Event " << value << " occurred" << std::endl;
}
void log_detailed(const std::string& message, int priority) {
std::cout << "Logger [Priority " << priority << "]: " << message << std::endl;
}
};
class DataProcessor {
int processed_count_ = 0;
public:
void on_data_received(int data) {
++processed_count_;
std::cout << "DataProcessor: Processed item #" << processed_count_
<< " with value " << data << std::endl;
}
int get_processed_count() const { return processed_count_; }
};
int main() {
std::cout << "=== C++ Callbacks and Event System Demo ===\n\n";
// Create events with different signatures
Event<int> simple_event;
Event<const std::string&, int> complex_event;
std::cout << "1. Basic Event Subscription:\n";
// Subscribe various types of callbacks
auto conn1 = simple_event.subscribe(global_handler);
auto conn2 = simple_event.subscribe([](int value) {
std::cout << "Lambda handler: Processing " << value << std::endl;
});
// Lambda with capture
int multiplier = 5;
auto conn3 = simple_event.subscribe([multiplier](int value) {
std::cout << "Captured lambda: " << value << " * " << multiplier
<< " = " << (value * multiplier) << std::endl;
});
std::cout << "Subscribers: " << simple_event.subscriber_count() << std::endl;
simple_event.emit(42);
std::cout << "\n2. Member Function Subscription:\n";
// Create objects and subscribe their member functions
auto logger = std::make_shared<Logger>();
auto processor = std::make_shared<DataProcessor>();
// Safe member function subscription using weak_ptr
auto conn4 = simple_event.subscribe_weak(logger, &Logger::log_event);
auto conn5 = simple_event.subscribe_weak(processor, &DataProcessor::on_data_received);
simple_event.emit(100);
std::cout << "\n3. Complex Event with Multiple Parameters:\n";
auto conn6 = complex_event.subscribe_weak(logger, &Logger::log_detailed);
auto conn7 = complex_event.subscribe([](const std::string& msg, int priority) {
if (priority > 5) {
std::cout << "HIGH PRIORITY ALERT: " << msg << std::endl;
}
});
complex_event.emit("System startup complete", 3);
complex_event.emit("Critical error detected", 8);
std::cout << "\n4. Automatic Cleanup Demo:\n";
std::cout << "Before object destruction - subscribers: "
<< simple_event.subscriber_count() << std::endl;
// Destroy objects - weak_ptr subscriptions become invalid
logger.reset();
processor.reset();
std::cout << "After object destruction, emitting event:\n";
simple_event.emit(200); // Only non-member callbacks will execute
std::cout << "\n5. Manual Disconnection:\n";
std::cout << "Disconnecting lambda handler...\n";
conn2.disconnect();
std::cout << "Emitting after disconnection:\n";
simple_event.emit(300);
std::cout << "\n6. Connection Lifetime Management:\n";
{
auto temp_conn = simple_event.subscribe([](int x) {
std::cout << "Temporary handler: " << x << std::endl;
});
std::cout << "With temporary connection:\n";
simple_event.emit(400);
} // temp_conn destroyed here, automatically disconnects
std::cout << "After temporary connection destroyed:\n";
simple_event.emit(500);
return 0;
}
Advanced Concepts and Best Practices
Thread Safety Considerations
Our implementation provides thread safety through several mechanisms:
- Mutex Protection: All access to the listeners map is protected
- Atomic ID Generation: Thread-safe unique ID assignment
- Copy-and-Release Pattern: We copy callbacks before calling them, reducing lock contention
- Exception Safety: Callbacks are called in try-catch blocks
Memory Management Patterns
The combination of shared_ptr and weak_ptr provides robust memory management:
// Event can be destroyed safely
{
Event<int> event;
auto connection = event.subscribe([](int x) { /* ... */ });
// Even when event goes out of scope, connection can detect this
// and avoid accessing destroyed memory
}
Performance Considerations
- Lock Contention: We minimize time spent holding locks
- Memory Allocation: Using reserve() to reduce vector reallocations
- Move Semantics: Connection objects use move-only semantics
- Template Efficiency: All template instantiations are compile-time optimized
Common Pitfalls and How to Avoid Them
1. Circular References
// BAD: Can create circular references
class BadPublisher {
Event<int> event;
std::shared_ptr<Subscriber> subscriber;
public:
void add_subscriber(std::shared_ptr<Subscriber> sub) {
subscriber = sub;
// This creates a cycle if subscriber holds shared_ptr to this
event.subscribe([sub](int x) { sub->handle(x); });
}
};
// GOOD: Use weak_ptr to break cycles
class GoodPublisher {
Event<int> event;
public:
void add_subscriber(std::shared_ptr<Subscriber> sub) {
event.subscribe_weak(sub, &Subscriber::handle);
}
};
2. Exception Safety in Callbacks
// Always consider that callbacks might throw
void emit_safely(Event<int>& event, int value) {
try {
event.emit(value);
} catch (const std::exception& e) {
std::cerr << "Callback threw exception: " << e.what() << std::endl;
}
}
3. Connection Lifetime Management
// BAD: Letting connections go out of scope unintentionally
void bad_subscribe(Event<int>& event) {
auto conn = event.subscribe([](int x) { /* ... */ });
// conn destroyed here, callback disconnected!
}
// GOOD: Store connections appropriately
class EventHandler {
std::vector<Event<int>::Connection> connections_;
public:
void subscribe_to(Event<int>& event) {
connections_.push_back(
event.subscribe([this](int x) { this->handle(x); })
);
}
};
Conclusion
Callbacks are a fundamental pattern in modern C++ that enable flexible, decoupled designs. Our event system demonstrates how to implement callbacks safely and efficiently, incorporating:
- Type Safety: Template-based design ensures compile-time type checking
- Memory Safety: Smart pointer usage prevents dangling references
- Thread Safety: Proper synchronization for concurrent usage
- Exception Safety: Robust error handling
- Performance: Optimized for minimal overhead
The PIMPL idiom, combined with smart pointers, provides a solid foundation for building sophisticated event-driven systems. Whether you're building GUI applications, game engines, or distributed systems, these patterns will serve you well.
Remember: callbacks are about communication patterns, not just function pointers. They enable loose coupling, support plugin architectures, and make your code more testable and maintainable.
Published on 2025-09-07