От переводчика:
Предлагаю вашему вниманию перевод двух постов из блога John Regehr. Я решил объединить их в одной публикации потому, что, во первых, они имеют небольшой объём, и, во-вторых, второй пост является продолжением первого, и является ответом на комментарий к первому посту на Hacker News.

Ссылка на первый пост
Ссылка на второй пост

image

Часть 1. Неопределённое поведение != Небезопасное программирование


Неопределённое поведение (UB) в C и C++ представляет собой опасность для разработчиков, особенно если код работает с недоверенными данными. Менее известно, что неопределённое поведение существует в промежуточном представлении (IR) большинства оптимизирующих AOT компиляторов. Например, LLVM IR имеет значение undef и «отравленные» значения в дополнение к взрывоопасному UB языка С. Когда люди начинают беспокоиться об этом, типичная реакция такова: “Что? LLVM IR так же плох, как и C!” Эта статья объясняет, почему считать так неверно.

Неопределённое поведение — это результат проектного решения: отказ от систематизированного обнаружения программных ошибок на определённом уровне системы. Ответственность за то, чтобы таких ошибок не было, возлагается на более высокий уровень абстракции. Например, очевидно, что безопасный язык может быть скомпилирован в машинный код, но также очевидно, что небезопасность машинного кода возлагает все бескомпромиссные высокоуровневые гарантии на реализацию языка. Swift и Rust компилируются в LLVM IR; некоторые их гарантии безопасности осуществляются динамическими проверками в генерированном коде, другие производятся с помощью проверки типов и не представлены на уровне LLVM IR. Другими словами, UB на уровне LLVM не обнаруживается и является проблемой кода из безопасного подмножества языков Swift и Rust. Даже C может быть использован безопасно, если какой-либо инструмент в среде разработки убедится в отсутствии UB. Проект L4.verified делает именно это.

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

func add(a : Int, b : Int)->Int {
  return (a & 0xffff) + (b & 0xffff);
}

Несмотря на то, что реализация Swift должна захватывать исключение при переполнении целого, компилятор видит, что переполнение невозможно и генерирует такой LLVM IR:

define i64 @add(i64 %a, i64 %b) {
  %0 = and i64 %a, 65535
  %1 = and i64 %b, 65535
  %2 = add nuw nsw i64 %0, %1
  ret i64 %2
}

Не только операция сложения с проверкой переполнения была заменена на операцию сложения без проверки, но также инструкция сложения была помечена атрибутами nsw и nuw, которые обозначают, что и знаковое, и беззнаковое переполнение не определено. Сами по себе эти атрибуты не приносят выгоды, но могут вызвать дополнительные оптимизации, если функция будет заинлайнена. Когда набор тестов Swift компилируется в LLVM, только одна инструкция сложения из восьми имеет атрибут, указывающий, что переполнение целого не определено.

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

В целом, неопределённое поведение является абстракциями, видимыми для программиста, которые представляют агрессивный и опасный компромисс: пожертвовать корректностью программы в обмен на производительность и на простоту компилятора. С другой стороны, UB на нижнем уровне системы, таком, как машинный код и промежуточное представление компилятора, является внутренним проектным решением, которое не имеет никакого влияния на то, как воспринимает систему программист. Такой тип UB просто требует от нас принять тот факт, что проверки безопасности могут быть удалены из соответствующих им небезопасных операций для достижения эффективности выполнения программы.

Часть 2. Всегда ли выразительный язык программирования имеет неопределённое поведение?


На Hacker News кто-то так прокомментировал мой предыдущий пост:

ЕМНИП, теорема о неполноте Гёделя подразумевает, что любой язык имеет по крайней мере в некоторой степени неопределённое поведение.

Давайте посмотрим на это утверждение, помня о том, что неполнота и неразрешимость — довольно сложные вещи. Много лет назад я с удовольствием прочитал теорему Гёделя: "неполнота приводит к злоупотреблениям" (мне несомненно нужно её перечитать).

Во-первых, существуют языки программирования без UB, например, такой язык, что любая программа на нём печатает «7». Что бы ни означало UB (мы пока не дали формального определения), ясно, что язык, который всегда печатает «7» его не имеет.

Также есть полезные языки, не имеющие UB, такие, как язык выражений, вычисляющих элементарные функции над вещественными числами в формате IEEE. О таких языках легко рассуждать, потому что они не Тьюринг-полные: все вычисления завершаются и мы должны убедиться, что они завершаются с определённым результатом.

Но на самом деле, комментатор с HN имел в виду теорему Райса: «любое нетривиальное свойство языка, распознаваемого машиной Тьюрига, неразрешимо.» Вследствие этого, если f() — некоторое произвольное вычисление, мы не можем в общем случае решить, вызывает ли программа неопределённое поведение:

main() {
  f();
  return 1 / 0; // UB!
}

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

Сейчас вернёмся к оригинальному комментарию на HN: он был о теоремах неполноты, не о задаче останова. Я даже не знаю, что сказать, я не вижу, как теорема Гёделя имеет отношение к неопределённому поведению языков программирования.

Комментарии (11)


  1. mayorovp
    24.01.2018 11:33

    Предложенный способ избавления от UB не избавляет от всех UB. Остаются UB растущие из многопоточности и модели памяти, для их устранения нужно либо полностью запретить многопоточность, либо ввести в язык понятие владения (как в Rust). До тех пор пока в языке есть многопоточность и разделяемая память — UB не избежать, потому что корни UB — в железе.


    Но даже в однопоточной программе избавиться от UB не так-то просто: UB потенциально может дать любая операция обращения по указателю, если только в языке нет способа гарантировать что указатель всегда указывает на валидный объект, т.е. ручное управление памятью в таком языке будет под запретом.


    Наконец, если все получится и мы сделаем язык без UB — то задача оптимизации программы на этом языке будет сводиться к проблеме останова...


    1. 32bit_me Автор
      24.01.2018 11:57

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


      1. mayorovp
        24.01.2018 11:59

        Автор пишет: «Но это проблема чисто практическая, теоретически проблемы не существует.»

        Но это не так. Проблема синхронизации потоков рассматривается не только практиками, она и в теории существует.


        1. 32bit_me Автор
          24.01.2018 12:02

          Это так, да.


  1. aamonster
    24.01.2018 11:38
    +1

    Часть 1 — какой-то странный поток сознания.
    Неужели у кого-то были претензии к наличию UB в LLVM? Да ладно. Казалось бы, это промежуточный язык, в котором может быть что угодно — лишь бы он позволял эффективно перевести код на ЯВУ (в котором UB есть) в машинный.


    Опять же, заголовок откровенно желтушный. UB — это безусловно небезопасное программирование. Вся суть UB сводится к тому, что его быть не должно, и компилятор может на это полагаться. Так что если вы видите UB в безопасной (а значит, корректной) программе — значит, его обходит какой-нибудь if или ещё что случается, чтобы мы не попали в эту ситуацию. There is no spoon.


    1. 32bit_me Автор
      24.01.2018 12:02

      Здесь речь вот о чём. Допустим, что в программе на определённом ЯВУ нет UB, и каждая операция, например, сложения имеет проверку на переполнение. Но оптимизирующий компилятор может выбросить эти проверки, если он видит, что переполнение невозможно, и сгенерировать код на IR, в котором есть UB.
      И в этом ничего плохого нет, поэтому и получается, что UB != Unsafe.


      1. mayorovp
        24.01.2018 12:25

        Тут терминологическая ошибка. "Есть UB" != "Есть операция имеющая UB-случаи"


        Простейший пример: разыменование указателя имеет два UB-случая: мусор в указателе и пустой указатель. Но в выражении *(new int) нет никакого UB, потому что оператор new всегда возвращает корректный непустой указатель.


      1. aamonster
        24.01.2018 13:54

        Это я понимаю. UB — это такая штука, которой в программе не должно быть, и гарантировать это должен программист. В случае LLVM-кода — программист даёт программу без UB, фронтэнд-компилятор делает из неё LLVM-код без UB (к примеру [насколько я понял], если на входе программа без UB — LLVM-код не сгенерирует poison value), дальше он транслируется в машинный код. На каждом этапе UB нет, или мы имеем GIGO.


        Иначе говоря — не может быть UB в LLVM-коде, если во входном коде его нет.


        1. 32bit_me Автор
          24.01.2018 13:57

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


          1. aamonster
            24.01.2018 14:44

            Именно. Он получил на входе код, не генерирующий UB — выдал на выходе код, не генерирующий UB.
            И если даже где-то в этом коде инструкция, которая может привести к UB — то транслятор следующего уровня может полагаться на то, что она в таком виде не вызовется, и смело оптимизировать.


            Вполне могу себе представить даже явное добавление фронтэндом таких инструкций — просто чтобы показать бэкэнду "вот это условие точно не выполняется".


    1. Jef239
      24.01.2018 13:17
      -1

      Часть UB для конкретного процессора и конкретного компилятора вполне определена. Это такой compiler specific, который в стандарте прописан как UB, чтобы не сковывать авторов компилятора. Если мы пишем драйвер для конкретного процессора и конкретной ОС — вполне безопасно будет использовать некоторые UB для ускорения обработки прерываний.

      Собственно об этом и статья: большинство UB — это всего лишь незадокументированное поведение. Ну как пример: "If the implementation does not support negative zeros, the behavior of the &, |, ^, ~, <<, and >> operators with operands that would produce such a value is undefined.". Такой код будет процессорно-специфичным (непереносимым), но вполне безопасным для конкретного процессора и компилятора.