解开高德纳的结:如何重构意大利面条代码?

Untying Knuth's knots: how to restructure spaghetti code?

本文关键字:意大利 重构 代码 高德纳 何重构      更新时间:2023-10-16

这个问题的灵感来自于如何将流程图转换为实现?其询问从代码中以算法方式消除CCD_ 1语句的方法。这篇科学论文描述了这个普遍问题的答案。

我已经实现了一些代码,遵循Knuth的《计算机编程艺术》中算法X的高级草图,描述了带有受限前缀的词典排列的生成(见本草案第16页)。

这是上述算法的相应流程图。

这可能是一个非常聪明且非常高效的算法,但代码的结构似乎很难遵循。我最终使用了旧的goto风格的实现:

//Algorithm X;
1:
initialize();
2:
enter_level(k);
3:
set(a[k],q);
if(test() == ok) {
  if (k == n) {
    visit();
    goto 6;
  }
  goto 4;
}
goto 5;
4:
increase(k);
goto 2;
5:
increasev2(a[k]);
if (q != 0) {
  goto 3;
}
6:
decrease(k);
if (k==0) {
  goto 7;
}
set(p,u_k);
goto 5;
7:
return;

问题是:如何重构此代码以消除所有goto调用?

一个(虚假的)答案是建议"查阅引用的科学论文,并逐行跟进"——事实上,这当然是可能的。但这个问题是关于经验丰富的程序员一看这段意大利面条式的代码就会立刻看到什么。

我感兴趣的是如何一步一步地重构,而不仅仅是代码。


注:

  1. 基于算法X的高级规范和goto跳转,实际实现算法X非常简单。实现黑盒函数initialize()等只需要一些额外的指令,但这些指令与代码的结构无关。函数调用过程中发生的事情并不重要,因为现在的重点是程序的流程
  2. 通常关于"GOTO仍然被认为有害吗?"的辩论与这个问题完全无关,答案和评论中应该完全解决而不是

相关:如何使用或完成意大利面条代码?

无需付出太多努力(也没有太大风险),您就可以快速减少goto和标签的数量。

1) 删除任何地方未引用的标签(这将是标签1:)

2) 寻找除了goto之外不能输入的代码块,这些代码块在少数地方被调用。这些通常可以简单地考虑在内。4:可以通过将代码移动到调用它的位置来处理,并且可以安全地完成,因为它唯一的出口是goto。这也允许我们删除它上面的goto5,因为该代码只会变成5:。7:可以通过修改if语句来处理。目前我们有

initialize();
2:
enter_level(k);
3:
set(a[k],q);
if(test() == ok) {
  if (k == n) {
    visit();
    goto 6;
  }
  increase(k);
  goto 2;
}
5:
increasev2(a[k]);
if (q != 0) {
  goto 3;
}
6:
decrease(k);
if (k!=0) {
  set(p,u_k);
  goto 5;
}
return;

我想在这里停下来。但是,如果继续,就变成了识别循环并用循环构造替换goto的问题。然而,由于代码的结构方式,进行这些更改的风险似乎要大得多。此外,你可能会以休息和继续结束,这无论如何都是一种gotos。我最终得到的是(如果没有一些非常严格的测试,我不会保证它的正确性):

initialize();
enter_level(k);
while (true) {
  set(a[k],q);
  if(test() == ok) {
    if (k == n) {
      visit();
    } else {
      increase(k);
      enter_level(k);
      continue;
    }
  } else {
    increasev2(a[k]);
    if (q != 0) {
      continue; 
    }
  }
  while (true) {
    decrease(k);
    if (k!=0) {
      set(p,u_k);
      increasev2(a[k]);
      if (q != 0) {
        break; 
      }
    } else {
      return;
    }
  }
}

我做了3:一个环,6:一个内环。我通过复制5:代码来代替goto,并用break替换goto 3,从而摆脱了goto 5。这使得制作更清洁的环变得更容易。goto 6是通过使用else来固定的。后藤3变成了继续。

在此之后(如果你还有剩余的能量),你可以尝试将while(true)with continue的循环改变为whiles with actual conditions。

最好先开发测试,然后进行一两次更改和测试。再做一次更改,然后再次测试。如果你不这样做,很容易在早期犯下结构性错误,然后使后续步骤无效,迫使你重新开始。

我之前在https://stackoverflow.com/a/36661381/120163

找到了一篇更好的论文,讨论了如何在准确保留原始控制流图的同时生成结构化代码:

W.D Maurer,"广义结构化程序和循环树",计算机程序设计科学,2007

我遵循了这个程序(从纸面上看,希望我做得对,凌晨2点40分看起来还可以)。他的基本技巧是找到强连通区域(代码中的循环);这些将成为循环;然后,他通过删除一条边来打破这种循环;这最终变成了一个循环反向链接(当他完成时恢复)。重复该过程,直到找不到更多的循环为止;剩下的基本上是一个具有已识别循环的结构化程序。做好这件事很难;你真的需要一个自动化的程序。你的代码虽然很小,但仍然很糟糕:-}

我在一个地方作弊。毛雷尔坚持认为前锋戈托斯是可以的,即使是在中路。如果你买了,那么你就可以准确地保留CFG。如果没有,则必须处理循环具有两个或多个入口点的情况;你的算法有这样一个循环。我通过对循环进行编码来解决这个问题,并编码一个循环尾部片段等价物,其作用类似于跳到中间的第一次迭代,然后是循环本身。

我的注释有点有趣:大多数语言都没有"块{…}"构造。[我编码的那个(见简历)是这样做的]。将其视为一个"执行一次迭代"循环:-}我假设块/循环有循环出口,循环继续。如果你没有这些,你可以用足够数量的块{…}来模拟它们exit_block@N.

接受后编辑:天哪,我做得不对,我暂时遗漏了loop@3.我已经补好了;对块构造的需求现在消失了,因为我可以暂时退出loop@3以达到同样的效果。实际上,代码读起来稍微好一点。

我留下了你的数字标签,即使在不需要的地方,为了更容易参考。

//Algorithm X;
1:
initialize();
2:
while (true) {
   enter_level(k);
   3: 
   while (true) {
      set(a[k],q);
      if (test() == ok) {
         if (k != n) exit_while@3;
         visit();
         decrease(k); // replicate logic at 6 to avoid jumping into middle of 5 loop
         if (k==0) return;
         set(p,u_k);
      }
      5:
      while (true) {
         increasev2(a[k]);
         if (q != 0) continue_while@3;
         6:
         decrease(k);
         if (k==0) return;
         set(p,u_k);
      } // while(true)@5
  } // while(true)@3
  4:
  increase(k);
} // while(true)@2

与我迄今为止看到的大多数其他答案不同,这与原始答案的运行速度相同(没有额外的标志或标志检查)。

@哈克特的回答很有趣;a) 同样快的是,b)他选择用同样的技术处理两个入口的循环,但他选择了"另一个入口"作为循环顶部。他对标签2的"enter_level(k)"操作做了类似的操作。

有趣的是,所有这些结构似乎丝毫没有帮助代码的可读性。让人怀疑"结构化程序"的全部意义。也许设计良好的意大利面条也没那么糟糕:-}

在c++中,算法可以写成:

void initialize() {}
void enter_level(int k) {}
void set(int x,int y) {}
bool test() { return true; }
void visit() {}
void increase(int k) {}
void increasev2(int k) {}
void decrease(int k) {}
void algorithm_x()
{
    int k{0};
    int a[] ={1,2,3,4,5};
    int q{0};
    bool ok{true};
    int n{0};
    int p{0};
    int u_k{0};
        //Algorithm X;
    lbl1:
        initialize();
    lbl2:
        enter_level(k);
    lbl3:
        set(a[k],q);
        if (test() == ok) {
            if (k == n) {
                visit();
                goto lbl6;
            }
            goto lbl4;
        }
        goto lbl5;
    lbl4:
        increase(k);
        goto lbl2;
    lbl5:
        increasev2(a[k]);
        if (q != 0) {
            goto lbl3;
        }
    lbl6:
        decrease(k);
        if (k==0) {
            goto lbl7;
        }
        set(p,u_k);
        goto lbl5;
    lbl7:
        return;
}
int main()
{
    algorithm_x();
    return 0;
}

假设我们不使用break语句,那么程序可能是:

void initialize() {}
void enter_level(int k) {}
void set(int x,int y) {}
bool test() { return true; }
void visit() {}
void increase(int k) {}
void increasev2(int k) {}
void decrease(int k) {}
void algorithm_x()
{
    int k{0};
    int a[] ={1,2,3,4,5};
    int q{0};
    bool ok{true};
    int n{0};
    int p{0};
    int u_k{0};
    bool skiptail{false};
    //Algorithm X;
    initialize();
    enter_level(k);
    while (true) {
        skiptail = false;
        set(a[k],q);
        if (test() == ok) {
            if (k == n) {
                visit();
                decrease(k);
                if (k==0) {
                    return;
                }
                set(p,u_k);
                while (true) {
                    increasev2(a[k]);
                    if (q != 0) {
                        //goto lbl3;
                        skiptail = true;
                    }
                    if (!skiptail) decrease(k);
                    if (!skiptail) if (k==0) {
                        return;
                    }
                    if (!skiptail) set(p,u_k);
                }
            }
            if (!skiptail) increase(k);
            if (!skiptail) enter_level(k);
            //goto lbl3;
            skiptail = true;
        }
        if (!skiptail) while (true) {
            increasev2(a[k]);
            if (q != 0) {
                //goto lbl3;
                skiptail = true;
            }
            if (!skiptail) decrease(k);
            if (!skiptail) if (k==0) {
                return;
            }
            if (!skiptail) set(p,u_k);
        }
        if (!skiptail) increase(k);
        if (!skiptail) enter_level(k);
        //goto lbl3;
        skiptail = true;
        if (!skiptail) while (true) {
            increasev2(a[k]);
            if (q != 0) {
                //goto lbl3;
                skiptail = true;
            }
            if (!skiptail) decrease(k);
            if (!skiptail) if (k==0) {
                return;
            }
            if (!skiptail) set(p,u_k);
        }
    }
}
int main()
{
    algorithm_x();
    return 0;
}

更改使用了以下算法:

  1. 去掉未使用的标签。删除lbl1

  2. 如果标签以goto结尾,那么在使用该块的任何位置都要替换该块。删除lbl4lbl6lbl7

  3. 若标签返回到自身,则将块放入while(true)中。移除底部goto0(lbl5现在是独立的,可以在使用时更换)

  4. 如果一个块是自包含的,则在使用它的任何位置进行替换。删除lbl5

  5. 如果一个标签跟在另一个标签后面,那么在块的末尾放置一个转到下一个标签,以便根据规则2替换它。删除lbl2(可以goto lbl3

  6. 现在,我们在整个代码中只剩下最后一个标签的goto。将goto lbl3替换为skiptail=true,将剩余块放置在while (true)块中,并设置剩余语句以检查是否为skiptail=false。取下lbl3并更换为skiptail = false

我从未使用过goto,但这似乎是一个有趣的挑战,所以我尝试了自己的重构。

首先,遍历代码,看看每个标签上有多少goto语句;记住这一点很重要,以避免出现错误。在你的例子中,没有任何东西导致1,所以我们可以忽略它

有时,当控制流暗示goto时,我发现添加它们很有用。当我在事物之间移动代码时,它有助于跟踪顺序。

重构goto的最佳方法是从内部或自下而上进行重构。

  • 最后一条指令是7:return;,它可以简单地移动到调用goto 7的任何位置。这很容易。

  • 接下来,我尝试查看哪些标签以goto(无条件)结尾,并直接位于不同的goto之后。在这种情况下是4;它可以移动到2的前面,在一个由哨兵控制的if内部(为循环做准备)。(goto我第一点看到2现在可以删除了。)

  • 我做的下一件事是把5和6放进一个循环。如果我错了,我无论如何都可以反悔。

  • 在这一点上,我看到6将在3或5之后执行。我还看到5可以执行3,所以我决定在5之后移动3。我添加了一个变量,以便第一次跳过5。我在6月底把它设置为真。

  • 为了确保5可以在需要时直接转到6,我可以将3包装在if语句中,并执行相反的条件5。当我确实需要从5变为3时,我可以在5内更改条件,以便在之后直接执行3。

  • 在这一点上,我只有一个goto,它从3变为4。如果我将其更改为break,我可以退出一个循环,并到达末尾。为了达到4,我只是把所有的东西(除了1)都包裹在一个循环中。

如果有任何这样的情况,您可能可以使用这个技巧来打破嵌套循环,而不使用goto,但在这种情况下这不是必要的。

最后,我得到了这个代码(标签只是为了清晰起见):


1: initialize();
reached4=false;
do5 = false;
while(true){
    if (reached4){
      4: increase(k);
    }
    2: enter_level(k);
    while(true){
      if(do5){
        5:
        increasev2(a[k]);
        if (q != 0) {
          do5 = false;//goto 3
        }
      }
      if(!do5){
        3:
        set(a[k],q);
        if(test() == ok) {
          if (k == n) {
            visit();//goto 6;
          }else{
            reached4 = true;
            break;//goto 4
          }
        }
      }
      6:
      decrease(k);
      if (k==0) {
        7: return;
      }
      set(p,u_k);
      do5 = true;
    }
}

您可以使用许多变量来模拟goto的流程,以使用if'swhile's

initialize();
enterLevel = true;
executeWhile = true;
do 
{
    if (enterLevel)
    {
        enter_level(k);
    }
    enterLevel = false;
    goto4 = false;
    goto5 = false;
    goto6 = false;
    set(a[k],q);
    if(test() == ok) 
    {
        if (k == n) 
        {
            visit();
            goto6 = true;
        }
        else
        {
            goto4 = true;
        }
    }
    else
    {
        goto5 = true;
    }
    if (goto4) 
    {
        increase(k);
        enterLevel = true;
    }
    else
    {
        do
        {
            if(goto5)
            {
                increasev2(a[k]);
                goto6 = goto5 = !(q != 0); // if (q != 0) { goto6 = goto5 = false; } else { goto6 = goto5 = true; }
            }
            if(goto6)
            {
                decrease(k);
                executeWhile = !(k==0); // if (k == 0) { executeWhile = false; } else { executeWhile = true; }
                set(p,u_k);
                goto5 = true;
            }
        } while (goto5 && executeWhile);
    }
} while (executeWhile);

这个版本是否比goto's的更好,我还不能说。


首先,我将所有标签完全分离。

然后我发现这里有两个循环:

1 - 
    * label 4 -> goto 2
    * label 5 -> goto 3. 

两个都转到代码的顶部,但其中一个执行enter_level(k),另一个不执行。这就是为什么enterLevel变种

2 - 
    * label 6 -> goto 5. This goes up a little in the code, and then executes again. 

在这个循环中,有两种情况它会熄灭:

    * label 5 -> goto 3. The same as before, but now inside a nested loop
    * label 6 -> goto 7. The way out of the outer loop.

其他变量和if只是为了保持控制流。

是的,我本可以使用一些休息时间(代码可能会变短),但由于问题是关于后藤的,我个人倾向于不使用它们。