- 论坛徽章:
- 2
|
成功经验和失败教训貌似都没什么印象了。。。 就说一些心得体会好了。。。
C标准与现实与实践
C标准对语言的规定很宽松。应该是为了能尽可能地在各种环境上实现。
宽松是指对C语言的实现者很宽松,有各种undefined/unspecified/implementation-defined behavior让实现者有选择空间。
而这种宽松会C程序员不那么好过。不仅仅是指针,各种地方都会增加复杂性。
无论C语言这种态度是否正确(就C语言的角色来说我认为是对的),总之这是事实。
另一方面,现实里那些奇奇怪怪的机器正在(已经?)逐步淘汰。
并且C语言本身也在改变。比如C99允许不同类型的struct/union指针相互转换,这是C89里没有的。
实现者的选择空间变窄而让程序员可以得到的保证更多。
实践里C语言的各种不确定性肯定会让代码变得复杂。
我认为“遵守C语言标准”并不是一个绝对准则。它是可以当权衡的筹码使用的。
在尽可能地遵守的前提下,当遵守起来不那么容易的时候,是可以在移植性与代码复杂性之间进行权衡的。
这是后续话题的基调。
以byte为例,它在C语言里的定义与大部分时候提到的那个byte —— 8bit octets —— 并不相同。
C语言对它的定义是执行环境上最小可寻址单元,并且以char类型来表示。
sizeof(char)恒为1。但并没有规定CHAR_BIT一定是8,只是规定CHAR_BIT>=8。而其他类型的sizeof都根据char的倍数来定义。
也就是说在9,12,36,48位的机器上都是可以实现C语言的。而比这更小的,比如4bit上应该怎么实现C语言就不清楚了,或许会将两个机器字拼成一个char?
当下是否还存在CHAR_BIT不为8的C语言实现?我只听说过某些DSP上有16bit的char。但也只是听闻而没有实际接触过。
实践上来说,要写出可移植的C程序是很困难的事情。
虽说可以采用一些技巧将大部分代码与具体C语言实现隔离,但就这一小部分代码也总得有人去写。并且C实现的差异只是移植中的一个环节而已。
想想在CHAR_BIT=9的机器上该怎么处理许多网络以及编码协议?它们很多都是使用8,16,32,64位的整数。
很有可能再也不能通过 *(uint16_t*)p 来获取而是像,比如Java那样, b0 | (b1 << s1) | (b2 << s2) ... 。
POSIX就规定CHAR_BIT=8: http://pubs.opengroup.org/online ... edefs/limits.h.html 。
于是可以:
- #include <limits.h>
- typedef int char_is_8bit[CHAR_BIT==8?1:-1];
复制代码 然后将char(byte)当作8bit octets使用。
并且POSIX也要求实现必须提供(u)int8/16/32_t等类型: http://pubs.opengroup.org/online ... edefs/stdint.h.html 。
于是可以直接使用uint8_t,uint16_t,uint32_t来处理各种协议。同时Windows也是定义了UINT8,UINT16,...等类型的。
对比一下用b0 | (b1 << s1) | (b2 << s2 )处理带来的额外“工作量”和移植到非POSIX非Windows之外的“可能性”。
无论最终决定是什么,这都是可以可以考虑的问题。
“就只根据当前环境”或“就只根据C语言定义”来作决策都是下策。
另外再附带一个有意思的言论:
lid
‘-a number’
‘--ambiguous=number’
List identifiers (not numbers) that are ambiguous for the first number characters. This feature might be in useful when porting programs to ancient pea-brained compilers that don't support long identifier names. However, the best long-term option is to set such systems on fire.
如果没记错,C89要求实现至少要支持6个字符长度的标识符。而C99增加到了31个字符。(都说的是外部符号长度)。
在C99出来之前有多少代码使用了超过6个字符长度的标识符的?或者说在C99没出来之前,为了能在所有C实现上都能工作而控制命名不超过6个字符?
反正我不会这么做。 那种去元音的缩写简直没法忍。。。
取有意义的名字然后等着那些奇怪的系统被丢进火堆就行了。。。
后续的话题都与“理论vs实践”有关。
空指针与零
大部分情况下,空指针的具体表示都(应该)是程序员不需要关心的。
0在指针上下文里都会被转换为“对应类型的空指针表示”。所谓指针上下文包括但不限于:
- T0* p0 = 0; /* 赋值 */
- T1* get(params...) { if (...) return 0; ... } /* 返回 */
- T1* p1 = get(args...);
- if (p1/* p1!=0 */) { ... } /* 条件 */
- if (!p1 /* p1==0 */) { ... }
- if (p1 && expr) { ... }
- void f(T2* p2); f(0); /* 参数传递 */
复制代码 总之只要编译器知道这个0打算转换为某个类型的指针,就会将0转换为该类型指针所对应的空指针表示。
例如上面的例子里:
p0会被赋值为T0类型的空指针的bit pattern。
get中的return 0会返回T1类型的空指针的bit pattern。
后续的几个比较会与T1类型的空指针的bit pattern比较。
最后一个例子里会向f传入T2类型的空指针的bit pattern。
bit pattern不是全0的情况在 http://c-faq.com/null/machexamp.html 里有记录一些例子。
但无论T0,T1,T2类型的空指针的bit pattern是否是全0,上面的代码都会如希望那样工作。编译器会照顾好这些情况。
接下来说一些编译器不能得知上下文的情况。。。
calloc,memset
用calloc,memset来初始化是比较常见的情况。
- typedef struct T {
- T0* p0;
- T1* p1;
- T2* p2;
- /* other */
- } T;
- T* x = calloc(1,sizeof *p); /* 不能保证x->p0,x->p1,x->p2是空指针 */
- x->p0 = 0; /* 编译器知道这是指针上下文,于是可以保证 x->p0 是空指针 */
- T y;
- memset(&y, 0, sizeof y); /* 不能保证y.p0,y.p1,y.p2是空指针 */
- T z = {0}; /* 指针上下文,可以保证 z.p0,z.p1,z.p2 全是空指针 */
复制代码 于是可能会有相应的函数/宏来初始化T。用memset,calloc来初始化整体,然后将指针域显式地赋值为0。这个赋值操作可以通过条件编译在空指针的表示就是全0的环境 —— 大部分 —— 下去掉。
这也比较容易处理。
变长参数
无论变长参数是否应该避免都改变不了已经有不少函数是这样设计的事实。于是和它们打交道也是很常见的。是错的。因为编译器不知道这是指针上下文,于是不会进行相应转换。
哪怕当前情况下不出问题它也是错的。
不需要进行什么复杂的工作就可以让它符合标准:
- printf("%p", (void*)0); /* 告知编译器这是指针上下文 */
复制代码 多的这个(void*)是很值得的。
使用NULL宏的情况比较特殊。。。在C++下(或者用C++编译器编译C代码的时候 —— Clean C)它肯定是错的。
C++里各种指针类型可以隐式转到到void*但反过来不行。而在C里双向都是隐式的。
如果C++里的NULL定义为(void*)0或者(char*)0,那上面的代码是正确的。但其他更多的代码写起来就痛苦了。
于是C++里的NULL是0。
在C++代码不能这么写。C代码如果打算要用C++编译器编译(Clean C)也不能这么写。
于是使用变长参数时不推荐使用NULL。直接用(void*)0就行。
其实在其他情况下我也不喜欢用这个没什么用的NULL,不过这只是口味问题,没什么技术上的原因。
用(unsigned )long保存地址
不(应该)太常见的情况就是把(unsigned )long当指针用了。比如Linux内核。。。
实话实说。。。 有些时候不得不用long来表示地址。。。 比如计算对齐的时候。。。
存在一些代码是有相关考虑的,比如:
obstack
- /* If B is the base of an object addressed by P, return the result of
- aligning P to the next multiple of A + 1. B and P must be of type
- char *. A + 1 must be a power of 2. */
- #define __BPTR_ALIGN(B, P, A) ((B) + (((P) -(B) + (A)) & ~(A)))
- /* Similiar to _BPTR_ALIGN (B, P, A), except optimize the common case
- where pointers can be converted to integers, aligned as integers,
- and converted back again. If PTR_INT_TYPE is narrower than a
- pointer (e.g., the AS/400), play it safe and compute the alignment
- relative to B. Otherwise, use the faster strategy of computing the
- alignment relative to 0. */
- #define __PTR_ALIGN(B, P, A) \
- __BPTR_ALIGN (sizeof (PTR_INT_TYPE) < sizeof (void *) ? (B) : (char *) 0, \
- P, A)
复制代码 根据sizeof (PRT_INT_TYPE) 与 sizeof (void*) 的关系,分别计算的是:
- (B) + (((P) - ( B) + (A)) & ~(A))
- (B) + (((P) - ((char*)0) + (A)) & ~(A))
复制代码 都是通过两个指针的减运算得到ptrdiff_t的整数类型,然后在此基础上计算对齐后的长度,最后再和B相加得到新的指针。
语言只定义了在同一个object上的指针运算,所以前者是符合标准的而后者不是。
后者可以让 (P) - ((char*)0) 优化掉。而前者可以避免这个计算超出ptrdiff_t的范围。
符合标准的计算会多一个减法。少一个减法的计算又确实会在AS/400上出问题。
obstack这样的处理方式把好处都占了,但又让代码变复杂。
我自己的选择会是: 先写前一种符合的,真的需要少一个减法的时候再说。而后一种不符合的从一开始就避免。
同时这样的技巧依赖B本身满足对齐,这依赖libc,并进一步依赖OS。
OS在没有“基准对齐指针”的情况下也要保证brk,sbrk,mmap,...能产生适合环境的对齐,并且让malloc和obstack在它的基础上实现。
于是OS就需要了解环境下的对齐规则是怎样,然后用long来表示地址。。。
因此在这方面总得有人绕过C语言定义直接与具体环境打交道。
指针低位
还有一些以“肮脏”的手段处理指针的情况。
因为前面提到的对齐的原因,于是很多情况下指针的低位是用不上的。于是就被用来存储各种信息。。。
比如红黑数的颜色。也是Liunx内核正在做的事情。。。
比如动态类型语言里值的类型。Emacs Lisp和许多Scheme,Common Lisp的实现都是这么干的。。。
相比少一个减法,这种技巧的诱惑力可能更大。 专门用一个int去存储几个bit确实太浪费了。。。
IEEE754
既然提到了用指针低位来表示类型就顺带提一下IEEE754。虽然与指针已经没什么关系了,但还是和理论与实践间的权衡有关。
C语言并不保证float,double,long double一定是符合IEEE754的。它连这些类型的长度都不作出保证,更不用说表示法了。。。不过好像在附录里有个地方推荐这样实现。
于是Lua也作同样的处理。。。“总之我默认映射到double,至于double到底是什么问C语言去”。
2.1 – Values and Types
Operations on numbers follow the same rules of the underlying C implementation, which, in turn, usually follows the IEEE 754 standard.
而在5.1的对应章节里连上面这句话都没有。。。
与之相对的。ECMA5(也就是通常说的Javascript的标准)明确规定Number的表示方式:
8.5 The Number Type
The Number type has exactly 18437736874454810627 (that is, 264−253+3) values, representing the doubleprecision 64-bit format IEEE 754 values as specified in the IEEE Standard for Binary Floating-Point Arithmetic, except that the 9007199254740990 (that is, 253−2) distinct "Not-a-Number" values of the IEEE Standard are represented in ECMAScript as a single special NaN value.
IEEE754与类型的关系是:NaN的表示不只一种,于是Lua和JS的许多实现(V8,JaegerMonkey)都用它们来存放类型。
为了效率不得不绕过C语言定义。
Lua在这方面的态度和C比较类似。态度暧昧,于是实现的时候就有回旋的余地。
而ECMA5就明确说了Number的行为。那不支持IEEE754的是什么机器?
这样的机器上需要运行浏览器么?感觉不会。。。
这样的机器上需要运行node.js么?也许会。。。 那用软件模拟?总之这是node.js的选择,也是它该考虑的问题。
幸好(在上面粗体部分)ECMA5标准还为NaN trick留下了一点余地。。。
64位机器流行起来(后?)这些脏手段可能会逐步尘封进历史。。。
strict aliasing
总是在说标准为了保证移植性而让符合标准的代码效率低。。。这里说一个标准为了提高效率作出的改动。。。
因为这个问题Python是大改过一次代码的。PEP 3123 -- Making PyObject_HEAD conform to standard C
就这个链接里的代码 —— 稍微修改过的 —— 简短说明一下Python遇到的问题。
- #define PyObject_HEAD int ob_refcnt; /* 假设这里是所有PyObject都有的域。原始代码里没有相关定义。 */
- typedef struct PyObject {
- PyObject_HEAD
- } PyObject; /* 这是标准的PyObject */
- struct FooObject{
- PyObject_HEAD
- int data; /* FooObject有额外的data域 */
- };
- PyObject *foo(struct FooObject*f); /* { return (PyObject*)f;} */
- /* 将原始代码里的定义隐藏了,否则编译器太聪明了。并且这也是符合通常情况下分别编译的场景的。 */
- struct FooObject* foo_get(void); /* { return malloc(sizeof(struct FooObject)); } */
- /* 同样将malloc隐藏。不过和这个例子没什么关系,而是我机器上没有对应的头文件。。。 */
- int bar(){
- struct FooObject *f = foo_get(); /* 同上,从直接malloc改为通过foo_get获取,不影响结果 */
- struct PyObject *o = foo(f); /* 问题出在这里。 根据foo的实现实际上f与o是同一块内存地址 */
- f->ob_refcnt = 0; /* 通过f设置所有PyObject都共有的ob_refcnt域 */
- o->ob_refcnt = 1; /* 通过o设置同一个PyObject的ob_refcnt */
- return f->ob_refcnt; /* 返回这一个PyObject的ob_refcnt */
- }
复制代码 然后:
- $ gcc -Wall -c -m32 -O3 py.c # -fno-strict-aliasing
- $ objdump -d -Mintel py.o | grep -A 3 xor
- 22: 31 c0 xor eax,eax ; 清零
- 24: 5b pop ebx
- 25: c3 ret ; 返回
复制代码 这是违反strict aliasing rules(记得是C99增加的)产生的问题。
除了少数几种例外情况下语言要求同一块内存必须用相同的类型去访问,于是编译器被允许认为f(FooObject)与o(PyObject)不是别名。
因此gcc就认为通过f设置的ob_refcnt与通过o设置的不是同一个,然后就只将通过f设置的0返回了(f->ob_refcnt=0,而避免重新通过f再读取一次(return f->ob_refcnt。
但这样的行为是不符合预期的。。。 因为程序员没有遵守strict aliasing rules。。。
一种work around是增加-fno-strict-aliasing参数让gcc不根据这个规则进行优化。
而如果想使用这种优化的话可以这样改:
- struct FooObject{
- PyObject HEAD;
- int data;
- };
- int bar(){
- ...
- f->HEAD.ob_refcnt = 0;
- o->ob_refcnt = 1;
- return f->HEAD.ob_refcnt;
- }
复制代码 这是前面提到的例外情况之一:PyObject是FooObject的第1个域。语言允许通过PyObject*去访问FooObject*中的HEAD部分。于是编译器就会认为这里可能会存在别名,就会放弃优化。
这样修改符合标准了,也能和新的编译器优化配合工作。
但另一方面原来的f->ob_refcnt就需要f->HEAD.ob_refcnt。这只是简化的例子。实际上当“继承”关系更深的时候就会f->x.y.z等等。如果还打算继续坚持使用C,估计就需要用宏了。
更详细的信息可以在上面那个链接里找。比如Python现在到底是个什么样子。
Python选择在strict aliasing上遵守语言标准。
而选择违反的例子也有。比较出名的就是Linux内核。。。
这货看起来是不打算去掉-fno-strict-aliasing参数了。反正之前也有过memcpy和memmove这种轻视规范的先例了。。。
函数指针
void*可以作为通用的数据指针。因为语言保证任何其他数据指针转换到void*后能无损地转换回去。
同时语言也保证函数指针之间可以相互转换,只要在调用前转换成正确的类型即可。于是任何一个函数指针都可以作为通用函数指针。
但数据指针(包括void*)和函数指针之间的转换就不是了。。。于是没有通用的既可以保存数据有可以保存函数的指针。。。
在C语言定义下要“同时”保存数据或函数指针只能:
* 用union
* 用void* 但不直接指向函数,而是指向一个“函数指针变量”,而这就是数据了。。。 好像boost.any就是这样做的。
但从实践角度来说用void*保存函数指针也没什么影响。。。
dlsym是这么干的。。。
Due to the problem noted here, a future version may either add a new function to return function pointers, or the current interface may be deprecated in favor of two new functions: one that returns data pointers and the other that returns function pointers.
这个future version到现在都没出现。。。
它在Windows下的对应物GetProcAddress也是这样干的。而且Windows下还有大量使用整数类型LPARAM来表示指针和函数指针的情况。。。
它们都运作得好好的。。。 有可能将来会作出改变的反而是C语言的定义。。。 |
|