C语言下实现的String库

45次阅读

共计 4946 个字符,预计需要花费 13 分钟才能阅读完成。

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 设计与实现
https://stackoverflow.com/que…
https://redis.io/topics/inter…

正文完
 0