Magazín KPI
Časopis Katedry počítačov a informatiky FEI TUKE
kpi

Swift Optionals: Povedzte dovidenia NullPointerException

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é.

Linkovať