Programmation orientée protocole, partie 2

Dans la suite du sujet, nous examinerons les types de protocoles et le code généralisé.


Les questions suivantes seront examinées en cours de route:


  • implémentation du polymorphisme sans types d'héritage et de référence
  • comment les objets de type protocole sont stockés et utilisés
  • comment la répartition des méthodes fonctionne avec eux

Types de protocoles


Implémentation du polymorphisme sans types d'héritage et de référence:


protocol Drawable { func draw() } struct Point: Drawable { var x, y: Int func draw() { ... } } struct Line: Drawable { var x1, x2, y1, y2: Int func draw() { ... } } var drawbles = [Drawable]() for d in drawbles { d.draw() } 

  1. Désignons le protocole Drawable, qui a une méthode de dessin.
  2. Nous implémentons ce protocole pour Point and Line - maintenant vous pouvez les gérer comme avec Drawable (appelez la méthode draw)

Nous avons toujours un code polymorphe. L'élément d du tableau drawables a une interface, qui est indiquée dans le protocole Drawable, mais a différentes implémentations de ses méthodes, qui sont indiquées dans Line et Point.


Le principe principal (ad-hoc) du polymorphisme: "Interface commune - nombreuses implémentations"

Répartition dynamique sans table virtuelle


Rappelons que la définition de l'implémentation correcte de la méthode lors de l'utilisation de classes (types de référence) est obtenue via Dynamic Sending et une table virtuelle. Chaque type de classe possède une table virtuelle; il stocke les implémentations de ses méthodes. La répartition dynamique définit l'implémentation de la méthode pour un type en examinant sa table virtuelle. Tout cela est nécessaire en raison de la possibilité d'héritage et de remplacement des méthodes.


Dans le cas des structures, l'héritage, ainsi que la redéfinition des méthodes, est impossible. Ensuite, à première vue, il n'est pas nécessaire d'avoir une table virtuelle, mais comment fonctionnera la répartition dynamique? Comment un programme peut-il comprendre quelle méthode sera appelée dans d.draw ()?


Il est à noter que le nombre d'implémentations de cette méthode est égal au nombre de types conformes au protocole Drawable.

Tableau des témoins du protocole


est la réponse à cette question. Chaque type qui implémente un protocole a cette table. Comme une table virtuelle pour les classes, elle stocke les implémentations des méthodes requises par le protocole.


ci-après, la table des témoins du protocole sera appelée «table des méthodes de protocole»

Ok, maintenant nous savons où chercher les implémentations de méthodes. Il ne reste que deux questions:


  1. Comment trouver la table de méthode de protocole appropriée pour un objet qui a implémenté ce protocole? Comment dans notre cas trouver cette table pour l'élément d du tableau drawables?
  2. Les éléments du tableau doivent être de la même taille (c'est l'essence du tableau). Alors, comment un tableau dessinable peut-il répondre à cette exigence s'il peut y stocker à la fois une ligne et un point, et qu'ils ont des tailles différentes?

 MemoryLayout.size(ofValue: Line(...)) // 32 bits MemoryLayout.size(ofValue: Point(...)) // 16 bits 

Conteneur existentiel


Pour résoudre ces deux problèmes, Swift utilise un schéma de stockage spécial pour les instances de types de protocole appelés conteneur existentiel. Cela ressemble à ceci:



Il faut 5 mots machine (dans le système x64 bits 5 * 8 = 40 bits). Il est divisé en trois parties:


tampon de valeur - espace pour l'instance elle-même
vwt - pointeur vers la table des témoins de valeur
pwt - pointeur vers la table des témoins du protocole


Considérez les trois parties plus en détail:


Tampon de contenu


Seulement trois mots machine pour stocker une instance. Si l'instance peut tenir dans le tampon de contenu, elle y est stockée. Si l'instance a plus de 3 mots machine, elle ne rentrera pas dans le tampon et le programme est obligé d'allouer de la mémoire sur le tas, d'y placer l'instance et de placer un pointeur sur cette mémoire dans le tampon de contenu. Prenons un exemple:


 let point: Drawable = Point(...) 

Point () occupe 2 mots machine et s'intègre parfaitement dans le tampon de valeur - le programme le mettra là:



 let line: Drawable = Line(...) 

La ligne () occupe 4 mots machine et ne peut pas tenir dans un tampon de valeur - le programme va lui allouer de la mémoire pour le tas et ajouter un pointeur à cette mémoire dans le tampon de valeur:



ptr pointe vers une instance de Line () placée sur le tas:



Tableau du cycle de vie


En plus de la table de méthode de protocole, chaque table qui a le protocole a cette table. Il contient une implémentation de quatre méthodes: allouer, copier, détruire, désallouer. Ces méthodes contrôlent l'ensemble du cycle de vie d'un objet. Prenons un exemple:


  1. Lors de la création d'un objet (Point (...) comme Drawable), la méthode d'allocation de T.Zh. cet objet. La méthode d'allocation décide où le contenu de l'objet doit être placé (dans le tampon de valeur ou sur le tas), et s'il doit être placé sur le tas, il allouera la quantité de mémoire requise
  2. La méthode de copie mettra le contenu de l'objet à l'endroit approprié.
  3. Après avoir terminé le travail avec l'objet, la méthode de destruction sera appelée, ce qui réduira tous les décomptes de liens, le cas échéant
  4. Après la destruction, la méthode deallocate sera appelée, ce qui libérera la mémoire allouée sur le tas, le cas échéant

Tableau des méthodes de protocole


Comme décrit ci-dessus, il contient des implémentations des méthodes requises par le protocole pour le type auquel cette table est liée.


Conteneur existentiel - Réponses


Ainsi, nous avons répondu à deux questions posées:


  1. La table des méthodes de protocole est stockée dans le conteneur Existentiel de cet objet et peut être facilement obtenue à partir de celui-ci
  2. Si le type d'élément du tableau est un protocole, alors tout élément de ce tableau prend une valeur fixe de 5 mots machine - c'est exactement ce qui est nécessaire pour un conteneur existentiel. Si le contenu de l'élément ne peut pas être placé dans le tampon de valeur, il sera alors placé sur le tas. Si c'est le cas, alors tout le contenu sera placé dans le tampon de valeurs. Dans tous les cas, nous obtenons que la taille de l'objet avec le type de protocole est de 5 mots machine (40 bits), et il s'ensuit que tous les éléments du tableau auront la même taille.

 let line: Drawable = Line(...) MemoryLayout.size(ofValue: line) // 40 bits let drawables: [Drawable] = [Line(...), Point(...), Line(...)] MemoryLayout.size(ofValue: drawables._content) // 120 bits 

Conteneur existentiel - Exemple


Considérez le comportement d'un conteneur existentiel dans ce code:


 func drawACopy(local: Drawable) { local.draw() } let val: Drawable = Line(...) drawACopy(val) 

Un conteneur existentiel peut être représenté comme ceci:


 struct ExistContDrawable { var valueBuffer: (Int, Int, Int) var vwt: ValueWitnessTable var pwt: ProtocolWitnessTable } 

Pseudo code


Dans les coulisses, la fonction drawACopy prend ExistContDrawable:


 func drawACopy(val: ExistContDrawable) { ... } 

Le paramètre de fonction est créé manuellement: créez un conteneur, remplissez ses champs à partir de l'argument reçu:


 func drawACopy(val: ExistContDrawable) { var local = ExistContDrawable() let vwt = val.vwt let pwt = val.pwt local.type = type local.pwt = pwt ... } 

Nous décidons où le contenu sera stocké (dans le tampon ou le tas). Nous appelons vwt.allocate et vwt.copy pour remplir le contenu local avec val:


 func drawACopy(val: ExistContDrawable) { ... vwt.allocateBufferAndCopy(&local, val) } 

Nous appelons la méthode draw et lui passons un pointeur vers self (la méthode projectBuffer décidera où se trouve self - dans le tampon ou sur le tas - et retournera le bon pointeur):


 func drawACopy(val: ExistContDrawable) { ... pwt.draw(vwt.projectBuffer(&local)) } 

Nous finissons de travailler avec les locaux. Nous nettoyons tous les liens de la hanche du local. La fonction renvoie une valeur - nous effaçons toute la mémoire allouée à drawACopy (frame de pile):


 func drawACopy(val: ExistContDrawable) { ... vwt.destructAndDeallocateBuffer(&local) } 

Conteneur existentiel - Objectif


L'utilisation d'un conteneur existentiel nécessite beaucoup de travail - l'exemple ci-dessus l'a confirmé - mais pourquoi est-il même nécessaire, quel est le but? L'objectif est d'implémenter le polymorphisme à l'aide de protocoles et des types qui les implémentent. Dans la POO, nous utilisons des classes abstraites et en héritons en remplaçant les méthodes. Dans EPP, nous utilisons des protocoles et implémentons leurs exigences. Encore une fois, même avec des protocoles, la mise en œuvre du polymorphisme est un travail important et énergivore. Par conséquent, pour éviter un travail "inutile", vous devez comprendre quand le polymorphisme est nécessaire et quand ce n'est pas le cas.


Le polymorphisme dans la mise en œuvre d'EPP l'emporte dans le fait que, en utilisant des structures, nous n'avons pas besoin d'un comptage de référence constant, il n'y a pas d'héritage de classe. Oui, tout est très similaire, les classes utilisent une table virtuelle pour déterminer l'implémentation d'une méthode, les protocoles utilisent la méthode protocolaire. Les classes sont placées sur le tas, des structures peuvent également parfois y être placées. Mais le problème est que n'importe quelle classe de pointeurs peut être dirigée vers la classe placée sur le tas, et le comptage des références est nécessaire, mais un seul pointeur vers les structures placées sur le tas et il est stocké dans un conteneur existentiel.


En fait, il est important de noter qu'une structure qui est stockée dans un conteneur existentiel conservera la sémantique des types de valeur, qu'elle soit placée sur la pile ou sur le tas. La table du cycle de vie est responsable de la préservation de la sémantique car elle décrit les méthodes qui déterminent la sémantique.


Conteneur existentiel - Propriétés stockées


Nous avons examiné comment une variable de type protocole est passée et utilisée par une fonction. Voyons comment ces variables sont stockées:


 struct Pair { init(_ f: Drawable, _ s: Drawable) { first = f second = s } var first: Drawable var second: Drawable } var pair = Pair(Line(), Point()) 

Comment ces deux structures Drawable sont-elles stockées à l'intérieur de la structure Pair? Quel est le contenu de la paire? Il se compose de deux conteneurs existentiels - l'un pour le premier, l'autre pour le second. La ligne ne peut pas tenir dans le tampon et est placée sur le tas. Point d'ajustement dans le tampon. Il permet également à la structure Pair de stocker des objets de différentes tailles:


 pair.second = Line() 

Maintenant, le contenu de second est également placé sur le tas, car il ne tient pas sur le tampon. Considérez ce que cela peut conduire à:


 let aLine = Line(...) let pair = Pair(aLine, aLine) let copy = pair 

Après avoir exécuté ce code, le programme recevra l'état de la mémoire suivant:



Nous avons 4 allocations de mémoire sur le tas, ce qui n'est pas bon. Essayons de corriger:


  1. Créer une ligne de classe analogique

 class LineStorage: Drawable { var x1, y1, x2, y2: Double func draw() {} } 

  1. Nous l'utilisons en paire

 let lineStorage = LineStorage(...) let pair = Pair(lineStorage, lineStorage) let copy = pair 

Nous obtenons un placement sur le tas et 4 pointeurs:



Mais nous avons affaire à un comportement référentiel. Changer copy.first affectera pair.first (le même pour .second), ce qui n'est pas toujours ce que nous voulons.


Stockage indirect et copie en cas de changement (copie sur écriture)


Avant cela, il a été mentionné que String est une structure de copie sur écriture (stocke son contenu sur le tas et le copie lorsqu'il change). Considérez comment vous pouvez implémenter votre structure, qui est copiée lors du changement:


 struct BetterLine: Drawable { private var storage: LineStorage init() { storage = LineStorage((0, 0), (10, 10)) } func draw() -> Double { ... } mutating func move() { if !isKnownUniquelyReferenced(&storage) { storage = LineStorage(self.storage) } // storage editing } } 

  1. BetterLine stocke toutes les propriétés dans le stockage, et le stockage est une classe et est stocké sur le tas.
  2. Le stockage ne peut être modifié qu'en utilisant la méthode move. Dans ce document, nous vérifions qu'un seul pointeur pointe vers le stockage. S'il y a plus de pointeurs, alors ce BetterLine partage le stockage avec quelqu'un, et pour que BetterLine se comporte complètement comme une structure, le stockage doit être individuel - nous en faisons une copie et nous l'utiliserons à l'avenir.

Voyons comment cela fonctionne en mémoire:


 let aLine = BetterLine() let pair = Pair(aLine, aLine) let copy = pair copy.second.x1 = 3.0 

À la suite de l'exécution de ce code, nous obtenons:



En d'autres termes, nous avons deux instances de Pair qui partagent le même stockage: LineStorage. Lors du changement de stockage dans l'un de ses utilisateurs (premier / second), une copie de stockage distincte pour cet utilisateur sera créée afin que sa modification n'affecte pas les autres. Cela résout le problème de violation de la sémantique des types de valeur de l'exemple précédent.


Types de protocoles - Résumé


  1. Petites valeurs . Si nous travaillons avec des objets qui prennent peu de mémoire et peuvent être placés dans le tampon d'un conteneur existentiel, alors:

  • il n'y aura pas de placement sur le tas
  • pas de référence
  • polymorphisme (envoi dynamique) à l'aide d'une table de protocole

  1. Une grande valeur. Si nous travaillons avec des objets qui ne rentrent pas dans le tampon, alors:

  • placement de tas
  • comptage des références si les objets contiennent des liens.

Les mécanismes d'utilisation de la réécriture pour le changement et le stockage indirect ont été démontrés et peuvent améliorer considérablement la situation avec le comptage des références dans le cas d'un grand nombre d'entre eux.

Nous avons constaté que les types de protocoles, comme les classes, sont capables de réaliser le polymorphisme. Cela se produit en stockant dans un conteneur existentiel et en utilisant des tables de protocole - tables de cycle de vie et tables de méthode de protocole.

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


All Articles