Тагир Валеев (lany) и Барух Садогурский (jbaruch) собрали новую коллекцию Java-паззлеров и спешат ими поделиться.
В основе статьи – расшифровка их выступления на осенней конференции JPoint 2017. Она показывает, сколько загадок таит в себе Java 8 и едва замаячившая на горизонте Java 9. Все эти стримы, лямбды, монады, Optional-ы и CompletableFuture-ы были добавлены туда исключительно для того, чтобы нас запутать.
Все, о чем они рассказывают, должно работать на последней версии Java 8 и 9, соответственно. Мы проверили – вроде все по-честному: как написано, так себя и ведет.
На всякий случай пара слов об авторах, хотя мы думаем, вы их и так уже хорошо знаете: Барух занимается Developer Relations в компании JFrog, Тагир – разработчик IntelliJ IDEA и автор библиотеки StreamEx.
Java-паззлер №1: как прохакать банк
Для разминки задаем первый паззлер: неизвестные американские хакеры пытаются прохакать банк.
Мы тут в общем-то маппируем банковскую логику на Java Semaphore. В конструкторе Semaphore у нас будет начальный баланс. Мы начинаем в овердрафте -42 и дальше мы маппируем некоторые методы Semaphore на банковские методы. То есть drainPermits
– это у нас будет «забрать все деньги из банка», а вот availablePermits
– это будет «проверить баланс».
public class PerfectRobbery {
private Semaphore bankAccount = new Semaphore(-42);
public static void main(String[] args) {
PerfectRobbery perfectRobbery = new PerfectRobbery();
perfectRobbery.takeAllMoney();
perfectRobbery.checkBalance();
}
public void takeAllMoney(){
bankAccount.drainPermits();
}
public void checkBalance(){
System.out.println(bankAccount.availablePermits());
}
}
И дальше у нас будет некоторая логика. Мы создаем объект PerfectRobbery
в классе PerfectRobbery
и вызываем два метода: забрать все деньги из банка и проверить, что мы действительно забрали все деньги.
Как можно создать Semaphore с отрицательным начальным значением? Это прекрасный вопрос, потому что это первый вариант ответа. И кроме него, у меня есть еще три.
A. IllegalArgumentException
– нельзя создавать семафор с негативным балансом;
B. UnsupportedOperationException
– можно создать семафор с негативным балансом, но нельзя вызывать на нем drainPermits;
C. 0 – drainPermits
при негативном балансе оставит ноль пермитов;
D. -42 — drainPermits
при негативном балансе оставит столько же, сколько было, потому что сливать нечего.
Голосование в аудитории показало, что большинство – за вариант D, а правильный ответ – С.
В документации Java можно найти упоминание о том, что семафор может принимать негативное значение. Кроме этого, там написано, что drainPermits
возвращает все available permits.
Сколько у нас доступно, когда у нас есть -42 пермита? У нас доступно 0, поэтому Сергей Курсенко открыл багу и сказал: ребята, что-то у вас какая-то ерунда. Drain available permits при -42 должен оставлять -42, потому что available permits 0. Когда мы сольем 0 из -42, будет -42.
Но не тут-то было, потому что в комменты пришел Даг Ли и сказал: «Я так хочу! И поэтому я «пофиксю это», добавив строчку в Javadoc».
Java-паззлер №2: синглтоны
Пойдем дальше. Маленький совет от нас: не создавайте синглтоны, лучше пейте синглтоны.
Давайте посмотрим Java 7. Вы можете создавать там пустые списки с помощью emptyList
и пустые итераторы с помощью emptyIterator
.
Collections.emptyList() == Collections.emptyList();
Collections.emptyIterator() == Collections.emptyIterator();
И вот вопрос: а синглтоны ли они? Всегда ли нам возвращается один и тот же объект? У нас есть четыре варианта ответа:
A. true/true – всегда возвращается;
B. true/false – синглтон — только список, а итератор каждый раз разный;
C. false/true – итератор – синглтон, а список каждый раз создается новый;
D. false/false – это вообще не синглтоны.
Голосование в аудитории показало, что большинство – за вариант B, а правильный ответ – A. Тут напрашивается вопрос: а где паззлер? Паззлер будет дальше.
Перейдем к Java 8. В ней появились новые методы, которые нам возвращают пустые штуки: сплиттераторы и стрим.
Spliterators.emptySpliterator() == Spliterators.emptySpliterator();
Stream.empty() == Stream.empty();
И давайте повторим вопрос для них:
A. true/true
B. true/false
C. false/true
D. false/false
Голосование в аудитории показало, что большинство – за вариант D, а правильный ответ – B. Сплиттератор может быть синглтоном, потому что у него нет состояния: пустой сплиттератор вы можете сколько угодно раз пытаться обойти, он скажет, что обойти нечего. Однако стрим имеет состояние, и оно состоит как минимум из двух вещей: во-первых, на стрим вы можете вешать closeHandler
-ы. Представьте, что если бы это был синглтон, вы бы в одном месте повесили на него один хендлер, а в другом – другой, и неизвестно, что у вас после этого получится. Конечно, каждый пустой стрим должен быть свободный, независимый. Во-вторых, стрим должен быть использован только один раз. Если стрим используется повторно, то он определяет это и кидает IllegalStateException
.
Java-паззлер №3: одинаковые списки
В следующем паззлере мы используем слово «одинаковые» в несколько странном смысле. Одинаковые – это такие же по состоянию внутренней структуры. Это не значит, что они равны по equals
или у них hashcode одинаковый, и не значит, что они имеют отношение к проверке референсов.
Мы создаем массив из двух списков. В Java 8 появился метод setAll
, который позволяет сразу его весь заполнить. Мы его заполняем с помощью конструктора ArrayList
. Получим массив, состоящий из двух списков:
List[] twins = new List[2];
Arrays.setAll(twins, ArrayList::new);
Вопрос: какие списки там будут? Варианты ответа:
A. Одинаковые пустые списки
B. Одинаковые не пустые списки
C. Не одинаковые пустые списки
D. Не одинаковые не пустые списки
Голосование в аудитории показало, что большинство – за вариант A, а правильный ответ – C.
Во-первых, setAll
принимает не supplier
, а inputInt Function
, которая ему на вход передается индекс массива и, соответственно, этот индекс массива аргументом. Он автоматически меппится на конструктор ArrayList
не от пустого аргумента, а от initialCapacity
. То есть этот индекс, который передается там, нигде не прописан и не виден. И это прямо какой-то Groovy: мы что-то пишем, и у нас что-то выполняется, а мы не знаем, что.
Кстати, мы можем вылететь на OutOfMemory
благодаря этому. Если бы мы создали массив на 100 тысяч, у нас были бы списки, в которых внутри были бы предопределенные массивы тоже на 100 тысяч.
Java-паззлер №4: Single Abstract Method
Давайте попробуем создать функциональный интерфейс. Сначала создадим просто интерфейс, но засунем в него четыре метода, три из них будут абстрактные. А потом от него наследуем другой интерфейс и сделаем его функциональным. Скомпилируется ли?
public interface Сэм<T> {
default void расширятьНато(String новаяСтрана) {
System.out.println(новаяСтрана); }
void расширятьНато(T songName);
void захватыватьНефть(T новаяСтрана);
void захватыватьНефть(String новаяСтрана);
}
@FunctionalInterface
public interface ДядяСэм extends Сэм<String> { }
Вот варианты ответов:
A. Что за ерунда?! ’Single’ означает один метод, не три!
B. Проблема с методом расширятьНато(T)
, если его убрать, все ОК
C. Проблема с методами захватыватьНефть
, если убрать один, то все
будет ОК.
D. Все путем! Дубликаты схлопываются, и мы остаемся с одним захватыватьНефть
.
Голосование в аудитории показало, что большинство – за вариант D, а правильный ответ – B. Дело в том, что метод, который не реализован (расширятьНато
), не перекрывается дефолтной реализацией, и компилятор не может решить, что использовать. Это написано в документации: вы можете унаследовать из интерфейса несколько абстрактных методов с override-equivalent signatures. Когда мы определили, что Т – это стринг, два метода схлопнулись, это хорошо. Но если интерфейс наследует дефолтный метод, и его сигнатура override-equivalent абстрактному методу, это ошибка компиляции потому что возникает неоднозначность: хотим мы использовать дефолтную реализацию или нет.
Скриншот из Java Language Specification
Java-паззлер №5: Как хакнуть банк. Вторая версия
Весь банковский софт написан на Java. Альфа-банк – это Java, Deutsche Bank – это Java, Сбербанк – это Java. Все банки пишут на Java, значит, атака на Java, в ней много дыр, ее легко хакнуть, потом найти самые крупные счета и снять с них деньги.
Set<String> accounts =
new HashSet<>(Arrays.asList("Gates", "Buffett", "Bezos", "Zuckerberg"));
System.out.println("accounts= " + accounts);
Давайте соберем их в сет и распечатаем. Интересно, мы увидим их в том же самом порядке, в котором мы их завели?
A. Порядок объявления сохраняется
B. Порядок неизвестен, но сохраняется между запусками
C. Порядок неизвестен и меняется при каждом перезапуске JVM
D. Порядок неизвестен и меняется при каждой распечатке
Голосование в аудитории показало, что большинство – за вариант B, и это правильный ответ. Все прекрасно знают, что внутри хешсета – хешмеп, а в хешмепе – ключи.
С этим надо что-то делать! Поэтому мы переходим на Early Access Release Java 9 (банки всегда так делают, они используют все самое свежее). И тут все становится интереснее, так как тут появился метод Set.of
, благодаря чему вместо всего этого длинного можно написать коротко.
Set<String> accounts = Set.of("Gates", "Buffett", "Bezos", "Zuckerberg");
System.out.println("accounts= " + accounts);
Вопрос остается таким же: если собрать в сет и распечатать, мы увидим счета в том же самом порядке, в котором мы их завели?
A. Порядок объявления сохраняется
B. Порядок неизвестен, но сохраняется между запусками
C. Порядок неизвестен и меняется при каждом перезапуске JVM
D. Порядок неизвестен и меняется при каждой распечатке
Голосование в аудитории показало, что большинство – за вариант С, и это правильный ответ. У нас есть доказательство. Мы можем воспользоваться новой замечательной штукой, которая появилась в девятой Java и называется JShell
. Мы засовываем этот код, получаем какой-то порядок, повторяем, получаем другой порядок, повторяем, получаем третий порядок. Как это работает?
Это сделано специально. Вот таким образом вычисляется элемент таблицы по хеш-коду в этом самом Set.of
:
private int probe(Object pe) {
int idx = Math.floorMod(pe.hashCode() ^ SALT, elements.length);
while (true) {
E ee = elements[idx];
if (ee == null) {
return -idx - 1;
} else if (pe.equals(ee)) {
return idx;
} else if (++idx == elements.length) {
idx = 0;
}
}
}
Видите, что там есть хеш-код ^ SALT
, а SALT – это статическое поле, которое при запуске JVM инициализируется случайным числом. Это сделано специально, потому что слишком много людей закладывались на порядок хеш-сета, когда он не был определен и когда в документации черным по белому было написано: «не закладывайтесь на него». Поэтому было сделано так, что при попытке заложиться, вы просто перезапустите JVM, и это больше не сработает. Вы просто не сможете на это заложиться. Хотя тут есть опасность: некоторые могут заложиться, что эта штука работает случайно.
Java-паззлер №6: Jigsaw
Есть несколько утверждений про Jigsaw
. Попробуйте угадать, какое из них правильное:
A. Если сделать приложение jigsaw модулем, зависимости в classpath
будут подгружаться корректно
B. Если одна из зависимостей – jigsaw модуль, то обязательно прописать файл module-info
C. Если вы прописали файл module-info, то все зависимости придется прописать дважды, в classpath
и в module-info
D. Никакое не верно
Правильный ответ – C. Конечно же, вам придется все прописывать дважды. Хорошие новости в том, что Gradle
и Maven
будут генерировать оба этих компонента для вас: и правильный classpath
, и правильный module-info
, поэтому вам не придется делать это ручками. Но если вы не работаете с этими инструментами, вам придется делать это два раза, хотя есть нюанс. Вы можете использовать флажок module-path
, и там есть свой паззлер, но про него в следующий раз.
Java-паззлер №7: Неудержимые 2
У нас есть коллекция Неудержимых, и мы их всех хотим уничтожить. Уничтожать будем так: возьмем итератор и вызовем у него метод forEachRemaining
. И для каждого элемента будем делать такую вещь: если там есть следующая запись, то мы передвигаемся и уничтожаем (и это все внутри forEachRemaining
).
static void killThemAll(Collection<Hero> expendables) {
Iterator<Hero> heroes = expendables.iterator();
heroes.forEachRemaining(e -> {
if (heroes.hasNext()) {
heroes.next();
heroes.remove();
}
});
System.out.println(expendables);
}
Какие есть варианты?
A. Все умерли
B. Только четные умерли
C. Все выжили
D. Только нечетные умерли
E. Все ответы верны
Правильный ответ – E. Это undefined behavior. Сюда можно попробовать подать разные коллекции, и если попробовать это сделать, мы получим разные результаты.
Если мы сюда подадим ArrayList
, то мы получим, что все умерли.
killThemAll(new ArrayList<String>(Arrays.asList("N","S","W","S","L","S","L","V")));
[]<
/source>
Если мы сюда подадим <code>LinkedList</code>, то мы получим, что четные умерли
<source lang="java">killThemAll(new LinkedList<String>(Arrays.asList("N","S","W","S","L","S","L","V")));
[S,S,S,V]
Если мы сюда подадим ArrayDeque
, то все останутся живы, и никаких исключений.
killThemAll(new ArrayDeque<String>(Arrays.asList("N","S","W","S","L","S","L","V")));
[N,S,W,S,L,S,L,V]
А если мы сюда подадим TreeSet
, то, наоборот, умрут нечетные.
killThemAll(new TreeSet<String>(Arrays.asList("N","S","W","S","L","S","L","V")));
[N,W,L,L]
Поэтому никогда! Никогда так не делайте! На самом деле это получилось случайно — просто потому, что никто не думал, что кто-то так будет делать. Когда мы сообщили об этом в Oracle, они сделали что? Правильно, «пофиксили эту проблему», написав об этом в документации:
Java-паззлер №8: Незаметная разница
Хотим создать оригинальный, настоящий Adidas в виде предиката, который будет проверять, что это действительно Adidas. Мы создаем функциональный интерфейс, параметризуем его каким-то типом T и, соответственно, реализуем его в виде Лямбды или в виде methodRef
:
@FunctionalInterface
public interface OriginalPredicate<T> {
boolean test(T t);
}
OriginalPredicate<Object> lambda = (Object obj) -> "adidas".equals(obj);
OriginalPredicate<Object> methodRef = "adidas"::equals;
Вопрос: скомпилируется это все или нет?
A. Оба скомпилируются
B. Ламбда скомпилируется, ссылка на метод – нет
C. Ссылка на метод скомпилируется, лямбда – нет
D. Не функциональный интерфейс!
Правильный ответ – A, тут фактически вообще нет паззлера. Но давайте сделаем функциональный интерфейс made in china.
@FunctionalInterface
public interface CopyCatPredicate {
<T> boolean test(T t);
}
CopyCatPredicate lambda = (Object obj) -> "adadas".equals(obj);
CopyCatPredicate methodRef = "adadas"::equals;
В чем отличие от предыдущего кода? Кроме adadas, мы перенесли generic из самого интерфейса в метод, и теперь у нас не класс generic, а метод generic. Можем ли мы создать функциональный интерфейс generic-методом?
A. Оба скомпилируются
B. Ламбда скомпилируется, ссылка на метод –нет
C. Ссылка на метод скомпилируется, лямбда –нет
D. Не функциональный интерфейс!
Правильный ответ – С. Вас предупреждали – метод лучше. Лямбда не может реализовывать generic метод. В Лямбде мы передаем параметр, мы должны указать ему тип. Даже если мы не укажем, он должен какой-то вывестись, но чтобы он вывелся, у нас должна быть generic-переменная. То есть там нужно сделать generic-лямбду, где-то в угловых скобочках написать T или не T (мы можем другую букву использовать). Но такого синтаксиса нет, не придумали и тогда решили, что, мол, давайте, ладно, но для Лямбды это работать не будет.
@FunctionalInterface
public interface CopyCatPredicate {
<T> boolean test(T t);
}
CopyCatPredicate lambda = (Object obj) -> "adadas".equals(obj);
А с метод-референсами все нормально, там такой проблемы не возникает. Поэтому опять же, если у нас что-то не получается, нужно дописать документацию.
Java-паззлер №9: на какую конференцию сходить?
Вы хотите сходить на конференцию, конференций много. Вы хотите их отфильтровать, загоняете в TreeSet
и, соответственно, распечатываете результат.
List<String> list = Stream.of("Joker", "DotNext", "HolyJS", "HolyJS",
"DotNext", "Joker").sequential()
.filter(new TreeSet<>()::add).collect(Collectors.toList());
System.out.println(list);
Что вы получите?
A. Отсортированные и отфильтрованные [DotNext, HolyJS, Joker]
B. Ровно то же, что было в начале [Joker, DotNext, HolyJS, HolyJS, DotNext, Joker]
C. В начальном порядке, но отфильтрованные [Joker, DotNext, HolyJS]
D. Отсортированные, но не отфильтрованные [DotNext, DotNext, HolyJS, HolyJS, Joker, Joker]
Правильный ответ – С. Фильтрация сработает потому что это метод reference
, и объект TreeSet
там будет один. Новички думают, что метод reference
и Лямбда – это почти одно и то же, но это не совсем одно и то же. Если бы мы написали Лямбду, новый TreeSet
создавался бы каждый раз, а так как это метод reference, он создается один раз перед тем, как мы эту всю фильтрацию делаем, и метод reference к нему привязывается. А ничего не отсортируется потому, что мы не используем то, что в TreeSet как результат, мы всего лишь используем метод add
как фильтр, который отвечает нам true или false (нужно выкидывать дубликаты или нет). По сути дела, можно было написать просто distinct, и было бы то же самое. Результат этого трисета потом соберется GarbageCollector
'ом, и никто не знает, что там будет.
Выводы
Java становится все лучше, и способов выстрелить себе в ногу становится намного больше. Поэтому вот парочка советов:
- Пишите читаемый код.
- Комментируйте все трюки, если не можете удержаться.
- Иногда даже в Java бывают баги, которые ставят вас в тупик.
- Статические анализаторы кода рулят! Используйте IntelliJ IDEA.
- Поскольку все баги чинятся добавлением строчек в документацию, документацию надо знать.
- Не болейте стримозом. Кстати, в самой новой IDEA вы можете превратить стрим в цикл, если он вам надоел.
Если вы наткнулись на паззлер, присылайте его на puzzlers@jfrog.com, с удовольствием проведем третий сезон на одной из следующих конференций. В обмен на ценный экземпляр мы вышлем вам фирменную футболочку.
Если вы любите смаковать все детали разработки на Java так же, как и мы, наверняка вам будут интересны вот эти доклады на нашей апрельской конференции JPoint 2018:
- Linux container performance tools for JVM applications (Sasha Goldshtein, Sela Group)
- Анализ программ: как понять, что ты хороший программист (Алексей Кудрявцев, JetBrains)
- Типовые проблемы разработки ПО в больших проектах (Рустам Мехмандаров, Computas AS)
Автор: olegchir