如果真的想玩转C语言,就需要理解C语言如何操纵存储器。
在函数参数中直接传递变量的例子:
这是因为数组变量好比指针,上述quote变量代表字符串中第一个字符的地址。
运行结果:
fgets()配合sizeof一起使用要小心!
scanf() vs fgets()对比
掌握指针和存储器寻址对成为一名地道的C程序员来讲非常重要。
什么是指针?
指针就是存储器中某条数据的地址。
为什么要使用指针?
1.在函数调用时,可以只传递一个指针,而不用传递整份数据。
2.让两段代码处理同一条数据,而不是处理两份独立的副本。
简单来讲,指针做了两件事:避免副本和共享数据。
先了解一下存储器结构:
在函数中声明的变量通常保存在 栈 中。
在函数外声明的变量保存在 全局量 区。
如果想要找出变量的存储器地址,可以用 & 运算符:
printf("x 保存在 %p\n", &x); //&x是x的地址,即指针,%p用来格式化地址
#include <stdio.h>
#include <stdlib.h>
void go_south_east(int lat, int lon) {
lat = lat - 1;
lon = lon + 1;
}
int main() {
- int latitude = 32;
int longitude = -64;
go_south_east(latitude, longitude);
printf(" 停! 当前位置 : [%i, %i]\n", latitude, longitude);
- return 0;
}
运行结果:
结论:直接传递变量然后修改,并没有修改成功。
原因:是因为C语言在传递变量的时候,传递的是变量的值,它复制了一个值的副本,你修改的是副本,所以原来变量的值的并没有改变。
解决办法:传递指向变量的指针即可!
如何使用存储器指针:
1.得到变量的地址。
printf("x lives at %p\n", &x);
int *address_of_x = &x; // 这是一个指针变量,它保存的是一个地址,这个地址中保存的是一个int型变量。
2.读取地址中的内容。
int value_stored = *address_of_x; // 用 *变量名 获取指针对应的变量的值
3.改变地址中的内容。
*address_of_x = 99; //它会把原x变量中的内容改成99
以指针形式修改上面的错误代码:
#include <stdio.h>
#include <stdlib.h>
//void go_south_east(int lat, int lon) {
void go_south_east(int* lat, int* lon) {
// lat = lat - 1;
// lon = lon + 1;
*lat = *lat -1 ; // *x表示地址x所存的数据
*lon = *lon + 1;
}
int main() {
int latitude = 32;
int longitude = -64;
// go_south_east(latitude, longitude);
go_south_east(&latitude, &longitude);
// printf("当前的count地址:%p\n", &count); // &x表示x的地址
// int *address_of_count = &count;
printf(" 停! 当前位置 : [%i, %i]\n", latitude, longitude);
return 0;
}
运行结果:OK了!
问: 指针是真实的地址单元,还是某种形式的引用?
答: 它们是进程存储器中真实编号的地址。
问: 为什么存储器是进程的?
答: 计算机会为每个进程分配一个简版存储器,看起来就像是一长串字节。
问: 但存储器并非如此?
答: 实际的存储器复杂多了,但细节对进程隐藏了起来,这样操作系统就可以
在存储器中移动进程,或释放并重新加载到其他位置。
问: 存储器不仅仅是一长列字节?
答: 物理存储器的结构十分复杂,计算机通常会将存储器地址分组映射
到存储芯片的不同的存储体(memory bank)。
问: 为什么我一定要用%p格式串来打印指针?
答: 不一定要用%p,在大多数的现代计算机上可以用%li,但编译器可能会给出警告。
问: 为什么%p以十六进制显示存储器地址?
答: 工程师通常以十六进制表示存储器地址。
问: 如果我们把读取存储器单元的内容称为“解引用”,那么指针是不是应该叫“引用”?
答: 人们有时会把指针叫做“引用”,因为它引用了存储器中的某个地址单元。
但C++程序员通常用“引用”表示C++中一个稍有不同的概念。
怎么把字符串传给函数?
void fortune_cookie(char msg[]) // 可以这样传
{
- printf("Message reads: %s\n", msg);
}
char quote[] = "Cookies make you fat";
fortune_cookie(quote);
sizeof 的运算符
它能告知某样东西在存储器中占多少字节,既可以对数据类型使用,也可以对某条数据使用。如:
sizeof(int); // 在大多数计算机中,将返回4这个值
sizeof("Turtles!");// 将返回9, 其中包含8个字符外加\0结束符。
打印字符串长度代码示例:
#include <stdio.h>
#include <stdlib.h>
void fortune_cookie(char msg[]) {
printf("Message reads: %s\n", msg);
printf("msg occupies %i bytes\n", sizeof(msg));
}
int main() {
char quote[] = "Cookies make you fat";
fortune_cookie(quote);
return 0;
}
运行结果:
为什么程序并没有显示字符串总长,而是返回了4或8个字节?
当你将数组作为参数传递给函数时,其实传递的是指针,于是你用sizeof(参数名)
想计算数组长度,其实计算的是一个指针变量的长度。
而指针在32位操作系统中占4字节,在64位操作系统中占8字节。
printf("The quote 字符串保存在 : %p\n", quote);//quote虽然是数组,但可以当指针变量来用
问: sizeof是一个函数吗?
答: 不是,它是一个运算符。
问: 有什么区别?
答: 编译器会把运算符编译为一串指令;而当程序调用函数时,会跳到一段独立的代码中执行。
所以,程序是在编译期间计算sizeof的,编译器可以在编译时确定存储空间的大小。
问: 为什么在不同的计算机上指针变量的大小不同?
答: 在32位操作系统中,存储器地址以32位数字的形式保存,所以它叫32位操作系统。
32位==4字节,所以64位操作系统要用8个字节来保存地址。
问: 我可以把指针转化为一般的数字吗?
答: 在大多数操作系统中,可以这样做。指针变量只不过是一个保存数字的变量罢了,
C编译器通常会把long数据类型设为和存储器地址一样长。如果想要把指针p保存在
long变量a中,可以输入a=(long)p,过几章我们会学习这种方法。
代码示例:
#include <stdio.h>
int main() {
int contestants[] = { 1, 2, 3};
int *choice = contestants;
contestants[0] = 2;
contestants[1] = contestants[2];
contestants[2] = *choice;
printf(" 我选 %i 号男嘉宾 ", contestants[2]);
return 0;
}
因为:contestants[2] == *choice == contestants[0] == 2。
数组变量与指针并不完全相同
char s[] = "How big is it?";
char *t = s;
1. 对数组直接 sizeof(数组)返回的是 数组的大小。
sizeof(s) 返回 15,而sizeof(t) 返回的是 4或8。
2. 对数组用&,获取的还是数组本身。
即:&s = s;但 &t != t ;
3.数组变量不能指向其他地方。
当创建指针变量时,计算机会为它分配4或8字节的存储空间。但如果创建的是数组呢?计算机会为数
组分配存储空间,但不会为数组变量分配任何空间,编译器仅在出现它的地方把它替换成数组的起始地
址。但是由于计算机没有为数组变量分配空间,也就不能把它指向其他地方。s = t; // 会报编译错误
指针退化
假如把数组赋给指针变量,指针变量只会包含数组的地址信息,而对数组的长度一无所知,相当于指针
丢失了一些信息。我们把
为指针,
这种信息的丢失称为退化。
只要把数组传递给函数,数组免不了退化
但需要记清楚代码中有哪些地方
发生过数组退化,因为它们会引发一些不
易察觉的错误。
为什么数组角标是从0开始,而不是1?
数组变量可以用作指针,该指针指向数组的第一个元素,也
就是说除了方括号表示法,还可以用 * 运算符
读取数组的第一个
元素,像这样:
int drinks[] = { 4, 2, 3};
printf(" 第一单 : %i 杯 \n", drinks[0]);
printf(" 第一单 : %i 杯 \n", *drinks);
// 也就是说下面这两行代码是等价的。
drinks[0] == *drinks
地址只是一个数字,所以可以进行指针算术运算,比如为了找到存储器中的下一个地址,可以 增加 指针的值。
既可以用方括号加上索引值2来读取元素,也可以对第一个元素的地址加2:
printf(" 第三单 : %i 杯 \n", drinks[2]);
printf(" 第三单 : %i 杯 \n", *(drinks + 2));
// 总之,下面的表达式是等价的
drinks[i] ==*(drinks + i)
这就解释了为什么数组要从索引0开始,所谓索引,其实就是为了找到元素的地址单元,指针需要加上的那个数字。
一言以蔽之,索引的本质是指针算术运算。
磨笔上阵
#include <stdio.h>
void skip(char *msg) {
// 为了从第7个字符开始打印这条消息,你需要在这里用什么表达式?
puts(msg+6); // 首字符地址基础上+6,就是把指针往后移动了6位。
}
int main() {
char *msg_from_amy = "Don't call me";
// 函数需要从字符c开始打印这条消息。
skip(msg_from_amy);
return 0;
}
运行结果:
为什么指针有类型,不能用一个统一的指针变量来表示呢?
因为指针算术运算会暗渡陈仓。
如果对 char 指针加 1 ,指针会指向存储器中下一个地址,那是因为 char 就占1字节。
如果是 int 指针呢? int 通常占4字节,如果对 int 指针加1,编译后的代码就会对存储器地址加4。
drinks[i] ==*(drinks + i) 公式变换:
int doses[] = { 1, 3, 2, 1000};
printf(" 服用 %i 毫克的药 ", 3[doses]);
// 这里打印结果是1000,原因如下
doses[3] == *(doses + 3) == *(3 + doses) == 3[doses]
问: C语言什么时候对指针算术运算进行调整?
答: 在编译器生成可执行文件时,编译器会根据变量的类型,用变量类型的大小乘以指针的增量或减量。
如:假如编译器看到你对一个指向int数组的指针加2,就会用2乘以4(int类型的长度),然后对地址加8。
用指针输入数据
scanf()是怎么工作的呢?它接收一个char指针,而在下面这个例子中,传给了它一个数组变量。这时你一定在想为什么scanf()
要接收指针,这是因为scanf()函数打算更新数组的内容,一个想要更新变量的函数可不需要变量本身的值,它要的是变量的地址。
char name[40];
printf("Enter your name: ");
scanf("%39s", name);//你将把人名保存在这个数组中。
用scanf()输入数字
int age;
printf("Enter your age: ");
scanf("%i", &age);
// %i 表示用户会输入一个int值
// 用&运算符得到int的地址
scanf()允许传递格式字符串,就像你对printf()函数做的那样,甚至可以用scanf()一次输入多条信息:
char first_name[20];
char last_name[20];
printf("Enter first and last name: ");
scanf("%19s %19s", first_name, last_name);
printf("First: %s Last:%s\n", first_name, last_name);
scanf()若不限制读取长度,会导致缓冲区溢出!
如果忘了限制scanf()读取字符串的长度,用户就可以输入远远超出程序空间的数据,多余的数据会写到计算机还没有分配好的存储器中。
如果运气好,数据不但能保存,而且不会有任何问题。但缓冲区溢出很有可能会导致程序出错,这种情况通常被称为段错误或abort trap,
不管出现什么错误消息,程序都会崩溃。
除了scanf()还可以用fgets()
和scanf()函数一样,fgets()接收char指针,不同的是,你必须给出最大长度!
char food[5]; // 保存输入字符的缓冲区
printf(" Enter favorite food : ");
fgets(food, sizeof(food), stdin);
// food-->指向缓冲区的指针;sizeof(food)-->表示接收字符串(包括“\0”)的最大长度。
- // stdin-->标准输入,这里表示数据将来自键盘
如果你要向fgets()函数传递数组变量,就用sizeof;如果只是传指针,就应该输入你想要的长度。
char* food;
printf(" Enter favorite food : ");
fgets(food, 5, stdin); // 这里的5就不能写成sizeof(food)了!
字符串字面值不能更新!
//指向字符串字面值的指针变量不能用来修改字符串的内容
char *cards = "JQK";//不能用cards这个变量修改这个字符串。
//但如果你用字符串字面值创建一个数组,就可以修改了
char cards[] = "JQK";
为什么用指针不能修改?
因为当计算机把程序载入存储器时,会把所有常数值(如字符串常量“JQK”)放到常量存储区,这部分存储器是只读的。
而char *cards=“JQK”;语句实际上是存储器在栈中开辟了一个空间来保存cards这个指针变量,这个变量指向了位于常量区
的“JQK”字面值,而这个是只读的,你通过指针去修改它肯定是不行的。
为什么用数组就可以修改?
因为如果声明了一个名为 cards 的字符数组,然后把它设置成字符串字面值, 那么存储器就会在栈上开辟块连续空间给
cards 数组,并在这里保存了一份字符串字面值的副本。 cards 不再只是一个指向字符串字面值的指针,而是一个崭新的数组,
里面保存了字符串字面值的最新副本。对于这个副本是完全可以修改的。
char cards[]在不同地方代表不同的含义:
1.如果是普通的变量声明,cards就是一个数组,而且必须立即赋值:
int my_function()
{
char cards[] = "JQK"; // 因为没有给出数组大小,因此必须立即赋值
...
}
2.但如果cards以函数参数的形式声明,那么cards就是一个指针:
// 下面这两个函数是等价的
void stack_deck(char cards[])
{
...
}
void stack_deck(char *cards)
{
...
}
代码示例:
#include <stdio.h>
int main()
{
// char* cards = "JQK"; 这样写就会报错
char cards[] = "JQK";
char a_card = cards[2];
cards[2] = cards[1];
cards[1] = cards[0];
cards[0] = cards[2];
cards[2] = cards[1];
cards[1] = a_card;
puts(cards);
return 0;
}
注:如果你确实想把指针设成字符串字面值,为了避免运行错误,必须确保使用了const关键字,
这样的话,如果编译器发现有代码试图修改字符串,就会提示编译错误。
const char *s = "some string";
问: 为什么数组变量不保存在存储器中?既然它存在,就应该在某个地方,不是吗?
答: 程序在编译期间,会把所有对数组变量的引用全部替换成数组的地址(第一个元素的地址)。也就是说,
在最后的可执行文件中,数组变量并不存在。既然数组变量从来不需要指向其他地方,有和没有其实都一样。
问: “声明”和“定义”的区别是什么?
答: 声明是一段代码,它声称某样东西(变量或函数)的存在;而定义则说明这个东西到底是什么。
如果在声明了变量的同时将其设为某个值(例如int x = 4;),那么这段代码既是声明又是定义。