Цель этой статьи — заставить всех, особенно программистов на языке C, сказать: «Я не знаю C».
Я хотел бы показать, что темные углы в C гораздо ближе, чем кажется, и даже тривиальные строки кода имеют неопределенное поведение.
Статья организована в виде набора вопросов.
Ответы написаны белым цветом.
Все примеры представляют собой отдельные файлы исходного кода.
1.
Вопрос: Это правильный код? (Произойдет ли ошибка, поскольку переменная определена дважды? Помните, что это отдельный файл исходного кода, а не на уровне функции или составного оператора) О: Да, это правильный код. Первая строка — это предварительное определение, которое становится объявлением после того, как компилятор обработает определение (вторая строка).int i; int i = 10;
2. extern void bar(void);
void foo(int *x)
{
int y = *x; /* (1) */
if(!x) /* (2) */
{
return; /* (3) */
}
bar();
return;
}
Вопрос: Оказывается, bar() вызывается даже тогда, когда x является нулевым указателем (и программа не аварийно завершает работу).
Ошибка оптимизатора или все правильно? О: Да, все правильно.
Если x — нулевой указатель, то в строке (1) появляется неопределенное поведение, и здесь программисту никто ничего не должен: программа не обязана ни аварийно завершаться в строке (1), ни возвращаться в строке (2), если вдруг удалось выполнить строку (1).
Если говорить о том, какими правилами руководствовался составитель, то все произошло так.
После анализа строки (1) компилятор считает, что x не может быть нулевым указателем, и удаляет (2) и (3) как устранение мертвого кода.
Переменная y удаляется как неиспользуемая, а поскольку тип *x не является летучим, чтение из памяти также удаляется.
Вот как неиспользуемая переменная сняла проверку на нулевой указатель.
3.
Была такая функция: #define ZP_COUNT 10
void func_original(int *xp, int *yp, int *zp)
{
int i;
for(i = 0; i < ZP_COUNT; i++)
{
*zp++ = *xp + *yp;
}
}
Они хотели оптимизировать его следующим образом: void func_optimized(int *xp, int *yp, int *zp)
{
int tmp = *xp + *yp;
int i;
for(i = 0; i < ZP_COUNT; i++)
{
*zp++ = tmp;
}
}
Вопрос: Можно ли вызвать исходную и оптимизированную функцию для получения разных результатов в zp?
О: Да, пусть yp == zp.
4. double f(double x)
{
assert(x != 0.);
return 1. / x;
}
Вопрос: Может ли эта функция вернуть inf (бесконечность)? Предположим, что числа с плавающей запятой реализованы в соответствии со стандартом IEEE 754 (подавляющее большинство машин).
Assert включен (NDEBUG не определен).
А: Да.
Достаточно передать денормализованный x, например, 1e-309.
5. int my_strlen(const char *x)
{
int res = 0;
while(*x)
{
res++;
x++;
}
return res;
}
Вопрос: Вышеуказанная функция должна возвращать длину строки, завершающейся нулем.
Найдите ошибку.
О: Использование типа int для хранения размера объектов ошибочно: int не гарантирует, что он сможет хранить размер любого объекта.
size_t следует использовать.
6. #include <stdio.h>
#include <string.h>
int main()
{
const char *str = "hello";
size_t length = strlen(str);
size_t i;
for(i = length - 1; i >= 0; i--)
{
putchar(str[i]);
}
putchar('\n');
return 0;
}
Вопрос: Цикл вечен.
Почему? О: size_t — это беззнаковый тип.
Если i без знака, то i > = 0 всегда выполняется.
7. #include <stdio.h>
void f(int *i, long *l)
{
printf("1. v=%ld\n", *l); /* (1) */
*i = 11; /* (2) */
printf("2. v=%ld\n", *l); /* (3) */
}
int main()
{
long a = 10;
f((int *) &a, &a);
printf("3. v=%ld\n", a);
return 0;
}
Эта программа была скомпилирована двумя разными компиляторами и запускалась на машине с прямым порядком байтов.
Мы получили два разных результата: 1. v=10 2. v=11 3. v=11
1. v=10 2. v=10 3. v=11
Вопрос: Как объяснить второй результат?
О: Данная программа имеет неопределенное поведение, а именно нарушены правила строгого алиасинга.
В строке (2) меняется int, поэтому можно считать, что любой long не изменился.
(Вы не можете разыменовать указатель, который создает псевдоним другого указателя несовместимого типа.
) Таким образом, компилятор может передать в строку (3) ту же длину, которая была прочитана во время выполнения строки (1).
8. #include <stdio.h>
int main()
{
int array[] = { 0, 1, 2 };
printf("%d %d %d\n", 10, (5, array[1, 2]), 10);
}
Вопрос: Это правильный код? Если здесь нет неопределенного поведения, то что оно выводит?
О: Да, здесь используется оператор запятая.
Аргумент с левой запятой сначала оценивается и отбрасывается, затем оценивается правый аргумент и используется как значение всего оператора.
Вывод: 10 2 10. Обратите внимание, что символ запятой в вызове функции (например, f(a(), b())) не является оператором-запятой и, следовательно, не гарантирует порядок вычисления: a(), b() можно вызывать в любом порядке.
.
9. unsigned int add(unsigned int a, unsigned int b)
{
return a + b;
}
Вопрос: Каков результат add(UINT_MAX, 1)?
О: Определено беззнаковое переполнение, вычисляемое по модулю 2^(CHAR_BIT * sizeof(unsigned int)).
Результат 0.
10. int add(int a, int b)
{
return a + b;
}
Вопрос: Каков результат add(INT_MAX, 1)?
О: Переполнение знакового числа является неопределенным поведением.
11. int neg(int a)
{
return -a;
}
Вопрос: Возможно ли здесь неопределенное поведение? Если да, то с какими аргументами?
A: отрицательный(INT_MIN).
Если ЭVM представляет отрицательные числа в дополнении до двух (подавляющее большинство машин), то абсолютное значение INT_MIN на единицу больше абсолютного значения INT_MAX. В этом случае -INT_MIN вызывает знаковое переполнение - неопределенное поведение.
12. int div(int a, int b)
{
assert(b != 0);
return a / b;
}
Вопрос: Возможно ли здесь неопределенное поведение? Если да, то с какими аргументами?
О: Если ЭVM представляет отрицательные числа в дополнении до двух, тогда div(INT_MIN, -1) - см.
предыдущий вопрос.
— Дмитрий Грибенко
Эта работа лицензирована под Непортированная лицензия Creative Commons Attribution-ShareAlike 3.0 .
Теги: #программирование #wtf #wtf #c #C++ #неопределённое поведение #алиасинг #переполнение #с плавающей запятой #Ненормальное программирование #C++ #C++
-
Язык – Кто Может Читать Ваш Веб-Сайт?
19 Oct, 24 -
Является Ли Интернет Оружием Пролетариата?
19 Oct, 24