Structures immuables et hiérarchie de composition profonde

9

Je suis en train de développer une application GUI, travaillant fortement avec des graphiques - vous pouvez y penser comme un éditeur de vecteur, pour le plaisir de l'exemple. Il est très tentant de rendre toutes les structures de données immuables - afin que je puisse annuler / refaire, copier / coller et bien d'autres choses presque sans effort.

Pour des raisons de simplicité, je vais utiliser l'exemple suivant - l'application est utilisée pour éditer des formes polygonales, j'ai donc un objet "Polygone", qui est simplement une liste de points immuables:

Scene -> Polygon -> Point

Et donc je n'ai qu'une seule variable mutable dans mon programme - celle qui contient l'objet Scene actuel. Le problème que je rencontre commence lorsque j'essaie d'implémenter le glissement de points - en version mutable, je prends simplement un Pointobjet et commence à modifier ses coordonnées. En version immuable - je suis coincé. J'aurais pu stocker les index de Polygondans le courant Scene, l'index du point glissé Polygonet le remplacer à chaque fois. Mais cette approche n'est pas évolutive - lorsque les niveaux de composition atteignent 5 et plus, le passe-partout devient insupportable.

Je suis sûr que ce problème peut être résolu - après tout, il y a Haskell avec des structures complètement immuables et une monade IO. Mais je n'arrive pas à trouver comment.

Pouvez-vous me donner un indice?

Rogach
la source
@Job - c'est comme ça que ça fonctionne en ce moment, et ça me donne beaucoup de peine. Je recherche donc des approches alternatives - et l'immuabilité semble parfaite pour cette structure d'application, au moins avant d'y ajouter une interaction utilisateur :)
Rogach
@Rogach: Pouvez-vous expliquer plus sur votre code passe-partout?
rwong

Réponses:

9

J'aurais pu stocker des indices de polygone dans la scène actuelle, un index de point glissé dans Polygon et le remplacer à chaque fois. Mais cette approche n'est pas évolutive - lorsque les niveaux de composition atteignent 5 et plus, le passe-partout devient insupportable.

Vous avez absolument raison, cette approche n'est pas évolutive si vous ne pouvez pas contourner le passe-partout . Plus précisément, le passe-partout pour créer une toute nouvelle scène avec une minuscule sous-partie a changé. Cependant, de nombreux langages fonctionnels fournissent une construction pour gérer ce type de manipulation de structure imbriquée: les lentilles.

Un objectif est essentiellement un getter et un setter pour des données immuables. Un objectif se concentre sur une petite partie d'une plus grande structure. Étant donné un objectif, il y a deux choses que vous pouvez faire avec lui - vous pouvez afficher la petite partie d'une valeur de la structure plus grande, ou vous pouvez définir la petite partie d'une valeur d'une structure plus grande sur une nouvelle valeur. Par exemple, supposons que vous ayez un objectif qui se concentre sur le troisième élément d'une liste:

thirdItemLens :: Lens [a] a

Ce type signifie que la plus grande structure est une liste de choses, et la petite sous-partie est l'une de ces choses. Compte tenu de cet objectif, vous pouvez afficher et définir le troisième élément de la liste:

> view thirdItemLens [1, 2, 3, 4, 5]
3
> set thirdItemLens 100 [1, 2, 3, 4, 5]
[1, 2, 100, 4, 5]

La raison pour laquelle les lentilles sont utiles est qu'elles sont des valeurs représentant des getters et des setters, et que vous pouvez les abstraire de la même manière que vous pouvez utiliser d'autres valeurs. Vous pouvez créer des fonctions qui renvoient des objectifs, par exemple une listItemLensfonction qui prend un nombre net renvoie un objectif affichant le ne élément dans une liste. De plus, les lentilles peuvent être composées :

> firstLens = listItemLens 0
> thirdLens = listItemLens 2
> firstOfThirdLens = lensCompose firstLens thirdLens
> view firstOfThirdLens [[1, 2], [3, 4], [5, 6], [7, 8]]
5
> set firstOfThirdLens 100 [[1, 2], [3, 4], [5, 6], [7, 8]]
[[1, 2], [3, 4], [100, 6], [7, 8]]

Chaque objectif résume le comportement pour traverser un niveau de la structure de données. En les combinant, vous pouvez éliminer le passe-partout pour traverser plusieurs niveaux de structures complexes. Par exemple, en supposant que vous ayez un scenePolygonLens iqui affiche le ie polygone dans une scène et un polygonPointLens nqui affiche le nthpoint dans un polygone, vous pouvez créer un constructeur d'objectif pour se concentrer uniquement sur le point spécifique qui vous intéresse dans une scène entière, comme ceci:

scenePointLens i n = lensCompose (polygonPointLens n) (scenePolygonLens i)

Supposons maintenant qu'un utilisateur clique sur le point 3 du polygone 14 et le déplace de 10 pixels vers la droite. Vous pouvez mettre à jour votre scène comme suit:

lens = scenePointLens 14 3
point = view lens currentScene
newPoint = movePoint 10 0 point
newScene = set lens newPoint currentScene

Celui-ci contient joliment tout le passe-partout pour parcourir et mettre à jour une scène à l'intérieur lens, tout ce dont vous avez besoin, c'est de savoir ce que vous voulez changer de point. Vous pouvez en outre résumer cela avec une lensTransformfonction qui accepte un objectif, une cible et une fonction pour mettre à jour la vue de la cible à travers l'objectif:

lensTransform lens transformFunc target =
  current = view lens target
  new = transformFunc current
  set lens new target

Cela prend une fonction et la transforme en "mise à jour" sur une structure de données compliquée, en appliquant la fonction uniquement à la vue et en l'utilisant pour construire une nouvelle vue. Revenons donc au scénario de déplacement du 3e point du 14e polygone vers la droite de 10 pixels, qui peut être exprimé en termes de lensTransformceci:

lens = scenePointLens 14 3
moveRightTen point = movePoint 10 0 point
newScene = lensTransform lens moveRightTen currentScene

Et c'est tout ce dont vous avez besoin pour mettre à jour toute la scène. C'est une idée très puissante et fonctionne très bien lorsque vous avez de belles fonctions pour construire des lentilles affichant les éléments de vos données qui vous intéressent.

Cependant, ce sont des trucs assez excentriques actuellement, même dans la communauté de programmation fonctionnelle. Il est difficile de trouver un bon support de bibliothèque pour travailler avec des objectifs, et encore plus difficile d'expliquer comment ils fonctionnent et quels sont les avantages pour vos collègues. Adoptez cette approche avec un grain de sel.

Jack
la source
Excellente explication! Maintenant, je comprends ce que sont les lentilles!
Vincent Lecrubier
13

J'ai travaillé exactement sur le même problème (mais seulement avec 3 niveaux de composition). L'idée de base est de cloner, puis de modifier . Dans un style de programmation immuable, le clonage et la modification doivent se produire ensemble, ce qui devient un objet de commande .

Notez que dans un style de programmation modifiable, le clonage aurait été nécessaire de toute façon:

  • Pour autoriser l'annulation / la restauration
  • Le système d'affichage peut avoir besoin d'afficher simultanément les modèles "avant modification" et "pendant modification", superposés (sous forme de lignes fantômes), afin que l'utilisateur puisse voir les modifications.

Dans un style de programmation modifiable,

  • La structure existante est clonée en profondeur
  • Les modifications sont effectuées dans la copie clonée
  • Le moteur d'affichage est censé rendre l'ancienne structure en lignes fantômes et la structure clonée / modifiée en couleur.

Dans un style de programmation immuable,

  • Chaque action utilisateur qui entraîne une modification des données est mappée sur une séquence de "commandes".
  • Un objet de commande encapsule exactement la modification à appliquer et une référence à la structure d'origine.
    • Dans mon cas, mon objet de commande ne se souvient que de l'index de point à modifier et des nouvelles coordonnées. (c'est-à-dire très léger, car je ne suis pas strictement le style immuable.)
  • Lorsqu'un objet de commande est exécuté, il crée une copie profonde modifiée de la structure, rendant la modification permanente dans la nouvelle copie.
  • Au fur et à mesure que l'utilisateur effectue plus de modifications, plus d'objets de commande seront créés.
rwong
la source
1
Pourquoi faire une copie complète d'une structure de données immuable? Il vous suffit de copier la "colonne vertébrale" des références de l'objet modifié vers la racine et de conserver les références aux parties restantes de la structure d'origine.
Rétablir Monica
3

Les objets profondément immuables ont l'avantage que le clonage profond de quelque chose nécessite simplement la copie d'une référence. Ils ont l'inconvénient que même une petite modification d'un objet profondément imbriqué nécessite la construction d'une nouvelle instance de chaque objet dans lequel il est imbriqué. Les objets mutables ont l'avantage que changer un objet est facile - il suffit de le faire - mais le clonage en profondeur d'un objet nécessite la construction d'un nouvel objet qui contient un clone profond de chaque objet imbriqué. Pire, si l'on veut cloner un objet et effectuer une modification, cloner cet objet, effectuer une autre modification, etc., peu importe la taille des modifications, il faut conserver une copie de la hiérarchie entière pour chaque version enregistrée de la l'état de l'objet. Méchant.

Une approche qui pourrait être utile serait de définir un type abstrait "peut-être mutable" avec des types dérivés mutables et profondément immuables. Tous ces types auraient une AsImmutableméthode; appeler cette méthode sur une instance profondément immuable d'un objet retournerait simplement cette instance. L'appeler sur une instance mutable retournerait une instance profondément immuable dont les propriétés étaient des instantanés profondément immuables de leurs équivalents dans l'original. Les types immuables avec des équivalents mutables arboreraient une AsMutableméthode, qui construirait une instance mutable dont les propriétés correspondraient à celles de l'original.

Changer un objet imbriqué dans un objet profondément immuable nécessiterait d'abord de remplacer l'objet immuable externe par un objet mutable, puis de remplacer la propriété contenant la chose à changer par un objet mutable, etc., mais en apportant des modifications répétées au même aspect du l'objet global ne nécessiterait pas de créer des objets supplémentaires jusqu'à ce qu'une tentative ait été faite d'appeler AsImmutableun objet mutable (ce qui laisserait les objets mutables mutables, mais retournerait des objets immuables contenant les mêmes données).

En tant qu'optimisations simples mais significatives, chaque objet mutable peut contenir une référence en cache à un objet de son type immuable associé, et chaque type immuable doit mettre en cache sa GetHashCodevaleur. Lors de l'appel AsImmutableà un objet mutable, avant de renvoyer un nouvel objet immuable, vérifiez qu'il correspondrait à la référence mise en cache. Si tel est le cas, renvoyez la référence mise en cache (abandon du nouvel objet immuable). Sinon, mettez à jour la référence mise en cache pour contenir le nouvel objet et renvoyez-le. Dans ce cas, des appels répétés àAsImmutablesans aucune mutation intermédiaire donnera les mêmes références d'objet. Même si l'on n'économise pas le coût de construction des nouvelles instances, on évitera le coût de la mémoire de les conserver. En outre, les comparaisons d'égalité entre les objets immuables peuvent être considérablement accélérées si dans la plupart des cas les éléments comparés sont égaux en référence ou ont des codes de hachage différents.

supercat
la source