Когда у языка нет цветовой дифференциации функций… то у языка нет цели?

Я уже много лет занимаюсь компиляторами и языками в целом. Хочу поделиться интересной мыслью, которая когда-то пришла мне в голову. Почему-то такого я нигде не видел.

Если немного расширить понятие функции (ввести атрибут «цвет»), можно описывать паттерны вида «вызывать логгер из performance-critical мест — это плохо» или «ходить в базу при рендеринге шаблонов запрещено».

Идея абсолютно не зависит от языка и применима к любому: хоть JS, хоть Go. Разберу её подробно в статье, это будет интересно больше с теоретической точки зрения. Хотя мы даже сделали практическую реализацию для PHP, чтобы использовать у себя. Ссылки на GitHub и видео приложу в конце, а пока обо всём по порядку.

Красные, зелёные и прозрачные

Представим, что есть атрибут @color над декларацией. Пусть будут красные функции и зелёные. А те, что без цвета — прозрачные.

Когда f1() вызывает f2(), их цвета смешиваются (добавляются в цепочку):

/** @color green */
function f1() { f2(); }

// без тега @color, прозрачная (transparent)
function f2() { f3(); }

/** @color red */
function f3() { /* ... */ }

Мы определили цвета, а теперь опишем палитру. Палитра — это паттерны смешивания цветов. К примеру, запретим вызывать красные из зелёных:

green red: "calling red from green is prohibited"

Цепочка "green transparent red" попадает под правило "green red", и поэтому "f1 → f2 → f3" триггерит ошибку. А просто "f1 → f2" — это цепочка "green transparent", уже нет. Это как паттерн-матчинг: "green yellow blue red" тоже попадает под "green red".

Думаем в терминах цветов: находим медленные места

Допустим, у нас есть медленные функции (работающие с базой данных или диском) и есть критичные к производительности (или быстрые) функции, задача которых — иметь минимальный оверхед.

Для наглядности пусть медленные будут красными, а быстрые зелёными:

Что такое потенциально медленное место в этом случае? Это если красная функция вызывается из зелёной. Как-то похоже на то, что выше, да? :)

На реальном примере:

// class ApiRequestHandler, зелёная
function handleRequest(DemoRequest $req) {
    $logger = createLogger();
    $logger->debug('Processing ' . $req->id);
}

// class Logger
function debug(string $msg) {
    DBLayer::instance()->addToLogTable([
        'time'    => time(),
        'message' => $msg,
    ]);
}

// class DBLayer, красная
function addToLogTable(array $kvMap) {
    // ...
} 

Будет такой граф вызовов (call graph):

И это снова попадает под правило "green red", только логически обозначает потенциальное замедление.

Не red и green, а вообще любое

На самом деле мы использовали красные и зелёные только для визуализации «красные медленные, зелёные быстрые». Но можно использовать непосредственно slow и fast:

/** @color slow */
function appendLogToTable() {}

/** @color fast */
function handleRequest() {}

И правило в палитре:

fast slow: "potential performance leak"

Любое свойство — это цвет:

  • @color slow, @color fast, @color performance-critical;

  • @color server-side-rendering, или просто @color ssr;

  • @color view и @color controller;

  • @color db-access;

  • ...и вообще что угодно.

У функции может быть одновременно несколько цветов. Например, api-entrypoint и slow.

Соответственно, в палитре мы описываем паттерны, которые отслеживаем. Например, контроллеры не могут зависеть от моделей:

controller model: "controllers may not depend on models"

Когда проаннотируем в коде все соответствующие классы и функции как @color controller и @color model, у нас будут проверки на это правило — на любом уровне вложенности.

Другие примеры:

ssr db: "don't fetch data from server-side rendering"

api-entrypoint curl-request: "performing curl requests on production is bad practice"

green yellow red: "a strange semaphore in your code"

Окей, запретить запретили, а как точечно разрешать?

Что, если хочется сказать «да, ходить в базу из SSR нельзя, но вот здесь, пожалуйста-пожалуйста, можно»? Или «это точно не скажется на производительности, потому что скрыто за if (debug)»?

Или вернёмся к коллграфу выше: как разрешить Logger::debug(), несмотря на правило "fast slow"?

Цвета и палитра — примерно как HTML и CSS. Не на 100% корректная аналогия, но позволяет понять, как описывать исключения.

Представьте, что каждая функция — это div'ник. Вызовы по коллграфу — это вложенность. А цвета — классы.

Если бы мы описывали правило "performance leak" в CSS, то писали бы так:

.fast .slow {
    error-text: "potential performance leak";
}

Конечно, CSS-правила могут быть любой вложенности, типа ".red .green .blue". Главное, что есть более специфичные селекторы. На этом и основана вся вёрстка:

.green .red {
    error-text: "don't call red from green";
}

.green .yellow .red {
    error-text: "a strange semaphore in your code";
}

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

.green .yellow .red {
    error-text: none;
}

Якобы используя CSS, убрали ошибку для этого селектора. Ну вы же догадываетесь, да?

.fast .slow-ignore .slow {
    error-text: none;
}

Цвета работают точно так же. Можно описывать более специфичные смешивания в палитре, которые перегружают или отменяют ошибки.

- fast slow: "potential performance leak"
- fast slow-ignore slow: ""

Тогда как разрешить Logger::debug() из fast-функций? Имея то правило в палитре, просто пометить:

/** @color slow-ignore */
function debug() { /* ... */ } 

Другой пример. Из server-side rendering загружать данные из БД нельзя. Но если специальным цветом пометить функцию, то можно:

- ssr db: "don't fetch data from server-side rendering"
- ssr allow-db db: ""

Нужно понимать, что должны проверяться все достижимые пути. К примеру, если есть другой путь по коллграфу из f() в h():

Первый путь — окей, а второй — это ошибка "f → g2 → h => don't fetch data from server-side rendering".

Неймспейсы — это тоже цвета?

Цвет может быть и над классом. Это означает, будто этот цвет написан над каждым методом.

/**
 * @color low-level
 */
class ConnectionCache { /* ... */ }

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

/**
 * @color VKApi\Handlers
 */
class AuthHandler { /* ... */ }

/**
 * @color VKDesktop\Templates
 */
function tplPostTitle($post) { /* ... */ } 

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

VKApi\Handlers VKDesktop\Templates: "using UI from api is strange"

И это тоже будет работать на любом уровне вложенности.

Можно даже эмулировать internal в языках, где его нет

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

namespace DBLayer\Internals;

// допустим, это какие-то внутренние утилиты
class LegacyIDMapper {
    static function mapUserId($user_id) { /* ... */ }
}
// и у вас есть высокоуровневая обёртка как публичный интерфейс
function transformLegacyUser(User $user) {
    $user->id = LegacyIDMapper::mapUserId($user->id);
    // ...
}

Даже если вы в Confluence напишете «Не используйте LegacyIDMapper, плиз!», его всё равно кто-то заиспользует, поскольку не запрещено языком.

А вот с цветами это решается действительно красиво. Сделаем два цвета:

/**
 * @color db-internals
 */
class LegacyIDMapper { /* ... */ }

/**
 * @color db-public
 */
function transformLegacyUser(User $user) { /* ... */ }  

И такие два правила в палитру:

- db-internals: "don't access db implementation layer directly"
- db-public db-internals: ""

И всё! Прямые вызовы из внешнего кода будут матчиться в первое правило и давать ошибку, а вызовы через обёртку — во второе. И если хочется из сторонней функции fff() всё-таки вызвать легаси, можно даже добавить правило:

- please-allow-legacy-here db-internals: "" 

Теперь можно проаннотировать fff() таким цветом — явно обозначая своё намерение и оставляя комментарий в коде. На код-ревью это тоже будет видно.

Использование на практике и выводы

Концепцию цветных функций я изначально придумал для KPHP, чтобы мы внутри монолита ВКонтакте могли изолировать участки кода и описывать правила вроде «лента не должна лезть в сообщения». Но идея и в целом получилась красивая, поэтому помимо решения для KPHP мы сделали отдельный инструмент и выложили его в open source под названием nocolor.

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

Идею можно развивать. Например, помимо цвета ввести насыщенность (saturation). Мол, не просто медленная, а медленная в такой-то степени. Например, для медленности мерой насыщенности может служить 95-й перцентиль исполнения согласно профилированию. Тогда не только цвета смешиваются, но ещё и интенсивности складываются. А через палитру мы триггерим ошибку — если, условно, потенциальный путь исполнения дольше трёх секунд.

Кстати, на прошлом PHP Russia я выступал с докладом, где рассказывал про всё это и делился подробностями реализации. А ещё объяснял, почему это всё-таки называется «цвет» и никак иначе. Посмотреть доклад можно по ссылке ниже.

Полезные ресурсы

Комментарии (28)


  1. jmdorian
    11.10.2022 12:50
    +5

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


    1. Akela_wolf
      11.10.2022 16:19
      +1

      1. Flying
        11.10.2022 19:51
        +1

        В статье у Романа есть ссылка на первоисточник, автор - Bob Nystrom.



  1. novoselov
    11.10.2022 12:54
    +5

    Приведенный пример усложняет существующий код и не решает глобальную проблему

    /** @color slow-ignore */ function debug() { /* ... */ }

    По сути вы делаете дополнительную обертку над существующей функцией ради навешивания атрибута, с тем же успехом можно было сделать добавление атрибута для инструкций

    /** @color fast */ function critical() {

    /** @color ignore */

    Log.debug();

    }

    При этом не обязательно заводить и прописывать правила для отдельных атрибутов, вместо этого можно использовать общий например @color ignore(slow, fetch, red)

    К тому же для полноценной поддержки вам придется размечать существующие сторонние библиотеки, например как это сделали в Intellij Idea для разметки nullability внутри JDK


    1. mayorovp
      11.10.2022 15:14
      +3

      Я так понимаю, идея-то в том, что функция debug является slow-ignore не потому что так захотелось, а потому что, к примеру, её вызовы вырезаются из кода на проде. Или потому что там внутри условный оператор, который на проде гарантировано не исполняется.


      Просто написав цветовой атрибут для инструкции вы никак не достигнете той же самой цели.


      1. unserialize Автор
        11.10.2022 20:24

        Спасибо за ответ. Только добрался до комментариев. Всё верно :)


    1. unserialize Автор
      11.10.2022 20:26

      Дельный комментарий.

      На часть пунктов ответил автор выше.

      По поводу сторонних библиотек — да, всё верно. Практическую сторону это омрачает. Но в плане самой идеи ничего не меняется, а делился я в первую очередь ей :)


  1. Mingun
    11.10.2022 13:36

    Давно пришла в голову такая идея, только для расчета сложности алгоритма в терминах Big-O. Чтобы нельзя было случайно сделать квадратичный алгоритм, вызывая в цикле линейный поиск. С поддержкой от компилятора была бы красота, только сложность в том, что принять за N?


    1. bfDeveloper
      11.10.2022 14:12
      +5

      Этим активно занимался Александреску, пытаясь внести в D. Но в итоге не получилось даже стандартную библиотеку нормально описать. Он обещает вернуться к этой задаче, но уже лет 5 никаких подвижек.

      https://forum.dlang.org/thread/58be13e9-91cc-9cdd-0c1f-e6c439aa8c53@erdani.org


  1. Akon32
    11.10.2022 15:57
    +1

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

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


    1. mayorovp
      11.10.2022 17:35

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


      1. Akon32
        11.10.2022 21:07
        +2

        Тогда получается, что цвет зависит от кучи разных параметров (да и ресурсы - это не только время). Почему бы не рассматривать параметры без дополнительных сущностей (цветов)?


        1. mayorovp
          11.10.2022 21:18

          Потому что эти параметры автоматически фиг проверишь, а проверятор цветов есть по ссылке в статье?


    1. Fen1kz
      11.10.2022 17:52
      +3

      Нет же, такие правила помогут писать менее запутанный код или находить запутанные места не только при обсуждении перформанса. В статье есть примеры что не надо вызывать контроллер из модели или ui из api




      Как бы вы не отмечали долгие функции как "прозрачные"

      автор прямо противопоставлял долгие функции "прозрачным"


      1. Akon32
        11.10.2022 21:03
        +1

        В статье есть примеры что не надо вызывать контроллер из модели или ui из api

        Но ведь это принцип инверсии зависимостей.


        1. MaryRabinovich
          11.10.2022 21:51

          Статью напишите, пожалуйста. А то попробовала вам карму поднять, но выше 4 нельзя без статей.


        1. Fen1kz
          11.10.2022 22:18
          +2

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


          1. Akon32
            11.10.2022 22:31

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


            1. Fen1kz
              12.10.2022 00:19
              +1

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

              Вы с тем же успехом могли сказать, что "Но ведь это open-closed principle" или "но ведь это связано с линтером".


              Для меня ваша высказывание равно, например: Модель не должна вызвать ui напрямую (это и проверяет механизм цветов), а если и вызывает, то может вызывать только корректным с т.з. линтера кодом.


              Что к статье не имеет ни малейшего отношения.


              1. Akon32
                12.10.2022 06:04

                Вы с тем же успехом могли сказать, что "Но ведь это open-closed principle" или "но ведь это связано с линтером".

                Нет, не мог.

                Для меня ваша высказывание равно, например: Модель не должна вызвать ui напрямую (это и проверяет механизм цветов), а если и вызывает, то может вызывать только корректным с т.з. линтера кодом.

                Что к статье не имеет ни малейшего отношения.

                Если вы настолько неправильно толкуете мои слова, друг друга мы не поймём. Не вижу смысла спорить дальше, обычно это ни к чему не приводит.


  1. Razoomnick
    11.10.2022 19:35
    +2

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

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

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

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

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


    1. unserialize Автор
      11.10.2022 20:36
      +1

      Хорошие пункты.

      По поводу классов и неймспейсов — я даже их немного упоминал в статье. Цвета кажутся более общим что ли свойством, точнее даже не свойством — это другой "вектор" кода, не зависящий от имплементации, на один класс разбита функциональность или на 10. А неймспейсы могут быть частным случаем цветов.

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

      Про "граф зависимостей можно посмотреть и глазами". Тоже так думал, пока не начал раскрашивать функции в нашем коде :)) Ох сколько там неявных зависимостей вылезло через 100500 слоёв, которых вообще не ожидаешь. Даже пришлось ввести специальный цвет "растворитель", который, смешиваясь с любым, превращает его в прозрачный (AnyColor + remover = transparent). В статье этого не упоминал, но в доке обозначил.


      1. MaryRabinovich
        11.10.2022 21:57
        +1

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

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

        Это тогда не про архитектурные паттерны, а про выживание при рефакторинге звонко чавкающего монолита. Такое расклеивание стикеров: "тут не влезай, убьёт", "а это розовая бумажка", "метод №1, передать в параметры что-то с синей бумажкой" и др. Цвет, конечно, помогает ориентироваться в хаосе - активизирует правополушарное художественное восприятие.


        1. mayorovp
          12.10.2022 18:16

          Какое такое хужожественное восприятие активируется при виде комментария /** @color slow */?


  1. maeris
    12.10.2022 04:16
    +2

    Немного перефразируя Кинга, у каждого начинающего разработчика языков программирования рано или поздно возникает идея о системе эффектов.

    Это активная тема исследований в теории типов последние лет 30, и весьма активно используется бекенд-разработчиками последние лет 5, даже если где-то язык не умеет это из коробки. State of the art можно посмотреть здесь. Может показаться, что это касается только функциональных ЯП, но это совсем не так.


  1. jorgen
    13.10.2022 13:03

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


  1. gev
    13.10.2022 17:07
    -2

    Лишь бы на хаскеле не писать ;)