Pull to refresh

Haskell Tutorial с другой стороны

Reading time16 min
Views16K
В данной статье я хочу развенчать мифы о сложности и узкоспециализированности функционального программирования в общем и языка Haskell в частности. Я постараюсь сделать эту статью понятной даже для людей с минимальным представлением о Haskell. Но сначала небольшое введение.

Я отношу себя к категории ленивых фотолюбителей. У меня есть неплохая «беззеркальная зеркалка», иногда на меня нападает желание пощёлкать чего-нибудь вокруг себя. Однако я ленив, и ковыряться потом в полученном фотоархиве у меня ни времени, ни желания нет. Как правило фотографии просматриваются один-два раза сразу после съемки путём подключения фотоаппарата к телевизору через HDMI кабель. Затем фотографии отправляются в небытие каталог ~/Pictures/Photos/Unsorted и, как правило, остаются там навсегда. С различным спец. ПО я как-то не сдружился, посему этот бардак просуществовал почти два года. И вот, на волне изучения Haskell, я созрел для решения проблемы.

Отказ от ответственности


Я не претендую на звание гуру функционального программирования, я допускаю, что код, который я написал — ужасен (ведь вплотную Haskell'ем я занимаюсь чуть более двух месяцев в свободное время), и более того — он может работать неправильно. Цель данной статьи показать, что Haskell — это действительно язык общего назначения, который может применяться не только для кошмарных сложнейших математических расчетов, но и для вполне обыденных, повседневных задач. И отлично с ними справляться.

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

Перед тем, как начать


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

Во-вторых, необходимые инструменты. Далее предполагается, что все события разворачиваются на Linux-машине, однако с небольшими изменениями всё это верно и для Windows, и уж тем более для Mac OS X. Для того, чтобы суметь собрать полученную программу необходимо сделать три вещи:
  1. скачать и установить Haskell platform из репозитория вашего дистрибутива;
  2. установить Haskell-библиотеку работы с EXIF: sudo cabal install --global exif;
  3. установить GTK+\Glade и соответствующий биндинг для Haskell: sudo cabal install --global glade.


Постановка задачи


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

Как будем решать


Всё просто. Начнём с того, что необходимо поддержать два режима:
  • «автоматический» — когда в качестве аргумента командной строки передается директория, которую необходимо «просканировать»;
  • «ручной» — когда запускается простенький GUI фронтенд, в котором мы самостоятельно выбираем директорию для обработки.

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

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

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

Хотели мы этого или нет, но в процессе рассказа о том, чего должна делать наша программа, мы описали три ее основные функции:
  • запуск программы и проверка аргументов;
  • обход каталога;
  • обработка конкретного файла.


Давайте добавим теперь немного исходников.

Ближе к коду


Лирическое отступление. Программы на языке Haskell имеют модульную структуру, прямо как в C, однако разумеется без ужасных header-файлов. Экспортируемые (внешние) функции задаются в описании модуля, далее идут импорты других модулей и код. Программы на языке Haskell состоят только из описания типов данных и функций. Всё.

Итак, начнем сначала. А именно с функции main модуля Main (ведь именно с неё начинается выполнение программы). Вот код всего модуля
Main:

  1. module Main where
  2.  
  3. import System( getArgs )
  4. import Manager
  5. import GUI
  6.  
  7. main :: IO ()
  8. main = getArgs >>= launch
  9.  
  10. --должен быть единственный аргумент с путём до фотографий, иначе запускаем gui
  11. launch :: [String] -> IO ()
  12. launch [x] = processDirectory x
  13. launch _ = startGUI
* This source code was highlighted with Source Code Highlighter.


На описании первого нашего модуля я остановлюсь чуть подробнее, с пояснением основных вещей. Итак, первая строка — описание модуля. Далее строки импорта (из модуля System импортируется только функция getArgs, возвращающая список аргументов командной строки без имени исполняемого файла). Модули Manager и GUI будут описаны позднее.

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

Лирическое отступление. Формально все функции можно разделить на чистые и «грязные», имеющие побочные эффекты. Попробую объяснить на пальцах.
Функция является чистой, если независимо ни от чего при одинаковых входных данных она генерирует одинаковые выходные данные, при этом она никогда не меняет никаких состояний системы и не зависит от них (будь-то состояние файловой системы, или наличия открытого TCP соединения). Возьмем для примера функцию сложения. Она принимает два аргумента, и если оба эти аргумента равны числу 2, то на выходе мы всегда (абсолютно всегда) будем получать число 4. И да, функция сложения TCP соединений не открывает.
Функции с побочными эффектами — разговор отдельный, они как раз придуманы для того, чтобы обозначить зависимость функций от каких-либо внешний проявлений посредством модели, называемой монада ввода\вывода (IO monad). Для простоты восприятия на данный момент будем думать о монадах лишь в контексте «ввода\вывода» (хотя на самом деле это понятие гораздо шире). Функция с побочными эффектами представляет собой описание последовательности действий, в результате выполнения которых мы получим некое значение-результат. Пример:
readFile :: FilePath -> IO (String) — это функция чтения файла по заданному пути к этому файлу. Точнее это не функция, это её тип. Тип функций читать очень просто, сперва идёт имя функции, затем после двух двоеточий символом -> разделены аргументы функций и после крайнего -> идёт тип возвращаемого значения. Это примитивное описание, но его пока достаточно. То есть видно, что функция принимает некий FilePath и возвращает действие ввода/вывода, выполнение которого даст значение String.
Здесь важно понять, что последовательность действий != последовательность вычислений. На самом деле мы вообще точно не знаем в какой на самом деле последовательности будут происходить вычисления, мы определяем лишь порядок действий, за которыми они стоят. Поэтому наличие такой абстракции, как монады, не далет Haskell императивным.

Итак, возвращаемся к нашей main. Она не принимает аргументов и возвращает, очевидно, действие ввода/вывода. Выполнение этого действия и есть выполнение всей нашей программы. Забавно, но во всей полученной программе не написано ни одной чистой функции, что разительно отличается ото всех имеющихся туториалов, однако просто такова задача, решаемая программой. Последовательность выполнения действий определяется функциями >>= и >>, принимающих два аргумента. В первом случае, результат выполнения действия слева передается как аргумент в вызов функции справа, а во втором — действие слева просто тихо выполняется, после чего выполняется действие справа.

В данном случае выполнение действия, определенного функцией getArgs, даёт в результате выполнения список строк-аргументов приложения, который передается на выполнение функции launch. Функция launch описана чуть ниже. Она принимает в качестве аргумента список строк, возвращает же она также действие ввода/вывода, не возвращающего никакого значения. Как я уже говорил, функции в Haskell описываются системами уравнений, что наглядно представлено в определении launch.

Лирическое отступление. То, какое уравнение будет выбрано при каждом конкретном вызове функции определяется процедурой pattern matching, то есть сопоставление с шаблоном. Шаблон функции задается частью уравнения, определенного слева. Процедура сопоставления с шаблоном выбирает управнения в порядке их появления в коде. Универсальный шаблон, обозначающий «что угодно», представляет собой символ _. В шаблонах можно (и нужно!) декомпозировать различные структуры данных, например запись foo ["a","б"] = 0 говорит о том, что если в функцию foo придет список из двух строк «а» и «б», то она вернет 0.

Итак, если в функцию launch приходит список из единственного значения x, то считаем, что это и есть путь к директории и вызываем функцию processDirectory из модуля Manager с аргументом равным этому самому х (заметим, что нет нужды явно указывать, из какого модуля функция, если при этом не получается двойной трактовки, т.е. в двух модулях есть функция с одним названием). А если что-то другое (обратите внимание на шаблон _ ) — то запускаем GUI.

Вот и весь модуль Main! Мы только что реализовали функцию №1 нашей программы, мы ее запустили!
Да, я прекрасно понимаю, как жутко всё это выглядит для непосвященных, однако теперь вновь посмотрите на исходник модуля Main. Вновь прочтите все лирические отступления и скажите себе, так ли это сложно? Может просто непривычно? Для тех, кто мужественно и стоически выдержал все испытания, предлагается продолжить чтение.

EXIF данные


Оставим пока основной вычислительный процесс и напишем функцию извлечения данных о времени фотографии из EXIF. Для этого был создан отдельный модуль Photo. Почему отдельный? Потому что это было продиктовано мне моим внутренним чутьем, предполагается, что это модуль работы с фотографией, вдруг мой софт разрастется дополнительным функционалом? Посмотрим на его исходник.

  1. module Photo (
  2.      getTime
  3.     ) where
  4.  
  5. import Data.Time
  6. import System.Locale
  7. import Graphics.Exif
  8.  
  9. getTime :: String -> IO (Maybe Day)
  10. getTime filePath =
  11.     fromFile filePath >>= (\e -> getTag e "DateTime") >>= (return . parseDateTime)
  12.     where
  13.         parseDateTime (Just str) = parseTime defaultTimeLocale "%Y:%m:%d %H:%M:%S" str
  14.         parseDateTime Nothing = Nothing
* This source code was highlighted with Source Code Highlighter.


Страшно? Вовсе нет. Заметим, что на этот раз в определении модуля мы в скобках указали имя функции getTime. Здесь указываются функции, которые доступны извне модуля. Вот такое просто и изящное решение вместо проевших плеш header-файлов в C\C++.

Обратим внимание на функцию getTime. Она принимает в качестве аргумента путь к файлу, а возвращает действие, в результате выполнения которого будет может быть Day. Maybe здесь не какое-то ключевое слово, это вполне себе определенный тип, который может принимать два значения: Just Something или Nothing. Этот тип данных применяется, если в результате функция может не вернуть какого-либо конкретного значения из области значений. Например, поиск элемента в списке, отвечающего заданным условиям. Такого элемента может и не быть.

Здесь наличие Maybe говорит нам о том, что в данном файле может и не быть данных о времени. Day же это просто один из типов данных, представляющих время в Haskell. Определение функции getTime (то есть правая часть уравнения) сразу же вводит еще два новых для нас понятия. Но обо всем по порядку. Видно, что getTime состоит из трех действий:
  • сперва функция fromFile из модуля Graphics.Exif считывает объект типа Exif, выполнение соответствующего действия даёт нам этот объект и он передается в вызов следующей функции;
  • следующая функция представляет собой лямбда-выражение \ e -> getTag e "DateTime", здесь обратный слэш неуловимо напоминает греческий символ «лямбда», это функция одного аргумента, которая и принимает полученный ранее Exif, которая возвращает действие IO (Maybe String), возвращаемое функцией getTag, принимающей Exif и имя тэга;
  • результат выполнения предыдущего действия предается в завершающую конструкцию, опишем ее подробнее.


Во-первых, страшное и спорное слово return. На самом деле return в Haskell ничего не возвращает, и к определению функции относится очень косвенно. return — это простая функция, которая переданное ей значение превращает в действие. Дело в том, что функция, выполнение которой представляет собой выполнение цепочки действий, не может вернуть «чистое» значение. Она не может вернуть «просто String», потому что получение этого String возможно лишь при выполнении определенной цепочки действий, то есть само по себе получение этого String — тоже действие, поэтому нам необходимо оборачивать результирующее значение в монаду, т.е. «возвращать его» (return) туда, откуда оно было получено. Оно было получено из монады, нам нужно его опять вернуть (завернуть?) в монаду. А что касается возвращаемого значения: монадическая функция возвращает последнее действие, определенное в ней, т.е. в данном случае как раз return.
Во-вторых, символ точки — это композиция функций. Пусть f y = z; g x = y;, тогда (f . g) x == f( g x ) == z. В результате выполнения второго действия мы получаем может быть строку с датой, а функция сама по себе возвращает может быть Day. Поэтому мы эту может быть строку сперва парсим, в результате, очевидно, получается может быть дата, её-то мы и возвращаем. Фактически последнее действие можно записать так:
\ str -> return (parseDateTime str)


Ну и, наконец, блок where, который поясняет, что еще за parseDateTime мы использовали в определении getTime. В блоке where определяются, скажем так, функции контекста. Их область видимости ограничина конкретным определением, в котором встречается этот блок. В этих функциях контекста можно и нужно обращаться к входным аргументам вышестоящей функции. В данном случае такой функцией контекста выступает функция парсинга значений. Мы вынесли ее в отдельный блок, потому что она должна быть представлена двумя уравнениями (в противном случае можно было бы подставить ее определение напрямую в определение getTime) для двух шаблонов — с Just и Nothing. В первом случае мы парсим значение, а во втором опять возвращаем Nothing. Согласитесь, довольно наглядно?

С функцией извлечения времени покончено. Присутпим к обходу каталога.

Функция №2 — обход каталога



Лирическое отступление. Haskell заставляет мыслить немного иначе, не так, как мы привыкли решать задачи на императивных языках. Когда заходит речь, скажем, об обходе каталога с файлами, программист на С сказал бы следующее: «я пробегусь в цикле по файлам и проверю А, сделаю Б». Однако программист на функциональном языке скажет так: «Я применю функцию обработки к каждому файлу в директории».

Немного перестроив свое мышление перестаешь жалеть об утеряных циклах. В функциональном языке они успешно заменяются свертками и «отображениями» (map). Свертка нам здесь не понадобится, не буду утруждать себя объяснениями. Остановлюсь на функции map. Самое близкое по смыслу русское слово — это «отображение», и это действительно так. Функция map отображает один набор данных в другой. Она принимает на вход список элементов типа a, функцию типа a -> b (а как же, это ведь функциональный язык, здесь можно в качестве аргумента передать функцию!) и на выходе мы получаем список типа b. Вот тип функции map:

map :: (a -> b) -> [a] -> [b]


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

add :: Int -> Int -> Int


Её тип совсем правильно нужно бы записать вот так:

add :: (Int -> (Int -> Int))


Чувствуете разницу? Итак, add 5 2 вернет значение 7. А вот add 5, вернет функцию, которая принимает значение типа Int и возращает значение типа Int! Именно на таких чудесах и построен принцип использования функции map. Вот так мы добавляем единицу ко всем элементам исходного списка:

map (+ 1) [1,2,3]


Но подобные примеры вы горами найдете в любом Haskell туториале. Вернемся к нашим баранам. Теперь становится ясно, что со всем списком элементов в каталоге нужно сделать совершенно однотипное действие и получить список результатов этих действий (на самом деле будет немного не так, но это не важно). Чувствуете? Здесь так и просится применение map. Но сперва определимся с тем, что нужно сделать с каждым элементом каталога.

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


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

  1. --обработка элемента: если каталог - входим в рекурсию, иначе обрабатываем фотографию
  2. processSingle :: (String, Bool) -> IO ()
  3. processSingle (path, True) = processDirectory path
  4. processSingle (path, False) = do
  5.     picturesDir <- getPicturesDir
  6.     maybeDate <- getTime path
  7.     copyPhoto picturesDir maybeDate
  8.         where
  9.             -- безопасное копирование
  10.             copyPhoto pictures Nothing = return ()
  11.             copyPhoto pictures (Just date) = do
  12.                 let newPath = pictures ++ "/" ++ (formatTime defaultTimeLocale "%Y/%B/%d" date)
  13.                 copyFile path newPath
* This source code was highlighted with Source Code Highlighter.


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

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

  1. foobar = action1 >>= action2 >>= action3
  2.  
  3. foobar' = do
  4.   result1 <- action1
  5.   result2 <- action2 result1
  6.   action3 result2
* This source code was highlighted with Source Code Highlighter.


В данном случае как раз используется два результата двух разных действий в качестве аргумента к третьей функции. Функция копирования тривиальна: вновь сопоставление по шаблону, если нашли дату — то копируем файл из path в newPath. Заметим две особенности:
  • path — это аргумент самой «верхней» функции processSingle, т.е. copyPhoto — это контекстно-зависимая функция;
  • let — это ключевое слово языка, оно позволяет привязать некоторое значение к некоторому имени (прошу заметить, это не присваивание, его значение нельзя изменить, хотя данное имя и можно привязать к другому значению, но если вы передали его до этого в какую-то функцию, то там оно не изменится) — в данном случае оно применено просто для удобства.


formatTime — это, очевидно, функция принимающая локаль, шаблон и дату, и возвращающая строку с отформатированной по данному шаблону датой.
++ — это функция конкатенации списков.

ВНЕЗАПНО, мы только что описали функцию №3. Правда просто? Так как же будет выглядеть наша функция №2. Она будет выглядеть следующим образом:

  1. processDirectory :: String -> IO ()
  2. processDirectory dir =
  3.     getDirectoryContents dir >>= checkItems >>= (mapM_ processSingle)
  4.     where
  5.         --по заданному списку содержимого каталога возвращаем кортежи с маркером "каталог" для каждого элемента
  6.         checkItems xs = mapM singleCheck xs
  7.             where
  8.                 singleCheck path = do
  9.                     isDirectory <- (doesDirectoryExist path)
  10.                     return (path, isDirectory)
* This source code was highlighted with Source Code Highlighter.


Приглядитесь к этому повнимательнее, на самом деле это очень простая функция. Но сперва пару слов о mapM и mapM_.

Лирическое отступление. Функция map, которая была рассмотрена выше, работает только с чистым кодом. У нас же код монадический, поэтому мы должны использовать монадический map. Рассмотрим типы этих функций:
mapM :: (a -> IO b) -> [a] -> IO [b]
mapM_ :: (a -> IO b) -> [a] -> IO ()


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

Итак, будем читать код снизу вверх:
  • функция singleCheck — это функция, которая проверяет, находится ли по данному пути каталог, и вовзращает пару путь-маркер (это ровно та конструкция, которую мы обрабатывали выше!);
  • функция checkItems принимает список файлов (путей к ним) и на возвращает действие, которое при выполнении даст список пар путь-маркер (здесь нам интересен результат действия, поэтому mapM);
  • наконец, функция processDirectory получает список файлов, устанавливает каждому из них маркер и далее каждую полученную пару обрабатываем согласно определенной выше функции processSingle (здесь сама по себе обработка ничего существенного не возвращает, нам не интересен результат действия, нам нужно, чтобы оно просто выполнилось).


И это всё! Конец! Программа готова. Перед заключительной частью, я хочу сказать пару слов о GUI.

GUI на декларативных языках


Чем отличаются декларативные языки от императивных? В императивных языках нужно описать КАК произвести вычисления, в декларативных же нужно описать ЧТО ты хочешь получить. Ярчайший пример декларативного языка — это SQL. Теперь подумаем на секунду о графическом интерфейсе. По сути, необходимо лишь декларативно (в соответствии с правилами конкретного тулкита) описать, что мы хотим видеть, и тут же рядом описать, что должен делать тот или иной объект при взаимодействии с ним.

Теперь вернемся к Haskell — как функциональный декларативный язык он чудесно ложится на эту структуру, ведь, например, для создания обработчика нажатия кнопки достаточно передать в качестве аргумента некую функцию обработчик! И это очень естественно. Для создания интерфейсов на Haskell я использую GTK. Делается это так: рисую формочки в Glade, а в Haskell коде размещаю обработчики.

Вот упрощенный код нашей задачи:

  1. prepareGUI mainWindow startButton fileChooser =
  2.     do
  3.     onDestroy mainWindow mainQuit
  4.     onClicked startButton (processClick fileChooser)
  5.     where
  6.         processClick fileChooser = fileChooserGetFilename fileChooser >>= processDirectory
* This source code was highlighted with Source Code Highlighter.


Я думаю, он не нуждается в комментариях.

Заключение


Полностью исходники проекта я выложил на github (ссылка ниже), предлагаю всем заинтересовавшимся ознакомиться, я снабдил их довольно подробными комментариями. Несмотря на внушительный объем получившейся статьи я считаю, что те, кто внимательно читал её поняли, что не так уж сложен этот ваш Haskell, он просто немного другой. В его основу положены другие принципы, однако на их основе вполне можно писать код любого уровня, будь-то системное или прикладное программирование. Можно пойти еще дальше: на секундочку, представьте какой прекрасный MVC веб-фреймворк мог бы родиться на базе Haskell! В этом туториале я задел лишь вершину айсберга, на самом деле еще есть что изучать — полиморфизм, классы типов, паралеллизм!.. Я надеюсь, что хотя бы кому-то этой статьей зажег искорку желания изучить этот прекрасный язык.

Проект на github
Чудеснейшая книжка по Haskell
Tags:
Hubs:
+38
Comments27

Articles

Change theme settings