Java Style Guide
Java Style Guide команды: 47 правил JS-N.M про инструментарий, дизайн, исключения, запреты, и 90 рецептов Effective Java как теоретический референс. Коды для AI-ревью.
Операционный свод правил Java-кода нашей команды. Чисто-форматирование (отступы, переносы, выравнивание, порядок модификаторов, naming-механика, wildcard imports, C-стиль массивов) делают IntelliJ formatter и Checkstyle — здесь только то, что требует решения человека или AI-ревьюера.
Базовый принцип (JS-1.1): любое нарушение руководства допустимо, если оно улучшает читаемость. От автора ожидается явное объяснение, чем нарушение лучше — в коде или в описании PR.
Каждое правило имеет код (JS-2.5, JS-4.7, …) — на эти коды ссылается AI-скилл ucp-java-style-review в findings.
В конце документа — раздел Effective Java с 90 рецептами Bloch (коды EJ-N-M), на которые ссылаются JS-правила выше для теоретического обоснования.
1. Общие рекомендации
JS-1.1 Любое нарушение допустимо, если оно улучшает читаемость
Это не индульгенция «писать как хочется». От автора ожидается явное объяснение, чем именно нарушение лучше — в коде или в описании PR. Цель руководства — улучшить читаемость, понимание и общее качество кода, а не превратить ревью в формальную проверку.
2. Именование
JS-2.2 Без множественного числа в пакетах
Следуем java.util (не java.utils).
package com.example.util; // PREFER
package com.example.utils; // AVOID
JS-2.3 Имена классов — существительные
class Car {} // PREFER
class Running {} // AVOID
JS-2.4 Имена интерфейсов — существительные или прилагательные на -able
Не начинаем с I — это привычка из C# / .NET.
interface Runnable {} // PREFER
interface Comparable {} // PREFER
interface IEnumerable {} // AVOID
JS-2.5 Аббревиатуры
Не должно быть нескольких заглавных подряд. Лучше — отказаться от аббревиатур.
- 2 буквы — обе CAPS.
- 3+ букв — только первая буква CAPS (цифры не считаются).
class IOStream {} // PREFER (2 буквы)
class IoStream {} // AVOID
class XmlParser {} // PREFER (3 буквы)
class XMLParser {} // AVOID
class Pk2dfCertificate {} // PREFER
class C2CTariff {} // PREFER (C2C — 2 «буквы», цифра не считается)
class C2сTariff {} // AVOID
JS-2.6 Имена методов — глаголы или описание действия
public String getName() {} // PREFER
public String name() {} // AVOID
public void expand() {} // PREFER
public boolean expanding() {} // AVOID
JS-2.6.1 Имена тестов
Имя теста должно отражать суть тест-кейса. Два допустимых подхода:
// PREFER — длинное говорящее имя метода
@Test
public void shouldReturnNullIfResponseEmptyArray() {}
// PREFER — короткое имя + @DisplayName
@Test
@DisplayName("should return null if response is empty array")
public void someShorterName() {}
// AVOID — snake_case
public void should_Return_Null_If_Response_Empty_Array() {}
// AVOID — слишком длинное имя без @DisplayName
public void shouldReturnNullIfResponseEmptyArrayOrExternalSystemIsDownAndStuff() {}
3. Выражения и поток управления
JS-3.1 Method reference вместо лямбды, где это имеет смысл
filter(someStrings::contains); // PREFER
filter(s -> someStrings.contains(s)); // AVOID
JS-3.2 Большие лямбды — в named methods
Если в лямбде логика «больше одного выражения», вынеси её в отдельный метод и передавай method reference.
JS-3.3 Guard expression вместо вложенных условий
public void someMethod() { // PREFER
if (!condition) {
throw e;
}
doSomething();
}
public void someMethod() { // AVOID
if (condition) {
doSomething();
} else {
throw e;
}
}
4. Инструменты (обязательно)
Lombok, MapStruct, Bean Validation — обязательный инструментарий, без них код не проходит ревью. Они снимают boilerplate и закрывают целые классы багов из Effective Java одной аннотацией.
JS-4.1 @Value для immutable классов
DTO, value objects, события домена — @Value. Одна аннотация даёт final класс, private final поля, getter, equals + hashCode + toString, all-args constructor. Ручная реализация immutable-класса — устаревшая идиома. См. EJ-4-3 про immutability как умолчание.
@Value // PREFER
public class Money {
Currency currency;
BigDecimal amount;
}
public final class Money { // AVOID — boilerplate
private final Currency currency;
private final BigDecimal amount;
// + конструктор, getters, equals, hashCode, toString
}
JS-4.2 @Builder для классов с 4+ параметрами или необязательными полями
Длинный конструктор нечитаем и хрупок к перестановке аргументов. @Builder даёт именованные параметры. Для иерархий с наследованием — @SuperBuilder. См. EJ-2-2.
Order order = Order.builder() // PREFER
.customerId(customerId)
.items(items)
.deliveryAddress(address)
.priority(HIGH)
.build();
Order order = new Order(customerId, items, address, HIGH, null, false); // AVOID
JS-4.3 @EqualsAndHashCode обязателен парой
Никогда отдельно — equals без hashCode ломает HashMap/HashSet. Для DDD-агрегатов сравнение по идентификатору: @EqualsAndHashCode(onlyExplicitlyIncluded = true) + @EqualsAndHashCode.Include на поле ID. См. EJ-3-1, EJ-3-2.
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
public class Order {
@EqualsAndHashCode.Include
private OrderId id;
private List<Item> items; // не входит в equals
}
JS-4.4 @ToString с @ToString.Exclude на чувствительных полях
PII, секреты, токены, пароли — @ToString.Exclude. Лог-инциденты от случайной печати пароля в toString — реальная угроза, не теоретическая.
@ToString
public class User {
private String email;
@ToString.Exclude
private String passwordHash;
@ToString.Exclude
private String apiToken;
}
JS-4.5 @UtilityClass для классов из одних статических методов
Делает класс final, добавляет private constructor с UnsupportedOperationException, помечает поля и методы static. Ручная идиома (private конструктор + AssertionError) допустима только для устаревших классов без Lombok.
@UtilityClass // PREFER
public class StringUtils {
public String capitalize(String input) { ... }
}
JS-4.6 @NonNull на параметрах не-public методов
Lombok генерирует Objects.requireNonNull в начале метода — fail-fast вместо отложенного NPE. Для public API контракта — Bean Validation (@NotNull), не @NonNull.
void updateName(@NonNull String newName) { // PREFER (внутренний метод)
this.name = newName;
}
JS-4.7 Constructor injection без @Autowired
@RequiredArgsConstructor + private final поля. Spring с Java 8+ резолвит DI без аннотации, если конструктор один. Полевая инъекция (@Autowired на поле) запрещена — ломает иммутабельность и тестируемость. См. EJ-2-5.
@Service
@RequiredArgsConstructor // PREFER
public class OrderService {
private final OrderRepository repository;
private final PaymentClient payments;
}
JS-4.8 MapStruct для маппинга между слоями
DTO ↔ entity ↔ domain — только MapStruct-mapper. Никакого ручного new TargetClass(source.getX(), source.getY(), ...). Маппер компилируется в чистый Java-код, типобезопасен, легко тестируем.
@Mapper(componentModel = "spring") // PREFER
public interface OrderMapper {
OrderDto toDto(Order order);
Order toDomain(OrderDto dto);
}
JS-4.9 Bean Validation на DTO + @Validated на контроллере
@NotNull, @Size, @Positive, @Pattern, @Email — на полях DTO. Не пишите if (x == null) throw ... в use case — это работа Bean Validation. См. Validation Style Guide.
public record CreateOrderRequest(
@NotNull @Positive Long customerId,
@NotEmpty @Size(max = 100) List<@Valid OrderItem> items,
@Email String contactEmail
) {}
5. Дизайн классов и методов
Правила, которые Lombok не автоматизирует — judgment-уровень про композицию, immutability, область видимости, сигнатуры. Это то, где AI-ревью и человек-ревьюер ловят design-проблемы.
JS-5.1 Композиция > наследование
Наследуйте только в sealed-иерархиях, шаблонном методе или абстрактных скелетных классах (AbstractList, AbstractMap). Иначе — wrapper-class через @Delegate Lombok или явная композиция полем. См. EJ-4-4.
public class TimedCache<K, V> { // PREFER — композиция
@Delegate
private final Map<K, V> delegate = new HashMap<>();
// + логика таймера
}
public class TimedCache<K, V> extends HashMap<K, V> { ... } // AVOID
JS-5.2 Immutable по умолчанию
DTO, value, события — @Value. Mutable только если профайлер показал GC pressure от копирования или нужен идентичный объект (entity с lifecycle). Сомневаетесь — immutable. См. EJ-4-3.
JS-5.3 Программируй к интерфейсу
Параметры, поля, возвращаемые типы — List<E>, Map<K,V>, Set<E>. Конкретные классы (ArrayList, HashMap) — только локальные переменные на месте создания. См. EJ-9-8.
public List<Order> findByStatus(Status s) { ... } // PREFER
public ArrayList<Order> findByStatus(Status s) { ... } // AVOID
JS-5.4 Минимум доступности
Package-private — первый шаг. public — только если действительно часть внешнего API. protected — только в каркасных классах для расширения. Чем уже видимость, тем меньше связность. См. EJ-4-1.
JS-5.5 Static nested > non-static по умолчанию
Non-static вложенный класс хранит ссылку на enclosing instance — лишняя память, утечка при сериализации, скрытая связность. Если ссылка не нужна — static. См. EJ-4-10.
public class Outer {
static class Inner { ... } // PREFER
class Inner { ... } // AVOID, если this не нужен
}
JS-5.6 Один top-level class на файл
Checkstyle ловит автоматически. Несколько публичных классов в одном файле — путаница в навигации и импортах.
JS-5.7 Optional<T> только для return-type
И только когда «нет результата» — частый и значимый исход (findById, parse). НЕ для полей, НЕ для параметров, НЕ для коллекций (используйте пустую коллекцию). См. EJ-8-7.
Optional<User> findByEmail(String email); // PREFER
void process(Optional<User> user); // AVOID — параметр
private Optional<String> name; // AVOID — поле
JS-5.8 Возвращайте пустую коллекцию, не null
Collections.emptyList(), List.of(), new String[0]. Клиент не должен помнить null-checks вокруг каждого вызова. См. EJ-8-6.
public List<Order> findRecent() {
if (cache.isEmpty()) {
return List.of(); // PREFER
}
// ...
}
JS-5.9 Не более 3-4 параметров метода
Больше — @Builder на параметрический объект (SearchCriteria, OrderRequest) или helper-класс. Длинный список параметров одного типа особенно опасен — легко перепутать порядок. См. EJ-8-3.
public List<Order> search(SearchCriteria criteria); // PREFER
public List<Order> search( // AVOID
String customer, Status status, LocalDate from,
LocalDate to, BigDecimal minAmount, BigDecimal maxAmount, int limit
);
6. Исключения
Исключения — для исключительных ситуаций, не для control flow. Граница слоя — место для translation, не для swallow.
JS-6.1 Стандартные исключения JDK > свои
IllegalArgumentException (плохой параметр), IllegalStateException (объект не в нужном состоянии), NullPointerException (null где не ждали), IndexOutOfBoundsException, UnsupportedOperationException. Свои — только для domain-specific случаев. См. EJ-10-4.
if (amount.signum() < 0) { // PREFER
throw new IllegalArgumentException("amount must be non-negative: " + amount);
}
JS-6.2 Checked exception — только при возможности recovery
Checked — когда у клиента есть meaningful recovery (повторить, fallback, спросить пользователя). Иначе — RuntimeException. Бессмысленный throws засоряет сигнатуры. См. EJ-10-2.
JS-6.3 Не игнорируйте exceptions
catch (X ignored) — только с комментарием почему игнорируется. Пустой catch без комментария — баг. См. EJ-10-9.
try { // PREFER
Files.delete(tmpFile);
} catch (IOException ignored) {
// временный файл удалится сборщиком ОС, потеря допустима
}
JS-6.4 Translate exceptions на границах слоёв
С chaining (new Wrapper(e)). IOException из persistence → RepositoryException(e) в use case. Низкоуровневые детали (SQLException, IOException) не должны просачиваться наверх. См. EJ-10-5.
try {
return jdbc.query(...);
} catch (DataAccessException e) {
throw new OrderLookupException("failed to load order " + id, e);
}
JS-6.5 Detail message — relevant variables
size=10, index=12, не "Произошла ошибка при обработке". Сообщение для логов и stacktrace, не для UI. Локализованные сообщения для пользователя — отдельный слой. См. EJ-10-7.
JS-6.6 Atomicity
Метод не оставляет объект в inconsistent state при failure. Способы: immutable (нечего ломать); проверка параметров до изменений; работа на копии с заменой в конце. См. EJ-10-8.
JS-6.7 Документируйте @throws в Javadoc
Каждый checked + значимые runtime. Это часть контракта метода — клиент должен знать, что ловить. См. EJ-10-6.
/**
* @throws IllegalArgumentException если amount отрицательный
* @throws InsufficientFundsException если на счёте меньше amount
*/
public void withdraw(BigDecimal amount) { ... }
7. Запрещено
То, что в нашей кодовой базе мы не пишем. Каждое из этих правил — следствие либо инструментария (Lombok закрывает), либо багов из практики, либо современный Java идиоматичнее старого подхода.
JS-7.1 Ручные equals / hashCode / toString
Только @EqualsAndHashCode / @ToString Lombok. Ручная реализация — источник тонких багов (забытое поле, несимметричный equals, hashCode не согласован с equals).
JS-7.2 clone() / implements Cloneable
Copy constructor Foo(Foo source) или MapStruct-mapper. Cloneable — сломанный по дизайну механизм с непредсказуемой семантикой. См. EJ-3-4.
JS-7.3 finalize() / Cleaner как primary cleanup
Только try-с-ресурсами + AutoCloseable. finalize() deprecated с Java 9, Cleaner — safety net, не основной механизм. См. EJ-2-8, EJ-2-9.
try (var conn = dataSource.getConnection()) { // PREFER
// ...
}
JS-7.4 Raw types
List без параметра типа — потеря type safety, warnings компилятора. Если параметр не важен — List<?>. См. EJ-5-1.
List<String> names = new ArrayList<>(); // PREFER
List<?> anything = service.fetch(); // PREFER (если тип не важен)
List names = new ArrayList(); // AVOID
JS-7.5 Object.wait() / notify() / notifyAll()
Только java.util.concurrent — CountDownLatch, BlockingQueue, Semaphore, CompletableFuture. Нативные методы Object для синхронизации — ловушка для условий гонки. См. EJ-11-4.
JS-7.6 new Thread(...) напрямую
Только ExecutorService — обычно Executors.newFixedThreadPool(N), ForkJoinPool.commonPool(), или Executors.newVirtualThreadPerTaskExecutor() для I/O-bound. См. EJ-11-3.
ExecutorService executor = // PREFER
Executors.newVirtualThreadPerTaskExecutor();
new Thread(() -> doWork()).start(); // AVOID
JS-7.7 java.io.Serializable для wire-формата
JSON (Jackson), Protobuf, Avro для inter-process данных. Serializable — только если действительно нужно (RMI, кэши JVM-уровня), и тогда с осторожностью. См. EJ-12-2.
JS-7.8 String для типизированных значений
Перечисление → enum. Дата → java.time.LocalDate / Instant. Деньги → BigDecimal или long копеек. ID → именованный value-object (UserId, OrderId). См. EJ-9-6.
public void process(OrderId orderId, Status status) { ... } // PREFER
public void process(String orderId, String status) { ... } // AVOID
JS-7.9 Boxed primitives в hot-path и счётчиках циклов
Long, Integer, Double в счётчиках циклов и hot-path — autoboxing создаёт объекты, GC pressure. Для коллекций и generics неизбежно, но осознанно. См. EJ-9-5.
long sum = 0L; // PREFER
for (int i = 0; i < n; i++) sum += data[i];
Long sum = 0L; // AVOID — n autoboxing операций
for (Integer i = 0; i < n; i++) sum += data[i];
JS-7.10 float / double для денег
BigDecimal (с MathContext или явным setScale + RoundingMode) или long копеек. Бинарная плавающая точка не представляет десятичные дроби точно. См. EJ-9-4.
BigDecimal total = price.multiply(BigDecimal.valueOf(qty)) // PREFER
.setScale(2, RoundingMode.HALF_UP);
double total = price * qty; // AVOID
JS-7.11 Public mutable static state
Global mutable = тестовый ад + race conditions. Если общий доступ нужен — Spring bean (DI) или явный singleton с immutable интерфейсом.
public class Config {
public static String dbUrl; // AVOID
}
@Component // PREFER
@ConfigurationProperties("app")
public class AppConfig { ... }
JS-7.12 null как значимое возвращаемое значение
Optional<T> для «может не быть», пустая коллекция для «список нулевой длины». null остаётся только для устаревших API (Map.get), и там сразу заворачивается в Optional.ofNullable.
Optional<User> findByEmail(String email); // PREFER
User findByEmail(String email); // returns null if not found // AVOID
Effective Java (теоретический референс)
Сжатый свод 90 статей Джошуа Блоха по 3-му изданию (2018, рус. перевод «Диалектика», 2019). Кодами EJ-N-M JS-правила выше ссылаются сюда — для теоретического обоснования.
В нашей кодовой базе серия — справочник, не операционная доктрина. Команда использует Lombok (
@Builder,@Value,@EqualsAndHashCode,@ToString,@UtilityClass,@NonNull) и MapStruct. Где Bloch предписывает ручную идиому — рецепт здесь как объяснение почему аннотация делает именно так. Конкретные замены отмечены инлайн пометкой «В нашей кодовой базе» в нужных рецептах.
Гл 2. Создание и уничтожение объектов
Девять рецептов про то, как создавать объекты в Java и как обеспечивать их корректное уничтожение. Статические фабрики, Builder, синглтон-идиомы, утилитарные классы, внедрение зависимостей, переиспользование, утечки памяти, очистители, try-с-ресурсами.
Каждое правило имеет код вида EJ-N-M (EJ-2-1 = глава 2, статья 1) — на эти коды ссылается AI-скилл ucp-effective-java-review в findings.
Источник: «Effective Java», Джошуа Блох, 3-е издание (2018, рус. перевод «Диалектика», 2019). Каждый рецепт — сжатая выжимка соответствующей статьи Bloch, не пересказ всей главы.
EJ-2-1 Рассмотрите применение статических фабричных методов вместо конструкторов
Статический метод, возвращающий экземпляр класса. Имеет осмысленное имя, не обязан создавать новый объект (может вернуть кэшированный), может вернуть подтип объявленного типа возврата, и сам объявленный тип может вообще не существовать на момент написания клиента.
// PREFER — имя выражает намерение, переиспользует TRUE/FALSE
public static Boolean valueOf(boolean b) {
return b ? Boolean.TRUE : Boolean.FALSE;
}
// AVOID — каждый вызов создаёт новый объект, имя не информативно
new Boolean(true);
Когда применять: когда у конструктора неочевидная семантика (несколько разных способов создать объект из одной сигнатуры); когда нужно кэширование экземпляров (Flyweight); когда возвращаемый тип — интерфейс, а реализация подбирается динамически (EnumSet.noneOf отдаёт RegularEnumSet или JumboEnumSet в зависимости от размера); в основе фреймворков провайдеров служб (JDBC).
Tradeoff: класс без public/protected конструктора нельзя наследовать (но это поощряет композицию). Статические фабрики не выделены в Javadoc как конструкторы — придерживайтесь стандартных имён: from, of, valueOf, getInstance, newInstance, getType, newType.
Java 8+: статические методы теперь можно держать прямо в интерфейсе — фабрики, возвращающие интерфейс, помещают в сам интерфейс (List.of(...), Stream.of(...)).
EJ-2-2 При большом количестве параметров конструктора подумайте о шаблоне Строитель
Телескопические конструкторы (4+ параметра, часть из которых необязательны) сложно писать и ещё сложнее читать. JavaBean-сеттеры допускают объект в несогласованном состоянии и исключают неизменяемость. Builder совмещает безопасность телескопа с читаемостью JavaBean: статический вложенный класс накапливает параметры fluent-стилем, а build() возвращает неизменяемый объект и проверяет инварианты.
// PREFER — Builder
public class NutritionFacts {
private final int servingSize;
private final int servings;
private final int calories;
private final int fat;
public static class Builder {
// обязательные параметры
private final int servingSize;
private final int servings;
// необязательные — со значениями по умолчанию
private int calories = 0;
private int fat = 0;
public Builder(int servingSize, int servings) {
this.servingSize = servingSize;
this.servings = servings;
}
public Builder calories(int val) { calories = val; return this; }
public Builder fat(int val) { fat = val; return this; }
public NutritionFacts build() { return new NutritionFacts(this); }
}
private NutritionFacts(Builder b) {
servingSize = b.servingSize;
servings = b.servings;
calories = b.calories;
fat = b.fat;
}
}
// Использование — имитирует именованные параметры
NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
.calories(100).fat(0).build();
// AVOID — телескопический конструктор: что есть что?
NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0, 35, 27);
Когда применять: конструктор с 4+ параметрами, особенно при большой доле необязательных; когда нужна валидация инвариантов в build() (бросать IllegalArgumentException при нарушении); для иерархий классов — рекурсивный generic Builder с protected abstract T self() и ковариантным build() в подклассе.
Tradeoff: Builder многословнее обычного конструктора и его нужно поддерживать параллельно с классом. Для 2–3 параметров не оправдан. Стоимость создания самого Builder обычно незаметна, но в критичных сценариях стоит замерить.
В нашей кодовой базе: Lombok @Builder для обычных классов и @SuperBuilder для иерархий. Ручная реализация Builder, описанная здесь, остаётся как объяснение, что именно генерирует аннотация (вложенный класс, fluent-сеттеры, единый build()). Bean Validation (@NotNull, @Positive) проверяет инварианты вместо проверок в build().
EJ-2-3 Получайте синглтон с помощью закрытого конструктора или типа перечисления
Три способа реализации синглтона: public static final-поле + private constructor; статический фабричный метод (getInstance()); enum с единственным элементом. Перечисление с одним элементом — лучший способ: бесплатно даёт сериализацию, защиту от рефлексивных атак и от дублирования при десериализации.
// PREFER — enum-синглтон, лучший подход в Java
public enum Elvis {
INSTANCE;
public void leaveTheBuilding() { ... }
}
// OK — public final-поле; для сериализации требует transient + readResolve
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public void leaveTheBuilding() { ... }
}
Когда применять: когда нужен ровно один экземпляр и класс не должен расширять иной суперкласс кроме Enum (enum-форма). Если синглтон обязан быть подклассом другого класса — используйте идиому с public static final-полем.
Tradeoff: превращение класса в синглтон затрудняет тестирование клиентов: подменить мок невозможно, если синглтон не реализует интерфейс, который и служит его типом для клиентов. Поэтому: «нужен ровно один экземпляр сейчас» ≠ «нужен синглтон». Если есть сомнения — заведите интерфейс.
EJ-2-4 Обеспечивайте неинстанцируемость с помощью закрытого конструктора
Утилитарный класс — только статические методы и поля (Math, Arrays, Collections). Если ни одного конструктора не объявлено, компилятор синтезирует public default — клиенты случайно его инстанцируют. Объявление класса абстрактным не помогает: подкласс инстанцируется, плюс клиенты решат, что класс задуман для наследования.
// PREFER — единственный явный private-конструктор + AssertionError
public class UtilityClass {
// Подавляет default-конструктор; AssertionError страхует
// от случайного вызова внутри самого класса.
private UtilityClass() {
throw new AssertionError();
}
public static String reverse(String s) { ... }
}
// AVOID — компилятор подставит public default constructor
public class UtilityClass {
public static String reverse(String s) { ... }
}
Когда применять: любой класс, состоящий только из статических членов; группировка фабрик и методов вокруг финального класса.
Tradeoff: идиома контринтуитивна (private-конструктор рядом со статическими методами), поэтому над ним нужен поясняющий комментарий. Побочный эффект — класс нельзя наследовать, потому что подкласс не сможет вызвать конструктор суперкласса.
Java 8+: статические методы, привязанные к интерфейсу, можно поместить в сам интерфейс — для нового кода это часто чище, чем заводить отдельный utility-класс.
В нашей кодовой базе: Lombok @UtilityClass — делает класс final, добавляет private конструктор с UnsupportedOperationException и помечает все методы и поля static. Ручная идиома из этого рецепта нужна только если Lombok недоступен (например, чужая библиотека).
EJ-2-5 Предпочитайте внедрение зависимостей жёстко прошитым ресурсам
Класс, поведение которого зависит от внешнего ресурса (например, проверка орфографии — от словаря), нельзя реализовывать ни как утилитарный класс со static final-полем, ни как синглтон. Оба варианта жёстко прошивают конкретный ресурс и делают класс негибким и плохо тестируемым. Передавайте ресурс через конструктор — это и есть простейшая форма внедрения зависимостей.
// PREFER — DI: ресурс приходит снаружи
public class SpellChecker {
private final Lexicon dictionary;
public SpellChecker(Lexicon dictionary) {
this.dictionary = Objects.requireNonNull(dictionary);
}
public boolean isValid(String word) { ... }
public List<String> suggestions(String t) { ... }
}
// AVOID — статический утилитарный класс с прошитым словарём
public class SpellChecker {
private static final Lexicon dictionary = ...;
private SpellChecker() {}
public static boolean isValid(String word) { ... }
}
// AVOID — синглтон с прошитым словарём
public class SpellChecker {
private final Lexicon dictionary = ...;
public static SpellChecker INSTANCE = new SpellChecker(...);
}
Когда применять: всегда, когда поведение класса определяется одним или несколькими базовыми ресурсами. Полезный вариант — передавать не сам ресурс, а его фабрику (Supplier<? extends Tile> для мозаики). Это шаблон Factory Method.
Tradeoff: в крупных проектах ручная передача зависимостей засоряет код тысячами связей — на этом этапе подключают фреймворк внедрения зависимостей (Spring, Guice, Dagger). API, спроектированный для ручного DI, тривиально адаптируется к любому из них.
EJ-2-6 Избегайте создания излишних объектов
Переиспользуйте неизменяемые объекты вместо создания новых. Не оборачивайте строковый литерал в new String("...") — литерал гарантированно один на JVM. Дорогие в создании объекты (Pattern, DateFormat) выносите в static final-поля. Опасайтесь автоупаковки: смешение Long/long в горячем цикле создаёт миллионы лишних объектов.
// PREFER — литерал, кэш-фабрика, скомпилированный Pattern в static final
String s = "stringette"; // один экземпляр на JVM
Boolean b = Boolean.valueOf(true); // кэш TRUE/FALSE
public class RomanNumerals {
private static final Pattern ROMAN =
Pattern.compile("^(?=.)M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
static boolean isRomanNumeral(String s) {
return ROMAN.matcher(s).matches();
}
}
// AVOID — миллионы лишних String
String s = new String("stringette");
// AVOID — autoboxing: sum типа Long, каждое += i создаёт новый Long
Long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; i++) {
sum += i;
}
// Замена Long → long даёт многократное ускорение.
Когда применять: в горячих участках и больших циклах; для неизменяемых объектов; для дорогих ресурсов (regex Pattern.compile, форматтеры дат). Предпочитайте примитивы упакованным примитивам и следите за непреднамеренной автоупаковкой.
Tradeoff: не путайте с преждевременной оптимизацией — для редких вызовов лишний объект не проблема. Этот рецепт — противовес правилу «делайте защитные копии mutable-полей»: пропуск защитной копии там, где она нужна, гораздо опаснее, чем создание лишнего объекта. Object pool в обычном коде — плохая идея, GC справляется лучше; пул оправдан только для очень тяжёлых ресурсов (соединения с БД).
Java 8+: в новом коде вместо Date / Calendar — java.time.LocalDate / Instant. Они неизменяемы, защитные копии не нужны:
private static final LocalDate BOOM_START = LocalDate.of(1946, Month.JANUARY, 1);
private static final LocalDate BOOM_END = LocalDate.of(1965, Month.JANUARY, 1);
EJ-2-7 Избегайте устаревших ссылок на объекты
GC не освободит объект, на который ещё есть ссылка. Самые опасные источники таких «утечек»: класс, который сам управляет своей памятью (массив-буфер вроде Stack); кэши без TTL; незарегистрированные обратные вызовы и слушатели.
// PREFER — обнуляем устаревшую ссылку в Stack.pop()
public Object pop() {
if (size == 0) {
throw new EmptyStackException();
}
Object result = elements[--size];
elements[size] = null; // устранение устаревшей ссылки
return result;
}
// AVOID — GC не очистит, пока живёт сам Stack
public Object pop() {
if (size == 0) {
throw new EmptyStackException();
}
return elements[--size];
}
Когда применять: только когда класс сам управляет своей памятью (свой массив-буфер, свой пул). В обычном коде «обнуление ссылок руками» — антипаттерн: лучший способ убрать устаревшую ссылку — выйти из области видимости переменной (объявляйте переменные в максимально узкой области).
Tradeoff: не обнуляйте всё подряд — это шум. Для кэшей: WeakHashMap, если время жизни записей определяется внешними ссылками на ключ; иначе — фоновая чистка через ScheduledThreadPoolExecutor или removeEldestEntry в LinkedHashMap. Для слушателей и обратных вызовов — слабые ссылки (WeakReference) либо явное снятие регистрации.
EJ-2-8 Избегайте финализаторов и очистителей
finalize непредсказуем (нет гарантии, что и когда будет вызван), снижает производительность (создание/уничтожение объекта с пустым finalize — на порядки дороже), небезопасен (непойманное исключение в finalize молча игнорируется и оставляет объект в повреждённом состоянии). System.gc и System.runFinalization гарантий не дают. В Java 9 finalize помечен deprecated; на смену пришёл Cleaner — менее опасный, но столь же непредсказуемый. Никогда не ставьте обновление сохраняемого состояния в зависимость от финализатора или очистителя (например, освобождение блокировок в БД).
// PREFER — реализуйте AutoCloseable, клиенты используют try-с-ресурсами
public final class FileResource implements AutoCloseable {
public FileResource(Path path) { ... }
@Override public void close() { ... }
}
// PREFER — Cleaner как safety-net поверх AutoCloseable (Java 9+)
public final class Room implements AutoCloseable {
private static final Cleaner cleaner = Cleaner.create();
// State не должен ссылаться на Room, иначе циклическая ссылка
private static final class State implements Runnable {
int numJunkPiles;
State(int n) { numJunkPiles = n; }
@Override public void run() { numJunkPiles = 0; /* очистка */ }
}
private final State state;
private final Cleaner.Cleanable cleanable;
public Room(int numJunkPiles) {
state = new State(numJunkPiles);
cleanable = cleaner.register(this, state);
}
@Override public void close() { cleanable.clean(); }
}
// AVOID — finalize «на всякий случай» как основной механизм
@Override
protected void finalize() throws Throwable {
if (!closed) close();
}
Когда применять: практически никогда. Два допустимых сценария: (1) подстраховка на случай, если клиент забыл вызвать close() — в библиотечных классах вроде FileInputStream; (2) объекты с платформозависимыми (native) узлами, чьи нативные ресурсы не критичны и допускают отложенное освобождение. Основной способ закрытия ресурсов — AutoCloseable + try-с-ресурсами (см. EJ-2-9).
Tradeoff: финализаторы открывают класс для атак финализаторов через рефлексивно сериализованные подклассы. Защита: в нефинальном классе с финализатором переопределите finalize пустым final-методом. Очистители проще в обращении, но всё равно работают в фоновом потоке без гарантий времени выполнения и могут не запуститься совсем (например, при System.exit).
EJ-2-9 Предпочитайте try-с-ресурсами использованию try-finally
Исторически try-finally был наилучшим способом гарантировать закрытие ресурса. Он плохо масштабируется на несколько ресурсов (вложенные блоки) и скрывает первичное исключение: если и readLine(), и close() бросают, диагностируется только второе, а исходная причина теряется. Java 7 ввела try-с-ресурсами для классов, реализующих AutoCloseable — короче, читабельнее и сохраняет первичное исключение, добавляя последующие в getSuppressed().
// PREFER — try-с-ресурсами, один ресурс
static String firstLineOfFile(String path) throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader(path))) {
return br.readLine();
}
}
// PREFER — несколько ресурсов остаются плоскими
static void copy(String src, String dst) throws IOException {
try (InputStream in = new FileInputStream(src);
OutputStream out = new FileOutputStream(dst)) {
byte[] buf = new byte[BUFFER_SIZE];
int n;
while ((n = in.read(buf)) >= 0) {
out.write(buf, 0, n);
}
}
}
// PREFER — можно добавить catch и обработать без лишней вложенности
static String firstLineOfFile(String path, String defaultVal) {
try (BufferedReader br = new BufferedReader(new FileReader(path))) {
return br.readLine();
} catch (IOException e) {
return defaultVal;
}
}
// AVOID — try-finally на нескольких ресурсах: вложенно и скрывает причину
static void copy(String src, String dst) throws IOException {
InputStream in = new FileInputStream(src);
try {
OutputStream out = new FileOutputStream(dst);
try {
// ...
} finally {
out.close();
}
} finally {
in.close();
}
}
Когда применять: всегда, когда работаете с объектом, реализующим AutoCloseable (InputStream, OutputStream, java.sql.Connection, Lock через адаптер). Если пишете класс, представляющий ресурс с явным закрытием — реализуйте AutoCloseable сами.
Tradeoff: конструкция доступна с Java 7 — в коде ниже Java 7 используйте try-finally, аккуратно закрывая ресурсы в обратном порядке открытия. Для подавления второстепенных исключений в логах используйте Throwable.getSuppressed().
В нашей кодовой базе: конструкция try-with-resources встроена в язык с Java 7, отдельная аннотация не нужна. Lombok @Cleanup существует, но он семантически слабее (без авто-suppression) — мы его не используем. Для shutdown-сценариев на уровне приложения см. Graceful Shutdown Style Guide.
Гл 3. Методы, общие для всех объектов
Object — конкретный класс, но почти все его не-final методы (equals, hashCode, toString, clone) задают общие контракты и предназначены для перекрытия. Нарушение контракта тихо ломает классы, которые зависят от него: HashMap, HashSet, TreeSet, сериализацию, отладочный вывод. Сюда же относится Comparable.compareTo — формально не метод Object, но природа у него та же.
Каждое правило имеет код вида EJ-N-M — на эти коды ссылается AI-скилл ucp-effective-java-review в findings.
Источник: «Effective Java», 3-е издание (2018, рус. перевод «Диалектика», 2019). Каждый рецепт — сжатая выжимка соответствующей статьи Bloch, не пересказ всей главы.
EJ-3-1 Перекрывая equals, соблюдайте общий контракт
Если для класса определено понятие логической эквивалентности, отличное от тождественности ссылок (классы значений: PhoneNumber, Money, LocalDate), equals нужно перекрывать. В остальных случаях — не трогайте: реализация из Object даёт ровно то, что нужно для активных сущностей (Thread), классов под управлением экземпляров и enum.
Контракт — отношение эквивалентности: рефлексивность, симметричность, транзитивность, непротиворечивость и x.equals(null) == false. Ловушки: расширение инстанцируемого класса с добавлением компонента-значения нарушает один из законов всегда. getClass-проверка ломает Лисков; instanceof-проверка плюс новое поле в подклассе ломает симметрию или транзитивность. Способа честно расширить инстанцируемый класс с сохранением контракта equals не существует — используйте композицию.
// PREFER — рецепт: == → instanceof → cast → сравнение значимых полей
@Override public boolean equals(Object o) {
if (o == this) return true;
if (!(o instanceof PhoneNumber)) return false;
PhoneNumber pn = (PhoneNumber) o;
return pn.lineNum == lineNum
&& pn.prefix == prefix
&& pn.areaCode == areaCode;
}
// AVOID — параметр не Object: это перегрузка, не перекрытие
public boolean equals(MyClass o) { ... }
// AVOID — компонент-значение в подклассе через наследование
public class ColorPoint extends Point {
private final Color color;
@Override public boolean equals(Object o) {
if (!(o instanceof ColorPoint)) return false;
return super.equals(o) && ((ColorPoint) o).color == color;
}
// нарушает симметрию: point.equals(colorPoint) == true,
// colorPoint.equals(point) == false
}
Для null-safe сравнения ссылочных полей применяйте Objects.equals(a, b). Для float/double — Float.compare/Double.compare (из-за NaN и -0.0f). Аннотация @Override обязательна — она ловит подмену Object на конкретный тип параметра.
Когда применять: классы значений (immutable value classes), DTO, ключи коллекций.
Tradeoff: ручная реализация утомительна и подвержена ошибкам — для простых value-классов рассматривайте AutoValue, Lombok @EqualsAndHashCode, record (Java 14+) или генерацию из IDE.
Java 14+: record сам реализует equals/hashCode/toString корректно по всем компонентам — для классов значений предпочитайте record.
В нашей кодовой базе: Lombok @EqualsAndHashCode генерирует пару equals/hashCode по полям. Для DDD-агрегатов — @EqualsAndHashCode(onlyExplicitlyIncluded = true) + @EqualsAndHashCode.Include на поле идентификатора, чтобы сравнение шло только по identity. Контракт equals из этого рецепта — объяснение почему аннотация бьёт по identity, а не по всем полям сразу.
EJ-3-2 Всегда при перекрытии equals перекрывайте hashCode
Контракт hashCode: равные по equals объекты обязаны давать одинаковый хеш-код; в течение работы программы хеш не меняется, пока не изменились значимые поля; неравные объекты желательно (но не обязаны) давать разные хеши — это влияет на производительность хеш-таблиц. Нарушение приводит к тому, что HashMap.get возвращает null для логически равного ключа.
«Хорошая» хеш-функция распределяет неравные экземпляры равномерно по диапазону int. Плохой пример — return 42 (всё в один блок, поиск превращается в линейный). Канонический рецепт Bloch: result = c0, для каждого значимого поля result = 31 * result + c_i, где c_i — Type.hashCode(field) для примитивов, Objects.hashCode(ref) для ссылок, Arrays.hashCode(arr) для массивов. Множитель 31 — нечётное простое, JIT превращает 31 * x в (x << 5) - x.
// PREFER — ручной рецепт: быстро и контролируемо
@Override public int hashCode() {
int result = Short.hashCode(areaCode);
result = 31 * result + Short.hashCode(prefix);
result = 31 * result + Short.hashCode(lineNum);
return result;
}
// PREFER — однострочник для не-критичного по производительности кода
@Override public int hashCode() {
return Objects.hash(lineNum, prefix, areaCode);
}
// AVOID — деградация хеш-таблицы до связного списка
@Override public int hashCode() { return 42; }
// AVOID — пропуск значимого поля ради «производительности»:
// близкие экземпляры схлопываются в одно ведро
Objects.hash(...) короче, но создаёт массив на каждый вызов и боксирует примитивы — медленнее ручного рецепта. Для immutable классов с дорогим хешем рассмотрите ленивую инициализацию-кеширование в поле private int hashCode (нулевое значение по умолчанию = «не вычислено»).
Когда применять: всегда, без исключений, при любом перекрытии equals.
Tradeoff: не специфицируйте точное значение hashCode в Javadoc — иначе вы навсегда привязаны к формуле и не сможете её улучшить.
Java 7+: Objects.hash(...) для краткости; Type.hashCode(primitive) (Integer.hashCode(int) и т.п.) вместо упаковки и вызова .hashCode().
В нашей кодовой базе: та же аннотация @EqualsAndHashCode (см. EJ-3-1) — Lombok физически не позволит сгенерировать одно без другого. Это снимает основной источник багов из этого рецепта.
EJ-3-3 Всегда перекрывайте toString
Реализация по умолчанию — ClassName@163b91 — почти бесполезна. Хорошая toString появляется в логах, отладчике, сообщениях assert, конкатенациях строк и printf сама — даже если вы её не вызываете явно. Без неё диагностические сообщения превращаются в «{Jenny=PhoneNumber@163b91}» вместо «{Jenny=707-867-5309}».
Контракт мягкий: «лаконичное, но информативное и удобное для чтения представление». Для классов значений (телефонный номер, IP, денежная сумма) рассматривайте фиксацию формата в Javadoc — тогда строка пригодна для CSV, ввода/вывода и round-trip через парсер. У фиксации цена: формат становится частью API, и менять его в будущих версиях нельзя без поломки клиентов.
// PREFER — формат документирован, есть accessor для каждой части
/**
* Возвращает строковое представление вида "XXX-YYY-ZZZZ", где
* XXX — код области, YYY — префикс, ZZZZ — номер. Каждая группа
* дополняется ведущими нулями до фиксированной длины.
*/
@Override public String toString() {
return String.format("%03d-%03d-%04d", areaCode, prefix, lineNum);
}
// PREFER — формат не фиксируем, явно об этом пишем в Javadoc
/**
* Возвращает краткое описание зелья. Точные детали не определены
* и могут меняться, но следующее можно рассматривать как типичное:
* "[Зелье #9: тип=приворотное, запах=скипидар, вид=густая жидкость]"
*/
// AVOID — фиксированный формат без accessor’ов.
// Клиенты неизбежно начнут парсить toString, и формат превратится
// в API de-facto без возможности эволюции.
Каким бы вы ни сделали выбор по формату — задокументируйте намерение явно. И всегда давайте программный доступ ко всем полям, попавшим в строку: areaCode(), prefix(), lineNum(). Иначе клиенты будут парсить вашу строку.
Когда применять: в каждом инстанцируемом классе, который вы пишете, кроме enum (там Java делает идеальный toString сама) и статических утилитарных классов. Tradeoff: для крупных или потенциально циклических объектных графов — резюме, а не полный дамп; включая в строку только ключевые поля.
Java 14+: для record — авто-toString со всеми компонентами; обычно достаточно.
В нашей кодовой базе: Lombok @ToString (или @Data/@Value, которые включают @ToString). Для агрегатов с чувствительными данными — @ToString.Exclude на полях с PII/секретами. Документировать формат в Javadoc, как советует Bloch — стоит, если toString парсится клиентами.
EJ-3-4 Перекрывайте метод clone осторожно
Cloneable сломан архитектурно: это интерфейс-маркер без методов, но он меняет поведение защищённого Object.clone — нетипичное использование интерфейсов. Правильный clone обходит конструктор, обязан вызвать super.clone(), нуждается в защитных копиях для всех изменяемых полей, конфликтует с final-полями, ссылающимися на изменяемое состояние, и заставляет ловить непроверяемое по сути CloneNotSupportedException.
Рецепт, если уже расширяете Cloneable-класс: вызов super.clone() в try-catch, ковариантный возврат, рекурсивный clone каждого изменяемого поля либо собственный deepCopy для связных структур. Для final-поля, ссылающегося на mutable, придётся убрать final. Не вызывайте из clone методов, которые могут быть перекрыты, — клон ещё не достроен.
// PREFER — копирующий конструктор / фабрика копий
public Yum(Yum other) { ... }
public static Yum newInstance(Yum other) { ... }
// Set<E> dst = new TreeSet<>(src); // конструктор преобразования
// PREFER — clone для классов без mutable-полей
@Override public PhoneNumber clone() {
try {
return (PhoneNumber) super.clone(); // ковариантный возврат
} catch (CloneNotSupportedException e) {
throw new AssertionError(); // не может случиться
}
}
// PREFER — clone с глубокой копией изменяемого состояния
@Override public Stack clone() {
try {
Stack result = (Stack) super.clone();
result.elements = elements.clone(); // массивы — клонируем
return result;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
// AVOID — мелкая копия с разделяемым изменяемым состоянием
@Override public HashTable clone() {
HashTable result = (HashTable) super.clone();
result.buckets = buckets.clone(); // массив новый, но Entry — те же
return result; // мутация клона затрагивает оригинал
}
Лучший подход к копированию — копирующий конструктор или статическая фабрика копий. Они: не зависят от опасного внеязыкового механизма, не требуют Cloneable, не конфликтуют с final-полями, не бросают checked-исключение, не требуют приведения типа и могут принимать аргумент-интерфейс (Collection, Map) — давая клиенту выбор реализации копии (new TreeSet<>(hashSet)).
Когда применять: новые классы — никогда; реализуйте Cloneable только если расширяете класс, который уже её реализует. Иначе — копирующий конструктор/фабрика.
Tradeoff: массивы — единственное безболезненное применение clone: arr.clone() короче, корректно по типу и без ловушек.
Java 16+: для record копия делается через канонический конструктор: new R(r.x(), r.y()) или wither-паттерн.
EJ-3-5 Подумайте о реализации Comparable
Если у класса значений есть очевидное естественное упорядочение — алфавитное, числовое, хронологическое — реализуйте Comparable<T>. Это открывает доступ к Arrays.sort, Collections.sort, TreeSet, TreeMap, Collections.min/max и обобщённым алгоритмам без дополнительного Comparator.
Контракт compareTo похож на equals: антисимметрия (sgn(x.compareTo(y)) == -sgn(y.compareTo(x))), транзитивность, согласованность (x.compareTo(y) == 0 ⇒ sgn(x.compareTo(z)) == sgn(y.compareTo(z))). Сильно рекомендуется согласованность с equals — иначе TreeSet и HashSet будут считать «равенство» по-разному (классический пример: BigDecimal("1.0") и BigDecimal("1.00") равны по compareTo, но не по equals). Если согласованность нарушена — задокументируйте это в Javadoc.
// PREFER — статические методы compare для примитивов
@Override public int compareTo(PhoneNumber pn) {
int result = Short.compare(areaCode, pn.areaCode);
if (result == 0) {
result = Short.compare(prefix, pn.prefix);
if (result == 0) {
result = Short.compare(lineNum, pn.lineNum);
}
}
return result;
}
// PREFER — лаконичный chain через Comparator (Java 8+, ~10% медленнее)
private static final Comparator<PhoneNumber> COMPARATOR =
comparingInt((PhoneNumber pn) -> pn.areaCode)
.thenComparingInt(pn -> pn.prefix)
.thenComparingInt(pn -> pn.lineNum);
@Override public int compareTo(PhoneNumber pn) {
return COMPARATOR.compare(this, pn);
}
// AVOID — операторы < и > и арифметика разности
public int compareTo(PhoneNumber pn) {
return this.areaCode - pn.areaCode; // переполнение int!
}
static Comparator<Object> badOrder = (a, b) -> a.hashCode() - b.hashCode();
// нарушает транзитивность из-за переполнения
Расширение инстанцируемого Comparable-класса с добавлением компонента-значения обречено по тем же причинам, что и для equals, — используйте композицию и метод-«представление» (asPoint()).
Когда применять: классы значений с очевидным линейным порядком — даты, версии, идентификаторы с лексикографическим сравнением.
Tradeoff: chain-компараторы выразительнее ручной реализации, но дают ~10% накладных по сравнению с прямым Type.compare. На горячих путях — ручной вариант.
Java 8+: Comparator.comparing(...)/thenComparing(...) и их Int/Long/Double-варианты — современная идиома; забудьте о <, > и a - b.
Гл 4. Классы и интерфейсы
Самая большая глава у Блоха — одиннадцать статей о том, как проектировать классы и интерфейсы, чтобы их API был узким, инварианты — защищёнными, а наследники не сломали систему через год. Главные мотивы главы: скрытие информации, неизменяемость по умолчанию, композиция вместо наследования, интерфейс как тип. В третьем издании добавились две статьи про default-методы и про несколько классов в одном файле — обе отражают опыт Java 8+.
Каждое правило имеет код вида EJ-N-M — на эти коды ссылается AI-скилл ucp-effective-java-review в findings.
Источник: «Effective Java», 3-е издание (2018, рус. перевод «Диалектика», 2019). Каждый рецепт — сжатая выжимка соответствующей статьи Bloch, не пересказ всей главы.
EJ-4-1 Минимизируйте доступность классов и членов
Главный признак хорошо спроектированного модуля — насколько он скрывает свои данные и детали реализации от внешнего мира. Принцип скрытия информации (encapsulation) фундаментален: модули общаются только через API, и никто не должен знать, что происходит внутри. Эмпирическое правило простое — делайте каждый класс или член настолько недоступным, насколько это возможно.
// PREFER — минимальная видимость, расширяем по мере необходимости
public class OrderService {
private final OrderRepository repo; // private по умолчанию
private static final int MAX_ITEMS = 100; // константа — public static final OK
OrderService(OrderRepository repo) { // package-private конструктор
this.repo = repo;
}
public Order place(Cart cart) { ... } // public — единственная точка входа
private void validate(Cart cart) { ... } // приватный helper
}
// AVOID — public-поле массива: «public static final» не спасает
public static final Thing[] VALUES = { ... }; // массив изменяем, это дыра в безопасности
// PREFER — два варианта починки
private static final Thing[] PRIVATE_VALUES = { ... };
public static final List<Thing> VALUES =
Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));
// ИЛИ
public static Thing[] values() { return PRIVATE_VALUES.clone(); }
Уровни доступа для членов: private (только этот класс), package-private (любой класс пакета — это default), protected (подклассы плюс пакет), public (все). Для классов верхнего уровня — только два: package-private и public. Защищённые члены — это часть публичного API, к ним применимы те же требования совместимости, что и к public; их должно быть очень мало. Подкласс не может сужать доступ перекрытого метода — иначе нарушается принцип подстановки Лисков. Ненулевой массив всегда изменяем — public static final ARRAY всегда ошибка.
Java 9+: модульная система добавила два неявных уровня — экспортируемый и не экспортируемый пакет. Полезно внутри JDK, но для прикладного кода эффект ограничен: достаточно отсутствия exports в module-info.java.
EJ-4-2 Используйте в открытых классах методы доступа, а не открытые поля
Класс с публичными полями нельзя изменить, не сломав клиентов: представление зашито в API. Никаких инвариантов, никаких побочных действий при чтении/записи, никакой ленивой инициализации. Поэтому для классов, видимых вне пакета, всегда — private поля и getter/setter.
// AVOID — вырожденный класс с публичными полями
class Point {
public double x;
public double y;
}
// PREFER — инкапсуляция через методы доступа
class Point {
private double x;
private double y;
public Point(double x, double y) { this.x = x; this.y = y; }
public double getX() { return x; }
public double getY() { return y; }
public void setX(double x) { this.x = x; }
public void setY(double y) { this.y = y; }
}
Исключение: package-private класс или закрытый вложенный класс. Если класс виден только внутри пакета (или вообще только внутри одного охватывающего класса), область распространения изменений ограничена самим пакетом — открытые поля допустимы и даже визуально чище. Классы java.awt.Point и Dimension нарушают этот совет — Bloch явно указывает их как антипример, который потом стал источником серьёзных проблем с производительностью. Открывать неизменяемые поля немного безопаснее (инварианты сохраняются), но всё равно нельзя поменять представление — пример с public final int hour, minute Bloch называет «спорным».
В нашей кодовой базе: Lombok @Getter/@Setter (или @Data для mutable POJO, @Value для immutable). Public-поля встречаются только в package-private утилитарных структурах (как в Point-примере), для всего остального — аннотация.
EJ-4-3 Минимизируйте изменяемость
Неизменяемый объект — тот, состояние которого не меняется после создания. String, BigInteger, BigDecimal, упакованные примитивы — все из этой категории. Неизменяемые классы проще проектировать, они потокобезопасны без синхронизации, их можно свободно разделять и использовать как ключи в Map/элементы Set.
Пять правил неизменяемости:
- Не предоставлять методы, изменяющие состояние (mutator).
- Гарантировать невозможность расширения класса (
finalили приватный конструктор + статическая фабрика). - Объявить все поля
final. - Объявить все поля
private. - Обеспечить монопольный доступ ко всем изменяемым компонентам — защитные копии в конструкторе и геттерах.
// PREFER — канонический пример неизменяемого класса
public final class Complex {
private final double re;
private final double im;
public Complex(double re, double im) {
this.re = re;
this.im = im;
}
public double realPart() { return re; }
public double imaginaryPart() { return im; }
// PREFER — функциональный подход: метод возвращает новый экземпляр
public Complex plus(Complex c) {
return new Complex(re + c.re, im + c.im);
}
public Complex times(Complex c) {
return new Complex(re * c.re - im * c.im, re * c.im + im * c.re);
}
public static final Complex ZERO = new Complex(0, 0);
public static final Complex ONE = new Complex(1, 0);
public static final Complex I = new Complex(0, 1);
}
Имена методов — предлоги (plus, minus, times), а не глаголы (add, subtract) — это подчёркивает, что метод не меняет операнд, а возвращает новый объект. BigInteger и BigDecimal это правило нарушают — отсюда множество багов при их использовании.
Альтернатива final class — приватный конструктор плюс открытая статическая фабрика. Класс эффективно неизменяем (расширить нельзя — нет открытого конструктора), но при этом сохраняется гибкость: можно добавить кэширование, вернуть подкласс из той же библиотеки.
Минус: для каждого уникального значения нужен отдельный объект. Если многошаговая операция создаёт цепочку временных объектов — производительность страдает. Решение — изменяемый класс-компаньон, package-private или public: String плюс StringBuilder, BigInteger плюс внутренний MutableBigInteger.
Подведение итогов: каждое поле должно быть private final, если у вас нет очень веских причин делать иначе (см. EJ-4-1/EJ-4-2). Конструкторы должны создавать полностью инициализированные объекты со всеми установленными инвариантами.
В нашей кодовой базе: Lombok @Value делает класс final, поля private final, генерирует equals/hashCode/toString/all-args-constructor — пять правил immutability из этого рецепта одной аннотацией. Для копирования с изменением одного поля — @With (Wither). Для агрегатов в DDD-стиле — комбинация @Value для value object и @AggregateRoot (наш) для агрегатов с identity.
EJ-4-4 Предпочитайте композицию наследованию
Наследование (имеется в виду наследование реализации — extends, не implements) нарушает инкапсуляцию: подкласс зависит от деталей реализации суперкласса, и эта реализация может измениться в следующей версии. Классический пример — InstrumentedHashSet, который пытается посчитать общее число попыток вставки:
// AVOID — наследование от HashSet ломается из-за self-use addAll → add
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
@Override public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size(); // считаем размер
return super.addAll(c); // ВНИМАНИЕ: HashSet.addAll внутри вызывает add,
} // и каждый элемент учитывается ДВАЖДЫ
public int getAddCount() { return addCount; }
}
// new InstrumentedHashSet().addAll(List.of("a","b","c")) → getAddCount() = 6, не 3
Решение — композиция плюс forwarding. Новый класс держит существующий как закрытое поле, через ForwardingSet пробрасывает все методы интерфейса Set, а нужное поведение перекрывает только в обёртке:
// PREFER — класс-обёртка (Wrapper / Decorator)
public class InstrumentedSet<E> extends ForwardingSet<E> {
private int addCount = 0;
public InstrumentedSet(Set<E> s) { super(s); }
@Override public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() { return addCount; }
}
// PREFER — повторно используемый forwarding-класс (один на интерфейс)
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> s) { this.s = s; }
public void clear() { s.clear(); }
public boolean contains(Object o) { return s.contains(o); }
public Iterator<E> iterator() { return s.iterator(); }
public boolean add(E e) { return s.add(e); }
public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }
// ... остальные методы Set
}
Теперь InstrumentedSet работает с любым Set — TreeSet, HashSet, ConcurrentSkipListSet — и не зависит от self-use внутри суперкласса. Этот шаблон называется Декоратор. Ограничение — обёртки плохо подходят для callback-фреймворков (проблема SELF: завёрнутый объект передаёт this, не зная про обёртку).
Когда наследование уместно: только если между классом B и классом A есть отношение «является» (B is-a A). Если на вопрос «действительно ли каждый B является A?» нет уверенного «да» — B не должен расширять A. JDK содержит много нарушений: Stack extends Vector, Properties extends Hashtable — стек не вектор, набор свойств не хеш-таблица. Композиция была бы уместнее.
EJ-4-5 Проектируйте и документируйте наследование либо запрещайте его
Если класс предназначен для расширения, к нему предъявляются жёсткие требования: документация на каждый перекрываемый метод (как класс сам себя использует), осторожный выбор protected-членов как точек расширения, тесты через написание трёх-четырёх подклассов до выпуска.
// AVOID — конструктор вызывает overridable-метод, ломает подкласс
public class Super {
public Super() {
overrideMe(); // вызовется ДО конструктора Sub
}
public void overrideMe() { }
}
public final class Sub extends Super {
private final Instant instant;
Sub() {
instant = Instant.now(); // ещё не выполнился, когда Super() позвал overrideMe()
}
@Override public void overrideMe() {
System.out.println(instant); // печатает null в первый раз
}
}
Конструкторы (а также clone/readObject) не должны вызывать методы, которые могут быть перекрыты — ни прямо, ни косвенно. Они выполняются раньше, чем подкласс успевает себя инициализировать. Внутри конструктора безопасны только private/final/static-методы.
Лучшая стратегия — запретить наследование для классов, которые специально под него не разрабатывались. Два способа: final class или приватный конструктор плюс статическая фабрика. Если запретить нельзя (класс реализует стандартный интерфейс типа List — клиенты ожидают наследовать), то по крайней мере уберите self-use: тело каждого перекрываемого метода вынесите в приватный helper и зовите helper напрямую отовсюду внутри класса.
EJ-4-6 Предпочитайте интерфейсы абстрактным классам
Java позволяет одиночное наследование классов, но множественную реализацию интерфейсов. Из этого следуют практические преимущества интерфейсов:
- Существующие классы легко дооснастить — добавить
implementsи нужные методы. Дооснастить классы для расширения нового абстрактного класса почти всегда невозможно: общий предок придётся поднимать на самый верх иерархии. - Интерфейсы идеальны для миксинов:
Comparable,Iterable,AutoCloseable— все необязательные сущности класса. - Иерархии нелинейны. Певец, автор песен, певец-автор песен:
public interface Singer { AudioClip sing(Song s); }
public interface Songwriter { Song compose(int chartPosition); }
public interface SingerSongwriter extends Singer, Songwriter {
AudioClip strum();
void actSensitive();
}
Сделать это иерархией классов — комбинаторный взрыв.
Скелетная реализация (AbstractInterface — AbstractList, AbstractSet, AbstractMap) объединяет преимущества интерфейсов и абстрактных классов: интерфейс задаёт тип, скелетный класс реализует все неосновные методы поверх примитивов, имитируя множественное наследование.
// PREFER — скелетная реализация Map.Entry
public abstract class AbstractMapEntry<K, V> implements Map.Entry<K, V> {
@Override public V setValue(V value) {
throw new UnsupportedOperationException();
}
@Override public boolean equals(Object o) {
if (o == this) return true;
if (!(o instanceof Map.Entry)) return false;
Map.Entry<?, ?> e = (Map.Entry<?, ?>) o;
return Objects.equals(e.getKey(), getKey())
&& Objects.equals(e.getValue(), getValue());
}
@Override public int hashCode() {
return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
}
@Override public String toString() {
return getKey() + "=" + getValue();
}
}
Скелетную реализацию нельзя свести к default-методам интерфейса — equals/hashCode/toString для default-методов запрещены, и интерфейсы не могут содержать поля. Если класс уже расширяет что-то ещё — он использует имитацию множественного наследования через приватный inner-класс, расширяющий скелет (см. EJ-4-4).
EJ-4-7 Проектируйте интерфейсы для потомков
В Java 8 появились default-методы — добавлять методы в существующие интерфейсы стало возможно, но по-прежнему рискованно. Default-метод «вводится» во все существующие реализации без ведома их авторов. Не всегда возможно написать default-реализацию, которая поддерживает инварианты всех мыслимых имплементаций.
Канонический пример опасности — Collection.removeIf из Java 8:
// Java 8 default — выглядит безобидно, но ломает Apache SynchronizedCollection
default boolean removeIf(Predicate<? super E> filter) {
Objects.requireNonNull(filter);
boolean result = false;
for (Iterator<E> it = iterator(); it.hasNext(); ) {
if (filter.test(it.next())) {
it.remove();
result = true;
}
}
return result;
}
org.apache.commons.collections4.collection.SynchronizedCollection — обёртка с собственной блокировкой. Default removeIf ничего не знает про синхронизацию, итерирует без захвата лока — отсюда ConcurrentModificationException или хуже, неопределённое поведение. JDK-версия Collections.synchronizedCollection была вынуждена перекрыть default, а внешние библиотеки — нет.
Мораль: даже несмотря на то, что default-методы — часть платформы, проектирование интерфейсов по-прежнему требует предельной аккуратности. Тщательно тестируйте интерфейс до выпуска — пишите минимум три различные реализации и хотя бы один клиент-программу. Хотя default-методы позволяют добавлять методы в существующие интерфейсы, это связано с большим риском.
EJ-4-8 Используйте интерфейсы только для определения типов
Если класс реализует интерфейс — это говорит клиенту, что именно можно делать с экземплярами этого класса. Создавать интерфейс ради чего-то другого — антишаблон. Самый известный — интерфейс констант:
// AVOID — антишаблон интерфейса констант
public interface PhysicalConstants {
static final double AVOGADROS_NUMBER = 6.022_140_857e23;
static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;
static final double ELECTRON_MASS = 9.109_383_56e-31;
}
// PREFER — вспомогательный класс констант
package com.example.science;
public final class PhysicalConstants {
private PhysicalConstants() { } // не допускает инстанцирование
public static final double AVOGADROS_NUMBER = 6.022_140_857e23;
public static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;
public static final double ELECTRON_MASS = 9.109_383_56e-31;
}
// На стороне клиента — статический импорт убирает префикс класса:
import static com.example.science.PhysicalConstants.*;
Реализация интерфейса констант просачивается в API класса: клиенты класса видят все константы, даже если им они не нужны. И если в будущей версии константы перестанут использоваться, класс всё равно обязан реализовывать интерфейс ради бинарной совместимости. JDK содержит несколько таких аномалий (java.io.ObjectStreamConstants) — это не образцы.
Если константы тесно связаны с существующим классом или интерфейсом — добавляйте их туда (Integer.MIN_VALUE, Integer.MAX_VALUE). Если константы — естественный enum — экспортируйте их как тип перечисления. В остальных случаях используйте неинстанцируемый вспомогательный класс.
EJ-4-9 Предпочитайте иерархии классов дескрипторам классов
Класс с дескриптором (tagged class) — это класс с полем-перечислением, указывающим разновидность экземпляра, и инструкцией switch на каждом методе:
// AVOID — tagged class
class Figure {
enum Shape { RECTANGLE, CIRCLE };
final Shape shape;
// используются только в RECTANGLE
double length;
double width;
// используется только в CIRCLE
double radius;
Figure(double radius) {
shape = Shape.CIRCLE;
this.radius = radius;
}
Figure(double length, double width) {
shape = Shape.RECTANGLE;
this.length = length;
this.width = width;
}
double area() {
switch (shape) {
case RECTANGLE: return length * width;
case CIRCLE: return Math.PI * (radius * radius);
default: throw new AssertionError(shape);
}
}
}
Минусы: класс многословен, склонен к ошибкам и неэффективен. Поля одного варианта простаивают в экземплярах другого варианта. Поля нельзя сделать final, не утратив возможность инициализации. Добавление новой разновидности требует изменения всех методов со switch. Тип экземпляра не отражён в системе типов — компилятор не помогает.
// PREFER — иерархия классов
abstract class Figure {
abstract double area();
}
class Circle extends Figure {
final double radius;
Circle(double radius) { this.radius = radius; }
@Override double area() { return Math.PI * (radius * radius); }
}
class Rectangle extends Figure {
final double length;
final double width;
Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
@Override double area() { return length * width; }
}
Поля окончательные, компилятор гарантирует инициализацию каждого подкласса. Добавить квадрат — отдельный подкласс Square extends Rectangle. Tagged class — бледное подобие настоящих подтипов: применение дескрипторов почти всегда указывает на необходимость рефакторинга в иерархию.
EJ-4-10 Предпочитайте статические классы-члены нестатическим
В Java четыре разновидности вложенных классов: статический класс-член, нестатический класс-член, анонимный класс, локальный класс. Последние три называются внутренними (inner). Каждая разновидность — для своей задачи.
- Статический класс-член — обычный класс, объявленный внутри другого; имеет доступ ко всем (включая закрытым) членам охватывающего класса. Используйте, если этот класс — открытый helper, имеющий смысл только в контексте охватывающего класса (
Calculator.Operationкак enum внутриCalculator). - Нестатический класс-член — каждый экземпляр неявно связан с охватывающим экземпляром (через
EnclosingClass.this). Используйте для определения адаптеров (Adapter) — например, итераторов коллекций. - Анонимный класс — лямбда-аналог до Java 8; теперь редко предпочтительнее лямбды.
- Локальный класс — наименее частый вариант, аналог локальной переменной.
// AVOID — нестатический класс-член без необходимости (ссылка на enclosing)
public class MyMap<K, V> {
private class Entry { // implicit reference to MyMap
K key;
V value;
}
}
// PREFER — статический класс-член, если ссылка на охватывающий не нужна
public class MyMap<K, V> {
private static class Entry<K, V> { // нет ссылки на MyMap
K key;
V value;
}
}
Если вложенный класс-член не нуждается в доступе к охватывающему экземпляру — всегда делайте его static. Скрытая ссылка на охватывающий объект тратит память, замедляет создание и, главное, держит охватывающий объект в памяти, мешая GC — это распространённая утечка. Ошибку трудно обнаружить: ссылка невидима в коде.
Для публичных или защищённых вложенных классов выбор «static vs non-static» особенно критичен: позже сделать static-нестатический член нельзя без поломки бинарной совместимости.
EJ-4-11 Ограничивайтесь одним классом верхнего уровня на исходный файл
Java позволяет определить несколько классов верхнего уровня в одном файле, но это всегда ошибка. Поведение программы становится зависимым от порядка передачи файлов компилятору:
// AVOID — Main.java
public class Main {
public static void main(String[] args) {
System.out.println(Utensil.NAME + Dessert.NAME);
}
}
// AVOID — Utensil.java содержит ОБА: Utensil + Dessert (top-level)
class Utensil { static final String NAME = "pan"; }
class Dessert { static final String NAME = "cake"; }
// AVOID — Dessert.java тоже содержит ОБА
class Utensil { static final String NAME = "pot"; }
class Dessert { static final String NAME = "pie"; }
javac Main.java Dessert.java — ошибка: «multiple definitions». javac Main.java — печатает pancake. javac Dessert.java Main.java — печатает potpie. Поведение зависит от порядка компиляции — это неприемлемо.
// PREFER — каждый top-level класс в своём файле
// Utensil.java
class Utensil { static final String NAME = "pan"; }
// Dessert.java
class Dessert { static final String NAME = "cake"; }
// PREFER — если очень хочется в одном файле, используйте static-классы-члены
public class Test {
public static void main(String[] args) {
System.out.println(Utensil.NAME + Dessert.NAME);
}
private static class Utensil { static final String NAME = "pan"; }
private static class Dessert { static final String NAME = "cake"; }
}
Один top-level класс на файл гарантирует, что результат компиляции и поведение программы не зависят от порядка передачи исходников компилятору.
В нашей кодовой базе: проверяет Checkstyle (OneTopLevelClass) и инспекция IntelliJ; нарушение ловится в IDE до коммита. Само правило по сути гигиена, не design.
Гл 5. Обобщенное программирование
Восемь статей Джошуа Блоха про generics. Generics появились в Java 5, но из-за миграционной совместимости (migration compatibility) старый pre-generics код продолжает компилироваться — и провоцирует ошибки времени выполнения там, где компилятор мог бы поймать их статически. Цель главы — закрыть все лазейки: пишите параметризованные типы и методы, никогда не используйте raw types, аккуратно сочетайте generics с массивами и varargs, выжимайте максимум гибкости из wildcards.
Каждое правило имеет код вида EJ-N-M — на эти коды ссылается AI-скилл ucp-effective-java-review в findings.
Источник: «Effective Java», 3-е издание (2018, рус. перевод «Диалектика», 2019). Каждый рецепт — сжатая выжимка соответствующей статьи Bloch, не пересказ всей главы.
EJ-5-1 Не используйте несформированные типы
Несформированный тип (raw type) — имя обобщённого типа без параметров: List вместо List<String>. Raw types выпадают из системы generics: компилятор не проверяет, что вы кладёте в коллекцию, и ловит ошибку только при ClassCastException во время выполнения — обычно далеко от места, где вставка случилась. Существуют raw types только ради совместимости с pre-generics кодом (Java 1.4 и старше).
// AVOID — raw type теряет проверку типов
private final Collection stamps = ...; // комментарий «только Stamp» не помогает
stamps.add(new Coin(...)); // unchecked warning, но компилируется
for (Iterator i = stamps.iterator(); i.hasNext(); ) {
Stamp stamp = (Stamp) i.next(); // ClassCastException — внезапно
}
// PREFER — параметризованный тип ловит ошибку при компиляции
private final Collection<Stamp> stamps = ...;
stamps.add(new Coin(...)); // error: incompatible types
// AVOID — raw type позволяет обойти инварианты типобезопасного списка
List<String> strings = new ArrayList<>();
unsafeAdd(strings, Integer.valueOf(42)); // компилируется (unchecked)
String s = strings.get(0); // ClassCastException на скрытом cast
private static void unsafeAdd(List list, Object o) { list.add(o); }
// PREFER — List<Object> явно говорит «любой объект»: List<String> уже не подтип
private static void safeAdd(List<Object> list, Object o) { list.add(o); }
Когда применять: никогда не пишите raw type в новом коде. Если тип элементов неизвестен или не важен — используйте неограниченный wildcard List<?> (читать можно любой Object, положить нельзя ничего, кроме null). Если нужно «любой объект, и вставка тоже разрешена» — пишите List<Object>.
Tradeoff: есть два узких исключения, где raw type обязателен. Литералы классов: List.class разрешён, List<String>.class — нет (спецификация запрещает параметризованные литералы). Оператор instanceof: информация о параметре типа стирается во время выполнения, поэтому o instanceof Set корректно, а o instanceof Set<String> — нет; после проверки сразу приведите к Set<?>.
// PREFER — instanceof + wildcard cast
if (o instanceof Set) {
Set<?> s = (Set<?>) o;
...
}
EJ-5-2 Устраняйте предупреждения о непроверяемом коде
При работе с generics компилятор выдаёт предупреждения нескольких видов: unchecked cast, unchecked method invocation, unchecked conversion, unchecked generic array creation. Каждое такое предупреждение — потенциальный ClassCastException во время выполнения. Устраняйте все unchecked warnings, какие можете. Если устранили все — код типобезопасен (с поправкой на JNI и Object[]).
Многие предупреждения убираются легко: например, Set<Lark> s = new HashSet(); чинится оператором ромба new HashSet<>() (Java 7+). Если устранить нельзя, но вы уверены в безопасности приведения, подавите warning через @SuppressWarnings("unchecked") в максимально узкой области — обычно это объявление локальной переменной, а не всего метода или класса.
// AVOID — @SuppressWarnings на всём методе скрывает и будущие настоящие проблемы
@SuppressWarnings("unchecked")
public <T> T[] toArray(T[] a) { ... }
// PREFER — локальная переменная сужает область подавления
public <T> T[] toArray(T[] a) {
if (a.length < size) {
// Это приведение корректно, поскольку создаваемый массив того же типа,
// что и переданный, т.е. T[].
@SuppressWarnings("unchecked")
T[] result = (T[]) Arrays.copyOf(elements, size, a.getClass());
return result;
}
System.arraycopy(elements, 0, a, 0, size);
if (a.length > size) a[size] = null;
return a;
}
Когда применять: при каждом @SuppressWarnings("unchecked") пишите комментарий, почему код безопасен. Если комментарий не получается — задумайтесь: возможно, операция и правда небезопасна. Никогда не ставьте @SuppressWarnings на целый класс — реальные проблемы там утонут в шуме.
EJ-5-3 Предпочитайте списки массивам
Массивы и обобщённые типы устроены принципиально по-разному. Массивы ковариантны: Sub[] — подтип Super[]. Generics инвариантны: List<Sub> не подтип List<Super>. Массивы reified (доступны при выполнении): они помнят и проверяют тип элементов в рантайме. Generics реализуются через стирание (erasure): информация о параметре типа выбрасывается компилятором.
Из-за этой разницы массивы ловят ошибку поздно, а generics — рано:
// AVOID — компилируется, падает с ArrayStoreException
Object[] objectArray = new Long[1];
objectArray[0] = "I don't fit in"; // ArrayStoreException при выполнении
// PREFER — не компилируется: ошибку видно сразу
List<Object> ol = new ArrayList<Long>(); // error: incompatible types
Создание обобщённых массивов запрещено: new List<String>[], new E[], new List<E>[] — ошибки компиляции. Если бы такие массивы разрешили, можно было бы через ковариантность массивов и стирание положить List<Integer> в List<String>[] и получить ClassCastException на скрытом приведении.
// AVOID — массив объектов требует явного приведения и не типобезопасен
public class Chooser {
private final Object[] choiceArray;
public Chooser(Collection choices) { choiceArray = choices.toArray(); }
public Object choose() { // клиент делает (T) каждый раз
return choiceArray[ThreadLocalRandom.current().nextInt(choiceArray.length)];
}
}
// PREFER — обобщённый класс на основе List, без приведений и без warnings
public class Chooser<T> {
private final List<T> choiceList;
public Chooser(Collection<T> choices) { choiceList = new ArrayList<>(choices); }
public T choose() {
Random rnd = ThreadLocalRandom.current();
return choiceList.get(rnd.nextInt(choiceList.size()));
}
}
Когда применять: если массивы и generics плохо смешиваются и появляются ошибки или предупреждения времени компиляции — замените массив на List<E>. Производительность немного просядет, но взамен вы получите безопасность типов и нормальное взаимодействие с обобщённым кодом.
EJ-5-4 Предпочитайте обобщенные типы
Если у вас есть pre-generics класс на основе Object[], его можно обобщить постфактум — клиенты, использовавшие старую версию, не сломаются. Шагов два: добавить параметр типа <E> в объявление и заменить все Object на E.
// AVOID — pre-generics: клиент обязан явно приводить pop()
public class Stack {
private Object[] elements;
private int size = 0;
public void push(Object e) { ensureCapacity(); elements[size++] = e; }
public Object pop() {
if (size == 0) throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // Удаление устаревшей ссылки
return result;
}
...
}
// PREFER — обобщённый Stack<E>
public class Stack<E> {
private E[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
// Массив elements содержит только экземпляры E из push(E).
// Этого достаточно для гарантии безопасности типов, но тип
// времени выполнения массива — Object[], не E[].
@SuppressWarnings("unchecked")
public Stack() {
elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(E e) { ensureCapacity(); elements[size++] = e; }
public E pop() {
if (size == 0) throw new EmptyStackException();
E result = elements[--size];
elements[size] = null;
return result;
}
...
}
Когда применять: при разработке нового типа — параметризуйте его сразу, чтобы клиенты не делали явных приведений. Существующие типы с Object-API стоит обобщить постфактум — это упростит жизнь новым пользователям и не сломает старых клиентов.
Tradeoff: обходной путь (E[]) new Object[N] приводит к heap pollution (см. EJ-5-7): тип времени выполнения массива (Object[]) не совпадает с типом времени компиляции (E[]). На практике это безопасно, если массив не утекает наружу — он хранится в приватном поле и читается/пишется только внутри класса. Альтернатива — поле Object[] elements плюс приведение (E) elements[--size] в pop() — даёт более «честный» рантайм-тип, но требует приведения в каждом методе чтения.
Параметр типа можно ограничить: class DelayQueue<E extends Delayed> требует, чтобы фактический параметр был подтипом Delayed. Это ограниченный параметр типа (bounded type parameter) — он позволяет вызывать методы границы (getDelay) без приведений и без риска ClassCastException.
EJ-5-5 Предпочитайте обобщенные методы
Методы, как и классы, могут быть обобщёнными. Список параметров типа стоит между модификаторами и возвращаемым типом: public static <E> Set<E> union(...). Все методы-«алгоритмы» из Collections (binarySearch, sort) — обобщённые. Преимущество то же, что у обобщённых типов: безопасность типов и отсутствие приведений в клиентском коде.
// AVOID — raw types в сигнатуре дают два unchecked warnings
public static Set union(Set s1, Set s2) {
Set result = new HashSet(s1);
result.addAll(s2);
return result;
}
// PREFER — обобщённый метод, никаких warnings, безопасен с точки зрения типов
public static <E> Set<E> union(Set<E> s1, Set<E> s2) {
Set<E> result = new HashSet<>(s1);
result.addAll(s2);
return result;
}
// PREFER — обобщённая фабрика синглтонов: один объект на все T
private static UnaryOperator<Object> IDENTITY_FN = (t) -> t;
@SuppressWarnings("unchecked")
public static <T> UnaryOperator<T> identityFunction() {
return (UnaryOperator<T>) IDENTITY_FN; // безопасно: identity stateless
}
Иногда параметр типа должен быть ограничен выражением, в которое сам входит — это рекурсивное ограничение типа (recursive type bound). Канонический случай — Comparable<T>: «любой E, который сравним с самим собой».
// PREFER — взаимная сравнимость через рекурсивное ограничение
public static <E extends Comparable<E>> E max(Collection<E> c) {
if (c.isEmpty()) throw new IllegalArgumentException("Пустая коллекция");
E result = null;
for (E e : c)
if (result == null || e.compareTo(result) > 0)
result = Objects.requireNonNull(e);
return result;
}
Когда применять: если метод требует от клиента приведения входных параметров или возвращаемого значения — обобщите его. Если несколько параметров должны быть одного типа — выразите это одним параметром типа, как в union(Set<E>, Set<E>). Связь «E сравним с самим собой» — <E extends Comparable<E>>.
EJ-5-6 Используйте ограниченные символы подстановки для повышения гибкости API
Параметризованные типы инвариантны: List<String> не подтип List<Object>. Это часто слишком строго. Ограниченные wildcards (? extends T, ? super T) ослабляют ограничение там, где это безопасно. Канонический пример — Stack<E> и его методы pushAll / popAll:
// AVOID — pushAll(Iterable<E>) запрещает Stack<Number>.pushAll(Iterable<Integer>)
public void pushAll(Iterable<E> src) { for (E e : src) push(e); }
// PREFER — параметр-производитель E принимает E и любой подтип
public void pushAll(Iterable<? extends E> src) { for (E e : src) push(e); }
// AVOID — popAll(Collection<E>) запрещает Stack<Number>.popAll(Collection<Object>)
public void popAll(Collection<E> dst) { while (!isEmpty()) dst.add(pop()); }
// PREFER — параметр-потребитель E принимает E и любой супертип
public void popAll(Collection<? super E> dst) { while (!isEmpty()) dst.add(pop()); }
Мнемоника PECS — Producer Extends, Consumer Super: если параметр производит экземпляры T (вы из него читаете), пишите <? extends T>; если потребляет (вы в него пишете), пишите <? super T>. Если параметр и производитель, и потребитель одновременно — wildcards не помогут, нужен точный тип.
Применяйте PECS к статическим методам и конструкторам:
// PREFER — Chooser читает choices, значит choices — производитель T
public Chooser(Collection<? extends T> choices) { ... }
// PREFER — оба параметра читаются, оба производители
public static <E> Set<E> union(Set<? extends E> s1, Set<? extends E> s2) { ... }
Сравнения и компараторы — всегда потребители, поэтому общий рецепт Comparable<? super T> / Comparator<? super T> предпочтительнее «голого» Comparable<T>:
// PREFER — рекурсивное ограничение с wildcard, работает для ScheduledFuture
public static <T extends Comparable<? super T>> T max(List<? extends T> list) { ... }
Простое правило для swap-подобных методов: если параметр типа появляется в сигнатуре только один раз — замените его wildcard. void swap(List<?> list, int i, int j) короче и понятнее, чем <E> void swap(List<E> list, int i, int j).
// PREFER — публичный API на wildcard, реализация захватывает тип через приватный helper
public static void swap(List<?> list, int i, int j) { swapHelper(list, i, j); }
private static <E> void swapHelper(List<E> list, int i, int j) {
list.set(i, list.set(j, list.get(i)));
}
Когда применять: для библиотечных API применение wildcards считайте обязательным. Не используйте ограниченные wildcards в возвращаемых типах — это заставит клиентов ставить wildcards в своём коде. Если пользователю API приходится думать про wildcards — у API проблема.
EJ-5-7 Аккуратно сочетайте обобщенные типы и переменное количество аргументов
Varargs и generics — leaky abstraction: при вызове метода с T... args создаётся массив для хранения параметров. Тип этого массива — недоступен при выполнении (non-reifiable), поэтому компилятор выдаёт предупреждение про возможное загрязнение кучи (heap pollution): обобщённая ссылка может указывать на объект, не принадлежащий её типу.
// AVOID — dangerous(): heap pollution + ClassCastException на скрытом cast
static void dangerous(List<String>... stringLists) {
List<Integer> intList = List.of(42);
Object[] objects = stringLists; // ковариантность массивов
objects[0] = intList; // загрязнение кучи
String s = stringLists[0].get(0); // ClassCastException
}
Если метод не сохраняет ничего в varargs-массиве и не передаёт этот массив наружу непроверенному коду — он безопасен. В этом случае пометьте его аннотацией @SafeVarargs (Java 7+) — это обещание автора, что метод типобезопасен; компилятор перестаёт ругаться и в объявлении, и в местах вызова.
// AVOID — toArray() возвращает varargs-массив наружу: запрещено
static <T> T[] toArray(T... args) { return args; } // утечка ссылки на массив
// AVOID — pickTwo() передаёт массив в чужой метод, который тоже его раскрывает
static <T> T[] pickTwo(T a, T b, T c) {
switch (ThreadLocalRandom.current().nextInt(3)) {
case 0: return toArray(a, b); // массив Object[] утекает
case 1: return toArray(a, c);
case 2: return toArray(b, c);
}
throw new AssertionError();
}
// PREFER — метод не пишет в varargs-массив и не отдаёт его наружу: @SafeVarargs
@SafeVarargs
static <T> List<T> flatten(List<? extends T>... lists) {
List<T> result = new ArrayList<>();
for (List<? extends T> list : lists) result.addAll(list);
return result;
}
// PREFER — альтернатива: List<List<? extends T>> вместо varargs
static <T> List<T> flatten(List<List<? extends T>> lists) {
List<T> result = new ArrayList<>();
for (List<? extends T> list : lists) result.addAll(list);
return result;
}
Когда применять: на каждом обобщённом или параметризованном varargs-методе ставьте @SafeVarargs — иначе пользователи будут перегружены unchecked warnings. Никогда не пишите небезопасный varargs-метод: метод безопасен тогда и только тогда, когда (1) он ничего не сохраняет в varargs-массив и (2) не делает массив (или его клон) видимым ненадёжному коду. Если ни одно из этих условий невыполнимо — замените varargs на List<T>-параметр и применяйте List.of(...) на стороне клиента.
Tradeoff: @SafeVarargs разрешён только на методах, которые невозможно переопределить — static, final, private (с Java 9). Для нестатических переопределяемых методов гарантировать безопасность всех будущих переопределений нельзя.
EJ-5-8 Применяйте безопасные с точки зрения типов гетерогенные контейнеры
Обычный обобщённый контейнер (Set<E>, Map<K,V>) фиксирован на конечное число параметров типа. Иногда нужна большая гибкость: например, строка БД содержит произвольное число столбцов разных типов, и хочется типобезопасно обращаться ко всем. Идея — параметризовать ключ, а не контейнер, и использовать Class<T> как токен типа (type token).
// PREFER — API типобезопасного гетерогенного контейнера
public class Favorites {
public <T> void putFavorite(Class<T> type, T instance);
public <T> T getFavorite(Class<T> type);
}
// клиент
Favorites f = new Favorites();
f.putFavorite(String.class, "Java");
f.putFavorite(Integer.class, 0xcafebabe);
f.putFavorite(Class.class, Favorites.class);
String favoriteString = f.getFavorite(String.class);
int favoriteInteger = f.getFavorite(Integer.class);
Class<?> favoriteClass = f.getFavorite(Class.class);
Реализация удивительно компактна. Внутреннее хранилище — Map<Class<?>, Object> (вложенный wildcard допускает разные Class<X> в качестве ключей). Связь между типом ключа и типом значения восстанавливается в getFavorite через динамическое приведение Class.cast:
// PREFER — реализация heterogeneous container
public class Favorites {
private Map<Class<?>, Object> favorites = new HashMap<>();
public <T> void putFavorite(Class<T> type, T instance) {
favorites.put(Objects.requireNonNull(type), type.cast(instance)); // runtime-checked
}
public <T> T getFavorite(Class<T> type) {
return type.cast(favorites.get(type)); // type-safe
}
}
Class.cast — типобезопасный аналог оператора приведения: проверяет аргумент на принадлежность типу и возвращает его без unchecked warnings. Дополнительный type.cast(instance) в putFavorite ловит вредоносных клиентов, которые передадут raw Class (например, (Class) String.class плюс Integer-инстанс) — иначе инвариант контейнера сломается, а ошибка проявится только на чтении.
Когда применять: когда нужен контейнер с произвольным числом разнотипных значений и доступом по типу — реляционные строки, реестры конфигурации, аннотации (AnnotatedElement.getAnnotation(Class<T>) — типобезопасный гетерогенный контейнер на reflection API).
Tradeoff: два ограничения. Литералы недоступных при выполнении типов запрещены: List<String>.class не существует, поэтому хранить отдельно List<String> и List<Integer> через эту схему нельзя — оба используют один и тот же List.class. Обходного пути нет. Ограниченные токены типа для сужения допустимых ключей делайте через Class<? extends Annotation> плюс Class.asSubclass:
// PREFER — bounded type token + asSubclass для безопасного приведения Class<?>
static Annotation getAnnotation(AnnotatedElement element, String annotationTypeName) {
Class<?> annotationType;
try {
annotationType = Class.forName(annotationTypeName);
} catch (Exception ex) {
throw new IllegalArgumentException(ex);
}
return element.getAnnotation(annotationType.asSubclass(Annotation.class));
}
Гл 6. Перечисления и аннотации
Восемь статей Джошуа Блоха про два специальных семейства ссылочных типов: enum и @interface. Перечисления Java — это полноценные классы, а не обёртки над int: они дают безопасность типов, своё пространство имён, методы и поля. Аннотации заменяют схемы именования вроде testFoo() явным контрактом, проверяемым компилятором.
Каждое правило имеет код вида EJ-N-M (EJ-6-1 = глава 6, статья 1) — на эти коды ссылается AI-скилл ucp-effective-java-review в findings.
Источник: «Effective Java», 3-е издание (2018, рус. перевод «Диалектика», 2019). Каждый рецепт — сжатая выжимка соответствующей статьи Bloch, не пересказ всей главы.
EJ-6-1 Используйте перечисления вместо констант int
Шаблон int enum (public static final int APPLE_FUJI = 0;) хрупок. Компилятор не отличит яблоко от апельсина — оба имеют тип int. Значения «зашиваются» в клиентский код при компиляции — переупорядочили константы, забыли пересобрать клиента, программа продолжит работать с неверными числами. Имена приходится префиксировать (APPLE_, ORANGE_) — нет своего пространства имён. Никакого toString для отладки.
enum Java — это класс, экспортирующий по одному экземпляру на каждую константу через public static final-поля. Конструкторы недоступны клиенту, экземпляров всего столько, сколько объявлено — это обобщённые синглтоны. Безопасность типов гарантирована: Apple принимает только три значения. Можно добавлять методы и поля, реализовывать интерфейсы, переопределять toString.
// PREFER — enum с данными и поведением
public enum Planet {
MERCURY(3.302e+23, 2.439e6),
VENUS (4.869e+24, 6.052e6),
EARTH (5.975e+24, 6.378e6);
private final double mass; // кг
private final double radius; // м
private final double surfaceGravity; // м/с^2
private static final double G = 6.67300E-11;
Planet(double mass, double radius) {
this.mass = mass;
this.radius = radius;
this.surfaceGravity = G * mass / (radius * radius);
}
public double surfaceWeight(double mass) {
return mass * surfaceGravity; // F = ma
}
}
// AVOID — int enum pattern
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int ORANGE_NAVEL = 0;
Constant-specific methods. Когда поведение разное для каждой константы, не используйте switch (this) — хрупко при добавлении значений. Объявите абстрактный метод и переопределите его в теле каждой константы:
// PREFER — поведение, зависимое от константы
public enum Operation {
PLUS("+") { public double apply(double x, double y) { return x + y; } },
MINUS("-") { public double apply(double x, double y) { return x - y; } },
TIMES("*") { public double apply(double x, double y) { return x * y; } },
DIVIDE("/"){ public double apply(double x, double y) { return x / y; } };
private final String symbol;
Operation(String symbol) { this.symbol = symbol; }
public abstract double apply(double x, double y);
@Override public String toString() { return symbol; }
}
Компилятор требует переопределить абстрактный метод в каждой константе — забыть невозможно.
Strategy enum. Если у нескольких констант общее поведение, но не у всех (рабочие/выходные дни оплачиваются по-разному), не дублируйте switch и не злоупотребляйте if-цепочками. Делегируйте вложенному enum-стратегии:
// PREFER — strategy enum
enum PayrollDay {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
SATURDAY(PayType.WEEKEND), SUNDAY(PayType.WEEKEND);
private final PayType payType;
PayrollDay() { this(PayType.WEEKDAY); }
PayrollDay(PayType type) { this.payType = type; }
int pay(int mins, int rate) { return payType.pay(mins, rate); }
private enum PayType {
WEEKDAY { int overtime(int m, int r) {
return m <= MINS ? 0 : (m - MINS) * r / 2;
}},
WEEKEND { int overtime(int m, int r) { return m * r / 2; }};
abstract int overtime(int m, int r);
private static final int MINS = 8 * 60;
int pay(int m, int r) { return m * r + overtime(m, r); }
}
}
Добавление нового дня требует выбрать стратегию явно — компилятор не даст «забыть».
Когда применять: всегда, когда нужен набор констант, известный во время компиляции — планеты, дни недели, коды операций, флаги меню. Множество не обязано быть неизменным — enum спроектирован под бинарно-совместимое расширение. Если перечисление не привязано к конкретному классу, объявляйте его типом верхнего уровня (java.math.RoundingMode).
Tradeoff: загрузка и инициализация enum требуют времени и памяти, но на практике это незаметно. switch уместен только для дополнения поведения извне (например, inverse(Operation) для не подконтрольного вам перечисления).
EJ-6-2 Используйте поля экземпляров вместо порядковых значений
У каждой константы enum есть метод ordinal(), возвращающий её позицию в объявлении (с нуля). Соблазн: вывести связанное int-значение из ordinal() — например, размер ансамбля.
// AVOID — числовое значение через ordinal
public enum Ensemble {
SOLO, DUET, TRIO, QUARTET, QUINTET,
SEXTET, SEPTET, OCTET, NONET, DECTET;
public int numberOfMusicians() { return ordinal() + 1; }
}
Кошмар сопровождения: переупорядочили константы — numberOfMusicians сломался. Хотите добавить двойной квартет (тоже 8 музыкантов) рядом с OCTET — нельзя, у каждой константы свой ordinal. Хотите пропустить значения (UNDECTET для 11 не существует как термин) — придётся вставлять фиктивные константы.
Решение — хранить значение в поле экземпляра, переданном через конструктор:
// PREFER — instance field
public enum Ensemble {
SOLO(1), DUET(2), TRIO(3), QUARTET(4), QUINTET(5),
SEXTET(6), SEPTET(7), OCTET(8), DOUBLE_QUARTET(8),
NONET(9), DECTET(10), TRIPLE_QUARTET(12);
private final int numberOfMusicians;
Ensemble(int size) { this.numberOfMusicians = size; }
public int numberOfMusicians() { return numberOfMusicians; }
}
Когда применять: всегда, когда константе нужно связанное число. Спецификация Enum прямо говорит про ordinal(): «большинству программистов этот метод никогда не понадобится. Он предназначен для структур данных общего назначения вроде EnumSet и EnumMap».
Tradeoff: нет — в прикладном коде использование ordinal() почти всегда ошибка. Если вы пишете обобщённую структуру данных (как EnumMap внутри), это законно, в остальных случаях избегайте.
EJ-6-3 Используйте EnumSet вместо битовых полей
Когда константы перечисления используются преимущественно во множествах, традиционно применялся шаблон битового поля: каждой константе — степень двойки, объединение через |.
// AVOID — битовое поле
public class Text {
public static final int STYLE_BOLD = 1 << 0;
public static final int STYLE_ITALIC = 1 << 1;
public static final int STYLE_UNDERLINE = 1 << 2;
public static final int STYLE_STRIKETHROUGH = 1 << 3;
public void applyStyles(int styles) { ... }
}
text.applyStyles(STYLE_BOLD | STYLE_ITALIC);
Это унаследует все недостатки int enum плюс свои: 42 в логе нечитаем, ширина битового поля (32 или 64) фиксируется навсегда — превысите её, и сломаете API.
java.util.EnumSet представляет множество значений одного enum-типа. Внутри это битовый вектор (long для перечислений ≤ 64 элементов — RegularEnumSet; массив long-ов для бо́льших — JumboEnumSet), производительность сравнима с битовыми полями, но снаружи — полноценный Set с типобезопасностью и операциями коллекций.
// PREFER — EnumSet
public class Text {
public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }
// принимаем Set, а не EnumSet — клиент может передать другую реализацию
public void applyStyles(Set<Style> styles) { ... }
}
text.applyStyles(EnumSet.of(Style.BOLD, Style.ITALIC));
Когда применять: всегда, когда вы рассматривали битовое поле. Параметр объявляйте как Set<Style>, а не EnumSet<Style> — следуйте принципу «принимайте интерфейс, а не реализацию».
Tradeoff: до Java 9 нет встроенного неизменяемого EnumSet. Если нужен immutable, оборачивайте через Collections.unmodifiableSet (с потерей краткости) или используйте Guava Sets.immutableEnumSet.
EJ-6-4 Используйте EnumMap вместо индексирования порядковыми номерами
Соблазн: имея enum LifeCycle { ANNUAL, PERENNIAL, BIENNIAL }, разложить растения по жизненным циклам в массив, индексируемый ordinal():
// AVOID — индексация массива через ordinal
Set<Plant>[] plantsByLifeCycle =
(Set<Plant>[]) new Set[Plant.LifeCycle.values().length];
for (int i = 0; i < plantsByLifeCycle.length; i++)
plantsByLifeCycle[i] = new HashSet<>();
for (Plant p : garden)
plantsByLifeCycle[p.lifeCycle.ordinal()].add(p);
Проблем три. Массив несовместим с обобщёнными типами — нужно непроверяемое приведение и предупреждения. При выводе придётся вручную помечать индексы строками. Самое опасное — ответственность за корректность int-индексов лежит на вас: ошиблись ordinal-ом — ArrayIndexOutOfBoundsException в лучшем случае, молчаливо неверный результат в худшем.
java.util.EnumMap — высокопроизводительная реализация Map с ключами-enum. Внутри тот же массив, индексируемый ordinal, но это деталь реализации.
// PREFER — EnumMap
Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycle =
new EnumMap<>(Plant.LifeCycle.class);
for (Plant.LifeCycle lc : Plant.LifeCycle.values())
plantsByLifeCycle.put(lc, new HashSet<>());
for (Plant p : garden)
plantsByLifeCycle.get(p.lifeCycle).add(p);
Скорость как у массива, типобезопасность как у Map, читаемый вывод. Конструктор принимает Class-литерал — это ограниченный токен типа (раздел 5.8), нужный для рефлексии в рантайме.
Для многомерных отношений (фазовый переход Phase × Phase → Transition) используйте вложенный EnumMap<..., EnumMap<..., V>>, а не двумерный массив ordinal × ordinal — добавление новой фазы (PLASMA) сводится к правке только enum, никаких хрупких таблиц переходов.
Когда применять: всегда, когда соблазн индексировать массив через ordinal().
Tradeoff: stream-вариант через Collectors.groupingBy(p -> p.lifeCycle) без явного mapFactory создаст HashMap, не EnumMap — проиграете в памяти и скорости. Передавайте () -> new EnumMap<>(...) третьим аргументом.
EJ-6-5 Имитируйте расширяемые перечисления с помощью интерфейсов
enum не может наследовать другой enum — это сознательное ограничение языка: расширяемые перечисления запутывают (элементы расширения являются членами базового типа, но не наоборот) и ломают перебор всех значений. Но иногда расширяемость нужна — например, для кодов операций калькулятора, где пользователь API хочет добавить свои операции.
Решение — определить интерфейс, реализовать его базовым enum, а расширения — другими enum, реализующими тот же интерфейс. Базовое перечисление не расширяется — расширяется тип интерфейса.
// PREFER — расширяемое перечисление через интерфейс
public interface Operation {
double apply(double x, double y);
}
public enum BasicOperation implements Operation {
PLUS("+") { public double apply(double x, double y) { return x + y; } },
MINUS("-") { public double apply(double x, double y) { return x - y; } },
TIMES("*") { public double apply(double x, double y) { return x * y; } },
DIVIDE("/"){ public double apply(double x, double y) { return x / y; } };
private final String symbol;
BasicOperation(String symbol) { this.symbol = symbol; }
@Override public String toString() { return symbol; }
}
// Клиент API расширяет набор операций
public enum ExtendedOperation implements Operation {
EXP("^") { public double apply(double x, double y) { return Math.pow(x, y); } },
REMAINDER("%"){ public double apply(double x, double y) { return x % y; } };
private final String symbol;
ExtendedOperation(String symbol) { this.symbol = symbol; }
@Override public String toString() { return symbol; }
}
Метод, перебирающий все значения расширенного перечисления, принимает ограниченный токен типа <T extends Enum<T> & Operation> — это и enum, и реализация интерфейса:
private static <T extends Enum<T> & Operation>
void test(Class<T> opEnumType, double x, double y) {
for (Operation op : opEnumType.getEnumConstants())
System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
}
// вызов
test(ExtendedOperation.class, 4, 2);
Альтернатива — Collection<? extends Operation> вместо Class<T>: гибче (можно объединять несколько расширений), но теряете доступ к EnumSet/EnumMap для этих операций.
Когда применять: в API, где клиенты должны добавлять собственные значения enum (плагины, расширяемые DSL, коды операций). Так сделано в JDK — java.nio.file.LinkOption реализует CopyOption и OpenOption.
Tradeoff: реализации не наследуются между enum — общий код приходится дублировать или выносить во вспомогательный статический метод. В случае BasicOperation/ExtendedOperation дублируется хранение symbol — терпимо.
EJ-6-6 Предпочитайте аннотации схемам именования
Историческая практика — помечать программные элементы соглашением об имени. JUnit 3 требовал, чтобы тестовые методы начинались с test. У схемы три проблемы. Опечатка молчит: tsetSafety() вместо testSafety() — JUnit 3 пропустит тест, а вы решите, что всё хорошо. Нет способа гарантировать применимость к правильному виду элементов: класс TestSafetyMechanisms — это попытка сказать «прогони все методы», но JUnit 3 проигнорирует. И нет способа связать с элементом параметры (тип ожидаемого исключения) — закодировать его в имени метода ненадёжно.
Аннотации решают всё это: явно объявляются, проверяются компилятором (через @Target), несут параметры. Своя аннотация-маркер для теста:
// PREFER — собственная аннотация-маркер
import java.lang.annotation.*;
@Retention(RetentionPolicy.RUNTIME) // видна в рантайме (для рефлексии)
@Target(ElementType.METHOD) // только на методах
public @interface Test { }
@Target(METHOD) — компилятор не даст применить @Test к классу или полю. @Retention(RUNTIME) — аннотация доступна во время выполнения через рефлексию.
Обработчик прост:
Class<?> testClass = Class.forName(args[0]);
for (Method m : testClass.getDeclaredMethods()) {
if (m.isAnnotationPresent(Test.class)) {
try { m.invoke(null); }
catch (InvocationTargetException wrapped) {
System.out.println(m + " failed: " + wrapped.getCause());
}
}
}
Аннотация с параметром (тип ожидаемого исключения):
@Retention(RUNTIME) @Target(METHOD)
public @interface ExceptionTest {
Class<? extends Throwable> value();
}
@ExceptionTest(ArithmeticException.class)
public static void m1() { int i = 1 / 0; }
Массив типов исключений (любой из них считается успехом):
@Retention(RUNTIME) @Target(METHOD)
public @interface ExceptionTest {
Class<? extends Exception>[] value();
}
@ExceptionTest({IndexOutOfBoundsException.class, NullPointerException.class})
public static void doublyBad() { ... }
Java 8+ — @Repeatable позволяет применять одну и ту же аннотацию несколько раз вместо параметра-массива. Удобнее читать, но обработка сложнее: повторяющиеся генерируют синтетический контейнер, isAnnotationPresent(ExceptionTest.class) вернёт false — проверяйте и тип, и тип-контейнер.
Когда применять: всегда, когда инструменту нужен особый маркер на элементе кода. Просто не существует причин использовать схемы именования, когда можно использовать аннотации. Большинству программистов своих аннотаций определять не придётся, но все обязаны использовать готовые (@Override, @SuppressWarnings, @Deprecated, аннотации IDE и статических анализаторов).
Tradeoff: аннотация без процессора javax.annotation.processing не проверяет инварианты, выходящие за @Target (например, «только статические методы без параметров») — нарушение всплывёт во время выполнения теста, не компиляции.
В нашей кодовой базе: не актуально. JUnit 5 (@Test, @ParameterizedTest), Spring (@Service, @Component), Bean Validation, MapStruct — все наши инструменты работают на аннотациях, схем именования (вроде JUnit 3 testFoo()) у нас нет. Bloch объясняет почему.
EJ-6-7 Последовательно используйте аннотацию @Override
@Override ставится на методе, который, по вашему намерению, перекрывает метод суперкласса (или интерфейса). Если намерение не выполняется — компилятор выдаст ошибку. Это защищает от целого класса «тихих» ошибок, особенно с equals и hashCode.
Классический пример — биграмма:
// AVOID — перегрузка вместо переопределения
public class Bigram {
private final char first, second;
public Bigram(char first, char second) { ... }
public boolean equals(Bigram b) { // ← НЕ Override; параметр Bigram, не Object
return b.first == first && b.second == second;
}
public int hashCode() { return 31 * first + second; }
}
Программа добавляет в HashSet 26 биграмм, каждая повторена 10 раз — ожидается 26, печатается 260. equals(Bigram) не переопределяет Object.equals(Object) — это перегрузка. HashSet вызывает унаследованный Object.equals (тождественность ссылок). Аннотация @Override в момент компиляции выдала бы method does not override or implement a method from a supertype.
Когда применять: на каждом методе, который, по вашему намерению, перекрывает объявление суперкласса или интерфейса. Современные IDE добавляют @Override автоматически — оставляйте.
Tradeoff: одно исключение — конкретный метод в неабстрактном классе, перекрывающий абстрактный метод суперкласса. Компилятор и так заставит реализовать абстрактный метод, @Override не добавит проверки. Но и не повредит. В абстрактных классах и интерфейсах аннотируйте все методы, которые перекрывают суперклассовые, конкретные или абстрактные — это страховка от случайного добавления нового метода в супертип (как в Set, который не добавляет ничего к Collection).
В нашей кодовой базе: IntelliJ ставит @Override автоматически при override через диалог «Generate → Override Methods», и подсвечивает её отсутствие инспекцией «Missing @Override annotation». Checkstyle тоже ловит. Ручной контроль из рецепта не нужен.
EJ-6-8 Используйте интерфейсы-маркеры для определения типов
Marker interface — интерфейс без методов, утверждающий, что класс-реализация обладает свойством. Канонический пример — java.io.Serializable: реализуя его, класс заявляет, что его экземпляры можно записать в ObjectOutputStream.
После появления marker-аннотаций (раздел 6.6) утверждали, что marker-интерфейсы устарели. Это неверно. У интерфейсов-маркеров два преимущества.
Они определяют тип. Marker-аннотация — нет. Интерфейс-маркер позволяет ловить ошибки во время компиляции: метод, принимающий только маркированные объекты, объявляет параметр типа маркера — и компилятор не даст передать ничего другого. С marker-аннотацией ошибка проявится только в рантайме при попытке прочитать аннотацию рефлексией. (К сожалению, ObjectOutputStream.writeObject принимает Object, а не Serializable — упущенная возможность, исправить нельзя из-за совместимости.)
Они точнее наводятся. Marker-аннотация с @Target(TYPE) применима к любому классу или интерфейсу. Если ваш маркер должен применяться только к подтипам некоторого интерфейса, объявите интерфейс-маркер расширяющим этот интерфейс — компилятор гарантирует, что все маркированные типы будут также подтипами базового. Set в каком-то смысле — ограниченный marker-интерфейс: применим только к подтипам Collection, не добавляет методов, но уточняет контракты.
Главное преимущество marker-аннотаций — они часть более мощной системы аннотаций. Если вы строите каркас, активно использующий аннотации (Spring, JUnit, JPA), marker-аннотация ложится в ту же систему — выбирайте её для согласованности.
// PREFER (marker interface) — когда маркер «является типом»
public interface Serializable { } // из java.io
// PREFER (marker annotation) — когда маркер вне иерархии типов
@Retention(RUNTIME) @Target(TYPE)
public @interface Entity { } // JPA — объявление сущности
Когда применять marker-интерфейс:
- маркер применим только к классам и интерфейсам (не к методам, полям, пакетам);
- вы хотите писать методы, принимающие только маркированные объекты, — то есть использовать маркер как тип параметра с проверкой на компиляции;
- маркер уточняет инвариант объекта или говорит, что экземпляры обрабатываются специальным методом другого класса (как
SerializableдляObjectOutputStream).
Когда применять marker-аннотацию: маркер применяется к элементам кода вне классов/интерфейсов; маркер — часть аннотационного каркаса; вы уверены, что не захотите писать методы, принимающие только помеченные объекты.
Tradeoff: интерфейс-маркер — это интерфейс, его можно реализовать «случайно» в подклассе и обратно убрать сложно. Marker-аннотация снимается простым удалением аннотации — менее обязывающее решение для эволюционирующих API.
Эта статья — зеркало EJ-4-8 («Используйте интерфейсы только для определения типов»). Здесь обратное: если вы хотите определить тип, используйте интерфейс.
Гл 7. Лямбда-выражения и потоки
Java 8 (2014) добавила в язык лямбда-выражения, ссылки на методы и Stream API. Это изменило идиоматический стиль сильнее, чем любой другой релиз: операции, для которых раньше требовались анонимные классы и циклы, теперь укладываются в одну декларативную строку. Но новые средства легко применить не по делу — превратить читаемый цикл в нечитаемый конвейер, попытаться ускорить программу parallel() и получить замедление в десять раз. Семь статей этой главы — про то, где лямбды и потоки выигрывают, а где портят код.
Каждое правило имеет код вида EJ-N-M (EJ-7-1 = глава 7, статья 1) — на эти коды ссылается AI-скилл ucp-effective-java-review в findings.
Источник: «Effective Java», 3-е издание (2018, рус. перевод «Диалектика», 2019). Глава 7 целиком новая — её не было во 2-м издании. Каждый рецепт — сжатая выжимка статьи Bloch, не пересказ всей главы.
EJ-7-1 Предпочитайте лямбда-выражения анонимным классам
Интерфейсы с одним абстрактным методом (Comparator, Runnable, Callable) исторически инстанцировались анонимными классами. С Java 8 такие интерфейсы называются функциональными и создаются лямбда-выражениями — функционально эквивалентными, но в разы более краткими. Лишний синтаксис убран, поведение становится очевидным.
// AVOID — анонимный класс как функциональный объект (устаревший стиль)
Collections.sort(words, new Comparator<String>() {
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});
// PREFER — лямбда: типы Comparator<String>, s1, s2, int выводятся компилятором
Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));
// PREFER — ещё короче через Comparator.comparingInt + ссылку на метод
Collections.sort(words, comparingInt(String::length));
// PREFER — и ещё короче через List.sort (Java 8)
words.sort(comparingInt(String::length));
Когда применять: для всех функциональных объектов небольшого размера — компараторы, обработчики событий, реализации Runnable / Callable, тела констант перечислений (передавайте лямбду в конструктор и сохраняйте в поле, см. enum Operation ниже).
Tradeoff: идеальная длина лямбды — одна строка, разумный максимум — три. Длинная лямбда наносит ущерб удобочитаемости — упростите вычисление, извлеките его в именованный метод (см. EJ-7-2) или останьтесь на старом подходе. Лямбды не могут заменить анонимный класс в трёх случаях: (1) если нужен экземпляр абстрактного класса или интерфейса с несколькими абстрактными методами; (2) если требуется ссылка this на сам функциональный объект — внутри лямбды this указывает на охватывающий экземпляр; (3) если нужны ссылки на нефинальные локальные переменные — лямбда видит только эффективно финальные. Не сериализуйте лямбды и анонимные классы — используйте приватный статический вложенный класс. И не опускайте обобщённые типы у переменных-источников: компилятор без них не выведет тип параметров лямбды.
// PREFER — enum с поведением через лямбды (вместо тел классов на каждой константе)
public enum Operation {
PLUS ("+", (x, y) -> x + y),
MINUS ("-", (x, y) -> x - y),
TIMES ("*", (x, y) -> x * y),
DIVIDE("/", (x, y) -> x / y);
private final String symbol;
private final DoubleBinaryOperator op;
Operation(String symbol, DoubleBinaryOperator op) {
this.symbol = symbol;
this.op = op;
}
public double apply(double x, double y) {
return op.applyAsDouble(x, y);
}
}
EJ-7-2 Предпочитайте ссылки на методы лямбда-выражениям
Главное преимущество лямбды над анонимным классом — краткость. Ссылка на метод (method reference) ещё короче и часто читается лучше: имя метода сразу говорит, что происходит, тогда как лямбда требует пробежать глазами параметры и тело.
// AVOID — лямбда с шаблонным телом
map.merge(key, 1, (count, incr) -> count + incr);
// PREFER — ссылка на метод: имя Integer::sum документирует операцию
map.merge(key, 1, Integer::sum);
Когда применять: там, где ссылка на метод короче или яснее лямбды. Среда разработки обычно сама предлагает замену — обычно (но не всегда) принимайте предложение. Пять разновидностей ссылок:
| Тип | Пример | Эквивалентная лямбда |
|---|---|---|
| Статическая | Integer::parseInt | str -> Integer.parseInt(str) |
| Ограниченная (bound) | Instant.now()::isAfter | Instant then = Instant.now(); t -> then.isAfter(t) |
| Неограниченная (unbound) | String::toLowerCase | str -> str.toLowerCase() |
| Конструктор класса | TreeMap<K,V>::new | () -> new TreeMap<K,V>() |
| Конструктор массива | int[]::new | len -> new int[len] |
Tradeoff: иногда лямбда читается лучше — особенно когда метод находится в том же классе и его имя ничего не добавляет, или когда параметры лямбды служат полезной документацией:
// AVOID — ссылка не короче и не яснее
service.execute(GoshThisClassNameIsHumongous::action);
// PREFER — лямбда нагляднее
service.execute(() -> action());
// AVOID — Function.identity() длиннее и не информативнее
stream.map(Function.identity());
// PREFER — встроенная лямбда
stream.map(x -> x);
Если лямбда оказывается слишком длинной или сложной — извлеките её в именованный метод и сошлитесь на него. Дайте методу описательное имя и подробную документацию — это и есть основная польза от метода как функционального объекта.
EJ-7-3 Предпочитайте использовать стандартные функциональные интерфейсы
Пакет java.util.function предоставляет 43 стандартных функциональных интерфейса. Если один из них выполняет нужную работу, используйте его, а не пишите собственный — клиентский код становится легче изучить, а ваш API получает методы по умолчанию (Predicate.and, Function.andThen).
Шесть базовых интерфейсов покрывают подавляющее большинство сценариев — остальные 37 выводятся из них:
| Интерфейс | Сигнатура | Пример |
|---|---|---|
UnaryOperator<T> | T apply(T t) | String::toLowerCase |
BinaryOperator<T> | T apply(T t1, T t2) | BigInteger::add |
Predicate<T> | boolean test(T t) | Collection::isEmpty |
Function<T,R> | R apply(T t) | Arrays::asList |
Supplier<T> | T get() | Instant::now |
Consumer<T> | void accept(T t) | System.out::println |
Для примитивов int / long / double есть по три варианта каждого базового интерфейса — IntPredicate, LongBinaryOperator, DoubleFunction<R>, LongToIntFunction и так далее. Для двух аргументов — BiPredicate<T,U>, BiFunction<T,U,R>, BiConsumer<T,U> плюс примитивные варианты результата (ToIntBiFunction<T,U> и т.д.). Отдельно стоит BooleanSupplier.
Когда применять: всегда, кроме редких случаев, описанных ниже. Не используйте базовые интерфейсы с упакованными примитивами там, где есть примитивный вариант — производительность для массовых операций может оказаться смертельно опасной.
Tradeoff: свой интерфейс пишите, если выполняется хотя бы одно из условий: (1) ни один стандартный не подходит структурно (например, нужен Predicate, бросающий проверяемое исключение); (2) интерфейс структурно совпадает со стандартным, но имеет три признака Comparator — описательное имя приносит пользу, со интерфейсом связан строгий контракт, он выигрывает от методов по умолчанию. Всегда аннотируйте свои функциональные интерфейсы @FunctionalInterface — она аналогична @Override по духу: документирует намерение, заставляет компилятор проверить наличие ровно одного абстрактного метода, не даёт случайно расширить интерфейс. Не предоставляйте перегрузки методов с разными функциональными интерфейсами в одной позиции — клиент столкнётся с неоднозначностью при разрешении (ExecutorService.submit принимает и Callable<T>, и Runnable — это создаёт проблемы для пользователей).
// AVOID — собственный интерфейс там, где подойдёт BiPredicate<Map<K,V>, Map.Entry<K,V>>
@FunctionalInterface interface EldestEntryRemovalFunction<K,V> {
boolean remove(Map<K,V> map, Map.Entry<K,V> eldest);
}
// PREFER — стандартный BiPredicate
BiPredicate<Map<K,V>, Map.Entry<K,V>> shouldRemove = (m, e) -> m.size() > 100;
EJ-7-4 Разумно используйте потоки
Stream API упрощает массовые операции — последовательные и параллельные. Поток (Stream) — конечная или бесконечная последовательность элементов; конвейер потока — состоит из исходного потока, нуля или более промежуточных операций (map, filter, sorted) и одной завершающей (forEach, collect, reduce). Конвейеры вычисляются отложенно — без завершающей операции вычисление не запускается.
// PREFER — итеративная версия (читается мгновенно)
public class Anagrams {
public static void main(String[] args) throws IOException {
File dictionary = new File(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
Map<String, Set<String>> groups = new HashMap<>();
try (Scanner s = new Scanner(dictionary)) {
while (s.hasNext()) {
String word = s.next();
groups.computeIfAbsent(alphabetize(word),
unused -> new TreeSet<>()).add(word);
}
}
for (Set<String> group : groups.values())
if (group.size() >= minGroupSize)
System.out.println(group.size() + ": " + group);
}
}
// AVOID — злоупотребление потоками: всё в одном выражении, нечитаемо
words.collect(
groupingBy(word -> word.chars().sorted()
.collect(StringBuilder::new,
(sb, c) -> sb.append((char) c),
StringBuilder::append).toString()))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.map(group -> group.size() + ": " + group)
.forEach(System.out::println);
// PREFER — золотая середина: поток + вспомогательный метод alphabetize
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(groupingBy(word -> alphabetize(word)))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.forEach(g -> System.out.println(g.size() + ": " + g));
}
Когда применять: потоки выигрывают, если вычисление сводится к (1) единообразному преобразованию последовательностей, (2) фильтрации, (3) объединению одной операцией (sum, min, конкатенация), (4) накоплению в коллекцию с группировкой, (5) поиску элемента по критерию.
Tradeoff: потоки проигрывают, если в теле нужно (1) читать или менять локальные переменные охватывающего метода — лямбда видит только эффективно финальные; (2) делать return, break, continue или бросать проверяемое исключение — из лямбды это невозможно. Потоки плохо справляются с одновременным доступом к нескольким стадиям конвейера — после map исходное значение теряется, а пары (было, стало) ведут к беспорядочному коду. Тщательно именуйте параметры лямбд: в конвейере нет явных типов временных переменных — g мало что говорит, group гораздо лучше. Извлекайте подсчёты в именованные методы (alphabetize) — это даёт типу собственное имя и убирает детали. Воздерживайтесь от обработки char через потоки — "x".chars() возвращает IntStream, а не поток символов: легко получить вывод чисел вместо букв. Если не уверены, какая версия лучше — попробуйте обе и сравните.
EJ-7-5 Предпочитайте в потоках функции без побочных эффектов
Поток — не просто API, а парадигма, основанная на функциональном программировании. Чтобы получить выразительность и возможность параллелизации, любые функциональные объекты в конвейере (промежуточные и завершающие) должны быть чистыми — результат зависит только от входов, состояние не меняется.
// AVOID — потоковый API, но не потоковая парадигма: forEach мутирует HashMap снаружи
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
words.forEach(word -> {
freq.merge(word.toLowerCase(), 1L, Long::sum);
});
}
// PREFER — корректное использование: collect(groupingBy(..., counting()))
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
freq = words.collect(groupingBy(String::toLowerCase, counting()));
}
Когда применять: для всех чистых преобразований используйте коллекторы из java.util.stream.Collectors (статически импортируйте всё — конвейеры читаются заметно лучше). Самые важные коллекторы: toList(), toSet(), toMap(), groupingBy(), joining(). Завершающую операцию forEach используйте только для вывода результата — например, forEach(System.out::println), — но не для выполнения вычислений.
// PREFER — топ-10 слов по частоте через Collectors.toList и Comparator.comparing
List<String> topTen = freq.keySet().stream()
.sorted(comparing(freq::get).reversed())
.limit(10)
.collect(toList());
// PREFER — toMap для построения отображения
Map<String, Operation> stringToEnum = Stream.of(values())
.collect(toMap(Object::toString, e -> e));
// PREFER — toMap с функцией слияния (стратегия «последний победил»)
toMap(keyMapper, valueMapper, (v1, v2) -> v2);
// PREFER — joining для строк
List.of("came", "saw", "conquered").stream()
.collect(joining(", ", "[", "]")); // "[came, saw, conquered]"
Tradeoff: forEach(stuff::add) — антипаттерн, выдаёт итеративный код, замаскированный под потоковый. Иногда forEach оправдан, если результаты добавляются в уже имеющуюся коллекцию по другой причине, но эти случаи редки. У groupingBy есть варианты со встречным коллектором: groupingBy(classifier, counting()) — частотная таблица; groupingBy(classifier, toSet()) — категории с множествами вместо списков; groupingByConcurrent для параллельных конвейеров. У Collectors 39 методов — большинство можно игнорировать; запомните toList, toSet, toMap, groupingBy, joining.
EJ-7-6 Предпочитайте коллекции потокам в качестве возвращаемых типов
Метод, возвращающий последовательность, должен поддерживать оба сценария использования — итерацию for-each и потоковую обработку. Stream не реализует Iterable (хотя его сигнатура iterator() совместима), а Iterable не предоставляет stream(). Collection (или подходящий подтип) — лучший возвращаемый тип в общем случае: это и Iterable, и источник stream().
// AVOID — возвращать Stream, если клиент захочет итерироваться
public Stream<ProcessHandle> activeProcesses() {
return ProcessHandle.allProcesses();
}
// AVOID — обходной путь через приведение, нечитаемо
for (ProcessHandle ph : (Iterable<ProcessHandle>)
ProcessHandle.allProcesses()::iterator) { ... }
// PREFER — адаптер Stream → Iterable (если изменить сигнатуру нельзя)
public static <E> Iterable<E> iterableOf(Stream<E> stream) {
return stream::iterator;
}
// PREFER — адаптер Iterable → Stream
public static <E> Stream<E> streamOf(Iterable<E> iterable) {
return StreamSupport.stream(iterable.spliterator(), false);
}
// PREFER — возвращать Collection: и for-each, и stream() работают «из коробки»
public Collection<ProcessHandle> activeProcesses() { ... }
Когда применять: для публичных API, возвращающих последовательность. Если последовательность достаточно мала, чтобы свободно поместиться в памяти, возвращайте стандартную реализацию — ArrayList или HashSet.
Tradeoff: не храните в памяти большую последовательность только ради возврата как коллекции. Если последовательность велика, но имеет сжатое представление, реализуйте специальную коллекцию поверх AbstractList / AbstractCollection — нужно реализовать contains и size сверх требуемого Iterator. Пример — показательное множество (powerset) множества из n элементов: содержит 2ⁿ подмножеств, каждое индексируется битовым вектором. Если ни эффективная коллекция, ни умещение в память невозможны — выбирайте между Stream и Iterable по тому, что естественнее для домена. Если знаете, что метод используется только в конвейере — верните Stream. Если только для итерации — Iterable. Когда сомневаетесь — Collection.
EJ-7-7 Будьте внимательны при параллелизации потоков
Добавить parallel() в конвейер легко — получить ускорение трудно. Не распараллеливайте огульно конвейеры потоков: последствия для производительности могут быть катастрофическими. Параллелизация — это оптимизация, а оптимизацию обязательно подтверждайте измерением до и после.
// AVOID — parallel() убивает программу: Stream.iterate + limit не разделяются
public static void main(String[] args) {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.parallel()
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(System.out::println);
}
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
// PREFER — параллелизация уместна: LongStream.rangeClosed разделяется,
// reduce-операция (count) ассоциативна, объект работы достаточно велик
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.parallel()
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}
Когда применять: выигрыш от параллелизации наибольший, если выполнены все три условия. (1) Источник — ArrayList, HashMap, HashSet, ConcurrentHashMap, массив, диапазон int / long: они дёшево делятся на поддиапазоны через Spliterator и обладают хорошей локальностью ссылок. (2) Завершающая операция — приведение (reduce, min, max, count, sum) или короткозамкнутая (anyMatch, allMatch, noneMatch). (3) Объём работы достаточен для компенсации накладных расходов: грубая оценка — количество элементов, умноженное на количество строк кода на элемент, — не меньше 100 000. Для случайных чисел используйте SplittableRandom, не ThreadLocalRandom (предназначен для одного потока) и тем более не Random (синхронизируется на каждой операции).
Tradeoff: параллелизация ухудшает дело для источников Stream.iterate (нельзя дёшево разделить) и для конвейеров с промежуточной операцией limit (надеется, что лишние элементы вычислятся «бесплатно»). Завершающие изменяющие приведения (collect) — плохие кандидаты: накладные расходы на объединение коллекций высоки. Если функциональные объекты в reduce / map / filter не ассоциативны, не свободны от состояния и взаимодействуют между собой — параллельный конвейер даст неверные результаты (ошибки безопасности), и выявить это последовательным прогоном невозможно. Порядок вывода forEach в параллельном потоке не сохраняется — используйте forEachOrdered, если порядок важен. Если ваш код корректен, а измерения подтверждают ускорение — тогда и только тогда распараллеливайте поток в рабочем коде.
Гл 8. Методы
Восемь статей Джошуа Блоха про проектирование методов: как обращаться с параметрами и возвращаемыми значениями, как формировать сигнатуру, что документировать. Глава фокусируется на удобстве использования, надёжности и гибкости — тех же качествах, что и глава про классы и интерфейсы, но в масштабе одного метода.
Каждое правило имеет код вида EJ-N-M (EJ-8-1 = глава 8, статья 1) — на эти коды ссылается AI-скилл ucp-effective-java-review в findings.
Источник: «Effective Java», 3-е издание (2018, рус. перевод «Диалектика», 2019). Каждый рецепт — сжатая выжимка соответствующей статьи Bloch, не пересказ всей главы.
EJ-8-1 Проверяйте корректность параметров
Большинство методов и конструкторов накладывают ограничения на параметры: индекс должен быть неотрицательным, ссылка — не null, число — положительным. Документируйте эти ограничения и проверяйте их в самом начале метода. Это частный случай fail-fast: чем раньше обнаружена ошибка, тем дешевле её диагностика. Иначе неверный параметр уйдёт вглубь, и метод либо завершится непонятным исключением, либо вернёт неправильный результат, либо — что хуже всего — оставит объект в испорченном состоянии и нарушит атомарность сбоев (см. EJ-10-8).
// PREFER — Java 7+: Objects.requireNonNull для public API
public final class Strategy {
private final Strategy delegate;
public Strategy(Strategy delegate) {
this.delegate = Objects.requireNonNull(delegate, "delegate");
}
}
// PREFER — явная проверка диапазона + документирование @throws
/**
* Возвращает остаток от деления this на m.
* @param m модуль; должен быть положительным
* @return this mod m
* @throws ArithmeticException если m <= 0
*/
public BigInteger mod(BigInteger m) {
if (m.signum() <= 0)
throw new ArithmeticException("Модуль <= 0: " + m);
// ...
}
// PREFER — для private методов используйте assertions
private static void sort(long[] a, int offset, int length) {
assert a != null;
assert offset >= 0 && offset <= a.length;
assert length >= 0 && length <= a.length - offset;
// ...
}
Когда применять: для каждого public/protected метода и конструктора. Обязательные исключения для нарушения контракта — IllegalArgumentException, IndexOutOfBoundsException, NullPointerException. Для private методов, где вы контролируете все вызовы, используйте assert (стоимость нулевая при выключенных утверждениях). Java 9+ — в Objects появились checkFromIndexSize, checkFromToIndex, checkIndex для индексных проверок.
Tradeoff: особенно важно проверять параметры, которые сохраняются для последующего использования (поля, кэши). Если вы примете null в конструкторе и сохраните его в поле, исключение всплывёт через час в чужом коде, и связь с источником будет потеряна. Исключение из правила: проверка дорогая, а параметр всё равно проверится в процессе вычислений (Collections.sort(List) не делает превентивную проверку взаимной сравнимости — compareTo сам бросит ClassCastException). Не превращайте методы в крепость с десятком ограничений — лучше делать их максимально общими; накладывайте только то, что действительно требуется абстракцией. См. EJ-8-8 про документирование @throws.
В нашей кодовой базе: валидация на границе use case — Bean Validation (@NotNull, @Size, @Positive, @Pattern) на DTO + Spring @Validated на контроллере. Для не-public методов — Lombok @NonNull (генерирует Objects.requireNonNull в начале метода). Полный корпус правил — Validation Style Guide.
EJ-8-2 При необходимости создавайте защитные копии
Java — безопасный язык: переполнения буфера и неконтролируемые указатели исключены. Но это не защищает от клиентов, которые могут — намеренно или случайно — нарушить инварианты вашего класса через изменяемые параметры или возвращаемые значения. Пишите классы оборонительно: исходите из того, что клиент попытается сломать инвариант.
// AVOID — «неизменяемый» Period, который ломается через mutable Date
public final class Period {
private final Date start;
private final Date end;
public Period(Date start, Date end) {
if (start.compareTo(end) > 0) throw new IllegalArgumentException();
this.start = start; // клиент держит ссылку
this.end = end;
}
public Date start() { return start; } // отдали наружу — теперь можно мутировать
}
// Атака:
Date end = new Date();
Period p = new Period(new Date(), end);
end.setYear(78); // инвариант сломан
p.end().setYear(78); // вторая атака — через accessor
// PREFER — defensive copy и в конструкторе, и в accessor'е
public Period(Date start, Date end) {
this.start = new Date(start.getTime()); // копия ДО проверки
this.end = new Date(end.getTime());
if (this.start.compareTo(this.end) > 0) // проверка над копией!
throw new IllegalArgumentException();
}
public Date start() { return new Date(start.getTime()); }
public Date end() { return new Date(end.getTime()); }
Когда применять: всякий раз, когда класс хранит во внутренней структуре или возвращает наружу ссылку на изменяемый объект, предоставленный клиентом. Массивы ненулевой длины — всегда изменяемые, копируйте их или возвращайте неизменяемое представление.
Tradeoff: порядок операций критичен — копия делается до проверки параметров, иначе остаётся окно уязвимости (TOCTOU, time-of-check/time-of-use): другой поток мутирует параметр между проверкой и копированием. Не используйте clone() для копий параметров, тип которых допускает наследование (Date не final — подкласс clone() может вернуть подделку). Java 8+ — самый чистый рецепт: используйте immutable Instant, LocalDate, LocalDateTime вместо Date, и защитное копирование становится не нужным. Защитное копирование стоит производительности; если класс и его клиент в одном пакете и доверяют друг другу — копирование можно заменить документированием ответственности клиента. Для wrapper-классов (см. EJ-4-4) защита от клиента бессмысленна — клиент сам себе вредит.
В нашей кодовой базе: нужда в защитных копиях минимальна, потому что: (1) маппинг между слоями делает MapStruct, он создаёт новые объекты автоматически; (2) типы из java.time immutable из коробки; (3) DTO/value-объекты помечены Lombok @Value (final + защита от mutation). Защитные копии всё ещё нужны для byte[], mutable коллекций — там MapStruct не помогает.
EJ-8-3 Тщательно проектируйте сигнатуры методов
Сборная статья без отдельных подзаголовков — пять советов, которые делают API проще и устойчивее к ошибкам.
Имена методов следуйте конвенциям. Понятность важнее краткости, согласованность важнее изобретательности. Избегайте длинных имён. В сомнениях смотрите в API стандартной библиотеки — там по большей части консенсус.
Не плодите методы без необходимости. Каждый метод должен делать свою часть работы. Избыток методов делает класс трудным для изучения, тестирования и сопровождения. Для интерфейсов это вдвойне болезненно — много методов мучительны и для разработчиков, и для пользователей. Если сомневаетесь — не добавляйте.
Избегайте длинных списков параметров. Практический максимум — 4 параметра, чем меньше тем лучше. Особенно вредны длинные последовательности параметров одного типа: пользователь перепутает порядок, программа скомпилируется и будет работать — но не так, как задумано.
// AVOID — 6 параметров, три из них одного типа
public Reservation book(String hotel, String guest, Date from, Date to,
int adults, int children, int rooms);
// PREFER — приём 1: разбивка на ортогональные операции
List<Slot> available = hotel.availableSlots(from, to); // вместо параметров на каждое поле
Reservation r = hotel.book(guest, available, occupancy);
// PREFER — приём 2: helper-класс для группы связанных параметров
public record Occupancy(int adults, int children, int rooms) {}
public Reservation book(String guest, Occupancy occupancy, Date from, Date to);
// PREFER — приём 3: builder для длинного конструктора (см. EJ-2-2)
NutritionFacts cola = new NutritionFacts.Builder(240, 8)
.calories(100).sodium(35).carbohydrate(27).build();
Предпочитайте интерфейсы классам в типах параметров. void doWith(Map<K,V> m), не HashMap<K,V> — клиент сможет передать TreeMap, ConcurrentHashMap или ещё не написанную реализацию.
Предпочитайте двухэлементные enum параметрам типа boolean, если значение не очевидно из имени метода. Thermometer.newInstance(TemperatureScale.CELSIUS) читается лучше, чем Thermometer.newInstance(true), и расширяется новой константой KELVIN без поломки клиентов.
EJ-8-4 Перегружайте методы разумно
Главное правило: выбор перегрузки (overload) делается статически — на этапе компиляции, по типу выражения, а не по типу объекта. В этом фундаментальное отличие от переопределения (override), где диспетчеризация динамическая. Программисты этого ожидают наоборот, поэтому код с перегрузками регулярно ведёт себя неожиданно.
// AVOID — классический gotcha: три раза напечатает "Unknown Collection"
public class CollectionClassifier {
public static String classify(Set<?> s) { return "Set"; }
public static String classify(List<?> lst) { return "List"; }
public static String classify(Collection<?> c) { return "Unknown Collection"; }
public static void main(String[] args) {
Collection<?>[] cs = { new HashSet<String>(),
new ArrayList<BigInteger>(),
new HashMap<String,String>().values() };
for (Collection<?> c : cs) System.out.println(classify(c));
// тип времени компиляции — Collection<?>, выбор перегрузки фиксирован
}
}
// PREFER — один метод с явной диспетчеризацией через instanceof
public static String classify(Collection<?> c) {
return c instanceof Set ? "Set"
: c instanceof List ? "List"
: "Unknown Collection";
}
// AVOID — List<E>.remove(int) vs remove(Object) — autoboxing trap
List<Integer> list = new ArrayList<>(List.of(-3, -2, -1, 0, 1, 2));
for (int i = 0; i < 3; i++) list.remove(i); // remove(int) — по индексу
// результат: [-2, 0, 2], а не [0, 1, 2] как наверняка ожидал автор
// PREFER — явная упаковка снимает неоднозначность
list.remove((Integer) i); // или Integer.valueOf(i)
Когда применять: никогда не экспортируйте две перегрузки с одинаковым числом параметров — это безопасное правило. Лучше дайте методам разные имена: ObjectOutputStream использует writeBoolean(boolean), writeInt(int), writeLong(long) — а не три перегрузки write. Конструкторы перегружать приходится (имя одно), но тогда обеспечьте, чтобы перегрузки имели совершенно разные типы параметров: один не приводится к другому. Для int vs Collection это безопасно — а вот для двух Object-подтипов или функциональных интерфейсов — уже нет.
Tradeoff: до Java 5 примитивы и ссылочные типы были «совершенно разными», но автоупаковка это сломала. С Java 8 функциональные интерфейсы тоже создают зону путаницы: submit(Runnable) vs submit(Callable<T>) — executor.submit(System.out::println) не компилируется, потому что println перегружен и тип ссылки на метод не определяется. Включайте -Xlint:overloads, компилятор предупредит. Если перегрузить нельзя избежать (история, эволюция API) — обеспечьте идентичное поведение всех перегрузок: String.contentEquals(StringBuffer) делегирует в contentEquals((CharSequence) sb). См. EJ-8-5 про varargs (особый случай перегрузки с переменной арностью).
EJ-8-5 Используйте методы с переменным количеством аргументов с осторожностью
Varargs (T...) принимают ноль или более аргументов: компилятор создаёт массив, заполняет аргументами и передаёт методу. Удобно для printf и рефлексии — а вне их есть две ловушки: пустой вызов и стоимость аллокации массива на каждый вызов.
// AVOID — min() без аргументов компилируется, но падает с runtime-ошибкой
static int min(int... args) {
if (args.length == 0)
throw new IllegalArgumentException("Слишком мало аргументов");
int min = args[0];
for (int i = 1; i < args.length; i++)
if (args[i] < min) min = args[i];
return min;
}
// PREFER — обязательный первый параметр + varargs для остатка
static int min(int firstArg, int... remainingArgs) {
int min = firstArg;
for (int arg : remainingArgs)
if (arg < min) min = arg;
return min;
}
// PREFER — performance-чувствительный API: серия overload + varargs как fallback
public void foo() { }
public void foo(int a1) { }
public void foo(int a1, int a2) { }
public void foo(int a1, int a2, int a3) { }
public void foo(int a1, int a2, int a3, int... rest) { } // массив только при >3 аргументах
Когда применять: varargs — для API, где количество аргументов действительно произвольно и нулевой случай осмыслен (String.format, Arrays.asList, рефлексия). Если требуется один или больше аргументов — выносите первый в обычный параметр. Для performance-критичных мест (если профилирование показало, что аллокация массива заметна) применяйте схему EnumSet.of: пять перегрузок для типичных случаев плюс varargs для остального.
Tradeoff: каждый вызов varargs-метода — это аллокация массива. Для большинства API цена ничтожна, но «5 + 1 перегрузок» — стандартный приём в горячих путях. Не злоупотребляйте: перегрузки увеличивают поверхность API и усложняют сопровождение. Никогда не делайте поле T... или параметр-Object..., который потом проверяете рефлексией — есть более типобезопасные альтернативы.
EJ-8-6 Возвращайте пустые массивы и коллекции, а не null
Возврат null для «нет результата» перекладывает ответственность на клиента: каждый вызов оборачивается в if (result != null). Забыть проверку легко — баг будет редким и далёким от точки возникновения. Аргумент в пользу null — «избегаем аллокации» — несостоятелен: на этом уровне беспокоиться о производительности преждевременно (см. EJ-9-11), а в редких случаях, когда профилирование подтверждает проблему, есть нормальные оптимизации.
// AVOID — null означает «пусто»
private final List<Cheese> cheesesInStock = ...;
public List<Cheese> getCheeses() {
return cheesesInStock.isEmpty() ? null : new ArrayList<>(cheesesInStock);
}
// клиент:
List<Cheese> ch = shop.getCheeses();
if (ch != null && ch.contains(Cheese.STILTON)) { /* ... */ } // легко забыть != null
// PREFER — пустая коллекция всегда
public List<Cheese> getCheeses() {
return new ArrayList<>(cheesesInStock);
}
List<Cheese> ch = shop.getCheeses();
if (ch.contains(Cheese.STILTON)) { /* ... */ } // никаких null-checks
// PREFER — оптимизация для горячего пути: разделяемая immutable пустая коллекция
public List<Cheese> getCheeses() {
return cheesesInStock.isEmpty()
? Collections.emptyList()
: new ArrayList<>(cheesesInStock);
}
// PREFER — для массивов: разделяемый константный пустой массив
private static final Cheese[] EMPTY_CHEESE_ARRAY = new Cheese[0];
public Cheese[] getCheeses() {
return cheesesInStock.toArray(EMPTY_CHEESE_ARRAY);
}
Когда применять: всегда. Для коллекций — Collections.emptyList(), emptySet(), emptyMap() (immutable, разделяемые). Для массивов — константа new T[0] как поле класса. Для потоков — Stream.empty(). Никогда не выделяйте new Cheese[size] для toArray — это медленнее, чем toArray(EMPTY_CHEESE_ARRAY).
Tradeoff: Optional<List<T>> — антипаттерн, см. EJ-8-7. Контейнер не должен заворачиваться в Optional, поскольку у него уже есть представление пустоты. Возврат null оправдан только в очень специфичных API (например, Map.get — null отличает «нет ключа» от «значение null», хотя и здесь современные альтернативы — containsKey или getOrDefault).
EJ-8-7 Возвращайте Optional с осторожностью
До Java 8 у метода, который не всегда мог вернуть значение, было два варианта: бросить исключение или вернуть null. Оба плохи: исключения дороги и предназначены для аномалий, null обязывает клиента писать защитный код, а кто забыл — получит NullPointerException далеко от источника. Java 8 добавила третий вариант — Optional<T>, immutable-контейнер на ноль или один элемент. Главное достоинство — Optional заставляет клиента признать, что значения может не быть.
// AVOID — возврат null
public static <E extends Comparable<E>> E max(Collection<E> c) {
if (c.isEmpty()) throw new IllegalArgumentException("Пустая коллекция");
// ...
}
// PREFER — возврат Optional<E>
public static <E extends Comparable<E>> Optional<E> max(Collection<E> c) {
if (c.isEmpty()) return Optional.empty();
// ...
return Optional.of(result);
}
// PREFER — каноничный stream-вариант
public static <E extends Comparable<E>> Optional<E> max(Collection<E> c) {
return c.stream().max(Comparator.naturalOrder());
}
// Использование на стороне клиента:
String word = max(words).orElse("Нет слов..."); // значение по умолчанию
Toy toy = max(toys).orElseThrow(TemperTantrumException::new); // своё исключение
String pid = ph.parent().map(h -> String.valueOf(h.pid())).orElse("N/A"); // map
Когда применять: возвращайте Optional<T>, если метод иногда не может вернуть результат, и важно, чтобы клиент это учитывал. findById, parse, max/min коллекции — каноничные кандидаты. Для примитивов используйте OptionalInt, OptionalLong, OptionalDouble — не Optional<Integer>.
Tradeoff (что НЕ нужно делать с Optional):
- Не оборачивайте контейнеры:
Optional<List<T>>— антипаттерн. Возвращайте пустойList<T>(см. EJ-8-6). То же дляMap,Set,Stream, массивов. - Не используйте как параметр метода или поле. Поле в
Optional— почти всегда «душок», намекающий на нужду в подклассе. Параметр-Optionalхуже, чем перегрузка илиnull— клиенту приходится оборачивать значение, которое и так есть. - Не используйте как ключ, значение или элемент коллекции.
Map<K, Optional<V>>создаёт два способа выразить отсутствие — путаница и баги. - Никогда не возвращайте
nullиз метода, возвращающегоOptional— теряется весь смысл. Optionalстоит производительности — это объект-обёртка плюс одно лишнее разыменование. В критичных местахnullили исключение могут быть оправданы. Измеряйте.
Optional духовно близок к проверяемым исключениям: оба заставляют клиента признать факт. Возврат null или непроверяемое исключение позволяют клиенту проигнорировать случай — с потенциально катастрофическими последствиями. См. EJ-8-6 про коллекции, EJ-8-1 про fail-fast параметры.
EJ-8-8 Пишите документирующие комментарии для всех открытых элементов API
Если API создан для использования — он должен быть документирован. Соглашения Javadoc де-факто часть языка: каждый Java-программист обязан их знать. Документирующий комментарий обязателен для каждого public/protected класса, интерфейса, конструктора, метода и поля. Для больших проектов того же стандарта стоит придерживаться и внутри пакета — кроме самых тривиальных случаев.
/**
* Возвращает элемент в указанной позиции этого списка.
*
* <p>Этот метод <i>не</i> гарантирует работу за константное время.
* В некоторых реализациях он может выполняться за время,
* пропорциональное позиции элемента.
*
* @param index Индекс возвращаемого элемента; должен быть
* неотрицательным и меньше размера списка
* @return Элемент в указанной позиции списка
* @throws IndexOutOfBoundsException если индекс выходит за пределы
* диапазона ({@code index < 0 || index >= this.size()})
*/
E get(int index);
/**
* Возвращает true, если эта коллекция пуста.
*
* @implSpec
* Эта реализация возвращает {@code this.size() == 0}.
*
* @return true если коллекция пуста
*/
public boolean isEmpty() { /* ... */ }
Когда применять: для каждого экспортируемого элемента. Комментарий метода должен лаконично описывать контракт между методом и клиентом: что метод делает, а не как. Перечисляйте предусловия (preconditions — обычно через @throws для непроверяемых исключений и через @param), постусловия (postconditions) и побочные эффекты (side effects — всё, что меняет состояние помимо очевидного результата).
Обязательные дескрипторы:
@param <name>— для каждого параметра, именная конструкция, описывающая значение.@return— если метод неvoid, именная конструкция о возвращаемом значении.@throws— для каждого исключения, проверяемого и непроверяемого, конструкция со словом «если…».@implSpec(Java 8+) — контракт между методом и его подклассом, для классов, рассчитанных на наследование. Включается флагом-tag "implSpec:a:Implementation Requirements:"(Javadoc 9 ещё не подхватывает по умолчанию).
Полезные приёмы:
{@code ...}— фрагмент кода моноширинным шрифтом, HTML-разметка внутри подавляется. Идеально дляindex < 0и подобного.{@literal ...}— текст с метасимволами HTML без особого шрифта ({@literal |r| < 1}).{@index ...}(Java 9+) — добавить термин в предметный указатель сгенерированной документации.- Многострочный код —
<pre>{@code ... }</pre>, но без знака@. - Краткое описание (первое предложение) должно быть глагольной фразой («Возвращает элемент…», «Конструирует пустой список…»), не оканчиваться точкой посреди предложения («к.т.н.» сломает Javadoc — оборачивайте
{@literal}).
Особые случаи:
- Generics: документируйте все параметры типа (
@param <K>,@param <V>). - Enum: документируйте каждую константу — обычно одной строкой
/** Деревянные духовые. */ WOODWIND,. - Annotations: документируйте сам тип и каждый член, как поля.
- Пакет: комментарий уровня пакета — в
package-info.java. Модуль — вmodule-info.java. - Многопоточность: безопасность с точки зрения потоков — критическая часть контракта, документируйте её всегда (см.
EJ-11-5). - Сериализация: для
Serializable— документируйте сериализованную форму (см.EJ-12-3).
Tradeoff: {@inheritDoc} позволяет наследовать комментарий от интерфейса — экономит дублирование, но имеет ограничения и применяется аккуратно. Для сложных API из множества классов комментариев недостаточно — нужен внешний обзорный документ; ссылка на него ставится в комментарий уровня пакета или класса. Включайте -Xdoclint (включён по умолчанию с Java 8), валидируйте сгенерированный HTML внешним инструментом. Главный тест документации — прочитайте сгенерированный HTML: ошибки видны только там.
Гл 9. Общие вопросы программирования
Двенадцать статей Джошуа Блоха о повседневных мелочах языка: где объявлять локальные переменные, как обходить коллекции, когда BigDecimal обязателен, чем опасны Integer и String, почему рефлексия и JNI — крайние меры, и как правильно именовать пакеты, классы и методы. Самая большая по числу статей глава книги — каждая отвечает на вопрос «как писать обычный Java-код, чтобы не было больно».
Каждое правило имеет код вида EJ-N-M (EJ-9-1 = глава 9, статья 1) — на эти коды ссылается AI-скилл ucp-effective-java-review в findings.
Источник: «Effective Java», Джошуа Блох, 3-е издание (2018, рус. перевод «Диалектика», 2019). Каждый рецепт — сжатая выжимка соответствующей статьи Bloch, не пересказ всей главы. Где идиомы зависят от версии Java — оставлен соответствующий блок
**Java 7+:**/**Java 8+:**/**Java 9+:**.
EJ-9-1 Минимизируйте область видимости локальных переменных
Цель — снизить шанс ошибки и облегчить чтение. Правило простое: объявляйте переменную там, где впервые её используете, и инициализируйте сразу. Преждевременное объявление разрывает связь между типом, начальным значением и местом использования; читатель забывает контекст ещё до того, как доберётся до самой инструкции.
// AVOID — объявление далеко от использования, нет инициализатора
int i;
String name;
// ... десятки строк ...
i = 0;
name = computeName();
// PREFER — объявление + инициализация в момент использования
int i = 0;
String name = computeName();
Цикл for предпочтительнее while, когда содержимое переменной цикла не требуется после выхода: переменные цикла видны только внутри. Классическая ловушка while:
// AVOID — copy-paste из первого цикла оставил i в области видимости
Iterator<Element> i = c.iterator();
while (i.hasNext()) { doSomething(i.next()); }
...
Iterator<Element> i2 = c2.iterator();
while (i.hasNext()) { // Опечатка: i вместо i2
doSomethingElse(i2.next()); // молча работает не так
}
// PREFER — for ограничивает scope, тот же баг даст ошибку компиляции
for (Iterator<Element> i = c.iterator(); i.hasNext(); ) { ... }
for (Iterator<Element> i = c2.iterator(); i.hasNext(); ) { ... }
Идиома с двумя переменными цикла кэширует дорогое граничное значение:
for (int i = 0, n = expensiveComputation(); i < n; i++) { ... }
Когда применять: всегда. Переменная без инициализатора допустима только при использовании внутри try/catch, где инициализатор может бросить исключение.
Tradeoff: последний приём — держите методы короткими и сфокусированными. Если в одном методе вы делаете две операции, локальные переменные одной просочатся в область видимости другой. Разделите метод на два — по одному на операцию.
EJ-9-2 Предпочитайте циклы for для коллекции традиционным циклам for
Расширенный цикл (for-each) скрывает итератор и индексную переменную — те самые места, где плодятся ошибки. Идиома работает для всего, что реализует Iterable, и компилируется в код, идентичный ручному.
// AVOID — итератор и индекс мелькают трижды-четырежды на цикл
for (Iterator<Element> i = c.iterator(); i.hasNext(); ) {
Element e = i.next();
...
}
for (int i = 0; i < a.length; i++) { ... a[i] ... }
// PREFER — for-each, читается «для каждого e из elements»
for (Element e : elements) { ... }
Главный выигрыш — на вложенных циклах. Классическая ошибка:
// AVOID — i.next() в *внутреннем* цикле; на каждую масть один Rank вместо 13
for (Iterator<Suit> i = suits.iterator(); i.hasNext(); )
for (Iterator<Rank> j = ranks.iterator(); j.hasNext(); )
deck.add(new Card(i.next(), j.next()));
// PREFER — проблема исчезает сама собой
for (Suit suit : suits)
for (Rank rank : ranks)
deck.add(new Card(suit, rank));
Когда применять: для всех коллекций, массивов и любых Iterable. Если пишете тип-контейнер — реализуйте Iterable, даже не реализуя Collection целиком.
Tradeoff: три ситуации, где for-each не работает и нужен явный итератор или индекс:
- Деструктивная фильтрация — нужен
Iterator.remove. С Java 8 часто заменяетсяCollection.removeIf. - Преобразование — заменить элементы на ходу нужен индекс списка или массива.
- Параллельный обход — нужно явно продвигать итераторы нескольких коллекций синхронно.
В нашей кодовой базе: for-each — стандарт, IntelliJ предлагает автозамену индексного for. Для transformations — Stream (см. EJ-7-4 про когда stream уместен).
EJ-9-3 Изучите и используйте возможности библиотек
Главный аргумент — за вас уже подумали эксперты, и эта работа оплачена сообществом. Классический пример — наивный random(int n):
// AVOID — три ошибки в трёх строках
static Random rnd = new Random();
static int random(int n) {
return Math.abs(rnd.nextInt()) % n; // (1) период, (2) bias, (3) Math.abs(MIN_VALUE) < 0
}
// PREFER — за вас уже написали
ThreadLocalRandom.current().nextInt(n); // Java 7+: быстрее Random в 3.6 раза
Преимущества стандартной библиотеки: знания экспертов; экономия времени на чужой задаче; растущая со временем производительность; новые возможности в каждом релизе; ваш код соответствует общепринятым идиомам.
// PREFER — Java 9: одна строка вместо цикла копирования
try (InputStream in = new URL(args[0]).openStream()) {
in.transferTo(System.out);
}
Когда применять: прежде чем писать утилиту, проверьте java.lang, java.util, java.io, java.util.concurrent, java.util.stream, java.nio.file. Знайте Collections, Comparator, Files, Math, Objects, Stream наизусть. Изучайте новинки каждого релиза — release notes публикуются.
Tradeoff: библиотеки иногда не покрывают вашу задачу — особенно специфичную. Сначала попробуйте сторонние (Guava, Apache Commons), и только в крайнем случае — пишите своё. Если функциональность общеприменима, библиотечный код всё равно станет лучше написанного вами в одиночку.
EJ-9-4 Если нужны точные ответы, избегайте float и double
Типы с плавающей точкой — двоичная арифметика, спроектированная для быстрого приближения научных и инженерных вычислений в широком диапазоне. Они не подходят для денежных вычислений — невозможно точно представить 0.1 или любую другую отрицательную степень десятки в двоичной системе.
// AVOID — выводит 0.6100000000000001
System.out.println(1.03 - 0.42);
// AVOID — выводит 0.09999999999999998
System.out.println(1.00 - 9 * 0.10);
// AVOID — итерация по double выходит через 4 итерации с остатком 0.3999999999999999
for (double price = 0.10; funds >= price; price += 0.10) { ... }
Для денег — три варианта:
// PREFER — BigDecimal, контроль округления, конструктор от String
BigDecimal funds = new BigDecimal("1.00");
BigDecimal tenCents = new BigDecimal(".10");
for (BigDecimal price = tenCents;
funds.compareTo(price) >= 0;
price = price.add(tenCents)) {
funds = funds.subtract(price);
itemsBought++;
}
// PREFER — int / long в наименьших единицах (копейки, центы)
int funds = 100; // в центах
for (int price = 10; funds >= price; price += 10) {
funds -= price;
itemsBought++;
}
Когда применять: BigDecimal — когда нужен полный контроль округления (восемь режимов), значения превышают 18 цифр, либо банковский алгоритм требует именно его. int — до 9 значащих цифр; long — до 18; BigDecimal — больше 18.
Tradeoff: BigDecimal неудобен (нет операторов, всё через методы) и значительно медленнее примитивов. Конструктор от double (new BigDecimal(0.1)) сохраняет неточность — всегда конструируйте от String.
EJ-9-5 Предпочитайте примитивные типы упакованным примитивным типам
Три ключевых различия: примитивы имеют только значение, упакованные — ещё и идентичность; примитивы — только полнофункциональные значения, упакованные имеют ещё null; примитивы быстрее по памяти и времени. Невнимание к этим различиям рождает три классических бага.
Баг 1 — == сравнивает идентичности, а не значения:
// AVOID — компаратор «работает», но падает на двух Integer(42)
Comparator<Integer> naturalOrder =
(i, j) -> (i < j) ? -1 : (i == j ? 0 : 1); // == сравнивает ссылки на Integer
// PREFER — распаковка через локальные int
Comparator<Integer> naturalOrder = (iBoxed, jBoxed) -> {
int i = iBoxed, j = jBoxed; // Авто-распаковка
return i < j ? -1 : (i == j ? 0 : 1);
};
// Ещё лучше: Comparator.naturalOrder()
Баг 2 — NullPointerException при автоматической распаковке null:
// AVOID — статическое поле Integer i имеет начальное значение null
static Integer i;
public static void main(String[] args) {
if (i == 42) // распаковка null → NPE
System.out.println("Невероятно");
}
// Исправляется заменой Integer i на int i.
Баг 3 — повторная упаковка в горячем цикле убивает производительность:
// AVOID — sum типа Long вместо long, каждая итерация боксит
Long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; i++) sum += i; // 2^31 ненужных объектов
Когда применять: упакованные типы обязательны как параметры обобщённых типов (List<Integer>, Map<String, Long>, ThreadLocal<Integer>) и при вызове рефлективных методов. Везде, где можно — примитивы.
Tradeoff: автоматическая упаковка скрывает различие синтаксически, но не семантически. При смешивании в одном выражении int и Integer — компилятор вставляет распаковку упакованного, и это место может выбросить NPE.
EJ-9-6 Избегайте применения строк там, где уместнее другой тип
Тип String создан для текста и плохо справляется с другими ролями. Четыре антипаттерна:
// AVOID — строка вместо числового или булева типа: теряется проверка диапазона
String age = readFromFile(); // PREFER int / BigInteger
String yes = readFromFile(); // PREFER boolean
// AVOID — строка вместо перечисления (см. EJ-6-1)
String status = "ACTIVE"; // PREFER enum Status
// AVOID — строка как составной ключ: разделитель # ломается, поля недоступны
String compoundKey = className + "#" + i.next(); // PREFER private static class
// AVOID — строка как ключ к thread-local: коллизии имён, нарушение безопасности
public class ThreadLocal {
public static void set(String key, Object value);
public static Object get(String key);
}
// PREFER — параметризованный класс ThreadLocal<T> с собственными методами get / set
Когда применять: только когда данные действительно являются текстом (из файла, сети, ввода пользователя — и не имеют более точного типа).
Tradeoff: строки гибкие и сериализуемые «бесплатно», но это плохая причина их выбирать — теряется проверка типов времени компиляции, methods equals/toString/compareTo накладываются от String, а не от вашей доменной логики. Если есть подходящий тип — используйте; если нет — напишите его.
EJ-9-7 Помните о проблемах производительности при конкатенации строк
Оператор + на строках — удобно, но квадратичен по времени при последовательной конкатенации n строк, потому что строки неизменяемы и каждое + копирует обе.
// AVOID — O(n²): для n=100, 80-символьных строк — в 6.5 раз медленнее
public String statement() {
String result = "";
for (int i = 0; i < numItems(); i++)
result += lineForItem(i);
return result;
}
// PREFER — O(n) через StringBuilder, можно предразместить буфер
public String statement() {
StringBuilder b = new StringBuilder(numItems() * LINE_WIDTH);
for (int i = 0; i < numItems(); i++)
b.append(lineForItem(i));
return b.toString();
}
Когда применять: + нормален для одиночной строки фиксированного размера (одно сообщение, одно toString). StringBuilder.append обязателен в циклах. Альтернатива — собрать через String.join или Collectors.joining поток.
Tradeoff: Java 9+ компилятор иногда сам оптимизирует серию + в StringConcatFactory.makeConcatWithConstants, но только в пределах одного выражения, не через границы итераций цикла. В цикле всегда явно StringBuilder.
В нашей кодовой базе: StringBuilder нужен только для горячих циклов с большим числом итераций — измеряйте профайлером, не пишите вручную «на всякий случай». Для статической сборки строк JIT в Java 9+ оптимизирует + через StringConcatFactory.makeConcat. Для логов — параметризованный log.info("user={} amount={}", id, amount), без конкатенации в принципе.
EJ-9-8 Для ссылки на объекты используйте их интерфейсы
Привычка простая: переменные, поля, параметры, возвращаемые типы — объявляйте интерфейсами, реализацию — конкретным классом в конструкторе.
// PREFER — тип переменной = интерфейс
Set<Son> sonSet = new LinkedHashSet<>();
// AVOID — тип переменной = реализация: смена реализации тянет за собой код
LinkedHashSet<Son> sonSet = new LinkedHashSet<>();
Программа становится гибче: смена реализации — одна строка в конструкторе. Окружающий код продолжает работать без изменений, потому что не знал старую реализацию в лицо.
Когда применять: всегда, когда есть подходящий интерфейс. Map<K,V> вместо HashMap, List<E> вместо ArrayList, Queue<E> вместо LinkedList, Executor вместо ThreadPoolExecutor.
Tradeoff: три ситуации, где допустим тип реализации:
- Класс значений без интерфейса —
String,BigInteger, ваши enum-ы, ваши финальные value-классы. - Каркас на классах, а не интерфейсах — большинство
java.io(OutputStream,Reader); ссылайтесь на наименее конкретный класс иерархии. - Реализация даёт нужный метод вне интерфейса —
PriorityQueue.comparator()отсутствует вQueue. Используйте только если код реально вызывает этот метод.
Если требуемая реализация заметно меняет контракт интерфейса (например, упорядочение LinkedHashSet против HashSet) — обоснуйте выбор в комментарии. Замена LinkedHashSet на HashSet сломает код, зависящий от порядка итераций.
EJ-9-9 Предпочитайте интерфейсы рефлексии
Рефлексия (java.lang.reflect) — мощно, но дорого. Три минуса:
- Теряются проверки типов времени компиляции, включая проверку исключений.
- Код многословен и неудобочитаем — десятки строк вместо одной.
- Производительность: рефлективный вызов в разы медленнее обычного (на машине Bloch — в 11 раз для метода без параметров, возвращающего
int).
Если рефлексия неизбежна — создавайте экземпляры рефлексивно, а вызывайте методы через интерфейс или суперкласс.
// PREFER — рефлексия только для creation, дальше — через Set<String>
public static void main(String[] args) {
Class<? extends Set<String>> cl = null;
try {
cl = (Class<? extends Set<String>>) Class.forName(args[0]);
} catch (ClassNotFoundException e) { fatalError("Класс не найден"); }
Constructor<? extends Set<String>> cons = null;
try { cons = cl.getDeclaredConstructor(); }
catch (NoSuchMethodException e) { fatalError("Нет no-arg конструктора"); }
Set<String> s = null;
try { s = cons.newInstance(); }
catch (ReflectiveOperationException e) { fatalError(e.getMessage()); }
// Дальше работаем с s как обычно — рефлексия не нужна
s.addAll(Arrays.asList(args).subList(1, args.length));
System.out.println(s);
}
Когда применять: инструментарий (визуализаторы, отладчики, инспекторы), управление зависимостями между разными версиями библиотек, провайдер-фреймворки с поздним связыванием. Java 7+ ReflectiveOperationException — общий супертип всех рефлексивных исключений; перехватывайте его, а не каждое по отдельности.
Tradeoff: для большинства приложений рефлексии действительно не нужно. Если её нужно лишь чуть — ограничьте применение этапом создания объекта, остальной код пусть работает через интерфейс или суперкласс, известный во время компиляции.
EJ-9-10 Пользуйтесь машинно-зависимыми методами осторожно
JNI открывает доступ к нативному коду (C, C++) и нужен в трёх случаях:
- Доступ к платформо-зависимым возможностям — реестр Windows, низкоуровневые API ОС (часть таких возможностей со временем переходит в JDK; например, API процессов появился в Java 9).
- Доступ к существующим нативным библиотекам, особенно унаследованным или передающим унаследованные данные.
- Производительность — но редко оправдан: с Java 3 виртуальная машина догнала большинство задач.
BigIntegerв Java 1.1 был обёрткой над C, в Java 3 — переписан на Java и стал быстрее оригинала.
Когда применять: только когда альтернативы нет. Если вынуждены — пишите минимум нативного кода, тщательно тестируйте, не забывайте про склеивающий слой (glue code).
Tradeoff: машинно-зависимый код небезопасен (см. EJ-8-2 про управление памятью), теряется переносимость, отладка сложнее, требуется перекомпиляция под каждую платформу. Накладные расходы на вызов через JNI и невозможность для GC отслеживать нативную память могут снизить общую производительность больше, чем выиграл нативный код. Подумайте дважды.
EJ-9-11 Оптимизируйте осторожно
Три афоризма, которые надо помнить:
Во имя эффективности (без необходимости её достижения) делается больше вычислительных грехов, чем по любой иной причине, включая непроходимую тупость. — Уильям Вульф
Преждевременная оптимизация — корень всех зол. — Дональд Кнут
- Не оптимизируйте. 2. (Только для экспертов.) Не оптимизируйте, пока не получите идеально ясное неоптимизированное решение. — М. Джексон
Правило: пишите хорошие, а не быстрые программы. Хорошая архитектура позволит оптимизировать; плохая — не позволит без переписывания.
Когда применять: думать о производительности нужно в проектировании, не в кодировании. Особенно — для API, протоколов и форматов данных: их потом не поменяешь, и плохое решение там навсегда ограничит производительность системы.
// AVOID — Component.getSize() возвращает изменяемый Dimension;
// каждый вызов рождает защитную копию → миллионы лишних объектов
public Dimension getSize();
// PREFER — два примитивных getter'а или immutable Dimension
public int getWidth();
public int getHeight();
Закончив систему — измерьте. Если работает достаточно быстро — оптимизация не нужна. Если нет — найдите узкое место профайлером (async-profiler, JFR), и только тогда меняйте код. Микробенчмарки — через jmh, а не System.currentTimeMillis() в цикле.
Tradeoff: измеряйте до и после каждой попытки оптимизации. Часто оптимизация не даёт измеримого эффекта или ухудшает картину: программы тратят 90% времени в 10% кода, и это не всегда то место, где вам кажется. Java имеет слабую модель производительности — относительная стоимость операций варьируется по реализациям JVM и аппаратным платформам; меряйте на каждой целевой.
EJ-9-12 Придерживайтесь общепринятых соглашений по именованию
Соглашения делятся на типографские (бесспорные) и грамматические (более гибкие).
Типографские:
| Тип идентификатора | Пример |
|---|---|
| Пакет, модуль | org.junit.jupiter.api, com.google.common.collect |
| Класс, интерфейс, enum, аннотация | Stream, FutureTask, LinkedHashMap, HttpClient |
| Метод, поле | remove, groupingBy, getCrc |
Константа (static final) | MIN_VALUE, NEGATIVE_INFINITY |
| Локальная переменная, параметр | i, denom, houseNum |
| Параметр типа | T, E, K, V, X, R, U, V, T1, T2 |
Аббревиатуры в именах классов — спорный момент. Bloch рекомендует только первая заглавная: HttpUrl, не HTTPURL — иначе непонятно, где кончается одно слово.
Грамматические:
- Классы — существительные единственного числа:
Thread,PriorityQueue,ChessPiece. Утилиты — множественное число:Collectors,Collections. - Интерфейсы — как классы, либо с суффиксом
-able/-ible:Runnable,Iterable,Accessible. - Методы-действия — глаголы:
append,drawImage. - Методы, возвращающие boolean — префикс
is(режеhas):isEmpty,isDigit,hasSiblings. - Методы-аксессоры — префикс
getдля совместимости с JavaBeans, либо без префикса (size,hashCode). - Преобразование типа —
toType(toString,toArray). - Представление —
asType(asList). - Извлечение примитива того же значения —
typeValue(intValue). - Статические фабрики —
from,of,valueOf,instance,getInstance,newInstance,getType,newType(см.EJ-2-1).
Когда применять: всегда. API, нарушающий типографские соглашения, сложно использовать; реализация — сложно сопровождать.
Tradeoff: грамматические соглашения не догма. Цитата из спецификации: «не следует рабски следовать соглашениям, если длительная практика диктует иное решение». Руководствуйтесь здравым смыслом — но сначала освойте соглашения настолько, чтобы отступать от них осознанно.
В нашей кодовой базе: действует расширенный свод правил Java Style Guide — JS-2.x: имена пакетов lowercase без множественного числа (util не utils), классы — существительные, интерфейсы — без префикса I, аббревиатуры по правилу 2 буквы CAPS / 3+ только первая CAPS. Bloch даёт базу, JS — нашу версию.
Гл 10. Исключения
Девять статей Джошуа Блоха про исключения: когда их вообще генерировать, какой тип выбрать (checked / RuntimeException / Error), как переводить низкоуровневые исключения в термины своей абстракции, что писать в detail message и как не оставлять объект в несогласованном состоянии после сбоя.
Главный тезис главы: исключения — для исключительных ситуаций, не для управления потоком выполнения. Использовать try / catch как замену if или для выхода из цикла — это не оптимизация, а маскировка ошибок и удар по читаемости.
Каждое правило имеет код вида EJ-N-M (EJ-10-1 = глава 10, статья 1) — на эти коды ссылается AI-скилл ucp-effective-java-review в findings.
Источник: «Effective Java», 3-е издание (2018, рус. перевод «Диалектика», 2019). Каждый рецепт — сжатая выжимка соответствующей статьи Bloch, не пересказ всей главы.
EJ-10-1 Используйте исключения только в исключительных ситуациях
Исключение — сигнал об исключительной ситуации: сбое инварианта, нарушении контракта, отказе ресурса. Управлять обычным потоком выполнения через catch — антипаттерн: он маскирует настоящие ошибки, мешает оптимизациям JVM (код внутри try оптимизируется хуже) и обычно работает медленнее обычной идиомы. На массиве из ста элементов «цикл через исключение» у Bloch оказался более чем в два раза медленнее стандартного for-each.
// AVOID — исключение как условие выхода из цикла
try {
int i = 0;
while (true)
range[i++].climb();
} catch (ArrayIndexOutOfBoundsException e) {
// нормальное завершение
}
// PREFER — стандартная идиома
for (Mountain m : range)
m.climb();
Симметричное правило для проектирования API: хорошо спроектированный API не должен заставлять клиента ловить исключение для обычного управления потоком. Если у класса есть метод, зависящий от состояния (его можно вызвать только при определённых условиях), предоставьте парный метод проверки состояния — как Iterator.hasNext рядом с Iterator.next. Альтернативы: возврат Optional или специального значения (null, -1) для случая, когда вычисление невозможно. Метод проверки лучше для однопоточного кода (читаемее, неправильное использование сразу вылезет исключением); Optional или специальное значение лучше при конкурентном доступе без внешней синхронизации, иначе состояние может измениться между проверкой и вызовом.
EJ-10-2 Используйте для восстановления проверяемые исключения, а для программных ошибок — исключения времени выполнения
В Java три типа выбрасываемых объектов: checked exception (extends Exception, но не RuntimeException), runtime exception (extends RuntimeException) и error (extends Error). Правило выбора:
- Проверяемое исключение — когда от вызывающего кода разумно ожидать восстановления.
FileNotFoundException,IOException, прикладнойInsufficientFundsException. Сам факт, что метод объявилthrows, говорит клиенту API: «эта ситуация возможна, обработай её». - Runtime exception — для нарушения предусловий, то есть программных ошибок клиента.
IllegalArgumentException,IllegalStateException,NullPointerException,IndexOutOfBoundsException,ConcurrentModificationException. Восстановление обычно невозможно или вредно — программа должна упасть с диагностикой. - Error — зарезервированы для JVM (нехватка памяти, нарушение инварианта виртуальной машины). По договорённости пользовательский код их не наследует и не генерирует (исключение —
AssertionError). Их не ловят.
// PREFER — checked для восстановимой ситуации
public void transfer(Account from, Account to, Money amount)
throws InsufficientFundsException { ... }
// PREFER — runtime для нарушения контракта
public Object pop() {
if (size == 0) throw new EmptyStackException();
...
}
Не создавайте классы, наследующие напрямую от Throwable или Exception (но не от RuntimeException) — это тот же checked, только без преимуществ. У проверяемых исключений делайте методы доступа к деталям сбоя — e.getRequiredAmount(), e.getDeficit() — иначе клиент не сможет осмысленно восстанавливаться.
EJ-10-3 Избегайте ненужных проверяемых исключений
Checked exception — это обязательство для клиента: либо try / catch, либо throws в сигнатуре. Это полезно, когда восстановление возможно. Если же лучшее, что может сделать клиент — это printStackTrace() плюс System.exit(1) или throw new AssertionError(), то проверяемое исключение только засоряет API. Особенно болезненно с Java 8: методы, объявляющие throws, плохо встраиваются в Stream, Optional, лямбды.
Лакмусовая бумажка: «как программист будет обрабатывать это исключение?». Если ответа нет или он сводится к «переброшу выше» — делайте runtime.
Способы убрать ненужное checked:
- Вернуть
Optional. Вместо «броситьNoSuchRecordException» — вернутьOptional<Record>. Минус: теряется детальная информация о причине. - Разделить метод на два. Вместо одного
obj.action(args) throws TheCheckedException—if (obj.actionPermitted(args)) obj.action(args);, гдеactionбросает уже непроверяемое. Подходит, когда проверка дешёвая и состояние не меняется между вызовами.
Если вызывающий не может восстановиться — переходите на runtime. Если может, но сценарий редкий — рассмотрите Optional. Только если Optional теряет важную информацию о сбое — оставляйте checked.
EJ-10-4 Предпочитайте использовать стандартные исключения
В библиотеках платформы есть набор исключений, покрывающий большинство потребностей. Использовать их, а не плодить свои подклассы — три плюса: API проще читается (соглашение знакомо), меньше классов в памяти, меньше когнитивной нагрузки на клиента.
| Исключение | Когда генерировать |
|---|---|
IllegalArgumentException | неверное ненулевое значение параметра |
IllegalStateException | объект в неподходящем состоянии для вызова |
NullPointerException | null передан туда, где он запрещён |
IndexOutOfBoundsException | индексный параметр вне диапазона |
ConcurrentModificationException | обнаружено параллельное изменение однопоточного объекта |
UnsupportedOperationException | объект не поддерживает запрошенную операцию |
Особые случаи: null — это не IllegalArgumentException, это NullPointerException. Индекс вне диапазона — не IllegalArgumentException, это IndexOutOfBoundsException. ArithmeticException и NumberFormatException подходят, например, для классов комплексных чисел и матриц.
Не генерируйте Exception, RuntimeException, Throwable, Error напрямую — это абстрактные суперклассы, их нельзя надёжно ловить. Свои подклассы создавайте, только если хотите добавить поля с информацией о сбое (InsufficientFundsException с getDeficit()) — статья EJ-10-7 объясняет почему.
Граничный случай — когда подходят и IllegalArgumentException, и IllegalStateException (например, дилер раздаёт карты, а попросили больше карт, чем осталось в колоде). Правило: если ни одно значение аргумента не сработает — это IllegalStateException; если конкретно этот аргумент плохой при правильном состоянии — IllegalArgumentException.
EJ-10-5 Генерируйте исключения, соответствующие абстракции
Когда метод верхнего уровня пробрасывает исключение нижнего уровня (например, парсер выдаёт IOException вместо ParseException), он засоряет свой API деталями реализации. Если завтра реализацию заменят на нелокальный источник, тип пробрасываемого исключения изменится — клиентский код сломается.
Решение — трансляция исключений (exception translation): перехватить низкоуровневое исключение и перебросить высокоуровневое, описанное в терминах абстракции верхнего уровня.
// PREFER — exception translation
try {
// вызов низкоуровневой абстракции
} catch (LowerLevelException e) {
throw new HigherLevelException(...);
}
// PREFER — exception chaining: исключение нижнего уровня сохранено в cause
try {
...
} catch (LowerLevelException cause) {
throw new HigherLevelException(cause);
}
Цепочка исключений (exception chaining) — частный случай трансляции, когда нижнее исключение полезно для диагностики. Передайте его в конструктор HigherLevelException(Throwable cause), и оно станет доступно через Throwable.getCause(), попадёт в стек-трейс. У большинства стандартных исключений такие конструкторы уже есть; если нет — используйте Throwable.initCause().
Пример из JDK — AbstractSequentialList.get(int index): внутри ListIterator.next() бросает NoSuchElementException, а наружу метод транслирует это в IndexOutOfBoundsException("Index: " + index) — потому что для клиента List.get контракт определён через индекс, а не через итератор.
Не злоупотребляйте трансляцией. Где возможно, лучший способ — предотвратить нижнее исключение проверкой аргументов на верхнем уровне. Где невозможно ни предотвратить, ни обработать — молча залогируйте через java.util.logging и изолируйте клиент от проблемы.
EJ-10-6 Документируйте все исключения, которые может генерировать метод
Описание выбрасываемых исключений — часть контракта метода. Используйте @throws в Javadoc.
- Каждое проверяемое исключение — отдельным
@throwsи отдельным элементом вthrowsсигнатуры. Не объявляйтеthrows Exceptionилиthrows Throwable— это делает обработку невозможной (см.EJ-10-4). Единственное оправданное исключение —main, который вызывает только JVM. - Непроверяемые исключения тоже документируйте через
@throws, но не указывайте их вthrowsв сигнатуре метода. Отсутствиеthrowsв сигнатуре — визуальный сигнал, что исключение непроверяемое. @throwsдля непроверяемых = предусловия метода. Хорошо составленный список runtime-исключений описывает, при каких условиях метод корректно работает.- Документация интерфейсов особенно важна: непроверяемые исключения, описанные в Javadoc интерфейсного метода, — часть общего контракта, и все реализации должны вести себя единообразно.
/**
* Возвращает элемент в указанной позиции списка.
*
* @throws IndexOutOfBoundsException если index за пределами
* диапазона ({@code index < 0 || index >= size()}).
*/
public E get(int index) { ... }
Если одно и то же исключение бросается всеми методами класса по одной причине (классика — NullPointerException при null в любом параметре), документируйте это в Javadoc класса, а не в каждом методе.
EJ-10-7 Включайте в сообщения информацию о сбое
Когда исключение не перехвачено, JVM печатает его строковое представление (toString). Часто это всё, что есть у программиста, разбирающего падение в продакшене. Поэтому detail message должен содержать значения всех параметров и полей, приведших к сбою.
// AVOID — бесполезное сообщение
throw new IndexOutOfBoundsException("bad index");
// PREFER — все relevant variables
throw new IndexOutOfBoundsException(
"Lower bound: " + lowerBound +
", Upper bound: " + upperBound +
", Index: " + index);
Не пишите user-facing текст — detail message читают разработчики при отладке, не пользователи. Пользовательские сообщения локализуются и формируются на уровне UI; диагностика — нет, и она важнее читаемости.
Не включайте чувствительные данные — пароли, ключи шифрования, токены. Стек-трейсы попадают в логи, баг-трекеры, скриншоты.
Способ гарантировать качественный detail message — принимать в конструкторе исключения сами параметры сбоя, а не готовую строку. Класс сам соберёт сообщение, и забыть какое-то поле не получится:
public IndexOutOfBoundsException(int lowerBound, int upperBound, int index) {
super(String.format("Lower bound: %d, Upper bound: %d, Index: %d",
lowerBound, upperBound, index));
this.lowerBound = lowerBound;
this.upperBound = upperBound;
this.index = index;
}
Бонус: поля lowerBound, upperBound, index доступны через геттеры — клиент при восстановлении (статья EJ-10-2) сможет их использовать программно.
EJ-10-8 Добивайтесь атомарности сбоев
Метод, завершившийся сбоем, должен оставлять объект в том же состоянии, в каком он был до вызова. Это свойство называют атомарностью по отношению к сбою (failure atomic). Без него клиент после catch имеет дело с объектом в неопределённом состоянии — продолжать работу с ним опасно.
Способы добиться:
-
Неизменяемые объекты. Атомарность даётся бесплатно — состояние не меняется в принципе.
-
Проверка параметров до изменений. Сначала валидация, потом мутация. Пример —
Stack.pop()проверяетsize == 0до того, как трогать массив:public Object pop() { if (size == 0) throw new EmptyStackException(); Object result = elements[--size]; elements[size] = null; return result; } -
Упорядочить вычисления так, чтобы потенциально сбойные операции шли до первой модификации. Пример —
TreeMap:ClassCastExceptionпри попытке добавить элемент несовместимого типа возникнет в процессе поиска позиции, до любого изменения дерева. -
Операция на копии. Изменения применяются к временной копии, оригинал замещается атомарно при успехе. Естественный приём для алгоритмов, которым копия нужна и так (например, некоторые сортировки копируют список перед сортировкой ради скорости доступа).
-
Recovery code. Перехватить сбой посреди операции и вручную откатить состояние. Сложно и редко — применяется в основном для долговременных структур на диске.
Когда атомарность недостижима или нежелательна:
- При гонке без синхронизации (
ConcurrentModificationException) объект уже мог быть повреждён другим потоком. - При
AssertionErrorвосстановление не имеет смысла — это сигнал нарушенного инварианта. - Для некоторых операций атомарность стоит дорого; в этом случае поведение при сбое обязано быть задокументировано в Javadoc.
EJ-10-9 Не игнорируйте исключения
Самый банальный, но упорно нарушаемый совет. Пустой блок catch лишает исключение смысла:
// AVOID — пустой catch
try {
...
} catch (SomeException e) {}
Это всё равно что отключить пожарную сигнализацию, чтобы её никто не услышал. Минимум — записать исключение в лог: даже если действие не требуется, информация о сбое нужна для последующей диагностики.
Иногда игнорирование действительно осознанно (например, при закрытии FileInputStream после успешного чтения — состояние файла уже не интересно). В таком случае соблюдайте две дисциплинарные нормы:
- Комментарий в
catch, объясняющий, почему игнорирование приемлемо. - Имя переменной —
ignored(вместо привычногоe). Это визуальный сигнал для следующего читателя.
// PREFER — осознанное игнорирование
Future<Integer> f = exec.submit(planarMap::chromaticNumber);
int numColors = 4;
try {
numColors = f.get(1L, TimeUnit.SECONDS);
} catch (TimeoutException | ExecutionException ignored) {
// используем значение по умолчанию: минимальная раскраска желательна,
// но не обязательна
}
Совет одинаково применим к проверяемым и непроверяемым исключениям. Программа, проигнорировавшая исключение, продолжит работу как ни в чём не бывало — и упадёт позже в случайном месте, далёком от настоящей причины.
Гл 11. Параллельные вычисления
Семь статей Джошуа Блоха про многопоточный код: когда synchronized действительно нужен, почему volatile не равен атомарности, почему чужой метод нельзя звать из-под блокировки, чем ExecutorService заменяет ручной new Thread(...), чем java.util.concurrent заменяет wait/notify, как документировать уровень потокобезопасности и как не сломать lazy-инициализацию.
Каждое правило имеет код вида EJ-N-M (EJ-11-1 = глава 11, статья 1) — на эти коды ссылается AI-скилл ucp-effective-java-review в findings.
Источник: «Effective Java», Джошуа Блох, 3-е издание (2018, рус. перевод «Диалектика», 2019). Каждый рецепт — сжатая выжимка соответствующей статьи Bloch, не пересказ всей главы.
EJ-11-1 Синхронизируйте доступ к совместно используемым изменяемым данным
Многие думают, что synchronized — это только взаимное исключение (mutual exclusion). На самом деле второе свойство не менее важно: видимость изменений между потоками (memory visibility, happens-before). Без синхронизации запись одного потока никогда не обязана стать видимой другому потоку — JVM может закешировать поле в регистре или поднять чтение из цикла наружу (hoisting), и фоновый поток будет крутиться вечно.
// AVOID — программа никогда не завершится: stopRequested закеширован,
// JIT превращает while(!stopRequested) в while(true)
public class StopThread {
private static boolean stopRequested;
public static void main(String[] args) throws Exception {
Thread bg = new Thread(() -> {
int i = 0;
while (!stopRequested) i++;
});
bg.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true; // запись может быть никогда не видна bg
}
}
// PREFER — synchronized accessor: и взаимное исключение, и happens-before
private static synchronized void requestStop() { stopRequested = true; }
private static synchronized boolean stopRequested(){ return stopRequested; }
// PREFER (легче) — volatile: visibility-only, без mutual exclusion
private static volatile boolean stopRequested;
Когда применять: для любого поля, которое читает один поток и пишет другой. Синхронизировать обязаны обе стороны — и чтение, и запись. Синхронизация одной только записи может казаться работающей на конкретной JVM, но это обманчиво.
volatile vs synchronized: volatile гарантирует только видимость, не атомарность. Классическая ловушка — nextSerialNumber++: оператор ++ выполняет три операции (read–modify–write), и два потока легко получат один и тот же номер. Здесь volatile не спасает — нужен либо synchronized, либо лучше AtomicLong.getAndIncrement() из java.util.concurrent.atomic (он быстрее synchronized-версии и не требует ручной блокировки).
Лучшая стратегия: не использовать совместно изменяемые данные вовсе. Либо immutable объекты (статья EJ-4), либо ограничить изменяемые данные одним потоком и публиковать ссылку безопасно (safe publication через volatile, final, статическое поле или параллельную коллекцию).
EJ-11-2 Избегайте излишней синхронизации
Если EJ-11-1 — про опасность недостаточной синхронизации, то EJ-11-2 — про обратную крайность: излишняя синхронизация роняет производительность, плодит deadlock и ведёт к непредсказуемому поведению.
Главное правило: никогда не передавайте управление клиентскому коду из синхронизируемой области. Метод, который класс не контролирует — переопределяемый метод подкласса, лямбда из параметра, callback наблюдателя — называется чужим (alien). Чужой метод может бросить исключение, заблокироваться, перехватить ту же блокировку (и пройти из-за reentrant-семантики Java), вызвать обратно ваш метод и сломать инвариант.
// AVOID — вызов observer.added() из synchronized блока:
// удаление наблюдателя из колбэка → ConcurrentModificationException;
// если же удаление выполняется из другого потока через ExecutorService.get() —
// получаем deadlock на той же блокировке observers.
private void notifyElementAdded(E element) {
synchronized (observers) {
for (SetObserver<E> observer : observers)
observer.added(this, element); // alien call под блокировкой
}
}
// PREFER — open call: снимок под блокировкой, обход без блокировки
private void notifyElementAdded(E element) {
List<SetObserver<E>> snapshot;
synchronized (observers) {
snapshot = new ArrayList<>(observers);
}
for (SetObserver<E> observer : snapshot)
observer.added(this, element);
}
// PREFER (ещё лучше) — CopyOnWriteArrayList: явная синхронизация не нужна
private final List<SetObserver<E>> observers = new CopyOnWriteArrayList<>();
Tradeoff: CopyOnWriteArrayList копирует массив на каждой модификации — для часто меняющихся коллекций он ужасен, но для списков подписчиков (редко меняются, часто обходятся) — идеален.
Когда применять: делайте как можно меньше внутри synchronized. Захватите блокировку, прочитайте/обновите данные, отпустите. Длительные операции (I/O, рендеринг, callback) — за пределы блокировки. Излишняя синхронизация дорого обходится не столько за счёт lock contention, сколько за счёт упущенной параллельности и запрета JIT-оптимизаций (компилятор не может переупорядочить операции через synchronized).
Изменяемый класс — внутренняя или внешняя синхронизация? Не оба сразу. Внутренняя (как ConcurrentHashMap) уместна только если за счёт неё достигается значительно более высокий параллелизм — иначе предпочитайте внешнюю (как ArrayList, HashMap) и явно документируйте «не потокобезопасен».
EJ-11-3 Предпочитайте исполнителей, задания и потоки данных потокам исполнения
Не пишите свою «рабочую очередь» поверх Thread и wait/notify — это сложно и подвержено сбоям безопасности и живучести. Пакет java.util.concurrent содержит Executor Framework — гибкий каркас выполнения задач.
// PREFER — одна строка вместо самописной рабочей очереди
ExecutorService exec = Executors.newSingleThreadExecutor();
exec.execute(runnable);
exec.shutdown(); // обязательно, иначе JVM не завершится
// AVOID — ручной new Thread для типовой задачи:
// нет переиспользования потоков, нет очереди, нет управления
new Thread(runnable).start();
Выбор фабрики:
Executors.newCachedThreadPool()— небольшие программы, прототипы. Создаёт новые потоки по требованию. Не годится для нагруженного prod-сервера: при насыщении CPU создание новых потоков только ухудшает ситуацию.Executors.newFixedThreadPool(n)— нагруженный сервер. Фиксированное число потоков, очередь задач.ThreadPoolExecutorнапрямую — если нужен полный контроль (тип очереди, политика отказа, фабрика потоков).
Иерархия абстракций:
- task (задача) —
RunnableилиCallable<V>; единица работы. - executor service — механизм выполнения; вы выбираете стратегию (один поток, пул фиксированного размера, fork-join).
- fork-join (с Java 7) —
ForkJoinPoolдля divide-and-conquer задач: задачи разбиваются на подзадачи, потоки «воруют» работу друг у друга (work-stealing) для высокой утилизации. - parallel streams (с Java 8) — пишутся поверх
ForkJoinPool.commonPool(), дают параллельность дёшево.
Когда применять: всегда, когда хочется создать поток вручную, сначала спросите — нет ли подходящей фабрики в Executors? Прямая работа с Thread сегодня оправдана только для очень специфичных низкоуровневых задач.
EJ-11-4 Предпочитайте утилиты параллельности методам wait и notify
wait/notify — это «параллельный ассемблер»: примитивы, поверх которых построена вся java.util.concurrent. С Java 5 писать новый код на wait/notify практически нет причин — высокоуровневые утилиты делают то же самое, но безопаснее.
Три категории утилит:
- Параллельные коллекции (
ConcurrentHashMap,ConcurrentLinkedQueue,CopyOnWriteArrayList) — высокопроизводительные потокобезопасные реализации стандартных интерфейсов с внутренней синхронизацией. ИспользуйтеConcurrentHashMapвместоCollections.synchronizedMap— выигрыш в производительности на параллельных нагрузках кратный. Атомарные составные операции (putIfAbsent,compute,merge) добавлены в Java 8 как методы по умолчанию интерфейсаMap. - Блокирующие очереди (
BlockingQueue,LinkedBlockingQueue) —take()блокируется до появления элемента; основа паттерна производитель–потребитель.ThreadPoolExecutorиспользуетBlockingQueueпод капотом. - Синхронизаторы —
CountDownLatch,Semaphore,CyclicBarrier,Phaser,Exchanger. Координируют действия потоков без ручногоwait/notify.
// PREFER — измерение времени параллельного выполнения через CountDownLatch
public static long time(Executor exec, int concurrency, Runnable action)
throws InterruptedException {
CountDownLatch ready = new CountDownLatch(concurrency);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(concurrency);
for (int i = 0; i < concurrency; i++) {
exec.execute(() -> {
ready.countDown(); // готов
try {
start.await(); // ждём общего старта
action.run();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown(); // финиш
}
});
}
ready.await();
long startNanos = System.nanoTime(); // всегда nanoTime, не currentTimeMillis
start.countDown();
done.await();
return System.nanoTime() - startNanos;
}
Если всё же приходится использовать wait:
- Только из
synchronized-области, владеющей объектом блокировки. - Только в цикле
while, никогда вif— из-за ложных пробуждений (spurious wakeup), вмешательства других потоков и других причин условие может оказаться ложным после пробуждения. - Предпочтите
notifyAll(), а неnotify()— консервативный выбор гарантирует, что нужные потоки разбудятся (лишние просто проверят условие и снова уснут).
// PREFER — стандартная идиома wait
synchronized (obj) {
while (<условие не выполнено>)
obj.wait(); // освобождает блокировку, повторно захватывает
// выполнение действий, соответствующих условию
}
EJ-11-5 Документируйте безопасность с точки зрения потоков
Поведение класса при параллельном использовании — часть его контракта. Без документации клиент вынужден гадать; неверная догадка ведёт либо к недостаточной (EJ-11-1), либо к избыточной (EJ-11-2) синхронизации.
Наличие synchronized в сигнатуре метода — не часть API. Javadoc его не показывает, и сам факт ничего не говорит о потокобезопасности класса.
Уровни безопасности (документируйте словами в Javadoc):
- Immutable (неизменяемый) — экземпляры выглядят константами; внешняя синхронизация не нужна.
String,Long,BigInteger. - Unconditionally thread-safe (безусловно безопасный) — экземпляры изменяемы, но достаточно внутренней синхронизации; внешняя не нужна.
AtomicLong,ConcurrentHashMap. - Conditionally thread-safe (условно безопасный) — как unconditionally, но некоторые последовательности вызовов требуют внешней синхронизации.
Collections.synchronizedMap— итерация поkeySet()требуетsynchronized(map). - Not thread-safe (небезопасный) — клиент обязан окружить каждый вызов внешней синхронизацией.
ArrayList,HashMap. - Thread-hostile (несовместимый с многопоточностью) — небезопасен даже при внешней синхронизации, обычно из-за неосторожной модификации статических данных. Плохой класс; обычно багфикс или deprecation.
Условно безопасный требует особого внимания: документируйте какие последовательности нужно синхронизировать и какую блокировку (обычно сам экземпляр) нужно захватывать.
// PREFER — закрытый объект блокировки для безусловно потокобезопасного класса
public class Service {
private final Object lock = new Object(); // всегда final
public void foo() {
synchronized (lock) {
// ...
}
}
}
Закрытый объект блокировки vs synchronized this: клиент не может захватить lock (поле приватное), значит не сможет случайно или намеренно (DoS-атака) удерживать вашу блокировку и блокировать ваши методы. Особенно важно для классов, спроектированных под наследование — иначе подкласс и базовый класс «наступят друг другу на пятки», используя одну и ту же блокировку для несвязанных целей. Поля блокировок всегда final.
Идиома закрытой блокировки годится только для безусловно безопасных классов. Условно безопасные обязаны раскрывать, какую блокировку клиент захватывает, — а закрытая блокировка не раскрывается по определению.
EJ-11-6 Аккуратно применяйте отложенную инициализацию
Lazy initialization — задержка инициализации поля до первого обращения. Двусторонний меч: уменьшает стоимость старта, но удорожает каждое обращение к полю. Как и большинство «оптимизаций», обычно вредит производительности, а не помогает.
Главный совет: не используйте lazy init, пока он действительно не нужен. Обычная (eager) инициализация лучше в большинстве случаев — измеряйте, прежде чем оптимизировать.
Когда lazy init оправдан: обращение к полю редкое, инициализация дорогая, и измерения это подтвердили. Также — для разрыва циклической инициализации (статья 51 из 2-го издания / EJ-9 в 3-м).
Идиомы (выбор зависит от того, поле экземпляра или статическое):
// PREFER (по умолчанию) — обычная инициализация: проще всего, лучше всего
private final FieldType field = computeFieldValue();
// PREFER (для поля экземпляра, нужен lazy) — synchronized accessor
private FieldType field;
private synchronized FieldType getField() {
if (field == null) field = computeFieldValue();
return field;
}
// PREFER (для статического поля, нужен lazy) — lazy initialization holder class
private static class FieldHolder {
static final FieldType field = computeFieldValue();
}
private static FieldType getField() { return FieldHolder.field; }
// PREFER (для поля экземпляра + производительность) — double-check idiom
// volatile КРИТИЧЕН, без него double-check сломан
private volatile FieldType field;
private FieldType getField() {
FieldType result = field; // локальная переменная — для скорости
if (result == null) { // первая проверка (без блокировки)
synchronized (this) {
if (field == null) // вторая проверка (под блокировкой)
field = result = computeFieldValue();
}
}
return result;
}
Holder-class idiom работает за счёт того, что JVM синхронизирует доступ к полям только во время инициализации класса; после неё JVM «удаляет» проверки — стоимость доступа = стоимость обычного static final. Метод getField не synchronized.
Double-check idiom — для полей экземпляра. Локальная переменная result гарантирует одно чтение volatile field в типовом случае (поле уже инициализировано); даёт ~25% ускорения. Для статических полей double-check не нужен — holder-class элегантнее.
Single-check (вариант) — volatile field без второй проверки и synchronized-блока; допускает многократную инициализацию из разных потоков. Применяйте, только если повторная инициализация не вредит. Для примитивов кроме long/double (которые требуют синхронизации для атомарности — см. EJ-11-1) можно убрать даже volatile — это «гоночная» single-check (racy single-check). Экзотика, не для повседневного использования.
EJ-11-7 Избегайте зависимости от планировщика потоков
Корректность программы не должна зависеть от того, как ОС распределяет процессорное время. Иначе программа будет работать на одной машине и ломаться на другой, или вести себя по-разному между запусками.
Главный приём: среднее число работающих потоков должно быть не сильно больше числа процессоров. Тогда планировщику не из чего выбирать — он гоняет всех, и поведение стабильно.
- Работающие — это не запущенные. Поток в
wait()илиBlockingQueue.take()не работающий. - В терминах EJ-11-3 это значит: правильно подбирайте размер пула потоков и не делайте задачи слишком короткими (накладные расходы съедят выгоду).
Антипаттерн — busy-wait (активное ожидание): поток в цикле проверяет состояние объекта вместо того, чтобы заблокироваться.
// AVOID — busy-wait: поток жжёт CPU, ничего не делая;
// ~10× медленнее CountDownLatch при 1000 ожидающих потоков
public class SlowCountDownLatch {
private int count;
public void await() {
while (true) {
synchronized (this) {
if (count == 0) return;
}
}
}
public synchronized void countDown() {
if (count != 0) count--;
}
}
// PREFER — CountDownLatch из java.util.concurrent (см. EJ-11-4)
Thread.yield() и приоритеты потоков — не лекарство. Если программа «почти работает» и вы добавляете Thread.yield(), чтобы стало лучше, — программа неисправна. У yield нет тестируемой семантики: на одной JVM он ускорит код, на другой замедлит, на третьей не сделает ничего. То же с Thread.setPriority(...) — самая непереносимая возможность Java; «поиграть» с приоритетами для тонкой настройки уже работающей программы можно, но «починить» сломанную живучесть — нельзя.
Если программа едва работает: ищите фундаментальную ошибку (контеншн на блокировке, неверный размер пула, бесконечная гонка), а не подкручивайте yield и приоритеты.
Гл 12. Сериализация
Самая короткая глава у Bloch и одна из самых жёстких по тону. Шесть статей о том, что java.io.Serializable — это не «удобный способ сохранить объект», а отдельный API-контракт с собственными правилами совместимости и огромной поверхностью атаки. Главные мотивы: никогда не десериализуйте непроверенные данные, сериализованная форма — часть публичного API, readObject — это конструктор поверх потока байтов. В третьем издании добавилась статья 12.1 — Bloch прямо рекомендует не использовать сериализацию Java вообще, а переходить на JSON или Protobuf.
Каждое правило имеет код вида EJ-N-M — на эти коды ссылается AI-скилл ucp-effective-java-review в findings.
Источник: «Effective Java», 3-е издание (2018, рус. перевод «Диалектика», 2019). Каждый рецепт — сжатая выжимка соответствующей статьи Bloch, не пересказ всей главы.
EJ-12-1 Предпочитайте альтернативы сериализации Java
Сериализацию добавили в Java в 1997 году с известными рисками — и риски оправдались. Уязвимости, которые обсуждались как теоретические в начале 2000-х, превратились в десятки реальных эксплойтов: атака на агентство пассажирских перевозок Сан-Франциско в 2016-м отключила систему тарифных сборов на двое суток. Фундаментальная проблема: метод readObject — это «волшебный конструктор», способный инстанцировать объект почти любого типа в classpath, лишь бы он реализовал Serializable. Поверхность атаки — все классы JDK, всех сторонних библиотек (Apache Commons Collections и подобных) и самого приложения.
// AVOID — приём недоверенного потока через ObjectInputStream
public Order parseOrder(InputStream in) throws Exception {
return (Order) new ObjectInputStream(in).readObject(); // RCE waiting to happen
}
// PREFER — кросс-платформенное представление структурированных данных
public Order parseOrder(InputStream in) throws IOException {
return objectMapper.readValue(in, Order.class); // Jackson / JSON
}
// PREFER — Protobuf для схематизированного бинарного обмена
public Order parseOrder(InputStream in) throws IOException {
return OrderProto.parseFrom(in); // схема описана в .proto
}
Атакующие и исследователи безопасности изучают сериализуемые типы в библиотеках в поисках гаджетов — методов, которые при десериализации выполняют опасные действия. Цепочка гаджетов даёт удалённое выполнение кода. Без всяких гаджетов работает атака отказом в обслуживании — бомба десериализации: 5744 байт потока приводят к более чем 2¹⁰⁰ вычислений hashCode. Лучший способ избежать проблем сериализации — её не использовать.
Если переходите на новую систему — JSON (текстовый, читаемый, схематизация опциональная) или Protocol Buffers (бинарный, эффективный, схема обязательна, есть и текстовый формат pbtxt). Если приходится поддерживать унаследованную систему на Java-сериализации — никогда не десериализуйте недоверенные данные. Используйте java.io.ObjectInputFilter (Java 9+, бэкпортирован) с белым списком классов; чёрный список защищает только от известных гаджетов. Фильтр не спасает от бомб.
EJ-12-2 Реализуйте интерфейс Serializable крайне осторожно
Добавить implements Serializable стоит несколько секунд. Долгосрочная цена — годы поддержки. Сериализованная форма становится частью публичного API: пока вашим классом кто-то пользуется, вы обязаны её поддерживать так же, как сигнатуры методов. Если приняли форму по умолчанию, она навсегда привязана к внутреннему представлению — закрытые поля становятся видимыми в API через сериализацию, и любая смена представления ломает совместимость.
// AVOID — наивная сериализуемость без serialVersionUID
public class UserSession implements Serializable { // UID сгенерирует JVM по структуре класса
private String token;
private List<Role> roles;
// любое изменение полей/методов → InvalidClassException на старых данных
}
// PREFER — явный UID, минимально достаточная контрактная форма
public class UserSession implements Serializable {
private static final long serialVersionUID = 1L; // зафиксировано навсегда
private final String token;
private final List<Role> roles;
// эволюция через writeObject/readObject (см. EJ-12-3) или прокси (EJ-12-6)
}
// AVOID — внутренний класс не должен реализовывать Serializable
public class Outer {
public class Inner implements Serializable { ... } // synthetic ссылка на Outer в форме
}
// PREFER — статический вложенный класс
public class Outer {
public static class Inner implements Serializable { ... } // сам по себе
}
Три неудобства реализации Serializable. Первое — снижается возможность изменять класс. Если не объявить serialVersionUID явно, его сгенерирует SHA-1 по структуре класса; добавили метод — новый UID — InvalidClassException у клиентов. Второе — повышается риск брешей безопасности: десериализация — это «скрытый конструктор» в обход языка, легко забыть про инварианты. Третье — растёт объём тестирования: для каждой новой версии класса нужно проверить совместимость в обе стороны со всеми существующими версиями.
Классы, спроектированные для наследования, редко должны реализовывать Serializable; внутренние классы — никогда (synthetic-поле с ссылкой на охватывающий экземпляр в форме не определено стандартом). Статический класс-член — можно. Если унаследованный класс с инвариантами на полях, перекройте finalize и объявите его final, чтобы исключить атаки финализатора, и добавьте readObjectNoData.
EJ-12-3 Подумайте о применении пользовательской сериализованной формы
Сериализованная форма по умолчанию — это эффективное кодирование физического представления графа объектов. Она годится только когда физическое представление совпадает с логическим содержанием. Не принимайте форму по умолчанию, не обдумав, насколько она вас устраивает.
// AVOID — связный список как форма по умолчанию: четыре недостатка разом
public final class StringList implements Serializable {
private int size = 0;
private Entry head = null;
private static class Entry implements Serializable {
String data; Entry next; Entry previous;
}
// форма навсегда зашивает Entry в API, ест память, тратит время на обход,
// даёт StackOverflowError на 1000-1800 элементах
}
// PREFER — пользовательская форма: «количество элементов + сами строки»
public final class StringList implements Serializable {
private transient int size = 0;
private transient Entry head = null;
// No longer Serializable!
private static class Entry { String data; Entry next; Entry previous; }
/**
* @serialData Размер списка ({@code int}), затем все элементы
* (каждый — {@code String}) в правильном порядке.
*/
private void writeObject(ObjectOutputStream s) throws IOException {
s.defaultWriteObject();
s.writeInt(size);
for (Entry e = head; e != null; e = e.next) s.writeObject(e.data);
}
private void readObject(ObjectInputStream s)
throws IOException, ClassNotFoundException {
s.defaultReadObject();
int n = s.readInt();
for (int i = 0; i < n; i++) add((String) s.readObject());
}
private static final long serialVersionUID = 1L;
}
Форма по умолчанию для StringList имеет четыре изъяна: навсегда привязывает API к внутреннему Entry; ест в разы больше памяти; рекурсивный обход даёт StackOverflowError; запись/чтение медленнее. Исправленная пишет только логические данные — количество строк и сами строки. Все нелогические поля помечены transient. Прежде чем не пометить поле как transient, убедитесь, что его значение — часть логического состояния.
Даже при пользовательской форме вызывайте defaultWriteObject / defaultReadObject — это нужно для добавления нетранзитных полей в будущих версиях с сохранением совместимости. Используйте ту же синхронизацию для writeObject, что и для остальных методов класса. Всегда объявляйте serialVersionUID явно — иначе вы платите за вычисление SHA-1 при каждом запуске и рискуете несовместимостью при первом же изменении класса.
EJ-12-4 Создавайте защищённые методы readObject
readObject — это публичный конструктор, единственный аргумент которого — поток байтов. Поток обычно поступает от штатно сериализованного экземпляра, но злоумышленник может сфабриковать произвольные байты. Поэтому в readObject нужно (1) проверять инварианты и (2) выполнять защитное копирование изменяемых компонентов — точно так же, как в обычном конструкторе.
// AVOID — readObject без защиты: атака MutablePeriod подменяет внутренние Date
private void readObject(ObjectInputStream s)
throws IOException, ClassNotFoundException {
s.defaultReadObject(); // start и end заняли значения из потока — и всё
}
// AVOID — проверка инвариантов есть, защитного копирования нет
private void readObject(ObjectInputStream s)
throws IOException, ClassNotFoundException {
s.defaultReadObject();
if (start.compareTo(end) > 0)
throw new InvalidObjectException(start + " после " + end);
// атака: в поток байтов добавили лишние ссылки на внутренние Date — теперь их можно менять извне
}
// PREFER — защитное копирование ДО проверки, поля не final
private Date start; // нельзя final: defensive copy в readObject требует переприсваивания
private Date end;
private void readObject(ObjectInputStream s)
throws IOException, ClassNotFoundException {
s.defaultReadObject();
start = new Date(start.getTime()); // защитное копирование — обязательно
end = new Date(end.getTime());
if (start.compareTo(end) > 0) // потом — проверка инвариантов
throw new InvalidObjectException(start + " после " + end);
}
Простой тест: «было бы вам комфортно, если бы класс имел публичный конструктор, принимающий значения всех полей и без проверки записывающий их в объект?» Если нет — readObject обязан явно проверять и копировать. Альтернатива — прокси-агент сериализации (см. EJ-12-6), который снимает большую часть этой работы.
Краткие правила. Для полей со ссылками на объекты, которые должны оставаться закрытыми, выполняйте защитное копирование. Изменяемые компоненты неизменяемых классов сюда тоже относятся. Проверяйте все инварианты, при ошибке — InvalidObjectException. Проверки — после защитного копирования. Никогда не вызывайте перекрываемые методы класса из readObject — ни прямо, ни косвенно. Перекрытый метод запустится до десериализации соответствующего подкласса и почти наверняка упадёт.
EJ-12-5 Для управления экземпляром предпочитайте типы перечислений методу readResolve
Класс с инвариантом «существует ровно один экземпляр» (singleton, type-safe enum дочисленного периода Java) ломается при implements Serializable: десериализация всегда возвращает новый экземпляр. Метод readResolve позволяет подменить только что десериализованный объект на канонический.
// AVOID — singleton + Serializable без readResolve: каждый readObject создаёт новый Elvis
public class Elvis implements Serializable {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {}
private String[] favoriteSongs = { "Hound Dog" }; // не-transient ссылка — уязвимость
}
// AVOID — readResolve есть, но ссылочное поле не transient: возможна атака ElvisStealer
public class Elvis implements Serializable {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {}
private String[] favoriteSongs = { "Hound Dog" }; // НЕ transient — кража ссылки
private Object readResolve() {
return INSTANCE; // подменяет десериализованный объект синглтоном
}
}
// PREFER — singleton как enum: JVM гарантирует единственность по построению
public enum Elvis {
INSTANCE;
private final String[] favoriteSongs = { "Hound Dog" };
public void printFavorites() { System.out.println(Arrays.toString(favoriteSongs)); }
}
Если у класса с readResolve есть не-transient поле со ссылкой на объект, содержимое этого поля десериализуется до вызова readResolve. Тщательно сделанный «класс-вор» (ElvisStealer) подцепит ссылку на полу-десериализованный экземпляр и сохранит её в статическом поле. Получается два разных Elvis. Решение через transient — хрупкое и требует дисциплины; правильное решение — enum-based singleton. Java гарантирует, что других экземпляров кроме объявленных констант не появится, если только злоумышленник не воспользуется AccessibleObject.setAccessible (а имея такие привилегии, он и так уже всё может).
readResolve остаётся актуальным, только если состав экземпляров неизвестен на момент компиляции — тогда невозможно представить класс перечислением. Доступность readResolve критична: в final-классе — private. В нефинальном — нужна аккуратность: private не действует на подклассы, package-private — только внутри пакета, protected/public распространяются на подклассы (но требуют, чтобы все подклассы тоже его перекрывали, иначе ClassCastException). И всегда: поля со ссылками на объекты — transient.
EJ-12-6 Подумайте о применении прокси-агента сериализации вместо сериализованных экземпляров
Шаблон прокси-агент сериализации (serialization proxy pattern) кардинально снижает риски Serializable. Идея: вместо самого экземпляра в поток пишется его маленький приватный заместитель, который содержит только логически необходимые поля.
// PREFER — прокси-агент сериализации для неизменяемого Period
public final class Period implements Serializable {
private final Date start;
private final Date end;
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());
if (this.start.compareTo(this.end) > 0)
throw new IllegalArgumentException(start + " после " + end);
}
// 1) приватный вложенный класс-прокси с логической формой
private static class SerializationProxy implements Serializable {
private final Date start;
private final Date end;
SerializationProxy(Period p) { this.start = p.start; this.end = p.end; }
// 3) при десериализации прокси — собрать настоящий Period через публичный конструктор
private Object readResolve() { return new Period(start, end); }
private static final long serialVersionUID = 234098243823485285L;
}
// 2) writeReplace заменяет сериализуемый объект на прокси
private Object writeReplace() { return new SerializationProxy(this); }
// 4) readObject в исходном классе блокирует попытки атаки байт-потоком
private void readObject(ObjectInputStream s) throws InvalidObjectException {
throw new InvalidObjectException("Требуется прокси");
}
private static final long serialVersionUID = 1L;
}
Прокси создаётся через тот же публичный конструктор, что и обычный экземпляр — все проверки инвариантов и защитные копии выполняются автоматически. Не нужно думать, какие поля могут быть скомпрометированы байт-потоком. Поля исходного класса остаются final — настоящая неизменяемость, в отличие от подхода с защитным копированием в readObject (EJ-12-4).
Прокси-агент даже мощнее, чем защитное копирование: десериализованный экземпляр может быть другого класса, чем сериализованный. Реальный пример из JDK — EnumSet: статические фабрики возвращают RegularEnumSet (≤64 элементов) или JumboEnumSet (>64). Сериализуем RegularEnumSet на 60 элементов, при десериализации добавляем ещё пять — получаем JumboEnumSet, потому что прокси EnumSet хранит Class<E> плюс Enum<?>[], а readResolve собирает множество через EnumSet.noneOf плюс цикл add.
Два ограничения. Прокси-агент несовместим с классами, расширяемыми пользователями — тип, возвращаемый readResolve, фиксирован. И он несовместим с графами с цикличностью: на момент readResolve объекта ещё не существует, только прокси, и обращение к самому объекту даст ClassCastException. Дополнительная цена — около 14% накладных расходов на сериализацию относительно защитного копирования. Когда приходится писать readObject/writeObject для класса, не расширяемого клиентами, прокси-агент сериализации — простейшее средство надёжной сериализации объектов с нетривиальными инвариантами.
Настройка IDE (IntelliJ IDEA)
- Берите
checkstyle.xmlи.editorconfigиз проекта — они закрывают формат, отступы, naming, imports, modifier order. - Ставьте плагин CheckStyle-IDEA.
File → Settings → Code Style → Java → Import Scheme → Checkstyle Configuration.- Включите Lombok plugin (
File → Settings → Plugins → Lombok) и аннотационный процессор (Build, Execution, Deployment → Compiler → Annotation Processors → Enable). - Запускайте сканирование Checkstyle перед коммитом — pre-commit hook желательно.
Дальше
- Скилл
java-style-reviewвusecase-pattern-skills— автоматическое ревью по этим правилам, с цитированием кодов в findings. - REST API Style Guide — правила контракта API в том же духе.
- Use Case Pattern — методология поверх стиля кода.