从 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 -> 2x
的 Model
对象。请注意,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 上发布
链接到 视频
链接到 Jupyter 笔记本
作者:David P. Sanders,副教授,墨西哥国立自治大学(UNAM)物理系,科学学院。