JavaRush/Java блог/Random UA/Порівняння об'єктів: практика
articles
15 рівень

Порівняння об'єктів: практика

Стаття з групи Random UA
учасників
Це друга із статей, присвячених порівнянню об'єктів. У першій з них йшлося про теоретичний базис порівняння – як це робиться, чому і де використовується. У цій же статті йтиметься безпосередньо про порівняння чисел, об'єктів, про окремі випадки, тонкощі та неочевидні моменти. А якщо точніше, ми поговоримо ось про що:
Порівняння об'єктів: практика - 1
  • Порівняння рядків: ' ==іequals
  • МетодString.intern
  • Порівняння речових примітивів
  • +0.0і-0.0
  • ЗначенняNaN
  • Java 5.0 Виробляючі методи та порівняння через ' =='
  • Java 5.0 Autoboxing/Unboxing: ' ==', ' >=' і ' <=' для об'єктних оболонок.
  • Java 5.0 порівняння елементів перерахувань (тип enum)
Отже, почнемо!

Порівняння рядків: ' ==іequals

Ах, ці рядки... Один з типів, що найчастіше використовуються, викликають при цьому чимало проблем. У принципі, про них є окрема стаття . А тут я торкнуся питань порівняння. Зрозуміло, що рядки можна порівнювати за допомогою equals. Більш того, їх потрібно порівнювати через equals. Однак є тонкощі, які варто знати. Насамперед, однакові рядки насправді є єдиним об'єктом. У чому легко переконатись, виконавши наступний код:
String str1 = "string";
String str2 = "string";
System.out.println(str1==str2 ? "the same" : "not the same");
Результатом буде "the same" . Що означає, що посилання рядки рівні. Це зроблено лише на рівні компілятора, очевидно, задля економії пам'яті. Компілятор створює один екземпляр рядка, і надає str1і str2посилання на цей екземпляр. Однак це стосується лише рядків, оголошених як літерали, у коді. Якщо скомпонувати рядок зі шматків, посилання на нього буде інше. Підтвердження – це приклад:
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), або, що краще, перевіряв би абсолютне значення різниці між ними:
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. При завантаженні цих класів створюються масиви їх екземплярів, що відповідають певним діапазонам значень примітивів. Діапазони такі:
Порівняння об'єктів: практика - 2
Це означає, що при використанні методу 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 – перерахування. Його екземпляри за промовчанням містять ім'я та порядковий номер в оголошенні екземпляра у класі. Відповідно, при зміні порядку оголошення номера змінюються. Однак, як я вже говорив у статті 'Серіалізація як вона є', це не викликає проблем. Усі елементи перерахування існують у єдиному екземплярі, це контролюється лише на рівні віртуальної машини. Тому їх можна порівнювати безпосередньо за посиланнями. * * * Мабуть, це все на сьогодні про практичний бік реалізації порівняння об'єктів. Можливо, я щось упустив. Як завжди, чекаю коментарів! А поки що дозвольте відкланятися. Всім дякую за увагу! Посилання на першоджерело: Порівняння об'єктів: практика
Коментарі
  • популярні
  • нові
  • старі
Щоб залишити коментар, потрібно ввійти в систему
Для цієї сторінки немає коментарів.