>
>
>
Пример проявления неопределённого повед…

Андрей Карпов
Статей: 671

Пример проявления неопределённого поведения из-за отсутствия return

Рассмотрим практический пример, когда отсутствие оператора return в функции, возвращающей значение, приводит к неопределённому поведению. Отличная демонстрация того, как ломается неправильный код, который благодаря везению мог ранее успешно работать многие годы.

Мы рассматриваем паттерн ошибки, который стандарт кодирования SEI CERT C++ описывает так: MSC52-CPP. Value-returning functions must return a value from all exit paths.

The C++ Standard, [stmt.return], paragraph 2 [ISO/IEC 14882-2014], states the following: 

Flowing off the end of a function is equivalent to a return with no value; this results in undefined behavior in a value-returning function.

Простой пример кода с ошибкой:

int foo(T a, T b)
{
  if (a < b)
    return -1;
  else if (a > b)
    return 1;
}

Программист забыл написать return 0, если два значения равны. Не все ветви выполнения возвращают значение, и это приводит к неопределённому поведению программы.

В общем-то думаю, всё понятно. Достаточно известный и распространённый паттерн ошибки. Проверяя с помощью анализатора PVS-Studio открытые проекты, мы регулярно находим благодаря диагностике V591 ошибки этого типа: примеры.

Если всё понятно и ошибки ищутся, то зачем, собственно, эта заметка? Вот тут мы подошли к самому интересному!

Дело в том, что программисты постоянно интерпретируют неопределённое поведение более узко, чем оно есть на самом деле. Неопределённое поведение при забытом операторе return часто воспринимается так: функция вернёт любое случайное значение. Более того, предыдущий опыт программиста может подтверждать, что это именно так.

Нет! Неправильно! Неопределённое — значит неопределённое. Нельзя предположить, что будет. Код, который работал одним образом, может начать работать по-иному.

Чтобы это продемонстрировать, я приведу немного отредактированную дискуссию (RU) с сайта RSDN.

Название темы: забавный крэш

Linux, libc-2.33, GCC 11.1.0, оптимизация -O2, следующий код падает с SIGSEGV:

#include <string>
#include <iostream>

bool foobar(const std::string &s)
{
    std::string sx = s;
    std::cout << sx << std::endl;
}

int main(int argc, char **argv)
{
    foobar(argv[0]);
    return 0;
}

/home/user/test$ g++ -O2 -std=c++11 ./test.cpp -o ./test && ./test

./test.cpp: In function 'bool foobar(const string&)':

./test.cpp:8:1: warning: no return statement in function returning non-void [-Wreturn-type]

8 | }

| ^

./test

Segmentation fault (core dumped)

А если поменять bool foobar на void foobar или добавить return false, то не падает.

Не падает и при использовании старого компилятора – GCC 7.5.0.

Кстати, std::string, как выяснилось, никак не влияет на ситуацию. Аналог этого кода на C также падает, будучи собранным g++.

#include <stdio.h>

bool foobar(const char *s)
{
    printf("foobar(%s)\n", s);
}

int main(int argc, char **argv)
{
    foobar(argv[0]);
    return 0;
}

/home/user/test$ g++ -O2 ./test.c -o ./test && ./test

./test.c: In function 'int foobar(const char*)':

./test.c:6:1: warning: no return statement in function returning non-void [-Wreturn-type]

6 | }

| ^

foobar(./test)

Segmentation fault (core dumped)

Если так: gcc -O2 ./test.c -o ./test && ./test, то всё хорошо.

Компилятор просто не генерирует инструкцию возврата из функции (ret)!

0000000000001150 <_Z6foobarPKc>:
 1150:  48 89 fe              mov   rsi,rdi
 1153:  48 83 ec 08           sub   rsp,0x8
 1157:  48 8d 3d a6 0e 00 00  lea   rdi,[rip+0xea6]  # 2004 <_IO_stdin_used+0x4>
 115e:  31 c0                 xor   eax,eax
 1160:  e8 cb fe ff ff        call  1030 <printf@plt>
 1165:  66 2e 0f 1f 84 00 00 00 00 00   cs nop WORD PTR [rax+rax*1+0x0]
 116f:  90                    nop

0000000000001170 <__libc_csu_init>:
 1170:  f3 0f 1e fa           endbr64 
 1174:  41 57                 push  r15

Спасибо пользователю ononim с сайта RSDN за такой интересный пример.

Вот такое яркое непривычное проявление неопределенного поведения.

Какие выводы можно сделать из этого? На мой взгляд их два:

  • Не пытайтесь предсказать, к чему приводит неопределённое поведение. Если вам кажется, что вы, например, знаете, к чему приведёт переполнение знаковой переменной типа int, то это самообман. Может быть очень необычное проявление.
  • Код, вызывающий неопределённое поведение, может перестать работать в любой момент. Используйте предупреждения компилятора и инструменты статического анализа кода (например, PVS-Studio), чтобы найти и исправить подобные опасные фрагменты кода.

Дополнительные ссылки: