Макросы Haxe. Вводная статья.

Все ниже написанное соответствует Haxe 2 и почти верно для Haxe 3. Но работать без изменений будет только во 2-м. Кто адаптирует все примеры на 3-й и поделится с остальными, получит много экспириенса. Так же написанное не претендует на истину в последней инстанции и может содержать ошибки.

Хочу немного рассказать о макросах в Haxe, статья будет именно вводная, попробую рассказать и показать зачем вообще нужны макросы и какие задачи они могут решать в повседневной жизни.
Макросы в Haxe используют неоправданно мало программистов, кого-то отпугивает их синтаксис, кто-то просто не знает, что это и зачем. Все возможности макросов отлично описаны в статье метапрограммирование на википедии. В двух словах скажу: макросы позволяют как модифицировать существующий код (самомодификация кода), так и создавать новый (генерация кода). В этой статье мы попробуем генерировать новый код, но в очень ограниченных масштабах. Такие вещи, как генерация классов или enum-ов или редактирование существующего кода, я не затрону, лишь замечу, что все это возможно в пределах Haxe макросов!

Так что же на самом деле макрос в Haxe? Вы не поверите, но макросы это обычные функции. Ну не совсем обычные, но и ничего незнакомого по сравнению с кодом Haxe в них нет. Главные плюсы макросов - это то, что в них, кроме стандартной библиотеки, доступны еще и все классы пакетов neko и haxe.macro и то, что функции-макросы вызываются на этапе компиляции, а не во время выполнения программы и возвращают Haxe код, который и будет выполнен на этапе исполнения программы. Neko дает доступ к файловой системе и вообще к системе в целом, а haxe.macro классы позволяют... Да они все позволяют: создавать новые классы, менять структуру существующих, получать полные данные о всех типах, enum-ах и т.д. в общем полный доступ. Главное знать, что именно вам надо. Все это и делает макросы такими интересными для нас. Но не будем сильно затягивать и посмотрим на пример, который будет запоминать в скомпилированном приложении и выводить дату сборки проекта (без сторонних утилит такую задачу решить довольно сложно и тут на помощь приходит макрос):

import haxe.macro.Context;
import haxe.macro.Expr;

class Main {
       
        #if !macro
        static function main() {
                trace(getBuildDate()); // 2013-05-04 14:11:44
        }
        #end
       
        @:macro static public function getBuildDate():Expr {
                var d = Date.now();
                return Context.makeExpr(d.toString(), Context.currentPos());
        }
}

Функция getBuildDate и есть наш макрос. Сразу обращаем внимание на мета тег @:macro в начале функции - это отличительный знак, говорящий компилятору, что выполнять эту функцию надо во время компиляции! Наша функция возвращает тип Expr, о нем немного позже. Дальше в методе getBuildDate создается обычный Date с текущей датой, который переводится в строку и передается в “магический” Context.makeExpr и еще менее понятный метод Context.currentPos. И тут стоит вернуться к типу Expr, его определение легко найти в коде модуля haxe.macro.Expr

typedef Expr = {
        var expr : ExprDef;
        var pos : Position;
}

Expr - это просто структура с двумя полями: expr - любое выражение, допустимое в Haxe, но не в привычном виде, а как одно из значений ExprDef enum-а (перечисляемого типа), например EFunction, EVar, ENew, EConst, ECall, EContinue, EReturn и т.д. И pos - указатель на место, где в файле будет расположено выражение. На самом деле pos самому вычислять придется не так часто и для начала запомним, что на его место подставляем Context.currentPos(). Вот как выглядит Position тип:

typedef Position = {
        var file : String;
        var min : Int;
        var max : Int;
}

Теперь, когда Expr перестал быть полной загадкой, вернемся к методу Context.makeExpr. Из названия понятно, что он что-то делает и вот тут лучше, как говорится, один раз увидеть, что-же он делает:

trace(Context.makeExpr(d.toString(), Context.currentPos()));
// { expr => EConst(CString(2013-05-04 14:11:44)), pos => #pos(src/Main.hx:8: characters 8-20) }

Вернитесь к описанию структуры Expr и убедитесь, что перед нами действительно Expr, содержащий одну строковую константу Econst(CString()) с датой сборки проекта и расположенную в Main.hx на 8 строке, и занимающую 8-20 символы, и это очень важно! Взгляните на 8-ю строку первого примера с макросом, там написано:

trace(getBuildDate());

8-й символ - как раз начало getBuildDate строки (“trace(“ + 2 символа табуляции в начале строки), а 20-й - ее окончание. Т.о. вызов макроса заменится на выражение со строковой константой “2013-05-04 14:11:44”. Если вы упустили этот волнительный момент, я повторюсь: Мы только что заменили вызов метода на строку с текущей датой, и эта замена выполнена во время компиляции один раз!

Если запустить теперь приложение, мы увидим Main.hx:8: 2013-05-04 14:11:44. И сколько бы раз мы не запускали приложение, дата на экране не будет меняться, т.к. она была записана на этапе компиляции.

Вот и свершилось: вы разобрали и, надеюсь, поняли свой, возможно, первый, но далеко не последний макрос. Хотя уверен, что остался вопрос: “Что за магические #if !macro перед main функцией?”. Все дело в том, что macro функции сильно влияют на поведение класса, в котором они определены, да и импорты, доступные макро функциям, часто непозволительны остальному коду, вот и пришлось так извиваться, чтобы уместить все в один модуль. Но мой вам совет (да и вообще так правильнее): пишите макросы в отдельно отведенных для этого классах, не смешивая их с остальным кодом. Дальше я буду делать только так, но “смешанный” вариант я не мог не показать.

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

Усложним задачу. Использовать строку с датой хорошо, и ее можно распарсить в привычный Date, ну так давайте и сразу вызывать парсинг строки, т.е. чтобы getBuildDate() заменялся на Date.fromString(“2013-05-04 14:11:44”); Обратите внимание, что макро функции могут вернуть целое выражение.

Задачу мы себе поставили не самую простую, а даже немного сложную. makeExpr нам уже не поможет, он умеет работать только с базовыми и перечисляемыми типами (Int, Float, String, Bool, Array и анонимные объекты, составленные из этих типов), самое время обратиться к Context.parse методу. Смотрим, что у меня получилось:

@:macro static public function getBuildDate2():Expr {
        var d = Date.now();
        return Context.parse("Date.fromString('" + d.toString() + "')", Context.currentPos());
}

Видно, что Context.parse первым параметром принял строку, содержащую Haxe код, а вот на ее результат советую взглянуть самим, лишь подскажу, как я это сделал:

var date = getBuildDate2();
trace(date);
trace(Type.typeof(date));

Context.parse - очень полезный метод для парсинга строк, содержащих Haxe код, например, из внешнего файла или самостоятельно собранных строк, как на примере выше. Еще есть одна хитрая задачка, которую можно решить только с помощью этого метода, и о ней я как нибудь расскажу. Но, по правде сказать, я недолюбливаю parse и стараюсь использовать его только при крайней необходимости, хотя бы потому, что передаваемые строки могут содержать синтаксические или логические ошибки, да и лишний парсинг отнимает время. Дальше я покажу, как в нашей задаче можно обойтись без parse.

Чтобы “избавиться” от Context.parse, да и для того, чтобы показать силу макросов, напишем все то же, но иначе и сложнее :). Но для начала вернемся к структуре ExprDef, как я говорил, она описывает любое выражение в Haxe, а значит сможет описать и наш Date.fromString(). “Но как это сделать,” - спросите вы, и я вам признаюсь честно, я не знаю. Нет, ну я догадываюсь, некоторые ExpDef я знаю, а остальное можно найти в документации, но я покажу вам максимально простой метод узнать как записать нужное вам выражение. Все очень просто, создадим вот такой метод:

@:macro static function test(e:Expr) {
        trace(e);
        return e;
}

и вызовем, передав ему нужное нам выражение:

trace(test(Date.fromString("2013-01-01")));

//src/Main.hx:16: { expr => ECall({ expr => EField({ expr => EConst(CIdent(Date)), pos => #pos(src/Main.hx:8: characters 13-17) },fromString), pos => #pos(src/Main.hx:8: characters 13-28) },[{ expr => EConst(CString(2013-01-01)), pos => #pos(src/Main.hx:8: characters 29-41) }]), pos => #pos(src/Main.hx:8: characters 13-42) }

Перед вами ни что иное, как запись haxe кода с помощью Expr. Если немного укоротить полученную строку, то выйдет нечто:
ECall(EField(Date, “fromString”), [2013-01-01”])
Т.е. мы находим поле (метод) fromString у класса Date и вызываем его, передав в качестве параметра массив, содержащий одну строку с текущей датой. Предлагаю сдержать крики “О боже, как все сложно!” и просто взглянуть на метод, который в итоге получился:

@:macro static public function getBuildDate3():Expr {
        var d = Date.now();
        var p = Context.currentPos();
        return { expr:ECall(
                        { expr:EField(
                                { expr:EConst(CIdent("Date")), pos:p },
                                "fromString"),
                        pos:p },
                        [ { expr:EConst(CString(d.toString())), pos:p } ]),
                pos:p };
}

Ну, я же говорил ,что сделаем сложнее, вот мы и сделали. Я не стану вас убеждать, что так правильнее или еще что-то, я лишь скажу, что дальше будет еще один вариант, намного проще этого и если вам пока не охота разбираться, что тут и как, сразу переходите дальше, но не забывайте, что с Expr и ExprDef вам все равно придется работать, когда вы дойдете до сложных макросов и обычные parse и makeExpr вам там уже не помогут. Главное, не бояться почаще смотреть, как сам Haxe собирает выражения (метод test выше), и все у вас получится. И не поленитесь внимательно изучить всю строку с ретурном хотя-бы на этом простом примере.

Но и это еще не все! Последний вариант хоть и работает, но заслуженно вызывает испуг у большинства. И хотя Николас (автор языка Haxe) может думать как компилятор и даже вместо него и может заставить разработчиков делать также, однако, быстро стало ясно, что работу с макросами нужно как-то упростить. Результатом данного упрощения стало macro reification, которое упростило синтаксис и работу с макросами.

Вспомните, с чего мы начали: у нас есть строка с датой, и мы хотим передать ее в метод, который распарсит ее и вернет дату. Т.е. в идеале хотелось бы взять и написать return Date.fromString(d.toString()); и мы так и сделаем, или почти так:

@:macro static public function getBuildDate4():Expr {
        var d = Date.now();
        var e = Context.makeExpr(d.toString(), Context.currentPos());
        return macro Date.fromString($e);
}

По-моему, получилось отлично! Результат getBuildDate4 совпадает с getBuildDate3, а внешне getBuildDate4 явно выглядят лучше. Взгляните на значение переменной е. Это именно то, что возвращал наш первый метод getBuildDate - выражение, содержащее строку с датой. Вся магия в последней строке после оператора return. Сначала мы видим оператор macro, который говорит компилятору, что все, что идет дальше, надо переводить сразу в Expr. Т.е. если написать

macro “foo”

то это то же самое, что написать
{expr:EConst(CString(“foo”)), pos:Context.currentPos()}

Согласитесь, отлично придумано. А чтобы в это выражение встраивать дополнительные значения извне, нужно передавать их с ключом $ вначале, тогда компилятор на это место подставит выражение из переменной, главное, чтобы она (переменная) тоже было типа Expr, в нашем примере это $e, переданная методу fromString. Т.о. весь ужас из нескольких вложеных enum-ов, мы записали все одной строкой: macro Date.fromString($e);, А в Haxe 3 это можно записать еще проще.

У Николаса в блоге есть очень наглядный пример того, как macro reification упростил код макросов, и я не могу его не показать:

@:macro static function repeat( e : Expr, eN : Expr ) {
    return macro for( x in 0...$eN ) $e;
}

До упрощения этот макрос можно было бы записать минимум семью строками кода! Разбор того, что он делает и как работает оставляю на вас, надеюсь, теперь это не составит особого труда. Подсказка и вариант до упрощения тут.

Для первого раза, думаю, достаточно и предлагаю на этом остановиться. Подводя итоги, могу сказать, что теперь вы научились писать простейшие макросы, работать с Expr, узнали несколько полезных методов Context класса, а их на самом деле намного больше, немного познакомились с macro reification и т.д. Что вы не узнали, так это, как создавать свои классы и enum-ы, как редактировать целые классы, дополняя их методами или изменяя существующие и многое другое. Надеюсь, мне хватит сил и я обязательно обо всем этом напишу.

Напоследок, к разбору макроса из блога Николаса, предложу еще попробовать всем написать макрос, который будет сохранять значение файла в строку и работающий как показано ниже:

var str:String = MyMacros.getFileContent("readme.txt");

Отдельная благодарность Александру Кузьменко, Александру Хохлову и SlavaRa за помощью в написании статьи, рецензирование, редактирование и полезную критику.