Java Puzzlers NG S02: всё чудесатее и чудесатее

Тагир Валеев (lany) и Барух Садогурский (jbaruch) собрали новую коллекцию Java-паззлеров и спешат ими поделиться.


В основе статьи — расшифровка их выступления на осенней конференции JPoint 2017. Она показывает, сколько загадок таит в себе Java 8 и едва замаячившая на горизонте Java 9. Все эти стримы, лямбды, монады, Optional-ы и CompletableFuture-ы были добавлены туда исключительно для того, чтобы нас запутать.

Все, о чем они рассказывают, должно работать на последней версии Java 8 и 9, соответственно. Мы проверили — вроде все по-честному: как написано, так себя и ведет.

На всякий случай пара слов об авторах, хотя мы думаем, вы их и так уже хорошо знаете: Барух занимается Developer Relations в компании JFrog, Тагир — разработчик IntelliJ IDEA и автор библиотеки StreamEx.

Java-паззлер №1: как прохакать банк


Для разминки задаем первый паззлер: неизвестные американские хакеры пытаются прохакать банк.

goi0wvruyyrywu9whmoofrgwlma.png

Мы тут в общем-то маппируем банковскую логику на 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.

735a8ddaa0290b5d403e82f587278dc2.png

Сколько у нас доступно, когда у нас есть -42 пермита? У нас доступно 0, поэтому Сергей Курсенко открыл багу и сказал: ребята, что-то у вас какая-то ерунда. Drain available permits при -42 должен оставлять -42, потому что available permits 0. Когда мы сольем 0 из -42, будет -42.

a6a30167c0cbccaaa9aa2df65fe72b95.png

Но не тут-то было, потому что в комменты пришел Даг Ли и сказал: «Я так хочу! И поэтому я «пофиксю это», добавив строчку в Javadoc».

3aa54b3f65cefa414e2baa478fba306b.png

Java-паззлер №2: синглтоны


Пойдем дальше. Маленький совет от нас: не создавайте синглтоны, лучше пейте синглтоны.

990e64002275eadfeb6cb66e1921b4f1.png

Давайте посмотрим 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.

87da7e510af8e47b472cd27914083a85.png

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: мы что-то пишем, и у нас что-то выполняется, а мы не знаем, что.

fcfa156303d8cd43e2d6c5917312ed95.png

Кстати, мы можем вылететь на OutOfMemory благодаря этому. Если бы мы создали массив на 100 тысяч, у нас были бы списки, в которых внутри были бы предопределенные массивы тоже на 100 тысяч.

Java-паззлер №4: Single Abstract Method


Давайте попробуем создать функциональный интерфейс. Сначала создадим просто интерфейс, но засунем в него четыре метода, три из них будут абстрактные. А потом от него наследуем другой интерфейс и сделаем его функциональным. Скомпилируется ли?

public interface Сэм {
       default void расширятьНато(String новаяСтрана) {
                  System.out.println(новаяСтрана); }
   	void расширятьНато(T songName);
   	void захватыватьНефть(T новаяСтрана);
   	void захватыватьНефть(String новаяСтрана);
}
@FunctionalInterface
public interface ДядяСэм extends Сэм { }


Вот варианты ответов:

A. Что за ерунда?! «Single» означает один метод, не три!
B. Проблема с методом расширятьНато(T), если его убрать, все ОК
C. Проблема с методами захватыватьНефть, если убрать один, то все
будет ОК.
D. Все путем! Дубликаты схлопываются, и мы остаемся с одним захватыватьНефть.

Голосование в аудитории показало, что большинство — за вариант D, а правильный ответ — B. Дело в том, что метод, который не реализован (расширятьНато), не перекрывается дефолтной реализацией, и компилятор не может решить, что использовать. Это написано в документации: вы можете унаследовать из интерфейса несколько абстрактных методов с override-equivalent signatures. Когда мы определили, что Т — это стринг, два метода схлопнулись, это хорошо. Но если интерфейс наследует дефолтный метод, и его сигнатура override-equivalent абстрактному методу, это ошибка компиляции потому что возникает неоднозначность: хотим мы использовать дефолтную реализацию или нет.

5e91345122a03acbfccae044f94101e3.png
Скриншот из Java Language Specification

Java-паззлер №5: Как хакнуть банк. Вторая версия


Весь банковский софт написан на Java. Альфа-банк — это Java, Deutsche Bank — это Java, Сбербанк — это Java. Все банки пишут на Java, значит, атака на Java, в ней много дыр, ее легко хакнуть, потом найти самые крупные счета и снять с них деньги.

Set accounts =
       	new HashSet<>(Arrays.asList("Gates", "Buffett", "Bezos", "Zuckerberg"));
System.out.println("accounts= " + accounts);


Давайте соберем их в сет и распечатаем. Интересно, мы увидим их в том же самом порядке, в котором мы их завели?

A. Порядок объявления сохраняется
B. Порядок неизвестен, но сохраняется между запусками
C. Порядок неизвестен и меняется при каждом перезапуске JVM
D. Порядок неизвестен и меняется при каждой распечатке

Голосование в аудитории показало, что большинство — за вариант B, и это правильный ответ. Все прекрасно знают, что внутри хешсета — хешмеп, а в хешмепе — ключи.

ff93a65ea542794bd9a7539d758676fc.png

С этим надо что-то делать! Поэтому мы переходим на Early Access Release Java 9 (банки всегда так делают, они используют все самое свежее). И тут все становится интереснее, так как тут появился метод Set.of, благодаря чему вместо всего этого длинного можно написать коротко.

Set accounts = Set.of("Gates", "Buffett", "Bezos", "Zuckerberg");
System.out.println("accounts= " + accounts);


Вопрос остается таким же: если собрать в сет и распечатать, мы увидим счета в том же самом порядке, в котором мы их завели?

A. Порядок объявления сохраняется
B. Порядок неизвестен, но сохраняется между запусками
C. Порядок неизвестен и меняется при каждом перезапуске JVM
D. Порядок неизвестен и меняется при каждой распечатке

Голосование в аудитории показало, что большинство — за вариант С, и это правильный ответ. У нас есть доказательство. Мы можем воспользоваться новой замечательной штукой, которая появилась в девятой Java и называется JShell. Мы засовываем этот код, получаем какой-то порядок, повторяем, получаем другой порядок, повторяем, получаем третий порядок. Как это работает?

fadfa1c1fca4999b3028a82320da03f4.png

Это сделано специально. Вот таким образом вычисляется элемент таблицы по хеш-коду в этом самом  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 expendables) {
   	Iterator 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(Arrays.asList("N","S","W","S","L","S","L","V")));
[]<
/source>
Если мы сюда подадим LinkedList, то мы получим, что четные умерли

killThemAll(new LinkedList(Arrays.asList("N","S","W","S","L","S","L","V")));
[S,S,S,V]


Если мы сюда подадим ArrayDeque, то все останутся живы, и никаких исключений.

killThemAll(new ArrayDeque(Arrays.asList("N","S","W","S","L","S","L","V")));
[N,S,W,S,L,S,L,V]


А если мы сюда подадим TreeSet, то, наоборот, умрут нечетные.

killThemAll(new TreeSet(Arrays.asList("N","S","W","S","L","S","L","V")));
[N,W,L,L]


Поэтому никогда! Никогда так не делайте! На самом деле это получилось случайно — просто потому, что никто не думал, что кто-то так будет делать. Когда мы сообщили об этом в Oracle, они сделали что? Правильно, «пофиксили эту проблему», написав об этом в документации:

0e5c981a95b6bc138e5c511e9ad82514.png

Java-паззлер №8: Незаметная разница


Хотим создать оригинальный, настоящий Adidas в виде предиката, который будет проверять, что это действительно Adidas. Мы создаем функциональный интерфейс, параметризуем его каким-то типом T и, соответственно, реализуем его в виде Лямбды или в виде methodRef:

@FunctionalInterface
public interface OriginalPredicate {
    	boolean test(T t);
}
OriginalPredicate lambda = (Object obj) -> "adidas".equals(obj);
OriginalPredicate methodRef = "adidas"::equals;



Вопрос: скомпилируется это все или нет?

A. Оба скомпилируются
B. Ламбда скомпилируется, ссылка на метод — нет
C. Ссылка на метод скомпилируется, лямбда — нет
D. Не функциональный интерфейс!

Правильный ответ — A, тут фактически вообще нет паззлера. Но давайте сделаем функциональный интерфейс made in china.

@FunctionalInterface
public interface CopyCatPredicate {
   	 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 {
   	 boolean test(T t);
}
CopyCatPredicate lambda = (Object obj) -> "adadas".equals(obj);


А с метод-референсами все нормально, там такой проблемы не возникает. Поэтому опять же, если у нас что-то не получается, нужно дописать документацию.

605a5b2437ff8fd46e3ee3ad0315275c.png

Java-паззлер №9: на какую конференцию сходить?


Вы хотите сходить на конференцию, конференций много. Вы хотите их отфильтровать, загоняете в TreeSet и, соответственно, распечатываете результат.

List 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'ом, и никто не знает, что там будет.

a0263ca37eb84b7762351d18b3c14aec.png

Выводы


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)

© Habrahabr.ru