JavaRush /Курси /C++ SELF /find_if, count_if, any_of/all_of: перевірки без циклів

find_if, count_if, any_of/all_of: перевірки без циклів

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

1. Вступ

Коли ви тільки починаєте програмувати, цикл for здається універсальною суперсилою: хочете знайти елемент — пишете цикл, хочете порахувати — пишете цикл, хочете перевірити умову — знову цикл. Це нормально. Проблема починається тоді, коли ви помічаєте, що 70% коду — це не суть завдання, а технічна рутина: створити лічильник, додати прапорець, не забути break, коректно опрацювати порожній контейнер.

Алгоритми STL дають змогу говорити з кодом трохи більш «людською» мовою:

  • «Знайди перший елемент, який підходить»std::find_if
  • «Порахуй, скільки підходить»std::count_if
  • «Чи є хоча б один відповідний?»std::any_of
  • «Чи всі підходять?»std::all_of

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

Але спершу нам потрібен ще один термін.

2. Предикат: функція, яка відповідає «так/ні»

Зараз буде слово, яке звучить наче імʼя якогось давнього бога: предикат. Але на практиці все дуже просто: це функція, яка отримує один елемент і повертає bool.

Тобто предикат — це просто «перевірка». Наприклад: «число парне?», «завдання виконано?», «рядок не порожній?». У коді зазвичай це виглядає так: bool pred(const T& x).

Мініприклад: предикат для int

bool is_even(int x) {
    return x % 2 == 0;
}

Мініприклад: предикат для struct Task

Припустімо, у нас є завдання:

#include <string>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

Тоді предикат «завдання виконано?» буде таким:

bool is_done(const Task& t) {
    return t.done;
}

Зверніть увагу на один приємний момент: імʼя is_done вже саме пояснює, що відбувається. Це майже як коментар, тільки краще, бо коментарі інколи брешуть, а функція — рідко.

3. std::find_if: «де перший відповідний?»

Тепер переходимо до першої справжньої суперздібності. Дуже часто вам потрібно не «порахувати» і не «перевірити», а саме знайти. Наприклад: знайти завдання за id, знайти перше виконане завдання, знайти перший порожній рядок, знайти перше число, більше за 100.

std::find_if якраз розвʼязує завдання «знайди перший елемент у діапазоні, для якого предикат повертає true».

Потрібний заголовковий файл:

#include <algorithm>

Базова форма

Якщо у вас є контейнер v, то «весь контейнер» — це діапазон [v.begin(), v.end()).

auto it = std::find_if(v.begin(), v.end(), pred);

Результат такий: it — це ітератор. Він або вказує на знайдений елемент, або дорівнює v.end(), що й означає «не знайдено».

Приклад 1: знайти перше виконане завдання

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

struct Task { int id{}; std::string title; bool done{}; };

bool is_done(const Task& t) { return t.done; }

int main() {
    std::vector<Task> tasks{{1, "Read", false}, {2, "Code", true}};
    auto it = std::find_if(tasks.begin(), tasks.end(), is_done);

    if (it != tasks.end()) std::cout << it->title << '\n'; // Code
}

Тут важливі дві речі. По-перше, ми перевіряємо it != tasks.end(), перш ніж використовувати it->title. По-друге, it->title читається дуже природно: «у знайденого завдання взяти title».

Приклад 2: знайти завдання за id через предикат

Предикат може перевіряти будь-що, але памʼятайте: ми ще не дійшли до лямбд, тому зробимо предикат, який шукає фіксований id, наприклад 2. Так, це трохи менш гнучко, зате цілком чесно в межах поточних знань.

bool has_id_2(const Task& t) {
    return t.id == 2;
}

І використання:

auto it = std::find_if(tasks.begin(), tasks.end(), has_id_2);
if (it != tasks.end()) std::cout << it->title << '\n';

Пізніше, але не сьогодні, ми навчимося зручно й красиво задавати умови «з параметром». А поки нам важливо зрозуміти саму механіку: find_if шукає за правилом, яке ви йому дали.

4. std::count_if: «скільки елементів підходить?»

Після «знайти» зазвичай хочеться «порахувати». І тут новачки майже завжди пишуть один і той самий цикл: int cnt = 0, а потім for (...) if (...) ++cnt. Це нормальна техніка, але вона повторюється буквально в кожному проєкті, і через це код починає нагадувати «день бабака».

std::count_if робить те саме, але коротше: він повертає кількість елементів, для яких предикат істинний.

Суттєва відмінність від find_if: find_if може зупинитися на першому відповідному елементі, а count_if мусить пройти весь діапазон, бо інакше не дізнається точної кількості.

Приклад 1: порахувати виконані завдання

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

struct Task { int id{}; std::string title; bool done{}; };
bool is_done(const Task& t) { return t.done; }

int main() {
    std::vector<Task> tasks{{1, "Read", false}, {2, "Code", true}, {3, "Sleep", true}};
    int done_count = std::count_if(tasks.begin(), tasks.end(), is_done);

    std::cout << done_count << '\n'; // 2
}

Намір видно одразу: count_if(..., is_done) — «порахуй, скільки виконаних».

Приклад 2: порахувати непорожні заголовки

bool has_non_empty_title(const Task& t) {
    return !t.title.empty();
}

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

int ok_titles = std::count_if(tasks.begin(), tasks.end(), has_non_empty_title);
std::cout << ok_titles << '\n';

Це вже схоже на реальну перевірку якості даних: «скільки завдань у нас узагалі мають нормальний заголовок».

5. std::any_of і std::all_of: перевірки «чи є?» і «чи всі?»

Дуже поширений запит до даних звучить так: «Чи є хоча б один елемент із такою властивістю?» або, навпаки, «Чи всі елементи нормальні?». Це можна розвʼязати циклом із прапорцем, можна — через find_if, але в STL для цього є спеціалізовані алгоритми. Вони часто читаються краще, бо виражають думку прямо.

std::any_of(first, last, pred) повертає true, якщо є хоча б один елемент, для якого pred істинний.

std::all_of(first, last, pred) повертає true, якщо для всіх елементів pred істинний.

І приємний бонус: обидва алгоритми можуть завершитися раніше, ніж дійдуть до кінця діапазону. any_of зупиниться на першому true, all_of — на першому false. Це не лише швидше, а й цілком логічно: ми не зобовʼязані переглядати все, якщо відповідь уже зрозуміла.

Невелика таблиця сенсу

Алгоритм Запитання до даних Коли може зупинитися раніше
find_if
«Де перший відповідний?» Так (знайшов — зупинився)
count_if
«Скільки відповідних?» Ні (потрібно пройти все)
any_of
«Чи є хоч один відповідний?» Так (знайшов — зупинився)
all_of
«Чи всі підходять?» Так (знайшов порушення — стоп)

Приклад 1: чи є хоча б одне виконане завдання?

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

struct Task { int id{}; std::string title; bool done{}; };
bool is_done(const Task& t) { return t.done; }

int main() {
    std::vector<Task> tasks{{1, "Read", false}, {2, "Code", false}};
    bool has_done = std::any_of(tasks.begin(), tasks.end(), is_done);

    std::cout << has_done << '\n'; // 0
}

Приклад 2: чи всі id додатні?

bool id_is_positive(const Task& t) {
    return t.id > 0;
}

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

bool ok = std::all_of(tasks.begin(), tasks.end(), id_is_positive);
std::cout << ok << '\n';

Така перевірка — це вже маленький «контракт даних»: якщо id раптом стане 0 або відʼємним, ви дізнаєтеся про це завдяки явній перевірці, а не через дивні баги пізніше.

6. Практичний мініприклад TaskBox

Тепер зберемо все в маленький «каркас застосунку». Це ще не повноцінний менеджер завдань — ми поки не робимо складне введення чи меню, — але це вже програма, де дані зберігаються у вигляді моделей (struct), а операції над ними винесені в окремі функції. І найголовніше: пошук, підрахунок і перевірки ми виконуємо алгоритмами.

Крок 1: модель і тестові дані

#include <string>
#include <vector>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

std::vector<Task> make_demo_tasks() {
    return {{1, "Read docs", false}, {2, "Write code", true}, {3, "", false}};
}

Зауважте: одне завдання спеціально має порожній title. Це наш «вбудований тест», щоб перевірки були не декоративними.

Крок 2: предикати

bool is_done(const Task& t) {
    return t.done;
}

bool title_not_empty(const Task& t) {
    return !t.title.empty();
}

bool id_is_positive(const Task& t) {
    return t.id > 0;
}

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

Крок 3: знайти перше виконане завдання

#include <algorithm>
#include <vector>

std::vector<Task>::const_iterator find_first_done(const std::vector<Task>& tasks) {
    return std::find_if(tasks.begin(), tasks.end(), is_done);
}

Тут ми повертаємо ітератор. Чому const_iterator? Тому що ми не збираємося змінювати завдання через цей результат. Це акуратний контракт: «шукаю, але не редагую».

Крок 4: порахувати виконані завдання

#include <algorithm>
#include <vector>

int count_done(const std::vector<Task>& tasks) {
    return static_cast<int>(std::count_if(tasks.begin(), tasks.end(), is_done));
}

Чому тут static_cast<int>? count_if повертає тип на кшталт std::ptrdiff_t (у загальному випадку), а нам для простого виведення й у навчальному прикладі зручний int. Це нормальна практика, якщо ви впевнені, що у вас не мільярди завдань.

Крок 5: перевірки «чи є?» і «чи всі?»

#include <algorithm>
#include <vector>

bool has_any_done(const std::vector<Task>& tasks) {
    return std::any_of(tasks.begin(), tasks.end(), is_done);
}

bool all_titles_ok(const std::vector<Task>& tasks) {
    return std::all_of(tasks.begin(), tasks.end(), title_not_empty);
}

Якщо ви дивитеся на ці функції й думаєте: «Надто просто» — чудово. Простота в перевірках і статистиці зазвичай означає менше багів.

Крок 6: поєднуємо все в main() і друкуємо результати

#include <iostream>

int main() {
    auto tasks = make_demo_tasks();

    std::cout << "done_count=" << count_done(tasks) << '\n';        // done_count=1
    std::cout << "has_any_done=" << has_any_done(tasks) << '\n';    // has_any_done=1
    std::cout << "all_titles_ok=" << all_titles_ok(tasks) << '\n';  // all_titles_ok=0
}

Уже зараз це корисна програма: вона показує «здоровʼя» даних. І ми написали її без жодного ручного циклу в логіці перевірок. Цикли, звісно, нікуди не зникли — просто тепер вони акуратно «запаковані» в STL.

Як обирати алгоритм за запитанням

Дуже поширена помилка новачка — обрати інструмент лише тому, що він «схожий». Наприклад, замість any_of використати count_if(...) > 0. Це працюватиме, але гірше передаватиме сенс і часто буде менш ефективним, бо count_if мусить пройти весь діапазон, а any_of може завершитися раніше.

Корисна звичка: перш ніж писати код, сформулюйте запитання до даних звичайною людською мовою. Якщо запитання звучить як «знайди», майже завжди першим кандидатом буде find_if. Якщо звучить як «скільки», беріть count_if. Якщо звучить як «чи є», беріть any_of. Якщо звучить як «чи всі», беріть all_of.

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

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

Помилка №1: розіменувати результат find_if, не перевіривши його на end().
std::find_if у випадку «не знайдено» повертає ітератор, рівний end(). Якщо після цього написати *it або it->field, ви в найкращому разі отримаєте падіння програми, а в найгіршому — дивну поведінку. Правильний шаблон тут завжди той самий: спочатку if (it != v.end()), і лише всередині — доступ до елемента.

Помилка №2: використовувати count_if там, де потрібна відповідь «чи є?».
Коли пишуть std::count_if(...) > 0, це виглядає логічно, але гірше передає сенс. Наступний читач коду бачить «порахувати» й думає, що вам важлива точна цифра, хоча ви просто перевіряєте факт існування. До того ж count_if пройде весь контейнер, навіть якщо відповідний елемент був першим. Для «чи є?» краще використовувати std::any_of або find_if.

Помилка №3: предикат із побічними ефектами (друк, зміна даних).
Предикат задуманий як чиста перевірка: «так/ні». Якщо всередині предиката ви друкуєте в консоль або, що гірше, змінюєте елементи, то робите поведінку алгоритму менш передбачуваною й ускладнюєте налагодження. Особливо підступно це працює в перевірках, які можуть завершуватися раніше (any_of/all_of): частину елементів алгоритм може навіть не перевірити, і ви отримаєте уривчастий друк або неповні зміни.

Помилка №4: плутанина «ітератор — це індекс».
Ітератор — не число. З ним не можна поводитися як з індексом і просто «додати 1», як ви робили з int i. Так, у ітератора є ++it, але це інша сутність: не позиція, а радше «вказівник на елемент». Якщо вам потрібно отримати номер елемента, це робиться окремо — і не завжди це взагалі потрібно. Поки що тримайте в голові просте правило: алгоритми повертають ітератори, і порівнювати їх потрібно з end(), а не з якимись числами.

Помилка №5: порівнювати ітератори від різних контейнерів.
Інколи код виглядає так: «знайшов в одному векторі, порівняв з end() іншого». Компілятор інколи навіть пропускає таке (залежно від типів), але логічно це нісенітниця. Ітератор «живе» у своєму контейнері. Якщо ви зробили auto it = find_if(a.begin(), a.end(), ...), то перевіряти треба it != a.end(), а не b.end().

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