Make Perl Great Again! Поиски простоты в программировании
Программирование само по себе очень сложная штука, и поэтому программировать надо как можно проще. Именно поиску простых способов программирования и посвящена эта статья.
Давайте я возьму какую‑то простую практическую задачу и решу ее, но не просто так, а как можно проще. Итак...
Задача: деление двух чисел
Надо написать программу, которая делит два числа и выводит результат. Казалось бы, ну что может быть проще…
# псевдокод 1
res = num1 / num2
print(res)
Вот и всё. Очень просто и понятно. Но это так называемый сферический конь в вакууме, ибо не имеет никакого отношения к действительности. Конечно это псевдокод, и он по определению нереальный, но всё‑таки надо бы оформить всё максимально по‑настоящему. Во‑первых, эти две строчки — это основная функция. Я может открою секрет, но даже в тех языках, в которых можно просто взять и написать программу как в псевдокоде 1, эти строки всё равно кем‑то вызываются как главная функция. Называется она по‑разному: где‑то это main() (ну или Main(), в общем всякие вариации со словом main), а где‑то её роль играет код верхнего уровня файла или модуля — но и его запускает не программист, а интерпретатор или среда. Это вообще отдельный разговор о том, что ты что‑то делаешь, а потом кто‑то твою работу доделывает. Или переделывает, или делает по‑другому, или делает не так, как ты хотел. Не буду углубляться в это, пока надо хотя бы просто знать что так происходит. А еще лучше самому контролировать. То есть сразу написать эту функцию main(). Во‑вторых, вот эти числа num1 и num2 надо откуда‑то взять, значит, надо добавить функцию, которая их инициализирует. Откуда именно — из аргументов командной строки, из файла, из сети — сейчас неважно. Важно, что это отдельная работа, и я вынесу её в функцию get_arg(). А в‑третьих, надо определить функцию print(). Конечно, чаще всего она уже так сказать встроена в стандартные библиотеки всех языков, но вот в моем воображаемом псевдоязыке ее пока нет, и я ее сделаю.
И сразу скажу о главном правиле этого псевдоязыка: показывать буду только то, что изменилось, — правда, иногда с окружением, чтобы пример читался целиком. Если же функция в псевдокоде не показана, значит в силе её версия из предыдущего псевдокода, где она встречалась. А если псевдокод показывает контрпример — как делать не надо, — то он действующей версией не становится.
Итак...
# псевдокод 2
fun print(arg) {
# куда-то как-то выводим arg
}
fun get_arg() {
arg = ??? # здесь каким-то образом откуда-то берется аргумент return arg
}
fun main() {
num1 = get_arg()
num2 = get_arg()
res = num1 / num2
print(res)
}Ну вот, программа растёт, и это плохо, ведь только начал, а она уже 15 строк! Но зато программа теперь точно запустится и будет что‑то делить и печатать. И это хорошо. А еще лучше то, что несмотря на многословность, программа всё так же проста и понятна.
Но если чуть‑чуть подумать, то оказывается не всё так просто. Ведь можно неожиданно вспомнить, что
делить на ноль нельзя!
То есть надо проверять делитель, не является ли он нулём?[1]
# псевдокод 3
fun main() {
num1 = get_arg()
num2 = get_arg()
if (num2 != 0) {
res = num1 / num2
print(res)
}
}Казалось бы, всё хорошо, но подождите... Вот пришло первое число, вот второе, если второе не ноль, то делим, получаем результат и выводим его. Ок. А если второе — ноль, то что? По тексту программы — ничего. Хорошо это или плохо? Плохо! То есть на самом деле даже непонятно, запускалась ли вообще программа.
Самое интересное во всём этом то, что предыдущий, менее продвинутый псевдокод 2, даже предпочтительнее, потому что умная операционная система обязательно скажет всё, что думает о делении на ноль.[2] И сразу станет ясно, что программа запускалась, и даже делила, но не смогла.
Что же делать? Самое очевидное — добавить else.
# псевдокод 4
fun main() {
num1 = get_arg()
num2 = get_arg()
if (num2 != 0) {
res = num1 / num2
print(res)
}
else {
print("на ноль делить нельзя")
}
}Ну вот, между прочим, получилась простая, понятная и отказоустойчивая программа[3]. Эээ… устойчивая ли? Нет. Потому что если ещё немного подумать, то можно понять: а вдруг num1 и num2 будут не числа? get_arg же может вернуть неизвестно что. И тогда надо сказать что‑то вроде
«эээ, а где число?»
и сделать… а что сделать? Прервать программу? Запросить число заново? А какое число? Первое? Или второе? Пока оставим эти праздные вопросы. Ясно, что надо сказать «эээ, а где число?» и что‑то сделать.
То есть программа должна вести себя примерно так: получить num1, проверить, является ли num1 числом[4], если нет, то сказать «эээ, а где число?» и что‑то сделать, если да, то получить num2, проверить, является ли num2 числом, если нет, то сказать «эээ, а где число?» и что‑то сделать, если да, то проверить num2 != 0, если да — поделить, если нет — сказать «на ноль делить нельзя». Программно это выглядит так:
# псевдокод 5
fun main() {
num1 = get_arg()
if (num1 не число) {
print("эээ, а где число?")
# Здесь пока не выбрано, что делать дальше.
}
else {
num2 = get_arg()
if (num2 не число) {
print("эээ, а где число?")
# Здесь пока не выбрано, что делать дальше.
}
else {
if (num2 != 0) {
res = num1 / num2
print(res)
}
else {
print("на ноль делить нельзя")
}
}
}
}Нда... Простая, понятная и НЕотказоустойчивая программа стала отказоустойчивой, но НЕпонятной. Посмотрите на эту лесенку отступов: это три уровня вложенных if, а самая глубокая строка стоит уже на четвёртом уровне отступа. Конечно, если всмотреться, то всё понятно, но ведь чисел могло быть больше, логика могла быть сложнее, и если бы подобный код растянулся на пару‑тройку экранов, понять, что к чему, было бы уже непросто.
Это так называемый спагетти‑код. Да, я знаю, что этот термин чаще применяют к колбэкам, но до них ещё далеко, а мешанина из логики неожиданно возникла уже сейчас.[5]
Что же делать? Нет ли какого‑то способа упростить?
Давайте поймем, откуда взялась эта феерия отступов? Для чего она? Какую задачу решает? Что привело к ее появлению? Сначала мне надо было проверить не является ли num2 нулём. И тогда я ввел в программу первый if. Это видно по псевдокоду 3, в котором и появился первый отступ. Далее я начал проверять являются ли num1 и num2 числами, и там уже if‑ы стали вложенными. А почему они стали вложенными? Нельзя ли было их располагать друг за другом, а не друг в друге?
Попробую.
# псевдокод 6
fun main() {
num1 = get_arg()
if (num1 не число) {
print("эээ, а где число?")
# Здесь пока не выбрано, что делать дальше.
}
num2 = get_arg()
if (num2 не число) {
print("эээ, а где число?")
# Здесь пока не выбрано, что делать дальше.
}
if (num2 != 0) {
res = num1 / num2
print(res)
}
else {
print("на ноль делить нельзя")
}
}Что произойдет, если я запущу этот код и введу вместо первого числа какую‑нибудь абракадабру? Программа радостно напечатает «эээ, а где число?», выйдет из блока if... и просто пойдёт выполняться дальше! Она запросит num2, а затем попытается поделить эту абракадабру на это второе значение.
Избавившись от else, я потерял механизм изоляции. В псевдокоде 5 лесенка if-else работала как шлагбаум: если условие выполнялось, программа попадала в ветку с ошибкой, а основной алгоритм оставался спрятан в else и не выполнялся. В псевдокоде 6 шлагбаумов нет: программа просто выводит предупреждение и шагает прямиком навстречу катастрофе.
То есть отличие в том, что псевдокод 5 при любой ошибке завершал работу программы. Стоп!
Ошибке? Какой такой ошибке?
Давайте разбираться. В чем был смысл первой проверки на ноль: чтобы программа не делила на ноль, потому что при делении на ноль происходит ошибка. Вернее, как очень любят говорить в среде с/с++, неопределенное поведение. Я человек простой, во всю эту философскую мутоту лезть не буду, мне такое поведение не надо, просто буду считать его ошибкой. На ноль делить нельзя и всё. То есть первый if оберегал программу от ошибки деления на ноль, или говоря программистским языком перехватывал эту ошибку. При нуле вместо деления, он говорил «на ноль делить нельзя» и завершал программу. На самом деле это не очевидно, справедливости ради надо сказать, что программа просто заканчивалась сама. Но давайте посмотрим на вторую проверку.
Вторая проверка (на то, является ли num1 или num2 числом) преследует ту же цель — она оберегает программу от бессмысленных математических операций с текстом. В псевдокоде 5 эта проверка отлично справлялась: при ошибке программа просто не доходила до основного кода (спрятанного в else) и мирно завершалась сама, дойдя до конца блока.
Но в псевдокоде 6 я убрал спасительный else. Теперь программа выводит сообщение об ошибке ввода и продолжает свою работу, в конечном итоге вызывая неадекватное поведение при попытке деления строк.
Как вернуть этому красивому плоскому коду логику безопасного завершения? Нужно явно объяснить программе: «если данные неверны, немедленно прекращай работу и выходи из функции». Для этого в языках программирования придуманы операторы раннего выхода (обычно это return, чтобы досрочно выйти из функции, или exit, чтобы убить всю программу целиком).
Меняю код, используя досрочный выход return. Заодно немного изменю проверку деления на ноль, чтобы она работала в том же стиле.
# псевдокод 7
fun main() {
num1 = get_arg()
if (num1 не число) {
print("эээ, а где число?")
return
}
num2 = get_arg()
if (num2 не число) {
print("эээ, а где число?")
return
}
if (num2 == 0) {
print("на ноль делить нельзя")
return
}
res = num1 / num2
print(res)
}Посмотрите на этот псевдокод. В нём нет никаких пугающих лесенок вложенности. Он читается ровно сверху вниз. Каждое условие работает как строгий охранник (в программировании этот приём так и называется — guard clauses или охранные выражения). Охранник проверяет данные: если что‑то не так, он выводит ошибку и сразу же пресекает работу функции с помощью return. А если данные в порядке, он пропускает дальше к следующим строкам.
Этот приём с ранним возвратом — один из самых мощных инструментов для борьбы со спагетти‑кодом. Он возвращает желанную простоту и линейность алгоритма.
Всё хорошо, кроме повторяемый код, и я как любой нормальный программист должен захотеть вынести его в отдельную функцию. Ведь так? Конечно нет, но ладно... вынесу. Вот эту проверку на число видимо надо перенести в функцию get_arg
©ложный путь рефакторинга
И это одно из самых ужасных действий в программировании: я возвращаюсь к функции, которая была написана когда‑то давно, аж в псевдокоде 2! и уже благополучно забыта. То есть я беру код get_arg и добавляю туда проверку на число, типа вот так:
# псевдокод 8
fun get_arg() {
arg = ??? # функция, которая каким-то образом откуда-то берет аргумент
if (arg не число) {
print("эээ, а где число?")
return
}
else {
return arg
}
}Казалось бы, не так уж и страшно, да? Стоп! Никогда не делайте так. То, что я сделал, — это очень плохой рефакторинг. Я бы даже сказал реfuckторинг. Дело в том, что прежний get_arg на самом деле был вполне хорош, если считать, что то, что прячется за комментарием, не содержит ошибок. get_arg со своей задачей — что‑то как‑то откуда‑то получить и это вернуть — отлично справляется. А проверки на соответствие ожиданиям — это проблемы кого‑то другого. Интересно, кого? Конечно же, другой функции. Назову её get_number.
А псевдокод 8 запомним как контрпример: он показывает, как делать не надо, и действующей версией get_arg не становится — в силе остаётся прежняя. И теперь программа становится такой:
# псевдокод 9
fun get_number() {
value = get_arg()
if (value не число) {
print("эээ, а где число?")
return
}
else {
return value
}
}
fun main() {
num1 = get_number()
num2 = get_number()
if (num2 == 0) {
print("на ноль делить нельзя")
return
}
res = num1 / num2
print(res)
}Также я вынесу и деление в отдельную функцию divide
# псевдокод 10
fun divide(num1, num2) {
if (num2 == 0) {
print("на ноль делить нельзя")
return
}
return num1 / num2
}
fun main() {
num1 = get_number()
num2 = get_number()
res = divide(num1, num2)
print(res)
}Ну что? Красота? Нет. Там есть ошибка из‑за которой псевдокод 10 не идентичен по поведению псевдокоду 7
В псевдокоде 7 оператор return находился внутри main(). Когда встречалась ошибка, происходил немедленный выход из главной функции — программа безопасно завершалась.
А теперь посмотрите на псевдокоды 9 и 10. Я вынес проверки в отдельные функции get_number() и divide(). И теперь, если пользователь ввёл буквы вместо числа, функция get_number() печатает сообщение об ошибке, делает return... и просто возвращает «пустоту» (в языках программирования это null, undefined или None) обратно в main!
Функция main об этом совершенно ничего не знает. Она записывает эту пустоту в num1 и продолжает выполнение как ни в чём не бывало. Запрашивает num2, а затем передаёт эти пустоты в divide(). Выделив проверки в функции, я сломал «ранний выход» и получил программу‑зомби, которая пережёвывает неверные данные.
И даже на честном нуле всё не так гладко. В псевдокоде 10 divide печатает «на ноль делить нельзя» и возвращает пустоту, а main всё равно выполняет print(res) — и после сообщения об ошибке на экран уходит ещё и пустая строка. В псевдокоде 7 такого не было: программа просто завершалась, ничего лишнего не печаталось.
Чтобы поведение снова стало идентично безопасному псевдокоду 7, return в функциях get_number и divide надо поменять на exit, то есть выход не из функции, а вообще из программы, что фактически и было в псевдокоде 7.
# псевдокод 11
fun get_number() {
value = get_arg()
if (value не число) {
print("эээ, а где число?")
exit
}
return value
}
fun divide(num1, num2) {
if (num2 == 0) {
print("на ноль делить нельзя")
exit
}
return num1 / num2
}
fun main() {
num1 = get_number()
num2 = get_number()
res = divide(num1, num2)
print(res)
}Казалось бы, вот оно — идеальное решение проблемы обработки ошибок! Плоско, линейно, безопасно. Единственный косяк, что при каждой ошибке программа просто вылетает. Хорошо ли это?
Мне кажется неудобно. Было бы неплохо перезапрашивать число.
Меняю ТЗ: пусть теперь при неправильном вводе числа программа говорит «некорректное число, введите заново» и перезапрашивает его. То есть была программа‑грубиян, которая говорила «эээ, а где число?», а теперь будет культурная программа. Сразу оговорю: ноль — это корректное число, а не ошибка ввода, поэтому перезапрос его не касается. При делении на ноль программу по‑прежнему убивает divide, и это ровно то судьбоносное решение низкоуровневой функции, о котором пойдёт речь ниже.
Что для этого надо? Надо переписать код get_number, чтобы при ошибке он вызывал себя рекурсивно, тем самым перезапрашивая число.
# псевдокод 12
fun get_number() {
value = get_arg()
if (value не число) {
print("некорректное число, введите заново")
return get_number()
}
else {
return value
}
}
fun main() {
num1 = get_number()
num2 = get_number()
res = divide(num1, num2)
print(res)
}Ну вот, наконец‑то получилась простая понятная и отказоустойчивая к неверному вводу программа.
Но присмотритесь повнимательнее к функции get_number, в которой я использовал рекурсию: если число неверное, функция вызывает саму себя и возвращает свой же результат через return get_number(). Код выглядит элегантно, и, казалось бы, проблема решена. Но что будет, если пользователь назло введёт неверное число 100 500 раз подряд?
Каждый новый вызов get_number остаётся в памяти, ожидая завершения следующего. Рано или поздно память переполнится, и программа рухнет с ошибкой переполнения стека (Stack Overflow). (Оговорка ради точности: в языках с оптимизацией хвостовой рекурсии — например, Scheme, Lua, Elixir — компилятор превращает такой рекурсивный вызов в обычный цикл, и стек не растёт. Но в большинстве популярных языков, включая JavaScript, Python и Perl, такой гарантии нет.) Но даже если я исправлю это, заменив рекурсию на обычный бесконечный цикл while внутри функции, останется куда более глубокая проблема.
Вот в чем она заключается: Я поменял функцию get_number, и поменялось поведение всей программы! Еще раз повторю — постарайтесь понять эту мысль правильно — я ничего не менял в основной функции программы main, я изменил код в рядовой функции get_number, а поменялось поведение всей программы. Таким образом получается, что функция get_number главнее main. Косяк ли это?
На самом деле, по большому счету — я не знаю. Это вопрос философский. Я бы даже сказал философический. То есть нет правильного ответа на все случаи жизни. Но в большинстве архитектур считается плохим тоном, когда низкоуровневая утилита (вроде get_number или divide) сама принимает судьбоносные решения: убивать ли всю программу через exit, уходить ли в рекурсию или выводить тексты на экран.
Функция get_number не знает контекста. Сегодня я пишу скрипт для терминала, и переспросить пользователя — это хорошая идея. А завтра эта же функция будет вызвана внутри веб‑сервера, где некому отвечать на консольные запросы, и нужно просто отдать клиенту ошибку 400.
Главной всегда должна оставаться функция main (или вызывающий код). Именно main видит весь сценарий работы и должна решать, что делать при ошибке. В конце концов get_number буквально означает «дай число», а не «убей программу»:)).
Но как функции get_number сообщить наверх о проблеме, не принимая решений самостоятельно? Ответим на это в следующей части: Продолжение следует:
Часть 2: Всё ещё сложнее, чем кажется
За десятилетия развития программирования разработчики придумали несколько основных способов
как функции сообщить о проблеме наверх
Досрочный выход exit я уже рассмотрел, поэтому ниже разберу другие классические способы.
Напомню правило чтения псевдокодов: если функция в псевдокоде не показана, значит в силе её версия из предыдущего псевдокода, где она встречалась.
1. Глобальная переменная состояния
Самый древний способ. Функция пытается выполнить работу. Если что‑то идёт не так, она записывает код ошибки в специальную глобальную переменную и возвращает фиктивное значение. А в main есть бесконечный цикл while, который проверяет эту переменную и, если нужно, повторяет запрос.
# псевдокод 13
global_error = 0
fun get_number() {
value = get_arg()
if (value не число) {
global_error = 1
return 0 # Возвращаем фиктивное значение
}
global_error = 0
return value
}
fun main() {
num1 = 0
while (true) {
num1 = get_number()
if (global_error == 0) break
print("некорректное число, введите заново")
}
# ... аналогичный цикл для второго числа и проверка деления
}Этот подход работает, но у него масса проблем: глобальную переменную легко случайно перезаписать, её нельзя безопасно использовать при многопоточности, а программист может попросту забыть проверить global_error — и снова получить зомби‑программу.
2. Возврат ошибки (значение и признак ошибки)
Вместо глобальной переменной функция может возвращать сразу два значения: сам результат и информацию об ошибке. Этот подход популярен в языках вроде Go. Отсутствие значения я дальше буду обозначать словом «пустота» — в реальных языках эту роль играют null, undefined или None.
# псевдокод 14
fun get_number() {
value = get_arg()
if (value не число) {
return (пустота, "введено не число")
}
return (value, пустота)
}
fun main() {
num1 = 0
while (true) {
(val, err) = get_number()
if (err == пустота) {
num1 = val
break
}
print("некорректное число, введите заново")
}
# ... аналогично для num2
}Это гораздо надёжнее, так как err прилетает явно. Однако main начинает раздуваться: после каждого вызова функции приходится писать циклы и проверки результата (if (err == пустота)). Красивая плоская программа превращается в громоздкий частокол из логики проверок.
3. Исключения (Exceptions)
Чтобы не проверять каждую ошибку вручную, придумали механизм исключений (try / catch). Функция «выбрасывает» ошибку. Выполнение летит наверх, пока catch её не поймает.
# псевдокод 15
fun get_number() {
value = get_arg()
if (value не число) {
throw Error("некорректное число")
}
return value
}
fun main() {
num1 = 0
while (true) {
try {
num1 = get_number()
break # Если дошли сюда, ошибки нет, выходим из цикла
} catch (err) {
print(err)
}
}
# ... аналогично для num2
}Код внутри try снова выглядит линейно и просто. Но за эту магию приходится платить. Исключения скрывают, куда именно уйдёт управление: любая строчка потенциально может прервать выполнение, уводя поток в неизвестном направлении.
Кроме того, у исключений есть своя цена — правда, платить её приходится в момент ошибки, а не в обычной работе. В большинстве современных реализаций (например, C++) путь выполнения без ошибок почти ничего не стоит: этот принцип называют «zero‑cost exception handling». Но когда происходит throw, программа начинает так называемую раскрутку стека (stack unwinding): она бежит назад по истории вызовов, уничтожая локальные переменные и пытаясь найти ближайший catch. Для этого среда исполнения хранит дополнительные таблицы и контекст, а сама раскрутка — дорогая операция. Поэтому исключения хороши, когда ошибки редки, и плохи, когда ошибки случаются часто.
4. Колбэки (Callbacks)
Ещё один способ вернуть контроль в main — это передать в get_number функцию‑инструкцию, которая говорит, что именно нужно сделать при ошибке.
# псевдокод 16
fun get_number(on_success, on_error) {
value = get_arg()
if (value не число) {
on_error("некорректное число")
} else {
on_success(value)
}
}
fun divide(num1, num2, on_success, on_error) {
if (num2 == 0) {
on_error("на ноль делить нельзя")
} else {
on_success(num1 / num2)
}
}
fun main() {
fun ask_num1() {
get_number(
fun(num1) {
fun ask_num2() {
get_number(
fun(num2) {
divide(num1, num2,
fun(res) {
print(res)
},
fun(err) {
print(err)
exit
}
)
},
fun(err) {
print(err)
ask_num2()
}
)
}
ask_num2()
},
fun(err) {
print(err)
ask_num1()
}
)
}
ask_num1()
}И тут происходит полный ужас. Программа окончательно превратилась в спагетти‑монстра. Такая структура называется «callback hell» (ад колбэков). Читать это абсолютно невозможно, вложенность растёт вправо с каждой новой операцией. Замечу также, что повторные запросы и здесь реализованы рекурсией (ask_num1 и ask_num2 вызывают сами себя), так что проблема переполнения стека никуда не делась.
Какой‑то замкнутый круг. Я хочу, чтобы главная функция управляла поведением, но при этом оставалась линейной и читаемой.
Храню код ошибки в глобальной переменной — получаю риск, что забуду её проверить.
Возвращаю ошибки — получаю частокол ручных проверок
if / else.Использую
try / catch— получаю линейность, но теряю контроль и прозрачность.Использую колбэки — попадаю в нечитаемый ад вложенности.
Кажется, что простого и одновременно безопасного пути не существует. Может, всё‑таки есть ещё какие‑то другие способы?
Другие пути обработки ошибок
Да, помимо этих классических способов, есть ещё парочка более экзотических подходов к управлению ошибками. Разберу их кратко.
1. Система рестартов (Common Lisp) В Common Lisp есть встроенная система условий и «рестартов» (вариантов восстановления). Вместо того чтобы просто провалиться с ошибкой, нижняя функция замирает на месте и предлагает main список вариантов, что делать дальше. main может выбрать вариант «перезапросить ввод», и функция продолжит работу с того же самого места, не ломая стек.
2. Философия «Пусть падает» (Erlang / Elixir) В языках типа Erlang программы делятся на тысячи крошечных изолированных процессов. Там вообще не принято писать проверки на ошибки (если это не бизнес‑логика). Если функция получает текст вместо числа, процесс просто мгновенно «умирает». Но за ним следит другой процесс («супервизор»), который тут же перезапускает упавшего с нуля. Это идеально для серверов, но не подходит для маленькой программки.
Ндааа... как‑то незаманчиво, да? Неужели нет никакого способа совместить простоту, линейность и безопасность? Погодите, есть же ещё ООП и ФП! Давайте взглянем на две главные парадигмы программирования и сделаем всё максимально по феншую.
Путь ООП
Одна из сил ООП — инварианты, которые перехватывают ошибки. Давайте разберём, что это за инварианты такие.
Инвариант — это класс, у которого есть правило и оно всегда истинно: не «иногда» и не «если программист не забыл», а «пока объект этого класса существует — правило выполнено».
В текущей задаче есть две проверки, которые можно взять за правила и уложить их в понятие инварианта:
Проверка на число. Создаю класс
Numberс правилом «внутри всегда число». Единственный вход — конструктор, и нечисло он не пропускает. Значит, любому коду дальше по тексту программы не нужна проверка if (value не число): такой ситуации просто не может быть.Проверка на ноль. Аналогично создаю класс
NonZero, и у него будет правило «внутри никогда не ноль». Конструктор не даёт построить объект из нуля, поэтомуdivideфизически не получит делитель‑ноль и может не содержать ни одной ветки с проверкой ошибки деления на ноль.
Хороший инвариант тянет за собой следующий. В моей задаче, например, инварианты вкладываются друг в друга. NonZero — это уже Number + ещё одно правило. Так из одного маленького обещания вырастает следующее, и каждое сужает множество допустимых значений: «что‑то» → «число» → «ненулевое число».
Вот так это будет в коде:
# псевдокод 17 (ООП)
class Number {
value
# Конструктор — единственный вход: нечисло внутрь не пройдёт
fun Number(raw) {
if (raw не число) {
throw Error("введено не число")
}
this.value = raw
}
}
# Второй инвариант: из нуля такой объект не построить
class NonZero {
value
fun NonZero(number) {
if (number.value == 0) {
throw Error("делитель равен нулю")
}
this.value = number.value
}
}
# Только читает и упаковывает. Ничего не решает:
# не печатает, не повторяет ввод, не завершает программу
fun get_number() {
return new Number(get_arg())
}
# Принимает проверенные числа: делитель гарантированно ненулевой,
# поэтому ветки с ошибкой здесь нет вообще
fun divide(num1, num2) {
return num1.value / num2.value
}
fun main() {
# Решения — здесь: циклы повтора, тексты и завершение
while (true) {
try {
num1 = get_number()
break
} catch (err) {
print("некорректное число, введите заново")
}
}
while (true) {
try {
num2 = get_number()
break
} catch (err) {
print("некорректное число, введите заново")
}
}
try {
divisor = new NonZero(num2)
} catch (err) {
print("на ноль делить нельзя")
exit
}
print(divide(num1, divisor))
}В этой версии get_number делает только то, что обещает её имя: читает значение и упаковывает его в Number. Внутри нет ни print, ни циклов, ни exit, ни рекурсии — она не решает, что делать с ошибкой, а сообщает о ней наверх. Тип Number отвечает за то, что значение — число, тип NonZero — за то, что делитель не ноль; divide получил второй инвариант и стал тотальным: он больше не умеет падать. Все решения принимает main: он печатает «некорректное число, введите заново» и «на ноль делить нельзя», повторяет ввод обычными циклами while и завершает программу. Проверим по пунктам ТЗ:
«некорректное число, введите заново» и перезапрос — циклы в
main(для обоих чисел);ноль — корректное число:
Numberпринимает0, перезапроса не будет, а проверка на ноль сработает позже, при построенииNonZero;“на ноль делить нельзя” —
NonZeroне строится из нуля,mainловит это, печатает ровно текст ТЗ и завершает программу;результат — печатает
main.
Стало ли лучше? Да, но не там, где идёт спор. Спор — о том, чтобы main управлял поведением и при этом оставался линейным и читаемым. Контроль в main уже был, а линейностью и не пахнет: механизм ошибок остался прежним (исключения), а политика повтора снова целиком в main — вернулся тот самый частокол, что и в способах 2 и 3, и даже вырос на один блок: два цикла ввода плюс стража вокруг NonZero. Зато появились настоящие гарантии: невалидное число в программе просто не может появиться, делитель не может оказаться нулём, а перепроверить вход никому не придёт в голову — это не «не забыть», а «негде забыть». Обмен честный: за два новых типа — арифметика без единой проверки и без единой ветки с ошибкой. Так что в управлении потоком ООП ничего не меняет: решения по‑прежнему приходится писать в main вручную. Зато оно отвечает на другой вопрос — «как не пустить ошибку в данные»: чем уже инвариант, тем меньше проверок остаётся во время работы. Для данной игрушечной задачи выигрыш от этого почти нулевой, но в большой программе именно на таких гарантиях ООП и стоит.
И у ООП есть свой сахар — цепочки вызовов. Если каждый метод возвращает объект, а не меняет состояние на месте, вызовы сцепляются в одну строку: a().b().c(). Промежуточные переменные исчезают, а с ними — и половина поводов написать if. В языках с безопасной навигацией (?. в C# и Kotlin) цепочка вдобавок обрывается на пустоте вместо падения: проверки не отменены, но писать их программисту больше не нужно. И проверок внутри неё не требуется ровно потому, что divide стал тотальным: падать на середине ему уже нечем.
Но в цикл цепочку не вытянуть: она описывает один удачный проход. Повтор ввода, текст ошибки и решение о завершении по‑прежнему остаются в main.
Путь ФП
В отличие от ООП функциональное программирование (ФП) не любит исключения, потому что они непредсказуемо рвут поток выполнения. В ФП функции вместо исключений возвращают ошибки как результат, вернее, они возвращают некий контейнер, который может содержать в себе как нормальный результат работы функции, так и ошибку. Дальше программа обрабатывает этот контейнер и в зависимости от того, что там находится, принимает решение, что делать.
# псевдокод 18 (ФП)
# Обозначения псевдоязыка:
# успех(значение) — операция удалась,
# ошибка(текст) — операция не удалась,
# пустота — отсутствие значения,
# Только читает и упаковывает. Ничего не решает:
# не печатает, не повторяет ввод, не завершает программу
fun get_number() {
value = get_arg()
if (value не число) {
return ошибка("введено не число")
}
return успех(value)
}
# Политику не принимает: сообщает ровно то, что знает
fun divide(num1, num2) {
if (num2 == 0) {
return ошибка("на ноль делить нельзя")
}
return успех(num1 / num2)
}
fun main() {
while (true) {
res = get_number()
if (res это успех) {
num1 = res.значение
break
}
print("некорректное число, введите заново")
}
while (true) {
res = get_number()
if (res это успех) {
num2 = res.значение
break
}
print("некорректное число, введите заново")
}
res = divide(num1, num2)
if (res это ошибка) {
print(res.текст)
exit
}
print(res.значение)
}Давайте разберём, что здесь происходит. В «чистом» функциональном подходе функция не должна возвращать просто число или выбрасывать исключение. Она всегда возвращает специальный объект‑коробочку: либо успех(значение), либо ошибка(текст).
Выглядит проще, чем ООП. Во что обходится такая простота на самом деле?
Многие думают, что этот подход работает быстрее и «легче» исключений. На самом деле всё зависит от языка и реализации. В одних языках коробочка — знаменитая «zero‑cost abstraction»: она не создаётся в куче, а живёт на стеке, скрытая проверка if (res это ошибка) стоит копейки и часто вообще выбрасывается оптимизатором (так устроен, например, Result в Rust). В других языках коробочка — это полноценный объект в памяти, и тогда каждое действие порождает создание структуры, скрытую проверку и извлечение данных. Исключения же, как я разбирал выше, ничего не стоят, пока ошибка не произошла, но дорого обходятся в момент throw. Сравнение получается зеркальным: коробочка дёшева всегда, но понемногу; исключения бесплатны на обычном пути и дороги в момент ошибки. Выбор зависит от того, как часто ошибки случаются в вашей задаче.
Что же в сухом остатке? Механизм ошибок остался прежним (Result), а политика повтора снова целиком в main — вернулся тот же частокол из двух циклов, что и в ООП‑варианте. Зато видно, в чём ФП действительно сильнее: ошибка стала обычным значением. Result — это тип: ошибка стоит в сигнатуре функции, и компилятор заставит программиста её обработать. Это не «не забыть обработать ошибку», а «нельзя её проигнорировать». ФП отвечает не на вопрос «кто принимает решение при ошибке», а на вопрос «как передать ошибку наружу, не потеряв её».
Но история ФП на коробочках не закончилась. Программисты заметили: ручная распаковка каждого результата — это шум, и придумали, как его спрятать: монадические цепочки. Идея цепочки простая. Операции связываются так, что следующая вызывается, только если предыдущая вернула успех; а если где‑то вылезла ошибка, цепочка останавливается и возвращает эту ошибку наружу как есть. В разных языках это называется and_then, flat_map или bind, но суть одна: «продолжай, пока всё хорошо».
Оператор ? в Rust — это та же самая цепочка, только вшитая в синтаксис языка. Запись num = get_number()? означает: «если внутри успех — достань значение и иди дальше, а если ошибка — немедленно верни её из текущей функции». Проверок if в коде больше не видно, но они никуда не делись: их пишет за вас компилятор.
Тогда хвост main из псевдокода 18 можно записать вот так:
# одна попытка целиком — цепочкой, без единого if:
num1 = get_number() ? # успех — распаковать, ошибка — вернуть наружу
num2 = get_number() ? # то же самое со вторым числом
res = divide(num1, num2) ?
print(res)
Красиво? Бесспорно. Но заметьте, чего здесь нет: циклов повтора, текстов «введите заново» и решения о завершении программы. Кто напечатает сообщение и решит, спрашивать ли число ещё раз? Опять main — цепочка и ? убирают шум проверок, но политику повтора оставляют там, где она была. Так что вывод раздела остаётся в силе: ФП отвечает на вопрос «как передать ошибку наружу, не потеряв её», а вопрос «кто принимает решение при ошибке» по‑прежнему открыт.
Общий итог
Как видите, программирование — это постоянный поиск компромисса. Идеального решения не существует. Каждый язык и каждая парадигма предлагает свой способ управлять сложностью, но полностью избавиться от неё невозможно. Главное — понимать, какую цену вы платите за выбранный вами инструмент.
Нравится вам такое программирование? Наверняка да — если бы не нравилось, вы бы давно придумали что‑то другое.
А вот мне не нравится. И я ищу, до сих пор ищу... И кажется, нашел.
Часть 3: Рождение нового стиля
Кстати, заметили? В последних псевдокодах, начиная с 13-го, вообще не идёт речь о простоте и понятности, там куча разговоров о механизмах обработки ошибок, «чистоте кода», строгом «контроле над потоком выполнения», архитектурных паттернах, сокрытии состояния и монадических цепочках. Всё очень очень сложно...
Напомню правило чтения псевдокодов: если функция в псевдокоде не показана, значит в силе её версия из предыдущего псевдокода, где она встречалась.
Последний простой псевдокод — 12-й, давайте вспомним его. Приведу полный вариант
# псевдокод 12.full
fun print(arg) {
# куда-то как-то выводим arg
}
fun get_arg() {
arg = ??? # здесь каким-то образом откуда-то берется аргумент
return arg
}
fun get_number() {
value = get_arg()
if (value не число) {
print("некорректное число, введите заново")
return get_number()
}
else {
return value
}
}
fun divide(num1, num2) {
if (num2 == 0) {
print("на ноль делить нельзя")
exit
}
return num1 / num2
}
fun main() {
num1 = get_number()
num2 = get_number()
res = divide(num1, num2)
print(res)
}В нём всё прекрасно, кроме того, что он работает неправильно%)). Напомню о его ошибках: Во‑первых, рекурсия в get_number при многократном неправильном вводе приведёт к переполнению стека вызовов (Stack Overflow). Во‑вторых, get_number жёстко зашивает в себя решение о печати и повторном запросе, отбирая контроль у main. Эту функцию больше нельзя переиспользовать в другом контексте (например, на веб‑сервере).
Пытаясь решить эти ошибки и вернуть контроль функции main, я пустился во все тяжкие. Я возвращал коды ошибок (получив частокол проверок if/else), выбрасывал исключения (скрыв поток выполнения), пробовал колбэки (провалившись в ад вложенности) и заходил со стороны ООП: получил типы, которые не пускают мусор, но платой снова стал частокол — на этот раз в main. ФП‑подход с контейнером Result тоже не отменил эту работу: ошибка стала значением, появилась статическая гарантия, что её нельзя проигнорировать, но повтор ввода всё равно остался в main. Правда, цена Result зависит от языка: в Rust он живёт на стеке и почти ничего не стоит, а в языках с объектами в куче — каждая операция порождает новую структуру. От реальной сложности это не избавляет.
А ведь все эти танцы с бубнами призваны всего лишь изменить порядок выполнения функций, чтобы при ошибке выполнилось что‑то другое. Ах, как было бы прекрасно просто построчно идти по функции main из псевдокода 12 и каким‑то волшебным образом без всяких if исполнять всё что задумали.
Так... ещё раз внимательно гипнотизирующе смотрим на функцию main
fun main() {
num1 = get_number()
num2 = get_number()
res = divide(num1, num2)
print(res)
}Чо тут происходит? По сути идет последовательный вызов функций:
get_number()
get_number()
divide()
print()В такой записи потерялась связь между функциями, что куда передается. Поэтому можно переписать вот так, чтобы сохранить эту связь
# псевдокод 12.1 (main одной строкой)
fun main() {
print(divide(get_number(), get_number()))
}Вы наверное мне не поверите, если я скажу, что это великолепие, совершенно естественное для восприятия современного программиста, на самом деле является абсолютно противоестественным с точки зрения обыкновенной логики. Посмотрите, первой записана функция, которая выполнится последней.
Кстати, здесь есть ещё более интересная противоестественность: в некоторых языках порядок вычисления аргументов не гарантирован, то есть может перепутаться делимое и делитель!
Но не будем о грустном, потому что у меня для вас есть ещё более прикольная фраза: гораздо естественнее записать это в обратной польской записи%))
# псевдокод 12.2 (обратная польская запись)
fun main() {
get_number() get_number() divide() print()
}Обратная польская запись (ОПЗ) — это математический способ записи, в которой аргументы (операнды) всегда располагаются перед функцией (оператором), которая будет с ними работать. Например, вместо привычного 2 + 3 надо писать 2 3 +. А выражение print(divide(10, 5)) превращается в 10 5 divide print. В такой записи больше не нужны скобки, а вычисления всегда идут линейно — строго слева направо. Самое главное в этом то, что аргументы и функции идут в одном списке. Результат работы функции попадает в этот же список замещая функцию.
Минус ОПЗ в том, что это абстракция, сферический конь... ну вы в курсе, я вам уже это объяснял. Надо привести всё к реальности. То есть надо всего лишь написать некую функцию, которая будет работать со списком, и запускать на выполнение функции, которые в нем лежат, принимать их результаты, помещать их обратно в список, и снова запускать функции, передавая им предыдущие результаты, если они есть, как аргументы. Я назову эту функцию flow, будет это выглядеть примерно так:
# псевдокод 12.3 (main через flow)
fun main() {
flow(get_number(), get_number(), divide(), print())
}Элементарно. Осталось только реализовать её flow.
Итак... что она будет делать: она идет по массиву и если элемент массива — функция, то запускает ее на исполнение, если же это не функция, то значит это аргумент и его надо положить в список аргументов, когда flow доедет до функции, она запустит функцию передав ей аргументы. Это один аспект. Второй, запущенная функция может вернуть результаты, и они должны стать аргументами последующих функций, как например divide возвращает результат деления и далее передает его функции print на печать. То есть flow перехватывает результат выполнения функции и кладет его в тот же список аргументов. Третий аспект, функции могут быть переданы аргументы, которые ей не нужны, это так называемые транзитные аргументы, они возможно будут нужны не этой функции, а другим функциям, которые идут дальше в потоке. Четвертый аспект: важно понимать, что список аргументов растет и уменьшается справа. Этот момент надо разобрать отдельно, посмотрим по шагам, как элементы переезжают из потока в аргументы:
# схема (как элементы переезжают из потока в аргументы)
поток [ get_number, get_number, divide, print_result ] аргументы [ ]
│
▼ get_number — функция! вызываем её: входящих данных ей не нужно
поток [ get_number, divide, print_result ] аргументы [ ]
│
▼ get_number вернула 10 → результат возвращается в поток
поток [ 10, get_number, divide, print_result ] аргументы [ ]
│
▼ 10 — не функция, значит это данные → в аргументы
поток [ get_number, divide, print_result ] аргументы [ 10 ]
│
▼ get_number — функция! вызываем её с аргументами [ 10 ]
поток [ divide, print_result ] аргументы [ 10 ]
│
▼ get_number вернула 5 → результат возвращается в поток
поток [ 5, divide, print_result ] аргументы [ 10 ]
│
▼ следом возвращается остаток — транзитная десятка
(в потоке она встаёт перед результатом, как и стояла)
поток [ 10, 5, divide, print_result ] аргументы [ ]
│
▼ 10 — данные → в аргументы
поток [ 5, divide, print_result ] аргументы [ 10 ]
│
▼ 5 — данные → в аргументы
поток [ divide, print_result ] аргументы [ 10, 5 ]
│
▼ divide — функция! вызываем её с аргументами [ 10, 5 ]
поток [ print_result ] аргументы [ ]
│
▼ divide вернула 2 → результат возвращается в поток
поток [ 2, print_result ] аргументы [ ]
│
▼ 2 — данные → в аргументы
поток [ print_result ] аргументы [ 2 ]
│
▼ print_result — функция! печатает 2, поток пуст
поток [ ] аргументы [ ]И пятый аспект: поток должен заканчиваться командой. Если в конце потока останутся одни данные, всё накопленное в списке аргументов молча пропадёт — без ошибки и без сообщения. Об этом я ещё напишу в «Цене FDD».
Давайте посмотрим как будет выглядеть flow в псевдокоде:
# псевдокод 19
fun flow(tasks) {
args = [] # Сюда копятся аргументы для следующей функции
while (tasks не пустой) {
item = shift(tasks) # Берём из потока первый элемент
if (item это функция) {
# Отдаём функции накопленные аргументы. Что ей нужно — она заберёт сама.
res = item(args)
# Результат команды возвращается в поток.
if (res это массив) {
unshift(tasks, ...res) # Массив — это пачка, распаковываем её
} else if (res != пустота) {
unshift(tasks, res)
}
# Следом возвращается остаток аргументов — транзитные данные.
# Он встанет в потоке перед результатом, как и стоял изначально.
if (args не пустой) {
unshift(tasks, ...args)
}
args = [] # Стек аргументов снова пуст
} else {
push(args, item) # Не функция — значит данные, копим их
}
}
# Если поток кончился, а в args что-то осталось, эти данные молча пропадут.
# Об этом см. в «Цена FDD».
}Обратите внимание, в каком порядке машина возвращает в поток результат команды и остаток её аргументов. Сначала уходит результат, следом — остаток; в итоговом потоке остаток встаёт перед результатом, ровно как и стоял там до вызова команды. Порядок здесь принципиален: следующая команда будет разбирать эти данные по очереди, и перестановка перепутала бы ей операнды.
Чтобы соответствовать этой функции flow естественно надо будет переделать и функции get_number и divide, а для print сделать функцию обертку, назову ее print_result, дело в том, что теперь это не просто функции, а функции конвейера, которые получают список накопленных аргументов, и сами должны забирать из него то, что им нужно. В псевдокоде я буду записывать это действие как взять(args) — «взять последнее положенное значение». Как устроен этот список в реальном языке и в каком порядке из него достаются аргументы, я разберу в четвёртой части, когда буду писать настоящую реализацию flow. А пока ещё раз напомню, что функции теперь принимают не просто аргументы, а список аргументов, и в нем могут быть транзитные аргументы, которые не принадлежат этой функции.
# псевдокод 20
fun get_number(args) {
# Функция не требует входящих данных для своей работы,
# поэтому она ничего не забирает из args.
value = get_arg()
if (value не число) {
print("некорректное число, введите заново")
return get_number(args)
}
return value
}
fun divide(args) {
# Функции нужны два числа: сначала забираем делитель, затем делимое.
num2 = взять(args)
num1 = взять(args)
if (num2 == 0) {
print("на ноль делить нельзя")
exit
}
return num1 / num2
}
fun print_result(args) {
# Забираем из аргументов финальный результат и печатаем его
res = взять(args)
print(res)
}
fun main() {
# Теперь main — это просто декларативный запуск конвейера flow
flow([
get_number,
get_number,
divide,
print_result
])
}Давайте разберём, как эта магия работает шаг за шагом на примере массива [get_number, get_number, divide, print_result].
Шаг первый.
get_numberвызывается при пустомargs: входящих данных ей не нужно. Она запрашивает число (допустим, 10) и возвращает его;flowкладёт результат (10) в начало потокаtasks. Аргументы так и остались пустыми. Поток:[10, get_number, divide, print_result],args:[].Шаг второй. Достаём
10— это не функция, значит данные:argsрастёт,[10]. Следующий элемент —get_number. Вызываем её сargs = [10], но входящих данных функции не нужно, поэтому из аргументов она ничего не забирает. Она возвращает второе число (допустим, 5).flowкладёт результат (5) в начало потока, а следом возвращает и остаток аргументов — нетронутую десятку (транзитный аргумент). В потоке она встаёт перед результатом, ровно как и стояла. Поток:[10, 5, divide, print_result],args:[].Шаг третий.
argsрастёт: достаём10, затем5— теперьargs = [10, 5]. Натыкаемся наdivide: функция извлекает оба числа, иargsсокращается до[].divideвозвращает2; поскольку аргументов не осталось, в поток уходит только результат деления. Поток:[2, print_result].Шаг четвёртый. Достаём
2—argsрастёт до[2]. Достаёмprint_result, вызываем его: он забирает двойку и печатает её,argsснова пуст. Поток пуст! Программа завершена.
Обратите внимание: в псевдокоде 20 get_number снова вызывает сам себя. Да, рекурсия вернулась: return get_number(args) — это те же грабли, что и в псевдокоде 12. Да ещё и печать сообщения об ошибке осталась внутри функции. Запомним этот вариант как ошибочный и попробуем передать функции готовое поведение снаружи — рабочая версия будет в псевдокоде 22.
Как вы думаете, а можно ли поменять поведение функций? Помните в псевдокоде 16 с колбеками были аргументы on_success и on_error? Что мне мешает сделать примерно то же самое сейчас?
Ничто не мешает! Я могу передавать ссылки на функции‑обработчики (колбэки) прямо через поток исполнения. Чтобы виртуальная машина flow не выполнила функцию раньше времени (когда она просто лежит в массиве команд), я буду передавать не саму функцию, а ссылку на неё, используя оператор \[6]. Для flow ссылка — это просто данные, поэтому она покорно положит её в args.
# псевдокод 21
fun exit() {
# Обёртка над оператором exit: сам оператор как данные не передать,
# а обёртку можно — поэтому в потоке стоит именно она (\exit).
exit
}
fun get_number(args) {
# Обработчик ошибки приезжает в аргументах — забираем его первым.
on_error = взять(args)
value = get_arg()
if (value не число) {
print("некорректное число, введите заново")
return on_error->()
}
return value
}
fun divide(args) {
# Забираем из аргументов обработчик, а следом — делитель и делимое.
on_error = взять(args)
num2 = взять(args)
num1 = взять(args)
if (num2 == 0) {
print("на ноль делить нельзя")
return on_error->()
}
return num1 / num2
}
fun main() {
# Передаём ссылки на функции (\get_number, \exit), которые flow положит в args,
# а за ними — сами функции (get_number, divide), которые flow будет выполнять.
flow([
\get_number, get_number,
\get_number, get_number,
\exit, divide,
print_result
])
}Смотрите, какой трюк я провернул! Я передал в конвейер не просто ссылку, а ссылку на функцию \get_number в качестве данных. Машина flow покорно кладёт её в список args. Когда доходит очередь до вызова get_number, функция извлекает этот колбэк из списка (on_error = взять(args)). Если происходит ошибка ввода, она вызывает его: return on_error->().
И вот здесь таится серьезный подвох. Во‑первых, поскольку on_error — это прямая ссылка на get_number, вызов on_error->() мгновенно инициирует рекурсию. Я снова вернулся к тому, от чего бежал — к накоплению стека вызовов. А во‑вторых (и это даже хуже), повторный запрос сработает всего один раз, после чего программа с треском упадёт!
Почему? Потому что при первой ошибке get_number извлекает колбэк из списка аргументов навсегда (взять(args)), оставляя список пустым. Когда внутри рекурсии get_number запускается во второй раз и снова пытается сделать взять(args), она получает пустоту (в реальных языках это null). И при очередной ошибке ввода попытка вызвать return пустота->() приведёт к фатальному сбою. К тому же, get_number всё ещё жёстко привязана к выводу текста «некорректное число, введите заново».
Ладно, on_error хотя бы один раз сработал, и на том спасибо. Чуть попозже ещё вернемся к этому.
А что же с on_success? С ним всё прекрасно. В виртуальной машине on_success — это просто остаток потока! Когда get_number возвращает корректное value, машина flow сама кладёт его в поток, затем перекладывает в список args, и следующая по потоку функция автоматически его использует. Больше не нужно явно управлять успешным потоком выполнения.
Но надо во что бы то ни стало избавиться от рекурсии при ошибке, и полностью вынести управление логикой (включая печать сообщения об ошибке) обратно в main.
Помните, что идеология конвейера требует, чтобы функция get_number не принимала никаких решений. Она должна просто вызвать переданный ей обработчик return on_error->() и вернуть его результат наверх.
Чтобы этот процесс зациклился бесконечно и без рекурсии, обработчик ошибки не должен выполняться внутри get_number. Он должен просто вернуть массив инструкций (ссылку на самого себя и команду повторного запуска), который get_number пробросит в виртуальную машину flow.
Поскольку безымянная функция не может сослаться на саму себя, я объявлю её как обычную функцию:
# псевдокод 22
fun on_err_retry() {
print("некорректное число, введите заново")
# Колбэк возвращает ссылку на самого себя (как данные) и функцию (как команду)
return (\on_err_retry, get_number)
}
fun get_number(args) {
on_error = взять(args)
value = get_arg()
if (value не число) {
# Функция ничего не знает о контексте. Она просто делегирует решение колбэку.
return on_error->()
}
return value
}
fun main() {
# Декларативно строю конвейер, передавая функцию on_err_retry
flow([
\on_err_retry, get_number,
\on_err_retry, get_number,
\exit, divide,
print_result
])
}Как это работает шаг за шагом:
# схема (что происходит, когда get_number ломается)
поток [ \on_err_retry, get_number, \on_err_retry, get_number, \exit, divide, print_result ]
аргументы [ ]
│
▼ \on_err_retry — это ссылка, а не команда: для flow это данные → в аргументы
поток [ get_number, \on_err_retry, get_number, \exit, divide, print_result ]
аргументы [ \on_err_retry ]
│
▼ get_number — функция! вызываем её: on_error = взять(args) забирает обработчик
поток [ \on_err_retry, get_number, \exit, divide, print_result ]
аргументы [ ]
│
▼ get_arg() вернул не число → return on_error->(): вызывается on_err_retry
поток [ \on_err_retry, get_number, \exit, divide, print_result ]
аргументы [ ]
│
▼ on_err_retry печатает «некорректное число, введите заново» и возвращает
массив-инструкцию ( \on_err_retry, get_number ); get_number пробрасывает
его обратно в виртуальную машину
поток [ \on_err_retry, get_number, \exit, divide, print_result ]
аргументы [ ]
│
▼ flow видит массив и распаковывает его в начало потока: unshift(tasks, ...res)
(это то самое исправление из псевдокода 19)
поток [ \on_err_retry, get_number, \on_err_retry, get_number, \exit, divide, print_result ]
аргументы [ ]
│
▼ остаточных аргументов нет — возвращать в поток нечего;
\on_err_retry снова не команда → в аргументы
поток [ get_number, \on_err_retry, get_number, \exit, divide, print_result ]
аргументы [ \on_err_retry ]
│
▼ get_number снова функция! и в аргументах её снова ждёт тот же обработчик:
круг замкнулся, шаг повторяется снова и снова, а стек не растёт
поток [ \on_err_retry, get_number, \exit, divide, print_result ]
аргументы [ ]
│
▼ ввод верный → число уходит в поток, и конвейер едет дальше
поток [ 10, \on_err_retry, get_number, \exit, divide, print_result ]
аргументы [ ]И вот получилась архитектура, которая на удивление удачно сочетает линейность, безопасность и гибкость! Здесь нет рекурсии (плоский список, так как get_number честно завершает свою работу перед повторным запуском). Здесь нет хардкода (функции не знают, что делать при ошибке). И здесь есть полный контроль в main: я динамически перестраиваю массив команд конвейера прямо на лету!
Промежуточный финал: зачем я всё это сделал?
Давайте на мгновение остановимся и посмотрим на путь, который я прошел. В самом начале был наивный псевдокод 2:
# псевдокод 2 (функция main)
fun main() {
num1 = get_arg()
num2 = get_arg()
res = num1 / num2
print(res)
}Он был идеален в своей простоте: строчки выполнялись одна за другой, образуя абсолютно линейный, понятный с первого взгляда алгоритм. Но как только реальность потребовала обрабатывать ошибки ввода и исключительные ситуации, эта простота исчезла.
Я пытался защитить программу if‑ами и увяз в спагетти‑коде из бесконечных отступов. Я пытался прятать проблемы в функции и получил «зомби‑программы», которые радостно глотали ошибки и продолжали работать с пустотой. Я перебрал классические паттерны: возвращал коды ошибок и строил вокруг них частокол ручных проверок; выбрасывал исключения, которые делали поток выполнения непредсказуемым; пробовал колбэки и проваливался в нечитаемый ад вложенности. Даже современные парадигмы вроде ООП и ФП не убрали эту работу: ООП добавило гарантии типов, ФП сделало ошибку значением, но повтор ввода всё равно остался в main.
Казалось, что программист обречён вечно балансировать между безопасностью и читаемостью.
Но потом я сделал шаг в сторону от привычного мышления. Я написал flow — крошечную машину, использующую идеи обратной польской записи. И результат оказался поразительным.
Посмотрите на итоговый main:
# псевдокод 22 (итоговый main)
fun main() {
flow([
\on_err_retry, get_number,
\on_err_retry, get_number,
\exit, divide,
print_result
])
}Линейность. Код снова читается сверху вниз, как в самом первом псевдокоде. В самом
mainне осталось ни одного ветвления: ниif, ниtry/catch, ниwhile— они переехали в обработчики. Логика стала простым списком!Чистота функций. Бизнес‑функции (
get_number,divide) больше не принимают судьбоносных решений. Они просто делают свою работу или вызывают колбэк, оставаясь полностью независимыми от контекста. Их можно переиспользовать в любом потоке — с одной оговоркой:get_numberпо‑прежнему сама решает, откуда взять ввод (она вызываетget_arg), и на веб‑сервере менять придётся именно это. Зато политика ошибок от функции уже не зависит.Безопасность без роста стека. Нет риска переполнения стека (Stack Overflow) и нет ресурсоёмкой раскрутки, как при исключениях, — стек вызовов всегда остаётся плоским. Плата за это — массивы, которые создаются на каждую команду и на каждую ошибку (подробнее — в «Цене FDD»).
Декларативное управление. Весь контроль над потоком сосредоточен в
main. Программа стала вести себя как данные: чтобы изменить логику приложения, достаточно переписать массив, меняя команды местами или подставляя другие обработчики ошибок.
Можно сказать, это новый стиль программирования. Хотя... как известно, всё новое — это хорошо забытое старое[7]:))
Однако самое время дать этому стилю программирования имя -
Flow Driven Development (FDD)
Почему это название подходит идеально? Слово «Flow» (поток) отражает саму суть этого подхода. В классическом программировании управление логикой идет с помощью жёстких конструкций (if, while, try/catch), а здесь программой управляет поток — плоский массив инструкций и данных. Вся архитектура этой программы строится вокруг того, как этот поток формируется, распаковывается и перестраивается «на лету» внутри крошечной виртуальной машины. Это уже не абстрактный код, а непрерывное, предсказуемое течение данных через цепочку функций.
И сразу определю границы: FDD — не замена ООП или ФП. Он не претендует на архитектуру всего приложения и не отменяет ни классы, ни типы. Это способ организовать управление потоком в линейных сценариях с точками отказа (пользовательский ввод, сеть, файлы) — тех самых, где обычно и начинается частокол проверок. За их пределами обычный код остаётся обычным кодом, и FDD в него не лезет.
Прежде чем вдохновляться дальше, давайте честно посмотрим, есть какие‑то минусы у этого подхода.
Цена Flow Driven Development
Во‑первых, вся безопасность построена на незримой дисциплине потока. Машина flow не знает, сколько аргументов ожидает каждая функция и лежит ли обработчик ошибки в списке аргументов. Забыли положить \on_err_retry перед командой — функция попробует взять аргумент из пустого списка, получит пустоту и попробует вызвать её как функцию. Никакой ошибки на этапе «компиляции» нет: массив выглядит совершенно нормально, а ломается только в момент выполнения. Это можно было наблюдать на примере одноразового колбэка в псевдокоде 21.
Во‑вторых, посмотрите на main: перед каждой падающей командой стоит свой обработчик ошибки. Шаблонный код, от которого я бежал в лесенках if/else, никуда не исчез — он просто переехал из тела функции в массив инструкций.
В‑третьих, отладка. Трассировка стека вызовов — кто кого вызвал — которую все привыкли читать при падении в ООП или ФП, в FDD почти бессмысленна: реальная логика программы живёт не в вызовах, а в данных — в содержимом массива tasks. Чтобы понять, что происходит, нужно мысленно симулировать работу виртуальной машины: какие аргументы лежат в списке, в каком порядке, что вернула предыдущая команда. Пару «функция + обработчик» невозможно проверить статически — вся корректность конвейера живёт только в голове программиста.
И ещё одна цена, самая коварная: тихие потери. Остаток аргументов возвращается в поток только в момент вызова команды. Если поток заканчивается данными, а не функцией, то команды в нём больше нет, накопленный args никуда не возвращается и просто исчезает — без ошибки и без сообщения. Я помечал этот случай комментарием в псевдокоде 19: достаточно один раз потерять команду в массиве, чтобы молча выбросить всю работу программы.
Наконец, сами правила списка аргументов — источник тонких ошибок. В divide программист обязан помнить, что аргументы забираются из списка в обратном порядке: сначала достаётся правый операнд, а потом левый. Перепутали — программа молча делит наоборот. И это ещё полбеды: функция, забравшая из args лишнее, съедает транзитные аргументы, предназначенные следующим командам. Ошибки не будет — следующая функция просто получит чужие данные.
Осталось сказать про аллокации — то есть про «накладные расходы» из списка обещаний. Полностью бесплатным FDD не является. Посмотрите на псевдокод 19: список аргументов пересобирается на каждой команде. А на каждую ошибку обработчик возвращает свежий массив‑инструкцию (псевдокод 22). То есть на успешном пути это одна аллокация на команду, а на ошибке — ещё одна‑две. Для сравнения: ФП‑контейнер создаёт коробочку на каждый вызов, включая успешные, — как я уже разбирал во второй части, плата берётся всегда, но понемногу. Исключения на успешном пути не аллоцируют вообще ничего, зато throw обходится дорого: раскрутка стека с уничтожением локальных переменных. Так что выигрыш FDD — не в нуле аллокаций, а в том, что они мелкие и предсказуемые: никаких коробочек с методами и никакого роста стека.
Итого: FDD — не серебряная пуля и уж точно не «абсолютный идеал». Это ещё один компромисс, при котором сложность не исчезает, а меняет форму: она перетекает из управляющих конструкций в структуру данных и в дисциплину обращения с ней. Понимать эту цену обязательно — иначе однажды конвейер укусит там, где этого не ждешь.
И всё‑таки мне кажется, что этот способ программировать пусть НЕ прост, но естественен, красив, прозрачен и невероятно гибок. Оказывается, чтобы вернуть коду естественную красоту, достаточно было всего лишь объединить инструкции и данные в один поток, и позволить самому потоку управлять собой.
НО! Есть одно, но очень серьезное, НО — всё это был псевдокод.
Часть 4: Поиски реального языка
Самое время перейти к реализации на каком‑нибудь действующем языке программирования. И вот вопрос... на каком?
Для реализации такого движка нужен язык программирования, который удовлетворяет трём условиям:
Поддержка функций как объектов первого класса (их можно передавать как данные).
Списки, способные хранить элементы разных типов (и числа, и ссылки на функции одновременно).
Удобные встроенные операции для работы со списками —
shift,pop,unshift,pushи распаковка списков.
Давайте посмотрим, какие языки для этого подойдут:
1. Языки со статической типизацией: Java, C#, Go, Rust Эти языки со строгой статической типизацией заставляют страдать. Обойтись здесь «просто массивом» не выйдет: чтобы положить рядом число 10 и функцию get_number, придётся либо стирать типы (object[] в C#, пустой интерфейс interface{} в Go), либо описывать сложные типы‑обёртки, интерфейсы и enum (как в Rust). А потом на каждом шаге конвейера мучительно приводить типы (кастовать). Написать на них flow можно, но это убьёт всю идею легковесности и простоты: та самая безопасность типов, ради которой такие языки и берут, окажется выключенной именно в движке.
2. C++ и шаблонное метапрограммирование Конечно, нельзя обойти стороной C++. С помощью магии шаблонного метапрограммирования (Template Metaprogramming) и вариативных шаблонов (variadic templates) можно реализовать очень похожий движок, который развернёт весь поток flow ещё на этапе компиляции. Это будет работать невероятно быстро, но ценой окажется сама динамичность: целиком развернуть можно только тот поток, который известен заранее, а значит, массив команд перестанет перестраиваться на лету — подменить команду или обработчик в нём уже не получится. К тому же код самого движка будет выглядеть как заклинание вызова древних демонов, понятное лишь избранным.
3. Функциональные языки: Lisp / Clojure / Scheme В функциональных языках семейства Lisp код и так по своей природе является данными (гомоиконичность). Написать там интерпретатор списков — это базовая задача, которая решается невероятно элегантно. Однако их синтаксис (обилие скобок) и философия подходят не каждому.
4. Стековые языки: Forth / Factor Родной дом для нашего конвейера. В Forth вся программа и есть такая стековая машина, а наш FDD‑поток — это просто её словарь «слов». Factor развивает идею: там есть продвинутая работа со стеком и богатые библиотеки. Однако мышление «стековыми словами» требует серьёзной перестройки сознания и подходит не каждой команде и не каждой задаче.
5. Динамические языки: Python / Ruby Оба языка прекрасно справятся с задачей. Списки в них динамические и легко хранят функции. В Ruby есть встроенные shift/unshift, а в Python можно использовать list.pop(0) или коллекции типа deque для работы с двусторонней очередью, плюс операторы распаковки *.
6. PHP PHP отлично справится с задачей функционально, но проиграет архитектурно. Проблема кроется под капотом: массивы в PHP — это сложные хэш‑таблицы. Любой вызов array_unshift (а в FDD он происходит постоянно) заставляет PHP заново пересчитывать и обновлять все числовые индексы в массиве. На длинных потоках эта переиндексация за O(N) станет узким местом. Кроме того, передача ссылок на функции в PHP исторически требовала костылей (строк или массивов), и лишь в версии 8.1 появился синтаксис my_func(...), который хоть и работает надёжно, но визуально перегружает конвейер.
7. JavaScript (или TypeScript) Идеальный кандидат для веба. В JS массивы гетерогенны (могут хранить что угодно), функции передаются предельно легко, а методы для работы со списком (shift, unshift, pop, push) и оператор распаковки (...) встроены прямо в синтаксис. Движок flow на JavaScript пишется максимум за пару экранов.
8. Perl Замечательный, мощнейший выбор! Да, синтаксис Perl тоже пестрит спецсимволами (сигилами $, @, &), но исторически этот язык обладает непревзойдённой системой работы со списками. Массивы (@array) в Perl хранят скаляры, а скаляром может быть как число, так и ссылка на функцию (\&func). В языке прямо из коробки встроены shift, unshift, pop и push, что делает его идеальным инструментом для написания подобной виртуальной машины.
Поскольку я ищу максимальную простоту и выразительность, выбор очевиден: нужен гибкий, динамический язык. Но давайте посмотрим на это с точки зрения производительности.
В парадигме Flow Driven Development виртуальная машина работает в бесконечном цикле, где на каждом такте происходят манипуляции со списками (shift, unshift, push, pop) и непрерывные вызовы функций.
Операции со списками В FDD постоянная распаковка остатков args в начало потока tasks требует частых вызовов unshift. В Python стандартный список (list) реализован как обычный динамический массив. Операции push и pop в конце работают за O(1), а вот shift/unshift (вставка в начало) заставляют сдвигать весь массив в памяти. Это даёт сложность O(N), что убьёт производительность на длинных потоках (если не использовать deque). А вот в JavaScript движки вроде V8 решают эту проблему за счёт агрессивных низкоуровневых оптимизаций памяти. На коротких массивах процессор делает смещение элементов (через быстрое копирование памяти memmove) за наносекунды — алгоритмическая сложность O(N) просто сливается с системным шумом, поэтому shift и unshift кажутся почти бесплатными. И только на очень длинных потоках эта перетасовка становится заметным узким местом. Но настоящим королём здесь выступает Perl. Его массивы изначально проектировались под интенсивную работу с очередями. Движок Perl резервирует память с обеих сторон массива (выделяя запасные слоты), поэтому shift и unshift в Perl работают за амортизированное O(1): в обычном случае сдвигается только указатель на начало, а сами элементы перемещаются лишь изредка — когда запас слотов с одной из сторон исчерпан.
Стоимость вызовов функций Каждая команда в FDD — это вызов функции. В Python и Ruby создание нового фрейма стека стоит довольно дорого, поэтому вызовы там традиционно медленные. PHP тоже не блещет: долгие годы замыкания и анонимные функции стоили в нём заметных ресурсов, и хотя в седьмой и восьмой версиях это стало заметно легче, репутация медленных колбэков закрепилась за языком надолго. Зато JavaScript благодаря своей агрессивной JIT‑компиляции показывает феноменальную скорость. Движок V8 способен на лету оптимизировать и даже встраивать (inline) мелкие функции, сводя затраты на вызов почти к нулю. Perl — интерпретатор без JIT‑компилятора, поэтому вызов по ссылке ($func->()) стоит заметно дороже, чем в JIT‑движках: горячие места никто не превращает в машинный код, и сократить этот расход нечем. Для языка без JIT это ожидаемая плата, но её приходится сопоставлять с выигрышем на массивах.
В сухом остатке я имею двух абсолютных финалистов: JavaScript (за счёт скорости JIT‑движка) и Perl (благодаря непревзойдённой архитектуре работы с массивами за O(1) из коробки). Именно эти два языка лучше всего подходят для реализации FDD на практике.
И я выбираю... Perl!
Потому что JS всё ещё великий:))
Я напишу на Perl модуль, который реализует FDD, и назову его
Часть 5: MPGA — Make Perl Great Again!
Итак... что у меня есть? У меня есть концепция виртуальной машины (псевдокод 19), понимание того, как разделять инструкции и данные, и идеальный язык для реализации этой задумки.
Давайте перенесу псевдокод 19 в реальный мир Perl. Естественно, при этом переносе всплывут нюансы, на которые, мягко говоря, не хотелось обращать внимание в придуманном мире псевдокода.
И самый первый нюанс — это обратный порядок аргументов, функция взять(args) брала последний элемент из списка аргументов. В Perl аналогом этого действия будет my $arg = pop @$args; в то время, когда стандартом является my $arg = shift @$args;. Кто я такой, чтобы идти против стандарта? К тому же pop — это неудобно, противоестественно и ведёт к глупым ошибкам (перепутал операнды деления — и ищи баг).
Но если перейти на shift, то функция изначально должна получать ровно столько аргументов, сколько ей надо. А вы же помните, что flow это совершенно тупая функция, которая просто идет по потоку в поисках функции и все, что не функция, попадает в список аргументов, который потом как есть, без разбора, будет передан найденной функции. То есть функция может получить транзитные аргументы, которые предназначены не ей. Что же делать?
Было бы прекрасно, если бы функция сама говорила что‑то вроде: «Вы знаете, на самом деле мне нужно только 2 аргумента», а flow сама отрезала бы их из списка аргументов и отдавала функции в нормальном прямом порядке?
К счастью, в Perl есть мощнейший инструмент — атрибуты. Можно использовать модуль Attribute::Handlers и задать свой атрибут : MPGAargsNum(N). Тогда объявление функции будет выглядеть примерно так:
sub first_fun : MPGAargsNum(5) {И это будет обозначать, что эта функция использует модуль MPGA, и принимает 5 аргументов.
Движок flow перед вызовом проверит реестр атрибутов. Если у функции указано, например, (2), он сам отрежет ровно два последних элемента из списка с помощью splice(@$args, -2) и передаст их функции. Программисту останется лишь написать привычные my $a = shift @$args;. А если атрибута нет, функция получит весь массив и будет управлять им сама.
Второй нюанс связан с тем, что такое список аргументов args. Он уже и в псевдокоде 19 на самом деле был не просто списком аргументов функции, а списком аргументов потока. Вот этот момент надо прочувствовать, если я в Perl вызову функцию как fun1(@args), то это будет означать примерно следующее fun1($args[0], $args[1], ...), и на самом деле тут происходит переход на алиасы fun1($_[0], $_[1], ...) и тогда я получаю проблему с тем, что я не могу уменьшить args, делая внутри функции my $arg = shift @_;. (Уменьшится @_, а не @args.) То есть нельзя передавать args вот так — fun1(@args), надо делать fun1(@args).
И это здОрово! Потому что получается, что на самом деле я в функцию буду всегда передавать не неопределенное число аргументов списка аргументов потока, а один фиксированный аргумент функции, который будет являться ссылкой на список аргументов потока. (Честно говоря я сам в шоке, от всех этих формулировок, когда программировал, я это не формулировал, а просто программировал, можно сказать не задумываясь.) Положительным побочным эффектом этого является ещё и то, что это хорошо сказывается на производительности, вместо кучи аргументов в функцию передается один аргумент. Но кто сказал, что я не могу передавать и другие фиксированные аргументы, чисто для удобства. Например... Так, тут надо подумать. Погодите... А что если я поиск функции в потоке вынесу в отдельную функцию? Назову ее chunk — эта функция будет перебирать поток и искать в нём функцию, в результате своей работы она будет возвращать первую найденную функцию в потоке и список аргументов, которые шли до этой найденной функции: $fun, \@args.
Итак после того, как я запущу на моем потоке @flow функцию chunk, в итоге я буду иметь:
функцию
$fun;ее аргументы
@args;и остаток
@flow;
Именно их я и передам функции fun. Зачем? Дело в том, что иногда бывает очень удобно, когда функция может влиять на своё окружение. Да‑да, я понимаю и помню вся эта байда началась с того, что в первой части я сказал, что функция не должна принимать глобальных решений. Но не должна и не может — разные вещи. А вдруг понадобится. Так что пускай.
И третий нюанс: (это вообще ужас‑ужас‑ужас!) Дело в том, что реально удобно, когда функции передают друг другу какие‑то данные через общий объект. Я сам сторонник чистых функций и сам стараюсь избегать хранить состояние, но иногда это тупо проще и даже правильнее. Так что тоже пусть будет. Назову это:
контекст $ctx.
Итак... вот такой будет вызов:
$fun->($fun, \@args, \@flow, $ctx);Исходя из этого, все функции, которые будут пользоваться этим модулем, будут принимать четыре аргумента функции:
sub first_fun : MPGAargsNum(0) {
my ($self, $args, $flow, $ctx) = @_;Кстааати, а что должны возвращать эти функции?.. Давайте подумаем, что вообще возвращают функции в Perl:
undef
скаляр
объект
ссылку
массив
хэш.
(Ещё замечу, что если функция не заканчивается return, то возвращается результат последней значимой строки функции, и чтобы это не было для вас неожиданностью, примите за правило всегда писать явно return чо‑то чо‑то...;)
Из этих шести вариантов я лично считаю, что есть смысл возвращать только первые 4. Дело в том, что это как с аргументами на вход, вместо неопределенного числа аргументов потока я стал передавать строго фиксированное число аргументов функции, и возврат подчиняется той же логике, не стоит возвращать что‑то сложное неопределенное, если можно вернуть только одно, это будет гораздо легче для производительности. Если уж прямо так хочется вернуть хэш, верните ссылку на него, хочется массив — верните ссылку на массив.
Итак... самое время написать модуль:
package MPGA;
use strict;
use warnings;
use Attribute::Handlers; # Добавлено для поддержки атрибутов
require Exporter;
our @ISA = qw(Exporter);
our @EXPORT = qw(
flow flow_ctx chunk
);
# Реестр для хранения количества обязательных аргументов функций
our %REGISTRY;
# Определение атрибута :MPGAargsNum
# этот атрибут необходим функциям, чтобы указать MPGA сколько аргументов обязательно
# нужно исполняемой функции, это для удобства работы в новом стиле. основываясь на
# это атрибуте flow() предоставит функции именно такой длины массив аргументов @$args.
# исполняемая функция должна иметь примерно такой вид:
# sub fun : MPGAargsNum(3) {
# my ($self, $args, $flow, $ctx) = @_;
# my $args1 = shift @$args;
# my $args2 = shift @$args;
# my $args3 = shift @$args;
#
# .....
# return;
# }
# данная запись явно дает понять, что это не обычная функция perl, а функция, которая
# написана для исполнения модулем MPGA и ей необходимы 3 обязательных аргумента.
# если же этот атрибут не указан
# sub old_style_fun {
# my ($self, $args, $flow) = @_;
# my $args1 = pop @$args;
# .....
# return;
# }
# значит функция сама управляет @$args и контекстом.
sub UNIVERSAL::MPGAargsNum : ATTR(CODE) {
my ($package, $symbol, $referent, $attr, $argsNum) = @_;
# Если данные пришли в виде массива, берем первый элемент.
# Если как обычный скаляр — берем его как есть.
$REGISTRY{$referent} = ref($argsNum) eq 'ARRAY' ? $argsNum->[0] : $argsNum;
}
# функция chunk() принимает только ссылку на массив,
# в котором ищет первую с начала ссылку на функцию, всё, что не является ссылкой на функцию
# считается аргументом функции и попадает в массив аргментов.
# возвращает массив из двух элементов ( fun, [args] )
#
sub chunk {
my $flow = shift;
return if ref( $flow ) ne 'ARRAY';
my ($fun, $args);
while(scalar @$flow) {
my $item = shift @$flow;
if( ref $item ne 'CODE' ) {
push @$args, $item; # Аргументы собираются слева направо в прямом порядке
}
else {
$fun = $item;
last;
}
}
return $fun, $args;
}
# функция flow_ctx() принимает ссылку на массив, и в этом массиве первый
# элемент считается объектом контекста ctx, который будет передан
# во все функции потока. вы можете определять ctx как угодно.
# рекомендую пустой хэш {}, в котором можно будет хранить промежуточные
# состояния функций (естественно надо не забывать их чистить :))
sub flow_ctx {
my $flow = shift;
return if !$flow;
return if ref( $flow ) ne 'ARRAY';
return if !scalar @$flow;
# Откусываем первый элемент и сохраняем его как контекст выполнения
my $ctx = shift @$flow;
# Передаем чистый поток и контекст дальше в цепочку
flow( $flow, $ctx );
return;
}
# функция flow() принимает два аргумента:
# первый аргумент обязательный - ссылка на массив - поток,
# второй аргумент необязательный $ctx - контекст,
# в цикле пока поток не пустой, этот
# поток парсится слева направо с помощью вызова функции chunk()
# в поисках первой ссылки на функцию.
# эта функция заносится в переменную $fun и будет исполняться, а все переменные
# найденные до этой функции заносятся в массив накопленных аргументов @$args.
# таким образом $flow становится короче.
#
# flow() принимает поток в прямом порядке
#
# исполняемой функции $fun передаются четыре аргумента:
# sub fun {
# my ( $self, $args, $flow, $ctx ) = @_;
# .....
# return;
# }
# - $fun - сама эта функция
# - вторым аргументом идет ссылка на массив аргументов:
# * если у функции задан атрибут :MPGAargsNum(N) — это ссылка на массив из N
# ближайших к функции аргументов, которые забрали с конца массива
# накопленных аргументов
# * если атрибут не задан — это ссылка на весь массив накопленных аргументов
# - $flow - остаток потока
# - $ctx - общий объект контекста (может быть undef)
#
# функция $fun может модифицировать любые свои аргументы
# - первый аргумент - ссылка на саму себя, модифицировать ее в принципе можно, но не нужно,
# функция передается сама себе для того, чтобы в случае необходимости она могла
# рекурсивно возвратить саму себя в поток @$flow
# - второй аргумент - массив аргументов @$args - аргументы могут относиться к этой функции или
# к другой, которая идет дальше по потоку, это зависит от того определен ли у функции
# атрибут MPGAargsNum:
# - если определен, то в @$args именно MPGAargsNum аргументов, и их можно обрабатывать самым
# обычным способом через shift.
# - если не определен, то в @$args возможно не все аргументы относятся к этой функции
# и обрабатывать их надо осторожно, чтобы не удалить из потока транзитные аргументы.
# поэтому надо обрабатывать их через pop и получать в обратном порядке с конца.
# - третий аргумент - поток $flow - тоже может быть модифицирован с целью изменить поток
# выполнения программы, но так как считается, что рядовая функция не должна знать слишком много,
# то рекомендую модифицировать поток только в плане прекращения потока в случае ошибки,
# это достигается обнулением @$flow и возвратом undef, т.е. надо выполнить такой код:
# @$flow = ();
# return;
# - четвертый аргумент - контекст ctx. если поток был определен через функцию flow_ctx, то
# это первый элемент этого потока - контекст, который должен быть передан всем функциям этого потока.
#
# исполняемая функция $fun может вернуть
# - ссылку на массив - в этом случае этот массив должен быть
# занесен в начало остатка потока $flow
# - ссылка на хэш - в этом случае возврат трактуется как объект, который
# должен быть занесен в начало остатка потока $flow
# - скаляр - в этом случае возврат трактуется как скаляр, который
# должен быть занесен в начало остатка потока $flow
# - undef - в этом случае поток $flow не меняется
# - во всех других случаях $flow не меняется
#
# после выполнения функции $fun список аргументов @$args может быть не пустым, в
# таком случае он должен быть добавлен в поток @$flow после добавления
# туда результатов работы $fun.
# также надо взять за правило, что функция $fun всегда должна заканчиваться return,
# иначе будет возвращен результат последнего оператора функции.
# таким образом функция всегда должна заканчиваться как-то так:
# return [...]; - ссылка на массив
# return {...}; - ссылка на хэш
# return $scalar; - скаляр
# return; - undef
sub flow {
my $flow = shift;
my $ctx = shift; # Принимаем контекст (будет undef, если вызван обычный flow)
return if !$flow;
return if ref( $flow ) ne 'ARRAY';
while(scalar @$flow) {
my ($fun, $args) = chunk($flow);
if ($fun) {
my $res;
# Если у функции объявлен атрибут :MPGAargsNum
if ( exists $REGISTRY{$fun} ) {
my $argsNum = $REGISTRY{$fun};
if (defined $args && (scalar @$args < $argsNum)) {
print "Предупреждение: аргументов (" . scalar @$args . ") меньше чем требуется ($argsNum)\n";
$argsNum = scalar @$args;
}
# ПРАВИЛЬНЫЙ ВАРИАНТ:
# Если требуется 0 аргументов — создаем пустой массив,
# иначе безопасно забираем $argsNum элементов с конца.
my @pass_args = ($argsNum == 0) ? () : splice(@$args, -$argsNum);
# Передаю стандартный набор аргументов
$res = $fun->($fun, \@pass_args, $flow, $ctx);
# Всё, что было в @pass_args, здесь забывается, так как переменная выходит из области видимости
}
else {
# Старый режим совместимости: передаем исходный $args целиком и $ctx четвертым аргументом
$res = $fun->($fun, $args, $flow, $ctx);
}
if( defined $res ) { # $fun вернула что-то определённое, НЕ undef
if( ref( $res ) eq 'ARRAY' ) { # $fun вернула ссылку на массив
unshift(@$flow, @$res) if scalar @$res;
}
else {
unshift @$flow, $res;
}
}
# Если в $args еще остались транзитные аргументы — возвращаем их в начало потока
if (defined $args && scalar @$args) {
unshift @$flow, @$args;
}
}
}
return;
}
1;Вот так наивные размышления об избавлении от if-else привели меня к созданию полноценного фреймворка со своим загрузчиком, реестром метаданных и системой контроля потока.
Теория — это прекрасно, но давайте вспомним то, ради чего это всё вообще городилось. Как будет выглядеть изначальная задача
деление двух чисел на реальном движке MPGA.pm
Я создам скрипт divide.pl:
#!/usr/local/bin/perl
use strict;
use warnings;
use Scalar::Util qw(looks_like_number);
use MPGA;
sub exit_prog : MPGAargsNum(0) {
my ($self, $args, $flow, $ctx) = @_;
@$flow = ();
return;
}
sub on_err_retry : MPGAargsNum(0) {
my ($self, $args, $flow, $ctx) = @_;
print "некорректное число, введите заново\n";
# Возвращаем ссылку на ссылку (REF) как данные, и ссылку на код (CODE) как команду
return [ "введите число: ", \$self, \&get_number ];
}
sub get_arg {
chomp(my $arg = <STDIN>);
return $arg;
}
sub get_number : MPGAargsNum(2) {
my ($self, $args, $flow, $ctx) = @_;
my $msg = shift @$args; # приглашение
my $on_error = shift @$args; # что делать при ошибке
print $msg;
my $arg = get_arg();
if (!looks_like_number($arg)) {
# Если ошибка, разыменовываем REF ($$on_error), превращая его в CODE
# и возвращаем в поток, чтобы машина его немедленно исполнила
return [ $$on_error ];
}
return $arg;
}
sub divide : MPGAargsNum(3) {
my ($self, $args, $flow, $ctx) = @_;
# Магия MPGAargsNum(3) отрезала 3 аргумента в конце стека.
# Они лежат в массиве ровно в том порядке, в котором попали в поток!
my $num1 = shift @$args;
my $num2 = shift @$args;
my $on_error = shift @$args;
if ($num2 == 0) {
print "на ноль делить нельзя\n";
return [ $$on_error ];
}
return $num1 / $num2;
}
sub print_result : MPGAargsNum(1) {
my ($self, $args, $flow, $ctx) = @_;
my ($res) = shift @$args;
print "Результат: $res\n";
return;
}
# --- ЗАПУСК ПОТОКА ---
# Передаём колбэки как ссылки на ссылки (\\&), а исполняемые команды как прямые ссылки (\&)
flow([
"Введите первое число: ", \\&on_err_retry, \&get_number,
"Введите второе число: ",
\sub {
my ($self, $args, $flow, $ctx) = @_;
print "некорректное число, введите еще раз.\n";
return [
"Введите второе число: ", \$self, \&get_number
];
},
\&get_number,
\\&exit_prog, \÷,
\&print_result
]);Ну... как говорится, комментарии излишни.
Без рекурсии, без глобальных переменных, с абсолютно плоским массивом управления потоком, который читается как декларативный список инструкций.
Надеюсь уже никто не помнит, как называлась эта статья%)))
Make Perl Great Again!
Сноски:
[1] В IEEE 754 ненулевое число, делённое на ноль, обычно даёт бесконечность со знаком, а 0 / 0 — NaN. Например, именно так ведёт себя JavaScript. В языках вроде Perl или Python попытка деления на ноль вызовет фатальную ошибку (исключение). Переполнение при делении конечных чисел тоже может дать бесконечность.
[2] В действительности поведение при делении на ноль задают язык и среда выполнения; сообщение об ошибке не обязано приходить от операционной системы. JavaScript молча вычислит 6 / 0 как Infinity и пойдёт дальше, а Perl завершит работу программы. Поэтому рассчитывать на «падение» программы как на обработку ошибок ненадёжно.
[3] Здесь слово «отказоустойчивая» употреблено шутливо. Технически отказоустойчивость — это способность системы продолжать работу при отказе части компонентов, например благодаря резервированию. Вывод сообщения о делении на ноль этого свойства не обеспечивает.
[4] Значения NaN, Infinity и -Infinity не считаются допустимыми числами. Проверка («не число») должна отвергать любой ввод, который после преобразования не является конечным числом. Инструменты для такой проверки (например, isFinite в JS) зависят от выбранного языка.
[5] Строго говоря, «спагетти‑код» обычно описывает запутанную структуру управления и переходов (goto), а не просто вложенные условия. Здесь термин употреблён образно: «мешанина логики» уже серьёзно ухудшает читаемость алгоритма.
Сноски: [6] Синтаксис зависит от языка. В JavaScript функция и «ссылка на функцию» — это одно и то же, поэтому различить в массиве «команду» и «данные» (колбэк) не выйдет: там потребовались бы обёртки‑маркеры. А вот в Perl это решается изящно: команда передаётся как прямая ссылка \&get_number (имеет тип CODE), а колбэк — как ссылка на ссылку \\&on_err_retry (имеет тип REF). Машина flow легко их отличит! В псевдокоде же я для наглядности пишу просто \on_err_retry.
[7] Справедливости ради скажу: flow — по сути, переизобретение идей конкатенативного программирования, самым известным представителем которого является язык Forth (рубеж 60-х и 70-х): там программа — это последовательность «слов», каждое из которых забирает аргументы со стека и кладёт результат обратно. Та же мысль живёт и в архитектуре: Flow‑Based Programming Дж. П. Моррисона, где данные идут между независимыми узлами. А любая стековая виртуальная машина — байткод JVM, байткод Python, WASM — работает ровно по этим правилам: команды и данные лежат в одном потоке, и машина исполняет их по очереди. Так что FDD — не революция, а удачное скрещивание старых, проверенных идей.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.