2016年,C语言该怎样写
C語言的首要原則是——能不寫C語言就不寫。如果一定要寫,請遵守現代規則。
C語言誕生于20世紀70年代初。人們在其發展的各個階段都在“學習C語言”,但在學習C語言之后,知識往往停滯不前,從開始學習它的那年起積攢起不同觀點。
很重要的一點是,用C語言開發,不要再繼續認為“那是我在八零或者九零年代學習的東西”。
本文首先假定處于符合現代設計標準的平臺上,并且不存在需要兼容過多遺留約束。我們當然不該僅因為某些公司拒絕更新其二十年前的老系統就完全遵守陳舊的標準。
準備
C99標準(C99和C11分別是指1999年和2011年誕生的C語言標準,顯然C11標準比較新)。
clang(一種C語言編譯器),默認情況
- clang使用C11的一種擴展版本(GNU C11模式),所以不需要多余的配置就可以適應現代特性。
- 如果你想使用標準的C11,需要指定-std=c11;如果你想使用標準C99,則指定-std=c99。
- 與gcc相比,clang編譯文件速度更快。
gcc需要指定-std=c99或-std=c11
- gcc構建源文件的速度一般慢于clang,但某些時候卻能保證編碼速度更快。性能比較和回歸測試也非常重要。
- gcc-5默認使GNU C11模型(與clang相同),但如果你確切地需要C11或C99,仍應指定-std=c11或-std=c99。
優化
-O2,-O3
- 通常想使用-O2,但有時也使用-O3。在兩個級別下(通過編譯器)分別進行測試并且保持最佳性能。
-Os
- -Os如果你關注緩存效率(本該如此),這個選項能幫上你。
警告
-wall -Wextra -pedantic
- 最新版本的編譯器支持-Wpedantic,但為了向后兼容其也接受古老的-pedantic。
在測試過程中,應該在所有的平臺上都添加-Werror和-Wshadow。
- 因為不同的平臺、編譯器和庫會發出不同警告,通過使用-Werror可更有針對性地部署生產資源。你或許并不想僅因為某個平臺上從未見過的GCC版本在新的運行方式下時報錯就毀掉用戶的全部構建。
額外選擇包括-Wstrict-overflow -fno-strict-aliasing。
- 要么指定-fno-strict-aliasing,要么就確保只以對象創建時的類型對其進行訪問。因為許多C語言代碼擁有跨類型的別名,當不能控制整個底層源代碼樹時,使用-fno-strict-aliasing是個不錯的選擇。
到目前為止,clang有時會對一些有效語法發出警告,所以需要添加-Wno-
- missing-field-initializers。GCC在4.7.0版本后修正了這些不必要的警告。
構建
編譯單元
- 構建C程序項目的最常見方式是將每個C源文件分解成目標文件,最后將所有目標文件鏈接到一起。這個過程在增量開發中表現很好,但從性能和優化角度看,并非最優。因為在這種方式下編譯器不能檢測出跨文件的潛在優化之處。
LTO——鏈接時優化
- LTO通過使用中間表示方式對目標文件進行處理,因此解決了“跨編譯單元源文件分析和優化問題”,所以source-aware優化可在鏈接時跨編譯單元實現。
- LTO明顯減緩了鏈接過程,但make-j會有所幫助。
- clang LTO (http://llvm.org/docs/LinkTimeOptimization.html)
- gcc LTO (https://gcc.gnu.org/onlinedocs/gccint/LTO-Overview.html)
- 到2016年為止,clang和gcc都可通過在目標文件編譯和最后庫/程序鏈接時,向命令行選項中添加-flto來支持LTO。
- 不過LTO仍需監管。有時,程序中一些代碼沒有被直接使用,而是被附加的庫使用了。因為LTO會在全局性鏈接時檢測出沒有使用或者不可訪問的代碼,也可能將其刪除,所以這些代碼將不會包含到最后的鏈接結果中。
架構
-march = native
- 允許編譯器使用CPU完整的特性集。
- 再一次,性能測試和回歸測試都非常重要(之后在多個編譯器以及多個版本中對結果進行比較)以確保任何啟用的優化都不會產生副作用。
如果你使用not-your-build-machine特性,-msse2和-msse4.2可能起到作用。
編寫代碼
類型
如果你將char、int、short、long或unsigned類型寫入新代碼中,就把錯了。
對于現代程序,應該先輸入#include,之后再使用標準類型。
更多細節,參見“stdint.h規范”(http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/stdint.h.html)。
常見的標準類型有:
- int8_t,int16_t,int32_t,int64_t——符號數
- uint8_t,uint16_t,uint32_t,uint64_t——無符號數
- float——標準32位浮點數
- double——標準64位浮點數
到底用不用int
一些讀者說他們真的非常喜愛int類型,但我們不得不把它從他們僵硬的手指中撬出來。在技術層面我想指出的是,如果在你的控制下類型的位數發生了改變,這時還希望程序正確運行是不可能的。
當然你也可以找出inttypes.h文件中的RATIONALE(http://pubs.opengroup.org/onlinepubs/9699919799/basedefs/inttypes.h.html#tag_13_19_06)來看看為何使用長度不固定的類型是不安全的。
如果你夠聰明到可以在平臺上對整個開發過程中的int類型統一定為16或32位,并且也在每個使用了它的地方測試所有16位和32位邊界情況,那就可以隨意使用int類型了。
對于我們中不能在編程時將多層決策樹規范層次清晰地記住的人,可以使用固定位數的類型,這樣在編程時就可以不必憂心那些麻煩的概念和測試開銷。
或者,規范中更加簡潔地指出:“ISO C語言標準中整數擴展規則將會造成意外改變。”
所以當使用int時,祝你好運!
使用char的例外情況
到2016年,char類型唯一的用處就是舊API中需要(比如strncat,printf中)。或者初始化一個只讀字符串(比如const char * hello = “hello”;),這是因為C語言字符串類型字面值(”hello”)是char []。
同樣,在C11中,我們也有原生的unicode支持,并且UTF-8的字符串類型仍然是char [],甚至是像const char * abcgrr =u8”abc “; 這樣的多字節序列也一樣。
使用int、long等的例外情況
如果你正在使用帶有原生返回類型或原生參數的函數,那么請使用函數原型或者API規范中描述的類型。
符號
無論何時都不應該將unsigned這個詞寫進代碼。丑陋的C語言規定往往會因多字類型而損壞了程序可讀性和使用性,而現在我們編寫的代碼不再需要遵守那些規定了。當能夠輸入uint64_t時又有誰想用unsigned long long int呢?中的類型更加明確,含義更加確切,也能夠更好地表達意義,并且具有更好的排版,提高了程序的使用性和可讀性。
指針作為整型
但你可能會說“當遇到麻煩的指針數學時,我不得不將指針轉換為long!”。這是錯誤的。
正確的指針數學類型是使用中定義的unitptr_t,當然同樣非常有用的ptrdiff_t也定義在stddef.h中。
而并非:
long diff = (long)ptrOld - (long)ptrNew;你可以使用:
ptrdiff_t diff = (uintptr_t)ptrOld - (uintptr_t)ptrNew;也可以用:
printf("%p is unaligned by %" PRIuPTR " bytes.\n", (void *)p, ((uintptr_t)somePtr & (sizeof(void *) - 1)));系統依賴的類型
你繼續爭辯:“在32位平臺上我想要32位字長,而在64位平臺上我要64位字長。”
這時思路將故意引入使用平臺依賴的兩種不同字長,會對編程造成困難。但是如果跳過這個問題,你仍然不想使用依賴系統的類型。
在此情況下,該使用intptr_t——當前平臺定義為字長的整型。在32位平臺上,intptr_t = int32_t;64位平臺上,intptr_t = int64_t。intptr_t也有uintptr_t的形式。
對管理指針的偏移來說,我們還有ptrdiff_t,該類型適于存儲指針減少的數值。
最大值容器
你需要一種能承載系統中所有可能的整數的類型嗎?在這種情況下,人們傾向于使用已知的最大類型,比如將較小的無符號類型轉換成uint64_t。從實際技術上講,有一種更正確的方式來保證可以裝載任何值。
對于所有整數來說,最安全的容器是intmax_t(或者uintmax_t)。可以無精度損失地將任何符號整數(或無符號整數)賦值或者轉換為這種形式。
其他類型
被廣泛使用的系統依賴類型是stdded.h中提供的size_t。它在程序中非常基礎,因為它是“能夠容納最大的數組索引的整數”,這也意味著它能容納最大的內存偏移。在實際應用中,size_t是運算符sizeof的返回類型。
size_t實際上與uintptr_t一樣都是定義在所有現代平臺上的。所以在32位的平臺上,size_t = uint32_t,而在64位平臺上,size_t = uint64_t。
還有一個帶有符號的size_t,也就是ssize_t,它用來表示在庫函數出現錯誤時返回-1(注意,sszie_t是POSIX,不適用于Windows接口)。
所以,難道你不該在自己的函數參數中使用size_t來適應任何字長的系統么?從技術上講,它是sizeof的返回值,所以任何接受多字節數值的函數都允許表示成size_t類型。
其他用途包括:size_t類型可作為malloc的參數,并且是read()和write()的返回類型(除了Windows平臺上ssize_t不存在,其返回值是int類型)。
輸出類型
你不應在輸出時進行類型轉換,而是永遠使用inttypes.h中定義的合適的類型說明符。
包括但不限于以下情況:
- size_t——%zu
- ssize_t——%zd
- ptrdiff_t——%td
- 原始指針值%p(現代編譯器中輸出16進制數; 0 首先將指針轉換為(void *))
- int64_t——”%” PRId64
- uint64_t——”%” PRIu64(64位類型應該只能使0 用PRI[udixXo]64風格的宏)
- intptr_t——”%” PRIdPTR
- uintptr_t——”%” PRIuPTR
- intmax_t——”%” PRIdMAX
- uintmax_t——”%” PRIuMAX
關于PRI* 格式化說明符需要注意:它們都是宏和宏在特定的平臺基礎上為了合適的輸出而做的擴展。這意味著你不能這樣:
printf("Local number: %PRIdPTR\n\n", someIntPtr);并且因為它們是宏,應該:
printf("Local number: %" PRIdPTR "\n\n", someIntPtr);注意應將%放進格式化字符串內,但類型指示符在格式化字符串外部,這是因為所有相鄰的字符串都被預處理器連接成為最后的結合字符串。
C99允許在任何地方進行變量聲明
所以,千萬不要這樣做:
void test(uint8_t input) {uint32_t b;if (input > 3) {return;}b = input; }而是這樣:
void test(uint8_t input) {if (input > 3) {return;}uint32_t b = input; }警告:如果存在緊密的循環,請測試初始值的配置。有時分散的聲明會造成意外減速。對于沒有選擇最快路徑的代碼(事情往往這樣),最好還是盡可能清晰。而且,與初始化一起定義類型會極大提升可讀性。
C99允許在 for 循環中聲明內聯計數器
所以不要這樣:
uint32_t i; for (i = 0; i < 10; i++)而應該:
for (uint32_t i = 0; i < 10; i++)一個例外:如果你需要在循環結束后仍保持計數器的值,那當然不要在循環域內聲明計數器。
現代編譯器支持 #pragma once
所以不要:
#ifndef PROJECT_HEADERNAME #define PROJECT_HEADERNAME . . . #endif /* PROJECT_HEADERNAME */而應該:
#pragma once#pragma once告訴編譯器只包含頭文件一次,且不再需要那三行頭部約束了。所有編譯器和平臺都支持并且推薦這樣的編譯方式,而不建議使用手動編寫頭部約束。更多細節,參見pragma once中編譯器支持的列表(https://en.wikipedia.org/wiki/Pragma_once)。
C語言允許對自動分配的數組進行靜態初始化
所以,不要:
uint32_t numbers[64]; memset(numbers, 0, sizeof(numbers));而應該:
uint32_t numbers[64] = {0};C語言允許對自動分配的結構體進行靜態初始化
不要:
struct thing {uint64_t index;uint32_t counter; }; struct thing localThing; void initThing(void) {memset(&localThing, 0, sizeof(localThing)); }而應該:
struct thing {uint64_t index;uint32_t counter; }; struct thing localThing = {0};重要提示:如果結構體存在填充,{0}方法不會將額外的字節歸零。例如結構體struct thing在counter(64位平臺上)后需要填充4字節,并且結構體以字長度進行增量填充。如果需要將包含沒有使用的填充在內的整個結構體置0,即使可尋址內容只有8+4=12字節,也需要使用memset(&localThing, 0, sizeof(localThing)) ,并且其中sizeof(localThing) == 16 字節。
如果需要重新初始化已存在并且完成了分配的結構體,那么就為后面的任務聲明一個全0的結構體:
struct thing {uint64_t index;uint32_t counter; }; static const struct thing localThingNull = {0}; ... struct thing localThing = {.counter = 3}; ... localThing = localThingNull;如果你足夠幸運是在C99(或者更新)的開發環境中,就可以使用復合文字而不是全0結構體,參見The New C: Compound Literals(http://www.drdobbs.com/the-new-c-compound-literals/184401404)。復合文字允許編譯器自動創建臨時匿名結構體,并將其值拷貝到目標中:
localThing = (struct thing){0};C99添加可變長數組(C11可選)
因此不要這樣:
uintmax_t arrayLength = strtoumax(argv[1], NULL, 10); void *array[]; array = malloc(sizeof(*array) * arrayLength); /* remember to free(array) when you're done using it */而該:
uintmax_t arrayLength = strtoumax(argv[1], NULL, 10); void *array[arrayLength]; /* no need to free array */重要警告:通常在棧區分配空間時,變長數組和普通數組一樣。如果不想靜態地創建一個含300萬個元素的普通數組,那就不要試圖在運行時使用這種語法來創建如此巨大的數組。這可不是靈活的Python、Ruby中自動增長的列表。如果運行時指定了一個數組的長度,而這個長度對于棧區而言過大的話,程序將會出現可怕的事情(崩潰、安全問題)。對于簡單的,用途單一的情況,變長數組非常方便,但它不能支撐大規模軟件開發。如果你需要3個元素的數組,而有時又需要300萬個,那么千萬不要使用變長數組。
了解變長數組的語法的確不錯,因為沒準你就會遇到(或者想來一次快速測試)。但有可能因為忘記邊界檢查,或忘記了目標平臺上沒有足夠的棧空間而使程序徹底崩潰,這是非常危險的反模式語法。
注意:必須確定數長度是合理的值。(比如小于幾KB,有時平臺上的棧最大空間為4KB)。你不能在棧中分配巨大的數組(數百萬條項目),但如果數組長度是有限的,使用C99的變長數組功能比通過malloc手動請求堆內存要簡單得多。
再次注意:如果沒有對用戶的輸入進行檢查,那么用戶就能夠通過分配一個巨大的變長數組輕松地殺死程序。一些人指出變長數組與模式相抵觸,但是如果嚴格檢查邊界,在某些情況下,或許會表現得更好。
C99允許注釋非重疊的指針參數
參看限制關鍵字 (https://en.wikipedia.org/wiki/Restrict,通常是 _restrict)
參數類型
如果函數能接受任意輸入數據和任意長度的程序,那就不要限制參數的類型。
所以,不要這樣:
void processAddBytesOverflow(uint8_t *bytes, uint32_t len) {for (uint32_t i = 0; i < len; i++) {bytes[0] += bytes[i];} }而應該:
void processAddBytesOverflow(void *input, uint32_t len) {uint8_t *bytes = input;for (uint32_t i = 0; i < len; i++) {bytes[0] += bytes[i];} }函數的輸入類型只是描述了代碼的接口,而不是帶有參數的代碼在做什么。上述代碼接口意思是“接受一個字節數組和一個長度”,所以不并想限定函數調用者只能使用uint_8字節流。也許用戶甚至想將一個老式的char*值或者其他什么意想不到的東西傳遞給你的函數。
通過聲明輸入類型為void*然后重新指定或轉換成為實際想在函數中使用的類型,這樣就將用戶從費力思考你自己的函數庫文件的內部抽象中拯救出來。
一些讀者指出這個例子中的對齊問題,但我們訪問的是輸入的單字節元素,所以一切都沒問題。如果不是這樣的話,我們就是在將輸入轉換為更大的類型,這時候就需要注意對齊問題了。對于處理跨平臺對齊的問題的不同寫操作,參看Unaligned Memory Access部分章節(https://www.kernel.org/doc/Documentation/unaligned-memory-access.txt,記住:這頁概述細節不是關于跨體系結構的C,所以擴展的知識和經驗完全適用于任何示例。)
返回參數類型
C99給我們了將true定義為1,false定義為0的的權利。對于成功或失敗的返回值,函數應該返回true或false,而不是手動指定1或0的int32_t返回類型(或者更糟糕1和-1;又或者0代表成功,1代表失敗?或0帶代表成功,-1代表失敗)。
如果函數將輸入參數類型的范圍變得更大的操作無效,那么在任何參數可能無效的地方,所有API都應強制使用雙指針作為參數,而不應返回改變的指針。對于大規模應用,那樣“對于某些調用,返回值使得輸入無效”的程序太容易出錯。
所以,不要這樣:
void *growthOptional(void *grow, size_t currentLen, size_t newLen) {if (newLen > currentLen) {void *newGrow = realloc(grow, newLen);if (newGrow) {/* resize success */grow = newGrow;} else {/* resize failed, free existing and signal failure through NULL */free(grow);grow = NULL;}}return grow; }而應該這樣:
/* Return value:* - 'true' if newLen > currentLen and attempted to grow* - 'true' does not signify success here, the success is still in '*_grow'* - 'false' if newLen <= currentLen */ bool growthOptional(void **_grow, size_t currentLen, size_t newLen) {void *grow = *_grow;if (newLen > currentLen) {void *newGrow = realloc(grow, newLen);if (newGrow) {/* resize success */*_grow = newGrow;return true;}/* resize failure */free(grow);*_grow = NULL;/* for this function,* 'true' doesn't mean success, it means 'attempted grow' */return true;}return false; }如果這樣的話就更好了:
typedef enum growthResult {GROWTH_RESULT_SUCCESS = 1,GROWTH_RESULT_FAILURE_GROW_NOT_NECESSARY,GROWTH_RESULT_FAILURE_ALLOCATION_FAILED } growthResult;growthResult growthOptional(void **_grow, size_t currentLen, size_t newLen) {void *grow = *_grow;if (newLen > currentLen) {void *newGrow = realloc(grow, newLen);if (newGrow) {/* resize success */*_grow = newGrow;return GROWTH_RESULT_SUCCESS;}/* resize failure, don't remove data because we can signal error */return GROWTH_RESULT_FAILURE_ALLOCATION_FAILED;}return GROWTH_RESULT_FAILURE_GROW_NOT_NECESSARY; }格式化
編碼風格非常重要,但同時也沒任何價值。如果項目有50頁編碼風格指南,沒人會幫你。但如果你的代碼沒有可讀性,也沒人會幫你。解決方案就是永遠使用自動的編碼格式化。
2016年,唯一可用的C格式化程序是clang-format。它擁有最好的默認C語言格式并且仍在持續改進。
這是我運行clang-format的腳本:
#!/usr/bin/env bash clang-format -style="{BasedOnStyle:llvm,IndentWidth:4,AllowShortFunctionsOnASingleLine: None, KeepEmptyLinesAtTheStartOfBlocks: false}" "$@"然后調用(假設你稱之為cleanup-format):
matt@foo:?/repos/badcode% cleanup-format -i *.{c,h,cc,cpp,hpp,cxx}選項-i通過覆蓋已有的文件進行格式化,而不是寫入新文件或者創建備份文件。如果有很多文件,可以采取并行方式遞歸處理整個源文件樹。
#!/usr/bin/env bash# note: clang-tidy only accepts one file at a time, but we can run it # parallel against disjoint collections at once. find . \( -name \*.c -or -name \*.cpp -or -name \*.cc \) |xargs -n1 -P4 cleanup-tidy# clang-format accepts multiple files during one run, but let's limit it to 12 # here so we (hopefully) avoid excessive memory usage. find . \( -name \*.c -or -name \*.cpp -or -name \*.cc -or -name \*.h \) |xargs -n12 -P4 cleanup-format -i如今新cleanup-tidy腳本出現了,其內容是:
#!/usr/bin/env bash clang-tidy \-fix \-fix-errors \-header-filter=.* \--checks=readability-braces-around-statements,misc-macro-parentheses \$1 \ -- -I.- readability-braces-around-statements——要求所有if/while/for的申明語句全部包含在大括號內。C語言允許在循環和條件語句后面的單條語句的“大括號可選”存在歷史原因。但在編寫現代代碼時不使用大括號來包含循環和條件將是不可原諒的。也許你會說“但是編譯器承認它”并且這樣不會影響代碼的可讀性、可維護性、可理解性和適用性。但你不是為了取悅編譯器而編程,編程是為了取悅未來數年后不能了解你當時思維的人。
- misc-macro-parentheses——自動添加宏中使用的所有參數的父類。
clang-tidy運行時非常棒,但卻會因為某些復雜的代碼卡住。clang-tidy不進行格式化,所以需要在排列整齊大括號和宏之后再運行clang-format。
其他想法
永遠不使用malloc,而應該用calloc,當得到為0的內存時將不會有性能損失。如果你不喜歡函數原型calloc(object count, size per object),可以用#define mycalloc(N) calloc(1, N) 將其封裝起來。
能不用memset就不用
當能靜態初始化結構體或數組為0時,不要用memset(ptr, 0, len)。不過,如果要對包括填充字節在內的整個結構體進行置0時,memset()是你唯一的選擇。
寫在最后
對于編寫大規模代碼來說,想要不出現任何錯誤是不可能的。就算是我們不必擔心那些像RAM中比特位隨機翻轉的問題或者設備以未知的幾率出現錯誤的可能性,也會有多種多樣的操作系統、運行狀態、庫文件以及硬件平臺的問題需要擔心。
所以我們能做的最好的事情就是——編寫簡單又易于理解的代碼。
原作者在文章結尾列出了對本文提出意見和建議的讀者,以及深入了解這些技術細節的在線文檔,受篇幅所限,譯文不再贅述。
http://geek.csdn.net/news/detail/63135
文/Matt Stancliff 譯/賈子甲?
感謝Matt Stancliff授權《程序員》翻譯,原文鏈接https://matt.sh/howto-c。?
本文未經允許不得轉載,訂閱2016年程序員請點擊?http://dingyue.programmer.com.cn。
總結
以上是生活随笔為你收集整理的2016年,C语言该怎样写的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Google谷歌首席科学家:神经网络的奇
- 下一篇: 颜色矩