Pull to refresh

Паттерны Command и Strategy с точки зрения функционального программирования

Reading time9 min
Views13K
В результате изучения функционального программирования в моей голове появились некоторые мысли, которыми я хочу с вами поделиться.

Паттерны проектирования и функциональное программирование? Как это вообще связано?


В умах многих разработчиков, привыкших к объектно-ориентированной парадигме, возникает впечатление, что проектирование программного обеспечения, как таковое, неразрывно связано с ООП и всё остальное — суть ересь. UML, большей частью нацеленный на ООП, используется как универсальный язык для проектирования — хотя он таким, конечно, не является. И мы видим, как мир объектно-ориентированного программирования постепенно погружается в пучину преступного переинженеринга (1).
В силу этого зачастую даже не ставится вопрос о выборе парадигмы программирования. Тем не менее, этот вопрос является весьма существенным, и зачастую правильный ответ даёт большие преимущества (3). Это, вообще говоря, выходит за рамки того, что мы привыкли называть проектированием — это вопрос из области архитектуры.

Лирическое отступление: разница между архитектурой, проектированием и реализацией

Не так давно я наткнулся на весьма интересное исследование — (2). В нём рассматривается задача формализации понятий «архитектура», «проектирование» и «реализация», которые чаще всего употребляются неформально. И авторам удаётся вывести весьма интересный критерий: критерий Intension/Locality. Я не буду углубляться в философию и просто приведу краткое описание критерия (эта часть — фактически перевод) и мои выводы из него.
Свойство Intension (интенсионность) означает способность некой сущности описывать бесконечное множество предметов: например, понятие простого числа. Ему противоположно свойство экстенсионности — сущность описывает конечный набор предметов: например, понятие страны — члены НАТО.
Свойство локальности — сущность влияет только на отдельную часть системы. Соответственно, глобальность — сущность влияет на всю систему в целом.
Дак вот, учитывая эти два свойства, авторы указанного исследования составляют такую таблицу:
image
Пользуясь ей легко определить, что относится к уровню архитектуры, а что — к уровню проектирования. И вот мой вывод: выбор парадигмы программирования, платформы и языка — это решение уровня архитектуры, т.к. этот выбор глобален (затрагивает все части системы) и интенсионен (парадигмы определяют способы решения бесконечного множества задач).

Тем не менее, решить столь глобальную задачу (найти критерии выбора подходящей парадигмы) мне пока не по силам. Поэтому я решил выбрать два уже существующих класса задач и показать, что для них стоит использовать не привычный для многих ОО подход, а функциональный, который в последнее время приобретает (заслуженно) всё большую популярность.
Классы задач я выбрал необычным методом — я взял два паттерна ОО проектирования и показал, что они, по сути — ограниченная реализация понятия из области функционального программирования — функции высшего порядка (higher-order function, далее: ФВП). Гипотеза заключалась в том, что паттерны — это устоявшиеся решения определённых проблем, а раз возникают проблемы и их устоявшиеся решения, видимо есть некие слабости и недостатки, которые приходиться преодолевать. Для рассмотренных паттернов это действительно так.
Кстати говоря, подобный подход был использован в (5) и (6). В (6) вообще было указано на возможность замены большинства паттернов, но подробный анализ каждого не проводился. В (5) было более подробное рассмотрение Command и Strategy, но немного с другой стороны. Я решил сделать что-то более практичное, чем в (6), и с другими акцентами, чем в (5). Итак, приступим.

Higher-order functions


Думаю, практически все в той или иной форме знакомы с этой идей.
Функция высшего порядка – это функция, которая принимает в качестве аргумента или возвращает как результат другую функцию.
Такое становится возможным благодаря основной концепции функционального программирования: функции – это значения. Стоит отметить, что когда мы говорим, что функция и значение в функциональном программировании полностью соответствуют аналогичным понятиям из математики, мы имеем в виду именно полное соответствие. Это одно и то же. Пример широко распространённых в математике ФВП – операторы дифференцирования, интегрирования и композиции (вообще говоря, это близко к понятию оператора из функционального анализа). Оператор композиции имеет непосредственное выражение в большинстве языков, поддерживающих функциональную парадигму. Пример на F#:

let f = (+) 10
let g = (*) 2
let composition = f << g
printfn "%i" <| g 15
printfn "%i" <| f 30
printfn "%i" <| composition 15

Вывод:

30
40
40

Очевидно, что запись f << g соответствует записи f(g(x)) или F ○ G.
Чтобы лучше понять это, предлагаю обратить внимание на тип оператора композиции:

('a -> 'b) -> ('c -> 'a) -> 'c -> 'b

Участки описания типа функции в скобках — это тоже типы функций. То есть, это функция, принимающая в качестве аргументов:
  • Функцию, принимающую в качестве аргумента значение обобщённого типа 'a и возвращающую значение обобщённого типа 'b
  • Функцию, принимающую в качестве аргумента значение обобщённого типа 'c и возвращающую значение обобщённого типа 'a
  • Значение типа 'c

и возвращающая значение типа 'b. Фактически же она строит функцию, принимающую в качестве аргумента значение типа 'c и возвращающую значение типа 'b, т.е. тип можно переписать так:

('a -> 'b) -> ('c -> 'a) -> ('c -> 'b)

ФВП позволяют выделять общее поведение. За счёт этого они улучшают повторную используемость кода.
Это можно применить для разных целей – например, для обработки исключений. Предположим, что у нас есть много участков кода, которые могут вызвать определённый набор исключений. Мы можем записывать сам склонный к ошибкам код в виде функций, которые будем передавать в качестве параметра другой функции, производящей обработку исключений. Пример на C#:

private void CalculateAdditionalQuantityToIncreaseGain()
        {
                //получаем данные
                var unitPrice = ExtractDecimal(gainUnitPriceEdit);
                var quantityReleased = ExtractDecimal(gainQuantityEdit);
                ...
        }


А вот ФВП, обрабатывающая исключения:

private static void ExecuteErrorProneCode(Action procedure)
{
            try
            {
                procedure(); //исполняем переданную в качестве параметра функцию
            }
            catch (WrongDecimalInputInTextBoxException ex)
            {
                MessageBox.Show(ex.Message, "Ошибка во вводе");
            }
            catch (Exception ex)
            {
                MessageBox.Show(ex.Message, "Ошибка");
            }
}

Тогда для обработки исключений, вызванных какой-либо функцией, достаточно писать:

ExecuteErrorProneCode(CalculateAdditionalQuantityToIncreaseGain);

Это существенно сокращает код, если обработчиков исключений много и/или много функций, который могут выкинуть исключения, которые необходимо обработать однотипно.
Также классическим примером выделения общего поведения является использование функций высшего порядка для сортировки. Очевидно, что для проведения сортировки необходимо иметь возможность сравнить элементы сортируемой коллекции между собой. В качестве такого «сравнителя» выступает функция, которая передаётся в качестве аргумента функции сортировки – соответственно, функция сортировки для обеспечения универсальности должна быть ФВП. В целом, возможность создавать ФВП – критически важное звено в цепочке действий, направленных на создание абстрактных обобщённых алгоритмов.
Кстати, так как ФВП, как и любые функции, являются значениями, их можно использовать для представления данных. Об этом смотрите статью про представление Чёрча.

Паттерн Command


Паттерн проектирования Command, как и Strategy, относится к поведенческим паттернам проектирования. Его основная роль — инкапсуляция некой функции. У этого паттерна много применений, но чаще всего он используется для того, чтобы сделать следующее:
  • Посылать запросы к разным получателям
  • Выстраивать команды в очереди, вести логи, отменять запросы
  • Создавать сложные операции из простых
  • Реализовать команды Undo (отмена последнего действия) и Redo (повтор последнего отменённого действия)

В общем случае он выглядит так:

image

Я буду рассматривать пример с реализацией undo и redo — чистую ОО версию реализации этой функциональности вы можете посмотреть тут.
Распределение ролей на этой диаграмме:

image

Здесь Filter соответствует Receiver'у, LoggingInvokerInvoker'у, IFilterCommandICommand. Вот как мы будем вызывать операции (создавать команды можно как в Client'e, передавая их как параметр методу Execute() LoggingInvoker'а, так и в самом LoggingInvoker'e — выбор зависит от конкретной ситуации):

image

А вот как мы их будем отменять:

image

performedOps и undoneOps — это стеки, хранящие исполненные и отменённые команды.
Однако, после рассмотрения ФВП довольно очевидно, что всё это поведение можно реализовать в виде ФВП, если выбранный язык поддерживает такую возможность. Действительно, объект Invoker можно заменить ФВП, принимающей в качестве аргумента функцию, соответствующую конкретной операции – нам больше не нужны объекты Command, потому что функции сами являются значениями, и интерфейс ICommand, т.к. его функции выполняет система типов языка, поддерживающего функциональную парадигму.
Приведём схему замены этого паттерна на конструкцию в функциональной парадигме, могущей выполнять те же функции:

image

На псевдокоде (инспирированном F#) соответствующая функциональная реализация будет выглядеть так:

//стеки для выполненных и отменённых операций
//здесь мы создаём тип - кортеж из функции и первоначальных данных
type OpType = (DataType -> DataType) * DataType
//и стеки из значений этого типа
let performedOps = Stack<OpType>()
let undoneOps = Stack<OpType>()
 
//замена LoggingInvoker - ФВП
let execute operation data = 
    let res = operation data //выполняем операцию
    performedOps.Push(operation, data) //заносим операцию и предыдущее состояние в стек
    res //возвращаем результат выполнения операции
 
let undo () =
    if performedOps.Count > 0 then
        //переносим запись об операции из стека выполненных в стек отменённых
        undoneOps.Push(performedOps.Pop())
        //и возвращаем состояние до выполнения операции
        Some (snd undoneOps.Peek()) //здесь мы используем обобщённый тип 'a option, см. (5) или (7)
    else
        None
 
//Операции
let OperationOne data = 
    ...
 
let OperationTwo data = 
    ...

//выполняем операцию OperationOne 
let mutable a = execute OperationOne data
//отменяем операцию
let b <- undo ()

Мы передаём функцию, которую хотим выполнить, ФВП execute. Функция execute ведёт стек выполненных операций, выполняет операцию и возвращает результат её выполнения. Функция undo отменяет последнюю выполненную операцию.

Данный подход имеет некоторые дополнительные преимущества перед использованием паттерна Command:
  1. Результирующий код естественнее, короче и проще
  2. Можно легко создавать макросы и сложные операции с помощью композиции или pipe-lining’a (о pipe-lining'e см. (5) или (7)) простых операций
  3. Можно создавать сложные структуры данных, содержащие операции, например для динамического построения меню.

Кроме того, если мы используем мультипарадигменный язык, мы можем сочетать в разных пропорциях ОО паттерн Command и продемонстрированный здесь подход.
Многие современные языки поддерживают ФВП в той или иной степени. Например, в C# есть механизм делегатов. Пример решения задачи о создании undo с помощью делегатов вы можете найти в (4).

Паттерн Strategy


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

image

Контекст хранит в себе ссылку на одну из реализаций интерфейса IStrategy, при необходимости выполнить некую операцию он обращается к методу этого хранимого объекта. Меняя объекты — меняем методы.
Это так же легко преобразуется к функциональному стилю. На этот раз мы можем использовать список функций, чтобы сохранить возможные стратегии:

image

На псевдокоде:

let strategyA data = ...
let strategyB data = ...
let useStrategy strategy data =
    ...
    strategy data

...
useStrategy strategyA data

Функции strategyA, strategyB,… – это функции, реализующие возможные стратегии. Функция высшего порядка useStrategy применяет выбранную стратегию к данным. Стратегия передаётся просто как аргумент для функции useStrategy.
Кроме значительного упрощения и сокращения кода этот подход даёт нам дополнительное преимущество – теперь мы можем легко создавать функции, параметризованные сразу несколькими стратегиями, что при обычном ОО подходе приводит к очень сложной структуре программы. Мы можем вообще не задавать отдельные имена для стратегий с помощью такой возможности, как анонимные функции, если они достаточно просты в реализации. Например для сортировки данных в ФП можно использовать ФВП sort и в качестве параметра передавать ей не тип, который реализует интерфейс IComparer, в котором реализован метод сравнения, как это делается в ООП, а просто саму операцию сравнения:

let a = sort (<) data

Выводы


1. Правильный выбор парадигмы в соответствии с классом решаемой задачи зачастую может быть критическим фактором для успешности её решения. Если ваша задача относится к классу т.н. behavior-centric, стоит задуматься об использовании функционального подхода.
2. Паттерны Command и Strategy — это ограниченная реализация функций высшего порядка
3. Не обязательно переходить на чисто функциональный язык, чтобы использовать преимущества решения с помощью ФВП — большинство современных мейнстримовых языков в той или иной мере поддерживают ФВП.

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

Источники


1. Criminal Overengineering
2. Architecture, Design, Implementation. Amnon H. Eden, Rick Kazman. Portland: б.н., 2003. 25th International Conference on Software Engineering — ICSE
3. Banking Firm Uses Functional Language to Speed Development by 50 Percent. Microsoft Case Studies. март 2010 г.
4. Bishop, Judith. C# 3.0 Design Patterns. Sebastopol, California: O’Reilly, 2008.
5. Tomas Petricek, Jon Skeet. Functional Programming for the Real World. б.м.: Manning Publications, 2010.
6. Gabriel, Richard P. Objects Have Failed Slides DreamSongs.com.
7. Smith, Chris. Programming F#. Sebastopol, California: O’Reilly, 2010.

UDP:
alexeyrom написал очень полезный комментарий, с его согласия выношу в тело поста, чтобы было видно:

«Норвиг в 1996 году рассматривал паттерны в Lisp и Dylan. Собственно, результат аналогичный (многие паттерны становятся тривиальными или существенно упрощаются), но на более богатом материале.»
Tags:
Hubs:
+42
Comments94

Articles