Come modellare un riferimento circolare tra oggetti immutabili in C #?


24

Nel seguente esempio di codice, abbiamo una classe per oggetti immutabili che rappresenta una stanza. Nord, Sud, Est e Ovest rappresentano le uscite in altre stanze.

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; }
}

Quindi vediamo che questa classe è progettata con un riferimento circolare riflessivo. Ma poiché la classe è immutabile, sono bloccato con un problema di "pollo o l'uovo". Sono certo che programmatori funzionali esperti sappiano come gestirlo. Come può essere gestito in C #?

Mi sto sforzando di programmare un gioco di avventura basato su testo, ma usando i principi di programmazione funzionale solo per motivi di apprendimento. Sono bloccato su questo concetto e posso usare un po 'di aiuto !!! Grazie.

AGGIORNARE:

Ecco un'implementazione funzionante basata sulla risposta di Mike Nakis per quanto riguarda l'inizializzazione lazy:

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();
    }
}

Questo sembra un buon caso per la configurazione e il Builder Pattern.
Greg Burghardt,

Mi chiedo anche se una stanza dovrebbe essere disaccoppiata dalla disposizione di un livello o palcoscenico, in modo che ogni stanza non sappia delle altre.
Greg Burghardt,

1
@RockAnthonyJohnson Non lo definirei davvero riflessivo, ma non è pertinente. Perché è un problema, però? Questo è estremamente comune. In effetti, è come quasi tutte le strutture di dati sono costruite. Pensa a un elenco collegato o un albero binario. Sono tutte strutture di dati ricorsive, e così è il tuo Roomesempio.
gardenhead,

2
@RockAnthonyJohnson Le strutture di dati immutabili sono estremamente comuni, almeno nella programmazione funzionale. Questo è come si definisce una lista collegata: type List a = Nil | Cons of a * List a. E un albero binario: type Tree a = Leaf a | Cons of Tree a * Tree a. Come puoi vedere, sono entrambi autoreferenziali (ricorsivi). Ecco come ci si definisce la vostra camera: type Room = Nil | Open of {name: string, south: Room, east: Room, north: Room, west: Room}.
gardenhead,

1
Se sei interessato, prenditi il ​​tempo per imparare Haskell o OCaml; espanderà la tua mente;) Tieni anche presente che non esiste una chiara demarcazione tra strutture di dati e "oggetti business". Guarda quanto sono simili la definizione della tua Roomclasse e una List nell'Haskell che ho scritto sopra.
gardenhead,

Risposte:


10

Ovviamente, non puoi farlo usando esattamente il codice che hai pubblicato, perché a un certo punto dovrai costruire un oggetto che deve essere collegato a un altro oggetto che non è stato ancora costruito.

Ci sono due modi a cui posso pensare (che ho usato prima) per fare questo:

Usando due fasi

Tutti gli oggetti vengono costruiti per primi, senza dipendenze, e una volta che sono stati tutti costruiti, vengono collegati. Ciò significa che gli oggetti devono attraversare due fasi della loro vita: una fase mutabile molto breve, seguita da una fase immutabile che dura per il resto della loro vita.

È possibile riscontrare lo stesso identico problema durante la modellazione di database relazionali: una tabella ha una chiave esterna che punta a un'altra tabella e l'altra tabella può avere una chiave esterna che punta alla prima tabella. Il modo in cui questo viene gestito nei database relazionali è che i vincoli di chiave esterna possono (e solitamente sono) specificati con un'istruzione aggiuntiva ALTER TABLE ADD FOREIGN KEYche è separata CREATE TABLEdall'istruzione. Quindi, prima crei tutte le tue tabelle, quindi aggiungi i vincoli della chiave esterna.

La differenza tra i database relazionali e ciò che si desidera fare è che i database relazionali continuano a consentire ALTER TABLE ADD/DROP FOREIGN KEYdichiarazioni per tutta la durata delle tabelle, mentre probabilmente si imposta un flag "IamImmutable" e si rifiuta qualsiasi ulteriore mutazione una volta realizzate tutte le dipendenze.

Utilizzando l'inizializzazione lazy

Invece di un riferimento a una dipendenza, si passa un delegato che restituirà il riferimento alla dipendenza quando necessario. Una volta recuperata la dipendenza, il delegato non viene più richiamato.

Il delegato di solito assumerà la forma di un'espressione lambda, quindi sembrerà solo leggermente più prolisso rispetto al passaggio effettivo delle dipendenze ai costruttori.

Il (minuscolo) aspetto negativo di questa tecnica è che devi sprecare lo spazio di archiviazione necessario per memorizzare i puntatori ai delegati che verranno utilizzati solo durante l'inizializzazione del grafico degli oggetti.

Puoi persino creare una classe generica di "riferimento pigro" che la implementa in modo da non doverla implementare nuovamente per ogni singolo membro.

Ecco una tale classe scritta in Java, puoi facilmente trascriverla in C #

(Il mio Function<T>è come il Func<T>delegato di 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 );
    }
}

Si suppone che questa classe sia thread-safe e che il "doppio controllo" sia correlato a un'ottimizzazione in caso di concorrenza. Se non hai intenzione di essere multi-thread, puoi rimuovere tutte quelle cose. Se decidi di utilizzare questa classe in una configurazione multi-thread, assicurati di leggere il "doppio controllo del linguaggio". (Questa è una lunga discussione che va oltre lo scopo di questa domanda.)


1
Mike, sei brillante. Ho aggiornato il post originale per includere un'implementazione basata su ciò che hai pubblicato sull'inizializzazione lazy.
Rock Anthony Johnson,

1
La libreria .Net fornisce un riferimento pigro, giustamente chiamato Lazy <T>. Che meraviglia! L'ho imparato da una risposta a una recensione di codice che ho postato su codereview.stackexchange.com/questions/145039/…
Rock Anthony Johnson,

16

Il modello di inizializzazione pigra nella risposta di Mike Nakis funziona perfettamente per un'inizializzazione una tantum tra due oggetti, ma diventa ingombrante per più oggetti interconnessi con aggiornamenti frequenti.

È molto più semplice e più gestibile mantenere i collegamenti tra le stanze esterne agli oggetti stessi, in qualcosa di simile a un ImmutableDictionary<Tuple<int, int>, Room>. In questo modo, invece di creare riferimenti circolari, stai solo aggiungendo un singolo riferimento unidirezionale, facilmente aggiornabile, a questo dizionario.


Tieni presente che stavano parlando di oggetti immutabili, quindi non ci sono aggiornamenti.
Rock Anthony Johnson,

4
Quando le persone parlano dell'aggiornamento di oggetti immutabili, significano creare un nuovo oggetto con gli attributi aggiornati e fare riferimento a quel nuovo oggetto in un nuovo ambito al posto del vecchio oggetto. Questo diventa un po 'noioso da dire ogni volta, però.
Karl Bielefeldt,

Karl, ti prego, perdonami. Sono ancora un noob ai principi funzionali, hahaa.
Rock Anthony Johnson,

2
Questa è la risposta esatta. Le dipendenze circolari dovrebbero in generale essere interrotte e delegate a terzi. È molto più semplice della programmazione di un sistema complesso di oggetti mutabili che diventano immutabili.
Benjamin Hodgson,

Vorrei poterlo dare qualche altro +1 ... Immutabile o no, senza un repository o un indice "esterno" (o qualsiasi altra cosa ), ottenere tutte queste stanze collegate correttamente sarebbe piuttosto inutilmente complesso. E questo non proibisce Roomdi sembrare avere tali relazioni; ma dovrebbero essere getter che leggono semplicemente dall'indice.
svidgen

12

Il modo per farlo in uno stile funzionale è riconoscere ciò che stai effettivamente costruendo: un grafico diretto con bordi etichettati.

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);

Una prigione è una struttura di dati che tiene traccia di un mucchio di stanze e cose e quali sono le relazioni tra di loro. Ogni chiamata "with" restituisce un nuovo dungeon immutabile diverso . Le stanze non sanno cosa sia a nord e a sud di loro; il libro non sa che è nel petto. Il sotterraneo conosce questi fatti e quella cosa non ha problemi con riferimenti circolari perché non ce ne sono.


1
Ho studiato grafici diretti e costruttori fluenti (e DSL). Vedo come questo potrebbe costruire un grafico diretto ma questo è il primo che ho visto le due idee associate. C'è un libro o un post sul blog che mi sono perso? O questo produce un grafico diretto semplicemente perché risolve il problema delle domande?
candied_orange,

@CandiedOrange: questo è uno schizzo di come potrebbe apparire l'API. Realizzare l'immutabile struttura dei dati dei grafici diretti sottostanti richiederebbe un po 'di lavoro, ma non è difficile. Un grafico diretto immutabile è solo un insieme immutabile di nodi e un insieme immutabile di (inizio, fine, etichetta) triple, quindi può essere ridotto a una composizione di problemi già risolti.
Eric Lippert,

Come ho detto, ho studiato sia i DSL che i grafici diretti. Sto cercando di capire se hai letto o scritto qualcosa che mette insieme i due o se li hai appena riuniti per rispondere a questa particolare domanda. Se sai qualcosa là fuori che li mette insieme mi piacerebbe se tu potessi indicarmelo.
candied_orange,

@CandiedOrange: non particolarmente. Ho scritto una serie di blog molti anni fa su un grafico immutabile e non diretto per creare un solutore di sudoku backtracking. E di recente ho scritto una serie di blog sui problemi di progettazione orientata agli oggetti per strutture di dati mutabili nel dominio dei maghi e dei sotterranei.
Eric Lippert,

3

Pollo e un uovo hanno ragione. Questo non ha senso in c #:

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

Ma questo fa:

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

Ma ciò significa che A non è immutabile!

Puoi imbrogliare:

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

Questo nasconde il problema. Sicuramente A e B hanno uno stato immutabile ma si riferiscono a qualcosa che non è immutabile. Che potrebbe facilmente sconfiggere il punto di renderli immutabili. Spero che C sia sicuro almeno quanto il filo di cui hai bisogno.

C'è uno schema chiamato freeze-thaw:

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

Ora "a" è immutabile. 'A' non è ma 'a' lo è. Perché va bene? Finché nient'altro sa di "a" prima che sia congelato, a chi importa?

C'è un metodo thaw () ma non cambia mai 'a'. Fa una copia mutabile di 'a' che può essere aggiornata e anche congelata.

L'aspetto negativo di questo approccio è che la classe non impone l'immutabilità. La seguente procedura è. Non puoi dire se è immutabile dal tipo.

Non conosco davvero un modo ideale per risolvere questo problema in c #. Conosco modi per nascondere i problemi. A volte basta.

Quando non lo è, uso un approccio diverso per evitare del tutto questo problema. Per esempio: guardare a come il modello di Stato è implementato qui . Penseresti che lo farebbero come riferimento circolare ma non lo fanno. Eseguono nuovi oggetti ogni volta che lo stato cambia. A volte è più facile abusare del bidone della spazzatura che capire come estrarre le uova dai polli.


+1 per avermi presentato un nuovo modello. Prima ho mai sentito parlare di congelamento-disgelo.
Rock Anthony Johnson,

a.freeze()potrebbe restituire il ImmutableAtipo. che lo rendono fondamentalmente modello di costruzione.
Bryan Chen,

@BryanChen, se lo fai, brimane un riferimento al vecchio mutabile a. L'idea è che a e bdovrebbe puntare a versioni immutabili della vicenda prima di rilasciare al resto del sistema.
candied_orange,

@RockAnthonyJohnson Questo è anche ciò che Eric Lippert ha definito immutabilità di Popsicle .
Scoperto il

1

Alcune persone intelligenti hanno già espresso le loro opinioni su questo, ma penso solo che non sia responsabilità della stanza sapere quali sono i suoi vicini.

Penso che sia responsabilità dell'edificio sapere dove si trovano le stanze. Se la stanza ha davvero bisogno di sapere che i suoi vicini le passano INeigbourFinder.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.