如何組織構建多檔案 C 語言程式(二)

Erik O'shaughnessy發表於2020-03-16

我將在本系列的第二篇中深入研究由多個檔案組成的 C 程式的結構。

第一篇中,我設計了一個名為喵嗚喵嗚的多檔案 C 程式,該程式實現了一個玩具編解碼器。我也提到了程式設計中的 Unix 哲學,即在一開始建立多個空檔案,並建立一個好的結構。最後,我建立了一個 Makefile 資料夾並闡述了它的作用。在本文中將另一個方向展開:現在我將介紹簡單但具有指導性的喵嗚喵嗚編解碼器的實現。

當讀過我的《如何寫一個好的 C 語言 main 函式》後,你會覺得喵嗚喵嗚編解碼器的 main.c 檔案的結構很熟悉,其主體結構如下:

/* main.c - 喵嗚喵嗚流式編解碼器 */

/* 00 系統包含檔案 */
/* 01 專案包含檔案 */
/* 02 外部宣告 */
/* 03 定義 */
/* 04 型別定義 */
/* 05 全域性變數宣告(不要用)*/
/* 06 附加的函式原型 */
   
int main(int argc, char *argv[])
{
  /* 07 變數宣告 */
  /* 08 檢查 argv[0] 以檢視該程式是被如何呼叫的 */
  /* 09 處理來自使用者的命令列選項 */
  /* 10 做點有用的事情 */
}
   
/* 11 其它輔助函式 */

包含專案標頭檔案

位於第二部分中的 /* 01 專案包含檔案 */ 的原始碼如下:

/* main.c - 喵嗚喵嗚流式編解碼器 */
...
/* 01 專案包含檔案 */
#include "main.h"
#include "mmecode.h"
#include "mmdecode.h"

#include 是 C 語言的預處理命令,它會將該檔名的檔案內容拷貝到當前檔案中。如果程式設計師在標頭檔案名稱周圍使用雙引號(""),編譯器將會在當前目錄尋找該檔案。如果檔案被尖括號包圍(<>),編譯器將在一組預定義的目錄中查詢該檔案。

main.h 檔案中包含了 main.c 檔案中用到的定義和型別定義。我喜歡儘可能多將宣告放在標頭檔案裡,以便我在我的程式的其他位置使用這些定義。

標頭檔案 mmencode.hmmdecode.h 幾乎相同,因此我以 mmencode.h 為例來分析。

/* mmencode.h - 喵嗚喵嗚流編解碼器 */
  
#ifndef _MMENCODE_H
#define _MMENCODE_H
  
#include <stdio.h>
  
int mm_encode(FILE *src, FILE *dst);
  
#endif /* _MMENCODE_H */

#ifdef#define#endif 指令統稱為 “防護” 指令。其可以防止 C 編譯器在一個檔案中多次包含同一檔案。如果編譯器在一個檔案中發現多個定義/原型/宣告,它將會產生警告。因此這些防護措施是必要的。

在這些防護內部,只有兩個東西:#include 指令和函式原型宣告。我在這裡包含了 stdio.h 標頭檔案,以便於能在函式原型中使用 FILE 定義。函式原型也可以被包含在其他 C 檔案中,以便於在檔案的名稱空間中建立它。你可以將每個檔案視為一個獨立的名稱空間,其中的變數和函式不能被另一個檔案中的函式或者變數使用。

編寫標頭檔案很複雜,並且在大型專案中很難管理它。不要忘記使用防護。

喵嗚喵嗚編碼的最終實現

該程式的功能是按照位元組進行 MeowMeow 字串的編解碼,事實上這是該專案中最簡單的部分。截止目前我所做的工作便是支援允許在適當的位置呼叫此函式:解析命令列,確定要使用的操作,並開啟將要操作的檔案。下面的迴圈是編碼的過程:

/* mmencode.c - 喵嗚喵嗚流式編解碼器 */
...
   while (!feof(src)) {

     if (!fgets(buf, sizeof(buf), src))
       break;

     for(i=0; i<strlen(buf); i++) {
       lo = (buf[i] & 0x000f);
       hi = (buf[i] & 0x00f0) >> 4;
       fputs(tbl[hi], dst);
       fputs(tbl[lo], dst);
     }
   }

簡單的說,當檔案中還有資料塊時( feof(3) ),該迴圈讀取(feof(3) )檔案中的一個資料塊。然後將讀入的內容的每個位元組分成兩個 hilo半位元組nibble。半位元組是半個位元組,即 4 個位。這裡的奧妙之處在於可以用 4 個位來編碼 16 個值。我將 hilo 用作 16 個字串查詢表 tbl 的索引,表中包含了用半位元組編碼的 MeowMeow 字串。這些字串使用 fputs(3) 函式寫入目標 FILE 流,然後我們繼續處理快取區的下一個位元組。

該表使用 table.h 中的巨集定義進行初始化,在沒有特殊原因(比如:要展示包含了另一個專案的本地標頭檔案)時,我喜歡使用巨集來進行初始化。我將在未來的文章中進一步探討原因。

喵嗚喵嗚解碼的實現

我承認在開始工作前花了一些時間。解碼的迴圈與編碼類似:讀取 MeowMeow 字串到緩衝區,將編碼從字串轉換為位元組

 /* mmdecode.c - 喵嗚喵嗚流式編解碼器 */
 ...
 int mm_decode(FILE *src, FILE *dst)
 {
   if (!src || !dst) {
     errno = EINVAL;
     return -1;
   }
   return stupid_decode(src, dst);
 }

這不符合你的期望嗎?

在這裡,我通過外部公開的 mm_decode() 函式公開了 stupid_decode() 函式細節。我上面所說的“外部”是指在這個檔案之外。因為 stupid_decode() 函式不在該標頭檔案中,因此無法在其他檔案中呼叫它。

當我們想釋出一個可靠的公共介面時,有時候會這樣做,但是我們還沒有完全使用函式解決問題。在本例中,我編寫了一個 I/O 密集型函式,該函式每次從源中讀取 8 個位元組,然後解碼獲得 1 個位元組寫入目標流中。較好的實現是一次處理多於 8 個位元組的緩衝區。更好的實現還可以通過緩衝區輸出位元組,進而減少目標流中單位元組的寫入次數。

/* mmdecode.c - 喵嗚喵嗚流式編解碼器 */
...
int stupid_decode(FILE *src, FILE *dst)
{
  char           buf[9];
  decoded_byte_t byte;
  int            i;
    
  while (!feof(src)) {
    if (!fgets(buf, sizeof(buf), src))
      break;
    byte.field.f0 = isupper(buf[0]);
    byte.field.f1 = isupper(buf[1]);
    byte.field.f2 = isupper(buf[2]);
    byte.field.f3 = isupper(buf[3]);
    byte.field.f4 = isupper(buf[4]);
    byte.field.f5 = isupper(buf[5]);
    byte.field.f6 = isupper(buf[6]);
    byte.field.f7 = isupper(buf[7]);
      
    fputc(byte.value, dst);
  }
  return 0;
}

我並沒有使用編碼器中使用的位移方法,而是建立了一個名為 decoded_byte_t 的自定義資料結構。

/* mmdecode.c - 喵嗚喵嗚流式編解碼器 */
...

typedef struct {
  unsigned char f7:1;
  unsigned char f6:1;
  unsigned char f5:1;
  unsigned char f4:1;
  unsigned char f3:1;
  unsigned char f2:1;
  unsigned char f1:1;
  unsigned char f0:1;
} fields_t;
  
typedef union {
  fields_t      field;
  unsigned char value;
} decoded_byte_t;

初次看到程式碼時可能會感到有點兒複雜,但不要放棄。decoded_byte_t 被定義為 fields_tunsigned char聯合。可以將聯合中的命名成員看作同一記憶體區域的別名。在這種情況下,valuefield 指向相同的 8 位記憶體區域。將 field.f0 設定為 1 也將會設定 value 中的最低有效位。

雖然 unsigned char 並不神祕,但是對 fields_t 的型別定義(typedef)也許看起來有些陌生。現代 C 編譯器允許程式設計師在結構體中指定單個位欄位的值。欄位所在的型別是一個無符號整數型別,並在成員識別符號後緊跟一個冒號和一個整數,該整數指定了位欄位的長度。

這種資料結構使得按欄位名稱訪問位元組中的每個位變得簡單,並可以通過聯合中的 value 欄位訪問組合後的值。我們依賴編譯器生成正確的移位指令來訪問欄位,這可以在除錯時為你節省不少時間。

最後,因為 stupid_decode() 函式一次僅從源 FILE 流中讀取 8 個位元組,所以它效率並不高。通常我們嘗試最小化讀寫次數,以提高效能和降低呼叫系統呼叫的開銷。請記住:少量的讀取/寫入大的塊比大量的讀取/寫入小的塊好得多。

總結

用 C 語言編寫一個多檔案程式需要程式設計師要比只是是一個 main.c 做更多的規劃。但是當你新增功能或者重構時,只需要多花費一點兒努力便可以節省大量時間以及避免讓你頭痛的問題。

回顧一下,我更喜歡這樣做:多個檔案,每個檔案僅有簡單功能;通過標頭檔案公開那些檔案中的小部分功能;把數字常量和字串常量儲存在標頭檔案中;使用 Makefile 而不是 Bash 指令碼來自動化處理事務;使用 main() 函式來處理命令列引數解析並作為程式主要功能的框架。

我知道我只是蜻蜓點水般介紹了這個簡單的程式,並且我很高興知道哪些事情對你有所幫助,以及哪些主題需要詳細的解釋。請在評論中分享你的想法,讓我知道。


via: https://opensource.com/article/19/7/structure-multi-file-c-part-2

作者:Erik O'Shaughnessy 選題:lujun9972 譯者:萌新阿巖 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出

如何組織構建多檔案 C 語言程式(二)

訂閱“Linux 中國”官方小程式來檢視

相關文章