Lex Kravetski (lex_kravetski) wrote,
Lex Kravetski
lex_kravetski

Category:

Тенденция к локальным контекстам

Я ещё помню те времена, когда мощным современным языком считался чистый C, где, в частности, существовали заголовочные файлы.

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

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

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

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

Однако, вы знаете, в самой идее тогдашнего, так и не взлетевшего способа подмены реализации было зерно истины. Сделанное, конечно, через жо, но всё-таки содержащее в себе некую находку.

Вот в чём проблема, которую как-то так можно было бы решать.

Есть у нас, некий алгоритм с овердофига вариантов комбинаций его составных частей. И из этих частей мы хотим как бы собирать частные реализации этого алгоритма.

Если пойти тупо в лоб и передавать внутрь алгоритма функции, то у нас получится примерно следующее.

type F = Int => Int

def level1(init: Int, alg1: F, alg2: F, alg3: F) = {
 val x = alg1(init)
 level2(x, alg1, alg2, alg3)
}

def level2(x: Int, alg1: F, alg2: F, alg3: F) = {
 val y = x + alg1(x)
 level3(y, alg1, alg2, alg3)
}

def level3(x: Int, alg1: F, alg2: F, alg3: F) = {
 alg2(x) + alg3(x)
}


Я написал коротко, но уже видно, в чём тут фигня.

С одной стороны, очень много аргументов. И что особенно обидно, часть этих аргументов нужны только для того, чтобы передать их по цепочке в более глубокую функцию. Например, alg2 и alg3 используются только внутри level3, однако заводить эти параметры приходится и в level1 с level2 тоже.

С другой стороны, это как бы всё равно лишь полугибкость. Что-то мы заменить можем, но как быть, если захочется заменить внутри level2

val y = x + alg1(x)


…на что-то ещё? Оно ведь прямо в код прямым текстом вписано. Завести ещё больше ссылок на функции?

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

Ещё можно сделать фабрику, которая будет по каким-то идентификаторам создавать разные конфигурационные объекты при первом обращении по каждому идентификатору, а потом их возвращать при повторных запросах. Тогда вместо объекта можно передавать идентификатор конфигурации. В чём-то так будет проще, в чём-то немного сложнее, но, блин, поверьте, и так я тоже неоднократно пробовал — оно тоже раздражает и тоже кажется избыточным.

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

Что-то типа

type F = Int => Int

object Context {
 var alg1: F = ???
 var alg2: F = ???
 var alg3: F = ???
}

import Context.*

def level1(init: Int) = {
 val x = alg1(init)
 level2(x)
}

def level2(x: Int) = {
 val y = x + alg1(x)
 level3(y)
}

def level3(x: Int) = {
 alg2(x) + alg3(x)
}


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

Теперь в тех местах, где будет вызываться level1, можно пихать в Config нужные методы, а то даже и менять их на лету — перед вызовом level1 для каждого элемента контейнера, не заморачиваясь с передачей параметров по цепочке.

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

Но всё равно остаётся проблема: в контекст кто угодно, имеющий к нему доступ, может пихнуть что-то своё, чего мы не ожидаем. Что будет особенно печально в многопоточном приложении, где что-то пихнуть в этот объект можно в любой момент исполнения нашего кода.

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

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

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

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

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

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

Частично оно уже реализовано в виде анонимных экземпляров: то есть возможности определить класс прямо внутри кода.

trait Context {
 var alg1: F
 var alg2: F
 var alg3: F

 def level1(init: Int) = {
  val x = alg1(init)
  level2(x)
 }

 def level2(x: Int) = {
  val y = x + alg1(x)
  level3(y)
 }

 def level3(x: Int) = {
  alg2(x) + alg3(x)
 }
}

println(
 new Context {
  var alg1 = _ * 10
  var alg2 = _ + 20
  var alg3 = _ - 1
 }.level1(1)
)


…и, таки да, во множестве случаев этого достаточно.

Ну, в тех, где можно обойтись одним контекстом.

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

В том числе для обхода этого затруднения в Scala используются неявные значения.

Дальнейший пример относится к Scala 3 — до третей версии всё это называлось «implicit».

type F = Int => Int

trait Algs {
 def alg1: F
 def alg2: F
 def alg3: F
}

def level1(init: Int)(using algs: Algs) = {
 val x = algs.alg1(init)
 level2(x)
}

def level2(x: Int)(using algs: Algs) = {
 val y = x + algs.alg1(x)
 level3(y)
}

def level3(x: Int)(using algs: Algs) = {
 algs.alg2(x) + algs.alg3(x)
}

{
 given Algs = new {
  def alg1 = _ * 10
  def alg2 = _ + 20
  def alg3 = _ + 1
 }

 println(level1(1))
}


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

Таким способом частично исправляется проблема потенциально большого количества схожих контекстов: контекст типа Algs можно использовать в овердофига местах, а потом единственным «given Algs» предоставить всем функциям, которые нуждаются в Algs, реализацию оного. Так, например, level1, level2 и level3 будут использовать один и тот же экземпляр типа Algs, хотя никаким образом его друг другу не передают и нигде не сохраняют.

Причём предоставленная через given реализация будет как бы помещена в локальный контекст данного участка кода — она никак не повлияет на другие участки и случайно вложить её в свой контекст, в отличие от глобальных переменных, уже не получится (потребуется прямой import, причём с дополнительным словом given).

В данном примере экземпляр Algs помещается в локальный контекст от строки с «given Algs = new …» до закрывающей фигурной скобки после println. Вне этого блока предоставленного экземпляра нет, поэтому в других местах надо заводить или импортировать свой собственный.

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

val a1 = new Algs {
 def alg1 = _ * 2
 def alg2 = _ * 3
 def alg3 = _ * 4
}

val a2 = new Algs {
 def alg1 = _ + 1
 def alg2 = _ + 2
 def alg3 = _ + 3
}

val list = List(1, 2, 3)

val res = list.map {
 x =>
  given Algs = if (x > 2) a1 else a2

  level1(x)
}

println(res)


Но ещё более интересный вариант, который всё больше и больше пленяет моё сердце, есть в языке Wolfram.



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

Ответ таков: это просто некие символы. И, таки да, они никак не определены, однако Wolfram по этому поводу совершенно не напрягается: «alg1[1]» имеет значение «alg1[1]», пока вы не наделите символ alg1 ещё каким-то значением.

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

Вообще говоря, безо всяких определений можно вызвать level1[1] и получить результат.

alg2(alg1(1) + alg1(alg1(1))) + alg3(alg1(1) + alg1(alg1(1)))


Этот результат можно запомнить, и потом «вызвать» внутри Block, где определены algN и получить число. А можно не вызывать, но превратить в функцию. А можно определить некоторые из algN, но не все.

Это уже практически совсем прямая версия с локальным контекстом, которую портит разве что некоторая синтаксическая громоздкость конструкций Block в языке Wolfram.

Скрестить бы это как-то со Scala — вообще было бы отлично.



doc-файл

Tags: программирование
Subscribe

  • Post a new comment

    Error

    Anonymous comments are disabled in this journal

    default userpic

    Your reply will be screened

    Your IP address will be recorded 

  • 21 comments