使用流式输出写调试日志
日志是一种有效的调试手段。但是日志写得太频繁会降低程序性能,所以一般采取的策略是,大部分日志只在调试版的程序中输出,少量重要的日志才在发行版的程序中输出。为了控制调试日志的输出,通常会使用下面的简便方法:
1 |
上面的代码定义了一个DLOG宏,当处于调试模式时,DLOG会展开成对printf函数(或者类似函数)的调用;否则什么也不做。使用这个宏之后,调试日志对发行版程序没有任何影响。
但是,由于printf固有的特点,使用这种方式写日志存在一些缺陷。最主要的是,格式化字符串中的形参与实参的个数和类型必须一致,否则,要么输出的信息不正确,要么程序直接崩溃。这种问题只有在真正执行的时候才显现出来,假如错误的DLOG调用位于深层次的条件语句中,那么它很可能会一直隐藏在那里。
不要觉得这种低级错误不会发生。根据实际经验来看,写了一句日志导致程序崩溃的尴尬情况还是挺常见的。
C++标准库使用的流式输出解决了上述缺陷。所谓流式输出,就像下面这样:
1 | std::cout << "Today is " << 2015 << '-' << 8 << '-' << 29 << '.'; |
这种方式不需要事先定义格式字符串,不必再担心实参个数和类型的匹配问题。更重要的是,如果实参不能被输出,在编译阶段就会出错,而不是等到运行的时候才出错。流式输出使用上更简便,安全性更高,假如能使用这种方式来写日志,无疑会提高开发效率。
流式输出的使用方式与printf截然不同,显然不能再使用可变参数宏来定义DLOG了。所以接下来的问题是,如何定义DLOG以支持流式输出。要是不能做到在发行版程序中完全消除调试日志的影响,那么它也只是一个不实用的花瓶。
这个问题看起来似乎很难,实际上并非如此。早已有人找到了解决方法——在Chromium中就是使用流式输出写日志的。Chromium是开源项目,可以直接从它的代码中寻找答案。不过它的相关代码中封装了太多功能,理解起来并不容易。在经过一番抽丝剥茧之后,这里还原出它的核心思想。
首先,根据是否处于调试模式定义ENABLE_DEBUG_LOG宏,这个宏仅仅是布尔值的简单替换。
1 |
接下来,把DLOG宏定义成一个问号表达式,为了便于理解,把这个定义分成多行。
1 |
问号表达式首先判断一下是否启用调试日志。如果不启用,则执行第一个分支,把0转换成void,也就是什么都不做。这是一种罕见的语法,可以把任意表达式转型为void。如果启用,则执行第二个分支,创建一个Vodify对象,并调用它的&操作符。下面是Vodify类的定义:
1 | class Vodify { |
Vodify类是一个辅助类,它只有一个空的operator&方法,其作用仅仅是为了使问号表达式两个分支的返回值相同。由于前面一个分支的返回值是void,第二个分支的返回值也必须是void,否则编译不通过。
一个提供了<<操作符的输出流对象必须放在最后面,这是为了接收后面的输出参数。在这里简单使用了std::cout对象。Vodify的operator&方法必须能够接收这个对象的引用。
现在,可以这样来使用DLOG:
1 | DLOG() << "Today is " << 2015 << '-' << 8 << '-' << 29 << '.'; |
这句代码展开之后变成了下面这样:
1 | ! ENABLE_DEBUG_LOG ? |
如果ENABLE_DEBUG_LOG为true,那么第二个分支会执行。<<操作符的优先级比&操作符高,后面的一串<<调用会先执行,最后再调用Vodify对象的&操作符,使整个表达式的返回值为void。
如果ENABLE_DEBUG_LOG为false,那么第二个分支永远都不会执行,无论它有多复杂。这是在编译期间就可以确定的,在发行版的程序中这部分代码完全可以优化掉,也就达到了调试日志不影响发行版程序的目的。
基于以上的核心思想,可以衍变出各种不同的版本来支持更丰富的功能,例如日志等级、日志分类等。C++中有很多小技巧,使用得当的话对程序开发有很大帮助。