看看如下的两段代码,其一是:

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 上,很容易搜到,但这里懒一下。

🔚

留下评论