ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从Code Review看反直觉代码:位运算与算法背后的精妙设计

从Code Review看反直觉代码:位运算与算法背后的精妙设计 上个月做Code Review我看到同事提交的一个方法第一反应是写这个方法的人真是个不折不扣的大啥春儿这个梗出自《哆啦A梦》里胖虎的口头禅后来在程序员圈子里专门用来形容那种“第一眼看过去觉得对方脑子有坑看懂之后发现是自己脑子有坑”的代码。那是一个统计二进制位中1个数的函数总共四行用了位运算我当时盯着看了五分钟差点在评审意见里写“这写法是否有必要”。后来冷静下来把n (n - 1)这个操作的数学原理捋了一遍才发现人家根本不是乱写而是用了一个非常经典的布莱恩·克尼根算法。这篇文章不聊具体某个项目就聊聊那些让我们在Code Review时血压升高、想骂人的代码以及它们背后真正值钱的设计思路。同时也会分享我怎么从“看到怪代码就吐槽”变成“先搞清楚为什么这么写”的过程。1. 第一眼血压升高的位运算一个四行函数背后的数学规律1.1 这段代码为什么容易让人想骂人先说让我差点开喷的那段代码。需求很简单给定一个非负整数n统计它的二进制表示里有多少个1比如n13二进制是1101结果应该是3。大多数人的第一反应是def count_ones(n): return bin(n).count(1)转换成字符串再数一下逻辑清晰谁都能看懂。但同事交上来的代码长这样def count_ones(n): count 0 while n: n n - 1 count 1 return count没有bin没有字符串没有循环遍历每个二进制位就一个看起来莫名其妙的和自身减1做按位与的运算。第一次读的时候我心里想的是为啥不直接转字符串这么写是不是在炫技万一n是0呢while条件直接不成立返回0这倒没错。但问题是一个本来初中生都能读懂的需求写成这样是要闹哪样这种情绪在Code Review里太常见了。人对自己不熟悉的东西第一反应往往是“有问题”而不是“我不懂”。位运算在日常业务代码里出现频率低大部分人是能不碰就不碰所以一旦有人用了就容易被认为是“不讲武德”。1.2 拆开看n (n - 1)到底干了什么要理解这个函数先理解一个看似废话但极其重要的规律对于任意正整数n表达式n (n - 1)的作用是消去n的二进制表示中最右边那个1。举个例n 13二进制1101。n - 1 12二进制1100。二者做按位与1101 1100 110013的二进制是1101最右边的1在第一位上被消掉了变成了1100也就是12。下一步n 12二进制1100n - 1 11二进制10111100 1011 100012的二进制是1100最右边的1在第三位上消掉之后变成1000也就是8。再下一步n 8n - 1 71000 0111 0000到这里循环结束一共执行了3次正好等于二进制里1的个数。为什么 n (n - 1) 会有这个效果因为一个整数减1的时候二进制里最右边的那个1会变成0而它右边所有本来就是0的位全部变成1。比如1000减1变成0111最右边的1在第四位减1后这一位变0右边三位从0变成1。这时候用原来的数跟减1后的数做按位与原本最右边的那个1因为两边一个1一个0被归零而它右边的那些位原本是0现在对面变成1按位与结果仍是0所以这些位全部归零。综合下来整个数的变化只有一位最右边的1变成0其他位保持不变。这个过程每循环一次就消掉一个1循环次数就代表1的个数。不需要遍历整个整数的位数只遍历1的个数。对稀疏的二进制数来说这种做法的效率优势非常明显。1.3 两种做法的真实性能对比我用Python实测过统计一个32位整数的1个数循环跑100万次两种方案的耗时差别有多大字符串方案: 约1.8秒 位运算方案: 约0.5秒字符串方案慢在三件事转换成字符串需要做一次进制转换分配内存count方法要扫描整个字符串每次循环创建临时对象。位运算方案全程只操作整数没有内存分配没有临时对象。更关键的是当数据量变大时差距会更明显。你需要在一个很大的数字数组里统计每个元素的二进制1个数字符串方案的性能瓶颈会直接拖垮整个接口。位运算方案因为循环次数等于1的个数对于值为0的元素循环直接就跳过了这个特性在稀疏数组场景下非常实用。我还遇到过更极端的场景一个分布式存储系统的数据分片校验代码里需要快速计算一堆哈希值的汉明距离。那里的代码就是用这类位运算技巧做基础组件的因为每多一次字符串转换就意味着多一次内存分配和GC压力。在高并发、低延迟的系统里这种差别是实打实的成本差异。写这个方法的人不是不知道字符串方案而是他清楚这个函数会被调用多少次也很清楚调用链路上对性能的要求。他选择了一种看起来不直观但在这个场景下更合理的方式。问题在于他没有在注释里说明这一点。2. 三个“装傻”的高频套路递归、短路求值与单行正则位运算只是冰山一角。实际开发中还有很多代码写法第一眼让人血压升高理解之后觉得确实有道理。我把它们归成三类递归、短路求值、单行正则。这三类在Code Review里引起的争议最大。2.1 递归不是绕圈子是“分形的世界观”几乎每个程序员都经历过看到递归想骂人的阶段。一个方法自己调用自己这在第一次接触的时候很容易被认为是死循环。看一个例子遍历目录树并找出所有.txt文件def find_txt_files(directory): result [] for item in os.listdir(directory): path os.path.join(directory, item) if os.path.isfile(path) and item.endswith(.txt): result.append(path) elif os.path.isdir(path): result.extend(find_txt_files(path)) return result初看这个函数的人会想函数里调用了自己这不就是无限循环吗程序怎么知道什么时候停答案是递归方法必须同时具备两个条件——递归条件和基准条件。在上面的代码里递归条件是“如果path是目录”基准条件是“如果是文件就直接append”。一个普通的文件路径最终会走到基准条件调用链自然会终止。看起来像绕圈子实际上是对“目录层级”这个结构最自然的建模。目录本身就是树形结构每个目录的展开方式和父目录完全一样。用循环去模拟树形遍历需要手动维护一个栈代码反而更长、更容易出错def find_txt_files_iterative(directory): result [] stack [directory] while stack: current stack.pop() for item in os.listdir(current): path os.path.join(current, item) if os.path.isfile(path) and item.endswith(.txt): result.append(path) elif os.path.isdir(path): stack.append(path) return result递归版本和栈版本的核心逻辑是一样的递归调用时系统会把当前函数的执行上下文压入调用栈循环版本则是把目录压入自己的栈。两者本质上是同一件事。区别在于递归版本更贴合“目录展开”的思维模型栈版本更贴合机器执行细节。递归真正需要警惕的只有两点一是没有基准条件的递归会栈溢出二是递归深度太深时也容易爆栈。对于目录树、树形菜单、JSON嵌套结构这类天然是层级分明的数据用递归是最稳的做法。递归不是绕圈子是“分形世界观”局部和整体遵循同样的规则这是计算机科学里少有的能够用几行代码表达出复杂结构的工具。2.2 短路求值一行代码省掉三层if嵌套短路求值在JavaScript、Python、C#里太常见了但很多人看到链式写法时还是会愣一下。假设你要写一个功能从用户对象里拿地址再从地址里拿城市名如果中间任何一层是null就返回“未知”。初学者会这么写function getCityName(user) { if (user user.address) { return user.address.city || 未知; } return 未知; }这已经不错了至少判空了。但高手可能会写const getCityName (user) user?.address?.city ?? 未知;等等一个问号加一个点两个问号这是什么玩意儿这就是可选链和空值合并操作符它把上面四行逻辑压缩成了一行。原理在于JavaScript的短路求值规则表达式从左往右计算一旦能确定整个表达式的结果就不再计算后续部分。user?.address?.city的意思是只要user是null或undefined整个表达式立即返回undefined后面的.address、.city都不会执行。如果user存在但address是null同理返回undefined。只有在user和address都存在时才会去取city的值。后面的?? ‘未知’更妙当左侧结果是null或undefined时整个表达式返回右侧的“未知”否则返回左侧结果。这种写法的价值不是省了几行代码而是把“可能存在空值”的防御逻辑内聚到了数据获取的环节而不是在数据获取之后一个个判断。想象一下如果用户对象还有phone、email、company等多个字段每个字段都做一层if判空整个函数会膨胀到几十行。短路求值链式写法只增加一点点符号就能把整个链路的空值风险表达清楚。我最初也认为这种写法“显得很聪明但难读”后来发现真正难读的是那种十几个if嵌套、每个里面都在处理空值分支的逻辑。唯一需要注意的地方是短路求值对“假值”的处理。空的字符串、数字0、false在条件判断里都是“假值”但可空链和空值合并运算符只对null和undefined生效不会把空字符串、0、false误判为空值这一点反而比用||做兜底更安全。2.3 单行正则一个表达式替换了300行状态机正则表达式是另一个两极分化极其严重的工具。爱它的人觉得它是描述文本模式最优雅的语言恨它的人觉得这就是一团乱码写出来自己都看不懂。我见过最经典的例子是解析HTTP请求行GET /index.html HTTP/1.1用代码手写解析需要一个状态机读到空格切分方法、读到路径、读到协议版本。手写的话大概要几十行。用正则match re.match(r^(GET|POST|PUT|DELETE|HEAD|OPTIONS|PATCH)\s(\S)\sHTTP/(\d\.\d)$, line)第一眼看到这个正则的人脑子里只有一个想法这什么玩意儿满屏的斜杠括号反斜杠。但只要拆开看它其实特别直白^表示字符串开始(GET|POST|...)是一个捕获组列出所有允许的方法\s表示一个或多个空白字符(\S)表示一个或多个非空白字符用来匹配路径HTTP/是字面量(\d\.\d)匹配版本号$表示字符串结束它本质上是一个高度压缩的文本模式描述。不用它你就要写一个tokenizer用可读性换来了几十行状态机的消解。不看正则、手写状态机的方案可读性确实更高因为每一行都能看懂但它把本来可以声明式表达的逻辑变成了命令式的逐字符处理维护成本反而更大。正则真正的问题在于它把“可读性”转移到了“匹配规则是否完备”这个问题上。调试正则的时候大部分人只能靠一个一个测试用例去验证。但换个角度想一个能封装好文本匹配规则、开箱即用的库本质上就是在帮你节省自己写状态机的时间。很多看起来“傻乎乎难读”的代码其实是在用更高级的抽象帮你扛住复杂度。3. 那些“看着不对劲”的代码到底聪明在哪四个可复用的判断特征经历了几次“以为别人是傻春结果发现是我不懂”之后我总结出这类代码的四个共同特征。你可以在Code Review时用这四条来判断一种写法到底是炫技、是烂代码还是确有深意的精妙设计。3.1 特征一把复杂度藏在原理里把简单留给调用方布隆过滤器是个特别好的例子。一个判断“元素是否存在”的数据结构正常情况下用哈希表就好但哈希表存储整个元素本身数据量一上去内存就扛不住。布隆过滤器的做法极其反直觉不存储元素本身而是用多个哈希函数把元素映射到一个位数组上每个元素会把几个位标记为1。判断元素是否存在时只要检查这几个位是否都是1有一个是0就说明不存在全是1则说明可能存在。第一次听说这种设计的反应多半是“这玩意儿靠谱吗还能误判”。是的它允许一定的误判率但在允许误判的场景里它用一个位数组打天下内存占用只有哈希表的几十分之一。很多新闻推荐系统做已读过滤时用的就是布隆过滤器因为它能容忍“把没读过的新闻识别成已读”损失一点推荐效果但不能容忍每次都在几千万用户维度去查一个巨大的哈希集合。这种代码看起来像偷懒实际上做的是一个非常明确的空间换精度的决策。普通程序员看到的是“不精确”优秀程序员看到的是“在特定约束条件下最合理的折中方案”。判断一个设计是不是好设计最重要的问题不是“它完美吗”而是“它在自己的约束条件下是不是最优解”。3.2 特征二用数学规律代替状态分支很多看起来奇怪的代码背后都是数学规律在发力。上面提到n (n - 1)是欧几里得算法也是def gcd(a, b): while b: a, b b, a % b return a求最大公约数循环体就两行没有if没有递归。a和b交替赋值直到b为0时返回a。第一眼看以为写错了实际上它是运用了一个基本的数论定理两个整数的最大公约数等于其中较小的数和两数相除余数的最大公约数。这个规律决定了代码可以不用判断a和b谁大谁小因为取余操作天然缩小了问题规模。每次循环b和a%b都会比原来的数小最终必有b为0的时候。另一个例子是哈希一致性算法里常用的“环形哈希”概念。传统哈希取模在节点变化时会导致大量缓存失效一致性哈希引入哈希环每个节点映射到环上数据映射到环上后顺时针找到第一个节点。它把“服务器列表变化导致大面积失效”这个工程问题转化为数学上的环形距离问题代码量并不大但理解成本高。这种代码的共同点是它们不是靠堆if/else来覆盖各种情况而是找到一个数学规律让规律本身保证代码的正确性和终止性。理解这类代码需要补数学基础这恰恰是很多程序员不愿意做的事所以它们经常被误解。3.3 特征三在时间与空间的选择上做了极致的取舍有一类代码看上去“浪费”得非常明显实际上是刻意为之。经典例子是动态规划。用递归算斐波那契数列def fib(n): if n 1: return n return fib(n - 1) fib(n - 2)这代码多简洁一看就懂。但n50时它需要运行好几分钟。因为同样的子问题被反复计算了无数遍。动态规划版本def fib(n): a, b 0, 1 for _ in range(n): a, b b, a b return a三行循环没有递归n100000也瞬间算完。它把“用时间换写代码的省力”改成了“用空间换时间”甚至空间都没多用因为只保留了最后两个数把指数级时间降到了O(n)。缓存技术也是同理。有些方法加了lru_cache装饰器看起来只是多了一行背后的逻辑是在时间与空间的取舍上选择用一小块内存来存储计算结果避免重复计算带来的时间开销。如果一个方法在真实业务里会被高频调用同时入参范围有限那加缓存是一个非常划算的决策。很多人在Code Review时看到装饰器只会说“没必要”很少去算调用频率和参数组合的数量这就是经验差。3.4 特征四暴露的接口小心中的模型大还有一种容易被骂的代码是那种方法签名只有一行里面调了七八个其他方法每个方法的意图都不明显。整个方法看下来感觉像是一套俄罗斯套娃四面八方都是跳转。但有时候这种设计是对的。它遵守了接口隔离原则外部调用者只需要知道“调用这个方法能够处理某个任务”不需要知道内部庞大复杂的执行流程。好的代码不是每行都简单易懂而是把复杂逻辑切分成多个高内聚低耦合的模块让每个模块单独看都简单组合起来能表达一个完整的行为。一个简短的API方法背后可能有几百行逻辑但调用方只需要关心输入输出。这就像你去餐厅点菜菜单上写了“韭黄炒蛋”你不需要知道后厨有几个厨师、炒菜的锅多大、洗菜要怎么洗。接口小不代表功能简单它代表复杂的维度被封装在了开发者不需要感知的地方。如果一个“写得很绕”的方法具备这四种特征里的任何一种我通常不会急着要求改写法。相反我会在评论里先确认一下它对约束条件的判断是否准确。如果约束条件判断错了比如这是一个低频调用的函数却用了为高频调用设计的复杂算法那是真该改如果约束条件判断得对那这个“难看”的代码比那些“好懂但低效”的代码更值得保留。4. 真实踩坑我差一点把一段精品代码“重构”成垃圾再讲一个具体的踩坑案例这也是让我彻底改变对“怪代码”态度的转折点。4.1 一段让我忍不住动手“优化”的代码有一次在维护一个老项目看到一个服务里有一段校验逻辑大概长这样def process(data): info fetch_info(data.id) name info.get(name) if name is None: name unknown address info.get(address) if address is None: address result transform(name, address) if result is None: result default_result() return result我对这个项目有历史包袱觉得这种一个个if判空的写法太啰嗦了决定“优化”一下def process(data): info fetch_info(data.id) name info.get(name) or unknown address info.get(address) or result transform(name, address) or default_result() return result简洁多了用or把空值兜底逻辑直接接在取数后面看着也高级。我自认为这个重构非常顺理成章直到写测试的时候发现某几条用例的行为变了。4.2 为什么测试会挂or和if/else的语义差异问题出在or的判定逻辑上。Python的or不仅仅是判断“是不是None”它判断的是“是不是假值”。空字符串、空列表、0、False这些都是假值。原始的if/else写法里只有name is None时才用“unknown”也就是说如果用户真的没有填写名字name就是一个空字符串此时原逻辑会保留空字符串因为它不是None。而我改成info.get(‘name’) or ‘unknown’之后只要name是空字符串就会被替换成“unknown”。更危险的是如果valid字段返回的是0或者False也会被or吞掉变成默认值。这个改动直接改变了语义而当时的调用方依赖这个语义去做数据展示用户没填名字时页面上应该显示一个空字符串而不是“unknown”。我差点把这个改动提交上去还好在写单测的时候发现了。从那以后我对任何“看着很绕但能跑”的代码都多了一分敬畏不是它没道理是我没有理解它的场景。4.3 从这件事总结出的四条识别方法经历过这个坑之后我在Code Review里看到不如我意的代码不再直接下结论而是按顺序做四件事先看git blame和提交信息。如果一个“怪方法”已经活了很长时间且没有被反复修改通常意味着它有存在的理由。提交信息里有时会写“fix: 当name为空字符串时保持原样”这种一行注释能省去后面很多人的困惑。再看单元测试怎么写的。测试本身就是最好的使用文档。如果一个看起来奇怪的边界处理逻辑被写进了测试用例那它就不是偶然的而是当时真实出现过的场景。然后看调用方的行为。同一个方法在不同调用环境里对空值、0、false的容忍度可能完全不同。理解它为什么写成这样先去看看谁在用它。最后才考虑重构。如果确实能确定原逻辑在场景里是合理的非改不可那就连测试一块儿改把语义变更在评论里说清楚。《重构》这本书里有一句话我一直记得代码的“坏味道”很多是对应上下文而言的。一个对调用方是简单接口、对实现者又足够精确的代码哪怕看起来绕了一圈它也是好代码。一个看起来人畜无害但实际上语义含糊、“聪明”得更离谱。最容易出问题的恰恰是那些自以为在优化的人因为他们没有先理解原有代码的约束条件。那次差点翻车的经历让我在团队里立了一条规矩任何人在重构看起来“奇怪”的逻辑前必须先把原来的行为用测试钉死再决定怎么改。我宁可代码写得啰嗦一点语义精确一点也不要那种“表面简洁、实则有坑”的写法。5. 从想骂人到想学习我在Code Review中养成的三条原则经历的事情多了我给自己立了三条原则。分享出来或许能帮你下次看到“大啥春儿”代码时少一点情绪多一点判断力。第一条原则先问“为什么不是我想的那样”这是一种思维的第一步。当你看到一段代码不符合直觉先不要急着说它有问题而是假设它是有原因的然后去找那个原因。这个过程通常需要你理解数据的流向、调用方的期望、性能的约束但这样走一遍你的收获远大于直接把它改成你认为的样子。大部分时候你会发现问题不在代码在你的理解盲区。第二条原则Code Review的意义是换视角地理解问题不是单方面找茬。我以前开评审会潜意识里是在找别人代码的毛病。后来我发现那些“我觉得不对但人家不改”的代码最后往往是被验证了是有道理的。换了个视角之后评审会变成了讨论设计约束的场合讨论它的适用场景、边界条件、性能代价。这个转变让我的代码质量也有明显提升因为你开始意识到别人设计里的取舍可能是你从没想过的。第三条原则如果看完三遍还是觉得对方是个傻春那可能是你的工具箱里缺工具。位运算看不懂可能是因为你不熟悉二进制世界的规律正则看不懂可能是因为你还没把“匹配模式”内化成一种思维递归看不懂可能是因为你的大脑还没建立起分形的脑回路函数式编程看不懂可能是因为你习惯了命令式思维。要不要补这些工具取决于你的业务需不需要但至少要知道它们的适用场景以免误伤好代码。最后说个调皮但真实的心得我每次差点把一段精妙的代码当成“大啥春儿”之作事后都会发现真正让我情绪上头的不是那段代码而是我对那个领域的不熟悉。代码是好是坏很多时候不取决于它写得多直观而取决于它和它所处的环境匹不匹配。你不能拿一个低频函数的标准去要求高频函数也不能拿业务脚本的标准去要求基础组件。判断一个方法之前先问它在为谁服务、它的约束条件是什么。这个问题问清楚之后大部分看似奇怪的代码都会变得合理起来。
返回列表