C語言的現代化:語法篇

我坦白,其實我已經很久沒用純C寫東西了。但,這不妨礙我讚美一下 C 語言,甚至爲它謳歌。

引言

所有的C程序都做同一件事,觀察一個字符,然後啥也不幹。
——Peter Weinberger

很多人覺得C語言就如同古董一般,醜陋的語法,簡陋的標準庫,時不時還要考慮一下底層的運行機制,處理指針,動不動就內存泄漏。因此,除非別無選擇(如:電子工程師),人們通常不會再去使用這門古老的預言。人生苦短,何苦用C語言呢?

C 語言會被如此詬病,無外乎是因爲它太過於底層,在計算機初生的歲月裏,還未曾像今天這般精緻。那麼,掌握C語言有沒有必要,大學的第一門編程課是否需要更換成如今如火如荼的Python?我的答案是否定的。

C 語言相對於今天其它高級編程語言來說是比較底層的,這是它的全部優點和缺點的來源。**正因爲它足夠的底層,所以高手可以駕馭它獲得最高的性能,充分地利用計算資源。**計算資源永遠是不夠的,資本家永遠希望能充分利用計算資源來節約成本,C 語言能雄踞 TIOBE 榜這麼多年的原因無外如此。另外,在物聯網時代,C 語言憑藉它的高度可控,優化得當,可以低能耗地作爲可穿戴設備的軟件開發語言。

幸運的是,C99之後,C語言並沒有那麼不堪,C語言的衍生工具體系,也已經極大便利了我們。所以,我希望我寫的,是21世紀的C語言,摒棄醜陋,留下純粹的美。

C語言小史

C詭異離奇,缺陷重重,卻獲得了巨大的成功。
——Dennis Ritchie

世界上出現的第一種高級語言是Algol,它可以算是C語言的前身。Algol的語法和普通語言表達式非常接近,適用於數值計算,多用於科學計算機。1960年,Algol 60推出了很多新概念,如局部性概念、動態、遞歸、巴科斯-諾爾範式(Backus-Naur Form,BNF)等等。從某種意義上,Algol 60是程序設計語言史上的一個里程碑,它標誌着程序設計語言已成爲一門獨立的學科,併爲後來的軟件自動化及軟件可靠性的發展奠定了基礎。

Algol描述算法很方便,但是離計算機硬件系統卻很遠,不適合用來編寫系統程序。1963年英國劍橋大學再Algol語言的基礎上添加了處理硬件的CPL(Combined Programming Language,複合程序語言)。CPL規模大、學習難度也大,最終沒能流行。1967年,劍橋大學的Martin Richards簡化了CPL,推出了BCPL(Basic Combined Programming Language,基本複合程序設計語言)。BCPL是最早使用庫函數封裝基本輸入輸出的語言之一,所以它的跨平臺移植性很好。

C語言的史前階段

1969年,通用電氣、麻省理工學院和貝爾實驗室聯合創立了一個龐大的項目——Multics 工程。該項目的目的是創建一個操作系統,但最終不但無法交付原先承諾的快速而便捷的在線系統,甚至連一點有用的東西都沒有弄出來。Multics 試圖建立一個非常巨大的操作系統,能夠應用於規模很小的硬件系統。Multics成了總結工程教訓的寶庫,但它同時也爲 C 語言體現 “小即是美” 鋪平了道路。

貝爾實驗室的專家們心灰意冷地撤離了 Multics 工程,又開始尋找其他任務。一名叫 Ken Thompson (Go 語言聯合創始人之一)的研究人員對另一個操作系統很感興趣,他爲此好幾次向貝爾實驗室管理層提議,但均遭到否決。在這過程中,Thompson 和他的同事 Dennis Ritchie 自娛自樂,把 Thompson 的“太空旅行”軟件移植到不太常用的 PDP-7 系統。後來 Thompson 爲PDP-7 系統編寫了一個簡易的新型操作系統。它比 Multics 簡單、輕便。整個系統都是用彙編語言寫的。Brian Kernighan 在 1970 年爲他取名爲 UNIX

確切地說,UNIX系統比C語言出現得早。用彙編語言編寫UNIX顯得很笨拙,編制數據結構的過程中浪費了大量的時間。於是,Thompson 簡化了BCPL,創建了 B 語言。因爲硬件系統的內存限制,只能放置解釋器,而不是編譯器,由此產生的低效阻礙了用 B 語言進行 UNIX 系統的編程的想法。

1970 年開發平臺轉移到了 PDP-11,無類型的語言很快就不合時宜了。Thompson 在 PDP-11 上重新用彙編語言實現了 UNIX。Dennis Ritchie 利用 PDP-11 的強大性能,創立了能夠同時解決多種數據類型和效率的 “New B”(牛B!)(很快就改成了“C”)。它採用編譯模式而不是解釋模式,引入了類型系統,每個變量在使用前必須先聲明。

C語言演化

現代C語言語法

本文假定讀者已經瞭解C語言基本語法。

不要使用void main()

時至今日,還看到有課本在用 void main(),但是,請注意,這個用法不符合任何標準。C 語言標準語法是:

int main()

並且,任何編譯器都必須支持

int main(void)

int main(int argc, char *argv[])

另外,C標準知道通常最後的 return 0 極少使用,因此其實是可以省略的。C標準要求:“到達結束main函數的}之前返回一個0值”,也就是說,如果我們沒有在程序最後一行寫return 0,C編譯器會默認加上它。

更靈活的聲明

C99以前,變量必須在函數開頭聲明,這是ANSI C89的古典風格。這樣做的原因是早期編譯器的技術限制。現在,使用變量前依然需要聲明用到的所有變量,但是可以在變量第一次使用的時候才聲明它們。從軟件工程和可讀性角度,都是更好的做法。

/* C89 */
#include <stdio.h>

int main() {
  char *head;
  int i;
  double ratio, denom;

  denom = 7;
  head = "There is a cycle to things divided by seven.";
  printf("%s\n", head);
  for (i = 1; i <= 6; i++) {
    ratio = i / denom;
    printf("%g\n", ratio);
  }
}

從軟件工程的角度,儘可能縮減變量作用域是最好的,減少衝突,也更容易 debug;從代碼編寫的角度,事先要考慮清楚變量,或者總是要回到開頭聲明都是很煩的。在 C99 之後,完全可以在需要的時候再聲明變量。因此上面的代碼可以寫成這樣:

// 現代C
#include <stdio.h>

int main() {
  double denom = 7;
  char *head = "There is a cycle to things divided by seven.";
  printf("%s\n", head);
  for (int i = 1; i <= 6; i++) {
    double ratio = i / denom;
    printf("%g\n", ratio);
  }
}

可能你會問 ratio 定義在循環內部,會不會跟隨循環總是創建和銷燬?理論上會這樣,但是現在我們可以信任編譯器能夠充分理解,不會浪費時間和資源在循環迭代時對這個變量進行銷燬和重新分配

減少顯式類型轉換

上個世紀七八十年代,malloc返回的是一個char *指針,必須對它的結果進行類型轉換(除非你是在爲字符串分配內存),形式類似下面這樣:

double* list = (double *) malloc(list_length * sizeof(double));

現在不需要這樣做了,malloc 現在返回的是 void *,編譯器可以很方便地將它自動轉換成任何類型。在更普遍的情況下,如果把一種類型的數據項賦值給另一種類型的變量是合法的,即使我們沒有顯式地類型轉換,C 也會爲我們完成這個任務。當然,對於不合法轉換,需要編寫一個函數設法完成轉換。

注意:C++ 對類型更爲依賴,需要顯式指定每個類型轉換。

除法陷阱

在C語言及很多語言中,一個整數除以另一個整數總是返回一個整數。因此下面的兩條語句都是正確的:

4 / 2 == 2
3 / 2 == 1

這是很多錯誤的來源,這個錯誤很容易修正:只要在整數的基礎上加上 0.0,就是與這個整數匹配的浮點數。

int two = 2;
3 / (two + 0.0) == 1.5
3 / (2 + 0.0) == 1.5
3 / 2.0 == 1.5
3 / 2. == 1.5

當然你可以選擇使用強制類型轉換的形式:

3 / (double)two == 1.5
3 / (double)2 == 1.5

摒棄float

由於計算機表示浮點數是存在誤差的,如果每個步驟所出現的誤差只有0.01%,經過1000次迭代之後,結果就面目全非了。這個問題在數值算法部分是需要特別關注的,一個很簡單易行的做法就是:使用 double 替代 float。對於計算過程中的中間值,只要使用 long double 就不會出現問題。(當然,是否需要採用long double,視場景而定,無法給出統一的答案)

比較無符號類型整數

一道經典的問題:

#include <stdio.h>

int main() {
  int neg = -2;
  size_t zero = 0;

  if (neg < zero)
    printf("Yes, -2 is less than 0.\n");
  else
    printf("No, -2 is not less than 0.\n");
}

顯然,這麼問了,答案肯定是“No, -2 is not less than 0.”。

要回答這個問題並不難,size_t 是一種無符號整數類型。當有符號類型整數和無符號類型整數進行運算時,有符號整數會先自動轉換成無符號整數類型。在這個問題中,-2在64位機器上被表示成 0xffff ffff ffff fffe,被解釋成無符號數時就是26422^{64}-2,也就是 18446744073709551614,自然比 0 大。

日常編程過程中,建議即使在都爲正數的場景下也用int,不要用 unsigned int

學C語言,一定要了解一下IEEE 754標準

安全地將字符串解析成數字

最流行的方法就是用atoiatof,它們的用法非常簡單,如下:

#include <stdio.h>
#include <stdlib.h>

int main() {
  char twelve[] = "12";
  int x = atoi(twelve);

  char million[] = "1e6";
  double m = atof(million);

  printf("x = %d\nm = %g", x, m);
}

但是,這兩個方法缺乏類型檢查,如果twelve是“XII”,那麼atoi(twelve) 返回0,程序仍然繼續運行。

一個更安全的方法是使用 strtolstrtod。這兩個方法在C89的就有了,但是一直被人們忽略

strtod函數接受第二個參數,是一個指向char的指針。這個指針將指向第一個不能解析爲數字的字母,利用這個指針,還可以告訴你文本是否只包含了數字。如果文本全都是數字,指針應該指向字符串的末尾,也就是\0。因此可以用一個條件來判斷它,如:

if (*end) {
  printf("readfailure\n");
}

下面給出一個例子,計算在命令行給出的數值的平方:

#include <stdio.h>
#include <stdlib.h>
#include <math.h>

int main(int argc, char **argv) {
  if (argc < 2) {
    printf("Give me a number on the command square.\n");
    return 1;
  }
  char* end;

  double in = strtod(argv[1], &end);
  if (*end) {
    printf("I couldn't parse '%s' to a number.\nI had trouble with '%s'.\n", argv[1], end);
    return 2;
  }

  printf("The square of %s is %g.\n", argv[1], pow(in, 2));
}

C99 以後,也有 strtofstrtold 函數用來進行 floatlong double 的轉換。整數版本的 strtolstrtoll,轉換 long intlong long int。這些函數接受三個參數:要轉換的字符串、指向end的指針和轉換基數。傳統的轉換基數是10,但是你也可以設置爲2用來讀入二進制,8用來讀入八進制,16用來讀入十六進制,甚至設置成36。

被忽視的語法

C語言有不少被傳統入門教科書忽略的語法:比如:

  • staticextern鏈接
  • const

編寫健壯的宏

在不少的C++的書中,都會強調少用宏,如果要定義常數,儘可能用const。但是,存在即有其合理性,一方面,宏確實很容易產生錯誤,但另一方面,精心編寫的、健壯的宏每次都能精確的完成任務。比如,logsinpow

易錯點

宏用於執行文本替換,一般而言,替換的文本比宏更長,所以經常被稱爲宏的展開。文本替換的思路和普通的函數並不一樣,因爲它的展開結果可能會和源代碼中的文本或者源代碼發生交互,從而可能產生意想不到的結果。

比如這個宏:

#define double(x) 2 * x

如果用戶寫了 double(5 + 4),用戶希望結果是 2 * (5 + 4) 等於18,但是實際上宏展開後會變成 2 * 5 + 4 等於14。

我們希望能在需要的場合使用宏,又不需要總是刻意防範宏帶來的副作用,這就要求必須編寫一個健壯的宏。由上面的例子,一個很自然的宏編寫規則就是加括號。除非有特定的理由不使用括號,否則都應該把所有的輸入放到括號中。

例如:上面的宏的需要加上括號才能正確發揮作用,

#define double(x) (2 * (x))

編寫宏的第二條原則:避免重複作用。比如這個加了括號的教科書例子還是存在一定的風險:

#define max(a, b) ((a) > (b) ? (a) : (b))

如果用戶嘗試

int x = 1, y = 2;
int m = max(x, y++);

用戶本意是希望 m = 2,並且 y 增加到3。但是,這個宏展開後的結果是這樣的:

m = ((x) > (y++)) ? (x) : (y++))

顯然,y++ 執行了兩次,結果m = 3,並不是用戶預期的2。

作爲宏的用戶,應該儘量避免在宏中進行會產生副作用的調用。編寫宏的第三個原則:如果是代碼塊兩端需要加上花括號

例如:

#define doubleincrement(a, b) (a)++;(b)++;

如果用在if語句的後面,它的行爲可能是不正確的:

int x = 1, y = 0;
if (x > y)
  doubleincrement(x, y);

如果調整一下縮進,可以使得錯誤表現得更加明顯:

int x = 1, y = 0;
if (x > y)
  (x)++;
(y)++;

所以,宏應該寫成

#define doubleincrement(a, b) { (a)++; (b)++; }

但是,即使加上花括號,還是會有問題,比如下面這樣:

if (a > b) doubleincrement(a, b);
else return 0;

展開之後,變成

if (a > b) {
  (a)++;
  (b)++;
};
else return 0;

顯然,多了一個分號,會得到編譯錯誤。很遺憾的告訴你,沒有什麼好的方法可以解決這個問題,刪掉分號或者再加上一對大括號都不是直接的解決方法,也不符合UI最好不透明的要求。

代碼塊的宏最好用函數,加上 inline (C99)也不錯啊!

有一個略微醜陋的解決方法是用do-while循環:

#define doubleincrement(a, b) do { (a)++; (b)++; } while (0)

如果你的宏不太正常,並且你使用的編譯器是gcc、Clang、icc等等,你可以使用 -E 選項只運行預處理器,查看所有預處理指令展開的結果。

預處理器技巧

在K&R中寫道:“#之前的空白被忽略”。這給我們一個提示:可以把隨用隨棄的宏放到函數中間,就在他們被使用之前,並且採用和函數一樣的縮進。

按照過去的原則,這不太符合程序的“正確”組織(所有的宏通常被放到文件的頭部)。但是,把宏放到相應位置更易於引用,清晰地凸顯它的隨用隨棄的本質。

# 的另外一個用法是放到宏中,把輸入參數轉換成一個字符串。

#include <stdio.h>
#define Peval(cmd) printf(#cmd ": %g\n", cmd);

int main(int argc, char const *argv[])
{
  double *plist = (double[]){1, 2, 3};
  double list[] = {1, 2, 3};
  Peval(sizeof(plist) / (sizeof(double) + 0.0));
  Peval(sizeof(list) / (sizeof(double) + 0.0));
  return 0;
}

運行結果:

sizeof(plist) / (sizeof(double) + 0.0): 1
sizeof(list) / (sizeof(double) + 0.0): 3

在這種用法中,宏的輸入被打印爲普通的文本,然後打印它的值。#cmd把cmd當作一個字符串。例如:

Peval(list[0])

的展開結果是

printf("list[0]" ": %g\n", list[0]);

接着,預處理器會把兩個相鄰的字符串合併爲一個。

注意:sizeof是C語言的關鍵字,並不是普通的函數。

測試宏

能夠運行 C 語言程序的硬件五花八門,C 代碼可以通過測試宏來發現編譯器和目標平臺的能力。可以使用-D選項來傳入,或者用#include包含列舉了該平臺一些能力的文件,比如POSIX系統的unistd.h,Windows系統的windows.h

gcc 和 clan g可以通過-E -dM 選項(-E:只運行預編譯;-dM:輸出宏的值)給出一系列定義好的宏。

echo "" | clang -dM -E -xc -

避免頭文件重複包含

如果重複包含同一個頭文件,預處理階段會產生重複。C語言爲了解決這個問題,採用了一種叫做“包含保衛”的方法來預防這種錯誤。在這種方法中,我們需要定義一個專屬於這個文件的變量,然後把這個文件的內容包裹起來。

#ifndef _ALREADY_INCLUDED_HEAD_H_
#define _ALREADY_INCLUDED_HEAD_H_

/* your header.h file */

#endif

第一次包含的時候,發現沒有定義_ALREADY_INCLUDED_HEAD_H_,這時候便定義_ALREADY_INCLUDED_HEAD_H_,幷包含頭文件的定義。

如果重複包含的該頭文件的時候,預處理時編譯器就會發現_ALREADY_INCLUDED_HEAD_H_已經定義了,就不會執行塊中的頭文件定義,直接跳轉到 #endif,從而避免了重複包含。

這種方法自從K&R的第二版就在用,不過,現在可以更加簡單在頭文件開頭加上#pragma once,編譯器就不會發生二次包含。#pragma是編譯器相關的,但是主流的編譯器都支持#pragma once

static和extern

假如編譯器處理 one.c,一般情況下會生成one.o文件,然後鏈接器把這些 .o 文件鏈接在一起,生成一個庫文件或者可執行文件。

如果在兩個不同的文件中都有變量 x 的聲明,可能一個文件的作者不知道另一個文件的作者也選擇了x 這個名稱,因此這兩個 x 應該被分隔到不同的空間。也有可能這兩位作者都注意到了他們引用了同一變量,鏈接器應該把所有x的引用都指向內存中的同一地址。

使用外部鏈接 extern 就意味着不同文件中匹配的符號應該被鏈接器視爲同一聲明。相反,使用 static 關鍵字表示內部鏈接,表明在函數外面聲明的任何東西都屬於文件作用域。

const

在C語言中,傳遞給函數的總是數據的一個複製,但是我們可以通過向函數傳遞指向數據的指針,從而允許函數修改輸入數據。我們看到函數輸入的是普通的非指針數據,就可以知道調用者所擁有的源變量並不會被更改。當我們傳入的是指針數據的時候,情況就不太明顯了,列表和字符串自然都是指針,因此輸入的可能是需要被修改的數據,也有可能僅僅只是字符串。

const 是讓代碼更易讀的修飾手法。它是一種類型限定符,表示輸入指針所指向的數據在整個函數的執行過程中不會被修改,編譯器也可以據此作出特定情況的優化,應該儘可能使用它。

不過,請注意,const只是修飾手法,它不能保證數據不能更改。 一個有const標記的數據可以通過另一個名字被修改。比如:

#include <stdio.h>

void set_elmt(int *a, int const *b) {
  a[0] = 3;
}

int main() {
  int a[10] = {};
  int const *b = a;
  set_elmt(a, b);
  return 0;
}

ab都指向同一個數據,但是由於在set_elmt中,a 並沒有const標記,因此仍然可以修改b數組的一個元素。

const只是一個修飾詞

理解聲明

在K&R的第二版中,Kernighan和Ritchie承認,“C語言聲明的語法有時候會帶來嚴重的問題”。類型、const、函數括號、數組下標等等聚集起來可以產生很晦澀的聲明。對絕大多人而言,這都將是一個不小的障礙。

理解複雜聲明的技巧在於從右往左閱讀,以最簡單的intconst爲例:

  • int const:一個常量整數
  • int const *:一個(變量)指針,指向一個常量整數
  • int * const:一個常量指針,指向一個(變量)整數
  • int * const *:一個指向常量指針的指針,常量指針指向一個整數。
  • int const **:一個指向指針的指針,指向常量整數
  • int const * const *:一個指向常量指針的指針,常量指針指向常量整數

const 是左結合,const int是因爲左邊沒有修飾才修飾右邊。爲了保持內在邏輯的一致性,建議用int const

轉換 const

實踐中,可能會遇到有一個指針被標記成 const,但是需要把它傳遞給一個並沒有使用 const 標記的形參。在強制轉換const之前,必須考慮這樣的問題:這個未使用的const的被調用函數,是否以某種方式修改了這個指針?

如果能確信這個函數並沒有違背通過 const關 鍵字對指針所施加的常量承諾,把這個常量指針強制轉換爲非常量指針以避免編譯器報錯就是非常合理的做法。如果你不能確定所調用的函數是否會修改傳入的指針,最保險的做法,複製一份完整數據

#include <stdio.h>

void set_elmt(int *a, int *b) {
  a[0] = 3;
}

int main() {
  int a[10] = {};
  int const *b = a;
  set_elmt(a, (int *)b);
  return 0;
}

還有什麼

還有一些,比如 asprintf 用來處理字符串比原生的方便得多得多。不過,它不是標準庫裏的,但是可以在 GNU 和 BSD 標準庫裏使用。C99 還帶來了一些新的語法,比如:變長參數宏、複合變量、指定初始化器等等。這些,有機會再補充~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章