Boas ou más práticas? Inicializando objetos no getter

167

Parece que tenho um hábito estranho ... pelo menos de acordo com meu colega de trabalho. Estamos trabalhando juntos em um pequeno projeto. A maneira como escrevi as aulas é (exemplo simplificado):

[Serializable()]
public class Foo
{
    public Foo()
    { }

    private Bar _bar;

    public Bar Bar
    {
        get
        {
            if (_bar == null)
                _bar = new Bar();

            return _bar;
        }
        set { _bar = value; }
    }
}

Então, basicamente, eu apenas inicializo qualquer campo quando um getter é chamado e o campo ainda é nulo. Imaginei que isso reduziria a sobrecarga ao não inicializar nenhuma propriedade que não fosse usada em nenhum lugar.

ETA: A razão pela qual fiz isso é que minha classe tem várias propriedades que retornam uma instância de outra classe, que por sua vez também possuem propriedades com mais classes e assim por diante. Chamar o construtor para a classe superior subseqüentemente chamaria todos os construtores para todas essas classes, quando elas nem sempre são necessárias.

Há alguma objeção contra essa prática, além da preferência pessoal?

ATUALIZAÇÃO: Eu considerei as muitas opiniões divergentes em relação a esta pergunta e defenderei minha resposta aceita. No entanto, agora cheguei a um entendimento muito melhor do conceito e sou capaz de decidir quando usá-lo e quando não.

Contras:

  • Problemas de segurança do thread
  • Não obedecendo a uma solicitação "setter" quando o valor passado é nulo
  • Micro-otimizações
  • O tratamento de exceção deve ocorrer em um construtor
  • Precisa verificar o código nulo na classe '

Prós:

  • Micro-otimizações
  • Propriedades nunca retornam nulas
  • Atraso ou evitar o carregamento de objetos "pesados"

A maioria dos contras não é aplicável à minha biblioteca atual, no entanto, eu teria que testar para ver se as "micro-otimizações" estão realmente otimizando alguma coisa.

ÚLTIMA ATUALIZAÇÃO:

Ok, mudei minha resposta. Minha pergunta original era se esse é um bom hábito ou não. E agora estou convencido de que não é. Talvez eu ainda o use em algumas partes do meu código atual, mas não incondicionalmente e definitivamente não o tempo todo. Então, vou perder meu hábito e pensar sobre isso antes de usá-lo. Obrigado a todos!

John Willemse
fonte
14
Esse é o padrão de carregamento lento, não está oferecendo exatamente um benefício maravilhoso aqui, mas ainda é uma coisa boa.
Machinarius
28
A instanciação preguiçosa faz sentido se você tiver um impacto mensurável no desempenho ou se esses membros raramente são usados ​​e consomem uma quantidade exorbitante de memória, ou se leva muito tempo para instancia-los e desejar fazê-lo somente sob demanda. De qualquer forma, certifique-se de ter em conta os problemas de segurança do encadeamento (seu código atual não é ) e considere usar a classe Lazy <T> fornecida .
Chris Sinclair
10
Eu acho que esta questão se encaixa melhor em codereview.stackexchange.com
Eduardo Brites
7
@PLB não é um padrão singleton.
Colin Mackay
30
Estou surpreso que ninguém tenha mencionado um bug sério com este código. Você tem uma propriedade pública que eu posso definir de fora. Se eu defini-lo como NULL, você sempre criará um novo objeto e ignorará meu acesso ao setter. Isso pode ser um bug muito sério. Para propriedades particulares, isso pode ser aceitável. Pessoalmente, não gosto de fazer otimizações prematuras. Adiciona complexidade para nenhum benefício adicional.
SolutionYogi

Respostas:

170

O que você tem aqui é uma implementação ingênua de "inicialização lenta".

Resposta curta:

Usar a inicialização lenta incondicionalmente não é uma boa ideia. Ela tem seus lugares, mas é preciso levar em consideração os impactos que esta solução tem.

Antecedentes e explicação:

Implementação concreta:
Vamos primeiro examinar sua amostra concreta e por que considero sua implementação ingênua:

  1. Viola o princípio da menor surpresa (POLS) . Quando um valor é atribuído a uma propriedade, espera-se que esse valor seja retornado. Na sua implementação, este não é o caso de null:

    foo.Bar = null;
    Assert.Null(foo.Bar); // This will fail
    
  2. Ele apresenta alguns problemas de encadeamento: dois chamadores de foo.Barthreads diferentes podem obter duas instâncias diferentes Bare um deles ficará sem conexão com a Fooinstância. Quaisquer alterações feitas nessa Barinstância são perdidas silenciosamente.
    Este é outro caso de violação do POLS. Quando apenas o valor armazenado de uma propriedade é acessado, espera-se que seja seguro para threads. Embora você possa argumentar que a classe simplesmente não é segura para threads - incluindo o getter de sua propriedade -, você teria que documentar isso corretamente, pois esse não é o caso normal. Além disso, a introdução desta questão é desnecessária, como veremos em breve.

Em geral:
agora é hora de examinar a inicialização lenta em geral: a inicialização lenta
geralmente é usada para atrasar a construção de objetos que demoram muito tempo para serem construídos ou que consomem muita memória depois de totalmente construídos.
Essa é uma razão muito válida para usar a inicialização lenta.

No entanto, essas propriedades normalmente não possuem setters, o que elimina o primeiro problema mencionado acima.
Além disso, uma implementação segura para threads seria usada - como Lazy<T>- para evitar o segundo problema.

Mesmo ao considerar esses dois pontos na implementação de uma propriedade lenta, os seguintes pontos são problemas gerais desse padrão:

  1. A construção do objeto pode não ter êxito, resultando em uma exceção de um getter de propriedade. Esta é mais uma violação do POLS e, portanto, deve ser evitada. Até a seção sobre propriedades nas "Diretrizes de design para desenvolvimento de bibliotecas de classes" afirma explicitamente que os getters de propriedades não devem lançar exceções:

    Evite lançar exceções de getters de propriedade.

    Os getters de propriedade devem ser operações simples, sem pré-condições. Se um getter lançar uma exceção, considere redesenhar a propriedade como um método.

  2. As otimizações automáticas do compilador são prejudicadas, nomeadamente inlining e predição de ramificação. Por favor, veja a resposta de Bill K para uma explicação detalhada.

A conclusão desses pontos é a seguinte:
Para cada propriedade individual implementada preguiçosamente, você deve ter considerado esses pontos.
Isso significa que é uma decisão por caso e não pode ser tomada como uma prática recomendada geral.

Esse padrão tem seu lugar, mas não é uma prática recomendada geral ao implementar classes. Não deve ser usado incondicionalmente , pelas razões expostas acima.


Nesta seção, quero discutir alguns dos pontos que outros apresentaram como argumentos para usar a inicialização lenta incondicionalmente:

  1. Serialização:
    EricJ afirma em um comentário:

    Um objeto que pode ser serializado não terá seu contratador invocado quando for desserializado (depende do serializador, mas muitos comuns se comportam assim). Colocar o código de inicialização no construtor significa que você precisa fornecer suporte adicional à desserialização. Esse padrão evita essa codificação especial.

    Existem vários problemas com esse argumento:

    1. A maioria dos objetos nunca será serializada. A adição de algum tipo de suporte quando não é necessário viola o YAGNI .
    2. Quando uma classe precisa oferecer suporte à serialização, existem maneiras de habilitá-la sem uma solução alternativa que não tem nada a ver com a serialização à primeira vista.
  2. Micro-otimização: seu principal argumento é que você deseja construir os objetos somente quando alguém realmente os acessar. Então você está realmente falando sobre como otimizar o uso da memória.
    Não concordo com esse argumento pelos seguintes motivos:

    1. Na maioria dos casos, mais alguns objetos na memória não têm nenhum impacto em nada. Computadores modernos têm memória suficiente. Sem um caso de problemas reais confirmados por um criador de perfil, essa é uma otimização pré-madura e há boas razões contra isso.
    2. Reconheço o fato de que, às vezes, esse tipo de otimização é justificado. Mas, mesmo nesses casos, a inicialização lenta não parece ser a solução correta. Há duas razões para falar contra:

      1. A inicialização preguiçosa prejudica o desempenho. Talvez apenas marginalmente, mas, como a resposta de Bill mostrou, o impacto é maior do que se imagina à primeira vista. Portanto, essa abordagem basicamente negocia desempenho versus memória.
      2. Se você tem um design em que é um caso de uso comum usar apenas partes da classe, isso sugere um problema com o próprio design: A classe em questão provavelmente tem mais de uma responsabilidade. A solução seria dividir a classe em várias classes mais focadas.
Daniel Hilgarth
fonte
4
@ JohnWillemse: Esse é um problema da sua arquitetura. Você deve refatorar suas aulas de forma que elas sejam menores e mais focadas. Não crie uma classe para 5 coisas / tarefas diferentes. Crie 5 classes.
Daniel Hilgarth
26
@ JohnWillemse Talvez considere isso um caso de otimização prematura então. A menos que você tenha um gargalo de desempenho / memória medido , recomendo isso, pois aumenta a complexidade e introduz problemas de encadeamento.
18713 Chris
2
+1, essa não é uma boa opção de design para 95% das classes. A inicialização preguiçosa tem seus prós, mas não deve ser generalizada para TODAS as propriedades. Acrescenta complexidade, dificuldade para ler o código, problemas de segurança de threads ... para nenhuma otimização perceptível em 99% dos casos. Além disso, como SolutionYogi disse como comentário, o código do OP é incorreto, o que prova que esse padrão não é trivial de implementar e deve ser evitado, a menos que seja realmente necessária uma inicialização lenta.
Ken2k
2
@DanielHilgarth Obrigado por percorrer todo o caminho para anotar (quase) tudo de errado em usar esse padrão incondicionalmente. Bom trabalho!
21413 Alex
1
@DanielHilgarth Bem, sim e não. A violação é a questão aqui, então sim. Mas também 'não' porque o POLS é estritamente o princípio de que você provavelmente não ficará surpreso com o código . Se o Foo não foi exposto fora do seu programa, é um risco que você pode correr ou não. Nesse caso, posso quase garantir que você acabará se surpreendendo , porque você não controla a maneira como as propriedades são acessadas. O risco acabou de se tornar um bug, e sua discussão sobre o nullcaso ficou muito mais forte. :-)
atlaste
49

É uma boa escolha de design. Altamente recomendado para código de biblioteca ou classes principais.

É chamado por alguma "inicialização lenta" ou "inicialização atrasada" e geralmente é considerado por todos como uma boa opção de design.

Primeiro, se você inicializar na declaração de variáveis ​​ou construtor no nível de classe, quando seu objeto for construído, você terá a sobrecarga de criar um recurso que nunca poderá ser usado.

Segundo, o recurso é criado apenas se necessário.

Terceiro, você evita a coleta de lixo de um objeto que não foi usado.

Por fim, é mais fácil lidar com exceções de inicialização que podem ocorrer na propriedade e depois com exceções que ocorrem durante a inicialização de variáveis ​​no nível de classe ou do construtor.

Existem exceções à esta regra.

Em relação ao argumento de desempenho da verificação adicional para inicialização na propriedade "get", é insignificante. A inicialização e o descarte de um objeto são uma ocorrência de desempenho mais significativa do que uma simples verificação de ponteiro nulo com um salto.

Diretrizes de design para o desenvolvimento de bibliotecas de classes em http://msdn.microsoft.com/en-US/library/vstudio/ms229042.aspx

A respeito de Lazy<T>

A Lazy<T>classe genérica foi criada exatamente para o que o pôster deseja, consulte Inicialização Lazy em http://msdn.microsoft.com/en-us/library/dd997286(v=vs.100).aspx . Se você possui versões mais antigas do .NET, deve usar o padrão de código ilustrado na pergunta. Esse padrão de código se tornou tão comum que a Microsoft considerou adequado incluir uma classe nas bibliotecas .NET mais recentes para facilitar a implementação do padrão. Além disso, se sua implementação precisar de segurança de encadeamento, você deverá adicioná-lo.

Tipos de dados primitivos e classes simples

Obvioulsy, você não vai usar a inicialização lenta para o tipo de dados primitivo ou uso de classe simples como List<string>.

Antes de comentar sobre o Lazy

Lazy<T> foi introduzido no .NET 4.0, portanto, não adicione mais outro comentário sobre esta classe.

Antes de comentar sobre micro-otimizações

Ao criar bibliotecas, você deve considerar todas as otimizações. Por exemplo, nas classes .NET, você verá matrizes de bits usadas para variáveis ​​de classe booleana em todo o código para reduzir o consumo e a fragmentação da memória, apenas para nomear duas "micro-otimizações".

Sobre interfaces de usuário

Você não usará a inicialização lenta para classes usadas diretamente pela interface do usuário. Na semana passada, passei a maior parte do dia removendo o carregamento lento de oito coleções usadas em um modelo de visualização para caixas de combinação. Eu tenho um LookupManagerque lida com carregamento lento e cache de coleções necessárias por qualquer elemento da interface do usuário.

"Setters"

Eu nunca usei uma propriedade set ("setters") para qualquer propriedade carregada lenta. Portanto, você nunca permitiria foo.Bar = null;. Se você precisar definir Bar, eu criaria um método chamado SetBar(Bar value)e não usaria a inicialização lenta

Colecções

As propriedades da coleção de classes sempre são inicializadas quando declaradas, porque nunca devem ser nulas.

Classes complexas

Deixe-me repetir isso de forma diferente, você usa a inicialização lenta para classes complexas. Que geralmente são classes mal projetadas.

Por último

Eu nunca disse isso para todas as classes ou em todos os casos. É um mau hábito.

AMissico
fonte
6
Se você pode chamar foo.Bar várias vezes em threads diferentes, sem nenhuma configuração de valor intermediária, mas obtém valores diferentes, então você tem uma classe ruim.
Lie Ryan
25
Eu acho que essa é uma regra ruim, sem muitas considerações. A menos que Bar seja um recurso conhecido, essa é uma micro otimização desnecessária. E, no caso em que o Bar consome muitos recursos, existe o Lazy <T> seguro para threads incorporado ao .net.
Andrew Hanlon
10
"é mais fácil lidar com exceções de inicialização que podem ocorrer na propriedade, em seguida, exceções que ocorrem durante a inicialização de variáveis ​​no nível de classe ou do construtor." - tudo bem, isso é bobagem. Se um objeto não puder ser inicializado por algum motivo, quero saber o mais rápido possível. Ou seja, imediatamente quando é construído. Existem bons argumentos a serem usados ​​para usar a inicialização lenta, mas não acho que seja uma boa ideia usá-la amplamente .
22813 millimoose
20
Estou realmente preocupado que outros desenvolvedores vejam essa resposta e pensem que essa é realmente uma boa prática (oh garoto). Essa é uma prática muito, muito ruim, se você a estiver usando incondicionalmente. Além do que já foi dito, você está tornando a vida de todos muito mais difícil (para desenvolvedores de clientes e desenvolvedores de manutenção) com tão pouco ganho (se é que algum ganho). Você deve ouvir os profissionais: Donald Knuth, na série The Art of Computer Programming, disse que "a otimização prematura é a raiz de todo mal". O que você está fazendo não é apenas mau, é diabólico!
Alex
4
Existem muitos indicadores de que você está escolhendo a resposta errada (e a decisão de programação errada). Você tem mais contras do que profissionais na sua lista. Você tem mais pessoas atestando contra isso do que a favor. Os membros mais experientes deste site (@BillK e @DanielHilgarth) publicados nesta pergunta são contra. Seu colega de trabalho já lhe disse que está errado. Sério, está errado! Se eu pegar um dos desenvolvedores da minha equipe (eu sou o líder da equipe) fazendo isso, ele levará um tempo limite de 5 minutos e será instruído sobre por que ele nunca deveria fazer isso.
9283 Alex
17

Você considera implementar esse padrão usando Lazy<T>?

Além da fácil criação de objetos com carregamento lento, você obtém segurança de thread enquanto o objeto é inicializado:

Como outros disseram, você carrega objetos preguiçosamente, se eles são realmente pesados ​​em recursos ou leva algum tempo para carregá-los durante o tempo de construção do objeto.

Matías Fidemraizer
fonte
Obrigado, eu o entendo agora e definitivamente irei investigar Lazy<T>agora e abster-me de usar da maneira que sempre fazia.
John Willemse
1
Você não obtém segurança com fio mágico ... ainda precisa pensar nisso. Do MSDN:Making the Lazy<T> object thread safe does not protect the lazily initialized object. If multiple threads can access the lazily initialized object, you must make its properties and methods safe for multithreaded access.
Eric J.
@EricJ. É claro é claro. Você só obtém segurança de thread ao inicializar o objeto, mas mais tarde precisa lidar com a sincronização como qualquer outro objeto.
Matías Fidemraizer
9

Eu acho que depende do que você está inicializando. Provavelmente eu não faria isso por uma lista, já que o custo de construção é muito pequeno, portanto pode ser incluído no construtor. Mas se fosse uma lista pré-preenchida, provavelmente não o faria até que fosse necessária pela primeira vez.

Basicamente, se o custo da construção exceder o custo de fazer uma verificação condicional em cada acesso, crie-o preguiçosamente. Caso contrário, faça-o no construtor.

Colin Mackay
fonte
Obrigado! Isso faz sentido.
John Willemse
9

A desvantagem que posso ver é que, se você quiser perguntar se Bars é nulo, nunca seria e você criaria a lista lá.

Luis Tellez
fonte
Eu não acho que isso é uma desvantagem.
Peter Porfy
Por que isso é uma desvantagem? Basta verificar com qualquer um deles contra nulo. if (Foo.Bars.Any ()!)
s.meijer
6
@PeterPorfy: viola o POLS . Você coloca null, mas não o recupera. Normalmente, você assume que recebe o mesmo valor que você coloca em uma propriedade.
Daniel Hilgarth
@DanielHilgarth Obrigado novamente. Esse é um argumento muito válido que eu não havia considerado antes.
John Willemse
6
@AMissico: Não é um conceito inventado. Da mesma forma que se espera que pressionar um botão ao lado de uma porta da frente toque uma campainha, espera-se que coisas que se parecem com propriedades se comportem como propriedades. Abrir um alçapão sob seus pés é um comportamento surpreendente, especialmente se o botão não estiver rotulado como tal.
21713 Bryan Boettcher
8

Instanciação / inicialização preguiçosa é um padrão perfeitamente viável. Porém, lembre-se de que, como regra geral, os consumidores da sua API não esperam que os getters e setters gastem um tempo discernível com o POV do usuário final (ou falhem).

Tormod
fonte
1
Eu concordo e editei minha pergunta um pouco. Espero que uma cadeia completa de construtores subjacentes leve mais tempo do que instanciar classes apenas quando elas forem necessárias.
John Willemse
8

Eu ia apenas comentar a resposta de Daniel, mas sinceramente não acho que isso seja suficiente.

Embora esse seja um padrão muito bom para usar em determinadas situações (por exemplo, quando o objeto é inicializado no banco de dados), é um hábito HORRÍVEL entrar.

Uma das melhores coisas de um objeto é que ele oferece um ambiente seguro e confiável. O melhor caso é se você fizer o maior número possível de campos "Final", preenchendo todos eles com o construtor. Isso torna sua classe bastante à prova de balas. Permitir que os campos sejam alterados por meio de levantadores é um pouco menos, mas não é terrível. Por exemplo:

classe SafeClass
{
    Nome da string = "";
    Idade inteira = 0;

    público vazio setName (String newName)
    {
        assert (newName! = null)
        name = newName;
    } // siga esse padrão por idade
    ...
    public String toString () {
        String s = "Classe segura tem nome:" + nome + "e idade:" + idade
    }
}

Com seu padrão, o método toString ficaria assim:

    if (nome == nulo)
        throw new IllegalStateException ("SafeClass entrou em um estado ilegal! nome é nulo")
    if (idade == nulo)
        throw new IllegalStateException ("O SafeClass entrou em um estado ilegal! a idade é nula")

    public String toString () {
        String s = "Classe segura tem nome:" + nome + "e idade:" + idade
    }

Não apenas isso, mas você precisa de verificações nulas em qualquer lugar que possa usar esse objeto em sua classe (Fora da sua classe é seguro por causa da verificação nula no getter, mas você deve usar principalmente os membros de suas classes dentro da classe)

Além disso, sua classe está perpetuamente em um estado incerto - por exemplo, se você decidiu fazer dessa classe uma classe de hibernação adicionando algumas anotações, como você faria isso?

Se você tomar qualquer decisão com base em alguma micro-optomização sem requisitos e testes, é quase certamente a decisão errada. De fato, há uma chance realmente muito boa de que seu padrão esteja realmente desacelerando o sistema, mesmo nas circunstâncias mais ideais, porque a instrução if pode causar uma falha de previsão de ramificação na CPU, o que atrasará muito as coisas muitas vezes mais do que apenas atribuindo um valor no construtor, a menos que o objeto que você esteja criando seja bastante complexo ou proveniente de uma fonte de dados remota.

Para um exemplo do problema de previsão de brancura (que você está enfrentando repetidamente, quase uma vez), veja a primeira resposta a esta pergunta impressionante: Por que é mais rápido processar uma matriz classificada do que uma matriz não classificada?

Bill K
fonte
Obrigado pela sua contribuição. No meu caso, nenhuma das classes possui métodos que possam precisar verificar nulo, portanto isso não é um problema. Vou levar em consideração suas outras objeções.
John Willemse
Eu realmente não entendo isso. Isso implica que você não está usando seus membros nas classes em que eles estão armazenados - que você está apenas usando as classes como estruturas de dados. Se for esse o caso, convém ler javaworld.com/javaworld/jw-01-2004/jw-0102-toolbox.html, que possui uma boa descrição de como seu código pode ser aprimorado, evitando a manipulação externa do estado do objeto. Se você as está manipulando internamente, como você faz isso sem verificar nulas tudo repetidamente?
Bill K
Partes desta resposta são boas, mas outras parecem artificial. Normalmente, ao usar esse padrão, toString()chamaria getName(), não use namediretamente.
Izkata
@ BillK Sim, as classes são uma estrutura de dados enorme. Todo o trabalho é feito em classes estáticas. Vou verificar o artigo no link. Obrigado!
John Willemse
1
@izkata Na verdade, dentro de uma turma, parece ser uma brincadeira o tempo em que você usa um getter ou não, na maioria dos lugares em que trabalhei usou o membro diretamente. Além disso, se você estiver sempre usando um getter, o método if () será ainda mais prejudicial, pois as falhas de previsão de ramificação ocorrerão com mais frequência E, devido à ramificação, o tempo de execução provavelmente terá mais problemas para manipular o getter. É tudo discutível, no entanto, com a revelação de John de que são estruturas de dados e classes estáticas, é com isso que eu mais me preocuparia.
Bill K
4

Permitam-me apenas acrescentar mais um ponto a muitos pontos positivos apresentados por outros ...

O depurador ( por padrão ) avaliará as propriedades ao percorrer o código, o que pode potencialmente instanciar o Barmais cedo do que normalmente aconteceria apenas executando o código. Em outras palavras, o mero ato de depuração está alterando a execução do programa.

Isso pode ou não ser um problema (dependendo dos efeitos colaterais), mas é algo para estar ciente.

Branko Dimitrijevic
fonte
2

Você tem certeza de que Foo deveria instanciar alguma coisa?

Para mim, parece fedorento (embora não necessariamente errado ) deixar Foo instanciar alguma coisa. A menos que seja o objetivo expresso de Foo ser uma fábrica, ela não deve instanciar seus próprios colaboradores, mas injetá-los em seu construtor .

Se, no entanto, o propósito de ser de Foo é criar instâncias do tipo Bar, não vejo nada de errado em fazê-lo preguiçosamente.

KaptajnKold
fonte
4
@BenjaminGruenbaum Não, não realmente. E respeitosamente, mesmo que fosse, que ponto você estava tentando fazer?
KaptajnKold