1. 三种类型的第一次正面交锋
先看一段最直白的代码,看完你就知道这三种类型在内存里长什么样。
char c = 'A'; // 一个字符,占用1个字节 char* p = &c; // 指向字符的指针,存的是c的地址 char** pp = &p; // 指向字符指针的指针,存的是p的地址很多新手看到第三行就懵了:指针还能指向指针?没错,而且在实际工程中,char**出现的频率比想象中高得多。比如你写的main函数:
int main(int argc, char** argv)这个argv就是char**类型。你每次写的命令行参数,本质上就是通过它传进来的。
咱们先把话放这儿:char存的是真实的数据,char*存的是数据在哪,char**存的是"数据在哪"这个信息本身在哪。这句话你品一品,后面两个小时你就不会白费。
1.1 从内存布局讲清楚三者的本质区别
要理解这三者的关系,你必须站在内存的角度去看。
假设内存地址0x100处存了一个字符'A'。那么:
char c = 'A',意思是地址0x100这个字节里直接存的就是0x41('A'的ASCII码)。char* p = &c,意思是p这个变量本身在另一个地址,比如0x200,0x200这个地址里存的值是0x100。char** pp = &p,意思是pp这个变量在0x300,0x300这个地址里存的值是0x200。
用表格表示就是:
| 变量 | 变量自身的地址 | 存储的内容 | 解释 |
|---|---|---|---|
| c | 0x100 | 0x41 | 直接就是字符数据 |
| p | 0x200 | 0x100 | 指向c的地址 |
| pp | 0x300 | 0x200 | 指向p的地址 |
这个关系捋顺了,剩下的事情都好办。
1.2 为什么说char*不止是“字符指针”
严格来说,char*是指向char类型变量的指针。但在C语言的实际使用中,char*通常被用来表示一个字符串。为什么?因为C语言没有专门的字符串类型,字符串就是连续存放的一串字符,以\0结尾,而这个连续空间的起点用char*来记录。
char* str = "hello";这个str指向的是'h'的地址,后面依次是'e'、'l'、'l'、'o'、'\0'。你拿str做加减运算,就能逐个访问字符串里的字符。
但你千万不要认为char*只能存字符串。它也可以指向单个字符,就像刚才代码里的char* p = &c。你甚至可以拿char*去遍历一个char数组里的任意一个元素。char*的本质是"指向char的指针","字符串"只是它的常见用法,不是它的全部。
2. 为什么需要char**:一级指针解决不了的场景
我在带新人的时候,经常有人问:既然char*已经能干这么多事了,为什么还要char**?答案其实就一句话:当你需要在函数里修改一个指针的指向时,你就需要指针的指针。
2.1 函数参数传递的经典陷阱
新手最容易踩的坑就是这个。看代码:
void modify(char* p) { p = (char*)malloc(100); } int main() { char* str = NULL; modify(str); // str还是NULL,你分配的100个字节丢了 }为什么会这样?因为函数参数是值传递。modify函数里的p是str的一个副本,你在副本上改指向,原来的str根本不受影响。这就好比你让同事帮你拿快递,结果同事把快递放自己工位上了,你以为快递到了你桌上。
这时候char**就派上用场了:
void modify(char** p) { *p = (char*)malloc(100); } int main() { char* str = NULL; modify(&str); // 这次str指向了那100个字节 }注意看区别:这次传的是str的地址,也就是char**。函数里*p = malloc(100)的意思是:在p所指向的内存里,写入malloc返回的地址。而p指向的正好是str,这就等于把str的值改了。
2.2 argv参数到底是怎么来的
再回到main函数的char** argv。你执行一行命令:
./program -a -b file.txt操作系统会把这一行拆成4个字符串:./program、-a、-b、file.txt,然后把这4个字符串的首地址放进一个数组,再把这个数组的首地址传给argv。
所以argv其实是指向一个指针数组的指针。每个元素都是char*,分别指向一个字符串。argv[0]是程序名,argv[1]是第一个参数,以此类推。
这就是char**最常见的工程场景:表示一个字符串数组。
3. 三种类型的实际使用场景拆解
3.1 char:单字符,别小看它
char在绝大多数平台上占1个字节。你可能觉得char只能存字母,但实际情况是,char在底层就是个整数。你可以拿它做算术运算:
char c = 'A'; char d = c + 1; // d是'B',因为'A'的ASCII是65有时候你会看到有人用char存小整数,比如传感器返回的0到127的值。这在嵌入式领域非常常见,因为省内存。但要注意,char可能是signed也可能是unsigned,取决于编译器实现。如果要做位运算,我建议直接指定signed char或unsigned char,不然可移植性是个坑。
3.2 char*:字符串操作的主力军
几乎所有的C语言字符串操作,输入输出都是char*。比如:
char buffer[128]; strcpy(buffer, "hello"); printf("%s", buffer);这里的buffer在表达式里退化成指向数组首元素的char*指针。
日常工程中,char*常用来做:
- 拼接字符串(strcat)
- 格式化输出(sprintf)
- 解析命令行参数
- 读写文件的行缓冲
实际操作的时候,我强烈建议你搞清楚自己手里拿的char*到底指向哪块内存。是栈上的数组?堆上malloc出来的?还是字符串字面量?这三种情况对你能不能修改、要不要free,影响非常大。
3.3 char**:字符串数组与动态分配
char**最常见的两种用途:
第一种,表示字符串数组。用一个指针的指针,指向一个char*数组。数组里的每一个char*,各自指向一个字符串。
char* fruits[3] = {"apple", "banana", "cherry"}; char** pp = fruits; printf("%s\n", pp[0]); // apple printf("%s\n", pp[1]); // banana第二种,在函数里分配并返回字符串数组。比如你要写一个函数,把一个文本文件的所有行读出来,行的数量提前不知道,那么就适合用char**:
int read_lines(char*** lines_out, int* count_out);这里甚至出现了char***,但原理是一样的:你想让函数直接修改调用者的char**变量,就要传char***。不过到了这个层级,代码的可读性就开始下降了,工程上建议用结构体包一层。
3.4 动态分配二维字符串结构的注意事项
如果要用char**动态创建一个字符串数组,通常分两步:先分配指针数组,再分配每个字符串。
char** arr = malloc(3 * sizeof(char*)); for (int i = 0; i < 3; i++) { arr[i] = malloc(32); sprintf(arr[i], "row %d", i); } // 释放顺序:先释放每个字符串,再释放指针数组 for (int i = 0; i < 3; i++) { free(arr[i]); } free(arr);释放的顺序很重要。你想想,先释放指针数组的话,字符串本身还在堆上没人管,就泄漏了。先释放每个字符串释放,再释放外层数组,内存就干净了。
4. 同名关键字在不同语言里的讲究
4.1 Java里的char不是C语言里的char
搜索引擎热词里有"java char是什么数据类型",这个必须单独拉出来说。Java的char是16位无符号整数,范围是0到65535,它用来表示Unicode码元。一个关键区别是:Java中char占2个字节,不是1个字节。
正因为占2个字节,Java的char可以直接存放中文:
char c = '中';这在C语言里做不到。C语言的char只有1个字节,存不了中文字符。想存中文只能用char数组配合UTF-8编码,或者用wchar_t之类的宽字符类型。
还有一个容易混淆的地方:Java没有char*和char**这种东西。Java的字符串是String对象,字符串数组是String[],你根本不用关心指针怎么指来指去。这也是很多人从Java转C之后,碰到的第一个认知冲击:Java里字符串是引用类型,传参传的是引用(类似共享地址),但引用本身还是值传递。
4.2 GIS领域里char、float、int、time的数据类型区别
搜索引擎热词里还有"gis里面char float int time",这个跟本文主题有点关联,我稍微展开说。
在GIS(地理信息系统)的矢量数据中,一个要素的属性表里每一列都有自己的数据类型。常用的有这么几种:
| 类型 | 典型用途 | 存储特征 |
|---|---|---|
| char / text | 地块名称、行政区划代码、道路名称 | 字符串,需要指定长度,如char(20) |
| float / double | 经纬度坐标、面积、长度 | 浮点数,有精度问题 |
| int | 人口数量、楼层数、年份 | 整数,存储紧凑 |
| time / date | 数据采集时间、更新日期 | 时间戳,数据库里有专门的类型 |
在GIS二次开发中,比如写一个Shapefile读取器,你从字段定义里解析出来的往往是"字段类型是字符型还是数值型"这样的信息,然后在内存里用char**来统一表达所有字段值的字符串形式,遇到numeric字段再转成float或int。这算是char**在GIS应用场景里的一个比较实际的用法了。
我见过不少搞GIS的新手,写属性查询的时候把numeric字段直接当字符串做了拼接,导致排序错误。比如"10"比"9"小,按字符串排序就会出问题。这时候正确的做法是先转成float或者int再比大小。说到底,理解char只是认识数据类型的起点,不同领域的数据类型差异,坑藏在细节里。
5. 一步一步教你搭建自己的测试环境
光看不练是学不会指针的。我建议你亲手写个程序验证一下。用VS Code或者任意文本编辑器都行,装一个gcc编译器。
第一步,创建test.c文件,敲入一段验证代码:
#include <stdio.h> int main() { char c = 'A'; char* p = &c; char** pp = &p; printf("c的值: %c\n", c); printf("c的地址: %p\n", &c); printf("p存的值: %p\n", (void*)p); printf("p的地址: %p\n", (void*)&p); printf("pp存的值: %p\n", (void*)pp); printf("pp解引用后: %p\n", (void*)*pp); printf("pp双重解引用: %c\n", **pp); return 0; }第二步,编译运行:
gcc test.c -o test ./test你会看到c的地址、p存的值完全一样,pp存的值和p的地址完全一样。这就把前面那张内存布局表实打实验证了一遍。
第三步,你自己动手改一改。弄个字符串试试:
char* str = "hello"; char** pstr = &str; printf("%s\n", *pstr); // hello再试试修改指针指向的经典例子,把函数传参的问题也复现一遍。这样一套下来,你对三种类型的记忆就不是死记硬背,而是真理解了。
6. 新手最容易犯的四个错误
6.1 混淆声明与解引用
char* p代表p是一个指针,p表示地址,*p才表示一个char。很多刚入门的朋友写代码时搞不清楚:
char* p = "hello"; char c = *p; // 正确,c = 'h' char d = p; // 错误,p是地址不是字符编译器大概率会报警告或者错误。但有些时候是隐式的错误,比如把指针直接赋给整数,不报错但结果根本没意义。
6.2 用char**存单个字符
有人会写:
char c = 'A'; char** pp = &c; // 编译报错这里编译一定报错,因为&c的类型是char*,不是char**。你得先有一个char*变量,再把它的地址取出来赋给char**。这个关系必须一级一级来,不能跳级。
6.3 以为char*指向的内容一定能改
字符串字面量通常存储在只读区。你写:
char* p = "hello"; p[0] = 'H'; // 运行时会崩溃(未定义行为)如果确实需要修改,你得用一个可写的副本:
char buf[] = "hello"; buf[0] = 'H'; // 没问题这个坑在很多有Java背景的人身上特别常见,因为在Java里字符串是对象,修改字符串实际上是创建新对象,不会让你直接改底层的字符数组。到了C语言这里,你得自己管好每一块内存的可写性。
6.4 忘记释放动态分配的内存
只要用了malloc,最好提前想好谁负责释放。在复杂的项目中,这往往不是技术问题,而是规范问题。项目越大,内存泄漏越难排查。我的经验是:写malloc的时候就把free写好,别等程序跑挂了再回来补。
7. 一句话总结:什么时候用哪种
给你的选择标准其实很简单:
- 只要处理一个字符,就用char。
- 要处理字符串,或者需要间接访问一个字符,用char*。
- 需要在一个函数里修改char*的指向,或者要表示字符串数组,用char**。
- 如果再往上层走,函数里需要修改char**本身,那就会用到char***,但这时候建议用结构体替代,可读性好得多。
最后再分享一个我在面试新人时必问的题:给你一段代码,输出里有**、*的混用,你数不清关系怎么办。我通常建议他们先画格子。把内存想象成一列带编号的格子,每个变量是一格,格子里存的内容就写进去。只要画出来,指针关系一目了然。这个习惯养成了,后面遇到再复杂的多级指针都不怕。
到目前为止,把ch、char*和char**的关系理清楚,C语言入门最绕的一道坎就算过了。剩下的无非是多写多调试,脑子里时刻有一幅内存地图。