使用不良实践、谨慎的设计和创造力,宏可以成为在GameMaker语言(GML)中引入新语法的强大功能。
前言
本文的目的并不是探讨使用宏作为语法扩展是否实用、有益,甚至是否是一个好主意。相反,本文旨在向那些对此感兴趣的人介绍这些想法。文中概述了创建此类语法扩展的基本方法。
注意:尽管宏通常按约定使用SCREAMING_SNAKE_CASE
风格,但本文中所有类似于关键词的宏定义将使用snake_case
。
宏的简要介绍
本文假设读者对宏有基本的了解,但对于那些想要快速入门的人来说:宏的作用是将某段代码替换为另一段代码的指令。宏在程序编译后不再存在,因为它们的任务在编译时就已经完成了。更具体地说,宏是一种编译指令或预处理指令,允许开发者将符号分配给(常用的)源代码。顾名思义,这些构造在编译时或编译之前执行其任务,因此在程序的构建过程完成后它们会消失。
在GML中,宏使用#macro
指令,功能类似于C语言中的宏。GML中最常见的宏应用是为常量提供别名。请看以下宏定义:
#macro MESSAGE "hello world"
符号MESSAGE
是宏标识符,"hello world"
是宏所代表的源代码片段。在编译时,所有出现的MESSAGE
都会被替换为"hello world"
。
以下小节将详细介绍在GML中可以利用的一些常见宏陷阱。
优先级问题
“优先级问题”这个术语借用自GNU C官方文档。我喜欢这个术语,因为它清晰地描述了问题所在!来看以下宏定义:
#macro PRICE 5 + 8
现在假设它在如下表达式中使用:
var tax = 0.2 * PRICE;
show_message("VAT: " + string(tax));
乍一看,tax
的预期值可能是2.6
,因为13
(5 + 8
)的20%
是2.6
。然而,事实并非如此。实际上,tax
的值最终是9
!原因在于宏PRICE
干扰了表达式的优先级。编译器会将表达式0.2 * PRICE
扩展为0.2 * 5 + 8
,而不是0.2 * (5 + 8)
。如果开发者没有意识到这一点,这种行为可能会导致难以诊断的错误。为了避免这个问题,应该将PRICE
宏的内容用括号括起来:
#macro PRICE (5 + 8)
再次运行这个程序时,会得到预期的结果2.6
。
那么这有什么用呢?这里有一些可以被利用的潜力。举例来说,假设一个玩家位置数组p
的形式为[x1, y1, x2, y2, ...]
。要索引这个数组并获取id为i
的玩家的x
和y
位置,可以使用以下代码:
var posX = p[i * 2];
var posY = p[i * 2 + 1];
这段代码很简洁,但宏可以帮助代码更清晰地表达自身:
#macro X 2
#macro Y 2 + 1
var posX = p[i * X];
var posY = p[i * Y];
宏X
和Y
可以被视为对值i
应用某种变换的“单位”。使用宏的好处与宏本身的用途一致:通过减少代码重复,可以降低源代码中出现人为错误的可能性。
尽管这个例子是经过精挑细选的,但它确实展示了这种行为的实际用途。
卫生问题
在宏(macros)中,“卫生”(Hygiene)是指宏与其定义之外的程序状态之间可能出现的奇怪交互。换句话说,非卫生的宏可以创建并访问它们作用域外的变量。请看下面的例子:
#macro ENTER_BUNNY var bunny = "/(.U x U.) <(yuh)"
ENTER_BUNNY;
show_message(bunny); // ???
如果 GML 中的宏是卫生的,那么这段代码会报错,提示变量 bunny
不存在。然而,编译器只会展开 ENTER_BUNNY
宏,世界一切恢复平衡:
var bunny = "/(.U x U.) <(yuh)";
show_message(bunny);
宏被设计成以这种方式“愚钝”地工作。它们不在乎语法是否有效,或者变量是否在作用域内,它们只执行自己的工作,然后消失。
非卫生的宏在创建有状态的宏定义时可以成为有用的工具。下面是一个例子,这组宏会返回不同的水果名称:
#macro FRUIT_BEGIN var fruits = ["pear", "grape", "apple", "mango"]
#macro FRUIT_OF_THE_DAY fruits[irandom(3)]
FRUIT_BEGIN;
show_message(FRUIT_OF_THE_DAY); // pear
show_message(FRUIT_OF_THE_DAY); // mango
show_message(FRUIT_OF_THE_DAY); // apple
show_message(FRUIT_OF_THE_DAY); // mango
关键字别名
使用宏可以创建语法扩展,其中比较简单的一类是为现有关键字创建别名。例如,假设一位开发者想用 elif
来替代所有 else if
的实例。这可以通过创建一个将同义词 elif
映射到短语 else if
的宏来实现;这样一来,开发者代码中的所有 elif
都会表现得像是写了 else if
:
#macro elif else if
var a = 1;
var b = 2;
if (a > b) {
show_message("yes");
} elif (a == b) {
show_message("no");
} else {
show_message("maybe (.O _ O.)");
}
这种语法扩展应用于关键字时并不是特别有用。然而,同样的方法也可以应用于内置常量和内置函数!如果开发者希望现有函数有一个简短的版本,他们可以利用宏来实现。例如,为 show_debug_message
创建一个较短的别名 log
:
#macro log show_debug_message
log("wow, cool");
虽然为长函数创建别名已经相当有用,但下一小节将介绍宏的一个更加实用的功能:函数重载。
重写内置函数
宏的一个鲜为人知的功能是能够用自定义实现重写内置函数。这一功能特别有用,例如可以重写 show_debug_message
,使其将信息写入日志文件,而不是 IDE 控制台窗口。将调试信息记录到文件(或留下一些“面包屑”)对于调试用户遇到的问题非常重要。这种行为可以通过创建一个宏来实现,其中要重写的函数是宏的名称:
#macro show_debug_message overrides_show_debug_message
function overrides_show_debug_message(str) {
var file = file_text_open_append("game.log");
file_text_write_string(file, str);
file_text_writeln(file);
file_text_close(file);
}
使用这个宏,所有 show_debug_message
的出现都会被替换为 overrides_show_debug_message
。这样,所有调试信息都会被写入名为 "game.log"
的外部文件。然而,需要注意的是!由于所有 show_debug_message
的出现现在都被替换为重写的函数,任何在该函数体内的 show_debug_message
实例都会导致无限循环(如果处理不当)。这一点并不显而易见,特别是当宏定义在单独的文件中时。因此,这是一种需要注意的陷阱。
同样的方法也可以应用于 GML 的其他部分,例如内置变量或常量。例如,字面量 c_red
的默认颜色非常难看,但可以通过宏重写为更好的色调:
#macro c_red make_colour_rgb(209, 88, 100)
如往常一样,c_red
将被代码片段 make_colour_rgb(209, 88, 100)
替换。
自定义语句
接下来,我们将从简单的关键字别名开始。本节将介绍三个自定义语句,并讲解衍生它们的不同技术。
忽略
第一个被创造出来的语句是 ignore
。ignore
语句是一个简单的结构,能够在运行时防止一段代码被执行。它的行为有点类似于注释,但允许语法高亮和 IDE 自动补全。下面的例子展示了 ignore
语句如何防止无限循环 while (true)
的发生:
show_message("this is shown");
ignore {
show_message("this is ignored");
while (true) {
// repeat forever
}
}
实现这一功能的常见方法是将 ignore
替换为 if (false)
,因此这也许应该是 ignore
的定义?在大多数情况下,这样的定义是正确的。然而,根据这个 ignore
的定义,以下代码片段是有效的:
#macro ignore if (false)
ignore {
show_message("ignore me");
} else {
get_funky();
}
ignore
上有 else
子句是否有意义,如果有,这又是什么意思呢?理想情况下,ignore
应该独立工作,而不允许附加的修饰符。
此时,我们需要更多的创造力。当然,if (false)
不是创建不可达代码的唯一方法。以下是其他示例:
if (true) { } else {
// ignore
}
while (false) {
// ignore
}
for (;false;) {
// ignore
}
repeat (0) {
// ignore
}
with (noone) {
// ignore
}
switch (0) {
case 0:
break;
default:
// ignore
break;
}
乍一看,这些选项似乎大多数都有用。然而,有两个选项完全优于其他选项:if (true) { } else
和 for (;false;)
。这是因为 while
、until
、repeat
和 with
语句都容易受到相同的“攻击”;即可以使用部分表达式,如 || true
,来强迫条件评估为 true
,因此不可达代码突然变得可访问。if (true) { } else
和 for (;false;)
的好处在于,这两个候选者都没有向外部用户暴露利用该攻击的方式。具体来说,GML 中的 for
循环是唯一需要顶级分组 ()
的结构,而 else
根本不包括可以利用的条件。这两种方法中的任何一种都非常适合 ignore
宏。最终,出于可读性考虑,下面的示例中使用了 if (true) { } else
:
#macro ignore if (true) { } else
ignore {
show_message("ignore me");
}
// 在这里放置 `else` 将导致语法错误,正如预期的那样
就这样!一个完整的语法扩展,为语言添加了一种新的语句。
下一小节将介绍 defer
语句,以及如何利用 for
循环的一个巧妙特性来实现这一语言特性。
延迟
本篇文章将介绍的第二个语法扩展是 defer
。defer
语句是一种在某些系统编程语言中常见的语句。它的功能是将一段代码的执行延迟到其作用域结束时。其主要用途是在资源超出作用域时清理这些资源。这种行为可以通过在 GML 中包含额外的 after
子句来模拟:
defer {
show_message("me second!");
} after {
show_message("me first!");
}
实现这种控制流有一种非常特定的方法,它涉及到 for
循环的一个鲜为人知的特性。你知道 for
循环中的第三个表达式可以是一个代码块吗?
for (var i = 0, j = 10; i < j; { i += 2; j += 1 }) {
show_message(string(i) + " < " + string(j));
}
请注意,第三个表达式的位置有一个增量语句 { i += 2; j += 1 }
。这可能看起来很奇怪,但在 GML 中这是有效的语法。这一特性之所以强大,是因为 for
循环的主体在执行第三个表达式之前就会被执行:
for (;; {
show_message("me second!");
break;
}) {
show_message("me first!");
}
break
是必需的,以防止无限循环。这已经看起来非常类似于提议的 defer
和 after
关键字;代码片段 for (;; {
可以表示 defer
关键字,而 ; break; })
则可以表示 after
关键字。它们共同形成完整的语句:
#macro defer for (;; {
#macro after ; break; })
var file = file_text_open_read("save.txt");
var content = "";
defer {
// 在读取完成后关闭文件
file_text_close(file);
} after {
// 读取文件的内容
while (!file_text_eof(file)) {
content += file_text_readln(file);
}
}
这个例子展示了如何在实际情况中使用 defer
语句:打开一个文本文件,读取其内容,然后关闭它。这是有益的,因为处理文件关闭的代码位于打开文件的类似代码附近。此外,如果读取文件内容的代码更复杂,例如跨越数百行,那么由人为错误引起的bug肯定会发生。
在下一小节中,将简要讨论 print
语句,包括如何使用类似的技术将值提取到宏的主体中。
打印
最后要介绍的语法扩展是 print
语句。乍一听,这可能显得有些平淡,但它实际上是三者中最具技术性的宏。print
语句基于前两小节中展示的方法,因此这里将仅做简要说明。秘密地,print
语句将要打印的值分配给一个隐藏变量。然后,该变量的值会在执行 break
退出循环之前输出到控制台:
#macro print \
for (var printValue;; { \
show_debug_message(printValue); \
break; \
}) printValue =
print "hello world";
print 7.2 * 3 + 9;
print { x : 90, y : 36 };
print [true, false, false, undefined];
这个宏的实现使用了 \
将定义延续到新的一行。因此,所谓的“多行宏”可以通过将长宏拆分为多行来提高可读性。
自定义运算符
到目前为止,宏仅用于为语句创建语法扩展。然而,自定义运算符也可以通过相同的方式定义!本节将介绍两个可以使用宏定义的自定义二元运算符。
布尔蕴涵
本节要强调的第一个自定义运算符是较少被知晓的逻辑蕴涵(->
)。操作 a -> b
通常可以用一句话来描述:“如果 a
为 true
,则 b
也为 true
。”因此,如果 a
为 true
,而 b
为 false
,则该表达式的值为 false
;否则,表达式的值为 true
。
通常,a -> b
是一个基于布尔 非(!
)和布尔 或(||
)的衍生操作:
a -> b == !a || b
然而,这种形式无法轻易地转化为宏,因为 a
出现在 !
和 ||
之间。幸运的是,有一种方法可以绕过这个问题,这涉及到布尔 异或(^^
):术语 !a
在语义上等价于 a ^^ true
。这是因为操作 b1 ^^ b2
仅在两个操作数 b1
和 b2
不相等时返回 true
。由于 b2
始终等于 true
,因此这就产生了始终返回 a
的否定,即 true ^^ true == false
和 false ^^ true == true
。这一知识可以用于基于 ^^
和 ||
定义一个自定义二元运算符 implies
:
#macro implies ^^ true ||
var a = true;
var b = false;
var aimplya = a implies a; // true -> true = true
var aimplyb = a implies b; // true -> false = false
var bimplyb = b implies b; // false -> false = true
var bimplya = b implies a; // false -> true = true
同样的思路也可以应用于按位运算,通过使用 ^
和 |
:
#macro bimplies ^ (~0) |
var a = 6; // 0110
var b = 5; // 0101
var abimplya = a bimplies a; // 0110 .-> 0110 = 1111
var abimplyb = a bimplies b; // 0110 .-> 0101 = 1101
var bbimplyb = b bimplies b; // 0101 .-> 0101 = 1111
var bbimplya = b bimplies a; // 0101 .-> 0110 = 1110
由于在按位布尔代数中不存在任何 true
的变体,因此它被定义为 0
的反码(~
)。这产生了一个可以用于位掩码的类似蕴涵运算符。
在下一小节中,将讨论一个有用的运算符 seq
,它在组合有副作用的表达式时非常有用。
顺序组合
第二个也是最后一个要展示的运算符是顺序组合运算符。实际上,GML 已经有这样一个运算符!它被称为语句终止符 ;
。然而,顾名思义,;
仅用于语句。这是一个相当大的缺陷,因为这意味着 for
循环(通常)只允许一个增量器。如果存在一个用于表达式的顺序组合运算符 seq
,那么就可以构造带有多个增量器的 for
循环:
for (
var i = 0,
key = ds_map_find_first(map);
key != undefined;
key = (i++ seq ds_map_find_next(map, key))
) {
show_message("the key at index " + string(i) + " is " + string(key));
}
幸运的是,GML 具备足够的功能,使得在表达式上实现 seq
运算符成为可能:
#macro seq == NaN) && false ? undefined : (
简单来说,seq
被定义为一个三元表达式,其中真分支永远不会被执行。因此,它的条件必须始终评估为 false
。条件末尾的 && false
确保了这一点。此外,宏开头和结尾的括号 )
和 (
确保用户将二元操作包装在分组 (a seq b)
中。这是必需的,因为三元运算符的优先级。
有趣的是,这个自定义组合运算符在功能上类似于逗号运算符。