Julia 学习笔记——9. 宏与元编程
宏和元编程是 Julia 里非常有代表性的部分。它们既是语言能力的一部分,也是很多 Julia 代码看起来“很不一样”的原因。
不过宏虽然重要,但绝对不是日常写 Julia 代码的起点。大多数普通逻辑都应该优先写成函数,只有当函数做不到,或者语法层面介入明显更自然时,才值得考虑宏。
概述
Julia 的宏并不是 C 语言那种简单文本替换,而是基于语法树(AST)的代码变换工具。
可以先这样理解:
- 函数操作的是“值”;
- 宏操作的是“代码表达式”;
- 宏在代码真正运行前先展开;
- 展开结果再交给 Julia 去编译和执行。
因此,宏更像是“改写代码的工具”,而不是普通的可调用逻辑。
宏的基本形式
最简单的宏定义如下:
1 | macro sayhello() |
调用时使用 @:
1 |
输出:
1 | Hello, World! |
这里最值得注意的是:
- 宏调用通常不需要括号;
- 宏返回的不是最终结果,而是一段表达式;
- 这段表达式会继续被 Julia 执行。
当然,带括号的调用也允许:
1 | () |
宏和函数的区别
这是最重要的一节。
先看函数:
1 | function show_value(x) |
这里函数拿到的已经是表达式 1 + 2 计算后的结果 3。
再看宏:
1 | macro show_expr(x) |
宏拿到的是表达式本身,而不是它的值。
这一点决定了宏能做很多函数做不到的事,例如:
- 获取用户写下来的原始表达式;
- 自动生成重复代码;
- 在语法层面插入日志、计时、边界消除等逻辑;
- 构造 DSL(领域专用语法)。
但也意味着宏更复杂、更容易把代码变得难读。
表达式 Expr
Julia 把代码本身表示为表达式对象 Expr。
例如
1 | ex = :(1 + 2) |
可以查看其结构:
1 | dump(ex) |
一般会看到:
head表示表达式类型;args表示表达式的组成部分。
例如 1 + 2 这种表达式,本质上大致可以理解为:
1 | Expr(:call, :+, 1, 2) |
这也是为什么 Julia 的元编程能力很自然,因为代码本身就是语言内可操作的数据结构。
引号表达式
:(...)
最常见的构造表达式方式是:
1 | :(a + b) |
这不会立即计算 a + b,而是构造出对应的表达式。
quote ... end
多行表达式可以用:
1 | quote |
这和 :(...) 的作用类似,只是更适合多行结构。
插值 $
在构造表达式时,可以使用 $ 把已有变量或表达式插进去:
1 | name = :x |
这在写宏时非常常见。
一个简单的宏例子
下面做一个非常经典的“打印表达式和值”的宏:
1 | macro myshow(ex) |
使用:
1 | x = 10 |
输出大致类似:
1 | x + 1 = 11 |
这个例子里有几个关键点:
string(ex)把原始表达式变成字符串;esc(ex)把用户传入的表达式放回调用者作用域求值;quote ... end用来返回一段新的代码。
esc
esc 是 Julia 宏里最需要重点理解的关键字之一。
如果不使用 esc,宏内部引用到的变量名可能会在宏自己的作用域中解析,而不是在调用者作用域中解析。
例如写宏时:
1 | macro bad(ex) |
在一些简单场景下看起来能工作,但一旦宏里生成的代码涉及变量绑定、局部变量、调用现场变量,就很容易因为卫生问题(hygiene)出现意料之外的名字解析。
通常的经验是:
- 用户传进来的表达式,往往应该
esc(...); - 宏自己内部生成的辅助变量,通常不应该
esc。
如果这一点没处理好,宏会非常容易出问题。
宏展开
Julia 提供了非常好用的调试工具来查看宏到底展开成了什么。
最常用的是:
1 | 1 + 2 |
或者
1 | macroexpand(Main, :( 1 + 2)) |
这在学习宏和调试宏时几乎是必备工具。
因为很多时候“宏为什么行为怪异”,答案都直接写在展开后的代码里。
常见内置宏
@show
1 | x = 3 |
它会同时打印表达式和值。
@time
1 | sum(rand(10^6)) |
可以快速查看运行时间和分配情况,但第一次运行往往包含编译成本。
@views
1 | A[:, 1] |
可以把一段代码里的切片尽量改成视图。
@inbounds
1 | for i in eachindex(a) |
表示跳过边界检查。这个宏和性能优化关系很大,但使用前必须确保索引绝对安全。
@simd
用于提示编译器对循环做 SIMD 优化尝试,不过它不是“加上就一定更快”的开关。
生成表达式与 eval
有了表达式之后,还可以用 eval 去执行它:
1 | ex = :(1 + 2) |
这让 Julia 具备非常强的动态生成代码能力。
不过需要注意:
eval发生在全局作用域;- 在函数内部依赖
eval往往不是一个好主意; - 绝大多数时候,如果只是普通逻辑分发,应该优先考虑函数、闭包、字典映射、分派机制,而不是
eval。
生成函数 @generated
Julia 还有一个很特别的机制:生成函数(generated function)。
形式大致如下:
1 | function f(x) |
它和宏很像,但又不是宏:
- 宏是在语法层面展开;
- 生成函数是在已知参数类型后生成对应方法体;
- 它更接近“类型驱动代码生成”。
这部分确实很强,但也更复杂、更容易写出晦涩代码。对于一般笔记整理,知道它的存在和大致定位就够了,不建议一开始就重度使用。
什么时候该用宏
宏适合的典型场景:
- 需要获取用户写下来的表达式本身;
- 需要在语法层面插入代码;
- 需要构造简洁的 DSL;
- 需要像
@time、@show这样做“包裹一段代码”的工具。
不适合宏的场景:
- 只是普通的值计算;
- 只是想少写几行重复逻辑;
- 用函数和高阶函数已经能解决的问题;
- 会让调用者看不出实际发生了什么的“魔法语法”。
一句话概括:能用函数解决,就优先不要用宏。
小结
宏和元编程最重要的地方,并不是语法有多花,而是它们让 Julia 可以直接把代码当数据来处理。Expr、quote、插值、esc 和 @macroexpand 这些工具,需要一起理解才比较顺。至于实际写代码,能用函数解决的问题,还是尽量不要先上宏。
