首页 / 资讯中心 / 文章详情

C语言结构体成员访问:点号与箭头的本质区别及实战用法

C语言结构体成员访问:点号与箭头的本质区别及实战用法 ★ FEATURED ARTICLE
说实话C语言里结构体变量的两种引用方式“.”和“-”是我见过最让新手头疼的语法点之一。不少同学一开始觉得反正都是访问成员为什么C语言非要搞出两套写法等真上手写代码敲错一个符号编译器满屏报错看半天也不知道错在哪。这篇就把这两种引用方式从头到尾拆开讲清楚结合内存布局、编译原理、实际场景和调试经验讲明白到底什么时候用点什么时候用箭头。先回答文章标题最核心的问题“.”和“-”本质上是一回事吗不是。它们只是“看起来都在访问成员”但操作对象的类型完全不同。一个作用于结构体变量本身一个作用于结构体变量的指针。这个差别是理解两种写法全部行为的关键也是熟练写出高质量C代码的基础。1. 先搞清楚点号和箭头到底差在哪1.1 从结构体变量和结构体指针的定义说起结构体本身是一个复合数据类型相当于你自定义了一个“图纸”。真正在内存里占地方的是结构体变量也就是按照这张图纸生产出来的“实物”。typedef struct { char name[32]; int age; float score; } Student; Student stu1; // 结构体变量内存已分配栈上占据一块连续空间 Student *pStu; // 结构体指针目前只分配了一个存放地址的变量 pStu stu1; // 让指针指向 stu1这个例子里Student是类型stu1是变量pStu是指针变量。stu1在栈上分配了实实在在的内存空间name、age、score这些成员都有自己的位置而pStu变量本身只有4个字节32位平台或者8个字节64位平台里面存的是stu1的起始地址不是成员数据本身。很多人容易忽略一个关键点指针变量本身也是一个变量它也有自己的内存地址只不过它内存里存的不是普通数据而是另一个变量的地址。这就像你手里拿着一张写有门牌号的纸条门牌号可以带你去找到房子但纸条本身不是房子。1.2 两种引用的本质直接寻址与间接寻址C语言标准里.运算符的精确名称叫“成员选择”运算符member access operator它要求左操作数是一个结构体或联合体类型的“对象”也就是变量本身-运算符叫“间接成员选择”运算符indirect member access operator它要求左操作数是一个指向结构体或联合体的“指针”。从最底层的寻址逻辑来看stu1.age编译器在编译期已经知道了age相对于结构体首地址的偏移量直接拿stu1的地址加上偏移量就得到age的内存地址。pStu-age编译器会做两步操作。第一步从pStu变量里取出它存储的目标地址第二步在这个目标地址的基础上加上age的偏移量再访问那块内存。也就是说.是直接从已知的对象地址出发找成员-是先通过指针找到目标对象的地址再从这个地址出发找成员多了一次“间接跳转”的过程。这也是为什么-在语义上被称为“间接”访问。可以用一个非常生活化的类比来理解你想找办公室里的打印机。如果办公室就在你面前你推门进去拿就行这是.如果只知道办公室在3楼某个房间你得先走到3楼找到房间号再进门拿打印机先找地址再访问这是-。1.3 内存视角变量和指针在内存里长什么样为了直观我把上面的代码对应的内存布局画出来这张图后面的很多细节都要用到假设 stu1 的起始地址是 0x1000pStu 自己的地址是 0x2000 地址 0x1000 (stu1的存储区) ---------------------- | name[32] 偏移 0 | ---------------------- | age 偏移 32 | ---------------------- | score 偏移 36 | ---------------------- 地址 0x2000 (pStu自己的存储区) ---------------------- | 0x1000 | - pStu的内容是 stu1 的地址 ----------------------编译器在处理stu1.age时相当于做了一次加法0x1000 32得到0x1020然后从0x1020这个地址读写4个字节的整数。在处理pStu-age时第一步先读取地址0x2000处的内容得到0x1000第二步再计算0x1000 32得到0x1020然后访问这块内存。// 这两种写法的结果是完全一致的 printf(age %d\n, stu1.age); // 直接访问 printf(age %d\n, pStu-age); // 间接访问 printf(age %d\n, (*pStu).age); // 解引用后再直接访问和 - 等价最后那行(*pStu).age是理解-的关键。它先对指针做解引用得到指针指向的那个结构体变量本身然后再用.去访问成员。C语言标准里明确说明pStu-age和(*pStu).age是等价的。之所以日常都用-纯粹是因为写起来更简洁、可读性更好毕竟带上一对括号代码会显得很啰嗦。2. 为什么C语言不允许混用运算符原理与常见混淆点2.1 编译器的类型检查逻辑与典型报错既然-和(*p).等价那能不能顺手把.用在指针上或者把-用在普通变量上答案是不能编译器会直接报错。这背后体现的是C语言的一个基本设计哲学操作数类型必须和运算符的要求严格匹配。int main(void) { Student stu1; Student *pStu stu1; // pStu 是指针不能用 . // pStu.age 18; // 错误编译报错 // stu1 是普通变量不能用 - // stu1-age 18; // 错误编译报错 return 0; }第一种错误的典型报错信息是error: request for member age in something not a structure or union第二种错误的典型报错信息是error: invalid type argument of - (have Student)很多初学者看到这段报错就懵了明明pStu指向的就是stu1为什么编译器不自动帮我转换一下关键在于如果C编译器自动把pStu.age理解成(*pStu).age那pStu到底是指向对象的指针还是个包含成员的对象语言就失去了明确的边界。C语言的哲学是“可以隐式做的事情尽量少要做也要能做清楚”这种涉及指针和对象语义转换的操作歧义太大编译器宁可报错也不猜。从另一个角度说这种严格性是好事。它逼着程序员在写代码的每一处都明确自己操作的对象到底是什么你是在直接操作一个结构体实体还是在通过地址间接操作。这个“明确性”在大型工程里价值巨大它让代码的可读性和可维护性都显著提升。2.2 优先级和结合方向那些带坑的写法第一次写(*pStu).age的人十个里有八个会先写成*pStu.age然后收获一条编译错误。这就要说到运算符优先级了。在C语言的运算符优先级表里[]、()、.、-这老哥几个是同一档优先级最高结合方向从左往右而*解引用运算符属于单目运算符优先级比它们低一级。所以*pStu.age会被解析成*(pStu.age)编译器先试图访问pStu的age成员但pStu是个指针于是报错。(*pStu).age先对pStu解引用得到stu1这个结构体变量再用.访问成员完全正确。pStu-age用箭头一写连括号都不用这就是箭头的第二个好处——省括号、防手滑。类似的坑还有自增运算符。代码里出现pStu-age编译器会按优先级规则解析成(pStu-age)意思是先取age成员然后对age这个值做自增而(pStu)-age则是先自增指针让指针指向下一个结构体对象再去访问它的age成员。这两种写法结果是天差地别的一个是改成员的值一个是改指针本身指向的对象。再看一个关于取地址的写法pStu-age。第一次看到这个表达式很多人会犹豫它到底是取pStu的地址还是取age成员的地址根据优先级规则-优先于所以它实际是(pStu-age)也就是取age这个成员的内存地址类型是int *。这个写法在底层编程、寄存器操作里非常常见一定要记住。2.3 易混淆写法对照*p.age、(*p).age、p-age把容易混淆的写法集中对比一下这是我觉得最实用的一张表建议保存下来写法实际含义是否合法pStu-age等价于(*pStu).age访问指针指向对象的成员合法(*pStu).age解引用后访问成员合法就是太啰嗦*pStu.age被解析为*(pStu.age)pStu没有age成员编译错误pStu.age对指针用点操作符编译错误stu1-age对普通变量用箭头操作符编译错误pStu-age解析为(pStu-age)取的是age成员的地址合法pStu.age解析为(pStu.age)和上面一样但pStu是指针编译错误stu1.age解析为(stu1.age)取stu1的age成员地址合法经常在代码评审里看到有人指着pStu-age问“这个是不是取pStu的地址”我都会让他去查一下优先级表再回来讨论。这种细节属于“基本功中的基本功”写错一个点编译器要么报错要么给你一个完全不是本意的地址值问题大了。还有个经典的嵌入式笔试技巧怎么在不定义变量的情况下算出结构体某个成员的偏移量。答案是用0地址// 16位平台上下面表达式的值就是 age 的偏移量即 32 int offset (int)((Student *)0)-age;原理是强制把0地址转换成Student *类型的指针然后用-访问其age成员再取地址得到的结果就是age相对结构体首地址的偏移量。这个写法在实现通用容器、序列化、协议解析时非常有用。不过它依赖指针和整数之间的强制转换写的时候要注意平台安全性一般作为技巧看待即可。3. 真实代码场景里怎么选从函数传参到链表操作3.1 函数传参场景传变量还是传指针实际写代码函数传参是“.”和“-”出现分歧最大的地方。考虑一个场景你写了一个函数要打印学生的信息。// 值传递整个结构体被拷贝一份 void printStudent1(Student s) { printf(%s %d %.1f\n, s.name, s.age, s.score); } // 指针传递只拷贝一个地址 void printStudent2(const Student *ps) { printf(%s %d %.1f\n, ps-name, ps-age, ps-score); }第一种方式函数形参s是实参的完整拷贝。如果结构体很小还好一旦结构体里躺着char buf[1024]、几个嵌套结构体一次调用就要拷贝几千字节性能和栈开销都很难看。第二种方式形参ps是一个指针只占4或8字节函数内部用-访问成员开销小得多。那什么时候用变量、什么时候用指针我的经验是只读访问并且结构体不大可以用值传递安全省心。只读访问结构体很大用const修饰的指针。需要修改调用者的结构体内容必须用指针函数内通过-直接改到原变量。看一个修改数据的例子void setStudentAge(Student *ps, int newAge) { ps-age newAge; // 等价于 (*ps).age newAge但箭头明显更清晰 } int main(void) { Student stu1; setStudentAge(stu1, 20); printf(%d\n, stu1.age); // 输出20 return 0; }如果这里用值传递函数改的只是拷贝出来的那份数据调用者的结构体纹丝不动这种bug排查起来非常隐蔽。这里还有个容易踩的小坑在函数内部拿到结构体指针形参后下意识想写ps.name编译报错了才反应过来。我的习惯是在函数开头先看一眼形参类型凡是带*的里面访问成员一律用-写完再回头检查一遍。别嫌啰嗦这个习惯在写长函数时能省不少编译报错的时间。3.2 动态内存分配、链表和寄存器映射里的必然选择当你使用malloc从堆上分配结构体时返回的是地址所以你手里拿到的必然是一个指针。这种情况下箭头是唯一顺手的选择Student *p (Student *)malloc(sizeof(Student)); if (p NULL) { // 处理分配失败 return -1; } p-age 21; strcpy(p-name, Tom); p-score 92.5; // 使用完别忘了释放 free(p);再来看链表几乎每个C语言学习者都会写。链表节点的定义里天生就带着自引用指针typedef struct Node { int data; struct Node *next; } Node; // 遍历链表 Node *head createList(); for (Node *cur head; cur ! NULL; cur cur-next) { printf(%d\n, cur-data); }cur是指针访问data和next都只能用-。原因不用多说cur本身不是Node变量cur的内存里只存了某个节点的地址。嵌入式方向的同学对箭头应该更亲切。很多单片机开发包里外设寄存器就是一组映射到固定地址的结构体typedef struct { volatile uint32_t MODER; volatile uint32_t OTYPER; volatile uint32_t OSPEEDR; volatile uint32_t ODR; // ... } GPIO_TypeDef; #define GPIOA ((GPIO_TypeDef *)0x48000000U) // 操作寄存器 GPIOA-ODR 0x0001;这里GPIOA本质上是一个指向固定地址的指针常量所以访问寄存器成员就必须用箭头。用“.”的话编译器会直接告诉你GPIOA不是结构体或联合体。这也解释了为什么很多嵌入式代码里箭头高频出现——外设访问天然是基于地址的。3.3 结构体数组、嵌套结构体与成员指针结构体数组是很容易把新手绕晕的场景。先定义一个数组Student class[3]; class[0].age 18; class[1].age 19; class[2].age 20;数组元素是结构体变量所以访问成员用点号没问题。但如果你写class-age在语法上也是合法的。为什么因为class作为数组名在绝大多数表达式中都会隐式转换成指向首元素的指针也就是class[0]所以class-age等价于(class[0])-age即class[0].age。这个隐式转换经常被忽略导致不少人看到class-age以为class是指针其实数组名和指针在不少场景下确实行为一致但本质不同数组名不是可修改的左值而指针可以重新赋值。再来看嵌套结构体这也是指针和点号混用的混乱区typedef struct { int year; int month; int day; } Date; typedef struct { char name[32]; Date birthday; } Person; Person p; Person *pp p; p.birthday.year 2000; // 成员是普通结构体变量一直用 . pp-birthday.year 2001; // pp是指针用 - 取到birthdaybirthday是变量继续用 . // pp-birthday-year; // 错误birthday不是指针不能用-关键点在于-只负责“从指针拿到成员”这一层动作拿到的成员如果本身是结构体变量后续继续访问要用.如果成员本身是指针后续访问才用-。一层一层拆开看思路就清晰了。还有成员是指针的情况typedef struct { char *name; // name是一个指针指向堆上的字符串 } Teacher; Teacher t; t.name (char *)malloc(32); // t是变量用.访问name成员然后对指向的内存赋值 strcpy(t.name, Alice); Teacher *tp t; tp-name[0] B; // tp-name 等价于 t.name拿到char*指针然后[0]访问字符串首字符这里tp-name[0]实际上要做两步理解第一步tp-name拿到name这个指针的值第二步name[0]通过下标访问指针指向的内存。很多人把这种写法误以为tp-name本身就是字符串其实name的类型是char *不是数组name[0]访问的是指针指向的第一个字符。还有一种很常考的坑结构体成员里有数组时用点号和箭头访问的语义也不一样。看例子typedef struct { char id[8]; } Card; Card c; Card *pc c; strcpy(c.id, 123); // c是变量用.访问数组 strcpy(pc-id, 456); // pc是指针用-访问数组效果等同于直接操作c.idpc-id的类型是char [8]在表达式里会退化成char *所以可以传给strcpy。这一点理解清楚后就不会在代码里写出pc-id abc这种错误的赋值方式——数组不能直接赋值只能用strcpy或逐元素赋值。4. 常见问题与调试技巧实录4.1 常见编译错误速查表把日常最常遇到的错误集中列一个表方便大家对照排查。下面这个表的每一行都是我在实际项目和帮别人调代码时见过的真实报错错误写法典型报错原因正确写法pStu.agerequest for member age in something not a structure or union对指针用点操作符pStu-agestu1-ageinvalid type argument of - (have Student)对普通变量用箭头操作符stu1.age*pStu.agerequest for member age in something not a structure or union点号优先级高于星号(*pStu).age或pStu-agepStu.agerequest for member age in something not a structure or union点号优先级高于取地址(pStu-age)ps.name形参是指针request for member name in something not a structure or union形参类型是指针ps-name看到这些报错先不要慌第一反应去看运算符左边那个表达式的类型是结构体变量就用.是结构体指针就用-。判断方法很简单如果左边是xxx.yyy这种样式xxx必须是一个结构体/联合体变量。如果左边是xxx-yyy这种样式xxx必须是一个指向结构体/联合体的指针。写代码时也可以走一个固定的心法看到变量名脑子里马上问一句“它是对象还是地址”。对象用点地址用箭头。这个反射练出来之后这一类报错基本就跟你说拜拜了。4.2 调试器里查看结构体内容的实用技巧报错解决了接下来更常见的需求是程序能编译能跑但结构体的值不对想看看当前结构体里到底存了什么。不同调试环境有不同操作这里挑最常用的两个场景讲。第一个场景Keil MDK调试模式。这也是嵌入式开发最常用的IDE之一。进入Debug模式后打开菜单 View - Watch Windows - Watch 1把Watch窗口调出来。如果想看结构体变量stu1直接在Name列输入stu1回车。Watch窗口会展开所有成员显示name、age、score的当前值。如果想看结构体指针pStu在Name列输入pStu回车。你不会直接看到成员值而是看到一个地址。这个地址就是指针指向的目标地址。点击该项前面的加号Keil会尝试展开它指向的结构体内容。还有一种更硬核的办法用Memory窗口。打开 View - Memory Windows - Memory 1在Address框输入stu1或者pStu的当前值内存窗口就会按十六进制列出对应地址的原始字节。这里有一个非常容易踩的坑在Debug模式下如果pStu没有初始化或者指向的内存已经被释放你在Watch窗口里展开pStu时会看到Cannot access memory之类的提示。这不是调试器坏了而是指针本身就是一个野指针或者悬空指针。遇到这种情况优先排查指针的赋值和生命周期而不是在显示层面纠结。第二个场景Linux环境下用GDB调试。命令行操作更直观// 编译带调试信息 gcc -g -o demo demo.c // 调试 gdb ./demo // 在main函数打断点运行 (gdb) break main (gdb) run // 打印各种变量 (gdb) print stu1 (gdb) print pStu (gdb) print *pStu (gdb) print pStu-ageprint pStu看到的是地址print *pStu才能看到它指向的结构体内容这两个命令在调试时特别容易混淆。如果嫌展开太深麻烦还可以用print pStu-age直接打印成员。另外推荐一个非常实用的GDB技巧用ptype查看结构体类型定义方便在调试时确认成员名和类型(gdb) ptype Student type struct Student { char name[32]; int age; float score; }这个命令在你记不清结构体成员名的时候能省去来回翻头文件的时间。4.3 两个容易写错的隐藏陷阱陷阱一自增运算作用于指针还是成员。pStu-age // 实际是 (pStu-age)对age成员自增 (pStu)-age // 实际是让pStu先指向下一个结构体再访问其age成员如果本意是让指针从结构体数组的第一个元素移到第二个再访问第二个元素的age结果写成了pStu-age程序不会报错但值会变得莫名其妙。这种bug在代码审查里非常难发现因为编译和运行都不报错只有逻辑结果不符合预期。我的建议是所有自增自减操作紧跟箭头或点号时一律显式加括号明确表达你的意图。陷阱二const位置带来的权限差别。void func(const Student *ps) { // ps指向的内容是只读的 // ps-age 10; // 编译错误不能通过const指针修改成员 printf(%d\n, ps-age); // 读取没问题 } void func2(Student *const ps) { // ps本身不能改变指向但可以修改指向的内容 ps-age 10; // 合法 // ps; // 编译错误不能修改指针本身 }const Student *ps表示“指向const Student的指针”通俗说就是“指向的内容不能改”Student *const ps表示“Student类型的const指针”通俗说就是“指针本身不能改”。这两个声明在面试题里出现频率极高实际写代码时也要分清楚不然会在维护别人代码时被绕晕。还有一个我见过不少次的问题函数形参是void *拿到后强制转换成结构体指针然后马上用箭头访问成员void callback(void *arg) { Student *ps (Student *)arg; printf(%d\n, ps-age); }这种写法本身没问题是现代C代码里回调函数的常见模式。问题在于转换前要确保传入的arg确实指向一个合法的Student对象否则箭头一访问就是未定义行为。调试时如果发现回调函数里一访问就崩溃优先怀疑传进来的指针类型和预期不符。5. 一点个人经验最后说点个人体会。我做嵌入式开发这些年来“.”和“-”的使用已经快变成肌肉记忆了但回头看当年确实在这上面栽过不少跟头尤其是把指针顺手写成点号、或者被优先级绕进去的时候。后来我给自己定了一个简单的规则拿到一个变量先看它的声明里有没有星号。声明里带星号说明它是指针访问成员就用-声明里不带星号说明它是普通变量访问成员就用.。函数形参同理。这个规则简单到有点“笨”但真的很管用。另一个建议是学结构体引用方式时别死记硬背语法一定自己动手画一画内存布局图。拿一张纸画出变量的地址、指针的内容、成员的偏移量再手动推一遍pStu-age和(*pStu).age到底是怎么找到内存的。等你把“地址”和“对象”这两个概念彻底区分开不仅点号和箭头不会再搞混后面学链表、树、指针数组、函数指针回调也都会顺很多。如果你现在正在学C语言或者刚接触嵌入式这篇文章里提到的那些报错信息、调试方法建议收藏起来等你真遇到时再翻出来对照一遍会比当时就背下来更有效果。
阅读完成 · 觉得有帮助?
咨询建站