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 知识结构 std::source_location 设计动机 替代 __FILE__ / __LINE__ 消除宏的副作用 类型安全、可封装 核心能力 file_name() function_name() line() / column() 典型应用:日志 / 断言 / 异常追踪

总结

std::source_location 是个小工具,但很实用。它解决了 C++ 里一个长期存在的痛点:如何优雅地获取源码位置。我个人觉得,它最大的价值不在于「能做什么」,而在于「不用再做什么」——不用再写那些丑陋的宏,不用再担心宏展开的坑,不用再手动拼接 __FILE____LINE__

如果你还在用 C++17 或更早的标准,我建议你考虑升级到 C++20。光是为了 source_locationstd::span 这几个小工具,就值得了。嗯,当然还有 conceptsranges 这些大杀器——不过那是另一章的内容了。

一句话记住:std::source_location::current() 是编译器给你的「当前位置快照」,用它代替宏,干净又安全。