-- 簡書作者 謝恩銘 轉載請註明出處
第二部分第五課:預處理
上一課C語言探索之旅 | 第二部分第四課:字符串,我們結束了關於字符串的旅程。
大家在一起經歷了前三課:指針,數組和字符串的“疲勞轟炸”之後,這一課迴歸輕鬆。
就像剛在沙漠裏行走了數日,突然看到一片綠洲,還有準備好的躺椅,清澈的小湖,冷飲,西瓜,一臺頂配電腦(又暴露了程序員的本質...)等等,腦補一下這個畫面還是挺開心的。
前面三課我們一下子學了不少新知識點,雖然小編沒有那麼"善良",但也不至於不給大家小憩的機會啊。
這一課我們來聊聊“預處理器”,這個程序就在編譯之前運行。
當然了,雖然這一課不難,可以作爲中場休息,但不要認爲這一課的內容不重要。相反,這一課的內容非常有用(你就說哪一課的內容不是非常有用吧...)。
include指令
在這個系列教程最初的某一課裏,我們已經向大家解釋過:在源代碼裏面總有那麼幾行代碼是很特別的,稱之爲“預處理命令”。
這些命令的特別之處就在於它們總是以#開頭,所以很容易辨認。
預處理命令有好幾種,我們現在只接觸了一種:
以#include開始的預處理命令。
"#include"命令可以把一個文件的內容包含到另一個文件中。
在之前的課程裏我們已經學習瞭如何用#include命令來包含頭文件(以.h結尾的)。
頭文件有兩種,一種是C語言的標準庫定義的頭文件(stdio.h, stdlib.h等),另一種是用戶自定義的頭文件。
- 如果要導入C語言標準庫的頭文件(位於你安裝的IDE(集成開發環境)的文件夾或者編譯器的文件夾裏),需要用到尖括號 <>。如下所示:
#include <stdio.h>
- 如果要導入用戶自己項目中定義的頭文件(位於你自己項目的文件夾裏),需要用到雙引號。如下所示:
#include "file.h"
事實上,預處理器在編輯之前運行,它會遍歷你的源文件,尋找每一個以#開頭的預處理命令。
例如,當它遇到#include開頭的預處理命令,就會把後面跟的頭文件的內容插入到此命令處,作爲替換。
假設我有一個C文件,包含我的函數的實現代碼;還有一個H文件,包含函數的原型。
我們可以用下圖來描繪預處理的時候發生的情況:
如上圖所示,H文件的所有內容都將替換C文件的那一行預處理命令。
假設我們的C文件內容如下所示:
#include "file.h"
int myFunction(int thing, double stuff)
{
/* 函數體 */
}
void anotherFunction(int value)
{
/* 函數體 */
}
我們的H文件內容如下所示:
int myFunction(int thing, double stuff);
void anotherFunction(int value);
編輯之前,預處理器就會用H文件的內容替換那一行 #include <file.h>
經過替換之後, C文件內容如下:
int myFunction(int thing, double stuff);
void anotherFunction(int value);
int myFunction(int thing, double stuff)
{
/* 函數體 */
}
void anotherFunction(int value)
{
/* 函數體 */
}
define命令
現在我們一起來學習一個新的預處理命令,就是#define命令。
這個命令使我們可以定義預處理常量,也就是把一個值綁定到一個名稱。例如:
#define LIFE_NUMBER 7
我們必須按照以下順序來寫:
- "#define"
- 要綁定數值的那個名稱
- 數值
注意:雖然說這裏的名稱是大寫字母(因爲習慣如此,你也可以小寫),但是這與我們之前學過的const變量還是很不一樣。
const變量的定義是像這樣的:
const int LIFE_NUMBER = 7;
上面的const變量在內存中是佔用空間的,雖然其不能改變,但是它確確實實儲存在內存的某個地方。但是預處理常量卻不是這樣。
那預處理常量是怎樣運作的呢?
事實上,處理器會把由#define定義的所有的名稱替換成對應的值。
如果大家使用過微軟的軟件Word,那應該對“查找並替換”的功能比較熟悉。我們的#define 就有點類似這個功能,它會查找當前文件的所有#define定義的常量名稱,將其替換爲對應的數值。
你也許要問:“用預處理常量意義何在呢?有什麼好處?”
問得好。
-
第一,因爲預處理常量不用儲存在內存裏。就如我們之前所說,在編譯之前,預處理常量都被替換爲代碼中的數值了。
-
第二,預處理常量的替換會發生在所有引入#define語句的文件裏。如果我們在一個函數裏定義一個const變量,那麼它會在內存裏儲存,但是如果前面不加static關鍵字(關於static,請參看之前的課程)的話,它只在當前函數有效,函數執行完就被銷燬了。然而預編譯常量卻不是這樣,它可以作用於所有函數,只要函數裏有那個名稱,都會替換爲對應的數值。這樣的機制在有些時候是非常有用的。特別對於嵌入式開發,內存比較有限,經常能看到預處理常量的使用。
能否給出一個實際使用#define的例子?
好吧,就說一個之後我們第三部分內容:《編寫C語言遊戲》中經常要用到的。
當你用C語言來創建一個窗口時,你需要定義窗口的寬度和高度,這時候就可以使用#define了:
#define WINDOW_WIDTH 800
#define WINDOW_HEIGHT 600
看到使用預處理常量的好處了麼?之後如果你要修改窗口的寬度和高度,不必到代碼裏去改每一個值,只需要在定義處修改就好了,非常節省時間。
注意:通常來說,#define語句放在.h頭文件中,和函數原型那些傢伙在一起。
如果有興趣,大家可以去看一下標準庫的.h文件,例如stdio.h,你可以看到有不少#define語句。
用於數組大小(維度)的#define
我們在C語言編程中也可以使用預處理常量(#define語句定義)來定義數組的大小。例如:
#define MAX_DIMENSION 2000
int main(int argc, char *argv[])
{
char string1[MAX_DIMENSION], string2[MAX_DIMENSION];
// ...
}
你也許會問:“但是,不是說我們不能在函數的中括號中放變量,甚至是const變量也不可以嗎?”
對,但是MAX_DIMENSION並不是一個變量,也不是一個const變量啊!就如之前說的,預處理器會在編譯之前把以上代碼替換爲如下:
int main(int argc, char *argv[])
{
char string1[2000], string2[2000];
// ...
}
這樣有一個好處,就如之前所說,如果將來你覺得你的數組大小要修改,可以直接修改MAX_DIMENSION的數值,非常便捷。
在#define中的計算
我們還可以在定義預處理常量時(#define語句中)做一些計算。
例如,以下代碼首先定義了兩個預處理常量WINDOW_HEIGHT和WINDOW_WIDTH,接着我們可以利用這兩個預處理常量來定義第三個預處理常量: PIXEL_NUMBER(意思是 像素數目,等於 窗口寬度 x 窗口高度),如下:
#define WINDOW_WIDTH 800
#define WINDOW_HEIGHT 600
#define PIXEL_NUMBER (WINDOW_WIDTH * WINDOW_HEIGHT)
在編譯之前,PIXEL_NUMBER會被替換爲,800 x 600 = 480000
當然預處理常量對於基本的運算:+,-,*,/和%都是支持的。
注意:用#define定義預處理常量時要儘量多用括號括起來,不然會出現意想不到的結果,因爲預處理常量只是簡單的替換。
系統預先定義好的預處理常量
我們自己可以定義很多預處理常量,C語言系統也爲我們預先定義了幾個有用的預處理常量。
這些C語言預定義的預處理常量一般都以兩個下劃線開始,兩個下劃線結束,例如:
- "__LINE__" :當前行號
- "__FILE__" :當前文件名
- "__DATE__" :編譯時的日期
- "__TIME__" :編譯時的時刻
這些預處理常量對於標明出錯的地方和調試是很有用的,用法如下:
printf("錯誤在文件 %s 的第 %d 行\n", __FILE__, __LINE__);
printf("此文件在 %s %s 被編譯\n", __DATE__, __TIME__);
輸出如下:
錯誤在文件 main.c 的第 10 行
此文件在 Oct 19 2016 09:11:01 被編譯
不帶數值的#define
我們也可以像如下這樣定義預處理常量:
#define CONSTANT
很奇怪吧,後面竟然沒有對應的數值。
以上語句用於告訴預處理器:CONSTANT這個預處理常量已經定義了,僅此而已。雖然它沒有對應的數值,但是它“存在”(想起了鄧紫棋唱的那首《存在》)。
你也許要問:“這樣有什麼意義呢?”
這樣做的用處暫時還不明顯,但是我們在這一課裏馬上會學到,請繼續讀下去。
宏
我們現在知道用#define語句可以把一個數值綁定到一個名稱上。然後在預處理階段(編譯之前)預處理器就可以在代碼裏用數值替換所有的預處理常量了,非常方便。例如:
#define NUMBER 10
意味着接下來你的代碼裏所有的NUMBER都會被替換爲10。是簡單的“查找-替換”。
但是#define預處理命令還可以做更厲害的事,果然是Bigger than bigger麼?
"#define"還可以用來替換… 一整個代碼體。當我們用#define來定義一個預處理常量,這個預處理常量的值是一段代碼的時候,我們說我們創建了一個“宏”。
“宏”,英語是macro。一開始可能不太好理解。這是一個編程術語。臺灣一般翻成“巨集”。可以說是一種抽象,但在C語言裏就只用於簡單的“查找-替換”。
趣事:之前某網站出現一個詞:“王力巨集”,原來這個網站在做簡體中文到繁體中文轉換時,把“王力宏”中的那個“宏”替換爲了“巨集”,我們的力宏就這麼“躺槍”了……
沒有參數的宏
下面給出一個很簡單的宏的定義:
#define HELLO() printf("Hello\n");
可以看到,與之前的預處理常量不太一樣的是:名稱後多了一對括號,我們馬上就來看這有什麼用處。
我們用一段代碼來測試一下:
#include <stdio.h>
#define HELLO() printf("Hello\n");
int main(int argc, char *argv[])
{
HELLO()
return 0;
}
運行輸出:
Hello
是不是有點意思,不過暫時還不是那麼新穎。
需要理解的是:宏不過是在編譯之前的一些代碼的簡單替換。
上面的代碼在編譯前會被替換爲如下:
int main(int argc, char *argv[])
{
printf("Hello\n");
return 0;
}
如果你理解了這個,那對於宏的基本概念也差不多理解了。
你也許會問:“那我們每一個宏只能寫在一行上麼?”
不是的,只需要在每一行的結尾寫上一個 \(反斜槓),就可以開始寫新的一行了,而預處理器會把這些行看成一行的,可以說 \ 起到了鏈接的作用。例如:
#include <stdio.h>
#define PRESENT_YOURSELF() printf("您好, 我叫Oscar\n"); \
printf("我住在法國巴黎\n"); \
printf("我喜歡游泳\n");
int main(int argc, char *argv[])
{
PRESENT_YOURSELF()
return 0;
}
運行輸出:
您好,我叫Oscar
我住在法國巴黎
我喜歡游泳
我們注意到了,調用宏的時候,在末尾是沒有分號的。事實上,因爲
PRESENT_YOURSELF()
這一行是給預處理器來處理的,所以沒必要以分號結尾。
有參數的宏
我們剛學習了無參的宏,也就是括號裏沒有帶參數的宏。這樣的宏有一個好處就是可以使代碼裏經常出現的較長的代碼段變得短一些,看起來簡潔。
但是,宏帶了參數才真正變得有趣起來。
#include <stdio.h>
#define MATURE(age) if (age >= 18) \
printf("你成年了\n");
int main(int argc, char *argv[])
{
MATURE(25)
return 0;
}
運行輸出:
你成年了
這樣是不是就有點像函數了?就是這麼酷炫。
上面的宏是怎麼運作的呢?
age這個參數在實際調用宏的時候,會被替換爲括號裏的數值,這裏是25,所以,整個宏就替換爲了:
if (25 >= 18)
printf("你成年了\n");
不就是我們熟悉的老朋友: if語句麼。
上面的宏定義中,我們也可以用一個else來處理“你還未成年”的條件,自己動手試一下吧,不難。
當然我們也可以創建帶多個參數的宏,例如:
#include <stdio.h>
#define MATURE(age, name) if (age >= 18) \
printf("你已經成年了, %s\n", name);
int main(int argc, char *argv[])
{
MATURE(27, "Oscar")
return 0;
}
運行輸出:
你已經成年了,Oscar
好了,對於宏我們需要了解的也差不多介紹完了。如果使用得當,宏是相當有用的。
但是有些時候,濫用宏也會產生很多難以調試的錯誤,所以宏是C語言的一把雙刃劍。
通常我們在C語言的編程中是不需要經常使用宏的,因爲宏有一個缺點:
它只是簡單的替換,根本不檢查變量和參數類型,所以用得不好會出問題。
不過,很多複雜的庫,例如我們在第三部分“用C語言編寫遊戲”裏會介紹的擅長圖形界面編程的wxWidgets和Qt,就大量使用了宏。
所以對於宏,我們需要理解。
條件編譯
預處理命令除了有以上三個作用以外,還可以實現“條件編譯”。聽起來有點玄乎,但是隻要語文沒有還給小學體育老師,那應該不難理解。
開個玩笑,我們還是一起來看看如下的例子:
#if 條件1
/* 如果條件1爲真,將會被編譯的代碼 */
#elif 條件2
/* 如果條件2爲真,將會被編譯的代碼 */
#endif
是不是有點類似之前學過的if語句?
可以看到:
- 關鍵字#if是一個條件編譯塊的起始,在後面可以插入一個條件
- 關鍵字#elif(else if的縮寫)的後面可以插入另一個條件
- 關鍵字#endif是一個條件編譯塊的結束
與if語句不同的是,條件編譯沒有大括號。
你會發現“條件編譯”是相當有用的,它使我們可以按照不同的條件來選擇編譯哪些代碼。
與if語句類似,條件編譯塊必須有且只能有一個#if,可以沒有或有多個#elif,必須有且只能有一個#endif。
如果條件爲真,那麼後面跟着的代碼會被編譯,如果條件爲假,後面的代碼就會在編譯時被忽略。
"#ifdef"和"#ifndef"
現在我們就來看看之前介紹的“沒有數值的#define”的用處。
還記得嗎?
#define CONSTANT
我們可以
- 用#ifdef來表述:“如果此名稱已經被定義”,因爲ifdef是if defined的縮寫,表示“如果已被定義”
- 用#ifndef來表述:“如果此名稱沒有被定義”,因爲ifndef是if not defined的縮寫,表示“如果沒有被定義”
不得不重提英語對於編程進階的重要性,可以參看我之前寫的文章:對於程序員, 爲什麼英語比數學更重要? 如何學習
例如我們有如下代碼:
#define WINDOWS
#ifdef WINDOWS
/* 當WINDOWS已經被定義的時候要編譯的代碼 */
#endif
#ifdef LINUX
/* 當LINUX已經被定義的時候要編譯的代碼 */
#endif
#ifdef MAC
/* 當MAC已經被定義的時候要編譯的代碼 */
#endif
可以看到,用這樣的方法,可以很方便地應對不同平臺的編譯,使我們的代碼實現跨平臺。
比如,我要編譯針對windows平臺的代碼,那就在開始處寫:
#define WINDOWS
我要編譯針對linux的代碼,那就改成:
#define LINUX
如果是mac系統,那就改成:
#define MAC
當然了,每次修改代碼之後都要重新編譯(畢竟沒有那麼神奇)。
使用#ifndef來避免“重複包含”
"#ifndef"是非常有用的,經常用於.h頭文件中,以避免“重複包含”。
什麼是“重複包含”呢?
其實不難理解,設想以下情況:
我有兩個頭文件,分別命名爲 A.h和B.h 。在A.h中我寫了
#include "B.h"
而不巧在B.h中我寫了
#include "A.h"
要知道,在代碼複雜度提高以後,這樣的情況不是不可能發生的。很多時候,我們一個文件裏要inculde好多個頭文件,很容易暈。
這樣一來,A.h文件需要B.h來運行,而B.h文件需要A.h來運行。
如果我們稍加思索,就不難想到會發生什麼:
電腦讀入A.h文件,發現需要包含B.h
電腦在A.h中包含進B.h文件的內容,可是在B.h文件的內容裏又發現需要包含A.h
如此循環往復,什麼時候是個頭啊…
你肯定認爲這會永不止息…
事實上,碰到這種情況,預處理器會停止,並且拋出“我受不了這麼多包含啦”的錯誤,你的程序就不能通過編譯。
那如何來避免這樣的悲劇呢?
下面就是解方。並且從今以後,我強烈建議大家在每一個.h頭文件中都這樣做!讓我任性一回吧...
#ifndef DEF_NAMEOFFILE // 如果此預處理常量還未被定義,即是說這個文件未被包含過
#define DEF_NAMEOFFILE // 定義此預處理常量,以避免重複包含
/* file .h文件的內容 (其他的#include, 函數原型, #define等...) */
#endif
如上所示,在#ifndef和#endif之間我們放置.h文件的內容(其他的#include, 函數原型, #define等)。
我們來理解一下到底這段代碼是怎麼起作用的?(我自己第一次碰到這個技術時也有點不太理解):
假使我們的file.h文件是第一次被包含(被include),預編譯器讀到開頭的那句話:
#ifndef DEF_NAMEOFFILE
意思是《DEF_NAMEOFFILE這個預編譯常量還沒有被定義》,這個條件是真的。所以預編譯器就進入#if語句內部啦(和普通的if語句類似的機制)
接着預編譯器就讀到第二句命令:
#define DEF_NAMEOFFIL
這句的意思是《定義DEF_NAMEOFFIL這個預處理常量》。所以預編譯器乖乖地執行,定義DEF_NAMEOFFIL。
接着它就將file.h頭文件的主體內容都包含進調用#include "file.h"或者#include <file.h>的那個文件。
這樣的話。下一次這個file.h頭文件再被其他文件包含時,
#ifndef DEF_NAMEOFFILE
這個條件就不爲真了,預編譯器就不會執行條件編譯內部的語句了,自然就不會再把頭文件的主體內容包含了。
這樣就能巧妙的避免“重複包含”。
當然,那個預處理常量的名稱不一定要和我的一樣,也不一定要大寫,但是最好大寫,習慣用法。
但是每一個頭文件的所用常量名稱須要不同,否則,只有第一個頭文件會被包含。
非常建議大家有空去看一下標準庫的頭文件,如stdio.h,stdlib.h等。你會發現它們都是以這樣的方式寫的(開頭#ifndef,結尾#endif)。
總結
-
預處理器是這樣一個程序,它在編譯之前執行, 它先分析源代碼, 然後做出一定修改。
-
預處理命令有好幾種。#include命令用於在一個文件中插入另一個文件的內容。
-
"#define"命令定義一個預處理常量。之後預處理器就會把代碼裏所有#define定義的常量名稱替換成對應的值。
-
宏是一些代碼塊,定義也要藉助#define。宏可以接受參數。
-
我們也可以用預編譯器的語言來寫一些預編譯條件,以實現條件編譯。一般我們使用關鍵字:#if,#elif和#endif等。
-
爲了防止一個頭文件被多次包含,我們會用條件編譯和預處理常量的組合來“保護”它。之後我們寫的.h頭文件都會採用這種方式,也很建議採用。
第二部分第六課預告:
今天的課就到這裏,一起加油咯。
下一次我們學習:C語言探索之旅 | 第二部分第六課:創建你自己的變量類型