JavaRush /Курси /C++ SELF /Мінігігієна продуктивності як частина triage

Мінігігієна продуктивності як частина triage

C++ SELF
Рівень 47 , Лекція 3
Відкрита

1. Мінігігієна продуктивності допомагає в triage

Коли ви чуєте слово «продуктивність», уява новачка часто малює таку картину: хтось у чорній водолазці оптимізує бульбашкове сортування, щоб воно стало «майже швидким». Під час розслідування помилок памʼяті все значно приземленіше: продуктивність важлива рівно настільки, наскільки вона впливає на стабільність поведінки. Чим більше випадкових копій і переміщень у памʼяті, тим більше «шуму» навколо багів. І тим важче відрізнити першопричину від наслідку.

Уявіть, що у вас є помилка, повʼязана з часом життя: ви десь зберегли вказівник на елемент std::vector, а потім зробили push_back — і стався reallocation. Якщо reallocation трапляється «іноді» — залежно від поточного capacity, — то баг теж проявлятиметься «іноді». А якщо ви на час розслідування додасте reserve, то переміщення може зникнути. Тоді ви або спростите трасування, або, навпаки, зрозумієте, що проблема не в reallocation, а в іншій частині коду. В обох випадках це виграш: сигнал стає керованим.

Важливо памʼятати головний принцип: спочатку коректність, потім оптимізація. Ми не будемо «підкручувати» програму заради швидкості. Натомість внесемо невеликі зміни, які водночас: (а) зазвичай пришвидшують код; (б) зменшують кількість випадкових подій — копій і переміщень; і тим самим (в) спрощують розслідування та роблять звіти санітайзерів прозорішими.

2. std::vector: reserve, reallocation і відмінність від resize

std::vector — контейнер-робоча конячка, але має одну характерну рису: він зберігає елементи в суцільному блоці памʼяті. Поки місця вистачає — чудово. Коли місця бракує, vector виділяє новий буфер, зазвичай більший, переміщує елементи туди й звільняє старий. Це і є reallocation. Після цього усі вказівники, посилання, ітератори й std::span на елементи старого буфера стають недійсними.

Ось проста схема того, що відбувається:

flowchart LR
    A[буфер vector №1
capacity=3] -->|push_back 4-го елемента| B[виділяємо буфер №2
capacity=6] B --> C[переміщуємо/копіюємо елементи] C --> D[звільняємо буфер №1]

Під час triage ключове слово тут — «іноді». Іноді capacity вистачає, іноді ні. Іноді push_back викликає переміщення, іноді ні. Тому один і той самий неправильний вказівник може «випадково» ще вказувати на дані, а може вже вказувати в нікуди. Санітайзер у такі моменти схожий на детектива, який прийшов на місце злочину через тиждень: сліди є, але половину змило дощем.

І тут reserve стає не стільки «оптимізацією», скільки стабілізатором експерименту. Якщо ви приблизно знаєте верхню межу розміру, можна заздалегідь попросити vector виділити достатньо памʼяті. Так ви зменшите кількість переміщень.

Мініприклад, у якому reserve робить поведінку передбачуванішою:

#include <vector>
#include <string>

int main() {
    std::vector<std::string> lines;
    lines.reserve(1000); // менше перевиділень -> менше випадкових переміщень

    lines.push_back("one");
    lines.push_back("two");
}

Зверніть увагу на практичну деталь: у розмові й у тексті легко сказати «reserve()», але в реальному коді ви викликаєте reserve(size_type), тобто завжди передаєте конкретне число. Під час налагодження корисно буквально тримати це в голові: «резервуємо саме стільки».

reserve і resize — схожі слова, різні наслідки

Ці два методи часто плутають, бо обидва ніби «про розмір». Але по суті вони з різних світів: reserve — про ємність (capacity), а resize — про логічний розмір (size) і навіть про створення або видалення елементів. У triage це критично: переплутали — отримали зайві елементи, неочікувані значення, іноді навіть інші гілки логіки. І ось ви вже розслідуєте не той баг, який шукали.

Скажімо простіше. reserve(n) каже: «дорогий vector, будь ласка, підготуй місце щонайменше для n елементів, але не змінюй те, що я вважаю реально збереженими елементами». resize(n) каже: «дорогий vector, тепер у тебе реально має бути n елементів; якщо їх бракувало — створи нові, якщо було більше — видали зайві».

Мінідемонстрація в навчальному контексті: умовно читаємо команди користувача й зберігаємо історію рядків.

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

int main() {
    std::vector<std::string> history;
    history.reserve(3);

    std::cout << history.size() << '\n'; // 0
    history.push_back("add task");
    std::cout << history.size() << '\n'; // 1
}

А тепер те саме з resize, щоб відчути різницю:

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

int main() {
    std::vector<std::string> history;
    history.resize(3); // size став 3, додалися 3 порожні рядки

    std::cout << history.size() << '\n'; // 3
    std::cout << '"' << history[0] << '"' << '\n'; // ""
}

У triage це особливо важливо: якщо ви «для оптимізації» випадково вставили resize, то вже змінили поведінку програми. А розслідування має спиратися на мінімальні зміни. Тож варто запамʼятати: reserve — зазвичай безпечний «стабілізатор», а resize — це вже зміна даних і логіки.

3. Менше копій і чесні сигнатури

Копіювання в C++ — підступна річ. Новачки часто сприймають його як «ну подумаєш, передали рядок». А потім зʼясовується, що рядок усередині — це буфер у динамічній памʼяті, і копіювання означає виділення памʼяті, перенесення даних, можливі перевиділення. Навіть якщо все коректно, це створює тло: більше алокацій, більше руху в памʼяті, більше місць, де баг часу життя може проявитися або замаскуватися.

З погляду розслідування помилок памʼяті зайві копії шкодять двома способами. По‑перше, вони змінюють картину алокацій: санітайзер показує трасу виділень і звільнень, і ви бачите більше подій, ніж потрібно. По‑друге, копії можуть випадково продовжити життя даних. Наприклад, ви зробили копію рядка — і раптом std::string_view «працює», хоча насправді він посилався на тимчасовий обʼєкт. Це особливо неприємно: баг стає «невідтворюваним», бо ви випадково замаскували його копіюванням.

Тому мінігігієна тут дуже проста: передаємо за значенням лише тоді, коли нам справді потрібна копія або володіння, а в усіх інших випадках — за const& або через view‑типи, якщо вони доречні з погляду часу життя.

Невеличка табличка для орієнтира, без фанатизму: ми не пишемо дисертацію.

Що передаємо Тип параметра Коли доречно Що важливо памʼятати
Маленьке й дешеве (int, bool)
int x
Майже завжди Просто й зрозуміло
Великий тип, що володіє даними (std::string, std::vector)
const T&
Читаємо, не копіюємо Дані мають жити в того, хто викликає
Рядок «на час розбору»
std::string_view
Парсинг і перевірки всередині виклику Не можна зберігати view «на потім»
Суцільні дані
std::span<const T>
Обробка масиву або вектора без копій Джерело не має переміщуватися

Чесні сигнатури на прикладі консольного менеджера завдань

Щоб тема не лишалася абстрактною, привʼяжемо її до одного застосунку: простого консольного «менеджера завдань». У нас є модель:

#include <string>

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

І є сховище:

#include <vector>

struct TaskStore {
    std::vector<Task> tasks;
};

Тепер подивімося на сигнатури функцій. Припустімо, ми хочемо додати завдання. Наївний варіант часто виглядає так:

#include <string>
#include <vector>

void add_task(std::vector<Task>& tasks, std::string text) {
    tasks.push_back(Task{static_cast<int>(tasks.size()) + 1, text, false});
}

Тут text передається за значенням, тобто копіюється під час виклику. Іноді це нормально. Але частіше ми хочемо приймати «щось рядкоподібне» без зайвих копій, а всередині вже зберігати std::string, бо завдання має володіти своїм текстом.

Акуратний варіант для нашого випадку:

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

void add_task(std::vector<Task>& tasks, std::string_view text) {
    Task t;
    t.id = static_cast<int>(tasks.size()) + 1;
    t.text = std::string{text}; // копію робимо рівно один раз: у сховищі
    t.done = false;
    tasks.push_back(t);
}

Сенс тут тонкий, але дуже практичний: функція не вимагає від того, хто її викликає, заздалегідь створювати std::string. Вона може прийняти і std::string, і рядковий літерал, і шматок рядка. Але водночас у Task ми зберігаємо власний std::string, а не std::string_view, щоб не отримати невалідний view.

Так само працює і виведення. Якщо ми просто друкуємо завдання, передавати контейнер за значенням — це класична зайва копія: і зайва памʼять, і зайві переміщення.

#include <iostream>
#include <vector>

void print_tasks(const std::vector<Task>& tasks) {
    for (const Task& t : tasks) {
        std::cout << t.id << ". " << t.text << '\n';
    }
}

Це не «супероптимізація». Це базова чесність: функція друку не має володіти списком завдань. Вона має лише читати його.

string_view і span — зручні view‑типи, але з суворими вимогами до часу життя

View‑типи — це як узяти дані «подивитися ближче», не забираючи їх із собою. Зручно, швидко, але якщо ви випадково порушите це правило — наприклад, збережете view у полі структури або повернете назовні view на тимчасовий обʼєкт, — то отримаєте класичне «учора працювало».

У triage view‑типи корисні тим, що вони зменшують кількість копій, а отже, і кількість алокацій та переміщень. Але за це доводиться платити дисципліною часу життя.

Розгляньмо типову функцію парсингу команди користувача. Ми читаємо рядок line, а далі хочемо перевірити, чи починається він із "add ":

#include <string_view>

bool starts_with_add(std::string_view s) {
    return s.starts_with("add ");
}

Використання:

#include <iostream>
#include <string>

int main() {
    std::string line = "add buy milk";
    std::cout << std::boolalpha << starts_with_add(line) << '\n'; // true
}

Тут усе безпечно: line живе довше за виклик. Але небезпека починається, коли ви намагаєтеся повернути std::string_view, що посилається на локальний рядок:

#include <string>
#include <string_view>

std::string_view bad() {
    std::string tmp = "add buy milk";
    return tmp; // tmp знищується -> view стає висячим
}

Ми вже бачили цю пастку в темі про час життя обʼєктів, і тут вона важлива саме як частина «гігієни»: іноді зайва копія приховує проблему, а чесна сигнатура, навпаки, допомагає їй проявитися. Тому правило просте: std::string_view і std::span гарні як параметри, але небезпечні, якщо зберігати їх як дані без чітких гарантій щодо власника.

reserve плюс менше копій: стабілізація на реальному фрагменті коду

Зберімо невеликий фрагмент застосунку так, щоб він одразу показував обидві ідеї: ми читаємо команди, додаємо завдання, іноді друкуємо список. Під час розслідування багів нам важливо, щоб вектор завдань не перевиділяв памʼять кожні дві операції, а рядки не копіювалися по три рази «просто тому, що так вийшло».

Найпростіша заготовка, без складної архітектури, лише принцип:

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

int main() {
    std::vector<Task> tasks;
    tasks.reserve(100); // стабілізуємо адреси елементів на час налагодження

    std::string line;
    while (std::getline(std::cin, line)) {
        if (line == "list") {
            print_tasks(tasks);
        } else if (line.starts_with("add ")) {
            add_task(tasks, std::string_view(line).substr(4));
        }
    }
}

Тут важливо, що add_task приймає std::string_view, а ми передаємо йому view на підрядок line. Це безпечно, бо всередині add_task ми створюємо std::string{text} і кладемо його в Task. Тобто ми не зберігаємо view, а використовуємо його лише під час виклику.

А tasks.reserve(100) — це не обіцянка «у нас буде рівно 100 завдань», а практичний крок: «поки ми розслідуємо, давайте зробимо так, щоб std::vector не перевиділяв памʼять надто часто». Коли баг знайдено й виправлено, це значення можна переглянути або взагалі прибрати, якщо воно більше не потрібне за змістом.

Окремо варто наголосити: reserve не дає абсолютної гарантії «ніколи не перемістимося». Якщо завдань стане 101, нове перевиділення все одно станеться. Але в triage нам часто потрібне саме це: зробити поведінку керованою. Якщо хочемо — ставимо reserve(1000) і перевіряємо одну гіпотезу. Якщо хочемо — прибираємо його й дивимося, чи змінюється симптом.

4. Типові помилки

Помилка № 1: перетворювати reserve на магічний оберіг «від усіх багів».
Іноді після пари вдалих випадків зʼявляється думка: «О, я додав reserve, і все перестало падати — значить, усе виправилося». Ні. reserve може прибрати прояв use‑after‑realloc, але першопричина — збереження вказівника або посилання на елемент std::vector — залишиться. Просто баг стане підступнішим: він чекатиме, доки даних стане більше.

Помилка № 2: плутати reserve і resize, а потім розслідувати вже нову проблему.
Якщо ви хотіли зменшити кількість перевиділень, але випадково зробили resize, ви змінили дані контейнера. У результаті можуть зʼявитися «порожні» елементи, логіка почне працювати інакше, і ви будете лагодити симптоми, яких спочатку не було. У triage це особливо боляче, бо ви втрачаєте звʼязок із початковим багом.

Помилка № 3: «оптимізувати» сигнатури, зберігаючи std::string_view усередині моделі.
Частий сценарій: ви бачите, що std::string_view прибирає копії, і вирішуєте зберігати його в Task, щоб «взагалі не копіювати рядки». А потім виявляється, що початковий рядок був тимчасовим або повторно використовувався, і всі завдання раптом «дивляться» на один і той самий буфер. Модель даних майже завжди має володіти тим, що їй потрібне для життя.

Помилка № 4: передавати великі обʼєкти за значенням «для простоти», а потім дивуватися нестабільності.
Передавання std::vector<Task> або std::string за значенням не завжди зло, але якщо функція не має володіти даними, це додає приховані копії. Такі копії створюють зайві алокації й переміщення, а в діагностиці зайві події можуть розмити картину: звіт ASan стає довшим, а поведінка — менш повторюваною.

Помилка № 5: використовувати view‑типи там, де потрібен обʼєкт, що володіє даними, і намагатися «полагодити» це reserve.
Іноді хочеться сказати: «Я поверну std::string_view, адже це швидше». Але якщо значення, що повертається, має жити незалежно від внутрішнього тимчасового рядка, то view тут концептуально хибний. reserve не врятує: він про контейнери, а не про час життя локальних змінних. У таких місцях правильне рішення часто банальне: повернути std::string за значенням і спати спокійно.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ