跳转到内容

计算机编程/编码风格/最小化嵌套

来自 Wikibooks,开放世界中的开放书籍

深度嵌套的代码是 结构化编程 的常见特征。虽然它有一些优点,在该部分中讨论,但它经常被认为难以阅读,并且是一种反模式:“扁平优于嵌套”。[1]

具体来说,嵌套的控制流 - 条件块(if)或循环(for,while) - 在超过三层嵌套时难以理解,[2][3] 并且具有很高的圈复杂度。这被称为“危险的深度嵌套”[3] 或,在嵌套 if 语句的情况下,被称为“箭头反模式”,因为它的形状如下

 if
   if
     if
       if
         do something
       endif
     endif
   endif
 endif

这有几个问题

  • 代码难以阅读。
  • 由于多级缩进,上下文难以理解。
  • 清理 发生在垂直距离原始原因很远的地方:如果资源是在顶部的缩进级别中获取的(例如,分配内存,打开文件),则清理发生在相同的缩进级别,但在底部,垂直距离很远。

除了重构或避免此代码外,处理深度嵌套代码的一种技术是在编辑器中进行代码折叠 - 这允许您折叠一个块,从而产生抽象,并允许您轻松地查看周围的代码,而无需查看中间代码(因此资源获取和清理都是可见的)。

解决方案

[编辑 | 编辑源代码]

解决方案包括以下内容。[4]

将块重构为单独的函数。

[编辑 | 编辑源代码]

这在循环体中尤其常见。

合并测试

[编辑 | 编辑源代码]

如果几个 if 子句只是测试(没有任何中间代码),那么这些子句可以合并成一个测试。比较

if a:
    if b:
        ...

if a and b:
    ...

使用布尔短路内联函数调用

[编辑 | 编辑源代码]

如果 if 子句的唯一主体是执行测试的函数调用和赋值,然后是另一个 if 子句,在像 C 这样的语言中,赋值是表达式(有值)并且布尔表达式是短路,这些可以合并

if (a) {
    int b = f();
    if (b) {
        ...
    }
}
if (a && int b = f()) {
    ...
}

辅助变量或函数

[编辑 | 编辑源代码]

辅助变量 在代码中内联复杂表达式时很有用,特别是布尔表达式或匿名函数。[5] 使用辅助表达式既减少了嵌套,因为它不再包含在另一个表达式中,变量名称也记录了表达式的含义。对于复杂的布尔表达式,另一种选择是使用一个单独的函数来调用,而不是使用辅助变量。

提前返回

[编辑 | 编辑源代码]

最重要的解决方案是提前返回,它有几种形式,特别是守卫子句。[4] 避免嵌套的控制流是非局部控制的基本原因,特别是:return(值)、raise(异常)、continue 和 break。一种常见的模式是用 if-then 或嵌套的 if ifs 替换 if not/return-continue(如果是一个函数,则返回/引发,如果是一个循环体,则继续/中断)。

比较

if a:
    ...
    if b:
      ...
      ...

if not a:
    return
...
if not b:
    return
...
...

类似地,比较

for i in l:
    if a:
        ...
        if b:
            ...
            ...

for i in l:
    if not a:
        continue
    ...
    if not b:
        continue
    ...
    ...

这减少了嵌套,使流程更加线性 - 要么继续执行代码块,要么返回/继续。

这种模式被称为“守卫子句”,当检查出现在代码开头并检查先决条件时。但是,它更广泛地用于在工作完成或计算出值后立即完成处理并返回:[3]

“当返回增强可读性时使用它:在某些例程中,一旦您知道答案,您就希望立即将其返回给调用例程。”

但是,提前返回可能会令人困惑并容易出错,特别是由于 清理 问题,并且违反了 结构化编程 的核心原则,即每个例程只有一个出口点。[3]

“最小化每个例程中的返回次数:当在底部阅读例程时,您不知道它在上面某个地方返回的可能性,这会让您难以理解例程。因此,谨慎使用返回 - 只有在它们提高可读性时才使用。”

在没有清理的情况下 - 当函数只是计算值或产生副作用时 - 提前返回的问题较少。在有清理的情况下,一些语言提供了即使有返回也能简化清理的功能(例如“finally”子句、Unix 或 Python 中的“atexit”或 Go 中的“defer”)。另一种选择是在清理子句之后保留一个在结尾的出口点,而不是提前返回,而是跳到(goto)清理子句。

在复杂嵌套的情况下 - 嵌套的 if/then/else 语句或给定级别上的多个 if 语句 - 逻辑通常只是多个互斥条件,这可以通过依次测试每个条件来处理,如果相关则执行代码,然后返回或使用 elif,允许一个扁平的结构,并使完整的条件清晰。

比较

if a:
    if b:
        f()
    else:
        g()
else:
    if b:
        h()
    else:
        i()

if a and b:
    f()
    return
if a and not b:
    g()
    return
if not a and b:
    h()
    return
if not a and not b:
    i()
    return

if a and b:
    f()
elif a and not b:
    g()
elif not a and b:
    h()
elif not a and not b:
    i()

替代控制结构

[编辑 | 编辑源代码]

提前返回具有许多其他控制结构的风格变体,特别是在消除 else 语句方面。

在 return 后省略 else[6]

比较

if a:
    return ...
else:
    return ...

if a:
    return ...
return ...

比较

if a:
    return ...
elif b:
    return ...
else:
    return ...

if a:
    return ...
if b:
    return ...
return ...
使用 switch

在具有 switch 语句的语言中,这可以替换多路条件。

switch (x) {
case a:    
    return ...
case b:
    return ...
default:
    return ...
}
使用 elseif

一些语言有 elseif 语句(elsif,elif)来减少 else 子句中 if 子句的嵌套,其功能类似于 switch:比较

if a:
    return ...
else:
    if b:
        return ...
    else:
        return ...

if a:
    return ...
elif b:
    return ...
else:
    return ...

if a:
    return ...
elif b:
    return ...
return ...

嵌套循环

[编辑 | 编辑源代码]

嵌套循环对于多维数据是自然的,但是对于一维数据的顺序处理,嵌套循环通常是不自然的,可以被更扁平的结构取代。

顺序循环

[编辑 | 编辑源代码]

当通过首先对某些数据执行一项操作,然后切换到不同的状态并处理其余数据来处理一系列数据时,会出现一个更微妙的问题。可以通过嵌套循环来完成此操作,但更自然的是中断循环,然后在单独的循环中继续。

在许多语言(如 C)中,这是通过在两个循环之间共享辅助索引变量来完成的。

比较

for (int i = 0; i < n; i++) {
    foo(a[i]);
    if (...) {
        for (int j = i; j < n; j++) {
            bar(a[j])
        }
        break;
    }
}

int i = 0;
for (; i < n; i++) {
    foo(a[i]);
    if (...)
        break;
}
for (; i < n; i++) {
    bar(a[i])
}

这更清晰地显示了顺序流,并避免了嵌套。

在像 Python 这样的实现迭代器的语言中,这可以在没有辅助变量的情况下完成,因为索引状态包含在迭代器中。

l = iter(a)
for x in l:
    foo(x)
    if ...:
        break
for x in l:
    bar(x)

循环之间的切换

[编辑 | 编辑源代码]

一个更复杂的例子是,当你想在两种处理数据的方式之间来回切换时,比如遍历整个字符串与在单词中遍历。一般来说,最优雅的解决方案是通过相互递归的协程(带尾调用)来实现,在共享迭代器(或索引变量)上运行,不过在没有协程的语言中,这可以通过状态机来实现,或者有时通过相互递归的子程序。

在更简单的案例中,如果存在主循环和辅助循环(比如遍历字符串,辅助操作其单词),则存在自然嵌套结构。在这种情况下,只需将辅助循环分解为单独的函数就足以消除嵌套。比较

for (int i = 0; i < n; i++) {
    foo(a[i]);
    while (...) {
        ...
    }
}

for (int i = 0; i < n; i++) {
    foo(a[i]);
    other(a, &i, n);
}

void other(char *a, int *i, int n) {
    ...
}

模块复杂性

[编辑 | 编辑源代码]

虽然单个函数中深度嵌套的代码不可取,但拥有独立的模块、函数和嵌套函数是模块化的一种重要形式,特别是由于限制了范围。一般来说,最好让模块中的所有函数都彼此相关(为了凝聚力),这有利于高水平的分解和独立模块。也就是说,这可能会增加模块的复杂性,特别是在极端情况下,比如 Java,它限制每个文件只能有一个公共顶层类。

嵌套函数

[编辑 | 编辑源代码]

一些语言,比如 Haskell、Kotlin、Pascal、Python 或 Scala,允许将辅助函数声明为**嵌套函数**。辅助函数在另一个外部值或函数的体中声明。然后,辅助函数的范围被限制在外部函数的体中。嵌套函数的一些优点如下

  • 通过在外部函数内声明,内部函数的体可以引用其封闭范围内的任何方法/函数/变量声明。这减少了在函数中声明参数以及来回传递这些参数的必要性。
  • 因此,内部函数隐藏在外部函数的实现中,无需将其暴露在外部。

比较

def foo():
    def foo_helper():
        ...

    ...
    foo_helper()
    ...

到(注意显式参数传递)

def foo():
    ...
    _foo_helper(x)
    ...
 
def _foo_helper(x):
    ...

然而,程序员可能也更喜欢声明一个可以嵌套在内部函数外部和主范围内的函数,即使这意味着在声明和调用中添加额外的参数。他/她可能会选择这样做的原因如下

  • 嵌套函数最有用的是可以访问封闭状态,而不是因为它们自己的范围受到限制。如果嵌套函数不依赖或使用封闭范围内的大多数变量或函数,最好将其声明为顶层私有函数。
  • 辅助函数可能太长,因此将其作为一个独立的私有顶层函数可能更清楚。
  • 内部函数可能实际上包含一个特别棘手的算法过程,开发人员可能更喜欢单独进行单元测试。
  • 如果外部函数可以访问可变数据或状态,他/她可能希望明确说明在何处以及何时可以修改它们。将辅助函数移出这些变量的范围可以清楚地表明它不会修改这些变量,并且还可以减少可以修改这些变量的代码的长度。

参考资料

[编辑 | 编辑源代码]
  1. Python 之禅
  2. 诺姆·乔姆斯基和杰拉尔德·温伯格(1986)
  3. a b c d 代码大全史蒂夫·麦康奈尔
  4. a b 扁平化箭头代码”,编码恐怖:编程与人因学,杰夫·阿特伍德,2006 年 1 月 10 日
  5. 减少代码嵌套”,埃里克·弗洛伦扎诺的博客,2012 年 1 月 2 日
  6. 重构,马丁·福勒
华夏公益教科书