1. 字串操作函數
程式按功能劃分可分為數值運算、符號處理和I/O操作三類,符號處理常式占相當大的比例,符號處理常式無處不在,編譯器、瀏覽器、Office套件等程式的主要功能都是符號處理。無論多複雜的符號處理都是由各種基本的字串操作組成的,本節介紹如何用C語言的庫函數做字串初始化、取長度、拷貝、串連、比較、搜尋等基本操作。
1.1. 初始化字串
#include <string.h>void *memset(void *s, int c, size_t n);傳回值:s指向哪,返回的指標就指向哪
memset函數把s所指的記憶體位址開始的n個位元組都填充為c的值。通常c的值為0,把一塊記憶體區清零。例如定義char buf[10];,如果它是全域變數或靜態變數,則自動初始化為0(位於.bss段),如果它是函數的局部變數,則初值不確定,可以用memset(buf, 0, 10)清零,由malloc分配的記憶體初值也是不確定的,也可以用memset清零。
1.2. 取字串的長度
#include <string.h>size_t strlen(const char *s);傳回值:字串的長度
strlen函數返回s所指的字串的長度。該函數從s所指的第一個字元開始找'\0'字元,一旦找到就返回,返回的長度不包括'\0'字元在內。例如定義char buf[] = "hello";,則strlen(buf)的值是5,但要注意,如果定義char buf[5] = "hello";,則調用strlen(buf)是危險的,會造成數組訪問越界。
1.3. 拷貝字串
在第 1 節 “本章的預備知識”中介紹了strcpy和strncpy函數,拷貝以'\0'結尾的字串,strncpy還帶一個參數指定最多拷貝多少個位元組,此外,strncpy並不保證緩衝區以'\0'結尾。現在介紹memcpy和memmove函數。
#include <string.h>void *memcpy(void *dest, const void *src, size_t n);void *memmove(void *dest, const void *src, size_t n);傳回值:dest指向哪,返回的指標就指向哪
memcpy函數從src所指的記憶體位址拷貝n個位元組到dest所指的記憶體位址,和strncpy不同,memcpy並不是遇到'\0'就結束,而是一定會拷貝完n個位元組。這裡的命名規律是,以str開頭的函數處理以'\0'結尾的字串,而以mem開頭的函數則不關心'\0'字元,或者說這些函數並不把參數當字串看待,因此參數的指標類型是void *而非char *。
memmove也是從src所指的記憶體位址拷貝n個位元組到dest所指的記憶體位址,雖然叫move但其實也是拷貝而非移動。但是和memcpy有一點不同,memcpy的兩個參數src和dest所指的記憶體區間如果重疊則無法保證正確拷貝,而memmove卻可以正確拷貝。假設定義了一個數組char buf[20] = "hello world\n";,如果想把其中的字串往後移動一個位元組(變成"hhello world\n"),調用memcpy(buf + 1, buf, 13)是無法保證正確拷貝的:
例 25.1. 錯誤的memcpy調用
#include <stdio.h>#include <string.h>int main(void){ char buf[20] = "hello world\n"; memcpy(buf + 1, buf, 13); printf(buf); return 0;}
在我的機器上啟動並執行結果是hhhllooworrd。如果把代碼中的memcpy改成memmove則可以保證正確拷貝。memmove可以這樣實現:
void *memmove(void *dest, const void *src, size_t n){ char temp[n]; int i; char *d = dest; const char *s = src; for (i = 0; i < n; i++) temp[i] = s[i]; for (i = 0; i < n; i++) d[i] = temp[i]; return dest;}
藉助於一個臨時緩衝區temp,即使src和dest所指的記憶體區間有重疊也能正確拷貝。思考一下,如果不藉助於臨時緩衝區能不能正確處理重疊記憶體區間的拷貝?
用memcpy如果得到的結果是hhhhhhhhhhhhhh倒不奇怪,可為什麼會得到hhhllooworrd這個奇怪的結果呢?根據這個結果猜測的一種可能的實現是:
void *memcpy(void *dest, const void *src, size_t n){ char *d = dest; const char *s = src; int *di; const int *si; int r = n % 4; while (r--) *d++ = *s++; di = (int *)d; si = (const int *)s; n /= 4; while (n--) *di++ = *si++; return dest;}
在32位的x86平台上,每次拷貝1個位元組需要一條指令,每次拷貝4個位元組也只需要一條指令,memcpy函數的實現儘可能4個位元組4個位元組地拷貝,因而得到上述結果。
C99的
restrict關鍵字
我們來看一個跟memcpy/memmove類似的問題。下面的函數將兩個數組中對應的元素相加,結果儲存在第三個數組中。
void vector_add(const double *x, const double *y, double *result){ int i; for (i = 0; i < 64; ++i) result[i] = x[i] + y[i];}
如果這個函數要在多處理器的電腦上執行,編譯器可以做這樣的最佳化:把這一個迴圈拆成兩個迴圈,一個處理器計算i值從0到31的迴圈,另一個處理器計算i值從32到63的迴圈,這樣兩個處理器可以同時工作,使計算時間縮短一半。但是這樣的編譯最佳化能保證得出正確結果嗎?假如result和x所指的記憶體區間是重疊的,result[0]其實是x[1],result[i]其實是x[i+1],這兩個處理器就不能各幹各的事情了,因為第二個處理器的工作依賴於第一個處理器的最終計算結果,這種情況下編譯最佳化的結果是錯的。這樣看來編譯器是不敢隨便做最佳化了,那麼多處理器提供的並行性就無法利用,豈不可惜?為此,C99引入restrict關鍵字,如果程式員把上面的函式宣告為void vector_add(const double *restrict x, const double *restrict y, double *restrict result),就是告訴編譯器可以放心地對這個函數做最佳化,程式員自己會保證這些指標所指的記憶體區間互不重疊。
由於restrict是C99引入的新關鍵字,目前Linux的Man Page還沒有更新,所以都沒有restrict關鍵字,本書的函數原型都取自Man Page,所以也都沒有restrict關鍵字。但在C99標準中庫函數的原型都在必要的地方加了restrict關鍵字,在C99中memcpy的原型是void *memcpy(void * restrict s1, const void * restrict s2, size_t n);,就是告訴調用者,這個函數的實現可能會做些最佳化,編譯器也可能會做些最佳化,傳進來的指標不允許指向重疊的記憶體區間,否則結果可能是錯的,而memmove的原型是void *memmove(void *s1, const void *s2, size_t n);,沒有restrict關鍵字,說明傳給這個函數的指標允許指向重疊的記憶體區間。在restrict關鍵字出現之前都是用自然語言描述哪些函數的參數不允許指向重疊的記憶體區間,例如在C89標準的庫函數一章開頭提到,本章描述的所有函數,除非特別說明,都不應該接收兩個指標參數指向重疊的記憶體區間,例如調用sprintf時傳進來的格式化字串和結果字串的首地址相同,諸如此類的調用都是非法的。本書也遵循這一慣例,除非像memmove這樣特別說明之外,都表示“不允許”。
關於restrict關鍵字更詳細的解釋可以參考[BeganFORTRAN]。
字串的拷貝也可以用strdup(3)函數,這個函數不屬於C標準庫,是POSIX標準中定義的,POSIX標準定義了UNIX系統的各種介面,包含C標準庫的所有函數和很多其它的系統函數,在第 2 節 “C標準I/O庫函數與Unbuffered I/O函數”將詳細介紹POSIX標準。
#include <string.h>char *strdup(const char *s);傳回值:指向新分配的字串
這個函數調用malloc動態分配記憶體,把字串s拷貝到新分配的記憶體中然後返回。用這個函數省去了事先為新字串分配記憶體的麻煩,但是用完之後要記得調用free釋放新字串的記憶體。
1.4. 連接字串
#include <string.h>char *strcat(char *dest, const char *src);char *strncat(char *dest, const char *src, size_t n);傳回值:dest指向哪,返回的指標就指向哪
strcat把src所指的字串串連到dest所指的字串後面,例如:
char d[10] = "foo";char s[10] = "bar";strcat(d, s);printf("%s %s\n", d, s);
調用strcat函數後,緩衝區s的內容沒變,緩衝區d中儲存著字串"foobar",注意原來"foo"後面的'\0'被串連上來的字串"bar"覆蓋掉了,"bar"後面的'\0'仍保留。
strcat和strcpy有同樣的問題,調用者必須確保dest緩衝區足夠大,否則會導致緩衝區溢位錯誤。strncat函數通過參數n指定一個長度,就可以避免緩衝區溢位錯誤。注意這個參數n的含義和strncpy的參數n不同,它並不是緩衝區dest的長度,而是表示最多從src緩衝區中取n個字元(不包括結尾的'\0')串連到dest後面。如果src中前n個字元沒有出現'\0',則取前n個字元再加一個'\0'串連到dest後面,所以strncat總是保證dest緩衝區以'\0'結尾,這一點又和strncpy不同,strncpy並不保證dest緩衝區以'\0'結尾。所以,提供給strncat函數的dest緩衝區的大小至少應該是strlen(dest)+n+1個位元組,才能保證不溢出。
1.5. 比較字串
#include <string.h>int memcmp(const void *s1, const void *s2, size_t n);int strcmp(const char *s1, const char *s2);int strncmp(const char *s1, const char *s2, size_t n);傳回值:負值表示s1小於s2,0表示s1等於s2,正值表示s1大於s2
memcmp從前到後逐個比較緩衝區s1和s2的前n個位元組(不管裡面有沒有'\0'),如果s1和s2的前n個位元組全都一樣就返回0,如果遇到不一樣的位元組,s1的位元組比s2小就返回負值,s1的位元組比s2大就返回正值。
strcmp把s1和s2當字串比較,在其中一個字串中遇到'\0'時結束,按照上面的比較準則,"ABC"比"abc"小,"ABCD"比"ABC"大,"123A9"比"123B2"小。
strncmp的比較結束條件是:要麼在其中一個字串中遇到'\0'結束(類似於strcmp),要麼比較完n個字元結束(類似於memcmp)。例如,strncmp("ABCD", "ABC", 3)的傳回值是0,strncmp("ABCD", "ABC", 4)的傳回值是正值。
#include <strings.h>int strcasecmp(const char *s1, const char *s2);int strncasecmp(const char *s1, const char *s2, size_t n);傳回值:負值表示s1小於s2,0表示s1等於s2,正值表示s1大於s2
這兩個函數和strcmp/strncmp類似,但在比較過程中忽略大小寫,大寫字母A和小寫字母a認為是相等的。這兩個函數不屬於C標準庫,是POSIX標準中定義的。
1.6. 搜尋字串
#include <string.h>char *strchr(const char *s, int c);char *strrchr(const char *s, int c);傳回值:如果找到字元c,返回字串s中指向字元c的指標,如果找不到就返回NULL
strchr在字串s中從前到後尋找字元c,找到字元c第一次出現的位置時就返回,傳回值指向這個位置,如果找不到字元c就返回NULL。strrchr和strchr類似,但是從右向左找字元c,找到字元c第一次出現的位置就返回,函數名中間多了一個字母r可以理解為Right-to-left。
#include <string.h>char *strstr(const char *haystack, const char *needle);傳回值:如果找到子串,傳回值指向子串的開頭,如果找不到就返回NULL
strstr在一個長字串中從前到後找一個子串(Substring) ,找到子串第一次出現的位置就返回,傳回值指向子串的開頭,如果找不到就返回NULL。這兩個參數名很形象,在乾草堆haystack中找一根針needle,按中文的說法叫大海撈針,顯然haystack是長字串,needle是要找的子串。
搜尋子串有一個顯而易見的演算法,可以用兩層的迴圈,外層迴圈把haystack中的每一個字元的位置依次假定為子串的開頭,內層迴圈從這個位置開始逐個比較haystack和needle的每個字元是否相同。想想這個演算法最多需要做多少次比較?其實有比這個演算法高效得多的演算法,有興趣的讀者可以參考[演算法導論]。
1.7. 分割字串
很多檔案格式或協議格式中會規定一些分隔字元或者叫界定符(Delimiter) ,例如/etc/passwd檔案中儲存著系統的帳號資訊:
$ cat /etc/passwdroot:x:0:0:root:/root:/bin/bashdaemon:x:1:1:daemon:/usr/sbin:/bin/shbin:x:2:2:bin:/bin:/bin/sh...
每條記錄佔一行,也就是說記錄之間的分隔字元是分行符號,每條記錄又由若干個欄位組成,這些欄位包括使用者名稱、密碼、使用者id、組id、個人資訊、主目錄、登入Shell,欄位之間的分隔字元是:號。解析這樣的字串需要根據分隔字元把字串分割成幾段,C標準庫提供的strtok函數可以很方便地完成分割字串的操作。tok是Token的縮寫,分割出來的每一段字串稱為一個Token。
#include <string.h>char *strtok(char *str, const char *delim);char *strtok_r(char *str, const char *delim, char **saveptr);傳回值:返回指向下一個Token的指標,如果沒有下一個Token了就返回NULL
參數str是待分割的字串,delim是分隔字元,可以指定一個或多個分隔字元,strtok遇到其中任何一個分隔字元就會分割字串。看下面的例子。
例 25.2. strtok
#include <stdio.h>#include <string.h>int main(void){ char str[] = "root:x::0:root:/root:/bin/bash:"; char *token; token = strtok(str, ":"); printf("%s\n", token); while ( (token = strtok(NULL, ":")) != NULL) printf("%s\n", token); return 0;}
$ ./a.outrootx0root/root/bin/bash
結合這個例子,strtok的行為可以這樣理解:冒號是分隔字元,把"root:x::0:root:/root:/bin/bash:"這個字串分隔成"root"、"x"、""、"0"、"root"、"/root"、"/bin/bash"、""等幾個Token,但Null 字元串的Token被忽略。第一次調用要把字串首地址傳給strtok的第一個參數,以後每次調用第一個參數只要傳NULL就可以了,strtok函數自己會記住上次處理到字串的什麼位置(顯然這是通過strtok函數中的一個靜態指標變數記住的)。
用gdb跟蹤這個程式,會發現str字串被strtok不斷修改,每次調用strtok把str中的一個分隔字元改成'\0',分割出一個小字串,並返回這個小字串的首地址。
(gdb) startBreakpoint 1 at 0x8048415: file main.c, line 5.Starting program: /home/akaedu/a.outmain () at main.c:55 {(gdb) n6 char str[] = "root:x::0:root:/root:/bin/bash:";(gdb)9 token = strtok(str, ":");(gdb) display str1: str = "root:x::0:root:/root:/bin/bash:"(gdb) n10 printf("%s\n", token);1: str = "root\000x::0:root:/root:/bin/bash:"(gdb)root11 while ( (token = strtok(NULL, ":")) != NULL)1: str = "root\000x::0:root:/root:/bin/bash:"(gdb)12 printf("%s\n", token);1: str = "root\000x\000:0:root:/root:/bin/bash:"(gdb)x11 while ( (token = strtok(NULL, ":")) != NULL)1: str = "root\000x\000:0:root:/root:/bin/bash:"
剛才提到在strtok函數中應該有一個靜態指標變數記住上次處理到字串中的什麼位置,所以不需要每次調用時都把字串中的當前處理位置傳給strtok,但是在函數中使用靜態變數是不好的,以後會講到這樣的函數是不可重新進入的。strtok_r函數則不存在這個問題,它的內部沒有靜態變數,調用者需要自己分配一個指標變數來維護字串中的當前處理位置,每次調用時把這個指標變數的地址傳給strtok_r的第三個參數,告訴strtok_r從哪裡開始處理,strtok_r返回時再把新的處理位置寫回到這個指標變數中(這是一個Value-result參數)。strtok_r末尾的r就表示可重新進入(Reentrant),這個函數不屬於C標準庫,是在POSIX標準中定義的。關於strtok_r的用法Man Page上有一個很好的例子:
例 25.3. strtok_r
#include <stdio.h>#include <stdlib.h>#include <string.h>int main(int argc, char *argv[]){ char *str1, *str2, *token, *subtoken; char *saveptr1, *saveptr2; int j; if (argc != 4) { fprintf(stderr, "Usage: %s string delim subdelim\n", argv[0]); exit(EXIT_FAILURE); } for (j = 1, str1 = argv[1]; ; j++, str1 = NULL) { token = strtok_r(str1, argv[2], &saveptr1); if (token == NULL) break; printf("%d: %s\n", j, token); for (str2 = token; ; str2 = NULL) { subtoken = strtok_r(str2, argv[3], &saveptr2); if (subtoken == NULL) break; printf(" --> %s\n", subtoken); } } exit(EXIT_SUCCESS);}
$ ./a.out 'a/bbb///cc;xxx:yyy:' ':;' '/'1: a/bbb///cc --> a --> bbb --> cc2: xxx --> xxx3: yyy --> yyy
a/bbb///cc;xxx:yyy:這個字串有兩級分隔字元,一級分隔字元是:號或;號,把這個字串分割成a/bbb///cc、xxx、yyy三個子串,二級分隔字元是/,只有第一個子串中有二級分隔字元,它被進一步分割成a、bbb、cc三個子串。由於strtok_r不使用靜態變數,而是要求調用者自己儲存字串的當前處理位置,所以這個例子可以在按一級分隔字元分割整個字串的過程中穿插著用二級分隔字元分割其中的每個子串。建議讀者用gdb的display命令跟蹤argv[1]、saveptr1和saveptr2,以理解strtok_r函數的工作方式。
Man Page的BUGS部分指出了用strtok和strtok_r函數需要注意的問題:
-
這兩個函數要改寫字串以達到分割的效果
-
這兩個函數不能用於常量字串,因為試圖改寫.rodata段會產生段錯誤
-
在做了分割之後,字串中的分隔字元就被'\0'覆蓋了
-
strtok函數使用了靜態變數,它不是安全執行緒的,必要時應該用可重新進入的strtok_r函數,以後再詳細介紹“可重新進入”和“安全執行緒”這兩個概念
習題
1、出於練習的目的,strtok和strtok_r函數非常值得自己動手實現一遍,在這個過程中不僅可以更深刻地理解這兩個函數的工作原理,也為以後理解“可重新進入”和“安全執行緒”這兩個重要概念打下基礎。
2、解析URL中的路徑和查詢字串。動態網頁的URL末尾通常帶有查詢,例如:
http://www.google.cn/search?complete=1&hl=zh-CN&ie=GB2312&q=linux&meta=
http://www.baidu.com/s?wd=linux&cl=3
比如上面第一個例子,http://www.google.cn/search是路徑部分,?號後面的complete=1&hl=zh-CN&ie=GB2312&q=linux&meta=是查詢字串,由五個“key=value”形式的索引值對(Key-value Pair) 組成,以&隔開,有些鍵對應的值可能是Null 字元串,比如這個例子中的鍵meta。
現在要求實現一個函數,傳入一個帶查詢字串的URL,首先檢查輸入格式的合法性,然後對URL進行切分,將路徑部分和各索引值對分別傳出,請仔細設計函數介面以便傳出這些字串。如果函數中有動態分配記憶體的操作,還要另外實現一個釋放記憶體的函數。完成之後,為自己設計的函數寫一個Man Page。