拿到一份陌生的C++工程,我最先干的事往往不是翻主流程,而是打开Logger.h和Logger.cc这两个文件从头到尾读一遍。原因很简单:日志模块是整个工程的地基,它直接暴露了写代码的人对线程安全、性能、可维护性的态度。读懂了它,后面再看业务代码会顺畅得多,因为你会知道这个项目的代码风格、错误处理习惯、甚至线程模型的脾气。
这篇内容就是把一次真实读码过程完整拆给你看。Logger.h和Logger.cc这种成对命名的文件,在中小型C++项目里非常典型——不用第三方库,自己封装一套日志。这类模块解决的核心问题有三个:统一日志输出格式、分级过滤日志信息、在多线程环境下保证输出不串行不丢失。适合想自己动手封装日志库的开发者,也适合正在啃某个陌生项目源码、想快速摸清项目底细的朋友。我不会只给你看“这个类有几个函数”,而是把每个设计决策背后的理由、实现时的取舍、以及我在真实工程里踩过的坑一并讲清楚。
1. 为什么一套自带Logger.h和Logger.cc的日志模块值得逐行读
1.1 这种文件命名的出现语境
在GitHub上随便搜一下C++项目,你会看到大量以Logger.h、Logger.cc命名的配对文件。这种朴素命名通常不是某个框架的标准产物,而是项目作者从零开始写的自研模块。选这种命名的人,一般有几个共同特点:不想引入额外依赖、对第三方日志库的功能用不满足、或者单纯想保持整个项目代码风格的统一。
我见过有些项目用log.h、logging.cpp这种叫法,但Logger.h和Logger.cc是更常见的配对。头文件负责对外暴露接口,源文件负责实现细节,经典的C++分离编译习惯。如果你在一个工程里同时看到这两个文件,基本可以断定:这个项目有一套自己掌控的日志基础设施,而不是随便调个printf了事。
这套东西的价值在于它是纯黑盒的观察窗口。通过它你能快速判断作者对三个问题的态度:第一,日志级别设计得细不细,决定了他对系统运行状态的重视程度;第二,多线程输出有没有加锁,决定了他对并发正确性的理解;第三,有没有把__FILE__和__LINE__拼进日志里,决定了他排查问题的习惯。
1.2 日志模块承载的三个核心任务
一套合格的Logger至少要接住三层压力。
第一层是格式化输出。把时间、线程ID、日志级别、文件名、行号、具体消息拼成一行可读的文本。看起来简单,实际处处是坑。时间戳怎么取能兼顾性能和精准度?线程ID用什么接口拿才是真正的内核线程号?文件行号怎么自动化带入而不是每次手写?这些细节决定了日后的排查效率。
第二层是分级过滤。程序在DEBUG阶段要打印海量调试信息,到了生产环境这些信息就是灾难。一套成熟的Logger必须支持编译期或运行期的级别开关,让低级日志在不需要的时候直接蒸发掉,而不是到输出端再去判断。
第三层是并发安全。线上程序八成是多线程的,如果两个线程同时写日志,日志行被拦腰截断、互相穿插,那这日志基本就没法看了。常见的做法是互斥锁、线程局部缓冲区、或者异步日志队列。你去看Logger.cc里锁放在哪个位置,就能猜到作者在并发这一块花了多少心思。
1.3 阅读顺序建议:头文件到源文件再到调用点
我读这种模块的顺序非常固定:先扫一遍Logger.h拿到全部对外接口,把类的成员变量、成员函数、枚举定义在脑子里过一遍;再看Logger.cc里最难懂的那几个函数,通常是析构函数和格式化输出相关的部分;最后去项目里搜几个调用点,看这个Logger在实际业务代码里是怎么被用的,尤其是宏怎么展开的。
这套顺序的好处是能建立“接口-实现-使用”三层映射。只读头文件你会漏掉很多实现细节的考量,只读源文件你会困惑为什么设计成这个接口,只看调用点你又会觉得代码莫名奇妙。三遍连起来,才算是真正读懂了。
2. Logger.h里的接口设计:从枚举、宏到核心类的分工
2.1 日志级别枚举:代码量最小但影响最大的决定
大多数自研Logger的起点都是一个日志级别枚举,我见过最典型的是这样:
enum LogLevel { DEBUG = 0, INFO, WARN, ERROR, FATAL };别小看这个看似简单的枚举,后面所有逻辑都围着它转。级别数值从低到高排列是我见过的主流做法,DEBUG最低,FATAL最高。这样做有两个直接好处:比较大小来判断是否需要输出,只需一行代码;FATAL级别还能顺带触发abort或exit,因为它的数值一定大于其他所有级别。
有些项目会再加一个TRACE级别放在DEBUG前面,或者加一个OFF级别放在最后用来彻底关闭日志。加TRACE的场景一般是算法调试、协议解析这种需要极致细节的场景;加OFF一般是为了在某些特殊进程里一键静音。不过级别越多,过滤判断的分支也会越多,对性能敏感的项目要谨慎。
这里有一个很隐蔽的设计决策:枚举的取值用的是隐式递增的整数,而不是位掩码。如果你看到有人把级别定义成1、2、4、8这种位掩码,那说明他打算用“按位与”的方式同时开启多个级别。但日志级别本质上是分段递增关系,用位掩码反而会让“级别大于等于WARN才输出”这种需求难写。所以我个人强烈建议:看到整数递增枚举,是好设计的概率远大于位掩码。
2.2 用宏包装Logger对象的原因与代价
几乎每个自研Logger都会在头文件里提供一组宏,比如这样:
#define LOG_DEBUG Logger(Logger::DEBUG, __FILE__, __LINE__).stream() #define LOG_INFO Logger(Logger::INFO, __FILE__, __LINE__).stream() #define LOG_WARN Logger(Logger::WARN, __FILE__, __LINE__).stream() #define LOG_ERROR Logger(Logger::ERROR, __FILE__, __LINE__).stream()为什么非要用宏,而不是直接提供一个Logger对象然后Logger.info("...")?关键原因在于__FILE__和__LINE__这两个魔法标识符。它们是由预处理器展开的,如果你写成普通函数调用,无论怎么传参都拿不到调用点的文件行号,只能拿到Logger.cc自己的文件行号,那日志的定位价值就彻底废了。
宏的代价也很明显:全局命名空间污染。LOG_INFO这种名字一旦被这个头文件定义,整个项目里就不能再有同名标识符。如果两个库都定义了LOG_INFO,轻则编译报错重则宏覆盖。所以你会看到一些项目给宏加前缀,比如LOG_DEBUG还是XLOG_DEBUG还是APP_LOG_DEBUG,前缀越长越安全,但也越难写。
另一个容易被忽略的代价是宏无法定义作用域。这在后面的日志级别过滤章节我再展开讲,简单说就是:宏展开之后生成的临时Logger对象,生命周期完全由表达式边界决定,你无法用普通的if - else逻辑去控制它,必须在宏内部想办法。
2.3 Logger类的私有成员与禁用拷贝的设计
日志级别、时间戳、文件行号这些东西放在哪里,直接反映了作者的设计思路。我见过把文件行号作为成员变量存下来的:
class Logger { public: Logger(LogLevel level, const char* file, int line); ~Logger(); std::ostream& stream(); private: LogLevel level_; const char* file_; int line_; std::ostringstream stream_; };注意这个类的设计有几个共性。第一,构造函数接收文件名指针和行号,但源文件里并不会立刻输出日志,只是把信息暂存起来。第二,类内部有一个ostream流对象,调用者拿到这个流之后用<<运算符拼装消息。第三,几乎所有自研Logger都禁用了拷贝构造和赋值操作,或者干脆不声明,因为它内部持有流对象,复制一份的开销毫无意义。
我特别提醒你注意file_这个成员的类型。很多人容易写成std::string file_,如果那样的话,每个日志对象构造时都要做一次字符串拷贝,__FILE__这种字符串通常不短,在高频日志场景下这个开销会被放大。成熟做法是用const char*直接保存字面量指针,因为__FILE__的生命周期是整个程序运行期,不存在悬垂问题。
2.4 为什么选择返回流对象而不是直接输出
第一眼看到.stream()这个接口,很多人会困惑:为什么不直接在构造函数里把日志打出来,非要返回一个流让调用者再走一步?这里藏着一个关键的设计意图:临时对象的析构时机。
Logger(Logger::INFO,FILE,LINE)构造了一个临时对象,然后调用stream()拿到内部流,再被<<运算符连续写入。这个表达式结束的时候,也就是分号处,临时对象析构。析构函数里做真正的输出——把级别前缀、文件行号、消息内容拼成一行写出去。
这样设计的好处是接口极其自然,调用者可以像用cout一样写日志,不用关心释放时机。析构函数总能拿到你通过<<写入的全部内容,因为它发生在表达式完全结束之后。你可以把它理解为:构造函数是开场,<<是表演,析构函数是谢幕,真正的观众只有谢幕时才到场。
如果你见过另一种写法——构造函数里直接printf,流接口只是做个摆设——那多半是半成品。真正的自研Logger核心逻辑一定在析构函数里,这一点去读Logger.cc的时候会被反复验证。
3. Logger.cc的实现取舍:时间戳、线程标志与IO输出
3.1 时间戳本地化:localtime_r与缓存策略
日志里没有时间戳等于没有灵魂。但时间戳怎么拿,以及拿到之后怎么转成字符串,这里面的讲究不少。
最常见的是用time(nullptr)拿秒级时间,再用localtime_r转成struct tm结构体。注意,一定要用localtime_r而不是localtime,因为后者内部用了一个静态共享缓冲区,多线程下并发调用会产生数据竞争,两个线程的时间会互相覆盖。这是C标准库里的老陷阱,初写日志模块的人十有八九会踩。
真正值得深入聊的是性能优化。localtime_r本身并不便宜,它要做时区换算、闰年判断、月份天数计算。如果每次写日志都调一次,在高频日志的热点路径上,这会成为不小的开销。所以一些讲究性能的Logger会在对象内部只存原始时间戳,真正格式化延迟到析构时的输出函数里;更激进的做法是每秒缓存一次格式化好的时间字符串前缀,如果两条日志落在同一秒内,直接复用缓存字符串。
我翻过一份性能调优记录:在某个压测场景下,把每秒缓存策略引入之后,日志模块本身的开销下降了差不多三分之一。代价是日志里看不到毫秒级精度,最高精度只到秒。如果你需要毫秒时间戳,那就要用到gettimeofday或clock_gettime,同时缓存策略要做更细的时间分段,复杂度会上一个台阶。
3.2 线程ID的获取方式:系统调用与标准库的差异
日志里带上线程ID,是为了在分析多线程问题时能分辨哪些日志来自哪个线程。这一步看似简单,实际容易踩坑的点在于——不同接口拿到的“线程ID”概念不一样。
| 接口 | 返回内容 | 特点 |
|---|---|---|
| pthread_self() | pthread_t(通常是结构体) | 不能直接打印,需转unsigned long,但数值可能很大甚至重复 |
| syscall(SYS_gettid) | 内核线程ID | 输出简洁、唯一,但每次调用都是系统调用开销 |
| std::this_thread::get_id() | std::thread::id | 可哈希,但标准库没提供数值打印接口,需自己转换 |
我在代码里见过最流行的做法是先调用syscall(SYS_gettid)拿一次内核线程ID,然后存在一个thread_local变量里。因为线程的ID在生命周期内是不变的,没必要每次写日志都做一次系统调用。thread_local使得每个线程只需初始化一击,后续访问都是零成本。这个设计可以说是典型的“每次少花一次系统调用”思路。
当然也有偷懒项目直接用pthread_self()然后强转成unsigned long打印。这样在早期的glibc实现上能得到一个还算简洁的数字,但到了新版系统上,pthread_t地址可能是一个指针值,打印出来是十几位的巨大长整型,日志行被拉得老长,看着很吃力。如果你在别人的Logger.cc里看到这种写法,心里要清楚这不是严谨方案。
3.3 日志输出的锁粒度:整行锁还是分片锁
多线程并发写日志,最怕两件事:内容穿插和性能下降。前者是说两条日志的字节混在一行里,后者是说因为加锁导致写日志的线程排队。
最简单的方案是在Logger析构函数入口处加一把全局互斥锁,把整行日志的格式化加输出全部锁住:
std::mutex& log_mutex() { static std::mutex m; return m; } Logger::~Logger() { std::lock_guard<std::mutex> lock(log_mutex()); // 格式化并输出 }这个方案的核心逻辑是“整行锁”:拿到锁之后才算完成日志串、再一次性write出去。因为同一时刻只有一个线程能进入临界区,日志行不可能穿插。代价是临界区里有格式化操作,耗时偏长,但换来的是实现简单、逻辑一目了然。
有些项目为了减少锁竞争,会先把格式化好的字符串放到一个线程局部缓冲区里,最后才在锁内做一次fwrite或write。这样临界区被压缩到只有一次IO调用的长度,多线程竞争窗口小得多。这种优化在锁竞争严重的场景下收益明显,但会让代码结构复杂起来——你需要额外维护一个线程局部缓冲对象,并处理缓冲区刷新的时机。如果是对性能有要求的项目,这个复杂度值得付。
3.4 不同输出目标:标准输出、文件句柄与缓冲刷新
Logger.cc里最后一段通常是输出逻辑。输出到stdout是最省事的选择,直接fprintf或者std::cout<<。但生产环境里这个选择有个问题:stdout默认是全缓冲还是行缓冲取决于环境,在非交互终端下往往是全缓冲,日志会积累在缓冲区里,进程崩溃时缓冲区内容丢失。如果是发布到后台服务,日志应该直接落到文件。
落文件的方式差别也很大。最粗暴的写法是每次写一条日志就fopen一次文件再fclose。这个写法在低频日志场景下没什么问题,但在高频场景下,频繁打开关闭文件句柄的开销非常可观。成熟做法是Logger对象内部持有一个FILE*成员,构造时fopen,析构时fclose。注意,一个Logger临时对象对应一次日志输出,所以如果你对每条日志都临时开文件,那本质上还是每次开关句柄,只是把开关动作藏到了构造析构里,并没有解决性能问题。
真正合理的方案是有一个全局的文件句柄,比如static FILE* file,整个进程生命周期只打开一次,每个Logger析构时fwrite进去。如果担心缓冲区把日志“吃掉”,可以在每次写入之后顺手fflush,或者干脆用open/write这种不带用户态缓冲的IO接口。fflush会牺牲一些吞吐量,但保证每条日志实时落盘,换来的排查价值远超那点性能损耗。
4. 读源码时容易卡住的三个设计点:析构写日志、级别过滤、宏展开
4.1 真正写日志的地方为什么是析构函数
我第一次完整看这类Logger.cc时,最大的困惑就是:写日志的代码怎么跑到了析构函数里。直到我把调用点的代码展开才恍然大悟。
展开后的调用是:
Logger(Logger::INFO, __FILE__, __LINE__).stream() << "user login success, uid=" << uid;这里发生的事情是:构造Logger临时对象,调用stream()返回内部流,然后连续调用operator<<,最后分号出现时临时对象析构。也就是说,写入日志的全部内容都保存在stream_这个ostringstream里,只有到析构时才能取到完整内容。
如果构造函数里就输出,那输出的一定是不完整的流内容;如果提供一个专门的log()方法,调用者必须手动在表达式末尾调用它,写起来就啰嗦了。析构函数方案真正聪明的地方,是把“日志内容聚合”和“日志落盘”两个阶段天然地做了时序分离。这个惯用法在C++里叫RAII——资源获取即初始化,资源释放即处理。日志内容本身被当作一种资源,析构就是统一处理它的时机。
理解了这一点,你就明白为什么Logger类的拷贝构造函数必须禁用了——如果临时对象被复制一份,两个对象的析构都会输出一次相同内容,日志就重复了。
4.2 日志级别过滤放在哪一层的性能差异
很多初版的Logger会在析构函数里判断级别。也就是无论调用方写的是LOG_DEBUG还是LOG_ERROR,都会完整构造Logger对象、拼装字符串,最后一刻才决定丢弃还是输出。这种写法实现简单,但性能很差,因为DEBUG日志在INFO级别下运行时,白白浪费了字符串拼接开销。
更讲究的实现会把级别判断提到宏内部,让不满足条件的日志连对象都不用构造:
#define LOG_DEBUG \ if (Logger::currentLevel() <= Logger::DEBUG) \ Logger(Logger::DEBUG, __FILE__, __LINE__).stream()这样展开之后,如果当前级别是INFO,DEBUG对象根本不会被创建,流拼接代码完全不会执行,开销接近于零。注意这里有个陷阱:宏展开后的if语句如果没有配套的else,可能会和调用者代码里的else产生“悬空else”匹配问题。所以严谨的日志宏会把整个判断包在do{}while(0)里,但那样又没法返回流对象继续拼日志了。
这是个结构性问题。我见过比较优雅的折中方案是:宏里还是保留if判断,但不带else;调用者用的时候注意不要把LOG_DEBUG直接放在if/else分支下。或者提供两个宏版本,一个带过滤的用于常规场景,一个不带过滤的用于强制输出。这种设计细节看代码就能看出作者的功力。
4.3FILE、__LINE__怎么通过宏带进对象
为什么自研Logger里方法级的代码永远只看到const char*和int两个参数,但日志输出里却出现了文件名和行号?答案就是前面提到的宏做参数代填。预处理器在展开宏时,会用调用点的__FILE__和__LINE__字面量替换到宏定义体里,所以构造函数总能拿到真实调用位置。
关于__FILE__,有个常见优化值得提一下。在大型项目里,文件的绝对路径可能很长,比如/home/xxx/project/src/module/a/b/logger.cc。把完整路径打进日志里会让日志行变得冗长。一些Logger会设计一个basename处理函数,std::strrchr(file_, '/')截出文件名部分。还有更激进的思路编译期做字符串压缩映射,用一个短ID替代文件名,日志量大的时候能显著降低存储成本。
至于__LINE__,它就是一个普通的整型字面量。但有个细节:同一行代码如果被多个线程同时执行,打出来的行号是一样的,光靠行号没法区分具体是哪个调用点。所以有经验的排查者会同时看文件、行号、线程ID、加上消息内容,四者结合才能精确定位。
5. 实际使用的踩坑记录:乱序、卡顿与格式化崩溃
5.1 多线程日志乱序的排查链路
我曾经在某个并发压测场景里看到过一种诡异现象:日志文件里A线程输出的消息跑到B线程日志的中间,把一行日志拦腰截断,看起来像这样:
[INFO][12345] user 请求处理开 [WARN][12346] 用户态缓冲 始,耗时=120ms一眼就能看出是线程穿插导致的串行输出。排查过程是这样的:先怀疑Logger析构的锁没加好,但读一遍源码发现确实有全局锁;再怀疑锁被某个线程长期持有没有释放,用调试工具挂上去看锁等待情况,也没发现问题;最终在日志内容中加了当前线程ID字段,把两条拼接出一行的日志的线程ID打出来,才发现其实是两个线程各自把半行日志交给了同一个输出流缓冲区,而输出流的内部操作本身并不是线程安全的。
也就是说,锁锁住了Logger对象,但输出用的FILE或ostream对象没有锁保护。一个线程在构造格式化字符串,另一个线程同时写FILE,两者交错就会裂行。修复方式要么把FILE*写入也纳入同一把锁,要么改用带原子性的write调用,每次把整行日志作为一个buffer一次性写出去,避免多次零散写入。
这个坑提醒我:读Logger.cc时,看到“加了锁”只是第一步,重点是锁到底保护了哪段代码、锁的粒度和输出IO的字节性是不是匹配。
5.2 高并发下同步IO导致的性能瓶颈
另一个印象深刻的坑是压测时发现日志模块吃掉了大量CPU。现象是整个服务的吞吐量上不去,CPU占用却接近打满。用性能分析工具采样后,热点集中指向日志库的fwrite调用。
原因分析下来是这样的:Logger析构时每个日志都直接fwrite到文件,而fwrite本身涉及用户态到内核态的切换。如果日志量达到每秒几万条,单线程串行fwrite就成了性能瓶颈。更难缠的是,每次fwrite都在持锁状态下,其他线程等待锁的时间也暴涨,等于全局串行化。
类似场景下的有效优化分几步走。第一,文件输出用open/write代替fwrite,减少用户态缓冲层;第二,日志内容先拼装到线程局部缓冲区,攒够一批再一次性写入;第三,更彻底的方案是做异步日志线程,日志线程只负责把数据丢进无锁队列,后台线程批量刷盘。第三种方案能大幅降低日志对业务线程的影响,但实现复杂度明显上升,需要处理队列满、日志丢弃策略、有序性问题。
5.3 类型不匹配导致的格式化崩溃
日志库最普遍的崩溃事故,是调用者手滑把格式化参数的类型搞错了。比如:
int ret_code = -1; LOG_INFO << "request failed, code=" << ret_code << ", reason=" << reason;这里reason是一个std::string,但在一些老式Logger里,如果内部用vsnprintf和%s去格式化,而实际传入的是std::string而不是const char*,就会导致读到无效内存,轻则输出乱码,重则直接段错误。这类问题在流式接口里其实可以天然规避,因为std::ostream的operator<<对每种类型都有重载,类型不匹配在编译期就报错了。
所以我的经验是:自研Logger一定要坚持流式接口,别为了追求“性能”去搞printf风格的格式化模板,否则等于把类型安全这个最大的优势丢掉了。真对性能有极端要求,也要在流式接口之下做严格的类型检查或者自定义高性能流缓冲,而不是退回到C风格的格式化。
还有一个容易忽略的崩溃点是错误码的errno取值。有些Logger会在每条日志后面附带打印errno,但如果LOG_INFO这个调用本身改变了errno,打印出来的值就不是调用者预期的那个。正确的做法是在进入Logger构造的瞬间就把errno快照存下来,析构输出时再读取快照。这个细节写不写进代码里,排查线上问题时差距很大。
6. 把这套Logger扩展成生产可用模块的四个方向
6.1 运行时日志级别开关
默认写死的日志级别只有在改代码时才能调整,这显然不够灵活。生产可用的Logger至少要支持运行期动态调整级别。
实现方式不复杂:定义一个全局原子变量,比如std::atomic g_logLevel,宏过滤判断从直接调用静态函数改为读取这个原子变量。这样在业务运行时就能用一个控制接口调整级别,比如收到某个管理信号后把DEBUG开关打开,排查完问题再关掉。关键是要用原子操作,避免多线程读到中间态。
有些项目会做得更细致,支持按模块设置级别。比如网络模块保持INFO,业务模块调成DEBUG,全局级别默认ERROR。这种需求就要把级别存储从单一变量改成按模块名索引的一张表。数据结构复杂度上去了,但排查问题的效率也实打实提升了。
6.2 按大小和天数的滚动文件
日志文件无限增长是生产环境的灾难。只按大小滚动或者只按天数滚动都有缺陷,所以成熟的方案是双条件:大小先到就滚动,日期变了也滚动。
滚动逻辑可以放在日志写入的函数末尾,每次写入后检查当前文件大小是否超限,或者时间是否跨天。如果超限,关闭当前文件句柄,把旧文件重命名为带日期或序号的归档名,再打开一个新文件。这里有个性能细节:检查文件大小如果每次都调用fseek/stat,会增加一次系统调用。更聪明的做法是维护一个累计字节数变量,每次写入多少字节就累加多少,达到阈值才触发一次真正的文件属性查询。
还有归档文件的命名和清理策略。比如保留最近7天的日志,超过就删除。这套机制在你引用的第三方库里往往已经内置,但在自研模块里经常被忽略。我在实际项目里见过一个服务跑了一年,日志目录占用了几十GB磁盘空间才被报警,就是因为滚动和清理策略没做全。
6.3 异步日志队列与背压处理
同步日志阻塞业务线程的问题,靠优化IO效果有限,终极大招是异步化。基本结构是:业务线程只负责把格式化好的日志消息丢进一个队列,后台有一个专门的日志线程从这个队列取数据进行真正的磁盘写入。业务线程几乎不感知IO耗时。
设计这个队列时有几个决策点。第一,队列是有界还是无界?无界队列会导致内存无限增长触发OOM;有界队列则要处理生产速度大于消费速度时的背压问题。常见策略是丢弃日志或者丢弃旧日志,并累加一条丢弃计数,在后续日志里警告一次。第二,队列是锁队列还是无锁队列?加锁队列实现简单,但锁竞争在高吞吐下又成为瓶颈;无锁队列需要处理内存序和ABA问题,实现难度高很多,但吞吐收益明显。
对于不追求极限性能的项目,我的建议是:先用带互斥锁的std::deque实现,确认能扛住业务峰值流量再说。过早引入无锁队列,往往是给自己挖调试大坑。
6.4 编译期移除DEBUG日志
最后这个方向和性能相关,但更关乎代码整洁:在编译期彻底移除DEBUG日志。
实现的思路是在宏定义里结合预处理指令:
#ifdef NDEBUG #define LOG_DEBUG \ if (false) \ Logger(Logger::DEBUG, __FILE__, __LINE__).stream() #else #define LOG_DEBUG \ if (true) \ Logger(Logger::DEBUG, __FILE__, __LINE__).stream() #endif如果条件编译设置了NDEBUG,那么LOG_DEBUG展开成if(false),编译器优化阶段会把里面的代码完全删掉,连字符串字面量都不留痕迹。这样发布的二进制文件里就没有调试日志的字符串,既能减小体积,又避免了日志内容泄漏业务细节。
这么做有一点要注意:如果某个日志语句里有副作用表达式,比如LOG_DEBUG << "value=" << getValue(),在NDEBUG下getValue()的调用会被删除。如果你依赖日志语句触发某个计算,代码行为就会在Debug和Release之间产生差异。这不算Logger自身的问题,但设计宏的时候要意识到这个语义变化。
最后再分享一个我自己的习惯:读完Logger.cc之后,我通常会在自己的工程里写一个最小复刻版本,只保留级别过滤、时间戳、线程ID三个功能。别小看这个精简版,它能逼你把最核心的几个设计决策重新想一遍——析构时机、锁粒度、宏过滤放哪一层。等你把这三个问题想透了,再去看那些几百上千行的商业级日志框架,你就能一眼认出它们每一处设计在解决什么问题,而不是被表面的复杂度吓退。这正是读Logger.h和Logger.cc最值得收获的东西。