从C++Primer某习题出发,谈谈C语言标准I/O的缓存问题
剛看完信號那章,覺得處理信號時的sigsetjmp/siglongjmp似乎跟異常的跳出很像,于是想去復習C++異常,然后發現了對I/O沒有充分理解的問題。
題目是C++ Primer 5.6.3節的練習5.25,描述如下:
1、從標準輸入讀取2個整數, 輸出第1個整數除以第2個整數的結果。
2、如果第2個整數為0,拋出異常;
3、用try語句塊捕捉異常,catch語句中為用戶輸出一條提示信息,詢問是否輸入新數并重新執行try語句塊的內容。
于是我隨手一寫,就寫出了這樣的代碼
#include <stdio.h> #include <stdexcept>int main() {int x, y;while (1) {try {fputs("input two numbers: ", stdout);scanf("%d %d", &x, &y);if (y == 0)throw std::runtime_error("除數為0!");printf("%d / %d = %d\n", x, y, x / y);}catch (std::exception& e) {fputs(e.what(), stderr);fputs("是否重新輸入?[Y/n] ", stdout);char ch = getchar();if (ch == 'Y' || ch == 'y')continue;}break;}return 0; }調試看看,在getchar()下面加一句printf("%d\n", ch);后重新運行,會發現打印的是10(ACSII碼中換行符'\n'對應的是10)
也就是說getchar()不需要等待我們輸入就獲取了字符。那么這個換行符是怎么來的呢?
哦,剛才輸入了"1 0"后是按了回車,然后scanf才執行。scanf讀到第2個int對應字符串部分('0')終止就不再讀了,也就是'\n'并沒有讀進去。而標準I/O庫采取了緩存策略,標準輸入的字符都放在一個字符串數組內,比如我剛才輸入1、空格、0、Enter時,在標準輸入(stdin)對應的FILE結構中,它的緩存(可以看做一個字符數組)是這樣的
'1', ' ', '0', '\n', '\0', '\0', ...
FILE結構有個指向當前位置的指針(注:下文中的指針均默認指代這個指針),最初是指向'1'的,然后進行scanf,讀第2個int時,指針指向'0',然后讀取'0',指針右移,此時指向'\n',不是一個數字,開始分析scanf讀到的2個int對應字符串"1"和"0"并且轉換成int存入x和y的地址(&x和&y)中。
結果就是,指針指向的是'\n',調用getchar()時,標準輸入的緩存中已經有字符,那么直接取出即可。只有在標準輸入的指針已經到達緩存非'\0'字符的末尾(即所謂字符數組風格字符串的末尾),才會阻塞進程并且等待用戶輸入,用戶的輸入會填入緩存,然后getchar()取得指針指向的字符。
回到這里,指針指向'\n',那么getchar()就會把它取出來并返回,然后指針右移。因此我們需要接收到用戶新輸入的字符,需要像這樣
getchar(); // 取出剛才的換行符 char ch = getchar();如果熟悉庫函數fflush(),很可能會采用fflush(stdin);的方式來取代getchar(),意思就是沖刷標準輸入的緩存。
看似可行,但是,標準輸入不同于標準輸出(stdout)和標準錯誤(stderr),后兩者被沖刷的話,指針右移直到字符串末尾,然后右移過程中的字符被輸出到屏幕上(雖然這么說,但實際上是一次系統調用打印出來)。也不同于打開普通文件(txt等等)的FILE*,沖刷它們會把字符串輸出到文本中。
那么,標準輸入又能輸出到哪呢?
POSIX.1-2001 did not specify the behavior for flushing of input streams, but the behavior is specified in POSIX.1-2008.
在POSIX.1-2001標準中,沖刷輸入流的行為是未定義的。雖然POSIX新標準定義了其行為,我沒有具體查看,但是在Ubuntu 16.04 gcc 5.4.0下,用-std=gnu++11編譯得到的結果并不是我們期望的那樣。盡管網上能搜到很多C語言考題會考fflush(stdin),還是VC6.0環境(我就不多說了,點到即止)
?
本來像上面那樣更改代碼后就OK了,但是健壯性較好的做法是只判斷第1個字符即可,后面的字符隨便輸入,比如卸載軟件的命令
我輸入了yabcd wufq ue這一段瞎按的字符串,只有首字母為y,但是卸載程序仍然執行了。
那么我的程序是否也能如此呢?
僅僅是輸入了2個字符,結果不僅重新輸入了一些信息,還直接返回了。
來分析一下程序的執行流程:
1、我輸入了yy,此時從指針指向的位置起,緩存字符是'\n', 'y', 'y';
2、getchar()讀取'\n',第2個getchar()讀取'y'返回并賦值給字符ch,然后if語句判斷ch是否為'Y'或'y'
3、if語句為真,執行continue;跳過while循環中剩余代碼(即break;),重新進入while循環。
就此打住,注意,現在stdin的緩存是'y',而scanf會根據格式化字符串"%d %d"讀取,也就是首先要讀1個int,如果碰到正負號和數字之外的字符會怎樣呢?
把代碼的scanf那句改成下面這樣,檢查返回值(scanf的返回值為成功格式化寫入的變量個數)
int n = scanf("%d %d", &x, &y); if (n != 2) {fprintf(stderr, "scanf實際讀取int的數量: %d\n", n);return 1; }運行結果如下
實際上碰到數字、正負號(還有空白字符)之外的字符就會返回,因為格式化輸入已經不合法了。
關于printf和scanf的具體實現,主要是利用了C語言的可變參數類型va_list,具體可以參考C語言的經典教材《C程序設計語言》作者是丹尼斯·里奇(Dennis Ritchie),C語言之父&UNIX之父。7.3節 變長參數表里面提供了一份簡化版printf的實現。
如果自己動手試著實現下,對printf/scanf的理解會更深刻。
?
于是回到問題,那我們該怎么解決呢?一個自然而然想到的方法是像剛才getchar()一樣,把stdin的緩存全部讀完,即在if語句之前加上
while (getchar() != '\n') { }但是這會有調用函數的開銷,比如我輸入了10000個字符,那么就要調用getchar() 10000次。函數調用次數過多的話,開銷就不能忽視了,因為每次函數調用都伴隨著參數的入棧、出棧,函數棧幀的建立和銷毀。
但是從性能的角度,可以采取更好的方法
char buf[BUFSIZ]; while (!fgets(buf, sizeof(buf), stdin)) { }那就是減少函數調用的次數,每次獲取BUFSIZ個字符,這樣輸入10000個字符的話只需要調用函數10000 / BUFSIZ次。
?
從實踐的角度看,這種優化在這里其實沒有必要,首先,沒有誰那么無聊輸入這么多字符,頂多不小心多按了幾個字母。比如手滑按Enter鍵時把旁邊的鍵給按下了。其次,這個程序本身就非常簡單,甚至都不用考慮效率。
但是了解這些是有意義的??丛创a不是為了重復造輪子,重復造輪子也不是僅僅為了重復造輪子,而是加深對底層實現的理解。既然選擇了C/C++,就不得不去面對名為“效率”的怪物,不得不去了解底層實現。
?
最后再補充一點,C語言標準I/O庫在終端I/O上默認是行緩沖,標準I/O庫其實也要從應用態切換到內核態去調用內核的read/write等函數,10000次用戶函數調用的開銷也許不大,但是10000次上下文切換的開銷就不小了。內核的I/O也有自己的一套緩存。所謂行緩沖,就是輸入換行符時,一次性把目前為止輸入/輸出的所有字符進行I/O,也就是每讀取一行(只要這一行不是特別特別長)只進行1次系統調用(system call)。(參考《Unix環境高級編程》)
因此每次輸入換行符時,才把鍵盤輸入的字符串一次性給搬運到內存中,然后scanf從頭開始分析字符串。
轉載于:https://www.cnblogs.com/Harley-Quinn/p/6741677.html
總結
以上是生活随笔為你收集整理的从C++Primer某习题出发,谈谈C语言标准I/O的缓存问题的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 字符串在内存中的存储——C语言进阶
- 下一篇: 集合中常用算法总结