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

Использование шаблонов проектирования в функциональном программировании

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

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

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



28.09.2012

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

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

Некоторые сторонники функционального программирования заявляют, что идея шаблонов проектирования ошибочна, и функциональное программирование в них не нуждается. Такую точку зрения можно обосновать, исходя из узкого понимания слова "шаблон" но это спор скорее о терминах, чем о практическом применении. Концепция "шаблона проектирования" - именованного и каталогизированного решения для стандартной проблемы - по-прежнему актуальна. Однако шаблоны проектирования в различных парадигмах программирования могут принимать различный вид. Поскольку в функциональном программировании применяются другие строительные блоки и подходы, некоторые из традиционных шаблонов "банды четырех" (см. раздел "Ресурсы") утрачивают актуальность, в то время как другие по-прежнему решают проблемы, но совершенно иными способами. В этой и последующей статьях исследуются несколько традиционных шаблонов проектирования и выполняется их «переосмысление» в функциональном стиле.

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

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

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

Фабрики и карринг

Карринг (currying) присутствует в списке возможностей многих функциональных языков. Этот прием, названный в честь математика Хаскелла Карри (Haskell Curry) (в честь которого также был назван язык программирования Haskell) преобразует функцию, принимающую на вход несколько аргументов, так, что она может вызываться как последовательность функций с единственным аргументом. Частичное применение (partial application) – это прием, родственный каррингу, при котором одному или нескольким аргументам функции присваиваются фиксированные значения, так что получается новая функция меньшей арности (арность функции определяется количеством передаваемых ей параметров). Я рассматривал оба подхода в статье "Функциональное мышление. Часть 3".

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

Листинг 1. Использование карринга в качестве «фабрики» функций
def adder = { x, y -> return x + y }
def incrementer = adder.curry(1)

println "инкрементировать 7: ${incrementer(7)}" 
// в результате запуска будет напечатано "инкрементировать 7: 8"

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

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

Листинг 2. Стандартный сценарий использования карринга в Scala
object CurryTest extends Application {

  def filter(xs: List[Int], p: Int => Boolean): List[Int] =
    if (xs.isEmpty) xs
    else if (p(xs.head)) xs.head :: filter(xs.tail, p)
    else filter(xs.tail, p)

  def dividesBy(n: Int)(x: Int) = ((x % n) == 0)

  val nums = List(1, 2, 3, 4, 5, 6, 7, 8)
  println(filter(nums, dividesBy(2)))
  println(filter(nums, dividesBy(3)))
}

Код в листинге 2 демонстрирует пример из документации Scala, в котором одновременно используются рекурсия и карринг (см. раздел "Ресурсы"). В методе filter() рекурсивно фильтруется список целых чисел с помощью параметра p. p – это функция-предикат, стандартный термин из области функционального программирования для определения булевской функции. Метод filter() проверяет, есть ли в списке элементы, и если он пуст, просто происходит возврат. В противном случае с помощью предиката проверяется первый элемент списка (xs.head) и определяется, нужно ли включать его в "отфильтрованный" список или нет. Если элемент соответствует предикату, возвращается новый список с проверенным головным элементом в начале и отфильтрованной частью в качестве остатка. Если же первый элемент не соответствует условию предиката, то возвращается только отфильтрованная часть списка.

С точки зрения шаблонов проектирования особый интерес в листинге 2 представляет "встроенное" использование карринга в методе dividesBy(). Этот метод принимает на вход два параметра и возвращает true или false в зависимости от того, является ли второй параметр делителем первого или нет. Однако, когда метод dividesBy() вызывается в ходе вызова метода filter(), ему передается только один параметр, в результате чего получается карринг-функция, которая затем используется в качестве предиката в методе filter().

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


Функции первого класса и шаблоны проектирования

Наличие функций первого класса значительно упрощает многие употребительные шаблоны проектирования. Шаблон Command (команда) вообще становится ненужным, так как для хранения переносимой функциональности больше не требуется объект-оболочка.

Шаблон Template Method

Функции первого класса упрощают реализацию шаблона Template Method (шаблонный метод — см. раздел "Ресурсы"), так как позволяют отказаться от потенциально ненужной структуры. Шаблон Template Method определяет "скелет" алгоритма в методе, перенося отдельные этапы алгоритма в подклассы и заставляя их определять эти этапы, при этом не изменяя структуру алгоритма. Реализация Template Method на языке Groovy приведена в листинге 3.

Листинг 3. "Стандартная" реализация шаблона Template Method
abstract class Customer {
  def plan
    
  def Customer() {
    plan = []
  }
    
  def abstract checkCredit()
  def abstract checkInventory()
  def abstract ship()
    
  def process() {
    checkCredit()
    checkInventory()
    ship()
  }
}

В листинге 3 метод process() основывается на методах checkCredit(), checkInventory() и ship(), определения которых должны быть представлены подклассами, так как они являются абстрактными.

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

Листинг 4. Реализация шаблона Template Method на основе функций первого класса
class CustomerBlocks {
  def plan, checkCredit, checkInventory, ship
    
  def CustomerBlocks() {
    plan = []
  }
    
  def process() {
    checkCredit()
    checkInventory()
    ship()
  }
}

class UsCustomerBlocks extends CustomerBlocks{
  def UsCustomerBlocks() {
    checkCredit = { plan.add "checking US customer credit" }
    checkInventory = { plan.add "checking US warehouses" }
    ship = { plan.add "Shipping to US address" }
  }
}

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

Эти два решения не эквивалентны. В традиционной реализации Template Method в листинге 3 абстрактный класс требует от подклассов реализовывать зависимые методы. Конечно, подкласс может просто создать метод с пустым телом, но определение абстрактного метода формирует документацию определенного рода, которая напоминает программистам, что именно нужно учитывать при наследовании классов. С другой стороны, «строгое» объявление методов может не подойти для ситуаций, когда требуется большая гибкость. Например, я бы мог создать версию класса Customer, которая принимает на вход список с любым количеством методов для последующей обработки.

Полноценная поддержка таких возможностей, как блоки кода, делает язык более дружественным к программисту. Рассмотрим случай, когда мы хотим позволить классам-наследникам пропустить некоторые из этапов. В Groovy для этого имеется специальный оператор для безопасного доступа (?.), который проверяет, что объект не равен null, перед тем как вызвать его метод. Рассмотрим определение метода process() в листинге 5.

Листинг 5. Обеспечение безопасности при вызове блока кода
def process() {
  checkCredit?.call()
  checkInventory?.call()        
  ship?.call()
}

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

Strategy

Другим популярным шаблоном проектирования, который также можно упростить с помощью функций первого класса, является Strategy (стратегия). Этот шаблон определяет семейство алгоритмов, инкапсулируя каждый из них и делая их взаимозаменяемыми. Это позволяет изменять алгоритмы независимо от использующих их клиентов. Функции первого класса позволяют легко создавать стратегии и манипулировать ими.

Традиционная реализация шаблона Strategy для подсчета произведения чисел приведена в листинге 6.

Листинг 6. Использование шаблона Strategy для вычисления произведения двух чисел
interface Calc {
  def product(n, m)
}

class CalcMult implements Calc {
  def product(n, m) { n * m }
}

class CalcAdds implements Calc {

  def product(n, m) {
    def result = 0
    n.times {
      result += m
    }
    result
  }
}

В листинге 6 я определяю интерфейс с методом product() для вычисления произведения двух чисел. Я реализую этот интерфейс в двух конкретных классах (стратегиях): в первом используется умножение, а во втором сложение. Для проверки этих стратегий я создаю тестовый сценарий, приведенный в листинге 7.

Листинг 7. Тестирование стратегий для вычисления произведений
class StrategyTest {
  def listOfStrategies = [new CalcMult(), new CalcAdds()]

  @Test
  public void product_verifier() {
    listOfStrategies.each { s ->
      assertEquals(10, s.product(5, 2))
    }
  }
}

Как и ожидалось в листинге 7, обе стратегии возвращают одно и тоже значение. Используя блоки кода как функции первого класса, я могу очистить предыдущий пример от большинства формальностей. Рассмотрим пример со стратегиями для возведения в степень, представленный в листинге 8.

Листинг 8. Менее формальное тестирование стратегий для возведения в степень
@Test
public void exp_verifier() {
  def listOfExp = [
      {i, j -> Math.pow(i, j)},
      {i, j ->
        def result = i
        (j-1).times { result *= i }
        result
      }]

  listOfExp.each { e ->
    assertEquals(32, e(2, 5))
    assertEquals(100, e(10, 2))
    assertEquals(1000, e(10, 3))
  }
}

В листинге 8 с помощью блоков кода Groovy я определил две стратегии для возведения в степень непосредственно в исходном коде. Как и в случае с примером реализации Template Method, я отказался от формальностей в пользу удобства. Традиционный подход требует, чтобы каждая стратегия имела уникальное имя и следовала общей структуре, что иногда нежелательно. Однако заметьте, что у меня есть возможность добавить к коду в листинге 8 несколько защитных «предохранителей», что не позволит мне легко обойти ограничения, накладываемые более традиционным подходом. Хотя на самом деле этот вопрос, скорее, относится к спору между статическим и динамическим поведением, а не между шаблонами проектирования и функциональным программированием.

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


Flyweight и мемоизация

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

Рассмотрим приведенные в листинге 9 классы, моделирующие различные типы компьютеров.

Листинг 9. Простые классы, моделирующие типы компьютеров.
class Computer {
  def type
  def cpu
  def memory
  def hardDrive
  def cd
}

class Desktop extends Computer {
  def driveBays
  def fanWattage
  def videoCard
}

class Laptop extends Computer {
  def usbPorts
  def dockingBay
}

class AssignedComputer {
  def computerType
  def userId

  public AssignedComputer(computerType, userId) {
    this.computerType = computerType
    this.userId = userId
  }
}

Судя по этим классам, было бы неэффективно создавать новый объект типа Computer для каждого пользователя, так как все компьютеры имеют одинаковую спецификацию. Класс AssignedComputer связывает компьютер с пользователем.

Стандартный способ повысить эффективность этого кода — это объединить в нем шаблоны Factory и Flyweight. Рассмотрит singleton-фабрику для создания канонических объектов типа Computer, приведенную в листинге 10.

Листинг 10. Singleton-фабрика для создания flyweight-объектов типа Computer
class ComputerFactory {
  def types = [:]
  static def instance;
  
  private ComputerFactory() {
    def laptop = new Laptop()
    def tower = new Desktop()
    types.put("MacBookPro6_2", laptop)
    types.put("SunTower",  tower)
  }

  static def getInstance() {
    if (instance == null)
      instance = new ComputerFactory()
    instance
  }

  def ofType(computer) {
    types[computer]
  }  
}

Класс ComputerFactory создает кэш с компьютерами различных типов, а затем возвращает необходимый объект при помощи своего метода ofType(). Это стандартная singleton-фабрика, какие обычно создаются в Java.

Однако Singleton – это тоже шаблон проектирования (см. раздел "Ресурсы"), и он служит другим хорошим примером шаблона, "поглощенного" средой исполнения. Рассмотрим версию класса ComputerFactory, упрощенную с помощью аннотации @Singleton, предоставляемой Groovy.

Листинг 11. Упрощенная singleton-фабрика
@Singleton class ComputerFactory {
  def types = [:]
  
  private ComputerFactory() {
    def laptop = new Laptop()
    def tower = new Desktop()
    types.put("MacBookPro6_2", laptop)
    types.put("SunTower",  tower)
  }

  def ofType(computer) {
    types[computer]
  }
}

Чтобы проверить, что фабрика возвращает канонические объекты, я написал unit-тест, приведенный в листинге 12.

Листинг 12. Тестирование канонических типов
@Test
public void flyweight_computers() {
  def bob = new AssignedComputer(ComputerFactory.instance.ofType("MacBookPro6_2"), "Bob")
  def steve = new AssignedComputer(ComputerFactory.instance.ofType("MacBookPro6_2"), 
  "Steve") assertTrue(bob.computerType == steve.computerType)
}

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

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

Листинг 13. Мемоизация flyweight-значений
def computerOf = {type ->
  def of = [MacBookPro6_2: new Laptop(), SunTower: new Desktop()]
  return of[type]
}

def computerOfType = computerOf.memoize()

В листинге 13 канонические типы компьютеров объявляются в методе computerOf(). Для создания мемоизированной версии этой функции я просто вызываю метод memoize(), определенный в среде исполнения Groovy.

В листинге 14 приведен unit-тест, сравнивающий работу обоих подходов.

Листинг 14. Сравнение подходов к реализации flyweight-объектов
@Test
public void flyweight_computers() {
  def bob = new AssignedComputer(ComputerFactory.instance.ofType("MacBookPro6_2"), "Bob")
  def steve = new AssignedComputer(ComputerFactory.instance.ofType("MacBookPro6_2"), 
  "Steve") assertTrue bob.computerType == steve.computerType

  def sally = new AssignedComputer(computerOfType("MacBookPro6_2"), "Sally")
  def betty = new AssignedComputer(computerOfType("MacBookPro6_2"), "Betty")
  assertTrue sally.computerType == betty.computerType
}

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


Заключение

В этой статье я представил три варианта того, как семантика шаблонов проектирования может проявляться в функциональном программировании. Во-первых, они могут быть "поглощены" языком или средой исполнения. Я показал подобные решения для шаблонов Factory, Strategy, Singleton и Template Method. Во-вторых, шаблоны могут сохранить свою семантику, но получить совершенно другую реализацию; я продемонстрировал этот подход на примере шаблона Flyweight, сравнив реализацию, основанную на классах, с версией, основанной на мемоизации. В-третьих, функциональные языки и среды исполнения могут обладать абсолютно иными возможностями, которые позволяют им решать проблемы своими уникальными способами.

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

Ресурсы

  • Functional thinking: Functional design patterns, Part 1: оригинал статьи (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, и многими дополнительными возможностями из арсенала функционального программирования.
  • Scala: современный функциональный язык программирования, работающий поверх JVM.
  • The busy Java developer's guide to Scala: серия статей Теда Ньюарда (Ted Neward) на портале developerWorks, знакомящая с языком программирования Scala.
  • Template method pattern: известный шаблон проектирования из каталога книги Design Patterns.
  • Singleton pattern: известный шаблон проектирования из каталога книги Design Patterns для поддержки глобального состояния в объектно-ориентированных языках.
  • Closure memoization: документ, в котором описывается реализация мемоизации в Groovy, добавленная в версии 1.8.

Комментарии

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=838121
ArticleTitle=Функциональное мышление: Часть 1. Шаблоны проектирования для функционального программирования
publish-date=09282012