Инструменты и приёмы отладки
Инварианты
Применение инвариантов относится не столько к отладке, сколько к вопросам написания качественного кода, содержащего минимум ошибок. Тем не менее, мы всё же решили вставить данный материал в эту главу ввиду его полезности.
Инвариантом (invariant) называется логическое выражение, которое на заданном отрезке (или в заданных точках) программы должно быть всегда истинным. Инварианты удобно использовать для обоснования корректности кода. Рассмотрим пример. Следующая функция находит наибольший общий делитель (НОД) двух неотрицательных целых чисел m и n (из них хотя бы одно не равно нулю):
// Пример 9.1.1 - поиск НОД алгоритмом Евклида
// параметры: m >= 0, n >= 0, m или n не равен нулю
int gcd(int m, int n)
{
int a = m, b = n;
while (a != 0 && b!= 0) {
if (a >= b) a %= b; else b %= a;
}
return std::max(a, b);
}
Докажем, что эта функция работает верно. Для этого рассмотрим такой инвариант: "после каждой итерации цикла while верно, что НОД(a, b) = НОД(m, n)". Если инвариант верен, то функция действительно найдёт НОД(m, n). Это следует из того, что цикл закончится тогда, когда одна из переменных станет равна нулю, а НОД(x, 0) = x. Заметим, что "зациклиться" программа не может, поскольку на каждой итерации цикла уменьшается либо a, либо b.
Осталось обосновать правильность инварианта. Для этого достаточно доказать, что НОД(a, b) = НОД(a % b, b) − оставляем это упражнение читателю.
Утверждения, макрос assert
Термин "утверждение" (assertion) очень похож на понятие инварианта. Утверждение − это условие, которое в заданной точке программы обязано быть всегда истинным. Если же оно вдруг оказывается ложным, то это говорит о наличии ошибки в программе.
Типичным примером является принадлежность аргументов функции своим областям определения, а результата − своей области значений. Например, в нашей функции gcd аргументы должны быть неотрицательными, и хотя бы один из них не равен нулю. Результат должен быть больше нуля, а также должен являться делителем чисел m и n.
Можно вставить в код функции условные операторы для проверки утверждений и возбуждать исключение, если утверждение не выполняются. Однако, такой подход замедляет работу, что не всегда приемлемо.
Хорошим вариантом является использование отладочного макроса assert из заголовочного файла cassert. Макрос assert принимает один аргумент − логическое выражение. Если выражение ложно, то в стандартный поток ошибок выводится диагностическое сообщение вида:
Assertion failed: expression, file filename, line line_number
где expression − текст логического выражения, filename − имя исходного файла, line_number − строка в исходном файле. После этого работа программы завершается.
Макрос assert можно отключить, вставив директиву #define NDEBUG перед директивой #include <cassert>. В этом случае диагностический код компилироваться не будет, и работа программы не замедлится. Типичной практикой является отключение диагностики при компиляции версии программы для передачи её конечным пользователям.
Приведём пример версии функции gcd с использованием assert:
// Пример 9.1.2 - поиск НОД алгоритмом Евклида
// параметры: m >= 0, n >= 0, m или n не равен нулю
// результат: НОД (m, n)
//#define NDEBUG
#include <cassert>
int gcd(int m, int n)
{
assert(m >= 0 && n >= 0);
assert (m != 0 || n != 0);
int a = m, b = n;
while (a != 0 && b!= 0)
{
if (a > b)
a %= b;
else
b %= a;
}
int res = std::max(a, b);
assert (res > 0);
assert (m % res == 0 && n % res == 0);
return res;
}
Пример сообщения, которое выдаётся при срабатывании assert:
Assertion failed: m >= 0 && n >= 0, file example_9_1_2.cpp, line 6
Заметим, что описанный подход широко применяется не только на стадии отладки, но ещё на стадии написания кода: зачастую он позволяет обнаружить при тестировании те ошибки, которые иначе были бы пропущены и проявились бы лишь впоследствии при эксплуатации программы.
Синтаксические ошибки
Обнаружение и исправление синтаксических ошибок обычно не представляет особых сложностей. Однако, некоторые моменты всё же стоит отметить.
Язык C++ имеет достаточно сложную грамматику, поэтому компилятор не всегда может точно определить строку с ошибкой. Если в сообщении компилятора говорится, что он обнаружил ошибку в строке 100, то на самом деле она может быть в строке 99 (или даже 98). При этом сообщение об ошибке может оказаться довольно неочевидным. Однако, при просмотре этой и соседних строк причина ошибки обычно быстро находится. Кроме того, зачастую единственная синтаксическая ошибка приводит к тому, что компилятор обнаруживает огромное количество ошибок, являющихся следствием данной. Для примера рассмотрим код:
// Пример 9.1.3 - программа с синтаксической ошибкой
#include <iostream>
int main() {
int x;
std::cin >> x;
std::cout << x * x;
}
В данной программе имеется одна ошибка − пропущена точка с запятой в конце четвёртой строки (должно быть int x;). А теперь посмотрим на сообщения компилятора:
example_9_1_3.cpp: In function 'int main()':
example_9_1_3.cpp:5:3: error: expected initializer before 'std'
std::cin >> x;
^
example_9_1_3.cpp:6:16: error: 'x' was not declared in this scope
std::cout << x * x;
^
Как видим, компилятор обнаружил синтаксическую ошибку в пятой строке, а не в четвёртой. Из сообщения "expected initializer before 'std'" не сразу очевидно, что проблема в отсутствии точки с запятой перед словом std. Кроме того, компилятор обнаружил ещё одну ошибку в шестой строке − переменная x не объявлена. Данная ошибка является следствием первой.
Обычно нет смысла продолжать компиляцию файла, если в нём уже нашлось много синтаксических ошибок, так как многие из них являются следствием предыдущих. Например, для компилятора g++ параметр -fmax-errors=2 приводит к остановке компиляции при обнаружении двух ошибок.
Предупреждения компилятора
Рекомендуем всегда включать максимальный или близкий к максимальному уровень предупреждений (warnings) компилятора (по умолчанию он обычно стоит на некотором среднем уровне). Например, в компиляторе g++ многие (но не все!) предупреждения включаются с помощью параметра командной строки -Wall. Некоторые дополнительные предупреждения активируются ключом -Wextra. Ключ -pedantic проверяет строгое соответствие программы стандарту языка C++, это полезно при написании переносимых программ.
При использовании интегрированной среды разработки эти опции обычно можно найти в настройках проекта. Например, в среде CodeBlocks нужно зайти в меню "Project − Build Options", открыть вкладку "Compiler Settings" и ниже − вкладку "Compilers Flags". При работе с Visual C++ аналогичные параметры можно найти в свойствах проекта в разделе "Configuration Properties − C/C++ − General", параметр "Warning Level".
Разумеется, предупреждения компилятора никак не помогут в отладке, если их игнорировать. Иногда возникает соблазн не обращать внимание на предупреждения, поскольку "программа ведь компилируется и работает". Это легко может привести к тому, что вы пропустите ошибку, которая не будет найдена при тестировании и проявится у конечных пользователей. Чтобы дисциплинировать себя, можно попросить компилятор обрабатывать предупреждения как ошибки: тогда при наличии предупреждений компиляция будет прервана. Например, в g++ для этого служит ключ -Werror.
Рассмотрим на примерах, как предупреждения помогают выявлять ошибки в программах. Пусть дан следующий код:
// Пример 9.1.4 - пример программы с ошибкой
#include <cstdio>
int main()
{
int x;
scanf("%d", x);
printf("%d", x * x);
}
При использовании компилятора g++ 4.9.2 с параметрами компиляции по умолчанию мы получаем исполняемый файл, при этом ничего подозрительного не обнаруживается. Однако, указав при компиляции параметр -Wall, получаем следующее сообщение:
example_9_1_4.cpp:5:22: warning: format '%d' expects argument of type 'int*', but argument 3 has type 'int' [-Wformat=]
Из этого предупреждения сразу понятно, что в пятой строчке функции scanf передаётся аргумент типа int, а ей нужен указатель на int. Исправленная строчка выглядит так:
scanf("%d", &x);
Рассмотрим ещё один пример:
// Пример 9.1.5 - ещё одна программа с ошибкой
#include <iostream>
int main()
{
int x;
std::cin >> x;
if (x = 0) std::cout << "zero\n";
}
На этот раз воспользуемся компилятором MS Visual C++. Компиляция с параметрами по умолчанию ни о чём подозрительном не сообщает. Однако, при компиляции с ключом /W4 мы получаем следующее сообщение:
example_9_1_5.cpp(5) : warning C4706: assignment within conditional expression
Из данного сообщения сразу понятна суть ошибки: вместо сравнения x==0 в программе написано присваивание x=0.
Примечание: здесь мы использовали ключ /W4, а не /Wall, так как с ключом /Wall компилятор Visual C++ находит слишком много предупреждений в стандартном заголовочном файле iostream.
Отладочные библиотеки
С некоторыми компиляторами C++ поставляются две версии стандартной библиотеки языка − основная и отладочная. Если основная версия реализована в расчёте на максимальную производительность, то отладочная содержит дополнительный код, позволяющий облегчить отладку программ. Отладочная библиотека может обнаружить, например, выход индекса за границу вектора, вызов функции с неверными аргументами и др. Для примера рассмотрим следующий код:
// Пример 9.1.6 - программа с ошибкой выхода за границу
#include <iostream>
#include <vector>
int main()
{
std::vector<int> x(10);
x[10] = 5;
}
Выражение x[10]=5; ошибочно, так как индекс должен принадлежать диапазону от 0 до 9. Откомпилируем данный код с помощью Visual C++ и запустим программу. Если при этом использовалась основная версия библиотеки, то поведение программы непредсказуемо − она может завершиться успешно, а может "вылететь" с ошибкой времени выполнения. Если использовалась отладочная библиотека, то при запуске программы будет выведено сообщение наподобие такого:
Debug Assertion Failed!
Program: C:\Windows\system32\MSVCP120D.dll
File: C:\ ... \ include\vector
Line: 1201
Expression: vector subscript out of range
Для указания компилятору Visual C++, какие версии библиотек использовать, имеются специальные ключи (/MD, /MDd, /MT, /MTd). Однако, проще всего при работе в Visual Studio использовать предустановленные конфигурации Debug и Release. Набор настроек в конфигурации Debug ориентирован на облегчение отладки, а в конфигурации Release − на максимальную производительность. Заметим, что разница в производительности может быть очень существенной. Например, замеры скорости обращения к элементу вектора показали отличие примерно в 90 раз!
Метод "разделяй и властвуй"
Одним из способов локализации места ошибки в коде является метод "разделяй и властвуй". Используется обычно в том случае, когда нет обоснованных предположений о месте ошибки. Суть метода в следующем. Выбираем некоторую точку в коде и определяем, произошла ошибка раньше этой точки или позже неё. Основная сложность заключается в том, как именно это определять − критерии очевидны далеко не всегда.
Рассмотрим для начала простой пример. Допустим, в программе последовательно вызываются функции f1 и f2, а ошибка проявляется в том, что программа аварийно завершается. Мы хотим определить, в которой из функций происходит ошибка. Закомментируем вызов функции f2. Если программа теперь завершается корректно, то ошибка, судя по всему, находится в функции f1, иначе − в f2.
Теперь рассмотрим пример посложней. Дан вектор, содержащий набор целых чисел − координаты некоторых точек на числовой оси. Требуется выполнить так называемое сжатие координат: самое маленькое число становится нулём, следующее по величине − единицей, и так далее. Реализация функции выглядит следующим образом:
// Пример 9.1.7 - функция сжатия координат (содержит ошибку)
void coord_compress(std::vector<int> &x)
{
// Часть 1. Строим отображение старых координат в новые
std::map<int, int> to;
std::vector<int> xx = x;
std::sort(xx.begin(), xx.end());
int cur = 0;
for (int v : x) // в этой строчке ошибка
if (to.count(v) == 0)
to[v] = cur++;
// Часть 2. Заменяем координаты в исходном массиве на сжатые
for (int &v : x)
v = to[v];
}
Работа функции состоит из двух этапов. На первом этапе строится отображение to из старых координат в новые. Например, для входного вектора {5, 2, 8, 5} должно получиться отображение 2→0, 5→1, 8→2. На втором этапе строится результирующий вектор − в нашем примере будет {1, 0, 2, 1}.
При проверке работы этой функции обнаружилось, что она работает неправильно. Вектор {5, 2, 8, 5} должен был преобразоваться в {1, 0, 2, 1}, а получилось {0, 1, 2, 0}. Чтобы найти ошибку, применим подход "разделяй и властвуй" − определим, в первой части функции ошибка или во второй.
Для этого посмотрим, правильно ли строится отображение to. Добавим в текст функции отладочную печать (прямо перед комментарием "// Часть 2…"):
for (auto elem : to)
std::cerr << elem.first << "->" << elem.second << " ";
std::cerr << std::endl;
Результат отладочного вывода: 2->1 5->0 8->2, то есть ошибка находится в той части функции, которая строит отображение. Посмотрев внимательно на код, понимаем, что вместо "for (int v : x)" должно быть "for (int v : xx)".
Примечание 1. Вероятно, для поиска ошибки в столь коротком коде хватило бы и метода "пристального взгляда".
Примечание 2. Интересно, что ни один из компиляторов, которыми пользовался автор, не выдал предупреждения о том, что переменная xx не используется для получения результата функции.
Логирование
Логирование − это вывод на консоль или в файл различной информации − событий входа в определённые функции и выхода из них, вывод значений входных и выходных аргументов функций, значений переменных в определённых точках программы и различных других сведений, которые могут быть полезны при отладке.
К логированию кода можно отнести две достаточно разные задачи.
Во-первых, отладочная печать. Операторы отладочного вывода добавляются в программу при поиске конкретной ошибки. После устранения ошибки эти операторы из программы удаляются. Пример использования отладочной печати приводился в предыдущем подразделе.
Во-вторых, это ведение файла журнала (так называемого "лог-файла"), содержащего историческое описание исполнения программы (когда и кем программа была запущена, какие функции, в какое время и с какими параметрами активизировались и т.п.) Для интерактивных приложений имеется сходное понятие журнал действий пользователей.
Представим ситуацию, что при работе пользователя с программой произошла ошибка. Он сообщает об ошибке в отдел поддержки. Чтобы исправить ошибку, нужно уметь её воспроизвести. Вряд ли обычный пользователь сможет вспомнить абсолютно все свои шаги, а ведь порой ошибка проявляется только при строго определённой последовательности действий. Если система логирует действия пользователя, то воспроизвести ошибку становится намного проще, и её исправление не составит проблемы.
Стоит отметить, что при наличии хорошего лога "продвинутый" пользователь зачастую и сам может понять причину ошибки, не прося помощи у разработчиков. Это характерно, например, для многих программ для операционных систем семейств Unix/Linux.
Для целей логирования можно использовать как стандартные средства ввода/вывода языка C++, так и специализированные библиотеки с дополнительными возможностями.
При использовании стандартной библиотеки C++ следует обратить внимание на важный момент: обычно при записи в файл данные буферизируются в оперативной памяти, и попадают в файл не сразу. Если вследствие аварийного завершения программы файл корректно не закроется, то данные из буфера могут пропасть.
При использовании потокового вывода для сброса содержимого буфера в файл достаточно вывести std::flush или std::endl (в последнем случае выведется также перевод строки). Заметим, что символ перевода строки ('\n'), в отличие от std::endl, не гарантирует сброс буфера в файл. Также для этой цели можно использовать C-функцию fflush. Пример:
// Пример 9.1.8 - пример вывода со сбросом буфера
#include <stdio.h>
#include <iostream>
int main()
{
// перенаправим стандартный поток вывода в файл
freopen("output.txt", "w", stdout);
std::cout << "Hello, " << std::flush;
std::cout << "world!" << std::endl;
printf("I like C++");
fflush(stdout);
}
Каждая из трёх операций вывода в данном случае гарантированно выведет свой текст в файл, даже если после неё в программе произойдёт исключение.
Использование отладчика
Как правило, любая среда программирования имеет в своём составе отладчик. Отладчик позволяет производить трассировку программы, то есть её пошаговое выполнение строка за строкой. При этом, если очередная строка является вызовом функции, то можно как выполнить её целиком, так и зайти внутрь и продолжать пошаговое выполнение уже внутри тела функции.
В ходе трассировки можно просматривать текущие значения переменных. Хорошие отладчики умеют отображать достаточно сложные данные − динамические массивы, объекты пользовательских классов и др. Зачастую бывает полезно посмотреть стек вызовов, чтобы узнать последовательность вызова функций, которая привела к текущей точке программы.
Стоит отметить, что с помощью отладчика можно не только смотреть, но и изменять значения переменных − иногда это бывает удобно.
Отладчик позволяет задавать так называемые точки останова (breakpoints) − условия, при котором выполнение программы приостанавливается. Чаще всего используются позиционные точки останова, которые привязываются к определённым строкам в программе. Как только управление доходит до такой строки, выполнение прерывается. Для таких точек останова обычно можно указать число итераций, после которого точка должна сработать, а также дополнительное условие срабатывания в виде логического выражения.
Помимо позиционных точек, некоторые отладчики поддерживают также точки останова по данным. Такая точка останова срабатывает, когда изменяется указанная область памяти. При этом неважно, в какой строке программы это произошло.
В качестве примера использования отладчика вернёмся к функции coord_compress из примера 9.1.7. Вместо добавления в программу кода отладочной печати можно было бы поставить точку останова на строку "for (int &v : x)". Тогда перед началом выполнения этого цикла сработала бы точка останова, после чего мы бы посмотрели содержимое переменной to.
Интересно, что некоторые специалисты в области программирования вообще не рекомендуют применять инструменты отладки, а для поиска ошибок советуют использовать свою голову. Это, конечно, спорное утверждение, но частично с ним можно согласиться: не следует полагаться на отладчик слишком сильно, он не сможет заменить грамотных рассуждений.
Другие инструменты отладки
При выполнении отладки можно использовать целый ряд дополнительных инструментов. Рассмотрим кратко некоторые из них.
Во-первых, это утилита для сравнения содержимого файлов. Если вы плохо помните, что именно успели изменить в большом исходном файле при отладке, то можно сравнить старую версию файла с новой и получить список различий. В Windows стандартная утилита для сравнения файлов называется fc, в Unix/Linux − diff.
Существуют инструменты, которые проверяют исходный код в каких-то аспектах тщательней, чем компилятор, и могут находить в нём подозрительные места − например, переменные без инициализации и др. Для C++ примерами таких программ являются lint и cppcheck.
Как ни странно, для целей отладки могут оказаться полезны и инструменты для профилирования выполнения программы (профилировщики). Допустим, вы реализовали эффективный алгоритм, который теоретически должен работать очень быстро. Однако, результат профилирования показал, что ваша функция выполняется многократно медленнее ожидаемого. Это может быть признаком того, что в реализации алгоритма есть ошибка.
Также при отладке можно использовать средства для поиска утечек памяти, инструменты для перехвата сетевого трафика при отладке приложений "клиент-сервер" и др.
Оптимизация кода
Рекомендации по выполнению оптимизации
Под оптимизацией кода понимается изменение кода для повышения эффективности его работы. Слово "эффективность", как правило, означает скорость работы программы (иногда также объём потребляемой памяти). Приведём несколько рекомендаций, соблюдение которых поможет вовсе избежать необходимости оптимизировать написанный код или значительно снизить объём работы.
1. Если производительность программы действительно важна, стоит подумать о ней ещё на ранних стадиях проектирования. Например, если разрабатывается сервер, к которому одновременно будут обращаться тысячи клиентов, стоит заранее выбрать распределённую архитектуру, при которой код будет выполняться на кластере из нескольких машин. Если предполагаются большие объёмы сложных однотипных вычислений, то стоит рассмотреть возможность использования для этого графических карт, и т.п.
2. Выбор правильных алгоритмов и структур данных при проектировании часто приводит к тому, что оптимизация кода вообще не потребуется. Например, как бы вы ни оптимизировали код сортировки пузырьком, он никогда не станет работать лучше, чем сортировка методом Хоара.
3. Прежде, чем тратить время на оптимизацию, стоит убедиться, что это действительно нужно. Производительность работы пользователей далеко не всегда связана со скоростью работы программы − например, в ней может быть просто неудобный интерфейс. Также не стоит заниматься оптимизацией кода без явной необходимости. Если скорость работы программы устраивает пользователей, то, возможно, стоит потратить своё время на другие задачи − например, добавление в программу новых возможностей.
Если у вас всё же имеется необходимость в оптимизации кода, то стоит помнить о так называемом "правиле 80/20". Применительно к программированию оно говорит о том, что на 20% кода программы приходится 80% процентов времени её выполнения. В одном из исследований Дональда Кнута получилась оценка "4/50" (примерно 4% кода выполняются 50% времени). В любом случае, здесь важно то, что нерационально тратить время на оптимизацию всего кода − сначала необходимо найти "узкие" места в программе, после чего оптимизировать именно их. Бывает, что изменение всего лишь нескольких строчек кода способно повысить скорость работы большой программы в несколько раз.
Поиск таких "узких" мест в большой программе вручную − занятие весьма непростое. Однако, для этой цели можно использовать специальные инструменты − профилировщики. Многие среды разработки содержат профилировщик в своём составе (например, Visual Studio). Профилировщик позволяет получить профиль выполнения программы − сколько и какая функция и даже конкретная строчка кода отнимают процессорного времени. После этого уже можно целенаправленно оптимизировать именно те места в коде, которые максимально влияют на производительность.
Отметим, что есть ещё одна причина, по которой не стоит пытаться оптимизировать весь код программы (кроме напрасной потери времени и возможности внести лишние ошибки). Она заключается в том, что код после оптимизации зачастую становится гораздо менее понятным, и его будет сложнее сопровождать.
Стоит также предостеречь от оптимизации кода во время написания программ. Во-первых, предугадать заранее узкие места в коде очень сложно. Во-вторых, если слишком сильно сконцентрироваться на микрооптимизации, то можно отвлечься от достижения других целей, а также упустить из виду по-настоящему важные глобальные аспекты оптимизации.
Методики оптимизации кода
Прежде чем говорить о методиках оптимизации, стоит сделать одно замечание. Иногда изменение кода, которое повышает производительность в одной среде, может снижать её в другой. Даже при использовании двух разных версий компилятора результаты могут иногда существенно отличаться. В этом плане особую сложность представляет оптимизация кода переносимых программ, которые должны работать на разных платформах.
Оптимизирующие компиляторы
Практически все современные компиляторы C++ являются оптимизирующими, то есть в них используются различные методы получения более оптимального кода при сохранении его функциональных возможностей. Компиляторы используют различные подходы к оптимизации кода − удаление ненужных вычислений, повторное использование результатов вычислений, хранение часто используемых переменных в регистрах процессора, использование встраивания функций и множество других способов.
Зачастую для получения достаточной производительности программы требуется лишь задать нужный ключ компилятора. Например, для компилятора g++ ключи -O1, -O2 и -O3 означают включение базовой, более сильной и ещё более сильной оптимизаций. Заметим, что при повышении уровня оптимизации время компиляции может существенно увеличиваться.
При отладке программ оптимизацию компилятора часто выключают, поскольку она затрудняет отладку (например, переменная может неожиданно оказаться в регистре, а не в памяти, и т.п.)
Рассмотрим несколько примеров. Пусть имеется функция, вычисляющая сумму кодов символов в строке:
// Пример 9.2.1 - пример оптимизации кода компилятором
int sum_of_chars(const char s[])
{
int sum = 0;
for (size_t i = 0; i < strlen(s); i++)
sum += s[i];
return sum;
}
Оценим её вычислительную сложность. На каждой итерации цикла производится вызов функции strlen для вычисления длины строки. Чтобы найти длину, функция strlen должна пробежаться по всей строке до завершающего символа с кодом нуль. Таким образом, количество операций в данной функции пропорционально квадрату длины строки.
Попробуем вызвать эту функцию со строкой-параметром длиной 100 000 символов. При использовании компилятора g++ c параметрами компиляции по умолчанию время работы на ноутбуке автора составило 2.6 секунды, а при указании ключа -O1 − всего 0.006 секунды. Разница более чем в 400 раз! Почему так получилось? Компилятор заметил, что на каждой итерации цикла функция strlen возвращает одно и то же число, поскольку строка в теле цикла не меняется. Поэтому компилятор исправил код так, чтобы функция strlen вызывалась лишь один раз перед циклом, и вычислительная сложность с квадратичной снизилась до линейной.
Рассмотрим похожий пример. Дана функция, которая заменяет в строке точки на запятые:
// Пример 9.2.2 - ещё один пример оптимизации кода компилятором
void change_dots_to_commas(char s[])
{
for (size_t i = 0; i < strlen(s); i++)
if (s[i] == '.')
s[i] = ',';
}
Отличие от предыдущего примера в том, что здесь строка может меняться внутри цикла, хотя длина её измениться по-прежнему не может. При включении оптимизации первого уровня (с ключом -O1) компилятор g++ не смог об этом догадаться. Более того, программа стала работать даже медленней, чем вообще без оптимизации (как это ни странно). Однако, использование ключа -O2 решило проблему − компилятор сообразил, что нигде в теле цикла символам строки не присваивается нулевой символ, потому её длина измениться не может.
Интересно, что компилятор Visual C++ 2013 даже при использовании ключа /O2 не смог оптимизировать пример 9.2.2. Это подтверждает мысль, что слишком сильно надеяться на компилятор не стоит − далеко не всегда он сможет поправить неэффективный код. Приведём версию функции change_dots_to_commas, которая будет эффективно работать при любом компиляторе и любых настройках оптимизации:
// Пример 9.2.3 - более эффективная реализация примера 9.2.2
void change_dots_to_commas(char s[])
{
size_t n = strlen(s);
for (size_t i = 0; i < n; i++)
if (s[i] == '.')
s[i] = ',';
}
В принципе, можно попытаться улучшить и эту версию, вообще избавившись от вызова strlen и даже от обращения к массиву по индексу:
// Пример 9.2.4 - ещё один вариант функции
void change_dots_to_commas(char s[]) {
for (char *c = s; *c != 0; c++)
if (*c == '.')
*c = ',';
}
Однако, какого-либо заметного ускорения данный вариант по сравнению с предыдущим не даёт, а понятность кода стала, возможно, чуть хуже.
В заключение данного подраздела стоит упомянуть ещё об использовании спецификатора inline в языке c++. Наличие спецификатора inline перед именем функции указывает компилятору сделать, по возможности, данную функцию встроенной. Это означает, что всякий раз, когда в тексте программы встречается вызов функции, компилятор будет вместо этого вставлять в текущее место полный код этой функции. Это позволяет не тратить процессорное время на помещение аргументов в стек, выполнение команды вызова, очистку стека при выходе из функции.
Необходимость использовать спецификатор inline у некоторых программистов вызывает сомнения, так как при задании максимального уровня оптимизации компилятор может сам решать, делать функцию встроенной, или нет. Однако, в некоторых случаях явное указание данного спецификатора оправдано. Например, как ни странно, он помогает для ускорения работы рекурсивных функций. Конечно, сделать рекурсивную функцию целиком встроенной компилятор не может, однако всё же способен добиться заметного ускорения.
Приведём пример из практического опыта автора. На одном турнире по программированию предлагалось решить задачу на перебор вариантов. Основную часть решения составляла рекурсивная функция, выполняющая перебор с возвратом (backtracking). При указании слова inline время работы программы сократилось примерно на 30% (в обоих случаях использовался ключ компиляции -O3).