1. 为什么运算符值得单独写一篇笔记很多人学 C 的时候对运算符的态度就是这玩意儿还用学加减乘除谁不会。我一开始也这么想直到有次写一个图像处理的 Demo用位运算做像素掩码结果因为搞混了和调试了整整一个下午才发现问题。从那以后我就明白运算符这东西看着简单坑却不少而且越是基础的东西越容易在关键时刻咬你一口。这篇笔记就是把我这些年对 C 运算符的理解系统整理一遍。算术、关系、逻辑、位运算、赋值再加上几个容易被忽略的其它运算符基本上覆盖了日常写代码会碰到的全部。不管你是刚入门的新手还是写了几年代码但想回头夯实基础的老手这篇内容都能帮你把运算符这块的知识点理清楚。我会尽量用生活化的类比来解释那些抽象的概念同时把每个运算符背后的原理、优先级规则、常见陷阱都讲透让你看完之后不只是知道有这么个东西而是真正能在项目里用对、用好。运算符本质上是 C 提供给我们的工具箱每个工具都有它的适用场景和脾气。用对了事半功倍用错了轻则逻辑错误重则程序崩溃。所以别小看这些符号它们是你和编译器沟通的基本语言。2. 算术运算符不只是加减乘除那么简单2.1 五个基本算术运算符的实际行为C 的算术运算符有五个加、-减、*乘、/除、%取模。前四个大家都很熟悉但%取模运算符是新手最容易忽略的。取模就是求余数比如7 % 3结果是 1因为 7 除以 3 商 2 余 1。这里有个关键点整数除法和浮点除法行为完全不同。如果你写5 / 2结果是 2 而不是 2.5因为两个操作数都是整数C 会执行整数除法直接截断小数部分。想要得到 2.5你必须写成5.0 / 2或者5 / 2.0让至少一个操作数是浮点类型。int a 5, b 2; double result1 a / b; // 结果是 2.0不是 2.5 double result2 a / 2.0; // 结果是 2.5 double result3 static_castdouble(a) / b; // 结果是 2.5这个坑我见过太多人踩。尤其是做数据分析或者图形计算的时候一个整数除法就能让你的结果完全偏离预期。我的建议是只要涉及除法先想清楚你要的是整数结果还是浮点结果拿不准就显式转换。取模运算符%只能用于整数类型浮点数是不能取模的。如果你需要对浮点数做类似取模的操作得用fmod()函数。取模的典型应用场景包括判断奇偶n % 2 0就是偶数、循环队列的索引计算、哈希函数等等。2.2 自增自减的前缀与后缀之争和--这两个运算符有前缀和后缀两种形式i和i。很多教程只说前缀是先加后用后缀是先用后加但实际写代码的时候这个区别在大多数场景下并不影响结果。真正需要注意的是性能差异和在复杂表达式中的行为。对于内置类型int、double 这些编译器优化之后前缀和后缀的性能几乎没差别。但对于自定义类型比如迭代器前缀形式通常更高效因为后缀形式需要先创建一个临时对象保存原值再自增最后返回临时对象。所以在写循环的时候我习惯性地用i而不是i算是一个好习惯。// 对于迭代器前缀更高效 for (auto it vec.begin(); it ! vec.end(); it) { // ... } // 后缀在需要原值的场景才用 int i 5; int j i; // j 5, i 6 int k i; // i 7, k 7在复杂表达式里混用自增自减是未定义行为的重灾区。比如int x i i;这种写法不同编译器可能给出不同结果因为 C 标准没有规定表达式中各部分的求值顺序。我的经验是一个表达式里最多只对一个变量做一次自增或自减这样代码可读性好也不会踩未定义行为的坑。2.3 算术运算中的类型转换陷阱C 的算术运算有一套寻常算术转换规则如果两个操作数类型不同会先把它们转换成能容纳两者值的最大类型。比如int double会先把 int 转成 double结果是 double。这个规则本身没问题但结合有符号和无符号类型就会出问题。unsigned int a 10; int b -5; if (a b 0) { // 你以为会进这里实际上不会 }上面这段代码a b的结果是unsigned int类型-5会被转换成一个非常大的正数所以a b实际上是一个巨大的正数条件成立。这种 bug 极其隐蔽我建议在涉及无符号类型的运算时格外小心能不用无符号就不用除非你确实需要它表示的范围。提示编译器通常会给出-Wsign-compare之类的警告别忽略这些警告它们往往能帮你提前发现类型转换的坑。3. 关系运算符与逻辑运算符条件判断的基石3.1 六个关系运算符的返回值真相关系运算符有六个等于、!不等于、大于、小于、大于等于、小于等于。它们返回的是bool类型true或false。在 C 里true会被转换成 1false转换成 0所以你也可以把关系运算的结果赋给 int 变量但不建议这么做可读性差。新手最容易犯的错误是把写成。if (a b)和if (a b)是完全不同的意思前者是赋值后者是比较。更坑的是if (a b)在语法上是合法的如果 b 非零条件就成立编译器可能只给个警告。我的习惯是把常量写在左边写成if (0 a)这样如果不小心写成if (0 a)编译器会直接报错因为不能给常量赋值。浮点数的相等比较是另一个大坑。由于浮点数精度问题0.1 0.2 0.3在大多数情况下是false。正确的做法是判断两个浮点数的差值是否小于一个很小的阈值epsilon。#include cmath #include limits bool almostEqual(double a, double b) { return std::fabs(a - b) std::numeric_limitsdouble::epsilon() * 100; }这个 epsilon 取多少要看你的精度需求std::numeric_limitsdouble::epsilon()是机器精度实际比较时通常会乘以一个系数因为累积误差可能比单次运算的误差大。3.2 逻辑运算符的短路求值特性逻辑运算符有三个逻辑与、||逻辑或、!逻辑非。它们最重要的特性是短路求值对于如果左边是false右边就不会执行对于||如果左边是true右边也不会执行。这个特性可以用来做安全检查也可以用来写简洁的代码// 安全检查先判断指针非空再解引用 if (ptr ! nullptr ptr-value 0) { // ... } // 利用短路做条件执行 bool success (file.open(data.txt) processFile(file));但短路求值也有副作用如果右边的表达式有副作用比如函数调用、自增它可能不会执行。这在写代码时要想清楚别指望右边的副作用一定会发生。逻辑运算符的操作数会被转换成bool。在 C 里非零值转true零值转false。所以if (ptr)等价于if (ptr ! nullptr)if (count)等价于if (count ! 0)。这种写法很常见但要注意if (ptr)判断的是指针本身是否为空不是它指向的值。3.3 逻辑运算符与位运算符的混淆问题和、||和|是两组完全不同的运算符。和||是逻辑运算结果是bool和|是位运算对整数的每一位进行操作。新手经常把写成虽然在某些情况下结果碰巧一样但语义完全不同。int a 4, b 2; bool logicResult a b; // true因为 4 和 2 都非零 int bitResult a b; // 0因为 100 010 000a b判断的是a 和 b 是否都非零a b是对 a 和 b 的二进制位做与运算。这两个结果可能完全不同用错了就是逻辑错误。我的经验是判断条件用和||操作二进制位用和|别混。4. 位运算性能优化的利器4.1 六个位运算符的原理与用途位运算符有六个按位与、|按位或、^按位异或、~按位取反、左移、右移。它们直接操作整数的二进制位是底层编程和性能优化的常用工具。按位与的规则是两个都是 1 才是 1常用来做掩码操作提取或清除某些位。比如x 0xFF可以取出 x 的最低 8 位。按位或|的规则是有一个是 1 就是 1常用来设置某些位。按位异或^的规则是不同为 1相同为 0常用来翻转某些位还有一个经典用途是不借助临时变量交换两个数。// 交换两个数不推荐在实际项目中使用可读性差 int a 5, b 3; a ^ b; b ^ a; a ^ b; // 现在 a 3, b 5按位取反~把每一位都翻转0 变 11 变 0。左移把二进制位向左移动右边补 0相当于乘以 2 的 n 次方。右移把二进制位向右移动对于无符号数是逻辑右移左边补 0对于有符号数是算术右移左边补符号位。运算符名称规则典型用途按位与都为 1 才为 1掩码、清零|按位或有一个 1 就为 1置位^按位异或不同为 1翻转位、加密~按位取反0 变 11 变 0求补码左移左移补 0乘以 2 的幂右移右移补符号位或 0除以 2 的幂4.2 位运算的实战场景与性能考量位运算在嵌入式开发、图形处理、网络协议解析、哈希算法里用得非常多。举个实际例子在图像处理中一个像素的 RGBA 值可以打包成一个 32 位整数用位运算可以快速提取或修改某个通道。// 假设 color 是 0xAARRGGBB 格式 uint32_t color 0xFF3366CC; uint8_t alpha (color 24) 0xFF; // 0xFF uint8_t red (color 16) 0xFF; // 0x33 uint8_t green (color 8) 0xFF; // 0x66 uint8_t blue color 0xFF; // 0xCC这种操作比用结构体或者数组要快得多因为位运算直接对应 CPU 指令没有额外的内存访问开销。但代价是可读性下降所以只在性能敏感的代码里用位运算普通业务逻辑还是用清晰的结构体更好。左移和右移有个需要注意的地方移位的位数不能超过类型宽度。比如对 32 位整数左移 32 位是未定义行为结果不可预测。另外左移有符号数如果溢出也是未定义行为所以做移位操作时最好用无符号类型。注意1 31对于 int 是未定义行为因为 1 是 int左移 31 位会溢出。应该写成1u 31或者1LL 31。4.3 位运算的常见陷阱与调试技巧位运算最容易出错的地方是优先级。位运算符的优先级比关系运算符和逻辑运算符都低所以if (x 0x0F 0)实际上会被解析成if (x (0x0F 0))结果是if (x 0)永远是 0。正确的写法是if ((x 0x0F) 0)加括号。另一个坑是符号位扩展。对有符号数做右移时如果最高位是 1右移会在左边补 1导致结果变成负数。如果你想要逻辑右移先把数转成无符号类型。int x -8; // 二进制 ...11111000 int y x 1; // 算术右移结果 -4 unsigned int ux static_castunsigned int(x); unsigned int uy ux 1; // 逻辑右移结果 2147483644调试位运算问题的时候我习惯用十六进制打印中间结果因为十六进制和二进制转换直观每 4 位二进制对应 1 位十六进制。用std::hex或者printf(%x, value)都能快速查看。5. 赋值运算符与复合赋值5.1 基本赋值与链式赋值赋值运算符把右边的值赋给左边的变量。赋值表达式本身也有值值就是被赋的那个值所以可以链式赋值a b c 5从右往左执行c 先被赋 5然后 b 被赋 c 的值最后 a 被赋 b 的值。链式赋值看起来简洁但可读性不好尤其是变量类型不同的时候容易出问题。我一般只在初始化多个同类型变量时用比如x y z 0。赋值运算符的左边必须是一个左值也就是可以取地址的表达式。常量、字面量、表达式结果都不能放在左边。5 a是编译错误a b c也是编译错误。5.2 复合赋值运算符的妙用复合赋值运算符把算术运算或位运算和赋值合并在一起包括、-、*、/、%、、|、^、、。a b等价于a a b但前者更简洁而且对于复杂表达式只求值一次。// 两种写法等价但复合赋值更高效 vec[i * 2 1] vec[i * 2 1] 10; // 索引计算了两次 vec[i * 2 1] 10; // 索引只计算一次复合赋值还有一个好处是避免类型转换问题。a b会把结果转换成 a 的类型再赋值而a a b会先做寻常算术转换可能得到更大的类型再赋给 a 时又转换回来。虽然最终结果通常一样但复合赋值的行为更明确。5.3 赋值与初始化的区别新手经常搞混赋值和初始化。初始化是在变量创建时给它一个初始值赋值是变量已经存在后修改它的值。对于基本类型两者差别不大但对于类类型差别就大了初始化调用构造函数赋值调用赋值运算符。int a 5; // 初始化 int b; // 默认初始化 b 5; // 赋值 std::string s1 hello; // 初始化调用构造函数 std::string s2; // 默认构造 s2 hello; // 赋值调用赋值运算符在成员初始化列表里只能用初始化不能用赋值。这也是为什么构造函数推荐用初始化列表的原因之一避免先默认构造再赋值的额外开销。6. 其它运算符容易被忽略但很有用的角色6.1 条件运算符的三目玩法条件运算符? :是 C 里唯一的三目运算符格式是条件 ? 值1 : 值2。条件为真返回值1否则返回值2。它常用来替代简单的 if-else让代码更紧凑。int max (a b) ? a : b; std::string status (score 60) ? pass : fail;条件运算符的优先级很低仅高于赋值运算符所以嵌套使用时要注意加括号。另外两个分支的类型要能转换成共同的类型否则可能出问题。比如cond ? 1 : 2.5的结果是 double因为 1 会被转成 double。我个人的经验是简单的二选一用条件运算符复杂的逻辑还是用 if-else。条件运算符嵌套超过两层就该考虑重构了可读性太差。6.2 逗号运算符与 sizeof逗号运算符,会依次执行两边的表达式返回右边表达式的值。它最常见的用途是在 for 循环里同时更新多个变量。for (int i 0, j 10; i j; i, --j) { // ... }逗号运算符的优先级是所有运算符里最低的所以a b, c会被解析成(a b), ca 得到 b 的值整个表达式的值是 c。这个特性容易让人困惑实际项目里我很少主动用逗号运算符除了 for 循环。sizeof是一个运算符不是函数返回类型或对象占用的字节数。sizeof(int)通常是 4sizeof(char)一定是 1。sizeof的结果类型是size_t一个无符号整数类型。注意sizeof对数组返回的是整个数组的字节数不是元素个数元素个数要用sizeof(arr) / sizeof(arr[0])。6.3 成员访问与作用域运算符成员访问运算符.和-用来访问对象的成员。.用于对象本身-用于指针。ptr-member等价于(*ptr).member但前者更简洁。作用域运算符::用来指定名字所属的命名空间或类。std::cout表示 std 命名空间里的 coutMyClass::staticVar表示 MyClass 类的静态成员。在类成员函数定义里void MyClass::func() {}表示 func 是 MyClass 的成员。struct Point { int x, y; }; Point p{1, 2}; Point* ptr p; int a p.x; // 对象用 . int b ptr-x; // 指针用 - int c (*ptr).x; // 等价于 ptr-x6.4 类型转换运算符的四种形式C 有四种类型转换运算符static_cast、dynamic_cast、const_cast、reinterpret_cast。它们比 C 风格的强制转换更安全、更明确。static_cast用于相关类型之间的转换比如 int 转 double、基类指针转派生类指针下行转换不检查。dynamic_cast用于多态类型的运行时检查转换转换失败返回 nullptr指针或抛异常引用。const_cast用来移除或添加 const 属性。reinterpret_cast做低层的重新解释比如把指针转成整数非常危险除非你明确知道自己在做什么。double d 3.14; int i static_castint(d); // 3 const int ci 10; int* p const_castint*(ci); // 移除 const但修改 ci 是未定义行为 Base* base new Derived(); Derived* derived dynamic_castDerived*(base); // 运行时检查我的建议是能用 static_cast 就别用 C 风格转换能用 dynamic_cast 做多态转换就别用 static_cast。类型转换是 bug 的高发区越明确的写法越容易发现问题。7. 运算符优先级与结合性实战指南7.1 优先级速查与记忆技巧C 有几十个运算符优先级规则很复杂没人能全部记住。我的做法是记住几个关键原则剩下的靠括号。优先级从高到低大致是作用域:: 成员访问.- 后缀自增自减 前缀自增自减和!~ 乘除模 加减 移位 关系 相等 位与 位异或 位或 逻辑与 逻辑或 条件 赋值 逗号。记忆技巧单目高于双目算术高于关系关系高于逻辑逻辑高于赋值。位运算的优先级比较尴尬位与、位异或、位或夹在相等和逻辑与之间容易搞错所以位运算一定要加括号。优先级运算符结合性1::从左到右2.-[]()--后缀从左到右3!~--前缀-正负*解引用取地址sizeof从右到左4*/%从左到右5-从左到右6从左到右7从左到右8!从左到右9从左到右10^从左到右11|从左到右12从左到右13||从左到右14?:从右到左15及复合赋值从右到左16,从左到右7.2 结合性对表达式求值的影响结合性决定了同优先级运算符的求值顺序。大多数运算符是从左到右结合比如a - b - c等价于(a - b) - c。赋值运算符和条件运算符是从右到左结合所以a b c等价于a (b c)a ? b : c ? d : e等价于a ? b : (c ? d : e)。结合性只影响语法解析不影响求值顺序。比如f() g()虽然是从左到右结合但 f() 和 g() 谁先执行是不确定的。这是 C 和很多语言不同的地方也是未定义行为的来源之一。int i 0; int arr[3] {0, 0, 0}; arr[i] i; // 未定义行为arr[0] 还是 arr[1] 被赋值不确定这种代码在不同编译器上结果可能不同甚至同一编译器不同优化级别结果也不同。避免在同一个表达式里对同一变量做多次修改这是铁律。7.3 用括号消除歧义的最佳实践我的原则很简单只要不确定优先级就加括号。括号不会影响性能但能极大提升可读性减少 bug。// 不清晰的写法 if (a 0x0F 0x0F) { } // 实际是 a (0x0F 0x0F) // 清晰的写法 if ((a 0x0F) 0x0F) { } // 不清晰的写法 int x a 2 3; // 实际是 a (2 3) // 清晰的写法 int x (a 2) 3;团队协作的时候代码规范通常会要求位运算、移位运算、条件运算必须加括号。这不是啰嗦而是用最小的成本避免最大的坑。我见过太多因为优先级问题导致的线上事故加个括号就能解决的事没必要省。8. 常见问题与排查技巧实录8.1 运算符相关 bug 的排查思路运算符相关的 bug 通常有几个特征结果不符合预期、在不同编译器上表现不同、优化开关影响结果。排查的时候我会按这个顺序来检查类型是不是有符号无符号混用是不是整数除法截断了用typeid或者decltype确认表达式类型。检查优先级把复杂表达式拆成多个简单语句看结果是否变化。如果变了就是优先级问题。检查求值顺序表达式里有没有对同一变量的多次修改有没有依赖函数调用顺序有的话就是未定义行为。检查边界移位位数是否超范围整数是否溢出浮点数比较是否用了8.2 高频问题速查表问题现象可能原因解决方法除法结果总是整数两个操作数都是整数至少一个转成浮点条件判断总是 true写成了常量放左边开启编译警告浮点数比较失败精度误差用 epsilon 比较位运算结果不对优先级问题加括号右移结果是负数有符号数算术右移转成无符号再移循环里 i 性能差后缀自增创建临时对象改用 i无符号数比较出意外有符号转无符号避免混用或用有符号链式赋值结果不对类型转换拆开写明确类型8.3 几个我踩过的坑和独家经验第一个坑是用^做幂运算。很多新手以为2 ^ 3是 2 的 3 次方实际上是按位异或结果是 1。C 里没有幂运算符要用std::pow()或者自己写循环。第二个坑是**sizeof用在函数参数上**。数组作为函数参数会退化成指针sizeof(arr)在函数里返回的是指针大小不是数组大小。要传数组大小得额外传一个参数或者用std::array、std::vector。第三个坑是条件运算符的类型转换。cond ? 1 : 2.5结果是 double如果你把结果赋给 int会截断。更隐蔽的是cond ? ptr1 : ptr2如果两个指针类型不同但可以转换结果类型可能出乎意料。第四个坑是逗号运算符在宏里的行为。宏展开后逗号可能被当成参数分隔符需要用括号保护。这也是为什么现代 C 推荐用constexpr和模板替代宏。提示开启编译器的所有警告-Wall -Wextra大部分运算符相关的低级错误都能被提前发现。别嫌警告烦它们是在帮你省钱省时间。8.4 性能相关的运算符选择建议在性能敏感的代码里运算符的选择会影响效率。整数运算比浮点运算快位运算比算术运算快但这些差异在现代 CPU 上已经不那么明显了。真正影响性能的是内存访问和分支预测而不是运算符本身。不过有几个场景还是值得注意用 1代替* 2在现代编译器上没区别编译器会自动优化用位运算做掩码比用取模快因为取模涉及除法指令短路求值可以避免不必要的计算但要小心副作用。我的建议是先写清晰的代码性能问题用 profiler 定位后再优化。过早优化是万恶之源运算符级别的微优化在 99% 的场景下都没必要。9. 运算符重载的入门认知虽然这篇笔记主要讲内置运算符但既然聊到运算符就不得不提运算符重载。C 允许你为自定义类型定义运算符的行为比如给一个复数类定义给一个字符串类定义。运算符重载的本质是函数调用a b会被编译器转换成operator(a, b)或者a.operator(b)。重载的时候要遵循一些原则不能改变运算符的优先级和结合性不能创造新运算符不能重载::、.、.*、?:这几个。struct Complex { double real, imag; Complex operator(const Complex other) const { return {real other.real, imag other.imag}; } bool operator(const Complex other) const { return real other.real imag other.imag; } };运算符重载能让自定义类型用起来像内置类型一样自然但要克制使用。重载表示加法很自然重载表示字符串拼接也还行但重载表示删除元素就让人困惑了。原则是重载后的行为要符合直觉别为了炫技而重载。赋值运算符比较特殊编译器会自动生成一个默认版本做逐成员拷贝。如果类里有指针成员默认版本会做浅拷贝导致两个对象指向同一块内存析构时重复释放。这时候需要自己重载赋值运算符做深拷贝。这就是著名的三法则如果需要自定义析构函数、拷贝构造函数、拷贝赋值运算符中的任何一个通常三个都需要。10. 把运算符用对是 C 基本功的体现写了这么多其实核心就几句话算术运算注意类型转换和整数除法关系运算注意浮点比较和陷阱逻辑运算利用短路但小心副作用位运算加括号并注意符号位赋值运算区分初始化和赋值其它运算符按需使用别炫技。运算符是 C 最基础的部分也是最容易出问题的地方。我见过工作五年的工程师还在和上栽跟头也见过因为无符号数比较导致线上事故的案例。基础不牢地动山摇这话在 C 里尤其适用。最后分享一个我个人的习惯写完涉及运算符的复杂表达式后用括号把它拆清楚或者干脆拆成多个语句。代码是给人看的不是给编译器看的。编译器不在乎你写得多简洁但你的同事和三个月后的你自己会在乎。多写几行清晰的代码比写一行炫技的代码有价值得多。