Показаны сообщения с ярлыком программирование. Показать все сообщения
Показаны сообщения с ярлыком программирование. Показать все сообщения

20101001

Как аппликативные функторы с if-ами боролись

Maybe А и Maybe Б сидели на трубе,
А упало, Б пропало, что осталось на трубе?


На днях заметил, как полезны могут быть аппликативные функторы для избавления от леса вложенных условных конструкций (if-ов и case-ов). А именно, как удобно, что тип Maybe можно использовать как аппликативный функтор.

О функторах, пирогах и яблоках



Речь пойдёт о языке Хаскель, но постараюсь рассказать так, чтобы пользователям других языков было тоже понятно. Кто Хаскель знает, этот раздел может пропустить и сразу перейти к следующему.

Итак, для представления такого результата, которого может и не быть, в Хаскеле используется тип Maybe a, где a — любой тип. У Maybe a бывают значения двух видов: Just a («просто а», если значение есть) и Nothing («ничего», если значения нет). Maybe является функтором, то есть из любой функции из a в b (тип :: a -> b) можно сделать функцию из Maybe a в Maybe b (тип :: Maybe a -> Maybe b). В общем, функтором является любой тип f, для которого определена*:

fmap :: (a -> b) -> (f a -> f b)


* определение fmap должно удовлетворять при этом двум условиям: 1) fmap id = id, где id — функция, возвращаюая свой аргумент, 2) fmap должна быть дистрибутивна слева относительно композиции функций... [ссылка]


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

data Яблоко = Яблоко deriving Show
data Пирог = Пирог deriving Show

испечь :: Яблоко -> Пирог
испечь Яблоко = Пирог

испечьЕслиЕсть :: (Maybe Яблоко) -> (Maybe Пирог)
испечьЕслиЕсть = fmap испечь


После этого мы можем печь пироги, которых может и не быть:

ghci> испечьЕслиЕсть (Just Яблоко)
Just Пирог
ghci> испечьЕслиЕсть Nothing
Nothing


А что делать, если самой функции из a в b может и не быть? Как применить Maybe (a -> b) к Maybe a? Такая операция определена для аппликативных функторов, Maybe — один из них. В Хаскеле для этого подключают модуль Control.Applicative, саму же операцию последовательного применения называют звучным словом (<*>).

Поясняю на яблоках. Что делать случае на входе у нас Maybe (Яблоко -> Пирог) — рецепт, которого может и не оказаться? Естественно, что пирог у нас получится тогда и только тогда, когда есть и яблоко, и сам рецепт. Если хоть что-то отсутствует, то и пирога не будет, будет Nothing. Итак, дано:

безРецепта = Nothing
сРецептом = Just испечь


Проверяем, как работает с такими «рецептами» аппликативный функтор Maybe:

ghci> :m + Control.Applicative
ghci> сРецептом <*> Just Яблоко
Just Пирог
ghci> сРецептом <*> Nothing
Nothing
ghci> безРецепта <*> Just Яблоко
Nothing
ghci> безРецепта <*> Nothing
Nothing


Это всё, что нам сейчас потребуется из «теории». И ещё пара замечаний: 1) с аппликативными функторами часто применяется ещё операция (<$>), которая является для них синонимом fmap и в инфиксной записи выглядит короче; 2) любая монада является также аппликативным функтором (обратное неверное), и для неё ap и (<*>) совпадают (об этом полезно знать, хотя нам сейчас и не потребуется).

Избавление от условных конструкций



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

Логику разбора такого формата на императивном псевдокоде можно описать так:

if (структура == координатная) {
if (тип == целые) {
читатьЦелыеПоКоординатам;
} else if (тип == действительные) {
читатьДействительныеПоКоординатам;
} else if (тип == комплексные) {
читатьКомплексныеПоКоординатам;
} else {
ошибка;
}
} else if (структура == массив) {
if (тип == целые) {
читатьМассивЦелых;
} else if (тип == действительные) {
читатьМассивДействительных;
} else if (тип == комплексные) {
читатьМассивКомплексных;
} else {
ошибка;
}
} else {
ошибка;
}


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

Опять псевдокод для пояснения этой идеи:

let читатьСтруктуру = lookup структура словарьФункцийРазбора
читатьЧисло = lookup тип словарьФункцийРазбораЧисла
in читатьСтруктуру читатьЧисло исходныеДанные


На практике дело обстоит немного сложнее. Операция поиска в словаре может ничего не найти. Как легко догадаться, lookup в Хаскеле возвращает Maybe a. Итак, у нас есть Maybe функция, Maybe аргумент, а мы хотим получить Maybe результат. Ничего не напоминает? Да-да, тут самое время применить (<*>).

Однако представим на минуту, как эта задача решается без операции последовательного применения Maybe a. И для функции, и для её аргумента нужно проверять являются ли они чем-то (Just a) или ничем (Nothing), и в результате получается тот же лес проверок, что и в императивном псевдокоде, только в этот раз с проверкой на результат поиска. Можно, правда, все проверки объединить в одной единственной условной конструкции на каждое применение функции, но все проверки надо всё равно записывать явно. То есть без (<*>) получается что-то такое:

let читатьСтруктуру = lookup структура словарьФункцийРазбора
читатьЧисло = lookup тип словарьФункцийРазбораЧисла
in case (читатьСтруктуру, читатьЧисло) of
(Just f, Just g) -> Just (f g исходныеДанные)
_ -> Nothing


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

К счастью, все эти проверки тривиальны, и код можно записать гораздо короче:

let читатьСтруктуру = lookup структура словарьФункцийРазбора 
читатьЧисло = lookup тип словарьФункцийРазбораЧисла
in читатьСтруктуру <*> читатьЧисло <*> (Just исходныеДанные)


Здесь нет ни одной явной условной конструкции, но код, тем не менее, делает все необходимые проверки, и вернёт Just результат только если нашлись обе функции.

У меня получилось немного длиннее, но суть та же:

let p = lookup fmt parsers        :: Maybe FormatReader
nr = lookup fieldq readers :: Maybe (Int, ValueReader)
sy = lookup symq symmetries :: Maybe Symmetry
p' = uncurry <$> p <*> nr :: Maybe ([String] -> Maybe MatrixData)
d = join $ p' <*> (Just toks) :: Maybe MatrixData
m = MM <$> d <*> sy :: Maybe Matrix
in ...


Что тут можно заметить. Во-первых, (<$>) практически так же часто используется, как и (<*>). Эта операция позволяет применять обычные функции к Maybe-значениям. Вместо (<$>) можно поднимать значения в функтор с помощью pure (для любых аппликативных функторов) или Just (конкретно для Maybe) и использовать только (<*>).

Во-вторых, если у нас кругом функции, возвращающие Maybe, и они сами оказываются внутри Maybe (см. p' в моём примере), то рано или поздно появляются «вложенные» значения типа Maybe (Maybe a). Тут помогает монадный join, превращающий их в просто Maybe a. join определён в Control.Monad. И кстати, это тоже помогает избавиться от вложенных тривиальных проверок (join (Just Nothing) даёт Nothing).

В-третьих, при таком подходе мы, естественно, теряем информацию о том, какое именно вычисление не дало результата (дало Nothing). Для передачи «исключения» по цепочке можно использовать другие типы, например Either e, определив для них Applicative.

Заключение



Мне такой способ комбинировать вычисления очень понравился. По-моему, случай, когда все условные проверки сводятся к «если в порядке, то считать дальше, а если нет, то прервать» — довольно распространённый. Тип Maybe с операцией последовательного применения (<*>) позволяет такие проверки, любой степени вложенности, не писать вообще.


Flattr this

20100322

Впечатления о Цюрихаке

Вчера вернулся с Цюрихака. Интересная была поездка. Программировать в компании 80 других фанатов очень даже занятно. Здорово, что можно прямо тут же что-то обсудить, наметить цели, поделить работу и её сделать. Приятно браться за незнакомый код и в сжатый срок добавлять к нему что-то полезное (а с Хаскелем такое по силам даже новичкам; и новичкам с готовностью помогали). Большинство участников поселились все вместе в одном хостеле, так что вечерами собирались большими компаниями, чтоб выпить кружечку пива, познакомиться, послушать рассказы других людей, поделиться своим опытом и впечатлениями. Ну и просто увидеть людей, имена которых были уже знакомы, тоже интересно. Вот они, цюрихакеры (я тут тоже есть):



20091028

Переименование переменных и слияние изменений в Darcs

Ныне к традиционным холиварам, вроде vi против emacs, прибавился ещё hg (Mercurial) против git. И то, и другое — распределённые системы управления версиями (DVCS). В чём их преимущество перед старыми централизованными системами и как пользоваться новыми давно уже написано. Впрочем, выбор этими двумя системами не ограничивается, отдельные маньяки успешно пользуются и другими системами. А среди альтернативных систем совершенно особняком стоит darcs.

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

Основное отличие darcs от собратьев: он отслеживает не состояние каталога с файлами (и историю его изменений), а хранит сами изменения — патчи. А уж состояние рабочего каталога определяется просто как результат применения всех накопленных изменений-патчей. Всякое такое изменение обратимо, а некоторые можно безболезненно переставлять местами (и это очень облегчает слияния).

В случае обычных DVCS, каждое изменение определяется разницей двух состояний каталога. Чтобы объединить такие изменения, нужен их общий «предок», к которому изменения можно применить. В darcs изменение не обязательно должно определяться разницей между двумя состояниями каталога. Это позвляет создавать разные типы изменений, и автор может определить что именно изменение делает (семантически). В том числе есть и такой вид изменений: замена слов в файле (token replace patch).

Покажу, как это работает, а вы уж сами судите, насколько это круто :-)

Завязка



Итак, создадим вначале исходный репозиторий и поместим в него простую программку. Тут отличия между darcs и hg или git минимальны:
$ mkdir repo-0
$ cd repo-0
repo-0$ darcs init
repo-0$ cat > hello.py
def hello(what):
print "Hello %s" % what

hello("World")
^D

repo-0$ darcs add hello.py
repo-0$ darcs record -m 'initial commit' hello.py
Recording changes in "hello.py":

addfile ./hello.py
Shall I record this change? (1/2) [ynWsfvplxdaqjk], or ? for help: y
hunk ./hello.py 1
+def hello(what):
+ print "Hello %s" % what
+
+hello("World")
Shall I record this change? (2/2) [ynWsfvplxdaqjk], or ? for help: y
Finished recording patch 'initial commit'


А теперь создадим две ветки. В каждой ветке сделаем свои изменения. В одной (A) изменим название переменной what на name, а в другой (B) переименуем и перепишем функцию hello().

Внезапно!



Клонируем исходный репозиторий:
repo-0$ cd ..
$ darcs get repo-0 repo-A
Copying patches, to get lazy repository hit ctrl-C...
Finished getting.

И переименовываем в этой ветке переменную. Только хитрость, мы хотим не просто сделать замену слов в файле, а мы хотим явно указать darcs-у, что это именно замена слов. Поэтому вместо текстового редактора выполняем такую вот команду:
$ cd repo-A
repo-A$ darcs replace what name hello.py

Убеждаемся, что программка изменилась:
repo-A$ cat hello.py
def hello(name):
print "Hello %s" % name

hello("World")

И записываем изменения в репозиторий:
repo-A$ darcs record -m 'renamed: what to name' hello.py
Recording changes in "hello.py":

replace ./hello.py [A-Za-z_0-9] what name
Shall I record this change? (1/1) [ynWsfvplxdaqjk], or ? for help: y
Finished recording patch 'renamed: what to name'


Тем временем...



Параллельно создаём другую ветку и как-нибудь меняем функцию hello:
repo-A$ cd ..
$ darcs get repo-0 repo-B
Copying patches, to get lazy repository hit ctrl-C...
Finished getting.
$ cd repo-B
repo-B$ cat > hello.py
def hello(what):
if len(what) > 6:
print "Hello %s" % what
else:
print "Hi %s" % what

hello("World")
^D

Изменения настолько серьёзны, что старое имя функции уже не подходит. Переименовываем её с помощью darcs replace:
repo-B$ darcs replace hello greet hello.py

И записываем изменения:
repo-B$ darcs record -m 'changed hello and renamed to greet' hello.py
Recording changes in "hello.py":

hunk ./hello.py 2
- print "Hello %s" % what
+ if len(what) > 6:
+ print "Hello %s" % what
+ else:
+ print "Hi %s" % what
Shall I record this change? (1/2) [ynWsfvplxdaqjk], or ? for help: y
replace ./hello.py [A-Za-z_0-9] hello greet
Shall I record this change? (2/2) [ynWsfvplxdaqjk], or ? for help: y
Finished recording patch 'changed hello and renamed to greet'


Кровавый финал



А теперь возвращаемся в исходный репозиторий и объединяем изменения:
repo-B$ cd ../repo-0
repo-0$ darcs pull ../repo-A ../repo-B
Wed Oct 28 17:21:41 CET 2009 me@example.com
* changed hello and renamed to greet
Shall I pull this patch? (1/2) [ynWsfvplxdaqjk], or ? for help: y
Wed Oct 28 17:12:12 CET 2009 me@example.com
* renamed: what to name
Shall I pull this patch? (2/2) [ynWsfvplxdaqjk], or ? for help: y
Finished pulling and applying.

И что же мы видим?
repo-0$ cat hello.py
def greet(name):
if len(name) > 6:
print "Hello %s" % name
else:
print "Hi %s" % name

greet("World")

Изменения объединились правильно. Система управления версиями оказалась достаточно умной, чтобы применить изменения в нужном порядке (вначале переписать функцию, а уж потом переименовать все случаи использования переменной).

Я впечатлён.

20091016

Микросоветы

Всё чаще в твиттер
одной строкой пост целый
пишу на память.

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

Приёмы работы
LaTeX и вёрстка
Программирование
Находки (всякие программки)

1. Приёмы работы:

  • Чтобы не закрывать Firefox, когда закрывается последняя вкладка по Ctrl-W: идём в about:config, находим browser.tabs.closeWindowWithLastTab, ставим false. Проверено на FF 3.5.
  • OpenOffice: чтобы запретить разрыв слова (т.е. запретить перенос), вставляем нечитаемый символ U+2060 (Zero-width WORD JOINER). Символ можно найти, запустив gucharmap. Надо в .XCompose добавить...
  • Чтобы использовать новый, сжимающий раза в два лучше, видео-кодировщик Theora 1.1, нужно взять саму новую библиотечку (уже есть в Debian unstable), и, главное, ffmpeg2theora версии не ниже 0.25. На сайте разработчиков есть и бинарные сборки.
  • Принудительное отключение подсветки ЖК-дисплея: xset dpms force off. Отсюда.
  • Банальность. Удаление пустых строк sed-ом: sed '/^\s*$/d'.
  • Редактируя диаграммы Graphviz в Vim, быстрый просмотр по :make можно сделать так: :set makeprg=dot\ -Tpng\ %\ \\\|display\ png:- errorformat='' autowrite. Подставить название используемой программы (dot, neato, fdp, ...).
  • Создание паролей (если нет KeePassX): cat /dev/urandom | tr -d -c 'a-zA-Z0-9' | fold -w 8 | head -1
  • Поиск и удаление дубликатов файлов: fdupes в командной строке, fslint — утилита с графическим интерфейсом.
  • В Debian можно заменить файл пакета, не пересобирая пакет. Поможет dpkg-divert.
  • sudo -i имитирует логин под рутом (даёт #). Бывает полезно (раньше sudo su - иногда пользовался).
  • Как создавать картинки предварительного просмотра видеофайлов:
    ffmpeg -itsoffset -1 -i видеофайл.avi -vcodec mjpeg -vframes 1 -an -y -f rawvideo -s 320x240 картинка.jpg ; done
    Как создавать картинки из PDF:
    convert -thumbnail 300x300 документ.pdf[0] -gravity center -extent 300x300 картинка.png


2. LaTeX и вёрстка:

  • Рекомендуемая минимальная ширина полей, чтобы документ можно было печатать и на A4, и на Letter — А4, слева и справа 20 мм, сверху и снизу 33 мм. RFC 2346.
  • Чтобы избежать разрыва страницы в LaTeX, можно поместить фрагмент текста в окружение samepage. Это частый вопрос.
  • Отступы элементов списка в LaTeX можно настроить, если использовать окружение list вместо itemize. Пример.
  • Чтобы добавить межабзацный пробел, \setlength{\parskip}{10pt plus 1pt minus 1pt}. Особенно полезно в наборе без абзацного отступа. Отсюда.
  • Чтобы выравнять картинку и текст справа от неё по вертикали, по середине, повозившись, сделал себе макрос \sidebyside{}{}:
    \newsavebox{\leftbox}\newlength{\leftboxheight}\newcommand{\sidebyside}[2]{\sbox{\leftbox}{#1}\settoheight{\leftboxheight}{\usebox{\leftbox}}\usebox{\leftbox}\raisebox{0.5\leftboxheight}{#2}}
    Смотрите пример использования.
  • Чтобы автоматически закрывать окружения LaTeX, пользователи Vim могут поставить плагин tex_autoclose. Использование: в режиме вставки Ctrl+\, затем c.
  • Разрезать на страницы и «склеивать» PDF-документы можно с помощью pdftk. Объединить два файла в один:
    pdftk первый.pdf второй.pdf cat output новый.pdf


3. Программирование:

  • В Python, примитивное транспонирование списка пар в пару списков:
    unzip = lambda pairs: zip(*pairs)
    @vlasovskikh подсказал, что для больших списков izip будет быстрее (проверили, так).
  • Занятное и доходчивое объяснение «что такое продолжения» на 11-й минуте видеопрезентации Swarm-dpl.
  • Быстро создавать графический интерфейс для научных программок позволяет библиотечка TraitsUI (Python). Пока не пробовал, но прочитал урок по TraitsUI.
  • Говорят, Intel готовит Concurrent Collections и для Хаскеля.
  • Я же пока проснулся и прочитал про со-процедуры на Си и устройство Даффа. Впечатлился.
  • Хотите полюбоваться, как можно добавлять побочные вычисления «наследованием» типов? Вот, пожалуйста, в этом примере (на Хаскеле). Хотя это, конечно не Java.
  • Учился использвать монадные трансформеры (бррр!) — оказалось несложно. В результате получился такой пример использования StateT поверх IO. Может кому пригодится.
  • Мелкое копирование словарей в Python — грабли.


4. Находки (всякие программки):

  • Atrack — анонимный открытый битторент трекер для Google App Engine. Всего 246 строк кода.
  • Sweet Home 3D — программа для планирования интерьера. Можно рисовать планы комнат, расставлять мебель, крутить по всякому. Сделана красиво.
  • fuse-zip — файловая система FUSE для монтирования zip-архивов. Быстрая, легко собирается по make, умеет писать в архив. Использование:
    fuse-zip архив.zip /точка/монтирования
    Есть также avfs, которая монтирует любые архивы, но не пишет и не такая удобная. Её использовать так:
    mountavfs ; ls ~/.avfs/полный/путь/к/архиву.zip#/файл/в/архиве
    В Debian нужно предварительно добавить пользователя в группу fuse.
  • Python(x,y) — дистрибутив Python для научных работников, для Windows и Ubuntu. Все инструменты и библиотечки «из коробки».
  • В дополнение к своему однострочнику antiodt нашёл ещё хороший конвертер ODT в Markdown odt2txt.py.
  • Дружественный к Гному вариант Xmonad — Bluetile. Раз попробовал, и две недели им пользовался.
  • Попробовал gitit. Самая простая вики для совместной работы над математическими текстами (вместе с jsMath из коробки). Хранилище — git или darcs.
  • TxtSushi — утилитки, позволяющие выполнять SQL-запросы по простому текстовому (CSV, TSV) файлу.


Ух-ты, а немало получилось.

20090918

(Новичковые) ужасы Хаскеля

Я — начинающий программист на Хаскеле, и пока я ещё помню всё, чем он страшен. И это хочу записать. Сразу скажу, когда я приступал к Хаскелю, я ещё не знал практически ничего о функциональном программировании, поэтому одновременно с языком, нужно было осваивать новые идеи и образ мысли. И вообще-то это было здорово. А у страха, как известно, глаза велики. В общем, я думаю, эта заметка может быть полезна и другим начинающим. В ней я укажу на пять непонятных мне вначале, но относительно несложных вещей, поняв которые хотя бы приблизительно, освоить язык мне было гораздо легче.

1. Ламбда-функции


— Но мы называем его лембас или путевой хлеб, он подкрепляет лучше, чем любая пища людей, и он гораздо вкуснее.


Вообще-то, именно поэтому я и выучил Хаскель. Мне было просто любопытно, что означают все эти лямбды. Очень помогла в самом начале статья Пола Худака Conception, evolution, and application of functional programming languages (PDF также здесь). Возможно, есть введения и получше, но я начинал с него.

Ламбда-выражение — самая суть «функций» — это выражение вида
\lambda x \;.\; \text{expression with $x$}
Значением этого выражения является пока ещё безымянная функция одного аргумента (x), что-то с ним вычисляющая (а именно, выражение справа от точки). С лямбда-функциями связана серьёзная математическая теория, но с точки зрения программирования можно считать \lambda ключевым словом для определения функций. Действительно, когда я и до этого уже пользовался лямбдами в Питоне (и почти во всех других современных языках они тоже есть). В Питоне они выглядят вот так:
lambda x: expression with x
Ими было очень удобно пользоваться в filter() и reduce(). И вообще, почти везде, где в качестве аргумента требуется имя функции. Однако у лямбда-функций нет имён, и именно поэтому их ещё называют анонимными (безымянными) функциями. В пайтоне иногда я давал им имена прямо на лету:
add_42 = lambda x: x + 42
Теперь имя add_42 указывает на функцию. Точно такой же результат можно было получить, записав определение функции как обычно:
def add_42(x):
return x+42
А что же насчёт Хаскеля? Да почти то же самое. Символ \ заменяет \lambda, -> служит вместо точки. Всё вместе записывается так:
\x -> выражение с x
И мы даже можем давать имена таким безымянным функциям, так же как и в Питоне:
add_42 = \x -> x + 42
Согласитесь, очень похоже.

Однако тут есть одна тонкость. Как только я начал читать о Хаскеле, я увидел лямбда-выражения, которые поначалу казались немного странными:
\x -> \y -> выражение с x и y
Что означают все эти «стрелочки»? Ответ оказался очень прост и очень полезен в дальнейшем освоении языка.

В Хаскеле все функции являются функциями одного аргумента. Поначалу это может показаться ограничением, но на деле это очень удобная и практичная идея. Любую функцию n аргументов можно представить как функцию одного аргумента, возвращающую другую функцию n–1 аргементов. И по науке это азывается каррированием. Эта идея, в частности, позволяет передавать функции только часть аргументов.

Узнав об этом, мы теперь можем читать любые выражение с множеством «стрелок»:
\x -> (\y -> выражение с x и y)
Значением такого выражения будет фукнция, берущая один аргумент и производящая другую функцию, которая берёт ещё один аргумент. Такое выражение в целом ведёт себя как функция двух аргументов. Например, мы можем вычислить такую функцию двух аргументов в интерпретаторе Хаскеля ghci:
ghci> (\x -> \y -> x + y ) 3 4
7
Конечно, есть более краткий способ записи функций двух аргументов (обратите внимание, что список аргументов брать в скобки совсем не нужно):
ghci> (\x y -> x + y) 3 4
7
Однако знать, что на самом деле все функции нескольких аргументов являются функциями одного аргумента очень полезно. Например, это помогает читать описания типов функций. Например, тип функции map выглядит так:
map :: (a -> b) -> [a] -> [b]
Я обычно читаю это следующим образом: «функция map принимает два аргумента, первый — функцию преобразующую a в b, второй — список элементов типа a, а возвращает список элементов типа b». Но иногда гораздо естественнее записать тот же самый тип так:
map :: (a -> b) -> ([a] -> [b])
«Функция, которая берёт функцию, преобразующую a в b, и возвращает функцию, преобразующую список a в список b».

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

2. Знак равенства


— Ну что, если тут нет смысла, — сказал Король, — тогда у нас гора с плеч: нам незачем пытаться его найти! Сэкономим кучу работы! И все же...


По-моему, знак равенства (=) — самый важный символ в Хаскеле. Понять его важно для понимания языка. И мне кажется, смысл равенства недостаточно подчёркивается во всевозможных учебниках. Например, это единственное ключевое слово, отсутствующее в списке ключевых слов Хаскеля в его вики.

В отличии от большинства императивных языков, где = означает присваивание (то есть действие), в Хаскеле он означает, что левая часть равна правой (то есть описывает свойство).

Дополнение: в комментариях уточняют, «равно» в Хаскеле — связывание имени слева с определением справа.


«Равна» — не значит «становится». Это означает, что нечто равно чему-то ещё. Всегда. Как в математике. a = b в Хаскеле означает, что a равно b по определению, a эквивалентно b.

Таким образом, = в Хаскеле служит для записи определений. «Равно» может определять самые разные вещи, но определяет их статично. Оно не зависит от порядка выполнения операций. На него можно положиться.

Пользователям функциональных языком это покажется слишком уж очевидным, но именно в смысле знака равенства самое важное изменение для тех, кто раньше пользовался императивными языками. Теперь, кстати, мы можем давать имена нашим безымянным функциям:
add = \x -> \y -> x + y
Признаю, что читается это плохо, поэтому в большинстве случаев функции в Хаскеле определяются так:
add x y = x + y
Но и это по-прежнему определение функции add.

3. Классы типов


Significant benefits arise from sharing a common type system, a common toolset, and so forth. These technical advantages translate into important practical benefits such as enabling groups with moderately differing needs to share a language rather than having to apply a number of specialized languages. — приписывается Б. Страуструпу


Система типов в Хаскеле просто прекрасна. В ней очень легко и естественно выражаются многие идеи. И возможно, именно классы типов — это наименее чуждая концепция для тех, кто приходит в Хаскель из процедурного и объектно-ориентированного мира. Во всяком случае, мне так показалось. Однако классы типов — это совсем не то же самое, что классы в Си++ или в Джаве. Гораздо больше они похожи на абстрактные шаблоны классов в Си++, потому что классы типов
  • определяют только абстрактный интерфейс
  • позволяют создавать несколько независимых реализаций интерфейса (таким образом, для любого типа можно определить экземпляр класса, если предоставить реализацию его методов)
  • полиморфны по своей природе и поддерживают наследование
  • не могут иметь переменных состояния

Как только мы привыкнем, что типы классов — это не классы Си++, а абстрактные интерфейсы, и экземпляры классов это не «объекты», а конкретные реализации абстрактных интерфейсов, Хаскель сразу станет привычным и уютным.

Я очень советую почитать вики-статью OOP vs type classes, которая гораздо более детально сравнивает объектно-ориентированный подход и классово-типовой.


4. Монады


И так как всякое настоящее состояние простой субстанции, естественно, есть следствие ее предыдущего состояния, то настоящее ее чревато будущим, — Лейбниц, «Монадология»


Не важно, насколько мягкое введение в Хаскель, рано или поздно его читатель упрётся лбом в крепкую стеную из монад. Да, это вам не плюшки тырить, это вам серьёзная математика. Где-то за этой стеной.

Но вот что я понял: изучать абстрактную математику совсем не обязательно, чтобы монады использовать, а они и правда очень изящная программистская техника. Вначале они казались мне немного странными, но понять раз и навсегда монады гораздо легче, чем запоминать (и правильно применять!) бесчисленные шаблоны ОО-проектирования. Монады логичней.

Поскольку тьюториалов по монадом огромное множество, я не буду их здесь повторять и ожидаю, что вы их уже прочитал парочку. Что же не так с монадами? Для человека, привыкшему к императивным языка, испорченному годами объектно-ориентированного мышления монады кажутся странными. Они выглядят как абстрактный класс-контейнер с загадочным методом >>=:
class Monad m where
return :: a -> m a
(>>=) :: m a -> (a -> m b) -> m b
fail :: String -> m a
Хорошо, если return — конструктор, то почему такое чудное имя? Если это класс-контейнер, то как из него что-либо извлечь? И какой смысл применять функцию внутри контейнера (а именно это делает метод >>=, называемый также операцией связывания), если мы не можем вытащить результат из этого контейнера?

Отвечу вначале на последний вопрос. Зачем нужно связывание (>>=)? Монады являются и одновременно не являются контейнерами. Они — обёртки, упаковки для вычислений, а не для значений (return). Однако они обёртывают вычисления не для того, чтобы их было удобнее хранить в монадных коробочках, а чтобы их можно было удобнее соединять друг с другом. Вместо «коробочек» представьте обыкновенные кирпичи, которые ровно кладутся друг к другу. Это, кстати, похоже на шаблон Adapter в ОО-проектировании. Каждая монада определяет какой-то способ передавать результат от одного вычисления к другому и реализует стандартный интерфейс, чтобы этот способ использовать (>>=). И что бы ни случилось, результат всегда останется в той же монаде (даже, если произойдёт сбой, fail).

Самая простая программистская аналогия монадам, которую я придумал, это конвееры (pipes) в командной оболочке Unix. Монады обеспечивают однонаправленный конвеер для вычислений. То же самое делают и конвееры в Unix. Например:
$ seq 1 5 | awk '{print $1 " " $1*$1*$1 ;}'
1 12 83 274 645 125
seq создаёт список целых чисел. awk вычислят куб каждого из них. Что здесь замечательного? У нас есть две слабо связанные друг с другом программы, которым мы можем легко указать работать вместе. Поток текста создаваемый программой слева попадает по конвееру в программу справа, которая может читать этот поток, что-то с ним делать и создавать уже новый текстовый поток. Текстовый поток — общий формат для результата вычислений, | — операция, связывающая их воедино.

Монады очень похожи. >>= берёт внутреннее вычисление из монады слева и подставляет его в вычисление справа, которое всегда должно создавать ещё одну монаду того же типа.

Как вы уже, наверное, знаете, списки и тип Maybe в Хаскеле — монады. Например, пусть у нас есть простое вычисление, которое возвращает пару из числа и его куба обратно в монаду (return):
\x -> return (x, x^3)
тогда мы можем взять список и направить его «по конвееру» в это вычисление:
ghci> [1,2,3,4,5] >>= \x -> return (x,x^3)
[(1,1),(2,8),(3,27),(4,64),(5,125)]
Заметьте, что мы получили список пар. Это та же самая исходная монада (то есть список). Однако если мы возмьём значение Maybe и направим его в то же вычисление, на выходе у нас будет та же сама монада Maybe:
ghci> Just 5 >>= \x -> return (x,x^3)
Just (5,125)
Таким образом, мы можем создать конвеер из двух вычислений, и поведение этого конвеера зависит от контекста (т.е. от того, какая монада используется), а не от самих вычислений. В отличии от юниксовых конвееров, монады строго типизованы, и сама система типов заботится о том, чтобы выход одной монады был совместим с входом другой. И в отличии от юниксовых конвееров, мы можем задавать наши собственные правила связывания (>>=). Например, такие: «не делать более 42 вычислений подряд» или «посмотреть на входное значение, сделать то или это». Классы монад содержат в себе подобные правила, как соединять вычисления.
Дополнение: в комментариях подсказывают, что гораздо более подробно и более строго аналогия между юниксовым конвеером и монадами разобрана в статье Monadic i/o and UNIX shell programming.
Теперь, я надеюсь, вы понимаете монады не хуже меня (не обязательно полностью). Хочу обсудить несколько мнемонических правил. Почему return называется return?

В большинстве языков return возвращает результат вычисления из функции. В Хаскеле же он конструктор для монад. Это очень странно. Однако посмотрим как работает >>=: эта операция извлекает значение из монады слева, а затем связывает его с аргументом функции справа (отсюда, кстати, и другое название метода — bind). А функция справа должна вернуть значение обратно в монаду, чтобы можно было передать эстафетную палочку дальше следующей операции >>=. Это первое мнемоническое правило: return — возвращает вычисленное значения обратно в монаду.

Вторая мнемоника. Функция верхнего уровня любой программы на Хаскеле выполняется в монаде IO (тип функции mainIO ()). Эта монада позволяет выполнять ввод-вывод и вообще любые последовательные действия. Таким образом, монадный код выполняется на самом верхнем уровне программы, и именно он вызывает любой «чистый» код по мере необходимости, а не наоборот. Таким образом, любое «чистое» значение, если не отбрасывается, то рано или поздно возвращается в монаду её вызвавшую.

Надеюсь, что после этих объяснений имя return для монадного конструктора больше не кажется таким уж странным. Я, однако, не утверждаю, что мои объяснения 100% технически верны.

Следующий вопрос бывшего ОО-программиста, как вытащить значение вычисления из монады?. Начнём с того, что монады преднамеренно спроектированы именно так, что это не всегда возможно. Например, нельзя извлечь чистое значение из монады IO. Если дана такая «односторонняя» монада, то всё, что можно с ней делать — передавать внутреннее значение по монадному конвееру дальше. В Хаскеле разработан специальный синтаксис с ключевым словом do, которые делает такую многократную передачу монады по конвееру очень похожей на последовательную императивную программу. Следующие две программы делают одно и то же. Первая записана с do-нотацией:
main :: IO ()
main = do
name <- getLine
putStrLn ("Hi, " ++ name)
а вторая явно использует >>=:
main :: IO ()
main = getLine >>= \name -> putStrLn ("Hi, " ++ name)
Эквивалентная программа на Питоне:
from sys import stdin, stdout

if __name__ == "__main__":
name = stdin.readline()
stdout.write("Hi, " + name)
Однако иногда вытащить чистое значение из монадного вычисления можно. Это не предусмотрено общим монадным интерфейсом, поэтому разработчик монады должен специально предусмотреть возможность извлекать значения наружу. Например, можно извлекать значения из монады Maybe, используя функцию fromMaybe:
ghci> fromMaybe 0 $ Just 3
3
ghci> fromMaybe 0 $ Nothing
0


Заключение по монадам

Итак, связывание (>>=) позволяет объединять различные монадные вычисления вместе. Почти везде, где есть цепь вычислений, монады очень подходят. Конкретные реализации монад могут содержать разные правила комбинирования вычислений. Имя метода return сбивает с толку начинающих, это метод возвращает результа вычисления обратно в монаду, а не из монады. В общем, когда я понял эти простые идеи, это сильно помогло.

5. Страшные слова


Я знаю только то, что ничего не знаю.


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

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

Например, есть современная XML-библиотека HXT. Она очень много использует стрелки. Стрелки — более универсальные комбинаторы вычислений, чем монады, но мне потребовалось гораздо больше, чем один день, чтобы их более-менее понять. Строго говоря, стрелки не являются частью языка, но они — понятие, которое применяют пользователи этого языка. И таких примеров немало. Хотя тем, кому стрелки осваивать не хочется, можно пользоваться более традиционной и активно поддерживаемой XML-библиотекой HaXml.

Я думаю, важно не бояться «страшных слов». К счастью, основополагающие идеи хорошо описаны. Как правило, есть статьи их очень детально объясняющие. Я сам решил осваивать такие идеи по мере необходимости. Это обещает быть и увлекательным, и одновременно посильным.

Заключение



Я перечислил пять простых идей, освоив которые, мне стало легче привыкнуть к Хаскелю. Лямбды — это просто способ записи функций, и функции нескольких аргументов можно всегда записать как функцию одного, возвращающую другую функцию. Типы классов очень похожи на абстрактные полиморфные интерфейсы в объектно-ориентированном подходе. Монады — стандартизованный способ соединять вычисления вместе. А страшные слова — просто страшные слова. Без них можно жить, но скучно.

Надеюсь, мои заметки будут полезны и ещё кому-нибудь.

Эта статья есть также по-английски. This post is also available in English.

20090908

Ещё одна библиотека комбинаторного парсинга

Не так давно я писал о библиотеке pyparsing для комбинаторного парсинга в Python. В комментариях появилась ссылка на ещё одну библиотеку, о которой я вначале не знал, а именно на funcparserlib, написанную Андреем Власовских.

В общем-то, я посмотрел на новую библитечку, и она мне тоже понравилась. Подкупает сравнительная простота самой библиотеки, ясные исходники и подробно написанные руководства — понять как работает библиотека нетрудно. Правда, при чтении документации нужно быть знакомым с нотацией типов, принятой в Haskell (ArgType -> ResultType). Общее впечатление: имеющиеся в funcparserlib комбинаторы практически полностью ортогональны друг другу, записываются кратко, места на экране занимают мало, называются понятно.

Комбинаторов в библиотеке всего-то: some, a, many, finished, maybe, skip, oneplus, forward_decl, +, | и >>. Действие большинства комбинаторов угадывается из названия. Рабочие лошадки: some(функция-предикат) берёт токен, удовлетворяющий условию, a(токен) берёт токен, указанный в аргументе, many(парсер), maybe(парсер), oneplus(парсер) — множественное, возможное или хотя бы однократное срабатывание парсера, skip(парсер) отбрасывает всё найденное парсером. Плюс (+) последовательно применяет два парсера, «или» (|) пробует альтернативные варианты, >> подставляет результат парсера в функцию и создаёт новый парсер (очень полезный комбинатор!). Ещё есть tuple для группировки.

Для пробы я решил написать пример разбора того же файлового формата, что и в заметке о pyparsing. Мне кажется, что наиболее эффективно применять funcparserlib с предварительным лексическим анализом (разбиением текста на токены-лексемы). В принципе, готовый токенизатор — часть стандартной библиотеки Python, поэтому это не проблема. Удобная обёртка приведена в руководстве к funcparserlib. Однако мне хотелось написать пример в том же стиле, что и для pyparsing, поэтому в примере ниже я разбираю текст на уровне отдельных символов.

Полный текст примера с тестами кладу в pastebin. Далее пояснения.

Половина кода пришлась на разбор чисел. Вообще, по-моему, в такого рода библиотеках было бы хорошо класть готовые парсеры для чисел, дат, e-mail и тому подобных обыденных вещей где-нибудь в разделе contrib... Впрочем, написать разбор чисел было даже занятно.

Чтение знака числа. Если ни плюса, ни минуса нет, подразумеваю плюс. Волшебный комбинатор >> позволяет обработать результат и сразу подготовить ответ, или -1, или +1:
sign = maybe(a("-")|a("+")) >> (lambda c: c == "-" and -1 or +1)

Целые числа состоят из знака и последовательности цифр. Так и запишем:
digits = many(some(lambda c: c.isdigit()))int_num = sign + (digits >> to_int) >> mk_int

Здесь я пользуюсь опять комбинатором >> и двумя вспомогательными функциями. Одна превращает последовательность символов-цифр в число (to_int), другая умножает результат на -1, если необходимо (mk_int). Эти функции вспомогательные, можно пропустить:
powers = lambda digs: zip(digs,xrange(len(digs)-1,-1,-1))add_digit = lambda acc, dp: acc+int(dp[0])*10**dp[1]to_int = lambda digs: reduce(add_digit, powers(digs), 0)mk_int = lambda (s,i): s*i

Аналогично строю и парсер для рациональных чисел, только прибавляю ещё и дробь, и, соответственно, ещё две вспомогательных функции:
to_frac = lambda digs: to_int(digs)*1.0/10**len(digs)mk_frac = lambda (s,i,f): s*(i+f)frac_num = sign + (digits >> to_int) + (skip(maybe(a("."))) + (digits >> to_frac)) >> mk_frac

Вот и всё. Использовать примерно так:
>>> frac_num.parse("-1.25")-1.25

Второй половиной кода оказалось написание парсеров для аналогов Literal и Word из pyparsing. Это необходимо, потому что без токенизации приходится распознавать цепочки токенов. Дополнительно создал парсер ws для пропуска пробелов:
pack = lambda cs: ''.join(cs)literal = lambda s: reduce(lambda a,b: a+b, map(a,s)) >> packword = lambda p: oneplus(some(p)) >> packws = skip(many(some(lambda c: c.isspace())))

После этих определений собственно парсер выбранного формата укладывается в три выражения:
varname = word(lambda c: c.isalpha())var = (ws + varname + ws + frac_num + ws)custom_format = skip(literal("Inspection")) + \ws + skip(literal("#")) + \ws + int_num + \ws + skip(literal("SHOULD")) + \ws + skip(literal("Ref. Sys")) + \ws + int_num + \many(var)

Смотрим на результат:
$ python test.py < input.txt (2, 1, [('X', 28.749300000000002), ('Y', 78.995999999999995), ('Z', -1.0014000000000001)])


В общем, впечатления хорошие. Документация у библиотеки приличная. Использовать приятно. Только одно досадное неудобство было связано с тем, что setup.py install --prefix=... из дистрибутивного тарбола не сработал как надо. Впрочем, библиотека такая маленькая, что можно положить все три её файла прямо в свой проект, без общесистемной установки.

Тонкости: нужно быть осторожным, не помещая универсально успешный парсер внутрь many, чтобы избежать вечного цикла. Короче, нельзя внутрь many помещать many, maybe и pure. Подробнее — см. FAQ.

См. также заметку про pyparsing.

Дополнение: слайды презентации funcparserlib на DevConf 2010:

20090712

Необыкновенно лёгкий парсинг в Python

Нашёл просто волшебную библиотечку для парсинга в Python (хм, правильно говорить синтаксического анализа), pyparsing. Ниже на простом примере я покажу, как её можно использовать для разбора пользовательских форматов данных.

Нашёл так: читая Real World Haskell, узнал про комбинаторную библиотеку для синтаксического анализа Parsec. Примеры в книжке впечатлили. В отличие от традиционного подхода, при этом нет разделения на лексический анализ (выделение «слов»-лексем) и синтаксический анализ (преобразование потока «слов» в упорядоченную структуру данных) — в комбинаторном парсинге эти два этапа объединяются. Берутся небольшие функции, распознающие элементы текста, и затем они комбинируются в соответветствии с синтаксисом текста. Таким образом, сама комбинация функций непосредственно отражает грамматику, и она же, естественно, является и программой для разбора текста. Как у всякой удачной идеи, у Parsec есть множество подражаний. Для Python комбинаторных парсеров нашлось целых два уже три уже четыре — Pysec, Pyparsing, LEPL (для Python 2.6/3.0) и funcparselib. Я буду говорить о pyparsing.

В следующей заметке — Ещё одна библиотека для комбинаторного парсинга — смотрите аналогичный пример для библиотечки funcparserlib.


Итак, перейдём к делу. Предположим нужно читать файлы состоящие из записей следующего вида:
Inspection
# 2 SHOULD Ref. Sys 1
X 28.7493
Y 78.9960
Z -1.0014

Всё необходимое импортируем из модуля pyparsing. При работе поглядываем в документацию к модулю. Для простоты примера импортируем всё:
from pyparsing import *

Теперь начинаем описывать грамматику. Например, определим числа как слова, состоящие из цифр, знака точки и дефиса (минуса)
number = Word(nums + ".-")

а значения переменных определим как пару заглавной латинской буквы и числа:
var = Regex("[A-Z]") + number

Обратим внимание, что плюс между двумя простыми парсерами (регулярное выражение и слово) создаёт новый парсер, который распознаёт уже последовательность выражений. По-умолчанию pyparsing игнорирует все лишние пробелы и переводы строк между элементами разбираемого текста (обычно именно это и нужно), поэтому указывать в грамматике наличие пробелов между элементами необязательно.

Уже на этом этапе мы можем попробовать наш парсер переменных. Запускаем интерпретатор и выполняем:
>>> var.parseString("X   42.0")
(['X', '42.0'], {})

— на выходе получили структуру данных в соответствии с нашей грамматикой (имя переменной и число за ним).

Допишем всё остальное. Для простоты будем считать комментарием всё после знака «#» до конца строки (комбинатор restOfLine):
comment = "#" + restOfLine

Теперь мы можем описать грамматику всей записи в целом.
record = Suppress("Inspection" + comment) + OneOrMore(var)

Запись опознаём по слову «Inspection» в начале (здесь строковой литерал Python автоматически конвертируется в Literal-парсер, проверяющий буквальное соответствие слову). Далее, обнаружив начало записи, состоящие из слова «Inspection» и следующий за ней комментарий, мы можем их просто пропустить (комбинатор Suppress), а вот то, что следует дальше — нам интересно. Мы ожидаем, что дальше могут идти значения для одной или нескольких переменных (применяем комбинатор OneOrMore).

Последний штрих. Нужно указать, что в файле таких записей может быть несколько. Для удобства работы с полученной структурой переменные каждой из записей группируем вместе (комбинатор Group):
datafile = OneOrMore(Group(record))

Всё! Синтаксический анализатор для нашего формата данных готов. Использовать можно, например, так:
import sys
print datafile.parseString(sys.stdin.read())


Проверяем:
$ python example.py << END
> Inspection
> # 2 SHOULD Ref. Sys 1
> X 28.7493
> Y 78.9960
> Z -1.0014
>
> Inspection
> # 3 SHOULD Ref. Sys 1
> X 54.0394
> Y 64.3977
> Z -0.9950
>
> END
[['X', '28.7493', 'Y', '78.9960', 'Z', '-1.0014'],
['X', '54.0394', 'Y', '64.3977', 'Z', '-0.9950']]

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

Например, чтобы разбирать также и строчку с «#» в моём примере, программку можно изменить так:
from pyparsing import *
number = Word(nums + ".-")
var = Regex("[A-Z]") + number
desc = Suppress("#") + Word(nums) + Word(alphas) \
+ Suppress("Ref. Sys") + Word(nums)
record = Suppress("Inspection") + desc + Group(OneOrMore(Group(var)))
datafile = OneOrMore(Group(record))

На выходе этот парсер даст:
[['2', 'SHOULD', '1', [['X', '28.7493'], ['Y', '78.9960'], ['Z', '-1.0014']]],
['3', 'SHOULD', '1', [['X', '54.0394'], ['Y', '64.3977'], ['Z', '-0.9950']]]]


P.S. Нормального тьюториала по pyparsing в сети я не нашёл, но автор библиотеки написал и продаёт на O’Reilly учебное электропособие за 10 долларов. Справочная же документация и разные примеры в интернете — вполне толковы.

См. также заметку про funcparserlib.

20090526

Twtrize — сократитель речи

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

Люди иногда сознательно сокращают слова, набирая SMS или твиты — чтобы потратить меньше денег или укоротить сообщение.

Идея возникла, когда на одном из многочисленных «сократителей URL» я увидел надпись «Shrink text». И мне пришло в голову, что вот он возьмёт, и сократит сам текст: выдаст что-нибудь вроде «shrnk txt». Конечно, сервис всего лишь заменял в тексте URL, но я подумал, что можно было бы сокращать и сам текст.

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

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

Зависимости


Программа написана на Literate Haskell (это значит, что то, что, вы сейчас читаете, и есть программа!). Используются следующие модули:
> import System.IO.UTF8 as U
> import Data.Char (toLower)
> import Text.Regex.Posix ((=~))
> import Data.Char (isPunctuation)

TODO: Я использую старый способ работать с UTF-8 (utf8-string), надо переделать под новую библиотеку text.

Алгоритм


Данная программа «сжимает» русский текст так:
I. Из слов убираются (почти) все гласные и мягкие знаки,
> filterVowels = filter (`notElem` (aVowels ++ jVowels))

Неприкосновенны гласные, которые:
I.a. являютя частью приставки «не-»
> rmVowels = map wordFilter
> where
> wordFilter ('н':'е':cs) = "не" ++ wordFilter cs

I.b. стоят в трёх- и менее -буквенных словах
>    wordFilter w = if length w <= 3
> then w

I.c. стоят в начале или конце слова
>                    else
> let (prefix,inner,ending) = splitWord w
> in prefix ++ (ajaFilter inner) ++ ending

>    splitWord s  = let p = takeWhile dontRemove s
> r = drop (length p) s
> e = reverse $ takeWhile dontRemove $ reverse r
> m = take ((length r) - (length e)) r
> dontRemove c = c `elem` vowels || isPunctuation c
> in (p,m,e)

I.d. являются комбинациями со звуком «й»: «-ою-», «-ая—» и проч.
>    ajaFilter [] = []
> ajaFilter s = let (b,m,a) = s =~ diftPat :: (String,String,String)
> diftPat = "[" ++ vowels ++ "][" ++ jVowels ++ "]"
> in (sameConsFilter b) ++ m ++ (ajaFilter a)

I.e. стоят меж двух одинаковых согласных
>    sameConsFilter [] = []
> sameConsFilter s =
> let (b,m,a) = s =~ sameConsPat :: (String,String,String)
> sameConsPat = "(["++consonants++"])[" ++ vowels ++ "]\\1"
> in (filterVowels b) ++ m ++ (sameConsFilter a)

Программа использует такой список гласных:
> vowels = aVowels ++ jVowels

где есть и простые гласные (к ним же причислен и мягкий знак)
> aVowels = "аиоуыэь"

и дифтонгообразующие (не знаю правильного термина — в общем, дающие звук «й»),
к ним же причислена и буква «й»:
> jVowels = "яйёюе"

Для некоторых правил требуется также список русских согласных:
> consonants = "бвгджзклмнпрстфхцчшщ"

II. из предложений убираются знаки препинания, кроме точек, вопросительных и восклицательных знаков
> rmSomePunctuation = filter (not . null) . map rmTrailing
> where rmTrailing = reverse . rmHead . reverse
> rmHead [] = []
> rmHead s@(c:cs) = case c `elem` rmlist of
> True -> rmHead cs
> False -> s

Список подлежащих удалению знаков препинания:
>         rmlist = ",;-—:–"

III. из текста удаляются некоторые предлоги (в телеграфном стиле)
> rmPrepositions = filter (`notElem` preps) . words
> where preps = [ "в", "во", "на", "над", "к", "от", "из"
> , "по", "под", "через" ]

IV. для пущей стилизации текст пишется в нижнем регистре
> tolower = map toLower


Использование программы


Программу можно использовать как простой unix-фильтр: он читает текст из потока stdin и печает «сжатый» текст в стандартный вывод (stdout).
> main = U.interact $ (++ "\n") . twtrize

> twtrize = unwords . filter ( not . null ) .
> rmVowels . rmSomePunctuation . rmPrepositions . tolower

Пример:
    $ printf "Гласные, а также некоторые предлоги — как, например, «на», — из \
текста удаляются, но какие-то остаются.\n" | runhaskell twtrize.lhs

глсные а ткже нектрые прдлги как нпрмр «на» ткста удляются но какие-то
остаются.


Последняя версия: исходник здесь. Лицензия: BSD-3.

20090505

Показать длинные (>80) строчки в Vim

Большинство программистов согласятся, что строчки кода должны быть короче 80 символов. Часто это просто хороший тон: читаем Linux Kernel Coding Style (80) , Style Guide for Python code (79), Good Haskell Style (79), Ruby Coding Conventions (80), Google C++ Style Guide (80)...

Практический вопрос: а как в Vim увидеть, что строка стала длиннее 80 символов? Это может быть очень полезно, если ширина окна больше 80. Простой и дубовый способ: 80| и курсор перемещается на 80-ю колонку. Однако каждую строчку так проверять неудобно.

Более элегантный выход — подсвечивать всё, что за 80-ю колонку вылазит. Сразу куча (похожих) рецептов: Highlight long lines.

Включить подсветку вручную:
:match ErrorMsg '\%>80v.\+'
Чтобы включать подсветку автоматически, каждый раз при открытии буфера, в ~/.vimrc помещаем:
:au BufWinEnter * let w:m1=matchadd('Search', '\%<81v.\%>77v', -1)
:au BufWinEnter * let w:m2=matchadd('ErrorMsg', '\%>80v.\+', -1)
Должно работать в Vim после 7.1.40. При этом последние 4 символа до 80-й колонки будут предупреждающе подсвечиваться «поиском», а все, что после 80-й — «ошибкой».

Дополнение: в комментариях предложен ещё и другой способ выделить последние 4 символа строки:
:au BufWinEnter * let w:m1=matchadd('Search', '\%>76v.*\%<81v', -1)
Тоже работает.


Получается вот так:



Про подсветку табуляций вперемежку с пробелами и концевых пробелов см. следующую заметку.

20090413

Функциональное веб-программирование

Многие читали Beating the Averages Пола Грэхэма (есть перевод). Он использовал Lisp, и считает, что именно поэтому создал самое удачное веб-приложение (которое потом превратилось в Yahoo! Store). Прекрасно!

А вот что реально уже готово и можно использовать прямо сейчас? Я решил не зацикливаться на Хаскеле, и вспомнил, что есть ещё Erlang, Caml, Scala, Scheme, Lisp. Стоило только начать поиски, и я нашёл много интересного. Делюсь находками.

1. Язык программирования Erlang и каркас для разработки веб-приложений Nitrogen. Кстати, сайт Nitrogen на нём же, видимо, и работает. Рекомендую посмотреть видео:

Видео: возможности Nitrogen (дек. 2008) (.mov)

скринкаст: Новые возможности Nitrogen (дек. 2008)


Обратите внимание на счётчик числа строк, нужных, чтобы запрограммировать страницу (такой счётчик есть и на всех страницах сайта Nitrogen). Число, как правило, двузначное. Меня лично поразило вот это — запуск долгоиграющих процессов на сервере. 31 строчка! Чтобы сделать подобное на Python и Django, мне пришлось попыхтеть.

Кроме Nitrogen, для Erlang есть ещё Erlyweb и Erlang Web.

2. Язык программирования Haskell и сервер приложений Happstack. Самое приятное, что проект хоть ещё и молодой, но уже работающий. На нём сделан Happstack-tutorial, на нём работает Patch-tag.com. Первые энтузиасты даже делают на нём свои блоги.

Запустить его на своей машине оказалось не очень трудно. И он работает. Но всё же, я чувствую, мне потребуется время, чтобы с ним более-менее разобраться. Документация, с точки зрения новичка, конечно, уступает документации Django.

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

Вот маленькая презентация Happstack:

Но и не Happstack единым. Читаем Is Haskell a Good Choice for Web Applications?. Весьма обнадёживает. Исходник работающего сайта — в подарок.

3. Язык программирования Scala (гибридный функциональный язык программирований для виртуальной машины Java) и каркас для веб-приложений Lift. Можно посмотреть на его демо.

Интересно, что буквально на днях Гугл объявил поддержку Java на AppEgnine, и народные умельцы уже используют там Scala. Более того, точно так же на AppEngine запустили и Lift.
Scala — по-итальянски лестница. Думаю, игра слов scala — lift теперь всем понятна.


4. Язык программирования Caml и каркас для веб-приложений Ocsigen. Точнее, как я понял, Ocsigen — это веб-сервер, а каркас для веб-приложений называется Eliom. И есть ещё набор библиотек Ocsimore. Однако это детали.

Интересные особенности Ocsigen: валидность XHTML документа гарантируется на уровне типов языка, активно используется стиль отложенных вычислений, управление сессиями, URL-схемами и параметрами страниц автоматическое.

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

Читая сайт Ocsigen, нашёл и совершенно удивительный проект. Немного не в тему, но очень здорово: O'Browser, написанная на Javascript виртуальная машина для байт-кода OCaml. Что это значит? Значит, что код на OCaml можно встраивать в веб-страницы! Вот, например, клон Boulderdash. Что-то подобное есть и для Haskell.


5. Язык программирования Scheme и каркас для веб-приложений LeftParen. Документация выглядит толково. К сожалению, не нашёл сайтов, которые его используют. Или какого-нибудь демонстрационного сайта.

Вообще-то LeftParen построен вокруг уже довольно развитой инфраструктуры для веб-программирования в PLT Scheme. И её вполне можно использовать непосредственно, см. документацию и учебник.

Для Scheme есть ещё Icing.

6. Язык программирования Lisp и веб-каркас Weblocks. Сайт и документация очень мне понравились. Если бы я писал на Лиспе, начал бы, наверное, с этого каркаса. Вот демонстрация.

Есть ещё KPAX (это не русское слово, просто такое смешное сокращение!). Пишут, что уже давно и серьёзно используется, но документация страдает. Для Лиспа есть ещё UnCommon Web, BKNR.

7. Ещё один диалект Лиспа — язык программирования Clojure, как и Scala рассчитан на использование на платформе JVM. Надо ли говорить, что и для него есть веб-каркас: Compojure. И на AppEngine его тоже оперативно запустили.



Вот так-то. Сколько всего, оказывается. Глаза разбегаются.

Созвучен этой заметке будет вот этот пост.

20090410

Иерархия числовых типов в Haskell

В Хаскеле очень затейливая иерархия числовых типов. Всё дело в том, что кроме обычных машинных целых и чисел с плавующей точкой, в ней нашлось место и числам произвольной точности (настоящим целым и рациональным), и комплексным, и числам с фиксированной точностью.

Цитирую по официальному описанию языка:
Класс Num числовых типов является подклассом класса Eq, так как все числа можно сравнить на равенство; его подкласс Real также является подклассом класса Ord, так как остальные операции сравнения применимы ко всем числам, за исключением комплексных (определенных в библиотеке Complex). Класс Integral содержит целые числа ограниченного и неограниченного диапазона; класс Fractional содержит все нецелые типы; а класс Floating содержит все числа с плавающей точкой, действительные и комплексные.
С одной стороны, система устроена очень логично: введение дополнительных операций расширяет множество чисел. Так, деление целых дополняет целые — рациональными. Логарифмы и экспоненты требуют действительных. Однако сразу представить и запомнить всю иерархию классов непросто. Лучше один раз увидеть! Поэтому хозяйке себе на заметку и другим на потеху я составил вот такую памятку:


Исходник диаграммы

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

Also in English.

20090404

gettext для Хаскеля

Василь Пастернак написал Haskell-интерфейс для gettext (библиотека для перевода программ на разные языки — интернационализации, как теперь говорят пишут).

Я хочу дать ссылки на пару его заметок:
Заметки написаны по-английски, но там, в основном, примеры кода, так что разобраться будет нетрудно.

Также: hgettext в HackageDB.

20090330

Скрипты на Хаскеле (пробую писать)

Я, кажется, созрел, чтобы переходить от чтения книжек и статей про Хаскель к попыткам что-то на нём писать самому. Вначале какую-нибудь мелочь. Скрипты, в общем. Поскольку я уже как-то публиковал здесь bash-скрипт rss2lj (кросспост RSS в ЖЖ), то решил в качестве упражнения его переписать и улучшить. Думаю, получилось. В этой заметке расскажу о том, как писал. Ну и о впечатлениях. Скрипт выложен на BitBucket и на Hackage.

Содержание:
 Задача состоит из кучи рутинных операций. Я думаю, именно поэтому, будет полезно и мне на будущее, и другим начинающим и пробующим, увидеть, как они выполняются на Хаскеле. В частности, по ходу дела я разобрался как
  • обрабатывать аргументы коммандной строки,
  • читать и писать файлы,
  • использовать регулярные выражения,
  • отсылать HTTP-запросы,
  • выполнять ввод-вывод в уникоде (UTF-8),
  • получать системное время.
Писать буду как начинающий — начинающим. На словах получается довольно долго, но сам код получился гораздо короче, чем эта статья (около 200 строк, считая комментарии, необязательные декларации типов, пустые строки и декларации импорта внешних модулей).

Хотя Хаскель язык компилируемый и строго типизированный, использовать его для таких дел вполне можно. Код получается примерно такой же, если не более, краткий, как на Python, а компилируется даже на лету достаточно быстро. Есть и особенности. Во-первых, вместо беззаботного duck-typing здесь — строгая типизация. Поэтому писать надо аккуратнее (но и ошибок при исполнении меньше). Однако в Хаскеле эта строгая типизация сделана на основе системы типов Хиндли–Миллнера и, в отличие от C++, под ногами не путается. Во-вторых, чтобы использовать преимущества функционального подхода (например, отложенные вычисления, частичное применение функций) нужно отделять чисто функциональную часть программы от императивных фрагментов. В простейшем случае, это означает необходимость отделить операции ввода-вывода от вычислений (преобразования информации). Переводя на Хаскель: функции ввода-вывода будут иметь монадный тип IO a, остальные же будут чистыми (без IO в типе).

Предварительное описание задачи и подхода

В моём примере можно выделить следущие операции ввода-вывода:
  • получение URL из аргументов командной строки,
  • чтение содержимого RSS или Atom фида по заданному URL,
  • чтение (и потом запись) файла со списком уже обработанных записей,
  • чтение файла с настройками доступа к учётной записи ЖЖ,
  • получение системного времени,
  • коммуникация с ЖЖ по установленному протоколу.
И соответственно следующие преобразования данных:
  • извлечение идентификаторов всех записей в фиде,
  • отсев уже обработанных записей,
  • извлечение заголовков, ссылок и текста оставшихся записей,
  • форматирование записей по заданному шаблону,
  • разбор файла с настройками.
Для разбора произвольных фидов я велосипед изобретать не стал, а воспользовался библиотекой feed. А для всех коммуникаций по HTTP протоколу использовал библиотеку curl (мне понравился её интерфейс). Обе библиотечки нашёл на Hoogle, а установил с помощью cabal. Из остальных зависимостей: нужен модуль Codec.Binary.UTF8.String (в убунту и дебиан он помещён в пакет libghc6-utf8-string-dev), модуль Text.Regex.Posix (соответственно, пакет libghc6-regex-posix-dev). Потом я сейчас заметил, что использовал urlEncode из Network.HTTP (у меня в ~/.cabal), хотя можно было обойтись пакетным escapeURIString (из Network.URI). То есть одна зависимость могла бы быть попроще.

В отдельный модуль я выделил всё, что касается связи связи с ЖЖ и его протокола (файл LjPost.hs). Собственно всю логику скрипта я поместил в другом файле (Feed2Lj.hs). Вспомогательную утилитку для тестирования модуля LjPost я поместил в RunLjPost.hs. Для использования скрипта она не нужна, я её использовал при его написании.

Модуль отправки сообщений в ЖЖ (LjPost)

Использование библиотеки Curl

Как я уже сказал, для работы по HTTP протоколу я использовал библиотечку curl. Соответственно, помещаю в списке импортов
import Network.Curl
а основную функцию оформляю так, всё это достаточно «императивно»:
postToLj ljuser ljpass subj msg = withCurlDo $ do
curl <- initialize
...
Функция withCurlDo должна охватывать все вызовы к curl и отвечает за инициализацию и деинициализацию библиотеки; initialize собственно и позволяет к библиотеке потом обращаться. Собственно HTTP запрос делается так (запрашиваю аутентификационный токен ЖЖ):
  r <- do_curl_ curl ljFlatUrl getChallengeOpts :: IO CurlResponse
Т.е. используем do_curl_, чтобы получить данные HTTP-ответа; результат (HTTP-ответ) связываю (<-) с переменной r; аргументы do_curl_ были определены мной ранее, URL ЖЖ-API
ljFlatUrl = "www.livejournal.com/interface/flat"
и собственно параметры запроса:
getChallengeOpts = CurlPostFields ["mode=getchallenge"] : postFlags
postFlags = [CurlPost True]
Дальнейшие действия определяются логикой протокола ЖЖ.

Разбор ответа ЖЖ

Во flat-протоколе, ответ сервера выглядит так:
ключ_1
значение_1
ключ_2
значение_2
...
Нужно, во-первых, проверять значение ключа success, во-вторых извлекать значения других ключей, для начала ключа challenge.

Поскольку здесь никакого ввода-вывода уже нет, эту часть кода вполне можно написать «функционально». Самый простой и универсальный сделать это, мне кажется, разбить тело ответа (respBody) на строчки (lines), преобразовать их в ассоциативный список (list2alist) и поискать в нём нужный ключ (lookup), получив, может быть (монада Maybe), значение:
lookupLjKey :: String -> CurlResponse -> Maybe String
lookupLjKey k = ( lookup k . list2alist . lines . respBody )
При этом функция преобразования списка в ассоциативный список простая двухстрочная рекурсия:
list2alist :: [a] -> [(a,a)]
list2alist (k:v:rest) = (k,v) : list2alist rest
list2alist _ = []
Всё, мы написали всё необходимое, чтобы разбирать ответы сервера.

Вспомогательная функция, проверяем, успешен ли был запрос (тогда и только тогда, когда в ответе есть ключ success со значением OK):
isSuccess :: CurlResponse -> Bool
isSuccess = (=="OK") . fromMaybe "" . lookupLjKey "success"
Мы определили isSuccess композицией трёх функций. lookupLjKey возвращает монаду Maybe String. Функция fromMaybe достаёт из неё строковое значение. Функция сравнения (==) записана в префиксной форме и сравнивает значение со строкой «OK».

Прошу заметить, что вытащить из монады Maybe собственно значение всегда можно с помощью fromJust, но если там ничего нет (Nothing), то будет возбуждена ошибка. Здесь функция fromMaybe возвращает в такой ситуации значение по умолчанию (пустую строку), но в других местах скрипта я часто использую fromJust без проверок (т.е. при отсутствии значения скрипт будет прерываться). В программах посерьёзнее, я думаю, лучше всегда использовать функции maybe или fromMaybe, позволяющие использовать Maybe-значения, указав для них значения по-умолчанию.

Отправка сообщения в ЖЖ

Возвращаемся к функции postToLj и пишем, что если аутентификационный токе был успешно получен (isSuccess r), взять текущее время (timeopts <- currentTimeOpts, об этом ниже), подготовить запрос для публикациии сообщения (let opts = postOpts ...) и отправить. Результатом функции будет ответ на последний выполненный запрос:
  if (isSuccess r) 
then do
let challenge = fromJust $ lookupLjKey "challenge" r
timeopts <- currentTimeOpts
let opts = postOpts ljuser ljpass challenge subj msg timeopts
r <- do_curl_ curl ljFlatUrl opts :: IO CurlResponse
return r
else return r
Как всегда в Хаскеле, если сказал if — then, говори и else (с тем же типом).

Ещё одно «новичковое» замечание: в блоке do мы связываем переменные с монадным значением с помощью (<-) (это соответствует присваиванию в императивных языках), но определяем переменные чистыми выражениями с помощью (=). Вообще, (=) в Хаскеле почти всегда можно читать как «равно по определению». Как только я это понял — жить стало проще ;-)

Теперь подробности. Чтобы отправить сообщение, нужно сформировать POST-запрос согласно протоколу. В моём примере этим занимается функция
postOpts u p c subj msg topts =
CurlPostFields ("mode=postevent" : (authOpts u p c)
++ ["event=" ++ quoteOpt msg, "subject=" ++ quoteOpt subj,
"lineendings=unix", "ver=1"]
++ topts ) : postFlags
которая аналогичная getChallengeOpts, только список полей, которые нужно отослать, гораздо больше. И есть некоторые тонкости.

Во-первых, нужно защищать («квотировать») некоторые символы в отсылаемых значениях. Их немного, на помощь приходит определение функции с помощью шаблонов аргумента:
quoteOpt ('=':xs) = "%3d" ++ quoteOpt xs
quoteOpt ('&':xs) = "%26" ++ quoteOpt xs
quoteOpt (x:xs) = x : quoteOpt xs
quoteOpt [] = []
Одно дело сделано. Во-вторых, нужно по имени пользователя, паролю и аутентификационному токену подготовить все поля запроса, касающиеся аутентификации:
authOpts u p c = [ "user=" ++ quoteOpt u, "auth_method=challenge",
"auth_challenge=" ++ quoteOpt c,
"auth_response=" ++ quoteOpt (evalResponse c p) ]
Собственно ответ на токен рассчитывается в одну строчку:
evalResponse c p = smd5 ( c ++ (smd5 p) ) where smd5 = md5sum . fromString
Кроме этого нужно импортировать соответствующие функции преобразования уникодной строки в байт-строку UTF-8 и функцию вычисления MD5-суммы:
import Data.ByteString.UTF8 (fromString)
import Data.Digest.OpenSSL.MD5 (md5sum)
И в-третьих, нужно заполнить в запросе поля, касающиеся времени публикации (текущего времени). Импортируем:
import Data.Time
import System.Locale (defaultTimeLocale)
Берём текущее время:
currentTime = do
t <- getCurrentTime
tz <- getCurrentTimeZone
return $ utcToLocalTime tz t
Заметим, что функция эта связана с вводом-выводом и не является «чистой» (не возвращает одно и то же значение всякий раз). По этой причине я предпочёл не вызывать её из «чистой» postOpts, а передать уже готовый список опций, касающихся времени в postOpts из postToLj. Там, напомню, я писал:
timeopts <- currentTimeOpts
а currentTimeOpts определил так:
currentTimeOpts :: IO [String]
currentTimeOpts = do
t <- currentTime
let opts = [ "year=%Y", "mon=%m", "day=%d", "hour=%H", "min=%M" ]
return $ map (flip showTime t) opts
Т.е. взял текущее время и подставил его в каждый из списка форматов (ЖЖ хочет в таков виде). Вспомогательная функция преобразования времени в строку по формату выглядит так:
showTime = formatTime defaultTimeLocale
Эта функция двух (неуказанных) аргументов получена каррированием функции formatTime. В map я меняю местами её аргументы (flip), чтобы формат передавался последним, и «перчу» ещё раз текущим временем.

Всё, у нас уже есть всё необходимое для отправки любых сообщений в любой ЖЖ. Нужно только знать логин и пароль.

Чтение файла конфигурации

Где-то логин и пароль хранить надо, и самое простое, что приходит в голову, поместить его в файле настроек, написанном в виде
username=мойлогин
password=мойпароль
В коде скрипта указываю путь по-умолчанию к этому файлу:
ljPassFile = "~/.ljpass"
Читаем этот файл и делаем из него знакомый и удобный ассоциативный список:
readPassFile f = do
ljpass <- readFile f
return $ map (\(f,s) -> (f,tail s)) $ map (break (== '=')) $ lines ljpass
Поскольку файл заведомо небольшой, можно использовать простую в обращении readFile. Далее как обычно: режем на строки (lines), каждую строку разбиваем по первому знаку «равно» (map (break (== '='))), правим получившийся ассоциативный список список, откидывая знаки «равно» (λ-функция во втором map). Результат заворачиваем в IO-монаду (return) как того требует тип функции.

Почти готово. Для пущего удобства сделаем себе раскрытие тильды в пути к файлу:
expandhome ('~':'/':p) = do h <- getHomeDirectory ; return (h ++ "/" ++ p)
expandhome p = return p
и собственно функцию, которая, будет нам давать значение любого ключа из файла конфигурации:
readLjSetting key = do
passfile <- expandhome ljPassFile
s <- readPassFile passfile
return (lookup key s)
В этот раз нам надо добавить ещё две декларации импорта:
import IO
import System.Directory (getHomeDirectory)
Последний штрих: в объявлении модуля перечисляем экспортируемые вовне функции, а вспомогательные замалчиваем:
module LjPost (readLjSetting, postToLj, isSuccess, lookupLjKey, putLjKey) where
Наш модуль готов к использованию. Он позволяет нам задавать настройки доступа в файле конфигурации, понимает ЖЖ-протокол, поддерживает challenge-response аутентификацию и позволяет публиковать в ЖЖ сообщения. Меньше 100 строк кода, если не считать комментарии.

Обработка RSS/Atom фида (Feed2Lj)

Переходим к заключительной части рассказа. Скрипт Feed2Lj.hs берёт URL фида из командной строки, настройки ЖЖ из файла с настройками (для него там добавляем третью настройку, имя файла со списком уже обработанных записей), скачивает фид и отсеивает уже обработанные, необработанные преобразует в plain-text, форматирует по образцу и отсылает в ЖЖ, обновляя список обработанных записей. Теперь подробно.

Получение аргументов командной строки

Получить список аргументов просто, его даёт функция getArgs из System.Environment. У нас аргумент один, адрес фида, поэтому может сразу связать нужную переменную (url) с первым элементом списка, проигнорировав остальные:
  url:_ <- getArgs
Такое связывание по шаблону мне кажется очень элегантным приёмом.

Скачивание фида

На помощь опять приходит библиотечка curl. И опять связывание по шаблону, чтобы взять только интересующую нас часть результата:
  (_,rawfeed) <- curlGetString url []

Используем модуль LjPost для чтения настроек

В общем-то вся работа уже сделана, осталось только использовать функцию readLjSetting. У неё тип [Char] -> IO (Maybe [Char]), т.е. по строке она возвращает IO-монаду, внутри которой, может быть строка (значения настройки найдено и считано), а может и не быть (настройка не найдена). Поскольку у нас тут сразу две монады (IO и Maybe), одна в другой, то, чтобы вытащить просто (Just) значение, я поступаю так:
ljuser <- return fromJust `ap` readLjSetting "username"
т.е. функцию fromJust применяю внутри монады IO (ap из Control.Monad). Аналогично с остальными значениями из файла настроек. Кажется немного громоздно с непривычки, но не так уж сложно потом. Уверен, можно написать короче.

Чтение списка обработанных записей

Мой старый bash-скрипт писал ID записей в файл, одно на строчку, поэтому новый скрипт использует тот же формат (и тот же файл). Читаем файл и преобразуем в список строк:
sent_ids <- (return . lines) =<< readFile sentfile
Здесь, чтобы не вводить временную переменную, я явно указал функцию связывания вычислений (=<<). return требуется типом (=<<). Результат эквивалентен записи
tmp <- readFile sentFile
let sent_ids = lines tmp

Отсеиваем обработанные записи

Для начала разберём содержимое фида и подготовим список всех записей. Благодаря библиотечке feed это легко:
  let feed = fromJust $ parseFeedString rawfeed
let items = feedItems feed
Ну а отсеять уже обработанные можно с помощью filter:
  let newitems = reverse $ filter (isNotSent sent_ids) items
Функция-предикат получилась за счёт каррирования isNotSent:
isNotSent sent i = ((snd . fromJust . getItemId) i) `notElem` sent
Буквально: взять просто ID элемента (возможна ошибка), проверить, что не входит в список sent. Сразу подготовим список ID подлежащих обработке записей:
let new_ids = map ( snd . fromJust . getItemId) newitems

Отправляем запись в ЖЖ

Тупо используем уже написанный модуль LjPost. Если даны имя пользователя, пароль, шаблон записи для отправки и собственно запись:
postItem u p t i = do
let message = renderItem t i
let subj = fromJust $ getItemTitle i
r <- postToLj u p subj message
if isSuccess r
then putLjKey "url" r
else putLjKey "errmsg" r
Стоп-стоп-стоп! Какой ещё такой шаблон записи (t) и что делает renderItem? Объясняю: отослать запись нам надо в HTML-е, и хорошо бы можно было менять формат записи, не переделывая весь код. В общем, renderItem — это маленькая template engine, t — её шаблон. Я её опишу в следующих разделах статьи.

Вызываем из main для каждой записи из списка необработанных:
  let t = encodeString "<p>%text%</p><p>( <a href=\"%link%\" title=\"%title%\">дальше</a> )</p>"
mapM_ (postItem ljuser ljpass t) newitems
Здесь мы формируем список IO-действий и их последовательно исполняем (mapM_). То есть последовательно отсылаем все записи из нашего списка. Обратим ещё внимание на encodeString из Codec.Binary.UTF8.String, которая кодирует строку в UTF-8.

Форматирование по шаблону (маленькая template engine)

Напишем нашу маленькую функцию форматирования по шаблону. Пусть, допустим, все параметры шаблона будут представлены как «%параметр%», а спецсимвол «%» будет представлен в шаблоне как «%%». Параметры будет передавать ассоциативным списком, а шаблон — строчкой. На выходе — строчка с подставленными в шаблон параметрами:
renderTemplate _ [] = []
renderTemplate alist s =
let (b,t,a) = s =~ "%[a-z0-9]*%" :: (String,String,String)
tagval t
| t == "%%" = Just "%"
| otherwise = let inner = take (length t - 2) $ drop 1 t
in lookup inner alist
val = tagval t
in if isJust val
then b ++ (fromJust val) ++ renderTemplate alist a
else b ++ t ++ renderTemplate alist a
Функция форматирования сообщения по шаблону готова. В ней мы последовательно «раскусываем» шаблон с помощью регулярных выражений на «текст-до», «тег» и «текст-после». Подставляем на место «тега» (t) значение соответствующего параметра, если есть, или буквальный «%», если тэг пустой. Продолжаем, пока не кончится шаблон.

О регулярных выражениях. Включаем импортом
import Text.Regex.Posix ((=~))
После этого можем в любой строчке искать регулярное выражение:
строка =~ выражение :: возвращаемый тип
Регулярные выражения ведут себя по-разному в зависимости от возвращаемого типа. Мне пока что пригождаются больше всего два из них: Bool для проверки соотвествия строки выражению и тройной кортеж (String,String,String), разрезающий строчку на три части.

Функция форматирования по шаблону готова. Она просто работает со строками (шаблонами) и ассоциативными списками (словарями). А где же обещанная renderItem?

Форматируем запись по шаблону

Итак, renderItem должна получать шаблон и запись из фида, а возвращать строчку. Всё, что делает эта функция — просто достаёт нужные параметры записи, помещает их в ассоциативный список и вызывает функцию форматирования по шаблону renderTemplate. В виде кода это выглядит гораздо понятнее:
renderItem :: String -> Item -> String
renderItem t i =
let title = ( fromJust . getItemTitle ) i
link = ( fromJust . getItemLink ) i
summary = ( takeSentences 5 . eatTags . fromJust . getItemSummary) i
tags = zip [ "title","link","text" ]
[ title, urlEncode link,summary ]
in renderTemplate tags t
Нетривиальна здесь только функция подготовки текста сообщения (summary).

Поскольку я весь текст пересылать не хочу, а хочу только первые несколько предложений, то я вначале преобразую HTML в простой текст (в котором уже нет HTML-тэгов), а затем просто берую первые пять предложений. Таким образом, мне не нужно заботиться о предолжения будут гарантировано законченными.

Функция eatTags использует тот же приём рекурсивного раскусывания строки с помощью регулярных выражений, что и renderTemplate:
eatTags [] = []
eatTags s =
let (b,t,a) = s =~ "</?[^>]*/?>" :: (String,String,String)
in b ++ eatTags a
Все HTML и XHTML теги должны быть этой функцией вырезаны.

Упражнение: изменить функцию так, чтобы тег <img/> выразался не бесследно, а заменялся содержимым его аттрибута alt.

Теперь осталось лишь взять первые n предложений. Возьмём вначале одно:
takeSentence s = 
let ends = ".?!;"
(first,rest) = break (`elem` ends) s
in if not (null rest)
then (first ++ [head rest],tail rest)
else (first,[])
Тут я обошёлся без регулярных выражений, просто задав список разделителей (ends) и раскусывая строку по символу из их числа (break (`elem` ends)). Напоследок присоединяю разделитель, если он есть, к «откушенному» предложению (break прикрепляет его к «остатку»).

Осталось лишь взять первые n штук:
takeSentences n s
| n > 0 = let (s',r) = takeSentence s
in s' ++ takeSentences (n-1) r
| otherwise = ""
Теперь любая запись может быть представлена так, как мы захотим.

Обновляем список обработанных записей

Записи получены, отобраны, отформатированы, отправлены. Осталось только обновить список обработанных. Вначале сохраним предыдущую версию файла (переименованием), а потом запишем на его место новый список:
  renameFile sentfile (sentfile ++ "~")
writeFile sentfile $ unlines (sent_ids ++ new_ids)
Здесь использована функция renameFile из System.Directory.

Заключение

Вот вроде и всё. Можно вызывать получившийся скрипт:
$ runhaskell Feed2Lj.hs URL-вашего-фида
Пробовал пока только с GHC, но, думаю, и с Hugs должно работать. Я, кстати, осознал, что у интерпретатора Hugs есть важное преимущество перед GHC: установка GHC тянет около 100 МБ, а Hugs — всего порядка 10 МБ. Так что как разберусь с Hugs, буду стараться проверять свои скрипты и на нём.

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

Кроме, понятно, гугла, большой подмогой является Hoogle. Сообщения GHC довольно подробные и понятные (разбирать ошибки C++-компиляторов про шаблоны гораздо труднее). Радует, что уже сейчас коллекция библиотек весьма богата (кажется, сопоставима с набором библиотек Python в то время, когда я с ним впервые познакомился). С уникодом, опять же, никаких проблем.

Есть и всякие «но»: но в коде других людей мне ещё далеко не всё понятно, но пихать ввод-вывод в любую точку кода в Хаскеле неудобно и не нужно (сделано намеренно, для отладки служит trace из Debug.Trace), но представить порядок ленивых вычислений не всегда легко, но документированы библиотеки в Hackage весьма лаконично (строго, по делу, но не так доходчиво и очевидно для новичков, как, например в Python), но cabal до сих пор нет ни в Debian, ни в Ubuntu.

Но всё равно, мне понравилось. Буду рад замечаниям и вопросам. Уверен, что-то можно было написать лучше (короче, понятнее и выразительнее). Что-то, наверное, забыл объяснить.