Перевод статьи What's wrong in Java 8, Part II: Functions & Primitives.
Тони Хоар назвал изобретение ссылки на null "ошибкой на миллиард долларов". Возможно введение в Java примитивных типов тоже можно назвать ошибкой стоимостью в миллиард долларов. Примитивные типы были созданы для одной цели - быстродействия. Примитивным типам нечего делать в объектном языке программирования. Введение автоупаковки/распаковки было правильным шагом, но еще многое должно было быть сделано. Сейчас же нам приходится иметь дело с примитивами, и это становится препятствием, особенно при использовании функций.
До выхода Java 8 написать функцию можно было так:
public interface Function<T, U> {
U apply(T t);
}
Function<Integer, Integer> addTax = new Function<Integer, Integer>() {
@Override
public Integer apply(Integer x) {
return x / 100 * (100 + 10);
}
};
System.out.println(addTax.apply(100));
Данный код выведет в консоль
100
Java 8 предоставляет нам интерфейс Function<T, U>
и синтаксис лямбда-выражений.
Теперь отпадает необходимость определять собственный функциональный интерфейс,
так как можно использовать следующий синтаксис:
Function<Integer, Integer> addTax = x -> x / 100 * (100 + 10);
System.out.println(addTax.apply(100));
Обратите внимание, что в первом примере мы использовали анонимный класс, чтобы создать именованную функцию. Во втором примере с использованием синтаксиса лямбда-выражений ничего не поменялось. Все так же присутствует анонимный класс и именованная функция.
Остается один интересный вопрос: какой тип имеет переменная x
?
В первом примере тип переменной был объявлен явно.
Во втором примере тип был выведен из типа функции.
Java знает, что тип аргумента функции Integer
, потому что он явно указан в левой части
выражения: Function<Integer, Integer>
.
Первый Integer
указывает на тип аргумента, а второй на тип возвращаемого значения.
Конвертация int
в Integer
и наоборот происходит автоматически с помощью автоупаковки/распаковки.
Подробнее об этом ниже.
Можем ли мы использовать анонимные функции? Можем, но это приводит к проблеме с выведением типа. Данный фрагмент не скомпилируется:
System.out.println((x -> x / 100 * (100 + 10)).apply(100));
Это означает, что мы не можем заменить переменную addTax
, ссылающуюся на лямбда-выражение,
самим лямбда-выражением.
Нам необходимо указать информацию, которая содержится в левой части выражения
(Function<Integer, Integer>
), потому что Java 8 не может вывести тип без контекста.
Мы можем определить тип аргумента лямбда-выражения явно:
System.out.println((Integer x) -> x / 100 * 100 + 10).apply(100));
После этого мы можем попробовать записать наш первый пример в следующем виде:
Function<Integer, Integer> addTax = (Integer x) -> x / 100 * 100 + 10;
System.out.println(addTax.apply(100));
и этого должно быть достаточно для Java, чтобы вывести тип. Но это не работает. Компилятор требует указать тип функции. Указать тип аргумента недостаточно, даже если тип возвращаемого значение может быть выведен. И для этого есть существенный довод: Java 8 ничего не знает о функциях. Функции - это обычные объекты с обычными методами, которые мы можем вызывать. Ничего больше. Так что нам необходимо указать тип следующим образом:
System.out.println(((Function<Integer, Integer>) x -> x / 100 * 100 + 10).apply(100));
Или это можно переработать в
System.out.println(((Whatever<Integer, Integer>) x -> x / 100 * 100 + 10).whatever(100));
Так что лямбда-выражение это всего лишь синтаксический сахар, существующий, для того чтобы
облегчить реализацию интерфейса Function
(или Whatever
) анонимным классом.
И это не имеет ничего общего с функциями.
Если бы в Java существовал бы только интерфейс Function
с методом apply
, это бы не было большой
проблемой.
Но что насчет примитивов?
Интерфейс Function
был бы хорош, если бы Java была бы полностью объектным языком.
Однако Java не полностью объектна.
Она ориентирована на использование объектов (и даже называется объектно-ориентированным языком),
но самыми важными типами в ней являются примитивы.
А примитивы не слишком хорошо вписываются в концепцию ООП.
Для того чтобы помочь нам с этой проблемой в Java 5 была представлена автоупаковка, но она имеет серьезные недостатки с точки зрения производительности, и это связано с тем, как работает Java. Java - строгий язык, так что все выражения в нем вычисляются жадно. Поэтому каждый раз когда у нас имеется примитив, а требуется иметь объект, примитив приходится запаковывать. И каждый раз, когда у нас есть объект, но нам нужен примитив, объект приходится распаковывать. Если мы понадеемся на автоупаковку/распаковку, то значительная доля вычислительных ресурсов будет тратиться на многочисленные операции упаковки/распаковки.
Другие языки по-разному решают эту проблему, например предоставляют исключительно объекты, а все операции по приведению их к примитивному виду осуществляют самостоятельно. Или имеют value-классы, которые являются объектами, основанными на примитивах. В таких решениях программисты манипулируют только объектами, а компилятор только примитивами (это упрощенное представление, но оно передает суть). Позволяя программистам обращаться с примитивами в явном виде, Java все усложняет и делает более уязвимым к ошибкам, потому что программистов поощряют использовать примитивные типы в качестве полей бизнес-объектов, хотя поступать так совершенно неправильно и с точки зрения объектно-ориентированной, и со стороны функциональной парадигм программирования. (Я вернусь к этому в другой статье).
Скажу прямо: мы не должны заботиться об уменьшении производительности при упаковке и распаковке. Если Java-приложения, которые используют эту фичу, слишком медленные, то это проблема в Java. Мы не должны использовать плохие практики программирования, для того чтобы обходить недостатки языка. Используя примитивы, мы заставляем язык работать против нас, а не на нас. Если эту проблему невозможно решить, исправив язык, мы просто должны использовать другой язык. К сожалению, перейти на другой язык мы не можем по многим причинам, главная из которых - нам платят за программирование на Java, а не на другом языке. В результате вместо того, чтобы решать проблемы бизнеса, мы решаем проблемы Java. А использование примитивов в Java - это проблема, и очень большая.
Давайте перепишем наш пример, используя примитивы вместо объектов.
Наша функция принимает аргумент типа Integer
и возвращает результат типа Integer
.
Для того чтобы поменять типы на примитивы в Java существует интерфейс IntUnaryOperator
.
Воу, это попахивает!
Определен этот интерфейс так:
public interface IntUnaryOperator {
int applyAsInt(int operand);
//...
}
Возможно назвать этом метод apply
показалось разработчикам Java слишком простым.
Итак, наш пример при использовании примитивов будет выглядеть так:
IntUnaryOperator addTax = x -> x / 100 * (100 + 10);
System.out.println(addTax.applyAsInt(100));
или, при использовании анонимной функции:
System.out.println(((IntUnaryOperator) x -> x / 100 * (100 + 10)).applyAsInt(100));
Для функций, принимающих int
и возвращающих int
, все выглядит достаточно просто.
Но в целом ситуация намного сложнее.
В Java 8 представлено 43 функциональных интерфейсов в пакете java.util.function
.
При этом не все из них представляют собой функции.
Эти интерфейсы можно классифицировать следующим образом:
- 21 функция одного аргумента, из которых 2 функции принимающие и возвращающие объекты, а
остальные 19 различные варианты функций преобразования объектов в примитивы и наоборот.
Одна из двух функций
объект -> объект
, является очень специфичной, когда аргумент и возвращаемое значение имеют одинаковый тип. - 9 функций двух аргументов, из которых 2 функции
объект, объект -> объект
и 7 функций различных видовобъект, объект -> примитив
илипримитив, примитив -> примитив
. - 7 эффектов - функций, которые ничего не возвращают, и используются из-за их побочных эффектов. (Странно, что их называют функциональными интерфейсами).
- 5 поставщиков - функций, которые не имеют аргументов, но возвращают значение. Их можно считать функциями. В функциональном мире такие функции называют нульарными (чтобы подчеркнуть, что их количество аргументов равно 0). Возвращаемое значение таких функций может быть неизменным, что позволяет использовать такие функции для оперирования константами. В Java 8 роль таких функций - поставлять различные значения в зависимости от состояния внешнего контекста. Таким образом поставщики не являются функциями.
Какой ужас!
Кроме того методы данных функциональных интерфейсов имеют разные имена.
Объектные функции имеют метод с именем apply
, тогда как методы функций, возвращающих
примитивные числовые значения, называются applyAsInt
, applyAsLong
и applyAsDouble
.
У функций, возвращающих boolean
, метод называется test
, а методы поставщиков имеют
названия get
, getAsInt
, getAsLong
, getAsDouble
и getAsBoolean
.
(Почему-то разработчики Java не решились назвать BooleanSupplier
"предикатом" с методом test
,
не принимающим аргументов. И я понятия не имею почему!)
Нужно отметить, что не существует функций для примитивных типов byte
, char
, short
и float
.
Также как отсутствуют функции с арностью больше двух.
Нечего и говорить, что это совершенно нелепо. Но нам приходится жить с этим. Пока Java может вывести тип, мы не заметим проблем. Однако если вы попытаетесь обращаться с функциями в функциональном стиле, то вы быстро столкнетесь с проблемой, что Java не может вывести тип. Даже хуже, иногда Java выводит не тот тип, что вам нужен.
Допустим нам нужна функция трех аргументов. Так как в Java 8 нет подходящих функциональных интерфейсов, то мы стоим перед выбором: написать собственный функциональный интерфейс или воспользоваться каррированием, как было предложено в предыдущей статье. Создание собственного функционального интерфейса с тремя аргументами - это решение "в лоб":
interface Function<T, U, V, R> {
R apply(T, t, U, u, V, v);
}
Однако мы сталкиваемся с двумя проблемами.
Первая: как быть, если нам понадобится использовать примитивы?
Обобщения не могут помочь нам с этой проблемой.
Мы можем создать отдельные версии этого функционального интерфейса для использования примитивных типов
вместо объектов.
В конце концов для восьми примитивных типов, трех типов аргументов и одного типа возвращаемого значения
существует всего 6 561 различная версия этой функции.
Теперь понятно почему Oracle не добавили TriFunction
в Java 8?
(Если быть точными, то они добавили ограниченное количество реализаций BiFunction
: с объектными
аргументами и типами возвращаемых значений int
, long
и double
; и с совпадающими типами
аргументов и возвращаемых значений для int
, long
и Object
, в итоге реализовав только 9
вариантов из 729 возможных.)
Гораздо более выгодным решением будет использование автоупаковки.
Просто используйте типы Integer
, Long
, Boolean
и прочие и позвольте Java разбираться с ними.
Любые другие решения являются корнем зла и так называемой преждевременной оптимизацией
(premature optimization).
Можно пойти другим путем (вместо написания собственного функционального интерфейса с тремя аргументами)
и использовать каррирование.
Кроме того это будет единственным подходящим решением, если аргументы должны иметь возможность
быть вычисленными в различные моменты времени.
Кроме того при каррировании мы используем функции только одного аргумента, что ограничивает
различных видов функций до 81.
Если мы ограничимся только типами boolean
, int
, long
и double
, то количество упадет до 25
(четыре примитивных типа и объектный тип в двух местах = 5^2).
Вторая проблема заключается в том, что могут возникнуть сложности в использовании каррирования с функциями, возвращающими примитивы или принимающими аргументы примитивных типов. В качестве примера, посмотрите на фрагмент, взятый из предыдущей статьи, но переписанный с использованием примитивов:
IntFunction<IntFunction<IntUnaryOperator>> intToIntCalculation = x -> y -> z -> x + y * z;
private IntStream calculate(IntStream stream, int a) {
return stream.map(intToIntCalculation.apply(b).apply(a));
}
IntStream stream = IntStream.of(1, 2, 3, 4, 5);
IntStream newStream = calculate(stream, 3);
Заметьте, что в результате мы получаем не стрим, содержащий значения 5, 8, 11, 14 и 17,
а не более чем первоначальный стрим со значениями 1, 2, 3, 4 и 5. newStream
еще не вычислен
на данном шаге, так что он еще не имеет значений (мы еще поговорим об этом в
следующей статье).
Для того чтобы получить результат, необходимо вычислить стрим, для чего нужно применить к нему
терминальную операцию.
Например, можно использовать метод collect
.
Но перед тем как сделать это, необходимо применить к стриму еще одну промежуточную функцию,
используя метод boxed
.
Метод boxed
оборачивает примитивные элементы стрима в соответствующие им объекты.
Это упростит вычисление:
System.out.println(newStream.boxed().collect(toList()));
В консоль будет напечатано:
[5, 8, 11, 14, 17]
Мы могли бы использовать анонимную функцию, однако Java не может вывести тип, поэтому нам приходится помочь ей в этом:
private IntStream calculate(IntStream stream, int a) {
return stream.map(((IntFunction<IntFunction<IntUnaryOperator>>)
x -> y -> z -> x + y * z).apply(b).apply(a));
}
IntStream stream = IntStream.of(1, 2, 3, 4, 5);
IntStream newStream = calculate(stream, 3);
Каррирование само по себе очень простая операция. Просто помните, как я писал в предыдущей статье, что это
(x, y, z) -> w
преобразуется в
x -> y -> z -> w
Определить правильный тип немного сложнее.
Вам нужно помнить, что при каждой подстановке аргумента вы получаете новую функцию.
А значит вам нужна функция, преобразующая тип аргумента в объектный тип
(потому что функции - это объекты).
В нашем случае каждый аргумент имеет тип int
, поэтому нам нужно использовать IntFunction
,
параметризованный типом возвращаемой функции. В качестве последнего типа используется
IntUnaryOperator
(как того требует метод map
класса IntStream
).
Общий вид типа функции для примитивного типа int
будет следующим:
IntFunction<IntFunction<...<IntUnaryOperator>...>>
В нашем случае с тремя аргументами типа int
тип функции запишется как:
IntFunction<IntFunction<IntUnaryOperator>>
Можно сравнить с решением, в котором мы использовали автоупаковку:
Function<Integer, Function<Integer, Function<Integer, Integer>>>
Если вы испытываете сложности с определением типа, начните с варианта, использующего автоупаковку, а затем поменяйте последний тип на тот, который требуется вам:
Function<Integer, Function<Integer, IntUnaryOperator>>
Заметьте, что вы прекрасно можете использовать такой тип функции в своем коде:
private IntStream calculate(IntStream stream, int a) {
return stream.map(((Function<Integer, Function<Integer, IntUnaryOperator>>) x -> y -> z -> x + y * z)
.apply(b).apply(a));
}
IntStream stream = IntStream.of(1, 2, 3, 4, 5);
IntStream newStream = calculate(stream, 3);
Затем вы можете заменить каждый Function<Integer ...
на конкретный функциональный интерфейс,
подходящий для того примитива, который вы используете.
В итоге получим:
private IntStream calculate(IntStream stream, int a) {
return stream.map(((Function<Integer, IntFunction<IntUnaryOperator>>) x -> y -> z -> x + y * z)
.apply(b).apply(a));
}
а затем:
private IntStream calculate(IntStream stream, int a) {
return stream.map(((IntFunction<IntFunction<IntUnaryOperator>>) x -> y -> z -> x + y * z)
.apply(b).apply(a));
}
Все три представленные решения компилируются и выполняются. Единственная разница состоит в том, используется автоупаковка или нет.
Как мы увидели на примерах выше, лямбда-выражения сильно упрощают создание анонимных классов, но редко бывает веская причина не именовать созданную функцию. Именование функций позволяет:
- использовать функции повторно;
- тестировать функции;
- заменять функции;
- проще поддерживать код;
- документировать код.
Именование функций совместно с каррированием делает вашу функцию абсолютно независимой от внешнего контекста (ссылочная прозрачность), делая код ваших программ безопаснее и пригодным для повторного использования. Однако здесь есть и сложности. Использование примитивов усложняет определение типа каррируемой функции. И, что еще хуже, примитивные типы, это не те типы, которые следует использовать в бизнес-логике, и компилятор не сможет вам помочь в этой области. Чтобы понять почему, взглянем на пример:
double tax = 10.24;
double limit = 500.0;
double delivery = 35.50;
DoubleStream stream3 = DoubleStream.of(234.23, 567.45, 344.12, 765.00);
DoubleStream stream4 = stream3.map(x -> {
double total = x / 100 * (100 + tax);
if (total > limit) {
total = total + delivery;
}
return total;
});
Определить тип именованной карированной функции, которой можно заменить анонимную функцию,
не так-то просто.
У функции должно быть четыре аргумента и она будет возвращать DoubleUnaryOperator
, значит ее
тип будет
DoubleFunction<DoubleFunction<DoubleFunction<DoubleUnaryOperator>>>
При этом перепутать порядок подстановки аргументов будет проще простого:
DoubleFunction<DoubleFunction<DoubleFunction<DoubleUnaryOperator>>> computeTotal = x -> y -> z -> w -> {
double total = w / 100 * (100 + x);
if (total > y) {
total = total + z;
}
return total;
};
DoubleStream stream2 = stream.map(computeTotal.apply(tax).apply(limit).apply(delivery));
Откуда вы можете знать, что означают x
, y
, z
и w
?
Существует простое правило: аргументы подставляются в метод apply
в том же порядке, в котором
они идут в лямбда-выражении, т.е. переменные tax
, limit
и delivery
будут подставлены на места
x
, y
и z
соответственно. Аргумент, который приходит из стрима - последний, поэтому ему
соответствует переменная w
.
Однако все еще осталась одна проблема: даже если мы протестировали функцию на безошибочное функционирование, мы не можем быть уверены, что ее будут правильно использовать. Например, если мы подставим аргументы в неверном порядке:
DoubleStream stream2 = stream.map(computeTotal.apply(limit).apply(tax).apply(delivery));
мы получим
[1440.8799999999999, 3440.2000000000003, 2100.2200000000003, 4625.5]
вместо
[258.215152, 661.05688, 379.357888, 878.836]
Это означает, что мы должны тестировать не только функцию, но и каждый вариант ее использования. Не было бы лучше, если бы попытка подставить аргументы в неверном порядке приводила бы к ошибке компиляции?
Все это касается использования правильной системы типов. Использование примитивов в качестве полей бизнес-объектов всегда считалось плохой практикой. Но теперь, с появлением функций, мы получили еще один повод избегать примитивные типы. Это будет предметом обсуждения еще одной статьи.
Мы увидели, что использование примитивных типов усложняет программу. Функции, позволяющие оперировать примитивными типами, появились в Java 8 совершенно напрасно. Но самое худшее еще впереди.