?

Предыдущий пост поделиться пожаловаться Следующий пост
А потом…
lex_kravetski
После нескольких лет полноценного использования Wolfram я реально подсел на конструкцию, которая оказалась удивительно удобной для лаконичной и одновременно с тем хорошо понятной записи последовательных рассуждений. Ну или, если вам угодно, цепочки преобразований.

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

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

Причём для длинной цепочки оба «стандартных» варианта выглядят очень так себе.

В варианте…

f5(f4(f3(f2(f1(1)))))


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

Если же это разнести по отдельным строкам, то получится вариант…

f5(
  f4(
    f3(
      f2(
        f1(1)
      )
    )
  )
)


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

К этому добавляется целая куча скобок, что даже с плагинами вида «Rainbow Brackets» всё равно выглядит весьма запутанно.

Но самое главное, происходящее тут записано в обратном порядке.

Поскольку мы читаем слева направо и переходим по строкам сверху вниз, для нас это читается как «сделать f5… с… хм… тем, что вернулось, когда мы сделали f4 c… хм… тем, что получилось, когда мы сделали f3 с…». То есть это что-то типа ребуса, который заставляет читателя удерживать в своей временной памяти некий «стек вызовов» — примерно так, как это делает компьютер, исполняющий данную программу.

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

Однако происходящее отлично описывается на любом разговорном языке. Мы в таких случаях просто говорим «надо взять число, а потом сделать с ним f1, а потом сделать с результатом f2, а потом сделать с результатом f3…».

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

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

val r1 = f1(1)
val r2 = f2(r1)
val r3 = f3(r2)
val r4 = f4(r2)
val r5 = f5(r4)


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

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

Вы, например, заметили таковую в только что увиденном вами примере?

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

И на этом месте яркими красками начинает играть та самая конструкция Wolfram, которая мне очень нравится. В нём…

x // f1 // f2 // f3 // f4 // f5


…это то же самое, что

f5[f4[f3[f2[f1[x]]]]]


Таким образом «//» буквально можно читать, как «а потом». Эта конструкция, прямо как и хотелось, сохраняет «линейность прочтения» и не плодит уровни вложенности, даже если её разбить на отдельные строки.

res = x //
      f1 //
     f2 //
    f3 //
   f4 //
  f5


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

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

Например, в Scala мы могли бы создать аналогичную функцию с «линейным» прочтением.

val g =
	f1 _ andThen
	f2 andThen
	f3 andThen
	f4 andThen
	f5
			 
val res = g(1)


В Scala 3 подчёркивание после f1 можно не ставить.

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

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

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

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

val g1 = f5 compose f4 compose f3 compose f2 compose f1

def g2(x: Int) = f5(f4(f3(f2(f1(x)))))


Ну да, при определении g2 нам пришлось в явном виде указать имя параметра и его тип, однако не факт, что эта запись в сумме оказалась менее понятной, чем вариант g1. Но вот короче-то она, это точно.

Тем более, если функция конструируется для передачи куда-то по ссылке, то там даже тип параметра указывать не надо.

list.map(x => f5(f4(f3(f2(f1(x))))))


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

Однако с обратным комбинатором «а-потом» смысл ещё как появляется — такой комбинатор организует сабжевое «линейное» прочтение процесса вычислений.

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

Сравните…

val res =
	list
	 .map(f1)
	 .map(f2)
	 .map(f3)
	 .map(f4)
	 .map(f5)


…и…

val res = (
	f1 _ andThen
	f2 andThen
	f3 andThen
	f4 andThen
	f5
)(x)


Да, замечательно было бы, если бы во втором случае x можно было бы написать в начале, как в Wolfram, а не в конце, и ещё «andThen» заменить бы на какой-то более короткий символ, но уже и так неплохо.

Поэтому лично мне весьма печально, что этот способ записи почти никогда не используется в Scala и ему подобных языках (я его ни разу не встретил даже в стандартной библиотеке — то есть его не используют даже сами разработчики языка!).

Впрочем, в сотнях просмотренных примеров программ на Wolfram я этот вариант тоже почти никогда не встречал: и там тоже программисты предпочитают писать вложенные вызовы даже тогда, когда линейная запись была бы радикально проще —как для прочтения, так и для написания.

Например, вот тут…

HistogramList[Map[RandomReal[{min, max}, {nTests, nInTest}], Total], 11]


…не так-то легко сходу понять, что происходит и с какой целью это написано.

Однако стоит превратить вложенную запись в линейную, как суть процесса становится гораздо более понятной.

(* Сгенерировали двумерный массив случайных данных*)
RandomReal[{min, max}, {nTests, nInTest}] //

	(* В каждой строке массива посчитали сумму элементов *)
	Map[Total] //

	(* Разбили диапазон результатов на 11 равных диапазонов и посчитали,
	сколько сумм случайных чисел попадает в каждый из них *)
	HistogramList[#, 11] &


Заодно появились места, куда можно вписать поясняющие комментарии — к каждому шагу «линейной» цепочки «а-потомов», между строк.

Но можно было бы и без комментариев — всё равно так гораздо более понятно, чем в оригинале.

RandomReal[{min, max}, {nTests, nInTest}] //
    Map[Total] //
  HistogramList[#, 11] &


В последней строке тут создаётся безымянная функция.

Дело в том, что для Map в Wolfram сразу заведён вариант «с двумя парами скобок»…

Map[f][list]


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

Однако для HistogramList такого варианта нет.

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

В Scala можно сделать аналогичное. Так, если у нас есть некоторая функция…

def histogramList(data: Seq[Number], nBins: Int) = ???


…то мы можем превратить её в функцию с одним параметром, написав…

histogramList(_, 11)


…и дальше использовать её в конструкции с «а-потомами».

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

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

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

В общем, проще читать, проще писать, проще править — настоятельно рекомендую.

А да, кстати, можно ли в Scala сделать так, как это в Wolfram?

Ну, как минимум, в Scala 3 — можно.

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

И при этом не нужно будет писать расширение к компилятору или использовать макросы.

Что ещё лучше, в отличие от Wolfram, функциональность данной конструкции окажется расширенной до функций с любым количеством аргументов. То есть, если…

def f(x: Int, y: Int) = (x + 1, y + 1)

def g(x: Int, y: Int) = x + y


…то…

(2, 2) |> f |> g


…будет эквивалентно…

g(f(2, 2))


Знаете, сколько строк понадобится, чтобы добавить такую функциональность?

Одна!

extension [T, R](x: T) inline def |>(f: T => R) = f(x)


Что тут происходит? Для произвольного типа T объявляется метод–расширение, который принимает аргумент типа «функция с аргументом типа T, возвращающая результат с произвольным типом R». Этот метод-расширение просто вызывает f(x).

Если он находится в области видимости, то, если компилятор встретит конструкцию вида «x |> f», и у класса переменной «x» метод «|>» не заявлен, компилятор попытается трактовать этот текст как вызов метода–расширения из области видимости. Поскольку же тот добавлен для любых произвольных типов, такая трактовка компилятору удастся.

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

Причём типы останутся строгими — компилятор не даст вызвать функцию у несовместимого с ней предыдущего результата.

Новое в Scala 3 ключевое слово «inline» приводит к тому, что тело функции будет вписываться на место её вызова, то есть никаких новых объектов не создастся.

А поскольку «|>» не содержит букв, то в Scala 3 не нужно отдельно указывать, что он может быть инфиксным. И по той же причине с него может начинаться строка: компилятор поймёт, что это — продолжение предыдущей строки, а не какая-то отдельная переменная.

Кроме того, в Idea «|>» отображается в виде похожего на стрелку треугольника, что ещё более упрощает прочтение.





doc-файл
публикация в блоге автора




  • 1
Вообще-то можно было так:
val r = f1(1)
r = f2(r)
r = f3(r)
r = f4(r)
r = f5(r)

Edited at 2021-04-30 11:58 (UTC)

Только если «var», а не «val». Это с одной стороны убавляет, а с другой добавляет траблов: значение этой переменной можно случайно изменить в процессе — там ведь не всегда стоят только имена функций, можно, например, вписать выражение и перепутать «==» с «=». Но, таки да, и этот вариант тоже используется.

В твоем варианте val, а язык ты не указал, поэтому я по аналогии.

> «надо взять число, а потом сделать с ним f1,
> а потом сделать с результатом f2, а потом сделать с результатом f3…».

Неаппликативная композиция называется.
Часто обозначается ещё через точку с запятой.
А где-то, и вообще через пробел :-)

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

> Неаппликативная композиция

Эти термины, они ведь вообще ничего не проясняют.

Бинарная операция композиции, которая берёт функции-стрелки в другом порядке.
А что тут ещё больше пояснять?

Например «операция композиции» и «функции–стрелки».

Гораздо проще написать «x // f // g — то же самое, что g[f[x]]».

Вы только что открыли "польскую запись/нотацию".

Польская запись не такая. Там пишется «2 3 + 4 *», а в этой записи оно было бы «2 // (_ + 3) // (_ * 4)».

Или «(2, 3) // plus // times(4)», если у нас есть такие функции.

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

  • 1