我最早写 PHP 扩展的那段时间,最怕的不是内存泄漏,是函数参数解析。参数拿错、类型没判断、引用计数没处理好,扩展直接 SIGSEGV,连错误日志都来不及打。后来我把所有函数签名都迁移到 ZEND_PARSE_PARAMETERS 这套宏方案,问题少了一大半。这篇专门聊 php 方案 ZEND_PARSE_PARAMETERS 高级用法,面向已经能写出基本扩展、想进一步把参数解析用活的开发者。内容会覆盖可选参数、可空参数、数组与对象解析、回调调用、变参收集,以及我实际踩过的崩溃和性能坑。
1. 参数解析这件事:ZPP 如何取代老式写法
1.1 老式 zend_get_parameters 的三个软肋
十年前写扩展,大家基本都用 zend_get_parameters_ex 配合 Z_TYPE_P 做类型判断。伪代码大概长这样:
PHP_FUNCTION(demo_get_user) { zval *id_zv, *options_zv; zend_long id; zend_string *name; if (zend_get_parameters_ex(2, &id_zv, &options_zv) == FAILURE) { RETURN_FALSE; } if (Z_TYPE_P(id_zv) != IS_LONG) { php_error_docref(NULL, E_WARNING, "id 必须是整数"); RETURN_FALSE; } id = Z_LVAL_P(id_zv); // 继续判断 options_zv 是数组还是对象…… }这套写法有三个软肋。第一,类型检查全靠手写,一个函数可能有五六个参数,每个都要 Z_TYPE_P 判断、报错、RETURN_FALSE,代码很长且有大量重复。第二,可变参数处理非常麻烦,你得自己判断 ZEND_NUM_ARGS() 和 argc 的差值,稍不留意就越界。第三,错误消息格式不统一,用户拿到的提示要么是“参数错误”这样没营养的废话,要么直接没有任何提示。
我当时第一次把这个扩展发给同事测试,同事传了个字符串 ID,扩展直接段错误。问题就出在 zend_get_parameters_ex 拿到的 zval 并不保证类型,而且它不会做任何转换,你手写判断漏了一条路径,后面用 Z_LVAL_P 就是非法内存访问。
1.2 ZPP 宏的展开逻辑与正确写法
后来迁到 ZEND_PARSE_PARAMETERS 方案,代码密度高了一个量级。先看一个标准函数骨架:
PHP_FUNCTION(demo_create_user) { zend_string *name; zend_long age; ZEND_PARSE_PARAMETERS_START(1, 2) Z_PARAM_STR(name) Z_PARAM_OPTIONAL Z_PARAM_LONG(age) ZEND_PARSE_PARAMETERS_END(); // 这里直接用 name 和 age RETURN_TRUE; }这套宏总共就三行框架,ZEND_PARSE_PARAMETERS_START 接收两个数字,第一个是必选参数个数,第二个是参数总数上限。然后是 Z_PARAM_xxx 列表,最后 END 收尾。它内部会生成本地变量、错误状态变量,然后统一调用 zend_parse_parameters_ex 完成解析。相比手写,等于把类型检查、数量检查、错误上报全部标准化了。
需要注意一个细节:Z_PARAM_OPTIONAL 之后的参数,如果调用时缺省,对应变量不会被赋值。这是新手最容易踩的坑,后面专门讲。
1.3 宏模式与传统 format-string 模式的等价关系
老版本扩展经常用 zend_parse_parameters(ZEND_NUM_ARGS(), "sl", &name, &age) 这种格式化字符串写法。ZPP 宏模式和它是等价的,但更贴近 C 语言的类型安全,参数和类型是绑定的,编译器在编译期就能发现变量类型不匹配,而格式化字符串要运行时才能暴露。
| 写法 | 类型检查方式 | 编译期检查 | 错误消息 | 推荐度 |
|---|---|---|---|---|
| zend_get_parameters_ex + 手写判断 | 人工逐字段判断 | 无 | 不统一 | 不推荐 |
| zend_parse_parameters("sl", ...) | 运行期格式化解析 | 无 | 统一但固定 | 一般 |
| ZEND_PARSE_PARAMETERS 宏 | 宏展开+运行期解析 | 有部分 | 可自定义 | 推荐 |
用宏方案还有一个潜在收益:PHP 版本升级时,ZPP 宏内部行为跟着内核演进,比如新版本增强了对 Union Type 的支持,你的扩展代码不用改,但拿到新能力。这也是我建议你从今天开始所有新扩展都走 ZPP 的原因。
2. 可选与可空:Z_PARAM_OPTIONAL 和 _OR_NULL 最容易踩的边界
2.1 可选参数的真实行为:缺省时不会赋值
先说 Z_PARAM_OPTIONAL 最重要的行为。看这个例子:
PHP_FUNCTION(demo_page_list) { zend_long page = 1; zend_long page_size = 20; zend_string *keyword = NULL; ZEND_PARSE_PARAMETERS_START(0, 3) Z_PARAM_OPTIONAL Z_PARAM_LONG(page) Z_PARAM_LONG(page_size) Z_PARAM_STR(keyword) ZEND_PARSE_PARAMETERS_END(); }如果你把 page 声明为 zend_long 类型但没给初值,然后调用端只传一个参数:demo_page_list(2),你会发现第二个可选参数 page_size 的内容是随机的栈内存。因为 ZPP 宏在参数缺失时根本不会写 page_size 这个变量,你后来读它读的是未初始化值。
正确做法是进入解析前全部赋好默认值。这个我在代码审查看过太多次了,十次有八次是忘了初始化。
zend_long page = 1; // 默认值先赋好 zend_long page_size = 20; zend_string *keyword = NULL;ZPP 宏不会帮你理解和执行业务默认值,它只负责“有就填,没有不动”,默认值决策永远是你的责任。
2.2 可空参数:Z_PARAM_xxx_OR_NULL 的适用场景
可空和可选是两码事。可选是指“调用方可以不传”,可空是指“调用方可以传 null”。比如你要实现一个函数 demo_get_user(zend_long $id, ?array $config),第二个参数既可以是合法的数组,也可以是 null,但必须显式传。这时候用 Z_PARAM_OPTIONAL 就不合适,因为它的语义是“可缺省”,而不是“可空”。
PHP 8 之后的 ZPP 提供了专门的 _OR_NULL 变体:
PHP_FUNCTION(demo_get_user) { zend_long id; HashTable *config = NULL; ZEND_PARSE_PARAMETERS_START(1, 2) Z_PARAM_LONG(id) Z_PARAM_OPTIONAL Z_PARAM_ARRAY_HT_OR_NULL(config) ZEND_PARSE_PARAMETERS_END(); }这里 config 用了 Z_PARAM_ARRAY_HT_OR_NULL,调用 demo_get_user(1, null) 和 demo_get_user(1) 都能成功解析。区别在于第一种情况下 config 为 NULL,你可以在业务代码里做统一处理。
注意 _OR_NULL 变体在每个类型上都有,比如 Z_PARAM_STR_OR_NULL、Z_PARAM_OBJ_OR_NULL。但我建议能不用就不用——能同时接收“类型A”和“null”的场景,说明这个参数本身语义不够清晰,很容易让调用方产生歧义。
2.3 可选加可空加默认值的组合陷阱
把 Z_PARAM_OPTIONAL 和 _OR_NULL 组合在一起时,陷阱就来了。看这段代码:
PHP_FUNCTION(demo_parse_config) { HashTable *config = NULL; zend_string *format = NULL; ZEND_PARSE_PARAMETERS_START(0, 2) Z_PARAM_OPTIONAL Z_PARAM_ARRAY_HT_OR_NULL(config) Z_PARAM_STR_OR_NULL(format) ZEND_PARSE_PARAMETERS_END(); }顺序上 config 和 format 都是可选+可空。调用 demo_parse_config(),config 和 format 都保持初值 NULL;调用 demo_parse_config(null, 'json'),config 解析为 NULL,format 解析为字符串。看起来合理,但有个隐藏行为:一旦第一个可选参数省略,第二个可选参数也无法传递。比如 demo_parse_config(format: 'json') 这种命名参数场景下,ZPP 的老式解析有个先天的顺序限制,处理起来非常别扭。
我个人的经验是:可选参数一旦超过两个,而且这中间还掺杂可空需求,直接全收 zval,自己在业务层解析。
PHP_FUNCTION(demo_parse_config) { zval *config = NULL, *format = NULL; ZEND_PARSE_PARAMETERS_START(0, 2) Z_PARAM_OPTIONAL Z_PARAM_ZVAL(config) Z_PARAM_ZVAL_OR_NULL(format) ZEND_PARSE_PARAMETERS_END(); if (config && Z_TYPE_P(config) == IS_ARRAY) { // 按数组处理 } }全收 zval 之后自己判断,牺牲的只是一点点解析效率,换来的是参数语义完全可控。特别是做框架类扩展,参数格式经常变化,这种防御性写法反而更稳妥。
3. 数组、对象与回调:复杂参数的解析与调用路径
3.1 为什么优先用 Z_PARAM_ARRAY_HT 而不是 Z_PARAM_ARRAY
接受数组参数的函数,有两个宏可以选:Z_PARAM_ARRAY(dest) 和 Z_PARAM_ARRAY_HT(dest)。前者传入的 dest 是 zval*,后者传入的是 HashTable*。数组在 PHP 底层就是 HashTable,所以绝大多数场景直接用 Z_PARAM_ARRAY_HT 更高效,省掉一层 zval 解引用,可以直接操作 HashTable API。
PHP_FUNCTION(demo_config_get) { HashTable *config; zval *value; ZEND_PARSE_PARAMETERS_START(1, 1) Z_PARAM_ARRAY_HT(config) ZEND_PARSE_PARAMETERS_END(); if ((value = zend_hash_str_find(config, "env", sizeof("env") - 1)) != NULL) { RETURN_ZVAL(value, 1, 0); } RETURN_NULL(); }Z_PARAM_ARRAY_HT 拿到的是原始 HashTable 指针,也就是说你后续所做的读操作是零拷贝、零引用的。读没问题,但如果你要做写操作、遍历删除、或者把值长期保存,必须额外做分离处理,否则会直接污染调用方的数组。这个细节放在第 6 节专门讲。
另外,真到了 PHP 8 时代,很多函数会接收 ArrayObject 而不是普通数组。老式的 Z_PARAM_ARRAY_HT 只认数组,传 ArrayObject 会直接 TypeError。你要兼容 ArrayObject,用 Z_PARAM_ARRAY_OR_OBJECT_HT,它会把对象内部属性表当作 HashTable 返回,代码不用大改。
3.2 对象类约束:Z_PARAM_OBJECT_OF_CLASS 的正确姿势
对象参数比数组好处理一些,因为 PHP 对象都有 class 信息。但如果你只是拿到 zend_object* 再手动 instanceof 判断,等于把类型检查又写了一遍。ZPP 提供了类约束宏:
PHP_FUNCTION(demo_log) { zend_object *logger; zend_string *message; ZEND_PARSE_PARAMETERS_START(2, 2) Z_PARAM_OBJECT_OF_CLASS(logger, logger_interface_ce) Z_PARAM_STR(message) ZEND_PARSE_PARAMETERS_END(); }这里 logger_interface_ce 是你要校验的类入口,可以是接口也可以是类。ZPP 内部会走 instanceof 检查,不通过直接抛 TypeError,连一行手工判断都不用写。这个宏大大减少了扩展代码里常见的“先取对象再 instanceof 再报错”三件套。
有个小提示:如果你要的参数确实是某个具体类的实例(而不是接口),可以把校验类写死成那个类的 ce。如果类来自别的扩展或者动态注册,记得在 MINIT 阶段用 zend_class_implements 或注册机制拿到 ce,不要在 RINIT 阶段反复查找,否则会有性能损耗。
3.3 Z_PARAM_FUNC:在扩展里安全调用用户回调
扩展里接收回调是高频需求。ZPP 提供了 Z_PARAM_FUNC(fci, fci_cache),它一次性帮你完成 callable 校验和闭包对象提取。用法看这个示例:
PHP_FUNCTION(demo_transform) { zend_fcall_info fci; zend_fcall_info_cache fci_cache; zend_string *input; zval params[1], retval; ZEND_PARSE_PARAMETERS_START(2, 2) Z_PARAM_STR(input) Z_PARAM_FUNC(fci, fci_cache) ZEND_PARSE_PARAMETERS_END(); ZVAL_STR_COPY(¶ms[0], input); fci.param_count = 1; fci.params = params; fci.retval = &retval; if (zend_call_function(&fci, &fci_cache) == SUCCESS) { RETURN_ZVAL(&retval, 1, 0); } zval_ptr_dtor(¶ms[0]); RETURN_FALSE; }Z_PARAM_FUNC 解析完成后,fci 里已经有函数名或闭包对象,fci_cache 里是 op_array 缓存。直接设置 param_count、params、retval 后调用 zend_call_function,性能和 PHP 用户态调用很接近。
这里有两个容易翻车的点:第一,params 里的每个元素都要确保计数正确,如果是从某个 HashTable 拿出来的 zval,记得先 ZVAL_ADDREF,防止回调还没执行完就被释放。第二,zend_call_function 返回后,如果 retval 不为 NULL,你有责任释放它,否则就是内存泄漏。上面代码用 RETURN_ZVAL(&retval, 1, 0) 的第二个参数 1 就是让返回值 transfer 给返回栈,第三个参数 0 表示不额外释放 retval,这是正确姿势。
4. 可变参数、弱类型转换与分离控制:更细一层的 ZPP 控制力
4.1 Z_PARAM_VARIADIC:变参收集后的内存责任
PHP 用户态有 func_get_args,扩展里没有。但 ZPP 提供了 Z_PARAM_VARIADIC,把剩余参数一次性收集到一个 zval 数组:
PHP_FUNCTION(demo_sum) { zval *args; uint32_t argc; zend_long sum = 0; int i; ZEND_PARSE_PARAMETERS_START(1, -1) Z_PARAM_VARIADIC(args, argc) ZEND_PARSE_PARAMETERS_END(); for (i = 0; i < argc; i++) { if (Z_TYPE(args[i]) == IS_LONG) { sum += Z_LVAL(args[i]); } } RETURN_LONG(sum); }注意 Z_PARAM_VARIADIC 接收的是一个 zval 数组,不是你传入的 zend_parse_parameters 参数列表。argc 是收集到的参数数量。这里有个内存细节:args 里的每个 zval 指向的是调用方栈上的参数 zval 拷贝,如果你只是临时遍历没问题;但如果要把这些参数存到全局、缓存、或者 HashTable 里,必须逐个 ZVAL_ADDREF,否则函数返回后这些 zval 会失效。
另外,Z_PARAM_VARIADIC 和固定参数混用的顺序限制跟用户态函数一致:变参之后不能再接固定参数。你在 ZEND_PARSE_PARAMETERS_START 里写 -1 表示无限变参,但编辑器不会帮你检查 Z_PARAM_VARIADIC 之后的宏是否还有效,你自己要保证顺序。
4.2 弱类型转换的规则:numeric string 什么时候可以当 long 用
ZPP 是典型的弱类型解析器。比如函数声明 Z_PARAM_LONG,调用时传一个数字字符串 "123",PHP 内部会把它转成 long 再填进去。这是很多从 PHP 7 迁移过来的扩展开发者容易忽略的——他们以为 ZPP 跟用户态函数声明一样严格,其实完全不是。
demo_sum("123", 4); // 第一个参数被内部转换成 123转换规则是:字符串必须完全匹配数字格式,比如 "123"、"12.5" 会转到 double 再夹紧到 long,但 "abc" 不会转换成 0,会直接 TypeError。bool 转 long 是老规矩:true 转 1,false 转 0。null 转 long 在 PHP 8 之前是 0,PHP 8 之后如果类型声明非 nullable 会抛错,ZPP 的行为跟随内核版本。
如果你希望禁止这种弱转换,任何 ZPP 宏都做不到。你需要先收下来再自己反射严格类型判断,这也是为什么框架型扩展往往宁可全收 zval 再手动校验。
4.3 Z_PARAM_xxx_EX 的 separate 参数何时必须为 1
带 _EX 后缀的宏会多一个 separate 参数。比如 Z_PARAM_STR_EX(dest, separate)、Z_PARAM_ZVAL_EX(dest, separate)。这个参数控制是否对参数做强制写时分离。
具体场景:你从 ZPP 拿到一个 zval 指针,它是引用类型或者共享计数大于 1,你直接对它修改会影响到外面的变量。最典型的是实现内部版的 strtoupper 这种会修改字符串内容的函数。正确的做法:
PHP_FUNCTION(demo_upcase) { zval *str_zv; ZEND_PARSE_PARAMETERS_START(1, 1) Z_PARAM_ZVAL_EX(str_zv, 1) ZEND_PARSE_PARAMETERS_END(); convert_to_string(str_zv); // 此时 str_zv 是独立副本,改它不会影响调用方 }separate 传 1 就是“如果要写,先把引用链断开”。传 0 则是读模式。这个参数非常容易写错,我见过很多人为了省一次拷贝传 0,结果函数内部改了字符串,调用方的变量莫名其妙跟着变了。
5. 性能实测与误用模式:ZPP 并不是万能银弹
5.1 一条宏在运行时做了什么
我以前也觉得 ZPP 宏能带来编译期优化,后来读了 zend_API.c 才明白,宏展开后核心调用还是运行时解析。每调用一次带 ZPP 的扩展函数,都会走一遍格式化字符串解释,也就是逐个字符匹配类型说明符。虽然 PHP 团队做了 format-string 的缓存,但开销并不会完全消失。
为了理解真实成本,我写过一个小测试:一个纯求和函数,用 Z_PARAM_LONG 接收 100 万次调用;另一个版本手动从 execute_data 里取参数,不经过 ZPP。测试结果让我意外:ZPP 版本慢大约 15%-20%。原因在于 zend_parse_parameters_ex 要处理参数数量检查、弱类型转换尝试、错误状态初始化等一系列通用逻辑,而手动版本只做一次类型判断。
但我要给你泼一盆冷水:这个 15%-20% 的开销,在绝大多数业务场景里可以忽略。你的扩展函数如果每次调用还要做哈希查找、内存分配、IO,那点解析开销算什么。真正需要抠的地方只有一类——被放在最内层循环、直接服务于热路径的 tiny 函数。
5.2 高频函数优化:减少限定符与提前 fast-path
如果你的函数确实处于热路径,有两个优化方向。
第一个是减少 Z_PARAM_xxx 数量。功能允许的话,把 3 个参数改成 1 个数组参数,让数组参数一次进入 HashTable,后续 zend_hash_find 取字段。但它改变了函数签名,属于破坏性优化。
第二个是 fast-path 手写。先快速检查参数数量和第一个参数类型,命中理想情况直接干活;非理想情况再走 ZPP 兜底。这也是 PHP 内部函数常见的朴素优化套路,比如 strlen 这类函数:
PHP_FUNCTION(demo_fast_abs) { zval *arg; if (ZEND_NUM_ARGS() == 1) { arg = ZEND_CALL_ARG(execute_data, 1); if (Z_TYPE_P(arg) == IS_LONG && Z_LVAL_P(arg) >= 0) { RETURN_LONG(Z_LVAL_P(arg)); } } // 兜底走 ZPP ZEND_PARSE_PARAMETERS_START(1, 1) Z_PARAM_LONG(num) ZEND_PARSE_PARAMETERS_END(); RETURN_LONG(num >= 0 ? num : -num); }这种写法牺牲了一部分代码整洁度,换取最理想路径下面零解析开销。注意事项很明确:ZEND_CALL_ARG 拿到的参数指针在函数入口阶段是合法的,但如果你在中途调用了其他函数导致栈重排,这个指针就不可信了,所以 fast-path 必须放在函数最前面,且在 ZPP 兜底之前用。
5.3 我见过的误用模式前三名
这些年建过不少扩展仓库,做 code review 时我总结出 ZPP 误用前三名。
第一名是可选参数默认值没初始化。前面已经详细说过,Z_PARAM_OPTIONAL 缺省时不赋值,这是崩溃重灾区。
第二名是把 Z_PARAM_ARRAY_HT 当成“任意哈希表”用,接收对象时直接崩。老代码里这种很多,因为 PHP 5 时代数组和对象混用频繁。解决办法是换 Z_PARAM_ARRAY_OR_OBJECT_HT 或手动加 Z_PARAM_OBJ 分支。
第三名是忽略引用参数。Z_PARAM_ZVAL 拿到的可能是一个引用 zval,直接 Z_STRVAL_P 这种底层宏读数据没问题,但如果你做 zend_hash_update 覆盖值,会覆盖到外部变量。最典型的就是扩展里实现一个错误收集器,把 error 数组引用传进去再 update 键,结果用户 main 函数的局部变量被改得乱七八糟。
6. 调试与错误消息:参数解析崩溃的完整排查链路
6.1 自定义错误消息:Z_PARAM_ERROR
ZPP 默认抛的 TypeError 消息往往不够友好,尤其是多参数函数,用户根本不知道是第几个参数错了。Z_PARAM_ERROR 可以接管错误输出:
PHP_FUNCTION(demo_connect) { Smart_String host; zend_long port; ZEND_PARSE_PARAMETERS_START(2, 2) Z_PARAM_STR(host) Z_PARAM_STR_OR_NULL(port) Z_PARAM_ERROR("参数 host 必须是字符串,参数 port 必须是可空字符串") ZEND_PARSE_PARAMETERS_END(); }Z_PARAM_ERROR 是最后的兜底,一旦前面的参数类型解析不通过,不会走到你自己的错误分支,而是直接用这里的内容抛 TypeError。用法上注意:它必须出现在所有参数宏之后,并且一个 parse 块里只能有一条 Z_PARAM_ERROR,多个就是编译错误。
这个宏的高级价值在于统一错误口径。比如你的扩展有 20 个函数,每个函数都带自定义错误消息,维护起来虽然费点功夫,但用户侧的错误可读性会好很多,尤其是面向二次开发者的 SDK 型扩展,这个投入非常值得。
6.2 gdb 和 phpdbg 定位 ZPP 崩溃
ZPP 崩溃有很强的迷惑性,有时候崩在类型宏内部,有时候崩在解析结束后的业务代码里,但背锅的都是 ZPP。推荐一个我自己常用的排查方法。
先让 PHP 崩溃生成 core,然后在 gdb 里加载:
gdb /usr/local/bin/php core btbt 输出如果看到 zend_parse_parameters_ex 或者在 __zend_parse_arg_long 这种符号附近崩,那大概率是类型转换时拿到的 zval 数据错误。这时候往下看 frame 列表,找到你的扩展函数帧,打印参数:
frame 3 info args p *execute_data比较有用的一个手段是给崩溃点打断点,然后单步跟踪 Z_PARAM_xxx 宏展开后的格式字符串。格式字符串可以在 gdb 里直接打印:
p (char *)format看到一堆 "l" "a" "s" 之类的字符,你的参数声明和实际调用之间的差异就一目了然了。phpdbg 也可以做类似的事,但 gdb 对 core 分析更直接,建议优先 gdb。
6.3 引用计数与分离时机中的典型坑
最后集中聊引用计数问题,这是 ZPP 高级用法里绕不过去的一环。
我见过最多的问题是扩展里直接 dtor 了解析出来的 zval 指针。ZPP 本来负责生命周期,你是“借用”了调用方的参数,不是“拥有”它。如果你在函数内部 zval_ptr_dtor 了这个 zval,比如 RETVAL_ZVAL(dest, 0, 1) 使用不当,会把调用方的变量给释放掉,造成 use-after-free。凡是解析出来的 zval 参数,默认只读;真要转移所有权或者延迟使用,必须做 ZVAL_ADDREF 或复制。
另一个是分离时机。第 3 节提到操作 HashTable 前要注意分离,这里的细节是:只有 refcount > 1 或 IS_REFERENCE 时才需要分离。如果你对每个参数都无条件分离,反而会多出大量无意义的内存拷贝。正确姿势是先检查:
if (Z_REFCOUNTED_P(zv) && !Z_ISREF_P(zv) && Z_REFCOUNT_P(zv) > 1) { zval tmp; ZVAL_COPY(&tmp, zv); // 操作 tmp,最后替换回 zv }ZPP 里的 Z_PARAM_ZVAL_EX(dest, 1) 其实已经在内部帮你做了这个判断。所以我建议:拿不准就全用 _EX(dest, 1),你不用关心分离逻辑,内核来兜底。代价是一次 refcount 检查和偶尔的复制,安全收益远大于开销。
我自己现在写新扩展有个习惯:参数表结构稳定之后,先把 Z_PARAM_OPTIONAL 的默认值全部初始化好,再把所有对象类型参数标上 _OR_NULL 或类约束,最后补一条 Z_PARAM_ERROR。整套写完后,用一个带十几种异常调用的 PHP 脚本来硬测,跑过再上线。ZPP 是成熟方案,但它的高级用法永远建立在“你对自己参数语义足够明确”的基础上,把边界想清楚,比多背几个宏语法重要得多。