Em que ponto as classes imutáveis ​​se tornam um fardo?


16

Ao projetar classes para manter seu modelo de dados que eu li, pode ser útil criar objetos imutáveis, mas em que momento o ônus das listas de parâmetros do construtor e cópias profundas se torna muito alto e você precisa abandonar a restrição imutável?

Por exemplo, aqui está uma classe imutável para representar uma coisa nomeada (estou usando a sintaxe C #, mas o princípio se aplica a todas as linguagens OO)

class NamedThing
{
    private string _name;    
    public NamedThing(string name)
    {
        _name = name;
    }    
    public NamedThing(NamedThing other)
    {
         this._name = other._name;
    }
    public string Name
    {
        get { return _name; }
    }
}

Os itens nomeados podem ser construídos, consultados e copiados para novos itens nomeados, mas o nome não pode ser alterado.

Tudo isso é bom, mas o que acontece quando eu quero adicionar outro atributo? Eu tenho que adicionar um parâmetro ao construtor e atualizar o construtor de cópia; o que não é muito trabalhoso, mas os problemas começam, tanto quanto posso ver, quando quero tornar um objeto complexo imutável.

Se a classe contiver vários atributos e coleções, contendo outras classes complexas, parece-me que a lista de parâmetros do construtor se tornaria um pesadelo.

Então, em que ponto uma classe se torna complexa demais para ser imutável?


Eu sempre faço um esforço para tornar as aulas do meu modelo imutáveis. Se você tem uma lista enorme e longa de parâmetros de contratantes, talvez sua classe seja muito grande e possa ser dividida? Se seus objetos de nível inferior também são imutáveis ​​e seguem o mesmo padrão, seus objetos de nível superior não devem sofrer (muito). Acho MUITO mais difícil alterar uma classe existente para tornar-se imutável do que tornar um modelo de dados imutável quando estou começando do zero.
Ninguém

1
Você pode olhar para o padrão do Construtor sugerido nesta pergunta: stackoverflow.com/questions/1304154/…
Ant

Você já viu o MemberwiseClone? Você não precisa atualizar o construtor de cópias para cada novo membro.
kevin Cline

3
@ Tony Se as suas coleções e tudo o que elas contêm também são imutáveis, você não precisa de uma cópia profunda, uma cópia superficial é suficiente.
Mjcopple

2
Como um aparte, eu normalmente uso campos "definir uma vez" em classes em que a classe precisa ser "razoavelmente" imutável, mas não completamente. Acho que isso resolve o problema de grandes construtores, mas fornece a maioria dos benefícios de classes imutáveis. (nomeadamente, o seu código de classe interna não ter que se preocupar com uma mudança de valor)
Earlz

Respostas:


23

Quando eles se tornam um fardo? Muito rapidamente (especialmente se o seu idioma de escolha não fornecer suporte sintático suficiente para imutabilidade.)

A imutabilidade está sendo vendida como a bala de prata para o dilema multinúcleo e tudo isso. Mas a imutabilidade na maioria dos idiomas OO obriga a adicionar artefatos e práticas artificiais em seu modelo e processo. Para cada classe imutável complexa, você deve ter um construtor igualmente complexo (pelo menos internamente). Não importa como você o projete, ele apresenta um forte acoplamento (portanto, é melhor ter um bom motivo para apresentá-los).

Não é necessariamente possível modelar tudo em pequenas classes não complexas. Portanto, para grandes classes e estruturas, nós as particionamos artificialmente - não porque isso faça sentido em nosso modelo de domínio, mas porque temos que lidar com suas instâncias complexas e construtores de código.

É ainda pior quando as pessoas levam a idéia de imutabilidade longe demais em uma linguagem de propósito geral como Java ou C #, tornando tudo imutável. Como resultado, você vê pessoas forçando construções de expressão s em idiomas que não suportam essas coisas com facilidade.

Engenharia é o ato de modelar através de compromissos e trade-offs. Tornar tudo imutável por decreto porque alguém lê que tudo é imutável na linguagem funcional X ou Y (um modelo de programação completamente diferente), isso não é aceitável. Isso não é uma boa engenharia.

Coisas pequenas, possivelmente unitárias, podem ser imutáveis. Coisas mais complexas podem ser tornadas imutáveis ​​quando faz sentido . Mas a imutabilidade não é uma bala de prata. A capacidade de reduzir erros, aumentar a escalabilidade e o desempenho, não é a única função da imutabilidade. É uma função de práticas de engenharia adequadas . Afinal, as pessoas escreveram software bom e escalável, sem imutabilidade.

A imutabilidade se torna um fardo muito rápido (isso aumenta a complexidade acidental) se for feita sem uma razão, quando for feita fora do que faz sentido no contexto de um modelo de domínio.

Eu, por exemplo, tento evitá-lo (a menos que esteja trabalhando em uma linguagem de programação com bom suporte sintático para isso).


6
luis, você notou como as respostas bem escritas e pragmaticamente corretas, escritas com uma explicação de princípios de engenharia simples, porém sólidos, tendem a não receber tantos votos quanto aqueles que usam modismos de codificação de ponta? Esta é uma ótima, ótima resposta.
Huperniketes

3
Obrigado :) Eu já notei as tendências, mas tudo bem. Fanboys da moda churn código que temos mais tarde chegar a reparação a taxas melhor por hora, hahah :) jk ...
luis.espinal

4
Bala de prata? não. Vale o pouco de constrangimento em C # / Java (não é tão ruim assim)? Absolutamente. Além disso, o papel da multicore na imutabilidade é bem menor ... o benefício real é a facilidade de raciocínio.
Mauricio Scheffer

@Mauricio - se você diz (que imutabilidade em Java não é tão ruim). Tendo trabalhado em Java de 1998 a 2011, eu imploraria para diferir, não é isento trivial em bases de código simples. No entanto, as pessoas têm experiências diferentes e reconheço que meu POV não está livre de subjetividade. Sinto muito, não posso concordar lá. Eu concordo, no entanto, com a facilidade de raciocínio ser a coisa mais importante quando se trata de imutabilidade.
Luis.espinal 15/03/2012

13

Passei por uma fase de insistir em que as aulas fossem imutáveis ​​sempre que possível. Os construtores tinham praticamente tudo, matrizes imutáveis, etc. etc. Achei que a resposta para sua pergunta é simples: em que momento as classes imutáveis ​​se tornam um fardo? Muito rapidamente. Assim que você deseja serializar algo, você deve ser capaz de desserializar, o que significa que deve ser mutável; assim que você deseja usar um ORM, a maioria deles insiste em que as propriedades sejam mutáveis. E assim por diante.

Acabei substituindo essa política por interfaces imutáveis ​​para objetos mutáveis.

class NamedThing : INamedThing
{
    private string _name;    
    public NamedThing(string name)
    {
        _name = name;
    }    

    public NamedThing(NamedThing other)
    {
        this._name = other._name;
    }

    public string Name
    {
        get { return _name; }
        set { _name = value; }
    }
}

interface INamedThing
{
    string Name { get; }
}

Agora, o objeto tem flexibilidade, mas você ainda pode dizer ao código de chamada que ele não deve editar essas propriedades.


8
Queixas menores à parte, eu concordo que objetos imutáveis ​​podem se tornar uma dor de cabeça muito rapidamente ao programar em uma linguagem imperativa, mas não tenho muita certeza se uma interface imutável realmente resolve o mesmo problema ou qualquer problema. A principal razão para usar um objeto imutável é para que você possa jogá-lo em qualquer lugar a qualquer momento e nunca ter que se preocupar com alguém que corrompa seu estado. Se o objeto subjacente é mutável, você não tem essa garantia, especialmente se o motivo para mantê-lo mutável foi porque várias coisas precisam modificá-lo.
precisa saber é o seguinte

1
@Aaronaught - ponto interessante. Eu acho que é uma coisa psicológica mais do que uma proteção real. No entanto, sua última linha tem uma premissa falsa. A razão para mantê-lo mutável é mais do que várias coisas precisam instanciar e preencher via reflexão, para não sofrer mutação uma vez instanciada.
Pd #

1
@Aaronaught: A mesma maneira IComparable<T>garante que se X.CompareTo(Y)>0e Y.CompareTo(Z)>0, então X.CompareTo(Z)>0. As interfaces têm contratos . Se o contrato para IImmutableList<T>especificar que os valores de todos os itens e propriedades devem ser "gravados em pedra" antes que qualquer instância seja exposta ao mundo externo, todas as implementações legítimas o farão. Nada impede que uma IComparableimplementação viole a transitividade, mas as implementações que o fazem são ilegítimas. Se um SortedDictionarymau funcionamento quando dado um ilegítimo IComparable, ... #
237

1
@Aaronaught: Por que devo confiar que as implementações de IReadOnlyList<T>serão imutáveis, uma vez que (1) esse requisito não está declarado na documentação da interface e (2) a implementação mais comum List<T>, nem é somente leitura ? Não estou muito claro o que é ambíguo sobre meus termos: uma coleção é legível se os dados contidos puderem ser lidos. É somente leitura se puder prometer que os dados contidos não podem ser alterados, a menos que alguma referência externa seja mantida pelo código que os alteraria. É imutável se pode garantir que não pode ser alterado, ponto final.
supercat 23/02

1
@ supercat: Aliás, a Microsoft concorda comigo. Eles lançaram um pacote de coleções imutáveis e percebem que são todos tipos concretos, porque você nunca pode garantir que uma classe ou interface abstrata seja realmente imutável.
Aaronaught

8

Eu não acho que haja uma resposta geral para isso. Quanto mais complexa é uma classe, mais difícil é argumentar sobre suas mudanças de estado e mais caro é criar novas cópias dela. Portanto, acima de algum nível (pessoal) de complexidade, será muito doloroso tornar uma classe imutável.

Observe que uma classe muito complexa ou uma longa lista de parâmetros de métodos são odores de design em si, independentemente da imutabilidade.

Portanto, normalmente, a solução preferida seria dividir essa classe em várias classes distintas, cada uma das quais pode ser transformada por mutação ou imutabilidade por si só. Se isso não for viável, pode ser alterado.


5

Você pode evitar o problema de cópia se armazenar todos os seus campos imutáveis ​​em um interior struct. Isso é basicamente uma variação do padrão de lembrança. Então, quando você quiser fazer uma cópia, basta copiar a lembrança:

class MyClass
{
    struct Memento
    {
        public int field1;
        public string field2;
    }

    private readonly Memento memento;

    public MyClass(int field1, string field2)
    {
        this.memento = new Memento()
            {
                field1 = field1,
                field2 = field2
            };
    }

    private MyClass(Memento memento) // for copying
    {
        this.memento = memento;
    }

    public int Field1 { get { return this.memento.field1; } }
    public string Field2 { get { return this.memento.field2; } }

    public MyClass WithNewField1(int newField1)
    {
        Memento newMemento = this.memento;
        newMemento.field1 = newField1;
        return new MyClass(newMemento);
    }
}

Eu acho que a estrutura interna não é necessária. É apenas outra maneira de fazer o MemberwiseClone.
Codism

@ Codism - sim e não. Há momentos em que você pode precisar de outros membros que não deseja clonar. E se você estivesse usando a avaliação lenta em um de seus getters e armazenando em cache o resultado em um membro? Se você fizer um MemberwiseClone, clonará o valor em cache e alterará um de seus membros dos quais o valor em cache depende. É mais limpo separar o estado do cache.
Scott Whitlock

Vale a pena mencionar outra vantagem da estrutura interna: facilita para um objeto copiar seu estado para um objeto ao qual existem outras referências . Uma fonte comum de ambiguidade no OOP é se um método que retorna uma referência de objeto está retornando uma exibição de um objeto que pode mudar fora do controle do destinatário. Se, em vez de retornar uma referência de objeto, um método aceitar uma referência de objeto do chamador e copiar o estado para ele, a propriedade do objeto será muito mais clara. Essa abordagem não funciona bem com tipos livremente herdáveis, mas ...
supercat

... pode ser muito útil com titulares de dados mutáveis. A abordagem também facilita a criação de classes mutáveis ​​e imutáveis ​​"paralelas" (derivadas de uma base "legível" abstrata) e permite que seus construtores possam copiar dados uns dos outros.
Supercat 23/02

3

Você tem algumas coisas trabalhando aqui. Conjuntos de dados imutáveis ​​são ótimos para escalabilidade multithread. Essencialmente, você pode otimizar bastante sua memória para que um conjunto de parâmetros seja uma instância da classe - em qualquer lugar. Como os objetos nunca mudam, você não precisa se preocupar em sincronizar para acessar seus membros. É uma coisa boa. No entanto, como você aponta, quanto mais complexo o objeto, mais você precisará de alguma mutabilidade. Eu começaria com o raciocínio ao longo destas linhas:

  • Existe alguma razão comercial para que um objeto possa mudar de estado? Por exemplo, um objeto de usuário armazenado em um banco de dados é exclusivo com base em seu ID, mas deve ser capaz de alterar o estado ao longo do tempo. Por outro lado, quando você altera as coordenadas em uma grade, ela deixa de ser a coordenada original e, portanto, faz sentido tornar as coordenadas imutáveis. O mesmo com as cordas.
  • Alguns dos atributos podem ser calculados? Em resumo, se os outros valores na nova cópia de um objeto são função de algum valor principal que você transmite, é possível computá-los no construtor ou sob demanda. Isso reduz a quantidade de manutenção, pois você pode inicializar esses valores da mesma maneira em copiar ou criar.
  • Quantos valores compõem o novo objeto imutável? Em algum momento, a complexidade de criar um objeto se torna não trivial e, nesse ponto, ter mais instâncias do objeto pode se tornar um problema. Os exemplos incluem estruturas de árvores imutáveis, objetos com mais de três parâmetros passados, etc. Quanto mais parâmetros, maior a possibilidade de alterar a ordem dos parâmetros ou anular a incorreta.

Em idiomas que oferecem suporte apenas a objetos imutáveis ​​(como Erlang), se houver alguma operação que pareça modificar o estado de um objeto imutável, o resultado final será uma nova cópia do objeto com o valor atualizado. Por exemplo, quando você adiciona um item a um vetor / lista:

myList = lists:append([[1,2,3], [4,5,6]])
% myList is now [1,2,3,4,5,6]

Essa pode ser uma maneira sensata de trabalhar com objetos mais complicados. À medida que você adiciona um nó da árvore, por exemplo, o resultado é uma nova árvore com o nó adicionado. O método no exemplo acima retorna uma nova lista. No exemplo deste parágrafo tree.add(newNode), retornaria uma nova árvore com o nó adicionado. Para os usuários, fica fácil trabalhar com eles. Para os escritores da biblioteca, torna-se entediante quando o idioma não suporta cópias implícitas. Esse limite é de sua própria paciência. Para os usuários da sua biblioteca, o limite mais sensato que encontrei é de três a quatro topos de parâmetros.


Se alguém estiver inclinado a usar uma referência de objeto mutável como um valor [o que significa que nenhuma referência está contida em seu proprietário e nunca é exposta], construir um novo objeto imutável que mantenha o conteúdo "alterado" desejado é equivalente a modificar o objeto diretamente, embora seja provavelmente mais lento. Objetos mutáveis, no entanto, também podem ser usados ​​como entidades . Como alguém faria coisas que se comportam como entidades sem objetos mutáveis?
Supercat 23/02

0

Se você possui vários membros da classe final e não deseja que eles sejam expostos a todos os objetos que precisam criá-lo, é possível usar o padrão do construtor:

class NamedThing
{
    private string _name;    
    private string _value;
    private NamedThing(string name, string value)
    {
        _name = name;
        _value = value;
    }    
    public NamedThing(NamedThing other)
    {
        this._name = other._name;
        this._value = other._value;
    }
    public string Name
    {
        get { return _name; }
    }

    public static class Builder {
        string _name;
        string _value;

        public void setValue(string value) {
            _value = value;
        }
        public void setName(string name) {
            _name = name;
        }
        public NamedThing newObject() {
            return new NamedThing(_name, _value);
        }
    }
}

a vantagem é que você pode criar facilmente um novo objeto com apenas um valor diferente de um nome diferente.


Eu acho que seu construtor sendo estático não está correto. Outro encadeamento pode alterar o nome estático ou o valor estático depois que você os define, mas antes de ligar newObject.
ErikE

Somente a classe do construtor é estática, mas seus membros não são. Isso significa que, para cada construtor criado, eles têm seu próprio conjunto de membros com valores correspondentes. A classe precisa ser estático para que ele possa ser usado e instanciado fora da classe que contém ( NamedThingneste caso)
Salandur

Entendo o que você está dizendo, apenas vejo um problema com isso, porque não leva um desenvolvedor a "cair no poço do sucesso". O fato de usar variáveis ​​estáticas significa que, se a Builderé reutilizada, existe um risco real de que eu mencionei. Alguém pode estar construindo muitos objetos e decidir que, como a maioria das propriedades é a mesma, simplesmente reutilizar Buildere, de fato, vamos torná-lo um singleton global que é injetado em dependência! Ops. Principais erros introduzidos. Então, acho que esse padrão de instanciado versus estático misto é ruim.
ErikE

1
@Salandur Classes internas em C # sempre são "estáticas" no sentido da classe interna Java.
Sebastian Redl

0

Então, em que ponto uma classe se torna complexa demais para ser imutável?

Na minha opinião, não vale a pena se preocupar em tornar as turmas pequenas imutáveis ​​em idiomas como o que você está mostrando. Estou usando aqui pequeno e não é complexo , porque mesmo que você adicione dez campos a essa classe e realmente realize operações sofisticadas, duvido que serão necessários kilobytes e muito menos megabytes e muito menos gigabytes, portanto, qualquer função usando instâncias de seu A classe pode simplesmente fazer uma cópia barata de todo o objeto para evitar modificar o original, se quiser evitar causar efeitos colaterais externos.

Estruturas de dados persistentes

Onde eu acho que o uso pessoal para imutabilidade é a grande estrutura de dados central que agrega um monte de dados pequeninos, como instâncias da classe que você está mostrando, como uma que armazena um milhão NamedThings. Pertencendo a uma estrutura de dados persistente que é imutável e estando atrás de uma interface que permite apenas acesso somente leitura, os elementos que pertencem ao contêiner se tornam imutáveis ​​sem que a classe de elemento ( NamedThing) precise lidar com ele.

Cópias baratas

A estrutura de dados persistente permite que regiões dele sejam transformadas e tornadas únicas, evitando modificações no original sem precisar copiar a estrutura de dados em sua totalidade. Essa é a verdadeira beleza disso. Se você deseja escrever ingenuamente funções que evitam efeitos colaterais que inserem uma estrutura de dados que consome gigabytes de memória e modifica apenas o valor de memória de um megabyte, copie toda a loucura para evitar tocar na entrada e retornar um novo resultado. Ou copia gigabytes para evitar efeitos colaterais ou causar efeitos colaterais nesse cenário, fazendo com que você escolha entre duas opções desagradáveis.

Com uma estrutura de dados persistente, ele permite que você escreva uma função e evite fazer uma cópia de toda a estrutura de dados, exigindo apenas cerca de um megabyte de memória extra para a saída se sua função transformar apenas o valor de memória de um megabyte.

Carga

Quanto ao fardo, há um imediato, pelo menos no meu caso. Eu preciso dos construtores sobre os quais as pessoas estão falando ou "transientes", como eu os chamo, para poder expressar efetivamente transformações nessa estrutura de dados maciça sem tocá-la. Código como este:

void transform_stuff(MutList<Stuff>& stuff, int first, int last)
{
     // Transform stuff in the range, [first, last).
     for (; first != last; ++first)
          transform(stuff[first]);
}

... então tem que ser escrito assim:

ImmList<Stuff> transform_stuff(ImmList<Stuff> stuff, int first, int last)
{
     // Grab a "transient" (builder) list we can modify:
     TransientList<Stuff> transient(stuff);

     // Transform stuff in the range, [first, last)
     // for the transient list.
     for (; first != last; ++first)
          transform(transient[first]);

     // Commit the modifications to get and return a new
     // immutable list.
     return stuff.commit(transient);
}

Mas, em troca dessas duas linhas de código extras, agora é seguro chamar a função através de threads com a mesma lista original, não causa efeitos colaterais, etc. Também facilita muito tornar essa operação uma ação do usuário desfavorável, já que o desfazer pode apenas armazenar uma cópia rasa barata da lista antiga.

Segurança de exceção ou recuperação de erros

Nem todo mundo pode se beneficiar tanto quanto eu de estruturas de dados persistentes em contextos como esses (achei muito útil para eles em sistemas de desfazer e edição não destrutiva, que são conceitos centrais no meu domínio VFX), mas uma coisa se aplica a praticamente todo mundo a considerar é segurança de exceção ou recuperação de erros .

Se você deseja tornar segura a exceção da função de mutação original, ela precisa de lógica de reversão, para a qual a implementação mais simples requer a cópia de toda a lista:

void transform_stuff(MutList<Stuff>& stuff, int first, int last)
{
    // Make a copy of the whole massive gigabyte-sized list 
    // in case we encounter an exception and need to rollback
    // changes.
    MutList<Stuff> old_stuff = stuff;

    try
    {
         // Transform stuff in the range, [first, last).
         for (; first != last; ++first)
             transform(stuff[first]);
    }
    catch (...)
    {
         // If the operation failed and ran into an exception,
         // swap the original list with the one we modified
         // to "undo" our changes.
         stuff.swap(old_stuff);
         throw;
    }
}

Nesse ponto, a versão mutável com exceção de segurança é ainda mais cara em termos de computação e sem dúvida ainda mais difícil de escrever corretamente do que a versão imutável usando um "construtor". E muitos desenvolvedores de C ++ apenas negligenciam a segurança de exceção e talvez isso seja bom para o domínio deles, mas no meu caso, eu gostaria de garantir que meu código funcione corretamente mesmo no caso de uma exceção (mesmo escrevendo testes que deliberadamente lançam exceções para testar exceção) segurança), e isso torna necessário que eu seja capaz de reverter quaisquer efeitos colaterais que uma função cause a meio caminho da função, se alguma coisa for lançada.

Quando você deseja ser protegido contra exceções e se recuperar de erros normalmente sem que seu aplicativo falhe e queime, você deve reverter / desfazer quaisquer efeitos colaterais que uma função possa causar no caso de um erro / exceção. E aí o construtor pode realmente economizar mais tempo do programador do que o custo, juntamente com o tempo computacional, porque: ...

Você não precisa se preocupar em reverter os efeitos colaterais em uma função que não causa nenhum efeito!

Então, voltando à questão fundamental:

Em que ponto as classes imutáveis ​​se tornam um fardo?

Eles sempre são um fardo em idiomas que giram mais em torno da mutabilidade do que da imutabilidade, e é por isso que acho que você deve usá-los onde os benefícios superam significativamente os custos. Mas em um nível amplo o suficiente para estruturas de dados grandes o suficiente, acredito que há muitos casos em que é uma troca valiosa.

Também na minha, eu tenho apenas alguns tipos de dados imutáveis ​​e todas elas são estruturas de dados enormes destinadas a armazenar um grande número de elementos (pixels de uma imagem / textura, entidades e componentes de um ECS e vértices / bordas / polígonos de uma malha).

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.