ФП: lazy evaluation — это завтрашние результаты вычисления функций уже сегодня.
После вводной статьи о ФП впору переходить к демонстрации конкретных примеров, приемов и трюков.
Сегодняшняя статья будет посвящена ленивым вычислениям (lazy evaluation). Оставайтесь с нами, и вы увидите, как функция может использовать в качестве аргумента результаты своей работы.
В качестве языка для иллюстрирования взят Haskell. От читателя не требуется знания Haskell или его синтаксиса.
Рассмотрим такую задачу: дан текст, в котором используются специальные слова @label
и @ref
. С помощью @label
определяются метки, а с помощью @ref
делаются ссылки на них. Например, так:
Введение@label{intro}. Как известно, чушь бывает разная: зеленая (@ref{green}) и красная (@ref{red}). ... Как мы уже отмечали (@ref{intro}), чушь бывает разная, и именно поэтому жизненно важно ... ... Чушь красная@label{red}. Не следует путать с чушью зеленой (@ref{green}). ... Чушь зеленая@label{green}. Радикально отличается от чуши красной (@ref{red}). ...
Нам необходимо написать программу, которая заменит метки их номером (в виде «[1]», «[2]», ...), а ссылки — текстом «см. [1]». Из нашего образца должно получиться:
Введение[1]. Как известно, чушь бывает разная: зеленая (см. [2]) и красная (см. [3]). ... Как мы уже отмечали (см. [1]), чушь бывает разная, и именно поэтому жизненно важно ... ... Чушь красная[2]. Не следует путать с чушью зеленой (см. [3]). ... Чушь зеленая[3]. Радикально отличается от чуши красной (см. [2]). ...
Классический алгоритм таков: проходим по тексту, собирая метки в порядке следования и назначая им номера. Проходим второй раз, заменяя метки и ссылки на вычисленые номера. Второй проход необходим, т.к. нам могут встретиться ссылки на метки, определенные дальше по тексту. Будем програмировать «сверху вниз»:
> module Main where > import Data.List > import Text.Regex.Posix > import Control.Arrow > > main = do > txt <- readFile "sample.txt" > let numbering = collectLabels txt > let result = replaceLabels numbering txt > writeFile "sample.out" result
Тут txt
, numbering
и result
— это не имена переменных, а имена, связанные (bound) с выражениями. Другими словами мы можем использовать numbering
для того, чтобы быстро сослаться на результат применения функции collectLabels
к выражению, связанному с именем txt
. Можно связывать имена со значениями несколько раз, при этом более «вложенное» связывание перекрывает внешнее (как и в случае с именами локальных переменных в императивных языках программирования).
Поскольку мы идем «сверху вниз» и реализации collectLabels
и replaceLabels
у нас еще нет, так и напишем:
> collectLabels = undefined > replaceLabels = undefined
Сохраняем наше творчество в файл «labeling.hs». Запускаем ghci (интерактивную среду, прилагающуюся к компилятору GHC), в появившемся промпте вбиваем «:load labeling.hs».
Ура, наша программа компилируется, хотя ничего разумного пока еще не делает. Мы еще не знаем, как именно будут реализованы функции collectLabels
и replaceLabels
, поэтому их тело состоит из одного примитива undefined
. Использование undefined
позволяет нам по ходу написания проверять правильность того, что мы пишем, начиная с самых первых строк. Как минимум, мы можем проверять, что компилятору удается выполнить вывод типов (type inference), т.е. определить, какой тип у всех выражений и функций, использованых в нашей программе, и проверить эти типы на соответствие друг другу (type checking).
Компиляторы C++/Java всегда выполняют проверку типов, но возможности вывода типов у них сильно ограничены. Упражнение номер один для пытливого читателя — написать минимальный компилирующийся «скелет», аналогичный приведеному, на C++/Java.
Начнем со сбора меток. Нам нужно найти в тексте все вхождения @label{xxx}
в порядке упоминания и сложить их в какую-то структуру данных. Например, в список. Для извлечения текста используем сравнение с регулярным выражением:
> collectLabels txt = > let matches = ( txt =~ "@label\\\\{([^}]*)\\\\}" :: [[String]] ) > in map (!!1) matches
Явно указывая тип возвращаемого значения для ф-ии (=~)
, мы заставляем библиотеку Text.Regex.Posix
выбирать один из многих вариантов представления результата сравнения с регулярным выражением (всего их более [весь_текст_совпадения, текст_группы_1, текст_группы_2, ...]
для каждого совпадения с образцом. Пример ожидаемого результата: [["@label{ccc}","ccc"],["@label{bbb}","bbb"]]
.
В каждом списке-результате нас интересует второй элемент — имя метки. Ф-ия извлечения элемента списка по номеру имеет имя (!!)
— например, второй элемент списка lst
можно получить выражением lst!!1
. Поскольку нам надо применить операцию ко всем результатам, воспользуемся ф-ией map
и приемом, называемым «частичное применение» (partial application или currying).
Чтобы этот кусок кода было проще понять, обратимся к ghci. Используем его для того, чтобы поиграться с кусками кода и лучше понять, что именно они делают:
<u>Можно просто проверить, совпала строка с регулярным выражением или нет</u>
<b>*Main> "@label{aaa} some text @label{bbb} some more text" =~ "@label\\{([^}]*)\\}" :: Bool</b>
True
<u>... или получить полный текст всех совпадений</u>
<b>*Main> "@label{aaa} some text @label{bbb} some more text" =~ "@label\\{([^}]*)\\}" :: [String]</b>
["@label{aaa}","@label{bbb}"]
<u> ... или полный текст всех совпадений, плюс - текст всех подгрупп</u>
<b>*Main> let matches = "@label{aaa} some text @label{bbb} some more text" =~ "@label\\{([^}]*)\\}" :: [[String]]</b>
[["@label{aaa}","aaa"],["@label{bbb}","bbb"]]
<u>... из которого потом можно извлечь либо текст совпадения</u>
<b>*Main> map (!!0) matches</b>
["@label{aaa}","@label{bbb}"]
<u> ... либо содержимое первой подгруппы.</u>
<b>*Main> map (!!1) matches</b>
["aaa","bbb"]
<u>Кроме того, мы можем посмотреть, что компилятор думает о типе определенного выражения:</u>
<b>*Main> :type map (!!1)</b>
map (!!1) :: [[a]] -> [a]
Если вы использовали функцию map
в Perl, Python или Ruby, то знаете, что ей нужно давать функцию от одного аргумента. В то же время (!!)
— это функция от двух аргументов (список и позиция_в_списке). Однако, если функции (!!)
дать всего один аргумент, то в результате получится новая функция от одного аргумента. Это и есть частичное применение.
Особо обратите внимание на последний пример: используя информацию о том, что (!!)
возвращает элемент списка, компилятор самостоятельно вычислил, что выражение-функция map (!!1)
будет преобразовывать список списков в список просто значений. Самостоятельно посмотрите на тип функций map
, (!!)
и (!!1)
, чтобы понять, как именно компилятор произвел вывод. Подобный алгоритм выведения типов используется также в языках Clean, ML, OCaml, Scala, Nemerle, D. Ожидается, что вывод типов появится в C# 3.0 и Perl 6 (помните, что я говорил о влиянии ФП на mainstream языки?).
Как мы видим, функции не только могут быть использованы в качестве аргументов в вызовах функций, но и могут быть результатами вычисления выражений. В этом смысле функции ничем не отличаются от прочих базовых типов языка — чисел, символов, списков. Говорят, что функции являются полноправными сущностями (first-class citizens). В качестве упражнения номер два можете попробовать определить на C++/Java аналоги функций map
и !!
так, чтобы их можно было комбинировать в стиле map (!!1)
и самостоятельно сделать вывод о том, являются ли функции полноправными сущностями в этих языках.
Проверим наш код:
<b>*Main> collectLabels "some text@label{one} with labels@label{two}\nand several lines@label{three}"</b>
["one","two","three"]
Быстро закрепим пройденый материал: мы увидели кусок программы на функциональном языке Haskell. Язык — со строгой типизацией (как в C++/Java), что не мешает существованию интерактивной среды исполнения (interactive top-level), как в Python. Для создания программы используется создание новых функций путем комбинирования библиотечных функций, и функций, определенных пользователем. Отсуствие изменяемых переменных (mutable variables) пока что сильно не мешает.
Теперь напишем замену меток и ссылок номерами. Имена меток у нас уже собраны в список. Номер, на который надо заменять метку — это ее позиция в этом списке. Нам надо пройтись по всему тексту, находя специальные слова и заменяя их номерами.
Нам нужно разделить исходный текст на строки, которые в свою очередь разбить на слова. Среди слов нам надо найти «специальные» и заменить их на номера, после чего собрать слова в строки, а строки — в текст. Так и запишем:
> replaceLabelsUgly labelList txt = unlines ( map unwords ( map (map process) ( map words ( lines txt )))) > where process = undefined
Люди, страдающие лиспофобией, уже бьются в припадке и хрипят «Скобки! Уберите скобки! Опять эти чертовы скобки! ». Пожалуй, они в чем-то правы. Перепишем эту функцию так:
> replaceLabelsBetter labelList txt = unlines $ map unwords $ map (map process) $ map words $ lines txt > where process = undefined
Уже лучше. «Прикольный синтаксический наворот» — скажут многие. Однако, это не выверт в синтаксисе языка. Тут использована функция ($)
, которая объявлена как инфиксный оператор и определена так:
f $ x = f x
Получается, что $
— это такой способ композиции функций, только и всего. И в самом деле, легко убедиться, что f (g x) = f $ g x
. Можно даже взять и переписать функцию replaceLabels
с использованием композиции функций в чистом виде:
> replaceLabelsPointfree labelList = unlines . map unwords . map (map process) . map words . lines > where process = undefined
Обратите внимание, что в этой форме записи у replaceLabelsPointfree
стало на один аргумент меньше, как это и происходит в математической нотации. Мы пишем f(x) = ...
и g(x) = ...
, но t = f . g
.
Впрочем, тут могут объявится любители Ruby и сказать «все равно эта функция записана криво и непонятно. Что за дела? Чтобы ее понять, надо читать ее с конца. То ли дело запись вида txt.to_lines().map({|l| l.words()}).map ...
, которую можно нормально прочесть слева направо».
Что ж перепишем так, чтобы нормально читалось слева направо:
-> replaceLabels labelList = lines >>> map words >>> map (map process) >>> map unwords >>> unlines -> where process = undefined
Как можно догадаться, (>>>)
— это еще одна функция, позволяющая записать композицию ф-ий вот так, «шиворот навыворот», в виде, напоминающем shell pipes из Unix-а.
Ну, тут уже явно возмутятся даже те, кто честно пытался вникнуть в написанное без перенесения на Haskell опыта работы со своим любимым языком программирования. «И это называется нормально?» — скажут они. «Тут же нифига не понятно!».
Ну, раз непонятно, надо «мерять хвост частями» с помощью ghci:
<b>*Main> lines "this is a\nsample\nmultiline text"</b>
["this is a","sample","multiline text"]
<b>*Main> lines >>> map words $ "this is a\nsample\nmultiline text"</b>
[["this","is","a"],["sample"],["multiline","text"]]
<b>*Main> lines >>> map words >>> map unwords $ "this is a\nsample\nmultiline text"</b>
["this is a","sample","multiline text"]
<b>*Main> lines >>> map words >>> map unwords >>> unlines $ "this is a\nsample\nmultiline text"</b>
"this is a\nsample\nmultiline text\n"
Теперь осталось разобраться с тем, что стоит между map words
и map unwords
. Если process w
проверяет, не является ли слово w
специальным, и заменяет его на номер, то map process ws
выполнит эту операцию для всех слов в ws
. Если ws
— это список слов, стоявших в одной строке, то нам осталось применить эту операцию ко всем строкам текста, чтобы обработать его полностью. Для этого и нужен второй, внешний map
. Получаем, что обработка все слов текста — это и есть map (map process)
.
Быстро закрепим пройденый материал: мы увидели, как возможность объявлять функции инфиксными операторами с нужным приоритетом позволяет гибко создавать новые управляющие конструкции языка и, фактически, создавать удобный нам синтаксис. Прослеживается типичный шаблон (pattern) програмирования на функциональных языках — создание «конвейера» из функций, последовательно применяющих к исходным данным ряд преобразований для получения конечного результата.
Нам осталось определить функцию process
. Она должна работать следующим образом: все слова вида @label{xxx}
заменять на «[номер метки xxx]», все слова @ref{xxx}
заменять на «см. [номер метки xxx]», а все прочие слова — не трогать. С вашего позволения, я сразу приведу соответствующих код, без подробных объяснений.
> replaceLabels labelList = lines >>> map words >>> map (map process) >>> map unwords >>> unlines > where process w | isInfixOf "@label{" w = replace "" w > | isInfixOf "@ref{" w = replace "see " w > | otherwise = w > > -- Эта функция заменяет ссылку/метку в слове `w' на ее [номер], предваряя [номер] строкой `prepend' > replace prepend w = > -- Слово "Введение@label{aaaa}." будет разбито на части "Введение", "aaa" и ".", > -- из которых мы потом соберем результат. > let [[_,prefix,label,suffix]] = ( w =~ "(.*)@.*\\\\{([^}]*)\\\\}(.*)" :: [[String]] ) > in prefix ++ prepend ++ "[" ++ idx label ++ "]" ++ suffix > > -- Возвращает позицию `l' в списке `labelList' (нумерация начинается с 1). > idx l = case elemIndex l labelList of > Just x -> show (x+1) > Nothing -> error $ "Label "++l++" is not defined"
Я надеюсь, что даже минимальный опыт работы с python и ruby позволит без особого напряжения прочесть и понять этот код. Возможно, вам также поможет пример функции, которая для четных аргументов возвращает «fuzz», а для нечетных — «buzz»:
fuzzbuzz x | even x = "fuzz" | odd x = "buzz" | otherwise = "something is odd ..."
Проверим наш код:
<b>*Main> main</b>
В файле «sample.out» должен оказаться текст с замененными ссылками.
Тут самое время спросить: «НУ И ЧТО?». Казалось бы, до сих пор мы не сделали ничего необычного и отличающегося от того, что мы делали бы при программировании на любом другом императивном языке. Ну, разве что, обошлись без переменных :)
Наберитесь еще немного терпения, мы уже подобрались «гвоздю программы». Попробуем соединить сбор меток и замену ссылок в одну функцию. Предположим, что у нас уже есть полученый каким-то волшебным образом список всех меток в документе, назовем его allLabels
. Откуда он взялся? А черт его знает. Допустим, мы сами прислали его себе из будущего, из того момента, когда мы уже обработали весь файл и знаем полный список меток :)
По-прежнему будем обрабатывать текст по словам, но чуть-чуть изменим алгоритм: если в слове есть ссылка, то использует allLabels
для вычисления ее номера. Если же в слове есть метка, то используем allLabels
для вычисления ее номера и добавим метку в список labelList
. Зачем нам нужен labelList
? Ну, должен же откуда-то взяться в будущем список меток, который мы пошлем себе в прошлое :)
Вашему вниманию предлагается функция process'
(апостроф — это допустимый символ в имени функции), которая обрабатывает слово w
, используя словарь меток allLabels
и аккумулятор labelList
, куда мы добавляем все найденые метки.
> process' (_,labelList,allLabels) w > | isInfixOf "@label{" w = replace "" True > | isInfixOf "@ref{" w = replace "see " False > | otherwise = (w, labelList, allLabels) > > -- Заменяем ссылку/метку в слове `w' на ее номер, предваряя его строкой `prepend'. Если `add' == True, > -- добавляем метку в список labelList > where replace prepend add = > let [[_,prefix,label,suffix]] = ( w =~ "(.*)@.*\\\\{([^}]*)\\\\}(.*)" :: [[String]] ) > labelList' = if add then (label:labelList) else labelList > in (concat [prefix, prepend, "[", idx label,"]", suffix], labelList', allLabels) > > idx l = case elemIndex l allLabels of > Just x -> show (x+1) > Nothing -> "Label "++l++" is not defined"
Кроме того, несколько изменим процесс обработки всего текста: вместо того, чтобы превращать его в список списков слов, превратим его в «плоский» список слов, который будет удобно обработать функций process'
. Чтобы можно было собрать текст обратно, сохранив разбиение на слова, свяжем в с каждым словом номер строки, в которой оно (слово) стояло. После замены ссылоку/меток собирем слова в цельный текст, используя сохраненную информацию о том, какие слова были в какой строке.
> process = combineFromWords . replaceLabels . splitToWords > where > splitToWords txt = unzip $ concat $ zipWith tagWords [1..] $ lines txt > replaceLabels (tags, ws) = zip tags (processWords ws) > combineFromWords ws = unlines $ map (unwords . map snd) $ groupBy (comparing fst) ws > tagWords lineNo line = zip (repeat lineNo) (words line) > comparing f a b = (f a) == (f b)
Пару примеров для облегчения понимания этого куска (используйте ghci, чтобы увидеть типы и «попробовать на зуб» все функции, которые я не упомянул в этих примерах):
<b>*Main> zip [1..] $ lines "aaa bbb ccc\nddd eee fff"</b>
[(1,"aaa bbb ccc"),(2,"ddd eee fff")]
<b>*Main> let l = zipWith (\lineNo line -> zip (repeat lineNo) (words line)) [1..] $ lines "aaa bbb ccc\nddd eee fff"</b>
[[(1,"aaa"),(1,"bbb"),(1,"ccc")],[(2,"ddd"),(2,"eee"),(2,"fff")]]
<b>*Main> unzip $ concat l</b>
([1,1,1,2,2,2],["aaa","bbb","ccc","ddd","eee","fff"])
А теперь, собственно, создадим нашу «машину времени» — функцию, которая получит результаты работы функции process'
(собраный списко меток) и «отправит его в прошлое» — передаст в качестве аргумента ... самой функции process'
.
> processWords ws = > -- Из списка слов ["@label{aaa}","@ref{aaa}","ccc"] получаем список > -- [ ("[0]",["aaa"],все_метки_из_будущего) > -- , ("см [0]", ["aaa"], все_метки_из_будущего) > -- , ("ccc", ["aaa"], все_метки_из_будущего) > -- ] > -- Нас интересует последий элемент этого списка, который содержит список всех найденых меток > let result = drop 1 $ scanl process' ([],[],reverse collectedLabels) ws > -- Извлекаем список найденых меток, чтобы использовать его ... > -- ... в предыдущей строке в качестве аргумента для process' ! > (_,collectedLabels,_) = last result > -- Конечным результатом является список обработаных слов, без информации о найденых метках. > in map discardLabelLists result > where discardLabelLists (w,_,_) = w
Чтобы проверить, что этот код действительно работает, изменим функцию main
:
> main' = do > txt <- readFile "sample.txt" > writeFile "sample.out" $ process txt
Выполнив main'
, вы можете убедиться, что все метки и ссылки в действительности были заменены номерами. Черная магия? Отнюдь. Но как ...?
Использование в вычислениях еще не посчитаных значений становится возможным благодаря так называемой «ленивой» схеме вычислений (lazy evaluation) выражений в языках функционального программирования. При использовании этой схемы результатом выражения let y = ((f x) + (g x))
является не фактическая сумма, а что-то вроде ссылки на код, который эту сумму посчитает, когда нам понадобится значение y
(я намерено опускаю технические детали). Если значение нам не понадобится — то и вычисление фактически не произойдет.
Таким образом, даже если вставить в произвольное место программы выражение let foobar = sum ( map (^2) [1..1000000000] )
, можно не боятся, что программа будет тратить время на подсчет суммы квадратов всех чисел от одного до ста миллионов. Если мы нигде явно или косвенно не используем символ foobar
, то это вычисление просто не произойдет.
Возвращаясь к нашему коду, становится понятно, что использование функции idx l
не приводит к фактическому немедленному поиску метки l
в списке allLabels
. Вместо него создается «замороженный код» (thunk), который произведет этот поиск тогда, когда в нем будет необходимость. А необходимость эта появляется аж в самом конце программы, когда мы выводим результат обработки в выходной файл. Естественно, в этот момент времени у нас уже есть полный список меток, использованых в обрабатываемом тексте.
Получается, что я всех обманул — мы не отправляем «в прошлое» результаты вычислений, мы как бы «отправляем в будущее» сами вычисления с пометкой «до востребования».
Прошу не считать эту статью поводом для очередной священной войны — она не писалась в рассчете на это. Я просто надеюсь, что этот текст послужил для вас хорошей пищей для размышлений и поводом присмотреться поближе к функциональному програмированию.
Да, а чтобы вы не думали, что использованый мной прием — искуственный и редковстречающийся, вот вам еще практических примеров из этой же категории:
-- Функция `fib' возвращает список чисел Фибоначчи fib = 1:1:(zipWith (+) fib (tail fib)) -- Функция `primes' возвращает список простых чисел primes = primes' [2..] where primes' (n:ns) = n : primes' [ y | y <- ns, y `mod` n /= 0] -- Функция ralign выравнивает список строк вправо: ralign xs = [ rjustify m x | x <- xs ] where m = maximum [ length x | x <- xs ] rjustify m x = replicate (m - length x) ' ' ++ x
Если эта статья вызовет интерес, то в следующих выпусках нашей программы ожидайте: работа с бесконечными списками и бесконечными структурами данных, зачем нужны в реальной жизни катаморфизмы (foldr) и анаморфизмы (unfoldr) списков и многое другое :)
PS
Текст этой статьи можно сохранить в файл с суффиксом «.lhs» (literate Haskell), и его можно будет без изменений загружать и исполнять в ghci как программу.
PPS
Ссылки для самостоятельного чтения:
- Перечень учебных текстов по Haskell на Haskell Wiki. Беззастенчивая реклама: «Hitchhickers guide to Haskell» написан вашим покорным слугой.
- Wikibook по Haskell
- OCaml Tutorial
61 коментар
Підписатись на коментаріВідписатись від коментарів Коментарі можуть залишати тільки користувачі з підтвердженими акаунтами.