На курсе будут рассматриваться
Разработку ПО можно разделить на две части:
Существенной проблемой при разработке является трудность управления памятью в так называемых неуправляемых языках (например, C и C++). Поэтому появились
Позже в 90-ых начали появляться управляемые языки: Java, C#, JavaScript. Как следствие появились виртуальные машины, которые управляли памятью и исполняли байт-код
Теперь условно можно выделить два типа языков:
Каждый язык соответствовал одной из этих целей:
В компиляторе берется исходный код, он представляется в виде промежуточных структур, таких как абстрактное синтаксическое дерево (Abstract Syntax Tree, AST) и граф потока управления (Control Flow Graph, CFG), а потом превращается в нативный для процессора/ОС код, который исполняется процессором
Термин AOT-компиляция (Ahead-of-Time) предполагает, что код обрабатывается до непосредственного выполнения. Напротив, JIT-компиляция (Just-in-Time) - это компиляция байт-кода в машинный код прямо во время выполнения программы. Это позволяет применять оптимизации на основе данных о реальном выполнении программы (профилирование)
В случае с виртуальной машиной исходный код превращается в промежуточный код, которой после исполняется виртуальной машиной
За последние два десятка лет очень изменилось то, как мы используем наши устройства - сейчас мы работаем с большой комбинацией устройств, и это создает множество совместных сценариев работы с устройствами
В индустрии стремятся сделать так, чтобы приложения разрабатывались под разные устройства
Так как бизнес хочет наилучшее решение за наименьшие деньги и время, нужно, чтобы устройства имели одну кодовую базу. Для запуска приложения на разных устройствах нужны виртуальные машины, фреймворки и библиотеки, реализованные для каждой из целевых платформ
Оказывается, что количество выручки с продаж потребительской электроники растет, а люди ценят наличие экосистемы среди своих устройств
Из-за возникшей дуополии Google и Apple это кажется простой задачей, однако существует огромное количество производителей, которые имеют свои маркеты приложений (около 400). С пользовательской точки зрения, когда на рынке 400 маркетов, становится больно пользоваться экосистемами
Появилась альтернатива - миниприложения. Такие миниприложения, написанные на JavaScript, встроены в мессенджер WeChat. Оказалось, что миниприложения начали преобладать над этими 400 маркетами, так как они были намного удобнее
Для разработчика мобильных приложений это означает:
Маленькому бизнесу становится дорого это поддерживать, поэтому нам нужен инструмент, который позволит решить эту проблему более эффективно. Проще всего сделать одну виртуальную машину под разные устройства
Многие производители телефонов в первую очередь зарабатывают с продажи аппаратного обеспечения. Они имеют широкую линейку продуктов и данные использования от реальных пользователей.
Разработчикам приложений важно минимизировать усилия на разработку приложений, поэтому для их привлечения нужна знакомая экосистема разработки и простой доступ к заработку
А пользователям важно, чтобы приложения были быстрыми, а батарея держалась долго, поэтому нужно, чтобы приложения были мгновенными, энергоэффективными и работали внутри экосистемы устройств
Не всегда разработка кроссплатформенных решений облегчает создание новых продуктов, например Oracle с агрессивным лицензированием Java и появившийся в ответ на это C#. Потребность в большинстве языках программирования появилась с ростом рынка потребительской электроники, такие как Go, Swift, Kotlin, Dart. Например, Kotlin появился как ответ на медленные обновления языка Java. Swift - как современная альтернатива Objective-C. React - как попытка сделать кроссплатформенное решение для JavaScript
Кроссплатформенные решения поддерживаются ограниченным набором компании (Google, Apple, Microsoft и так далее). Такие компании заинтересованы, потому что они получают основную выгоду
Разберем примеры кроссплатформенных решений:
Платформа .NET и фреймворк Xamarin как единая кодовая база для разработки
Для Android виртуальная машина Dalvik (позже Android Runtime, ART). Изначально Dalvik был простым интерпретатором. Позже в него добавили JIT- и AOT-компиляцию. Далее сборка профилей использования кода позволила компилировать приложения на серверной инфраструктуре
Для браузеров есть виртуальная машина (например, V8 для JavaScript), пользовательский интерфейс и движок для рендера
LLVM - платформа для построения компиляторов. Она предоставляет универсальное промежуточное представление (Intermediate Representation, IR), на котором проводятся оптимизации, и бэкенды для генерации машинного кода под разные архитектуры. Например, на основе LLVM разработан компилятор Clang для C/C++
Разработка языков программирования началась с создания языка для машины ЭНИАК (ENIAC, Electronic Numerical Integrator and Computer)
Программирование на ЭНИАК осуществлялось соединением нужных разъемов на панели проводами и зачастую занимало недели
ЭНИАК использовалась для расчета вычисления, связанных с ядерным оружием, прогнозирования погоды, инженерных расчетов
Первым широкоиспользуемым языком стал FORTRAN, созданный в 1950-ых Джоном Бэкусом в IBM. Далее в 1956-1958 создается LISP при участии Джона Маккарти
Большинство популярных языков, таких как Go, Rust, Typescript, Kotlin, используемых в индустрии, были разработаны в течение нескольких лет (4-6 лет), что подтверждает, что создание промышленного языка программирования - задача нетривиальная
Целями создания языка программирования могут быть разными. Например,
Поэтому у конкретного языка может быть ограниченная область применения
Так что же нужно для создания языка:
Разработка языка программирования - огромная работа, которая может не прекращаться. Разработка начинается с формулировки требований и приоритетов:
Также нужно выбрать:
Что бы разработчик не выбрал, кому-то это можно не понравится. Нельзя уверенно сказать, какая комбинация решений приведет к наилучшему исходу без прототипирования, а функции языка могут взаимодействовать друг с другом
Пример взаимодействия функций языка из языка Go:
package main
import "fmt"
type IM interface {
Method()
}
type SM struct {i int}
fune (x *SM) Method() {
x.i = 1
}
func check(im IM) {
if im != nil {
im.Method()
}
fmt.Println("checked")
}
func main() {
check(nil) // checked
var S_nil *SM = nil
check(S_nil) // segfault
}
Здесь функция check
правильно обработает значение nil
, однако при передачи указателя на объект SM
интерфейса IM
, равного nil
, выбрасывается ошибка доступа к адресу, так как по адресу nil
нет объекта с методом Method()
Современная система программирования или приложение содержит компоненты, написанные на нескольких языках, так как ни один язык не удовлетворяет все потребности. Части системы, такие как бизнес-логика, фреймворки, UI, требуют разных языков, которые отличаются в управлении памятью, параллелизмом, системе типом и так далее
Тогда появилась идея создания семейства языков, которые будут хорошо вместе взаимодействовать, вместо языка, который будет встраиваться с другими
Тогда для этих языков нужно писать компилятор, значит, нужно выбрать язык, на котором нужно написать этот компилятор
При этом, в нашем семействе должен быть язык, на котором будет приятно писать следующие версии нашего компилятора, чтобы снизить зависимость и влияние от сторонних языков
В начале этот первый язык должен быть легко читаемым, легко понимаемым, иметь все нужные простые функции и механизмы и ничего того, что выходит за нашу область. Также в нем должно быть уменьшено число ошибок времени исполнения, безопасность null-типов, системы типов, сборщик мусора и модульность
Производительность первого языка не должна быть приоритетной для нас
Название языка должно быть простым для понимания. После этого можно начать предварительный дизайн: дизайн грамматики и система типов
Шаг второй - разработка компилятора на другом языке и исполнения скомпилированного кода. Далее компилятор пишется на самом языке, что позволяет протестировать наш язык в прикладной задаче
Грамматика и синтаксис языка должна быть простой, а семантика - очевидной, не должно быть скрытой семантики и “злой магии”
Среди базовых типов можно выбрать байт (8-битное целое число), 64-битное целое число, 64-битное вещественное число, логический тип, символ, строка. Для создания компилятора могут также понадобиться вектор (динамический массив), опциональный тип (вместо null) и классы
Другие продвинутые механизмы могут вызвать дополнительные трудности в разработке:
foreach
может потребовать другие функции, такие как итераторы и кортежиСейчас большинство веб-приложений написаны на JavaScript - динамически типизируемом языке для скриптов с похожим на язык C синтаксисом
Код на JavaScript могут исполнять множество движков (виртуальных машин)
Хотя в JavaScript есть примитивные типы, многие операции заставляют их вести себя как объекты через автоматическую упаковку в объекты. У объекта в JavaScript есть свойства, у свойства - имя и значения, а сами свойства могут добавляться и удаляться во время исполнения
В JavaScript вместо наследование использованы прототипы: каждый объект хранит в себе ссылку на прототип другого объекта, тем самым наследуя его свойства. Объекты могут представлять собой цепочку прототипов, которую можно изменять во время исполнения
Конвертация типов в JavaScript позволяют выполнять такие операции:
var x = 1 + 2; // 3
x = 1 + "2"; // "12"
x = 1 + {}; // "1[object Object]"
x = +"23"; // 23
Из-за прототипов, свойств и динамической типизации JavaScript очень динамический, что представляет трудность для реализации виртуальной машины. Из-за этого появляются проблемы в разработке: приходящий тип объекта не соответствует ожидаемому
Далее появился TypeScript, позволяющий аннотировать типы и проверять их перед исполнением программы
Движок JavaScript работает так:
Спецификация JavaScript не указывает формат промежуточного кода, поэтому разработчик движка волен сам выбрать его формат. Интерпретатор также содержит сборщик мусора, который управляет памятью, и совершает оптимизации над кодом
Внутри компилятора:
После этого интерпретатор исполняет байткод
Из-за того, что JavaScript - динамически типизированный, выражение x + y
может представлять из себя:
Из-за этого JavaScript обладает низкой производительностью
Главная цель для JavaScript - мгновенно исполнять код, чтобы веб-страницы загружались быстро. Поэтому парсер и интерпретатор не должны тратить ресурсы на оптимизации. Другой путь - компиляция после парсера, что замедляет время запуска
Сейчас в браузерах байткод после парсера сразу исполняется интерпретатором, а в другом потоке горячие регионы - часто вызывающиеся участки кода - компилируются и исполняются нативно
Всего можно выделить 4 уровня:
Если кусок кода вызывается больше какого-то выбранного числа, то далее выбирается нужные оптимизации исходя из информации о выполнении кода
Большая часть веб-приложений и мобильных приложений используют такую гибридную модель: часть кода исполняется в интерпретаторе, часть кода нативно как машинные инструкции
Старая версия движка JavaScriptCore использовала три уровня исполнения
В новой версии добавился новый уровень:
Позднее поняли, что с таким конвейером тяжело работать, поэтому упростили инфраструктуру
Интерпретатор имеет ряд достоинств
И недостатков: большое потребление памяти и низкая скорость по сравнению с машинным кодом
Интерпретацию можно ускорить с помощью AOT- и JIT-компиляций, скрытых классов (структур, с помощью которых можно быстрее обрабатывать сложные объекты). Также можно применить такие оптимизации:
Для определения операции из инструкции байткода не следует использовать блоки if/switch. Вместо этого можно использовать:
Прямое перенаправление (Direct Threading) - изменение указателя на текущую инструкцию на значение, равное коду инструкции (opcode)
Непрямое перенаправление (Indirect Threading) - переход делается не напрямую по коду инструкции, а через уровень косвенной адресации (сначала берётся адрес в таблице, затем по нему происходит переход)
Перенаправление токенов (Token Threading) - использование токенов-указателей
Встроенное кеширование (Inline Caching)
Вместо того, чтобы заново проверять типы для исполнения операции, можно для часто применяющихся ситуаций (например, для сложения двух чисел) перенаправлять на другой поток инструкций
Встроенное кеширование имеет несколько состояний