Функции, которые выглядят чисто для вызывающих, но внутренне используют мутацию
Я только что получил свою копию Expert F# 2.0 и наткнулся на это утверждение, которое меня несколько удивило:
Например, при необходимости вы можете использовать побочные эффекты для частных структур данных, выделенных в начале алгоритма, а затем отбросить эти структуры данных перед возвратом результата; тогда общий результат фактически является функцией без побочных эффектов. Одним из примеров отделения от библиотеки F# является реализация библиотеки List.map, которая использует мутацию внутри; записи происходят во внутренней отдельной структуре данных, к которой другой код не может получить доступ.
Очевидно, что преимуществом такого подхода является производительность. Мне просто любопытно, есть ли какие-нибудь недостатки - применимы ли какие-либо подводные камни, которые могут сопровождаться побочными эффектами? Влияет ли параллельность?
Другими словами, если бы производительность была отложена, было бы предпочтительнее List.map
в чистом виде?
(Очевидно, это касается, в частности, F#, но мне также интересно узнать общую философию)
7 ответов
Я думаю, что почти каждый недостаток побочных эффектов связан с "взаимодействием с другими частями программы". Сами побочные эффекты неплохие (как говорит @Gabe, даже чисто функциональная программа постоянно изменяет ОЗУ), это общие побочные эффекты (нелокальные взаимодействия), которые вызывают проблемы (с отладкой / производительностью / понятностью /так далее.). Таким образом, влияние на чисто локальное состояние (например, на локальную переменную, которая не выходит за пределы) в порядке.
(Единственный вред, о котором я могу думать, это то, что, когда человек видит такой локальный изменяемый объект, он должен подумать о том, может ли он убежать. В F# локальные изменяемые элементы никогда не могут избежать (замыкания не могут захватить изменяемые элементы), поэтому единственный потенциал " "Ментальный налог" исходит из рассуждений об изменчивых ссылочных типах.)
Резюме: хорошо использовать эффекты, при условии, что просто убедить себя, что эффекты случаются только на не спасающихся местных жителей. (Также можно использовать эффекты в других случаях, но я игнорирую эти другие случаи, поскольку в этой цепочке вопросов мы - просвещенные функциональные программисты, пытающиеся избегать эффектов всякий раз, когда это разумно.:))
(Если вы хотите углубиться в подробности, локальные эффекты, такие как в реализации List.map в F#, являются не только не препятствием для распараллеливания, но и преимуществом с точки зрения того, что более эффективная реализация выделяет меньше, и, следовательно, меньше нагрузки на общий ресурс GC.)
Возможно, вас заинтересуют "Потоки ленивого функционального состояния" Саймона Пейтона Джонса. Я только когда-либо делал это через первые несколько страниц, которые очень ясны (я уверен, что остальное также очень ясно).
Важным моментом является то, что при использовании Control.Monad.ST
чтобы делать подобные вещи в Haskell, сама система типов обеспечивает инкапсуляцию. В Scala (и, вероятно, F#) подход более "просто поверьте нам, что мы не делаем ничего подлого с этим ListBuffer
в вашем map
".
Если функция использует локальные, частные (для функции) изменяемые структуры данных, распараллеливание не затрагивается. Так что если map
Функция внутренне создает массив размером с список и перебирает его элементы, заполняя массив, вы все равно можете запустить map
100 раз одновременно в одном списке и не волнуйтесь, потому что каждый экземпляр map
будет иметь собственный закрытый массив. Поскольку ваш код не может видеть содержимое массива до тех пор, пока он не будет заполнен, он фактически чистый (помните, что на каком-то уровне ваш компьютер должен фактически изменять состояние ОЗУ).
С другой стороны, если функция использует глобальные изменяемые структуры данных, это может повлиять на распараллеливание. Например, скажем, у вас есть Memoize
функция. Очевидно, что весь смысл в том, чтобы поддерживать некое глобальное состояние (хотя оно и "глобально" в том смысле, что оно не локально для вызова функции, оно все еще "приватно" в том смысле, что оно не доступно вне функции), чтобы он не должен запускать функцию несколько раз с одними и теми же аргументами, но он все еще чистый, потому что одни и те же входные данные всегда будут давать одинаковые выходные данные. Если ваша структура данных кэша является поточно-ориентированной (например, ConcurrentDictionary
) тогда вы все равно можете запустить свою функцию параллельно с самим собой. Если нет, то вы можете утверждать, что функция не является чистой, поскольку имеет побочные эффекты, которые наблюдаются при одновременном запуске.
Я должен добавить, что в F# это обычная техника - начинать с чисто функциональной подпрограммы, а затем оптимизировать ее, используя преимущества изменяемого состояния (например, кэширование, явное зацикливание), когда профилирование показывает, что оно слишком медленное.
Тот же подход можно найти в использовании в Clojure. Неизменяемые структуры данных в Clojure - list, map и vector - имеют свои "временные" аналоги, которые могут изменяться. Ссылка Clojure о переходных процессах призывает использовать их только в коде, который не виден "любому другому коду".
В коде клиента предусмотрены меры защиты от утечек:
Обычная функция, которая работает с неизменяемыми структурами данных, не работает с переходными процессами. Вызов их вызовет исключение.
Переходные процессы связаны с потоком, в котором они созданы. Изменение их из любого другого потока вызовет исключение.
Код clojure.core сам по себе использует множество переходных процессов за кулисами.
Основным преимуществом использования переходных процессов является значительное ускорение, которое они обеспечивают.
Таким образом, строго контролируемое использование изменяемого состояния в функциональных языках кажется нормальным.
Одной из проблем может быть то, что хороший функциональный компилятор сконструирован так, чтобы хорошо оптимизировать "функциональный" код, но если вы используете некоторые изменчивые вещи, компилятор может не оптимизировать так хорошо, как в другом случае. В худшем случае это приводит к более неэффективным алгоритмам, чем неизменный вариант.
Другая проблема, о которой я могу подумать, - это лень - изменяемая структура данных обычно не ленива, поэтому изменяемое соединение может привести к ненужной оценке аргументов.
Это не повлияет на возможность запуска функции параллельно с другими функциями. Это будет влиять на то, можно ли сделать внутреннюю часть функции параллельной, хотя это вряд ли будет проблемой для большинства небольших функций (таких как карта), предназначенных для ПК.
Я заметил, что некоторые хорошие программисты на F# (в Интернете и в книгах), похоже, очень расслаблены в использовании императивных методов для циклов. Кажется, они предпочитают простой цикл с переменными переменными цикла сложной рекурсивной функции.
Я бы ответил на этот вопрос: "Вы пишете функцию или используете функцию?"
Есть 2 аспекта для функций, пользователей и разработчиков.
Как пользователь, никто не заботится о внутренней структуре функции вообще. Он может быть закодирован в байт-коде и использовать жесткие побочные эффекты внутри компании до сегодняшнего дня, пока он соответствует контракту данных -> данных, ожидаемых. Функция - это черный ящик или оракул, ее внутренняя структура не имеет значения (при условии, что она не делает ничего глупого и внешнего).
Как разработчик функции, внутренняя структура имеет большое значение. Неизменность, постоянная корректность и предотвращение побочных эффектов - все это помогает развивать и поддерживать функцию, а также расширять ее в параллельную область.
Многие люди развивают функцию, которую они затем используют, поэтому оба эти аспекта применимы.
Каковы преимущества неизменяемости по сравнению с изменчивыми структурами - это другой вопрос.