Skip to the content.

11 Mar 2024

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

Гомоиконность

wikipedia

Идея в том, что код на языке программирования может быть представлен в виде данных и как-то преобразован. Пример - Lisp-подобные языки.

(* (+ 2 2) 2)

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

Для манипулирования с кодом в языке нужен механизм quoting (получить данные, описывающие код) и splicing (преобразовать эти данные обратно в код)

Evaluation strategy

wikipedia

Внезапно в википедии оказалась целая табличка с кучей разных способов, но глобально я бы их разделил на три:

  1. Call by value - то что есть в большинстве языков программирования, когда все аргументы функции вычисляются перед её вызовом.
  2. Сall by name - аргумент “лениво” передаётся в функцию и вычисляется только если понадобится, но может вычисляться несколько раз. Используется в обычных языках для boolean в выражениях типа bool1 && bool2.
  3. Call by need - как call by name, но результат первого вычисления запоминается. Используется в Haskell.

Всякие варианты типа передачи указателей или константных ссылок не вижу смысла рассматривать, можно считать что это call by value c передачей указателя на что-то.

В Scala есть поддержка варианта вызова по имени, и эта возможность крутая. Пример:

sealed trait MyBool
case object MyFalse extends MyBool
case object MyTrue extends MyBool

def f1(): MyBool =
    println("f1")
    MyFalse

def f2(): MyBool =
    println("f2")
    MyTrue

def myAnd(left: MyBool, right: => MyBool): MyBool =
    if (left == MyFalse)
        MyFalse
    else
        right

myAnd(f1(), f2())

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

кроме того, всякие конструкции типа

map.getOrElse(key, new Value())

не будут создавать new Value, если в map что-то есть. Очень удобно и красиво.

Синтаксис вызова функций

Самый известный как в Си:

f(1, 2, g())

В LISP открывающая скобка стоит до имени функции.

(f 1 2 (g ()))

Почему-то в лиспе мне периодически кажется, что в итоге скобочек слишком много.

Кроме того, в Haskell это могло бы выглядеть так:

f 1 2 (g ())

В принципе как в Lisp, но с меньшим количеством скобок. Причём с трочки зрения Haskell аргументы передаются по одному. Типа f принимает 1, возвращает функцию, в которую передают 2, та возвращает функцию, в которую передают (g ()). И можно передать пару аргументов, поделать какую-то логику, а потом передать ещё один аргумент.

В Scala такое тоже возможно, но синтаксис другой:

val func = f(1, 2, _)
func(g1 ())

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

Ещё есть забавный (и близкий к ассемблеру) вариант в языке Forth - там все аргументы просто кидаются на стек, а потом вызывается функция.

В некоторых языках (Groovy, Kotlin, Scala, C#) можно делать extensions methods

extension (a: Int) def myAdd (b: Int): Int = a + b

1.myAdd(2)

По-факту это те же самые функции от двух аргументов, но с другим синтаксисом для вызова.

Иногда языки разрешают опускать скобочки

1 myAdd 2

Я не встречал попыток обобщить этот подход на более общий случай, теоретически могло бы быть так:

def[T] if(cond: Boolean) then (body: => T) else (otherBody: => T): T = ...

тогда можно было бы вводить “новые” базовые конструкции языка.

Но остаётся открытым вопрос с разрешением неоднозначности таких конструкций. Например, если рядом будет второе определение, но без else:

def if(cond: Boolean) then (body: => Unit)

Парсер будет должен догадаться использовать более длинное определение.

Кортежи и Unit

В паскале и делфи были две отдельные сущности - процедуры и функции. Первые не возвращали ничего, вторые возвращали.

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

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

val (x, y) = getPair()

На тип Unit можно смотреть как на кортеж из нуля элементов. А на процедуру - как на функцию с возвращаемым типом Unit.

И если сделать в языке встроенную поддержку кортежей, то получается красивый универсальный вид:

  1. Любая синтаксическая конструкция в языке - это выражение. Просто некоторые из них возвращают Unit.
  2. Процедуры это функции, которые возвращают Unit
  3. Функция может возвращать любое количество аргументов
  4. Функция принимает любое количество аргументов (и они по-сути тоже кортеж)
  5. Легко писать функции-обрётки для других функций, например логирующие или запоминающие результаты.

Ещё кортежи можно назвать типами-произведениями.

Структуры и именованные кортежи

Если не обращать внимание на изменяемость, можно заметить что структуры в Си это по-сути кортежи, но у каждого поля есть удобное уникальное имя. Но, к сожалению, есть нюансы с работой sizeof() и структура без элементов может оказатья размером в один байт (а в идеале там должен быть нулевой размер, и ещё могут быть различия между С и С++)

На аргументы функции тоже можно смотреть как на именованный кортеж, в некоторых языках возможна запись вида f(x=1, y=1).

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

Или, например, можно будет определить Set<T> = Map<T, Unit> и в идеале не занимать место под хранение значений.

Симметрия относительно аргументов функции и результата.

В scala 3 появилась экспериментальная фича, которая повзоляет определить имена полей кортежа в сигнатуре функции. Причём это всё делается в compile time, в рантайме это самый обычный кортеж.

def divide(a: Int, b: Int): (quotent: Int, remainder: Int) =
  (quotent = a / b, remainder a % b).

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

Виды неизменяемости

Их несколько, и неплохо бы их различать:

  1. изменяемая переменная
  2. переменная, которую в текущем контексте изменить нельзя, но вообще говоря она может поменяться
  3. переменная, которая не будет изменена, пока на неё есть неизменяемая ссылка (как в Rust)
  4. неизменяемая переменная, которая когда-то инициализирована и больше никогда не поменяется
  5. выражение, которое можно посчитать во время компиляции
  6. константа (литерал) времени компиляции

Какие есть нюансы: В динамических языках понятия компиляции и выполнения смешаны и особых проблем нет. В статическом языке, если хочется иметь какие-то вычисления времени компиляции (привет constexpr в C++), по-факту придётся придумывать какое-то подмножество языка (или даже получать другой язык). Появятся всякие contexpr if, contexpr int f() и т.п. Возможно, если разрабатывать язык “с чистого листа”, то это получится более органично.

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

Для пунта 3: у ссылки есть lifetime, и пока ссылка актуальная, доступное по ней значение не изменится. Язык разрешает иметь либо одну изменяемую, либо много неизменяемых ссылок.

Для пункта 2 в С++ есть всякие константные ссылки и констанстные методы, но они действуют “целиком” на весь объект.

Теоретически можно изменяемость из пункта 2 сделать с помощью интерфейсов. Собственно, в JVM языках так обычно и делают.

В языках типа C++ и Rust сложно делать константные объекты, для них пропадает возможность перемещения. Для языков с GC такой проблемы нет.

Nullability

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

В Kotlin это сделано типом c ? в конце. Например

String // non nullable
String? // nullable
(String?)? // same as String?

Запись очень компактная, но мне такое не нравится. При обобщённом программировании это может доставить боль, потому что абстрактный тип T может быть любым. И что хуже, для T = String? получится что T? == T, но для T=String будет T != T?

На мой взгляд, это усложняет язык, и было бы удобнее иметь (с точки зрения системы типов) отдельные типы Option[String], Option[Option[String]] и т.п.

Вопросы со звёздочкой:

  1. как сделать, чтобы тип Option[Pointer] занимал места в памяти как обычный нулевой указатель?
  2. Что делать с Option[int] или абстрактным Option[T]?

Модели дженериков

Вдохновлено следующей статьей: https://thume.ca/2019/07/14/a-tour-of-metaprogramming-models-for-generics/

глобально есть два подхода:

  1. мономорфизация: сгенерировать новую версию кода для каждого типа. Сюда можно отнести дженерики, макросы, кодогенерацию. Языки - C, C++, Rust, Haskell, Go, Zig.
  2. boxing: смотреть на разные объекты каким-то общим способом.
    1. type erased generics - как коллекции в java, во время исполнения их тип не известен
    2. vtables - в объекте хранится таблица с методами (С++, Java, Go, Rust, Python)
    3. dictionary passing - передавать табличку с функциями. (тайпклассы в Haskell, witness table в Swift)

Минус мономорфизации - раздувание исходного кода и замедление компиляции. Минус боксинга - потенциально более низкая производительность.

В Ocaml забавный boxing - все объекты одинакового размера, но по самым первым битам можно узнать, что в объекте - реальное значение (int) или ссылка на что-то в куче. В Swift witness table содержат информацию о том, как объект двигать или копировать. И вдобавок в Swift есть аннотация @inlineable, чтобы сгенерировать быстрый код как в С++.

Ещё интересный момент - язык с JIT может сделать шаг от “универсальой” функции к мономорфизации и в горячих местах получить более производительный код.

Как можно создавать объекты

Подходов несколько:

  1. Статические константы и переменные (синглтоны), доступные всё время жизни программы. Из плюсов - простой подход, не надо думать про время жизни. Можно положить данные в область памяти, защищённую от записи. Такой подход часто используется в микроконтроллерах. Из минусов - подход применим только к штукам, которые точно будут нужны.
  2. Можно выделять объекты на стеке, бонусом идёт автоматическое освобождение памяти, минусом - от освобождения отказаться нельзя, это же место на стеке в будущем будет переиспользовано и объект “протухнет”. Размер стека ограничен (порядка 1-10Мб, при желании можно сделать больше).
  3. Можно создавать объекты в куче и управлять указателями на них вручную либо через подсчёт ссылок. Плюсы - объект лежит в куче, размер объекта не важен, можно передавать указатель на него. Минусы - выделение памяти в куче может быть чуть медленнее, чем на стеке. Возможны ошибки типа утечек памяти или попыток удалить объект несколько раз. Возможна фрагментация памяти, когда по ней раскиданы мелкие объекты и между ними не влазит что-то более большое.
  4. Region-based: под какую-то задачу создаётся регион памяти, в нём создаются временные объекты, потом при завершении задачи весь регион освобождается. Плюсы - простое и быстрое освобождение памяти. Частичная устойчивость к утечкам: они будут внутри региона. Регионы можно использовать по-разному: например, можно вообще не освобождать объекты, создавать каждый новый в новом месте, не использовать дестркуторы и потом освобождать всё целиком. Будет просто, быстро, но возможно не эффективно по потреблению памяти. Либо можно разрешить “освобождать” объекты в регионе, но появятся проблемы с фрагментацией памяти. Подход используется в геймдеве - например, можно сделать регион для объектов игрового уровня и при переходе игрока на другой уровень его освободить. Ещё вариация: кастомный аллокатор для какого-то конкретного типа. Например, можно сделать массив на тысячу игровых объектов, при “создании” аллокатор будет возвращать ссылку на какой-нибудь объект в массиве, при освобождении отмечать его как “неиспользуемый”. Плюсы: компактное расположение объектов, удобное итерирование по всем объектам этого типа. Минусы - вряд ли есть смысл делать отдельные аллокаторы для каждого типа. Если использовать заранее созданный массив какого-то размера, то будет ограничение сверху на количество объектов, причём память будет занята независимо от того, сколько реально объектов было создано.
  5. Использовать GC. На мой взгляд, между GC и подсчётом ссылок есть принципиальная разница: GC может двигать объекты, обновляя ссылки между объектами. Это позволяет избегать фрагментации, но лишает контроля над расположением объектов в памяти. Технически в языке с GC могут быть деструкторы (finalize в Java), но их использоване не рекомендуется. Из плюсов - утечку памяти сделать сложно. Код проще, особенно многопоточный. Вся сложность многопточного освобождения объектов и их обхода ложится на виртуальную машину, но она одна, а программ - много. Минусы - меньше контроля, потенциально более низкая производительность. Теоретически, виртуальная машина внутри может использовать регионы, как-то перекидывать объекты между ними и потом освобождать регион с неиспользуемыми объектами одним махом. Опять же, в качестве оптимизации виртуальная машина может располагать временные объекты на стеке, но это не гарантируется. Ещё важный момент - трудно “поженить” два языка, если в каждом из них свой GC, не знающий про другой. Кроме того, обычно в языке либо вообще нет GC, либо он есть для всего что есть в языке, “промежуточных” вариантов толком нет. Возможно, для “промежуточных” вариантов нужны какие-то доработки системы типов, чтобы различать контексты “внутри GC” и “без GC” и не давать ссылкам на GC-объекты уходить наружу. Так как GC должен знать про все ссылки на GC-объект, чтобы его перемещать или освобождать. Но при этом из мира “без GC” должна быть какая-то возможность манипулировать объектами, например в имплементации GC.

Исключения

  1. Можно их не использовать (C, Go, Rust). Язык становится проще. Из минусов - приходится явно передавать коды ошибок или ещё что-то по цепочке и иметь накладные расходы на их передачу/проверку. Обычно для непредвиденных случаев всё ещё остаётся exit(0) или её аналог для фатальной ошибки.
  2. Checked exceptions. Исключения, которые надо явно указывать в сигнатуре функции. И язык и код на нём становятся сложнее, причём бывают случаи, когда в сигнатуре интерфейса исключение есть, а на практике его никто не кинет, но обрабатывать всё равно надо. По-факту идея не очень удобная, в языках Scala/Kotlin от неё отказались.
  3. Unchecked exceptions. Плюсы - обычно нет больших накладных расходов, если код не кидает исключений. Если код их кидает, производительность может сильно просаживаться. Минусы - язык становится сложнее, исключение может прилететь из какого-нибудь неожиданного места. Усложняется язык программирования, для RAII нужна специальная поддержка.

Fun fact - В Python код, бросающий исключения, работает почти так же медленно, как и нормально исполняющийся, просто питон сам по себе очень медленный.

Ковариантность и контрвариантность

wikipedia

Допустим, есть тип Animal и от него унаследован тип Dog, а от него Corgi. Запишем это как Animal :> Dog и Dog :> Corgi

Ковариантность: если Animal :> Dog, то (Animal => T) :> (Dog => T) Контрвариантность: если Animal :> Dog, то (T => Dog) :> (T => Animal), отношение в обратную сторону Инвариантность: допустим, если есть клетка с собакой Box[Dog], собаку можно положить или достать. Но “положить” и “достать” дают ограничения с двух сторон, мы не можем положить животное в клетку и не можем достать из клетки корги, там может быть любая собака. В итоге клетка для собак и клетка для корги - совершенно разные объекты. Нонвариантность - вариант, который обычно не упоминают (а может я его неправильно понимаю). Но иногда тип может быть вообще не важен. Например, если мы хотим узнать, какой цвет у клетки, нам вообще без разницы для кого она сделана. def getColor : Box[T, Color] => Color

В принципе эти отноешения есть везде где есть дженерики, но местами это “спрятано под капот” и сделано через bridge methods в JVM или ещё как-то.

Линейные типы

википедия: Substructural type system

Практическое применение: концепция владение объектом, его нужно удалить ровно один раз. Иcпользуется в Rust. Есть обобщения с менее жёсткими ограничениями типа “использовать не больше одного раза” или “использовать как минимум один раз”.

Минусы: на мой взгляд, в Rust это сделано неудобно, местами накладывает кучу ограничений. Это не значит что сама идея линейных типов плоха, возможно в будущем будет какое-то развитие в плане гибкости и удобства.

Было бы очень интересно скрестить их с языком с GC, это дало бы интересные возможности:

  1. Можно снизить нагрузку на GC и явно указать, что какие-то штуки будут удалены и когда именно
  2. Язык бы позволил писать и низкоуровневый код без GC, и высокоуровневый с ним. Сейчас такое достигается, когда например пишут библиотеки на Си и потом используют из Python, но это два совсем разных языка, а не один универсальный.
  3. GC - не единстенный способ, есть ускоспецефичные подходы типа использования аллокаторов, которые хорошо работают в некоторых случаях. Например, когда освобождается вся арена без удаления каждого объекта.

Локальные функции

В Си нельзя объявить функцию внутри функции. Плюсы - простота, минусы - раздувание кода функций, неудобства в написании кода.

С++: можно объявить лямбду внутри функции. Необходимо явно указывать, что и как “захватывается”. Между лямбдами и функциями есть разница.

Kotlin: можно объявить функцию внутри функции, но есть ограничения - например, локальная функция не может иметь модификатор inline. “Захват” происходит автоматически (по факту создаются временные объекты-ячейки с переменными)

Scala: как в Kotlin, но меньше ограничений.

Scope для переменных

  1. Переменные объявляеются до блока или в его начале. Pascal, Delphi, старые стандарты Си. Самый простой подход
  2. Общий скоуп для переменных внутри функции. Часто используется в интерпретируемых языках, чтобы на весь вызов функции создать только одну табличку. Минус - у конструкций типа циклов и т.п. нет своего скопуа, временные переменные “утекают” в скоуп функции.
  3. Переменные объявляются в любом месте блока, тип фиксирован. Подход большинства языков.
  4. Каждое следующее выражение в блоке открывает “свой” скоуп, и в нём типы переменных могут быть другими. Используется в Kotlin и Rust

Пример для Rust

fn f() {
    let a = 1;
    let a = a.to_string();
}

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

Пример для Kotlin (там это называется smart cast )

fun f(a: String?) {
    if (a == null) return;
    val nonNullableA: String = a;
}

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

fun f(a: String?, b: String?) {
    if (a == null || b == null) return;
    val nonNullableA: String = a;
    val nonNullableB: String = b;
}

С предыдущим кодом компилятор ещё справляется, а вот со следующим уже нет:

fun f(iter: Iterable<String?>) {
    val iter2: Iterable<String> = iter.filter{ it != null } // error!!
}

Что можно делать с абстрактными объектами

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

  1. Сравнивание ссылок на объекты: в Jvm для Value Objects от него хотят отказаться. Это даст JVM больше свободы, такие объекты можно будет копировать и перемещать.
  2. Сравнивание объекта с объектом другого типа: Нужно далеко не всегда. При сравнении, допустим, String и Int, результат всегда False и скорее всего код некорректный.
  3. Вычисление hash: в JVM оно есть для любого объекта, но возможно это должно быть явным интерфейсом.
  4. Клонирование объекта: опять же, эта фича должна быть интерфейсом, а не возможностью любого объекта
  5. toString(): метод, очень удобный для отладки и т.п., но не факт что он нужен всегда и везде. Опять же, возможно он должен быть интерфейсом.
  6. синхронизация но объекте - всегда доступна в Java, но спустя кучу лет это не кажется хорошим решением.
  7. копирование объектов - всегда возможно в java (включая копирование ссылок на объекты), но например не всегда доступно в C++.
  8. присваивание объекта куда-нибудь - можно запретить в С++ и Rust.
  9. взятие ссылки на объект - невозможно для примитивных типов в Java.
  10. сохранение ссылки на объект - ограничено в Rust временами жизни.
  11. явное удаление объекта: обычно в GC языках нет таких гарантий. Если хочется удалить - делается какой-то метод, который переведёт объект в какое-то “терминальное” состояние, но нет контроля над тем, сколько ещё объект будет существовать.

Корутины

В разных языках сделаны по-разному.

Корутины - это не промногопоточность, а про возможность управлять потоком выполнения - приостанавливать, возобновлять, отменять. И вот эти возможности позволяют (но не обязывают) использовать многопоточность.

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

В kotlin из кода корутины компилятор делает конечный автомат (внутри есть int label для хранения номера состояния и switch по всем возможным состояниям). Вызов suspend функции может возвращать специальный объект kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED по которому вызывающая сторона понимает, что вычисление ещё не завершилось и тоже возвращает COROUTINE_SUSPENDED дальше. Корутина - не про многопоточность, а про управление потоком исполнения, всё может прекрасно работать и в одном потоке.

Интересный момент: в Python корутины и генераторы сделаны по-разному. В Kotlin это одна и та же сущность. Вдобавок корутины в котлин не прибиты гвоздями к языку, можно написать какую-то свою имплементацию, например свой класс для sequence или для какой-то своей модели корутин. Пример такого самодельного класса

Константность и дженерики

В C++ есть интересный момент - для методов есть const-qualifiers. Методы с ним не могут менять объект. В других языках такой возможности иногда не хватает.

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

Представим, что у нас есть типы: ConstValue :> Value. У ConstValue есть только не изменяющие методы, Value - его подтип с методами, меняющими состояние.

предположим, что есть тип

class Pair[+T1, +T2](val first: T1, val second: T2)

посскольку Value - подтип ConstValue, можно сказать, что Pair[Value, Value] - это подтип Pair[ConstValue, ConstValue].

Но что самое прикольное - ещё возможны типы Pair[Value, ConstValue] и Pair[ConstValue, Value], можно точечно управлять тем, какуя часть объекта можно менять и какую нет.

Чистые функции и сайд-эффекты

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

Абстрактная модель vs низкоуровневый код

Где то есть заметка Линуса о том, что в Си не нужен bool, а нужно честно возвращать byte или char, потому что процессор именно это и делает.

С другой стороны стоят языки типа Haskell, где декларативное описание это всё, а низкоуровневой реализации не уделяется никакого внимания.

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

У меня пока не сложилось чёткого понимания как надо, но кажется что “абстрактную модель” и “способ разложения объектов по памяти и компиляции в код” надо разделять. Возможно, оставлять какие-то подсказки для компилятора или описывать такие детали отдельно. Причём не везде в обязательном порядке, а только там, где это важно.

Data-oriented design

Современные процессоры становятся всё мощнее, а память не так радикально ускоряется. Растёт bandwidth, но толком не улучшается latency. Скорость света не обманешь, за один такт на 3 Ггц свет пробегает десять сантиметров.

Возможно, что современный язык программирования должен давать удобный контроль над расположением объектов в памяти и эффект от этого будет намного лучше, чем от каких-то хитрых оптимизаций компилятора. Может быть, что даже интерпретируемый язык с data-oriented подходом сможет показать очень хорошую производительность, сравнимую с компилируемым языком. Примером можно назвать библиотеку numpy для Python - снаружи удобный интерфейс, внутри оптимальная для процессора раскладка многомерных тензоров.

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

Частично я двохновлён вот этим видео: https://youtu.be/rX0ItVEVjHc, оказывается доступ к памяти реально очень медленный и неудачное расположение данных приведёт к тому, что 90% времени будет тратиться на ожидание, и никакой супер-оптимальный компилятор не сможет это улучшить.

JIT и инлайнинг кода на лету

Есть фреймворк Truffle и на нём truffle-ruby работает раза в четыре быстрее стандартной виртуальной машины Руби. Как это получилось? А вот так - разработчики пишут интерпретатор языка на java (причём даже не сложную виртуальную машину, а просто вычисление AST), расставляют кучу всяких аннотаций, а потом за дело берётся JIT и агрессивно инлайнит всё, включая действия интерпретатора.

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

Из красоты идеи - в теории, даже можно вызывать такой код из java и наоборот и вообще произвольно смешивать языки друг с другом. Разработчики даже написали экспериментальный интерпретатор java. Например, в теории так можно запустить интерпретаторы java 7 или java 24 на виртуальной машине, допустим, с java 22. На практике такая интерпретация в несколько раз медленее просто jvm с java - надеюсь, в будущем станет лучше.

Самое лучшее описание, которое я нашёл - вот этот цикл статей: https://www.endoflineblog.com/graal-truffle-tutorial-part-1-setup-nodes-calltarget. Проблема только в том, что в нём 16 частей и автор пишет их уже 5 лет. И это не потому что автор медленно пишет - нет, просто truffle требует реализовать интерпретатор языка как довольно замороченную модель с кучей нюансов.

Подходы к сборке мусора и удалению объектов

  1. Переложить всё на программиста (ассемблер, си)
  2. Переложить всё на систему типов и программиста (RAAII в С++, Rust) - норм, но подходит не для всех сценариев.
  3. Подсчёт ссылок - если код многопоточный, то нужны синхронизации на каждый захват/освобождение ссылки. Не спасает от кольцевых ссылок
  4. Кастомные аллокаторы - сильно зависят от сценария, позволяют пожертвовать чем-то ради производительности. Например, отказаться от деструкторов и одним махом освободить арену с объектами

Языки с GC стоят особняком, потому что GC есть куча разных с самыми разными свойствами.

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

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

Минусы - очень сложно “поженить” друг с другом два разных языка с разными GC. (Например, Python и Lua друг с другом) Становятся возможны кольцевые ссылки и так же появляются ограничения на то, как одному GC двигать объекты, потому что объекты из второго языка могут на них ссылаться.