Говори, а не спрашивай

Адаптированный перевод статьи Tell, Don't Ask за авторством Энди Ханта (Andy Hunt)

В этот раз мы поговорим о некоторых практических приёмах, касающихся объектно-ориентированного программирования, и, в основном, сосредоточенных на вопросах ответственности классов:

  • "Говорить" vs. "Спрашивать"
  • Закон Деметры
  • Разделение команд и запросов [CQRS]

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

"Говорить" vs. "Спрашивать"

Алек Шарп (Alec Sharp), в своей последней книге "Smalltalk by Example [SHARP]", даёт очень полезный урок в нескольких строках:

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

То есть вы должны стараться говорить объектам, что им нужно сделать; не спрашивайте об их внутреннем состоянии, чтобы принять решение о том, что нужно сделать.

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

Конечно, вы скажете, это очевидно - я бы никогда не написал такой код.

И всё же, очень легко увлечься изучением объекта и вызывать его методы, опираясь на полученые знания. И это точно не лучший вариант. Говорите объекту, чего вы от него хотите. Позвольте ему решать, как это сделать. Думайте декларативно, а не процедурно!

Чтобы избежать этой ловушки, начните с проработки дизайна класса, основываясь на его ответственности. Далее вы придёе к определению комманд, которые должен выполнять класс, вместо того, чтобы отдавать наружу доступ к его состоянию.

Просто данные

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

Наибольшая опасность заключается в том, что запрашивая данные у объекта вы получаете просто данные. Вы не работает с объектом в глобальном смысле. Даже, если данные, которые вы прочитали, являются объектом структурно (напр. String), то семантически это уже не объект. Он больше не имеет никаких ассоциаций с объектом-владельцем. Прочитав строку "RED" вы не можете сказать, что она означает. Это фамилия владельца? Цвет машины? Текущее состояние тахометра? Объект знает ответы на эти вопросы, а данные — нет.

Базовый принцип объектно-ориентированного программирования — объединение методов и данных. Их неуместное разделение возвращает вас прямиком к процедурному программированию.

Инвариантов недостаточно

В каждом классе есть инварианты — выражения, которые всегда истины. Некоторые языки (такие, как Eiffel) предоставляют прямую поддержку для составления и проверки инвариантов. Но таких языков мало, и это значит только то, что инварианты не представлены в явном виде. Но они по-прежнему существуют. Например, инвариант итератора:

hasMoreElements() == true // подразумевает, что:
nextElement() // вернёт значение

Другими словами, если hasMoreElements() возвращает истину, тогда обращение к следующему элементу завершится успехом, иначе в системе закрался серьёзный баг. Если вы запускаете мультипоточный код без подобающей синхронизации (блокировок), то вполне может произойти так, что какой-то из других потоков нарушит выше описаную инвариантность, "забрав" следующий эллемент раньше вас.

Инвариант не выполняется; значит, что-то идёт не так и у вас где-то ошибка.

Согласно контрактному программированию [DbC], пока ваши методы (запросы и команды) можно легко перемешивать при этом не нарушая инвариантность класса, тогда всё ок. В то же время сохраняя инвариант класса вы можете значительно увеличить связность между вызывающим кодом и самим вызовом в зависимости от того, насколько много состояния вы отдали наружу.

Представьте, у вас есть объект-контейнер. Вы бы могли отдать наружу итераторы внутренних объектов, как это делают многие процедуры JDK core, или вы можете предоставить метод, который будет выполнять какую-то функцию при обращениии к каждому эллементу коллекции. В 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. "Спрашивать". Спрашивать - это запрос, а говорить - команда. Мне нравится идея разделения этих понятий. Почему это важно?

  1. Это помогает придерживаться принципа "Говори, а не спрашивай", когда вы мыслите в терминах команд, которые выполняют конкретное, чётко определённое действие.
  2. Когда класс основан на командах, то становится легче думать об его инвариантах (если вы просто отдаёте данные наружу, то вряд ли вы вообще вспоминаете про инварианты).
  3. Если допустимо предположить, что выполнение запросов не порождает каких-либо сайд эффектов, то появляются возможности:
    • использовать запросы в дебаггере, не мешая выполнению тестов
    • создавать встраиваемые, автоматические регрессионные тесты
    • прикинуть инварианты классов, их пре и пост условия

Напоследок хочется отметить то, что Eiffel требует, чтобы внутри Assertion вызывались только такие методы, которые не порождают сайд эффекты. Хотя в тех же Java и C++, если вам нужно обратиться к состоянию объекта в какой-то момент выполнения, вы можете сделать это только в том случае, когда уверены, что ваши запросы не породят какие-то изменения.

Ссылки

Спасибо Дэйву "Сомневающемуся" Томасу за его взгляды на инвариантность.

 Обсудить