C語言的String
C語言作爲一門古老的高級語言,對於字符串的支持十分的薄弱。
入門時我們就知道我們使用數組來包含一串的ASCII字符來作爲字符串的實現,如
char arr[] = "hello world!";
這樣基於長度固定的數組的實現方式就導致了C的字符串的長度是不可變的,但是arr[]
的內容卻是可變的。
這樣的設計導致很多時候我們對字符串的處理十分的麻煩與危險,像我之前寫的哈夫曼編碼解碼的時候,爲了盛放解碼後的結果,我不得不創建一個非常大的靜態數組或者動態分配內存來放置函數產生的長度不定的字符串。
相較於其後輩(如Python/Java,C++基本兼容C的語法,儘管C++實現了自己的string類),C在很多方面也是比較異類的,比如C使用'\0'
來標誌字符串的結束,因而len(arr)
這樣的操作的複雜度就達到了O(n),這是一個比較大的開銷,而Pascal/Python等的實現都可以做到O(1),同時,由於char
類型本身就是最短的整型再加上C語言的弱類型的類型系統,'a'- 32
也是完全有效的語法,而在Python中這會引發*TypeError*
. 這些問題在C語言誕生的年代不是大問題,畢竟當時沒有那麼多字符串的處理需求,而且C主要的應用場景也比較偏底層。
而現在,一些選擇C實現的程序需要頻繁的處理字符串(如 Redis
,需要頻繁的處理鍵值對),爲了應對這種場景,很多很有意思的自己的String實現都被提了出來。
在這裏我主要是介紹ccan的xstring和sds的一些實現的思路。
xstring
/**
* struct xstring - string metadata
* @str: pointer to buf
* @len: current length of buf contents
* @cap: maximum capacity of buf
* @truncated: -1 indicates truncation
*/
typedef struct xstring {
char *str;
size_t len;
size_t cap;
int truncated;
} xstring;
xstring *xstrNew(const size_t size)
{
char *str;
xstring *x;
if (size < 1) {
errno = EINVAL;
return NULL;
}
str = malloc(size);//mark 1
if (!str)
return NULL;
x = malloc(sizeof(struct xstring));//mark 2
if (!x) {
free(str);
return NULL;
}
xstrInit(x, str, size, 0);
return x;
}
透過xstring
結構體與*xstrNew(const size_t size)
這個創建新的xstring
的函數,ccan
的這個實現的思路就比較清晰了,xstring
結構體本身佔據內存,但是並不存儲字符串,字符串在mark 1被分配存儲空間,而結構體在mark 2被分配內存。
PS:
在剛剛學習使用C來實現數據結構的時候,我很疑惑爲何不能直接
struct xstring* newStruct(){
struct xstring s;
return &s;
}
直到後來才逐漸明白了棧上的變量與動態分配的變量的微妙的區別,s在這個函數返回後就已經被銷燬了,傳出的這個地址是無效的,而對他的引用很可能會導致段錯誤(segment fault),操作系統,編譯原理等課真的會讓自己對於程序設計語言獲得更深的理解。
而且這種寫法當時很有吸引力,畢竟不用malloc,不用強制類型轉換。
這種野指針是很多很難修正的錯誤的來源,有興趣的同學可以去學習一下Rust語言的所有權系統,很多的概念很有意思。
| xstring
| -> | str
|
可以看出xstring
的實現中內存是分爲兩個部分的。
Note: xstring只需要編譯器支持C89/90。
sds
redis sds(simple dynamic string)是Redis對於str的實現,在這裏有官方對於sds實現的一些技巧的介紹,
在這裏我會將SDS實現的主要的細節介紹以下。
// sds 類型
typedef char *sds;
// sdshdr 結構
struct sdshdr {
// buf 已佔用長度
int len;
// buf 剩餘可用長度
int free;
// 實際保存字符串數據的地方
// 利用c99(C99 specification 6.7.2.1.16)中引入的 flexible array member,通過buf來引用sdshdr後面的地址,
// 詳情google "flexible array member"
char buf[];
};
和上面的實現不太一樣的是sds只存儲存儲的字符串長度以及剩餘長度,但是最引人矚目的無疑是最後的那一個數組聲明:
char buf[];
結構體中竟然沒有聲明數組的大小,這樣好像與我們對於數組一貫的印象不符,但是這是合法的特性,叫做柔性數組。
具體的語法細節我不再介紹,但是注意以下幾點
-
sizeof(struct sdshdr) == sizeof(len) + sizeof(buf)
,在x86_64上典型值應該爲8個字節(4 + 4),這說明buf[]
沒有實際佔據空間,一個64位系統下的指針就要8個字節。 -
上面的寫法是C99 only的,這個特性應該來自於以下這種寫法,
struct header { size_t len; unsigned char data[1]; };
這種寫法下
data
就是一個unsigned char*
型的指針,可以通過它用來訪問存儲的字符串。//在分配內存的時候,結構體中存儲了一個字符,其他的(n-1)個空間在 //緊隨結構體結束地址的地方 // | struct (char) | (n - 1) char | ptr = malloc(sizeof(struct header) + (n-1));
對比
sds
中的實現,sds
中不存儲任何一個數據,只有一個不佔據內存空間的標記代表,所有的數據都存儲在結構體所佔空間後面| struct |
str
|
我們來看這有什麼用:
/*
* 返回 sds buf 的已佔用長度
*/
static inline size_t sdslen(const sds s) {
struct sdshdr *sh = (void*)(s-(sizeof(struct sdshdr)));
return sh->len;
}
/*
* 返回 sds buf 的可用長度
*/
static inline size_t sdsavail(const sds s) {
struct sdshdr *sh = (void*)(s-(sizeof(struct sdshdr)));
return sh->free;
}
/*
* 創建一個指定長度的 sds
* 如果給定了初始化值 init 的話,那麼將 init 複製到 sds 的 buf 當中
*
* T = O(N)
*/
sds sdsnewlen(const void *init, size_t initlen) {
struct sdshdr *sh;
// 有 init ?
// O(N)
if (init) {
sh = zmalloc(sizeof(struct sdshdr)+initlen+1);
} else {
sh = zcalloc(sizeof(struct sdshdr)+initlen+1);
}
// 內存不足,分配失敗
if (sh == NULL) return NULL;
sh->len = initlen;
sh->free = 0;
// 如果給定了 init 且 initlen 不爲 0 的話
// 那麼將 init 的內容複製至 sds buf
// O(N)
if (initlen && init)
memcpy(sh->buf, init, initlen);
// 加上終結符
sh->buf[initlen] = '\0';
// 返回 buf 而不是整個 sdshdr
return (char*)sh->buf;
}
我們創建一個新的sds的時候,分配sizeof(struct sdshdr) + len + 1
大小的空間,len代表不包含結束符號在內的容量,最後我們返回的是字符串開始的地址,這個返回的地址可以直接作爲一般的字符串被其他庫函數等使用,即Redis所說的二進制兼容的(因爲其內部也使用'0'結尾)。
同時結構體的地址可以通過用字符串的地址減去結構體的大小得到
struct sdshdr *sh = (void*)(s-(sizeof(struct sdshdr)));
這樣一來sds可以在常數時間內獲得字符串的長度。
#include <stdio.h>
#include "./src/simple_dynamic_str.h"
int main() {
sds s = sdsnew("Hello World! K&R");
printf("%s\n", s);
printf("%zu %zu\n", sdslen(s), sdsavail(s));
printf("%c",s[0]);
return 0;
}
結果:
Hello World! K&R
16 0
H
這種通過指針的計算獲得結構體的地址的方式還是比較少見的技巧,我也只是在Linux內核的task_struct
結構體中見識過類似的技巧,當然那個更復雜。
這種操作是很危險的,但是C數組在這方面其實也沒有好多少(並沒有多出數組越界檢查等),不是嗎?
在字符串較短時,結構體佔據放入空間是比較可觀的,更新版本的Redis
優化了不同長度的字符串結構體的定義。
/* Note: sdshdr5 is never used, we just access the flags byte directly.
* However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
總結
這篇文章中有些技巧還是有些難度的,像sds
我也是花了一些時間才弄明白其原理,這裏的兩種實現我個人更偏愛第二種,但是這畢竟是二等公民,沒有語言級別的支持是硬傷。
所以如果真的需要大量處理字符串,特別是非純ASCII碼,左轉Java/Python etc.
reference:
[redis sds(simple dynamic string)]()
[ccan xstring]()
Redis設計與實現