Tento článok sa venuje jazyku Swift a tomu ako sa tento jazyk vysporiadal s reprezentáciou absencie hodnoty v premennej. Za týmto účelom Swift používa objekt nil, ktorý však nemôže byť priradený ľubovoľnej premennej, ale iba premennej, ktorá má Optional typ. Prečítajte si, ako Optional typy zvyšuju bezpečnosť jazyka a pri správnom použití odstraňujú riziko vzniku ekvivalentu NullPointerException.
Keď som začal programovať vo svojom prvom objektovo-orientovanom jazyku, v Jave (ktorý býva prvým programovacím jazykom pre väčšinu našich študentov na TUKE), netrvalo dlho, a stretol som sa s NullPointerException. A chvíľku mi trvalo, kým som sa ju naučil efektívne riešiť (rozumejte nájsť miesto jej vzniku, identifikovať pôvod hodnoty null
, a vyriešiť to). Napriek rokom skúseností sa ku mne táto výnimka neprestajne vracia, aj keď už oveľa zriedkavejšie. Je to dôvod mojej nepozornosti? Častokrát áno. Ale veľakrát je to aj následok nepochopenia dokumentácie knižnice alebo frameworku, a teda jeho nesprávne použitie (áno, dokumentácia je veľmi dôležitá).
NullPointerException v skratke
Skúsme si v krátkosti zopakovať, ako NullPointerException vzniká. Tomu totiž potrebujeme rozumieť, aby sme porozumeli významu a motivácii za optional typmi vo Swifte. Majme nasledujúcu triedu v Jave:
import java.util.List;
import java.util.LinkedList;
public class Person {
private String name;
private List<Person> friends;
public Person(String name) {
this.name = name;
this.friends = new LinkedList<Person>();
}
public String getName() {
return this.name;
}
public void befriend(Person otherPerson) {
this.friends.add(otherPerson);
System.out.println(this.name + " became friends with " + otherPerson.getName() + ".");
}
public void printFriends() {
System.out.println(this.name + "'s friends:");
for (Person friend : friends) {
System.out.println(friend.getName());
}
}
}
Trieda Person
je pomerne jednoduchá a samovysvetľujúca. Predstavuje jednoduchú abstrakciu osôb. Každá osoba má meno a zoznam osôb, ktorí sú jej priatelia. Z hľadiska správania sa (metódy) poskytuje okrem gettera pre meno dve metódy — 1. metóda befriend
vytvorí nový vzťah priateľstva; 2. metóda printFriends
vypíše zoznam priateľov danej osoby. Samozrejme, že pre modelovanie reálneho sveta by sme potrebovali riešiť obojsmernosť vzťahu priateľstva a mnoho ďalších vecí (už takýto jednoduchý príklad ukazuje náročnosť povolania programátora) — pre naše potreby ale dôslednosť modelu nie je kľúčová.
Vezmime si teraz nasledovné použitie triedy Person
:
public class Main {
public static void main(String[] args) {
Person milan = new Person("Milan");
Person ivan = new Person("Ivan");
milan.befriend(ivan);
}
}
Vytvorili sme si objekty dvoch osôb, Milana a Ivana, a oni sa spriatelili (modelujeme predsa reálny svet, takže inak to nemohlo dopadnúť). Ak spustíme tento kód, všetko prebehne v poriadku (môžete si tento kód vyskúšať online na ). Ak ho však jemne pozmeníme nasledovne, stretneme sa s nechválne známou NullPointerException (uvádzam len podstatné riadky, kostra triedy Main ostáva ako je uvedené vyššie):
Person milan = new Person("Milan");
Person ivan = null;
milan.befriend(ivan);
Tento kód sa síce bez problémov skompiluje, ale pri spustení nám v metóde befriend
triedy Person
ohlási NullPointerException. Ak tejto výnimke rozumiete, tak viete, že problém nie je riadok
milan.befriend(ivan);
ale nasledovný riadok už vnútri metódy befriend
:
System.out.println(this.name + " became friends with " + otherPerson.getName() + ".");
Aký s ním má Java Virtual Machine (JVM) problém? Nuž, v parametri otherPerson
je hodnota null
, tú sme tam poslali, keď sme metódu zavolali a ako jej parameter sme jej odovzdali premennú ivan
, ktorá v sebe schovávala null
. A teraz sa nad premennou otherPerson
pokúšame volať členskú metódu getName()
, aby sme zistili meno tej osoby a mohli ho vypísať. A to je zdrojom NullPointerException — snažíme sa volať členskú metódu, teda metódu, ktorá sa vykonáva nad objektom (na rozdiel od statickej metódy), ale nemáme objekt! Máme iba hodnotu null
.
Uvedený príklad zjednodušene ukazuje problém s NullPointerException, a trošku aj naznačuje, aká zapeklitá táto výnimka môže byť. Totiž aj keď výnimka nastala v metóde befriend
na riadku, kde sme sa snažili vypísať meno tej ďalšej osoby, tak tento riadok nebol skutočným problémom. Problém bol, že sme túto metódu nesprávne použili — ako parameter sme jej poslali null
hodnotu namiesto skutočnej osoby. Takáto situácia môže nastať, ak si neprečítame poriadne dokumentáciu — napríklad zlyhaním z mojej strany v tomto príklade bolo aj to, že metóda befriend
nemá JavaDoc dokumentáciu. V dokumentácii by som mal jasne a jednoznačne uviesť, že parameter otherPerson
nesmie byť null
.
V našom príklade bolo miesto vyhodenia výnimky veľmi blízko skutočnému zdroju problému, bolo vzdialené iba o jedno volanie. V reálnejších podmienkach máme však oveľa väčší systém, a zložitejšie závislosti medzi jednotlivými komponentami a triedami. A tak môže byť oveľa ťažšie uvidieť potenciálny problém skôr než nastane, ba niekedy vôbec identifikovať, že kde hodnota null
vôbec vznikla a ako sa dostala na miesto vyhodenia výnimky.
Kompilátor zasahuje
Napadne vás zrejme otázka: “Prečo mi to neodkontroluje kompilátor ešte počas kompilácie? Veď keď mi vie odkontrolovať správnosť typov, mohol by zvládnuť aj toto.” Áno, i nie. Áno, teoreticky by to mohol zvládnuť — ako uvidíme, vo Swifte to ide. Ten v Jave ale nemá dostatok informácii na to, aby to dokázal. Ako má kompilátor vedieť, že niekde nechceme null
, a zasa inde null
umožníme (pamätajme na to, že null
hodnota má svoj význam)?
Nuž, dodnes štandardná Java (v čase písania verzia 8) nepodporuje označenie premenných ako “nenullových”. Existujú treťostranné frameworky (checker-framework) a prostredia (napr. NetBeans alebo IntelliJ Idea), ktoré to umožňujú použitím anotácii — @NonNull
, @NotNull
, a pod., a vedia programátora upozorniť na to, ak sa do premennej označenej takouto anotáciou môže dostať hodnota null
. Dúfajme, že v budúcnosti príde aj natívna podpora, ako je to v jazyku Swift.
Jazyk Swift túto situáciu rieši použitím špeciálneho typu Optional
. Swift namiesto null
hodnoty používa špeciálny objekt nil
(null
a nil
nie je to isté, null
je absencia hodnoty, nil
je objekt reprezentujúci absenciu). Ten však nemôžete vložiť do ľubovoľnej premennej, iba do premennej typu Optional
. Optional
je enumeračný typ s dvoma hodnotami — none
reprezentujúcou objekt nil
, a some(wrapped)
reprezentujúci nejaký objekt zaobaleného typu (The Swift Programming Language (Swift 3) — Types). Z tejto diskusie vyplýva, že premenná s údajovým typom, ktorý nie je Optional
, nemôže nikdy nadobnúť hodnotu objektu nil
. Teda sa dá povedať, že predvolene je každá premenná “nenullová”, a tento stav je vynucovaný kompilátorom. Ak sa do takej premennej pokúsite vložiť nil
, alebo ju proste neinicializujete (nevložíte do nej žiadnu hodnotu), kompilátor vám program neskompiluje. Teda pokiaľ v programe nepoužívate typ Optional
, výnimka ekvivalentná NullPointerException je prakticky nemožná.
Pozrime sa na prepis triedy Person
do jazyka Swift (verzia 3):
import Foundation
public class Person {
private(set) var name: String
private var friends: [Person]
init(name: String) {
self.name = name
self.friends = []
}
func befriend(otherPerson: Person) {
self.friends.append(otherPerson)
print("(self.name) became friends with (otherPerson.name).");
}
func printFriends() -> Void {
print("(self.name)'s friends:")
self.friends.forEach { (friend: Person) in
print(friend.name)
}
}
}
V tejto triede máme dve premenné, premennú name
typu String
a premennú friends
typu pole osôb (hranaté zátvorky sú syntaktickým cukrom pre pole, teda typ [Person]
je skratka pre Array
). Žiadna z týchto premenných nemá typ Optional
, a teda nemôže nikdy nadobúdať hodnotu nil
— preto trieda Person
potrebuje inicializér (konštruktor), ktorý do oboch premenných vloží nejaký objekt daného typu. V našom prípade si meno osoby opýtame pri vytváraní objektu, a do premennej friends
vložíme nové prázdne pole.
Prepíšme teraz do Swiftu prípad použitia, ktorý v Jave vyvolal NullPointerException:
let milan: Person = Person(name: "Milan")
let ivan: Person = nil
milan.befriend(otherPerson: ivan)
Tento kód kompilátor Swiftu neskompiluje, ale dá nám nasledovnú chybovú hlášku:
nil cannot initialize specified type 'Person'
a to sa udeje na nasledovnom riadku:
let ivan: Person = nil
Táto chyba nám v podstate hovorí, že objekt nil
nemôžeme použiť na inicializáciu premennej ivan
— a to preto, že typ tejto premennej nie je Optional
, ale je to priamo typ Person
. Aby sme do premennej ivan
mohli vložiť nil
, musíme typ premennej deklarovať ako Optional
:
let milan: Person = Person(name: "Milan")
let ivan: Optional<Person> = nil
milan.befriend(otherPerson: ivan)
V tomto prípade opäť vznikne chyba pri kompilácii, ale tentokrát na inom mieste, a to na riadku:
milan.befriend(otherPerson: ivan)
pretože teraz nebude sedieť typ parametra (Person
) s typom premennej, ktorú tam vkladáme (Optional
). Táto chyba je teda výsledkom statickej typovej kontroly. Takto typová kontrola skombinovaná s typom Optional
zabezpečuje program pred výskytmi NullPointerException.
Práca s Optionals
My však dobre vieme, že absencia hodnoty je pri programovani veľakrát žiadaná. A teda Optional
typy budeme zrejme chcieť používať často. Ako teda autori Swiftu dosiahli to, aby sa nám s nimi pracovalo pohodlne, a aby sme teda napokon nesmútili za možnosťou dostať NullPointerException?
Nuž, za prvé, Swift nám poskytuje pekný syntaktický cukor na zápis Optional
typu. Namiesto použitia generického zápisu Optional
môžeme použiť otáznik za názvom typu, teda Wrapped?
. Napr. premennú ivan
môžeme deklarovať nasledovne:
let ivan: Person? = nil
Pekné, že? To je malé vylepšenie. Ale čo ak chceme sfunkčniť nasledovný kód, ale premennú ivan potrebujeme ako Optional
(napr. kvôli definícii API, a pod)? Ako môžeme použiť premennú ivan
, ak sme si istý, že v nej hodnota je?
let milan: Person = Person(name: "Milan")
let ivan: Person? = Person(name: "Ivan")
// some code
milan.befriend(otherPerson: ivan)
Jedna z možností je použiť nad premennou ivan
vlastnosť (property) unsafelyUnwrapped
, ktorá je členom typu Optional
a vracia zaobalený objekt, alebo priamo syntakticky cukrík v podobe skráteného zápisu s výkričníkom:
let milan: Person = Person(name: "Milan")
let ivan: Person? = Person(name: "Ivan")
// some other code
if ivan != nil {
milan.befriend(otherPerson: ivan!)
// milan.befriend(otherPerson: ivan.unsafelyUnwrapped)
}
Ak v premennej ivan
je skutočne objekt typu Person
, tak ivan!
bude práve daný objekt a už nie Optional
— teda typová kontrola nám umožní tento objekt poslať ako argument metóde befriend
. Prv sme ho však museli otestovať a rozbaliť. Tým nám jazyk pomáha udržiavať bezpečnosť voči NullPionterException. Samozrejme, predchádzajúci kód sa dá zapísať aj bez testu či premenná ivan
neobsahuje nil
, ale to už by nebolo veľmi bezpečné — to by sme totiž riskovali ekvivalent NullPointerException.
Jedna z vecí, ktorá vám mala udrieť do očí, bol názov vlastnosti unsafelyUnwrapped
. Unsafely? Nie je to bezpečné? Ako teda bezpečne rozbaliť Optional
typ?
Práve na tento účel existuje podmienené priradenie (if let
):
let milan: Person = Person(name: "Milan")
let unsafeIvan: Person? = Person(name: "Ivan")
// some other code
if let ivan = unsafeIvan {
milan.befriend(otherPerson: ivan)
}
Kombinácia if let
umožňuje deklarovať dočasnú premennú (v skutočnosti je to konštanta, keďže je deklarovaná použitím let
a nie var
) z typu Optional
. Ak v premennej unsafeIvan
je hodnota nil
, if
sa vyhodnotí ako false
a kód v bloku sa nevykoná. Ak je však v premennej unsafeIvan
hodnota, tak tá sa priradí do premennej ivan a môžeme ju používať už ako konštantu rozbaleného typu, v našom prípade priamo typu Person
. V jednom if-e môžeme takto deklarovať hneď viacero konštánt (a dokonca v rámci toho istého if-u použiť aj nejaké normálne boolovské podmienky, ale tým si príklad už komplikovať nebudeme):
let unsafeMilan: Person? = Person(name: "Milan")
let unsafeIvan: Person? = Person(name: "Ivan")
// some other code
if let milan = unsafeMilan,
let ivan = unsafeIvan {
milan.befriend(otherPerson: ivan)
}
Existuje ešte jedna pekná konštrukcia na prácu s Optional
typmi — niečo ako podmienené vykonávanie. Vezmime si nasledovný príklad:
let milan: Person? = Person(name: "Milan")
let ivan: Person = Person(name: "Ivan")
milan?.befriend(otherPerson: ivan)
Premenná milan je Optional
, a teda môže obsahovať objekt nil
. Na poslednom riadku dávam za názov premennej otáznik, a až tak nad ňou volám metódu befriend
. Tento zápis hovorí: Ak v premennej milan
je nejaký objekt typu Person
, zavolaj nad tým objektom metódu befriend
; ak v nej je objekt nil
, nerob nič. Konštrukciu if let
, resp. guard let (Hacking With Swift — The guard keyword in Swift 2: early returns made easy), používame vtedy, keď rozbalený objekt potrebujeme použiť viackrát.
Zhrnutie
Takže týmto by som uzavrel tento úvod do typu Optional
v jazyku Swift. Jazyk Swift si kvôli bezpečnosti vyžaduje, aby každá premenná mala konkrétnu hodnotu, a vynucuje to typovou kontrolou. Prípad absencie hodnoty je vyjadrený špeciálnym objektom nil
, avšak aby tento objekt mohol byť do premennej priradený, musí mať premenná Optional
typ. A keď má premenná typ Optional, tak pred použitím sa tento typ musí explicitne rozbaliť (napr. použitím if let
konštrukcie), inak kompilátor odmientne kód skompilovať. Vďaka týmto opatreniam si je Swift programátor viac vedomý toho, kedy pracuje s premennými, ktoré môžu nadobúdať hodnotu nil
, a musí s nimi narábať opatrnejšie. Táto opatrnosť vynútená kompilátorom zvyšuje bezpečnosť kódu.
P.S.
Kvôli úplnosti ešte spomeniem možnosť tzv. implicitného rozbalenia (implicitly unwrapped Optional). Implicitne rozbalenú premennú deklarujeme použitím výkričníku za typom, napr. Person!
je implicitne rozbalený typ Person
. Takto deklarovaná premenná je vlastne typu Optional
, a teda môže mať v sebe objekt nil
, ale v kontexte statickej typovej kontroly sa táto premenná tvári ako rozbalený typ, a teda vieme ju použiť tam, kde sa očakáva priamo typ Person
. Ako vás pravdepodobne hneď napadlo, implicitne rozbalený typ zavrhuje bezpečnosť Optional
typu a vracia sa k stavu, aký je napr. v Jave — to znamená, že môže dôjsť k obdobe výnimky NullPointerException. Prečo teda takúto možnosť autori Swiftu zaviedli?
Nuž, jediný opodstatný prípad, kedy implicitne rozbalený Optional môžete oprávnene použiť, je prípad, kedy pri vytváraní objektu niektoré jeho premenné ešte neviete dodať, ale viete, že po jeho konfigurácii premenné nadobudnú hodnotu a tá sa už neprepíše (viď napr. StackOverflow — Why create “Implicitly Unwrapped Optionals”?). To sa napr. využíva pri definovaní používateľského rozhrania InterfaceBuilder-om, keď sa kód konfiguruje výsledkom použitia InterfaceBuilderu, ale až počas behu programu. Pri vytvárani objektu ešte obsah premenných nepoznáme, nakonfiguruje ho InterfaceBuilder. Za behu sa ale premenné odkazujúce na tlačidlá a iné komponenty rozhrania už neprepisujú, a teda zakaždým ich rozbaľovať by bolo zbytočné.