如何写出高可读性的代码
- 因为一段代码的读次数比写入次数多很多,代码由多个人维护,很长一段时间内需要变更,那么可维护性非常重要
- 如果可读性差,会导致看不懂,进而改不动。或者改起来风险高。得先读懂,才能去修改
- 可维护性比性能优化要优先考虑,仅当性能不满足业务需求时,通过性能测试定位瓶颈,再针对性优化,避免“过早优化”
本文结合工程实践经验,从命名、注释、变量、架构、格式这几个维度,梳理一套通用且落地性强的代码规范
本文以go语言为主,但代码可读性的思想在各个语言是相通的,不影响理解
一、命名规范
1. 遵循规范
- 遵从编程语言的原生规范:例如用
i/j代表循环变量、k代表Map的键、v代表Map的值,符合开发者的通用认知 - 团队内统一命名口径:借助业务词汇表统一语言,比如所有事务相关变量统一命名为
tx,上下文变量统一命名为ctx,让代码逻辑可预测
2. 命名要“有意义、无歧义”
- 拒绝无意义命名:工程代码中避免使用
a/b/c这类模糊命名,算法题场景除外。例如:
// 差的写法:v的含义模糊,代码块变长后易混淆for _, v := range userList {// 业务逻辑}// 好的写法:直接体现变量含义,可读性提升for _, user := range userList {// 业务逻辑}
- 避免二义性:例如同一方法内,
block不能既表示“文件块号”,又表示“磁盘块号”,需通过前缀区分(如fileBlock/diskBlock) - 通过方法名展示方法意图,例如:
getXXXFromCache(明确数据源)、fetchXXX(强调远程获取)、computeCostTime(强调计算逻辑)。而不是没有明确含义的handeXXX
3. 简洁性
- 在
RuneCount方法中,结果变量命名为cnt即可,无需冗长的runeCount - 包名已体现业务时,类型/方法名需精简(如包名
dingding,接口名无需重复写dingdingRobotService,直接用RobotService,避免调用时出现dingding.dingdingRobotService.XXX的冗余写法)
- 采用通用缩写:参数用
req(request)、返回值用resp(response),符合行业惯例 - 命名长度与使用距离匹配:变量使用位置离声明越远,名称应越长;局部短生命周期变量可适当简化(如方法内临时计数变量用
i,全局统计变量用totalUserCount)
4. 命名与实际含义一致
- 不能出现“变量名是
userIds,实际存储userInfos”的情况,这种错误会直接误导代码阅读者 - 方法名为
getXXXFromDB,但实际从缓存查询
- 这类问题常发生在需求迭代时:修改了变量存储的内容,却忽略了同步修改变量名
二、注释是“更高层的抽象”
优秀的代码本身应具备可读性,注释仅用于补充代码无法表达的信息,而非重复描述代码逻辑
1. 注释的适用场景
- 补充抽象信息:方法声明(名称、参数、返回值)无法完整体现的逻辑,需用注释说明——比如API用法、方法间的依赖关系、核心流程的分段总结
- 解释“为什么”:代码只能体现“做什么”和“怎么做”,特殊逻辑的设计原因(如“此处加锁是为了避免并发修改缓存”)必须通过注释说明,让阅读者与编写者掌握同等信息
2. 不建议写注释的场景
- 无需注释“显而易见”的逻辑:比如给
i++加注释“i自增1”,纯属冗余。 - 魔法数字用常量替代:
const secondsInDay = 86400 替代 // 86400代表一天的秒数; - 复杂逻辑用函数封装:将一段判断逻辑提取为函数,用函数名替代注释,例如:
// 重构前:注释才能说明逻辑含义publicvoidapplyFee(Account account){ if (account.getBalance() < 0 && account.isOverdraftEnabled()) { account.addFee(OVERDRAFT_FEE); }}// 重构后:函数名即注释,代码更简洁publicvoidapplyFee(Account account){ if (shouldApplyOverdraftFee(account)) { account.addFee(OVERDRAFT_FEE); }}privatebooleanshouldApplyOverdraftFee(Account account){ return account.getBalance() < 0 && account.isOverdraftEnabled();}
3. 注释的注意事项
注释必须与代码同步更新:迭代代码时若修改了逻辑,需同步修改注释,避免注释成为误导
三、变量规范
1. 就近声明,最小化作用域
变量在哪使用就在哪声明,避免提前声明大作用域变量。例如:
// 差的写法:提前声明,作用域覆盖整个方法funchandleOrder() {var totalAmount float64// 大量无关逻辑... totalAmount = calculateAmount(order)// 使用totalAmount}// 好的写法:就近声明,作用域仅包含使用逻辑funchandleOrder() {// 大量无关逻辑... totalAmount := calculateAmount(order)// 使用totalAmount}
2.尽量不使用全局变量
全局变量会与所有函数耦合,成为“隐形参数”——一旦全局变量修改,所有依赖它的函数都可能出错
优先通过参数传递、结构体封装等方式替代全局变量
3. 提炼高可读性的变量
将复杂表达式的结果赋值给临时变量,提升代码清晰度:
// 重构前:表达式冗长,可读性差if (order.getTotalPrice() - order.getDiscounts() > 100) { // 逻辑处理}// 重构后:变量名体现含义,逻辑一目了然double netPrice = order.getTotalPrice() - order.getDiscounts();if (netPrice > 100) { // 逻辑处理}
四、代码架构规范
1. 最小化原则,降低包的表面积
- 常量拆分:通用常量可放在公共包,业务常量按模块/层级拆分(如
service层和dao层分别维护常量),避免单个常量文件臃肿、命名冲突。 - 内部结构体私有化:仅在方法内使用的结构体,声明为私有(如Go中用小写开头),避免其他包误用。
2. 避免跨层依赖
遵循“依赖倒置”,上层仅依赖下层抽象,不直接依赖下层的第三方依赖。例如:
- DAO层使用GORM框架,若查询无结果返回
gorm.RecordNotFound,DAO层需封装为自身的RecordNotFound错误返回给Service层; - 若Service层直接依赖
gorm.RecordNotFound,当DAO层替换ORM框架(如从GORM改为XORM)时,Service层需全量修改;而依赖DAO层封装的错误,仅需修改DAO层即可。
3. 杜绝循环依赖
循环依赖会导致模块“相互影响”:上层依赖下层时,上层崩溃不影响下层;但循环依赖下,任一模块出错都可能引发连锁反应。例如:
- 错误示例:
user模块依赖order模块,order模块又依赖user模块; - 解决方式:抽离公共逻辑为独立模块(如
common),让user和order都依赖common,消除循环。
4. 避免代码重复
重复代码的核心问题是“修改时容易漏、逻辑不一致”。例如:两处实现“订单金额计算”的代码,若仅修改一处,会导致不同场景下金额计算规则不一致
为什么会产生重复代码?当发现需要的功能正好和某处很相似时,看起来最简单的做法就是:将其复制过来,改几个地方,测试下,就收工
解决方式:将重复逻辑封装为公共方法,所有场景统一调用,实现逻辑“收口”
五、代码格式与结构:降低阅读的“认知负荷”
1. 统一格式规范
- 借助工具格式化:Go语言用
go fmt统一代码样式,避免因风格差异增加阅读成本; - 行长度限制:每行代码不超过120个字符,避免横向滚动;
- 空行分隔逻辑块:不同业务逻辑、函数段落间留空行,让代码结构更清晰。
2. 杜绝魔法数字
魔法数字(无解释的硬编码数字)是维护噩梦,需用常量封装:
// 差的写法:4的含义不明确if order.Status == 4 {// 处理已完成订单}// 好的写法:常量名自解释,修改时仅需改常量值const OrderStatusCompleted = 4if order.Status == OrderStatusCompleted {// 处理已完成订单}
3. 使用短函数
长函数的缺点:
- 把多个业务处理流程放在一个函数里实现。把不同层面的细节放到一个函数里实现,分离关注点没做好
- 一个人能理解的东西是有限的,没有人能同时面对所有细节
// 差的写法:函数包含多层面细节,逻辑混杂funchandleTrade() {// 1. 日期转换(基础细节) startTime, _ := time.Parse("2006-01-02", req.StartDate)// 2. 校验订单(业务逻辑)if req.Amount <= 0 {return errors.New("订单金额不合法") }// 3. 扣减库存(核心逻辑) stockService.Deduct(req.GoodsID, req.Num)// 4. 生成交易记录(持久化逻辑) db.Create(&TradeRecord{GoodsID: req.GoodsID, Amount: req.Amount})}// 好的写法:拆分函数,每个函数仅做一件事funchandleTrade() { startTime := parseStartDate(req.StartDate)if !validateOrderAmount(req.Amount) {return errors.New("订单金额不合法") } deductStock(req.GoodsID, req.Num) createTradeRecord(req.GoodsID, req.Amount)}funcparseStartDate(dateStr string)time.Time { /* 日期转换逻辑 */ }funcvalidateOrderAmount(amount float64)bool { /* 金额校验逻辑 */ }funcdeductStock(goodsID int, num int) { /* 扣减库存逻辑 */ }funccreateTradeRecord(goodsID int, amount float64) { /* 生成交易记录逻辑 */ }
这样修改后:
- 人们面对的就不再是细节,而是模块,模块的数量显然会比细节数量少,人们的理解成本就降低了
- 当阅读代码的人对某一行感兴趣时,才跳转到底层去看怎么实现的,不会干扰到阅读当前层的逻辑。而不是把所有细节都在当前层事无巨细地展示出来,强迫阅读者阅读底层代码
- 拆解长函数后,变量命名可以更短,理解的成本也相应地会降低。因为变量都是在这个短小的上下文里,也就不会产生那么多的命名冲突,变量名当然就可以写短一些
从流程上解决:用圈复杂度限制来准入代码
4. 减少缩进
过多缩进会让代码“层层嵌套”,可读性骤降,可通过“卫语句”优化,尽量提前返回
优化完成后:
- 正常逻辑在代码布局上始终靠左,读者一眼就能看清主干逻辑
// 差的写法:多层嵌套,主干逻辑不清晰funcGetEnvName()string {if IsOnline() {return"online" } else {if IsLocalHost() {return"localhost" } else {return"test_" + GetShipName() } }}// 好的写法:卫语句提早返回,逻辑层级扁平化funcGetEnvName()string {if IsOnline() {return"online" }if IsLocalHost() {return"localhost" }return"test_" + GetShipName()}
5. 表驱动编程:分离逻辑与数据
将“判断逻辑+静态数据”分离,用映射表替代大量if-else/switch,提升扩展性:
// 差的写法:逻辑与数据混合,新增月份需修改代码funcgetMonthName(month int)string {if month == 1 {return"Jan" } elseif month == 2 {return"Feb" }// ... 更多月份判断return"Invalid month"}// 好的写法:表驱动,新增月份仅需修改映射表var monthNameMap = map[int]string{1: "Jan",2: "Feb",3: "Mar",4: "Apr",5: "May",6: "Jun",7: "Jul",8: "Aug",9: "Sep",10: "Oct",11: "Nov",12: "Dec",}// 使用的地方不需要修改funcgetMonthName(month int)string {if name, ok := monthNameMap[month]; ok {return name }return"Invalid month"}
六、方法设计规范
1. 参数设计:清晰、灵活、安全
- 参数名体现角色:例如
copy(dest, src)而非copy(a, b),明确“目标”和“源”;
// 差的写法:依赖具体的os.File,仅能写入本地文件,测试困难funcSave(f *os.File, doc *Document)error {// 写入逻辑}// 好的写法:依赖io.Writer抽象,可写入文件、网络、内存等,测试更简单funcSave(w io.Writer, doc *Document)error {// 写入逻辑}
- 慎用指针参数:避免方法内部修改参数内容,如需修改,优先通过返回值传递
- 精简参数个数:参数过多(超过6个)会增加调用和维护成本。造成很多参数的原因是:每次只增加一点点,累积起来,便不忍直视了。解决方式:
- 封装为请求结构体:将多个参数封装为
XXXReq结构体,新增参数时无需修改函数签名 - 使用Option模式:适用于参数可选、组合灵活的场景
2. 封装性:暴露最少必要信息
封装性好的优势:
减少调用方的认知负荷:即使不知道模块内部的细节,也能使用
示例1:HTTP框架提供getParameter(String name)获取参数,而非直接返回底层的参数Map
- 用户根本不关心底层怎么存储的,只关心某个参数的值是啥
示例2:Linux文件系统的read/write接口,没有暴露磁盘读写的底层逻辑
示例3:Go的GC机制,仅暴露少量必要调优参数,屏蔽底层实现细节