О частичных вычислениях

В соседней теме зашёл разговор об истоках ООП. На самом деле, ООП появился из серьёзных теоретических работ, но сейчас разговор не об этом. Та тема закрыта, и любые попытки реанимировать её здесь будут пресекаться немедленно – гораздо менее толерантно, чем это сделал @Araris. Мне, на фоне той темы, захотелось поговорить о другом, просто навеяло. Возможно, это будет кому-то интересно, по крайней мере, я надеюсь на это.

Подводка к основной теме, можно пропустить

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

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

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

Параллельно с «промышленными» языками, которые создавались на основе компромиссов, чтобы решать реальные вычислительные задачи, развивались и языки для теоретического программирования. О них мало кто слышал (за пределами узкого круга), но все «крутые фичи» приходили в промышленные языки именно оттуда (а вовсе не из втыкания одних структур в начало других.). В этих языках изначально программы и данные не разделялись (а если разделялись, то ради исследования каких-то специальных вопросов). Например, что такое S-выражение в Лиспе? Это способ записи программ или данных? Это и то, и другое! В Лиспе, строго говоря, вообще нет ничего кроме S-выражений – они единственная сущность, которая есть в языке – они и программы, и данные и всё, что Вы можете придумать.

И по мере усиления «железа», а также по мере получения новых теоретических результатов, идёт естественное сближение теоретических языков с «промышленными» просто потому, что всё больше и больше мощных теоретических фич удаётся реализовать достаточно эффективно, чтобы включить их в промышленный язык. И ООП пришла именно оттуда, из теории, а вовсе не из втыкания одних структур в начало других.

Сейчас, прямо на наших глазах, до промышленных языков программирования добирается другая мощная теоретическая вещь – «частичные вычисления» (ещё не добралась, но уже в пути). Чтобы Вы поняли, о чём речь, приведу простой пример:

Пока особой выгоды не видно, но представьте себе, что вычисления, связанные с первым аргументом требуют 100500 часов счёта, а со вторым – пары микросекунд. Сразу выгода проявляется.

Более того, частичные вычисления иногда позволяют превратить квадратичный (или даже экспоненциальный) алгоритм в линейный. Там много всякого наработано.

Пока в «промышленных» языках такой фичи нет (по крайней мере, я не в курсе), но в «полупромышленных», т.е. в таких, которые таки известны за пределами узкого круга теоретиков и иногда применяются для программирования реальных задач, уже потихоньку появляется.

Специально для Вас я подготовил пример на Haskell. Можете зайти, полюбоваться на код, нажать Run и посмотреть на результат.

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

2 лайка

Интересная штука.

Мне кажется существующему Си не хватает совсем чуть для реализации этой фичи.

Определяем перегрузку функции sum() с одним аргументом, которая будет производить предварительные вычисления и запоминать результат в каком-то массиве или хеше, индексируемом по значению первого аргумента. А потом при вызове sum(int, int) с двумя аргументами ищем первый аргумент в этом хеше и если он есть - берем предварительный результат оттуда, а если нет - выполняем полный расчет

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

Не всё так просто, это там сильно упрощённый – утрированный пример. К тому же Вы его неверно поняли (или автор решил всё упростить до искажения смысла). То, что Вы описали – это не частичные вычисления, а суперкомпиляция – штука близкая к частичным вычислениям, но не идентичная им. Главный водораздел между ними в том, что суперкомпиляция – механизм времени компиляции – исполняемый код получается обычным, без фокусов. А частичные вычисления – это механизм, который работает во время исполнения программы (как в моём примере на haskell).

Но, в главном

Вы, безусловно, правы.

Да, нет, это только видимость. Главное то здесь в том, что любая функция может вернуть либо результат (уж того типа под который заточена) либо новую функцию. Т.е. то, что Вы предлагаете может создать внешнюю картинку, как будто всё так, но идейно она не возвращает функцию как результат своей работы. В С++ такое не делается принципиально потому, что функции в С++ не являются “объектами первого класса”.

Правильно ли я понял, в С++ любой объект является объектом первого класса? Не смог придумать исключений

Если специально палок в колёса не вставлять типа искусственно делать объект некопируемым, то конечно.
Но тут есть еще некоторое двойное дно. Статья не зря говорит про функции - потому что именно они в первую очередь во многих языках “лишены” в общих правах. И поэтому же в статье говорится про объекты-функторы потому что они в C++ как раз являются имитаторами таких штук которые в ФЯ идут из коробки и std::function и std::bind (ну и прочее) закрепляют эти вещи в таком же естественном стиле как доступно в ФЯ, а в последних ревизиях даже лямбды завезли, но…
Но почти.
Есть еще одна линия обороны где C++ всё-таки не настолько полноценен - и это кложуры или замыкания. Если вкратце, то в ФП функции обладают еще одной степенью свободы - захватывать неявно своё внешнее окружение - это особенно важно для лямбд - вести себя таким образом как будто кусок кода в который они встроены продолжает существовать в том же состоянии в котором он был на момент вызова.
Псевдокод:

function Foo(x)
{
  return function (y) { return x + y };
}

Т.е. функция Foo вызванная с аргументом 10 вернёт еще одну функцию внутри которой идентификатор x будет заполнен 10. Можно заметить, что это в чём то похоже на сабж, но нет это совсем другое - как правило вот эта вот внутреняя функция просто возвращается сопровождаемая облаком контекста - прямо хранится, что в момент вызова у неё есть внешний контекст где лежит идентификатор x равный 10 и к нему можно свободно обращаться. Вот это и есть замыкание или кложура - это контекст и его вытаскивание наружу из кишок с лямбдой как будто так и надо, как будто это естественно.
Так вот C++ так не умеет, он для этого слишком низкоуровневый, а чтобы такие трюки проворачивать нужна серьёзная инфраструктура внутри языка, как правило собственно некие контексты, некие ссылки на контекст и как правило garbage collector который всё это без приложения дополнительных усилий захватит и подчистит когда надо.
Но в C++ без видимых усилий не получится - там реальный контекст внешней функции как состояние стека гарантированно разрушится в момент выхода из неё и нельзя хранить ссылки на переменные в нём - это настоящее UB.
Поэтому там это солнышко закатывается вручную и есть полемика вокруг того - настоящие ли лямбды в плюсах или инвалиды провоцирующие UB.

Так я же прямо писал

Посмотрите там признаки таких объектов чётко перечислены.

Умеет. Вот здесь была дискуссия, только читайте тему до конца, там сначала пример с ошибкой был, потом поправили. Там же есть ссылка на подробный разбор развития лямбд начиная с 11-го стандарта и до 20-го.

Легко накосячить взяв в захват ссылку на внешнюю переменную в стеке и пролететь в UB после выхода из внешней функции. Я про это - во первых нужно руками прописывать захват ("закатывать солнышко вручную (c)), во вторых следить за собой и быть осторожным.

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

Мы здесь говорим о языке. Каким вообще боком тут стек? В языке есть такое понятие? Нет! Так чего Вы его сюда приплели? Кто Вам вообще сказал, что переменные хранятся в стеке? Язык нигде и никак не регламентирует место хранения переменных.

Специально открыл стандарт С++20, слово stack там встречается 114 раз исключительно в контексте операции “stack unwinding”, что есть просто абстракция для описания выбора процедуры актуальных методов и в контексте контейнера stack из библиотеки std (наряду с очередью и прочими структурами данных). В языке как таковом этого понятия просто нет.

Просто попытка прямолинейного воспроизведения кода выше с переложением на С++ так что переменная x будет захватываться по ссылке, а не по значению (ведь это отдано на откуп программисту) приведёт к UB. Ну и поэтому неплохо бы понимать поэтому про сроки жизни переменных, чем локальные от глобальных отличаются. И никакие улучшения стандарта не сделают из плюсов уже джаваскрипт по простоте использования никогда - обо всём этом надо знать и иметь ввиду, иначе “веревка достаточной длины” ™.

Вот с этим согласен на 100%!

P.S.
Кстати, недавно совсем узнал - пресловутое в узких кругах название книги “Веревка достаточной длины чтобы выстрелить себе в ногу” это игра слов составленная из двух английских идиоматических выражений “веревка достаточной длины чтобы повесится” (применяется в выражениях вида “дай ему веревку достаточной длины чтобы повесится”) ну и просто “выстрелить себе в ногу”. Поэтому то, что слово “повесится” выпало из названия не означает, что его там нет. :smiley: