
🔥铅笔小新z:个人主页
🎬博客专栏:C语言
💫滴水不绝,可穿石;步履不休,能至渊。

一、操作符的分类
C语言提供了非常丰富的操作符,按功能可以分为以下几类:
| 类别 | 操作符 |
|---|---|
| 算术操作符 | + - * / % |
| 移位操作符 | << >> |
| 位操作符 | & ^ ~ |
| 赋值操作符 | = += -= *= /= %= <<= >>= &= |
| 单目操作符 | ! ++ -- & * + - ~ sizeof (类型) |
| 关系操作符 | > >= < <= == != |
| 逻辑操作符 | && |
| 条件操作符 | ? : |
| 逗号表达式 | , |
| 下标引用 | [] |
| 函数调用 | () |
| 结构成员访问 | . -> |
前面我们已经学过算术、赋值、逻辑、条件等操作符,这一讲重点深入那些和二进制有关的操作符。
知识拓展
操作符按操作数个数可分为:单目操作符(一个操作数,如!、++、sizeof)、双目操作符(两个操作数,如+、-、*、&)和三目操作符(三个操作数,即?:条件操作符)。操作符的优先级和结合性决定了表达式的求值顺序。理解操作符的分类是学习C语言表达式的基础,尤其在嵌入式编程和底层开发中,位操作符和移位操作符的应用极为广泛。
总结
C语言提供了丰富的操作符,按功能分为算术、移位、位、赋值、单目、关系、逻辑、条件、逗号、下标引用、函数调用和结构成员访问共十二大类。按操作数个数分为单目、双目和三目操作符。不同类型操作符的混合使用需要关注优先级和结合性规则。
二、二进制和进制转换
2.1 什么是进制?
我们平时用的是十进制 ,但计算机内部用的是二进制 。不同的进制只是数字的不同表示形式。
比如数值 15 的不同进制表示:
| 进制 | 表示 | 说明 |
|---|---|---|
| 2进制 | 1111 |
每位是 0 或 1 |
| 8进制 | 017 |
以 0 开头,每位 0~7 |
| 10进制 | 15 |
每位 0~9 |
| 16进制 | 0xF |
以 0x 开头,每位 0~9, a~f |
2.2 二进制 → 十进制
和二进制的每一位都有一个权重,从右向左分别是 2⁰, 2¹, 2², 2³...
二进制 1101 转十进制:
1 1 0 1 (二进制位)
↓ ↓ ↓ ↓
2³ 2² 2¹ 2⁰ (权重)
↓ ↓ ↓ ↓
8 + 4 + 0 + 1 = 13
所以二进制 1101 = 十进制 13。
2.3 十进制 → 二进制
用除2取余法:
13 ÷ 2 = 6 余 1 ← 最低位
6 ÷ 2 = 3 余 0
3 ÷ 2 = 1 余 1
1 ÷ 2 = 0 余 1 ← 最高位
从下往上读余数:1101 ✓
2.4 二进制 → 八进制
规则: 从右向左,每3个二进制位换算成1个八进制位。
二进制: 0 1 1 0 1 0 1 1
分组: 011 010 111
↓ ↓ ↓
八进制: 3 2 7
结果:二进制 01101011 = 八进制 0327(前面加 0 表示八进制)
2.5 二进制 → 十六进制
规则: 从右向左,每4个二进制位换算成1个十六进制位。
二进制: 0 1 1 0 1 0 1 1
分组: 0110 1011
↓ ↓
十六进制: 6 b
结果:二进制 01101011 = 十六进制 0x6b(前面加 0x 表示十六进制)
知识拓展
计算机底层全部使用二进制,因为二值逻辑(0和1)对应电路的开关状态,抗干扰能力强且实现简单。八进制和十六进制是二进制的紧凑表示:每3位二进制对应1位八进制,每4位二进制对应1位十六进制。C语言中,八进制常量以0开头(如017),十六进制以0x或0X开头(如0x1F)。进制转换的实质是权值求和------每个位上的数字乘以该位的权重然后求和。理解进制转换对于位操作、内存地址表示、网络协议分析等场景至关重要。
总结
二进制是计算机内部使用的数字表示方式。常用进制包括二进制(0/1)、八进制(0开头)、十进制和十六进制(0x开头)。二进制转十进制用"权值求和法";十进制转二进制用"除2取余法";二进制转八进制每3位一组;二进制转十六进制每4位一组。掌握进制转换是理解计算机底层运行机制的基础。
三、原码、反码、补码
整数的二进制表示有三种形式:原码、反码、补码。
3.1 基本概念
对于有符号整数:
- 符号位 :最高位(最左边)是符号位,
0表示正数,1表示负数 - 数值位:剩下的位表示数值大小
3.2 正整数的三种码
正整数的原码、反码、补码都相同!
以 +10 为例(假设用 32 位表示):
原码:00000000 00000000 00000000 00001010
反码:00000000 00000000 00000000 00001010 (和原码一样)
补码:00000000 00000000 00000000 00001010 (和原码一样)
3.3 负整数的三种码
负整数的三种码不同。
以 -10 为例:
原码:10000000 00000000 00000000 00001010
↑ 符号位为1,表示负数
反码:11111111 11111111 11111111 11110101
(符号位不变,其他位按位取反)
补码:11111111 11111111 11111111 11110110
(反码 + 1)
转换规则:
- 原码 → 反码:符号位不变,其他位按位取反(0变1,1变0)
- 反码 → 补码:反码 + 1
- 补码 → 原码:也可以"取反 + 1"(和原码→补码的过程一样)
3.4 为什么计算机用补码存储?
重要结论:整数在内存中存放的是补码。
原因:
- 统一符号位和数值位------补码把符号位和数值位统一处理
- 加法和减法统一------CPU 只有加法器,补码可以把减法变成加法
- 原码和补码的转换过程相同------不需要额外的硬件电路
例子:用补码实现减法 1 - 1
1 的补码: 00000000 00000000 00000000 00000001
-1 的补码: 11111111 11111111 11111111 11111111
相加结果: 100000000 00000000 00000000 00000000
↑ 超出32位,丢掉
结果: 00000000 00000000 00000000 00000000 = 0 ✓
用补码把 1 - 1 变成了 1 + (-1),完美解决了减法问题!
知识拓展
补码的设计基于模运算的思想------在n位系统中,一个数加上它的补码等于2ⁿ。补码的一个重要特性是:对补码再求补(取反加1)会得到原码,即"补码的补码是原码"。有符号整数的表示范围是不对称的:对于n位有符号整数,表示范围为-2ⁿ⁻¹到2ⁿ⁻¹-1(例如8位范围为-128到127)。这是因为补码表示中,负数的个数比正数多一个(最小负数没有对应的正数)。了解原反补码对调试程序中整数溢出的问题非常有帮助。
总结
整数的二进制表示有三种形式:原码(符号位加绝对值)、反码(符号位不变,其余按位取反)、补码(反码加1)。正整数三种形式相同;负整数需要转换。计算机内存中统一使用补码存储整数,原因是补码能将符号位和数值位统一处理,且能将减法运算转化为加法运算,简化了CPU电路设计。原码转补码和补码转原码的操作相同(取反加1)。
四、移位操作符
移位操作符是用来移动二进制位 的,操作数必须是整数。
4.1 左移操作符 <<
规则:左边抛弃,右边补0
c
#include <stdio.h>
int main()
{
int num = 10;
int n = num << 1; // 左移1位
printf("n = %d\n", n); // 输出 20
printf("num = %d\n", num); // 输出 10(num本身不变)
return 0;
}
推演过程:
num = 10 的补码:
00000000 00000000 00000000 00001010
左移1位(左边抛弃,右边补0):
00000000 00000000 00000000 00010100 = 20
💡 左移 1 位相当于乘以 2,左移 n 位相当于乘以 2ⁿ
4.2 右移操作符 >>
右移分为两种:
- 逻辑右移:左边补 0,右边丢弃(用于无符号数)
- 算术右移 :左边补符号位,右边丢弃(用于有符号数,大多数编译器用这个)
c
#include <stdio.h>
int main()
{
int num = 10;
int n = num >> 1; // 右移1位
printf("n = %d\n", n); // 输出 5
printf("num = %d\n", num); // 输出 10(num本身不变)
return 0;
}
推演过程:
num = 10 的补码(正数,符号位为0):
00000000 00000000 00000000 00001010
算术右移1位(左边补符号位0,右边丢弃):
00000000 00000000 00000000 00000101 = 5
💡 右移 1 位相当于除以 2(向下取整)
负数右移的例子:
num = -10 的补码:
11111111 11111111 11111111 11110110
算术右移1位(左边补符号位1):
11111111 11111111 11111111 11111011 = -5
4.3 移位操作符的注意事项
c
int num = 10;
num >> -1; // ❌ 错误!移动负数位是标准未定义的行为
num << 0; // 可以,但没变化
num << 31; // 可能改变符号位,小心使用
知识拓展
移位操作在硬件层面执行效率极高,通常只需一个CPU时钟周期。左移n位相当于乘以2ⁿ,右移n位相当于除以2ⁿ(对于正数向下取整,负数取决于编译器实现)。需要注意:当移位位数大于等于操作数的位宽时(如32位整数左移32位),属于未定义行为(UB)。对有符号整数进行右移时,大多数编译器采用算术右移(补符号位),但C标准未强制规定,因此可移植代码应避免依赖算术右移的行为,或者使用无符号类型。移位操作广泛应用于位图、权限管理、编解码等场景。
总结
左移操作符<<:左边抛弃,右边补0,相当于乘以2的幂。右移操作符>>分为逻辑右移(补0)和算术右移(补符号位),大多数编译器对有符号整数采用算术右移,相当于除以2的幂(向下取整)。移位操作不改变原操作数的值,而是产生新的值。移位位数不能为负数,也不能大于等于操作数的位宽,否则行为未定义。
五、位操作符
位操作符直接操作二进制位 ,操作数必须是整数。
| 操作符 | 名称 | 含义 |
|---|---|---|
& |
按位与 | 两个位都是1,结果才是1 |
| ` | ` | 按位或 |
^ |
按位异或 | 两个位不同,结果是1;相同是0 |
~ |
按位取反 | 0变1,1变0(单目操作符) |
5.1 按位与 &
c
#include <stdio.h>
int main()
{
int num1 = -3;
int num2 = 5;
printf("%d\n", num1 & num2); // 按位与
return 0;
}
-3 的补码:11111111 11111111 11111111 11111101
5 的补码:00000000 00000000 00000000 00000101
& -----------------------------------
结果: 00000000 00000000 00000000 00000101 = 5
5.2 按位或 |
-3 的补码:11111111 11111111 11111111 11111101
5 的补码:00000000 00000000 00000000 00000101
| -----------------------------------
结果: 11111111 11111111 11111111 11111101 = -3
5.3 按位异或 ^
异或的规律:相同为0,不同为1
-3 的补码:11111111 11111111 11111111 11111101
5 的补码:00000000 00000000 00000000 00000101
^ -----------------------------------
结果: 11111111 11111111 11111111 11111000 = -8
异或的重要特性(面试常考):
a ^ a = 0(任何数和自己异或得0)a ^ 0 = a(任何数和0异或得自己)a ^ b ^ a = b(异或满足交换律)
5.4 按位取反 ~
c
#include <stdio.h>
int main()
{
printf("%d\n", ~0); // 输出 -1
return 0;
}
0 的补码: 00000000 00000000 00000000 00000000
~0 的结果:11111111 11111111 11111111 11111111 = -1
5.5 经典面试题:不创建临时变量交换两个数
c
#include <stdio.h>
int main()
{
int a = 10;
int b = 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); // 输出 a = 20, b = 10
return 0;
}
5.6 练习1:求二进制中1的个数
方法1:模2除2(不能处理负数)
c
#include <stdio.h>
int main()
{
int num = 10; // 二进制:1010
int count = 0;
while (num) // 当num不为0
{
if (num % 2 == 1) // 如果最低位是1
{
count++; // 计数+1
}
num = num / 2; // 去掉最低位
}
printf("二进制中1的个数 = %d\n", count); // 输出 2
return 0;
}
方法2:按位与 + 移位(可以处理负数)
c
#include <stdio.h>
int main()
{
int num = -1;
int i = 0;
int count = 0;
for (i = 0; i < 32; i++) // 遍历32位
{
if (num & (1 << i)) // 检查第i位是否为1
{
count++;
}
}
printf("二进制中1的个数 = %d\n", count); // 输出 32(-1的补码全是1)
return 0;
}
方法3:n & (n-1) 技巧(最高效)
c
#include <stdio.h>
int main()
{
int num = -1;
int count = 0;
while (num)
{
count++;
num = num & (num - 1); // 每次去掉最右边的一个1
}
printf("二进制中1的个数 = %d\n", count);
return 0;
}
n & (n-1) 这个操作的作用是:去掉 n 的二进制表示中最右边的那个 1。
例如 n = 12(二进制 1100):
n-1 = 11(二进制1011)n & (n-1) = 1100 & 1011 = 1000(去掉了最右边的 1)
循环执行这个操作,直到 n 变成 0,执行次数就是 1 的个数。
5.7 练习2:将二进制某位置0或置1
题目: 将 13 的二进制第 5 位修改为 1,然后再改回 0
13 的二进制:00000000 00000000 00000000 00001101
第5位置1: 00000000 00000000 00000000 00011101 = 29
第5位置0: 00000000 00000000 00000000 00001101 = 13
c
#include <stdio.h>
int main()
{
int a = 13;
// 把第5位设置为1:用 | 操作符
a = a | (1 << 4); // 1 << 4 = 000...00010000
printf("a = %d\n", a); // 输出 29
// 把第5位设置为0:用 & 和 ~ 操作符
a = a & ~(1 << 4); // ~(1<<4) = 111...11101111
printf("a = %d\n", a); // 输出 13
return 0;
}
技巧总结:
- 置1 :
a |= (1 << n)(把第 n 位变成 1) - 置0 :
a &= ~(1 << n)(把第 n 位变成 0)
知识拓展
位操作符直接操作内存中的二进制位,是C语言"接近硬件"特性的重要体现。按位与&常用于掩码操作------将特定位清零或提取特定位。按位或|用于将特定位设置为1。按位异或^的一个关键性质是可逆性:a ^ b ^ b = a,这是简单加密算法的理论基础。按位取反~与移位组合使用可实现灵活的位操作。n & (n-1)是最经典的位操作技巧,用于判断2的幂以及统计二进制中1的个数。位操作在嵌入式编程、网络协议、图像处理等对性能敏感的领域发挥重要作用。
总结
位操作符允许程序员直接操作整数的二进制位:&(按位与)用于清零或掩码提取,|(按位或)用于置位,^(按位异或)相同为0不同为1,~(按位取反)逐位翻转。异或的性质(与自身异或得0、与0异或得自身、满足交换律和结合律)可用于无临时变量交换。n & (n-1)操作可去除n二进制表示中最右边的1。置位技巧:置1用a |= (1 << n),置0用a &= ~(1 << n)。
六、单目操作符
单目操作符 :只需要一个操作数的操作符。
c
! // 逻辑取反
++ // 自增
-- // 自减
& // 取地址
* // 解引用(和指针相关)
+ // 正号
- // 负号
~ // 按位取反
sizeof // 计算大小
(类型) // 强制类型转换
其中 & 和 * 跟指针相关,后面学指针时再详细讲解。
知识拓展
单目操作符的优先级较高,其中++和--的前置和后置形式有重要区别:前置形式先自增减再取值,后置形式先取值再自增减。sizeof是一个编译时操作符(不是函数),对表达式求大小不会执行该表达式。sizeof对数组名使用时得到的是整个数组的字节数(不退化),而对指针使用得到的是指针本身的大小(4或8字节)。强制类型转换(类型)在转换指针时应格外小心,不当的转换可能导致对齐问题或未定义行为。
总结
单目操作符只操作一个操作数。!逻辑取反,++自增,--自减,&取地址,*解引用,+正号,-负号,~按位取反,sizeof计算类型或变量所占字节数,(类型)强制类型转换。其中++和--有前置和后置两种形式,理解其区别对正确编写表达式至关重要。sizeof在编译时计算,不会对表达式求值。
七、逗号表达式
语法: 用逗号隔开的多个表达式
c
exp1, exp2, exp3, ..., expN
规则: 从左向右依次执行,整个表达式的结果是最后一个表达式的结果。
c
// 代码1
int a = 1;
int b = 2;
int c = (a > b, a = b + 10, a, b = a + 1);
// ① a > b → 1 > 2 → 0(不关心这个结果)
// ② a = b + 10 → a = 2 + 10 = 12
// ③ a → 12(不关心)
// ④ b = a + 1 → b = 12 + 1 = 13(这是整个表达式的结果)
// c = 13
逗号表达式的实用场景:
c
// 不用逗号表达式
a = get_val();
count_val(a);
while (a > 0)
{
// 业务处理
a = get_val();
count_val(a);
}
// 用逗号表达式,更紧凑
while (a = get_val(), count_val(a), a > 0)
{
// 业务处理
}
}
知识拓展
逗号表达式在C语言中引入了序列点(sequence point)------在逗号处,左侧表达式的所有副作用都已完成后,才执行右侧表达式。这使得逗号表达式在宏定义中非常有用,可以在一个表达式内安全地执行多个操作。逗号表达式的优先级在所有操作符中最低,因此使用时通常需要加括号。在for循环中,逗号表达式常用于同时更新多个循环变量或在循环条件中进行多个操作。
总结
逗号表达式依次从左到右计算每个子表达式,整个表达式的结果为最后一个表达式的值。逗号表达式常用于:for循环中同时操作多个变量、宏定义中组合多个语句、或需要在一个表达式位置执行多个操作的场景。逗号表达式的优先级最低,使用时注意加括号。
八、下标访问和函数调用操作符
8.1 下标引用操作符 []
c
int arr[10]; // [] 用于创建数组
arr[9] = 10; // [] 用于访问数组元素
[] 的操作数有两个:数组名 和 下标值。
8.2 函数调用操作符 ()
c
#include <stdio.h>
void test1()
{
printf("hehe\n");
}
void test2(const char* str)
{
printf("%s\n", str);
}
int main()
{
test1(); // () 作为函数调用操作符
test2("hello bit."); // () 作为函数调用操作符
return 0;
}
() 接受一个或多个操作数:第一个是函数名 ,其余是传递给函数的参数。
知识拓展
数组下标引用操作符[]在C语言中定义为:arr[i]等价于*(arr + i)。由于加法满足交换律,因此arr[i]也等价于i[arr],这虽然看起来奇怪但是在语法上是合法的。函数调用操作符()接受一个函数名或函数指针作为第一个操作数。当通过函数指针调用函数时,(*ptr)(args)和ptr(args)是等价的。函数调用时,参数的求值顺序由编译器决定,在C语言中是未指定的行为。
总结
下标引用操作符[]接受数组名和下标值两个操作数,本质是通过指针算术运算访问内存。函数调用操作符()接受函数名(或函数指针)和参数列表,执行函数并获取返回值。[]的实质是*(arr + i),因此arr[i]和i[arr]等价。理解这些操作符的本质有助于深入掌握数组和指针的关系。
九、结构成员访问操作符
9.1 什么是结构体?
C语言提供了一些内置类型 (如 int、char、double 等),但有时候我们需要描述更复杂的对象,比如一个"学生"或"一本书"。
结构体 就是用来解决这个问题的------它允许我们把不同类型的变量组合在一起,形成一个新的自定义类型。
c
// 描述一个学生
struct Stu
{
char name[20]; // 名字(字符串)
int age; // 年龄(整数)
char sex[5]; // 性别
char id[20]; // 学号
}; // 注意分号不能少!
9.2 结构体变量的定义和初始化
c
// 定义结构体变量
struct Stu s1 = {"张三", 20, "男", "2024001"};
// 指定顺序初始化
struct Stu s2 = {.age = 20, .name = "李四", .sex = "男", .id = "2024002"};
// 结构体嵌套
struct Point
{
int x;
int y;
};
struct Node
{
int data;
struct Point p; // 结构体里嵌套另一个结构体
struct Node* next; // 指向自己的指针(链表用)
};
9.3 直接访问结构体成员(使用 . 操作符)
c
#include <stdio.h>
struct Point
{
int x;
int y;
};
int main()
{
struct Point p = {1, 2};
printf("x: %d y: %d\n", p.x, p.y); // 使用 . 访问成员
p.x = 10; // 修改成员
p.y = 20;
printf("x: %d y: %d\n", p.x, p.y);
return 0;
}
9.4 间接访问结构体成员(使用 -> 操作符)
当我们有一个指向结构体的指针 时,用 -> 来访问成员:
c
#include <stdio.h>
struct Point
{
int x;
int y;
};
int main()
{
struct Point p = {3, 4};
struct Point* ptr = &p; // ptr 是一个指向结构体的指针
ptr->x = 10; // 等价于 (*ptr).x = 10
ptr->y = 20; // 等价于 (*ptr).y = 20
printf("x = %d y = %d\n", ptr->x, ptr->y);
return 0;
}
9.5 综合示例
c
#include <stdio.h>
#include <string.h>
struct Stu
{
char name[15];
int age;
};
// 打印学生信息(直接传结构体)
void print_stu(struct Stu s)
{
printf("%s %d\n", s.name, s.age);
}
// 修改学生信息(传指针,才能修改原数据)
void set_stu(struct Stu* ps)
{
strcpy(ps->name, "李四"); // 字符串复制
ps->age = 28;
}
int main()
{
struct Stu s = {"张三", 20};
print_stu(s); // 输出:张三 20
set_stu(&s); // 传地址,才能修改
print_stu(s); // 输出:李四 28
return 0;
}
知识拓展
结构体的内存布局涉及数据对齐(alignment)------编译器会在结构体成员之间插入填充字节(padding),使每个成员对齐到其自然对齐边界(sizeof(类型)的倍数)。对齐的原因:CPU访问对齐数据的效率远高于访问非对齐数据。可以使用#pragma pack(n)或__attribute__((packed))来控制对齐方式。结构体成员访问时,.操作符直接访问,->操作符先解引用再访问,两者的差异对应于值语义和指针语义的区别。
总结
结构体是C语言中允许将不同类型变量组合成新类型的自定义类型。定义结构体使用struct关键字,用花括号括起成员列表,末尾分号不能省略。访问结构体成员有两种方式:直接访问用.操作符;通过指针间接访问用->操作符。结构体成员在内存中可能因对齐要求而存在填充字节,影响结构体的总大小。
十、操作符的属性和表达式求值
10.1 优先级
优先级 决定当表达式中包含多个操作符时,谁先算谁后算。
c
3 + 4 * 5; // 先算 4*5=20,再算 3+20=23(乘法的优先级高于加法)
10.2 结合性
当两个操作符的优先级相同 时,由结合性决定计算顺序:
- 左结合:从左到右执行(大部分操作符)
- 右结合 :从右到左执行(如赋值
=)
c
5 * 6 / 2; // * 和 / 优先级相同,左结合,先算 5*6=30,再算 30/2=15
常用操作符的优先级(从高到低):
()圆括号(最高)++--自增自减+-(单目正负号)*/乘除+-加减<>等关系运算符=赋值(最低之一)
💡 如果不确定优先级,就用
()圆括号明确指定顺序!
10.3 整型提升
什么是整型提升?
C语言中,小于 int 长度的整型 (如 char、short)在运算时,会先被提升为 int 类型再进行计算。
c
char a, b, c;
// ...
a = b + c; // b 和 c 先被提升为 int,然后相加,结果截断后存入 a
提升规则:
- 有符号数:按符号位提升(正数补0,负数补1)
- 无符号数:高位补0
c
char c1 = -1; // 二进制:11111111
// 提升为 int:
// 11111111 11111111 11111111 11111111(补符号位1)
char c2 = 1; // 二进制:00000001
// 提升为 int:
// 00000000 00000000 00000000 00000001(补符号位0)
unsigned char c3 = 255; // 二进制:11111111
// 无符号数提升为 int:
// 00000000 00000000 00000000 11111111(高位补0)
10.4 算术转换
当操作符的两个操作数类型不同 时,会进行算术转换,把"级别低"的类型转换为"级别高"的类型:
long double ← 最高
double
float
unsigned long int
long int
unsigned int
int ← 最低
例如:int 和 double 运算,int 会先转为 double。
10.5 问题表达式分析
即使知道了优先级和结合性,有些表达式的结果仍然不确定,这种代码应该避免。
表达式1:
c
a * b + c * d + e * f;
优先级只能保证 * 比 + 先算,但不能保证是 (a*b) + (c*d) 先算,还是 (c*d) + (e*f) 先算。不同的求值顺序结果一样还好,但如果涉及到函数调用可能有副作用。
表达式2(有歧义):
c
c + --c;
--c 肯定比 + 先算,但是 + 的左边操作数 c 是在 --c 之前取值还是之后取值?不确定!
表达式3(极其复杂,不同编译器结果不同):
c
int i = 10;
i = i-- - --i * (i = -3) * i++ + ++i;
这个表达式在不同的编译器上会有不同的结果,千万不要写这样的代码!
表达式4(函数调用顺序不确定):
c
int fun()
{
static int count = 1;
return ++count;
}
int main()
{
int answer;
answer = fun() - fun() * fun(); // 函数调用的先后顺序不确定
printf("%d\n", answer);
return 0;
}
表达式5(前置++的求值时机不确定):
c
int i = 1;
int ret = (++i) + (++i) + (++i);
不同的编译器(gcc vs VS2022)结果不同,因为无法确定第一个 + 执行时第三个 ++i 有没有执行。
10.6 总结
即使有了操作符的优先级和结合性,有些表达式仍然可能得不到唯一结果。不要写出过于复杂的表达式!
好习惯:
- 用括号明确表达式的计算顺序
- 一行代码里不要对同一个变量多次修改
- 不要写出依赖于特定编译器求值顺序的代码
知识拓展
表达式求值中的"未定义行为"(Undefined Behavior, UB)是C语言中需要特别警惕的问题。UB意味着标准允许程序做任何事情------可能崩溃、可能产生错误结果、也可能看起来正常工作。常见的UB场景包括:同一个变量在两个序列点之间被多次修改、数组越界访问、解引用空指针、整数溢出等。现代编译器在遇到UB时可能进行激进的优化,导致程序行为与预期严重不符。静态分析工具(如Clang Static Analyzer、Coverity)和编译器警告(-Wall -Wextra)可以帮助发现潜在的UB。
总结
操作符的属性包括优先级(决定不同操作符的计算顺序)、结合性(决定同优先级操作符的计算方向)和求值顺序。即使理解了优先级和结合性,某些表达式仍然可能产生不确定的结果。整型提升是将short和char类型提升为int后再进行运算;算术转换是将低精度类型转换为高精度类型。良好的编程习惯是:用括号明确优先级、避免对同一变量多次修改、不编写依赖特定编译器实现细节的表达式。
总结
| 知识点 | 要点 |
|---|---|
| 操作符分类 | 算术、移位、位、赋值、单目、关系、逻辑、条件、逗号等 |
| 进制转换 | 二进制、八进制、十进制、十六进制互转 |
| 原反补码 | 正数相同,负数需要转换,内存存补码 |
| 移位操作符 | << 左移(乘2),>> 右移(除2) |
| 位操作符 | & ` |
| 异或特性 | a ^ a = 0,a ^ 0 = a,可用于交换变量 |
n & (n-1) |
去掉最右边的1,用于统计二进制中1的个数 |
| 结构体 | 自定义类型,用 . 或 -> 访问成员 |
| 优先级和结合性 | 决定表达式的计算顺序 |
| 整型提升 | 小于 int 的类型在运算时先提升为 int |
| 算术转换 | 不同精度的类型运算时,低精度转高精度 |
| 表达式求值 | 避免写太复杂、有歧义的表达式 |
