Последнее изменение: 1 сентября 2007г.

Сравнение объектов: практика

Это вторая из статей, посвященных сравнению объектов. В первой из них речь шла о теоретическом базисе сравнения – как это делается, почему и где используется. В этой же статье речь пойдет непосредственно о сравнении чисел, объектов, о частных случаях, тонкостях и неочевидных моментах. А если точнее, мы поговорим вот о чем:

Итак, приступим!

Сравнение строк: '==' и equals

Ах, эти строки... Один из наиболее часто используемых типов, вызывающих при этом немало проблем. В принципе, о них есть отдельная статья. А здесь я коснусь вопросов сравнения.

Разумеется, строки можно сравнивать с помощью equals. Более того, их НУЖНО сравнивать через equals. Однако, есть тонкости, которые стоит знать.

Прежде всего, одинаковые строки на самом деле являются единственным объектом. В чем легко убедиться, выполнив следующий код:

String str1 = "string";
String str2 = "string";
System.out.println(str1==str2 ? "the same" : "not the same");

Результатом будет "the same". Что означает, что ссылки на строки равны. Это сделано на уровне компилятора, очевидно, для экономии памяти. Компилятор создает ОДИН экземпляр строки, и присваивает str1 и str2 ссылку на этот экземпляр1.

Однако, это относится только к строкам, объявленным как литералы, в коде. Если скомпоновать строку из кусков, ссылка на нее будет другой. Подтверждение – данный пример:

String str1 = "string";
String str2 = "str";
String str3 = "ing";
System.out.println(str1==(str2+str3) ? "the same" : "not the same");

Результатом будет "not the same". Также можно создать новый объект с помощью копирующего конструктора:

String str1 = "string";
String str2 = new String("string");
System.out.println(str1==str2 ? "the same" : "not the same");

Результатом также будет "not the same".

Таким образом, иногда строки можно сравнивать и через сравнение ссылок. Но на это лучше не полагаться.

Я хотел бы затронуть один весьма любопытный метод, который позволяет получить так называемое каноническое представление строки – String.intern. Поговорим о нем поподробнее.

Метод String.intern

Начнем с того, что класс String поддерживает пул строк. В этот пул добавляются все строковые литералы, определенные в классах, и не только они. Так вот, метод intern позволяет получить из этого пула строку, которая равна имеющейся (той, у которой вызывается метод intern) с точки зрения equals. Если такой строки в пуле не существует, то туда помещается имеющаяся, и возвращается ссылка на нее. Таким образом, если даже ссылки на две равных строки разные (как в двух примерах выше), то вызовы у этих строк intern вернут ссылку на один и тот же объект:

String str1 = "string";
String str2 = new String("string");
System.out.println(str1.intern()==str2.intern() ? "the same" : "not the same");

Результатом выполнения этого фрагмента кода будет "the same".

Я не могу сказать точно, зачем это сделано так. Метод intern – native, а в дебри С-кода мне, честно сказать, не хочется. Скорее всего это сделано для оптимизации потребления памяти и производительности. В любом случае, стоит знать об этой особенности реализации.

Переходим к следующей части.

Сравнение вещественных примитивов

Для начала я хочу задать вопрос. Очень простой. Чему равна следующая сумма – 0.3f + 0.4f? Чему? 0.7f? Проверим:

float f1 = 0.7f;
float f2 = 0.3f + 0.4f;
System.out.println("f1==f2: "+(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);

Обратите внимание на приведение к double. Это сделано для того, чтобы вывести побольше знаков после запятой. Результат:

f1=0.30000001192092896
f2=0.4000000059604645
f3=0.7000000476837158
f4=0.699999988079071

Собственно говоря, результат прогнозируемый. Представление дробной части осуществляется с помощью конечного ряда 2-n, а потому о точном представлении произвольно взятого числа говорить не приходится. Как видно из примера, точность представления float – 7 знаков после запятой.

Строго говоря, в представлении float на мантиссу отведено 24 бита. Таким образом минимальное по модулю число, которое можно представить с помощью float (без учета степени, ибо мы говорим о точности) – это 2-24≈6*10-8. Именно с таким шагом реально идут значения в представлении float. А поскольку есть квантование – есть и погрешность.

Отсюда вывод: числа в представлении float можно сравнивать только с определенной точностью. Я бы рекомендовал округлять их до 6-го знака после запятой (10-6), либо, что предпочтительнее, проверял бы абсолютное значение2 разности между ними:

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

В этом случае результат вселяет надежду:

|f3-f4|<1e-6: true

Разумеется, точно та же картина и с типом double. С единственной разницей, что там на мантиссу отведено 53 бита, следовательно, точность представления – 2-53≈10-16. Да, величина квантования куда меньше, но она есть. И может сыграть злую шутку.

Кстати, в тестовой библиотеке JUnit в методах сравнения вещественных чисел точность указывается в явном виде. Т.е. метод сравнения содержит три параметра – число, чему оно должно быть равно и точность сравнения.

Еще кстати, хочу упомянуть о тонкости, связаной с записью чисел в научном формате, с указанием степени. Вопрос. Как записать 10-6? Практика показывает, что более 80% отвечают – 10e-6. Между тем, правильный ответ – 1e-6! А 10e-6 – это 10-5! Мы наступили на эти грабли в одном из проектов, довольно неожиданно. Ошибку искали очень долго, на константы смотрели раз 20. И ни у кого не возникло ни тени сомнения в их правильности, пока однажды, в большой степени случайно, константу 10e-3 не вывели на печать и не обнаружили у нее после запятой два знака вместо ожидавшихся трех. А потому – будьте бдительны!

Движемся дальше.

+0.0 и -0.0

В представлении вещественных чисел старший бит является знаковым. А что будет, если все остальные биты равны 0? В отличие от целых, где в такой ситуации получается отрицательное число, находящееся на нижней границе диапазона представления, вещественное число только со старшим битом, выставленным в 1, тоже обозначает 0, только со знаком минус. Таким образом, у нас есть два нуля – +0.0 и -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

Таким образом, в некоторых случаях есть смысл расценивать +0.0 и -0.0 как два разных числа. А если у нас есть два объекта, в одном из которых поле равно +0.0, а в другом -0.0 – эти объекты точно так же можно расценивать как неравные. Возникает вопрос – а как понять, что числа неравны, если их прямое сравнение виртуальной машиной дает true?

Ответ таков. Несмотря на то, что виртуальнай машина считает эти числа равными, представления у них все-таки отличаются. Поэтому – единственное, что можно сделать, это сравнить представления. А для того, чтобы его получить, существуют методы int Float.floatToIntBits(float) и long Double.doubleToLongBits(double), которые возвращают битовое представление в виде int и long соответственно (продолжение предыдущего примера):

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

Таким образом, если у вас +0.0 и -0.0 – разные числа, то сравнивать вещественные переменные следует через их битовое представление.

С +0.0 и -0.0 вроде как разобрались. -0.0, однако, является не единственным сюрпризом. Есть еще такое явление как...

Значение NaN

NaN расшифровывается как Not-a-Number. Это значение появляется в результате некорректных математических операций, скажем, деления 0.0 на 0.0, бесконечности на бесконечность и т.п.

Особенностью этого значения является то, что оно не равно самому себе. Т.е.:

float x = 0.0f/0.0f;
System.out.println("x="+x);
System.out.println("x==x: "+(x==x));

... даст в результате...

x=NaN
x==x: false

Чем это может обернуться при сравнении объектов? Если поле объекта будет равно NaN, то сравнение даст false, т.е. объекты гарантированно будут считаться неравными. Хотя по логике вещей мы можем хотеть как раз обратного.

Добиться нужного результата можно, используя метод Float.isNaN(float). Он возвращает true, если аргумент – NaN. На сравнение битовых представлений я бы в этом случае не полагался, т.к. оно не стандартизовано.

Пожалуй, о примитивах хватит. Перейдем теперь к тонкостям, появившимся в Java с версии 5.0. И первый момент, которого я бы хотел коснуться –

Java 5.0. Производящие методы и сравнение через '=='

В проектировании есть шаблон, называемый производящий метод. Иногда его использование гораздо более выгодно, нежели использование конструктора. Приведу пример. Думаю, все хорошо знаю объектную оболочку Boolean. Этот класс неизменяемый, способен содержать всего два значения. Т.е., фактически, для любых нужд хватит всего-навсего двух экземпляров. И если их создать заранее, а потом просто возвращать, то это будет намного быстрее, чем использование конструктора. Такой метод у Boolean есть: valueOf(boolean). Появился он в версии 1.4.

Подобные же производящие методы были введены с версии 5.0 и в классах Byte, Character, Short, Integer и Long. При загрузке этих классов создаются массивы их экземпляров, соответствующие определенным диапазонам значений примитивов. Диапазоны эти следующие:

Byte:-128..127(фактически – все значения)
Character:0..127
Short:-128..127
Integer:-128..127
Long:-128..127

Означает это, что при использовании метода valueOf(...) при попадании аргумента в указанный диапазон всегда будет возвращаться один и тот же объект. Возможно, это и дает какое-то увеличение скорости. Но при этом появляются проблемы такого характера, что докопаться до сути бывает довольно сложно. Читайте об этом дальше.

Теоретически производящий метод valueOf добавлен и в классы Float и Double. В их описании сказано, что если не нужен новый экземпляр, то лучше пользоваться этим методом, т.к. он может дать прибавку в скорости и т.д. и т.п. Однако в текущей (Java 5.0) реализации в этом методе создается новый экземпляр, т.е. прибавки в скорости его использование не даст гарантированно. Более того, мне сложно представить, как можно ускорить этот метод, ибо ввиду непрерывности значений кеш там не организуешь. Разве что для целых чисел. В смысле, без дробной части.

Java 5.0. Autoboxing/Unboxing: '==', '>=' и '<=' для объектных оболочек.

Подозреваю, что производящие методы и кеш экземпляров были добавлены в оболочки для целочисленных примитивов ради оптимизации операций autoboxing/unboxing. Напомню, что это такое. Если в операции должен участвовать объект, а участвует примитив, то этот примитив автоматически оборачивается в объектную оболочку. Это autoboxing. И наоборот – если в операции должен участвовать примитив, то можно подставить туда объектную оболочку, и значение будет автоматически из нее развернуто. Это unboxing.

Естественно, за такое удобство надо платить. Операции автоматического преобразования несколько замедляют скорость работы приложения. Однако к текущей теме это не относится, потому оставим этот вопрос.

Все хорошо до тех пор, пока мы имеем дело с операциями, однозначно относящимися к примитивам либо к оболочкам. А что будет с операцией '=='? Допустим, у нас есть два объекта Integer, с одинаковым значением внутри. Как они будут сравниваться?

Integer i1 = new Integer(1);
Integer i2 = new Integer(1);
System.out.println("i1==i2: "+(i1==i2));

Результат:

i1==i2: false

Кто бы сомневался... Сравниваются они как объекты. А если так:

Integer i1 = 1;
Integer i2 = 1;
System.out.println("i1==i2: "+(i1==i2));

Результат:

i1==i2: true

Вот это уже интереснее! При autoboxing-е возвращаются одинаковые объекты!

Вот тут и находится ловушка. Однажды обнаружив, что возвращаются одинаковые объекты, мы начнем экспериментировать, чтобы проверить, всегда ли это так. И сколько мы проверим значений? Одно? Десять? Сто? Скорее всего ограничимся сотней в каждую сторону вокруг нуля. И везде получим равенство. Казалось бы, все хорошо. Однако, посмотрите чуть назад, вот сюда. Догадались, в чем подвох?..

Да, экземпляры объектных оболочек при autoboxing-е создаются с помощью производящих методов. Что хорошо иллюстрируется следующим тестом:

public class AutoboxingTest {

    private static final int numbers[] = new int[]{-129,-128,127,128};

    public static void main(String[] args) {
        for (int number : numbers) {
            Integer i1 = number;
            Integer i2 = number;
            System.out.println("number=" + number + ": " + (i1 == i2));
        }
    }
}

Результат будет таков:

number=-129: false
number=-128: true
number=127: true
number=128: false

Для попадающих в диапазон кеширования значений возвращаются одинаковые объекты, для находящихся вне него – разные. А следовательно, если где-то в приложении будут сравниваться оболочки вместо примитивов – есть шанс получить самую страшную ошибку: плавающую. Потому как тестировать код, скорее всего, тоже будут на ограниченом диапазоне значений, в котором эта ошибка не проявится. А в реальной работе она то будет проявляться, то исчезать, в зависимости от результатов каких-то вычислений. Проще сойти с ума, чем найти такую ошибку. А потому – я бы советовал избегать autoboxing-а где только можно.

И это не всё. Вспомним математику, не далее чем 5-го класса. Пусть выполняются неравенства A>=B и А<=B. Что можно сказать об отношении A и B? Только одно – они равны. Согласны? Думаю, да. Запускаем тест:

Integer i1 = new Integer(1);
Integer i2 = new Integer(1);
System.out.println("i1>=i2: "+(i1>=i2));
System.out.println("i1<=i2: "+(i1<=i2));
System.out.println("i1==i2: "+(i1==i2));

Результат:

i1>=i2: true
i1<=i2: true
i1==i2: false

И вот это для меня – самая большая странность. Я вообще не понимаю, зачем было вводить в язык эту возможность, если она вносит такие противоречия. В общем, повторю еще раз – если есть возможность обойтись без autoboxing/unboxing, то стоит эту возможность использовать на полную катушку.

Последняя тема, которой я хотел бы коснуться, это...

Java 5.0. сравнение элементов перечислений (тип enum)

Как известно, с версии 5.0 в Java появился такой тип как enum – перечисление. Его экземпляры по умолчанию содержат имя и порядковый номер в объявлении экземпляра в классе. Соответственно, при изменении порядка объявления номера меняются. Однако, как я уже говорил в статье 'Сериализация как она есть', это не вызывает проблем. Все элементы перечисления существуют в единственном экземпляре, это контролируется на уровне виртуальной машины. Поэтому их можно сравнивать напрямую, по ссылкам.

* * *

Пожалуй, это всё на сегодня о практической стороне реализации сравнения объектов. Возможно, я что-то упустил. Как обычно, жду комментариев! А пока позвольте откланяться. Всем спасибо за внимание!


1. Спасибо за комментарий участнику конференции с ником Pabel

2. Спасибо за внимательность участнику конференции Виталию Самоловских!