在《Simple Dynamic Strings(SDS)源码解析和使用说明一》文中,我们分析了SDS库中数据的基本结构和创建、释放等方法。本文将介绍其一些其他方法及实现。(转载请指明出于breaksoftware的csdn博客)
SDS库提供下面两种方法进行字符串连接
sds sdscatlen(sds s, const void *t, size_t len);
sds sdscat(sds s, const char *t);
sdscat函数在底层使用了sdscatlen去实现。sdscatlen方法第一个元素是需要被连接的SDS字符串,第二个参数是需要连接的内容起始地址,第三个是其内容的长度。和C语言中的连接函数不同,sdscatlen方法并不要求追加的内容要以NULL结尾,因为SDS字符串可以在内容中间承载NULL字符。但是sdscat则需要追加的字符串以NULL结尾,因为它没有提供长度参数。我们看下它们的实现:
sds sdscat(sds s, const char *t) {
return sdscatlen(s, t, strlen(t));
}
sds sdscatlen(sds s, const void *t, size_t len) {
size_t curlen = sdslen(s);
s = sdsMakeRoomFor(s,len);
if (s == NULL) return NULL;
memcpy(s+curlen, t, len);
sdssetlen(s, curlen+len);
s[curlen+len] = '\0';
return s;
}
sdscatlen方法中通过sdsMakeRoomFor方法获取需要被追加的sds对象,然后通过memcpy追加相关的内容到该对象的字符串末尾。最后修改SDS字符串中代表已经使用了空间长度的字段len,并把最后一位设置为NULL。我们需要关注下sdsMakeRoomFor方法的实现
sds sdsMakeRoomFor(sds s, size_t addlen) {
void *sh, *newsh;
size_t avail = sdsavail(s);
size_t len, newlen;
char type, oldtype = s[-1] & SDS_TYPE_MASK;
int hdrlen;
/* Return ASAP if there is enough space left. */
if (avail >= addlen) return s;
sdsMakeRoomFor方法首先需要知道被追加的SDS字符串还有多少空余的空间,这步计算通过sdsavail方法实现,其实现也很简单,我们以SDS_TYPE_5和SDS_TYPE_8为例:
static inline size_t sdsavail(const sds s) {
unsigned char flags = s[-1];
switch(flags&SDS_TYPE_MASK) {
case SDS_TYPE_5: {
return 0;
}
case SDS_TYPE_8: {
SDS_HDR_VAR(8,s);
return sh->alloc - sh->len;
}
从这个设计可以看出,作者认为如果调用sdsavail方法时,这个SDS字符串可能是需要扩展空间了。如果此时它的类型是SDS_TYPE_5,则不经过任何计算,直接认为可用空间不够。如果不是空间最小的类型,则通过分配的了空间大小alloc减去已使用的空间大小len计算出还可用的空间大小。
再回到sdsMakeRoomFor方法中,如果判断发现SDS字符串剩余空间的大小足以承载追加的内容,则直接返回入参字符串对象。如果不够,则需要计算需要的长度
len = sdslen(s);
sh = (char*)s-sdsHdrSize(oldtype);
newlen = (len+addlen);
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;
SDS_MAX_PREALLOC是1M的空间,如果追加的长度和原始长度之和在1M以内,则新的空间是它们和的2倍大;如果大于1M,则在它们之和的基础上增加1M。这就是预分配的逻辑,这样设计可以预防频繁的的内存分配操作,当然相应的也会增加一定的内存浪费。但是总的来说,在目前CPU资源比内存资源贵的场景下,空间换时间还是比较好的。
然后通过新空间的大小匹配SDS字符串类型,如果新老类型相同,则直接使用realloc操作扩展空间。如果类型不同,则需要重新分配一个空间,将老空间里内容复制过来,最后还要将老空间释放掉。
type = sdsReqType(newlen);
/* Don't use type 5: the user is appending to the string and type 5 is
* not able to remember empty space, so sdsMakeRoomFor() must be called
* at every appending operation. */
if (type == SDS_TYPE_5) type = SDS_TYPE_8;
hdrlen = sdsHdrSize(type);
if (oldtype==type) {
newsh = s_realloc(sh, hdrlen+newlen+1);
if (newsh == NULL) return NULL;
s = (char*)newsh+hdrlen;
} else {
/* Since the header size changes, need to move the string forward,
* and can't use realloc */
newsh = s_malloc(hdrlen+newlen+1);
if (newsh == NULL) return NULL;
memcpy((char*)newsh+hdrlen, s, len+1);
s_free(sh);
s = (char*)newsh+hdrlen;
s[-1] = type;
sdssetlen(s, len);
}
sdssetalloc(s, newlen);
return s;
}
sdsMakeRoomFor方法在之后的代码中我们会反复见到,但是见到它就要有个印象:通过它操作的SDS字符串可能是原始的,也可能是将原始空间释放后重新分配的。
我们再看下它们的使用样例:
sds s = sdsempty();
s = sdscat(s, "Hello ");
s = sdscat(s, "World!");
printf("%s\n", s);
output> Hello World!
SDS字符串库还提供了连接两个SDS字符串的方法
sds sdscatsds(sds s, const sds t);
其底层还是调用了sdscatlen方法
sds sdscatsds(sds s, const sds t) {
return sdscatlen(s, t, sdslen(t));
}
它的使用方法是:
sds s1 = sdsnew("aaa");
sds s2 = sdsnew("bbb");
s1 = sdscatsds(s1,s2);
sdsfree(s2);
printf("%s\n", s1);
output> aaabbb
还有一个特殊的方法,它是用于扩张SDS字符串的长度
sds sdsgrowzero(sds s, size_t len);
如果入参s的长度已经大于等于len了,则不作任何操作;否则增加s的长度到len,并使用NULL去填充多出来的空间。它的实现和sdscatlen很像,只是填充的长度是总长,而不是追加的长度;填充的字符是NULL而已。
sds sdsgrowzero(sds s, size_t len) {
size_t curlen = sdslen(s);
if (len <= curlen) return s;
s = sdsMakeRoomFor(s,len-curlen);
if (s == NULL) return NULL;
/* Make sure added region doesn't contain garbage */
memset(s+curlen,0,(len-curlen+1)); /* also set trailing \0 byte */
sdssetlen(s, len);
return s;
}
我们看下使用例子:
sds s = sdsnew("Hello");
s = sdsgrowzero(s,6);
s[5] = '!'; /* We are sure this is safe because of sdsgrowzero() */
printf("%s\n', s);
output> Hello!
字符串格式化是非常有用的工具,它可以让我们通过指定格式生成一个字符串。SDS字符串库也提供了相应的方法:
sds sdscatprintf(sds s, const char *fmt, ...) {
va_list ap;
char *t;
va_start(ap, fmt);
t = sdscatvprintf(s,fmt,ap);
va_end(ap);
return t;
}
这个方法底层调用了sdscatvprintf方法:
sds sdscatvprintf(sds s, const char *fmt, va_list ap) {
va_list cpy;
char staticbuf[1024], *buf = staticbuf, *t;
size_t buflen = strlen(fmt)*2;
/* We try to start using a static buffer for speed.
* If not possible we revert to heap allocation. */
if (buflen > sizeof(staticbuf)) {
buf = s_malloc(buflen);
if (buf == NULL) return NULL;
} else {
buflen = sizeof(staticbuf);
}
/* Try with buffers two times bigger every time we fail to
* fit the string in the current buffer size. */
while(1) {
buf[buflen-2] = '\0';
va_copy(cpy,ap);
vsnprintf(buf, buflen, fmt, cpy);
va_end(cpy);
if (buf[buflen-2] != '\0') {
if (buf != staticbuf) s_free(buf);
buflen *= 2;
buf = s_malloc(buflen);
if (buf == NULL) return NULL;
continue;
}
break;
}
/* Finally concat the obtained string to the SDS string and return it. */
t = sdscat(s, buf);
if (buf != staticbuf) s_free(buf);
return t;
}
这和我们之前使用C语言的格式化不同,这个方法将格式化后的字符串追加到入参第一个参数s的后面。怎么感觉第一个参数s非常鸡肋,我们看看调用的例子就感觉到了:
char *name = "Anna";
int loc = 2500;
sds s;
s = sdscatprintf(sdsempty(), "%s wrote %d lines of LISP\n", name, loc);
我们再看一个使用特例:
int some_integer = 100;
sds num = sdscatprintf(sdsempty(),"%d\n", some_integer);
这个方法将一个整形转换成一个字符串。但是这个方法在此次转换场景中还是非常低效的,我们会在之后介绍专门针对整形数字转换成字符串的高效方法。
sds提供下面这种方法将整形数字数字转换成字符串,当然这个转换的效率要比sdscatprintf要高:
sds sdsfromlonglong(long long value) {
char buf[SDS_LLSTR_SIZE];
int len = sdsll2str(buf,value);
return sdsnewlen(buf,len);
}
我们看下sdsll2str为什么比较高效:
int sdsll2str(char *s, long long value) {
char *p, aux;
unsigned long long v;
size_t l;
/* Generate the string representation, this method produces
* an reversed string. */
v = (value < 0) ? -value : value;
p = s;
do {
*p++ = '0'+(v%10);
v /= 10;
} while(v);
if (value < 0) *p++ = '-';
/* Compute length and add null term. */
l = p-s;
*p = '\0';
/* Reverse the string. */
p--;
while(s < p) {
aux = *s;
*s = *p;
*p = aux;
s++;
p--;
}
return l;
}
这个方法底层没有使用字符串格式化这种比较通用但是效率不高的方法,它将入参整形数字从后向前逐个分解出来转换成字符,然后再逆序将这些字符写回到字符串内存空间中。这个函数还可以处理负数,相应的SDS字符串库还提供了针对无符号整形数的转换函数sdsull2str,其实现和sdsll2str非常类似,这儿我就不详细说明了。
字符串裁剪操作在实际工作中也是常用的。SDS字符串库通过下面这个方法实现裁剪
sds sdstrim(sds s, const char *cset) {
char *start, *end, *sp, *ep;
size_t len;
sp = start = s;
ep = end = s+sdslen(s)-1;
while(sp <= end && strchr(cset, *sp)) sp++;
while(ep > sp && strchr(cset, *ep)) ep--;
len = (sp > ep) ? 0 : ((ep-sp)+1);
if (s != sp) memmove(s, sp, len);
s[len] = '\0';
sdssetlen(s,len);
return s;
}
该函数第二个参数是一个C语言的字符串,其以NULL结尾,包含了需要被裁剪掉的字符。这个裁剪操作通过两个while循环,分别从待裁剪的字符串最前和最后两个位置向另一个方向进行检索,只要遇到需要被裁剪的就继续探索下一个字符,如果是不需要裁剪的就终止当前方向的探测和检索。最终确定剩下的字符串的起始地址后,将这段空间内容复制到SDS字符串内容起始处,并设置结尾符NULL和已使用的空间长度记录变量len。我们来看个使用例子:
sds s = sdsnew(" my string\n\n ");
sdstrim(s," \n");
printf("-%s-\n",s);
output> -my string-
可见my和string之间的空格没有被裁剪,虽然它在要被裁剪的字符串列表中。这儿还有个地方需要说明下,该字符串裁剪操作没有进行字符串空间的再分配,而是利用原来的字符串空间进行处理的。
字符串截取操作是通过指定字符串的前后下标方式,截取其区间的内容并返回。这块操作通过下面方法实现的:
void sdsrange(sds s, int start, int end) {
size_t newlen, len = sdslen(s);
if (len == 0) return;
if (start < 0) {
start = len+start;
if (start < 0) start = 0;
}
if (end < 0) {
end = len+end;
if (end < 0) end = 0;
}
newlen = (start > end) ? 0 : (end-start)+1;
if (newlen != 0) {
if (start >= (signed)len) {
newlen = 0;
} else if (end >= (signed)len) {
end = len-1;
newlen = (start > end) ? 0 : (end-start)+1;
}
} else {
start = 0;
}
if (start && newlen) memmove(s, s+start, newlen);
s[newlen] = 0;
sdssetlen(s,newlen);
}
可见其实现就是简单的下标计算,然后内容复制,最后是结尾符NULL设置和已使用空间长度len字段设置。从代码中我们可以看出,用户传入的下标参数可以是正数,也可以是负数。正数代表下标从起始位置开始,负数代表下标从结束位置开始。这个操作也没有进行内存的重新分配。其使用样例见下:
sds s = sdsnew("Hello World!");
sdsrange(s,6,-1);
printf("-%s-\n");
sdsrange(s,0,-2);
printf("-%s-\n");
output> -World!-
output> -World-
字符串复制的操作也非常简单,SDS字符串库提供了两个方法,一个是供C语言字符串使用的
sds sdscpy(sds s, const char *t) {
return sdscpylen(s, t, strlen(t));
}
另一个则是可以复制包含NULL字符的二进制数据的
sds sdscpylen(sds s, const char *t, size_t len) {
if (sdsalloc(s) < len) {
s = sdsMakeRoomFor(s,len-sdslen(s));
if (s == NULL) return NULL;
}
memcpy(s, t, len);
s[len] = '\0';
sdssetlen(s, len);
return s;
}
因为SDS字符串中可以包含二进制字符,所以当我们试图打印出这个字符串时,printf方法可能输出不可见的字符。这在调试一一段数据的时候可能比较有用,SDS提供了下面这个方法将字符串中不可见字符和转义字符都转换成可见的内容:
sds sdscatrepr(sds s, const char *p, size_t len) {
s = sdscatlen(s,"\"",1);
while(len--) {
switch(*p) {
case '\\':
case '"':
s = sdscatprintf(s,"\\%c",*p);
break;
case '\n': s = sdscatlen(s,"\\n",2); break;
case '\r': s = sdscatlen(s,"\\r",2); break;
case '\t': s = sdscatlen(s,"\\t",2); break;
case '\a': s = sdscatlen(s,"\\a",2); break;
case '\b': s = sdscatlen(s,"\\b",2); break;
default:
if (isprint(*p))
s = sdscatprintf(s,"%c",*p);
else
s = sdscatprintf(s,"\\x%02x",(unsigned char)*p);
break;
}
p++;
}
return sdscatlen(s,"\"",1);
}
这个方法的实现原理也很简单。对于可见字符,则直接显示。对于被反斜杠转义的字符,增加一个反斜杠使得反斜杠自身被转义,从而显示出可打印的内容。对于剩下的不可见的,则将其转成8进制数字输出。我们看下例子:
sds s1 = sdsnew("abcd");
sds s2 = sdsempty();
s[1] = 1;
s[2] = 2;
s[3] = '\n';
s2 = sdscatrepr(s2,s1,sdslen(s1));
printf("%s\n", s2);
output> "a\x01\x02\n"
字符串拆分是指将一个一定规则的字符串,按照某种分隔符进行切分,从而得到一组切分后的字符串的操作。举个例子,我们要对下面这串字符按照|-|为切割符进行切割
foo|-|bar|-|zap
最终得到的结果是一组字符串,它们分别为foo、bar和zap。如果以-为切割符,则切割后的字符串数组包含:foo|、|bar|、|zap。我们看看SDS字符串库通过什么接口完成这个功能的
sds *sdssplitlen(const char *s, int len, const char *sep, int seplen, int *count);
第一个参数是需要被切割的字符串的指针,第二个参数是该字符串的长度。第三个参数是分隔符字符串的起始地址,第四个则是分隔符字符串的长度。通过这种形式传递进去的字符串,在其内容中是可以包含NULL的,因为提供了长度信息就意味着不用以NULL来查找字符串结尾了。该函数的返回值是一个SDS字符串数组的起始地址,这个数组的长度通过第五个参数返回。我们看下其实现:
sds *sdssplitlen(const char *s, int len, const char *sep, int seplen, int *count) {
int elements = 0, slots = 5, start = 0, j;
sds *tokens;
if (seplen < 1 || len < 0) return NULL;
tokens = s_malloc(sizeof(sds)*slots);
if (tokens == NULL) return NULL;
if (len == 0) {
*count = 0;
return tokens;
}
首先预分配了5个槽位用于存储切割留下的数据,然后遍历整个字符串空间,并使用分隔符进行对比,取出分割后的字符串
for (j = 0; j < (len-(seplen-1)); j++) {
/* make sure there is room for the next element and the final one */
if (slots < elements+2) {
sds *newtokens;
slots *= 2;
newtokens = s_realloc(tokens,sizeof(sds)*slots);
if (newtokens == NULL) goto cleanup;
tokens = newtokens;
}
/* search the separator */
if ((seplen == 1 && *(s+j) == sep[0]) || (memcmp(s+j,sep,seplen) == 0)) {
tokens[elements] = sdsnewlen(s+start,j-start);
if (tokens[elements] == NULL) goto cleanup;
elements++;
start = j+seplen;
j = j+seplen-1; /* skip the separator */
}
}
/* Add the final element. We are sure there is room in the tokens array. */
tokens[elements] = sdsnewlen(s+start,len-start);
如果预分配的5个槽位不够,则在填充即将满了的时候,让槽位数量增加一倍。这些操作如果都成功,则返回SDS字符串数组,否则清空整个申请的空间
if (tokens[elements] == NULL) goto cleanup;
elements++;
*count = elements;
return tokens;
cleanup:
{
int i;
for (i = 0; i < elements; i++) sdsfree(tokens[i]);
s_free(tokens);
*count = 0;
return NULL;
}
}
在我们调用该方法获取到切割后的字符串数组后,我们要释放该数组的所有空间,以防止内存溢出问题。释放的方法是:
void sdsfreesplitres(sds *tokens, int count) {
if (!tokens) return;
while(count--)
sdsfree(tokens[count]);
s_free(tokens);
}
最后看下这些方法的使用样例:
sds *tokens;
int count, j;
sds line = sdsnew("Hello World!");
tokens = sdssplitlen(line,sdslen(line)," ",1,&count);
for (j = 0; j < count; j++)
printf("%s\n", tokens[j]);
sdsfreesplitres(tokens,count);
output> Hello
output> World!
之前介绍的字符串拆解方法要求入参字符串是严格按照一定方式布局的。然而用户输入形式的字符串,比如命令行,则非常可能不严格遵守格式。比如命令行中我们一般以空格分隔调用程序和其参数,但是并不严格要求使用几个空格去分隔
call "Sabrina" and "Mark Smith\n"
上面这个命令行式的字符串则不能使用之前介绍的通过分隔符切割出各个字符串的方法,这时要准确切割需要使用:
sds *sdssplitargs(const char *line, int *argc)
这个方法第一个参数是待切割的字符串首地址,当然是以NULL结尾的。返回值是切割后的字符串数组首地址,第二个参数是用于传出这个数组的长度。它的实现就是从头向尾遍历整个字符串空间,然后切分出各个字符串。由于过程比较简单,但是代码比较长,我就不在这儿贴出来了。唯一要说的,可能引号匹配的场景截取稍微复杂点,因为引号里的空格是不能当成分隔符的。我们看下通过上面函数切分本节例子的结果
"call"
"Sabrina"
"and"
"Mark Smith\n"
有些场景下,我们会需要将“字符串切割成字符串数组”的行为逆过来,让字符串数组中对象逐个连接变成一个字符串。SDS字符串库提供了如下两个方法:
sds sdsjoin(char **argv, int argc, char *sep, size_t seplen);
sds sdsjoinsds(sds *argv, int argc, const char *sep, size_t seplen);
sdsjoin方法针对C语言的字符串数组。第一个参数是字符串数组首地址。第二个参数是该数组的长度。第三个参数是连接各个字符串元素的“分隔符”字符串首地址。第四个参数是“分隔符”字符串的长度。sdsjoinsds方法则是针对sds字符串数组的。从这种参数设计可以看出,分隔符是可以包含NULL的,因为它提供了其长度信息。它们的实现也很简单:
sds sdsjoin(char **argv, int argc, char *sep) {
sds join = sdsempty();
int j;
for (j = 0; j < argc; j++) {
join = sdscat(join, argv[j]);
if (j != argc-1) join = sdscat(join,sep);
}
return join;
}
sds sdsjoinsds(sds *argv, int argc, const char *sep, size_t seplen) {
sds join = sdsempty();
int j;
for (j = 0; j < argc; j++) {
join = sdscatsds(join, argv[j]);
if (j != argc-1) join = sdscatlen(join,sep,seplen);
}
return join;
}
我们再看下其使用样例:
char *tokens[3] = {"foo","bar","zap"};
sds s = sdsjoin(tokens,3,"|",1);
printf("%s\n", s);
output> foo|bar|zap
SDS字符串在初次创建时,其分配空间大小就是使用了的空间大小。但是由于字符串连接等操作,会触发sdsMakeRoomFor方法,从而产生预分配的现象。这个时候往往被使用的空间大小只占已分配空间的一半。在大部分场景下,这种设计没有什么问题。但是对于内存特别紧张的时候,可能需要缩减这些字符串空间。SDS提供了如下方法实现空间缩减:
sds sdsRemoveFreeSpace(sds s) {
void *sh, *newsh;
char type, oldtype = s[-1] & SDS_TYPE_MASK;
int hdrlen;
size_t len = sdslen(s);
sh = (char*)s-sdsHdrSize(oldtype);
type = sdsReqType(len);
hdrlen = sdsHdrSize(type);
if (oldtype==type) {
newsh = s_realloc(sh, hdrlen+len+1);
if (newsh == NULL) return NULL;
s = (char*)newsh+hdrlen;
} else {
newsh = s_malloc(hdrlen+len+1);
if (newsh == NULL) return NULL;
memcpy((char*)newsh+hdrlen, s, len+1);
s_free(sh);
s = (char*)newsh+hdrlen;
s[-1] = type;
sdssetlen(s, len);
}
sdssetalloc(s, len);
return s;
}
这个操作一定会产生内存重分配的问题,所以它还是比较消耗效率的。好在需要它的场景不太多。
C语言中的字符串是以NULL结尾的,而SDS字符串可以包含NULL。如果我们希望SDS字符串按照C语言字符串格式一样,以NULL结尾,则可以调用如下方法:
void sdsupdatelen(sds s) {
int reallen = strlen(s);
sdssetlen(s, reallen);
}
这步操作非常简单,它只是以C语言字符串方式重新计算长度,并设置长度信息。它并没有进行字符串空间的重分配。我们看下例子:
sds s = sdsnew("foobar");
s[2] = '\0';
printf("%d\n", sdslen(s));
sdsupdatelen(s);
printf("%d\n", sdslen(s));
output> 6
output> 2
基于上面的介绍,我们可以得知SDS字符串存在如下的特点:
sds mystring = sdsnew("Hello World!");
mystring[0] = 'h';
printf("%s\n", mystring);
……
output>hello World!
当然它也有相应的缺陷:
本文的很多知识和样例来源于GitHub上的SDS说明:https://github.com/antirez/sds。我本来是想翻译这篇文章的,但是翻译过后感觉如果是内容直译可能不太符合大众的阅读口味,所以我就穿插的代码将它重新写了一遍。