Qual é a melhor maneira de inicializar a referência de uma criança para seus pais?

35

Estou desenvolvendo um modelo de objeto que possui várias classes pai / filho diferentes. Cada objeto filho tem uma referência ao seu objeto pai. Posso pensar em (e tentei) várias maneiras de inicializar a referência pai, mas encontro desvantagens significativas em cada abordagem. Dadas as abordagens descritas abaixo, qual é a melhor ... ou o que é ainda melhor.

Não vou garantir que o código abaixo seja compilado; portanto, tente ver minha intenção se o código não estiver sintaticamente correto.

Observe que alguns dos meus construtores de classe filho usam parâmetros (que não sejam pai), mesmo que nem sempre eu os mostre.

  1. O chamador é responsável por definir o pai e adicionar ao mesmo pai.

    class Child {
      public Child(Parent parent) {Parent=parent;}
      public Parent Parent {get; private set;}
    }
    class Parent {
      // singleton child
      public Child Child {get; set;}
      //children
      private List<Child> _children = new List<Child>();
      public List<Child> Children { get {return _children;} }
    }
    

    Desvantagem: definir pai é um processo de duas etapas para o consumidor.

    var child = new Child(parent);
    parent.Children.Add(child);
    

    Desvantagem: propenso a erros. O chamador pode adicionar um filho a um pai diferente do que foi usado para inicializar o filho.

    var child = new Child(parent1);
    parent2.Children.Add(child);
    
  2. O pai verifica se o chamador adiciona o filho ao pai para o qual foi inicializado.

    class Child {
      public Child(Parent parent) {Parent = parent;}
      public Parent Parent {get; private set;}
    }
    class Parent {
      // singleton child
      private Child _child;
      public Child Child {
        get {return _child;}
        set {
          if (value.Parent != this) throw new Exception();
          _child=value;
        }
      }
      //children
      private List<Child> _children = new List<Child>();
      public ReadOnlyCollection<Child> Children { get {return _children;} }
      public void AddChild(Child child) {
        if (child.Parent != this) throw new Exception();
        _children.Add(child);
      }
    }
    

    Desvantagem: o chamador ainda tem um processo de duas etapas para definir o pai.

    Desvantagem: verificação do tempo de execução - reduz o desempenho e adiciona código a cada add / setter.

  3. O pai define a referência do pai da criança (para si mesmo) quando o filho é adicionado / atribuído a um pai. O setter pai é interno.

    class Child {
      public Parent Parent {get; internal set;}
    }
    class Parent {
      // singleton child
      private Child _child;
      public Child Child {
        get {return _child;}
        set {
          value.Parent = this;
          _child = value;
        }
      }
      //children
      private List<Child> _children = new List<Child>();
      public ReadOnlyCollection<Child> Children { get {return _children;} }
      public void AddChild(Child child) {
        child.Parent = this;
        _children.Add(child);
      }
    }
    

    Desvantagem: o filho é criado sem uma referência pai. Às vezes, a inicialização / validação requer o pai / mãe, o que significa que alguma inicialização / validação deve ser realizada no pai / mãe da criança. O código pode ficar complicado. Seria muito mais fácil implementar o filho se ele sempre tivesse sua referência principal.

  4. Pai expõe métodos de adição de fábrica para que um filho sempre tenha uma referência pai. O filho é interno. O setter dos pais é privado.

    class Child {
      internal Child(Parent parent, init-params) {Parent = parent;}
      public Parent Parent {get; private set;}
    }
    class Parent {
      // singleton child
      public Child Child {get; private set;}
      public void CreateChild(init-params) {
          var child = new Child(this, init-params);
          Child = value;
      }
      //children
      private List<Child> _children = new List<Child>();
      public ReadOnlyCollection<Child> Children { get {return _children;} }
      public Child AddChild(init-params) {
        var child = new Child(this, init-params);
        _children.Add(child);
        return child;
      }
    }
    

    Desvantagem: não é possível usar sintaxe de inicialização como new Child(){prop = value}. Em vez disso, tem que fazer:

    var c = parent.AddChild(); 
    c.prop = value;
    

    Desvantagem: É necessário duplicar os parâmetros do construtor filho nos métodos de adição de fábrica.

    Desvantagem: Não é possível usar um configurador de propriedades para um filho único. Parece lame que eu preciso de um método para definir o valor, mas fornecer acesso de leitura através de um getter de propriedade. É desequilibrado.

  5. Filho se adiciona ao pai referenciado em seu construtor. O child child é público. Nenhum acesso público de adição do pai.

    //singleton
    class Child{
      public Child(ParentWithChild parent) {
        Parent = parent;
        Parent.Child = this;
      }
      public ParentWithChild Parent {get; private set;}
    }
    class ParentWithChild {
      public Child Child {get; internal set;}
    }
    
    //children
    class Child {
      public Child(ParentWithChildren parent) {
        Parent = parent;
        Parent._children.Add(this);
      }
      public ParentWithChildren Parent {get; private set;}
    }
    class ParentWithChildren {
      internal List<Child> _children = new List<Child>();
      public ReadOnlyCollection<Child> Children { get {return _children;} }
    }
    

    Desvantagem: chamar a sintaxe não é ótimo. Normalmente, chama-se um addmétodo no pai, em vez de apenas criar um objeto como este:

    var parent = new ParentWithChildren();
    new Child(parent); //adds child to parent
    new Child(parent);
    new Child(parent);
    

    E define uma propriedade em vez de apenas criar um objeto como este:

    var parent = new ParentWithChild();
    new Child(parent); // sets parent.Child

...

Acabei de aprender que a SE não permite algumas perguntas subjetivas e, claramente, essa é uma pergunta subjetiva. Mas, talvez seja uma boa pergunta subjetiva.

Steven Broshar
fonte
14
A melhor prática é que as crianças não devem saber sobre seus pais.
Telastyn
2
@ Telastyn Eu não posso deixar de ler isso como língua na bochecha, e é hilário. Também completamente morto sangrento preciso. Steven, o termo para analisar é "acíclico", pois há muita literatura por aí sobre por que você deve tornar os gráficos acíclicos, se possível.
Jimmy Hoffa
10
@Telastyn, você deve tentar usar esse comentário em parenting.stackexchange #
Fabio Marcolini
2
Hmm. Não sabe como mover uma postagem (não vê um controle de bandeira). Voltei a postar para programadores, já que alguém me disse que pertencia a ele.
Steven Broshar

Respostas:

19

Eu ficaria longe de qualquer cenário que requer necessariamente que a criança conheça os pais.

Existem maneiras de passar mensagens de filho para pai através de eventos. Dessa forma, o pai, ao adicionar, simplesmente precisa se registrar em um evento que o filho aciona, sem que o filho precise saber diretamente sobre o pai. Afinal, esse provavelmente é o uso pretendido de uma criança que conhece seus pais, para poder usá-los com algum efeito. Exceto que você não gostaria que a criança realizasse o trabalho dos pais, então realmente o que você quer fazer é simplesmente dizer aos pais que algo aconteceu. Portanto, o que você precisa lidar é com um evento no filho, do qual os pais podem tirar vantagem.

Esse padrão também é muito bom se esse evento se tornar útil para outras classes. Talvez seja um exagero, mas também impede que você se atire no pé mais tarde, pois fica tentador querer usar os pais na classe de seus filhos, que apenas acasalam ainda mais as duas classes. A refatoração dessas classes posteriormente consome tempo e pode facilmente criar bugs no seu programa.

Espero que ajude!

Neil
fonte
6
Fique longe de qualquer cenário que exija necessariamente que a criança conheça os pais ” - por quê? Sua resposta depende da suposição de que gráficos de objetos circulares são uma má idéia. Embora esse seja o caso algumas vezes (por exemplo, quando o gerenciamento de memória é feito através de uma contagem ingênua de ref - não é o caso do C #), geralmente não é uma coisa ruim. Notavelmente, o Padrão do Observador (que é frequentemente usado para despachar eventos) envolve o observável ( Child) mantendo um conjunto de observadores ( Parent), que reintroduz a circularidade de maneira inversa (e introduz uma série de questões próprias).
amon
11
Porque dependências cíclicas significa estruturar seu código de forma que você não possa ter um sem o outro. Pela natureza do relacionamento pai-filho, elas devem ser entidades separadas ou você corre o risco de ter duas classes fortemente acopladas, que também podem ser uma única classe gigantesca com uma lista de todos os cuidados cuidadosos em seu design. Não vejo como o padrão Observer é o mesmo que pai-filho, exceto pelo fato de uma classe ter referências a várias outras. Para mim, pai-filho é um pai que tem uma forte dependência do filho, mas não o inverso.
Neil
Concordo. Os eventos são a melhor maneira de lidar com o relacionamento pai-filho neste caso. É o padrão que eu uso com muita frequência e facilita a manutenção do código, em vez de ter que se preocupar com o que a classe filho está fazendo com os pais por meio da referência.
precisa saber é o seguinte
@ Neil: Dependências mútuas de instâncias de objetos formam uma parte natural de muitos modelos de dados. Em uma simulação mecânica de um carro, diferentes partes do carro precisarão transmitir forças umas às outras; isso geralmente é mais bem tratado ao fazer com que todas as partes do carro considerem o próprio mecanismo de simulação como um objeto "pai", do que tendo dependências cíclicas entre todos os componentes, mas se os componentes puderem responder a qualquer estímulo externo ao pai eles precisarão de uma maneira de notificar os pais, se esses estímulos tiverem algum efeito que os pais precisem conhecer.
Supercat 6/14
2
@ Neil: Se o domínio incluir objetos de floresta com dependências de dados cíclicos irredutíveis, qualquer modelo do domínio também o fará. Em muitos casos, isso implica ainda que a floresta se comportará como um único objeto gigante, quer a pessoa queira ou não . O padrão agregado serve para concentrar a complexidade da floresta em um único objeto de classe chamado Raiz Agregada. Dependendo da complexidade do domínio que está sendo modelado, essa raiz agregada pode ficar um pouco grande e pesado, mas se a complexidade é inevitável (como é o caso de alguns domínios) é melhor ...
supercat
10

Eu acho que sua opção 3 pode ser a mais limpa. Você escreveu.

Desvantagem: o filho é criado sem uma referência pai.

Não vejo isso como uma desvantagem. De fato, o design do seu programa pode se beneficiar de objetos filhos que podem ser criados sem um pai primeiro. Por exemplo, ele pode facilitar muito o teste de crianças isoladas. Se um usuário do seu modelo esquecer de adicionar um filho ao pai e chamar métodos que esperam que a propriedade pai seja inicializada na classe filho, ele receberá uma exceção de ref nula - que é exatamente o que você deseja: uma falha precoce por um erro uso.

E se você acha que um filho precisa que seu atributo pai seja inicializado no construtor sob todas as circunstâncias por motivos técnicos, use algo como um "objeto pai nulo" como valor padrão (embora isso corra o risco de mascarar erros).

Doc Brown
fonte
Se um objeto filho não puder fazer nada útil sem um pai, ter um objeto filho iniciado sem um pai exigirá que ele possua um SetParentmétodo que seja necessário para suportar a alteração da paternidade de um filho existente (o que pode ser difícil de fazer e / ou sem sentido) ou só poderá ser chamado uma vez. Modelar situações como agregadas (como sugere Mike Brown) pode ser muito melhor do que ter filhos começando sem os pais.
Super dec
Se um caso de uso exigir algo pelo menos uma vez, o design deverá permitir sempre. Então, restringir essa capacidade a circunstâncias especiais é fácil. Adicionar essa capacidade posteriormente, no entanto, é geralmente impossível. A melhor solução é a opção 3. Provavelmente com um terceiro objeto "Relacionamento" entre o pai e o filho, de modo que [Pai] ---> [Relacionamento (o pai possui o filho)] <--- [Filho]. Isso também permite várias instâncias de [Relacionamento], como [Filho] ---> [Relacionamento (o filho pertence ao pai)] <--- [Pai].
DocSalvager 8/16
3

Não há nada para evitar alta coesão entre duas classes que são usadas comumente juntas (por exemplo, um Order e LineItem geralmente se referem). No entanto, nesses casos, tenho a tendência de aderir às regras de Design Orientado a Domínio e modelá-las como um Agregado, com o Pai sendo a Raiz Agregada. Isso nos diz que o AR é responsável pela vida útil de todos os objetos em seu agregado.

Portanto, seria mais parecido com o cenário quatro, em que o pai expõe um método para criar seus filhos, aceitando todos os parâmetros necessários para inicializar adequadamente os filhos e adicioná-lo à coleção.

Michael Brown
fonte
11
Eu usaria uma definição um pouco mais flexível de agregado, que permitiria a existência de referências externas em partes do agregado que não a raiz, desde que - do ponto de vista de um observador externo - o comportamento fosse consistente com cada parte do agregado mantendo apenas uma referência à raiz e não a qualquer outra parte. Na minha opinião, o princípio chave é que cada objeto mutável deve ter um proprietário ; um agregado é uma coleção de objetos que pertencem a um único objeto (a "raiz agregada"), que deve conhecer todas as referências que existem para suas partes.
Super dec
3

Eu sugeriria ter objetos "child-factory" que são passados ​​para um método pai que cria um filho (usando o objeto "child factory"), o anexa e retorna uma exibição. O próprio objeto filho nunca será exposto fora do pai. Essa abordagem pode funcionar bem para coisas como simulações. Em uma simulação eletrônica, um objeto "fábrica filho" específico pode representar as especificações para algum tipo de transistor; outro pode representar as especificações para um resistor; um circuito que precisa de dois transistores e quatro resistores pode ser criado com código como:

var q2N3904 = new TransistorSpec(TransistorType.NPN, 0.691, 40);
var idealResistor4K7 = new IdealResistorSpec(4700.0);
var idealResistor47K = new IdealResistorSpec(47000.0);

var Q1 = Circuit.AddComponent(q2N3904);
var Q2 = Circuit.AddComponent(q2N3904);
var R1 = Circuit.AddComponent(idealResistor4K7);
var R2 = Circuit.AddComponent(idealResistor4K7);
var R3 = Circuit.AddComponent(idealResistor47K);
var R4 = Circuit.AddComponent(idealResistor47K);

Observe que o simulador não precisa reter nenhuma referência ao objeto criador filho e AddComponentnão retornará uma referência ao objeto criado e mantido pelo simulador, mas sim a um objeto que represente uma exibição. Se o AddComponentmétodo for genérico, o objeto de exibição poderá incluir funções específicas do componente, mas não exporá os membros que o pai usa para gerenciar o anexo.

supercat
fonte
2

Ótima lista. Eu não sei qual método é o "melhor", mas aqui está um para encontrar os métodos mais expressivos.

Comece com a classe pai e filho mais simples possível. Escreva seu código com esses. Depois de notar a duplicação de código que pode ser nomeada, você a coloca em um método.

Talvez você entenda addChild(). Talvez você consiga algo como addChildren(List<Child>)ou addChildrenNamed(List<String>)ou loadChildrenFrom(String)ou newTwins(String, String)ou Child.replicate(int).

Se o seu problema é realmente forçar um relacionamento um para muitos, talvez você deva

  • forçá-lo nos levantadores, o que pode levar a confusão ou cláusulas de lançamento
  • você remove os setters e cria métodos especiais de copiar ou mover - que são expressivos e compreensíveis

Esta não é uma resposta, mas espero que você encontre uma ao ler isso.

Do utilizador
fonte
0

Compreendo que ter links de filho para pai tenha suas desvantagens, conforme observado acima.

No entanto, para muitos cenários, as soluções alternativas com eventos e outras mecânicas "desconectadas" também trazem suas próprias complexidades e linhas extras de código.

Por exemplo, a criação de um evento de Child para ser recebido por seu Parent vinculará os dois, embora de maneira flexível.

Talvez para muitos cenários esteja claro para todos os desenvolvedores o que significa uma propriedade Child.Parent. Para a maioria dos sistemas em que trabalhei, funcionou bem. O excesso de engenharia pode ser demorado e…. Confuso!

Tenha um método Parent.AttachChild () que execute todo o trabalho necessário para vincular o filho ao pai. Todo mundo está claro sobre o que isso "significa"

Rax
fonte