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

PHP — именно такой язык, и я использую его каждый день — как профессионально в @trivago, так и лично. Это также язык, на который я хочу поближе взглянуть в контексте нулевых ссылок. Однако нам нужно сначала уточнить, что на самом деле представляют собой нулевые ссылки, прежде чем мы сможем копнуть глубже.

Название этой статьи — дань уважения шедевру Серджио Мартино Дело о сказке о Скорпионе.

Что имел в виду сэр Хоар, когда говорил о нулевых ссылках.

Он не имел в виду тип или значение null, как мы можем найти его во многих языках программирования, он конкретно говорил о нулевых указателях, особенно об указателях нулевых объектов.

«В чем разница между нулевым указателем и оборванным указателем?»
Очень хороший вопрос на собеседовании!

Разыменование нулевого указателя, то есть попытка доступа к данным или данным, хранящимся в этом месте в памяти (обычно это буквально 0), обычно заканчивается какой-то ошибкой. Тип ошибки зависит от языка программирования и, например, не определен в C и приводит к печально известному NullPointerException в Java.

Какая разница? Нуль есть ноль!

Jain (да и нет, как мы говорим по-немецки), нулевая ссылка может использоваться как типы, допускающие значение NULL, или опциональные типы — подробнее об обоих позже — и используется как таковая во многих программах. Это совершенно правильно и правильно, а не ошибка на миллиард долларов! Рассмотрим следующий простой пример программы на Java:

У нас есть подпрограмма f с параметром x, который ограничен типом Integer и возвращает сумму xи 42. Вы наверняка уже заметили, что f вызывается с нулевым значением в подпрограмме main. Это будет компилироваться без каких-либо предупреждений или ошибок, однако выполнение программы приводит к NullPointerException во время выполнения из-за операции добавления в строке 7. В Java, как и в большинстве других языков, поддерживающих пустые ссылки, любые объект, несмотря на ограничения, может быть нулевым в любой момент времени, и средство проверки типов не будет жаловаться на это.

Это эквивалентная программа на PHP, и она компилируется без каких-либо предупреждений или ошибок. Это также приводит к ошибке во время выполнения, как и в Java, если быть точным, это приводит к необработанной ошибке TypeError в строке 4. Разница здесь в том, что в Java ошибка во время выполнения возникает, когда мы пытаемся вызвать добавьте к x, тогда как в PHP ошибка времени выполнения возникает в тот момент, когда мы вызываем подпрограмму, потому что null не является частью аннотированных типов для x.

Следовательно, в PHP невозможно, чтобы ограниченное значение было нулевым, но при этом проходило все проверки типов. Вы можете возразить сейчас, что я создаю пример в пользу PHP, поскольку переменные всегда могут содержать любое значение. Это часть динамической природы PHP, однако мы не говорим о нулевых присваиваниях в непосредственной близости в пределах одной области видимости.

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

Это ошибка на миллиард долларов!

При этом мы можем создать такую ​​же ситуацию в PHP, рассмотрим следующий пример:

Выполнение этого кода прервется с ошибкой TypeError, поскольку вызванная подпрограмма toInt не возвращает int, как того требует ограничение типа возвращаемого значения; еще раз обратите внимание, что строгие типы не включены. Никаких предупреждений или ошибок компилятора не выдавалось, а значение в инкапсулированном свойстве изменялось без уведомления, нарушая инкапсуляцию и инварианты класса.

Это ошибка на миллиард долларов!

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

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

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

Теперь давайте рассмотрим другой пример подпрограммы коллекции или репозитория. Рассматриваемый код получает указание получить данные, соответствующие идентификатору 42. Значение находится в допустимых пределах 1 ≤ x ≤ 2³² − 1 (быть за пределами было бы исключительным случаем), но данных нет. можно найти для 42. Обратите внимание, что удаление внутри коллекции допустимо и что мы имеем дело с разреженной коллекцией.

Многие теперь предпочтут генерировать исключение, в основном возвращаясь к использованию goto для какого-либо символа — читайте блок try-catch в контексте исключений — в любом другом месте программы. , или — если такой символ не был определен — дать программе аварийный сбой. Использование исключений, а также goto для потока управления является плохой практикой и приводит к созданию программ, которые трудно понять.

Следовательно, указание отсутствия чего-либо через исключения, а также goto кажется неуместным, особенно если учесть, что отсутствие блока try-catch приводит к завершению процесса. Ведь коллекция выполнила свою обязанность по поиску 42 и пришла к результату; результат - просто ничего, что красиво переводится в ноль.

Если мы используем null, у нас есть обнуляемый тип ?T, который отличается от нулевой ссылки. В нашем контракте четко определено, что процедура find приводит либо к нулевому значению (?), либо к значению ограниченного типа T. Вспомните, в случае нулевой ссылки наш тип был бы T, а не ?T, но все же возможно, что вызывающая сторона получит null, что, в конце концов, является нарушением нашего контракта.

Звучит неплохо, но мне все равно приходится везде проверять наличие нулей, как в случае нулевой ссылки!

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

С другой стороны, исправление всех частей для учета типа, допускающего значение NULL, является гораздо более сложным, и, вероятно, это причина, по которой некоторые выступают за исключения над типами, допускающими значение NULL. Опять же, разве мы не все небрежны и склонны что-то забывать? Мы люди, не так ли? Никто не создает идеальный код всегда — несмотря на то, что некоторые утверждают, что делают это — и никто не способен продумать все, всегда и везде.

В этот момент те части аудитории, которые в той или иной мере соприкоснулись с функциональным программированием, будут кричать может быть, монада». И действительно, я уже несколько раз упоминал типы опций, не вдаваясь в подробности. Тип параметра или, может быть, монада подобны проверенному исключению в Java. Это все еще может быть неясным для среднего разработчика PHP, позвольте мне сначала объяснить последнее, чтобы потом вернуться к первому.

Проверенные исключения — это исключения, являющиеся частью сигнатуры подпрограммы. Рассмотрим следующий гипотетический PHP-код:

function f() throws Exception {}

Любой код, вызывающий эту подпрограмму, должен либо заключать ее в блок try-catch для этого конкретного исключения или любого из его суперклассов, либо расширять собственную сигнатуру, чтобы генерировать ее — или любой из ее суперклассов — также. Эти ограничения применяются компилятором, как и ограничения типов в цепочке наследования.

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

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

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

Чтобы не потерять информацию о типе, мы должны создать специальный класс опций — реализацию типа в особом случае — для каждого возможного типа в полной программе. Хотя это приводит к лучшему удобству использования, это также приводит к худшей ремонтопригодности и чрезвычайно увеличивает накладные расходы на эти варианты. Это проблема, которая постоянно возникает в PHP из-за простой системы типов и вышеупомянутого отсутствия поддержки дженериков.

Нуль или не нуль, вот в чем вопрос!

Это сводится к вопросу, удобно ли использовать исключения для потока управления или нет. Типы, допускающие значение NULL, и типы параметров почти всегда являются лучшим выбором. Однако они являются тяжелым бременем в PHP, и его не следует недооценивать. Это связано с тем, что сейчас можно только объявлять обнуляемые типы, но не работать с ними напрямую безопасным образом. Для них всегда требуется защитное условие if, которое излишне загрязняет код.

Попытки улучшить эргономику типов, допускающих значение null, были предприняты задолго до того, как типы, допускающие значение null, появились в PHP — с помощью Nullsafe Calls RFC. Вызовы Nullsafe в сочетании с интеллектуальной проверкой типов приводят к реализации перехвата всех нулевых объектов без накладных расходов на типы опций или реальных реализаций нулевых объектов. Кроме того, им не требуется поддержка дженериков для правильного сохранения информации о типах. На самом деле, есть языки, которые уже усовершенствовали это:

Это тот же пример, который у нас был изначально, но написанный на Цейлоне, где мы должны явно разрешить x быть нулевым. Программа не компилируется, если мы не объявляем x допускающим значение NULL, но вызываем ее с нулевым значением, она также не компилируется, если мы вызываем plus непосредственно для x без оператора вызова nullsafe ?., который предшествует вызову метода. Конечно, накладные расходы на выполнение таких глубоких самоанализов исходного кода невозможны в интерпретируемом языке, таком как PHP. Однако инструменты статического анализа уже способны добавить это.

Заключение

Нулевые ссылки - неприятная проблема. Тем не менее, они не являются реальной проблемой в программном обеспечении PHP, поскольку фактические ссылки используются редко. Опытные разработчики не используют их, потому что знают побочные эффекты, а новички избегают их, потому что в конце концов не понимают, что они делают.

Типы, допускающие значение NULL, или тип и значение null, очень полезны в языках программирования и необходимы для обозначения отсутствия значимого значения. Неважно, как они называются (null, nil, ?, ничего*, …) или если они не доступны напрямую в пользовательской среде, например. Rust или большинство функциональных языков, которые используют необязательный тип вместо null.

*) Nothing чаще всего используется как нижний тип, а не как null, PHP-эквивалент здесь будет void.

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