
如果你在搜索引擎里敲一个“模板”跳出来的大概率是PPT模板、LaTeX论文模板、软著文档模板这些“套壳资源”。但“模板编译期哈希计算”跟这些完全不是一回事——这是C模板元编程里一个相当有积累的话题。核心思路一句话利用模板与constexpr机制把字符串、类型名称的哈希计算从程序运行阶段挪到编译阶段最终得到的是一个个整数常量。换成大白话以前你在运行时拿START、STOP这些字符串跟配置项逐个比较现在你在编译期就把它们变成几个uint64_t整数运行时只剩一次整数比较。这个技术的价值远不是省几微秒那么简单。它能让命令分发、配置解析、类型标识、序列化字段映射这些代码更简洁、错误更早暴露还能把结果塞进模板参数、static_assert这类只有编译期才用得上的位置。如果你在做嵌入式、网关、服务端框架、日志系统、序列化库或者经常要跟“字符串映射枚举”打交道这篇文章值得看完。下面我按自己实际踩过的路径把整个思路、代码和坑一次讲透。1. 编译期哈希到底解决什么问题——先别急着写代码1.1 一个再常见不过的痛点我最早碰上这个需求是在维护一个老旧的嵌入式网关项目。设备上跑着一个命令分发器客户端发过来的是START、STOP、RESTART、STATUS这类字符串指令服务端收到之后逐条跟配置项比对再决定走哪个处理函数。原有实现是一长串if-else每个分支都调用一次字符串比较函数。命令稍微多一点函数体就变成一面墙而且每增加一条命令都要小心翼翼地把新分支插到正确位置。后来我跟同事聊起来发现大家都遇到过类似的尴尬字符串集合在编译期就已经完全确定了代码却还要在运行期一遍又一遍地对它们做比较运算。更麻烦的是有些场景下字符串比较还会伴随堆分配——比如把字符串塞进std::map做查找光是节点分配和哈希计算就比业务逻辑本身还重。对资源紧张的嵌入式环境来说这简直是在浪费宝贵的运行时间和内存。再往深一层想字符串还不是唯一的痛点。做序列化或者运行时反射的时候经常要用到类型标识。typeid(T).name()虽然能用但返回的字符串格式跟编译器绑定MSVC、GCC、Clang各自给出一套直接拿去比较既不可靠也不直观。要是能把类型名也折算成一个稳定整数跨模块传ID就简单太多了。1.2 核心思路把“计算”提前到编译期编译期哈希解决的就是这一类问题。它用constexpr函数或者模板特化让编译器在编译阶段完成字符串扫描、字节混合、哈希运算最后产出一个编译期整数常量。运行时拿这个整数跟目标值做比较路径短、开销极小而且代码语义非常清楚。这不是什么黑魔法本质上是C“尽可能把计算前移”的思路在一个具体场景下的体现。constexpr函数写起来跟普通函数几乎一样只是它既能在编译期求值也能在运行期调用。当你把一份代码同时用在两种上下文中编译器会根据调用环境自动决定在哪里执行。这一点带来的好处非常实在第一代码结构更干净。命令分发从一长串if-else变成switch-case可读性提升一个量级。第二错误更早暴露。字符串拼错了、哈希碰撞了、映射关系冲突了这些在编译阶段就会被static_assert或枚举值重复定义直接拦下来而不是等到线上运行才出问题。第三结果可以参与编译期运算。比如把哈希值用作枚举底层值、模板非类型参数、数组维度这些都是运行时字符串比较做不到的。第四绕开静态初始化顺序的坑。传统的static std::mapstd::string, Command在模块加载时要走动态初始化跨编译单元容易出现初始化顺序导致的诡异崩溃。编译期哈希常量是纯整数根本不参与运行期初始化。1.3 它到底能用在哪根据我自己这些年接触过的项目编译期哈希最常用的场景大概有下面这些协议与命令分发网络指令字符串映射到枚举再映射到处理函数。配置项解析INI、JSON里的小规模字段映射成枚举减少反复的字符串比较。类型标识与反射type_hashT()生成稳定整数作为类型ID替代typeid(T).name()。序列化格式字段ID用整数哈希代替冗长字符串字段名减小消息体积。日志级别匹配日志等级字符串在编译期转成整数过滤判断变成整数比较。HTTP路由匹配把路径前缀映射成整数路由分发走switch而不是一堆正则。这还只是C语境下的“模板编译期哈希”。在Web模板引擎里“模板编译期哈希”也有类似存在模板源码经过编译阶段之后生成指纹信息用于缓存失效判断。但那个方向的技术细节和C这里差异很大本文后面全部围绕C模板与constexpr展开。2. 技术地基模板、constexpr与编译期计算的关系2.1 C模板在这里究竟扮演什么角色说到编译期哈希很多人第一反应是constexpr函数反而容易忽略模板的位置。实际上模板是这套技术里不可替代的一半。先看类型哈希的场景type_hashint()和type_hashfloat()为什么能拿到不同的字符串因为模板为每个T实例化出独立的函数体而__PRETTY_FUNCTION__这个编译器魔法常量里嵌着当前实例化的完整签名。换句话说模板负责把“类型”变成“字符串”constexpr哈希再把“字符串”变成“整数”。模板的另一个贡献是非类型模板参数。编译期哈希得到的整数常量可以直接作为模板参数传递比如templateuint64_t H struct Handler;这种写法。运行时即便把你算好的哈希值放到局部变量里也没法塞进模板参数因为模板参数必须在编译期可见。只有把哈希计算整个前移到编译期这条路才走得通。顺带说一句Rust的const fn、Zig的comptime其实也都在做类似的事只是C这里的模板加constexpr组合语法上更繁琐但能力上限很高。理解了一处其他语言的概念基本能触类旁通。2.2 constexpr函数 vs 模板特化两条路线的取舍编译期哈希有两种经典实现路线一条是constexpr函数另一条是纯模板特化递归。纯模板特化是老派写法典型形态长这样定义一个templateuint32_t N的结构体在里面递归展开FNV-1a的每一步value通过枚举或者static constexpr保存中间结果。每一步计算都对应一个模板实例N从字符串长度递减到0。这种写法有几大致命伤代码极度不直观、模板实例化数量大导致编译慢、报错信息长到怀疑人生。我在老项目里见过这种风格后来全部重写了。constexpr函数的路线在C14之后变成绝对主流。C14放开了constexpr函数的限制允许局部变量、循环、多个return语句这让哈希算法可以用正常人思考的方式写出来。C17又加入了if constexpr并对std::string_view的成员函数标注了constexpr字符串处理在编译期变得更顺手。到了C20consteval关键字可以提供“强制编译期求值”的保证。下面用表格直接对比两条路线对比维度constexpr函数路线模板特化递归路线代码可读性接近普通函数直观几乎不可读逻辑埋在模板参数里可维护性改算法只需要改一处constexpr函数改一步计算要动整个模板递归结构编译资源循环求值对模板实例化压力小每个字符都产生模板实例深度压力大调试难度可以用static_assert逐步验证中间结果报错信息包含几十层模板展开适用C版本C14开始体验良好C11可以但没必要一句话总结除非你要兼容C11而且必须把计算全部放进模板参数里否则优先constexpr函数。就连模板参数场景constexpr函数的返回结果也完全可以直接作为非类型模板参数使用没必要牺牲可维护性去换一个“纯模板”的名头。2.3 哈希算法选型为什么常选FNV-1a编译期的哈希算法选择跟运行期很不一样。运行期你可以用MurmurHash、CityHash这些性能极佳的算法因为它们有精心调优的查找表和复杂的指令流水。但编译期哈希对算法有三个硬性要求状态简单、无外部依赖、能在constexpr函数里用循环实现。MurmurHash不是不能写它里面有大量混合操作和可选查找表在constexpr环境里写出来又长又慢CityHash对指令集还有依赖根本不适合。FNV-1a在这三个要求上几乎完美。算法核心就两行公式hash ^ byte hash * prime从offset basis出发逐个字节异或再乘一个大质数。没有查找表不需要额外的随机种子整数溢出由C按模运算自动处理。32位版本用2166136261作为起始值、16777619作为乘数64位版本用14695981039346656037作为起始值、1099511628211作为乘数。为什么“先异或后乘”的顺序有效初看这只是两次算术运算但乘上大质数会把当前字节的信息扩散到哈希值的许多位异或则保证每个字节都不可缺失地进入状态。迭代之后任意一个字节的变化都会传导到最终结果的相当多比特位上。FNV-1a不是加密算法对抗不了蓄意构造的碰撞但用于字符串标记、映射、分发这类非对抗场景分布质量足够好而且实现简单到不会出错。3. 实操篇手写一个编译期字符串哈希工具3.1 第一步实现constexpr版FNV-1a先写核心函数。这里我直接给64位版本适用于绝大多数场景。#include cstdint #include cstddef #include string_view constexpr uint64_t fnv1a_64(std::string_view sv) { uint64_t hash 14695981039346656037ULL; for (unsigned char c : sv) { hash ^ c; hash * 1099511628211ULL; } return hash; }两个细节必须说明白。第一循环变量用的是unsigned char而不是char。char是否有符号是编译器实现决定的如果直接对char做位运算再提升到uint64_t负数会出现符号扩展导致同一个字符串在不同编译器下得到不同哈希。用unsigned char可以保证每一位的语义完全一致。第二乘法溢出是预期行为C保证无符号整数按模2^64回绕不需要额外处理。这一步写完就可以做基础验证static_assert(fnv1a_64(hello) ! fnv1a_64(world)); static_assert(fnv1a_64(hello) fnv1a_64(hello));这两条编译期断言能跑通说明constexpr求值环境没问题。注意fnv1a_64(hello)能编译是因为std::string_view有constexpr构造函数字符串字面量隐式转换成了视图这是C17才有的能力所以项目标准至少要设成C17。3.2 第二步自定义字面量运算符让字符串字面量直接变哈希函数写好了但每次写fnv1a_64(start)还是不够顺手。更自然的方式是让字符串字面量自己带上哈希能力。C的字面量运算符正好干这个constexpr uint64_t operator_h(const char* str, std::size_t len) { return fnv1a_64(std::string_view(str, len)); }编译器看到start_h这样的表达式时会把指向字符串第一个字符的指针和长度5传进这个运算符然后调fnv1a_64进行计算。因为整个表达式所有输入都来自编译期字面量函数又是constexpr所以结果是一个编译期常量。使用时有几个规矩要记住字面量运算符必须定义在命名空间作用域不能定义在函数内部也不能是类的成员。如果定义在某个命名空间里使用前需要通过using引入。这里有一个常见误用把运算符定义在匿名命名空间里结果在另一个翻译单元里找不到符号。现在验证就简单了static_assert(hello_h hello_h); static_assert(hello_h ! world_h); static_assert(hello_h fnv1a_64(hello));如果用的是C20建议把fnv1a_64和这个字面量运算符都改成consteval。这样一旦有人在运行期依赖这两个函数编译器直接拒绝编译从根源上防止误用。C17项目没有consteval但constexpr在绝大多数场景也足够用。3.3 第三步用枚举和switch实现零成本命令分发哈希值和枚举结合是这个技术最经典的形态。既然字符串枚举值的哈希都是编译期常量那它们天然可以作为枚举的底层值enum class Command : uint64_t { Unknown 0, Start start_h, Stop stop_h, Restart restart_h, Status status_h, };C规定枚举的底层值必须是整型常量表达式start_h正好满足。这样写还有个额外好处如果两个字符串不小心哈希碰撞枚举会出现重复值定义编译器直接报错。也就是说碰撞在编译期就被逮住了而不是留到线上运行才暴露。有了枚举分发逻辑就能写成switch-caseconstexpr Command parse_command(std::string_view sv) { switch (fnv1a_64(sv)) { case static_castuint64_t(Command::Start): return Command::Start; case static_castuint64_t(Command::Stop): return Command::Stop; case static_castuint64_t(Command::Restart): return Command::Restart; case static_castuint64_t(Command::Status): return Command::Status; default: return Command::Unknown; } }注意switch的case标签里每一项都是static_castuint64_t(Command::Start)这类整数常量表达式而switch表达式的类型是uint64_t两者类型一致编译器不会产生歧义。这个函数最妙的地方在于同一份代码跑两种场景int main() { constexpr std::string_view compile_time_cmd restart; constexpr Command c parse_command(compile_time_cmd); static_assert(c Command::Restart); std::string_view runtime_cmd GetRawInput(); Command rc parse_command(runtime_cmd); }第一段里compile_time_cmd是constexprparse_command走的是编译期求值路径编译器直接算出结果是Command::Restart甚至不需要等程序运行。第二段输入来自运行时的GetRawInput()那parse_command就在运行期老老实实算哈希再进switch。同一套代码编译期、运行期通吃这就是constexpr函数最让人舒服的地方。还可以顺手加一条映射正确的编译期断言static_assert(parse_command(unknown_xyz) Command::Unknown); static_assert(parse_command(stop) Command::Stop);3.4 第四步类型名称哈希的编译器兼容技巧字符串映射枚举之外另一个非常实用的方向是类型哈希。typeid(T).name()在不同编译器下格式不统一而且返回的是运行期字符串不适合做模板参数。用__PRETTY_FUNCTION__或__FUNCSIG__配合constexpr解析可以拿到“类型名字符串”再交给FNV-1a变成整数。这类技巧在很多开源库里都能看到影子核心代码是提取模板参数中的类型名。GCC和Clang环境下__PRETTY_FUNCTION__的格式大约长这样constexpr uint64_t type_hash() [with T int]MSVC环境下__FUNCSIG__的格式大约长这样uint64_t __cdecl type_hashint(void)两种格式完全不同提取逻辑必须分开写template typename T constexpr uint64_t type_hash() { #if defined(_MSC_VER) constexpr std::string_view sig __FUNCSIG__; constexpr auto marker sig.find(type_hash); static_assert(marker ! std::string_view::npos, MSVC signature format changed, update extractor); constexpr auto start marker 10; // 跳过 type_hash constexpr auto end sig.find(, start); constexpr std::string_view type_name sig.substr(start, end - start); #else constexpr std::string_view sig __PRETTY_FUNCTION__; constexpr auto marker sig.find(T ); static_assert(marker ! std::string_view::npos, compiler signature format changed, update extractor); constexpr auto start marker 4; constexpr auto end sig.find(], start); constexpr std::string_view type_name sig.substr(start, end - start); #endif return fnv1a_64(type_name); }使用方法和验证static_assert(type_hashint() ! type_hashfloat()); static_assert(type_hashint() type_hashint()); constexpr uint64_t IntId type_hashint();这里有几个非常关键的实操心得。第一不同编译器的__PRETTY_FUNCTION__/__FUNCSIG__格式不仅不同还会随编译器版本、命名空间、返回类型变化。上面这段提取逻辑只保证简单类型可用。真实项目里用到复杂模板类型之前先写个临时函数printf(%.*s\n, (int)sig.size(), sig.data())把签名原样打印出来再对着实际格式调整提取位置这是最稳的做法。第二static_assert的防护不是可有可无。编译器升级后如果签名格式变了提取代码的find会返回npos紧接着的substr在编译期求值时会触发断言错误。有static_assert拦在前面你看到的错误信息是“signature format changed”没有的话是一串莫名其妙的偏移量错误。第三type_hashT()在同一个编译器版本下跨编译单元得到的结果一致可以当作模块间的类型ID使用。它比typeid(T).name()更适合做持久化标识因为计算结果完全确定、不依赖运行期初始化。但如果你要用它做跨编译器甚至跨语言的持久化标识就要非常小心因为不同编译器的类型名提取结果可能不同。4. 参数、冲突与性能细节决定成败4.1 选32位还是64位碰撞概率与场景匹配哈希位宽直接决定碰撞风险。碰撞概率可以用生日悖论近似估算一组随机分布、数量为n的哈希值在2^b的哈希空间里发生任意两两碰撞的概率大约是n² / (2 * 2^b)。我整理了一下实际数据哈希位宽字符串数量碰撞概率近似值实际量级32位1000约0.000116万分之一级别32位10000约0.01164百分之一级别64位10000约2.7e-12基本不可能碰到所以我的建议很直接只要哈希值会被持久化存储、跨模块传输、用作文档或协议中的字段标识直接上64位。多出来的4个字节在64位平台上完全不值得纠结反而64位乘法在多数现代CPU上和32位乘法几乎同价。只有在内存极度受限、哈希值只做进程内临时索引的场景32位才值得考虑。另外要注意FNV-1a不是加密哈希不要把它用在对抗恶意输入的认证、防篡改场景。它跟MD5、SHA-256不是一类东西不存在“更安全”的FNV-1a版本。需要安全哈希就换算法别在FNV-1a上打补丁。还有一点容易被忽略如果哈希值要跟其他语言写的服务端做协议对接双方必须约定同一个算法、同一个偏移量、同一个乘法常量。FNV-1a虽然算法本身固定但存在32位、64位、128位等多个变体少约定一个参数两边算出来的结果就对不上。4.2 编译耗时的真实情况与优化我实测过一段用了大量编译期哈希的代码十几个命令字符串、几十个类型哈希用GCC和Clang编译开启-O2之后编译时间的增加几乎可以忽略。编译期哈希不会成为项目编译的瓶颈真正会造成编译时间爆炸的是模板递归特化那套老写法——每个字符都产生一层模板实例字符串稍微长一点编译器就要处理几百层实例化耗时和错误信息都很难看。constexpr函数的循环求值走的是编译器的常量求值器不是模板实例化机制所以没有模板深度爆炸的问题。但要注意C标准允许实现设一个最大求值深度默认通常是128层或可配置。如果你的字符串有几千个字符或者循环体内嵌套了复杂的substr、find操作个别编译器可能报constexpr evaluation depth exceeded之类的错误。解决办法很直接把大输入拆成小段分别计算或者调高编译器允许的求值深度参数但后者不跨编译器通用我不建议依赖。还有一个性能相关的经验不要在热路径上对同一个编译期哈希函数做重复求值。写const auto h cmd_h;而不是在每次循环里都写cmd_h。虽然编译器通常会把后者优化掉但显式保存常量的写法能让代码意图更清楚也避免某些低优化级别下产生不必要的重复调用。4.3 ODR、静态初始化与跨模块使用的坑这一段是很多文章不会提但真实项目里早晚要面对的。用编译期哈希替代static std::map之后最直接的好处是绕开了静态初始化顺序问题。传统的做法是// 不推荐 static const std::unordered_mapstd::string, Command g_map { ... };这个g_map在程序加载时要走动态初始化而且初始化顺序在跨翻译单元时是未定义的。两个模块各自用一个g_map一个在初始化阶段去查另一个直接崩给你看。编译期哈希常量是纯整数不存在运行期初始化步骤整个类别的崩溃问题从根上消失。但跨模块使用有个新坑type_hashT()的取值依赖编译器的签名格式。如果两个动态库分别用不同版本编译器编译同一个T可能提取出不同的类型名进而得到不同的哈希值。解决思路无非三条统一编译器版本这是最省事的方案。不要跨编译器使用type_hash作为持久化ID只在进程内用它做类型索引。如果确实需要稳定ID用一个集中的头文件把所有类型ID固化成显式常量不让编译器来猜。最后提醒一下自定义字面量运算符在头文件里的传播问题。定义在头文件里的operator_h要求每个包含它的翻译单元都能看到同样的定义否则可能出现ODR违规。好在字面量运算符本身是inline函数语义定义在头文件里是安全的但不要在多个源文件里各自定义不同版本的operator_h否则连接器只会报一串看不懂的符号错误。5. 实战中的坑与排查实录5.1 编译期常量求值超限遇到constexpr evaluation depth exceeded或者constexpr call exceeds maximum evaluation depth这类报错先别慌绝大多数情况不是代码写错了而是编译器的常量求值深度设置了上限。排查顺序我建议这样先确认自己的代码是不是用了终极递归模板。如果是老式模板特化递归产生的直接改写成constexpr循环问题通常立刻消失。如果已经是constexpr循环了再看字符串长度和循环体复杂度。C标准只要求实现支持至少512层的嵌套求值实际编译器默认值各不相同。如果字符串实在很长更稳妥的做法是分两段计算constexpr uint64_t hash_part(std::string_view sv) { return fnv1a_64(sv); } // 超长字符串分段处理 constexpr uint64_t long_hash hash_part(first_part_) * 16777619ULL ^ hash_part(second_part_);不过老实说我在生产环境里很少遇见超长字符串导致编译失败的情况。几百字符的字符串在constexpr循环里跑GCC和Clang都处理得很快。真正容易踩的反而是调试信息里那些“required from here”的深层模板实例化那才让人头皮发麻。5.2 不同编译器的哈希结果不一致这个问题主要集中在type_hashT()上。__PRETTY_FUNCTION__和__FUNCSIG__格式不同提取出来的类型名字符串自然不同FNV-1a算出来当然不一样。解决方式前面已经说过核心就是“分别校准、加断言保护”。我在实际项目中有一个习惯在CI里安排两到三台不同编译器的机器跑同一组static_assert。这样一旦某个编译器升级导致签名格式变化CI第一时间红起来而不是等客户现场报问题。字符串字面量哈希不受这个影响。start_h在不同编译器下结果一定一致因为输入字符串是源代码里的原生字面量编译器不参与内容解读。所以命令分发、配置映射这类场景可以放心跨编译器。5.3 哈希碰撞导致枚举值重复不报错其实报了很多人担心“万一两个字符串撞了怎么办”。其实在枚举这个场景下编译器会替你兜底。如果start_h和stop_h恰好相等enum class Command里两个枚举值就是同一个底层值编译器直接报“enumerator value already defined”之类的错误。但编译器报错的信息毕竟有点晦涩而且它没法告诉你“这两个字符串撞了建议换一个”。所以我习惯加一个显式的碰撞检测constexpr bool hashes_are_unique() { constexpr uint64_t hs[] { start_h, stop_h, restart_h, status_h }; for (std::size_t i 0; i 4; i) for (std::size_t j i 1; j 4; j) if (hs[i] hs[j]) return false; return true; } static_assert(hashes_are_unique(), command hash collision detected!);这段代码能在编译期把命令集合的碰撞一次性查完而且错误信息是自定义的一眼就能看懂。字符串数量增加时只需要同步更新数组里的元素和循环上限代价很小。5.4 避坑速查表我在表格里汇总了最常见的几类问题、原因和处理方式如果后面有人接手我的代码我会让他首先看这张表现象根本原因处理方式枚举出现重复值32位哈希碰撞改用64位加static_assert碰撞检测constexpr evaluation depth exceeded模板递归特化或求值深度上限改constexpr循环拆大输入type_hashT()在GCC和MSVC下不一致编译器签名格式不同分别提取、统一编译器或固定ID字面量运算符找不到符号定义在错误作用域移到全局或命名空间作用域用using引入static_assert误报offset错误编译器升级后签名格式变化先printf打印签名调提取位置哈希值用于持久化ID算法版本或位宽约定不清固定64位FNV-1a文档记录参数这张表看起来简单每一条背后都对应着我实际踩过的坑。尤其是“编译器升级导致签名格式变化”这一条最容易在不知不觉中出现——代码不动换一个编译器版本编译直接失败而且报错的位置还特别诡异。5.5 几个容易忽略的编码细节除了上面那些大坑还有几个更细的编码细节值得一提。第一FNV-1a的循环必须按字节处理。如果你把std::string_view里的值直接当char异或碰上非ASCII字符时不同编译器的char符号性差异会污染哈希结果。我在代码里统一写成for (unsigned char c : sv)不要图省事用auto推导。第二static_castuint64_t(Command::Start)这里的显式转换不能省。枚举类型和uint64_t做switch比较时有些编译器会给出警告甚至报错显式转换能让意图清晰也方便代码审查。第三如果项目里同时存在多个哈希运算函数比如32位和64位都写了命名上一定区分清楚。我见过有人只写了fnv1a这个名字结果32位和64位重载混着用最后两个模块拿到了不同长度的哈希值定位问题花了整整一天。第四字符串字面量运算符只对“字面量”生效不能用在变量上。std::string cmd start; cmd_h这种写法是编译不过的。如果你确实需要对运行期变量算哈希直接调fnv1a_64(std::string_view(cmd))别想着让字面量运算符替你干活。我个人在实际项目中感受最深的一点是编译期哈希不是银弹不该为了用而用。字符串集合在编译期完全固定、量级不大、又频繁出现在热路径里这是最理想的使用场景如果字符串集合来自外部输入、动辄几万条那编译期哈希就不合适了老老实实用运行时哈希表更好。这套技术的价值恰恰在于“在正确的地方省掉不必要的工作”——把那些注定了会反复执行的字符串比较一次性交给编译器处理掉剩下的时间让开发者专注于真正的业务逻辑。后来我在做序列化库时又用上了type_hash配合static_assert碰撞检测几十行代码就把类型ID体系搭起来了。这几年我的习惯是看到“字符串集合在编译期就确定”的场景第一反应就是编译期哈希。字符串比较那件事能交给编译器的绝不留到运行时。