C语言指针入门学习面面观

这似乎是一个很凝重的话题,但是它真的很有趣。

1. 指针是指向某一类型的东西,任何一个整体,只要能称为整体就能拥有它自己的独一无二的指针类型,所以指针的类型其实是近似无穷无尽的

2. 函数名在表达式中总是以函数指针的身份呈现,除了取地址运算符以及sizeof

3. C语言最晦涩难明的就是它复杂的声明: void (*signal(int sig, void (*func)(int)))(int),试试着把它改写成容易理解的形式

4. 对于指针,尽最大的限度使用const保护它,无论是传递给函数,还是自己使用

先来看看一个特殊的指针,姑且称它为指针,因为它依赖于环境: NULL,是一个神奇的东西。先附上定义,在编译器中会有两种NULL(每种环境都有唯一确定的NULL):

#define NULL 0
#define NULL ((void*)0)

有什么区别吗?看起来没什么区别都是0,只不过一个是常量,一个是地址为0的指针。

当它们都作为指针的值时并不会报错或者警告,即编译器或者说C标准认为这是合法的:

int* temp_int_1 = 0; //无警告
int* temp_int_2 = (void*)0; //无警告
int* temp_int_3 = 10; //出现警告

为什么?为什么0可以赋值给指针,但是10却不行?他们都是常量。

因为C语言规定当处理上下文的编译器发现常量0出现在指针赋值的语句中,它就作为指针使用,似乎很扯淡,可是却是如此。

回到最开始,对于NULL的两种情况,会有什么区别?拿字符串来说,实际上我是将字符数组看作是C风格字符串。

在C语言中,字符数组是用来存储一连串有意义的字符,默认在这些字符的结尾添加'\0',好这里又出现了一个0值。

对于某些人,在使用字符数组的时候总是分不清楚NULL与'\0'的区别而误用,在字符数组的末尾使用NULL是绝对错误的!虽然它们的本质都是常量0,但由于位置不同所以含义也不同。

开胃菜已过

对于一个函数,我们进行参数传递,参数有两种形式: 形参与实参

int function(int value)
{
    /*...*/
}
//...
function(11);

其中,value是形参,11是实参,我们知道场面上,C语言拥有两种传递方式:按值传递和按址传递,但是你是否有认真研究过?这里给出一个实质,其实C语言只有按值传递,所谓按址传递只不过是按值传递的一种假象。至于原因稍微一想便能明白。

对于形参和实参而言两个关系紧密,可以这么理解总是实参将自己的一份拷贝传递给形参,这样形参便能安全的使用实参的值,但也带给我们一些麻烦,最经典的交换两数

void swap_v1(int* val_1, int* val_2)
{
  int temp = *val_1;
  *val_1 = *val_2;
  *val_2 = *val_1;
}

这就是所谓的按址传递,实际上只是将外部指针(实参)的值做一个拷贝,传递给形参val_1与val_2,实际上我们使用:

#define SWAP_V2(a, b) (a += b, b = a - b, a -= b)
#define SWAP_V3(x, y) {x ^= y; y ^= x; x ^= y}

试一试是不是很神奇,而且省去了函数调用的时间,空间开销。上述两种写法的原理实质是一样的。

但是,动动脑筋想一想,这种写法真的没有瑕疵吗?如果输入的两个参数本就指向同一块内存,会发生什么?

...
int test_1 = 10, test_2 = 100;
SWAP_V2(test_1, test_2);
printf("Now the test_1 is %d, test_2 is %d\n", test_1, test_2);
.../*恢复原值*/
SWAP_V2(test_1, test_1);
printf("Now the test_1 is %d\n", test_1);

会输出什么?:

$: Now the test_1 is 100, test_2 is 10
$: Now the test_1 is 0

对,输出了0,为什么?稍微动动脑筋就能相通,那么对于后面的SWAP_V3亦是如此,所以在斟酌之下,解决方案应该尽可能短小精悍:

static inline void swap_final(int* val_1, int* val_2)
{
  if(val_1 == val_2)
    return;
  *val_1 ^= *val_2;
  *val_2 ^= *val_1;
  *val_1 ^= *val_2;
}
#define SWAP(x, y) \
do{         \
  if(&x == &y)  \
    break;   \
  x ^= y;   \
  y ^= x;   \
  x ^= y;   \
}while(0)

这便是目前能找到最好的交换函数,我们在此基础上可以考虑的更深远一些,如何让这个交换函数更加通用?即适用范围更大?暂不考虑浮点类型。 提示:可用void*

与上面的情况类似,偶尔的不经意就会造成严重的后果:

int combine_1(int* dest, int* add)
{
  *dest += *add;
  *dest += *add;
  return *dest;
}
int combine_2(int* dest, int* add)
{
  *dest = 2* (*add);//在不确定优先级时用括号是一个明智的选择
  return *dest;
}

上述两个函数的功能一样吗?恩看起来是一样的

int test_3 = 10, test_4 = 100;

combine_1(&test_3, &test_4);
printf("After combine_1, test_3 = %d\n",test_3);
.../*恢复原值*/
combine_2(&test_3, &test_4);
printf("After combine_2, test_3 = %d\n",test_3);

输出

$: After combine_1, test_3 = 210

$: After combine_2, test_3 = 210

如果传入两个同一对象呢?

... /*恢复test_3原值*/
combine_1(&test_3, &test_3);
printf("After second times combine_1, test_3 = %d\n",test_3);
...
combine_2(&test_3, &test_3);
printf("After second times combine_2, test_3 = %d\n",test_3);

输出

$: After second times combine_1, test_3 = 30

$: After second times combine_2, test_3 = 20

知道真相总是令人吃惊,指针也是那么令人又爱又恨。

C99 标准之后出现了一个新的关键字, restrict,被用于修饰指针,它并没有太多的显式作用,甚至加与不加,在 你自己 看来,效果毫无区别。但是反观标准库的代码中,许多地方都使用了该关键字,这是为何

  • 首先这个关键字是写给编译器看的
  • 其次这个关键字的作用在于辅助编译器更好的优化该程序
  • 最后,如果不熟悉,绝对不要乱用这个关键字。

关于数组的那些事

数组和指针一样吗?

不一样

要时刻记住,数组与指针是不同的东西。但是为什么下面代码是正确的?

int arr[10] = {10, 9, 8, 7};
int* parr = arr;

我们还是那句话,结合上下文,编译器推出 arr处于赋值操作符的右侧,默默的将他转换为对应类型的指针,而我们在使用arr时也总是将其当成是指向该数组内存块首位的指针。

//int function2(const int test_arr[10]
//int function2(const int test_arr[]) 考虑这三种写法是否一样
int function2(const int* test_arr)
{
  return sizeof(test_arr);
}
...
int size_out = sizeof(arr);
int size_in = function2(arr);

printf("size_out = %d, size_in = %d\n", size_out, size_in);

输出:

size_out = 40, size_in = 8

这就是为什么数组与指针不同的原因所在,在外部即定义数组的代码块中,编译器通过上下文发觉此处arr是一个数组,而arr代表的是一个指向10个int类型的数组的指针,只所谓最开始的代码是正确的,只是因为这种用法比较多,就成了标准的一部分。就像世上本没有路,走的多了就成了路。"正确"的该怎么写

int (*p)[10] = &arr;

此时p的类型就是一个指向含有10个元素的数组的指针,此时(*p)[0]产生的效果是arr[0],也就是parr[0],但是(*p)呢?这里不记录,结果是会溢出,为什么?

这就是数组与指针的区别与联系,但是既然我们可以使用像parr这样的指针,又为什么要写成int (*p)[10]这样丑陋不堪的模式呢?原因如下:

回到最开始说过的传递方式,按值传递在传递arr时只是纯粹的将其值进行传递,而丢失了上下文的它只是一个普通指针,只不过我们程序员知道它指向了一块有意义的内存的起始位置,我想要将数组的信息一起传递,除了额外增加一个参数用来记录数组的长度以外,也可以使用这个方法,传递一个指向数组的指针 这样我们就能只传递一个参数而保留所有信息。但这么做的也有限制:对于不同大小,或者不同存储类型的数组而言,它们的类型也有所不同

 int arr_2[5];
 int (*p_2)[5] = &arr_2;
 float arr_3[5];
 float (*p_3)[5] = &arr_3;

如上所示,指向数组的指针必须明确指定数组的大小,数组存储类型,这就让指向数组的指针有了比较大的限制。

这种用法在多维数组中使用的比较多,但总体来说平常用的并不多,就我而言,更倾向于使用一维数组来表示多维数组,实际上诚如前面所述,C语言是一个非常简洁的语言,它没有太多的废话,就本质而言C语言并没有多维数组,因为内存是一种线性存在,即便是多维数组也是实现成一维数组的形式。

就多维数组在这里解释一下。所谓多维数组就是将若干个降一维的数组组合在一起,降一维的数组又由若干个更降一维的数组组合在一起,直到最低的一维数组,举个例子:

int dou_arr[5][3];

就这个二维数组而言,将5个每个为3个int类型的数组组合在一起,要想指向这个数组该怎么做?

  int (*p)[3]    = &dou_arr[0];
  int (*dou_p)[5][3] = &dou_arr;
  int (*what_p)[3]  = dou_arr;

实际上多维数组只是将多个降一维的数组组合在一起,令索引时比较直观而已。当真正理解了内存的使用,反而会觉得多维数组带给自己更多限制 对于第三句的解释,当数组名出现在赋值号右侧时,它将是一个指针,类型则是 指向该数组元素的类型,而对于一个多维数组来说,其元素类型则是其降一维数组,即指向该降一维数组的指针类型。这个解释有点绕,自己动手写一写就好很多。

对于某种形式下的操作,我们总是自然的将相似的行为结合在一起考虑。考虑如下代码:

int* arr_3[5] = {1, 2, 3, 4, 5};
int* p_4   = arr_3;

printf("%d == %d == %d ?\n", arr_3[2], *(p_4 + 2), *(arr_3 + 2));

输出: 3 == 3 == 3 ? 实际上对于数组与指针而言, []操作在大多数情况下都能有相同的结果,对于指针而言*(p_4 + 2)相当于p_4[2],也就是说[]便是指针运算的语法糖,有意思的是2[p_4]也相当于p_4[2],"Iamastring"[2] == 'm',但这只是娱乐而已,实际中请不要这么做,除非是代码混乱大赛或者某些特殊用途。 在此处,应该声明的是这几种写法的执行效率完全一致,并不存在一个指针运算便快于[]运算,这些说法都是上个世纪的说法了,随着时代的发展,我们应该更加注重代码整洁之道

在此处还有一种奇异又实用的技巧,在char数组中使用指针运算进行操作,提取不同类型的数据,或者是在不同类型数组中,使用char*指针抽取其中内容,才是显示指针运算的用途。但在使用不同类型指针操作内存块的时候需要注意,不要操作无意义的区域或者越界操作。

实际上,最简单的安全研究之一,便是利用溢出进行攻击。

Advance:对于一个函数中的某个数组的增长方向,总是向着返回地址的,中间可能隔着许多其他自动变量,我们只需要一直进行溢出试验,直到某一次,该函数无法正常返回了!那就证明我们找到了该函数的返回地址存储地区,这时候我们可以进行一些操作,例如将我们想要的返回地址覆盖掉原先的返回地址,这就是所谓的溢出攻击中的一种。

内存的使用的那些事儿

你一直以为你操作的是真实物理内存,实际上并不是,你操作的只是操作系统为你分配的资格虚拟地址,但这并不意味着我们可以无限使用内存,那内存卖那么贵干嘛,实际上存储数据的还是物理内存,只不过在操作系统这个中介的介入情况下,不同程序窗口(可以是相同程序)可以共享使用同一块内存区域,一旦某个傻大个程序的使用让物理内存不足了,我们就会把某些没用到的数据写到你的硬盘上去,之后再使用时,从硬盘读回。这个特性会导致什么呢?假设你在Windows上使用了多窗口,打开了两个相同的程序:

...
int stay_here;
char tran_to_int[100];
printf("Address: %p\n", &stay_here);

fgets(tran_to_int, sizeof(tran_to_int), stdin);
sscanf(tran_to_int, "%d", &stay_here);

for(;;)
{
  printf("%d\n", stay_here);
  getchar();
  ++stay_here;
}
...

对此程序(引用前桥和弥的例子),每敲击一次回车,值加1。当你同时打开两个该程序时,你会发现,两个程序的stay_here都是在同一个地址,但对它进行分别操作时,产生的结果是独立的!这在某一方面验证了虚拟地址的合理性。虚拟地址的意义就在于,即使一个程序出现了错误,导致所在内存完蛋了,也不会影响到其他进程。对于程序中部的两个读取语句,是一种理解C语言输入流本质的好例子,建议查询用法,这里稍微解释一下:

通俗地说,fgets将输入流中由调用起,stdin输入的东西存入起始地址为tran_to_int的地方,并且最多读取sizeof(tran_to_int)个,并在后方sscanf函数中将刚才读入的数据按照%d的格式存入stay_here,这就是C语言一直在强调的流概念的意义所在,这两个语句组合看起来也就是读取一个数据这么简单,但是我们要知道一个问题,一个关于scanf的问题

 scanf("%d", &stay_here);

这个语句将会读取键盘输入,直到回车之前的所有数据,什么意思?就是回车会留在输入流中,被下一个输入读取或者丢弃。这就有可能会影响我们的程序,产生意料之外的结果。而使用上当两句组合则不会。

函数与函数指针的那些事

事实上,函数名出现在赋值符号右边就代表着函数的地址

int function(int argc){ /*...*/
}
...
int (*p_fun)(int) = function;
int (*p_fuc)(int) = &function;//和上一句意义一致

上述代码即声明并初始化了函数指针,p_fun的类型是指向一个返回值是int类型,参数是int类型的函数的指针

p_fun(11);
(*p_fun)(11);
function(11);

上述三个代码的意义也相同,同样我们也能使用函数指针数组这个概念

int (*p_func_arr[])(int) = {func1, func2,};

其中func1,func2都是返回值为int参数为int的函数,接着我们能像数组索引一样使用这个函数了。

Tips: 我们总是忽略函数声明,这并不是什么好事。

在C语言中,因为编译器并不会对有没有函数声明过分深究,甚至还会放纵,当然这并不包含内联函数(inline),因为它本身就只在本文件可用。
比如,当我们在某个地方调用了一个函数,但是并没有声明它:

 CallWithoutDeclare(100); //参数100为 int 型

那么,C编译器就会推测,这个使用了int型参数的函数,一定是有一个int型的参数列表,一旦函数定义中的参数列表与之不符合,将会导致参数信息传递错误(编译器永远坚信自己是对的!),我们知道C语言是强类型语言,一旦类型不正确,会导致许多意想不到的结果(往往是Bug)发生。

对函数指针的调用同样如此
C语言中malloc的那些事儿

我们常常见到这种写法:

int* pointer = (int*)malloc(sizeof(int));

这有什么奇怪的吗?看下面这个例子:

int* pointer_2 = malloc(sizeof(int));

哪个写法是正确的?两个都正确,这是为什么呢,这又要追求到远古C语言时期,在那个时候, void* 这个类型还没有出现的时候,malloc 返回的是 char* 的类型,于是那时的程序员在调用这个函数时总要加上强制类型转换,才能正确使用这个函数,但是在标准C出现之后,这个问题不再拥有,由于任何类型的指针都能与 void* 互相转换,并且C标准中并不赞同在不必要的地方使用强制类型转换,故而C语言中比较正统的写法是第二种。

题外话: C++中的指针转换需要使用强制类型转换,而不能像第二种例子,但是C++中有一种更好的内存分配方法,所以这个问题也不再是问题。

Tips:

C语言的三个函数malloc, calloc, realloc都是拥有很大风险的函数,在使用的时候务必记得对他们的结果进行校验,最好的办法还是对他们进行再包装,可以选择宏包装,也可以选择函数包装。
realloc函数是最为人诟病的一个函数,因为它的职能过于宽广,既能分配空间,也能释放空间,虽然看起来是一个好函数,但是有可能在不经意间会帮我们做一些意料之外的事情,例如多次释放空间。正确的做法就是,应该使用再包装阉割它的功能,使他只能进行扩展或者缩小堆内存块大小。
指针与结构体

typedef struct tag{
    int value;
    long vari_store[1];
}vari_struct;

乍一看,似乎是一个很中规中矩的结构体

...
vari_struct vari_1;
vari_struct* vari_p_1 = &vari_1;
vari_struct* vari_p_2 = malloc(sizeof(vari_struct))(

似乎都是这么用的,但总有那么一些人想出了一些奇怪的用法

int     what_spa_want = 10;
vari_struct* vari_p_3 = malloc(sizeof(vari_struct) + sizeof(long)*what_spa_want);

这么做是什么意思呢?这叫做可变长结构体,即便我们超出了结构体范围,只要在分配空间内,就不算越界。what_spa_want解释为你需要多大的空间,即在一个结构体大小之外还需要多少的空间,空间用来存储long类型,由于分配的内存是连续的,故可以直接使用数组vari_store直接索引。 而且由于C语言中,编译器并不对数组做越界检查,故对于一个有N个数的数组arr,表达式&arr[N]是被标准允许的行为,但是要记住arr[N]却是非法的。 这种用法并非是娱乐,而是成为了标准(C99)的一部分,运用到了实际中

对于内存的理解

在内存分配的过程中,我们使用 malloc 进行分配,用 free 进行释放,但这是我们理解中的分配与释放吗? 在调用 malloc 时,该函数或使用 brk() 或使用 nmap() 向操作系统申请一片内存,在使用时分配给需要的地方,与之对应的是 free,与我们硬盘删除东西一样,实际上:

int* value = malloc(sizeof(int)*5);
...
free(value);
printf("%d\n", value[0]);

代码中,为什么在 free 之后,我又继续使用这个内存呢?因为 free 只是将该内存标记上释放的标记,示意分配内存的函数,我可以使用,但并没有破坏当前内存中的内容,直到有操作对它进行写入。 这便引申出几个问题:

Bug更加难以发现,让我们假设,如果我们有两个指针p1,p2指向同一个内存,如果我们对其中某一个指针使用了 free(p1); 操作,却忘记了还有另一个指针指向它,那这就会导致很严重的安全隐患,而且这个隐患十分难以发现,原因在于这个Bug并不会在当时显露出来,而是有可能在未来的某个时刻,不经意的让你的程序崩溃。
有可能会让某些问题更加简化,例如释放一个条条相连的链表域。
总的来说,还是那句话C语言是一把双刃剑。

(0)

相关推荐

  • 对C语言中指针的理解与其基础使用实例

    C语言的指针,关键意思在于"指". "指"是什么意思? 其实完全可以理解为指示的意思.比如,有一个物体,我们称之为A.正是这个物体,有了这么个称谓,我们才能够进行脱离这个物体的实体而进行一系列的交流.将一个物体的指示,是对这个物体的抽象.有了这种抽象能力,才有所谓的智慧和文明.所以这就是"指示"这种抽象方法的威力. 退化到C语言的指针,指针是一段数据/指令(在冯诺易曼体系中,二者是相通,在同一空间中的)的指示.这是指示,也就是这段数据/指令的起始

  • 简单总结C语言中各种类型的指针的概念

    C语言中有很多关于指针的使用,指针也是C语言的灵魂所在,而且C语言中也有很多有关指针的概念,这里学习并总结了一些知道的概念.   常量指针: 首先它是一个指针,常量只是用来修饰指针的定语.其定义如下: char const * cp; char a='a'; 如何识别呢?根据右结合优先,先是*优先,所以这个cp变量是一个指针,然后是const修饰*,所以这是一个常量指针.即指向常量的指针. cp=&a; //正常语法 *cp=a; //错误语法,因为其指向的值是一个常量 指针常量: 首先它是一个

  • 举例理解C语言二维数组的指针指向问题

    之前对数组的概念一直没有理解透彻,只觉得数组名就是个常量指针而已,用法和基本的指针差不多.所以当我尝试用二级指针去访问二维数组时,就经常会出错.下面就是刚开始写的一个错误的程序: #include <stdio.h> int main() { int iArray[2][3] = {{1,2,3},{4,5,6}}; int **pArray = NULL; pArray = iArray; printf("array[0][0] = %d\n", pArray[0][0]

  • 详解C语言中的常量指针和指针常量

    概述 对于新手来说,指针在c语言里总是一个非常难以理解的概念.在这篇文章中,我们将解释常量指针,指针常量,const pointer to const(ps:楼主以为这可以翻译成指向常量的常量指针)的区别 常量指针 让我们先来理解什么是常量指针.常量指针是指指针指向的地址是常量.换句话说,一旦常量指针指向了一个变量,你不能让该常量指针指向其他变量了 常量指针的声明方法如下: <type of pointer> * const <name of pointer> 常量指针声明示例:

  • C语言中lseek()函数和fseek()函数的使用详解

    C语言lseek()函数:移动文件的读写位置 头文件: #include <sys/types.h> #include <unistd.h> 定义函数: off_t lseek(int fildes, off_t offset, int whence); 函数说明: 每一个已打开的文件都有一个读写位置, 当打开文件时通常其读写位置是指向文件开头, 若是以附加的方式打开文件(如O_APPEND), 则读写位置会指向文件尾. 当read()或write()时, 读写位置会随之增加,ls

  • C语言中下标与指针的转换以及指向指针的指针的例子

    下标到指针之间和转换 以下的程序做了什么. #include <stdio.h> int main() { int a[3][3] = {{0, 1, 2}, {3, 4, 5}, {6, 7, 8}}; int i = 0; int j = 0; for(i = 0; i<3; i++) { for(j=0; j<3; j++) { printf("%d\n", *(*(a+i)+j)); } } } 打印了数组中的每个元素: 指向指针的指针 #include

  • 简单分析C语言中指针数组与数组指针的区别

    首先来分别看一下,指针数组的一个小例子: #include <stdio.h> #include <string.h> int lookup_keyword(const char*key, const char* table[], const int size) { int ret = -1; int i = 0; for(i=0; i<size; i++) { if (strcmp(key, table[i]) == 0) { ret = i; break; } } ret

  • Swift与C语言指针结合使用实例

    Objective-C和C的API常常会需要用到指针.Swift中的数据类型都原生支持基于指针的Cocoa API,不仅如此,Swift会自动处理部分最常用的将指针作为参数传递的情况.这篇文章中,我们将着眼于在Swift中让C语言指针与变量.数组和字符串共同工作. ####用以输入/输出的参数指针 C和Objective-C并不支持多返回值,所以Cocoa API中常常将指针作为一种在方法间传递额外数据的方式.Swift允许指针被当作inout参数使用,所以你可以用符号&将对一个变量的引用作为指

  • 详解C语言中fseek函数和ftell函数的使用方法

    fseek函数: int fseek(FILE * _File, long _Offset, int _Origin); 函数设置文件指针stream的位置.如果执行成功,stream将指向以fromwhere为基准,偏移offset(指针偏移量)个字节的位置,函数返回0.如果执行失败则不改变stream指向的位置,函数返回一个非0值. 超出文件末尾位置,还是返回0.往回偏移超出首位置,还是返回0,小心使用. 第一个参数stream为文件指针. 第二个参数offset为偏移量,正数表示正向偏移,

  • 讲解C语言编程中指针赋值的入门实例

    从const int i 说起 你知道我们声明一个变量时象这样int i :这个i是可能在它处重新变赋值的.如下: int i = 0; /* . . . */ i = 20; /*这里重新赋值了*/ 不过有一天我的程序可能需要这样一个变量(暂且称它变量),在声明时就赋一个初始值.之后我的程序在其它任何处都不会再去重新对它赋值.那我又应该怎么办呢?用const . /* . . . */ const int ic =20; /* . . . */ ic = 40; /*这样是不可以的,编译时是无

随机推荐