В этой статье я расскажу о том, как однажды я написал небольшую консольную программку для ведения списка задач почти полностью в функциональном стиле (за исключением использования переменных и некоторых приемов из ООП).
К сожалению, в статье помимо полезных практических приемов будут и некоторые отрицательные примеры, в частности, некоторое дублирование кода и нерациональное использование функций стандартной библиотеки.
Также в этой скромной публикации я покажу с чего я начал функциональное проектирование программы (будет показан пример кода на одном из функциональных языков), нестандартное использование одного файла из состава библиотеки 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.
