使用宏在 Julia 中创建特定领域语言

2017 年 8 月 9 日 | David P. Sanders

从 Julia 的诞生之日起,就一直有人尝试使用宏来编写 **特定领域语言** (DSL),也就是说,通过 *扩展* Julia 语法来提供更简单的接口,以便创建具有复杂行为的 Julia 对象。第一个也是至今为止最广泛的例子是 JuMP

自修复了臭名昭著的早期 Julia 问题 #265 之后,该修复程序已集成到 Julia 0.6 中,一些之前在 Julia 中创建 DSL 的方法,主要涉及 eval,已不再有效。

在这篇文章中,我们将描述一个推荐的模式(即一个可重用的结构),用于在 *不* 使用 eval 的情况下创建 DSL,使用适合 Julia 0.6 及更高版本的语法;强烈建议升级到 Julia 0.6。

创建包含函数的 Model 对象

这篇文章源于 JuliaCon 2017 黑客马拉松中关于 Modia 建模语言 的一个问题,其中有一个 @model 宏。在这里,我们将描述这种宏的最简单版本,它将创建一个包含函数的 Model 对象,并且该对象本身也是可调用的。

首先,我们定义 Model 对象。人们很容易这样写

struct NaiveModel
    f::Function
end

然后,我们可以使用默认构造函数创建 NaiveModel 类型的实例(即该类型的对象),例如,通过向其传递一个匿名函数

julia> m1 = NaiveModel(x -> 2x)
NaiveModel(#1)

我们可以使用以下方法调用函数

julia> m1.f(10)
20

如果希望像 m 这样的实例本身也像函数一样,我们可以在 NaiveModel 对象上重载调用语法

julia> (m::NaiveModel)(x) = m.f(x)

这样,我们现在可以只写

julia> m1(10)
20

对类型进行参数化

由于 Function 是一个抽象类型,为了性能,我们 *不* 应该在对象内部有这种类型的字段。相反,我们使用函数类型对类型进行参数化

struct Model{F}
    f::F
end

(m::Model)(x) = m.f(x)
julia> m2 = Model(x->2x)
Model{##3#4}(#3)
julia> m2(10)
20

让我们比较一下性能

julia> using BenchmarkTools

julia> @btime m1(10);
41.482 ns (0 allocations: 0 bytes)

julia> @btime m2(10);
20.212 ns (0 allocations: 0 bytes)

事实上,我们在第二种情况下减少了一些开销。

操作表达式

我们希望定义一个 *宏*,它将允许我们使用我们选择的简单语法来创建对象。假设我们希望使用以下语法

julia> @model 2x

来定义一个包含函数 x -> 2xModel 对象。请注意,2x 本身不是创建函数的有效 Julia 语法;宏将允许我们根据自己的需要使用这种简化的语法。

在开始使用宏之前,让我们先构建一些工具来以正确的方式操作表达式 2x,以使用标准的 Julia 函数从中构建 Model 对象。

首先,让我们创建一个函数来操作我们的表达式

function make_function(ex::Expr)
    return :(x -> $ex)
end
julia> ex = :(2x);

julia> make_function(ex)
:(x->begin  # In[12], line 2:
    2x
end)

在这里,我们创建了一个名为 ex 的 Julia 表达式,它只包含我们想要作为新函数主体使用的表达式 2x,并将此表达式传递给了 make_function,该函数将它包装成一个完整的匿名函数。这假设 ex 是一个包含变量 x 的表达式,并生成一个新的表达式,代表一个带有单个参数 x 的匿名函数。(例如,参见 我的 JuliaCon 2017 教程,了解如何遍历表达式树以 *自动* 提取其中包含的变量。)

现在,让我们定义一个函数 make_model,它接受一个函数,对其进行包装,然后将其传递给一个 Model 对象

function make_model(ex::Expr)
    return :(Model($ex))
end
julia> make_model(make_function(:(2x)))
:(Model((x->begin  # In[12], line 2:
            2x
        end)))

如果我们 "手动" 评估它,我们会看到它正确地创建了一个 Model 对象

julia> m3 = eval(make_model(make_function(:(2x))))
Model{##7#8}(#7)

julia> m3(10)
20

然而,这很丑陋,也很笨拙。相反,我们现在将所有内容包装在一个 **宏** 中。宏是一个代码操作器:它接收代码,以某种方式对其进行处理(可能包括完全重写它),并输出新生成的代码。这使得宏在正确使用时成为一个非常强大(因此也危险)的工具。

在最简单的情况下,宏将单个 Julia Expr 对象作为参数,即一个未经评估的 Julia 表达式(即一段 Julia 代码)。它操作这个表达式对象以创建一个新的表达式对象,然后返回该对象。

关键在于,这个返回的表达式将 *替换* 旧代码,"拼接" 到新生成的代码中。编译器实际上永远不会看到旧代码,只会看到新代码。

让我们从最简单的宏开始

macro model(ex)
    @show ex
    @show typeof(ex)
    return nothing
end

这只是显示了它被传递的参数并退出,返回一个空表达式。

julia> m4 = @model 2x
ex = :(2x)
typeof(ex) = Expr

我们看到,Julia Expr 对象已从我们键入的显式代码中自动创建。

现在,我们可以插入我们之前的函数来完成宏的功能

julia> macro model(ex)
           return make_model(make_function(ex))
       end

@model (macro with 1 method)

julia> m5 = @model 2x
Model{##7#8}(#7)

julia> m5(10)
20

为了检查宏是否按预期执行,我们可以使用 @macroexpand 命令,它本身是一个宏(如初始 @ 所示)

julia> @macroexpand @model 2x
:((Main.Model)((#71#x->begin  # In[12], line 2:
                    2#71#x
                end)))

宏 "卫生"

但是,我们的宏存在一个问题,称为宏 "卫生"。这与变量定义的位置有关。让我们将到目前为止的所有内容都放在一个模块中

module Models

export Model, @model

struct Model{F}
    f::F
end

(m::Model)(x) = m.f(x)

function make_function(ex::Expr)
    return :(x -> $ex)
end

function make_model(ex::Expr)
    return :(Model($ex))
end

macro model(ex)
    return make_model(make_function(ex))
end

end

现在,我们导入模块并使用宏

julia> using Models

julia> m6 = @model 2x;

julia> m6(10)
20

到目前为止一切顺利。但现在,让我们尝试在表达式中包含一个全局变量

julia> a = 2;

julia> m7 = @model 2*a*x
Models.Model{##7#8}(#7)

julia> m7(10)
UndefVarError: a not defined
Stacktrace:
 [1] #7 at ./In[1]:12 [inlined]
 [2] (::Models.Model{##7#8})(::Int64) at ./In[1]:9

我们看到它找不到 a。让我们看看宏在做什么

julia> @macroexpand @model 2*a*x
:((Models.Model)((#4#x->begin  # In[1], line 12:
                    2 * Models.a * #4#x
                end)))

我们看到 Julia 正在查找 Models.a,即在 Models 模块中定义的变量 a

为了解决这个问题,我们必须编写一个 "非卫生" 宏,通过使用 esc 函数对代码进行 "转义"。这是一种机制,告诉编译器在调用宏的范围(此处为当前模块 Main)中查找变量定义,而不是宏定义所在的范围(此处为 Models 模块)

module Models2

export Model, @model

struct Model{F}
    f::F
end

(m::Model)(x) = m.f(x)

function make_function(ex::Expr)
    return :(x -> $ex)
end

function make_model(ex::Expr)
    return :(Model($ex))
end

macro model(ex)
    return make_model(make_function(esc(ex)))
end

end
julia> using Models2

julia> a = 2;

julia> m8 = @model 2*a*x
Models2.Model{##3#4}(#3)

julia> m8(10)
40

这是宏的最终工作版本。

结论

我们已经成功地完成了我们的任务:我们已经看到了如何创建一个宏,它使能够创建一种简单的语法,用于创建可以在以后使用的 Julia 对象。

有关元编程技术和宏的更深入讨论,请参见我的视频教程 *Julia 入门*,该教程在 JuliaCon 2016 上发布

作者David P. Sanders,副教授,墨西哥国立自治大学(UNAM)物理系,科学学院。