1. Вступ
Якщо раніше ви писали цикли вручну, алгоритми STL можуть здаватися магією: «А де тут узагалі відбувається робота?». Насправді ідея дуже проста: стандартна бібліотека дає вам готовий двигун — алгоритм, а ви передаєте йому правило поведінки. І саме це правило найзручніше задавати лямбдою: маленькою, локальною й читабельною, просто поруч із місцем використання.
Уявіть, що std::sort — це «робот-сортувальник», який уміє швидко переставляти елементи, але не читає ваших думок. Йому треба пояснити, що означає «раніше»: менше число, раніше за абеткою, вищий пріоритет, спочатку невиконані завдання… Саме це правило і є компаратором — викличним обʼєктом виду bool comp(a, b).
Аналогічно, std::remove_if — це «робот-прибиральник», який проходить контейнером і відокремлює потрібне від непотрібного за вашим критерієм. А std::transform — «робот-перетворювач», який бере один елемент і перетворює його на інший за вашим рецептом.
Щоб не заплутатися, тримайте в голові просту схему:
flowchart LR
A[Контейнер] --> B[Алгоритм STL]
C[Лямбда = правило] --> B
B --> D[Результат: змінений контейнер або новий набір даних]
Алгоритм — це «як робити», а лямбда — «за яким правилом».
2. std::sort: компаратор-лямбда і правило «строго раніше»
Коли ви вперше бачите std::sort(v.begin(), v.end(), ...), так і хочеться запитати: «А що тут за третій параметр?». Третій параметр — компаратор, тобто викличний обʼєкт, який каже, чи має a стояти раніше за b.
Важливо: компаратор має описувати саме «строго раніше», тому зазвичай використовують <, а не <=. Це не дрібʼязкова формальність, а базова логіка сортування: якщо ви почнете стверджувати, що a <= b означає «a раніше b», алгоритм отримає суперечливі правила.
Найпростіший приклад: сортування чисел за спаданням
Почнімо з максимально приземленого прикладу. Вектор чисел за замовчуванням сортується за зростанням, а для сортування за спаданням уже потрібен компаратор:
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{5, 1, 7, 2};
std::sort(v.begin(), v.end(), [](int a, int b) { return a > b; });
for (int x : v) std::cout << x << ' ';
std::cout << '\n'; // 7 5 2 1
}
Тут лямбда [](int a, int b) { return a > b; } означає: «вважай, що a має стояти раніше за b, якщо a більше».
Сортування наших моделей: мінізастосунок «Список завдань»
Щоб приклади не виглядали розрізненими, продовжимо один і той самий мінізастосунок: консольний список завдань. У нас є модель Task: заголовок, пріоритет і прапорець виконання. Подібні структури ми вже використовували раніше, тож сьогодні просто візьмемо їх як дані для алгоритмів.
#include <string>
struct Task {
std::string title;
int priority{}; // що більше, то важливіше
bool done{};
};
Тепер відсортуймо завдання за спаданням пріоритету:
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<Task> tasks{
{"Buy milk", 2, false},
{"Finish report", 5, false},
{"Clean room", 1, true}
};
std::sort(tasks.begin(), tasks.end(),
[](const Task& a, const Task& b) {
return a.priority > b.priority;
});
for (const auto& t : tasks) {
std::cout << t.priority << " - " << t.title << '\n';
}
}
Зверніть увагу на параметри компаратора: const Task&. Це майже завжди хороша практика: ми не копіюємо великі обʼєкти й не змінюємо їх під час порівняння.
Сортування за кількома полями: «спочатку пріоритет, потім назва»
На практиці одного поля часто замало. Якщо два завдання мають однаковий пріоритет, хочеться, щоб порядок був послідовним і зрозумілим. Наприклад, за однакового пріоритету можна сортувати за назвою.
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<Task> tasks{
{"Email client", 3, false},
{"Buy milk", 3, false},
{"Finish report", 5, false}
};
std::sort(tasks.begin(), tasks.end(),
[](const Task& a, const Task& b) {
if (a.priority != b.priority) return a.priority > b.priority;
return a.title < b.title;
});
for (const auto& t : tasks) {
std::cout << t.priority << " - " << t.title << '\n';
}
}
Такий компаратор читається як звичайне правило: «якщо пріоритети різні — порівняй за пріоритетом, інакше — за назвою».
Дуже важлива думка про компаратор
std::sort очікує, що ваш компаратор задає коректний порядок. Якщо компаратор починає «брехати» — наприклад, іноді каже, що a раніше за b, а іноді навпаки для тих самих значень, — сортування може поводитися непередбачувано. Це як намагатися побудувати чергу людей, якщо ви щоразу по-новому вирішуєте, хто з них вищий на зріст.
Щоб закріпити, ось невелика таблиця:
| Що пише компаратор | Що це означає | Добре? |
|---|---|---|
|
«a раніше b, якщо менше» | Так |
|
«a раніше b, якщо менше або дорівнює» | Погано (нестрого) |
|
«a раніше b, якщо більше» | Так (сортування за спаданням) |
|
«a раніше b, якщо не дорівнює» | Дуже погано (хаос) |
3. std::remove_if: «логічне видалення» за предикатом
Назва remove_if звучить як «видали, будь ласка», але насправді все трохи хитріше. Сам по собі цей алгоритм не «вирізає» елементи з контейнера, бо не змінює його розмір автоматично.
Натомість std::remove_if робить корисний трюк: переставляє елементи так, щоб «хороші» опинилися на початку, а «погані» — наприкінці, і повертає ітератор на межу між ними — так званий new end.
Якщо сказати простіше, remove_if робить «прибирання з переставлянням меблів», а сміттєвий пакет — тобто фактичне зменшення розміру — ви виносите окремою операцією контейнера.
Предикат для remove_if
remove_if приймає предикат — викличний обʼєкт, який повертає true, якщо елемент треба вважати «зайвим».
Наприклад, видалімо з vector<int> усі відʼємні числа:
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{3, -1, 5, -7, 2};
auto new_end = std::remove_if(v.begin(), v.end(),
[](int x) { return x < 0; });
v.erase(new_end, v.end()); // тепер реально видалили хвіст
for (int x : v) std::cout << x << ' ';
std::cout << '\n'; // 3 5 2
}
Тут важливі два рядки, і вони працюють у парі: спочатку remove_if, потім erase(new_end, end).
Видаляємо виконані завдання з нашого списку
Повернімося до Task. Видалімо всі завдання, які вже виконано (done == true):
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<Task> tasks{
{"Buy milk", 2, true},
{"Finish report", 5, false},
{"Clean room", 1, true}
};
auto new_end = std::remove_if(tasks.begin(), tasks.end(),
[](const Task& t) { return t.done; });
tasks.erase(new_end, tasks.end());
for (const auto& t : tasks) {
std::cout << t.title << '\n';
}
// Finish report
}
Лямбда-предикат тут дуже прямолінійний: «якщо завдання виконано — вважаємо його кандидатом на видалення».
Захоплення в remove_if: видаляємо завдання з пріоритетом нижче порога
Іноді предикат залежить від налаштування. Наприклад, ми хочемо залишити тільки завдання з пріоритетом не нижчим за min_priority. Це чудовий випадок для захоплення за значенням: поріг — це «налаштування», тож його безпечно копіювати.
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<Task> tasks{
{"Buy milk", 2, false},
{"Finish report", 5, false},
{"Clean room", 1, false}
};
int min_priority = 2;
auto new_end = std::remove_if(tasks.begin(), tasks.end(),
[min_priority](const Task& t) {
return t.priority < min_priority;
});
tasks.erase(new_end, tasks.end());
for (const auto& t : tasks) {
std::cout << t.priority << " - " << t.title << '\n';
}
// 2 - Buy milk
// 5 - Finish report
}
Чому тут доречно саме [min_priority], а не [&]? Тому що так ви явно фіксуєте: поріг узято «як число» в момент створення предиката. І випадкова зміна min_priority пізніше вже не змінить зміст заданого правила.
4. std::transform: «перекласти елементи» в новий вигляд
std::transform часто недооцінюють, бо «ну можна ж і циклом». Можна. Але transform робить намір дуже прозорим: «я беру кожен елемент і перетворюю його за правилом».
Це особливо зручно, коли ви будуєте похідний набір даних: довжини рядків, лише назви, лише пріоритети, форматовані підписи. У базовій формі transform приймає вхідний діапазон, вихідний ітератор і викличний обʼєкт, який повертає новий елемент.
Перетворюємо числа: підносимо до квадрата
#include <algorithm>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3, 4};
std::vector<int> squares(v.size());
std::transform(v.begin(), v.end(), squares.begin(),
[](int x) { return x * x; });
for (int x : squares) std::cout << x << ' ';
std::cout << '\n'; // 1 4 9 16
}
Зверніть увагу: вихідний вектор squares ми заздалегідь створили потрібного розміру. transform не робить push_back сам — йому потрібно, щоб було «куди писати».
Із завдань робимо список назв
Тепер зробімо з vector<Task> список назв завдань vector<string>. Це дуже типовий сценарій: наприклад, хочемо вивести лише заголовки або передати їх іншій функції.
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<Task> tasks{
{"Buy milk", 2, false},
{"Finish report", 5, false}
};
std::vector<std::string> titles(tasks.size());
std::transform(tasks.begin(), tasks.end(), titles.begin(),
[](const Task& t) { return t.title; });
for (const auto& s : titles) std::cout << s << '\n';
// Buy milk
// Finish report
}
Лямбда повертає t.title. Так, це копія рядка — і це нормально, бо ми створюємо новий список рядків. Якби ми хотіли обійтися без копій, це вже інша історія й інша тема, а сьогодні тримаємо все простим і чесним.
«Зробити красиво»: форматуємо рядок звіту
Ще один приємний приклад: перетворімо кожне завдання на рядок виду "[ ] Title (p=5)".
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<Task> tasks{
{"Buy milk", 2, true},
{"Finish report", 5, false}
};
std::vector<std::string> lines(tasks.size());
std::transform(tasks.begin(), tasks.end(), lines.begin(),
[](const Task& t) {
std::string mark = t.done ? "[x] " : "[ ] ";
return mark + t.title + " (p=" + std::to_string(t.priority) + ")";
});
for (const auto& line : lines) std::cout << line << '\n';
// [x] Buy milk (p=2)
// [ ] Finish report (p=5)
}
Тут лямбда трохи довша, але це все ще одна думка: зібрати рядок. Якщо ви ловите себе на тому, що всередині лямбди вже хочеться if/else if/else на 30 рядків, — це знак, що час винести логіку у звичайну функцію.
Мінісценарій в одному main: сортування → видалення → звіт
Коли дивишся на sort, remove_if і transform окремо, може здатися, що це три різні світи. Але на практиці вони чудово складаються в невеликий «конвеєр» оброблення даних: спочатку наводимо зручний порядок, потім прибираємо зайве, а тоді готуємо дані для виводу.
Ось компактний приклад «реалістичного» main, який виконує всі три кроки. Зверніть увагу: частини невеликі, але разом вони вже нагадують справжню програму.
#include <algorithm>
#include <iostream>
#include <string>
#include <vector>
struct Task {
std::string title;
int priority{};
bool done{};
};
int main() {
std::vector<Task> tasks{
{"Buy milk", 2, true},
{"Finish report", 5, false},
{"Clean room", 1, true},
{"Call mom", 3, false}
};
std::sort(tasks.begin(), tasks.end(),
[](const Task& a, const Task& b) { return a.priority > b.priority; });
auto new_end = std::remove_if(tasks.begin(), tasks.end(),
[](const Task& t) { return t.done; });
tasks.erase(new_end, tasks.end());
std::vector<std::string> lines(tasks.size());
std::transform(tasks.begin(), tasks.end(), lines.begin(),
[](const Task& t) {
return t.title + " (p=" + std::to_string(t.priority) + ")";
});
for (const auto& s : lines) std::cout << s << '\n';
// Finish report (p=5)
// Call mom (p=3)
}
Зауважте, як ця програма читається зверху вниз: спочатку сортуємо за важливістю, потім відкидаємо виконане, а тоді готуємо рядки для виводу. Якщо згодом ви захочете винести це в окремі функції — чудово, але навіть у такому вигляді логіка вже доволі прозора.
5. Типові помилки
Помилка № 1: компаратор для sort пишуть через <= або >=.
Це виглядає логічно («ну я ж хочу порядок»), але для сортування потрібен строгий порядок. Компаратор має відповідати на запитання «a раніше b?» і зазвичай будується на < або >. Якщо використовувати <=, ви стверджуєте, що «a раніше b» навіть тоді, коли вони рівні, — і алгоритму стає важко жити в логічно несуперечливому світі.
Помилка № 2: предикат у remove_if сприймають як «залишити», а не як «видалити».
У remove_if предикат повертає true для елементів, які вважаються «зайвими». Якщо переплутати зміст, можна видалити все потрібне й залишити сміття, а потім довго підозрювати стандартну бібліотеку у змові. Гарна звичка — подумки читати такий предикат як is_bad(element).
Помилка № 3: забувають, що після remove_if у vector розмір не змінюється автоматично.
Новачки часто роблять std::remove_if(...) і відразу виводять вектор, дивуючись «привидам» наприкінці. Правильна модель така: remove_if повертає new_end, а потім ви фізично зменшуєте контейнер через erase(new_end, end).
Помилка № 4: для transform не готують місце для результату.
transform не робить контейнер «більшим». Якщо ви передали out.begin() у порожній out, то отримаєте запис за межі діапазону (а це вже територія дуже неприємних помилок). Мінімальна дисципліна: std::vector<Result> out(input.size());, і лише потім transform.
Помилка № 5: у лямбдах роблять побічні ефекти там, де очікується чисте правило.
Особливо часто це трапляється в компараторах і предикатах: хтось друкує в cout, хтось змінює глобальні змінні, хтось намагається видаляти елементи контейнера просто всередині предиката. Такі лямбди перетворюють алгоритм на непередбачувану пригоду. В ідеалі компаратор і предикат мають бути «чистими»: лише перевірка або порівняння, без втручання в зовнішній стан.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ