Функции, которые выглядят чистыми для вызывающих абонентов, но внутренне используют мутацию

Я только что получил свою копию Expert F # 2.0 и наткнулся на это утверждение, которое меня несколько удивило:

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

Очевидно, что преимущество этого подхода - производительность. Мне просто любопытно, есть ли какие-либо недостатки - применимы ли здесь какие-либо подводные камни, которые могут иметь побочные эффекты? Влияет ли параллельность?

Другими словами, если бы производительность была отложена, было бы предпочтительнее реализовать List.map в чистом виде?

(Очевидно, это касается, в частности, F #, но мне также интересна общая философия)


person J Cooper    schedule 13.09.2010    source источник
comment
Может быть, измените заголовок на что-то более прямое, например, "Являются ли чистые функции вредными для внутреннего использования изменяемыми вещами?"   -  person fuz    schedule 13.09.2010


Ответы (7)


Я думаю, что почти каждый недостаток побочных эффектов связан с «взаимодействием с другими частями программы». Сами по себе побочные эффекты неплохие (как говорит @Gabe, даже чистая функциональная программа постоянно мутирует ОЗУ), это общие побочные последствия эффектов (нелокальные взаимодействия), которые вызывают проблемы (с отладкой / производительностью / понятностью /так далее.). Таким образом, влияние на чисто локальное состояние (например, на локальную переменную, которая не исчезает) в порядке.

(Единственный недостаток, о котором я могу думать, это то, что когда человек видит такую ​​локальную изменяемую переменную, он должен подумать о том, может ли она ускользнуть. В F # локальные изменяемые никогда не могут ускользнуть (замыкания не могут захватывать изменяемые), поэтому единственный потенциал " интеллектуальный налог "происходит из рассуждений об изменяемых ссылочных типах.)

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

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

person Brian    schedule 13.09.2010
comment
re: местные эффекты могут быть более эффективными. FUZxxl указывает ниже, что чисто функциональный код может быть легче оптимизирован компилятором. Этот эффект, казалось бы, уравновешивает или перевешивает более эффективное неоптимизированное решение. - person Stephen Hosking; 13.09.2010
comment
Оптимизация компиляторов - это миф (разве что в Haskell). Хорошо, это утверждение слишком сильное, но очень редко компилятор нечистого языка лучше справляется с оптимизацией чистого кода, чем человек, оптимизирующий его вручную с помощью нечистого кода. Компиляторы просто не так хороши. - person Brian; 13.09.2010
comment
@Brian: Учитывая, что базовое оборудование нечистое, я думаю, можно с уверенностью сказать, что достаточно умная ручная оптимизация человеческого написания нечистого кода превзойдет любой компилятор. Компиляторы Haskell удивительно умны, но есть причина, по которой существуют такие вещи, как монада ST, и вся глубокая магия GHC в основном просто дает вам производительность, аналогичную грамотно написанному (не оптимизированному) нечистому коду. Без обширных статических гарантий, предоставляемых Haskell, безумие ожидать, что другие языки будут работать лучше (хотя я слышал, что OCaml иногда может давать впечатляющие результаты). - person C. A. McCann; 13.09.2010
comment
Вы большой поклонник утверждений в скобках, не так ли? :) - person Lucas; 14.09.2010
comment
Это потому, что в английском нет # свет :) - person Stephen Hosking; 15.09.2010
comment
@camccann: OCaml получает отличную производительность с помощью совершенно другого пути: имея простую и предсказуемую модель затрат и языковой дизайн, который имеет тенденцию к быстрому сокращению кода. Это полная противоположность Haskell. - person J D; 24.04.2011
comment
Оптимизация компиляторов - это миф Да ладно. Вы не хотите, например, найдите в C ++ каждую функцию, которая однажды использовалась в коде для записи inline, особенно если она большая. Но компилятор достаточно умен, чтобы увидеть для оптимизации времени компоновки, что call'n'ret здесь не нужно, и встроить его. Он также может выявить, что небольшой цикл выполняется только один или два раза, и развернуть его, но вы не захотите делать это явно в коде. Кроме того, компилятор может выполнять оптимизацию в зависимости от того, какие инструкции ЦП доступны, и даже оптимизировать для размера кэша ЦП (то, что любят Gentoo). - person Hi-Angel; 13.06.2015

Возможно, вас заинтересует " Ленивые потоки функционального состояния ". Я проработал только первые несколько страниц, которые очень ясны (я уверен, что все остальное тоже очень ясное).

Важным моментом является то, что когда вы используете Control.Monad.ST для выполнения подобных действий в Haskell, система типов сама обеспечивает инкапсуляцию. В Scala (и, вероятно, F #) подход больше похож на «просто поверьте нам, что мы не делаем здесь ничего хитрого с этим ListBuffer в вашем map».

person Travis Brown    schedule 13.09.2010
comment
На практике же в реальном коде Haskell неконтролируемые побочные эффекты изобилуют. - person J D; 24.04.2011
comment
@JonHarrop, можете ли вы привести пример? - person sleblanc; 31.08.2015
comment
@sebleblanc: Конечно. Я изучил несколько фрагментов кода Haskell с открытым исходным кодом, таких как игра Frag, и обнаружил, что они часто прибегали к unsafePerformIO. Я спросил авторов, почему, и все они ответили на производительность. С тех пор я слышал, что основные проблемы были решены, и в этом больше нет необходимости, поэтому, возможно, это уже не так. - person J D; 31.08.2015
comment
Для справки, я не смог найти ни одного появления unsafePerformIO во всех опубликованных версиях Frag. - person sleblanc; 01.09.2015
comment
Опять же, можете ли вы подтвердить свои претензии? - person sleblanc; 09.09.2015

Если функция использует локальные, частные (для функции) изменяемые структуры данных, распараллеливание не затрагивается. Поэтому, если функция map внутренне создает массив размера списка и выполняет итерацию по его элементам, заполняя массив, вы все равно можете запускать map 100 раз одновременно в одном и том же списке и не беспокоиться, потому что каждый экземпляр map будет иметь свой собственный частный множество. Поскольку ваш код не может видеть содержимое массива, пока он не будет заполнен, он фактически чистый (помните, что на каком-то уровне ваш компьютер должен фактически изменить состояние ОЗУ).

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

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

person Gabe    schedule 13.09.2010
comment
На самом деле это распространенное заблуждение. Нечистые кишки, как правило, улучшают масштабируемость, потому что мутация на месте означает лучшее повторное использование пространства и, следовательно, меньшее выделение и лучший объем памяти, что означает меньшую конкуренцию за сборщик мусора и основную память, соответственно. Меньшая конкуренция за общие ресурсы означает лучшую масштабируемость. - person J D; 24.04.2011

Такой же подход можно найти в Clojure. Неизменяемые структуры данных в Clojure - список, карта и вектор - имеют свои «временные» аналоги, которые можно изменять. Ссылка Clojure на переходный процесс побуждает использовать их только в коде, который не может быть увиден "любым другим кодом".

В клиентском коде есть средства защиты от утечки переходных процессов:

  • Обычные функции, которые работают с неизменяемыми структурами данных, не работают с переходными процессами. Их вызов вызовет исключение.

  • Переходные процессы привязаны к потоку, в котором они созданы. Их изменение из любого другого потока вызовет исключение.

Сам код clojure.core использует множество негласных переходных процессов.

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

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

person Abhinav Sarkar    schedule 13.09.2010

Это не повлияет на возможность запуска функции параллельно с другими функциями. Это повлияет на то, можно ли сделать внутреннее устройство функции параллельным, но это вряд ли будет проблемой для большинства небольших функций (таких как карта), нацеленных на ПК.

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

person Stephen Hosking    schedule 13.09.2010

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

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

person fuz    schedule 13.09.2010

Я бы ответил на это вопросом: «Вы пишете функцию или используете ее?»

Есть 2 аспекта функций, пользователей и разработчиков.

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

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

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

В чем преимущества неизменяемости перед изменяемыми структурами - это другой вопрос.

person Snark    schedule 13.09.2010
comment
Но вот в чем был вопрос. - person fuz; 14.09.2010