Background Story
It was a relaxed evening on vacation. No todo list, no deadline, no code review. Just me, my laptop, and the idea to code something for fun.
Spoiler: It didn’t stay simple. But that’s exactly why this story is worth telling.
A few days earlier, I had found my old bachelor thesis while cleaning up. The topic back then was a particle simulation. Geometric shapes get randomly distributed in a 2D space. When two particles overlap, they push each other away. Over time, order emerges from chaos. Physicists call this Random Organization.
I really enjoyed that project back then. There were conference talks, even a published paper. I was proud of the physics results.
Less proud of the code.
How did I develop back then? Chaotic. No version control, no tests, no documentation. The result: The code is gone. No backups, nothing.
But the idea was still there. And now I also had the knowledge of how to do it better. So that evening, I started rewriting the simulation. This time properly.
Rapid Prototyping
Was my old code bad? Yes. But that’s okay. Back then I was a physicist, not a developer. Code was a means to an end.
Today, after several years as a professional developer, I see it differently. Code is communication with the computer, with other developers, with my future self.
So I started fresh. Quick, without thinking too much. Get something running first. The prototype worked. The simulation did what it was supposed to. But I wanted to do better. So I started refactoring.
Design Patterns: Bringing Structure to Chaos
The prototype worked. But reading through the code, I realized this can be better. Over the past years, I had learned a lot about design patterns and used many. Patterns aren’t magic. They’re documented solutions to problems other developers had before me. Why reinvent the wheel?
So I looked at the code again. Where does something repeat? Where is the code inflexible? Where are potential bugs lurking?
1. Factory Pattern: Three Methods Are Two Too Many
In the prototype, I had three almost identical methods:
void Simulation::initCircleSetup(uint16_t num) { /* ... */ }
void Simulation::initRectangleSetup(uint16_t num) { /* ... */ }
void Simulation::initSquareSetup(uint16_t num) { /* ... */ }
Classic Copy Paste Programming.
Plus there was a string comparison for selecting the type:
if (cfg.type == "circle") {
initCircleSetup(cfg.num_particles);
}
else if (cfg.type == "rectangle") {
initRectangleSetup(cfg.num_particles);
}
else if (cfg.type == "square") {
initSquareSetup(cfg.num_particles);
}
else {
initCircleSetup(cfg.num_particles); // Silent fallback
}
What happens with a typo? “cicle” instead of “circle”? The program silently falls back to the default. No warning, no exception. The bug only gets discovered hours later during debugging.
After refactoring, the code looks like this:
// ShapeType.hpp
enum class ShapeTypes {
Circle,
Rectangle,
Square
};
class ShapeType {
public:
static Shape create(ShapeTypes type, const Vec& pos,
double size1 = 1.0, double size2 = 1.0) {
switch (type) {
case ShapeTypes::Circle:
return Circle(pos, size1);
case ShapeTypes::Rectangle:
return Rectangle(pos, size1, size2);
case ShapeTypes::Square:
return Square(pos, size1);
}
throw std::invalid_argument("Unknown shape type");
}
};
// Simulation.cpp
void Simulation::initParticles(uint16_t num)
{
std::uniform_real_distribution<double> xDist(0.0, config.area_width);
std::uniform_real_distribution<double> yDist(0.0, config.area_height);
particles.reserve(num);
for (uint16_t i = 0; i < num; i++) {
Vec pos(xDist(rng), yDist(rng));
particles.push_back(ShapeType::create(config.shape_type, pos));
}
}
The result: Three methods become one. The DRY principle (Don’t Repeat Yourself) in action. Plus type safety through the enum class. A typo in ShapeTypes::Cicle leads to a compiler error, not a silent bug at runtime.
Shape creation is now in a central place. If the creation changes, for example because Circle now needs two parameters instead of one, I change it in one place. Not three. And a new shape? Extend the enum, add a case in the factory, done.
2. Strategy Pattern: More Than Just True or False
The next problem was the boundary condition. In the prototype, I only had a boolean:
class Simulation {
bool periodicBoundary; // true or false, nothing more
};
That works for the simple case. But what if I want reflective boundaries or hard walls? With a boolean, that’s not possible. I’d need an enum and then a switch statement that grows with every new boundary. The Simulation class would keep getting bloated.
The solution: The Strategy Pattern. Instead of having the logic in the Simulation class, it gets extracted into separate classes. The simulation only knows the interface and doesn’t know which concrete implementation is behind it.
// Boundary.hpp
enum class BoundaryTypes {
Hardwall,
Periodic,
Reflective
};
class Boundary {
public:
virtual ~Boundary() = default;
virtual void apply(Shape& particle, double width, double height) const = 0;
};
class PeriodicBoundary : public Boundary {
public:
void apply(Shape& particle, double width, double height) const override { /* ... */ }
};
class ReflectiveBoundary : public Boundary {
public:
void apply(Shape& particle, double width, double height) const override { /* ... */ }
};
class HardwallBoundary : public Boundary {
public:
void apply(Shape& particle, double width, double height) const override { /* ... */ }
};
// Simulation.cpp
void Simulation::randomPush(Shape& particle1, Shape& particle2) {
// displacement logic
move(particle1, displacement);
move(particle2, displacement * (-1.0));
// Apply boundary
boundary_->apply(particle1, config.area_width, config.area_height);
boundary_->apply(particle2, config.area_width, config.area_height);
}
The beauty of this: The Simulation class never needs to be touched again when a new boundary is added. That’s the Open/Closed Principle in practice. The code is open for extension but closed for modification.
Plus: Each strategy can be tested in isolation. A unit test for PeriodicBoundary, one for ReflectiveBoundary, without having to spin up the entire simulation.
3. Builder Pattern: Configuration Without Silent Failures
For the simulation configuration, I had a simple struct:
SimulationConfig config;
config.num_particles = 50;
config.area_width = 50;
config.area_height = 50;
config.max_displacement = 0.5;
config.max_iterations = 5000;
config.boundary_type = BoundaryTypes::Periodic;
Simulation sim(config);
That works. But what if someone forgets to set num_particles? Division by zero, somewhere deep in the code.
The solution is the Builder Pattern. Instead of manipulating the struct directly, there’s a builder that gets configured step by step and validates the input:
auto config = SimulationConfiguration()
.withShapeType(ShapeTypes::Circle)
.withParticles(50)
.withArea(50.0, 50.0)
.withMaxDisplacement(0.5)
.withMaxIterations(5000)
.withBoundaryType(BoundaryTypes::Hardwall)
.build();
Simulation sim(config);
The trick: The SimulationConfig struct has sensible default values. In build(), it checks if all values are valid. Is a value invalid? Exception. No silent failure. Plus, the code reads almost like a sentence. withParticles(50) is clearer than config.num_particles = 50. The methods document themselves.
The Moment Everything Collapsed
I was proud. Factory, Strategy, Builder. Everything there. The code looked professional. I even wrote tests. Time for a demo.
Circles? Perfect. Rectangles? Works. Circles AND Rectangles mixed? Segmentation Fault.
No warning. No exception. Just crash.
I stared at the screen. “This can’t be. I did everything right!”
After a debugging session, I found the culprit. The classes for geometric shapes are built according to classic inheritance hierarchy:
class Shape {
public:
virtual ~Shape() = default;
virtual bool overlaps(const Shape& other) const = 0;
protected:
Vec position;
};
class Circle : public Shape {
public:
bool overlaps(const Shape& other) const override;
private:
double radius;
};
The problem lurked in the overlaps implementation:
bool Circle::overlaps(const Shape& other) const
{
const Circle* otherCircle = dynamic_cast<const Circle*>(&other);
Vec diff = position - otherCircle->position; // CRASH!
double distance = diff.magnitude();
return distance < (radius + otherCircle->radius);
}
The problem: dynamic_cast returns nullptr if other is not a Circle. And what do I do? I dereference the pointer directly without checking. Classic mistake.
The quick solution would be a nullptr check:
if (!otherCircle) {
throw std::invalid_argument("Circle::overlaps called with non-Circle");
}
But that felt wrong. I had put a lot of effort into design patterns and now I’m supposed to add nullptr checks everywhere? Throw runtime exceptions? That’s exactly the kind of defensive code I wanted to avoid.
The problem wasn’t the implementation. The problem was the design.
The Search for a Better Solution
I researched. How do other developers solve this problem?
Double Dispatch? Possible, but lots of boilerplate. Visitor Pattern? Works, but even more code.
And then I came across std::variant.
A feature from C++17 that I had heard of but never really understood. It comes from functional programming and solves exactly my problem. Not with runtime checks, but at compile time.
What Is a Sum Type?
std::variant is a sum type: A value can be exactly ONE of several types. Not “maybe one”, not “multiple simultaneously”. Exactly one. Always. No nullptr.
std::variant<int, double, std::string> value;
value = 42; // now int
value = 3.14; // now double
value = "hello"; // now string
The crucial difference to classic inheritance: With inheritance, the type hierarchy is open. Anyone can derive new classes from Shape. That makes it easy to add new types. But adding a new operation? Then every class needs to be touched. Every class needs a new method.
With sum types, it’s the opposite. The type set is closed. All types are known at compile time. That makes it harder to add new types because all handlers need to be adjusted. But a new operation? Just write a new handler. The existing code stays untouched.
For my simulation, that means: My shapes are known. Circle, Rectangle, Square. New shapes? Rare. But new operations? Overlap check, rendering, collision response? Those come all the time.
Sum types fit better here than inheritance.
The killer feature: The compiler enforces completeness. Forget a case, you get a compiler error. Not a crash at runtime. Not a nullptr. An error at compile time.
From Classes to Data Structs
Before, I had a class hierarchy:
class Shape { virtual bool overlaps(...) = 0; };
class Circle : public Shape { ... };
class Rectangle : public Shape { ... };
std::vector<std::unique_ptr<Shape>> particles;
After, I have pure data structs:
struct CircleData {
Vec position;
double radius;
CircleData(const Vec& pos, double r) : position(pos), radius(r) {}
};
struct RectangleData {
Vec position;
double width;
double height;
RectangleData(const Vec& pos, double w, double h)
: position(pos), width(w), height(h) {}
};
struct SquareData {
Vec position;
double size;
SquareData(const Vec& pos, double s) : position(pos), size(s) {}
};
using Shape = std::variant<CircleData, RectangleData, SquareData>;
Since shapes are now pure data structs, not classes with methods, operations are implemented as free functions:
Vec getPosition(const Shape& s)
{
return std::visit([](const auto& shape) { return shape.position; }, s);
}
void setPosition(Shape& s, const Vec& pos)
{
std::visit([&pos](auto& shape) { shape.position = pos; }, s);
}
void move(Shape& s, const Vec& delta)
{
std::visit([&delta](auto& shape) { shape.position = shape.position + delta; }, s);
}
For collision detection, we create a handler with overloaded operator():
struct OverlapHandler {
bool operator()(const CircleData& a, const CircleData& b) const
{
// Implementation of circle-circle collision
}
bool operator()(const RectangleData& a, const RectangleData& b) const
{
// Implementation of rectangle-rectangle collision
}
bool operator()(const SquareData& a, const SquareData& b) const
{
// Implementation of square-square collision
}
bool operator()(const CircleData& c, const RectangleData& r) const
{
// Implementation of circle-rectangle collision
}
bool operator()(const RectangleData& r, const CircleData& c) const
{
return (*this)(c, r); // Symmetric
}
// more combinations
};
bool overlaps(const Shape& a, const Shape& b)
{
return std::visit(OverlapHandler{}, a, b);
}
What does all this bring? The most obvious advantage is type safety. With inheritance, type checking happens at runtime with dynamic_cast. With std::variant, it happens at compile time. A forgotten case leads to a compiler error, not a crash at 3 AM in production.
Plus: There’s no nullptr anymore. The value is always one of the defined types. Never null, never undefined. That alone eliminates an entire category of bugs.
Performance is also a factor. Inheritance needs virtual dispatch. The compiler doesn’t know which method gets called, that’s decided at runtime. With std::variant, the compiler knows exactly which function gets called. Direct call instead of indirection. Plus, the data sits on the stack instead of scattered on the heap. More cache-friendly, faster.
The only downside: Adding new types is more expensive. Every handler needs to be adjusted. But for my simulation with three known shapes, that wasn’t a problem.
What I Really Learned
This project taught me more than just C++ features. It changed how I think about software design.
1. “It Works” Is Not the Same as “It’s Good”
My prototype worked. But the difference between working code and good code is the time I invest in refactoring. Not because someone demands it, but because I want to do better.
2. Design Patterns Are Not an Academic Exercise
Patterns are documented solutions to problems others had before me. Why reinvent the wheel?
3. Paradigms Are Tools, Not Religions
OOP or FP? The answer is: Both. My final code uses OOP patterns for structure and FP concepts for type safety. The best solution is often a hybrid.
4. The Compiler Is My Friend
The more I can check at compile time, the less can go wrong at runtime. Type-safe enums instead of strings. std::variant instead of inheritance plus dynamic_cast. Every compiler error is a bug I don’t have to debug.
Conclusion: Why It’s Worth It
What started as a relaxed vacation evening became several weeks of work. Was that too much for a hobby project? Maybe. But it was about getting better. Not perfect. Better.
I learned how std::variant works. I understood when OOP and when FP fits. I experienced that refactoring isn’t a waste of time.
And most importantly: It was fun again.
No deadline. No code review. No stakeholders. Just me, the code, and the question: “How can I make this better?”
This freedom is often missing in daily work. That makes it all the more important to find it in hobby projects.