什么样的代码是好读的?

289 阅读10分钟

"Any fool can write code that a computer can understand. Good programmers write code that humans can understand." - Martin Fowler

代码是否好读,关键是要看我们的大脑是如何“解释执行”这些代码的,有哪些因素会影响我们大脑的“执行效率”。大体上有4个主要的原因使得代码不可读:

  • 太多了或者太长了:当你的大脑需要同时跟踪N个变量,跟踪N行代码的逻辑,这个N越大,效率就越低。
  • 不集中的逻辑:我们偏爱连续的,平铺直叙的而且独立的逻辑。有三个原因会使得逻辑分散:
    • 编码风格:全局变量,SIMD intrinsics v.s. SPMD 风格 GPU 计算,回调 v.s. 协程
    • 代码重用:为了重用代码,我们需要把多个执行路径合并成通用的一个
    • 非功能性需求:它和功能性代码在空间上(源代码)和时间上(运行时)都是在一起的
  • 所见非所得:如果运行的代码行为和源代码显著不同的话,我们就强迫自己去脑补这个转换过程。例如元编程,多线程共享内存等。
  • 不熟悉的概念:我们使用文本的名字和引用把一个陌生概念链接到另外一个熟悉的概念上。如果这个链接不强,我们就会感觉这东西“不明白是啥玩意”

让我们一个个来谈谈。部分例子从这些文章里摘抄而来

太多了或者太长了

我们在工作记忆里持有变量的能力是非常有限的。在脑袋里跟踪逻辑的变化,每多一步的消耗成指数级增长。

// more variable
sum1 = v.x
sum2 := sum1 + v.y
sum3 := sum2 + v.z
sum4 := sum3 + v.w
// less variable
sum = sum + v.x
sum = sum + v.y
sum = sum + v.z
sum = sum + v.w

每个变量都可能被改变。如果sum1,sum2,sum3,sum4都是常量会压力小一些。

// longer execution path to track
public void SomeFunction(int age)
{
    if (age >= 0) {
        // Do Something
    } else {
        System.out.println("invalid age");
    }
}
// shorter execution path to track
public void SomeFunction(int age)
{
    if (age < 0){
        System.out.println("invalid age");
        return;
    }

    // Do Something
}

提前返回减少了我们需要用大脑跟踪的执行路径的数量。从看一段代码到达成的结论,路径越短就越好。

不集中的逻辑

我们偏爱连续的,平铺直叙的而且独立的逻辑。让我们来解释一下这些是啥意思

  • 连续的:第2行应该要和第1行有关系,它们被摆得那么近,肯定是要表达什么因果关系
  • 平铺直叙的:你从头往下阅读代码,机器也是从头往下执行这段代码
  • 独立的:你所有需要关心的东西都在这里
// continuous, linear, isolated
private static boolean search(int[] x, int srchint) {
  for (int i = 0; i < x.length; i++)
     if (srchint == x[i])
        return true;
  return false;
}

有三个原因会使得逻辑分散:

  • 编码风格:全局变量,SIMD intrinsics v.s. SPMD 风格 GPU 计算,回调 v.s. 协程
  • 代码重用:为了重用代码,我们需要把多个执行路径合并成通用的一个
  • 非功能性需求:它和功能性代码在空间上(源代码)和时间上(运行时)都是在一起的

不集中的逻辑:编码风格

通过全局变量进行通信会导致因果关系隐晦不明,这就导致了要把相关的逻辑拼回去很困难。

// declare global variable
int g_mode;

void doSomething()
{
    g_mode = 2; // set the global g_mode variable to 2
}

int main()
{
    g_mode = 1; // note: this sets the global g_mode variable to 1.  It does not declare a local g_mode variable!

    doSomething();

    // Programmer still expects g_mode to be 1
    // But doSomething changed it to 2!

    if (g_mode == 1)
        std::cout << "No threat detected.\n";
    else
        std::cout << "Launching nuclear missiles...\n";

    return 0;
}

同样的逻辑从用全局变量转换为显式用参数传递上下文并不困难。仅仅是因为我们主动选择了某种编码风格,从而导致了代码不可读。

Second example is about SIMD programming. Write code to drive SIMD executor, we need to take care of multiple "data lane" at the same time. Notice the%ymm0is 256bit register, 8 data lane for 32 bit:

第二个例子和SIMD编程有关。编写SIMD代码的时候,我们需要同时考虑多条”数据管线“。下面的%ymm0就是一个256位的寄存器,按32位来算就是8条数据管线。

LBB0_3:
    vpaddd    %ymm5, %ymm1, %ymm8
    vblendvps %ymm7, %ymm8, %ymm1, %ymm1
    vmulps    %ymm0, %ymm3, %ymm7
    vblendvps %ymm6, %ymm7, %ymm3, %ymm3
    vpcmpeqd  %ymm4, %ymm1, %ymm8
    vmovaps   %ymm6, %ymm7
    vpandn    %ymm6, %ymm8, %ymm6
    vpand     %ymm2, %ymm6, %ymm8
    vmovmskps %ymm8, %eax
    testl     %eax, %eax
    jne       LBB0_3

如果不用一条指令同时操纵多条数据管线,而是写那种独立处理单条数据管线的逻辑就要简单得多:

float powi(float a, int b) {
    float r = 1;
    while (b--)
        r *= a;
    return r;
}

ispc.github.io/ispc.html 编译之后,上面的代码就可以从SPMD的风格,转换成SIMD的风格,其实他们是等价的代码。

第三个例子是回调v.s.协程

const verifyUser = function(username, password, callback){
   dataBase.verifyUser(username, password, (error, userInfo) => {
       if (error) {
           callback(error)
       }else{
           dataBase.getRoles(username, (error, roles) => {
               if (error){
                   callback(error)
               }else {
                   dataBase.logAccess(username, (error) => {
                       if (error){
                           callback(error);
                       }else{
                           callback(null, userInfo, roles);
                       }
                   })
               }
           })
       }
   })
};

相比

const verifyUser = async function(username, password){
   try {
       const userInfo = await dataBase.verifyUser(username, password);
       const rolesInfo = await dataBase.getRoles(userInfo);
       const logStatus = await dataBase.logAccess(userInfo);
       return userInfo;
   }catch (e){
       //handle errors as needed
   }
};

协程使得代码变成平铺直叙的风格,从上往下叙述。而代码使用回调的方式来写,是从左往右叙述的。但其实它们是等价的。只要编程语言允许,我们是可以从一种风格切换到另外一种风格来把代码变得更可读的。

不集中的逻辑:重用

为了泛化,你首先得特化。如果你需要在一段公共代码里区别支持10种产品自己特有的逻辑。有多少时间你是需要同时关心这10种产品的逻辑的?大部分是时候,你都只关系某1种产品是如何工作的。但是为了阅读这段泛化的代码(就是所谓抽象的代码),你被迫去跳过那些为其他9种产品而写的业务逻辑。这种跳跃就带来了大脑认知负担。

这里是一个简单的例子

public double PrintBill()
{
    double sum = 0;
    foreach (Drink i in drinks)
    {
        sum += i.price;
    }
    drinks.Clear();
    return sum;
}

我们认为 PrintBill 这个函数简直太有用了,必须要重用一下。但是为了happy hour,我们需要给某些饮品打折。所以代码就变成了

interface BillingStrategy
{
    double GetActPrice(double rawPrice);
}

// Normal billing strategy (unchanged price)
class NormalStrategy : BillingStrategy
{
    public double GetActPrice(Drink drink)
    {
        return drink.price;
    }
}

// Strategy for Happy hour (50% discount)
class HappyHourStrategy : BillingStrategy
{
    public double GetActPrice(Drink drink)
    {
        return drink.price * 0.5;
    }
}

public double PrintBill(BillingStrategy billingStrategy)
{
    double sum = 0;
    foreach (Drink i in drinks)
    {
        sum += billingStrategy.GetActPrice(i);
    }
    drinks.Clear();
    return sum;
}

为了把 PrintBill 泛化为同时能处理普通时段和happy hour的逻辑,我们必须特化出 BillingStrategy 的概念。这种泛化的代码,显然比之前的版本难读多了。

Also to support all kinds of cases, the code can not be specific for just one scenario. This will create a lot of "variation point". It is very possible, for certain scenario the variation is simply a empty impl to fill the hole. For example, if we need extra service charge for normal hours. The code looks like

而且为了支持各种场景,这份代码不能为某个场景搞特殊对待。这就会导致大量的”变化点"插入到代码里。很有可能,某个场景是不需要某个变化点的,然后对于这个场景就要填入一个“空”的实现。例如,假设我们需要在普通时段收取额外的费用。这份代码就变成了

interface BillingStrategy
{
    double GetActPrice(double rawPrice);
}

// Normal billing strategy (unchanged price)
class NormalStrategy : BillingStrategy
{
    public double GetActPrice(Drink drink)
    {
        return drink.price;
    }
    public double GetExtraCharge()
    {
        return 1;
    }
}

// Strategy for Happy hour (50% discount)
class HappyHourStrategy : BillingStrategy
{
    public double GetActPrice(Drink drink)
    {
        return drink.price * 0.5;
    }
    public double GetExtraCharge()
    {
        return 0;
    }
}

public double PrintBill(BillingStrategy billingStrategy)
{
    double sum = 0;
    foreach (Drink i in drinks)
    {
        sum += billingStrategy.GetActPrice(i);
    }
    sum += billingStrategy.GetExtraCharge();
    drinks.Clear();
    return sum;
}

假如你是维护happy hour逻辑的开发,这行 sum += billingStrategy.GetExtraCharge(); 和你是一点关系都没有的。到那时无论如何,你还是被迫去读了它。

而且有太多种写“分支”逻辑的方式了。函数重载,class模板,对象的多态,函数对象,跳转表,以及最朴素的if/else。为什么需要这么多种方式来表达简单的"if"?简直太荒谬了。

不集中的逻辑:非功能性需求

功能性需求和非功能性需求的代码是交织在一起的,这让代码给人阅读的时候变得困难。源代码的主要目标是描述业务上的因果关系。当某个x发生了,接下来y要发生。如果y没有发生,我们就认为是出了bug了,这就是所谓的因果链。为了让逻辑的因果链在物理的机器上被执行,很多细节需要被指定。比如:

  • 值是被分配在了栈上还是堆上
  • 参数是用值传递还是用指针传递
  • 如何把执行过程记录下来,要不然在调试的时候没法回溯
  • 如何执行多个头绪的“业务”过程,在一条线程上,多个线程上,多台机器上,然后还有要跨越数天的持续过程。

这里是一个关于错误处理的例子

err := json.Unmarshal(input, &gameScores)
if ShouldLog(LevelTrace) {
  fmt.Println("json.Unmarshal", string(input))
}
metrics.Send("unmarshal.stats", err)
if err != nil {
   fmt.Println("json.Unmarshal failed", err, string(input))
   return fmt.Errorf("failed to read game scores: %v", err.Error())
}

功能性代码其实只有 json.Unmarshal(input, &gameScores) 和 if err != nil return 这么一点。我们添加了大量非功能性代码来处理错误。要跳过这些代码,看清到底是在做什么,可不是简单的事情。

所见非所得

如果运行的代码行为和源代码显著不同的话,我们就强迫自己去脑补这个转换过程:

public class DataRace extends Thread {
  private static volatile int count = 0; // shared memory
  public void run() {
    int x = count;
    count = x + 1;
  }

  public static void main(String args[]) {
    Thread t1 = new DataRace();
    Thread t2 = new DataRace();
    t1.start();
    t2.start();
  }
}

count不总是x+1。如果另外一个线程恰好同时给count复制,他们的x+1就会覆盖掉你的x+1。

元编程也需要很丰富的想象力。和函数调用不同,你是没法跳转到这个函数的定义去一看究竟的。例如:

int main() {
    for(int i = 0; i < 10; i++) {
        char *echo = (char*)malloc(6 * sizeof(char));
        sprintf(echo, "echo %d", i);
        system(echo);
    }
    return 0;
}

代码是在运行时即时生成的。没有一个简单的办法可以去查看生成的代码,去推理这份代码的正确性。

不熟悉的概念

我们使用文本的名字和引用把一个陌生概念链接到另外一个熟悉的概念上。如果这个链接不强,我们就会感觉这东西“不明白是啥玩意”

我们认为左边的游戏比右边的游戏简单很多,为什么?因为“梯子”和“火焰”是能够和真实生活经验有对应的熟悉概念。如果代码和需求脱离了,和真实生活经验脱离了,它就会变得很难以理解。

func getThem(theList [][]int) [][]int {
    var list1 [][]int
    for _, x := range theList {
        if x[0] == 4 {
            list1 = append(list1, x)
        }
    }
    return list1
}

相比就不如下面这段代码好读

func getFlaggedCells(gameBoard []Cell) []Cell {
    var flaggedCells []Cell
    for _, cell := range gameBoard {
        if cell.isFlagged() {
            flaggedCells = append(flaggedCells, cell)
        }
    }
    return flaggedCells
}

type Cell []int

func (cell Cell) isFlagged() bool {
    return cell[0] == 4
}

因为[][]int是不熟悉的概念,但是[]Cell来源于生活

把概念从生活经验链接到代码中是通过“名字”来做的,也就是一个文本。在代码模块之间链接概念则是通过“引用”。你定义函数,然后引用函数。你定义类型,然后引用类型。在IDE里,你可以点击一个引用,然后跳转到对应的定义。当链接关系强而且清晰,我们就能够构建起一个词汇库,在这个基础上去阅读和思考代码就会更快。

而概念来自分解。每次我们做分拆的时候,都要取很多名字。有三种分拆的模式:

  • 按空间分解
  • 按时间分解
  • 按层分解

当我们感觉问题还比较大,难以直接处理的时候,总是可以再用上面三种方法去进一步分解的。只是记得要给分解出来的概念取好名字噢。