Loading... <div class="tip inlineBlock info"> 本文针对的对象是CPer(competitive programmer),对于熟悉C++的同学来说以下内容是非常基础的。 </div> 算法竞赛选手在使用`cin`、`cout`进行输入输出的时候,为了防止读写被卡常,往往会加上以下两行优化: ```cpp ios_base::sync_with_stdio(false); cin.tie(nullptr); ``` 大家都知道这两行代码写完以后使用`cin`和`cout`进行读写,效率就跟使用`scanf`和`printf`相差无几。但,<span style='color:Brown'>为什么呢?</span> * 以及,既然可以通过这种方式几乎抹平`cin`、`cout`和`scanf`、`printf`的效率差距,那么**为什么C++要默认一种开销更大的方式**呢? * 在没有写这两行代码的时候,使用`cin`和`cout`进行输入输出时**进行了什么额外的工作**? * 这两行代码又**具体实现了什么**呢? 以下我们逐步解释这些问题。 ## 01 缓冲区 首先要理解的是**缓冲区**的概念。缓冲区对输入输出非常重要。 首先我们要知道,**输入输出**操作相比于算术或逻辑运算,**时间开销是非常大**的。原因在于此类操作并不是CPU的局部可以完成的,无论是向打印机、屏幕还是硬盘输出文字,都需要通过对应的总线访问去实现。这就好像是邻居之间串门和跨省旅游之间的区别。 ![](https://zclll.com/usr/uploads/2022/03/2835555318.png) 例如以下这段代码: ```cpp cout<<1+2; cout<<2+3; cout<<3+4; ``` 它最大的时间开销完全不在算术运算上,而是在通过`cout`流将运算结果输出到屏幕上。 因此,我们就要想办法优化这种情况。 硬件上如何加速不归我们管,我们只考虑代码层面是否有可以优化的地方。这里就要用到一个性质——有很多输入输出操作对**实时性的要求是很低**的。原因在于这些操作的交互对象是人类,例如屏幕的输出、文件的输出等。此类操作具有一定的延迟,人类是无法察觉的,也并不在乎。(当然,输出信息到供其他程序使用的文件或共享池中的情形,就不在此列了) 那么,我们就可以引入一个思想: **既然向外设写入开销很大,那么我们就减少这种写入的次数。** 具体方法来说,**我们可以设立一个内存池,将要输出的东西全部存起来,等到合适的时机再输出**。 例如,上述代码改写成 ```cpp a=1+2; write_to_device(a); a=2+3; write_to_device(a); a=3+4; write_to_device(a); ``` 的话,需要3次总线访问,那么设立一个缓冲区: ![image.png](https://zclll.com/usr/uploads/2022/03/1849919544.png) 代码就变成了: ```cpp int buffer[n]; a=1+2; buffer[++len] = a; a=2+3; buffer[++len] = a; a=3+4; buffer[++len] = a; write_to_device(buffer); ``` 只需要一次总线访问。单次`write_to_device()`开销对于操作数大小是不敏感的,所以这段代码可以几乎将时间缩减为原先的$1/3$。 对于C++输出流`cout`来说,实际上使用的是`flush()`来进行`write_to_device()`这种将缓冲区内容输出至对应设备的操作——并称之为**刷新**。对于C标准输出流`stdout`来说,对应的做法是`fflush(stdout)`。 ## 02 刷新的时机 我们知道,将越多次输出内容暂存到缓冲区内,就可以减少越多不必要的总线访问,提升程序效率。那么这种行为是可以无止境做下去的么?显然不行。接下来我们就需要探讨,什么时候**必须**进行缓冲区刷新。 ### 0201 缓冲区满 显然,缓冲区的大小不能是无限的,否则一方面会增大不必要的内存开销,另外会导致一些情况下的风险增大。 例如,当我们有大量数据要输出时: ```cpp for (int i = 1; i <= 1000000; i++) cout<<a[i]; ``` 此时,如果程序意外中断,则有大量原本应被记录的数据丢失。而即使不出现这种情况,内存负担也是很大的。 综合以上考量,我们应当设计一个不过分大的缓冲区大小,然后在缓冲区满时输出。 ![image.png](https://zclll.com/usr/uploads/2022/03/2986305849.png) (大概这样.jpg <div class="tip inlineBlock success"> (没什么卵用的)新黑科技——缓冲区大小 </div> 那么,**缓冲区大小**这个参数,在使用`stdin`和`stdout`时(它的含义我们会在后面提到),也会影响到程序输入输出的效率——这很显然。如果我们知道一种更合适的大小,那么可以通过以下方式手动指定它,以提高输入输出效率: ```cpp setvbuf(stdin, NULL, _IONBF, 0); setvbuf(stdout, NULL, _IOFBF, 100000); ``` 以上代码的含义是,使`stdin`无缓冲(不经过缓冲区),且设定其缓冲区大小为0;使`stdout`全缓冲(缓冲区满或手动操作时再刷新),缓冲区大小为100000——具体情形下的具体数值,有待实验。 其中,`_IONBF`、`_IOLBF`、`_IOFBF`分别指代无缓冲、行缓冲(遇到换行则刷新)、全缓冲。 ### 0202 互动 另一个,也是最重要的刷新缓冲区的动机就是互动需求。“缓冲区”之所以能够成立,就是因为有些输出在当时不会被立刻使用。例如输出到硬盘的数据,显示在屏幕上的数据(人眼无法分辨若干条指令之间的间隙)。 但当输出的数据立刻就要被其他主体使用的时候,这种及时性需求就使得程序输出不能再依赖缓冲,而是要尽最快速度输出到对应设备。例如交互题中总有这样的说法: ![image.png](https://zclll.com/usr/uploads/2022/03/3933239294.png) 就是因为交互器需要得到你的输出之后才能做出对应反应。此时我们是无法利用缓冲区的。 接下来,我们看一段正常的人机交互代码: ```cpp int main() { string username, password; cout<<"Please enter your name: "; //24 chars cin>>username; cout<<"Please enter your password: "; //28 chars cin>>password; ...... } ``` 此时我们假设缓冲区大小为30byte,也就是可以容纳30个char,没有额外的输出缓冲刷新机制,那么会发生什么呢? 1. 空命令行等待用户输入,用户一脸懵逼 2. 用户随意输入什么,回车 3. 界面显示“Please enter your name:”,但username已经被赋值为刚才用户随意输入的内容。 4. 同时,下面紧接着显示“Please enter your password:” 5. 用户一脸懵逼。 这说明了一个重要的事实——**在用户应该意识到当前程序的期望进展时,输出应当和它被期待的表现一致**。 之前我们可以利用缓冲区延迟输出,是凭借着程序运行有着远大于人眼所能识别的速度这一条件。那么,当程序以中断的形式将时间交给用户的时候,所有缓冲下来的输出必须送到它们应有的位置。 那么,我们应该能想到,像是`cin`这种将程序中断给用户的行为,应当在进行前刷新输出缓冲区——这是非常自然的做法。 事实上,C++的实现也的确是如此做的——方法是:将`cin`流与`cout`流(的缓冲区)绑定,在调用前者前刷新后者: ![image.png](https://zclll.com/usr/uploads/2022/03/2588379976.png) 所以,我们就知道了 ```cpp cin.tie(nullptr); ``` 的含义了——解除`cin`和`cout`的绑定。它所消除掉的,是使用`cin`读入前刷新输出缓冲区的开销。 ### 0203 适宜的时机 其他规定刷新缓冲区的时机,主要是一些约定俗成的原理了,主要是为了贴合人类的理解。这类刷新更类似于程序员在认为合适的时候手动调用了`flush()`。这里,主要指的是`endl`。 `endl`和`'\n'`同样是输出换行,但`endl`会刷新缓冲,而`'\n'`只是单纯的换行,这主要是人为原因: * 如字面所见,`'\n'`只是一个单纯的符号而已 <div class="tip inlineBlock warning"> 注意,无论任何标准与实现中,均未规定`'\n'`与刷新缓冲区有任何特定联系$^{[1]}$ </div> * `endl`包含了“本行已经结束”的语义,提示“应该把本行内容给用户看了” 这也就是为什么有这行火车头的原因: ```cpp #define endl '\n' ``` 另一种典型的情况是,错误流`cerr`是无缓冲的,因为错误信息应当在发生错误时立刻准确反馈给用户,不适用缓冲。 ## 03 C流与C++流 还有一点需要知道的是,C和C++输入输出所使用的环境是不同的,虽然同样是向屏幕输出,但C环境使用的是`stdin`和`stdout`两个流,而C++使用的是`cin`和`cout`两个流(当然,这两个环境都还有其他流)。 而这两个环境中的流是什么关系呢?见下图: ![image.png](https://zclll.com/usr/uploads/2022/03/871546408.png) 可以看到,默认情况下,C流和C++流是直接关联的: ![image.png](https://zclll.com/usr/uploads/2022/03/387616366.png) 这样,通过C流的`scanf`和`printf`和通过C++流的`cin`和`cout`,虽然使用不同的对象进行操作,但是由于路径是相同的,所以不必担心输出混乱。 而我们注意到,如果我们只使用C++流的话,再通过C流的buffer去“桥接”一遍,开销很大。那么我们能否断开这种连接,让C++流直接与对应ABI相连呢? 答案就是我们的优化头的第一句: ```cpp ios_base::sync_with_stdio(false); ``` 现在再来考虑它为何能够提速?重新画图一目了然: ![image.png](https://zclll.com/usr/uploads/2022/03/3930259758.png) 此时C++流“饶了近路”。 那么,关闭流同步后再同时使用两种输入输出方式,因为它们**各自有自己的缓冲机制**,输入输出错乱也就可以理解了。 * 如果既要同时使用,又要关闭同步,也是可以的——但那势必要设置二者同时无缓冲,那就与我们的初衷背道而驰了。 为加深理解,读者可以自行推理一个问题,以下两行代码,是否有依赖关系? ```cpp ios_base::sync_with_stdio(false); cin.tie(nullptr); ``` 答案是:**有**。 <span style='color:Red'>第一行代码单独存在有用,**第二行代码单独存在没用**。</span> 原因通过上图很好理解:C++流与C流同步时,不自主管理缓冲区,缓冲区是由`stdin`和`stdout`管理的。只有关闭同步时,C++的流自行管理其缓冲区,此时才会有“`cin`先刷新`cout`缓冲区再读取”的现象,此时取消这种行为才有意义。 ## 04 一些小细节 * 虽然没有标准没有约束,但有时`'\n'`确实会导致刷新缓冲区。这是由于`stdout`存在一种“行缓冲”$^{\#0201}$模式导致的,而存在这样一件事:![image.png](https://zclll.com/usr/uploads/2022/03/1525068586.png),所以不同环境下会出现针对这一情景的不同行为。但<span style='color:Red'>因为它不是标准约定,所以不要依赖这种实现!</span> * C++流是如何保证状态和缓冲区终末刷新的? * `#include<iostream>`会初始化其中的静态对象`cin`和`cout`,它们可以保有状态。它们析构于程序终止时,此时会最后清空缓冲区。 * `sync_with_stdio()`调用前,IO流上**不能**有任何操作。 * ……想起来再说 ## 05 总结 看完以上内容我们可以知道,C++中所有开销巨大的设计(除了`unordered_map`和`unordered_set`!)都有它存在的道理。而在CP中我们采取一些手段消除这些开销也是有道理的:因为我们的代码面向的是judger和checker,而并非人类,所以不需要考虑**可读性**和**鲁棒性**。因此,我们可以通过去除那些**提高程序鲁棒性**的代码来提高效率。这是CP火车头(包括快读在内)的核心原理。 此时回顾文首的几个问题,相信大家可以自行回答了。 ## 参考文献 [1] https://stackoverflow.com/questions/213907/stdendl-vs-n 以及各种来自cppreference的资料、现行C、C++标准。 © 允许规范转载 打赏 赞赏作者 赞 9 如果觉得我的文章对你有用,请随意赞赏