Статьи

Классы утилит не имеют ничего общего с функциональным программированием

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

В Java есть в основном две допустимые альтернативы этим уродливым служебным классам, активно продвигаемым Guava , Apache Commons и другими. Первый — это использование традиционных классов, а второй — лямбда-версия Java 8 . Теперь давайте посмотрим, почему служебные классы даже не близки к функциональному программированию и откуда исходит это заблуждение.

Color Me Kubrick (2005) Брайан В. Кук

Color Me Kubrick (2005) Брайан В. Кук

Вот типичный пример служебного класса Math из Java 1.0:

1
2
3
4
public class Math {
  public static double abs(double a);
  // a few dozens of other methods of the same style
}

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

1
double x = Math.abs(3.1415926d);

Что с этим не так? Нам нужна функция, и мы получаем ее из класса Math . Внутри класса есть много полезных функций, которые можно использовать для многих типичных математических операций, таких как вычисление максимума, минимума, синуса, косинуса и т. Д. Это очень популярное понятие; просто посмотрите на любой коммерческий продукт или продукт с открытым исходным кодом. Эти служебные классы используются повсеместно с момента изобретения Java (этот класс Math был введен в первой версии Java). Ну, технически в этом нет ничего плохого. Код будет работать. Но это не объектно-ориентированное программирование. Вместо этого это является обязательным и процедурным. Мы заботимся? Ну, решать вам. Посмотрим, в чем разница.

Есть два основных подхода: декларативный и императивный.

Императивное программирование сосредоточено на описании того, как программа работает в терминах операторов, которые изменяют состояние программы. Мы только что видели пример императивного программирования выше. Вот еще одно (это чисто императивное / процедурное программирование, которое не имеет ничего общего с ООП):

1
2
3
4
5
6
7
public class MyMath {
  public double f(double a, double b) {
    double max = Math.max(a, b);
    double x = Math.abs(max);
    return x;
  }
}

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

1
(defun f (a b) (abs (max a b)))

В чем подвох? Просто разница в синтаксисе? На самом деле, нет.

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

1
2
3
4
5
6
7
8
public void foo() {
  double x = this.calc(5, -7);
  System.out.println("max+abs equals to " + x);
}
private double calc(double a, double b) {
  double x = Math.f(a, b);
  return x;
}

Здесь метод calc() является покупателем, метод Math.f() является упаковщиком результата, а метод foo() является потребителем. Независимо от того, какой стиль программирования используется, в процессе всегда участвуют три человека: покупатель, упаковщик и потребитель.

Представьте, что вы покупатель и хотите купить подарок для вашего друга. Первый вариант — посетить магазин, заплатить 50 долларов, дать им упаковку духов, а затем доставить их другу (и получить поцелуй в ответ). Это императивный стиль.

Второй вариант — посетить магазин, заплатить 50 долларов и получить подарочную карту. Затем вы дарите эту открытку другу (и получаете поцелуй взамен). Когда он или она решит преобразовать это в духи, он или она посетит магазин и получит это. Это декларативный стиль.

Увидеть разницу?

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

Более того, ваш друг может использовать эту подарочную карту как сам продукт, никогда не посещая магазин. Вместо этого он может подарить его кому-то другому в качестве подарка или просто обменять на другую карту или товар. Сама подарочная карта становится продуктом!

Таким образом, разница в том, что получает потребитель — продукт, готовый к использованию (обязательно) или ваучер на продукт, который впоследствии можно превратить в реальный продукт (декларативный).

Служебные классы, такие как Math из JDK или StringUtils из Apache Commons, возвращают продукты, готовые к немедленному использованию, в то время как функции в Lisp и других функциональных языках возвращают «ваучеры». Например, если вы вызываете функцию max в Лиспе, фактический максимум между двумя числами будет вычислен только тогда, когда вы фактически начнете использовать его:

1
2
(let (x (max 1 5))
  (print "X equals to " x))

Пока этот print не начнет выводить символы на экран, функция max не будет вызываться. Этот x является «ваучером», возвращаемым вам, когда вы пытались «купить» максимум от 1 до 5 .

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

1
2
3
4
5
public class MyMath {
  public double f(double a, double b) {
    return Math.abs(Math.max(a, b));
  }
}

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

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

1
(defun foo (x) (x 5))

Затем, позже, вы можете назвать это x :

1
2
(defun bar (x) (+ x 1)) // defining function bar
(print (foo bar)) // passing bar as an argument to foo

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

И теперь мы подходим к последнему вопросу, который я слышу от вас: «Хорошо, служебные классы — это не функциональное программирование, но они выглядят как функциональное программирование, они работают очень быстро и ими очень легко пользоваться. Почему бы не использовать их? Зачем стремиться к совершенству, когда 20 лет истории Java доказывают, что служебные классы являются основным инструментом каждого разработчика Java? »

Помимо фундаментализма ООП, в котором меня очень часто обвиняют, есть несколько очень практических причин (кстати, я фундаменталист ООП):

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

Эффективность Классы полезности, в силу своего императивного характера, гораздо менее эффективны, чем их декларативные альтернативы . Они просто делают все расчеты прямо здесь и сейчас, забирая ресурсы процессора, даже когда в этом нет необходимости. Вместо того, чтобы возвращать обещание разбить строку на куски, StringUtils.split() разбивает ее прямо сейчас. И это разбивает его на все возможные куски, даже если «первый покупатель» требует только первого.

Читаемость Классы StringUtils обычно бывают огромными (попробуйте прочитать исходный код StringUtils или FileUtils от Apache Commons). Вся идея разделения интересов, которая делает ООП такой красивой, отсутствует в служебных классах. Они просто помещают все возможные процедуры в один огромный файл .java , который становится абсолютно не поддерживаемым, когда он превосходит дюжину статических методов.

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