对一个序列进行循环遍历操作是再平常不过的操作。
此处列举这么一个场景,需要对对两个字符串进行拷贝。
在C语言中void* memcpy( void *dest, const void *src, size_t count );
可以帮我们完成。
#include
#include
int main() {
char src[128] = "Hello World!";
char dest[128] = "";
memcpy(dest, src, sizeof(src));
printf("dest = %s\n", dest);
printf("src = %s\n", src);
return 0;
}
现在我们要自己实现一个这样的函数,我们定义如下的接口。
#include
#include
/**
* @brief
* 实现类 void* memcpy( void *dest, const void *src, size_t count );
* 的字符串拷贝功能
* @param dest 目标串的首地址
* @param src 被复制串的首地址
* @param len 复制长度
*/
void my_memcpy(char* dest, char* src, int len) ;
int main() {
char src[128] = "Hello World!";
char dest[128] = "";
my_memcpy(dest, src, strlen(src));
printf("dest = %s\n", dest);
printf("src = %s\n", src);
return 0;
}
最直接的办法便是直接遍历赋值。
但是对于这个循环,循环体内的操作非常简单,但是循环判断条件确一次也不能少。
这种情况通常for中的判断开销比循环体内更大。
void my_memcpy(char* dest, char* src, int len) {
for (int i = 0; i < len; i += 1) {
*dest++ = *src++;
}
}
这里对不熟悉C语言的朋友做简单解释。
*dest++ = *src++;
中++
运算级在*
之前。具体的,是在指针进行递增操作的同时进行解引用操作(取值操作),然后进行赋值。
这样在下一轮操作中,dest和src就能到下一个位置。保证两者的操作相对位置一致。
可以近似的理解为等效于下方的操作:
for (int i = 0; i < len; i += 1) { dest[i] = src[i]; }
尝试以BASE=8次操作为一轮进行计算。
分别对余量进行操作,这里的余量只可能是[0, 8]闭区间的数量。
然后对每一组进行8次相同的操作。
从而达到在代码中直接增加操作次数,且循环判断次数以8倍的速率降低。
具体的,我们可以写出如下形式的代码。
code1
void my_memcpy(char* dest, char* src, int len) {
const int BASE = 8;
// 处理余量
int remainder = len % BASE;
while (remainder--) {
*dest++ = *src++;
}
// 以base为一组进行计算
int cnt = len / BASE;
while (cnt--) {
*dest++ = *src++;
*dest++ = *src++;
*dest++ = *src++;
*dest++ = *src++;
*dest++ = *src++;
*dest++ = *src++;
*dest++ = *src++;
*dest++ = *src++;
}
}
code2
void my_memcpy(char* dest, char* src, int len) {
const int BASE = 8;
// 处理余量
switch (len % BASE) {
case 7: *dest++ = *src++;
case 6: *dest++ = *src++;
case 5: *dest++ = *src++;
case 4: *dest++ = *src++;
case 3: *dest++ = *src++;
case 2: *dest++ = *src++;
case 1: *dest++ = *src++;
}
// 以base为一组进行计算
int cnt = len / BASE;
while (cnt--) {
*dest++ = *src++;
*dest++ = *src++;
*dest++ = *src++;
*dest++ = *src++;
*dest++ = *src++;
*dest++ = *src++;
*dest++ = *src++;
*dest++ = *src++;
}
}
上方的代码已经达到了我们的基本目的。但是一眼望去是两部分的代码块,并不是非常的美观。
下面进行本文的重点描述,
Duff’s Device
。
首先我们需要对C/C++中的switch操作有扎实的理解。
在switch语句中,最常出现的就是case /**/ : 和 default :
。
这两者的本质是跳转的标签,就像goto
语法中的标签一样,从switch的判断中直接跳转过来。
而这些标签本身不能构成域,因此上下都能添加各种语句(块)。
这样我们可以把case的标签对应到while的8条操作语句中。为了方便,这里使用do{}while(bool);
的形式。
while一次执行8条是固定的,但是这个余量该如何处理呢?答案很简单,将case的编号顺序逆向放置即可。
如此时len % BASE == 3
那程序会直接跳转到case 3:
处,由于没有break,continue等操作,后面的case 2: case 1:
后的操作也会执行。这样就完美的执行了3次操作。
而随之带来的一个小细节是,我们需要将len/BASE
的组数进行上取整,且while中的判断需要用while(--cnt)
的操作。
void my_memcpy(char* dest, char* src, int len) {
const int BASE = 8;
// 取上整
int cnt = (len + BASE - 1) / BASE;
// 余数正常计算
switch (len % BASE) {
do {
case 0: *dest++ = *src++;
case 7: *dest++ = *src++;
case 6: *dest++ = *src++;
case 5: *dest++ = *src++;
case 4: *dest++ = *src++;
case 3: *dest++ = *src++;
case 2: *dest++ = *src++;
case 1: *dest++ = *src++;
}
// 注意cnt的运算顺序
while (--cnt);
}
}
可惜的是,这种语法操作在部分语言中并不支持,如java。
下面我们增大数据量,并用定时器进行简单的性能测试。
框架进行测试如下。
#include
#include
#include
#define STR_LENGTH 1000000
#define TEST_COUNT 5
#define RUN_COUNT 1000
void my_memcpy(char* dest, char* src, int len);
int main() {
int test_count = TEST_COUNT;
char src[STR_LENGTH];
char dest[STR_LENGTH];
while (test_count--) {
int run_count = RUN_COUNT;
clock_t start = clock();
while (run_count--) {
my_memcpy(dest, src, STR_LENGTH);
}
clock_t stop = clock();
printf("run time = %ld\n", (stop - start));
}
return 0;
}
增加运算次数,减少判断次数
的代码不进行测试。
run time = 2177
run time = 2067
run time = 1991
run time = 2009
run time = 1996
令人惊讶的事情出现了,Duff’s Device的操作居然没有普通for循环暴力快。
甚至还比其更慢了一点。可见现代编译器对基础循环做了很大的优化。
run time = 2266
run time = 2139
run time = 2232
run time = 2239
run time = 2421
再尝试一下,内部的memcpy()
。直接快的飞起。
像``memcpy() memset()`这类函数,在现代多数编译器中会进一步的优化。
具体如何操作,那就要看具体的编译器是怎么实现的了,一些资料显示这类函数会专门调用一些特定的汇编指令,极大的增加了运算的速度。
#include
void my_memcpy(char* dest, char* src, int len) {
memcpy(dest, src, len);
}
run time = 80
run time = 69
run time = 53
run time = 56
run time = 55
参考资料:How does Duff’s Device work?