нулевые указатели против шаблона нулевого объекта

27

Атрибуция: Это выросло из связанного вопроса P.SE

Мой опыт работы в C / C ++, но я много работал на Java и в настоящее время пишу на C #. Из-за моего C-фона проверка пройденных и возвращенных указателей является подержанной, но я признаю, что это искажает мою точку зрения.

Недавно я увидел упоминание об объекте Null Object Pattern, где идея заключается в том, что объект всегда возвращается. Обычный регистр возвращает ожидаемый заполненный объект, а регистр ошибки возвращает пустой объект вместо нулевого указателя. Предполагается, что вызывающая функция всегда будет иметь какой-то объект для доступа и, следовательно, избегает нарушений памяти нулевого доступа.

  • Так каковы плюсы / минусы нулевой проверки по сравнению с использованием шаблона нулевого объекта?

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

  • Не может ли шаблон пустого объекта иметь проблемы, подобные тем, что он не выполняет проверку нуля?

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

Сообщество
источник
Не «случай ошибки», а «во всех случаях допустимый объект».
@ ThorbjørnRavnAndersen - хорошая мысль, и я отредактировал вопрос, чтобы отразить это. Пожалуйста, дайте мне знать, если я все еще искажаю предположение NOP.
6
Короткий ответ: «шаблон нулевого объекта» - это неправильное название. Точнее, это называется «анти-шаблон нулевого объекта». Обычно вам лучше использовать «объект ошибки» - действительный объект, который реализует весь интерфейс класса, но любое его использование вызывает громкую, внезапную смерть.
Джерри Гроб
1
@JerryCoff в этом случае должно быть указано исключение. Идея состоит в том, что вы никогда не можете получить нулевое значение и, следовательно, не нужно проверять нулевое значение.
1
Мне не нравится этот «шаблон». Но, возможно, он укоренен в перегруженных реляционных базах данных, где легче ссылаться на специальные «нулевые строки» во внешних ключах во время подготовки редактируемых данных до окончательного обновления, когда все внешние ключи совершенно не равны нулю.

Ответы:

24

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

Null Object Pattern больше подходит для мест, где есть поведение по умолчанию, которое можно использовать в случае, когда объект не найден. Например, рассмотрим следующее:

User* pUser = GetUser( "Bob" );

if( pUser )
{
    pUser->SetAddress( "123 Fake St." );
}

Если вы используете NOP, вы должны написать:

GetUser( "Bob" )->SetAddress( "123 Fake St." );

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

В местах, где вы действительно не можете жить без Боба, я бы заставил GetUser () выдать исключение приложения (т.е. не нарушение прав доступа или что-то в этом роде), которое было бы обработано на более высоком уровне и сообщало бы об общем сбое операции. В этом случае нет необходимости в NOP, но также нет необходимости явно проверять NULL. IMO, эти проверки для NULL, только делают код больше и лишают читабельности. Проверка на NULL - все еще правильный выбор дизайна для некоторых интерфейсов, но не так много, как думают некоторые люди.

DXM
источник
4
Согласитесь с вариантом использования для шаблонов нулевых объектов. Но зачем возвращать ноль при возникновении катастрофических сбоев? Почему бы не бросить исключения?
Апур Хурасия
2
@MonsterTruck: отменить это. Когда я пишу код, я проверяю большинство входных данных, полученных от внешних компонентов. Тем не менее, между внутренними классами, если я пишу код таким образом, чтобы функция одного класса никогда не возвращала NULL, то на вызывающей стороне я не буду добавлять нулевую проверку просто для «большей безопасности». Единственный способ, которым это значение может быть NULL, - это если в моем приложении произошла какая-то катастрофическая логическая ошибка. В этом случае я хочу, чтобы моя программа зависала именно в этом месте, потому что тогда я получаю хороший файл аварийного дампа и возвращаю обратно состояние стека и объекта, чтобы идентифицировать логическую ошибку.
ДХМ
2
@MonsterTruck: под «инвертировать это» я имел в виду, что не следует явно возвращать NULL при выявлении катастрофической ошибки, но ожидать, что NULL произойдет только из-за непредвиденной катастрофической ошибки.
ДХМ
Теперь я понимаю, что вы имели в виду под катастрофической ошибкой - что-то, что приводит к сбою выделения самого объекта. Правильно? Изменить: не может сделать @ DXM, потому что ваш ник всего 3 символа.
Апур Хурасия
@MonsterTruck: странная вещь о "@". Я знаю, что другие люди делали это раньше. Интересно, они недавно подправили сайт? Это не должно быть распределение. Если я напишу класс, и намерение API - всегда возвращать действительный объект, то я бы не дал вызывающей стороне выполнить нулевую проверку. Но катастрофической ошибкой может быть что-то вроде ошибки программиста (опечатка, которая вызвала возврат NULL), или может быть какое-либо состояние гонки, которое стерло объект раньше времени, или, возможно, некоторое повреждение стека. По сути, любой неожиданный путь кода, который привел к возвращению NULL. Когда это произойдет, вы хотите ...
ДХМ
7

Так каковы плюсы / минусы нулевой проверки по сравнению с использованием шаблона нулевого объекта?

Pros

  • Нулевая проверка лучше, так как она решает больше случаев. Не все объекты имеют нормальное поведение по умолчанию или неоперативное поведение.
  • Нулевая проверка более надежна. Даже объекты с нормальными значениями по умолчанию используются в местах, где нормальные значения по умолчанию недопустимы. Код должен потерпеть неудачу близко к основной причине, если он собирается потерпеть неудачу Код должен потерпеть неудачу, если он потерпит неудачу.

Cons

  • Нормальные значения по умолчанию обычно приводят к более чистому коду.
  • Разумные значения по умолчанию обычно приводят к менее катастрофическим ошибкам, если им удается попасть в дикую природу.

Это последнее «про» является основным отличием (по моему опыту), когда каждый из них должен применяться. «Неудача должна быть шумной?». В некоторых случаях вы хотите, чтобы неудача была жесткой и немедленной; если какой-то сценарий, который никогда не должен произойти, каким-то образом Если жизненно важный ресурс не был найден ... и т. Д. В некоторых случаях у вас все в порядке с нормальным значением по умолчанию, поскольку на самом деле это не ошибка : получение значения из словаря, но ключ отсутствует, например.

Как и любое другое дизайнерское решение, в зависимости от ваших потребностей есть свои плюсы и минусы.

Telastyn
источник
1
В разделе «Плюсы»: согласен с первым пунктом. Второй момент - ошибка дизайнеров, потому что даже нуль можно использовать там, где его не должно быть. Ничего плохого в шаблоне не говорит, просто отражается на разработчике, который использовал шаблон неправильно.
Апур Хурасия
Что является более катастрофическим провалом: неспособность отправить деньги или отправить (и, следовательно, больше не иметь) деньги, если получатель не получил их и не знал об этом до явной проверки?
user470365
Минусы: разумные значения по умолчанию могут быть использованы для создания новых объектов. Если возвращается ненулевой объект, некоторые из его атрибутов могут быть изменены при создании другого объекта. Если возвращается нулевой объект, его можно использовать для создания нового объекта. В обоих случаях работает один и тот же код. Нет необходимости в отдельном коде для копирования-изменения и нового. Это часть философии, согласно которой каждая строка кода - это еще одно место для ошибок; сокращение строк уменьшает количество ошибок.
shawnhcorey
@shawnhcorey - что?
Теластин
Нулевой объект должен использоваться, как если бы он был реальным объектом. Например, рассмотрим NaN IEEE (не число). Если уравнение идет не так, оно возвращается. И это может быть использовано для дальнейших расчетов, так как любая операция с ним вернет NaN. Это упрощает код, так как вам не нужно проверять NaN после каждой арифметической операции.
shawnhcorey
6

Я не хороший источник объективной информации, но субъективно

  • Я не хочу, чтобы моя система умерла, никогда; для меня это признак плохо спроектированной или неполной системы; полная система должна обрабатывать все возможные случаи и никогда не переходить в неожиданное состояние; чтобы достичь этого, мне нужно быть откровенным в моделировании всех потоков и ситуаций; использование нулей в любом случае не является явным и объединяет множество различных случаев под одним зонтиком; Я предпочитаю, чтобы объект NonExistingUser равнялся нулю, я предпочитаю, чтобы NaN равнялся нулю, ... такой подход позволяет мне быть в явной форме об этих ситуациях и их обработке в максимально возможном количестве деталей, в то время как нуль оставляет мне только опцию null; в такой среде, как Java, любой объект может быть нулевым, так почему бы вы предпочли скрывать в этом общем пуле случаев и ваш конкретный случай?
  • нулевые реализации имеют резко отличающееся поведение, чем объект, и в основном привязаны к множеству всех объектов (почему? почему? почему?); для меня такая радикальная разница кажется результатом появления нулей, указывающих на ошибку, и в этом случае система, скорее всего, умрет (меня удивляет, что многие люди на самом деле предпочитают, чтобы их система умерла в приведенных выше сообщениях), если это не было явно обработано; для меня это ошибочный подход в корне - он позволяет системе умереть по умолчанию, если вы явно не позаботились об этом, не так ли это безопасно? но в любом случае невозможно написать код, который обрабатывает значение null и объект одинаково - только несколько базовых встроенных операторов (assign, equal, param и т. д.) будут работать, весь другой код просто потерпит неудачу, и вам нужно два совершенно разных пути;
  • использование Null Object лучше масштабируется - вы можете поместить в него столько информации и структуры, сколько захотите; NonExistingUser - очень хороший пример - он может содержать электронное письмо пользователя, которого вы пытались получить, и предлагать создать нового пользователя на основе этой информации, в то время как с нулевым решением мне нужно будет подумать, как сохранить электронное письмо, к которому люди пытались получить доступ близко к обработке нулевого результата;

В таких средах, как Java или C #, это не даст вам основного преимущества явности - безопасности завершенности решения, потому что вы все равно можете получить нулевое значение вместо любого объекта в системе, а у Java нет средств (кроме пользовательских аннотаций для Beans-компонентов). и т. д.), чтобы защитить вас от этого, кроме явных ifs. Но в системе, свободной от нулевых реализаций, подход к решению проблемы таким образом (с объектами, представляющими исключительные случаи) дает все упомянутые преимущества. Поэтому попробуйте прочитать о чем-то, кроме основных языков, и вы обнаружите, что меняете способы кодирования.

В целом, объекты Null / Error / возвращаемые типы считаются очень надежной стратегией обработки ошибок и вполне математически обоснованными, в то время как стратегия обработки исключений Java или C идет всего лишь на один шаг выше стратегии «die ASAP» - так что, в основном, все бремя нестабильность и непредсказуемость системы на разработчика или сопровождающего.

Есть также монады, которые можно считать превосходящими эту стратегию (сначала они также сложнее в понимании), но это больше касается случаев, когда вам нужно моделировать внешние аспекты типа, в то время как Null Object моделирует внутренние аспекты. Так что NonExistingUser, вероятно, лучше смоделировать как монаду может быть существующим.

И, наконец, я не думаю, что есть какая-то связь между шаблоном Null Object и тихой обработкой ситуаций ошибок. Это должно быть наоборот - это должно заставить вас явно моделировать каждый случай ошибки и обрабатывать его соответствующим образом. Теперь, конечно, вы можете решить быть неаккуратным (по какой-либо причине) и пропустить обработку ошибки в явном виде, но обрабатывать ее в общем - хорошо, это был ваш явный выбор.

Богдан Цимбала
источник
9
Причина, по которой мне нравится, что мое приложение перестает работать, когда происходит что-то неожиданное, заключается в том, что произошло нечто неожиданное. Как это произошло? Зачем? Я получаю аварийный дамп, и я могу исследовать и исправить потенциально опасную проблему, а не позволить ее скрыть в коде. В C # я, конечно, могу перехватить все неожиданные исключения, чтобы попытаться восстановить приложение, но даже тогда я хочу зарегистрировать место, где произошла проблема, и выяснить, требует ли это отдельная обработка (даже если это так, не зарегистрируйте эту ошибку, она действительно ожидаема и исправима ").
Луаан
3

Я думаю, что интересно отметить, что жизнеспособность Null Object, кажется, немного отличается в зависимости от того, типизирован ли ваш язык или нет. Ruby - это язык, который реализует объект для Null (или Nilв терминологии языка).

Вместо возврата указателя на неинициализированную память язык вернет объект Nil. Этому способствует тот факт, что язык динамически типизирован. Функция не всегда гарантирует возврат int. Он может вернуться, Nilесли это то, что ему нужно сделать. Это становится более запутанным и трудным сделать это на языке статической типизации, потому что вы, естественно, ожидаете, что значение null будет применимо к любому объекту, который может быть вызван по ссылке. Вы должны начать реализацию нулевых версий любого объекта, который может быть нулевым (или пойти по пути Scala / Haskell и иметь какую-то оболочку «Maybe»).

MrLister поднял вопрос о том, что в C ++ вам не гарантируется инициализация ссылки / указателя при первом его создании. Имея выделенный нулевой объект вместо необработанного нулевого указателя, вы можете получить некоторые приятные дополнительные функции. Ruby's Nilвключает функцию to_s(toString), которая приводит к пустому тексту. Это замечательно, если вы не возражаете получить нулевой возврат по строке и хотите пропустить сообщение об этом без сбоев. Открытые классы также позволяют вам вручную переопределить вывод, Nilесли это необходимо.

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

KChaloux
источник
1

Для некоторой дополнительной точки зрения взгляните на Objective-C, где вместо объекта Null значение nil вроде работает как объект. Любое сообщение, отправленное объекту nil, не имеет никакого эффекта и возвращает значение 0 / 0.0 / NO (false) / NULL / nil, независимо от типа возвращаемого значения метода. Это используется как очень распространенный шаблон в программировании на MacOS X и iOS, и обычно методы разрабатываются так, чтобы нулевой объект, возвращающий 0, был разумным результатом.

Например, если вы хотите проверить, содержит ли строка один или несколько символов, вы можете написать

aString.length > 0

и получить приемлемый результат, если aString == nil, потому что несуществующая строка не содержит символов. Людям, как правило, требуется некоторое время, чтобы привыкнуть к этому, но мне, вероятно, понадобится некоторое время, чтобы привыкнуть к проверке нулевых значений везде на других языках.

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

gnasher729
источник
Objective-C сделал объект Null Object / Null Pointer действительно размытым. nilget #define'd в NULL и ведет себя как нулевой объект. Это функция времени выполнения, в то время как в C # / Java нулевые указатели выдают ошибки.
Maxthon Chan
0

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

Во-первых, давайте перечислим desiderata, а затем подумаем, как мы можем выразить это на нескольких языках (C ++, OCaml, Python)

  1. поймать нежелательное использование объекта по умолчанию во время компиляции.
  2. сделайте очевидным, является ли данное значение потенциально дефолтным или нет при чтении кода.
  3. выберите наше разумное значение по умолчанию, если применимо, один раз для каждого типа . Определенно не выбирайте потенциально разные значения по умолчанию на каждом сайте вызовов .
  4. упростите инструмент статического анализа или человека, который grepможет выследить возможные ошибки.
  5. Для некоторых приложений программа должна продолжаться в обычном режиме, если неожиданно задано значение по умолчанию. Для других приложений программа должна немедленно остановиться, если задано значение по умолчанию, в идеале информативным способом.

Я думаю, что напряжение между шаблоном нулевого объекта и нулевыми указателями исходит из (5). Если мы сможем обнаружить ошибки достаточно рано, то (5) станет спорным.

Давайте рассмотрим этот язык по языкам:

C ++

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

Однако это означает, что вы не можете точно знать, находится ли значение типа MyClassв «состоянии по умолчанию» или нет. Помимо того, что вы вставляете bool nonemptyили аналогичное поле для отображения поверхностных настроек по умолчанию во время выполнения, лучшее, что вы можете сделать, - это создать новые экземпляры MyClassтаким образом, чтобы заставить пользователя проверить его .

Я бы порекомендовал использовать фабричную функцию, которая возвращает std::optional<MyClass>, std::pair<bool, MyClass>или, std::unique_ptr<MyClass>если возможно, ссылку на r-значение .

Если вы хотите, чтобы ваша фабричная функция возвращала какое-то состояние "заполнителя", отличное от созданного по умолчанию MyClass, используйте a std::pairи обязательно задокументируйте, что ваша функция делает это.

Если фабричная функция имеет уникальное имя, ее легко grepнайти и искать неряшливость. Тем не менее, это трудно grepдля случаев, когда программист должен был использовать заводскую функцию, но не использовал ее .

OCaml

Если вы используете такой язык, как OCaml, то вы можете просто использовать optionтип (в стандартной библиотеке OCaml) или eitherтип (не в стандартной библиотеке, но легко развернуть свой собственный). Или defaultableтип (я придумываю термин defaultable).

type 'a option =
    | None
    | Some of 'a

type ('a, 'b) either =
    | Left of 'a
    | Right of 'b

type 'a defaultable =
    | Default of 'a
    | Real of 'a

A, defaultableкак показано выше, лучше, чем пара, потому что пользователь должен сопоставить шаблон для извлечения 'aи не может просто игнорировать первый элемент пары.

Эквивалент defaultableтипа, показанного выше, можно использовать в C ++, используя a std::variantс двумя экземплярами одного типа, но части std::variantAPI непригодны, если оба типа одинаковы. Также это странное использование, std::variantтак как его конструкторы типов не называются.

питон

Вы все равно не получаете никакой проверки во время компиляции для Python. Но, будучи динамически типизированным, обычно нет обстоятельств, когда вам нужен экземпляр заполнителя какого-либо типа, чтобы успокоить компилятор.

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

Если это неприемлемо, я бы порекомендовал создать объект, DefaultedMyClassкоторый наследуется, MyClassи вернуть его из функции фабрики. Это дает вам гибкость в плане «заглушения» функциональности в дефолтных экземплярах, если вам нужно.

Грегори Нисбет
источник