Notre entreprise utilise Kotlin en production depuis plus de deux ans. Personnellement, j'ai rencontré cette langue il y a environ un an. Il y a de nombreux sujets à aborder, mais aujourd'hui nous parlerons de la gestion des erreurs, y compris dans un style fonctionnel. Je vais vous dire comment faire cela à Kotlin.
(Photo de la réunion sur ce sujet, qui a eu lieu dans le bureau de l'une des sociétés Taganrog. Alexey Shafranov, chef du groupe de travail (Java) chez Maxilekt, a pris la parole)Comment gérer les erreurs en principe?
J'ai trouvé plusieurs façons:
- vous pouvez utiliser une valeur de retour comme pointeur sur le fait qu'il y a une erreur;
- vous pouvez utiliser le paramètre indicateur dans le même but,
- entrez une variable globale
- gérer les exceptions
- Ajouter des contrats (DbC) .
Arrêtons-nous plus en détail sur chacune des options.
Valeur de retour
Une certaine valeur «magique» est renvoyée en cas d'erreur. Si vous avez déjà utilisé des langages de script, vous devez avoir vu des constructions similaires.
Exemple 1:
function sqrt(x) { if(x < 0) return -1; else return √x; }
Exemple 2:
function getUser(id) { result = db.getUserById(id) if (result) return result as User else return “Can't find user ” + id }
Paramètre indicateur
Un certain paramètre passé à la fonction est utilisé. Après avoir renvoyé la valeur par le paramètre, vous pouvez voir s'il y avait une erreur à l'intérieur de la fonction.
Un exemple:
function divide(x,y,out Success) { if (y == 0) Success = false else Success = true return x/y } divide(10, 11, Success) id (!Success) //handle error
Variable globale
La variable globale fonctionne à peu près de la même manière.
Un exemple:
global Success = true function divide(x,y) { if (y == 0) Success = false else return x/y } divide(10, 11, Success) id (!Success) //handle error
Exceptions
Nous sommes tous habitués aux exceptions. Ils sont utilisés presque partout.
Un exemple:
function divide(x,y) { if (y == 0) throw Exception() else return x/y } try{ divide(10, 0)} catch (e) {//handle exception}
Contrats (DbC)
Franchement, je n'ai jamais vu cette approche en direct. Après une longue recherche sur Google, j'ai trouvé que Kotlin 1.3 avait une bibliothèque qui permet en fait l'utilisation de contrats. C'est-à-dire vous pouvez définir la condition sur les variables qui sont passées à la fonction, la condition sur la valeur de retour, le nombre d'appels, d'où elle est appelée, etc. Et si toutes les conditions sont remplies, on pense que la fonction a fonctionné correctement.
Un exemple:
function sqrt (x) pre-condition (x >= 0) post-condition (return >= 0) begin calculate sqrt from x end
Honnêtement, cette bibliothèque a une syntaxe terrible. C'est peut-être pour cela que je n'ai pas vu une telle chose en direct.
Exceptions en Java
Passons à Java et comment tout cela a fonctionné depuis le début.

Lors de la conception d'une langue, deux types d'exceptions ont été prévus:
- vérifié - vérifié;
- non cochée - non cochée.
À quoi servent les exceptions vérifiées? Théoriquement, ils sont nécessaires pour que les gens doivent vérifier les erreurs. C'est-à-dire si une certaine exception vérifiée est possible, elle doit être vérifiée ultérieurement. Théoriquement, cette approche aurait dû conduire à l'absence d'erreurs non traitées et à une meilleure qualité du code. Mais en pratique, ce n'est pas le cas. Je pense que tout le monde au moins une fois dans sa vie a vu un bloc de capture vide.
Pourquoi cela peut-il être mauvais?
Voici un exemple classique directement de la documentation Kotlin - une interface du JDK implémenté dans StringBuilder:
Appendable append(CharSequence csq) throws IOException; try { log.append(message) } catch (IOException e) { //Must be safe }
Je suis sûr que vous avez rencontré beaucoup de code enveloppé dans try-catch, où catch est un bloc vide, car une telle situation n'aurait tout simplement pas dû se produire, selon le développeur. Dans de nombreux cas, la gestion des exceptions vérifiées est implémentée de la manière suivante: elles lèvent simplement une RuntimeException et l'attrapent quelque part au-dessus (ou ne l'attrapent pas ...).
try { // do something } catch (IOException e) { throw new RuntimeException(e); // - ...
Ce qui est possible à Kotlin
En termes d'exceptions, le compilateur Kotlin est différent en ce que:
1. Ne fait pas de distinction entre les exceptions cochées et non cochées. Toutes les exceptions ne sont décochées que et vous décidez vous-même de les intercepter et de les traiter.
2. Try peut être utilisé comme expression - vous pouvez exécuter le bloc try et en renvoyer la dernière ligne ou renvoyer la dernière ligne du bloc catch.
val value = try {Integer.parseInt(“lol”)} catch(e: NumberFormanException) { 4 } //
3. Vous pouvez également utiliser une construction similaire lorsque vous faites référence à un objet, qui peut être annulable:
val s = obj.money ?: throw IllegalArgumentException(“ , ”)
Compatibilité Java
Le code Kotlin peut être utilisé en Java et vice versa. Comment gérer les exceptions?
- Les exceptions vérifiées de Java dans Kotlin ne peuvent être ni vérifiées ni déclarées (car il n'y a pas d'exceptions vérifiées dans Kotlin).
- Les exceptions vérifiées possibles de Kotlin (par exemple, celles qui sont originaires de Java) ne doivent pas être vérifiées en Java.
- S'il est nécessaire de vérifier, l'exception peut être rendue vérifiable à l'aide de l'annotation @Throws dans la méthode (il est nécessaire d'indiquer quelles exceptions cette méthode peut lever). L'annotation ci-dessus est uniquement pour la compatibilité Java. Mais en pratique, beaucoup de gens l'utilisent pour déclarer qu'une telle méthode, en principe, peut lever une sorte d'exception.
Alternative au bloc try-catch
Le bloc try-catch présente un inconvénient important. Lorsqu'il apparaît, une partie de la logique métier est transférée à l'intérieur du crochet, et cela peut se produire dans l'une des nombreuses méthodes ci-dessus. Lorsque la logique métier est répartie sur des blocs ou sur toute la chaîne d'appels, il est plus difficile de comprendre le fonctionnement de l'application. Et les blocs de lisibilité eux-mêmes n'ajoutent pas de code.
try { HttpService.SendNotification(endpointUrl); MarkNotificationAsSent(); } catch (e: UnableToConnectToServerException) { MarkNotificationAsNotSent(); }
Quelles sont les alternatives?
Une option nous offre une approche fonctionnelle de la gestion des exceptions. Une implémentation similaire ressemble à ceci:
val result: Try<Result> = Try{HttpService.SendNotification(endpointUrl)} when(result) { is Success -> MarkNotificationAsSent() is Failure -> MarkNotificationAsNotSent() }
Nous avons la possibilité d'utiliser la monade Try. En substance, il s'agit d'un conteneur qui stocke une certaine valeur. flatMap est une méthode de travail avec ce conteneur, qui, avec la valeur actuelle, peut prendre une fonction et, encore une fois, retourner une monade.
Dans ce cas, l'appel est encapsulé dans la monade Try (nous renvoyons Try). Il peut être traité en un seul endroit - là où nous en avons besoin. Si la sortie a une valeur, nous effectuons les actions suivantes avec elle, si une exception est levée, nous la traitons à la toute fin de la chaîne.
Gestion des exceptions fonctionnelles
Où puis-je obtenir Try?
Tout d'abord, il existe plusieurs implémentations communautaires des classes Try et Either. Vous pouvez les prendre ou même écrire une implémentation vous-même. Dans l'un des projets de «combat», nous avons utilisé l'implémentation Try self-made - nous avons réussi avec une classe et avons fait un excellent travail.
Deuxièmement, il y a la bibliothèque Arrow, qui en principe ajoute beaucoup de fonctionnalités à Kotlin. Naturellement, il y a Try and Either.
Eh bien, en plus, la classe Result est apparue dans Kotlin 1.3, dont je parlerai plus en détail plus tard.
Essayez d'utiliser la bibliothèque Arrow comme exemple
La bibliothèque Arrow nous donne une classe Try. En fait, cela peut être dans deux états: Succès ou échec:
- Le succès d'un retrait réussi conservera notre valeur,
- L'échec stocke une exception qui s'est produite lors de l'exécution d'un bloc de code.
L'appel est le suivant. Naturellement, il est emballé dans un try-catch régulier, mais cela se produira quelque part dans notre code.
sealed class Try<out A> { data class Success<out A>(val value: A) : Try<A>() data class Failure(val e: Throwable) : Try<Nothing>() companion object { operator fun <A> invoke(body: () -> A): Try<A> { return try { Success(body()) } catch (e: Exception) { Failure(e) } } }
La même classe devrait implémenter la méthode flatMap, qui vous permet de passer une fonction et de renvoyer notre try monad:
inline fun <B> map(f: (A) -> B): Try<B> = flatMap { Success(f(it)) } inline fun <B> flatMap(f: (A) -> TryOf<B>): Try<B> = when (this) { is Failure -> this is Success -> f(value) }
À quoi ça sert? Afin de ne pas traiter les erreurs pour chacun des résultats lorsque nous en avons plusieurs. Par exemple, nous avons obtenu plusieurs valeurs de différents services et souhaitons les combiner. En fait, nous pouvons avoir deux situations: soit nous les avons reçues et combinées avec succès, soit quelque chose est tombé. Par conséquent, nous pouvons effectuer les opérations suivantes:
val result1: Try<Int> = Try { 11 } val result2: Try<Int> = Try { 4 } val sum = result1.flatMap { one -> result2.map { two -> one + two } } println(sum) //Success(value=15)
Si les deux appels ont réussi et que nous avons obtenu les valeurs, nous exécutons la fonction. S'ils échouent, l'échec revient avec une exception.
Voici à quoi cela ressemble en cas de chute:
val result1: Try<Int> = Try { 11 } val result2: Try<Int> = Try { throw RuntimeException(“Oh no!”) } val sum = result1.flatMap { one -> result2.map { two -> one + two } } println(sum) //Failure(exception=java.lang.RuntimeException: Oh no!
Nous avons utilisé la même fonction, mais la sortie est un échec d'une RuntimeException.
De plus, la bibliothèque Arrow vous permet d'utiliser des constructions qui sont en fait du sucre syntaxique, en particulier la liaison. Tout de même peut être réécrit via un flatMap série, mais la liaison vous permet de le rendre lisible.
val result1: Try<Int> = Try { 11 } val result2: Try<Int> = Try { 4 } val result3: Try<Int> = Try { throw RuntimeException(“Oh no, again!”) } val sum = binding { val (one) = result1 val (two) = result2 val (three) = result3 one + two + three } println(sum) //Failure(exception=java.lang.RuntimeException: Oh no, again!
Étant donné que l'un des résultats est tombé, nous obtenons une erreur sur la sortie.
Une monade similaire peut être utilisée pour les appels asynchrones. Par exemple, voici deux fonctions qui s'exécutent de manière asynchrone. Nous combinons leurs résultats de la même manière, sans vérifier séparément leur statut:
fun funA(): Try<Int> { return Try { 1 } } fun funB(): Try<Int> { Thread.sleep(3000L) return Try { 2 } } val a = GlobalScope.async { funA() } val b = GlobalScope.async { funB() } val sum = runBlocking { a.await().flatMap { one -> b.await().map {two -> one + two } } }
Et voici un exemple plus «de combat». Nous avons une demande au serveur, nous la traitons, en obtenons le corps et essayons de la mapper à notre classe, à partir de laquelle nous retournons déjà des données.
fun makeRequest(request: Request): Try<List<ResponseData>> = Try { httpClient.newCall(request).execute() } .map { it.body() } .flatMap { Try { ObjectMapper().readValue(it, ParsedResponse::class.java) } } .map { it.data } fun main(args : Array<String>) { val response = makeRequest(RequestBody(args)) when(response) { is Try.Success -> response.data.toString() is Try.Failure -> response.exception.message } }
Try-catch rendrait ce bloc beaucoup moins lisible. Et dans ce cas, nous obtenons response.data à la sortie, que nous pouvons traiter en fonction du résultat.
Résultat de Kotlin 1.3
Kotlin 1.3 a introduit la classe Result. En fait, c'est quelque chose de similaire à Try, mais avec un certain nombre de limitations. Il est à l'origine destiné à être utilisé pour diverses opérations asynchrones.
val result: Result<VeryImportantData> = Result.runCatching { makeRequest() } .mapCatching { parseResponse(it) } .mapCatching { prepareData(it) } result.fold{ { data -> println(“We have $data”) }, exception -> println(“There is no any data, but it's your exception $exception”) } )
Sinon, cette classe est actuellement expérimentale. Les développeurs de langage peuvent changer sa signature, son comportement ou le supprimer complètement, donc pour le moment il est interdit de l'utiliser comme valeur de retour à partir de méthodes ou d'une variable. Cependant, il peut être utilisé comme variable locale (privée). C'est-à-dire en fait, il peut être utilisé comme un essai de l'exemple.
Conclusions
Conclusions que je me suis fait:
- la gestion des erreurs fonctionnelles dans Kotlin est simple et pratique;
- personne ne prend la peine de les traiter par le biais du try-catch dans le style classique (à la fois cela et ce qui a droit à la vie; à la fois cela et cela sont pratiques);
- l'absence d'exceptions vérifiées ne signifie pas que les erreurs ne peuvent pas être traitées;
- des exceptions non frappées sur la production entraînent de tristes conséquences.
Auteur de l'article: Alexey Shafranov, chef du groupe de travail (Java), Maxilect
PS Nous publions nos articles sur plusieurs sites du Runet. Abonnez-vous à nos pages sur les
chaînes VK ,
FB ou
Telegram pour découvrir toutes nos publications et autres actualités Maxilect.