CodeGym /Kursy /C++ SELF /„has‑a” vs „is‑a”: narzędzie projektowe

„has‑a” vs „is‑a”: narzędzie projektowe

C++ SELF
Poziom 50, Lekcja 2
Dostępny

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.

1
Zadanie
C++ SELF, poziom 50, lekcja 2
Niedostępne
Rysunek prostokąta
Rysunek prostokąta
1
Zadanie
C++ SELF, poziom 50, lekcja 2
Niedostępne
Wypisywanie z prefiksem
Wypisywanie z prefiksem
1
Zadanie
C++ SELF, poziom 50, lekcja 2
Niedostępne
Repozytorium filmów
Repozytorium filmów
1
Zadanie
C++ SELF, poziom 50, lekcja 2
Niedostępne
Aplikacja notatek
Aplikacja notatek
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION