Unreal Features of Real Types, або Будзьце асцярожныя з REAL

Пасля публікацыі артыкулы аб асаблівасцях тыпізацыі ў PostgreSQL, першы ж каментар быў пра складанасці працы з рэчавымі лікамі. Я вырашыў бегла прабегчыся па кодзе даступных мне SQL-запытаў, каб паглядзець, наколькі часта ў іх выкарыстоўваецца тып REAL. Досыць часта выкарыстоўваецца, як аказалася, і не заўсёды распрацоўшчыкі разумеюць небяспекі, якія стаяць за ім. І гэта нягледзячы на ​​тое, што ў Інтэрнэце і на Хабры дастаткова шмат добрых артыкулаў пра асаблівасці захоўвання рэчавых лікаў у машыннай памяці і аб працы з імі. Таму ў гэтым артыкуле я пастараюся ўжыць такія асаблівасці да PostgreSQL, і паспрабую "на пальцах" разгледзець звязаныя з імі непрыемнасці, каб распрацоўнікам SQL-запытаў было лягчэй пазбегнуць іх.

Дакументацыя PostgreSQL змяшчае лаканічную фразу: "Кіраванне падобнымі памылкамі і іх распаўсюджванне ў працэсе вылічэнняў з'яўляецца прадметам вывучэння цэлага раздзела матэматыкі і кампутарнай навукі, і тут не разглядаецца" (пры гэтым разважліва адсылаючы чытача да стандарту IEEE 754). Што за памылкі тут маюцца на ўвазе? Давайце абмяркуем іх па-парадку, і хутка стане зразумела, чаму я зноў узяўся за пяро.

Возьмем, напрыклад, просты запыт:

********* ЗАПРОС *********
SELECT 0.1::REAL;
**************************
float4
--------
    0.1
(1 строка)

У выніку не ўбачым нічога асаблівага - атрымаем чаканае 0.1. Але зараз параўнаем яго з 0.1:

********* ЗАПРОС *********
SELECT 0.1::REAL = 0.1;
**************************
?column?
----------
f
(1 строка)

Не роўныя! Што за цуды! Але далей-больш. Хтосьці скажа, маўляў, я ведаю, што REAL дрэнна паводзіць сябе з дробамі, ну дык я буду туды занасіць цэлыя лікі, з імі-то сапраўды ўсё будзе добра. Ок, давайце прывядзем лік 123 да тыпу REAL:

********* ЗАПРОС *********
SELECT 123456789::REAL::INT;
**************************
   int4   
-----------
123456792
(1 строка)

А яно атрымалася большым на 3! Усё, база канчаткова развучылася лічыць! Ці мы чагосьці не разумеем? Давайце разбірацца.

Для пачатку ўспомнім матчнасць. Як вядома, любы дзесятковы лік можна раскласці па ступенях дзесяці. Так, лік 123.456 будзе роўны 1*102 + 2*101 + 3*100 + 4*10-1 + 5*10-2 + ​​6*10-3. Але кампутар аперуе лікамі ў двайковым выглядзе, такім чынам прадстаўляць іх даводзіцца ў выглядзе раскладання па ступенях двойкі. Таму лік 5.625 у двайковым выглядзе ўяўляецца як 101.101 і будзе роўна 1*22 + 0*21 + 1*20 + 1*2-1 + 0*2-2 + 1*2-3. І калі дадатныя ступені двойкі заўсёды даюць цэлыя дзесятковыя лікі (1, 2, 4, 8, 16 і г.д.), то з адмоўнымі ўсё складаней (0.5, 0.25, 0.125, 0,0625 і г.д.). Праблема ў тым, што не ўсякі дзесятковы дроб можна прадставіць у выглядзе канчатковага двайковага дробу. Так, наша праславутае 0.1 у выглядзе двайковага дробу паўстае як перыядычнае значэнне 0.0(0011). Такім чынам, выніковае значэнне гэтага ліку ў машыннай памяці будзе мяняцца ў залежнасці ад разраднасці.

Зараз самы час успомніць, як рэчавыя лікі захоўваюцца ў памяці кампутара. Гаворачы ў агульных рысах, рэчавы лік складаецца з трох асноўных частак - знака, мантысы і экспаненты. Знак можа быць альбо плюс, альбо мінус, таму на яго адводзіцца адзін біт. А вось колькасць біт мантысы і экспаненты вызначаецца рэчавым тыпам. Так, для тыпу REAL даўжыня мантысы складае 23 біт (адзін біт, роўны 1, няяўна дадаецца ў пачатак мантысы, і атрымліваецца 24), а экспаненты - 8 біт. Разам атрымліваецца 32 біт, ці 4 байта. А для тыпу DOUBLE PRECISION даўжыня мантысы будзе ўжо 52 біт, і экспаненты - 11 біт, у суме складнікаў 64 біт, або 8 байт. Вялікую дакладнасць PostgreSQL для лікаў з якая плавае кропкай не падтрымлівае.

Давайце спакуем наш лік 0.1 у дзесятковым выглядзе ў абодва тыпу - REAL і DOUBLE PRECISION. Паколькі знак і значэнне экспаненты ў нас супадае, засяродзімся на мантысе (я свядома прапускаю невідавочныя асаблівасці захоўвання значэнняў экспаненты і нулявых рэчавых значэнняў, паколькі яны абцяжарваюць разуменне і адцягваюць ад сутнасці праблемы, калі цікава – глядзіце стандарт IEEE 754). Што мы атрымаем? У верхнім радку я прывяду «мантысу» для тыпу REAL (з улікам акруглення апошняга біта ў 1 да найблізкага ўяўнага ліку, інакш атрымаецца 0.099999…), а ў ніжняй – для тыпу DOUBLE PRECISION:

0.000110011001100110011001101
0.00011001100110011001100110011001100110011001100110011001

Відавочна, што гэта два зусім розныя лічбы! Таму пры параўнанні першы лік будзе дапоўнена нулямі і, такім чынам, будзе больш другога (з улікам акруглення - пазначанай тоўстым адзінкі). Гэтым і тлумачыцца неадназначнасць нашых прыкладаў. У другім прыкладзе відавочна паказаны лік 0.1 прыводзіцца да тыпу DOUBLE PRECISION, пасля чаго параўноўваецца з лікам тыпу REAL. Абодва прыводзяцца да аднаго тыпу, і маем роўна тое, што бачым вышэй. Відазмянім запыт, каб усё ўстала на свае месцы:

********* ЗАПРОС *********
SELECT 0.1::REAL > 0.1::DOUBLE PRECISION;
**************************
?column?
----------
t
(1 строка)

Выканаўшы падвойнае прывядзенне ліку 0.1 да REAL і DOUBLE PRECISION атрымліваем адказ на загадку:

********* ЗАПРОС *********
SELECT 0.1::REAL::DOUBLE PRECISION;
**************************

      float8       
-------------------
0.100000001490116
(1 строка)

Гэтым жа тлумачыцца і трэці прыклад з пазначаных вышэй. Лік 123 456 789 проста немагчыма змясціць у 24 біта мантысы (23 відавочных + 1 які разумеецца). Максімальны цэлы лік, якое можна размясціць у 24 біта, будзе 224-1 = 16 777 215. Таму наш лік 123 456 789 акругляецца да бліжэйшага прадстаўленага 123 456 792. Змяніўшы тып на DOUBLE PRECISION, мы ўжо не ўбачым такога

********* ЗАПРОС *********
SELECT 123456789::DOUBLE PRECISION::INT;
**************************
   int4   
-----------
123456789
(1 строка)

Вось і ўсё. Аказваецца, ніякіх цудаў. Але ўсё апісанае - добрая нагода задумацца на прадмет таго, наколькі вам сапраўды патрэбен тып REAL. Мабыць, самы вялікі плюс яго выкарыстання - шпаркасць вылічэнняў з загадзя наяўнай стратай дакладнасці. Але ці будзе гэта ўніверсальным сцэнарам, якія апраўдваюць гэтак частае выкарыстанне гэтага тыпу? Не думаю.

Крыніца: habr.com

Дадаць каментар