22

首先,我同意 goto 语句在很大程度上与现代编程语言中的高级构造无关,并且在有合适的替代品可用时不应使用。

我最近在重读 Steve McConnell 的 Code Complete 的原始版本,忘记了他对常见编码问题的建议。几年前我刚开始的时候就读过它,但我认为我没有意识到这个食谱有多么有用。编码问题如下:在执行循环时,您经常需要执行循环的一部分来初始化状态,然后用其他一些逻辑执行循环,并用相同的初始化逻辑结束每个循环。一个具体的例子是实现 String.Join(delimiter, array) 方法。

我想每个人对这个问题的第一反应就是这个。假设 append 方法被定义为将参数添加到您的返回值。

bool isFirst = true;
foreach (var element in array)
{
  if (!isFirst)
  {
     append(delimiter);
  }
  else
  {
    isFirst = false;
  }

  append(element);
}

注意:对此的轻微优化是删除 else 并将其放在循环的末尾。赋值通常是单条指令,等效于 else,将基本块的数量减少 1,并增加主要部分的基本块大小。结果是在每个循环中执行一个条件来确定是否应该添加分隔符。

我还看到并使用了其他方法来处理这个常见的循环问题。您可以先在循环外执行初始元素代码,然后从第二个元素执行循环到结束。您还可以更改逻辑以始终附加元素,然后附加分隔符,一旦循环完成,您可以简单地删除您添加的最后一个分隔符。

后一种解决方案往往是我更喜欢的解决方案,只是因为它不会复制任何代码。如果初始化序列的逻辑发生变化,您不必记住在两个地方修复它。然而,它确实需要额外的“工作”来做某事然后撤消它,这至少会导致额外的 cpu 周期,并且在许多情况下,例如我们的 String.Join 示例也需要额外的内存。

当时我很兴奋读到这个结构

var enumerator = array.GetEnumerator();
if (enumerator.MoveNext())
{
  goto start;
  do {
    append(delimiter);

  start:
    append(enumerator.Current);
  } while (enumerator.MoveNext());
}

这样做的好处是您不会得到重复的代码,也不会得到额外的工作。您开始循环到执行第一个循环的一半,这就是您的初始化。您仅限于使用 do while 构造来模拟其他循环,但翻译很容易,阅读起来并不困难。

所以,现在的问题。我很高兴地尝试将它添加到我正在处理的一些代码中,但发现它不起作用。在 C、C++、Basic 中效果很好,但在 C# 中,您不能跳转到不是父作用域的不同词法作用域内的标签。我非常失望。所以我想知道,在 C# 中处理这个非常常见的编码问题(我主要在字符串生成中看到它)的最佳方法是什么?

也许更具体的要求:

  • 不要重复代码
  • 不要做不必要的工作
  • 不要比其他代码慢 2 到 3 倍
  • 可读

我认为可读性是我所说的食谱唯一可能会受到影响的事情。但是它在 C# 中不起作用,那么下一个最好的事情是什么?

* 编辑 * 因为一些讨论,我改变了我的表现标准。性能通常不是这里的限制因素,所以更正确的目标应该是不要不合理,不要成为有史以来最快的。

我不喜欢我建议的替代实现的原因是因为它们要么重复代码,这为更改一个部分而不是另一部分留出了空间,或者对于我通常选择的那个,它需要“撤消”操作,这需要额外的思考和时间来撤消事情你刚刚做的。特别是对于字符串操作,这通常会使您因一个错误或未能考虑空数组并试图撤消未发生的事情而打开关闭状态。

4

9 回答 9

18

我个人喜欢 Mark Byer 的选项,但您始终可以为此编写自己的通用方法:

public static void IterateWithSpecialFirst<T>(this IEnumerable<T> source,
    Action<T> firstAction,
    Action<T> subsequentActions)
{
    using (IEnumerator<T> iterator = source.GetEnumerator())
    {
        if (iterator.MoveNext())
        {
            firstAction(iterator.Current);
        }
        while (iterator.MoveNext())
        {
            subsequentActions(iterator.Current);
        }
    }
}

这相对简单......给出一个特殊的最后一个动作稍微难一些:

public static void IterateWithSpecialLast<T>(this IEnumerable<T> source,
    Action<T> allButLastAction,
    Action<T> lastAction)
{
    using (IEnumerator<T> iterator = source.GetEnumerator())
    {
        if (!iterator.MoveNext())
        {
            return;
        }            
        T previous = iterator.Current;
        while (iterator.MoveNext())
        {
            allButLastAction(previous);
            previous = iterator.Current;
        }
        lastAction(previous);
    }
}

编辑:由于您的评论与此性能有关,我将在此答案中重申我的评论:虽然这个一般问题相当普遍,但它并不常见,因为它是一个值得进行微优化的性能瓶颈。事实上,我不记得曾经遇到过循环机器成为瓶颈的情况。我确定它会发生,但这并不“常见”。如果我遇到它,我会特例化那个特定的代码,最好的解决方案将取决于代码需要做什么

然而,总的来说,我更看重可读性和可重用性,而不是微优化。

于 2010-08-29T20:07:04.210 回答
12

对于您的具体示例,有一个标准解决方案:string.Join. 这可以正确处理添加分隔符,这样您就不必自己编写循环。

如果您真的想自己编写,可以使用以下方法:

string delimiter = "";
foreach (var element in array)
{
    append(delimiter);
    append(element);
    delimiter = ",";
}

这应该是相当有效的,我认为阅读是合理的。常量字符串 "," 是 intern 的,因此这不会导致在每次迭代时都创建一个新字符串。当然,如果性能对您的应用程序至关重要,您应该进行基准测试而不是猜测。

于 2010-08-29T20:00:13.353 回答
7

你已经愿意放弃 foreach 了。所以这应该是合适的:

        using (var enumerator = array.GetEnumerator()) {
            if (enumerator.MoveNext()) {
                for (;;) {
                    append(enumerator.Current);
                    if (!enumerator.MoveNext()) break;
                    append(delimiter);
                }
            }
        }
于 2010-08-29T20:23:10.753 回答
6

您当然可以goto在 C# 中创建解决方案(注意:我没有添加null检查):

string Join(string[] array, string delimiter) {
  var sb = new StringBuilder();
  var enumerator = array.GetEnumerator();
  if (enumerator.MoveNext()) {
    goto start;
    loop:
      sb.Append(delimiter);
      start: sb.Append(enumerator.Current);
      if (enumerator.MoveNext()) goto loop;
  }
  return sb.ToString();
}

对于您的具体示例,这对我来说看起来很简单(这是您描述的解决方案之一):

string Join(string[] array, string delimiter) {
  var sb = new StringBuilder();
  foreach (string element in array) {
    sb.Append(element);
    sb.Append(delimiter);
  }
  if (sb.Length >= delimiter.Length) sb.Length -= delimiter.Length;
  return sb.ToString();
}

如果您想获得功能,可以尝试使用这种折叠方法:

string Join(string[] array, string delimiter) {
  return array.Aggregate((left, right) => left + delimiter + right);
}

虽然它读起来非常好,但它没有使用 a StringBuilder,所以你可能想滥用Aggregate一点来使用它:

string Join(string[] array, string delimiter) {
  var sb = new StringBuilder();
  array.Aggregate((left, right) => {
    sb.Append(left).Append(delimiter).Append(right);
    return "";
  });
  return sb.ToString();
}

或者你可以使用这个(从这里的其他答案中借用这个想法):

string Join(string[] array, string delimiter) {
  return array.
    Skip(1).
    Aggregate(new StringBuilder(array.FirstOrDefault()),
      (acc, s) => acc.Append(delimiter).Append(s)).
    ToString();
}
于 2010-08-29T21:54:33.690 回答
4

有时我使用 LINQ.First().Skip(1)处理这个......这可以提供一个相对干净(并且非常易读)的解决方案。

以你为例,

append(array.First());
foreach(var x in array.Skip(1))
{
  append(delimiter);
  append (x);
}

[这假设数组中至少有一个元素,如果要避免这种情况,可以轻松添加测试。]

使用 F# 将是另一个建议:-)

于 2010-08-29T20:31:20.530 回答
2

您“可以”通过多种方式绕过重复代码,但在大多数情况下,重复代码比可能的解决方案更丑陋/危险。你引用的“goto”解决方案对我来说似乎没有改进——我真的不认为你真的通过使用它来获得任何重要的东西(紧凑性、可读性或效率),同时你增加了程序员出错的风险在代码生命周期的某个时刻。

一般来说,我倾向于采用这种方法:

  • 第一个(或最后一个)动作的特殊情况
  • 循环其他动作。

这消除了每次检查循环是否在第一次迭代中引入的低效率,并且非常容易理解。对于不平凡的情况,使用委托或辅助方法来应用操作可以最大限度地减少代码重复。

或者我有时在效率不重要时使用的另一种方法:

  • 循环,并测试字符串是否为空以确定是否需要分隔符。

这可以写得比 goto 方法更紧凑和可读,并且不需要任何额外的变量/存储/测试来检测“特殊情况”迭代。

但我认为 Mark Byers 的方法对于您的特定示例来说是一个很好的干净解决方案。

于 2010-08-29T20:34:45.557 回答
0

如果你想走功能路线,你可以定义 String.Join 像 LINQ 构造,它可以跨类型重用。

就个人而言,我几乎总是会为了代码清晰而不是保存一些操作码执行。

例如:

namespace Play
{
    public static class LinqExtensions {
        public static U JoinElements<T, U>(this IEnumerable<T> list, Func<T, U> initializer, Func<U, T, U> joiner)
        {
            U joined = default(U);
            bool first = true;
            foreach (var item in list)
            {
                if (first)
                {
                    joined = initializer(item);
                    first = false;
                }
                else
                {
                    joined = joiner(joined, item);
                }
            }
            return joined;
        }
    }

    class Program
    {

        static void Main(string[] args)
        {
            List<int> nums = new List<int>() { 1, 2, 3 };
            var sum = nums.JoinElements(a => a, (a, b) => a + b);
            Console.WriteLine(sum); // outputs 6

            List<string> words = new List<string>() { "a", "b", "c" };
            var buffer = words.JoinElements(
                a => new StringBuilder(a), 
                (a, b) => a.Append(",").Append(b)
                );

            Console.WriteLine(buffer); // outputs "a,b,c"

            Console.ReadKey();
        }

    }
}
于 2010-08-30T02:01:57.067 回答
0

我更喜欢first变量方法。这可能不是最干净但最有效的方式。或者,您可以使用Length附加的东西并将其与零进行比较。与StringBuilder.

于 2010-08-29T20:06:12.447 回答
0

为什么不处理循环外的第一个元素?

StringBuilder sb = new StrindBuilder()
sb.append(array.first)
foreach (var elem in array.skip(1)) {
  sb.append(",")
  sb.append(elem)
}
于 2010-08-29T20:13:23.023 回答