Продолжается подписка на наши издания! Вы не забыли подписаться?

Чтобы было яснее

Автор: Мартин Фаулер
Chief Scientist, ThoughtWorks
Атрибуты и отображения
События и явные вызовы
Код, поведение которого определяется данными, и явное наследование

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

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

Атрибуты и отображения

Допустим, нам нужна структура данных для описания человека. Мы можем реализовать ее, используя некоторые поля, как показано в листинге 1. Разумеется, для этого нужно определить ряд переменных в классе Person. С другой стороны, язык Ruby, как и многие другие современные языки программирования, поддерживает отображения (известные также под именем ассоциативных массивов или хэш-таблиц). Мы могли бы определить структуру данных для класса Person, воспользовавшись отображением, как показано в листинге 2 (это будет работать медленнее, но предположим, что производительность этой части кода не так уж важна).

Использование отображения - весьма привлекательный способ, так как он позволяет менять набор полей, сохраняемых в классе Person, не изменяя сам класс. Если вы захотите добавить сюда номер телефона, вам не придется менять для этого начальный код.

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

Основной принцип ясного кода в том, что его проще понимать (и, как следствие, проще модифицировать). Как говорит Кент Бек, такой код представляет собой ясное выражение изначальных намерений.

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

Листинг 1. Использование полей класса (язык Ruby)
class Person
  attr_accessor :lastName,:firstName,:numberOfDependents
end
def frag1
  martin =Person.new
  martin.firstName ="Martin "
  martin.lastName ="Fowler "
  martin.numberOfDependents =1
  print (martin.firstName,"",martin.lastName,"has ", 
    martin.numberOfDependents,"dependents ")
end
Листинг 2. Использование отображения для хранения полей (язык Ruby)
class Person
    attr_accessor :data
    def initialize()
        @data = {}
    end
end

def frag2
    martin = Person.new
    martin.data ["firstName"] = "Martin"
    martin.data ["lastName"] = "Fowler"
    martin.data ["numberOfDependents "] = 1
    print (martin.data ["firstName"],"",
        martin.data ["lastName"],"has ",
        martin.data ["numberOfDependents "],
        "dependents ")
end

События и явные вызовы

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

Мы можем реализовать это, используя событие, как показано в листинге 3. Для этого достаточно определить различные события для модуля предварительных заказов, и любой объект, который хочет выполнить некоторые действия при наступлении какого-либо события, может определить для него соответствующий обработчик. Этот подход выглядит привлекательным, так как нам не придется вносить изменения в класс Reservation, если мы захотим добавить какие-либо дополнительные действия при отмене предварительного заказа. Другие объекты тоже могут добавлять обработчики событий, поэтому можно легко наращивать поведение в точках их обработки.

Листинг 3. Отмена заказа с использованием событий (язык C#)
public delegate void ReservationHandler (IReservation source);
public class Reservation 
...
  public String Id;
  public event ReservationHandler Cancelled;
    public Person client 
    {
      get { return client; }
      set { value.AddReservation(this); }
    }
  public void Cancel(){
    Cancelled (this);
  }

public class Person ...
  public String EmailAddress;
  public readonly ArrayList reservations;
  
  public void SendCancellationMessage(Reservation arg)
  {
    // отправить сообщение
  }
  
  public void AddReservation(Reservation arg)
  {
    // вызвать SendCancellationMessage, если случилось событие Cancelled
    arg.Cancelled += new ReservationHandler(SendCancellationMessage);
  }
Листинг 4. Явная реакция на отмену заказа (язык C#)
public class Reservation ...
  public String Id;
  public Person client;
  public void Cancel()
  {
    client.SendCancellationMessage(this);
  }

Однако за использование событий приходится платить: просматривая код соответствующего метода, я не могу сказать, что происходит при наступлении какого-либо события. Чтобы разобраться в происходящем, придется просмотреть весь остальной разрозненный код, создающий обработчики для этого события. Более ясный и понятный код для изложенной задачи (листинг 4) четко показывает последовательность действий при отмене заказа. Однако в этом случае при изменении или добавлении поведения при обработке события придется модифицировать код класса Reservation.

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

Я не утверждаю, что события вообще нельзя использовать. Они позволяют наращивать поведение класса дополнительными действиями, не меняя сам класс. Это особенно удобно при работе с библиотечными классами, которые вы не можете изменить напрямую. События также ценны тем, что не создают зависимости между классом, инициирующим наступление события, и классом, обрабатывающим его. Это особенно полезно, если эти классы находятся в различных пакетах, и вы не хотите делать пакеты зависимыми. Примером может послужить изменение содержимого окна на презентационном уровне при изменении объекта предметной области (модели). Механизм событий позволяет сделать это, сохранив исключительно важное разделение между моделью и ее представлением.

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

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

Код, поведение которого определяется данными, и явное наследование

Последний пример, который я предлагаю вашему вниманию, будет еще более громоздким. Рассмотрим систему скидок на заказы, использующую различные правила для вычисления скидки. "Синее" правило предоставляет вам фиксированную скидку в 150 долларов, если вы покупаете товары, поставляемые определенной группой поставщиков, а также если общая сумма вашего заказа превышает определенное пороговое значение. "Красное" правило предоставляет вам скидку в 10% при поставке товара в определенные штаты США.

В листинге 5 изображен явный код для этой системы скидок. У заказа есть ссылка на вспомогательный объект класса, отвечающего за вычисление скидки. У этого класса есть два подкласса – для "красного" и для "синего" правила. В листинге 6 показан пример кода, в котором используется один, более общий класс для подсчета скидки. Поведение этого класса определяется данными, задаваемыми при создании заказа.

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

Листинг 5. Явная логика вычисления скидок (язык C#)
public class Order ...
  public Decimal BaseAmount;
  public String Supplier;
  public String DeliveryState;
  public Discounter Discounter;
  public virtual Decimal Discount
  {
    get { return Discounter.Value(this); }
  }
}
abstract public class Discounter
{
  abstract public Decimal Value (Order order);
}
public class BlueDiscounter :Discounter
{
  public readonly IList DiscountedSuppliers = new ArrayList();
  public Decimal Threshold = 500m;
  public void AddDiscountedSupplier(String arg)
  {
    DiscountedSuppliers.Add(arg);
  }

  public override Decimal Value (Order order)
  {
    return DiscountApplies(order) ? 150 : 0;
  }

  private Boolean DiscountApplies(Order order)
  {
    return DiscountedSuppliers.Contains(order.Supplier)
      && (order.BaseAmount > Threshold);
  }
}

public class RedDiscounter :Discounter
{
  public readonly IList DiscountedStates = new ArrayList();
  public void AddDiscountedState (String arg)
  {
    DiscountedStates.Add(arg);
  }
  public override Decimal Value (Order order){
    return (DiscountedStates.Contains(order.DeliveryState))?
        order.BaseAmount * 0.1 : 0;
  }
}

// создаем "синий" заказ
BlueDiscounter bluePlan = new BlueDiscounter();
bluePlan.AddDiscountedSupplier("ieee");
blue = new Order();
blue.Discounter = bluePlan;
blue.BaseAmount = 500;
blue.Supplier = "ieee";
Листинг 6. Логика вычисления скидок, основанная на данных (язык C#)
public class GenericOrder :Order ...
  public Discounter Discounter;
  public override Decimal Discount 
  {
    get { return Discounter.Value(this); }
  }
  
  public enum DiscountType {constant,proportional};

  public class Discounter ...
    public DiscountType Type;
    public IList DiscountedValues;
    public String PropertyNameForInclude;
    public String PropertyNameForCompare;
    public Decimal CompareThreshold;
    public Decimal Amount;

    public Decimal Value(GenericOrder order)
    {
      if (ShouldApplyDiscount(order))
      {
        if (Type == DiscountType.constant)
          return Amount;

        if (Type == DiscountType.proportional)
          return Amount *order.BaseAmount;

        throw new Exception ("Unreachable Code reached ");
      }
      else
        return 0;
    }

    private Boolean ShouldApplyDiscount(Order order)
    {
      return PassesContainTest(order)
         && PassesCompareTest(order);
    }

    private Boolean PassesContainTest(Order order)
    {
      return DiscountedValues.Contains (
        GetPropertyValue(order,PropertyNameForInclude));
    }

    private Boolean PassesCompareTest(Order order)
    {
      if (PropertyNameForCompare == null)return true;
      else 
      {
        Decimal compareValue = (Decimal)GetPropertyValue(order,PropertyNameForCompare);
        return compareValue > CompareThreshold;
      }
    }

    private Object GetPropertyValue (Order order,String propertyName)
    {
      FieldInfo fi = typeof(Order).GetField(propertyName);

      if (fi == null)
        throw new Exception("unable to find field for " + propertyName);

      return fi.GetValue(order);
    }
}

// создаем "синий" заказ
GenericDiscounter blueDiscounter = new GenericDiscounter();
String []suppliers = {"ieee"};
blueDiscounter.DiscountedValues = suppliers;
blueDiscounter.PropertyNameForInclude = "Supplier";
blueDiscounter.Amount = 150;
blueDiscounter.PropertyNameForCompare = "BaseAmount";
blueDiscounter.CompareThreshold = 500m;
blueDiscounter.Type = DiscountType.constant;
blue = new Order();
blue.BaseAmount = 500;
blue.Discounter = blueDiscounter;

Явно определенные подклассы проще читать в коде, равно как и понимать задаваемое ими поведение. Что же касается варианта с общим классом, то для его понимания придется изучить не только сам класс, но и найти то место, где задаются данные, определяющие дальнейшее его поведение. При всем этом очень сложно понять, что происходит на самом деле. И это даже в таком простом случае! Что же говорить о более сложных системах? Конечно, используя в дизайне системы один общий класс, мы получаем возможность расширения поведение без "программирования", но и здесь бы я поспорил – в конце концов, конфигурирование начальных данных тоже является формой программирования. Отладка и тестирование такого кода тоже дело непростое, к тому же о них нередко забывают.

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

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

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

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

Перевод А.Максимовой

Copyright © 1994-2016 ООО "К-Пресс"