Jumping on a new C++ project, I was about to be schooled by copy semantics. Here’s my four-hour voyage through the complexities of C++ references and object copying.

Setup

The adventure began with the creation of WebSocketServer, a class designed to manage WebSocket connections elegantly (at least in my mind).

// Bikeshed C++ class that mimics a WebSocket server

#include <cassert>
#include <functional>
#include <iostream>
#include <map>
#include <string>

class WebSocketServer {
public:
  WebSocketServer(std::string path) : path_(path), handlers_{} {}

  void handle(std::string event, std::string message) {
    assert(handlers_.contains(event));
    handlers_.at(event)(message);
  }

  void on(std::string event, std::function<void(std::string)> handler) {
    handlers_.insert({event, handler});
  }

private:
  std::string path_;
  std::map<std::string, std::function<void(std::string)>> handlers_;
};

With the WebSocketServer ready, I have to craft the Server class to manage these instances:

// The server C++ class is on top of the WebSocket one.

class Server {
public:
  Server(std::uint32_t port) : port_(port) {}

  WebSocketServer& socket(std::string path) {
    auto [inserted_it, success] =
        sockets_.emplace(std::piecewise_construct, std::forward_as_tuple(path),
                         std::forward_as_tuple(path));

    return inserted_it->second;
  }

private:
  std::uint32_t port_;
  std::map<std::string, WebSocketServer> sockets_;
};

The failure

In my context, I had to create a WebSocket server, attach my handler with the “.on” method, and later, in another part of the code, recall “.socket” and use the handler stored before.

Like this:

int main() {
  Server server{8080};
  auto socket = server.socket("/ws"); // A seemingly innocuous line

  // Create the handler
  socket.on("message", [](std::string message) {
    std::cout << "Received message: " << message << std::endl;
  });

  auto socket2 = server.socket("/ws");
  // Use it later
  socket2.handle("message", "Hello, World!");

  return 0;
}

Then, when I ran this code, I had the following failure:

Assertion failed: (handlers_.contains(event)), function handle, file main.cpp, [...]

What has happened?

Why Did It Fail?

Why wasn’t the handler being found? The breakthrough came when I realized my error: I had overlooked C++’s copy semantics. I used auto instead of auto& then, it created a copy of the WebSocketServer instance, not a reference to the original.

Server Object
+-----------------+
|  server {8080}  |
|                 |
|  sockets_ map   |
|    ["/ws"]------|----> WebSocketServer #1 (Original)
+-----------------+     
                         WebSocketServer #2 (socket, a copy)
                         +--------------+
                         | .on("message |
                         +--------------+

                         WebSocketServer #3 (socket2, another copy)
                         +----------------+
                         | .handle("message |
                         +----------------+

Each “server.socket” call creates a copy of the WebSocketServer object.

The solution was beautifully simple:

// Now my variables `socket` and `socket2` points to the same instance of 
// `WebSocketServer` class store in the `Server` instance.

int main() {
  Server server{8080};
  auto& socket = server.socket("/ws"); // The missing '&' was the key

  socket.on("message", [](std::string message) {
    std::cout << "Received message: " << message << std::endl;
  });

  auto& socket2 = server.socket("/ws"); // Now correctly references the same instance
  socket2.handle("message", "Hello, World!"); // Success at last!

  return 0;
}
Server Object
+-----------------+
|  server {8080}  |
|                 |
|  sockets_ map   |
|    ["/ws"]-----------------------------> WebSocketServer #1 (Original & Referred by socket & socket2)
|                 |                      +----------------------------------+
|                 |                      | .on("message")                  |
|                 |                      | (handler attached here)         |
|                 |                      |                                 |
|                 |                      | .handle("message", ...)         |
|                 |                      | (handled through the same obj.) |
+-----------------+                      +----------------------------------+

socket & socket2 (both references to the same WebSocketServer object inside `sockets_` map)

Now, we use the same ref to the same WebSocketServer.

Deleting the Copy Constructor

Then, I remembered I read once that we could remove the copy constructor and assignment operator in a C++ class. Like this:

WebSocketServer(const WebSocketServer&) = delete;
WebSocketServer& operator=(const WebSocketServer&) = delete;

This measure effectively turned a potential runtime bug into a compile-time error, a much-preferred outcome for me.

Takeaway: the rule of 5:

Reflecting on my four-hour debugging, a deeper understanding of C++’s object lifecycle and copy semantics emerged. This experience brings us to an essential C++ principle: the Rule of Five. This rule is pivotal in C++ for managing resources efficiently and avoiding subtle bugs like the one I encountered.

The Rule of Five is a best practice in C++ that involves explicitly defining five special member functions if your class manages resources that require deep copying, moving, or custom deletion. These functions are:

  1. Destructor - Cleans up resources.
  2. Copy Constructor - Creates a new object as a copy of an existing object.
  3. Copy Assignment Operator - Assign one existing object’s state to another.
  4. Move Constructor - Transfers resources from a temporary object to a new object.
  5. Move Assignment Operator - Transfers resources from a temporary object to an existing object.

By consciously managing these five functions, you take control of your class’s behavior during copy and move operations, ensuring resources are handled safely and efficiently.