菜鸟笔记
提升您的技术认知

代码整洁之道(上篇)

目录

整洁代码重要性

有意义的命名

函数

注释

格式

对象和数据结构

错误处理

边界

单元测试

系统

迭进

总结

推荐一本书:罗伯特 C. 马丁的《代码整洁之道》。

组内最近在强调研发意识,即对线上有一颗敬畏之心:

营地意识:让代码比你来的时候更干净,警惕破窗效应;
信息同步:变更同步关键角色,相信群众力量;
风险意识:提高风险评估意识,凡事留个后路;
刨根问题:问题追查到底,而非点到为止;
自食其力:切勿依赖测试同学兜底。
本文旨在讨论营地意识,此篇为理论篇,分别从命名、函数、注释、格式、对象和数据结构、错误处理、边界、单元测试、类、系统等多个方面进行阐述。

下篇地址为:>>>

整洁代码重要性
1. 糟糕代码会毁了公司,代码整洁不仅关乎效率,还关乎生存;赶上DDL/做得快的唯一方法是保持代码整洁。

2. 假如你是位医生,病人请求做手术前别洗手,因为会花费太多时间,医生会选择拒绝遵从,因为了解疾病和感染的风险。同理,程序员遵从不了解混乱风险的产品经理的意愿,是不专业的。

3. 什么是整洁的代码?Bjarne说整洁的代码只做好一件事情,完善的错误处理;Dave说易修改、“尽量少”以及字面上表达其含义;Ron说不要重复代码,只做一件事。

有意义的命名
变量、函数、参数、类和封包均需命名。

1. 名副其实:体现本意的名称更容易理解和修改;使用读得出来的名称;使用可搜索的名称:长名胜于短名。

优化前:

//读者很容易有以下问题:
//1.list1是什么类型的东西?
//2.list[0]是什么意义?
//3.4的意义是什么?
//4.怎么使用返回的列表?
public List<int[]> getTheme() {
  List<int[]> list1 = new ArrayList<int[]>();
  for (int[] x : theList) 
    if (x[0] == 4) 
      list1.add(x);
  return list1;
}
优化后:

//1.盘名为gameBoard,而非theList的单元格列表;
//2.不用int数组表示单元格,而是另一个类;
//3.类中包含一个名副其实的函数isFlagged(),掩盖魔术数。
public List<Cell> getFlaggedCells()  {
  List<Cell> flaggedCells = new ArrayList<Cell>();
  for (Cell cell : gameBoard) 
    if (cell.isFlagged()) 
      flaggedCells.add(cell); 
  return flaggedCells;
}
2. 避免误导,别用双关语:1.避免留下掩藏代码本意的错误线索。eg:推荐accountGroup;不推荐hp(专有名称)、accountsList(如果非List类型,会误导);2.提防外形相似度高的名称。

3. 做有意义的区分:1.不推荐数字系列命名;2.废话是没有意义区分,ProductData和ProductInfo无区别,variable不应该出现在变量中,table永远别出现在表名中。

4. 避免使用编码:1.成员前缀:作者不推荐,但我觉得成员变量m打头、静态变量s打头,普通变量无打头更具标示性。2.接口与实现:接口用IShapFactory,实现用ShapeFactoryImpl。

5. 类名和方法名:类名是名词或名词短语(eg:Customer、WikiPage、Account等);方法名是动词或动词短语(eg:postPayment、deletePage等)。

6. 添加有语义的语境:几个变量能组合成类的可以抽象为类。

函数
1. 短小:20行封顶最佳,每个函数依序把你带到下一个函数。

2. 只做一件事:应该做一件事,只做一件事,做好一件事;无副作用:函数承诺只做一件事。要么做什么事,要么回答什么事,二者不可兼得。

3. 抽象层级:函数中的语句要在同一个抽象级上。区分较高、中间、较低抽象层级。

4. switch语句:1.switch语句处于较低抽象层级且永不重复;2.可以创建多态对象以尽可能避免switch语句。

5. 命名方式:1.长名称;2.花时间打磨;3.命名方式一致。

6. 函数参数:最理想的是0参数,避免3参数以上。1.单参数的普遍形式:询问该参数的问题(boolean isFileExists(XXX));将该参数操作并转换(InputStream fileOpen(XXX));事件(void passwordAttempt(XXX))。2.双参数的普遍形式:尽可能利用一些机制转换为单参数。比如构建新类、某参数变为成员变量等。3.三参数:排序、琢磨、忽略都很重要,如果函数有很多参数,应该要封装成类了。

7. 抽离try...catch代码块。

public void delete(Page page) {
  try {
    deletePageAndAllReferences(page);
  } catch (Exception e) {
    logError(e);
  }
}
private void deletePageAndAllReferences(Page page) throws Exception {
  deletePage(page);
  registry.deleteReference(page.name);
  configKeys.deleteKey(page.name.makeKey());
}
private void logError(Exception e) {
  logger.log(e.getMessage());
}

8. 别写重复代码,先写代码再打磨。

重构前:

public static String testableHtml(
  PageData pageData,
  boolean includeSuiteSetup
) throws Exception {
  WikiPage wikiPage = pageData.getWikiPage();
  StringBuffer buffer = new StringBuffer();
  if (pageData.hasAttribute("Test")) {
    if (includeSuiteSetup) {
      WikiPage suiteSetup =
        PageCrawlerImpl.getInheritedPage(
                SuiteResponder.SUITE_SETUP_NAME, wikiPage
        );
      if (suiteSetup != null) {
        WikiPagePath pagePath =
          suiteSetup.getPageCrawler().getFullPath(suiteSetup);
        String pagePathName = PathParser.render(pagePath);
        buffer.append("!include -setup .")
              .append(pagePathName)
              .append("\n");
      }
    }
    WikiPage setup = 
      PageCrawlerImpl.getInheritedPage("SetUp", wikiPage);
    if (setup != null) {
      WikiPagePath setupPath =
        wikiPage.getPageCrawler().getFullPath(setup);
      String setupPathName = PathParser.render(setupPath);
      buffer.append("!include -setup .")
            .append(setupPathName)
            .append("\n");
    }
  }
 //.....
  pageData.setContent(buffer.toString());
  return pageData.getHtml();
}

 重构一次:

public static String renderPageWithSetupsAndTeardowns(
  PageData pageData, boolean isSuite
) throws Exception {
  boolean isTestPage = pageData.hasAttribute("Test");
  if (isTestPage) {
    WikiPage testPage = pageData.getWikiPage();
    StringBuffer newPageContent = new StringBuffer();
    includeSetupPages(testPage, newPageContent, isSuite);
    newPageContent.append(pageData.getContent());
    includeTeardownPages(testPage, newPageContent, isSuite);
    pageData.setContent(newPageContent.toString());
  }
 
  return pageData.getHtml();
}

最终重构:

public static String renderPageWithSetupsAndTeardowns(
  PageData pageData, boolean isSuite) throws Exception {
  if (isTestPage(pageData))
    includeSetupAndTeardownPages(pageData, isSuite);
  return pageData.getHtml();
}
注释
别给糟糕的代码加注释-重新写吧。

1. 原则:花心思减少注释,持续维护注释,因为不准确的注释远比没注释糟糕得多;用代码去阐释注释;注释可附上文档链接;

//重构前:
// Check to see if the employee is eligible for full benefits 
if ((employee.flags & HOURLY_FLAG) && 
    (employee.age > 65))
    
//重构后:
if (employee.isEligibleForFullBenefits())
2. 注释的作用是:提供信息、对意图的解释、阐释(正确性)、警示、TODO注释、放大不合理之物重要性。

// 提供信息
// format matched kk:mm:ss EEE, MMM dd, yyyy
Pattern timeMatcher = Pattern.compile(
  "\\d*:\\d*:\\d* \\w*, \\w* \\d*, \\d*");
  
//对意图解释
public int compareTo(Object o)
{
  //XXXXX
  return 1; // we are greater because we are the right type.
}
 
//阐释
assertTrue(a.compareTo(b) != 0);    // a != b
 
//警示
// Don't run unless you
// have some time to kill. 
public void _testWithReallyBigFile()
{
  //XXXXX
}
 
//TODO注释
//TODO-MdM these are not needed
// We expect this to go away when we do the checkout model
protected VersionInfo makeVersion() throws Exception{
  return null;
}
 
//放大不合理之物的重要性
String listItemContent = match.group(3).trim();
// the trim is real important. It removes the starting 
// spaces that could cause the item to be recognized
// as another list.
new ListItemWidget(this, listItemContent, this.level + 1);
return buildList(text.substring(match.end()));

3. 警惕:多余和误导性的注释、循规式注释、日志式注释、废话注释、不明显注释等

//多余和误导性的注释
// Utility method that returns when this.closed is true. Throws an exception
// if the timeout is reached.
public synchronized void waitForClose(final long timeoutMillis) throws Exception{
  if(!closed)
  {
    wait(timeoutMillis);
    if(!closed)
      throw new Exception("MockResponseSender could not be closed");
  }
}
 
//循规式注释
/**
 * 
 * @param title The title of the CD
 * @param author The author of the CD
 * @param tracks The number of tracks on the CD
 * @param durationInMinutes The duration of the CD in minutes
 */
public void addCD(String title, String author, 
                   int tracks, int durationInMinutes) {
  CD cd = new CD();
  cd.title = title;
  //XXX
}
 
//废话注释
/** The day of the month. */
 private int dayOfMonth;
 
//能用代码别用注释
//重构前
// does the module from the global list <mod> depend on the
// subsystem we are part of?
if (smodule.getDependSubsystems().contains(subSysMod.getSubSystem()))
//重构后
ArrayList moduleDependees = smodule.getDependSubsystems(); 
String ourSubSystem = subSysMod.getSubSystem();
if (moduleDependees.contains(ourSubSystem))
 
//不明显联系
/*
 * start with an array that is big enough to hold all the pixels
 * (plus filter bytes), and an extra 200 bytes for header info
 */
this.pngBytes = new byte[((this.width + 1) * this.height * 3) + 200];

格式
垂直格式:

1. 最多200~500行,向报纸学习。

2. 概念间垂直方向上的间隔:封包声明、导入声明和每个函数之间,都有空白行隔开(每个空行都是一个线索,标识出新概念);靠近的代码行暗示了它们之间的紧密关系;垂直顺序:被调用的函数应该放在执行调用的函数下面,建立了自顶向下贯穿源代码模块的良好信息流。

横向格式:

1. 一行不超过120个字符,一屏。

2. 水平方向上的区隔与靠近:空格字符将相关性较弱的事物区隔开。赋值操作符周围、参数一一隔开等。

代码示例:

public class WikiPageResponder implements SecureResponder {
  protected WikiPage page;
  protected PageData pageData;
  protected String pageTitle;
  protected Request request;
  protected PageCrawler crawler;
 
  public Response makeResponse(FitNesseContext context, Request request)
    throws Exception {
    String pageName = getPageNameOrDefault(request, "FrontPage");
    loadPage(pageName, context);
    if (page == null)
      return notFoundResponse(context, request);
    else
      return makePageResponse(context);
  }
 
  private String getPageNameOrDefault(Request request, String defaultPageName) 
  {
    String pageName = request.getResource();
    if (StringUtil.isBlank(pageName))
      pageName = defaultPageName;
    return pageName;
  }
 
  protected void loadPage(String resource, FitNesseContext context)
    throws Exception {
    WikiPagePath path = PathParser.parse(resource);
    crawler = context.root.getPageCrawler();
    crawler.setDeadEndStrategy(new VirtualEnabledPageCrawler());
    page = crawler.getPage(context.root, path);
    if (page != null)
      pageData = page.getData();
  }
 
  private Response notFoundResponse(FitNesseContext context, Request request)
    throws Exception {
    return new NotFoundResponder().makeResponse(context, request);
  }
 
  private SimpleResponse makePageResponse(FitNesseContext context)
    throws Exception {
    pageTitle = PathParser.render(crawler.getFullPath(page));
    String html = makeHtml(context);
 
    SimpleResponse response = new SimpleResponse();
    response.setMaxAge(0);
    response.setContent(html);
    return response;
  }
...

对象和数据结构
1. 数据抽象:隐藏实现关乎抽象,类并不是简单地用取值器和赋值器将其变量推向外界,而是暴露抽象接口,以便用户无需了解数据的实现操作数据本体。

2. 数据、对象的反对称性:过程式代码难以添加新数据结构,因为必须修改所有函数;面向对象代码难以添加新函数,因为需要修改所有类。

//过程式形状代码
public class Square {
  public Point topLeft;
  public double side;
}
 
public class Rectangle {
  public Point topLeft;
  public double height;
  public double width;
}
 
public class Circle {
  public Point center;
  public double radius;
}
 
public class Geometry {
  public final double PI = 3.141592653589793;
 
  public double area(Object shape) throws NoSuchShapeException 
  {
    if (shape instanceof Square) {
      Square s = (Square)shape;
      return s.side * s.side;
    }
    else if (shape instanceof Rectangle) {
      Rectangle r = (Rectangle)shape;
      return r.height * r.width;
    }
    else if (shape instanceof Circle) {
      Circle c = (Circle)shape;
      return PI * c.radius * c.radius;
    }
    throw new NoSuchShapeException();
  }
}

//多态式形状代码
public class Square implements Shape {
  private Point topLeft;
  private double side;
 
  public double area() {
    return side*side;
  }
}
 
public class Rectangle implements Shape {
  private Point topLeft;
  private double height;
  private double width;
 
  public double area() {
    return height * width;
  }
}
 
public class Circle implements Shape {
  private Point center;
  private double radius;
  public final double PI = 3.141592653589793;
 
  public double area() {
    return PI * radius * radius;
  }
}

3. 得墨忒定律:模块不应该了解它所操作对象的内部细节,链式调用可能会需要各种判空逻辑。

//重构前
final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();
 
//重构后
//我们发现,取得临时目录绝对路径的初衷是为了创建制定名称的临时文件。
BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName);
错误处理
1. 使用异常返回而非返回码

//重构前
public class DeviceController {
  ...
  public void sendShutDown() {
    DeviceHandle handle = getHandle(DEV1);
    // Check the state of the device
    if (handle != DeviceHandle.INVALID) {
      // Save the device status to the record field
      retrieveDeviceRecord(handle);
      // If not suspended, shut down
      if (record.getStatus() != DEVICE_SUSPENDED) {
        pauseDevice(handle);
        clearDeviceWorkQueue(handle);
        closeDevice(handle);
      } else {
        logger.log("Device suspended.  Unable to shut down");
      }
    } else {
      logger.log("Invalid handle for: " + DEV1.toString());
    }
  }
 }
 
 //重构后
 //采用异常处理
 public class DeviceController {
  ...
 
  public void sendShutDown() {
    try {
      tryToShutDown();
    } catch (DeviceShutDownError e) {
      logger.log(e);
    }
  }
 
  private void tryToShutDown() throws DeviceShutDownError {
    DeviceHandle handle = getHandle(DEV1);
    DeviceRecord record = retrieveDeviceRecord(handle);
 
    pauseDevice(handle);
    clearDeviceWorkQueue(handle);
    closeDevice(handle);
  }
 
  private DeviceHandle getHandle(DeviceID id) {
    ...
    throw new DeviceShutDownError("Invalid handle for: " + id.toString());
    ...
  }
 
  ...
}

2. 缩小异常的具体范围。比如将Exception修改为FileNotFoundException

3. 特例模式:创建一个类或配置一个对象,用于处理特例。

//重构前:
try {
  MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
  m_total += expenses.getTotal();
} catch(MealExpensesNotFound e) {
  m_total += getMealPerDiem();
}
 
//重构后
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();
//异常行为被封装至特例对象中
public class PerDiemMealExpenses implements MealExpenses {
  public int getTotal() {
    // return the per diem default
  }
}

4. 别返回null值、别传递null值。

//重构前:
List<Employee> employees = getEmployees();
if (employees != null) {
  for(Employee e : employees) {
    totalPay += e.getPay();
  }
}
 
//重构后
//使用Collections.emptyList();返回一个预定义的不可变列表,可用于达到这种目的。避免空指针异常。
List<Employee> employees = getEmployees();
for(Employee e : employees) {
  totalPay += e.getPay();
}
 
public List<Employee> getEmployees() {
  if( .. there are no employees .. ) 
    return Collections.emptyList();
}

边界
使用第三方SDK应对其提供的API做好封装。

单元测试
1. 整洁的测试:测试显然呈现构造-操作-检验模式;分三个环节:构造测试数据、操作测试数据、检验操作是否得到期望的结果。

2. FIRST原则:

快速(Fast):测试应当足够快;
 
独立(Independent):测试应当相互独立,每次只测试一个概念;
 
可重复(Repeatable):测试应当在任何环境中重复通过;
 
自足验证(Self-Validating):测试应该有布尔值输出,无需查看日志文件;
 
及时(Timely):测试应该及时编写。

1. 类的组织:

(1)先公共静态变量,后私有静态变量,私有实体变量;

(2)自顶向下:公共函数应在变量列表之后,被调用的私有工具函数跟在公共函数后面;

(3)做好封装,放开封装是下策。

2. 类应当短小:对于函数,是代码行,对于类,是权责。

(1)单一权责原则:系统应该由许多短小的类而不是少数巨大的类组成,每个小类封装了一个权责,只有一个修改的理由,并与其他少数类一起协同达成期望的系统行为。

(2)内聚:一般来说,创造极大化内聚类是不可取也不可能的,另一方面,我们希望内聚性保持较高未知,内聚性高,意味着类中的方法和变量互相依赖、互相组合成一个逻辑整体。

//一个内聚类
public class Stack {
  private int topOfStack = 0;
  List<Integer> elements = new LinkedList<Integer>();
 
  public int size() {
    return topOfStack;
  }
 
  public void push(int element) {
    topOfStack++;
    elements.add(element);
  }
 
  public int pop() throws PoppedWhenEmpty {
    if (topOfStack == 0)
      throw new PoppedWhenEmpty();
    int element = elements.get(--topOfStack);
    elements.remove(topOfStack);
    return element;
  }
}

(3)保持内聚性就会得到许多短小的类。以文中一段代码(P136)为例:PrimePrinter类中只有主程序,职责是处理执行环境,如果调用方式有变,它也会变化;RowColumnPagePrinter类是将数字列表格式化到固定行列的页面,若输出格式有变化,它也会变化。PrimeGenrator类懂得如何生成素数列表,如果计算素数算法发生改动,则类会改动。

3. 类应当对扩展开放,对修改封闭。

系统
系统应该是整洁的:将构造和使用分开;工厂模式;依赖注入等。后续会在《大话设计模式》具体讨论。

迭进
1. 四条原则:运行所有测试;不可重复;表达程序员的意图;尽可能减少类和方法数量。

2. 运行所有测试:测试用例越多,系统约会贴近低耦合、高内聚的目标

3. 消除重复:抽离方法;模板模式。

 //重构前
 public void scaleToOneDimension(
     float desiredDimension, float imageDimension) {
   if (Math.abs(desiredDimension - imageDimension) < errorThreshold)
      return;
   float scalingFactor = desiredDimension / imageDimension;
   scalingFactor = (float)(Math.floor(scalingFactor * 100) * 0.01f);
 
   RenderedOp newImage = ImageUtilities.getScaledImage(
      image, scalingFactor, scalingFactor);
   image.dispose();
   System.gc();
   image = newImage;
 }
 
 public synchronized void rotate(int degrees) {
   RenderedOp newImage = ImageUtilities.getRotatedImage(
     image, degrees);
   image.dispose();
   System.gc();
   image = newImage;
}
 
//重构后
public void scaleToOneDimension(
    float desiredDimension, float imageDimension) {
  if (Math.abs(desiredDimension - imageDimension) < errorThreshold)
     return;
   float scalingFactor = desiredDimension / imageDimension;
   scalingFactor = (float)(Math.floor(scalingFactor * 100) * 0.01f);
   replaceImage(ImageUtilities.getScaledImage(
 image, scalingFactor, scalingFactor)); 
}
 
public synchronized void rotate(int degrees) {
  replaceImage(ImageUtilities.getRotatedImage(image, degrees)); 
}
private void replaceImage(RenderedOp newImage) {
 image.dispose();
 System.gc();
 image = newImage;
}

//重构前
public class VacationPolicy {
  public void accrueUSDivisionVacation() {
    // code to calculate vacation based on hours worked to date
    // ...
    // code to ensure vacation meets US minimums
    // ...
    // code to apply vaction to payroll record
    // ...
  }
 
   public void accrueEUDivisionVacation() {
     // code to calculate vacation based on hours worked to date
     // ...
     // code to ensure vacation meets EU minimums
     // ...
     // code to apply vaction to payroll record
     // ...
   }
 }
 
 
 //重构后
 abstract public class VacationPolicy {
   public void accrueVacation() {
     calculateBaseVacationHours(); 
    alterForLegalMinimums(); 
    applyToPayroll(); 
   }
 
   private void calculateBaseVacationHours() { /* ... */ };
   abstract protected void alterForLegalMinimums();
   private void applyToPayroll() { /* ... */ };
}
 
public class USVacationPolicy extends VacationPolicy {
  @Override protected void alterForLegalMinimums() {
    // US specific logic
  }
}
 
public class EUVacationPolicy extends VacationPolicy {
  @Override protected void alterForLegalMinimums() {
    // EU specific logic
  }
}

3. 表达力:选择比较好的名称;保持函数和类的短小;多重构;编写好的单元测试。

4. 尽可能少的类和方法:优先级较低。

总结
重要的九条建议:

关于命名:类名和方法名:类名是名词或名词短语(eg:Customer、WikiPage、Account等);方法名是动词或动词短语(eg:postPayment、deletePage等)。添加有语义的语境:几个变量能组合成类的可以抽象为类。
关于函数:短小精悍:20行封顶最佳,每个函数依序把你带到下一个函数。只做一件事:应该做一件事,只做一件事,做好一件事。无副作用:函数承诺只做一件事。要么做什么事,要么回答什么事,二者不可兼得。
关于注释:花心思减少注释,不准确的注释远比没注释糟糕得多;用代码去阐释注释;注释可附上文档链接。
关于格式:垂直方向最多200~500行,向报纸学习。垂直方向顺序:被调用的函数应该放在执行调用的函数下面,建立了自顶向下贯穿源代码模块的良好信息流。水平方向一行不超过120个字符。
关于对象和数据结构:数据、对象的反对称性:过程式代码难以添加新数据结构,因为必须修改所有函数;面向对象代码难以添加新函数,因为需要修改所有类。
关于错误处理:别返回null值、别传递null值。创建一个类或配置一个对象,用于处理特例。
关于边界:FIRST原则:快速(Fast);独立(Independent);可重复(Repeatable);自足验证(Self-Validating);及时(Timely)。
关于类:类应当短小:单一权责原则,内聚性高,保持内聚性就会得到许多短小的类。
关于迭代:四条原则:运行所有测试;不可重复;表达程序员的意图;尽可能减少类和方法数量。