Переполнение буфера и выход за границы массива — злобные ошибки и причины не только лишь простых падений программ, но дыр в безопасности, позволяющих получать доступ куда не следует или даже исполнять произвольный код.
В стандартной библиотеке C, доставшейся C++ по наследству, великое множество дырявых функций, позволяющих добиться переполнения буфера, если программист не удосужился проверить все возможные и невозможные варианты.
scanf("%s", buf)
— нет проверки размера буфераstrcpy(dst, src)
— нет проверки размера буфераstrcat(dst, src)
— нет проверки размера буфераgets(str)
— нет проверки размера буфераmemcpy(dst, src, n)
— проверку размераdst
нужно делать вручную.
И еще многие другие, преимущественно работающие со строками, функции.
Эти функции доставляли и продолжают доставлять проблемы. Некоторые компиляторы (msvc) по умолчанию откажутся собирать ваш код, если увидят одну из них. Другие будут менее заботливыми и, возможно, выдадут предупреждение. По крайней мере про функцию gets
уж точно. Если с другими функциями у программиста есть возможность уберечься (проверка до вызова; у scanf
можно указать размер в ограничение строке), то с gets
— без вариантов.
Для большинства старых небезопасных сишных функций сейчас есть «безопасные» аналоги с размерами буферов. Часть из них не стандартизирована, часть стандартизирована. Все это породило огромное количество костылей с макроподстановками для работы со всем этим зоопарком. Но сейчас не об этом.
Проверки размеров — дополнительная работа. Генерировать под них инструкции — замедлять программу. Тем более программист мог все проверить сам. Так что в C/С++ обращение за границы массива, хоть на запись, хоть на чтение — влечет неопределенное поведение. И дыры в безопасности могут зарастать различными спецэффектами.
В большинстве случаев, если нарушение размеров происходит не всегда, попытка почитать за границами массива проявится либо получением мусорных результатов, либо простой и так всеми любимой ошибкой сегментации (SIGSEGV).
Но иногда начинается веселье.
const int N = 10;
int elements[N];
bool contains(int x) {
for (int i = 0; i <= N; ++i) {
if (x == elements[i]) {
return true;
}
}
return false;
}
int main() {
for (int i = 0; i < N; ++i) {
std::cin >> elements[i];
}
return contains(5);
}
Эта программа, собранная gcc c оптимизациями, всегда «найдет» пятерку в массиве. Независимо от того какие числа будут введены. Причем никаких предупреждений ни clang, ни gcc не производят.
Происходит такой спецэффект из следующих соображений:
- Компиляторы вольны считать, что UB в программах не бывает
-
В этом цикле будет обращение за границы массива, а значит UB.
for (int i = 0; i <= N; ++i) { if (x == elements[i]) { return true; } }
- Но, так как UB не бывает, до
N+1
итерации дело дойти не должно - Значит, мы выйдем из цикла по
return true
- А значит вся функция
contains
— это одинreturn true
. Оптимизировано!
Или вот конечный цикл становится бесконечным:
const int N = 10;
int main() {
int decade[N];
for (int k = 0; k <= N; ++k) {
printf("k is %d\n",k);
decade[k] = -1;
}
}
И фокус здесь не менее хитрый:
decade[k] = -1;
Обращение к элементу массива должно быть без UB. А значитk < N
- Раз
k < N
, то условие продолжения циклаk <= N
— всегда истинно. Проверять его не надо. Оптимизировано!
В этих примерах, конечно, сразу же должен броситься в глаза <=
в заголовках циклов. Но и с более привычным <
тоже можно изобрести себе проблемы. Константа N
, например, может быть не связана с размером массива. И все, приехали.
В дружелюбных и безопасных языках вы получите ошибку во время выполнения. Панику или исключение. В C++ же все надо проверять, проверять и еще раз проверять самим:
- Не использовать отдельно висящие константы при проверке размеров. Лучше
std::size()
или методsize()
- Писать меньше сырых циклов со счетчиками. Предпочтительнее range-based-for или стандартные алгоритмы из
#include <algorithm>
- Не использовать
operator[]
, когда не критична производительность. Безопаснее методat()
контейнера, проверяющий границы.