Итак, вы с недавних пор начали работать с Java? А помните времена, когда она называлась "Oak", когда объектно-ориентированность была еще горячей темой, когда люди на C++ думали, что у Java нет никаких шансов, и когда про аплеты никто даже не слыхал?
Я могу предположить, что вы не знаете даже и половины следующих вещей. Давайте начнем неделю с нескольких крутых сюрпризов внутренней работы Java.
Все примеры в статье проверены на совместимость с современными версиями Java (8-21). Где есть изменения в поведении, это специально отмечено.
1. Не существует такого понятия как checked exception
Все верно! JVM понятия не имеет о такой вещи, лишь язык Java знает об этом. Сегодня все соглашаются, что checked exceptions были ошибкой. Как говорил Брюс Эккель на своем заключительном выступлении на GeeCON в Праге, ни один другой язык после Java не использует checked exceptions, даже Java 8 больше не охватывает их в новом Streams API (что, правда, может доставлять небольшие неудобства, когда ваши лямбды будут использовать IO или JDBC). Вы хотите доказательств того, что JVM не знает такой вещи? Попробуйте следующий код:public class Test {
// No throws clause here
public static void main(String[] args) {
doThrow(new SQLException());
}
static void doThrow(Exception e) {
Test.doThrow0(e);
}
@SuppressWarnings("unchecked")
static void doThrow0(Exception e) throws E {
throw (E) e;
}
} Это не только скомпилируется, это также бросит SQLException, вам даже не нужно использовать Lombok's @SneakyThrows для этого.
Практическая применимость: Этот прием иногда используется в библиотеках для работы с reflection API или в тестовых фреймворках.
Дополнительные материалы: JLS §11.2 Compile-Time Checking of Exceptions
2. Вы можете иметь перегруженные методы, отличающиеся только возвращаемыми типами
Это не скомпилируется, верно?class Test {
Object x() { return "abc"; }
String x() { return "123"; }
}Верно. Язык Java не позволяет одновременно двум методам быть эквивалентно переопределенными в пределах одного класса, не обращая внимания на их различия в throws или return типах.
Но подождите минутку. Проверьте еще раз документацию по Class.getMethod(String, Class…). Там написано:
Отметьте, что, возможно, есть более одного соответствующего метода в классе, потому что, пока язык Java запрещает множество методов с одинаковой сигнатурой но разными возвращаемыми типами, виртуальная машина Java этого не делает. Эта гибкость в виртуальной машине может использоваться для реализации различных функций языка. Например, ковариантные возвраты могут осуществляться с bridge методами; bridge метод и переопределенный метод имели бы одинаковую сигнатуру, но разные возвращаемые типы.
Ничего себе, да это имеет смысл! На самом деле довольно много всего происходит, когда вы пишете следующее:
abstract class Parent {
abstract T x();
}
class Child extends Parent {
@Override
String x() { return "abc"; }
} Посмотрите на сгенерированный байт-код:
// Method descriptor #15 ()Ljava/lang/String;
// Stack: 1, Locals: 1
java.lang.String x();
0 ldc [16]
2 areturn
Line numbers:
[pc: 0, line: 7]
Local variable table:
[pc: 0, pc: 3] local: this index: 0 type: Child
// Method descriptor #18 ()Ljava/lang/Object;
// Stack: 1, Locals: 1
bridge synthetic java.lang.Object x();
0 aload_0 [this]
1 invokevirtual Child.x() : java.lang.String [19]
4 areturn
Line numbers:
[pc: 0, line: 1]
Итак, T на самом деле Object в байт-коде. Это хорошо понимается.
Синтетический bridge метод на самом деле генерируется компилятором, потому что тип возвращаемого значения Parent.x() можно ожидать на определенных участках вызовов. Добавление generics без таких bridge методов перестало бы быть возможным в двоичном представлении. Итак, изменения в JVM, чтобы позволить такую функцию, произвели меньше боли (которая также позволяет ковариантное переопределение метода в качестве побочного эффекта...). Умно, правда?
Практическая применимость: Понимание bridge методов важно при работе с reflection API и анализе производительности.
Дополнительные материалы: JLS §8.4.8.3 Requirements in Overriding and Hiding, Oracle: Bridge Methods
3. Все следующее – двумерные массивы
class Test {
int[][] a() { return new int[0][]; }
int[] b() [] { return new int[0][]; }
int c() [][] { return new int[0][]; }
}Это на самом деле так! Даже если ваш ментальный анализатор не может сразу понять возвращаемый тип из описанных выше способов, все они одинаковы! Как и следующий кусок кода:
class Test {
int[][] a = {{}};
int[] b[] = {{}};
int c[][] = {{}};
}Вы думаете, что это безумие? Количество возможностей написать то же самое просто взрывает воображение!
@Target(ElementType.TYPE_USE)
@interface Crazy {}
class Test {
@Crazy int[][] a1 = {{}};
int @Crazy [][] a2 = {{}};
int[] @Crazy [] a3 = {{}};
@Crazy int[] b1[] = {{}};
int @Crazy [] b2[] = {{}};
int[] b3 @Crazy [] = {{}};
@Crazy int c1[][] = {{}};
int c2 @Crazy [][] = {{}};
int c3[] @Crazy [] = {{}};
}Type annotations – устройство, загадочность которого уступает только его мощи.
Обновление для Java 8+: Type annotations значительно развились и теперь активно используются в фреймворках для статического анализа и валидации (Checker Framework, Spring, etc.).
Практическая применимость: Хотя синтаксис позволяет такое разнообразие, в реальных проектах рекомендуется придерживаться единого стиля для читаемости кода.
Дополнительные материалы: JLS §10.2 Array Variables
4. Вы не понимаете условное выражение
Итак, вы думали, что уже знаете все про условные выражения, когда начали их использовать? Позвольте вас огорчить – вы ошибались. Большинство из вас подумает, что следующие два примера эквивалентны:Object o1 = true ? new Integer(1) : new Double(2.0);эквивалентно этому?
Object o2;
if (true)
o2 = new Integer(1);
else
o2 = new Double(2.0);Нет! Давайте используем быстрый тест:
System.out.println(o1);
System.out.println(o2);Программа выведет следующее:
1.0
1Да! Условный оператор будет осуществлять приведение типов, если понадобится.
А поскольку в ином случае вы ожидали бы, что программа бросит NullPointerException?
Integer i = new Integer(1);
if (i.equals(1))
i = null;
Double d = new Double(2.0);
Object o = true ? i : d; // NullPointerException!
System.out.println(o);Обновление для Java 9+: Конструкторы Integer(int) и Double(double) помечены как deprecated. Используйте Integer.valueOf() и Double.valueOf().
Практическая применимость: Это поведение может приводить к неожиданным результатам в реальном коде, особенно при работе с числовыми типами.
Дополнительные материалы: JLS §15.25 Conditional Operator
5. Вы также не понимаете составной оператор присваивания
Изворотливости достаточно? Давайте рассмотрим следующие два фрагмента кода:i += j;
i = i + j;Интуитивно они должны быть равны, правда? Но знаете что – они разные!
Спецификация JLS говорит:
Составное выражение типа E1 op= E2 эквивалентно E1 = (T) ((E1) op (E2)), где T это тип E1, за исключением того, что E1 вычисляется только один раз.
Хороший пример – это использовать *= или /=:
byte b = 10;
b *= 5.7;
System.out.println(b); // prints 57или:
byte b = 100;
b /= 2.5;
System.out.println(b); // prints 40или:
char ch = '0';
ch *= 1.1;
System.out.println(ch); // prints '4'или:
char ch = 'A';
ch *= 1.5;
System.out.println(ch); // prints 'a'Итак, это до сих пор полезный инструмент?
Практическая применимость: Неявное приведение типов в составных операторах может маскировать ошибки. В критичном коде лучше использовать явные приведения.
Дополнительные материалы: JLS §15.26.2 Compound Assignment Operators
6. Случайные целые числа
Теперь более трудное задание. Не читайте решение. Посмотрите, сможете ли вы найти ответ самостоятельно. Когда я запускаю следующую программу:for (int i = 0; i < 10; i++) {
System.out.println((Integer) i);
}иногда я получаю следующий вывод:
92
221
45
48
236
183
39
193
33
84Но как такое вообще возможно?
[Место для размышлений]
Ответ кроется в переопределении JDK кеша Integer через рефлексию и затем в использовании auto-boxing и auto-unboxing. Не делайте этого без разрешения взрослых!
Обновление для Java 9+: С введением модульной системы доступ к внутренним API стал более ограниченным. Такие трюки могут не работать без специальных флагов JVM.
Практическая применимость: Демонстрирует важность понимания внутреннего устройства Integer cache (-128 до 127) для корректной работы с autoboxing.
Дополнительные материалы: JLS §5.1.7 Boxing Conversion
7. GOTO
Одно из моих самых любимых. У Java есть GOTO! Напишите это:int goto = 1;и вы получите это:
Test.java:44: error: expected
int goto = 1;
^ Это потому, что goto – это неиспользуемое зарезервированное слово, просто на всякий случай...
Но это не самая захватывающая часть. Самое интересное то, что вы можете включить goto в паре с break, continue и помеченными блоками:
Прыжки вперед:
label: {
// do stuff
if (check) break label;
// do more stuff
}В байт-коде:
2 iload_1 [check]
3 ifeq 6 // Jumping forward
6 ..Прыжки назад:
label: do {
// do stuff
if (check) continue label;
// do more stuff
break label;
} while(true);В байт-коде:
2 iload_1 [check]
3 ifeq 9
6 goto 2 // Jumping backward
9 ..Практическая применимость: Labeled statements полезны для выхода из вложенных циклов, что делает код более читаемым, чем использование флагов.
Дополнительные материалы: JLS §14.7 Labeled Statements, Oracle Java Tutorials: Branching Statements
8. У Java есть псевдонимы типов
В других языках (например, Ceylon) мы можем определять псевдонимы типов очень легко:interface People => Set; Тип People здесь построен таким образом, что может взаимозаменяться с множеством Set:
People? p1 = null;
Set? p2 = p1;
People? p3 = p2; В Java мы не можем просто так определить псевдоним на верхнем уровне. Но мы можем сделать так для потребностей класса либо метода. Давайте предположим, что нас не устраивают такие имена, как Integer, Long и т.д., и мы хотим имена покороче: I и L. Да легко:
class Test {
void x(I i, L l) {
System.out.println(
i.intValue() + ", " +
l.longValue()
);
}
}В примере выше Integer преобразован в I для видимости класса Test, в то время как Long преобразован в L для нужд метода x(). Теперь мы можем вызвать этот метод следующим образом:
new Test().x(1, 2L);Конечно, эту технику не следует воспринимать всерьез. В данном случае Integer и Long – final типы, что означает, что I и L – эффективные псевдонимы (почти, преобразование идет только в одну сторону). Если бы мы решили использовать non-final типы (к примеру, Object), тогда мы могли бы обойтись обычными дженериками.
Обновление для Java 10+: С введением var необходимость в коротких именах типов частично отпала.
Практическая применимость: В реальном коде такие псевдонимы могут ухудшить читаемость. Лучше использовать осмысленные имена типов.
Дополнительные материалы: JLS §4.4 Type Variables
9. Некоторые отношения типов неразрешимы!
Хорошо, сейчас будет действительно интересно, так что возьмите чашку концентрированного кофе и давайте рассмотрим следующие два типа:// A helper type. You could also just use List
interface Type {}
class C implements Type> {}
class D implements Type>>> {} Так что же C и D вообще означают?
В каком-то смысле они рекурсивны, похоже на рекурсию в java.lang.Enum. Подумайте:
public abstract class Enum> { ... } С учетом указанных выше спецификаций, фактическая реализация enum – это всего лишь синтаксический сахар:
// This
enum MyEnum {}
// Is really just sugar for this
class MyEnum extends Enum { ... } Думая об этом, давайте вернемся к нашим двум типам. Скомпилируется ли следующий код?
class Test {
Type c = new C();
Type d = new D();
}Сложный вопрос... и он фактически неразрешим!
Является ли C подтипом Type?
Step 0) C <: Type
Step 1) Type <: ?
Step 0) D <: Type
Step 1) Type>> <: ?
Step 2) D> <: ?
Step 3) Type>>> <: ?
Step 4) D>> <: ?
Step ... (expand forever)
Попробуйте скомпилировать это в вашем Eclipse или IntelliJ IDEA, и они расскажут все, что о вас думают.
Некоторые отношения типов в Java неразрешимы!
Практическая применимость: Это академический пример, демонстрирующий ограничения системы типов Java. В реальном коде такие конструкции обычно указывают на проблемы в дизайне.
Дополнительные материалы: JLS §4.5.1 Type Arguments and Wildcards
10. Пересечение типов
В Java есть очень интересная особенность под названием пересечение типов (type intersection). Вы можете объявить (generic) тип, который является на самом деле пересечением двух типов. Например:class Test {
} Параметр настраиваемого типа T, который вы связываете с экземплярами класса Test, должен реализовывать как Serializable, так и Cloneable интерфейсы. Например, String нельзя использовать, но Date – пожалуйста:
// Doesn't compile
Test s = null;
// Compiles
Test d = null; Эта особенность находит многократное использование в Java 8, где вы можете приводить лямбда-выражения к пересечению типов. Как это помогает? Практически никак, но если вы хотите привести свое лямбда-выражение к нужному вам типу, то другого пути нет. Допустим, у вас есть такое сумасшедшее ограничение в вашем методе:
void execute(T t) {} Вы хотите Runnable, который в то же время Serializable, только в том случае, если хотите выполнить это в другом месте и отправить результат по сети. Лямбды и сериализация привносят немного иронии.
Вы можете сериализировать ваше лямбда-выражение, если его целевой тип и захваченные аргументы сериализуемы.
Но даже если это верно, они автоматически не реализуют интерфейс Serializable. Вы должны самостоятельно приводить их к этому типу. Но когда вы приводите только к Serializable:
execute((Serializable) (() -> {}));тогда лямбда больше не будет Runnable, поэтому приводите их к обоим типам:
execute((Runnable & Serializable) (() -> {}));Обновление для Java 8+: Type intersection стал особенно важен с появлением лямбд и method references. Также активно используется в современных фреймворках.
Практическая применимость: Очень полезно при создании generic методов и классов, где нужны множественные ограничения типов.
Дополнительные материалы: JLS §4.9 Intersection Types, Oracle: Bounded Type Parameters
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ