Статьи

Функциональное программирование: чистые функции

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

Чистые функции

Чистая функция — это функция, в которой возвращаемое значение определяется только его входными значениями без видимых побочных эффектов. Вот как работают математические функции: Math.cos(x) при одном и том же значении x всегда будет возвращать один и тот же результат. Вычисление это не меняет x . Он не записывает файлы журнала, не выполняет сетевые запросы, не запрашивает ввод пользователя и не изменяет состояние программы. Это кофемолка: бобы входят, порошок выходит, конец истории.

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

Заданный вызов чистой функции всегда можно заменить ее результатом. Нет разницы между Math.cos(Math::PI) и -1 ; мы всегда можем заменить первое вторым. Это свойство называется ссылочной прозрачностью .

Держите государство Местным

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

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

Чистые Методы

В Ruby мы обычно не говорим о функциях. Вместо этого у нас есть объекты с методами, но разница невелика. Когда вы вызываете метод объекта, это как если бы объект передавался функции в качестве аргумента self . Это еще одно значение, на которое может рассчитывать функция для вычисления своего результата.

Возьмите метод String последовательности

 str = "ukulele" str.upcase # => "UKULELE" str # => "ukulele" 

Строка превращается в верхний регистр, но исходная строка остается нетронутой. upcase больше ничего не делал, например, записывал в файл журнала или считывал ввод с помощью мыши. upcase это чисто. То же самое нельзя сказать о upcase!

 str = "ukulele" str.upcase! # => "UKULELE" str # => "UKULELE" 

Руби добавляет удар, чтобы показать, что эта функция разрушительна . После того, как вы это называете, исходная строка исчезает, заменяется новой версией. upcase! не чисто.

Преимущества

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

Воспроизводимые результаты

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

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

Распараллеливание

Чистые функции всегда можно распараллелить. Распределите входные значения по нескольким потокам и соберите результаты. Вот наивная версия метода параллельной карты:

 module Enumerable def pmap(cores = 4, &block) [].tap do |result| each_slice((count.to_f/cores).ceil).map do |slice| Thread.new(result) do |result| slice.each do |item| result << block.call(item) end end end.map(&:join) end end end 

Теперь давайте смоделируем некоторые дорогостоящие вычисления:

 def report_time t = Time.now yield puts Time.now-t end report_time { 100.times.map {|x| sleep(0.1); x*x } } # 10.014289725 report_time { 100.times.pmap {|x| sleep(0.1); x*x } } # 2.504685127 

Версия с #map заняла 10 секунд, параллельная версия — всего 2,5 секунды. Но мы можем поменять #map на #pmap случае, если знаем, что вызываемая функция чистая.

мемоизации

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

Лень

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

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

 class Lazy < BasicObject def initialize(&blk) @blk = blk end def method_missing(name, *args, &blk) _resolve.send(name, *args, &blk) end def respond_to?(name) _resolve.respond_to?(name) end def _resolve @resolved ||= @blk.call end end def lazy(&blk) Lazy.new(&blk) end 

Теперь мы можем обернуть потенциально дорогостоящие вычисления в lazy {} ,

 def mul(a, b, c) a * b end a = lazy { sleep(0.5) ; 5 } b = lazy { sleep(0.5) ; 7 } c = lazy { sleep(3) ; 9 } mul(a, b, c) # => 35 

Вызовы в sleep имитируют некоторые ресурсоемкие задачи. Окончательный результат появляется примерно через секунду. Несмотря на то, что для вычисления c потребуется 3 секунды, поскольку значение никогда не используется, нам не нужно нести эту стоимость.

Рефакторинг на Функциональный

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

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

 def parse_cli_options opts = OptionParser.new do |opts| opts.banner = 'cli_tool [options] infile outfile' opts.on('--version', 'Print version') do |name| $stderr.puts VERSION exit 0 end.on('--help', 'Display help') do $stderr.puts opts exit 0 end end opts.parse!(ARGV) if ARGV.length != 2 $stderr.puts "Wrong number of arguments" $stderr.puts opts exit 1 end opts end 

Это как можно дальше от чистой функции. Это делает все из следующего.

  • Напишите прямо в $stderr
  • Позвоните Kernel.exit
  • Положитесь на глобальный ARGV
  • Альтер ARGV

Как вы будете писать тесты для такого чудовища? Это близко, это невозможно. Чтобы сделать это чистой функцией, нам нужно спросить себя, что нужно войти и что должно выйти. В качестве входных данных эта функция явно нуждается в доступе к аргументам командной строки. Как вывод, он должен сказать нам:

  • Был ли разбор успешным?
  • Если нет, что за сообщение об ошибке?
  • Какой код выхода должен использовать процесс?

     def parse_cli_options(argv) opts = OptionParser.new do |opts| opts.banner = 'cli_tool [options] infile outfile' opts.on('--version', 'Print version') do |name| return { message: VERSION } end.on('--help', 'Display help') do return { message: opts.to_s } end end filenames = opts.parse(argv) if filename.length != 2 return { message: ["Wrong number of arguments!", opts].join("\n"), exit_code: 1 } end { filename: filenames } end 

Теперь у нас есть чистая функция, которую очень легко протестировать, и мы можем обернуть ее «императивной оболочкой».

 def run result = parse_cli_options(ARGV) perform(*result[:filenames]) if result.key?(:filenames) $stderr.puts result[:message] if result.key?(:message) Kernel.exit(result.fetch(:exit_code, 0)) end 

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

Функциональное программирование — большая тема, которую понимают не все рубисты. После этих двух статей у вас должна быть хорошая основа для того, чтобы сделать свой собственный код более функциональным. Попробуйте и посмотрите, куда приведет вас путешествие.