Очень часто замечаю, что люди пишут вот так:

var length = array.Length;
for (int i = 0; i < length; i++) {
    //do smth
}

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

Для начала рассмотрим два метода на C#:

public int WithoutVariable() {
    int sum = 0;
    for (int i = 0; i < array.Length; i++) {
        sum += array[i];
    }
    return sum;
}
public int WithVariable() {
    int sum = 0;
    int length = array.Length;
    for (int i = 0; i < length; i++) {
        sum += array[i];
    }
    return sum;
}

И давайте посмотрим на код, который получается после JIT компиляции в релизе на .NET Framework 4.7.2 под LegacyJIT-x86:
WithoutVariable()
;int sum = 0;
    xor  edi, edi
;int i = 0;
    xor  esi, esi
;int[] localRefToArray = this.array;
    mov  edx, dword ptr [ecx+4]
;int arrayLength = localRefToArray.Length;
    mov  ecx, dword ptr [edx+4]
;if (arrayLength == 0) return sum;
    test ecx, ecx
    jle  exit
;int arrayLength2 = localRefToArray.Length;
    mov  eax, dword ptr [edx+4] 
;if (i >= arrayLength2)
;  throw new IndexOutOfRangeException();
  loop:
    cmp  esi, eax
    jae  056e2d31
;sum += localRefToArray[i];
    add  edi, dword ptr [edx+esi*4+8]
;i++;
    inc  esi
;if (i < arrayLength) goto loop
    cmp  ecx, esi
    jg  loop
;return sum;
  exit:
    mov  eax, edi
WithVariable()
;int sum = 0;
    xor  esi, esi
;int[] localRefToArray = this.array;
    mov  edx, dword ptr [ecx+4]
;int arrayLength = localRefToArray.Length;
    mov  edi, dword ptr [edx+4]
;int i = 0;
    xor  eax, eax
;if (arrayLength == 0) return sum;
    test edi, edi
    jle  exit
;int arrayLength2 = localRefToArray.Length;
    mov  ecx, dword ptr [edx+4]
;if (i >= arrayLength2)
;  throw new IndexOutOfRangeException();
  loop:
    cmp  eax, ecx
    jae  05902d31
;sum += localRefToArray[i];
    add  esi, dword ptr [edx+eax*4+8]
;i++;
    inc  eax
;if (i < arrayLength) goto loop
    cmp  eax, edi
    jl   loop
;return sum;
  exit:
    mov  eax, esi

Скриншот сравнения в Meld


Несложно заметить, что количество ассемблерных инструкций абсолютно одинаково — 15 штук. Причём логика этих инструкций тоже практически полностью совпадает. Есть только небольшое различие в порядке инициализации переменных и сравнении стоит ли продолжать цикл. Можно заметить, что в обоих случаях длина массива заносится в регистры два раза перед циклом:

  • чтобы проверить на 0 (arrayLength);
  • и во временную переменную для проверки в условии цикла (arrayLength2);

Получается, что оба вышеприведённых метода компилируются в один и тот же код, но первый пишется быстрее и явный выигрыш в скорости выполнения отсутствует.

Приведённый выше ассемблерный код навёл меня на некоторые мысли и я решил проверить ещё пару методов:

public int WithoutVariable() {
    int sum = 0;
    for(int i = 0; i < array.Length; i++) {
        sum += array[i] + array.Length;
    }
    return sum;
}

public int WithVariable() {
    int sum = 0;
    int length = array.Length;
    for(int i = 0; i < length; i++) {
        sum += array[i] + length;
    }
    return sum;
}

Теперь суммируется текущий элемент и длина массива, только в первом случае длина массива каждый раз запрашивается, а во втором сохраняется один раз в локальную переменную. Посмотрим на ассемблерный код этих методов:
WithoutVariable()
int sum = 0;
    xor  edi, edi
int i = 0
    xor  esi, esi
int[] localRefToArray = this.array
    mov  edx, dword ptr [ecx+4]
int arrayLength = localRefToArray.Length
    mov  ebx, dword ptr [edx+4]
if (arrayLength == 0) return sum;
    test ebx, ebx
    jle  exit
int arrayLength2 = localRefToArray.Length;
    mov  ecx, dword ptr [edx+4]
if (i >= arrayLength2)
  throw new IndexOutOfRangeException()
  loop:
    cmp  esi, ecx 
    jae  05562d39
int t = array[i]
    mov  eax, dword ptr [edx+esi*4+8]
+= sum;
    add  eax, edi
t+= arrayLength;
    add  eax, ebx
sum = t
    mov  edi, eax
i++;
    inc  esi
if (i < arrayLength) goto loop
    cmp  ebx, esi
    jg   loop
return sum;
  exit:
    mov  eax, edi
WithVariable()
int sum = 0;
    xor  esi, esi
int[] localRefToArray = this.array;  
    mov  edx, dword ptr [ecx+4]
int arrayLength = localRefToArray.Length;  
    mov  ebx, dword ptr [edx+4]
int i = 0;  
    xor  ecx, ecx
if (arrayLength == 0) (return sum;) 
    test  ebx, ebx
    jle  exit
int arrayLength2 = localRefToArray.Length;
    mov   edi, dword ptr [edx+4]
if (i >= arrayLength2)
 throw new IndexOutOfRangeException();
 loop:
    cmp  ecx, edi
    jae  04b12d39
int t = array[i]
    mov  eax, dword ptr [edx+ecx*4+8]
+= sum
    add  eax, esi
t+= arrayLength
    add  eax, ebx
sum = t
    mov  esi, eax
i++
    inc  ecx
if (i < arrayLength) goto loop 
    cmp  ecx, ebx
    jl   loop
return sum;
  exit:
    mov  eax, esi

Скриншот сравнения в Meld


Количество инструкций у этих двух методов и сами инструкции почти абсолютно одинаковые, опять различие есть только в порядке инициализации переменных и проверке на продолжение цикла. Причём можно заметить, что в расчёте sum учитывается только тот array.Length, который был взят самым первым. Очевидно что вот это:
int arrayLength2 = localRefToArray.Length;
    mov     edi, dword ptr [edx+4]
if (i >=arrayLength2) throw new IndexOutOfRangeException();
    cmp     ecx, edi
    jae     04b12d39
во всех четырёх методах — заинлайниная проверка на выход за пределы массива и она выполняется для каждого элемента массива.

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

Совершенно другая ситуация с ForEach. Возьмём три метода:

public int ForEachWithoutLength() {
    int sum = 0;
    foreach (int i in array) {
        sum += i;
    }
    return sum;
}

public int ForEachWithLengthWithoutLocalVariable() {
    int sum = 0;
    foreach (int i in array) {
        sum += i + array.Length;
    }
    return sum;
}

public int ForEachWithLengthWithLocalVariable() {
    int sum = 0;
    int length = array.Length;
    foreach (int i in array) {
        sum += i + length;
    }
    return sum;
}

И посмотрим на их код после JIT:

ForEachWithoutLength()
;int sum = 0;
    xor  esi, esi
;int[] localRefToArray = this.array; 
    mov  ecx, dword ptr [ecx+4]
;int i = 0;
    xor  edx, edx
;int arrayLength = localRefToArray.Length; 
    mov  edi, dword ptr [ecx+4]
;if (arrayLength == 0) goto exit;
    test  edi, edi
    jle  exit
;int t = array[i];
  loop:
    mov  eax, dword ptr [ecx+edx*4+8]
;sum+=i;
    add  esi, eax
;i++;
    inc  edx
;if (i < arrayLength) goto loop
    cmp  edi, edx
    jg  loop
;return sum;
  exit:
    mov  eax, esi

ForEachWithLengthWithoutLocalVariable()
;int sum = 0;
    xor  esi, esi
;int[] localRefToArray = this.array;  
    mov  ecx, dword ptr [ecx+4]
;int i = 0; 
    xor  edx, edx
;int arrayLength = localRefToArray.Length;  
    mov  edi, dword ptr [ecx+4]
;if (arrayLength == 0) goto exit
    test  edi, edi
    jle  exit
;int t = array[i];  
  loop:
    mov  eax, dword ptr [ecx+edx*4+8]
;sum+=i; 
    add  esi, eax
;sum+=localRefToArray.Length;
    add  esi, dword ptr [ecx+4]
;i++;
    inc  edx
;if (i < arrayLength) goto loop 
    cmp  edi, edx
    jg  loop
;return sum;
  exit:
    mov  eax, esi

ForEachWithLengthWithLocalVariable()
;int sum = 0;
    xor  esi, esi
;int[] localRefToArray = this.array;   
    mov  edx, dword ptr [ecx+4]
;int length = localRefToArray.Length;   
    mov  ebx, dword ptr [edx+4]
;int i = 0;  
    xor  ecx, ecx
;int arrayLength = localRefToArray.Length;   
    mov  edi, dword ptr [edx+4]
;if (arrayLength == 0) goto exit; 
    test  edi, edi
    jle  exit
;int t = array[i];  
  loop: 
    mov  eax, dword ptr [edx+ecx*4+8]
;sum+=i;  
    add  esi, eax
;sum+=length ; 
    add  esi, ebx
;i++; 
    inc  ecx
;if (i < arrayLength) goto loop
    cmp  edi, ecx
    jg  loop
;return sum;
  exit:
    mov  eax, esi

Первое что бросается в глаза это то, что получилось меньше ассемблерных инструкций, по сравнению с циклом for (например для простого суммирования элементов вышло в foreach 12 инструкций, в for 15).

Скриншот сравнения For и ForEach


В самом деле, если написать бенчмарк for vs foreach на 1 000 000 элементов массива, то получится такая картина для

sum+=array[i];
Method ItemsCount Mean Error StdDev Median Ratio RatioSD
ForEach 1000000 1.401 ms 0.2691 ms 0.7935 ms 1.694 ms 1.00 0.00
For 1000000 1.586 ms 0.3204 ms 0.9447 ms 1.740 ms 1.23 0.65
и для

sum+=array[i] + array.Length;
Method ItemsCount Mean Error StdDev Median Ratio RatioSD
ForEach 1000000 1.703 ms 0.3010 ms 0.8874 ms 1.726 ms 1.00 0.00
For 1000000 1.715 ms 0.2859 ms 0.8430 ms 1.956 ms 1.13 0.56
ForEach для массивов отрабатывает быстрее чем For. Почему так происходит? Чтобы это понять нужно сравнивать код после JIT.

Скриншот сравнения всех трёх вариантов foreach


Посмотрим на ForEachWithoutLength. В нём длина массива запрашивается только один раз и не происходит никаких проверок на выход элементов за пределы массива. Так происходит потому что цикл ForEach во-первых запрещает изменение коллекции внутри цикла, а во-вторых точно не будет выходить за пределы коллекции. Из-за этого JIT может себе позволить вообще убрать проверки на выход за пределы массива.

Теперь посмотрим внимательно на метод ForEachWithLengthWithoutLocalVariable. В его коде есть только один странный момент в том, что sum+=length происходит не с сохранённой ранее локальной переменной arrayLength, а с новой, которую программа спрашивает каждый раз из памяти. Получается, что обращений к памяти за длиной массива будет N + 1, где N — длина массива.

Осталось рассмотреть метод ForEachWithLengthWithLocalVariable. Его код ничем не отличается от ForEachWithLengthWithoutLocalVariable, кроме работы с длинной массива. Компилятор опять сгенерировал локальную переменную arrayLength, с помощью которой проверяет что массив не пустой, но при этом компилятор честно сохранил нашу явную локальную переменную length и сложение в теле цикла происходит уже с ней. Получается, что этот метод всего дважды обращается к памяти для определения длины массива. Эту разницу в количестве обращений к памяти в реальных приложениях очень сложно заметить.

Ассемблерный код рассмотренных методов получился такой простой потому что сами методы простые. Будь в методе больше параметров, там была бы уже работа со стеком, переменные хранились бы не только в регистрах и возможно было бы больше проверок, но основная логика осталась бы такая же: введение локальной переменной с длиной массива может иметь смысл только для повышения читабельности кода. Кроме того, оказалось, что Foreach по массиву часто выполняется быстрее чем For.

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


  1. jevius
    23.12.2018 16:03
    +1

    Сомнительная оптимизация, это как называть переменные одной буквой для экономии памяти.

    И поправьте отображение кода


    1. T-D-K Автор
      23.12.2018 16:08
      +2

      Вводить лишнюю переменную всегда медленнее чем её не вводить. Мне часто объсняли, что сохраняя в локальную переменную длину массива, программист таким образом увеличивает скорость выполнения цикла. Я доказал, что это не так. Всё. За оптимизациями я не гнался.

      И поправьте отображение кода

      Что не так с отображением кода? Можно подробнее? habrahabr не умеет в нативную подсветку ассемблера и пришлось экспериментировать с другими тулзами.


      1. jevius
        23.12.2018 16:13
        -4

        Скриншотом хоть. Как-то грязно сейчас


        1. T-D-K Автор
          23.12.2018 16:18
          +2

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


          1. jevius
            23.12.2018 16:48
            -3

            В данном конкретном случае скорее всего никто не станет копировать те вставки. А гуглить код… Здесь тоже маловероятный сценарий


      1. u-235
        23.12.2018 17:43

        Мне часто объсняли, что сохраняя в локальную переменную длину массива, программист таким образом увеличивает скорость выполнения цикла.
        Наверно это относилось к компиляторам, которые не умели делать хорошую оптимизацию типа SSA.


        1. SiliconValleyHobo
          23.12.2018 22:19
          +2

          Это не ssa, это loop invariant


      1. navrocky
        23.12.2018 21:39

        А если это не функция Length стандартного Array, а какая-нибудь кастомная функция, как компилятор такое соптимизирует не проверяли?


        1. T-D-K Автор
          23.12.2018 22:00

          В общем случае функция будет вычислять на каждой итерации цикла. SZ-массивы в .NET это очень нативная вещь и под неё сделано очень много оптимизаций. Для остального такие оптимизации в общем случае могут быть не сделаны. Подробности можно узнать только дизассемблировав код метода после JIT компиляции конкретным JIT.
          Ради интереса. Вот код: pastebin.com/2fhb1fUZ, вот asm (LegacyJit x86): pastebin.com/8uSmwitv
          Метод вызывается каждый раз, хотя возвращает каждый раз одного и тоже значение.


          1. severgun
            24.12.2018 10:05

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


            1. poxvuibr
              24.12.2018 10:52

              Тогда какой смысл не создавать переменную?

              Чтобы не было лишней строчки в идиоматичном коде.


              Быстрее код не станет, но и медленнее тоже. Скорость написания кода тоже сомнительный аргумент.

              Обход массива — идиоматичный код, лучше, если он у всех выглядит одинаково.


              Зато если всегда писать, то это будет делаться машинально и будет хоть какая то однородность в стиле.

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


            1. LoadRunner
              24.12.2018 11:32

              А если размер массива меняется внутри цикла от шага к шагу?


              1. poxvuibr
                24.12.2018 12:45

                А если размер массива меняется внутри цикла от шага к шагу?

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


                1. LoadRunner
                  24.12.2018 12:54

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


                  1. poxvuibr
                    24.12.2018 12:59

                    Например, при кодовой генерации интерфейса, когда надо удалить те контролы, которые проходят по условию.

                    Если из листа надо только удалить элементы, то лучше просто вернуть новый лист, где удалённых элементов нет.


                    1. LoadRunner
                      24.12.2018 13:13

                      Эм… А если работа напрямую со списком контролов, который предоставляет родительский контрол? А на дочерние контролы могут и события вешаться, и прочая шелуха WinForms.


                      1. poxvuibr
                        24.12.2018 13:55

                        А если работа напрямую со списком контролов, который предоставляет родительский контрол?

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


                      1. PsyHaSTe
                        24.12.2018 13:58

                        var controlsToUpdate = Controls.Where(SomeCondition).ToArray()


              1. PsyHaSTe
                24.12.2018 13:52

                Размер массива не может меняться. Это его ключевое свойство, в общем-то.


      1. LmTinyToon
        23.12.2018 22:44

        Строго говоря вы доказали это лишь для одной структуры данных. Я бы побоялся делать такие выводы на одном примере. А если работа идёт с какой-то коллекцией где логика подсчета длины не совсем константая по сложности? Слишком тонкая тема. Думаю серебряной пули тут нет. Разве что только для встроенных примитивных типов


    1. kalininmr
      23.12.2018 23:41

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


  1. Greendq
    23.12.2018 16:44
    +2

    Хмм, на заре дотнета foreach был намного тормознутее простого for-а. Интересный прогресс :)


    1. T-D-K Автор
      23.12.2018 16:50
      +3

      И сейчас есть разница в производительности.
      Если написать:

      void SomeMethod(int[] array) {
         foreach(var i in array) {
         }
      }

      то JIT это развернёт в обычный цикл со счётчиком, а если сделать:
      void SomeMethod(IEnumerable<int> array) {
         foreach(var i in array) {
         }
      }

      то тут уже будут все «прелести» foreach — получение енумератора, MoveNext и т.д. И вот этот код уже будет тормозить даже если передать туда массив интов.


      1. ialexander
        23.12.2018 18:11

        Думаю, во втором случае речь должна идти о «прелестях» не foreach, а IEnumerabe/IEnumerator.
        Выходит, что foreach научился оптимизировать циклы, используя дополнительную функциональность, к примеру, IList.
        И получается, что с точки зрения производительности, выгоднее не генерализировать код, передавая минимально допустимый интерфейс вроде IEnumerable, а передавать ILIst, давая возможность компилятору оптимизировать код.


        1. T-D-K Автор
          23.12.2018 18:14

          Можно написать много кастований к разным интерфейсам и в зависимости от успеха вызывать уже метод, оптимизированный пот конкретную коллекцию.
          Примерно также сделали вот здесь: github.com/dotnet/corefx/blob/master/src/System.Linq/src/System/Linq/Count.cs
          Но мне кажется, что если приходится в реальном проекте делать такую оптимизацию, то скорее всего что-то сильно раньше пошло не так.


          1. a-tk
            23.12.2018 18:51

            Все эти касты тоже далеко не дешёвые. Если коллекция короткая, то накладные расходы на касты будут приносить больше вреда чем пользы.
            Жаль в C# нету перегрузок методов по constraint-ам на generic-параметры…


            1. BlackOne
              24.12.2018 01:52

              Наиболее правильным вариантом тут будет использовать generic типы с необходимыми вам constraint-ами. В этом случае runtime проводит дополнительные оптимизaции используя информацию о типе (например переданные тип может быть struct … или же sealed … или идин из немногих типов от которых наследование возможно, но наследников фактически нет (на счет этой оптимизации не уверен)). Такой код будет значительно быстрее, но к сожалению «duck typing» к-рый используется в foreach конструкциях не будет задействован (а ведь именно он даёт такой прирост производительности в сравнении с for циклами)


              1. a-tk
                24.12.2018 13:11

                Так я ж о чём и говорю…
                Но нельзя сделать перегрузки вида

                void Smth<T, X>(this T obj) where T : IList<X>;
                void Smth<T, X>(this T obj) where T : IEnumerable<X>;
                

                Generic-методы дают максимальный выигрыш в скорости только тогда, когда не происходит приведений типов. Получение интерфейсной ссылки и вызов методов через неё обходится дороже чем прямой вызов метода, даже через generic с constraint-ом.

                Кроме того, код с кастом выполняется в runtime и способен обнаруживать совместимые конкретизации типов, когда объект передан обобщённо в виде интерфейсной ссылки или приведением к базовому типу.

                PS: На хабре отвалилась подсветка C# из списка языков?


        1. ICELedyanoj
          23.12.2018 23:14
          +2

          Зачем IList? Передача изменяемых списков в качестве аргументов — попахивает.
          Я в последнее время всё, где можно — на IReadonlyCollection переделываю. List, Array и некоторые другие списки её умеют из коробки, соответственно переделываем только сигнатуру, а вызовы можно оставить как есть.
          Исключение делается только в одном случае — если состав коллекции действительно IEnumerable, например это результат асинхронного выполнения запроса к базе или результат рекурсивной загрузки дерева файлов с диска, когда мы не можем получить весь результат одним куском и не знаем конечного размера массива элементов.
          Во всех остальных случаях IReadonlyCollection — то, что доктор прописал.
          Только одна претензия — слишком длинное название интерфейса, но здесь уж ничего не изменишь.
          И главное — чистота кода. IReadonly сам собой подразумевает, что список передаётся в метод только для чтения, и не будет использоваться для неявного пополнения внутри метода.


          1. poxvuibr
            24.12.2018 10:56

            Зачем IList? Передача изменяемых списков в качестве аргументов — попахивает.
            Я в последнее время всё, где можно — на IReadonlyCollection переделываю.

            IReadonlyCollection даёт произвольный доступ к элементам? Если нет, то вот и причина передавать IList.


            1. mayorovp
              24.12.2018 11:11
              +1

              Произвольный доступ к элементам дает IReadonlyList


            1. ICELedyanoj
              24.12.2018 11:31

              IList? Например Array реализует IList, но бросает NotSupportedException при попытке вызвать методы, изменяющие коллекцию.
              Если реализация метода, принимающая IList закрыта от вызывающей стороны интерфейсом, то вызывающая сторона не может быть уверена, что в такой метод можно передать Array — вдруг реализация начнёт менять коллекцию?
              Если вам нужен доступ к элементам по индексу — используйте IReadonlyList, но, как по мне, это тоже попахивает какими-то костылями.
              Просто вопрос — зачем? Если, например, методу нужен какой-то конкретный элемент массива, это означает, что метод слишком много знает об организации коллекции. Вместо этого метод может принимать нужный элемент, вместо коллекции.
              В общем принимать коллекции с доступом по индексу — это должна быть какая-то очень специфическая необходимость.


              1. poxvuibr
                24.12.2018 12:56

                IList? Например Array реализует IList, но бросает NotSupportedException при попытке вызвать методы, изменяющие коллекцию.

                Это у меня из-за недостатка знаний про .net.


                Если вам нужен доступ к элементам по индексу — используйте IReadonlyList, но, как по мне, это тоже попахивает какими-то костылями.

                Ага, спасибо.


                В общем принимать коллекции с доступом по индексу — это должна быть какая-то очень специфическая необходимость.

                Есть ещё такое соображение, что в коллекцию можно передать что угодно, в том числе LinkedList. Возможно вы хотите запретить его использование на уровне API.


                1. ICELedyanoj
                  24.12.2018 14:49

                  LinkedList — сам по себе очень специфичный вид коллекции. Даже приведу цитату из хабракоммента из статьи по LinkedList:

                  Согласен, что очень специфичный и редко используемый контейнер.

                  Я же говорю об остальных 99 случаях из 100 — IReadOnlyCollection будет предпочтительнее — с ним интерфейсы становятся чище и понятнее.


                  1. poxvuibr
                    24.12.2018 15:14

                    Я же говорю об остальных 99 случаях из 100 — IReadOnlyCollection будет предпочтительнее — с ним интерфейсы становятся чище и понятнее.

                    Если LinkedList используется редко, то в 99 случаях из 100 можно рассчитывать, что его в метод не передадут. Следовательно в этих 99 случаях из 100 можно сделать явное ограничение в виде IReadOnlyList.


                    IReadOnlyCollection — явно указывает, что можно передать спокойно передавать LinkedList и так и надо. В 99 случаях из 100 так не надо, поэтому я считаю, что лучше это выразить явно.


                    1. ICELedyanoj
                      24.12.2018 17:07
                      +1

                      IReadOnlyCollection — явно указывает, что можно передать спокойно передавать LinkedList и так и надо. В 99 случаях из 100 так не надо, поэтому я считаю, что лучше это выразить явно.

                      А что в этом плохого? Вызывающая сторона будет обращаться с LinkedList так же, как и с любой другой коллекцией. Зачем вызывающей стороне IReadOnlyList, который отличается от IReadonlyCollection исключительно геттером по индексу элемента?
                      Наоборот — ожидая IReadonlyList метод указывает вызывающей стороне, что он собирается вызывать Collection[index], иначе он ожидал бы базовый интерфейс IReadonlyCollection.
                      Если методу нужна исключительно энумерация входящих в коллекцию элементов — то LinkedList ничем не хуже любой другой коллекции, и ограничение не имеет смысла.


                      1. poxvuibr
                        24.12.2018 17:42

                        Зачем вызывающей стороне IReadOnlyList, который отличается от IReadonlyCollection исключительно геттером по индексу элемента?

                        Чтобы нам не передали LinkedList.


                        Наоборот — ожидая IReadonlyList метод указывает вызывающей стороне, что он собирается вызывать Collection[index], иначе он ожидал бы базовый интерфейс IReadonlyCollection.

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


                        Если методу нужна исключительно энумерация входящих в коллекцию элементов — то LinkedList ничем не хуже любой другой коллекции

                        LinkedList жрёт память, убивает перформанс, не даёт элементам коллекции ложиться в кеш и так далее.


                        1. a-tk
                          24.12.2018 22:07
                          +1

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


                          1. poxvuibr
                            25.12.2018 01:29
                            -1

                            Как и любой контейнер, двусвязный список предназначен для решения специализированных задач.

                            Вот когда появляются такие задачи и надо использовать LinkedList.


                            У него быстрая вставка и удаление по итератору.

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


                            Например, он лучше других будет подходить как контейнер для MRU List

                            И что делает LinkedList лучше других подходящим на роль MRU List? Чем он лучше ArrayList? Почему там нельзя использовать банальный массив фиксированного размера?


                            1. mayorovp
                              25.12.2018 06:32

                              И что делает LinkedList лучше других подходящим на роль MRU List? Чем он лучше ArrayList? Почему там нельзя использовать банальный массив фиксированного размера?

                              Потому что в массиве фиксированного размера операция "взять произвольный элемент и переставить в начало" выполняется за ?(N)


                              1. poxvuibr
                                25.12.2018 11:26

                                Потому что в массиве фиксированного размера операция "взять произвольный элемент и переставить в начало" выполняется за ?(N)

                                Для того, чтобы переставить в начало произвольный элемент в двусвязном списке нужно:


                                1. Вписать в prev первого элемента ссылку наш элемент
                                2. Вписать в prev нашего элемента null
                                3. Вписать в next нашего элемента ссылку на бывший первый элемент
                                4. Вписать в next предущего элемента ссылку на следующий элемент
                                5. Вписать в prev следующего элемента ссылку на предыдущий элемент

                                Итого 5 действий. То есть, пока у нас в листе 5 элементов и менее, то массив лучше двусвязного списка, тут даже обсуждать нечего.


                                Но сдвиг в массиве это не просто N операций, это N операций, операнды которых хорошо легли в кеш, а значит они сильно быстрее, чем операции с Linked List.


                                Кроме того, сдвиг в массиве оптимизируется на низком уровне, а значит — он ещё быстрее.


                                Поэтому количество элементов, при котором массив — лучший инструмент для решения нашей задачи существенно больше пяти.


                                MRU List это такая штука, которая ограничена в размерах каким-то небольшим числом и поэтому массив тут подойдёт лучше, чем LinkedList, если речь только о времени, затрачиваемом на то, чтобы переместить в начало произвольный элемент.


                                1. mayorovp
                                  25.12.2018 11:47

                                  Э… а почему вы считаете что MRU List должен быть ограничен каким-то небольшим числом?


                                  1. poxvuibr
                                    25.12.2018 12:05

                                    Э… а почему вы считаете что MRU List должен быть ограничен каким-то небольшим числом?

                                    Потому что он нужен для того, чтобы вернуться к файлу или проекту или ещё какой-то сущности с которой вы либо недавно работали, либо работатете постоянно. Хранить там больше какого-то небольшого количества элементов не имеет смысла, если надо найти что-то что вы использовали давно — если другие инструменты.


                                1. a-tk
                                  25.12.2018 15:49

                                  Обновление ссылки — это обновление одного машинного слова в памяти.
                                  Перемещение элемента целиком — зависит от размера элемента, но редко бывает элемент размером в 1 машинное слово. Альтернатива — сами элементы хранятся в одном контейнере, а индексы для доступа — в другом. Но такие структуры не кэш-френдли. Ну и напоследок: большие структуры, да ещё хранящие ссылки, например, на строки — тоже не слишком кэш-френдли.


                                  1. poxvuibr
                                    25.12.2018 18:07

                                    Вы точно в ответ на мой комментарий это написали? Если да, то я не понял, что вы хотели сказать.


                        1. mayorovp
                          25.12.2018 06:30
                          +1

                          А что страшного случится если вам передадут LinkedList? Почему бы не предположить что тот, кто создает LinkedList и передает его нам, знает что делает?


                        1. ICELedyanoj
                          25.12.2018 07:13

                          Это какое-то новое слово в архитектуре интерфейсов — специально ограничивать API, чтобы запретить использование ненавистного типа.
                          Представим себе ситуацию. Мы пишем API, в котором нужен метод вычисления какой-нибудь математической функции из входящей коллекции Int. Единственным условием для успешности вычисления этой функции является конечность коллекции.
                          Вы действительно предлагаете запретить использование всех базовых коллекций, не реализующих IReadonlyList, только для того, чтобы не убить перформанс?
                          Чей перформанс? Перформансом LinkedList должен озаботиться в первую очередь тот, кто нам его передаёт. Если пользователя API устраивает перформанс LinkedList, и ему нужна именно такая коллекция? Или даже она досталась ему по наследству из другого API. Что же ему теперь — перед каждым вызовом нашего метода API вызывать .ToArray() на своём списке?
                          Кроме того, запрещая использование IReadonlyCollection, помимо LinkedList вы теряете и другие базовые коллекции — например Stack или Queue. Да даже Dictionary.
                          Попахивает каким-то рассизмом по отношению к типам.


                          1. a-tk
                            25.12.2018 15:51

                            Интерфейсы вообще-то по-хорошему нужны для того, чтобы заявлять, какой функционал от коллекции нужен алгоритму, который Вы реализуете.
                            Если коллекция (любая) его удовлетворяет — окей, пользователь вашего API лучше знает, что ему надо на самом деле.


      1. a-tk
        23.12.2018 18:45

        При таком подходе надо передавать как минимум

        IList<int>


      1. dmitry_dvm
        23.12.2018 21:11
        +1

        Тормознутость какого порядка? Микросекунды на миллион итераций или хуже?
        Сам не особо парюсь такими оптимизация и использую везде IEnunerable<> и List<>, потому что любой запрос к соседнему микросервису отработает в сотни и тысячи раз дольше.


        1. T-D-K Автор
          23.12.2018 21:37
          +3

          Такими оптимизациями и не стоит заниматься, пока именно в них не упирается производительность.
          Но для интереса я накидал бенчмарк. Вот сорец: gist.github.com/tdkkdt/181e3f1e2ee6bb1ce1662bfae1241545
          Вот результаты:
          gist.github.com/tdkkdt/c3250b9c4d25f13a486cb435870bd7aa
          foreach на IEnumerable в среднем в 10 раз медленнее чем foreach на массиве.


          1. dmitry_dvm
            24.12.2018 00:12

            Спасибо, очень наглядно.


  1. DrPass
    23.12.2018 17:50

    Я решил раз и навсегда для себя понять стоит так делать или можно сэкономить своё время и написать без временной переменной.

    Можно было и не тратить время на проверку. То, что условие цикла for вычисляется один раз перед входом в цикл, написано в документации на C#. Собственно, точно так же этот цикл ведет себя и в других языках.


    1. T-D-K Автор
      23.12.2018 18:08

      А можно ссылку на документацию? Интересно почитать подробнее.

      условие цикла for вычисляется один раз перед входом в цикл

      Странно сформулировано. Условие выхода из цикла в общем случае точно вычисляется на каждой итерации.


      1. VezhLos
        23.12.2018 18:23

        Вот здесь


        1. VezhLos
          23.12.2018 18:24

          1. T-D-K Автор
            23.12.2018 18:27

            «The condition section, if present, must be a boolean expression. That expression is evaluated before every loop iteration.» По этой логике каждый раз вызывается get_Length у массива на каждой итерации.


            1. VezhLos
              23.12.2018 18:31

              Да, простите, не в тот пост ответил, надо было в предыдущий


        1. VezhLos
          23.12.2018 18:28

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


          1. T-D-K Автор
            23.12.2018 18:30
            +1

            Об этом и статья. Чтобы знать наверняка надо либо прочитать код компилятора, либо провести эксперименты. Я их провёл и теперь знаю.


            1. VezhLos
              23.12.2018 18:35
              +1

              Этот вопрос с циклами, кстати, и меня много раз интересовал, так что спасибо, прояснили этот момент.


      1. DrPass
        23.12.2018 20:01

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


    1. 1andy
      23.12.2018 18:13

      Насмешили.
      «Условие», а точнее булевое выражение в for вычисляется каждый раз (если оно есть).


    1. Oxoron
      23.12.2018 18:53

      Ок, поставим эксперимент

      Заголовок спойлера
      public class LimitWithCounter
          {
              private static readonly Random Rnd = new Random();
              public int CurrentLimit = 2;
              public int Limit()
              {
                  CurrentLimit = Rnd.Next(15) + 5 ;
                  Console.Write(CurrentLimit);
                  return CurrentLimit;
              }
          }
      
      ...
      
      static void Main(string[] args)
              {
                  var limit = new LimitWithCounter();
                  limit.Limit();      Console.WriteLine($"Limit {limit.CurrentLimit}");
                  limit.Limit();      Console.WriteLine($"Limit {limit.CurrentLimit}");
                  limit.Limit();      Console.WriteLine($"Limit {limit.CurrentLimit}");
                  Console.WriteLine();
      
                  for(int i = 0; i < limit.Limit(); i++)
                  {
                      Console.WriteLine($"\tStep: {i}");                
                  }
      
                  Console.WriteLine();
                  Console.ReadLine();
                  return;
      }
      


      1. ICELedyanoj
        24.12.2018 08:11

        Вы невнимательно прочли статью. Автор даже результаты Jit-компиляции анализировал.
        Очевидно, что компилятор достаточно умный, чтобы отличить свойство Length стандартного Array от метода самописного класса. В первом случае Length измениться не может, и компилятор генерирует код, кеширующий длину массива перед циклом. Во втором случае генерируется код, вызывающий метод на каждом цикле.


        1. Oxoron
          24.12.2018 13:49

          То, что условие цикла for вычисляется один раз перед входом в цикл, написано в документации на C#.
          Я комментировал не сам пост, а комментарий к нему.

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

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

          Просто чтобы продемонстрировать опасность слов «очевино» и «компилятор может» в одном предложении.

          Чуток кода
                      var arr5 = new int[5];
                      var arr10 = new int[10];
                      var loopArr = arr5;
          
                      for(int i =0; i<loopArr.Length; i++)
                      {
                          loopArr = arr10;
                          Console.WriteLine($"\tStep: {i}");
                      }
          
          
                      Console.WriteLine();
                      Console.ReadLine();
                      return;
          


          Собственно, у меня выводится Step: 0… Step: 9. Всего 10 значений, хоть я и использовал переменную типа Array, изначально указывающую на массив из 5 элементов. Вывод: длина массива как минимум, в некоторых случаях, не кешируется при использовании в цикле for.


          1. TheDaemon
            24.12.2018 17:50
            +1

            При реюзе (переприсваивании) переменных начинает играть такая штука как SSA
            Т.е. для компилятора это уже две разные переменные, да еще и связанные через Фи-ноду. Но это никак не умаляет ваших аргументов.


    1. bro-dev0
      24.12.2018 00:50

      В js кэширование длины массива точно дает выигрыш, по крайней мере в V8, где-то 5-50% может дать.


      1. Drag13
        24.12.2018 10:33

        Зависит от того что за массив.

        Вот последние замеры на Chrome
        Не кешированый: 3,638
        Кешированый: 3,628
        Разница в пределах погрешности.

        А вот на EDGE
        Не кешированый: 64,482
        Кешированый: 67,909
        Разница в 6%

        Это если речь про обычный массив с тестом аналогичным авторскому.


        1. bro-dev0
          24.12.2018 20:09

          Как вы мерили, я просто открыл готовый тест jsben.ch/qTUpg ну и раньше эта тема уже обсасывалась на хабре с теми же результатами.


          1. Drag13
            25.12.2018 00:03

            jsperf.com/length-to-const
            Там есть полезная нагрузка в виде суммы, так что может она нивелирует. Надо смотреть во что оно скомпилируется.


  1. a-tk
    23.12.2018 18:49

    На C++ есть был такой вот паттерн написания циклов до того, как появились нормальный range for:

    for (
      Container::const_iterator it = container.begin(), end = container.end(); 
      it != end; 
      ++it)
    {
      // do something
    }
    

    Ваши разработчики случаем раньше на С++ не писали?


    1. T-D-K Автор
      23.12.2018 18:54

      На чём они только не писали. Опыта у них точно много.


    1. robert_ayrapetyan
      23.12.2018 19:58

      А это реально быстрее it != container.end()?


      1. a-tk
        23.12.2018 21:43

        Зависит от контейнера. Где-то выигрыш будет, где-то нет. Но проигрыша точно не будет нигде.


  1. dmitryb-dev
    23.12.2018 19:34
    +1

    Вообще не понимаю такой экономии «на спичках» (и речь не только о вышеописанном случае). Всегда в голове проносится вопрос: «А что, остальной ваш код настолько идеален и оптимизирован, что вот осталось только вот тут сэкономить на вызове гетера?».
    На мой взгляд любая оптимизация, которая ухудшает читабельность кода, должна быть чем-то аргументирована, если не замерами и профайлерами, то хотя бы мыслью «мне кажется эта функция будет вызваться очень часто, надо бы написать ее чуть поаккуратнее». А не «оптимизирую, потому что могу».


    1. iproger
      23.12.2018 22:55
      +1

      Ну вот в js ваш посыл не работает. Вряд ли сильно ошибусь если скажу что цикл с работой с dom в 10 раз медленнее чем без.


      1. Drag13
        23.12.2018 23:44

        del


    1. bro-dev0
      24.12.2018 00:45
      +1

      Как раз такие не спички, когда вы обходите 1ккк итераций даже 1% выигрыша уже будет заметен.


      1. alexstup
        24.12.2018 08:45

        Это типа как ждать 5 месяцев или 5 месяцев и 5 дней?


        1. MahmudS
          24.12.2018 15:39

          5 дней в отношении к 5 месяцев — это 3%


    1. aensidhe
      24.12.2018 09:45

      Да, иногда наш остальной код оптимизирован. А иногда метод, в котором вы оптимизируете, вызывается миллионы или миллиарды раз в час. Ну и там получается нормально.


  1. WRONGWAY4YOU
    23.12.2018 22:59

    Да, конечно, в указанных в статье случаях компилятор это делает за вас. Но, однако, бывают и более нетривиальные случаи. Правда?


    Должен ли компилятор делать такие оптимизации в данной функции, например?


    void whatever(char* s, char* a) {
      int k = 0;
      for (int i = 0; i < strlen(s); ++i) {
        for (int j = 0; j < strlen(s); ++j) {
          if (s[i] == s[j]) {
            a[k++] = s[i]; // Modifying "a"
          }
        }
      }
    }

    А вот нет: указатель a может ссылаться на регион в памяти, принадлежащей s. В процессе исполнения этого цикла мы модифицируем a, и, следовательно, можем изменить расположение '\0\' в s, которое, в свою очередь, может поменять результат исполнения выражения strlen(s), из-за чего компилятор, конечно, оптимизировать этот код не будет.


    Не нужно постоянно полагаться на компилятор, т.к.


    Compiler is a tool, not a magic stick.


    1. T-D-K Автор
      23.12.2018 23:00
      +1

      У нас в .net всё проще. :) есть конечно fixed, unsafe и прочий unmanaged, но это очень не приветствуется.


      1. aensidhe
        24.12.2018 10:07

        Это не так.
        Во-первых, это приветствуется прятать во всякие методы и классы отдельные.
        Во-вторых, есть ref/Span, которые полностью безопасны.


        1. mayorovp
          24.12.2018 10:10

          Вот именно, они безопасны, и там нет никаких strlen. Длина всегда хранится отдельно и не может внезапно измениться.


          1. a-tk
            24.12.2018 13:17

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


      1. a-tk
        24.12.2018 13:16

        Смотря у кого не приветствуется и смотря где.
        Не приветствоваться должны некорректные обобщения.


  1. kuber
    23.12.2018 23:04
    +1

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


    1. easyman
      23.12.2018 23:59
      +1

      И тут выясняется, что не хватает сравнения с новым типом Index из C# 8 :)
      https://blogs.msdn.microsoft.com/dotnet/2018/11/12/building-c-8-0/


      1. T-D-K Автор
        24.12.2018 00:04

        А ещё можно сравнить в RyuJit, LegacyJit x64, Mono и т.д. Увы, этот вопрос возник у меня только для рабочих проектов, потому я и проверял только то, что доступно на работе. Новый .net даёт всё больше возможностей. Сейчас изучаю SIMD в .netcore 3.0 (готовлю статью) и вот там уже можно разгуляться во всю. Правда в домашних условиях применять это некуда.


  1. iga2iga
    24.12.2018 00:52

    Геттер у Array.Length? Серьезно? Ну по дизасму же видно, что нет там никакого геттера, это обычная переменная, хранящая длину массива, точно такая же как и у строк и у кучи всего остального и не только в C#…


    1. tsul
      24.12.2018 01:12

      Конечно серьёзно. Это же property. Скомпилируйте в Debug, будет честный вызов геттера.


      1. iga2iga
        24.12.2018 20:23

        Ну так и надо было это отобразить в статье, т.е. показать дизасм дебаг версии. Потому что даже в моём мире Delphi, даже в дебаг версии без оптимизаций, даже при явном указании «якобы функции» len := length(array) нет никаких call в дизасме, регистр напрямую читает длину массива из памяти. Было бы очень странно и как-то даже глупо, если бы в C# было бы по другому. Ну а с геттером — это вообще рукалицо. В смысле прям с геттером, который вызывает целую функцию (уж не спец в C#), а не просто возвращает длину массива.


        1. ICELedyanoj
          24.12.2018 21:58

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


          1. mayorovp
            24.12.2018 22:06

            А в чем смысл «гулять» внутри кода стандартной библиотеки?


            1. a-tk
              24.12.2018 22:09
              +2

              Не боги горшки обжигают. Стандартную библиотеку пишут люди (C++ STL не в счёт), им тоже надо иметь возможность отладки своего творения.


              1. mayorovp
                25.12.2018 06:20

                Хорошо, уточню вопрос: в чем смысл разработчику прикладной программы «гулять» внутри кода стандартной библиотеки?


                1. a-tk
                  25.12.2018 15:54

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


                  1. mayorovp
                    25.12.2018 16:08

                    Так я именно об этом и говорю! Незачем мне туда лезть. А значит, отладчик в пошаговом режиме и не должен заходить внутрь, это совершенно корректное поведение.


          1. iga2iga
            24.12.2018 22:55

            В смысле не в пользу компилятора Delphi?! Тут и компилятор-то не при делах… Это ж как бы логично, что если имеем некий массив, уж без разницы как он реализован, на уровне компилятора (языка), на уровне рукописного класса или класса стандартной библиотеки, то там должна быть (даже обязана быть) переменная 1 штука, которая всегда хранит текущую длину этого массива. При увеличении количества элементов массива на N, увеличиваем переменную длины массива на N. То же самое при уменьшении. Зачем это проходить отладчиком? Строки обязаны быть реализованы точно таким же образом. Я, собственно, именно по этому и был удивлен геттеру получения длины массива, когда вот просто возьми и прочитай эту самую переменную из памяти в регистр. Её адрес или смещение и всё прочее заранее известны компилятору.


            1. a-tk
              24.12.2018 23:56
              +1

              В С++ ни в старом варианте — [], ни в новом array<T, N> не хранится переменная с длиной. В первом случае это просто указатель, во втором — constexpr-метод.


              1. iga2iga
                25.12.2018 04:10

                Она явно не хранится, она создается, когда есть ключевое слово array или string (Delphi). Для плюсов, да, там все прям квадратно и перпендикулярно: sizeof(array)/sizeof(array[0]), хотя вот в векторе скорее всего как в дельфи. Впрочем оставим неизлечимо больного в покое (это я про с++)… Ну а уж в C#-то должно же быть как в дельфи хотя бы… разве нет?
                На счет «скрытых» элементов массива не скажу, а вот строки дельфи, например, содержат такие элементы как CodePage, ElemSize, RefCount, ну и наш length. У массивов всё точно так же, за минусом CodePage. Они хранятся по отрицательному смещению от первого элемента строки/массива.
                зы Мне кажется я сейчас ещё чуточку больше полюбил Delphi :)


            1. mayorovp
              25.12.2018 06:27

              Вы забываете, что в C# компиляция идет не сразу в машинный код, а сначала в байт-код. А потому для определения длины массива есть лишь два варианта — или придумывать отдельную команду IL именно для этой цели, или просто вызвать метод из стандартной библиотеки. Создатели .NET решили пойти по второму пути для упрощения спецификации.


              А вот JIT уже никто не мешает вызов известного ему метода get_Length() заменить простое на чтение переменной из памяти.


    1. T-D-K Автор
      24.12.2018 01:19

      Я не специалист по исходникам .NET. Но копание в .netcore 3.0 показало, что при вызове Array.Length выполняется вот этот «код»:

      FCIMPL2(INT32, ArrayNative::GetLength, ArrayBase* array, unsigned int dimension)
      {
          FCALL_CONTRACT;
      
          VALIDATEOBJECT(array);
      
          if (array==NULL)
              FCThrow(kNullReferenceException);
          
          if (dimension != 0)
          {
              // Check the dimension is within our rank
              unsigned int rank = array->GetRank();
              if (dimension >= rank)
                  FCThrow(kIndexOutOfRangeException);
          }
          
          return array->GetBoundsPtr()[dimension];
      }
      FCIMPLEND


      1. panteleymonov
        24.12.2018 01:53

        unsigned int rank = array->GetRank();
        if (dimension >= rank)

        Забавно, тут тоже можно было бы спросить зачем выносить в отдельную переменную.


        1. a-tk
          24.12.2018 13:19

          Эта переменная всё равно осядет в регистр в релизном коде и будет вести себя точно так же, как если бы её не было, потому что используется ровно 1 раз. Зато отлаживать такой код гораздо удобнее если что-то пойдёт не так.


          1. panteleymonov
            24.12.2018 16:06

            Спасибо кеп. Чуть ниже я именно об этом написал, если игнорировать саму статью и множество подобных комментариев под ней.


  1. panteleymonov
    24.12.2018 01:43

    Если покопаться в памяти и вспомнить почему выносят «var length», то это не только какая то мнимая оптимизация. Что то вроде правила хорошего тона «ходить с завязанными шнурками» вместо «сунул и пошел». Как приводился пример выше, со вложенными циклами, такой подход «защищает» от проблемы лишнего вызова. То есть когда у многих задач одно решение, то оно превращается в привычку и получается что вроде как ляпается повсюду. А с другой стороны еще и помогает избавиться от длинных строк кода, если есть такие предпочтения. Предпосылок конечно больше у тех кто работал с С++ и С еще с 90-x. Но на таком простом коде это не наглядно даже для старых компиляторов. И логика в некоторых случаях могла меняться по причине большого объема кода, вплоть до запарывания стека самим компилятором.

    В общем если есть много времени на то чтобы лишний раз просмотреть как это откомпилировалось то заморачиваться стоит. Но куда проще добавить этот самый «var length» аналог и не нарушать феншуй гармонию кода.


    1. Kiel
      24.12.2018 08:31
      +1

      Просто используйте foreach, пожалуйста, он для этого и придуман.


      1. panteleymonov
        24.12.2018 16:11

        Перееду из 90-x, c C++ на С# и сразу начну.


  1. shibaev
    24.12.2018 06:56

    Есть еще один аргумент в пользу использования Array.Length напрямую, без доп. переменных. С включенными оптимизациями JIT компиляторы умеют избавляться от array bounds check при доступе к элементам массива внутри цикла. С доп. переменной такие оптимизации могут не работать:
    blogs.msdn.microsoft.com/clrcodegeneration/2009/08/13/array-bounds-check-elimination-in-the-clr
    stackoverflow.com/questions/16713076/array-bounds-check-efficiency-in-net-4-and-above

    В ваших примерах bounds check присутствуют:
    cmp esi, eax
    jae 056e2d31


    Возможно, это ограничение LegacyJIT x86. Либо код запускался без оптимизаций. RyuJIT x64 должен быть умнее (https://stackoverflow.com/a/17138483/136138).


    1. ideatum
      24.12.2018 09:27

      Вы удивитесь, но проверка границ массива практически не влияет на производительность. Современные процессоры умеют делать это очень эффективно, так что время доступа к элементу массива с проверкой границ увеличивается всего на (0.881 ± 0.009) %
      Более подробно здесь


      1. panteleymonov
        24.12.2018 11:47

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


      1. aensidhe
        24.12.2018 12:43

        Вы удивитесь, но влияет и очень сильно.


      1. shibaev
        24.12.2018 14:14

        Не удивлюсь, т.к. про это написано по ссылке со StackOverflow, которую я привел в своем сообщении (https://stackoverflow.com/a/16719474/136138)

        На практике на моем i7-4200HQ разница существенна для RyuJIT x64. Я взял benchmark gist.github.com/aensidhe/0d412e142eb29fd21eea01b5f6462d41 и сравнил For() и ForReverse(). В первом случае RyuJIT убирает bounds check, во втором — оставляет. И у меня на машине получаются такие результаты:

        BenchmarkDotNet=v0.11.3, OS=Windows 10.0.17134.472 (1803/April2018Update/Redstone4)
        Intel Core i7-4720HQ CPU 2.60GHz (Haswell), 1 CPU, 8 logical and 4 physical cores
        Frequency=2533210 Hz, Resolution=394.7561 ns, Timer=TSC
          [Host]        : .NET Framework 4.7.2 (CLR 4.0.30319.42000), 32bit LegacyJIT-v4.7.3260.0
          Clr LegacyJit : .NET Framework 4.7.2 (CLR 4.0.30319.42000), 64bit LegacyJIT/clrjit-v4.7.3260.0;compatjit-v4.7.3260.0
          Clr RyuJit    : .NET Framework 4.7.2 (CLR 4.0.30319.42000), 64bit RyuJIT-v4.7.3260.0
        
        Platform=X64  Runtime=Clr
        
             Method |           Job |       Jit |     Mean |    Error |   StdDev |
        ----------- |-------------- |---------- |---------:|---------:|---------:|
                For | Clr LegacyJit | LegacyJit | 724.0 ns | 1.945 ns | 1.819 ns |
         ForReverse | Clr LegacyJit | LegacyJit | 725.5 ns | 9.341 ns | 7.800 ns |
                For |    Clr RyuJit |    RyuJit | 583.3 ns | 2.706 ns | 2.259 ns |
         ForReverse |    Clr RyuJit |    RyuJit | 732.2 ns | 2.568 ns | 2.402 ns |


        1. aensidhe
          24.12.2018 15:50

          Это странно. Запустите ещё пару раз. В релизе. Из командной строки. И не используйте при этом компьютер.


  1. fukkit
    24.12.2018 08:05
    +1

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


    1. RegisterWindowClassExA
      24.12.2018 08:54

      Плюсую. Эта статья скорее о том, что неявная оптимизация может сильно подгадить. И вообще не понятно, для каких нубов нужна такая «неявная оптимизация». Я не пишу на C#, но например, если в теле цикла меняется размер массива, или если массив может меняться из другого потока, то «оптимизированная» компилятором функция может вести себя не так, как ожидает программист. Т.к. в условии цикла, по сути, будет проверяться не текущий размер массива, а размер массива на момент входа в цикл. Хорошо, если массив уменьшается, тогда, возможно, Вы получите исключение типа «выход за пределы массива», и будете долго врубаться, в чем собственно дело. А если массив увеличивается, а Ваш цикл (поток?) проходит не по всем его элементам, а только по части, ошибки будут очень неявными, и искать их будет на порядок труднее…


      1. Ogra
        24.12.2018 09:18
        +1

        Компилятор не будет оптимизировать, если он не уверен, что размер не меняется. Если в теле цикла будет что-то вроде такого
        for (int i=0; i<array.getLength(); i++) {
        if (something) array.nonConstMethod();
        }

        то оптимизации не произойдет и обращение к array.length() будет происходить при каждой итерации.
        Собственно, иногда и нужно сохранять размер контейнера в локальную переменную — когда компилятор не может быть уверенным, а программист может.


        1. RegisterWindowClassExA
          24.12.2018 09:25

          Мне тоже подумалось про «если будет в теле цикла», но вот то, что Вы написали — это Ваше 100%-ное знание или просто идеализация компиляторов? А если будет поток? Тоже компилятор будет анализировать, используется ли функция в потоке, или меняется ли массив другими потоками? Теоретически это возможно, практически это тоже и возможно, и скорее всего так и есть, НО… Всё-таки я привык к тому, что «машина делает ровно то, что Вы написали, а не то, что Вы имели в виду»… Вот мой последний проект, например, работает нормально только при отключенной оптимизации. И вообще у нас так повелось, что если программа сбоит, попробуй отключить оптимизацию.


          1. mayorovp
            24.12.2018 10:00

            Это общий принцип любой оптимизации — не менять наблюдаемого поведения. Если проект работает только при отключенной оптимизации — значит, либо в коде где-то UB, либо в компиляторе баг. Второе возможно, но куда реже чем первое.


            А если будет поток?

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


            1. RegisterWindowClassExA
              24.12.2018 12:06

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


            1. RegisterWindowClassExA
              24.12.2018 15:06
              -1

              Тем не менее, подумав еще раз, склоняюсь к тому, что явная оптимизация лучше. В общем случае, если Вы программируете на всем, что допускает возможность программирования, это сослужит Вам добрую службу.
              Например, пишете Вы код, знаете хорошо повадки компилятора. Ок. Потом Вы решили сделать Ваше приложение кроссплатформенным, каков будет масштаб изменений? Явно оптимизированное приложение гораздо проще перенести, в другой ОС будет другой компилятор, который не будет столь же продвинутым, и сделает неэффективный код.
              У Вас есть хорошая библиотека, которую Вам нужно перенести на другой ЯП для другого проекта. Опять же неизвестно, что там будет за компилятор, кто будет исполнять полученный код. Известно только одно — явно оптимизированный код с большей вероятностью будет выполняться более оптимально везде, где это может быть. Нормально делай — нормально будет.
              И вот куда мне во всей этой куче ситуаций нужно впихнуть знание, что «компилятор С# в такой-то конкретной ситуации неявно догадывается о том, что я хочу сделать». ИМХО, на таком уровне хорошо бы и мне представлять, что именно я написал, и какие есть пути оптимизации.
              На мой взгляд, явная оптимизация всё-таки показатель того, что человек понимает, что происходит за ширмой IDE, в которой он работает. И она учит этому пониманию и других, особенно если это прокомментировано в коде.


              1. mayorovp
                24.12.2018 15:28

                У Вас есть хорошая библиотека, которую Вам нужно перенести на другой ЯП для другого проекта.

                Поскольку я пишу на C#, то и переносить я буду ее с другого языка программирования на C#, и никак иначе. И я не вижу чем знание "повадок" компилятора мне в этом помешает...


                А если я захочу перейти на другой ЯП (например, на Rust) — то я просто изучу основные "повадки" компилятора этого самого другого языка.


          1. Ogra
            24.12.2018 12:18

            вот то, что Вы написали — это Ваше 100%-ное знание или просто идеализация компиляторов?

            На разумном уровне оптимизации, компиляторы делают только то, в чем уверены. Я не знаю, лезут ли компиляторы каждый раз в тело метода, или просто смотрят на модификаторы (вроде const), решая, когда переменную можно заоптимизировать, а когда нет. Я даже уверен, что для разных языков будет по разному.
            Я, конечно, не идеализирую компиляторы, но в плане оптимизаций после этой статьи про предсказание ветвлений, не решаюсь высказываться в духе «Ассемблер быстрее, чем Си».
            А если будет поток? Тоже компилятор будет анализировать, используется ли функция в потоке, или меняется ли массив другими потоками?
            Тогда трындец. Ставьте volatile там, где у вас многопоточный доступ к переменной.


      1. LMSn
        24.12.2018 13:34

        Эта статья скорее о том, что неявная оптимизация может сильно подгадить.

        Я не пишу на C#, но например, если в теле цикла меняется размер массива

        Ага, «не читал, но осуждаю». В .NET все массивы без исключения фиксированной длины. Она не может измениться, можно только создать новый массив другого размера.


        1. RegisterWindowClassExA
          24.12.2018 13:42

          Пффф, а я же думал: «Ну не пишешь ведь на C#, ну иди мимо, не пиши». Но нет, решил натянуть свою сову на чужой глобус :)
          Спасибо, буду знать :)


  1. skaz07
    24.12.2018 08:45

    После Вашей статьи мой мир теперь уже никогда не станет прежним


  1. ideatum
    24.12.2018 09:38

    Зная структуру (memory layout) массива, можно посмотреть здесь, в общем понятно, что смысла сохранять длину массива в локальную переменную смысла не имеет и обращение к Length хорошо оптимизированно. imho на мой взгляд более интересным вопросом является, каким образом стоит обращаться со свойством коллекций Count.


  1. aensidhe
    24.12.2018 09:43

    Для подобных вещей есть давно BenchmarkDotNet. Вот, например, вариант кода и результаты.


    Как можно увидеть — никакого влияния на производительность вынос Length не имеет.


  1. alex_zzzz
    24.12.2018 11:14

    Спасибо. Сегодня я узнал, что вынесение array.Length из условия цикла в отдельную переменную больше не приводит к замедлению. Проверка выхода за пределы массива оптимизируется в обоих случаях. Видать, компиляторы поумнели. Хотя, надо ещё Моно проверить, там это важно.


    1. aensidhe
      24.12.2018 11:33

      Выше бенчмарк, запустите его на моно.


      1. alex_zzzz
        24.12.2018 11:54

        Запущу обязательно. Вечером.


      1. alex_zzzz
        25.12.2018 01:46

        Проверил. Убрал тест Aggregate, добавил несколько с указателями из любопытства. На NET Core не проверял, оно у меня не установлено. Добавил Моно и x86.


        Результаты забавные


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


        1. LegacyJit x64 отдал предпочтение методу с array.Length, вынесенному из условия цикла (540 нс против 770 нс). Но проверка на выход индекса за пределы массива есть в обоих случаях. Нихрена она не убрана.
        2. В других конфигурациях NET Framework-компиляторам пофиг, скорость одинаковая. Хотя RyuJit догадался убрать проверку в обоих случаях (они вообще идентичные с точностью до замены регистров). Оставил её только в варианте ForReverse. Поэтому без необходимости ходить по массиву в обратную сторону наверное не надо, хотя разница невелика (600 нс против 640 нс).
        3. Компилятор Mono x64 тоже выбрал вариант с вынесением array.Length из цикла (860 нс против 1040 нс). Так же и в Mono x86 (1800 нс против 1960 нс). Осталась там проверка на выход за пределы или нет, непонятно. Дизассемблер непривычный и без отображения меток. Вообще ХЗ что там происходит.
        4. Замена статического массива на экземплярный дала изменение только в одном пункте: немного ускорился For на Mono x64, но на общую картину это не повлияло.
        5. Моно генерирует какие-то бессмысленные портянки машинных инструкций, перетасовывая регистры взад-вперёд. Дизассемблер методов с развёрнутыми циклами сам разворачивается на несколько экранов. В результате чем сильнее цикл развёрнут, тем больше кода и всё медленнее. При том что для LegacyJit x64 это тест даёт лучший результат из всех возможных.
        6. Лучше таки использовать foreach и для скорости, и для наглядности, и писать быстрее, и негде ошибиться.
        7. Вариант с указателями тоже неплох (почему-то из трёх похожих лучше всего именно PtrA). Когда очень надо, все средства хороши.


        1. aensidhe
          25.12.2018 12:52

          Без тестов на .net core выводы делать так себе. Потому что .net framework переведён в maintenance мод и в .net core была куча оптимизаций (в рантайме, джите и BCL), не все из которых портированы обратно в полный фреймворк.


          Спасибо за тесты.


  1. erty
    24.12.2018 11:24

    Я не специалист в C#, но в некоторых языках реализация foreach не гарантирует строго последовательного перебора набора. т.е. могут браться элементы вразнобой (как они там хранятся в памяти), а не строго подряд. В C# такого не наблюдается?


    1. aensidhe
      24.12.2018 11:36
      +1

      Это зависит от коллекции.


      foreach (var x in collection) {}

      Это всего лишь сахар для:


      using (var e = collection.GetEnumerator())
      {
          while (e.MoveNext()) {}
      }

      Так что всё зависит от реализации MoveNext.


      Если тип collection — массив и компилятору это точно известно, то там будет просто проход по индексу.


    1. mayorovp
      24.12.2018 11:41

      Если там элементы хранятся в памяти вразнобой — то откуда вообще возьмется быстрый доступ по индексу?


    1. ICELedyanoj
      24.12.2018 11:43

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


  1. GLeBaTi
    24.12.2018 11:28

    Стоит упомянуть, что если использовать другое свойство:

    for (int i = 0; i < myObj.MyProperty; i++)

    То компилятор может не оптимизировать и вызов будет каждый раз.
    blogs.msdn.microsoft.com/vancem/2008/08/19/to-inline-or-not-to-inline-that-is-the-question
    Поэтому не вижу ничего страшного, что кто-то выделяет в локальную переменную. Явное лучше неявного.


    1. alex_zzzz
      24.12.2018 11:40

      https://blogs.msdn.microsoft.com/clrcodegeneration/2009/08/13/array-bounds-check-elimination-in-the-clr/


      Advice 2: When possible, use “a.Length” to bound a loop whose index variable is used to index into “a”.

      Как минимум раньше эта «оптимизация» приводила к обратному эффекту. Вместо явного (i < a.Length) JIT видел сравнение индекса с какой-то левой переменной и на всякий случай вставлял в код проверку на выход за границы массива.


  1. UncleJey
    24.12.2018 12:31

    Статья не полная.
    не вижу обратного перебора.

    for (int i = array.Length - 1; i>=0;  i--) {
        //do smth
    }


    1. PsyHaSTe
      24.12.2018 12:36
      -1

      А что должно поменяться? У массива нижняя граница, к слову, не всегда 0. У них даже название есть для массивов специального вида — SZArray.


      1. UncleJey
        24.12.2018 14:29
        +1

        А в чём смысл неполной статьи?


        1. aensidhe
          24.12.2018 14:55

          Без разницы, см. бенчмарк


    1. T-D-K Автор
      24.12.2018 15:43

      Цель моей статьи разве была определить самый быстрый способ итерации на массиве? Или разобрать все возможные варианты итерации? Я рассматривал один конкретный пример и делал выводы для одного конкретного примера. С моей точки зрения статья полная.


  1. PsyHaSTe
    24.12.2018 12:36

    Очень часто замечаю, что люди пишут вот так

    Не знаю, ни разу не встречал. В 99% случаях люди пишут `var result = arr.Sum(x=>x.Something)`, и в оставшемся 1% случае foreach.

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

    К слову, for/foreach должны давать одинаковую производительность для массивов. Для список результат иной, насколько я помню.


    1. aensidhe
      24.12.2018 12:44

      Нет никакой деоптимизации.


      1. PsyHaSTe
        24.12.2018 13:57

        Ну как нет, могли пойти по быстрому пути, но нарушили эвристику компилятора.

        Вообще, не знаю реального кода, который бы на это заявзывался. Такой код навреное только на заре карьеры пишут. Я когда джуном был, тоже в циклах где мог byte/short использовал, если позволяло максимальное значение циклов. И про себя думал «вот поэтому мне 4гб памяти и не хватает, во всех примеров программисты разбазаривают её, int пишут!».


        1. aensidhe
          24.12.2018 14:54

          Ну так нет. Есть бенчмарк, который показывает, что деоптимизации нет.


  1. Warg_nvkz
    24.12.2018 14:07

    Это не имеет значения, потому что длина массива хранится в отдельной переменной в классе, а не вычисляется. Так писали на старых языках, вроде C, Pascal и Basic и их ближайших потомках, потому что там массив, это просто последовательность неизвестной длины.
    Ну, и length писать короче, чем array.Length


    1. PsyHaSTe
      24.12.2018 14:45

      Не хранится, выше показывали плюсовый код, который вызывается при обращении к свойству.


    1. mayorovp
      24.12.2018 15:29

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


  1. Brightori
    25.12.2018 10:09

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


    1. mayorovp
      25.12.2018 10:28

      Length — int, это value type. С каких пор в .net возврат value type стал требовать аллокаций памяти?


  1. eshirshov
    25.12.2018 13:24

    но ведь это примитивный пример. в общем случае как компилятор может гарантировать неизменность размера массива? вызов метода напрочь убьёт все гарантии.
    теоретически разумеется.


    1. a-tk
      25.12.2018 15:57

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


      1. eshirshov
        25.12.2018 17:20

        я упустил что речь идёт об Array. подразумевал абстрактный контейнер со свойством Lenght. посылаю голову пеплом.


    1. mayorovp
      25.12.2018 16:10

      В .NET — запросто: длина массива неизменяема. Может поменяться только ссылка на сам массив, но если она находится в локальной переменной, то тоже не может поменяться сторонним кодом.