Другие способы обработки "инициализации цикла" в С#

Начнем с того, что я соглашусь с тем, что утверждения goto в значительной степени не имеют отношения к конструкциям более высокого уровня в современных языках программирования и не должны использоваться, когда доступен подходящий заменитель.

Я недавно перечитал оригинальное издание Кодекса Стива Макконнелла и забыл о его предположении об общей проблеме кодирования. Я читал это много лет назад, когда я начинал сначала и не думал, что понял, насколько полезен рецепт. Проблема кодирования заключается в следующем: при выполнении цикла вам часто нужно выполнить часть цикла для инициализации состояния, а затем выполнить цикл с помощью некоторой другой логики и закончить каждый цикл с помощью той же логики инициализации. Конкретным примером является реализация метода String.Join(разделитель, массив).

Я думаю, что все сначала берут на себя эту проблему. Предположим, что метод append определен для добавления аргумента к возвращаемому значению.

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

  append(element);
}

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

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

Последнее решение стремится к тому, которое я предпочитаю только потому, что оно не дублирует код. Если логика последовательности инициализации когда-либо изменится, вам не нужно запоминать ее в двух местах. Тем не менее он требует дополнительной "работы", чтобы что-то сделать, а затем отменить, вызывая как минимум дополнительные циклы процессора, и во многих случаях, таких как наш пример String.Join, требует дополнительной памяти.

Тогда я был взволнован, чтобы прочитать эту конструкцию

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

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

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

Итак, теперь вопрос. Я с удовольствием попытался добавить это к некоторому коду, над которым я работал, и нашел, что он не работает. Отлично работает на C, С++, Basic, но в С# вы не можете перейти на метку внутри другой лексической области, которая не является родительской областью. Я был очень разочарован. Поэтому мне стало интересно, как лучше всего справиться с этой очень распространенной проблемой кодирования (я вижу это в основном в генерации строк) на С#?

Чтобы быть более конкретным с требованиями:

  • Не дублировать код
  • Не делайте ненужной работы.
  • Не более 2 или 3 раза медленнее, чем другой код.
  • Быть читаемым

Я думаю, что удобочитаемость - единственное, что может быть связано с рецептом, который я изложил. Однако это не работает на С#, так что следующая лучшая вещь?

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

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

Ответ 1

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

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

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

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

Ответ 2

Лично мне нравится опция 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);
    }
}

EDIT: поскольку ваш комментарий был связан с его производительностью, я повторю свой комментарий в этом ответе: хотя эта общая проблема достаточно распространена, для нее нередко бывает такое узкое место в производительности, которое стоит микро- оптимизация вокруг. В самом деле, я не могу вспомнить, когда-либо сталкивался с ситуацией, когда зацикливание становилось узким местом. Я уверен, что это происходит, но это не "обычное дело". Если я когда-нибудь столкнусь с этим, я сделаю особый случай именно для этого кода, и лучшее решение будет зависеть именно от того, что должен делать код.

В целом, однако, я ценю читаемость и повторное использование намного больше, чем микро-оптимизация.

Ответ 3

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

        using (var enumerator = array.GetEnumerator()) {
            if (enumerator.MoveNext()) {
                for (;;) {
                    append(enumerator.Current);
                    if (!enumerator.MoveNext()) break;
                    append(delimiter);
                }
            }
        }

Ответ 4

Вы можете создать решение goto в С# (примечание: я не добавлял проверки 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);
}

Несмотря на то, что он отлично читается, он не использует 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();
}

Ответ 5

Иногда я использую LINQ .First() и .Skip(1) для обработки этого... Это может дать относительно чистое (и очень читаемое) решение.

Используя ваш пример,

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

[Предполагается, что в массиве есть хотя бы один элемент, легкий тест для добавления, если этого избежать.]

Использовать F # будет другое предложение: -)

Ответ 6

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

В целом я склонен идти за подходом:

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

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

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

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

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

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

Ответ 7

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

Ответ 8

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

StringBuilder sb = new StrindBuilder()
sb.append(array.first)
foreach (var elem in array.skip(1)) {
  sb.append(",")
  sb.append(elem)
}

Ответ 9

Если вы хотите перейти по функциональному маршруту, вы можете определить String.Join как конструкцию LINQ, которая многократно используется для разных типов.

Лично я бы почти всегда обращал внимание на ясность кода за сохранение нескольких операций с опкодом.

EG:

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();
        }

    }
}