В этот раз мы поговорим о некоторых практических вопросах, касающихся объектно-ориентированной разработки программного обеспечения, в основном сосредоточившись на проблемах ответственности классов:
Говорить vs спрашивать
Закон Деметры
Разделение команд и запросов
Если вкратце: отделяй команды от фактов, рассказывай о себе как можно меньше и не общайся с другими сверх необходимого.
Говорить vs спрашивать
Алек Шарп в своей недавней книге о Smalltalk [SHARP] емко выражает очень ценную мысль:
Процедурный код получает информацию, а затем принимает решения. Объектно-ориентированный код говорит объектам, что делать.
То есть вам следует стремиться говорить объектам, что вы хотите, чтобы они сделали; не спрашивайте их о состоянии, чтобы принять решение и затем сказать им, что делать.
Проблема в том, что вызывающий код не должен принимать решения, основанные на состоянии вызываемого объекта, чтобы затем самостоятельно изменить это состояние. Реализуемая вами логика, скорее всего, является ответственностью вызываемого объекта, а не вашей. Принятие решений за пределами объекта нарушает его инкапсуляцию.
Вы могли бы ответить: «Само собой, это очевидно. Я бы никогда не написал такой код».
И все же очень легко незаметно скатиться к изучению полученного объекта и вызову разных методов в зависимости от результата. Но это, скорее всего, не лучший способ действий. Просто скажите объекту, чего вы хотите. Позвольте ему самому разобраться, как это сделать. Мыслите декларативно, а не процедурно!
Избежать этой ловушки будет проще, если вы будете проектировать классы, основываясь на их ответственностях. Затем вы сможете естественным образом перейти к определению команд, которые может выполнять класс, — в отличие от запросов, информирующих о состоянии объекта.
(Подробности см. в моем руководстве по объектно-ориентированной разработке.)
Просто данные
Основная цель этого упражнения — обеспечить правильное распределение ответственности, при котором нужная функциональность располагается в нужном классе, не создавая избыточной связанности с другими классами.
Главная опасность здесь в том, что, запрашивая данные у объекта, вы получаете лишь данные. Вы не получаете объект — во всяком случае, не в полном смысле этого слова. Даже если то, что вы получили в ответ на запрос, является объектом структурно (например, String), семантически это уже не объект. У него уже нет связи со своим объектом-владельцем. Хотя вы и получили строку “RED”, вы не можете спросить у этой строки, что это означает. Это фамилия владельца? Цвет машины? Текущее состояние тахометра? Объект это знает, а данные — нет.
Объединение методов и данных — основополагающий принцип объектно-ориентированного программирования. Их неуместное разъединение мгновенно возвращает вас к процедурному программированию.
Инвариантов недостаточно
У каждого класса есть инварианты — утверждения, которые всегда должны оставаться истинными. Некоторые языки (как Eiffel) предоставляют прямую поддержку для объявления и проверки инвариантов. В большинстве языков такой поддержки нет, но это лишь означает, что инварианты не объявлены явно. Они все равно существуют. Например, для итератора действует следующий инвариант (в качестве примера используется Java):
hasMoreElements() == true
// означает, что:
nextElement()
// вернет значениеИными словами, если hasMoreElements() возвращает true, то попытка получить следующий элемент обязательно будет успешной, или же что-то всерьез поломалось. Если вы выполняете многопоточный код без необходимой синхронизации (блокировок), вполне может оказаться, что приведенный выше инвариант не выполняется: какой-то другой поток успел забрать последний элемент до вас.
Инвариант не выполняется; значит, что-то не так — у вас баг.
В соответствии с принципом контрактного программирования, если ваши методы (запросы и команды) можно свободно комбинировать, при этом не нарушая инвариант класса, то все в порядке. Однако, сохраняя инвариант класса, вы могли одновременно резко усилить связанность между вызывающим и вызываемым кодом — в зависимости от того, сколько внутреннего состояния было раскрыто.
Предположим, у вас есть объект-контейнер C. Вы могли бы предоставить доступ к итераторам объектов, хранящихся в этом контейнере (как это делают многие процедуры в ядре JDK), либо предоставить метод, который применит заданную функцию ко всем элементам коллекции. В Java это можно было бы объявить примерно так:
public interface Applyable {
public void each(Object anObject);
}
...
public class SomeClass {
void apply(Applyable);
}
// Вызов:
SomeClass foo;
...
foo.apply(new Applyable() {
public void each(Object anObject) {
// действия с anObject
}
});(Простите за варварский неологизм "Apply-able" — называть интерфейсы через "-able" удобно, но английский не всегда так податлив, как хотелось бы.)
Такой код проще реализовать на языках с указателями на функции и еще проще — на Perl или Smalltalk, где эти возможности встроены. Но суть ясна: «примени эту функцию ко всем элементам внутри, мне все равно как».
Одного и того же результата можно добиться двумя способами: либо с помощью метода, наподобие apply, либо с помощью итераторов. Выбор сводится к тому, какой уровень связанности вы готовы допустить: чтобы минимизировать связанность, раскрывайте минимально необходимое состояние. Как видно из примера, apply раскрывает меньше состояния, чем предоставление итератора.
Закон Деметры
Итак, мы решили раскрывать ровно столько состояния, сколько необходимо для достижения наших целей. Отлично! Можем ли мы теперь внутри нашего класса начать свободно рассылать команды и запросы любым другим объектам в системе? Технически — да, но, согласно закону Деметры, это была бы плохая идея. Закон Деметры стремится ограничить взаимодействие между классами, чтобы свести к минимуму связанность между ними. (Хорошее обсуждение этой темы см. в [APPLETON].)
Иными словами, чем с большим количеством объектов вы разговариваете, тем выше риск, что ваш код перестанет работать при изменении одного из них. Поэтому вам следует не только говорить как можно меньше, но и максимально ограничить круг своих собеседников. Фактически, согласно закону Деметры для методов:
Любой метод объекта должен вызывать только методы, принадлежащие:
ему самому;
любым параметрам, переданным в метод;
любым объектам, созданным этим методом;
любым составляющим его объектам.
В этом списке неспроста отсутствуют методы объектов, полученных в результате какого-либо вызова. Например (используем синтаксис Java):
SortedList thingy = someObject.getEmployeeList();
thingy.addElementWithKey(foo.getKey(), foo);— это именно то, чего мы стремимся избежать. (foo.getKey() — еще один пример, когда мы спрашиваем, а не говорим.) Прямой доступ к дочернему объекту создает избыточную связанность вызывающего кода.
Вызывающий код зависит от следующих фактов:
someObject хранит сотрудников в SortedList.
Метод добавления в SortedList — это addElementWithKey().
Метод foo для запроса его ключа — это getKey().
Вместо этого следует сделать так:
someObject.addToThingy(foo);Теперь вызывающий код зависит только от того факта, что он может добавить foo в thingy. Эта формулировка звучит достаточно высокоуровнево, чтобы считаться ответственностью и не быть слишком привязанной к реализации.
Недостаток, разумеется, в том, что в итоге вам приходится писать множество небольших методов-оберток, которые по сути лишь делегируют обход контейнера и т.п. Приходится выбирать: либо смириться с этой неэффективностью, либо получить более высокую связанность классов. Чем выше степень связанности между классами, тем выше вероятность, что любое внесенное изменение нарушит что-то в другом месте. Обычно это приводит к созданию хрупкого, неустойчивого кода.
В зависимости от приложения, затраты на разработку и сопровождение кода с высокой связанностью классов в большинстве случаев могут с лихвой перевесить неэффективность во время выполнения.
Разделение команд и запросов
Итак, вернемся к противопоставлению спрашивать vs говорить. Спрашивать — это запрос, говорить — команда. Я придерживаюсь принципа, что это должны быть разные методы. Для чего это нужно?
Это помогает придерживаться принципа «говори, а не спрашивай», если мыслить в терминах команд, выполняющих конкретное, четко определенное действие.
Это помогает задуматься об инвариантах класса, если он в основном состоит из команд. (Если вы просто возвращаете данные, вы, вероятно, не особо задумываетесь об инвариантах.)
Если можно предположить, что обработка запроса не имеет побочных эффектов, то вы можете:
использовать запросы в отладчике, не влияя на тестируемый процесс;
создавать встроенные автоматические регрессионные тесты;
проверять инварианты класса, предусловия и постусловия.
Именно последний пункт объясняет, почему в Eiffel требуется, чтобы внутри Assertion вызывались только методы без побочных эффектов. Но даже в C++ или Java, если вам нужно вручную проверить состояние объекта в определенной точке кода, вы можете сделать это с уверенностью — при условии, что вызываемые вами запросы ничего не изменят.
Источники
[SHARP] Sharp A. Smalltalk By Example. McGraw-Hill, 1997.
[APPLETON] Appleton B. Law of Demeter (OTUG Mailing List).
Отдельная благодарность Дейву «Сомневающемуся» Томасу за его соображения на тему инвариантности.
