Читать книгу Код. Культура, скомпилированная в байты - - Страница 13

ЧАСТЬ I: ФИЛОСОФИИ
Глава 2. Синтаксис как мировоззрение
2.3. Обработка ошибок как отношение к неудаче

Оглавление

Ошибки неизбежны.

Файл не найден. Сеть недоступна. Пользователь ввёл текст вместо числа. Память закончилась. Диск заполнен. Соединение разорвано. Сервер не отвечает. Данные повреждены.

Как язык программирования предлагает справляться с неизбежным? Ответ на этот вопрос многое говорит о философии языка. Это не техническая деталь – это мировоззренческая позиция.

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

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

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

Go унаследовал этот подход, но сделал его более явным и более настойчивым. Функции в Go могут возвращать несколько значений, и идиоматический паттерн – возвращать результат и ошибку вместе:

result, err:= someFunction ()

if err!= nil {

    return err

}


Эти три строчки – if err!= nil – повторяются в Go-коде десятки, сотни раз. Они стали мемом, предметом шуток и критики. Противники Go называют это многословием, шумом, церемонией, которая засоряет код и отвлекает от сути.

Создатели Go видят в этом добродетель.

«Errors are values», – написал Роб Пайк в статье 2015 года. Ошибки – это значения. Не исключительные ситуации, не что-то особенное, а обычные значения, с которыми можно работать, которые можно передавать, комбинировать, оборачивать, анализировать. Философия Go в том, что ошибки – нормальная часть потока выполнения. Они не исключительны. Они ожидаемы. И код должен явно показывать, как с ними обращаются.

Когда вы видите if err!= nil в коде на Go, вы видите место, где программист подумал об ошибке. Принял решение. Обработал или передал дальше. Это не скрыто где-то в стеке вызовов, не спрятано в блоке catch на другом конце файла. Это здесь, на виду, часть основного потока кода.

Философия исключений, реализованная в Java, Python, C#, C++, противоположна. Исключения – это механизм для ситуаций, которые не должны происходить в нормальном потоке. Файл должен открыться. Сеть должна работать. Память должна выделиться. Если что-то пошло не так – это исключение из нормы. Выбрасываем объект исключения, и он «всплывает» по стеку вызовов, пока кто-то его не поймает.

Эта модель позволяет писать «счастливый путь» – код, который описывает, что происходит, когда всё хорошо. Обработка ошибок отделена от основной логики, вынесена в блоки try-catch. Основной код становится чище, намерение – яснее. Вот что программа делает. А вот, отдельно, что происходит, если что-то идёт не так.

Но есть цена.

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

Легко забыть, какие исключения может выбросить функция. Легко пропустить ошибку, которая тихо поднимется наверх и уронит программу в неожиданном месте. Java пыталась решить эту проблему через checked exceptions – обязательное объявление исключений в сигнатуре функции. Но программисты возненавидели это и научились обходить: ловить исключение, оборачивать в RuntimeException, выбрасывать снова. Добровольно-принудительная проверка оказалась хуже, чем никакой проверки.

Rust и Haskell предлагают третий путь – алгебраические типы для ошибок. Тип Result в Rust может содержать либо успешное значение Ok, либо ошибку Err. Тип Option может содержать значение Some или ничего None. И компилятор требует обработать все случаи.

Это кажется похожим на коды возврата, но есть критическое отличие: компилятор не позволит игнорировать ошибку. Если функция возвращает Result, программист обязан что-то с ним сделать. Обработать оба варианта через pattern matching. Или явно передать ошибку выше с помощью оператора?. Или явно проигнорировать с помощью unwrap – но тогда программа упадёт с паникой, если ошибка произойдёт. Каждый выбор явен. Каждый выбор – осознанное решение.

Философия Rust: ошибки – это данные, у которых есть тип. Не исключения, которые могут вылететь откуда угодно. Не коды, которые легко проигнорировать. Структурированные данные, которые компилятор умеет отслеживать. Ошибка становится частью сигнатуры функции, частью контракта. Глядя на тип функции, вы знаете, может ли она завершиться неудачей и какого рода неудачей.

Но есть и радикально иной подход – философия «пусть падает», рождённая в Erlang.

Erlang был создан в Ericsson в середине 1980-х для телекоммуникационных систем. Телефонные станции должны работать всегда. Не «почти всегда». Не «99% времени». Всегда. Требование было сформулировано как «пять девяток» – 99.999% доступности, что означает не более пяти минут простоя в год. А в некоторых случаях говорили о «девяти девятках» – 99.9999999%, что означает доли секунды простоя за десятилетия.

Как достичь такой надёжности? Джо Армстронг, один из создателей Erlang, пришёл к парадоксальному выводу.

«Мы были, кажется, единственными людьми в мире, проектировавшими систему, которая могла бы восстанавливаться после программных ошибок», – вспоминал он. Команда Erlang постоянно задавала один и тот же вопрос: «Что произойдёт, если это сломается?» И почти всегда получала ответ: «Наша модель предполагает отсутствие сбоев».

Армстронг понял: нельзя предотвратить все ошибки. Программы пишут люди, люди ошибаются. Оборудование выходит из строя. Сеть рвётся. Вместо того чтобы пытаться предотвратить каждую возможную ошибку – что невозможно – нужно строить систему, которая выживает при ошибках.

Отсюда философия «let it crash» – пусть падает. Не пытайся обработать каждую ошибку. Пусть процесс упадёт. Другой процесс – супервизор – заметит падение и перезапустит упавший. Процессы изолированы друг от друга, не делят память, общаются только сообщениями. Падение одного не затрагивает другие.

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

Армстронг описывал это так: представьте идеальную организацию, где каждый сотрудник делает свою работу. Если сотрудник не справляется, его увольняют и нанимают нового. Менеджеры следят за сотрудниками и при необходимости заменяют их. Директора следят за менеджерами. И так далее. Система остаётся работоспособной, даже если отдельные части выходят из строя.

Четыре философии, четыре ответа на один вопрос.

Си и Go говорят: ошибки – нормальная часть жизни, проверяй их явно, каждый раз. Это дисциплина, это внимательность, это ответственность программиста.

Java и Python говорят: ошибки – исключения из нормы, обрабатывай их отдельно, чтобы не засорять основной код. Это разделение ответственности, это чистота намерения, это фокус на «счастливом пути».

Rust и Haskell говорят: ошибки – структурированные данные, пусть компилятор следит, чтобы ты их не пропустил. Это безопасность через типы, это гарантии на этапе компиляции, это невозможность забыть.

Erlang говорит: ошибки неизбежны, строй систему так, чтобы падение части не убивало целое. Это архитектура отказоустойчивости, это принятие несовершенства мира, это дизайн для реальности.

Каждый подход формирует код и мышление.

Программист на Go привыкает видеть обработку ошибок на каждом шагу – это часть текстуры кода, как знаки препинания в тексте. Программист на Python привыкает думать об исключениях как о потоке, который идёт параллельно основному, – иногда пересекаясь с ним в блоках try-catch. Программист на Rust привыкает к тому, что компилятор не даст забыть об ошибке, – это строгий, но справедливый учитель. Программист на Erlang привыкает думать о системах, не о функциях – о том, как части взаимодействуют и как восстанавливаются после сбоев.

Выбор модели обработки ошибок – это выбор отношения к неудаче. Неудача – это то, что нужно проверять постоянно? Или исключительное событие? Или типизированные данные? Или нормальная часть жизни системы?

Ответ определяет не только синтаксис. Он определяет архитектуру. Он определяет культуру программирования на языке. Он определяет, как программисты думают о надёжности и что считают «правильным» кодом.

Код. Культура, скомпилированная в байты

Подняться наверх