Функциональное мышление: Часть 2. Шаблоны проектирования для функционального программирования

Решение одной и той же проблемы с точки зрения различных парадигм программирования

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

Нил Форд, Архитектор приложений, ThoughtWorks

Нил Форд (Neal Ford) работает архитектором приложений в ThoughtWorks, революционной компании, предоставляющей профессиональные IT-услуги и помогающей талантливым людям по всему миру эффективнее использовать программное обеспечение. Он также является проектировщиком и разработчиком приложений, учебных материалов, журнальных статей, учебных курсов, видео/DVD-презентаций и автором книг "Разработка с Delphi: Объектно-ориентированный подход", "JBuilder 3 Unleashed" и "Искусство разработки Web-приложений на Java". Он специализируется на консультациях по построению широкомасштабных корпоративных приложений. Он также является общепризнанным докладчиком и выступал на многочисленных конференциях разработчиков по всему миру. С ним можно связаться по адресу: nford@thoughtworks.com.



28.09.2012

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

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

Об этой серии статей

Эта серия статей призвана переориентировать читателя в "функциональном" направлении и помочь ему взглянуть на проблемы под новым углом, чтобы улучшить повседневное написание кода. В ней изучаются принципы функционального программирования и инфраструктуры, позволяющие использовать функциональное программирование в языке Java™. Так, в статьях рассматриваются функциональные языки программирования, работающие на виртуальной Java-машине, и некоторые направления будущего развития языков программирования. Эта серия статей предназначена для программистов, знающих язык Java и работу его абстракций, но не имеющих опыта в использовании функциональных языков.

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

Реализация Adapter в Java

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

Рисунок 1. Квадрат, вложенный в круг
Рисунок 1. Квадрат, вложенный в круг

Чтобы определить, вложится ли квадрат в круг, я воспользуюсь формулой, изображенной на рисунке 2.

Рисунок 2. Формула для определения соотношения размеров квадрата и круга
Рисунок 2. Формула для определения соотношения размеров квадрата и круга

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

Я могу легко решить проблему вложения квадрата в круг с помощью простого вспомогательного класса, выполняющего преобразование. Но это только один из вариантов более крупной проблемы. Например, представим, что мне требуется встроить объект типа Button (кнопка) в объект типа Panel (панель), который ранее не предполагалось использовать совместно с Button. Возникает вопрос, как обеспечить совместимость в данном случае? Проблема вложения квадрата в круг – это удобное упрощение более общей проблемы, решаемой шаблоном проектирования Adapter: адаптации двух несовместимых интерфейсов. Чтобы обеспечить совместное использование окружностей и квадратов, мне потребуется несколько классов и интерфейсов для реализации шаблона Adapter, как показано в листинге 1.

Листинг 1. Классы, необходимые для решения задачи вложения квадрата в круг в Java
public class SquarePeg {
    private int width;

    public SquarePeg(int width) {
        this.width = width;
    }

    public int getWidth() {
        return width;
    }
}

public interface Circularity {
    public double getRadius();
}

public class RoundPeg implements Circularity {
    private double radius;

    public double getRadius() {
        return radius;
    }

    public RoundPeg(int radius) {
        this.radius = radius;
    }
}

public class RoundHole {
    private double radius;

    public RoundHole(double radius) {
        this.radius = radius;
    }

    public boolean pegFits(Circularity peg) {
        return peg.getRadius() <= radius;
    }
    
}

Чтобы сократить количество Java-кода, я добавил интерфейс Circularity (окружность), показывающий, что реализующие его объекты обладают радиусом. Это позволило мне написать класс RoundHole, учитывающий все круглые предметы, а не только сущности типа RoundPeg (квадрат, вписанный в круг). Это стандартное допущение для шаблона Adapter, которое позволяет упростить определение типов.

Чтобы поместить квадрат в окружность, нам потребуется адаптер, добавляющий поддержку интерфейса Circularity к объектам типа SquarePeg за счет раскрытия метода getRadius(), как показано в листинге 2.

Листинг 2. Адаптер для решения задачи вложения квадрата в круг
public class SquarePegAdaptor implements Circularity {
    private SquarePeg peg;

    public SquarePegAdaptor(SquarePeg peg) {
        this.peg = peg;
    }

    public double getRadius() {
        return Math.sqrt(Math.pow((peg.getWidth()/2), 2) * 2);
    }
}

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

Листинг 3. Тестирование адаптера
@Test
public void square_pegs_in_round_holes() {
    RoundHole hole = new RoundHole(4.0);
    Circularity peg;
    for (int i = 3; i <= 10; i++) {
        peg = new SquarePegAdaptor(new SquarePeg(i));
        if (i < 6)
            assertTrue(hole.pegFits(peg));
        else
            assertFalse(hole.pegFits(peg));
    }
}

В листинге 3 для каждой предполагаемой длины стороны квадрата при создании объекта SquarePeg я помещаю его в объект SquarePegAdaptor, чтобы затем использовать метод pegFits() для оценки совместимости квадрата и круга.

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


Динамический адаптер в Groovy

Язык Groovy (см. раздел "Ресурсы") поддерживает несколько парадигм из области разработки ПО, которые отсутствуют в Java, поэтому я воспользуюсь именно им для реализации оставшихся примеров. Сначала я реализую стандартный шаблон Adapter из листинга 2, но уже средствами Groovy, как показано в листинге 4.

Листинг 4. Реализация шаблона проектирования Adapter в Groovy
class SquarePeg {
    def width
}

class RoundPeg {
    def radius
}

class RoundHole {
    def radius

    def pegFits(peg) {
        peg.radius <= radius
    }
}

class SquarePegAdapter {
    def peg

    def getRadius() {
        Math.sqrt(((peg.width/2) ** 2)*2)
    }
}

Самым заметным различием между Java-версией из листинга 2 и Groovy-версией из листинга 4 будет количество кода. Groovy спроектирован так, чтобы исключить некоторые повторы, свойственные Java, с помощью динамической типизации и других удобных возможностей. Например, последняя строка метода автоматически используется в качестве возвращаемого им значения, как показано в методе getRadius().

В листинге 5 приведен unit-тест для Groovy-версии адаптера.

Листинг 5. Тестирование стандартного адаптера в Groovy
@Test void pegs_and_holes() {
    def hole = new RoundHole(radius:4.0)
    (4..7).each { w ->
        def peg = new SquarePegAdapter(
                peg:new SquarePeg(width:w))
        if (w < 6 )
            assertTrue hole.pegFits(peg)
        else
            assertFalse hole.pegFits(peg)
    }        
}

В листинге 5 я использую еще одно преимущество Groovy – конструктор, принимающий на вход пары имя / значение, который автоматически генерируется, когда я создаю объекты типов RoundHole, SquarePegAdaptor и SquarePeg.

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


Создание адаптеров с помощью метапрограммирования

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

ExpandoMetaClass

Открытые классы являются стандартной возможностью динамических языков. Эта возможность позволяет "открывать" существующие классы (собственные или системные, например, String или Object), чтобы добавить, удалить или изменить их методы. Открытые классы часто используются в узкоспециализированных языках (DSL - domain-specific languages) и для построения гибких интерфейсов. В Groovy есть два подхода к открытию классов: категории и ExpandoMetaClass, который я и использую в своих примерах.

Класс ExpandoMetaClass позволяет добавлять новые методы к классам или отдельным экземплярам объектов. В случае с адаптером мне необходимо добавить свойство "радиус" к классу SquarePeg, чтобы я смог проверить, помещается ли данный квадрат в окружность, как показано в листинге 6.

Листинг 6. Использование ExpandoMetaClass для добавления свойства "радиус" к квадрату
static {
    SquarePeg.metaClass.getRadius = { -> 
        Math.sqrt(((delegate.width/2) ** 2)*2)
    }
}

@Test void expando_adapter() {
    def hole = new RoundHole(radius:4.0)
    (4..7).each { w ->
        def peg = new SquarePeg(width:w)
        if (w < 6)
            assertTrue hole.pegFits(peg)
        else
            assertFalse hole.pegFits(peg)
    }        
}

У каждого класса в Groovy есть предопределенное свойство metaclass, открывающее доступ к объекту ExpandoMetaClass данного класса. В листинге 6 я использую это свойство, чтобы добавить метод getRadius(), использующий известную формулу, в класс SquarePeg. При использовании ExpandoMetaClass важно соблюдать очередность: я должен убедиться, что метод уже добавлен, прежде чем вызывать его в unit-тесте. Поэтому я добавляю новый метод в инициализирующем static-блоке, который добавит метод в класс SquarePeg перед загрузкой тестирующего класса. После добавления метода getRadius() в класс SquarePeg я могу передать его в метод hole.pegFits, и Groovy возьмёт на себя всю остальную работу.

Используя ExpandoMetaClass, нам удалось получить более короткую версию, чем при использовании шаблона. И она получилась практически "незаметной", хотя это можно считать и одним из недостатков. Добавление методов к существующим классам следует выполнять с осторожностью; это хотя и удобно, но порождает "невидимую" функциональность, при отладке которой могут возникнуть трудности. Это допустимо в некоторых случаях, например, в DSL или при глубоких изменениях в существующей инфраструктуре, когда это требуется технологическим каркасам.

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

Динамические адаптеры

Groovy оптимизирован для интеграции с Java, включая даже те места, где синтаксис Java особенно строг. Например, динамическая генерация классов не является простой задачей в Java, но Groovy решает её с легкостью. Это позволяет мне сгенерировать класс адаптера на лету, как показано в листинге 7.

Листинг 7. Использование динамических адаптеров
def roundPegOf(squarePeg) {
    [getRadius:{Math.sqrt(
               ((squarePeg.width/2) ** 2)*2)}] as RoundThing
}

@Test void functional_adaptor() {
    def hole = new RoundHole(radius:4.0)
    (4..7).each { w ->
        def peg = roundPegOf(new SquarePeg(width:w))
        if (w < 6)
            assertTrue hole.pegFits(peg)
        else
            assertFalse hole.pegFits(peg)
    }
}

Для создания хеш-таблиц в синтаксисе Groovy используются квадратные скобки, которые показаны в методе roundPegOf() в листинге 7. Чтобы сгенерировать класс, реализующий интерфейс, Groovy позволяет создать хеш-таблицу, указав названия методов в качестве ключей, а реализующие их блоки кода в качестве значений. Оператор as использует хеш-таблицу для создания класса, реализующего интерфейс, используя ключи хеш-таблицы для генерации методов. Таким образом, в листинге 7 метод roundPegOf() создает хеш-таблицу с одной записью, в которой getRadius используется в качестве названия метода (в Groovy не обязательно помещать ключи хеш-таблицы в двойные кавычки, если они являются строками) и уже знакомый код для преобразования в качестве реализации. Оператор as преобразует эти компоненты в класс, реализующий интерфейс RoundThing, который будет использоваться в качестве адаптера при создании объекта SquarePeg в тестовом сценарии functional_adaptor().

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


Функциональные адаптеры

Когда у вас из инструментов есть только молоток, то любая проблема будет рассматриваться как «потенциальный» гвоздь. Если вы используете только объектно-ориентированную парадигму, то можете разучиться замечать альтернативные возможности. Одной из опасностей, подстерегающих тех, кто проводит слишком много времени с языками, не поддерживающими функции первого класса, является «чрезмерное» применение шаблонов для решения проблем. Многие шаблоны (например, Observer, Visitor и Command), по сути являются механизмами для использования переносимого кода, реализованными в языках, в которых отсутствуют функции высшего порядка. Я могу отбросить большинство сложностей, связанных с объектами, и просто написать функцию для выполнения преобразования. И, как будет показано, такой подход обладает определенными преимуществами.

Функции

Если у вас имеются функции первого класса, которые могут появляться везде, где могут встречаться другие конструкции из арсенала языка и даже за пределами классов, то можно написать преобразующую функцию, которая берет задачу адаптации на себя, как показано в Groovy-коде в листинге 8.

Listing 8. Using a simple conversion function
def pegFits(peg, hole) {
    Math.sqrt(((peg.width/2) ** 2)*2) <= hole.radius
}

@Test void functional_all_the_way() {
    def hole = new RoundHole(radius:4.0)
    (4..7).each { w ->
        def peg = new SquarePeg(width:w)
         if (w < 6)
            assertTrue pegFits(peg, hole)
        else
            assertFalse pegFits(peg, hole)
    }
}

В листинге 8 я создаю функцию, которая принимает на вход квадрат и окружность, и использую её для проверки их совместимости. Этот подход работает, но он выносит принятие решения о совместимости из области ответственности окружности, к которой оно должно относиться, согласно объектно-ориентированному подходу. В некоторых случаях имеет смысл вынести подобную функциональность за "рамки", а не адаптировать класс. Это пример функциональной парадигмы: «чистые» функции, принимающие параметры и возвращающие значения.

Композиция

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

Листинг 9. Композиция функций с помощью "облегченных" динамических адаптеров
class CubeThing {
    def x, y, z
}

def asSquare(peg) {
    [getWidth:{peg.x}] as SquarePeg
}
def asRound(peg) {
    [getRadius:{Math.sqrt(
               ((peg.width/2) ** 2)*2)}] as RoundThing
}

@Test void mixed_functional_composition() {
    def hole = new RoundHole(radius:4.0)
    (4..7).each { w ->
        def cube = new CubeThing(x:w)
         if (w < 6)
            assertTrue hole.pegFits(asRound(asSquare(cube)))
        else
            assertFalse hole.pegFits(asRound(asSquare(cube)))
    }
}

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

Сравним "облегченный" динамический адаптер с «тяжеловесной» версией адаптера, составленной из классов библиотеки Java I/O, которая приведена в листинге 10.

Листинг 10. "Тяжелый" составной адаптер
ZipInputStream zis = 
    new ZipInputStream(
        new BufferedInputStream(
            new FileInputStream(argv[0])));

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


Заключение

Если вы всегда придерживались одной и той же парадигмы, со временем вам может оказаться сложно оценить преимущества других подходов, так как они просто не будут "вписываться" в вашу картину мира. Современные языки, объединяющие несколько парадигм, предлагают целую палитру решений для проектирования, и понимание того, как работает каждая парадигма (и как она взаимодействует с другими парадигмами) поможет вам выбирать лучшие решения. В этой статье я показал стандартную проблему адаптации функциональности и решил её с помощью стандартного шаблона проектирования Adapter средствами Java и Groovy. Затем я решил эту же проблему с помощью парадигмы метапрограммирования Groovy и класса ExpandoMetaClass, а в конце продемонстрировал динамические классы-адаптеры. Также вы увидели, что возможность использования "облегченного" синтаксиса для создания классов адаптеров позволяет легко применять композицию функций, что довольно затруднительно в Java.

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

Ресурсы

  • Functional thinking: Functional design patterns, Part 2: оригинал статьи (EN).
  • The Productive Programmer (Neal Ford, O'Reilly Media, 2008): новая книга Нила Форда, в которой более подробно рассматриваются некоторые вопросы из данной серии статей.
  • Design Patterns: Elements of Reusable Object-Oriented Software (Erich Gamma et al., Addison-Wesley, 1994): классическая книга четырех авторов ("банды четырех") по шаблонам проектирования.
  • Design Patterns in Dynamic Languages: презентация Питера Норвига (Peter Norvig) объясняет, почему языки программирования с богатыми возможностями (например, функциональные) не так сильно нуждаются в шаблонах проектирования.
  • Groovy: этот язык, реализующий несколько парадигм программирования и работающий поверх JVM, обладает синтаксисом, близким к Java, и многими дополнительными возможностями из арсенала функционального программирования.
  • Adapter pattern: широко известный шаблон проектирования из каталога книги Design Patterns.
  • "Practically Groovy": статья с дополнительной информацией о том, как в Groovy можно динамически добавлять новые методы к классам прямо во время исполнения.

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


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

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java
ArticleID=838125
ArticleTitle=Функциональное мышление: Часть 2. Шаблоны проектирования для функционального программирования
publish-date=09282012