Другие способы обработки "инициализации цикла" в С#
Начнем с того, что я соглашусь с тем, что утверждения 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();
}
}
}