元编程:从 C 预处理到 Elixir 宏
C/C++
红宝石
灵药❤️
结论
开发者对元编程可谓又爱又恨。一方面,它是构建可重用代码的强大工具;另一方面,它也可能很快变得难以理解和维护。
我喜欢把它比作盐。很多时候它都很有用,但如果用量过多,就会做出不好吃的菜。
此外,大剂量服用其中任何一种都可能导致血压升高。😅
然而,元编程自诞生之初以来已经取得了长足的进步。虽然我仍然尽量避免过度使用它,但它变得越来越有用,也越来越易于使用。让我们来看看它是如何演变的。
C/C++
如果我们回到几十年前,回到编程语言更接近底层硬件的时代,C/C++ 预处理器是我们进行类似元编程的少数选择之一。
这个预处理器正如其名:它是一个解析器,能够遍历 C 代码,处理特定的定义(例如关键字 ` and` 和#define`or` #if),并将最终版本的 C 代码输出给编译器。这个最终版本会根据某些标准而改变。它看起来大概是这样的:
#define FOO 1
#if FOO == 1
#define MSG "Hello, World"
#else
#define MSG "Goodbye, World"
#endif
#include <stdio.h>
int main() {
printf(MSG);
end
这个程序会"Hello, World"一直输出“”。正如你可能猜到的,将 FOO 的定义改为 0,然后重新编译程序,它就会输出“” "Goodbye, World"。
这些预处理器指令通常用于创建针对特定平台或架构的代码。例如,您可以为编译到 Windows 系统和 Linux 系统的程序设置不同的行为。生成的两个二进制文件将仅包含与特定平台相关的代码,因此无需执行运行时检查。这通常可以显著节省存储空间并提升运行时性能。
然而,如果你有任何 C 语言经验,你就会知道它本身就很危险。如果在此基础上添加大量的预处理行为,它很快就会变得难以管理。因此,大多数情况下,不建议将其用于大型配置。
红宝石
随着技术的进步和更高级脚本语言的出现,也出现了创建更复杂编程风格的可能性。尤其是在 Ruby 中,元编程被证明是一项强大但又令人望而生畏的特性。
Ruby 的工作原理基于这样的理念:代码只不过是一串文本,由 Ruby 环境解释和执行。
由于 Ruby 是运行时解释型语言,因此无需预先编译整个代码库。Ruby 允许您动态地为类定义实例方法。
此外,由于 Ruby 类和实例的内部构造方式,您甚至可以为单个实例而不是整个类定义方法!
PS:更多关于 Ruby 类的信息请点击此处阅读。
class Foo
def hello1
puts "Hello from a regular method"
end
[:hello2, :hello3].each do |f|
define_method f do
puts "Hello from a dynamically-defined #{f} method"
end
end
end
foo = Foo.new
foo.define_singleton_method(:hello4) { puts "Hello only from this instance of Foo" }
foo.hello1
foo.hello2
foo.hello3
foo.hello4
Ruby 在编辑现有代码方面也相当宽松,即使是来自标准库的代码也不例外。以下是有效的 Ruby 代码:
array = [1, 2, 3]
# will print out 3
puts array.size
class Array
def size
"Hello"
end
end
# will now print out "Hello"
puts array.size
千万别这么做!这很可能会破坏你的程序,而且总体来说也是一种不好的做法。
最后但同样重要的是,Ruby 提供了一些强大的方法来处理意外的函数调用,例如method_missing回调函数:
array = [1, 2, 3]
class Array
def method_missing(method, *args)
puts "#{method} method not found"
if method == :sise then
puts "Did you intend to type size instead?"
end
end
end
puts array.sise
总的来说,这些能力在我第一次接触它们时,对我来说意义非凡。它们让我能够以一种全新的方式思考我的代码库,并在这一过程中对其进行改进。
不过,也存在一些问题。俗话说得好:能力越大,责任越大。
一些 Ruby 库滥用了这些元编程机制,创建了自己的领域特定语言。长此以往,这种过度使用会导致与 C++ 时代类似的问题:代码库难以维护和理解。
在我看来,Elixir 这次又朝着正确的方向迈出了一步……
灵药❤️
Elixir 将元编程以更强大的方式内置于语言核心之中。Ruby 允许你动态定义方法,甚至可以生成字符串并将其作为代码执行(这种老eval方法我们都讨厌),而 Elixir 则允许你直接修改抽象语法树 (AST)。
这是通过quote关键词实现的:
iex> expr = quote do
"Hello, " <> "World"
end
尝试运行上面的代码,你会发现字符串连接操作并没有直接执行。最终得到的不是最终的字符串,而是一个描述代码的抽象语法树(AST)表达式:
{:<>, [context: Elixir, import: Kernel], ["Hello, ", "World"]}
熟悉波兰表示法的人很快就会发现,这与上面的字符串连接代码等效。因此,通过引用一段代码,你可以得到该代码的抽象语法树(AST)描述,然后可以在代码库的其他部分中使用它。
然后,你就可以像分析数据结构(它实际上就是……抽象语法树)一样分析你的代码,并执行相应的操作来转换它:
我们稍微修改一下:
iex> expr = quote do
"Hello, " <> name
end
现在我们的表达式使用了一个动态名称。但是,这个名称是从哪里来的呢?
我们没有在任何地方定义这个变量,但它的语法仍然是正确的:
{:<>, [context: Elixir, import: Kernel], ["Hello, ", {:name, [], Elixir}]}
但是,它将无法执行,我们可以使用以下命令进行测试Code.eval_quoted/3:
iex> Code.eval_quoted(expr)
** (CompileError) nofile:1: undefined function name/0
(elixir) lib/code.ex:590: Code.eval_quoted/3
test.ex:5: (file)
现在我们来创建第二个 AST 定义:
definition = quote do
name = "Miguel"
end
第二个表达式定义定义了一个名为 的变量name。但是,请记住,我们并没有定义任何值,只是为该操作创建了抽象语法树 (AST)。
我们可以将这两个表达式合并成一个表达式:
final_code = quote do
unquote(definition)
unquote(expr)
end
这样做的结果和我们输入以下内容的结果是一样的:
name = "Miguel"
"Hello, " <> name
但是请注意,我们始终没有放弃 Elixir 的语法和规则。我们编写的 Elixir 代码本身就是 Elixir 代码!
Elixir 核心内部大量使用了这种机制。每当你定义一个函数或一个简单的 if 语句时,你实际上都在执行宏,这些宏会根据宏的类型修改代码的抽象语法树 (AST),使你的代码能够适应这些类型。说到这里……
Elixir 的宏
Elixir 的许多特性都是用宏编写的。许多常用的运算符都可以用宏重写。例如,我们来unless定义一下运算符(它已经存在于语言的核心中):
defmodule Foo do
defmacro custom_unless(condition, do: do_clause, else: else_clause) do quote do
if !unquote(condition) do
unquote(do_clause)
else
unquote(else_clause)
end
end
end
defmacro custom_unless(condition, do: do_clause) do
quote do
Foo.custom_unless(unquote(condition), do: unquote(do_clause), else: nil)
end
end
end
defmodule Bar do
require Foo
Foo.custom_unless true, do: IO.puts("not true"), else: IO.puts("true")
end
我们的custom_unless宏接收一个布尔值作为参数。在宏内部,我们会检查条件的相反情况(即,根据该条件运行给定的代码抽象语法树,并将结果取反)。然后,根据结果,执行针对 `is`do或 `is`子句的 `is` 抽象语法树。else
然而,Elixir 的乐趣在于,即使是像if子句这样的基本结构也经常使用宏来构建,因此我们可以更好地将宏嵌入到语言中。换句话说,定义宏之后,以下代码也能正常运行:
defmodule Bar
# importing instead of requiring allows us to call the macro directly,
# without the Foo. prefix
import Foo
custom_unless true do
IO.puts("not true")
else
IO.puts("true")
end
end
之所以有效,是因为 Elixir 中多行 if/else 代码块的解释本质上只是以下语句的语法糖:
if condition do: something, else: something_else
结论
希望本文能帮助读者了解宏在过去是如何演变的,特别是对于那些可能不了解 Elixir 语言全部功能及其历史的 Elixir 开发人员而言。
如果你想定期收到⚗️灵药炼金术的内容,请订阅,即可将下一集炼金术直接发送到你的邮箱。
本文由特约作者Miguel Palhas撰写。Miguel 是@subvisual 的专业过度设计工程师,也是@rubyconfpt和@MirrorConf的组织者。
文章来源:https://dev.to/appsignal/metaprogramming-from-c-preprocessing-to-elixir-macros-6a1