Na programação funcional, ter a maioria das estruturas de dados imutáveis ​​requer mais uso de memória?

63

Na programação funcional, uma vez que quase toda estrutura de dados é imutável, quando o estado precisa mudar, uma nova estrutura é criada. Isso significa muito mais uso de memória? Conheço bem o paradigma de programação orientada a objetos, agora estou tentando aprender sobre o paradigma de programação funcional. O conceito de tudo ser imutável me confunde. Parece que um programa usando estruturas imutáveis ​​exigiria muito mais memória do que um programa com estruturas mutáveis. Estou olhando para isso da maneira certa?

Jbemmz
fonte
7
Isso pode significar isso, mas a maioria das estruturas de dados imutáveis ​​reutiliza os dados subjacentes para as alterações. Eric Lippert tem uma ótima série de blogs sobre imutabilidade em C #
Oded
3
Gostaria de dar uma olhada em estruturas de dados puramente funcional, é um grande livro que é escrito pelo mesmo cara que escreveu a maior parte de biblioteca recipiente de Haskell (embora o livro é principalmente SML)
jozefg
11
Esta resposta, relacionada ao tempo de execução em vez do consumo de memória, também pode ser interessante para você: stackoverflow.com/questions/1990464/…
9000
11
Você pode achar isso interessante: en.wikipedia.org/wiki/Static_single_assignment_form
Sean McSomething

Respostas:

35

A única resposta correta para isso é "às vezes". Existem muitos truques que as linguagens funcionais podem usar para evitar desperdiçar memória. A imutabilidade facilita o compartilhamento de dados entre funções e até entre estruturas de dados, pois o compilador pode garantir que os dados não serão modificados. Linguagens funcionais tendem a incentivar o uso de estruturas de dados que podem ser usadas eficientemente como estruturas imutáveis ​​(por exemplo, árvores em vez de tabelas de hash). Se você adicionar preguiça à mistura, como muitas linguagens funcionais, isso adiciona novas maneiras de economizar memória (também adiciona novas maneiras de desperdiçar memória, mas não vou entrar nisso).

Dirk Holsopple
fonte
24

Na programação funcional, já que quase toda estrutura de dados é imutável, quando o estado precisa mudar, uma nova estrutura é criada. Isso significa muito mais uso de memória?

Isso depende da estrutura dos dados, das mudanças exatas que você executou e, em alguns casos, do otimizador. Como um exemplo, vamos considerar anexar a uma lista:

list2 = prepend(42, list1) // list2 is now a list that contains 42 followed
                           // by the elements of list1. list1 is unchanged

Aqui, o requisito adicional de memória é constante - assim como o custo de tempo de execução da chamada prepend. Por quê? Porque prependsimplesmente cria uma nova célula que tem 42como cabeça e list1cauda. Não é necessário copiar ou iterar list2para conseguir isso. Ou seja, exceto a memória necessária para armazenar 42, list2reutiliza a mesma memória usada por list1. Como as duas listas são imutáveis, esse compartilhamento é perfeitamente seguro.

Da mesma forma, ao trabalhar com estruturas de árvore balanceadas, a maioria das operações requer apenas uma quantidade logarítmica de espaço adicional, pois tudo, exceto um caminho da árvore, pode ser compartilhado.

Para matrizes, a situação é um pouco diferente. É por isso que, em muitas linguagens FP, matrizes não são tão comumente usadas. No entanto, se você fizer algo parecido arr2 = map(f, arr1)e arr1nunca for usado novamente após essa linha, um otimizador inteligente pode realmente criar código que sofre mutação em arr1vez de criar uma nova matriz (sem afetar o comportamento do programa). Nesse caso, o desempenho será como em uma linguagem imperativa, é claro.

sepp2k
fonte
11
Por interesse, qual implementação de quais idiomas reutiliza o espaço, como você descreveu no final?
@delnan Na minha universidade, havia uma linguagem de pesquisa chamada Qube, que fazia isso. Não sei se existe alguma linguagem usada na natureza que faça isso. No entanto, a fusão de Haskell pode alcançar o mesmo efeito em muitos casos.
sepp2k
7

Implementações ingênuas realmente exporiam esse problema - quando você cria uma nova estrutura de dados em vez de atualizar uma existente, é necessário ter alguma sobrecarga.

Idiomas diferentes têm maneiras diferentes de lidar com isso, e existem alguns truques que a maioria deles usa.

Uma estratégia é a coleta de lixo . No momento em que a nova estrutura foi criada, ou logo após, as referências à estrutura antiga ficam fora do escopo, e o coletor de lixo a coleta instantaneamente ou em breve, dependendo do algoritmo do GC. Isso significa que, embora ainda haja uma sobrecarga, ela é apenas temporária e não cresce linearmente com a quantidade de dados.

Outro é escolher diferentes tipos de estruturas de dados. Onde matrizes são a estrutura de dados de lista de acesso em linguagens imperativas (geralmente agrupadas em algum tipo de contêiner de realocação dinâmica, como std::vectorem C ++), as linguagens funcionais geralmente preferem listas vinculadas. Com uma lista vinculada, uma operação de pré-adição ('contras') pode reutilizar a lista existente como a cauda da nova lista; portanto, tudo o que realmente é alocado é o novo cabeçalho da lista. Existem estratégias semelhantes para outros tipos de estruturas de dados - conjuntos, árvores, etc.

E depois há uma avaliação preguiçosa, à Haskell. A idéia é que as estruturas de dados que você cria não são totalmente criadas imediatamente; em vez disso, eles são armazenados como "thunks" (você pode pensar nelas como receitas para construir o valor quando necessário). Somente quando o valor é necessário é que o thunk é expandido para um valor real. Isso significa que a alocação de memória pode ser adiada até que a avaliação seja necessária e, nesse ponto, vários thunks podem ser combinados em uma alocação de memória.

tdammers
fonte
Uau, uma pequena resposta e muita informação / insight. Obrigado :)
Gerry
3

Eu só sei um pouco sobre Clojure e suas estruturas de dados imutáveis .

Clojure fornece um conjunto de listas imutáveis, vetores, conjuntos e mapas. Como eles não podem ser alterados, 'adicionar' ou 'remover' algo de uma coleção imutável significa criar uma nova coleção exatamente como a antiga, mas com as alterações necessárias. Persistência é um termo usado para descrever a propriedade em que a versão antiga da coleção ainda está disponível após a 'alteração' e que a coleção mantém suas garantias de desempenho para a maioria das operações. Especificamente, isso significa que a nova versão não pode ser criada usando uma cópia completa, pois isso exigiria tempo linear. Inevitavelmente, as coleções persistentes são implementadas usando estruturas de dados vinculadas, para que as novas versões possam compartilhar a estrutura com a versão anterior.

Graficamente, podemos representar algo como isto:

(def my-list '(1 2 3))

    +---+      +---+      +---+
    | 1 | ---> | 2 | ---> | 3 |
    +---+      +---+      +---+

(def new-list (conj my-list 0))

              +-----------------------------+
    +---+     | +---+      +---+      +---+ |
    | 0 | --->| | 1 | ---> | 2 | ---> | 3 | |
    +---+     | +---+      +---+      +---+ |
              +-----------------------------+
Arturo Herrero
fonte
2

Além do que foi dito em outras respostas, gostaria de mencionar a linguagem de programação limpa, que suporta os chamados tipos únicos . Não conheço essa linguagem, mas suponho que tipos únicos suportem algum tipo de "atualização destrutiva".

Em outras palavras, embora a semântica da atualização de um estado seja a criação de um novo valor a partir de um antigo aplicando uma função, a restrição de exclusividade pode permitir que o compilador reutilize objetos de dados internamente porque sabe que o valor antigo não será referenciado mais no programa após a produção do novo valor.

Para obter mais detalhes, consulte, por exemplo, a página inicial limpa e este artigo da wikipedia.

Giorgio
fonte