1. Wprowadzenie
Gdy piszesz mały program o 30 linijkach, architektura nie jest potrzebna: wszystko mieści się w głowie, a największe zło to zapomnienie ; albo pomylenie == z =. Ale kiedy program staje się „trochę większy niż jedna funkcja”, umysł zaczyna wymagać porządku: gdzie przechowywane są dane, kto je zmienia, kto drukuje, kto rozbiera polecenia.
I tutaj pojawia się podstawowe pytanie projektowe: czy twój obiekt jest czymś (is‑a), czy on zawiera/ma coś (has‑a)?
Wyobraź sobie, że tworzysz proste konsolowe todo. Masz zadania, masz magazyn zadań, masz logikę poleceń ("add", "done", "list"), masz konsolę. W pewnym momencie chcesz napisać jedną „główną klasę”, która to wszystko połączy. I na rozdrożu możesz zrobić dwie rzeczy: „skleić przez dziedziczenie” (to słowo trzymamy na bezpieczny dystans) albo „zebrać z komponentów” (kompozycja). Dziś wybieramy drugie — bo jest niemal zawsze prostsze, bardziej niezawodne i przyjaźniejsze dla początkującego.
2. Is‑a: obiekt jest innym obiektem
Wyrażenie „is‑a” brzmi jak gramatyka angielskiego, ale w programowaniu to bardzo praktyczny test zdrowego rozsądku. „X is a Y” oznacza: obiekt X uczciwie można nazwać obiektem Y i nie będzie poczucia, że okłamujesz użytkownika twojego kodu.
Bardzo ważne: mówimy tutaj o znaczeniu, a nie o mechanizmie technicznym. Uczymy się formułować relacje tak, by kod był czytelny.
Przykłady, które zwykle są „uczciwe”:
- „Kot jest zwierzęciem” (Cat is an Animal) — brzmi sensownie.
- „Okrąg jest figurą” (Circle is a Shape) — także.
- „Student jest człowiekiem” — logiczne.
A teraz przykłady, które brzmią jak zły żart na rozmowie rekrutacyjnej:
- „TodoApp jest TaskRepository”.
- „Parser poleceń jest wektorem zadań”.
- „Konsola jest loggerem” (czasem tak bywa, ale częściej — nie).
Czyli is‑a — to sytuacja, gdy chcesz, żeby obiekt wszędzie mógł występować w roli innego bez niespodzianek. Jeśli tak nie jest — znaczy, że próbujesz udawać.
Mały test na uczciwość, który można wypowiedzieć na głos: jeśli w rozmowie powiedziałbyś „nasz obiekt X to Y” i nie chciałbyś później przepraszać, to być może to jest is‑a.
3. Has‑a: kompozycja zamiast udawania
Kompozycja — to budowanie złożonego obiektu z zestawu prostszych obiektów, które pełnią swoje role. Po ludzku: „aplikacja ma magazyn”, „CLI ma dostęp do strumieni I/O”, „repozytorium ma listę zadań”.
Słowo „ma” tu niekoniecznie oznacza własność „jak posiadanie” — to oddzielny temat dotyczący czasu życia. Ale prawie zawsze oznacza, że jeden obiekt używa innego jako komponentu. I to złoty standard w praktycznym C++: gdy masz wątpliwości — wybierz has‑a.
Utrwalmy różnicę w krótkiej tabeli:
| Pytanie | is‑a | has‑a |
|---|---|---|
| Co oznacza relacja? | „X — to Y” | „X zawiera/używa Y” |
| Jak brzmi sensowo? | X można podstawić tam, gdzie oczekuje się Y | X nie musi być Y, ale wewnątrz ma Y |
| Typowe ryzyko | „udawanie” i dziwne obowiązki | trochę więcej kodu, ale jasne role |
| Dla początkującego | łatwo się pomylić | zwykle bezpieczniejsze |
Główna idea: kompozycja pokazuje budowę obiektu, zamiast udawać, że jest innym obiektem.
Dlaczego „TodoApp is a TaskRepository” prawie zawsze jest złe
Warto tu zrobić pauzę i szczerze odpowiedzieć: „dlaczego w ogóle nie mogę nazwać aplikacji repozytorium? Przecież operuje zadaniami!”
Problem w tym, że repozytorium — to rola przechowywania i wyszukiwania. Aplikacja robi znacznie więcej: czyta polecenia, wypisuje odpowiedzi, łączy akcje użytkownika z operacjami magazynu. Jeśli powiesz „aplikacja jest repozytorium”, obiecujesz, że aplikacja to tylko magazyn zadań. A nie jest tylko magazynem. Jest też „mózgiem”, i „ustami”, i „uszami”.
Jest tu ważny psychologiczny aspekt: początkującym bardzo chce się zrobić „jeden główny obiekt, który potrafi wszystko”. To normalne — wszyscy tak zaczynaliśmy. Następny krok w dojrzewaniu kodu to umiejętność stworzenia „głównego obiektu”, który koordynuje, a nie „robi wszystko osobiście”.
4. Przykład todo: rozkładamy aplikację na komponenty
Teraz złożymy przykład. Będzie „edukacyjnie‑prosty”: bez plików, bez skomplikowanej obsługi błędów, bez udziwnień. Naszym celem jest zobaczyć, jak kompozycja rozkłada aplikację na czytelne elementy.
Model: Task
Zacznijmy od modelu zadania. Model — to dane. Nie czyta i nic nie drukuje. To po prostu „coś, co istnieje w domenie”.
#include <string>
namespace todo::model {
struct Task {
int id{};
std::string title{};
bool done{false};
};
} // namespace todo::model
Magazyn: TaskRepository
Teraz zrobimy repozytorium: przechowuje std::vector<Task> i potrafi dodawać oraz pytać o rozmiar. Tutaj kompozycja wyrażona jest dosłownie polem: repozytorium ma wektor zadań.
#include <cstddef>
#include <utility>
#include <vector>
#include "task.hpp"
namespace todo::storage {
class TaskRepository {
public:
void add(todo::model::Task t) { tasks_.push_back(std::move(t)); }
std::size_t size() const { return tasks_.size(); }
private:
std::vector<todo::model::Task> tasks_;
};
} // namespace todo::storage
Na razie repozytorium jest małe, ale widać już: nie „jest zadaniem” i nie „jest aplikacją”. Po prostu przechowuje.
TodoApp jako koordynator
Bardzo częsty błąd — próbować sprawić, by główny obiekt „był wszystkim”: i repozytorium, i parserem, i printerem. Ale jeśli wybieramy kompozycję, robimy inaczej: główny obiekt ma repozytorium i deleguje mu pracę.
Wprowadzimy prostą klasę TodoApp, która zawiera repozytorium.
#include <cstddef>
#include <string>
#include "task_repository.hpp"
namespace todo::app {
class TodoApp {
public:
void add_task(int id, const std::string& title) {
repo_.add(todo::model::Task{id, title, false});
}
std::size_t task_count() const { return repo_.size(); }
private:
todo::storage::TaskRepository repo_;
};
} // namespace todo::app
Ważna uwaga: TodoApp — to nie repozytorium. W środku ma repozytorium. To sprawia, że kod jest uczciwy: czytelnik widzi, że TodoApp zarządza repozytorium, a nie udaje je.
Delegowanie jako kontynuacja kompozycji
Delegowanie — to sytuacja, kiedy metoda jednego obiektu „przekierowuje” pracę do innego obiektu. W praktyce kompozycja niemal zawsze idzie w parze z delegowaniem: jeśli „zbudowałeś obiekt z części”, potrzebujesz, żeby główny obiekt potrafił powiedzieć: „hej, repozytorium, dodaj zadanie”, „hej, parser, rozbij linię”, „hej, CLI, wypisz komunikat”.
To przypomina menedżera w zespole programistycznym. Menedżer nie pisze core'u, nie testuje wszystkiego ręcznie i nie wdraża release'u na produkcję w piątek wieczorem (choć czasem życie zmusza). Koordynuje: deleguje zadania temu, kto ma kompetencje. Dobry design jest taki sam: każdy komponent robi swoje, a „na górze” jest ktoś, kto je łączy.
5. Warstwa I/O: Printer i CLI
Teraz ważny zwyczaj architektoniczny, który oszczędza godziny pracy: komponenty, które nie powinny komunikować się z konsolą, nie powinny #include <iostream>.
Jeśli repozytorium zacznie drukować „OK” lub „ERROR” w sobie, staje się zależne od konsoli i trudniej go użyć w innym kontekście. Dlatego zwykle <iostream> żyje w warstwie CLI.
Printer: drukowanie do std::ostream
Zrobimy bardzo prosty „drukarz” (printer), który wypisuje do std::ostream. To też kompozycja: printer ma referencję do strumienia wyjścia (zależność niewłaścicielska).
#include <ostream>
#include <string>
namespace todo::cli {
class Printer {
public:
explicit Printer(std::ostream& out) : out_(out) {}
void print_line(const std::string& s) { out_ << s << '\n'; }
private:
std::ostream& out_;
};
} // namespace todo::cli
Na razie nie zagłębiamy się w niuanse czasu życia, ale sens jest jasny: Printer nie „jest konsolą” ani nie „jest ostream”. Po prostu operuje tym, co mu podano.
TodoCli: powłoka, która ma zależności
Teraz stworzymy TodoCli, które czyta linie i wykonuje polecenia. Na razie bez pełnego parsera: dla tej lekcji ważniejsza jest kompozycja niż format poleceń.
TodoCli będzie mieć dostęp do repozytorium (referencja), mieć printer (referencja) i mieć strumień wejścia.
#include <istream>
#include <string>
#include "printer.hpp"
#include "task_repository.hpp"
namespace todo::cli {
class TodoCli {
public:
TodoCli(todo::storage::TaskRepository& repo, Printer& printer, std::istream& in)
: repo_(repo), printer_(printer), in_(in) {}
private:
todo::storage::TaskRepository& repo_;
Printer& printer_;
std::istream& in_;
};
} // namespace todo::cli
To czysta kompozycja: CLI ma zależności. Korzysta z nich, ale nie udaje ani repozytorium, ani strumienia wejścia.
Dodajmy mały przykład „kroku polecenia” jako funkcję wolnostojącą, by nie rozrastać klasy. Niech polecenie będzie prymitywne: jeśli linia jest równa "count", wypisujemy liczbę zadań.
#include <istream>
#include <string>
namespace todo::cli {
void run_one(todo::storage::TaskRepository& repo, Printer& printer, std::istream& in) {
std::string line;
if (!std::getline(in, line)) return;
if (line == "count") {
printer.print_line("Tasks: " + std::to_string(repo.size())); // na przykład: Tasks: 3
}
}
} // namespace todo::cli
Jak wygląda struktura w kategoriach has‑a
Zanim napiszemy więcej kodu, warto zobaczyć rysunek, bo inaczej umysł zaczyna „trzymać wszystko w powietrzu” i męczy się. Oto uproszczona struktura, do której dążymy:
flowchart TD
App["TodoApp: koordynator"]
Repo["TaskRepository: storage"]
Print["Printer: cli helper"]
Cli["TodoCli: czyta polecenia"]
App --> Repo
Cli --> Repo
Cli --> Print
Sens diagramu jest prosty: strzałka oznacza „ma/używa”. To właśnie has‑a.
TodoApp łączy komponenty
W normalnej architekturze TodoApp — to koordynator. Tworzy (lub otrzymuje) komponenty i łączy je.
W minimalnej postaci wygląda to tak: TodoApp ma repozytorium jako wartość, a run() tworzy tymczasowy Printer i wywołuje run_one.
#include <iostream>
#include "printer.hpp"
#include "task_repository.hpp"
namespace todo::app {
class TodoApp {
public:
void run(std::istream& in, std::ostream& out) {
todo::cli::Printer printer(out);
todo::cli::run_one(repo_, printer, in);
}
private:
todo::storage::TaskRepository repo_;
};
} // namespace todo::app
I znowu kompozycja: aplikacja składa się z repozytorium. Nie jest repozytorium.
6. Formy kompozycji i czas życia
Teraz kompozycja zaczyna przecinać się z pytaniem o czas życia obiektów. Nie będziemy schodzić w szczegóły, ale ustalimy podstawowy obraz, by nie pisać „wszystko przez wskaźnik tylko dlatego, że to bardziej tajemnicze”.
Przechowywanie przez wartość
To najprostsza opcja: jeśli TodoApp zawiera TaskRepository repo_;, to repozytorium tworzy się razem z aplikacją i niszczy razem z nią. Zwykle to idealny start dla początkującego: mniejsze ryzyko „wiszącej referencji” i większa przewidywalność.
class TodoApp {
private:
todo::storage::TaskRepository repo_; // żyje razem z TodoApp
};
Przechowywanie przez referencję
Jeśli komponent powinien istnieć, ale jest tworzony „przez świat zewnętrzny”, można trzymać referencję. To wygodne dla zależności takich jak std::ostream&.
class Printer {
public:
explicit Printer(std::ostream& out) : out_(out) {}
private:
std::ostream& out_; // out musi żyć dłużej niż Printer
};
Referencja mówi: „bez tego nie działam”. Ale wymaga dyscypliny: nie można przekazać obiektu tymczasowego i liczyć, że wszystko będzie dobrze.
std::unique_ptr
Czasem komponent może nie istnieć, albo chcesz opóźnić tworzenie, albo ukryć szczegóły. Wtedy unique_ptr jest normalnym narzędziem. Traktuj to jako „opcjonalnie‑zaawansowane”, by nie komplikować sobie życia przedwcześnie.
#include <memory>
class TodoApp {
private:
std::unique_ptr<todo::storage::TaskRepository> repo_;
};
Ważne: jeśli nie rozumiesz, po co ci unique_ptr — prawdopodobnie jeszcze go nie potrzebujesz. To nie jest „modne”, to „konkretna potrzeba”.
Wskaźnik T*
Wskaźnik jest przydatny, gdy zależność może nie istnieć. Ale wymaga stałych kontroli i uwagi, dlatego w edukacyjnym kodzie aplikacyjnym lepiej zaczynać od referencji lub wartości.
7. Typowe błędy
Błąd nr 1: mylenie „używa” i „jest” oraz próba sklejenia ról w jedną encję.
Zwykle wygląda to tak: „skoro aplikacja operuje na repozytorium, to aplikacja jest repozytorium”. W efekcie otrzymujesz obiekt, którego nie da się opisać jedną rolą, i zaczyna on ciągnąć w siebie wszystko. Leczy się to prostym zwyczajem: wypowiedz na głos zdanie „X is a Y” i sprawdź, czy nie brzmi to jak dziwne kłamstwo.
Błąd nr 2: robić komponent, który jednocześnie przechowuje dane, parsuje polecenia i drukuje na konsolę.
Na początku wydaje się, że to szybsze. Potem okazuje się, że każda zmiana formatu poleceń psuje magazyn, każda zmiana formatu wyjścia łamie parser, a każda weryfikacja logiki wymaga „podszywania konsoli”. Kompozycja polega właśnie na rozdzieleniu ról i jawnych połączeniach ich, a nie mieszaniu wszystkiego w jednym kotle.
Błąd nr 3: wystawiać na zewnątrz niemodyfikowalne referencje do wnętrza „dla wygody”.
Bardzo kuszące jest dać metodę „daj mi std::vector<Task>& i ja sam to poprawię”. Ale wtedy tracisz kontrolę nad inwariantami i zamieniasz repozytorium w „worek z danymi”. Zdrowsze jest udostępnianie operacji: add, remove, mark_done, find_by_id.
Błąd nr 4: trzymać referencje do obiektów, które żyją krócej niż twój obiekt.
Pola‑referencje są potężne, ale wymagają gwarancji dotyczącej czasu życia. Jeśli trzymasz std::ostream& lub TaskRepository&, musisz rozumieć, kto kogo „przeżyje”. Braku pewności? Lepiej zacząć od przechowywania przez wartość (dla prostych komponentów) i dopiero potem przechodzić do referencji, gdy pojawi się wyraźna architektoniczna potrzeba.
Błąd nr 5: próbować „zgadnąć przyszłość” i zawczasu komplikować kompozycję unique_ptr-ami i wskaźnikami.
Kompozycja nie wymaga domyślnej dynamiki. Bardzo często kod początkującego staje się złożony nie dlatego, że zadanie jest skomplikowane, ale dlatego, że autor postanowił „od razu zrobić jak w dużym projekcie” i dodał zbędne poziomy pośrednictwa. Jeśli komponent zawsze istnieje — trzymaj przez wartość. Jeśli komponent jest wymagany, ale zewnętrzny — trzymaj referencję. Wszystko inne dodawaj tylko, gdy pojawi konkretna potrzeba.
GO TO FULL VERSION