ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

从操作符到运算规则:浮点数、位运算与工程实践

从操作符到运算规则:浮点数、位运算与工程实践 做技术这么多年有一个感受越来越强烈很多人对操作符的理解停留在“加减乘除那几个符号”的层面。真到了写代码、踩坑、排查问题的时候才发现同样的运算符换一种语言、换一种数据类型、甚至换一个编译器结果可能完全不一样。我见过不少求职者被i和i这种题目绕晕也见过线上事故原因是两笔金额用浮点数比较0.1 0.2不等于0.3直接导致逻辑分支判断错误。操作符看似基础背后其实藏着一整套运算规则而这些规则是理解一门语言、读懂一段代码、定位一个诡异 bug 的地基。这篇文章不打算像教材那样把每个符号罗列一遍。我想换个方式从实际场景出发把算术运算、位运算、一元操作符、模运算这些高频话题讲透再延伸到集合运算、矩阵运算、乃至机器视觉里的开闭运算和模拟电路里的运算放大电路。你会发现“运算”这个概念在不同领域里其实是一脉相承的都是“把若干输入按照特定规则变换成输出”。1. 操作符不是简单的符号而是一套运算规则1.1 操作符、操作数与运算规则先搞清楚三个基本概念操作符、操作数和运算规则。a b里是操作符a和b是操作数最终结果取决于这套“加法规则”。很多人觉得这话是废话但真正深入进去就会发现同一个在不同语言里可能有完全不同的行为。在 C 语言里两个整数相加可能溢出在 Python 里整数可以无限大在 JavaScript 里1 1会变成字符串11而在 Python 里这样写直接报错。操作符本身只是一个抽象层真正的行为由语言规范和操作数类型共同决定。这三个要素不是并列关系而是层层嵌套的关系。操作数类型决定底层采用哪套规则规则决定结果操作符只是触发这套规则的一个入口。从这个角度看操作符本质上是一种语法糖它把常见的操作封装成简单的符号让代码读起来更像数学表达式。CPU 层面根本没有这个概念只有加法指令编译器或解释器会把源码里的翻译成对应的机器指令。理解这一层很重要因为后面要讨论的所有坑几乎都源于同一个问题写代码的人默认了一套规则而语言实际采用的是另一套规则。1.2 操作数类型会改变操作符的语义类型是操作符行为的最大变量。C 语言里5 / 2结果是2因为整数除法直接截断小数部分而5.0 / 2结果是2.5因为浮点数除法会保留精度。同一个/符号仅仅因为操作数类型不同结果就不一样。更典型的是操作符。C 语言里100 200只是整数加法Python 里[1, 2] [3, 4]是列表拼接C 里两个std::string相加是字符串连接JavaScript 里3 4会触发隐式类型转换变成字符串。如果只记住“加号就是求和”遇到这些场景必然懵。这也是为什么现代语言都提供了操作符重载机制。C 和 Python 允许开发者给自定义类型定义操作符行为例如用一个Vector类重载让vector1 vector2表示向量相加。这个能力很强大但也要求使用者对操作数类型的语义足够敏感。我给团队做代码评审时经常说一句话看到一个操作符先别急着判断结果先看清楚操作数是什么类型。1.3 每个语言都有自己的一套规则不同语言的操作符体系差异非常大这恰恰是初学者最容易忽略的地方。C 语言的操作符数量多优先级和结合性复杂而且某些行为是未定义的。C 在 C 的基础上增加了操作符重载、new/delete、类型转换操作符等。Python 的操作符体系相对简洁但is、in、not in这些关键词也承担了操作符的职责。JavaScript 因为隐式类型转换机制操作符行为最“令人惊喜”[] []结果是空字符串[] {}结果是[object Object]这种反直觉的结果层出不穷。就算同一个语言不同版本也可能改变行为。C99 之前负数除法和取模的实现是未定义的C99 开始统一为向零截断。Python 2 里整数除法5 / 2结果是2Python 3 里结果是2.5必须使用//才能得到整除结果。所以排查运算相关的问题时先确认你用的语言版本和编译器版本再往下查逻辑。我个人的建议是无论多熟悉一门语言手边放一份运算符优先级表遇到不确定的组合就查一下或者干脆用括号把优先级写清楚。代码里多几个括号不会牺牲性能但能帮你和后来维护的人省掉很多猜测时间。2. 算术运算与浮点数运算0.10.2为什么不等于0.32.1 整数算术也隐藏着边界很多人觉得整数运算没什么好讲的但整数运算第一个坑就是溢出。C 语言里int通常占 32 位有符号整数范围是 -2147483648 到 2147483647。一旦累加超过上限行为可能是未定义的。实际中常见的是回绕成负数这在循环计数、积分累加场景里会造成非常隐蔽的错误。C 里int x INT_MAX; x 1;这段代码的后果是无法预测的编译器可能做任何优化。Python 的整数则没有这个问题因为 Python 的int是任意精度的自动支持大数运算。C#、Java 则提供了 checked/unchecked 机制可以在溢出时抛异常或回绕。做底层开发、嵌入式开发的人必须自己关心溢出问题而在 Python 里这个问题基本可以被忽略。整数除法的截断行为也值得注意。C 语言里5 / 2是 2-5 / 2在 C99 里是 -2向零截断而 Python 里-5 // 2是 -3向下取整。这个差异直接影响了后面要讲的负数取模。2.2 浮点数的二进制本质与精度问题先说结论二进制浮点数无法精确表示大多数十进制小数这是 IEEE 754 标准本身决定的不是 Python、C 语言或者任何一个具体实现的问题。十进制小数 0.1 转换成二进制是一个无限循环小数就像十进制里 1/3 写成 0.333... 一样。而 double 类型只有 52 位尾数实际是 53 位有效位因为有一个隐式的前导 1所以 0.1 存进去的时候就已经被舍入成一个非常接近但不完全等于 0.1 的数。两个这样的近似数相加误差就会显性化。print(0.1 0.2) # 0.30000000000000004 print(0.1 0.2 0.3) # FalseC 语言里同样的情况0.1 0.2 0.3的结果同样是 false。这个不是 bug是浮点数表示方式的必然结果。真正的 bug 是写出这段比较代码的人他没有意识到浮点数是近似值。实际项目中因为浮点数比较翻车的案例太多了。我见过有人用 double 存金额然后判断两个余额是否相等。也见过做数值计算时累计了几千步之后误差被放大到不可接受的程度。这些都是对浮点数本质认识不够导致的。2.3 浮点数实战比较、累计与金额处理浮点数操作有几个实用经验。比较两个浮点数时不要用用容差比较。Python 里可以直接用math.iscloseC 语言里手写一个绝对值差小于 epsilon 的判断。import math # 不要这么写 if 0.1 0.2 0.3: pass # 应该这么写 if math.isclose(0.1 0.2, 0.3, rel_tol1e-9): passepsilon 怎么选经验值是1e-9到1e-6之间具体看你的数据量级。如果数据本身是千万级绝对误差可能已经大于 1e-9这时候要采用相对误差rel_tol比abs_tol更可靠。累计误差的解决方案是改变运算顺序。大量小数相加时先把数值按从小到大排序或者用 Kahan 求和算法。但如果你的场景是金融计算我会直接建议不要用浮点数用整数分单位存储或者用 Python 的decimal.Decimal。金额领域精度就是生命线用浮点数就是在给未来埋雷。3. 位运算直接操作二进制的操作符3.1 位运算基础与工程实战位运算全家桶包括按位与、按位或|、按位异或^、按位取反~、左移、右移。它们直接在二进制位上进行操作执行效率高很多高级技巧都建立在它们之上。实际开发中我最常用位运算的场景有三个。第一个是状态标记和权限系统。一组布尔权限可以压缩成一个整数用位来表示每个权限位。READ 0b001 WRITE 0b010 EXEC 0b100 # 赋予权限 permission READ | EXEC # 检查是否有写权限 if permission WRITE: print(no write permission)第二个是奇偶判断。n 1比n % 2更快这个优化虽然在现代编译器里意义不大但在嵌入式开发里依旧有效。第三个是用异或实现交换变量。a ^ b; b ^ a; a ^ b;这三行可以不用临时变量交换两个整数。这个方法现在更多作为面试题出现因为现代编译器对普通交换的优化已经非常好没有必要为了炫技牺牲可读性。3.2 Python和C语言位运算的差异Python 和 C 语言在位运算上有几个关键差异踩过一次坑的人都会印象深刻。第一个差异是整数精度。C 语言的整数是固定位宽的比如int是 32 位左移超出位宽会直接丢失高位数据。Python 的整数是任意精度的1 100可以正常得到 1267650600228229401496703205376没有溢出问题。第二个差异是负数的位运算行为。在 Python 里直接对负数做位运算时Python 会把它当成无限长的补码形式处理。比如~5的结果是 -6因为 5 的补码按位取反后符号位变成了 1对应的值就是 -6。很多初学者第一次看到这个结果都会怀疑人生。第三个差异是右移。C 语言里无符号整数右移一定是逻辑右移高位补零而有符号整数的右移行为是实现定义的不过绝大多数编译器的实现都是算术右移高位补符号位。Python 里负数右移同样采用算术右移-3 1的结果是 -2不是 1。这个结果等价于-3 // 2因为算术右移本身就是向下取整的除法。3.3 位运算的注意事项位运算虽然快但可读性一直是痛点。我见过一段代码用一堆和从一个 32 位整数里提取颜色通道作者写了详细注释还好没写注释的话后来的维护者估计要花半天时间反推。建议是适当封装。把位运算提取成有名字的函数或常量而不是到处裸写魔法数。还有一点位运算不要和逻辑运算混淆。、||是短路逻辑运算返回布尔值、|是按位运算返回整数。C 语言里1 2结果是 11 2结果是 0完全不同的东西。写成if (a b)和if (a b)在二进制含义上天差地别。4. 一元操作符与模运算i的坑和负数取模的争执4.1 一元操作符全景与自增自减陷阱一元操作符是只需要一个操作数的操作符常见的有正号、负号-、逻辑非!、按位取反~、自增、自减--、解引用*、取地址。最容易踩坑的是和--。前缀形式i是先自增再返回新值后缀形式i是先返回旧值再自增。这个区别单独考没问题组合起来就容易出乱子。int i 1; printf(%d %d, i, i);这段代码在 C 语言里是未定义行为输出什么全看编译器的实现心情。原因在于函数参数的求值顺序在 C 语言标准里没有规定i的两次求值修改了同一个变量中间没有序列点隔离。类似这种“利用副作用构造表达式”的写法在任何语言里都应该被消灭。编辑器和团队规范里通常会禁止这类代码。C 的 Google 风格指南里也建议不要依赖自增自减的返回值除了必要的迭代器操作之外。我自己写 C/C 代码时i和i只作为独立语句使用绝不嵌入其他表达式这样从根源上避开问题。还有一个特殊案例是 Python。Python 没有和--操作符很多人误写i会直接报语法错误。因为 Python 里i实际上是两个正号一元运算符叠在一起相当于(i)不会产生自增效果。4.2 标准C模运算负数取余的结果到底是哪个模运算也是操作符世界里争议最多的一块。C 语言的%操作符明确规定操作数必须是整数结果是整除后的余数。但负数的余数符号取决于除法结果的取整方向。C99 标准规定整数除法向零截断因此余数的符号和被除数保持一致。所以-5 / 2 // 结果是 -2因为向零截断 -5 % 2 // 结果是 -1因为 -5 2 * (-2) (-1)Python 里的%行为完全不同。Python 的//是向下取整除法所以-5 // 2 # 结果是 -3因为趋近负无穷方向取整 -5 % 2 # 结果是 1因为 -5 2 * (-3) 1同一个表达式-5 % 2C 语言给出 -1Python 给出 1这就是“标准 C 模运算”和“Python 模运算”的核心差异。哪种是对的两种在各自的数学体系里都自洽问题在于你要明确自己所在语言使用的是哪套规则。遇到负数取模不要靠记忆直接写一小段代码验证。特别是做循环数组索引、循环队列时负数取模的结果直接决定数组下标是否越界。模运算的正数场景也有应用价值。循环队列里经常用(head 1) % capacity来推进队首位置这个公式在 capacity 是 2 的幂时可以改成(head 1) (capacity - 1)效率更高。哈希计算也大量使用取模用质数做模数往往能减少碰撞这一经验在小学哈希表设计里仍然有效。4.3 模运算的进阶伽罗华域里的乘法如果以为模运算只有整数取余那就太小看它了。模运算可以推广到多项式上这就引出通信和密码学里极其重要的伽罗华域也叫有限域。伽罗华域 GF(2^m) 里的元素是多项式系数只有 0 和 1运算结果超出范围后对某个不可约多项式取模。听起来抽象但 AES 加密算法的所有字节运算都建立在 GF(2^8) 上CRC 校验的核心也是多项式取模。AES 标准文档里有一个经典例子0x57 乘以 0x83在 GF(2^8) 下结果等于 0xC1。这里不是普通的整数乘法而是先把两个字节看作多项式相乘之后再对不可约多项式 x^8 x^4 x^3 x 1 取模。理解伽罗华域的乘法有助于理解为什么 AES 的 S 盒计算那么复杂为什么某些加密算法能被快速实现成查表操作。这类运算在普通业务开发里很少直接碰到但它是安全领域的地基。如果你做通信协议、加密算法或者纠错码模运算的理解深度直接决定你能不能看懂协议标准。5. 从集合运算到矩阵运算运算符在数学结构上的扩展5.1 传统集合运算Python的、|、-、^集合运算在数学里是并集、交集、差集Python 直接用操作符实现了这套语义。A {1, 2, 3} B {2, 3, 4} A | B # 并集 - {1, 2, 3, 4} A B # 交集 - {2, 3} A - B # 差集 - {1} A ^ B # 对称差集 - {1, 4}这里|、、-、^原本在整数上分别是按位或、按位与、减法和按位异或一旦操作数是集合语义就变成集合运算了。背后的逻辑是自洽的集合本质上可以用二进制位来表示位运算和集合运算在数学上就是同构的。这也是为什么、|会被复用在集合上。数据库里的传统集合运算也是同样道理。SQL 的 UNION、INTERSECT、EXCEPT 分别对应并集、交集、差集。SQL 的 UNION 默认还会去重相当于集合语义。理解集合运算的核心就是记住“元素的归属判断”它比任何逐条遍历都更接近数学定义。5.2 矩阵运算乘法不是逐元素相乘矩阵运算是另一个“运算符复用”的典型场景。工程上最常见的工具是 NumPy但很多用了多年 NumPy 的人会踩同一个坑混淆*和。import numpy as np a np.array([[1, 2], [3, 4]]) b np.array([[2, 0], [1, 3]]) a * b # 逐元素乘积也叫 Hadamard 积 # 结果: # [[2 0] # [3 12]] a b # 矩阵乘法 # 结果: # [[4 6] # [10 12]]*是逐元素运算要求两个矩阵形状一致是线性代数里的矩阵乘法要求第一个矩阵的列数等于第二个矩阵的行数。二者运算规则和几何意义完全不同。矩阵乘法不满足交换律A B通常不等于B A。这个性质让很多从算术思维过来的人很不适应。旋转矩阵相乘的顺序直接决定最终旋转效果先绕 X 轴再绕 Y 轴跟先绕 Y 轴再绕 X 轴结果完全不同。图形学、机器人学里搞错矩阵乘法顺序模型就会扭曲得不成样子。矩阵运算和前面讲的操作符重载是天然搭档。C 的 Eigen 库、Python 的 NumPy 都是重载了大量操作符让矩阵表达式写起来像数学公式一样简洁。但越是简洁越要清楚每个符号背后的数学含义。5.3 自定义类型也能有运算C链表的运算符重载C 里的链表本身没有运算符但你可以通过重载让链表参与运算。我自己写链表练习时会重载operator来实现两个链表的拼接重载operator[]来实现按下标访问节点这样使用起来语义直观。struct Node { int val; Node* next; Node(int v, Node* n nullptr) : val(v), next(n) {} }; // 拼接两个链表 Node* operator(Node* a, Node* b) { if (!a) return b; Node* p a; while (p-next) p p-next; p-next b; return a; } // 初始化两个链表 Node* listA new Node(1, new Node(2)); Node* listB new Node(3, new Node(4)); Node* combined listA listB;这里重载的是指针运算符严格来说不是标准做法但它展示了操作符重载的思路把“遍历链表再修改尾指针”的内部细节隐藏起来让调用者直观地写下listA listB。需要注意的是重载运算符不是越多越好。如果一个运算符的语义很容易被误解就要慎重。比如给链表重载operator-究竟表示删除一个节点还是求链表差集如果不明确就不要重载。好的运算符重载让代码贴近自然语义坏的重载只会增加心智负担。6. 机器视觉开闭运算图像世界里的操作符与参数原理6.1 腐蚀与膨胀两种最基础的形态学操作机器视觉里也有一组“运算”叫形态学运算最基础的是腐蚀和膨胀。你可以把它们理解成图像处理领域的操作符输入是图像和结构元素输出是变换后的图像。结构元素是一个小的二值模板通常是一个矩形、十字形或圆形区域。腐蚀操作就是让结构元素在图像上滑动取结构元素覆盖区域内的最小值作为输出像素值。对于二值图像一个像素被保留的条件是结构元素覆盖的所有像素都是前景色所以腐蚀会让前景区域缩小一圈消除细小毛刺。膨胀正好相反取覆盖区域内的最大值前景区域会扩大填平小凹陷。有一个容易混淆的点腐蚀是对白色前景区域的腐蚀。如果你的图像前景是黑色效果就会反着来。处理前的二值化方向会影响你对结果的理解。6.2 开运算与闭运算组合出来的“修图”操作符开运算和闭运算是腐蚀和膨胀的组合。开运算是先腐蚀后膨胀闭运算是先膨胀后腐蚀。开运算的作用是去除小的噪声点、断开临近物体之间纤细的连接同时尽量保持大物体的大小不变。先腐蚀把毛刺和孤立点消掉再膨胀把剩余部分恢复原样整体效果就像给图像做了一次“去杂点”。闭运算是先膨胀填平小空洞和裂缝再腐蚀把整体轮廓恢复适合用来填充前景物体内部的空洞、连接断裂的线条。选择口诀可以这么记要去除多余就开运算要补齐缺失就闭运算。实际场景里OCR 之前的图像预处理经常用开运算去掉笔画上的噪声医学图像和工业缺陷检测里闭运算用来把断裂的目标区域连成整体。6.3 开闭运算的核心参数怎么选搞懂开闭运算并不难难的是参数调优。关键参数有两个结构元素的大小和迭代次数。结构元素大小直接决定影响范围。3x3 的核只影响每个像素周围的 8 邻域适合去除细小噪点5x5 或 7x7 的核影响范围更大但会把一些原本正常的细小结构也抹掉。我的经验是先小后大、逐步尝试。能用 3x3 解决的就不要上 5x5。结构元素的形状同样重要。矩形核实现快但对对角方向的结构影响可能不够圆滑十字形核更适合处理十字交叉形状的目标圆形核各向同性最好但计算量大一些。做细胞计数时圆形的核能避免把相邻细胞错误合并这是我在实际项目里的切身体会。迭代次数等价于连续做多次相同操作。但要注意两次 3x3 腐蚀的效果不等于一次 6x6 腐蚀前者能更精细地控制腐蚀程度。实际调参时固定核大小用迭代次数微调通常比反复换核大小更可控。OpenCV 里的调用方式很直接import cv2 import numpy as np img cv2.imread(sample.png, cv2.IMREAD_GRAYSCALE) kernel np.ones((3, 3), np.uint8) opened cv2.morphologyEx(img, cv2.MORPH_OPEN, kernel, iterations1) closed cv2.morphologyEx(img, cv2.MORPH_CLOSE, kernel, iterations1)形态学运算本质上也是操作符思想在图像空间的延伸输入是一张图和一个小模板输出是一张新图。理解了这一点你会发现不同领域的“运算”底层逻辑是相通的。7. 运算放大电路模拟电路里实现“加减乘除”的硬件7.1 运算放大器为什么叫“运算”放大器运算放大器简称运放是模拟电路里最常见的有源器件之一。名字里的“运算”来自它的历史角色早期模拟计算机用它来实现加法、减法、积分、微分等数学运算。运放是一个高增益直流放大器理想情况下输入阻抗无穷大、输出阻抗为零、开环增益无穷大。实际运放当然达不到这些指标但工程分析时通常会采用“虚短”和“虚断”两条近似原则。虚短的意思是运放两个输入端之间的电压差近似为零因为开环增益极大输出电压有限时输入必然很小虚断的意思是运放输入端几乎不吸取电流因为输入阻抗极高。基于这两条原则配合外部电阻网络就能实现各种精确的数学关系。这就是运放被称为“运算放大器”的原因它不再仅仅是一个放大电路而是一个可以通过外围电路配置行为的功能模块。7.2 反相比例运算电路最经典的实用电路反相比例运算电路是运放最基础的接法之一输出与输入成比例并且相位相反。电路结构是这样输入信号Vin通过电阻R1接到运放的反相输入端反相输入端通过反馈电阻Rf接到输出端同相输入端直接接地。利用虚短和虚断分析同相端接地所以反相端电位也是 0这个点称为虚地。由于运放输入端不吸取电流流过 R1 的电流完全流向 Rf。于是I Vin / R1 Vout -I * Rf -Vin * Rf / R1所以增益是-Rf / R1。负号表示反相输入正电压输出负电压输入负电压输出正电压。举例来说R1 取 10kΩRf 取 100kΩ增益就是 -10 倍。输入 1V 直流电压理想情况下输出 -10V。实际搭电路时如果你用的是 ±12V 电源-10V 完全可以输出没什么问题。但如果输入 2V理想输出 -20V超过了负电源轨输出就会饱和在接近 -12V 的地方。这个削顶现象是你动手调电路时第一个会遇到的坑。7.3 实际搭建反相比例电路的注意事项原理计算很简单但实际搭出来的运放电路能不能达到设计指标还要看很多细节。第一电源必须加去耦电容。每个运放的电源引脚和地之间就近放置一个 0.1μF 的瓷片电容大电流开关噪声会引起振荡去耦电容能显著改善稳定性。我见过不少电路原理完全正确但波形乱跳最后发现就是缺了这几个电容。第二注意输入偏置电流的影响。真实运放两个输入端总会有微小的偏置电流流入或流出。为了平衡这两股电流造成的失调电压常规做法是在同相输入端到地之间接一个平衡电阻阻值等于 R1 和 Rf 的并联值。如果运放是 MOSFET 输入的偏置电流极小这个电阻的影响没那么大双极型输入的运放则必须重视。第三运放的增益带宽积决定了频率上限。运放的开环增益随频率升高而下降增益调得越高可用带宽就越窄。比如某运放的增益带宽积是 1MHz你把它接成 10 倍增益实际有效带宽大概只有 100kHz。处理高频信号时要特别关注这个参数不能只看直流特性。第四电阻精度直接影响增益精度。要求 1% 以内的增益就要用 1% 精度的金属膜电阻普通 5% 碳膜电阻受温度影响漂移更大在高精度直流放大场景里会带来不小的误差。反相比例电路看似简单却是学习运放的最佳入门案例。你把这一条电路的推导和实际搭建跑通后面再看同相放大、差分放大、加法器、积分器会发现都是同一套虚短虚断的分析方法只是外围元件的接法不同而已。最后说点个人的体会。很多人觉得操作符是入门第一课不值得反复钻研。但这些年做开发、带新人、查线上事故我发现半数以上的疑难杂症最终都会归结到某个运算规则没搞清楚浮点数比较、负数取模、自增自减副作用、矩阵乘法顺序、位运算的符号位处理这些看似细微的点每一个都能酿成线上事故。我自己也养成了一个习惯凡是不确定的运算行为永远不凭直觉猜先写一小段代码打印出来看结果几秒钟就能验证的事不值得赌。希望这篇梳理能帮你把操作符和运算的底层逻辑串起来以后再遇到相关问题时能少踩几个坑。
返回列表