CodeGym /Courses /C++ SELF /Factory of implementations by config/CLI

Factory of implementations by config/CLI

C++ SELF
Level 52, Lesson 3
Available

1. The problem of choosing an implementation

When an application becomes a bit more complex, it suddenly turns out that “one class” is a myth from textbooks. In real code we often have multiple implementations of the same idea: an in-memory fast storage for tests, another for production, a third for debugging. And here the question arises: who decides which implementation to create?

If you make the choice "inline" directly in main() via a long if/else, it's still bearable… for about the first two days. Then you'll add a second place where the same choice is needed, then a third — and hello, copy-paste.

Lecture idea: the implementation choice should live in a single place — the factory. And the "config/CLI" (or any user-provided setting) should only tell the factory what to choose.

TaskTracker and the ITaskStorage interface

To avoid discussing factories in the void, let's take a small learning app called TaskTracker: add tasks, show the list, mark a task as done.

We need an extension point: suppose we want two variants of task storage. For that we'll describe the ITaskStorage interface, which says “what the storage can do” but not “how it is implemented internally.”

#include <iosfwd>
#include <string>
#include <string_view>

struct ITaskStorage {
    virtual ~ITaskStorage() = default;

    // Precondition: title may be empty (we allow it, but it's debatable).
    // Postcondition: returns the id of the created task (id > 0).
    virtual int add(std::string_view title) = 0;

    // Precondition: id > 0.
    // Postcondition: returns true if the task was found and marked done.
    virtual bool mark_done(int id) = 0;

    // Postcondition: prints the current state of the storage.
    virtual void print_all(std::ostream& out) const = 0;
};

Note: we intentionally do not say “this is a std::vector” or “this is a std::map.” Client code should depend on the contract, not on the “insides.”

2. Implementation #1: storage on std::vector

The first implementation will be as straightforward as possible: the task list is stored in a std::vector. It's a decent choice for small data volumes and for learning: easy to print, easy to add, easy to understand.

Inside we'll introduce a Task model and a VectorTaskStorage class.

#include <iostream>
#include <string>
#include <string_view>
#include <vector>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

struct VectorTaskStorage : ITaskStorage {
    int add(std::string_view title) override {
        const int id = next_id_++;
        tasks_.push_back(Task{id, std::string(title), false});
        return id;
    }

    bool mark_done(int id) override {
        for (auto& t : tasks_) {
            if (t.id == id) {
                t.done = true;
                return true;
            }
        }
        return false;
    }

    void print_all(std::ostream& out) const override {
        for (const auto& t : tasks_) {
            out << "[" << (t.done ? 'x' : ' ') << "] "
                << t.id << ": " << t.title << "\n";
        }
    }

private:
    int next_id_ = 1;
    std::vector<Task> tasks_;
};

Yes, there's a linear search through the vector. But today we're not about optimization, we're about the architecture of choosing an implementation.

4. Implementation #2: storage on std::map

Now let's make an alternative: store tasks in a std::map<int, Task>, where the key is the id. Then marking done by id becomes simpler and usually faster.

#include <iostream>
#include <map>
#include <string>
#include <string_view>

struct MapTaskStorage : ITaskStorage {
    int add(std::string_view title) override {
        const int id = next_id_++;
        tasks_.emplace(id, Task{id, std::string(title), false});
        return id;
    }

    bool mark_done(int id) override {
        auto it = tasks_.find(id);
        if (it == tasks_.end()) {
            return false;
        }
        it->second.done = true;
        return true;
    }

    void print_all(std::ostream& out) const override {
        for (const auto& [id, t] : tasks_) {
            out << "[" << (t.done ? 'x' : ' ') << "] "
                << id << ": " << t.title << "\n";
        }
    }

private:
    int next_id_ = 1;
    std::map<int, Task> tasks_;
};

Notice a pleasant thing: from the outside (through ITaskStorage) both implementations look the same. Client code may not know what's inside — vector, map or “a one-element array.”

5. Configuration: config/CLI and StorageKind

Now we get to the core. Suppose the user wants to specify which mode to use: "vector" or "map". In a real app this could be a command-line argument or a configuration parameter. We'll do it simpler for now: read a string from std::cin.

First let's introduce an enum class so the choice is type-safe. Strings are good for input, but bad as the program's internal protocol: typos happen.

enum class StorageKind {
    vector,
    map
};

It's useful to fix the mapping "input → enum":

User input StorageKind
"vector"
StorageKind::vector
"map"
StorageKind::map

6. Parsing: string → StorageKind

At this step we separate the “dirty outside world of strings” from the “clean inner world of enums.” You can skip this separation — but later it will be harder to test and extend.

#include <string_view>

StorageKind parse_storage_kind(std::string_view text) {
    if (text == "vector") return StorageKind::vector;
    if (text == "map")    return StorageKind::map;

    // Contract: treat unknown as vector by default.
    return StorageKind::vector;
}

Yes, we chose “default on error.” This is not the only option. You could signal an error via std::optional, but today our goal is the factory. The main point: behavior on unknown value must be defined.

7. Factory: make_storage as the single choice point

A factory is a function that creates a concrete object according to the setting, but returns it to the outside via the interface: std::unique_ptr<ITaskStorage>.

Key idea: client code should not know the names of concrete implementation classes. Let it know only ITaskStorage.

#include <memory>

std::unique_ptr<ITaskStorage> make_storage(StorageKind kind) {
    switch (kind) {
        case StorageKind::vector:
            return std::make_unique<VectorTaskStorage>();
        case StorageKind::map:
            return std::make_unique<MapTaskStorage>();
    }
    return nullptr; // In practice this should not be reached.
}

Why unique_ptr and not a plain object? Because the interface type (ITaskStorage) cannot be created directly (it's abstract), and we also need correct deletion through the base type. unique_ptr solves ownership and lifetime.

Put simply: the factory says “I'll create it, you own it.”

8. Usage in main

Now let's assemble a tiny application skeleton. We'll intentionally keep main() thin: it only reads the setting, asks the factory to create the needed object, and runs a primitive scenario.

#include <iostream>
#include <memory>
#include <string>

int main() {
    std::cout << "Choose storage (vector/map): ";
    std::string mode;
    std::cin >> mode;

    const StorageKind kind = parse_storage_kind(mode);
    std::unique_ptr<ITaskStorage> storage = make_storage(kind);

    const int id1 = storage->add("Learn C++");
    const int id2 = storage->add("Write factory");

    storage->mark_done(id1);

    std::cout << "Tasks:\n";
    storage->print_all(std::cout);
}

Possible output:

// Choose storage (vector/map): vector
// Tasks:
// [x] 1: Learn C++
// [ ] 2: Write factory

The most important thing here is not the “tasks” but that main() does not know what exactly was created. It only knows that it's an ITaskStorage. And that is what reduces coupling.

9. Flowchart of the selection flow

To keep track, it's useful to see the whole flow as a simple block diagram. In real life the “config/CLI” can be anything (a string, an enum, a settings structure), but the principle doesn't change: first interpret settings, then create the implementation in one place.

flowchart TD
    A[User setting (string/config/CLI)] --> B[parse_* (string -> enum)]
    B --> C[make_* factory (enum -> unique_ptr)]
    C --> D[Client code works through ITaskStorage]

If somewhere you feel like skipping make_* and creating an implementation manually, imagine you're breaking a door in this scheme. You can, but then the whole point of the architecture disappears.

10. Useful nuances and extension

Why a factory is not “an extra function”

A very common newbie reaction: “Why do I need a factory? I can just write an if in main().” You can. But the factory is needed not because if is a forbidden operator, but because the choice point must be single.

If the choice is spread across the code, when you add a third implementation you'll have to find all the places where the object is created. And then you'll certainly forget one place (by Murphy's law — the one that breaks at the demo).

The factory does two things: centralizes the choice and shields client code from knowledge of concrete classes. That's especially useful when the application grows: tests, separate teams, separate layers and multiple run scenarios appear.

Why a factory by enum is almost always better than by string

Sometimes you might want to do this:

std::unique_ptr<ITaskStorage> make_storage(std::string_view mode);

This can work, but you mix two responsibilities: “recognize the input” and “create the object.” In a small example this seems convenient, but later you'll want to accept "vector", "vec", "v", "VECTOR" — and the factory will start doing nonsense.

Better keep separation: parse_* handles “understanding,” make_* handles “creation.” This makes code easier to test and easier to read: each function has a single responsibility.

How to add a new implementation without pain

Imagine you want to add StorageKind::debug, which prints everything that happens. With a factory you have a clear checklist: add a new item to the enum class, implement a new class, add a switch branch in the factory. That's it. Client code doesn't change.

Without a factory you'd be searching “where do we create storage”, “where else do we create storage”, “why is it created in tests too” and “who on earth wrote that code besides my past self”.

11. Common mistakes

Mistake #1: creating a concrete implementation directly in client code (“oh, I'll do it just for a minute”).
Usually this starts with “there's only one make_unique here.” Then such places become five, and the factory turns into a decorative element. If you agreed the choice is centralized, creation of concrete classes should live in the factory.

Mistake #2: factory returns ITaskStorage* instead of std::unique_ptr<ITaskStorage>.
Raw pointers immediately raise “who deletes the object?” and “when?”. Sometimes the answer is “nobody” — and that's, surprise, a leak. unique_ptr makes ownership explicit.

Mistake #3: forgot the virtual destructor in the interface.
If the base type is polymorphic but the destructor is not virtual, deleting via unique_ptr<ITaskStorage> may lead to incorrect destruction of the object. Simple rule: an interface almost always should have a virtual ~I() = default;

Mistake #4: stuffed parsing of strings, reading from cin, printing errors and half the project into the factory.
The factory should be boring: it must not read from the console, prompt the user or decide UX. Its job is to create the needed object from an already prepared “choice.” If the factory starts doing everything, it becomes a monster nobody wants to touch.

Mistake #5: not defining behavior for an unknown variant.
When an unknown value arrives (a typo, wrong setting), the program must behave predictably: choose a default, return nullptr, print a message — anything, but defined. “Well, it'll somehow work” usually ends with dereferencing a nullptr and a sad student.

1
Task
C++ SELF, level 52, lesson 3
Locked
Signature mode
Signature mode
1
Task
C++ SELF, level 52, lesson 3
Locked
Formatter Factory
Formatter Factory
1
Task
C++ SELF, level 52, lesson 3
Locked
Distance Converter
Distance Converter
1
Task
C++ SELF, level 52, lesson 3
Locked
Task Storage
Task Storage
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION