4

“使用 ‘\n’ 还是 std::endl,以及相关问题”

 1 year ago
source link: https://hedzr.com/c++/algorithm/use-slash-n-instead-of-std-endl/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

#include <iostream>
int main() {
  std::cout << "Hello, World!"
            << '\n';
}
#include <iostream>
int main() {
  std::cout << "Hello, World!"
            << '\n';
}

其二,尤其是很多的网络上示例,书籍教程中的示例都是这样的:

#include <iostream>
int main() {
  std::cout << "Hello, World!" << std::endl;
}
#include <iostream>
int main() {
  std::cout << "Hello, World!" << std::endl;
}

那么,谁对谁错?

内部实现Permalink

探讨上述代码的内部实现是解决问题的唯一途径。不过,这个问题实际上包含了很多内涵,所以下面我们也会抛开问题本身,针对多数的可能的衍生出来的场景进行一番梳理。

首先看看字符字面量形式,

'\n' - 字符字面量Permalink

std::cout << '\n' 将被编译器解释为 operator<< 的一种重载形式,重载到下面的原型:

template<class _Traits>
basic_ostream<char, _Traits>&
operator<<(basic_ostream<char, _Traits>& __os, char __c)
{
    return _VSTD::__put_character_sequence(__os, &__c, 1);
}
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” - 字符串字面量Permalink

std::cout << "\n" 有类似的表现。它将被编译器解释为 operator<< 的一种重载形式,重载到下面的原型:

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));
}
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 形式Permalink

std::cout << std::string("\n") 将会调用 std::string 的关于 const char* 的构造函数来生成一个临时对象,然后采用 operator<<(std::string const &o) 重载形式来输出该临时对象。类似于如下的代码:

std::string t1("\n");
std::cout << t1;
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::endlPermalink

如果你使用 std::cout << std::endl; 语句,那么它相当于如下的代码:

std::cout << '\n';
std::cout << std::flush;
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,如此一来接收端就总是能收到这个报文的完整内容,因此这时候的接收端的代码逻辑将得到大幅度的简化。

小结Permalink

作为一个证明,上面的代码核心的汇编输出可以到这里去查阅:

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<< 调用?

std::cout << "Hello\n";
std::cout << "Hello" << '\n';
std::cout << "Hello\n";
std::cout << "Hello" << '\n';

出于经验上的提示,我们认为这样时合乎时宜的:如果你的一片代码中,会频繁遇到上述形式的字符串字面量输出,作为一个良好的代码风格而言,我们推荐第二种形式,将 ‘\n’ 单独出来。这会带来击键上的少许麻烦以及函数调用次数的增多,但在运行时仍是有所收益的,且保持了风格上的统一和潜在的收益。

一种潜在收益是,当你在遇到代码重构时,例如将所有这些输出语句替换为一个包装过后的函数、或者宏,你可能会需要去除最后一个 ‘\n’,此时也许你能够借助于查找、替换来让重构更为省力。

但是不管怎么说,保持风格统一是总的原则,不宜混用上面的两种形式,以免带来额外的负担。

后记Permalink

实际上是偶然翻到一篇 post 在讨论 ‘endl’ 问题,想到自己编码时渐渐形成的那些认识,故而觉得可以梳理一遍。

post 在 SO 上,很容易搜到,但这里懒一下。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK