Однажды я потратил три часа на поиск бага. Три чертовых часа! Программа вроде работала, но результаты расчетов постоянно плавали. И только случайно я заметил, что
0.3 + 0.4 у меня не равно
0.7. Вот тогда-то я и понял, что сравнение в Java это не такая простая штука, как кажется.
Это продолжение первой статьи про сравнение, где мы разбирали теорию. А сейчас давай копнем глубже и посмотрим, где Java может тебя неприятно удивить. Поговорим про строки, вещественные числа и всякие неочевидные моменты, о которых на собеседованиях обожают спрашивать. Если нужен короткий ответ прямо сейчас: оператор
== у объектов сравнивает ссылки, а не содержимое, поэтому строки и обертки сравнивают через
equals(), вещественные числа с погрешностью, а
enum можно спокойно и через
==.
![Две руки держат красное и зеленое яблоки: наглядная метафора сравнения объектов]()
Кратко
- == у ссылочных типов сравнивает адреса объектов, а не значения, и только у примитивов сравнивает сами значения.
- Со строками == иногда «работает» из-за пула литералов, но ломается на конкатенации и на new String(). Правильный способ это equals().
- Вещественные числа не сравнивают напрямую: 0.3f + 0.4f не равно 0.7f. Сравнивают с погрешностью, а для денег берут BigDecimal.
- NaN не равен даже самому себе, а +0.0 и -0.0 оператор == считает равными, хотя деление на них дает бесконечности разного знака.
- Обертки Integer, Long, Short, Byte, Character кешируют маленькие значения, поэтому == для 127 даст true, а для 128 уже false.
- Элементы enum существуют в единственном экземпляре, так что тут == честен и безопасен.
Строки и оператор ==: история одного предательства
Знаешь, строки в Java это как хороший друг, который иногда ведет себя странно. Вроде все нормально, но потом бац, и сюрприз.
Смотри, что происходит:
String str1 = "JavaRush";
String str2 = "JavaRush";
System.out.println(str1 == str2 ? "одна и та же" : "разные");
Запусти этот код. Результат будет "одна и та же". Погоди, разве мы не учили, что строки надо сравнивать через
equals? Да, но тут есть нюанс. Компилятор Java тот еще хитрец. Он видит два одинаковых литерала и думает: "Зачем создавать два объекта? Создам один, а переменным дам ссылки на него". Экономия памяти, все дела.
Но попробуй вот так:
String str1 = "JavaRush";
String str2 = "Java";
String str3 = "Rush";
System.out.println(str1 == (str2 + str3) ? "одна и та же" : "разные");
Теперь уже "разные"! Потому что конкатенация создает новый объект. И ссылки получаются разные.
А можно и вообще так:
String str1 = "JavaRush";
String str2 = new String("JavaRush");
System.out.println(str1 == str2 ? "одна и та же" : "разные");
Опять "разные". Ключевое слово
new говорит Java: "Я хочу НОВЫЙ объект, даже если такая строка уже есть".
Вот так и попадаются джуны на собеседованиях. Используют
== для строк, код вроде работает, а потом бац, и баг в продакшене. На курсе JavaRush мы этому посвящаем целый модуль, потому что штука реально важная.
И еще одна ловушка, про которую вспоминают уже в проде:
equals вызывается
у объекта. Если слева окажется
null, программа упадет с
NullPointerException. С Java 7 для таких случаев есть
Objects.equals(a, b) из пакета
java.util: он сам проверит обе ссылки и вернет
true, только если оба аргумента равны или оба равны
null.
String s1 = null;
String s2 = "JavaRush";
// System.out.println(s1.equals(s2)); // NullPointerException
System.out.println(Objects.equals(s1, s2)); // false
System.out.println(Objects.equals(null, null)); // true
Разница простая:
equals требует, чтобы слева был живой объект, а
Objects.equals() сначала проверяет ссылки и только потом сравнивает значения.
String.intern(): магия пула строк
А теперь покажу тебе трюк, который знают далеко не все. У класса
String есть метод
intern(), который работает с пулом строк. Представь себе такой склад, где Java хранит все строковые литералы. Когда ты вызываешь
intern(), Java идет на этот склад и проверяет: есть там такая строка или нет?
Если есть, возвращает ссылку на нее. Если нет, добавляет твою строку в пул и возвращает ссылку.
String str1 = "JavaRush";
String str2 = new String("JavaRush");
System.out.println(str1 == str2); // false
System.out.println(str1.intern() == str2.intern()); // true
Теперь они указывают на один объект! Но честно говоря, я за семь лет работы использовал
intern() от силы раза три. Это такая оптимизация, которая нужна только в специфических случаях.
Вещественные числа: добро пожаловать в ад
Окей, держись. Сейчас я покажу тебе что-то, что взорвет твой мозг. Готов?
Чему равно
0.3f + 0.4f? Не спеши отвечать "0.7". Проверь:
float f1 = 0.7f;
float f2 = 0.3f + 0.4f;
System.out.println("f1 == f2: " + (f1 == f2));
Результат?
false.
Я серьезно. Запусти и убедись сам. Первый раз, когда я это увидел, я минут пятнадцать пялился в монитор и думал, что у меня с головой что-то не так.
Давай разберемся, что творится. Выведем эти числа с большей точностью:
float f1 = 0.3f;
float f2 = 0.4f;
float f3 = f1 + f2;
float f4 = 0.7f;
System.out.println("f1 = " + (double)f1);
System.out.println("f2 = " + (double)f2);
System.out.println("f3 = " + (double)f3);
System.out.println("f4 = " + (double)f4);
Вывод:
f1 = 0.30000001192092896
f2 = 0.4000000059604645
f3 = 0.7000000476837158
f4 = 0.699999988079071
Видишь? Числа разные! И это не баг, а особенность
представления дробных чисел в памяти компьютера.
Представь, что ты пытаешься нарезать пиццу на идеально ровные кусочки, но у тебя только обычный нож (не лазерный). Каждый раз будет небольшая погрешность. Вот и компьютер хранит дроби с погрешностью, потому что память конечна. У
float на мантиссу отведено 24 бита, это примерно 7 значащих цифр. У
double получше: 53 бита и около 15-16 значащих цифр. Но погрешность есть всегда.
Как сравнивать вещественные числа правильно
Раз прямое сравнение не работает, надо сравнивать с погрешностью. Вот так:
float f1 = 0.3f;
float f2 = 0.4f;
float f3 = f1 + f2;
float f4 = 0.7f;
System.out.println("|f3 - f4| < 1e-6: " + (Math.abs(f3 - f4) < 1e-6));
Результат:
true. Вот теперь работает!
Мы проверяем, что разница между числами меньше одной миллионной. Для
float достаточно
1e-6, для
double можно взять
1e-15.
Кстати, в библиотеке JUnit методы сравнения чисел так и работают: они принимают три параметра: два числа и точность сравнения. Библиотека знает, о чем говорит.
И еще один момент. Когда пишешь числа в научной нотации, будь внимателен. Хочешь написать одну миллионную? Правильно так:
1e-6. А вот
10e-6 это уже 10 умножить на 10 в минус шестой, то есть
0.00001. Я на этом спалился на одном проекте, искали баг полдня. Не повторяй моих ошибок!
Плюс ноль и минус ноль: да, они разные
Думаешь, ноль это просто ноль? Как бы не так. В представлении вещественных чисел есть знаковый бит. И если все остальные биты нули, а знаковый бит единица, получается
-0.0.
Звучит безумно, правда? Но посмотри:
float f1 = 0.0f / 1.0f;
float f2 = 0.0f / -1.0f;
System.out.println("f1 = " + f1);
System.out.println("f2 = " + f2);
System.out.println("f1 == f2: " + (f1 == f2));
float f3 = 1.0f / f1;
float f4 = 1.0f / f2;
System.out.println("f3 = " + f3);
System.out.println("f4 = " + f4);
Вывод:
f1 = 0.0
f2 = -0.0
f1 == f2: true
f3 = Infinity
f4 = -Infinity
Видишь? Java считает их равными, но деление на них дает разные результаты! Один дает положительную бесконечность, другой отрицательную.
Если тебе нужно различать
+0.0 и
-0.0, сравнивай битовые представления:
int i1 = Float.floatToIntBits(f1);
int i2 = Float.floatToIntBits(f2);
System.out.println("i1 (+0.0): " + Integer.toBinaryString(i1));
System.out.println("i2 (-0.0): " + Integer.toBinaryString(i2));
System.out.println("i1 == i2: " + (i1 == i2));
Результат:
i1 (+0.0): 0
i2 (-0.0): 10000000000000000000000000000000
i1 == i2: false
Теперь Java их различает.
NaN: число, которое не равно само себе
NaN расшифровывается как Not-a-Number, "не число". Получается при каких-то некорректных операциях типа деления нуля на ноль. И у него есть одна дикая особенность: оно не равно само себе!
float x = 0.0f / 0.0f;
System.out.println("x = " + x);
System.out.println("x == x: " + (x == x));
Результат:
x = NaN
x == x: false
Представляешь? Переменная не равна сама себе! Это единственное значение в Java с таким поведением.
Если в твоем объекте поле стало
NaN, а
equals сравнивает поля оператором
==, результат всегда будет
false, даже если все остальные поля идентичны. А вот обертка
Float ведет себя иначе: ее
equals() сравнивает битовые представления через
floatToIntBits(), поэтому два
NaN она считает равными, а
+0.0 и
-0.0, наоборот, разными.
Проверить, является ли число
NaN, можно методом
Float.isNaN():
float x = 0.0f / 0.0f;
System.out.println(Float.isNaN(x)); // true
А если хочется закрыть обе ловушки сразу, и
NaN, и минус ноль, есть метод
Float.compare() (для
double соответственно
Double.compare()). Он сравнивает числа так же, как
equals() у обертки: два
NaN для него равны, а
+0.0 считается больше
-0.0.
System.out.println(Float.compare(Float.NaN, Float.NaN)); // 0, числа равны
System.out.println(Float.compare(0.0f, -0.0f)); // больше нуля: +0.0 больше -0.0
System.out.println(Float.NaN == Float.NaN); // false
System.out.println(0.0f == -0.0f); // true
Видишь? Там, где оператор
== ошибается,
Float.compare() дает ожидаемый ответ. Именно поэтому его используют в
equals() и в компараторах.
Autoboxing и кеширование: неожиданные сюрпризы
С Java 5 появился
autoboxing, автоматическое преобразование примитивов в объекты и обратно. Казалось бы, удобная штука. Но есть подвох.
Классы-обертки
Integer,
Long,
Short,
Byte,
Character кешируют некоторые значения. Например,
Integer кеширует числа от -128 до 127. Это значит, что при создании объектов с такими значениями Java возвращает ссылку на один и тот же объект из кеша.
Integer a = 127;
Integer b = 127;
System.out.println(a == b); // true
Integer c = 128;
Integer d = 128;
System.out.println(c == d); // false
Видишь разницу? Для 127 сработал кеш, и
a и
b указывают на один объект. А для 128 создались два разных объекта.
Это оптимизация от создателей Java. Они решили, что маленькие числа используются часто, поэтому кешировать их выгодно. Но это может привести к странным багам, если ты не в курсе.
На курсе JavaRush мы разбираем эту тему в разделе про коллекции и обертки: там много практических задач на понимание autoboxing.
Enum: сравнивай спокойно
А вот с перечислениями (enum) все просто и хорошо. Каждый элемент enum существует в единственном экземпляре, это гарантирует виртуальная машина. Поэтому их можно спокойно сравнивать через
==:
enum Season {
WINTER, SPRING, SUMMER, AUTUMN
}
Season s1 = Season.SUMMER;
Season s2 = Season.SUMMER;
System.out.println(s1 == s2); // true
Никаких подвохов. Enum это один из немногих типов, где
== работает предсказуемо и надежно.
Что запомнить
Давай подведем итоги. Сравнение в Java штука коварная:
| Что сравниваем |
Что не работает |
Как правильно |
| Строки |
Оператор ==: он сравнивает ссылки, а не текст |
equals(), а intern() только в особых случаях |
| Вещественные числа |
Прямое сравнение: 0.3f + 0.4f не равно 0.7f |
Сравнение с погрешностью Math.abs(a - b) < epsilon, а для денег BigDecimal |
| NaN и -0.0 |
NaN не равен сам себе, а +0.0 == -0.0 дает true |
Float.isNaN() и сравнение битов через Float.floatToIntBits() |
| Обертки примитивов |
Оператор ==: он работает только внутри кеша от -128 до 127 |
equals() или сравнение распакованных примитивов |
| enum |
Ничего, тут все честно |
Оператор ==, это самый надежный случай |
Строки сравнивай через
equals, не надейся на
==: даже если сейчас работает, завтра может сломаться. Для финансовых расчетов бери
BigDecimal, а с autoboxing будь осторожен: кеш оберток дает неожиданное поведение ровно там, где его не ждешь.
Удачи с багами и помни: если что-то работает странно, возможно, дело в сравнении!
Вопросы и ответы
Почему нельзя сравнивать строки через ==?
Потому что
== сравнивает ссылки, а не текст. Иногда он все же дает
true: одинаковые литералы компилятор складывает в пул строк и переиспользует один объект. Но стоит собрать строку конкатенацией во время работы программы или создать ее через
new String(), как объект будет новый и сравнение сломается. Правильный способ один:
equals().
Почему 0.3f + 0.4f не равно 0.7f?
Потому что дробные числа хранятся в двоичном виде с округлением. В переменную
0.3f на самом деле попадает 0.30000001192092896, в
0.4f попадает 0.4000000059604645, их сумма дает 0.7000000476837158, а литерал
0.7f это 0.699999988079071. Числа разные, поэтому
== честно возвращает
false.
Как тогда сравнивать вещественные числа?
Через погрешность: проверять методом
Math.abs(), что разница по модулю меньше выбранного порога. Для
float обычно берут одну миллионную, то есть
1e-6, для
double достаточно
1e-15. Так устроены и методы сравнения в JUnit, которые принимают третьим параметром допустимую погрешность. Если речь про деньги, вещественные типы лучше не использовать вовсе, для них есть
BigDecimal.
Правда ли, что NaN ломает equals у объекта?
Смотря как написан
equals. Если он сравнивает поля оператором
==, то да:
NaN не равен сам себе, и объекты никогда не совпадут. А вот обертка
Float ведет себя иначе: ее
equals() сравнивает битовые представления через
floatToIntBits(), поэтому два
NaN она считает равными, а
+0.0 и
-0.0, наоборот, разными.
Почему Integer со значением 127 сравнивается через == как true, а 128 уже нет?
Из-за кеша оберток. При автоупаковке значения от -128 до 127 берутся из готового кеша, и обе переменные указывают на один объект. Для 128 кеша уже нет, создаются два разных объекта, и
== дает
false. Верхнюю границу кеша можно поднять настройкой виртуальной машины, поэтому полагаться на такое поведение нельзя: сравнивайте обертки через
equals().
Когда == все-таки безопасен?
Для примитивов, для сравнения с
null и для элементов
enum. Каждый элемент перечисления существует в единственном экземпляре, это гарантирует виртуальная машина, поэтому
== тут не только работает, но и считается хорошим тоном: он быстрее и не упадет, если ссылка окажется
null.
Читайте также:
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ