Привет! Представляю вашему вниманию перевод статьи "5 Hidden Secrets in Java" автора Justin Albano.
Хотите стать джедаем Java? Раскройте древние секреты Java. Мы сосредоточимся на расширении аннотаций, инициализации, на комментариях и интерфейсах enum.
По мере развития языков программирования начинают появляться и скрытые функции, а конструкции, о которых никогда не задумывались основатели, все больше распространяются для всеобщего использования. Некоторые из этих функций становятся общепринятыми в языке, тогда как другие отодвигаются в самые темные уголки языкового сообщества. В этой статье мы рассмотрим пять секретов, которые часто упускаются из виду многими разработчиками Java (справедливости ради, некоторые из них имеют веские на это причины). Мы рассмотрим как варианты их использования, так и причины, которые привели к появлению каждой функции, а также некоторые примеры, демонстрирующие, когда целесообразно использовать эти функции.
Читатель должен понимать, что не все эти функции на самом деле скрыты, просто они часто не используются в повседневном программировании. Некоторые из них могут быть очень полезны в подходящий момент, тогда как использование других – почти всегда плохая идея, и показаны они в этой статье, чтобы заинтересовать читателя (и возможно рассмешить его или ее). Читатель также должен сам принимать решение когда использовать функции, описанные в этой статье: «То, что это можно сделать, не означает, что это нужно делать».
1. Реализация аннотаций
Начиная с Java Development Kit (JDK) 5, аннотации являются неотъемлемой частью многих приложений и сред Java. В подавляющем большинстве случаев аннотации применяются к конструкциям, таким как классы, поля, методы и т.д. Однако их можно использовать и как реализуемые интерфейсы. Например, предположим, у нас есть следующее определение аннотации:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
String name();
}
Обычно мы применяем эту аннотацию к методу, как показано ниже:
public class MyTestFixure {
@Test
public void givenFooWhenBarThenBaz() {
// ...
}
}
Затем мы можем обработать эту аннотацию, как это описано в Создание аннотаций в Java. Если бы мы также хотели создать интерфейс, позволяющий создавать тесты как объекты, нам пришлось бы создать новый интерфейс, назвав его чем-то другим, а не Test:
public interface TestInstance {
public String getName();
}
Далее мы можем создать экземпляр объекта TestInstance:
public class FooTestInstance implements TestInstance {
@Override
public String getName() {
return "Foo";
}
}
TestInstance myTest = new FooTestInstance();
Хотя наша аннотация и интерфейс практически идентичны, с очень заметным дублированием, похоже что нет способа объединить эти две конструкции. К счастью, внешность обманчива, и есть метод для объединения этих двух конструкций: Реализация аннотации:
public class FooTest implements Test {
@Override
public String name() {
return "Foo";
}
@Override
public Class<? extends Annotation> annotationType() {
return Test.class;
}
}
Обратите внимание, что мы должны реализовать метод annotationType и также возвращать тип аннотации, поскольку это неявная часть интерфейса Annotation. Хотя почти во всех случаях реализация аннотации не являются правильным решением для проектирования (компилятор Java будет показывать предупреждение при реализации интерфейса), это может быть полезно в некоторых случаях, например в annotation-driven framework.
2. Нестатические блоки инициализации.
В Java, как и в большинстве объектно-ориентированных языков программирования, объекты создаются исключительно с использованием конструктора (с некоторыми исключениями, такими как десериализация объектов Java). Даже когда мы создаем статические фабричные методы для создания объектов, мы просто заключаем вызов в конструктор объекта, чтобы создать его экземпляр. Например:
public class Foo {
private final String name;
private Foo(String name) {
this.name = name;
}
public static Foo withName(String name) {
return new Foo(name);
}
}
Foo foo = Foo.withName("Bar");
Поэтому, когда мы хотим инициализировать объект, мы объединяем логику инициализации в конструкторе объекта. Например, устанавливаем поле name класса Foo в его параметризованном конструкторе. Хотя может показаться обоснованным предположение, что вся логика инициализации находится в конструкторе или наборе конструкторов для класса, в Java это не так. Вместо этого мы можем использовать нестатические блоки инициализации чтобы выполнить код при создании объекта:
public class Foo {
{
System.out.println("Foo:instance 1");
}
public Foo() {
System.out.println("Foo:constructor");
}
}
Нестатические блоки инициализации указываются путем добавления логики инициализации в набор фигурных скобок в определение класса. Когда объект создается, сначала вызываются нестатические блоки инициализации, а затем конструкторы объекта. Обратите внимание, что можно указать более одного нестатического блока инициализации, и в этом случае каждый вызывается в том порядке, в котором он указан в определении класса. Помимо нестатических блоков инициализации, мы также можем создавать и статические, которые выполняются когда класс загружается в память. Чтобы создать статический блок инициализации, мы просто добавляем ключевое слово static:
public class Foo {
{
System.out.println("Foo:instance 1");
}
static {
System.out.println("Foo:static 1");
}
public Foo() {
System.out.println("Foo:constructor");
}
}
Когда в классе присутствуют все три метода инициализации (конструкторы, нестатические блоки инициализации и статические блоки инициализации), статические всегда выполняются первыми (когда класс загружается в память) в порядке их объявления, затем выполняются нестатические блоки инициализации в порядке, в котором они объявлены, а после них – конструкторы. Когда вводится суперкласс, порядок выполнения немного меняется:
- Статические блоки инициализации суперкласса, в порядке их объявления
- Статические блоки инициализации подкласса, в порядке их объявления
- Нестатические блоки инициализации суперкласса, в порядке их объявления
- Конструктор суперкласса
- Нестатические блоки инициализации подкласса, в порядке их объявления
- Конструктор подкласса
Например, мы можем создать следующее приложение:
public abstract class Bar {
private String name;
static {
System.out.println("Bar:static 1");
}
{
System.out.println("Bar:instance 1");
}
static {
System.out.println("Bar:static 2");
}
public Bar() {
System.out.println("Bar:constructor");
}
{
System.out.println("Bar:instance 2");
}
public Bar(String name) {
this.name = name;
System.out.println("Bar:name-constructor");
}
}
public class Foo extends Bar {
static {
System.out.println("Foo:static 1");
}
{
System.out.println("Foo:instance 1");
}
static {
System.out.println("Foo:static 2");
}
public Foo() {
System.out.println("Foo:constructor");
}
public Foo(String name) {
super(name);
System.out.println("Foo:name-constructor");
}
{
System.out.println("Foo:instance 2");
}
public static void main(String... args) {
new Foo();
System.out.println();
new Foo("Baz");
}
}
Если мы выполним этот код, то получим следующий вывод:
Bar:static 1
Bar:static 2
Foo:static 1
Foo:static 2
Bar:instance 1
Bar:instance 2
Bar:constructor
Foo:instance 1
Foo:instance 2
Foo:constructor
Bar:instance 1
Bar:instance 2
Bar:name-constructor
Foo:instance 1
Foo:instance 2
Foo:name-constructor
Обратите внимание на то что статические блоки инициализации были выполнены только один раз, даже если были созданы два объекта Foo. Хотя нестатистические и статические блоки инициализации могут быть полезны, логика инициализации должна быть помещена в конструкторы, а методы (или статические методы) должны использоваться в случаях когда сложная логика требует инициализации состояния объекта.
3. Двойная скобка инициализации
Многие языки программирования включают в себя некоторый синтаксический механизм для быстрого и краткого создания списка или карты (или словаря) без использования подробного шаблонного кода. Например, C ++ включает в себя инициализацию скобок, которая позволяет разработчикам быстро создавать список перечисляемых значений или даже инициализировать целые объекты, если конструктор для объекта поддерживает эту функцию. К сожалению, до JDK 9 такая функция не была реализована (об этом позже). Чтобы просто создать список объектов, мы бы сделали следующее:
List<Integer> myInts = new ArrayList<>();
myInts.add(1);
myInts.add(2);
myInts.add(3);
Хотя это и выполняет нашу цель по созданию нового списка, инициализированного тремя значениями, это слишком многословно, требуя от разработчика повторения имени переменной списка для каждого добавления. Чтобы сократить этот код, мы можем использовать двойную инициализацию скобок:
List < Integer >List<Integer> myInts = new ArrayList<>() {{
add(1);
add(2);
add(3);
}};
Инициализация с двойной скобкой, которая получила свое название от набора двух открытых и закрытых фигурных скобок, на самом деле представляет собой совокупность нескольких синтаксических элементов. Сначала мы создаем анонимный внутренний класс, который расширяет класс ArrayList. Поскольку ArrayList не имеет абстрактных методов, мы можем создать пустое тело для анонимной реализации:
List<Integer> myInts = new ArrayList<>() {};
Используя этот код, мы по существу создаем анонимный подкласс, ArrayList – точно такой же, как и оригинальный ArrayList. Одно из основных отличий в том, что наш внутренний класс имеет неявную ссылку на содержащий класс (в форме захваченной this переменной), т.к. мы создаем нестатический внутренний класс. Это позволяет нам написать некоторую интересную, если не запутанную логику. Например, добавление этой переменной к анонимному внутреннему классу, инициализированному двойной скобкой:
public class Foo {
public List<Foo> getListWithMeIncluded() {
return new ArrayList<Foo>() {{
add(Foo.this);
}};
}
public static void main(String... args) {
Foo foo = new Foo();
List<Foo> fooList = foo.getListWithMeIncluded();
System.out.println(foo.equals(fooList.get(0)));
}
}
Если бы этот внутренний класс был определен как статический, у нас не было бы доступа к Foo.this. Например, следующий код, который создает статический FooArrayList внутренний класс, не имеет доступа к Foo.this ссылке и поэтому он не компилируется:
public class Foo {
public List<Foo> getListWithMeIncluded() {
return new FooArrayList();
}
private static class FooArrayList extends ArrayList<Foo> {{
add(Foo.this);
}}
}
Возобновляя конструкцию нашей инициализированной двойной скобкой ArrayList, как только был создан нестатический внутренний класс, мы используем нестатические блоки инициализации, как описано выше, чтобы добавить три начальных элемента, при создании экземпляра анонимного внутреннего класса. Когда анонимный внутренний класс создан и когда существует только один объект анонимного внутреннего класса, мы можем сказать что создали нестатический внутренний объект, который добавляет три начальных элемента при его создании. Это будет видно, если мы разделим пару фигурных скобок, где одна фигурная скобка представляет собой определение анонимного внутреннего класса, а другая обозначает начало логики инициализации экземпляра:
List<Integer> myInts = new ArrayList<>() {
{
add(1);
add(2);
add(3);
}
};
Хотя этот трюк может быть полезен, JDK 9 (JEP 269) заменил полезность этого трюка набором статических фабричных методов для List (а также многих других типов коллекций). Например, мы могли бы создать List раньше, используя эти статические фабричные методы, как показано далее:
List<Integer> myInts = List.of(1, 2, 3);
Эта статическая фабричная техника используется по двум основным причинам: (1) не создается анонимный внутренний класс и (2) для сокращения стандартного кода, необходимого для создания List. Следует помнить что в таком случае полученный результат List является неизменным и не может быть изменен после его создания. Чтобы создать изменяемый List файл с любыми начальными элементами, нам приходится использовать обычный метод или метод с двойной скобкой инициализации.
Обратите внимание что простая инициализация, двойная скобка и статические фабричные методы JDK 9 не просто доступны для List. Они доступны для Set и Map объектов, как показано в следующем фрагменте:
// Простая инициализация
Map<String, Integer> myMap = new HashMap<>();
myMap.put("Foo", 10);
myMap.put("Bar", 15);
// Инициализация с двумя скобками
Map<String, Integer> myMap = new HashMap<>() {{
put("Foo", 10);
put("Bar", 15);
}};
// Статическая фабричная инициализация
Map<String, Integer> myMap = Map.of("Foo", 10, "Bar", 15);
Важно понять как происходит инициализация двойной скобки, прежде чем принимать решение об ее использовании. Это улучшает читабельность кода, однако могут появится некоторые побочные эффекты.
4. Исполняемые комментарии
Комментарии являются неотъемлемой частью почти каждой программы, и основное преимущество комментариев заключается в том, что они не выполняются. Это становится еще более очевидным, когда мы закомментируем строку кода в нашей программе: мы хотим сохранить код в нашем приложении, но не хотим, чтобы он выполнялся. Например, следующая программа в результате выводит «5»:
public static void main(String args[]) {
int value = 5;
// value = 8;
System.out.println(value);
}
Многие думают что комментарии никогда не выполняются, но это не совсем верно. Например, что выведет следующий фрагмент кода?
public static void main(String args[]) {
int value = 5;
// u000dvalue = 8;
System.out.println(value);
}
Вы могли предположить что это снова 5, но если мы запустим приведенный выше код, то увидим 8 на выходе. Причиной этой «ошибки» является символ Unicode u000d; этот символ на самом деле является возвратом каретки Unicode, и исходный код Java используется компилятором как текстовые файлы в формате Unicode. Его добавление в код присваивает значению value = 8 в строке, идущей за комментарием, обеспечивая его выполнение. Это означает, что приведенный выше фрагмент кода фактически равен следующему:
public static void main(String args[]) {
int value = 5;
//
value = 8;
System.out.println(value);
}
Хотя это кажется ошибкой Java, на самом деле это специально добавленная функция в язык. Первоначальная цель состояла в том, чтобы создать независимый от платформы язык (отсюда создание виртуальной машины Java или JVM), и функциональная совместимость исходного кода является ключевым аспектом этой цели. Позволяя исходному коду Java содержать символы Unicode, мы можем использовать нелатинские символы универсальным способом. Это гарантирует, что код, написанный в одном регионе мира (который может содержать нелатинские символы, например в комментариях), может быть выполнен в любом другом. Для получения дополнительной информации см. Раздел 3.3 Спецификации языка Java или JLS.
Мы можем довести это до крайности и даже написать целое приложение в Unicode. Например, что делает следующая программа (исходный код, получен из Java: Выполнение кода в комментариях ?!)?
u0070u0075u0062u006cu0069u0063u0020u0020u0020u0020
u0063u006cu0061u0073u0073u0020u0055u0067u006cu0079
u007bu0070u0075u0062u006cu0069u0063u0020u0020u0020
u0020u0020u0020u0020u0073u0074u0061u0074u0069u0063
u0076u006fu0069u0064u0020u006du0061u0069u006eu0028
u0053u0074u0072u0069u006eu0067u005bu005du0020u0020
u0020u0020u0020u0020u0061u0072u0067u0073u0029u007b
u0053u0079u0073u0074u0065u006du002eu006fu0075u0074
u002eu0070u0072u0069u006eu0074u006cu006eu0028u0020
u0022u0048u0065u006cu006cu006fu0020u0077u0022u002b
u0022u006fu0072u006cu0064u0022u0029u003bu007du007d
Если поместить вышеупомянутый код в файл с именем Ugly.java и запустить его, то будет напечатано Hello world на стандартном выходе. Если мы преобразуем эти символы Юникода в символы Американского Стандартного Кода для Обмена Информацией (ASCII), то получим следующую программу:
public class Ugly {
public static void main(String[] args){
System.out.println("Hello w"+"orld");
}
}
Итак, символы Unicode могут быть включены в исходный код Java, однако если они не требуется настоятельно не рекомендуется их использовать (например, включать нелатинские символы в комментарии). Если они все же требуются, убедитесь, что они не включают символы, такие как возврат каретки, которые изменяют ожидаемое поведение исходного кода.
5. Реализация интерфейса Enum
Одним из ограничений enums(списка перечислений) по сравнению с другими классами в Java является то, что перечисления не могут расширять другой класс или сами enums. Например, невозможно выполнить следующее:
public class Speaker {
public void speak() {
System.out.println("Hi");
}
}
public enum Person extends Speaker {
JOE("Joseph"),
JIM("James");
private final String name;
private Person(String name) {
this.name = name;
}
}
Person.JOE.speak();
Однако мы можем заставить наш enums реализовывать интерфейс и обеспечить реализацию для его абстрактных методов следующим образом:
public interface Speaker {
public void speak();
}
public enum Person implements Speaker {
JOE("Joseph"),
JIM("James");
private final String name;
private Person(String name) {
this.name = name;
}
@Override
public void speak() {
System.out.println("Hi");
}
}
Person.JOE.speak();
Теперь мы можем использовать экземпляр Person везде, где требуется Speaker объект. Более того, мы также можем обеспечить реализацию абстрактных методов интерфейса на постоянной основе (так называемые методы, специфичные для констант):
public interface Speaker {
public void speak();
}
public enum Person implements Speaker {
JOE("Joseph") {
public void speak() { System.out.println("Hi, my name is Joseph"); }
},
JIM("James"){
public void speak() { System.out.println("Hey, what's up?"); }
};
private final String name;
private Person(String name) {
this.name = name;
}
@Override
public void speak() {
System.out.println("Hi");
}
}
Person.JOE.speak();
В отличие от некоторых других секретов в этой статье, эту технику следует использовать только там, где это необходимо. Например, если enum константа, такая как JOE или JIM, может использоваться вместо интерфейса, такого как Speaker, то enum определяющее константу, должен реализовывать этот тип интерфейса. Для получения дополнительной информации см. Пункт 38 (стр. 176-9) Effective Java, 3rd Edition.
Заключение
В этой статье мы рассмотрели пять скрытых секретов в Java, а именно: (1) аннотации можно расширять, (2) нестатические блоки инициализации можно использовать для настройки объекта при его создании, (3) инициализацию с двойными скобками можно использовать для выполнения инструкции при создании анонимного внутреннего класса, (4) комментарии иногда могут выполняться, и (5) enums могут реализовывать интерфейсы. Хотя эти функции используются определенного типа задач, некоторых из них следует избегать (например создание исполняемых комментариев). Принимая решение об использовании этих секретов, обязательно соблюдайте правило: «То, что это можно сделать, не означает, что это нужно делать».
Автор: Яхин Азамат