Найти тему
ZDG

Циклы, языки и ассемблер

Оглавление

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

Для начала обратимся к основам – как циклы делаются на ассемблере. Я буду использовать диалект x86, хотя это не принципиально.

Итак, задача: Создать цикл, который повторяется 10 раз.

Первый вариант:

Если вы не знаете, что такое регистр, достаточно считать, что это просто переменная с именем cx.
Если вы не знаете, что такое регистр, достаточно считать, что это просто переменная с именем cx.

Этот цикл равносилен конструкции:

for (i = 0; i < 10; i++) { ... }

Как видим, здесь сначала i присваивается 0, затем указывается условие повторения цикла (i < 10), затем то, как меняется i (i++ увеличивает i на 1). Те же части цикла и в том же порядке есть в ассемблерном варианте. Данный цикл обладает одной особенностью – он не выполнится ни разу, если условие повторения заведомо невыполнимо.

Также, на каждом шаге цикла i увеличивается на 1 и значит служит не только счётчиком шагов, но и порядковым номером. А этот номер можно использовать, например, для последовательного доступа к элементам массива: a[i].

Второй вариант:

-2

Этот цикл равносилен конструкции:

i = 10;
while (i != 0) { ...; i--; }

Фактически это цикл for, развёрнутый задом наперёд: мы движемся не от 0 к 10, а от 10 к 0, вычитая по единице из счётчика.

Но есть нюанс: в ассемблере используется не команда сравнения с нулём:

cmp cx, 0

а команда неразрушающего логического "И":

test cx, cx

И операция cmp (compare), и операция test дают один и тот же результат, если cx равен 0. При этом один из служебных флагов процессора, который отвечает за нулевой результат операции, устанавливается в активное состояние и следующая команда jz (jump if zero) срабатывает именно на этот флаг.

Почему используется test, а не cmp? Это небольшой лайфхак. В силу устройства процессора операции с регистрами быстрее. А операция test cx, cx использует только регистры. Если устроить cx логическое "И" с самим собой, то результат 0 получится только тогда, когда cx равен 0. Значит, мы получили эквивалент сравнения с 0.

На ассемблере сравнения с 0 делать выгоднее, чем сравнения с другими числами. Следовательно, цикл, который идёт от 10 к 0, более эффективен, чем цикл, который идёт от 0 к 10.

А также любые сравнения в программе, которые можно переделать на сравнения с 0 или "не 0", теоретически более эффективны.

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

Третий вариант:

-3

Этот цикл равносилен конструкции:

i = 10;
do { ... } while (--i != 0);

Посмотрите, насколько проще стал ассемблерный код. Сейчас в нём вообще нет сравнений и исчезла одна метка. Сразу после уменьшения cx на 1 следует команда jnz (jump if not zero).

Нам не нужно сравнивать cx с 0, потому что флаг нулевого результата активируется не только при сравнениях, но и при арифметических действиях. Следовательно, сразу после dec cx мы можем использовать команду jnz.

Не правда ли, очень просто и эффективно? Данный цикл самый лучший, но за это приходится платить. Ему придётся выполниться хотя бы один раз, даже если условие повторения сразу невыполнимое. В программах, однако, таких циклов бывает предостаточно, так что возможности для их использования есть (хотя насколько заметный эффект это даст, чтобы быть оправданным?)

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

Бонус. Четвёртый вариант:

-4

Он аналогичен третьему, только здесь используется специализированная команда loop, которая эквивалентна двум командам:

dec cx
jnz @@_1

Она работает только с регистром cx, и поменять это нельзя. Она процессорно-зависима, поэтому как общий пример я не стал её приводить.

Python, Rust и другие

В таких языках, как C, Java, Javascript мы привыкаем к конструкции цикла, которая у них одинакова:

for (i = 0; i < 10; i++) { ... }

Если после этого начать писать на Python, то мы встретим такой цикл:

for i in range(0, 10): ...

Аналогично в Rust:

for i in 0..10 { ... }

И можем чисто интуитивно решить, что это наш старый добрый for, просто в другом синтаксисе.

И тут нас может ждать неприятный сюрприз. Да, если "ванильно" пользоваться этими циклами, то с виду всё будет хорошо. Переменная i будет проходить от 0 до 9 как обычно, и мы даже сможем использовать её как индекс массива и т.п.

Но если мы попытаемся внутри цикла изменить i...

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

Но суть тут вот в чём. В "нормальных" языках мы действительно можем поменять значение счётчика и тем самым повлиять на цикл. Например, на JavaScript внутри цикла присвоим переменной i значение 100:

-5

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

Попробуем сделать это на Питоне:

-6

И... цикл выполнится 10 раз. Каждый раз присваивая i = 100:

-7

Пример на Rust приводить не буду, но картина там аналогичная. Что происходит?

Итераторы

Конструкция range(0, 10) в Питоне это вовсе не красивый синтаксис для обёртки классического цикла for. Она создаёт отдельный объект-итератор, который содержит внутри себя числа от 0 до 9. Как именно содержит, в каком виде – от нас скрыто, нам предоставляется только интерфейс к этому итератору.

И цикл, написанный как

for i in range(0, 10)

вовсе не создаёт счётчик i, который увеличивается на 1 на каждом шаге и работает до 9. Вместо этого в i каждый раз попадает очередное число из объекта-итератора.

Чтобы было наглядней, перепишем цикл так:

-8

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

Настоящий цикл работает внутри итератора, скрытый от нас.

В Rust всё так же, только ещё и менее подозрительно, так как вместо range(0, 10) пишется невинное 0..10. И вывести его на чистую воду можно лишь использовав отдельную переменную iterator:

-9

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

Но что с обычными циклами?

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

Чтобы сделать цикл с обычным счётчиком на Python или Rust, нужно воспользоваться while:

-10

В таком виде мы получим классический цикл с переменной-счётчиком, значение которой при желании можно изменить.