Выпуск 11. Январь 2014

Асинхронное программирование с IO::Async | Содержание | Обзор CPAN за декабрь 2013 г.

Обход дерева директорий на Perl и Haskell (часть 1)

Perl, как известно, не единственный язык программирования, однако он сочетает в себе множество подходов и стилей. В данной статьей рассматривается сравнение реализаций обхода дерева директорий на Perl и Haskell

Функциональное программирование неизбежно проникает в массы. Даже такие безнадежные языки, как Java и C++ больше не могут сопротивляться здравому смыслу и обзаводятся анонимными функциями (или «лямбда-выражениями»).

Что уж говорить о Perl, в котором анонимные функции ($subref = sub { ... }) и классические комбинаторы вроде map и grep уже давно не новость.

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

И действительно, Perl достаточно выразителен, чтобы на нем можно было реализовать множество функциональных идиом, что блестяще демонстрирует книга «Higher-order Perl» авторства Mark Jason Dominus.

Все же, есть ли какая-то выгода от написания кода на Haskell по сравнению с Perl? Чтобы это выяснить, я решил взять пример из Higher-Order Perl и переписать его на Haskell.

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

Не стоит бояться, если вы не понимаете, что за синтаксис используется в каком-то конкретном примере. Главное видеть общую картину и интуитивно понимать, что делает код.

Пример, который мы будем рассматривать, описывается в главе 1.6 и представляет собой рекурсивный обход дерева директорий. Задача здесь — не просто обойти дерево с конкретной целью, а написать «обобщенный» обход, который можно использовать, например, для:

  • получения дерева в виде структуры данных;
  • поиска файла в дереве;
  • печати списка директорий;
  • поиска поломанных символических ссылок.

и т.д.

Чтобы наша функция могла совершать разные действия, мы параметризуем ее двумя коллбеками — для файлов и директорий. Вот функция dir_walk, написанная Марком на Perl:

# From Higher-Order Perl by Mark Dominus, published by Morgan Kaufmann Publishers
# Copyright 2005 by Elsevier Inc
# LICENSE: http://hop.perl.plover.com/LICENSE.txt

sub dir_walk {
  my ($top, $filefunc, $dirfunc) = @_;
  my $DIR;

  if (-d $top) {
    my $file;
    unless (opendir $DIR, $top) {
      warn "Couldn't open directory $code: $!; skipping.\n";
      return;
    }

    my @results;
    while ($file = readdir $DIR) {
      next if $file eq '.' || $file eq '..';
      push @results, dir_walk("$top/$file", $filefunc, $dirfunc);
    }
    return $dirfunc->($top, @results);
  } else {
    return $filefunc->($top);
  }
}

Вот довольно близкая трансляция этого кода на Haskell:

import System.FilePath
import System.Directory

dir_walk :: FilePath -> (FilePath -> IO a) -> (FilePath -> [a] -> IO a) -> IO a
dir_walk top filefunc dirfunc = do
  isDirectory <- doesDirectoryExist top
  
  if isDirectory
    then do
      files <- getDirectoryContents top
      let nonDotFiles = filter (not . (`elem` [".", ".."])) files
      results <- mapM (\file -> dir_walk (top </> file) filefunc dirfunc) nonDotFiles
      dirfunc top results
    else
      filefunc top

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

Первое, что бросается в глаза в коде на Haskell — сигнатура типа (строка 4). Сигнатуры типов в Haskell в большинстве случаев, включая этот, опциональны — компилятор достаточно умен, чтобы самостоятельно вывести тип. Тем не менее, обычно программисты выписывают сигнатуры, потому что это дополняет документацию и упрощает чтение и понимание кода. Никто не возбраняет сначала написать тело функции, а затем спросить у компилятора сигнатуру и скопировать ее в код, хотя при написании сколь-нибудь сложных функций сигнатуру обычно пишут еще до кода самой функции — это позволяет сразу ответить себе на вопрос: «Что эта функция должна делать?».

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

Итак, dir_walk — функция, возвращающая IO a и принимающая три аргумента, типы которых FilePath, FilePath -> IO a и FilePath -> [a] -> IO a соответственно.

Здесь a — переменная типа, обозначающая тип результата, который мы хотим получить в результате обхода. Например, если мы хотим узнать суммарный размер директории, то при вызове функции вместо a будет подставлен тип Integer.

Чем отличается IO a от a? IO a — тип действий, которые, будучи исполненными, возвращают значение типа a. Действие можно исполнить (и получить его результат) только в контексте другого действия, используя следующий синтаксис:

action_b :: IO b
action_b = do
  result_of_a <- action_a :: IO a
  -- отсюда и до конца do-блока result_of_a имеет тип a
  ...

Функцию, у которой тип возвращаемого значения имеет вид IO a, можно упрощенно понимать как функцию, возвращающую a и при этом совершающую какие-либо побочные эффекты (ввод-вывод, работа с файловой системой и сетью, и т.д.). Разница в том, что, в отличие от Perl, соответствующие действия не выполняются каждый раз, когда вызов функции встретился в коде. Например, мы можем написать

action_b :: IO b
action_b = do
  let x = action_a :: IO a
  -- отсюда и до конца do-блока x имеет тип IO a
  ...

Здесь мы дали действию action_a альтернативное имя x, но от этого само действие не выполнилось, и его результата мы не получили. Чтобы выполнить действие как часть другого действия, мы должны использовать синтаксис <-, как показано выше.

В скомпилированной программе действие выполняется только если оно (прямо или косвенно) является частью главного действия программы — действия под именем main (по аналогии с языком C).

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

Перейдем теперь к собственно коду функции dir_walk. На строке 6 мы видим пример работы с действиями с использованием уже знакомого нам синтаксиса <-. Дело в том, что функция doesDirectoryExist (аналог оператора -d из Perl) имеет тип FilePath -> IO Bool, поскольку для получения ответа требуется доступ к файловой системе. Строчка isDirectory <- doesDirectoryExist top является аналогом $isDirectory = -d $top; — она выполняет действие doesDirectoryExist top :: IO Bool и записывает результат в переменную isDirectory. Однако, если бы мы наивно написали

if doesDirectoryExist top
  then ...
  else ...

то получили бы ошибку типов, потому что конструкция if ожидает выражение типа Bool, а не IO Bool.

Аналогично, в строке 10 выполняется действие getDirectoryContents top типа IO [FilePath], и результат записывается в переменную files.

В строке 11 мы отбрасываем «виртуальные» директории . и .. (по аналогии со строкой 18 из кода на Perl). Обратите внимание, что функция filter не требует выполнения каких-либо побочных эффектов, поэтому мы используем синтаксис let, а не <-. В общем случае, чтобы выяснить, требует ли функция выполнения действий, надо посмотреть на ее тип. Так, функция filter имеет тип

filter :: (a -> Bool) -> [a] -> [a]

В строке 12 мы рекурсивно вызываем функцию dir_walk, по аналогии со строкой 19 кода на Perl, для каждого файла или поддиректории в нашей директории. Для этого используется функция

mapM :: (a -> IO b) -> [a] -> IO [b]

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

Наконец, в строках 13 и 15 мы вызываем коллбеки, подобно строкам 21 и 23 кода на Perl. Здесь мы не использовали синтаксис <-, чтобы записать результат в переменную — вместо этого, результат действия становится возвращаемым значением нашей функции, прямо как в Perl. Альтернативно, блок вида

do
  ...
  dirfunc top results

можно заменить на

do
  ...
  result <- dirfunc top results
  return result

Здесь result имеет тип a (поскольку это результат действия типа IO a), а функция return (тип которой a -> IO a) делает из значения «тривиальное» действие, которое возвращает результат без каких-либо побочных эффектов.

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

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

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

Чтобы узнать больше о Haskell и попробовать его в действии, рекомендую слайды со встречи одесской группы пользователей Haskell. Там вы найдете ссылки на учебные материалы, инструкции по установке и другую полезную информацию.

Если вам кажется, что вы не поняли что-то важное в этой статье, пожалуйста, мне, и я постараюсь прояснить это в следующем номере. Дальше будет сложней и интересней!

Роман Чепляка


Асинхронное программирование с IO::Async | Содержание | Обзор CPAN за декабрь 2013 г.
Нас уже 1393. Больше подписчиков — лучше выпуски!

Комментарии к статье