awesome-everything EN
↑ Обратно к восхождению

Базовый CS с нуля

Неопределённое поведение

Суть Определённая ошибка обнаруживается и сообщается — машина возбуждает исключение. Неопределённое поведение не даёт гарантированного результата: чтение за концом массива, переполнение целого, опора на undefined. Никакая ошибка не возбуждается; программа продолжается с мусором.
◷ 22 min

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

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

Цель

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

1

Определённая ошибка обнаруживается и сообщается. В последних двух уроках каждый отказ был определённым: язык задаёт, что происходит. Деление на ноль, плохое преобразование типа, вызов метода у значения, у которого его нет — для каждого правило говорит «возбудить исключение». Машина обнаруживает условие и сообщает о нём.

Решающее свойство определённой ошибки — что ты узнаёшь. Исключение возбуждается, поток управления останавливается, печатается трассировка стека. Отказ громкий. Даже аварийное завершение — в этом смысле успех: машина поймала проблему и сказала тебе о ней. Определённая ошибка — это отказ, для которого у языка есть определённый, сообщаемый ответ.

2

Неопределённое поведение: язык не гарантирует никакого результата. Неопределённое поведение (часто сокращают до UB) — это противоположность. Это операция программы, для которой спецификация языка не даёт никакого гарантированного результата вообще. Язык не говорит «возбудить исключение». Он не говорит «вернуть ноль». Он не говорит ничего. Операция просто вне множества вещей, которые язык обещает определить.

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

3

Пример один: чтение за концом массива. Вспомни из Блока 09, что массив — это ряд смежных ячеек, а индекс — это смещение от базового адреса массива. Запрос индекса 5 у массива из 3 элементов означает вычисление адреса ячейки, которая не является частью массива.

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

4

Пример два: переполнение целого. Вспомни из Блока 01, что число хранится в фиксированном числе битов — скажем, 8 битов, которые могут хранить значения от 0 до 255. Теперь прибавь 1 к значению 255. Истинный результат, 256, нуждается в 9-м бите. 9-го бита нет. Старший бит теряется, и сохранённое значение становится 0 (или в знаковом типе — большим отрицательным числом). Это переполнение целого.

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

5

Пример три: опора на undefined в JavaScript. JavaScript всё же проверяет границы массива — чтение индекса 5 у массива из 3 элементов не приводит к аварийному завершению; оно даёт особое значение undefined. Это определено. Опасность в стиле неопределённого поведения — это то, что происходит, когда твой код затем использует это undefined, не замечая.

undefined + 1 — это NaN («не число»). NaN молча распространяется через каждое вычисление, которого касается: NaN * 2 — это NaN, NaN > 0 — это false. Никакое исключение не возбуждается ни на одном шаге. Твоя программа продолжает работать, вычислять и сохранять NaN там, где должно быть настоящее число — неправильный ответ, тихо распространяющийся, ровно так, как распространяется истинный результат неопределённого поведения. Отказ молчаливый, хотя каждый отдельный шаг «определён».

6

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

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

10
[0]
20
[1]
30
[2]
??
[3]
Массив из 3 элементов в смежных ячейках: допустимые индексы 0, 1, 2. Индекс 3 (розовым) указывает на одну ячейку за концом. Определённая ошибка проверяет границу и возбуждает исключение. Неопределённое поведение читает ячейку [3] всё равно — какие бы остаточные биты там ни были — без возбуждения ошибки.
Разбор примера

Одно и то же чтение вне диапазона, два языка, два исхода.

Программа хранит массив из 3 элементов prices = [10, 20, 30] (допустимые индексы 0, 1, 2), и баг вычисляет индекс как 3 — на один за концом.

Язык A — определённая ошибка. Рантайм хранит длину массива рядом с ним. Перед чтением он проверяет: меньше ли 3, чем длина 3? Нет. Рантайм возбуждает исключение: Error: index out of range. Поток управления останавливается. Печатается трассировка стека, указывающая на точную строку. Программист видит отказ при первом тестовом запуске и чинит индекс. Громко. Найдено. Исправлено.

Язык B — неопределённое поведение. Рантайм не делает проверки границ. Он вычисляет адрес ячейки [3] — базовый адрес плюс 3 размера элемента — и читает её. Эта ячейка никогда не была частью массива; она хранит остаточные биты, скажем число 48291, от каких-то несвязанных данных. Программа читает 48291, как если бы это была настоящая цена. Никакого исключения. Никакой трассировки. Программа продолжается, возможно прибавляя 48291 в сумму, возможно сохраняя его. Молча. Отгружено. Портит данные неделями позже.

Тот же баг, та же строка. Определённая ошибка превратила его в пятиминутную правку; неопределённое поведение превратило его в производственный инцидент без трассировки, по которой можно следовать.

Почему это работает

Зачем языку вообще оставлять поведение неопределённым? Скорость. Проверка границ — это лишнее сравнение перед каждым чтением массива; проверка переполнения — лишняя работа после каждого сложения. Низкоуровневые языки, построенные для максимальной производительности, оставляют эти проверки в стороне и объявляют непроверяемые случаи неопределёнными — программист обещает никогда их не запускать, и в обмен машинный код выполняется без накладных расходов на проверку. Это намеренный размен: чистая скорость в обмен на потерю страховочной сетки. Более высокоуровневые языки обычно делают противоположный выбор и сохраняют проверки.

Частая ошибка

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

Практика 0 / 5

Отказ, который машина обнаруживает и сообщает, возбуждая исключение — это какой вид? Введи 1 за определённую ошибку, 2 за неопределённое поведение.

8-битное беззнаковое значение хранит 255. Программа прибавляет 1. Истинный результат 256 нуждается в 9-м бите, которого нет. Какое значение сохраняется (к чему заворачивается результат)?

У массива 4 элемента, допустимые индексы от 0 до 3. Индекс, который ВНЕ границ ровно на единицу (одна ячейка за концом) — это какой?

Неопределённое поведение опаснее определённой ошибки по одной главной причине. Введи 1, если причина — «отказ молчаливый — никакая ошибка не возбуждается», или 2, если причина — «оно всегда аварийно завершает компьютер».

В JavaScript чтение индекса 9 у массива из 3 элементов даёт значение undefined (не аварийное завершение). Затем вычисляется undefined + 1. Результат — NaN. Возбуждает ли вычисление undefined + 1 исключение? Введи 1 за да, 0 за нет.

Проверь себя
Викторина

Что делает неопределённое поведение опаснее определённой ошибки?

Итог

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

Продолжить восхождение ↑Отладка как рассуждение
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.