Pull to refresh

Julia. Метапрограммирование и макросы

Level of difficultyMedium
Reading time20 min
Views2.1K

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

Механизм макросов применяется в Julia довольно часто. Макрос при использовании начинается с символа @ и имеет вид @show, @benchmark… А также, в неявной форме, макросами являются регулярные выражения r”[a..z]” (это макрос с полным именем r_str), а также многочисленные другие способы применения, включая красивые примеры Modia.jl с макросом u_str, где физическая величина «вшита» в число:

using Modia
Pendulum = Model(
   L = 0.8u"m",
   m = 1.0u"kg",
   d = 0.5u"N*m*s/rad",
   g = 9.81u"m/s^2",
   phi = Var(init = 1.57*u"rad"),
   w   = Var(init = 0u"rad/s"),
   equations = :[
          w = der(phi)
        0.0 = m*L^2*der(w) + d*w + m*g*L*sin(phi)
          r = [L*cos(phi), -L*sin(phi)]
   ]
)

или пример описания химической реакции в пакете Catalyst.jl с макросом @reaction_network:

using Catalyst
rs = @reaction_network begin
  c1, S + E --> SE
  c2, SE --> S + E
  c3, SE --> P + E
end
p  = (:c1 => 0.00166, :c2 => 0.0001, :c3 => 0.1)
tspan = (0., 100.)
u0 = [:S => 301, :E => 100, :SE => 0, :P => 0]

Метапрограммирование в Julia — это прямое наследие языка Lisp, где сама программа является структурой данных языка. Код программы разбирается на этапе компиляции в абстрактное синтаксическое дерево. При этом, Julia позволяет манипулировать элементами этого дерева в процессе выполнения кода. Что и является прямым аналогом макросов Lisp. Макросы C или C++ же работают совершенно иначе. Они раскрываются до собственно момента компиляции на этапе работы препроцессора. И никакой возможности повлиять на раскрытие макросов кодом на С++, у программиста нет (макросы имеют иной синтаксис).

Для программиста в Julia важно понимать то, что как на этапе раскрытия макроса, так и на этапе выполнения получившейся программы, работает всё тот же код на Julia. И это делает макросы простым и весьма полезным инструментом.

Julia изначально является скриптовым языком программирования. И это значит, что основной предполагаемый режим её использование — написание небольших фрагментов кода для решения конкретных вычислительных задач в виде автономных файлов или на страницах блокнота Jupyter Notebook или Pluto.jl. Но язык развивается. И сейчас на нём пишут сервисы для высоконагруженных систем, что включает и требование минимального времени запуска программы при развёртывании в кластере. Julia имеет компилятор, который отличается по принципу работы от прочих языков программирования, и отличается от JIT-компиляторов других распространённых динамических скриптовых языков.

Заметим, что автономный исполняемый файл программы на Julia сформировать можно, но это не является основным режимом запуска её программ. При этом, компилятора-кодогенератора в понимании языков программирования C, C++, Rust, у неё нет. Компиляция Julia-программы начинается в момент её запуска. Компилируются только те методы функций, которые реально потребовались для выполнения. То есть, код, который не был ни разу запущен, не будет откомпилирован вообще. При этом, есть ещё одна особенность. Если объявлена функция с аргументами какого-то общего типа, например Any, то при каждом обращении к этой функции с новым набором типов аргументов, компилятор будет создавать новый метод функции под эти конкретные типы. Итогом работы компилятора является код LLVM, транслируемый в машинный код.

Следствие такого подхода к компиляции — Julia долгие годы носила титул уникального языка программирования с проблемой времени первого подключения пакета (time to first execution - TTFE). Это было прямое следствие необходимости компиляции постоянно создаваемых новых методов с новыми комбинациями типов аргументов. И подключение пакетов в форме вызова using DataFrames, или using Plots приводило к тому, что программа могла задуматься на минуту ещё ничего не сделав полезного для разработчика. К моменту выпуска Julia 1.0 в 2018-м году механизм сохранения ранее скомпилированного LLVM-кода хоть и был, но, проблему в целом не решал.

Тем не менее, время не стоит на месте. Каждая последующая версия Julia хоть как-то, но уменьшала эту проблему. А в последние 2-3 года, команда разработчиков проделала очень серьёзную работу по именно решению этой проблемы предкомпиляции и кэширования результатов. В Julia 1.10 первый запуск программы всё равно требует цикл компиляции, но поскольку откомпилированный LLVM код хранится на диске, то последующие запуски с подключением даже упомянутых выше DataFrames или Plots происходят быстро.

Однако, сохранённый на диске LLVM-код ещё не является пригодным для исполнения процессором. И вот этап кодогенерации в целевую платформу в Julia решается совершенно нетрадиционно. Есть два варианта запуска бинарного исполняемого кода на Julia — автономный исполняемый файл (редко) или образ памяти программы, сохранённый на диск. В обоих случаях используется пакет PackageCompiler.jl . По сути, этот пакет осуществляет сброс на диск всех тех методов, которые были откомпилированы в LLVM-код и загружены в оперативную память для исполнения на конкретном процессоре. При этом, для следующего запуска программы достаточно подгрузить этот образ памяти при помощи опции --sysimage, а компиляция уже требоваться не будет. При условии, что в коде не появится вызов eval или вызов существующей функции с новым набором типов аргументов. Ответственность за стабильность типов полностью лежит на программисте, хотя инструменты для контроля этого в Julia имеются.

Более детальное пояснение процесса компиляции изложено в документации Julia. Оттуда же заимствована схема, предствленная ниже.

Компиляция и выполнение кода Julia
Компиляция и выполнение кода Julia

Такое длинное введение в особенности компиляции Julia важно для того, чтобы читателю было понятно, что идея динамической генерации кода в процессе выполнения программы хотя и допустима для Julia, но приводит к падению производительности, тратя время на компиляцию. И именно поэтому, макросы Julia, хоть и пишутся на Julia, имеют две строгие фазы: исполнение кода на этапе компиляции для раскрытия макроса, результатом которого будет новый код. И последующий этап исполнения этого нового кода. То есть, единожды запущенный код будет полностью откомпилирован, а код самого макроса имеет нулевое влияние на время дальнейшего исполнения. И в обеих фазах код представлен на Julia.

Введение в макросы

Идеи метапрограммирования в Julia довольно подробно описаны в соответствующем разделе документации . Основные моменты могут быть проиллюстрированы следующими примерами:

Любое выражение изначально представляет собой строку:

julia> prog = "1 + 1"
"1 + 1"

Но эта строка может быть разобрана в AST при помощи методов Meta.parse или Meta.parseall:

julia> ex1 = Meta.parse(prog)
:(1 + 1)

Тип выражения — Expr.

julia> typeof(ex1)
  Expr

При помощи метода dump мы можем распечатать это выражение:

julia> dump(ex1)
Expr
  head: Symbol call
  args: Array{Any}((3,))
    1: Symbol +
    2: Int64 1
    3: Int64 1

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

julia> ex3 = Meta.parse("(4 + 4) / 2")
:((4 + 4) / 2)

распечатка которого даёт уже явное дерево:

julia> dump(ex3)
Expr
  head: Symbol call
  args: Array{Any}((3,))
    1: Symbol /
    2: Expr
      head: Symbol call
      args: Array{Any}((3,))
        1: Symbol +
        2: Int64 4
        3: Int64 4
    3: Int64 2

При этом, выражение можно компактно распечатать при помощи метода Meta.show_sexpr .

julia> Meta.show_sexpr(ex3)
(:call, :/, (:call, :+, 4, 4), 2)

Следует отметить, что последовательность символов вида :abc — это литерал типа Symbol. А вот :(abc) — это выражение (в данном случае некий идентификатор abc).

Выражения также поддерживают интерполяцию аналогично строкам. Если необходимо подставить значение внутри выражения, используем символ $

julia> a = 1;
julia> ex = :($a + b)
:(1 + b)

При этом, вместо a можно подставить другое выражение. Например,

julia> args = [:x, :y, :z];
julia> :(f(1, $(args...)))
:(f(1, x, y, z))

Выражения могут быть многострочными. Для этого используется quote...end:

julia> ex4 = quote
         a = 1
         b = 2
         a + b
       end

Пару слов надо сказать о типе QuoteNode. Если запись :($a + b) предполагает непосредственную подстановку аргумента $a, то объект внутреннего типа QuoteNode исключает подстановку выражений внутри до того момента, пока запрос на это не будет сделан явно.

Выполнением выражения Expr занимается метод eval.

julia> eval(ex4)
3

При этом, выражения могут иметь сколь угодно глубокую вложенность. eval будет раскрывать их на каждом уровне.

Поскольку выражение является объектом типа Expr, оно может использоваться в качестве значения переменной, аргументов функции или для возвращаемого результата. Пример из документации. Создаём функцию, которая будет возвращать выражения:

julia> function make_expr2(op, opr1, opr2)
           opr1f, opr2f = map(x -> isa(x, Number) ? 2*x : x, (opr1, opr2))
           retexpr = Expr(:call, op, opr1f, opr2f)
           return retexpr
       end
make_expr2 (generic function with 1 method)

Проверяем подстановку аргументов этой функции:

 julia> ex = make_expr2(:+, 1, Expr(:call, :*, 5, 8))
:(2 + 5 * 8)

И запускаем результат выполнения (то есть Expr) при помощи eval:

julia> eval(ex)
 42

Собственно, определить макрос мы можем при помощи ключевого слова macro:

julia> macro sayhello()
           return :( println("Hello, world!") )
       end

Теперь, мы можем вызвать этот макрос:

julia> @sayhello()
Hello, world!

И тут возникает вопрос, если результатом выполнения функции может быть выражение, то для чего использовать макросы? Ответ очень прост. Дерево синтаксического разбора ещё не является кодом LLVM. В отличии от функции, возвращающей выражение, результат раскрытия макроса всегда будет откомпилирован до начала исполнения кода вокруг этого макроса.

Пакет DebugDataWriter.jl

Разберём принципы программирования макросов на примере простого, но весьма полезного для отладки пакета DebugDataWriter.jl .

В отличии от языков типа Java или C++, в Julia традиционно не используется пошаговый отладчик. Исторически это обусловлено проблемой скорости его работы. Как следствие, основной режим отладки — это отладка на модульных тестах с отладочной печатью. Но внутренние структуры данных бывают разными. Иногда они не помещаются на экран. Пакет DebugDataWriter.jl был разработан для того, чтобы в ключевых местах программы можно было вставить отладочный вывод структур данных в файлы, пригодные для дальнейшей обработки (например в JSON-формате). Параллельно, если отладка осуществляется в VS Code, то в консоль выводятся ссылки на эти сформированные файлы, и они могут быть тут же открыты простым нажатием ссылки на них. А после окончания отладки не надо заботиться о том, чтобы вырезать все эти вставки, поскольку они легко отключаются настройками. Принципиально в этом пакете то, что при выключенном режиме отладки, фрагменты кода отладки не будут помещены в финальный код или оказывают минимальное влияние на производительность в зависимости от того, как предполагается включать режим отладки. И в любом случае, часть макроса, ответственная за предварительные проверки аргументов, не влияет на финальную производительность.

Пример использования макроса @debug_output в режиме, когда отладочная печать может быть включена или выключена в любой части программы:

using DebugDataWriter
# Enable adding trace info with the @info macro
# Each record contains links to the source code and to the saved data file 
DebugDataWriter.config().enable_log = true

# Enable saving dumps of data structures
DebugDataWriter.config().enable_dump = true

id = get_debug_id("Some query")
@debug_output id "some complex structure" ones(2, 3)
@debug_output id "some complex structure" ones(2, 3) :HTML
@debug_output id "some complex structure" ones(2, 3) :TXT

# the lambda is executed when enable_dump == true
@debug_output id "some data structure as a lambda" begin
    zeros(5, 2)
end

И очень похожий код, но режим макроса @ddw_out определяется состоянием переменной окружения DEBUG_OUTPUT. Если её нет, то никакой код сгенерирован не будет.

# id = @ddw_get_id       # this call gives a default name
id = @ddw_get_id "test"  # id will have this prefix
@ddw_out id "some structure as lambda" begin
    zeros(5, 2)
end
@ddw_out id "text as a text" ones(2, 3) :TXT

Начнём с макроса @debug_output. Метод get_debug_id возвращает идентификатор, который используется в качестве имени директории, куда будет сбрасываться структура данных в одном из указанных макросу формате — HTML, TXT, JSON. В составе метода get_debug_id имеются проверки, включен ли режим отладочной печати. Если нет, то он вернёт пустую строку. Но проверка переменных enable_dump и enable_log будет выполняться при каждом вызове этого метода.

В самом пакете есть внутренний метод для, собственно, вывода на печать:

debug_output(data_getter::Function, debug_id::AbstractString,
    code_pos::Union{AbstractString,Nothing}, title::AbstractString, fmt=:JSON

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

Макрос имеет две реализации. Первая довольно простая и предполагает вывод в формате JSON по-умолчанию:

macro debug_output(debug_id, title, data_func)
    code_pos = string(__source__)
    return :(debug_output($(esc(debug_id)), $code_pos, $(esc(title)), $(esc(data_func))))
end

В этом коде следует обратить внимание на __source__ – это встроенная переменная, которая содержит имя файла и позицию в коде, где разворачивается этот макрос.

И второй важный момент — абсолютно все аргументы, переданные макросу, обёрнуты вызовом метода esc. Это необходимо для изоляции переданного выражения от контекста самого макроса. Этот момент совершенно не очевиден. А отладка подобных мест, если её упустить, может занять очень много времени. Развёрнутые объяснения приводятся в разделе документации о гигиене макросов.

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

julia> module MacroDemo
       macro m1(x)
           :($x)
       end

       macro m2(x)
           :($(esc(x)))
       end
       end

Теперь проверим во что они разворачиваются:

julia> @macroexpand MacroDemo.@m1 a + b + 1
:(Main.MacroDemo.a + Main.MacroDemo.b + 1)

julia> @macroexpand MacroDemo.@m2 a + b + 1
:(a + b + 1)

И тут мы видим, что макрос @m1, где выражение было прямо передано в возвращаемый результат, развернулось с дополнительным префиксом переменных Main.MacroDemo. То есть, ожидается наличие этих переменных внутри модуля MacroDemo. Main в данном случае модуль текущего контекста в REPL. Второй же макрос оставил переменные в неизменном виде. Если дальше мы определим переменные и, всё же, запустим макросы с этими выражениями, то для вызова @m1 мы получим совершенно непонятную ошибку:

julia> a = 1; b = 2;

julia> MacroDemo.@m1 a + b + 1
ERROR: UndefVarError: `a` not defined
Stacktrace:
 [1] top-level scope
   @ REPL[33]:1

А вот макрос @m2 выполнится без каких-либо проблем:

julia> MacroDemo.@m2 a + b + 1
4

При этом, ошибка UndefVarError: `a` not defined вызвана тем, что переменной Main.MacroDemo.a действительно нет. А код, в который развернулся макрос @m2, использует эти переменные в то контексте, откуда был вызван макрос.

В модуле DebugDataWriter есть вторая реализация макроса @debug_output, где присутствует логика анализа запрошенного формата. Она реализована в методе assert_format. Идея в том, чтобы выдать ошибку на этапе развёртывания макроса и не доводить её до исполнения.

macro debug_output(debug_id, title, data_func, fmt)
    assert_format(fmt)

    code_pos = string(__source__)

    # code_pos = string(@__FILE__, ":", @__LINE__)
    return :(debug_output($(esc(debug_id)), $code_pos, $(esc(title)), $(esc(data_func)), fmt=$(esc(fmt))))
end

Если программист указал неправильный формат, мы хотим сгенерировать сообщение об ошибке ещё на этапе компиляции.

Например:

julia> @debug_output get_debug_id() "text table" ones(2, 3) :TEX
ERROR: Error format :TEX. Only the following formats are supported: HTML; XML; JSON; TXT; SVG

Или перепутанный аргумент:

@debug_output get_debug_id() "text table" :TXT ones(2, 3)
ERROR: Error format ones(2, 3). Only the following formats are supported: HTML; XML; JSON; TXT; SVG

Реализация метода assert_format очень проста. В качестве аргумента он принимает выражение в виде QuoteNode (нормальный случай передачи литералов Symbol) или Expr (для прочих случаев). В сущности, все аргументы макроса имеют тип QuoteNode (выражение без раскрытия подстановок значений) или Expr. Задача в том, чтобы проверить что же было подставлено программистом в качестве fmt.

function assert_format(fmt::Union{QuoteNode, Expr})
    supported_outputs = keys(FORMAT_WRITERS)
    # dump(fmt);  dump(esc(fmt))
  
    if !isa(fmt, QuoteNode) || !(fmt.value in supported_outputs)
        throw(
            ErrorException(
                "Error format $fmt. Only the following formats are supported: " *
                join(supported_outputs, "; ")
            )
        )
    end
end

Закомментированная строка с вызовом dump содержит отладочный вывод структуры, которая пришла на вход метода. Если мы ориентируемся на то, что входной аргумент у макроса указан как Symbol, например :JSON, :TXT, :HTML, то тип переданного аргумента будет QuoteNode. Вызов dump(fmt) выдаст:

QuoteNode
  value: Symbol JSON

Здесь очевидно, что значение доступно прямо через fmt.value. Если бы входной аргумент был обёрнут в вызов метода esc, то переданная структура AST имела бы вид:

Expr
  head: Symbol escape
  args: Array{Any}((1,))
    1: QuoteNode
      value: Symbol JSON

А добраться до собственно указанного значения мы могли бы через каскадное обращение по полям argsпоследний элемент массиваvalue:

    fmt_expr = last(esc(fmt).args)
    if !isa(fmt, QuoteNode) || !(fmt_expr.value in supported_outputs)
    …
    end

При такой логике метод assert_format способен разобрать выражения, содержащее только литерал типа Symbol. Естественно, может возникнуть вопрос, а почему бы не реализовать передачу переменных в качестве формата. И от таких попыток надо сразу предостеречь:

fmt = :TXT
@debug_output get_debug_id() "text table" ones(2, 3) fmt

Суть проблемы в том, что такой код предполагает раскрытие макроса в этом контексте до того, как будет выполнено присвоение локальной переменной fmt. То есть первая фаза компиляции. И чтобы мы ни пытались сделать, мы не сможем получить ничего, кроме имени переданной переменной внутри макроса.

Однако, если мы хотим сделать поведение внутри макроса зависимым от внешних переменных, мы, всё же, можем это сделать. И таким примером является реализация очень похожего макроса этого же пакета:

macro ddw_out(debug_id, title, data_func, fmt)
    is_debug_output_enabled() || return
  
    assert_format(fmt)

    code_pos = string(__source__)
    # code_pos = string(@__FILE__, ":", @__LINE__)
    return :(debug_output($(esc(debug_id)), $code_pos, $(esc(title)), $(esc(data_func)), fmt=$(esc(fmt))))
end

Принципиальное отличие здесь от предыдущего макроса — это наличие проверки is_debug_output_enabled() || return. Если проверка не выполнена, то макрос возвращает nothing в качестве сгенерированного кода. А это значит, что после его раскрытия в месте вызова никакого дополнительного кода не появляется, и выполнение программы будет проходить так, как будто здесь ничего и не было.

Метод is_debug_output_enabled имеет весьма простую реализацию. Он проверяет наличие переменной окружения DEBUG_OUTPUT. Если её нет, то возвращает false. Если есть, то взводит режим вывода в соответствии со строкой, содержащейся внутри DEBUG_OUTPUT.

function is_debug_output_enabled()
    mode = get(ENV, "DEBUG_OUTPUT", nothing)

    isnothing(mode) && return false

    contains(mode, "log") && (config().enable_log = true)
    contains(mode, "dump") && (config().enable_dump = true)

    return true
end

Выглядит это как смешивание этапа компиляции и выполнения. И, по сути, так оно и есть, поскольку Julia может выполнить код в рамках одного контекста (модуля или функции), но продолжать компилировать другие. Чисто технически, можно даже надеяться на то, что код ниже будет корректно работать, а метод is_debug_output_enabled будет всегда возвращать true для первого случае и false для второго.

ENV["DEBUG_OUTPUT"] = "log | dump"
function f1()
  @ddw_out id "text as a text" "ones(2, 3)" :TXT
end

delete!(ENV, "DEBUG_OUTPUT")
function f2()
  @ddw_out id "text as a text" "ones(2, 3)" :TXT
end

Макросы, действительно, помещены внутрь кода методов. И при последовательном выполнении этого кода в REPL всё будет прекрасно работать, но мы не можем гарантировать, что фаза раскрытия макроса произойдёт строго после присвоения значения переменной, поскольку эти процессы не последовательны во времени. Впрочем, если переменная выставлена ещё до запуска процесса Julia, мы можем быть полностью уверены, что проверка внутри макроса будет всегда корректной.

Тесты макросов

Важным этапом создания пакета с макросами является написание модульных тестов. В любом Julia-пакете они должны находиться в директории test/. Для макросов, по сути, нам надо выполнить проверку как факта их раскрытия, так и проверку корректности выполнения кода, который был ими подставлен. В этом пакете макрос @debug_output вызывает внутри метод debug_output. Поэтому можно разделить проверку логики метода и макроса.

Для проверки факта раскрытия макроса достаточно поместить его внутрь блока тестирования @testset. Если допущены ошибки в реализации этой фазы, тесты не пройдут по факту ошибки компиляции. А вот тестирование макросов, зависимых от внешней переменной, требует немного другого подхода.

Например, мы можем написать следующий код:

ENV["DEBUG_OUTPUT"] = "log | dump"
@test "" !== @macroexpand @ddw_get_id
@test "" !== @macroexpand @ddw_get_id "test"
@test nothing !== @macroexpand @ddw_out id "some structure as lambda" () -> zeros(5, 2)
@test nothing !== @macroexpand @ddw_out id "text as a text" "ones(2, 3)" :TXT

Макрос @macroexpand возвращает выражение-результат раскрытия макроса, но этот код не компилируется в LLVM-код. Данный пример иллюстрирует, что раскрытие макроса @ddw_get_id при выставленной переменной DEBUG_OUTPUT приводит к тому, что некий результат присутствует. Технически, можно провести детальный анализ выражения AST, которое вернул @macroexpand. Но здесь конкретно проверяется, что для случая, когда макрос должен быть активирован, код, который генерируется макросом не является пустой строкой (для совместимости @ddw_get_id) или пустым.

Если же мы хотим выполнить реальный код, который получен при раскрытии макроса по условию наличия переменной, нам надо гарантировать, что макрос раскрыт строго после установки переменной. Возможно, единственный надёжный способ сделать это в автоматическом тесте — это принудительно вызвать фазу компиляции с помощью комбинации методов Meta.parseall для разбора строки, содержащей код, и метода eval для запуска полученного выражения.

eval(Meta.parseall("""
        id = @ddw_get_id
        @test !isempty(id)
        id = @ddw_get_id "test"
        @test !isempty(id)

        @ddw_out id "some structure as lambda" begin
            zeros(5, 2)
        end
        @ddw_out id "text as a text" ones(2, 3) :TXT
""")    

Использование eval в реальном коде крайне не рекомендуется потому что это приводит к непредсказуемым задержкам и требует наличия модуля компилятора как такового. Однако на этапе модульного тестирования время не критично. А модуль компилятора гарантированно будет присутствовать. В случае же создания автономного исполняемого файла, использования eval следует избегать, поскольку иначе он не сможет быть собран без динамической библиотеки компилятора.

В целом, конкретная реализация тестов макросов зависит от решаемых ими задач. Иногда макросы создают большой объем кода, который будет весьма трудоёмко обходить по полям иерархических структур Expr. В этих случаях тесты могут проверять именно результат работы того кода, который был ими порождён. В простых случаях, тесты могут проверять именно результат раскрытия в форме Expr.

Пакет ToggleableAsserts.jl

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

using ToggleableAsserts
function foo(u, v)
    @toggled_assert length(u) == length(v)
    1
end
function bar()
    toggle(false)
    foo([1, 2], [1])
    toggle(true)
    foo([1, 2], [1])
end

Вызовы toggle(false), toggle(true) осуществляют включение и отключение режима проверки. Реализация всего пакете и макроса @toggled_assert очень проста:

assert_toggle() = true
macro toggled_assert(cond, text=nothing)
    if text==nothing
        assert_stmt = esc(:@assertt $cond))
    else
        assert_stmt = esc(:@assertt $cond $text))
    end
    :(assert_toggle() ? $assert_stmt  : nothing)
end
const toggle_lock = ReentrantLock()
function toggle(enable::Bool)
    lock(toggle_lock) do
        @eval ToggleableAsserts assert_toggle() = $enable
        on_or_off = enable ? "on." : "off."
        @info "Toggleable asserts turned "*on_or_off
    end
end

Однако здесь следует обратить на два момента. Макрос @toggled_assert возвращает выражение :(assert_toggle() ? $assert_stmt : nothing), содержащее проверку результата возврата метода assert_toggle(). Код, который содержится в макросе до этого момента, выполняется только на этапе раскрытия макроса и не влияет на дальнейшую работу. А вот вызов assert_toggle() здесь довольно хитрый. Дело в том, что компилятор Julia умеет оптимизировать код и исключать ненужное, если, например, условие всегда истинно или всегда ложно. В данном случае, метод assert_toggle() определяется динамически вызовом @eval ToggleableAsserts assert_toggle() = $enable. Факт пересоздания этого метода автоматически приводит к перекомпиляции всего кода, где он используется. Но если метод объявлен как assert_toggle() = false, это приводит к тому, что всё выражение, возвращаемое toggled_assert, будет пустым.

Решение довольно интересное, но спорное. Главная претензия в том, что eval будет вызываться во время работы программы, а не во время её первичной компиляции. Если программа собрана как отдельный исполняемый модуль, то этот пакет будет работать лишь при наличии библиотеки компилятора. У современных версий Julia её можно не подключать. Тем не менее, при условной компиляции по значению внешней переменной, как в случае DebugDataWriter.jl, такой подход использовать можно.

Реализации языков предметной области с помощью макросов

В эту группу отнесём пакеты, в которых макросы обеспечивают использование некоего языка, адаптированного под эту предметную область. Сюда отнесём как вариант DSL - domain specific language, так и DSeL — domain specific embedded language.

Эта группа пакетов довольно многочисленная. Например, макрос @reaction_network из пакета Catalyst.jl.

using Catalyst
rs = @reaction_network begin
  c1, S + E --> SE
  c2, SE --> S + E
  c3, SE --> P + E
end
p  = (:c1 => 0.00166, :c2 => 0.0001, :c3 => 0.1)
tspan = (0., 100.)
u0 = [:S => 301, :E => 100, :SE => 0, :P => 0]

В пакете имеются четыре реализации макроса @reaction_network. Код для обработки выше приведённого фрагмента:

macro reaction_network(ex::Expr)
    ex = MacroTools.striplines(ex)

    # no name but equations: @reaction_network begin ... end ...
    if ex.head == :block
        make_reaction_system(ex)
    else  # empty but has interpolated name: @reaction_network $name
        networkname = :($(esc(ex.args[1])))
        return Expr(:block, :(@parameters t),
                    :(ReactionSystem(Reaction[], t, [], []; name = $networkname)))
    end
end

Основной принципе обработки — Julia сама уже проделала предварительную работу и составила из фрагментов не совсем Julia-кода дерево разбора выражений, которое представлено объектом Expr. А вот дальнейшая логика обработки в Catalyst.jl для нашего случая реализуется в методе make_reaction_system.

Метод имеет громоздкую реализацию, но некоторые его фрагменты покажем здесь:

    # Read lines with reactions and options.
    reaction_lines = Expr[x for x in ex.args if x.head == :tuple]
    option_lines = Expr[x for x in ex.args if x.head == :macrocall]

    # Get macro options.
    options = Dict(map(arg -> Symbol(String(arg.args[1])[2:end]) => arg,
                       option_lines))

    # Parses reactions, species, and parameters.
    reactions = get_reactions(reaction_lines)
    species_declared = extract_syms(options, :species)
    parameters_declared = extract_syms(options, :parameters)
    variables = extract_syms(options, :variables)

Хорошо видно организацию пересоздания выражений типа Expr по компонентам через фильтры полей args и head.

Возвращает этот метод уже полноценный Julia-код, который может быть откомпилирован в LLVM-представление и запущен. В коде можно заметить генерацию новых выражений на основе выявленных конструкций языка реакций, который реализуют авторы пакета.

...
# Creates expressions corresponding to actual code from the internal DSL representation.
sexprs = get_sexpr(species_extracted, options; iv_symbols = ivs)
vexprs = haskey(options, :variables) ? options[:variables] : :()
pexprs = get_pexpr(parameters_extracted, options)
ps, pssym = scalarize_macro(!isempty(parameters), pexprs, "ps")
vars, varssym = scalarize_macro(!isempty(variables), vexprs, "vars")
sps, spssym = scalarize_macro(!isempty(species), sexprs, "specs")
rxexprs = :(Catalyst.CatalystEqType[])
for reaction in reactions
    push!(rxexprs.args, get_rxexprs(reaction))
end
# Returns the rephrased expression.
return quote
    $ps
    $ivexpr
    $vars
    $sps
    Catalyst.make_ReactionSystem_internal($rxexprs, $tiv, union($spssym, $varssym),
                                          $pssym; name = $name,
                                          spatial_ivs = $sivs)
end

Тесты этих макросов являются косвенными. В силу объема кода, проверки осуществляются по результатам выполнения того, что было сгенерировано. См. пример из этого пакета.

В эту же группу DSeL отнесём макросы из пакетов, предназначенных для построения конвейеров обработки. Например в контексте обработки таблицы (или DataFrame).

DataPipes.jl

@p begin
    tbl
    filter(!any(ismissing, _))
    filter(_.id > 6)
    groupview(_.group)
    map(sum(_.age))
end

Chain.jl

@chain df begin
  dropmissing
  filter(:id => >(6), _)
  groupby(:group)
  combine(:age => sum)
end

Pipe.jl

@pipe df |>
  dropmissing |>
  filter(:id => >(6), _)|>
  groupby(_, :group) |>
  combine(_, :age => sum)

Lazy.jl

@> df begin
  dropmissing
  x -> filter(:id => >(6), x)
  groupby(:group)
  combine(:age => sum)
end

Во всех этих случаях, макросы обеспечивают преобразование кода, который находится в блоке begin...end, в код, непосредственно соответствующий синтаксису Julia. В отличии от подробно рассмотренных выше пакетов, вспомогательные функции для обработки выражений здесь намного сложнее. Поэтому касаться их мы здесь не будем. Код всех перечисленных выше пакетов доступен на GitHub.

Макросы для строк

Julia предоставляет механизм реализации нестандартных строк . Например, литерал регулярного выражения r"^\s*(?:#|$)" или последовательность из 8-ми байт b"DATA\xff\u2200". Для реализации подобных строк используется макрос со специальным суффиксом _str. Например, для регулярных выражений это макрос:

macro r_str(p)
    Regex(p)
end

И вызвать его можно не только в сокращённой форме, но и в полной форме: @r_str("^\s*(?:#|$)").

В то же время, полный вид выглядит как:

macro foo_str(str, flag)
    # do stuff
end

А вызов этого макроса имеет вид foo"str"flag.

Более сложный вариант разбора реализован в пакете Unitful.jl для обозначения физических величин, который используется в Modia.jl . Макрос Unitful.@u_str разбирает выражения вида 1.0u"m/s", 1.0u"N*m", u"m,kg,s".

Реализация этого метода :

macro u_str(unit)
    ex = Meta.parse(unit)
    unitmods = [Unitful]
    for m in Unitful.unitmodules
        # Find registered unit extension modules which are also loaded by
        # __module__ (required so that precompilation will work).
        if isdefined(__module__, nameof(m)) && getfield(__module__, nameof(m)) === m
            push!(unitmods, m)
        end
    end
    esc(lookup_units(unitmods, ex))
end

Строка, которая передана аргументом unit, подвергается разбору с помощью метода Meta.parse. Результатом его работы является объект Expr, который далее разбирается методом lookup_units. Итогом же его работы является кортеж объектов типа Unitful.FreeUnits, соответствующих указанным физическим величинам.

julia> dump(Meta.parse("m,kg,s"))
Expr
  head: Symbol tuple
  args: Array{Any}((3,))
    1: Symbol m
    2: Symbol kg
    3: Symbol s
julia> dump(u"m,kg,s")
Tuple{Unitful.FreeUnits{(m,), 𝐋, nothing}, Unitful.FreeUnits{(kg,), 𝐌, nothing}, Unitful.FreeUnits{(s,), 𝐓, nothing}}
1: Unitful.FreeUnits{(m,), 𝐋, nothing} m
2: Unitful.FreeUnits{(kg,), 𝐌, nothing} kg
3: Unitful.FreeUnits{(s,), 𝐓, nothing} s

То есть, код, который сгенерировал макрос, полностью собран на основании анализа строки.

Аналогично примеры:

 julia> dump(u"N*m")
Unitful.FreeUnits{(m, N), 𝐋² 𝐌 𝐓⁻², nothing} m N
julia> dump(u"m/s")
Unitful.FreeUnits{(m, s⁻¹), 𝐋 𝐓⁻¹, nothing} m s⁻¹

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

Заключение

За рамками этой статьи остались генерируемые функции, которые создаются специальным макросом @generated. Но, возможно, следует вынести их в отдельную статью вместе с вариантами динамической генерации новых методов при помощи макросов. Дополнительная информация по макросу @generated и по способу генерации фунций из любого макроса.

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

Tags:
Hubs:
Total votes 13: ↑13 and ↓0+13
Comments0

Articles