使用流式输出写调试日志

日志是一种有效的调试手段。但是日志写得太频繁会降低程序性能,所以一般采取的策略是,大部分日志只在调试版的程序中输出,少量重要的日志才在发行版的程序中输出。为了控制调试日志的输出,通常会使用下面的简便方法:

1
2
3
4
5
#ifdef DEBUG
#define DLOG(format, ...) printf(format, __VA_ARGS__)
#else
#define DLOG(format, ...)
#endif

上面的代码定义了一个DLOG宏,当处于调试模式时,DLOG会展开成对printf函数(或者类似函数)的调用;否则什么也不做。使用这个宏之后,调试日志对发行版程序没有任何影响。

但是,由于printf固有的特点,使用这种方式写日志存在一些缺陷。最主要的是,格式化字符串中的形参与实参的个数和类型必须一致,否则,要么输出的信息不正确,要么程序直接崩溃。这种问题只有在真正执行的时候才显现出来,假如错误的DLOG调用位于深层次的条件语句中,那么它很可能会一直隐藏在那里。

不要觉得这种低级错误不会发生。根据实际经验来看,写了一句日志导致程序崩溃的尴尬情况还是挺常见的。

C++标准库使用的流式输出解决了上述缺陷。所谓流式输出,就像下面这样:

1
std::cout << "Today is " << 2015 << '-' << 8 << '-' << 29 << '.';

这种方式不需要事先定义格式字符串,不必再担心实参个数和类型的匹配问题。更重要的是,如果实参不能被输出,在编译阶段就会出错,而不是等到运行的时候才出错。流式输出使用上更简便,安全性更高,假如能使用这种方式来写日志,无疑会提高开发效率。

流式输出的使用方式与printf截然不同,显然不能再使用可变参数宏来定义DLOG了。所以接下来的问题是,如何定义DLOG以支持流式输出。要是不能做到在发行版程序中完全消除调试日志的影响,那么它也只是一个不实用的花瓶。

这个问题看起来似乎很难,实际上并非如此。早已有人找到了解决方法——在Chromium中就是使用流式输出写日志的。Chromium是开源项目,可以直接从它的代码中寻找答案。不过它的相关代码中封装了太多功能,理解起来并不容易。在经过一番抽丝剥茧之后,这里还原出它的核心思想。

首先,根据是否处于调试模式定义ENABLE_DEBUG_LOG宏,这个宏仅仅是布尔值的简单替换。

1
2
3
4
5
#ifdef DEBUG
#define ENABLE_DEBUG_LOG true
#else
#define ENABLE_DEBUG_LOG false
#endif

接下来,把DLOG宏定义成一个问号表达式,为了便于理解,把这个定义分成多行。

1
2
3
4
#define DLOG()                 \
! ENABLE_DEBUG_LOG ? \
(void)0 : \
Vodify() & std::cout

问号表达式首先判断一下是否启用调试日志。如果不启用,则执行第一个分支,把0转换成void,也就是什么都不做。这是一种罕见的语法,可以把任意表达式转型为void。如果启用,则执行第二个分支,创建一个Vodify对象,并调用它的&操作符。下面是Vodify类的定义:

1
2
3
4
class Vodify {
public:
void operator&(const std::ostream&) { }
};

Vodify类是一个辅助类,它只有一个空的operator&方法,其作用仅仅是为了使问号表达式两个分支的返回值相同。由于前面一个分支的返回值是void,第二个分支的返回值也必须是void,否则编译不通过。

一个提供了<<操作符的输出流对象必须放在最后面,这是为了接收后面的输出参数。在这里简单使用了std::cout对象。Vodify的operator&方法必须能够接收这个对象的引用。

现在,可以这样来使用DLOG:

1
DLOG() << "Today is " << 2015 << '-' << 8 << '-' << 29 << '.';

这句代码展开之后变成了下面这样:

1
2
3
! ENABLE_DEBUG_LOG  ?
(void)0 :
Vodify() & std::cout << "Today is " << 2015 << '-' << 8 << '-' << 29 << '.';

如果ENABLE_DEBUG_LOG为true,那么第二个分支会执行。<<操作符的优先级比&操作符高,后面的一串<<调用会先执行,最后再调用Vodify对象的&操作符,使整个表达式的返回值为void。

如果ENABLE_DEBUG_LOG为false,那么第二个分支永远都不会执行,无论它有多复杂。这是在编译期间就可以确定的,在发行版的程序中这部分代码完全可以优化掉,也就达到了调试日志不影响发行版程序的目的。

基于以上的核心思想,可以衍变出各种不同的版本来支持更丰富的功能,例如日志等级、日志分类等。C++中有很多小技巧,使用得当的话对程序开发有很大帮助。