每种编程语言都是一件独特的艺术品,在设计理念与表达可能性上各具魅力;而使用它们的开发者也同样多元 —— 每一位都为这门技艺注入了自己的偏好、创造力与个性。
有些开发者钟情于 Python 的优雅与简约,沉醉于其清晰一致的缩进风格所带来的代码可读性与表现力;
另一些则偏爱像 Forth 这样的语言,享受那种对堆栈进行精准操控、以极简语法构建强大系统的极致掌控感 ——尽管如今仍在使用 Forth 的人,大概已经可以称得上是“数字考古学家”了;
还有那些勇敢的探险家,投身于 Lisp 那神秘而深邃的世界中,与无穷无尽的括号共舞。对他们而言,看似单调的语法反而是通往元编程仙境的钥匙,使他们能够如炼金术士般将代码视为数据,自由操纵、层层抽象,更别提还能随时召唤出神兵利器 Emacs。
而最后,当然不能少了我们这群 C++ 的信徒 —— 属于我们的部落。
在我们眼中,以下这一行代码足以展现编程的魔幻之美:
auto main()->int{return<:]<class _>(_)->_<
这颗星球上,还需要什么比这更美的 C++ 代码吗?
这一行看似由颜文字拼凑而成的“咒语”,最终会向调用者返回那个神秘而幸运的数字:7。其中并无高深技术,只是一个返回数字的 lambda 表达式而已 —— 但为了迷惑你,亲爱的读者,我们特意为 main 函数使用了尾置返回类型(为什么不呢?)。更妙的是,我们还祭出了臭名昭著的 C++ 双字符组 <: 和 %> 来增添一抹晦涩的美感。
可惜的是,三字符组已在 C++17 中被弃用,否则我们还能玩得更欢——当然,硬要使用也不是不行,只是不敢造次罢了。
仅用一行代码就制造出如此混乱与优雅并存的奇迹,但这真正的问题是:能否为您呈现更"优美"的阅读体验?答案当然是肯定的。我们能实现吗?是的,我们几乎可以!不过首先......得把数字去掉,因为嘿......
或者说,谁真的需要它们呢?
数字因其抽象本质,常常令人困惑。更何况,在我们大多数无需高阶思维和符号理解能力的日常生活中,它们几乎是多余的。
或许这正是亚马逊丛林中某些原住民部落(没错,我说的就是蒙杜鲁库人https://www.amazon.com/Alexs-Adventures-Numberland-Alex-Bellos/dp/1408809591)只发展出“无”、“一”、“二”直至“五”的概念,之后便统统归为一个词:“多”。如果你觉得这种认知方式已经足够应对世界,我完全尊重这份智慧。
那么,何不将这份古老而质朴的智慧应用于我们的编程追求中?让我们将美洲原住民的古老智慧应用于编程追求:打造当今世界所能见到的最优美C++代码片段。为此,首先要驱逐那些烦人的数字——仅保留0和1(为了至高无上的比特们不被冷落),然后以如下部落风格的代码实现:
#define __(...)sizeof(int[]){0,\
##__VA_ARGS__}/sizeof(int)-1
auto main()->int{return<:]<class _>(_)->_<
__(_(), _(), _(), _(), _(), _(), _()) ;}(__());
啊,这纯粹的美丽!简直让人喜极而泣,不是吗?当然,某些吹毛求疵的开发者可能会恶毒地批评代码的可读性、可维护性、标准合规性等问题......特别是那些使用微软(小巧软萌)C++编译器的人——直接拒绝编译这段代码。但我们颤抖着欢庆,因为我们成功让一个编译器崩溃了,而其他主流编译器都愉快地接受并编译了它。
可惜这段代码存在大量重复,这实在令人不悦。既然不需要重复,我们至少该去掉其中一部分,对吧?或者干脆全部去掉,何乐而不为呢?因为这正是C++语言的真正魅力所在 —— 不惜一切代价重塑自我、提供更优代码版本的能力。让可读性见鬼去吧!让可维护性下地狱!混乱、破坏与困惑的自由代码万岁!
勇敢的战士们,任务已下达:准备好你们的武器(我是说键盘),让我们像下面这段代码示范的那样,拯救每一个字节:
#define $$ sizeof
#define $ return
#define $_ int
#define __(...)$$($_[]){0,##__VA_ARGS__}/$$($_)-1
auto main()->$_<
__(_(), _(), _(), _(), _(), _(), _()) ;
让我们再次凝视这纯粹的美丽。C++ 的力量如万千超新星般穿透乌云,照亮我们心中每一个编程的渴望 —— 用美元符号$替代return关键字这样的语言核心元素。虽然这并不符合标准规定的有效字符集,不过关于这点(以及几只熊的故事),我们稍后再详谈。
但请看看光明的一面:至少我们没写出下面这样的代码:
#define return(...) main
#define main(...) int
main(7)(return(7))(){
return 7;
}
我们不得不承认,我们曾考虑过将这段代码写入本书,但在窥见未来后,终于意识到万事万物皆有界限 —— 即便是最资深开发者对无厘头(但有趣)代码的容忍度也不例外。
这很可能是本书中最邪恶的代码片段,其邪恶程度堪比尝试编写括号匹配的Lisp程序时的痛苦体验。想想看,若因括号太多而删减一个,或(但愿不会)突发奇想增加一个,后果会怎样?请相信我,千万别尝试。
所以请假装这段代码不存在于书中,即便存在你也未曾得见。纵使不幸读到,也千万别改动其中任何一个括号的数量。
暂且放下这段邪恶代码,让我们回归参赛作品 —— 那角逐"本书最美C++代码"桂冠的候选者。若能让它再精简些、再凝练些、再富有表现力些,比如用更优雅的方式替换那些丑陋的宏定义,就像这样......
#ifndef MINK
#define MINK
#include __FILE__
DD $$ sizeof
DD $ return
DD $_ int
DD _$ _()
DD __(_...)$$($_[]){0,##_}/$$($_)-1
auto main()->$_<
$_<
#endif
#ifdef MINK
#define CAT(x, y) CAT_I(x, y)
#define CAT_I(x, y) x ## y
#define HH CAT(
#define DD HH define
#endif
啊,这代码简直辣眼睛。实在抱歉,虽然提前道过歉了,但若再追求所谓"美感",作者们怕是要集体偏头痛发作。
读到此处我们突然醒悟:既然已有混淆C代码大赛,何必再为其C++版本过度努力?毕竟按C++的本质,就算不刻意混淆也足够晦涩了。但展示完刚才那段四不像的代码后,确实欠您一个道歉和解释(话说回来,四不像也可能很美,关键要有发现美的眼睛)。
首要结论是:这段代码根本无法独立编译。若强行尝试,GCC会给出如下错误:
error: stray '
15 | #define HH CAT(
ICC报错提示:
error: "#" not expected here
DD $$ sizeof
MSVC明确拒绝:
error C2121: '#': invalid character: possibly the result of a macro expansion
Clang同样编译失败:
error: expected unqualified-id
4 | DD $$ sizeof
|
可见各大编译器虽未能统一报错信息,但至少达成共识:这段代码谁都无法编译。其中几条错误提示直指要害 —— 比如"#号可能是宏展开结果"(但笔者实在想不出能展开成#的宏,毕竟#define D #这种写法根本无效),还有关于程序中莫名出现的%:符号的报错。
这些宏展开问题自然将我们引向宏的话题。若您不熟悉C/C++宏,请先阅读《C++程序设计语言》(Bjarne Stroustrup著)等著作。因为本书只探讨传奇级别的宏用法,而那本由C++之父撰写的经典则会教导您:除非万不得已,尽量不用宏;即使用,也要极克制。
回到正题。所有主流编译器都支持输出预处理后的代码,不妨用g++ -E(或clang -E,MSVC用户可用/P命令行参数,或在Visual Studio项目生成目录查找)查看预处理结果:
auto main()->$_<
$_<
我们暂且只展示关键部分,跳过编译器添加的行号信息。可以看到预处理后的输出俨然是份合法的C++文件(虽然可读性欠佳) —— 但令人惊讶的是,文件中赫然残留着多个%:符号开头的define指令。这些符号经过双字符组替换后会转换为井号(#),最终生成有效代码。
要理解其中玄机,必须深入编译器处理宏的机制。C/C++编译器通过预处理器执行系统化的宏展开:首先对源代码进行词法分析,识别待替换的宏。
对于对象式宏(object-like macros),直接进行文本替换;而函数式宏(带括号的宏)则需经历参数预扫描 —— 替换到宏体之前,先完全展开所有参数。这种预扫描机制确保嵌套宏调用被正确展开,并对宏体进行二次扫描以捕获待展开的嵌套宏。
但预扫描不适用于字符串化(#)或标记拼接(##)场景,也不会影响已标记为禁止重复展开的宏。这种特性迫使我们强制编译器对拼接操作执行二次处理,如下图所示:
#define CAT(x, y) CAT_I(x, y)
#define CAT_I(x, y) x ## y
这段代码确保了所有必要参数都能正确展开。像 __LINE__
和 __TIME__
这样的特殊宏会单独处理,以防止意外的二次展开。当所有展开完成后,预处理器会确保在将最终代码传递给编译器前,所有可展开的宏都已处理完毕。这套完整的流程保证了,即使面对嵌套宏和字符串化操作等复杂场景,宏展开也能高效准确地完成。
现在,已经尝试解释了宏替换的工作原理(虽然对初学者来说可能并不直观),是时候回到正题,最终编译我们的程序了。如您所见,预处理后的源代码中仍包含一些 define 指令。
掌握了这些知识后,要揭示一个鲜为人知的技巧 —— 毕竟这是一本关于 C++ 玄学的书。这个技巧叫做"双重预处理"。不过在继续之前,我们需要简单了解一下编译器处理代码的过程。
在编译 C++ 源文件的初始阶段,编译器首先进行预处理和编译。预处理阶段会展开宏(正如前面展示的)、处理条件编译指令(如 #ifdef、#ifndef 等)、包含头文件并删除注释,最终生成一个完整且所有外部文件和宏都已解析的翻译单元。接着在编译阶段,预处理后的代码会经过词法分析被分解为标记(token),这些标记会根据语言语法规则被检查并构建成解析树或抽象语法树(AST)。
然后是语义分析阶段,编译器在此验证类型、变量和函数的正确使用,并可能进行早期优化。最后,编译器将 AST 转换为中间表示(IR),为后续优化和最终的机器代码生成做准备 —— 不过这些内容已经超出了本书的范围。对此感兴趣的读者可以参考著名的"龙书"(《编译原理:原理、技术与工具》,作者 Alfred Aho、Jeffrey Ullman、Ravi Sethi 和 Monica Lam),这是每个想开发编译器或单纯想了解相关技术的开发者的必读书籍。
回到双重预处理技术。通过这项技术,我们将把预处理后的源文件再次送入编译器处理。在 Linux 中这称为管道(piping),而在 Windows 上则更像是某种"黑魔法"。
以下是实现这一操作的 Windows 命令:
cl /P test.cpp & cl /Tp test.i
第一部分负责生成预处理文件(在Visual C++环境中通常使用.i扩展名),第二部分则会将预处理输出保存到test.i文件,并将其作为C++源文件进行编译(/Tp开关专门用于此用途)。最终生成的test.exe执行效果完全符合预期。
在Linux环境下,命令序列也极为相似:
clang++ -E test.cpp | g++ -w -x c++ -std=c++20 -
管道符前的第一部分使用clang++生成预处理代码,通过Linux的管道魔法传递给g++编译——为什么不呢(笑脸)。在这个简单场景中,就算调换两个编译器的顺序也无妨,因为它们本就相辅相成,共享基本的命令行选项,比如用-x c++指定编译的是C++代码,或是用-std指定遵循的C++标准版本。第二个编译器调用最关键的是末尾的-参数,它指示编译器从标准输入而非文件读取代码。
就这样,借助这种神秘技巧,我们竟能编译看似不可能的代码...但请务必不要实际使用。这段代码已接近疯狂边缘,本书之所以展示它,只因我们面向高级C++开发者群体探讨非常规的传奇技巧。千万别让这种代码污染你的编程风格,或吓得你远离键盘 —— 我们可不希望读者半途弃书。下一章,让我们暂时放空大脑,沉浸于纯粹的虚无之中。