Простая консольная программа для ведения списка задач (todo)

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

И тестируем:

kartinka-namber-van

Работает!

Функциональный стиль позволяет гораздо проще и аккуратнее выразить алгоритм программы, делая его достаточно изящным и изысканным. Применение точечной нотации (ступенчатая обработка и использование UFCS в D) представляет собой довольно мощное средство, которое в равной степени может как улучшить читаемость кода, так и ухудшить ее, при этом чистое использование функционального подхода в D может быть несколько непрактичным, что легко исправляется совмещением его с традиционным императивным подходом.

Конечно, на одной конкретной ситуации не покажешь всех нюансов функционального программирования в D, поэтому я советую читателям внимательнее познакомится со стандартной библиотекой D (в частности, разделы std.algorithm, std.functional, std.range) и немного попрактивоваться в каком-нибудь чисто функциональном языке (Haskell, Scheme, Clojure и т.д).

P.S.: Автор статьи от всего сердца благодарит создателей языка Haskell, Мирана Липовача, Мохова Геннадия Владимировича за прекрасную библиотеку QtE5 и Адама Руппа за превосходную коллекцию готовых программных решений arsd.

Добавить комментарий