Подтвердить что ты не робот

Каким образом достигается эффективная неразрушающая манипуляция коллекциями в функциональном программировании?

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

Вот как далеко я до сих пор:

Используя F #, я придумал функцию insert, которая разбивает список на две части и вводит новый элемент между ними, казалось бы, без клонирования всех неизменных элементов:

// return a list without its first n elements:
// (helper function)
let rec skip list n =
    if n = 0 then
        list
    else
        match list with
        | []    -> []
        | x::xs -> skip xs (n-1)

// return only the first n elements of a list:
// (helper function)
let rec take list n =
    if n = 0 then
        []
    else
        match list with
        | []    -> []
        | x::xs -> x::(take xs (n-1))

// insert a value into a list at the specified zero-based position:
let insert list position value =
    (take list position) @ [value] @ (skip list position)

Затем я проверил, будут ли объекты из исходного списка "переработаны" в новых списках с помощью .NET Object.ReferenceEquals:

open System

let (===) x y =
    Object.ReferenceEquals(x, y)

let x = Some(42)
let L = [Some(0); x; Some(43)]
let M = Some(1) |> insert L 1

Следующие три выражения оцениваются до true, указывая, что значение, на которое ссылается x, повторно используется как в списках L, так и M, т.е. что в памяти только 1 копия этого значения:

L.[1] === x
M.[2] === x
L.[1] === M.[2]

Мой вопрос:

У функциональных языков программирования обычно повторное использование значений, а не клонирование их в новую ячейку памяти, или мне просто повезло с поведением F #? Предполагая первое, заключается в том, насколько разумно эффективное редактирование коллекций может быть реализовано в функциональном программировании?

(Btw: Я знаю о Книга Криса Окасаки. Совершенно функциональные структуры данных, но еще не успели прочитать ее полностью. )

4b9b3361

Ответ 1

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

Эта страница содержит несколько описаний и реализаций структур данных в F #. Большинство из них основаны на функциональных структурах данных Okasaki Purely, хотя дерево AVL - это моя собственная реализация, поскольку в книге не было.

Теперь, поскольку вы спросили о повторном использовании немодифицированных узлов, позвольте взять простое двоичное дерево:

type 'a tree =
    | Node of 'a tree * 'a * 'a tree
    | Nil

let rec insert v = function
    | Node(l, x, r) as node ->
        if v < x then Node(insert v l, x, r)    // reuses x and r
        elif v > x then Node(l, x, insert v r)  // reuses x and l
        else node
    | Nil -> Node(Nil, v, Nil)

Обратите внимание, что мы повторно используем некоторые из наших узлов. Пусть говорят, что мы начинаем с этого дерева:

500px-Purely_functional_tree_before.svg.png

Когда мы вставляем e в дерево, мы получаем новое дерево, а некоторые из узлов указывают на наше исходное дерево:

500px-Purely_functional_tree_after.svg.png

Если у нас нет ссылки на дерево xs выше, тогда .NET будет мусор собирать любые узлы без живых ссылок, в частности узлы d, g и f.

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

Имеют ли языки функционального программирования как правило, повторно используют значения вместо клонируя их в новое место памяти, или мне просто повезло с F # поведение? Предполагая первое, это как разумно эффективно с точки зрения памяти редактирование коллекций может быть реализовано в функциональном программировании?

Да.

Списки, однако, не очень хорошая структура данных, так как для большинства нетривиальных операций для них требуется время O (n).

Сбалансированные двоичные деревья поддерживают вставки O (log n), то есть мы создаем копии O (log n) для каждой вставки. Поскольку log 2 (10 ^ 15) = ~ 50, накладные расходы очень малы для этих конкретных структур данных. Даже если вы сохраняете каждую копию каждого объекта после вставки/удаления, использование вашей памяти будет увеличиваться со скоростью O (n log n) - очень разумно, на мой взгляд.

Ответ 2

Как можно изменить или удалить отдельные элементы, не создавая полностью новую коллекцию, в которой все элементы, даже немодифицированные, будут дублироваться в памяти.

Это работает, потому что независимо от того, какая коллекция, указатели на элементы хранятся отдельно от самих элементов. (Исключение: некоторые компиляторы некоторое время будут оптимизированы, но они знают, что они делают.) Например, у вас могут быть два списка, которые отличаются только первым элементом и обмениваются хвостами:

let shared = ["two", "three", "four"]
let l      = "one" :: shared
let l'     = "1a"  :: shared

Эти два списка имеют общую часть shared, а их первые элементы разные. Что менее очевидно, что каждый список также начинается с уникальной пары, которую часто называют "cons cell":

  • Список l начинается с пары, содержащей указатель на "one" и указателя на общий хвост.

  • Список l' начинается с пары, содержащей указатель на "1a" и указателя на общий хвост.

Если бы мы объявили l и хотели изменить или удалить первый элемент для получения l', мы сделаем следующее:

let l' = match l with
         | _ :: rest -> "1a" :: rest
         | []        ->  raise (Failure "cannot alter 1st elem of empty list")

Существует постоянная стоимость:

  • Разделите l на голову и хвост, исследуя ячейку cons.

  • Выделите новую ячейку cons, указывающую на "1a" и хвост.

Новая ячейка cons станет значением списка l'.

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

  • Gerard Huet zipper может быть определен практически для любой древовидной структуры данных и может использоваться для перемещения и создания точечных модификаций при постоянной стоимости. Молнии легко понять.

  • Патроны Патерсона и Хинца предлагают очень сложные представления о последовательностях, которые, среди прочего, позволяют вам эффективно менять элементы в середине, но их трудно понять.

Ответ 3

Пока объекты, на которые ссылаются, одинаковы в вашем коде, Я верю место для хранения самих ссылок и структура списка дублируется на take. В результате, в то время как ссылочные объекты одинаковы, и хвосты разделяются между двумя списками, "ячейки" для начальных частей дублируются.

Я не специалист по функциональному программированию, но, возможно, с каким-то деревом, которое вы могли бы достичь дублирование только log (n) элементов, так как вам нужно будет воссоздать только путь от корня к вставленному элементу.

Ответ 4

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

Грубо говоря, ссылочная прозрачность означает, что между переменным/выражением и значением, которым оно владеет/оценивается, нет никакой практической разницы. Поскольку кусок непреложных данных (по определению) никогда не изменится, его можно тривиально идентифицировать со своим значением и должно вести себя неотличимо от любых других данных с одинаковым значением.

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

Аналогично, неизменяемые структуры данных обладают внутренней ссылочной прозрачностью их структуры (хотя и не их содержимого). Предполагая, что все содержащиеся значения также неизменяемы, это означает, что части структуры можно безопасно повторно использовать и в новых структурах. Например, хвост списка минусов часто можно использовать повторно; в вашем коде, я бы ожидал, что:

(skip 1 L) === (skip 2 M)

... также будет истинным.

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

В таких случаях наивные подходы быстро попадают в ужасную работу, о которой вы беспокоились. Часто необходимо существенно переосмыслить основные алгоритмы и структуры данных, чтобы успешно адаптировать их к неизменяемым данным. Методы включают разбиение структур на слоистые или иерархические фрагменты, чтобы изолировать изменения, инвертируя части структуры, чтобы выставлять дешевые обновления часто изменяемым частям или даже сохранять исходную структуру вместе с набором обновлений и комбинировать обновления с оригиналом на лету при доступе к данным.

Поскольку вы используете F # здесь, я собираюсь предположить, что вы хотя бы немного знакомы с С#; неоценимый Eric Lippert имеет количество сообщений в неизменяемых структурах данных на С#, что, вероятно, просветит вас намного дальше того, что я мог бы предоставить. В течение нескольких сообщений он демонстрирует (разумно эффективные) неизменяемые реализации стека, двоичного дерева и очереди с двойным концом. Восхитительное чтение для любого .NET-программиста!

Ответ 5

Вам могут быть интересны стратегии сокращения выражений в языках функционального программирования. Хорошая книга по теме Реализация языков функционального программирования, Саймон Пейтон Джонс, один из создателей Haskell. Посмотрите особенно на следующую главу График сокращения выражений лямбда, поскольку он описывает совместное использование общих подвыражений. Надеюсь, это поможет, но я боюсь, что это относится только к ленивым языкам.