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

И вот это наконец случилось со мной - так почему бы не использовать это как возможность (написать какую-то дичь) (CLI парсер)? Скажу сразу - мы тут чтобы развлекаться, потому требования к парсеру будут... Интересные

Первый день я лежал и представлял идеальный CLI парсер без изъянов, к вечеру Платоново пространство идей открыло следующие критерии "идеальности":

  • zero-overhead абстракция, то есть лучше руками написать у вас не выйдет (это отсекает любые аллокации и исключения, например)

  • простое, нет, очень простое подключение и использование. Исключительно один хедер файл, который нужно #include. Никто не станет использовать большой "фреймворк" для парсинга cli

  • удобство описания интерфейса для создающего программу, на компиляции убрать возможные ошибки с одинаковыми/конфликтующими именами опций, неправильным поиском по строке в мапе, неправильный тип опции и так далее

  • удобство использования полученной программы, например сделать так, чтобы --help у программы всегда был, а на опечатки выдавались подсказки по возможным исправлениям

Что есть аргумент?

Итак, в main (судя по типам прямиком из 70х годов прошлого столетия) пришли аргументы, как мы их представим?

int main(int argc, char* argv[])

Вариантов несколько:

  • vector<string> (решение cppfront, rust и прочих не zero overhead языков), нам аллокации запрещены. В конце концов, вдруг кто-то захочет парсить cli аргументы на машине без ОС?..

  • продолжать использовать C-строки

  • немного подумать

Мы выберем конечно немного подумать: внезапно оказывается, что можно просто написать специальный range для входных аргументов программы и потом спокойно оперировать им

using arg = const char*;

using args_t = std::span<const arg>;

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. Часть, в которой находится общий вспомогательный код, она включается 1 раз

  2. Генерирующий код, который инклудит сам себя множество раз и с помощью этого создаёт конечный код

  3. часть, которая инклудится генератором бесконечно много раз

Чтобы было понятнее, вот примерная схема:

#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 хочет меня отправить за этот код
интересно, куда IDE хочет меня отправить за этот код

Труд

Ключевые решения приняты, всё почти готово и остается лишь взять да и реализовать. Ничего особо интересного(можете посмотреть в исходниках, но там то ещё поле для рефакторинга)

Вот так например выглядит реализация генерации help:

template <typename Out>
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 вам не предлагала сходить на неймспейс по ссылке

Комментарии (8)


  1. DungeonLords
    19.12.2023 12:25

    Мне не хватило в статье сравнения с linenoise


    1. KanuTaH
      19.12.2023 12:25

      А что именно вы собираетесь сравнивать между парсером аргументов командной строки и некоей альтернативой readline()?


  1. verls
    19.12.2023 12:25

    Имя приложения тоже аргумент - не надо выкидывать его. Код может использоваться для сборки более чем одного приложения: к примеру trial или lite версии или еще каких вариантов вплоть до библиотеки.


    1. Kelbon Автор
      19.12.2023 12:25

      так кто его выкидывает? Оно не аргумент, а имя, было бы странно из имени распарсить себе опцию как будто её передал пользователь


      1. verls
        19.12.2023 12:25

        Why does argv include the program name?

        Is "argv[0] = name-of-executable" an accepted standard or just a common convention?

        Вы не поверите, но даже если вы запустите командную оболочку как sh или bash, то она будет работать по разному.


        1. Kelbon Автор
          19.12.2023 12:25

          У меня просто вынесено отдельно имя программы, отдельно аргументы, в статье не весь код


  1. AnthonyMikh
    19.12.2023 12:25

    А я правильно понимаю, что у сгенерированного cli::options есть конструктор по умолчанию? Если да, то как он работает с параметрами ENUM? И, кстати, что там происходит с ENUM с пустым списком вариантов?


    1. Kelbon Автор
      19.12.2023 12:25

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

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