Comment modéliser une référence circulaire entre des objets immuables en C #?


24

Dans l'exemple de code suivant, nous avons une classe pour les objets immuables qui représente une pièce. Le nord, le sud, l'est et l'ouest représentent des sorties dans d'autres pièces.

public sealed class Room
{
    public Room(string name, Room northExit, Room southExit, Room eastExit, Room westExit)
    {
        this.Name = name;
        this.North = northExit;
        this.South = southExit;
        this.East = eastExit;
        this.West = westExit;
    }

    public string Name { get; }

    public Room North { get; }

    public Room South { get; }

    public Room East { get; }

    public Room West { get; }
}

On voit donc que cette classe est conçue avec une référence circulaire réflexive. Mais parce que la classe est immuable, je suis coincé avec un problème de «poulet ou d'oeuf». Je suis certain que les programmeurs fonctionnels expérimentés savent comment gérer cela. Comment le gérer en C #?

J'essaie de coder un jeu d'aventure basé sur du texte, mais en utilisant des principes de programmation fonctionnels juste pour le plaisir d'apprendre. Je suis coincé sur ce concept et je peux utiliser de l'aide !!! Merci.

MISE À JOUR:

Voici une implémentation fonctionnelle basée sur la réponse de Mike Nakis concernant l'initialisation paresseuse:

using System;

public sealed class Room
{
    private readonly Func<Room> north;
    private readonly Func<Room> south;
    private readonly Func<Room> east;
    private readonly Func<Room> west;

    public Room(
        string name, 
        Func<Room> northExit = null, 
        Func<Room> southExit = null, 
        Func<Room> eastExit = null, 
        Func<Room> westExit = null)
    {
        this.Name = name;

        var dummyDelegate = new Func<Room>(() => { return null; });

        this.north = northExit ?? dummyDelegate;
        this.south = southExit ?? dummyDelegate;
        this.east = eastExit ?? dummyDelegate;
        this.west = westExit ?? dummyDelegate;
    }

    public string Name { get; }

    public override string ToString()
    {
        return this.Name;
    }

    public Room North
    {
        get { return this.north(); }
    }

    public Room South
    {
        get { return this.south(); }
    }

    public Room East
    {
        get { return this.east(); }
    }

    public Room West
    {
        get { return this.west(); }
    }        

    public static void Main(string[] args)
    {
        Room kitchen = null;
        Room library = null;

        kitchen = new Room(
            name: "Kitchen",
            northExit: () => library
         );

        library = new Room(
            name: "Library",
            southExit: () => kitchen
         );

        Console.WriteLine(
            $"The {kitchen} has a northen exit that " +
            $"leads to the {kitchen.North}.");

        Console.WriteLine(
            $"The {library} has a southern exit that " +
            $"leads to the {library.South}.");

        Console.ReadKey();
    }
}

Cela ressemble à un bon cas pour la configuration et le modèle de générateur.
Greg Burghardt

Je me demande également si une pièce doit être découplée de la disposition d'un niveau ou d'une scène, de sorte que chaque pièce ne connaît pas les autres.
Greg Burghardt

1
@RockAnthonyJohnson Je n'appellerais pas vraiment cela réflexif, mais ce n'est pas pertinent. Mais pourquoi est-ce un problème? C'est extrêmement courant. En fait, c'est ainsi que presque toutes les structures de données sont construites. Pensez à une liste chaînée ou à un arbre binaire. Ce sont toutes des structures de données récursives, tout comme votre Roomexemple.
gardenhead

2
@RockAnthonyJohnson Les structures de données immuables sont extrêmement courantes, au moins dans la programmation fonctionnelle. Voici comment vous définissez une liste chaînée: type List a = Nil | Cons of a * List a. Et un arbre binaire: type Tree a = Leaf a | Cons of Tree a * Tree a. Comme vous pouvez le voir, ils sont tous deux auto-référentiels (récursifs). Voici comment vous définissez votre chambre: type Room = Nil | Open of {name: string, south: Room, east: Room, north: Room, west: Room}.
gardenhead

1
Si vous êtes intéressé, prenez le temps d'apprendre Haskell ou OCaml; cela élargira votre esprit;) Gardez également à l'esprit qu'il n'y a pas de démarcation claire entre les structures de données et les "objets métier". Regardez à quel point la définition de votre Roomclasse et celle de a List sont similaires dans le Haskell que j'ai écrit ci-dessus.
gardenhead

Réponses:


10

De toute évidence, vous ne pouvez pas le faire en utilisant exactement le code que vous avez publié, car à un moment donné, vous devrez construire un objet qui doit être connecté à un autre objet qui n'a pas encore été construit.

Il y a deux façons de penser (que j'ai utilisées auparavant) pour ce faire:

Utilisation de deux phases

Tous les objets sont construits en premier, sans aucune dépendance, et une fois qu'ils ont tous été construits, ils sont connectés. Cela signifie que les objets doivent passer par deux phases dans leur vie: une phase mutable très courte, suivie d'une phase immuable qui dure tout au long de leur vie.

Vous pouvez rencontrer exactement le même type de problème lors de la modélisation de bases de données relationnelles: une table a une clé étrangère qui pointe vers une autre table, et l'autre table peut avoir une clé étrangère qui pointe vers la première table. La manière dont cela est géré dans les bases de données relationnelles est que les contraintes de clé étrangère peuvent (et sont généralement) spécifiées avec une ALTER TABLE ADD FOREIGN KEYinstruction supplémentaire qui est distincte de l' CREATE TABLEinstruction. Ainsi, vous créez d'abord toutes vos tables, puis vous ajoutez vos contraintes de clé étrangère.

La différence entre les bases de données relationnelles et ce que vous voulez faire est que les bases de données relationnelles continuent à autoriser les ALTER TABLE ADD/DROP FOREIGN KEYinstructions tout au long de la durée de vie des tables, tandis que vous définirez probablement un indicateur `` IamImmutable '' et refuserez toute autre mutation une fois que toutes les dépendances auront été réalisées.

Utilisation de l'initialisation paresseuse

Au lieu d'une référence à une dépendance, vous passez un délégué qui retournera la référence à la dépendance en cas de besoin. Une fois la dépendance récupérée, le délégué n'est plus jamais appelé.

Le délégué prend généralement la forme d'une expression lambda, il ne sera donc que légèrement plus verbeux que le fait de passer des dépendances aux constructeurs.

L'inconvénient (minuscule) de cette technique est que vous devez gaspiller l'espace de stockage nécessaire pour stocker les pointeurs vers les délégués qui ne seront utilisés que lors de l'initialisation de votre graphique d'objet.

Vous pouvez même créer une classe générique de "référence paresseuse" qui l'implémente afin que vous n'ayez pas à la réimplémenter pour chacun de vos membres.

Voici une telle classe écrite en Java, vous pouvez facilement la transcrire en C #

(Mon Function<T>est comme le Func<T>délégué de C #)

package saganaki.util;

import java.util.Objects;

/**
 * A {@link Function} decorator which invokes the given {@link Function} only once, when actually needed, and then caches its result and never calls it again.
 * It behaves as if it is immutable, which includes the fact that it is thread-safe, provided that the given {@link Function} is also thread-safe.
 *
 * @param <T> the type of object supplied.
 */
public final class LazyImmutable<T> implements Function<T>
{
    private static final boolean USE_DOUBLE_CHECK = false; //TODO try with "double check"
    private final Object lock = new Object();
    @SuppressWarnings( "FieldAccessedSynchronizedAndUnsynchronized" )
    private Function<T> supplier;
    @SuppressWarnings( "FieldAccessedSynchronizedAndUnsynchronized" )
    private T value;

    /**
     * Constructor.
     *
     * @param supplier the {@link Function} which will supply the supplied object the first time it is needed.
     */
    public LazyImmutable( Function<T> supplier )
    {
        assert supplier != null;
        assert !(supplier instanceof LazyImmutable);
        this.supplier = supplier;
        value = null;
    }

    @Override
    public T invoke()
    {
        if( USE_DOUBLE_CHECK )
        {
            if( supplier != null )
                doCheck();
            return value;
        }

        doCheck();
        return value;
    }

    private void doCheck()
    {
        synchronized( lock )
        {
            if( supplier != null )
            {
                value = supplier.invoke();
                supplier = null;
            }
        }
    }

    @Override
    public String toString()
    {
        if( supplier != null )
            return "(lazy)";
        return Objects.toString( value );
    }
}

Cette classe est censée être thread-safe, et le "double contrôle" est lié à une optimisation dans le cas de la concurrence. Si vous ne prévoyez pas d'être multi-thread, vous pouvez supprimer tout cela. Si vous décidez d'utiliser cette classe dans une configuration multi-thread, assurez-vous de lire à propos de "l'idiome de double vérification". (Il s'agit d'une longue discussion au-delà de la portée de cette question.)


1
Mike, tu es génial. J'ai mis à jour le message d'origine pour inclure une implémentation basée sur ce que vous avez publié sur l'initialisation paresseuse.
Rock Anthony Johnson

1
La bibliothèque .Net fournit une référence paresseuse, bien nommée Lazy <T>. Merveilleux! J'ai appris cela d'une réponse à une revue de code que j'ai publiée sur codereview.stackexchange.com/questions/145039/…
Rock Anthony Johnson

16

Le modèle d'initialisation paresseux dans la réponse de Mike Nakis fonctionne très bien pour une initialisation unique entre deux objets, mais devient difficile à gérer pour plusieurs objets interdépendants avec des mises à jour fréquentes.

Il est beaucoup plus simple et plus facile à gérer de maintenir les liens entre les pièces en dehors des objets de la pièce eux-mêmes, dans quelque chose comme un ImmutableDictionary<Tuple<int, int>, Room>. De cette façon, au lieu de créer des références circulaires, vous ajoutez simplement une référence unidirectionnelle simple et facilement actualisable à ce dictionnaire.


Gardez à l'esprit que vous parliez d'objets immuables, donc il n'y a pas de mises à jour.
Rock Anthony Johnson

4
Lorsque les gens parlent de mettre à jour des objets immuables, cela signifie créer un nouvel objet avec les attributs mis à jour et faire référence à ce nouvel objet dans une nouvelle portée au lieu de l'ancien objet. Cela devient cependant un peu fastidieux à chaque fois.
Karl Bielefeldt

Karl, pardonne-moi. Je suis toujours un noob aux principes fonctionnels, hahaa.
Rock Anthony Johnson

2
C'est la bonne réponse. Les dépendances circulaires doivent en général être rompues et déléguées à un tiers. C'est beaucoup plus simple que de programmer un système complexe de construction et de gel d'objets mutables qui deviennent immuables.
Benjamin Hodgson

J'aimerais pouvoir lui donner quelques +1 de plus ... Immuables ou non, sans référentiel ou index "externe" (ou autre ), obtenir toutes ces salles correctement connectées serait assez inutilement complexe. Et cela n'interdit pas Roomde paraître avoir ces relations; mais, ils devraient être des getters qui lisent simplement à partir de l'index.
svidgen

12

La façon de le faire dans un style fonctionnel est de reconnaître ce que vous construisez réellement: un graphique dirigé avec des bords étiquetés.

Room library = new Room("Library");
Room ballroom = new Room("Ballroom");
Thing chest = new Thing("Treasure chest");
Thing book = new Thing("Ancient Tome");
Dungeon dungeon = Dungeon.Empty
  .WithRoom(library)
  .WithRoom(ballroom)
  .WithThing(chest)
  .WithThing(book)
  .WithPassage("North", library, ballroom)
  .WithPassage("South", ballroom, library)
  .WithContainment(library, chest)
  .WithContainment(chest, book);

Un donjon est une structure de données qui permet de suivre un tas de pièces et d'objets, et quelles sont les relations entre eux. Chaque appel "avec" renvoie un nouveau donjon immuable différent . Les chambres ne savent pas ce qui se trouve au nord et au sud; le livre ne sait pas qu'il est dans la poitrine. Le donjon connaît ces faits, et cette chose n'a aucun problème avec les références circulaires car il n'y en a pas.


1
J'ai étudié les graphes dirigés et les constructeurs fluides (et DSL). Je peux voir comment cela pourrait créer un graphique dirigé, mais c'est le premier que j'ai vu les deux idées associées. Y a-t-il un livre ou un billet de blog que j'ai manqué? Ou cela produit-il un graphique dirigé simplement parce que cela résout le problème des questions?
candied_orange

@CandiedOrange: Ceci est une esquisse de ce à quoi pourrait ressembler l'API. En fait, la construction de la structure de données de graphique orienté immuable sous-jacente nécessiterait du travail, mais ce n'est pas difficile. Un graphe orienté immuable n'est qu'un ensemble immuable de nœuds et un ensemble immuable de triplets (début, fin, étiquette), il peut donc être réduit à une composition de problèmes déjà résolus.
Eric Lippert

Comme je l'ai dit, j'ai étudié à la fois les DSL et les graphiques dirigés. J'essaie de savoir si vous avez lu ou écrit quelque chose qui rassemble les deux ou si vous venez de les réunir pour répondre à cette question particulière. Si vous savez quelque chose qui les rassemble, je serais ravi si vous pouviez me le signaler.
candied_orange

@CandiedOrange: Pas particulièrement. J'ai écrit une série de blogs il y a de nombreuses années sur un graphique immuable non orienté pour faire un solveur de sudoku de retour en arrière. Et j'ai récemment écrit une série de blogs sur les problèmes de conception orientée objet pour les structures de données mutables dans le domaine des assistants et des donjons.
Eric Lippert

3

Le poulet et un œuf ont raison. Cela n'a aucun sens en c #:

A a = new A(b);
B b = new B(a);

Mais cela:

A a = new A();
B b = new B(a);
a.setB(b);

Mais cela signifie que A n'est pas immuable!

Vous pouvez tricher:

C c = new C();
A a = new A(c);
B b = new B(c);
c.addA(a);
c.addB(b);

Cela cache le problème. Bien sûr, A et B ont un état immuable, mais ils se réfèrent à quelque chose qui n'est pas immuable. Ce qui pourrait facilement vaincre le point de les rendre immuables. J'espère que C est au moins aussi sûr que les threads dont vous avez besoin.

Il existe un schéma appelé gel-dégel:

A a = new A();
B b = new B(a);
a.addB(b);
a.freeze();

Maintenant, «a» est immuable. «A» ne l'est pas mais «a» l'est. Pourquoi ça va? Tant que rien d'autre ne connaît «a» avant qu'il ne soit gelé, qui s'en soucie?

Il existe une méthode thaw () mais elle ne change jamais «a». Il fait une copie modifiable de «a» qui peut être mise à jour puis gelée également.

L'inconvénient de cette approche est que la classe n'applique pas l'immuabilité. La procédure suivante est. Vous ne pouvez pas dire si c'est immutabile du type.

Je ne connais pas vraiment un moyen idéal pour résoudre ce problème en c #. Je sais comment cacher les problèmes. Parfois ça suffit.

Quand ce n'est pas le cas, j'utilise une approche différente pour éviter complètement ce problème. Par exemple: regardez comment le modèle d'état est implémenté ici . On pourrait penser qu'ils feraient cela comme référence circulaire, mais ils ne le font pas. Ils lancent de nouveaux objets chaque fois que l'état change. Parfois, il est plus facile d'abuser du ramasse-miettes que de trouver comment retirer les œufs des poulets.


+1 pour m'avoir présenté un nouveau modèle. J'ai d'abord entendu parler du gel-dégel.
Rock Anthony Johnson

a.freeze()pourrait retourner le ImmutableAtype. ce qui en fait un modèle fondamentalement constructeur.
Bryan Chen du

@BryanChen si vous faites cela, il breste une référence à l'ancien mutable a. L'idée est que a et bdevrait pointer vers des versions immuables les unes des autres avant de les publier sur le reste du système.
candied_orange

@RockAnthonyJohnson C'est aussi ce qu'Eric Lippert a appelé l' immuabilité Popsicle .
Repéré

1

Certaines personnes intelligentes ont déjà exprimé leur opinion à ce sujet, mais je pense simplement que ce n'est pas la responsabilité de la salle de savoir ce que sont ses voisins.

Je pense que c'est la responsabilité du bâtiment de savoir où se trouvent les pièces. Si la pièce a vraiment besoin de connaître ses voisins, passez-lui INeigbourFinder.

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.