跳轉到內容

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

來自華夏公益教科書

我們對程式級結構的調查將最初以編譯器的結構為指導。我們將首先看看程式實體(令牌)是如何線性形成的,以及它們是如何進一步分組到層次結構中的。前者對應於詞法分析器檢視源程式的方式,而後者對應於語法分析器對令牌流施加的層次結構。隨後將介紹子程式結構以及子程式可用的資料物件。最後我們將詳細闡述不同型別的引數傳遞機制。

程式文字

[編輯 | 編輯原始碼]

任何語言的語法都是一組規則,這些規則控制著語法上正確的字元字串的構建。程式文字可以被認為是一個字元字串,最終被翻譯成機器指令的有序序列。我們現在將檢查這些字元本身,它們如何組織成構成程式文字各個部分的子字串,以及它們如何在構成完整程式的許多行程式碼中排列。

字元集

[編輯 | 編輯原始碼]

就像自然語言中使用的通訊中的字母符號一樣,我們使用字元集來組成程式語言中的程式。雖然所有語言的字元集都可以誇耀英語字母的 26 個大寫字母、10 個十進位制數字、空格符號以及標點符號和基本運算子的符號,但一些較舊的語言不支援小寫字母。一些例子,例如 FORTRAN 和 COBOL - 因為它們最初開發時(50 年代)常用的字元程式碼不包括小寫字母 - 在它們的字元集中不包括小寫字母。另一個具有這種特性的程式語言示例是 APL,它具有包含許多希臘字母符號的特殊字元集。

在計算機記憶體中,這些字元可以以不同的方式編碼。表示這些字元最常用的選擇是 ASCII 標準。Unicode 標準本質上是 Anglo-American ASCII 的超集,它為世界上所有 spoken 的自然語言提供了支援,甚至更多。因此,由 Java、C# 和許多其他程式語言支援的 Unicode 隨著軟體國際化變得越來越重要而獲得了認可。

示例:一個計算從 1 到 10 的數字總和的 Java 程式。

public class TryUnicode { public static void main(String[] args) { int toplam = 0; // "toplam" 是土耳其語中的 "sum" for (int sayaç = 1; sayaç < 10; sayaç++) toplam += sayaç; // "sayaç" 是土耳其語中的 "counter" System.out.println("1'den 10'a kadar sayıların toplamı: " + toplam); // "Summation of numbers from 1 to 10: " } // void main(String[] 結束 } // TryUnicode 類結束

除了這兩個之外,我們還有一系列 ISO/IEC 標準,它們允許使用單位元組表示各種字母。這些字元編碼被稱為 ISO-8859-n,它們是基於 ASCII 的,並在相關字元表的上半部分提供特定於語言/語言組的字母;所有 ISO-8859-n 編碼的下半部分與 ASCII 字元編碼完全相同。得益於 ISO-8859-n,英語和各種其他語言現在可以在同一個程式原始碼中共存。但是,使用來自不同 ISO/IEC 編碼的語言意味著我們必須使用多種編碼,這可能需要相當多的技巧。解決我們擔憂的辦法是使用 UTF-8,它是基於 Unicode 的可變長度編碼。在 UTF-8 編碼中,Unicode 的 ASCII 子集(與其他基於拉丁字母的字母表有許多共同的字元)用一個位元組表示,而其餘字元用 2、3 或 4 個位元組表示。

運算子

[編輯 | 編輯原始碼]

運算子是返回值的泛型函式。運算子名稱通常由單個字元或兩個字元的字串組成。儘管許多語言將可用運算子的語義限制在語言規範中,但有些語言,包括 C++、C# 和 Ada,為程式設計師提供了重新定義這種語義的功能。有些語言,例如 PROLOG,甚至可以讓您構建新的運算子併為它們提供語義。

示例:一個過載“+”運算子以新增兩個 Fraction 型別的運算元的 Ada83 函式定義。

FUNCTION "+" (Left, Right: Fraction) RETURN Fraction IS Result: Fraction; BEGIN Result.Numerator := Left.Numerator * Right.Denominator + Right.Numerator * Left.Denominator; Result.Denominator := Left.Denominator * Right.Denominator; Reduce(Result); END "+"; ... f1, f2, f3: Fraction; ... f3 := f1 + f2; ...

運算子可以大致分為以下幾類

  1. 算術運算子提供基本運算,如加、減、乘、除。一些語言,例如 FORTRAN,還包括指數運算子。
  2. 布林運算子僅對邏輯值(truefalse)起作用。示例包括合取、析取和否定。
  3. 關係運算符測試兩個運算元之間關係的真值。如果關係成立,關係運算符返回的結果為真。否則,結果為假。此類中眾所周知的運算子有小於、等於、不等於和大於。
  4. 位運算子涉及對單個位的布林操作。此類中的典型運算子是按位與、按位或、按位非和按位異或。

另一種可能的劃分,在許多情況下有助於找出優先順序,如下所示

  1. 一元運算子,如 -、+、NOT(最高優先順序)
  2. 乘法運算子,如 *、/、DIV、MOD、AND
  3. 加法運算子,如 +、-、OR
  4. 關係運算符,如 <、>、<=、>=、<>(最低優先順序)

分隔符

[edit | edit source]

分隔符是用於分隔程式文字片段的符號或關鍵字。從某種意義上說,它們是程式的標點符號。

它們有兩種形式

  • 分隔符表示一個程式實體的結束和下一個程式實體的開始。例如,空格通常用於分隔語句中的識別符號和表示式;基於 Pascal 的語言使用冒號來分隔變數名及其型別宣告。
示例:Pascal 中的變數定義。

var ID_Number : integer;


許多不同的符號可以用來分隔一個程式語句的結束和下一個語句的開始。基於 ALGOL 的程式語言中常見的選擇是分號。但是,應該記住,基於 Pascal 的語言和基於 C 的語言對分號的使用略有不同。在前者中,分號用作一種分隔符。這就是為什麼這些語言中塊的最後一個語句沒有以分號結尾。(這就像寫 a + b + 而不是 a + b。)在後者中,分號用作終止符。也就是說,每個語句,無論它在哪裡,都必須以分號結尾。

  • 括號是成對出現的符號或關鍵字。它們用於界定一段程式程式碼。示例包括 ( ) 用於界定子程式引數列表,[ ] 用於指定相關陣列元件,以及 { }(或 BEGIN-END)用於構建複合語句
示例:交換兩個變數值的 C 塊。

if (a < b) { int temp = a; a = b; b = temp; }

程式文字外觀

[edit | edit source]

固定格式與自由格式

[edit | edit source]

在 1950 年代,人們,或者更確切地說是一群精選的研究人員,習慣於透過在卡片上打孔單個指令來輸入程式。這些穿孔卡具有固定格式。為了簡化與這些卡片的向後相容性,早期的程式語言被設計為固定格式。

FORTRAN 90 之前原始碼行的結構
列號 功能
1 C 用於註釋
1-5 行號
6 續行指示符
7-72 Fortran 語句
73-80 標識和排序,編譯器不讀取
COBOL 原始碼行的結構
列號 功能
1-6 排序,編譯器不使用
7 * 用於註釋或 - 用於繼續單詞或字串
8-11 程式單元的標題(A 邊緣)
12-72 語句(B 邊緣)
73-80 程式標識,編譯器不使用

後來,隨著終端開始用於輸入/輸出目的,自由格式語言佔據了上風。這些語言的起源可以追溯到 ALGOL。

不用說,比較這兩種格式沒有太多意義。一個跡象是,從 FORTRAN 90 開始,FORTRAN 現在是自由格式的。但這並不意味著用固定格式程式語言編寫的程式碼都會消失。要維護此類程式碼,仍然需要了解固定格式。

實際問題

[edit | edit source]

程式不僅是使用特定語言實現演算法的方案,也是將您的想法傳達給其他程式設計師的一種方式。因此,除了程式語言和我們所處的環境提供的功能之外,我們還應該努力使程式碼儘可能清晰。為了實現這一點,我們需要使用許多方法和約定。這些是原始碼縮排、空行和註釋。大量使用並一致使用這三者,而不掩蓋程式碼,將使程式更容易理解,因此也更容易維護。

程式結構

[edit | edit source]

接下來,我們將研究程式的層次結構。換句話說,程式中包含了什麼?這種包含關係可以擴充套件到多個級別嗎?第二個問題的答案是“是”。在本節中,我們將確定這些級別。

識別符號

[edit | edit source]

識別符號是用於訪問程式和資料實體的單詞。資料級識別符號的一個示例是變數名。程式級識別符號的一個示例是過程名。

在與變數名類似的上下文中使用的是引用。引用必須首先經過一定程度的評估。示例包括函式引用和陣列元件引用。

一些識別符號由程式設計師提供,而另一些則內置於程式語言中。後者通常被稱為關鍵字。通常,程式語言禁止將這些關鍵字重新用作使用者定義的識別符號。PL/1 是一個值得注意的例外。

表示式

[edit | edit source]

表示式由運算子和運算元組成,是待評估的運算序列。表示式評估為單個結果,因此非常像變數,只是它沒有名稱。事實上,在執行過程中會為表示式(子表示式)賦予臨時變數名,但這些名稱對程式設計師不可用。

表示式本質上是遞迴的;一個表示式可以包含在另一個表示式中,並用另一個表示式定義。這意味著透過評估一個表示式獲得的結果隨後可以用作運算元來評估下一個表示式。

語句

[edit | edit source]

語句可以類似於程式或子程式來定義。主要區別在於它們的抽象級別。在一個級別上被認為是簡單語句的東西,在另一個級別上可能被認為是複合語句。它們(語句、子程式和程式)都完成一項特定任務。

語句可以分類為

可執行語句:這類語句會導致計算機執行某種操作,例如將(新)值賦予變數。資訊性語句:也被稱為非可執行語句,這類語句描述了程式中使用的資料的性質。變數宣告語句就是一個例子。

還可以根據語句的組合進行進一步分類。

簡單語句:簡單語句包含單個操作。常見的例子包括 I/O 語句和賦值語句。複合語句:複合語句是一種程式結構,它由零個或多個簡單語句組成,通常用一對匹配的關鍵字或括號分隔。前者的例子是 Pascal 中的 BEGIN/END 對。C 語言程式語言中用於相同目的的花括號 { 和 } 是後者的例子。Python 程式語言在分隔複合語句的方式上相當獨特。它不使用分隔符,而是透過利用程式中的縮排,由語言處理器來判斷塊。

由於複合語句在語法上被視為單個語句,因此它可以在允許使用單個語句的任何地方使用。

子程式

[編輯 | 編輯原始碼]

子程式是通常帶有名稱的一組語句;它相對獨立,執行特定任務。

子程式對它的使用者來說是一個單一的語句,而對它的實現者來說則是資訊性語句和可執行語句的集合。這突出了語句和子程式之間的相似性:在一個抽象級別上被視為子程式的東西,在另一個級別上可能被視為簡單語句,反之亦然。

內部子程式
這些是命名程式單元,是程式文字的一部分,包含在程式文字中。內部子程式與呼叫它的程式一起編譯。
外部子程式
這些是命名程式單元,位於程式文字的外部,可以獨立(或單獨)編譯成目標模組,然後根據需要使用(連結)。

模組是相關子程式的集合,作為獨立的單元打包,以便其他單元重用,可能還會附帶相關資料。例子包括 Java 和 C# 中的類、Modula-2 中的模組和 C 原始檔。

模組的原始碼和相應目的碼通常被區分,分別稱為源模組和目標模組。

程式可以鬆散地定義為可以執行的獨立單元。它通常有名稱,並且可以呼叫一個或多個子程式,在這種情況下,它可能被稱為主程式。

應該注意程式和程序的區別。程式是一個可執行檔案,而程序是正在執行的程式的例項。也就是說,在某種程度上,程式之於程序,就像類之於物件。當生成一個程序時,它的記憶體映像是使用相關的程式作為模板建立的。

除了這些程式結構,作業可以定義為與作業系統互動的單元,它可以包含多個需要連結在一起或按順序執行的程式。例如,指令碼語言程式,如 DOS 中的批處理程式。

示例

gcc –o $1 $1.c if [ $? –eq 0 ]; then mv –f $1 Executables/$1 fi

上面的作業編譯 C 程式,並將生成的可執行檔案移動到名為 Executables 的目錄中。只有在第一個操作成功的情況下才會執行第二個操作。這是透過檢查 gcc 命令的退出值來實現的。按照廣泛採用的約定;成功時,gcc 以 0 退出,失敗時以非零值退出。在 bash 中,可以透過檢查 $0 來找到最後一個命令的退出值,就像在第二行中所做的那樣。

子程式作為程式結構

[編輯 | 編輯原始碼]

子程式,就像程式本身一樣,包含資訊性語句和可執行語句。[1] 子程式的宣告部分包含資訊性語句,這些語句由與子程式相關的宣告組成,並提供子程式中包含的動作語句和呼叫者之間的語法介面。

動作部分包含可執行語句;即實現。一個(或多個)語句被指定為子程式的入口點。通常,只有一個入口點,它是第一個語句。出口點可能是子程式中的最後一個語句,或者是一個顯式的返回或退出語句。不建議有多個入口點和出口點。

塊用於收集一組相關語句。與子程式類似,它開啟了自己的新作用域。它不是由顯式呼叫語句啟用的,而是由程式中指令的正常順序執行啟用的。

允許巢狀塊的程式語言被稱為塊結構語言。這有時會被清教徒反對,他們認為,為了將一種語言歸類為塊結構語言,它必須允許使用者定義巢狀子程式。

示例:實現氣泡排序的 C 程式碼片段。

#define FALSE 0 #define TRUE 1 ... pass = 1; do { swapped = FALSE; for (j = 0; j < noOfComponents - pass; j++) { if (a[j] <= a[j + 1]) continue; swap: { int temp = a[j]; a[j] = a[j + 1]; a[j + 1] = temp; swapped = TRUE; } /* end of swap block */ } /* end of for loop */ pass += 1; } while (pass < noOfComponents && swapped);

注意:這不是實現氣泡排序的正確方法;一些不必要的和醜陋的程式碼被新增進來以展示在 C 中使用塊。

段落

[edit | edit source]

段落是一個內部子程式,一個命名的語句序列。它透過偽呼叫語句來呼叫,例如 COBOL 中的 PERFORM paragraph-name。它們沒有宣告部分。換句話說,它們不允許您宣告區域性變數,也不接受引數。

示例:一個用於交換兩個變數值的 COBOL 段落。

PERFORM SWAP. ... SWAP. MOVE A TO TEMP. MOVE B TO A. MOVE TEMP TO B. SOME_OTHER_PARAGRAPH. ...

請注意,段落不接受任何引數。因此,示例中使用的所有變數必須宣告為全域性變數。

過程,子程式

[edit | edit source]

通常,術語過程和子程式用於指代不返回值的子程式(內部或外部)。它們用於其副作用;也就是說,用於在螢幕上列印輸出,透過一組賦值語句更改記憶體,等等。它們透過顯式呼叫被啟用,並接受引數。在某些程式語言中,如基於 Pascal 的語言,它們可以巢狀。

函式

[edit | edit source]

函式可以被稱為返回值過程或型別化過程。它們僅僅透過在程式文字中任何允許使用變數名作為右值的地方使用函式名(以及任何需要的引數)來啟用。

內建函式是那些與編譯器一起提供的函式,因此是語言詞彙的一部分。泛型函式是指其型別取決於呼叫時使用的引數型別的函式。

示例:一個查詢其兩個引數的最小值的泛型 C++ 函式。

template <class Type> Type min(Type a, Type b) { return a < b ? a : b; } // end of <Type> min(<Type>, <Type>)

子程式中的資料訪問

[edit | edit source]

定義:程式執行期間儲存繫結到物件的時間段——即可以檢查和儲存的記憶體區域——被稱為物件的範圍

定義:宣告的作用域是程式文字中該宣告生效的區域。

認識到作用域是一個靜態概念,而範圍是一個動態概念。作用域可以透過掃描原始碼來確定,這是一個在程式開始執行後無法改變的東西,因此是一個靜態概念。另一方面,範圍取決於程式的控制流,這是一個可能被輸入改變的東西,因此是一個動態概念。

全域性變數

[edit | edit source]

當子程式被呼叫時,一些資料,全域性變數,可能已經可以被訪問,而無需做任何特殊的事情。全域性變數的作用域從其宣告點擴充套件到原始檔的末尾,並且只有一個副本。只有一個副本意味著它只能有一個變數名-地址繫結,這意味著分配給它的記憶體位置在整個程式執行過程中不會改變。這樣的物件被稱為具有靜態範圍;其相對地址可以在執行開始之前確定,即在編譯時;它儲存在稱為靜態資料區域的記憶體區域中。檢視原始碼,可以計算所有靜態物件的地址——即所有全域性和靜態區域性物件——相對於該區域的開頭。

記憶體中的全域性變數

int i2; int i1; void f(...) { ... } /* end of void f(...) */ int main(void) { ... ... } /* end of void main(void) */

程式碼
0 int i2
4 int i1
?

類變數

[edit | edit source]

類變數 用於表示在不同例項之間不變的屬性。這意味著所有例項都可以共享一個副本,這使得在編譯時確定[相對]地址成為可能。

區域性變數

[編輯 | 編輯原始碼]

區域性變數在呼叫子程式時變得可訪問。這些變數被認為具有區域性作用域,從它們的宣告點到它們宣告所在的子程式的末尾。同樣,它們是可見的,除非被相同名稱的另一個識別符號隱藏,從它們的宣告點到子程式的末尾。它們的範圍(生命週期)通常僅限於特定的子程式呼叫,但在某些程式語言中,可以透過將它們宣告為static來擴充套件到整個程式的執行。

子程式的引數可以被認為是特殊區域性變數,它們從子程式外部初始化。

如果我們限制自己進行簡單的呼叫,區域性變數的處理將非常類似於全域性變數:它們將只有一個副本;將只有一個(變數名-地址)繫結。唯一的區別是變數的作用域。

這種區域性變數分配方式的優點是能夠靜態地確定地址。這可以做到,因為在程式執行過程中的任何時間,一個函式最多隻能被啟用一次。另一方面,遞迴是不可能的;程式設計師必須模擬遞迴演算法或編寫演算法的非遞迴版本。

記憶體中的區域性變數(無遞迴)

int i2; int i1; void f(...) { double d1; double d2; ... } /* void f(...) 的結束 */ int main(void) { ... ... } /* void main(void) 的結束 */

程式碼
0 int i2
4 int i1
8 double d1
16 double d2
?
問題
在 FORTRAN 90 之前,FORTRAN 僅支援簡單的呼叫。您認為這是什麼原因呢?
嘗試將區域性變數放在記憶體中(使用遞迴)

int i2; int i1; void f(...) { double d1; double d2; f(...); } /* void f(...) 的結束 */ int main(void) { ... ... } /* void main(void) 的結束 */

程式碼
0 int i2
4 int i1
8 double d1
16 double d2
8 double d1
16 double d2
8 double d1
16 double d2
?

遞迴的可能性、子程式可能被呼叫的次數不確定、每個子程式呼叫都有獨立的引數和區域性變數集,以及子程式呼叫終止順序的性質,要求使用單獨的動態、堆疊式記憶體區域來儲存區域性變數和引數,以及一些其他資訊。該區域稱為執行時堆疊。每次呼叫子程式時,都會建立一個新的呼叫記錄並將其壓入此堆疊。該記錄被稱為該特定子程式呼叫的啟用記錄;插入啟用記錄的槽稱為啟用幀

典型啟用記錄中包含的欄位

  1. 臨時值,例如表示式求值時產生的值,儲存在臨時值欄位中。
  2. 區域性資料欄位儲存子程式執行時區域性的資料。
  3. 儲存的機器狀態欄位儲存有關子程式被呼叫之前機器狀態的資訊。它包括程式計數器和機器暫存器的值,這些值在控制從子程式返回到呼叫者時必須恢復。
  4. 文字連結用於引用儲存在其他啟用幀中的非區域性資料。在基於 C 的語言中,不需要文字連結,因為不允許巢狀定義子程式。允許巢狀子程式定義的語言,例如 Pascal,需要使用此欄位。
  5. 動態連結指向呼叫者的啟用幀。
  6. 呼叫者在實際引數欄位中向被呼叫者提供引數。此區域對於特定函式的大小不會改變。(可變長度引數列表是對此的例外。)在實踐中,引數通常為了更高效而傳遞到機器暫存器中。
  7. 被呼叫者在返回的值欄位中將結果值返回給呼叫者。同樣,在實踐中,此值通常為了更高效而返回到暫存器中。
啟用記錄中的典型欄位
返回值
實際引數
文字連結
動態連結
儲存的機器狀態
區域性資料
臨時值

子程式呼叫和返回

[編輯 | 編輯原始碼]

在將控制傳遞給被呼叫者之前,會對引數進行評估,並將它們壓入執行時堆疊的頂部,然後調整 LB(區域性基址)以指向被呼叫者的啟用幀。LB 的先前值,即呼叫者啟用幀的起始地址,與一些其他控制資訊一起被壓入執行時堆疊。每次將新內容壓入堆疊時,ST(堆疊頂部)都會向前移動,以便顯示第一個可用位置。

在此處插入圖片

在進入被呼叫者後,會在堆疊上分配區域性變數。隨著表示式求值過程中的需要,臨時變數也會在堆疊上建立。當子程式完成執行並返回到呼叫者時,區域性變數將被釋放,引數透過彈出當前啟用記錄來解除關聯,並且在函式子程式的情況下,返回值將返回給呼叫者。

問題
根據經驗,將指向區域性變數的指標作為子程式的結果返回被認為是不好的程式設計方式,應該避免。您認為這是什麼原因呢?

出於最佳化目的,編譯器可以選擇將有關當前子程式呼叫的一些資訊儲存在暫存器中,而不是執行時堆疊中。

區域性變數與全域性變數

[編輯 | 編輯原始碼]

在子程式範圍內定義的變數是區域性變數。它們只有在呼叫子程式時才出現(為子程式建立啟用記錄並壓入執行時堆疊),並且一旦子程式執行完成,它們就將不存在(透過彈出執行時堆疊來銷燬子程式的啟用記錄)。它們不會在同一函式的不同啟用之間保留其值。

區域性宣告用於限制變數的作用域。使用區域性變數減少了資料名稱衝突的問題。它還有助於建立獨立的單元,即子程式。另一方面,全域性變數在主程式中宣告,在程式執行過程中一直存在,並且可以從所有程式單元訪問。因此,它們可以在編譯時分配,甚至在程式開始執行之前,並且在程式終止時釋放。

強烈建議不要在子程式中使用全域性變數。子程式中對全域性變數的修改會造成副作用,導致相同引數下返回不同的結果。同時,它也會導致不同子程式之間強耦合,使得子程式測試更加困難,並帶來除錯和維護方面的困擾。可以透過將這些強耦合的子程式分組,並放置在模組、類或(如果語言不支援這些結構)原始檔中,來最小化這種影響。

一些語言提供了介於區域性變數和全域性變數之間的概念:static 區域性變數。這類變數具有區域性作用域,類似於區域性變數,但具有靜態範圍,類似於全域性變數。也就是說,它們在呼叫之間保留其值,但仍然只能在子程式中訪問。在編譯時(從靜態資料區域)為它們分配記憶體,並在程式執行結束時釋放。

示例:C 中的靜態區域性變數。

void silly(void) { static int noOfTimes = 1; printf("This function has been called %d times.\n", noOfTimes++); } /* end of void silly(void) */

在首次呼叫此函式時,區域性變數noOfTimes 的值為 1。在子程式執行結束時,noOfTimes 變數的值為 2。該值在子程式結束和下次呼叫之間被保留。下次呼叫子程式時,noOfTimes 的值為 2。

由於靜態區域性變數的初始化與普通區域性變數不同,是在執行時之前發生的,因此它們保證具有一致的值。作為演示,執行以下程式,您將看到staticLocal_i 列印了 5,而plainLocal_i 列印了隨機值。

示例:C 中static 區域性變數的初始化。

#include <stdio.h> void f(void) { goto L1; { static int staticLocal_i = 5; int plainLocal_i = 6; L1: printf("Static i: %d\nNon-static i: %d\n", staticLocal_i, plainLocal_i); } } /* void f(void) */ int main(void) { f(); exit(0); } /* int main(void) */

問題
在 C 中,static 區域性變數預設初始化為 0,而普通區域性變數則保持未初始化狀態。這背後的原因是什麼?

堆變數

[edit | edit source]

到目前為止,我們所討論的所有資料物件的生命週期都與它們定義的塊相對應。但是,一些語言支援這樣一種特性:資料物件的生命週期與其建立它們的程式結構無關。大多數基於 ALGOL 的程式語言中動態建立並透過指標或控制代碼操作的記錄是這類資料物件的例子。為了在執行時表示這些物件,必須使用其他一些儲存分配和回收方法。由於這些語言功能允許以隨機順序進行儲存分配和回收,因此所涉及的儲存及其控制機制通常被稱為堆。

在一些程式語言中,程式設計師透過語言支援的操作(如 Pascal 中的dispose 或 C 中的free)顯式地丟棄堆記憶體,而在另一些語言中,這由稱為垃圾回收的語言執行時庫的一部分來處理。

由於堆物件的壽命不受建立它的程式結構的影響,因此這些物件被稱為具有動態範圍。透過指標或控制代碼間接訪問堆物件,會使這些物件具有匿名性。不要忘記,並不是引用是匿名的,而是被引用的堆物件是匿名的。由於這個特性,我們可以透過簡單的賦值讓兩個控制代碼代表同一個堆物件。這意味著我們可以共享同一個資料物件。請注意,是堆物件被共享,而不是控制代碼;控制代碼被複制。如果同一個堆物件的控制代碼具有不同的範圍,則堆物件本身預期將一直存在,直到指向它的最後一個控制代碼消失。這就是為什麼這些物件被稱為具有動態範圍。

透過共享而不是複製,透過控制代碼間接操作而不是直接操作;人們必須採用不同的程式設計風格,一種更有紀律的風格。對於沒有垃圾回收功能的程式語言來說,這一點尤為重要。除了懸掛指標問題(指標儲存了指向不存在物件的地址)外,人們還必須避免建立垃圾:沒有指標指向的堆物件。

除了列表和動態陣列等動態結構之外,堆記憶體最常用於在面向物件程式語言中分配例項變數——即用於表示物件狀態的非靜態資料。為了提供多型性,這需要同一個控制代碼儲存大小可變的物件的引用,動態分配堆中的記憶體成為唯一的選擇。[2]

總結

[edit | edit source]

前面介紹的內容引出了下面的圖。基本上,正在執行的程式的記憶體佈局包括程式碼段和資料段。前者是不可變的,也就是說在執行時不能被改變。出於這個簡單的原因,編譯器將程式碼頁面標記為只讀。這可以防止(意外或惡意)覆蓋這些頁面。資料段,除了編譯時常量外,都是讀寫。編譯時常量可以作為立即數資料合併到程式碼段中。執行時常量(如在 C#、Java 中找到的)可以透過地址進行操作,非常像變數。但它們必須儲存在只讀頁面中。

執行程式的各個部分示意圖

至於資料段的細分,靜態資料區用於分配全域性和靜態區域性資料物件。此類資料物件的初始化是在執行時之前完成的。如果程式設計師可能沒有提供顯式的初始值,編譯器保證它們在表示中都為零。對於整數,此值被解釋為 0;對於實數,被解釋為 0.0;對於控制代碼或指標,被解釋為 null;對於布林值,被解釋為 false;等等。該區域由編譯器管理,在執行時之前分配,並且其中的物件具有靜態作用域。

執行時棧用於分配普通的區域性變數、引數、臨時變數和一些控制資訊。除非程式設計師沒有提供初始值,否則不會提供預設初始值。這是因為該區域是在執行時分配的,任何額外的操作都會導致效能損失。因此,如果程式對初始值做出假設,程式設計師必須明確提供這些值。儘管子程式可以被呼叫無限次,但在呼叫子程式(前奏)過程中要執行的操作和在從子程式返回(後奏)過程中要執行的操作始終是相同的。這些模板被放在程式碼中:每次呼叫子程式時都會執行前奏,並跳轉到被呼叫者的開頭;每次子程式返回時,都會執行後奏,並將控制權返回到 IP 中包含的指令。由於此序列始終相同,因此編譯器可以管理執行時棧。執行時棧資料物件的範圍僅限於建立它們的幀的生命週期;也就是說,它們具有區域性作用域。

最後,堆用於分配具有動態作用域的資料物件。它們的用法模式不可預測,這意味著它必須由程式設計師管理,儘管垃圾收集器可以減輕一些負擔。我們對執行時棧資料物件初始化所做的推理也適用於堆物件:如果需要,程式設計師必須加倍努力。

不同資料段屬性表

引數

[edit | edit source]

子程式,就像程式一樣,可以被認為是將一組特定的值對映到一組特定的結果。這些值和結果的名稱統稱為引數。輸入引數指的是輸入到子程式並由子程式操作的值。輸出引數透過子程式內部完成的處理接收它們的值;這些是結果。一個專門指定的輸出引數也稱為子程式的返回值。輸入/輸出引數提供呼叫者和被呼叫者之間的雙向通訊:它們既可以用於將資料輸入到子程式,也可以用於從子程式輸出結果。[3]

示例:一個 Ada 子程式,用於查詢其兩個引數中較大的一個。

PROCEDURE Max(num1: IN Integer; num2: IN Integer; larger: OUT Integer);
或者
FUNCTION Max(num1: IN Integer; num2: IN Integer) RETURN Float;

示例:一個 Ada 子程式,用於交換其引數的值。

PROCEDURE Swap(num1: IN OUT Float; num2: IN OUT Float);

當呼叫子程式時,子程式名稱後面跟著一個引數列表,該列表通常用括號括起來。引數的數量和順序與子程式規範中列出的引數相對應。呼叫者提供的引數稱為實際引數,而在子程式規範中提供的引數稱為形式引數。

一些程式語言可以減輕程式設計師對上一段中所述限制的負擔。例如,C、C++ 和 Java 等程式語言為程式設計師提供了定義具有可變大小引數列表的子程式的功能。類似地,Lisp 和 Ada 等程式語言使程式設計師能夠以非順序的方式將引數與形式引數匹配。

示例:在 C 中傳遞可變數量的引數。

int printf(const char* format, ...);

上面的原型中的省略號表示 printf 可以傳遞零個或多個引數,這些引數在第一個固定引數之後。第一個引數中傳遞的格式資訊大概會提供資料來確定後續引數的數量和型別。

printf("Sum of %d and %d is %d\n", num1, num2, num1 + num2); printf("Class average in CSE224 came out as %f", avg);

示例:Lisp 中的命名引數。

(defun make-complex (&key real-part imag-part) (cons real-part imag-part))

上面的 &key 關鍵字允許 make-complex 的使用者透過將引數與形式引數名稱關聯來傳遞引數。以這種方式傳遞的引數稱為關鍵字引數

(setq c1 (make-complex :imag-part 4.0 :real-part 3.0))

引數傳遞機制

[edit | edit source]

將引數傳遞給子程式對應引數的機制稱為引數傳遞。引數傳遞活動遵循引數求值的活動,其中呼叫程式(也稱為呼叫者)中的每個引數都與其在子程式(也稱為被呼叫者)中的對應引數相關聯,並執行任何必要的計算。

按值呼叫

[edit | edit source]

當按值將引數傳遞給引數時,引數被設定為子程式的區域性變數,並初始化為引數的值。實際上,引數的值被複制到為引數保留的位置。因此,引數的儲存位置受到子程式內更改的保護。

示例:一個返回其引數立方的 C 函式。

// 計算給定值的立方體不是一個特別好的實現。 long cube(int n) { long result = n * n * n; return(result); } /* end of long cube(int) */ a = cube(m); ... ...

按位置呼叫

[edit | edit source]

當使用此機制將引數傳遞給子程式時,呼叫者傳遞對該引數的引用或機器地址。然後,引數是一個指向引數位置的指標變數。因此,它也稱為按引用呼叫按地址呼叫

這種方法不是將值傳送到子程式,而是讓兩個模組共享駐留在某個儲存位置的資料。資料共享產生的效果是,按位置傳遞的變數引數可以不受限制地從子程式中訪問。換句話說,子程式中的語句可以訪問儲存在儲存位置中的資料,既可以讀取也可以寫入。當子程式終止時,指標會被釋放,但對引數所做的任何更改當然都是永久性的。因此,這種方法與全域性變數幾乎沒有區別。它們都會產生副作用。

當使用全域性變數時,被呼叫者中使用的資料名稱必須與呼叫者中使用的資料名稱完全相同。這是 BASIC 和 COBOL 中唯一可用的資料共享方法。與使用全域性變數相比,使用位置引數的優勢在於,用於引數的資料名稱與用於引數的資料名稱無關。

示例:用於交換兩個變數值的 OBERON 過程。

PROCEDURE swap(VAR a, b: INTEGER); VAR temp : INTEGER; BEGIN temp := a; a : = b; b := temp; END swap; swap(x, y); ... ...

示例:用於交換兩個變數值的 C 函式。

int x = 3, y = 5; ... ... void swap(int *a, int *b) { int temp = *a; *a = *b; *b = temp; } /* end of void swap(int*, int*) */ ... ... swap(&x, &y); ... ...

上面的 C 函式和前面的示例完成了相同的任務:交換兩個整數變數。呼叫者和被呼叫者之間傳遞引數資訊的方式相同:透過實際引數的地址。但是,仍然有一個(OBERON)被稱為按引用呼叫,而另一個(C)被稱為按值呼叫。OBERON 工具的使用者不需要知道資料是如何在呼叫者和被呼叫者之間傳遞的;他們只需要知道過程的作用即可。另一方面,C 工具的使用者必須知道他們必須傳遞一個地址才能看到實際引數的修改後的值,這是在 OBERON 情況下由編譯器完成的;他們必須知道“如何”問題的答案。因此,可以說使用者實際上是在按值傳遞地址,而不是變數本身;他們使用按值呼叫來模擬按引用呼叫。

問題
在 C 中,陣列無論其大小如何,都作為指向其第一個元件的指標傳遞。這背後的原理是什麼?

按位置呼叫的變體是按結果呼叫機制,與按位置呼叫不同,呼叫者不需要初始化傳遞的引數。換句話說,按結果呼叫實現輸出引數,而按位置呼叫實現輸入輸出引數。

示例:C# 中按結果呼叫機制的示例。

class CName { ... public static void Add(int lhs, int rhs, out long result) { result = lhs + rhs; return; } // end of void Add(int, int, long) ... } // end of class CName ... long res; int lhs = 3; ... CName.Add(lhs, 5, res); Console.WriteLine(Sum of {0} and 5 is {1}, lhs, res); ...

上面的片段將產生以下輸出:3 和 5 的和是 8

按結果傳遞引數用於從被呼叫子程式返回的值。因此,如果在上面的示例中省略對 result 的修改,C# 編譯器會將其視為錯誤。

使用這種引數的效果可以透過將引數值作為子程式結果的一部分返回來獲得。上面的示例可以改寫如下

public static long Add(int lhs, int rhs) { return (lhs + rhs); } // end of long Add(int, int)

按名稱呼叫

[edit | edit source]

這種機制將每個引數的求值推遲到在子程式執行期間實際需要時。它不是將值或值的地址傳遞給引數,而是傳遞一個求值規則。至少在概念上,它是一種文字替換機制。每次引數名稱出現在子程式文字中時,它都會被“替換”(實際上沒有,但效果就像被替換一樣)為引數的精確文字。

對於按名稱呼叫,如果引數是標量變數,其效果與按位置呼叫相同;如果引數是表示式,其效果與按值呼叫相同。

示例:偽帕斯卡中的按名稱呼叫。

function SIGMA(name x: real; name k, n: integer):integer; begin real s = 0; for k := 1 to n do s := s + x; SIGMA := s end ... ... SIGMA(A[i], i, 10); SIGMA(A[i] * B[i], i, 10);

第一個呼叫返回向量 A 中元素的總和,而第二個呼叫返回兩個向量 AB 的標量積。

可以利用返回給定引數地址的函式(稱為thunk)來模擬這種行為。

按需呼叫

[edit | edit source]

在某些程式語言中,特別是函數語言程式設計語言,表示式只有在其他表示式或函式需要其輸出時才會被求值。這種延遲求值,也稱為惰性求值,在引數傳遞機制中被稱為**按需呼叫**。其效果與**按名呼叫**非常相似,因為引數只在需要其值時才會被求值,而不是在呼叫時立即求值。

示例:Haskell 中的延遲求值。

non_strict_func a b = if a == 0 then 0 else a / b non_strict_func 0 (1/0) 0.0 :: Double

由於 Haskell 延遲求值其引數,因此上面的程式碼片段會產生“更自然”的結果:0。然而,執行相同片段的 C 版本會導致除零異常。[4] 我們說一個子程式在延遲求值的引數中是**非嚴格的**,而一個子程式在引數進入子程式之前就被求值,則被稱為**嚴格的**。

筆記

[edit | edit source]
  1. 在舊的程式語言中,宣告部分通常需要在可執行部分之前,而在其他語言中,兩種型別的語句可以混合排序。
  2. 注意關鍵字是多型性,而不是繼承。因為在沒有多型性的情況下,自然使用繼承是不可能的。然而,在某些程式語言(如 C++)中,也可以在沒有提到多型性的情況下討論繼承。在這種情況下,例項變數可以分配在資料段的其他部分。
  3. 使用輸出引數從子程式返回結果被認為是糟糕的程式設計實踐。如果可能,子程式應該實現為函式,輸出引數應該作為函式結果的一部分返回。
  4. 為了公平起見,應該強調的是,這一說法對幾乎所有基於 Algol 的程式語言都是正確的,包括 C、Pascal、C++、Java、C# 等等。
華夏公益教科書