Pull to refresh

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

Reading time 4 min
Views 9.2K
Original author: Mark Seemann
Это первый пост из серии о 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 теперь будет определяться во время компиляции, а не во время выполнения. Интересным побочным эффектом по мере продвижения к статически декларированной структуре взаимодействия является то, что классы стремятся к неизменяемости. Неизменяемые классы автоматически становятся потокобезопасными, что является всё более важным качеством в новую (относительно) эру многоядерности.
Tags:
Hubs:
+7
Comments 5
Comments Comments 5

Articles