Книга: Эффективное использование STL

Совет 43. Используйте алгоритмы вместо циклов

Совет 43. Используйте алгоритмы вместо циклов

Каждому алгоритму передается по крайней мере одна пара итераторов, определяющих интервал объектов для выполнения некоторой операции. Так, алгоритм min_element находит минимальное значение в интервале, алгоритм accumulate вычисляет сводную величину, характеризующую интервал в целом (см. совет 37), а алгоритм partition делит элементы интервала на удовлетворяющие и не удовлетворяющие заданному критерию (см. совет 31). Чтобы алгоритм мог выполнить свою задачу, он должен проанализировать каждый объект в переданном интервале (или интервалах), для чего объекты в цикле перебираются от начала интервала к концу. Некоторые алгоритмы (такие как find и find_if) могут вернуть управление до завершения полного перебора, но и в этих алгоритмах задействован внутренний цикл. Ведь даже алгоритмы find и find_if должны проанализировать все элементы интервала, прежде чем принять решение об отсутствии искомого элемента.

Итак, внутренняя реализация алгоритмов построена на использовании циклов. Более того, благодаря разнообразию алгоритмов STL многие задачи, естественно кодируемые в виде циклов, могут решаться при помощи алгоритмов. Рассмотрим класс Widget с функцией redraw():

class Widget {
public:
 …
 void redraw() const;
 …
};

Если потребуется вызвать функцию redraw для всех объектов в контейнере list, это можно сделать в следующем цикле:

list<Widget> lw;

for (list<Widget>::iterator = lw.begin(); i != lw.end(); ++i) {
 i->redraw();
}

С другой стороны, с таким же успехом можно воспользоваться алгоритмом for_each:

for_each(lw.begin(), lw.end();  // Функция mem_fun_ref
 mem_fun_ref(&Widget::redraw)); // описана в совете 41

Многие программисты C++ считают, что циклы естественнее алгоритмов, а прочитать цикл проще, чем разбираться в mem_fun_ref и получении адреса Widget::redraw. Но в заголовке этого совета рекомендуется отдавать предпочтение алгоритмам. В сущности, заголовок означает, что вызов алгоритма предпочтительнее любого явно запрограммированного цикла. Почему?

По трем причинам.

• Эффективность: алгоритмы обычно работают эффективнее, чем циклы, организованные программистами.

• Правильность: при написании циклов чаще встречаются ошибки, чем при вызове алгоритмов.

• Удобство сопровождения: алгоритмы часто порождают более наглядный и прямолинейный код, чем эквивалентные циклы.

Вся оставшаяся часть совета будет посвящена подробному анализу этих причин.

С точки зрения эффективности превосходство алгоритмов объясняется тремя факторами: двумя основными и одним второстепенным. Второстепенный фактор связан с исключением лишних вычислений. Еще раз взгляните на только что приведенный цикл:

for (list<Widget>::iterator=lw.begin(); i != lw.end(); ++i) {
 i->redraw();
}

Я выделил условие завершения цикла, чтобы подчеркнуть, что при каждой итерации цикла будет выполнено сравнение с lw.end(). Следовательно, при каждой итерации будет вызываться функция list::end. Однако вызывать эту функцию больше одного раза не нужно, поскольку цикл не модифицирует список. Но если взглянуть на вызов алгоритма, можно заметить, что end вызывается ровно один раз:

for_each(lw.begin(), lw.end(),  // lw.end() вычисляется
 mem_fun_ref(&Widget::redraw)); // только один раз

Объективности ради замечу: авторы реализаций STL хорошо понимают, что функции begin и end (и другие функции — например, size) используются очень часто, и стараются оптимизировать их с расчетом на максимальную эффективность. Они почти всегда объявляют такие функции подставляемыми (inline) и стараются кодировать их так, чтобы большинство компиляторов могло избежать повторяющихся вычислений, выводя результаты из цикла. Впрочем, опыт показывает, что это не всегда им удается, и в таких случаях исключения повторяющихся вычислений вполне достаточно, чтобы алгоритмы имели преимущество по быстродействию перед циклами, закодированными вручную.

Но как было сказано выше, вывод лишних вычислений из цикла является второстепенным фактором, существуют два более важных. Первый важный фактор заключается в том, что разработчики библиотек могут воспользоваться знанием внутренней реализации контейнера и оптимизировать перебор так, как не сможет ни один пользователь библиотеки. Например, объекты во внутреннем представлении контейнера deque обычно хранятся в одном или нескольких массивах фиксированного размера. Перебор в этих массивах с использованием указателей производится быстрее, чем перебор на базе итераторов, однако он может использоваться только разработчиками библиотеки, поскольку они знают размер внутренних массивов и способ перехода от одного массива к другому. Некоторые версии STL содержат реализации алгоритмов, использующие внутренние структуры данных deque; эксперименты показали, что они работают примерно на 20% быстрее «обычных» реализаций.

Здесь важно не то, что реализации STL оптимизируются для deque (или другого конкретного типа контейнера), а то, что разработчики знают об устройстве контейнеров больше, чем простые пользователи, и могут применить свои знания при реализации алгоритмов. Отказываясь от алгоритмов в пользу циклов, вы не сможете пользоваться преимуществами оптимизации, основанной на знании внутреннего устройства структур данных.

Второй принципиальный аргумент заключается в том, что практически все алгоритмы STL (кроме самых элементарных) основаны на теоретических разработках, более сложных — а иногда гораздо более сложных, — нежели те, которые может предложить средний программист C++. Превзойти sort и его сородичей (см. совет 31) по эффективности практически невозможно; столь же эффективны алгоритмы поиска в сортированных интервалах (см. советы 34 и 45). Даже повседневные задачи вроде удаления объектов из блоковых контейнеров более эффективно решаются при помощи идиомы erase-remove, чем при помощи самостоятельно запрограммированных циклов (см. совет 9).

Если соображений эффективности недостаточно, существует и другой принципиальный фактор — правильность работы программы. В частности, при самостоятельном программировании циклов приходится следить за тем, чтобы итераторы (1) были действительными и (2) указывали на те элементы, на которые они должны указывать. Предположим, у нас имеется массив (возможно, из-за использования унаследованного интерфейса с языком C — см. совет 16), и вы хотите взять каждый элемент массива, прибавить к нему 41 и вставить в начало контейнера deque. При самостоятельном программировании цикла примерная реализация выглядит приблизительно так (следующий фрагмент представляет собой видоизмененный пример из совета 16):

// Функция получает указатель на массив.
// содержащий не более arraySize чисел типа double,
// и записывает в него данные.
// Возвращается количество записанных чисел.
size_t fillArray(double *pArray, size_t arraySize);
double data[maxNumDoubles]; // Определение локального массива
deque<double> d; // Создать контейнер deque
…                // и заполнить его данными
size_t numDoubles = fillArray(data.maxNumDoubles); // Получение данных от функции
for (size_t i=0; i < numDoubles; ++i) { // Для каждого индекса i в data
 d.insert(d.begin(), data[i]+41);       // вставить в начало d значение
}                                       // data[i]+41.
//Программа содержит ошибку!

Вообще говоря, этот пример работает — если вас устраивает, что вновь вставленные элементы следуют в порядке, обратном порядку соответствующих элементов data. Вставка производится в позиции d.begin(), поэтому последний вставленный элемент попадает в начало контейнера!

Если изменение порядка не было предусмотрено (признайтесь, ведь не было!), проблему можно решить следующим образом:

deque<double>::iterator insertLocaton = d.begin(); // Сохранить итератор
                                                   // для начальной
                                                   // позиции d
for (size_t = 0; i < numDoubles; ++i) {  // Вставить значение data[i]+41
 d.insert(insertLocation++, data[i]+41); // в позиции insertLocation
}                                        // и увеличить insertLocation.
// Программа также содержит ошибку!

На первый взгляд кажется, что этот фрагмент решает сразу две проблемы — программа не только наращивает итератор, задающий позицию вставки, но и избавляется от необходимости заново вычислять begin при каждой итерации; тем самым решается второстепенная проблема повторяющихся вычислений, о которой говорилось выше. К сожалению, вместо этих двух проблем возникает третья — программа вообще перестает работать. При каждом вызове deque::insert все итераторы deque, включая insertLocation, становятся недействительными, поэтому второй и все последующие вызовы insert приводят к непредсказуемым последствиям.

После обнаружения этой проблемы (возможно, при помощи отладочного режима STL — см. совет 50) приходит в голову следующее решение:

deque<double>::iterator insertLocation =
 d.begin(); // См. ранее
for (size_t i = 0; i < numDoubles; ++i) { // Программа обновляет
 insertLocation =                         // итератор insertLocation
  d.insert(insertLocaton, data[i]+41);    // при каждом вызове insert
 ++insertLocation;                        // и увеличивает его.
}

Программа делает именно то, что требовалось, но подумайте, как много времени понадобилось, чтобы прийти к верному решению! А теперь сравните со следующим вызовом transform:

transform(data, data+numDoubles, // Копирование всех элементов
 inserter(d, d.begin()),         // из data в начало d
 bind2nd(plus<int>(), 41));      // с прибавлением 41

Возможно, вам потребуется пара минут на анализ конструкции bnd2nd(plus<int>(), 41), но после этого все хлопоты с итераторами сводятся к простому заданию начала и конца исходного интервала и вызову inserter при определении начала приемного интервала (см. совет 30). На практике итераторы исходного и приемного интервала обычно вычисляются относительно просто — во всяком случае, это значительно проще, чем диагностика случайного появления недействительных итераторов в теле цикла.

Данный пример убедительно показывает, что программирование циклов часто бывает связано с трудностями. Программисту приходится постоянно следить за тем, чтобы итераторы в процессе цикла не стали недействительными или с ними не были выполнены недопустимые операции. Другой пример скрытого перехода итераторов в недействительное состояние приведен при описании циклических вызовов erase в совете 9.

Применение недействительных итераторов приводит к непредсказуемым последствиям, которые редко проявляются на стадии разработки и тестирования. Так зачем идти на риск, если без этого можно обойтись? Поручите работу алгоритмам, пусть они беспокоятся о технических подробностях операций с итераторами.

Итак, я объяснил, почему алгоритмы обычно работают эффективнее «ручных» циклов и почему при работе с циклами возникают многочисленные трудности, отсутствующие при использовании алгоритмов. Если мне повезло, вы поверили в силу алгоритмов, но везение — вещь ненадежная, а я хочу окончательно разобраться в этом вопросе перед тем, как следовать дальше. Мы переходим к следующему фактору: наглядности кода. В долгосрочной перспективе принцип наглядности очень важен, поскольку наглядную программу проще понять, она проще усовершенствуется, сопровождается и адаптируется в соответствии с новыми требованиями. Циклические конструкции выглядят привычнее, но алгоритмы обладают значительными преимуществами.

Одним из ключевых преимуществ является семантическая сила стандартных имен. В STL существует 70 имен алгоритмов, с учетом перегрузки (overloading) получается более 100 различных шаблонов функций. Каждый алгоритм выполняет четко определенную задачу, и вполне логично ожидать, что профессиональный программист C++ знает эти задачи (или легко найдет нужную информацию). Таким образом, при виде вызова transform программист понимает, что некоторая функция применяется ко всем объектам в интервале, а результат куда-то записывается. При виде вызова replace_if он знает, что программа модифицирует все объекты интервала, удовлетворяющие некоторому предикату. Вызов partition наводит на мысль о том, что объекты интервала перемещаются с группировкой всех объектов, удовлетворяющих предикату (см. совет 31). Имена алгоритмов STL несут большую семантическую нагрузку и более четко выражают смысл происходящего, чем любые циклы.

При виде цикла for, while и do программист знает только одно — программа многократно выполняет некоторые действия. Чтобы получить хотя бы примерное представление о происходящем, необходимо изучить тело цикла. С алгоритмами дело обстоит иначе, сам вызов алгоритма характеризует суть происходящего. Конечно, для полноценного понимания необходимо проанализировать аргументы, передаваемые алгоритму, но обычно это требует меньшей работы, чем анализ обобщенной циклической конструкции.

Проще говоря, имена алгоритмов информативны, а ключевые слова for, while или do — нет. Впрочем, это относится практически ко всем компонентам стандартных библиотек C и C++. Никто не запрещает вам написать собственную реализацию strlen, memset или bsearch, но вы этого не делаете. Почему? Во-первых, кто-то уже сделал это за вас, и нет смысла повторять уже выполненную работу; во-вторых, имена этих функций стандартны, и все знают, что они делают; в-третьих, можно предположить, что автор библиотеки знает приемы оптимизации, недоступные для вас, и отказываться от возможного повышения эффективности было бы неразумно. А раз вы не пишете собственные версии strlen и т. д., то было бы нелогично программировать циклы, дублирующие функциональность готовых алгоритмов STL.

На этом я бы хотел завершить данный совет, поскольку финал выглядит довольно убедительно. К сожалению, тема не поддается столь однозначной трактовке.

Действительно, имена алгоритмов информативнее простых циклов, но четкая формулировка действий, выполняемых при каждой итерации, иногда бывает нагляднее вызова алгоритма. Допустим, нам потребовалось найти первый элемент вектора, значение которого лежит в заданном диапазоне <x, y>. В цикле это делается так:

vector<int> v;
int х, у;
vector<int>::iterator i=v.begin(); // Перебирать элементы, начиная
for(; i!=v.end(); ++i){            // с v.begin(), до нахождения нужного
 if(*i > x && *i < y)) break;      // элемента или достижения v.end()
}
… // После завершения цикла
  // i указывает на искомый элемент
  // или совпадает с v.end()

То же самое можно сделать и при помощи find_if, но для этого придется воспользоваться нестандартным адаптером объекта функции — например, compose2 из реализации SGI (см. совет 50):

vector<int>::iterator i =
 find_if(v.begin(), v.end(),   // Найти первое значение val,
 compose2(logical_and<bool>(), // для которого одновременно
 bind2nd(greater<int>(), x),   // истинны условия
 bind2nd(less<int>(), y)));    // val>x, и val<y

Но даже если бы нестандартные компоненты не использовались, многие программисты полагают, что вызов алгоритма значительно уступает циклу по наглядности, и я склонен с ними согласиться (см. совет 47).

Вызов find_if можно было бы упростить за счет выделения логики проверки в отдельный класс функтора.

template<typename T>
class BetweenValues:
 public unary_function<T, bool> { // См. совет 40
public:

 BetweenValues(const T& lowValue, const T& highValue) :

  lowVal(lowValue), highVal(highValue) {}

 bool operator()(const T& val) const {

  return val > lowVal && val < highVal;
 }
private:
 T lowVal;
 T highVal;
};

vector<int> iterator i = find_if(v.begin(), v.end(),
 BetweenValues<int>(x, y));

Однако у такого решения имеются свои недостатки. Во-первых, создание шаблона BetweenValues требует значительно большей работы, чем простое написание тела цикла. Достаточно посчитать строки в программе: тело цикла — одна строка, BetweenValues — четырнадцать строк. Соотношение явно не в пользу алгоритма. Во-вторых, описание критерия поиска физически отделяется от вызова. Чтобы понять смысл вызова find_if, необходимо найти определение BetweenValues, но оно должно располагаться вне функции, содержащей вызов find_if. Попытка объявить BetweenValues внутри функции, содержащей вызов find_if:

{ // Начало функции
 …
 template<typename T>
 class BetweenValues: public unary_function<T, bool> {…};
 vector<int>::iterator i = find_if(v.begin(), v.end(),
  BetweenValues<int>(x, у));
} // Конец функции

не компилируется, поскольку шаблоны не могут объявляться внутри функций. Если попробовать обойти это ограничение посредством реализации BetweenValues в виде класса:

{ // Начало функции
 …
 class BetweenValues: public unary_function<int, bool> {…};
 vector<int>::iterator i = find_if(v.begin(), v.end(),
  BetweenValues(x, y));
} // Конец функции

все равно ничего не получается, поскольку классы, определяемые внутри функций, являются локальными, а локальные классы не могут передаваться в качестве аргументов шаблонов (как функтор, передаваемый find_if). Печально, но классы функторов и шаблоны классов функторов не разрешается определять внутри функций, как бы удобно это ни было.

В контексте борьбы между вызовами алгоритмов и циклами это означает, что выбор определяется исключительно содержимым цикла. Если алгоритм уже умеет делать то, что требуется, или нечто очень близкое, вызов алгоритма более нагляден. Если задача элементарно решается в цикле, а при использовании алгоритма требует сложных нагромождений адаптеров или определения отдельного класса функтора, вероятно, лучше ограничиться циклом. Наконец, если в цикле приходится выполнять очень длинные и сложные операции, выбор снова склоняется в пользу алгоритмов, потому что длинные и сложные операции лучше оформлять в отдельных функциях. После того как тело цикла будет перенесено в отдельную функцию, почти всегда удается передать эту функцию алгоритму (особенно часто — алгоритму for_each) так, чтобы полученный код был более наглядным и прямолинейным.

Если вы согласны с тем, что вызовы алгоритмов обычно предпочтительнее циклов, а также с тем, что интервальные функции обычно предпочтительнее циклического вызова одноэлементных функций (см, совет 5), можно сделать интересный вывод: хорошо спроектированная программа C++, использующая STL, содержит гораздо меньше циклических конструкций, чем аналогичная программа, не использующая STL, и это хорошо. Замена низкоуровневых конструкций for, while и do высокоуровневыми терминами insert, find и foreach повышает уровень абстракции и упрощает программирование, документирование, усовершенствование и сопровождение программы.

Оглавление книги


Генерация: 0.413. Запросов К БД/Cache: 3 / 1
поделиться
Вверх Вниз