C语言从入门到熟悉------第四阶段

指针

地址和指针的概念

要明白什么是指针,必须先要弄清楚数据在内存中是如何存储的,又是如何被读取的。如果在程序中定义了一个变量,在对程序进行编译时,系统就会为这个变量分配内存单元。编译系统根据程序中定义的变量类型分配一定长度的空间。内存的基本单元是字节,一字节有8位。每字节都有一个编号,这个编号就是“地址”,它相当于旅馆的房间号。在地址所标示的内存单元中存放的数据,就相当于在该旅馆房间中居住的旅客。

还有一种间接访问的方式,即变量中存放的是另一个变量的地址。也就是说,变量中存放的不是数据,而是数据的地址。就跟寻宝一样,可能你按藏宝图千辛万苦找到的宝藏不是金银珠宝,而是另一张藏宝图。按C语言的规定,可以在程序中定义整型变量、实型变量、字符型变量,也可以定义这样一种特殊的变量,它是存放地址的。

由于通过地址能找到所需的变量单元,所以我们可以说,地址“指向”该变量单元。如同一个房间号指向某一个房间一样,只要告诉房间号就能找到房间的位置。因此在C语言中,将地址形象地称为“指针”,意思就是通过它能找到以它为地址的内存单元。 

所以,一个变量的地址就称为该变量的指针。指针就是地址,而地址就是内存单元的编号。它是一个从零开始的、操作受限的非负整数。为什么是操作受限的?因为非负整数与非负整数可以加减乘除,但是指针和指针只能进行相减运算,不能进行其他运算,因为没有意义。而且进行相减运算也是有条件的:只有同一块空间中的地址才能相减。而且两个指针变量相减之后的结果是一个常量,而不是指针型变量,即相减的结果是这两个地址之间元素的个数,而不是地址的个数。

如果有一个变量专门用来存放另一个变量的地址,那么就称它为“指针变量”。也就是说,指针变量里面存放的是指针,即地址。大家一定要区分“指针”和“指针变量”这两个概念。指针是一个地址,而指针变量是存放地址的变量。

习惯上我们也将“指针变量”简称为“指针”,但大家心里一定要明白这两个指针的区别。一个是真正的指针,它的本质是地址;而另一个是指针变量的简称。

指针和指针变量

为了表示指针变量和它所指向的变量之间的联系,在程序中用“*”表示“指向”。如果定义变量i为指针变量,那么*i就表示指针变量i里面存放的地址所指向的存储单元里面的数据。很绕吧!好好体会一下。

指针变量的定义:

指针变量定义的一般形式为:

    基类型 *指针变量名;

比如:

    int *i;
    float *j;

说明:

1)“*”表示该变量的类型为指针类型。指针变量名为i和j,而不是*i和*j。

2)在定义指针变量时必须指定其基类型。指针变量的“基类型”用来指定该指针变量可以指向的变量的类型。比如“int *i; ”表示i只可以指向int型变量;又比如“float *j; ”表示j只可以指向float型变量。

3)为什么叫基类型,而不直接叫类型?因为比如“int *i; ”,其中i是变量名,i变量的数据类型是“int *”型,即存放int变量地址的类型。“int”和“*”加起来才是变量i的类型,所以int称为基类型。

4)“int *i; ”表示定义了一个指针变量i,它可以指向int型变量的地址。但此时并没有给它初始化,即此时这个指针变量并未指向任何一个变量。此时的“*”只表示该变量是一个指针变量,至于具体指向哪一个变量要在程序中指定。这个就跟定义了“int j; ”但并未给它赋初值一样。

5)因为不同类型的数据在内存中所占的字节数是不同的,比如int型数据占4字节,char型数据占1字节。而每个字节都有一个地址,比如一个int型数据占4字节,就有4个地址。那么指针变量所指向的是这4个地址中的哪个地址呢?指向的是第一个地址,即指针变量里面保存的是它所指向的变量的第一个字节的地址,即首地址。因为通过所指向变量的首地址和该变量的类型就能知道该变量的所有信息。

6)指针变量也是变量,是变量就有地址,所以指针变量本身也是有地址的。只要定义了一个变量,程序在运行时系统就会为它分配内存空间。但指针变量又是存放地址的变量,所以这里有两个地址大家一定要弄清楚:一个是系统为指针变量分配的地址,即指针变量本身的地址;另一个是指针变量里面存放的另一个变量的地址。这两个地址一个是“指针变量的地址”,另一个是“指针变量的内容”。

7)地址也是可以进行运算的,我们后面会学到指针的运算和移动。比如“使指针向后移1个位置”或“使指针加1”,这个1代表什么呢?这个1与指针变量的基类型是直接相关的。指针变量的基类型占几字节,这个1代表的就是几。比如指针变量指向一个int型变量,那么“使指针移动1个位置”就意味着移动4字节,“使指针加1”就意味着使地址加4。所以必须指定指针变量所指向的变量的类型,即指针变量的基类型。某种基类型的指针变量只能存放该种基类型变量的地址。

8)我们前面讲过两个指针变量相减的结果是一个常量,而不是指针型变量。如两个“int *”型的指针变量相减,结果是int型常量。此时要是把相减的结果赋给“int *”型就会报错。而且两个指针变量相减的结果是这两个地址之间元素的个数,而不是地址的个数。原因也是一样的。比如两个“int *”型的指针变量相减,第一个指针变量里面存放的地址是1245036,第二个指针变量里面存放的地址是1245032,那么这两个地址相减的结果是几?是1,而不是4。因为int型变量占4字节,所以一个int元素就占4字节,两个地址之间相差4个地址,正好是一个int元素,所以结果就是1。

指针变量的初始化:

可以用赋值语句使一个指针变量得到另一个变量的地址,从而使它指向该变量。比如:

    int i, *j;
    j = &i;

此外有两点需要注意:

第一,j不是i, i也不是j。修改j的值不会影响i的值,修改i的值也不会影响j的值。j是变量i的地址,而i是变量i里面的数据。一个是“内存单元的地址”,另一个是“内存单元的内容”,大家一定要分清楚,好好理解一下。

第二,定义指针变量时的“*j”和程序中用到的“*j”含义不同。定义指针变量时的“*j”只是一个声明,此时的“*”仅表示该变量是一个指针变量,并没有其他含义。而且此时该指针变量并未指向任何一个变量,至于具体指向哪个变量要在程序中指定,即给指针变量初始化。而当指定j指向变量i之后,*j就完全等同于i了,可以相互替换。

注意:切忌将一个没有初始化的指针变量赋给另一个指针变量。这是非常严重的语法错误。

指针常见错误:

1.引用未初始化的指针变量

如果指针变量未初始化,那么编译器会让它指向一个固定的、不用的地址。

在VC++ 6.0中只要指针变量未初始化,那么编译器就让它指向0XCCCCCCCC这个内存单元。而且这个内存单元是程序所不能访问的,访问就会触发异常,所以也不怕往里面写东西。而如果在VS 2008这个编译器中,程序虽然能编译通过,但是在运行的时候直接出错,它并不会像VC++ 6.0那样还能输出所指向的内存单元的地址。

下面来看一个程序:

/*
    这个程序是错的:引用未初始化的指针变量
*/
#include<stdio.h>
int main(void) {
	int i = 3, *j;

	*j = i;

	return 0;
}

程序中,j是int *型的指针变量。j中存放的应该是内存空间的地址,然后“变量i赋给*j”表示将变量i中的值放到该地址所指向的内存空间中。但是现在j中并没有存放一个地址,程序中并没有给它初始化,那么它指向的就是0XCCCCCCCC这个内存单元。这个内存单元是不允许访问的,即不允许往里面写数据。而把i赋给*j就是试图往这个内存空间中写数据,程序执行时就会出错。但这种错误在编译的时候并不会报错,只有在执行的时候才会出错,即传说中的“段错误”。所以一定要确保指针变量在引用之前已经被初始化为指向有效的地址。

2.往一个存放NULL地址的指针变量里面写入数据

我们把前面的程序改一下:

/*
    这个程序是错的:往一个存放NULL地址的指针变量里面写入数据
*/
#include<stdio.h>

int main(void) {
	int i = 3;
	int *j = NULL;

	*j = i;

	return 0;
}

之前是没有给指针变量j初始化,现在初始化了,但是将它初始化为指向NULL。NULL也是一个指针变量。NULL指向的是内存中地址为0的内存空间。以32位操作系统为例,内存单元地址的范围为0x00000000~0xffff ffff。其中0x00000000就是NULL所指向的内存单元的地址。但是在操作系统中,该内存单元是不可用的。凡是试图往该内存单元中写入数据的操作都会被视为非法操作,从而导致程序错误。同样,这种错误在编译的时候也不会报错,只有在执行的时候才会出错。这种错误也属于“段错误”。

然而虽然这么写是错误的,但是将一个指针变量初始化为指向NULL,这在实际编程中是经常使用的。就跟前面讲普通变量在定义时给它初始化为0一样,指针变量如果在定义时不知道指向哪里就将其初始化为指向NULL。只是此时要注意的是,在该指针变量指向有效地址之前不要往该地址中写入数据。

最后关于NULL再补充一点:NULL是定义在stdio.h头文件中的符号常量,它表示的值是0。

指针作为函数参数

互换两个数怎么实现?先写个程序看看:

#include<stdio.h>
void Swap(int a, int b);  //函数声明

int main(void) {
	int i = 3, j = 5;

	Swap(i, j);

	printf("i = %d, j = %d\n", i, j);

	return 0;
}

void Swap(int a, int b) {
	int buf;

	buf = a;
	a = b;
	b = buf;

	return;
}

大家想一下,执行这个程序是否能互换i和j的值?不能!i还是3, j还是5。以上传递方式叫作拷贝传递,即将内存1中的值拷贝到内存2中。拷贝传递的结果是:不管如何改变内存2中的值,对内存1中的值都没有任何影响,因为它们两个是不同的内存空间。

所以要想直接对内存单元进行操控,用指针最直接,指针的功能很强大。下面改进这个程序:

#include<stdio.h>

void Swap(int *p, int *q);  //函数声明

int main(void) {
	int i = 3, j = 5;

	Swap(&i, &j);

	printf("i = %d, j = %d\n", i, j);

	return 0;
}

void Swap(int *p, int *q) {
	int buf;

	buf = *p;
	*p = *q;
	*q = buf;

	return;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
i = 5, j = 3
           --------------------------------------
           */

此时实参向形参传递的不是变量i和j的数据,而是变量i和j的地址。其实传递指针也是拷贝传递,只不过它拷贝的不是内存单元中的内容,而是内存单元的地址,这就是天壤之别了。拷贝地址就可以直接对地址所指向的内存单元进行操作,即此时被调函数就可以直接对变量i和j进行操作了。

定义只读变量:const

const是constant的缩写,意思是“恒定不变的”!它是定义只读变量的关键字,或者说const是定义常变量的关键字,说它定义的是变量,但又相当于常量;说它定义的是常量,但又有变量的属性,所以叫常变量。用const定义常变量的方法很简单,就在通常定义变量时前面加const即可,如:

    const   int   a = 10;

const和变量类型int可以互换位置,二者是等价的,即上条语句等价于:

    int   const   a = 10;

用const定义的变量的值是不允许改变的,即不允许给它重新赋值,即使是赋相同的值也不可以。所以说它定义的是只读变量。而且用const修饰的变量,无论是全局变量还是局部变量,生存周期都是程序运行的整个过程。而使用const修饰过的局部变量就有了静态特性,它的生存周期也是程序运行的整个过程。但是用const修饰过的局部变量只是有了静态特性,并没有说它变成了静态变量。我们知道,局部变量存储在栈中,静态变量存储在静态存储区中,而经过const修饰过的变量存储在内存中的“只读数据段”中。只读数据段中存放着常量和只读变量等不可修改的量。

前面说过,数组的长度不能是变量。虽然const定义的是只读变量,就相当于是定义一个常量。但是只读变量也是变量,所以const定义的变量仍然不能作为数组的长度。但是需要注意的是,在C++中可以!C++扩展了const的含义,在C++中用const定义的变量也可作为数组的长度。

多人在学习const的时候都会混淆它与define的区别。从功能上说它们确实很像,但它们又有明显的不同:

define是预编译指令,而const是普通变量的定义。用const定义的常变量具有宏的优点,而且使用更方便。所以编程时在使用const和define都可以的情况下尽量使用常变量来取代宏。const定义的是变量,而宏定义的是常量,所以const定义的对象有数据类型,而宏定义的对象没有数据类型。所以编译器可以对前者进行类型安全检查,而对后者只是机械地进行字符替换,没有类型安全检查。这样就很容易出问题,即“边际问题”或者说是“括号问题”。这个问题不是本书讨论的范畴。

用const修饰指针变量时的三种效果

前面讲过,当一个变量用const修饰后就不允许改变它的值了。那么如果在定义指针变量的时候用const修饰会怎样?同样必须要在定义的时候进行初始化。比如:

    int   a;
    int   *p = &a;

当用const进行修饰时,根据const位置的不同有三种效果。原则是:修饰谁,谁的内容就不可变,其他的都可变。这三种情况在面试的时候几乎是必考的,在实际编程中也是经常使用的,所以大家必须要掌握。

(1)const int *p=&a;

同样const和int可以互换位置,二者是等价的。我们以放在最前面时进行描述。

当把const放最前面的时候,它修饰的就是*p,那么*p就不可变。*p表示的是指针变量p所指向的内存单元里面的内容,此时这个内容不可变。其他的都可变,如p中存放的是指向的内存单元的地址,这个地址可变,即p的指向可变。但指向谁,谁的内容就不可变。

这种用法常见于定义函数的形参。前面学习printf和scanf,以及后面将要学习的很多函数,它们的原型中很多参数都是用const修饰的。为什么要用const修饰呢?这样做的好处是安全!我们通过参数传递数据时,就把数据暴露了。而大多数情况下我们只是想使用传过来的数据,并不想改变它的值,但往往由于编程人员个人水平的原因会不小心改变它的值。这时我们在形参中用const把传过来的数据定义成只读的,这样就更安全了。这也是const最有用之处。

(2)int * const p=&a;

此时const修饰的是p,所以p中存放的内存单元的地址不可变,而内存单元中的内容可变。即p的指向不可变,p所指向的内存单元的内容可变。

(3)const int * const p=&a;

此时*p和p都被修饰了,那么p中存放的内存单元的地址和内存单元中的内容都不可变。

综上所述,使用const可以保护用指针访问内存时由指针导致的被访问内存空间中数据的误更改。因为指针是直接访问内存的,没有拷贝,而有些时候使用指针访问内存时并不是要改变里面的值,而只是要使用里面的值,所以一旦不小心误操作把里面的数据改了就糟糕了。但是这里需要注意的是,上面第1种情况中,虽然在*p前加上const可以禁止指针变量p修改变量a中的值,但是它只能“禁止指针变量p修改”。也就是说,它只能保证在使用指针变量p时,p不能修改a中的值。但是我并没有说const可以保护a禁止一切的修改,其他指向a的没有用const修饰的指针变量照样可以修改a的值,而且变量a自己也可以修改自己的值,这点一定要记清楚。

指针和一维数组的关系

用指针引用数组元素:

引用数组元素可以用“下标法”,这个在前面已经讲过,也用过了。但是除了这种方法之外还可以用指针,即通过指向某个数组元素的指针变量来引用数组元素。数组包含若干个元素,元素就是变量,变量都有地址。所以每一个数组元素在内存中都占有存储单元,都有相应的地址。指针变量既然可以指向变量,当然也就可以指向数组元素。同样,数组的类型和指针变量的基类型一定要相同。下面给大家写一个程序:

#include<stdio.h>
int main(void) {
	int a[] = {1, 2, 3, 4, 5};
	int *p = &a[0];
	int *q = a;

	printf("*p = %d, *q = %d\n", *p, *q);

	return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
*p = 1, *q = 1
--------------------------------------
*/

C语言中规定,“数组名”是一个指针“常量”,表示数组第一个元素的起始地址。所以p=&a[0]和p=a是等价的。

指针的移动:

C语言规定:如果指针变量p已经指向一维数组的第一个元素,那么p+1就表示指向该数组的第二个元素。

知道元素的地址后引用元素就很简单了。如果p指向的是第一个元素的地址,那么*p表示的就是第一个元素的内容。同样,p+i表示的是第i+1个元素的地址,那么*(p+i)就表示第i+1个元素的内容。即p+i就是指向元素a[i]的指针,*(p+i)就等价于a[i]。

那么反过来,因为数组名a表示的也是数组的首地址,那么元素a[i]的地址也可以用a+i表示吗?回答也是“可以的”。这时有人说,a不是指针变量也可以写成“*”的形式吗?只要是地址,都可以用“*地址”表示该地址所指向的内存单元中的数据。而且也只有地址前面才能加“*”。

实际上系统在编译时,数组元素a[i]就是按*(a+i)处理的。即首先通过数组名a找到数组的首地址,然后首地址再加上i就是元素a[i]的地址,然后通过该地址找到该单元中的内容。所以a[i]写成*(a+i)的形式,程序的执行效率会更高、速度会更快。

所以建议你们以后在编程的时候,除了定义数组时使用数组的形式之外,程序中在访问数组元素时全部写成指针*(a+i)的形式。这样能够提高程序的执行效率。所以前面的p[i]也不要用,就用*(p+i)就行了。如果指针还使用p[i]形式就好像给人一种“社会向后倒退发展”的感觉。公司要求编写程序第一个要考虑的就是代码的执行效率,这也是数组的一个缺陷。或者直接抛弃数组,直接用指针malloc动态分配一块大的内存空间当作数组来用。什么是malloc我们稍后再讲。

 指针变量的自增运算:

自增就是指针变量向后移,自减就是指针变量向前移。下面给大家写一个程序:

#include<stdio.h>
    int main(void)
    {
        int a[] = {2, 5, 8, 7, 4};
        int *p = a;

        printf("*p++ = %d, *++p = %d\n", *p++, *++p);

        return 0;
    }
    /*
    在VC++ 6.0中的输出结果是:
    --------------------------------------
    *p++ = 5, *++p = 5
    --------------------------------------
    */

因为指针运算符“*”和自增运算符“++”的优先级相同,而它们的结合方向是从右往左,所以*p++就相当于*(p++), *++p就相当于*(++p)。

如果不用指针,用a,能用a++吗?

在前面讲自增和自减的时候强调过,只有变量才能进行自增和自减,常量是不能进行自增和自减的。a代表的是数组的首地址,是一个常量,所以不能进行自增,所以不能写成a++。

指针变量占多少字节:

同样,用sizeof写一个程序看一下就知道了:

#include<stdio.h>

int main(void) {
	int     *a = NULL;
	float   *b = NULL;
	double *c = NULL;
	char    *d = NULL;

	printf("%d %d %d %d\n", sizeof(a), sizeof(b), sizeof(c), sizeof(d));

	return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
4 4 4 4
--------------------------------------
*/

可见,不管是什么基类型,系统给指针变量分配的内存空间都是4字节。指针变量的“基类型”仅用来指定该指针变量可以指向的变量类型,并没有其他意思。

注意:一个指针在32位的计算机上,占4个字节;一个指针在64位的计算机上,占8个字节。

用64位的电脑跑这个程序,结果如下:

 我们还是接着4字节来讲这个问题-------

那么为什么系统给指针变量分配的是4字节?我们前面讲过,32位计算机有32根地址线,每根地址线要么是0要么是1,只有这两种状态。内存单元中的每个地址都是由这32根地址线通过不同的状态组合而成的。而4字节正好是32位,正好能存储下所有内存单元的地址信息。少一字节可能就不够,多一字节就浪费,所以是4字节。

动态内存分配

动态内存是指在堆上分配的内存,而静态内存是指在栈上分配的内存。堆上分配的内存是由程序员通过编程自己手动分配和释放的,空间很大,存储自由。

传统数组的缺点:

1)数组的长度必须事先指定,而且只能是常量,不能是变量。

2)因为数组长度只能是常量,所以它的长度不能在函数运行的过程当中动态地扩充和缩小。

3)对于数组所占内存空间程序员无法手动编程释放,只能在函数运行结束后由系统自动释放。

所谓“传统数组”的问题,实际上就是静态内存的问题。我们讲传统数组的缺陷实际上就是以传统数组为例讲静态内存的缺陷。本质上讲的是以前所有的内存分配的缺陷。正因为它有这么多缺陷,所以动态内存就变得很重要。动态数组就能很好地解决传统数组的这几个缺陷。

malloc函数的使用(一):

那么动态内存是怎么造出来的?这个难度就比较大了,这是第一个难点。后面讲的“跨函数使用动态内存”是第二个难点,而且难度更大。

malloc是一个系统函数,它是memory allocate的缩写。其中memory是“内存”的意思,allocate是“分配”的意思。所以顾名思义malloc函数的功能就是“分配内存”。要调用它必须要包含头文件<stdlib.h>。它的原型为:

#include<stdlib.h>
void *malloc(unsigned long size);

malloc函数只有一个形参,并且是整型。该函数的功能是在内存的动态存储空间即堆中分配一个长度为size的连续空间。函数的返回值是一个指向所分配内存空间起始地址的指针,类型为void *型。说得简单点就是,malloc函数的返回值是一个地址,这个地址就是动态分配的内存空间的起始地址。如果此函数未能成功地执行,如内存空间不足,则返回空指针NULL。

“int i=5; ”表示分配了4字节的“静态内存”。这里需要强调的是:“静态内存”和“静态变量”虽然都有“静态”两个字,但是它们没有任何关系。不要以为“静态”变量的内存就是“静态内存”。静态变量的关键字是static,它与全局变量一样,都是在“静态存储区”中分配的。这块内存在程序编译的时候就已经分配好了,而且在程序的整个运行期间都存在;而静态内存是在栈中分配的,比如局部变量。

那么我们如何判断一个内存是静态内存还是动态内存呢?凡是动态分配的内存都有一个标志:都是用一个系统的动态分配函数来实现的,如malloc或calloc。calloc和malloc的功能很相似,我们一般都用malloc。calloc用得很少,所以我们不讲。

那么如何用malloc动态分配内存呢?比如:

int *p = (int *)malloc(4);

它的意思是:请求系统分配4字节的内存空间,并返回第一字节的地址,然后赋给指针变量p。在前面讲指针的时候,强调了千万不要引用未初始化的指针变量。但是当用malloc分配动态内存之后,上面这个指针变量p就被初始化了。

但是下面有一个很关键的问题:函数malloc的返回值类型为void *型,而指针变量p的类型是int *型,即两个类型不一样,那么可以相互赋值吗?上面语句是将void *型“强制类型转换”成int *型,但事实上可以不用转换。在C语言中void *型可以不经转换地直接赋给任何类型的指针变量(函数指针变量除外)。实际上也是经过转换的,只不过是系统自动转换的。当然在C语言中强制转换malloc()的返回值并没有错,只是没有必要。因此在C语言中不推荐强制类型转换malloc()的返回值。但是在C++中如果要使用malloc()函数,那么必须要进行强制类型转换,否则编译时就会出错。但是在C++中我们一般也不会使用malloc(),而是使用new。

所以“int *p=(int *)malloc(4); ”就可以写成“int *p=malloc(4); ”。

这时有人会问:“void不是不会有返回值吗?为什么malloc还会有返回值?”这里需要注意的是,malloc函数的返回值类型是void *型,而不是void型。void *型和void型是有区别的。void *型是定义一个无类型的指针变量,它可以指向任何类型的数据。但是需要注意的是,不能对void *型的指针变量进行运算操作,如指针的运算、指针的移动等。

下面利用“int *p=malloc(4); ”语句给大家写一个很有意思的程序:

#include<stdio.h>
#include<stdlib.h>
int main(void) {
	while (1) {
		int *p = malloc(1000);
	}

	return 0;
}

这个程序是非常简单的一个木马病毒。这个程序有一个专业的名称,叫“内存泄漏”。什么是“内存泄漏”呢?每台电脑都有内存,所有的程序都是先存放到内存里面才能运行。但是上面这个程序将内存空间都占满了,那么其他程序就没有地方存放了,所以内存就好像泄漏了一样。

malloc函数的使用(二):

下面使用malloc函数写一个程序,程序的功能是:调用被调函数,将主调函数中动态分配的内存中的数据放大10倍。

#include<stdio.h>
#include<stdlib.h>
void Decuple(int *i);  //函数声明,decuple是10倍的意思

int main(void) {
	int *p = malloc(4);

	*p = 10;
	Decuple(p);
	printf("*p = %d\n", *p);

	return 0;
}

void Decuple(int *i) {
	*i = (*i) * 10;

	return;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
*p = 100
--------------------------------------
*/

这个程序有一个地方需要说明,即“int *p=malloc(4); ”。4表示分配了4字节的动态内存。但是,这是因为在前面用sizeof试过笔者的计算机,其给int型变量分配的是4字节,所以这么写没有问题。但是如果把这个程序移植到其他计算机中呢?系统给int型变量分配的还是4字节吗?所以直接写“4”的可移植性很差。如果别的计算机给int型变量分配的是8字节,这时候如果还写“int *p=malloc(4); ”,代码也能通过编译,但是会有4字节因“无家可归”而直接“住进邻居家”。造成的后果是后面内存中的原有数据被覆盖。下面写一个程序验证一下:

#include<stdio.h>
#include<stdlib.h>

int main(void) {
	int *p = malloc(1);  //分配了1字节的动态内存空间
	*p = 1000;

	printf("*p = %d\n", *p);

	return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
*p = 1000
--------------------------------------
*/

malloc动态分配了1字节的内存空间,最多能存放的整数是255。但现在将1000赋给p竟然不警告,而且还能输出1000,这说明超过一字节的数据肯定住到“邻居”家去了。这样“邻居”家的数据就被覆盖了。

也就是说,int型变量并不是一定占4字节。C语言从来没有规定一个整型必须要强制占几字节。C语言只规定了:短整型的长度不能大于整型,整型的长度不能大于长整型。至于具体占多少字节,不同的计算机是有差别的,这主要由计算机的操作系统决定,或者说由安装在该系统上的编译器的编译规则决定。所以malloc后面直接写“4”不是很好,最好的方式为sizeof(int),即:

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

sizeof(int)的值是int型变量所占的字节数,这样就能很好地表示当前计算机中int型变量占几字节。这样写程序的可移植性就增强了。所以动态内存最好是需要多少就构建多少。多了虽然不会有问题,但是会浪费内存,而少了就可能出问题。

如果还想简单一点的话,也可以像下面这样写:

    int *p = malloc(sizeof*p);

前面讲过sizeof的后面可以紧跟类型,也可以直接跟变量名。如果是变量名,那么就表示该变量在内存中所占的字节数。所以*p是int型的,那么sizeof*p就表示int型变量在内存中所占的字节数。而且这样连sizeof后面的括号都可以省略了,当然加上括号也行。但是如果写类型的话,sizeof(int)中int两端的括号就不能省略。此外,如果写变量名,那么可以不加括号,但是必须要用空格隔开。但是如果变量名前面有“*”,比如“sizeof*p”,那么空格也可以不加,当然加上也可以。笔者习惯不加,因为这样sizeof和*p看上去更像一个整体。

free函数的使用:

前面讲过,动态分配的内存空间是由程序员手动编程释放的。那么怎么释放呢?是用free函数。free函数的原型是:

#include<stdio.h>

void free(void *p);

free函数无返回值。它的功能是释放指针变量p所指向的内存单元。所谓释放并不是指清空内存空间,而是指将该内存空间标记为“可用”状态,使操作系统在分配内存时可以将它重新分配给其他变量使用。但是需要注意的是,指针变量p被释放之后,它仍然是指向那块内存空间的,只是那块内存空间已经不再属于它了而已。如果其他变量在里面存放了值,而你现在用p往里面写入数据就会把那个值给覆盖,这样就会造成其他程序错误。所以当指针变量被释放后,要立刻把它的指向改为NULL。

那么当指针变量被释放后,它所指向的内存空间中的数据会怎样呢?是被清空了,还是仍然是原来那个值呢?或是存放一个极小的负数?这个不一定!free的标准行为只是表示这块内存可以被再分配,至于它里面的数据是否被清空并没有强制要求。不同的编译器处理的方式可能不一样,这个不需要我们考虑。如果是在VC++ 6.0中,当指针变量被释放后,虽然它仍然是指向那个内存空间的,但那个内存空间中的值将会被重新置一个非常小的负数。这个大家了解一下就行了,编程中不需要我们考虑这个问题,而且在不同的编译器中结果可能都不一样。

malloc和free一定要成对存在,一一对应。有malloc就一定要有free,有几个malloc就要有几个free。而且free和置NULL也一定要成对存在,也就是说释放一个指向动态内存的指针变量后要立刻把它指向NULL。

最后需要注意的是,对传统数组而言,对数组名使用sizeof可以求出整个数组在内存中所占的字节数,即可以求出数组的长度。但是对动态数组而言这么做是行不通的,因为动态数组是通过指针变量引用的,而对指针变量使用sizeof结果都是4,所以无法通过sizeof求出整个动态数组的长度。不过动态数组的长度也不需要通过sizeof去求得,因为动态数组的长度是可以动态指定的,即可以是变量,这个变量表示的就是动态数组的长度。

动态数组长度的扩充和缩小:

动态数组的长度可以在函数运行的过程当中动态地扩充和缩小。怎么扩充和缩小?用realloc函数。realloc函数也是系统提供的系统函数,它是英文单词reallocate的缩写,即“重新分配”的意思。该函数的原型为:

#include<stdlib.h>

void *realloc(void *p, unsigned long size);

其中指针变量p是指向“要改变内存大小的动态内存的”指针变量。指针变量p是void *型的,表示可以改变任何基类型的、指向动态内存的指针变量。第二个参数size是重新指定的“新的长度”。

新的长度”可大可小,但是要注意,如果“新的长度”小于原内存的大小,可能会导致数据丢失,慎用!如果是扩充的话,原有数据仍然被保留着,仅在已有内存的基础上进行扩充。比如现在是5字节,扩充到7字节,那么原来的5个内存单元不动,里面的数据也不会改变,只在原来的基础上增加2个内存单元。

动态数组长度的扩充和缩小大家了解一下就行了,不是我们学习的重点,在实际编程中用得也不多。

通过指针引用二维数组

要理解指针和二维数组的关系首先要记住一句话:二维数组就是一维数组。如果理解不了这句话,那么你就无法理解指针和二维数组的关系。

假如有一个二维数组:

    int a[3][4] = {{1, 3, 5, 7}, {9, 11, 13, 15}, {17, 19, 21, 23}};

其中a是二维数组名。a数组包含3行,即3个行元素:a[0], a[1], a[2]。每个行元素都可以看成含有4个元素的一维数组。而且C语言规定,a[0]、a[1]、a[2]分别是这三个一维数组的数组名。如下所示:

a[0]、a[1]、a[2]既然是一维数组名,一维数组的数组名表示的就是数组第一个元素的地址,所以a[0]表示的就是元素a[0][0]的地址,即a[0]==&a[0][0]; a[1]表示的就是元素a[1][0]的地址,即a[1]==&a[1][0]; a[2]表示的就是元素a[2][0]的地址,即a[2]==&a[2][0]。

我们知道,在一维数组b中,数组名b代表数组的首地址,即数组第一个元素的地址,b+1代表数组第二个元素的地址,…, b+n代表数组第n+1个元素的地址。所以既然a[0]、a[1]、a[2]、…、a[M -1]分别表示二维数组a[M][N]第0行、第1行、第2行、…、第M-1行各一维数组的首地址,那么同样的道理,a[0]+1就表示元素a[0][1]的地址,a[0]+2就表示元素a[0][2]的地址,a[1]+1就表示元素a[1][1]的地址,a[1]+2就表示元素a[1][2]的地址……a[i]+j就表示a[i][j]的地址。

二维数组的首地址和数组名:

下面来探讨一个问题:“二维数组a[M][N]的数组名a表示的是谁的地址?”在一维数组中,数组名表示的是数组第一个元素的地址,那么二维数组呢?a表示的是元素a[0][0]的地址吗?不是!我们说过,二维数组就是一维数组,二维数组a[3][4]就是有三个元素a[0]、a[1]、a[2]的一维数组,所以数组a的第一个元素不是a[0][0],而是a[0],所以数组名a表示的不是元素a[0][0]的地址,而是a[0]的地址,即:

    a == &a[0]

而a[0]又是a[0][0]的地址,即:

    a[0] == &a[0][0]

所以二维数组名a和元素a[0][0]的关系是:

    a == &(&a[0][0])

即二维数组名a是地址的地址,必须两次取值才可以取出数组中存储的数据。对于二维数组a[M][N],数组名a的类型为int (*)[N],所以如果定义了一个指针变量p:

    int *p;

并希望这个指针变量指向二维数组a,那么不能把a赋给p,因为它们的类型不一样。要么把&a[0][0]赋给p,要么把a[0]赋给p,要么把*a赋给p。前两个好理解,可为什么可以把*a赋给p?因为a==&(&a[0][0]),所以*a==*(&(&a[0][0]))==&a[0][0]。除此之外你也可以把指针变量p定义成int (*)[N]型,这时就可以把a赋给p,而且用这种方法的人还比较多。

下面写个程序:

#include<stdio.h>
int main(void)
{
	int arr[2][2]={{1,2},{3,4}};
	int (*p)[2]=arr;
	for(int i=0;i<2;i++){
		for(int j=0;j<2;j++){
			printf("%-2d\x20",*(*(p+i)+j));//注意写法 
		}
	}
	return 0;
}

再来看:如果把&a[0][0]赋给指针变量p的话,会有如下规律:

p+i*4+j == &a[i][j]

其中4是二维数组的列数。

写个程序验证一下:

#include<stdio.h>
int main(void) {
	int a[3][4] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12};
	int i, j;
	int *p = &a[0][0];  //把a[0][0]的地址赋给指针变量p

	for (i=0; i<3; ++i) {
		for (j=0; j<4; ++j) {
			printf("%-2d\x20", *(p+i*4+j));
		}

		printf("\n");
	}

	return 0;
}
/*
  在VC++ 6.0中的输出结果是:
--------------------------------------
1   2   3   4
5   6   7   8
9   10 11 12
--------------------------------------
*/

 函数指针

如果在程序中定义了一个函数,那么在编译时系统就会为这个函数代码分配一段存储空间,这段存储空间的首地址称为这个函数的地址。而且函数名表示的就是这个地址。既然是地址我们就可以定义一个指针变量来存放,这个指针变量就叫作函数指针变量,简称函数指针。

那么这个指针变量怎么定义呢?虽然同样是指向一个地址,但指向函数的指针变量同我们之前讲的指向变量的指针变量的定义方式是不同的。例如:

    int(*p)(int, int);

这个语句就定义了一个指向函数的指针变量p。首先它是一个指针变量,所以要有一个“*”,即(*p);其次前面的int表示这个指针变量可以指向返回值类型为int型的函数;后面括号中的两个int表示这个指针变量可以指向有两个参数且都是int型的函数。

所以函数指针的定义方式为:

    函数返回值类型 (* 指针变量名) (函数参数列表);

如何用函数指针调用函数:

给大家举一个例子:

    int Func(int x);     /*声明一个函数*/
    int (*p) (int x);   /*定义一个函数指针*/
    p = Func;            /*将Func函数的首地址赋给指针变量p*/

赋值时函数Func不带括号,也不带参数。由于函数名Func代表函数的首地址,因此经过赋值以后,指针变量p就指向函数Func()代码的首地址了。

下面来写一个程序,看了这个程序你们就明白函数指针怎么使用了:

#include<stdio.h>

int Max(int, int);  //函数声明

int main(void) {
	int(*p)(int, int);  //定义一个函数指针
	int a, b, c;

	p = Max;  //把函数Max赋给指针变量p,使p指向Max函数

	printf("please enter a and b:");
	scanf("%d%d", &a, &b);

	c = (*p)(a, b);  //通过函数指针调用Max函数
	printf("a = %d\nb = %d\nmax = %d\n", a, b, c);

	return 0;
}

int Max(int x, int y) { //定义Max函数
	int z;
	if (x > y) {
		z = x;
	} else {
		z = y;
	}

	return z;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
please enter a and b:3 4
a = 3
b = 4
max = 4
--------------------------------------
*/

字符串

C语言规定,在每一个字符串常量的结尾,系统都会自动加一个字符'\0'作为该字符串的“结束标志符”,系统据此判断字符串是否结束。这里要特别强调一点:' \0'是系统自动加上的,不是人为添加的。'\0'是ASCII码为0的字符,它不会引起任何控制动作,也不是一个可以显示的字符。

在字符串常量中,如果“双撇号”中能看见的字符有n个,那么该字符串在内存中所占的内存空间为n+1字节。但是C语言规定,1个英文字符占1字节,而1个中文字符占2字节,就算是中文的标点符号也是占2字节。

不能将一个字符串常量赋给一个字符变量:

第一,我们在前面讲过,字符变量用char定义。一个字符变量中只能存放一个字符。而字符串一般都有好多字符,占多字节。所以不能将多个字符赋给只占一字节的变量。那么如果字符串常量的双撇号内什么都不写,此时就只有一个字符'\0',那么此时可不可以将它赋给字符变量?不可以!原因看下面第二点。

第二,字符串是指一系列字符的组合。在C语言中,字符变量的类型用char定义。我们这里讲的是数据类型,但是字符串不属于数据类型,也就不存在字符串变量。一种类型的变量要想存储某个对象,必须能兼容该对象的数据类型,而字符串连数据类型都算不上,又怎么能将它赋给字符变量呢?所以在C语言中,任何数据类型都不可以直接存储一个字符串。那么字符串如何存储?在C语言中,字符串有两种存储方式,一种是通过字符数组存储,另一种是通过字符指针存储。

这里需要注意的是:虽然C语言里面没有数据类型可以存储字符串,但C++和Java中都有。

字符数组

因为字符数组首先是一个数组,所以前面讲的数组内容通通都适用。其次它是存放字符的数组,即数组的类型是char型。比如:

    char name[10];

表示定义了10字节的连续内存空间。

1)如果字符串的长度大于10,那么就存在语法错误。这里需要注意的是,这里指的“字符串的长度”包括最后的'\0'。也就是说,虽然系统会自动在字符串的结尾加'\0',但它不会自动为'\0'开辟内存空间。所以在定义数组长度的时候一定要考虑'\0'。

2)如果字符串的长度小于数组的长度,则只将字符串中的字符赋给数组中前面的元素,剩下的内存空间系统会自动用'\0'填充。

字符与数组的初始化:字符数组的初始化与前面所讲数组的初始化一样,要么定义时初始化,要么定义后初始化。先定义后初始化必须一个一个地进行赋值,不能整体赋值;

前面讲过系统会在字符串的最后自动添加结束标志符'\0',但是当一个一个赋值时,系统不会自动添加'\0',必须手动添加。如果忘记添加,虽然语法上没有错误,但是程序将无法达到我们想要的功能。此外,空格字符必须要在单引号内“敲”一个空格,不能什么都不“敲”,什么都不“敲”就是语法错误。也不能多“敲”,因为一个单引号内只能放一个字符,“敲”多个空格就是多个字符了。

假如数组b最后没有手动添加'\0'的。程序是希望数组b输出“i miss you”,但输出结果是“i miss you烫i love you”。原因就是系统没有在最后添加'\0'。虽然程序中对数组b的长度进行了限制,即长度为10,但是由于内存单元是连续的,对于字符串,系统只要没有遇到'\0',就会认为该字符串还没有结束,就会一直往后找,直到遇到'\0'为止。被找过的内存单元都会输出,从而超出定义的10字节。

定义时初始化可以整体赋值。整体赋值有一个明显的优点——方便。定义时初始化可以不用指定数组的长度,而先定义后初始化则必须要指定数组的长度。

汉字不能分开一个一个赋值。因为一个汉字占2字节,若分开赋值,由于一个单引号内只能放一个字符,即一字节,所以将占2字节的汉字放进去当然就出错了。因此如果用字符数组存储汉字的话必须整体赋值,即要么定义时初始化,要么调用strcpy函数。

下面写一个程序巩固一下:

#include<stdio.h>

    int main(void)
    {
        char str[3] = "";

        str[2] = ' a' ;

        printf("str = %s\n", str);

        return 0;
    }
    /*
    在VC++ 6.0中的输出结果是:
    --------------------------------------
    str =
    --------------------------------------
    */

程序中定义了一个长度为3的字符数组,然后给第三个元素赋值为'a',然后将整个字符数组输出。但是输出结果什么都没有,原因就是其直接初始化为一对双引号,此时字符数组中所有元素都是'\0'。所以虽然第三个元素为'a',但因为第一个元素为'\0',而'\0'是字符串的结束标志符,所以无法输出。需要注意的是,使用此种初始化方式时一定要指定数组的长度,否则默认数组长度为1。

总结:字符数组与前面讲的数值数组有一个很大的区别,即字符数组可以通过“%s”一次性全部输出,而数值数组只能逐个输出每个元素。

初始化内存函数:memset():

在前面不止一次说过,定义变量时一定要进行初始化,尤其是数组和结构体这种占用内存大的数据结构。在使用数组的时候经常因为没有初始化而产生“烫烫烫烫烫烫”这样的野值,俗称“乱码”。每种类型的变量都有各自的初始化方法,memset()函数可以说是初始化内存的“万能函数”,通常为新申请的内存进行初始化工作。它是直接操作内存空间,mem即“内存”(memory)的意思。该函数的原型为:

#include<string.h>

void *memset(void *s, int c, unsigned long n);

函数的功能是:将指针变量s所指向的前n字节的内存单元用一个“整数”c替换,注意c是int型。s是void *型的指针变量,所以它可以为任何类型的数据进行初始化。

memset()的作用是在一段内存块中填充某个给定的值。。memset一般使用“0”初始化内存单元,而且通常是给数组或结构体进行初始化。一般的变量如char、int、float、double等类型的变量直接初始化即可,没有必要用memset。如果用memset的话反而显得麻烦。当然,数组也可以直接进行初始化,但memset是对较大的数组或结构体进行清零初始化的最快方法,因为它是直接对内存进行操作的。

这时有人会问:“字符串数组不是最好用'\0'进行初始化吗?那么可以用memset给字符串数组进行初始化吗?也就是说参数c可以赋值为'\0'吗?”可以的,虽然参数c要求是一个整数,但是整型和字符型是互通的。但是赋值为'\0'和0是等价的,因为字符'\0'在内存中就是0。所以在memset中初始化为0也具有结束标志符'\0'的作用,所以通常我们就写“0”。

memset函数的第三个参数n的值一般用sizeof()获取,这样比较专业。注意,如果是对指针变量所指向的内存单元进行清零初始化,那么一定要先对这个指针变量进行初始化,即一定要先让它指向某个有效的地址。而且用memset给指针变量如p所指向的内存单元进行初始化时,n千万别写成sizeof(p),这是新手经常会犯的错误。因为p是指针变量,不管p指向什么类型的变量,sizeof(p)的值都是4。

下面写一个程序:

#include<stdio.h>
#include<string.h>

int main(void) {
	int i;  //循环变量
	char str[10];
	char *p = str;

	memset(str, 0, sizeof(str));  //只能写sizeof(str),不能写sizeof(p)

	for (i=0; i<10; ++i) {
		printf("%d\x20", str[i]);
	}
	printf("\n");
	return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------------------------------------------------
memset(p, 0, sizeof(p));  //地址的大小都是4字节
0 0 0 0 -52 -52 -52 -52 -52 -52
--------------------------------------------------------------------------------
memset(p, 0, sizeof(*p));  //*p表示的是一个字符变量,只有一字节
0 -52 -52 -52 -52 -52 -52 -52 -52 -52
--------------------------------------------------------------------------------
memset(p, 0, sizeof(str));
0 0 0 0 0 0 0 0 0 0
--------------------------------------------------------------------------------
memset(str, 0, sizeof(str));
0 0 0 0 0 0 0 0 0 0
--------------------------------------------------------------------------------
memset(p, 0, 10);  //直接写10也行,但不专业
0 0 0 0 0 0 0 0 0 0
--------------------------------------------------------------------------------
*/

用scanf输入字符串:

除了在定义字符数组时初始化外,还可以通过scanf从键盘输入字符串。下面写一个程序:

#include<stdio.h>
int main(void) {
	char str[10];  //str是string的缩写,即字符串

	printf("请输入字符串:");
	scanf("%s",  str);   /*输入参数是已经定义好的“字符数组名”,不用加&,因为在C语言中数组
    名就代表该数组的起始地址*/

	printf("输出结果:%s\n", str);

	return 0;
}
/*
在VC++ 6.0中的输出结果是:
----------------------------------------
请输入字符串:爱你一生一世
输出结果:爱你一生一世
----------------------------------------
*/

用scanf给字符数组赋值不同于对数值型数组赋值。前面讲过,给数值型数组赋值时只能用for循环一个一个地赋值,不能整体赋值。而给字符数组赋值时可以直接赋值,不需要使用循环。此外从键盘输入后,系统会自动在最后添加结束标志符'\0'。但是用scanf输入字符串时有一个地方需要注意:如果输入的字符串中带空格,比如“i love you”,那么就会有一个问题。我们将上面程序运行时输入的字符串改一下:

#include<stdio.h>
int main(void) {
	char str[10];  //str是string的缩写,即字符串

	printf("请输入字符串:");
	scanf("%s",  str);   /*输入参数是已经定义好的“字符数组名”,不用加&,因为在C语言中数组
    名就代表该数组的起始地址*/

	printf("输出结果:%s\n", str);

	return 0;
}
/*
在VC++ 6.0中的输出结果是:
----------------------------------------
请输入字符串:i love you
输出结果:i
----------------------------------------
*/

我们看到,输入的是“i love you”,而输出的只有“i”。原因是系统将空格作为输入字符串之间的分隔符。也就是说,只要一“敲”空格,系统就认为当前的字符串已经结束,接下来输入的是下一个字符串,所以只会将空格之前的字符串存储到定义好的字符数组中。那么这种情况该怎么办?那么就以空格为分隔符,数数有多少个字符串,有多少个字符串就定义多少个字符数组。比如“i love you”有两个空格,表示有三个字符串,那么就定义三个字符数组:

#include<stdio.h>

int main(void) {
	char str1[10], str2[10], str3[10];

	printf("请输入字符串:");
	scanf("%s%s%s", str1, str2, str3);

	printf("输出结果:%s %s %s\n", str1, str2, str3);  //%s间要加空格

	return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
请输入字符串:i love you
输出结果:i love you
--------------------------------------
*/

最后还有一点需要说明,我们在前面讲“清空缓冲区”的时候讲过,用scanf输入时,不管输入什么,最后“敲”的回车都会被留在缓冲区,这里也不例外。输入字符串时最后“敲”的回车也会被留在缓冲区,如果紧接着要给一个字符变量赋值的话,那么还没等你输入系统就自动退出来了。因为系统自动将回车产生的字符'\n'赋给该字符变量了,所以此时对字符变量赋值前要首先清空缓冲区。

字符串与指针

在C语言中有两种方法存储和访问一个字符串,一是用字符数组,二是用字符指针,指向一个字符串。字符指针首先是一个指针变量,所以要有“指针运算符*”;其次指针变量里面存放的是地址,这一点一定要明确;最后它是字符,所以是char型。

下面写一个程序:

#include<stdio.h>

int main(void) {
	char *string = "I Love You Mom! ";

	printf("%s\n", string);  //输出参数是已经定义好的“指针变量名”

	return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
I Love You Mom!
--------------------------------------
*/

这个程序并没有定义一个字符数组,而是定义了一个字符指针变量string。C语言对字符串常量是按字符数组处理的,即在内存中开辟了一个字符数组用来存放字符串常量。程序中对字符指针变量string的初始化实际上是把系统为字符串"I Love You Mom! "在内存中开辟的字符数组的第一个元素的地址赋给了string。即string中存放的是字符'I'的地址。

输出参数要写“字符指针变量名”,这样系统在输出时会首先输出该指针变量所指向的字符,即'I',然后string自动加1,使之指向下一个字符……直到遇到字符串结束标志'\0'为止。同样,' \0'是系统自动添加的。

如何用scanf给字符指针变量所指向的内存单元初始化

前面在讲指针的时候专门讲过scanf的问题。首先要明确的一点是,scanf只能给字符指针变量所指向的内存单元初始化,不能给字符指针变量初始化。其次,在用scanf给字符指针变量所指向的内存单元初始化之前一定要先使字符指针变量明确地指向某个具体的字符数组。下面写一个程序:

#include<stdio.h>
int main(void) {
	char str[30];
	char *string = str;  //一定要先给字符指针变量初始化

	printf("请输入字符串:");
	scanf("%s", string);

	printf("%s\n", string);  //输出参数是已经定义好的“指针变量名”

	return 0;
}
/*
在VC++ 6.0中的输出结果是:
------------------------------------------------------
请输入字符串:Hi,清明节一起去玩啊
Hi,清明节一起去玩啊
------------------------------------------------------
*/

字符串处理函数

字符串输入函数gets()

在前面从键盘输入字符串是使用scanf和%s。其实还有更简单的方法,即使用gets()函数。该函数的原型为:

#include<stdio.h>
char *gets(char *str);

gets()函数的功能是从输入缓冲区中读取一个字符串存储到字符指针变量str所指向的内存空间。就算输入的字符串中有空格也可以直接输入,不用像scanf那样要定义多个字符数组。、

关于使用gets()函数需要注意:使用gets()时,系统会将最后“敲”的换行符从缓冲区中取出来,然后丢弃,所以缓冲区中不会遗留换行符。这就意味着,如果前面使用过gets(),而后面又要从键盘给字符变量赋值的话就不需要吸收回车清空缓冲区了,因为缓冲区的回车已经被gets()取出来扔掉了。

优先使用fgets()函数

虽然用gets()时有空格也可以直接输入,但是gets()有一个非常大的缺陷,即它不检查预留存储区是否能够容纳实际输入的数据,这样很不安全。如果输入的字符数目大于数组的长度,就会发生内存越界,所以编程时建议使用fgets()。

fgets()的原型为:

#include<stdio.h>
char *fgets(char *s, int size, FILE *stream);

fgets()虽然比gets()安全,但安全是要付出代价的,代价就是它的使用比gets()要麻烦一点——有三个参数。它的功能是从stream流中读取size个字符存储到字符指针变量s所指向的内存空间。它的返回值是一个指针,指向字符串中第一个字符的地址。

其中:s代表要保存到的内存空间的首地址,可以是字符数组名,也可以是指向字符数组的字符指针变量名。size代表的是读取字符串的长度。stream表示从何种流中读取,可以是标准输入流stdin,也可以是文件流,即从某个文件中读取,这个在后面讲文件的时候再详细介绍。标准输入流就是前面讲的输入缓冲区。所以如果是从键盘读取数据的话就是从输入缓冲区中读取数据,即从标准输入流stdin中读取数据,所以第三个参数为stdin。

下面写一个程序:

#include<stdio.h>

int main(void) {
	char str[20];  /*定义一个最大长度为19,末尾是'\0'的字符数组来存储字符串*/

	printf("请输入一个字符串:");

	fgets(str, 7, stdin);  /*从输入流stdin即输入缓冲区中读取7个字符到字符数组str中*/

	printf("%s\n", str);

	return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
请输入一个字符串:i love you
i love
--------------------------------------
*/

我们发现输入的是“i love you”,而输出只有“i love”。原因是fgets()只指定了读取7个字符放到字符数组str中。“i love”加上中间的空格和最后的'\0'正好是7个字符。那有人会问:“用fgets()是不是每次都要去数有多少个字符呢?这样不是很麻烦吗?”不用数!fget()函数中的size如果小于字符串的长度,那么字符串将会被截取;如果size大于字符串的长度则多余的部分系统会自动用'\0'填充。所以假如你定义的字符数组长度为n,那么fgets()中的size就指定为n-1,留一个给'\0'就行了。

但是需要注意的是:如果输入的字符串长度没有超过n-1,那么系统会将最后输入的换行符'\n'保存进来,保存的位置是紧跟输入的字符,然后剩余的空间都用'\0'填充。所以此时输出该字符串时printf中就不需要加换行符'\n'了,因为字符串中已经有了。下面写一个程序看一下:

#include<stdio.h>

int main(void) {
	char str[30];
	char *string = str;  //一定要先给指针变量初始化

	printf("请输入字符串:");
	fgets(string, 29, stdin);  //size指定为比字符数组元素少一就行了

	printf("%s", string);  //printf中不需要添加'\n',因为字符串中已经有了

	return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------------------
请输入字符串:i love studying C语言
i love studying C语言
--------------------------------------------------
*/

我们看到,printf中没有添加换行符'\n',输出时也自动换行了。所以fgets()和gets()一样,最后的回车都会从缓冲区中取出来。只不过gets()是取出来丢掉,而fgets()是取出来自己留着。

使用gets()和fgets()前注意吸收回车:

在前面介绍清空输入缓冲区时讲过,当使用gets()或fgets()给字符数组赋值时,如果前面使用过scanf,那么scanf遗留的回车将会被它们取出并赋给该字符串,并且只能获取这个回车符,从而导致字符数组赋值失败。

我们要习惯在每个scanf后都要加上getchar()或“while (getchar()!=' \n' ); ”,不管是否需要。在后面的程序中可能有些地方不会添加,但一定要养成这个习惯。

字符串输出函数puts()

前面在输出字符串时都使用printf,通过“%s”输出字符串。其实还有更简单的方法,就是使用puts()函数。该函数的原型为:

#include<stdio.h>
int puts(const char *s);

这个函数也很简单,只有一个参数。s可以是字符指针变量名、字符数组名,或者直接是一个字符串常量。功能是将字符串输出到屏幕。输出时只有遇到'\0'也就是字符串结束标志符才会停止。

使用puts()函数连换行符'\n'都省了,使用puts()显示字符串时,系统会自动在其后添加一个换行符。但是puts()和printf相比也有一个小小的缺陷,就是如果puts()后面的参数是字符指针变量或字符数组,那么括号中除了字符指针变量名或字符数组名之外什么都不能写。下面要讲解的fputs()也不可以。

字符串输出函数fputs()

fputs()函数也是用来显示字符串的,它的原型是:

#include<stdio.h>
int fputs(const char *s, FILE *stream);

s代表要输出的字符串的首地址,可以是字符数组名或字符指针变量名。stream表示向何种流中输出,可以是标准输出流stdout,也可以是文件流。

fputs()和puts()有两个小区别:

1)puts()只能向标准输出流输出,而fputs()可以向任何流输出。

2)使用puts()时,系统会在自动在其后添加换行符;而使用fputs()时,系统不会自动添加换行符。

那么这是不是意味着使用fputs()时就要在后面添加一句“printf("\n"); ”换行呢?看情况!如果输入时使用的是gets(),那么就要添加printf换行;但如果输入时用的是fgets(),则不需要。因为使用gets()时,gets()会将回车读取出来并丢弃,所以换行符不会像scanf那样被保留在缓冲区,也不会被gets()存储;而使用fgets()时,换行符会被fgets()读出来并存储在字符数组的最后,这样当这个字符数组被输出时换行符就会被输出并自动换行。但是也有例外,比如使用fgets()时指定了读取的长度,如只读取5个字符,事实上它只能存储4个字符,因为最后还要留一个空间给'\0',而你却从键盘输入了多于4个字符,那么此时“敲”回车后换行符就不会被fgets()存储。数据都没有地方存放,哪有地方存放换行符呢!此时因为fgets()没有存储换行符,所以就不会换行了。

虽然gets()、fgets()、puts()、fput()都是字符串处理函数,但它们都包含在stdio.h头文件中,并不是包含在string.h头文件中。

字符串复制函数strcpy()

两个字符串变量不可以使用“=”进行直接赋值,只能通用strcpy()函数进行赋值。strcpy是string copy的缩写,即“字符串复制”。它的原型是:

#include<string.h>
char *strcpy(char *dest, const char *src);

功能是将指针变量src所指向的字符串复制到指针变量dest所指向的位置。dest是destination的缩写,即“目的地”; src是source的缩写,即“源”。

注意:要想用strcpy()将一个字符串复制到一个字符数组中,那么字符数组在定义时长度一定要够大,要足以容纳被复制的字符串。如果不够大,程序运行时就会出错。同样,在定义字符数组长度时,一定要将结束标志符'\0'考虑进去,系统是不会自动为'\0'分配内存单元的。

下面写一个程序看一下:

#include<stdio.h>
#include<string.h>
int main(void) {
	char str[20];
	int i;

	strcpy(str, "i love you");
	strcpy(str, "Lord");

	for (i = 0; i<11; i++) {
		printf("%c", str[i]);
	}

	printf("\n");

	return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
Lord e you
--------------------------------------
*/

该程序先将字符串“i love you”赋给数组str,然后再把字符串“Lord”赋给数组str。字符串“Lord”总共占5字节的内存单元(包括最后的'\0')。从最后的输出结果可以看出,字符串“Lord”分别用字符'L' 、' o ' 、' r' 、' d' 、' \0'覆盖了“i love you”的前5个内存单元'i' 、' ' 、' l' 、' o' 、' v'。从第6个内存单元往后仍保存着原来的数据,即'e' 、' ' 、' y' 、' o' 、' u'。

为什么用printf和puts()无法直接输出上面这个结果?为什么要使用循环逐个输出字符?因为如果使用printf或者puts(),那么输出的只有“Lord”。原因是用printf或者puts()输出字符串时,只要遇到'\0'就会停止输出,“Lord”最后正好有一个'\0',所以后面的'e' 、' ' 、' y' 、' o' 、' u'就不会输出了。

字符串复制函数strncpy()

strncpy()函数和strcpy()很相似,就中间多了一个“n”。事实上它们的功能也是相似的。strncpy()函数的原型为:

#include<string.h>
char *strncpy(char *dest, const char *src, unsigned long n);

strcpy()的功能是将指针变量src所指向的字符串复制到指针变量dest所指向的位置。而strncpy()的功能是将指针变量src所指向的字符串的前n个字符复制到指针变量dest所指向的位置。只要将strcpy()函数掌握之后,strncpy()就很简单了。关于strncpy()唯一需要注意的是,如果它不是复制整个字符串,那么最后的结束标志符'\0'就不会被复制,这时候必须手动编程在后面添加'\0'否则输出时由于找不到结束标志符就会输出乱码。它会一直输出,直到遇到'\0'为止。

内存拷贝函数memcpy()

strcpy()的对象只能是字符串,但是memcpy()可以是任何类型的数据,因为它是内存拷贝函数,是直接对内存进行操作的。该函数的原型为:

#include<string.h>
void *memcpy(void *dest, const void *src, unsigned long n);

功能是从指针变量src所指向的内存空间中复制n字节的数据到指针变量dest所指向的内存空间中。

其前两个参数的类型都是void *型,所以该函数可以将任何类型的数据复制给任何类型的变量。但是在编程的时候最好前两个参数的类型是一致的!虽然不一致也不会报错,但是那么做没有意义,而且往往也达不到想要的效果。

memcpy()可以在任何类型的数据间进行复制,但是用得更多的还是复制字符串。因为字符数组无法直接赋给字符数组,而其他变量都可以直接赋值,所以其他变量没有必要使用memcpy()。

当使用memcpy()复制字符串的时候需要注意以下几点:

1)字符数组dest的长度一定要大于复制的字节数n,否则将会产生溢出,导致相邻内存空间的数据被覆盖,这样很危险。

2)如果复制的是完整的字符串,那么字符数组dest的长度和复制的字节数n一定要考虑最后的结束标志符'\0'。

3)如果不是完整复制一个字符串,而是仅复制前几个字符,那么最后的结束标志符'\0'就不会被复制。这时在输出dest的时候,因为找不到结束标志符'\0',就会一直往后输出,直到遇到'\0'为止。为此需要通过编程的方式人为添加'\0'。怎么添加呢?就是在定义字符数组dest时直接给它初始化为"\0",或用memset()给它初始化为0或'\0'。我们一直强调在定义变量时一定要进行初始化,这是良好的编程习惯。

下面写一个程序:

#include<stdio.h>
#include<string.h>
int main(void) {
	char src[20] = "i love you";

	char dest[20] = "\0";  //养成初始化的习惯

	memcpy(dest, src, 19);  /*dest的长度要大于n,所以n就指定比dest小1就行了*/

	printf("dest = %s\n", dest);

	return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
dest = i love you
--------------------------------------
*/
sprintf()

sprintf()是非常强大的字符串处理函数。它与printf相比就是前面多了一个“s”。“s”就表示字符串,即string,说明它是为字符串而“生”的。我们知道,printf是向屏幕输出,其实sprintf()和printf的原理和用法几乎是一样的,但是你绝对想不到sprintf()是给一个字符数组赋值的,而且还不单单是赋值那么简单,其功能更加丰富。sprintf()函数的原型为:

#include<stdio.h>
int sprintf(char *str, "输出控制符", 输出参数);

sprintf()和printf在用法上几乎一模一样,只是“打印”的目的地不同而已。printf是“打印”到屏幕上,而sprintf()是“打印”到字符串中。这直接导致sprintf()比printf有用得多。sprintf()可以将任何类型的输出参数输出到字符串str中,仅此一点我们就能感受到它的强大。sprintf()的强大功能很少会让我们失望。但这里需要注意的是,当把一个字符数组中的内容“打印”到str中后,该字符数组中的内容并不会消失,即相当于是复制式的读取,而不是剪切式的。sprintf()最常见的应用莫过于把数字“打印”到字符串中,下面写一个程序:

#include<stdio.h>
int main(void) {
	int i = 12345;
	float j = 3.14159;

	char str1[20];
	char str2[20];
	char str3[20];
	char str4[20];
	char str5[20];
	sprintf(str1, "%d", i);

	sprintf(str2,  "%.5f",  j);   /*如果不加“.5”的话因为float默认保存到小数点后六位,所
    以最后会输出3.141590*/

	sprintf(str3, "%d%.5f", i, j);

	sprintf(str4, "%d", 123456);

	sprintf(str5, "%d%d", 123, 456);   /*如果%d之间加逗号的话,这个逗号也会打印出来,所
    以如果你想原样输入的话,不要加任何非输出控制符*/

	printf("str1 = %s\nstr2 = %s\nstr3 = %s\nstr4 = %s\nstr5 = %s\n", str1, str2,
	       str3, str4, str5);

	return 0;
}
/*
在VC++ 6.0中的输出结果是:
--------------------------------------
str1 = 12345
str2 = 3.14159
str3 = 123453.14159
str4 = 123456
str5 = 123456
--------------------------------------
*/

sprintf()具有强大的字符串处理功能:

1)sprintf()既然可以将任何类型的输出参数赋给字符数组str,当然也可以将字符指针变量、字符数组或字符串常量赋给str,即相当于strcpy()。

2)如上面程序,可以有多个输出控制符,从而将多个数字连起来然后赋给字符数组str。所以当然也可以将多个字符串连起来赋给str,即相当于strcat()。在许多场合sprintf()可以替代strcat(),但sprintf()比strcat()更强大。因为sprintf()可以连接多个字符串,而strcat()只能连接两个字符串。strcat()本书未涉及,因为用得很少,而且很简单,就是将两个字符串连接起来。

3)sprintf()的返回值返回本次调用“打印”到字符数组str中的字符数目,不包含最后的结束标志符'\0'。也就是说调用sprintf()之后,你无须再调用一次strlen()就可以知道字符数组str的长度,相当于调用strlen()函数。

字符串比较函数strcmp()

strcmp是string compare的缩写,即“字符串比较”。它的原型是:

#include<string.h>
int strcmp(const char *s1, const char *s2);

功能是比较s1和s2所指向的字符数组中的字符串,返回一个int型值。s1和s2可以是字符数组名或字符指针变量名。

怎么比较呢?从左往右一个字符一个字符的比较,比较它们的ASCII码值。如果每个字符都相等,则说明两个字符串完全相同,返回0;如果比较到一个不相等的字符,那么后面的都不比较了。如果前面的大就返回一个大于0的值,如果后面的大就返回一个小于0的值。strcmp的返回值与字符串的长短没有任何关系。比如其中一个字符串的所有字符都比较完了而另一个字符串还有字符,那么系统就会默认前者小。因为当所有字符都比较完之后,最后就剩下一个'\0',而'\0'的ASCII码值是最小的。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/461383.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【滤波专题-第8篇】ICA降噪方法——类EMD联合ICA降噪及MATLAB代码实现(以VMD-ICA为例)

今天来介绍一种效果颇为不错的降噪方法。&#xff08;针对高频白噪声&#xff09; 上一篇文章我们讲到了FastICA方法。在现实世界的许多情况下&#xff0c;噪声往往接近高斯分布&#xff0c;而有用的信号&#xff08;如语音、图像特征等&#xff09;往往表现出非高斯的特性。F…

unity学习(60)——选择角色界面--MapHandler2-MapHandler.cs

1.新建一个脚本&#xff0c;里面有static变量loadingPlayerList using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks;namespace Assets.Scripts.Model {internal class LoadData{public static List<Pl…

3D地图在BI大屏中的应用实践

前言 随着商业智能的不断发展&#xff0c;数据可视化已成为一项重要工具&#xff0c;有助于用户更好地理解数据和分析结果。其中&#xff0c;3D地图作为一种可视化工具&#xff0c;已经在BI大屏中得到了广泛地应用。 3D地图通过将地理信息与数据相结合&#xff0c;以更加直观…

【AI】用iOS的ML(机器学习)创建自己的AI App

用iOS的ML(机器学习)创建自己的AI App 目录 用iOS的ML(机器学习)创建自己的AI App机器学习如同迭代过程CoreML 的使用方法?软件要求硬件开始吧!!构建管道:设计和训练网络Keras 转 CoreML将模型集成到 Xcode 中结论推荐超级课程: Docker快速入门到精通Kubernetes入门到…

计算机网络——物理层(数据通信基础知识)

计算机网络——物理层&#xff08;1&#xff09; 物理层的基本概念数据通信的基本知识一些专业术语消息和数据信号码元 传输速率的两种表示方法带宽串行传输和并行传输同步传输和异步传输 信道基带信号调制常用编码方式 我们今天进入物理层的学习&#xff0c;如果还没有了解OSI…

Transformer代码从零解读【Pytorch官方版本】

文章目录 1、Transformer大致有3大应用2、Transformer的整体结构图3、如何处理batch-size句子长度不一致问题4、MultiHeadAttention&#xff08;多头注意力机制&#xff09;5、前馈神经网络6、Encoder中的输入masked7、完整代码补充知识&#xff1a; 1、Transformer大致有3大应…

C++ 入门篇

目录 1、了解C 2、C关键字 2、命名空间 2.1 命名空间的定义 2.2 命名空间的使用 3. C输入与输出 4.缺省参数 4.1 缺省参数的概念 4.2 缺省参数的分类 5. 函数重载 5.1 函数重载的概念 5.2 C中支持函数重载的原理--名字修饰 6. 引用 6.1 引用概念 6.2 引用…

Docker 系列2【docker安装mysql】【开启远程连接】

文章目录 前言开始步骤1.增加mysql挂载目录2.下载镜像2.启动容器具体步骤4.无法连接5.测试连接 总结 前言 本文开始&#xff0c;默认已经安装docker&#xff0c;如果你还没有完成这个步骤&#xff0c;请查看这一篇文章【docker安装与使用】 开始步骤 1.增加mysql挂载目录 m…

网络原理(1)——UDP协议

目录 一、应用层 举个例子&#xff1a;点外卖 约定数据格式简单粗暴的例子 客户端和服务器的交互&#xff1a; 序列化和返序列化 xml、json、protobuffer 1、xml 2、json 3、protobuffer 二、传输层 端口 端口号范围划分 认识知名的端口号 三、UDP协议 端口 U…

宜搭faas服务器报错Network response was not OK

[error] https://api.dingtalk.com/v1.0/yida/forms/instances? fetch error Error: Network response was not OK 不出意外的话肯定是请求代码的某个部分出了问题&#xff1a;其中formInstanceId和updateFormDataJson是业务的内容 我检查过是没问题的。appType和systemToken…

面试经典-MySQL篇

一、MySQL组成 MySQL数据库的连接池&#xff1a;由一个线程来监听一个连接上请求以及读取请求数据&#xff0c;解析出来一条我们发送过去的SQL语句SQL接口&#xff1a;负责处理接收到的SQL语句查询解析器&#xff1a;让MySQL能看懂SQL语句查询优化器&#xff1a;选择最优的查询…

【C#】【SAP2000】读取SAP2000中所有Frame对象在指定工况的温度荷载值到Grasshopper中

if (build true) {// 连接到正在运行的 SAP2000// 使用 COM 接口获取 SAP2000 的 API 对象cOAPI mySapObject (cOAPI)System.Runtime.InteropServices.Marshal.GetActiveObject("CSI.SAP2000.API.SapObject");// 获取 SAP2000 模型对象cSapModel mySapModel mySap…

Vue 项目安装依赖提示core-js版本低的处理办法

core-js2.6.12: core-js<3 is no longer maintained and not recommended for usage due to the number of issues. Please, upgrade your dependencies to the actual version of core-js3. 我是下载一个老的项目&#xff0c;npm install之后提示上面的错误&#xff1b;本…

Linux——ELK日志分析系统

实验环境 虚拟机三台CentOS 7.9&#xff0c; 组件包 elasticsearch-5.5.0.rpm elasticsearch-head.tar.gz node-v8.2.1.tar.gz phantomjs-2.1.1-linux-x86_64.tar.bz2 logstash-5.5.1.rpm kibana-5.5.1-x86_64.rpm 初始…

分享一下自己总结的7万多字java面试笔记和一些面试视频,简历啥的,已大厂上岸

分享一下自己总结的7万多字java面试笔记和一些面试视频&#xff0c;简历啥的&#xff0c;已大厂上岸 自己总结的面试简历资料&#xff1a;https://pan.quark.cn/s/8b602fe53b58 文章目录 SSMspringspring 的优点&#xff1f;IoC和AOP的理解**Bean 的生命周期****列举一些重要…

C++进阶:详解多态(多态、虚函数、抽象类以及虚函数原理详解)

C进阶&#xff1a;详解多态&#xff08;多态、虚函数、抽象类以及虚函数原理详解&#xff09; 结束了继承的介绍&#xff1a;C进阶&#xff1a;详细讲解继承 那紧接着的肯定就是多态啦 文章目录 1.多态的概念2.多态的定义和实现2.1多态的构成条件2.2虚函数2.2.1虚函数的概念2…

算法笔记 连载中。。。

HashMap&#xff08;会根据key值自动排序&#xff09; HashMap<String, Integer> hash new HashMap<>() hash.put(15,18) hash.getOrDefault(ts, -1) //如果ts(key)存在&#xff0c;返回对应的value 否则返回-1 hashMap1.get(words1[i])1会报错&#xff0c;因…

Vue2在一个页面内动态切换菜单显示对应的路由组件

项目的需求是在一个页面内动态获取导航菜单&#xff0c;导航菜单切换的时候显示对应的路由页面&#xff0c;类似于tab切换的形式&#xff0c;切换的导航菜单和页面左侧导航菜单是同一个路由组件&#xff0c;只是放到了一个页面上&#xff0c;显示的个数不同&#xff0c;所有是动…

QT下跨平台库实现及移植经验分享

最近在移植公司一个QT桌面软件到android上&#xff0c;有一些公司自定义的库&#xff0c;用了很多windows的api&#xff0c;移植过程很是曲折&#xff0c;在此有一些感悟分享一下~ 一.自编写跨平台库 1.有时候为了程序给第三方用需要编译一些qt封装库&#xff0c;并可能跨平台…

AI智慧校园电子班牌云平台源码

目录 家长端 学校端 电子围栏 亲情通话 课堂答题 移动化管理模式 统一资源管理平台 模板内容智能更换 家校互联 家长端 多场景通话:上学放学联系、紧急遇险求助联系、日常亲情通话关注孩子人身安全:到校离校情况、进入危险区域预警等。 学校端 课堂秩序管理:提高教…