Этой публикацией мы начинаем серию небольших статей с разбором «сложных» заданий из сертификации для Java‑разработчиков, чтобы помочь вам подготовиться к сдаче сертификационного экзамена и помочь вам лучше понять, как работает Java и как эффективнее использовать его в своих проектах.
Итак, один из подвопросов, традиционно выносимых на экзамен — будь это сертификация Oracle или сертификации для Java‑разработчиков от Учебного центра IBS, — интересуется разработкой кода с использованием классов‑оболочек, в частности, Boolean, Double или, скажем, Integer. Такая задача может быть сформулирована в следующей форме:
Дано:
String five = "5";
Boolean boo = Boolean.valueOf(five); // строка X
Integer i1 = new Integer(five);
Integer i2 = 5;
if (boo) System.out.print(i1 == i2);
Каков результат?
A. Выброс исключения на строке Х
B. true
С. false
D. Код успешно компилируется и исполняется, но ничего не печатает
Как мы видим, в вопросе задействованы два класса‑оболочки, а именно Boolean (в лице его статического фабричного метода) и Integer. Следует отметить, что для нашего экзамена характерен подход, сочетающий в себе сразу несколько тестируемых аспектов. Поэтому не всегда очевидно, чем именно задача интересуется: тем, как работают «фабрики», механизмом автоупаковки/распаковки или оператором сравнения и т. д.
Обсудим все по порядку. Практически в каждом классе‑оболочке есть два популярных статических метода для создания объекта: valueOf(), который принимает подстилающий примитив, и parseXXX(), который — как и всякий парсер — принимает стринг (здесь вместо XXX надо подставить, например, Double, Int и т. д.). Исключением является класс Character: в нем парсера нет. Встречаются и перегруженные парсеры, где можно указать систему счисления, но наш экзамен таких тонкостей не касается.
Далее, присваивание примитива к ссылке на класс‑оболочку приводит к автоупаковке (примитив, разумеется, должен быть при этом соответствующего типа), поэтому такой подход часто используется при создании объектов, одно из преимуществ — возможность вызвать конструктор, хотя этот способ был депрецирован еще в 9-й версии. Постараемся дать ответ, почему такая депрекация вообще понадобилась.
Мы знаем, что при вызове конструктора через ключевое слово new возможны два результата: мы получим совершенно новый объект данного типа, либо вылетит исключение. Из‑за такой ограниченности функционала конструкторов предпочтение отдается фабричным методам, которые — в отличие от любого конструктора — способны вернуть ссылку на уже существующий объект.
И вот что интересно: поскольку класс‑оболочка Integer является немутирующим, два объекта этого класса с одним и тем же значением способны взаимозаменять друг друга. Вот почему нет никакой необходимости иметь два разных объекта с одинаковым подстилающим значением. Мало того, помимо экономии памяти, мы получаем также возможность сравнивать объекты через == вместо вызова метода equals(Object o). В частности, для класса Integer такой подход популярен с объектами, чьи значения лежат в пределах диапазона байта, т. е. от -128 до 127 (и если тут возникает ощущение дежавю, то все правильно: со стрингами аналогичная история, мы предпочитаем пользоваться строковыми константами в двойных кавычках вместе вызова, скажем, new String(«5»).
Фабричные методы обладают и другими преимуществами. Например, мы можем иметь несколько аналогичных методов с разными именами, но одинаковыми списками формальных параметров. А вот с конструкторами такой фокус не пройдет: их можно только перегрузить.
В книге Джошуа Блока, «Effective Java«, приведен полный список преимуществ статических фабричных методов над конструкторами. Еще одна выгода в том, что конструктор способен вернуть объект одного типа, а вот фабричный метод может вернуть что угодно, если это не противоречит задекларированному типу возвращаемого значения. Одно из наиболее наглядных и понятных проявлений — это имплементация интерфейсов, где можно аккуратно скрыть специфические детали реализации.
Метод Boolean.valueOf() может вернуть одно из двух значений, причем это константные объекты, а именно: Boolean.TRUE и Boolean.FALSE. Именно эти объекты будут использоваться раз за разом, не требуя дополнительного выделения памяти, что не представляется возможным с ключевым словом new.
Далее, большинство оболочечных классов бросают исключение в случае null‑аргумента или стринга, чей формат нарушает требования к подстилающему примитиву (например, если бы мы вызвали Integer.valueOf() с аргументом «five», а не «5»). Но здесь следует отметить, что парсер класса java.lang.Boolean всегда тестирует аргумент: во‑первых, существует ли он и, во‑вторых, содержит ли значение «true» при любом сочетании букв в верхнем и нижнем регистрах. Если да, булевый парсер вернет Boolean.TRUE, а в противном случае — Boolean.FALSE. Другими словами, парсеру можно скормить хоть «что угодно», хоть даже null — и он просто вернет Boolean.FALSE без вылета какого‑либо исключения.
Вот почему строка Х в нашем примере не бросает исключение и присваивает переменной boo значение Boolean.FALSE. Следовательно, вариант A неверный.
Теперь давайте займемся поведением оператора if. Мы знаем, что тестируемое выражение обязано иметь тип boolean. Привыкнув к механизму автоупаковки и распаковки, мы ожидаем, что Boolean‑объект будет транспарентно распакован в подстилающий примитив. Так оно и случится, поэтому код скомпилируется — но из‑за того, что наш экзамен рассчитан на Java11; До появления автоупаковки в Java5 эта же строчка вызвала бы синтаксическую ошибку. Впрочем, в нашей задаче вообще нет такого варианта.
После распаковки булевый тест получит значение false, и, стало быть, оператор печати не исполнится. Что ж, выходит, вариант D — единственный правильный.
А теперь давайте задумаемся над тем, что было бы, если бы булевый тест дал значение true.
Как мы знаем, Java предоставляет две идиомы сравнения. Одна из них является конструкцией, встроенной непосредственно в лексику языка: это оператор ==. Вторая идиома — а именно метод equals(Object o) — предоставлена стандартной библиотекой и, будучи частью функционала класса java.lang.Object, доступна любому объекту. Она не делает ничего полезного и своим поведением не отличается от оператора «double equals«, поэтому классам‑наследникам предписывается по‑своему решать, каким образом переопределить такое поведение. Метод equals() имеет интересный контракт из пяти пунктов, но в нашем примере он не используется, поэтому мы не будем обсуждать его; вместо этого давайте займемся оператором ==.
Мы знаем, что «double equals» принимает два операнда, точнее, два выражения. Нюанс в том, что выражения могут иметь разные типы — и это различие сказывается на результате сравнения самым радикальным образом.
Что за разные типы? Это наши старые знакомые: примитивы (коих в Java восемь: boolean, byte, short, char, int, long, float и, наконец, double), либо то, что мы именуем термином ссылка (reference). Напомним, что ссылка в Java напоминает указатель (pointer), который показывает, где именно в памяти расположен тот или иной объект.
Если выражение имеет примитивный тип, то это бинарная репрезентация некоего числа, например 0b101 010, то есть десятичное 42. Это число будет записано в стеке, и ему будет присвоена некая метка, которую мы привыкли называть словом «переменная». Но вот если речь идет не про примитив 42, а про Integer‑объект, который инкапсулирует подстилающий примитив 42, то в стеке будет сидеть уже не 0b101 010, а нечто куда более «вихрастое», например 0xffff5637. Эта комбинация шестнадцатиричных литералов, по сути, является значением ссылки на объект, который в языке Java живет не в стеке, а в т. н. динамически аллоцируемой памяти, в знаменитой «куче» (heap). К примеру, в языке C/С++, прародителе Java, значением пойнтера является адрес объекта. В Java ситуация несколько сложнее, но в первом приближении мы тоже можем сказать, что значением ссылки (которая живет в стеке) является адрес объекта (который, напомним, живет на heap'е). Что в итоге? Пусть оператор «double equals» сравнивает значения двух переменных. Если это примитивы, JVM сравнит их бинарные значения, то есть — числа. Если это ссылки, то JVM сравнит их значения, но ведь значением ссылки является адрес того объекта, на который данная ссылка указывает. Вот почему принято говорить, что оператор == проверяет эквивалентность примитивных значений, а в случае ссылок он сравнивает уже идентичность объектов. Если у двух ссылочных переменных (например, класса Integer) одно и то же значение, мы имеем дело с одним и тем же объектом (один и тот же адрес!), но если объектов два, то они расположены по разным адресам. В этой ситуации оператор == вернет false.
Сейчас не должно вызывать удивления, что данный код:
Integer v1 = new Integer("1");
Integer v2 = new Integer("1");
System.out.print(v1 == v2);
обязательно покажет false: ведь успешный вызов конструктора через new, как мы уже упоминали, приведет к рождению очередного объекта. Простое правило на нашем экзамене: сколько видим слов new, как минимум столько же будет объектов. Стало быть, переменные v1 и v2 по необходимости ссылаются на разные объекты, и сравнение их адресов оператором == даст нам false.
А вот небольшая вариация на эту же тему: пусть у нас такой код:
Integer v1 = new Integer("1");
Integer v2 = 1;
System.out.print(v1 == v2);
Опять мы видим new, и это означает, что у нас вновь два разных объекта: один родился благодаря стараниям конструктора, а второй прилетел к нам из статического фабричного метода, который был вызван имплицитно и полностью для нас транспарентно механизмом автоупаковки.
В заключение следует отметить, что фабрики для немутирующих объектов часто пишутся так, чтобы возвращать все тот же объект, если в метод были переданы те же аргументы. В частности, в документации на java.lang.Integer API мы видим следующую ремарку о методе valueOf(int):
“This method will always cache values in the range -128 to 127, inclusive, and may cache other values outside of this range.”
Другими словами, следующий кодовый сниппет:
Integer v1 = Integer.valueOf(1);
Integer v2 = Integer.valueOf(1);
System.out.print(v1 == v2);
даст нам true.
Официально задокументированная гарантия присутствует лишь для метода valueOf(int), хотя на практике valueOf(String)также демонстрирует это же поведение: Integer‑объекты в пределах диапазона байта помещаются в константный пул.
Подведем итоги: поскольку код пользуется механизмом автоупаковки (в форме вызова Integer.valueOf(int) для создания одного объекта, а для второго применяет вызов конструктора, это означает, что, если бы отработал оператор печати, мы бы увидели false. Поэтому правильным вариантом ответа остается D, о чем мы уже упоминали.