Kotlin nullable fantastici e dove trovarli
Come comporre Kotlin nullable calcolati indipendentemente, in modo facile e pulito
In Kotlin esiste il concetto di null safety.
I Nullable non possono essere considerati monadi a tutti gli effetti. Tuttavia sono molti i vantaggi nell’utilizzarli. Permettono di esprimere l’opzionalità del risultato di una computazione, direttamente nella firma di un metodo. Questo garantisce a compile-time, che a run-time non avremo sorprese. Significa che una funzione con side-effect viene riportata nel “mondo” delle funzioni pure.
Trovo che i nullable siano una delle caratteristiche più interessanti e idiomatiche di questo linguaggio.
Detto ciò, rimane comunque un nervo scoperto nel loro utilizzo. La componibilità!
In questo articolo voglio proporre una soluzione.
Hai mai provato a comporre dei nullable?
Beh… non posso certo dire che comporre dei valori nullabili, risultati da computazioni indipendenti, sia semplice ed intuitivo. Non se si vuole mantenere il codice pulito.
Esaminiamo un caso d’uso realistico.
un exchange di cripto-valute
Immaginiamo come funziona un exchange di cripto-valute. Al momento della registrazione, vengono richiesti nome e cognome, username e una email di riferimento. A seguito della registrazione, l’exchange mette a disposizione la consultazione di un nuovo fantastico mondo.
Dopo qualche tempo, necessario ad approfondire le regole e gli strumenti, rimaniamo entusiasti. Decidiamo allora di comprare un po’ della nostra criptovaluta preferita. Non si può!
Prima di poter comprare, è necessario fornire degli altri dati, che ai soli fini della registrazione erano opzionali. In particolare ci viene richiesto un numero di telefono, una carta di credito e di completare la procedura di KYC.
Vediamo come modellarlo nella business logic:
data class CryptoUser( val username: String, val name: PersonalName, val email: Email, val phoneNumber: PhoneNumber? = null, val creditCard: CreditCard? = null, val kycVerification: KycVerificationData? = null)
L’ exchange dispone una procedura chiamata becomeRich
che ci permette di acquistare criptovaluta.
data class BuyCryptoInfo( val username: String, val phoneNumber: PhoneNumber, val creditCard: CreditCard, val kycVerification: KycVerificationData) fun becomeRich(crypto: CryptoInfo, buyInfo: BuyCryptoInfo) = TODO("conquer the world")
Non-null da Nullables
Riesci ad immaginare una maniera per passare da CryptoUser
a BuyCryptoInfo
?
Esistono due maniere, di seguito proposte come factory method:
data class BuyCryptoInfo( val username: String, val phoneNumber: PhoneNumber, val creditCard: CreditCard, val kycVerification: KycVerificationData){ companion object{ fun from1(user: CryptoUser): BuyCryptoInfo? = with(user){ if(phoneNumber != null && creditCard != null && kycVerification != null) BuyCryptoInfo(username, phoneNumber, creditCard, kycVerification) else null } fun from2(user: CryptoUser): BuyCryptoInfo? = with(user){ phoneNumber?.let { creditCard?.let { kycVerification?.let { BuyCryptoInfo(username, phoneNumber, creditCard, kycVerification) }}} } } }
Personalmente, nessuna di queste due mi piace. Continuiamo lo stesso:
Clicchiamo il bottone “Compra“. Immediatamente a schermo ci appare Non sono stati forniti dei dati obbligatori per completare la procedura. Controlla i tuoi dati.
Gestendo solo la nullabilità dei dati, possiamo solo dire che qualcosa di necessario è mancante. Però non possiamo dire cosa.
Modifichiamo allora il codice:
sealed class Result<out T>{ data class Ok<out T>(val value: T): Result<T>() data class Error(val description: String): Result<Nothing>() } data class BuyCryptoInfo( val username: String, val phoneNumber: PhoneNumber, val creditCard: CreditCard, val kycVerification: KycVerificationData){ companion object{ fun from1(user: CryptoUser): Result<BuyCryptoInfo> = with(user){ if(phoneNumber == null) return Result.Error("il numero di telefono e' obbligatorio") if(creditCard == null) return Result.Error("la carta di credito e' obbligatoria") if(kycVerification == null) return Result.Error("devi completare la procedura KYC") return Result.Ok(BuyCryptoInfo(username, phoneNumber, creditCard, kycVerification)) } } }
Ottimo, ora gestiamo gli errori.
Proviamo ancora a comprare. Click… il numero di telefono e' obbligatorio
. Mettiamo il telefono. Compra! la carta di credito e' obbligatoria
. Mettiamo la carta di credito. Compra! devi completare la procedura KYC
Cambiamo ancora il codice.
data class BuyCryptoInfo( val username: String, val phoneNumber: PhoneNumber, val creditCard: CreditCard, val kycVerification: KycVerificationData){ companion object{ fun from1(user: CryptoUser): Result<BuyCryptoInfo> = with(user){ val listOfErrors = mutableListOf<String>() if(phoneNumber == null) listOfErrors.add("il numero di telefono e' obbligatorio") if(creditCard == null) listOfErrors.add("la carta di credito e' obbligatoria") if(kycVerification == null) listOfErrors.add("devi completare la procedura KYC") if(listOfErrors.size > 0) return Result.Error(listOfErrors.joinToString(",")) return Result.Ok(BuyCryptoInfo(username, phoneNumber!!, creditCard!!, kycVerification!!)) } } }
Ora l’utente avra’ tutti gli errori in una volta sola, e la sua UX sarà migliore. Ma tu, come sviluppatore, sei soddisfatto?
Konad al salvataggio!
Non volevo rinunciare all’utilizzo dei nullable, però non ero nemmeno soddisfatto da soluzioni paragonabili a quelle riportate.
Convinto che una maniera migliore dovesse per forza esistere, ho cominciato a cercare una soluzione. E’ stato un viaggio lungo, attraverso Monad, Applicative Functors e Higher-Kinded Types.
Alla fine ho capito che per Kotlin, non esisteva alcuna soluzione pre-cotta. Esiste Arrow, ma richiede un completo cambio di paradigma di programmazione e di strutture dati. Sarebbe come sparare ad un topolino con un carro-armato. Inoltre la curva di apprendimento per un programmatore OOP è molto ripida, trattandosi di una libreria che implementa il paradigma funzionale al 100%.
Per questi motivi ho deciso di creare una soluzione tutta mia. Vi presento quindi Konad.Usando Konad, i metodi precedenti cambiano come segue:
import io.konad.* data class BuyCryptoInfo( val username: String, val phoneNumber: PhoneNumber, val creditCard: CreditCard, val kycVerification: KycVerificationData){ companion object{ // Se vuoi riportare l'elenco di errori fun from1(user: CryptoUser): Result<BuyCryptoInfo> = with(user){ ::BuyCryptoInfo.curry() .on(username) .on(phoneNumber.ifNull("missing phone number")) .on(creditCard.ifNull("missing credit card")) .on(kycVerification.ifNull("missing kyc verification")) .result } // Se vuoi gestire solo l'opzionalità fun from2(user: CryptoUser): BuyCryptoInfo? = with(user){ ::BuyCryptoInfo.curry() .on(username) .on(phoneNumber.maybe) .on(creditCard.maybe) .on(kycVerification.maybe) .nullable } } }
Penso di poter affermare con una buona confidenza, che questa versione sia molto più semplice e pulita delle precedenti.
Vediamo infine come diventare ricchi!
fun becomeRich(crypto: CryptoInfo, buyInfo: BuyCryptoInfo): Boolean = false
val user = CryptoUser(username = "foo.bar", ..., ...)
val crypto = CryptoInfo("Bitcoin")
val youGotRich1: Result<Boolean> = ::becomeRich.curry()
.on(crypto)
.on(BuyCryptoInfo.from1(user))
.result
// or
val youGotRich2: Result<Boolean> = BuyCryptoInfo.from1(user)
.map { cryptoInfo -> becomeRich(crypto, cryptoInfo) }
when(youGotRich1 /*or youGotRich2*/){
is Result.Ok -> "Congrats!"
is Result.Errors -> youGotRich1.description
}.run(::println) // Stampera' Congrats! oppure la lista delle informazioni mancanti
Conclusioni
I Nullable di Kotlin sono perfetti per esprimere l’opzionalità del risultato di una computazione. Tuttavia manca un po’ di UX in casi d’uso più avanzati, come quello presentato in questo articolo.
Con Konad, spero di aver coperto questo buco e di aver convinto a dargli un’opportunità nei vostri progetti 😊.
Qualsiasi feedback vorrete darmi sarà molto apprezzato. Compresi contributi alla libreria, o segnalazioni di bug o errori di concetto. Potete contattarmi tramite Twitter all’ handle @luca_picci o commentando questo articolo.
Ringrazio per la lettura.