«Запах» проектирования: временная связность

http://blog.ploeh.dk/2011/05/24/DesignSmellTemporalCoupling/
  • Перевод
Это первый пост из серии о Poka-yoke проектировании – также известном, как инкапсуляция.

Известной проблемой в проектировании API является временная связность, которая получается в том случае, если в классе присутствуют скрытые отношения между двумя или более членами, требующие от клиента правильной последовательности вызовов. Это жёстко связывает члены класса во временном разрезе.

Архитипичным примером является использование метода Initialize, хотя можно найти ещё множество примеров, в том числе в BCL (FCL). В качестве примера из BCL, следующее использование класса EndpointAddressBuilder компилируется, но рушится во время выполнения:
var b = new EndpointAddressBuilder();
var e = b.ToEndpointAddress();

Выясняется, что для конструирования EndpointAddress, нужно как минимум предоставить URI. Следующий пример компилируется и нормально выполняется:
var b = new EndpointAddressBuilder();
b.Uri = new UriBuilder().Uri;
var e = b.ToEndpointAddress();

API не даёт никаких подсказок о том, что использование URI необходимо. Здесь появляется временная связность между установкой свойства URI и вызовом метода ToEndpointAddress.
Далее мы рассмотрим более завершённый пример, и я дам руководство по улучшению API в сторону Poka-yoke.

Пример «запаха».
Этот пример описывает более абстрактный «запах», показанный в классе Smell. Открытый API может выглядеть следующим образом:
public class Smell
{
    public void Initialize(string name)
 
    public string Spread()
}

Семантически, имя метода Initialize является ключом к разгадке, но на структурном уровне этот API не указывает нам на наличие временной связности. Таким образом, следующий код компилируется, но рушится в процессе исполнения:
var s = new Smell();
var n = s.Spread();

Выясняется, что метод Spread выбрасывает InvalidOperationException, потому что объект Smell не был инициализирован с именем. Проблема с классом Smell заключается в том, что он не защищает правильно свои инварианты. Другими словами, инкапсуляция нарушена.
Чтобы решить проблему, метод Initialize должен быть вызван до вызова метода Spread:
var sut = new Smell();
sut.Initialize("Бабочка из семейства белянок");
var n = sut.Spread();

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

Исправление: инъекция через конструктор
Инкапсуляция требует того, чтобы класс никогда не находился в противоречивом состоянии. С учётом того, что имя является необходимым для класса Smell, гарантия его присутствия должны быть встроена в класс. Если невозможно предоставить имени значение по умолчанию, то имя должно быть запрошено в конструкторе класса Smell:
public class Fragrance : IFragrance
{
    private readonly string name;
 
    public Fragrance(string name)
    {
        if (name == null)
        {
            throw new ArgumentNullException("name");
        }
 
        this.name = name;
    }
 
    public string Spread()
    {
        return this.name;
    }
}

Это эффективная гарантия того, что имя является всегда доступным во всех экземплярах класса. Здесь также присутствуют и другие положительные эффекты:
  • сокращена цикломатическая сложность класса;
  • класс теперь является неизменяемым, а значит, потокобезопасным.

Однако, иногда так случается, что исходная версия класса реализует интерфейс, который является причиной временной связности. Вот пример:
public interface ISmell
{
    void Initialize(string name);
 
    string Spread();
}

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

Исправление: абстрактная фабрика
Для того, чтобы отделить методы в интерфейсе ISmell (ха-ха), метод Initialize может быть перемещён в новый интерфейс. Вместо изменения состояния (противоречивого) класса, метод Create (бывший Initialize) возвращает новый экземпляр интерфейса IFragrance:
public interface IFragranceFactory
{
    IFragrance Create(string name);
}

Простая реализация:
public class FragranceFactory : IFragranceFactory
{
    public IFragrance Create(string name)
    {
        if (name == null)
        {
            throw new ArgumentNullException("name");
        }
        return new Fragrance(name);
    }
}

Это обеспечивает инкапсуляцию, поскольку оба класса – FragranceFactory и Fragrance защищают свои инварианты. Они никогда не смогут находиться в противоречивом состоянии. Клиент, который ранее взаимодействовал с интерфейсом ISmell, теперь может использовать комбинацию IFragranceFactory/IFragrance для получения той же функциональности:
var f = factory.Create(name);
var n = f.Spread();

Так значительно лучше, поскольку неправильное использование API теперь будет определяться во время компиляции, а не во время выполнения. Интересным побочным эффектом по мере продвижения к статически декларированной структуре взаимодействия является то, что классы стремятся к неизменяемости. Неизменяемые классы автоматически становятся потокобезопасными, что является всё более важным качеством в новую (относительно) эру многоядерности.
Метки:
Поделиться публикацией
Похожие публикации
Комментарии 5
  • 0
    От временной связности не всегда можно избавиться. Временная связность API часто диктуется самой природой используемых абстракций над чем-либо. Многие процессы просто должны выполняться последовательно во времени. Ну вот, например, возьмём некую сущность, которая должна быть сначала создана, затем подготовлена, затем выполнена, а затем мы можем спросить у неё о результатах выполнения. Получается такой API:

    1. Create (Создать)
    2. Prepare (Подготовить состояние)
    3. Run (Выполнить работу)
    4. GetResult (Получить результаты)

    Как в этом случае избавиться от временной связности? Нельзя получить результаты, не выполнив работы. Можно резонно заметить, что можно запихать все эти 4 действия в одно, на что я отвечу, что это сделает API негибким и перегруженным, сломает логику и модульность, т. к. данная сущность может быть подготовлена один раз, а работу выполнить несколько раз над разными объектами. К тому же подготовка состоянием по умолчанию может занимать длительное время, а требуется не всегда, а также не всегда требуются результаты работы. В общем, всё не так хорошо как часто рассуждают "Астронавты от Архитектуры".
    • 0
      Такой код можно свести к следующему, на каждом шаге задействовав свой тип (интерфейс):

      factory = MyClass.Create();
      worker = factory.Prepare(1,2,3);
      task = worker.Run();
      result = task.GetResult();


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

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

        • 0
          Как вариант — использовать флаги вызван/не вызван с подробным описанием ошибки и, прямо в ней, примером правильной последовательности вызовов.
          • 0
            В идеале, ошибочный код не должен ждать, пока его запустят, он не должен компилироваться.
            Если разработчик написал несколько раз неправильно, и на некоторой ветке исполнения код дал ему по рукам, то нет гарантии (кроме тестирования со 100% покрытием), что остальные участки кода будут корректно исправлены.

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.