Nauka C++ bez frustracji: standardy, CMake, biblioteki i nawyki, które uratują Twój kod w pracy

0
31
3.5/5 - (2 votes)

Nawigacja:

Po co uczyć się C++ w 202x i co to znaczy „bez frustracji”

Realne zastosowania C++ poza „gołym systemem”

C++ kojarzy się wielu osobom z pisaniem sterowników, silników gier albo firmware do urządzeń. Tymczasem w firmach, które szukają programistów C++, dominują cztery obszary: backend o wysokiej wydajności, systemy finansowe, embedded/IoT oraz duże systemy desktopowe (np. CAD, narzędzia inżynierskie, narzędzia developerskie). W każdym z nich kluczowa jest kombinacja szybkości działania, kontroli nad pamięcią i długoterminowej stabilności.

W backendzie C++ bywa wybierany tam, gdzie Python czy Java są zbyt wolne lub wymagają zbyt dużych zasobów. Serwisy tradingowe, serwery gier online, silniki wyszukiwarki, usługi przetwarzające ogromne ilości danych – w tych miejscach nowoczesny C++ w praktyce wygrywa tym, że pozwala precyzyjnie panować nad czasem i pamięcią. W finansach i telekomunikacji liczy się również przewidywalne opóźnienie i możliwość optymalizacji niskopoziomowej.

Embedded to drugi biegun: ograniczona pamięć, konkretne procesory, czasem brak systemu operacyjnego. C++ pozwala mieć i obiektowość, i kontrolę nad sprzętem. Tam, gdzieś pomiędzy, są wielkie aplikacje desktopowe – często kilkanaście lat rozwoju, dziesiątki programistów, duża część w „starym” C++, ale nowe moduły pisane już w standardach C++17/C++20.

Mit: „C++ to język tylko do low-levelu i gier”. Rzeczywistość jest szersza: im bardziej wymagający system, tym większa szansa, że w środku jest C++, często z mieszanką innych języków. High-levelowe API na zewnątrz, C++ w środku jako silnik.

Źródła frustracji początkujących i jak je wygasić

To nie sam język najbardziej męczy początkujących, tylko narzędzia i ekosystem. Najczęstsze powody porzucania C++ po kilku tygodniach:

  • konfiguracja środowiska – „u mnie nie działa”, problemy z kompilatorem, PATH, wersjami bibliotek,
  • mylące komunikaty kompilatora – kilkadziesiąt linii błędu za brakujący średnik,
  • błędy linkera (undefined reference, multiple definition) – zupełnie inny typ problemu niż błędy kompilacji,
  • różnica między nowoczesnym a „sprzed 20 lat” C++ – tutoriale uczą jednego, a kod w pracy wygląda inaczej.

Frustracja rośnie, gdy próbujesz rozwiązać naraz teorię języka, narzędzia i legacy code. Klucz do „C++ bez frustracji” to rozdzielenie tych wątków w czasie: najpierw fundamenty nowoczesnego C++, potem stopniowe dokładanie CMake i projektów wieloplikowych, a dopiero na końcu głębsze wejście w utrzymanie starego kodu.

Dobrym sposobem na ograniczenie zniechęcenia jest też szybka zmiana priorytetu: nie „jak działa każda część języka”, tylko „co jest potrzebne, żeby pisać kod produkcyjny, który się kompiluje, nie wycieka i da się utrzymać”. To zupełnie inne pytania i inne ćwiczenia niż w większości kursów akademickich.

Jakie oczekiwania mają firmy wobec juniora C++

W ogłoszeniach często pojawiają się listy życzeń: C++17, STL, Boost, CMake, Linux, Git, testy jednostkowe, znajomość wzorców projektowych, a obok tego „doświadczenie komercyjne”. Po zebraniu tego do realnego minimum dla juniora wychodzi kilka konkretnych obszarów:

  • Znajomość nowoczesnego C++ – praktyczne użycie std::vector, std::string, smart pointerów, range-based for, konstrukcji z C++11–17.
  • Podstawowy CMake – umiejętność zrozumienia prostego CMakeLists.txt, dodania nowego pliku źródłowego, prostej biblioteki i zależności.
  • Testy jednostkowe – chociażby bazowe ogarnięcie GoogleTest (jak napisać test, jak uruchomić, jak dodać do CMake).
  • Git i praca w zespole – branch, commit, pull request, rozwiązanie prostego konfliktu.
  • Debugowanie – uruchomienie programu pod debuggerem, postawienie kilku breakpointów, podgląd zmiennych.

Mit: „Firma oczekuje, że junior będzie znał każdy zakamarek standardu C++ i szablony na poziomie biblioteki STL”. Rzeczywistość: dobry junior to ten, który rozumie podstawy, nie boi się narzędzi oraz ma przyzwoite nawyki. Reszty nauczy się w projekcie, z pomocą zespołu i code review.

Strategia nauki C++ bez jazdy po ścianie

Strategia „bez frustracji” to świadome ograniczenie zasięgu i skupienie na elementach, które realnie pojawiają się w pracy. Dobra kolejność wygląda mniej więcej tak:

  1. Solidne opanowanie C++17 w stylu nowoczesnym: typy, RAII, smart pointery, kontenery STL, algorytmy, move semantics bez wchodzenia w każdą egzotykę standardu.
  2. Małe projekty z CMake: najpierw jeden plik i prosty CMakeLists.txt, potem biblioteka, testy, podkatalogi.
  3. Dołączenie kilku typowych bibliotek: fmt do formatowania tekstu, spdlog do logów, GoogleTest do testów, ewentualnie fragment Boost.
  4. Wyrabianie nawyków: const-correctness, RAII, preferencja wartości i referencji zamiast gołych wskaźników, praca z Git i code review.
  5. Kontakt z legacy: czytanie „dziwnego” kodu z new/delete, makrami i ręcznymi tablicami, ale już z fundamentami w głowie.

W ten sposób zamiast bezustannie walczyć z konfiguracją, zyskujesz rytm: mała funkcja → test → kompilacja → code review (choćby własne). Mniej magii, więcej kontrolowanych kroków.

Standardy C++ po ludzku – co naprawdę musisz ogarnąć

Krótka oś czasu i trzy prawdziwe skoki języka

Standardów C++ było już kilka: C++98, C++03, C++11, C++14, C++17, C++20, a na horyzoncie C++23. W praktyce dla pracy zawodowej kluczowe są trzy przełomy:

  • C++11 – początek „nowoczesnego C++”: auto, range-based for, smart pointery (std::unique_ptr, std::shared_ptr), lambda, move semantics, thread.
  • C++17 – ugruntowanie nowoczesnego stylu: std::optional, std::variant, structured bindings, if/switch z inicjalizacją, filesystem, lepsze constexpr.
  • C++20 – duży krok do przodu: koncepty (concepts), ranges, coroutines (jeszcze w praktyce nie wszędzie), modules (wciąż wdrażane), sporo ulepszeń biblioteki standardowej.

Wiele firm przez lata zatrzymało się na C++11/14 z powodu narzędzi i kompilatorów. Obecnie C++17 to rozsądne minimum, a coraz częściej w nowych projektach spotkasz C++20. C++98/C++03 to już głównie legacy, choć kod napisany wtedy nadal działa i ma się dobrze.

Jak sprawdzić, jakiego standardu używa projekt

Znajomość standardu języka nic nie da, jeśli kompilator i projekt używają czegoś innego niż myślisz. Najprostsze sposoby sprawdzenia:

  • CMake: w pliku CMakeLists.txt szukaj zmiennych:
    • set(CMAKE_CXX_STANDARD 17),
    • target_compile_features(target PRIVATE cxx_std_17).

    Jeżeli nic nie widzisz, może być stosowany domyślny standard kompilatora – to sygnał ostrzegawczy.

  • Flagi kompilatora:
    • GCC/Clang: -std=c++17, -std=c++20,
    • MSVC: /std:c++17, /std:c++20.

    Flagi te mogą być w CMake (w target_compile_options), w Makefile lub w konfiguracji IDE.

  • Dokumentacja projektu – pliki README.md, CONTRIBUTING.md, wiki repozytorium; często jest tam sekcja „Requirements” albo „Toolchain”.

Jeżeli widzisz, że projekt deklaruje C++17, a w kodzie używa klas z C++20 (np. ranges), może to oznaczać techniczny dług lub niedopasowanie deklaracji do praktyki. Lepiej dopytać w zespole niż zakładać, że „tak ma być”.

Co standard oznacza w praktyce pracy nad kodem

Standard to nie tylko „syntax sugar”. To pakiet dostępnych funkcji, typów, zachowań i wsparcia narzędzi. Z punktu widzenia osoby piszącej kod:

  • Dostępne elementy biblioteki standardowej – np. std::filesystem pojawia się dopiero w C++17, a ranges w C++20.
  • Możliwość użycia nowoczesnego stylu – structured bindings (auto [x, y] = point;) to C++17; jeśli projekt jest w C++14, trzeba pisać kod „po staremu”.
  • Wsparcie narzędzi – sanitizery, static analyzers, IDE mogą polegać na konkretnych funkcjach standardu.
  • Kompatybilność bibliotek – niektóre biblioteki wymagają co najmniej C++17, inne kompilują się od C++11.

Przykładowo, jeśli w pracy musisz pisać kod zgodny z C++14, nie sięgniesz po std::optional – trzeba użyć alternatyw typu boost::optional albo własnego prostego wrappera. Dlatego tak ważne jest, by znać granice danego standardu i nie wprowadzać do projektu funkcji, których dany kompilator w danej wersji nie obsłuży.

Priorytety dla juniora: na czym się skupić

Dla osoby, która szykuje się do pracy z C++, rozsądna selekcja wygląda tak:

  • C++17 jako baza – auto, range-based for, lambdy, smart pointery, std::vector, std::string, std::optional, std::variant, std::unique_ptr, std::shared_ptr, std::move, std::forward, constexpr w prostej formie.
  • Wybrane elementy C++20 – koncepty (w wersji użytkowej: std::convertible_to, std::integral), ranges (std::ranges::views::filter itp.), ulepszenia constexpr, jeżeli w projekcie jest C++20.
  • Stary C++ tylko w kontekście utrzymanianew/delete, ręczne zarządzanie tablicami, makra do zastępowania szablonów; rozumiesz, co to robi, ale nie piszesz nowego kodu w tym stylu.

Mit: „Dobry programista C++ musi znać każdy szczegół standardu i potrafić napisać własną implementację std::vector”. Prawda: dobre zespoły zakładają specjalizację. Jedni siedzą głęboko w szablonach i meta-programowaniu, inni projektują API i architekturę, jeszcze inni są mistrzami debuggera i optymalizacji. Junior ma umieć poruszać się pewnie po podstawach, zadawać sensowne pytania i nie bać się dokumentacji.

Programista pisze kod C++ na klawiaturze przed monitorem
Źródło: Pexels | Autor: Jakub Zerdzicki

Fundamenty nowoczesnego C++: typy, RAII, zasady pisania bez wycieków

Mentalny model: pamięć i obiekty w C++

Osoba przychodząca z Pythona, Javy czy C# często zakłada, że „pamięć jest gdzieś tam pod spodem i nie trzeba się nią przejmować”. W C++ to skrót, który prędzej czy później uderzy. Warto mieć w głowie prosty obraz:

  • Stos (stack) – zmienne lokalne funkcji; ich czas życia kończy się, gdy funkcja się kończy. To bardzo bezpieczne miejsce na dane.
  • Sterta (heap) – pamięć przydzielana dynamicznie (historycznie przez new, dziś typowo przez kontenery i smart pointery). Czas życia jest ręcznie kontrolowany przez program.
  • Pamięć statyczna – zmienne globalne i statyczne; istnieją przez cały czas trwania programu.

W językach zarządzanych garbage collector pilnuje, by nie zostawiać „śmieci” po sobie. W C++ odpowiednikiem tej ochrony jest RAII i rozsądne typy. Każdy obiekt ma swój początek (konstruktor) i koniec (destruktor). Jeśli w destruktorze zadbasz o sprzątanie, nie potrzebujesz osobnych „free” ani „dispose” – dzieje się to automatycznie.

Trzeba też oddzielić „miejsce, gdzie jest obiekt” od „miejsca, gdzie jest wskaźnik/referencja do tego obiektu”. Błędy typu dangling reference biorą się z sytuacji, gdy wskaźnik żyje dłużej niż obiekt, na który wskazuje. Im mniej gołych wskaźników w kodzie, tym rzadziej trafiasz na ten problem.

RAII jako parasol bezpieczeństwa: praktyczne przykłady

RAII (Resource Acquisition Is Initialization) to zasada: „jeśli tworzysz obiekt, to w jego konstruktorze przejmij zasób (pamięć, uchwyt do pliku, mutex), a w destruktorze go oddaj”. W praktyce w nowoczesnym C++ dzień bez RAII to dzień stracony.

Najprostsze przykłady standardowe:

RAII w standardowej bibliotece i w Twoich własnych klasach

Standardowa biblioteka jest praktycznie jednym wielkim pokazem RAII. Kilka codziennych bohaterów:

  • std::vector<T> – rezerwuje pamięć w konstruktorze, zwalnia ją w destruktorze.
  • std::string – zarządza buforem znaków, nie potrzebuje free().
  • std::unique_ptr<T> – przejmuje własność wskaźnika w konstruktorze, wywołuje delete w destruktorze.
  • std::lock_guard<std::mutex> – blokuje mutex przy tworzeniu, odblokowuje przy zniszczeniu obiektu, nawet gdy wyskoczy wyjątek.
  • std::ifstream – otwiera plik w konstruktorze, zamyka go w destruktorze.

Jeśli brakuje gotowego typu RAII, piszesz własny. Przykład prostego strażnika pliku w stylu C:

class FileHandle {
public:
  explicit FileHandle(const char* path, const char* mode)
    : file_(std::fopen(path, mode)) {}

  ~FileHandle() {
    if (file_) {
      std::fclose(file_);
    }
  }

  FILE* get() const { return file_; }

  // zakaz kopiowania (jeden właściciel)
  FileHandle(const FileHandle&) = delete;
  FileHandle& operator=(const FileHandle&) = delete;

  // pozwalamy na przenoszenie
  FileHandle(FileHandle&& other) noexcept
    : file_(other.file_) {
    other.file_ = nullptr;
  }

  FileHandle& operator=(FileHandle&& other) noexcept {
    if (this != &other) {
      if (file_) {
        std::fclose(file_);
      }
      file_ = other.file_;
      other.file_ = nullptr;
    }
    return *this;
  }

private:
  FILE* file_ = nullptr;
};

Z taką klasą nie musisz pamiętać o każdorazowym fclose(). Tworzysz obiekt na stosie, używasz, wychodzisz z funkcji – plik jest zamknięty. Zero „magicznego” sprzątania ręcznie, kod jest czytelniejszy.

Typowa historia z projektu: ktoś pisał pomocniczą funkcję logującą do pliku, użył FILE* i paru if-ów. Wszystko działało, dopóki w połowie nie dorzucono wyjątków. Jeden dodatkowy throw i nagle w rzadkich scenariuszach pliki zostawały otwarte. Po owinięciu w RAII problem znika, a testy przestają być ruletką.

Unikanie new/delete w kodzie aplikacyjnym

Mit krąży od lat: „prawdziwy programista C++ używa new i delete”. W realnych projektach jest odwrotnie – im rzadziej je widzisz w kodzie biznesowym, tym lepiej dla zdrowia zespołu.

W praktyce schemat jest taki:

  • gołe new/delete – tylko w bardzo niskopoziomowej warstwie, która od razu owija to w RAII (np. std::unique_ptr albo własny wrapper).
  • kontenerystd::vector, std::array, std::map, std::string załatwiają dynamiczną pamięć.
  • smart pointerystd::unique_ptr w 90% przypadków, std::shared_ptr tam, gdzie rzeczywiście jest współdzielona własność.
// Źle (legacy style, podatne na wycieki):
auto* user = new User("Jan");
// ... dużo kodu, różne ścieżki
delete user;

// Lepiej:
auto user = std::make_unique<User>("Jan");
// ... używasz user
// koniec zakresu - User niszczy się sam

Kiedy w kodzie pojawia się new w środku funkcji biznesowej, prawie zawsze da się je zastąpić konstrukcją na stosie lub kontenerem. Zdarzają się wyjątki (interfejsy C API, specyficzne sterowniki), ale to margines, a nie codzienność.

Const-correctness jako tani sposób na mniej bugów

Słowo kluczowe const to najprostszy, a często ignorowany sposób na ograniczenie liczby błędów. Kompilator staje się dodatkowym recenzentem, który pilnuje, by „to, co miało być niezmienne, nie zmieniło się przypadkiem”.

Parę codziennych zasad:

  • Parametry przekazywane przez referencję, jeśli nie modyfikujesz – const T&.
  • Metody, które nie zmieniają stanu obiektu – ...() const.
  • Wskaźniki, które mają wskazywać zawsze na to samo – T* const ptr.
  • Zmienna, która po inicjalizacji ma pozostać taka sama – const auto value = ...;.
class User {
public:
  explicit User(std::string name) : name_(std::move(name)) {}

  const std::string& name() const {
    return name_;
  }

  void rename(const std::string& new_name) {
    name_ = new_name;
  }

private:
  std::string name_;
};

W takiej klasie od razu widać, które metody tylko odczytują, a które modyfikują stan. Gdy ktoś spróbuje przypadkiem zmieniać pola w metodzie oznaczonej jako const, kompilator krzyczy zamiast puszczać to do testów integracyjnych.

Częsta obawa juniorów: „jak dam const, to będzie mi przeszkadzał w pisaniu kodu”. Po kilku tygodniach okazuje się, że jest odwrotnie – brak const sprawia, że nie wiesz, czy możesz bezpiecznie wywołać daną funkcję w wielu wątkach, czy nie schowa w sobie jakiejś mutacji.

Wartość, referencja, wskaźnik – kiedy czego używać

Decyzja „przekazać przez wartość czy referencję?” to codzienny dylemat. Zamiast traktować to jako magię, można oprzeć się na prostym schemacie:

  • przez wartość – małe, tanie w kopiowaniu typy (int, double, małe struktury), albo gdy chcesz mieć kopię na własność.
  • przez referencję do const – gdy obiekt jest większy, a Ty go tylko czytasz: const std::string&, const std::vector<T>&.
  • przez referencję nie-const – gdy chcesz coś zmodyfikować: std::string&.
  • wskaźnik – gdy parametr może nie istnieć (nullptr) albo gdy pracujesz z C API.
void print_user(const User& user);      // nie zmieniam
void update_user(User& user);           // modyfikuję
void optional_log(User* logger);        // logger może być null
void set_age(User& user, int age);     // mały typ przez wartość

Mit bywa odwrotny: „wszystko przez referencję, bo szybciej”. Dla małych typów referencja potrafi skomplikować optymalizację i utrudnia czytanie kodu, zwłaszcza gdy wychodzisz poza jedną funkcję. Prosty int przez wartość jest jasny i bezpieczny.

Move semantics – praktyczne minimum

Semantyka przenoszenia brzmi groźnie, ale w codziennym kodzie da się ją sprowadzić do kilku zachowań:

  • obiekty mogą być kopiowane albo przenoszone (czyli „kradniesz” ich zasoby zamiast je dublować),
  • std::move mówi kompilatorowi: „możesz potraktować ten obiekt jak źródło przenoszenia”.

Najczęściej spotkasz to w konstruktorach i operatorach przypisania klas zarządzających zasobami.

class Buffer {
public:
  Buffer(size_t size) : size_(size), data_(new int[size]) {}

  ~Buffer() { delete[] data_; }

  // zakaz kopiowania
  Buffer(const Buffer&) = delete;
  Buffer& operator=(const Buffer&) = delete;

  // konstruktor przenoszący
  Buffer(Buffer&& other) noexcept
    : size_(other.size_), data_(other.data_) {
    other.size_ = 0;
    other.data_ = nullptr;
  }

  // operator przypisania przenoszący
  Buffer& operator=(Buffer&& other) noexcept {
    if (this != &other) {
      delete[] data_;
      size_ = other.size_;
      data_ = other.data_;
      other.size_ = 0;
      other.data_ = nullptr;
    }
    return *this;
  }

private:
  size_t size_ = 0;
  int* data_ = nullptr;
};

Od strony użytkownika wygląda to prosto:

Buffer make_buffer() {
  Buffer buf(1024);
  return buf;              // przenoszenie, nie kopiowanie
}

Buffer b = make_buffer();  // korzysta z move

Nie trzeba obsesyjnie wstawiać std::move wszędzie, gdzie się da. Często kompilator i tak wykorzysta elision (pomijanie kopiowania) i przenoszenie. Warto natomiast rozumieć koncept: jeśli Twój typ zarządza zasobem, przenoszenie pozwala na jego bezpieczne przekazywanie bez nadmiarowego kopiowania.

Podstawowe nawyki przy wyjątkach i błędach

Wyjątki w C++ budzą emocje: jedni je kochają, inni zakazują w całym projekcie. W praktyce spotkasz oba światy. Ważniejsze od samego „try/catch” jest to, jak Twoje klasy zachowują się w obecności wyjątków.

  • RAII + wyjątki – gdy obiekt sprząta po sobie w destruktorze, nie martwisz się wyciekami przy throw.
  • silna gwarancja – dobrze, gdy operacja albo się powiedzie w całości, albo nie zmieni stanu (np. użycie kopii tymczasowej i std::swap).
  • obszar graniczny – w kodzie biznesowym zwykle łapiesz wyjątki na „krawędziach” (wejście z sieci, start wątku), a w środku pozwalasz im lecieć wyżej.

Jeśli pracujesz w firmie, w której wyjątki są wyłączone (np. wbudowane systemy, część gamedevu), schemat jest podobny, tylko zamiast throw używane są kody błędów lub typy w stylu expected<T, Error>. RAII nadal zostaje, destruktory nadal sprzątają.

CMake dla ludzi – jak nie zniszczyć sobie dnia buildem

Minimalny projekt z CMake krok po kroku

Mit, który mocno trzyma się początkujących: „CMake to czarna magia, której nie da się zrozumieć”. Po bliższym spojrzeniu okazuje się, że w większości przypadków używa się kilku prostych poleceń i paru zmiennych.

Najprostszy projekt:

cmake_minimum_required(VERSION 3.16)
project(HelloCxx LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

add_executable(hello main.cpp)

To naprawdę wystarcza, żeby:

  • zdefiniować nazwę projektu,
  • wymusić C++17 bez rozszerzeń kompilatora,
  • zbudować plik wykonywalny z jednego źródła.

Do tego klasyczny workflow:

mkdir build
cd build
cmake ..
cmake --build .

Jeśli w zespole każdy trzyma się takiej struktury „kod w katalogu, build w osobnym katalogu”, nagle odpada half problemów z „brudnymi” plikami po kompilacji i dziwnymi artefaktami IDE.

Dodawanie bibliotek i testów bez bólu

Gdy projekt rośnie, naturalnie pojawia się potrzeba podzielenia go na biblioteki i testy. Schemat może być prosty, bez wchodzenia w zaawansowany „modern CMake” od pierwszego dnia.

add_library(core
  src/user.cpp
  src/order.cpp
)

target_include_directories(core
  PUBLIC
    ${CMAKE_CURRENT_SOURCE_DIR}/include
)

add_executable(app src/main.cpp)

target_link_libraries(app
  PRIVATE
    core
)

W tym wariancie:

  • core jest biblioteką z logiką biznesową,
  • app to cienka warstwa wejścia (np. CLI),
  • logika nie jest przyspawana do pliku main.cpp, co ułatwia testy.

Dorzucenie prostych testów z GoogleTest potrafi wyglądać tak (pomijając instalację samego GoogleTest):

add_executable(core_tests
  tests/user_tests.cpp
  tests/order_tests.cpp
)

target_link_libraries(core_tests
  PRIVATE
    core
    GTest::gtest_main
)

include(GoogleTest)
gtest_discover_tests(core_tests)

Nagle zamiast ręcznie odpalać jakieś pliki z main(), masz testy zintegrowane z CTest i możesz je odpalać jednym poleceniem ctest. W firmie często w tym samym miejscu podłącza się CI – a Ty korzystasz z wyników.

Typowe pułapki przy pierwszych projektach z CMake

Kilka rzeczy, które regularnie psują dzień ludziom zaczynającym z CMake:

  • grzebanie w globalnych zmiennychCMAKE_CXX_FLAGS i inne tego typu pułapki. Lepiej ustawiać wymagania per-target, np. target_compile_features, target_compile_options.
  • mieszanie buildów – przełączanie debug/release w tym samym katalogu build bez jego wyczyszczenia. Często prowadzi do dziwnych błędów linkera.
  • Rozsądne zarządzanie konfiguracjami debug/release

    Przy jednym małym projekcie różnice między debugiem a releasem wydają się abstrakcją. W pracy nagle masz trzy konfiguracje, pięć platform i CI, które musi to wszystko ogarniać. Kilka prostych nawyków pozwala uniknąć chaosu.

    Najpierw dodanie typowych konfiguracji w CMake (na systemach wielokonfiguracyjnych jak Visual Studio):

    set(CMAKE_CONFIGURATION_TYPES "Debug;Release;RelWithDebInfo" CACHE STRING "" FORCE)

    Potem – zamiast ustawiać flagi globalnie – rozdzielenie opcji per target i per konfiguracja:

    target_compile_definitions(core
      PRIVATE
        $<CONFIG:Debug>=CORE_DEBUG
        $<CONFIG:Release>=CORE_NDEBUG
    )
    
    target_compile_options(core
      PRIVATE
        $<CONFIG:Debug>-O0 -g
        $<CONFIG:Release>-O3
    )

    Mit bywa taki: „Release jest święty, Debug służy tylko do lokalnych testów”. W rzeczywistości większość błędów czasowych, wyścigów wątków i problemów z optymalizacją wychodzi dopiero w Release lub RelWithDebInfo. Dlatego CI powinno budować i testować przynajmniej dwie konfiguracje, a Ty musisz umieć przełączać je bez wywracania katalogu build.

    Jak nie walczyć z CMake w dużym repo

    W większych firmach częsty wzorzec to „monorepo z jedną wielką konfiguracją CMake”. Dla juniora brzmi to jak labirynt, ale parę punktów orientacyjnych pomaga:

  • każdy większy moduł ma własny CMakeLists.txt,
  • główny CMakeLists.txt tylko składa moduły przez add_subdirectory(),
  • łączysz się z innymi modułami jak z bibliotekami: target_link_libraries.
# root/CMakeLists.txt
add_subdirectory(core)
add_subdirectory(net)
add_subdirectory(ui)

add_executable(main_app src/main.cpp)
target_link_libraries(main_app
  PRIVATE
    core
    net
    ui
)

Zamiast błądzić po całym repo, szukasz katalogu modułu i zaczynasz od jego pliku CMake. To tam zwykle zobaczysz, jakie nagłówki eksportuje, jakie ma zależności i czy ma swoje testy. W praktyce to często szybsze niż odpalenie wyszukiwarki po całym kodzie.

Kolorowy kod PHP na ciemnym ekranie monitora
Źródło: Pexels | Autor: Pixabay

Zewnętrzne biblioteki bez dramatu – Conan, vcpkg i własne third_party

Dlaczego „ściągnij ZIP-a i wrzuć do repo” to pułapka

Naturalny odruch przy pierwszym kontakcie z biblioteką: pobrać archiwum, skopiować źródła do third_party/ i jakoś podpiąć. Działa – dopóki ktoś nie będzie chciał zaktualizować wersji, zbudować na innej platformie albo włączyć sanitizery. Wtedy nagle okazuje się, że pliki są poprzesuwane, local patche nieudokumentowane, a CMake przerabiany „na szybko”.

Znacznie stabilniejszy schemat: albo używasz managera pakietów (Conan, vcpkg), albo trzymasz bibliotekę jako submoduł git z minimalnymi, jasno opisanymi poprawkami.

Conan – typowy przepływ w projekcie

Conan dobrze sprawdza się w projektach, gdzie masz wiele zależności albo różne profile kompilacji (inna wersja dla embedded, inna na desktop). Minimalny przykład z integracją z CMake:

# konanfile.txt (prosty wariant)
[requires]
fmt/11.0.0

[generators]
CMakeDeps
CMakeToolchain

Workflow może wyglądać tak:

conan install . --output-folder=build --build=missing
cd build
cmake .. -DCMAKE_TOOLCHAIN_FILE=conan_toolchain.cmake
cmake --build .

W CMake korzystasz potem z wygenerowanych configów:

find_package(fmt CONFIG REQUIRED)

target_link_libraries(core
  PRIVATE
    fmt::fmt
)

Mit mówi: „manager pakietów to dodatkowa warstwa problemów”. W rzeczywistości największe problemy pochodzą z dziesięciu ręcznie wklejonych bibliotek o nieznanym pochodzeniu. Conan daje przynajmniej powtarzalność: ten sam opis zależności na Twoim laptopie, na CI i na maszynie kolegi.

vcpkg – szczególnie wygodny na Windowsie

vcpkg świetnie ułatwia życie na Windowsie, ale obsługuje też Linuxa i macOS. Kluczowe są dwa tryby: klasyczny (globalny) i manifestowy (per projekt). W pracy sens ma głównie manifest.

{
  "name": "my-app",
  "version-string": "0.1.0",
  "dependencies": [
    "fmt",
    "gtest"
  ]
}

Plik vcpkg.json wrzucasz do repo, a w CMake podłączasz toolchain vcpkg:

cmake -B build -S . -DCMAKE_TOOLCHAIN_FILE=/path/to/vcpkg.cmake
cmake --build build

Nagle masz w projekcie spójny zestaw bibliotek, wersje opisane w pliku, a nie „w pamięci najstarszego developera”. Przejście na nową wersję fmt czy spdlog to zmiana numeru w vcpkg.json i testy, zamiast polowania po googlach, skąd brałeś ZIP-a dwa lata temu.

Własny katalog third_party z głową

Nie każdy projekt da się oprzeć na managerze pakietów – czasem masz własne forki, biblioteki bez wsparcia Conana/vcpkg albo kod wewnętrzny. Da się to zrobić sensownie, jeśli trzymasz porządek.

  • Każda zewnętrzna biblioteka w swoim podkatalogu: third_party/spdlog/, third_party/json/.
  • Obowiązkowy plik README.md opisujący źródło, wersję upstream i lokalne poprawki.
  • Osobny CMakeLists.txt, który exportuje targety (np. spdlog::spdlog), zamiast mieszać w Twoich flagach kompilatora.
# third_party/spdlog/CMakeLists.txt
add_library(spdlog INTERFACE)
target_include_directories(spdlog
  INTERFACE
    ${CMAKE_CURRENT_SOURCE_DIR}/include
)

add_library(spdlog::spdlog ALIAS spdlog)

Na wyższym poziomie używasz tego jak każdej innej biblioteki:

add_subdirectory(third_party/spdlog)

target_link_libraries(core
  PRIVATE
    spdlog::spdlog
)

Dzięki temu wymiana spdloga na inną wersję albo przejście z „vendorowanego” kodu na managera pakietów nie rozsypuje połowy repo. Zmieniasz implementację w jednym miejscu, a nie poprawiasz include pathy w 50 plikach CMake.

Nawyki projektowe, które ułatwią życie Twojemu przyszłemu „ja”

Małe, hermetyczne moduły zamiast jednego „god objecta”

Popularny mit: „zrobię jedną klasę Application i tam będzie cała logika, będzie mi łatwo nawigować”. Rzeczywistość: po miesiącu każda zmiana wymaga dotykania pół ekranu pól i metod, testy są bolesne, a konflikty w gitcie codzienne.

Znacznie zdrowszy schemat to kilka mniejszych modułów, każdy z prostą odpowiedzialnością. Dla aplikacji biznesowej może to być:

  • domain/ – modele i logika domenowa,
  • infrastructure/ – bazy danych, HTTP, pliki,
  • app/ – warstwa use-case’ów,
  • ui/ – CLI, GUI, REST API.

W CMake odzwierciedlasz to bibliotekami:

add_library(domain ...)
add_library(infrastructure ...)
add_library(app ...)

target_link_libraries(app
  PRIVATE
    domain
    infrastructure
)

Od strony C++ sprowadza się to do tego, że Twój kod biznesowy nie musi wiedzieć o konkretnym kliencie HTTP ani typach bazy. W testach możesz to podmienić na fałszywe implementacje.

Interfejsy i implementacje – prosty wzorzec

Często spotykany problem: klasa „do wszystkiego” zależy bezpośrednio od biblioteki sieciowej, loggera i bazy, a potem nikt nie umie jej przetestować bez stawiania całego środowiska. C++ nie wymusza interfejsów jak Java, ale można to ograć zwykłymi abstrakcyjnymi klasami (pure virtual).

// include/user_repository.hpp
class UserRepository {
public:
  virtual ~UserRepository() = default;
  virtual User find_by_id(int id) = 0;
  virtual void save(const User& user) = 0;
};

Implementacja trzyma zależność na konkretną technologię:

// src/sql_user_repository.hpp
class SqlUserRepository : public UserRepository {
public:
  explicit SqlUserRepository(SqlConnection& conn) : conn_(&conn) {}

  User find_by_id(int id) override;
  void save(const User& user) override;

private:
  SqlConnection* conn_;
};

W testach możesz mieć prostą wersję w pamięci:

class InMemoryUserRepository : public UserRepository {
public:
  User find_by_id(int id) override { return storage_.at(id); }
  void save(const User& user) override { storage_[user.id] = user; }

private:
  std::unordered_map<int, User> storage_;
};

Mit: „interfejsy to overengineering, wystarczą funkcje”. W mniejszych fragmentach funkcje faktycznie są prostsze. Ale gdy Twój kod zaczyna żyć w wielu procesach, modułach i środowiskach testowych, ten minimalny poziom abstrakcji zwraca się bardzo szybko.

Style guide i formatowanie jako tarcza przed konfliktami

W zespole bez spójnego stylu formatowania dyskusje o spacjach potrafią zająć tyle czasu, co optymalizacja algorytmu. Najprostsza ochrona to zautomatyzowane narzędzia: clang-format i clang-tidy (albo inne, jeśli firma ma swój zestaw).

clang-format -i src/*.cpp include/*.hpp

W CMake możesz podpiąć prosty target pomocniczy:

add_custom_target(format
  COMMAND clang-format -i
    ${PROJECT_SOURCE_DIR}/src/*.cpp
    ${PROJECT_SOURCE_DIR}/include/*.hpp
  WORKING_DIRECTORY ${PROJECT_SOURCE_DIR}
)

W praktyce ustala się jedną konfigurację .clang-format w repo i traktuje jako część API projektu. Nikt nie „formatuje po swojemu”, IDE robi to automatycznie albo w pre-commit hooku. Konflikty w gitcie spadają, bo nikt nie zmienia stylu ręcznie.

„Głośne” ostrzeżenia kompilatora jako pierwszy test

Dużo produkcyjnych bugów to w istocie ostrzeżenia, które ktoś zignorował. Nieużywane zmienne, nieprzypisane pola, porównania signed/unsigned – wszystko to kompilator zwykle widzi. Ustawienie wysokiego poziomu ostrzeżeń i traktowanie ich jak błędów na nowych targetach zmienia jakość kodu.

if (MSVC)
  target_compile_options(core PRIVATE /W4 /permissive-)
else()
  target_compile_options(core PRIVATE -Wall -Wextra -Wpedantic)
endif()

Można też dla kluczowych bibliotek dołożyć „ostrzeżenia jako błędy”:

if (MSVC)
  target_compile_options(core PRIVATE /WX)
else()
  target_compile_options(core PRIVATE -Werror)
endif()

Mit: „-Werror utrudnia pracę, bo build często się wywala”. Rzeczywistość: build wywala się szybciej i bliżej miejsca, gdzie problem powstał. Zamiast dziwnego crasza na produkcji dostajesz jasny komunikat w CI. Zdrowy kompromis to trzymanie -Werror dla „rdzenia” projektu, a w aplikacjach czy prototypach dopuszczanie ostrzeżeń.

Programista w słuchawkach pisze kod C++ przy dwóch monitorach
Źródło: Pexels | Autor: hitesh choudhary

Testy, debugowanie i narzędzia, które realnie ratują dzień

Testy jednostkowe – minimalny, ale działający schemat

Wiele osób ma złe doświadczenia z testami w C++, bo zaczynali od nieczytelnych, gigantycznych przypadków testowych. Tymczasem prosty schemat „jeden przypadek – jeden mały scenariusz” jest jak najbardziej realny.

// tests/user_service_tests.cpp
#include <gtest/gtest.h>
#include "user_service.hpp"
#include "in_memory_user_repository.hpp"

TEST(UserService, CreatesUserWithDefaultRole) {
  InMemoryUserRepository repo;
  UserService service(repo);

  auto id = service.create_user("alice");

  auto user = repo.find_by_id(id);
  EXPECT_EQ(user.name, "alice");
  EXPECT_EQ(user.role, Role::User);
}

Taki test:

  • nie odpala bazy,
  • dotyka tylko logiki domenowej,
  • jest szybki i odpala się lokalnie w sekundę.

W połączeniu z CMake i CTest dostajesz podstawowy feedback loop „zmiana – test – wynik” bez ręcznego odpalania binarek.

Sanitizery – lepsze niż godzinne gapienie się w core dump

ASan, UBSan i koledzy to jedne z najważniejszych narzędzi przy pracy z C++. W wielu firmach są włączone domyślnie w CI, ale lokalnie często o nich zapominamy. A wystarczy jedna konfiguracja w CMake, żeby mieć wersję debugową z dodatkowymi kontrolami.

Co warto zapamiętać

  • C++ nie kończy się na sterownikach i silnikach gier – siedzi w backendach o wysokiej wydajności, systemach finansowych, embedded/IoT i ogromnych aplikacjach desktopowych, często jako wydajny „silnik” pod high‑levelowym API.
  • Główne źródło frustracji początkujących to nie sam język, tylko ekosystem: konfiguracja kompilatora, CMake, biblioteki, różnica między nowoczesnym a „zabytkowym” C++ oraz niezrozumiałe błędy kompilatora i linkera.
  • Mit, że junior musi znać każdy detal standardu i szablony jak autor STL, rozbija się o rzeczywistość: firmie zwykle wystarczy sensowne ogarnięcie C++17, STL, podstaw CMake, prostych testów, Git i umiejętność debugowania.
  • Najmniej bolesna ścieżka nauki to świadome ograniczenie zakresu: najpierw solidne fundamenty nowoczesnego C++ (RAII, smart pointery, kontenery i algorytmy), potem małe projekty z CMake i testami, a dopiero później zanurzenie w legacy.
  • Realny „stack startowy” dla juniora to: C++11–17 w praktyce, proste CMakeLists.txt, GoogleTest lub podobne testy, kilka popularnych bibliotek (fmt, spdlog, fragment Boost), plus codzienna praca z Git i code review.
  • Mit, że C++ to wyłącznie low‑level i mikrozarządzanie pamięcią, jest przestarzały: współczesny styl opiera się na RAII, kontenerach STL, smart pointerach i unikaniu gołych wskaźników tam, gdzie nie są absolutnie potrzebne.