Java. Решение практических задач
Книга Анджела Леонарда позиционируется как каталог типовых решений для Java разработчиков младшего и среднего уровней. Заявляется, что представленные решения производительны, корректны и поддерживаемы.
В книге все разбито на «задачи». Они тут нескольких типов:
Алгоритмические задачи — 10%
Структуры и алгоритмы — 5%
Теория языка Java — 10%
Стандартная библиотека Java — 50%
Практические приёмы — 25%
Алгоритмические задачи здесь начального уровня. Их мало. Но самое интересное — это предлагаемые решения. Автор пишет, что книга поможет укладываться в сроки. В этом она похожа на рецепты со https://stackoverflow.com/ с ровно тем же результатом: работать может и будет, но в большинстве случаев так делать не стоит. Нет, я, конечно, больше за экономическую целесообразность и согласен с цитатой:
Преждевременная оптимизация — корень всех зол.
— Д.Э. Кнут
Но приводить настолько неэффективные решения в качестве эталонных стоило бы постесняться. Можно говорить, что такой подход улучшает читаемость, а значит и сопровождаемость, но ведь заявлено, что приведены
правильные практические приемы и технические решения в отношении сложности, производительности, удобочитаемости
По поводу удобочитаемости тоже можно было бы поспорить, но это, в конце концов, дело вкуса. Но вот неэффективность никуда не денешь. Например, в задаче отыскания самого часто встречающегося символа предлагается в карте символ→количество искать максимум через Collections.max()
для values()
, а потом ещё циклом пройтись по entrySet()
и поискать какой же getKey()
соответствует значению, которое вернула Collections.max()
.
Автор часто прибегает к регулярным выражениям там, где они совершенно излишни. И даже не предлагает их предварительно компилировать, а все время норовит вызвать replaceAll()
или split()
прямо внутри тела цикла. Опять же, можно было бы поспорить, что split()
оптимизирована для случая односимвольного разделителя и не будет для них применять регулярные выражения. Однако, автор про это ни разу не упоминает. Более того, автор регулярно напоминает использовать Pattern.quote()
при передаче параметра в split()
, а значит он подразумевает именно регулярные выражения. Но даже если опираться на оптимизацию split()
для односимвольных разделителей, то можно видеть, что внутри у нас ArrayList
из которого данные копируются в новый массив, а автор после чуть ли не каждого split()
предлагает конвертировать массив в список через Arrays.asList()
. Копирования и мусор в куче, который надо будет собирать, автор просто игнорирует. StringTokenizer
, по утверждению автора, обладает меньшей производительностью и из-за этого его не рекомендуется использовать. Нет, StringTokenizer
, рассчитанный на Unicode code pointы и несколько разделителей, действительно медленнее в плане обработки строки по сравнению со split()
при односимвольном разделителе (за вычетом дополнительного мусора и копирований). Но автор в своей сноске явным образом говорит про гибкость регулярных выражений. Да, в Javadoc на StringTokenizer
есть рекомендация по использованию split()
, но там нет ничего про производительность. На самом деле речь идет о том, что StringTokenizer
— это Enumeration
, а не Collection
и значит придётся писать явный цикл для обхода, копирования и тому подобного. Не более.
StringTokenizer tokenizer = new StringTokenizer("abc, def, xyz", ";, ");
List tokens = new ArrayList<>(tokenizer.countTokens());
while (tokenizer.hasMoreTokens()) {
tokens.add(tokenizer.nextToken());
}
Ну или тупо Collections.list()
, если лень или повторное сканирование перевешивает динамический рост списка. Если хочется красоты и однострочности, то начиная с Java 9 можно сделать .asIterator().forEachRemaining()
и оно ничем не будет отличаться от цикла for-each.
new StringTokenizer("abc, def, xyz", ";, ")
.asIterator().forEachRemaining(System.out::println);
Вообще автор везде сует потоки с цепочками вызовов заканчивающимися collect(Collectors.toList())
и ни разу не упоминает, что список будет динамически расти, что данные будут копироваться, что мусор потом придется собирать.
Можно опять поспорить, что это копейки и на сложность алгоритма в целом не влияет. Плюс/минус 5–10% (кто же будет профилировать?) — не высокая цена за скорость разработки. Пусть так. Соглашусь. Часто важнее скорость разработки, а не скорость работы. Но тут же можно найти рецепт как скопировать объект через JSON:
Берем исходный объект;
Сериализуем его в JSON;
Парсим получившийся текст;
Копия объекта готова!
И это не прикол, а целая «задача» с детальным описанием рецепта и примером кода. Бери и копипасти. И ни слова о том, на сколько это будет быстро.
Часто говорят, что Java — это тормоза и излишнее потребление памяти. Есть мнение, что проблема не в Java, а в том как на Java пишут. Так вот автор не сильно старался сделать решения эффективными по времени или потреблению памяти.
Про структуры и алгоритмы в книге откровенно мало. В стандартной библиотеке конечно много всего готового и свое реализовывать надо не так уж и часто. Но можно было бы рассказать про варианты представления деревьев и графов с использованием инструментария Java. Дерево Фенвика и фильтр Блума здесь рассмотрены чисто для галочки.
Много «задач», в которых рассказывается о самом языке Java. В основном речь идет о нововведениях после Java 8:
Новые выражения
switch
;Автоматический тип локальных переменных и
var
;Функциональные интерфейсы и лямбда-выражения.
С одной стороны это все полезно. Даже не новичку. Особенно когда подается в виде практических рецептов. Но есть проблемы. Например, автор утверждает, что автоматические типы улучшают сопровождаемость кода. Приводится пример, когда локальная переменная — результат возврата функции, функцию позже поменяли и автоматическая переменная волшебным образом изменила свой тип. В коде ничего менять не надо и все счастливы. Другой пример — использование автоматического типа для «исправления» непонимания программистом того, какой же тип вернет тернарный оператор. Ну или предложение прятать тип элемента в цикле for-each. Я бы еще мог понять, если бы речь шла о длинных вложенных обобщенных типах:
Map headers = new HashMap<>();
for (Map.Entry entry : headers.entrySet()) {
}
Тогда хоть какой-то смысл появляется:
Map headers = new HashMap<>();
for (var entry : headers.entrySet()) {
}
Но автор говорит о другом. О том, что можно менять тип элементов коллекции (с int
на String
) и не заморачиваться изменением кода. Сразу вспоминаешь, что Groovy ругают за def
.
Также сомнительны рецепты с заменой классических шаблонов на лямбды. Смысл ведь был в инкапсуляции и полиморфизме, а тут все кишки наружу. Нет, кодить конечно быстрее, но будет ли экономия, когда придётся выбросить и переписать с нуля?
Не могу согласиться с рецептами тестирования лямбд. Тестирование — это проверка соответствия контракту. Какой контракт у лямбда-выражения? Аналогично с отладкой путем System.out.println()
. Кто, как и когда будет всё это потом вычищать? А ведь будут последствия. Вместо навязчивой рекламы лямбда-выражений и рекомендаций по запихиванию их всегда и везде стоило бы описать где и когда их применять не стоит и как и на что их надо заменить. С примерами.
Стандартная библиотека — это основная часть книги. Масса рассказов, что есть вот такой-то класс или метод, полезный тем и этим. Много и более практических рецептов: если стоит такая-то задача, то решается она так-то. В принципе, все эти практические рецепты работы с датами, коллекциями, файлами, потоками, интроспекцией, многопоточностью, инструментами синхронизации, клиентом HTTP и WebSocket полезны. Особенно, если не штудировал Javadoc и не читал Core Java в двух томах.
Но в бочке мёда снова не обошлось без кастрюли дёгтя ибо есть масса неточностей и неверностей. Например, рекомендуется локальную дату конвертировать в UTC, используя нулевой сдвиг. Работать это будет только в городе Гринвич.
LocalDateTime local = LocalDateTime.now();
Instant suggested = local.toInstant(ZoneOffset.UTC);
Instant correct = local.atZone(ZoneId.systemDefault()).toInstant();
Другой пример, когда автор утверждает, что List.replaceAll()
и цикл с List.set(i,v)
«должны работать практически одинаково». Для ArrayList
это может и так, но есть и другие списки. Нет, работать LinkedList.set(i,v)
в цикле будет конечно корректно, но совсем не также как ListIterator.set(v)
, используемый в LinkedList.replaceAll()
. Автор на такие мелочи внимание не обращает.
Частенько автор противоречит Javadoc. Например, при описании ExecutorService.invokeAll()
написано:
первый вызов метода
Feature.get
блокирует до тех пор, пока не будут завершены все экземплярыFeature
.
То есть, как будто между разными Feature
есть какая-то связь и они друг друга ждут. На самом деле ничего такого не происходит, а блокируется не столько Feature.get()
, сколько ExecutorService.invokeAll
.
Ну или другой пример расхождения с официальной документацией — это секция про StampedLock
и оптимистическую блокировку. Весь смысл оптимистичных блокировок в отсутствии блокировок. В данном случае, если очень много читателей, то писатель может ждать слишком долго, особенно для non-fair случая по умолчанию. Так вот StampedLock
как раз и позволяет избежать блокировки писателя. Правильный пример кода можно найти в официальной документации:
double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead();
try {
retryHoldingLock: for (;; stamp = sl.readLock()) {
if (stamp == 0L)
continue retryHoldingLock;
// possibly racy reads
double currentX = x;
double currentY = y;
if (!sl.validate(stamp))
continue retryHoldingLock;
return Math.hypot(currentX, currentY);
}
} finally {
if (StampedLock.isReadLockStamp(stamp))
sl.unlockRead(stamp);
}
}
Перед возвращением (фиксацией результата) мы проверяем не было ли значение изменено. При необходимости перечитываем и заново вычисляем. Мы стараемся, по возможности, избежать readLock()
, которая заблокирует писателей.
А что предлагает автор? Автор предлагает делать validate()
сразу за tryOptimisticRead()
и ничего не делать, если validate()
скажет, что записи не было (между tryOptimisticRead()
и validate()
). Налицо непонимание того, как работает StampedLock
и нежелание разобраться в примере из библиотечной документации.
Практические приёмы — это всякие полезные практические хитрости, которые напрямую не указаны в документации, ну или просто указание на неприметный, но полезный класс в стандартной библиотеке:
Преобразование 1000 в 1K и тому подобное.
Комбинирование
Stream.reduce()
,Function.identity()
иFunction.andThen()
на списке функций для вызова их цепочкой.Генераторы потоков.
Сильно портит книгу перевод:
Масса опечаток.
Для многих терминов используется транслитерация вместо перевода («состояние терминации», «дерегистрированных участников», «конкурентно запустить», «с несколькими имплементациями этого интерфейс»).
И наоборот, некоторые термины переведены крайне странно («обход должен быть жестоко терминирован», «альтернатива подклассированию», «стековая трасса сбоя», «замок на уровне объекта», «будет уменьшать стопор», «семафор может начинаться и умирать», «продвинуть исключение», «приращение», «атомарно наращивает переменную»).
Ближе к концу книги, переводчик перестал заморачиваться и так и оставил машинный перевод с тут и там несогласованными окончаниями.
Орфография — это неприятно, но терпимо. Но терминология — это серьезней. Часть просто неприятно читать. Например, все эти многонитиевости. Причем, переводчик не поленился и расписал целую секцию почему именно нити, хотя редактор в сноске указала, что потоки. Но или уговорить не удалось, или (что видно по числу опечаток) ограничилась первым десятком страниц.
Вот научится человек стопорам, тупикам и запираниям на замок, и попробуй, пойми потом, что речь о CountDownLatch
, deadlock и acquire lock (затвор, взаимная блокировка и получение блокировки в терминах той же Core Java).
В главе про шаблоны используется термин трафаретный метод. При этом, в тексте даже есть ссылка на GoF. То есть переводчик, даже если он не специалист в данной предметной области, мог просто подглядеть. А ведь шаблоны — это ещё и язык, который призван помочь в объяснении того, как система устроена или должна быть устроена.
Есть места, где корявый перевод меняет смысл написанного. Например:
Блокирующая нить исполнения обычно находится в состоянии
BLOCKED
…
Неопытный разработчик может и не понять, что речь о том потоке, который был заблокирован, а не о том, который блокирует.
Резюмируя, можно сказать, что книга крайне неоднозначна. Опытный разработчик может найти что-то новое, если не следил за эволюцией. Может освежить память о том, что давно не использовал. Или даже может найти пару рецептов, о которых ранее не знал. Не очень опытный разработчик может узнать очень много. Это даже может быть лично ему полезно, чтобы «укладываться в сроки». Также как может быть полезен https://stackoverflow.com/, чтобы копипастить оттуда первый попавшийся рецепт. Если новичок «вырастит» на этой книге, то велик шанс того, что его работу придется переделывать. А ведь он ещё и может ссылаться на данную книгу в обоснование своей точки зрения. Тогда и взаимопонимание может быть затруднено. Хоть автор и позиционирует книгу для начального и среднего уровней, я бы рекомендовал её (и то не сильно) только сложившимся разработчикам. Прочитавший же её новичок будет просто опасен.