注册

编写整洁代码的技巧

背景

前菜

什么样的代码是整洁的?

衡量代码质量的唯一标准,是别人阅读你代码时的感受。所谓整洁代码,即可读性高、易于理解的代码。

不整洁的代码,阅读体验是这样的:

  1. 乱(组织乱、职责乱、名称乱起)
  2. 逻辑不清晰(if-else太多)
  3. 绕弯子(简单的事写的很复杂)
  4. 看不懂(只有写的人能理解)
  5. 难修改(耦合严重,各种写死)

整洁的代码,阅读体验是这样的:

  1. 清晰(是什么,做了什么,一眼看得出来)
  2. 简单(职责少,代码少,逻辑少)
  3. 干净(没有多余的逻辑)
  4. 好拓展(依赖的比较少,修改不会影响很多)

为什么需要编写整洁的代码?

  1. 保持代码整洁是程序员专业性的重要体现。 写软件就像是盖房子,很难想象一个地板不平、门窗关不严实的房子能称为一个大师制作。代码整洁可以体现一个人的专业水平和追求专业性的态度。

  2. 读代码的时间远远大于写代码。 根据《整洁代码之道》作者在书中小数据量统计,读代码与写代码的时间比可能达到10:1,实际项目中虽然达不到这个比例,但是需要阅读其他同学代码的场景并不少见。让代码容易阅读和理解,可以优化阅读代码的时间成本和沟通成本。

  3. 不整洁的代码带来诸多坏处。

    1. 每一笔不整洁的代码都是一笔技术债,迟早需要偿还,且随着时间的推移,偿还成本可能会越来越大。
    2. 烂代码难以理解,不敢改动,容易按住葫芦浮起瓢,修完一个bug又引入了另一个bug。
    3. 阅读不好的代码,会让人心情烦躁,充满负能量,是一种精神折磨。
    4. 容易引起破窗效应。当代码开始有一些bad smell,因为破窗效应,可能会导致代码越来越烂,不断积累形成“屎山”。

让代码变得整洁

命名

名副其实

在新建变量、函数或类的时候,给一个语义化的命名,不要因为害怕花时间取名字就先随手写一个想着以后再改(个人经验以后大概率是不会再改,或者想改的时候忘记要改哪里了)。如果名称需要注释来补充,那就不算是名副其实。

避免误导

起名字时,避免别人将这个名字误读成其他的含义。有以下几条准则可以使用:

  • 避免使用和本意相悖的词。

e.g.表达一组账号的变量:

  • 如果不是一个List类型,不要使用accountList。
  • 建议使用accountGroup。
  • 避免有歧义的命名。

e.g. 表达过滤后剩下的数据

  • 不要使用filteredUsers,filter具有二义性,不清楚到底是被过滤的,还是过滤后剩下的。
  • 建议使用removedUsers、remainedUsers来分别表示被过滤的和过滤后剩下的。
  • 避免使用外形相似度高的名称。

e.g.简单和单选:

  • 不要使用simple和single,外形相似,容易混淆。
  • 建议使用easy和single。
  • 避免使用不常见的缩写。

避免使用没有区分性的命名

  • 避免使用一些很宽泛的词: 比如Product类和ProductInfo或者ProductData类名称虽然不同,但其实意思是一样的。应该使用更有区分性的命名。再比如,getSize可能返回一个数据结构的长度、所占空间等,改成getLength或getMemoryBytes则更合适一些。
  • 避免tmp之类的名字: 除非真的是显而易见且无关紧要的变量,否则不要使用tmpXxx来命名。
  • 修改 IDE 自动生成的 变量名  IDE自动生成变量名字,有些时候是没有语义的,为了易于理解,在生成代码后,顺便修改变量名字。
/// BAD: element表示的语义是啥?需要结合前面的selectedOptions来推断element的语义
List<String> get selectedKeys {
return selectedOptions.map((element) => element.key).toList();
}

/// GOOD: 阅读代码即可知道获取的是已选选项的key
List<String> get selectedKeys {
return selectedOptions.map((option) => option.key).toList();
}

给变量名带上重要的细节

  • 表示度量的 变量名 带上单位: 如果变量是一个度量(长度、字节数),最好在名字中带上它的单位。比如:startMs、delaySecs、sizeMb等。
  • 附带其他属性: 比如未处理的变量前面加上raw。

不使用魔法数字

遇到常量时,避免直接将魔法数字编写到代码中。这种方式有诸多坏处:

  • 没有语义,需要思考这个魔法数字代表什么意思。进而导致这个代码只有写的人敢改。
  • 如果该魔法数出现多次,之后修改时需要覆盖到每个使用之处,一旦有一处没改,就会有风险。
  • 不便于搜索。

建议改为表达意图的常量,或使用枚举。

/// BAD: 需要耗费注意力寻找2的意义
if (status == 2) {
retry();
}

/// GOOD: 改为表达意图的命名变量
const int timeOut = 2;
if (status == timeOut) {
retry();
}

避免拼写错误

AndroidStudio有自带的拼写检查,平时在写代码的时候可以注意一下拼写错误提示。

注意变量名的长度

变量名不能太长,也不能太短。 太长的名字读起来太费劲,太短的名字读不懂是什么意思。那变量名长度到底多少最合适呢?这个问题没有定论,但是在决策变量名长度时,有一些准则可以使用:

  • 在小的作用域里可以使用短的名字: 作用域小的标识符不用带上太多信息。
  • 丢掉没用的词: 有时候名字中的某些单词可以拿掉且不会损失任何信息。例如:convertToString可以替换为toString。
  • 使用常见的缩写降低变量长度: 例如,pre代替previous、eval代替evaluation、doc代替document、tmp代替temporary、str代替string。

e.g. 在方法里,使用tempMap命名,只需要理解它是用于临时存储,最后作为返回值;但是如果tempMap是在一个类中,那么看到这个变量可能就会比较费解了。

static Map<String, dynamic> toMap(List<Pair> valuePairs) {
Map<String, dynamic> tempMap = {};
for (final pair in valuePairs) {
tempMap[pair.first] = pair.second;
}
return tempMap;
}

附:一些常用 命名规范 

变量

删除没有价值的临时变量

当某个临时变量满足以下条件时,可以删除这个临时变量:

  • 没有拆分任何复杂的表达式。
  • 没有做更多的澄清,即表达式本身就已经比较容易理解了。
  • 只用过一次,并没有压缩任何冗余代码。
/// BAD: 使用临时变量now
final now = datetime.datetime.now();
rootMessage.lastVisitTime = now;

/// GOOD: 去除临时变量now
rootMessage.lastVisitTime = datetime.datetime.now();

缩小变量的作用域

  1. 谨慎使用全局变量。 因为很难跟踪这些全局变量在哪里以及如何使用他们,并且过多的全局变量可能会导致与局部变量命名冲突,进而使得代码会意外地改变全局变量的值。所以在定义全局变量时,问自己一个问题,它一定要被定义成全局变量吗?

  2. 让你的变量对尽可能少的代码可见。 因为这样有效地减少了读者需要同时考虑的变量个数,如果能把所有的变量作用于都减半,则意味着同时需要思考的变量个数平均来说是原来的一半。比如:

    1. 当类中的成员变量太多时,可以将大的类拆分成小的类,让某些变量成为小类中的私有变量。
    2. 定义类的成员变量或方法时,如果不希望外界使用,将它定义成私有的。
  3. 把定义下移。 把变量的定义放在紧贴着它使用的地方。不要在函数或语句块的顶端直接放上所有需要使用的变量的定义,这会让读者在还没开始阅读代码的时候强迫考虑这几个变量的意义,并在接下来的阅读中,不断地索引是哪个变量。

函数

  1. 避免长函数

  • 函数要短小!函数要短小!函数要短小!(重要的事情说三遍)
  • 每个函数只做一件事。

如果发现一个函数太长,一般都是一个函数里干了太多事情,可以使用Extract Method(提取函数) 重构技巧,将函数拆分成若干个子功能,放到若干个子函数中,并给每个子函数一个语义化的命名(必要时可以添加注释)。这样既提高了函数的可读性,同时短小、单一功能的函数也方便复用。

避免太重的分支逻辑

if-else语句、switch语句、try-catch语句中,如果某个分支过于复杂,可以将该分支的内容提炼成独立的函数。这样不但能保持函数短小,而且因为块内调用的函数拥有较具说明性的名称,从而增加了文档上的价值。

/// BAD: if-else中语句多且繁杂
if (date.before(summerStart) || date.after(summerEnd)) {
charge = quantity * winterRate + winterServiceCharge;
} else {
charge = quantity * summerRate;
}

/// GOOD: 分别提炼函数
if (notSummer(date)) {
charge = winterCharge(quantity);
} else {
charge = summerCharge(quantity);
}

使用具有语义化的描述性名称给函数命名

  1. 函数名称应具有描述性,别害怕长的名称。长而具有描述性的名称,要比短而令人费解的名称好。
  2. 别害怕花时间取名字。

降低参数个数

  1. 参数个数越少,理解起来越容易。同时也意味着单测需要覆盖的参数组合少,有利于写单测。

  2. 当输入参数中有bool值时,建议使用Dart中的命名参数。

       /// BAD: bool类型取值只有true和false,无法理解在这个场景下取值的意义,必须得点到方法的声明里
    search(true);

    /// GOOD: 通过命名函数可以了解到取值的意义
    search(forceSearch : true);
  3. 当输入参数过多时,建议将其中一些参数封装成类。不然后续每每增加一个参数,就得修改函数的声明。

       /// BAD: 函数参数中放置多个离散的数据项
    void initUser({
    required String key,
    required String name,
    required int age,
    required String sex,
    }) {
    ...
    }

    /// GOOD: 将紧密相连的数据项聚合到一个类中
    class UserInfo {
    String key;
    String name;
    String sex;
    int age;
    }

    void initStore({required UserInfo user}) {
    ...
    }

分隔指令和查询

函数要么做什么事,要么回答什么事,二者不可得兼。函数应该修改某对象的状态,或是返回该对象的有关信息。两样都干常会导致逻辑混乱。

注释

真正好的注释只有一种,那就是通过其他方式不写注释。

  1. 如果你发现自己需要写注释,再想想看能否用更清晰的代码来表达。
  2. 为什么要贬低注释的价值?注释存在的时间越久,就离所描述的代码越远,变得越来越错误,因为程序员不能坚持维护注释。
  3. 这个目标也并非铁律,项目中经常会存在一些千奇百怪背景的代码,指望全部靠代码表达是不可能的。

坏的注释

  1. 臃肿的、不清楚的、令人费解的注释。 如果不确定注释写的是否合适,让你旁边的同学看下能不能看懂。
  2. 简单的代码,复杂的注释。 阅读注释比代码本身更费时。
  3. 有误导的注释。 随着业务迭代,在改代码的时候并没有更改对应的注释,导致代码逻辑与注释不匹配,引起误解,这对其他开发人员是致命的。
  4. 显而易见的东西,没必要写注释。
  5. 注释不需要的代码。 不需要的代码不要通过注释的方式保存,直接删掉就好。不然别人可能也不敢删除这段代码,会觉得放在那里是有原因的。
  6. 注释不应该用于粉饰不好的设计。 比如给函数或变量随便取了个名字,然后花一大段注释来解释。应该想办法取个更易懂的名字。

好的注释

以下场景值得加上注释:

  1. 代码中含有复杂的业务逻辑,或需要一定的上下文才能理解的代码。 如果希望阅读者对代码背景或代码设计有个全局的了解,可以附上相关的文档链接。
  2. 用输入输出举例,来说明特别的情况。 相较于大段注释来说,一个精心挑选的输入、输出例子更有效。
  3. 将一些晦涩的参数和返回值翻译成可读的东西。
assertTrue(a.compareTo(a) == 0);  // a == a
assertTrue(a.compareTo(b) != 0); // a != b
  1. 代码中的警告、强调,避免其他人调用代码时踩坑。 在写注释的时候问问自己:“这段代码有什么出人意料的地方?会不会被误用?”预料到其他人使用你的代码时可能会遇到的问题,再针对问题写注释。
  2. 对代码的想法。 TODO(待办)、FIXME(有问题的代码)、HACK(对一个问题不得不采用的比较粗糙的解决方案)或一些自定义的注释(REFACTOR、WARNING)。
  3. 在文件、类的级别上,使用“全局观”的注释来解释所有的部分是如何工作的。 用注释来总结代码块,使读者不至于迷失在细节中。
  4. 代码注释应该仅回答代码不能回答的问题。 例如,方法注释应当应该写的是“为什么存在这个方法” 和 “方法做了什么”,而不是“方法是如何实现的”。如果方法注释过于关注方法“如何”工作,那么随着代码的不断变化,它很快就会过时。当开发人员依赖过时的注释来理解方法的工作原理时,这可能会导致混淆和错误。

格式

限制单个文件的代码行数

上图统计了Java中一些知名项目的每个文件的代码行数。可以看到都是由很多行数比较小的文件构成,没有超过500行的单独文件,大多数都少于200行。

小文件通常比大文件更加容易理解。 虽然这不是一个硬性规定,但一般一个文件不应该超过200行,且上限为500行。

dart中,可以通过part字段,对长文件进行拆分。

  1. 限制代码的长度

眼睛在阅读高而窄的文本时会更舒服,这正是报纸文章看起来是这样的原因:避免编写太长的代码行是一个很好的做法。另外,短行代码在使用三栏方式解冲突的时候,不需要横向滚动,更容易发现冲突的内容。

/// BAD: 参数放在一行展示
Future navigateToXxxPage({required BuildContext context, required Map<String, dynamic> queryParams, Object? arguments,});

/// GOOD: 每个参数一行展示,更清晰
Future navigateToXxxPage({
required BuildContext context,
required Map<String, dynamic> queryParams,
Object? arguments,
});

合理使用代码中的空行

源代码中的空行可以很好的区分不同的概念。反之,内容相关的代码不应该空行,应该紧贴在一起。

变量、函数声明

  1. 变量的声明应尽可能接近它使用的地方。 类的成员变量的声明应该出现在类的顶部。局部使用的变量应该声明在它使用之处附近。
  2. Dart函数中参数的声明,required标记的参数尽量归在一起。
  3. 如果有一堆变量要声明(类的成员变量、函数的参数),可以从重要的到不重要的进行排序。
  4. 如果一个函数调用另一个函数,它们应该在垂直上靠近,并且如果可能的话,调用者应该在被调用者之上。 在一般情况下,我们希望函数调用依赖关系指向向下的方向。也就是说,一个被调用的函数应该在一个执行调用的函数下面。像在看报纸一样,我们期待最重要的概念最先出现,低层次的细节出现在最后。

简化控制流、表达式

如果代码中没有条件判断、循环或者任何其他的控制流语句,那么它的可读性会很好。而跳转和分支等部分则会很快地让代码变得混乱。

调整条件语句中参数的顺序

比较的左值为变量,右值为常量。这种方式更符合自然语言的顺序。

/// BAD: 
if (10 <= length)

/// GOOD:
if (length >= 10)

调整if-else语句块的顺序

在写if-else语句的时候:

  • 首先处理正逻辑而不是负逻辑的情况。例如,用if(debug)而不是if(!debug)
  • 先处理掉简单的情况。这种方式可能还会使得if和else在屏幕之内都可见。
  • 先处理有趣的或者是可疑的情况。

合并相同返回值

当有一系列的条件测试返回同样的结果时,可以将这些测试合并成一个条件表达式,并将这个条件表达式提炼成一个独立函数。/// BAD: 多个条件分开写,但是返回了同一个值。

int test() {
final bool case1 = ...;
final bool case2 = ...;
final bool case3 = ...;
if (case1) {
return 0;
}
if (case2) {
return 0;
}
if (case3) {
return 0;
}
return 1;
}

/// GOOD:将统一返回值对应的条件合并。
int test() {
if (shouldReturnZero()) {
return 0;
}
return 1;
}

bool shouldReturnZero() {
final bool case1 = ...;
final bool case2 = ...;
final bool case3 = ...;
return case1 || case2 || case3;
}

不要追求降低代码行数而写出难理解的表达式

三目运算符可以写出紧凑的代码,但是不要为了将所有代码都挤到一行里而使用三目运算符。三目运算符应该是从两个简单的值中做选择,如果逻辑复杂,使用if-else更好。

/// BAD: 
return exponent >= 0 ? mantissa * (1 << exponent): mantissa/(1<<-exponent);

/// GOOD:
if (exponent >= 0) {
return mantissa * (1 << exponent);
} else {
return mantissa / (1 << -exponent);
}

避免嵌套过深

条件表达式通常有两种表现形式:

  • 所有分支都属于正常行为(使用if-else形式)。
  • 只有一种是正常行为,其他都是不常见的情况(if-if-if...-正常情况)。

嵌套过多深使代码更难读取和跟踪,可以尽量将代码转为以上两种标准的if形式。

/// BAD: if-else嵌套太深,难以理解逻辑。
void test() {
if (case1) {
return a;
} else {
if (case2) {
return b;
} else {
if (case3) {
return c;
} else {
return d;
}
}
}
}

/// GOOD: 先处理非正常情况,直接退出,再处理正常情况,降低理解成本
void test() {
if (case1) return a;
if (case2) return b;
if (case3) return c;
return d;
}

不要使用if-else代替switch

一般使用switch的场景,都是某个变量有可枚举的取值,比如枚举类型,不要使用if-else来代替枚举值的判断:

enum State {success, failed, loading}

/// BAD: 对于现在的流程是没问题,但是万一新增了一个State,忘记修改这里,就会出现风险;
/// 况且switch本身就适合在这种场景下使用。
void fun() {
if (state == State.success) {
// do something when success
} else if (state == State.failed) {
// do something when failed
} else {
// do something when loading
}
}

/// GOOD: 当State新增了一个枚举值时,这里会报错,必须修改这里才能编译通过
void fun () {
switch (state) {
case State.success:
// do something when success
break;
case State.failed:
// do something when failed
break;
case State.loading:
// do something when loading
break;
}
}

让表达式更易读

日常写代码时,最常见的一个现象就是if语句的条件中,包含了大量的与或非表达式,如果表达式的逻辑简单还好,一旦表达式开始嵌套或多个与或非并列,那么对于理解代码的人来说将是一个灾难。遇到这种情况,可以使用以下的技巧,逐步优化代码:

  1. 提取解释变量。 引入额外的变量,来表示一个小一点的子表达式。

    /// BAD: 阅读代码的人需要理解line.split(":")[0].trim()代表什么,当没有注释时往往纯靠猜测
    if (line.split(":")[0].trim() == "root") {
    // xxx
    }
    /// GOOD: 快速理解line.split(":")[0].trim()的语义,便于理解if条件表达式
    final userName = line.split(":")[0].trim();
    if (userName == "root") {
    // xxx
    }

    /// BAD: 理解这个表达式需要花多久?
    if (line.split(":")[0].trim() == "root" || line.split(":")[1].trim() == "admin") {
    // xxx
    }
    /// GOOD: 还是这个更容易理解?
    final isRootUser = line.split(":")[0].trim() == "root";
    final isAdminUser = line.split(":")[1].trim() == "admin";
    if (isRootUser || isAdminUser) {
    // xxx
    }
  2. 使用总结变量。 当if语句的条件比较复杂时,将整个条件表达式使用一个总结变量代替。

    /// BAD: 阅读代码的人需要理解什么情况下能进入if语句,代表什么语义
    if (newSelect != null && preSelect != null && newSelect != preSelect) {
    // xxx
    }
    /// GOOD: 快速理解if语句的语义,如果关注细节,再看表达式的构成
    final selectionChanged = newSelect != null && preSelect != null && newSelect != preSelect;
    if (selectionChanged) {
    // xxx
    }
  3. 减少非逻辑嵌套。 对于一个bool表达式,有一下两种等价写法,大家可以自行判断哪个更加可读。

    /// BAD: 阅读代码的人需要理解什么情况下能进入if语句,代表什么语义
    if (!(fileExists && !isProtected)) {
    // xxx
    }
    /// GOOD: 快速理解if语句的语义,如果关注细节,再看表达式的构成
    if (!fileExists || isProtected) {
    // xxx
    }

类应该短小

与函数一样,在设计类时,首要规则就是尽可能短小。对于函数,评价的指标是代码行数;对于类,评价指标则为职责,即如果无法为某个类取个精准的名称,那就表明这个类太长了。

那么,如何让类保持短小呢?这里需要先介绍一条原则:

单一职责原则:即类或模块应有且只有一条加以修改的理由,即一个类只负责一项职责。

使用单一职责,将大类拆分为若干内聚性高的小类,即可实现类应该短小的规则。

  • 所谓内聚,即类应该只有少量实体变量,类中的每个方法都应该操作一个或多个这种变量。通常而言,方法操作的变量越多,则内聚性越高。
  • 让代码能运行保持代码整洁,是截然不同的两项工作。大多数人往往把精力花在了前者,这是没问题的。问题在于,当代码能运行后,不是马上转向实现下一个功能,而是回头将臃肿的类切分成只有单一职责的去耦合单元。
  • 许多开发者可能会觉得使用单一职责会导致类的数量变多,但其实这种方式会让复杂系统中的检索和修改变得更加清晰简单。

为修改而组织

编写代码时,需要考虑以后的修改是否方便,降低修改代码的风险。

开放封闭原则 :类应当对扩展开放,对修改关闭。

单元测试

测试驱动开发(Test-Driven Development, TDD),要求在编写某个功能的代码之前先编写测试代码,然后只编写使测试通过的功能代码,通过测试来推动整个开发的进行。 这有助于编写简洁可用和高质量的代码,并加速开发过程。

虽然在日常开发中,我们并不是按照这种方式开发,但是这种思想对于提高代码能力大有裨益。也许你会好奇,这种测试先行的方法与测试后行的方法有什么区别?总结下来就是:

  • 假如你首先编写 测试用例 ,那么你将可以更早发现缺陷,同时也更容易修正它们。 当你聚焦在一个方法时,将会更容易发现这个方法的边界case,而我们代码中大部分的缺陷都是由于一些边界case疏漏导致的。
  • 在编写代码之前先编写 测试用例 ,能更早地把需求上的问题暴露出来。 在写业务代码前,先思考测试用例有哪些,一些边界问题自然就会浮出水面,迫使你去解决。
  • 首先编写 测试用例 ,将迫使你在开始写代码之前至少思考一下需求和设计,而这往往会催生更高质量的代码。 写测试,你就会站在代码用户的角度来思考,而不仅仅是一个单纯的实现者,因为你自己要使用它们,所以能设计一个更有用,更一致的接口。另外为了保证代码的可测性,也迫使你会将不同的逻辑解耦,降低测试需要的上下文。

保持测试整洁

  • 如果没有测试代码,程序员会不敢动他的业务代码。 这点在改表单、计算引擎逻辑时深有体会,经常按下葫芦浮起瓢。

  • 修改业务代码的同时,相应的测试代码也需要修改。 如果单测不能跑,那单测就毫无意义,写单测并不是为了应付,而是保证代码的正确性,所以不要因为懒得修改导致破窗效应。

  • 如何让测试代码整洁? 可读性!可读性!可读性!单元测试中的可读性比生产代码中更加重要。测试代码中,相同的代码应抽象在一起,遵循 Build-Operate-Check 原则,即每一个测试应该清晰的由以下这三个部分组成:

    • Build: 构建测试数据。
    • Operation: 操作测试数据。
    • Check: 检验操作是否得到期望结果。
  • 每个 测试用例 只做一件事。 不要写出超长的测试函数。如果想要测试3个功能,就是拆成3个测试用例。

整洁测试规则(F.I.R.S.T)

  • 快速(Fast) :测试代码需要执行得很快。测试运行慢→不想频繁运行测试代码→不能尽早发现生产代码的问题→代码腐坏。
  • 独立(Independent):测试代码不应该相互依赖,某个测试不应该成为下一个测试的设定条件。测试代码都应该可以独立运行,以及按任何顺序运行。当测试互相依赖时,会导致问题难以定位。
  • 可重复(Repeatable):测试代码应该在任何环境下都可以重复执行。
  • 自足验证(Self-Validating) :测试需要有一个bool类型的输出。不能通过看log判断测试是否通过,而应该通过断言。
  • 及时(Timely):测试代码需要及时更新,在编写业务代码之前先写测试代码。如果程序员先写业务代码,很有可能造成写测试代码不方便的问题。

作者:陌上疏影凉
链接:https://juejin.cn/post/7258445326913683517
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册