Статьи

Уроки абстракции: чему ПП может научить ООП

Абстракция — это один из величайших призрачных инструментов, когда-либо изобретенных людьми, для представления, расшифровки и изображения мира. — Джерри Зальц

Я хочу приблизиться к истине как можно ближе, и поэтому я абстрагирую все, пока не достигну фундаментального качества объектов. — Пит Мондриан

Одной из наиболее рекомендуемых «лучших практик» в программировании является принцип СУХОЙ: не повторяйте себя . Для реализации этого принципа могут использоваться многие методы: инкапсуляция, параметризация, инверсия управления и многое другое. Одним из этих методов является абстракция, и одно из основных различий между функциональным программированием (FP) и объектно-ориентированным программированием (ООП) заключается в способе применения абстракции. Обычной практикой в ​​ООП является ограничение абстракции строгим полезным минимумом для рассматриваемой проблемы. В ООП преждевременная абстракция часто считается ошибкой, во многом как преждевременная оптимизация.

В FP, с другой стороны, абстракция обычно проталкивается настолько далеко, насколько это возможно. Каждая проблема разбита на ряд простейших возможных функций, которые затем составляются для построения решения проблемы. Выявление этих абстракций, как правило, является наиболее важной частью решения проблемы. На самом деле, программисты на FP часто тратят больше времени, пытаясь найти проблему, которую они должны решить, чем ее решение. И, конечно же, обычно кажется, что эти функции одинаковы при переходе от одной проблемы к другой. Только то, как они составлены, отличается. Это причина, почему абстракция является одним из наиболее ценных методов, используемых программистами FP.

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

static int sum(int n) { int sum = 0; for (int i = 1; i <= n; i++) { sum += i; } return sum; } 

Кажется, что не существует более простого и эффективного способа сделать это. Однако для программиста ООП, а также для программиста на FP интересная часть проблемы еще впереди. Но каждый из этих программистов, вероятно, пойдет по-разному. Мы также могли бы заметить (и продемонстрировать!), Что сумма n первых целых чисел равна (n * (n + 1) / 2) . Как вы увидите, это пошло бы в другую сторону, к как можно меньшей абстракции.

Абстрагирование вычислений в объектно-ориентированном программировании

Программисты FP и OOP сразу заметят одну возможную абстракцию, которая является операцией, применяемой для построения результата. Здесь мы добавляем значения для построения результата. Что если нас попросят вернуть товар вместо суммы?

В ООП существует два очевидных способа абстрагировать вычисления: использование шаблона стратегии или инверсия управления ( IoC ).

Использование шаблона стратегии

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

 static int compute(int n, Computer computer) { int result = computer.identity(); for (int i = 1; i <= n; i++) { result = computer.compute(result, i); } return result; } interface Computer { int compute(int a, int b); int identity(); } static class Adder implements Computer { @Override public int compute(int a, int b) { return a + b; } @Override public int identity() { return 0; } } static class Multiplier implements Computer { @Override public int compute(int a, int b) { return a * b; } @Override public int identity() { return 1; } } public static void main(String... args) { System.out.println(compute(5, new Adder())); System.out.println(compute(5, new Multiplier())); } 

Здесь мы абстрагировали вычисления и теперь можем повторно использовать программу с другой стратегией вычисления, такой как умножение. Обратите внимание, что мы помещаем элемент identity , используемый в качестве начального значения для вычислений, в различные реализации Computer , поскольку он будет отличаться для разных типов вычислений ( 0 для сложения, 1 для умножения).

Честно говоря, это не кажется большим улучшением, и многие программисты не видят интереса отсоединения вычислений от итерации. Кроме того, этот подход не учитывает, что у нас фактически есть две вложенные возможные абстракции: сама операция (сложение или умножение) и концепция тождественности определенного значения, которое связано с операцией и не меняет значение для его получения. связан через эту операцию. Значение 0 — это идентификатор для сложения, а 1 — это идентификатор для умножения. Многие другие операции имеют значение идентичности. Пустая строка — это идентификатор для конкатенации строк, а пустой список — это идентификатор для конкатенации списков.

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

Использование инверсии контроля (IoC)

Другой, более объектно-ориентированный метод абстрагирования вычислений называется инверсией управления . Решение паттерна стратегии использует композицию для решения проблемы, тогда как инверсия управления основана на наследовании.

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

 static abstract class Computer { public final int compute(int n) { int result = identity(); for (int i = 1; i <= n; i++) { result = doCompute(result, i); } return result; } abstract int doCompute(int a, int b); abstract int identity(); } static class Adder extends Computer { @Override public int doCompute(int a, int b) { return a + b; } @Override int identity() { return 0; } } static class Multiplier extends Computer { @Override public int doCompute(int a, int b) { return a * b; } @Override int identity() { return 1; } } public static void main(String... args) { Computer adder = new Adder(); System.out.println(adder.compute(5)); Computer multiplier = new Multiplier(); System.out.println(multiplier.compute(5)); } 

Этот подход, вероятно, является наиболее объектно-ориентированным, и может показаться, что он намного чище, чем шаблон стратегии, поскольку, по-видимому, он также абстрагирует итерацию в классе Computer . (Кстати, этому конкретному использованию IoC было дано конкретное имя: шаблон метода шаблона .) Но на самом деле мы не абстрагировали итерацию! Мы просто инкапсулировали всю программу в компьютерном классе. Давайте теперь посмотрим, как функциональный программист справится с этой проблемой.

Абстрагирование в функциональном программировании

Теперь мы посмотрим, как функциональный программист справится с проблемой абстракции. Абстрагирование вычислений в принципе ничем не отличается от того, что мы сделали с паттерном стратегии. Однако функциональное программирование продвигает абстракцию дальше.

Абстрагирование вычислений

В FP фактические вычисления будут абстрагироваться очень похоже на то, что мы делали с шаблоном стратегии. Мы снова создаем Computer интерфейс:

 @FunctionalInterface interface Computer { int compute(int a, int b); } 

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

Поскольку мы используем Java 8, интерфейс Computer не нужен, поскольку его можно заменить на IntBinaryOperator , который также отображает два типа int на IntBinaryOperator int :

 static int compute(int n, int identity, IntBinaryOperator computer) { int result = identity; for (int i = 1; i <= n; i++) { result = computer.applyAsInt(result, i); } return result; } 

Если мы используем язык с лямбдами, как в случае с Java 8, нам не нужно создавать классы, которые реализуют IntBinaryOperator :

 public static void main(String... args) { System.out.println(compute(5, 0, (a, b) -> a + b)); System.out.println(compute(5, 1, (a, b) -> a * b)); } 

Абстрагирование создания коллекции

Функциональный программист сразу заметил бы, что мы действительно создаем коллекцию целых чисел от 1 до n и последовательно применяем вычисления к текущему результату и каждому элементу, начиная с начального числа. Создание коллекции можно абстрагировать в метод:

 static int[] range(int n) { int[] result = new int[n]; for (int i = 0; i < n; i++) { result[i] = i + 1; } return result; } 

Абстрактная итерация

Итерация, применяющая вычисления, также может быть абстрагирована в метод:

 static int fold(int[] collection, int identity, IntBinaryOperator computer) { int result = identity; for(int i : collection) { result = computer.applyAsInt(result, i); } return result; } 

Если мы поместим эти абстракции в библиотеку, наша программа станет такой простой, как:

 static int compute(int n, int identity, IntBinaryOperator computer) { return fold(range(n), identity, computer); } public static void main(String... args) { System.out.println(compute(5, 0, (a, b) -> a + b)); System.out.println(compute(5, 1, (a, b) -> a * b)); } 

Кстати, эти абстракции, которые мы поместили в отдельную библиотеку, являются избыточными, поскольку в Java 8 они уже есть, хотя с небольшими отличиями:

  • Метод range может быть заменен на IntStream::range , который принимает два параметра, начальное и конечное значение, и создает IntStream вместо массива.
  • Абстракция fold называется reduce .

Получающаяся программа тогда так же проста как:

 public static void main(String... args) { System.out.println(IntStream.rangeClosed(1, 5).reduce(0, (a, b) -> a + b)); System.out.println(IntStream.rangeClosed(1, 5).reduce(1, (a, b) -> a * b)); } 

Вы не думаете, что пример IoC был проще? (Просто шучу.)

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

Применение абстракции к более сложным задачам

Предыдущий пример был прост, потому что все используемые абстракции уже были предоставлены Java 8.

Но знайте, что мы еще не довели абстракцию до предела! Мы могли бы абстрагировать тип int . В конце концов, мы можем захотеть применить те же вычисления к другим типам, таким как double , или BigDecimal , или даже String , или List . Мы могли бы также использовать вычисления, производящие результат другого типа, чем элементы коллекции. Например, мы могли бы применить к списку символов операцию, заключающуюся в добавлении каждого символа в начальную String (которая, кстати, может быть или не быть пустой).

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

Начальная проблема

Первоначальная задача — вычислить список промежуточных результатов предыдущей задачи. Рассматривая сумму целых чисел от 1 до n , мы теперь хотим составить список промежуточных результатов этого вычисления. Например, если n = 5 , мы хотим получить список 1, 3, 6, 10, 15 .

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

 1 1 + 2 = 3 1 + 2 + 3 = 6 1 + 2 + 3 + 4 = 10 1 + 2 + 3 + 4 + 5 = 15 

Тривиальная реализация программы, вычисляющей треугольные числа, может быть:

 private static List<Integer> triangular(int length) { List<Integer> result = new ArrayList<>(); for(int i = 1; i <= length; i++) { // <1> result.add(result.size() == 0 ? i : result.get(result.size() - 1) + i); // <2> } return result; } public static void main(String... args) { System.out.println(triangular(5)); } 

Мы можем видеть, что в <1> мы создаем список целых чисел от 1 до n включительно. В <2> мы используем result.get(result.size() - 1) чтобы получить последний элемент списка. В качестве первой абстракции мы должны либо переместить эту часть в метод, либо использовать коллекцию, предлагающую эту функциональность.

Абстрагирование Обработка Коллекции

Давайте начнем с создания last операции для List . Проблема, с которой мы столкнулись, заключается в том, что хотя в этом конкретном примере список никогда не бывает пустым при доступе к последнему элементу (поскольку мы проверяем, что размер не равен 0 ), этот случай может возникнуть в абстракции. В случае пустого списка, мы не знаем, что делать. Об этом знают только те, кто вызывал абстрагированный метод, поэтому мы просто должны позволить им сказать нам, что делать. Здесь мы предложим возможность передать значение по умолчанию.

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

 private static <T> T last(List<T> list, T defaultValue) { return list.size() == 0 ? defaultValue : list.get(list.size() - 1); } 

С этой абстракцией под рукой наша программа становится:

 private static List<Integer> triangular(int length) { List<Integer> result = new ArrayList<>(); for(int i = 1; i <= length; i++) { result.add(last(result, 0) + i); } return result; } 

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

 private static List<Integer> range(int start, int end) { List<Integer> result = new ArrayList<>(); for(int i = start; i <= end; i++) { result.add(i); } return result; } 

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

 private static <T, U> U fold( Iterable<T> elements, U initial, BiFunction<U, T, U> f) { U result = initial; for (T t : elements) { result = f.apply(result, t); } return result; } 

Наши программы теперь становятся:

 private static List<Integer> triangular(int length) { return fold(range(1, length), new ArrayList<>(), (a, b) -> { a.add(last(a, 0) + b); return a; }); } 

В этой версии new ArrayList<>() является начальным значением, а вычисление состоит из добавления текущего значения к последнему результату и добавления нового результата в список.

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

 private static <T> List<T> emptyList() { return new ArrayList<>(); } private static <T> List<T> append(List<T> list, T t) { list.add(t); return list; } 

Вот версия нашей программы с использованием этих абстракций:

 private static List<Integer> triangular(int length) { return fold( range(1, length), emptyList(), (a, b) -> append(a, last(a, 0) + b)); } 

Абстрагирование вычислений

Как и в предыдущем примере, мы можем захотеть абстрагировать вычисления, чтобы можно было использовать не только сложение, но, среди прочего, умножение:

 private static List<Integer> scan( int length, Integer z, BinaryOperator<Integer> op) { return fold( range(1, length), emptyList(), (a, b) -> append(a, op.apply(last(a, z), b))); } private static List<Integer> triangular(int length) { return scan(length, 0, (a, b) -> a + b); } private static List<Integer> factorial(int length) { return scan(length, 1, (a, b) -> a * b); } 

Обратите внимание, что z используется по соглашению, что означает «ноль», что означает не 0 а единицу или нейтральный элемент данной операции, по аналогии с 0 являющейся единицей сложения.

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

Абстрагирование типа

Конечно, наша программа может использоваться для произвольных типов, а не только для чисел, поэтому мы хотим абстрагировать тип:

 private static <T> List<T> scan( Iterable<T> elements, T z, BinaryOperator<T> op) { return fold( elements, emptyList(), (a, b) -> append(a, op.apply(last(a, z), b))); } 

Обратите внимание, что поскольку тип является общим scan больше не может создавать сами элементы с range , поэтому теперь вызывающие стороны должны сделать это:

 private static List<Integer> triangular(int length) { return scan(range(1, length), 0, (a, b) -> a + b); } private static List<Integer> factorial(int length) { return scan(range(1, length), 1, (a, b) -> a * b); } 

Использование другого типа для возвращаемого значения

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

 private static <T, U> List<U> scanLeft( Iterable<T> elements, U z, BiFunction<U, T, U> f) { return fold( elements, emptyList(), (a, b) -> append(a, f.apply(last(a, z), b))); } private static List<Integer> triangular(int length) { return scanLeft(range(1, length), 0, (a, b) -> a + b); } private static List<Integer> factorial(int length) { return scanLeft(range(1, length), 1, (a, b) -> a * b); } 

Здесь мы сканируем слева направо (имеется в виду, начиная с первого элемента списка) и с BiFunction<U, T, U> . Эта операция является сканированием влево, отсюда и название scanLeft . (Мы могли бы начать справа (но не с Iterable ) и использовать BiFunction<T, U, U> , создавая другую абстракцию, называемую scanRight .)

Теперь мы можем использовать эту абстрактную функцию для списка символов, создающих список строк:

 private static List<String> constructString(List<Character> characters) { return scanLeft(characters, "", (s, c) -> s + c); } System.out.println(constructString(Arrays.asList( 'H', 'e', 'l', 'l', 'o', ',', ' ', 'W', 'o', 'r', 'l', 'd', '!'))); 

Эта программа отображает:

 [H, He, Hel, Hell, Hello, Hello,, Hello, , Hello, W, Hello, Wo, Hello, Wor, Hello, Worl, Hello, World, Hello, World!] 

Конечно, было бы удобно, если scanLeft функция scanLeft была добавлена ​​в интерфейс Collection . Поскольку это не так, мы просто добавим его в нашу функциональную библиотеку.

Остерегайтесь изменчивых списков

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

 private static <T> List<List<T>> starts(List<T> list) { return scanLeft(list, emptyList(), (lst, elem) -> append(lst, elem)); } System.out.println(starts(Arrays.asList(1, 2, 3, 4, 5))); 

Эта программа должна затем отобразить:

 [[1], [1, 2], [1, 2, 3], [1, 2, 3, 4], [1, 2, 3, 4, 5]] 

Но это не работает и производит:

 [[1, 2, 3, 4, 5], [1, 2, 3, 4, 5], [1, 2, 3, 4, 5], [1, 2, 3, 4, 5], [1, 2, 3, 4, 5]] 

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

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

 private static <T> List<T> append(List<T> list, T t) { List<T> result = new ArrayList<>(list); result.add(t); return result; } 

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

Резюме

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

Так называется ли преждевременная абстракция, как преждевременная оптимизация? Действительно интересно отметить, что абстракция и оптимизация (для производительности) — это две противоположные вещи. Оптимизация часто осуществляется путем решения только конкретных проблем. Абстракция, с другой стороны, состоит в поиске наиболее общего представления проблемы.

Учитывая начальную проблему (сумма целых чисел до n ), использование формулы (n * (n + 1) / 2) действительно является оптимизацией, учитывая специфику операции, применяемой для сворачивания списка целых чисел, и конкретный характер этих целых чисел. Хорошая сторона в том, что это самый быстрый способ вычислить результат. Плохая сторона в том, что если вы измените операцию (например, чтобы вычислить произведение целых чисел до n ), это совсем не поможет, и вам придется написать совершенно новую программу. Еще хуже, в отличие от абстрактного решения, это не будет работать для вычисления суммы любого другого списка целых чисел.

С абстрагированным решением вам просто нужно изменить операцию и идентификатор, и вы можете применить его к любому списку целых чисел или даже к любому списку элементов любого типа. Фактически, вы обнаружили особый вид операции, который можно применить к списку любого типа и функции. Эта операция называется сканированием. Вычисление суммы целых чисел от 1 до n сканирует этот список целых чисел с добавлением. Вычислительный n! сканирует тот же список с умножением. И любой список типа List<T> может быть отсканирован с помощью функции типа BiFunction<U, T, U> чтобы получить результат типа List<U> .

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

Одной из концепций, представленных в этой статье, является тот факт, что некоторые коллекции являются «складными». Если бы у нас было больше места (возможно, в другой статье), было бы интересно проанализировать, как эта концепция связана с Optional.orElse в Java 8. Тогда мы могли бы понять, что эти две концепции на самом деле представляют собой две разные композиции из двух более простых общих концепции. Кстати, мы также обнаружили важное различие между функциональной и нефункциональной парадигмой, известной как ссылочная прозрачность.