記錄一下筆者遇到過的預處理和宏定義相關的內容。這里的總結主要來自於筆者閱讀 CS106L 課程材料, C++ Primer 的內容以及官方文檔。
(CS106L 是 Stanford 開設的一門關於 C++ 的課程,課程網址 CS106L.課程網站提供一份關於 C++ 編程的閱讀材料( course reader )和配套的3個編程作業)
程序編譯流程
總的來說,c/c++ 程序由源文件( 字符文本文件 )經歷預處理、編譯、匯編、鏈接這幾個過程轉換為二進制可執行文件,上述流程的示意圖所下所示( csapp 原書第三版,P3 )。
以 Linux 平台下 gcc 編譯程序的過程為例。其經歷的主要過程如下:
1) 首先進行預處理過程,由預處理器處理源文件中以 "#"開始的預處理語句,如將 #include 語句對應的頭文件包含進源文件,將 #define 定義的名字替換為實際的對象等,本文的后續即主要記錄的是在這一部分中起作用的預處理指令。預處理過程結束后,源文件仍為文本文件,仍保持 c/c++ 的語言結構。預處理程序為 cpp .
2) 編譯器進行編譯過程,源程序由對應的 c/c++ 語句轉換為對應的匯編語句,該過程即會對源程序的語法使用進行檢查,報告如行尾未包含分號';',引用了未定義的變量等錯誤。編譯過程結束產生的是根據源文件邏輯生成的匯編文件,其內容為匯編語言。編譯器為 ccl.
3) 匯編器進行匯編過程,在這一過程中,將匯編階段產生的匯編語句轉換為對應的二進制數據,此階段完成后,文件已經完成了二進制化,匯編器為 as。最后,通過鏈接器合並各個二進制文件,檢查文件間的依賴調用關系,程序編譯中常見的 "undefined reference to xxx" 錯誤即發生在這個階段,最終生成可執行文件/庫,完成編譯過程,鏈接器為 ld。
gcc/g++ 使用 -E 參數指定編譯過程到預處理完成后結束,-S 參數指定編譯過程到編譯過程后結束,-c 參數指定編譯過程到匯編過程后結束。更多 gcc/g++ 的編譯參數可以參考筆者的另外一篇博客Linux下編輯、編譯、調試命令總結——gcc和gdb描述 。
預處理
在程序進行編譯的過程中,首先由預處理程序( cpp )對源文件進行了處理,主要是對源文件中的預處理指令( directives )進行處理,生成經過預處理后的 .i 文件。預處理過程由獨立的程序執行,與 c/c++ 語言無關,故而遵循與 c/c++ 不同的語法規則。預處理語句遵循以下幾個語法規則:
1)預處理指令必須為所在行的第一個非空白字符;
2)一條完整的預處理指令必須處於同一行中;
3)預處理指令與 c/c++ 語句不同,在指令末尾不應該加入分號( ';' );
預處理程序依次掃描源文件,並對遇到的預處理指令進行處理,直到掃描完所有源文件內容,完成預處理過程,經過預處理過程的文件一般使用 .i 作為后綴。
預處理指令介紹
#include
#include 指令是 c/c++ 程序中最常見的預處理指令,其一般有兩種形式,#include<stdio,h> 和 #include "stdio.h".當預處理器遇到 #include 指令時,會將該指令指定的頭文件內容復制到源文件 #include 指令所在的位置,即使用指定頭文件的內容替換 #include 指令所在行。上述兩種不同的形式主要區別在於指定了不同的頭文件查找位置和順序。當使用 "stdio.h" 形式指定頭文件時,會首先在當前目錄下尋找對應的頭文件,而使用 <stdlib.h> 形式指定頭文件時,會在一個系統指定的位置尋找對應的頭文件。
在 Windows 環境下,與 IDE Visual Studio 開發環境相關的編譯程序和頭文件等均位於 Visual Studio 的安裝文件夾中,以筆者電腦為例,目錄 Microsoft Visual Studio\2017\Enterprise\VC\Tools\MSVC\14.11.25503\ 為 Visual Studio 文件夾中與 VC++ 編譯相關的內容,其中的 /bin 文件夾中存放的是編譯所需的可執行文件,如編譯器 cl.exe 和鏈接器 link.exe 等,/include 文件夾中存放對應的標准頭文件,使用 #include <stdio.h> 指令時即在該文件夾中尋找指定的頭文件。
在 Linux 環境下,系統按照文件分類將其分別放置在不同的文件夾中.與編譯相關的可執行程序一般存放在 /usr/bin/ 目錄下,與編譯相關的標准頭文件則存放在對應的 /usr/include/ 目錄下。使用 #include<stdio.h> 指令時,預處理程序即在 /usr/include/ 下尋找對應的頭文件。
#include "stdio.h" //首先在當前目錄中查找指定的頭文件 #include <stdio.h> //在系統指定的目錄中尋找指定的頭文件
g++/gcc 中頭文件的搜索順序可以參見 GNU Options for Directory Search. msvc 中頭文件的搜索順序參見 MSVC compilier options /I.
#define
#define 指令是另一個常用的預處理指令. #define 指令可以認為是給表達式"起"一個別名,在預處理器進行處理時,會將所有出現別名的地方替換為對應的表達式,表達式可以是數字,字符串乃至計算表達式.
#define phrase replacement //預處理在源程序中遇到 phrase 時,會將其替換為 replacement
在使用 #define 語句時,有兩個地方需要注意:
1)預處理程序處理時僅進行字符對象的替換,即將"字符串" phrase 替換為"字符串" replacement ,並不會對替換的內容進行語義解析,故而在使用 #define 定義常量的別名時應該注意直接替換是否會造成潛在的語義改變.如下面的例子中, 預處理程序直接將 TEST 使用字符對象 "3+1" 替換,從而使得 x 的賦值語句變為 int x = 3 * 3+1,出現運算邏輯的改變.通過 #define 定義包含有運算的宏時,最好使用括號保證運算的邏輯不會因為外部環境改變.
#define TEST 3+1 // 定義宏 TEST,其值為 4 int x = 3 * TEST; // int x = 3 * 3+1,此時 x 的值為 10 #define TEST (3+1) int x = 3 * TEST; // int x = 3 *(3+1),此時 x 的值為 12
2) #define 語句將 phrase 后的第一個空白字符作為 phrase 與 replacement 的分界, replacement 部分對應為自 phrase 后第一個空白字符開始到行尾換行符的所有內容.比如在 #define 語句的末尾加入了分號 ';'.則該分號 ';' 同樣作為替換的一部分。
#define example //形式上,#define 僅定義了 phrase,沒有定義 diaplacement,該寫法是符合語法的,但無作用 #define test 3; //在 #define 末尾添加了';',則實際被用於替換的部分變為 "3;" int a = test + 3; //在預處理階段被替換為 int a = 3; + 3;
#define 指令還可以定義接收參數的宏,用於定義某些重復使用但又比較簡單的計算流程,比如進行兩個數大小的比較。
#define Marco( arg1, arg2, ... ) macro-body //定義宏 Marco #define max(a,b) ( (a) > (b) ? (a) : (b) ) //定義宏 max,其接收兩個參數 a 和 b,返回 a 和 b 中較大的值,加入括號保證運算順序 int m = 3, n = 4; int c = max( m, n ); //預處理階段 max 語句被替換為 int c = ( (m) > (n) ? (m) : (n) )
通常而言,由於#define 屬於預處理語句,故而替換操作均在預處理階段完成( 遵循預處理器的語法 ),而在實際編譯程序時我們所能看到的大部分反饋信息都是由 c/c++ 編譯器提供的 warning 和 error,所以上述預處理過程產生的錯誤在實際操作時可能會比較難以被 debug 發現。c++ 中也在逐漸提倡使用 const 變量和 inline 函數來盡可能的取代部分宏定義的功能,由於 const 和 inline 均為 c++ 語言所支持的語法,所以使用這些內容帶來的錯誤可以通過編譯器的報錯信息反映出來,從而更容易被定位和發現。內聯函數與 #define 宏的區別在於,#define 為預處理指令,其由預處理器處理,而內聯函數為 c/c++ 語法規定,其由 c/c++ 編譯器處理( 因而后者在出現錯誤時可能可以提供更多的調試信息 )。
#undef
#undef 指令后接一個名字,表示解除該名字的定義,從而不再使用或者重新定義該名字的用法。
#undef TEST //去除 TEST 的定義
更多 #undef 的使用可以參考 GNU - Undefining and Redefining Macros 和 MS - #undef directive (C/C++)。
#if / #elif / #else / #endif
預處理指令中包含有一套用於條件判斷的指令。這些指令在 c/c++ 標准庫頭文件中常見,用於如平台、運行環境判斷等方面。#if 指令的常用結構如下所示。當某個條件判斷的值為真時,則預處理其會將對應的代碼片段包含進源文件中,而其他部分則被直接忽略。值得注意的是,預處理指令均由預處理器進行處理,所以其支持的判斷表達式與 c/c++ 本身支持的表達式有所區別。預處理指令中條件判斷中的表達式僅可以包括 #define 定義的常量,整型,以及這些量構成的算數和邏輯表達式( 可以看到 c/c++ 程序中定義的變量是不被支持的,同時也不支持對浮點型的判斷 )。
#if exp1 code //若 exp1 為真,則對應的代碼片段會被包含在源文件中 #elif exp2 code #else code #endif //使用 #endif 作為結尾
#ifdef / #ifndef / #defined
#defined 宏接收一個名字作為參數,返回值為 1 時表示該名字對應的宏已被定義,返回值為 0 表示值未被定義。
#if defined( TEST ) //如果宏 TEST 被定義,則將 code 包含進源文件中 code #endif
上述判斷宏是否被定義的更簡單的方法是使用 #ifdef 和 #ifndef 來進行條件判斷,其在 c/c++ 標准庫頭文件中比較常見,同樣用於平台、運行環境判斷等方面,還可以用於避免頭文件的重復包含問題。
#ifdef/#ifndef TEST //當定義/未定義宏 TEST 時( 與 #if defined( TEST ) / #if !defined( TEST ) 作用相同 ),將 code 對應的內容包含進源文件中( 供后續編譯 ) code #endif
其他
字符串操作 —— # 以及 ## 操作符
在程序的預處理階段,所有的源文件內容以字符串的方式被處理( 如進行替換 )。預處理指令可通過 # 和 # 運算符執行特定的字符串操作。
在預處理指令中,形如 #name 的操作方式代表的是 name 對應的內容的字符串表示(從而可以進行顯示)。如下面的例子中,輸出了 #n 的內容。這里有兩點值得注意,一是在輸出宏 PRINTOUT 的參數 n 時,該宏定義中使用了 ( n ),保證了 PRINTOUT 的參數 n 是一個表達式時( 如下例中 PRINTOUT 的參數為 x * 42,若不使用括號則預處理后的語句存在語法錯誤 )同樣可以進行正確的編譯;二是在正文代碼中,通過 PRINTOUT( x * 42 ) 使用了宏 PRINTOUT,則字符串 "x * 42" 與宏定義的參數 n 對應,此時 #n 表示字符串常量 "x * 42"。
#define PRINTOUT(n) cout << #n << " is " << (n) << endl int x = 137; //源程序中片斷 PRINTOUT(x * 42); // PRINTOUT 的參數為 x * 42,故而 PRINTOUT 定義中的參數 n 與 x * 42 對應 //預處理后片斷 cout << "x * 42" << " is " << ( x * 42 ) << endl; //#n 表示對應的對象 x * 42 的字符串常量表示"x * 42",而 n 則是進行對象的替換,即將 n 替換為 x * 42,之后進行后續編譯
形如 ##name 的操作方式表示字符串的拼接操作。如下例中,宏 Prefix 的作用是將參數 n 對應的內容加入前綴 "prefix_",注意使用 ##name 方式拼接后的結果並不是字符串常量,而是標志符( 供后續的編譯過程處理 ),所以進行 ## 操作的結果應該滿足 c/c++ 的命名規范,否則后續編譯過程會產生錯誤。
#define Prefix(n) prefix_##n //將 prefix_ 和 n 對應的字符串內容拼接在一起 int Prefix(x); // int prefix_x; 拼接結果是標志符 prefix_x 而不是字符串常量 "prefix_x" int Prefix(3); // int prefix_3;
還有一些比較神奇的宏的用法如 X Macro trick 等,感興趣的可以自行了解一下。