Однажды я потратил три часа на поиск бага. Три чёртовых часа! Программа вроде работала, но результаты расчётов постоянно плавали. И только случайно я заметил, что 0.3 + 0.4 у меня не равно 0.7. Вот тогда-то я и понял, что сравнение в Java – это не такая простая штука, как кажется. Это продолжение первой статьи про сравнение, где мы разбирали теорию. А сейчас давай копнём глубже и посмотрим, где Java может тебя неприятно удивить. Поговорим про строки, вещественные числа и всякие неочевидные моменты, о которых на собеседованиях обожают спрашивать.Сравнение объектов: практика - 1

Строки и оператор ==: история одного предательства

Знаешь, строки в 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 мы этому посвящаем целый модуль, потому что штука реально важная.

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 бита, точность около 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. Даже если все остальные поля идентичны! Проверить, является ли число NaN, можно методом Float.isNaN():
float x = 0.0f / 0.0f;
System.out.println(Float.isNaN(x)); // true

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, не надейся на ==. Даже если сейчас работает, завтра может сломаться. Вещественные числа нельзя сравнивать напрямую. Используй погрешность: Math.abs(a - b) < epsilon. Для финансовых расчётов вообще бери BigDecimal. Помни про NaN и -0.0 – они могут испортить тебе сравнение объектов. С autoboxing будь осторожен. Integer кеширует значения, и это может дать неожиданное поведение. Enum сравнивай смело через == – тут всё честно. Удачи с багами и помни: если что-то работает странно, возможно, дело в сравнении!