Pull to refresh

10 вещей, которых вы не знали о Java

Reading time 8 min
Views 105K
Original author: Lukas Eder
Итак, вы работаете на Java с самого её появления? Вы помните те дни, когда она называлась «Oak», когда про ООП говорили на каждом углу, когда сиплюсплюсники думали, что у Java нет шансов, а апплеты считались крутой штукой?

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

1. Проверяемых (checked) исключений не существует


Да-да! JVM ничего про них не знает, знает только Java.

Сегодня уже любой согласится, что проверяемые исключения были плохой идеей. Как сказал Брюс Эккель в своей завершающей речи на GeeCON в Праге, ни один язык после Java не связывался с проверяемыми исключениями и даже в новом Streams API в Java 8 от них отказались (что может вызвать трудности, когда ваши лямбды используют ввод-вывод или базы данных).

Хотите убедиться, что JVM ничего про них не знает? Запустите этот код:

public class Test {
    // Нету throws: исключения не объявлены
    public static void main(String[] args) {
        doThrow(new SQLException());
    }
 
    static void doThrow(Exception e) {
        Test.<RuntimeException> doThrow0(e);
    }
 
    @SuppressWarnings("unchecked")
    static <E extends Exception> 
    void doThrow0(Exception e) throws E {
        throw (E) e;
    }
}

Эта программа не только компилируется, но и на самом деле кидает SQLException. Вам даже не нужен @SneakyThrows из Lombok'а.

Более подробно об этом вы можете прочитать здесь, или здесь, на Stack Overflow.

2. Можно создать два метода, которые отличаются только возвращаемым типом


Такой код не скомпилируется, верно?

class Test {
    Object x() { return "abc"; }
    String x() { return "123"; }
}

Верно. Язык Java не позволяет в одном классе иметь два «эквивалентно перегруженных» метода, даже если они отличаются возвращаемым типом или объявленными исключениями.

Но погодите-ка. Давайте почитаем документацию к Class.getMethod(String, Class...). Там написано:
Обратите внимание, что класс может содержать несколько подходящих методов, потому что хотя язык Java и запрещает объявлять несколько методов с одинаковой сигнатурой, виртуальная машина Java всё же позволяет это, если отличается возвращаемый тип. Такая гибкость виртуальной машины может использоваться для реализации некоторых возможностей языка. Например, ковариантный возвращаемый тип может быть реализован с помощью бридж-метода, который отличается от реального перегруженного метода только возвращаемым типом.
О как! Да, звучит разумно. На самом деле так и произойдёт, если вы напишете:

abstract class Parent<T> {
    abstract T x();
}

class Child extends Parent<String> {
    @Override
    String x() { return "abc"; }
}

Вот такой байткод будет сгенерирован для класса Child:

// Method descriptor #15 ()Ljava/lang/String;
// Stack: 1, Locals: 1
java.lang.String x();
  0  ldc <String "abc"> [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. Синтетический бридж-метод генерируется компилятором, потому что в некоторых местах, где метод вызывается, в качестве возвращаемого типа Parent.x() может ожидаться Object. Было бы трудно добавить генерик-типы без бридж-методов и обеспечить бинарную совместимость. Меньшим злом оказалось доработать JVM, чтобы она поддерживала такую возможность (а в качестве побочного эффекта появились ковариантные возвращаемые типы). Умно получилось, да?

Интересуетесь перегрузкой по возвращаемому типу? Почитайте это обсуждение на Stack Overflow.

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[][] = {{}};
}

Скажете, безумие? А если ещё добавить к этому аннотации типов Java 8? Количество вариантов возрастает в разы!

@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 [] = {{}};
}

Аннотации типов. Загадочный и мощный механизм. Круче его загадочности разве что его мощь.

Или другими словами:

Мой последний коммит перед месячным отпуском
Мой последний коммит перед месячным отпуском

Найти реальный сценарий использования этих конструкций я оставляю вам в качестве упражнения.

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);

Больше подробностей на эту тему здесь.

5. Составной оператор присваивания вы тоже не понимаете


Не верите? Рассмотрим две строчки кода:

i += j;
i = i + j;

Интуитивно они должны быть эквивалентны, так? Сюрприз! Они отличаются. Как сказано в JLS:

Составной оператор присваивания вида E1 op= E2 эквивалентен выражению E1 = (T)((E1) op (E2)), где T — это тип E1, за исключением того, что E1 вычисляется только один раз.

Это настолько прекрасно, что я хотел бы процитировать ответ Питера Лори на Stack Overflow:

Пример такого приведения типов можно показать на *= или /=

byte b = 10;
b *= 5.7;
System.out.println(b); // выведет 57

или

byte b = 100;
b /= 2.5;
System.out.println(b); // выведет 40

или

char ch = '0';
ch *= 1.1;
System.out.println(ch); // выведет '4'

или

char ch = 'A';
ch *= 1.5;
System.out.println(ch); // выведет 'a'

Видите, какая полезная фича? Теперь я буду умножать свои символы с автоматическим приведением типов. Потому что, знаете ли…

6. Случайные целые числа


Это скорее загадка. Не подглядывайте в решение, попробуйте догадаться сами. Когда я запускаю такой код:

for (int i = 0; i < 10; i++) {
  System.out.println((Integer) i);
}

«в некоторых случаях» получаю такой результат:

92
221
45
48
236
183
39
193
33
84

Как это возможно??

Разгадка
Ответ приводится здесь и заключается в перезаписи кэша целых чисел JDK с помощью reflection и в использовании автобоксинга. Не пытайтесь повторить это дома! Ну или вспомните картинку выше про последний коммит перед отпуском.

7. GOTO


А вот моё любимое. В Java есть GOTO! Попробуйте:

int goto = 1;

И вы получите:

Test.java:44: error: <identifier> expected
    int goto = 1;
        ^

Всё потому, что goto — это неиспользуемое ключевое слово. На всякий случай, вдруг когда пригодится.

Но это ещё не самое интересное. Больше всего впечатляет, что вы на самом деле можете реализовать goto с помощью break, continue и блоков с метками:

Прыжок вперёд:

label: {
  // ... какой-то код...
  if (check) break label;
  // ...ещё какой-то код...
}

В байт-коде:

2  iload_1 [check]
3  ifeq 6          // Прыжок вперёд
6  ..

Прыжок назад

label: do {
  // ... какой-то код...
  if (check) continue label;
  // ...ещё какой-то код...
  break label;
} while(true);

В байт-коде:


 2  iload_1 [check]
 3  ifeq 9
 6  goto 2          // Прыжок назад
 9  ..


8. В Java есть алиасы к типам


В других языках, например, в (Цейлоне), мы можем легко объявить алиас для типа:

interface People => Set<Person>;

Тип People создан таким образом, что может в любом месте использоваться вместо Set<Person>:

People?      p1 = null;
Set<Person>? p2 = p1;
People?      p3 = p2;

В Java мы не можем объявлять алиасы типов глобально. Но это возможно сделать в пределах класса или метода. Предположим, нам не нравятся длинные имена Integer, Long и т. д., мы хотим вместо них использовать короткие: I и L. Легко:

class Test<I extends Integer> {
    <L extends Long> void x(I i, L l) {
        System.out.println(
            i.intValue() + ", " + 
            l.longValue()
        );
    }
}

В этом коде I — это алиас для Integer в пределах класса Test, а L — алиас для Long в пределах метода x(). Мы можем спокойно вызывать этот метод:

new Test().x(1, 2L);

Конечно, такую методику нельзя воспринимать всерьёз. В нашем случае типы Integer и Long объявлены final, и это означает, что генерик-типы I и L эффективно алиасы (ну почти: совместимость при присваивании работает только в одну сторону). Если бы мы использовали типы, не объявленные как final (например, Object), то это были бы обычные генерики.

Ну хватит уже дурацких фокусов. Пришло время чего-то посерьёзнее!

9. Некоторые отношения между типами невычислимы!


Окей, сейчас будет реально круто, так что налейте себе кофе и сконцентрируйтесь. Рассмотрим следующие типы:

// Вспомогательный тип. Можно использовать и просто List
interface Type<T> {}

class C implements Type<Type<? super C>> {}
class D<P> implements Type<Type<? super D<D<P>>>> {}

Что же на самом деле означают типы C и D?

Они в каком-то смысле рекурсивны и чем-то похожи (хотя и не полностью) на объявление типа java.lang.Enum. Смотрите:

public abstract class Enum<E extends Enum<E>> { ... }

Фактически объявление enum — это синтаксический сахар:

// Это
enum MyEnum {}

// На самом деле сахар для этого
class MyEnum extends Enum<MyEnum> { ... }

Запомним это и вернёмся к нашим исходным типам. Скомпилируется ли такой код?

class Test {
    Type<? super C> c = new C();
    Type<? super D<Byte>> d = new D<Byte>();
}

Вопрос сложный и у Росса Тейта есть ответ. Определить это вообще-то нельзя:

Является ли C подтипом Type<? super C>?
Шаг 0) C является ли Type<? super C>
Шаг 1) Type<Type<? super C>> является ли Type<? super C> (наследование)
Шаг 2) C является ли Type<? super C> (проверяем маску? super C)
Шаг... (бесконечный цикл)

Если с C мы просто зацикливаемся, то с D ещё веселее:
Является ли D<Byte> подтипом Type<? super D<Byte>>?
Шаг 0) D<Byte> является ли Type<? super D<Byte>>
Шаг 1) Type<Type<? super D<D<Byte>>>> является ли Type<? super D<Byte>>
Шаг 2) D<Byte> является ли Type<? super D<D<Byte>>>
Шаг 3) Type<Type<? super D<D<Byte>>>> является ли Type<? super D<D<Byte>>>
Шаг 4) D<D<Byte>> является ли Type<? super D<D<Byte>>>
Шаг... (бесконечный рост)

Попытайтесь скомпилировать это в Eclipse, и он упадёт с переполнением стека! (не беспокойтесь, я уже сообщил в багтрекер)
Придётся смириться:
Некоторые отношения между типами невычислимы!

Если вас интересуют проблемы, связанные с генерик-типами, почитайте статью Росса Тейта «Укрощение шаблонов в системе типов Java» (в соавторстве с Аланом Лёнгом и Сорином Лернером), или наши размышления на тему.

10. Пересечение типов


В языке Java есть весьма своеобразная штука — пересечение типов. Вы можете объявить генерик-тип, который является пересечением двух типов. Например:

class Test<T extends Serializable & Cloneable> {
}

Тип, которому соответствует T в конкретных экземплярах класса Test, должен реализовывать оба интерфейса Serializable и Cloneable. К примеру, String не подойдёт, а вот Date — вполне:

// Ошибка компиляции
Test<String> s = null;

// Работает
Test<Date> d = null;

Эта возможность получила развитие в Java 8, где вы можете преобразовать тип к пересечению. Где это может пригодиться? Почти нигде, но если вам надо привести лямбда-выражение к такому типу, у вас нет других вариантов. Предположим, у вашего метода вот такое безумное ограничение на тип:

<T extends Runnable & Serializable> void execute(T t) {}

Вас устраивает только такое Runnable, которое ещё и Serializable на тот случай, если вы захотите передать его по сети и запустить где-то ещё. В принципе лямбды сериализовать можно:
Вы можете сериализовать лямбда-выражение, если его целевой тип и захваченные аргументы сериализуемы.
Но даже если это соблюдается, лямбда не будет автоматически реализовывать маркерный интерфейс Serializable. Вам потребуется приведение типа. Но если вы приведёте только к Serializable:

execute((Serializable) (() -> {}));

то лямбда больше не будет Runnable.

Эх…

Остаётся…

Привести к двум типам сразу:

execute((Runnable & Serializable) (() -> {}));

Заключение


Обычно я говорю это только про SQL, но пришло время завершить статью вот так:
Java — загадочный и мощный механизм. Круче его загадочности разве что его мощь.
Tags:
Hubs:
+89
Comments 55
Comments Comments 55

Articles