Other ways to deal with “loop initialization” in C#

To start with I'll say that I agree that goto statements are largely made irrelevant by higher level constructs in modern programming languages and shouldn't be used when a suitable substitute is available.

I was re-reading an original edition of Steve McConnell's Code Complete recently and had forgotten about his suggestion for a common coding problem. I had read it years ago when I was first getting started and don't think I realized how useful the recipe would be. The coding problem is the following: when executing a loop you often need to execute part of the loop to initialize state and then execute the loop with some other logic and ending each loop with the same initialization logic. A concrete example is implementing String.Join(delimiter, array) method.

I think everybody's first take on the problem is this. Assume the append method is defined to add the argument to your return value.

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

  append(element);
}

Note: A slight optimization to this is to remove the else and put it at the end of the loop. An assignment usually being a single instruction and equivalent to an else and decreases the number of basic blocks by 1 and increases the basic block size of the main part. The result being that execute a condition in each loop to determine if you should add the delimiter or not.

I've also seen and used other takes on dealing with this common loop problem. You can execute the initial element code first outside the loop, then perform your loop from the second element to the end. You can also change the logic to always append the element then the delimiter and once the loop is completed you can simply remove the last delimiter you added.

The latter solution tends to be the one that I prefer only because it doesn't duplicate any code. If the logic of the initialization sequence ever changes, you don't have to remember to fix it in two places. It does however require extra "work" to do something and then undo it, causing at least extra cpu cycles and in many cases such as our String.Join example requires extra memory as well.

I was excited then to read this construct

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

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

The benefit here being that you get no duplicated code and you get no additional work. You start your loop half way into the execution of your first loop and that is your initialization. You are limited to simulating other loops with the do while construct but the translation is easy and reading it is not difficult.

So, now the question. I happily went to try adding this to some code I was working on and found it didn't work. Works great in C, C++, Basic but it turns out in C# you can't jump to a label inside a different lexical scope that is not a parent scope. I was very disappointed. So I was left wondering, what is the best way to deal with this very common coding problem (I see it mostly in string generation) in C#?

To perhaps be more specific with requirements:

  • Don't duplicate code
  • Don't do unnecessary work
  • Don't be more than 2 or 3 times slower than other code
  • Be readable

I think readability is the only thing that might arguably suffer with the recipe I stated. However it doesn't work in C# so what's the next best thing?

* Edit * Я изменил свои критерии эффективности из-за некоторых обсуждений. Производительность, как правило, здесь не является ограничивающим фактором, поэтому более правильной целью должно быть не быть необоснованным, а не быть самым быстрым.

Причина, по которой я не люблю альтернативные реализации, которые я предлагаю, заключается в том, что они либо дублируют код, который оставляет место для замена одной части, а не другой или той, которую я обычно выбираю, требует «отмены» операции, которая требует дополнительных усилий и времени, чтобы отменить то, что вы только что сделали. В частности, при манипулировании строками это обычно приводит к тому, что вы открыты для одной ошибки или не можете учесть пустой массив и пытаетесь отменить то, что не произошло.

22
задан Mat 23 December 2011 в 15:32
поделиться

5 ответов

Для вашего конкретного примера есть стандартное решение: string.Join. Это правильно обрабатывает добавление разделителя, поэтому вам не нужно писать цикл самостоятельно.

Если вы действительно хотите написать это самостоятельно, вы можете использовать следующий подход:

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

Это должно быть достаточно эффективным, и я думаю, что его разумно прочитать. Постоянная строка "," интернирована, поэтому новая строка не будет создаваться на каждой итерации. Конечно, если производительность критична для вашего приложения, вы должны проводить тесты, а не гадать.

11
ответ дан 29 November 2019 в 04:19
поделиться

Вы уже готовы отказаться от foreach. Так что это должно быть подходящим:

        using (var enumerator = array.GetEnumerator()) {
            if (enumerator.MoveNext()) {
                for (;;) {
                    append(enumerator.Current);
                    if (!enumerator.MoveNext()) break;
                    append(delimiter);
                }
            }
        }
7
ответ дан 29 November 2019 в 04:19
поделиться

Есть способы обойти дублированный код, но в большинстве случаев дублированный код гораздо менее уродлив/опасн, чем возможные решения. Решение "goto", которое вы цитируете, не кажется мне улучшением - я действительно не думаю, что вы действительно получаете что-то существенное (компактность, читабельность или эффективность), используя его, в то время как вы увеличиваете риск того, что программист сделает что-то неправильно в какой-то момент жизни кода.

В целом я предпочитаю подход:

  • Особый случай для первого (или последнего) действия
  • цикл для остальных действий.

Это устраняет неэффективность, связанную с проверкой того, находится ли цикл в первой итерации каждый раз, и это действительно легко понять. В нетривиальных случаях использование делегата или вспомогательного метода для применения действия может свести к минимуму дублирование кода.

Или другой подход, который я иногда использую, когда эффективность не важна:

  • цикл и проверка, пуста ли строка, чтобы определить, требуется ли разделитель.

Это может быть написано более компактно и читабельно, чем подход goto, и не требует никаких дополнительных переменных/хранилищ/тестов для обнаружения итерации «особого случая».

Но я думаю, что подход Марка Байерса является хорошим чистым решением для вашего конкретного примера.

2
ответ дан 29 November 2019 в 04:19
поделиться

Я предпочитаю первый переменный метод. Это, вероятно, не самый чистый, но самый эффективный способ.В качестве альтернативы вы можете использовать Длина того, к чему вы добавляете, и сравнить его с нулем. Хорошо работает с StringBuilder.

0
ответ дан 29 November 2019 в 04:19
поделиться

Почему бы не переместить работу с первым элементом за пределы цикла?

StringBuilder sb = new StrindBuilder()
sb.append(array.first)
foreach (var elem in array.skip(1)) {
  sb.append(",")
  sb.append(elem)
}
0
ответ дан 29 November 2019 в 04:19
поделиться
Другие вопросы по тегам:

Похожие вопросы: