深度解析C語言:語法細節雜談(番外篇)

C語言中有不少有趣的特性,這些東西不知道也無妨,若知道了則能夠錦上添花,提升生產力或減小bug。數組

因爲這些東西比較雜,內容也很少,不值得專門去介紹他們,也沒有合適的地方在其餘文章內安插這些,因此就以番外篇的形式作個大雜燴。安全

申明位置

在C89(C90)以及以前,塊內的申明(好比函數內)必須放在塊開頭(即全部申明在語句以前)。而從C99開始,申明能夠放在任何位置。好比如下代碼,在C99中能夠經過編譯,而在C89(C90)中不行。app

int main(void) {
    int var1 = 0;
    var1 = 5;
    int var2 = 3;   // 不在塊開頭
}
複製代碼

雖然標準這麼規定,但在實現中仍然採用C89(C90)的方法,「能夠放在任何位置」只是編譯器對代碼進行重排的結果。若是用調試器跟蹤變量,會發如今進入函數時兩個變量就已經申明,只是在各自申明的位置進行初始化。編輯器

main函數

受早期沒有規範以及譚浩強的影響,不少人喜歡寫void main();。其實這是一種徹底錯誤的寫法。函數

從C89(C90)開始,C標準就規定main函數必須返回int類型,正常狀況下應返回0優化

C語言代碼從main函數開始執行,爲何返回值如此重要?ui

代碼執行從main開始,但編譯後的二進制程序的執行卻並非從main開始。在進入main以前,操做系統還有不少工做要作,如初始化環境和傳遞參數等,main的返回值用於操做系統評估程序是否正常退出。(Windows常常在某些不規範程序退出後彈出「**程序是否正常退出」的詢問)spa

若是main函數忘記寫return 0;,那麼編譯時會自動補上,這也是C標準規定的。(可是其餘本該有返回值的函數若是沒有return,其行爲是未定義的)操作系統

說到傳遞參數,C標準規定了兩種main函數參數形式:指針

int main(void);
int main(int argc, char **argv);    // int main(int argc, char *argv[]);
複製代碼

參數能夠爲空或者兩個參數,參數名能夠任意,但類型必須爲第一個int,第二個char **

同時,C標準規定能夠針對不一樣平臺擴展參數。(如下內容摘抄自維基百科:Entry_point

好比在Unix和Windows中能夠經過第三個參數指定運行環境:

int main(int argc, char **argv, char **envp);
複製代碼

基於Darwin的操做系統(例如macOS)具備第四個參數,該參數包含操做系統提供的任意信息:

int main(int argc, char **argv, char **envp, char **apple);
複製代碼

此外,int main();也是能夠經過編譯的。在C++中int main();int main(void);相同,但在C中是不一樣的。C語言中若是函數的參數列表爲(),表示不肯定參數的數量,能夠向函數傳遞任意數量的參數。若是要表示沒有參數,須要添加關鍵詞void

通常而言,第一個參數argc表示操做系統調用此程序的參數計數,包括文件名。第二個參數argv表示參數向量,指向一個指針數組,數組中的每一個指針指向一個字符串(此處的數組和字符串都不是嚴謹的意義)。第一個字符指針所指的字符串是文件名,具體內容與調用程序時終端的寫法一致;最後一個字符指針是個空指針,也就是說argv[argc] == (char *)0

好比,調用一個名爲a.out的程序:

$ ./a.out I love C
複製代碼
argc == 4;
argv[0] == "./a.out";
argv[1] == "I";
argv[2] == "love";
argv[3] == "C";
argv[4] == (char *)0;
複製代碼

以0做爲參數向量結尾與字符串末尾的0有相同的妙用。

有趣的是,即便main寫爲int main(void);,代表不接受參數,但在終端調用程序時仍然能夠輸入參數,而且這些參數也會傳遞給程序,只是咱們在程序內沒法使用這些參數。

switch 語句

switch語句大概是C語言中最使人討厭的語句了,不只幾乎用不到,語法還特別麻煩。(因此某些語言刪除了switch語句)

swith語句的原理其實就是匹配標籤與跳轉,跟goto語句相似,能夠當作是goto語句的加強魔改版。

首先,switch語句只支持整數匹配,好比intchar,不支持floatdouble等浮點數。由於浮點數在計算機中使用二進制表示,並不能徹底準確地表示十進制小數,尤爲在進行計算後尾數很不肯定,幾乎不可能按照預期匹配。

switch語句首創了case標籤。在「語言結構」那篇我講過,case標籤只能用在switch語句內,標籤做用域只是當前switch語句而不是當前函數,而且case標籤容許且只容許標籤名使用整數類型常量表達式。整數能夠理解,上一段已經說了switch只支持整數匹配;由於要進行匹配,要求一個返回值,因此得是表達式;至於常量,case標籤畢竟是個標籤,在編譯時就必須獲得肯定的值,而變量的值在運行時才能肯定,因此只能是常量表達式。

case標籤中的表達式在編譯時進行計算,因此下面兩個標籤相同,若是在同一個swith語句中編譯會報錯。

case 0 + 2:
case 1 + 1:
複製代碼

因爲switch語句只是如goto般的匹配標籤與跳轉,跳轉後就按照順序結構繼續執行了,再也不理會其餘標籤,因此才一般在switch語句內使用break跳出,只執行須要的部分。不過,也能夠利用這個特性讓多個匹配結果執行相同的操做。

switch (var) {
case 0:
case 1:
case 2:
    printf("var < 3");
    break;
default:
    printf("var >= 3");
}
複製代碼

此外,switch語句內的申明必須寫在塊內,以免跳過初始化。以下:

switch (var) {
case 0:
    {
        int a = 5;  // 編譯經過
        printf("%d\n", a);
    }
case 1:
    int a = 5;      // 編譯報錯
    printf("%d\n", a);
}
複製代碼

for 語句

for語句的存在是爲了簡化循環,跟三元運算符 ? : 相似。

for語句緊跟的括號內是由分號;分隔的三個表達式(是的,此處的;是分隔符而不是語句結尾,三個份量都是表達式而不是語句),三個表達式均可以省略(直接空着就行,不能寫void),若是中間的表達式爲空,默認爲1。

PS:ifwhile等語句括號內的判斷條件不能省略。

雖然申明函數時在函數名前加void表示沒有返回值,但函數調用表達式仍然會返回一個void類型的值,但這個值不能被使用,只能丟棄。

for語句括號內的第二個表達式是循環的判斷條件,若是此處調用了一個void函數,那麼void值就被使用了,編譯就會出錯,除此以外,這個位置能夠是任意表達式。至於另外兩個表達式則沒有任何限制。

從C99開始,for語句括號內的第一個表達式能夠換成一個不徹底的申明(徹底的申明以分號結尾,而此處的分號是分隔符,不是申明的一部分)。

for語句括號內變量的做用域小於for所在的塊,大於for語句內部的塊。舉例以下:

int iter = 0;
for (int iter = 0; iter < 5; iter++) {
    int iter = 0;
    printf("%d\n", iter++);
}
printf("%d\n", iter);
複製代碼

在這段代碼中申明瞭三個iter變量,分別在for語句外,for語句的括號內,for語句的塊內。

首先申明瞭最外層的iter,接着進入for語句,又申明瞭一個iter
括號內的空間能夠看做是外層的一個子域,因此能夠申明和外層同名的變量,而且屏蔽掉了外層的iter
括號中另外兩個iter都是在括號內申明的那個iter
而後進入for語句內的塊,又申明瞭一個iter
這個塊內部能夠看做是括號內空間的子域,因此此處申明的iter又屏蔽掉了外層的兩個iter
每次循環都會從新申明一個iter並初始化爲0,因此每次循環輸出一個0,自增操做沒有實際做用。
因爲塊內的iter屏蔽掉了括號內的iter,因此控制條件不受塊內代碼的影響,循環執行5次。
跳出循環後,for語句的兩個iter都被銷燬,打印的是最外層的的iter
因此程序的執行結果是輸出6個0

若是for語句的塊內有continue;語句,那麼在執行此語句後會跳過塊內的剩餘代碼,並執行for語句括號內的第三個表達式,以後纔是判斷條件。
若是for語句的塊內有break;語句,那麼在執行此語句後會直接跳出循環,不執行for語句括號內的第三個表達式。

因此在for語句內使用continue;break;語句都是安全的。

字符串常量

所謂字符串常量,就是指"fake_string"這種類型的東西。

衆所周知,C語言沒有真正意義上的字符串類型,只是以0結尾的字符數組。

"fake_string"並無它看上去那麼簡單,實際上它作了不少事。首先,申請一段連續的內存空間,依次放入char字符fake_string\0(注意最後補了個0),而後返回char數組。與數組同樣,字符串常量也能夠當指針來使用,類型爲char *,且這個指針同時有底層和頂層const屬性。

既然能夠當指針來用,那麼指針運算在字符串常量上也適用。

"string"[0] == 's';
"string"[6] == 0;

char *ch = "string";
複製代碼

字符串常量也能夠像其餘指針同樣做爲ifwhile等的判斷條件。

不過,和數組同樣,對字符串常量使用sizeof運算符獲得的是所佔內存空間的大小。
對字符串常量取地址獲得的是一個行指針。
(兩者都包括末尾的0)

sizeof("1234") == 5;
char (*pt)[5] = &"1234";
複製代碼

結構體

定義


首先,誰都知道但常常忽略的一點,定義結構體末尾必須加分號

結構體定義由4部分組成,struct關鍵字、結構體名、成員列表、結構體變量。

一個完整的結構體定義能夠寫爲:

struct StructName {
    int member1;
    char member2;
} var, *pt;
複製代碼

若是要使用此結構體來申明變量,能夠寫爲:

struct StructName var2;
複製代碼

其中struct StructName是一個完整的類型名。

在結構體定義中,結構體名和結構體變量能夠省略。若是省略結構體名,就沒有辦法在其餘地方使用此結構體,因此不該該同時省略結構體名和結構體變量。

結構體定義僅僅是類型定義,不能對結構體成員初始化。(C++能夠指定成員默認值)

除了定義,也能夠申明一個結構體。僅需struct關鍵字和結構體名。

struct StructName;
複製代碼

申明的結構體甚至能夠沒有定義(只要不去使用它)。

結構體也能夠和typedef結合使用,該類型定義能夠在結構體定義以前。一樣,若是沒有使用此類型,結構體也能夠沒有定義。

typedef struct StructName TypeName;
複製代碼

結構體名,類型名分別屬於不一樣的名字空間,也就是說,這兩個名稱能夠相同。以下:

typedef struct TreeNode TreeNode;

struct TreeNode {
    int data;
    TreeNode *left;
    TreeNode *right;
};
複製代碼

不過仍是建議使用不一樣的名字,避免混淆。

結構體的成員能夠是結構體變量。如:

struct S1 {
    int member1;
    int member2;
};

struct S2 {
    struct S1 member1;
    int member2;
};
複製代碼

或者

struct S1 {
    struct S2 {
        int member1;
        int member2;
    } member1;
    int member2;
};
複製代碼

儘管在第二例中,S2S1內部定義,但其做用域與S1相同。(這點與C++不一樣)

初始化


和數組同樣,結構體也可使用列表初始化。列表按照成員的申明順序依次初始化,沒有指定的成員初始化爲0。

對於包含結構體成員的結構體,可使用相似多維數組那樣的初始化方法。

struct S1 {
    struct S2 {
        int member1;
        int member2;
    } member1;
    int member2;
};

struct S2 var1 = { 1, 2 };
struct S1 var2 = { { 1, 2 }, 3 };
複製代碼

結構體也支持指定初始化,以下:

struct S1 var = { .member1.member2 = 1, .member2 = 2 };
複製代碼

PS:C++不支持指定初始化

臨時結構體


從C99開始,可使用類型加列表創造一個臨時結構體。如:

struct S {
    int member1;
    int member2;
};

(struct S){ 1, 2 };
複製代碼

臨時結構體能夠像通常結構體那樣使用。

(struct S){ 1, 2 }.member1 == 1;
複製代碼

臨時結構體也可使用多維列表以及指定初始化。

PS:C++不支持臨時結構體

內存對齊


若是對一個結構體使用sizeof運算符,會發現它的大小並不必定等於成員大小之和。

C爲了提升效率,採用了內存對齊,是一種犧牲空間換時間的方案。

內存對齊能夠簡述爲:

  1. 結構體的第一個成員的偏移量爲0
  2. 每一個成員相對於結構體起始地址的偏移量是該成員大小的整數倍
  3. 結構體的最終大小是體積最大成員的整數倍
  4. 結構體的最終大小是知足上述條件的最小值

關於結構體的內存對齊,網上有不少很詳細的說明,我就再也不贅述。

其實,在C中到處都有內存對齊,好比定義變量,只是咱們通常無需關心。

整數提高

爲了提升運算效率,C在運算時會將比int小的整數類型提高爲int類型。

這是由於int的大小通常就是處理器的字長。雖然內存按字節編址,但處理器是以字爲單位處理數據的,這種提高是硬件層面的語言優化。

char a = 0, b = 0;
printf("%d\n", sizeof(a + b));
複製代碼

上面的代碼將輸出4

事實上,除了在字符串裏爲了節省空間而使用char類型之外,C幾乎不會使用char類型。

好比字符常量'a',實際上是int類型。(C++中爲char

sizeof('a') == 4;
複製代碼

此外,咱們熟知的getcharfgetc等函數也是返回int類型。

C中的字符常量還有另一個特性,好比有這些寫法:'ab''abc''abcd'

這些可都不是字符串,而是int類型整數。由於int類型的大小通常是char類型的4倍,因此能夠容納最多4個ASCII字符。

暫且無論大小端存儲,只從邏輯上分析。以abcd爲例,將這4個字符的二進制碼依次排列,再組合成一個數,就是abcd的值。爲了簡便,我用十六進制表示二進制。

'a' == 0x61;
'b' == 0x62;
'c' == 0x63;
'd' == 0x64;

'abcd' == 0x61626364;
'abcd' == 1633837924;
複製代碼

關於浮點數,C也幾乎不使用float,由於其精度過低。

咱們經常使用的浮點數常量如0.5實際上是double類型,math,h中接受或返回浮點數的函數也幾乎都是double類型。

布爾類型

C並不原生支持布爾類型,直到c99才引入了關鍵字及類型_Bool

而咱們經常使用的stdbool.h頭文件的主要內容只有:

#define bool _Bool
#define true 1
#define false 0
複製代碼

雖然理論上布爾類型只有1bit,但實際上它佔用1個字節。

_Bool類型只有01兩個值,任何非0數值賦值給_Bool變量都轉換爲1

由於並無原生支持布爾類型,因此在C中條件表達式如1 < 2、邏輯表達式如1 && 0的返回值都是int類型。事實上,通常狀況下,在C中使用布爾類型沒有實際意義。

PS:C++有對布爾類型的完整支持

auto

注意:C中的auto與C++中的auto徹底不一樣

C中的auto表示動態生存期。

auto大概是C中最沒有存在感的東西了,事實上,它確實沒什麼用。

局部變量默認爲動態生存期,無需指定auto;而全局變量默認靜態生存期,不能指定auto

正因如此,C++纔將auto的做用改成了自動判斷類型。

函數申明

C中有兩種申明函數的方式。傳統的「函數申明」以及「函數原型」。

函數申明僅需指定返回值類型和函數名,參數列表爲空。

函數原型還需指定參數類型(參數名可選)。

int fn1();              // 傳統申明
int fn2(int, double) // 函數原型 int fn3(void) // 函數原型 複製代碼

因爲在C中函數參數列表爲()表示不肯定參數,因此編譯器不會在函數調用時檢查參數。

建議在申明函數時使用函數原型,最好連參數名都加上。這樣可讓編譯器來檢查函數調用的正確性,若是使用比較智能的編輯器,還能夠在編寫代碼時得到正確的參數提示。

相關文章
相關標籤/搜索