[关闭]
@qidiandasheng 2021-02-08T08:45:13.000000Z 字数 12163 阅读 573

代码简洁之道(😁)

架构


有意义的命名

名副其实(获取已标记点的数组)

选个好名字要花时间,但省下来的时间比花掉的多。注意命名,而且一旦发现有更好的名称,就换掉旧的。这么做,读你代码的人(包括你自己)都会更开心。

  1. public List<int[]> getThem() {
  2. List<int[]> list1 = new ArrayList<int[]>();
  3. for (int[] x : theList)
  4. if (x[0] == 4)
  5. list1.add(x);
  6. return list1;
  7. }
  1. public List<Cell> getFlaggedCells() {
  2. List<Cell> flaggedCells = new ArrayList<Cell>(); for (Cell cell : gameBoard)
  3. if (cell.isFlagged())
  4. flaggedCells.add(cell);
  5. return flaggedCells;
  6. }

做有意义的区分

  1. public static void copyChars(char a1[], char a2[]) {
  2. for (int i = 0; i < a1.length; i++) {
  3. a2[i] = a1[i];
  4. }
  5. }
  1. public static void copyChars(char source[], char destination[]) {
  2. for (int i = 0; i < source.length; i++) {
  3. destination[i] = source[i];
  4. }
  5. }

没意义的区分

要区分名称,就要以读者能鉴别 不同之处的方式来区分

近似的命名

调用方根本不知道改调用那个函数

使用读的出来的名称

  1. class DtaRcrd102 {
  2. private Date genymdhms;
  3. private Date modymdhms;
  4. private final String pszqint = "102";
  5. };
  1. class Customer {
  2. private Date generationTimestamp;
  3. private Date modificationTimestamp;;
  4. private final String recordId = "102";
  5. };

使用可搜索的名称

单字母名称和数字常量有个问题,就是很难在一大篇文字中找出来。找MAX_CLASSES_PER_STUDENT很容易, 但想找数字7就麻烦了,它可能是某些文件名或其他常量定义的一部分,出现在因不同意图而采用的各种表达式中。如果该常量是个长数字,又被人错改过,就会逃过搜索,从而造成错误。

  1. for (int j=0; j<34; j++) {
  2. s += (t[j]*4)/5;
  3. }
  1. int realDaysPerIdealDay = 4;
  2. const int WORK_DAYS_PER_WEEK = 5;
  3. int sum = 0;
  4. for (int j=0; j < NUMBER_OF_TASKS; j++) {
  5. int realTaskDays = taskEstimate[j] * realDaysPerIdealDay;
  6. int realTaskWeeks = (realdays / WORK_DAYS_PER_WEEK);
  7. sum += realTaskWeeks;
  8. }

类名和方法名

类名尽量用名词和名词短语,方法名尽量用动词或动词短语。

添加有意义的语境

很少有名称是能自我说明的——多数都不能。反之,你需要用有良好命名的类、函数或名称空间来放置名称,给读者提供语境。如果没这 么做,给名称添加前缀就是最后一招了。

设想你有名为firstName、lastName、street、houseNumber、city、state和zipcode的变量。当它们搁一块儿的时候,很明确是构成了一个地址。不过,假使只是在某个方法中看见孤零零一个state变量呢?你会理所当然推断那是某个地址的 一部分吗?可以添加前缀addrFirstName、 addrLastName、addrState等,以此提供语境。

当然更好的方案是创建名为Address的类。这样即便是编译器也会知道这些变量隶属某个更大的概念了。

不要添加没用的语境

只要短名称足够清楚,就要比长名称好。别给名称添加不必要的语境。对于Address类的实体来说,accountAddress和 customerAddress都是不错的名称,不过用在类名 上就不太好了。Address是个好类名。

函数

短小

函数的第一规则是要短小。第二条规则是还要更短小。我们常说函数不该长于一屏。

if语句、else语句、while语句等,其中的代码块应该只有一行。该行大抵应该是一个函数调用语句。这样不但能 保持函数短小,而且,因为块内调用的函数拥有较具说明性的名称,从而增加了文档上的价值。这也意味着函数不应该大到足以容纳嵌套结构。所以,函数的缩进层级不该多于一层或两层。当然这样的函数易于阅读和理解。

函数应当只做一件事

函数应该做一件事。做好这件事。只做这一件事。要判断函数是否不止做了一件事,还有一个方法,就是看是否能再拆出一个函数。

每个函数一个抽象层级

要确保函数只做一件事,函数中的语句都要在同一抽象层级上。函数中混杂不同抽象层级,往往让人迷惑。读者可能无法判断某个表达式是基础概念还是细节。更恶劣的是,就像破损的窗户,一旦细节与基础概念混杂,更多的细节就会在函数中纠结起来。

我们想要让代码拥有自顶向下的阅读顺序。我们想要让每个函数后面都跟着位于下一抽象层级的函数,这样 一来在查看函数列表时,就能偱抽象层级向下阅读了。我把这叫做向下规则。

switch语句

  1. public Money calculatePay(Employee e)
  2. throws InvalidEmployeeType {
  3. switch (e.type) {
  4. case COMMISSIONED:
  5. return calculateCommissionedPay(e);
  6. case HOURLY:
  7. return calculateHourlyPay(e);
  8. case SALARIED:
  9. return calculateSalariedPay(e);
  10. default:
  11. throw new InvalidEmployeeType(e.type);
  12. }
  13. }

将switch语句埋到抽象工厂底下,不让任何人看到。该工厂使用switch语句为Employee的派生物创建适当的实体,而不同的 函数,如calculatePay、isPayday和deliverPay等,则藉由 Employee接口多态地接受派遣。

  1. public abstract class Employee {
  2. public abstract boolean isPayday();
  3. public abstract Money calculatePay();
  4. public abstract void deliverPay(Money pay);
  5. }
  6. -----------------
  7. public interface EmployeeFactory {
  8. public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType;
  9. }
  10. -----------------
  11. public class EmployeeFactoryImpl implements EmployeeFactory {
  12. public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType{
  13. switch (r.type) {
  14. case COMMISSIONED:
  15. return new CommissionedEmployee(r) ;
  16. case HOURLY:
  17. return new HourlyEmployee(r);
  18. case SALARIED:
  19. return new SalariedEmploye(r);
  20. default:
  21. throw new InvalidEmployeeType(r.type);
  22. }
  23. }
  24. }

使用描述性名称

沃德原则:“如果每个例程都让你感到深合己意,那就是整洁代码。”要遵循这一原则,大半工作都在于为只做一件事的小函数取个好名字。函数越短小、功能越 集中,就越便于取个好名字。

别害怕长名称。长而具有描述性的名称,要比短而令人费解的名称好。长而具有描述性的名称,要比描述性的
长注释好。使用某种命名约定,让函数名称中的多个单词容易阅读,然后使用这些单词给函数取个能说清其功用的名称。

函数参数

最理想的参数数量是零(零参数函数),其次是一(单参数函数),再次是二(双参数函数),应尽量避免三(三参数函数)。有足够特殊的理由才能用三个以上参数(多参数函数)——所以无论如何也不要这么做。参数与函数名处在不同的抽象层级,它要求你了解目前并不特别重要的细节。

如果函数看来需要两个、三个或三个以上参数,就说明其中一些参数应该封装为类了。

  1. Circle makeCircle(double x, double y, double radius);
  2. Circle makeCircle(Point center, double radius);

无副作用

函数使 用标准算法来匹配userName和password。如果匹配成功,返回true,如果失败则返回false。但它会有副作用。

  1. public class UserValidator {
  2. private Cryptographer cryptographer;
  3. public boolean checkPassword(String userName, String password) {
  4. User user = UserGateway.findByName(userName);
  5. if (user != User.NULL) {
  6. String codedPhrase = user.getPhraseEncodedByPassword();
  7. String phrase = cryptographer.decrypt(codedPhrase, password);
  8. if ("Valid Password".equals(phrase)) {
  9. Session.initialize();
  10. return true;
  11. }
  12. }
  13. return false;
  14. }
  15. }

副作用就在于对Session.initialize( )的调用。checkPassword函数,顾名思义,就是用来检查密码的。该名称并未暗示它会初始化该次会话。所以,当某个误信了函数名的调用者想要检查用户有效性时,就得冒抹除现有会话数据的风险。

这一副作用造出了一次时序性耦合。也就是说checkPassword只能在特定时刻调用(换言之,在初始化会话是安全的时候调用)。如果在不合适的时候调用,会话数据就有可能沉默地丢失。时序性耦合令人迷惑,特别是 当它躲在副作用后面时。如果一定要时序性耦合,就应该在函数名称中说明。在本例中,可以重命名函数为checkPasswordAndInitializeSession,虽然那还是违反了“只做一件事”的规则。

分隔指令与询问

  1. if (set("username", "unclebob")){
  2. }

从读者的角度考虑一下吧。这是什么意思呢?它是在 问username属性值是否之前已设置为unclebob吗?或者它是 在问username属性值是否成功设置为unclebob呢?从这行调用很难判断其含义,因为set是动词还是形容词并不清楚。

  1. if (attributeExists("username")) {
  2. setAttribute("username", "unclebob");
  3. ...
  4. }

使用异常替代返回错误码

当返回错误码时,就是在要求调用者立刻处理错误。

  1. if (deletePage(page) == E_OK) {
  2. if (registry.deleteReference(page.name) == E_OK) {
  3. if (configKeys.deleteKey(page.name.makeKey()) == E_OK){
  4. logger.log("page deleted");
  5. } else {
  6. logger.log("configKey not deleted");
  7. }
  8. } else {
  9. logger.log("deleteReference from registry failed");
  10. }
  11. } else {
  12. logger.log("delete failed");
  13. return E_ERROR;
  14. }

如果使用异常替代返回错误码,错误处理代码就能从主路径代码中分离出来,得到简化:

  1. try {
  2. deletePage(page);
  3. registry.deleteReference(page.name);
  4. configKeys.deleteKey(page.name.makeKey());
  5. }
  6. catch (Exception e) {
  7. logger.log(e.getMessage());
  8. }

抽离Try/Catch代码块,Try/catch代码块丑陋不堪。它们搞乱了代码结构,把错误处理与正常流程混为一谈。最好把try和catch代码块的主 体部分抽离出来,另外形成函数。

  1. public void delete(Page page) {
  2. try {
  3. deletePageAndAllReferences(page);
  4. }
  5. catch (Exception e) {
  6. logError(e);
  7. } }
  8. private void deletePageAndAllReferences(Page page) throws Exception {
  9. deletePage(page);
  10. registry.deleteReference(page.name);
  11. configKeys.deleteKey(page.name.makeKey());
  12. }
  13. private void logError(Exception e) {
  14. logger.log(e.getMessage());
  15. }

如何写出这样的函数

写代码和写别的东西很像。在写论文或文章时,你先想什么就写什么,然后再打磨它。初稿也许粗陋无序,你
就斟酌推敲,直至达到你心目中的样子。

注释

什么也比不上放置良好的注释来得有用。什么也不会比乱七八糟的注释更有本事搞乱一个模块。什么也不会比陈旧、提供错误信息的注释更有破坏性。

注释存在的时间越久,就离其所描述的代码越远,越来越变得全然错误。原因很简单。程序员不能坚持维护注释。代码在变动,在演化。从这里移到那里。彼此分离、重造又合到一处。很不幸,注释并不总是随之变动——不能总是跟着走。

写注释的常见动机之一是糟糕的代码的存在。我们编写一个模块,发现它令人困扰、乱七八糟。我们知道,它烂透了。 我们告诉自己:“喔,最好写点注释!”不!最好是把代码弄干净!

带有少量注释的整洁而有表达力的代码,要比带有大量注释的零碎而复杂的代码像样得多。与其花时间编写解释你搞出的糟糕的代码的注释,不如花时间清洁那堆糟糕的代码。

Bad

  1. // Check to see if the employee is eligible for full benefits
  2. if ((employee.flags & HOURLY_FLAG) &&
  3. (employee.age > 65))

Good

  1. if (employee.isEligibleForFullBenefits())

Bad

  1. // does the module from the global list <mod> depend on the
  2. // subsystem we are part of?
  3. if (smodule.getDependSubsystems().contains(subSysMod.getSubSystem()))

Good

  1. ArrayList moduleDependees = smodule.getDependSubsystems();
  2. String ourSubSystem = subSysMod.getSubSystem();
  3. if (moduleDependees.contains(ourSubSystem))

格式

对象和数据结构

数据、对象的反对称性

面向过程:如果给Geometry类添加一个primeter()函数会怎样。那些形状类根本不会因此而受影响!另一方面,如果添加一个新形状,就得修改Geometry中的所有函数来处理它。

  1. public class Square {
  2. public Point topLeft;
  3. public double side;
  4. }
  5. public class Rectangle {
  6. public Point topLeft;
  7. public double height;
  8. public double width;
  9. }
  10. public class Circle {
  11. public Point center;
  12. public double radius;
  13. }
  14. public class Geometry {
  15. public final double PI = 3.141592653589793;
  16. public double area(Object shape) throws NoSuchShapeException
  17. {
  18. if (shape instanceof Square) {
  19. Square s = (Square)shape;
  20. return s.side * s.side;
  21. }
  22. else if (shape instanceof Rectangle) {
  23. Rectangle r = (Rectangle)shape;
  24. return r.height * r.width;
  25. }
  26. else if (shape instanceof Circle) {
  27. Circle c = (Circle)shape;
  28. return PI * c.radius * c.radius;
  29. }
  30. throw new NoSuchShapeException();
  31. }
  32. }

面向对象:如果添加一个新形状只需要添加新类,但是如果要添加新函数就需要修改所有的类。

  1. public class Square implements Shape {
  2. private Point topLeft;
  3. private double side;
  4. public double area() {
  5. return side*side;
  6. }
  7. }
  8. public class Rectangle implements Shape {
  9. private Point topLeft;
  10. private double height;
  11. private double width;
  12. public double area() {
  13. return height * width;
  14. }
  15. }
  16. public class Circle implements Shape {
  17. private Point center;
  18. private double radius;
  19. public final double PI = 3.141592653589793;
  20. public double area() {
  21. return PI * radius * radius;
  22. }
  23. }

过程式代码(使用数据结构的代码)便于在不改动既有数据结构的前提下添加新函数。面向对象代码便于在不改动既有函数的前提下添加新类。过程式代码难以添加新数据结构,因为必须修改所有函数。面向对象代码难以添加新函数,因为必须修改所有类。

错误处理

使用异常而非返回码

这类手段的问题在于,它们搞乱了调用者代码。调用者必须在调用之后即刻检查错误。不幸的是,这个步骤很容易被遗忘。所以,遇到错误时,最好抛出一个异常。调用代码很整洁,其逻辑不会被错误处理搞乱。

先写Try-Catch-Finally语句

异常的妙处之一是,它们在程序中定义了一个范围。执行try-catch-finally语句中try部分的代码时,你是在表 明可随时取消执行,并在catch语句中接续。在某种意义上,try代码块就像是事务。catch代码块将程序维持在一种持续状态,无论try代码块中发生了什么均如此。所以,在编写可能抛出异常的代码时,最好先写出try-catch-finally语句。这能帮你定义代码的用户应该期待什么,无论try代码块中执行的代码出什么错都一样。

别返回null

  1. List<Employee> employees = getEmployees();
  2. if (employees != null) {
  3. for(Employee e : employees) {
  4. totalPay += e.getPay();
  5. }
  6. }

getExployees可能返回null,但是否一定要这么做呢?如果修改getEmployee,返回空列表,就能使代码整洁起来:

  1. List<Employee> employees = getEmployees();
  2. for(Employee e : employees) {
  3. totalPay += e.getPay();
  4. }

别传递null值

在方法中返回null值是糟糕的做法,但将null值传递 给其他方法就更糟糕了。除非API要求你向它传递null 值,否则就要尽可能避免传递null值。

  1. public class MetricsCalculator
  2. {
  3. public double xProjection(Point p1, Point p2) {
  4. return (p2.x p1.x) * 1.5;
  5. }
  6. ...
  7. }

传入null的情况

  1. calculator.xProjection(null, new Point(12, 13));

创建一个新异常类型并抛出:

  1. public class MetricsCalculator
  2. {
  3. public double xProjection(Point p1, Point p2) {
  4. if (p1 == null || p2 == null) {
  5. throw InvalidArgumentException(
  6. "Invalid argument for MetricsCalculator.xProjection");
  7. }
  8. return (p2.x p1.x) * 1.5;
  9. }
  10. }

断言:

  1. public class MetricsCalculator
  2. {
  3. public double xProjection(Point p1, Point p2) {
  4. assert p1 != null : "p1 should not be null";
  5. assert p2 != null : "p2 should not be null";
  6. return (p2.x p1.x) * 1.5;
  7. }
  8. }

看上去很美,但仍未解决问题。如果有人传入null 值,还是会得到运行时错误。

在大多数编程语言中,没有良好的方法能对付由调用者意外传入的null值。事已如此,恰当的做法就是禁止传入null值。这样,你在编码的时候,就会时时记住参数列表中的null值意味着出问题了,从而大量避免这种无心之失。

边界

使用第三方代码

  1. Map sensors = new HashMap();

当代码的其他部分需要访问这些sensor,就会有这行代码:

  1. Sensor s = (Sensor)sensors.get(sensorId);

这行代码一再出现。代码的调用端承担了从Map中取得对象并将其转换为正确类型的职责。行倒是行,却并非整洁的代码。而且,这行代码 并未说明自己的用途。通过对泛型的使用,这段代码可读性可以大大提高,如下所示:

  1. Map<Sensor> sensors = new HashMap<Sensor>();
  2. ...
  3. Sensor s = sensors.get(sensorId );

使用Map的更整洁的方式大致如下。Sensors的用户不必关心是否用了泛型,那将是(也该是)实现细节才关心的。

  1. public class Sensors {
  2. private Map sensors = new HashMap();
  3. public Sensor getById(String id) {
  4. return (Sensor) sensors.get(id);
  5. }
  6. //片段
  7. }

边界上的接口(Map)是隐藏的。它能随来自应用程序其他部分的极小的影响而变动。对泛型的使用不再是个大问题,因为转换和类型管理 是在Sensors类内部处理的。该接口也经过仔细修整和归置以适应应用程序的需要。结果就是得到易于理解、难以被误用 的代码。Sensors类推动了设计和业务的规则。

类的组织

类应该从一组变量列表开始。如果有公共静态常量,应该先出现。然后是私有静态变量,以及私有实体变量。很少会有公共变量。公共函数应跟在变量列表之后。我们喜欢把由某个公共函数调用的私有工具函数紧随在该公共函数后面。这符合了自顶向下原则,让程序读起来就像一篇报纸文章。

类应该短小

对于函数,我们通过计算代码行数衡量大小。对于类我们采用不同的衡量方法,计算权责。

单一权责原则

单一权责原则(SRP)认为,类或模块应有且只有一条加以修改的理由。该原则既给出了权责的定义,又是关于类的长度的指导方针。类只应有一个权责——只有一条修改的理由。

许多开发者害怕数量巨大的短小单一目的类会导致难以一目了然抓住全局。他们认为,要搞清楚一件较
大工作如何完成,就得在类与类之间找来找去。

然而,有大量短小类的系统并不比有少量庞大类的系统拥有更多移动部件,其数量大致相等。问题是:你是想把工具归置到有许多抽屉、每个抽屉中装有定义和标记良好的组件的工具箱中呢,还是想要少数几个能随便把所有东西扔进去的抽屉?

每个达到一定规模的系统都会包括大量逻辑和复杂性。管理这种复杂性的首要目标就是加以组织,以便开发者知道到哪儿能找到东西,并且在某个特定时间只需要理解直接有关的复杂性。

内聚

类应该只有少量实体变量。类中的每个方法都应该操作一个或多个这种变量。通常而言,方法操作的变量越多,就越黏聚到类上。如果一个类中的每个变量都被每个方法所使用,则该类具有最大的内聚性。

我们希望内聚性保持在较高位置。内聚性高,意味着类中的方法和变量互相依赖、互相结合成一个逻辑整体。

保持内聚性就会得到许多短小的类

当类丧失了内聚性,就拆分它!所以,将大函数拆为许多小函数,往往也是将类拆分为多个小类的时机。程序会更加有组织,也会拥有更为透明的结构。

为了修改而组织

对于多数系统,修改将一直持续。每处修改都让我们冒着系统其他部分不能如期望般工作的风险。在整洁的系统中,我们对类加以组织,以降低修改的风险。

系统

如何建造一个城市

你能自己掌管一切细节吗?大概不行。即便是管理一 个既存的城市,也是一个人无法做到的。不过,城市还是 在运转(多数时候)。因为每个城市都有一组组人管理不同的部分,供水系统、供电系统、交通、执法、立法,诸 如此类。有些人负责全局 ,其他人负责细节。

城市能运转,还因为它演化出恰当的抽象等级和模 块,好让个人和他们所管理的“组件”即便在不了解全局时 也能有效地运转。

将系统的构造与使用分开

软件系统应将启始过程和启始过程之后的运行时逻辑分离开,在启始过程中构建应用对象,也会存在互相缠结
的依赖关系。

分解main

将构造与使用分开的方法之一是将全部构造过程搬迁 到main或被称之为main的模块中,设计系统的其余部分 时,假设所有对象都已正确构造和设置。

工厂

有时应用程序也要负责确定何时 创建对象。比 如,在某个订单处理系统中,应用程序必须创建LineItem实 体,添加到Order对象。在这种情况下,我们可以使用抽象工厂模式让应用自行控制何时创建LineItems,但构造的 细节却隔离于应用程序代码之外。

依赖注入

有一种强大的机制可以实现分离构造与使用,那就是 依赖注入 (Dependency Injection,DI),控制反转(Inversion of Control,IoC)在依赖管理中的一种应用手段。控制反转将第二权责从对象中拿出来,转移到另一个 专注于此的对象中,从而遵循了单一权责原则。在依赖管理情景中,对象不应负责实体化对自身的依赖。反之,它应当将这份权责移交给其他“有权力”的机制,从而实现控制的反转。因为初始设置是一种全局问题,这种授权机制 通常要么是main例程,要么是有特定目的的容器 。

逐步改进

代码整洁之道的第十四章,是一个Java程序代码的例子,可以试试看用OC写一份逐步改进的代码。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注