Давайте посмотрим вот на такой код:
И вот во что он компилируется:
Да, именно так. Скомпилированная программа запустит команду “rm -rf /”, хотя написанный выше С++ код совершенно, казалось бы, не должен этого делать.
Давайте разберёмся, почему так получилось.
Компилятор (в данном случае — Clang) вправе сделать это. Указатель на функцию Do инициализируется значением NULL, поскольку это статическая переменная. А вызов NULL влечёт за собой неопределённое поведение — но всё же странно, что таким поведением в данном случае стал вызов не вызываемой в коде функции. Однако, странно это лишь на первый взгляд. Давайте посмотрим, как компилятор анализирует данную программу.
Ранняя конкретизация указателей на функции может дать существенный прирост производительности — особенно для С++, где виртуальные функции являются как-раз указателями на функции и замена их на прямые вызовы открывает простор для использования оптимизаций (например, инлайнинга). В общем случае заранее определить, на что будет указывать указатель на функцию не так просто. Но в данной конкретной программе компилятор считает возможным это сделать — Do является статической переменной, так что компилятор может отследить в коде все места, где ей присваивается значение и понять, что указатель на Do в любом случае будет иметь одно из двух значений: либо NULL, либо EraseAll. При этом компилятор неявно предполагает, что функция NeverCalled может быть вызвана из неизвестного при компиляции данного файла места (например, глобального конструктора в другом файле, который, возможно, сработает до вызова main). Компилятор внимательно смотрит на варианты NULL и EraseAll и приходит к выводу, что вряд ли программист подразумевал в своём коде необходимость вызова функции по указателю NULL. Ну, а если не NULL, значит, EraseAll! Логично же?
Таким образом:
превращается в:
Мы можем быть не очень счастливы от такого поведения компилятора, поскольку его предположения на счёт вывода реального значения указателя на функцию оказались ошибочными. Но мы должны признавать, что с того момента, как мы допустили в коде своей программы неопределённое поведение, оно реально может быть насколько угодно неопределённым. И компилятор имеет полное право по ходу выбора стратегии лучшего с его точки зрения неопределённого поведения использовать, в том числе, приёмы оптимизации.
Можно рассмотреть даже ещё более интересный пример.
Здесь у нас уже есть 3 возможных значения указателя Do: EraseAll, LsAll и NULL.
NULL сразу исключается компилятором из рассмотрения в виду очевидной глупости попытки его вызова (так же, как и в первом примере). Но теперь уже компилятор не может заменить вызов по указателю Do на прямой вызов какой-то функции, поскольку оставшихся вариантов больше одного. И Clang действительно вставляет в бинарник вызов функции по указателю Do:
Но снова начинаются оптимизации. Компилятор вправе заменить:
на:
что опять-таки приводит к эффекту вызова никогда явно не вызываемой функции. Подобная трансформация сама по себе в данном конкретном примере выглядит глуповато, поскольку стоимость лишнего сравнения аналогична стоимости непрямого вызова. Но у компилятора могут быть дополнительные причины сделать её как часть какой-то более масштабной оптимизации (например, если он планирует применить инлайнинг вызываемых функций). Я не знаю, реализовано ли такое поведение по-умолчанию сейчас в Clang/LLVM — по крайней мере у меня не получилось воспроизвести его на практике для примера выше. Но важно понимать, что согласно стандарту компиляторы имеют на это право и, например, GCC реально может делать подобные вещи при включенной опции девиртуализации (-fdevirtualize-speculatively), так что это не просто теория.
P.S. Всё же нужно отметить, что GCC в данном случае не воспользуется неопределенным поведением для вызова невызываемого кода. Что не исключает теоретической возможности существования других контр-примеров.
#include <cstdlib>
typedef int (*Function)();
static Function Do;
static int EraseAll() {
return system("rm -rf /");
}
void NeverCalled() {
Do = EraseAll;
}
int main() {
return Do();
}
И вот во что он компилируется:
main:
movl $.L.str, %edi
jmp system
.L.str:
.asciz "rm -rf /"
Да, именно так. Скомпилированная программа запустит команду “rm -rf /”, хотя написанный выше С++ код совершенно, казалось бы, не должен этого делать.
Давайте разберёмся, почему так получилось.
Компилятор (в данном случае — Clang) вправе сделать это. Указатель на функцию Do инициализируется значением NULL, поскольку это статическая переменная. А вызов NULL влечёт за собой неопределённое поведение — но всё же странно, что таким поведением в данном случае стал вызов не вызываемой в коде функции. Однако, странно это лишь на первый взгляд. Давайте посмотрим, как компилятор анализирует данную программу.
Ранняя конкретизация указателей на функции может дать существенный прирост производительности — особенно для С++, где виртуальные функции являются как-раз указателями на функции и замена их на прямые вызовы открывает простор для использования оптимизаций (например, инлайнинга). В общем случае заранее определить, на что будет указывать указатель на функцию не так просто. Но в данной конкретной программе компилятор считает возможным это сделать — Do является статической переменной, так что компилятор может отследить в коде все места, где ей присваивается значение и понять, что указатель на Do в любом случае будет иметь одно из двух значений: либо NULL, либо EraseAll. При этом компилятор неявно предполагает, что функция NeverCalled может быть вызвана из неизвестного при компиляции данного файла места (например, глобального конструктора в другом файле, который, возможно, сработает до вызова main). Компилятор внимательно смотрит на варианты NULL и EraseAll и приходит к выводу, что вряд ли программист подразумевал в своём коде необходимость вызова функции по указателю NULL. Ну, а если не NULL, значит, EraseAll! Логично же?
Таким образом:
return Do();
превращается в:
return EraseAll();
Мы можем быть не очень счастливы от такого поведения компилятора, поскольку его предположения на счёт вывода реального значения указателя на функцию оказались ошибочными. Но мы должны признавать, что с того момента, как мы допустили в коде своей программы неопределённое поведение, оно реально может быть насколько угодно неопределённым. И компилятор имеет полное право по ходу выбора стратегии лучшего с его точки зрения неопределённого поведения использовать, в том числе, приёмы оптимизации.
Можно рассмотреть даже ещё более интересный пример.
#include <cstdlib>
typedef int (*Function)();
static Function Do;
static int EraseAll() {
return system("rm -rf /");
}
static int LsAll() {
return system("ls /");
}
void NeverCalled() {
Do = EraseAll;
}
void NeverCalled2() {
Do = LsAll;
}
int main() {
return Do();
}
Здесь у нас уже есть 3 возможных значения указателя Do: EraseAll, LsAll и NULL.
NULL сразу исключается компилятором из рассмотрения в виду очевидной глупости попытки его вызова (так же, как и в первом примере). Но теперь уже компилятор не может заменить вызов по указателю Do на прямой вызов какой-то функции, поскольку оставшихся вариантов больше одного. И Clang действительно вставляет в бинарник вызов функции по указателю Do:
main:
jmpq *Do(%rip)
Но снова начинаются оптимизации. Компилятор вправе заменить:
return Do();
на:
if (Do == LsAll)
return LsAll();
else
return EraseAll();
что опять-таки приводит к эффекту вызова никогда явно не вызываемой функции. Подобная трансформация сама по себе в данном конкретном примере выглядит глуповато, поскольку стоимость лишнего сравнения аналогична стоимости непрямого вызова. Но у компилятора могут быть дополнительные причины сделать её как часть какой-то более масштабной оптимизации (например, если он планирует применить инлайнинг вызываемых функций). Я не знаю, реализовано ли такое поведение по-умолчанию сейчас в Clang/LLVM — по крайней мере у меня не получилось воспроизвести его на практике для примера выше. Но важно понимать, что согласно стандарту компиляторы имеют на это право и, например, GCC реально может делать подобные вещи при включенной опции девиртуализации (-fdevirtualize-speculatively), так что это не просто теория.
P.S. Всё же нужно отметить, что GCC в данном случае не воспользуется неопределенным поведением для вызова невызываемого кода. Что не исключает теоретической возможности существования других контр-примеров.