為Python程式設計師準備的C++知識

程序員書屋 發佈 2020-05-26T18:57:05+00:00

目標了解C++的編譯過程。了解C++的內置數據類型、輸入/輸出、決策語句和循環語句這些主要組成部分的語法和語義。了解C++的數組的語法和用法。了解C++的函數和參數傳遞機制的細節。了解C++的變量的作用域和它的生命周期。8.

目標

  • 了解C++的編譯過程。
  • 了解C++的內置數據類型、輸入/輸出、決策語句和循環語句這些主要組成部分的語法和語義。
  • 了解C++的數組的語法和用法。
  • 了解C++的函數和參數傳遞機制的細節。
  • 了解C++的變量的作用域和它的生命周期。

8.1 概要

本書前面部分的章節重點介紹了如何使用Python語言來開發算法和數據結構。Python具有相對簡單的語法,以及強大的內置數據結構和函數庫,所以它是一個非常適合於初學者的優秀語言。目前看來,Python在行業內的使用量正在持續增長。然而,即使Python已經是最常用的語言之一,計算機科學家也應該知道若干種其他不同的計算機語言。不同的程式語言提供了不同的功能,這就讓任何一個單一的程式語言都不會是所有問題的最好選擇。不同的程式語言具有不同的能力,而這將會鼓勵你不斷地去思考解決問題的更多方法,因此學習新的程式語言有助於擴展你解決問題的能力。

Python語言的數據結構和許多內置函數隱藏了程序的許多底層實現細節。正如我們之前討論的那樣,使用Python的時候,你不必像在使用某些語言的時候那樣,擔心內存釋放相關的操作。很明顯,當人們在開發更高級別的語言,並為這些語言編寫解釋器和運行時環境的時候,需要了解實現它們所需要的所有底層細節。應該明白的一點是,Python一般來說並不是處理大量數據或者需要大量計算的應用程式最好的語言。這是因為,它使用了額外的內存來存儲每個對象的引用計數以及這個對象的數據類型。而且,它的解釋器在執行的時候,還必須要把這個Python的執行語句從字節碼轉換成機器代碼。

在這一章和接下來的4章里,我們將會介紹C++程式語言的一個很大的組成部分。C++對Python程式設計師來說,是非常優秀的補充語言,這是因為它是一種相對來說級別較低的語言。它需要你了解許多底層實現的細節,其中就包括了內存管理。C++可以更有效地使用計算機的內存和CPU。能夠同時使用Python和C++編程,你就能夠在解決給定問題的時候選擇更恰當的語言。在實際工作中,當算法的速度和內存使用很重要的時候,Python程序通常都會去直接使用已經編譯好的C或C++代碼。

8.2 C++的歷史和背景

C語言是在20世紀70年代早期開發出的一個跨平台系統語言。在20世紀60年代的時候,當一台計算機被製造出來之後,每台機器都會裝上使用彙編語言編寫的新的作業系統。於是,AT&T貝爾實驗室的布萊恩·柯林漢(Brian Kernigan)和丹尼斯·里奇(Dennis Ritchie)決定開發一種用於系統代碼的高級跨平台語言。他們和肯·湯普遜(Ken Thompson)一起用C語言開發了UNIX作業系統,而且,他們可以輕鬆地把這個作業系統移植到新的計算機硬體上。直到現在,C語言仍然被廣泛地用在對於速度至關重要的應用里,比如說像作業系統和科學計算這些地方。事實上,Python的解釋器就是用C語言編寫的。

在20世紀70年代末和80年代初的時候,計算機科學家們開始意識到:面向對象設計以及面向對象的編程將能夠允許他們編寫出更易於維護以及可重用的代碼。當時有若干個已經存在了的面向對象語言,但C語言在裡面是最受歡迎的。在20世紀80年代早期,AT&T的比雅尼·史特勞斯特魯普(Bjarne Stroustrup)決定開發一種對C程式設計師來說相對容易學習的新的面向對象的語言。他為C語言添加了顯式的面向對象編程的支持,並稱這個新語言為C++。除了會使用獨有的新關鍵字之外,C++基本上是向後兼容C語言的,這也就使得C語言的程式設計師可以很容易地使用C++。完整的C++語言比C語言更龐大、更複雜,許多程式設計師在編寫C++代碼的時候只會使用C++的一部分功能。

C和C++是比Python更低級的語言。C語言不提供內置的列表和字典類型。C++語言則使用了被稱為標準模板庫的類和方法集來支持一些更高級別的數據結構。相比Python而言,C和C++顯得更簡潔,而且它們使用了更多的特殊字符(比如,&&相當於Python的and,||相當於or)。新版本的C++除了特殊符號之外,也開始允許使用and和or了。

這本書里主要使用的是C++語言,然而,在這一章里包含的大部分內容也同樣適用於C語言。後面章節里的一些話題也同樣適用於C語言,但在通常來說,我們不會明確地去指出什麼部分適合於C語言。簡單來說,任何涉及類的部分,都不會適用於C語言。

當你讀到前面的段落的時候,你肯定會問為什麼你需要學習C++語言,用它來編寫代碼看起來會更難一些。在你發現編寫C++代碼更困難的同時,你還會發現在執行相同操作的情況下,C++原始碼幾乎總是比Python原始碼更長。但是,Python並不是適用於所有應用程式的最好的語言。使用C或C++這類編譯語言所編寫的代碼,通常來說執行速度會比Python代碼快一個數量級。而且,這些代碼所使用的內存也會比解釋相應的Python代碼要少。直到現在,在許多應用程式領域,你仍然會希望能夠最大限度地提高執行速度,並且有效地利用內存,從而讓你的代碼可以處理大量數據。比如說,你肯定不希望用Python來編寫作業系統,或者像是Web伺服器以及資料庫伺服器這樣的伺服器。最後,學習C++還可以幫助你更好地理解Python解釋器里的細節。

C和C++原始碼將會被編譯成機器語言代碼,而Python使用的則是混合過程,在這個混合過程里,原始碼將會被編譯成字節碼,然後通過解釋這些字節碼來執行。兩種方法都各有優缺點。編譯代碼的執行速度比解釋代碼快得多,但是不如解釋代碼靈活。我們將在後面的章節里討論它們之間的一些差異。編譯C++代碼過程可以用圖8.1所示的圖形來表示。我們將使用下面這個簡單的C++程序來描述編譯過程的工作原理:

// hello.cpp
#include <iostream>
using namespace std;

int main()
{
  cout << "hello world\n";
  return 0;
}

圖8.1 C++代碼的編譯和連結過程

如果告訴你cout是用來產生輸出的,你可能就已經能夠猜到這個程序和print "hello world"這樣的Python程序做的是同一件事了。預處理器(通常被稱為C預處理器,C preprocessor)獲取到原始碼之後,會處理所有以井號(#)開頭的行。示例程序中的#include預處理程序指令會告訴預處理器將iostream文件里的所有代碼複製到我們的源文件里。這和我們把這個文件里的內容複製粘貼到#include語句所在的程序里的效果是一樣的。iostream文件被稱為頭文件(header file)。每個C++源文件可以包含任意數量的頭文件。我們將會在這一章的後面以及之後的章節里更詳細地討論頭文件的細節。現在,你需要知道的是,頭文件包含了一些關於在其他文件里編寫的原始碼的信息。

預處理器的輸出結果仍然是C++原始碼,然後它們會被送到C++編譯器。編譯器的工作是將C++原始碼轉換為特定晶片和特定作業系統的機器語言代碼(計算機CPU可以執行的0和1)。編譯器執行的第一步是檢查代碼里是否存在任何語法錯誤。這是因為存在語法錯誤就意味著程序是不正確的,會導致編譯器將無法確定你的代碼的意思,從而不能完成整個過程。如果你的代碼有語法錯誤的話,編譯器就會停下來,並且向你顯示一條錯誤消息,這條消息會指向它無法理解的那部分內容。這也就意味著,在修復所有的語法錯誤之前,你是沒辦法嘗試運行這個程序的。當原始碼語法都正確之後,編譯器將會生成與C++源文件中的代碼相對應的機器語言代碼。這種機器語言代碼通常也被稱為目標代碼(object code)。

就像我們會把Python程序拆分成多個文件一樣,除最簡單的C++程序以外的所有程序通常都會被分成多個源文件。和圖8.1里所展示的一樣,每個源文件都是會被獨立編譯的。一個源文件可以調用另一個源文件里定義的函數。這也是使用頭文件的主要原因:通過包含在另一個文件中定義的函數的有關信息,編譯器才可以知道你是不是正確地調用了這個函數。連結器(linker)的工作是:把各個機器代碼的目標文件組合成一個可執行程序,並且確保每個被調用的函數都存在於其中的一個目標文件里。大多數作業系統也支持機器代碼庫,這個庫里包含常用的類以及函數的目標/機器代碼。在C++里,輸入和輸出語句是iostream頭文件所聲明的庫中的一部分。在最後,就像圖8.1里一樣,連結器還會把程序里使用的庫里的代碼複製到最終的可執行代碼里去。

由於生成的可執行程序是機器語言,因此它只能在支持這個機器語言和作業系統的計算機上被執行。比如,為運行Windows作業系統的英特爾晶片所編譯的程序,一般來說可以在任何與英特爾晶片兼容的計算機(同一代或更新版本的英特爾晶片)以及相同版本或者更新版本的Windows作業系統上運行。但是,在使用英特爾晶片的計算機上為Linux作業系統編譯的程序,通常都不能在Windows系統上運行,反之亦然。對於簡單的C/C++程序來說,可以對另一個作業系統或計算機晶片重新編譯,從而達到移植(porting)程序的效果。移植程序是指讓程序能夠在不同的晶片或者作業系統上執行的過程。將代碼移植到另一個作業系統真正的困難在於,不同的作業系統有不同的功能庫來支持輸入/輸出以及圖形用戶介面(Graphical User Interfaces,GUI)。許多作業系統都提供了額外的代碼庫。使用了任何這些特定某個作業系統的代碼庫的程序,通常來說都很難移植到其他作業系統。在這種情況下,移植程序將需要把這些庫也移植到其他作業系統上,或者重寫這部分代碼來避免使用這個代碼庫。

Python代碼與機器是無關的,它可以在任何包含Python解釋器的機器上被執行。這就意味著,Python解釋器本身就必須要為這個計算機以及作業系統進行單獨的移植和編譯。如果你的程序使用了特定於某個作業系統的額外的Python模塊(例如僅存在於某個作業系統上的GUI工具包),那麼你的Python代碼將不能被移植到其他作業系統上。如果你只使用了Python的標準模塊,那麼Python程序將能夠在不需要對代碼進行任何修改的情況下,在任何包含解釋器的機器以及作業系統上運行。當然,就像Python解釋器可以在許多不同的系統上編譯來支持它們一樣,許多額外的模塊也是可以被移植到其他作業系統的,很明顯,這就需要更多的工作來完成了。

執行Python代碼的過程與編譯和連結C++代碼的過程有很大不同。圖8.2用圖像表示了這個過程。可以看到,你只能直接執行一個Python文件。但是,你可以通過導入其他Python文件來有效地組合多個源文件的代碼。Python原始碼首先會被編譯成與機器無關的指令集,它們被稱為字節碼。當你運行Python程序或者導入Python模塊的時候,都會自動發生這個過程。你可能已經發現了,在你的計算機里有一些以.pyc為擴展名的文件,這些就是導入Python模塊的時候所創建的字節碼文件。一個字節碼指令對應的是函數調用,或者是添加兩個操作數之類的代碼。

圖8.2 Python編譯和解釋的混合過程

在編譯成字節碼之後,Python解釋器就會開始處理與程序里的第一個語句所對應的字節碼。每次處理字節碼語句的時候,這段代碼都會被轉換為機器語言,進而被執行。就是在這個過程里,解釋器解釋了每一個字節碼語句,並且在每次執行字節碼的時候都把它轉換為機器語言,這也就是為什麼Python代碼的執行效率比編譯過的C++代碼要慢。然而,字節碼可以比純粹的Python原始碼更快地被轉換為機器語言。這也就是為什麼Python會一次性地先把所有的原始碼都轉換為字節碼,而不是在執行每一個Python語句的同時將它轉換為機器語言。

在之後,就像圖里所展示的那樣,你的Python代碼可以調用在機器代碼庫里已經編譯好了的C或C++代碼。這也就能夠讓你在自己的程序里混合使用Python、C和C++ 3種代碼。編寫可以被Python解釋器調用的C或C++代碼需要遵循一些特別的約定,我們不會在書里詳細介紹這一部分內容。對於任何你希望在Python里調用的C或C++代碼,都必須要在對應版本的作業系統以及相應的晶片上進行編譯。

當在Python里編寫顯式的循環調用時,Python和C/C++之間的執行的速度差異將會變得非常明顯。因此,對於大型疊代來說,最好是調用內置的Python方法或函數,而不是直接編寫這個循環來執行相應的操作(如果存在的話)。這是因為Python里內置的方法或函數都是通過編譯了的C代碼來實現的。比如說,你應該已經注意到了,1.3.1小節里我們手動編寫的線性搜索功能和使用index方法的性能差別。總之,權衡應該使用Python還是C/C++的主要因素是:執行速度和代碼量以及開發時間之間的關係。

Python和C++的基本語句是類似的。因此,對於Python程式設計師來說,學習閱讀C++代碼相對會比較容易。然而,學習編寫C++代碼會比較困難,這是因為編寫相應的代碼需要你去學習C++的具體語法細節。但是,Python程式設計師學習C++還是會比沒有編程經驗的人更容易。畢竟,已經了解了一種程式語言的程式設計師也就已經理解了一些基礎知識,像是決策語句、循環、函數這類的常用概念。許多程式語言,包括C、C++、Python、Java、C#以及Perl,它們都使用了相似的語句和語法來讓這門語言能夠和其他語言一樣易於學習。我們通常會認為Python是初學者的理想語言,因為它的語法夠簡單;C++是一種很好的第二語言,因為它類似於Python,但又同時能夠讓大家獲得Python解釋器所隱藏的底層編程細節的相關知識。

這一章和接下來的幾章里所介紹的許多C++的概念一般來說也同樣會適用於C語言,但並不是全部的概念都相通。具體來說,輸入/輸出機制在C和C++里是不同的,這是因為C語言並不完全地支持類。這本書不會涉及在C語言裡的輸入/輸出或者是C語言裡的類的簡化版本——結構。書里的這些關於C++的章節並不是要讓你了解C++語言的所有細節,而是要讓一個Python程式設計師可以快速地開始使用C++語言,並且幫助你了解顯式的內存管理的細節。要成為C++專家,我們建議你去閱讀如比雅尼·史特勞斯特魯普撰寫的C++的參考書這樣的相關書籍。由於C++是一種相當複雜的語言,因此在編寫完整的C++程序之前必須要掌握許多相關知識。我們將在學習過Python的基礎之上開始介紹這些概念。

8.3 注釋、代碼塊、變量名和關鍵字

C++支持兩種類型的注釋。和Python里的#注釋標記相對應的是兩個正斜槓(//)。一行里從兩個正斜槓開始到行尾的任何字符都將被視為注釋,從而被編譯器所忽略。同時,C++編譯器還支持多行注釋,這種注釋以/*開頭,以*/結尾:

// this is a one-line C++ comment

/* this is a
multi-line
C++ comment */

Python使用縮進來表示代碼塊。而在C++里,使用大括號對({})來標記代碼塊的開頭和結尾。在C++中,縮進除了讓代碼更易於閱讀之外,不會有任何效果。因此,為了易讀性,程式設計師們通常還是會遵循和Python相同的縮進規則。空白(空格、制表符和換行符)除非是在字符串里,否則對C++代碼不會產生影響。而由於空格、制表符和換行符在C++中沒有任何作用,因此,每個C++語句都必須以分號作為結束。於是,對於熟悉Python的程式設計師來說,在語句結束的地方忘記了分號是非常常見的一個錯誤。更麻煩的事情是,當你忘記分號的時候,許多C++編譯器都會顯示下一行代碼存在問題。因此,在跟蹤編譯錯誤的時候,通常需要查看編譯器指示存在錯誤的代碼行的上面一行或多行代碼。

合法C++變量名的規則與Python的規則是相同的:變量名必須以字母或下劃線開頭;在首字母或下劃線之後,後續的字符可以是字母、數字或者是下劃線;除此之外,變量名也不能是C++的關鍵字。圖8.3列出了C++所有的關鍵字。[1]但是在本書里,將不會涵蓋所有C++的關鍵字的詳細信息。

圖8.3 C++的關鍵字

8.4 數據類型和變量聲明

和Python不同的是,C++要求所有變量在使用前都必須要被顯式地聲明。C++支持int、char、float、double以及bool這些內置的數據類型。在使用指定的數據類型聲明變量之後,變量就只能保存這個類型的數據值了。int類型對應於Python中的整數類型,並且也支持相同的操作,包括模數運算符(%)。但是,與Python不同的是,Python里的整數會根據需要自動地轉換為長整數,而C++的int類型在值太大而無法存儲的時候會靜默地溢出。C++的int類型必須至少使用16位內存,也就是說它合法的值大約在−32 000~+32 000範圍內。然而,大多數系統使用的內存都至少是32位,也就是說它可以存儲的合法數字在−20億~+20億這個範圍之內。char類型則會被用來存儲單個字符。在內部,它存儲的是字符的ASCII值,因此char變量可以存儲介於−128~+127的值。

C++的int類型還支持修飾符short和long。在大多數32位系統里,short int是16位,int是32位,而long int也是32位。它們的區別是:long int類型保證至少使用32位內存,而int僅僅保證至少使用16位內存。int和char類型也都支持unsigned修飾符,這個修飾符被用來表示變量只支持非負數,從而允許更大的值。32位unsigned int大約可以支持0~40億的數字,而不是−20億~+20億之間的值。unsigned char則可以存儲0~255的值。

float和double數據類型對應的是被數學家們稱為實數的類型,但是在計算機里,它們並不是被完整地存儲的。這是因為,在內部它們只能使用0和1的位來表示這個實數,因此,它們其實更適合被稱為浮點數(floating point number)。float類型使用32位內存來存儲數字,並且提供6位或7位有效十進位數字。double類型使用64位內存來存儲數字,並且提供15位或16位有效十進位數字。在Python里,它使用的是C語言的double類型來實現的浮點數。這是因為,現代計算機都具有足夠大的內存,並且現在大多數浮點運算都是在硬體中實現的,所以在幾乎所有情況下都應該使用double類型而不是float類型。表8.1總結了C++數據類型的一些細節。


在C++里,變量可以在代碼塊的任何一個地方被定義;之後,從這個地方開始,一直到這個代碼塊的末尾,都能訪問到它們。出於對代碼格式以及可讀性的考慮,許多C++程式設計師會在代碼塊的頂部聲明這部分代碼將會需要的所有變量。聲明變量是通過指定類型,然後在類型之後跟著變量名來完成的。在聲明變量的同時也可以用逗號分隔變量名,這樣就可以在一行上聲明多個相同類型的變量了。下面的代碼片段展示了一個包含變量聲明的簡單程序。根據我們之前的內容可以知道,cout被用來生成輸出,因此,利用Python的相關知識,你應該能夠猜到這個C++程序最終會輸出的內容:

// output.cpp
#include <iostream>
using namespace std;
int main()
{
  int i, j;
  double x, y;

  i = 2;
  j = i + i;
  x = 3.5;
  y = x + x;
  cout << j << "\n" << y << "\n";
  return 0;
}

你可能會想,為什麼C++要求你聲明變量,而Python並不需要你這樣做。要知道,C++代碼會被直接編譯為機器語言,而機器語言的指令是基於特定的數據類型的。比如,所有的CPU都有用來添加兩個整數的指令,而且大多數現代CPU甚至還有添加兩個浮點數的指令。一些老式的CPU沒有直接的浮點指令,但是可以通過使用多個整數指令在軟體里實現浮點計算,而這樣的操作會使浮點運算慢不少。在這個例子裡,編譯器需要知道數據類型來為j = i + i這個添加兩個整數的語句生成機器指令;也需要知道數據類型來為y = x + x這個添加兩個浮點數的語句生成機器指令。因此,指示了數據類型的變量聲明,將能夠允許編譯器編譯出正確的機器指令。

而Python解釋器則會把這兩個相對應的Python添加語句,比如add i, i和add x, x,轉換為相同的字節代碼。也就是說,相同的字節代碼會被用來表示這兩種不同情況下的add語句。然後,當Python解釋器執行這部分字節代碼的時候,它才會去確定這兩個操作數的數據類型,從而在第一種情況下生成整數add指令,在第二種情況下生成浮點add指令。如果兩個操作數是字符串,那麼它將生成連接兩個字符串的機器指令。於是乎,由於Python在真正執行這個語句之前不會創建機器指令,因此在編寫代碼的時候,它不需要像C++編譯器那樣需要先知道數據的類型。這樣,在Python里,即使相應的變量的數據類型在語句多次執行之間發生了變化,代碼也可以正常工作。下面這個看起來挺蠢的Python程序就表示了這個例子。在第一次循環的時候,語句x + x添加了兩個整數,而在第二次循環里它被用來連接兩個字符串。在C++里,如果不為每種不同的數據類型使用單獨的變量的話,這段代碼是不可能正常工作的:

for i in range(2):
    if i == 0:
        x = 1
    else:
        x = 'hi'
    print x + x

這類問題的術語是動態類型(dynamic typing)和靜態類型(static typing)。Python使用了動態類型,這也就意味著變量或名稱的數據類型是可以更改的。相應地,C++使用的是靜態類型,也就是說特定變量的數據類型在編譯時是固定的,是不能更改的。Python和C++在處理變量方面的另一個顯著區別是:C++的變量在函數被調用的時候就被分配好了內存,並且在執行函數的時候,這個變量會繼續使用相同的內存位置。然而,純理論來說,在Python里使用術語變量是不正確的,而應該使用術語名稱。Python的名稱是指存儲在內存中的某個對象。在執行Python函數的過程中,名稱所引用的內存位置是可以改變的。我們在4.2節里曾經討論過這個話題。在下面這個簡單的程序里,名稱x引用了兩個不同地址里的兩個不同對象:

x = 3
x = 4

Python的名稱在它被使用之前是不會分配相應的內存地址的,而且,在每次把新對象分配給它的時候,這個內存地址都會更改。然而,C++的變量將會一直使用一個特定的被分配的內存位置,並且在執行期間一直都不會被改變。因此,相同的內存位置會被用來存儲3以及之後的4。我們將在第8.7節和第10章里更詳細地研究這個問題。

C++還支持常量和編譯時的檢查,來保證程序不會嘗試更改某個值。例如定義一個常量const double PI = 3.141592654;。在程序里定義了這個常量之後,如果還包含另一個為這個常量賦值的語句(例如,PI = 5)的話,那麼就會發生語法錯誤,程序將無法編譯。許多程式設計師都使用全大寫字母的名字來表示常量。

C++不像Python那樣提供了許多內置的像列表、元組和字典這樣的高級數據結構。C++支持可以被用來構建類似數據結構的數組(在8.11節里介紹)。正如你所期望的那樣,因為C++是一種面向對象的程式語言,所以它提供了類來讓你去封裝數據成員和相應的函數。因此你可以去構建用來操作相應數據的列表、元組以及字典類。我們將在9.1節講解C++類。

8.5 Include語句、命名空間以及輸入/輸出

Python里使用import語句來訪問另一個文件里編寫的代碼。在C++里,將會使用#include語句來把在不同文件里定義的類和函數聲明複製到當前文件里,從而讓編譯器可以檢查這些函數或者類有沒有被正確地使用。包含這些聲明的文件稱為頭文件(header file)。頭文件除了包含類和函數聲明之外,還可以包含一些其他的元素,但我們現在不用去關心這部分內容。關於函數原型的細節部分將會在8.12節里討論,它的基本思想是:函數原型指定了參數的數量、每個參數的數據類型以及函數的返回類型。函數原型能夠讓編譯器創建一個列表來包含所有存在的函數和類。因此,當你嘗試調用文件里沒有被定義過的函數的時候,編譯器就可以判斷在其他地方是不是已經聲明了具有這個名稱的函數,以及你是否使用了恰當的參數來調用這個函數。同樣的概念也同樣適用於類的定義,從而讓編譯器可以確定你是否正確地使用了一個類(也就是,存在一個具有這個名稱的類,並且這個類里包含了你使用的這個方法)。頭文件里通常不會包含函數以及類方法的代碼,它只會包含相應的聲明。一般來說,會有一個單獨的實現文件來包含函數的定義(即函數體)。但是這個方面也有一些特例,我們將會在稍後的章節討論這些特殊情況。函數和類的實際機器代碼將會由連結器組合在一起,從而創建可執行代碼(如圖8.1所示)。我們將在這一章的後面介紹一些關於編譯以及連結的其他詳細信息。

與Python模塊創建的命名空間一樣,C++也支持類似的命名空間(namespace)技術。每個Python文件都是它自己的模塊,這樣也就直接地擁有了自己的命名空間。C++並不強制要求使用命名空間,但是很多內置的C++類和函數都是在命名空間裡定義的。我們將在選讀小節8.17.2里介紹如何編寫自己的命名空間的相關細節。在本章的這部分內容里,我們將只介紹應該如何使用現有命名空間的基礎知識。最常用的命名空間是標準命名空間,它的縮寫為std,而且,這個命名空間是C++程式語言的定義的一部分。由於在std命名空間裡聲明了許多C++內置的函數和類,因此我們需要先知道如何使用命名空間,才能去編寫C++程序。

C++使用函數庫來處理輸入/輸出相關的操作,這就需要包含一個文件來訪問這個函數庫。訪問這個函數庫最簡單的方法是將下面這段代碼放在文件的頂部:

#include <iostream>
using namespace std;

正如我們前面提到過的那樣,#include語句能夠讓C++編譯器快速地把iostream頭文件里的內容複製到你的文件里去,然後編譯整個文件。這個頭文件里定義了各種輸入/輸出相關的函數和類,這些函數和類都位於命名空間std里。C++輸出語句使用的是iostream文件里定義的ostream類的cout實例。using namespace std這條語句將會告訴編譯器,接下來的代碼將允許直接訪問std命名空間裡定義的所有元素。這就像是Python里的包含語句from math import *一樣,它將允許訪問math模塊里定義的所有元素。如果沒有using語句的話,就只能使用std::cout這樣的全稱來引用它。另一個方案是:在包含語句之後使用using std::cout語句。這將能夠讓我們在使用cout實例的時候不用去指定std::前綴,但對於std命名空間裡的其他任何成員,都不會允許我們直接訪問。這在Python里也就類似於from math import sqrt這樣的語句,它將能夠讓我們訪問math模塊里定義的sqrt函數,但不能訪問math模塊里定義的除它以外的其他任何元素。C++和Python的命名空間(每個Python文件是一個單獨的命名空間)之間的主要區別在於:在C++里,即使不使用using語句,都始終可以使用全名(namespace::item)來訪問C++命名空間裡定義的元素;而對於Python命名空間來說,必須使用import語句來允許使用命名空間裡的元素。

C++的cout實例與Python里的print語句的工作方式是類似的,它們都可以輸出變量、表達式以及常量。Python使用逗號來分隔一個語句里輸出的多個元素;而在C++里,則會使用符號<<來分隔在一個語句里輸出的多個元素。同時,C++不會像Python那樣在每個以逗號分隔的元素之間自動地插入空格,而且C++也不會像Python的print語句那樣自動輸出換行符。和Python類似的是,任何不在引號內的元素都會被執行。但是,必須使用雙引號來表示C++的字符串。在C++里,單引號僅被用來表示單個字符(即內置的字符數據類型)。

所有的C++程序都必須要有一個名為main的函數(主函數),同時,這個main函數還必須要能夠返回一個int值。在執行程序的時候,這個函數將會被調用。

把到目前為止所學到的概念匯總到一起,你就應該能夠理解"hello world"代碼示例里的大部分語法了:

// hello.cpp
#include <iostream>
using namespace std;

int main()
{
  cout << "hello world\n";
  return 0;
}

和Python一樣,C++也使用反斜槓來作為轉義字符。上面的程序里使用了\n,從而能夠在輸出hello world之後輸出一個換行符,這樣新的輸出將會換行。C++還允許使用在std命名空間裡聲明的endl(如果沒有使用using namespace std這一行的話,就必須要用全程std::endl)來表示換行符。因此,上面的cout語句也可以寫成cout << "hello world" <<endl。通常來說,會在cout輸出語句以引號結束的時候使用\n,而在語句的最後一項不是字符串常量的時候使用endl。使用"\n"和endl的一個區別是:endl將會強制刷新輸出緩衝區。正是利用了緩衝輸出,作業系統才可以等待並在稍後的時間裡將輸出的數據發送到螢幕(或文件,如果你正在寫入文件),從而提高整體效率。當程序正常退出的時候,輸出緩衝區會被刷新,但如果遇到了程序崩潰,你就可能看不到程序已經生成的某些輸出了。而這樣,你以為的程序崩潰的地方就會在實際崩潰的位置的前面。因此,如果你使用cout語句來幫助你跟蹤程序崩潰的位置的話,你就需要使用endl來換行。

與cout實例類似的,C++還有一個在istream類里的cin實例,它也是標準命名空間的一部分,用於輸入。符號>>用來分隔多個輸入的值。cin語句使用空格來分隔多個值,並跳過任何空白(空格、制表符或空行)來查找下一個數字、字符、字符串等。下面的程序和執行輸出示例表示了你在第一門編程課程里學習過的程序以及它的執行結果。在這裡,我們使用符號␣來表示原始碼和輸出的空格,這是因為cout並不會像Python的print語句那樣自動輸出空格以及換行:

//␣ctof.cpp
#include␣<iostream>
using␣namespace␣std;

int␣main()
{
␣␣double␣celsius,␣fahrenheit;

␣␣cout␣<<␣"Enter␣Celsius␣temperature:␣";
␣␣cin␣>>␣celsius;
␣␣fahrenheit␣=␣9.0␣/␣5.0␣*␣celsius␣+␣32.0;
␣␣cout␣<<␣celsius␣<<␣"␣degrees␣Celsius␣is␣";
␣␣cout␣<<␣fahrenheit␣<<␣"␣degrees␣Fahrenheit\n";
␣␣return␣0;
}

Enter␣Celsius␣temperature:␣22.5
22.5␣degrees␣Celsius␣is␣72.5␣degrees␣Fahrenheit

如果我們將celsius變量聲明為int類型,那麼用戶就只能輸入整數值。這樣做的話,會讓這個程序不那麼通用。因此,在聲明變量的時候,你應該問一問自己,這個變量可能值是什麼。如果它可能是浮點值的話,那麼就應該使用double類型;但如果它只會是整數的話,就應該使用int類型。

當使用cin來輸入多個值的時候,用戶可以輸入任意數量的空白來分隔各個值。用戶可以通過輸入一個或多個空格或者制表符來分隔兩個值,或者是在輸入每個數字後按回車鍵(Return)來輸入數據。與Python類似的,在按下回車鍵之前程序不會去處理輸入操作。下面是一個完整的代碼示例,它展示了如何使用cin語句來輸入兩個值。我們將把這個程序基於特定輸入的輸出留作練習:

// input1.cpp
#include <iostream>
using namespace std;
int main()
{
  double x, y;
  cout << "enter x and y: ";
  cin >> x >> y;
  cout << "x = " << x << " and y = " << y << endl;
  cout << "x + y = " << x + y << endl;
  return 0;
}

在C++里使用cin輸入值會跳過空白這個現象,在使用它輸入字符的時候會有一些麻煩。在讀取數字的時候跳過空白肯定是應該的,但是,由於輸入的值也是char數據類型,因此在使用cin讀取char數據類型的時候,用戶無法將自己輸入的空白存儲在char類型的內存空間裡。比如說,如果用戶在執行下面這個程序的時候輸入的是x␣y␣z的話,程序的輸出將會是xyz,而不是你所猜想的x␣y:

// input2.cpp
#include <iostream>
using namespace std;

int main()
{
  char a, b, c;

  cin >> a >> b >> c;
  cout << a << b << c;
  return 0;
}

8.6 編譯

我們已經介紹了足夠的背景知識了,因此你已經可以開始自己編寫簡單的C++程序了。我們在這裡將會簡單地討論一下應該如何在你的計算機上編譯程序。目前,3個最常用的作業系統分別是Microsoft的Windows、UNIX/Linux以及Mac OS X。這些作業系統都提供了相應的應用程式來編輯和編譯程序。微軟銷售的是一個當前叫作Visual Studio的開發環境的完整版本。同時,它還提供了一個免費但有限的版本——Visual Studio Express。如果你使用的是Microsoft Windows,那麼,你可以從Microsoft的網站上下載這個軟體。儘管它沒有完整版本里的所有功能,但對於這本書里的所有C++的示例和練習,它應該夠用了。Apple向所有人都免費提供了名為Xcode的完整開發環境。它可能已經預先安裝在了你的Mac計算機上,如果沒有的話,你也可以從Apple的網站下載(在撰寫這本書的時候是需要註冊的,但是是免費的)。UNIX有許多不同的作業系統。我們不會在這本書里介紹UNIX的歷史,但你需要知道不同的公司會銷售略有不同的UNIX版本。事實上,Apple的Mac OS X就是建立在UNIX作業系統之上的。Linux作業系統是UNIX系統的免費克隆版本。在這本書里,我們將會使用術語UNIX來代表包括Linux在內的所有UNIX系統。

Visual Studio和Xcode的圖形開發環境會隨著時間的推移而發生變化,因此我們不會在這本書里詳細介紹如何使用這些應用程式來編寫和編譯C++代碼。因為是圖形開發環境,因此你自己就可以很容易地弄清楚,或者在別人的指導下弄清楚應該怎麼使用它們。在大多數的UNIX系統上,GNU g++編譯器會被用來編譯C++程序。當然,也有適用於各種UNIX系統的商業級的C++編譯器。嚴格來說,Mac的Xcode也只是g++編譯器的圖形化前端,因此,你可以在Mac上直接通過終端來使用g++。由於g++的命令行用法在幾年內都沒有改變,所以我們將會介紹一下g++在UNIX系統上編譯C++程序的基本用法。

C++程序的文件擴展名通常使用.cpp、.C以及.cc。在這本書的各個例子裡,我們將使用.cpp擴展名,這是因為它被3個常用作業系統上的編譯器所使用。對於不使用任何其他庫、被稱作program.cpp的單個文件來說,如果你的程序在語法上都是正確的話,命令g++ program.cpp -o program將會使用C++源文件program.cpp來創建一個名為program的可執行文件。你可能還記得在這一章開頭跟編譯相關的話題里所提到的多個步驟:預處理、編譯以及連結。我們指定的g++命令執行了所有步驟。

根據UNIX系統上的make版本,指令make program也可能會產生相同的結果。但要記住,program.cpp文件里必須要包含一個main函數,這是program文件開始執行的地方。要執行這個程序的話,可以鍵入./program然後按回車鍵。在可執行程序名稱前面加上./是用來確保作業系統在當前目錄中執行這個程序最安全的方法。當然,根據你的UNIX帳戶的設置,你可能也可以只輸入program來執行這個程序,但我們還是建議你養成輸入./program的習慣,因為不論配置是怎樣的,這個指令都是有效的。

和Python類似,我們最好把大的應用程式拆分為被良好組織的許多較小的源文件。就像我們在這一章的開頭提過的那樣,C++里的每個文件都會被單獨編譯,從而為這個文件中的C++代碼生成相應的機器語言代碼。那麼,在使用g++的時候,每個以.cpp擴展名結尾的源文件都可以通過使用g++命令的-c標誌來編譯出一個擴展名為.o的目標文件。這個命令對應著預處理和編譯這兩個步驟。如果你不使用-c標誌的話,g++的命令將會去嘗試執行預處理、編譯和連結3個步驟。然而,在你有多個源文件的時候,連結這個步驟是你不想執行的。

圖8.4所示為如何編譯兩個源文件。這個例子裡,main函數是在test_sort.cpp文件中。最後一行的指令是執行連結步驟,它將會檢查test_sort.o文件是否包含一個叫作main的函數,以及所有文件調用的每個函數在這些以.o 為後綴名的文件里是否都只出現了一次。在這個例子裡,我們還在g++命令里添加了-g標誌,因此它的輸出將會包含符號名稱。這就讓調試器能夠提供有關變量和函數的實際名稱的相關信息,而不僅僅是存儲它們的內存地址。

圖8.4 編譯兩個源文件

和大多數重複性的任務一樣,這個過程也可以被自動化。UNIX作業系統提供了make命令,這個命令可以被用來重新編譯自上次創建相應目標文件以來被修改的源文件,並且連結所有的目標文件。make命令將會查看名為Makefile或makefile的文件,這個文件被用來描述應該如何從源文件中創建可執行文件。圖8.5所示為用於圖8.4中的排序例子的Makefile文件的內容。

圖8.5 test_sort的makefile

這本書里不會涵蓋makefile的所有細節,但這個文件的基本思路是:帶冒號的行里,在冒號的前面有一個文件的名稱,冒號後面的若干個文件名則被用來表示這個文件所依賴的文件(也就是說,如果冒號後的文件被修改了,那麼就需要重新生成冒號前的文件)。帶冒號的行的下面一行用來指定如何生成上一行冒號之前的那個文件,並且這一行必須用制表符作為開頭(就是說,你不能用空格來對這一行進行縮進)。當你輸入了make並按回車鍵之後,它就會構建makefile里列出的第一個元素(在這個例子裡,它會構建可執行的test_sort文件)。你還可以通過在make命令里後接另一個名稱來告訴它應該在構建之後輸出其他名稱(也就是,你可以輸入make linear_sort.o並執行它,它就會創建一個名為linear_sort.o的文件)。通常來說,clean指令會刪除所有添加的目標文件和可執行文件,因此你可以通過鍵入make clean命令來刪除所有對象,然後使用所有的源文件重建整個可執行文件。你可以在大多數介紹UNIX的書籍或者是網絡上找到有關makefile的更多詳細信息。但是,如果你使用的不是UNIX系統的話,集成開發環境(Integrated Development Environment,IDE)一般都會有用來自動編譯程序的構建系統。

8.7 表達式和運算符優先級

表達式在C++里和Python里是類似的,但是,C++不支持任意數據類型的賦值,而且也用了不同的布爾運算符。C++賦值語句的語法和Python是一樣的,只是C++並不支持元組賦值語法;C++表達式右側的數據類型必須與分配給它的在左側的變量數據類型相互兼容。C++語言的賦值運算符的左側只能有一個變量。要在C++里完成像Python語句x, y = y, x這樣的功能,就必須要使用一個臨時變量。下面這段C++代碼展示了這一點:

// swap.cpp
#include <iostream>
using namespace std;

int main()
{
  int x = 3, y = 5, temp;
  cout << x << " " << y << endl;
  temp = x;
  x = y;
  y = temp;
  cout << x << " " << y << endl;
  return 0;
}

這個程序的輸出是:

3 5
5 3

在這個示例里,你可以看到所有變量都必須要事先聲明,當然在聲明語句里,也可以同時為變量分配一個初始值。C++也像Python一樣支持x = y = z這樣的賦值語句。它代表了y被z的值給賦值,然後y的值又賦值給了x。

如果忘記了在表達式里使用變量之前賦值的話,通常會產生一些奇怪的結果。下面這個程序在編譯以及執行的時候都沒有任何錯誤,但會產生不確定的結果。比如,在某一次執行的時候,它可能會輸出134514708,而另一次執行則可能輸出-3456782。

// uninit.cpp
#include <iostream>
using namespace std;

int main()
{
  int x, y;

  y = x;
  cout << y << endl;
  return 0;
}

通常來說,在函數內部聲明的本地變量被稱為自動變量(automatic variable)。函數在開始的時候,會為自動變量分配一個內存位置,但不會對這個變量使用任何的值進行初始化。因此,在為它們分配一個值之前,它們將一直保留函數啟動時這個內存位置里的任何值。這也就是為什麼在每次運行上面那個例子裡的程序的時候,你都可能會得到不同的結果。這種在C++里會發生的編程錯誤在Python里是沒有的。在Python里,如果第一行代碼就是y = x的話,那麼將會拋出NameError異常,這是因為名稱x並不存在。

前面有提到過,C++里支持的運算符和Python支持的運算符除了一些較小的語法差異[例如,表示邏輯與(and)的&&,表示邏輯或(or)的||,以及表示邏輯否(not)的!],基本上是相同的。運算符的優先級規則也是相同的,但是C++支持一些Python里沒有的其他運算符。比如,C++提供的兩個加號運算符是增量和減量運算符,這些運算符有前綴和後綴兩個版本。它們可被用來讓整數變量的值加1或者減1:加1的增量運算符是++運算符;相對應的,減量運算符是--運算符。這些運算符可以和賦值語句一起混用,也可以不和賦值語句一起混用。下面這個例子展示了增量運算符、減量運算符的工作方式是完全相同的,只是它們的作用一個是增1另一個是減1而已。看這段代碼的時候要注意的一點是,前綴版本和後綴版本的增(減)量操作符的差異在與賦值語句一起混用的時候非常關鍵。所以,許多C++程式設計師為了讓代碼更清晰,會避免把增量和減量運算符和賦值語句一起使用:

// increment.cpp
#include <iostream>
using namespace std;
int main()
{
  int a, b, c, d;
  a = 2;
  b = 5;
  a++; // increments a to 3
  ++a; // increments a to 4
  c = ++b; // increments b to 6 and then assigns 6 to c
  d = c++; // assigns 6 to d and then increments c to 7
  cout << a << " " << b << " " << c << " " << d << endl;
  return 0;
}

Python里的所有名稱實際上都是對相應內存位置的引用。而每個C++的變量都指向了保存實際值的內存位置。將一個變量賦值給另一個變量的時候,在Python里的這兩個變量都會引用相同的內存位置;然而在C++里,賦值運算符會把數據從賦值語句的右側變量的內存位置複製到左側變量的內存位置。還好,在只使用不可變類型的時候,C++和Python之間的這種差異並不太明顯。與Python引用相對應的功能在C++里就是指針變量。你可以把引用理解為一個不帶指針符號的指針。在第10章的內容里,我們將會介紹關於自動變量、引用以及指針的內存使用和分配的詳細信息。

8.8 條件語句

和Python一樣,C++支持相同的基本條件語句——if語句。雖然有一些語法差異,但是整個語句的語義還是相同的。比如,在Python里的elif,在C++里使用的是兩個單詞else if。此外,C++還要求用括號把布爾表達式括起來,而Python不需要這樣做。我們提到過,大括號對{}被用來標記代碼塊,因此,它也被用來指示當if語句的布爾表達式為真的時候,應該執行哪些代碼。但是,C++里有一個特例,如果if語句為真的時候只執行一條語句的話,那麼可以不用大括號。但是,如果在後面又去添加了第二條語句的話,就可能會導致混淆,從而產生錯誤。因此,許多程式設計師會通過總是使用大括號來避免這個問題。下面這個例子展示了這個問題:

// if1.cpp
#include <iostream>
using namespace std;

int main()
{
  int x = 5, y = 3;

  // incorrect example: misleading indentation
  if (x < y)
    cout << "x is less ";
    cout << "than y\n";
  cout << "the end\n";
  return 0;
}

這個程序的輸出是:

than y
the end

在這個例子裡,縮進是具有誤導性的,代碼行cout << "than y\n";在布爾表達式為假的時候,也會被執行。要知道,在C++里,縮進並不重要。要正確編寫上面這個程序的話,就需要像下面這樣使用大括號了:

// if2.cpp
#include <iostream>
using namespace std;

int main()
{
  int x = 5, y = 3;

  if (x < y) {
    cout << "x is less ";
    cout << "than y\n";
  }
  cout << "the end\n";
  return 0;
}

這個程序的輸出是:

the end

在這段代碼里,左大括號的位置並不是統一的。一些程式設計師喜歡把它放在與if語句相同的行,而其他一些程式設計師則喜歡把它放在下面一行。但是,幾乎所有人都同意:右大括號應該在獨立的一行上,並且應該與if語句或者{(如果左大括號在單獨的一行上的話)相互對齊。許多程式設計師即使會把if語句以及其他的一些C++語句開頭的左大括號放在與語句相同的行上,他們也會像我們例子裡的main函數的左大括號一樣,把函數開始的那個左大括號放在單獨一行上。大多數公司都會選擇其中一種方案,之後就會要求他們的程式設計師遵循這個代碼風格,從而保證一致性和易讀性。下面這個例子和上面的例子是一樣的,只不過它的左大括號都在單獨的一行上:

// if3.cpp
#include <iostream>
using namespace std;

int main()
{
  int x = 5, y = 3;

  if (x < y)
  {
    cout << "x is less ";
    cout << "than y\n";
  }
  cout << "the end";
  return 0;
}

我們提到過C++里的縮進並不重要,但C++程式設計師通常還是會遵循與Python程式設計師相同的縮進規則,從而實現更好的可讀性。雖然Python 允許任意數量的縮進來表示新的代碼塊,但大多數Python程式設計師都會使用4個空格來作為每一級的縮進。然而在C++程序里,並沒有一個標準,程式設計師們會使用兩個、3個、4個或者8個空格來作為縮進的級別,當然8個空格通常會用輸入制表符鍵來表示。這本書里的示例都會使用兩個空格作為縮進的級別,這是因為,我們認為大括號已經提供了額外的視覺提示來表示代碼塊。而且,更少的空格也意味著嵌套的代碼塊即使有更長的代碼也不會超過80列(大多數程式設計師將代碼行的長度限制在80列)。

如果在C++里沒有遵循與Python相同的縮進規則的話,那麼對於嵌套的if/else語句的語義來說,縮進可能反而會產生一些誤導。在Python里,縮進清晰地表明了elif或else語句與if語句之間的匹配關係。在C++里,匹配if和else語句的規則基本上和Python是相同的。你只需要記住大括號標記了代碼塊,而且即使沒有大括號,if或else語句之後的單個語句也可以是它自身的代碼塊。我們可以這樣來描述這個規則:else語句將會和它上方最接近的那個處於同一級的大括號的if語句相互配對。下面這個例子只是一個代碼片段,因為它不包含main函數和一個完整程序所有必需的語句,所以它並不是一個完整的程序,因此它也不會通過編譯。但是,它在沒有使用額外代碼的情況下展示了一個編程理念。思考下面代碼里的else語句與哪一個if語句相匹配:

if (x < y)
  if (y < z)
    cout << "a";
else
    cout << "b";

if (x < y) {
   if (y < z)
    cout << "a";
}
else
    cout << "b";

第一個else語句與它上面兩行的if (y < z)語句相匹配。可以看到,這條語句是這個else語句的同一級大括號的上方最接近的if語句。基於同樣的理由,第二個else語句與它上面4行的if (x < y)語句相匹配。這個else語句和它上面兩行的if (y < z)語句處於不同的括號級別。這個例子展示了要始終使用大括號的另一個原因:它能夠讓我們更容易去匹配else和if語句。

以下這個例子展示了嵌套的if語句以及else if語句。基於你對Python的了解以及前面提供的相關知識,這段代碼的語義應該是很清晰的(並且,這個程序的執行與它的輸出是匹配的)。唯一要注意的是,在C++里,必須用else if,而不能像Python那樣寫elif:

// grades.cpp
#include <iostream>
using namespace std;

int main()
{
  double grade;

  cout << "Enter your grade average (i.e., 93.2): ";
  cin >> grade;

  if (grade >= 90.0) {
    if (grade > 92.0) {
      cout << "Your grade is an A\n";
    }
    else {
      cout << "Your grade is an A-\n";
    }
  }
  else if (grade >= 80.0) {
    if (grade > 87.0) {
      cout << "Your grade is a B+\n";
    }
    else if (grade > 82.0) {
      cout << "Your grade is a B\n";
    }
    else {
      cout << "Your grade is a B-";
    }
  }
  return 0;
}

上面的這個例子使用了嵌套的if語句,它也可以被寫成一個if語句後跟4個不嵌套的else if語句。我們在這裡選擇使用嵌套的這個版本來展示else if語句以及嵌套語句。

Python使用關鍵字and、or以及not布爾運算符。而在C++里,使用符號&&、||和!分別代表and、or和not。在C++里,等價於Python語句if (x < 3) and not (y > 4)的代碼是if ((x < 3) && !(y > 4))。最新的C++編譯器也開始在&&、||和!之外支持and、or以及not了。

與Python不同的是,C++允許在if語句和循環語句里的判斷表達式中使用賦值語句。這就意味著,即使你並不想要在那個地方進行賦值,C++編譯器也不會將if (x = 0)作為錯誤來處理。這個if語句有一個副作用會把x賦值為零,然後這個結果會被當作是這個布爾表達式。賦值語句的結果就是所賦的值,因此,這個語句就等同於:x = 0; if (0)。這也就是為什麼賦值語句可以連接起來使用(就像x = y = 0這樣)。因此,語句if (x = 0)的判斷將會導致x被賦值為零,並且這個布爾表達式永遠都會被評估為false。和Python一樣,C++會把任何非零的值視為true,而將零視為false。例如,語句if (x = 10)會把10分配給x,並且這個判斷將會評估為true。這種錯誤會非常難以調試。當使用常量作為判斷的時候,一些程式設計師會寫成if (0 == x)。用這種方式書寫條件判斷,如果忘了一個等號的話就會導致錯誤。但是,在你打算寫if (x == y),而寫成了if (x = y)這樣的語句的時候,這個錯誤也會不可避免地產生。

同樣,C++也支持switch條件判斷語句,但在這一節里,我們不打算介紹它。因為任何可以用switch語句編寫的邏輯也可以被寫成if/else if語句。我們將會在選讀的8.17.1小節里詳細討論switch語句。

8.9 數據類型轉換

在Python里,許多數據類型的轉換都是隱式的。下面的Python代碼例子裡,在執行b + c的時候,從b這裡拿到的數字3將會被隱式地轉換為浮點值3.0,這是因為操作數c是一個浮點值。但是,名稱b的值還是會繼續保留為整數3,只是在計算的時候會被替換。在語句d = float(b)里,存儲在b里的3將會被顯式地轉換為3.0並作為浮點數存儲在d中。同樣,b里的值仍然是整數3。在語句e = int(c)里,c的值(5.5)將被顯式地轉換為5,並且作為整數存儲在e里。當一個浮點數被轉換為整數的時候,它的小數部分會被截斷而不是被四捨五入:

b = 3
c = 2.5
c = b + c
d = float(b)
e = int(c)

C++也支持在表達式里進行顯式的轉換,以及各種隱式的類型轉換。下面這個C++的例子對應著上面的Python示例。如果在d的賦值語句里不用顯式轉換,而是直接寫為d = b;的話,大多數編譯器都不會產生錯誤,但會產生一個警告,這個警告會指出這一行里包含了隱式的類型轉換。當一個值從浮點類型轉換為整數類型的時候,C++會使用和Python相同的規則——小數部分會被截斷:

int b, e;
double c, d;
b = 3;
c = 2.5;
c = b + c;     // c holds 5.5
d = double(b); // d holds 3.0
e = int(c);    // e holds 5; this could also be written as e = (int) c;

用括號來指定變量或者表達式的數據類型,雖然Python和C++都支持用這個語法來進行類型轉換,但新的編譯器所支持的新版本的C++代碼有另一種不同的語法。下面這個例子裡展示了使用關鍵字static_cast來進行數據類型轉換的語法:

int b, e;
double c, d;
b = 3;
c = 2.5;
c = b + c;                    // c holds 5.5
d = static_cast <double> (b); // d holds 3.0
e = static_cast <int> (c);    // e holds 5

8.10 循環語句

C++支持3種循環語句:for、while以及do while。while循環和Python里的while語句基本上是相同的。因為while循環的布爾表達式是在循環體執行之前先檢查條件,因此被歸類為前測循環(pretest loop)。它在Python和C++之間的語法差異與if語句的差異是類似的。在C++里,while語句里的布爾表達式必須要放在括號里,而且,C++使用的布爾運算符是&&、||和!(前面提過,新的編譯器也支持and、or以及not)。還有,大括號{}也代替縮進被用來表示代碼塊。和C++的if語句一樣,如果循環體只有一行代碼的話,那麼大括號就可以省略,但多數程式設計師仍然會使用大括號來把這一行代碼包括在裡面。下面這個例子是一個包含C++的while語句的代碼片段。這一節里的所有循環示例都會輸出從0到9的10個數字:

int i = 0;
while (i < 10) {
  cout << i << endl;
  i += 1;
}

C++還支持一個在Python里沒有的do while語句。與while循環不同,do while語句里的代碼總是被執行至少一次。和前測循環的語法不同,循環的條件檢測在循環體執行之後才會進行,因此do while循環被分類為後測循環(posttest loop)。它的語法是:

do {
   // loop body
} while (<Boolean-expression>);

如果循環體里只有一行代碼的話,do while語句也是不需要用大括號來標記循環體的開頭和結尾的。但是,如果循環體里有多行代碼的話,就必須要加上大括號了。這個語句的語義是先執行循環體,然後檢測布爾表達式。如果條件為真,那麼會再去執行一次循環體,之後再去檢測布爾表達式,以此往復。對於上面的那個例子,如果用do while語句做同樣的功能的話,可以寫成:

int i = 0;
do {
  cout << i << endl;
  i += 1;
} while (i < 10);

C++里的for循環語句和Python里的for語句有很大的不同。Python里的for語句是對一系列元素進行疊代,但是在C++里,for語句是一個更通用的循環語句。實際上,你可以把它理解為一個類似於while的循環。通過觀察下面這個同樣輸出0~9的10個數字的for語句的例子,你就可以更好地理解C++的for語句了。圖8.6所示為這個代碼的流程圖:

int i;
for (i = 0; i < 10; ++i) {
  cout << i << endl;
}

圖8.6 C++的for語句的流程圖示例

在for語句的括號里有被兩個分號分隔開的3個語句。第一個語句i = 0;在for語句中通常都被用來當作初始化語句,而且它也只會被for語句執行一次。在執行完初始化語句之後,將會開始執行第二個語句,這個語句會被當作布爾表達式。如果它的結果為真的話,就去執行循環體,在之後就會去執行第三個語句。第三個語句通常也被稱為增量操作(increment action)或者是更新操作(update action)。在我們的例子裡,這個增量語句可以使用後綴版本i++,也可以使用前綴版本++i。在這本書里,我們將會像C++的標準模板庫里的一樣使用前綴版本。在執行了增量操作之後,第二個語句將會被再次執行。如果結果還是真,那麼就會再一次執行循環體里的代碼,接著再執行一次增量語句,然後檢查布爾表達式,如此往復。

就像剛才我們提到過的那樣,任何for循環都可以被重寫為while循環。如果你還沒有看出for循環里的各個語句位置與while循環里的語句之間的對應關係的話,你可以再看看那個流程圖,以及比較while循環和for循環的兩個代碼片段。C++的for語句可以更複雜一些,比如說,可以包含用逗號來分隔的多個初始化語句,以及多個增量語句。在這本書里,我們將不會去展示這些用法。一些程式設計師認為,如果面對的是很複雜的情況的話,應該使用while語句來處理。

C++的for循環還支持在語句里直接聲明循環的疊代變量。如果這樣做了,那麼這個變量就只能在循環體內部被訪問。在循環之外,這個變量將不再存在。下面這個例子展示了這一點:

for (int i = 0; i < 10; ++i) {
  cout << i << endl;
}
// you cannot access the variable i here

類似地,如果你在for循環里定義的循環疊代變量和之前已經聲明的變量同名的話,那麼先前聲明的那個變量在循環體內是不可以被訪問的,在循環之後才能被訪問,而且會保留在循環開始執行之前的那個值。正是因為可能導致混淆,所以我們不建議你使用在for語句里聲明變量的這種語法。這個問題是被稱為作用域(scope)和生命周期(lifetime)的話題的一部分,我們將會在8.15節里介紹關於C++的變量的作用域和生命周期的詳細信息。

和C++里的if、while和do while語句一樣,如果循環體只包含一行代碼的話,for循環也不需要大括號,但很多程式設計師仍然會一直使用大括號來包括循環體。和Python一樣,C++也支持終止循環的語句——break。就像在Python里我們建議的那樣,只有在增加break語句會讓循環的可讀性提高的時候,才應該使用break語句。

8.11 數組

Python里包含允許對一組數據進行索引訪問的列表以及元組。Python列表還支持排序、查找元素和許多其他有用的算法等方法。相對而言,C++的數組雖然也支持索引操作,但是它更底層一些,並且也不像Python列表那樣具有很好的靈活性。比如說,C++的數組裡必須包含相同類型的元素,而且它也不支持切片以及使用負數索引來訪問數組末尾的元素。

8.11.1 一維數組

C++的數組用方括號進行聲明,並且使用方括號來進行訪問。和Python一樣,數組的第一個元素的索引是0,最後一個元素的索引會比數組的尺寸小一個數字。在下面的這個代碼片段里聲明了一個數組,並且為數組裡的每個元素都賦了一個值。這個數組在聲明的時候就設置成了等於10的固定大小,裡面的元素將會通過索引0~9進行訪問,而且這個數組只能存儲整數:

int i, a[10];

for (i = 0; i < 10; ++i) {
  a[i] = i;
}

最新的C和C++編譯器支持用一個變量來指定數組的大小,這種數組被稱為可變長自動數組(variable length automatic array,這是20世紀90年代後期,被稱為C99的C語言的更新的一部分)。在程序運行之後才去指定數組大小的另一種實現方案是:使用指針和動態內存。這部分內容將在10.3節里介紹。下面這個代碼片段展示了可變長自動數組:

int i, n;

cout << "Enter size: ";
cin >> n;

int a[n];
for (i = 0; i < n; ++i) {
  a[i] = i;
}

與Python不同的是,C++的數組不會對任何索引進行範圍檢查。也就是說,如果程序試圖在超出數組邊界的地方進行訪問的話,可能會得到無法確定的結果,從而導致程序崩潰,當然也可能會讓程序看起來還在正常工作。我們將在第10章里詳細地討論這些關於內存方面的錯誤。在許多作業系統里,當C++程序崩潰的時候,它是不會像Python那樣顯示出堆棧跟蹤(程序崩潰時所處在的代碼行,以及程序執行到那個地方所執行的調用函數的序列)的。大多數的UNIX系統將會創建一個core文件,這個文件里會包含有關執行和崩潰位置的相關信息。在UNIX系統上,gdb調試程序可以通過後面這個命令,顯示出存儲在core文件里的堆棧跟蹤信息。這個命令就是gdb <executable-filename> core。然後輸入bt[「回溯(backtrace)」的縮寫]再按回車鍵。大多數的集成開發環境(IDE)也都提供可以在程序崩潰時提供堆棧跟蹤的編譯器和調試環境。

C++也支持像這樣的語法:int a[5] = {0, 0, 0, 0, 0};,在聲明語句里直接初始化數組。C++並不支持直接對數組變量進行賦值。要實現數組變量的賦值的話,就只能去對數組的每一個元素進行賦值,就像下面這個代碼片段里做的一樣:

int i, a[5], b[5];

a[0] = 0; a[1] = 1; a[2] = 2; a[3] = 3; a[4] = 4;
// b = a; cannot be used
for (i = 0; i < 5; ++i) {
  b[i] = a[i];
}

8.11.2 多維數組

C++還支持多維數組,只要系統支持相應的內存空間,這個多維數組的維數並沒有任何限制。聲明多維數組的語法和聲明一維數組的語法是類似的,只不過每一個維度將會使用一對額外的方括號。下面的這個代碼片段聲明了一個總共包含120個元素的三維數組,並且每個元素都被初始化為了零:

double a[4][10][3];
int i, j, k;

for (i=0; i<4; ++i) {
  for (j=0; j<10; ++j) {
    for (k=0; k<3; ++k) {
      a[i][j][k] = 0.0;
    }
  }
}

8.11.3 字符數組

在C++里也可以像C語言那樣,用字符(char數據類型)數組來表示字符串。但是,在用C++編程的時候,通常都會使用我們將會在9.2節里介紹的內置的C++字符串(string)類。使用原始的字符數組來表示字符串的時候,字符串的尾部字節將會用零來表示字符串的結尾,因此,這個數組的大小必須要大於你將要存儲的最大字符數。這個零字節由一個字符'\0'來表示。在這裡要注意的是,單引號被用來表示單個字符(屬於char數據類型),而雙引號則被用來表示字符串。由於一些C++代碼庫的函數使用的不是C++的字符串類,而是C語言風格的字符串(字符數組),因此我們將簡單介紹一下C語言風格字符串的基礎知識。下面這個例子展示了一個使用C語言風格的字符串,它允許你輸入你的姓名,然後向你說"Hello"。但是,這個例子非常糟糕,這是因為,在這個例子裡,有一個可以被利用的緩衝區溢出漏洞。

// buffer.cpp
#include <iostream>
using namespace std;

int main()
{
  char c[20];

  cout << "enter your first name: ";
  // this code is a security risk
  // a buffer overflow occurs if the user enters
  // more than 19 characters
  cin >> c;
  cout << "Hello " << c << endl;
}

如果輸入的是Dave的話,程序會把字符D、a、v、e以及\0分別存儲在數組的第0~4位。當代碼輸出變量c的時候,它會從這個數組的開頭開始依次輸出字符,直到它到達了代表著字符串結尾的\0。如果用戶鍵入的字符超過19個的話,那麼輸入的數據將會超過數組的末尾,也就是說,它會允許用戶將數據寫入程序里沒有分配給這個數據的內存之中。於是一些聰明的計算機破解程序在某些情況下,會利用這種情況去輸入一段可執行代碼,從而能夠讓它們竊取你可能會在程序里輸入的密碼或者是財務相關的信息這樣的私人數據。這也就是我們推薦使用C++的字符串(string)類的另一個原因。正是基於這樣的建議,這本書將不會再去介紹關於C語言風格的字符串的其他細節和操作函數。

8.12 函數的細節

函數在Python里被用來把代碼拆分成較小的子問題,從而避免不斷地重複編寫相同的代碼。在C++里使用函數具有類似的目的,但是在C++里需要考慮有關函數的問題會比在Python里要多一些。就像我們已經看到的那樣,所有C++的可執行語句都必須在函數內被執行,並且每個C++可執行程序都必須要包含一個名為main的函數。我們將會使用一些你不會在Python里使用到的術語來討論C++里的函數。

8.12.1 聲明、定義以及原型

與Python不同,除了非局部變量、類的定義以及變量或函數的聲明之外,所有的C++代碼都只能出現在函數的內部。為了能夠理解這是為什麼,我們就需要先去理解聲明(declaration)和定義(definition)之間的區別。區分這兩者的一種簡單方法是:定義會讓內存分配存儲空間,而聲明只會告訴編譯器某個名稱的存在及其含義(某種類型的變量、類或是具有參數的函數)。變量、類和函數可以被多次聲明,但只能被定義一次。因此,通常所說的變量聲明(variable declaration),其實嚴格上來說是變量定義(variable definition)。即使在函數的開頭列出各個變量以及它的類型,也應該被稱為變量定義,雖然很多程式設計師都會把它叫作變量聲明。定義也可以被當作聲明,這是因為它也告訴了編譯器一個名稱的存在,但是聲明並不是定義。

那麼,既然我們已經說明了聲明和定義之間的區別,現在讓我們來看一個包含函數聲明和函數定義的簡單例子:

#include <iostream>
using namespace std;

// this is a function declaration
int main();

// this is a function definition
int main()
{
  // this is a variable definition which is also a declaration
  int x;

  x = 42;
  cout << "x = " << x << endl;
  return 0;
}

所有的C++函數都必須有一個返回類型(對於main函數,會返回一個int類型)。函數聲明里就表示了返回類型、名稱以及名稱之後的括號里的參數。函數聲明以分號結尾,並不包含函數體。因此,函數聲明也被稱為函數原型(function prototype)。函數聲明/原型將會告訴編譯器關於這個函數足夠多的信息,因此編譯器會知道它的存在,並且可以確定在調用這個函數的時候,有沒有正確地使用它。函數的定義包含了與聲明里相同的信息,但是它沒有結尾處的分號,而是使用大括號來包含函數的主體。在我們之前的例子裡,我們沒有像在剛剛這個例子裡那樣包含一個main函數的單獨聲明。在那些例子裡,main函數的定義也被當作它的聲明。一般來說,除非有其他代碼調用這個函數,通常我們都不會去編寫函數的單獨聲明。

除了在8.4節里列出的數據類型,以及將要在9.1節里介紹的用戶自定義的數據類型之外,C++還支持void返回類型。void返回類型在函數不會有返回值的時候使用。在以void作為返回類型的函數裡,return語句不是必需的,但是也可以包含它。如果一個非void的函數沒有返回一個值的話,大多數C++編譯器都會產生一個警告,而不會像Python那樣,如果函數沒有顯式地返回一個值,就返回None。和Python類似,C++的函數也可以有多個return語句,而且只要執行了任何一個return語句,這個函數就不會繼續執行其他代碼,並且控制權會傳回給在調用點之後的那個語句。和Python不同的是,C++的函數只能返回一個值。這並不是C++的一個重大缺陷,因為我們可以通過將多個值封裝在一個類里,並返回這個類的實例來解決;或者也可以使用引用傳遞(在8.12.3小節里介紹)來解決需要多個返回值的問題。

通常來說,為了讓你的代碼能夠正常編譯,都應該編寫函數原型。特別是,如果要在函數聲明之前調用這個函數的話,大多數編譯器都需要這個函數的函數原型。在調用一個沒有被聲明的函數的時候需要原型的原因是,編譯器必須要確定使用了正確數量的參數去調用這個函數,並且這些參數的類型也是正確的。回想一下你之前學到的Python的相關知識,函數聲明或者定義里的參數被稱為形參(formal parameter),而調用這個函數時所使用的表達式或變量被稱為實參(actual parameter)。下面這個例子展示了在函數聲明或者定義之前調用這個函數會導致的問題:

// this example will not compile
int main()
{
  double a=2.5, b=3.0, c;
  // the compiler has not yet seen the f function
  // so it cannot determine if f is called correctly
  c = f(a, b);
}

double f(double x, double y)
{
  return x * x + 2 * x * y;
}

對於這段代碼,大多數編譯器在c = f(a,b)這一行代碼上標錯,並提示f未被聲明。在這個例子裡,有兩種方法可以解決這個問題。最簡單的方法是在主函數的上面編寫f函數,這樣一來,函數f的定義也會被當作它的聲明。另一種方法則是在主函數之上編寫f函數的原型,就像下面這個例子裡做的一樣:

double f(double x, double y);

// you do not need to list the formal parameter names in the prototype
// this example also shows you that you can declare a function multiple
// times even though you generally do not do this
double f(double, double);

int main()
{
  double a=2.5, b=3.0, c;

  // the prototype allows the compiler to determine
  // that f is called correctly
  c = f(a, b);
}

double f(double x, double y)
{
  return x * x + 2 * x * y;
}

函數f的原型指出了它的返回類型是一個double(雙精度浮點)值,並且它需要兩個參數,每個參數都是一個double值。就像例子裡的注釋代碼寫的那樣,你並不需要給形參提供名稱,但如果你願意加上名稱也是可以的。大多數程式設計師都會指定形參的名稱,這是因為參數的名稱通常表示著參數所代表的內容。有一個非常重要且需要注意的點是:原型後需要加上分號,但是在定義函數的時候,右括號後面不需要加上分號。另外一點需要注意的是,數據類型的名稱必須要放在每個形參的前面,在函數的原型或者實現里寫成double f(double x, y)是不正確的。

程式設計師剛開始用C++寫程序時的一個常見錯誤是:把形參聲明成了局部變量,像下面這段代碼片段展示的一樣。這是不正確的,因為局部變量會阻止對形參的訪問。你的C++編譯器可能會生成一個警告,提示這個變量會影響參數。有些編譯器可能會忽略這個警告,繼續編譯這個程序,而其他一些編譯器則會認為這是一個錯誤,並且拒絕編譯程序:

#include <iostream>
using namespace std;

void f(int a, int b)
{
  int a, b; // incorrect - compiler error/warning: variables shadow parameter
  cout << a << " " << b << endl;
}

int main()
{
  int x = 2, y = 3;
  f(x, y);
  return 0;
}

你可能已經發現了,你引用的頭文件里包含的正是你的代碼會用到的元素的聲明。比如,iostream頭文件里包含了cout和cin的聲明。但是這些元素的定義並沒有包含在這個頭文件里。對於cout以及cin來說,它們的定義是在機器代碼庫里,連結器在創建可執行代碼時會自動連結這些定義。我們將在8.13節里介紹如何編寫自己的頭文件。

8.12.2 值傳遞

C++里參數傳遞的默認機制是值傳遞(pass by value)。值傳遞將為每個參數生成一個單獨的數據副本。由於使用了完全獨立的副本,因此對函數的形參進行的任何更改都不會反映在實參里。這就允許你把形參視為一個附加的局部變量了,因為對它們所做的修改不會影響到程序的其他部分。和Python一樣,形參和實參的名稱是否一致並不重要。下面這個例子就說明了這一概念:

// value.cpp
#include <iostream>
using namespace std;

void f(int a, int b) // a and b are the formal parameters
{
  cout << a << " " << b << endl;
  a += 3;
  b += 5;
  cout << a << " " << b << endl;
}
int main()
{
  int x = 1, y = 2;

  f(x, y); // x and y are the actual parameters
  cout << x << " " << y << endl;
}

這個程序的輸出是:

1 2
4 7
1 2

8.12.3 引用傳遞

C++還支持另一個參數傳遞的機制,被稱為引用傳遞(pass by reference)。和值傳遞不同的是,它不會去複製一份數據副本,而是直接傳遞數據的引用(內存中的地址)。因此,對形參所做的任何改變都會反映在實參上。在Python里,如果把可變數據類型(列表、字典或是類的實例)傳遞給函數,然後再在函數內部進行修改的話,那麼這些更改將會反映在實參里。但是,如果把一個新的實例分配給形參的話,這個改變將不會反映到實參上。對於C++里的引用傳遞來說,對形參的任何更改(包括分配一個新值)都會直接反映到實參上。要表示參數通過引用傳遞,只需要在形參(不是實參)前放置&符號就行了。而在實參前面放一個&符號將會有不同的效果(參見10.2節)。下面的例子和前面的例子類似,但是其中的一個參數是通過引用傳遞的,從而導致了不同的輸出結果:

// reference.cpp
#include <iostream>
using namespace std;

void f(int a, int &b) // a and b are the formal parameters
{
  cout << a << " " << b << endl;
  a += 3;
  b += 5;
  cout << a << " " << b << endl;
}
int main()
{
  int x = 1, y = 2;

  f(x, y); // x and y are the actual parameters
  cout << x << " " << y << endl;
}

這個程序的輸出是:

1 2
4 7
1 7

使用引用傳遞的任何形參所對應的實參都必須是一個變量,而不能是一個表達式。在這個例子裡,我們不能用f(2,4);。生成數字2的副本,並且把它存儲在形參a的位置是可以的,但問題在於,如果我們更改形參b的話,我們並沒有相應的實參來進行修改(因為它只是一個常數)。

8.12.4 將數組作為參數傳遞

出於對效率的考慮,C++會自動通過引用傳遞數組,因此你不需要使用&來指定數組通過引用傳遞。這樣,傳遞的時候就不會去複製整個數組,而是傳遞一個數組的起始內存地址的副本。因此,函數對數組所做的任何改變都將會反映在傳遞給函數的數組裡。這其實和把Python里的可變類型(比如Python列表)傳遞給函數是一樣的。你只能改變整個數組的內容,但是不能去更改數組使用的內存位置。我們通過後面第10章的學習,在探討過了關於指針和動態內存相關的內容之後,這部分的細節以及結果將會更加清晰。

你並不需要在數組的形參里指定整個數組的大小,但是,整個函數仍然需要注意在使用的過程中不要超出數組尺寸。一種常見的解決辦法是再傳遞一個指定數組尺寸的附加參數。下面的代碼通過選擇排序的實現來展示了這一方法。形參(int a[])後面的方括號表示一個不定大小的一維數組將會被傳遞給它。你也可以根據需要在這個地方指定一個大小尺寸,但整個值在這裡會被忽略掉。第二個參數表示了整個數組的大小。由於數組不是值傳遞的,因此在selection_sort函數裡對數組進行的修改也會影響到傳遞的實參。整個程序的輸出(未示出)將會是按照順序進行排列的數組:

// selection.cpp
#include <iostream>
using namespace std;

void selection_sort(int a[], int size)
{
  int i, j, min_pos, temp;

  for (i = 0; i < size - 1; ++i) {
    min_pos = i;
    for (j = i + 1; j < size; ++j) {
      if (a[j] < a[min_pos]) {
        min_pos = j;
      }
    }
    temp = a[i];
    a[i] = a[min_pos];
    a[min_pos] = temp;
  }
}

int main()
{
  int i;
  int a[5] = {7, 6, 4, 2, 3};
  int b[10] = {3, 0, 5, 7, 4, 6, 8, 1, 9, 2};

  selection_sort(a, 5);
  selection_sort(b, 10);
  for (i = 0; i < 5; ++i) {
    cout << a[i] << " ";
  }
  cout << endl;
  for (i = 0; i < 10; ++i) {
    cout << b[i] << " ";
  }
  cout << endl;
  return 0;
}

多維數組也可以被傳遞給函數。但是,除了第一個維度之外的所有維度都必須要指定相應的大小。在C++里,多維數組按照行主序(row-major order)的順序進行存儲。比如說,對於聲明為int b[2][3]的數組,它的值在內存中的存儲順序為:b[0][0]、b[0][1]、b[0][2]、b[1][0]、b[1][1]、b[1][2]。為了能夠計算出數組中指定位置的內存地址,我們必須要能夠知道除了第一個維度之外的所有維度。在上面這個例子裡,b[i][j]的位置是從數組的開始處偏移i * 3 * 4 + j * 4個字節。要知道,我們曾經假設過整數會占用4個字節。所以,要移動到第i行,我們就必須要移動i * 3 * 4字節,然後還需要移到這一行里相應的點j,因此,我們必須再移過j * 4字節。後面這個函數原型將會接受一個後兩個維度大小分別為10和20的三維數組:void f(int b[][10][20],int size);。正是因為不需要第一維的維度來計算元素在數組中的位置的內存地址,所以在形參的數組聲明中不需要指定它,同時size參數將會被用來指示第一個維度的尺寸。因此,這個函數裡的代碼是能夠知道作為實參傳遞的數組的大小的。

8.12.5 常量參數

C++里支持把參數標記為常量(const),這意味著函數不能更改這個參數。這個功能對於讓編譯器去檢查你的代碼里是否意外地去嘗試了修改這個參數時非常有用。如果你的代碼真的修改了標記為const的參數,那麼這個代碼將無法通過編譯,並且會生成錯誤來告訴你相應的原因。以下示例演示了語法:

void f(const int a, int b)
{
  a = 2; // this will generate a compiler error
  b = 2; // this is fine
}

const標記也可以和通過引用進行傳遞的參數一起使用。乍一看,這可能非常矛盾,因為當我們想要修改這個參數的時候,才會使用引用傳遞。回想一下,按值傳遞會傳遞一個數據的副本。製作一個像int或double類型這樣的不需要太多內存的副本不是什麼問題,但是,如果需要複製一個包含成百上千字節的變量,需要花費大量的時間,並且還需要大量額外的內存。使用引用傳遞的話,會把變量的起始地址作為對現有數據的引用進行傳遞,而不會去複製整個數據。並且,無論整個數據的實際大小是多少,在32位的系統上,都只需要4字節。因此,如果要傳遞一個大型的數據結構,又不希望函數去改變它的話,就可以使用const標記來通過引用傳遞整個數據結構。下面這個例子裡,假設我們已經定義了一個名為LargeType的類:

void f(const LargeType&big)
{
  // any changes to parameter big will generate a compiler error
}

這也是Python把所有的數據都視為引用的一個原因。賦值、傳遞以及返回任何對象的時候,都只需要引用(可能還會有引用計數),而不用去複製像列表或者字典對象里潛在的大量數據。

8.12.6 默認參數

和Python類似,C++也在函數裡支持默認參數。默認參數允許使用比形參更少的實參來調用整個函數或方法。在函數/方法的聲明里定義的默認值將會被用來代替缺少的實參。下面這個例子展示了默認參數的使用方法:

void f(int a, int b, int c = 2, int d = 3)
{
    // do something with the parameters
}

int main()
{
  f(0, 1); // equivalent to f(0, 1, 2, 3);
  f(4, 5, 6); // equivalent to f(4, 5, 6, 3);
  f(4, 5, 6, 7); // no default values used
}

這個例子裡有兩個必須要始終指定的參數以及兩個默認參數。因此,這個函數允許使用兩個、3個或者4個參數來調用。就像在注釋代碼里描述的那樣,當需要的時候,參數的默認值將會被使用。與Python一樣,默認參數必須是最後的幾個參數,只有這樣編譯器才可以根據順序來匹配實參和形參。默認值只會在函數的聲明里,而不是在函數的定義里被指定。除非函數的定義也同時是它的聲明,那麼在這種情況下你就需要像前面我們說的那樣去設置它們。下面這個例子展示了當函數的聲明和定義同時存在時,應該如何使用默認參數,我們將在8.13節里展示另一個和頭文件相關的默認參數的例子:

double f(double x=0, double y=0);

double f(double x, double y)
{
  return x * x + 2 * x * y;
}

int main()
{
  double x=2.5, y=3.0, z;

  z = f(x, y);
}

在Python里,你可以使用* args傳遞任意數量的參數,但在C++里,這個情況非常複雜,超出了這本書的範圍。

8.13 頭文件和內聯函數

頭文件的用處是聲明各個函數、類(類相關的內容將在9.1節里介紹)以及非局部變量,從而能夠讓它們在其他的C++源文件里被使用。在我們之前的例子裡包含了iostream頭文件,現在讓我們來看看應該如何編寫自己的頭文件。我們將用排序算法作為例子來展示它。我們將首先在頭文件里聲明兩個不同的排序函數:

// sort.h
#ifndef __SORT__H
#define __SORT__H

void selection_sort(int a[], int size);
void merge_sort(int a[], int size);

#endif

這段代碼首先需要注意的是,我們添加了一些新的預處理器命令。之前我們曾經提到過,預處理器命令是以井號(#)開頭的。在這個代碼里的ifndef行將會去檢查是否已經定義了符號__SORT__H。如果沒有的話,那麼在下一行就會定義符號__SORT__H,接下來就是我們的函數聲明。如果已經定義了這個符號,那麼在包含這個文件的時候,將不會複製代碼行#ifndef到#endif之間的代碼。使用這些預處理器命令是防止頭文件被包含兩次的標準方法。多次包含一個只含有聲明的頭文件並不會產生錯誤,但會降低編譯速度,因為編譯器需要處理更多行代碼。並且,如果包含了多次含有定義的頭文件(比如說類的頭文件)的話,也會出現問題,因為一個名稱只能有一個定義。

雖然在一個文件里並不會直接包含某個文件兩次,但是頭文件通常也可能包含著其他的頭文件。因此,如果你的頭文件包含了文件<cmath>,然後在實現文件里同時包含這個頭文件以及<cmath>的話,那麼<cmath>文件就被合理地包含了兩次。用來定義的符號的名稱並不要求完全遵循__SORTS__H這樣的模式。一般來說會使用下劃線以及文件名的組合,來讓每個頭文件都具有與它相關的唯一符號。

sort.cpp文件通常會包含sort.h文件,儘管在這個例子裡這兩個函數都不會調用另一個,因而並不需要這麼做。sort.cpp文件看起來就應該是:

// sort.cpp

#include "sort.h"

void selection_sort(int a[], int size)
{
  // code for selection_sort function
}

void merge(int a[], int low, int mid, int high)
{
  // code for merge function
}

void merge_sort(int a[], int size)
{
  // code for merge_sort function
}

如果一個文件想要調用我們這兩個排序函數的話,就需要包含頭文件sort.h並且與編譯器生成的sort.o文件進行連結。要注意的是,我們並沒有將merge函數放在頭文件里,這是因為它只會被merge_sort函數調用。下面,讓我們用一個簡單的例子來使用上面的排序算法。你可以使用8.6節里列出的g++命令在UNIX系統上編譯並連結這3個文件:

// test_sort.cpp
#include <iostream>
using namespace std;
#include "sort.h"

int main()
{
  int i;
  int a[10] = {9, 8, 7, 6, 5, 4, 3, 2, 1, 0};
  int b[10] = {9, 8, 7, 6, 5, 4, 3, 2, 1, 0};

  selection_sort(a, 10);
  merge_sort(b, 10);
  for (i=0; i<10; ++i) {
    cout << a[i] << " " << b[i] << endl;
  }
  return 0;
}

讓我們來看看另一個頭文件的示例,它能夠讓我們了解到在使用默認參數值的時候會經常犯的一個錯誤。讓我們來編寫幾個函數進行溫度轉換,並且把它們放在一個單獨的文件里,這樣許多其他程序就都可以輕鬆地使用它們了。我們的頭文件和實現文件將會像下面這樣:

// conversions.h
#ifndef __CONVERSIONS_H
#define __CONVERSIONS_H

double f_to_c(double f=0.0);
double c_to_f(double c=0.0);

#endif

// conversions.cpp
#include "conversions.h"

// the next line is commented out since it is incorrect
// double f_to_c(double f=0.0)
double f_to_c(double f)
{
  return (f - 32.0) * (5.0 / 9.0);
}
double c_to_f(double c)
{
  return (9.0 / 5.0) * c + 32.0;
}

這個常見的錯誤是:把函數聲明直接從頭文件里複製並粘貼到實現文件里去。這樣做會導致在實現文件里也指定了默認參數的值。在例子裡,我們通過注釋掉了不正確的代碼行,並且添加了沒有默認值的正確行來顯示了這一點。如果在從頭文件複製粘貼函數原型的時候忘記了在實現文件里刪除默認值的話,C++編譯器將給出一個出錯消息。

由於這些函數的代碼都很短,因此,如果進行函數調用的話,調用函數可能會比執行實際函數代碼要花費更多的執行時間。C++提供了一種被稱為內聯函數(inline function)的機制,從而實現更高效的函數執行。內聯函數通常會直接寫在頭文件里,並且它的寫法與函數在實現文件中的編寫是完全相同的。唯一的不同是,它會在函數定義之前放一個關鍵字inline。這樣一來,它既是一個定義同時也是一個聲明。對於我們的溫度轉換這個例子來說,使用內聯函數的頭文件會像下面這樣:

// conversions2.h

#ifndef __CONVERSIONS_H
#define __CONVERSIONS_H

inline double f_to_c(double f=0.0)
{
  return (f - 32.0) * (5.0 / 9.0);
}

inline double c_to_f(double c=0.0)
{
  return (9.0 / 5.0) * c + 32.0;
}

#endif

當在頭文件里編寫了所有的內聯函數之後,因為所有信息都包含在頭文件之中,所以你就不再需要那個實現(conversion.cpp)文件了。當有多個文件都包含了conversion.h頭文件的時候,inline關鍵字可以避免函數被創建多個定義。

而且,如果你的內聯函數相對較短的話,編譯器將生成這段函數體的機器代碼,並且把這部分機器代碼直接放在代碼里,而不會去創建調用這個函數的相關代碼。但是,如果你的函數比較長的話,編譯器將會忽略掉你的內聯指令,不僅如此,它還會創建一個普通的函數調用來調用這個函數。因為,如果有很多不同的地方都調用這個函數的話,複製這個比較長的函數的機器代碼到各個地方,將會使得整個程序變得更大。一般來說,少於5行的函數都可以聲明為內聯函數。

最初的時候,C語言並不支持內聯函數,因此,為了實現讓短函數不去創建函數調用的相同結果,會使用預處理器宏來定義這些函數。C++里也支持(macro)命令,因為它也屬於C語言,但是,我們還是建議你使用內聯函數,因為它會強制執行類型檢查,更加安全。下面這段代碼為c_to_f定義了宏,並且使用了宏:

// macro.cpp
#include <iostream>
using namespace std;

#define c_to_f(c) (9.0 / 5.0) * c + 32.0

int main()
{
  int x = 10;
  cout << c_to_f(x) << " ";
  cout << c_to_f(x + 10) << endl;
}

#define預處理器命令被用來定義宏。預處理器將會執行搜索,然後替換括號里的元素。那麼,基於這樣的邏輯,你認為這段代碼的輸出應該是什麼?

那兩行使用宏的代碼將會被預處理器擴展為:

cout << (9.0 / 5.0) * x + 32.0 << " ";
cout << (9.0 / 5.0) * x + 10 + 32.0 << endl;

基於這樣的擴展,你應該可以知道為什麼這個程序的輸出是50 60了。而我們期望的輸出,20攝氏度的正確轉換應該是68華氏度。你可以通過在宏里添加更多的括號來解決這個問題,但是,宏仍有其他的潛在問題。因此,在編寫C++代碼時,你應該使用inline關鍵字而不是用宏來避免函數調用的相關開銷。

在命名空間和頭文件之間,還有一個重要問題。對於頭文件來說,你不應該使用using namespace ...這樣的代碼來使用任何一個命名空間。如果你這樣做的話,那麼任何包含這個頭文件的文件都會被using namespace語句所影響。因此,當源文件定義了一個在這個指定的命名空間裡也定義過的名稱,就會導致問題發生。所以,如果你需要在頭文件里使用一個被定義在名稱空間裡的名稱的話,請不要在頭文件里包含using語句,而是始終使用namespace::name這樣的語法來引用它。我們將在9.4節里看到一個關於這點的例子。

就像Python包含了許多很有用的功能模塊那樣,C++也提供了一個標準的函數庫。我們已經看到過在C++語言裡用來包含輸入和輸出代碼庫的iostream頭文件。C++里提供的許多函數都是最初的C語言標準庫的一部分,當然這些頭文件也專門為C++進行了更新。C語言代碼庫的一些頭文件被叫作stdio.h、stdlib.h以及math.h。要在C++程序里使用這些頭文件的話,就需要刪除擴展名.h,並且在開頭添加字母c。因此,相應的名稱就是cstdio、cstdlib以及cmath。例如,要使用C語言的math頭文件里定義的sqrt函數的話,就需要在C++文件的頂部添加這樣一行語句:#include <cmath>。當然,還有一些其他的標準C++頭文件,其中一些將在後面介紹到,這些iostream中提供的功能是C++程式設計師屢見不鮮的。

一個標準約定是,對於C++代碼庫的頭文件,以及常見的位於標準目錄里的代碼庫,使用小於號和大於號把它們的頭文件的名稱包括起來。你的C++編譯器還提供了一種可以指定要去檢索的其他目錄的方法。一般來說,在大多數系統里,編譯器會首先檢索你指定的其他目錄,然後檢索包含頭文件的一組標準目錄。在這個檢索過程中,第一個與名稱匹配的頭文件將會被使用。當頭文件與正在編譯的C++源文件位於同一目錄的時候,你必須在頭文件的名字兩邊使用雙引號。對於使用雙引號指定的頭文件,編譯器將會首先檢索當前目錄。如果編譯器在當前目錄中找不到頭文件,那麼編譯器將檢索用戶指定的其他的包含目錄以及標準目錄。對於當前目錄里的頭文件,你不能使用小於號和大於號來表示它,因為這樣的語法在默認情況下是不會去檢索當前目錄的。但是,你可以在包含的標準頭文件兩側使用雙引號,因為這樣寫會去檢索當前目錄以及標準目錄。雖然可以在任何情況下都使用雙引號,但是通常來說,C++程式設計師都會遵循對於標準頭文件使用小於號和大於號這樣的約定。

8.14 斷言與測試

與內置了單元測試框架的Python不同的是,標準C++語言並不包括任何單元測試框架。好在你可以下載安裝許多第三方的C++單元測試框架。大多數(甚至可能是全部的)這些框架都與Python的單元測試框架是類似的,這是因為,C++和Python的單元測試框架都是基於Java的單元測試框架的。在這裡,我們將會討論C++的斷言(assert)語句,而不去覆蓋更多的C++單元測試框架,因為斷言語句也能允許你輕鬆編寫單元測試。

Python的單元測試框架提供了許多方法來驗證某些條件是否為真,並將「assert」作為其名稱中的一部分(例如,assertEquals和assertRaises)。這些方法其實都是基於C++的斷言(assert)語句(嚴格來說,是一個被預處理器擴展了的宏),而這個C++的斷言(assert)語句採用的是布爾表達式。如果這個布爾表達式為真,那麼程序會繼續執行下去;但如果為假的話,那麼程序就會立即退出,並標記出assert語句失敗的代碼行。C++的斷言(assert)語句與Python的單元測試框架不同的是:Python單元測試框架在其中一個測試失敗之後,還會繼續運行其他測試,但是使用C++的assert語句則會導致程序在斷言不成立的時候立即退出。也就是說,不會去執行失敗的斷言語句之後的任何測試。

在這裡,我們將會修改8.13節里的test_sort.cpp文件來使用assert宏命令。assert宏將會接受一個表達式,並且對這個表達式的值進行判斷。如果這個表達式的計算結果為真的話,那麼就會繼續執行;如果這個表達式的計算結果為假,程序就會立即退出,並且輸出一條錯誤消息,來指出包含失敗的那個斷言的代碼行:

// test_sort2.cpp
#include <iostream>
using namespace std;
#include <cassert>
#include "sort.h"
int main()
{
  int i;
  int a[10] = {9, 8, 7, 6, 5, 4, 3, 2, 1, 0};
  int b[10] = {9, 8, 7, 6, 5, 4, 3, 2, 1, 0};

  cout << "test selection sort" << endl;
  selection_sort(a, 10);
  for (i=0; i<9; ++i) {
    assert(a[i] <= a[i+1]);
  }
  cout << "selection sort passed" << endl;
  cout << "test merge sort" << endl;
  merge_sort(b, 10);
  for (i=0; i<9; ++i) {
    assert(b[i] <= b[i+1]);
  }
  cout << "merge sort passed" << endl;
  return 0;
}

要使用C++的assert宏的話,就必須要包含cassert頭文件。Python的單元測試框架會指示出測試通過與否,但是C++里是不一樣的,如果所有測試都通過的話,使用這個簡單的方法來做的測試將不會產生任何輸出。如果你需要輸出,那麼可以就像我們在上面這個例子裡做的那樣,在每個assert語句之後或者在一組assert語句之後放一條輸出語句,表示相應的測試已經通過了。在輸出的時候需要記住的是,輸出會被作業系統先放在緩衝區,如果程序在作業系統將緩衝區的輸出內容發送到螢幕之前就崩潰了,那麼你可能就不會看見任何輸出。所以,需要使用endl來輸出一個新行,並且強制刷新緩衝區。因此,在測試代碼的時候,始終需要在輸出語句的末尾使用endl。

如果你要測試許多的函數或者類方法的話,你可能應該創建單獨的測試函數來測試每一個方法,然後在main函數裡調用這些測試函數。這就像使用Python單元測試框架那樣,會調用所有以test這4個字符開頭的方法。


本文截選自《數據結構和算法(Python和C++語言描述)》

本書使用Python和C++兩種程式語言來介紹數據結構。全書內容共15章。書中首先介紹了抽象與分析、數據的抽象等數據結構的基本原理和知識,然後結合Python的特點介紹了容器類、鏈式結構和疊代器、堆棧和隊列、遞歸、樹;隨後,簡單介紹了C++語言的知識,並進一步講解了C++類、C++的動態內存、C++的鏈式結構、C++模板、堆、平衡樹和散列表、圖等內容;最後對算法技術進行了總結。每章最後給出了一些練習題和編程練習,幫助讀者複習鞏固所學的知識。

本書適合作為高等院校計算機相關專業數據結構課程的教材和參考書,也適合對數據結構感興趣的讀者學習參考。

關鍵字: