Cheguei atrasado a essas perguntas e respostas com ótimas respostas, mas eu queria me intrometer como estrangeiro acostumado a ver as coisas do ponto de vista de baixo nível de bits e bytes na memória.
Estou muito empolgado com designs imutáveis, mesmo vindo de uma perspectiva C e da perspectiva de encontrar novas maneiras de programar efetivamente esse hardware bestial que temos atualmente.
Mais lento / mais rápido
Quanto à questão de tornar as coisas mais lentas, seria uma resposta robótica yes
. Nesse tipo de nível conceitual muito técnico, a imutabilidade só pode tornar as coisas mais lentas. O hardware funciona melhor quando não está alocando esporadicamente a memória e pode apenas modificar a memória existente (por que temos conceitos como localidade temporal).
No entanto, uma resposta prática é maybe
. O desempenho ainda é em grande parte uma métrica de produtividade em qualquer base de código não trivial. Normalmente, não consideramos as bases de código de manutenção horrível que tropeçam nas condições de corrida como as mais eficientes, mesmo se desconsiderarmos os erros. A eficiência geralmente é uma função da elegância e simplicidade. O pico das micro-otimizações pode entrar em conflito, mas geralmente é reservado para as seções menores e mais críticas do código.
Transformando bits e bytes imutáveis
Vindo do ponto de vista de baixo nível, se fizermos radiografar conceitos como objects
e strings
assim por diante, no centro estão apenas bits e bytes em várias formas de memória com diferentes características de velocidade / tamanho (velocidade e tamanho do hardware da memória sendo tipicamente Mutualmente exclusivo).
A hierarquia de memória do computador gosta quando acessamos repetidamente o mesmo pedaço de memória, como no diagrama acima, pois ele mantém esse pedaço de memória acessado com frequência na forma mais rápida de memória (cache L1, por exemplo, que é quase tão rápido quanto um registro). Podemos acessar repetidamente exatamente a mesma memória (reutilizando-a várias vezes) ou acessar repetidamente diferentes seções do pedaço (por exemplo, percorrendo os elementos em um pedaço contíguo que acessa várias seções desse pedaço de memória).
Acabamos jogando uma chave de boca nesse processo, se modificar essa memória quiser criar um novo bloco de memória ao lado, da seguinte maneira:
... nesse caso, acessar o novo bloco de memória pode exigir falhas de página obrigatórias e falhas de cache para movê-lo de volta às formas mais rápidas de memória (até o registro). Isso pode ser um verdadeiro matador de desempenho.
Existem maneiras de atenuar isso, no entanto, usando um pool de reserva de memória pré-alocada, já tocada.
Agregados grandes
Outra questão conceitual que surge de uma visão de nível um pouco mais alto é simplesmente fazer cópias desnecessárias de agregados realmente grandes a granel.
Para evitar um diagrama excessivamente complexo, vamos imaginar que esse bloco de memória simples seja caro (talvez caracteres UTF-32 em um hardware incrivelmente limitado).
Nesse caso, se quiséssemos substituir "HELP" por "KILL" e esse bloco de memória fosse imutável, teríamos que criar um bloco totalmente novo para criar um novo objeto único, mesmo que apenas partes dele tenham mudado :
Ampliando um pouco a nossa imaginação, esse tipo de cópia profunda de todo o resto apenas para tornar uma pequena parte única pode ser bastante caro (nos casos do mundo real, esse bloco de memória seria muito, muito maior para representar um problema).
No entanto, apesar de tal despesa, esse tipo de design tende a ser muito menos propenso a erros humanos. Qualquer pessoa que tenha trabalhado em uma linguagem funcional com funções puras provavelmente pode apreciar isso, e especialmente em casos multithread, em que podemos multithread esse código sem se preocupar com o mundo. Em geral, programadores humanos tendem a tropeçar em mudanças de estado, especialmente aquelas que causam efeitos colaterais externos a estados fora do escopo de uma função atual. Mesmo a recuperação de um erro externo (exceção) nesse caso pode ser incrivelmente difícil com alterações de estado externas mutáveis no mix.
Uma maneira de mitigar esse trabalho redundante de cópia é transformar esses blocos de memória em uma coleção de ponteiros (ou referências) para caracteres, da seguinte maneira:
Desculpas, não percebi que não precisamos ser L
únicos ao fazer o diagrama.
Azul indica dados copiados rasos.
... infelizmente, isso seria incrivelmente caro pagar um custo de referência / ponteiro por personagem. Além disso, podemos espalhar o conteúdo dos caracteres por todo o espaço de endereço e acabar pagando por ele na forma de um monte de falhas de página e falhas de cache, tornando esta solução ainda pior do que copiar a coisa toda na sua totalidade.
Mesmo se tivéssemos o cuidado de alocar esses caracteres de forma contígua, digamos que a máquina possa carregar 8 caracteres e 8 ponteiros para um caractere em uma linha de cache. Acabamos carregando uma memória assim para percorrer a nova string:
Nesse caso, acabamos exigindo que 7 linhas de cache diferentes, com memória contígua, sejam carregadas para atravessar essa string, quando idealmente precisamos apenas de 3.
Repartir os dados
Para atenuar o problema acima, podemos aplicar a mesma estratégia básica, mas com um nível mais grosso de 8 caracteres, por exemplo
O resultado requer que 4 linhas de cache com valor de dados (1 para os 3 ponteiros e 3 para os caracteres) sejam carregadas para percorrer essa sequência, que é apenas 1 a menos do ideal teórico.
Então isso não é ruim. Há algum desperdício de memória, mas a memória é abundante e o uso de mais não diminui a velocidade se a memória extra for apenas para dados frios que não são acessados com frequência. É apenas para dados contíguos e quentes, onde o uso e a velocidade reduzidos da memória geralmente andam de mãos dadas, onde queremos colocar mais memória em uma única página ou linha de cache e acessar tudo isso antes da remoção. Essa representação é bastante amigável ao cache.
Rapidez
Portanto, utilizar uma representação como a acima pode fornecer um equilíbrio decente de desempenho. Provavelmente, os usos mais críticos para o desempenho de estruturas de dados imutáveis assumem essa natureza de modificação de pedaços de dados em pedaços e os tornam únicos no processo, enquanto copia superficialmente partes não modificadas. Também implica em algumas despesas gerais das operações atômicas para fazer referência às partes copiadas rasas com segurança em um contexto multithread (possivelmente com alguma contagem de referência atômica acontecendo).
No entanto, desde que esses dados robustos sejam representados em um nível suficientemente grosseiro, grande parte dessa sobrecarga diminui e é possivelmente trivializada, enquanto ainda nos oferece a segurança e a facilidade de codificar e multithreading mais funções de uma forma pura, sem lado externo efeitos
Mantendo dados novos e antigos
Onde eu vejo a imutabilidade como potencialmente mais útil do ponto de vista do desempenho (em um sentido prático) é quando podemos ser tentados a fazer cópias inteiras de dados grandes, a fim de torná-los únicos em um contexto mutável, onde o objetivo é produzir algo novo. algo que já existe de uma maneira em que queremos manter novos e antigos, quando poderíamos apenas tornar pequenos pedaços dele únicos com um design imutável cuidadoso.
Exemplo: Desfazer Sistema
Um exemplo disso é um sistema de desfazer. Podemos alterar uma pequena parte de uma estrutura de dados e queremos manter o formulário original para o qual podemos desfazer e o novo formulário. Com esse tipo de design imutável que apenas torna pequenas seções modificadas da estrutura de dados, podemos simplesmente armazenar uma cópia dos dados antigos em uma entrada de desfazer, pagando apenas o custo da memória dos dados de porções exclusivas adicionados. Isso fornece um equilíbrio muito eficaz de produtividade (tornando a implementação de um sistema de desfazer um pedaço de bolo) e desempenho.
Interfaces de alto nível
No entanto, algo estranho surge com o caso acima. Em um tipo local de contexto de função, dados mutáveis costumam ser os mais fáceis e diretos de modificar. Afinal, a maneira mais fácil de modificar uma matriz é frequentemente percorrê-la e modificar um elemento de cada vez. Podemos acabar aumentando a sobrecarga intelectual se tivéssemos um grande número de algoritmos de alto nível para escolher para transformar uma matriz e tivéssemos que escolher o apropriado para garantir que todas essas cópias superficiais e grossas sejam feitas enquanto as partes modificadas são feito único.
Provavelmente, a maneira mais fácil nesses casos é usar buffers mutáveis localmente dentro do contexto de uma função (onde eles normalmente não nos enganam) que confirmam alterações atomicamente na estrutura de dados para obter uma nova cópia imutável (acredito que alguns idiomas chamam esses "transitórios") ...
... ou podemos simplesmente modelar funções de transformação de nível superior e superior sobre os dados para ocultar o processo de modificação de um buffer mutável e comprometê-lo com a estrutura sem a lógica mutável envolvida. De qualquer forma, esse ainda não é um território amplamente explorado, e temos nosso trabalho cortado se abraçarmos projetos imutáveis mais para criar interfaces significativas de como transformar essas estruturas de dados.
Estruturas de dados
Outra coisa que surge aqui é que a imutabilidade usada em um contexto crítico de desempenho provavelmente desejará que as estruturas de dados se dividam em dados em pedaços onde os pedaços não são muito pequenos em tamanho, mas também não são muito grandes.
As listas vinculadas podem querer mudar um pouco para acomodar isso e se transformar em listas não roladas. Matrizes grandes e contíguas podem se transformar em uma matriz de ponteiros em blocos contíguos com indexação de módulo para acesso aleatório.
Ele potencialmente muda a maneira como olhamos para as estruturas de dados de uma maneira interessante, enquanto pressiona as funções de modificação dessas estruturas de dados para se parecer com uma natureza mais volumosa e ocultar a complexidade extra na cópia superficial de alguns bits aqui e na criação de outros bits únicos lá.
atuação
Enfim, esta é minha pequena visão de nível inferior sobre o tópico. Teoricamente, a imutabilidade pode ter um custo que varia de muito grande a menor. Mas uma abordagem muito teórica nem sempre faz com que os aplicativos sejam rápidos. Pode torná-los escaláveis, mas a velocidade do mundo real geralmente exige a adoção de uma mentalidade mais prática.
Do ponto de vista prático, qualidades como desempenho, manutenção e segurança tendem a se transformar em um grande borrão, especialmente para uma base de código muito grande. Embora o desempenho, em certo sentido absoluto, seja degradado pela imutabilidade, é difícil argumentar sobre os benefícios que ele tem sobre a produtividade e a segurança (incluindo a segurança da rosca). Com esse aumento, muitas vezes pode haver um aumento no desempenho prático, mesmo que os desenvolvedores tenham mais tempo para ajustar e otimizar seu código sem serem invadidos por bugs.
Então, acho que, desse sentido prático, estruturas de dados imutáveis podem realmente ajudar o desempenho em muitos casos, por mais estranho que pareça. Um mundo ideal pode procurar uma mistura desses dois: estruturas de dados imutáveis e mutáveis, com as mutáveis normalmente sendo muito seguras para uso em um escopo muito local (ex: local para uma função), enquanto as imutáveis podem evitar o lado externo efetua completamente e transforma todas as alterações em uma estrutura de dados em uma operação atômica, produzindo uma nova versão sem risco de condições de corrida.