Сеанс Scala-магии с неожиданным применением имплиситов

Jun 28, 2022 15:41

Такое встречается не так, чтобы очень часто, но регулярно.

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

Зачем?

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

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

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

Ну или, наконец, определённые действия всегда надо логировать.

В общем, какая-то такая ситуация.

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

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

И ладно если оно ещё в рантайме сыплет перехваченными ошибками (которые ведь тоже ещё надо как-то описать в коде). А если нет? Где теперь искать пропущенные обязательные обёртки?

Есть вариант: вписать прямо в сами методы то самое, что должно их оборачивать.

Но, к сожалению, это чревато.

Во-первых, у вас возникнет овердофига посторонних зависимостей между классами.

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

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

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

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

Тем не менее, после некоторых размышлений я вдруг осознал, что по крайней мере в Scala 3 возможен манёвр, который делает ровно вот это самое: заставляет вызывать одни функции внутри других, проверяя это на этапе компиляции.

И нет, оно без макросов - с имплиситами. Хотя каких-либо намёков на такой вариант их использования я пока что тоже не встречал.

Манёвр вот такой.

Сначала мы делаем где-то класс для «ключа» с произвольным именем. Например…

@implicitNotFound("Call this method only inside of \"saveSettings\"")
private class Key

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

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

Далее делаем класс, скажем, с настройками.

class MySettings {
def x = ""
def x_=(s: String)(using Key) = s
}

Тут в основном просто стандартные геттеры и сеттеры, однако ключевой момент тут - «using Key». То есть при вызове сеттера «x» в области видимости должен быть given экземпляр класса Key. Который, как мы помним, приватный, а потому хрен кто его создаст.

Кроме, конечно, нас самих, чем мы не постесняемся воспользоваться.

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

object SettingsManager {

private given Key()

def saveSettings(s: Key ?=> String) = println(s)
}

Финальная часть манёвра - стрелочка со знаком вопроса в сигнатуре «saveSettings». Она задаёт функцию, которая зависима от контекста. Фактически, это - аналог «using Key», но не в сигнатуре метода, а в декларации ссылки на функцию.

Скомпилируется она только в том случае, если там, где её пытаются вызывать, есть given экземпляр Key. Поскольку же это подразумевает «using», этот экземпляр будет передан в «текущий контекст», то есть в «тело вызова» saveSettings. А точнее в скобки, где будет описываться значение параметра «s».

Тот given Key, который лежит в SettingsManager, виден методу saveSettings, но не виден кому-либо извне SettingsManager, поскольку он - приватный. То есть никто не сможет просто импортировать что-то из SettingsManager, чтобы до добраться до given Key и тем самым попрать наши строгие правила. И создать given экземпляр самостоятельно он тоже не сможет, как уже говорилось выше.

Таким образом, чтобы вызвать сеттер «x» и что угодно другое, что имеет в сигнатуре «using Key», он будет вынужден «обернуть» этот вызов в saveSettings.

И вы не поверите, на этом месте проблема решена целиком и полностью.

val settings = MySettings()

// Будет ошибка компиляции с написанными нами разъяснениями
settings.x = "1"

SettingsManager.saveSettings {
// Всё отлично сработает
settings.x = "1"
}

Сам же код реализации всего этого не особо очевидный, но вместе с тем очень короткий.

@implicitNotFound("Call this method only inside of \"saveSettings\"")
private class Key

class MySettings {
def x = ""
def x_=(s: String)(using Key) = s
}

object SettingsManager {

private given Key()

def saveSettings(s: Key ?=> String) = println(s)
}

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

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

doc-файл

программирование

Previous post Next post
Up