規則並不是完美的,通過禁止在特定情況下有用的特性,可能會對代碼實現造成影響。但是我們制定規則的目的「為了大多數程式設計師可以得到更多的好處」, 如果在團隊運作中認為某個規則無法遵循,希望可以共同改進該規則。參考該規範之前,希望您具有相應的C語言基礎能力,而不是通過該文檔來學習C語言。
了解C語言的ISO標準;
熟知C語言的基本語言特性;
了解C語言的標準庫;
總體原則代碼需要在保證功能正確的前提下,滿足可讀、可維護、安全、可靠、可測試、高效、可移植的特徵要求。
約定規則:編程時必須遵守的約定
建議:編程時必須加以考慮的約定
無論是「規則」還是「建議」,都必須理解該條目這麼規定的原因,並努力遵守。
例外在不違背總體原則,經過充分考慮,有充足的理由的前提下,可以適當違背規範中約定。
例外破壞了代碼的一致性,請儘量避免。「規則」的例外應該是極少的。
下列情況,應風格一致性原則優先:
修改外部開原始碼、第三方代碼時,應該遵守開原始碼、第三方代碼已有規範,保持風格統一。
命名包括文件、函數、變量、類型、宏等命名。
命名被認為是軟體開發過程中最困難,也是最重要的事情。
標識符的命名要清晰、明了,有明確含義,符合閱讀習慣,容易理解。
統一的命名風格是一致性原則最直接的體現。
總體風格駝峰風格(CamelCase)
大小寫字母混用,單詞連在一起,不同單詞間通過單詞首字母大寫來分開。
按連接後的首字母是否大寫,又分: 大駝峰(UpperCamelCase)和小駝峰(lowerCamelCase)
類型
命名風格
函數,結構體類型,枚舉類型,聯合體類型大駝峰變量,函數參數,宏參數,結構體中欄位,聯合體中成員小駝峰宏,常量,枚舉值,goto 標籤全大寫,下劃線分割注意:
上表中常量是指,全局作用域下,const 修飾的基本數據類型、枚舉、字符串類型的變量,不包括數組、結構體和聯合體。
上表中變量是指除常量定義以外的其他變量,均使用小駝峰風格。
C 與 C++ 不同,沒有名字空間,沒有類,所以全局作用域下的標識符命名要考慮不要衝突。
對於全局函數、全局變量、宏、類型名、枚舉名的命名,應當精確描述並全局唯一。
例:
int GetCount(void); // Bad: 描述不精確
int GetActiveConnectCount(void); // Good
為了命名更精確,必要時可以增加模塊前綴。
模塊前綴與命名主體之間,按駝峰方式連接。
示例:
int PrefixFuncName(void); // OK: 駝峰方式,形式上無前綴,內容上有前綴
enum XxxMyEnum { // OK.
...
};
文件名命名只允許使用小寫字母、數字以及下劃線(_)。
文件名應儘量簡短、準確、無二義性。
不大小寫混用的原因是,不同系統對文件名大小寫處理會不同(如 MicroSoft 的 DOS, Windows 系統不區分大小寫,但是 Unix / Linux, Mac 系統則默認區分)。
好的命名舉例:
dhcp_user_log.c
壞的命名舉例:
dhcp_user-log.c: 不推薦用'-'分隔
dhcpuserlog.c: 未分割單詞,可讀性差
函數命名統一使用大駝峰風格。
建議1.3 函數的命名遵循閱讀習慣動作類函數名,可以使用動賓結構。如:
AddTableEntry() // OK
DeleteUser() // OK
GetUserInfo() // OK
判斷型函數,可以用形容詞,或加 is:
DataReady() // OK
IsRunning() // OK
JobDone() // OK
數據型函數:
TotalCount() // OK
GetTotalCount() // OK
變量命名使用小駝峰風格,包括全局變量,局部變量,函數聲明或定義中的參數,帶括號宏中的參數。
規則1.2 全局變量應增加 'g_' 前綴,函數內靜態變量命名不需要加特殊前綴全局變量應當儘量少使用,使用時應特別注意,所以加上前綴用於視覺上的突出,促使開發人員對這些變量的使用更加小心。
全局靜態變量命名與全局變量相同,函數內的靜態變量命名與普通局部變量相同。
int g_activeConnectCount;
void Func(void)
{
static int pktCount = 0;
...
}
注意:常量本質也是全局變量,但如果命名風格是全大寫,下劃線連接的格式,則不適用當前規則。
建議1.4 局部變量應該簡短,且能夠表達相關含義函數局部變量的命名,在能夠表達相關含義的前提下,應該簡短。
如下:
int Func(...)
{
enum PowerBoardStatus powerBoardStatusOfSlot; // Not good: 局部變量有點長
powerBoardStatusOfSlot = GetPowerBoardStatus(slot);
if (powerBoardStatusOfSlot == POWER_OFF) {
...
}
...
}
更好的寫法:
int Func(...)
{
enum PowerBoardStatus status; // Good: 結合上下文,status 已經能明確表達意思
status = GetPowerBoardStatus(slot);
if (status == POWER_OFF) {
...
}
...
}
類似的, tmp 可以用來稱呼任意類型的臨時變量。
過短的變量命名應慎用,但有時候,單字符變量也是允許的,如用於循環語句中的計數器變量:
int i;
...
for (i = 0; i < COUNTER_RANGE; i++) {
...
}
或一些簡單的數學計算函數中的變量:
int Mul(int a, int b)
{
return a * b;
}
類型命名採用大駝峰命名風格。
類型包括結構體、聯合體、枚舉類型名。
例:
struct MsgHead {
enum MsgType type;
int msgLen;
char *msgBuf;
};
union Packet {
struct SendPacket send;
struct RecvPacket recv;
};
enum BaseColor {
RED, // 注意,枚舉類型是大駝峰,枚舉值應使用宏風格
GREEN,
BLUE
};
typedef int (*NodeCmpFunc)(struct Node *a, struct Node *b);
通過 typedef 對結構體、聯合體、枚舉起別名時,儘量使用匿名類型。
若需要指針自嵌套,可以增加 'tag' 前綴或下劃線後綴。
typedef struct { // Good: 無須自嵌套,使用匿名結構體
int a;
int b;
} MyType; // 結構體別名用大駝峰風格
typedef struct tagNode { // Good: 使用 tag 前綴。這裡也可以使用 'Node_'代替也可以。
struct tagNode *prev;
struct tagNode *next;
} Node; // 類型主體用大駝峰風格
宏、枚舉值採用全大寫,下劃線連接的格式。
常量推薦採用全大寫,下劃線連接風格。作為全局變量,也可以保持與普通全局變量命名風格相同。
這裡常量如前文定義,是指基本數據類型、枚舉、字符串類型的全局 const 變量。
函數式宏,如果功能上可以替代函數,也可以與函數的命名方式相同,使用大駝峰命名風格。
這種做法會讓宏與函數看起來一樣,容易混淆,需要特別注意。
宏舉例:
#define PI 3.14
#define MAX(a, b) (((a) < (b)) ? (b) : (a))
#ifdef SOME_DEFINE
void Bar(int);
#define Foo(a) Bar(a) // 特殊場景,用大駝峰風格命名函數式宏
#else
void Foo(int);
#endif
常量舉例:
const int VERSION = 200; // OK.
const enum Color DEFAULT_COLOR = BLUE; // OK.
const char PATH_SEP = '/'; // OK.
const char * const GREETINGS = "Hello, World!"; // OK.
非常量舉例:
// 結構體類型,不符合常量定義
const struct MyType g_myData = { ... }; // OK: 用小駝峰
// 數組類型,不符合常量定義
const int g_xxxBaseValue[4] = { 1, 2, 4, 8 }; // OK: 用小駝峰
int Foo(...)
{
// 局部作用域,不符合常量定義
const int bufSize = 100; // OK: 用小駝峰
...
}
枚舉舉例:
// 注意,枚舉類型名用大駝峰,其下面的取值是全大寫,下劃線相連
enum BaseColor {
RED,
GREEN,
BLUE
};
首先,儘量少的使用函數式宏。
當函數式宏需要定義局部變量時,為了防止跟外部函數中的局部變量有命名衝突。
後置下劃線,是一種解決方案。例:
#define SWAP_INT(a, b) do { \
int tmp_ = a; \
a = b; \
b = tmp_; \
} while (0)
代碼行寬不宜過長,否則不利於閱讀。
控制行寬長度可以間接的引導開發去縮短函數、變量的命名,減少嵌套的層數,提升代碼可讀性。
強烈建議和要求每行字符數不要超過 120 個;除非超過 120 能顯著增加可讀性,並且不會隱藏信息。
雖然現代顯示器解析度已經很高,但是行寬過長,反而提高了閱讀理解的難度;跟本規範提倡的「清晰」、「簡潔」原則相背。
如下場景不宜換行,可以例外:
例:
#ifndef XXX_YYY_ZZZ
#error Header aaaa/bbbb/cccc/abc.h must only be included after xxxx/yyyy/zzzz/xyz.h
#endif
只允許使用空格(space)進行縮進,每次縮進為 4 個空格。不允許使用Tab鍵進行縮進。
當前幾乎所有的集成開發環境(IDE)和代碼編輯器都支持配置將Tab鍵自動擴展為4空格輸入,請配置你的代碼編輯器支持使用空格進行縮進。
K&R風格
換行時,函數左大括號另起一行放行首,並獨佔一行;其他左大括號跟隨語句放行末。
右大括號獨佔一行,除非後面跟著同一語句的剩餘部分,如 do 語句中的 while,或者 if 語句的 else/else if,或者逗號、分號。
如:
struct MyType { // Good: 跟隨語句放行末,前置1空格
...
}; // Good: 右大括號後面緊跟分號
int Foo(int a)
{ // Good: 函數左大括號獨佔一行,放行首
if (...) {
...
} else { // Good: 右大括號與 else 語句在同一行
...
} // Good: 右大括號獨佔一行
}
在聲明和定義函數的時候,函數的返回值類型應該和函數名在同一行。
函數參數列表換行時,應合理對齊。
參數列表的左圓括號總是和函數名在同一行,不要單獨一行;右圓括號總是跟隨最後一個參數。
換行舉例:
ReturnType FunctionName(ArgType paramName1, ArgType paramName2) // Good:全在同一行
{
...
}
ReturnType VeryVeryVeryLongFunctionName(ArgType paramName1, // 行寬不滿足所有參數,進行換行
ArgType paramName2, // Good:和上一行參數對齊
ArgType paramName3)
{
...
}
ReturnType LongFunctionName(ArgType paramName1, ArgType paramName2, // 行寬限制,進行換行
ArgType paramName3, ArgType paramName4, ArgType paramName5) // Good: 換行後 4 空格縮進
{
...
}
ReturnType ReallyReallyReallyReallyLongFunctionName( // 行寬不滿足第1個參數,直接換行
ArgType paramName1, ArgType paramName2, ArgType paramName3) // Good: 換行後 4 空格縮進
{
...
}
函數調用時,函數參數列表如果換行,應該進行合理的參數對齊。
左圓括號總是跟函數名,右圓括號總是跟最後一個參數。
換行舉例:
ReturnType result = FunctionName(paramName1, paramName2); // Good:函數參數放在一行
ReturnType result = FunctionName(paramName1,
paramName2, // Good:保持與上方參數對齊
paramName3);
ReturnType result = FunctionName(paramName1, paramName2,
paramName3, paramName4, paramName5); // Good:參數換行,4 空格縮進
ReturnType result = VeryVeryVeryLongFunctionName( // 行寬不滿足第1個參數,直接換行
paramName1, paramName2, paramName3); // 換行後,4 空格縮進
如果函數調用的參數存在內在關聯性,按照可理解性優先于格式排版要求,對參數進行合理分組換行。
// Good:每行的參數代表一組相關性較強的數據結構,放在一行便於理解
int result = DealWithStructureLikeParams(left.x, left.y, // 表示一組相關參數
right.x, right.y); // 表示另外一組相關參數
我們要求條件語句都需要使用大括號,即便只有一條語句。
理由:
if (objectIsNotExist) { // Good:單行條件語句也加大括號
return CreateNewObject();
}
條件語句中,若有多個分支,應該寫在不同行。
如下是正確的寫法:
if (someConditions) {
...
} else { // Good: else 與 if 在不同行
...
}
下面是不符合規範的案例:
if (someConditions) { ... } else { ... } // Bad: else 與 if 在同一行
循環規則2.7 循環語句必須使用大括號和條件表達式類似,我們要求for/while循環語句必須加上大括號,即便循環體是空的,或循環語句只有一條。
for (int i = 0; i < someRange; i++) { // Good: 使用了大括號
DoSomething();
}
while (condition) { } // Good:循環體是空,使用大括號
while (condition) {
continue; // Good:continue 表示空邏輯,使用大括號
}
壞的例子:
for (int i = 0; i < someRange; i++)
DoSomething(); // Bad: 應該加上括號
while (condition); // Bad:使用分號容易讓人誤解是while語句中的一部分
switch語句規則2.8 switch 語句的 case/default 要縮進一層switch 語句的縮進風格如下:
switch (var) {
case 0: // Good: 縮進
DoSomething1(); // Good: 縮進
break;
case 1: { // Good: 帶大括號格式
DoSomething2();
break;
}
default:
break;
}
switch (var) {
case 0: // Bad: case 未縮進
DoSomething();
break;
default: // Bad: default 未縮進
break;
}
較長的表達式,不滿足行寬要求的時候,需要在適當的地方換行。一般在較低優先級操作符或連接符後面截斷,操作符或連接符放在行末。
操作符、連接符放在行末,表示「未結束,後續還有」。
例:
// 假設下面第一行已經不滿足行寬要求
if ((currentValue > MIN) && // Good:換行後,布爾操作符放在行末
(currentValue < MAX)) {
DoSomething();
...
}
int result = reallyReallyLongVariableName1 + // Good: 加號留在行末
reallyReallyLongVariableName2;
表達式換行後,注意保持合理對齊,或者4空格縮進。參考下面例子
int sum = longVaribleName1 + longVaribleName2 + longVaribleName3 +
longVaribleName4 + longVaribleName5 + longVaribleName6; // OK: 4空格縮進
int sum = longVaribleName1 + longVaribleName2 + longVaribleName3 +
longVaribleName4 + longVaribleName5 + longVaribleName6; // OK: 保持對齊
每行最好只有一個變量初始化的語句,更容易閱讀和理解。
int maxCount = 10;
bool isCompleted = false;
下面是不符合規範的示例:
int maxCount = 10; bool isCompleted = false; // Bad:多個初始化放在了同一行
int x, y = 0; // Bad:多個變量定義需要分行,每行一個
int pointX;
int pointY;
...
pointX = 1; pointY = 2; // Bad:多個變量賦值語句放同一行
例外情況:
對於多個相關性強的變量定義,且無需初始化時,可以定義在一行,減少重複信息,以便代碼更加緊湊。
int i, j; // Good:多變量定義,未初始化,可以寫在一行
for (i = 0; i < row; i++) {
for (j = 0; j < col; j++) {
...
}
}
初始化包括結構體、聯合體及數組的初始化
規則2.10 初始化換行時要有縮進,或進行合理對齊結構體或數組初始化時,如果換行應保持4空格縮進。
從可讀性角度出發,選擇換行點和對齊位置。
// Good: 滿足行寬要求時不換行
int arr[4] = { 1, 2, 3, 4 };
// Good: 行寬較長時,換行讓可讀性更好
const int rank[] = {
16, 16, 16, 16, 32, 32, 32, 32,
64, 64, 64, 64, 32, 32, 32, 32
};
對於複雜結構數據的初始化,儘量清晰、緊湊。
參考如下格式:
int a[][4] = {
{ 1, 2, 3, 4 }, { 2, 2, 3, 4 }, // OK.
{ 3, 2, 3, 4 }, { 4, 2, 3, 4 }
};
int b[][8] = {
{ 1, 2, 3, 4, 5, 6, 7, 8 }, // OK.
{ 2, 2, 3, 4, 5, 6, 7, 8 }
};
int c[][8] = {
{
1, 2, 3, 4, 5, 6, 7, 8 // OK.
}, {
2, 2, 3, 4, 5, 6, 7, 8
}
};
注意:
左大括號放行末時,對應的右大括號需另起一行
左大括號被內容跟隨時,對應的右大括號也應跟隨內容
規則2.11 結構體和聯合體在按成員初始化時,每個成員初始化單獨一行C99標準支持結構體和聯合體按照成員進行初始化,標準中叫"指定初始化"(designated initializer)。如果按照這種方式進行初始化,每個成員的初始化單獨一行。
struct Date {
int year;
int month;
int day;
};
struct Date date = { // Good:使用指定初始化方式時,每行初始化一個
.year = 2000,
.month = 1,
.day = 1
};
聲明或定義指針變量或者返回指針類型函數時,"*" 靠左靠右都可以,但是不要兩邊都有或者都沒有空格。
int *p1; // OK.
int* p2; // OK.
int*p3; // Bad: 兩邊都沒空格
int * p4; // Bad: 兩邊都有空格
選擇一種風格,並保持一致性。
選擇"*"跟隨類型風格時,避免一行同時聲明帶指針的多個變量。
int* a, b; // Bad: 很容易將 b 誤理解成指針
選擇"*"跟隨變量風格時,可能會存在無法緊跟的情況。
無法跟隨時就不跟隨,不要破壞風格一致性。
char * const VERSION = "V100"; // OK.
int Foo(const char * restrict p); // OK.
注意,任何時候 "*" 不要緊跟 const 或 restrict 關鍵字。
編譯預處理規則2.12 編譯預處理的"#"默認放在行首,嵌套編譯預處理語句時,"#"可以進行縮進編譯預處理的"#"統一放在行首;即便編譯預處理的代碼是嵌入在函數體中的,"#"也應該放在行首。
空格和空行規則2.13 水平空格應該突出關鍵字和重要信息,避免不必要的留白水平空格應該突出關鍵字和重要信息,每行代碼尾部不要加空格。總體規則如下:
if, switch, case, do, while, for 等關鍵字之後加空格;
小括號內部的兩側,不要加空格
二元操作符(= + ‐ < > * / % | & ^ <= >= == !=)左右兩側加空格
一元操作符(& * + ‐ ~ !)之後不要加空格
三目操作符(? :)符號兩側均需要空格
結構體中表示位域的冒號,兩側均需要空格
前置和後置的自增、自減(++ --)和變量之間不加空格
結構體成員操作符(. ->)前後不加空格
大括號內部兩側有無空格,左右必須保持一致
逗號、分號、冒號(不含三目操作符和表示位域的冒號)緊跟前面內容無空格,其後需要空格
函數參數列表的小括號與函數名之間無空格
類型強制轉換的小括號與被轉換對象之間無空格
數組的中括號與數組名之間無空格
涉及到換行時,行末的空格可以省去
對於大括號內部兩側的空格,建議如下:
一般的,大括號內部兩側建議加空格
對於空的,或單個標識符,或單個字面常量,空格不是必須 如:'{}', '{0}', '{NULL}', '{"hi"}' 等
連續嵌套的多重括號之間,空格不是必須 如:'{{0}}', '{{ 1, 2 }}' 等 錯誤示例:'{ 0, {1}}',不屬於連續嵌套場景,而且最外側大括號左右不一致
常規情況:
int i = 0; // Good:變量初始化時,= 前後應該有空格,分號前面不要留空格
int buf[BUF_SIZE] = {0}; // Good:數組初始化時,大括號內空格可選
int arr[] = { 10, 20 }; // Good: 正常大括號內部兩側建議加空格
函數定義和函數調用:
int result = Foo(arg1,arg2);
^ // Bad: 逗號後面應該有空格
int result = Foo( arg1, arg2 );
^ ^ // Bad: 小括號內部兩側不應該有空格
指針和取地址
x = *p; // Good:*操作符和指針p之間不加空格
p = &x; // Good:&操作符和變量x之間不加空格
x = r.y; // Good:通過.訪問成員變量時不加空格
x = r->y; // Good:通過->訪問成員變量時不加空格
操作符:
x = 0; // Good:賦值操作的=前後都要加空格
x = -5; // Good:負數的符號和數值之前不要加空格
++x; // Good:前置和後置的++/--和變量之間不要加空格
x--;
if (x && !y) // Good:布爾操作符前後要加上空格,!操作和變量之間不要空格
v = w * x + y / z; // Good:二元操作符前後要加空格
v = w * (x + z); // Good:括號內的表達式前後不需要加空格
循環和條件語句:
if (condition) { // Good:if關鍵字和括號之間加空格,括號內條件語句前後不加空格
...
} else { // Good:else關鍵字和大括號之間加空格
...
}
while (condition) {} // Good:while關鍵字和括號之間加空格,括號內條件語句前後不加空格
for (int i = 0; i < someRange; ++i) { // Good:for關鍵字和括號之間加空格,分號之後加空格
...
}
switch (var) { // Good: switch 關鍵字後面有1空格
case 0: // Good:case語句條件和冒號之間不加空格
...
break;
...
default:
...
break;
}
注意:當前的集成開發環境(IDE)和代碼編輯器都可以設置刪除行尾的空格,請正確配置你的編輯器。
建議2.4 合理安排空行,保持代碼緊湊減少不必要的空行,可以顯示更多的代碼,方便代碼閱讀。下面有一些建議遵守的規則:
ret = DoSomething();
if (ret != OK) { // Bad: 返回值判斷應該緊跟函數調用
return -1;
}
int Foo(void)
{
...
}
int Bar(void) // Bad:最多使用連續2個空行。
{
...
}
int Foo(void)
{
DoSomething(); // Bad:大括號內部首尾,不需要空行
...
}
一般的,儘量通過清晰的架構邏輯,好的符號命名來提高代碼可讀性;需要的時候,才輔以注釋說明。
注釋是為了幫助閱讀者快速讀懂代碼,所以要從讀者的角度出發,按需注釋。
注釋內容要簡潔、明了、無二義性,信息全面且不冗餘。
注釋跟代碼一樣重要。
寫注釋時要換位思考,用注釋去表達此時讀者真正需要的信息。在代碼的功能、意圖層次上進行注釋,即注釋解釋代碼難以表達的意圖,不要重複代碼信息。
修改代碼時,也要保證其相關注釋的一致性。只改代碼,不改注釋是一種不文明行為,破壞了代碼與注釋的一致性,讓閱讀者迷惑、費解,甚至誤解。
使用英文進行注釋。
注釋風格在 C 代碼中,使用 /* */和 // 都是可以的。
按注釋的目的和位置,注釋可分為不同的類型,如文件頭注釋、函數頭注釋、代碼注釋等等;
同一類型的注釋應該保持統一的風格。
注意:本文示例代碼中,大量使用 '//' 後置注釋只是為了更精確的描述問題,並不代表這種注釋風格更好。
文件頭注釋規則3.1 文件頭注釋必須包含版權許可/*
Copyright (c) 2020 XXX
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License. */
函數頭注釋規則3.2 禁止空有格式的函數頭注釋並不是所有的函數都需要函數頭注釋;
函數原型無法表達的信息,加函數頭注釋輔助說明;
函數頭注釋統一放在函數聲明或定義上方。
選擇使用如下風格之一:
使用'//'寫函數頭
// 單行函數頭
int Func1(void);
// 多行函數頭
// 第二行
int Func2(void);
使用'/*' '*/' 寫函數頭
/* 單行函數頭 */
int Func1(void);
/*
* 單行或多行函數頭
* 第二行
*/
int Func2(void);
函數儘量通過函數名自注釋,按需寫函數頭注釋。
不要寫無用、信息冗餘的函數頭;不要寫空有格式的函數頭。
函數頭注釋內容可選,但不限於:功能說明、返回值,性能約束、用法、內存約定、算法實現、可重入的要求等等。
模塊對外頭文件中的函數接口聲明,其函數頭注釋,應當將重要、有用的信息表達清楚。
例:
/*
* 返回實際寫入的字節數,-1表示寫入失敗
* 注意,內存 buf 由調用者負責釋放
*/
int WriteString(char *buf, int len);
壞的例子:
/*
* 函數名:WriteString
* 功能:寫入字符串
* 參數:
* 返回值:
*/
int WriteString(char *buf, int len);
上面例子中的問題:
參數、返回值,空有格式沒內容
函數名信息冗餘
關鍵的 buf 由誰釋放沒有說清楚
代碼注釋規則3.3 代碼注釋放於對應代碼的上方或右邊規則3.4 注釋符與注釋內容間要有1空格;右置注釋與前面代碼至少1空格代碼上方的注釋,應該保持對應代碼一樣的縮進。
選擇並統一使用如下風格之一:
使用'//'
// 這是單行注釋
DoSomething();
// 這是多行注釋
// 第二行
DoSomething();
使用'/*' '*/'
/* 這是單行注釋 */
DoSomething();
/*
* 這是單/多行注釋
* 第二行
*/
DoSomething();
代碼右邊的注釋,與代碼之間,至少留1空格,建議不超過4空格。
通常使用擴展後的 TAB 鍵即可實現 1-4 空格的縮進。
選擇並統一使用如下風格之一:
int foo = 100; // 放右邊的注釋
int bar = 200; /* 放右邊的注釋 */
右置格式在適當的時候,上下對齊會更美觀。
對齊後的注釋,離左邊代碼最近的那一行,保證1-4空格的間隔。
例:
#define A_CONST 100 /* 相關的同類注釋,可以考慮上下對齊 */
#define ANOTHER_CONST 200 /* 上下對齊時,與左側代碼保持間隔 */
當右置的注釋超過行寬時,請考慮將注釋置於代碼上方。
規則3.5 不用的代碼段直接刪除,不要注釋掉被注釋掉的代碼,無法被正常維護;當企圖恢復使用這段代碼時,極有可能引入易被忽略的缺陷。
正確的做法是,不需要的代碼直接刪除掉。若再需要時,考慮移植或重寫這段代碼。
這裡說的注釋掉代碼,包括用 /* */ 和 //,還包括 #if 0, #ifdef NEVER_DEFINED 等等。
建議3.1 case語句塊結束時如果不加break/return,需要有注釋說明(fall-through)有時候需要對多個case標籤做相同的事情,case語句在結束不加break或return,直接執行下一個case標籤中的語句,這在C語法中稱之為"fall-through"。
這種情況下,需要在"fall-through"的地方加上注釋,清晰明確的表達出這樣做的意圖;或者至少顯式指明是 "fall-through"。
例,顯式指明 fall-through:
switch (var) {
case 0:
DoSomething();
/* fall-through */
case 1:
DoSomeOtherThing();
...
break;
default:
DoNothing();
break;
}
如果 case 語句是空語句,則可以不用加注釋特別說明:
switch (var) {
case 0:
case 1:
DoSomething();
break;
default:
DoNothing();
break;
}
對於C語言來說,頭文件的設計體現了大部分的系統設計。
正確使用頭文件可使代碼在可讀性、文件大小和編譯構建性能上大為改觀。
本章從編程規範的角度總結了一些方法,可用於幫助合理規劃頭文件。
頭文件職責頭文件是模塊或文件的對外接口。
頭文件中適合放置接口的聲明,不適合放置實現(內聯函數除外)。
頭文件應當職責單一。頭文件過於複雜,依賴過於複雜還是導致編譯時間過長的主要原因。
通常情況下,每個.c文件都有一個相應的.h(並不一定同名),用於放置對外提供的函數聲明、宏定義、類型定義等。
如果一個.c文件不需要對外公布任何接口,則其就不應當存在。
例外:程序的入口(如main函數所在的文件),單元測試代碼,動態庫代碼。
示例:
foo.h 內容
#ifndef FOO_H
#define FOO_H
int Foo(void); // Good:頭文件中聲明對外接口
#endif
foo.c 內容
static void Bar(void); // Good: 對內函數的聲明放在.c文件的頭部,並聲明為static限制其作用域
void Foo(void)
{
Bar();
}
static void Bar(void)
{
// Do something;
}
內部使用的函數聲明,宏、枚舉、結構體等定義不應放在頭文件中。
有些產品中,習慣一個.c文件對應兩個.h文件,一個用於存放對外公開的接口,一個用於存放內部需要用到的定義、聲明等,以控制.c文件的代碼行數。
不提倡這種風格,產生這種風格的根源在於.c過大,應當首先考慮拆分.c文件。
另外,一旦把私有定義、聲明放到獨立的頭文件中,就無法從技術上避免別人包含。
本規則反過來並不一定成立。比如:
有些特別簡單的頭文件,如命令 ID 定義頭文件,不需要有對應的.c存在。
同一套接口協議下,有多個實例,由於接口相同且穩定,所以允許出現一個.h對應多個.c文件。
有些產品中使用了 .inc 作為頭文件擴展名,這不符合C語言的習慣用法。在使用 .inc 作為頭文件擴展名的產品,習慣上用於標識此頭文件為私有頭文件。但是從產品的實際代碼來看,這一條並沒有被遵守,一個 .inc 文件被多個 .c 包含。本規範不提倡將私有定義單獨放在頭文件中,具體見建議4.1。
頭文件依賴頭文件包含是一種依賴關係,頭文件應向穩定的方向包含。
一般來說,應當讓不穩定的模塊依賴穩定的模塊,從而當不穩定的模塊發生變化時,不會影響(編譯)穩定的模塊。
依賴的方向應該是:產品依賴於平臺,平臺依賴於標準庫。
除了不穩定的模塊依賴於穩定的模塊外,更好的方式是每個模塊都依賴於接口,這樣任何一個模塊的內部實現更改都不需要重新編譯另外一個模塊。
在這裡,假設接口本身是最穩定的。
頭文件循環依賴,指 a.h 包含 b.h,b.h 包含 c.h,c.h 包含 a.h, 導致任何一個頭文件修改,都導致所有包含了a.h/b.h/c.h的代碼全部重新編譯一遍。
而如果是單向依賴,如a.h包含b.h,b.h包含c.h,而c.h不包含任何頭文件,則修改a.h不會導致包含了b.h/c.h的原始碼重新編譯。
頭文件循環依賴直接體現了架構設計上的不合理,可通過架構優化來避免。
規則4.2 頭文件必須編寫#define保護,防止重複包含為防止頭文件被多重包含,所有頭文件都應當使用 #define 作為包含保護;不要使用 #pragma once
定義包含保護符時,應該遵守如下規則:
假定 timer 模塊的 timer.h,其目錄為 timer/include/timer.h。其保護符若使用 'TIME_H' 很容易不唯一,所以使用項目原始碼樹的全路徑,如:
#ifndef TIMER_INCLUDE_TIMER_H
#define TIMER_INCLUDE_TIMER_H
...
#endif
只能通過包含頭文件的方式使用其他模塊或文件提供的接口。
通過 extern 聲明的方式使用外部函數接口、變量,容易在外部接口改變時可能導致聲明和定義不一致。
同時這種隱式依賴,容易導致架構腐化。
不符合規範的案例:
a.c 內容
extern int Foo(void); // Bad: 通過 extern 的方式引用外部函數
void Bar(void)
{
int i = Foo(); // 這裡使用了外部接口 Foo
...
}
應該改為:
a.c 內容
#include "b.h" // Good: 通過包含頭文件的方式使用其他.c提供的接口
void Bar(void)
{
int i = Foo();
...
}
b.h 內容
int Foo(void);
b.c內容
int Foo(void)
{
// Do something
}
例外,有些場景需要引用其內部函數,但並不想侵入代碼時,可以 extern 聲明方式引用。
如:
針對某一內部函數進行單元測試時,可以通過 extern 聲明來引用被測函數;
當需要對某一函數進行打樁、打補丁處理時,允許 extern 聲明該函數。
在 extern "C" 中包含頭文件,有可能會導致 extern "C" 嵌套,部分編譯器對 extern "C" 嵌套層次有限制,嵌套層次太多會編譯錯誤。
extern "C" 通常出現在 C,C++ 混合編程的情況下,在 extern "C" 中包含頭文件,可能會導致被包含頭文件的原有意圖遭到破壞,比如連結規範被不正確地更改。
示例,存在a.h和b.h兩個頭文件:
a.h 內容
...
#ifdef __cplusplus
void Foo(int);
#define A(value) Foo(value)
#else
void A(int)
#endif
b.h 內容
...
#ifdef __cplusplus
extern "C" {
#endif
#include "a.h"
void B(void);
#ifdef __cplusplus
}
#endif
使用C++預處理器展開b.h,將會得到
extern "C" {
void Foo(int);
void B(void);
}
按照 a.h 作者的本意,函數 Foo 是一個 C++ 自由函數,其連結規範為 "C++"。但在 b.h 中,由於 #include "a.h" 被放到了 extern "C" 的內部,函數 Foo 的連結規範被不正確地更改了。
例外:如果在 C++ 編譯環境中,想引用純C的頭文件,這些C頭文件並沒有 extern "C" 修飾。非侵入式的做法是,在 extern "C" 中去包含C頭文件。
5 函數函數的作用:避免重複代碼、增加可重用性;分層,降低複雜度、隱藏實現細節,使程序更加模塊化,從而更有利於程序的閱讀,維護。
函數應該簡潔、短小。
一個函數隻完成一件事情。
函數設計的精髓:編寫整潔函數,同時把代碼有效組織起來。代碼簡單直接、不隱藏設計者的意圖、用乾淨利落的抽象和直截了當的控制語句將函數有機組織起來。
規則5.1 避免函數過長,函數不超過50行(非空非注釋)函數應該可以一屏顯示完 (50行以內),只做一件事情,而且把它做好。
過長的函數往往意味著函數功能不單一,過於複雜,或過分呈現細節,未進行進一步抽象。
例外:
考慮代碼的聚合性與功能的全面性,某些函數可能會超過50行,但前提是不影響代碼的可讀性與簡潔。
這些例外的函數應該是極少的,例如特定算法處理。
即使一個長函數現在工作的非常好, 一旦有人對其修改, 有可能出現新的問題, 甚至導致難以發現的bug。
建議將其拆分為更加簡短並易於管理的若干函數,以便於他人閱讀和修改代碼。
函數的代碼塊嵌套深度指的是函數中的代碼控制塊(例如:if、for、while、switch等)之間互相包含的深度。
每級嵌套都會增加閱讀代碼時的腦力消耗,因為需要在腦子裡維護一個「棧」(比如,進入條件語句、進入循環等等)。
應該做進一步的功能分解,從而避免使代碼的閱讀者一次記住太多的上下文。
使用衛語句可以有效的減少 if 相關的嵌套層次。例:
原代碼嵌套層數是 3:
int Foo(...)
{
if (received) {
type = GetMsgType(msg);
if (type != UNKNOWN) {
return DealMsg(...);
}
}
return -1;
}
使用衛語句重構,嵌套層數變成 2:
int Foo(...)
{
if (!received) { // Good: 使用'衛語句'
return -1;
}
type = GetMsgType(msg);
if (type == UNKNOWN) {
return -1;
}
return DealMsg(..);
}
例外:
考慮代碼的聚合性與功能的全面性,某些函數嵌套可能會超過4層,但前提是不影響代碼的可讀性與簡潔。
這些例外的函數應該是極少的。
一個函數(標準庫中的函數/第三方庫函數/用戶定義的函數)能夠提供一些指示錯誤發生的方法。這可以通過使用錯誤標記、特殊的返回數據或者其他手段,不管什麼時候函數提供了這樣的機制,調用程序應該在函數返回時立刻檢查錯誤指示。
示例:
char fileHead[128];
ReadFileHead(fileName, fileHead, sizeof(fileHead)); // Bad: 未檢查返回值
DealWithFileHead(fileHead, sizeof(fileHead)); // fileHead 可能無效
正確寫法:
char fileHead[128];
ret = ReadFileHead(fileName, fileHead, sizeof(fileHead));
if (ret != OK) { // Good: 確保 fileHead 被有效寫入
return ERROR;
}
DealWithFileHead(fileHead, sizeof(fileHead)); // 處理文件頭
注意,當函數返回值被大量的顯式(void)忽略掉時,應當考慮函數返回值的設計是否合理。
如果所有調用者都不關注函數返回值時,請將函數設計成void型。
使用返回值而不是輸出參數,可以提高可讀性,並且通常提供相同或更好的性能。
函數名為 GetXxx、FindXxx 或直接名詞作函數名的函數,直接返回對應對象,可讀性更好。
建議5.3 使用強類型參數,避免使用void*儘管不同的語言對待強類型和弱類型有自己的觀點,但是一般認為c/c++是強類型語言,既然我們使用的語言是強類型的,就應該保持這樣的風格。
好處是儘量讓編譯器在編譯階段就檢查出類型不匹配的問題。
使用強類型便於編譯器幫我們發現錯誤,如下代碼中注意函數 FooListAddNode 的使用:
struct FooNode {
struct List link;
int foo;
};
struct BarNode {
struct List link;
int bar;
}
void FooListAddNode(void *node) // Bad: 這裡用 void * 類型傳遞參數
{
FooNode *foo = (FooNode *)node;
ListAppend(&g_fooList, &foo->link);
}
void MakeTheList(...)
{
FooNode *foo;
BarNode *bar;
...
FooListAddNode(bar); // Wrong: 這裡本意是想傳遞參數 foo,但錯傳了 bar,卻沒有報錯
}
上述問題有可能很隱晦,不易輕易暴露,從而破壞性更大。
如果明確 FooListAddNode 的參數類型,而不是 void *,則在編譯階段就能發現上述問題。
void FooListAddNode(FooNode *foo)
{
ListAppend(&g_fooList, &foo->link);
}
例外:某些通用泛型接口,需要傳入不同類型指針的,可以用 void * 入參。
建議5.4 模塊內部函數參數的合法性檢查,由調用者負責對於模塊外部傳入的參數,必須進行合法性檢查,保護程序免遭非法輸入數據的破壞。
模塊內部函數調用,預設由調用者負責保證參數的合法性,如果都由被調用者來檢查參數合法性,可能會出現同一個參數,被檢查多次,產生冗餘代碼,很不簡潔。
由調用者保證入參的合法性,這種契約式編程能讓代碼邏輯更簡潔,可讀性更好。
示例:
int SomeProc(...)
{
int data;
bool dataOK = GetData(&data); // 獲取數據
if (!dataOK) { // 檢查上一步結果,其實也就保證了數據合法
return -1;
}
DealWithData(data); // 調用數據處理函數
...
}
void DealWithData(int data)
{
if (data < MIN || data > MAX) { // Bad: 調用者已經保證了數據合法性
return;
}
...
}
const 指針參數,將限制函數通過該指針修改所指向對象,使代碼更牢固、安全。
示例:C99標準 7.21.4.4 中strncmp 的例子,不變參數聲明為const。
int strncmp(const char *s1, const char *s2, size_t n); // Good:不變參數聲明為const
注意:指針參數要不要加 const 取決於函數設計,而不是看函數實體內有沒有發生「修改對象」的動作。
建議5.6 函數的參數個數不超過5個函數的參數過多,會使得該函數易於受外部(其他部分的代碼)變化的影響,從而影響維護工作。函數的參數過多同時也會增大測試的工作量。
函數的參數個數不要超過5個,如果超過可以考慮:
看能否拆分函數
看能否將相關參數合在一起,定義結構體
內聯函數內聯函數是C99引入的一種函數優化手段。函數內聯能消除函數調用的開銷;並得益於內聯實現跟調用點代碼的合併,編譯器有更大的視角,從而完成更多的代碼優化。內聯函數跟函數式宏比較類似,兩者的分析詳見建議6.1。
建議5.7 內聯函數不超過10行(非空非注釋)將函數定義成內聯一般希望提升性能,但是實際並不一定能提升性能。如果函數體短小,則函數內聯可以有效的縮減目標代碼的大小,並提升函數執行效率。
反之,函數體比較大,內聯展開會導致目標代碼的膨脹,特別是當調用點很多時,膨脹得更厲害,反而會降低執行效率。
內聯函數規模建議控制在 10 行以內。
不要為了提高性能而濫用內聯函數。不要過早優化。一般情況,當有實際測試數據證明內聯性能更高時,再將函數定義為內聯。對於類似 setter/getter 短小而且調用頻繁的函數,可以定義為內聯。
規則5.3 被多個源文件調用的內聯函數要放在頭文件中定義內聯函數是在編譯時內聯展開,因此要求內聯函數定義必須在調用此函數的每個源文件內可見。
如下所示代碼,inline.h 只有SomeInlineFunc函數的聲明而沒有定義。other.c包含inline.h,調用SomeInlineFunc時無法內聯。
inline.h
inline int SomeInlineFunc(void);
inline.c
inline int SomeInlineFunc(void)
{
// 實現代碼
}
other.c
#include "inline.h"
int OtherFunc(void)
{
int ret = SomeInlineFunc();
}
由於這個限制,多個源文件如果要調用同一個內聯函數,需要將內聯函數的定義放在頭文件中。
gnu89 在內聯函數實現上跟C99標準有差異,兼容做法是將函數聲明為 static inline。
函數式宏是指形如函數的宏(示例代碼如下所示),其包含若干條語句來實現某一特定功能。
#define ASSERT(x) do { \
if (!(x)) { \
printk(KERN_EMERG "assertion failed %s: %d: %s\n", \
__FILE__, __LINE__, #x); \
BUG(); \
} \
} while (0)
定義函數式宏前,應考慮能否用函數替代。對於可替代場景,建議用函數替代宏。
函數式宏的缺點如下:
函數式宏缺乏類型檢查,不如函數調用檢查嚴格。示例代碼見下。
宏展開時宏參數不求值,可能會產生非預期結果,詳見規則6.1和規則6.3。
宏沒有獨立的作用域,跟控制流語句配合時,可能會產生如規則6.2描述的非預期結果。
宏的技巧性太強(參見下面的規則),例如#的用法和無處不在的括號,影響可讀性。
在特定場景下必須用特定編譯器對宏的擴展,如 gcc 的 statement expression,可移植性也不好。
宏在預編譯階段展開後,在其後編譯、連結和調試時都不可見;而且包含多行的宏會展開為一行。函數式宏難以調試、難以打斷點,不利於定位問題。
對於包含大量語句的宏,在每個調用點都要展開。如果調用點很多,會造成代碼空間的膨脹。
函數式宏缺乏類型檢查的示例代碼:
#define MAX(a, b) (((a) < (b)) ? (b) : (a))
int Max(int a, int b)
{
return (a < b) ? b : a;
}
int TestMacro(void)
{
unsigned int a = 1;
int b = -1;
(void)printf("MACRO: max of a(%u) and b(%d) is %d\n", a, b, MAX(a, b));
(void)printf("FUNC : max of a(%u) and b(%d) is %d\n", a, b, Max(a, b));
return 0;
}
由於宏缺乏類型檢查,MAX中的a和b的比較提升為無符號數的比較,結果是a < b。輸出結果是:
MACRO: max of a(1) and b(-1) is -1
FUNC : max of a(1) and b(-1) is 1
函數沒有宏的上述缺點。但是,函數相比宏,最大的劣勢是執行效率不高(增加函數調用的開銷和編譯器優化的難度)。
為此,C99標準引入了內聯函數(gcc在標準之前就引入了內聯函數)。
內聯函數跟宏類似,也是在調用點展開。不同之處在於內聯函數是在編譯時展開。
內聯函數兼具函數和宏的優點:
內聯函數/函數執行嚴格的類型檢查
內聯函數/函數的入參求值只會進行一次
內聯函數就地展開,沒有函數調用的開銷
內聯函數比函數優化得更好
對於性能敏感的代碼,可以考慮用內聯函數代替函數式宏。
函數和內聯函數不能完全替代函數式宏,函數式宏在某些場景更適合。
比如,在日誌記錄場景下,使用帶可變參和默認參數的函數式宏更方便:
int ErrLog(const char *file, unsigned long line, const char *fmt, ...);
#define ERR_LOG(fmt, ...) ErrLog(__FILE__, __LINE__, fmt, ##__VA_ARGS__)
宏參數在宏展開時只是文本替換,在編譯時再求值。文本替換後,宏包含的語句跟調用點代碼合併。
合併後的表達式因為操作符的優先級和結合律,可能會導致計算結果跟期望的不同,尤其是當宏參數在一個表達式中時。
如下所示,是一種錯誤的寫法:
#define SUM(a, b) a + b // Bad.
下面這樣調用宏,執行結果跟預期不符:
100 / SUM(2, 8) 將擴展成 (100 / 2) + 8,預期結果則是100 / (2 + 8)。
這個問題可以通過將整個表示式加上括號來解決,如下所示:
#define SUM(a, b) (a + b) // Bad.
但是這種改法在下面這種場景又有問題:
SUM(1 << 2, 8)擴展成1 << (2 + 8)(因為<<優先級低於+),跟預期結果(1 << 2) + 8不符。
這個問題可以通過將每個宏參數都加上括號來解決,如下所示:
#define SUM(a, b) (a) + (b) // Bad.
再看看第三種問題場景:SUM(2, 8) * 10 。擴展後的結果為 (2) + ((8) * 10),跟預期結果(2 + 8) * 10不符。
綜上所述,正確的寫法如下:
#define SUM(a, b) ((a) + (b)) // Good.
但是要避免濫用括號。如下所示,單獨的數字或標識符加括號毫無意義。
#define SOME_CONST 100 // Good: 單獨的數字無需括號
#define ANOTHER_CONST (-1) // Good: 負數需要使用括號
#define THE_CONST SOME_CONST // Good: 單獨的標識符無需括號
下列情況需要注意:
宏參數參與 '#', '##' 操作時,不要加括號
宏參數參與字符串拼接時,不要加括號
宏參數作為獨立部分,在賦值(包括+=, -=等)操作的某一邊時,無需括號
宏參數作為獨立部分,在逗號表達式,函數或宏調用列表中,無需括號
舉例:
#define MAKE_STR(x) #x // x 不要加括號
#define HELLO_STR(obj) "Hello, " obj // obj 不要加括號
#define ADD_3(sum, a, b, c) (sum = (a) + (b) + (c)) // a, b, c 需要括號;而 sum 無需括號
#define FOO(a, b) Bar((a) + 1, b) // a 需要括號;而 b 無需括號
宏本身沒有代碼塊的概念。當宏在調用點展開後,宏內定義的表達式和變量融合到調用代碼中,可能會出現變量名衝突和宏內語句被分割等問題。通過 do-while(0) 顯式為宏加上邊界,讓宏有獨立的作用域,並且跟分號能更好的結合而形成單條語句,從而規避此類問題。
如下所示的宏是錯誤的用法(為了說明問題,下面示例代碼稍不符規範):
// Not Good.
#define FOO(x) \
(void)printf("arg is %d\n", (x)); \
DoSomething((x));
當像下面示例代碼這樣調用宏,for循環只執行了宏的第一條語句,宏的後一條語句只在循環結束後執行一次。
for (i = 1; i < 10; i++)
FOO(i);
用大括號將FOO定義的語句括起來可以解決上面的問題:
#define FOO(x) { \
(void)printf("arg is %d\n", (x)); \
DoSomething((x)); \
}
由於大括號跟分號沒有關聯。大括號後緊跟的分號,是另外一個語句。
如下示例代碼,會出現'懸掛else' 編譯報錯:
if (condition)
FOO(10);
else
FOO(20);
正確的寫法是用 do-while(0) 把執行體括起來,如下所示:
// Good.
#define FOO(x) do { \
(void)printf("arg is %d\n", (x)); \
DoSomething((x)); \
} while (0)
例外:
包含 break, continue 語句的宏可以例外。使用此類宏務必特別小心。
宏中包含不完整語句時,可以例外。比如用宏封裝 for 循環的條件部分。
非多條語句,或單個 if/for/while/switch 語句,可以例外。
規則6.3 不允許把帶副作用的表達式作為參數傳遞給函數式宏由於宏只是文本替換,對於內部多次使用同一個宏參數的函數式宏,將帶副作用的表達式作為宏參數傳入會導致非預期的結果。
如下所示,宏SQUARE本身沒有問題,但是使用時將帶副作用的a++傳入導致a的值在SQUARE執行後跟預期不符:
#define SQUARE(a) ((a) * (a))
int a = 5;
int b;
b = SQUARE(a++); // Bad: 實際 a 自增加了 2 次
SQUARE(a++)展開後為((a++) * (a++)),變量a自增了兩次,其值為7,而不是預期的6。
正確的寫法如下所示:
b = SQUARE(a);
a++; // 結果:a = 6,只自增了一次。
此外,如果參數包含函數調用,宏展開後,函數可能會被重複調用。
如果函數執行結果相同,則存在浪費;如果函數多次調用結果不一樣,執行結果可能不符合預期。
宏中使用 return、goto、continue、break 等改變流程的語句,雖然能簡化代碼,但同時也隱藏了真實流程,不易於理解,容易導致資源洩漏等問題。
首先,宏封裝 return 容易導致過度封裝和使用。
如下代碼,status的判斷是主幹流程的一部分,用宏封裝起來後,變得不直觀了,閱讀時習慣性把RETURN_IF宏忽略掉了,從而導致對主幹流程的理解有偏差。
#define LOG_AND_RETURN_IF_FAIL(ret, fmt, ...) do { \
if ((ret) != OK) { \
(void)ErrLog(fmt, ##__VA_ARGS__); \
return (ret); \
} \
} while (0)
#define RETURN_IF(cond, ret) do { \
if (cond) { \
return (ret); \
} \
} while (0)
ret = InitModuleA(a, b, &status);
LOG_AND_RETURN_IF_FAIL(ret, "Init module A failed!"); // OK.
RETURN_IF(status != READY, ERR_NOT_READY); // Bad: 重要邏輯不明顯
ret = InitModuleB(c);
LOG_AND_RETURN_IF_FAIL(ret, "Init module B failed!"); // OK.
其次,宏封裝 return 也容易引發內存洩漏。再看一個例子:
#define CHECK_PTR(ptr, ret) do { \
if ((ptr) == NULL) { \
return (ret); \
} \
} while (0)
...
mem1 = MemAlloc(...);
CHECK_PTR(mem1, ERR_CODE_XXX);
mem2 = MemAlloc(...);
CHECK_PTR(mem2, ERR_CODE_XXX); // Wrong: 內存洩漏
如果 mem2 申請內存失敗了,CHECK_PTR 會直接返回,而沒有釋放 mem1。
除此之外,CHECK_PTR 宏命名也不好,宏名只反映了檢查動作,沒有指明結果。只有看了宏實現才知道指針為空時返回失敗。
綜上所述:不推薦宏定義中封裝 return、goto、continue、break 等改變程序流程的語句;
對於返回值判斷等異常處理場景可以例外。
注意: 包含 return、goto、continue、break 等改變流程語句的宏命名,務必要體現對應關鍵字。
建議6.3 函數式宏不超過10行(非空非注釋)函數式宏本身的一大問題是比函數更難以調試和定位,特別是宏過長,調試和定位的難度更大。
而且宏擴展會導致目標代碼的膨脹。建議函數式宏不要超過10行。
在C語言編碼中,除了函數,最重要的就是變量。
變量在使用時,應始終遵循「職責單一」原則。
按作用域區分,變量可分為全局變量和局部變量。
儘量不用或少用全局變量。
在程序設計中,全局變量是在所有作用域都可訪問的變量。通常,使用不必要的全局變量被認為是壞習慣。
使用全局變量的缺點:
破壞函數的獨立性和可移植性,使函數對全局變量產生依賴,存在耦合;
降低函數的代碼可讀性和可維護性。當多個函數讀寫全局變量時,某一時刻其取值可能不是確定的,對於代碼的閱讀和維護不利;
在並發編程環境中,使用全局變量會破壞函數的可重入性,需要增加額外的同步保護處理才能確保數據安全。
如不可避免,對全局變量的讀寫應集中封裝。
規則7.1 模塊間,禁止使用全局變量作接口全局變量是模塊內部的具體實現,不推薦但允許跨文件使用,但禁止作為模塊接口暴露出去。
對全局變量的使用應該儘量集中,如果本模塊的數據需要對外部模塊開放,應提供對應函數接口。
這裡的變量,指的是局部動態變量,並且還包括內存堆上申請的內存塊。
因為他們的初始值都是不可預料的,所以禁止未經有效初始化就直接讀取其值。
void Foo(...)
{
int data;
Bar(data); // Bad: 未初始化就使用
...
}
如果有不同分支,要確保所有分支都得到初始化後才能使用:
void Foo(...)
{
int data;
if (...) {
data = 100;
}
Bar(data); // Bad: 部分分支該值未初始化
...
}
未經初始化就使用,一般靜態檢查工具是可以檢查出來的。
如 PCLint 工具,針對上述兩個例子分別會報錯:
Warning 530: Symbol 'data' (line ...) not initialized Warning 644: Variable 'data' (line ...) may not have been initialized
規則7.3 禁止無效、冗餘的變量初始化如果沒有確定的初始值,而仍然進行初始化,不僅不簡潔,反而不安全,可能會引入更難發現的問題。
常見的冗餘初始化:
int cnt = 0; // Bad: 冗餘初始化,將會被後面直接覆蓋
...
cnt = GetXxxCnt();
...
對於後續有條件賦值的變量,可以在定義時初始化成默認值
char *buf = NULL; // Good: 這裡用 NULL 代表默認值
if (condition) {
buf = malloc(MEM_SIZE);
}
...
if (buf != NULL) { // 判斷是否申請過內存
free(buf);
}
針對大數組的冗餘清零,更是會影響到性能。
char buf[VERY_BIG_SIZE] = {0};
memset(buf, 0, sizeof(buf)); // Bad: 冗餘清零
無效初始化,隱藏更大問題的反例:
void Foo(...)
{
int data = 0; // Bad: 習慣性的進行初始化
UseData(data); // 使用數據,本應該寫在獲取數據後面
data = GetData(...); // 獲取數據
...
}
上例代碼,如果沒有賦 0 初始化,靜態檢查工具可以幫助發現「未經初始化就直接使用」的問題。
但因為無效初始化,「使用數據」與「獲取數據」寫顛倒的缺陷,不能被輕易發現。
因此,應該寫簡潔的代碼,對變量或內存塊進行正確、必要的初始化。
C99不再限制局部變量定義必須在語句之前,可以按需定義,即在靠近變量使用的地方定義變量。
這種簡潔的做法,不僅將變量作用域限制更小,而且更方便閱讀和維護,還能解決定義變量時不知該怎麼初始化的問題。
如果編譯環境支持,建議按需定義。
例外:
遵從「安全規範」要求,指針變量、表示資源描述符的變量、BOOL變量不作要求。
所謂魔鬼數字即看不懂、難以理解的數字。
魔鬼數字並非一個非黑即白的概念,看不懂也有程度,需要結合代碼上下文和業務相關知識來判斷
例如數字 12,在不同的上下文中情況是不一樣的:
type = 12; 就看不懂,但 month = year * 12; 就能看懂。
數字 0 有時候也是魔鬼數字,比如 status = 0; 並不能表達是什麼狀態。
解決途徑:
對於單點使用的數字,可以增加注釋說明
對於多處使用的數字,必須定義宏或const 變量,並通過符號命名自注釋。
禁止出現下列情況:
沒有通過符號來解釋數字含義,如 #define ZERO 0
符號命名限制了其取值,如 #define XX_TIMER_INTERVAL_300MS 300
當變量與常量比較時,如果常量放左邊,如 if (MAX == v) 不符合閱讀習慣,而 if (MAX > v) 更是難於理解。
應當按人的正常閱讀、表達習慣,將常量放右邊。寫成如下方式:
if (v == MAX) ...
if (v < MAX) ...
也有特殊情況,如:if (MIN < v && v < MAX) 用來描述區間時,前半段是常量在左的。
不用擔心將 '==' 誤寫成 '=',因為 if (v = MAX) 會有編譯告警,其他靜態檢查工具也會報錯。讓工具去解決筆誤問題,代碼要符合可讀性第一。
規則8.1 含有變量自增或自減運算的表達式中禁止再次引用該變量含有變量自增或自減運算的表達式中,如果再引用該變量,其結果在C標準中未明確定義。各個編譯器或者同一個編譯器不同版本實現可能會不一致。
為了更好的可移植性,不應該對標準未定義的運算次序做任何假設。
注意,運算次序的問題不能使用括號來解決,因為這不是優先級的問題。
示例:
x = b[i] + i++; // Bad: b[i]運算跟 i++,先後順序並不明確。
正確的寫法是將自增或自減運算單獨放一行:
x = b[i] + i;
i++; // Good: 單獨一行
函數參數:
Func(i++, i); // Bad: 傳遞第2個參數時,不確定自增運算有沒有發生
正確的寫法:
i++; // Good: 單獨一行
x = Func(i, i);
可以使用括號強調表達式操作順序,防止因默認的優先級與設計思想不符而導致程序出錯。
然而過多的括號會分散代碼使其降低了可讀性,應適度使用。
當表達式包含不常用,優先級易混淆的操作符時,推薦使用括號,比如位操作符:
c = (a & 0xFF) + b; /* 涉及位操作符,需要括號 */
語句規則8.2 switch語句要有default分支大部分情況下,switch語句中要有default分支,保證在遺漏case標籤處理時能夠有一個預設的處理行為。
特例:
如果switch條件變量是枚舉類型,並且 case 分支覆蓋了所有取值,則加上default分支處理有些多餘。
現代編譯器都具備檢查是否在switch語句中遺漏了某些枚舉值的case分支的能力,會有相應的warning提示。
enum Color {
RED,
BLUE
};
// 因為switch條件變量是枚舉值,這裡可以不用加default處理分支
switch (color) {
case RED:
DoRedThing();
break;
case BLUE:
DoBlueThing();
...
break;
}
goto語句會破壞程序的結構性,所以除非確實需要,最好不使用goto語句。使用時,也只允許跳轉到本函數goto語句之後的語句。
goto語句通常用來實現函數單點返回。
同一個函數體內部存在大量相同的邏輯但又不方便封裝成函數的情況下,譬如反覆執行文件操作, 對文件操作失敗以後的處理部分代碼(譬如關閉文件句柄,釋放動態申請的內存等等), 一般會放在該函數體的最後部分,在需要的地方就goto到那裡,這樣代碼反而變得清晰簡潔。實際也可以封裝成函數或者封裝成宏,但是這麼做會讓代碼變得沒那麼直接明了。
示例:
// Good: 使用 goto 實現單點返回
int SomeInitFunc(void)
{
void *p1;
void *p2 = NULL;
void *p3 = NULL;
p1 = malloc(MEM_LEN);
if (p1 == NULL) {
goto EXIT;
}
p2 = malloc(MEM_LEN);
if (p2 == NULL) {
goto EXIT;
}
p3 = malloc(MEM_LEN);
if (p3 == NULL) {
goto EXIT;
}
DoSomething(p1, p2, p3);
return 0; // OK.
EXIT:
if (p3 != NULL) {
free(p3);
}
if (p2 != NULL) {
free(p2);
}
if (p1 != NULL) {
free(p1);
}
return -1; // Failed!
}
當進行數據類型強制轉換時,其數據的意義、轉換後的取值等都有可能發生變化,而這些細節若考慮不周,就很有可能留下隱患。
如下賦值,多數編譯器不產生告警,但值的含義還是稍有變化。
char ch;
unsigned short int exam;
ch = -1;
exam = ch; // Bad: 編譯器不產生告警,此時exam為0xFFFF。
分享C/C++技術文章