В этом посте будет исследовано, как математическую концепцию можно постепенно переформулировать во всё более «вычислительных» понятиях, от высокоуровневого языка, далее до машинного кода и, наконец, до прямого исполнения компьютером. Для этого определю одну и ту же логику в нескольких разных, но перекликающихся друг с другом форматах:
1. Математика — чистая математика.
2. Haskell — язык для функционального программирования.
3. C — язык для императивного программирования.
4. Ассемблер — сравнительно удобочитаемое представление машинного кода.
5. Машинный код для архитектуры x86-64 — вот это уже интересно.
Если вам интересно, какие отличия бывают между языковыми стилями или любопытно, как ваш код может выглядеть после компиляции — добро пожаловать под кат!
Факториалы в математике
Факториал — это произведение целого числа и всех прочих целых чисел выше нуля. Существует множество способов выразить это определение, например вот так:

Здесь утверждается, что n! — это произведение всех целых чисел от 1 до n. Например, факториал от 5 равен:
5! = 1 * 2 * 3 * 4 * 5 = 120
Вот несколько первых факториалов:
n |
n! |
0! |
1 |
1! |
1 |
2! |
2 |
3! |
6 |
4! |
24 |
5! |
120 |
Важная область применения факториалов — подсчёт общего количества возможных перестановок в множестве. Например, строку «cat» можно переупорядочить 6 способами: «cat», «act», «atc», «tac», «tca» и «cta». В этой строке три буквы, а 3! = 6.
В строке «a» всего один символ, и поэтому переупорядочиванию она не поддаётся. Отсутствие перестановок равноценно одной перестановке и выражается как: 1! = 1.
Подобные вещи широко распространены в анализе алгоритмов. Об алгоритме, в котором требуется учесть все возможные перестановки входных данных, говорят, что он выполняется за факторное время. В нотации Big O это имеет вид: O(n!). Алгоритмы такого рода очень плохо масштабируются, поэтому полезно будет научиться распознавать их заранее, чтобы изначально искать более быстрый способ решения задачи.
Факториалы на функциональном языке
Подобно тому, как существует множество способов выразить что-либо математически, так есть и множество вариантов, как можно описывать сущности компьютеру. Начнём с языка Haskell, у которого, кроме всех прочих достоинств, очень классный логотип:

Haskell — чисто функциональный язык. В общем и целом это означает, что на этом языке мы не указываем компьютеру, что делать. Вместо этого в программе описано, каковы сами сущности. Когда программа на Haskell написана, в дело вступает компилятор Haskell, задача которого — найти способ преобразовать эти определения в понятные компьютеру инструкции.
Рассмотрим следующую функцию на Haskell, вычисляющую факториал предоставленного ей числа:
factorial :: Int -> Int
factorial n = product [1..n]
Если вы пока не баловались функциональными языками, этот код может показаться вам весьма странным.
В первой строке сказано, что факториал — это функция, принимающая целое число и возвращающая другое целое число. Вот другая, слегка отформатированная версия этой строки. В ней специально расставлены пробелы так, чтобы было понятнее, какой синтаксический элемент за что отвечает:
-- факториал – это функция, принимающая целое число и возвращающая другое целое число
factorial :: Int -> Int
Строго говоря, эта первая строка необязательна, но считается хорошим тоном её включать. Язык Haskell достаточно умён, чтобы, как правило, самостоятельно выяснять эту информацию по сигнатурам типов, но сигнатуру функции всё равно полезно документировать, чтобы облегчить жизнь не только другим программистам, но и себе в будущем.
Во второй строке определяется тело функции, которое можно трактовать как «факториал от n равен произведению всех целых чисел от 1 до n». Вот ещё одна версия функции с пояснениями элементов синтаксиса:
-- Факториал от n равен произведению всех целых чисел от 1 до n
factorial n = product [1 .. n]
Обратите внимание: мы не объясняем Haskell, как вычислять факториал, а даём ему определение, что такое факториал. В этом и заключается одно из важнейших отличий между функциональными и императивными языками.
Давайте ещё подробнее разберём это определение. Когда функцию вызывают с каким-либо числом n в качестве аргумента, часть выражения справа от знака равенства вычисляется, и возвращается ответ:
product [1..n]
Сначала давайте рассмотрим ту часть, которая в квадратных скобках:
[1..n]
Это диапазон списков. Эта структура данных в Haskell подобна массиву — то есть представляет собой упорядоченную коллекцию значений одного и того же типа. Можно иметь списки целых чисел, чисел с плавающей точкой, строк, пользовательских типов и даже списки списков.
Здесь .. указывает, что данный список является диапазоном. Так создаётся список всех целых чисел от 1 до n. Таким образом, если n равно 5, то получится список, содержащий 5 значений:
[1, 2, 3, 4, 5]
После того, как диапазон списков вычислен, он передаётся функции произведения, вот так:
product [1, 2, 3, 4, 5]
Функция произведения принимает список чисел, перемножает их все и возвращает результат. Таким образом, получится:
1 * 2 * 3 * 4 * 5
Оказывается, ответ равен 120, что равноценно 5! — именно тому числу, что нас интересует. Какое счастливое совпадение!
Как только определена функция факториала, приведённая выше, можно получить факториал любого числа, вызвав подобный код:
factorial 0 -- возвращает 1
factorial 3 -- возвращает 6
factorial 5 -- возвращает 120
Факториалы в императивном языке
Рассмотрев, как математическая идея факториалов может быть выражена в стиле функционального программирования, пойдём на уровень глубже и повторим ту же реализацию на языке C. С мне очень нравится, жаль только, что логотип у него не такой крутой, как у Haskell:

Напомню, что при программировании на функциональных языках, подобных Haskell, мы обычно даём определение сущностям и предоставляем языку возможность самостоятельно вывести нас к ответу. Напротив, при программировании на императивном языке требуется объяснить компьютеру, какие вычисления он должен выполнить — то есть нужно написать последовательность шагов для компилятора.
Рассмотрим следующую функцию факториала, написанную на C:
int factorial(int n)
{
int ret = 1;
while (n > 1)
{
ret *= n;
n--;
}
return ret;
}
В основе неё лежит ровно та же логика, что и в версии на Haskell, просто мы иначе её описали.
Вот что в общем виде делает эта функция:
1. Устанавливает ret в 1. Это будет возвращаемое значение.
2. Умножает n на ret.
3. Вычитает 1 из n.
4. Повторяет шаги 2-3 при условии, что n больше 1.
5. Возвращает значение, содержащееся в ret.
Разберём эту функцию построчно и посмотрим, как всё это делается.
int factorial(int n)
{
С этого начинается функция факториала. Здесь постулируется, что факториал — это функция, принимающая целое число n и возвращающая другое целое число. Эта информация не поддаётся переводу на естественный язык, как сигнатура типа Haskell, но можно попытаться:
// Возврат целого числа, факториал — это функция, принимающая целое число под именем n
int factorial ( int n)
Порядок этого синтаксиса таков, что воспринимается немного неуклюже, но в этой строке заключён тот же смысл, что и в сигнатуре функции Haskell.
int ret = 1;
Так объявляется новое целое число под именем ret, и ему присваивается значение 1. Это будет возвращаемое значение. Мы будем раз за разом умножать эту переменную на n и возвращать то, что у нас получится, когда будем готовы.
while (n > 1)
{
Так начинается цикл. Часть while (n > 1) нужна для многократного выполнения кода, заключённого в фигурные скобки { ... } в тех случаях, если n больше 1. Если перед началом работы функции n равно 0 или 1, то этот цикл вообще не станет выполняться.
ret *= n;
При каждом прогоне цикла умножаем n на ret и сохраняем результат в ret.
n--;
Затем вычитаем 1 из n. Таким образом, n будет возрастать на каждом прогоне цикла.
}
Это окончание тела цикла. Когда выполнение программы доходит до этой точки, мы возвращаемся к началу цикла и запускаем его снова, предполагая при этом, что условие в строке where по-прежнему остаётся истинным.
return ret;
}
Как только цикл завершится, мы возвращаем значение, содержащееся в ret, и заканчиваем функцию.
Эту функцию факториала можно вызвать вот так:
factorial(5);
При вызове этой функции со значением 5 происходят следующие шаги:
1. ret устанавливается в 1.
2. n равно 5, а 5 > 1, поэтому тело цикла будет выполняться.
3. ret умножается на 5 и становится равно 5.
4. n уменьшается на единицу, таким образом, становится равно 4.
5. n равно 4, а 4 > 1, поэтому тело цикла снова выполняется.
6. ret умножается на 4 и становится равно 20.
7. n уменьшается на единицу, таким образом, становится равно 3.
8. n равно 3, а 3 > 1, поэтому тело цикла снова выполняется.
9. ret умножается на 3 и становится равно 60.
10. n уменьшается на единицу, таким образом, становится равно 2.
11. n равно 2, а 2 > 1, поэтому тело цикла снова выполняется.
12. ret умножается на 2 и становится равно 120.
13. n уменьшается на единицу, таким образом, становится равно 1.
14. n равно 1, а 1 не больше 1, поэтому цикл завершается.
15. ret в значении 120 возвращается вызывающей стороне.
Факториалы в ассемблере
Несмотря на стилистические отличия, функции как на C, так и на Haskell остаются относительно высокоуровневыми. Таким образом, когда вы пишете такой код, вам не приходится особенно вдаваться в частности работы с машиной. Компиляторы C и Haskell сами могут преобразовать ваш код в удобоваримую форму для целевого компьютера. Но как выглядит тот код, который они генерируют?
Здесь мы углубляемся в ассемблер. Ассемблер — это символьное выражение машинного кода. В основном инструкции ассемблера строго соответствуют инструкциям машинного кода.
Поэтому невозможно что-либо постулировать на ассемблере так, как это делается в C и Haskell: в сравнительно высокоуровневых языках проделывается большая работа по адаптации синтаксиса к человеческому мышлению. Но при работе с ассемблером часть этой работы ложится на плечи программиста, который сам должен подстраиваться к частностям работы железа.
На самом деле, с точки зрения синтаксиса существует несколько вариантов ассемблера. В данном случае мы будем работать с Netwide Assembler, также именуемым nasm. Прежде, чем двигаться далее, давайте разберёмся с по-настоящему важными вещами. Вот логотип nasm:

Боюсь, Haskell по-прежнему впереди, но этот логотип совсем не плох.
Вот функция факториала, записанная синтаксисом nasm для архитектуры компьютера x86-64:
factorial:
mov rdi, 1
.loop:
cmp rax, 1
jle .done
imul rdi, rax
dec rax
jmp .loop
.done:
ret
Ладно, что же здесь происходит? Если вы можете осмыслить этот код, то либо вы уже знакомы с ассемблером, либо вы гораздо умнее меня. В коде на C и Haskell хотя бы есть полноценные слова и знакомые выражения. Но, хотя стиль кода и существенно изменился, логика здесь осталась примерно та же самая, более-менее в том же порядке, что и в предыдущих версиях.
Рассмотрим следующую версию, сопровождаемую комментариями. Она позволяет составить примерное впечатление о том, что делает каждая строка на C:
factorial: ; int factorial(int n) {
mov rdi, 1 ; int ret = 1;
.loop: ; .loop:
cmp rax, 1 ; if (n <= 1)
jle .done ; goto .done; // Этот страшный goto!
imul rdi, rax ; ret *= n;
dec rax ; n--;
jmp .loop ; goto .loop; // О боже, ещё один!
.done: ; .done:
ret ; return ret;
; }
Соответствие не вполне точное, но, когда комментарии расставлены, этот код должен казаться чуть менее странным.
При всей разнице в описании базовая логика здесь точно такая же, как и в версии на C. Это неслучайно: язык C тонким слоем намазан поверх ассемблера, и большинству его конструкций можно найти весьма точное соответствие в ассемблере на самых разных машинах.
Принципиальная разница между версией на ассемблере и версиями на C/Haskell заключается в том, что в ассемблере отсутствуют сигнатуры типов. В ассемблерной версии нигде не определяется, какие значения будет принимать на вход, и какие будет выдавать на выход функция факториала. Напротив, ожидается, что значение n будет загружено в регистр rax ещё до того, как будет вызвана функция. Перед выходом эта функция оставляет возвращённое значение в регистре rdi — опять же, предполагается, что вызывающая сторона будет знать, где искать этот ответ. Нигде в коде это явно не артикулировано: чтобы пользоваться данной функцией, в принципе нужно заранее знать, как она работает. В идеале код должен содержать комментарии, либо эта информация должна быть дана во внешней документации. В противном случае придётся читать код самой функции, чтобы методом проб и ошибок выяснить, как же её использовать.
Начиная работу, функция устанавливает rdi в 1, это и будет возвращаемое значение. Далее она многократно умножает это возвращаемое значение на значение, содержащееся в rax, на каждой итерации вычитая из rax единицу. Как только rax достигнет 0 или 1, функция завершается, а возвращаемое значение остаётся в rdi, чтобы им могла пользоваться вызывающая сторона. Если предположить, что вызывающая сторона записала целое число в регистр rax до того, как вызвать функцию факториала, то после возврата функции в регистре rdi определённо будет находиться факториал именно этого целого числа.
Если вам интересно не только сопоставить эти инструкции с C-подобным синтаксисом, но и узнать, что именно делает каждая из них — читайте далее!
Подробное исследование ассемблерной версии
Для начала — небольшое разъяснение. Процессор не «мыслит» такими переменными как int ret = 1 или ret *= n;. Нет, у него есть некоторое количество регистров. В каждом регистре может храниться фиксированный набор данных. Если процессор 64-разрядный, то в каждом его регистре общего назначения можно сохранить 64 бита информации.
Выполняя инструкции, программа может загружать данные в эти регистры, а затем выполнять над данными математические операции.
Поскольку регистры — это крошечные фрагменты памяти в аппаратной части ЦП, выполнение операций над данными в регистрах происходит молниеносно. Ведь процессору не приходится дожидаться, пока данные отправятся в системную память или вернутся из неё.
Вот несколько наиболее востребованных регистров:
Регистр |
Описание |
rax |
Общего назначения |
rbx |
Общего назначения |
rcx |
Общего назначения |
rdx |
Общего назначения |
rdi |
Общего назначения |
rsi |
Общего назначения |
rbp |
С его помощью часто отслеживают, где начинается кадр стека вызовов |
rsp |
Всегда указывает на вершину стека |
rip |
Всегда указывает на следующую инструкцию, которую необходимо выполнить |
Регистрами общего назначения вы в основном можете пользоваться так, как посчитаете нужным. Другие регистры зарезервированы под конкретные цели, и для них существуют правила, указывающие, как эти регистры можно использовать или изменять.
Процессор может производить вычисления лишь над теми данными, что загружены в регистры. Так, чтобы сложить два числа, нужно сначала приказать компьютеру загрузить каждое из чисел в регистр, а потом приказать сложить значения, содержащиеся в этих регистрах.
Давайте построчно разберём ассемблерную функцию и в деталях посмотрим, как именно она работает.
factorial:
Это начало функции факториала. Следующее после двоеточия имя часто называется меткой. Можно приказать компьютеру перейти (jump) к этой метке в любой момент, когда нам потребуется, чтобы выполнился следующий за этой меткой код.
В самом начале функции мы исходим из того, что вызывающая сторона записала в регистр rax некоторое целое число n.
mov rdi, 1
Результат этой функции мы возвратим в регистре rdi. Эта инструкция задаёт 1 в качестве исходного значения rdi. Мы понятия не имеем, в какое значение установлен этот регистр в начале выполнения функции, так как при вызове функций регистры автоматически не очищаются. Прежде, чем использовать регистр, потребуется установить его в какое-либо значение.
.loop:
Это ещё одна метка, соответствующая началу цикла. Поскольку перед ней стоит точка, эта метка является локальной, то есть локальной в пределах той функции, где мы находимся. Можно перейти к .loop: в любой момент, когда мы захотим выполнить данный цикл.
cmp rax, 1
При каждом прогоне цикла первым делом нужно проверить, не должен ли цикл уже завершиться.
Напомню, что n сохранено в rax. Эта инструкция сравнивает записанное в rax значение с 1. Она ничего не делает с этой информацией, а просто готовит те вещи, с которыми мы позже сможем поработать.
jle .done
Эта инструкция работает над предыдущей инструкцией сравнения. Здесь jle означает «jump if less than or equal to» (перейти в случае, если значение меньше или равно x). Таким образом, если rax меньше или равно 1, то выполнение кода сразу перейдёт в стадию готово (done): это метка, завершающая цикл. В противном случае, выполнение продолжится до следующей инструкции, которая запустит тело цикла.
imul rdi, rax
Если программа не выскочила из цикла к метке .done:, то известно, что n должно быть равно 2 или выше. Эта инструкция умножает rdi на rax и сохраняет результат в rdi.
dec rax
Эта инструкция уменьшает rax на единицу, то есть вычитает 1. Если значение в rax равно 5, то эта инструкция установит его в 4.
jmp .loop
Эта инструкция переходит обратно к метке .loop:, и с этого цикл начинается снова.
.done:
Как только работа с циклом закончится, выполнение кода перейдёт сюда:
ret
Теперь результат вычисления факториала должен находиться в rdi. Эта инструкция завершает функцию, в результате чего выполнение кода возвращается к той точке, в которой мы остановились, когда была вызвана эта функция. Возвращаемое значение n! останется в регистре rdi, чтобы им могла воспользоваться вызывающая сторона.
Как видите, вся заложенная здесь логика очень похожа на логику из версии C. Реализация сравнительно высокоуровневых конструкций, например цикла while из C, требует перескакивать между метками и отдельными инструкциями сравнения, но работает всё одинаково. Эта функция гораздо хуже самодокументирована, так как в ней нет формального определения типов, но в остальном она принимает всё тот же ввод и возвращает всё тот же вывод.
Факториалы в машинном коде
Выше мы рассмотрели функцию факториала, написанную на ассемблере, но может ли компьютер выполнять её напрямую? Ну… почти. Ассемблер можно считать мнемоникой для работы с машинным кодом — то есть каждая ассемблерная инструкция соответствует одной инструкции машинного кода. Но в ассемблере инструкции обозначаются сокращёнными английскими словами и десятичными числами, человеку так понятнее читать и писать.
Можно ассемблировать код вручную, для этого есть удобная справка по адресу ref.x86asm.net. Подробный рассказ о сборке машинного кода вручную — это, пожалуй, тема для отдельного поста. Но просто из интереса давайте рассмотрим, как ассемблерная функция может соотноситься с машинным кодом.
Обратите внимание: здесь я опущу некоторые распространённые оптимизации.
Полюбуйтесь!
48 bf 01 00 00 00 00 00 00 00 48 3d 01 00 00 00 7e 0c 48 0f af f8 48 ff
c8 e9 ec ff ff ff c3
Это функция факториала, записанная в машинном коде. Всё понятно, верно? Рад, что вам понятно, спасибо, что дочитали!
...
Да, мне тоже приходится через это продираться, но примерно так компьютер и видит машинный код. Это большой лоскут байт, находящийся где-то в памяти. В регистре rip хранится адрес одного из этих байтов. Выполняя инструкцию, компьютер проверяет значение rip, чтобы узнать, куда оно указывает, а затем декодирует данные, которые там находит. Таким образом, руководствуясь набором правил, компьютер разбирается, какая инструкция понимается под какой последовательностью байт.
Декодировав информацию, ЦП делает то, что предписывает инструкция. По умолчанию rip должна указывать на следующую инструкцию, которая должна быть выполнена после команды, выполненной только что. Таким образом, в следующий раз, когда ЦП будет выполнять инструкцию, rip будет указывать уже на следующую инструкцию в памяти. Поэтому инструкции будут выполняться последовательно. Но в некоторых случаях (например, при переходах) инструкция изменяет регистр rip так, чтобы он указывал куда-то ещё — и выполнение становится скачкообразным.
Вышеприведённый код представлен в шестнадцатеричной системе. Каждый элемент шестнадцатеричного числа занимает один байт. Этот код можно с тем же успехом представить как последовательность нулей и единиц (8 на байт) или в десятичном выражении (число от 0 до 255 на каждый байт). Например:
Десятичное |
Шестнадцатеричное |
Двоичное |
72 |
48 |
01001000 |
Не столь важно, каким образом представлены данные. При желании вы могли бы изобрести ваш собственный формат кодировки, правда, никто, кроме вас, не знал бы, как его читать.
Поскольку пользы от этого лоскута байт мало, давайте разобьём его на инструкции:
48 bf 01 00 00 00 00 00 00 00 mov rdi, 1
48 3d 01 00 00 00 cmp rax, 1
7e 0c jle .done ; Переход вперёд на 12 байт
48 0f af f8 imul rdi, rax
48 ff c8 dec rax
e9 ec ff ff ff jmp .loop ; Переход назад на 20 байт
c3 ret
Пожалуй, самая существенная разница между этой версией и ассемблерной (кроме того, что короткие англоподобные словечки преобразуются в бульон шестнадцатеричных цифр) — в том, что здесь нет меток. Дело в том, что метки вроде factorial: и .done: предоставляются в ассемблерах просто для удобства. В машинном коде переход делается так: меняется значение в rip, чтобы эта инструкция указывала куда-то ещё.
Рассмотрим ассемблированную версию jle .done:
7e 0c jle .done ; Переход на 12 байт вперёд
Разберём, что означает каждый байт в этой инструкции:
Шестнадцатеричный код |
Роль |
Значение |
Смысл |
7e |
Код операции |
jle |
Переход, если выполняется условие «больше или равно» |
0c |
Операнд |
12 |
Переход на 12 байт вперёд |
Таким образом, 7e приказывает компьютеру сделать переход, а куда — зависит от предыдущей инструкции cmp. 0c указывает, куда именно переходить, если переход состоится. 0c — это 12 в шестнадцатеричном представлении. В совокупности это означает, что нужно перейти на 12 байт вперёд от актуальной позиции.
При исполнении инструкции rip будет указывать на следующий после этой инструкции байт. Поэтому при выполнении 7e 0c (jle .done) rip будет указывать на 48 0f af f8 (imul rdi, rax). При переходе значение rip будет увеличено на 12, в результате чего он будет указывать c3 (ret) до самого конца. Таким образом, следующей будет выполнена инструкция 48 0f af f8 (imul rdi, rax) либо c3 (ret), в зависимости от того, каков будет результат выполненного сравнения.
Что насчёт переходов назад? Принцип работы тот же, только используется отрицательный сдвиг. Рассмотрим код jmp .loop, совершающий переход обратно к началу цикла:
e9 ec ff ff ff jmp .loop ; Переход на 20 байт назад
Эту инструкцию можно разделить на две части, как и предыдущую:
Шестнадцатеричный код |
Роль |
Значение |
Смысл |
e9 |
Код операции |
jmp |
Переход в любом случае (безусловный переход) |
ec ff ff ff |
Операнд |
-20 |
Переход на 20 байт назад |
Таким образом, e9 приказывает ЦП совершить переход, а ec ff ff ff указывает, что перейти нужно на 20 байт назад. При выполнении этой инструкции rip будет указывать на c3 (ret) в конце функции. Применив к rip дельту -20, мы добьёмся, что поток выполнения откатится на 20 байт назад к 48 3d 01 00 00 00 (cmp rax, 1), в результате чего цикл выполнится снова.
Предоставляя метки и совершая переходы по меткам, а не по сдвигам, работать становится значительно удобнее — именно поэтому в ассемблерах и есть такая фича. Без них для реализации управляющих структур, например, условных операторов или циклов, пришлось бы подсчитывать байты. Всякий раз при добавлении, удалении или даже изменении инструкций вам пришлось бы заново пересчитывать значения всех смещений при переходах. Таким образом, польза ассемблеров далеко не ограничивается переводом псевдоанглийских сокращений вроде jmp или rax в их двоичные эквиваленты.
Если не считать того, что метки заменяются относительными смещениями, а вся прочая информация преобразуется в двоичный формат, логика машинного кода не отличается от ассемблера, который, в свою очередь, очень близок к C. Складывается впечатление, что все языки и форматы состоят в каком-то родстве друг с другом. Страшно!
Заключение
Здесь мы рассмотрели, как можно выразить одну и ту же идею на всё более низкоуровневых языках вплоть до машинного кода. Надеюсь, этот опыт получился интересным и, возможно, даже открыл вам на что-то глаза. Спасибо, что дочитали!
Комментарии (20)

MonkeyWatchingYou
29.08.2025 12:31Си назван императивным по причине какой то обиды или скрытого пренебрежения?

atues
29.08.2025 12:31Нет, это общее названии категории языков программирования. Никаких обид или пренебрежений, никаких психологических мотивов. См. хотя бы тут: https://ru.m.wikipedia.org/wiki/Императивное_программирование

rukhi7
29.08.2025 12:31Для посчитать факториал не нужен не С ни Ассемблер ни Хаскель. Вы вполне можете посчитать его в тетрадке столбиком и осчастливить человечество таблицей значений факториала навсегда. Это не программирование!
Программирование это когда вам надо посчитать квадрат числа каждую микросекунду, а у вас нет инструкции умножения в Ассемблере.

atues
29.08.2025 12:31Вы вполне можете посчитать его в тетрадке столбиком и осчастливить человечество таблицей значений факториала навсегда.
Смелое утверждение :) Хотя факториал определен на множестве натуральных чисел и хотя это множество счетно (т.н.алеф-нуль), но это множество, увы, бесконечно. Вы же не пользуетесь таблицами Брадиса, чтобы узнать синус или логарифм? Я, по крайней мере лет 40 этого не делаю и другие, сдается мне, тоже. Так с какой стати кому-то нужны таблицы факториалов, тем более, что их полнота невозможна физически? Рано или поздно кому-то понадобится факториал, которого в такой таблице нет

rukhi7
29.08.2025 12:31Так с какой стати кому-то нужны таблицы факториалов, тем более, что их полнота невозможна физически?
А вы не видите предела до которого приведенная функция будет работать на х86 архитектуре? Достаточно таблицы на 15 максимум до 20 значений!

AndreyDmitriev
29.08.2025 12:31Это так, но, ещё раз — это же просто простая демка к командам ассемблера, к примеру, написать на чистом асме функцию, которая посчитает и напечатает все цифры, скажем 1000! — тоже хорошее упражнение. На самом деле такие функции в продакшене или при реальных расчётах не пишет никто — есть же библиотеки, скажем в Матлабе я просто возьму Symbolic Math Toolbox, и там вроде вообще лимита нет (за исключением памяти компа).

AndreyDmitriev
29.08.2025 12:31Это же учебный пример. Ещё Фибоначчи берут иногда. Из таких вот маленьких кирпичиков как раз и складывается программирование. А Хаскель тут слегка избыточен, я согласен, просто автор оригинала (это перевод) судя по всему хорошо им владеет, а вот Си и Ассемблером чуть менее уверенно, судя по терминологии и опущенным важным деталям о механизме возврата из функции.

atues
29.08.2025 12:31Соглашусь! И добавлю: нахождение НОД по методу Евклида тоже часто рассматривается как пример алгоритма

rukhi7
29.08.2025 12:31Мне, в конце 80-х, тоже тоже давали учебный пример про факториал посчитать, и вывести число в десятичном виде неограниченной длины (тоже конечно условно неограниченного, но с пределом который не так просто определить, хотя бы). Эта задача решается на компьютере, но тем же столбиком, как ни странно! И это по моему действительно погружает в проблемы программирования, в понимание применения базовых элементов для понимания программирования, таких как массивы, циклы, чем отличается машинное представление чисел от десятичного человеческого... А самое интересное, этот пример демонстрирует что ручные методы "в лоб" при использовании компьютера, зачастую, оказываются самыми эффективными.
Кажется с годами обучающие задачи несколько поизмельчали, что ли. Грустно это.

Jijiki
29.08.2025 12:31самый топ по обучению это плотная работа с текстом(редактирование, вставка, взятие текста, открыть/закрыть файл, и там число дробилок не меньше кстати ))
например текстовый буффер(сама структура его функции, и вторая его часть его рисование )
при желании углубления обучения, можно углубиться в формат текста(ttf например, тут можно познакомиться зачем нужна математика в текстовом буффере в полной мере)
просто факториал или 1 алгоритм это часть от стандарта, который предоставляет точность расчета, по началу это не интересно и не понятно зачем надо, а с текстом всё нагляднее
да и чтобы дойти до этих алгоритмов надо реализовывать всю математическую библиотеку(почитывая стандарт)

stranger_shaman
29.08.2025 12:31Во-первых буфер а не буфер. Во-вторых ttf это не формат текста, а формат описания шрифта от Эппл и Майкрософт. В-третьих факториал это математическая функция, а никакой не стандарт.
Прежде чем учить, сначала сами разберитесь во всем.

AndreyDmitriev
29.08.2025 12:31Для обучения ассемблера, кстати, могу смело порекомендовать Евро Ассемблер (euroassembler.eu). Это на самом деле офигенная, хотя и мало известная игрушка, причём написанная одним человеком из Чехии, который охотно отвечает на вопросы. Что удобно, это ассемблер и компоновщик в одном флаконе, написанный на себе самом.
Если хочется сделать минимальное консольное приложение под Windows для вычисления факториала, то кода будет всего ничего:
EUROASM AutoSegment=Yes, CPU=X64 fact PROGRAM Format=PE, Width=64, Model=Flat, ListMap=Yes, IconFile=, Entry=Start: INCLUDE winscon.htm, winabi.htm, cpuext64.htm Msg1 D "Enter Number:",0 Msg2 D "Factorial is ",0 Buffer DB 80 * B Start: nop StdOutput Msg1, Console=Yes StdInput Buffer ; Input Number LodD Buffer ; https://euroassembler.eu/maclib/cpuext64.htm#LodD (rax <- Num) mov rbx, 1 ; Initialize result to 1 .loop: imul rbx, rax ; Multiply rbx by rax dec rax ; Decrement rax cmp rax, 1 ; Check if rax <= 1 jg .loop ; If rax > 1, repeat loop mov rax, rbx ; Move result to rax StoD Buffer ; https://euroassembler.eu/maclib/cpuext64.htm#StoD (from rax) StdOutput Msg2, Buffer, Console=Yes TerminateProgram ENDPROGRAM factЯ код чутка поправил, так как два перехода тут не нужны совершенно.
Ассемблируется командой
euroasm.exe fact.asmПоходу генерит листинг, в котором все ходы расписаны
| |EUROASM AutoSegment=Yes, CPU=X64 | |fact PROGRAM Format=PE, Width=64, Model=Flat, ListMap=Yes, IconFile=, Entry=Start: |[.text] ::::Section changed. |00000000: | |00000000: |INCLUDE winscon.htm, winabi.htm, cpuext64.htm |00000000: * INCLUDE "./maclib/winscon.htm" |[.text] ::::Section changed. |00000000: * INCLUDE "./maclib/winabi.htm" |00000000: * INCLUDE "./maclib/cpuext64.htm" |00000000: | |[.data] ::::Section changed. |00000000:456E746572204E75~|Msg1 D "Enter Number:",0 |0000000E:466163746F726961~|Msg2 D "Factorial is ",0 |[.bss] ::::Section changed. |00000000:................~|Buffer DB 80 * B |00000050: | |[.text] ::::Section changed. |00000000:90 |Start: nop |00000001: | StdOutput Msg1, Console=Yes |00000018: | StdInput Buffer ; Input Number |0000002F: | LodD Buffer ; https://euroassembler.eu/maclib/cpuext64.htm#LodD (rax <- Num) |0000003B: | |0000003B:BB01000000 | mov rbx, 1 ; Initialize result to 1 |00000040: |.loop: |00000040:480FAFD8 | imul rbx, rax ; Multiply rbx by rax |00000044:48FFC8 | dec rax ; Decrement rax |00000047:4883F801 | cmp rax, 1 ; Check if rax <= 1 |0000004B:7FF3 | jg .loop ; If rax > 1, repeat loop |0000004D:4889D8 | mov rax, rbx ; Move result to rax |00000050: | |00000050: | StoD Buffer ; https://euroassembler.eu/maclib/cpuext64.htm#StoD |0000005E: | StdOutput Msg2, Console=Yes |00000075: | StdOutput Buffer, Console=Yes |0000008C: | TerminateProgram |0000009A: | | |ENDPROGRAM fact | **** ListMap "fact.exe",model=FLAT,groups=0,segments=5,entry=Start:,stack= | [.text],FA=00000400h,VA=00401000h,size=00000479h=1145,width=64,align=0010h,purpose=CODE+LITERAL | [.data],FA=00000A00h,VA=00402000h,size=0000001Ch=28,width=64,align=0010h,purpose=DATA | [.bss],FA=00000C00h,VA=00403000h,size=00000050h=80,width=64,align=0010h,purpose=BSS | [.idata],FA=00000C00h,VA=00404000h,size=00000173h=371,width=64,align=8,purpose=IMPORT+IAT | [.reloc],FA=00000E00h,VA=00405000h,size=00000030h=48,width=32,align=4,purpose=BASERELOC | **** ListGlobals "fact.exe",Global=0,Public=3,Extern=0,eXport=0,Import=8 | ExitProcess,[.idata]:0000016Ch,VA=0040416Ch,scope='I',lib="kernel32.dll" | GetStdHandle,[.idata]:00000150h,VA=00404150h,scope='I',lib="kernel32.dll" | LodD64@RT,[.text]:0000023Eh,VA=0040123Eh,scope='P' | ReadConsoleA,[.idata]:00000165h,VA=00404165h,scope='I',lib="kernel32.dll" | ReadConsoleW,[.idata]:0000015Eh,VA=0040415Eh,scope='I',lib="kernel32.dll" | ReadFile,[.idata]:00000157h,VA=00404157h,scope='I',lib="kernel32.dll" | Start,[.text]:00000000h,VA=00401000h,scope='P' | StoD64@RT,[.text]:000002CDh,VA=004012CDh,scope='P' | WriteConsoleA,[.idata]:00000142h,VA=00404142h,scope='I',lib="kernel32.dll" | WriteConsoleW,[.idata]:00000149h,VA=00404149h,scope='I',lib="kernel32.dll" | WriteFile,[.idata]:0000013Bh,VA=0040413Bh,scope='I',lib="kernel32.dll"На выходе исполняемый файл в три с половиной килобайта и работает:
>fact.exe Enter Number:6 Factorial is 720Умеет в AVX512, можно сделать DLL вместо консольного приложения. Использую время от времени для небольших набросков и тонкой настройки и бенчмаркинга узких мест в коде (после интрисиков). Эх, надо будет статью как-нибудь написать.
Из современных книжек могу порекомендовать Даниэль Куссвюрм — Профессиональное программирование на ассемблере x64 с расширениями AVX, AVX2 и AVX-512:

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

old_bear
29.08.2025 12:31Я код чутка поправил, так как два перехода тут не нужны совершенно.
По моим смутным воспоминаниям оригинальная версия более удобна для блока предсказания переходов. Впрочем для учебного примера и небольшого числа итераций это не особенно важно. Да и сам блок предсказания переходов умнеет от поколения к поколению.

AndreyDmitriev
29.08.2025 12:31Да, разные конструкции могут быть более или менее удобнее для предсказателя переходов, но в данном случае код формально будет работать максимум до 20 итераций.
Впрочем давайте проверим навскидку, пофиг на переполнение, тут работы на десять минут:
EUROASM AutoSegment=Yes, CPU=X64 benchm PROGRAM Format=PE, Width=64, Model=Flat, ListMap=Yes, IconFile=, Entry=Start: INCLUDE winscon.htm, winabi.htm, cpuext64.htm Msg0 D "Two Loops Benchmark",13,10,0 Msg1 D "Single Branch Ticks: ",0 Msg2 D "; Dual Branch Ticks: ",0 Buffer DB 80 * B N DQ 1_000_000_000 ; Amount of Iterations StartBench %MACRO cpuid ; force all previous instructions to complete (rax...rdx reset) rdtsc ; read time stamp counter mov edi, eax ; save EAX for later mov esi, edx ; save EDX for later %ENDMACRO StartBench EndBench %MACRO cpuid ; wait to complete before RDTSC rdtsc ; read time stamp counter again sub eax, edi ; subtract the most recent CPU ticks from the original CPU ticks sbb edx, esi ; now, subtract with borrow shl rax, 32 shrd rax, rdx, 32 ; ticks are in rax %ENDMACRO EndBench Start: nop StdOutput Msg0, Console=Yes ; ======== Single Branch Test ======== StartBench mov rbx, 1 ; Initialize result to 1 mov rax, [N] ; Amount of interations .loop1: imul rbx, rax ; Multiply rbx by rax dec rax ; Decrement rax cmp rax, 1 ; Check if rax <= 1 jg .loop1 ; If rax > 1, repeat loop EndBench StoD Buffer StdOutput Msg1, Buffer, Console=Yes ; ======== Dual Branch Test ======== StartBench mov rbx, 1 mov rax, [N] .loop2: cmp rax, 1 jle .done imul rbx, rax dec rax jmp .loop2 .done: EndBench StoD Buffer StdOutput Msg2, Buffer, Console=Yes TerminateProgram ENDPROGRAM benchmРезультат примерно такой:
>benchm.exe Two Loops Benchmark Single Branch Ticks: 1914967186; Dual Branch Ticks: 1894910657В общем да, небольшая разница есть, но почти гомеопатическая (если прогоны местами поменять, то тенденция та же, я проверил).
На самом деле тут можно от явного сравнения избавиться, так тоже будет работать, потому что декремент устанавливает флаг, который можно сразу использовать для условного перехода:
mov rbx, 1 ; Initialize result to 1 .loop1: imul rbx, rax ; Multiply rbx by rax dec rax ; Decrement rax jnz .loop1 ; If rax > 1, repeat loop mov rax, rbx ; Move result to raxОднако в современных реалиях времена, когда перестановкой команд можно было улучшить конвейеризацию или там помочь предсказателю переходов постепенно канули в лету, в основном производительность вытягивается векторизацией и многоядерностью. Я в прошлом году положил довольно много времени на то, чтобы сделать рабочий пример, показывающий эффект предвыборки (ну тот что PREFETCH) на XEON камушке да так и не смог явно и уверенно это продемонстрировать.

vadimr
29.08.2025 12:31Думаю, что в 2025 году учить систему команд Intel – пустая трата времени. Притом, что она сама по себе очень громоздкая и запутанная.

vadimr
29.08.2025 12:31Определение факториала в математике имеет совершенно другой смысл (семантику), чем программа для вычисления факториала в машинном коде. Это становится явно заметным для невычислимых функций, а также для тождеств вида
Само по себе математическое определение не подразумевает никаких вычислений или даже их потенциальной возможности.
На этапе рассуждений о Хаскеле автор обходит это обстоятельство, незаметно подменяя определение функции
factorial n = product [1..n]её применением
factorial 5Либо эту статью написал LLM, либо автор находится на его уровне разумности.
apevzner
Боюсь, что если из n вычитать 1, n будет скорее уменьшаться, чем возрастать...
atues
Там еще один потенциальный косяк: функция факториала определена только для неотрицательных целых (к слову, 0!=1). Если в вызывающем коде этой проверки нет, то не исключено, что в функцию будет передано отрицательное целое. И функция в том виде, как написано вернет 1.