Обобщенный паттерн "Null Object"[]
Как известно, в Smalltalk-е nil это особый объект, который обычно используется как начальное значение всех [неинициализированных] переменных. nil возвращает true в ответ на сообщение #isNil, и бросает исключение "Does Not Understand" ("не понимаю") в ответ на почти любое другое сообщение.
Но все ли динамические языки имеют подобную концепцию и такой же nil? Ответ - нет. В этой статье я кратко сравню поведение nil в Smalltalk-е с nil-ом в другом динамическом языке - Objective-C. Не смотря на то, что я не программировал в Objective-C более 6-ти лет (потому, что я переключился на Smalltalk), некоторые технологии из Objective-C оказались полезными для моей карьеры в Smalltalk-е. Например, есть определённые ситуации, в которых я предпочитаю использовать концепцию nil-а из Objective-C, а не из Smaltalk-а. В этой статья я рассмотрю некоторые подобные ситуации.
nil возвращаемый из методов в Objective-C, очень похож на nil в Smalltalk-е. Единственное различие, это то, что в ответ на посылаемое ему сообщение nil не бросает исключение, а попросту игнорирует это сообщение. То есть, nil из Objective-C ведёт себя как "черная дыра". Это пустота. Это ничто. При посылке к nil-у любого сообщения, всё что вы получите назад - это nil. Не будет никаких исключений, никаких возвращенных значений. Ничего. Если мы захотим получить такое же поведение в Smalltalk-е, то наиболее очевидное решение -- это просто переопределить метод экземпляра #doesNotUnderstand: в классе UndefinedObject, таким образом, что бы он сразу возвращал nil. Но действительно ли это хорошая идея? Нет, не очень. Но я тороплюсь с ответом. Лучше, рассмотрим всё подробнее потому, что это приведёт нас к тому решению, которое я действительно использую.
В Objective-C nil не всегда "поедал" посылаемые ему сообщения. Брэд Кокс (Brad Cox), создатель Objective-C изначально дал nil-у поведение, которое было очень похоже на поведение nil-а в Smalltalk-е. То есть, посылка сообщения nil-у вызывала исключение во время выполнения. Так было, например, в релизе (выпущенном при помощи "StepStone Corporation") его библиотеки классов "ICPack 101" и, в идущих с ней компиляторе и ран-тайме. Затем, начиная с "ICPack 201" (вместе с компилятором и ран-таймом) это поведение было изменено на текущее, которое "поедает" сообщения. Затем NeXT Computers выпустила свою реализацию Objective-C, в которой nil тоже "поедает" сообщения. Так же поступила и Free Software Foundation, выпустив свой компилятор GNU Objective-C. Но так nil вёл себя не всегда.
Так зачем была создана другая реализация nil-а?
Естественно, внесение этих изменений разделили программистов на два лагеря. Одни предпочитали первоначальное поведение (генерация исключения). Другие программисты предпочитали новое поведение ("поедание" сообщений). Каждая группа выдвигала свои аргументы в пользу того или другого варианта. Эти дебаты были очень активные и интересные. Но в них не было других победителей, кроме победителей de-facto. То есть тех, за кого проголосовали сами производители компиляторов [Objective-C]. Это такие фирмы как NeXT Computers, StepStone Corp, а позже - FSF. Тем не менее, аргументы "за" и "против" были интересными, и особенно интересно то, что был факт с которым соглашались оба лагеря! Обе стороны сошлись на том, что поведение "поедающее" сообщения позволяет создавать более элегантный код!
Конечно же, "исключенческая" сторона отвечала, что "да, код выглядит более элегантным, но потенциально он более опасен и менее надёжен". И приводила факты в пользу этого утверждения. Мы рассмотрим некоторые из этих аргументов, но, для начала, я продемонстрирую, как "поедающее" поведение помогает создать более элегантный код.
Предположим, что мы хотим найти последний телефонный номер, который вы набрали со своего офисного телефона, и сохранить этот номер в переменной, скажем, 'последнийНомер'. Далее предположим, что мы хотим сохранить этот номер как строку и затем отобразить его в виджете (или же использовать этот номер позже). Если вы -- это 'человек' в последовательности сообщений приведённой далее, то достаточно ли этой последовательности для получения работоспособной программы?
последнийНомер := человек офис телефон последнийИсходящийНомер какСтрока. виджет строковоеЗначение: последнийНомер.
Возможно.
Но что, если у человека нет офиса? Или в офисе нет телефона? Или что если это новый телефон и по нему еще никто не звонил? В любом из этих случаев, при использовании "исключенческого" nil-а будет выброшено исключение, которое приведёт к остановке программы, если только не будет создан соответствующий обработчик исключения.
А что с nil-ом "поедающим" сообщения? В этом случае 'последнийНомер' может в качестве значения принять nil, но всё будет работать и в этом случае. Даже передача nil-а как аргумента для метода #строковоеЗначение: 1 будет работать, потому что "поедающий" nil может нормально работать с виджетами. Совершенно не важно, что аргумент это nil. Всё будет работать без бросания исключений и без заметных с первого взгляда побочных эффектов (на этом подробнее остановимся позже).
Для сравнения, ниже приведён код для работы с "исключенческим" nil-ом:
| врем | врем := человек офис. врем неПусто еслиИстина: [врем := врем телефон]. врем неПусто еслиИстина: [врем := врем последнийИсходящийНомер]. врем неПусто еслиИстина: [врем := врем какСтрока]. виджет строковоеЗначение: последнийНомер.
Н-да... Все эти явные проверки не очень вдохновляют! Естественно, вместо явных проверок, можно завернуть код во внутрь обработчика исключений:
[последнийНомер офис телефон последнийИсходящийНомер какСтрока] по: Объект сигналСообщениеНеПонято делать: [].
Это выглядит немного проще, чем предыдущий пример, но даже он проигрывает первому варианту. Из всех трёх вариантов первый - наиболее простой! Вы просто "сделали это" не беспокоясь об исключениях, их обработчиках или явных проверках.
Но насколько часто возникают подобные ситуации? Часто ли мы вынуждены задавать явные проверки или обработчики исключений при работе с "исключенческим" nil-ом ?
Довольно часто. Рассмотренный пример выглядит немного путано. Давайте возьмём реальный пример. Это метод #objectWantingControl класса VisualPart из VisualWorks 2. Клас VisualPart это суперкласс для класса View. Все объекты класса View обычно ожидают, что у них есть контроллер который обрабатывает ввод от пользователя (события от клавиатуры и мыши). Метод #objectWantingControl спрашивает у контроллера, который связан с экземпляром класса View, имеет ли контроллер фокус ввода. Если "да", то #objectWantingControl возвращает self, иначе nil. В том случае, если этот метод возвращает nil, то экземпляр класса View считается объектом "только для чтения", который не обрабатывает пользовательский ввод. Реализован этот метод в VW так:
objectWantingControl | ctrl | ctrl := self getController. ctrl isNil ifTrue: [^nil]. " Trap errors occurring while searching for the object wanting control. " ^Object errorSignal handle: [:ex | Controller badControllerSignal raiseErrorString: 'Bad controller in objectWantingControl'] do: [ctrl isControlWanted ifTrue: [self] ifFalse: [nil]]
Обратите внимание, что метод имеет и явные проверки (методом #isNil) и обработчик исключения. Как же метод может выглядеть если бы nil был "поедающим" сообщения? Тут есть несколько вариантов, один из которых короче других (но необязательно понятнее). Мы перепишем метод так:
objectWantingControl self getController isControlWanted ifTrue: [^self]. ^nil
Обратите внимание насколько сократился этот метод. Нет ни явных проверок, ни обработчиков исключений, ни дополнительного кода, который может скрыть от намерения программистов создавших метод.
Более того, при использовании "исключенческого" nil-а, даже если код будет написан так, что бы избежать явных проверок и/или обработчиков исключений, то стиль кодирования всё равно будет выделяться. И, несомненно, это изменение стиля не даёт такого элегантного кода, как при использовании "поедающего" nil-а. "Поедающий" сообщения nil позволяет создать более понятный, более элегантный код. Это был единодушное мнение обеих сторон, спорящих о предпочтительной реализации nil-а в Objective-C. Те, кто предпочитал "исключенческий" nil утверждали, что этот более "простой" код также и потенциально опасен, и даже может содержать ошибки. Они приводили множество примеров, но всё сводилось к двум аргументам.
Во-первых, если с поедающим nil-ом последовательность сообщений возвращает nil, то затруднительно определить где же этот nil возник. Другими словами, какое из сообщений вернуло nil первым?
Ответом на этот аргумент было: программиста обычно не интересует где появился первый nil, а если его это заинтересует, то можно использовать явные проверки.
Ответом на этот ответ было: программист должен этим интересоваться, но поскольку он обычно не интересуется, то использование "поедающего" nil-а развивает плохую практику.
Ясно, что эта цепочка выпадов, когда одна сторона просто отрицала утверждение другой стороны и выдвигает своё (например "почему это программисты должны заботиться о том где возник nil"), не имеет конца.
Страсти накалялись. Но, тем не менее, обе стороны соглашались, что "поедающий" nil приводит к созданию более простого, более элегантного кода. Весь опыт программистов подтверждал этот вывод. А более простой код - это хороший код, если он еще и правильный.
И так, действительно ли код с "поедающим" nil-ом правильный? Или "поедающий" nil приводит к появлению трудноуловимых ошибок? На этот вопрос сторонники "исключающего" nil-а отвечали, что каверзные ошибки появляются. И приводили определённые примеры. Интересно, что все примеры, которые я видел, были со статически объявленными 3 переменными, и иллюстрировали обычно платформенно-зависимые особенности, которые возникли до того, как nil стал допустимым значением. Один специфический пример мы и рассмотрим далее (код я модифицировал так, что бы он соответствовал синтаксису Smalltalk-а, а не Objective-C):
значение := виджет значениеСПлавающей
В этом примере, если 'значение' статически объявлена как имеющая тип float (float-ы в Objective-C это не объекты, а значения примитивных типов данных), и сообщение #значениеСПлавающей 4 вернёт nil вместо числа типа float, то в результате присваивания 'значение' будет содержать 0 на процессорах семейства Motorola M68K, и ненулевое значение на процессорах семейства Intel 5. Так происходит из-за особенностей неявного приведения nil-а к примитивному типу float. Этот неочевидный результат может привести к трудно находимым ошибкам.
Но этот аргумент относится только к Objective-C, и не имеет абсолютно никакого отношения к Smalltalk-у. Так как в Smalltalk-е нет переменных, объявленных статически, и нет примитивных типов данных, то этот код будет полностью работоспособным.
Итак, имеет ли смысл изменить семантику nil-а в Smalltalk-е, так, что бы он тоже "поедал" сообщения? Сделать это легко, переопределив метод #doesNotUnderstend: что бы он сразу возвращал self. Но нужно ли это? Думаю, что нет. Огромное количество уже существующего кода ожидает "исключенческого" nil-а. Изменение семантики с "исключенческой" на "поедающую" скорее всего помешает работе большей части кода. Изменить это может быть очень тяжело.
Более того, даже в Smalltalk-е, первое возражение против введения "поедающего" nil-а, это то, что в последовательности сообщений трудно определить кто же первым вернул nil. Хотя важность этого факта трудно оценить объективно, я не знаю никого, кто не признавал бы существование такой проблемы. Возможно мелкой (а возможно и не очень), но реальной.
Итак, вместо модификации существующего в Smalltalk-е nil-а давайте рассмотрим как альтернативу создание специального объекта, который бы имел "поедающую" сообщения семантику. Первый встреченный мною общедоступный документ, рассматривающий этот вариант, это отличная статья Боби Вульфа (Bobby Woolf) "The Null Object Pattern" 6. Но я думаю существуют и более ранние работы. В этой книге (которой более пяти лет -- вечность по компьютерным меркам) также используется пример с VisualPart. Фактически, это основная причина, по которой я выбрал этот пример для иллюстрации "поедающего" nil-а. Как результат, я могу сохранить ясность и целостность примеров, не добавляя дополнительного кода ко всем примерам.
В паттерне "Null Object" рекомендовано создавать объекты, которые ничего не делают. То есть, реализуют тот же интерфейс, что и исходный объект, но все методы не делают никакой работы. Для примера с VisualPart нужно создать класс, названный NoController, который будет реализовывать интерфейс контроллера, но методы которого не будут ничего делать.
Не смотря на то, что объект не должен ничего делать, он является специфическим контроллером. Например, NoController должен отвечать false в ответ на сообщение #isControlWanted. Почему это важно? Потому, что клиенты NoController-а ожидают булевый результат в ответ на #isControlWanted. Затем они могут послать сообщение #ifTrue: или #ifFalse: к этому результату. А на #ifTrue: и #ifFalse: отвечают только объекты true, false (и, возможно, "поедающий" nil). NoController должен возвращать булево значение в ответ на это сообщение, иначе NoController не будет полностью совместимым с настоящим контроллером.
Но что будет, если в ответ на #isControlWanted вернётся "поедающий" nil? Или еще лучше, если его вернёт экземпляр класса VisualPart в ответ на сообщение #getController? Я уверен, что всё будет работать, и что это наиболее простой путь обобщить паттерн "Null Object" из книги Боба Вульфа. Довольно интересно то, что Боб Вульф так описывает преимущества применения паттерна "Null Object":
- "...упрощает клиентский код. Клиент может единообразно работать с настоящим партнёром и с пустым. Клиент обычно не знает (и не интересуется) работает ли он с настоящим партнёром или с пустым. А это упрощает клиентский код потому, что не нужно писать методы которые изменяют поведение в зависимости от партнёра."
Это утверждение совпадает с выводом к которому пришло сообщество NeXTSTEP-а, о том, что "поедающий" nil в Objective-C позволяет упростить код, как я уже показал.
Но должны ли мы для реализации паттерна "Null Object" создать класс NoController или классы НетОфиса, НетТелефона, НетПоследнегоИсходящегоНомера? Действительно, возможность появления множества классов при реализации паттерна отмечена Бобом Вульфом:
- "[Одним из] недостатков является то, что паттерн 'Null Object' ... приводит к взрывному росту числа классов. Потому что необходимо создать свой 'пустой' класс для каждого абстрактного класса."
"Поедающий" сообщения nil позволяет избежать подобного "демографического взрыва", так как он совместим с любым протоколом. Лично я вижу здесь параллель между спорами сторонников динамической и статической типизации, которую я могу проиллюстрировать так:
- Статическая типизация против динамической:
Разрешить переменной хранить объект определённого типа или любого типа?
- Объекты-"пустышки" против "поедающего" сообщения nil-а:
Разрешить ли отправление любого сообщения к объекту или только сообщений из определённого протокола?
Я не скрываю, что предпочитаю динамическую типизацию статической. Так же, я уверен, что универсальный "поедающий" сообщения nil более удобен, чем специфический "объект-пустышка". Естественно "поедающий" nil должен быть правильно реализован. Дальше я опишу свою реализацию "поедающего" nil-а, который я назвал null. null - это экземпляр моего класса Null.
Помните, что первым возражением против null-а было то, что невозможно определить кто первым вернул null. Как быть с этим?
Просто возмите и спросите.
Класс Null должен иметь переменные экземпляра класса в которых бы сохранялись объект, который вызвал 'Null new', и метод в котором это было сделано.
Не значит ли это, что создатель null-а должен об этом заботиться? Ведь это дополнительная работа. А что если кто-то забудет об этих дополнительных шагах? Всё просто. Никаких дополнительных действий не нужно. Кто угодно может вызвать ``Null new, а всю необходимую информацию должен быть способен найти сам Null.
Но это будет возможно, только в том случае, если Null будет способен определить кто вызвал один из его методов для создания экземпляра. Иными словами, нужно определить кто отправитель соответствующего сообщения. Как это сделать? Это не стандартизовано в Smalltalk-е.
Вот как это можна сделать в VisualWorks:
Object>>sender ^thisContext sender sender receiver Object>>sentFromMethod ^thisContext sender sender selector
А вот код, для VisualAge:
Object>>sender | sender | sender := Processor activeProcess stackAtFrame: 2 offset: -3. (sender isKindOf: BlockContext) ifTrue: [sender := sender methodContext instVarAt: 2]. ^sender Object>>sentFromMethod ^Processor activeProcess methodAtFrame: 2.
Код для GemStone:
Object>>sender ^(GsProcess _framecontentsAt: 3) notNil ifTrue: [frame at: 10] ifFalse: [nil]. Object>>sentFromMethod ^(GsProcess _framecontentsAt: 3) nonNil ifTrue: [(frame at: 1) selector] ifFalse: [nil].
Теперь, каждый раз, когда мы захотим вернуть nil с "поедающей" сообщения семантикой (который я называю null), вы должны использовать '^Null new' вместо '^nil'. Автор же null-а легко может быть получен у самого null-а по сообщению #originator.
Если вы не хотите плодить экземпляры класса Null, то можете создать экземпляр "по умолчанию", который будет хранится в переменной класса Default'.
Null class>>new | tmp | tmp := super new. tmp originator: self sender. tmp fromMethod: self sentFromMethod. ^tmp
null по умолчанию теперь может быть получен через 'Null default' вместо 'Null new'. Обычно я использую его для автоматической инициализации переменных экземпляра в моём абстрактном классе DomainModel. Этот класс является суперклассом всех моих доменных объектов:
Null class>>default Default == nil ifTrue: [Default := super new initialize. Default originator: Null. Default fromMethod: #default]. ^Default
По моему опыту, это действительно упрощает доменную логику, как и предполагается в этой статье. Иногда это очень упрощает код. И у меня еще не было проблем с этим подходом при использовании его только в коде доменного слоя. Правда, у меня были сложности, при попытке выделить эти идеи в слой, ответственный за интерфейс с пользователем, и я решил, что там это лучше не использовать. Лично моя реализация класса Null изначально была написана в VisualAge, и была частью большей доменно-специфической библиотеки. В этой библиотеке использовалось несколько новых идей, что бы проверить насколько они работоспособны. Использование класса Null, описанного в этой статье, было одним из этих новшеств. Не смотря на то, что это маленькая идея, её активное использование в доменном коде было обусловленно моим предыдущим опытом работы в Objective-C. Я до сих пор не знаю, есть ли какие-то "подводные камни" при использовании её в Smalltalk-е. Но в доменном коде (не в коде графического интерфейса) всё работает нормально.
Через некоторое время, после того как я создал вышеописанную библиотеку, вся она была портирована в GemStone, и, наконец, в VisualWorks. Реализацию класса Null для VisualWorks скачать можно тут. Свяжитесь со мной через эл.почту по адресу nevinop@xmission.com 7, если захотите получить код для GemStone или VA.
Если же вы захотите сами реализовать ваш собственный класс Null, то вы должны помнить, что #isNil оптимизируется в VA (но не в VW). В результате 'Null new isNil' всегда будет возвращать false в VA. Даже если 'Null>>isNil' будет возвращать true. Возможное решение - создать метод #isNull и использовать его вместо #isNil. Так изначально и было устроено в моём коде. Этот подход я перенёс и в версию для GemStone, но не стал переносить в VW.
Оригинальная статья была опубликована на сайте "Smalltalk Cronicles" в марте 2000 года.
Перевод Андрея Собчука, 2003
Сноски[]
Кое-кто может усомнится в том, действительно ли нужно в примере использовать метод который работает только со строками вместо более универсального метода #показать:, который может быть использован с объектом любого класса. В пользу этого есть три аргумента:
- во-первых, вспомните о наиболее часто используемом методе #show: класса Transcript в Smalltalk-е и типе ожидаемого аргумента (это строка);
- во-вторых, #setStringValue: (тут #строковоеЗначение: -- Перев.) это реальный метод, который использовался в виджете TextField в NeXTSTEP;
- в третих, это ведь просто пример.
Судя по данным Cincom Smalltalk Wiki этого метода нет в VW7. -- Перев.
Утверждение "статическое объявление" относится к типу переменной (противоположно "динамической переменной"), а не к области видимости. -- Перев.
#floatValue (тут #значениеСПлавающей -- Перев.) это метод, который реализован классом TextFields в NeXTSTEP, равно как и метод #setStringValue: из примера выше
Думаю, речь идёт об x86 -- Перев.
Опубликовано Pattern Languages of Program Design. Addison-Wesley, James Coplien and Douglas Schmidt (editors). Reading, MA, 1995; http://www.awl.com/cseng/titles/0-201-60734-4. (В эл.виде вы можете скачать её отсюда -- Перев.)
Вполне возможно, что этот адрес устарел. Загляните в news:comp.lang.smalltalk и попросите автора отозваться. -- Перев.