看看如下的两段代码,其一是:
1
2
3
4
5
#include <iostream>
int main() {
std::cout << "Hello, World!"
<< '\n';
}
其二,尤其是很多的网络上示例,书籍教程中的示例都是这样的:
1
2
3
4
#include <iostream>
int main() {
std::cout << "Hello, World!" << std::endl;
}
那么,谁对谁错?
内部实现
探讨上述代码的内部实现是解决问题的唯一途径。不过,这个问题实际上包含了很多内涵,所以下面我们也会抛开问题本身,针对多数的可能的衍生出来的场景进行一番梳理。
首先看看字符字面量形式,
'\n'
- 字符字面量
std::cout << '\n'
将被编译器解释为 operator<<
的一种重载形式,重载到下面的原型:
1
2
3
4
5
6
template<class _Traits>
basic_ostream<char, _Traits>&
operator<<(basic_ostream<char, _Traits>& __os, char __c)
{
return _VSTD::__put_character_sequence(__os, &__c, 1);
}
从表面上看,这仿佛是隐含了一层 basic_ostream<char, _Traits>
的构造函数的调用,带来了额外的开销,但实际上并没有。在汇编级查看生成的代码,这里无需隐式地构造一个 basic_ostream<char, _Traits>
临时对象,而是直接来到 _VSTD::__put_character_sequence(__os, &__c, 1)
环节,单独而直接地向输出流写入一个 char 类型的 '\n'
字符。
“\n”
- 字符串字面量
std::cout << "\n"
有类似的表现。它将被编译器解释为 operator<<
的一种重载形式,重载到下面的原型:
1
2
3
4
5
6
template<class _Traits>
basic_ostream<char, _Traits>&
operator<<(basic_ostream<char, _Traits>& __os, const char* __str)
{
return _VSTD::__put_character_sequence(__os, __str, _Traits::length(__str));
}
同样地,这里也无需额外的临时对象构造,只需直接地将 C-style字符串(即一个 0 结尾的字符串,有时候也用术语 asciiz-string 来表达)写入输出流即可。
std::string 形式
std::cout << std::string("\n")
将会调用 std::string
的关于 const char*
的构造函数来生成一个临时对象,然后采用 operator<<(std::string const &o)
重载形式来输出该临时对象。类似于如下的代码:
1
2
std::string t1("\n");
std::cout << t1;
由于产生了到 std::string
的对象构造,因此它是带来了额外开销的,在 CPU 和 Memory 上有双重的额外消耗。
在多数场合,这种额外消耗是可以忽略的,因为它们(额外的数百个 CPU 时钟周期以及字符串本身的尺寸的双倍长度的内存占用,外加数个内部指针所消耗的额外内存)实在是太微小了。
std::string 内部会使用到数个参考指针,它们有的是真的 C++ 指针,有的是下标索引量,但每一个这样的指针数据都占用 CPU 字长或者内存总线宽度的地址,对于 Intel 64-bit CPU 来说,通常它是 8 个字节大小。对于其它 CPU 来说,多数情况下也都相当于该 CPU 的字长,所以对于 32-bit CPU 来说,一个这样的指针数据占用 4 字节内存。
除非,当编译器能够优化所有这些指针到寄存器中时,那么在大多数情况下它们将不占据额外的内存。
但是由于机器指令仍需装载到内存中才能被执行,所以无论有无寄存器优化,它们实质上还是会消耗内存。
但是如果你正在编写的代码处于时间、性能、容量敏感的场景中时,那么就应该避免增多这样的隐式 std::string 对象构造,在能够使用 const char* 或者 const char[n] 的时候尽可能使用字符串的字面量。
如果字面量本身较大,尺寸较长,那么构造 std::string
时带来的额外开销就越大,这也就更不被推荐。
std::endl
如果你使用 std::cout << std::endl;
语句,那么它相当于如下的代码:
1
2
std::cout << '\n';
std::cout << std::flush;
对于输出设备来说,操作系统维持一个写缓冲区,这个缓冲区的尺寸也可以被 Stdc 或者 Stdc++ 库所重定义。
当你在写出到 cout 时,内容被放在该缓冲区中,所以你并不能立即在屏幕上看到它们。直到缓冲区满或者不足以容纳下一次写入内容时,它才会被真正地写到输出设备,这个真正写入的操作,即 flush。
此时,屏幕上才会显示我们写入的内容。
当程序退出前,Stdc/Stdc++ 也会隐含地 flush 所有输出设备。所以最后时刻虽然你没有显式地调用 flush 又没有写满缓冲区,但你曾经的写入内容也不会在屏幕上不被呈现——它们还是会被正确地打印出来。
如果你频繁地发出 endl 或者 flush,输出设备可能会很不满意,此时它反而可能成为性能瓶颈。
所以,在 Release 版本中都会关闭调试信息的屏幕输出,如果需要日志输出都会改为日志文件。
原因就在于屏幕显示是慢速的、不可推测的。
由于屏幕显示的复杂性,特别是当你使用 SSH 登录到一个远程 tty 时,屏幕显示的性能就是随时可变,无法预测的。
但是对于并发程序、多线程、多核心的场景来说,明确地控制何时 flush 就是一种关键性问题了,这既是作为程序员所必须掌握的能力,也是程序员所以是程序员、他所具有的操控能力的证明。而且,它(不确定的 flush)甚至足以破坏接收端地算法。
例如典型的 TCP 编程中,如果写出方不在报文结束时 flush,那么接收端就不能完整地接收到该报文。这就意味着接收端不可以做出报文总是完整地的假设,所以它就必须明确地进行粘包切分工作。反之,如果报文总是不会超过一个固定长度,那么保证每个报文结束时 flush,如此一来接收端就总是能收到这个报文的完整内容,因此这时候的接收端的代码逻辑将得到大幅度的简化。
小结
作为一个证明,上面的代码核心的汇编输出可以到这里去查阅:
https://godbolt.org/z/aEx8azTo9
总的来说,你应该
- 从不使用
std::string("...")
,缩减临时对象构造的可能性 - 尽可能在写入至输出流时直接使用字符串字面量以及字符字面量,也即
'\n'
,"Hello"
等等 - 在需要的时候使用
std::cout << std::flush;
或者std::cout.flush()
来显式地刷新写缓冲区,例如让此前的输出内容立即被呈现在屏幕上。
针对 ‘\n’ 还是 std::endl 的问题,最佳实践是
- 从不使用
std::cout << std::endl
的形式,改为 ‘\n’ - 在需要的时候使用
std::cout << std::flush;
或者std::cout.flush()
来显式地刷新写缓冲区,例如让此前的输出内容立即被呈现在屏幕上。
在使用 ‘\n’ 还是 “\n” 的问题上,最佳建议是
- 尽可能一律使用 ‘\n’,甚至是从字符串字面量的尾部将其拆分出来
- 如果可能,则不应使用 “\n”,它需要额外的内存占用,并且还隐式地包含了一个 ‘\0’ 结尾
是否应该防止多次 operator<<
调用?
1
2
std::cout << "Hello\n";
std::cout << "Hello" << '\n';
出于经验上的提示,我们认为这样时合乎时宜的:如果你的一片代码中,会频繁遇到上述形式的字符串字面量输出,作为一个良好的代码风格而言,我们推荐第二种形式,将 ‘\n’ 单独出来。这会带来击键上的少许麻烦以及函数调用次数的增多,但在运行时仍是有所收益的,且保持了风格上的统一和潜在的收益。
一种潜在收益是,当你在遇到代码重构时,例如将所有这些输出语句替换为一个包装过后的函数、或者宏,你可能会需要去除最后一个 ‘\n’,此时也许你能够借助于查找、替换来让重构更为省力。
但是不管怎么说,保持风格统一是总的原则,不宜混用上面的两种形式,以免带来额外的负担。
后记
实际上是偶然翻到一篇 post 在讨论 ‘endl’ 问题,想到自己编码时渐渐形成的那些认识,故而觉得可以梳理一遍。
post 在 SO 上,很容易搜到,但这里懒一下。
留下评论