Como modelar uma referência circular entre objetos imutáveis ​​em C #?


24

No exemplo de código a seguir, temos uma classe para objetos imutáveis ​​que representam uma sala. Norte, Sul, Leste e Oeste representam saídas para outras salas.

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

Então vemos que esta classe é projetada com uma referência circular reflexiva. Mas, como a classe é imutável, estou com um problema do tipo "galinha ou ovo". Estou certo de que programadores funcionais experientes sabem como lidar com isso. Como ele pode ser tratado em c #?

Estou tentando codificar um jogo de aventura baseado em texto, mas usando princípios de programação funcional apenas para o aprendizado. Estou preso a este conceito e posso usar alguma ajuda !!! Obrigado.

ATUALIZAR:

Aqui está uma implementação de trabalho baseada na resposta de Mike Nakis sobre inicialização lenta:

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

Esse é um bom argumento para a configuração e o Padrão do Construtor.
Greg Burghardt

Também me pergunto se uma sala deve ser dissociada do layout de um nível ou estágio, para que cada sala não conheça as outras.
precisa

11
@RockAnthonyJohnson Eu realmente não chamaria isso de reflexivo, mas isso não é pertinente. Por que isso é um problema? Isso é extremamente comum. De fato, é como quase todas as estruturas de dados são construídas. Pense em uma lista vinculada ou em uma árvore binária. Todas elas são estruturas de dados recursivas, e assim é o seu Roomexemplo.
gardenhead

2
@RockAnthonyJohnson Estruturas de dados imutáveis ​​são extremamente comuns, pelo menos em programação funcional. Isto é como você define uma lista ligada: type List a = Nil | Cons of a * List a. E uma árvore binária: type Tree a = Leaf a | Cons of Tree a * Tree a. Como você pode ver, ambos são auto-referenciais (recursivos). Veja como você definiria o seu quarto: type Room = Nil | Open of {name: string, south: Room, east: Room, north: Room, west: Room}.
gardenhead

11
Se você estiver interessado, reserve um tempo para aprender Haskell ou OCaml; isso expandirá sua mente;) Lembre-se também de que não há uma demarcação clara entre estruturas de dados e "objetos de negócios". Veja como as definições de sua Roomclasse e a List são semelhantes no Haskell que escrevi acima.
gardenhead

Respostas:


10

Obviamente, não é possível fazê-lo usando exatamente o código que você postou, porque em algum momento você precisará construir um objeto que precise ser conectado a outro objeto que ainda não foi construído.

Existem duas maneiras em que posso pensar (que usei antes) para fazer isso:

Usando duas fases

Todos os objetos são construídos primeiro, sem nenhuma dependência e, depois de construídos, eles são conectados. Isso significa que os objetos precisam passar por duas fases na vida: uma fase mutável muito curta, seguida por uma fase imutável que dura o resto da vida.

Você pode encontrar exatamente o mesmo tipo de problema ao modelar bancos de dados relacionais: uma tabela possui uma chave estrangeira que aponta para outra tabela e a outra tabela pode ter uma chave estrangeira que aponta para a primeira tabela. A maneira como isso é tratado nos bancos de dados relacionais é que as restrições de chave estrangeira podem (e geralmente são) especificadas com uma ALTER TABLE ADD FOREIGN KEYinstrução extra que é separada da CREATE TABLEinstrução. Então, primeiro você cria todas as suas tabelas e depois adiciona suas restrições de chave estrangeira.

A diferença entre os bancos de dados relacionais e o que você deseja fazer é que os bancos de dados relacionais continuem permitindo ALTER TABLE ADD/DROP FOREIGN KEYinstruções durante toda a vida útil das tabelas, enquanto você provavelmente definirá um sinalizador 'IamImmutable' e recusará outras mutações depois que todas as dependências forem realizadas.

Usando inicialização lenta

Em vez de uma referência a uma dependência, você passa um delegado que retornará a referência para a dependência quando necessário. Depois que a dependência é buscada, o delegado nunca é chamado novamente.

O delegado geralmente assumirá a forma de uma expressão lambda, portanto parecerá um pouco mais detalhado do que realmente passar as dependências para os construtores.

A (pequena) desvantagem dessa técnica é que você precisa desperdiçar o espaço de armazenamento necessário para armazenar os ponteiros para os delegados, que serão usados ​​apenas durante a inicialização do gráfico de objetos.

Você pode até criar uma classe genérica de "referência lenta" que implementa isso para que você não precise reimplementá-la para todos os membros.

Aqui está uma classe escrita em Java, você pode transcrevê-la facilmente em C #

(Meu Function<T>é como o Func<T>delegado 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 );
    }
}

Supõe-se que esta classe seja segura para threads e o material "verificação dupla" está relacionado a uma otimização no caso de simultaneidade. Se você não planeja ser multithread, pode retirar todo esse material. Se você decidir usar esta classe em uma instalação multithread, não deixe de ler sobre o "idioma da verificação dupla". (Essa é uma longa discussão além do escopo desta pergunta.)


11
Mike, você é brilhante. Atualizei a postagem original para incluir uma implementação com base no que você postou sobre inicialização lenta.
Rock Anthony Johnson

11
A biblioteca .Net fornece uma referência lenta, apropriadamente chamada Lazy <T>. Que maravilha! Aprendi isso com uma resposta a uma revisão do código eu postei sobre a codereview.stackexchange.com/questions/145039/...
Rock Anthony Johnson

16

O padrão de inicialização lenta na resposta de Mike Nakis 'funciona muito bem para uma inicialização única entre dois objetos, mas se torna pesado para vários objetos inter-relacionados com atualizações frequentes.

É muito mais simples e mais gerenciável manter os links entre salas fora dos próprios objetos da sala, em algo como um ImmutableDictionary<Tuple<int, int>, Room>. Dessa forma, em vez de criar referências circulares, você está apenas adicionando uma referência unidirecional fácil de atualizar e atualizável a este dicionário.


Lembre-se de falar sobre objetos imutáveis, portanto não há atualizações.
Rock Anthony Johnson

4
Quando as pessoas falam sobre a atualização de objetos imutáveis, elas significam a criação de um novo objeto com os atributos atualizados e a referência a esse novo objeto em um novo escopo, em vez do objeto antigo. Isso fica um pouco tedioso para dizer o tempo todo, no entanto.
Karl Bielefeldt

Karl, por favor me perdoe. Eu ainda sou um noob em princípios funcionais, hahaa.
Rock Anthony Johnson

2
Esta é a resposta certa. As dependências circulares geralmente devem ser desfeitas e delegadas a terceiros. É muito mais simples do que programar um sistema complexo de construção e congelamento de objetos mutáveis ​​que se tornam imutáveis.
Benjamin Hodgson

Gostaria de poder dar mais alguns +1 a isso ... Imutável ou não, sem um repositório ou índice "externo" (ou o que seja ), obter todas essas salas conectadas corretamente seria desnecessariamente complexo. E este não proíbe a Roompartir de aparecer para ter essas relações; mas devem ser getters que simplesmente leem do índice.
Svidgen 22/10/16

12

A maneira de fazer isso em um estilo funcional é reconhecer o que você está realmente construindo: um gráfico direcionado com bordas rotuladas.

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

Uma masmorra é uma estrutura de dados que monitora várias salas e coisas, e quais são as relações entre elas. Cada chamada "com" retorna uma masmorra imutável nova e diferente . Os quartos não sabem o que é norte e sul deles; o livro não sabe que está no peito. A masmorra conhece esses fatos, e essa coisa não tem problema com referências circulares, porque não existem.


11
Estudei gráficos direcionados e construtores fluentes (e DSLs). Eu posso ver como isso pode criar um gráfico direcionado, mas esta é a primeira vez que eu vejo as duas idéias associadas. Há uma publicação de livro ou blog que eu perdi? Ou isso produz um gráfico direcionado simplesmente porque isso resolve o problema das perguntas?
candied_orange

@CandiedOrange: este é um esboço de como a API pode ser. Realmente construir a estrutura imutável de dados de gráfico direcionado subjacente levaria algum trabalho, mas não é difícil. Um gráfico direcionado imutável é apenas um conjunto imutável de nós e um conjunto imutável de (início, fim, rótulo) triplos, para que possa ser reduzido a uma composição de problemas já resolvidos.
precisa

Como eu disse, estudei DSLs e gráficos direcionados. Estou tentando descobrir se você leu ou escreveu algo que reúne os dois ou se acabou de reuni-los para responder a essa pergunta em particular. Se você souber de algo que os une, eu adoraria se você pudesse me indicar.
candied_orange

@CandiedOrange: Não particularmente. Eu escrevi uma série de blogs há muitos anos em um gráfico imutável e não direcionado para fazer um solucionador de sudoku de retorno. E escrevi uma série de blogs mais recentemente sobre problemas de design orientado a objetos para estruturas de dados mutáveis ​​no domínio assistentes e masmorras.
precisa

3

Frango e um ovo estão certos. Isso não faz sentido em c #:

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

Mas isso faz:

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

Mas isso significa que A não é imutável!

Você pode trapacear:

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

Isso esconde o problema. Claro que A e B têm um estado imutável, mas se referem a algo que não é imutável. O que poderia facilmente derrotar o ponto de torná-los imutáveis. Espero que C seja pelo menos tão seguro quanto o segmento que você precisar.

Existe um padrão chamado congelamento-descongelamento:

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

Agora 'a' é imutável. 'A' não é, mas 'a' é. Por que está tudo bem? Enquanto nada mais souber sobre 'a' antes de congelar, quem se importa?

Existe um método thaw (), mas ele nunca muda 'a'. Faz uma cópia mutável de 'a' que pode ser atualizada e congelada também.

A desvantagem dessa abordagem é que a classe não está impondo imutabilidade. O procedimento a seguir é. Você não pode dizer se é imutável do tipo.

Eu realmente não sei uma maneira ideal de resolver esse problema em c #. Eu sei maneiras de esconder os problemas. Às vezes é o suficiente.

Quando não é, eu uso uma abordagem diferente para evitar esse problema completamente. Por exemplo: veja como o padrão de estado é implementado aqui . Você pensaria que eles fariam isso como uma referência circular, mas não o fazem. Eles acionam novos objetos toda vez que o estado muda. Às vezes, é mais fácil abusar do coletor de lixo do que descobrir como tirar os ovos das galinhas.


+1 por me apresentar um novo padrão. Primeiro, eu já ouvi falar de congelamento e degelo.
Rock Anthony Johnson

a.freeze()poderia retornar ImmutableAtipo. o que o torna basicamente padrão de construtor.
Bryan Chen

@BryanChen, se você fizer isso, ficará bsegurando uma referência ao antigo mutável a. A idéia é essa a e bdeve apontar para versões imutáveis ​​uma da outra antes de liberá-las para o restante do sistema.
Candied_orange # 25/16

@RockAnthonyJohnson Isso também é o que Eric Lippert chamou de imutabilidade de picolé .
manchado

1

Algumas pessoas inteligentes já expressaram suas opiniões sobre isso, mas acho que não é responsabilidade da sala saber quais são seus vizinhos.

Eu acho que é responsabilidade do edifício saber onde estão os quartos. Se a sala realmente precisar conhecer seus vizinhos, passe o INeigbourFinder para ela.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.