Страницы

Язык C безопаснее Java из-за неопределённого поведения

Неопределённое поведение в стандарте языка C принято считать злом, делающие программы на C более опасными, но полезной стороной которого является свойство, позволяющее компилятору создавать более эффективный код. Надо сказать, что это верно лишь отчасти, но указать на это, похоже, некому, так как обильное количество материалов на эту тему пересказывает, по большому счёту, одну и ту же точку зрения. А тут есть что возразить.

Порой, когда читаешь очередной опус об «интересном» поведении компилятора при включённых оптимизациях, складывается впечатление, что где-то в стандарте написано: «для программы, достигшей ситуации, которая зачислена стандартом как неопределённая, наилучшим выбором поведения будет то, которое будет более неадекватным». Но можно смело утверждать, что такой записи в документе нет. По крайней мере до тех пор, пока не вышла новая версия стандарта.

Неопределённое поведение возникает из-за самоустранения создателей стандарта от принятия сложных решений насчёт языка и предоставления свободы создателю компилятора для выбора лучшего решения в соответствии с его возможностями. Распорядиться свободой можно по-разному. Можно предложить вариант, позволяющий быстрей находить ошибки и предотвращающий потенциально опасное выполнение некорректной программы, или можно забить на самую важную составляющую языка программирования ради погони за очками — скоростью программы. Если судить по результату, то авторы наиболее популярных компиляторов предпочитают второй вариант. Это вполне закономерно, потому что, в конце концов, надёжность программ — величина эфемерная и, похоже, волнует людей не так уж и сильно, а вот скорость — величина легко измеримая и понятная, хорошо подходящая для специальных соревнований.

Но даже если создатель компилятора выберет наилучший вариант для воплощения неопределённого поведения, то может ли это быть лучше, чем реализация по полной спецификации языка, не предусматривающей двусмысленности? Тут мы подходим к тому, что может быть хуже неопределённого поведения — плохо определённое поведение. И если 1-е предоставляет свободу, то 2-е вынуждает выбрать тот вариант, который сложно назвать лучшим. Причины же выбора не лучшего решения для дизайна языка тоже понятны — отсутствие однозначно лучшего решения с разных точек зрения.

Главная ошибка разоблачителей неопределённого поведения заключается в подмене причины и следствия. Для проработанной спецификации языка программа может зайти в состояние, при котором проявляется неопределённость спецификации, преимущественно, из-за ошибки в программе. Изобличатели же напирают на то, что наоборот — дополнительные ошибки программы возникают из-за неопределённости в спецификации. В качестве аргумента приводятся примеры кода, в которых благодаря удачному стечению обстоятельств ошибки проявляют себя некритично для программы в целом или даже идеально вписывается в задумку. В результате вместо устранения ошибок в коде такие разработчики предлагают устранить неопределённое поведение. Беда в том, что такое устранение зачастую приводит не к улучшенной диагностике ошибок, а к ухудшенной, потому что нередко наилучшая диагностика ошибок оказывается слишком сложной или накладной, что не позволяет в общем случае заменить неопределённость в стандарте диагностикой ошибок.

В качестве примера можно рассмотреть арифметическое переполнение чисел со знаком(int, long int, ...). В стандарте Си поведение программы в таком случае не определено, что при наличия такой воли позволяет аварийно остановить программу, предотвратив потенциально опасное исполнение некорректной программы, а при отладке помогает выявить ошибку. Такого поведения можно добиться в компиляторах gcc и clang, используя опцию -ftrapv. Более того, если для проверки программы на Си используется развитый статический анализатор, то в случае удачи выявление ошибки даже не потребует тестирования. В Java компилятор обязан следовать стандарту и произвести код, который молча бы проглотил переполнение, отбросив старшие разряды, как это следует сделать в дополнительном коде. Статический анализатор для Java также не должен трактовать как ошибку возможность переполнения, по крайней мере при адекватных настройках по умолчанию. Таким образом неопределённого поведения в данном случае нет, а ошибки в программе остаются, требуя больших усилий для их выявления. И спецификация Си позволяет автоматическими средствами их диагностировать, а Java — нет.

Почему бы не предоставлять выбор с помощью неопределённого поведения, а гарантировать проверку на уровне языка? Цена гарантированной проверки в общем случае оказывается неоправданно большой, поэтому там где борются не с ошибками в программе, а с неопределённым поведением, до сих пор выбирают молчаливое переполнение[0].

Другим примером может служить не специфицированные значения динамически выделенных переменных. Си позволяет диагностировать использование не инициализированной переменной как ошибку, в то время как в Java это нормальная ситуация, потому что стандарт гарантирует зануление переменных, что далеко не всегда является правильным значением. Поскольку компилятор не имеет возможности спросить, что имел ввиду программист, то выходит, что и в этой ситуации Java не позволит выявить ошибку, в то время как Си — позволит.

Поскольку неопределенное поведение в стандарте предоставляет свободу, то можно создать компилятор, соответствующий стандарту, но на все неоднозначные места предоставляющий наиболее адекватную ошибкоустойчивую реакцию, которая может быть не хуже, а местами и лучше, чем реакция, предусмотренная более строго определенным стандартом Java.

Но это общие размышления на тему потенциальных возможностей, а что на практике? На самом деле и тут всё относительно неплохо. Хотя и с опозданием, но важность корректности была осознана создателями распространённых компиляторов Си. Например, gcc и clang предоставляют опции командной строки

-fsanitize=address 
-fsanitize=undefined -fno-sanitize=alignment -fsanitize-undefined-trap-on-error

которые дают возможность предотвращать потенциально опасное поведение программы при проявлении ошибок. По-видимому, авторы компиляторов рассматривают эти возможности только как отладочные, поэтому накладные расходы таких проверок весьма велики, но если производительность программы не довлеет над разработчиком, то их можно использовать и для выходного продукта, особенно, если ограничиться последними тремя.

Таким образом, неопределённое поведение в стандарте языка Си — это не только то, что позволяет генерировать более быстрый код, но и добиваться более высокой защищённости от ошибок, если понимать с чем имеешь дело. Проблемы языка Си находятся совсем не в неопределённом поведении, а в других его особенностях.

Напомню, что главная ошибка разоблачителей и борцов с неопределённым поведением заключается в том, что они путают причину и следствие. Они считают, что неопределённое поведение приводит к ошибкам в программе, хотя на самом деле всё наоборот — это ошибки в программе приводят к поведению, не определённому в стандарте.


[0] Нет гарантированной проверки арифметического переполнения и в большинстве Oberon-трансляторов, если кому интересно.

4 комментария:

  1. Ну это же в корне не верный подход. Безопасности мало не бывает. И в этом смысле Си позволяет такие выкрутасы, что иногда просто страшно становится, как вообще это может работать. Сложность до добра не доведёт.

    ОтветитьУдалить
    Ответы
    1. Какой подход? Есть язык, есть его свойства, которые бы неплохо понимать. Потому что без понимания люди делают ложные выводы и приходят к неверным решениям. Заметка об этом, а не о призывах программировать на Си с "в корне не верным подходом".

      Удалить
  2. Вообще говоря, на мой взгляд, современный язык ОБЯЗАТЕЛЬНО должен обладать одним свойством: НИКАКИХ УМОЛЧАНИЙ! Даже преобразование целого в дробное в выражениях - делать явно!
    Не должен язык за программиста принимать решения.
    И тогда ВСЕ ошибки будут ошибками программиста!

    ОтветитьУдалить
    Ответы
    1. Наверно, речь не о всех умолчаниях, потому что язык всегда вводит некие правила по умолчанию. Например, данные локальных переменных обычно размещаемых на стеке, "освобождаются" по умолчанию и это замечательно. Что толку для надёжности, если все ошибки будут ошибками программиста, сделанными не по умолчанию, если их всё равно будет много. Ошибкоустойчивость, на мой взгляд, не уместится в упрощённых правилах.

      Удалить