Тагир Валеев (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:

Комментарии (5)


  1. Anton23
    30.03.2018 19:31
    +1

    едва замаячившая на горизонте Java 9
    — разве не 10ая java маячит?


    1. Borz
      30.03.2018 19:45
      +1

      это расшифровка апрельского доклада 2017 года — тогда 9 маячила


  1. GamaleyVV
    30.03.2018 22:35

    Больше всего нравится методика фиксов от Oracle. Можно писать абсолютно непредсказуемый код, но описать его непредсказуемость в документации… :)


    1. olegchir Автор
      31.03.2018 11:12

      Ммм… приведи пример какого-нибудь предсказуемого языка! :-)


      1. kalininmr
        31.03.2018 16:42

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