dimhold.by
← Артыкулы

Option замест null: 16 байтаў, якіх я не знайшоў

NullPointerException прыляцеў з радка, у якім тры кропкі.

def zipOf(order: Order): String = order.customer.address.zip

Паведамленне пустое. Стэктрэйс назваў метад і радок, якія я і так ведаў з таго, што выключэнне наогул адбылося. Любы з трох мог аказацца null і выключэнне ў любым выпадку выглядае аднолькава.

Я хацеў закрыць два пытанні. Ці прыбірае Option гэты клас памылак насамрэч: вакол мяне так кажуць, а я веру ў гэта напалову. І колькі ён каштуе, бо я даўно паўтараю, што Option алакуе на кожны пошук і ні разу гэтага не памераў. Усё, што ніжэй, гэта Scala 2.9.1 на jdk 7.

complete: 00-001
no address: NullPointerException, message=[null] at zipOf line 8
no customer: NullPointerException, message=[null] at zipOf line 8
no order: NullPointerException, message=[null] at zipOf line 8
order customer address zip няма заказу null × няма кліента null × няма адрасу null × NullPointerException message=[null] Chains.scala:8 тыя ж тры ўваходы, праз Option None None None выключэння няма
Тры розныя дэфекты ломаюць ланцужок у трох розных звёнах. Усе тры прыходзяць адным і тым жа выключэннем з пустым паведамленнем, на адным радку.

Тры розныя дэфекты прыходзяць адным радком вываду, паўтораным тройчы. Каб зразумець, якое звяно абарвалося, мне давялося вярнуцца ў зыходнік і разабраць ланцужок рукамі. Аргумент за Option менавіта пра гэты клас памылак і ён справядлівы.

Той жа шлях, але палі аб’яўлены як Option:

def zipOf(order: Option[OrderO]): Option[String] =
  order.flatMap(_.customer).flatMap(_.address).map(_.zip)
complete: Some(00-001)
no address: None
no customer: None
no order: None

Не кідаецца нічога. Тры дэфекты нікуды не падзеліся і праграма па-ранейшаму не можа выдаць індэкс, але адсутнасць цяпер перадаецца як значэнне і даходзіць туды, дзе я вырашыў яе апрацоўваць. Заадно кампілятар не дае мне выцягнуць індэкс, пакуль я не скажу, што будзе пры яго адсутнасці.

Гэта праўда для кода, які ідзе праз map і flatMap. get нікуды з тыпу не падзеўся:

none.get: NoSuchElementException, message=[None.get]

Тое самае падзенне пад іншым імем класа. У паведамленні хаця б напісана None.get, што лепш за пустое, але гарантыя пашыраецца толькі на тыя выклікі, якія я сам выбіраю.

Дзе цячэ

Вакол у асноўным java, а java вяртае null. Так і пішацца абгортка:

def zipOf(id: String): Option[String] = Some(Legacy.zip(id))

Менавіта так яна выходзіць, калі пішу хутка. Кампілюецца і пры гэтым няверная:

zipOf("c-1")                    = Some(null)
zipOf("c-1").isDefined          = true
zipOf("c-1").getOrElse("none")  = null
lengthOfZip("c-1")              ! java.lang.NullPointerException
    Interop$$anonfun$lengthOfZip$1.apply(Interop.scala:9) <- Interop$$anonfun$lengthOfZip$1.apply(Interop.scala:9) <- scala.Option.map(Option.scala:133)

Some(null) гэта значэнне, якое выглядае запоўненым і не трымае нічога. isDefined дае true, getOrElse вяртае той самы null, ад якога мусіў мяне закрыць, а NPE пераязджае ўнутр лямбды ў map. Сустракаць яго там горш, чым у зыходным ланцужку, бо стэк цяпер ідзе праз бібліятэчны код. Усё гэта чыніцца, калі пісаць Option(...) замест Some(...), бо Option.apply правярае на null і аддае None.

Другой цечы я не чакаў. Option гэта спасылка, як любая іншая, таму ён і сам можа быць null:

val o: Option[String] = null    = null
o.isDefined                     ! java.lang.NullPointerException
    Interop$.main(Interop.scala:25) <- Interop.main(Interop.scala)
o.getOrElse("none")             ! java.lang.NullPointerException
    Interop$.main(Interop.scala:27) <- Interop.main(Interop.scala)

Кампілюецца без папярэджання. Я прагнаў кампілятар яшчэ раз з -deprecation, на выпадак, калі прапусціў. Адзіныя два папярэджанні на ўсю зборку тычацца аліяса Integer у іншым файле. Неініцыялізаванае поле тыпу Option[String] трымае null, а не None. Любы выклік на ім падае па-старому.

У 2.9.1 ёсць сцяг якраз пра гэта. -Xcheck-null папярэджвае пра зварот па спасылцы, якая можа быць null. На гэтых двух маленькіх файлах ён выдаў 54 папярэджанні. Тры з іх гэта сапраўдныя разыменаванні на восьмым радку. Ён жа адзначае o.isDefined на Option, які роўны null, гэта значыць выпадак, якому я толькі што здзівіўся. Астатняе накшталт стрэлкі на радковым літарале і label.+, бо канкатэнацыя радкоў таксама зварот па спасылцы. Сямнаццаць з 54 гэта канкатэнацыі, восем гэта тая самая стрэлка. Выцягнуць патрэбныя тры з астатніх пяцідзесяці аднаго ў мяне не атрымалася.

val zip: Option[String] Some("00-001") апісана тыпам значэнне None апісана тыпам адсутнасць Some(null) законна на гэтай jvm npe унутры map null з java null законна на гэтай jvm npe на любым выкліку поле без значэння
За значэннем тыпу Option[String] уміщаецца чатыры станы. Тып апісвае з іх два.

Што я думаў пра цану

Я даўно паўтараю, што Option абыходзіцца ў алакацыю на кожны пошук. І ні разу не мераў. Табліца на 100000 элементаў, 20 мільёнаў пошукаў на цыкл, тры прагрэвы, потым пяць замераў з друкам медыяны. Тыя ж 2.9.1 і jdk 7, куча прыбітая -Xms256m -Xmx256m, каб compressed oops засталіся ўключанымі:

1 java null                median 192 ms   runs 192,192,176,201,182            15.9802 bytes/lookup
2 scala Option             median 436 ms   runs 453,447,436,430,412            15.9795 bytes/lookup
3 java + Option()          median 188 ms   runs 184,188,197,181,195            15.9795 bytes/lookup
4 scala apply, no Option   median 434 ms   runs 434,438,424,432,466            15.9795 bytes/lookup
без Option у цыкле з Option у цыкле java.util.HashMap scala mutable.HashMap jm.get(k) 192 ms Option(jm.get(k)) 188 ms sm(k) 434 ms sm.get(k) 436 ms
Медыяны пяці таймаваных прагонаў па 20 мільёнаў пошукаў. Абедзве марудныя клеткі стаяць у радку scala. Перанос Option у цыкл і з цыкла не мяняе нічога.

Цыклы 1 і 3 працуюць па адной і той жа java.util.HashMap. Розніца якраз у тым, што цыкл 3 загортвае кожны вынік у Option(...). 188 супраць 192. Па трох прагонах, якія я захаваў, яны ўкладваюцца ў 181..189 супраць 186..199. Абгортка не каштуе нічога прыкметнага. Цікавейшы разрыў паміж цыкламі 2 і 3: 248 мс, прытым што абодва будуюць Option на кожны пошук. Разводзіць іх scala.collection.mutable.HashMap супраць java.util.HashMap.

Калонка алакацый гэта тое, у чым я памыляўся. Цыклы 2, 3 і 4 алакуюць 15.9795 байта на пошук, а цыкл 1 стаіць на волас убок, 15.9802. Гэтыя байты ідуць на ключ, а не на Some. Ключ i % SIZE упакоўваецца ў Integer. Кэш па змаўчанні трымае ад -128 да 127, а ключы ідуць ад 0 да 99999. Значыць 128 пошукаў з кожных 100000 прыходзяць з кэша, а астатнія алакуюць па 16 байтаў. Гэта 16 × (1 − 128/100000) або 15.97952.

Цыкл 4 задумваўся кантрольным. sm(k) аддае значэнне наўпрост і слова Option у цыкле няма. Ён выйшаў такім жа марудным, як цыкл 2, у двух прагонах з трох крыху маруднейшым. Я чытаў гэта як пацвярджэнне, пакуль не выключыў escape analysis:

1 java null                median 195 ms   runs 192,189,195,195,210            15.9802 bytes/lookup
2 scala Option             median 544 ms   runs 540,552,536,562,544            31.9795 bytes/lookup
3 java + Option()          median 229 ms   runs 209,229,239,230,227            31.9795 bytes/lookup
4 scala apply, no Option   median 535 ms   runs 495,545,553,535,518            31.9795 bytes/lookup

Цыкл 1 трымаецца на 15.9802, а астатнія тры набіраюць роўна па 16 байтаў. Цыкл 4 набірае іх таксама, гэта значыць Some у ім усё-такі ёсць: apply кліча get, get будуе Some, а apply яго разгортвае і выкідае. У кантрольным цыкле сядзела тое, што ён мусіў выключыць. Заўважыў я гэта толькі таму, што лічыльнік алакацый не сышоўся з зыходнікам, які я сам напісаў.

Шаснаццаць байтаў гэта адзін Some: загаловак у 12 байтаў плюс адна спасылка ў 4 байты пад compressed oops. Пры ўключаным escape analysis, а ён уключаны па змаўчанні, JIT разумее, што аб’ект не пакідае метад і не алакуе яго зусім. Цыкл 3 сыходзіць са 188 мс да 229, калі я гэта выключаю. Аб’ект сапраўдны і ён каштуе часу, калі JIT не можа яго прыбраць. Проста ён не ствараецца.

Той жа блок закрывае другое пытанне лепш, чым мая першая пара. Цыклы 2 і 3 з прымусовай алакацыяй будуюць аднолькавую колькасць Some і алакуюць пароўну, па 31.9795 байта на пошук. Паміж імі ўсё роўна 315 мс. Чым бы гэты разрыў ні быў, гэта не Option.

Чаго я не правяраў

Куды сыходзяць тыя самыя 248 мс. Я памераў, што гэта не Option. Далей не пайшоў, так што scala.collection.mutable.HashMap застаецца ў спісе.

Ці выжывае ўсё гэта па-за шчыльным цыклам. Дваццаць мільёнаў пошукаў, калі больш нічога не адбываецца, гэта цяплічныя ўмовы для escape analysis. У апрацоўшчыку запыту, у якога зверху стэк фрэймаў, я не ведаю, ці застанецца Some па-за кучай. Такога тэста я не сабраў.