跳轉到內容

使用 C 和 C++ 的程式語言概念/控制級別結構

來自華夏公益教科書,開放的世界,開放的書籍

本章討論隱式和顯式控制結構,這些結構影響計算機執行構成程式的一組操作的順序,在表示式級別、語句級別和子程式級別。

此順序有時由語言規則隱式指定,有時由程式設計師使用程式語言中的一些構造顯式強加。前者被稱為隱式控制結構,而後者被稱為顯式控制結構。

對操作的控制

[編輯 | 編輯原始碼]

表示式級別的控制與對操作的控制有關,即計算表示式值的順序。

表示式

[編輯 | 編輯原始碼]

表示式是計算值的公式,它表示為運算子運算元和括號的正式序列。表示式產生一個計算值,該值駐留在臨時儲存位置,直到被使用。因此,表示式具有從其子表示式派生的隱式型別。

表示式寫為運算子、運算元和括號的序列。運算子是透過單個符號或兩個符號的字串表示的原始函式。運算元表示資料值,實際上是訪問值的工具,它可以是常量、變數名、函式引用、陣列引用,甚至可以是另一個表示式。

程式語言透過優先順序結合性規則提供對錶達式的隱式控制。程式設計師可以透過使用括號來強加顯式控制。

表示式求值順序。
 9 + 7 * 3 is evaluated as 9 + (7 * 3) because * has higher precedence than +.
 9 + 7 + 3 is evaluated as (9 + 7) + 3 because + associates to left.
 (9 + 7) * 3 is evaluated differently than the first expression due to the use of parentheses.
許多 PL 不指定運算子運算元求值的順序。也就是說,在subexp1 op subexp2中,程式設計師不能假設subexp1subexp2之前被求值;這也適用於子程式引數求值順序。在這兩種情況下,編譯器都可以自由選擇求值順序:當一個編譯器按您的預期行為時,另一個編譯器可能會選擇打破它。避免這種情況的最佳方法是將產生副作用的表示式替換為沒有副作用的表示式。例如,greater_than(i++, i); 必須替換為

k = i++; greater_than(k, i); 最後要注意的一點是:程式語言可能會提供對此一般規則的例外。例如,在 C 中,&&||?:, 的運算元以明確定義的順序進行求值。

運算子可以根據它接受的運算元數量進行分類。

  • 一元運算子:它只接受一個運算元。例如,基於 C 的程式語言中的 ++-- 運算子。
  • 二元運算子:運算子接受兩個運算元。典型的例子是基本算術運算子 +-*/
  • 三元運算子:只有一個運算子接受三個運算元,即條件表示式:?:
示例:返回兩個值中較大的 C 函式。
int max(int i, int j) { return(i > j ? i : j); }

表示式的表示

[編輯 | 編輯原始碼]

有許多方法可以表示表示式。它們是

  1. 函式式形式:函式式形式將所有操作表示為函式;運算元是這些函式的引數。它也稱為應用式形式普通字首形式。使用此表示法,9 + 7 * 3 將表示為 Add(9, Mult(7, 3))
  2. 劍橋波蘭語:作為 LISP 中使用的函式式形式的型別,括號圍繞運算子及其相關運算元。根據此表示法,9 + 7 * 3 表示為 (Add 9 (Mult 7 3))
  3. 中綴:這是表示式的傳統表示方式,其中運算子出現在運算元的中間。
  4. 字首:在字首表示法中,運算子出現在運算元之前。它也被稱為波蘭表示法。使用此表示法,9 + 7 * 3 表示為 + * 7 3 9
  5. 字尾:在後綴表示法中,操作數出現在運算子之前。它也被稱為逆波蘭表示法9 + 7 * 3 表示為 7 3 * 9 +

請注意,字首和字尾表示法是無括號的。這是因為我們不需要顯式地強加求值順序;運算子和運算元的順序本身足以指示操作應用的順序。

對語句的控制

[編輯 | 編輯原始碼]

語句級控制關注構成程式的語句的排序——這是一種可以按順序、透過選擇備選方案或透過迭代來完成的組合。

簡單序列

[編輯 | 編輯原始碼]

簡單序列,或順序流,是語句級控制流的預設控制流。語句的執行遵循它們在程式原始碼中的順序。通常,僅靠簡單序列不足以表達必要的計算。

當我們希望從兩個或多個備選語句(或語句組)中進行選擇時,就會使用選擇性組合。選擇性組合的典型例子是

  • if: if 語句根據指定表示式是否為真,提供對語句或語句塊的條件執行。通常,它採用以下形式

if (condition) statement; else statement;

一些程式語言,如 Scheme 和 Perl,提供了一個 if 語句的否定版本:unless
  • case: 深度巢狀的 if-else 語句在語法上通常是正確的,但不能表達程式設計師的預期邏輯。例如,意外的 else-if 匹配更有可能被忽略。對語句的修改也很難正確執行。因此,作為在多個互斥選擇之間進行選擇的一種替代方法,大多數程式語言都提供了一個 case 語句。
需要注意的一點是:許多程式語言將選擇器表示式的型別限制為整數值。也就是說,以下程式碼片段可能會被拒絕。

switch (name) { case "Deniz": ... break; case "Defne": ... break; case "Mete": ... break; ... default: ... } /* end of switch(name) */

示例:一個計算每個母音出現次數的 C++ 程式片段。

char ch; int aCnt = 0, eCnt = 0, iCnt = 0, oCnt = 0, uCnt = 0, consonantCnt = 0, othersCnt = 0;

// switch-case 版本 while (cin >> ch) switch (ch) { case 'a': case 'A': ++aCnt; break; case 'e': case 'E': ++eCnt; break; case 'i': case 'I': ++iCnt; break; case 'o': case 'O': ++oCnt; break; case 'u': case 'U': ++uCnt; break; default: if (isalpha(ch)) ++consonantCnt; else ++othersCnt; } /* end of switch(ch) */

// if-else 版本 while (cin >> ch) if (ch == a || ch == A) ++aCnt; else if (ch == e || ch == E) ++eCnt; else if (ch == i || ch == I) ++iCnt; else if (ch == o || ch == O) ++oCnt; else if (ch == u || ch == U) ++uCnt; else if (isalpha(ch)) ++consonantCnt; else ++othersCnt;

當程式的一部分需要執行零次或多次時,就會使用迭代組合。有四種基本的迭代結構。

索引迴圈

[編輯 | 編輯原始碼]

也稱為確定性迴圈,當我們知道迴圈執行的次數時,我們會使用這種結構。一個例子是Pascal中的for迴圈。

示例:計算從1到n的數字之和的Pascal程式碼片段。

sum := 0; for counter := 1 to n do sum := sum + counter;

請注意,C語言中的for語句是一個功能更強大、更通用的結構,應該被視為一個“測試前”迴圈。除了實現非確定性迴圈之外,確定性迴圈也可以很容易地被模擬。例如,上面的程式碼片段可以寫成

for (sum = 0, counter = 1; counter <= n; counter++) sum += counter;

for迴圈中有一個變體,它可以迭代一個值列表。它從列表中取出每個值(而不是從一個數字範圍內取值)並對它執行一個或多個命令。

示例:一個csh程式碼片段,它檢查每個命令列引數是選項還是非選項。

foreach arg ($argv) # 它是否以 ‘-‘ 開頭? if ($arg =~ -*) then echo “An option” else echo “Not an option” endif end

測試前迴圈

[edit | edit source]

作為兩種非確定性迴圈之一,條件在迴圈開始時檢查。這意味著迴圈體可能永遠不會被執行。一個典型的例子是ALGOL語言中的while語句。

示例:一個Modula-3子程式,它計算兩個數字的最大公約數。

PROCEDURE gcd(x, y: CARDINAL): CARDINAL = BEGIN WHILE y # 0 DO VAR temp: CARDINAL := y; BEGIN y := x MOD y; x := temp; END; END; RETURN x; END gcd;

示例:一個C程式碼片段,用於計算單行上的字元數量。

for (n = 0; getchar() != ‘\n; ++n) /* no-op */ ;

請注意,右括號後跟一個單獨的分號。這意味著for迴圈包含一個空語句。

C語言中for迴圈的一般形式如下所示。 expression-1在迴圈開始之前被評估一次。它用於進行初始化。 expression-2在每次迭代之前被評估,只要它的結果為零(或者在更安全的程式語言如Java和C#中為假),執行就會從for迴圈後的下一行繼續執行。 expression-3在每次迭代結束時被評估。

for (expression1; expression2; expression3) statement;

這些表示式中的任何一個都可以被省略,儘管分號必須保留。如果第一個表示式被省略,for迴圈將簡單地不執行任何初始化。如果第三個表示式被省略,在迭代結束時將不會有任何副作用發生。至於第二個表示式的缺失,它將被視為真。也就是說,

for ( ; ; ) statement; while (any non-zero value) statement;

在Java和C#中,它等價於while (true) statement;

有關for迴圈和while迴圈等價性的更多資訊,請參見Goto語句部分。

測試後迴圈

[edit | edit source]

在測試後迴圈的情況下,條件在迴圈結束時檢查。因此,迴圈體至少被執行一次。典型的例子是Pascal語言中的repeat-until語句和C語言中的do-while語句。

示例:一個C程式碼片段,它不斷從輸入流中讀取,直到讀取到 'a' 為止。

do ch = getchar(); while (ch != a);

無條件迴圈

[edit | edit source]

除了這三種結構之外,一些程式語言還提供了一種無條件迴圈結構。這種結構通常與exit語句一起使用。Modula-3中的LOOP-END結構就是一個例子。

示例:一個Modula-3程式碼片段,它計算從1到n的數字之和。

sum := 0; LOOP IF counter > n THEN EXIT; END; /* end of IF */ sum := sum + counter; INC(counter, 1); END;

Goto語句

[edit | edit source]

已經證明,如果選擇和迭代結構可用,那麼任何程式都可以不使用goto語句編寫。但這並不意味著它完全沒有用。不同的迭代形式也可以用其他形式來表達。這是否意味著它們沒有用?不!

用while迴圈表達C語言的for迴圈。

for (expression1; expression2; expression3) statement;

可以使用while迴圈重寫為

expression1; while (expression2) { statement; expression3; }

眾所周知,goto 是一種相當低階的控制結構,在可能的情況下最好避免使用它。但是,在某些情況下,它可能是最佳選擇。例如,當我們需要提前退出迴圈時該怎麼辦?一些程式語言,如 C、C++、Java[1] 和 Modula-3,如無條件迴圈結構的示例所示,提供了breakcontinue 這樣的控制結構;但有些程式語言沒有[2]。在這種情況下,唯一的方法是 either 複雜化迴圈的測試表達式或者使用一個 goto,其目標是迴圈結束後的語句。因此,推論是:避免使用它,但不要說它完全沒有用。

子程式控制

[edit | edit source]

子程式級別的控制與子程式呼叫以及呼叫模組和被呼叫模組之間的關係有關。以下是呼叫者和被呼叫者之間可能存在關係的列表。

簡單呼叫

[edit | edit source]

在簡單呼叫中,呼叫者對被呼叫者擁有完全控制權。也就是說,它們之間存在主從關係。

Control flow of simple call

當呼叫一個子程式時,執行控制權會轉移到子程式的入口點,而下一個指令(即在子程式完成之後將要處理的指令)的地址會被儲存。當執行過程中遇到子程式的出口點時,控制權會返回到呼叫者,以及與呼叫時儲存的地址相對應的指令。

這種呼叫者和被呼叫者之間關係的內在約束是

  1. 子程式不能呼叫自身。
  2. 子程式透過呼叫程式中構成呼叫程式的一系列語句中的顯式呼叫語句來呼叫。
  3. 在任何時間,只有一個子程式擁有執行控制權。因此,兩個子程式不能被呼叫併發執行。
  4. 當然,呼叫程式對從屬子程式擁有完全控制權,就像主程式對從程式一樣。

遞迴

[edit | edit source]

當我們刪除上述列表中的第一個約束時,就會得到遞迴。對於遞迴子程式的每一次呼叫,都會建立一個單獨的啟用記錄(幀)。考慮到呼叫指令的成本以及執行時堆疊中使用的空間,可以公平地說,遞迴解決方案在時間和空間方面都可能不如其迭代對應解決方案高效。但是,為了公平起見,應該強調,成本是由呼叫指令造成的,而不是遞迴本身。

在函數語言程式設計語言中,遞迴(一種偽裝的迴圈結構)比迭代更受歡迎。為了避免由於函式呼叫造成的效能損失和執行時堆疊溢位,Scheme(一種函數語言程式設計語言)規定編譯器將尾遞迴呼叫轉換為 goto 語句。出於同樣的原因,許多編譯器(不是語言!)在其技巧包中包含了尾遞迴消除。

示例:使用 gcc 進行尾遞迴消除。

#include <stdio.h> int add_expensive(int a, int b) { if (b > 0) return add_expensive(a + 1, b - 1); else if (b < 0) return add_expensive(a 1, b + 1); else return a; } /* end of int add_expensive(int, int) */ int main(void) { int a, b; printf("Enter two integers: "); scanf("%i %i", &a, &b); printf("%i and %i added gives %i\n", a, b, add_expensive(a, b)); return 0; } /* end of int main(void) */

如果您在沒有最佳化的情況下編譯後執行上述程式,對於足夠大的數字,它將退出而不會寫入任何結果。但是,在開啟最佳化的情況下,保證會輸出結果。

# 您可以使用 -O2、-O3 或 -Os 代替 -foptimize-sibling-calls gcc -foptimize-sibling-calls -o Tail_Recursion.exe Tail_Recursion.c↵ # 使用 DJGPP-gcc Tail_Recursion↵ Enter two integers: 123456789 987654321↵ 123456789 and 987654321 added gives 1111111110 gcc -o Tail_Recursion.exe Tail_Recursion.c↵ Tail_Recursion↵ Enter two integers: 123456789 987654321↵

一些程式語言要求顯式宣告子程式為遞迴。例如,PL/I 和 FORTRAN 90。90 之前的 FORTRAN 和 COBOL 版本甚至不允許遞迴子程式定義。為什麼這樣一個重要的解決問題的工具被排除在外,並不是因為這些程式語言的設計者無法預見遞迴的使用。這些程式語言問世時,不得不與組合語言和機器程式碼競爭。為了使這個目標更容易實現,他們的設計者決定將所有資料實體設為靜態的,這自動排除了遞迴的可能性。

隱式呼叫

[edit | edit source]

當我們取消子程式必須顯式呼叫的要求時,我們就有了子程式在程式設計師控制範圍之外被呼叫的可能性。這種情況以兩種方式發生

  1. 異常處理:異常是指意外、不頻繁且隨機發生的事件。例如,除以零下標越界檔案結尾。異常處理程式是由程式設計師編寫的子程式,它與作業系統互動,只有在遇到指定的“異常情況”時才會被呼叫。
  1. 排程呼叫:排程呼叫是面向事件的模擬程式中使用的子程式控制型別的典型代表。對某些子程式的控制是由計時機制管理的,計時機制可能是內建在程式語言中的子程式,也可能是程式設計師編寫的子程式。例如,假設子程式 A “呼叫”(即預排程)子程式 B,並指定一個啟用時間。[3] 為子程式 B 建立一個啟用記錄,並將其插入到一個按照啟用時間升序排列的佇列中。計時例程會定期檢查佇列中的第一條記錄;當其啟用時間與當前模擬時間匹配時,該記錄將從佇列中刪除,放置到執行時堆疊上,並呼叫該子程式。子程式也可以被排程為不是在明確的時間發生,而是在“儘快”發生。當子程式依賴於特定資源的可用性或另一個子程式的完成時,就會發生這種情況。

並行處理

[編輯 | 編輯原始碼]

並行處理,也稱為併發處理,是指同時執行兩個或多個子程式以解決特定問題的過程。 並行性可以是真實的,即在多個處理器上即時同時執行,也可以是虛擬的,即在單箇中央處理器上模擬的並行性。

Parallel flow of control
示例:Ada83 中的任務。
假設一家小型公司的會計部門負責執行以下功能(任務):訂購用品、支付賬單和準備發票。 只有一個會計的情況下,流程可以表示為

PROCEDURE Accounting IS BEGIN Order_Supplies; Pay_Bills; Prepare_Invoices; END Accounting;

但是,如果有三個會計,這些任務可以並行執行。 Ada 對這種情況的典型表示將是

PROCEDURE Accounting IS TASK Order; TASK BODY Order IS BEGIN Order_Supplies; END Order; TASK Pay; TASK BODY Pay IS BEGIN Pay_Bills; END Pay; BEGIN Prepare_Invoices; END Accounting;

在設計具有並行控制的程式時,程式設計師必須考慮一些重要問題,包括同步臨界區死鎖

如果我們取消呼叫者對被呼叫者擁有完全控制權的要求,我們就會得到相互控制。 相互控制子程式被稱為協程。 協程可以互相呼叫。 但是,與普通呼叫不同,B 對 A 的呼叫實際上將控制權轉移到子程式 A 中 A 最後呼叫 B 的位置。 不同的關鍵字(如 resume)可以用作呼叫的替代。

Control flow in coroutines

作業系統中不同程序相互關聯的方式大體相同。 當一個程序放棄處理器時,另一個程序會在處理器上執行。 一旦該程序完成了其時間段,它將放棄處理器,可能返回到前一個程序。 此程序不會從第一行開始執行,而是從其之前停止的位置繼續執行。

  1. Java 沒有 goto 語句。 但它是一個保留字
  2. 與 Java 不同,C 和 C++ 沒有帶標籤的 break。 在需要退出深度巢狀迴圈的情況下,我們可能最終需要使用帶有 goto 的解決方案。
  3. 在模擬程式中,這通常是指模擬時間,而不是實際時間。
華夏公益教科書