Introduction à la programmation contextuelle de Kotlin

Ceci est une traduction d' une introduction à la programmation contextuelle dans Kotlin

Dans cet article, je vais essayer de décrire un nouveau phénomène qui est apparu comme un sous-produit du développement rapide du langage Kotlin. Il s'agit d'une nouvelle approche de la conception de l'architecture des applications et des bibliothèques, que j'appellerai la programmation contextuelle.

Quelques mots sur les autorisations de fonction


Comme on le sait, il existe trois principaux paradigmes de programmation ( note de Pedant : il existe d'autres paradigmes):

  • Programmation procédurale
  • Programmation orientée objet
  • Programmation fonctionnelle

Toutes ces approches fonctionnent avec les fonctions d'une manière ou d'une autre. Examinons cela du point de vue de la résolution des fonctions, ou de l'ordonnancement de leurs appels (c'est-à-dire du choix de la fonction à utiliser à cet endroit). La programmation procédurale se caractérise par l'utilisation de fonctions globales et leur résolution statique en fonction du nom de la fonction et des types d'arguments. Bien entendu, les types ne peuvent être utilisés que dans le cas de langues typées statiquement. Par exemple, en Python, les fonctions sont appelées par leur nom et si les arguments sont incorrects, une exception est levée lors de l'exécution pendant l'exécution du programme. La résolution des fonctions dans les langues avec une approche procédurale est basée uniquement sur le nom de la procédure / fonction et de ses paramètres, et dans la plupart des cas, elle se fait de manière statique.

Un style de programmation orienté objet limite la portée des fonctions. Les fonctions ne sont pas globales, elles font plutôt partie de classes et ne peuvent être appelées que sur une instance de la classe correspondante ( note de Pedant : certains langages procéduraux classiques ont un système modulaire et, par conséquent, une portée; langage procédural! = C).

Bien sûr, nous pouvons toujours remplacer une fonction membre d'une classe par une fonction globale avec un argument supplémentaire du type de l'objet appelé, mais d'un point de vue syntaxique, la différence est assez significative. Par exemple, dans ce cas, les méthodes sont regroupées dans la classe à laquelle elles se réfèrent, et par conséquent, il est plus clairement visible quel type de comportement les objets de ce type fournissent.

Bien sûr, l'encapsulation est la plus importante ici, car certains champs d'une classe ou son comportement peuvent être privés et accessibles uniquement aux membres de cette classe (vous ne pouvez pas fournir cela dans une approche purement procédurale), et le polymorphisme, grâce auquel la méthode réellement utilisée est déterminée non seulement en fonction du nom , mais également en fonction du type d'objet à partir duquel il est appelé. La distribution d'un appel de méthode dans une approche orientée objet dépend du type d'objet défini dans l'exécution, du nom de la méthode et du type d'arguments au stade de la compilation.

Une approche fonctionnelle n'apporte rien de fondamentalement nouveau en termes de résolution de fonction. Typiquement, les langages orientés fonctions ont de meilleures règles pour distinguer les zones de visibilité ( note pédante : encore une fois, C n'est pas tous les langages procéduraux, il y a ceux dans lesquels les zones de visibilité sont bien délimitées) qui permettent un contrôle plus rigoureux de la visibilité des fonctions basées sur le système modules, mais à part cela, la résolution est effectuée au moment de la compilation en fonction du type d'arguments.

Qu'est-ce que c'est?


Dans le cas de l'approche objet, lors de l'appel d'une méthode sur un objet, nous avons ses arguments, mais en plus nous avons un paramètre explicite (dans le cas de Python) ou implicite représentant une instance de la classe appelée (ci-après tous les exemples sont écrits en Kotlin):

class A{ fun doSomething(){ println("    $this") } } 

Les classes imbriquées et les fermetures compliquent un peu les choses:

 interface B{ fun doBSomething() } class A{ fun doASomething(){ val b = object: B{ override fun doBSomething(){ println("    $this  ${this@A}") } } b.doBSomething() } } 

Dans ce cas, il y en a deux implicitement pour la fonction doBSomething - l'un correspond à une instance de classe B , et l'autre résulte de la fermeture de l'instance A. La même chose se produit dans le cas beaucoup plus courant de fermeture lambda. Il est important de noter que dans ce cas, cela fonctionne non seulement comme paramètre implicite, mais aussi comme portée ou contexte pour toutes les fonctions et tous les objets appelés dans la portée lexicale. Ainsi, la méthode doBSomething a réellement accès à tous les membres de la classe A , publics ou privés, ainsi qu'aux membres de B lui-même.

Et voici Kotlin


Kotlin nous offre un tout nouveau «jouet» - des fonctions d'extension . ( Note de Pedant : En fait, ils ne sont pas si nouveaux, ils existent aussi en C #). Vous pouvez définir une fonction comme A.doASomething () n'importe où dans le programme, pas seulement à l'intérieur de A. A l'intérieur de cette fonction, nous avons un paramètre implicite, appelé récepteur, pointant vers l'instance A sur laquelle la méthode est appelée:

 class A fun A.doASomthing(){ println(" -   $this") } fun main(){ val a = A() a.doASomthing() } 

Les fonctions d'extension n'ont pas accès aux membres privés de leur destinataire, donc l'encapsulation n'est pas violée.

La prochaine chose importante que Kotlin a est des blocs de code avec des récepteurs. Vous pouvez exécuter un bloc de code arbitraire en utilisant quelque chose comme destinataire:

 class A{ fun doInternalSomething(){} } fun A.doASomthing(){} fun main(){ val a = A() with(a){ doInternalSomething() doASomthing() } } 

Dans cet exemple, les deux fonctions peuvent être appelées sans « a » supplémentaire . Au début, car la fonction with place tout le code du bloc suivant dans le contexte de a. Cela signifie que toutes les fonctions de ce bloc sont appelées comme si elles étaient appelées sur l'objet (explicitement transmis) a .

La dernière étape à ce stade de la programmation contextuelle est la possibilité de déclarer des extensions en tant que membres d'une classe. Dans ce cas, la fonction d'extension est définie dans une autre classe, comme ceci:

 class B class A{ fun B.doBSomething(){} } fun main(){ val a = A() val b = B() with(a){ b.doBSomething() //   } b.doBSomething() //   } 

Il est important qu'ici B obtienne un nouveau comportement, mais uniquement lorsqu'il se trouve dans un contexte lexical spécifique. Une fonction d'extension est un membre régulier de la classe A. Cela signifie que la résolution de la fonction se fait statiquement en fonction du contexte dans lequel elle est appelée, mais la mise en œuvre réelle est déterminée par l'instance de A passée en tant que contexte. La fonction peut même interagir avec l'état de l'objet a .

Envoi contextuel


Au début de l'article, nous avons discuté de différentes approches pour répartir les appels de fonction, et cela a été fait pour une raison. Le fait est que les fonctions d'extension de Kotlin vous permettent de travailler avec la répartition d'une nouvelle manière. Maintenant, la décision concernant la fonction particulière à utiliser est basée non seulement sur le type de ses paramètres, mais également sur le contexte lexical de son appel. Autrement dit, la même expression dans différents contextes peut avoir des significations différentes. Bien sûr, rien ne change du point de vue de l'implémentation, et nous avons toujours un objet récepteur explicite qui définit la répartition pour ses méthodes et extensions décrites dans le corps de la classe elle-même (extensions membres) - mais du point de vue syntaxique, c'est une approche différente .

Examinons en quoi l'approche orientée contexte diffère de l'approche orientée objet classique, en utilisant le problème classique des opérations arithmétiques sur les nombres en Java comme exemple. La classe Number en Java et Kotlin est le parent de tous les nombres, mais contrairement aux nombres spécialisés comme Double, elle ne définit pas ses opérations mathématiques. Vous ne pouvez donc pas écrire, par exemple, comme ceci:

 val n: Number = 1.0 n + 1.0 //  `plus`     `Number` 

La raison ici est qu'il n'est pas possible de définir de manière cohérente les opérations arithmétiques pour tous les types numériques. Par exemple, la division entière est différente de la division en virgule flottante. Dans certains cas particuliers, l'utilisateur sait quel type d'opération est nécessaire, mais généralement cela n'a aucun sens de définir de telles choses globalement. Une solution orientée objet (et, en fait, fonctionnelle) serait de définir un nouveau type d'héritier de la classe Number , les opérations nécessaires et de l'utiliser si nécessaire (dans Kotlin 1.3, vous pouvez utiliser des classes inline). Définissons plutôt un contexte avec ces opérations et appliquons-le localement:

 interface NumberOperations{ operator fun Number.plus(other: Number) : Number operator fun Number.minus(other: Number) : Number operator fun Number.times(other: Number) : Number operator fun Number.div(other: Number) : Number } object DoubleOperations: NumberOperations{ override fun Number.plus(other: Number) = this.toDouble() + other.toDouble() override fun Number.minus(other: Number) = this.toDouble() - other.toDouble() override fun Number.times(other: Number) = this.toDouble() * other.toDouble() override fun Number.div(other: Number) = this.toDouble() / other.toDouble() } fun main(){ val n1: Number = 1.0 val n2: Number = 2 val res = with(DoubleOperations){ (n1 + n2)/2 } println(res) } 

Dans cet exemple, le calcul de res est effectué dans un contexte qui définit des opérations supplémentaires. Il n'est pas nécessaire de définir un contexte localement; il peut au contraire être transmis implicitement en tant que récepteur d'une fonction. Par exemple, vous pouvez faire ceci:

 fun NumberOperations.calculate(n1: Number, n2: Number) = (n1 + n2)/2 val res = DoubleOperations.calculate(n1, n2) 

Cela signifie que la logique des opérations dans le contexte est complètement distincte de la mise en œuvre de ce contexte et peut être écrite dans une autre partie du programme ou même dans un autre module. Dans cet exemple simple, un contexte est un singleton sans état, mais des contextes d'état peuvent également être utilisés.

Il convient également de rappeler que les contextes peuvent être imbriqués:

 with(a){ with(b){ doSomething() } } 

Cela donne l'effet de combiner le comportement des deux classes, cependant, cette fonctionnalité est difficile à contrôler aujourd'hui en raison du manque d'extensions avec plusieurs destinataires ( KT-10468 ).

Le pouvoir des coroutines explicites


L'un des meilleurs exemples d'une approche contextuelle est utilisé dans la bibliothèque Kotlinx-coroutines. Une explication de l'idée peut être trouvée dans un article de Roman Elizarov. Ici, je veux juste souligner que CoroutineScope est un cas de conception orientée contexte avec un contexte avec état. CoroutineScope joue deux rôles:

  • Il contient le CoroutineContext , qui est nécessaire pour exécuter la coroutine et est hérité lors du lancement d'une nouvelle coroutine.
  • Il contient l'état de la coroutine parent, ce qui vous permet de l'annuler si la coroutine générée génère une erreur.

En outre, la concurrence structurée fournit un excellent exemple d'une architecture contextuelle:

 suspend fun CoroutineScope.doSomeWork(){} GlobalScope.launch{ launch{ delay(100) doSomeWork() } } 

Ici, doSomeWork est une fonction de contexte, mais définie en dehors de son contexte. Les méthodes de lancement créent deux contextes imbriqués qui sont équivalents aux zones lexicales des fonctions correspondantes (dans ce cas, les deux contextes sont du même type, donc le contexte interne masque le externe). Un bon point de départ pour apprendre les coroutines Kotlin est le guide officiel.

DSL


Il existe une large classe de tâches pour Kotlin, généralement appelées tâches de construction DSL (Domain Specific Language). Dans ce cas, DSL est compris comme un code fournissant un constructeur convivial d'une sorte de structure complexe. En fait, l'utilisation du terme DSL n'est pas entièrement correcte ici, car dans de tels cas, la syntaxe de base de Kotlin est simplement utilisée sans astuces spéciales - mais utilisons toujours ce terme courant.

Les constructeurs DSL sont orientés contextuellement dans la plupart des cas. Par exemple, si vous souhaitez créer un élément HTML, vous devez d'abord vérifier si cet élément particulier peut être ajouté à cet endroit. La bibliothèque kotlinx.html le fait en fournissant des extensions de classe basées sur le contexte qui représentent une balise spécifique. En fait, la bibliothèque entière se compose d'extensions de contexte pour les éléments DOM existants.

Un autre exemple est le générateur d'interface graphique TornadoFX . Le générateur entier du graphique de scène est organisé comme une séquence de générateurs de contexte imbriqués, où les blocs internes sont responsables de la construction des enfants pour les blocs externes ou du réglage des paramètres des parents. Voici un exemple tiré de la documentation officielle:

 override val root = gridPane{ tabpane { gridpaneConstraints { vhGrow = Priority.ALWAYS } tab("Report", HBox()) { label("Report goes here") } tab("Data", GridPane()) { tableview<Person> { items = persons column("ID", Person::idProperty) column("Name", Person::nameProperty) column("Birthday", Person::birthdayProperty) column("Age", Person::ageProperty).cellFormat { if (it < 18) { style = "-fx-background-color:#8b0000; -fx-text-fill:white" text = it.toString() } else { text = it.toString() } } } } } } 

Dans cet exemple, la région lexicale définit son contexte (ce qui est logique, car elle représente la section GUI et sa structure interne) et a accès aux contextes parents.

Et ensuite: plusieurs destinataires


La programmation contextuelle fournit aux développeurs de Kotlin de nombreux outils et ouvre une nouvelle façon de concevoir l'architecture d'application. Avons-nous besoin d'autre chose? Probablement oui.

À l'heure actuelle, le développement d'une approche contextuelle est limité par le fait que vous devez définir des extensions afin d'obtenir une sorte de comportement de classe limité au contexte. C'est bien quand il s'agit d'une classe personnalisée, mais que se passe-t-il si nous voulons la même chose pour une classe d'une bibliothèque? Ou si nous voulons créer une extension pour un comportement déjà restreint (par exemple, ajouter une sorte d'extension dans CoroutineScope)? Kotlin ne permet pas actuellement aux fonctions d'extension d'avoir plus d'un destinataire. Mais plusieurs destinataires pourraient être ajoutés à la langue sans rompre la compatibilité descendante. La possibilité d'utiliser plusieurs destinataires est en cours de discussion ( KT-10468 ) et sera émise en tant que demande KEEP (UPD: déjà émise ). Le problème (ou peut-être une puce) des contextes imbriqués est qu'ils vous permettent de couvrir la plupart, sinon la totalité, des options d'utilisation des classes de types (classes de types ), une autre très souhaitable des fonctionnalités proposées. Il est peu probable que ces deux fonctionnalités soient implémentées dans le langage en même temps.

Addition


Nous tenons à remercier notre amant à temps plein Pedant et Haskell Alexei Khudyakov pour ses commentaires sur le texte de l'article et les modifications apportées à mon utilisation plutôt libre des termes. Je remercie également Ilya Ryzhenkov pour ses précieux commentaires et la relecture de la version anglaise de l'article.

Auteur de l'article original: Alexander Nozik , chef adjoint du laboratoire de méthodes expérimentales de physique nucléaire de JetBrains Research .

Traduit par: Petr Klimay , chercheur au laboratoire de méthodes d'expérimentation en physique nucléaire de JetBrains Research

Source: https://habr.com/ru/post/fr477898/


All Articles