维护代码是程序员非常重要的日常工作之一,那么你是否曾遇见过以下问题?
-
接手维护项目,却发现文档缺失、代码无注释,加上维护人离职,基本只能靠猜来梳理代码逻辑。
-
代码风格过于抽象(命名过短、魔鬼数字、重名方法等),看不懂,也不敢轻易修改。
-
运行代码出现故障时,不打日志,不抛异常,导致定位问题需要耗费很长时间。
-
大段的if-else代码嵌套组合,调用逻辑复杂冗长,扩展性差,重构优化费时、费力。
你发现没,造成这些问题的原因其实都是代码的可读性差,没能很好地串联起代码内在的逻辑。可读性差的代码难以理解,这不仅会造成诸多误解和麻烦,还会导致项目交付效率变低。虽然代码可读性是一种主观的定义,但是它确实能保证研发人员快速准确地理解代码的真实含义。
那么,为了提高代码的可读性,我们该如何做呢?今天这一讲我们就一起来学习一个能帮助你快速提升代码可读性的原则:表达原则。
为什么要提升源代码的可读性
提升源代码的可读性主要有以下四大好处。
第一,更易于维护。代码写好后,需要调试、运行与修复 Bug,设计文档、需求文档和口头交流只能表达部分业务逻辑的意图,而代码则能反映出编程实现业务逻辑时的全部真实意图。可读性高的代码,能让阅读者在阅读时快速理解编写者的意图,即便逻辑复杂,也能在修改时准确地分析和理解,大大节省维护和修改代码的时间。
第二,更易于重构。现在很多项目之所以难以重构,就是因为代码的可读性太差。当你无法理解一段代码时,你会跳过它,而整个系统都难以理解的话,你可能就会选择重写而不是重构,因为重构必然会修改原有代码,这会引入一定的风险,一旦因为重构而导致故障,那么维护的人就要担责。所以说,可读性的高低在某种程度上决定了你重构意愿的大小。
第三,更易于测试。代码在修改时需要反复调试,如果代码的可读性很差,那么很多时候都需要写一些额外的 Mock 或测试接口来对原有的代码进行测试,不仅浪费时间,还容易造成误读。可读性高的代码,参数与输出都更清晰,在测试时能更精准地找到对应逻辑和问题点。
第四,更易于应用设计模式。设计模式除了在设计之初被使用外,其实更多时候都是在代码重构过程中被使用。在工作中,你会发现有的代码虽然写了很多嵌套的if-else,但命名和注释都写得很好,逻辑也很易读,在重构时就能通过设计模式很好地去优化。而有的代码虽然看上去很简洁,但使用了很多高级技巧或缩写命名,理解起来非常费时、费力,对于维护人员来说,自然不愿意考虑使用设计模式。
总体来说,提升代码的可读性能够帮助我们更好地理解代码,只有理解了代码才能更好地维护代码,而本质上代码就是用来维护的——不断修改与发布;另外,还有一个重要的用处是,能帮助阅读代码的人快速找到代码的实现逻辑。
表达原则:凸显代码的内在逻辑
虽说编写文档能够表达软件开发意图,但事实上,你可能很讨厌写文档,这是因为大部分文档都与代码没有直接关系,并且随着代码的不断调试与修改,文档会变得越来越难以与最新的真实情况同步。
另外,你可能也没有太多时间阅读文档,需求上线、Bug 修复、多项目并发是现在程序员的日常现状。因为时间紧、任务重,你可能只能边改代码边学习,这时一份逻辑清晰的代码才是你真正需要的。
不过,很多时候你可能并不知道该怎么在代码中表达自己的意图,其实,有一个原则可以帮到你,那就是表达原则。
表达原则(Program Intently and Expressively,简称 PIE),起源于敏捷编程,是指编程时应该有清晰的编程意图,并通过代码明确地表达出来。
简单来说,表达原则的核心思想就是:代码即文档。也就是说,写代码时要像写设计文档一样帮助阅读者理解你想要表达的意图,要从程序设计者的角度跳出来,站在使用者的角度来写代码。
可以换个角度想想,假如你是代码使用者,你希望看到什么样的代码?很明显,没有人想要看到这样的代码(来自网络的一段烂代码):
cName = InpList.get(0).replace(",", ".");
cCode = InpList.get(1).replace(",", ".");
cAlpha2 = InpList.get(2).replace(",", ".");
cAbreviation = InpList.get(3).replace(",", ".");
dYear = InpList.get(4).replace(",", ".");
dPoliticalCompatibility = InpList.get(5).replace(",", ".");
dRankPoliticalCompatibility = InpList.get(6).replace(",", ".");
dEconomicCompatibility = InpList.get(7).replace(",", ".");
dRankEconomicCompatibility = InpList.get(8).replace(",", ".");
dMilitaryCompatibility = InpList.get(9).replace(",", ".");
dRankMilitaryCompatibility = InpList.get(10).replace(",", ".");
dDemoScore = InpList.get(11).replace(",", ".");
dRankDemoScore = InpList.get(12).replace(",", ".");
dEnvironmentalCompatibility = InpList.get(13).replace(",", ".");
dRankEnvironmentalCompatibility = InpList.get(14).replace(",", ".");
dSumCompatibility = InpList.get(15).replace(",", ".");
dRankCompatibility = InpList.get(16).replace(",", ".");
dPoliticalUtility = InpList.get(17).replace(",", ".");
dRankPoliticalUtility = InpList.get(18).replace(",", ".");
dEconomicUtility = InpList.get(19).replace(",", ".");
dRankEconomicUtility = InpList.get(20).replace(",", ".");
dMilitaryUtility = InpList.get(21).replace(",", ".");
dRankMilitaryUtility = InpList.get(22).replace(",", ".");
dEnvironmentalUtility = InpList.get(23).replace(",", ".");
dRankEnvironmentalUtility = InpList.get(24).replace(",", ".");
dSumUtility = InpList.get(25).replace(",", ".");
dRankUtility = InpList.get(26).replace(",", ".");
dPoliticalScore = InpList.get(27).replace(",", ".");
dRankPoliticalScore = InpList.get(28).replace(",", ".");
dEconomicScore = InpList.get(29).replace(",", ".");
dRankEconomicScore = InpList.get(30).replace(",", ".");
dMilitaryScore = InpList.get(31).replace(",", ".");
dRankMilitaryScore = InpList.get(32).replace(",", ".");
dEnvironmentalScore = InpList.get(33).replace(",", ".");
dRankEnvironmentalScore = InpList.get(34).replace(",", ".");
dAggregate = InpList.get(35).replace(",", ".");
dRankAggregate = InpList.get(36).replace(",", ".");
而是,希望看到这样的代码(HttpClient 的某个代码片段):
/**
* {@inheritDoc}
*/
@Override
public CloseableHttpResponse execute(
final HttpUriRequest request,
final HttpContext context) throws IOException, ClientProtocolException {
Args.notNull(request, "HTTP request");
return doExecute(determineTarget(request), request, context);
}
private static HttpHost determineTarget(final HttpUriRequest request) throws ClientProtocolException {
// A null target may be acceptable if there is a default target.
// Otherwise, the null target is detected in the director.
HttpHost target = null;
final URI requestURI = request.getURI();
if (requestURI.isAbsolute()) {
target = URIUtils.extractHost(requestURI);
if (target == null) {
throw new ClientProtocolException("URI does not specify a valid host name: "
+ requestURI);
}
}
return target;
}
所以说,在开发代码时,应该更注重代码表达的意图是否清晰,考虑使用一些方法和技巧,虽然会耗费一点时间,但是从整体来看,你会节省很多沟通与解释的时间,做到在真正的提升编码效率。
如何写出有“逻辑线索”的源代码
要想写出可读性高的代码,你可以从三个方面来入手。
-
代码表现形式:在命名(变量名、方法名、类名)、代码格式、注释等方面的改进。
-
控制流和逻辑:尽量分离控制流和逻辑,让代码变得更容易理解。
-
惯性思维:找出常犯的一些惯性思考方式并逐一改进。
下面我就来具体解释下。
1. 优化代码表现形式
命名在编程中至关重要,无论是变量名、类名还是方法名,好的名字能快速准确地传达要表达的含义,而缩写、自定义名称会让代码变得难以理解。我们先来看一段代码:
public class T {
private Set<String> pns = new HashSet();
private int s = 0;
private Boolean f(String n) {return pns.contains(n);}
int getS() {return s;}
int s(List<T> ts, String n) {
for (T t :ts)
if (t.f(n))
return t.getS();
return 0;
}
}
这段代码到底实现了什么功能?估计没有人能回答出来。如果编写者不是我,我肯定也无法理解这段代码。光凭看代码,几乎是无法理解这段代码的真实含义到底是什么的。
实际上,这个类是获取球队比赛得分的,除了通过球队直接获得比赛得分外,还可以通过球队里的某个球员来查找对应得分,具体修改如下:
/**
* 获取球队比赛得分
**/
public class Team {
private Set<String> playerNames = new HashSet(); //保证名字不重复
private int score = 0; //默认为零
/**
* 判断是否包含球员
* @param playerName
* @return
*/
private Boolean containsPlayer(String playerName) {
return playerNames.contains(playerName);
}
/**
* 知道队伍,直接获取分数
* @return
*/
public int getScore() {
return score;
}
/**
* 通过队员名字查找所属队伍分数
* @param teams 支持多个队伍
* @param playerName
* @return 兜底为0分,不出现负分
*/
public int getTeamScoreForPlayer(List<Team> teams, String playerName) {
for (Team team :teams) {
if (team.containsPlayer(playerName)) {
return team.getScore();
}
}
return 0;
}
}
从优化后的代码中,你就能直观地看到,“命名的优化加上注释的说明”一下子就让源代码的逻辑变得清晰起来,即便你没有学过编程,也能大致了解这段代码的逻辑和作用。
2. 改进控制流和逻辑
这里我们还是直接从一个例子开始,具体代码如下:
public List<User> getUsers(int id) {
List<User> result = new ArrayList<>();
User user = getUserById(id);
if (null != user) {
Manager manager = user.getManager();
if (null != manager) {
List<User> users = manager.getUsers();
if (null != users && users.size() > 0) {
for (User user1 : users) {
if (user1.getAge() >= 35 && "MALE".equals(user1.getSex())) {
result.add(user1);
}
}
} else {
System.out.println("获取员工列表失败");
}
} else {
System.out.println("获取领导信息失败");
}
} else {
System.out.println("获取员工信息失败");
}
return result;
}
这段代码的含义是:想要通过 id 来查询员工的信息,如果 id找不到,就查询员工的领导,然后通过他领导下的员工信息来寻找,这时还需要判断员工年龄大于 35 岁且为男性。
这是我们最常使用的逻辑实现方式,俗称箭头型代码,但是随着判断条件逐渐增多,嵌套就会增多。代码逻辑越多,你就越容易搞不清楚逻辑是什么,因为看到最内层的代码时,你已经忘记前面每一层的条件判断是什么了。
那么,我们该如何去优化呢?其实很简单,就是改变控制流,先判断会出现失败的条件,一旦出现优先推出。优化后的代码如下:
public List<User> getStudents(int uid) {
List<User> result = new ArrayList<>();
User user = getUserByUid(uid);
if (null == user) {
System.out.println("获取员工信息失败");
return result;
}
Manager manager = user.getManager();
if (null == manager) {
System.out.println("获取领导信息失败");
return result;
}
List<User> users = manager.getUsers();
if (null == users || users.size() == 0) {
System.out.println("获取员工列表失败");
return result;
}
for (User user1 : users) {
if (user1.getAge() > 35 && "MALE".equals(user1.getSex())) {
result.add(user1);
}
}
return result;
}
现在,代码逻辑是不是很清晰了?虽然这个快速失败方法很简单,但是非常有效。实际上,快速失败就是 KISS 原则一个很好的实践,这样能保证条件判断的逻辑简单清晰。只要 if 的嵌套超过三层,你就可以应用这个原则来改进控制流,让逻辑更清晰易懂。
3. 避免惯性思维
除了改进表层和逻辑外,我们更应该尽量避免设计代码时的一些惯性思维,这里我总结出了“五个避免”,下面我们就来具体分析一下。
第一,要避免一次性代码。一次性编码最大的坏处在于,一旦需要修改,多处就得跟着修改,而多次修改又可能会出现遗漏的风险。一次性代码在越来越多的软件代码中出现,一个本质的原因就是多人协作开发的情况越来越多。由于编程是一件非标准化的事情,不同程序员可能对同一个逻辑的理解完全不同,而一旦每个人都只从自己的角度出发写一次性代码,那么同一个系统里的代码很快就会变得冗余与混乱。
第二,要避免复制粘贴代码。一方面,不同的人编码风格可能会有所不同,这会给阅读者在理解上造成一定的认知负担(需要来回切换判断标准)。另一方面,还会带来未知 Bug的风险。复制过来的代码,更多是关注输入和输出,一旦代码正常运行后,很少会去关注代码的内部逻辑,但是等出现问题后,再想去梳理逻辑反而变得更加困难(因为不知道详细的实现逻辑)。
第三,避免写超长代码。超长代码带来的最大问题是:在阅读代码时,函数方法之间的跳转过多,思维很容易发生混乱,尤其对于一些命名相同但参数不同的方法,很容易出现修改错误的情况。从编写者的角度来看,你写超长代码,可能是觉得在一个文件里维护代码比较方便;但对于阅读者来说,他可能并不知道你是如何对代码进行职责划分的,更多时候他都会以为一个类里都是一个职责,但实际上一旦出现多个职责,加上逻辑跳转很多,阅读者基本上是会放弃阅读的。
第四,避免过度简化命名和表达式。在开发任务重的时候,我们通常会选用一些简化命名的方法,比如,num1、num2、num3 这类变量命名形式。虽然在写代码的时候,我们可能记得这些变量的含义,但是过一段时间后,如果没有注释或说明,几乎是不可能直接通过名字知道它们的作用的,还得借助上下文代码,这样不仅费时,而且还可能会出现理解错误的情况。
第五,避免写“是什么”的注释。代码的命名和结构如果能直接反映出来“是什么”的话,我们就不应该用注释去表达,因为看代码一眼就能明白,比如,获取用户信息的方法名——get 和 getFromUserInfo。
我们应该多写“为什么”的注释,比如,为什么要多加一个适配的方法,原因可能是线上 xxx 问题引起,或临时修复的Bug,后续可能随 xxx 接口调整而废弃,等等。在很多优秀的开源框架中,我们都能看到作者会在 interface 接口上写很多“为什么”的说明,就是为了帮助我们快速抓住代码的逻辑线索。
另外,写“为什么”的注释还有一个好处:尤其在早期快速迭代过程中,能给后来的维护者提供一个优化的切入点,而不至于交接代码后让维护代码的人看不懂、不敢动。
总结
表达原则的核心思想在于:通过代码清晰地表达我们的真实意图。
虽然软件开发过程中会有诸多文档,比如,架构设计文档、流程文档、PRD 文档等,但这些文档并不足以帮助我们正确理解代码是如何运行和组织的,很多时候我们只能通过阅读代码来掌握软件的真实运行情况。
我们之所以应该把提高代码可读性作为第一要务,就是因为读代码的次数远比写代码的次数多,包括你正在写的代码也是如此。
今天我们学习了三种主要的改进办法:一是表层的改进,从命名、表达式、变量和注释入手去提升代码可读性;二是改进代码逻辑,比如用策略模式替换嵌套过多的if-else 代码;三是改进思维习惯,习惯的改善更多是需要日积月累的坚持。
课后思考
学习完表达原则后,你一定意识到编程中表达意图的重要性了,那是不是就应该一味地只重视表达而忽略算法和数据结构呢?还有哪些你认为很重要却在编程中常常被忽略的地方呢?
欢迎留言分享,我会第一时间给你回复。
在下一讲,我会接着与你分享“职责原则:如何在代码设计中实现职责分离?”的相关内容,记得按时来听课!