Разбор функции из стандартной библиотеки D

в 0:51, , рубрики: D, getopt, phobos, Программирование

Разбор функции из стандартной библиотеки D - 1 Привет Хабр, хочу пригласить всех на небольшую экскурсию по языку D. Зачем? Ну зачем люди вообще на экскурсии ходят — чтобы развлечься, увидеть что-то новое и вообще — это интересно. D трудно назвать новым или хотя бы молодым, однако в последние пару лет шло бурное развитие, в сообщество пришел Андрей Александреску и быстро стал ведущим разработчиком, с его способностью предвидеть тренды он внес огромный вклад в концепции самого языка и особенно в стандартную библиотеку.

С самого своего возникновения D позиционировался как улучшенный C++ (по крайней мере в моем прочтении истории). Возможность отбросить некоторые устаревшие конструкции и внести вместо них то новое, что не могло быть реализовано в классическом C++, и одновременно бережное сохранение низкоуровневых возможностей, таких как встроенный ассемблер, указатели и использование библиотек С, делают D уникальным претендентом на звание «следующего в ряду C — C++ — ...». Ну это с моей точки зрения, сам я (наверное вежливо было бы добавить «к сожалению») абсолютно моноязычен, много лет пишу на C++ и любые попытки познакомиться с другими языками неизбежно заканчивались крепким здоровым сном. Однако я слышал от представителей других конфессий что D для них тоже интересен как язык, так что на экскурсию приглашаю всех.

Что я буду показывать? По D уже написано несколько очень хороших книг, поэтому я решил просто взять функцию getopt() из стандартной библиотеки и посмотреть на ее код, неоценимо полезное упражнение позволяющее оживить прочитанное в книгах. Почему именно эту функцию? Ну, она же всем знакома и системно независима, я лично ее использую 3-4 раза в неделю и в деталях представляю как она могла бы быть написана на 3-х различных языках. Кроме того, автором кода значится Александреску, я много раз видел учебные примеры его кода в книгах и никогда не видел кода написанного в продакшн, любопытно же. В конце я конечно же не удержался и написал свой велосипед (естественно улучшенный), в данном случае это совершенно уместно и не менее полезно чем разбор чужого кода.

Увидим мы далеко не все из того что бы стоило посмотреть, да и сам я далеко не эксперт, поэтому читайте сами кому интересно, ссылки в конце.

Наружный осмотр

Вот такой примерно код иллюстрирует как пользоваться функцией:

void main(string[] args)
{
	// placeholders
        string file;
	bool quiet;
	enum Count { zero, one, two, three };
	Count count;
	int selector;
	int[] list;
	string[string] dict;

	std.getopt.arraySep=",";
	auto help=getopt(args, 
		, std.getopt.config.bundling
		, "q|quiet", "opposite of verbose", &quiet
		, "v|verbose", delegate{quiet=false;}
		, "o|output", &file
		, "on", delegate{selector=1;}
		, "off", delegate{selector=-1;}
		, std.getopt.config.required, "c|count", "counter", &count
		, "list", &list
		, "map", &dict
	);
	
	if(help.helpWanted)
	    defaultGetoptPrinter("Options:", help.options);
}

Первое что мы видим — «почти C», потом замечаем наличие динамических массивов — string[] и int[], и ассоциативных массивов — string[string]. Затем какое-то подозрительное присвоение — std.getopt.arraySep=",", неужели глобальная переменная!?, мы что, в кунсткамеру пришли или куда? Все так, динамические и ассоциативные массивы присутствуют в языке и составляют одну из его основ (я лично сразу вспоминаю Perl, в хорошем смысле слова). А вот std.getopt.arraySep — действительно глобальная переменная принадлежащая модулю и присвоение ей наверное ужасно с точки зрения пуриста, даже в такой специфической функции как getopt(). Однако тут все не так однозначно, arraySep могла бы быть определена как пара функций:

@property string arraySep() { return ... }
@property void arraySep(string separator) { .... }

и выглядеть как переменная, при этом удовлетворяя самым строгим стандартам инкапсуляции данных. Это своего рода фирменная фишка D — синтаксический сахар доведенный до совершенства и образующий неповторимый облик языка. Более того, этот вызов мог бы выглядеть как

",".arraySep;

кажется надуманным извращением? А как насчет вот такой конструкции:

auto helloWorld="dlrowolleh".reverse.capitalize_at(0).capitalize_at(5).insert_at(5,' ');

это конечно умозрительный пример, просто чтобы показать что такой синтах имеет смысл, однако эта конструкция используется в D так же широко и с тем же успехом как pipe (знак |) в скрипах на bash. Она имеет свое красивое название: Uniform Function Call Syntax, хотя по сути это не более чем синтаксический сахар позволяющий вызывать fun(a,b,c) как a.fun(b,c).
Дальше мы видим собственно вызов функции и сразу же бросается в глаза невероятная гибкость интерфейса, прямо в функцию передается произвольное количество конфигурационных параметров, включая произвольный обработчик и описание. Невольно закрадывается подозрение что D — язык с динамической типизацией. Ничего подобного, как мы увидим позже, это всего лишь доведенная до совершенства техника шаблонов.
В общем виде описание опции задается такой строкой:
[модификатор,] варианты опции, [описание,] &обработчик
Самая тривиальная часть здесь — варианты опции, просто строка вида «f|foo|x|something-else» которая задает возможные синонимы, как короткие так и длинные. Описание (строка синтаксической помощи) — тоже просто строка, но она уже не обязательна, что уже предполагает некоторую работу с типами на этапе компиляции.
Настоящая магия начинается с обработчика, это должен быть вдрес, но адрес практически чего угодно включая enum (на этом месте мой внутренний C++-ник сильно наморщил лоб), а так же адресом функции или лямбда-функцией (ну это просто, да?).
Подробнее:

  • если в качестве обработчика задан указатель на bool, опция подразумевается без аргументов, -f или --foo запишет true в переменную. Впрочем, можно сделать и так: --foo true или --foo=false.
  • если обработчик — указатель на строку, численный тип или enum, ожидается опция с аргументом, который конвертируется к нужному типу и присваивается указателю.
  • еще один подвариант, если обработчик — указатель на целый тип, а опция оканчивается на '+', то обработчик инкрементируется каждый раз когда опция встречается в командной строке.
  • если обработчик — указатель на массив, то подразумевается опция с аргументом, который конвертируется к нужному типу и добавляется к массиву, можно также давать сразу несколько значений разделенных запятыми. После разбора командной строки --foo=1,2,3,4,5 соответствующий массив будет [1,2,3,4,5].
  • подобным же образом можно передать указатель на ассоциативный массив, тогда в качестве параметра нужно передавать список пар &ltkey&gt=&ltvalue&gt, которые будут сконвертированы к нужному типу.

Функция возвращает кортеж из двух элементов — списка опций, который можно распечатать, и логической переменной helpWanted, =true если в командной строке присутствовала опция -h или --help (которая автоматически добавляется к списку).
Ну и для завершения картины, перед каждой опцией может стоять модификатор, например required или caseInsensitive. Кроме того, в модуле определено несколько глобальных переменных, таких как optionChar='-', endOfOptions="--" и arraySep=",", присвоение которым меняет синтаксис командной строки.
В результате получаем универсальную и удобную функцию, очевидно что это шаблон и примерно понятно как что-то похожее реализовать в C++, но как именно это делается в D?

Открываем капот

Первое что обращает на себя внимание — чрезвычайно простой и естественный способ определения шаблонных функций, разница в синтаксисе обычных и шаблонных функций настолько тонка что меняет восприятие — пишешь не «обычные» и «шаблонные» функции, а просто функции, некоторые из формальных параметров которых могут быть шаблонными. Забегая вперед, скажу что к аргументам opts можно обращаться как к массиву — opts[0], opts[$-1] или opts[2..5];

GetoptResult getopt(T...)(ref string[] args, T opts)
{
    ...
    getoptImpl(args, cfg, rslt, opts);
    return rslt;
}

Больше про функцию верхнего уровня сказать собственно и нечего, потому что она сразу же передает управление getoptImpl() в которую мы сейчас и заглянем.

 1 private void getoptImpl(T...)(ref string[] args, ref configuration cfg, ref GetoptResult rslt, T opts)
 2 {
 5     static if(opts.length) {

 6         static if(is(typeof(opts[0]) : config)) {
 7             // it's a configuration flag, act on it
 8             setConfig(cfg, opts[0]);
 9             return getoptImpl(args, cfg, rslt, opts[1 .. $]);

10         } else {
11            // it's an option string
                ...
16             static if(is(typeof(opts[1]) : string)) {
17                auto receiver=opts[2];
18                 optionHelp.help=opts[1];
19                 immutable lowSliceIdx=3;

20             } else {
21                 auto receiver=opts[1];
22                 immutable lowSliceIdx=2;
23             }
                 ...
34             bool optWasHandled=handleOption(option, receiver, args, cfg, incremental);
41             return getoptImpl(args, cfg, rslt, opts[lowSliceIdx .. $]);
42         }

43     } else {
44         // no more options to look for, potentially some arguments left
            ...
68         }
75     }
76 }

Как видно по номерам, строк я выбросил совсем немного, зато вся структура этого кода как на ладони.
Первое что привлекает внимание — конструкция static if() {} else static if() {} else {}, да, это именно то о чем вы вероятно подумали. Ветка выражения static if выбирается во время компиляции, естественно условие тоже должно быть известно во время компиляции. Таким образом этот код (слегка отдающий спагетти на мой придирчивый вкус) во время компиляции обрезается до нескольких строк имеющих смысл именно для этого набора аргументов функции. Как я уже говорил, с шаблонными параметрами можно обращаться как с immutable массивом, static if(opts.length) возвращает 0 если список опций пуст, таким образом код начиная со строки 43 заменяет нам специализацию шаблона для этого случая.
Еще один интересный момент, фигурные скобkи после static if() не меняют область видимости, взгляните:

16             static if() {
19                 immutable lowSliceIdx=3;
20             } else {
22                 immutable lowSliceIdx=2;
23             }
41             return getoptImpl(args, cfg, rslt, opts[lowSliceIdx .. $]);

Переменная lowSliceIdx определяется в одном из блоков, однако используется за их пределами, очень логично по-моему. Поскольку эта переменная определена как immutable (= constexpr), она также доступна во время компиляции и может применяться в шаблонах.
Давайте заглянем глубже, туда где начинается разбор опций и собственно работа с типами:

 6         static if( is(typeof(opts[0]) : config)) {
 7             // it's a configuration flag, act on it
 8             setConfig(cfg, opts[0]);
 9             return getoptImpl(args, cfg, rslt, opts[1 .. $]);

10         } else {
               ......
42         }

Оооо, вот оно! В D сделали долгожданную в C++ typeof(expr) и работает она именно так как предполагалось. Но это еще не все, выражение is(Т == U) равно true тогда и только тогда (естественно во время компиляции) когда типы T и U равны, а с шаблонными параметрами и другими вариантами использования is превращается просто в швейцарский нож для работы с типами. Вообще говоря, is() — это встроенный SFINAE возвращающий true тогда и только тогда когда аргументом является любой тип, то есть выражение является синтаксически правильным. Например is(arg == U[], U) проверяет что arg будет массивом, а is(arg: int) — что arg может быть автоматически конвертирован в int, двоеточие ненавязчиво намекает на наследование. Попозже еще будут примеры. Таким образом, выражение на строчке 6 статически проверяет если тип первого параметра (typeof(opt[0]) приводится к некоему типу config. А config это просто-напросто перечисление всех возможных модификаторов опции:

enum config {
    /// Turns case sensitivity on
    caseSensitive,
    /// Turns case sensitivity off
    caseInsensitive,
    /// Turns bundling on
    bundling,
    /// Turns bundling off
    noBundling,
    /// Pass unrecognized arguments through
    passThrough,
    /// Signal unrecognized arguments as errors
    noPassThrough,
    /// Stop at first argument that does not look like an option
    stopOnFirstNonOption,
    /// Do not erase the endOfOptions separator from args
    keepEndOfOptions,
    /// Makes the next option a required option
    required
}

после чего getoptImpl() сохраняет значение (сохраняет + значение =&gt runtime) модификатора и рекурсивно перевызывает себя же, убрав из опций первый аргумент (opt[1..$]). Таким образом мы разобрались с первым случаем обработки типов и получилось на удивление просто. Если выбросить из головы эти бесконечные compile time/runtime а читать код как он есть, и встречая typeof(T) заглядывать на пару страниц вверх, туда где этот тип определен (в нашем случае в списке фактических параметров getopt(), то даже до обидного просто, в C++ это гораздо больше похоже на магию. А может так и было задумано? В конце концов, компилятор имеет всю ту же информацию что и я — в виде входного кода.
Далее, рекурсивно выдергивая по одному элементы из входного массива, компилятор доберется до первого строкового параметра, который обязан быть списком тэгов для данной опции, строка 11. Тут начинаются варианты, которые опять же очень легко разрешаются: если второй (следующий) параметр — строка, то это описание, а третий соответственно — адрес обработчика; иначе (не строка), это обработчик. Соответственно, мы выдергиваем из списка либо три, либо два параметра и передаем их следующей функции — handleOption() которая уже разбирает саму командную строку, а потом естественно рекурсивно вызываем себя и все начинается сначала.
Дальше не происходит ничего нового по сравнению с тем что мы уже видели. Функция handleOption(), шаблон с единственным параметром — типом обработчика, проходит по всей командной строке, проверяя не подходит ли она под описание и, если находит, совершает действие соответствующее своему обработчику. Я кратко рассмотрю наиболее интересные с моей точки зрения моменты.
Сначала, общий вид сверху:

static if(is(typeof(*receiver) == bool)) {
    *receiver=true;
} else {
// non-boolean option, which might include an argument

    static if(is(typeof(*receiver) == enum)) {
        *receiver=to!(typeof(*receiver))(val);
    } else static if(is(typeof(*receiver) : real)) {
        *receiver=to!(typeof(*receiver))(val);
    } else static if(is(typeof(*receiver) == string)) {
        *receiver=to!(typeof(*receiver))(val);

    } else static if(is(typeof(receiver) == delegate) || is(typeof(*receiver) == function)) {
        // functor with two, one or no parameters
        static if(is(typeof(receiver("", "")) : void)) {
            receiver(option, val);
        } else static if(is(typeof(receiver("")) : void)) {
            receiver(option);
        } else {
            static assert(is(typeof(receiver()) : void));
            receiver();
        }

    } else static if(isArray!(typeof(*receiver))) {
        foreach (elem; ...)
            *receiver ~= elem;

    } else static if(isAssociativeArray!(typeof(*receiver))) {
        foreach (k, v; ...)
            (*receiver)[k]=v;

    } else {
        static assert(false, "Dunno how to deal with type " ~ typeof(receiver).stringof);
    }
}

Обращает внимание повторяющаяся конструкция

static if(is(typeof(*receiver) == ...)) {
    *receiver=to!(typeof(*receiver))(val);

фактически означает «если в качестве обработчика передан указатель на что-либо, попытайся конвертировать аргумент к этому типу и присвой указателю».
Отдельно обрабатываются указатели на bool, которые могут не иметь аргумента; массивы и ассоциативные массивы, где аргумент добавляется к контейнеру; а также функции и лямбда-функции, которые могут иметь один, два или не иметь аргументов. Обратите внимание на внутренний селектор выбора типа функции:

        static if(is(typeof(receiver("", "")) : void)) {
            receiver(option, val);
        } else static if(is(typeof(receiver("")) : void)) {
            receiver(option);
        } else {
            static assert(is(typeof(receiver()) : void));
            receiver();
        }

Это еще один из вариантов использования выражения is(T), он приводится к true только если Т — некоторый существующий тип. В данном конкретном случае он смотрит на тип возвращаемый функциами (*receiver)(), (*receiver)("") или (*receiver)("",""), если такая сигнатура функции существует, тип тоже существует, иначе — SFINAE. (void является полноценным типом)
Полезно также познакомиться с универсальным конвертором D из модуля std.conv: to!(T)(&ltlexical&gt), он работает подобно boost::lexical_cast но в отличие от него способен даже сконвертировать строку в enum поскольку D беззастенчиво пользуется всей информацией доступной во время компиляции, что мы и видим в коде выше.
Вот собственно и все, примерно в 400 значимых строках кода реализована достаточно сложная функция, причем с результатом который очень трудно, если вообще возможно, воспроизвести на C++. Ну а мы в свою очередь познакомились с особенностями работы с типами в D — шаблонными функциями с переменным числом аргументов, выбором типа и ветки кода во времени компиляции, а также с конвертацией типов. На самом деле это только маленькая часть того инструментария который D предлагает разработчикам, на сайте есть огромная коллекция статей на самые разные темы. Я никого не призываю переходить на D или учить D, но если у вас сохранилась искорка любопытства и интереса к новому — это безусловно тот язык с которым стоит познакомиться хотя бы поверхностно.

Критика чистого разума

Не могу однако удержаться от критики, кое-что в предложенной реализации мне решительно не нравится. По большому счету к самому языку это отношения не имеет, тем не менее любопытно обсудить с общих позиций программирования.
Во первых, данная реализация сделана однопроходной, то есть опция извлекается из списка и сразу же делается проход по командной строке, первое найденное совпадение прерывает цикл. Это значит что вы не можете написать -qqq как синоним для «тише, тише, еще тише», или --map A=1 --map B=2 --map C=3 вместо --map A=1,B=2,C=3. Это вообще говоря не баг, однако нарушает некоторые сложившиеся конвенции при использовании getopt() и хотелось бы видеть более традиционное поведение.
Во вторых, и это уже серьезная архитектурная ошибка на мой взгляд, функция возвращает некую структуру с синтаксической помощью, то что обычно распечатывается по ключику -h|--help, однако эта же функция бросает исключение в случае ошибки. То есть, если вы сделали ошибку в командной строке, программа уже не сможет подсказать вам как правильно. Вообще говоря, это получается из той же однопроходной реализации.

UPD: Александреску читает Хабр?

В последнем коммите это было исправлено, не совсем так как сделал бы я, но тем не менее.

Кроме этого есть еще несколько мелких недочетов, например у опции может быть сколько угодно синонимов, но в синтаксическую подсказку попадают только два первых: в опции «x|abscissa|initialX» последнее значение можно обнаружить только заглянув вкод. Ну и тому подобные раздражающие мелочи.
Поэтому я сделал в качестве упражнения собственную реализацию где пофиксил эти недостатки и наделал разных своих наворотов (исключительно в качестве упражнения), в общем развлекался как хотел.

Здесь был мой велосипед! Где мой велосипед?

А нету, велосипед теперь воон там, бывает. Я решил что хороший экскурсовод должен знать где остановиться, так что на этом экскурсия заканчивается.
Надеюсь было интересно

Библиография

Первые три книги я читал, поэтому привожу каждую отдельным номером. Они все хороши, но ни одна на мой вкус не идеальна, поэтому я читал бутербродиком — глава из одной, соответствующие главы из двух других.

  1. Наверное исторически первая книга по D
  2. Книга Александреску
  3. Очень неплохой cookbook
  4. Все остальные существующие на настоящий момент книги по D
  5. Куча статей, еще более интересных чем книги, но каждая на отдельную тему
  6. Wiki, так же интересна как статьи, но к сожалению надо знать что именно ищешь
  7. Главный сайт D
  8. Русская версия тоже существует
  9. Стандартная библиотека на github. Исходники getopt() там же.

Автор: degs

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js