1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9

# теория · шаг 1 из 9

Персистентность: старая версия остаётся живой

Коротко

Обычная структура эфемерна: изменение уничтожает прошлое состояние. Персистентная хранит все версии: операция не меняет старую, а возвращает новую. Частичная персистентность разрешает читать любые версии, а менять только последнюю; полная - менять любую, и версии ветвятся деревом. Дёшево это благодаря общим частям: узлы никогда не меняются, и новая версия переиспользует старые. Стек из неизменяемых узлов персистентен бесплатно: положить - один новый узел, снять - шаг к следующему. В модели урока 2000 операций с ветвлением создали 1416 узлов; копирование стека целиком стоило бы 55 421.

Если ты знаешь, чем персистентная структура отличается от эфемерной, что такое частичная и полная персистентность и почему стек из неизменяемых узлов персистентен бесплатно, листай до «Теперь сам».

Как думаешь, что окажется в истории

Хотели сохранить историю корзины: в цикле по товарам «хлеб», «сыр», «чай» делаем korzina.append(tovar), а потом istoriya.append(korzina) - снимок после каждого товара. Что окажется в istoriya после цикла?

Ответь себе до чтения. Если «['хлеб'], ['хлеб', 'сыр'], ['хлеб', 'сыр', 'чай']» - разбор покажет три одинаковых списка. В истории лежат три ссылки на одну и ту же корзину, а она менялась на месте.

На что это похоже

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

Механизм: неизменяемые узлы и общие части

Персистентность строят не копированием, а неизменяемостью: узел, однажды созданный, не меняется никогда. Тогда его можно смело делить между версиями - никто не испортит его под другой версией.

Стек на неизменяемых узлах [значение, следующий]:

  • положить x на версию v - создать один узел [x, v], он и есть новая вершина;
  • снять с версии v - взять её «следующий»: новых узлов нет вовсе;
  • версия - это просто номер вершины. Две версии с одной вершиной (как 2 и 4 ниже) - одно и то же содержимое; номер в списке версий - лишь место в истории.

Обе операции - O(1), и ни одна не трогает старых узлов. Положить на старую версию так же можно, как на новую, - стек получается полностью персистентным, версии ветвятся.

Классификация Дрисколла, Сарнака, Слейтора и Тарьяна (1989): частичная персистентность - читать любые версии, менять последнюю (история правок); полная - менять любую (ветки); слияемая - вдобавок соединять две версии в одну (слияние веток в git; для списков - например, склейка двух версий в одну последовательность).

хлебсыр чайкофе версия 1версия 2, 4 версия 3 версия 5 каждый узел создан один раз и не меняется; новая версия - новый узел поверх старых
Шесть версий стека хранятся в четырёх узлах. «Снять чай» не создаёт ничего: версия 4 - это просто вершина «сыр». «Положить кофе» на версию 2 - одна новая клетка, и стек ветвится.

Правило

Персистентная структура: операция возвращает новую версию, старые остаются доступны. Основа - неизменяемые узлы и общие части между версиями. Частичная персистентность - менять последнюю версию, полная - любую, слияемая - ещё и соединять версии. Стек на неизменяемых узлах полностью персистентен бесплатно: O(1) на операцию и O(1) памяти на «положить».

Разбор: корзина эфемерно и персистентно

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

# эфемерный список: «старая версия» - та же самая ссылка
istoriya = []
korzina = []
for tovar in ["хлеб", "сыр", "чай"]:
    korzina.append(tovar)
    istoriya.append(korzina)
for snimok in istoriya:
    print("эфемерно:", snimok)

# персистентный стек: узел [значение, следующий], узлы не меняются
znach = []
dalshe = []

def polozhit(verh, x):
    znach.append(x)
    dalshe.append(verh)
    return len(znach) - 1            # номер нового узла - новая вершина

def soderzhimoe(verh):
    out = []
    while verh != -1:
        out.append(znach[verh])
        verh = dalshe[verh]
    return out

versii = [-1]                        # -1 - пустой стек
for tovar in ["хлеб", "сыр", "чай"]:
    versii.append(polozhit(versii[-1], tovar))
versii.append(dalshe[versii[-1]])    # снять «чай»: просто шаг назад
versii.append(polozhit(versii[2], "кофе"))   # ветка от версии 2
for i in range(len(versii)):
    print("версия", i, soderzhimoe(versii[i]))
print("узлов создано:", len(znach))
эфемерно: ['хлеб', 'сыр', 'чай']
эфемерно: ['хлеб', 'сыр', 'чай']
эфемерно: ['хлеб', 'сыр', 'чай']
версия 0 []
версия 1 ['хлеб']
версия 2 ['сыр', 'хлеб']
версия 3 ['чай', 'сыр', 'хлеб']
версия 4 ['сыр', 'хлеб']
версия 5 ['кофе', 'сыр', 'хлеб']
узлов создано: 4

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

Разбор: сколько стоит общая память

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

import random

znach = []
dalshe = []
dlina = []                           # длина стека от этого узла вниз

def polozhit(verh, x):
    znach.append(x)
    dalshe.append(verh)
    if verh == -1:
        dlina.append(1)
    else:
        dlina.append(dlina[verh] + 1)
    return len(znach) - 1

random.seed(8)              # то же зерно - те же числа в Python и движке
versii = [-1]
kopij = 0                            # сколько стоило бы копировать стек
for shag in range(2000):
    otkuda = versii[-1]              # обычно - от последней версии
    if random.random() < 0.1:        # иногда - от любой старой
        otkuda = versii[random.randint(0, len(versii) - 1)]
    if otkuda != -1 and random.random() < 0.3:
        versii.append(dalshe[otkuda])            # снять
        kopij = kopij + dlina[otkuda] - 1        # копия без вершины
    else:
        versii.append(polozhit(otkuda, shag))    # положить
        if otkuda != -1:
            kopij = kopij + dlina[otkuda]
        kopij = kopij + 1
samaya = 0
for v in versii:
    if v != -1 and dlina[v] > samaya:
        samaya = dlina[v]
print("версий:", len(versii), "узлов:", len(znach))
print("самая длинная версия:", samaya)
print("копирование целиком стоило бы элементов:", kopij)
версий: 2001 узлов: 1416
самая длинная версия: 66
копирование целиком стоило бы элементов: 55421

Узлов - ровно столько, сколько было операций «положить», 1416. Копирование каждой версии целиком - 55 421 элемент, в 39 раз больше, и разрыв растёт с длиной стека: копия стоит O(n), узел - O(1). Граница правила «персистентность бесплатна»: бесплатна она для стека, где меняется только вершина. Очередь, где меняются оба конца, и дерево, где меняется середина, так просто не даются - им посвящены следующие уроки.

Что ломается без этого

История из ссылок. istoriya.append(korzina) хранит ссылку, а не снимок; так же ломаются «кеш предыдущего состояния», «отмена последнего действия» и любые снимки изменяемого объекта.

Глубокая копия на каждый шаг. Лечение copy.deepcopy(korzina) работает, но стоит O(n) на шаг и O(n) памяти на версию: на длинных историях - квадрат.

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

Зачем это в работе

Отмена и повтор в редакторах, ветки в git, снимки файловых систем (btrfs, ZFS), версии строк в базах данных (MVCC: читатель видит снимок на момент начала транзакции или запроса - смотря по уровню изоляции, пока писатель создаёт новую версию), состояние интерфейса в React и Redux, коллекции Clojure, Scala, Haskell. Ещё один бонус - потоки: неизменяемую версию можно отдать любому числу читателей без замков, её никто не поменяет.

Теперь сам

  1. Стек-версия v содержит [3, 2, 1] (3 - вершина). Сколько новых узлов создадут операции «положить 4 на v», «снять с v», «положить 5 на результат снятия»?
  2. Чем частичная персистентность отличается от полной?
  3. Почему кортеж Python неизменяем, но список кортежей - не персистентная структура?

Ответ. 1. Два: узел [4, v] и узел [5, узел-с-двойкой]; снятие с v нового узла не создаёт.

  1. При частичной менять можно только последнюю версию, старые - только читать; при полной можно менять любую, и версии ветвятся.

  2. Неизменяемы элементы, а сам список меняется на месте: «старая версия» - та же ссылка. Нужны неизменяемые связи между узлами, а не только неизменяемые значения.

Главное

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

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.