Статьи

Что не так в Java 8, часть II: функции и примитивы

Тони Хоар назвал изобретение нулевой ссылки «ошибкой в ​​миллиард долларов». Возможно, использование примитивов в Java можно назвать ошибкой в ​​миллион долларов. Примитивы были созданы по одной причине: производительность. Примитивам нечего делать на языке объектов. Введение автоматического бокса / распаковки было хорошей вещью, но нужно было сделать гораздо больше. Это, вероятно, будет сделано (иногда говорят, что это на дорожной карте Java 10). В то же время нам приходится иметь дело с примитивами, и это хлопотно, особенно при использовании функций.

Функции в Java 5/6/7

До 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));

Этот код дает следующий результат:

110

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тип аргумента, а второй Integerтип возвращаемого значения.

Бокс автоматически используется для преобразования intв Integerи обратно по мере необходимости. Подробнее об этом позже.

Можем ли мы использовать анонимную функцию? Да, но у нас будет проблема с типом. Это не работает:

 
 
System.out.println((x -> x / 100 * (100 + 10)).apply(100));

Это означает, что мы не можем заменить идентификатор addTaxего значением ( addTaxфункцией). Мы должны восстановить информацию о типе, которая сейчас отсутствует, потому что Java 8 просто не может определить тип в этом случае.

Наиболее заметной вещью, которая не имеет явного типа, является идентификатор x. Итак, мы можем попробовать:

 
 
System.out.println((Integer x) -> x / 100 * 100 + 10).apply(100));

В конце концов, в первом примере мы могли бы написать:

 
 
Function<Integer, Integer> addTax = (Integer x) -> x / 100 * 100 + 10;

так что для 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 был язык объекта. Но это не так. Он только смутно ориентирован на использование объектов (отсюда и название Object Oriented ). Наиболее важными типами в Java являются примитивы. И примитивы плохо вписываются в ООП.

Автоматический бокс был введен в Java 5, чтобы помочь нам справиться с этой проблемой, но автоматический бокс является серьезным ограничением с точки зрения производительности, и это связано с тем, как все оценивается в Java. Java — строгий язык, поэтому стремление к оценке — это правило. Следствием этого является то, что каждый раз, когда у нас есть примитив и нужен объект, примитив должен быть упакован. И каждый раз, когда у нас есть объект и нам нужен примитив, он должен быть распакован. Если мы полагаемся на автоматическую упаковку при распаковке, у нас может быть много накладных расходов на множественную упаковку и распаковку.

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

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

Давайте перепишем наш пример, используя примитивы вместо объектов. Наша функция принимает аргумент типа Integerи возвращает Integer. Чтобы заменить это, Java имеет тип IntUnaryOperator. Вау, это пахнет! И угадайте, что, это определяется как:

 
 
public interface IntUnaryOperator {
  int applyAsInt(int operand);
  ...
}

Вероятно, было бы слишком просто вызвать метод apply.

Итак, наш пример с использованием примитивов может быть переписан как:

 
 
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 являются «поставщиками», что означает функции, которые не принимают аргумент, но возвращают значение. Это могут быть функции. В функциональном мире это специальные функции, называемые нулевыми функциями (чтобы указать, что их арность или число аргументов равно нулю). Что касается функций, их возвращаемое значение может никогда не измениться, поэтому они позволяют рассматривать константы как функции. В Java 8 их роль заключается в зависимости от изменяемого контекста для возврата значений переменных. Итак, они не являются функциями.

Какой беспорядок! И более того, методы этих интерфейсов имеют разные имена. Функции объекта есть метод с именем apply, где методы , возвращающие числовые примитивы имеют имя метода applyAsInt, applyAsLongили applyAsDouble. Функции возвращающие booleanесть метод , называемый testи поставщики метода называют get, или getAsInt, getAsLong, getAsDoubleили getAsBoolean. (Они не осмеливались называть BooleanSupplier«Predicate» testметодом, не имеющим аргументов. Мне действительно интересно, почему!)

Единственное , что следует отметить, что нет функции для byte, char, shortи float. Не существует и функций для арности больше двух.

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

Как помочь найти правильный тип

Допустим, мы хотим использовать функцию с тремя аргументами. Поскольку в Java 8 таких функциональных интерфейсов нет, у вас остается выбор: создать собственный функциональный интерфейс или использовать каррирование, как мы видели в предыдущей статье ( Что не так с Java 8, часть I ). Создание функционального интерфейса с тремя аргументами объекта, возвращающего объект, довольно просто: 

 
 
interface Function<T, U, V, R> {
  R apply(T, t, U, u, V, v);
}

However, we may face two problems. The first one is that we may need to process primitives. Parametric types will not help us for this. You may create special versions of the function using primitives instead of objects. After all, with eight type of primitives, three arguments and one return value, there are only 6 561 different versions of this function. Why do you think Oracle did not put TriFunction in Java 8? (To be precise, they only put a very limited number of BiFunction where arguments are Object and return type int, long or double, or when argument and return types are of the same type int, long or Object, leading to a total of 9 out of 729 possible.)

A much better solution is to use autoboxing. Just use Integer, Long, Boolean and so on and let Java handle this. Doing whatever else would be the root of all evil, i.e. premature optimization (see http://c2.com/cgi/wiki?PrematureOptimization).

Another way to go (beside creating three arguments functional interface) is to use currying. This is mandatory if the arguments may not be evaluated at the same time. Furthermore, it allows using only functions of one argument, which limits the number of possible functions to 81. If we restrict ourselves to boolean, int, long and double, the number falls to 25 (four primitive types plus Object in two places equals 5 x 5).

The problem is that it may be somewhat difficult to use currying with functions returning primitives or taking primitives as their argument. As an example, here is the same example used in our previous article (What’s wrong with Java 8 part I ), but using primitives:

 
 
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);

Note that the result is not “a stream containing the values 5, 8, 11, 14 and 17”, no more than the initial stream would have contained the value 1, 2, 3, 4 and 5. newStream in not evaluated at this stage, so it does not contain values. (We’ll talk about this in a next article).

To see the result, we have to evaluate the stream, which may be forced by binding it to a terminal operation. This may be done through a call to the collect method. But before doing this, we will bind the result to one more non terminal function using the method boxed. The boxed methods binds to the stream a function converting primitives to the corresponding objects. This will simplify evaluation:

 
 
System.out.println(newStream.boxed().collect(toList()));

This prints:

[5, 8, 11, 14, 17]

We could as well use an anonymous function. However, Java is not be able to infer the type, so we must help it:

 
 
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);

Currying in itself is very easy. Just remember, as I said in a previous article, that:

(x, y, z) -> w

translates to

x -> y -> z -> w

Finding the right type is slightly more complicated. You have to remember that each time you apply an argument, you are returning a function, so you need a function from the type of the argument to an object type (because functions are objects). Here, each argument is of type int, so we need to use IntFunction parameterized with the type of the returned function. As the final type is IntUnaryOperator (as required by the map method of the IntStream class), the result is:

 
 
IntFunction<IntFunction<...<IntUnaryOperator>>>

Here, we are applying two of the three parameters and all parameters are of type int, so the type is:

 
 
IntFunction<IntFunction<IntUnaryOperator>>

This may be compared to the version using autoboxing:

 
 
Function<Integer, Function<Integer, Function<Integer, Integer>>>

If you have problems determining the right type, start with the version using autoboxing, just replacing the final type you know you need (since it is the type of the argument of map):

 
 
Function<Integer, Function<Integer, IntUnaryOperator>>

Note that you may perfectly use this type in your program:

 
 
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);

You may then replace each Function<Integer... with the specific version for the primitive you are using, going to:

 
 
private IntStream calculate(IntStream stream, int a) {
  return stream.map(((Function<Integer, IntFunction<IntUnaryOperator>>) x -> y -> z -> x + y * z).apply(b).apply(a));
}

and then to:

 
 
private IntStream calculate(IntStream stream, int a) {
  return stream.map(((IntFunction<IntFunction<IntUnaryOperator>>) x -> y -> z -> x + y * z).apply(b).apply(a));
}

Note that all three versions compile and run. The only difference is whether autoboxing is used or not.

When to be anonymous

So, as we saw in the examples above, lambdas are very good at simplifying anonymous class creation, but there is rarely good reason not to name the instance that is created. Naming functions allows:

  • function reuse

  • function testing

  • function replacement

  • program maintenance

  • program documentation

Naming function plus currying will make your function completely independent from the environment (“referential transparency”), making you programs safer and more modular. There is however a difficulty. Using primitives makes it difficult to figure the type of curried function. And worst, primitive are not the right business types to use, so the compiler will not be able to help you in this area. To see why, look at this example:

 
 
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;
});

To replace the anonymous “capturing” function by a named curried one, determining the correct type is not so difficult. There will be four arguments and it will return a DoubleUnaryOperator, so the type will be DoubleFunction<DoubleFunction<DoubleFunction<DoubleUnaryOperator>>>. However, it is very easy to misplace the arguments:

 
 
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));

How can you be sure what x, y, z and w are ? There is in fact a simple rule: the arguments that are evaluated through the explicit use of the apply method come first, in the order they are applied, i.e. tax, limit, delivery, corresponding to x, y and z. The argument coming from the stream is applied last, so it corresponds to w.

However, we are still having a problem: once the function is tested, we now that it is correct, but there is no way to be sure it will be used right. For example if we apply the parameters in the wrong order:

 
 
DoubleStream stream2 = stream.map(computeTotal.apply(limit).apply(tax).apply(delivery));

we get

 
 
[1440.8799999999999, 3440.2000000000003, 2100.2200000000003, 4625.5]

instead of:

 
 
[258.215152, 661.05688, 379.357888, 878.836]

This means we have to test not only the function, but each use of it. Wouldn’t it be nice if we could be sure that using the parameters in the wrong order would not compile?

This is what using the right type system is about. Using primitives for business types is not good. It has never be. But now, with functions, we have one more reason not to do this. This will be the subject of another article.

What’s next

We have seen how using primitives is somewhat more complicated that using objects. Functions using primitives are a real mess in Java 8. But the worst is to come. In a next article, we will talk about using primitives with streams.