Функциональное мышление: Связывание и композиция, часть 2

Сравнение приемов объектно-ориентированного программирования с арсеналом функционального программирования

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

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

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



12.05.2012

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

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

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

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

Переосмысление метода equals()

В одной из глав книги Effective Java Джошуа Блоха (Joshua Bloch) рассказывается, как правильно реализовывать методы equals() и hashCode() (см. раздел "Ресурсы"). При решении этой задачи возникает проблема, связанная с отношениями между семантикой операции "равенство" и наследованием. Метод equals() в Java должен соответствовать требованиям, указанным в Javadoc-документации метода equals() класса Object.

  • он должен быть рефлексивным: для любой ссылки x, указывающей на какой-либо объект (не null), всегда должно выполняться условие:
    x.equals(x) == true
  • он должен быть симметричным: для любых ссылок x и y, указывающих на какие-либо объекты (не null), всегда должно выполняться условие:
    x.equals(y) == true, только если и y.equals(x) == true.
  • он должен быть транзитивным: для любых ссылок x, y и z, указывающих на какие-либо объекты (не null), всегда должно выполняться условие:
    x.equals(y) == true и y.equals(z) == true, только если x.equals(z) == true
  • он должен быть стабильным: для любых ссылок x и y, указывающих на какие-либо объекты (не null), должно выполняться условие:
    при нескольких последовательных вызовах x.equals(y) должно возвращаться одно и тоже значение (true или false), если информация, используемая для сравнения объектов, не изменялась между вызовами метода equals()
  • для любой ссылки x, указывающей на какой-либо объект (не null), всегда должно выполняться условие: x.equals(null) == false

Для примера Блох создал два класса: Point и ColorPoint и попытался написать правильную реализацию метода equals(), которая могла бы одновременно использоваться сразу для двух классов. Попытка проигнорировать дополнительное поле, появившееся в классе-потомке, вела к нарушению симметричности, а если данное поле использовалось в реализации equals(), это вело к нарушению симметричности. В результате Джошуа сделал неутешительный прогноз о возможности решения этой проблемы:

Не существует способа расширить инстанциируемый класс и добавить в него свойство, сохранив при этом соответствие метода equals() требованиям, описанным выше.

Реализовать проверку на равенство гораздо проще, если не надо беспокоиться об унаследованных изменяемых полях. Добавление механизмов связывания, таких как наследование, приводит к появлению тонких моментов и скрытых ловушек. Правда, существует способ решить эту проблему, одновременно сохранив возможность наследования, но для этого придется добавить дополнительный зависимый метод (см. врезку "Наследование и метод canEqual()").

Наследование и метод canEqual()

В книге Programming Scala авторы представили способ, который позволяет выполнить проверку на равенство даже при наличии наследования (см. раздел "Ресурсы"). Корень проблемы, поднятой Джошуа, лежит в том, что родительский класс не обладает достаточным количеством информации о дочерних классах, чтобы определить, должны ли они участвовать в проверке на равенство или нет. Чтобы устранить это ограничение, в базовый класс можно добавить метод canEqual() и переопределить его в дочерних классах, которые должны участвовать в проверке на равенство. Это позволяет текущему классу с помощью метода canEqual() решить, имеет ли смысл сравнивать объекты двух типов.

Этот алгоритм решает проблему, но за счет создания еще одной связи между родительским и дочерним классом через метод canEqual().

Напомню цитату Майкла Фезерса (Michael Feathers), послужившую эпиграфом к двум предыдущим статьям данной серии:

Объектно-ориентированное программирование облегчает понимание кода за счет инкапсуляции "движущихся частей". Функциональное программирование облегчает понимание кода за счет сокращения числа "движущихся частей".

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

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

Книга Effective Java не стала бы такой популярной, если бы Джошуа оставил проблему определения равенства без решения. Но он воспользовался ею как возможностью еще раз дать хороший совет, неоднократно упоминавшийся в книге: "используйте композицию, а не наследование". Решение для проблемы метода equals(), предложенное Джошуа, было основано на композиции, а не связывании. В нём он полностью отказался от наследования, добавив в класс ColorPoint ссылку на объект типа Point вместо того, чтобы сделать класс ColorPoint потомком класса Point.


Композиция и наследование

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

Рисунок 1. Объектно-ориентированная система
Рисунок 1. Объектно-ориентированная система

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

Рисунок 2. Извлечение полезных фрагментов иерархии классов в форме графа
Рисунок 2. Извлечение полезных фрагментов иерархии классов в форме графа

Неудивительно, что книга Design Patterns: Elements of Reusable Object-Oriented Software, посвященная шаблонам проектирования стала одной из самых популярных книг в области разработки ПО. В этой книге содержится каталог различных вариантов извлечения графов классов для повторного использования, один из которых изображен на рисунке 2. Подход к повторному использованию, основанный на шаблонах проектирования, стал настолько популярным, что появились и другие книги с подобными каталогами (при этом иногда имя шаблона менялось на другое). Шаблоны проектирования принесли огромную пользу всей отрасли разработки ПО, так как они помогли создать стандартную терминологию и примеры эталонной реализации того или иного подхода. Но по существу шаблоны проектирования обеспечивают повторное использование на низком уровне функциональности, так как один шаблон (например, Flyweight) может быть противоположным другому шаблону (например, Memento). Любая проблема, решаемая с помощью шаблонов проектирования, является уникальной. С одной стороны, в этом и заключается преимущество шаблонов проектирования, так как всегда можно найти шаблон, подходящий к конкретной проблеме, но с другой стороны это также и сужает область применения шаблона, так как он подходит только к определенной проблеме.

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

Рисунок 3. Повторное использование, основанное на высокоуровневых алгоритмах и переносимом коде
Рисунок 3. Повторное использование, основанное на высокоуровневых алгоритмах и переносимом коде

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


Стандартные блоки кода

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

Свертывание

Один из методов классификатора выполняет суммирование всех найденных делителей, как показано в листинге 1.

Листинг 1. Метод sum() из функционального классификатора чисел
public int sum(List<Integer> factors) {
    return factors.foldLeft(fj.function.Integers.add, 0);
}

На первый взгляд неочевидно, как можно выполнить суммирование с помощью всего одной строки, представленной в листинге 1. Этот пример иллюстрирует конкретную операцию из большого множества операций для преобразования списков, называемых катаморфизмами. Катаморфизм (англ. catamorphism) – это преобразование из одной формы в другую (см. раздел "Ресурсы"). В данном случае операция "свертывание" (англ. fold) – это трансформация, которая объединяет каждый элемент списка с последующим, образуя в конечном итоге единственное значение для всего списка. Свертывание влево уменьшает список с левой стороны, начиная со стартового значения и присоединяя к нему каждый элемент из списка, чтобы получить окончательный результат. На рисунке 4 представлен пример свертывания.

Рисунок 4. Операция свертывания
Рисунок 4. Операция свертывания

Так как сложение – коммутативная операция, не имеет значения, какой метод использовать – foldLeft() или foldRight(). Но для некоторых операций (включая вычитание и деление) порядок операндов имеет значение, поэтому для них предусмотрен симметричный метод для свертывания вправо - foldRight().

В листинге 1 показан пример сложения чисел с помощью метода add() инфраструктуры Functional Java, которая включает поддержку большинства стандартных математических операций. Но что делать в случаях, когда вам необходимо более точное условие? Рассмотрим пример, представленный в листинге 2.

Листинг 2. Использование метода foldLeft() с условием, определенным пользователем
static public int addOnlyOddNumbersIn(List<Integer> numbers) {
    return numbers.foldLeft(new F2<Integer, Integer, Integer>() {
        public Integer f(Integer i1, Integer i2) {
            return (!(i2 % 2 == 0)) ? i1 + i2 : i1;
        }
    }, 0);
}

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

Фильтрация

Другой стандартной операцией, применяемой к спискам, является фильтрация: создание меньшего по размеру списка путем фильтрования элементов из исходного списка в зависимости от условия, определенного пользователем. Пример фильтрации приведен на рисунке 5.

Рисунок 5. Фильтрация списка
Рисунок 5. Фильтрация списка

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

Листинг 3. Использование фильтрации для поиска делителей
public boolean isFactor(int number, int potential_factor) {
    return number % potential_factor == 0;
}

public List<Integer> factorsOf(final int number) {
    return range(1, number + 1)
            .filter(new F<Integer, Boolean>() {
                public Boolean f(final Integer i) {
                    return isFactor(number, i);
                }
            });
}

В коде в листинге 3 создается диапазон чисел (в виде объекта List) начиная с 1 до целевого числа. Затем к полученному списку применяется метод filter(), в качестве параметра которого указан метод isFactor() (определенный в начале листинга 3); он удаляет из списка элементы, не являющиеся делителями указанного числа.

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

Листинг 4. Groovy-версия программы для фильтрации списка
def isFactor(number, potential) {
  number % potential == 0;
}

def factorsOf(number) {
  (1..number).findAll { i -> isFactor(number, i) }
}

Groovy-версия метода filter() – это метод findAll(), принимающий на вход блок кода, в котором определено условие фильтрации. На последней строке метода происходит возврат вычисленного значения (в данном случае – списка делителей).

Привязка

Операция map (привязка) преобразует одну коллекцию в другую путем применения функции к каждому элементу исходной коллекции, как показано на рисунке 6.

Рисунок 6. Привязка функции к коллекции
Рисунок 6. Привязка функции к коллекции

В примере с классификатором чисел я использовал привязку в оптимизированной версии метода factorsOf(), приведенной в листинге 5.

Листинг 5. Оптимизированный метод для поиска делителей, созданный на базе метода map() библиотеки Functional Java
public List<Integer> factorsOfOptimized(final int number) {
    final List<Integer> factors = range(1, (int) round(sqrt(number) + 1))
            .filter(new F<Integer, Boolean>() {
                public Boolean f(final Integer i) {
                    return isFactor(number, i);
                }
            });
    return factors.append(factors.map(new F<Integer, Integer>() {
        public Integer f(final Integer i) {
            return number / i;
        }
    }))
   .nub();
}

Код в листинге 5 сначала собирает список делителей от 1 до квадратного корня из целевого числа и сохраняет его в переменной factors. Затем я присоединяю к данной коллекции новую коллекцию, созданную функцией map() из первоначального списка делителей (применяемый код создает список, в который помещаются делители, симметричные уже найденным). Последним вызывается метод nub(), который удаляет из списка повторяющиеся значения.

Как обычно, версия на языке Groovy, показанная в листинге 6, более прямолинейна, так как в Groovy доступны настраиваемые типы и блоки кода.

Листинг 6. Оптимизированный метод для поиска делителей
def factorsOfOptimized(number) {
  def factors = (1..(Math.sqrt(number))).findAll { i -> isFactor(number, i) }
  factors + factors.collect({ i -> number / i})
}

Хотя сигнатуры методов и отличаются, но код из листинга 6 выполняет туже задачу, что код из листинга 5:

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

Переосмысление проблемы поиска совершенных чисел

После появления в нашем арсенале функций высшего порядка для решения задачи о том, является число совершенным или нет, нам потребуется всего несколько строк кода на языке Groovy, как показано в листинге 7.

Листинг 7. Программа для поиска совершенных чисел, реализованная на Groovy
def factorsOf(number) {
  (1..number).findAll { i -> isFactor(number, i) }
}

def isPerfect(number) {
    factorsOf(number).inject(0, {i, j -> i + j}) == 2 * number
}

Конечно, этот пример относится исключительно к классификатору чисел, так что будет довольно сложно сделать его универсальным и применимым к другим типам кода. Тем не менее можно отметить важное изменение в стилях кодирования, применяемых в языках, поддерживающих эти абстракции (неважно, являются они функциональными или нет). Я впервые заметил его в проектах на Ruby on Rails. В языке Ruby имелись аналогичные методы для манипуляции списками, в которых использовались блоки-замыкания, и меня удивило, насколько часто применяются методы collect(), map() и inject(). Но после того как вы хорошо освоите эти инструменты, вы также будете применять их постоянно и повсеместно.


Заключение

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

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

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

Ресурсы

Научиться

  • Functional thinking: Coupling and composition, Part 2: оригинал статьи (EN).
  • Effective Java (Joshua Bloch, Addison-Wesley, 2001): в этой книге Джошуа Блоха содержится множество советов о том, как правильно использовать возможности языка Java.
  • Programming in Scala, 1st ed. (Martin Odersky, Lex Spoon, Bill Venners): первое издание этой книги о языке Scala можно скачать бесплатно, а второе улучшенное издание доступно во всех онлайновых магазинах ИТ-литературы.
  • Design Patterns: Elements of Reusable Object-Oriented Software (Erich Gamma et al., Addison-Wesley, 1994): классическая книга о шаблонах проектирования.
  • Теория категорий: область математики, занимающаяся изучением на абстрактном уровне свойств различных математических объектов.
  • Катаморфизм: уникальное преобразование из одной алгебры в другую.
  • Language designer's notebook: First, do no harm (Brian Goetz, developerWorks, июль 2011): статья Брайана Гетца, в которой рассматриваются лямбда-выражения (lambda expressions) – новая возможность, которая появится в Java SE 8. Лямбда-выражения — это функции-литералы (выражения, содержащие встроенную логику), на которые можно ссылаться как на значения и которые можно вызывать впоследствии.

Получить продукты и технологии

  • Functional Java: Web-страница инфраструктуры Functional Java, которая привносит в язык программирования Java многие возможности функционального программирования.

Комментарии

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=815512
ArticleTitle=Функциональное мышление: Связывание и композиция, часть 2
publish-date=05122012