元编程简介

作者:Ariel Ortiz

元程序是生成其他程序或程序部分的程序。因此,元编程意味着编写元程序。许多有用的元程序可用于 Linux;最常见的包括编译器(GCC 或 FORTRAN 77)、解释器(Perl 或 Ruby)、解析器生成器(Bison)、汇编器(AS 或 NASM)和预处理器(CPP 或 M4)。通常,您使用元程序来消除或减少繁琐或容易出错的编程任务。因此,例如,您可以使用 C 等高级语言,而不是手动编写机器代码程序,然后让 C 编译器将代码翻译成等效的低级机器指令。

元编程乍一看似乎是一个高级主题,只适合编程语言大师,但一旦您知道如何使用合适的工具,它实际上并没有那么困难。

源代码生成

为了展示一个非常简单的元编程示例,让我们假设以下完全虚构的情况。

Erika 是一位非常聪明的大一计算机科学本科生。她已经掌握了几种编程语言,包括 C 和 Ruby。在她的编程入门课程中,课程讲师 Gomez 教授发现她在笔记本电脑上聊天。作为惩罚,他要求 Erika 编写一个 C 程序,打印以下 1000 行文本

1. I must not chat in class.
2. I must not chat in class.
...
999. I must not chat in class.
1000. I must not chat in class.

另一个强制施加的限制是程序不能使用任何类型的循环或 goto 指令。它应该只包含一个大的 main 函数,其中包含 1000 个 printf 指令——类似这样

#include <stdio.h>
int main(void) {
    printf("1. I must not chat in class.\n");
    printf("2. I must not chat in class.\n");

    /* 996 printf instructions omitted. */

    printf("999. I must not chat in class.\n");
    printf("1000. I must not chat in class.\n");
    return 0;
}

Gomez 教授并没有太天真,所以他基本上期望 Erika 将 printf 指令写一次,复制到剪贴板,粘贴 999 次,然后手动更改数字。他期望即使是这种繁琐而重复的工作也足以给她一个教训。但是,Erika 立即看到了一个简单的出路——元编程。与其手动编写这个程序,为什么不编写另一个程序来自动为她编写这个程序呢?所以,她编写了以下 Ruby 脚本

File.open('punishment.c', 'w') do |output|
  output.puts '#include <stdio.h>'
  output.puts 'int main(void) {'
  1.upto(1000) do |i|
    output.puts "    printf(\"#{i}. " +
      "I must not chat in class.\\n\");"
  end
  output.puts '    return 0;'
  output.puts '}'
end

这段代码创建了一个名为 punishment.c 的文件,其中包含预期的 1000 多行 C 源代码。

虽然这个例子可能看起来有点虚构,但它说明了编写一个程序来生成另一个程序的源代码是多么容易。这种技术可以用于更实际的设置中。假设您有一个 C 程序需要包含 PNG 图像,但由于某种原因,部署平台只能接受一个文件,即可执行文件。因此,构成 PNG 文件数据的数据必须集成到程序代码本身中。为了实现这一点,我们可以预先读取 PNG 文件并生成 C 源代码文本,用于数组声明,并使用相应的数据作为文字值进行初始化。这个 Ruby 脚本正是这样做的

INPUT_FILE_NAME = 'ljlogo.png'
OUTPUT_FILE_NAME = 'ljlogo.h'
DATA_VARIABLE_NAME = 'ljlogo'

File.open(INPUT_FILE_NAME, 'r') do |input|
  File.open(OUTPUT_FILE_NAME, 'w') do |output|
    output.print "unsigned char #{DATA_VARIABLE_NAME}[] = {"
    data = input.read.unpack('C*')
    data.length.times do |i|
      if i % 8 == 0
        output.print "\n    "
      end
      output.print '0x%02X' % data[i]
      output.print ', ' if i < data.length - 1
    end
    output.puts "\n};"
  end
end

此脚本读取名为 ljlogo.png 的文件,并创建一个名为 ljlogo.h 的新输出文件。首先,它写入变量 ljlogo 的声明,作为一个无符号字符数组。接下来,它一次性读取整个输入文件,并将每个输入字符解包为无符号字节。然后,它将每个输入字节作为两位十六进制数字写入,每行八个元素一组。正如预期的那样,除了最后一个元素外,各个元素都以逗号结尾。最后,脚本写入右大括号和分号。这是一个可能的输出文件样本

unsigned char ljlogo[] = {
    0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
    0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,

    /* A few hundred lines omitted. */

    0x0B, 0x13, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
    0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82
};

以下 C 程序演示了如何将生成的代码用作普通的 C 头文件。重要的是要注意,当程序本身加载时,PNG 文件数据将存储在内存中

#include <stdio.h>
#include "ljlogo.h"

/* Prints the contents of the array ljlogo as
   hexadecimal byte values. */
int main(void) {
    int i;
    for (i = 0; i < sizeof(ljlogo); i++) {
        printf("%X ", ljlogo[i]);
    }
    return 0;
}

您还可以有一个程序,既可以生成源代码,又可以在现场执行它。某些语言具有称为 eval 的工具,它允许您在运行时翻译和执行包含在字符字符串中的一段源代码。此功能通常在解释型语言中可用,例如 Lisp、Perl、Ruby、Python 和 JavaScript。在这段 Ruby 代码中

x = 3
s = 'x + 1'
puts eval(s)

字符串“x + 1”在代码运行时被翻译和执行,打印结果为 4。请注意,即使绑定到变量 x 的值在运行时评估期间也可用。

以下 Ruby 代码演示了一种人为的方法来查找 1 到 100 之间所有整数的和。我们没有使用普通的循环或迭代方法,而是生成一个包含表达式“1+2+3+...+99+100”的大字符串,然后继续评估它

puts eval((1..100).to_a.join('+'))

eval 函数应谨慎使用。如果用作 eval 参数的字符串来自不受信任的源(例如,来自用户输入),则它可能具有潜在的危险(想象一下,如果要评估的字符串包含 Ruby 表达式rm -r *),可能会发生什么情况。在许多情况下,有 eval 的替代方案,它们更灵活、安全性更高,并且不需要在运行时解析代码的速度损失。

Quine(自复制程序)

Quine 是一种特殊的源代码生成器。《行话文件》将 Quine 定义为“一个程序,它生成自身源代码的副本作为其完整输出”。如果您认为这本身没有任何实际价值,您可能是对的,但作为一种脑筋急转弯,它可能会令人大开眼界。这是 Ryan Davis 编写的 Quine,它是 Ruby 语言中最短的 Quine 之一

f="f=%p;puts f%%f";puts f%f

运行此程序,您将得到它作为输出。您甚至可以从 shell 提示符尝试这样的操作

ruby -e 'f="f=%p;puts f%%f";puts f%f' | ruby

在这里,我们使用命令行中的 -e 选项来指定一行要执行的 Ruby 源代码,然后我们使用管道将其输出发送到 Ruby 解释器的另一个实例。输出再次是相同的程序源代码。

在运行时修改程序

动态语言(如 Ruby)允许您在运行时轻松修改程序的不同部分,而无需像我们之前那样显式生成源代码。Ruby 的核心 API 和框架(如 Ruby on Rails)利用此工具来自动化常见的编程任务。例如,在类定义中,您可以使用 attr_accessor 方法为给定的属性名称自动生成读/写访问方法。因此,以下代码

class Person
  attr_accessor :name
end

等效于此更冗长的代码

class Person
  def name
    @name
  end
  def name=(new_name)
    @name = new_name
  end
end

之前的代码有一个小缺点:只有在您首次设置其值时,才会真正创建相应的实例变量 @name。这意味着如果您在写入 name 属性之前恰好读取了它,您将获得 nil 值。如果您不小心,这可能会在您的程序中引入一些细微的错误。避免此问题的最简单方法是在 Person#initialize 方法中将 @name 实例变量设置为合理的值。由于这是一个非常常见的场景,如果除了读/写访问器之外,还可以自动生成此方法,岂不是更好?让我们定义一个 attr_initialize 方法,它将使用 Ruby 的元编程工具来完成此操作。

首先,让我们简要介绍一下执行我们期望的元编程魔法的两个关键方法

cls.define_method(name) { body }

这会将一个新的实例方法添加到接收类。它将方法的名称(作为符号或字符串)及其主体(作为代码块)作为输入

obj.instance_variable_set(name, value)

上面的代码将实例变量绑定到指定的值。实例变量的名称应为符号或字符串,并且还应包括 @ 前缀。

现在,我们准备将 attr_initialize 类方法定义为 Object 类的扩展,以便任何其他类都可以使用它

require 'generator'

class Object
  def Object.attr_initialize(*attrs)
    define_method(:initialize) do |*args|
      if attrs.length != args.length
        raise ArgumentError,
          "wrong number of arguments " +
          "(#{args.length} for #{attrs.length})"
      end
      SyncEnumerator.new(attrs, args).each do
        |attr, arg|
        instance_variable_set("@#{attr}", arg)
      end
    end
    attr_accessor *attrs
  end
end

attr_initialize 方法将可变数量的属性名称 (attrs) 作为输入。每个属性名称在动态创建的 initialize 方法参数列表 (args) 中都保留了相同的位置,以便设置其初始值。我们通过检查接收到的参数数量是否与我们最初指定的属性数量相同来启动新方法的代码。如果不是,我们会引发一个错误,并附带描述性消息。之后,我们使用 SyncEnumerator 对象(来自 generator 库)同时迭代声明的属性列表 (attrs) 和实际的参数列表 (args),以便使用 instance_variable_set 方法执行一对一的属性-参数绑定。最后,我们委托给 attr_accessor 方法,以便为所有声明的属性创建读/写访问方法。

以下是我们如何使用 attr_initialize 方法

class Student
  attr_initialize :name, :id, :address
end

s = Student.new('Erika', 123, '13 Fake St')
s.address = '13 Wrong Rd'
puts s.name, s.id, s.address

预期的输出将是

Erika
123
13 Wrong Rd
结论

一旦您熟悉了这些技术,元编程就不像最初听起来那么复杂。元编程允许您自动化容易出错或重复的编程任务。您可以使用它来预先生成数据表,自动生成无法抽象为函数的样板代码,甚至可以测试您编写自复制代码的独创性。

“我宁愿编写编写程序的程序,也不愿编写程序。”——Richard Sites

资源

《行话文件》:www.catb.org/esr/jargon

Ruby Cookbook,作者 Lucas Carlson 和 Leonard Richardson,由 O'Reilly Media 于 2006 年出版。本书第 10 章包含 16 个关于使用 Ruby 进行反射和元编程的秘诀。强烈推荐。

Quine 页面:www.nyx.net/~gthompso/quine.htm。此网页包含多种不同编程语言的 Quine。它甚至还有可以在多种语言中使用的 Quine。

Ariel Ortiz 是墨西哥蒙特雷科技大学墨西哥州校区计算机科学系的教员。他从事计算机编程教学已近二十年。他不确定他最喜欢的编程语言是什么,但他认为要么是 Scheme、Python 或 Ruby。可以通过 ariel.ortiz@itesm.mx 与他联系。

加载 Disqus 评论