Цель этой статьи — заставить всех, особенно программистов на Си, сказать «я не знаю Си».
Хочется показать, что тёмные углы в Си значительно ближе, чем кажется и даже тривиальные строки кода несут в себе undefined behavior.
Статья организована как набор вопросов. Ответы написаны белым. Все примеры — отдельные файлы исходного кода.
1.
Q: Это корректный код? (Не возникнет ли ошибка, связанная с тем, что переменная определяется два раза? Напоминаю, это отдельный файл исходного кода, не на уровне функции или compound statement)
A: Да, это корректный код. Первая строка это предварительное объявление (tentative definition), которое становится объявлением после того, как компилятор обработал определение (вторую строку).
2.
Q: Оказалось, что bar() вызывается даже тогда, когда x — нулевой указатель (и программа не завершается аварийно). Ошибка оптимизатора или всё корректно?
A: Да, всё корректно. Если x — нулевой указатель, то в строке (1) появляется undefined behavior, и тут уже никто ничего программисту не обязан: программа не обязана ни упасть в строке (1), ни сделать return в строке (2) если вдруг удалось выполнить строку (1). Если говорить о том, какими правилами руководствовался компилятор, то всё происходило так. После анализа строки (1) компилятор считает, что x не может быть нулевым указателем и удаляет (2) и (3) как недоступный код (dead code elimination). Переменная y удаляется как неиспользуемая и так как тип *x не квалифицирован volatile, то и чтение из памяти тоже удаляется.
Вот так неиспользуемая переменная убрала проверку на нулевой указатель.
3.
Была вот такая функция:
Её захотели соптимизировать так:
Q: Можно ли вызвать исходную и оптимизированную функцию так, чтобы получились разные результаты в zp?
A: Можно, пусть yp == zp.
4.
Q: Может ли эта функция вернуть inf (бесконечность)? Считать, что числа с плавающей запятой реализованы по IEEE 754 (подавляющее большинство машин). assert включен (NDEBUG не определён).
A: Да. Достаточно передать денормализованный x, например, 1e-309.
5.
Q: Приведённая выше функция должна возвращать длину null-terminated строки. Найдите ошибку.
A: Использование типа int для хранения размеров объектов является ошибочным: не гарантируется, что int сможет вместить размер любого объекта. Следует использовать size_t.
6.
Q: Цикл является вечным. Почему?
A: size_t — беззнаковый тип. Если i беззнаковое, то i >= 0 всегда выполняется.
7.
Данную программу скомпилировали двумя разными компиляторами и запустили на little-endian машине. Получили два разных результата:
Q: Как объяснить второй результат?
A: В данной программе есть undefined behavior, а именно, нарушены правила строгого алиасинга (strict aliasing). В строке (2) изменяется int, поэтому можно считать что любой long не изменился. (Нельзя разыменовывать указатель, который алиасит другой указатель несовместимого типа.) Поэтому компилятор может передать в строке (3) тот же long, который был считан в процессе выполнения строки (1).
8.
Q: Это корректный код? Если здесь нет undefined behavior, то что он выводит?
A: Да, тут использован оператор запятая. Сначала вычисляется левый аргумент запятой и отбрасывается, затем вычисляется правый аргумент и используется как значение всего оператора. Вывод: 10 2 10.
Обратите внимание, что символ запятой в вызове функции (например, f(a(), b())) не является оператором запятой и поэтому не гарантирует порядок вычислений: a(), b() могут быть вызваны в любом порядке.
9.
Q: Каков результат add(UINT_MAX, 1)?
A: Переполнение беззнаковых чисел определено, вычисляется по модулю 2^(CHAR_BIT * sizeof(unsigned int)). Результат 0.
10.
Q: Каков результат add(INT_MAX, 1)?
A: Переполнение знаковых чисел — undefined behavior.
11.
Q: Возможен ли тут undefined behavior? Если да, то при каких аргументах?
A: neg(INT_MIN). Если ЭВМ представляет отрицательные числа в дополнительном коде (англ. twos complement, подавляющее большинство машин), то абслютное значение INT_MIN на единицу больше, чем абсолютное значение INT_MAX. В этом случае -INT_MIN вызывает знаковое переполнение — undefined behavior.
12.
Q: Возможен ли тут undefined behavior? Если да, то при каких аргументах?
A: Если ЭВМ представляет отрицательные числа в дополнительном коде, то div(INT_MIN, -1) — см. предыдущий вопрос.
— Dmitri Gribenko <gribozavr@gmail.com>
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.
Хочется показать, что тёмные углы в Си значительно ближе, чем кажется и даже тривиальные строки кода несут в себе undefined behavior.
Статья организована как набор вопросов. Ответы написаны белым. Все примеры — отдельные файлы исходного кода.
1.
int i;
int i = 10;
Q: Это корректный код? (Не возникнет ли ошибка, связанная с тем, что переменная определяется два раза? Напоминаю, это отдельный файл исходного кода, не на уровне функции или compound statement)
A: Да, это корректный код. Первая строка это предварительное объявление (tentative definition), которое становится объявлением после того, как компилятор обработал определение (вторую строку).
2.
extern void bar(void);
void foo(int *x)
{
int y = *x; /* (1) */
if(!x) /* (2) */
{
return; /* (3) */
}
bar();
return;
}
Q: Оказалось, что bar() вызывается даже тогда, когда x — нулевой указатель (и программа не завершается аварийно). Ошибка оптимизатора или всё корректно?
A: Да, всё корректно. Если x — нулевой указатель, то в строке (1) появляется undefined behavior, и тут уже никто ничего программисту не обязан: программа не обязана ни упасть в строке (1), ни сделать return в строке (2) если вдруг удалось выполнить строку (1). Если говорить о том, какими правилами руководствовался компилятор, то всё происходило так. После анализа строки (1) компилятор считает, что x не может быть нулевым указателем и удаляет (2) и (3) как недоступный код (dead code elimination). Переменная y удаляется как неиспользуемая и так как тип *x не квалифицирован volatile, то и чтение из памяти тоже удаляется.
Вот так неиспользуемая переменная убрала проверку на нулевой указатель.
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;
}
}
Q: Можно ли вызвать исходную и оптимизированную функцию так, чтобы получились разные результаты в zp?
A: Можно, пусть yp == zp.
4.
double f(double x)
{
assert(x != 0.);
return 1. / x;
}
Q: Может ли эта функция вернуть inf (бесконечность)? Считать, что числа с плавающей запятой реализованы по IEEE 754 (подавляющее большинство машин). assert включен (NDEBUG не определён).
A: Да. Достаточно передать денормализованный x, например, 1e-309.
5.
int my_strlen(const char *x)
{
int res = 0;
while(*x)
{
res++;
x++;
}
return res;
}
Q: Приведённая выше функция должна возвращать длину null-terminated строки. Найдите ошибку.
A: Использование типа 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;
}
Q: Цикл является вечным. Почему?
A: 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;
}
Данную программу скомпилировали двумя разными компиляторами и запустили на little-endian машине. Получили два разных результата:
1. v=10 2. v=11 3. v=11
1. v=10 2. v=10 3. v=11
Q: Как объяснить второй результат?
A: В данной программе есть undefined behavior, а именно, нарушены правила строгого алиасинга (strict aliasing). В строке (2) изменяется int, поэтому можно считать что любой long не изменился. (Нельзя разыменовывать указатель, который алиасит другой указатель несовместимого типа.) Поэтому компилятор может передать в строке (3) тот же long, который был считан в процессе выполнения строки (1).
8.
#include <stdio.h>
int main()
{
int array[] = { 0, 1, 2 };
printf("%d %d %d\n", 10, (5, array[1, 2]), 10);
}
Q: Это корректный код? Если здесь нет undefined behavior, то что он выводит?
A: Да, тут использован оператор запятая. Сначала вычисляется левый аргумент запятой и отбрасывается, затем вычисляется правый аргумент и используется как значение всего оператора. Вывод: 10 2 10.
Обратите внимание, что символ запятой в вызове функции (например, f(a(), b())) не является оператором запятой и поэтому не гарантирует порядок вычислений: a(), b() могут быть вызваны в любом порядке.
9.
unsigned int add(unsigned int a, unsigned int b)
{
return a + b;
}
Q: Каков результат add(UINT_MAX, 1)?
A: Переполнение беззнаковых чисел определено, вычисляется по модулю 2^(CHAR_BIT * sizeof(unsigned int)). Результат 0.
10.
int add(int a, int b)
{
return a + b;
}
Q: Каков результат add(INT_MAX, 1)?
A: Переполнение знаковых чисел — undefined behavior.
11.
int neg(int a)
{
return -a;
}
Q: Возможен ли тут undefined behavior? Если да, то при каких аргументах?
A: neg(INT_MIN). Если ЭВМ представляет отрицательные числа в дополнительном коде (англ. twos complement, подавляющее большинство машин), то абслютное значение INT_MIN на единицу больше, чем абсолютное значение INT_MAX. В этом случае -INT_MIN вызывает знаковое переполнение — undefined behavior.
12.
int div(int a, int b)
{
assert(b != 0);
return a / b;
}
Q: Возможен ли тут undefined behavior? Если да, то при каких аргументах?
A: Если ЭВМ представляет отрицательные числа в дополнительном коде, то div(INT_MIN, -1) — см. предыдущий вопрос.
— Dmitri Gribenko <gribozavr@gmail.com>
This work is licensed under a Creative Commons Attribution-ShareAlike 3.0 Unported License.