【GameMaker】滥用宏:创建语法扩展

原文地址

使用不良实践、谨慎的设计和创造力,宏可以成为在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,因为135 + 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的玩家的xy位置,可以使用以下代码:

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];

XY可以被视为对值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) 替换。

自定义语句

接下来,我们将从简单的关键字别名开始。本节将介绍三个自定义语句,并讲解衍生它们的不同技术。

忽略

第一个被创造出来的语句是 ignoreignore 语句是一个简单的结构,能够在运行时防止一段代码被执行。它的行为有点类似于注释,但允许语法高亮和 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) { } elsefor (;false;)。这是因为 whileuntilrepeatwith 语句都容易受到相同的“攻击”;即可以使用部分表达式,如 || true,来强迫条件评估为 true,因此不可达代码突然变得可访问。if (true) { } elsefor (;false;) 的好处在于,这两个候选者都没有向外部用户暴露利用该攻击的方式。具体来说,GML 中的 for 循环是唯一需要顶级分组 () 的结构,而 else 根本不包括可以利用的条件。这两种方法中的任何一种都非常适合 ignore 宏。最终,出于可读性考虑,下面的示例中使用了 if (true) { } else

#macro ignore if (true) { } else

ignore {
  show_message("ignore me");
}
// 在这里放置 `else` 将导致语法错误,正如预期的那样

就这样!一个完整的语法扩展,为语言添加了一种新的语句。

下一小节将介绍 defer 语句,以及如何利用 for 循环的一个巧妙特性来实现这一语言特性。

延迟

本篇文章将介绍的第二个语法扩展是 deferdefer 语句是一种在某些系统编程语言中常见的语句。它的功能是将一段代码的执行延迟到其作用域结束时。其主要用途是在资源超出作用域时清理这些资源。这种行为可以通过在 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 是必需的,以防止无限循环。这已经看起来非常类似于提议的 deferafter 关键字;代码片段 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 通常可以用一句话来描述:“如果 atrue,则 b 也为 true。”因此,如果 atrue,而 bfalse,则该表达式的值为 false;否则,表达式的值为 true

通常,a -> b 是一个基于布尔 !)和布尔 ||)的衍生操作:

a -> b == !a || b

然而,这种形式无法轻易地转化为宏,因为 a 出现在 !|| 之间。幸运的是,有一种方法可以绕过这个问题,这涉及到布尔 异或^^):术语 !a 在语义上等价于 a ^^ true。这是因为操作 b1 ^^ b2 仅在两个操作数 b1b2 不相等时返回 true。由于 b2 始终等于 true,因此这就产生了始终返回 a 的否定,即 true ^^ true == falsefalse ^^ 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) 中。这是必需的,因为三元运算符的优先级。

有趣的是,这个自定义组合运算符在功能上类似于逗号运算符。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值