CLI'нический парсинг
Каждый программист однажды получает по голове необходимостью парсить аргументы командной строки. Кого-то этот удар сломит и заставит написать несколько сотен строк кода, по которому потом будут восстанавливать интерфейс программы следующие поколения, другие сделают наборы рантайм мап и начнут оперировать строками, по которым будет уже непонятно, то ли это так и должно быть, то ли опечатка… Последние в порыве отчаяния возьмут целый boost для этой задачи, а тот сведётся всё к тем же рантайм мапам и строкам.
И вот это наконец случилось со мной — так почему бы не использовать это как возможность (написать какую-то дичь) (CLI парсер)? Скажу сразу — мы тут чтобы развлекаться, потому требования к парсеру будут… Интересные
Первый день я лежал и представлял идеальный CLI парсер без изъянов, к вечеру Платоново пространство идей открыло следующие критерии «идеальности»:
zero-overhead абстракция, то есть лучше руками написать у вас не выйдет (это отсекает любые аллокации и исключения, например)
простое, нет, очень простое подключение и использование. Исключительно один хедер файл, который нужно #include. Никто не станет использовать большой «фреймворк» для парсинга cli
удобство описания интерфейса для создающего программу, на компиляции убрать возможные ошибки с одинаковыми/конфликтующими именами опций, неправильным поиском по строке в мапе, неправильный тип опции и так далее
удобство использования полученной программы, например сделать так, чтобы --help у программы всегда был, а на опечатки выдавались подсказки по возможным исправлениям
Что есть аргумент?
Итак, в main (судя по типам прямиком из 70х годов прошлого столетия) пришли аргументы, как мы их представим?
int main(int argc, char* argv[])
Вариантов несколько:
vector
(решение cppfront, rust и прочих не zero overhead языков), нам аллокации запрещены. В конце концов, вдруг кто-то захочет парсить cli аргументы на машине без ОС?… продолжать использовать C-строки
немного подумать
Мы выберем конечно немного подумать: внезапно оказывается, что можно просто написать специальный range для входных аргументов программы и потом спокойно оперировать им
using arg = const char*;
using args_t = std::span;
constexpr args_t args_range(int argc, const arg* argv) noexcept {
// используем некоторые гарантии относительно argc/argv из стандарта С++
assert(argc >= 0);
if (argc == 0)
return args_t{};
// +1 - пропускаем имя программы, которое не аргумент
return args_t{argv + 1, argv + argc};
}
Можно конечно подкрутить и сделать random access ренж из string_view, но тогда их придётся каждый раз конструировать заново, чего неочень-то хочется. плюс потеряются некоторые гарантии (да ещё и код писать надо, неприемлемо! )
Curiously recurring header pattern
Лёгкие задачи кончились, начался дизайн — для декларативности и описательности интерфейса решено было, что пользователь создаст свой файл-описание интерфейса программы, этот файл позже может быть использован как документация — ну или просто полюбоваться, не знаю что там ещё они придумают с файлами делать
Файл примерно такой:
То есть на псевдоязыке имя опции, описание и всякое разное в зависимости от типа опции
Идея в том, чтобы дефайнить макросы TAG/BOOLEAN/ENUM … и потом инклудить файл-описание, впринципе — ничего совсем уж невероятного, но внезапно я вспомнил, что хедер у меня должен быть ровно 1.
А значит — пора использовать С хедеры по максимуму. Забудьте про #pragma once — это наш враг.
С помощью препроцессора мы поделим файл на 3 части:
Часть, в которой находится общий вспомогательный код, она включается 1 раз
Генерирующий код, который инклудит сам себя множество раз и с помощью этого создаёт конечный код
часть, которая инклудится генератором бесконечно много раз
Чтобы было понятнее, вот примерная схема:
#ifndef XXX
#define XXX
<.. вспомогательный код..>
...
#include __FILE__ // инклуд самого себя
...
#define INTEGER(<...>) <...>
...
#include __FILE__ // инклуд самого себя
...
#else // self-include part
#ifndef OPTION
#define OPTION(...)
#endif
// своего рода "наследование" на препроцессоре, если BOOLEAN не определён, то это OPTION
#ifndef BOOLEAN
#define BOOLEAN(...) OPTION(bool, __VA_ARGS__)
#endif
...
// по умолчанию используем такой путь для файла-описания
#ifndef program_options_file
#define program_options_file "program_options.def"
#endif
#include program_options_file
// и добавляем всегда опцию help, которая обрабатывается хедером специально
TAG(help, "list of all options")
#undef BOOLEAN
#undef OPTION
...
#endif
Например, таким образом выглядит создание списка всех опций:
Как видите, IDE почему-то плохо, что ж, мир несовершенен, отправим баг-репорт (нет, я милосерден)
интересно, куда IDE хочет меня отправить за этот код
Труд
Ключевые решения приняты, всё почти готово и остается лишь взять да и реализовать. Ничего особо интересного (можете посмотреть в исходниках, но там то ещё поле для рефакторинга)
Вот так например выглядит реализация генерации help:
template
Out print_help_message_to(Out out) {
...
for_each_option([&](auto o) {
out(" --"), out(o.name()), out(' '), out(option_arg_str(o));
const int whitespace_count = 2 + largest_help_string - option_string_len(o);
for (int i = 0; i < whitespace_count; ++i)
out(' ');
out(o.description()), out('\n');
});
#define ALIAS(a, b) out(" -" #a " is an alias to " #b "\n");
#define OPTION(...)
#include __FILE__
return ::std::move(out);
}
Здесь, чтобы не зависеть от низменных std: cout и подобных вещей, Out представляет функцию, принимающую всякую всячину и выводящая её куда-нибудь (сами выбирайте куда), а '#a » is an alias to » #b' в макросе это кстати объединение стринг литералов (да, создатели С явно знали что делали)
// то же самое, но без макроса
std::puts( "hello" " " "world");
Дальше идёт реализаций функций parse, структуры с опциями, много обработки ошибок, красивостей для вывода (например нахождения наиболее близкой опции при опечатке, до которого руки не дошли, хотя пишется за 15 минут)
Плоды труда
В итоге мы имеем что-то такое:
Файл описание:
И код использующий этот файл-описание:
Как видите, мы смогли переложить на компилятор и IDE большую часть работы, неплохо.
Пройдёмся по целям:
ни одной аллокации нет, исключения не бросаются
минимализм соблюдён, один хедер на 400 строк (и это можно ещё сократить), использование простое, инклудишь и получаешь готовую структуру и функцию для парсинга по декларативному описанию
для пользователя тоже удобно, help генерируется, остальное тоже добавить не проблема. просто я этим не занимался
Кстати, вот как выглядит сгенерированный help:
На этом всё, всем удачи, желаю никогда не увидеть в проде файлы инклудящие сами себя и чтобы IDE вам не предлагала сходить на неймспейс по ссылке