Chinaunix

标题: 拨开迷雾见真知--C语言中指针的使用(获奖名单已公布-2014-4-21) [打印本页]

作者: MMMIX    时间: 2014-03-13 16:59
标题: 拨开迷雾见真知--C语言中指针的使用(获奖名单已公布-2014-4-21)
获奖名单已公布,详情请看:http://bbs.chinaunix.net/thread-4135834-1-1.html

背景介绍
C语言的指针作为进程虚拟地址空间中地址在语言中的表示,赋予了程序员无与伦比的灵活性和表达能力,复杂数据结构的实现,内存管理,以及可移植、可扩展的程序框架的实现,都需要能正确灵活的运用指针。但是,指针的灵活多变和强大表达能力也使得它成为C语言中最难完全掌握的一个部分。

以往的C语言教程,由于指针只是其中内容的一小部分,往往难以对其做详尽完整的介绍,使得程序员无法系统的学习C中指针的种种用法。《深入理解C指针》的出现弥补了这一缺憾,该书完全专注于C中指针的使用,利于系统的学习和后续的参考。

讨论话题(C指针相关,包括但不限于):
在C中使用指针的


活动时间
2014年3月14 - 2014年4月13日

活动奖励
精彩回复,6名,每位可获《深入理解C指针》一本。

图书简介


原书名:Understanding and using C pointers
原出版社: O'Reilly Media
作者: (美)Richard Reese   
译者: 陈晓亮
丛书名: 图灵程序设计丛书
出版社:人民邮电出版社
ISBN:9787115344489
上架时间:2014-2-17
出版日期:2014 年2月
开本:16开
页码:188
版次:1-1


作者: folklore    时间: 2014-03-13 17:11
一个指针用一本书来讲。。。。
作者太有才了,
大礼参跪~~
作者: qingduo04    时间: 2014-03-13 17:39
z支持,板凳
作者: forgaoqiang    时间: 2014-03-13 22:33
可以说不止一本的样子 你要去京东搜下 就会发现3、5本还是有的 传说中的博大精深 很多用不到的都有 各种稀奇古怪的写法。。。

回复 2# folklore


   
作者: drfxiaoliuzi    时间: 2014-03-13 22:37
记得以前,经常使用指针不初始化,再者经常性的造成指针越界。这个可能是自己最痛苦的了,让自己很长一段时间,一有问题首先就会想,是不是指针又越界了?还是,没有成功分配空间,抑或指针没有得到正确使用。。。?
作者: Hugo801122    时间: 2014-03-14 02:24
这本可是好书,有电子版吗?
作者: 2009532140    时间: 2014-03-14 10:26

一个指针用一本书来讲。。。。
作者太有才了,
大礼参跪~~
作者: fender0107401    时间: 2014-03-14 10:57
话说我的感觉跟楼上几位差不多,不就一个指针吗,至于写本书吗。。。
作者: fender0107401    时间: 2014-03-14 10:58
一个指针就写本书,这样容易让人产生误解,认为C语言里面的指针是个很难学会的东西。。。
作者: woosuv    时间: 2014-03-14 10:59
请问带签名的吗?
作者: seufy88    时间: 2014-03-14 11:15
回复 8# fender0107401


    老外态度就这样,有时候一个小东西也能写一本书。
    佩服的同时,也羡慕他们有这环境和心态。
作者: tan1301230147    时间: 2014-03-14 11:21
回复 1# MMMIX


    已经在当当网上付款
作者: pandaiam    时间: 2014-03-14 11:55
感觉弄懂指针和弄懂声明很有关联。
c专家以及c陷阱 里面都已经讲的挺好的了。
作者: craaazy123    时间: 2014-03-14 12:46
虽然现在不做c的项目,当每次涉及到比较底层的东西时都需要去看一部分c的源代码,各种指针,各种宏,各种编程风格,各种怪异,一下子就晕了
作者: Vinge    时间: 2014-03-14 13:43
用的最多是把指针当数组名来用,那样能避免很多麻烦。其实觉得指针如果不套上各种类型转换,应该不至于有什么大问题——忘记初始化和释放之类不该叫指针的问题,应该叫你自己的问题。
作者: Godbach    时间: 2014-03-14 14:44
回复 1# MMMIX

好活动。支持啊。

指针可是 C 语言的精髓啊。一定得学会指针的操作。

当年看了 《C和指针》这本书,就感觉对指针的认识提升了一个层次。


   
作者: Godbach    时间: 2014-03-14 14:44
回复 1# MMMIX

好活动。支持啊。

指针可是 C 语言的精髓啊。一定得学会指针的操作。

当年看了 《C和指针》这本书,就感觉对指针的认识提升了一个层次。


   
作者: bfdhczw    时间: 2014-03-14 15:18
成功,谈不上;失败,貌似也木有;说说个人理解吧。

1、我觉得,指针就是指针,数组就是数组,两者是完全不一样的东西,没关系,没必要拿来比较,越比较,越把新手弄糊涂。
个人觉得,两者唯一算得上有点关系的地方,也就是数组名做参数的时候退化为指针这一点了。

2、一个指针不管什么类型,它本省所占的存储空间永远都是sizeof(void*)。

3、忘了那些指针类型吧,什么int型指针,char型指针,指向指针的指针,函数指针……  没那么麻烦,记住一点,指针就是地址,别无其他。
所谓的指针类型不过就是告诉你应该如何解释这块地址里面的内容。

随便说的,说错了大家别见怪。
作者: folklore    时间: 2014-03-14 16:08
回复 18# bfdhczw


    百分百正确,你可以去写书了~~

*(int *)0 =0;
这个就是指针, 用来将内存单元 0的一度长度为int的数据置为0。
作者: liuyu57665    时间: 2014-03-14 17:34
在C中使用指针的
•成功经验
  一级指针用于传递和修改非指针类型的数据,例如UINT,USHORT,UCHAR;
          指向字符串,用于字符串比较和拷贝;
  一级指针和数组作为函数入参使用的时候是等同的;但作为局部变量则不同,数组表示的指针不可修改;

  二级指针作为函数调用的入参数,用于给指针赋值;
  三级指针没有使用过;

  linux代码中广泛的使用了双向循环链表.
  
•失败教训
  动态申请的内存在异常场景下没有及时释放,导致系统内存泄露;
  多核下没有对链表访问进行加锁保护,导致指针被改写为空,系统kernel panic;

•心得体会
  需要深入理解指针在不同场景下的含义,指针就是一把利剑,高手用之所向披靡;如果理解不深,容易引人指针越界、空指针访问、内存泄露、重复释放等问题。
作者: tklist    时间: 2014-03-14 21:45
看过c语言经典3本书,感觉对c语言认识更深一层(主要之前太菜了)。对指针也没有以前害怕感觉。
作者: iamlushu    时间: 2014-03-15 00:36
还有一本经典书叫《c与指针》
作者: Ager    时间: 2014-03-15 07:16
本帖最后由 Ager 于 2014-03-15 07:16 编辑

照话题要求,准备发一篇关于“在C中使用指针的心得体会”的回帖。

正在写作中,@starwing83 是主要贡献者。

请允许我先占个位子,哈哈。。


作者: tempname2    时间: 2014-03-15 13:40
bfdhczw 发表于 2014-03-14 15:18
忘了那些指针类型吧,什么int型指针,char型指针,指向指针的指针,函数指针……  没那么麻烦,记住一点,指针就是地址,别无其他。


当然要管类型了,不然做数学运算何解?
作者: tempname2    时间: 2014-03-15 13:51
回复 23# Ager


对搞底层的人来说,心得就一句话:指针暴露了所有的抽象内存,Period。
作者: 寂寞小七    时间: 2014-03-15 14:07
int p;    //这是一个普通的整型变量
int *p;  //首先从P处开始,先与*结合,所以说明P是一个指针,然后再与int结合,
         //说明指针所指向的内容的类型为int型.所以P是一个返回整型数据的指针  
int p[3];    // 首先从P处开始,先与[]结合,说明P是一个数组,然后与int结合,
        // 说明数组里的元素是整型的,所以P是一个由整型数据组成的数组
int *p[3]; //首先从P处开始,先与[]结合,因为其优先级比*高,所以P是一个数组,  
     //然后再与*结合,说明数组里的元素是指针类型, 然后再与int结合,      
    //说明指针所指向的内容的类型是整型的,所以P是一个由返回整型数据          //的指针所组成的数组
int (*p)[3]; //首先从P处开始,先与*结合,说明P是一个指针,然后再与[]结合       //(与"()"这步可以忽略,只是为了改变优先级), 说明指针所指向的       //内容是一个数组,然后再与int结合, 说明数组里的元素是整型的.          //所以P是一个指向由整型数据组成的数组的指针
int **p;   //首先从P开始,先与*结合,说是P是一个指针,然后再与*结合, 说明指       //针所指向的元素是指针,然后再与int结合, 说明该指针所指向的元素       //是整型数据.由于二级指针以及更高级的指针极少用在复杂类型中, 所          //以后面更复杂的类型我们就不考虑多级指针了, 最多只考虑一级指针.
int p(int);  //从P处起,先与()结合,说明P是一个函数,然后进入()里分析,说明该       //函数有一个整型变量的参数,然后再与外面的int结合, 说明函数的          //返回值是一个整型数据
Int (*p)(int);   //从P处开始,先与指针结合,说明P是一个指针,然后与()结合,//说明指针指向的是一个函数,然后再与()里的//int结合,说明 //函数有一个int型的参数,再与最外层的int结合,说明函数的 //返回类型是整型,所以P是一个指//向有一个整型参数且返回 //类型为整型的函数的指针  
int *(*p(int))[3]; //可以先跳过,不看这个类型,过于复杂  //从P开始,先与()结合,说明P是一个函数,然后进入()里面, //与int结合,说明函数有一个整型变量参数,然后再与外面           //的*结合,说明函数返回的是一个指针, 然后到最外面一层,          //先与[]结合,说明返回的指针指向的是一个数组,然后再与*结          //合,说明数组里的元素是指针,然后再与int结合,说明指针指          //向的内容是整型数据.所以P是一个参数为一个整数据且返回             //一个指向由整型指针变量组成的数组的指针变量的函数.    说到这里也就差不多了,我们的任务也就这么多,理解了这几个类型,其它的类型对我们来说也是小菜了,不过我们一般不会用太复杂的类型,那样会大大减小程序的可读性,请慎用,这上面的几种类型已经足够我们用了.回复 1# MMMIX


   
作者: gvim    时间: 2014-03-15 18:15
回复 26# 寂寞小七


    +10086
作者: sincerefly    时间: 2014-03-16 12:03
感觉《C与指针》这本书写的不错)
作者: CUTianrui007    时间: 2014-03-16 12:57
指针不只是C的精华,同时也是C++一些特性的实现的必须,比如说虚表,就是函数指针。我对指针的理解如下:
指针其实很简单,就是个地址。它有三个基本属性:地址属性,步长属性,数据格式化。地址属性是其最基本的属性,也是指针的定义所在。而常用的则是其步长属性和数据格式化。具体解析如下。
*****************************************************
地址属性:
比如你定义一个变量:
U16 u16Temp;
那么软件就会为你所定义的变量分配一个地址,具体这个地址在哪里?依据你所定义的变量的类型,如果是全局变量或静态(二者本质是一样的),那就在全局区中,如果是局部变量,那就在函数的栈中。可以通过&u16Temp来得到其具体的值。这是其地址属性。
*****************************************************
步长属性:
这里的步长属性是指指针在参与运算过程中,主要是加减运算,进行一个单位的加减时,其所能跨越的地址范围。比如,定义一个指针变量如下:
U16* u16pPointer = 0X12345678;
当你执行u16pPointer++时,其值变会变成了0X1234567A,注意:是从8变到A,加了2,为什么加2?因数U16占用两个字节,那么指针运算一次,其步长就是2个字节。如果你定义成U32,那么步长就是4个字节了。如果两个指针做差运算,那么得到是什么呢?比如U16* u16P1 = 0Xaaa0; U16* u16P2= 0XAAAA,如果u16P2-u16P1=?,如果直接理解,得到值将是0XAAAA-0XAAA0=A, 这样分析错了,为什么?指针另时,编译器会乘上步长,与此类似,这里也会除以步长,也就是说,两个指针相差,你得到的将是步长值,所以正确的结果是A/2=5.
*****************************************************
数据格式化
这是对步长属性的引申,比如你定义了一个结构体,
typedef struct
{

    U16 Data1;

    U8  Data2;

    U8  Data3;

}tStrTest;

这里定义了个结构体,你定义一个指针U16* u16P1=0X12345678,显然其步长是2,如果你对其做强制转化,如下所示:
(tStrTest*)u16P1,那么0X12345678开始的数据将会被编译器解析为一个结构体,也就是说,你可以使用0X12345678~0X1234567B在编译器看来,就是一个结构体, 前面两个字节是Data1,后面两个字节分别是Data2和Data3.这个技巧很有用,可以将其叫做数据流的解析,比如,在网络编程中,你将接收到的数据放到一个数组中,然后定义一个IP头结构体去解析这个数组,那么就可以对数据进行解析了.
作者: CUTianrui007    时间: 2014-03-16 12:59
一个指针写一本书?指针的概念说白了,也就那几句话,几个用法而已,感觉此书可能应该不只是局限于指针本身,可能关系到指针的具体用法,比如指针在OS中的应用。
作者: MMMIX    时间: 2014-03-16 23:40
CUTianrui007 发表于 2014-03-16 12:57
指针其实很简单,就是个地址。它有三个基本属性:地址属性,步长属性,数据格式化。


不知你这些术语哪来的?

就指针类型本身来说,指针变量和 C 中其他变量没有任何的差别,有类型(决定了可允许的值和操作)、有值、有地址,只不过是允许的值和操作不同罢了;指针型的值也是类似的。在 C 中,指针类型唯一比较复杂的地方就是和其他数据类型、操作的交互,例如指针和整数的算术运算要考虑指针所指数据类型的大小,例如某些情况下数组向指针的退化。

就指针的使用来说,最主要的有两块:复杂数据类型的构造,和函数指针提供的抽象能力。这两个在用 C 实现复杂的跨平台软件的时候是必不可少的。
作者: MMMIX    时间: 2014-03-16 23:41
回复 26# 寂寞小七


    内容倒是不错,不过这排版也忒糙了点。
作者: to407    时间: 2014-03-17 00:27
回复 19# folklore


    这句代码本身就说不通,因为对于0位置的内存地址写操作   本身就是不确定的行为。
作者: to407    时间: 2014-03-17 00:46
也看过几本指针的书

http://book.douban.com/subject/3012360/
http://book.douban.com/subject/21317828/

推荐看看这两本书,

今次这本 书,我看了下英文版本, 个人觉得 不是冲着“指针” 本身来的。

我个人理解的话,从易到难,我们遇到的常见的指针问题也就这样子:
1) 混淆C和C++的指针和引用的概念
2) 在C下面把变量类型完全抛开,只从地址空间考虑指针
3) 函数指针
4) 高级应用, 如Linux的VFS部分,调用函数指针实现oo设计。
...

除了问题之外,也就是一些小技巧,比如如何去利用0指针查找一个项在struct里的位置之类。

但这本书,我觉得不针对指针, 倒有些针对更上层的应用技巧了。

要知道,那些变态的old style的指针相关书籍, 可是要写一长串括号然后让你读出来其中某个星星的意思的。。。

这本书很大的部分 讲的内存操作,倒不是说书内容不好,而是不切题, 换个角度去想吧, 这本书压根就不是讲的怎么理解指针,而是在哪些地方用的列举一下而已。

当然对于书的内容我也有所保留的,

比如书里面 直接讲了
#defile NULL ((void *)0)

没有商讨的余地,也许是我们老了, 这个NULL,到底什么意思,毕竟是implement specific的,应该留模糊空间,而不是这么不留余地地讲。

而像void *这个表达式, 书里也讲得含糊,说和char *的alignment一样,但不可比较。 这也讲糊涂了, ANSI标准是不允许,但GNU就直接当char *用了,然后随便算术操作。

觉得可以严谨一些。



作者: folklore    时间: 2014-03-17 08:55
回复 33# to407


    这个是合法的C语句,
而且是正确的~。
只是在C语言中,
NULL(并非0)被当做为一个比较特殊的值来对待而已。
在某些系统中, 你可能可以看到:
C标准文件中有:
#define NULL ((void *)0)
。。。

作者: to407    时间: 2014-03-17 11:30
回复 35# folklore


    你倒是把这个标准找出来?  
作者: folklore    时间: 2014-03-17 13:22
回复 36# to407


    我被你打败了,好吧,
也是我打错了,
我想说:

C标准文件中有:
#define NULL ((void *)-10

C语言并没有说不能写0地址,
我猜它甚至不会有不能写NULL(不管它是不是0)地址的规定。

C语言标准并不死文本,
它是基于更实的,
现实中无法规定不能写NULL地址,
不然你就无法为某些奇葩的CPU写编译器了。
作者: to407    时间: 2014-03-17 13:33
ISO/IEC 9899:1999

Sec 7.17

The macros are

NULL
which expands to an implementation-defined null pointer constant;



这边NULL具体什么意思,是基于实现的.  不能武断地讲 (void *)0

作者: to407    时间: 2014-03-17 13:46
回复 37# folklore


    1. 我认为标准不是迂腐, 还是有很多余地的, 就像c99里面提到了这个null的值是基于实现,也提到了null pointer和null pointer constant 就是以 cast 0为例的。 但不是vice versa的意思。
   
    2. 书里面 直接这么写是有问题的, 因为这个超出了标准的界限,不严谨,对读者而言也没什么好处。

    3. stddef.h确实是标准列举的头文件,  就算在我的linux x86平台下,也不是简单粗暴地实现成 (void*)0 啊
  1. #ifndef _LINUX_STDDEF_H
  2. #define _LINUX_STDDEF_H



  3. #undef NULL
  4. #if defined(__cplusplus)
  5. #define NULL 0
  6. #else
  7. #define NULL ((void *)0)
  8. #endif


  9. #endif
复制代码
4. 留这个模糊空间是有意义的, 因为0并不一定是指向物理地址x0000, 也不一定是指向所在区块的x0000偏移,具体在哪,看系统的实现。 既然0都有模糊空间,NULL就更需要这个空间了,因为0一旦cast成(void *)就不能被简单视为地址值,而标准特为其定义一个名词NULL pointer constant, 这样太明显地说,就会有鸡和蛋的先后问题。
作者: seesea2517    时间: 2014-03-17 15:13
看到一个指针就能写一本书,觉得自己真是低估了指针的内涵了……当前对指针的认识已被楼主写在31楼了,略过打字……
作者: MMMIX    时间: 2014-03-17 15:26
to407 发表于 2014-03-17 13:33
这边NULL具体什么意思,是基于实现的.  不能武断地讲 (void *)0


folklore 有说在某些系统中嘛。不过,确实在有些系统下 NULL 不是 0。
作者: to407    时间: 2014-03-17 16:04
回复 41# MMMIX


    是啊, 我也没针对他的发言, 我是针对原书里提到的这段话, 个人评论的。
作者: amarant    时间: 2014-03-17 16:59
看到folklore 老大的那句话,心里有一万个core dump飘出来
虽然cpu的物理地址0是可以读写的,但是linux里面是把虚拟地址里的0地址当作了非法地址的
作者: pitonas    时间: 2014-03-17 17:13
  这样容易让人产生误解

回复 9# fender0107401


   
作者: Susake_    时间: 2014-03-18 00:39
关注!NULL
c11的55页66注释
The macro NULL is defined in <stddef.h>(and other headers) as a null pointer constant
在stddef.h中
The macros are
NULL
which expands to an implementation-defined null pointer constant
作者: tony_trh    时间: 2014-03-18 10:32
谈谈我对C语言指针的一点看法,
首先声明,我不是C语言专家,只是对使用C有点经验和理解,所以不排除有些错误的地方。
指针在很多语言中都存在,比如:PASCAL/DELPHI,其指针的能力,一点也不比C弱,只是写法不同而已,所以,很多PASCAL/DELPHI技术狂热分子,对指针是C语言的精华,向来不服,用各种对比来证明PASCA/DELPHI的指针能力,但指针在PASCAL系列的语言里,其使用频率和范围,远远没有C语言高,其原因是语言的设计理念不同,因为PASCAL系语言有内置的字符串处理和动态数组类型,所以,大部分情况下,已经不需要使用指针来做处理,而C语言因为缺乏内置的字符串类型和原生的动态数组类型,所以,需要指针来处理这些数据,如果没有指针,那C语言估计就是个废物语言。

由于指针在C语言程序里,无处不在,所以,如果不深入理解C指针,还真是没办法写出程序。

对于指针的理解和使用,每个初学者都是似懂非懂,要说简单,其实也确实简单,指针变量就是指向一个地址,也就是说,取指针变量的值是一个地址,而普通变量的值则是就是直接的值;
说复杂,也确实复杂,因为指针跟数组、函数、数据类型、多级指针,搭上勾,就会变得非常晦涩难懂,即使对于有经验的人,也容易出错。

建议学一下汇编语言和其它有指针功能的语言,这样可以增加对C语言指针的理解。
在汇编语言里,是没有指针这个概念的,但它却有类似指针这样的功能,那就是在申明变量时候,其后面的类型不是普通变量的数值,而是另外一个变量的偏移地址。这样,就能好理解C语言的指针了。
当指针有了地址后,还需要说明这个地址里类型是什么,在汇编语言里,就是用BYTE,DB,DW来表示,意思是每个数值占多少空间(字节),让CPU可以根据类型,跳到下一个元素的开始地址。

所以,指针这个东西,其实也就是转了点弯,不直接告诉东西在哪里,而是给你个地址号码,再去查下,就好像一座大楼里,很多房间,其中有一个房间放了黄金,但管理的人,不直接告诉你在哪个房间,只给你一个盒子,盒子放有这个存放黄金的房间地址,所以,要找到黄金,则需要先打开这个盒子。指针也就是类似这样的概念。

由于指针是直接操作地址,就变得非常不安全,比如越界非法访问,导致程序出错,在C语言程序里,一直是个很头痛的问题,需要程序员非常小心来处理这些问题。

先写到这里,有空再写下C语言跟其他语言指针的比较。


作者: vimney    时间: 2014-03-18 10:49
记住一点就可以了,指针是解耦利器。
作者: MMMIX    时间: 2014-03-18 11:13
to407 发表于 2014-03-17 16:04
回复 41# MMMIX


对照 C99 标准看了一下原书的内容,那个 NULL 的定义并没有问题:

1. 6.3.2.3 Pointers
3 An integer constant expression with the value 0, or such an expression cast to type
void *, is called a null pointer constant.66) If a null pointer constant is converted to a
pointer type, the resulting pointer, called a null pointer, is guaranteed to compare unequal
to a pointer to any object or function.


2. 66)
The macro NULL is defined in <stddef.h> (and other headers) as a null pointer constant; see 7.19.


可见 NULL 需要被扩展为一个 null pointer constant,而 (void *)0 本身就是一个 null pointer constant.
作者: xike2002    时间: 2014-03-18 11:41
这个话题对于广大程序员来说可以好好发表一下自己的意见。
作者: to407    时间: 2014-03-18 12:47
MMMIX 发表于 2014-03-18 11:13
对照 C99 标准看了一下原书的内容,那个 NULL 的定义并没有问题:

1. 6.3.2.3 Pointers


问题就在这里呀

你引的第一段标准里面说

在pointer部分, value 0 或者(void *)0 是 "null pointer constant"
另外这个值 转成指针型,对应的指针就叫 “ null pointer”

这个定义是列举式的,没有问题啊。
但列举式就像我前面提到的,不是 vice versa的, 换句话说, "null pointer constant"具体是什么,没有讲清楚。

所以这一段话跟这个争议无关。


如上面提到过的,NULL 本身的意义,是基于实现的。

而你引的第二段,讲的  NULL is defined as a null pointer constant,这个也没错, 但 就不能说 #defince NULL (void *)0了啊。 因为 除了(void *)0以外的"null pointer constant" 还存在。

所以我说的只是 这本书原文不严谨, 毕竟都讲指针了,那就至少 讲明这个NULL实现 并不是惟一值 嘛,备注一下 也有实现成0的也行啊 。



作者: CUTianrui007    时间: 2014-03-18 12:48
回复 31# MMMIX


    这些都是我自己总结的,没有任何一本书上提到这些概念。当然,函数指针我是没有提到这个概念的。
作者: MMMIX    时间: 2014-03-18 13:53
回复 56# to407


    嗯,明白了,你说的也有道理。

#define NULL ((void *)0)

在所有遵循标准的 C 编译器中都是正确的,但这并不是唯一的定义 NULL 的方式。

说到不严谨处,这书里面有不少。
作者: tony_trh    时间: 2014-03-18 14:11
比较一下C语言指针跟其它语言的指针。

这里不想做不同语言的指针能力比较,只从如何理解的角度来比较。

不论什么语言的指针,其值是都是一个地址,指针的类型,决定这个指针在移动时候,需要一次移动的距离。
比如C语言:
char *p ;     字符型,则每次在内存里,取1个字节的数据到CPU里运算,
int   *p;        整数型,则每次在内存里,取4字节的数据到CPU里运算。

在PASCAL/DELPHI里,相应的是这么写的:
p:^char;  
p:^integer;

为了兼容C语言,DELPHI专门有一套处理C字符串风格的函数和指针类型,
如:PCHAR   等同    char *p

调用C语言的函数,需要传入字符串的话,比如:
C语言函数:int  c_fun(char *p),编译成DLL库。
在DELPHI里,调用方法:
c_fun(pchar("这里是DELPHI字符串"));   通过pchar函数就可以转换为C字符串类型,传入地址给C函数处理。

反之,C语言也一样可以调用DELPHI的函数,只要按约定的规则即可。

所以从这里看得出,即便不同的语言,指针之间,也是能互相通话的,因为他们都有一个共同点:指向地址!


不同的地方,我认为只是写法的不一样,比如:定义一个函数型指针变量,
C语言:
char *(char *p)(char *src,  char *dest);

而DELPHI 的写法:
p:  function(src: pchar;  dest: pchar):pchar;

如果要定义二级指针;
C语言:
int   **p;

DELPHI:
tint=^integer;
tint2=^tint;

可以看出,C语言比较简单,连续2个*,就定义了,而DELPHI则需要二次定义,
在使用C的过程中,感觉C语言风格确实很实用、自由,而DELPHI则比较呆板点(也可以叫严谨),各有好处。

C语言的函数型指针,可以直接跳到一个绝对地址执行,比如:获得函数c_fun的地址,假如是:0x000D
当函数型指针变量被赋值为:
p=0x000D;
或者
p=c_func;
其效果是一样的。表明p指向同一个程序地址入口。然后再执行p("XXXXXXX");,便执行了函数c_func

在DELPHI里直接赋予地址写法是:p=pointer(0x000D)


可见C指针跟其他语言的指针,本质上都是一样,都是执行地址运算,当然能力也有大有小,比如新版的FORTRAN也有指针,但指针的能力比较弱,远没有C语言那么强大。



作者: tony_trh    时间: 2014-03-18 14:51
在DELPHI里直接赋予地址写法是:p=pointer(0x000D)
--------------------------------------------------------------
更正一下:应该是:p=pointer($000D)
作者: jimmy-_-lixw    时间: 2014-03-18 15:27
C语言是个不老的传说,支持话题讨论。
作者: 孙轩    时间: 2014-03-18 18:03

     如果说在linux里一切都是文件的话,那么在程序里可以认为一切都是地址,就我来看每一个程序都可以推演成地址和指令的结合,而指令又是存储于地址之上的。所以我觉得控制了地址就控制了程序.而在c中,指针就是一个地址控制很重要的途径。如果说不了解指针很难想像你可以说掌握了C语言。反过来说,要真用好指针就需要对整个程序乃至系统运行程序的机制有深刻的了解,才能把指针用好用活。以上是个人浅见,贻笑大方了。
作者: MMMIX    时间: 2014-03-18 19:47
tony_trh 发表于 2014-03-18 14:51
在DELPHI里直接赋予地址写法是:p=pointer(0x000D)
--------------------------------------------------- ...


你自己的帖子在发布后你仍然可以编辑。
作者: gvim    时间: 2014-03-19 00:28
本帖最后由 gvim 于 2014-03-19 00:29 编辑

@Ager
还没写完?菜凉了啊,赶紧的!
作者: Ager    时间: 2014-03-19 02:54
gvim 发表于 2014-03-19 00:28
@Ager
还没写完?菜凉了啊,赶紧的!


不是说“ - 2014年4月13日”嘛。。。。


作者: zm_wl    时间: 2014-03-19 15:10
本帖最后由 zm_wl 于 2014-03-19 15:11 编辑

最近娃快生了啊,没怎么来CU,忙啊!今天一来,发现老版发布了一个关于C Pointer的话题,看看期限不算晚啊
顺便说两句  表支持
      
       之前因为工作需要,刚毕业那几年疯狂研究 C
       对于图灵系列(貌似是吧)的C语言图书我看过
  1. 《POINTERS ON C》
复制代码
  1. 《Expert C Programming》
复制代码
这两本书对Pointer都做了很清晰的描述。建议大家看看!
       有时候说什么就是地址、C精华啊。我相信这些话每个学C的人接触指针都对这话感到挺腻。
       对于指针理解,理解的人每个人脑海里面都有一套相关联的“地图”,
       比如对指针理解浅薄的人脑海中相关联要素为:地址。结构很单一;
          而理解深的高手脑海中相关联系要素为: 内存,地址  16进制符号  申请  组织、效率、方式、操纵  释放等等关联起来,形成相关联的脉络网,四通八达。
        所以看到一些老鸟之言,视角都很独特,至于接受者能比较好理解那种视角,这就取决于你喜欢萝卜,还是黄瓜了。
      
       虽然C现在很少用了,俺想了想,依稀表达如下:
       以C这种平台语言为载体,
       1、从数据处理层说: POINTER  是一把钥匙, 让Developer能对诸如内存等相关区域数据操纵自由掌控(代价有啊);
       2、从应用代码层说: POINTER  是个百宝箱(“计数器”可能更直观一些),比如在封装机制没有成熟的条件下,为处理一串char的处理也就是str,提供帮助;还有对于一些抽象数据结构(对列,链表、栈等)提供操作;     再说还有一些function之间的调用的入参等等。
       POINTER 的特征码: 自由(双刃剑)、强大(说的就是效率)  


表达不好,个人简陋理解,有错,诚恳接受批评
        
作者: humjb_1983    时间: 2014-03-19 17:53
我是来学习的
作者: humjb_1983    时间: 2014-03-19 17:53
我是来学习的
作者: hadisiw    时间: 2014-03-22 10:57
好书啊真想来一本
作者: JackNorton    时间: 2014-03-22 22:29
在C中使用指针特别灵活,特别方便,但如果使用不好容易出现很多潜在的问题。
我曾经在项目中实现一个用于存储IP地址的检索树中就有一个指针,用了几天的时间才找到错误所在,上次是错误因为涉及到二级指针,在进行传参的时候,在函数中操作出现错误。
使用指针首先要理解指针在内存中的存储情况;然后要深刻理解  指针的类型、指针所指向的类型、指针的值(指针所指向的内存区)、指针本身所占据的内存区,这几个概念至关重要;最后就是在程序严格针对需要使用指针的变量进行跟踪,知道指针的变化。
指针,就是指的地址,这个地址理解有很多,包括结构体地址、函数地址、变量的地址、地址的地址,这些都需要深刻理解,这样才能运用自如。
作者: rover12421    时间: 2014-03-25 10:12
在指针偏移上吃过亏,偏移是按当前指针类型大小做偏移(和数组一样),刚开始的时候就因为忘记了这个,以为是一个字节一个字节的偏移,结果直接偏移了n*sizeof大小,可想结果肯定是溢出了,所以后来就养成了习惯,不管是啥类型,都先转成char指针再加偏移量.不过在看有些源码里是转成void的指针再加偏移量,这个VC下是没问题,但是感觉怪怪的,我记得void指针是不能偏移的,C基础不是太好,还请懂的人帮解答下.
函数指针也是很头疼的一件事,简单的还是可以理解的,复杂的就乱了,看的头疼.不过函数指针确实是一个很好的东西,在回调上用的非常方便,可以模拟泛型,不过就是查找调用关系会累点,代码逻辑没那么清晰
我的心得就是,只用自己了解的部分,不熟悉的部分千万别用.自定义函数指针尽量简单.
作者: wwx0715    时间: 2014-03-26 12:35
看看linux的内核,大量使用到指针,不过指针也是最容易引入bug的
作者: wwx0715    时间: 2014-03-26 12:35
看看linux的内核,大量使用到指针,不过指针也是最容易引入bug的
作者: supermegaboy    时间: 2014-03-26 20:34
[quote]bfdhczw 发表于 2014-03-14 15:18
成功,谈不上;失败,貌似也木有;说说个人理解吧。

1、我觉得,指针就是指针,数组就是数组,两者是完全不一样的东西,没关系,没必要拿来比较,越比较,越把新手弄糊涂。
个人觉得,两者唯一算得上有点关系的地方,也就是数组名做参数的时候退化为指针这一点了。

2、一个指针不管什么类型,它本省所占的存储空间永远都是sizeof(void*)。

3、忘了那些指针类型吧,什么int型指针,char型指针,指向指针的指针,函数指针……  没那么麻烦,记住一点,指针就是地址,别无其他。
所谓的指针类型不过就是告诉你应该如何解释这块地址里面的内容。

随便说的,说错了大家别见怪。[quote]

第1条是对的,但后面两条有很大问题。

指针所占的存储空间是实现相关的,不同的实现可能会不同。C/C++仅保证一个指针转换到void*后再转换回来,其二进制宽度不变,其余不保证;

对于“指针就是地址”是否正确,这得看你说的地址指的是哪个“地址”,如果你指的是平台地址空间中的地址,这就是错误的说法。平台地址空间中的地址仅是C/C++指针的实现,两者不是同一的。只有当针对C/C++内存模型中所述的地址,“指针就是地址”这句话才是正确的,这时候两者是同一的。
作者: supermegaboy    时间: 2014-03-26 20:51
晕,写错了点东西,“其二进制宽度不变”应是“其值不变”。
作者: linux非常菜    时间: 2014-03-27 10:07
对于C的指针知识很匮乏,所以没办法讨论。 望可以获得一本研究研究。
作者: liitokala    时间: 2014-03-27 10:13
在理解C指针时,最好能结合相应的编译器工作原理来分析,会更好。
简单一例:
  1. uint32 cu = 0x0400;
  2. uint8 *pcu_8 = (uint8 *)&cu;
  3. uint 16 *pcu_16 = (uint16 *)&cu;
复制代码
都知道指针是地址,&cu代表32整型值0x400的地址,假设为0x12345678,pcu_8、pcu_16同时指向该地址;
当通过*pcu_8来访问该地址的值时,编译器会解释为*pcu_8是个8位值,只会访问0x12345678开始的第一个字节的数据,为0x00;
同样的*pcu_16只会访问从0x12345678开始的连续2个字节的数据,为0x0400。

作者: Lijun0509    时间: 2014-03-27 13:53
成功经验:
    玩儿函数指针没出岔子
失败教训:
    忘记空指针判断...段错误
心得体会:
    洒家还是菜鸟,洒家是来向巨巨学习的
作者: bfdhczw    时间: 2014-03-27 14:41
回复 74# supermegaboy

第二点如你所说,看了下C99标准,正确的理解应该是除void*之外的指针占用的存储空间都是一样的,void*占用的存储空间不小于其他类型的指针。

至于第三点,没明白你所说的“平台地址空间中的地址”指的是什么。
   
作者: supermegaboy    时间: 2014-03-27 23:12
bfdhczw 发表于 2014-03-27 14:41
回复 74# supermegaboy

第二点如你所说,看了下C99标准,正确的理解应该是除void*之外的指针占用的存储 ...


第二点依然不对:

C99/C11:

Types

......Pointers to other types need not have the same representation or alignment requirements.



平台地址空间指的是操作系统或CPU提供的地址空间。

作者: MMMIX    时间: 2014-03-28 17:55
supermegaboy 发表于 2014-03-26 20:51
晕,写错了点东西,“其二进制宽度不变”应是“其值不变”。


直接编辑自己的帖子撒。
作者: hutiewei2008    时间: 2014-03-29 10:52
那看完我是不是肯定蒙了不少,分不清很多东西啊
作者: tony_trh    时间: 2014-04-01 14:32
个人经验:
要理解指针,就要搞清楚指针的三大核心要素:
1)指针的值:这点前面很多人已经说得很清楚了,其值就是地址,要获取这块地址的内容,就要用指针变量名称
前面加*号来获取,有几级指针,前面就得加几个*。

2)当前指针的指向:当前指针正指向何处?这点没有清楚的认识,就会非常容易导致程序出错。

3)指针的移动:使用指针,就不可避免的要移动指针来处理数据,每次需要移动的距离,这个跟指针变量类型息息相关。

搞清楚了这三个要素,大体上对指针的基本使用就没什么难了。但需要彻底搞清楚,光看书,是很难掌握的,需要多写程序去验证,没有其他办法。

本人以前在学习一级指针时候,觉得比较容易懂,但涉及到二级指针,常常困惑,
比如利用一个二级指针,构造二维数组,或者用一个数组型指针来处理多维数据,老出错。
后来多写写,了解一下内存地址,也就慢慢好了。

下面是一个对指针处理二维数据的理解:

如一个二维数组:int  ARR[][3];
如果要用一个指针来处理,则定义数组型指针:
int  (*p)[3];        //告诉编译器:P是一个指针,但指向的地址块内容,占三个整数的长度,
                           //所以,编译器会翻译为CPU寻址指令,每次在对应的内存里移动是三个整数 长度;
                          //而*P也是地址,但元素长度为一个整数长度,即每次移动距离为一个整数长度,

由于数组的内存块是连续的,所以,可以通过P的地址顺序变化来逐个访问该数组的内容。

假设用i  ,j  分别表示行列变量,可以用  *(*(p+i)+j)形式来访问每个元素   

p+i          :表示每次移动一行,如:p+1,则表示一次移动三个整数长度的距离,指向下一行的首地址
*(p+i)+j  :表示依次访问一行内的每个元素地址,由于p是二级指针,所以*(p+i)的值依然是地址
*(*(p+i)+j):则表示指针移动相应距离后,获取每个对应地址块内的内容(值)

其实在CPU的眼里,全部都是数字,没有什么地址和数据区分,学过汇编语言,就会深有体会,我们在写程序时候的指针定义和处理规则,是编译器规定的,编译器根据这些规定,翻译成寻址指令,计算指令等等,处理完后,编译又根据规则翻译过来,让人看,其实就是个这么的游戏规则。

不知道说的对与否,请高手批评指正,暂时只说这么多了,要忙去了。


作者: cjdao    时间: 2014-04-04 10:50
本帖最后由 cjdao 于 2014-04-04 10:52 编辑

成功经验
1.指针可以用来封装C里的对象,如:
  1. struct someone {
  2.         struct data data;
  3.         void *op1;
  4.         void *op2;
  5. }
复制代码
虽然从实现看struct里面都是是数据,但从设计的角度看,op1和op2就是操作而data则是属性。这就将属性和操作封装在一起形成了一个对象的概念。linux内核里的struct file封装用到的就是这种技巧。

2.指针扩展了函数的输入输出。在C中函数的参数可支持多个,但返回值且只有一个,但你想返回多个值时,一个做法是将需要返回的成员封装成结构体,让函数返回结构体指针或者结构体本身。但是有时这些返回值在逻辑上的关联可能没那么强,将它们封装在一起会应该整个代码的可维护性。另一个做法,是在函数参数里头传指针,函数内部通过操作指针返回结果。socket API里的好些接口都使用了这种技巧,如recvfrom函数。

失败教训(这个简直太多了)
1.混淆了C中数组名和指针,
如:
  定义一个函数 void foo(char a[8]),会以为函数会将整个数组传递进来,实际是数组早已幻化成了指针, 因此sizeof(a)就变成了指针的长度而不是数组的长度.
又如:
   在一个文件中定义了一个数组 char a[8];在另一个文件中却将其声明成了指针char *a;
2.错将栈上的内存,通过指针返回给函数外部使用。
如:
  1.    char *foo()
  2.    {
  3.        char data[8];
  4.        // fill data
  5.        return data;
  6.    }
复制代码
3.还有就是比较经典的 内存访问越界 内存泄露及double free等

心得体会
C语言里的指针绝对是把双刃剑,用得好威力无比,用得不好惨不忍睹。但问题的关键还是在于使用工具的人,而不在于工具本身。

最后,分享一道考题,下面代码段的执行结果是什么:
  1. #include <stdio.h>
  2. struct str{
  3.     int len;
  4.     char s[0];
  5. };

  6. struct foo {
  7.     struct str *a;
  8. };

  9. int main(int argc, char** argv) {
  10.     struct foo f={0};
  11.     if (f.a->s) {
  12.         printf( f.a->s);
  13.     }
  14.     return 0;
  15. }
复制代码

作者: 羽翼灵动    时间: 2014-04-04 23:08
很想看看啊啊
作者: Ager    时间: 2014-04-11 19:32
本帖最后由 Ager 于 2014-04-11 21:15 编辑

Reply To->@gvim

写好了 ……

指针迷局

——在C中使用指针的心得体会



其实,就谈这么一个问题:我们是否可以构造一个指针,使得该指针指向它自己

这样,或许有人要呵责了:你又在扯了不是?这么做有什么意义?!这样的指针又有什么意义?!

对上面问题的回答,我希望自己不再扯进那些艰涩的哲学样的说辞,那么,转而这么说吧……

匈牙利女作家Kristóf Ágota笔下有一对双胞胎弟兄,他们在儿时曾一个扮瞎子、一个扮聋子。他们曾说:

Celui qui a fait l'aveugle tourne simplement son regard vers l'intérieur, le sourd ferme ses oreilles à tous les bruits.

翻译成中文就是:瞎子,因为眼睛被遮住,就将眼光射向心灵的深处;聋子,因为耳朵被堵住,就能拒绝所有的噪音。

在我看来,一个指向它自己的指针,正是一个“又聋又瞎”的指针。这样的指针,如何“看”它自己,如何“看”它之外的世界,及至,它的言说,如何被我们体会,再及至,我们如何构造它们 (它们如何扮演自己)—— 这些问题,或许可以帮助我们开启一个前所未见的奇妙视界。

首先,我们须要为“指向它自己的指针”寻求一个形式化的定义。我们的尝试,从下面开始。

P是一个指向类型T的指针。

若P == &(P),则P是一个指向它自己的指针。

这样的定义,非常符合我们对指针的直接体验(常识)。不过,我们的常识真的那么经得起推敲吗?

我们知道,若a,b都在函数f的定义域上,且a == b,则一定有 f(a)==f(b)。那么,我们为函数f找到一个实例,即C的解引用操作符(*)。

P == &(P)
对等号两边解引用,得到:
*(P) == *(&(P))

根据解引用操作的定义,我们可以化简上式的右边,得到:
*(P) == P

综上,我们可以得到:
若P == &(P),则P == *(P)。
即:P == &(P)是P == *(P)的充分条件。

类似地,我们对P == *(P)的等号两边进行取地址操作,得到:
&(P) == &(*(P))

根据取地址操作的定义,我们可以化简上式的右件,得到:
&(P) == P

那么,我们又可以得到:
若P == *(P),则P == &(P)。
即:P == *(P)是P == &(P)的充分条件。

由上述过程,我们可以得到:
P == &(P)是P == *(P)是充分必要条件,即前后两件是等效的。

那么,回到最开始的形式化定义,我们用P == &(P)的等价条件P == *(P)来替换之,即有:

若P == *(P),则P是一个指向它自己的指针。

现在我们构造一个指针:
  1. Tfoo * iptr;
  2. iptr = & iptr;
复制代码
根据定义,我们易知iptr正是一个指向它自己的指针。概念图如下:



上图的解释:正方形代表一个存储数据的空间,它外面的粗体字(iptr)是被绑定到它的标识符(有时候也不严格地被称作:变量名),它里面的则是被它所容纳的数据,由于这个数据藉由对某个对象(在这里就是标识符它自己所绑定的空间)的左值进行取地址操作得来,所以我们就用iptr自己来代表之,从正方形里面(数据)出发并且指向这个正方形(空间)自己的曲线带箭头,说明了该数据乃一个指向它自己所在空间的指针。

我们亦容易用iptr == *(iptr)来判定:iptr确是一个指向它自己的指针。

现在,我们再构造一个指针:
  1. Tfoo * jptr;
  2. jptr = iptr;
复制代码
因为jptr == iptr,又因为有前面的iptr == *(iptr),所以我们可以得到:

jptr == *(jptr)

事实也是如此。概念图如下:



上图的解释:左边一个正方形的里面(数据),乃是右边的数据的一个副本。横连左右两个正方形的那条直线带箭头,既标明了副本关系,又可以间接地揭示了对jptr的解引用,即是对iptr的解引用,因为中间的直线带箭头与右边的曲线带箭头,可以“统合”为一个指向iptr所被绑定到的空间的表示(即*jptr == *iptr == iptr)。但是,请注意:C实现不可能为iptr和jptr在起初就绑定同一块空间,所以,jptr并不是一个能够指向它自己所被绑定的空间的指针。

如上图所示,jptr乃iptr的一个副本。对jptr解引用,与对iptr解引用,两者效果是完全一致的。

但问题在这里出现了:jptr并不是一个指向自己指针!

我们可以确认jptr == *(jptr)这个事实,却发现jptr不是指向它自己的指针!—— 这与上面我们通过严格数学论证得到的“若P == *(P),则P是一个指向它自己的指针”这一定论截然矛盾!

问题究竟出在哪里呢?难道严格的数学论证工具,也会有纰漏吗?

为了解决这个迷局,我们必须重新审视“指针”这个奇妙的C事物,让我们回到事情的本身!

考虑以下概念图:



这是我们重新思考C程序上的数据对象,所得到的一种关于一个数据对象的逻辑事实的一个概念图(想象图)。我们来详解这个图。

先分述上图中的四个层。

最下面一层,叫做“Meta层”—— “Meta”一词来自希腊哲学,意思是“在……之后(发挥本原作用的)”、“关于……的……”,近代亦将之翻译为“元……”。自然地,层里的内容叫做“Meta值”,我们在左边一列都用“……值”的写法来表示每一层的意思。藉由粗浅的基本常识,我们可以认为:Meta值就是一个数据对象的“地址(Address)”,不过在这里,我们故意不称之为“地址”而以“Meta值”代之 —— 因为,在逻辑上,C本身其实并不关心地址这种范畴 —— C惟关心的是用来代表数据对象的标识符,可以被绑定到一个标的物上,并且,这样的标的物总是拥有唯一的(在一个虚拟机层次上不会重复的)值范畴(属性),此外,这一系列标的物在值范畴上,须要具有一定的连续性,而这种值范畴上之连续性,又与C实现为某一聚合类型的数据对象的成员在存储空间上进行分配之连续性,是耦合的 —— 这是C指针加法具有与数学上正整数加法相似的性质的基础。

再往上一层,叫做“Adverbial层” ——“Adverbial”的意思是“有副词性的”、“状语”。这个词,从构词法上说,由“ad”、“verb”、“ial”构成:前缀“ad”来自拉丁语,有“朝向……”、“为了……”、“附加于……”或“先于……”的意思;“verb”也来自拉丁语,有“言说“、“谓说”的意思;后缀“ial”在英语里表示“与……有关系的”的意思。而Adverbial值,专门用来记录数据对象的“类型”范畴。一个数据对象的类型范畴,总在程序声明它的时候,被知会于C的首要实现即编译器,并由编译器负责在编译时维护 —— 这是程序中含有类型错误就会首先被编译器发现(所谓“类型检查”)的原因。不过,编译器不是可以预知未来的设施,那些潜隐的类型错误或实际上导致了不符合用户预期的结果的类型上的错误,将在运行时暴发(甚至“不暴发”,那是最坏的事情),而那些错误,对于编译器来说是透明的。

把“类型”范畴叫做“状语”,是因为:C的实现总是藉由这样的信息,来决定该如何解释程序文本中出现的字面信息,以及该如何解释(读取)存储空间上的数据,及至该如何在实现的逻辑中为那些数据设置它们应有的角色,最终地,还要决定实现中的数据该如何符合用户感官预期(即又回到了字面层次) —— 即:“状语”决定了该如何言说、如何实现逻辑。

再往上一层,叫做“Logos层” ——“Logos”一词也是来自于希腊哲学,意思是“言说”、“逻辑”。一个数据对象的Logos值,就是用来代表它自己的那个标识符 —— 这是用户指称数据对象的第一抓手(Handle),也是C实现与用户在数据对象的指涉上达成“共识”的第一个界面。关于一个数据对象的Logos值,有几点须要注意:第一,一旦用户在程序中定义性地声明了一个数据对象,它的Logos值就被确定了,直至它被消灭或丢弃,它的Logos值都不会改变。注意,匿名的数据对象,是没有Logos值的,所以对于它们,是不存在声明行为的。第二,对于数据对象的定义性的声明,使得它的Logos值与它的Meta值得绑定在一起(有时也叫做“为变量分配存储空间”)。此外,引用性的声明,则不进行也无法进行这种绑定,最终的绑定,将留到将来才实现。第三,一个数据对象的Logos值,在同一个命名空间(其实也是“命名时间”)中,是唯一的,正如在同一个虚拟机层次上,数据对象的Meta值是唯一的一样。所以,在同一个命名空间或命名时间中,重复定义性地声明同一个标识符,将引发错误,这是“命名碰撞(Name Collision)”中最直观的一种。

最后,最上面的那一层,叫做“Enslaving层” —— “Enslaving” 一词是“奴役着……”的意思,这生动地表达了:Enslaving值,只是一个被其他抓手所奴役着的东西,它自己无法称为任何抓手,比如说:无论何人,都不可能将一个字面值当作左值使用。比如:
  1. int a = 123;
  2. int b = 123;
复制代码
对于少数初学者来说,上述片段,或许会给他们造成错觉,令它们实际上地认为a和b被绑定到了同一个数据空间上。除非一个具体的C实现有相应的出于经济性考虑的措施,否则,这种被错觉的事情,是不会发生的。此外,在这样的例子中,赋值表达式b = 456一般都是完全合法的 —— 不要感觉标识符b成了另外一个数据对象的Logos值。

      对于这样的情况,亦是如此:
  1. char a[]={'A','B','\0'};
  2. char b[]={'A','B','\0'};
  3. a[1]= 'X';
复制代码
不过,我们须要特别警醒自己:在这样的例子中,标识符a和b,它们是在存储空间中连续摆放着的'A'、'B'、'\0'数据系列的首个成员即'A'的Logos值吗?“除非数组名作为sizeof()或&操作符的操作数,此外在表达式中的数组名被转换为指向数组的首个元素的指针 ”这个论断,容易令初学者产生上述错觉。可以为他们澄清的理由是,该论断强调了“数组名是指针”,即,在这个意义上,数组名乃是一个指针类型的数据对象的Logos值,而非一个算术类型(比如一种被称为“字符类型”的整数类型)的数据对象的Logos值。然而,当数组名作为sizeof()或&操作符的操作数,它至少在逻辑上,是一个聚合类型的数据对象的Logos值,那样的表达式将得到关于一个聚合类型数据对象的“整体属性”。

我们知道,对于上面这个片段,C标准保证了它将与以下的片段完全等效:
  1. char a[]="AB";
  2. char b[]="AB";
  3. a[1]= 'X';            /*   *(a+1) = 'X'  */
复制代码
那么,这样呢?
  1. char *a="AB";
  2. char *b="AB";
  3. a[1]= 'X';            /*   *(a+1) = 'X'  */
复制代码
深受“数组名就是指针”这样的粗糙论断的感染,初学者习惯用自己的程序来实践这条“真理”,即用比较“白净”、“干练”的关于指针类型的数据对象的声明,来代替关于数组类型的。于是,就容易铸成大错 —— 这样的程序片段,在编译时,有很大可性可以被通过,然而,在运行时,程序可能“成功”,但更大的可能是崩溃。

这是为什么呢?因为C标准允许编译器为两个完全相同的字符序列构成的字符串常量(如上面的"AB")布置相同的存储空间。这样,那些追求空间经济性的编译器,就没有理由不充分利用这条规则,于是,它们将这样的字符串常量编制在已经成为目标文件的程序的被规定为“只读”的区域里 —— 在这样的区域里,只读数据与指令代码具有相同的可写性(不可写性)。

那么,在编译时界中,那些由程序员编写的将在运行时改写只读数据的源代码,仅仅被翻译了,而未有被实际地运行(即:真正可以发生效用的写保护机制,是在编译的产物中存在的,而在编译的过程中,这样的产物尚未娩成),所以编译器因着它没有未卜先知的能力,就不会报告错误 —— 除非被声明的标识符被const所限定。然而,程序进入了运行时界,那些将要改写只读数据的指令,将触犯某些机构(可能就是操作系统)为只读数据提供的保护机制。也就是说,这样的错误,将是程序自身所无法挽回的,除非程序在被编制为目标序列的时候,就已由语言的实现做了某种处理运行时异常的担保。

回到Enslaving值。它的变化,在程序中是最活跃的。在上图中,我们用字面值的样子来示写它,其实可以想到,在C实现或机器的内部,它并非是这个样子的 —— 之所以我们可以用字面值的样子来示写它,并且,C实现能够以字面值的样子将它呈现在用户的面前,都有赖于C实现利用了这个数据对象的Adverbial值这个事实。

综上,我们可以做出如下的总结。

在上述的四层结构中,一个数据对象的Meta值是严格恒定的 —— 其实,我们在上文中反复使用的“数据对象”这个不准确的术语,就是源自于C标准语境中的“对象(Object)”。而“对象”的含义就是:一块内存区域。(参见:Harbison III, S.P., C: A Reference Manual, 7.1) 而Meta值正是关于一块内存区域不可或缺的首要抓手。

并且,可以认为,程序只能为为一个数据对象获得Meta值,或者说:程序在为一个标识符定义性声明时,程序的实现就会为该标识符绑定一个Meta值,即,实现为变量或其他具有标识符的东西分配了存储空间。C语言为程序能够获取到一个已经被分配了存储空间的标识符(或数据对象)的Meta值,提供了方法,即取地址操作,操作符是 & 。 这种操作的结果,可以被保存到一个指针类型的数据对象里,具体地说,是被写为一个指针类型的数据对象的Enslaving值。请注意,此时我们用这个新的数据对象(指针),承载了一个对上面那个数据对象的引用。但是,仅使用这个指针的Enslaving值(用字面值的样子),或仅使用这个指针的Logos值(使用指针的名字),是无法让程序领会你需要直接操作被引用的那个数据对象的意图的。此时,C语言满足为用户这样的需求,提供了解引用操作(也叫间接操作),  操作符是 * 。一个解引用操作,可以被施加于上述指针的Logos值(指针的名字),也可以被施加于上述指针的Enslaving值(用字面值的样子),甚至可以被施加于一个匿名的字面值(即从来没有一个承载此字面值的有Logos值的数据对象),比如:
  1. *(char *)(0x22ff4d)='X';
  2. printf("%c\n",*(char *)(0x22ff4d));
复制代码
上面这个片段,其实还揭示了解引用操作表达式的另一个特性:如果一个解引用操作表达式是合法的话,那么,这个表达式的整体,将可以操作数据空间的抓手,即它是一个合法的左值。所以,如果p是一个合法的指针,那么以下表达式的结果,恒为1(真)。
  1. p == &(*p)
复制代码
一个数据对象的Logos值也是严格恒定的,不过这种恒定比Meta值的,要稍次一些。惟有当一个数据对象的Logos值不被视为左值的时候,这个Logos值将使得相关的操作取得该数据对象的Enslaving值并将其代入运算,即这个Logos值成为了右值。请特别注意:并非作为出现在表达式中操作符的操作数的Logos值,都是右值。考虑这个取地址表达式:
  1. &a
复制代码
这里的a,被视为一个左值,表达式的意图乃是取得Logos值为a的数据对象的Meta值。如果这里的a是一个右值的话,被取地址操作符作用的,则就是Logos值为a的数据对象的Enslaving值,这是没有意义的。同样的原则,也适用于sizeof()操作。

跟大多数初学者的观念不同,其实,一个数据对象的Adverbial值也是严格恒定的。只是,他们会认为类型转换操作,会改变一个数据对象的Adverbial值。请注意,类型转换操作的结果,不是一个左值。下面这些片段,体现了初学者的常见意图,它们都是错误的或无效的:
  1. int a = 123;
  2. (float) a;
复制代码
这个片段的意图是“将整数类型的变量a转换为浮点类型的”,即令Logos值为a的数据对象的Adverbial值由int变更为float。第二行代码中的类型转换表达式,没有任何的变更变量a本身的Adverbial属性(数据类型)的副作用。
  1. (float) a = 4.56f;
复制代码
这个片段的意图是将浮点数字面量赋值给“被转换为float类型的变量a”。这是错误的,赋值等号的左边,是一个类型转换表达式,它不是左值。

一个数据对象的Enslaving值,则是富于变化的。但是,请注意,如果从程序的实在角度看,这种变化,并非在编译时发生,而是在程序运行时发生 —— 要么程序自己的指令令它们发生变化,要么程序以外的事物(比如用户的输入干预)令它们发生变化。C语言为用户改变一个数据对象的Enslaving值,提供了赋值操作,操作符是 = 或 op=。当一个可以成为合法左值的表达式(比如一个数据对象的Logos值,或一个合法的解引用表达式)位于赋值操作符的左边,那么赋值操作符的右边的表达式的结果(右值)将被写入上述左值表达式所代表的数据空间。

在函数体内进行定义性声明的数据对象(有时也叫做“局部变量”),在声明之后,初始化之前,它的Enslaving值将是随机的。在数据对象被分配空间之前,没有什么机构会预先为它的Enslaving值置一个约定的值比如八进制常量0或其他有类似功能的值,在被被分配空间之后,也是如此,除非它的Enslaving值被初始化。所以,在这种情况下,Enslaving值就是一个“野值”。如果这个数据对象是指针类型的话,它就是一个“野指针”。所以,野指针是Enslaving野值在指针类型数据对象上的一个实例。

对于那些具有静态存储期限(Static Storage Duration)的数据对象,包括外部变量、用存储类别指定符static所指定的局部变量(令原本应是自动存储期限的局部变量,变成了静态存储类型的),如果在定义性声明(分配空间)之后,至终没有进行初始化,那么实现将为它们的Enslaving层赋予一个约定好了的值。比如,对于指针类型的数据对象来说,这个约定的值是NULL。由于这样的值都是约定了的 —— 约定,意味着可以跨越编译时和运行时(或载入时)的界限 —— 那么,追求空间经济性的编译器,将把未来(运行时的起头)会被赋予约定值的数据对象,统统编入目标序列的某个不占用实际空间的段中,比如BSS段,此后,在程序运行的起头,那些数据对象总归会被赋予约定好了的Enslaving值 —— 这就好比,去阴间的死人,总归将在阴间使用冥币,那么就无须在棺材里预留人民币了。

下面,我们就要回到“有jptr == *(jptr),但jptr却不是一个指向自己的指针”这个谜题上来。按照我们上面所阐释的数据对象的四层结构,我们不难得到关于iptr和jptr的概念图。



我们重列一下相关的代码片段,如下:
  1. Tfoo * iptr, * jptr;
  2. iptr = & iptr;
  3. jptr = iptr;
复制代码
上面的三行代码,可以与上面的概念图的每一个部分,相互阐释。

第一行代码,作为定义性声明,令实现为两个数据对象分配了空间,即:Logos值为iptr的数据对象有了它的Meta值,该值是0x0123;Logos值为jptr的数据对象有了它的Meta值,该值是0x0456。此外,声明还令实现开始维护两个数据对象的类型信息,即它们的Adverbial值,两个值都是Tfoo*。因为仅仅是为数据对象(标识符)进行声明,尚为对它们的Enslaving值初始化,所以,此时它们的Enslaving层里面的,都是随机值(如果是外部变量,则将被赋约定的值,即NULL)。

第二行代码,是一个仅由赋值表达式构成的语句。该赋值表达式中赋值操作符的左边,是iptr,即意味着将有一个数据值被写入iptr的Enslaving层。而赋值操作符的右边,则是一个取地址表达式,取地址操作符的操作数是iptr,即意味着取地址操作将获得iptr的Meta值,这个值是0x0123。所以,0x0123就被写入iptr的Enslaving层。于是,根据指向自己的指针之定义,我们可以确定:一个指向自己的指针iptr就构造好了,正如图中所显示的它的样子那样。

如果此时我们对iptr进行解引用,那么,我们将得到一个Meta值为0x0123的数据对象,这个数据对象恰恰就是iptr它自己。所以,“iptr == *(iptr)”为真。

第三行代码,也是一个仅由赋值表达式构成的语句。该赋值表达式中赋值操作符的左边,是jptr,即意味着将有一个数据值被写入jptr的Enslaving层。而赋值操作符的右边,单单是iptr的Logos值,这意味着,这个iptr的Logos值将被用来取得它的Enslaving值,即0x0123,这个值最后被写入jptr的Enslaving层。于是,我们实际上获得到了一个iptr的“副本”。

如果此时我们对如果此时我们对jptr进行解引用,那么,我们将得到一个Meta值为0x0123的数据对象,这个数据对象则是iptr。而且,“jptr == *(jptr)”也为真 —— 为什么?我们需要重新仔细思考这个相等操作符表达式。在此表达式中的相等操作符的左边,单单是jptr的Logos值,这意味着,这个jptr的Logos值将被用来取得它的Enslaving值,即0x0123;而相等操作符的右边,是一个解引用表达式,解引用操作符的操作数,单单是jptr的Logos值,这意味着,这个jptr的Logos值将被用来取得它的Enslaving值,即0x0123,那么,对0x0123进行解引用操作,将得到一个Meta值为0x0123的数据对象(一个合法的左值),这个数据对象正是iptr。于是在相等操作符的右边的,其实就是数据对象iptr,具体的说,应该是iptr的Enslaving值(即合法的左值在这里扮演了右值的角色),这个值就是0x0123。那么,相等操作符左右两边的操作数,是数据类型一致为Tfoo*的右值0x0123,所以,“jptr == *(jptr)”这个相等操作符表达式的结果为1(真) —— 这个分析过程,同样也适用于上面的“iptr == *(iptr)”。

现在,我们面临的巨大困惑是:“jptr == *(jptr)”为真,但jptr显然不是一个指向自己的指针。而我们之所以有这样的困惑的原因,在于我们起先已经通过严格的数学工具,论证了“P == *(P)是P为一个指向它自己的指针的充分条件”。但摆在我们面前的现实是:P==*(P)并非是P为一个指向自己的指针的充分条件!

问题到底出在哪里呢?…… 我们依赖的严格的数学工具,难道有什么问题吗?

从我们的数学论证过程的起头开始看。我们首先注意到,我们利用了一个最最基本的数学上的定论:

若a,b都在函数f的定义域上,且a == b,则一定有 f(a)==f(b)。

又注意到,在我们的论证过程中,我们分别将解引用操作与取地址操作,作为函数f的一个实例。这样的符号实例化,有什么问题吗?

问题似乎变得明朗起来 —— 对于用到字母代表数学量的数学工具,那些数学工具总是将字母视为它所代表的数学量的值!我们利用这样的数学工具,求获字母a和字母b是怎样的数学关系,其实是在求获字母a和字母b所代表的数学量的值之间的数学关系。用C语言的话说,字母a和字母b在数学工具中,被当作右值来处理,而不是左值!对于数学工具来说,是不存在左值这个概念的,因为:在数学工具中,字母所代表的数学量完全没有空间(位置/地理)属性。所以,将解引用操作作为数学工具中函数f的实例化,是保真的,因为解引用操作的操作数是右值。而将取地址操作作为数学工具中函数f的实例化,则就坠入了陷阱 —— 因为取地址操作的操作数是左值。两个数据对象的Enslaving值相等,根本不能保证它们的Meta值也相等,甚至可以直接说,两个数据对象之所以是“两个“,就在于它们有着不同的Meta值 —— 它们存在于数据空间上的不同的地理位置。这里也体现出Meta属性在哲学上的重要意义:它是先的,它是元的,它为对象之间的区分,提供了最本原的保障机制。

至此,我们完全破解了关于指针的第一个迷局。总结一下我们硕果仅存的重要结论:

对于一个合法的指针P,若有P == &(P),则P是一个指向它自己的指针,简谓之,P是一个自指指针。

上述重要而可贵的论证,为我们提供了一个战利品 —— 一个宏,我们用它来判别一个指针是否是自指指针,若是,则打印“…… is Alpha!”,否则打印“…… is NOT Alpha!“。 这里的“Alpha”的意思就是“自指指针”。
  1. #define ISALPHA(x) \
  2.                     if(x == &x) \
  3.                     printf(#x" is Alpha!\n");\
  4.                     else printf(#x" is NOT Alpha!\n");
复制代码
或许有人要问,这里为什么用这么不优雅的宏,而不用函数呢?理由自会在后面得以分晓。

(未完,转至下一楼

作者: Ager    时间: 2014-04-11 19:34
本帖最后由 Ager 于 2014-04-11 21:16 编辑

(此,接续自上一楼

现在,或许有初学者觉得,通过仔细理解上面的阐述,自己已然可以对指针有深刻的掌握,于是,他们变得跃跃欲试,开始着手构造一个自指指针了。他们可能会编写如下的代码片段:
  1. int * p;
  2. p = &p;
复制代码
任何一个负责任的编译器,对此都会发出这样的抱怨:
  1. incompatible pointer type
复制代码
真是出师未捷!编译器其实已经否定了这段程序的正确性 —— 数据对象在类型上,出了问题!

所以我说,不要以为了解了以上篇幅中的关于指针的解说,就能真正掌握指针,乃至构造出一个合法的自指指针!—— 还差得远呢!

我们在前述内容中,已经解说了关于数据对象的四层结构,其中从下往上数的第二层,是Adverbial层。在关于“jptr = iptr“的例子中,概念图里为它们所标出的Adverbial值是”Tfoo *“。现在的问题是:Tfoo类型具体是什么?我们如何构造一个C标准允许的Tfoo类型,使得该类型上可以有一个自指指针?

请注意:对于任何类型T,都可以构造关于它的指针类型,即“指向T的指针”。 (参见:Harbison III, S.P., C: A Reference Manual, 5.3。)

现在,我们要构造一个指向自己的指针,它必然是一个“指向T(某种类型)的指针”。既然上面说了 —— “对于任何类型T,都可以构造指针类型”,那么,必然存在一种类型(T),令一种指针可以指向它,而这个指针是指向它自己的,除非:指向自己的指针在C中是不合法的,但是,这么一来,“对于任何类型T,都可以构造指针类型”这句话,好像就是错的,取而代之的“正确“做法是:把“能被指向自己的指针所指向的类型“排除在”任何类型“之外。其实,更严格地说,上面那句话在数理形式上的表达,应该是这样:

∀合法类型的T, ∃ T *。

现在的问题是:如果对于指向自己的指针来说,被它指向的(即它自己)的类型是合法的T,那么,这种类型(T)是什么样的类型?而指向T的指针,又将是什么样的类型呢?是 T * 类型吗?

如果指向T类型的指针是T * 类型,并且这个指针(T * 类型)是指向它自己的,那么,T * 类型就应该是T ** 类型的 —— 因为:指向S类型的指针是S * 类型的(我们用字母S代换了前面的T *)。同理,从另一个方向看,T类型能够被T * 类型所指向,又因为这里的T *类型是指向它自己的,所以,T *类型所指向的T类型就是它自己的类型,这样的话,T类型就应该是T * 类型的。

如果你还没有头昏的话,可以得到这么一个结论:如果指向自己的指针所指向的之类型是T,那么,T类型就是T * 类型,就是T **类型,而这三者是一致的类型。

但是,这样是可能的吗?对于C语言,这意味着什么?

暂时抛开“纯粹”的形上逻辑,让我们谈谈编程语言对指针的概念实现。首先,回到关于设置指针的宗旨,或指针的定义 —— 指针,作为一种数据类型,定义了一类数据对象,它们的Enslaving值是其他数据对象的Meta值。而构造指针类型的单个数据对象,在编程语言中,基本上有两种方式:

第一,指针仅能引用一种类型的数据对象。在形式上,指针数据对象的Adverbial值被限制为它所要引用的数据对象的Adverbial值加上“*”。Ada、Pascal、C都采用了这种方式,并且语言实现采用了静态类型检查。

第二,指针可以引用任何类型的数据对象。请注意,这里虽然出现了“任何”这个观念,但它并表明是静态上的“任何”,实际上,它是一种动态上的“任何”,即:允许指针在程序的运行时指向多种类型的数据对象(于是就似乎可以让T*在运行时中跃迁为T**,再跃迁为T***等)。这种方式的典型例子是Smalltalk。相关的类型检查在程序运行时维持,并且,数据对象在程序运行时仍具有类型描述符范畴。

所以,对于C语言来说,如果T和T *和T **是一致的类型,就必须保证它们是静态上的一致,即,在运行时前、编译期内,它们就必须被类型检查机构判定为合法。

回到上面的初学者代码 —— 看到上述抱怨信息,我们第一个想到的,就是使用一个名叫“类型转换“的操作符(即赋值操作符的右操作数是一个类型转换表达式)。于是有:
  1. int * p;
  2. p = (int *)&p;
复制代码
即:把 &p 这个int ** 类型的转换成了int * 类型的。不过,既然是使用了类型转换 —— 即不使用类型转换就不合法,就严格地意味着:int ** 类型与int * 类型不是一致的类型。

其实还有:int * 类型与int 类型也不是一致的类型 —— 如果一致的话,最先一个例子就应该合法,即:“p = &p“(假若)能够合法,在于左边的p是int *类型且右边的p是int类型,即int *类型与int 类型一致。也可以反过来说,如果int * 类型与int 类型一致,该赋值表达式就是合法的。

这样看来,int**类型、int*类型、int类型,都不是我们期望的那种“T”类型。之于其他算术类型,情况亦是如此。

在C中,是否存在一个“通用指针”的类型,使得它可以被转换为任何类型的指针而不违反标准?—— 当然是有的,并且C标准提供给用户的这种工具,正是叫做“通用指针”,有时也叫做“泛型指针”,即void * 类型。

之前我们曾经说过,一个数据对象的Adverbial值,由实现维护,并且可以让实现藉此决定,该如何将以机器内部样式存在的Enslaving值解释或表示出来。若一个数据对象被实现所维护的Adverbial值形如“T *”,那么这个数据对象就是指针。对于指针,实现之所以关心它的Adverbial值,是因为实现要为将来的解引用操作和加法减法对指针的操作做好预备 —— 作为解引用的结果,一个数据对象作为合法的左值,或在它成为一个右值的时候,即需要操作它的Enslaving值的时候,实现必须已经明确该用何种类型(方式)来写入(构成机器内部序列)或解读(构成符合程序逻辑的或符合用户感官预期的序列)。而作为加法减法的操作数,一个指针的Enslaving值,将被加上或减去多少(Meta值域的元量),或者将得到多大的“指针差”(ptrdiff_t类型上的数量),都是实现藉由该指针的Adverbial值来决定的。

我们不难理解,对于具有Tbar * 类型属性的指针(意思是Adverbial值为Tbar *的指针,而不是指向Tbar * 类型数据对象的指针),合法的解引用操作结果,一定是其Adverbial值为Tbar的数据对象,而合法的加法减法操作结果,一定与所操作的指针的Adverbial值有着洽适的关联。那么,对于具有void * 类型属性的指针,“合法”的解引用操作结果,一定是其Adverbial值为void的数据对象,即这个数据对象是“void”(废弃、无效、败坏)的,所以,这样的解引用是非法的。同理,对于具有void * 类型属性的指针,加法减法操作也是“void”的。

这里顺便提一点,由于“void”一词在非计算机学科专用的英汉词典中,被简单地解译为“空的”,参见下图:



因此,不少初学者将void * 理解为“空指针”,其实是和真确意义上的空指针(Null Pointer)混为一谈了,后者的意义是:不指向任何地方的指针。用来表示空指针的宏,是NULL,它被C标准规定必须在头文件<stddef.h>中获得定义,用来定义它的,是值为0的任何整数表达式,在一些编译器所提供的头文件中,这个整数表达式将被转换为void *类型。这么说,也是反过来暗示:空指针并非必须(天然地)是void *类型的,其实,根据设置空指针的起初宗旨,我们不难理解:每一种指针类型都需要在概念中有一个用来表示“不指向任何地方”的指针实例,这个实例就是各种指针类型上的空指针,而不必然是void *类型上的空指针。总之,将表示空指针的宏定义为void *类型的,仅仅是C标准所允许的一种技巧性行为。此外,虽然用值为0的任何整数表达式来定义宏NULL,但这并不意味着空指针的Enslaving值就是各位全为0的Meta值,总之,空指针的Enslaving值到底在机器的内部以何种样式存在,以及怎样的Meta值才会被视为“哪里也不会有”,这都是编译器设计者的专业工作。作为编写源代码的用户,只需要知道:一个指针在概念上的Enslaving值若是某个值为0的整数,那么,这个指针就是空指针。

顺便说一下,单纯从词语本义上说,“void”是“空的”的意思,那是完全没错的。这个词来自于拉丁语,意思是“没被占用的”,比如一间屋子没有人用或没人待在里面我们就说“屋子是空的”,这是说得通的。此外,“vacuum”(真空)跟这个词是同源的。而至于“null”,也是来自拉丁语,意思是“一个也没有”,可以延伸为“零”的意思。

我们用通用指针类型来构造自指指针,是十分简单顺利的:
  1. void * p;
  2. p = &p;
  3. ISALPHA(p);
复制代码
这里甚至不需要类型转换操作也是合法的,这样看来,&p所获得一个指向通用指针的指针(即void**类型)与通用指针(即void*类型)是相兼容的 —— 事实也的确如此,不过,其中的原理在于这一点:void*类型总与其他类型的指针相兼容 —— 在这个例子里,“其他类型”就是void**类型。而另一方面,void**类型并非总与其他类型的指针相兼容。可以考虑以下代码片段:
  1. void * vip, ** vipp;
  2. int * fip;
  3. fip = vip;   /* 兼容 */
  4. fip = vipp   /* 不兼容 */
复制代码
现在,我们终于找到一个符合构造自指指针的类型“T”了,就是void。

接下来,我们将寻找“T”的视野,拓展到C的聚合类型。

首先,我们看数组类型。观察以下片段:
  1. int fig[123];
  2. ISALPHA(fig);
复制代码
这段代码在被编译时,会被抱怨有类型上的问题,但编译还是完成了。如果将ISALPHA宏定义中的“ x == &x ”改为“ x == (int*)&x ”就可以规避这个问题。

这段代码的运行结果,告诉我们fig“是一个自指指针” —— 这或许会让初学者们大吃一惊,因为我们并没有对fig指针作任何特殊的处理,比如取地址和赋值操作。

难道一个数组标识符(有时候也叫:数组名)天然地就是一个自指指针吗?基于直觉的判断,是否定的。但我们还是应该弄清其中的缘由。我们注意到,程序用相等操作符表达式fig == &fig 来判断fig是否为一个自指指针。我们知道,凡出现在表达式中的数组标识符,它的类型就从“T的数组”经由寻常转换而变成“指向T的指针”且它的值将称为指向该数组的首个元素的指针,除非数组标识符是sizeof()操作符或取地址操作符的操作数。那么,在相等操作符表达式fig == &fig中,相等操作符左边的fig,将转换为指向该数组的首个元素的指针(类型是int *),而操作符右边的,则是一个取地址表达式,fig成了取地址操作符的操作数,此时取地址表达式将获得指向整个数组的指针(类型是int (*)[123]),上述二个数据对象具有迥异的Adverbial值,但却具有相同的Enslaving值,所以fig == &fig或安全的做法fig == (int*)&fig就可以得到为1(真)的结果。

fig == &fig之所以能够成立,其原理可以被夸张地等同于 a==a 能够成立,即:单一事物的自等性,藉由C语言的对于数组标识符的规则,衍变而成了一种表面上的指针自指性。之所以说是“表面上的”,那在于:fig == &fig 表达式中两方的fig并非是同一个指针(这跟我们本初设置这个用来判别的表达式的宗旨有异):左方的fig不是指代整个数组而是指代指向数组首个元素的指针,而右方的fig根本就不是指针而是指代整个数组。而排除了“表面上的”因素,其中实在的作为原理性因素的事实是:右方那个被视为指代整个数组的fig,经由取地址操作,被产生了一个指向整个数组的指针,而这个指针的Enslaving值,与指向该数组首个元素的指针的Enslaving值,是相等的 —— 这是以作为聚合类型的数组及其元素在空间上的存在方式作为保证的。

此外,我们须要特别注意: 正如上面已经说到的,C标准或关于C标准的文献所提到的关于数组标识符的规则是“凡出现在表达式中的数组标识符,它的类型就从‘T的数组’经由寻常转换而变成‘指向T的指针’且它的值将称为指向该数组的首个元素的指针,除非数组标识符是sizeof()操作符或取地址操作符的操作数”。那么,如果这个表达式是赋值表达式呢?出现在赋值表达式中的数组标识符,实际情况仅仅是上述原则所透露的那么简单嘛?

初学者经常试图使用数组标识符“将一个数组赋值(复制)到另外一个数组”,比如会编写如下片段:
  1. int fig[123];
  2. int feg[123];
  3. fig = feg;
复制代码
如果粗糙地理解“在表达式中数组标识符将被转换称为指向数组首个元素的指针”这个规则的话,我们会这样理解上面的第三行代码中的赋值表达式,即:将指向 fig数组首个元素的指针变更(被赋值)为指向 feg数组首个元素的指针。显然实际效果,并非如此。

或许有人会这样解释:试图改变一个数组名的指向(指向数组的指针之指向,或指向数组首个元素的指针之指向)是非法的。—— 虽然这种理解中提到的宗旨,即一种对数组的保护机制,是很容易理解的,并且是合理的,但是,似乎C标准中并未明确有这样的规定。

那么, C标准是如何确保上述对数组的保护机制被实现的呢?—— 这跟赋值操作符本身的特性有关。我们注意到,C标准对于赋值操作符有这样的规定:数组名不是可以修改的左值,它不能出现在赋值操作符的左边。更进一步地对C标准的规定进行回溯:作为左值的数组类型、不完整类型或被const限定的类型 …… ,是不允许被修改的左值。

因为上面例子中的左值是数组类型,所以它是不可修改的 —— 这真的就是“fig = feg”之所以非法的全部原理吗?那么,我们要问:假若把赋值操作符左边的fig视为一个已经换转为指向该数组首元素的指针,那么,它还是那种不可修改的左值吗?好像是的。《高质量程序设计指南——C++/C语言》在7.2中,是这样说的:

数组名字本身本身就是一个指针,是一个指针常量,即a等价于int * const a,因此你不能试图修改数组名的值。

按照这个说法(这个说法在国内许多技术讨论区中被援引),数组名a等价于int * const a,显然这个a还是左值,至少可以是左值,结合上面提到的“被const限定的类型是不允许被修改的左值”,就可以得出结论:这里的a是那种不允许被修改的左值。—— 但是这样的解释,是正确的吗?

我们注意看C99标准(6.3.2.1.3):

Except when it is the operand of the sizeof operator or the unary & operator, or is a string literal used to initialize an array, an expression that has type ‘‘array of type’’ is converted to an expression with type ‘‘pointer to type’’ that points to the initial element of the array object and is not an lvalue.

C标准已经明确了这一点:一个原本具有数组类型的表达式若被换转为指向该数组首个元素的指针,那么,所得到的那个指针不是左值! —— 这意味着,这样的指针,根本不能作为赋值操作符的左操作数。

这样说来,即便我们将赋值操作符左边的fig视为一个指向该数组首个元素的指针,它也不能作为赋值操作符的合法左操作数(因为它不是左值,而只有左值才能作为赋值操作符的合法左操作数)—— 将这个“不可能的可能性”都排除了,我们似乎获得了“fig = feg”非法的真相的全部 —— 不过,我们还是要追问:真的是如此吗?

我们注意看手头上的编译器,对“fig = feg”的报错信息。如果编译器确因fig不是左值而报错的话,那么它给出的信息应该是“error: lvalue required as left operand of assignment”这样的。然而,实际上它的报错信息,却是这样的:
  1. error: incompatible types when assigning to type 'int[123]' from type 'int *'
复制代码
编译器在这里强调了错误的原因是:赋值操作时的类型不能兼容! —— 这个理由,是上面已经给出的所有理由都无法解释的。我们还是回到赋值操作的本身上来。我们注意到,C标准对赋值操作的过程,有这样的表述:

赋值操作的结果之类型等于左操作数的(未转换且未限定 )类型。而这个结果将被保存到左操作数。

—— 这样的规定,保证了“fig = feg”的结果一定是fig本来的类型,即数组类型 int[123]。在赋值操作的两个操作数都是算术类型的情况下,在操作间会发生寻常赋值转换,使右操作数转换为左操作数的类型,然后将这个具有新类型的值保存到左操作数中(赋值操作表达式的副作用即告完结)。然而,在这里,作为右操作数的feg已经被转换为int *类型,这种类型是无法被进行寻常赋值转换而成为int[123]类型的。所以,“fig = feg”操作,首先是一个类型或类型转换上的错误。我们手头上的编译器,正是准确而细致地报告了这一错误,看来,这个编译器,是非常遵循C标准的。

下面,我们看结构类型。

我们知道:C语言的结构,从构造上看,就不允许包含自身,但可以包含指向自身的指针。于是,C语言藉着这个简单的规则,向用户提供了实现复杂而精妙算法的丰富可能性。基于这种规则允许结构包含指向自身的指针,我们就有了构造自指指针的可能性。也就是说,我们将一个结构构造中指向该构造自己的指针,实现为指向它自己的指针,那么,这里的充分条件是:该结构构造是以指向该构造的指针为起头的。于是,我们可以编写这样的片段:
  1. struct hog
  2.     {
  3.         struct hog *p;
  4.         int a;
  5.     } hig;

  6. hig.p = &hig;                        /*(<- 注) */
  7. ISALPHA(hig.p);
复制代码
(注 ->)上面这个代码片段,在本文的写作过程中,最早由@Starwing83给出。

请注意,在结构hog的构造中,成员int a被放置在了成员hog *p的后面,如果颠倒过来,即hog *p作为结构hog构造的第二个成员,却在将来的实例hig.p上指向结构实例hig(的起头),那么,hig.p将不是一个自指指针。

此外,利用ISALPHA宏对指针hig.p进行判别的时候,仍无法避免类型上的问题,不过可以被顺利的转换。

接下来,我们看联合类型。

跟常见的初学者的误解不同,C的联合类型并非是聚合类型的一种。在C中,联合类型独立于包含着数组、结构的聚合类型之外。联合类型与结构类型,具有非常大的差异。我们首先看以下代码片段:
  1. union pog
  2. {
  3.     union pog *p;
  4.     int a;
  5. } pig;

  6. pig.p =&pig;
  7. ISALPHA(pig.p);
复制代码
虽然联合类型与结构类型的原理不同,但上面这个片段跟再上面的那个关于结构类型的例子,在原理上是类型的,即:在一个联合构造中,设置一个指向该联合构造自己的指针。这个已经不难理解。

然而,体现出联合类型与结构类型之不同的,是我们将该联合构造的各成员位置,在编写声明的时候,位置颠倒一下,即:
  1. union pog
  2. {
  3.         int a;
  4. union pog *p;
  5. } pig;

  6. pig.p = &pig;
  7. ISALPHA(pig.p);
复制代码
如果类型的行为,发生在编写结构构造的过程中,那么,那个指向结构实例的指针将不再是一个自指指针,但是,在联合构造中,这样的颠倒,并不会妨碍这个指向联合实例的指针成为一个自指指针。这是为什么呢?

首先,联合类型的原理,决定了每一个联合实例的成员,都具有相同的Meta值,也就是该联合实例的Meta值 —— 相同的Meta值,意味着它们以同一个Meta值为空间存在的起点。那么,不仅对该联合实例进行取地址操作,可以构造出一个自指指针,而且,对该联合实例的任何成员进行取地址操作,都可以构造出一个自指指针,比如,将上面的 pig.p = &pig 更改为 pig.p = &pig.a 也可以令 pig.p 成为一个自指指针,进而利用 pig.p = &pig.p 也可以。此时,该指针能够指向自己的原理,已经不再是该指针指向它所在的联合的实例,而是嬗变为:它总是被赋予了任何具有与自己一致Meta值的数据对象(联合实例的成员、联合实例)的Meta值。也就是说,联合类型本身具有的“其实例的各成员的Meta值,与其他成员的Meta值一致,并亦与该实例本身的Meta值一致”这一特性,才是我们可以使联合构造所包含的指针被构造为自指指针的真正原理。

其次,我们须要揭示一个被大多数人所忽略的实际上的原则:联合构造所包含的各个成员之声明,在代码中的编写次序,不会影响它们将来会被获得的Meta值。而这一点,在结构类型上就不适用,因为 —— 结构构造所包含的各个成员之声明,在代码中的编写次序,决定了它们将来在数据空间上的存放次序,并且它们不会具有相同的Meta值。也就是说,之于结构类型,其成员在数据空间上的次序,是敏感于其声明在代码中的编写次序的。

接下来,我们还注意到,在上面的例子中,除了“pig.p = &pig”中赋值操作的右操作数是严格符合该指针(即左操作数)的类型的之外,“pig.p = &pig.a”和“pig.p = &pig.p”等赋值操作,都包含了不符合左操作数类型的右操作数,所以编译器在翻译这些赋值表达式的时候,都会发出抱怨。而下面的片段,给出了一个比较优美的例子:
  1. union pog
  2.     {
  3.         int *p;
  4.         int **pp;
  5.     } pig;

  6. pig.pp = &pig.p; /*(<- 注) */
  7. ISALPHA(hig.p);
复制代码
(注 ->)上面这个代码片段,在本文的写作过程中,最早由@Starwing83给出。

接下来,我们看函数类型。

首先需要说明的是,函数虽然是C的类型的一种,但函数类型的实例(或者说,函数类型的值)却在C标准的语境中被排除在“对象”概念之外。所以,作为一个与“对象”相对的概念 —— “函数指示符(Function Designator)”具有函数类型的值,却无法具有左值的地位。函数指示符在代码中的表示的一种,是函数的名称,并且,对指向函数的指针进行解引用的结果,也是函数指示符。除了函数指示符之外,函数调用表达式也不能成为左值。同时,请注意这样的例子:
  1. int *ted (int *p)
  2. {
  3.     return p;
  4. }

  5. int main(void)
  6. {
  7.     int x = 123;

  8.     *ted(&x) = 456;
  9.     printf("%d\n",x);
  10.     return 0;
  11. }
复制代码
在这个例子中,“*ted(&x) = 456”并非一个以函数调用表达式作为左值的表达式,在这里,作为左值的是一个解引用表达式。

此外,C标准保证:如果对函数指示符进行取地址操作,那么结果将是一个指向该函数实例的指针。另一方面,如果对函数指示符进行解引用操作,那么在操作间,函数指示符将被转换为指向该函数实例的指针。所以,我们可以得到:

若有 void tod(void){},
& tod => 结果是:指向tod的指针;
* tod => 过程中,tod换转为指向tod的指针;
那么,* tod 和 * & tod 是等效的,
即:对于解引用操作,函数指示符与指向函数的指针是等效的。

请注意,之前提到了:对指向函数的指针进行解引用的结果,是函数指示符。所以,* & tod 的结果,就是 tod。那么,* tod 和 * & tod 和 tod 三者是等效的。由于* tod 和 tod 是等效的,就可以将 tod 与指向 tod 的指针即& tod视为等效的。由于这里的 tod,可以指代其他一切函数指示符,所以,我们得到结论:

对于任何一个函数指示符,与一个指向该函数实例的指针,是等效的。

所以,任何一个函数指示符,可以被视为一个指针,并且,这个指针指向该函数指示符所代表的函数类型的实例。简谓之,任何一个函数指示符,都是自指指针。

对于已经定义了的函数 tod,我们可以通过ISALPHA宏来检验tod的函数指示符:
  1. ISALPHA(tod);
复制代码
整个程序在编译时和运行时,均没有任何警告和错误,验证的结果是:tod是一个自指指针。

最后,我们要解答上面产生的一个疑问:为什么我们用宏,而不是用一个设计好的函数,来验证指针呢?

我们用编写函数的方式,来取代宏 ISALPHA:
  1. void IsAlpha(void * x)
  2. {
  3.     if(x == &x)
  4.         printf("It is Alpha!\n");
  5.     else printf("It is NOT Alpha!\n");
  6. }
复制代码
那么,在任何情况下,对于那些已经被验证了是指向自己的指针,都将得到它不是自指指针的结果。这是为什么呢?—— 原因在于:

在IsAlpha函数体内,用来进行比较的是指针 x ,而这个指针仅仅是该函数中的一个局部的数据对象。对于任何函数内部的即局部的数据对象,只是作为被传递的实际参数在值范畴上的拷贝,而不是被传递的实际参数本身。即,这个局部数据对象仅仅在函数被运行的起头,其Enslaving值被赋予了实际参数的Enslaving值,但它的Meta值是不可能与实际参数的Meta值相同的,因为函数例程为其局部数据对象所实现的储存,是不可能占用实际参数的数据空间的。所以,该局部数据对象的Enslaving值是实际参数的Enslaving值,如果实际参数是一个自指指针,那么,实际参数的Enslaving值与Meta值是一致的,这样,该局部数据对象的Enslaving值就是实际参数的Meta值,却不可能是它自己的Meta值。所以,在IsAlpha函数体内,作为局部变量的 x,永远不可能是一个自指指针。该函数永远不会给出“是自指指针”的结果 —— 这跟前面例子中所提到的“jptr 看似是一个自指指针,实际却不是”的道理,非常类似,在那个例子中,jptr是iptr的一个值范畴上的拷贝,却不是iptr本身,所以,若iptr是一个自指指针,那么jptr就一定不是一个自指指针。

那么,我们如何让函数处理的对象,是我们业务逻辑上需要处理的对象本身呢?—— 当然就是利用指针了。我们在上面已经提到,指针是C语言向用户提供的一种引用机制,即可以藉由指针获得某个被引用者的Meta值。而函数的局部变量只会被生成为实际参数的一份值范畴上的副本,所以,我们如果向函数传递一个关于被引用者的指针,那么,这个被引用者的Meta值藉由实际参数的Enslaving值,被复制到由函数生成的它的局部数据对象的Enslaving层上,即函数的局部数据对象的Enslaving值,即是被引用者的Meta值,进而,在函数内部对作为指针的局部数据对象进行解引用操作,就将获得一个被引用者的左值,此时,在此左值上的操作,就是在被引用者上的操作了。

最简单的例子,可以是这样:
  1. void foo(int *x){
  2. (*x)++;
  3. }
复制代码
可以在其他函数内,做这样的调用:
  1. foo(&a);
复制代码
即可以令int类型实例a,获得与a++等效的操作。

所以,我们改写函数IsAlpha为:
  1. void IsAlpha(void ** x)
  2. {
  3.     if(*x == &*x)
  4.         printf("It is Alpha!\n");
  5.     else printf("It is NOT Alpha!\n");
  6. }
复制代码
并且可以在其他函数内,做这样的调用:
  1. IsAlpha(&p);
复制代码
如果这里的p是上面例子中被验证了的一个自指指针的话,函数IsAlpha将给出“是自指指针”的结果,而若p是上面例子中被验证了的一个自指指针的话,函数IsAlpha将给出“是自指指针”的结果,即:函数IsAlpha的效用与宏ISALPHA的效用是一致的 —— 但是,对于函数tod,则是一个例外,这是为什么?

函数tod,是我们验证的“最完美”的一个自指指针,反而在被改进了的函数IsAlpha中却得不到正确的结果,这是为什么?

我们回顾一下函数IsAlpha的定义。在改进了的函数IsAlpha中,局部变量 x 是一个指向通用指针的指针 —— 注意,与通用指针不可以被解引用不同,指向通用指针的指针是可以被解引用的,解引用的结果就是一个通用指针。而这个由解引用所得到的通用指针,其实就是被转换为了通用指针的我们需要检验的指针自己。所以,函数IsAlpha内部可以检验我们需要检验的指针。但是,如果需要检验的指针是作为指针的函数,就是一个例外。以需要检验的指针是作为指针的函数指示符tod为例,IsAlpha(&tod)这样的调用,令函数IsAlpha的局部变量 x (类型是指向通用指针的指针) 的Enslaving值被赋予了指向tod函数的指针的Meta值(这里我们将tod视为指向tod函数的指针)。

进而,函数内部对这个x的Enslaving值进行解引用,将得到一个通用指针(我们希望:这个通用指针就是指向tod函数的指针,即这个通用指针的Enslaving值是tod函数的Meta值),然而,实际上,这个通用指针的Enslaving值不会是tod函数的Meta值,为什么?—— 因为C标准规定了:任何指向对象或不完整类型的指针,都可以转换为通用指针,但指向函数类型的指针是例外!也就是说:如果将指向函数类型的指针转换为通用指针,将是一个错误的行为。所以,对x的Enslaving值的解引用,会产生一个通用指针,这实际上就是把一个指向函数类型的指针,转换为了一个通用指针,而这种行为是错误的,所以这个通用指针的Enslaving值将是一个不符合我们预期的“错误值”。

回到 *x == &*x 这个表达式。相等操作符的左边,*x,是一个通用指针(因为x是指向通用指针的指针,所以对它解引用,就是一个通用指针),如果被传递到函数IsAlpha的,是指向那些除指向函数实例的指针之外的指针的指针(即这些指针的引用),那么*x 这个通用指针,就是那些除指向函数类型的指针之外的指针本身(因此我们才能用函数IsAlpha来检验我们需要检验的指针),而如果被传递到函数IsAlpha的,是指向指向函数实例的指针(实际上就是函数实例自己)的指针,那么*x 这个通用指针,就是指向函数实例的指针,但这是违反C标准的,*x 这个通用指针将是一个垃圾指针。

相等操作符的右边,&*x,是对*x 这个通用指针的取地址操作,如果这个通用指针是我们需要检验的自指指针(指向函数实例的指针除外),那么,取地址操作的结果即自指指针的Meta值,将与*x即自指指针的Enslaving值一致,所以可以判定被检验的指针是自指指针,而如果作为通用指针的*x是指向函数实例的指针,那么*x就是一个垃圾指针,此时对这个垃圾指针进行取地址操作,结果好像也应该是垃圾值,但事实并非如此,为什么呢?(请坚持住,这是本文讨论的最后一个迷局了。)

因为:这个垃圾指针之所以是垃圾,在于它的Enslaving值被表示为垃圾值(因为它的Adverbial值出错),但这并不意味着这个垃圾指针本身位于一个垃圾位置。这是一个上面曾经提到过的再简单不过的道理:

取地址操作符,将它的操作数当作左值看待。即,取地址操作的结果,只跟它的操作数的Meta值有关,而跟这个操作数的Enslaving值无关。—— 这是隐藏在贯穿本文中的几乎所有迷局后面的最关键的“作祟点”。

其实,这个垃圾的通用指针,正是指向tod函数的指针的原原本本的自己。所以,对这个垃圾指针进行取地址操作,得到的仍是指向tod函数的指针的Meta值,即被我们传递进函数IsAlpha的那个值,这一点,从 &*x == x 也能看的出来。当然,指向tod函数的指针的Meta值,就是tod函数的Meta值,也就是指向tod函数的指针的Enslaving值。

综上,在*x == &*x 这个表达式中,相等操作符左边的是一个被转换为通用指针的指向tod函数的指针,右边的则是一个指向指向tod函数的指针的指针,即:左边的是一个垃圾指针,右边的则是一个符合逻辑意义且实际上也是正确的指针,所以,这个相等操作表达式将得到0(假)的结果 —— 这就是导致函数IsAlpha在检验指向函数的指针是否为自指指针的任务中不适用的原因。

至此,我们在那个因着我们寻找自指指针而被我们“误入”的谜题世界中的游历,就告一段落了。在这次游历中,我们花尽脑力,从一个又一个迷局的陷阱中爬了出来,并且见识了这个充满谜题的世界里的奇妙景色。而这一切,使得我们在“活着”从那里走出来的时候,真是唏嘘不已啊!

以上全部,仅供参考。

P.S.:如果本帖有幸获得奖励,请先给@starwing83发奖品,他为本文的发起做出了重要的贡献。



作者: starwing83    时间: 2014-04-11 19:37
最近忙到昏天瞎地的,所以没法给太多的建议——其实墙哥的这篇文章之前就写完了的,我也完全看了,但是没有给出什么反馈,实在是感觉很惭愧……

额,反正我有时间针对这个事儿,我觉得还是再单独写个帖子评论一下最好,当然到时候肯定会先给@Ager看的……
作者: Ager    时间: 2014-04-11 19:52
starwing83 发表于 2014-04-11 19:37
最近忙到昏天瞎地的,所以没法给太多的建议


辛苦啦!致敬 {:3_193:}


作者: gvim    时间: 2014-04-12 19:40
回复 89# Ager


    文笔好,可就是写的太绕了,隐约觉得第一篇用math的角度来说工程上的东西意义不大,可一时半会想不到怎么表述理由。
作者: OwnWaterloo    时间: 2014-04-13 06:51
成功经验和失败教训貌似都没什么印象了。。。 就说一些心得体会好了。。。

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
于是可以:

  1. #include <limits.h>
  2. 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在指针上下文里都会被转换为“对应类型的空指针表示”。所谓指针上下文包括但不限于:

  1. T0* p0 = 0; /* 赋值 */

  2. T1* get(params...) { if (...) return 0; ... } /* 返回 */

  3. T1* p1 = get(args...);
  4. if (p1/* p1!=0 */) { ... } /* 条件 */
  5. if (!p1 /* p1==0 */) { ... }
  6. if (p1 && expr) { ... }

  7. 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来初始化是比较常见的情况。

  1. typedef struct T {
  2.   T0* p0;
  3.   T1* p1;
  4.   T2* p2;
  5.   /* other */
  6. } T;

  7. T* x = calloc(1,sizeof *p); /* 不能保证x->p0,x->p1,x->p2是空指针 */
  8. x->p0 = 0; /* 编译器知道这是指针上下文,于是可以保证 x->p0 是空指针 */

  9. T y;
  10. memset(&y, 0, sizeof y); /* 不能保证y.p0,y.p1,y.p2是空指针 */

  11. T z = {0}; /* 指针上下文,可以保证 z.p0,z.p1,z.p2 全是空指针 */
复制代码
于是可能会有相应的函数/宏来初始化T。用memset,calloc来初始化整体,然后将指针域显式地赋值为0。这个赋值操作可以通过条件编译在空指针的表示就是全0的环境 —— 大部分 —— 下去掉。
这也比较容易处理。

变长参数

无论变长参数是否应该避免都改变不了已经有不少函数是这样设计的事实。于是和它们打交道也是很常见的。

  1. printf("%p", 0);
复制代码
是错的。因为编译器不知道这是指针上下文,于是不会进行相应转换。
哪怕当前情况下不出问题它也是错的。

不需要进行什么复杂的工作就可以让它符合标准:

  1. printf("%p", (void*)0); /* 告知编译器这是指针上下文 */
复制代码
多的这个(void*)是很值得的。

使用NULL宏的情况比较特殊。。。

  1. printf("%p", 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

  1. /* If B is the base of an object addressed by P, return the result of
  2.    aligning P to the next multiple of A + 1.  B and P must be of type
  3.    char *.  A + 1 must be a power of 2.  */

  4. #define __BPTR_ALIGN(B, P, A) ((B) + (((P) -(B) + (A)) & ~(A)))

  5. /* Similiar to _BPTR_ALIGN (B, P, A), except optimize the common case
  6.    where pointers can be converted to integers, aligned as integers,
  7.    and converted back again.  If PTR_INT_TYPE is narrower than a
  8.    pointer (e.g., the AS/400), play it safe and compute the alignment
  9.    relative to B.  Otherwise, use the faster strategy of computing the
  10.    alignment relative to 0.  */

  11. #define __PTR_ALIGN(B, P, A)                                                  \
  12.   __BPTR_ALIGN (sizeof (PTR_INT_TYPE) < sizeof (void *) ? (B) : (char *) 0, \
  13.                 P, A)
复制代码
根据sizeof (PRT_INT_TYPE) 与 sizeof (void*) 的关系,分别计算的是:

  1. (B) + (((P) - (       B) + (A)) & ~(A))
  2. (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遇到的问题。

  1. #define PyObject_HEAD int ob_refcnt; /* 假设这里是所有PyObject都有的域。原始代码里没有相关定义。 */

  2. typedef struct PyObject {
  3.   PyObject_HEAD
  4. } PyObject; /* 这是标准的PyObject */

  5. struct FooObject{
  6.   PyObject_HEAD
  7.   int data; /* FooObject有额外的data域 */
  8. };

  9. PyObject *foo(struct FooObject*f); /* { return (PyObject*)f;} */
  10. /* 将原始代码里的定义隐藏了,否则编译器太聪明了。并且这也是符合通常情况下分别编译的场景的。 */

  11. struct FooObject* foo_get(void); /* { return malloc(sizeof(struct FooObject)); } */
  12. /* 同样将malloc隐藏。不过和这个例子没什么关系,而是我机器上没有对应的头文件。。。 */

  13. int bar(){
  14. struct FooObject *f = foo_get(); /* 同上,从直接malloc改为通过foo_get获取,不影响结果 */
  15. struct PyObject *o = foo(f);     /* 问题出在这里。 根据foo的实现实际上f与o是同一块内存地址 */
  16. f->ob_refcnt = 0;                /* 通过f设置所有PyObject都共有的ob_refcnt域 */
  17. o->ob_refcnt = 1;                /* 通过o设置同一个PyObject的ob_refcnt */
  18. return f->ob_refcnt;             /* 返回这一个PyObject的ob_refcnt  */
  19. }
复制代码
然后:

  1. $ gcc -Wall -c -m32 -O3 py.c # -fno-strict-aliasing
  2. $ objdump -d -Mintel py.o | grep -A 3 xor
  3.   22:        31 c0                        xor    eax,eax ; 清零
  4.   24:        5b                           pop    ebx
  5.   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不根据这个规则进行优化。
而如果想使用这种优化的话可以这样改:

  1. struct FooObject{
  2.   PyObject HEAD;
  3.   int data;
  4. };

  5. int bar(){
  6. ...
  7. f->HEAD.ob_refcnt = 0;
  8. o->ob_refcnt = 1;
  9. return f->HEAD.ob_refcnt;
  10. }
复制代码
这是前面提到的例外情况之一: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语言的定义。。。
作者: Ager    时间: 2014-04-13 20:37
gvim 发表于 2014-04-12 19:40
回复 89# Ager

文笔好,可就是写的太绕了,隐约觉得第一篇用math的角度来说工程上的东西意义不大,可一时半会想不到怎么表述理由。


谢谢支持与批评:)

其实,math部分,原来是没打算写的。只是,后来所讨论到的“如何用函数,即让待判指针作为函数的参数或参数的一部分,来判别该指针是否为自指指针”这个问题,把它倒逼出来的。


作者: Ager    时间: 2014-04-14 00:01
OwnWaterloo 发表于 2014-04-13 06:51
成功经验和失败教训貌似都没什么印象了。。。 就说一些心得体会好了。。。

C标准与现实与实践


Significantly marked:)




作者: windoze    时间: 2014-04-14 17:20
本帖最后由 windoze 于 2014-04-14 20:47 编辑

回复 87# Ager

我觉得你折腾了这一圈,构造的只是一个“能安全转化为指向自身指针的指针”,T**永远不是T*,不管你怎么搞。
不过话说回来,搞C的童鞋们本来也就不是特别在乎类型这玩艺儿。
C99里说:
intptr_t         integer type capable of holding a pointer
uintptr_t         unsigned integer type capable of holding a pointer
也就是说,C99允许你把任何一个“pointer”(好吧,它没说是data pointer还是function pointer),放在这样两个整数类型里,只要你在使用它之前把它转回原来的类型就好了。
有这条垫底,还怕什么?管它T*还是T**,塞到这个整数里不就好了吗?(我知道C99标准没说两个intptr_t能不能拿来比较,要比较要都转成void*才“合法”,不过我真心觉得接下来会做出改动的不是程序,而是C标准)
其实吧,C里面(除了浮点数外)所有东西都是整数,老A你洋洋洒洒写了这么多,怎么看都像是在分析鲁迅家院外那两棵枣树……

PS. 刚才又仔细想了一下,没理由两个intptr_t不能直接拿来比较的,因为:
1、所有指针都能“无损”的转换成void *
2、void *能做等值比较
3、void *能“无损”的转换成intptr_t
如此看来,intptr_t必须能做等值比较,因为在T *->void *->intptr_t这条路径上,做第二步时,转化成void *之前的指针类型已经不知道了,如果转化结果不能做等值比较,就等于说相等的两个void *转成intptr_t“有可能”不相等,这基本就是在胡扯……

所以,原问题T==&T,T居然可以干脆不是一个指针

  1. intptr_t p=&p;
  2. assert(p==&p);
复制代码
完全合法。

话说对这种代码给警告的编译器,我能不能说它多管闲事呢?

作者: windoze    时间: 2014-04-14 17:25
OwnWaterloo 发表于 2014-04-13 06:51
C标准对语言的规定很宽松。应该是为了能尽可能地在各种环境上实现。

这就是挖坑啊,每次标准委员会那帮家伙开会,都在吵吵ABI规范及兼容性,每次都不了了之……
作者: Ager    时间: 2014-04-15 10:34
windoze 发表于 2014-04-14 17:20
回复 87# Ager

我觉得你折腾了这一圈,构造的只是一个“能安全转化为指向自身指针的指针”,T**永远不是T*,不管你怎么搞。
不过话说回来,搞C的童鞋们本来也就不是特别在乎类型这玩艺儿。
C99里说:



作者: windoze    时间: 2014-04-15 12:48
说点闲话,说说为什么C标准里要规定这些指针转换的“合法性”规则。
假定有一个CPU规定,所有尺寸为n字节的基本类型必须在内存中按照n字节对齐(比如说4字节整数一定要放在4字节边界上)否则就会出错(这不是纯虚构,很多RISC有类似的限制),那么一个“合法”的编译器(或者CPU本身)可以做这样的事
1、强制要求所有的int32_t指针最后两个bit为0
2、任何类型转换到int32_t *时,主动将最后两个bit清0
3、等值比较int32_t *和其它数据时,忽略最后两个bit
牢记一点,上述所有行为都是完全符合C标准的,因为如果想在这种体系结构上实现C,C的标准必须能包容这些行为。
如果你的程序不规范,就会产生一些古怪的结果,比如

  1. char *p1="hello";
  2. int *p2=p1;
  3. char *p3=p2;
  4. assert(p1==p3);
复制代码
上述代码可能会失败,因为p1可能是在1字节边界上对齐的,比如0x00012345,转换到p2就变成了0x00012344,和p1已经不相等了。
在这样的环境下,C标准中的void *就是用来保证上述事情不会发生,下面的代码是合法的

  1. char *p1="hello";
  2. void *p2=p1;
  3. char *p3=p2;
  4. assert(p1==p3);
复制代码
因为C标准要求void *必须能“无损”地容纳其它类型的指针,但并没有对int *有这样的要求。

对于函数指针,C标准规定任何函数指针都能“无损”地容纳任何其它类型的函数指针,其实背后的原因是,目前为止,没有任何一个有实际应用的体系架构对代码的对齐有强制性要求,比如要求add指令一定要在2字节边界上否则就会SIGBUS之类。
作者: windoze    时间: 2014-04-15 13:04
回复 91# OwnWaterloo

Linux内核和Windows API里那些不太规范的用法,说明“可移植性”的实际边界其实有限。
对于Linux内核来说,它所需要适应的编译器其实只有一个,就是GCC,所以它根本不需要考虑对其它编译器的支持,而且针对GCC这个特定的编译器,就连我们常说的UB也变成了确定行为,Linux内核所面对的“可移植性”其实是各个不同的CPU/体系架构,它的开发者才不会在意其它编译器会不会报错,君不见ICC/Clang反倒都以能成功编译Linux内核为荣么?
同样,整个Windows API需要面对的编译器也只有VC,只要VC支持,Windows API可以随意违反C的任何标准,反正Windows也没法用其它任何编译器编译出来,反正想要生成Windows EXE你就得兼容VC的行为……




欢迎光临 Chinaunix (http://bbs.chinaunix.net/) Powered by Discuz! X3.2