Zlepšila som si život prípravkom proti null!

2021/09/24

V predošlom dieli sme videli použitie návrhového vzoru monáda na príklade škatule, ktorá dokázala obaliť ľubovoľný reťazec.

Teraz je čas to trochu vylepšiť.

Načo je trieda Maybe?

Keďže null je chyba za miliardu dolárov, skúsme to odstrániť.

Vždy, keď by metóda mala vracať null, radšej nech vráti objekt Maybe, ktorý buď obsahuje alebo neobsahuje hodnotu. A toto vieme poľahky implementovať návrhovým vzorom monáda!

To, že Maybe obsahuje hodnotu alebo neobsahuje môžeme programovať dvojako:

--------
| Maybe |
--------
    A    A
    |      \
--------     --------     
| Some |     |  None |
--------     ---------

Podtriedy „dačo“ a „nič“

Trieda Maybe bude mať dve podtriedy: Some pre objekty s hodnotou („nejaká hodnota“) alebo None pre prípad, že hodnota nie je, čo je ekvivalent chýbajúcej, nedostupnej, alebo null hodnoty.

 Toto je v skutočnosti variant návrhového vzoru Null Object, ale s mnohými vylepšeniami.

Trieda Maybe bude mať jedinú metódu: then, ktorá … presne ako v predošlom dieli:

… vie na svoj obsah aplikovať Java funkciu a vrátiť novú škatuľu, ale len vtedy, ak nie je prázdna. Ak je škatuľa prázdna, vráti prázdnu škatuľu.

Inými slovami: na svoj obsah aplikuje funkciu a vráti nové Maybe s novým obsahom, ale len vtedy, ak nie je None. Ak je to None, vráti None.

    public abstract class Maybe<V> {
        public abstract <R> Maybe<R> then(Function<V, Maybe<R>> handler);
    }

Oproti škatuli Box vie trieda Maybe obaliť generický dátový typ V, teda vie obaliť ľubovoľný objekt – V ako value, alebo „vé ako vnútro“.

Metóda then berie funkciu, čo zoberie vnútro V a vráti iný objekt Maybe, ktorý obalí výslednú hodnotu typu RR ako result. Z „možno vé“ sa tak stane „možno er“.

Na to, aby to fungovalo, musíme definovať dve podtriedy – ideálne vo vnútri triedy Maybe – triedu None a triedu Some.

Podtrieda „nič“ – None

Trieda None je jednoduchá: keďže nemá vnútro, nemá zmysel naň aplikovať funkciu. Ak sa o to pokúsime, dostaneme z ničoho… nič.

public static class None<V> extends Maybe<V> {
    public <R> Maybe<R> then(Function<V, Maybe<R>> handler) {
        return new None<>();
    }
}   

Podtrieda „dačo“ – Some

Trieda Some je zase podobná škatuli Box:

public static class Some<V> extends Maybe<V> {
    private final V value;

    public Some(V value) {
        this.value = value;
    }

    @Override
    public <R> Maybe<R> then(Function<V, Maybe<R>> handler) {
        return handler.apply(this.value);
    }
}

Objekt Some čosi obaľuje, a ak naň použijeme funkciu, zoberieme ono „čosi“, aplikujeme naň funkciu, ktorá vráti iný objekt, čo má „možno hodnotu, možno nie“.

Obalenie ľubovoľnej hodnoty

Okrem metódy then() potrebujeme možnosť zabaliť ľubovoľnú hodnotu, čo dokážeme cez konštruktor Some().

Ukážme si však ukážku testu, v ktorom použijeme náš kód:

package com.github.novotnyr.monad;

import com.github.novotnyr.monad.maybe.Maybe.Some;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class MaybeTest {
    @Test
    void testRegularRun() {
        EtcPasswd etcPasswd = new EtcPasswd();

        new Some<>("root")
                .then(login -> new Some<>(etcPasswd.findEntry(login)))
                .then(line -> new Some<>(etcPasswd.getGecos(line)))
                .then(gecos -> new Some<>(etcPasswd.getEmail(gecos)))
                .then(email -> {
                    assertEquals("root@example.com", email);
                    return new Some<>(email);
                });
    }
}

Kód veľmi pripomína používanie škatule Box, akurát musíme použiť generický dátový typ. Našťastie, Java sa postará o automatické odvodzovanie (inferenciu), takže namiesto new Some<String> stačí písať new Some<>.

Opravujeme ukecaný kód

Kód je aj tak ukecaný, takže ho vylepšíme a rovno z dvoch koncov. Oba súvisia so spracovaním null hodnôt.

Obalenie priamo v Maybe

Do Maybe dodajme pomocnú metódu, ktorá automaticky rozhodne, či je vstup null alebo nie:

public static <V> Maybe<V> of(V value) {
    if (value == null) {
        return new None<>();
    } else {
        return new Some<>(value);
    }
}

Test následne upravme:

@Test
void testRegularRun() {
    EtcPasswd etcPasswd = new EtcPasswd();

    Maybe.of("root")
            .then(login -> Maybe.of(etcPasswd.findEntry(login)))
            .then(line -> Maybe.of(etcPasswd.getGecos(line)))
            .then(gecos -> Maybe.of(etcPasswd.getEmail(gecos)))
            .then(email -> {
                assertEquals("root@example.com", email);
                return Maybe.of(email);
            });
}

Zbavme sa null v triede EtcPasswd

Ak sa chceme zbaviť null aj z opačného konca, musíme upraviť EtcPasswd. Táto trieda totiž nikdy nebude vracať z metód null, ale vždy nejaké Maybe!

Najprv si však do Maybe dodajme ešte jednu pomocnú metódu:

public static <V> Maybe<V> none() {
    return new None<>();
}

Následne si vytvorme vylepšenú triedu SafeEtcPasswd, kde všetky metódy vracajú Maybe. Napr. metóda getGecos():

public Maybe<String> getGecos(String line) {
    String[] components = line.split(FIELD_SEPARATOR);
    if (components.length < 7) {
        return Maybe.none();
    }
    String gecos = components[4];
    if (gecos.isEmpty()) {
        return new Maybe.None<>();
    }
    return Maybe.of(gecos);
}

Ostatné metódy necháme na pozorného čitateľa!

Ak si vytvoríme nový test, tak uvidíme, že výsledky volania metód na SafeEtcPasswd už nemusíme obaľovať do Maybe, pretože sa to deje automaticky – každá metóda vždy vracia objekt Maybe.

    Maybe.of("root")
            .then(login -> etcPasswd.findEntry(login))

Keďže do metódy then posielame funkciu, ktorá je priamym volaním metódy na objekte, môžeme použiť odkaz na metódu, method reference:

@Test
void testSafeEtcPasswd() {
    SafeEtcPasswd etcPasswd = new SafeEtcPasswd();

    Maybe.of("root")
        .then(etcPasswd::findEntry)
        .then(etcPasswd::getGecos)
        .then(etcPasswd::getEmail)
        .then(email -> {
            assertEquals("root@example.com", email);
            return Maybe.of(email);
        });
}

Zápis je teraz už omnoho krajší ako na začiatku, nehovoriac o pyramíde hrôzy!

A čo so zlyhanými výsledkami?

Toto platí aj pre prípad, že postupnosť volaní zlyhá, a teda, že niektorý krok vráti None.

Aha, test, kde zámerne vyrobíme nespracovateľný riadok, kde je položka GECOS prázdna:

@Test
void testWithUnparsableLine() {
    AtomicBoolean testFailed = new AtomicBoolean(false);
    SafeEtcPasswd etcPasswd = new SafeEtcPasswd();
    Maybe.of("root:*:0:0::/var/root:/bin/sh")
            .then(etcPasswd::getGecos)
            .then(etcPasswd::getEmail)
            .then(email -> {
                testFailed.set(true);
                return Maybe.of(email);
            });
    assertFalse(testFailed.get());
}

Test sa nikdy nedopracuje k spracovaniu emailu, ba dokonca ani k volaniu metódy getEmail! Keďže položka GECOS je prázdna, výsledkom volania getGecos() je hodnota None a zvyšok zreťazených volaní metódy then sa nepoužije.

Máme tak elegantný objekt Maybe, ktorá nikdy nenarazí na null a nikdy sa ním nemusíme zapodievať.

A samozrejme nezabudnime, že metóda then() podporuje aj iné dátové typy!

Premieňame čísla na reťazce

Dopracujme do SafeEtcPasswd metódu na spracovanie identifikátora používateľa z tretej položky riadku:

public Maybe<Integer> getUid(String line) {
    String[] components = line.split(FIELD_SEPARATOR);
    if (components.length < 7) {
        return Maybe.none();
    }
    try {
        String uidValue = components[2];
        int uid = Integer.parseInt(uidValue);
        return Maybe.of(uid);
    } catch (NumberFormatException e) {
        return Maybe.none();
    }

}

Test potom vyzerá úplne rovnako ako v predošlom prípade – metóda getUid() vracia možno číslo – možno nie, ale vždy ho vráti obalené v objekte Maybe<Integer>. Ten pošleme na ďalšie spracovanie do then a ak je všetko v poriadku, výsledok je null.

@Test
void testUid() {
    SafeEtcPasswd etcPasswd = new SafeEtcPasswd();

    Maybe.of("root")
            .then(etcPasswd::findEntry)
            .then(etcPasswd::getUid)
            .then(uid -> {
                assertEquals(0, uid);
                return Maybe.of(uid);
            });
}    

Čo sme teda dostali?

Máme teda triedu Maybe reprezentovanú ako monádu:

Naša trieda má však stále priestor na vylepšenie:

To si ukážeme nabudúce!

>> Home