В функциональном программировании требует ли использование большей части памяти большей части неизменяемых структур данных?

63

В функциональном программировании почти все структуры данных являются неизменными, когда состояние должно измениться, создается новая структура. Значит ли это, что использование памяти будет намного больше? Я хорошо знаю парадигму объектно-ориентированного программирования, теперь я пытаюсь узнать о парадигме функционального программирования. Концепция того, что все неизменно, смущает меня. Казалось бы, программе, использующей неизменяемые структуры, потребуется гораздо больше памяти, чем программе с изменяемыми структурами. Я даже смотрю на это правильно?

Jbemmz
источник
7
Это может означать, что, но большинство неизменных структур данных повторно используют базовые данные для изменений. У Эрика Липперта есть отличная серия блогов об неизменяемости в C #
Одед,
3
Я хотел бы взглянуть на чисто функциональные структуры данных. Это отличная книга, написанная тем же человеком, который написал большую часть библиотеки контейнеров на Haskell (хотя книга в основном SML)
jozefg
1
Этот ответ, связанный со временем выполнения вместо потребления памяти, также может быть интересен для вас: stackoverflow.com/questions/1990464/…
9000
1
Вы можете найти это интересным: en.wikipedia.org/wiki/Static_single_assignment_form
Шон МакSomething

Ответы:

35

Единственный правильный ответ на это «иногда». Есть много приемов, которые функциональные языки могут использовать, чтобы не тратить память. Неизменность упрощает обмен данными между функциями и даже между структурами данных, поскольку компилятор может гарантировать, что данные не будут изменены. Функциональные языки, как правило, поощряют использование структур данных, которые могут эффективно использоваться как неизменяемые структуры (например, деревья вместо хеш-таблиц). Если вы добавляете лень в смесь, как это делают многие функциональные языки, это добавляет новые способы экономии памяти (это также добавляет новые способы тратить память, но я не буду вдаваться в подробности).

Дирк Холсоппл
источник
24

В функциональном программировании почти все структуры данных являются неизменными, когда состояние должно измениться, создается новая структура. Значит ли это, что использование памяти будет намного больше?

Это зависит от структуры данных, точных внесенных вами изменений и, в некоторых случаях, от оптимизатора. В качестве одного примера давайте рассмотрим добавление в список:

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

Здесь потребность в дополнительной памяти постоянна, как и стоимость вызова во время выполнения prepend. Почему? Потому что prependпросто создает новую клетку, у которой есть 42голова и list1хвост. Для этого не нужно копировать или иным образом перебирать list2. То есть, за исключением памяти, необходимой для хранения 42, list2повторно используется та же память, которая используется list1. Поскольку оба списка являются неизменными, это совместное использование совершенно безопасно.

Аналогично, при работе со сбалансированными древовидными структурами большинству операций требуется только логарифмическое количество дополнительного пространства, потому что все, кроме одного пути дерева, может быть общим.

Для массивов ситуация немного другая. Вот почему во многих языках FP массивы не так часто используются. Однако, если вы делаете что-то подобное arr2 = map(f, arr1)и arr1больше никогда не используете после этой строки, умный оптимизатор может фактически создавать код, который мутирует, arr1вместо создания нового массива (не влияя на поведение программы). В этом случае производительность будет, как и на императивном языке, конечно.

sepp2k
источник
1
Интересно, какая реализация каких языков повторно использует пространство, как вы описали ближе к концу?
@delnan В моем университете был исследовательский язык под названием Qube, который сделал это. Я не знаю, есть ли какой-нибудь используемый в дикой природе язык, который делает это, хотя. Однако слияние Хаскелла может достичь того же эффекта во многих случаях.
sepp2k
7

Наивные реализации действительно разоблачили бы эту проблему - когда вы создаете новую структуру данных вместо обновления существующей на месте, у вас должны быть некоторые накладные расходы.

Разные языки по-разному справляются с этим, и большинство из них используют несколько хитростей.

Одна стратегия - сборка мусора . В тот момент, когда новая структура была создана, или вскоре после этого, ссылки на старую структуру выходят из области видимости, и сборщик мусора обнаружит ее мгновенно или достаточно быстро, в зависимости от алгоритма GC. Это означает, что, хотя накладные расходы все еще существуют, они носят временный характер и не будут расти линейно с объемом данных.

Еще один выбор различных типов структур данных. Там, где массивы являются структурой данных списка переходов в императивных языках (обычно заключенных в какой-то контейнер динамического перераспределения, такой как std::vectorв C ++), функциональные языки часто предпочитают связанные списки. Со связанным списком операция prepend ('cons') может повторно использовать существующий список в качестве хвоста нового списка, поэтому все, что действительно выделяется - это новый заголовок списка. Подобные стратегии существуют для других типов структур данных - наборов, деревьев, вы называете это.

А потом ленивая оценка, а-ля Хаскелл. Идея состоит в том, что создаваемые вами структуры данных создаются не полностью сразу; вместо этого они хранятся как «thunks» (вы можете думать о них как о рецептах для создания значения, когда это необходимо). Только когда значение требуется, thunk расширяется до фактического значения. Это означает, что выделение памяти может быть отложено до тех пор, пока не потребуется оценка, и в этот момент несколько блоков могут быть объединены в одном выделении памяти.

tdammers
источник
Вау, один маленький ответ и так много информации / понимания. Спасибо :)
Джерри
3

Я только немного знаю о Clojure и о его неизменных структурах данных .

Clojure предоставляет набор неизменяемых списков, векторов, наборов и карт. Поскольку их нельзя изменить, «добавление» или «удаление» чего-либо из неизменяемой коллекции означает создание новой коллекции, такой же, как старая, но с необходимыми изменениями. Постоянство - это термин, используемый для описания свойства, в котором старая версия коллекции все еще доступна после «изменения», и что коллекция поддерживает свои гарантии производительности для большинства операций. В частности, это означает, что новая версия не может быть создана с использованием полной копии, поскольку для этого потребуется линейное время. Постоянные коллекции неизбежно реализуются с использованием связанных структур данных, так что новые версии могут делиться структурой с предыдущей версией.

Графически мы можем представить что-то вроде этого:

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

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

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

              +-----------------------------+
    +---+     | +---+      +---+      +---+ |
    | 0 | --->| | 1 | ---> | 2 | ---> | 3 | |
    +---+     | +---+      +---+      +---+ |
              +-----------------------------+
Артуро Эрреро
источник
2

В дополнение к тому, что было сказано в других ответах, я хотел бы упомянуть язык программирования Clean, который поддерживает так называемые уникальные типы . Я не знаю этого языка, но полагаю, что уникальные типы поддерживают какое-то «деструктивное обновление».

Другими словами, хотя семантика обновления состояния заключается в том, что вы создаете новое значение из старого, применяя функцию, ограничение уникальности может позволить компилятору повторно использовать объекты данных для внутреннего использования, поскольку он знает, что на старое значение не будет ссылаться больше в программе после создания нового значения.

Для более подробной информации смотрите, например, Чистую домашнюю страницу и эту статью в Википедии.

Джорджио
источник