В этой статье я расскажу о том, как однажды я написал небольшую консольную программку для ведения списка задач почти полностью в функциональном стиле (за исключением использования переменных и некоторых приемов из ООП).
К сожалению, в статье помимо полезных практических приемов будут и некоторые отрицательные примеры, в частности, некоторое дублирование кода и нерациональное использование функций стандартной библиотеки.
Также в этой скромной публикации я покажу с чего я начал функциональное проектирование программы (будет показан пример кода на одном из функциональных языков), нестандартное использование одного файла из состава библиотеки QtE5, одну интересную библиотеку для раскрашивания сообщений в командной строке, а также я сделаю небольшое резюме о полученном в ходе работы над этой программкой todo опыте.
Идея написать подобное приложение пришла мне в голову после прочтения замечательной книги Мирана Липовача «Изучай Haskell во имя добра!», которую я начал читать просто ради интереса и познания дао функционального программирования. В этом нескучном учебнике приводился исходный код программы для ведения списка задач (далее для краткости, я буду именовать список задач словом todo) на Haskell и предлагалось в качестве упражнения написать несколько функций, неописанных автором. Что сказать, мне бросили вызов.
Суть программы предельно проста. Есть обычный текстовый файл (расширение — *.txt, хотя это и не принципиально) и в нем хранится набор записей, разделенных новой строкой. Программа имеет ряд команд add, remove, view, bump с помощью которых пользователь может добавлять, удалять, просматривать и поднимать на вершину списка записи из файла. При этом все команды отдаются исключительно из командной строки.
Cинтаксис команд очень прост:
todo add <имя файла> <запись_1> <запись_2> <запись_3> ... <запись_N> todo remove <имя файла> <номер записи> todo view <имя файла> todo bump <имя файла> <номер записи>
На Haskell программа, которая реализует все эти команды, с учетом возможного некорректного ввода и с рядом некоторых моих правок выглядит примерно так (не спрашивайте меня, когда и как я учил язык):
import Control.Exception import Data.List import System.Directory import System.Environment import System.IO -- Доступные команды dispatch :: String -> [String] -> IO() dispatch command | command == "add" = add | command == "view" = view | command == "remove" = remove | command == "bump" = bump | otherwise = doesntExist command -- Обработка неправильной команды doesntExist :: String -> [String] -> IO() doesntExist command _ = if command == "" then putStrLn "Empty command !" else putStrLn $ "Command " ++ command ++ " isn't exist" --Программа действует только, если файл действительно существует withCorrectFile :: String -> IO() -> IO() withCorrectFile fileName fileAction = do fileExists <- doesFileExist fileName if fileExists then fileAction else putStrLn $ "File " ++ fileName ++ " doesn't exists !" -- Добавить задачу в список задач add :: [String] -> IO() add [fileName, todoItem] = appendFile fileName (todoItem ++ "\n") add _ = putStrLn "Command add has exactly two arguments" -- Просмотреть задачи из текущего списка view :: [String] -> IO() view [fileName] = withCorrectFile fileName (do contents <- readFile fileName let todoTasks = lines contents numberedTasks = zipWith (\n line -> show n ++ " -- " ++ line) [0..] todoTasks putStr $ unlines numberedTasks) view _ = putStrLn "Command view has exactly one argument" -- Вспомогательная функция для манипулирования файлами -- нужна в том случае, если файл обновляется fileManipulate :: String -> String -> IO() fileManipulate fileName todoItems = withCorrectFile fileName (bracketOnError (openTempFile "." "temp") (\(temporaryFileName, temporaryFile) -> do hClose temporaryFile removeFile temporaryFileName) (\(temporaryFileName, temporaryFile) -> do hPutStr temporaryFile todoItems hClose temporaryFile removeFile fileName renameFile temporaryFileName fileName)) -- Удаление задачи из списка remove :: [String] -> IO() remove [fileName, numberOfString] = do contents <- readFile fileName let todoTasks = lines contents number = read numberOfString todoItems = unlines $ delete (todoTasks !! number) todoTasks fileManipulate fileName todoItems remove _ = putStrLn "Command remove has exactly two arguments" -- Поднять задачу на верх списка задач bump :: [String] -> IO() bump [fileName, numberOfString] = do contents <- readFile fileName let todoTasks = lines contents number = read numberOfString todo = todoTasks !! number todoItems = unlines $ (todo : (delete todo todoTasks)) fileManipulate fileName todoItems bump _ = putStrLn "Command bump has exactly two arguments" main :: IO() main = do arguments <- getArgs if length arguments == 0 then putStrLn "Usage: todo <add|remove|view|bump> [arguments]" else do (command : argumentsList) <- getArgs dispatch command argumentsList
89 строк почти чистого функционального кода, и при этом, я уверен, что это еще можно оптимизировать и улучшить! Haskell дает очень ценный урок: многие программы могут быть написаны без использования циклов, переменных и некоторых других вещей, при этом программный код становится более качественным и более простым в сопровождении и тестировании.
Именно по этой причине, я рекомендую читателю познакомится с этим языком программирования, чтобы понять концепции функционального программирования и научиться функциональному мышлению.
Однако, вернемся в D.
Для интереса, я изменил некоторые команды в приложении (ну и кое-что добавил) и сделал цветной вывод на экран. Команду bump я заменил на команду head, а также ввел команду tail, которая по синтаксису совпадает с bump, но совершенно противоположна по результату действия (tail переносит задачу в самый низ списка задач).
Для работы над программой нам потребуется скачанные библиотеки QtE5 и arsd, а именно, файлы asc1251.d (из QtE5) и terminal.d (из arsd): asc1251 содержит набор процедур, которые умеют работать с кодировкой в командной строке Windows, а terminal.d — содержит набор процедур, для работы с командной строкой.
Идея, которую я буду использовать в своем приложении примерно следующая: программа todo получает из консоли команды манипуляции списком задач, разбирает их, выводя соответствующие сообщения пользователю (учитывает и корректный и некорректный ввод со стороны пользователя), и передает имя команды управления todo и ее аргументы в некоторый исполнитель, который и вызовет интересующую нас процедуру.
Все сказанное описывается так:
import std.algorithm; import std.conv; import std.file; import std.path; import std.range; import std.stdio; import std.string; import asc1251; import terminal; void main(string[] arguments) { auto parsedArguments = arguments.drop(1); auto terminal = Terminal(ConsoleOutputType.linear); if (parsedArguments.empty) { terminal.color(Color.red, Color.black); terminal.writeln("\nNot enough arguments!"); } else { auto command = parsedArguments.front; auto commandArguments = parsedArguments.drop(1).array; executeCommand(terminal, command, commandArguments); } }
Что тут происходит? Аргумент arguments процедуры main содержит в себе список всех строк переданных в командной строке приложению плюс имя самого приложения, поэтому с помощью алгоритма drop мы избавляемся от нулевого элемента массива arguments (drop возвращает диапазон, который получается путем пропуска n первых элементов переданного в нее диапазона). Далее создаем структуру, через которую будем манипулировать терминалом и помещаем ее в переменную terminal.
Если, список аргументов, обработанный drop, оказывается пустым, то это значит, что программе в командной строке не были переданы аргументы. В этом случае, мы устанавливаем в качестве фонового цвета командной строки черный, а в качестве цвета сообщения — красный, используя метод color и ряд описанных в arsd типов Color.red и Color.black. Само сообщение выводится в командную строку с помощью метода writeln, который аналогичен функции writeln из std.stdio. Таким образом, в случае запуска программы todo без аргументов, пользователю красным шрифтом будет выведена надпись «Not enough arguments!» («Недостаточно аргументов!»).
Если, список аргументов после drop оказался не пустым, то в переменную command с помощью метода front выделяем первый элемент обработанного списка, а в commandArguments — с помощью drop помещаем аргументы команды манипуляции todo. Далее с помощью алгоритма array мы переводим диапазон в массив, который наряду с другими аргументами (структура терминала и сама команда) передается в исполнитель executeCommand.
Исполнитель выглядит достаточно просто:
void executeCommand(ref Terminal terminal, string command, string[] arguments) { switch (command.toLower.strip) { case "add": addTodo(terminal, arguments); break; case "view": viewTodo(terminal, arguments); break; case "remove": removeTodo(terminal, arguments); break; case "head": moveTodoUp(terminal, arguments); break; case "tail": moveTodoDown(terminal, arguments); break; case "": terminal.color(Color.red, Color.black); terminal.writeln("\nEmpty command !"); break; default: with (terminal) { terminal.color(Color.red, Color.black); terminal.write("\nUnknown command "); terminal.color(Color.yellow, Color.black); terminal.write(command); terminal.color(Color.red, Color.black); terminal.writeln(" !"); } break; } }
Довольно простой код, который позволяет выбрать нужную функцию для исполнения, а также позволяет правильно обработать ситуации, когда команда манипуляции todo представляет собой пустую строку или неизвестную команду. При этом, перед попаданием в исполнитель строка приводится к нижнему регистру (toLower) и из нее вырезаются конечные и начальные пробелы (strip).
Теперь остается реализовать отдельные функции, которые будут выполнять все действия команд манипуляции списком задач.
Функция addTodo выглядит следующим образом:
void addTodo(ref Terminal terminal, string[] arguments) { if (arguments.length < 2) { terminal.color(Color.red, Color.black); terminal.writeln("\nCommand \"add\" has 2 arguments"); } else { auto fileName = arguments.front; File file; file.open(fileName, "a+"); arguments .drop(1) .filter!(a => (a != "") ? true : false) .map!(a => toCON(a)) .each!(a => file.writeln(a)); auto numberOfTodo = arguments.drop(1).length; terminal.color(Color.green, Color.black); terminal.writefln("\n%d" ~ " todo(s) was been added in file %s.", numberOfTodo, fileName ); } }
Работает это следующим образом: если длина аргументов команды add меньше 2, то значит, что пользователь где-то ошибся и ему будет выведено сообщение «Command add has 2 arguments» («Команда add имеет 2 аргумента»), в противном случае — переданный список аргументов add содержит имя файла для обработки и список записей для внесения в файл. После извлечения имени файла происходит его открытие в режиме добавления данных, после чего идет некоторая хитрая обработка содержимого arguments.
Сначала из arguments удаляется первый элемент (drop), после чего выделяются только непустые строки (с помощью алгоритма filter и анонимной функции a => (a != «») ? true : false, которая описывает условие фильтрации), производится перевод в кодировку консоли (toCON из asc1251.d) и соответственно запись результата в файл (с помощью алгоритма each и анонимной функции a => file.writeln(a)).
После записывания всех записей в файл, мы подсчитываем их количество, делаем цвет шрифта в командной строке зеленым, и выводим сообщение на английском о том, что некоторое количество записей было добавлено в некоторый файл.
Функция removeTodo выглядит так и имеет несколько параллелей с уже рассматривавшейся addTodo:
void removeTodo(ref Terminal terminal, string[] arguments) { if (arguments.length < 2) { terminal.color(Color.red, Color.black); terminal.writeln("\nCommand \"remove\" has 2 arguments"); } else { auto fileName = arguments.front; if (fileName.exists) { auto contents = (cast(string) std.file.read(fileName)) .splitLines; try { int index = to!size_t(arguments[1].strip); File temporaryFile; temporaryFile.open(fileName ~ `.temp`,`w`); contents .removeNth(index) .each!(a => temporaryFile.writeln(a)); temporaryFile.close; remove(fileName); rename(fileName ~ `.temp`, fileName); terminal.color(Color.green, Color.black); terminal.writefln("\nTodo with number %d was been removed from file %s.", index, fileName ); } catch (Exception e) { terminal.color(Color.red, Color.black); terminal.writeln("\nSecond argument must be a positive integer!"); } } else { terminal.color(Color.red, Color.black); terminal.writeln("\nFile " ~ fileName ~ " doesn't exists!"); } } }
Также, как и в предыдущей функции, проверяется длина списка переданных аргументов, и в случае если она меньше 2, то выдается предупреждение; в противном случае — происходит дальнейшая обработка аргументов. Функция removeTodo считает, что первый переданный ей аргумент — это имя файла, а второй — номер записи в файле (к номерам записей файла я еще вернусь). В программе этот факт используется на всю катушку: извлекая имя файла из списка аргументов, тут же проверяется его существование (exists из стандартной библиотеки) и сразу же производится извлечение с последующим приведением к size_t (предварительно из строки, содержащей второй аргумент, вырезаются лишние терминирующие символы: пробелы и им подобные). Если нужный файл не существует, то будет выведено сообщение «File doesn’t exists!» (Файл не существует). Если по каким-то причинам не удалось проделать преобразование, то возникнет исключение, которое будет перехвачено с помощью try/catch блока и пользователь увидит сообщение «Second argument must be a positive integer!» (Второй аргумент должен быть положительным целым).
Если все переданные аргументы корректны, то для осуществления удаления записи из файла необходимо считать весь файл в массив строк, удалить из этого массива элемент с нужным индексом (removeNth), перенести массив строк во временный файл (each и writeln), удалить исходный файл (remove) и переименовать временный файл, используя имя исходного файла (rename). Именно это и происходит внутри блока обработки исключения, в котором для удаления элемента из массива используется вспомогательная функция removeNth, которая описывается следующим образом:
T[] removeNth(T, U)(T[] array, U index) { auto newIndex = cast(size_t) index; if (array.length < 0) { return array; } else { if (newIndex < array.length) { return array[0..newIndex] ~ array[newIndex+1..$]; } } return array; }
В случае успешного удаления записи программа выдаст написанное зеленым цветом сообщение «Todo with number %d was been removed from file %s.» (Запись с номером %d была удалена из файла %s), которое легко и просто формируется с помощью функции format.
Функции moveTodoUp и moveTodoDown, с учетом рассмотренных фрагментов, реализуются достаточно просто и также используют массив-накопитель и временный файл:
void moveTodoUp(ref Terminal terminal, string[] arguments) { if (arguments.length < 2) { terminal.color(Color.red, Color.black); terminal.writeln("\nCommand \"head\" has 2 arguments"); } else { auto fileName = arguments.front; if (fileName.exists) { auto contents = (cast(string) std.file.read(fileName)) .splitLines; try { int index = to!size_t(arguments[1].strip); string element = contents[index]; File temporaryFile; temporaryFile.open(fileName ~ `.temp`,`w`); (element ~ contents.removeNth(index)) .each!(a => temporaryFile.writeln(a)); temporaryFile.close; remove(fileName); rename(fileName ~ `.temp`, fileName); terminal.color(Color.green, Color.black); terminal.writefln("\nTodo with number %d was been moved to the top of list in file %s.", index, fileName ); } catch (Exception e) { terminal.color(Color.red, Color.black); terminal.writeln("\nSecond argument must be a positive integer!"); } } else { terminal.color(Color.red, Color.black); terminal.writeln("\nFile " ~ fileName ~ " doesn't exists !"); } } } void moveTodoDown(ref Terminal terminal, string[] arguments) { if (arguments.length < 2) { terminal.color(Color.red, Color.black); terminal.writeln("\nCommand \"tail\" has 2 argument"); } else { auto fileName = arguments.front; if (fileName.exists) { auto contents = (cast(string) std.file.read(fileName)) .splitLines; try { int index = to!size_t(arguments[1].strip); string element = contents[index]; File temporaryFile; temporaryFile.open(fileName ~ `.temp`,`w`); (contents.removeNth(index) ~ element) .each!(a => temporaryFile.writeln(a)); temporaryFile.close; remove(fileName); rename(fileName ~ `.temp`, fileName); terminal.color(Color.red, Color.black); terminal.writefln(`Todo with number %d was been moved to the bottom of list in file %s.`, index, fileName ); } catch (Exception e) { terminal.color(Color.red, Color.black); terminal.writeln("\nSecond argument must be a positive integer!"); } } else { terminal.color(Color.red, Color.black); terminal.writeln("\nFile " ~ fileName ~ " doesn't exists!"); } } }
Теперь можно рассмотреть одну из самых интересных функций программы todo — viewTodo. Работает она с использованием весьма простого алгоритма: в случае успешного прохождения всех предварительных проверок (количество аргументов, существование файла и т.д.) происходит считывание всего файла в массив строк, который затем нумеруется, начиная с нуля (при помощи алгоритма enumerate), а затем выводится в командную строку с помощью алгоритма each:
void viewTodo(ref Terminal terminal, string[] arguments) { if (arguments.empty) { terminal.color(Color.red, Color.black); terminal.writeln("\nCommand \"view\" has 1 argument"); } else { auto fileName = arguments.front; if (fileName.exists) { terminal.color(Color.cyan, Color.black); writeln; auto contents = (cast(string) std.file.read(fileName)) .splitLines; contents .enumerate(0) .each!(a => writefln("%d -- %s", a[0], a[1])); terminal.color(Color.green, Color.black); terminal.writefln("\nYou have %d todo(s) now in file %s.", contents.length, fileName ); } else { terminal.color(Color.red, Color.black); terminal.writeln("\nFile " ~ fileName ~ " doesn't exists!"); } } }
Естественно, перед выводом в командную строку производится окрашивание строк в голубой цвет (при этом, сам файл будет выведен в формате » номер_записи — запись»), а затем зеленым цветом будет выведена надпись «You have %d todo(s) now in file %s.» (В файле %s находится %d заметок).
Копируем все функции в один файл (не забываем про main) и компилируем командой:
dmd todo.d asc1251.d terminal
И тестируем:
Работает!
Функциональный стиль позволяет гораздо проще и аккуратнее выразить алгоритм программы, делая его достаточно изящным и изысканным. Применение точечной нотации (ступенчатая обработка и использование UFCS в D) представляет собой довольно мощное средство, которое в равной степени может как улучшить читаемость кода, так и ухудшить ее, при этом чистое использование функционального подхода в D может быть несколько непрактичным, что легко исправляется совмещением его с традиционным императивным подходом.
Конечно, на одной конкретной ситуации не покажешь всех нюансов функционального программирования в D, поэтому я советую читателям внимательнее познакомится со стандартной библиотекой D (в частности, разделы std.algorithm, std.functional, std.range) и немного попрактиковаться в каком-нибудь чисто функциональном языке (Haskell, Scheme, Clojure и т.д).
P.S: Автор статьи от всего сердца благодарит создателей языка Haskell, Мирана Липовача, Мохова Геннадия Владимировича за прекрасную библиотеку QtE5 и Адама Руппа за превосходную коллекцию готовых программных решений arsd.