Статьи

Почему загрязнение интерфейса в Java 8

Я читал этот интересный пост о  Темной стороне Java 8 . В ней автор Лукас Эджер упоминает, как плохо, что в JDK 8 типы не просто называются функциями. Например, в языке, таком как C #, есть набор предопределенных типов функций, принимающих любое количество аргументов с необязательным типом возврата ( Func  и  Action,  каждый из которых имеет до 16 параметров различных типов T1, T2, T3,…, T16 ), но в JDK 8 мы имеем набор различных функциональных интерфейсов с  разными  именами и именами методов  , и чьи абстрактные методы представляют собой подмножество хорошо известных сигнатур функций (т. е. нулевые, унарные, двоичные, троичные и т. д.) ,

Проблема стирания типа

Таким образом, в некотором смысле оба языка страдают от некоторой формы загрязнения интерфейса (или делегируют загрязнение в C #). Разница лишь в том, что в C # все они имеют одинаковые имена. К сожалению, в Java из-за  стирания типов нет разницы между  Function<T1,T2> и,  Function<T1,T2,T3> или Function<T1,T2,T3,...Tn>, очевидно, мы не могли просто назвать их одинаково, и нам пришлось придумывать творческие имена для всех возможных типов комбинаций функций.

Не думаю, что экспертная группа не боролась с этой проблемой. По словам Брайана Гетца в лямбда-рассылке :

[…] В качестве одного примера, давайте возьмем типы функций. У лямбда-соломника, предлагаемого на devoxx, были типы функций. Я настоял, чтобы мы удалили их, и это сделало меня непопулярным. Но я возражал против функциональных типов не потому, что мне не нравятся функциональные типы — я люблю функциональные типы — но что функциональные типы плохо борются с существующим аспектом системы типов Java, стиранием. Стираемые типы функций являются худшими из обоих миров. Таким образом, мы удалили это из дизайна.

Но я не желаю говорить, что «у Java никогда не будет типов функций» (хотя я признаю, что у Java никогда не может быть типов функций.) Я считаю, что для того, чтобы перейти к типам функций, мы должны сначала иметь дело с стиранием. Это может или не может быть возможно. Но в мире усовершенствованных структурных типов функциональные типы начинают иметь гораздо больше смысла […]

Итак, как это влияет на нас как разработчиков? Ниже приведена классификация некоторых наиболее важных новых функциональных интерфейсов (и некоторых старых) в JDK 8, упорядоченных по типу возвращаемого значения функции и количеству ожидаемых аргументов в методе интерфейса.

Функции с пустым типом возврата

В области функций с возвращаемым типом void мы имеем следующее:

ТИП ФУНКЦИИ LAMBDA EXPRESSION ИЗВЕСТНЫЕ ФУНКЦИОНАЛЬНЫЕ ИНТЕРФЕЙСЫ
нульарных
() -> doSomething()
Runnable
Одинарный
foo  -> System.out.println(foo)
Потребительский  
IntConsumer  
LongConsumer  
DoubleConsumer
двоичный
(console,text) -> console.print(text)
BiConsumer  
ObjIntConsumer  
ObjLongConsumer  
ObjDoubleConsumer
п-арной
(sender,host,text) -> sender.send(host, text)
Определите свой собственный

Функции с некоторым типом возврата T

В области функций с типом возврата T мы имеем следующее:

ТИП ФУНКЦИИ LAMBDA EXPRESSION ИЗВЕСТНЫЕ ФУНКЦИОНАЛЬНЫЕ ИНТЕРФЕЙСЫ
нульарных
() ->
"Hello World"
Вызываемый  
поставщик  
BooleanSupplier
IntSupplier  
LongSupplier  
DoubleSupplier
Одинарный
n -> n +
1 
n -> n >=
0 
Функция  
IntFunction  
LongFunction  
DoubleFunction

IntToLongFunction  
IntToDoubleFunction  
LongToIntFunction
LongToDoubleFunction  
DoubleToIntFunction
DoubleToLongFunction

UnaryOperator  
IntUnaryOperator  
LongUnaryOperator  
DoubleUnaryOperator

Предикат  
IntPredicate  
LongPredicate  
DoublePredicate

двоичный
(a,b) -> a > b ?
1
:
0
(x,y) -> x + y
(x,y) -> x % y ==
0
 
БиФункция  компаратора
ToIntBiFunction  
ToLongBiFunction  
ToDoubleBiFunction

BinaryOperator  
IntBinaryOperator  
LongBinaryOperator  
DoubleBinaryOperator

BiPredicate

п-арной
(x,y,z) ->
2
* x + Math.sqrt(y) - z
Определите свой собственный

Преимущество этого подхода состоит в том, что мы можем определять наши собственные типы интерфейсов с методами, принимающими столько аргументов, сколько мы хотели бы, и мы могли бы использовать их для создания лямбда-выражений и ссылок на методы по своему усмотрению. Другими словами, мы можем загрязнять мир еще более новыми функциональными интерфейсами. Также мы можем создавать лямбда-выражения даже для интерфейсов в более ранних версиях JDK или для более ранних версий наших собственных API, которые определяли типы SAM, подобные этим. И вот теперь у нас есть возможность использовать  Runnable и  Callable как функциональные интерфейсы.

Тем не менее, эти интерфейсы становятся более трудными для запоминания, так как все они имеют разные имена и методы.

Тем не менее, я один из тех , кому интересно , почему они не решают проблему , как в Scala, определяющие интерфейсы , как Function0Function1Function2, …,  FunctionN. Возможно, единственный аргумент, который я могу выдвинуть против этого, состоит в том, что они хотели максимизировать возможности определения лямбда-выражений для интерфейсов в более ранних версиях API, как упоминалось ранее.

Отсутствие типов значений

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

Другими словами, мы не можем сделать это:

List<
int
> numbers = asList(
1
,
2
,
3
,
4
,
5
);

Но мы действительно можем сделать это:

List<Integer> numbers = asList(
1
,
2
,
3
,
4
,
5
);

Второй пример, однако, влечет за собой затраты на упаковку и распаковку упакованных объектов назад и вперед от / к примитивным типам. Это может стать очень дорогостоящим в операциях, связанных с коллекциями примитивных значений. Таким образом, группа экспертов решила создать этот взрыв интерфейсов для работы с различными сценариями. Чтобы сделать вещи «хуже», они решили иметь дело только с тремя основными типами: int, long и double.

Цитируя слова Брайана Гетца в  лямбда-рассылке :

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

Хитрость № 1 в том, чтобы не усугубить ситуацию: мы не делаем все восемь примитивных типов. Мы делаем int, long и double; все остальные могут быть смоделированы этим. Возможно, мы могли бы избавиться и от int, но мы не думаем, что большинство Java-разработчиков к этому готовы. Да, будут звонки для персонажа, и ответ будет «вставьте его в int». (Каждая специализация проецируется на ~ 100 тыс. К занимаемой площади JRE.)

Уловка №2 заключается в следующем: мы используем примитивные потоки для демонстрации того, что лучше всего делать в примитивном домене (сортировка, сокращение), но не пытаемся дублировать все, что вы можете делать в коробочном домене. Например, как указывает Алексей, нет IntStream.into (). (Если бы это было так, следующий вопрос (-ы) был бы следующим: «Где находится IntCollection? IntArrayList? IntConcurrentSkipListMap?) Предполагается, что многие потоки могут начинаться как ссылочные потоки и заканчиваться как примитивные потоки, но не наоборот. Это нормально, и это уменьшает количество необходимых преобразований (например, нет перегрузки карты для int -> T, нет специализации Function для int -> T и т. Д.)

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

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

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

Writer out =
new
StringWriter();
Consumer<String> printer = s -> out.write(s);
//oops! compiler error

Это невозможно сделать, потому что  write операция выдает проверенное исключение (т. Е.  IOException), Но сигнатура  Consumer метода не объявляет, что оно вообще выдает какое-либо исключение. Таким образом, единственным решением этой проблемы было бы создание еще большего количества интерфейсов, некоторые объявляли бы исключения, а некоторые нет (или предлагали еще один механизм на уровне языка для  прозрачности исключений ). Опять же, чтобы сделать вещи «менее худшими», экспертная группа решила ничего не делать в этом случае.

По словам Брайана Гетца в  лямбда-рассылке :

Да, вы должны предоставить свои собственные исключительные SAM. Но тогда лямбда-преобразование будет нормально работать с ними.

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

Решения, основанные на библиотеках, вызывают двукратный взрыв в типах SAM (исключительный или нет), которые плохо взаимодействуют с существующими комбинаторными взрывами для примитивной специализации.

Доступные языковые решения были проигрышными из-за сложности / ценности. Хотя есть некоторые альтернативные решения, которые мы собираемся продолжить исследовать — хотя явно не для 8 и, вероятно, не для 9 тоже.

А пока у вас есть инструменты, чтобы делать то, что вы хотите. Я понимаю, что вы предпочитаете, чтобы мы предоставили эту последнюю милю для вас (и, во-вторых, ваш запрос на самом деле является тонко завуалированным запросом «почему бы вам просто не отказаться от уже проверенных исключений»), но я думаю, что текущее состояние позволяет Вы сделали свою работу.

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

interface IOConsumer<T> {
void accept(T t) throws IOException;
}
static<T> Consumer<T> exceptionWrappingBlock(IOConsumer<T> b) {
return e -> {
try { b.accept(e); }
catch (Exception ex) { throw new RuntimeException(ex); }
};
}

Для того, чтобы сделать:

Writer out = new StringWriter();
Consumer<String> printer = exceptionWrappingBlock(s -> out.write(s));

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

Таким образом, мы видим, что группа экспертов боролась с несколькими проблемами проектирования. Необходимость, требование или ограничение для обеспечения обратной совместимости усложняли ситуацию, поэтому у нас есть другие важные условия, такие как отсутствие типов значений, стирание типов и проверенные исключения. Если бы у Java было первое и не хватало двух других, дизайн JDK 8, вероятно, был бы другим. Итак, мы все должны понимать, что это были сложные проблемы с большим количеством компромиссов, и EG пришлось где-то подвести черту и принять решение.

Итак, когда мы оказываемся в темной стороне Java 8, вероятно, нам нужно напомнить себе, что есть причина, по которой вещи темны в этой части JDK ?