1. Контекст і критерії вибору
Коли ви щойно навчилися писати for і while, виникає природне запитання: «Навіщо мені взагалі потрібні std::find_if та інші алгоритми, якщо я можу просто написати цикл і все зробити сам?». І це відчуття цілком нормальне. Цикл — універсальний швейцарський ніж: ним можна і бутерброд нарізати, і системний блок відкрити. Щоправда, друге я все ж не рекомендую: гарантія образиться.
Але за універсальність доводиться платити. Ручний цикл майже завжди приносить із собою «шум»: лічильники, прапорці, break, межі, ризик off-by-one, забуті ініціалізації та інші дрібні пастки. Алгоритм, навпаки, намагається виразити намір: не «як саме я проходжу елементами», а «що я хочу зʼясувати або зробити». У цій лекції ми навчимося обирати підхід не за релігією — «алгоритми святі» / «цикли чесніші» — а за читабельністю й прозорістю поведінки.
Читабельність і прозорість: дві різні якості коду
Читабельність — це коли людина, не вдивляючись у кожну кому, розуміє сенс: «Ага, тут шукають задачу за id», «Ага, тут перевіряють, що всі оцінки в діапазоні». Прозорість — це коли ви покроково контролюєте процес: де зупинилися, що виводите, як обробляєте особливі випадки, що робите за один прохід, а що — за два.
Важливо розуміти: ці якості іноді збігаються, а іноді конфліктують. Алгоритми часто виграють у читабельності, бо в одному рядку показують намір. Ручний цикл часто виграє в прозорості, коли логіка багатокрокова, має побічні дії — друк, накопичення кількох різних результатів — або нестандартне керування потоком виконання. Наше завдання — навчитися відчувати цей баланс, а не «переписувати все на алгоритми», ніби ви складаєте іспит із краси коду.
Наша невелика історія: навчальний застосунок TaskTracker
Щоб порівняння не було надто абстрактним, триматимемо в голові просту модель: список задач. Ми не будемо ускладнювати введення й парсинг та не намагатимемося робити «справжній продукт» — нам потрібен лише невеликий контекст, у якому природно виникають питання «де?», «скільки?», «чи є?» і «чи всі?».
Ось базова модель і трохи тестових даних. Це можна вважати фрагментом нашого main.cpp:
#include <string>
#include <vector>
struct Task {
int id{};
std::string title;
bool done{};
int priority{}; // 1..10
};
std::vector<Task> make_demo_tasks() {
return {{1, "Read STL", false, 7}, {2, "Submit homework", true, 9}, {3, "Sleep", false, 10}};
}
Далі ми писатимемо невеликі фрагменти коду навколо цього списку й порівнюватимемо, де алгоритм робить код яснішим, а де звичайний цикл справді виявляється зрозумілішим.
2. Коли алгоритм сильніший: менше шуму, більше сенсу
Алгоритми стандартної бібліотеки особливо доречні тоді, коли ваше запитання до даних легко сформулювати однією короткою фразою. Майже як у житті: якщо запитання звучить «Скільки людей у кімнаті?», це одна дія, а якщо «Скільки людей у кімнаті, хто з них у капелюсі й чому один тримає кактус?», це вже невелике розслідування.
Важливий психологічний момент: алгоритм не робить код «розумнішим», він лише робить намір очевиднішим. Компілятор однаково перетворить усе це на цикли, але читачеві буде простіше.
«Скільки задач виконано?» — count_if проти ручного лічильника
Тут типова ручна реалізація виглядає цілком нормально, але в ній уже зʼявляються деталі, не повʼязані із сенсом: змінна-лічильник, if, інкремент. А сенс у нас один: «порахувати виконані задачі».
Ручний цикл:
#include <iostream>
#include <vector>
int main() {
auto tasks = make_demo_tasks();
int done_count = 0;
for (const auto& t : tasks) {
if (t.done) ++done_count;
}
std::cout << done_count << '\n'; // 1
}
Алгоритм:
#include <algorithm>
#include <iostream>
#include <vector>
bool is_done(const Task& t) { return t.done; }
int main() {
auto tasks = make_demo_tasks();
int done_count = std::count_if(tasks.begin(), tasks.end(), is_done);
std::cout << done_count << '\n'; // 1
}
З погляду результату — те саме. Але з погляду читання другий варіант часто виграє: оком одразу видно count_if, видно is_done, і мозку не треба тримати в голові механіку лічильника.
«Чи є хоч одна задача з високим пріоритетом?» — any_of проти break
Ще одна класична задача — «знайти хоча б один відповідний елемент». Ручний цикл майже неминуче перетворюється на bool found = false; + break. Це не помилка, а просто шаблон, який доводиться писати знову й знову.
Ручний цикл:
#include <iostream>
#include <vector>
bool is_high_priority(const Task& t) { return t.priority >= 9; }
int main() {
auto tasks = make_demo_tasks();
bool has_high = false;
for (const auto& t : tasks) {
if (is_high_priority(t)) { has_high = true; break; }
}
std::cout << has_high << '\n'; // 1
}
Алгоритм:
#include <algorithm>
#include <iostream>
#include <vector>
bool is_high_priority(const Task& t) { return t.priority >= 9; }
int main() {
auto tasks = make_demo_tasks();
bool has_high = std::any_of(tasks.begin(), tasks.end(), is_high_priority);
std::cout << has_high << '\n'; // 1
}
Сенс any_of зазвичай зчитується швидше, ніж конструкція «прапорець + break». До того ж any_of за контрактом уміє зупинятися раніше, ніж дійде до кінця діапазону. Тож ви не забудете про break. А таке трапляється частіше, ніж хотілося б визнавати.
«Чи всі задачі мають коректний пріоритет?» — all_of як «щит від забутих перевірок»
Перевірка «чи всі елементи задовольняють умову» — ще одне типове питання. Вручну ви пишете прапорець ok = true, потім у разі порушення ставите false і виходите. У реальних проєктах люди інколи забувають вийти з циклу, інколи — правильно ініціалізувати змінну, а інколи просто плутають true/false — особливо після третього горнятка кави.
Алгоритм:
#include <algorithm>
#include <iostream>
#include <vector>
bool priority_ok(const Task& t) { return 1 <= t.priority && t.priority <= 10; }
int main() {
auto tasks = make_demo_tasks();
bool ok = std::all_of(tasks.begin(), tasks.end(), priority_ok);
std::cout << ok << '\n'; // 1
}
Тут читачеві не треба «прокручувати виконання» в голові. Він бачить all_of і відразу розуміє: «перевіряємо коректність кожного елемента».
«Знайти задачу за id» — алгоритм добрий, але потребує дисципліни з результатом
Пошук «де лежить елемент» зручно робити через std::find_if. Але важливо памʼятати: результатом буде ітератор, і його не можна розіменовувати без перевірки. Саме тут алгоритм робить код коротшим, але вимагає уважності.
#include <algorithm>
#include <iostream>
#include <vector>
bool has_id_2(const Task& t) { return t.id == 2; }
int main() {
auto tasks = make_demo_tasks();
auto it = std::find_if(tasks.begin(), tasks.end(), has_id_2);
if (it != tasks.end()) std::cout << it->title << '\n'; // Submit homework
}
Зверніть увагу на маленьку «психологію API»: find_if повертає не bool, а позицію. Тому після нього майже завжди йде «ритуал безпеки»: if (it != end()).
3. Коли цикл кращий і не варто цього соромитися
Якщо після попередніх прикладів у вас зʼявилося бажання замінити всі цикли на алгоритми — пригальмуймо. Хороший код — це не конкурс «хто швидше напише std::», а код, який легко читати й важко зламати.
Ручний цикл виграє там, де логіка не вкладається в одне стандартне формулювання або де побічні дії — центральна частина задачі. У філософії стандартної бібліотеки алгоритми віддають перевагу «чистоті»: предикат щось перевіряє, компаратор щось порівнює, а побічні дії краще не ховати всередині.
Один прохід, два результати й трохи друку
Уявімо, що ми хочемо вивести назви виконаних задач і водночас порахувати їхню кількість. Так, можна зробити count_if, а потім ще один цикл для друку. Але якщо друк — частина сценарію, цикл може бути чеснішим: один прохід, усе видно, нічого не «сховано».
#include <iostream>
#include <vector>
int main() {
auto tasks = make_demo_tasks();
int done_count = 0;
for (const auto& t : tasks) {
if (!t.done) continue;
std::cout << t.title << '\n'; // Submit homework
++done_count;
}
std::cout << "виконано: " << done_count << '\n'; // виконано: 1
}
Це саме той випадок, коли цикл справді прозорий: видно, коли ми друкуємо, видно, коли рахуємо, видно, що все робиться в одному місці й за один прохід.
Коли потрібен індекс і ви не хочете перетворювати код на ребус
Навіть якщо у вас є ітератори, іноді за змістом вам потрібен номер елемента: «покажіть задачі з їхніми порядковими номерами». Можна «витягувати індекс» хитрими способами, але на рівні початківця це часто перетворює код на математику, а не на програму.
#include <iostream>
#include <vector>
int main() {
auto tasks = make_demo_tasks();
for (std::size_t i = 0; i < tasks.size(); ++i) {
std::cout << (i + 1) << ") " << tasks[i].title << '\n';
// 1) Read STL
}
}
Такий цикл може бути читабельнішим за будь-яку «акробатику» з ітераторами, бо індекс — частина змісту виводу. Так, тут потрібно бути уважним до меж, зате логіка пряма.
4. Головна пастка алгоритмів: результат треба зрозуміти правильно
Коли ви пишете цикл, то зазвичай самі визначаєте, що є «результатом»: змінна-лічильник, прапорець, знайдений індекс. Коли ж ви використовуєте алгоритм, результат уже заданий його контрактом: ітератор, bool або число. І саме тут починаються типові помилки початківців: алгоритм обрали правильно, а результат обробили неправильно.
Класична дисципліна така: якщо алгоритм повертає ітератор, ми спочатку перевіряємо, що він не дорівнює end(). Якщо алгоритм повертає bool, ми називаємо змінну так, щоб було зрозуміло, що означає true. Якщо алгоритм повертає число, ми памʼятаємо, що це кількість, а не позиція.
Антиприклад: розіменування end() після find_if
Ось код, який інколи «випадково працює», а потім раптово падає або поводиться дивно. І найнеприємніше те, що на маленьких тестах він може здаватися «нормальним».
#include <algorithm>
#include <iostream>
#include <vector>
bool has_id_999(const Task& t) { return t.id == 999; }
int main() {
auto tasks = make_demo_tasks();
auto it = std::find_if(tasks.begin(), tasks.end(), has_id_999);
std::cout << it->title << '\n'; // ПОМИЛКА: якщо не знайшли, it == end()
}
Правильний варіант — нудний, зате безпечний:
#include <algorithm>
#include <iostream>
#include <vector>
bool has_id_999(const Task& t) { return t.id == 999; }
int main() {
auto tasks = make_demo_tasks();
auto it = std::find_if(tasks.begin(), tasks.end(), has_id_999);
if (it == tasks.end()) std::cout << "не знайдено\n"; // не знайдено
else std::cout << it->title << '\n';
}
5. Один прохід чи кілька: критерій без фанатизму
Іноді вибір «алгоритм чи цикл» зводиться до кількості проходів. Наприклад, вам треба і перевірити наявність чогось, і порахувати кількість. Наївне рішення через алгоритми — викликати any_of, а потім count_if. Це два проходи. Циклом усе можна зробити за один.
З іншого боку, у навчальних задачах і невеликих програмах ця різниця часто не критична, а читабельність важливіша. Якщо дані невеликі, а код читається вдвічі легше, це хороший обмін. Якщо ж дані великі — тисячі або мільйони елементів — і ви робите багато проходів у гарячому місці програми, цикл може бути цілком виправданим.
Приклад «два результати за один прохід» — цикл як чесна прозорість:
#include <iostream>
#include <vector>
int main() {
auto tasks = make_demo_tasks();
bool has_done = false;
int done_count = 0;
for (const auto& t : tasks) {
if (!t.done) continue;
has_done = true;
++done_count;
}
std::cout << has_done << " " << done_count << '\n'; // 1 1
}
Тут цикл виграє не тому, що «алгоритми погані», а тому, що нам справді потрібні дві речі одночасно, і цикл виражає це прямо.
6. Мінісхема вибору: алгоритм чи цикл
Коли ви дивитеся на задачу, корисно буквально на секунду зупинитися й запитати себе: «Що я зараз роблю — ставлю стандартне запитання до даних чи описую процес?». Якщо це запитання, найчастіше знайдеться алгоритм. Якщо це процес, цикл може бути яснішим.
Невелика блок-схема для мозку. Так, мозку теж подобаються блок-схеми — він просто соромиться:
flowchart TD
A[Треба обійти контейнер] --> B{Це стандартне запитання?}
B -->|Де елемент?| F[find / find_if]
B -->|Скільки підходить?| C[count / count_if]
B -->|Чи є хоча б один?| D[any_of]
B -->|Чи всі підходять?| E[all_of]
B -->|Звести до одного значення?| G[accumulate]
B -->|Треба впорядкувати?| H[sort]
B -->|Ні, це багатокроковий процес| I[Ручний цикл]
І ще одна корисна «табличка здорового глузду» — не магія, а просто підказка:
| Якщо в задачі головне… | Частіше краще… | Чому |
|---|---|---|
| Сенс «знайти/порахувати/перевірити» | Алгоритм | У коді менше шуму, намір видно |
| Побічні дії — друк, логування | Цикл | Прозоро, де і що відбувається |
| Потрібен індекс як частина сенсу | Цикл for (i...) | Менше акробатики, простіше читати |
| Потрібні 2–3 результати за один прохід | Цикл | Один прохід і все поруч |
| Складна умова, але її можна назвати | Алгоритм + функція-предикат | Імʼя функції поліпшує читабельність |
7. Типові помилки під час вибору між алгоритмом і циклом
Помилка № 1: обирати алгоритм «на автоматі», а не за сенсом питання.
Іноді студент бачить, що «сьогодні день алгоритмів», і намагається втиснути будь-який прохід по контейнеру в std::count_if або std::find_if, навіть якщо задача насправді про друк, форматування й накопичення кількох значень. У підсумку код стає коротшим, але менш зрозумілим: сенс розповзається між кількома викликами, і читачеві доводиться збирати логіку по шматках.
Помилка № 2: робити предикат або компаратор із побічними діями.
Предикат має відповідати на запитання true/false, а компаратор — на запитання «хто раніше». Якщо всередині предиката ви друкуєте в консоль або змінюєте елементи, код стає менш передбачуваним для читача: алгоритм може викликати предикат багато разів, у різному порядку, і ваш вивід у консоль перетворюється на шум. Початківцям особливо легко випадково «засунути логіку програми в перевірку».
Помилка № 3: розіменовувати ітератор результату без перевірки.
std::find і std::find_if повертають end(), якщо не знайшли елемент. Розіменування end() — це вже вихід за межі коректної роботи програми. Тому схема «перевірити it != end() → використати *it або it->field» має стати звичкою рівня «пристебнути пасок безпеки в авто».
Помилка № 4: плутати «алгоритм дав відповідь» і «алгоритм дав позицію».
any_of повертає bool, а find_if повертає ітератор. Якщо ви подумки очікуєте true/false, але отримали позицію, ви почнете писати дивні порівняння або намагатися «перетворити ітератор на bool» незрозумілими способами. Вирішується це просто: перед використанням алгоритму вголос сформулюйте, що саме він повертає.
Помилка № 5: намагатися замінити будь-який цикл двома-трьома алгоритмами й втратити цілісність.
Іноді з добрих намірів — зробити «гарно» — код розбивають на any_of, потім count_if, потім ще один цикл на друк. Формально це працює, але читати такий код важче: логіка розосереджується, і стає неясно, що тут «основний результат», а що — допоміжне. Якщо сенс задачі — один цілісний сценарій, іноді чесний цикл передає його краще.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ