ST Monad == запах кода?

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

Учитывая все это жонглирование, я недостаточно сообразителен, чтобы понять, как сделать все дерево поиска красивой неизменной структурой данных а-ля Окасаки. Вместо этого я немного поигрался с монадой ST, создав структуры, состоящие из изменяемых STRef. Надуманный пример (не связанный с UCT):

import Control.Monad
import Control.Monad.ST
import Data.STRef

data STRefPair s a b = STRefPair { left :: STRef s a, right :: STRef s b }

mkStRefPair :: a -> b -> ST s (STRefPair s a b)
mkStRefPair a b = do
    a' <- newSTRef a
    b' <- newSTRef b
    return $ STRefPair a' b'

derp :: (Num a, Num b) => STRefPair s a b -> ST s ()
derp p = do
    modifySTRef (left p) (\x -> x + 1)
    modifySTRef (right p) (\x -> x - 1)

herp :: (Num a, Num b) => (a, b)
herp = runST $ do
    p <- mkStRefPair 0 0
    replicateM_ 10 $ derp p
    a <- readSTRef $ left p
    b <- readSTRef $ right p
    return (a, b)

main = print herp -- should print (10, -10)

Очевидно, что этот конкретный пример было бы намного проще написать без использования ST, но, надеюсь, ясно, к чему я буду стремиться ... если бы я применил такой стиль к моему варианту использования UCT, это неправильно?

Кто-то задал аналогичный вопрос здесь пару лет назад, но Я думаю, что мой вопрос немного другой ... У меня нет проблем с использованием монад для инкапсуляции изменяемого состояния, когда это необходимо, но меня привлекает именно предложение «когда необходимо». Меня беспокоит, что я преждевременно возвращаюсь к объектно-ориентированному мышлению, когда у меня есть куча объектов с геттерами и сеттерами. Не совсем идиоматический Haskell ...

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


person mergeconflict    schedule 24.10.2011    source источник
comment
разумный изоморфен time ∩ budget   -  person Thomas Eding    schedule 25.10.2011
comment
Незначительный момент: вы можете использовать liftM2: mkStRefPair a b = liftM2 STRefPair (newSTRef a) (newSTRef b), liftM2 (,) (readSTRef $ left p) (readSTRef $ right p)   -  person sdcvvc    schedule 25.10.2011
comment
Ах, @sdcvvc, очень мило. По какой-то причине мне не пришло в голову, что я могу использовать liftM2 в конструкторах данных.   -  person mergeconflict    schedule 25.10.2011
comment
Просто перевод алгоритма из изменяемого в неизменяемый должен быть достаточно простым: где бы вы ни выполняли обновление в изменяемом алгоритме, вместо этого возвращайте обновленную версию всей структуры данных. Самое сложное - сделать его производительным (в основном за счет максимального обмена). Но вы, наверное, знали об этом. Учитывая, что весь смысл такого алгоритма - это производительность и (минимизация) временной сложности, я полагаю, что это наблюдение не очень полезно. :)   -  person glaebhoerl    schedule 25.10.2011
comment
Это ответ на запрос о вознаграждении - я не думаю, что существует хорошее правило, кроме использования ST, когда вам нужна мутация, и вам нужна мутация, когда алгоритм требует мутации для сохранения границ сложности и принятия решения если алгоритм нуждается в изменении для сохранения границ сложности путем анализа алгоритма.   -  person sclv    schedule 27.10.2011
comment
возможный дубликат Использование монады состояния Haskell запах кода?   -  person Mogsdad    schedule 27.09.2015


Ответы (5)


Я не часто использую ST, но иногда это просто лучшее решение. Это может быть во многих сценариях:

  • Уже есть известные действенные способы решения проблемы. Quicksort - прекрасный тому пример. Он известен своей скоростью и поведением на месте, которое нельзя очень хорошо имитировать с помощью чистого кода.
  • Вам нужны жесткие временные и пространственные рамки. Особенно с ленивой оценкой (а Haskell даже не указывает, есть ли ленивая оценка, просто она не является строгой), поведение ваших программ может быть очень непредсказуемым. Наличие утечки памяти может зависеть от того, включена ли определенная оптимизация. Это сильно отличается от императивного кода, который имеет фиксированный набор переменных (обычно) и определенный порядок оценки.
  • У вас есть крайний срок. Хотя чистый стиль почти всегда является лучшей практикой и более чистым кодом, если вы привыкли писать императивно и скоро вам понадобится код, начать императивно и переходить к функциональному позже - вполне разумный выбор.

Когда я использую ST (и другие монады), я стараюсь следовать этим общим рекомендациям:

  • Часто используйте стиль Applicative. Это упрощает чтение кода и, если вы действительно переключаетесь на неизменяемую версию, значительно упрощает преобразование. Не только это, но и стиль Applicative намного компактнее.
  • Не используйте просто ST. Если вы программируете только на ST, результат будет не лучше, чем у огромной программы на C, возможно, хуже из-за явных операций чтения и записи. Вместо этого используйте чистый код Haskell там, где он применим. Я часто использую такие вещи, как STRef s (Map k [v]). Сама карта видоизменяется, но большая часть тяжелой работы выполняется чисто.
  • Не переделывайте библиотеки, если в этом нет необходимости. Большая часть кода, написанного для ввода-вывода, может быть чисто и довольно механически преобразована в ST. Заменить все IORefs на STRefs и IOs на STs в Data.HashTable было намного проще, чем было бы написать реализацию хеш-таблицы с ручным кодированием, и, вероятно, быстрее.

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

person gereeter    schedule 01.11.2011
comment
Довольно забавно, что способы обхода этой ссылки - это сообщение, написанное @augustss, который написал ответ на этот вопрос, который я не нашел полезным: P Ironic. Тем не менее, это очень круто и хорошая мотивация, чтобы проверить Applicative. Я слышал об этом хорошие отзывы :) - person mergeconflict; 01.11.2011
comment
Я бы дал на этот ответ +5, если бы мог. Я рад видеть, что вы получили награду. Дело в том, что императивный код - это не бедствие, которого следует избегать любой ценой; просто работать над ним гораздо труднее, чем над функциональным кодом. Функциональное программирование - прекрасная абстракция, но это всего лишь одно: компьютеры - это машины, основной режим работы которых - выполнение действий одно за другим. Когда задача легко (или уже) правильно решена императивной программой, нет веских причин отказываться от этого решения, особенно когда ST может даже доказать нам, что у него чистый интерфейс. - person mokus; 18.11.2011
comment
Я не уверен, что быстрая сортировка - такой отличный пример: она может быть полезна, если вы уже использовали изменяемые массивы по другой причине, но для простой сортировки списка она не быстрее, чем Data.List.sort - person Jeremy List; 20.07.2015
comment
@JeremyList - быстрая сортировка плохо названа, что приводит к путанице. Учитывая все обстоятельства, это не особенно быстро. Но это алгоритм на месте, что означает, что он использует вдвое меньше памяти, чем любой другой алгоритм сортировки, с которым вы столкнетесь, включая Data.List.sort, который является (сильно оптимизированной версией) сортировки слиянием . - person Jules; 10.04.2017

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

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

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

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

Итак, мы создаем код для описания «поэтапного» дерева. Затем у нас есть другая структура данных для определения частично исследованного дерева вместе с частичными оценками вознаграждения. Затем у нас есть функция randseed -> ptree -> ptree, которая дает случайное начальное число и частично исследованное дерево, приступает к еще одному исследованию дерева, обновляя структуру ptree по мере продвижения. Затем мы можем просто выполнить итерацию этой функции по пустому начальному дереву ptree, чтобы получить список все большего количества выборок пробелов в ptree. Затем мы можем просматривать этот список, пока не будет выполнено определенное условие отсечения.

Итак, теперь мы перешли от одного алгоритма, в котором все смешано вместе, к трем отдельным шагам: 1) ленивое построение всего дерева состояний, 2) обновление некоторого частичного исследования с помощью некоторой выборки структуры и 3) принятие решения, когда мы собрал достаточно образцов.

person sclv    schedule 25.10.2011
comment
Я думаю, что проблема связана с реализацией ptree. Это довольно здорово. Каждый нелистовой узел в дереве - это многорукий бандит, а каждый дочерний узел такого узла - рука. Вы всегда играете рукой с наибольшим значением UCB1 среди его братьев и сестер, и каждый раз, когда разыгрывается любая рука бандита, значения UCB1 всех рук этого бандита меняются ... Я подозреваю, что вы правильно: вероятно, есть отличный способ реализовать это с использованием неизменяемых структур данных без ущерба для границ сложности, но я хочу сказать, что я недостаточно умен, чтобы понять это. - person mergeconflict; 25.10.2011
comment
@mergeconflict - так у вас PTreeNode [(UCB1,PTreeNode)]? - т.е. просто хранить UCB1 с узлами, а не внутри узлов? - person sclv; 27.10.2011
comment
Имейте в виду, что стоимость создания нового неизменяемого дерева - это не стоимость копирования всех узлов, а только связка, ведущая к обновленным узлам и новым узлам. То есть среда выполнения ghc использует изменчивость под капотом; неизменное решение может быть более эффективным, чем вы думаете. В любом случае, я бы получил неизменную версию, работающую правильно, а затем, если она слишком медленная, добавьте ST. - person ja.; 29.10.2011

Может быть действительно сложно сказать, уместно ли использование ST. Я бы посоветовал вам сделать это с ST и без ST (не обязательно в таком порядке). Сохраняйте простую версию, отличную от ST; использование ST следует рассматривать как оптимизацию, и вы не хотите этого делать, пока не узнаете, что вам это нужно.

person augustss    schedule 24.10.2011
comment
Итак, мм ... Есть примеры, когда вы сталкивались с подобным выбором? Что-нибудь вы узнали о том, как поддерживать этот стиль кода в чистоте? - person mergeconflict; 25.10.2011

Я должен признать, что не могу читать код Haskell. Но если вы используете ST для изменения дерева, вы, вероятно, можете заменить его неизменяемым деревом без особых потерь, потому что:

Одинаковая сложность для изменяемого и неизменяемого дерева

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

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

Обновление дерева, по-видимому, не является узким местом

Оценка нового листа, вероятно, будет намного дороже, чем обновление дерева. По крайней мере, так обстоит дело с UCT в компьютерном Go.

person ziggystar    schedule 18.11.2011
comment
Это верно только для одиночных операций. - person dan_waterworth; 25.11.2011
comment
@dan_waterworth Верно для деревьев, массивы разные. - person Jeremy List; 19.01.2015

Использование монады ST обычно (но не всегда) в качестве оптимизации. Для любой оптимизации я применяю ту же процедуру:

  1. Напишите код без него,
  2. Профилируйте и определите узкие места,
  3. Постепенно переписывайте узкие места и проверяйте улучшения / регрессии,

Другой известный мне вариант использования - это альтернатива монаде состояний. Ключевое отличие состоит в том, что с монадой состояний тип всех хранимых данных указывается сверху вниз, тогда как с монадой ST он указывается снизу вверх. Бывают случаи, когда это полезно.

person dan_waterworth    schedule 25.11.2011
comment
Вот почему почти каждый раз, когда я когда-либо использовал ST, это не было ST, это было ReaderT <structure full of STRefs> ST - person Jeremy List; 21.04.2015