1. Введение
Если вы раньше писали циклы руками, то алгоритмы STL могут казаться магией уровня «а где тут вообще происходит работа?». Но на практике идея очень человеческая: стандартная библиотека даёт вам готовый двигатель (алгоритм), а вы передаёте ему правило поведения (callable). И вот это правило чаще всего удобнее всего выразить лямбдой — маленькой, локальной и читаемой прямо рядом с местом использования.
Представьте, что 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(), ...), хочется спросить: «а третий параметр — это кто?». Третий параметр — компаратор, то есть callable, который говорит, должен ли 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 принимает предикат — callable, который возвращает 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 принимает входной диапазон, выходной итератор и callable, который возвращает новый элемент.
Преобразуем числа: возвести в квадрат
#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, кто-то меняет глобальные переменные, кто-то пытается удалять элементы контейнера прямо внутри предиката. Такие лямбды превращают алгоритм в непредсказуемое приключение. В идеале компаратор и предикат должны быть “чистыми”: только проверка/сравнение, без вмешательства в мир вокруг.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ