C99的新特性(3)

发现最后这部分的内容花费的时间要比我想像的多。本来以为留到最后说的都是一些比较少用的东西,简单带带也就罢了。结果设计和分析restrict的实验就搞了我快一个晚上~~

复合常量(Compound Literals)

简单来说复合常量就是允许你定义一个匿名的结构体或数组变量。如:

const int *p_array = (const int []) {12};

这样就得到了一个已经被初始化为指向一个整型数组的指针了。

复合常量比较有用的地方在于其可以方便传参。如之前我们通常这样写带超时的select:

struct timeval wait = {.tv_sec = timeout, .tv_usec = 0};
select(sd+1&sready, NULL, NULL, &wait);

有了复合常量以后可以这么写了(你可以省得专为传参而另外定义一个结构体变量):

select(sd+1&sready, NULL, NULL,
       &((struct timeval) {.tv_sec = timeout, .tv_usec = 0}));

同样,参数是个数组的函数也可以这么搞:

func((int []) {1234});

或者,可以利用复合常量给一个结构体变量整体赋值:

point = (point_t) {.x = 1, .y = 2};

这个写法等价于:

point_t tmp = {.x = 1, .y = 2};
point = tmp;

复合常量乍看起来不错,其实仔细想了以后觉得它实在是个可有可无的东西——没有很给力的实用价值。从效率上看用复合常量不像能有什么提升,而从提高可读性的角度看其效果也很有限:比如前面那个select的例子用复合常量改写后可读性实际上并没有提升,反而有些下降——因为这个结构体并非没意义到实在连个名字都不知起啥好的地步。所以除非是这个结构体实在是像路人甲一样没有任何起名价值的时候(比如我上面举的第二个例子),你才可以考虑用复合常量来写它。

btw:其实举的第二个例子也很牵强:我想大家应该还是甘愿用代码来分别为各成员赋值吧。毕竟这是一种习惯的写法,而且在大部分情况下可以只对少数必要的成员赋值(直接对结构体变量做整体赋值编译器实际上是编译成两个结构体之间做memcpy()的)。所以关于复合常量我真是没想出什么特别合适的例子来,而且在我的编码实践中也从来没遇到过什么非用不可的时候。建议各位也不要只是为了用复合常量而用复合常量——否则看来实在是有点卖弄技巧之嫌。但如果你确实找到了不用复合常量就很难把代码写得优雅的例子,麻烦给post在评论里。

复数(Complex Numbers)

C99开始支持complex float/double/long double类型了,这样做信号分析或时域频域变换之类的处理会方便许多。

可以这样在代码里面以a+bi的形式表示一个复数:

#include <complex.h>
double complex cmpl = 1.0 + 2.3 * I; // 1+2.3i
printf("%f + %fi"creal(cmpl), cimag(cmpl));

当然,复数变量之间的加减乘除自然也是照着复数的运算法则来的了。另外<complex.h>里面还包含了不少支持复数的数学函数(c打头的就是),比如三角函数、开方之类的。

restrict指针

restrict是对编译器的一个hinting:该指针是访问其所指数据对象的唯一且初始的方式——所以编译器优化起与restrict指针相关的代码来说就更加容易了。在使用效果上restrict和volatile相反:restrict指针的访存操作会被编译器尽量放心大胆地优化。

干说这些还是很模糊,所以我设计了一个实验能让各位直观地看到restrict修饰符对编译器的优化行为带来的影响。这个实验我是在cygwin+gcc 4的环境下做的,优化级别为-O2。

首先看没有加restrict的情况:

func.c:
void func
(int *a, int *b)
{
    *+= *b;
    *= *b;
}

main.c:
int main
(int argc, char *argv[])
{
    int n = 1;
    func(&n, &n);
    printf("n = %d\n", n);
    return (0);
}

这个程序执行的结果是:n = 2,与我们的预期一致:因为传给func()的a和b都指向相同的整型变量n,所以在执行*a += *b的时候,实际上就把n的值变成了1+1=2。此时再将*b的值赋给*a,就相当于做了个n=n的赋值了。所以n的最终值就是2。

不过在将func()的a,b改为restrict指针以后,情况就变得有趣了:

void func(int *restrict a, int *restrict b)
{
    *+= *b;
    *= *b;
}

修改后的执行结果是:n = 1。我们来看看为啥会出现两个不同的结果,首先看未加restrict时编译器生成的func()汇编码:

push    ebp
mov     ebp, esp
mov     ecx, [ebp+arg_4]
mov     edx, [ebp+arg_0]
mov     eax, [ecx] ; 这里取了一次*b
add     [edx], eax
mov     eax, [ecx] ; 这里又取了一次*b
mov     [edx], eax
pop     ebp
retn

可以发现编译器对*a,*b两个指针的处理非常老实,基本没有做出啥偷懒的行为——因为编译器拿不准这两个指针是否是指向同一个对象,也拿不准指针所指的对象还有没有可能被第三方(别的线程或硬件)并发修改。所以编译器很老实地在每次计算过程中都从内存里取数值。因此这种保守的行为保证了结果的正确。

加了restrict以后,编译器认为开发者可以保证a、b两个指针所指的对象只能通过这两个指针访问,这就意味着:① a、b不可能指向相同的对象,且 ② a、b所指的对象也不存在被第三方并发修改的可能。换句话说在func()函数内掌控了对a、b所指对象的全部读写访问,因此编译器就采取了更为大胆的优化策略生成了这样的汇编码:

push    ebp
mov     ebp, esp
mov     edx, [ebp+arg_4]
mov     eax, [ebp+arg_0]
mov     edx, [edx] ; 注意,这里只取了一次*b
add     [eax], edx ; 遗憾,我还以为这个加法也会被优化掉,没想到还在
mov     [eax], edx ; 因为编译器认为*b不会变,所以第二次直接用了寄存器值
pop     ebp
retn

由于在func函数中*b是“只读”的,所以编译器就放心地优化掉了对*b的访存操作:*b实际只取了一次值,在之后的计算过程中始终用之前取到寄存器中的值以提高效率。而杯具的是,我们在main()里面调用func()时违反了我们对编译器所做的承诺:a、b实际指向的是同一个变量。所以在执行了*a += *b的时候,*a、*b都变成了2。但编译器仍然使用之前读到的1来做后面的赋值操作,从而得到了错误的结果。

所以从这个简单的实验里面可以看出来:① restrict确实对编译器的优化能力有改善,在承诺属实的前提下,能够最大限度提高编译器生成的目标码质量。② restrict是开发者对编译器做出的承诺,但如果承诺不兑现,后果很严重。

restrict既然是一个对编译器的hinting,所以貌似其主要用于提高编译器生成的目标码效率的——所以工程实践的角度来看能用上的地方实在不多。因为首先以减少访存为手段的优化至少要求①你要优化的这个函数必须是系统的热点,并且②你要优化的这个指针所指向的数据结构也要足够“热”。否则指望加个restrict修饰就能给目标效率带来什么明显的优化是不可能的。而再则:即使加了编译器也可能因为条件所限(如寄存器数量的限制),不一定就能生成明显更快的目标码来(个人推测可能在RISC体系里面restrict的效果相对好些,因为RISC体系的访存操作开销大,而处理器寄存器又多有利于编译器做有效优化)。

但有经验的人应该也都知道,即使在这种热点热数据上简单地加个hinting,其作用恐怕远不如对算法和数据结构的精心调整来得显著。所以从提高效率这个角度看restrict的实用价值实在比较有限。但不能说restrict就是个没什么用的东西——在我的实践中,把restrict放在函数参数的声明里面用,对代码的可读性能有不错的提升。因为就跟const一样,函数参数声明中的restrict能起到很好的对函数的前/后置条件(Pre/Postcondition)自注释的作用。

举个例子,看看C89的memset()和memmove()的声明:

void *memcpy(void *const void *, size_t);
void *memmove(void *const void *, size_t);

从这个声明里面你可以很快地看出:第一个参数肯定是拷贝的目的,第二个参数是拷贝的源——因为第二个参数带了const修饰,所以马上就能知道这个指针在函数的处理过程中是只读的,所以它只会是源。因此const在这里起到了两个作用:①自注释:都不用费劲在函数前面写啥注释文档,你也用不着去翻C库手册也能知道两个指针里面到底哪个是源指针那个是目的指针。②做出对调用者的承诺(或者说是自注释了函数的后置条件Postcondition):在函数处理的过程中保证对第二个参数所指内容是只读的。

C99在支持了restrict以后,memset()和memmove()的声明又变成了这样:

void *memcpy(void *restrictconst void *restrict, size_t);
void *memmove(void *const void *, size_t);

这样代码的自注释能力就更强了:memcpy中的restrict提出了一个由调用者承诺满足的前置条件,即这两个指针所指的内存区域不能重叠(否则restrict就不成立)。所以我们又能很快看出:memcpy不能处理两块存在重叠的内存区间的拷贝,而memmove可以。所以代码里面依然是一行注释也没有,仅仅通过多加了几个修饰符,我们就能从中看出这么多的东西,免去RTFM求证到底是从哪拷到哪或者两块内存能不能重叠之类的烦恼!这就是自注释的力量,也是我认为像restrict这种即使不用也不会产生什么副作用的“可有可无”的修饰符的最大价值之所在。

综上,我对restrict的使用建议是:鼓励用在函数参数的声明中,以提高可读性实现自注释。但在函数内部还是少用,因为在看到一个函数里面用了某些以提高效率为目的的写法时,读者或代码维护者可能会有点紧张:这往往说明这个函数效率攸关十分重要,需要你小心翼翼地去对付。通常看到const令人安心,看到volatile和restrict一般都不是啥好事:要么意味着这里有并发的雷区,要么说明这块代码性能重要到连个访存优化也不能放过——所以尽量不要在代码里面到处狼来了,应该只把它用在确实需要的地方。

最后顺便提一下,想自己尝试这个实验的注意一定要把func和main放在不同的c文件里面编译成obj以后在链接为可执行文件。绝对不能偷懒把两个函数都堆在一个c文件里面编译。因为编译器的编译优化是以文件为单位的。如果你把两个函数堆在一块,编译器立马就发现了你的小把戏,无论加不加restrict,得到的结果都是n = 2——实际上在我反汇编以后,发现一个更杯具的情况:堆在一块编译后,gcc干脆直接把结果直接算好了,func函数尸骨无存:

mov     dword ptr [esp+4], 2 ; 耍赖了直接printf最终结果2,func被优化没了
mov     dword ptr [esp], offset aND ; "n = %d\n"
call    printf

关于C99的扩展的使用心得就到这里结束。以上应该涵盖了绝大部分C99相对C89的扩展,也是我自己有实践过觉得值得拿出来说的。不过还有些很少用的或者我觉得实在是没有太大实用价值的特性(如static array parameters等)没有列出来,欢迎各位留言补充。另外不管你是再哪里看到这篇文章的,一定注意如果要留言的话请到这篇文章在我的网易博客的【原始出处】上留,不然我是看不到也不会回的。


原文链接C99的新特性(3)  


你可能感兴趣的:(数据结构,c,优化,编译器,Literals)