День добрый, уважаемые коллеги.

В данном обзоре будет рассмотрен самый популярный в программировании цикл for в контексте языка C#, что, впрочем, не мешает программистам на других си-подобных языках ознакомиться с этой статьей — вы также найдете для себя кое-что полезное.

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

Содержание





Обзор синтаксиса оператора for


Итак, наш подопытный на сегодня — цикл for.
Согласно MSDN, синтаксис цикла выглядит следующим образом:

for (initializer; condition; iterator)
    body


  • Раздел инициализатора (initializer) устанавливает начальные условия. Оператор в этом разделе выполняется только один раз, перед тем как начнется цикл. Важным моментом здесь является то, что переменные, объявленные в инициализаторе, являются локальными для тела цикла и не могут использоваться по его окончанию.
  • Раздел условия (condition) содержит логическое выражение, вычисляемое для определения того, должен ли цикл остановиться или должен выполнить попытку.
  • Раздел итератора (iterator) определяет, что происходит после каждой итерации тела цикла.
  • Тело цикла (body) выполняется при каждой итерации и может содержать директиву break для выхода из цикла или continue для перехода к следующей итерации.


Более подробно с тем, какие инструкции могут использоваться в инициализаторе и итераторе, вы можете ознакомиться в документации по ссылке выше, мы же перейдем непосредственно к разбору вариантов использования цикла for.


0. Счетчик



Типичное использование цикла for — выполнение некоторой операции n раз, в этом случае цикл выглядит следующим образом:

// счетчик от 0 до n-1
for (int i = 0; i < n; i++)
{
    // TODO
}

// счетчик от n до 1
for (int i = n; i > 0; i--)
{
    // TODO
}


Этот пример приведен под номером ноль, т.к. все без исключения программисты используют цикл for в данном варианте. Можно только добавить, что вовсе не обязательно изменять значение счетчика на единицу, например, следующий код выводит всем числа, кратным трем, в интервале от 12 до 100 (последняя итерация, разумеется, будет по числу 99):

for (int i = 12; i < 100; i += 3)
{
    Console.WriteLine(i);
}



1. Цикл while



Цикл while является частным случаем цикла for, если отсутствует инициализатор и итератор:

for (; condition; )
{
    // TODO
}


Цикл будет выполняться, пока верно условие. Условие и вовсе можно убрать, тогда получится бесконечный цикл, аналогичный while (true):

for (; ; )
{
    // TODO
}


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


2. Объектный итератор



Т.к. «научное» название этого типа цикла мне неизвестно, решил назвать его объектным итератором. Это означает, что на этот раз мы имеем дело не с числовой переменной-счетчиком, а с объектом.

В общем виде цикл выглядит следующим образом:

for (item = initialValue; item != null; item = GetNext(item))
{
    // TODO
}


У нас есть некий объект item, который получает начальное значение в инициализаторе или до начала цикла, далее, если объект не пустой, мы выполняет с ним какие-то действия в теле цикла и получаем очередное значение в итераторе. Лучше всего это работает для связей предок-потомок, как например в классе Exception. Под спойлером как раз приведен пример цикла для сбора полного текста ошибки из исключения и всех его дочерних исключений.

Метод GetExceptionTextFull
Код метода GetExceptionTextFull:

/// <summary>
/// получить полный текст ошибки
/// </summary>
/// <param name="ex"> исключение </param>
/// <returns></returns>
public string GetExceptionTextFull(Exception ex)
{
    StringBuilder sb = new StringBuilder();
    for (string tab = ""; ex != null; ex = ex.InnerException, tab = tab.Length == 0 ? "+---" : "    " + tab)
    {
        sb.Append(tab);
        sb.AppendLine(ex.Message);
    }
    return sb.ToString();
}


Использование:

// формируем исключение с двумя вложенными исключениями
Exception ex = new Exception("Exception #1", new Exception("Exception #2", new Exception("Exception #3")));

// получаем и печатаем полный текст ошибки
string message = GetExceptionTextFull(ex);
Console.WriteLine(message);


Результат:

Exception #1
+---Exception #2
    +---Exception #3




Аналогичным образом можно идти вверх по иерархии, например, в поиске базового типа, удовлетворяющего условию:

for (Type t = type; t != typeof(object); t = t.BaseType)
{
    // TODO
}


Если вы используете собственные классы, содержащие ссылку на родителя, обход вверх по иерархии очень удобно выполнять циклом for. Например, у вас есть класс Entity, содержащий ссылку на родителя (public Entity Parent { get; set; }).

Используя цикл for можно написать такой вот метод расширения, который для данной вершины вернет родителя указанного типа или null, если родителя, удовлетворяющего условию, нет.

/// <summary>
/// получить родительскую сущность
/// </summary>
/// <typeparam name="T"> тип родительской сущности </typeparam>
/// <param name="entity"> сущность </param>
/// <returns></returns>
public static T GetParentOfType<T>(this Entity entity) where T : Entity
{
    for (entity = entity.Parent; entity != null && !(entity is T); entity = entity.Parent) ;
    return entity as T;
}



3. Итератор по коллекции



Иногда используется на практике как альтернатива foreach, т.к. в отличие от него позволяет получить индекс элемента и, возможно, изменить коллекцию (во время итерации с помощью foreach коллекцию менять нельзя):

for (int i = 0, count = list.Count; i < count; i++)
{
    var item = list[i];
    // TODO
}


Также стоит помнить, что хотя for не дает прироста производительности, foreach создает дополнительные объекты (Enumerator), которые впоследствии вынужден собирать сборщик мусора. Поэтому в некоторых редких случаях использование for вместо foreach может понизить затраты ресурсов системы. Кто-то, вероятно, скажет, что C# не предназначен для высоконагруженных систем, где подобная оптимизация может стать актуальной и будет прав, пока не начнет писать игры на C#.


X. Прочие интересные циклы



В данном разделе будут рассмотрены просто интересные циклы, которые я откопал в своем коде.

1. Построение всех возможных комбинаций элементов списка.

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

Количество комбинаций равно 2 ^ N, где N — число комбинируемых элементов. Идея основана на том, что биты чисел от 0 до N-1 определяют, будет ли включен конкретный элемент исходного набора в результат или нет. Например, для набора букв A, B и C, комбинация под номером 5 (двоичный код 101) включает элементы A и C, а комбинация №7 (код 111) включает все элементы. Вложенный цикл ведет итерацию сразу по двум переменным: j — номер элемента и k — это номер комбинации, сдвигаемый побитово вправо (k >>= 1). Если младший бит числа k равен единице ((k & 1) != 0), то элемент с номером j включается в результат.

/// <summary>
/// построить все возможные комбинации
/// </summary>
/// <typeparam name="T"> тип элемента </typeparam>
/// <param name="data"> список объектов </param>
/// <returns></returns>
public static List<List<T>> MakeCombinations<T>(IEnumerable<T> data)
{
    if (data == null) return null;

    // конвертируем в массив
    T[] array = data.ToArray();

    // считаем число комбинаций и готовим результирующий список
    int count = 1 << array.Length;
    List<List<T>> res = new List<List<T>>(count);

    // перечисляем все комбинации
    for (int i = 0; i < count; i++)
    {
        List<T> list = new List<T>();
        for (int j = 0, k = i; k > 0; j++, k >>= 1)
        {
            if ((k & 1) != 0) list.Add(array[j]);
        }
        res.Add(list);
    }
    return res;
}


Использование:

// формируем список исходных значений - набор букв от A до D
var data = Enumerable.Range('A', 'D' - 'A' + 1).Select(e => ((char)e).ToString()).ToList();

// печатаем все возможные перестановки (2 ^ 4 = 16)
foreach (var list in MakeCombinations(data).OrderBy(e => e.Count))
{
    Console.WriteLine(string.Join(" + ", list.ToArray()));
}


Результат:

A
B
C
D
A + B
A + C
B + C
A + D
B + D
C + D
A + B + C
A + B + D
A + C + D
B + C + D
A + B + C + D


2. Подсчет суммы первых N чисел Фибоначчи.

Это, конечно, не пример из кода промышленной системы, да и сама задача решается несколько иным способом (сумма первых N чисел Фибоначчи равна f(N + 2) — 1), но у решения «в лоб» достаточно интересный код, а цикл, используемый в нем примечателен отсутствием тела.

// начальные условия
int n = 10;
int sum = 0;

// суммирование
for (int a = 0, b = 1, i = 0; i++ < n; sum += b, b = a + (a = b)) ;

// вывод результата
Console.WriteLine("Сумма первых {0} чисел Фибоначчи равна {1}", n, sum);


Также прошу обратить внимание на оператор b = a + (a = b), который одновременно вычисляет следующее число Фибоначчи (b) и сохраняет предыдущее значение b в a. Если изменить последовательность слагаемых, то результат будет иной, т.к. сперва выполнится присваивание, а только затем суммирование. В целом, следует избегать таких выражений, если вы не до конца уверены в своем знании арифметики C, с другой стороны, вы и не узнаете ее, пока не опробуете сами.


Заключение



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

Другие мои публикации



  1. Локализация проектов на .NET с интерпретатором функций
  2. Заполнение текстовых шаблонов данными на основе модели. Реализация на .NET с использованием динамических функций в байт-коде (IL)

Комментарии (28)


  1. StrangerInRed
    19.06.2015 12:16
    +2

    В истории программирования был такой момент, когда for хотели признать «синтетическим циклом» и не использовать, а использовать только while и do-while. Вирт, по моему, все это продвигал.


    1. Yuuri
      19.06.2015 17:10
      +3

      В тред реквестируются оберонщики.


      1. StrangerInRed
        19.06.2015 18:02

        Ни в коем случае. Я наоборот всегда смеялся с такого положения. Какая разница, синтетический или нет? Функции тоже синтетические, а вот метки нет. Но опять же, Вирт и метки в общем хотел уничтожить.


  1. lair
    19.06.2015 12:20
    +2

    Также стоит помнить, что хотя for не дает прироста производительности, foreach создает дополнительные объекты (Enumerator), которые впоследствии вынужден собирать сборщик мусора.

    Это, кстати, не обязательно.


  1. Temirkhan
    19.06.2015 12:22
    +31

    Я как-будто статью «программирование для чайников» прочитал.


  1. Mrrl
    19.06.2015 13:53

    Есть ещё один вариант использования:
    Пусть мы хотим выполнить какое-то действие, которое можно сделать разными способами. Пробуем первый способ, не получилось — пробуем второй, и т.д. Выносить действие в отдельную функцию не хотим, писать 10-кратный вложенный if — тем более.

    for(;;){
       Attempt1;
       if(success) break;
       Attempt2;
       if(success) break;
       Attempt3;
       if(success) break;
       LastAttempt;
       break;
    }
    

    Можно и наоборот — break писать в случае, если произошла ошибка, а пользоваться Exception для обработки штатных ситуаций не позволяет религия.


    1. lair
      19.06.2015 13:56
      +2

      … и это в то время, как прогрессивное человечество придумывает Try[T]


      1. Mrrl
        19.06.2015 13:59

        Это на каком языке?


        1. lair
          19.06.2015 14:01

          Ну, конкретно это на Scala. Но реализовать эту монаду можно везде, где монады вообще реализуемы.


          1. Mrrl
            19.06.2015 14:04

            Так это, наверное, про функциональное программирование. А здесь императивное.


            1. lair
              19.06.2015 14:05
              +2

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


          1. 0xd34df00d
            19.06.2015 14:10

            А кстати, расскажите не-скалисту, пожалуйста, зачем нужна эта Try, когда есть Maybe (в терминах хаскеля), реализующая MonadPlus и, как следствие, msum:

            Prelude Control.Monad> msum [Nothing, Just 1, Just 2, Just 3, Nothing]
            Just 1
            Prelude Control.Monad> msum [Nothing, Nothing]
            Nothing
            


            1. lair
              19.06.2015 14:12

              Я не очень понимаю, какая связь между суммами и ошибками. Maybe — это работа с потенциально отсутствующими значениями, а Try — с ошибками.


              1. 0xd34df00d
                19.06.2015 14:19
                +2

                Но ведь в исходном комментарии была речь о последовательности вычислений, которые надо выполнять, пока что-нибудь из них не получится.

                Условно, используя некоторые из имён из начального комментария:

                attempt1 :: Maybe a
                attempt2 :: Maybe a
                attempt3 :: Maybe a
                lastAttempt ::Maybe a
                
                ...
                
                tryAll = msum [attempt1, attempt2, attempt3, lastAttempt]
                


                Какие-нибудь там Either тоже так же себя ведут при фиксированном первом аргументе, впрочем.


                1. lair
                  19.06.2015 14:33

                  На мой личный вкус — семантика нарушается. Try прозрачнее (там явно выделена семантика ошибочной ситуации и возможность ее обработать).


                  1. 0xd34df00d
                    19.06.2015 14:36

                    Так либо вы уже обработали ошибку и возвращаете что-то Maybe-подобное, либо ещё не обработали, но тогда логично прокидывать наверх. Навешивать на одну функцию две обязанности — обработку ошибок конкретного вычисления и выбор первого неошибочного результата из набора — как-то не очень правильно, ИМХО.


                    1. lair
                      19.06.2015 14:43

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

                      Теперь мы хотим в одном конкретном случае сделать композицию этих функций по принципу «нам все равно, какие там ошибки, мы просто хотим получить результат хотя бы откуда-нибуь». В этом случае нам надо либо оборачивать каждую функцию во что-то, что превращает значение — в Just, а ошибку — в Nothing, либо же взять готовый примитив для обработки ошибок в виде Try, который позволит написать приблизительно следующее:

                      Try(Attempt1)
                      .RecoverWith(Attempt2)
                      .RecoverWith(Attempt3)
                      .RecoverWith(Attempt4)
                      .RecoverWith(() => null)
                      


                      При этом сами компонуемые функции никак не меняются.


            1. senia
              20.06.2015 23:05

              На самом деле нужен не Maybe (Option в Scala), а Validation (есть в scalaz и haskell), чтоб сохранить информацию о причине неудачи. Try же — это такая вариация на тему Validation, удобная для работы с java-библиотеками, да и вообще упрощенная (не требующая понимания монад).


              1. senia
                20.06.2015 23:09

                Всем, кто приготовил яйца и помидоры: я знаю, что Validation — не монада.


              1. 0xd34df00d
                21.06.2015 23:20

                Ну, тащем,

                The Validation data type is isomorphic to Either and has a Monad instance that does the same as Either. The only difference to Either is the constructor names and surrounding library support.


                А про Either я там выше уже писал.

                Почему ж не монада, когда вполне себе монада? :)


                1. senia
                  22.06.2015 01:23

                  Для scalaz это не верно. В scalaz Applicative на Validation комбинирует ошибки. Вот обсуждение.


  1. kahi4
    19.06.2015 14:11
    +7

    Ужас. Думал, прочитаю про хитрые скрытые оптимизации, например, когда будет компилироваться в аппаратный цикл (правда не уверен, что он есть в x86, однако в микроконтроллерах не редкость), про то, как красиво и локанично сделать break сразу двух вложенных циклов (задача не такая уж и редкая — прервать оба цикла при каком-то условии), а не чтобы городить

    for(...)
    {
     bool stop = False;
     for(...) { if (condition) stop = True; break; }
     if (stop) break;
    }
    


    И какие-то хитрые примеры использования, а тут вторая глава из книги «бейсик для школьников». Простите.


    1. Mrrl
      19.06.2015 14:23
      +2

      про то, как красиво и лаконично сделать break сразу двух вложенных циклов

      Парочка goto на большую программу её не сильно испортит… А если тип выхода (по break или по завершению внешнего цикла) нужно знать для дальнейшей работы — что бывает почти всегда, — то с логической переменной получается гибче. Только описывать её приходится снаружи от внешнего цикла. Соптимизировать можно и потом.


      1. kahi4
        19.06.2015 14:58
        +4

        Я вообще не вижу ничего плохого в goto в пределах одной функции. Но скажите это js, где goto нет. И тот же js не любит try catch, покуда функции с ним не компилируются и страдает производительность. А пробрасывать уже три вложенных цикла (ох, бывает и такое) только путает с лишними условиями. Хотя это объект яростных споров, поэтому оставим это в сторонке.

        Что касается статьи, то чтобы она была достойна хабра, я бы сделал примерно такую структуру:

        1. Что такое циклы в принципе (участок кода, который будет выполняться пока верно какое-то условие).
        2. Префиксные и постфиксные циклы (while… do while)
        3. Во что это компилируется в простейшем случае (в префиксном в условный jump в начале цикла для обеспечения выхода из цикла и в безусловный jump в конце для возвращения в начало цикла. И отсюда можно порассуждать, почему два условия в объявлении цикла может давать просяд по скорости). Заодно, на этом этапе можно показать почему и какие оптимизации оказываются действительно полезными, а какие — попытка помогать насосу откачивать воду ложкой.
        4. Каким образом компиляторы (сишные и .Net) пытаются оптимизировать циклы, какие для этого необходимо соблюсти условия (например, бытует мнение, что цикл for (int i = 0; i < 3; i++) foo(); развернется в foo(); foo(); foo(); foo();. Но что этому помешает? (помимо самого очевидного — если цикл не от константы до константы с константным инкрементом, простите за товтологию), и что это дает (не отводятся под ненужные переменные драгоценные регистры).
        5. Тут уже собственно примеры от простых до каких-то хитрых и изящных, которые неожиданно дают большой прирост, так же какие-то рекомендации (например, свести количество break к минимому, покуда на каждую конструкцию генерируется свой условный переход и лучше если он будет один, тогда предсказывать ветвления конвееру будет проще. Правда этот пример я придумал из головы и совсем не факт, что это правда, но как раз об этом было бы крайне интересно почитать в статье о циклах для профессионалов, а не для учащихся начальных классов).

        6. Уже совсем для любознательных — в какую схему синтезируются циклы в verilog и vhdl.


  1. gurux13
    19.06.2015 15:01
    +4

    for (int a = 0, b = 1, i = 0; i++ < n; sum += b, b = a + (a = b)) ;
    

    На мой взгляд, не стоит так писать. Даже если здесь нет UB, читающий Ваш код маньяк с топором пойдет туда, где Вы живёте.


    1. kahi4
      19.06.2015 15:03

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


  1. Mrrl
    19.06.2015 15:24
    +1

    Перебор многозначных чисел (длина LNum, цифры от 0 до MaxDig)

                int LNum=3,MaxDig=3;
                for(var S=new Stack<int>(new int[] { -1 });S.Count>0;) {
                    S.Push(S.Pop()+1);
                    if(S.Peek()>MaxDig) S.Pop();
                    else if(S.Count==LNum) Console.WriteLine(string.Join(",",S));
                    else S.Push(-1);
                }
    



  1. Mrrl
    19.06.2015 15:49

    А перебор чисел без повторений цифр — ещё смешнее. В нём for выглядит почти как обычный цикл со счётчиком:

                int LNum=4,MaxDig=3;
                var S=new Stack<int>();
                for(int a=0;a<=MaxDig || S.Count>0;a++) {
                    if(a<=MaxDig) {
                        if(S.Contains(a)) continue;
                        S.Push(a);
                        a=-1;
                        if(S.Count!=LNum) continue;
                        Console.WriteLine(string.Join(",",S));
                    }
                    a=S.Pop();
                }