我们之前第三讲和第四讲简单介绍过操作符,之前我们对于操作符仅限于 "运算符号",实际上,操作符不是单纯的 "运算符号",背后是计算机的存储、运算逻辑,这一讲进行详细讲解,但是还是没有将最基础的操作符全部讲透,因为我们还没有讲解指针
一、操作符分类
C 语言的操作符功能多样,按用途可分为 10 大类,覆盖编程中的各类场景:
- 算术操作符:
+、-、*、/、%(基础运算,注意%仅适用于整数)
- 移位操作符:
<<、>>(二进制位移动,仅作用于整数) - 位操作符:
&、|、^、~(二进制位直接运算) - 赋值操作符:=、+=、-=、*=、/=、%=、
<<=、>>=、&=、|=、^=(变量赋值与复合运算) - 单目操作符:!、++、--、&、*、+、-、
~、sizeof、(强制类型转换)(仅需一个操作数,功能灵活) - 关系操作符:
>、>=、<、<=、==、!=(判断变量关系) - 逻辑操作符:
&&、||(逻辑判断,短路求值特性) - 条件操作符:
? :(三目运算,简化分支逻辑)
- 逗号表达式:
,(多表达式顺序执行,取最后结果) - 特殊操作符:
[](下标访问)、()(函数调用)、.(结构体直接访问)、->(结构体指针访问)
其中,移位、位操作符依赖二进制知识,我们先铺垫基础概念。
二、二进制与进制转换:操作符的底层基础
所有数据在计算机中都以二进制存储,掌握进制转换是理解位操作、移位操作的前提。
2.1 核心进制概念
- 10 进制:满 10 进 1,由 0-9 组成(日常使用)
- 2 进制:满 2 进 1,由 0-1 组成(计算机存储格式)
- 8 进制:满 8 进 1,由 0-7 组成,前缀加
0(如017表示 15) - 16 进制:满 16 进 1,由 0-9、a-f 组成,前缀加
0x(如0xF表示 15)
2.2 关键转换方法
- 2 进制转 10 进制:按权重求和,从右向左权重为
2⁰、2¹、2²...例:二进制1101=1*2³ + 1*2² + 0*2¹ + 1*2⁰= 13 - 10 进制转 2 进制:除 2 取余,余数倒序排列例:125 转二进制 → 1111101
- 2 进制转 8 进制:从右向左每 3 位一组,不足补 0,每组转成 1 位 8 进制数例:
01101011→ 分组01 101 011→ 8 进制0153 - 2 进制转 16 进制:从右向左每 4 位一组,不足补 0,每组转成 1 位 16 进制数例:
01101011→ 分组0110 1011→ 16 进制0x6b
三、原码、反码、补码:整数的二进制表示
整数在内存中以补码存储,这是 CPU 简化运算的核心设计,三者的规则如下:
3.1 核心规则
- 有符号整数:最高位为符号位(0 = 正,1 = 负),剩余为数值位
- 正整数:原码、反码、补码完全相同例:+5 的原码 = 反码 = 补码 →
00000101 - 负整数:
- 原码:直接翻译数值的二进制(含符号位)例:-5 的原码 →
10000101 - 反码:符号位不变,数值位按位取反例:-5 的反码 →
11111010 - 补码:反码 + 1(内存存储格式)例:-5 的补码 →
11111011
- 原码:直接翻译数值的二进制(含符号位)例:-5 的原码 →
- 无符号整数:无符号位,原码 = 反码 = 补码
32 位 int 占 4 字节(32 位),讲解中默认以 32 位补码为例
3.2 补码转原码
正数和无符号整数补码直接等于原码,无需转换;
负数补码转原码有两种方法:
①先减1得反码,再保持符号位不变、数值位取反;
②对补码全位取反后加1,最终保留符号位即可。
| 方法 | 核心操作 | |
|---|---|---|
| 逆推法 | 补码减 1 → 数值位取反 | |
| 按位取反加 1 法 | 补码取反 + 1 → 补符号位 |
3.3 补码的优势
- 符号位与数值位统一处理,无需额外硬件电路
- 减法运算转化为加法(CPU 仅需加法器)
- 原码与补码转换规则统一(取反 + 1)
四、移位操作符(<<、>>)
仅支持整数,移位规则直接决定结果,需避开负数位陷阱:
- 左移(<<):左边丢弃,右边补 0(例:10→二进制 000000000...0000001010,左移 1 位→00000000000...0000010100→20)
cpp
#include <stdio.h>
int main()
{
int a = 10;
int b = (a << 1);
printf("%d\n", b);
return 0;
}
- 右移(>>):分两种类型
- 逻辑右移:左边补 0,右边丢弃(无符号数默认)
- 算术右移:左边补符号位,右边丢弃(有符号数默认,例:-1 补码 11111111,右移 1 位仍为 11111111→-1)
右移到底是逻辑右移?还是算术右移呢?这个是取决于编译器大部分的编译器上都是采用算术右移的,VS2022 对有符号数采用算术右移,无符号数采用逻辑右移。
cpp
#include <stdio.h>
int main()
{
int a = -10;
int b = (a >> 1);
printf("%d\n", b);
return 0;
}
分析:以 -10 为例,其 32 位原码为:
cpp
1000000 00000000 00000000 00001010
其 32 位反码为:
cpp
11111111 11111111 11111111 11110101
其 32 位补码为:
cpp
11111111 11111111 11111111 11110110
算术右移 1 位(补符号位 1)后补码为:
cpp
11111111 11111111 11111111 11111011
b的原码为:
cpp
10000000 00000000 00000000 00000101
(十进制 -5)
若按逻辑右移(补 0),
结果会是
cpp
01111111 11111111 11111111 11111011
(十进制 2147483643),
但 VS2022 实际输出 -5,证明是算术右移。
补充:有符号数 vs 无符号数右移的对比示例(VS2022 验证)
cpp
#include <stdio.h>
int main() {
// 有符号数(int):算术右移
int a = -10;
printf("有符号数-10右移1位:%d\n", a >> 1); // 输出-5(算术右移)
// 无符号数(unsigned int):逻辑右移
unsigned int b = -10; // 无符号数存储为补码:4294967286
printf("无符号数-10右移1位:%u\n", b >> 1); // 输出2147483643(逻辑右移)
return 0;
}
运行结果(VS2022):
cpp
有符号数-10右移1位:-5
无符号数-10右移1位:2147483643
坑点:禁止移动负数位(如num >> -1,属于未定义行为,编译器结果不一致)
五、位操作符(&、|、^、~)
位操作符是直接操作整数二进制补码位 的运算符(仅支持整数类型,浮点数不适用),因执行效率极高(CPU 原生支持),是面试高频考点,也是底层开发、性能优化的核心技巧。以下按「按位取反(~)→ 按位与(&)→ 按位或(|)→ 按位异或(^)」的顺序,拆解每个操作符的规则、特性、场景和代码示例,注意:他们的操作数必须是整数。
A、按位取反(~):所有位取反(补码)
~ 是唯一的单目位操作符,仅需 1 个操作数,核心是对整数 补码的每一位逐位取反(0→1,1→0),是位运算的基础操作。
1. 核心规则
- 操作对象:仅支持整数类型(char/short/int/long 等,浮点数用
~编译报错); - 运算逻辑:对整数补码的每一位直接翻转,无进位、无偏移;
2. 典型示例推导
示例 1:~0 = -1
-
0的 32 位补码:00000000 00000000 00000000 00000000; -
逐位取反后:
11111111 11111111 11111111 11111111(补码); -
取反后原码:
10000000 00000000 00000000 00000001 (十进制 -1)cpp#include <stdio.h> int main() { int a = 0; int b = ~a; printf("%d\n", b); return 0; }运行结果:
cpp-1
示例 2:~5 = -6
-
5的补码(正数补码 = 原码):00000000 00000000 00000000 00000101; -
逐位取反后:
11111111 11111111 11111111 11111010(补码); -
补码转原码:减 1 得反码
11111111 11111111 11111111 11111001→ 数值位取反得原码10000000 00000000 00000000 00000110(十进制-6);cpp#include <stdio.h> int main() { int a = 5; int b = ~a; printf("%d\n", b); return 0; }
运行结果:-6
3. 实用场景 :配合 & 清 0 整数的特定位
构造 "某一位为 0,其余位为 1" 的掩码,~(1<<n)是经典写法(1 左移 n 位后取反,仅第 n 位为 0)。
cpp
#include <stdio.h>
int main() {
int num = 45; // 二进制:00101101(8位简化)
int n = 3; // 要清0的位:第3位(从0计数)
int mask = ~(1 << n); // 掩码:~00001000 = 11110111
int res = num & mask; // 00101101 & 11110111 = 00100101(37)
printf("清0第%d位后:%d\n", n, res); // 输出:清0第3位后:37
return 0;
}
B、按位与(&):对应位都为 1 则为 1
1. 核心规则(双目操作符)
| 操作数 1 位 | 操作数 2 位 | 按位与结果 |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
核心特性:
- 任何位与
0与 → 结果为0(清除特定位); - 任何位与
1与 → 保留原位值 (提取特定位); - 满足交换律、结合律:
a&b = b&a,(a&b)&c = a&(b&c)。
2. 典型示例:-3 & 5 = 5
cpp
#include <stdio.h>
int main()
{
int a = -3;
int b = 5;
int c = a&b;
printf("%d\n", c);
return 0;
}
步骤 1:计算-3和5的 32 位补码
步骤 2:按位与运算
cpp
11111111 11111111 11111111 11111101 (-3补码)
& 00000000 00000000 00000000 00000101 (5补码)
---------------------------------------
00000000 00000000 00000000 00000101 (结果补码,对应十进制5)
3. 实用场景 + 代码示例
场景 1:判断整数奇偶(面试高频)
思路:奇数的二进制最后一位是 1,偶数是 0;num & 1结果为 1 则奇数,0 则偶数(比取模%效率更高)。
cpp
#include <stdio.h>
int main()
{
int num = 17;
if (num & 1)
{
printf("%d 是奇数\n", num); // 输出17是奇数
}
else
{
printf("%d 是偶数\n", num);
}
return 0;
}
场景 2:提取整数的特定位
需求:提取0b101101(45)的第 2~4 位。
在 C 语言中,0b(小写 b,也可写0B大写)是二进制整数字面量的专属前缀,用来明确标识其后的数字序列是二进制数(仅由 0 和 1 组成),区别于默认的十进制、0 开头的八进制、0x 开头的十六进制。
结合代码示例:
0b11100表示二进制数11100(对应十进制 28);num=45的二进制是00101101,也可写成0b101101(前缀 0b 是关键标识)。
注:该写法从 C99 标准开始支持,主流编译器(GCC、VS2019+、Clang)均兼容。
cpp
#include <stdio.h>
int main() {
int num = 45; // 00101101
int mask = 0b11100; // 掩码:00011100(仅第2~4位为1)
int res = (num & mask) >> 2; // 先提取→00001100,右移2位→00000011(3)
printf("第2~4位的值:%d\n", res); // 输出3
return 0;
}
C、按位或(|):对应位有 1 则为 1
1. 核心规则(双目操作符)
| 操作数 1 位 | 操作数 2 位 | 按位或结果 |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 1 |
核心特性:
- 任何位与
1或 → 结果为1(置 1 于特定位); - 任何位与
0或 → 保留原位值; - 满足交换律、结合律:
a|b = b|a,(a|b)|c = a|(b|c)。
2. 典型示例:13 | (1<<4) = 29
cpp
#include <stdio.h>
int main()
{
int a = 13;
int b = (1<<4);
int c = a|b;
printf("%d\n", c);
return 0;
}
步骤 1:拆解运算数
步骤 2:按位或运算
cpp
00000000 00000000 00000000 00001101 (13补码)
| 00000000 00000000 00000000 00010000 (16补码)
---------------------------------------
00000000 00000000 00000000 00011101 (结果补码,对应十进制29)
3. 实用场景 + 代码示例
场景 1:置 1 于整数的特定位
需求:将0b101101(45)的第 6 位置 1,其他位保留。
cpp
#include <stdio.h>
int main()
{
int num = 45; // 00101101
int mask = 1 << 6; // 掩码:01000000(第6位为1)
int res = num | mask; // 00101101 | 01000000 = 01101101(109)
printf("置1第6位后:%d\n", res); // 输出109
return 0;
}
场景 2:合并权限(开发高频)
思路:用二进制位表示权限(如 001 = 读、010 = 写、100 = 执行),按位或合并多个权限。
cpp
#include <stdio.h>
#define READ 1 // 001
#define WRITE 2 // 010
#define EXEC 4 // 100
int main() {
// 合并"读+写+执行"权限
int perm = READ | WRITE | EXEC; // 001 | 010 | 100 = 111(7)
printf("合并权限值:%d\n", perm); // 输出7
return 0;
}
D、按位异或(^):对应位相同为0,不同为 1
1. 核心规则(双目操作符)
| 操作数 1 位 | 操作数 2 位 | 按位异或结果 |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
核心特性:
a ^ a = 0(相同数异或为 0);a ^ 0 = a(任何数异或 0 等于自身);- 交换律、结合律:
a^b = b^a,(a^b)^c = a^(b^c); - 可逆性:
a ^ b ^ b = a(异或两次同一数,恢复原值)。
2. 典型场景 + 代码示例
场景 1:无临时变量交换两个整数(经典面试题)
思路:利用a^b^b = a的特性,无需额外变量。
cpp
#include <stdio.h>
int main()
{
int a = 10, b = 20;
printf("交换前:a=%d, b=%d\n", a, b); // 10,20
a = a ^ b; // a = 10^20
b = a ^ b; // b = (10^20)^20 = 10^(20^20)=10^0=10
a = a ^ b; // a = (10^20)^10 = 20^(10^10)=20^0=20
printf("交换后:a=%d, b=%d\n", a, b); // 20,10
return 0;
}
场景 2:找数组中唯一出现奇数次的数(面试高频)
需求:数组中只有 1 个数出现奇数次,其余均出现偶数次,找出该数。思路:利用a^a=0,所有数异或后,偶数次的数抵消为 0,最终结果为目标数。sizeof在第二讲第五部分有讲,忘了的话可以回去看看
cpp
#include <stdio.h>
int main()
{
int arr[] = {2,3,2,4,4,5,5};
int len = sizeof(arr)/sizeof(arr[0]);
int res = 0;
for (int i=0; i<len; i++) {
res ^= arr[i]; // 0^2^3^2^4^4^5^5 = 3
}
printf("唯一奇数次的数:%d\n", res); // 输出3
return 0;
}
场景 3:翻转整数的特定位
需求:将0b101101(45)的第 3 位翻转(0→1,1→0)。
cpp
#include <stdio.h>
int main() {
int num = 45; // 00101101(第3位是1)
int mask = 1 << 3; // 掩码:00001000
int res = num ^ mask; // 00101101 ^ 00001000 = 00100101(37,第3位翻转为0)
printf("翻转第3位后:%d\n", res); // 输出37
return 0;
}
六、 单目操作符:未掌握的 2 个后续指针章节详解
单目操作符仅需一个操作数,大部分用法简单,重点关注&和*(后续指针章节详解):
sizeof:计算变量 / 类型的字节数(类型):强制类型转换(例:(int)3.14→3,慎用,可能丢失精度)++/--:前置先自增 / 减再使用,后置先使用再自增 / 减(例:int a=1; printf("%d", ++a);→2)
七、逗号表达式:顺序执行 + 取最后结果
逗号分隔的多个表达式,从左到右依次执行,最终结果为最后一个表达式的值:
cpp
int a=1, b=2;
int c = (a>b, a=b+10, a, b=a+1); // 执行顺序:a>b(假)→a=12→a=12→b=13 → c=13
实用场景:简化循环条件,:如
cpp
a = get_val();
count_val(a);
while (a > 0)
{
//业务处理
//...
a = get_val();
count_val(a);
}
如果使⽤逗号表达式,改写:
while (a = get_val(), count_val(a), a>0)
{
//业务处理
}
八、下标访问 []和函数调用 ()
1. 下标访问 []
-
作用:用于访问数组元素,通过 "数组名 + 下标" 定位元素
-
用法 :
数组名[下标](下标从 0 开始)示例:cppint arr[5] = {1,2,3,4,5}; printf("%d", arr[2]); // 输出3(访问数组第3个元素)
2. 函数调用 ()
-
作用:执行函数,传递参数
-
用法 :
函数名(参数1, 参数2,...)(无参数时可写()或(void))示例:cpp// 定义函数 int add(int a, int b) { return a+b; } // 调用函数 int sum = add(3,5); // 执行add,传入3和5,sum=8 -
注意:参数个数、类型需与函数定义匹配
九、结构成员访问操作符(.、->)
用于访问结构体成员,根据结构体类型(变量 / 指针)选择操作符:
- 结构体变量:用
.(例:struct Stu s; s.age=20;) - 结构体指针:用
->(例:struct Stu *p; p->age=20;)这个等指针讲解后来学习
C语言已经提供了内置类型,如:char、short、int、long、float、double等,但是只有这些内置类型还是不够的,假设我想描述学生,描述一本书,这时单一的内置类型是不行的。描述一个学生需要名字、年龄、学号、身高、体重等;描述一本书需要书名、作者、出版社、定价等。C语言为了解决这个问题**,增加了结构体这种自定义的数据类型,让程序员可以自己创造适合的类型。**
结构是一些值的集合,这些值称为成员变量。结构的每个成员可以是不同类型的变量,如:标量、数组、指针,甚至是其他结构体。
比如描述学生:要包含名字(字符串)、年龄(整数)、学号(字符串),这些数据无法用一个 int 或 char 存,结构体就能解决这个问题。
我们今天先来简单学习一下结构体,后面还会深入讲解
A、结构体的核心基础(声明、定义、初始化)
1. 结构体声明(创建自定义类型)
格式:struct 类型名 { 成员列表 };(分号不能丢!)
- 类型名:自己取的名字,比如描述学生就叫 Student,描述坐标就叫 Point。
- 成员列表:要打包的不同类型数据,每个成员单独声明类型。
cpp
struct Stu {
char name[20]; // 名字(字符串)
int age; // 年龄(整数)
char id[20]; // 学号(字符串)
};
这一步只是 "创建了一个新类型",就像 int 一样,还没实际存储数据。
2. 结构体变量的定义(创建具体 "实例")
定义方式有 3 种,核心是 "用声明的结构体类型创建变量":
- 声明时直接定义:
struct Stu { 成员 }; s1, s2;(s1、s2 就是学生变量)
核心:声明结构体类型的同时,直接定义变量,变量可在全局或局部使用。
cpp
#include <stdio.h>
// 声明struct Stu类型的同时,直接定义变量s1、s2(全局变量)
struct Stu
{
char name[20]; // 名字
int age; // 年龄
} s1, s2;
- 单独定义:
struct Stu s3;(先声明类型,后续再创建变量)
核心:先声明结构体类型(相当于 "创建模板"),后续在需要时用该类型定义变量。
cpp
#include <stdio.h>
// 1. 先声明struct Stu类型(仅模板,无变量)
struct Stu
{
char name[20];
int age;
};
int main()
{
// 2. 单独定义变量s3、s4(用已声明的struct Stu类型)
struct Stu s3, s4;
return 0;
}
- 嵌套定义:结构体成员可以是另一个结构体(比如描述节点包含坐标)
核心:先声明内层结构体,再声明外层结构体,外层成员包含内层结构体实例。
cpp
#include <stdio.h>
// 1. 先声明内层结构体(坐标模板)
struct Point
{
int x; // x轴坐标
int y; // y轴坐标
};
// 2. 声明外层结构体(节点模板),成员包含struct Point
struct Node
{
int data; // 节点数据
struct Point p; // 嵌套的坐标结构体(内层结构体实例)
};
int main()
{
// 定义外层变量并初始化嵌套成员
struct Node n = {10, {3, 4}}; // data=10,p.x=3,p.y=4
printf("节点数据:%d,坐标:(%d,%d)\n", n.data, n.p.x, n.p.y);
return 0;
}
3. 结构体变量的初始化(给成员赋值)
- 顺序初始化:按成员声明顺序赋值,用大括号包裹。示例:
struct Stu s1 = {"张三", 20, "2024001"}; - 指定成员初始化(不用按顺序):用
.成员名=值的方式,更灵活。示例:struct Stu s2 = {.age=19, .name="李四"};(没赋值的成员默认 0 或空)
第二种初始化方式也正是我们今天要学习的结构成员访问操作符的使用
两种初始化的本质差异:顺序初始化依赖结构体成员的声明顺序,指定初始化通过成员名定位赋值(与顺序无关),核心是 "按位置赋值" vs "按名字赋值"。
顺序初始化(按成员声明顺序赋值)
本质:
编译器会按照结构体成员的声明顺序,依次将大括号内的值,对应写入成员在内存中的位置 ------ 就像按 "格子顺序" 往盒子里放东西,第一个值放第一个格子,第二个值放第二个格子,一一对应。
语法格式
cpp
struct 结构体名 变量名 = {值1, 值2, 值3, ...};
- 大括号内的 "值" 必须和结构体成员的声明顺序完全一致。
- 成员数量可以少于结构体总成员数(未赋值的成员默认是 0 或空字符串)。
详细示例(覆盖多类型成员)
先声明一个包含 "字符串、整数、数组" 的结构体,再用顺序初始化:
cpp
#include <stdio.h>
#include <string.h> // 用于后续赋值时的strcpy
// 声明结构体(成员顺序:name→age→id→score[3])
struct Stu {
char name[20]; // 姓名(字符串数组)
int age; // 年龄(整数)
char id[20]; // 学号(字符串数组)
int score[3]; // 三门课成绩(整数数组)
};
int main() {
// 顺序初始化:值的顺序必须和上面的成员声明顺序一致
struct Stu s1 = {
"张三", // 对应 name[20]
20, // 对应 age
"2024001", // 对应 id[20]
{85, 90, 95}// 对应 score[3](数组成员也按顺序初始化)
};
// 打印结果,验证赋值
printf("姓名:%s\n", s1.name);
printf("年龄:%d\n", s1.age);
printf("学号:%s\n", s1.id);
printf("成绩:%d, %d, %d\n", s1.score[0], s1.score[1], s1.score[2]);
return 0;
}
运行结果:
cpp
姓名:张三
年龄:20
学号:2024001
成绩:85, 90, 95
指定成员初始化(按成员名赋值)
本质:
通过**.成员名=值**的语法,直接 "定位" 到结构体中目标成员的内存位置赋值 ------ 就像按 "格子名字" 往盒子里放东西,不管格子顺序,直接找到对应名字的格子放值。
语法格式:
cpp
struct 结构体名 变量名 = {.成员名1=值1, .成员名2=值2, ...};
- 成员名必须是结构体中声明过的有效成员。
- 多个成员之间用逗号分隔,顺序可以任意调整。
- 可以跳过任意成员(未赋值的成员默认 0 或空)。
详细示例(覆盖多场景)
沿用上面的struct Stu结构体,演示不同场景的指定初始化:
cpp
#include <stdio.h>
struct Stu {
char name[20];
int age;
char id[20];
int score[3];
};
int main() {
// 场景1:打乱成员顺序初始化
struct Stu s1 = {
.age=19, // 先赋值age(原本是第2个成员)
.name="李四", // 再赋值name(原本是第1个成员)
.id="2024002", // 再赋值id(原本是第3个成员)
.score={88, 92, 89}
};
// 场景2:跳过部分成员(score未赋值,默认全0)
struct Stu s2 = {
.id="2024003",
.name="王五"
// age和score未赋值,age默认0,score默认[0,0,0]
};
// 场景3:嵌套结构体的指定初始化(延续之前的嵌套示例)
struct Point { int x; int y; };
struct Node { int data; struct Point p; };
struct Node n = {
.p.x=5, // 嵌套成员:直接定位到p的x成员
.data=30,
.p.y=6 // 嵌套成员:定位到p的y成员,顺序无关
};
// 打印验证
printf("s1:%s, %d岁, 学号%s, 成绩%d\n", s1.name, s1.age, s1.id, s1.score[0]);
printf("s2:%s, %d岁, 成绩%d\n", s2.name, s2.age, s2.score[1]);
printf("n:数据%d, 坐标(%d,%d)\n", n.data, n.p.x, n.p.y);
return 0;
}
运行结果:
cpp
s1:李四, 19岁, 学号2024002, 成绩88
s2:王五, 0岁, 成绩0
n:数据30, 坐标(5,6)
B、核心:结构成员访问操作符(. 和 ->)
目的是 "从结构体变量 / 指针中,取出里面的具体成员"(比如从学生变量 s1 中取出名字、年龄),两个操作符的区别只看 "访问的对象是结构体变量,还是结构体指针"。
1. 直接访问:点操作符(.)
- 用法:
结构体变量.成员名 - 场景:当你手里有 "结构体变量本身"(不是指针)时用。
示例:
cpp
struct Point { // 声明"坐标"类型
int x; // x轴坐标
int y; // y轴坐标
};
int main() {
struct Point p = {3, 4}; // 定义变量p并初始化
printf("x坐标:%d\n", p.x); // 用.直接取p的x成员
printf("y坐标:%d\n", p.y); // 取p的y成员
return 0;
}
本质:直接定位到结构体变量的内存,找到对应成员的位置。
2. 间接访问:箭头操作符(->)
- 用法:
结构体指针->成员名 - 场景:当你手里只有 "指向结构体的指针"(不是变量本身)时用。
示例:
cpp
int main() {
struct Point p = {3, 4}; // 结构体变量p
struct Point *ptr = &p; // ptr是指向p的指针(存p的地址)
ptr->x = 10; // 用->通过指针修改x成员
ptr->y = 20; // 通过指针修改y成员
printf("x:%d,y:%d\n", ptr->x, ptr->y); // 输出10和20
return 0;
}
本质:指针存的是结构体的地址,-> 会先通过地址找到结构体,再取出成员(等价于 (*ptr).x,但 -> 更简洁)。
十、关键属性:优先级与结合性
表达式的计算顺序不是 "从左到右" 这么简单,而是由优先级 和结合性共同决定 ------ 这两个属性是避免表达式计算错误的核心,也是初学者最容易踩坑的地方。下面我们用 "通俗解释 + 多案例 + 避坑提示" 的方式,把这部分内容讲透!
10.1 优先级:谁的 "话语权" 更高
核心定义
优先级指多个不同操作符共存时,哪个操作符先执行,就像数学中的 "先乘除后加减",操作符的 "话语权" 有明确高低之分。
案例 1:算术操作符 vs 赋值操作符
cpp
int a = 3 + 4 * 2; // 结果a=11,不是14
- 解析:
*优先级(3 级)>+优先级(4 级)>=优先级(14 级) - 计算顺序:先算
4*2=8→ 再算3+8=11→ 最后执行a=11
案例 2:关系操作符 vs 逻辑操作符
cpp
int res = 3 > 2 && 5 < 4 || 6 == 6; // 结果res=1(真)
- 解析:关系操作符(6-7 级)> 逻辑与
&&(11 级)> 逻辑或||(12 级) - 计算顺序:先算
3>2=1、5<4=0、6==6=1→ 再算1&&0=0→ 最后算0||1=1
案例 3:单目操作符 vs 算术操作符
cpp
int b = -3 + 5; // 结果b=2,不是-8
- 解析:单目负号
-(2 级)>+(4 级) - 计算顺序:先算
-3(单目操作)→ 再算-3+5=2
不确定优先级时,可以直接用圆括号()强制指定顺序(()优先级最高,1 级),比如a = (3 + 4) * 2,既清晰又不会出错。
10.2 结合性:优先级相同时,"谁先上"
核心定义
当多个优先级相同的操作符共存时,由结合性决定执行顺序:
- 左结合(默认):从左到右执行(大部分操作符,如
+、-、*、/、&&、||) - 右结合:从右到左执行(少数操作符,如
=、+=、++、--(单目)、?:)
案例 1:左结合(乘除运算)
cpp
int c = 12 / 4 * 3; // 结果c=9,不是1
- 解析:
/和*优先级相同(3 级),左结合 - 计算顺序:从左到右 → 先算
12/4=3→ 再算3*3=9
案例 2:右结合(赋值运算)
cpp
int x = y = z = 5; // 结果x=5、y=5、z=5(正确)
- 解析:
=优先级相同(14 级),右结合 - 计算顺序:从右到左 → 先算
z=5→ 再算y=z(y=5)→ 最后算x=y(x=5)
10.3 常用优先级排序
按优先级从高到低 排列,重点记住**"括号最高、赋值最低、单目高于算术、关系高于逻辑":**
官网参考: https://zh.cppreference.com/w/c/language/operator_precedence
十一、表达式求值陷阱(深入拆解,避坑指南)
即使掌握了优先级和结合性,有些表达式的结果依然 "不确定"------ 这是因为 C 语言对部分表达式的求值顺序未做定义,不同编译器可能给出不同结果。下面拆解 3 个核心陷阱,每个都配 "反例 + 正例"。
11.1 陷阱 1:整型提升(隐形的 "类型转换")
核心定义
C 语言中,char、short 类型参与算术运算时,会先自动转换为 int 类型(称为 "整型提升"),运算完成后再截断回原类型。这是 CPU 运算机制决定的(CPU 默认按 int 长度运算)。
整型提升的规则
- 有符号类型(signed char、short):按符号位提升(正数补 0,负数补 1)
- 无符号类型(unsigned char、unsigned short):高位补 0
实战反例(踩坑代码)
cpp
#include <stdio.h>
int main() {
char a = 127; // 有符号char最大值(二进制01111111)
char b = 1;
char c = a + b; // 预期128,实际结果为-128(溢出)
printf("c = %d\n", c);
return 0;
}
拆解过程(蓝色辅助理解)
- 整型提升:a(127)→ 补码
00000000 00000000 00000000 01111111;b(1)→ 补码00000000 00000000 00000000 00000001 - 运算:a + b =
00000000 00000000 00000000 10000000(十进制 128) - 截断:char 类型仅 8 位,截断后为
10000000(有符号 char 中,这是 - 128 的补码) - 结果:c = -128(和预期完全不同)
避坑正例
将参与运算的变量显式转为 int 类型,避免截断溢出:
cpp
int c = (int)a + (int)b; // 结果为128(正确)
11.2 陷阱 2:算术转换(不同类型的 "强制统一")
核心定义
当操作符的两个操作数类型不同时,会按 "寻常算术转换" 规则,将低优先级类型转为高优先级类型,再运算。
寻常算术转换顺序(从高到低)
long double > double > float > unsigned long int > long int > unsigned int > int
实战反例(踩坑代码)
cpp
#include <stdio.h>
int main() {
unsigned int u = 3;
int i = -5;
if (u + i > 0) { // 预期为假(3-5=-2<0),实际为真
printf("u + i > 0\n");
} else {
printf("u + i <= 0\n");
}
return 0;
}
拆解过程(蓝色辅助理解)
- 类型对比:
unsigned int(优先级 6)>int(优先级 7) - 转换:int 类型的 i(-5)被转为
unsigned int类型- -5 的补码是
11111111 11111111 11111111 11111011,转为无符号数后是4294967291
- -5 的补码是
- 运算:u + i = 3 + 4294967291 = 4294967294(远大于 0)
- 结果:条件为真,执行第一个 printf
避坑正例
显式统一类型,避免无符号和有符号混用:
cpp
if ((int)u + i > 0) { // u转为int,3 + (-5) = -2 < 0,执行else(正确)
11.3 陷阱 3:未定义行为的表达式(编译器 "各说各的")
核心定义
C 语言标准未规定某些表达式的求值顺序,不同编译器(如 gcc、VS)可能按不同顺序计算,导致结果不确定 ------ 这类表达式坚决不能写!
常见未定义表达式及解析
反例 1:同一表达式中多次修改同一变量
cpp
int x = 1;
int y = x++ + ++x; // 结果不确定(gcc中y=4,VS中y=3)
- 问题:
x++(后置自增)和++x(前置自增)的求值顺序未定义- gcc:先算
++x(x=2),再算x++(x=1,后自增为 2),y=1+2=3?不对,实际 gcc 中 x 初始 1,++x使 x=2,x++取 1 后 x=2,y=3?不同版本可能有差异,核心是顺序不确定。
- gcc:先算
- 正例:拆分表达式,明确顺序
cpp
int x = 1;
++x; // x=2
int y = x + x; // y=4(结果唯一)
反例 2:赋值与自增混用
cpp
int m = 2;
m = ++m + 1; // 未定义(不同编译器结果不同)
- 问题:
++m修改了 m,同时赋值操作也修改 m,求值顺序未定义 - 正例:
cpp
int m = 2;
++m; // 先自增,m=3
m = m + 1; // m=4(结果唯一)
反例 3:函数参数中多次修改同一变量
cpp
int n = 3;
printf("%d %d\n", n++, ++n); // 结果不确定(gcc中输出3 5,VS中输出4 5)
- 问题:函数参数的求值顺序未定义,可能先算第一个参数,也可能先算第二个
- 正例:拆分参数,明确顺序
cpp
int n = 3;
int a = n++; // a=3,n=4
int b = ++n; // b=5,n=5
printf("%d %d\n", a, b); // 输出3 5(结果唯一)
避坑原则
- 一个表达式中,同一变量最多修改一次(赋值、++、-- 都算修改)
- 避免将修改变量的操作(如 ++、--)和使用变量的操作混在同一表达式中
- 不确定时,果断拆分表达式,牺牲 "简洁" 换 "正确性"
总结
操作符的优先级和结合性是表达式计算的 "规则手册",但更重要的是避开 "整型提升、算术转换、未定义行为" 这 3 个核心陷阱。初学者的核心原则是:
- 不确定优先级就用
(),不要靠记忆硬扛; - 避免 char/short 参与复杂运算,避免有符号和无符号混用;
- 不写 "炫技" 的复杂表达式,拆分后更清晰、更安全。
这篇博客写的稀烂,毫无水准,但是现在我的能力也就只能止步于此,哎,还是得练
OK,还是经典结尾:嗯,希望能够得到你的关注,希望我的内容能够给你带来帮助,希望有幸能够和你一起成长。
写这篇博客的时候窗外正下着小雨,滴答声很安静。
雨洗东华尘未消,栈帧深处见真招。
我走到阳台拍下了一张宿舍对面的照片作为本文的封面。