11 Mar 2024
Текст буду дописывать, сейчас он ближе к перечислению интересных идей и концепций из разных языков.
Гомоиконность
Идея в том, что код на языке программирования может быть представлен в виде данных и как-то преобразован. Пример - Lisp-подобные языки.
(* (+ 2 2) 2)
Можно смотреть на это как на код, а можно как на список. Структура списка совпадает с синтаксическим деревом. Такой подход даёт шикарную поддержку метапрограммирования, макросы манипулируют с кодом как с обычными списками, а потом код можно запустить.
Для манипулирования с кодом в языке нужен механизм quoting (получить данные, описывающие код) и splicing (преобразовать эти данные обратно в код)
Evaluation strategy
Внезапно в википедии оказалась целая табличка с кучей разных способов, но глобально я бы их разделил на три:
- Call by value - то что есть в большинстве языков программирования, когда все аргументы функции вычисляются перед её вызовом.
- Сall by name - аргумент “лениво” передаётся в функцию и вычисляется только если понадобится, но может вычисляться несколько раз. Используется в обычных языках для boolean в выражениях типа
bool1 && bool2
. - 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.
И если сделать в языке встроенную поддержку кортежей, то получается красивый универсальный вид:
- Любая синтаксическая конструкция в языке - это выражение. Просто некоторые из них возвращают Unit.
- Процедуры это функции, которые возвращают Unit
- Функция может возвращать любое количество аргументов
- Функция принимает любое количество аргументов (и они по-сути тоже кортеж)
- Легко писать функции-обрётки для других функций, например логирующие или запоминающие результаты.
Ещё кортежи можно назвать типами-произведениями.
Структуры и именованные кортежи
Если не обращать внимание на изменяемость, можно заметить что структуры в Си это по-сути кортежи, но у каждого поля есть удобное уникальное имя.
Но, к сожалению, есть нюансы с работой 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).
На мой взгляд это очень красиво - подобно тому, как мы задаём имена аргументов, можно так же задать имена у возвращаемых полей. Такого можно добиться в Си, если создавать структуру и возвращать, но момент в том, что определение структуры придётся как-то отдельно задавать и обычно людям лень такое делать. Начинаются извращения типа передачи указателя на место, куда надо записать результат и т.п.
Виды неизменяемости
Их несколько, и неплохо бы их различать:
- изменяемая переменная
- переменная, которую в текущем контексте изменить нельзя, но вообще говоря она может поменяться
- переменная, которая не будет изменена, пока на неё есть неизменяемая ссылка (как в Rust)
- неизменяемая переменная, которая когда-то инициализирована и больше никогда не поменяется
- выражение, которое можно посчитать во время компиляции
- константа (литерал) времени компиляции
Какие есть нюансы:
В динамических языках понятия компиляции и выполнения смешаны и особых проблем нет. В статическом языке, если хочется иметь какие-то вычисления времени компиляции (привет 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]] и т.п.
Вопросы со звёздочкой:
- как сделать, чтобы тип
Option[Pointer]
занимал места в памяти как обычный нулевой указатель? - Что делать с
Option[int]
или абстрактнымOption[T]
?
Модели дженериков
Вдохновлено следующей статьей: https://thume.ca/2019/07/14/a-tour-of-metaprogramming-models-for-generics/
глобально есть два подхода:
- мономорфизация: сгенерировать новую версию кода для каждого типа. Сюда можно отнести дженерики, макросы, кодогенерацию. Языки - C, C++, Rust, Haskell, Go, Zig.
- boxing: смотреть на разные объекты каким-то общим способом.
- type erased generics - как коллекции в java, во время исполнения их тип не известен
- vtables - в объекте хранится таблица с методами (С++, Java, Go, Rust, Python)
- dictionary passing - передавать табличку с функциями. (тайпклассы в Haskell, witness table в Swift)
Минус мономорфизации - раздувание исходного кода и замедление компиляции. Минус боксинга - потенциально более низкая производительность.
В Ocaml забавный boxing - все объекты одинакового размера, но по самым первым битам можно узнать, что в объекте - реальное значение (int) или ссылка на что-то в куче.
В Swift witness table содержат информацию о том, как объект двигать или копировать. И вдобавок в Swift есть аннотация @inlineable
, чтобы сгенерировать быстрый код как в С++.
Ещё интересный момент - язык с JIT может сделать шаг от “универсальой” функции к мономорфизации и в горячих местах получить более производительный код.
Как можно создавать объекты
Подходов несколько:
- Статические константы и переменные (синглтоны), доступные всё время жизни программы. Из плюсов - простой подход, не надо думать про время жизни. Можно положить данные в область памяти, защищённую от записи. Такой подход часто используется в микроконтроллерах. Из минусов - подход применим только к штукам, которые точно будут нужны.
- Можно выделять объекты на стеке, бонусом идёт автоматическое освобождение памяти, минусом - от освобождения отказаться нельзя, это же место на стеке в будущем будет переиспользовано и объект “протухнет”. Размер стека ограничен (порядка 1-10Мб, при желании можно сделать больше).
- Можно создавать объекты в куче и управлять указателями на них вручную либо через подсчёт ссылок. Плюсы - объект лежит в куче, размер объекта не важен, можно передавать указатель на него. Минусы - выделение памяти в куче может быть чуть медленнее, чем на стеке. Возможны ошибки типа утечек памяти или попыток удалить объект несколько раз. Возможна фрагментация памяти, когда по ней раскиданы мелкие объекты и между ними не влазит что-то более большое.
- Region-based: под какую-то задачу создаётся регион памяти, в нём создаются временные объекты, потом при завершении задачи весь регион освобождается. Плюсы - простое и быстрое освобождение памяти. Частичная устойчивость к утечкам: они будут внутри региона. Регионы можно использовать по-разному: например, можно вообще не освобождать объекты, создавать каждый новый в новом месте, не использовать дестркуторы и потом освобождать всё целиком. Будет просто, быстро, но возможно не эффективно по потреблению памяти. Либо можно разрешить “освобождать” объекты в регионе, но появятся проблемы с фрагментацией памяти. Подход используется в геймдеве - например, можно сделать регион для объектов игрового уровня и при переходе игрока на другой уровень его освободить. Ещё вариация: кастомный аллокатор для какого-то конкретного типа. Например, можно сделать массив на тысячу игровых объектов, при “создании” аллокатор будет возвращать ссылку на какой-нибудь объект в массиве, при освобождении отмечать его как “неиспользуемый”. Плюсы: компактное расположение объектов, удобное итерирование по всем объектам этого типа. Минусы - вряд ли есть смысл делать отдельные аллокаторы для каждого типа. Если использовать заранее созданный массив какого-то размера, то будет ограничение сверху на количество объектов, причём память будет занята независимо от того, сколько реально объектов было создано.
- Использовать GC. На мой взгляд, между GC и подсчётом ссылок есть принципиальная разница: GC может двигать объекты, обновляя ссылки между объектами. Это позволяет избегать фрагментации, но лишает контроля над расположением объектов в памяти. Технически в языке с GC могут быть деструкторы (finalize в Java), но их использоване не рекомендуется. Из плюсов - утечку памяти сделать сложно. Код проще, особенно многопоточный. Вся сложность многопточного освобождения объектов и их обхода ложится на виртуальную машину, но она одна, а программ - много. Минусы - меньше контроля, потенциально более низкая производительность. Теоретически, виртуальная машина внутри может использовать регионы, как-то перекидывать объекты между ними и потом освобождать регион с неиспользуемыми объектами одним махом. Опять же, в качестве оптимизации виртуальная машина может располагать временные объекты на стеке, но это не гарантируется. Ещё важный момент - трудно “поженить” два языка, если в каждом из них свой GC, не знающий про другой. Кроме того, обычно в языке либо вообще нет GC, либо он есть для всего что есть в языке, “промежуточных” вариантов толком нет. Возможно, для “промежуточных” вариантов нужны какие-то доработки системы типов, чтобы различать контексты “внутри GC” и “без GC” и не давать ссылкам на GC-объекты уходить наружу. Так как GC должен знать про все ссылки на GC-объект, чтобы его перемещать или освобождать. Но при этом из мира “без GC” должна быть какая-то возможность манипулировать объектами, например в имплементации GC.
Исключения
- Можно их не использовать (C, Go, Rust). Язык становится проще. Из минусов - приходится явно передавать коды ошибок или ещё что-то по цепочке и иметь накладные расходы на их передачу/проверку. Обычно для непредвиденных случаев всё ещё остаётся exit(0) или её аналог для фатальной ошибки.
- Checked exceptions. Исключения, которые надо явно указывать в сигнатуре функции. И язык и код на нём становятся сложнее, причём бывают случаи, когда в сигнатуре интерфейса исключение есть, а на практике его никто не кинет, но обрабатывать всё равно надо. По-факту идея не очень удобная, в языках Scala/Kotlin от неё отказались.
- Unchecked exceptions. Плюсы - обычно нет больших накладных расходов, если код не кидает исключений. Если код их кидает, производительность может сильно просаживаться. Минусы - язык становится сложнее, исключение может прилететь из какого-нибудь неожиданного места. Усложняется язык программирования, для RAII нужна специальная поддержка.
Fun fact - В Python код, бросающий исключения, работает почти так же медленно, как и нормально исполняющийся, просто питон сам по себе очень медленный.
Ковариантность и контрвариантность
Допустим, есть тип 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, это дало бы интересные возможности:
- Можно снизить нагрузку на GC и явно указать, что какие-то штуки будут удалены и когда именно
- Язык бы позволил писать и низкоуровневый код без GC, и высокоуровневый с ним. Сейчас такое достигается, когда например пишут библиотеки на Си и потом используют из Python, но это два совсем разных языка, а не один универсальный.
- GC - не единстенный способ, есть ускоспецефичные подходы типа использования аллокаторов, которые хорошо работают в некоторых случаях. Например, когда освобождается вся арена без удаления каждого объекта.
Локальные функции
В Си нельзя объявить функцию внутри функции. Плюсы - простота, минусы - раздувание кода функций, неудобства в написании кода.
С++: можно объявить лямбду внутри функции. Необходимо явно указывать, что и как “захватывается”. Между лямбдами и функциями есть разница.
Kotlin: можно объявить функцию внутри функции, но есть ограничения - например, локальная функция не может иметь модификатор inline. “Захват” происходит автоматически (по факту создаются временные объекты-ячейки с переменными)
Scala: как в Kotlin, но меньше ограничений.
Scope для переменных
- Переменные объявляеются до блока или в его начале. Pascal, Delphi, старые стандарты Си. Самый простой подход
- Общий скоуп для переменных внутри функции. Часто используется в интерпретируемых языках, чтобы на весь вызов функции создать только одну табличку. Минус - у конструкций типа циклов и т.п. нет своего скопуа, временные переменные “утекают” в скоуп функции.
- Переменные объявляются в любом месте блока, тип фиксирован. Подход большинства языков.
- Каждое следующее выражение в блоке открывает “свой” скоуп, и в нём типы переменных могут быть другими. Используется в 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.
- Сравнивание ссылок на объекты: в Jvm для Value Objects от него хотят отказаться. Это даст JVM больше свободы, такие объекты можно будет копировать и перемещать.
- Сравнивание объекта с объектом другого типа: Нужно далеко не всегда. При сравнении, допустим, String и Int, результат всегда False и скорее всего код некорректный.
- Вычисление hash: в JVM оно есть для любого объекта, но возможно это должно быть явным интерфейсом.
- Клонирование объекта: опять же, эта фича должна быть интерфейсом, а не возможностью любого объекта
- toString(): метод, очень удобный для отладки и т.п., но не факт что он нужен всегда и везде. Опять же, возможно он должен быть интерфейсом.
- синхронизация но объекте - всегда доступна в Java, но спустя кучу лет это не кажется хорошим решением.
- копирование объектов - всегда возможно в java (включая копирование ссылок на объекты), но например не всегда доступно в C++.
- присваивание объекта куда-нибудь - можно запретить в С++ и Rust.
- взятие ссылки на объект - невозможно для примитивных типов в Java.
- сохранение ссылки на объект - ограничено в Rust временами жизни.
- явное удаление объекта: обычно в 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 требует реализовать интерпретатор языка как довольно замороченную модель с кучей нюансов.
Подходы к сборке мусора и удалению объектов
- Переложить всё на программиста (ассемблер, си)
- Переложить всё на систему типов и программиста (RAAII в С++, Rust) - норм, но подходит не для всех сценариев.
- Подсчёт ссылок - если код многопоточный, то нужны синхронизации на каждый захват/освобождение ссылки. Не спасает от кольцевых ссылок
- Кастомные аллокаторы - сильно зависят от сценария, позволяют пожертвовать чем-то ради производительности. Например, отказаться от деструкторов и одним махом освободить арену с объектами
Языки с GC стоят особняком, потому что GC есть куча разных с самыми разными свойствами.
Потенциально GC позволяют перемещать объекты в куче, уплотняя их. GC может быть многопоточным, когда GC работает в своём потоке и маркирует/освобождает объекты, почти не мешая другим потокам. Из-за того что GC может перемещать объекты, ничто не мешает сделать свою область под новые объекты каждому потоку и задёшево делать много объектов.
Вариации - акторная модель (Erlang, Pony), когда сообщения перекидываются между акторами и сборщик мусора как-то учитывает эту модель. Например, запускается для объектов актора, не мешая всем остальным акторам.
Минусы - очень сложно “поженить” друг с другом два разных языка с разными GC. (Например, Python и Lua друг с другом) Становятся возможны кольцевые ссылки и так же появляются ограничения на то, как одному GC двигать объекты, потому что объекты из второго языка могут на них ссылаться.