41、C++20 实用工具:std::source_location,获取源码位置信息。
调试日志、异常处理、单元测试……这些场景里,我们经常需要知道「这行代码到底在哪个文件、哪个函数、第几行」。以前的做法很原始:用 __FILE__、__LINE__ 这些预定义宏。说实话,用起来挺别扭的——宏是文本替换,没法封装,还容易踩坑。
C++20 终于给了我们一个正经的解决方案:std::source_location。它是个轻量级的值类型,能捕获调用点的源码位置。我个人习惯把它当作「编译器的自拍神器」——你调用它的时候,它咔嚓一下,把当前的文件名、行号、函数名全拍下来。
为什么需要它?
先说说老办法的问题。你写过这样的代码吗?
#define LOG(msg) \
std::cout << __FILE__ << ":" << __LINE__ << " " << msg << std::endl;
嗯,宏确实能工作。但宏有个毛病:它不遵守作用域规则,没法放进命名空间,也没法作为参数传递。更麻烦的是,如果你在头文件里定义了这个宏,不同编译单元展开时,__LINE__ 的值可能不是你想要的。
我在项目中遇到过一个问题:一个日志宏被嵌套在另一个宏里调用,结果 __LINE__ 始终指向外层宏的调用点,而不是真正出问题的那一行。排查了半天,最后发现是宏展开顺序的锅。从那以后,我就特别希望有个「非宏」的解决方案。
std::source_location 就是来干这个的。它是个标准库类,不是宏,所以可以正常传参、返回、存储。而且它捕获的是「调用点」的信息,不是「定义点」的信息——这一点非常关键。
核心用法
先看个最简单的例子:
#include <source_location>
#include <iostream>
void log(const std::string& msg,
const std::source_location& loc = std::source_location::current())
{
std::cout << loc.file_name() << "("
<< loc.line() << ":"
<< loc.column() << ") "
<< loc.function_name() << ": "
<< msg << std::endl;
}
int main()
{
log("Hello, world!");
return 0;
}
输出大概是这样的:
main.cpp(15:5) int main(): Hello, world!
注意看,log 函数的默认参数 std::source_location::current() 是在调用点求值的。也就是说,当你在 main 里调用 log 时,current() 捕获的是 main 函数里第 15 行的位置,而不是 log 函数定义的位置。
为什么会这样?因为默认参数是在调用者的上下文中求值的。这是 C++ 的一个巧妙设计,source_location 充分利用了这一点。
成员方法一览
| 方法 | 返回值 | 说明 |
|---|---|---|
file_name() |
const char* |
源文件名(实现定义,通常是相对路径) |
function_name() |
const char* |
函数签名(包含返回类型和参数) |
line() |
uint_least32_t |
行号(从 1 开始) |
column() |
uint_least32_t |
列号(从 1 开始,有些编译器可能返回 0) |
这里有个细节:column() 的支持程度因编译器而异。GCC 和 Clang 从较新版本开始支持列号,MSVC 我记得在 VS 2019 16.10 之后也支持了。如果你需要跨编译器兼容,建议先检查一下。
实战:带源码位置的断言
我们来实现一个更智能的断言宏——不对,这次我们用函数,不用宏。
#include <source_location>
#include <cstdlib>
#include <iostream>
#include <string>
[[noreturn]] void assertion_failed(
const std::string& expr,
const std::source_location& loc = std::source_location::current())
{
std::cerr << "Assertion failed: " << expr
<< "\n at " << loc.file_name()
<< ":" << loc.line()
<< " in " << loc.function_name()
<< std::endl;
std::abort();
}
#define MY_ASSERT(expr) \
do { \
if (!(expr)) { \
assertion_failed(#expr); \
} \
} while (false)
等等,这里还是用了宏?嗯,是的。因为 #expr 这个「字符串化」操作只有宏能做到。但关键的区别在于:source_location 的捕获是在 assertion_failed 函数内部完成的,而不是在宏展开处。这样即使宏被嵌套调用,位置信息也是准确的。
我曾经在调试一个大型服务时,发现日志里打印的行号总是差几行。后来发现是因为宏里用了 __LINE__,而宏被包在另一个宏里。换成 source_location 之后,问题就解决了。说白了,source_location 比宏更「诚实」——它只反映真实的调用点,不玩文字游戏。
性能与开销
你可能会担心:每次调用都构造一个 source_location 对象,会不会有性能开销?
答案是:几乎为零。std::source_location::current() 是一个「魔法函数」,编译器通常把它实现为内建函数,直接读取编译时的调试信息。它不分配内存,不涉及系统调用,连虚函数都没有。生成的对象大小通常只有几十个字节,可以按值传递。
我做过一个简单的基准测试:在热路径上每 100 纳秒调用一次带 source_location 的日志函数,性能损耗几乎测不出来。当然,如果你在每秒百万次调用的循环里打印日志,那瓶颈肯定在 I/O 上,不在 source_location 上。
与旧式宏的对比
| 特性 | __FILE__/__LINE__ |
std::source_location |
|---|---|---|
| 类型安全 | 宏,文本替换 | 值类型,可传参 |
| 作用域 | 全局可见 | 可放入命名空间 |
| 函数名 | 需要 __FUNCTION__ 或 __PRETTY_FUNCTION__ |
内置 function_name() |
| 列号 | 无标准支持 | 有 column() |
| 默认参数 | 不支持 | 完美支持 |
| 可测试性 | 难以 mock | 可构造假对象 |
避坑指南
注意:不要试图在函数体内手动构造 std::source_location 对象。它的构造函数是私有的,你只能通过 current() 静态方法获取实例。这是设计上的刻意为之——确保你拿到的永远是调用点的真实信息,而不是伪造的。
另外,function_name() 返回的字符串格式是「实现定义」的。GCC 和 Clang 会返回完整的函数签名(包括模板参数和返回类型),而 MSVC 可能只返回函数名。如果你需要解析这个字符串,建议先在你的目标编译器上测试一下。
小技巧:如果你在写一个库,希望用户能传入自定义的 source_location,可以提供一个重载版本,让用户显式传入。默认参数用 current(),这样既保留了便利性,又给了高级用户灵活性。
知识结构图
下面这张图展示了 std::source_location 在整个 C++20 实用工具中的位置,以及它的核心设计思路:
总结
std::source_location 是个小工具,但很实用。它解决了 C++ 里一个长期存在的痛点:如何优雅地获取源码位置。我个人觉得,它最大的价值不在于「能做什么」,而在于「不用再做什么」——不用再写那些丑陋的宏,不用再担心宏展开的坑,不用再手动拼接 __FILE__ 和 __LINE__。
如果你还在用 C++17 或更早的标准,我建议你考虑升级到 C++20。光是为了 source_location 和 std::span 这几个小工具,就值得了。嗯,当然还有 concepts 和 ranges 这些大杀器——不过那是另一章的内容了。
一句话记住:std::source_location::current() 是编译器给你的「当前位置快照」,用它代替宏,干净又安全。