1. 先搞清楚运算符到底是什么很多初学者刚开始学C语言看到一堆 - * / %、 !、 ||就头大觉得这是在背符号表。其实没这么复杂。运算符本质上是“对数据做操作的记号”你写的每一行代码几乎都离不开它们。你不需要死记硬背只需要理解每一类运算符“解决什么问题”然后多用几次就熟了。我见过不少新手问“为什么我写的程序结果不对”排查到最后往往是运算符优先级搞错了或者是把赋值和比较混用了。这类问题在C语言里极其常见因为C的运算符数量多、优先级层级也多而且很多符号长相相似含义却完全不同。这篇文章我就把C语言里的几大类运算符——算术、赋值、关系、逻辑、位、逗号、三元、下标、指针相关——全部过一遍重点讲它们在真实代码里怎么用、有哪些坑、以及怎么避免踩坑。不用急着把整篇文章看完你可以把它当工具书。写代码时遇到不确定的运算符回来看一眼对应小节就行。我还会穿插一些我实际调试代码时遇到的案例这些经验比教科书上的定义要实在得多。2. 算术运算符不止加减乘除2.1 基础四则运算算术运算符就是 - * /这个谁都知道。但C语言里有一个非常容易出错的点整数除法。int a 7; int b 2; int c a / b; // 结果是 3不是 3.5两个整数相除结果还是整数小数部分直接丢掉不是四舍五入是“截断”。这一点新手常常搞不明白写7 / 2期望得到3.5结果拿到个3。如果你想得到小数结果至少要有一个操作数是浮点类型float c 7.0 / 2; // 结果 3.5 float d 7 / 2.0; // 结果 3.5 float e (float)7 / 2; // 结果 3.5我当年第一次写计算器程序就是栽在这里。用户输入两个整数相除无论怎么算都是整数我还以为是自己逻辑写错了。后来才意识到整型除法的截断特性。所以如果你要精确计算要么用浮点数要么先把其中一个操作数强制转换成浮点。2.2 取余运算符的细节取余%只适用于整数不能用于浮点数。它得到的是整除后的余数。int r1 7 % 2; // 1 int r2 10 % 5; // 0 int r3 -7 % 2; // C语言中结果为 -1符号跟随被除数关于负数取余不同语言规则不太一样。C语言里-7 % 2的结果是-1因为结果是-(7 % 2)。如果你用7 % -2结果是1。这个细节容易考也容易在算法题里用错。记住一句话余数的符号和被除数一致。取余的实际应用太多了。判断奇偶用n % 2 0循环中做分页用i % pageSize时间换算比如把总秒数转成分钟和秒也离不开它。我写一个倒计时程序时就经常用取余把秒数拆成“分:秒”的格式。2.3 自增自减 和 -- 的前后置差异这可能是C语言里最经典的“坑王”。i和i的区别几乎面试必问实际代码也处处是陷阱。i先返回 i 的旧值再让 i 自增。i先让 i 自增再返回新的 i。看代码int i 5; int a i; // a 5i 变为 6 int b i; // i 先变为 7然后 b 7这种前中后置的差异在单独使用时没影响但一放进表达式里就会让人头晕。比如int i 3; int x i i;这种代码在C语言里属于“未定义行为undefined behavior”因为你在同一个表达式中多次修改同一个变量的值编译器的处理方式可能不一样。不同编译器、不同优化等级跑出来的结果可能都不同。我自己的建议是永远不要写这种“高深”的代码。一行代码能让别人看懂比显摆技巧重要得多。如果确实需要先自增再使用那就老老实实分开写i; int x i;这样没有任何歧义也不会被编译器“坑”。自增自减最常见的场景是循环和数组遍历for (int i 0; i n; i) { printf(%d , arr[i]); }这里的i其实就是循环每轮结束后让 i 加1你完全可以把i改成i单独使用效果一样。个人习惯用i不过在一些C风格里推荐i因为避免创建临时对象。在C语言里这种性能差异基本可以忽略。3. 赋值运算符别把“赋值”当成“等于”3.1 单个等号 是赋值C语言用单个表示赋值才表示判断相等。这几乎是所有新手犯得最多的一类错误。看下面这段int a 5; if (a 3) { printf(条件成立了\n); }这里的if (a 3)不是判断 a 是否等于 3而是把 3 赋给 a然后赋值表达式的值就是 3非零所以条件判断为真。结果就是不管 a 原来是多少只要这个赋值操作能执行if 一定会进去。这种 bug 特别隐蔽尤其是当a原本就是个变量你忘了写双等号编译器可能只给个警告不报错。我在实际项目里就遇到过同事在判断里少打了一个等号导致整个逻辑反过来的事故。后来我们约定如果判断对象是常量就把常量写在左边比如写if (3 a)。这样万一漏写一个等号变成if (3 a)编译器直接报错因为不能给常量赋值。这个习惯叫“左值常量”虽然刚开始看起来别扭但能救你一命。3.2 复合赋值运算符、-、*、/、%这类复合赋值本质是“先运算再赋值”的简写。int sum 0; sum 10; // sum sum 10 sum * 2; // sum sum * 2复合赋值不但省字而且语义清晰。更关键的是它在求值时只计算一次左侧的表达式而展开写法可能会计算两次。看这个例子arr[i] 5;这行代码的意思是把arr[i]的值加上5再放回arr[i]。因为复合赋值只对左侧地址求值一次所以这里的i只执行一次。如果你展开成arr[i] arr[i] 5那么i会执行两次行为就变了。所以复合赋值不光是简洁还能避免某些重复求值带来的副作用。当然我依然不建议把自增放进数组下标里既难读又容易出错。还有一个细节赋值运算符的优先级很低比算术和比较都低。所以写a b c;时是先把b c算完再赋值给 a。这个大家都懂但要注意在复杂表达式里别想当然加括号int x (y 5) 3; // y 被赋值为 5x 为 8这种“在表达式中赋值”的写法虽然合法但可读性很差除非特殊情况否则不推荐。3.3 链式赋值的运行逻辑C语言允许连续赋值int a, b, c; a b c 10;这个从右往左执行先把10赋给 c然后 c 的值赋给 b再赋给 a。因为赋值表达式的值就是赋完后的值所以链式赋值能顺利传递下去。用这个方法初始化多个变量很方便但要注意类型转换问题int a; double b; a b 3.8;执行顺序是b 3.8b 变成 3.8然后a b因为 a 是 int所以 a 变成 3小数被截断。如果你写b a 3.8那 a 先被赋值 3截断然后 b 再赋值 3精度就没有了。所以链式赋值时要想清楚每个变量的类型转换。4. 关系运算符比较后得到真或假关系运算符包括!它们的计算结果是一个整数条件成立为1不成立为0。在C语言早期没有专门的布尔类型就是用 0 表示假非0 表示真。到了C99引入了_Bool但本质上还是整数。关系运算符的坑主要在两个地方浮点数比较连续比较的写法先看浮点数。因为浮点数在计算机里是二进制近似存储的所以0.1 0.2 0.3这个判断很可能是 false。实际开发中比较浮点数要用“差值绝对值小于某个很小阈值”的方式double a 0.1 0.2; double b 0.3; if (fabs(a - b) 1e-9) { printf(相等\n); }fabs是数学库里的绝对值函数需要#include math.h链接时要加-lmLinux下。这个技巧在做物理计算、图形处理时特别常用。再说连续比较。数学中写0 x 10表示 x 介于0和10之间。但C语言里这么写会出乎意料int x 20; if (0 x 10) { printf(x在0和10之间\n); }这个条件居然会成立原因在于运算符是从左往右结合的0 x先算得出1或0然后拿这个结果和10比较(0 20)得11 10成立所以整个表达式为真。哪怕 x 是200也一样成立。正确的写法应该是if (x 0 x 10) { printf(x在0和10之间\n); }这种错误我见过不止一两个刚接触C语言的同学中招。数学思维和C语法要划清界限写条件判断时一定要拆成逻辑与。关系运算符还有一个细节比较两个字符串时千万不要直接写str1 str2。这个比较的是字符串的首地址不是内容。两个不同的字符数组哪怕内容完全一样地址也不同结果一定是 false。比较字符串要用strcmp函数if (strcmp(str1, str2) 0) { printf(两个字符串相同\n); }strcmp返回0表示相等这一点也和直觉相反很多新手会写成if (strcmp(str1, str2))表示相同那就反了。记住判断字符串相等看 strcmp 结果是不是 0。5. 逻辑运算符、||、! 的短路规则逻辑运算符处理的是“真/假”判断结果是 1 或 0。是逻辑与||是逻辑或!是逻辑非。它们最大的特点就是“短路求值”。5.1 短路求值是怎么回事a b如果 a 为假那么整个表达式一定为假b 不管是什么都不影响结果所以编译器根本不去执行 b 的代码。a || b如果 a 为真整个表达式一定为真b 不再执行。这个特性非常有用很多代码会利用它来避免无效操作。比如if (ptr ! NULL ptr-value 10) { // 安全只有ptr非空才会访问它的成员 }如果不用短路求值当 ptr 为 NULL 时第二个条件ptr-value会崩溃。但有了短路规则ptr ! NULL为假后面的代码不会执行就不会崩溃。再比如if (i n arr[i] 0) { // 不会越界访问 }这种写法比嵌套 if 更简洁。很多老手喜欢用这种“短路式保护”。5.2 逻辑运算里的常见坑第一个坑是位运算符和逻辑运算符混淆。是逻辑与是位与||是逻辑或|是位或。它们完全不同。比如int a 3; // 二进制 11 int b 2; // 二进制 10 int c a b; // 结果是 2 int d a b; // 结果是 1因为a和b都非零新手常在条件里把打成程序可能碰巧也能跑但逻辑是完全不同的。位运算是按每一位进行“与”逻辑运算是整体真值判断。第二个坑是把!和~搞混。!是逻辑非!a在 a 为0时得1a非0时得0~是位取反把每个二进制位都翻转。~0的结果是所有位全是1在32位int下是-1而!0的结果是1。两码事。第三个坑是用代替还是那个老问题。if (a 5 b)这种写法先执行a 5再执行... b5非零所以为真。代码自己把自己绕进去了。建议大家写复杂条件时把每个判断都用括号括起来既清晰又能避免优先级错误。5.3 逻辑表达式的结果是整数C语言逻辑运算的结果是 int 类型1 或 0。这个结果也可以参与其他运算int x (10 5) (20 5); // x 1 1 2这种写法在算法题里偶尔会见到比如统计一个数组里满足多个条件的元素个数int count 0; for (int i 0; i n; i) { count (arr[i] 0) (arr[i] % 2 0); }不过可读性一般更推荐用 if 累加。写代码首先要让别人能看懂其次才是炫技。6. 位运算符二进制层面的运算位运算符包括按位与、|按位或、^按位异或、~按位取反、左移、右移。位运算直接操作二进制位效率极高在嵌入式、图形学、协议解析、系统编程里经常用到。6.1 按位与、或、异或的应用按位与常用于掩码操作。比如你想读取一个字节里的低4位可以用value 0x0F想判断某个变量第3位是不是1可以用value (1 3)。unsigned char flag 0b10101100; if (flag (1 3)) { // 判断第3位 printf(第3位为1\n); }按位或|用于置位把某个位置1。比如flag | (1 5);把第5位设为1其他位不变。按位异或^特点神奇相同为0不同为1。最经典的应用是“不用临时变量交换两个整数”int a 5, b 9; a a ^ b; b a ^ b; a a ^ b; // 交换后 a9, b5这个操作虽然炫酷但在实际工程里不推荐因为可读性差而且当 a 和 b 指向同一个内存位置时会出现问题变成0。老老实实用临时变量才是正道。异或另一个常见用途是加密一个数据异或同一个密钥两次会恢复原值。在网络传输的简单校验里也常见。6.2 移位运算符的效果左移是低位补0相当于乘以2的幂右移对于无符号数是高位补0对于有符号数逻辑右移 vs 算术右移在不同编译器上有区别大多数实现是算术右移即符号位填充相当于除以2的幂向下取整。unsigned int x 8; x 2; // 结果是32相当于8 * 4 x 1; // 结果是4相当于8 / 2移位运算比乘除法快所以早期优化代码时会用x 1代替x * 2。现在的编译器优化已经很聪明会自己把乘以2的幂转为移位所以你写乘法更清晰不必刻意用移位。移位时要注意位移位数不能大于等于类型的位宽。比如 int 是32位x 32属于未定义行为不能这么写。常用方式是把位移量对位宽取模但C标准里不是这么规定的所以尽量避免。位运算符的优先级比较低在复杂表达式里要加括号。比如x y 0会先执行y 0再按位与。如果你想判断(x y) 0必须写成(x y) 0。这类优先级坑我后面整理成表方便你查。7. 三元运算符简单条件赋值的捷径C语言里唯一的三目运算符是?:格式条件 ? 表达式1 : 表达式2如果条件为真整个表达式的结果是表达式1的值否则是表达式2的值。比如int max (a b) ? a : b;这比写 if-else 简洁得多。三元运算符通常用在简单的二选一赋值场景不建议嵌套太多层。万一写个a ? b : c ? d : e虽然合法但读代码的人会抓狂。我自己的规范是优先级低于 if-else可读性优先。如果你的逻辑超过两层分支直接用 if-else 或者 switch。三元运算符还有一个“类型统一”的问题。比如printf(%d\n, 1 ? 3.5 : 2);结果是3double转int因为编译器会把两个表达式统一成相同类型。这里结果是3不是3.5。这种隐式类型转换很容易带来精度问题写的时候要留意。三元运算符可以嵌套在宏定义里写一些短小的最大值、最小值宏#define MAX(a, b) ((a) (b) ? (a) : (b))注意宏里每个参数都要加括号避免展开出错。比如MAX(1 2, 3)如果没加括号会变成(1 2 3) ? (1 2) : (3)结果不对。8. 逗号运算符一个被忽略的“顺序执行”逗号运算符,在C语言里的作用是从左到右依次执行两个表达式整个表达式的值是最后一个表达式的值。最常见的场景是在 for 循环中初始化多个变量for (int i 0, j 10; i j; i, j--) { // ... }这里i 0, j 10是逗号表达式两个操作数按顺序执行。i, j--也是。逗号运算符的值是最后一个表达式所以下面这个例子int x (1, 2, 3); // x 等于 3但这种写法没有实际意义只是为了展示。你在写代码时逗号运算符通常只出现在 for 循环头部或宏的多步骤操作中。注意区分“变量声明中的逗号”和“逗号运算符”。int a, b;这里的逗号是分隔符不是逗号运算符。int x (a, b);则是运算符。逗号运算符的优先级是所有运算符里最低的比赋值还低。所以写a 1, b 2;实际上是先执行赋值 a1再执行 b2整行结果等于2。如果你写成x (y 3, y 2);则 x 等于5。这里逗号表达式先执行y 3然后整个逗号表达式的结果是y 2即5赋给 x。9. 其他重要运算符下标、指针、取地址与长度9.1 下标运算符 [ ]数组下标arr[i]的本质是*(arr i)。也就是说arr[3]完全等价于*(arr 3)也等价于3[arr]虽然这样写很诡异。在C语言里下标运算符实际上就是加法加解引用。理解这一点有助于理解为什么数组下标可能越界时不报错。arr[i]只是按地址计算编译器不会检查 i 是否在合法范围内。越界访问会读到内存中其他位置的数据这叫未定义行为。所以你要自己保证下标合法int arr[10]; for (int i 0; i 10; i) { arr[i] i * i; // i 不超过 9 }如果i写成 10就会越界把 arr[10] 这个本不存在的元素写进去可能导致程序崩溃或数据错乱但有时候又不崩让 bug 时隐时现特别难排查。我调试过一个项目就是循环边界写多了一位导致相邻变量被覆盖整个模块的行为变得莫名其妙。这种问题用肉眼很难看后来用内存检测工具才定位到是越界写。9.2 取地址 和解引用 *作为一元运算符是取地址*作为一元运算符是解引用也叫间接访问。这两个和指针紧密绑定。int a 10; int *p a; // p 保存 a 的地址 printf(%d\n, *p); // 访问 p 指向的内存得到10 *p 20; // 修改 a 的值为20和*在声明中和表达式中含义不同。声明里int *p表示声明 p 是一个指针表达式中*p表示取出 p 指向位置的值。初学者容易混淆“声明指针”和“解引用”。还需要注意不能用于常量或表达式比如(a 1)是非法的因为a 1是一个临时值没有内存地址严格来说是右值。只能返回一个对象的地址如变量、数组元素、结构体成员等。9.3 sizeof 运算符sizeof是一个运算符不是函数编译时就会计算返回对象或类型占用的字节数。注意用法int a; size_t s1 sizeof(a); // 变量不需要括号 size_t s2 sizeof(int); // 类型需要括号sizeof的结果类型是size_t通常是无符号整型打印用%zuC99标准。用%d打印在某些平台上类型不匹配会有警告。计算数组元素个数是sizeof的重要应用int arr[] {1, 2, 3, 4, 5}; int n sizeof(arr) / sizeof(arr[0]);这里sizeof(arr)是整个数组的字节数sizeof(arr[0])是首元素的字节数相除得到元素个数。这个写法非常常用但只适用于“数组变量”不适用于“函数参数”。因为数组作为函数参数时会退化成指针sizeof(arr)就变成指针大小你再除以元素大小结果就完全不对了。我遇到很多同事在函数里用这个方法计算数组长度得到1或者2之类的奇怪数字就是因为忘了指针退化。9.4 结构体成员运算符访问结构体成员有两种方式点.和箭头-。当你有一个结构体变量时用.struct Student { char name[20]; int age; }; struct Student stu; stu.age 18;当你有一个结构体指针时用-struct Student *p stu; p-age 19; // 等价于 (*p).agep-age就是把(*p).age简化了。这个箭头符号本质上也是“解引用成员访问”的合体。很多新手搞不清什么时候用-记住一个诀窍如果左边是结构体变量用点如果左边是结构体指针用箭头。10. 运算符优先级速查一张表记住重点C语言的运算符优先级一共分为15级左右。我当年学的时候也头疼后来发现不需要死记全部只需要记住几个关键层级和结合方向即可。下面是我整理的最容易出错的优先级关系运算符优先级高→低结合性()[]-.最高从左到右!~--(正)-(负)*(解引用)(取地址)sizeof高从右到左*/%较高从左到右-较高从左到右中从左到右中从左到右!中从左到右中低从左到右^中低从左到右中低低从左到右?:很低从右到左-等最低之一从右到左,最低从左到右容易踩的优先级坑我列几个常见例子*p等价于*(p)因为优先级高于*。它先让指针 p 自增然后返回自增前指向的值。如果你想要指针指向的值自增得写(*p)。a b 0等价于a (b 0)因为优先级高于。如果你想判断(a b) 0必须加括号。(x x 1) 0需要用括号把赋值包起来因为赋值优先级低。不要写成x x 1 0那等价于x (x 1 0)。* a是先取地址再解引用结果就是 a 本身。 *p就是 p 指向的变量的地址结果相当于 p。我的建议是碰到多种运算符混在一起的表达式先停下来想一下优先级不确定的地方一律加括号。括号不会降低执行效率但能大大减少误解。11. 实际操作中总结的几条经验光看理论不够我把这些年写C代码实际踩过的坑和总结的经验放在这里你可以直接拿走用第一写复杂条件时每个二元操作符都加括号。除了最简单的一层运算尽量不要依赖优先级。比如if ((a 0x0F) 0x0F (b 2) 5) { // ... }这样无论谁来看都不需要回忆优先级表。第二区分好“赋值”和“相等”。我的做法是一切条件判断里把常量放左边if (NULL ptr) { ... } while (0 strcmp(s1, s2)) { ... }这个习惯一开始会有点别扭但能有效防止少写等号。第三涉及浮点数比较时永远不要用 。要么使用误差范围要么转成整数比较。比如计算金额时用整数分元×100来存储比浮点数精确得多。第四位运算时尽量使用无符号类型。有符号数右移是算术右移还是逻辑右移由编译器决定容易有平台差异而且位运算结果可能变成负数逻辑更复杂。用unsigned int或unsigned char明确语义。第五运算符表达式不要写太复杂。我见过的代码里一行超过30个字符的表达式半年后作者自己都看不懂了。所以不管是三元运算、自增自减还是位运算拆成多行、多步会让维护成本大大降低。12. 一个完整的综合小例子最后给你一段样例代码把这些运算符串起来用一下覆盖算术、赋值、关系、逻辑、位、三元、下标和指针#include stdio.h int main(void) { int numbers[] {12, 5, 23, 8, 17}; int n sizeof(numbers) / sizeof(numbers[0]); int sum 0; int even_count 0; int max numbers[0]; for (int i 0; i n; i) { sum numbers[i]; if ((numbers[i] 1) 0) { // 用位运算判断偶数 even_count; } if (numbers[i] max) { max numbers[i]; } } double avg (double)sum / n; // 强制转换避免整数除法 // 逻辑运算判断和是否大于50且偶数个数不小于2 int ok (sum 50) (even_count 2); // 三元运算根据 ok 选择提示 const char *msg ok ? 满足条件 : 不满足条件; printf(数组元素个数: %d\n, n); printf(总和: %d\n, sum); printf(平均值: %.2f\n, avg); printf(最大值: %d\n, max); printf(偶数个数: %d\n, even_count); printf(结果: %s\n, msg); return 0; }这段代码里(double)sum / n是强制类型转换避免了整数除法截断numbers[i] 1用位运算判断奇偶是逻辑短路?:做了条件字符串选择。你可以试着把里面某些算符换成容易搞错的形式比如把换成观察结果变化这样能加深理解。我个人实际带新人时通常会让他们做一个小练习写一个程序输入一个整数输出它的二进制表示中1的个数汉明重量。这个题只需要用到移位、按位与、循环做一遍就能把位运算符和优先级理解透。你也可以试试。运算符这关过去了C语言就算入了门。别指望一次记住所有规则多写代码、多踩坑、回头看这篇文章慢慢就内化了。