避免使用“ goto”伏都教?


47

我有一个switch可以处理几种情况的结构。对进行switch操作会enum通过组合值引起重复代码的问题:

// All possible combinations of One - Eight.
public enum ExampleEnum {
    One,
    Two, TwoOne,
    Three, ThreeOne, ThreeTwo, ThreeOneTwo,
    Four, FourOne, FourTwo, FourThree, FourOneTwo, FourOneThree,
          FourTwoThree, FourOneTwoThree
    // ETC.
}

当前,该switch结构分别处理每个值:

// All possible combinations of One - Eight.
switch (enumValue) {
    case One: DrawOne; break;
    case Two: DrawTwo; break;
    case TwoOne:
        DrawOne;
        DrawTwo;
        break;
     case Three: DrawThree; break;
     ...
}

你在那里得到了主意。我目前将其分解为堆叠if结构,以处理单行组合:

// All possible combinations of One - Eight.
if (One || TwoOne || ThreeOne || ThreeOneTwo)
    DrawOne;
if (Two || TwoOne || ThreeTwo || ThreeOneTwo)
    DrawTwo;
if (Three || ThreeOne || ThreeTwo || ThreeOneTwo)
    DrawThree;

这就带来了令人难以置信的长逻辑评估问题,该评估令人困惑且难以维护。重构之后,我开始考虑替代方案,并想到了switch案例之间相互渗透的结构的想法。

goto在这种情况下,我必须使用,因为C#不允许掉线。但是,即使它在switch结构中跳来跳去,它的确可以防止冗长的逻辑链,并且仍然带来了代码重复。

switch (enumVal) {
    case ThreeOneTwo: DrawThree; goto case TwoOne;
    case ThreeTwo: DrawThree; goto case Two;
    case ThreeOne: DrawThree; goto default;
    case TwoOne: DrawTwo; goto default;
    case Two: DrawTwo; break;
    default: DrawOne; break;
}

这仍然不是一个足够干净的解决方案goto,我想避免与该关键字相关的污名。我敢肯定,有一种更好的方法可以解决此问题。


我的问题

在不影响可读性和可维护性的情况下,是否有更好的方法来处理这种特定情况?


28
听起来您想进行大辩论。但是,您将需要一个使用goto的更好案例的更好示例。flag enum巧妙地解决了这个问题,即使您的if语句示例不可接受并且对我来说也不错
Ewan

5
@Ewan没有人使用goto。您最后一次查看Linux内核源代码是什么时候?
John Douma

6
goto在您的语言不存在高级结构时使用。有时我希望有一个fallthru; 关键字来摆脱的特定用法,goto但是很好。
约书亚

25
一般而言,如果您需要使用goto像C#这样的高级语言,那么您可能已经忽略了许多其他(更好的)设计和/或实现替代方案。极度气disc。
code_dredd

11
在C#中使用“ goto case”不同于使用更通用的“ goto”,因为这是您实现显式失败的方式。
Hammerite

Answers:


175

我发现这些goto语句很难读取代码。我建议您采用enum不同的结构。例如,如果您enum是一个位域,其中每个位代表一个选择,则它可能如下所示:

[Flags]
public enum ExampleEnum {
    One = 0b0001,
    Two = 0b0010,
    Three = 0b0100
};

Flags属性告诉编译器您正在设置不重叠的值。调用此代码的代码可以设置适当的位。然后,您可以执行以下操作以弄清正在发生的事情:

if (myEnum.HasFlag(ExampleEnum.One))
{
    CallOne();
}
if (myEnum.HasFlag(ExampleEnum.Two))
{
    CallTwo();
}
if (myEnum.HasFlag(ExampleEnum.Three))
{
    CallThree();
}

这需要设置代码myEnum以正确设置位域并用Flags属性标记。但是您可以通过将示例中的枚举值更改为:

[Flags]
public enum ExampleEnum {
    One = 0b0001,
    Two = 0b0010,
    Three = 0b0100,
    OneAndTwo = One | Two,
    OneAndThree = One | Three,
    TwoAndThree = Two | Three
};

当您以形式写一个数字时0bxxxx,您是以二进制形式指定它。因此,您可以看到我们设置了1、2或3位(从技术上讲,是0、1或2,但是您知道了)。如果组合可能经常设置在一起,也可以使用按位或来命名组合。


5
看起来如此!但我想它一定是C#7.0或更高版本。
user1118321

31
无论如何,也可以这样做:public enum ExampleEnum { One = 1 << 0, Two = 1 << 1, Three = 1 << 2, OneAndTwo = One | Two, OneAndThree = One | Three, TwoAndThree = Two | Three };。无需坚持使用C#7 +。
重复数据删除器

8
您可以使用Enum.HasFlag
IMil

13
[Flags]属性不向编译器发出任何信号。这就是为什么你仍然为2的幂显式声明枚举值
乔·休厄尔

19
@UKMonkey我非常不同意。 HasFlag是正在执行的操作的描述性和显式术语,并从功能中抽象实现。使用,&因为它在其他语言中很常见,这比使用byte类型而不是声明一个类型更有意义enum
BJ Myers

136

IMO问题的根源在于,这段代码甚至不应该存在。

您显然具有三个独立的条件,并且在满足这些条件的情况下要采取三个独立的操作。那么,为什么要将所有这些都集中到一个需要三个布尔标志来告诉它要做什么的代码中(无论您是否将它们混淆成一个枚举),然后将三个独立的东西进行某种组合?单一责任原则似乎在这里休假。

将调用放在所属的三个函数(即您发现需要执行操作的位置)上,并将这些示例中的代码委托给回收站。

如果有十个标志和动作而不是三个,您是否会扩展这种代码以处理1024个不同的组合?我希望不是!如果出于相同的原因,如果1024太多,则8也太多。


10
实际上,有许多设计良好的API,它们具有各种标志,选项和各种其他参数。在不破坏SRP的前提下,有些可能会继续下去,有些会产生直接影响,而另一些可能会被忽略。无论如何,该功能甚至可以解码一些外部输入,谁知道呢?同样,还原的荒谬往往会导致荒谬的结果,尤其是当起点还不是那么坚实的时候。
重复数据删除器

9
提出了这个答案。如果您只想辩论GOTO,那是与您提出的问题不同的问题。根据提供的证据,您有三个不相交的要求,这些要求不能汇总。从SE的角度来看,我认为alephzero是正确的答案。

8
@Deduplicator仔细观察一下1,2&3的一些但不是全部组合,这表明它不是一个“精心设计的API”。
user949300

4
这么多。其他答案并不能真正改善问题所在。此代码需要重写。编写更多函数没有错。
only_pro

3
我喜欢这个答案,尤其是“如果1024太多,那么8也太多”。但这本质上是“使用多态性”,不是吗?我的(较弱的)答案因为这样说而变得很废话。我可以雇用您的公关人员吗?:-)
user949300

29

永远不要使用gotos是计算机科学的“对孩子不利谎言”概念之一。这是正确的建议,在99%的情况下,它的出现并不那么罕见和专业,以至于每个人只要向新编码员解释为“不要使用它们”,对所有人来说都会更好。

那么什么时候应该使用它们呢?有几种情况,但是您遇到的最基本的情况是:对状态机进行编码时。如果没有比状态机更好的组织化和结构化算法表达,那么它在代码中的自然表达涉及非结构化分支,那么就可以说,没有什么可以做的事情无法做,因此可以做很多事情。状态机本身更加晦涩,而不是更少。

编译器编写者知道这一点,这就是为什么大多数编译器实现LALR解析器的源代码*包含的goto。但是,很少有人会实际编写自己的词法分析器和解析器。

*-IIRC,可以完全递归地实现LALL语法,而无需求助于跳转表或其他非结构化控制语句,因此,如果您确实是反goto,这是一种出路。


现在,下一个问题是:“这个例子是其中一种吗?”

我看到的是,根据当前状态的处理情况,您可能会遇到三个不同的下一个状态。由于其中之一(“默认”)仅是一行代码,因此从技术上讲,您可以通过在适用状态的末尾加上该行代码来摆脱它。那将使您下降到2个可能的下一个状态。

剩下的一个(“三个”)仅从我可以看到的一个地方分支。因此,您可以用相同的方法摆脱它。您最终将获得如下所示的代码:

switch (exampleValue) {
    case OneAndTwo: i += 3 break;
    case OneAndThree: i += 4 break;
    case Two: i += 2 break;
    case TwoAndThree: i += 5 break;
    case Three: i += 3 break;
    default: i++ break;
}

但是,这再次是您提供的玩具示例。在“默认”中包含大量代码的情况下,“三个”被转换为多个状态,或者(最重要的是)进一步的维护可能会使状态增加或复杂化,说实话,您会更好使用gotos(甚至可能摆脱掩盖事物状态机本质的枚举结构,除非有充分的理由要保留它)。


2
@PerpetualJ-好吧,我很高兴您找到了更干净的东西。如果确实是您的情况,可能仍然很有趣,尝试使用gotos(没有大小写或枚举。只是标记为block和gotos),看看它们在可读性上如何比较。话虽这么说,“ goto”选项不仅必须更具可读性,而且还应具有足够的可读性,以避开其他开发人员的论点和笨拙的“修复”尝试,因此您似乎找到了最佳选择。
TED

4
我不相信如果“进一步的维护可能会增加或使状态复杂化”,那么您“会更好”。您是否真的在说意大利面条代码比结构化代码更易于维护?这违反了〜40年的实践。
user949300

5
@ user949300-不反对构建状态机,事实并非如此。您可以简单地阅读我对这个答案的先前评论,作为一个真实示例。如果您尝试使用C情况失败,这会在状态机算法上固有地施加无条件限制的人为结构(其线性顺序),那么每次重新布置所有代码时,您都会发现自己的几何复杂度不断增加订单以适应新的状态。如果只编写状态和转换代码(如算法要求的那样),则不会发生。添加新状态是微不足道的。
TED

8
@ user949300-同样,“〜40年的实践”构建编译器表明,gotos实际上是解决该问题领域的最佳方法,这就是为什么如果您查看编译器源代码,几乎总是会发现gotos。
TED

3
@ user949300在使用特定功能或约定时,Spaghetti代码不仅会固有地出现。它可以随时以任何语言,功能或约定出现。原因是在非预期的应用程序中使用了该语言,功能或约定,并使它向后弯曲以使其脱离。始终使用正确的工具进行工作,是的,有时这意味着使用goto
Abion47

26

最好的答案是使用多态性

另一个答案,国际海事组织(IMO),使“如果”的东西更清楚,可以说更短

if (One || OneAndTwo || OneAndThree)
  CallOne();
if (Two || OneAndTwo || TwoAndThree)
  CallTwo();
if (Three || OneAndThree || TwoAndThree)
  CallThree();

goto 这可能是我的第58个选择...


5
+1表示多态性,尽管理想情况下可以将客户端代码简化为“ Call();”,使用tell do n't ask-将决策逻辑从使用客户端上推到类机制和层次结构中。
Erik Eidt

12
宣布任何看不见的最佳景象当然是非常勇敢的。但是,在没有其他信息的情况下,我建议您有些怀疑并保持开放的态度。也许遭受组合爆炸的困扰?
重复数据删除器

哇,我认为这是我第一次因为暗示多态性而被否决。:-)
user949300

25
有些人在遇到问题时会说“我只会使用多态”。现在他们有两个问题,即多态性。
与莫妮卡(Monica)进行的轻度比赛

8
实际上,我的硕士论文是关于使用运行时多态性摆脱编译器状态机中的问题的。这是完全可行的,但是从那以后,我发现在大多数情况下,这样做是不值得的。它需要大量的OO设置代码,所有这些当然都可能导致错误(并且此答案省略了)。
TED

10

为什么不这样:

public enum ExampleEnum {
    One = 0, // Why not?
    OneAndTwo,
    OneAndThree,
    Two,
    TwoAndThree,
    Three
}
int[] COUNTS = { 1, 3, 4, 2, 5, 3 }; // Whatever

int ComputeExampleValue(int i, ExampleEnum exampleValue) {
    return i + COUNTS[(int)exampleValue];
}

好的,我同意,这有点骇人听闻(我不是C#开发人员,请原谅我输入代码),但是从效率的角度来看,这是必须的吗?将枚举用作数组索引是有效的C#。


3
是。用数据替换代码的另一个示例(通常希望这样做是为了提高速度,结构和可维护性)。
彼得-恢复莫妮卡

2
毫无疑问,对于最新版本的问题,这是个绝妙的主意。它可以用来从第一个枚举(值的密集范围)到通常必须执行的或多或少独立动作的标志。
重复数据删除器

我没有访问C#编译器,但也许码,可以使用这种代码进行安全:int[ExempleEnum.length] COUNTS = { 1, 3, 4, 2, 5, 3 };
LaurentGrégoire

7

如果您不能或不想使用标志,请使用尾部递归函数。在64位发布模式下,编译器将生成与您的goto语句非常相似的代码。您只是不必处理它。

int ComputeExampleValue(int i, ExampleEnum exampleValue) {
    switch (exampleValue) {
        case One: return i + 1;
        case OneAndTwo: return ComputeExampleValue(i + 2, ExampleEnum.One);
        case OneAndThree: return ComputeExampleValue(i + 3, ExampleEnum.One);
        case Two: return i + 2;
        case TwoAndThree: return ComputeExampleValue(i + 2, ExampleEnum.Three);
        case Three: return i + 3;
   }
}

那是一个独特的解决方案!+1我认为我不需要这样做,但对将来的读者来说非常有用!
PerpetualJ

2

公认的解决方案很好,是解决您问题的具体解决方案。但是,我想提出一个替代的,更抽象的解决方案。

以我的经验,使用枚举定义逻辑流是一种代码味道,因为它通常是不良类设计的标志。

我在去年编写的代码中遇到了一个现实的例子。最初的开发人员创建了一个同时执行导入和导出逻辑的类,并基于枚举在这两个类之间进行切换。现在的代码是相似的,并且有一些重复的代码,但是它是如此的不同,以致使代码更加难以阅读,几乎无法测试。我最终将其重构为两个单独的类,从而简化了这两个类,实际上让我发现并消除了许多未报告的错误。

再一次,我必须声明使用枚举来控制逻辑流通常是一个设计问题。在一般情况下,枚举应主要用于提供类型安全,对消费者友好的值,其中明确定义了可能的值。它们比用作逻辑控制机制更好地用作属性(例如,用作表中的列ID)。

让我们考虑问题中提出的问题。我真的不知道这里的上下文或这个枚举代表什么。是抽奖卡吗?画画?抽血?订单重要吗?我也不知道性能有多重要。如果性能或内存至关重要,那么此解决方案可能就不是您想要的解决方案。

无论如何,让我们考虑一下枚举:

// All possible combinations of One - Eight.
public enum ExampleEnum {
    One,
    Two,
    TwoOne,
    Three,
    ThreeOne,
    ThreeTwo,
    ThreeOneTwo
}

我们在这里拥有许多代表不同业务概念的不同枚举值。

相反,我们可以使用抽象来简化事情。

让我们考虑以下接口:

public interface IExample
{
  void Draw();
}

然后,我们可以将其实现为抽象类:

public abstract class ExampleClassBase : IExample
{
  public abstract void Draw();
  // other common functionality defined here
}

我们可以有一个具体的类来表示图一,图二和图三(出于论证的目的,它们具有不同的逻辑)。这些可能使用上面定义的基类,但是我假设DrawOne概念不同于枚举所表示的概念:

public class DrawOne
{
  public void Draw()
  {
    // Drawing logic here
  }
}

public class DrawTwo
{
  public void Draw()
  {
    // Drawing two logic here
  }
}

public class DrawThree
{
  public void Draw()
  {
    // Drawing three logic here
  }
}

现在,我们可以构成三个单独的类,以提供其他类的逻辑。

public class One : ExampleClassBase
{
  private DrawOne drawOne;

  public One(DrawOne drawOne)
  {
    this.drawOne = drawOne;
  }

  public void Draw()
  {
    this.drawOne.Draw();
  }
}

public class TwoOne : ExampleClassBase
{
  private DrawOne drawOne;
  private DrawTwo drawTwo;

  public One(DrawOne drawOne, DrawTwo drawTwo)
  {
    this.drawOne = drawOne;
    this.drawTwo = drawTwo;
  }

  public void Draw()
  {
    this.drawOne.Draw();
    this.drawTwo.Draw();
  }
}

// the other six classes here

这种方法更加冗长。但是它确实有优势。

考虑下面的类,其中包含一个错误:

public class ThreeTwoOne : ExampleClassBase
{
  private DrawOne drawOne;
  private DrawTwo drawTwo;
  private DrawThree drawThree;

  public One(DrawOne drawOne, DrawTwo drawTwo, DrawThree drawThree)
  {
    this.drawOne = drawOne;
    this.drawTwo = drawTwo;
    this.drawThree = drawThree;
  }

  public void Draw()
  {
    this.drawOne.Draw();
    this.drawTwo.Draw();
  }
}

发现丢失的drawThree.Draw()调用有多简单?而且,如果顺序很重要,则绘制调用的顺序也很容易看到和遵循。

这种方法的缺点:

  • 提出的八个选项中的每个选项都需要一个单独的类
  • 这样会占用更多内存
  • 这将使您的代码表面变大
  • 有时这种方法是不可能的,尽管可能有些变化

这种方法的优点:

  • 这些类中的每一个都可以完全测试。因为
  • draw方法的循环复杂度很低(理论上,如有必要,我可以模拟DrawOne,DrawTwo或DrawThree类)
  • 绘制方法是可以理解的-开发人员不必费心去弄清楚该方法的作用
  • 错误很容易发现并且很难编写
  • 类组成更高级的类,这意味着定义ThreeThreeThree类很容易

每当您需要将任何复杂的逻辑控制代码写入case语句时,请考虑使用此(或类似方法)。将来,您会为您感到高兴。


这是一个写得很好的答案,我完全同意这种方法。在我的特殊情况下,考虑到每个后面的琐碎代码,此冗长的内容将在某种程度上过大。为了正确理解,假设代码只是在执行Console.WriteLine(n)。这实际上与我正在使用的代码等效。在所有其他情况的90%中,遵循OOP时,您的解决方案无疑是最佳答案。
PerpetualJ

是的,这不是在每种情况下都有用的方法。我想把它放在这里,因为对于开发人员来说,通常只专注于一种解决问题的特定方法,而有时候回头寻找替代方案是值得的。
史蒂芬

我完全同意这一点,这实际上是我之所以来到这里的原因,是因为我正试图用另一位开发人员写出的习惯来解决可伸缩性问题。
PerpetualJ

0

如果您打算在这里使用开关,那么如果您分别处理每种情况,您的代码实际上会更快

switch(exampleValue)
{
    case One:
        i++;
        break;
    case Two:
        i += 2;
        break;
    case OneAndTwo:
    case Three:
        i+=3;
        break;
    case OneAndThree:
        i+=4;
        break;
    case TwoAndThree:
        i+=5;
        break;
}

在每种情况下,仅执行一次算术运算

也正如其他人所说的,如果您正在考虑使用gotos,则您可能应该重新考虑算法(尽管我承认C#缺少大小写下降,但这可能是使用goto的原因)。参见埃德加·迪克斯特拉(Edgar Dijkstra)的著名论文“发表声明被认为有害”


3
我想说这对遇到简单问题的人来说是个好主意,因此感谢您发布它。就我而言,这不是那么简单。
PerpetualJ

而且,我们今天并没有埃德加在撰写论文时遇到的问题。如今,大多数人甚至没有充分的理由讨厌goto,这会使代码难以阅读。好吧,说实话,如果被滥用,那就是,否则,它会被包含在包含方法中,因此您无法真正制作意大利面条代码。为什么要花精力解决一种语言特征?我会说goto是过时的,您应该在99%的用例中考虑其他解决方案。但是,如果您需要它,那就可以了。
PerpetualJ

当布尔值很多时,这将无法很好地扩展。
user949300

0

对于您的特定示例,由于枚举中您真正想要的只是每个步骤的执行/不执行指示器,因此重写您的三个if语句的解决方案优于switch,并且使它成为可接受的答案是一件好事。

但是,如果你有一些更复杂的逻辑是根本工作了那么干净,那么我还是觉得goto在S switch语句混乱。我宁愿看到这样的事情:

switch (enumVal) {
    case ThreeOneTwo: DrawThree; DrawTwo; DrawOne; break;
    case ThreeTwo:    DrawThree; DrawTwo; break;
    case ThreeOne:    DrawThree; DrawOne; break;
    case TwoOne:      DrawTwo; DrawOne; break;
    case Two:         DrawTwo; break;
    default:          DrawOne; break;
}

这不是完美的方法,但是我认为这种方法比使用gotos 更好。如果事件序列如此之长,并且彼此重复太多,以至于没有必要为每种情况拼出完整的序列,那么我宁愿使用子例程而不是goto为了减少代码重复。


0

我不确定这些天是否真的有人会讨厌goto关键字。它绝对是过时的,在99%的用例中都不需要,但是出于某种原因,它是该语言的功能。

讨厌goto关键字的原因是这样的代码

if (someCondition) {
    goto label;
}

string message = "Hello World!";

label:
Console.WriteLine(message);

糟糕!这显然是行不通的。该message代码路径中未定义该变量。因此,C#不会通过。但这可能是隐式的。考虑

object.setMessage("Hello World!");

label:
object.display();

并假设其中display包含该WriteLine语句。

由于goto掩盖了代码路径,因此很难找到这种错误。

这是一个简化的示例。假设一个真实的例子不会那么明显。label:使用和之间可能有五十行代码message

该语言可以通过限制goto使用方式(仅从下而上)来帮助解决此问题。但是C#goto并不受此限制。它可以跳过代码。此外,如果要限制goto,则更改名称也一样。其他语言使用break带有多个(要退出的块)或带有标签(位于其中一个块上)的块下降。

概念goto是低级机器语言指令。但是,我们拥有高级语言的全部原因是,我们只能使用高级抽象,例如可变范围。

综上所述,如果gotoswitch语句中使用C#来逐个跳转,那是无害的。每个案例已经是一个切入点。我仍然认为goto在这种情况下称它为愚蠢的,因为它将这种无害的用法goto与更危险的形式结合在一起。我宁愿他们continue为此使用类似的东西。但是由于某种原因,他们在写语言之前忘了问我。


2
用这种C#语言,您不能跳过变量声明。为了证明这一点,请启动一个控制台应用程序,然后输入您在帖子中提供的代码。您会在标签中看到编译器错误,指出该错误是由于使用了未分配的局部变量'message'。在允许这样做的语言中,这是一个有效的问题,但在C#语言中则不是。
PerpetualJ

0

当您有这么多选择(如您所说的更多)时,也许不是代码而是数据。

创建一个将枚举值映射到动作的字典,以函数或表示动作的更简单枚举类型表示。然后,您的代码可以简化为简单的字典查找,然后调用函数值或打开简化选项。


-7

使用循环以及中断和继续之间的区别。

do {
  switch (exampleValue) {
    case OneAndTwo: i += 2; break;
    case OneAndThree: i += 3; break;
    case Two: i += 2; continue;
    case TwoAndThree: i += 2;
    // dropthrough
    case Three: i += 3; continue;
    default: break;
  }
  i++;
} while(0);
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.