# 第?6?章?循環語句
**目錄**
+ [1\. while語句](ch06s01.html)
+ [2\. do/while語句](ch06s02.html)
+ [3\. for語句](ch06s03.html)
+ [4\. break和continue語句](ch06s04.html)
+ [5\. 嵌套循環](ch06s05.html)
+ [6\. goto語句和標號](ch06s06.html)
## 1.?while語句
在[第?3?節 “遞歸”](ch05s03.html#func2.recursion)中,我們介紹了用遞歸求n!的方法,其實每次遞歸調用都在重復做同樣一件事,就是把n乘到(n-1)!上然后把結果返回。雖說是重復,但每次做都稍微有一點區別(`n`的值不一樣),這種每次都有一點區別的重復工作稱為迭代(Iteration)。我們使用計算機的主要目的之一就是讓它做重復迭代的工作,因為把一件工作重復做成千上萬次而不出錯正是計算機最擅長的,也是人類最不擅長的。雖然迭代用遞歸來做就夠了,但C語言提供了循環語句使迭代程序寫起來更方便。例如`factorial`用`while`語句可以寫成:
```
int factorial(int n)
{
int result = 1;
while (n > 0) {
result = result * n;
n = n - 1;
}
return result;
}
```
和`if`語句類似,`while`語句由一個控制表達式和一個子語句組成,子語句可以是由若干條語句組成的語句塊。
語句?→?while?(控制表達式)?語句
如果控制表達式的值為真,子語句就被執行,然后再次測試控制表達式的值,如果還是真,就把子語句再執行一遍,再測試控制表達式的值……這種控制流程稱為循環(Loop),子語句稱為循環體。如果某次測試控制表達式的值為假,就跳出循環執行后面的`return`語句,如果第一次測試控制表達式的值就是假,那么直接跳到`return`語句,循環體一次都不執行。
變量`result`在這個循環中的作用是累加器(Accumulator),把每次循環的中間結果累積起來,循環結束后得到的累積值就是最終結果,由于這個例子是用乘法來累積的,所以`result`的初值是1,如果用加法累積則`result`的初值應該是0。變量`n`是循環變量(Loop Variable),每次循環要改變它的值,在控制表達式中要測試它的值,這兩點合起來起到控制循環次數的作用,在這個例子中`n`的值是遞減的,也有些循環采用遞增的循環變量。這個例子具有一定的典型性,累加器和循環變量這兩種模式在循環中都很常見。
可見,遞歸能解決的問題用循環也能解決,但解決問題的思路不一樣。用遞歸解決這個問題靠的是遞推關系n!=n·(n-1)!,用循環解決這個問題則更像是把這個公式展開了:n!=n·(n-1)·(n-2)·…·3·2·1。把公式展開了理解會更直觀一些,所以有些時候循環程序比遞歸程序更容易理解。但也有一些公式要展開是非常復雜的甚至是不可能的,反倒是遞推關系更直觀一些,這種情況下遞歸程序比循環程序更容易理解。此外還有一點不同:看[圖?5.2 “factorial(3)的調用過程”](ch05s03.html#func2.factorial),在整個遞歸調用過程中,雖然分配和釋放了很多變量,但所有變量都只在初始化時賦值,沒有任何變量的值發生過改變,而上面的循環程序則通過對`n`和`result`這兩個變量多次賦值來達到同樣的目的。前一種思路稱為函數式編程(Functional Programming),而后一種思路稱為命令式編程(Imperative Programming),這個區別類似于[第?1?節 “程序和編程語言”](intro.program.html "1.?程序和編程語言")講的Declarative和Imperative的區別。函數式編程的“函數”類似于數學函數的概念,回顧一下[第?1?節 “數學函數”](ch03s01.html#func.mathfunc)所講的,數學函數是沒有Side Effect的,而C語言的函數可以有Side Effect,比如在一個函數中修改某個全局變量的值就是一種Side Effect。[第?4?節 “全局變量、局部變量和作用域”](ch03s04.html#func.localvar)指出,全局變量被多次賦值會給調試帶來麻煩,如果一個函數體很長,控制流程很復雜,那么局部變量被多次賦值也會有同樣的問題。因此,不要以為“變量可以多次賦值”是天經地義的,有很多編程語言可以完全采用函數式編程的模式,避免Side Effect,例如LISP、Haskell、Erlang等。用C語言編程主要還是采用Imperative的模式,但要記住,_給變量多次賦值時要格外小心,在代碼中多次讀寫同一變量應該以一種一致的方式進行_。所謂“一致的方式”是說應該有一套統一的規則,規定在一段代碼中哪里會對某個變量賦值、哪里會讀取它的值,比如在[第?2.4?節 “errno與perror函數”](ch25s02.html#stdlib.errno)會講到訪問`errno`的規則。
遞歸函數如果寫得不小心就會變成無窮遞歸,同樣道理,循環如果寫得不小心就會變成無限循環(Infinite Loop)或者叫死循環。如果`while`語句的控制表達式永遠為真就成了一個死循環,例如`while (1) {...}`。在寫循環時要小心檢查你寫的控制表達式有沒有可能取值為假,除非你故意寫死循環(有的時候這是必要的)。在上面的例子中,不管`n`一開始是幾,每次循環都會把`n`減掉1,`n`越來越小最后必然等于0,所以控制表達式最后必然取值為假,但如果把`n = n - 1;`這句漏掉就成了死循環。有的時候是不是死循環并不是那么一目了然:
```
while (n != 1) {
if (n % 2 == 0) {
n = n / 2;
} else {
n = n * 3 + 1;
}
}
```
如果`n`為正整數,這個循環能跳出來嗎?循環體所做的事情是:如果`n`是偶數,就把`n`除以2,如果`n`是奇數,就把`n`乘3加1。一般來說循環變量要么遞增要么遞減,可是這個例子中的`n`一會兒變大一會兒變小,最終會不會變成1呢?可以找個數試試,例如一開始`n`等于7,每次循環后`n`的值依次是:7、22、11、34、17、52、26、13、40、20、10、5、16、8、4、2、1。最后`n`確實等于1了。讀者可以再試幾個數都是如此,但無論試多少個數也不能代替證明,這個循環有沒有可能對某些正整數`n`是死循環呢?其實這個例子只是給讀者提提興趣,同時提醒讀者寫循環時要有意識地檢查控制表達式。至于這個循環有沒有可能是死循環,這是著名的3x+1問題,目前世界上還無人能證明。許多世界難題都是這樣的:描述無比簡單,連小學生都能看懂,但證明卻無比困難。
### 習題
1、用循環解決[第?3?節 “遞歸”](ch05s03.html#func2.recursion)的所有習題,體會遞歸和循環這兩種不同的思路。
2、編寫程序數一下1到100的所有整數中出現多少次數字9。在寫程序之前先把這些問題考慮清楚:
1. 這個問題中的循環變量是什么?
2. 這個問題中的累加器是什么?用加法還是用乘法累積?
3. 在[第?2?節 “if/else語句”](ch04s02.html#cond.ifelse)的習題1寫過取一個整數的個位和十位的表達式,這兩個表達式怎樣用到程序中?
## 2.?do/while語句
`do/while`語句的語法是:
語句?→?do?語句?while?(控制表達式);
`while`語句先測試控制表達式的值再執行循環體,而`do/while`語句先執行循環體再測試控制表達式的值。如果控制表達式的值一開始就是假,`while`語句的循環體一次都不執行,而`do/while`語句的循環體仍然要執行一次再跳出循環。其實只要有`while`循環就足夠了,`do/while`循環和后面要講的`for`循環都可以改寫成`while`循環,只不過有些情況下用`do/while`或`for`循環寫起來更簡便,代碼更易讀。上面的`factorial`也可以改用`do/while`循環來寫:
```
int factorial(int n)
{
int result = 1;
int i = 1;
do {
result = result * i;
i = i + 1;
} while (i <= n);
return result;
}
```
寫循環一定要注意循環即將結束時控制表達式的臨界條件是否準確,上面的循環結束條件如果寫成`i < n`就錯了,當`i == n`時跳出循環,最后的結果中就少乘了一個`n`。雖然變量名應該盡可能起得有意義一些,不過用`i`、`j`、`k`給循環變量起名是很常見的。
## 3.?for語句
前兩節我們在`while`和`do/while`循環中使用循環變量,其實使用循環變量最見的是`for`循環這種形式。`for`語句的語法是:
for?(控制表達式1;?控制表達式2;?控制表達式3)?語句
如果不考慮循環體中包含`continue`語句的情況(稍后介紹`continue`語句),這個`for`循環等價于下面的`while`循環:
```
控制表達式1;
while (控制表達式2) {
語句
控制表達式3;
}
```
從這種等價形式來看,控制表達式1和3都可以為空,但控制表達式2是必不可少的,例如`for (;1;) {...}`等價于`while (1) {...}`死循環。C語言規定,如果控制表達式2為空,則認為控制表達式2的值為真,因此死循環也可以寫成`for (;;) {...}`。
上一節`do/while`循環的例子可以改寫成`for`循環:
```
int factorial(int n)
{
int result = 1;
int i;
for(i = 1; i <= n; ++i)
result = result * i;
return result;
}
```
其中`++i`這個表達式相當于`i = i + 1`<sup>[[9](#ftn.id2726856)]</sup>,++稱為前綴自增運算符(Prefix Increment Operator),類似地,--稱為前綴自減運算符(Prefix Decrement Operator)<sup>[[10](#ftn.id2726883)]</sup>,`--i`相當于`i = i - 1`。如果把`++i`這個表達式看作一個函數調用,除了傳入一個參數返回一個值(等于參數值加1)之外,還產生一個Side Effect,就是把變量`i`的值增加了1。
`++`和`--`運算符也可以用在變量后面,例如`i++`和`i--`,為了和前綴運算符區別,這兩個運算符稱為后綴自增運算符(Postfix Increment Operator)和后綴自減運算符(Postfix Decrement Operator)。如果把`i++`這個表達式看作一個函數調用,傳入一個參數返回一個值,返回值就等于參數值(而不是參數值加1),此外也產生一個Side Effect,就是把變量`i`的值增加了1,它和`++i`的區別就在于返回值不同。同理,`--i`返回減1之后的值,而`i--`返回減1之前的值,但這兩個表達式都產生同樣的Side Effect,就是把變量`i`的值減了1。
使用++、--運算符會使程序更加簡潔,但也會影響程序的可讀性,[[K&R]](bi01.html#bibli.kr "The C Programming Language")中的示例代碼大量運用++、--和其它表達式的組合使得代碼非常簡潔。為了讓初學者循序漸進,在接下來的幾章中++、--運算符總是單獨組成一個表達式而不跟其它表達式組合,從[第?11?章 _排序與查找_](ch11.html#sortsearch)開始將采用[[K&R]](bi01.html#bibli.kr "The C Programming Language")的簡潔風格。
我們看一個有意思的問題:`a+++++b`這個表達式如何理解?應該理解成`a++ ++ +b`還是`a++ + ++b`,還是`a + ++ ++b`呢?應該按第一種方式理解。編譯的過程分為詞法解析和語法解析兩個階段,在詞法解析階段,編譯器總是從前到后找最長的合法Token。把這個表達式從前到后解析,變量名`a`是一個Token,`a`后面有兩個以上的+號,在C語言中一個+號是合法的Token(可以是加法運算符或正號),兩個+號也是合法的Token(可以是自增運算符),根據最長匹配原則,編譯器絕不會止步于一個+號,而一定會把兩個+號當作一個Token。再往后解析仍然有兩個以上的+號,所以又是一個++運算符。再往后解析只剩一個+號了,是加法運算符。再往后解析是變量名`b`。詞法解析之后進入下一階段語法解析,`a`是一個表達式,表達式++還是表達式,表達式再++還是表達式,表達式再+b還是表達式,語法上沒有問題。最后編譯器會做一些基本的語義分析,這時就有問題了,++運算符要求操作數能做左值,`a`能做左值所以`a++`沒問題,但表達式`a++`的值只能做右值,不能再++了,所以最終編譯器會報錯。
C99規定了一種新的`for`循環語法,在控制表達式1的位置可以有變量定義。例如上例的循環變量`i`可以只在`for`循環中定義:
```
int factorial(int n)
{
int result = 1;
for(int i = 1; i <= n; i++)
result = result * i;
return result;
}
```
如果這樣定義,那么變量`i`只是`for`循環中的局部變量而不是整個函數的局部變量,相當于[第?1?節 “if語句”](ch04s01.html#cond.if)講過的語句塊中的局部變量,在循環結束后就不能再使用`i`這個變量了。這個程序用`gcc`編譯要加上選項`-std=c99`。這種語法也是從C++借鑒的,考慮到兼容性不建議使用這種寫法。
* * *
<sup>[[9](#id2726856)]</sup> 這兩種寫法在語義上稍有區別,詳見[第?2.1?節 “復合賦值運算符”](ch16s02.html#op.compound)。
<sup>[[10](#id2726883)]</sup> increment和decrement這兩個詞很有意思,大多數字典都說它們是名詞,但經常被當成動詞用,在計算機術語中,它們當動詞用應該理解為increase by one和decrease by one。現代英語中很多原本是名詞的都被當成動詞用,字典都跟不上時代了,再比如transition也是如此。
## 4.?break和continue語句
在[第?4?節 “switch語句”](ch04s04.html#cond.switch)中我們見到了`break`語句的一種用法,用來跳出`switch`語句塊,這個語句也可以用來跳出循環體。`continue`語句也會終止當前循環,和`break`語句不同的是,`continue`語句終止當前循環后又回到循環體的開頭準備執行下一次循環。對于`while`循環和`do/while`循環,執行`continue`語句之后測試控制表達式,如果值為真則繼續執行下一次循環;對于`for`循環,執行`continue`語句之后首先計算控制表達式3,然后測試控制表達式2,如果值為真則繼續執行下一次循環。例如下面的代碼打印1到100之間的素數:
**例?6.1.?求1-100的素數**
```
#include <stdio.h>
int is_prime(int n)
{
int i;
for (i = 2; i < n; i++)
if (n % i == 0)
break;
if (i == n)
return 1;
else
return 0;
}
int main(void)
{
int i;
for (i = 1; i <= 100; i++) {
if (!is_prime(i))
continue;
printf("%d\n", i);
}
return 0;
}
```
`is_prime`函數從2到`n-1`依次檢查有沒有能被`n`整除的數,如果有就說明`n`不是素數,立刻跳出循環而不執行`i++`。因此,如果`n`不是素數,則循環結束后`i`一定小于`n`,如果`n`是素數,則循環結束后`i`一定等于`n`。注意檢查臨界條件:2應該是素數,如果`n`是2,則循環體一次也不執行,但`i`的初值就是2,也等于`n`,在程序中也判定為素數。其實沒有必要從2一直檢查到`n-1`,只要從2檢查到?sqrt(n)?,如果全都不能整除就足以證明`n`是素數了,請讀者想一想為什么。
在主程序中,從1到100依次檢查每個數是不是素數,如果不是素數,并不直接跳出循環,而是`i++`后繼續執行下一次循環,因此用`continue`語句。注意主程序的局部變量`i`和`is_prime`中的局部變量`i`是不同的兩個變量,其實在調用`is_prime`函數時主程序的局部變量`i`和參數`n`的值相等。
### 習題
1、求素數這個程序只是為了說明`break`和`continue`的用法才這么寫的,其實完全可以不用`break`和`continue`,請讀者修改一下控制流程,去掉`break`和`continue`而保持功能不變。
2、上一節講過怎樣把`for`循環改寫成等價的`while`循環,但也提到如果循環體中有`continue`語句這兩種形式就不等價了,想一想為什么不等價了?
## 5.?嵌套循環
上一節求素數的例子在循環中調用一個函數,而那個函數里面又有一個循環,這其實是一種嵌套循環。如果把那個函數的代碼拿出來寫就更清楚了:
**例?6.2.?用嵌套循環求1-100的素數**
```
#include <stdio.h>
int main(void)
{
int i, j;
for (i = 1; i <= 100; i++) {
for (j = 2; j < i; j++)
if (i % j == 0)
break;
if (j == i)
printf("%d\n", i);
}
return 0;
}
```
現在內循環的循環變量就不能再用`i`了,而是改用`j`,原來程序中`is_prime`函數的參數`n`現在直接用`i`代替。在有多層循環或`switch`嵌套的情況下,`break`只能跳出最內層的循環或`switch`,`continue`也只能終止最內層循環并回到該循環的開頭。
用循環也可以打印表格式的數據,比如打印小九九乘法表:
**例?6.3.?打印小九九**
```
#include <stdio.h>
int main(void)
{
int i, j;
for (i=1; i<=9; i++) {
for (j=1; j<=9; j++)
printf("%d ", i*j);
printf("\n");
}
return 0;
}
```
內循環每次打印一個數,數與數之間用兩個空格隔開,外循環每次打印一行。結果如下:
```
1 2 3 4 5 6 7 8 9
2 4 6 8 10 12 14 16 18
3 6 9 12 15 18 21 24 27
4 8 12 16 20 24 28 32 36
5 10 15 20 25 30 35 40 45
6 12 18 24 30 36 42 48 54
7 14 21 28 35 42 49 56 63
8 16 24 32 40 48 56 64 72
9 18 27 36 45 54 63 72 81
```
結果有一位數的有兩位數的,這個表格很不整齊,如果把打印語句改為`printf("%d\t", i*j);`就整齊了,所以Tab字符稱為制表符。
### 習題
1、上面打印的小九九有一半數據是重復的,因為8*9和9*8的結果一樣。請修改程序打印這樣的小九九:
```
1
2 4
3 6 9
4 8 12 16
5 10 15 20 25
6 12 18 24 30 36
7 14 21 28 35 42 49
8 16 24 32 40 48 56 64
9 18 27 36 45 54 63 72 81
```
2、編寫函數`diamond`打印一個菱形。如果調用`diamond(3, '*')`則打印:
```
*
* * *
*
```
如果調用`diamond(5, '+')`則打印:
```
+
+ + +
+ + + + +
+ + +
+
```
如果用偶數做參數則打印錯誤提示。
## 6.?goto語句和標號
分支、循環都講完了,現在只剩下最后一種影響控制流程的語句了,就是`goto`語句,實現無條件跳轉。我們知道`break`只能跳出最內層的循環,如果在一個嵌套循環中遇到某個錯誤條件需要立即跳出最外層循環做出錯處理,就可以用`goto`語句,例如:
```
for (...)
for (...) {
...
if (出現錯誤條件)
goto error;
}
error:
出錯處理;
```
這里的`error:`叫做標號(Label),任何語句前面都可以加若干個標號,每個標號的命名也要遵循標識符的命名規則。
`goto`語句過于強大了,從程序中的任何地方都可以無條件跳轉到任何其它地方,只要在那個地方定義一個標號就行,唯一的限制是`goto`只能跳轉到同一個函數中的某個標號處,而不能跳到別的函數中<sup>[[11](#ftn.id2727782)]</sup>。_濫用`goto`語句會使程序的控制流程非常復雜,可讀性很差_。著名的計算機科學家Edsger W. Dijkstra最早指出編程語言中`goto`語句的危害,提倡取消`goto`語句。`goto`語句不是必須存在的,顯然可以用別的辦法替代,比如上面的代碼段可以改寫為:
```
int cond = 0; /* bool variable indicating error condition */
for (...) {
for (...) {
...
if (出現錯誤條件) {
cond = 1;
break;
}
}
if (cond)
break;
}
if (cond)
出錯處理;
```
通常`goto`語句只用于這種場合,一個函數中任何地方出現了錯誤條件都可以立即跳轉到函數末尾做出錯處理(例如釋放先前分配的資源、恢復先前改動過的全局變量等),處理完之后函數返回。比較用`goto`和不用`goto`的兩種寫法,用`goto`語句還是方便很多。但是除此之外,在任何其它場合都不要輕易考慮使用`goto`語句。有些編程語言(如C++)中有異常(Exception)處理的語法,可以代替`goto`和`setjmp/longjmp`的這種用法。
回想一下,我們在[第?4?節 “switch語句”](ch04s04.html#cond.switch)學過`case`和`default`后面也要跟冒號(:號,Colon),事實上它們是兩種特殊的標號。和標號有關的語法規則如下:
語句?→?標識符:?語句
語句?→?case?常量表達式:?語句
語句?→?default:?語句
反復應用這些語法規則進行組合可以在一條語句前面添加多個標號,例如在[例?4.2 “缺break的switch語句”](ch04s04.html#cond.switch2)的代碼中,有些語句前面有多個`case`標號。現在我們再看`switch`語句的格式:
switch?(控制表達式)?{
case?常量表達式:?語句列表
case?常量表達式:?語句列表
...
default:?語句列表
}
{}里面是一組語句列表,其中每個分支的第一條語句帶有`case`或`default`標號,從語法上來說,`switch`的語句塊和其它分支、循環結構的語句塊沒有本質區別:
語句?→?switch?(控制表達式)?語句
語句?→?{?語句列表?}
有興趣的讀者可以在網上查找有關Duff's Device的資料,Duff's Device是一段很有意思的代碼,正是利用“`switch`的語句塊和循環結構的語句塊沒有本質區別”這一點實現了一個巧妙的代碼優化。
* * *
<sup>[[11](#id2727782)]</sup> C標準庫函數`setjmp`和`longjmp`配合起來可以實現函數間的跳轉,但只能從被調用的函數跳回到它的直接或間接調用者(同時從棧空間彈出一個或多個棧幀),而不能從一個函數跳轉到另一個和它毫不相干的函數中。`setjmp/longjmp`函數主要也是用于出錯處理,比如函數`A`調用函數`B`,函數`B`調用函數`C`,如果在`C`中出現某個錯誤條件,使得函數`B`和`C`繼續執行下去都沒有意義了,可以利用`setjmp/longjmp`機制快速返回到函數`A`做出錯處理,本書不詳細介紹這種機制,有興趣的讀者可參考[[APUE2e]](bi01.html#bibli.apue "Advanced Programming in the UNIX Environment")。
- Linux C編程一站式學習
- 歷史
- 前言
- 部分?I.?C語言入門
- 第?1?章?程序的基本概念
- 第?2?章?常量、變量和表達式
- 第?3?章?簡單函數
- 第?4?章?分支語句
- 第?5?章?深入理解函數
- 第?6?章?循環語句
- 第?7?章?結構體
- 第?8?章?數組
- 第?9?章?編碼風格
- 第?10?章?gdb
- 第?11?章?排序與查找
- 第?12?章?棧與隊列
- 第?13?章?本階段總結
- 部分?II.?C語言本質
- 第?14?章?計算機中數的表示
- 第?15?章?數據類型詳解
- 第?16?章?運算符詳解
- 第?17?章?計算機體系結構基礎
- 第?18?章?x86匯編程序基礎
- 第?19?章?匯編與C之間的關系
- 第?20?章?鏈接詳解
- 第?21?章?預處理
- 第?22?章?Makefile基礎
- 第?23?章?指針
- 第?24?章?函數接口
- 第?25?章?C標準庫
- 第?26?章?鏈表、二叉樹和哈希表
- 第?27?章?本階段總結
- 部分?III.?Linux系統編程
- 第?28?章?文件與I/O
- 第?29?章?文件系統
- 第?30?章?進程
- 第?31?章?Shell腳本
- 第?32?章?正則表達式
- 第?33?章?信號
- 第?34?章?終端、作業控制與守護進程
- 第?35?章?線程
- 第?36?章?TCP/IP協議基礎
- 第?37?章?socket編程
- 附錄?A.?字符編碼
- 附錄?B.?GNU Free Documentation License Version 1.3, 3 November 2008
- 參考書目
- 索引