вторник, 2 сентября 2014 г.

Open/Closed Principle. ФП vs. ООП

Цикл статей о SOLID принципах

--------------------------------------------------

Как мы выяснили в прошлый раз, принцип открыт/закрыт подразумевает две вещи: необходимость фиксации интерфейса с возможностью изменения реализации, а также расширяемость за счет использования наследования и полиморфизма.

Давайте рассмотрим вопрос расширяемости в рамках семейства типов более подробно.

Большинство примеров, показывающих пользу «открытости» модулей обычно сводятся к демонстрации мощи полиморфизма над старым структурным подходом: «Смотрите, как здорово, когда мы избавляемся от конструкции switch в функции Draw и переносим всю логику в класс Shape и его наследники! Теперь наше решение является расширяемым и соответствует принципу Открыт/Закрыт, поскольку мы легко можем к квадрату и треугольнику добавить еще ромб с кругом!».

Да, действительно, добавить новый класс в существующую иерархию довольно легко, но что если мы хотим добавить в существующую иерархию новую операцию, например, метод GetArea в иерархию Shapes?

Добавление нового абстрактного метода в абстрактный класс Shape является «ломающим» изменением (breaking change), и потребует изменения всех классов наследников. Когда вся иерархия контролируется одним человеком, то это не проблема, но если речь касается библиотеки или широкоиспользуемых классов, то быть беде.

Расширяемость в ОО-мире

ОО подход подразумевает единство данных и операций, но это не всегда удобно или возможно. Рассматривая даже простой пример с фигурами, можно представить себе разные «контексты» использования классов фигур: у нас могут быть модели с бизнес-логикой, а также объекты, которые будут себя рисовать (в DDD даже существует специальное понятие для описание разных контекстов, под названием Bounded Contexts).

Мы не можем держать столь разнообразную логику в одной иерархии классов, поэтому у нас появляются две параллельные иерархии: ShapeModels и DrawingShapes. В таком случае, идеальный мир «открытости/закрытости» начинает рушиться, поскольку добавление нового типа фигуры влечет за собой изменение уже двух иерархий:

image

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

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

Паттерн «Посетитель»

Давайте рассмотрим реальный пример. Мой плагин для поддержки контрактного программирования показывает ошибки использования контрактов во время редактирования кода, аналогично тем, что выдает Code Contract Compiler во время компиляции:

image

Для поддержки этой функциональности необходимо проанализировать каждый контрактный оператор (contract statement) и определить, является ли он корректным, или некорректным с точки зрения библиотеки Code Contracts. При этом уровней «некорректности» несколько: это может быть ошибка или предупреждение от компилятора, или же это может быть мое собственное предупреждение (Custom Warning).

Вся логика валидации сосредоточена в нескольких классах – ContractBlockValidator и ContractStatementValidator, а результат валидации представлен в виде следующей иерархии:

image

Как и в случае с деревьями выражений, я не могу поместить всю логику непосредственно в классы ValidationResult. Вернувшись к терминам DDD, данная иерархия представляет собой неизменяемые объекты-значения (Immutable Value Objects), которые содержат лишь логику получения строкового представления, и не знают, каким образом их будут использовать. Результаты валидации используются различным образом, начиная от показа ошибок и предупреждений, заканчивая исправлениями (Quick Fixes) этих проблем, если такое возможно.

Можно сказать, что в данном случае сама иерархия классов является стабильной, а набор операций над ней – нет.

Как мы обычно решаем такую проблему? Например, с помощью паттерна «Посетитель». Для этого в класс ValidationResult добавляется абстрактный метод Accept, который принимает IValidationResultVisitor, а каждый класс-наследник просто вызывает метод Visit:

public interface IValidationResultVisitor
{
   
void Visit(NoErrorValidationResult
vr);
   
void Visit(ContractErrorValidationResult
vr);
   
void Visit(ContractWarningValidationResult
vr);
   
void Visit(CustomWarningValidationResult
vr);
}

public abstract class ValidationResult
{
   
private ICSharpStatement
_statement;

   
protected ValidationResult(ICSharpStatement
statement)
    {
       
Contract.Requires(statement != null
);
        _statement
=
statement;
    }

   
public abstract void Accept(IValidationResultVisitor
visitor);
}

public sealed class ContractErrorValidationResult : ValidationResult
{
   
// Другие методы пропущены ...

   
public override void Accept(IValidationResultVisitor
visitor)
    {
       
// Благодаря overload resolution будет вызван
        // IValidationResultVisitor.Visit(ContractErrorValidationResult)
        visitor.Visit(this);
    }
}

Теперь, когда мне понадобиться проверить, является ли проблема «устранимой» с помощью быстрых действий (Quick Fixes), я могу создать класс IsIssueFixableVisitor и реализовать в нем все перегруженные версии метода Visit.

class IsIssueFixableVisitor : IValidationResultVisitor
{
   
public bool IsIssueFixable { get; private set
; }
   
public void Visit(NoErrorValidationResult
vr)
    {
        IsIssueFixable
= false
;
    }

   
public void Visit(ContractErrorValidationResult
vr)
    {
        IsIssueFixable
=
          vr.Error == MalformedContractError.
VoidReturnMethodCall;
    }

   
public void Visit(ContractWarningValidationResult
vr)
    {
        IsIssueFixable
=
          vr.Warning == MalformedContractWarning.
NonVoidReturnMethodCall;
    }

   
public void Visit(CustomWarningValidationResult
vr)
    {
        IsIssueFixable
= false;
    }
}

Каждый «фикс» содержит свою уникальную логику, поэтому для каждого из них нужен свой посетитель. Так, представленный выше посетитель будет содержать true в свойстве IsIssueFixable, если внутри контрактного блока находится вызов метода (Code Contract Compiler выдает ошибку при использовании внутри блока контрактов void-методов, но выдает предупреждение при вызове метода с возвращающим значением!)

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

Размеченные объединения и сопоставление с образцом

А что если вместо использование интерфейса IValidationResultVisitor с набором перегруженных методов, мы воспользуемся набором делегатов? Для этого, метод Accept мы переименуем в Match, который будет принимать несколько делегатов для обработки конкретных типов иерархии ValidationResult:

public T Match<T>(
   
Func<CodeContractErrorValidationResult, T>
errorMatch,
   
Func<CodeContractWarningValidationResult, T>
warningMatch,
   
Func<ValidationResult, T>
defaultMatch)
{

   
var errorResult = this as CodeContractErrorValidationResult
;
   
if (errorResult != null
)
       
return
errorMatch(errorResult);

   
var warningResult = this as CodeContractWarningValidationResult
;
   
if (warningResult != null
)
       
return
warningMatch(warningResult);

   
return defaultMatch(this);
}

Теперь, вместо создания специализированного посетителя, я могу воспользоваться вызовом метода Match напрямую:

ValidationResult vr = GetValidationResult();
bool isFixable = vr.
Match(
    error
=> error.Error ==
        MalformedContractError.
VoidReturnMethodCall,
    warning
=> warning.Warning ==
        MalformedContractWarning.
NonVoidReturnMethodCall,
    @default
=> false);

Разница между ФП-посетителем на основе делегатов и классическим ОО-посетителем примерна такая же, как и между ФП и ОО «стратегиями»: в некоторых случаях значительно удобнее создавать именованный класс, реализующий сложную стратегию сохранения или шифрования данных; но когда речь заходит о сравнении или сортировки объектов, то стратегия на основе лямбда-выражений будет выглядеть более предпочтительной.

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

Мы можем добавить несколько перегруженных методов Match, которые будут принимать не все возможные типы иерархии, а лишь некоторые наиболее часто используемые. При этом метод Match может находится в базовом классе иерархии наследования, а может быть реализован в виде метода расширения в классе ValidationResultEx.

ПРИМЕЧАНИЕ
Сейчас идет обсуждение возможности добавления сопоставления с образцом в C#. Подробнее см. здесь: https://roslyn.codeplex.com/discussions/560339

Большинство функциональных языков программирования поддерживают возможность «разбора вариантов» прямо из коробки, а также поддерживают возможность создания «вариантов» более удобным способом.

Так, если взять F#, то весь код иерархии ValidationResult будет выглядеть так:

// Перечень ошибок компилятора Code Contract
type MalformedContractError =
    | VoidReturnMethodCall
    | Assignment
    | ContractWithinTryBlock
 

// Перечень предупреждений компилятора Code Contract
type MalformedContractWarning =
    | NonVoidReturnMethodCall
 

// Создаем «иерархию» типов ValidationResult
type ValidationResult =
    | NoError
    | ContractError
of Error:
MalformedContractError
    | ContractWarning
of Warning: MalformedContractWarning
    | CustomWarning

При этом никакие методы Match не понадобятся, поскольку F#, как и любой другой функциональный язык поддерживают сопоставление с образцом (pattern matching) из коробки:

let vr = GetValidationResult()
let isFixable =
 
   
match vr with
    | ContractError(Error = er) -> er = MalformedContractError.
VoidReturnMethodCall
    | ContractWarning(Warning
= wr) -> wr = MalformedContractWarning.
NonVoidReturnMethodCall
    | _
-> 
false

ПРИМЕЧАНИЕ
Пример с размеченными объединениями показывает еще один способ получения новых типов на основе существующих. Обычный класс или кортеж (tuple) создает новый тип путем объединение внутренних членов по «И»: класс Point содержит X И Y, класс Person – Id И Name, Tuple<int, string> - неименованные поля типа int И string. Размеченное объединение создает новый тип путем объединения членов по «ИЛИ»: результат валидации – это Ok, Error или Warning; в результате анализа данных мы получим int ИЛИ string и т.п.
В ОО мире для объединения членов по «ИЛИ» используем наследование, а в ФП мире – размеченные объединения (discriminated unions или tagged union).

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

Заключение

Приведенные выше примеры показывают «ортогональность» ОО и ФП подходов. Классический ОО подход (без использования посетителей) позволяет легко добавлять новые классы в существующую иерархию, а ФП подход позволяет легко добавлять в стабильную иерархию новые операции. Проблема «однобокости» каждого из решений является одной из классических проблем нашего мира и носит название Expression Problem.

Во время дизайна модуля нужно принять решение о том, в каком направлении упрощать расширяемость. Если более вероятным является добавление новых типов, то более подходящим будет классический ОО-подход на основе наследования. Если более вероятным является добавление новых операций в существующую иерархию типов, то более подходящим будет ФП-подход на основе размеченных объединений или ОО-подход на основе паттерна «Посетитель».

Дополнительные ссылки

3 комментария:

  1. Опечаточка "такой предметной области являются деревья выражениЙ"

    ОтветитьУдалить
  2. Великолепная статья! Буду направлять людей на нее дабы читали до просветления. :)

    ОтветитьУдалить
    Ответы
    1. Жду наплыва посетителей:)
      А если серьезно, то спасибо!

      Удалить