宏和元编程是 Julia 里非常有代表性的部分。它们既是语言能力的一部分,也是很多 Julia 代码看起来“很不一样”的原因。

不过宏虽然重要,但绝对不是日常写 Julia 代码的起点。大多数普通逻辑都应该优先写成函数,只有当函数做不到,或者语法层面介入明显更自然时,才值得考虑宏。

概述

Julia 的宏并不是 C 语言那种简单文本替换,而是基于语法树(AST)的代码变换工具。

可以先这样理解:

  • 函数操作的是“值”;
  • 宏操作的是“代码表达式”;
  • 宏在代码真正运行前先展开;
  • 展开结果再交给 Julia 去编译和执行。

因此,宏更像是“改写代码的工具”,而不是普通的可调用逻辑。

宏的基本形式

最简单的宏定义如下:

1
2
3
macro sayhello()
return :(println("Hello, World!"))
end

调用时使用 @

1
@sayhello

输出:

1
Hello, World!

这里最值得注意的是:

  • 宏调用通常不需要括号;
  • 宏返回的不是最终结果,而是一段表达式;
  • 这段表达式会继续被 Julia 执行。

当然,带括号的调用也允许:

1
@sayhello()

宏和函数的区别

这是最重要的一节。

先看函数:

1
2
3
4
5
function show_value(x)
println("value = ", x)
end

show_value(1 + 2)

这里函数拿到的已经是表达式 1 + 2 计算后的结果 3

再看宏:

1
2
3
4
5
6
macro show_expr(x)
println(x)
return x
end

@show_expr 1 + 2

宏拿到的是表达式本身,而不是它的值。

这一点决定了宏能做很多函数做不到的事,例如:

  • 获取用户写下来的原始表达式;
  • 自动生成重复代码;
  • 在语法层面插入日志、计时、边界消除等逻辑;
  • 构造 DSL(领域专用语法)。

但也意味着宏更复杂、更容易把代码变得难读。

表达式 Expr

Julia 把代码本身表示为表达式对象 Expr

例如

1
2
ex = :(1 + 2)
typeof(ex) # Expr

可以查看其结构:

1
dump(ex)

一般会看到:

  • head 表示表达式类型;
  • args 表示表达式的组成部分。

例如 1 + 2 这种表达式,本质上大致可以理解为:

1
Expr(:call, :+, 1, 2)

这也是为什么 Julia 的元编程能力很自然,因为代码本身就是语言内可操作的数据结构。

引号表达式

:(...)

最常见的构造表达式方式是:

1
:(a + b)

这不会立即计算 a + b,而是构造出对应的表达式。

quote ... end

多行表达式可以用:

1
2
3
4
5
quote
x = 1
y = 2
x + y
end

这和 :(...) 的作用类似,只是更适合多行结构。

插值 $

在构造表达式时,可以使用 $ 把已有变量或表达式插进去:

1
2
3
4
name = :x
ex = :($name + 1)

ex # :(x + 1)

这在写宏时非常常见。

一个简单的宏例子

下面做一个非常经典的“打印表达式和值”的宏:

1
2
3
4
5
macro myshow(ex)
return quote
println($(string(ex)), " = ", $(esc(ex)))
end
end

使用:

1
2
x = 10
@myshow x + 1

输出大致类似:

1
x + 1 = 11

这个例子里有几个关键点:

  • string(ex) 把原始表达式变成字符串;
  • esc(ex) 把用户传入的表达式放回调用者作用域求值;
  • quote ... end 用来返回一段新的代码。

esc

esc 是 Julia 宏里最需要重点理解的关键字之一。

如果不使用 esc,宏内部引用到的变量名可能会在宏自己的作用域中解析,而不是在调用者作用域中解析。

例如写宏时:

1
2
3
macro bad(ex)
:(println($ex))
end

在一些简单场景下看起来能工作,但一旦宏里生成的代码涉及变量绑定、局部变量、调用现场变量,就很容易因为卫生问题(hygiene)出现意料之外的名字解析。

通常的经验是:

  • 用户传进来的表达式,往往应该 esc(...)
  • 宏自己内部生成的辅助变量,通常不应该 esc

如果这一点没处理好,宏会非常容易出问题。

宏展开

Julia 提供了非常好用的调试工具来查看宏到底展开成了什么。

最常用的是:

1
@macroexpand @time 1 + 2

或者

1
macroexpand(Main, :(@time 1 + 2))

这在学习宏和调试宏时几乎是必备工具。

因为很多时候“宏为什么行为怪异”,答案都直接写在展开后的代码里。

常见内置宏

@show

1
2
x = 3
@show x

它会同时打印表达式和值。

@time

1
@time sum(rand(10^6))

可以快速查看运行时间和分配情况,但第一次运行往往包含编译成本。

@views

1
@views A[:, 1]

可以把一段代码里的切片尽量改成视图。

@inbounds

1
2
3
@inbounds for i in eachindex(a)
s += a[i]
end

表示跳过边界检查。这个宏和性能优化关系很大,但使用前必须确保索引绝对安全。

@simd

用于提示编译器对循环做 SIMD 优化尝试,不过它不是“加上就一定更快”的开关。

生成表达式与 eval

有了表达式之后,还可以用 eval 去执行它:

1
2
ex = :(1 + 2)
eval(ex) # 3

这让 Julia 具备非常强的动态生成代码能力。

不过需要注意:

  • eval 发生在全局作用域;
  • 在函数内部依赖 eval 往往不是一个好主意;
  • 绝大多数时候,如果只是普通逻辑分发,应该优先考虑函数、闭包、字典映射、分派机制,而不是 eval

生成函数 @generated

Julia 还有一个很特别的机制:生成函数(generated function)。

形式大致如下:

1
2
3
@generated function f(x)
# 根据参数类型生成代码
end

它和宏很像,但又不是宏:

  • 宏是在语法层面展开;
  • 生成函数是在已知参数类型后生成对应方法体;
  • 它更接近“类型驱动代码生成”。

这部分确实很强,但也更复杂、更容易写出晦涩代码。对于一般笔记整理,知道它的存在和大致定位就够了,不建议一开始就重度使用。

什么时候该用宏

宏适合的典型场景:

  • 需要获取用户写下来的表达式本身;
  • 需要在语法层面插入代码;
  • 需要构造简洁的 DSL;
  • 需要像 @time@show 这样做“包裹一段代码”的工具。

不适合宏的场景:

  • 只是普通的值计算;
  • 只是想少写几行重复逻辑;
  • 用函数和高阶函数已经能解决的问题;
  • 会让调用者看不出实际发生了什么的“魔法语法”。

一句话概括:能用函数解决,就优先不要用宏。

小结

宏和元编程最重要的地方,并不是语法有多花,而是它们让 Julia 可以直接把代码当数据来处理。Exprquote、插值、esc@macroexpand 这些工具,需要一起理解才比较顺。至于实际写代码,能用函数解决的问题,还是尽量不要先上宏。