CodeGym /Kursy /C++ SELF /Wydajność i czytelność

Wydajność i czytelność

C++ SELF
Poziom 62, Lekcja 4
Dostępny

1. Wprowadzenie

Kiedy po raz pierwszy widzisz ranges, masz ochotę natychmiast przepisać wszystko na pipeline'y — mniej więcej jak pierwszego dnia z std::cout, gdy chcesz wypisać nawet temperaturę procesora. W praktyce ranges to narzędzie, a nie religia. Czasem sprawia, że kod jest krótszy, czytelniejszy i nawet szybszy „po wyjęciu z pudełka”. A czasem otrzymujesz piękną jednowierszową „poemat”, której autor boi się dotykać po dwóch tygodniach, plus niespodziewaną cenę wydajności z powodu powtórnych przejść.

Bardzo ważne jest, by zadawać sobie dorosłe pytania: „Czy ten pipeline będzie przebiegał raz czy wiele razy?”, „Czy potrzebuję wyniku będącego właścicielem danych?”, „Czy na pewno nie będę zmieniać źródłowego kontenera?”, „Czy kod czyta się z lewej do prawej jak przepis, czy jak łamigłówka na olimpiadzie?”.

Aby rozmawiać konkretnie, oprzemy się na naszym szkolnym mini‑projekcie: aplikacji konsolowej TaskBook (lista zadań). Mamy std::vector<Task>, potrafimy wypisywać zadania, wyszukiwać, sortować itd. Dzisiaj dodamy „wystawkę”: pokazać pierwsze N ważnych niezakończonych zadań, a potem omówimy, w jakich przypadkach robić to przez view‑pipeline, a kiedy przez materializację albo zwykłą pętlę.

2. Koszt ranges: co jest prawie darmowe, a co może „ugryźć”

Pipeliny wyglądają jakbyś złożył taśmę na fabryce: na wejście wsadziłeś kontener, na wyjściu otrzymujesz rezultat. Kluczowy niuans jest taki, że view to najczęściej nie fabryka, a plan fabryki. Dopóki nie iterujesz po range w pętli for, realna praca mogła w ogóle nie zajść. Z tego wynikają zarówno zalety, jak i pułapki: można nie robić zbędnych rzeczy, ale można też „nie zauważyć”, że zrobiło się coś zbędnego dwukrotnie.

Zapiszmy trzy najważniejsze „koszty” ranges/views: liczba przejść, alokacje/kopie oraz własności zakresu (np. czy można szybko poznać size() i czy jest dostęp losowy).

Lenistwo: praca wykonuje się podczas iteracji i może się powtarzać

Lenistwo jest świetne, dopóki świadomie wykonujesz jedno przejście. Ale jeśli iterujesz view dwa razy, to „leniwy konwerter” wykona się dwa razy. Czasami to akceptowalne, a czasami przypadkowo podwajasz koszt.

Mini‑przykład z licznikiem wywołań (idea ważniejsza niż konkretny projekt):

#include <iostream>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> v{1, 2, 3};
    int calls = 0;

    auto doubled = v | std::views::transform([&](int x) {
        ++calls;              // efekt uboczny (licznik)
        return x * 2;
    });

    for (int x : doubled) { (void)x; }
    for (int x : doubled) { (void)x; }

    std::cout << calls << '\n'; // 6
}

Tu pipeline wygląda nieszkodliwie, ale dwa przejścia dały 6 wywołań lambdy. Jeśli w transform nie masz ++calls, ale na przykład ciężką funkcję (normalizacja stringa, obliczanie hasha, parsowanie), to „oj” staje się zauważalne.

Praktyczna myśl: view‑pipeline jest świetny do „odczytaj i wypisz” albo „odczytaj i raz zgromadź”. Jeśli zamierzasz używać wyniku wielokrotnie, często lepiej zmaterializować.

Alokacje i kopie: view wygrywa, dopóki nie trzeba przechować wyniku

Gdy używasz filter/transform/take, zazwyczaj nie tworzysz nowego std::vector i nie robisz push_back dla każdego elementu. To oznacza: mniej alokacji, mniej kopii, często mniej kodu.

Ale gdy trzeba „przechować wynik”, „przekazać gdzieś dalej”, „wrócić do niego później” — i tak wylądujesz przy kontenerze. I tu pojawiają się pytania: ile elementów będzie, czy potrzebne reserve, czy nie kopiujemy std::string zbędnie itd.

Dobre reguły: view‑pipeline to przetwarzanie na bieżąco, materializacja to stabilizacja danych.

Utrata wygodnych własności: filter może odebrać size() i indeksy

Kontener typu std::vector jest świetny, bo ma szybki dostęp losowy i czytelną size(). Po std::views::filter dostajesz „filtrowaną sekwencję”, która może już nie być „jak vector”: na przykład z góry nie wiadomo, ile elementów przejdzie przez filtr i czasem nie da się efektywnie skoczyć do i‑tego elementu.

To nie jest „źle”, to inny model. Po prostu nie oczekuj od view tego, czego zwykle oczekujesz od kontenera.

Jeśli piszesz kod, któremu kluczowo potrzebne są size() i indeksy, to trzy opcje: trzymaj się kontenerów, zmaterializuj wynik na granicy albo przemyśl zadanie tak, by jedno przejście wystarczyło.

3. Czytelność: pipeline jako przepis, a nie zaklęcie

Wydajność jest ważna, ale początkujący programiści częściej „potykają się” nie o nanosekundy, lecz o utrzymanie. Jeśli sam nie możesz szybko zrozumieć, co robi kod, to z czasem zaczniesz „optymalizować” go młotkiem.

Ranges można łatwo zamienić w „ładne, ale niezrozumiałe”, bo operator | bardzo kusi, żeby napisać kolos „w jednym wierszu”. Dlatego dziś najważniejsze jest zrozumieć: jak pisać pipeline'y tak, aby poprawiały czytelność.

Długość pipeline'u: 1–3 kroki zwykle łatwe, 6–8 — już trudne

Pipeline jest dobry, gdy można go dosłownie wypowiedzieć: na przykład: „bierzemy zadania → zostawiamy niezakończone → bierzemy pierwsze 3”. To brzmi jak przepis.

Gdy kroków robi się dużo (szczególnie z zagnieżdżonymi lambdami), mózg przestaje trzymać wszystko naraz. Wtedy lepiej nadać imiona pośrednim krokom.

Porównajmy dwie formy.

Opcja „kolos” (na przykładzie zadań — kod ilustracyjny, ważne jest znaczenie):

auto view = tasks
    | std::views::filter([](const Task& t){ return !t.done; })
    | std::views::filter([](const Task& t){ return t.priority >= 5; })
    | std::views::transform([](const Task& t){ return t.title; })
    | std::views::take(3);

To jeszcze do przyjęcia, ale już na granicy: dwa filtry z rzędu plus transform.

Opcja „kroki z nazwami”:

auto pending = tasks | std::views::filter([](const Task& t) { return !t.done; });
auto urgent  = pending | std::views::filter([](const Task& t) { return t.priority >= 5; });
auto titles  = urgent  | std::views::transform([](const Task& t) { return t.title; });
auto top3    = titles  | std::views::take(3);

Tak, linii jest więcej. Za to każda linia to mała myśl, którą łatwo sprawdzić wzrokiem. I to jest krytyczne podczas debugowania, review albo gdy przyszły ty próbuje zrozumieć, dlaczego „wyświetlają się nie te trzy zadania”.

Lambdy: trzymaj je krótkie i „czyste”

Lambda wewnątrz filter to predykat. W transform — transformacja. Idealnie: bez efektów ubocznych i bez zmieniania zewnętrznych zmiennych.

Jeśli robisz efekt uboczny (druk, inkrementacja licznika, zapis do pliku), pamiętaj: view jest leniwy i efekt wystąpi dopiero przy iteracji. Dwa przejścia — dwa wydruki. To nie błąd, to uczciwa semantyka.

Gdy efekt uboczny jest konieczny, czasem uczciwiej napisać zwykłą pętlę, bo pętla „wizualnie” obiecuje wykonanie tu i teraz, a view — tego nie obiecuje.

auto pomaga czytelności, bo typy zwracane przez views są „straszne”

Typy obiektów zwracane przez std::views::filter i std::views::transform są długie i zależą od tego, jakie lambdy podałeś. Próbowanie ich pisania ręcznie to prawie pewny sposób na popsucie dnia.

Dlatego w kodzie z ranges auto to nie „ukrywanie typu przed czytelnikiem”, a „nie zmuszanie czytelnika do patrzenia na trzy ekrany szablonowej papki”. Czytelnik ma widzieć sens: pending, urgent, top3. To jak zmienne sum i count: są ważniejsze niż dokładny typ procesora.

4. Kiedy ranges/views rzeczywiście przyspieszają kod

Są scenariusze, w których ranges wygrywają jednocześnie pod względem czytelności i wydajności. Zazwyczaj to scenariusze „jedno przejście, minimalna pamięć, wczesne zatrzymanie”.

Poniżej tabela, którą warto mieć w głowie (to nie „prawo”, a wskazówka):

Scenariusz Zwykle lepiej Dlaczego
„Trzeba wypisać elementy spełniające warunek”
views::filter + range-for
Minimum kodu, brak zbędnych kopii
„Trzeba przekształcić i od razu wypisać”
views::transform + range-for
Obliczenia na bieżąco, brak pośredniego kontenera
„Trzeba wziąć pierwsze N pasujących”
filter + take
take daje wczesne zatrzymanie; często to duży zysk
„Wynik potrzebny raz i od razu do algorytmu/wypisania”
view-pipeline
Nie tworzymy tymczasowych wektorów
„Wynik potrzebny wiele razy”
materializacja
Obliczamy raz, potem używamy

To właśnie „filter + take” często daje miły bonus: nie musisz przechodzić całego kontenera, jeśli potrzebujesz pierwszych 3 pasujących elementów. Można to też zrobić ręcznie, ale ranges pozwalają napisać to deklaratywnie.

5. Kiedy ranges/views komplikują kod i mogą spowalniać

Są też scenariusze przeciwne. W nich ranges nadal działają, ale cena za ład może być zbyt wysoka: albo w wydajności (powtarzane obliczenia), albo w utrzymaniu (zbyt skomplikowany pipeline), albo w bezpieczeństwie (czas życia/inwalidacja), albo w debugowaniu.

Ważne: celem nie jest „unikać ranges”, lecz rozumieć, kiedy zatrzymać się i zrobić kod prostszym.

Wielokrotne użycie wyniku: view nie buforuje

Jeśli budujesz view i potem używasz go w trzech miejscach (liczysz statystyki, potem wypisujesz, potem coś jeszcze), możesz nie zauważyć, że zrobiłeś trzy pełne przejścia i trzykrotnie wykonałeś transform.

Jeśli dane są małe — nic się nie stanie. Jeśli duże — może to być zauważalne.

W takich przypadkach materializacja często czyni kod szybszym i bardziej zrozumiałym: „zebrałem listę ważnych zadań, teraz z nią pracuję”.

Pipeline z „inteligentną” logiką w lambdzie

Jeśli w filter masz 15 linii warunków, kilka zmiennych tymczasowych i odwołania do zewnętrznych struktur, to otrzymujesz nie „styl funkcyjny”, a „przeniesienie zwykłego kodu do wnętrza lambdy”. Czytelność zwykle spada.

Często lepiej wyciągnąć warunek do osobnej funkcji o nazwie. Wtedy pipeline pozostaje deklaratywny: filter(IsUrgentTask) zamiast połowy bloku warunków bezpośrednio w miejscu użycia.

Granice danych i czas życia: ładny view, którego niebezpiecznie przechowywać

Omówiliśmy to dokładnie na wykładzie o dangling: view żyje nad źródłem. Jeśli zwracasz view z funkcji, a źródło było lokalne — prawie na pewno zwrócisz „widok na coś, co już umarło”.

Nawet jeśli kompilator cię nie powstrzyma, przyszły bug report zrobi swoje.

6. Praktyka: „top‑3” pilnych niezakończonych zadań

Teraz zrobimy fragment funkcjonalności „uczciwie”: mamy zadania, chcemy wypisać pierwsze 3 zadania, które są jeszcze niewykonane i mają priorytet nie niższy niż zadany.

Na początek ustalmy model i prosty sposób wypisania. (Zakładamy, że Task i tasks masz już z poprzednich dni o struct, vector, funkcjach i wypisywaniu.)

Model Task

Aby przykłady były zrozumiałe, oto minimalny model:

#include <string>

struct Task {
    int id = 0;
    std::string title;
    bool done = false;
    int priority = 0;
};

View‑pipeline „od razu do wypisania”

Ta opcja jest dobra, kiedy wynik trzeba po prostu wypisać raz.

#include <iostream>
#include <ranges>
#include <vector>

int main() {
    std::vector<Task> tasks{{1,"Read C++",false,7},{2,"Sleep",true,10},{3,"Debug",false,6}};

    auto top = tasks
        | std::views::filter([](const Task& t) { return !t.done; })
        | std::views::filter([](const Task& t) { return t.priority >= 6; })
        | std::views::take(3);

    for (const Task& t : top) std::cout << t.id << ": " << t.title << '\n';
    // 1: Read C++
    // 3: Debug
}

Tu pipeline czyta się po ludzku: „niewykonane → wystarczająco ważne → pierwsze trzy”. I nie tworzy nowego kontenera.

Zauważ, że take(3) nie oznacza „weź trzy pierwsze elementy z oryginalnego wektora”. Oznacza „weź trzy pierwsze elementy po filtrach”. Jeśli pasujących zadań są dwa — będą dwa.

Te same kroki, ale z nazwami

Gdy pipeline zaczyna rosnąć, nazwy pomagają.

#include <ranges>
#include <vector>

auto PendingTasks(const std::vector<Task>& tasks) {
    return tasks | std::views::filter([](const Task& t) { return !t.done; });
}

Ten przykład jest specjalnie krótki, żeby pokazać ideę: można budować view z funkcji. Ale tu jest niuans: zwracamy view, które odnosi się do tasks. To bezpieczne tylko wtedy, gdy kod wywołujący gwarantuje, że tasks żyje dłużej niż wynik. W main zwykle tak jest. Jeśli zaczniesz zwracać taki view „w próżnię” — przypomnij sobie wykład o czasie życia.

Kiedy materializacja wygrywa: używamy wyniku dwukrotnie

Wyobraźmy sobie, że chcemy zarówno wypisać „top‑3”, jak i policzyć, ile z nich ma priorytet 10. Jeśli będziemy dwa razy iterować to samo view, dwukrotnie wykonamy warunki filter.

Czasem to drobnostka, ale pokażmy „dorosłą” wersję: zebrać wynik do osobnego std::vector<Task>.

#include <algorithm>
#include <iterator>
#include <ranges>
#include <vector>

int main() {
    std::vector<Task> tasks{{1,"Read C++",false,7},{2,"Sleep",true,10},{3,"Debug",false,6}};

    auto view = tasks
        | std::views::filter([](const Task& t) { return !t.done; })
        | std::views::filter([](const Task& t) { return t.priority >= 6; })
        | std::views::take(3);

    std::vector<Task> top;
    std::ranges::copy(view, std::back_inserter(top));
}

Teraz top to wynik będący właścicielem. Możesz go sortować, wypisywać, liczyć statystyki, przekazywać do innych funkcji — i nie bać się, że przy powtórnym użyciu filtry znów się wykonały.

Tak, zapłaciłeś kopiami Task (a więc także kopiami std::string w title). Dlatego materializacja powinna być świadoma: jest potrzebna, gdy faktycznie korzystasz z wyniku dalej, a nie „na zapas”.

Mini‑schemat wyboru: view czy kontener

Czasem warto mieć „mentalny diagram blokowy”, żeby nie zawieszać się nad każdą linijką kodu. Teraz będzie maksymalnie praktyczny i bez filozofii.

flowchart TD
    A[POTRZEBA UZYSKAĆ PODZBIÓR/TRANSFORMACJĘ DANYCH] --> B{Wynik potrzebny raz?}
    B -->|Tak| C{Czy trzeba przechować wynik?}
    C -->|Nie| D[Views + range-for/algorytm]
    C -->|Tak| E[Materializacja do kontenera]
    B -->|Nie, będę używać wielokrotnie| E
    D --> F{Ryzyko czasu życia/inwalidacji źródła?}
    F -->|Tak| E
    F -->|Nie| G[Zostawiamy view]

Ten schemat jest specjalnie prosty: nie zastępuje myślenia, ale pomaga nie zapomnieć o dwóch głównych powodach materializacji — wielokrotne użycie i bezpieczeństwo czasu życia.

7. Typowe błędy przy wyborze między ranges/views i „zwykłym” kodem

Błąd nr 1: sądzić, że view „liczy raz i zapamiętuje”.
Początkujący często traktują auto x = v | views::transform(...) jako „utworzyłem nowy zestaw danych”. W rzeczywistości stworzyłeś opis obliczenia. Jeśli później dwukrotnie przejdziesz po x, dwa razy wykona się filter/transform. Jeśli w środku jest ciężka praca, dostaniesz spowolnienie „znikąd”.

Błąd nr 2: robić skutki uboczne wewnątrz filter/transform i dziwić się „dziwnemu zachowaniu”.
Jeśli w transform drukujesz coś lub zmieniasz zewnętrzną zmienną, mocno uzależniasz poprawność od liczby przejść. Jeden dodatkowy for i logika się psuje. W takich sytuacjach albo trzymaj lambdy „czyste”, albo użyj zwykłej pętli, gdzie wykonanie jest oczywiste.

Błąd nr 3: budować gigantyczny pipeline w jednym wierszu i nazywać to „pięknem”.
Pipeline jest dobry, dopóki czyta się z lewej do prawej jak przepis. Kiedy masz pięć adapterów, skomplikowane lambdy i gdzieś obok take, robi się łamigłówka. Zwykle pomaga podział na nazwane kroki: pending, urgent, top3.

Błąd nr 4: materializować zawsze i wszędzie „na zapas”.
Druga skrajność po „wszystko przez views” to „wszystko do nowego vectora”. Tracisz wtedy główną zaletę ranges: przetwarzanie na bieżąco bez zbędnej pamięci. Materializuj z powodów: potrzeba własności, wielokrotne użycie, granica API, bezpieczeństwo czasu życia.

Błąd nr 5: zapominać, że źródło view może się zmienić lub zostać unieważnione.
Nawet jeśli tasks_view wygląda jak oddzielny obiekt, zależy od std::vector<Task> tasks. Jeśli zmienisz tasks strukturalnie (np. push_back, erase) i potem iterujesz view, można dostać niespodzianki. To nie „mystery ranges”, to zwykłe reguły czasu życia i inwalidacji, tylko w nowej otoczce.

1
Zadanie
C++ SELF, poziom 62, lekcja 4
Niedostępne
Witryna parzystych
Witryna parzystych
1
Zadanie
C++ SELF, poziom 62, lekcja 4
Niedostępne
Leniwa magia
Leniwa magia
1
Zadanie
C++ SELF, poziom 62, lekcja 4
Niedostępne
Lista priorytetów
Lista priorytetów
1
Zadanie
C++ SELF, poziom 62, lekcja 4
Niedostępne
Normalizacja słów
Normalizacja słów
1
Ankieta/quiz
ranges/views, poziom 62, lekcja 4
Niedostępny
ranges/views
ranges/views
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION