Язык программирования
Язык программирования (Programming Language) — это формальный язык, созданный для описания задач, которые человек поручает компьютеру, и их представления в форм...
| Классификация | формальный язык в области информатики, средство разработки в области программной инженерии |
|---|---|
| Назначение | выражение компьютерных программ и вычислительных процедур |
| Основные компоненты | синтаксис, семантика, система типов, модель выполнения |
| Способы реализации | компилятор, интерпретатор, виртуальная машина |
| Основные парадигмы | императивное программирование, объектно-ориентированное программирование, функциональное программирование, логическое программирование, декларативное программирование |
| Основные категории | низкоуровневый язык программирования, высокоуровневый язык программирования, язык программирования общего назначения, предметно-ориентированный язык |
| Стандартизация | организации по стандартизации, такие как ISO/IEC JTC 1/SC 22 и Ecma International |
| Связанные области | компилятор, теория языков программирования, формальная семантика, статический анализ программ |
Язык программирования (Programming Language) — это формальный язык, созданный для описания задач, которые человек поручает компьютеру, и их представления в форме, понятной и исполнимой компьютером. Языки программирования проектируются таким образом, чтобы выражать различные вычислительные модели, включая алгоритмы, структуры данных, поток управления, абстракции и параллельную обработку, и являются одной из ключевых технологий, лежащих в основе разработки программного обеспечения. Современные языки программирования применяются практически во всех областях вычислительной техники, включая системное программное обеспечение, веб-приложения, мобильные приложения, искусственный интеллект, встраиваемые системы, разработку игр и научные вычисления. Каждый язык отличается особенностями синтаксиса, способа выполнения, управления памятью, системы типов, производительности и продуктивности в зависимости от целей и философии его проектирования. Как правило, языки программирования выполняются после преобразования компилятором или интерпретатором в машинный код либо промежуточное представление. В информатике они исследуются и развиваются с различных точек зрения, включая проектирование языков, синтаксис (Syntax), семантику (Semantics), системы типов (Type System), компиляторы, среды выполнения (Runtime) и стандартные библиотеки.
История
История языков программирования началась не внезапно после появления электронных компьютеров. Её истоки можно найти в попытках человека выражать вычислительные процедуры с помощью определённых символов и правил, а затем поручать машине их автоматическое выполнение. Современные языки программирования превратились в системы обозначений для передачи команд машине и развились в формальные системы, позволяющие выражать алгоритмы, данные, абстракции, изменения состояния, логические отношения и вычислительные модели.
Механические вычисления и формирование понятия программы
В начале XIX века жаккардовый ткацкий станок, разработанный Жозефом Мари Жаккаром, управлял узором ткани с помощью расположения отверстий на перфокартах. Карты жаккардового станка не содержали команд для числовых вычислений, однако возможность изменять работу машины в соответствии с шаблоном, записанным на внешнем носителе, считается важным предшественником последующих методов программного управления.
С 1830-х годов Чарльз Бэббидж проектировал универсальное механическое вычислительное устройство — аналитическую машину. В её состав должны были входить мельница (Mill), выполнявшая вычисления, хранилище (Store) для числовых данных, устройства ввода и вывода, а также перфокарты, передававшие инструкции для операций и данные. Хотя машина так и не была завершена, её архитектура была близка к устройству современных универсальных компьютеров, поскольку вычислительный блок был отделён от памяти, а сама машина должна была выполнять различные вычисления в соответствии с внешними инструкциями.
В 1843 году Ада Лавлейс, переводя на английский язык описание аналитической машины, написанное Луиджи Менабреа, добавила к нему примечания, которые по объёму превосходили исходный текст. В примечании G содержалась таблица с последовательностью операций для вычисления чисел Бернулли на аналитической машине, перемещением переменных, промежуточными результатами и повторяющимися действиями. Эту таблицу обычно представляют как первую опубликованную подробную программу, предназначенную для машины. Однако существуют записи, согласно которым Бэббидж также составлял вычислительные процедуры для аналитической машины примерно в 1837 году. Поэтому точнее считать Лавлейс не безусловно первым программистом в истории, а человеком, впервые систематически описавшим широко опубликованный алгоритм для вычислительной машины.
Лавлейс считала, что аналитическая машина способна обрабатывать не только числа, но и любые объекты, которые можно выразить с помощью определённых правил. Это был один из ранних взглядов на программу не как на средство получения конкретного числового результата, а как на общую процедуру манипулирования символами. Однако сама аналитическая машина не была завершена, поэтому в то время не существовало возможности выполнять такую программу на реальном устройстве или сформировать систему программирования для её многократного использования.
Математическое определение вычислимости
В 1930-х годах, ещё до широкого распространения реальных компьютеров, проводились исследования, направленные на математическое определение вычислений и алгоритмов. Алонзо Чёрч развил лямбда-исчисление — вычислительную систему, основанную на абстракции и применении функций, а Алан Тьюринг в своей статье 1936 года предложил машину Тьюринга, использующую ленту с записанными символами и конечный набор переходов между состояниями.
Тьюринг определил не только машины, выполняющие одно конкретное вычисление, но и универсальную машину Тьюринга, способную принимать описание другой машины Тьюринга и имитировать её работу. Эта идея стала теоретической основой универсального компьютера, в котором команды и данные хранятся в одной памяти, а замена сохранённой программы позволяет выполнять различные задачи. Хотя лямбда-исчисление и машина Тьюринга использовали разные способы представления вычислений, было установлено, что они эквивалентны по классу вычислимых функций. Эти вычислительные модели впоследствии оказали долгосрочное влияние на вызов функций, рекурсию, правила вычисления, теорию типов и формальную семантику языков программирования.
Ранние электронные компьютеры и машинный код
Ранние электронные компьютеры 1940-х годов были далеки от современной модели, при которой программа записывается в виде исходного кода и затем запускается. В некоторых компьютерах вычислительная процедура задавалась соединением кабелей, переключением тумблеров и изменением проводки, поэтому для смены программы требовалось заново настраивать физическую конфигурацию машины. Позднее, с распространением архитектуры хранимой программы, при которой инструкции записывались в числовом виде в память или на перфоленту, программа начала постепенно отделяться от аппаратного обеспечения.
Первые программы записывались на машинном языке — в виде числовых команд, непосредственно интерпретируемых центральным процессором. Программисту приходилось вручную указывать коды операций, адреса памяти и позиции переходов. При смене модели компьютера систему команд и саму программу обычно требовалось почти полностью переписывать. Даже небольшая ошибка в вычислении адреса или неверно записанная команда могла привести к неправильной работе всей программы, а чтение и сопровождение её структуры были чрезвычайно сложными.
Для уменьшения этих проблем появился язык ассемблера, в котором машинным командам присваивались символические имена, такие как ADD, LOAD и JMP. Ассемблер преобразовывал эти символические инструкции в машинный код и вычислял адреса памяти. Язык ассемблера был удобнее для чтения, чем числовой машинный код, однако оставался тесно связанным с набором команд конкретного процессора, а логику программы по-прежнему приходилось строить в соответствии с регистрами и организацией памяти машины.
Первые языки программирования
Ответ на вопрос о том, какой язык программирования был первым, зависит от того, по каким критериям определяется само понятие языка программирования.
- В качестве первого подробно опубликованного алгоритма для конкретной машины обычно упоминается вычислительная процедура Ады Лавлейс для аналитической машины.
- Первым спроектированным языком программирования высокого уровня часто называют Plankalkül.
- Одной из первых высокоуровневых систем записи, применявшихся на реальном электронном компьютере, считается Short Code.
- К ранним системам автоматического программирования и компиляции относят A-0 System.
- Первым широко использовавшимся практическим компилируемым языком высокого уровня обычно называют FORTRAN.
Конрад Цузе разработал Plankalkül в период с 1942 по 1945 год. Язык был предназначен для представления не только чисел, но и массивов, логических значений, сложных данных, похожих на записи, а также условных операторов и циклических конструкций. Plankalkül считается первым проектом языка программирования высокого уровня, поскольку в нём предпринималась попытка выражать процедуры решения задач на более высоком уровне, а не просто перечислять команды конкретной машины. Однако из-за войны и послевоенных обстоятельств язык не был реализован на компьютерах Цузе того времени, а сведения о нём получили широкую известность значительно позже.
Short Code был предложен приблизительно в 1949 году и использовался в начале 1950-х годов как один из первых языков высокого уровня. Он позволял записывать математические выражения в символической форме вместо машинных команд, однако не создавал полный машинный код с помощью компилятора в современном понимании. Его команды интерпретировались и выполнялись интерпретатором. Писать программы на Short Code было проще, чем на машинном языке, но скорость их выполнения оставалась низкой, а выразительные возможности были ограничены.
A-0 System, разработанная Грейс Хоппер в период с 1951 по 1952 год, представляла собой систему автоматического программирования, которая по символическим обозначениям заранее подготовленных подпрограмм находила необходимые машинные процедуры и объединяла их в программу. В то время эту систему называли компилятором, и её широко представляют как первый компилятор. Однако с точки зрения современной классификации она имела черты не только компилятора, переводящего полноценный язык высокого уровня, но также компоновщика или загрузчика, размещавшего и связывавшего подпрограммы. Несмотря на это, A-0 System стала важным этапом в развитии компиляторов, поскольку продемонстрировала возможность автоматизировать с помощью машины часть процесса создания программ.
1950-е годы: практическое применение языков высокого уровня и компиляторов
В 1950-х годах начали получать практическое распространение языки программирования высокого уровня, позволявшие программистам описывать программы в форме, более близкой к математической или деловой структуре задачи, чем к системе машинных команд, а транслятор преобразовывал эти описания в машинный код. В этот период в соответствии с различными потребностями научных вычислений, символьной обработки и обработки деловых данных сформировались основные семейства языков.
FORTRAN
В 1954 году исследовательская группа IBM под руководством Джона Бэкуса начала разработку FORTRAN (Formula Translation) — системы трансляции формул для IBM 704. Чтобы язык высокого уровня мог получить широкое признание, он должен был обеспечивать производительность, близкую к программам, написанным вручную на машинном языке. Поэтому при разработке FORTRAN проектированию оптимизирующего компилятора уделялось не меньше внимания, чем синтаксису самого языка.
FORTRAN, выпущенный для коммерческого использования в 1957 году, позволял описывать научные и инженерные вычисления с помощью математических выражений, переменных, циклов и условных переходов. Программистам больше не требовалось вручную управлять распределением регистров и отдельными машинными командами, поскольку компилятор анализировал записанные выражения и создавал эффективный машинный код. FORTRAN не был первым языком высокого уровня как таковым, однако считается первым высокоуровневым компилируемым языком, получившим широкое распространение в промышленности и научных исследованиях. Успех FORTRAN доказал, что языки высокого уровня и компиляторы могут быть практически полезными без значительной потери производительности.
Позднее официальное название языка стало записываться как Fortran. Язык Fortran продолжал развиваться, получив операции над массивами, структурированные управляющие конструкции, модули, пользовательские типы данных, объектно-ориентированные возможности и средства параллельной обработки. Несмотря на то что он относится к числу первых языков программирования, Fortran продолжает использоваться в современных высокопроизводительных вычислениях, численном анализе, метеорологическом и физическом моделировании.
FLOW-MATIC и COBOL
В отличие от научных вычислений, в расчёте заработной платы, бухгалтерском учёте, управлении запасами и административной деятельности предприятий важнее сложных формул были возможности чтения и классификации больших объёмов записей, а также формирования отчётов. Грейс Хоппер утверждала, что специалисты по деловым операциям должны иметь возможность использовать команды, близкие к английским словам, а не математические обозначения.
Разработанный командой Хоппер язык FLOW-MATIC был одним из первых языков, в котором процедуры обработки данных выражались с помощью английских слов, таких как INPUT, OUTPUT и COMPARE. FLOW-MATIC продемонстрировал возможность создания языков обработки деловых данных, отличающихся от языков, ориентированных на математику, и повлиял на движение за создание общего делового языка, способного работать на компьютерах разных производителей.
В 1959 году правительство США совместно с несколькими производителями компьютеров и организациями пользователей создало CODASYL для разработки COBOL. В 1960 году была опубликована спецификация COBOL 60. COBOL использовал длинные ключевые слова, напоминавшие английские предложения, явный раздел определения данных и структуру файловой обработки, ориентированную на записи. Язык создавался как средство делового программирования, независимое от машинных команд конкретного производителя, и впоследствии получил широкое распространение в основных информационных системах банков, страховых компаний, государственных учреждений и крупных предприятий. COBOL также способствовал повышению внимания к стандартизации спецификаций языков программирования и соответствию компиляторов установленным стандартам.
LISP
В конце 1950-х годов Джон Маккарти разработал LISP — язык для искусственного интеллекта и символьных вычислений. В отличие от FORTRAN, ориентированного прежде всего на числовые массивы, LISP использовал символы и списки в качестве основных типов данных. Центральное место в языке занимали определение функций, рекурсия, условные выражения, динамические структуры данных и автоматическое освобождение памяти. Сам программный код также мог быть представлен в виде списковых структур данных.
Опубликованная в 1960 году статья о LISP систематически описывала способы обработки рекурсивных функций и символьных выражений на компьютере. Функциональная модель вычислений LISP находилась под влиянием лямбда-исчисления и впоследствии оказала долгосрочное влияние на функциональные языки программирования, системы макросов, автоматическое управление памятью, интерактивные среды разработки и исследования в области искусственного интеллекта.
ALGOL
В отличие от FORTRAN, возникшего в конкретной среде IBM, исследователи из Европы и США стремились создать язык записи алгоритмов, независимый от машин и отдельных стран. В результате в 1958 году появился ALGOL 58, а в 1960 году был опубликован официальный отчёт по его переработанной версии — ALGOL 60.
ALGOL 60 предоставлял блочную структуру, ограниченную ключевыми словами begin и end, локальные переменные, действующие только внутри соответствующего блока, рекурсивные процедуры и систематические управляющие конструкции. Синтаксис языка был описан с помощью формальной грамматики семейства формы Бэкуса — Наура, которая впоследствии стала стандартным способом описания спецификаций языков программирования и проектирования синтаксических анализаторов. Хотя промышленное использование самого ALGOL было более ограниченным, чем у FORTRAN или COBOL, он оказал значительное влияние на синтаксис и структуру многих языков, включая Pascal, C (язык программирования), Simula и другие.
1960-е годы: структурирование, образование и появление объектной концепции
В 1960-х годах сфера применения компьютеров расширилась за пределы исследовательских учреждений и военных объектов, охватив предприятия и университеты. По мере роста размеров программ всё более важной становилась не только замена машинного языка более удобной системой записи, но и организация программ таким образом, чтобы их можно было понимать, изменять и совместно разрабатывать нескольким людям.
Блочная структура и локальные переменные, введённые языками семейства ALGOL, стали основой для разделения программы на относительно независимые части. На проектирование языков также повлияло движение структурного программирования, стремившееся сократить бесконтрольное использование команд перехода и строить программы преимущественно на последовательном выполнении, выборе, повторении и вызовах процедур. Последующие языки начали уделять особое внимание не только синтаксическим средствам выражения, но и возможностям, ограничивающим и проясняющим структуру программы.
BASIC и интерактивное программирование
В 1963 году Джон Кемени и Томас Курц начали в Дартмутском колледже разработку языка и вычислительной среды с разделением времени, которые могли бы легко освоить даже студенты, не специализировавшиеся в области вычислительной техники. В результате 1 мая 1964 года первая программа на BASIC была выполнена в Дартмутской системе разделения времени.
BASIC использовал простые команды, похожие на английские слова, номера строк и интерактивный способ выполнения. Вместо того чтобы сдавать перфокарты и долго ждать результатов, пользователь мог вводить программу через терминал и сразу видеть результат её выполнения. Позднее, когда в 1970-х и 1980-х годах различные интерпретаторы BASIC стали стандартно поставляться с персональными компьютерами, BASIC превратился в один из основных языков, с которыми широкая публика впервые знакомилась с программированием.
Simula и объектно-ориентированное программирование
Норвежские исследователи Уле-Йохан Даль и Кристен Нюгор разработали Simula для моделирования сложных систем реального мира. Первоначально Simula представляла собой язык моделирования на основе ALGOL, а позднее в Simula 67 были систематизированы понятия классов и объектов, наследования, виртуальных методов и динамической диспетчеризации.
Объекты Simula объединяли данные и действия, обрабатывающие эти данные, в единую сущность. Объекты использовались для представления отдельных участников реального мира или компонентов системы, а классы — для определения их общей структуры. Эта модель впоследствии стала одной из основных концептуальных основ таких объектно-ориентированных языков, как Smalltalk, C++, Java и C#. Simula обычно считается первым объектно-ориентированным языком программирования.
1970-е годы: становление основных парадигм программирования
В 1970-х годах несколько парадигм программирования, сохраняющих значение до настоящего времени, оформились в виде самостоятельных языков. В системном программировании появился C, в образовании и структурном программировании — Pascal, в объектно-ориентированных средах — Smalltalk, в логическом программировании — Prolog, а в области статически типизированного функционального программирования — ML.
Pascal
Никлаус Вирт представил Pascal в 1970 году, взяв за основу структуру ALGOL 60. Pascal выражал принципы структурного программирования с помощью ясного синтаксиса и строгой проверки типов, а также предоставлял массивы, записи, множества, указатели и пользовательские типы данных.
Pascal широко применялся в обучении программированию, однако изначально не проектировался исключительно как учебный язык. Вирт считал, что язык должен обладать простыми основополагающими принципами и одновременно позволять эффективно создавать крупные программы. Pascal впоследствии оказал влияние на Modula-2, Oberon, Object Pascal, а также на различные учебные и коммерческие среды разработки.
C и UNIX
Деннис Ритчи разработал C (язык программирования) в Bell Labs в период с 1971 по 1973 год под влиянием BCPL и B (язык программирования). C создавался как язык системного программирования для реализации ранней операционной системы UNIX.
До этого операционные системы преимущественно писались на языке ассемблера, однако после переписывания значительной части UNIX на C систему стало проще переносить на другие компьютерные архитектуры. C предоставлял указатели, прямой доступ к памяти, побитовые операции и простую модель среды выполнения, позволяя сохранять близкий к аппаратному обеспечению контроль и одновременно использовать функции, типы данных и структуры для программирования высокого уровня.
Сам язык C оставался сравнительно небольшим, а ввод-вывод, обработка строк, управление памятью и другие возможности были вынесены в библиотеки. Такая структура хорошо подходила для реализации операционных систем, компиляторов, баз данных, встраиваемого программного обеспечения и сред выполнения других языков. Впоследствии синтаксис и модель выполнения C прямо или косвенно повлияли на множество языков, включая C++, Objective-C, Java, C#, JavaScript, Go (язык программирования), Rust и Wave (язык программирования).
Smalltalk
В 1972 году исследовательская группа Xerox PARC, в которую входили Алан Кей, Дэн Ингаллс, Адель Голдберг и другие, разработала раннюю версию Smalltalk. Smalltalk представлял собой не только язык, но и интегрированную среду, объединявшую объектно-ориентированный язык, виртуальную машину, браузер исходного кода, отладчик и графический пользовательский интерфейс.
В Smalltalk почти все элементы, включая числа, строки, классы и саму среду выполнения, рассматривались как объекты, а вычисления выражались через передачу сообщений между ними. Разработчик мог изменять объекты и код непосредственно внутри работающей системы и сразу наблюдать результат. Объектная модель, виртуальная машина, интерактивный процесс разработки и графическая среда Smalltalk оказали значительное влияние не только на последующие объектно-ориентированные языки, но и на современные интегрированные среды разработки и пользовательские интерфейсы.
Prolog
В 1972 году в Марселе Ален Кольмероэ и его исследовательская группа разработали первую реализацию Prolog. В Prolog программа описывалась не как последовательность команд, которые необходимо выполнить, а в форме фактов, правил и запросов, для которых требовалось найти решение.
Среда выполнения Prolog использует логическую унификацию и возврат с перебором для поиска решений, удовлетворяющих заданным условиям. Этот подход сформировал отдельную парадигму — логическое программирование — и применялся в обработке естественного языка, представлении знаний, экспертных и основанных на правилах системах, а также в исследованиях по решению задач с ограничениями.
ML и статически типизированные функциональные языки
Робин Милнер разработал ML как метаязык для написания процедур доказательства в системе автоматизированного доказательства теорем LCF. ML был функциональным языком высшего порядка, в котором функции рассматривались как значения, и одновременно предоставлял полиморфную статическую систему типов, способную выводить большинство типов без их явного указания программистом.
Вывод типов и параметрический полиморфизм ML продемонстрировали, что строгую типобезопасность можно сочетать с меньшей многословностью кода. Сопоставление с образцом, алгебраические типы данных и модульная система ML впоследствии повлияли на Haskell, OCaml, F#, Scala и современные языки со статической типизацией.
SQL и декларативные языки
С развитием реляционных баз данных появились отдельные декларативные языки для обработки данных. Разработанный в IBM в 1970-х годах Дональдом Чемберлином и Рэймондом Бойсом язык SEQUEL позднее был переименован в SQL.
Пользователь SQL описывает условия и форму требуемых данных, не задавая подробно порядок их поиска и объединения. Конкретные пути доступа и план выполнения определяются системой управления базами данных. Хотя SQL отличается по назначению от универсальных языков программирования, он закрепил в практических крупномасштабных системах языковую модель, в которой объявляется желаемый результат, а не последовательность действий для его получения.
1980-е годы: персональные компьютеры, модульность и распространение объектно-ориентированного программирования
В 1980-х годах распространение персональных компьютеров значительно расширило круг пользователей языков программирования. На ранних персональных компьютерах широко применялся BASIC, а компиляторы Pascal и C получили распространение в образовании и коммерческой разработке программного обеспечения. С появлением сред разработки, объединявших редактор, компилятор и отладчик в одном продукте, процесс написания и запуска программ также стал значительно быстрее.
По мере увеличения размеров программ организации кода в виде простого набора процедур стало недостаточно. Возросло значение средств, позволяющих явно определять открытые интерфейсы между модулями, скрывать реализацию и безопасно объединять отдельно скомпилированные компоненты. Modula-2 и Oberon, созданные Виртом, а также различные языки семейства Pascal предоставляли языковые средства модульности и проверки интерфейсов.
C++
Бьёрн Страуструп начал разработку языка «C with Classes» в Bell Labs в 1979 году. Этот язык представлял собой попытку объединить классы и объектные абстракции Simula с производительностью и возможностями системного управления C. Примерно в 1983 году язык получил название C++, а в 1985 году были выпущены его первая коммерческая реализация и книга с описанием языка.
Помимо классов и наследования, в C++ постепенно появились перегрузка функций и операторов, конструкторы и деструкторы, шаблоны, обработка исключений и обобщённое программирование. Сохраняя значительную совместимость с C, язык развился в многопарадигменный инструмент, позволяющий сочетать различные подходы к программированию. Впоследствии C++ стал широко применяться в крупных прикладных программах, компонентах операционных систем, игровых движках, браузерах и высокопроизводительном программном обеспечении.
Perl и скриптовые языки
В 1987 году Ларри Уолл выпустил Perl, предназначенный для автоматизации создания отчётов, обработки текста и задач системного администрирования в среде UNIX. Perl объединил особенности C, командных оболочек, sed, awk и других инструментов и языков.
Perl глубоко интегрировал регулярные выражения и средства обработки строк непосредственно в язык, позволяя преобразовывать файлы и тексты с помощью коротких программ. Позднее он сыграл важную роль в раннем веб-программировании на основе CGI и системной автоматизации, а также повлиял на модель построения языковой экосистемы вокруг крупного репозитория расширяемых модулей.
Haskell
В 1980-х годах исследовалось несколько функциональных языков с ленивыми вычислениями, однако различия в их синтаксисе и возможностях затрудняли обмен результатами исследований и библиотеками. Для решения этой проблемы во второй половине 1980-х годов в рамках работы специального комитета был разработан Haskell.
Основными особенностями Haskell стали чистое функциональное программирование, отделяющее побочные эффекты средствами типов и структуры языка, ленивые вычисления, алгебраические типы данных, сопоставление с образцом и классы типов. Haskell стал не только языком коммерческого применения, но и основой для экспериментов с новыми системами типов и средствами абстракции в исследованиях языков программирования.
1990-е годы: интернет, виртуальные машины и рост динамических языков
В 1990-х годах повышение производительности персональных компьютеров, распространение графических операционных систем и интернета значительно изменили среду, с которой должны были работать языки программирования. Возросло значение возможности распространять программы по сети, выполнять один и тот же код в разных операционных системах и за короткое время разрабатывать быстро изменяющиеся приложения.
Python
В конце 1980-х годов Гвидо ван Россум, работая в нидерландском институте CWI, задумал язык, который сохранял бы преимущества учебного языка ABC, но при этом легко расширялся для работы в среде UNIX и практического системного программирования. В конце 1989 года он начал его реализацию, а в начале 1990-х годов выпустил Python.
Python использовал отступы для обозначения блочной структуры и предоставлял динамическую типизацию, автоматическое управление памятью, обработку исключений, модули и богатый набор встроенных типов данных. Возможность легко связывать язык с внешними модулями, написанными на C, способствовала расширению его применения от простых сценариев до научных вычислений, веб-серверов, автоматизации и искусственного интеллекта.
Java
В начале 1990-х годов Джеймс Гослинг и исследователи Sun Microsystems разрабатывали программную платформу для подключённых к сети бытовых устройств и встраиваемых систем. Первоначально они использовали C++, однако для решения проблем переносимости, надёжности, управления памятью и безопасности создали новый язык и среду выполнения.
Представленный в 1995 году Java использовал модель, при которой исходный код компилировался не в машинные команды конкретного процессора, а в байт-код, выполняемый в Java Virtual Machine. Благодаря автоматическому управлению памятью, проверкам во время выполнения и стандартной библиотеке Java подчёркивал платформенную независимость, позволяющую запускать одну программу в разных операционных системах. Начав с веб-апплетов, Java впоследствии распространился на серверное и корпоративное программное обеспечение, мобильные приложения и крупные распределённые системы.
JavaScript
В 1995 году Брендан Айк, работавший в Netscape, разработал язык, предназначенный для реакции на ввод пользователя и динамического изменения содержимого веб-документа. Первоначально язык создавался под другими названиями, однако в конечном итоге был включён в Netscape Navigator под названием JavaScript.
Несмотря на сходство названий, JavaScript является отдельным от Java языком. Он использует динамическую типизацию, объекты на основе прототипов и функции как объекты первого класса. Изначально язык применялся преимущественно для небольших сценариев, встроенных в веб-страницы, однако благодаря повышению производительности браузерных движков, стандартизации ECMAScript, появлению Node.js и экосистемы модулей он вырос в универсальный язык для разработки как клиентской, так и серверной части приложений.
PHP и Ruby
Расмус Лердорф в 1994 году создал набор CGI-инструментов для ведения статистики посещений собственного веб-сайта. Эти инструменты прошли через этап PHP/FI и впоследствии развились в PHP. Благодаря возможности встраивать серверный код непосредственно в HTML-документы и простой модели развёртывания PHP быстро распространился в разработке динамических веб-сайтов.
Юкихиро Мацумото разработал Ruby, объединив влияние Perl, Smalltalk, LISP и других языков и уделив особое внимание выразительности и удобству программиста. Разработка Ruby началась в 1993 году, а в 1995 году язык был опубликован. Он предоставлял последовательную объектную модель, в которой все значения рассматривались как объекты, а также блоки и средства метапрограммирования. Позднее, с появлением Ruby on Rails, Ruby получил широкую известность в области разработки веб-приложений.
2000-е годы: управляемые платформы и многопарадигменные языки
В 2000-х годах виртуальные машины и управляемые среды выполнения стали центральными технологиями разработки крупного программного обеспечения. Язык начал восприниматься не только как синтаксис исходного кода, но и как целая экосистема, включающая сборщик мусора, стандартную библиотеку, управление пакетами, отладчик, JIT-компилятор и платформу развёртывания.
C# и .NET
Microsoft разработала C# для платформы .NET под руководством Андерса Хейлсберга. Первая широко распространённая реализация была представлена в 2000 году вместе с инициативой .NET.
C# использовал синтаксис семейства C и объектно-ориентированную модель, одновременно интегрируя автоматическое управление памятью, информацию о типах во время выполнения, свойства, делегаты и события. Впоследствии в язык были добавлены обобщения, LINQ, лямбда-выражения, асинхронные функции, сопоставление с образцом, записи и другие возможности, благодаря чему он развился в язык, сочетающий объектно-ориентированное, функциональное и декларативное программирование.
Scala
Мартин Одерски начал разработку Scala в EPFL в 2001 году, а в 2004 году опубликовал реализацию для JVM. Scala сохранял совместимость с Java Virtual Machine и библиотеками Java, объединяя объектно-ориентированное и функциональное программирование в рамках единой статической системы типов.
Scala предоставлял функции высшего порядка, сопоставление с образцом, неизменяемые структуры данных, трейты и развитый вывод типов. Стратегия внедрения новых языковых возможностей с одновременным использованием экосистемы существующей платформы впоследствии повторялась во многих современных языках, ориентированных на JVM и .NET.
2010-е годы: конкурентность, безопасность памяти и крупномасштабная разработка
С конца 2000-х годов развитие процессоров стало смещаться от повышения производительности одного ядра к многоядерности и параллельной обработке. Одновременно с ростом масштабов интернет-сервисов и распространением облачной инфраструктуры ключевыми задачами языкового проектирования стали конкурентность, надёжное развёртывание, быстрая сборка и управление крупными кодовыми базами.
Go
В 2007 году сотрудники Google Роберт Гризмер, Роб Пайк и Кен Томпсон начали проектирование Go (язык программирования), чтобы решить проблемы длительной компиляции, сложных зависимостей и конкурентности, возникавшие при разработке крупной программной инфраструктуры. Проект Go был представлен в 2009 году, а Go 1 выпущен в 2012 году.
Go предоставлял сравнительно небольшой синтаксис, быструю компиляцию, сборку мусора, пакетную систему, горутины и каналы. Вместо расширения количества языковых конструкций разработчики сделали упор на интеграцию стандартных инструментов, форматтера, тестирования, модулей и процесса сборки, стремясь упростить командную разработку серверного и сетевого программного обеспечения.
Rust
Грэйдон Хор положил начало разработке Rust, которую впоследствии продолжили Mozilla и открытое сообщество. Целью проекта было создание языка системного программирования, обеспечивающего безопасность памяти и конкурентного выполнения без сборщика мусора. В 2015 году вместе с Rust 1.0 были выпущены стабильный язык, стандартная библиотека и пакетный и сборочный инструмент Cargo.
Rust проверяет правила использования памяти на этапе компиляции с помощью системы владения, заимствования и анализа времён жизни. Язык стремился предотвращать ошибки, связанные с гонками данных, обращением к освобождённой памяти и другими нарушениями безопасности, одновременно предоставляя низкоуровневый контроль и производительность, близкие к C и C++. Кроме того, была принята система редакций, позволяющая поэтапно развивать синтаксис языка при сохранении совместимости с существующим кодом.
Kotlin
JetBrains начала проект Kotlin в 2010 году и представила его общественности в 2011 году. Kotlin проектировался как язык, совместимый с существующим кодом Java и экосистемой JVM, но предоставляющий современные возможности, включая защиту от нулевых значений, вывод типов, функции расширения, классы данных и корутины.
Kotlin 1.0 был выпущен в 2016 году. Впоследствии язык расширился за пределы JVM и превратился в многоплатформенный язык, ориентированный также на JavaScript, машинный код и WebAssembly.
Swift
Apple разработала Swift для модернизации среды разработки, ранее основанной на Objective-C, и представила язык в 2014 году. Swift предоставлял статическую типизацию, вывод типов, типы-значения, необязательные значения, обобщения и абстракции, основанные на протоколах.
Первоначально Swift применялся главным образом для разработки приложений под iOS и macOS, однако позднее был открыт исходный код языка, а его поддержка распространилась на Linux, Windows, серверные и встраиваемые системы.
TypeScript
По мере роста веб-приложений усиливалась потребность в проверке структуры и ошибок динамически типизированного кода JavaScript ещё на этапе разработки. Представленный Microsoft в 2012 году TypeScript был спроектирован как надмножество JavaScript, позволяющее сохранять существующий код и постепенно добавлять необязательные статические аннотации типов.
TypeScript использует структурную систему типов и анализ потока управления, а результатом компиляции является обычный JavaScript. Он стал одним из наиболее успешных примеров постепенной типизации, при которой поверх существующей крупной среды выполнения и экосистемы добавляется слой статического анализа без их замены.
WebAssembly
В 2019 году Консорциум Всемирной паутины утвердил основную спецификацию WebAssembly в качестве веб-стандарта. WebAssembly представляет собой не обычный язык высокого уровня, предназначенный для непосредственного написания человеком, а безопасный и переносимый низкоуровневый байт-код и формат инструкций виртуальной машины, обычно создаваемый компиляторами C, C++, Rust и других языков.
Благодаря этому веб-браузеры получили общую целевую платформу для эффективного выполнения результатов компиляции различных языков, а не только JavaScript. Впоследствии WebAssembly стал применяться за пределами браузеров — на серверах, в подключаемых модулях, песочницах и переносимых средах выполнения, вновь расширив отношения между языками программирования и платформами исполнения.
2020-е годы и последующий период
Языки программирования 2020-х годов становится всё сложнее классифицировать по одной парадигме или единственной модели выполнения. Современные языки при необходимости объединяют процедурное, объектно-ориентированное, функциональное и декларативное программирование, а также различные модели конкурентности. Статически типизированные языки усиливают вывод типов и интерактивные инструменты, тогда как динамические языки внедряют необязательную проверку типов и возможности статического анализа.
К основным направлениям развития языков относятся:
- безопасность памяти и сокращение неопределённого поведения;
- асинхронное выполнение и структурированная конкурентность;
- явная обработка нулевых значений и ошибок;
- вывод типов и постепенные системы типов;
- расширение применения неизменяемых данных и функциональных выражений;
- стандартизация пакетных менеджеров, систем сборки и форматтеров;
- многоплатформенное выполнение с поддержкой различных операционных систем и архитектур процессоров;
- взаимодействие с существующими экосистемами C ABI, JVM, .NET, JavaScript и WebAssembly;
- гетерогенные вычисления, объединяющие CPU, GPU и ускорители;
- открытая разработка спецификаций и реализаций языков, а также управление сообществом.
В архитектуре компиляторов также всё шире используется не простой поток непосредственного преобразования одного исходного языка в один машинный код, а многоступенчатая система промежуточных представлений. Такие компиляторные инфраструктуры, как LLVM и MLIR, позволяют повторно использовать языковые фронтенды, оптимизаторы и серверные части для CPU и GPU, а также стремятся снизить стоимость разработки предметно-ориентированных языков для конкретного аппаратного обеспечения или вычислительной области.
При этом появление новых языков не приводит к немедленному исчезновению существующих. Fortran и COBOL продолжают использоваться в научных и деловых системах, накапливавшихся десятилетиями, а C и C++ сохраняют обширные экосистемы операционных систем и высокопроизводительного программного обеспечения. Java и C# продолжают получать новые возможности в рамках управляемых платформ, а Python и JavaScript развивают производительность языков и сред выполнения, а также средства анализа типов, сохраняя совместимость с существующим кодом и пакетными экосистемами.
Таким образом, история языков программирования представляет собой последовательное появление различных вычислительных моделей и способов абстракции в ответ на производительность и архитектуру компьютеров своего времени, расширение круга пользователей, рост масштабов создаваемого программного обеспечения и требования к безопасности и продуктивности. Современные языки программирования развиваются, объединяя эти исторические направления, а языки различных уровней — от низкоуровневых средств, близких к управлению машиной, до декларативных и предметно-ориентированных языков, близких к описанию человеческих задач, — используются взаимодополняющим образом.
Классификация и составные элементы
Языки программирования невозможно однозначно разделить только по одному критерию. Один язык может одновременно быть языком высокого уровня и статически типизированным языком, поддерживать объектно-ориентированное, процедурное и функциональное программирование, а также использовать в качестве целевых сред как машинный код, так и виртуальную машину. Поэтому классификацию языков программирования следует понимать не как набор взаимоисключающих категорий, а как многомерную систему, описывающую язык с различных точек зрения: уровня абстракции, способа выполнения, системы типов, парадигм программирования, назначения и модели управления ресурсами. Система классификации вычислительной техники ACM также выделяет функциональные, объектно-ориентированные, логические и ограничительные, потоковые, параллельные, распределённые и многопарадигменные языки в отдельные категории, одновременно позволяя применять несколько классификаций к одному объекту исследования.
Основные критерии классификации можно обобщить следующим образом.
| Критерий | Примеры категорий | Что различается |
|---|---|---|
| Уровень абстракции | машинный язык, язык ассемблера, язык высокого уровня | насколько непосредственно выражаются детали аппаратного устройства |
| Способ выполнения | предварительная компиляция, интерпретация, байт-код, JIT | каким образом исходный код преобразуется в исполняемую форму |
| Система типов | статическая и динамическая типизация, явное указание и вывод типов | когда и каким образом проверяется допустимость значений и операций |
| Отношения типов | номинальная типизация, структурная типизация | по каким признакам определяется совместимость разных типов |
| Парадигма | императивная, объектно-ориентированная, функциональная, логическая, потоковая | каким способом представляются вычисления и структура программы |
| Назначение | универсальный язык, предметно-ориентированный язык | насколько широка область задач, для которой спроектирован язык |
| Управление ресурсами | ручное управление, сборка мусора, управление на основе владения | каким способом управляется жизненный цикл памяти и внешних ресурсов |
| Модель конкурентности | потоки, общая память, передача сообщений, каналы, корутины | каким способом выражаются выполнение и синхронизация нескольких задач |
Классификация по уровню абстракции
Машинный язык представляет собой двоичное представление команд, непосредственно интерпретируемых процессором. Каждая команда выражает код операции, операнды, регистр или адрес памяти в рамках конкретной архитектуры набора команд. Машинный язык позволяет наиболее непосредственно управлять аппаратным обеспечением, однако его трудно читать и писать человеку. Кроме того, при смене целевого процессора представление команд той же программы необходимо создавать заново.
Язык ассемблера представляет машинные команды не числами, а символами, такими как MOV, ADD и JMP. Использование меток, констант и макросов делает его более удобным для чтения и сопровождения, чем машинный код. Однако он по-прежнему непосредственно работает с регистрами, соглашениями о вызовах, способами адресации памяти и набором команд целевой архитектуры, поэтому относится к языкам низкого уровня.
Язык программирования высокого уровня выражает программу преимущественно через понятия, близкие к решению задачи, такие как алгоритмы, структуры данных, функции, объекты и модули, а не через аппаратные команды. Степень абстракции может значительно различаться: от C, позволяющего непосредственно работать с адресами памяти и указателями, до Python или Haskell, в которых значительная часть исполнения и представления данных возлагается на реализацию языка. Поэтому различие между низкоуровневыми и высокоуровневыми языками является не абсолютным разделением на две ступени, а скорее непрерывной шкалой аппаратной зависимости и уровня абстракции.
Термин «высокий уровень» не означает качество языка или сложность его изучения. Он указывает на степень абстрагирования конкретных аппаратных операций. Язык высокого уровня не обязательно прост в использовании или медленен при выполнении. Компилятор может преобразовать структуры высокоуровневого языка в эффективный машинный код через промежуточные представления и этапы оптимизации. LLVM предоставляет архитектуру, в которой промежуточное представление, созданное языковым фронтендом, анализируется и оптимизируется, а затем преобразуется в машинный код различных целевых архитектур.
Классификация по способу выполнения
Языки программирования часто разделяют на компилируемые и интерпретируемые, однако это различие является не столько абсолютным свойством самого языка, сколько способом выполнения программы в конкретной реализации. Один язык в зависимости от реализации может предварительно компилироваться в машинный код, преобразовываться в байт-код виртуальной машины, компилироваться в машинный код во время выполнения или исполняться путём непосредственной интерпретации синтаксической структуры. Даже Python имеет различные реализации, включая CPython, PyPy, Jython и IronPython. PyPy использует JIT-компиляцию, а Jython и IronPython выполняются соответственно в средах JVM и .NET.
Предварительная компиляция, или AOT-компиляция, преобразует исходный код в машинный код целевой системы или другой исполняемый формат до запуска программы. В типичных реализациях C и C++ создание исполняемого файла или библиотеки включает предварительную обработку, синтаксический и семантический анализ, формирование промежуточного представления, оптимизацию, генерацию целевого кода, ассемблирование и компоновку. Однако в реальных инструментах несколько этапов могут объединяться одной командой, а создание некоторых промежуточных файлов может пропускаться.
Интерпретатор читает представление программы во время выполнения и выполняет действия в соответствии с его значением. Интерпретатор не обязательно непосредственно выполняет исходный код построчно. Сначала он может преобразовать исходный текст в токены, синтаксическое дерево, внутренние инструкции или байт-код, а затем интерпретировать полученное промежуточное представление.
При использовании байт-кода и виртуальной машины исходный код преобразуется не в инструкции физического процессора, а в набор виртуальных команд. Исходный код Java обычно компилируется в файлы классов JVM и байт-код, а JVM определяет структуру среды выполнения, включая стек, кучу, область методов, пул констант и набор инструкций. Такая модель создаёт общий исполнительный слой между языком и физическим аппаратным обеспечением, позволяя использовать один формат программы в разных операционных системах и на различных процессорах.
JIT-компиляция преобразует промежуточное представление или часто выполняемые участки кода в машинный код текущей системы во время работы программы. Она позволяет использовать сведения, собранные во время исполнения, и выполнять оптимизацию с учётом реальных связей вызовов, форм значений и частоты повторений. Однако время и память, необходимые для компиляции, становятся частью процесса выполнения. Современные реализации языков могут одновременно использовать интерпретатор, JIT-компилятор и AOT-компилятор, поэтому компиляцию и интерпретацию следует рассматривать не как полностью раздельные способы, а как стратегии, которые могут сочетаться в одной системе исполнения.
Классификация по системе типов
Система типов представляет собой набор правил, определяющих виды значений, доступных в программе, допустимые для них операции и отношения между значениями. Например, для целых чисел и строк разрешены разные операции, а параметры и возвращаемые значения функций могут иметь определённые ограничения типов. Спецификация Go описывает тип как множество определённых значений и совокупность операций и методов, применимых к этим значениям. В Haskell выражение одновременно представляет значение и обладает статическим типом.
Языки со статической типизацией проверяют отношения типов выражений, переменных и вызовов функций до запуска программы. C, C++, Rust, Java, Haskell и Go преимущественно используют статическую проверку типов. Она позволяет обнаружить часть возможных ошибок до выполнения и предоставляет компилятору информацию о представлении значений и структуре вызовов, которую можно использовать для оптимизации. Однако даже в статически типизированных языках условия, определяемые только во время выполнения, такие как границы массивов, внешние данные и динамическая загрузка, могут требовать отдельных проверок.
В языках с динамической типизацией значения имеют информацию о типе во время выполнения, а допустимость операции часто проверяется в момент её применения к фактическому значению. К таким языкам относятся Python, Ruby и JavaScript. Динамическая типизация позволяет последовательно связывать одно имя со значениями разных видов и естественно работать со структурами, создаваемыми во время выполнения. Однако некоторые ошибки типов могут не проявляться до тех пор, пока соответствующий путь исполнения не будет фактически выполнен. В модели данных Python все данные программы представляются объектами или отношениями между объектами, причём каждый объект обладает идентичностью, типом и значением.
Различие между статической и динамической типизацией не связано напрямую с тем, обязан ли программист явно записывать типы. Статически типизированные языки с поддержкой вывода типов могут определять типы на основе выражений и способов их использования без полного явного указания. Haskell, ML, Rust, Kotlin и Swift активно используют вывод типов. С другой стороны, в динамические языки можно добавлять слои статического анализа, не изменяющие непосредственно поведение программы, например аннотации TypeScript или подсказки типов Python.
Совместимость типов также может определяться через номинальную или структурную типизацию. Номинальная система типов оценивает совместимость главным образом по имени объявленного типа или по явно заданным отношениям наследования и реализации. Структурная система сравнивает не имена типов, а фактический набор полей, методов и других элементов. TypeScript использует структурную подтипизацию, определяя совместимость объектов по составу их членов.
Системы типов также различаются по наличию обобщений и параметрического полиморфизма, подтипизации, типов-сумм и типов-произведений, алгебраических типов данных, зависимых типов, постепенной типизации и других свойств. Распространённые выражения «сильная типизация» и «слабая типизация» используются в литературе и среди пользователей в разных значениях, включая неявные преобразования, безопасность памяти, проверки во время выполнения и возможность обхода системы типов. Поэтому при необходимости точной классификации целесообразнее непосредственно описывать конкретные характеристики, такие как статическая или динамическая проверка, явные или неявные преобразования и безопасность памяти. Типы и полиморфизм образуют не единую шкалу силы, а набор независимых правил, определяющих классификацию значений, абстракции и совместимость.
Классификация по парадигмам программирования
Парадигма программирования определяет, с помощью каких понятий и структур выражаются вычисления. Большинство современных языков не придерживаются строго одной парадигмы и являются многопарадигменными. Например, C++ одновременно поддерживает процедурное, объектно-ориентированное, обобщённое программирование и функциональные выражения, а Python и JavaScript позволяют совместно использовать объекты, функции и императивные управляющие конструкции. Классификация языков ACM также отдельно рассматривает функциональные, объектно-ориентированные, логические и ограничительные, потоковые, конкурентные, распределённые и многопарадигменные языки.
Императивное программирование выражает вычисление как последовательность команд, изменяющих состояние программы. Поток выполнения управляется присваиванием значений переменным, условными операторами, циклами и вызовами функций. Процедурное программирование организует императивную программу в виде вызываемых единиц, таких как функции, процедуры и подпрограммы. C, Pascal и FORTRAN обычно относят к процедурным языкам.
Объектно-ориентированное программирование объединяет данные и обрабатывающие их действия в единицы, называемые объектами. В языках, основанных на классах, класс определяет структуру и поведение объектов, а инкапсуляция, наследование, полиморфизм и динамическая диспетчеризация используются для выражения отношений между ними. Java, C#, C++ и Smalltalk являются характерными примерами, однако способы определения объектов, классов и наследования различаются между языками. Некоторые языки, такие как JavaScript, предоставляют синтаксис классов, но внутренне используют объектную модель на основе прототипов. Спецификация ECMAScript определяет вычисления и операции с объектами в самом языке, но относит ввод-вывод, включая работу с файлами и сетью, к ответственности среды-хоста.
Функциональное программирование рассматривает функции как центральные единицы вычисления и как значения, строя программы через композицию функций, рекурсию, функции высшего порядка и неизменяемые структуры данных. Чистые функциональные языки подчёркивают использование функций, возвращающих одинаковый результат для одинаковых входных данных, и контроль побочных эффектов. Haskell строит язык вокруг выражений, функций и статических типов, а LISP, ML, F# и многие современные языки также поддерживают основные функциональные концепции. Функциональное программирование исследовалось как вычислительная модель, способная заменять или дополнять традиционное программирование в стиле фон Неймана, основанное на командах и состоянии памяти.
Логическое программирование описывает факты, правила и запросы, после чего исполнительная система ищет решения, удовлетворяющие заданным условиям, с помощью логического вывода. Наиболее характерным примером является Prolog, использующий унификацию, возврат с перебором и описание отношений. Программирование в ограничениях задаёт условия, которым должны удовлетворять переменные, а решатель ограничений ищет допустимые значения. Оба подхода описывают отношения и условия, которые должны выполняться, вместо непосредственного задания точной последовательности команд.
Декларативное программирование представляет собой широкую категорию, в которой описывается желаемый результат или отношение, а не подробная последовательность вычислительных действий. Функциональные и логические языки иногда включаются в декларативную категорию. К декларативным также относят языки вроде SQL, в которых пользователь задаёт условия и форму необходимых данных, а порядок доступа и план выполнения определяются системой.
Потоковое программирование выражает зависимости между операциями в виде графа данных и выполняет операцию, когда становятся доступны необходимые входные значения. Оно часто используется в визуальных средах программирования, обработке сигналов, параллельных вычислениях и системах графов машинного обучения. Реактивное программирование строит программу вокруг изменяющихся во времени значений, потоков событий и отношений распространения изменений между значениями.
Конкурентное программирование рассматривает структуры, в которых несколько вычислений выполняются с перекрытием во времени, а также способы коммуникации и синхронизации между ними. В зависимости от языка могут использоваться потоки операционной системы и общая память, акторы и передача сообщений, корутины и асинхронные функции, а также каналы. Go определяет обмен через каналы как один из основных способов синхронизации горутин и закрепляет в модели памяти отношения порядка между отправкой в канал и соответствующим получением.
Классификация по назначению
Язык программирования общего назначения проектируется для создания различных видов программного обеспечения и не ограничивается одной конкретной задачей. К таким языкам относятся C, C++, Java, Python, Rust и Go. Однако даже языки общего назначения могут чаще применяться в определённых областях, таких как системное программное обеспечение, корпоративные приложения, веб-серверы или автоматизация, в зависимости от целей их проектирования и сложившейся экосистемы.
Предметно-ориентированный язык создаётся для краткого и точного выражения задач определённой предметной области. SQL используется для запросов и изменения реляционных данных, регулярные выражения — для описания строковых шаблонов, языки шейдеров — для графических вычислений на GPU, а языки описания аппаратуры — для представления цифровых схем. Предметно-ориентированный язык может выполняться как самостоятельная программа или быть встроен в строки, библиотеки, макросы либо специальный синтаксис другого языка.
По области применения языки также подразделяют на языки системного программирования, скриптовые и командные языки, языки запросов к данным, языки научных вычислений, учебные языки, языки описания аппаратуры, языки шейдеров, а также языки конкурентного и параллельного программирования. Эти названия отражают прежде всего основное назначение и сложившиеся способы применения языка, а не полный набор его возможностей, поэтому один язык может относиться сразу к нескольким областям.
Основные составные элементы языка
Язык программирования состоит не только из грамматики. Чтобы язык функционировал как полноценная вычислительная система, необходимо определить символы и токены исходного кода, структуру предложений, значение каждой конструкции, значения и типы, области видимости имён, поток управления, средства абстракции, а также способы управления памятью и обработки ошибок. Система классификации ACM относит к основным элементам языков типы и структуры данных, управляющие конструкции, функции и процедуры, классы и объекты, модули и пакеты, полиморфизм, рекурсию, конструкции конкурентности и динамическое управление памятью.
Лексическая структура и токены
Лексическая структура определяет наименьшие грамматические единицы исходного кода. К ним относятся идентификаторы, ключевые слова, литералы, операторы, разделители, комментарии, а также правила обработки пробелов и переводов строк. Лексический анализатор читает последовательность входных символов и преобразует её в последовательность токенов, которую может обработать синтаксический анализатор.
Значение пробелов различается в зависимости от языка. В языках семейства C большинство пробелов служит только для разделения токенов, тогда как в Python изменение отступа преобразуется в токены INDENT и DEDENT, обозначающие блочную структуру. Справочник Python различает лексический анализ символьного ввода и синтаксический анализ токенов, полученных в его результате.
Синтаксис
Синтаксис определяет, в каком порядке и в какой структуре должны объединяться токены, чтобы образовать корректную программу. Синтаксические правила описывают форму объявлений переменных, определений функций, выражений, условных операторов, циклов, объявлений типов и модулей. Спецификации языков обычно описывают синтаксис с помощью BNF, EBNF, PEG или сопоставимых формальных нотаций.
Синтаксические правила определяют только внешнюю структуру программы. Например, выражение a + b может быть синтаксически корректным, но возможность применения сложения к a и b, а также результат операции определяются правилами типов и семантикой.
Семантика
Семантика определяет, что означает синтаксически корректная программа и как она должна выполняться. Стандарт языка обычно задаёт семантику вместе с синтаксисом. Стандарт COBOL также определяет синтаксис и семантику языка, чтобы программы могли оставаться независимыми от конкретной машины.
Статическая семантика рассматривает правила, которые можно проверить до запуска программы. К ним относятся наличие объявлений имён, правильность областей видимости, соответствие аргументов и возвращаемых типов при вызове функций, а также допустимость расположения управляющих конструкций. Проверка типов и разрешение имён, которые трудно выразить только синтаксическими правилами, относятся к статической семантике.
Динамическая семантика определяет, какие изменения состояния создают выражения и команды и какие значения они вычисляют во время выполнения. К ней относятся порядок вычисления операторов, вызов функций, возникновение исключений, создание объектов, доступ к памяти и порядок конкурентного выполнения. Семантика может описываться естественным языком или формализоваться математическими методами, такими как операционная, денотационная и аксиоматическая семантика.
Значения и модель данных
Модель данных определяет, что считается значением в языке и какие виды значений можно создавать и комбинировать. Помимо базовых значений, таких как целые и вещественные числа, логические значения, символы и строки, язык может поддерживать массивы, кортежи, записи, структуры, списки, множества, отображения, функции, классы и объекты.
Отношение между значениями и объектами различается в разных языках. Python моделирует все данные как объекты или отношения между объектами и определяет, что каждый объект обладает идентичностью, типом и значением. В Haskell выражения обозначают значения, а каждому выражению назначается статический тип. C определяет объекты, значения и области хранения с помощью модели памяти, более близкой к аппаратному обеспечению. Различия в моделях данных влияют на присваивание и копирование, сравнение идентичности, изменяемость и размещение в памяти.
Имена, связывание и область видимости
Идентификаторы программы обозначают переменные, функции, типы, модули и другие сущности. Связь между идентификатором и фактическим объектом называется связыванием. Связывание может формироваться во время компиляции, компоновки, загрузки программы или непосредственно во время выполнения.
Область видимости представляет собой участок исходного кода, в котором можно обращаться к определённому имени, а время жизни — период выполнения, в течение которого фактически существует значение, объект или область хранения. Эти понятия связаны, но не совпадают. Объект, вышедший из области видимости, может продолжать существовать благодаря другим ссылкам. Возможна и обратная ситуация, при которой имя всё ещё находится в области видимости, но связанный с ним ресурс уже недействителен.
Язык может предоставлять глобальную, модульную, функциональную, блочную и классовую области видимости. Во вложенных функциях может создаваться замыкание, захватывающее локальные переменные внешней функции. Порядок поиска имён и способ их захвата зависят от модели выполнения языка.
Выражения и операторы
Выражение представляет собой элемент языка, вычисляющий или обозначающий значение. Выражением могут быть литерал, обращение к переменной, применение оператора, вызов функции, условное выражение или создание объекта. Оператор или инструкция обозначает действие, влияющее на поток выполнения или состояние программы, например присваивание, цикл, ветвление, возврат или обработку исключения.
В некоторых языках условные конструкции и блоки также считаются выражениями и создают значения. Другие языки строго разделяют выражения, создающие значения, и операторы, выполняющие действия. Справочник языка Python отдельно определяет синтаксис и семантику выражений, а Haskell рассматривает выражения как центральные единицы вычисления.
Поток управления
Поток управления определяет, какие части программы и в каком порядке выполняются. К базовым управляющим конструкциям относятся последовательное выполнение, условные переходы, циклы, вызов и возврат из функций, рекурсия и обработка исключений.
Некоторые языки также предоставляют расширенные управляющие конструкции, включая сопоставление с образцом, корутины, генераторы, асинхронные функции, продолжения и недетерминированный выбор. Эти конструкции являются не только синтаксическими удобствами: они определяют состояние программы, организацию стека вызовов, порядок вычисления значений и распространение ошибок.
Функции и абстракция
Функции, процедуры и подпрограммы разделяют повторяющиеся вычисления на именованные единицы. Основными элементами функциональной системы являются параметры, возвращаемые значения, локальные переменные, соглашения о вызовах и способы передачи значений. Если функцию можно передавать как аргумент или возвращать как результат, язык поддерживает функции первого класса. Функция, принимающая или возвращающая другие функции, называется функцией высшего порядка.
Помимо функций язык может предоставлять классы, интерфейсы, трейты, протоколы, классы типов и абстрактные типы данных для абстрагирования данных и поведения. Полиморфизм позволяет применять один интерфейс или участок кода к значениям различных типов и подразделяется, в частности, на перегрузку, подтипный и параметрический полиморфизм.
Модули и пакеты
Модуль объединяет объявления связанных значений, функций, типов и классов в единое пространство имён и единицу распространения. Он позволяет разделять открытые элементы и внутреннюю реализацию, а также предоставляет механизм импорта возможностей других модулей. Модуль Haskell может определять значения, типы данных, синонимы типов и классы, экспортировать часть этих элементов и включать элементы других модулей в область видимости с помощью объявлений импорта.
Пакет часто используется как более крупная единица распространения, объединяющая один или несколько модулей, ресурсы и метаданные. Однако точное значение и отношения между модулями и пакетами различаются в зависимости от языка и набора инструментов.
Управление памятью и ресурсами
Программа должна выделять память для хранения значений и объектов и освобождать её, когда она больше не требуется. В C и C++ программист может вручную выделять и освобождать память или использовать правила времени жизни объектов. Java, C#, Go и Python преимущественно используют сборку мусора, при которой среда выполнения находит и освобождает недостижимые объекты.
Rust применяет систему владения, заимствования и времён жизни, проверяемую компилятором, чтобы обеспечивать безопасность памяти без сборщика мусора. Когда владелец значения покидает область видимости, ресурс освобождается. Компилятор ограничивает ситуации, в которых ссылка может существовать дольше исходного значения или создаются конфликтующие изменяемые ссылки.
Помимо памяти существуют файлы, сетевые соединения, блокировки, ресурсы GPU и другие объекты, требующие явного освобождения. Языки управляют временем жизни таких ресурсов с помощью деструкторов, конструкций очистки, менеджеров контекста, отложенного выполнения и подходов, объединяющих получение ресурса с инициализацией объекта.
Обработка ошибок и исключений
Язык предоставляет способы представления и распространения сбоев. Программа может использовать традиционные коды возврата и глобальное состояние ошибки либо возбуждать исключение, прерывая текущий поток вызовов и передавая управление обработчику верхнего уровня. Некоторые статически типизированные языки представляют успешные и ошибочные результаты с помощью типов-сумм или типов результата, заставляя вызывающую сторону учитывать возможность ошибки через систему типов.
Способ обработки ошибок влияет на возвращаемые типы функций, поток управления, очистку ресурсов и устойчивость программы. Даже в языках с исключениями восстанавливаемые ошибки, программные дефекты и ситуации аварийного завершения системы могут обрабатываться различными способами.
Конкурентность и асинхронное выполнение
Модель конкурентности определяет, как создавать несколько задач, обмениваться между ними данными и координировать порядок их выполнения. В языках, использующих потоки и общую память, важны блокировки, атомарные операции и правила порядка памяти. В моделях передачи сообщений или каналов центральным механизмом синхронизации становится обмен значениями между задачами.
Корутины и конструкции async и await позволяют задаче вернуть управление на время ожидания ввода-вывода или другой асинхронной операции, а затем продолжить выполнение с места остановки. Эти средства не обязательно означают реальный параллелизм: несколько задач могут поочерёдно выполняться в одном потоке или сочетаться с несколькими потоками.
Стандартная библиотека и среда выполнения
Стандартная библиотека предоставляет общие интерфейсы для часто используемых возможностей, включая строки, коллекции, файлы, математические операции, время, сеть и конкурентность. Ядро спецификации языка и стандартная библиотека тесно связаны, но не являются одним и тем же. Справочник Python также различает правила самого языка и возможности встроенных и стандартных модулей.
Среда выполнения представляет собой программный слой, поддерживающий языковые возможности во время работы программы. Она может отвечать за выделение памяти, сборку мусора, динамическую проверку типов, обработку исключений, управление потоками, вызов виртуальных методов и рефлексию. Может существовать отдельная виртуальная машина, подобная JVM, явно определяющая модель памяти выполнения и набор виртуальных инструкций. В других случаях, как в C, используется сравнительно небольшая библиотека поддержки компилятора и системные библиотеки.
Язык может не определять ввод-вывод и функции операционной системы непосредственно, передавая их среде выполнения. Спецификация ECMAScript задаёт вычислительную и объектную модели языка, однако получение внешних данных и вывод результатов обеспечиваются средой-хостом, такой как браузер или серверная среда выполнения. Поэтому реальная среда разработки состоит из сочетания спецификации языка, среды выполнения, стандартной библиотеки и API хоста.
Системы обработки языка и инструменты
Система обработки языка может включать лексический и синтаксический анализаторы, семантический анализатор, компилятор, интерпретатор, оптимизатор, генератор кода, ассемблер, компоновщик и загрузчик. Реальная цепочка компиляции анализирует исходный код, строит абстрактное синтаксическое дерево, понижает его до промежуточного представления, выполняет оптимизацию и генерацию кода для целевой платформы, а затем связывает объектные файлы и библиотеки в исполняемую программу.
Среда разработки может включать отладчик, статический анализатор, форматтер, линтер, средства тестирования, генератор документации, систему сборки и менеджер пакетов. Эти инструменты не являются синтаксическими частями языка, однако существенно влияют на практическое удобство использования, устойчивость экосистемы, переносимость и воспроизводимость кода.
Различие между языком, спецификацией и реализацией
Язык программирования следует рассматривать отдельно от его спецификации, реализации, стандартной библиотеки и экосистемы разработки. Спецификация языка определяет, какие программы являются корректными и какое значение имеет каждая из них. Реализация представляет собой программное обеспечение, такое как компилятор, интерпретатор или виртуальная машина, позволяющее фактически выполнять программы в соответствии с этой спецификацией.
У одного языка может существовать несколько реализаций, различающихся внутренней архитектурой, производительностью, целевыми средами выполнения и дополнительными возможностями. Помимо CPython, для Python существуют Jython для JVM, IronPython для .NET и PyPy, использующий JIT-компиляцию. В то же время одна среда выполнения может поддерживать несколько языков. На JVM можно выполнять не только Java, но также Kotlin, Scala и Clojure, а на платформе .NET языки C#, F# и Visual Basic используют общую промежуточную форму и среду выполнения.
Поэтому при описании конкретного языка программирования недостаточно называть его только «компилируемым», «объектно-ориентированным» или «статически типизированным». Необходимо совместно рассматривать его синтаксис и семантику, систему типов, поддерживаемые парадигмы, модель выполнения, управление памятью, конструкции конкурентности и основные реализации. Сочетание этих элементов определяет форму программ, которые можно выразить на языке, способы разработки, безопасность, производительность и области применения.
Парадигмы программирования
Парадигма программирования — это основная точка зрения и метод представления программ и вычислений. Одни парадигмы описывают программу как последовательность команд, изменяющих состояние памяти, другие — как вычисление функций, обмен сообщениями между объектами, логические отношения, зависимости данных или поток событий. Парадигма представляет собой модель проектирования, определяющую, на какие единицы разделяется программа, где размещаются состояние и поток управления и каким способом абстрагируется задача.
Языки программирования и парадигмы программирования не соответствуют друг другу в отношении один к одному. Один язык может поддерживать несколько парадигм, а одна и та же парадигма может быть реализована в разных языках с помощью различных синтаксических и исполнительных моделей. C преимущественно применяется как процедурный и императивный язык, но указатели на функции и абстрактные типы данных позволяют использовать и другие способы организации программ. C++ сочетает процедурное, объектно-ориентированное, обобщённое программирование и функциональные выражения, а Python и JavaScript позволяют совместно использовать императивный код, объекты, функции первого класса и функции высшего порядка. Поэтому точнее рассматривать не принадлежность языка только к одной парадигме, а то, вокруг каких парадигм он спроектирован и в какой степени поддерживает каждую из них.
Основные парадигмы программирования можно представить следующим образом.
| Парадигма | Центральный взгляд на вычисление | Характерные элементы |
|---|---|---|
| Императивное программирование | последовательность команд, изменяющих состояние | переменные, присваивание, условные операторы, циклы |
| Процедурное программирование | разложение команд на вызываемые процедуры | функции, процедуры, подпрограммы |
| Объектно-ориентированное программирование | взаимодействие объектов, обладающих состоянием и поведением | объекты, сообщения, классы, интерфейсы |
| Функциональное программирование | вычисление и композиция функций | чистые функции, функции высшего порядка, неизменяемые значения |
| Логическое программирование | вывод результатов из фактов и правил | отношения, правила, запросы, унификация |
| Программирование в ограничениях | описание условий, которым должны удовлетворять значения | переменные, области значений, ограничения, поиск решений |
| Потоковое программирование | выполнение операций в соответствии с зависимостями данных | узлы, соединения, токены, граф данных |
| Реактивное программирование | распространение изменений, происходящих во времени | потоки событий, сигналы, подписки, распространение |
| Конкурентное программирование | продвижение и взаимодействие нескольких вычислений | потоки, сообщения, каналы, акторы |
| Обобщённое программирование | алгоритмы, независимые от конкретных типов и реализаций | параметры типов, ограничения, концепты |
| Аспектно-ориентированное программирование | разделение функциональности, пересекающей несколько модулей | аспекты, точки соединения, советы, сплетение |
Эти категории не являются взаимоисключающими. Функциональная программа может одновременно быть декларативной и конкурентной, а внутри объектно-ориентированного языка могут использоваться процедурные алгоритмы и обобщённые структуры данных. Потоковые, реактивные и акторные модели могут быть реализованы как парадигма всего языка или как частичная программная модель, предоставляемая библиотекой либо фреймворком.
Императивное программирование
Императивное программирование представляет программу как последовательность команд, изменяющих состояние компьютера. Программа читает текущее состояние, записывает результаты вычислений в переменные или память и в зависимости от условий выбирает следующую выполняемую команду. Обычная императивная программа состоит из переменных, присваиваний, условных операторов, циклов, вызовов функций и операций ввода-вывода.
Базовая модель императивного программирования тесно связана с традиционной архитектурой компьютера с хранимой программой, в которой значения считываются из памяти, обрабатываются и затем записываются обратно. В своей Тьюринговской лекции 1977 года Джон Бэкус проанализировал, насколько сильно традиционные императивные языки отражают вычислительную модель фон Неймана, основанную на последовательном изменении значений в хранилище, и предложил альтернативный подход, сосредоточенный на композиции функций. Эта дискуссия стала одним из основных теоретических оснований для объяснения различий между императивным и функциональным программированием.
Следующий код показывает типичную императивную структуру вычисления суммы.
int total = 0;
for (int i = 0; i < count; ++i) {
total += values[i];
}
В этом коде значения total и i постоянно изменяются во время выполнения. Чтобы понять результат программы, необходимо проследить, как меняется состояние переменных после выполнения каждой команды.
Императивное программирование естественно соответствует работе аппаратного обеспечения и позволяет детально контролировать порядок выполнения, использование памяти и побочные эффекты. Оно широко применяется в операционных системах, драйверах устройств, игровых движках, встраиваемых системах и высокопроизводительном программном обеспечении, где важны процесс выполнения и управление ресурсами. Однако изменение общего состояния в нескольких местах может усложнить рассуждение о поведении кода, а в конкурентной среде способно привести к состояниям гонки и проблемам синхронизации.
Процедурное программирование
Процедурное программирование организует императивную программу в виде вызываемых единиц, называемых функциями, процедурами или подпрограммами. Каждая процедура выполняет определённую задачу, получает значения через параметры и возвращает результат либо изменяет состояние программы.
Процедурное программирование разделяет крупную задачу на несколько небольших процедур. Например, программа обработки файла может быть разделена на процедуры открытия файла, чтения заголовка, разбора данных, сохранения результата и обработки ошибок. Каждая процедура может предоставлять вызывающей стороне только необходимый интерфейс, скрывая внутренние детали реализации.
C, Pascal, FORTRAN и BASIC относятся к характерным процедурным языкам. В современных объектно-ориентированных и многопарадигменных языках алгоритмы внутри методов и обычных функций также часто записываются в процедурном стиле.
Суть процедурного программирования заключается не просто в использовании функций. Она состоит в организации потока выполнения через ясные отношения вызова процедур и разделении задач, входных и выходных данных каждой процедуры. Правильная декомпозиция повышает повторное использование и тестируемость кода, однако чрезмерная зависимость от общего изменяемого состояния может увеличить связанность между процедурами.
Структурное программирование
Структурное программирование — это подход, направленный на организацию потока управления с помощью ясных конструкций, таких как последовательное выполнение, выбор и повторение, и на сокращение неструктурированных переходов в произвольные точки программы. Его цель состоит в том, чтобы выражать выполнение через вложенные блоки и процедуры, упрощая локальное понимание и проверку поведения кода.
Эдсгер Дейкстра и другие исследователи считали, что по мере увеличения размеров программ особенно важной становится структура, позволяющая человеку точно рассуждать о возможных путях исполнения. Работа Дейкстры «Notes on Structured Programming» стала одним из основных текстов, систематизировавших пошаговую декомпозицию программ и применение ограниченного набора управляющих конструкций.
Структурное программирование часто описывается только как отказ от оператора goto, однако его суть состоит не в запрете конкретной синтаксической конструкции. Основная задача заключается в разделении программы на небольшие единицы, которые можно понять целиком, и в ясном определении точек входа и выхода, предварительных условий и результатов каждой единицы. Блоки, функции, условные операторы, циклы и конструкции обработки исключений большинства современных языков сформировались под влиянием структурного программирования.
Объектно-ориентированное программирование
Объектно-ориентированное программирование представляет программу как множество объектов, обладающих состоянием и поведением, а также взаимодействий между ними. Объект объединяет данные, которыми он управляет, и операции, применяемые к этим данным, в единую сущность. Другие объекты взаимодействуют с ним через открытый интерфейс или сообщения, не нуждаясь в прямом знании его внутреннего представления.
Истоки объектно-ориентированного программирования связаны с Simula, разработанным в 1960-х годах. Для представления реальных участников и процессов в дискретно-событийном моделировании Simula ввёл понятия, соответствующие объектам, классам и наследованию. Simula 67 систематизировал классы, объекты, виртуальные процедуры и другие концепции, ставшие центральными для последующих объектно-ориентированных языков.
Smalltalk 1970-х годов распространил объекты и передачу сообщений на всю структуру программы. В Smalltalk почти все элементы, включая числа, коллекции, классы и саму среду разработки, рассматриваются как объекты, а вычисление выражается через отправку сообщений между ними. Smalltalk оказал значительное влияние не только на объектно-ориентированные языки, но также на графические пользовательские интерфейсы и интерактивные среды разработки.
В объектно-ориентированном программировании обычно важны следующие понятия.
- Объект — единица программы, обладающая идентичностью, состоянием и поведением.
- Инкапсуляция отделяет внутреннее состояние и реализацию объекта от внешнего интерфейса.
- Передача сообщений или вызов методов представляет собой способ, которым один объект запрашивает действие у другого.
- Полиморфизм позволяет использовать разные реализации через один и тот же интерфейс.
- Наследование позволяет определять новый тип на основе характеристик существующего типа или класса.
- Композиция объединяет несколько объектов для построения более сложного поведения.
Не все объектно-ориентированные языки предоставляют эти элементы одинаковым образом. Java и C# строятся вокруг явных классов и интерфейсов, а C++ сочетает классы, типы-значения и шаблоны. Объектная модель JavaScript традиционно основана на связях прототипов, и даже синтаксис class внутренне работает поверх прототипной модели. Go не предоставляет традиционного наследования классов, но поддерживает объектно-ориентированное проектирование с помощью методов, интерфейсов и композиции.
Наследование является лишь одним из инструментов объектно-ориентированного проектирования, а не обязательным центральным элементом. Более фундаментальные цели состоят в ясном распределении ответственности объектов, предоставлении устойчивых внешних интерфейсов и управлении зависимостями между объектами. Глубокие иерархии наследования и широкое совместное использование изменяемого состояния могут повышать связанность программы, поэтому современное объектно-ориентированное проектирование часто сочетает интерфейсы, композицию, неизменяемые объекты и внедрение зависимостей.
Функциональное программирование
Функциональное программирование выражает вычисления через оценку и композицию функций. Функции рассматриваются не только как процедуры, объединяющие команды, но и как значения первого класса, которые можно сохранять в переменных, передавать в качестве аргументов и возвращать как результаты.
Одной из теоретических основ функционального программирования является лямбда-исчисление, разработанное Алонзо Чёрчем. Ранний LISP внедрил в практический язык программирования основные идеи функций, рекурсии и обработки списков. Позднее языки семейства ML развили статический вывод типов и алгебраические типы данных, а Haskell сформировал характерную модель чистого функционального языка, основанную на чистых функциях, ленивых вычислениях и классах типов. Haskell возник в 1980-х годах из обсуждения необходимости общего открытого языка для различных функциональных языков с ленивыми вычислениями.
В функциональном программировании важное место занимают следующие понятия.
- Функции первого класса позволяют сохранять и передавать функции как обычные значения.
- Функции высшего порядка принимают другие функции в качестве аргументов или возвращают функции как результаты.
- Чистая функция не изменяет внешнее состояние и возвращает одинаковый результат для одинакового ввода.
- Ссылочная прозрачность означает, что выражение можно заменить его результатом без изменения смысла программы.
- Неизменяемость предполагает создание новых значений вместо изменения уже существующих.
- Рекурсия выражает повторяющиеся вычисления через вызов функцией самой себя.
- Композиция функций объединяет небольшие функции в более сложные вычисления.
- Сопоставление с образцом позволяет выбирать ветвь вычисления по структуре значения и извлекать его внутренние элементы.
Ниже приведён типичный пример обработки коллекции с помощью композиции функций.
sumOfSquares =
sum
. map (\x -> x * x)
. filter even
Это выражение не задаёт явно, в каком порядке обходить коллекцию и какие значения присваивать временным переменным. Оно описывает отношение преобразований: выбрать чётные значения, возвести каждое в квадрат и сложить результаты.
Организация программы вокруг чистых функций и неизменяемых значений уменьшает влияние функций на внешнее состояние, упрощает локальное рассуждение о результатах и независимое тестирование. Сокращение общего изменяемого состояния также помогает ограничивать гонки данных в конкурентных программах.
Однако не все функциональные языки разрешают только чистые функции. LISP, OCaml, F# и Scala сочетают функциональные возможности с изменяемым состоянием и императивными управляющими конструкциями. Haskell также способен выражать ввод-вывод и изменение состояния, но отделяет эти эффекты от чистых выражений через систему типов и вычислительную структуру. Поэтому функциональное программирование можно понимать не как полное устранение побочных эффектов, а как их явное локализованное управление.
Функциональные языки различаются и по стратегии вычисления. При строгом вычислении аргументы функции вычисляются до её вызова, тогда как нестрогое или ленивое вычисление может откладывать операцию до момента, когда её результат действительно понадобится. Ленивые вычисления позволяют естественно выражать бесконечные структуры данных и композицию вычислений, но иногда затрудняют прогнозирование фактического момента выполнения и использования памяти.
Декларативное программирование
Декларативное программирование охватывает подходы, в которых описываются желаемый результат, отношение или набор ограничений, а не непосредственная последовательность команд для выполнения вычисления. Императивная программа в первую очередь отвечает на вопрос «в каком порядке выполнять вычисления», тогда как декларативная — на вопрос «какой результат должен быть получен».
Декларативное программирование не обозначает одну единственную модель выполнения. В широком смысле к декларативным парадигмам относят функциональное, логическое, ограничительное и основанное на правилах программирование, а также языки запросов к данным. Однако функциональный язык может содержать императивные средства, а исполнитель декларативного языка внутренне использовать сложные императивные алгоритмы, поэтому граница между декларативным и императивным подходами не всегда является строгой.
Запрос SQL является характерным примером декларативного программирования.
SELECT name
FROM users
WHERE active = TRUE
ORDER BY name;
Этот запрос не указывает, в каком порядке читать файлы данных, какой индекс использовать и каким алгоритмом выполнять сортировку и фильтрацию. Пользователь описывает форму желаемого результата, а оптимизатор запросов системы управления базами данных выбирает конкретный план выполнения.
Передавая детали исполнения системе, декларативные программы позволяют делать выражения более краткими и дают исполнительной системе возможность выбирать различные стратегии оптимизации в зависимости от ситуации. Однако поскольку фактический процесс определяется реализацией, для анализа проблем производительности может потребоваться отдельное понимание внутреннего плана выполнения и правил оптимизации.
Логическое программирование
Логическое программирование представляет программу как совокупность фактов, правил и запросов. Программист описывает, какие отношения являются истинными, а исполнительная система с помощью логического вывода ищет решения, удовлетворяющие запросу.
Роберт Ковальский описывал алгоритм как сочетание логики и управления. Логика определяет отношения и смысл, которые должны выполняться в задаче, а управление задаёт порядок и способ поиска по этим отношениям. Это разделение стало важной основой для понимания различий между смыслом задачи и стратегией её выполнения в логическом программировании.
Prolog является характерным языком логического программирования. Программа на Prolog сначала определяет факты и правила, после чего выполняет запросы.
parent(alice, bob).
parent(bob, carol).
ancestor(X, Y) :-
parent(X, Y).
ancestor(X, Y) :-
parent(X, Z),
ancestor(Z, Y).
Здесь правило ancestor не задаёт конкретную последовательность вычислений с помощью обычных команд, а определяет отношение между родителем и предком. Исполнительная система использует унификацию и возврат с перебором, чтобы находить значения переменных, удовлетворяющие условиям.
Логическое программирование применялось в обработке естественного языка, представлении знаний, доказательстве теорем, экспертных системах, анализе программ и системах, основанных на правилах. Ограниченные логические языки, такие как Datalog, также используются для запросов к базам данных, статического анализа, описания политик управления доступом и отношений в распределённых системах.
Программирование в ограничениях
Программирование в ограничениях — это парадигма, в которой вместо написания процедуры непосредственного вычисления значений переменных описываются условия, которым эти переменные должны удовлетворять, а решатель ограничений ищет возможное решение.
Например, в задаче планирования начальные и конечные моменты каждой работы можно представить переменными и определить следующие ограничения.
- Работа A должна завершиться раньше работы B.
- Две работы не могут одновременно использовать один и тот же ресурс.
- Все работы должны завершиться до заданного срока.
- Определённая работа может выполняться только в разрешённом временном интервале.
Решатель ограничений сокращает области допустимых значений переменных, распространяет ограничения и при необходимости выполняет поиск с выбором кандидатов и возвратом. Программирование в ограничениях подходит для планирования, распределения ресурсов, комбинаторной оптимизации, проектирования схем, задач конфигурации и верификации.
В программировании логики с ограничениями логические отношения и числовые, множественные или временные ограничения можно совместно выражать в одной программе.
Программирование на основе правил
Программирование на основе правил выражает действия, которые необходимо выполнить при наступлении определённых условий, в виде правил. Обычное правило состоит из условной и заключительной частей.
Условие:
учётная запись пользователя заблокирована
и с момента последней неудачной аутентификации прошло 30 минут.
Действие:
снять блокировку учётной записи.
Механизм правил проверяет текущие факты и состояние, находит применимые правила и выполняет их в соответствии с приоритетами и политикой разрешения конфликтов. Подход на основе правил используется в экспертных системах, деловых политиках, управлении доступом, обработке событий и системах поведения в играх.
Программирование на основе правил может выглядеть похожим на логическое программирование, однако оно не обязательно использует модель вывода формальной логики. Некоторые системы правил содержат императивные действия, непосредственно изменяющие состояние, а порядок выполнения правил может влиять на результат.
Обобщённое программирование
Обобщённое программирование — это парадигма создания алгоритмов и структур данных, не зависящих от конкретного типа или реализации, с явным описанием операций и свойств, необходимых этим алгоритмам.
Например, алгоритм сортировки можно написать не только для массива целых чисел, а для разных типов данных, элементы которых можно сравнивать и перемещать. Алгоритму требуется не конкретное имя типа, а выполнение условий сравнимости и перемещаемости элементов.
Обобщённое программирование было систематизировано в 1980-х годах в исследованиях Дэвида Массера и Александра Степанова. Впоследствии стандартная библиотека шаблонов C++ предоставила характерную систему обобщённого программирования, разделяющую итераторы, контейнеры и алгоритмы, но позволяющую сочетать их с помощью параметров типов и полиморфизма времени компиляции. Важным вопросом обобщённого программирования является и возможность специализации абстрактного алгоритма для конкретного типа без лишних затрат времени выполнения.
Способы выражения обобщённого программирования различаются в зависимости от языка.
- C++ использует шаблоны и concepts.
- Java и C# используют обобщённые типы и ограничения.
- Rust использует обобщения и ограничения трейтов.
- Haskell использует параметрический полиморфизм и классы типов.
- Go использует параметры типов и ограничения интерфейсов.
- Языки семейства ML используют полиморфные функции и модульные системы.
Обобщённое программирование не сводится к простой замене имени типа параметром в исходном коде. Это подход к проектированию, при котором выявляются общие понятия, действительные для разных типов и алгоритмов, и выражаются через минимальный набор требований.
Потоковое программирование
Потоковое программирование представляет программу как граф вычислительных узлов и зависимостей данных между ними. Каждая операция выполняется, когда готовы необходимые входные данные, а результат передаётся следующим связанным операциям.
Традиционная императивная программа последовательно выполняет команды, на которые указывает счётчик команд, тогда как в потоковой модели возможность выполнения определяется готовностью данных. Независимые друг от друга операции могут выполняться одновременно, поэтому потоковая модель является естественной основой для выражения параллельных вычислений. Исследования потоковых архитектур Джека Денниса конкретизировали вычислительную модель, в которой операции активируются зависимостями данных, а не заранее заданным порядком команд.
Потоковую программу можно представить следующим образом.
Вход A ─┐
├─ Умножение ─┐
Вход B ─┘ │
├─ Сложение ─ Результат
Вход C ───────────────┘
Операция умножения выполняется, когда готовы A и B, а сложение — когда доступны результат умножения и C. Поток управления не задаётся отдельно, а выводится из отношений зависимости между данными.
Потоковый подход широко применяется в графических средах программирования, цифровой обработке сигналов, потоковых системах, системах сборки, электронных таблицах, графах вычислений на GPU и фреймворках машинного обучения. Однако для представления циклов, условных ветвлений и изменяемого состояния внутри потокового графа необходимы отдельные узлы, токены, управляющие зависимости или механизмы хранения состояния.
Событийно-ориентированное программирование
Событийно-ориентированное программирование — это подход, при котором поток выполнения программы определяется событиями, такими как пользовательский ввод, сетевые сообщения, таймеры, изменения файлов и сигналы датчиков. Программа регистрирует обработчики событий или функции обратного вызова, а среда выполнения вызывает соответствующий обработчик при возникновении события.
Графический пользовательский интерфейс является характерным примером событийной системы. Программа не управляет моментом, когда пользователь нажмёт кнопку, а определяет действие, которое должно быть выполнено при возникновении события нажатия.
button.addEventListener("click", () => {
saveDocument();
});
Событийные программы хорошо подходят для серверов, пользовательских интерфейсов, игр, встраиваемых систем и распределённых служб, реагирующих на внешние данные. Однако при многоуровневом вложении функций обратного вызова и изменении ими общего состояния становится сложнее понимать порядок выполнения и распространение ошибок. Promise, future, корутины и конструкции async и await используются для более структурированного представления связей между асинхронными событиями и передачи их результатов.
Событийно-ориентированное и реактивное программирование тесно связаны, но не являются одним и тем же. В событийной программе при возникновении события обычно императивно вызывается зарегистрированный обработчик. Реактивная программа в большей степени определяет зависимости между изменяющимися во времени значениями и событиями и организует распространение изменений по этим отношениям.
Реактивное программирование
Реактивное программирование — это парадигма представления потоков значений и событий, возникающих во времени, при которой изменения входных данных автоматически распространяются к связанным вычислениям. Последовательные изменения значений или асинхронные события моделируются как потоки либо сигналы, а новые потоки создаются с помощью операций фильтрации, преобразования и объединения.
Электронную таблицу можно рассматривать как простой пример реактивного вычисления. При изменении значения одной ячейки автоматически пересчитываются результаты формул, ссылающихся на неё. Программист не записывает явную последовательность команд обновления каждой ячейки, а определяет зависимости между значениями.
Функциональное реактивное программирование объединяет функциональное программирование с реактивными вычислениями. В ранних исследованиях FRP непрерывно изменяющиеся во времени поведения и дискретные события представлялись как компонуемые абстракции. Позднее FRP развилось в способ декларативного построения пользовательских интерфейсов, анимации, систем управления роботами и интерактивных систем.
Современные реактивные библиотеки обычно предоставляют следующие операции.
mapпреобразует каждое событие или значение в другое значение.filterпропускает только события, удовлетворяющие условию.mergeобъединяет несколько потоков в один.combineсоздаёт новое значение на основе последних значений нескольких потоков.debounceобъединяет события, многократно возникающие за короткий период.bufferсобирает события за определённый период или до заданного количества.subscribeподключает обработчик, получающий результаты потока.
В реактивных программах необходимо совместно управлять обратным давлением, возникающим, когда производитель создаёт данные быстрее потребителя, временем жизни подписок, распространением ошибок и планировщиками выполнения.
Конкурентное программирование
Конкурентное программирование — это парадигма построения программ, в которых два или более вычисления независимо продвигаются в течение одного и того же временного интервала. Несколько задач не обязаны выполняться буквально в один и тот же момент: конкурентность существует и тогда, когда они поочерёдно исполняются на одном процессоре, но имеют перекрывающиеся периоды жизни.
Параллельное программирование сосредоточено на одновременном выполнении двух или более вычислений для увеличения пропускной способности или скорости обработки. Распределённое программирование рассматривает системы, в которых несколько компьютеров с независимой памятью и отдельными областями отказа взаимодействуют через сеть.
Эти три понятия связаны, но не тождественны.
| Понятие | Центральная задача |
|---|---|
| Конкурентность | как правильно организовать и координировать несколько задач |
| Параллелизм | как одновременно выполнять вычисления с использованием нескольких вычислительных ресурсов |
| Распределённость | как координировать несколько систем при наличии сети и частичных отказов |
Модели конкурентности различаются в зависимости от того, используется ли общее состояние, каким способом выполняется обмен данными и как определяется порядок исполнения задач.
Общая память и потоки
В модели общей памяти несколько потоков обращаются к значениям в одной и той же памяти. Обмен между потоками осуществляется через общие переменные и структуры данных, а для предотвращения гонок данных при одновременном изменении одного состояния применяются блокировки, семафоры, условные переменные и атомарные операции.
Общая память позволяет эффективно обмениваться данными без их явной передачи, однако результат программы может зависеть от тонких различий в порядке выполнения. Чтобы избегать взаимных блокировок, активных блокировок, инверсии приоритетов, гонок данных и проблем видимости памяти, необходимо понимать модель памяти, определённую языком и средой выполнения.
Communicating Sequential Processes
Communicating Sequential Processes — это модель, в которой независимо выполняющиеся последовательные процессы взаимодействуют посредством явной коммуникации. В статье 1978 года Тони Хоар предложил рассматривать ввод-вывод и синхронизированное взаимодействие между процессами как основные компоненты параллельных программ.
В CSP каждый процесс управляет собственным локальным состоянием и обменивается значениями через каналы. Коммуникация служит не только для передачи данных, но и для синхронизации процессов.
Горутины и каналы Go являются характерным примером модели программирования, испытавшей влияние CSP. Однако модель выполнения и каналы Go не являются буквальной реализацией исходного формального CSP: язык предоставляет возможности и планирование среды выполнения, адаптированные для практического системного программирования.
Модель акторов
Модель акторов представляет конкурентную систему как совокупность независимых акторов. Получив сообщение, актор может выполнить следующие действия:
- изменить собственное внутреннее состояние;
- отправить сообщения другим акторам;
- создать новые акторы;
- определить поведение для обработки следующего сообщения.
Акторы не обращаются напрямую к внутреннему состоянию других акторов и взаимодействуют посредством асинхронных сообщений. Модель акторов возникла из предложенной в 1973 году Карлом Хьюиттом, Питером Бишопом и Ричардом Стайгером модели конкурентных вычислений.
На концепции акторов основаны модели процессов Erlang и Elixir, Akka и различные распределённые акторные фреймворки. Изолированное состояние и передача сообщений уменьшают конкуренцию за общую память и хорошо подходят для представления компонентов распределённых систем.
Однако необходимо обрабатывать задержки, порядок, дублирование и потерю сообщений, а также отказы акторов. Согласованное изменение состояния нескольких акторов требует отдельных протоколов. Само использование акторов не решает автоматически проблемы отказов и согласованности в распределённых системах.
Корутины и структурированная конкурентность
Корутина — это вычислительная единица, способная приостановить выполнение и позднее продолжить его с того же места. Корутины используются для реализации генераторов, асинхронных функций и кооперативных задач и часто предоставляются как более лёгкие единицы выполнения по сравнению с потоками операционной системы.
async и await позволяют выражать асинхронные операции в структуре, похожей на обычный последовательный код. Во время ожидания результата ввода-вывода функция может приостановить выполнение и вернуть управление исполнителю или циклу обработки событий.
Структурированная конкурентность связывает время жизни создаваемых конкурентных задач с блочной и вызывающей структурой программы. Родительская задача управляет завершением, ошибками и отменой дочерних задач, что уменьшает количество задач, продолжающих существовать за пределами породившего их кода, и асинхронных операций, которые трудно отслеживать.
Корутины сами по себе не гарантируют параллельного выполнения. Несколько корутин могут кооперативно исполняться в одном потоке или выполняться параллельно поверх нескольких потоков либо исполнителя задач.
Аспектно-ориентированное программирование
Аспектно-ориентированное программирование — это парадигма, стремящаяся выделить в отдельные единицы сквозные функциональные возможности, повторяющиеся в нескольких модулях и классах. К характерным сквозным аспектам относятся журналирование, проверка прав доступа, транзакции, измерение производительности и восстановление после ошибок.
В обычной объектно-ориентированной или процедурной структуре такая функциональность может повторяться во множестве функций и классов и смешиваться с основной бизнес-логикой. Аспектно-ориентированное программирование определяет сквозную функциональность как аспект и применяет её в определённых точках соединения программы. Исследования Грегора Кичалеса и других систематизировали аспектно-ориентированное программирование как способ улучшения разделения ответственностей.
Основные понятия включают:
- точка соединения — место программы, например вызов функции или создание объекта, в котором может применяться дополнительное поведение;
- срез точек — описание того, какие точки соединения необходимо выбрать;
- совет — действие, выполняемое до, после или вокруг выбранной точки;
- сплетение — процесс объединения поведения аспекта с целевой программой.
Аспекты позволяют отделить основную логику от повторяющейся вспомогательной функциональности, однако фактический поток управления может стать труднее отслеживать, поскольку часть поведения вставляется из других мест и непосредственно не видна в исходном коде. Современные языки и фреймворки нередко предоставляют похожие возможности не через полноценный аспектно-ориентированный язык, а с помощью атрибутов, аннотаций, декораторов, прокси, промежуточного программного обеспечения и подключаемых модулей компилятора.
Метапрограммирование
Метапрограммирование — это способ написания программ, которые анализируют, создают или преобразуют другие программы как данные. Его часто рассматривают не как самостоятельную основную парадигму, а как технику построения программ, применимую внутри различных парадигм.
Метапрограммирование может принимать следующие формы:
- макросы препроцессора заменяют или создают фрагменты исходного кода;
- синтаксические макросы преобразуют абстрактные синтаксические структуры;
- шаблоны создают код на основе типов и констант;
- рефлексия исследует типы и структуру программы во время выполнения;
- генераторы кода создают исходный код из схем или определений интерфейсов;
- подключаемые модули компилятора анализируют или изменяют объявления и выражения;
- частичное вычисление заранее обрабатывает известные входные данные и специализирует программу.
Макросы языков семейства LISP позволяют обрабатывать синтаксическую структуру программы как структуры данных самого языка. Шаблоны C++ используются не только для обобщённого программирования, но также для вычислений времени компиляции и генерации кода. Макросы Rust включают декларативные макросы на основе сопоставления с образцом и процедурные макросы, обрабатывающие синтаксические деревья.
Метапрограммирование позволяет уменьшать повторение кода и добавлять в язык новые абстракции, однако созданный код и фактический поток выполнения могут быть неочевидны в исходном тексте. Поэтому необходимо совместно учитывать сообщения об ошибках, удобство отладки, поддержку инструментов, время сборки и читаемость кода.
Многопарадигменное программирование
Современные универсальные языки программирования обычно поддерживают несколько парадигм одновременно. Даже внутри одного приложения разные парадигмы могут сочетаться в зависимости от характера задачи.
Например, игровой движок может быть организован следующим образом:
- императивное программирование применяется для формирования команд рендеринга и управления устройствами;
- объектно-ориентированное или ориентированное на данные проектирование используется для компонентов игровых систем и инструментов;
- функциональные операции применяются для преобразования коллекций и обработки ресурсов;
- модели потоков данных и конкурентности используются в планировщике задач;
- потоковое программирование применяется в графах шейдеров;
- событийное и реактивное программирование используется в интерфейсе редактора;
- метапрограммирование применяется для сериализации и генерации кода.
Цель многопарадигменного программирования состоит не в использовании всех возможных подходов в одной программе. Она заключается в выборе модели, наиболее ясно выражающей структуру конкретной задачи, и в определении чётких границ между различными моделями.
При сочетании парадигм необходимо последовательно определять владение состоянием и данными, распространение ошибок, границы конкурентности, направление вызовов и уровень абстракции. Внутри объектно-ориентированной структуры можно применять функциональные преобразования, однако непредсказуемое изменение внутреннего состояния объектов может лишить программу преимуществ обеих моделей. При объединении реактивных потоков с императивными обработчиками также необходимо явно определить создание и удаление подписок, порядок выполнения и правила обработки ошибок.
Парадигмы и проектирование программного обеспечения
Парадигма программирования определяет не только выбор конкретного синтаксиса. Она влияет на то, на какие единицы разделяется программа и каким образом определяются отношения между ними.
В императивной парадигме основное внимание уделяется изменениям состояния и последовательности команд. В объектно-ориентированной — ответственности объектов и их взаимодействию. В функциональной — преобразованию значений и композиции функций. В логической и ограничительной — отношениям и условиям, которые должны выполняться. В потоковой — зависимостям между операциями, а в конкурентной — коммуникации и синхронизации между независимыми задачами.
Одна и та же задача может быть выражена по-разному в зависимости от парадигмы. Обработку коллекции, при которой удовлетворяющие условию значения преобразуются и суммируются, можно представить следующим образом:
Императивный подход:
перебирать элементы, проверять условие
и обновлять переменную.
Функциональный подход:
скомпоновать filter, map и reduce.
Объектно-ориентированный подход:
запросить у объекта коллекции выполнение
преобразования и агрегации.
Логический подход:
описать отношения и условия для элементов,
которые должны входить в результат.
Потоковый подход:
соединить узлы фильтрации, преобразования
и агрегации.
Параллельный подход:
разделить входные данные, вычислить части
в нескольких задачах и объединить результаты.
Ни один способ выражения не является всегда превосходящим остальные. Подходящая модель зависит от размера и частоты изменения входных данных, требований к производительности, обработки ошибок, опыта команды, существующего кода и среды выполнения.
Парадигмы программирования также не образуют последовательную цепочку развития, в которой каждая новая парадигма полностью заменяет предыдущую. Процедурные языки не исчезли с появлением объектно-ориентированных языков, а функциональное программирование не заменило целиком императивное. Новые парадигмы позволяют рассматривать с другой точки зрения задачи, которые трудно выражать существующими способами, а затем объединяются с прежними подходами и включаются в современные языки и методы проектирования программного обеспечения.
Поэтому понимание парадигм имеет более широкое значение, чем запоминание синтаксиса отдельных языков. Необходимо уметь моделировать одно вычисление несколькими способами и сравнивать, как в каждом из них представлены состояние, управление, данные, абстракции и конкурентность. Такое понимание позволяет независимо от конкретного языка выбирать структуру программы и языковые средства, наиболее соответствующие решаемой задаче.
Выполнение и реализация
Исходный код, написанный на языке программирования, сам по себе не является набором команд, которые способен непосредственно выполнить центральный процессор. Реализация языка читает исходный текст, анализирует его синтаксис и семантику, а затем преобразует программу в машинный код, байт-код, промежуточное представление или внутреннюю структуру, пригодную для обработки интерпретатором. Преобразованная программа фактически выполняется при поддержке загрузчика операционной системы, виртуальной машины или среды выполнения языка.
Этот процесс значительно различается в зависимости от языка и реализации. Типичные реализации C и C++ компилируют исходный код в объектные файлы, а затем связывают их в нативный исполняемый файл. Java и Kotlin обычно создают байт-код JVM, а C# и F# используют общее промежуточное представление .NET. CPython преобразует исходный код Python в объекты кода, содержащие внутренний байт-код, и выполняет их на виртуальной машине. Движки JavaScript после синтаксического анализа исходного кода часто совместно используют интерпретатор и JIT-компилятор. Поэтому путь от исходного кода до выполнения представляет собой не единственную фиксированную процедуру, а систему реализации, в которой различные этапы могут выбираться и комбинироваться.
Традиционный процесс нативной компиляции можно представить следующим образом:
Исходный код
↓
Предварительная обработка
↓
Лексический анализ
↓
Токены
↓
Синтаксический анализ
↓
Абстрактное синтаксическое дерево
↓
Семантический анализ и проверка типов
↓
Промежуточное представление
↓
Оптимизация
↓
Генерация целевого кода
↓
Ассемблерный или машинный код
↓
Объектный файл
↓
Компоновка
↓
Исполняемый файл или библиотека
↓
Загрузчик операционной системы и среда выполнения
↓
Выполнение
Не все реализации чётко разделяют эти этапы или выполняют их в одинаковом порядке. Некоторые языки не имеют препроцессора, отдельные реализации создают байт-код непосредственно из абстрактного синтаксического дерева, а некоторые компиляторы анализируют исходный код во время выполнения или используют несколько уровней промежуточных представлений. Драйвер Clang также концептуально разделяет предварительную обработку, компиляцию, ассемблирование и компоновку, однако при фактическом выполнении отдельные этапы могут объединяться внутри одного процесса или связываться через каналы и временные файлы.
Исходный код и единицы трансляции
Исходный код — это первоначальный текст программы, написанный программистом в соответствии с синтаксисом определённого языка программирования. Обычно он состоит из одного или нескольких файлов, которые обрабатываются с учётом кодировки символов, переводов строк, путей к файлам, а также структуры модулей или пакетов.
Компилятор не обязательно считывает весь проект одновременно. В C и C++ широко применяется традиционная модель, при которой один исходный файл после завершения предварительной обработки рассматривается как единица трансляции. Объектные файлы, созданные из нескольких единиц трансляции, затем объединяются компоновщиком в единый исполняемый файл или библиотеку. В Java один файл class обычно содержит двоичное представление одного класса или интерфейса, а JVM может отдельно загружать необходимые классы во время выполнения.
Модульные системы современных языков не обязательно считают исходный файл и логическую единицу программы одним и тем же объектом. Один модуль может состоять из нескольких файлов, а один файл — содержать несколько типов и объявлений. Компилятор может анализировать зависимости между модулями и определять порядок их обработки либо сохранять открытые интерфейсы в отдельных промежуточных файлах, чтобы уменьшить затраты на повторный анализ других модулей.
Предварительная обработка
Предварительная обработка — это этап преобразования части исходного кода перед началом основного синтаксического анализа. Не все языки используют отдельный препроцессор, однако в C и C++ директивы предварительной обработки и макросы занимают важное место в процессе обработки языка.
Препроцессор языков семейства C обычно выполняет следующие задачи:
- включает содержимое файлов, указанных директивой
#include; - разворачивает макросы, определённые с помощью
#define; - включает или исключает части кода в соответствии с директивами условной компиляции;
- подставляет предопределённые макросы, предоставляемые реализацией;
- отслеживает логические позиции в исходном коде и сведения для диагностических сообщений.
Результат предварительной обработки можно рассматривать как последовательность токенов, в которой исходные и заголовочные файлы, ранее разделённые между собой, объединены, а макросы развёрнуты. Затем с этим результатом работают синтаксический и семантический анализаторы компилятора. На практике Clang может тесно объединять предварительную обработку с синтаксическим анализом, поэтому ему не обязательно сначала создавать на диске полный файл с результатом работы препроцессора.
Макросы предварительной обработки преобразуют токены, не понимая обычной синтаксической структуры языка. Из-за этого область видимости, типы и порядок вычисления сгенерированного макросом кода могут отличаться от ожидаемых. Современные языки нередко предоставляют аналогичные возможности без отдельного препроцессора — с помощью модулей, константных выражений, обобщений, атрибутов, синтаксических макросов и подключаемых модулей компилятора, способных учитывать структуру языка.
Лексический анализ
Лексический анализ — это процесс преобразования символов исходного кода в последовательность токенов, которым язык способен придавать значение. Компонент, выполняющий лексический анализ, называется лексером или токенизатором.
Можно рассмотреть следующий исходный код:
int result = value + 10;
Лексер приблизительно разделяет его на следующие токены:
Ключевое слово(int)
Идентификатор(result)
Оператор(=)
Идентификатор(value)
Оператор(+)
Целочисленный литерал(10)
Разделитель(;)
Лексер различает идентификаторы, ключевые слова, числовые и строковые литералы, операторы, разделители и комментарии. Он также может обрабатывать кодировку исходного текста, escape-последовательности, объединение строк и значение пробельных символов. В Python увеличение и уменьшение отступа преобразуется в токены INDENT и DEDENT, обозначающие блочную структуру, поэтому пробелы имеют синтаксическое значение. Официальная документация Python также объясняет, что до передачи программы синтаксическому анализатору лексический анализатор преобразует входной текст в поток токенов.
Помимо типа и исходного текста токен может хранить имя файла, номер строки и столбца, а также начальную и конечную позиции. Эти сведения используются в сообщениях об ошибках, навигации по коду, рефакторинге и создании отладочной информации.
Синтаксический анализ
Синтаксический анализ — это процесс проверки соответствия последовательности токенов грамматике языка и построения структурных отношений между ними. Компонент, выполняющий этот процесс, называется парсером.
Например, следующее выражение имеет определённую структуру в соответствии с приоритетом операторов:
a + b * c
Парсер рассматривает его не просто как пять последовательно записанных токенов, а как следующую иерархическую структуру:
Сложение
├── a
└── Умножение
├── b
└── c
Правило, согласно которому умножение выполняется раньше сложения, отражается в синтаксической структуре. Парсер также структурирует скобки, направление ассоциативности операторов, вызовы функций, условные конструкции, циклы, объявления и выражения типов в соответствии с грамматикой языка.
Парсер может быть реализован с помощью рекурсивного спуска, LR, LALR, LL, PEG и других методов. Некоторые парсеры автоматически создаются из определения грамматики, тогда как другие пишутся разработчиками вручную. Парсер Clang получает токены от лексера, проверяет грамматическую структуру исходного текста и совместно с семантическим анализатором создаёт AST.
Если исходный код не соответствует грамматике, парсер сообщает о синтаксической ошибке. Практический синтаксический анализатор обычно стремится не останавливаться сразу после первой ошибки, а приблизительно определить её расположение, восстановиться до следующего оператора или объявления и показать несколько ошибок за один запуск компиляции. Однако структура после ошибочного токена может быть восстановлена неточно, поэтому первая ошибка иногда становится причиной нескольких последующих диагностических сообщений.
Абстрактное синтаксическое дерево
Абстрактное синтаксическое дерево — это внутреннее представление грамматической структуры программы в форме дерева. Обычно используется сокращение AST.
AST отличается от конкретного синтаксического дерева, сохраняющего все символы исходного текста. Оно может опускать некоторые элементы, такие как скобки и точки с запятой, которые определяют структуру, но не требуются непосредственно для описания исполняемого смысла, и сосредотачивается на значимых отношениях между объявлениями, выражениями, типами и операторами.
В качестве примера можно рассмотреть следующий код:
int square(int value) {
return value * value;
}
AST может приблизительно иметь следующую структуру:
Объявление функции: square
├── Возвращаемый тип: int
├── Параметры
│ └── value: int
└── Тело функции
└── return
└── Умножение
├── Ссылка на value
└── Ссылка на value
AST используется как основа для проверки типов, разрешения имён, статического анализа, генерации кода, рефакторинга и навигации по программе. Компилятор может добавлять к узлам AST сведения о разрешённых типах, ссылках на объявления, исходных позициях, константных значениях и правах доступа. Clang создаёт AST как абстрактное представление исходного текста по результатам синтаксического и семантического анализа, а ASTContext управляет долгоживущими узлами AST, включая типы и объявления.
Некоторым инструментам разработки необходимо сохранять комментарии, пробелы и точные позиции токенов исходного текста, поэтому наряду с AST они могут хранить конкретное синтаксическое дерево или отдельные токенные данные. Автоматические форматтеры и преобразователи исходного кода должны учитывать не только исполняемый смысл, но также исходное оформление и расположение комментариев.
Семантический анализ
Синтаксически корректный код не обязательно является семантически правильным. Семантический анализ проверяет на основе AST, удовлетворяет ли программа статическим семантическим правилам языка, и вычисляет сведения, необходимые для последующих этапов.
Семантический анализ обычно включает следующие задачи:
- разрешение имён, связывающее идентификаторы с объявлениями;
- проверку областей видимости переменных, функций и типов;
- определение типов выражений и объявлений;
- проверку неявных преобразований типов;
- разрешение перегрузки функций;
- проверку ограничений обобщений или шаблонов;
- проверку управления доступом;
- проверку отношений наследования и интерфейсов;
- проверку допустимости операторов возврата и потока управления;
- вычисление константных выражений;
- проверку порядка инициализации и правил времени жизни;
- диагностирование запрещённых языком конструкций.
Например, следующий код синтаксически является корректным выражением вызова функции, однако если параметр функции принимает только целые числа, семантический анализ может обнаружить ошибку:
calculate("text")
Семантический анализатор сравнивает тип "text" с типом параметра calculate и определяет допустимость вызова. В языке с перегрузкой, таком как C++, необходимо также выбрать из нескольких одноимённых функций вариант, наиболее подходящий по типам аргументов и правилам преобразования.
Компонент Clang, отвечающий за семантический анализ, называется Sema. Он определяет семантическую корректность исходного кода и создаёт большую часть диагностических сообщений компилятора. Парсер и Sema тесно взаимодействуют, поэтому имена и типы могут разрешаться непосредственно в процессе построения синтаксической структуры.
После семантического анализа AST содержит значительно больше сведений, чем простое грамматическое дерево. Идентификаторы связываются с конкретными объявлениями, выражения получают типы, а неявные преобразования и цели вызовов становятся явными. На основе этой информации компилятор может преобразовать поведение программы в представление более низкого уровня.
Статический анализ и диагностика
Компилятор может анализировать не только нарушения правил языка, но и код, который допустим для выполнения, однако с высокой вероятностью содержит дефект. Статический анализ исследует исходный код или промежуточное представление программы без её непосредственного выполнения, чтобы обнаружить возможные проблемы.
Статический анализ может выявлять следующие проблемы:
- использование неинициализированных значений;
- недостижимый код;
- условия, которые всегда истинны или всегда ложны;
- возможное обращение к освобождённой памяти или null-ссылке;
- утечки ресурсов;
- возможный выход за границы массива;
- неправильный порядок захвата блокировок;
- возможные гонки данных;
- игнорирование возвращаемого значения;
- несоответствие строки формата аргументам;
- операции, способные привести к неопределённому поведению.
Статический анализатор может использовать графы потока управления, анализ потока данных, символьное выполнение и абстрактную интерпретацию. Полностью предсказать все реальные входные данные и пути выполнения в общем случае невозможно, поэтому результаты анализа могут включать предупреждения, не соответствующие реальному дефекту, или пути, корректность которых анализатор не смог доказать.
Диагностические сообщения компилятора могут подразделяться на ошибки, предупреждения и примечания. Ошибка обозначает проблему, из-за которой невозможно получить корректную программу и компиляция должна быть остановлена. Предупреждение сообщает о подозрительной конструкции, которая формально разрешена языком. Хорошая диагностическая система показывает не только расположение проблемы, но также связанные объявления и возможные способы исправления.
Промежуточное представление
Промежуточное представление — это внутренний формат программы, находящийся между AST исходного языка и командами реальной целевой машины. Обычно используется сокращение IR.
Компиляторы применяют промежуточные представления, чтобы уменьшить сложность, возникающую при непосредственном соединении каждого исходного языка с каждой целевой архитектурой. Фронтенды нескольких языков могут создавать общее IR, общий оптимизатор — обрабатывать его, а разные бэкенды — генерировать код для соответствующих процессоров или виртуальных машин.
C ─────────┐
C++ ───────┤
Rust ──────┼─ Общее промежуточное представление ─┬─ x86-64
Fortran ───┤ ├─ AArch64
Swift ─────┘ ├─ RISC-V
└─ WebAssembly
Промежуточное представление не обязательно должно быть единственным. Современные компиляторы могут последовательно использовать несколько IR с разными уровнями абстракции.
- Высокоуровневое IR сохраняет относительно много конструкций исходного языка, таких как функции, объекты, массивы, исключения и корутины.
- IR среднего уровня представляет поток управления, поток данных и доступ к памяти в форме, удобной для анализа.
- Низкоуровневое IR использует структуры, близкие к регистрам и машинным операциям, но ещё может не зависеть от конкретного процессора.
- Машинное IR содержит целевые сведения, необходимые для выбора реальных инструкций и распределения регистров.
LLVM IR представляет собой типизированное низкоуровневое промежуточное представление, спроектированное вокруг формы статического однократного присваивания. Оно используется как общее представление кода во всей стратегии компиляции LLVM и образует границу между фронтендами, оптимизацией и бэкендами.
Форма статического однократного присваивания
Форма статического однократного присваивания — это способ представления промежуточного кода, при котором каждому имени переменной значение присваивается только один раз. Обычно используется сокращение SSA.
Можно рассмотреть следующий императивный код:
x = 10
x = x + 1
x = x * 2
В SSA каждому присваиванию назначается новое имя:
x1 = 10
x2 = x1 + 1
x3 = x2 * 2
В такой структуре становится ясно, где каждое значение определяется и где используется. Компилятор может легче отслеживать поток значений и выполнять такие оптимизации, как распространение констант, удаление неиспользуемого кода и устранение общих подвыражений.
В точках слияния потока управления может использоваться конструкция, соответствующая φ-функции, которая выбирает одно из значений, пришедших по разным путям:
if condition:
x1 = 10
else:
x2 = 20
x3 = φ(x1, x2)
Фактическая форма LLVM IR и правила её преобразования определены более строго. Во время оптимизации компилятор поддерживает структуру SSA или понижает её до машинного представления целевой архитектуры.
Понижение
Понижение — это процесс преобразования высокоуровневых языковых конструкций в более простые операции низкого уровня. Термин происходит от английского слова lowering.
Например, следующим возможностям высокоуровневого языка может не соответствовать одна непосредственная команда целевой машины:
- объекты и виртуальные методы;
- замыкания;
- сопоставление с образцом;
- обработка исключений;
- корутины и
async·await; - интерполяция строк;
- итераторы и циклы по диапазонам;
- создание объектов с управлением сборщиком мусора;
- обобщённые функции;
- операции с динамическими типами.
Компилятор преобразует такие конструкции в более базовые операции, включая вызовы функций, ветвления, выделение памяти, конечные автоматы, указатели на функции, таблицы переходов и проверки среды выполнения.
Корутина может быть преобразована в конечный автомат, сохраняющий точку приостановки и состояние локальных переменных. Замыкание может быть представлено как сочетание объекта окружения, содержащего захваченные переменные, и вызываемой функции. Вызов виртуального метода может быть понижен до поиска адреса функции в информации о типе объекта и последующего косвенного вызова.
Понижение не обязательно выполняется за один этап. Оно может происходить несколько раз: от IR, близкого к исходному языку, к общему высокоуровневому IR, затем к низкоуровневому IR и машинному IR целевой архитектуры. Использование нескольких уровней IR позволяет каждой оптимизации и трансформации работать с наиболее подходящим уровнем информации.
Оптимизация
Оптимизация компилятора — это преобразование, улучшающее время выполнения, размер кода, доступ к памяти, энергопотребление и другие характеристики при сохранении наблюдаемого поведения программы.
Оптимизатор анализирует, что вычисляет программа, и определяет, можно ли получить тот же результат с меньшим числом операций или с помощью более эффективных команд. При этом преобразования должны оставаться в пределах наблюдаемого поведения, разрешённого языком, его модели памяти, правил вычислений с плавающей точкой, исключений и неопределённого поведения.
К характерным оптимизациям относятся:
- свёртка констант заранее вычисляет операции, результат которых известен во время компиляции;
- распространение констант подставляет значения, признанные постоянными, в последующие места использования;
- удаление неиспользуемого кода устраняет операции, не влияющие на результат, и недостижимые участки;
- устранение общих подвыражений повторно использует результат одинаковых вычислений;
- встраивание функций заменяет вызов функции кодом её тела;
- вынесение инвариантного кода из цикла перемещает за пределы цикла операции, дающие одинаковый результат на каждой итерации;
- разворачивание цикла несколько раз копирует тело цикла, сокращая количество переходов;
- векторизация объединяет одинаковые операции над несколькими элементами в SIMD-команды;
- анализ ускользания определяет, передаётся ли объект за пределы функции или потока, и оптимизирует место его размещения;
- скалярная замена раскладывает небольшие объекты или составные значения на отдельные значения;
- оптимизация хвостовых вызовов повторно использует текущий кадр вызова для некоторых вызовов функций;
- упрощение ветвлений удаляет или объединяет лишние условия и переходы.
Оптимизация не завершается одним этапом. Одно преобразование может создать возможности для другого, поэтому оптимизационные проходы выполняются повторно или в определённом порядке. Например, после встраивания функции исчезает граница вызова, что может сделать возможными распространение констант и удаление неиспользуемого кода.
Анализ псевдонимов
В языках с указателями и ссылками важно определить, могут ли два разных выражения обозначать одну и ту же область памяти. Это называется анализом псевдонимов.
Если компилятор не может исключить возможность того, что два указателя ссылаются на один объект, он должен предполагать, что запись через один указатель способна изменить результат чтения через другой. Это ограничивает перестановку операций, векторизацию и длительное хранение значений в регистрах.
Система типов, правила владения, квалификаторы вроде restrict и неизменяемые ссылки могут уменьшать возможное число псевдонимов и предоставлять оптимизатору больше информации. Напротив, произвольные операции с указателями и вызовы внешних функций могут затруднять вывод о воздействии на память.
Оптимизация на основе профиля
Оптимизация на основе профиля использует при компиляции сведения, собранные во время фактического выполнения программы. Обычно применяется сокращение PGO.
Обычный процесс выглядит следующим образом:
Сборка инструментированной программы
↓
Запуск с характерными входными данными
↓
Сбор частоты вызовов и вероятностей ветвлений
↓
Повторная компиляция с использованием профиля
Компилятор может различать часто выполняемые функции и ветви, а также редко используемые пути обработки ошибок, чтобы скорректировать встраивание, размещение кода, порядок ветвлений и использование регистров. Если профиль плохо отражает реальные сценарии применения, ожидаемый эффект может уменьшиться, а производительность отдельных задач — ухудшиться.
Оптимизация времени компоновки
Оптимизация времени компоновки — это способ совместного анализа промежуточных представлений нескольких модулей на этапе компоновки после их отдельной компиляции. Обычно применяется сокращение LTO.
При обычной раздельной компиляции компилятор одной единицы трансляции не знает тела функций, определённых в других файлах. При LTO компоновщик или его подключаемый модуль объединяет IR нескольких модулей либо обменивается их сводной информацией, позволяя выполнять межмодульное встраивание, удаление неиспользуемого глобального кода и анализ всей программы.
Обработка всего IR одновременно может потреблять значительные объёмы памяти и времени. Поэтому в крупных программах применяются распределённые или облегчённые варианты LTO, использующие сводные данные модулей и параллельные бэкенды.
Генерация целевого кода
После оптимизации промежуточное представление преобразуется в команды целевого процессора или виртуальной машины. Этот процесс называется генерацией кода.
Генератор нативного кода выполняет следующие задачи:
- выбирает целевые команды для операций промежуточного представления;
- определяет способы адресации, поддерживаемые целевым процессором;
- размещает виртуальные регистры в физических регистрах;
- выгружает значения в стек или память при нехватке регистров;
- изменяет порядок команд с учётом особенностей целевого процессора;
- размещает аргументы и возвращаемые значения в соответствии с соглашением о вызовах;
- формирует стековый кадр;
- определяет расположение ветвлений, констант и глобальных данных;
- создаёт дополнительные сведения для обработки исключений и отладки.
Выбор команд
Выбор команд — это процесс преобразования операций IR в реальные инструкции целевого процессора.
Для одной и той же операции разные процессоры могут предоставлять разные команды. Один процессор способен одной инструкцией считать значение из памяти и выполнить над ним операцию, тогда как другой требует сначала загрузить значение в регистр, а затем применить отдельную вычислительную команду.
Генератор кода учитывает стоимость целевых инструкций и доступные возможности при выборе подходящей последовательности команд. Если процессор поддерживает SIMD, специальные битовые операции или криптографические инструкции, они могут использоваться вместо последовательности обычных операций.
Распределение регистров
Промежуточное представление может использовать больше виртуальных регистров, чем физически имеется в процессоре. Распределение регистров размещает виртуальные значения в ограниченном числе физических регистров.
Если одновременно живых значений больше, чем доступных регистров, некоторые из них временно сохраняются в стековой памяти. Это называется вытеснением регистров. Поскольку доступ к регистрам обычно быстрее доступа к памяти, важно сохранять часто используемые значения в регистрах и сокращать ненужные операции сохранения и загрузки.
Соглашение о вызовах определяет, какие регистры должен сохранять вызывающий код, а какие — вызываемая функция. Генератор кода распределяет регистры в соответствии с этими правилами.
Планирование команд
Современные процессоры способны выполнять несколько инструкций с перекрытием, однако производительность зависит от зависимостей данных между командами и ограничений исполнительных устройств. Планирование команд изменяет порядок инструкций без изменения их смысла, чтобы уменьшить остановки конвейера и конфликты ресурсов.
Даже если целевой процессор динамически переупорядочивает инструкции во время выполнения, удачное расположение кода компилятором может расширить набор команд, доступных для параллельной обработки.
Ассемблирование и объектные файлы
Если генератор кода выводит текстовый ассемблерный код, ассемблер преобразует его в машинные команды. Компилятор также может использовать встроенный ассемблер и создавать объектный файл напрямую, без отдельного промежуточного ассемблерного файла.
Объектный файл — это не завершённая исполняемая программа, а промежуточный двоичный файл, содержащий машинный код, данные, символы, информацию о перемещении и различные метаданные.
Обычный объектный файл содержит:
- исполняемый машинный код;
- константы только для чтения;
- инициализированные глобальные данные;
- сведения о размере неинициализированных глобальных данных;
- символы, определяемые или используемые извне;
- информацию о перемещении для мест, окончательные адреса которых ещё не определены;
- сведения для обработки исключений и раскрутки стека;
- отладочную информацию;
- сведения о платформе и архитектуре.
В зависимости от платформы применяются форматы объектных и исполняемых файлов ELF, PE/COFF, Mach-O и другие. Каждый формат разделяет код и данные на секции или сегменты и предоставляет сведения, необходимые компоновщику и загрузчику.
Символы и перемещения
Символ представляет имя и свойства объекта, который должен быть идентифицирован на этапе компоновки, например функции, глобальной переменной или статических данных. Объектный файл записывает как определённые в нём символы, так и символы, которые необходимо получить извне.
Например, один файл может вызывать внешнюю функцию:
extern int calculate(int value);
int run(void) {
return calculate(10);
}
Во время отдельной компиляции этого файла окончательный адрес calculate может быть неизвестен. Компилятор и ассемблер оставляют в месте вызова временное значение и запись о перемещении. Компоновщик находит другой объектный файл или библиотеку, определяющую calculate, и подставляет фактический адрес либо способ его вычисления.
Перемещение — это процесс исправления зависящих от адресов мест в коде и данных с учётом их окончательного размещения. Помимо абсолютных адресов существуют относительные адреса от текущей команды, глобальная таблица смещений, таблица связывания процедур и другие способы перемещения.
Компоновщик
Компоновщик объединяет несколько объектных файлов и библиотек в исполняемый файл или разделяемую библиотеку.
Обычно компоновщик выполняет следующие задачи:
- собирает и размещает секции входных объектных файлов;
- связывает определения и ссылки символов с одинаковыми именами;
- ищет неопределённые символы в библиотеках;
- диагностирует дублирующиеся или конфликтующие определения;
- определяет окончательные адреса кода и данных;
- применяет перемещения;
- при необходимости удаляет неиспользуемые секции;
- формирует порядок функций инициализации и завершения;
- создаёт сведения для динамического компоновщика;
- записывает точку входа и заголовки программы.
Статическая компоновка
Статическая компоновка включает необходимый библиотечный код непосредственно в исполняемый файл. Поскольку при запуске не требуется отдельно загружать соответствующую библиотеку, развёртывание может быть проще. Однако размер исполняемого файла увеличивается, а несколько программ могут содержать собственные копии одного и того же библиотечного кода.
Статическая библиотека обычно представляет собой архив нескольких объектных файлов. Компоновщик выбирает и включает в результат те объектные файлы, которые предоставляют символы, фактически используемые программой.
Динамическая компоновка
Динамическая компоновка не копирует весь код и данные разделяемой библиотеки в исполняемый файл, а связывает программу с внешней библиотекой во время загрузки или при первом использовании символа.
В системах на основе ELF исполняемый файл, участвующий в динамической компоновке, может указывать динамический компоновщик через сведения об интерпретаторе программы. Операционная система загружает в адресное пространство процесса как исполняемый файл, так и динамический компоновщик. Затем динамический компоновщик находит необходимые разделяемые объекты, разрешает символы и выполняет перемещения.
Динамическая компоновка позволяет нескольким процессам совместно использовать доступный только для чтения библиотечный код и заменять совместимую библиотеку без повторной сборки исполняемого файла. При этом в среде выполнения должна присутствовать правильная версия библиотеки, а версии символов, пути поиска и совместимость ABI необходимо отдельно контролировать.
ABI и соглашения о вызовах
Двоичный интерфейс приложений определяет правила взаимодействия отдельно скомпилированного кода на двоичном уровне. Обычно используется сокращение ABI.
ABI обычно включает следующие правила:
- размещение аргументов функций в регистрах и стеке;
- передачу возвращаемых значений;
- регистры, которые должны сохраняться до и после вызова;
- выравнивание стека и структуру стекового кадра;
- размеры и выравнивание базовых типов;
- размещение структур и классов в памяти;
- преобразование имён символов в двоичные имена;
- обработку исключений и раскрутку стека;
- структуру таблиц виртуальных функций;
- форматы объектных и исполняемых файлов;
- системные вызовы и интерфейсы операционной системы.
Даже если объявления функции в исходном коде одинаковы, код, использующий разные ABI, может по-разному интерпретировать аргументы и возвращаемые значения, из-за чего корректный вызов становится невозможным.
C++ использует декорирование имён, чтобы представлять в символах сведения о перегрузке функций, классах, пространствах имён, шаблонах и других элементах. Itanium C++ ABI определяет интерфейсы вызова функций, обработку исключений, глобальные имена и соглашения объектного кода, позволяя независимо скомпилированному коду C++ работать совместно.
ABI не совпадает со спецификацией языка. Спецификация языка определяет смысл программы, тогда как ABI задаёт, каким образом этот смысл выражается в двоичной структуре конкретной платформы и реализации. Для одного языка ABI может различаться в зависимости от операционной системы, архитектуры процессора и семейства компиляторов.
Исполняемые файлы и загрузка
Загрузчик — это компонент операционной системы, который читает исполняемый файл, формирует адресное пространство нового процесса и начинает его выполнение.
Обычный процесс загрузки включает следующие этапы:
- Проверка заголовка исполняемого файла.
- Проверка целевой архитектуры и формата выполнения.
- Отображение кода, данных только для чтения и доступных для записи данных в память.
- Подготовка области неинициализированных данных.
- Создание стека и начального состояния процесса.
- Загрузка динамического компоновщика, если требуется динамическая компоновка.
- Отображение разделяемых библиотек и применение перемещений.
- Инициализация среды выполнения языка и глобальных объектов.
- Передача управления точке входа программы.
Точка входа, которую первой вызывает операционная система, не обязательно является написанной пользователем функцией main. В программах на C и C++ стартовый код получает от операционной системы аргументы и сведения об окружении, инициализирует среду выполнения и только затем вызывает main. При завершении программы он может передать возвращаемое значение операционной системе, выполнить зарегистрированные обработчики завершения и уничтожить статические объекты.
Java Virtual Machine различает загрузку двоичного представления класса, связывание класса с состоянием выполнения и подготовку его статического состояния при инициализации. Во время работы JVM новые классы могут загружаться через пользовательские загрузчики классов.
Система времени выполнения
Система времени выполнения — это программный слой, поддерживающий возможности языка программирования во время исполнения скомпилированной программы. Размер и роль среды выполнения значительно различаются между языками.
Обычная среда выполнения C может быть сравнительно небольшой и включать стартовый код, стандартную библиотеку, средства раскрутки стека и вспомогательные арифметические функции. Java и .NET используют крупные управляемые среды выполнения, содержащие виртуальную машину, JIT-компилятор, сборщик мусора, систему типов, загрузчик классов, обработку исключений и управление потоками.
Среда выполнения может предоставлять следующие возможности:
- инициализацию программы и потоков;
- управление стеком вызовов и кадрами выполнения;
- выделение памяти в куче;
- сборку мусора;
- управление метаданными объектов и типов;
- динамическую диспетчеризацию методов;
- возбуждение исключений и раскрутку стека;
- рефлексию;
- динамическую загрузку модулей и классов;
- планирование потоков и задач;
- корутины и асинхронный ввод-вывод;
- проверку границ строк и массивов;
- операции над динамическими типами;
- взаимодействие с нативным кодом;
- сбор профилировочных данных и сведений для JIT-оптимизации.
Управляемая среда выполнения .NET предоставляет такие службы, как JIT-компиляция, сборка мусора, взаимодействие с неуправляемым кодом, межъязыковая отладка, развёртывание и управление версиями.
Стек выполнения и кадры вызовов
При вызове функции обычно создаётся кадр вызова, в котором хранится состояние, необходимое для этого вызова. Кадр вызова также называют стековым кадром.
Кадр вызова может содержать:
- адрес возврата;
- аргументы функции;
- локальные переменные;
- временные результаты вычислений;
- сведения, указывающие на предыдущий кадр;
- значения регистров, которые необходимо сохранить;
- данные для обработки исключений и раскрутки стека.
Когда функция вызывает другую функцию, добавляется новый кадр, а после возврата он удаляется. Рекурсивные вызовы последовательно создают отдельные кадры для одной и той же функции.
JVM выделяет каждому потоку отдельный стек JVM и при вызове метода создаёт кадр, содержащий массив локальных переменных, стек операндов и другие данные. Куча JVM является общей для всех потоков JVM и используется для размещения экземпляров классов и массивов.
Не все значения обязательно физически хранятся в стековой памяти. В оптимизированном нативном коде локальная переменная может существовать только в регистре или быть полностью устранена. В реализации виртуальной машины кадры вызовов также могут храниться в непрерывном нативном стеке, куче или отдельной внутренней структуре.
Куча и выделение памяти
Куча — это область памяти для объектов и данных, размер и время жизни которых определяются динамически во время выполнения. Объекты, которые должны существовать независимо от структуры вызовов стека, обычно размещаются в куче.
Распределитель памяти в куче находит блок требуемого размера и возвращает его программе, а освобождённые области повторно использует для последующих запросов. Эффективные распределители могут применять списки свободных блоков по размерам, региональное выделение, локальные кэши потоков и управление страницами.
При использовании кучи могут возникать следующие проблемы:
- утечки из-за неосвобождённой памяти;
- обращение к уже освобождённой памяти;
- повторное освобождение одного блока;
- фрагментация, уменьшающая объём доступных непрерывных областей;
- затраты на синхронизацию при обращении нескольких потоков к распределителю;
- снижение эффективности кэша из-за удалённого размещения связанных объектов.
Языки и среды выполнения управляют временем жизни объектов в куче с помощью ручного освобождения, подсчёта ссылок, трассирующей сборки мусора, регионального управления, анализа владения и других подходов.
Сборка мусора
Сборка мусора — это способ, при котором среда выполнения автоматически освобождает память объектов, ставших недоступными для программы.
Трассирующий сборщик мусора обычно определяет набор корней, начинающийся с глобальных переменных, стеков потоков, регистров процессора и других источников, после чего следует по ссылкам и отмечает достижимые объекты. Недостижимые объекты больше не могут быть использованы программой и поэтому подлежат освобождению. Сборщик мусора .NET также исследует граф достижимых объектов на основе сведений о корнях, управляемых JIT-компилятором и средой выполнения.
Существуют различные способы реализации сборки мусора:
- алгоритм пометки и очистки отмечает достижимые объекты, а затем освобождает неотмеченные;
- копирующий сборщик перемещает живые объекты в другую область и целиком освобождает прежнюю;
- поколенческий сборщик управляет недавно созданными и долгоживущими объектами в разных областях;
- инкрементальная сборка делит работу на небольшие этапы;
- конкурентная сборка выполняет часть работы одновременно с потоками приложения;
- уплотняющая сборка перемещает живые объекты, уменьшая фрагментацию.
Сборка мусора автоматизирует освобождение памяти, но не означает автоматического и своевременного освобождения внешних ресурсов, таких как файлы, сокеты, блокировки и объекты GPU. Для них могут применяться отдельные конструкции явной очистки, управление на основе области видимости и методы завершения.
Обработка исключений
Обработка исключений — это управляющий механизм для передачи вызывающему коду ошибок и особых ситуаций, которые трудно обработать через обычный поток возврата.
При возникновении исключения среда выполнения ищет подходящий обработчик в текущей функции. Если обработчика нет, она переходит к кадру вызывающей функции и продолжает подниматься по стеку, пока обработчик не будет найден. В ходе этого процесса могут выполняться деструкторы уже созданных локальных объектов и конструкции очистки. Этот процесс называется раскруткой стека.
В зависимости от реализации обработка исключений может быть организована следующим образом:
- после каждого вызова функции или операции постоянно проверяется наличие исключения;
- нормальное выполнение не содержит дополнительных ветвлений, а при исключении место обработки ищется по таблицам;
- среда выполнения управляет объектами исключений и сведениями об их типах;
- механизм объединяется со структурированной обработкой исключений или сигналами операционной системы.
ABI C++ может определять двоичные правила интерфейсов обработки исключений, раскрутки стека, очистки объектов и поиска обработчиков. Передача исключений между разными компиляторами или средами выполнения языков требует совместимых ABI и моделей исключений.
Исключение на уровне языка не всегда совпадает с аппаратным исключением или ошибкой операционной системы. Некоторые реализации преобразуют деление на ноль и неверный доступ к памяти в языковые исключения, тогда как другие немедленно завершают программу.
Байт-код и виртуальные машины
Байт-код — это формат инструкций, предназначенный не для конкретного физического процессора, а для виртуальной машины. Преобразование исходного кода в байт-код создаёт общий исполнительный слой между фронтендом языка и реальным аппаратным обеспечением.
Виртуальная машина определяет команды байт-кода, модель памяти, правила вызовов, проверку типов и формат модулей. Байт-код может непосредственно исполняться интерпретатором или преобразовываться JIT-компилятором в нативный код.
JVM
Исходный код Java обычно компилируется в формат class, содержащий команды JVM. Сама JVM не обязана понимать язык Java: любой другой язык, способный создавать корректный формат class, также может выполняться на JVM. Файл класса JVM содержит байт-код, пул констант, выполняющий роль таблицы символов, поля, методы и различные атрибуты.
Перед выполнением файла класса JVM может проверить корректность его структуры и байт-кода. Проверка удостоверяется, что отношения типов инструкций, использование стека, цели переходов и другие элементы соответствуют правилам JVM, ограничивая возможность повреждения внутренних правил среды выполнения некорректным двоичным кодом.
CPython
CPython преобразует исходный код Python во внутренние объекты кода и байт-код. Объект кода является неизменяемым объектом, содержащим скомпилированный исполняемый код и связанные метаданные, а объект функции объединяет объект кода с контекстом выполнения, включая глобальное пространство имён и аргументы по умолчанию.
При импорте модуля CPython может сохранять результат компиляции в кэш-файле .pyc. Если при последующем запуске кэш соответствует текущему исходному коду, повторное преобразование исходного текста в байт-код можно пропустить. Система импорта Python может проверять действительность кэша по временной метке и размеру файла либо по хэшу исходного кода.
.NET
Компиляторы языков .NET обычно создают сборки, содержащие общий промежуточный язык и метаданные. CLR загружает сборку, интерпретирует сведения о типах и методах и может JIT-компилировать выполняемые методы либо использовать предварительно скомпилированный код.
Общая система типов и метаданные позволяют различным языкам, таким как C# и F#, использовать одну среду выполнения и одни библиотеки. Однако не все уникальные возможности каждого языка доступны другим языкам в той же форме. Для взаимодействия необходимо использовать открытые интерфейсы, которые можно выразить через общие правила платформы.
WebAssembly
WebAssembly — это переносимый низкоуровневый формат виртуальных инструкций. Основная спецификация определяет набор команд, типы, модули, двоичное кодирование, проверку и семантику выполнения. Способы взаимодействия с конкретной операционной системой или API браузера отделены от основной семантики выполнения и предоставляются средой-хостом либо отдельным интерфейсным слоем.
Перед выполнением модуль WebAssembly проверяется на соответствие структурным правилам и правилам типов. При создании экземпляра модуля формируются экземпляры функций, памяти, таблиц, глобальных переменных и других объектов среды выполнения, инициализируются сегменты данных и при необходимости вызывается указанная стартовая функция.
Интерпретатор
Интерпретатор читает внутреннее представление программы и непосредственно выполняет действия, соответствующие его смыслу.
Простейший интерпретатор с обходом дерева рекурсивно посещает узлы AST.
Выполнение узла сложения:
вычислить левый дочерний узел;
вычислить правый дочерний узел;
сложить два результата.
Интерпретатор с обходом дерева сравнительно прост в реализации, а соответствие между исходной структурой и выполнением остаётся очевидным. Однако при каждой операции требуется определять вид узла и переходить по указателям, что может создавать значительные накладные расходы.
Интерпретатор байт-кода сначала преобразует AST в более компактный массив инструкций, а затем выполняет их, перемещая виртуальный счётчик команд.
LOAD_CONST 10
LOAD_LOCAL value
ADD
RETURN
Интерпретатор байт-кода использует диспетчеризацию команд и стек операндов либо виртуальные регистры. Виртуальная машина может быть стековой или регистровой, причём эти архитектуры различаются размером команд, стоимостью декодирования и возможностями оптимизации.
Интерпретатор обеспечивает короткое время компиляции и позволяет гибко изменять структуру программы во время выполнения. Однако стоимость интерпретации повторяется при каждой операции, поэтому длительные вычисления могут выполняться медленнее нативного кода. Современная среда выполнения может быстро начинать работу в интерпретаторе, а затем JIT-компилировать только часто выполняемые участки.
JIT-компиляция
JIT-компиляция — это способ преобразования байт-кода или промежуточного представления в машинный код текущей системы во время выполнения программы.
JIT-компилятор может собирать во время работы следующие сведения:
- функции, которые фактически вызываются чаще всего;
- число выполнений циклов;
- реальные соотношения переходов по условным ветвям;
- типы объектов, наблюдаемые в определённом месте вызова;
- фактические цели вызовов виртуальных методов;
- размеры массивов и диапазоны значений;
- пути, на которых часто возникают исключения;
- виды значений, регулярно передаваемых в аргументы функции.
На основе этих сведений код можно специализировать с использованием предположений, которые предварительный компилятор не смог бы безопасно сделать. Например, если в определённом месте вызова наблюдался только один тип объекта, поиск виртуального метода можно исключить и заменить прямым вызовом соответствующей функции.
Однако во время выполнения может появиться новый тип, нарушающий прежнее предположение. В таком случае среда выполнения должна отказаться от оптимизированного кода и вернуться к более общему коду или состоянию интерпретатора. Этот процесс называется деоптимизацией.
JIT-компиляция также связана со следующими затратами:
- во время выполнения расходуется процессорное время на компиляцию;
- для созданного машинного кода требуется дополнительная память;
- сразу после запуска программы ещё отсутствует достаточная профилировочная информация;
- производительность может различаться до и после завершения оптимизации;
- динамическое создание исполняемой памяти может зависеть от политик безопасности и требований к подписи кода.
API ORC JIT в LLVM предназначен для построения JIT-инфраструктуры, способной преобразовывать LLVM IR в нативный код во время выполнения и управлять символами.
Многоуровневая компиляция
Многоуровневая компиляция объединяет несколько уровней выполнения, различающихся скоростью исполнения и стоимостью компиляции.
Быстрый интерпретатор
↓ часто выполняемый код
Недорогая JIT-компиляция
↓ очень часто выполняемый код
Дорогостоящая оптимизирующая JIT-компиляция
Сначала программа немедленно запускается в интерпретаторе или с помощью быстрого компилятора. Среда выполнения наблюдает за частотой исполнения и повторно компилирует часто выполняемый код с более высоким уровнем оптимизации. Для редко выполняемого кода дорогостоящая оптимизация не применяется, что уменьшает время запуска и потребление памяти.
AOT-компиляция
AOT-компиляция — это способ преобразования программы в целевой код до её запуска. К AOT-компиляции относится не только обычная нативная компиляция, но и преобразование промежуточного формата виртуальной машины в нативный образ до распространения программы.
Основные характеристики AOT-компиляции:
- затраты на компиляцию во время выполнения отсутствуют или минимальны;
- время запуска и начальную производительность легче прогнозировать;
- среде выполнения необязательно разрешать динамическое создание кода;
- код необходимо заранее генерировать для целевой архитектуры;
- сведения о типах и вызовах, доступные только во время выполнения, можно использовать лишь в ограниченной степени;
- для сохранения рефлексии, динамической загрузки и генерации кода во время выполнения могут потребоваться дополнительные метаданные.
Современные платформы не всегда жёстко выбирают только AOT или JIT. Они могут сочетать предварительно скомпилированный основной код с профилируемой оптимизацией во время выполнения.
Взаимодействие с нативным кодом
Интерфейс внешних функций — это система вызова функций и обмена данными между различными языками программирования и средами выполнения. Обычно используется сокращение FFI.
FFI должен решать следующие задачи:
- сопоставление типов двух языков;
- согласование соглашений о вызовах;
- кодирование и представление строк в памяти;
- размещение структур и массивов;
- управление временем жизни объектов и ссылок;
- определение владельца памяти и ответственности за её освобождение;
- передача функций обратного вызова;
- преобразование исключений и ошибок;
- соединение потоков с состоянием среды выполнения;
- фиксация и перемещение объектов, управляемых сборщиком мусора.
C ABI сравнительно прост и поддерживается множеством языков, поэтому широко применяется как общая граница взаимодействия. Классы, шаблоны, исключения и объекты стандартной библиотеки C++ сильнее зависят от ABI и версии компилятора. Поэтому внешние интерфейсы часто используют соглашение о вызовах C, непрозрачные дескрипторы и явные функции создания и освобождения объектов.
При вызове нативной функции из управляемой среды может потребоваться маршалинг — преобразование управляемого объекта в представление нативной памяти. Если нативный код, наоборот, сохраняет ссылку на управляемый объект, необходим специальный дескриптор, чтобы сборщик мусора учитывал эту ссылку и сохранял объект живым.
Кросс-компиляция
Кросс-компиляция — это способ компиляции, при котором система, где работает компилятор, отличается от системы, на которой будет выполняться созданная программа.
Например, на компьютере с x86-64 Linux можно создавать программы для AArch64 Linux или встраиваемой операционной системы.
Кросс-компиляционная среда обычно требует следующих компонентов:
- бэкенд генерации кода для целевой архитектуры;
- целевой ABI и соглашения о вызовах;
- заголовочные файлы целевой операционной системы;
- целевую стандартную и системные библиотеки;
- ассемблер и компоновщик для цели либо инструменты со встроенной поддержкой этих этапов;
- sysroot, представляющий файловую систему целевой платформы;
- реальное устройство или эмулятор для запуска и тестирования.
Одной возможности генерировать машинный код целевого процессора недостаточно. Используемые программой библиотеки, стартовый код и настройки компоновщика также должны соответствовать целевой среде. Clang позволяет создавать код для других архитектур, задавая target, модель процессора, ABI, sysroot, а также пути к заголовкам и библиотекам.
Гетерогенная компиляция
Современная программа может предназначаться не только для одного CPU. При совместном использовании GPU, DSP, нейронных ускорителей и других исполнительных устройств код хоста и код устройства необходимо отдельно компилировать из одного исходного файла или модуля.
Один исходный код
├── Код хоста CPU → x86-64 или AArch64
└── Код устройства GPU → GPU ISA или SPIR-V
Цепочка инструментов может компилировать одну единицу трансляции для нескольких целей и встраивать полученный код устройства в исполняемый файл хоста либо упаковывать его в отдельный двоичный объект. Инструменты разгрузки Clang также создают отдельные кодовые объекты для хоста и одного или нескольких устройств, а затем объединяют их в один пакет.
Отладочная информация
Оптимизированный машинный код не сохраняет в исходном виде переменные, операторы и структуру типов исходной программы. Отладочная информация — это метаданные, позволяющие отладчику связывать состояние выполнения машинного кода с понятиями уровня исходного текста.
Отладочная информация может включать:
- соответствие машинных адресов исходным файлам, строкам и столбцам;
- сведения о функциях и встроенных вызовах;
- типы переменных и параметров;
- регистры или области памяти, где хранится значение в определённой точке выполнения;
- расположение полей структур и классов;
- пространства имён и модули;
- сведения для восстановления кадров вызовов.
В средах ELF семейства Unix широко применяется формат DWARF, а в средах PE/COFF Windows — информация семейств CodeView и PDB. DWARF стандартизирован как формат сведений для отладки на уровне исходного текста программ, написанных на различных компилируемых языках.
В оптимизированной программе значение, соответствующее исходной переменной, может быть удалено или перемещаться между несколькими местами, функция может быть встроена, а порядок выполнения операторов — изменён. Поэтому показываемые отладчиком позиции выполнения и значения переменных не всегда полностью соответствуют интуитивному порядку исходного кода.
Инкрементальная компиляция и кэширование
Если при каждом изменении крупная программа полностью компилируется с начала, время разработки значительно увеличивается. Инкрементальная компиляция повторно обрабатывает только изменённые части и зависимые от них элементы.
Инкрементальный компилятор может сохранять следующие сведения:
- хэш исходного кода каждого модуля;
- открытые интерфейсы и сведения о типах;
- зависимости между модулями;
- результаты предыдущего синтаксического и семантического анализа;
- созданное промежуточное представление;
- результаты оптимизации и объектные файлы;
- параметры компиляции и целевую среду.
Если изменилась только реализация внутри функции, а открытый интерфейс сохранился, может потребоваться повторная генерация лишь содержащего её модуля. Если изменился открытый тип, шаблон или встроенная функция, может понадобиться повторная компиляция других модулей, которые используют этот элемент.
Кэш сборки использует содержимое исходного кода, версию компилятора, параметры, заголовки и зависимые файлы как ключ для повторного использования прежнего результата. Неправильно составленный ключ кэша может привести к использованию устаревшего результата или к излишне частой инвалидизации кэша.
Начальная раскрутка и самокомпиляция
Компилятор языка программирования, написанный на том же языке, называется самокомпилирующимся. У нового языка ещё нет собственного компилятора, поэтому первоначально его можно реализовать на другом языке или начать с небольшого компилятора, поддерживающего только подмножество нового языка.
Обычный процесс начальной раскрутки выглядит следующим образом:
Написать первоначальный компилятор на существующем языке
↓
Скомпилировать новый язык первоначальным компилятором
↓
Переписать компилятор на новом языке
↓
Собрать новый компилятор предыдущим компилятором
↓
Новый компилятор компилирует собственный исходный код
Самокомпиляция показывает, что язык подходит для создания реального крупного программного обеспечения, и позволяет разработчикам компилятора непосредственно использовать собственный язык и его инструменты. При этом возникает проблема доверия к начальной раскрутке: необходимо проверять, можно ли доверять двоичному файлу и исходному коду компилятора и каким образом предыдущие этапы сборки повлияли на результат.
Соответствие спецификации языка и реализации
Реализация языка должна правильно воспроизводить смысл программ, определённый спецификацией. Даже если компилятор способен создавать более быстрый код, он не должен произвольно изменять наблюдаемое поведение программы.
Корректность реализации можно проверять следующими способами:
- тестами на соответствие отдельным возможностям стандарта языка;
- модульными и интеграционными тестами самого компилятора;
- дифференциальным тестированием одной программы в нескольких реализациях;
- фаззингом с генерацией случайных программ;
- проверкой промежуточных представлений и оптимизационных проходов;
- сравнением поведения программы до и после компиляции;
- применением статических анализаторов и средств проверки во время выполнения;
- использованием формально верифицированных компиляторов или доказанных преобразований.
В языках, допускающих неопределённое или зависящее от реализации поведение, разные реализации не обязаны всегда выдавать одинаковый результат. В таком случае спецификация разграничивает обязательные требования и области, в которых реализация может выбирать собственное поведение.
Двоичный код для виртуальной машины может проходить отдельную проверку до выполнения. WebAssembly проверяет соответствие инструкций и модулей статическим типам и структурным правилам, а JVM применяет структурные ограничения к файлам классов и байт-коду.
Полный процесс выполнения
Хотя процесс выполнения зависит от языка и реализации, роль каждого этапа можно представить следующим образом.
| Этап | Основная роль | Характерный результат |
|---|---|---|
| Предварительная обработка | обработка включаемых файлов, макросов и условного кода | предварительно обработанные токены |
| Лексический анализ | разделение символов на значимые единицы | токены |
| Синтаксический анализ | формирование грамматической структуры из токенов | синтаксическое дерево или AST |
| Семантический анализ | проверка имён, типов, областей видимости и правил языка | AST с семантической информацией |
| Понижение | преобразование высокоуровневых возможностей в базовые операции | низкоуровневое AST или IR |
| Создание промежуточного представления | формирование представления для оптимизации и преобразования под цель | IR |
| Оптимизация | улучшение кода при сохранении смысла | оптимизированное IR |
| Генерация кода | выбор целевых команд, регистров и соглашений о вызовах | ассемблерный или машинный код |
| Ассемблирование | формирование машинного кода и метаданных | объектный файл |
| Компоновка | разрешение символов, применение перемещений и объединение двоичных файлов | исполняемый файл или библиотека |
| Загрузка | размещение программы и библиотек в памяти | состояние выполняющегося процесса |
| Среда выполнения | поддержка памяти, типов, исключений, потоков и других возможностей языка | выполняющаяся программа |
В реализации с байт-кодом и виртуальной машиной некоторые этапы изменяются следующим образом:
Исходный код
↓
Анализ фронтенда
↓
Байт-код или общий промежуточный язык
↓
Проверка и загрузка модулей
↓
Интерпретатор или JIT-компилятор
↓
Выполнение нативных инструкций
Реализация языка программирования не является простой задачей преобразования синтаксиса в машинный код. Компилятор и интерпретатор должны реализовать смысл языка, его систему типов, модель памяти, правила исключений и конкурентности поверх реального аппаратного обеспечения и операционной системы. Компоновщик, загрузчик, ABI, среда выполнения и стандартная библиотека также являются частями языковой экосистемы, влияющими на результат исполнения.
Современные реализации при необходимости объединяют AOT-компиляцию, интерпретацию, байт-код и JIT-компиляцию и используют несколько уровней промежуточных представлений и профилировочные сведения для оптимизации программ. Поэтому фактическая производительность языка, использование памяти, время запуска, переносимость и характеристики отладки определяются не только синтаксисом, а всей системой выполнения, объединяющей компилятор, среду выполнения, операционную систему, ABI, библиотеки и целевое аппаратное обеспечение.
Система типов
Система типов — это набор правил, классифицирующих используемые в программе значения и выражения и определяющих допустимые операции и отношения совместимости между ними. Тип обозначает не только формат хранения значения в памяти, но также операции, которые можно выполнить с этим значением, условия его передачи другим частям программы, входы и выходы функций и границы программных абстракций.
Например, целые числа и строки являются значениями, хранящимися в памяти, однако имеют разные допустимые операции и смысл. К целым числам применяются арифметические операции, а к строкам — конкатенация, вычисление длины и другие действия. Тип функции может определять, какие значения она принимает и что возвращает, а тип объекта — какие поля и методы доступны или каким интерфейсам соответствует объект.
Система типов не является средством устранения всех ошибок программы. Отсутствие файла, сбой сетевого соединения, неверный пользовательский ввод и логические ошибки алгоритма могут возникать во время выполнения и плохо поддаваться проверке только на уровне типов. Роль системы типов состоит в обнаружении несовместимости значений и операций в пределах правил языка, выражении структуры и намерений программы, а также предоставлении компилятору и инструментам разработки сведений для анализа. Карделли и Вегнер рассматривали типы как центральное понятие, систематизирующее классификацию программных выражений и значений, абстракцию и полиморфизм.
Основные классификации систем типов можно представить следующим образом.
| Критерий | Основные категории | Что различается |
|---|---|---|
| Время проверки | статическая и динамическая типизация | когда проверяются правила типов — до выполнения или во время него |
| Запись типов | явное указание и вывод типов | как программист и компилятор определяют сведения о типах |
| Отношения типов | номинальная и структурная типизация | определяется ли совместимость по имени или структуре |
| Полиморфизм | параметрический, подтипный, специальный | каким способом один код работает с несколькими типами |
| Объединение значений | типы-произведения, типы-суммы, типы функций | как представлены составные значения |
| Использование ресурсов | линейные, аффинные типы и типы владения | сколько раз можно использовать значение и кто им управляет |
| Точность | обычные, уточнённые и зависимые типы | насколько конкретные условия о значениях включаются в тип |
| Эффекты | обычные типы и системы эффектов | отслеживаются ли действия функции над внешним состоянием |
| Способ внедрения | полностью статическая и постепенная типизация | в какой степени статическая информация о типах применяется к программе |
Эти категории не являются взаимоисключающими. Один язык может одновременно предоставлять статическую типизацию, вывод типов, номинальные отношения типов, обобщения, анализ возможности null и проверку владения. Динамически типизированный язык, в свою очередь, может добавить необязательные аннотации и статический анализатор, применяя проверку типов только к части программы.
Типы и суждения о типах
Проверка типов назначает выражениям программы определённые типы и проверяет их совместимость с контекстом использования. В формальной системе типов может применяться суждение следующего вида:
Γ ⊢ expression : Type
Оно означает, что в окружении Γ выражение expression имеет тип Type. Окружение содержит сведения о переменных, функциях, параметрах типов и самих типах, доступных в текущей области видимости.
Можно рассмотреть следующий код:
x: Integer
y: Integer
x + y
Проверяющий типы определяет, что x и y являются целыми числами, и применяет правило, согласно которому результат сложения целых чисел также имеет целочисленный тип. Если попытаться сложить значения, для которых операция сложения не определена, например строку и файловый объект, возникает ошибка типов.
Вызов функции проверяется аналогичным образом:
parse: String → Integer
Если parse принимает строку и возвращает целое число, строковое выражение можно передать как аргумент. Передача произвольного объекта, который нельзя преобразовать в строку, может быть запрещена.
Суждение о типах не сводится к сравнению одинаковых имён типов. Оно может учитывать отношения подтипов, неявные преобразования, ограничения обобщений, разрешение перегрузок, времена жизни, возможность null и результаты вывода параметров типов.
Безопасность и корректность системы типов
Безопасность типов — это свойство, ограничивающее использование значений способами, запрещёнными правилами типов. Например, система типов может предотвращать вызов целого числа как указателя на функцию, интерпретацию строки как произвольной структуры объекта или вызов несуществующего метода.
В формальной теории языков корректность системы типов часто объясняется с помощью двух свойств — сохранения и прогресса.
- Сохранение означает, что корректно типизированное выражение после одного шага выполнения продолжает удовлетворять правилам типов.
- Прогресс означает, что корректно типизированное выражение либо уже является результатом, либо может выполнить следующий шаг, определённый языком.
Совместное доказательство этих свойств даёт основание утверждать, что программа, прошедшая проверку типов, не попадёт во время выполнения в состояние, не предусмотренное системой типов. Райт и Феллейзен систематизировали синтаксический подход к доказательству корректности систем типов на основе этих свойств.
Корректность типов не совпадает с безопасностью памяти. Если язык допускает небезопасные операции с указателями, произвольные преобразования типов или вызов внешнего нативного кода, ошибки памяти возможны даже в программе, прошедшей статическую проверку. Некоторые языки отделяют такие операции специальной границей, например областью unsafe, чтобы ясно различать гарантии безопасного и небезопасного кода. Rust также позволяет использовать unsafe внутри безопасных абстракций, однако автор такого кода должен самостоятельно обеспечивать соблюдение правил указателей, псевдонимов и времён жизни.
Гарантии системы типов различаются между языками. Одни языки проверяют границы массивов через типы, другие — во время выполнения. Переполнение целого числа может обрабатываться как ошибка или как циклический переход значения. Поэтому выражение «типобезопасный язык» не следует понимать как гарантию всех видов безопасности выполнения.
Статическая и динамическая типизация
Статическая типизация анализирует отношения типов переменных, выражений и вызовов функций до запуска программы. В Java тип каждой переменной и выражения определяется во время компиляции, а сам тип ограничивает множество значений и допустимых операций.
Статический анализ типов может заранее обнаруживать:
- обращение к несуществующему полю или методу;
- несоответствие типов аргументов и параметров функции;
- неверный возвращаемый тип;
- запрещённое преобразование типов;
- нарушение ограничений обобщённого типа;
- неполную обработку образцов или вариантов типа;
- нарушение правил возможности null;
- неверные времена жизни ссылок и использование владения;
- нарушения некоторых правил эффектов и исключений.
Статическая типизация не означает, что до выполнения известны все фактические значения программы. Можно знать, что переменная имеет целочисленный тип, но обычно нельзя заранее определить, равна ли она 1 или 100. Тип абстрактно представляет множество возможных значений и разрешённых операций.
В системе с динамической типизацией тип значения и допустимость операций преимущественно проверяются во время выполнения. Точнее говорить, что не имя переменной имеет один постоянный тип, а текущее значение, на которое оно указывает, содержит сведения о типе.
value = 10
value = "text"
В динамически типизированном языке одно имя может в разные моменты обозначать целое число и строку. Однако различие между целым числом и строкой не исчезает: исполнительная система проверяет текущий тип значения и определяет допустимость операции.
Динамическая типизация подходит для быстрого создания программ и гибкой обработки структур и данных, формируемых во время выполнения. Однако некоторые ошибки типов обнаруживаются только при фактическом прохождении соответствующего пути исполнения. Для компенсации применяются тесты, проверки времени выполнения, необязательные аннотации типов и внешние статические анализаторы.
В статически типизированном языке также могут существовать проверки типов во время выполнения. Приведение к производному типу, динамическая загрузка модулей, рефлексия, интерфейсные объекты и десериализация внешних данных требуют проверки фактического значения. Динамический язык, напротив, может заранее диагностировать очевидные ошибки или выводить типы для оптимизации. Поэтому различие между статической и динамической типизацией показывает не единственный момент существования всех проверок, а этап, на котором язык возлагает основную ответственность за типовую корректность.
Явные типы и вывод типов
Аннотация типа — это явное указание программистом типа переменной, функции, параметра или возвращаемого значения.
fn add(a: i32, b: i32) -> i32 {
a + b
}
Вывод типов — это процесс, при котором компилятор определяет типы неаннотированных частей программы на основе окружающих выражений и отношений использования.
let value = 10;
В этом коде компилятор может вывести тип value из целочисленного литерала и контекста использования. Применение вывода типов не делает язык динамически типизированным. Выведенный тип участвует в статической проверке, и последующее присваивание несовместимого значения может привести к ошибке.
Вывод типов может использовать следующие сведения:
- типы литералов по умолчанию;
- типы операндов, требуемые операторами;
- типы параметров и возвращаемых значений функций;
- отношения присваивания переменных;
- ограничения параметров обобщённых типов;
- отношения подтипов и преобразований;
- ожидаемый тип окружающего контекста;
- сужение типов на основе потока управления.
Системы семейства Hindley–Milner позволяют выводить наиболее общий главный тип даже для функциональных программ с небольшим количеством явных аннотаций. Работы Дамаса и Милнера сформулировали систему главных типов, в которой другие допустимые типы программы представляются экземплярами главного типа.
Вывод типов в современных языках не обязательно полностью совпадает с Hindley–Milner. Сочетание подтипов, перегрузки, объектов, обобщений, времён жизни и изменяемости требует более сложных систем ограничений. Вывод типов в Java также преобразует совместимость выражений и отношения подтипов в ограничения, а затем вычисляет возможные границы переменных типов.
Вывод типов может иметь различный масштаб:
- локальный вывод типов определяет только некоторые типы внутри функции или выражения;
- глобальный вывод типов анализирует более широкую область программы, включая границы функций;
- однонаправленный вывод вычисляет тип на основе самого выражения;
- контекстный вывод передаёт ожидаемый тип из окружения внутрь выражения;
- двунаправленная проверка типов для одних выражений синтезирует тип, а для других проверяет соответствие заданному типу.
Двунаправленная проверка разделяет синтез и проверку типов и используется для управления количеством необходимых аннотаций и сложностью анализа в выразительных системах типов.
Полностью автоматический вывод всех типов не всегда является наилучшим решением. Явное указание типов открытых функций делает контракт API и сообщения об ошибках яснее и снижает вероятность того, что изменение реализации непреднамеренно изменит выведенный тип.
Базовые и составные типы
Языки программирования предоставляют различные типы для представления видов значений. Способы классификации различаются в зависимости от языка, однако обычно встречаются следующие типы:
- целые и вещественные числа;
- логические значения;
- символы и строки;
- указатели и ссылки;
- массивы и срезы;
- кортежи и записи;
- структуры и классы;
- перечислимые типы;
- типы функций;
- интерфейсы и трейты;
- обобщённые типы;
- типы-суммы и типы-пересечения;
- рекурсивные типы;
- nullable-типы;
- параметры типов.
Java разделяет типы на примитивные и ссылочные. К примитивным относятся логический и числовые типы, а к ссылочным — классы, интерфейсы и массивы.
Эта классификация не применяется одинаково ко всем языкам. В одних языках целые числа также являются объектами, тогда как в других целые числа и объекты имеют разные представления в памяти и соглашения о вызовах. Различие между типами-значениями и ссылочными типами также нельзя сводить только к разнице между стеком и кучей. Основное значение имеют способы копирования, сравнения и совместного использования значений, а также то, как язык определяет их идентичность.
Типы-произведения
Тип-произведение — это составной тип, одновременно содержащий несколько значений. Характерными примерами являются кортежи, записи и структуры.
Point = {
x: Float,
y: Float
}
Значение Point должно содержать и x, и y. Полное множество значений Point образуется всеми возможными сочетаниями значений x и y, поэтому такой тип называется произведением.
В кортеже компоненты обычно различаются по позиции, а в записи — по имени.
(Integer, String)
{
id: Integer,
name: String
}
Кортежи Haskell также определяются как алгебраические типы данных с одним конструктором.
Типы-суммы
Тип-сумма — это тип, значение которого принимает одну из нескольких возможных форм. Он также называется вариантным типом, размеченным объединением или дискриминируемым объединением.
enum Result<T, E> {
Ok(T),
Err(E),
}
Значение Result<T, E> не содержит одновременно Ok и Err, а принимает одну из этих форм. Каждая форма может содержать собственные данные, а текущий вариант различается с помощью метки или конструктора.
Типы-суммы удобны для явного представления следующих состояний:
- успех или ошибка;
- наличие или отсутствие значения;
- различные виды команд;
- разные узлы синтаксического дерева;
- виды сетевых сообщений;
- текущее состояние конечного автомата.
enum в Rust определяет номинальный перечислимый тип и отдельные варианты, которые можно создавать и обрабатывать с помощью сопоставления с образцом.
Алгебраические типы данных
Алгебраический тип данных определяется путём сочетания типов-произведений и типов-сумм. Haskell предоставляет пользовательские алгебраические типы данных и сопоставление с образцом как основные возможности языка.
Следующий рекурсивный алгебраический тип данных представляет двоичное дерево:
data Tree a
= Empty
| Node (Tree a) a (Tree a)
Tree a является либо Empty, либо Node, содержащим левое дерево, значение и правое дерево. Поскольку определение типа ссылается само на себя, это также рекурсивный тип.
Сопоставление с образцом проверяет текущий конструктор и извлекает содержащиеся в нём значения.
size Empty = 0
size (Node left _ right) =
1 + size left + size right
Компилятор может проверять, обработаны ли все варианты закрытого типа-суммы. Благодаря этому при добавлении нового варианта можно обнаружить затронутый код или диагностировать необработанное состояние.
Типы функций
Тип функции выражает входные значения функции и возвращаемый результат.
String → Integer
Это тип функции, принимающей строку и возвращающей целое число.
Несколько параметров можно представить в форме кортежа:
(Integer, Integer) → Integer
В языках с каррированием функция может быть представлена как принимающая один аргумент и возвращающая другую функцию:
Integer → Integer → Integer
В языках с функциями первого класса типы функций используются для переменных, полей, параметров и возвращаемых значений.
При подтипизации функций направления входных и выходных типов различаются. Возвращать более конкретный тип может быть безопасно, однако тип параметра должен быть достаточно широким, чтобы принимать значения, которые вправе передать вызывающая сторона. Эти отношения приводят к понятиям ковариантности и контравариантности.
Типы-объединения и типы-пересечения
Тип-объединение означает, что значение может принадлежать одному из нескольких типов.
string | number
Значение этого типа является строкой или числом. Конкретный тип необходимо сузить на основе пути выполнения или проверки условия. До такого уточнения разрешены только операции, безопасные для всех входящих в объединение типов. TypeScript использует анализ потока управления и проверки типов для сужения объединений до более конкретных типов.
Тип-пересечение одновременно удовлетворяет требованиям нескольких типов.
Serializable & Loggable
Такое значение должно предоставлять все компоненты, необходимые и Serializable, и Loggable. В TypeScript пересечение объединяет члены нескольких типов в один тип.
Типы-объединения и типы-пересечения можно понимать как множества значений:
A | B— множество значений, принадлежащих A или B;A & B— множество значений, удовлетворяющих одновременно условиям A и B.
Специальные типы
Помимо обычных типов данных система типов может содержать специальные типы, представляющие структуру вычислений.
Верхний тип
Верхний тип — это наиболее широкий тип, включающий все значения системы типов. В зависимости от языка он может называться Object, Any, unknown и другими именами.
К значению верхнего типа нельзя сразу применять операции, существующие только для конкретного типа. Перед использованием его необходимо сузить посредством проверки типа, сопоставления с образцом или приведения вниз.
В некоторых языках any предоставляет более сильный механизм обхода, чем обычный верхний тип. Он может отключать проверку типов или разрешать большинство операций, поэтому его не следует считать полностью эквивалентным верхнему типу в теоретическом смысле.
Нижний тип
Нижний тип — это тип, не имеющий ни одного возможного значения. Он может обозначать функцию, которая никогда нормально не возвращается или всегда возбуждает исключение.
Тип ! в Rust не содержит значений и обозначает незавершающийся результат вычисления. Он может приводиться к другим типам.
fn terminate() -> ! {
panic!("terminated");
}
Поскольку нижний тип не создаёт фактического значения, он логически может использоваться в местах, где требуется другой тип.
let value = if condition {
10
} else {
terminate()
};
terminate() не возвращает значение, поэтому не конфликтует с целочисленным типом другой ветви.
Единичный тип
Единичный тип содержит ровно одно значение, не несущее существенной информации. Он может выражать тот факт, что функция не возвращает вычислительный результат, а выполняет только побочный эффект.
Unit
Единичный тип отличается от нижнего типа, обозначающего отсутствие значений:
- единичный тип содержит одно значение;
- нижний тип не содержит ни одного значения.
Нулевой тип
В некоторых языках null или аналогичное значение входит в ссылочные типы. В других языках возможность null различается непосредственно на уровне типа.
Если возможность null неявно включена во все ссылочные типы, при каждом использовании ссылки необходимо учитывать отсутствие значения. Современные языки уменьшают эту проблему, разделяя T и T?, Option<T> или Maybe T, то есть явно представляя наличие и отсутствие значения в типе.
Номинальные и структурные типы
Номинальная типизация определяет совместимость по именам объявленных типов и явно заданным отношениям.
class UserId
class OrderId
Даже если оба типа внутренне представлены одним целым числом, при отдельных объявлениях они могут считаться разными типами. Чтобы один тип стал подтипом другого, обычно требуется явное отношение, например наследование или реализация интерфейса.
Номинальные типы удобны для разделения понятий предметной области и предотвращения непреднамеренного смешивания типов. Даже если длина и масса представлены одинаковыми вещественными числами, отдельные типы могут запрещать ошибочные присваивания между ними.
Структурная типизация оценивает совместимость по фактически предоставляемой структуре полей и методов, а не по имени типа.
type Named = {
name: string;
};
Значение с полем name: string может быть совместимо с Named, даже если оно явно не объявляло реализацию этого типа. TypeScript использует структурную типизацию, отражая широкое применение анонимных объектов и объектных литералов в JavaScript.
Номинальные и структурные типы имеют разные свойства.
| Различие | Номинальный тип | Структурный тип |
|---|---|---|
| Основа совместимости | имя объявления и явные отношения | структура полей и методов |
| Объявление отношений | обычно требуется | может отсутствовать при совпадении структуры |
| Разделение предметных понятий | выражено сильнее | одинаковые структуры могут смешиваться |
| Повторное использование существующих объектов | может требовать явной реализации | возможно без отдельного изменения объекта |
| Эволюция API | отношения объявлений ясны | изменение структуры может иметь широкие последствия |
Некоторые языки сочетают номинальные и структурные элементы. Например, они могут предоставлять номинальные типы классов вместе со структурными типами функций или анонимными записями.
Подтипизация
Подтипизация — это отношение, при котором значение одного типа можно безопасно использовать там, где требуется другой тип.
Dog <: Animal
Это означает, что Dog является подтипом Animal. Если Dog удовлетворяет всем условиям, ожидаемым кодом для Animal, его значение можно передать в соответствующее место.
Подтипизация не означает только наличие большего количества полей. Необходимо учитывать входные и выходные типы методов, изменяемые поля, исключения и правила изменения состояния. Карделли и Вегнер классифицировали полиморфизм включения на основе подтипов как одну из форм универсального полиморфизма.
Наследование и подтипизация тесно связаны, но не тождественны:
- наследование является языковой конструкцией повторного использования или расширения реализации и объявлений;
- подтипизация является отношением совместимости, позволяющим использовать значение в месте, где требуется другой тип.
В зависимости от языка интерфейсная подтипизация может существовать без наследования. Возможны и механизмы повторного использования реализации, которые не создают безопасного отношения присваивания.
Rust не использует обычные иерархии классовой подтипизации. Подтипизация в нём преимущественно ограничивается отношениями времён жизни и типами с квантификацией по временам жизни. Если не учитывать времена жизни, большинство отношений типов рассматривается как равенство типов.
Ковариантность, контравариантность и инвариантность
Вариантность описывает, как параметр типа внутри конструктора типов сохраняет отношение подтипов.
Если Dog <: Animal, основным вопросом становится отношение между Container<Dog> и Container<Animal>.
Ковариантность
Если направление отношения сохраняется, тип является ковариантным.
Dog <: Animal
Producer<Dog> <: Producer<Animal>
Производитель, создающий только Dog, может использоваться как производитель Animal. Вызывающая сторона ожидает Animal, и получение Dog остаётся безопасным.
Контравариантность
Если направление отношения меняется на противоположное, тип является контравариантным.
Dog <: Animal
Consumer<Animal> <: Consumer<Dog>
Потребитель, способный обрабатывать любое Animal, может обрабатывать и Dog, поэтому его можно использовать там, где требуется потребитель Dog.
Инвариантность
Если отношение подтипов не переносится на обобщённые типы, тип является инвариантным.
Между Box<Dog> и Box<Animal>
нет автоматического отношения подтипов
Изменяемый контейнер, разрешающий и чтение, и запись, обычно должен быть инвариантным. Если считать Box<Dog> разновидностью Box<Animal>, кто-либо сможет записать туда Cat, нарушив предположения кода, использующего исходный Box<Dog>.
В обобщённых интерфейсах и делегатах .NET ковариантность и контравариантность можно указывать явно. Инвариантные параметры требуют точного совпадения типов. Возвращаемые типы в роли производителя могут быть ковариантными, а параметры в роли потребителя — контравариантными.
В типах функций параметры обычно контравариантны, а возвращаемые значения ковариантны.
Animal → Dog
Такая функция принимает Animal и возвращает Dog. Если заменить её функцией, принимающей только более узкий тип, она может оказаться неспособной обработать обычное Animal, которое передаст вызывающая сторона. Напротив, возврат более конкретного Dog безопасен для вызывающего кода, ожидающего Animal.
Преобразования типов и приведения
Преобразование типа — это процесс, при котором значение рассматривается как значение другого типа или фактически меняет представление.
Преобразования типов подразделяются на:
- неявные преобразования;
- явные преобразования;
- числовые преобразования, изменяющие представление;
- восходящее приведение подтипа;
- нисходящее приведение, требующее проверки во время выполнения;
- упаковку и распаковку;
- переинтерпретацию указателей;
- пользовательские преобразования.
Принудительное преобразование, или coercion, — это преобразование типа, автоматически выполняемое языком в определённом контексте. В Rust такие преобразования также происходят только в заданных местах и для ограниченного набора отношений типов.
Восходящее приведение обычно рассматривает подтип как родительский тип без потери информации.
Dog → Animal
При нисходящем приведении может потребоваться проверка во время выполнения, действительно ли значение родительского типа является конкретным подтипом.
Animal → Dog
Спецификация Java также объясняет, что преобразование Thread в Object возможно без дополнительной проверки, тогда как при преобразовании Object в Thread необходимо во время выполнения убедиться, что фактический объект имеет соответствующий тип.
При числовых преобразованиях могут меняться диапазон и точность значения. Расширение малого целого типа до более крупного может сохранять значение, тогда как преобразование вещественного числа в целое или большого целого в малое может приводить к потере информации.
Следует различать преобразование, меняющее смысл значения, и переинтерпретацию, сохраняющую битовое представление, но изменяющую его трактовку. Произвольная переинтерпретация способна нарушать требования выравнивания, допустимые битовые шаблоны и правила псевдонимов, вызывая неопределённое поведение.
Полиморфизм
Полиморфизм — это свойство функции, типа или интерфейса применяться к значениям нескольких видов. Карделли и Вегнер разделяли полиморфизм на универсальный и специальный, а универсальный — на параметрический полиморфизм и полиморфизм включения.
Параметрический полиморфизм
Параметрический полиморфизм абстрагирует конкретный тип в параметр типа и применяет одну реализацию к нескольким типам.
identity<T>(value: T) -> T
identity можно применять к целым числам, строкам и пользовательским объектам с одной и той же структурой. Реализация использует только действия, допустимые для всех типов, а не конкретную природу T.
Параметрический полиморфизм повышает обобщённость и повторное использование кода, поскольку реализация не меняется произвольно в зависимости от типа.
Полиморфизм включения
Полиморфизм включения использует значение подтипа через интерфейс родительского типа.
draw(shape: Shape)
Если Circle и Rectangle являются подтипами Shape, их можно передавать одной функции. Фактическая реализация метода может выбираться во время выполнения через динамическую диспетчеризацию.
Специальный полиморфизм
Специальный полиморфизм означает, что одно имя или операция имеет разные реализации для разных типов. Характерными примерами являются перегрузка функций и операторов.
add(Integer, Integer)
add(Float, Float)
add(Vector, Vector)
Используется одно имя add, но фактическая реализация может различаться в зависимости от типов.
Классы типов
Класс типов систематизирует специальный полиморфизм, объявляя операции, которые должен предоставлять тип, и позволяя отдельно определять реализацию для каждого типа.
class Eq a where
(==) :: a -> a -> Bool
Тип, предоставляющий экземпляр Eq, может использовать операцию сравнения на равенство. Уодлер и Блотт предложили выражать специальный полиморфизм, включая перегрузку операторов, с помощью классов типов.
Трейты Rust также определяют абстрактные интерфейсы, реализуемые типами, и могут содержать функции, ассоциированные типы и константы. Обобщённая функция ограничивает допустимое поведение типов с помощью границ трейтов.
Обобщения
Обобщения — это языковая возможность определять часть типа или функции через параметры типов и повторно использовать её для нескольких конкретных типов.
struct Pair<T> {
first: T,
second: T,
}
Для параметров обобщённого типа можно указывать ограничения.
fn maximum<T: Ord>(values: &[T]) -> &T
Эта функция принимает не любые типы, а только те, которые поддерживают отношение порядка.
Ограничения обобщений могут выражаться в виде:
- верхней границы класса или интерфейса;
- границы трейта;
- ограничения класса типов;
- набора методов;
- ограничения времени жизни;
- требования наличия конструктора;
- требования типа-значения или ссылочного типа;
- множества типов и требований к операторам.
Даже если обобщения выглядят одинаково в исходном коде, способы их реализации в исполняемом коде могут различаться.
Стирание типов
Стирание типов преобразует часть или все аргументы обобщённого типа в общее представление во время компиляции. Обобщения Java используют стирание для сохранения обратной совместимости, поэтому во время выполнения не все аргументы типов можно непосредственно получить. Спецификация Java определяет правила стирания обобщённых типов и переменных типов.
Стирание позволяет нескольким экземплярам типов использовать один исполняемый код, но может ограничивать операции, которым требуется информация о параметрах типа во время выполнения.
Мономорфизация
Мономорфизация создаёт отдельный специализированный код для каждого конкретного типа, с которым используется обобщённый код. Rust во время компиляции анализирует места использования обобщений и генерирует код для соответствующих типов.
Мономорфизация удобна для оптимизации под конкретный тип и статической диспетчеризации, но при большом числе комбинаций типов может увеличивать размер кода и время компиляции.
Сохранение типов во время выполнения
Некоторые среды выполнения сохраняют аргументы обобщённых типов во время выполнения. Обобщения .NET поддерживают сведения о типах в среде выполнения и не используют стирание, аналогичное Java.
Это предоставляет больше информации для рефлексии и динамической проверки типов, но требует поддержки со стороны среды выполнения и метаданных.
Реальная реализация может не принадлежать полностью только к одному из этих способов. Например, код для ссылочных типов может совместно использоваться, а код для типов-значений — специализироваться. Также возможно создание общего кода с передачей некоторых операций через вспомогательные функции, зависящие от типа.
Nullable-типы и типы выбора
null используется для обозначения отсутствующего значения или ссылки, не указывающей на объект. Если null неявно разрешён для всех ссылочных типов, безопасность следующего выражения невозможно определить только по типу:
user.name
Необходимо отдельно проверить, является ли user фактическим объектом или null.
Система типов с поддержкой null-безопасности обычно различает типы следующим образом:
User
User?
User допускает только ненулевые значения, а User? — значение User или null. Kotlin различает возможность null в системе типов и ограничивает непосредственный доступ к nullable-значениям.
Nullable reference types в C# реализованы через аннотации времени компиляции и анализ потока управления поверх существующих ссылочных типов. string и string? не являются разными классами во время выполнения, а используются компилятором для предупреждения о потенциальном обращении к null.
Функциональные языки и Rust преимущественно представляют наличие значения через тип-сумму.
enum Option<T> {
None,
Some(T),
}
В этой структуре отсутствие значения является явным вариантом, поэтому обе ситуации необходимо обрабатывать через сопоставление с образцом или комбинирующие функции.
Даже при выражении nullable-состояний в типах ошибки null не исчезают полностью. Они могут возникать на границах взаимодействия с внешним кодом, при принудительных утверждениях, рефлексии и проблемах порядка инициализации. Необходимо различать области, где действуют гарантии системы типов, и механизмы, позволяющие их обходить.
Потокочувствительные типы и сужение типов
Потокочувствительный анализ типов уточняет сведения о типе одной и той же переменной в зависимости от потока управления программы.
function length(value: string | string[]) {
if (typeof value === "string") {
return value.length;
}
return value.length;
}
Внутри условной ветви проверка typeof value === "string" сужает тип value до строки. В другой ветви он может быть сужен до массива строк.
TypeScript использует typeof, instanceof, проверку свойств, пользовательские предикаты типов и анализ потока управления для сужения типов-объединений.
Сходная система также называется вхожденческой типизацией. Typed Scheme ввёл систему occurrence typing, уточняющую тип переменной в соответствии с условными конструкциями и проверками типов.
Потокочувствительный анализ должен учитывать возможность изменения значения переменной после проверки. Общие изменяемые переменные, асинхронные функции обратного вызова и состояние, доступное для изменения из другого потока, могут сделать прежнюю проверку типа недействительной. Поэтому компилятор может сохранять суженный тип только для неизменяемых локальных переменных или в пределах области, поведение которой доступно анализу.
Постепенная типизация
Постепенная типизация — это подход, позволяющий совместно использовать в одной программе статически типизированные и динамически проверяемые части.
Вместо одновременного перевода всей существующей динамической кодовой базы на статическую типизацию сведения о типах можно добавлять на уровне отдельных модулей, функций или выражений. Участки с конкретными типами проверяются статически, а при пересечении границы с неизвестным типом могут вставляться проверки времени выполнения.
Постепенная типизация имеет более строгое значение, чем простое параллельное предоставление статических и динамических проверок. Необходимо систематически определить отношения между точными и неизвестными типами, проверки времени выполнения на границах и место ответственности за ошибку типов. Исследования постепенной типизации развивались в направлении, позволяющем программисту регулировать степень статической проверки отдельных частей программы.
TypeScript является характерным практическим примером добавления статического анализа типов к коду JavaScript, однако не все возможности TypeScript полностью совпадают с одной конкретной формальной моделью постепенной типизации. В языке одновременно существуют any, позволяющий широко обходить проверку типов, структурная типизация и стираемые аннотации типов.
Преимущество постепенной типизации состоит в возможности расширять область действия типов, сохраняя существующий код и внешние библиотеки. В то же время на границах статических и динамических областей могут возникать затраты на проверки и сложности при определении источника ошибок. Правила таких границ должны предотвращать нарушение предположений статически типизированной области нетипизированными значениями.
Уточнённые типы
Уточнённый тип добавляет к существующему типу логическое условие, позволяя точнее выразить множество допустимых значений.
PositiveInteger = {
value: Integer | value > 0
}
Обычный целочисленный тип допускает все целые числа, тогда как PositiveInteger — только числа больше нуля.
Условия можно включать и в типы функций.
divide:
x: Integer
y: Integer where y != 0
→ Integer
Этот тип выражает предварительное условие, согласно которому делитель не должен быть равен нулю.
Проверяющий уточнённые типы может передавать возникающие в программе логические условия автоматическому средству доказательства, например SMT-решателю. Системы семейства Liquid Types используют ограниченную форму уточнений, добавляя к типам логические предикаты и сохраняя возможность автоматической проверки. Уточнённые типы позволяют проверять более точные свойства программ, чем обычные типы.
К свойствам, которые можно выражать с помощью уточнённых типов, относятся:
- нахождение индекса массива в допустимых границах;
- принадлежность целого числа определённому диапазону;
- равенство длины результирующего массива длине входного;
- ненулевой знаменатель;
- условие упорядоченности коллекции;
- права доступа и уровни безопасности;
- отсутствие арифметического переполнения.
По мере усложнения логических условий могут возрастать стоимость проверки типов и сложность объяснения ошибок. Поэтому область логики, доступная автоматическому доказателю, может ограничиваться, а для отдельных свойств пользователь может предоставлять вспомогательные доказательства.
Зависимые типы
Зависимый тип — это тип, который может зависеть от значения. Обычный обобщённый тип принимает в качестве параметра другой тип, тогда как зависимый тип может использовать при формировании типа и значения, например натуральные числа, логические значения или строки.
Характерным примером является вектор, длина которого входит в тип.
Vector<Element, 3>
Vector<Element, 4>
Вектор длины 3 и вектор длины 4 являются разными типами. Функция объединения векторов может выражать отношение их длин непосредственно в типе.
append:
Vector<T, n>
→ Vector<T, m>
→ Vector<T, n + m>
Документация Agda описывает зависимые типы как семейства типов, индексируемые объектами других типов, и приводит Vec n в качестве примера типа векторов, индексированного натуральным числом n.
В зависимом типе функции возвращаемый тип может меняться в зависимости от входного значения.
isSingleton:
Bool → Type
На уровне типов можно определить вычисление, при котором для входного значения true возвращается целочисленный тип, а для false — тип списка целых чисел. Idris рассматривает типы как значения первого класса, которые можно передавать функциям, возвращать и вычислять.
Зависимые типы позволяют включать в типы следующие свойства:
- размеры массивов и матриц;
- текущее состояние протокола;
- факт соответствия синтаксического дерева правилам типов;
- длину и структуру результата операции;
- доказательство выполнения программой определённой теоремы;
- свидетельство допустимости индексного доступа.
В соответствии с соответствием Карри — Ховарда тип можно интерпретировать как высказывание, а значение этого типа — как доказательство высказывания. Реализация функции становится термом, доказывающим утверждение, выраженное её типом. Такая структура позволяет писать программу и доказательство на одном языке.
Зависимые типы предоставляют очень точные контракты, однако проверка типов может требовать вычислений и нормализации на уровне значений. Если автоматический вывод затруднён, типы и доказательные термы приходится указывать явно, а изменение реализации программы может потребовать изменения доказательств.
Линейные и аффинные типы
В обычной системе типов одно значение можно использовать несколько раз или не использовать вовсе. Линейный тип ограничивает значение требованием использовать его ровно один раз. Аффинный тип разрешает использовать значение не более одного раза, в том числе не использовать его совсем.
LinearResource
Если такой ресурс нельзя скопировать или потребить дважды, через тип можно управлять объектами, которым необходим единственный владелец, например файловыми дескрипторами, буферами памяти, каналами связи и буферами команд GPU.
Линейные типы контролируют повторное и пропущенное использование ресурсов, ограничивая число обращений к значению. Аффинные типы гарантируют, что некоторые значения не будут использованы более одного раза.
К примерам применения относятся:
- освобождение памяти ровно один раз;
- закрытие файла после использования;
- запрет повторного использования снятой блокировки;
- однократная отправка сообщения;
- управление некопируемыми аппаратными ресурсами;
- ограничение копирования квантового состояния;
- ограничение псевдонимов в конкурентной программе.
Rust не является традиционным языком с линейной системой типов, однако объединяет владение, перемещение, заимствование и времена жизни с проверкой типов. Правила владения позволяют компилятору проверять отношения управления памятью и служат основой безопасности памяти без сборщика мусора.
Правила ссылок Rust разрешают в один момент либо одну изменяемую ссылку, либо несколько неизменяемых и не позволяют ссылке существовать дольше объекта, на который она указывает.
Владение и линейность показывают, что система типов способна отслеживать не только форму значения, но также права на его использование и время жизни.
Типы эффектов
Обычный тип функции описывает только вход и выход.
String → Integer
Однако функции с одинаковыми типами входа и выхода могут вести себя совершенно по-разному:
- выполнять чистый разбор строки;
- читать файл;
- отправлять сетевой запрос;
- изменять глобальное состояние;
- возбуждать исключение;
- запускать асинхронную операцию.
Система эффектов отслеживает побочные эффекты функции вместе с её типом.
readConfig:
Path → Config
effects { FileRead, IOError }
Система эффектов может представлять:
- ввод-вывод;
- чтение и изменение состояния;
- исключения;
- асинхронное выполнение;
- продолжения и управляющие эффекты;
- доступ к областям памяти;
- создание конкурентных задач;
- полномочия безопасности;
- чистоту функции.
Лукассен и Гиффорд предложили систему эффектов, полиморфно отслеживающую действия, которые способны выполнять выражения, чтобы анализировать ограничения параллельного выполнения.
Типы эффектов включают в интерфейс не только тип результата программы, но и свойства процесса выполнения. Это позволяет запрещать передачу изменяющей состояние функции туда, где требуется чистая функция, или требовать от вызывающей стороны обработки определённых исключений.
Проверяемые исключения Java можно рассматривать как ограниченную форму отслеживания эффектов, однако общие системы эффектов способны комбинировать и выводить не только исключения, но и несколько видов побочного воздействия.
Сеансовые типы
Сеансовый тип выражает через тип порядок и направление сообщений, передаваемых по каналу связи, а также правила ветвления и завершения.
Client:
Send<Request>
Receive<Response>
Close
Этот тип описывает протокол, согласно которому клиент должен отправить запрос, получить ответ и затем закрыть канал.
Тип серверной стороны выражает противоположное направление коммуникации.
Server:
Receive<Request>
Send<Response>
Close
Сеансовые типы могут использоваться для предотвращения следующих ошибок:
- отправки сообщений в неверном порядке;
- получения сообщения неожиданного типа;
- несовпадения ветвей коммуникации;
- закрытия канала только одной стороной;
- пропуска отдельных шагов протокола;
- несовпадения ролей участников коммуникации.
Многосторонние сеансовые типы определяют общий протокол связи между несколькими ролями, а затем проецируют его в локальный тип каждого участника. Исследования сеансовых типов развивались как типовая основа для структурирования программ, ориентированных на коммуникацию.
Сеансовые типы часто объединяются с линейными типами. Если одно состояние канала произвольно используется в нескольких местах, состояние протокола может разветвиться. Ограничение владения каналом и числа его использований позволяет сделать переход к следующему этапу однозначным.
Представление типов во время выполнения
Сохраняется ли тип только в исходном коде и во время компиляции или также существует во время выполнения, зависит от языка и реализации.
Реифицированный тип сохраняет сведения о типе во время выполнения, позволяя использовать их для рефлексии, проверки типов и сериализации. Такие сведения могут содержаться в объектах классов, описателях типов среды выполнения и таблицах виртуальных методов.
Стёртый тип частично или полностью удаляется из исполняемого кода после проверки типов. Стираться могут переменные обобщённых типов, псевдонимы типов и сведения о nullable-значениях, используемые только статическим анализом.
Стирание типа не означает, что он не оказал никакого влияния на результат выполнения. Сведения о типах могли ранее определить:
- выбираемую функцию или перегрузку;
- размещение данных в памяти;
- создаваемый машинный код;
- вставляемые проверки времени выполнения;
- специализацию обобщённого кода;
- способы уничтожения и копирования;
- ABI и соглашение о вызовах;
- возможности оптимизации.
Существуют и сведения о типах, которые имеют одинаковое представление во время выполнения, но используются только потоковым анализом компилятора, как nullable reference types в C#.
Сильная и слабая типизация
Термины «сильная типизация» и «слабая типизация» широко используются, однако не имеют единого общепринятого формального критерия. В зависимости от контекста они могут обозначать различные свойства:
- объём неявных преобразований типов;
- возможность совместного использования различных типов;
- возможность переинтерпретации памяти;
- наличие проверки типов во время выполнения;
- наличие механизмов обхода правил типов;
- автоматические преобразования между строками и числами;
- операции с указателями и неопределённое поведение;
- момент возникновения ошибки типов.
Например, статически типизированный язык с большим количеством неявных числовых преобразований и динамически типизированный язык с минимальным числом автоматических преобразований, но с проверками во время выполнения, трудно расположить на одной простой шкале силы типов.
Для точного описания лучше непосредственно указывать конкретные свойства:
- язык использует статическую проверку типов;
- допускает неявное преобразование строк и чисел;
- требует проверки времени выполнения при нисходящем приведении;
- позволяет переинтерпретировать указатель как произвольный тип;
- сохраняет аргументы обобщённых типов во время выполнения;
- возбуждает исключение при ошибке типов;
- запрещает переинтерпретацию памяти в безопасном коде.
Спецификация Java называет Java статически и строго типизированным языком, однако это выражение используется в контексте ограничений типов и операций, определённых самой Java. Оно не устанавливает универсальную шкалу силы типизации, применимую ко всем языкам.
Обход системы типов и границы доверия
Практические языки предоставляют механизмы обхода для ситуаций, которые трудно выразить средствами системы типов, а также для взаимодействия с внешними системами.
К характерным примерам относятся:
- явные приведения типов;
- динамические типы;
- типы, исключающие проверку, такие как
any; - необработанные указатели;
- блоки
unsafe; - рефлексия;
- интерфейсы внешних функций;
- сериализация и десериализация;
- нативные подключаемые модули;
- утверждения типов;
- встроенные функции компилятора.
Такие возможности могут быть необходимы для системного программирования, оптимизации производительности и подключения низкоуровневых API. Однако ответственность за условия, которые ранее доказывал проверяющий типы, переходит к программисту или внешнему коду.
Для создания безопасной границы обходные механизмы следует ограничивать небольшой внутренней реализацией, предоставляя снаружи интерфейс, удовлетворяющий правилам типов. К этой структуре относится и подход Rust, при котором реализация с unsafe заключается внутри безопасной функции.
Внешние данные также находятся за пределами доверительной границы системы типов. JSON, сетевые пакеты, файлы и строки базы данных не обязаны автоматически соответствовать типам, ожидаемым программой. На этапе десериализации необходимо проверить их структуру и диапазоны значений, а затем преобразовать в внутренние типы.
Проектные решения системы типов
Система типов проектируется не только путём безусловного увеличения выразительности и безопасности. Разработчики языка выбирают баланс между следующими факторами:
- точностью проверки типов;
- объёмом аннотаций типов;
- возможностями вывода типов;
- временем компиляции;
- понятностью сообщений об ошибках;
- совместимостью с существующим кодом;
- динамическими возможностями и рефлексией;
- производительностью выполнения и размером кода;
- раздельной компиляцией и границами модулей;
- взаимодействием с другими языками;
- возможностями анализа в инструментах и IDE;
- сложностью спецификации и реализации языка.
Более точная система типов способна находить больше ошибок до выполнения и документировать свойства программы. Однако проверка типов может усложняться, а объём аннотаций и доказательного кода — увеличиваться. Более простая система типов облегчает понимание языка и его реализации, но может передавать проверку большего числа инвариантов средствам времени выполнения и тестам.
Современные языки часто не ограничиваются одним способом, а объединяют несколько уровней проверки. Они могут предоставлять базовую статическую типизацию и вывод типов, а при необходимости добавлять типы-суммы и сопоставление с образцом, ограничения обобщений, nullability, владение, уточнённые типы или внешние статические анализаторы.
Цель системы типов не состоит в принятии всех возможных программ. Отказ от программы, безопасность которой язык не способен установить, также является частью её назначения. Даже фактически безопасная программа может быть отклонена, если проверяющему типы не хватает сведений для доказательства. В этом случае можно изменить структуру программы, добавить аннотации и доказательства, перенести проверку на время выполнения или использовать ограниченную небезопасную границу.
Таким образом, система типов — это не просто механизм назначения меток значениям, а формальная система, выражающая структуру программы, границы допустимых вычислений, абстракции, владение ресурсами, ошибки, побочные эффекты и протоколы коммуникации. Она начинается с базовых возможностей, таких как статические и динамические проверки, вывод типов, подтипизация, полиморфизм, обобщения и алгебраические типы данных, и может расширяться до зависимых, линейных, эффектных и сеансовых типов, определяя, какие программы способен выражать язык и какие ошибки он может предотвращать заранее.
Проектирование и стандартизация языков
Проектирование языка программирования не сводится к созданию новых ключевых слов и синтаксиса. Оно включает определение задач, которые должен решать язык, и основных сред его применения, а также объединение значений и типов, изменения состояния, вызовов функций, управления памятью, обработки ошибок, конкурентности, модулей, способов выполнения и правил взаимодействия с внешними системами в единую согласованную вычислительную систему. Только в сочетании со спецификацией, реализациями, стандартной библиотекой, инструментами разработки, процедурами изменения и политиками совместимости возникает экосистема языка программирования, пригодная для длительного использования.
Стандартизация языка программирования — это процесс определения его синтаксиса и семантики в общем документе и установления критериев, позволяющих различным реализациям обрабатывать одну программу максимально согласованно. Её цель состоит в повышении переносимости программ и библиотек и формировании языковой экосистемы, не зависящей от одной реализации. Подкомитет SC 22 в составе ISO/IEC JTC 1 занимается международной стандартизацией языков программирования, сред их выполнения и интерфейсов системного программного обеспечения, рассматривая переносимость как одну из основных целей.
Не все языки программирования имеют международный стандарт. C (язык программирования) и C++ стандартизуются рабочими группами ISO/IEC, тогда как базовая спецификация JavaScript, ECMAScript, развивается комитетом TC39 организации Ecma International. Изменения Python обсуждаются через PEP, Rust — через RFC, Go (язык программирования) — через процесс предложений, Kotlin — через KEEP, а Swift — через Swift Evolution. Таким образом, развитие языков программирования может различными способами направляться международными организациями по стандартизации, промышленными консорциумами, компаниями, открытыми программными проектами и независимыми сообществами.
Цели проектирования и область применения
При создании нового языка программирования прежде всего необходимо определить задачи, которые он должен решать, и его основных пользователей. Трудно одновременно обеспечить наивысшую производительность во всех областях, самый простой синтаксис, максимальную безопасность, наиболее быструю компиляцию, высочайшую переносимость и полную обратную совместимость. Проектирование языка представляет собой выбор приоритетов между целями, которые могут противоречить друг другу.
К основным целям проектирования могут относиться:
- возможность детально управлять аппаратным обеспечением и системными ресурсами;
- степень предотвращения ошибок памяти и типов;
- предсказуемость поведения программы и стоимости выполнения;
- возможность выражать код кратко и ясно;
- высокая скорость компиляции и запуска программы;
- высокая производительность длительно работающих программ;
- лёгкость переноса между операционными системами и процессорами;
- поддержка крупных кодовых баз и совместной работы многих разработчиков;
- повторное использование существующих языков, библиотек и API операционных систем;
- безопасное выражение конкурентности и параллелизма;
- простота реализации компилятора, среды выполнения и инструментов разработки;
- долговременное сохранение совместимости исходного кода и библиотек;
- понятные сообщения об ошибках и удобная отладка.
Языки системного программирования часто уделяют особое внимание размещению данных и времени жизни, соглашениям о вызовах, интерфейсам внешних функций, задержкам и предсказуемости стоимости выполнения. Язык, преимущественно используемый для автоматизации или анализа данных, может ставить выше интерактивное выполнение, краткие выражения, динамические структуры данных и доступность библиотек. Учебные языки обычно ориентируются на ясность понятий, последовательный синтаксис и понятные сообщения об ошибках, а предметно-ориентированные языки — на краткое и точное выражение понятий определённой области.
Целевая среда выполнения языка также непосредственно влияет на его проектирование. Для операционных систем и встраиваемых устройств важны автономные исполняемые файлы, небольшая среда выполнения, ясный ABI и работа при ограниченных ресурсах. Для языка, выполняющегося в браузере, существенны песочница, постепенная загрузка кода и связь со средой-хостом. Для языка распределённых служб ключевыми элементами могут быть лёгкие задачи, изоляция отказов, сетевое взаимодействие, наблюдаемость и средства развёртывания.
Принципы проектирования
Разработчику языка следует не добавлять возможности изолированно, а устанавливать основные принципы, применимые ко всему языку. Если разные возможности используют несогласованные правила, язык становится сложнее изучать и реализовывать, а их сочетание может приводить к непредсказуемому поведению.
К характерным принципам проектирования относятся:
- согласованность обеспечивает сходный синтаксис и смысл для сходных понятий;
- ортогональность позволяет сочетать небольшое число независимых возможностей различными способами;
- явность делает существенные изменения состояния, возможность ошибок и стоимость выполнения заметными в исходном коде;
- абстракция скрывает детали реализации, сохраняя необходимый смысл и характеристики производительности;
- локальная рассуждаемость уменьшает необходимость исследовать всю программу для понимания отдельного участка;
- консервативное расширение не позволяет новым возможностям без необходимости изменять смысл существующих программ;
- реализуемость обеспечивает возможность воплотить определённые языком средства в практическом компиляторе и среде выполнения;
- удобство для инструментов позволяет парсерам, форматтерам, отладчикам, статическим анализаторам и IDE надёжно анализировать код;
- взаимодействие обеспечивает связь с операционной системой, внешними библиотеками, другими языками и существующими форматами данных;
- простота стремится свести число понятий и исключительных правил языка к минимуму.
Высокая ортогональность не означает, что все возможности должны сочетаться без ограничений. Даже если отдельные возможности просты, их комбинация может порождать неоднозначный смысл или чрезмерно сложную реализацию. Обобщения, перегрузка, вывод типов, неявные преобразования и подтипизация полезны по отдельности, но их совместное предоставление значительно усложняет проверку типов и диагностику ошибок. Поэтому при проектировании необходимо анализировать не только количество возможностей, но и их взаимодействие.
Простота языка также не означает только малое число ключевых слов. Синтаксис может быть кратким, но множество скрытых неявных действий и исключений сделает фактическую семантику сложной. И наоборот, даже при большем числе синтаксических элементов ясные роли и правила сочетания позволяют легче предсказывать поведение программы.
Основная вычислительная модель
Язык должен определить, в рамках какой вычислительной модели представляются программы. Императивный язык сосредоточен на изменениях состояния хранилища и порядке выполнения команд, функциональный — на вычислении функций и преобразовании значений. Объектно-ориентированный язык строится вокруг объектов, сообщений или вызовов методов, а логический — вокруг фактов, правил и запросов.
Современные языки программирования часто поддерживают несколько парадигм, однако не все они имеют одинаковый вес. Даже если функции являются значениями первого класса, язык может преимущественно использоваться императивно и объектно-ориентированно, когда его основная структура и стандартная библиотека построены вокруг изменяемых объектов и императивного потока управления.
При выборе вычислительной модели необходимо совместно определить:
- что считается значением и объектом;
- обладают ли значения идентичностью и изменяемостью;
- как вызываются функции и методы;
- в каком порядке вычисляются выражения;
- где хранится и как изменяется состояние программы;
- как представляются побочные эффекты и ввод-вывод;
- каким способом передаются ошибки и неудачи;
- как конкурентные задачи совместно используют состояние или обмениваются им;
- на какие единицы разделяется и как объединяется программа.
Вычислительную модель важно сначала определить семантически, а затем выражать синтаксисом. Одну вычислительную модель можно представить разными синтаксическими способами, однако если начинать с синтаксиса при неясной семантике, добавление последующих возможностей может привести к росту числа исключений и неявных действий.
Значения и модель данных
При проектировании языка необходимо определить, какие значения существуют в программе. Модель данных может включать не только базовые значения, такие как целые и вещественные числа, логические значения, символы и строки, но также массивы, кортежи, записи, функции, объекты, указатели, ссылки, типы-суммы и модули.
Модель данных должна отвечать на следующие вопросы:
- копируется ли значение непосредственно или совместно используется через ссылку;
- обладает ли значение уникальной идентичностью;
- можно ли изменить значение после создания;
- как различаются идентичность и равенство;
- какое представление имеет значение в памяти;
- кто управляет временем жизни значения;
- допускаются ли циклические ссылки;
- можно ли рассматривать функции и типы как значения;
- сохраняются ли сведения о типах во время выполнения.
Различие между типами-значениями и ссылочными типами не определяется только стеком и кучей. Важно, копируют ли присваивание и передача аргументов само значение или совместно используют ссылку и наблюдаются ли изменения через другие ссылки. Оптимизированная реализация может размещать значение-тип в куче, а объект ссылочного типа — в стеке, поэтому семантику языка следует отделять от конкретного размещения в памяти.
Состояние и изменяемость
Возможность изменения состояния программы и области, в которых оно разрешено, также является центральной проблемой проектирования. Изменяемые переменные и объекты позволяют непосредственно представлять состояние реальных систем, однако совместное использование одного состояния несколькими частями программы усложняет рассуждение о её поведении.
Язык может управлять изменяемостью следующими способами:
- делать все переменные изменяемыми по умолчанию;
- различать неизменяемые и изменяемые переменные отдельными объявлениями;
- различать изменяемость самого объекта и изменяемость ссылки;
- выражать в типе возможность функции изменять внешнее состояние;
- разрешать изменение значения только одному владельцу;
- предоставлять к общему значению только неизменяемый доступ;
- ограничивать изменение состояния внутренней областью модуля или объекта.
Неизменяемость не означает полного отсутствия состояния. Это способ контролировать место и область изменений посредством создания новых значений или явной передачи состояния через аргументы и возвращаемые результаты. Даже в языке с изменяемостью состояние можно локализовать с помощью инкапсуляции, владения и ограничений доступа.
Проектирование системы типов
Система типов определяет отношения между значениями и операциями, разрешёнными языком. Разработчик должен выбрать способы сочетания статической и динамической проверки, вывода типов, подтипизации, обобщений, перегрузки, преобразований типов и информации о типах времени выполнения.
Проектирование системы типов включает следующие решения:
- проверять ли типы до выполнения или во время него;
- насколько явно программист должен указывать типы;
- использовать ли имена или структуру типов как основу совместимости;
- в каком объёме разрешать неявные преобразования;
- как представлять и реализовывать обобщённый код;
- включать ли возможность null и состояния ошибок в типы;
- должен ли проверяющий типы отслеживать владение значениями и времена жизни;
- выражать ли побочные эффекты функций и исключения через типы;
- на каких границах разрешать небезопасные операции.
Повышение выразительности системы типов позволяет проверять больше ошибок и инвариантов во время компиляции, но может усложнить проверку и сообщения об ошибках. Более простая система типов облегчает понимание языка и реализации, однако передаёт больше условий проверкам времени выполнения и тестированию.
Вывод типов также не следует проектировать исключительно в направлении полного устранения аннотаций. Можно выводить очевидные типы локальных переменных, но требовать явных типов для открытых функций и границ модулей, уменьшая многословность и сохраняя ясные контракты API.
Поток управления и правила вычисления
Поток управления определяет, какие части программы и в каком порядке выполняются. Язык должен задавать правила последовательного выполнения, условного ветвления, циклов, вызовов функций, рекурсии, обработки исключений, сопоставления с образцом, корутин и асинхронного выполнения.
Если порядок вычисления не определён ясно, выражения с побочными эффектами могут давать разные результаты в различных реализациях. Поэтому спецификация должна устанавливать:
- порядок вычисления аргументов функций;
- использование короткого замыкания логическими операторами;
- приоритет и ассоциативность присваиваний и операторов;
- момент создания и уничтожения временных значений и локальных переменных;
- действия по очистке при возникновении исключения;
- моменты приостановки и возобновления асинхронной функции;
- порядок наблюдения операций памяти несколькими потоками.
Если язык оставляет часть порядка вычисления неопределённой, реализация может выбирать разные варианты для оптимизации. Однако программа, зависящая от неустановленного порядка, способна вести себя по-разному в зависимости от реализации и уровня оптимизации. Предоставляя такую свободу, спецификация должна ясно отделять гарантированное поведение от негарантированного.
Функции и модель вызовов
Функции являются основной единицей разделения и повторного использования вычислений в большинстве языков программирования. Их проектирование включает не только синтаксис объявлений, но и передачу параметров, возврат результатов, захват переменных, перегрузку, полиморфизм и правила вызовов.
Язык должен определить:
- передаются ли аргументы по значению или по ссылке;
- поддерживаются ли передача по имени и ленивое вычисление;
- разрешены ли аргументы по умолчанию и переменное число аргументов;
- могут ли функции быть значениями первого класса;
- каким способом замыкание захватывает внешние переменные;
- разрешены ли перегрузка функций и операторов;
- используется ли динамическая диспетчеризация при вызове методов;
- гарантируется ли оптимизация хвостовых вызовов;
- как выражаются ошибки и эффекты, которые может возвращать функция;
- какой ABI используется при вызове внешнего кода.
Вопрос о том, копирует ли замыкание внешние переменные, захватывает их по ссылке или продлевает их время жизни, тесно связан с моделью памяти языка. Модель вызовов также необходимо проектировать совместно с системой типов, объектной моделью и реализацией среды выполнения.
Управление памятью и ресурсами
Разработчик языка должен определить способы управления временем жизни объектов, памяти, файлов, сетевых соединений, блокировок, ресурсов GPU и других объектов.
К основным подходам относятся:
- явное выделение и освобождение программистом;
- автоматическое освобождение на основе подсчёта ссылок;
- трассирующая сборка мусора;
- управление объектами по областям или аренам;
- статическая проверка владения и времён жизни;
- автоматический вызов деструкторов или функций очистки при выходе из области;
- совместное использование нескольких подходов.
Автоматическое управление памятью не означает автоматической и своевременной очистки всех внешних ресурсов. Даже в языке со сборщиком мусора для файлов, сокетов и блокировок, которые необходимо освобождать немедленно, требуются отдельные конструкции очистки.
Способ управления памятью влияет на производительность, задержки, безопасность, ABI, взаимодействие с другими языками и структуру программы. Ручное управление даёт точный контроль над стоимостью выполнения, но требует от программиста предотвращать обращения после освобождения, двойное освобождение и утечки. Сборка мусора автоматизирует время жизни объектов, но добавляет стоимость сбора, возможные паузы и сложность среды выполнения. Система владения может статически предотвращать многие ошибки, однако накладывает дополнительные ограничения на ссылки и времена жизни.
Обработка ошибок
Язык должен определить, как выражаются и распространяются неудачи. Он может возвращать коды ошибок, возбуждать исключения или представлять успех и неудачу через тип-сумму либо тип результата.
При проектировании обработки ошибок необходимо учитывать:
- различаются ли восстанавливаемые ошибки и программные дефекты;
- указываются ли ошибки в типе функции;
- обязан ли вызывающий код обрабатывать ошибку;
- как исключения пересекают границы функций;
- каким способом освобождаются ресурсы при раскрутке стека;
- можно ли сохранять несколько ошибок одновременно;
- куда передаются ошибки асинхронных и конкурентных задач;
- как ошибки преобразуются на границе с другим языком.
Исключения делают распространение ошибок более кратким, однако по объявлению функции может быть трудно определить, какие исключения она способна вызвать. Типы результата делают возможность ошибки явной, но могут увеличивать количество повторяющегося кода передачи. Язык может уменьшать эти затраты сочетанием синтаксиса, системы типов, сопоставления с образцом и операторов распространения.
Конкурентность и модель памяти
Современный язык должен учитывать среды, в которых одновременно выполняется несколько задач. Недостаточно предоставить только API создания потоков: необходима модель памяти, определяющая, в каком порядке разные единицы выполнения наблюдают операции над памятью.
Проектирование конкурентности включает следующие решения:
- использовать ли общую память и потоки;
- сосредоточиться ли на передаче сообщений и каналах;
- предоставлять ли акторов и изолированное состояние;
- поддерживать ли корутины и асинхронные функции;
- как определять атомарные операции и порядок памяти;
- считать ли гонку данных ошибкой или неопределённым поведением;
- как организовать отмену задач и распространение ошибок;
- включать ли структурированную конкурентность в язык;
- как выражать параллельные циклы и параллелизм данных.
Компилятор и процессор могут изменять фактический порядок операций памяти для повышения производительности. Модель памяти языка должна определять допустимые перестановки и отношения порядка, создаваемые операциями синхронизации. Без ясной модели одна программа может вести себя по-разному в зависимости от процессора и компилятора.
Модули и пространства имён
Модульная система разделяет программу на несколько единиц и отличает открытый интерфейс каждой единицы от её внутренней реализации.
При проектировании модулей необходимо определить:
- является ли один файл одним модулем;
- может ли один модуль состоять из нескольких файлов;
- могут ли пространства имён быть вложенными;
- как различаются открытые и закрытые объявления;
- как разрешаются конфликты имён при импорте;
- допускаются ли циклические зависимости;
- можно ли компилировать модули отдельно;
- в каком формате сохраняются сведения об открытых типах и функциях;
- как различаются пакеты, модули и библиотеки;
- можно ли одновременно использовать разные версии одного пакета.
Хорошая модульная система скрывает реализацию, делает зависимости явными и поддерживает инкрементальную компиляцию. Если же открытый интерфейс чрезмерно раскрывает детали реализации, даже небольшое внутреннее изменение может привести к повторной компиляции зависимых модулей или проблемам совместимости.
Взаимодействие и внешние интерфейсы
Чтобы новый язык программирования применялся в реальной среде, он должен взаимодействовать с существующими операционными системами, библиотеками, форматами данных и другими языками. Взаимодействие особенно важно для первоначального формирования экосистемы.
Проектирование взаимодействия включает следующие вопросы:
- можно ли вызывать C ABI и нативные функции;
- в каком формате памяти передаются структуры, массивы и строки;
- какая сторона владеет объектом и отвечает за его освобождение;
- можно ли передавать функции обратного вызова другому языку;
- можно ли передавать исключения через языковую границу;
- можно ли закрепить управляемый средой выполнения объект, чтобы предотвратить его перемещение;
- как представляются обобщения, классы и интерфейсы другого языка;
- можно ли совместно использовать двоичные файлы и среды выполнения разных версий.
Внешний интерфейс может стать границей, на которой прекращаются гарантии безопасности языка. Если внешняя функция возвращает неверный указатель или длину, ошибка способна возникнуть и в безопасном языке. Поэтому необходим слой, проверяющий внешние данные и преобразующий их в безопасные внутренние типы.
Проектирование синтаксиса
Синтаксис — это способ выражения понятий языка посредством символов и токенов. При его проектировании необходимо учитывать не только длину кода, но и читаемость, неоднозначность, восстановление после ошибок и возможность обработки инструментами.
Следует определить:
- форму идентификаторов и ключевых слов;
- способы разделения операторов и блоков;
- значение пробелов и отступов;
- приоритет и ассоциативность операторов;
- синтаксис объявлений функций, типов и модулей;
- синтаксис обобщений и аннотаций типов;
- форматы обычных и документационных комментариев;
- запись строковых и числовых литералов;
- синтаксис макросов и метапрограммирования;
- возможности восстановления после синтаксической ошибки.
Синтаксис должен избегать неоднозначных конструкций, допускающих несколько интерпретаций. Даже если парсер способен однозначно разобрать выражение, форма, которую человек легко понимает неверно, ухудшает сопровождаемость. Добавление новых ключевых слов также может нарушить существующий код, использующий те же имена как идентификаторы, поэтому политика резервирования связана с долговременным развитием языка.
Проектирование синтаксиса влияет и на автоматические форматтеры, IDE и генераторы кода. Если значение токена чрезмерно зависит от контекста или грамматика требует сведений из других файлов, независимый синтаксический анализ и поддержка редактора усложняются.
Разделение грамматики и семантики
Спецификация языка различает синтаксически корректные и семантически корректные программы. Грамматика определяет, как токены могут объединяться в структуры, но наличие объявлений имён, совместимость типов, права доступа и результаты вычисления определяются отдельными семантическими правилами.
Например, следующее выражение может быть синтаксически корректным:
left + right
Однако возможность сложения left и right, выбор конкретной реализации сложения и тип результата определяются семантическим анализом и системой типов.
Спецификация языка обычно отдельно описывает:
- лексическую грамматику;
- синтаксическую грамматику;
- правила статической семантики;
- динамическую семантику выполнения;
- интерфейсы библиотек;
- области выбора реализации;
- поведение, на которое программа не должна полагаться.
Если грамматика подробно описана, но семантика остаётся неясной, разные реализации могут по-разному обрабатывать один исходный код. Если же семантика чрезмерно привязана к определённой процедуре реализации, становится трудно применять новые методы оптимизации и исполнения. Спецификация должна достаточно ясно определять результат и наблюдаемое поведение, не вводя ненужных ограничений на внутреннюю структуру реализации.
Спецификация языка
Спецификация языка — это нормативный документ, определяющий, какие исходные программы корректны и какой смысл имеют корректные программы. Она отличается от документа, просто описывающего текущее поведение определённого компилятора. Реализация должна следовать спецификации, а расхождение между ними рассматривается как дефект реализации или неоднозначность спецификации.
Обычная спецификация языка включает:
- набор символов и кодирование исходного кода;
- токены и лексическую грамматику;
- синтаксическую грамматику;
- имена и области видимости;
- типы и преобразования;
- выражения и операторы;
- объявления и модули;
- порядок выполнения и правила вычисления;
- объектную модель и модель памяти;
- конкурентность и атомарные операции;
- ошибки и исключения;
- стандартную библиотеку;
- условия соответствия реализации;
- поведение, зависящее от реализации, неуточнённое и неопределённое поведение;
- отношения с другими стандартами.
Стандарт C (язык программирования) определяет форму и интерпретацию программ на C и предназначен для создания общей основы переносимых программ и соответствующих реализаций. WG14 отвечает за международную стандартизацию C, а WG21 — за международную стандартизацию C++.
Нормативные и пояснительные положения
В стандартном документе можно различать нормативные положения, обязательные для реализации, и пояснительные материалы, помогающие пониманию.
Нормативное содержание может включать требования следующего вида:
- реализация должна выполнять определённое действие;
- программа должна удовлетворять определённому условию;
- при нарушении условия необходимо выдать диагностическое сообщение;
- результат должен принадлежать заданному диапазону.
Пояснительные материалы содержат примеры, историю проектных решений, рекомендации и библиографию, но могут не определять соответствие реализации непосредственно.
Если это различие неясно, могут возникать споры о том, следует ли считать пример кода или поясняющее предложение обязательным правилом. Поэтому официальной спецификации желательно ясно отделять требования от справочных объяснений.
Соответствующие программы и реализации
Стандарт языка может определять соответствие не только программ, но и реализаций. Соответствующая реализация должна поддерживать требуемые стандартом синтаксис, семантику и библиотечные средства и предоставлять необходимые диагностические сообщения.
Реализация может предоставлять расширения, выходящие за пределы стандарта. Однако она не должна изменять смысл соответствующей программы, которая не использует расширения, или отклонять стандартную программу. Реализации могут отдельно предоставлять режим расширений и строгий стандартный режим.
Соответствие не означает, что все реализации должны создавать одинаковый машинный код. Они могут свободно выбирать оптимизации, размещение в памяти и внутренние структуры данных, но наблюдаемый программой результат должен находиться в пределах, разрешённых спецификацией.
Поведение, зависящее от реализации, неуточнённое и неопределённое поведение
Спецификация языка может не фиксировать каждую деталь единственным способом. Чтобы учитывать различия аппаратного обеспечения и операционных систем или сохранять свободу оптимизации, некоторые виды поведения разделяются на несколько категорий.
Поведение, зависящее от реализации
Поведение, зависящее от реализации позволяет реализации выбрать один из нескольких вариантов, но требует документировать сделанный выбор.
К таким свойствам могут относиться:
- размер базового целочисленного типа;
- знаковость символьного типа;
- обработка определённых файлов и кодировок символов;
- расширенные типы, предоставляемые реализацией;
- платформенные соглашения о вызовах.
Программа может полагаться на такое поведение после изучения документации реализации, однако при переносе на другую реализацию результат способен измениться.
Неуточнённое поведение
Неуточнённое поведение допускает спецификацией несколько результатов, причём реализация не обязана документировать, какой из них выбирается. Даже в одной реализации выбор может изменяться в зависимости от оптимизации или условий выполнения.
Программа должна предполагать лишь то, что будет получен один из разрешённых результатов, и не должна зависеть от конкретного варианта.
Неопределённое поведение
Неопределённое поведение — это поведение, для которого спецификация не устанавливает результат. При его возникновении реализация не гарантирует программе никакого конкретного результата.
Неопределённое поведение иногда применяется в языках, близких к аппаратному обеспечению, чтобы обеспечить высокую производительность и поддержать различные реализации. Однако его возникновение в обычном пути выполнения может привести к уязвимостям безопасности и непредсказуемым ошибкам.
Необходимо различать неопределённое поведение и дефект реализации. Если программа соответствует спецификации, но реализация выдаёт неверный результат, это дефект реализации. Если же программа выполняет запрещённое спецификацией действие, отсутствие определённого результата само по себе не означает дефект реализации.
Отношения между спецификацией и реализациями
У одного языка программирования может существовать несколько реализаций. Они могут применять различные архитектуры компилятора, способы оптимизации, виртуальные машины, сборщики мусора, реализации стандартной библиотеки и целевые платформы.
Наличие нескольких реализаций даёт следующие преимущества:
- уменьшает зависимость от одной компании или проекта;
- позволяет использовать разные стратегии реализации для различных сред выполнения;
- помогает выявлять неоднозначности спецификации посредством сравнения реализаций;
- позволяет проверять дефекты одной реализации с помощью другой;
- создаёт возможность экспериментировать с новыми оптимизациями и архитектурами среды выполнения.
Однако если реализации по-разному обрабатывают одни и те же программы, экосистема может фрагментироваться. Для предотвращения этого необходимы ясная спецификация, тесты на соответствие и дифференциальное тестирование реализаций.
В языке, где фактически используется только одна реализация, её поведение может выполнять роль неофициальной спецификации. Но если случайные внутренние особенности реализации не отделены от намеренных правил языка, последующая оптимизация или исправление дефекта может нарушить существующие программы.
Эталонная реализация
Эталонная реализация — это основной компилятор или интерпретатор, реализующий спецификацию языка. Она позволяет проверять языковые возможности на практике и предоставляет другим реализациям ориентир для сравнения поведения.
Однако эталонная реализация и спецификация языка не являются одним и тем же. Эталонная реализация также может содержать ошибки и использует только один из нескольких способов реализации, разрешённых спецификацией. Другая реализация может иметь иную внутреннюю структуру и характеристики производительности, но при соблюдении спецификации также считаться соответствующей.
Если эталонная реализация становится чрезмерно строгим ориентиром, даже отсутствующие в спецификации детали реализации могут закрепиться как фактический стандарт. Поэтому языковому проекту желательно ясно отделять преднамеренные правила, зафиксированные спецификацией и тестами, от случайного поведения конкретной реализации.
Тестирование на соответствие
Тестирование на соответствие — это набор испытаний, проверяющий выполнение реализацией требований спецификации языка и стандартной библиотеки.
Тесты на соответствие могут включать:
- проверку корректной обработки правильных программ;
- проверку выдачи необходимых диагностических сообщений для неправильных программ;
- проверку результатов выполнения выражений и потока управления;
- проверку преобразований типов и выбора перегрузок;
- проверку результатов и условий ошибок стандартной библиотеки;
- проверку модели памяти и поведения конкурентных программ;
- проверку структуры файловых и двоичных форматов;
- сравнение результатов на разных платформах.
Успешное прохождение тестов на соответствие не означает отсутствия дефектов в реализации. Число возможных программ практически бесконечно, поэтому тесты охватывают характерные правила спецификации и граничные условия. Генерация случайных программ, фаззинг, дифференциальное тестирование и формальная верификация позволяют обнаруживать дополнительные ошибки реализации.
Спецификации Java явно рассматривают условия, при которых независимая реализация должна выполнять установленные требования и проходить испытания на совместимость. Спецификация языка Java также выделяет отдельную главу для правил двоичной совместимости классов и интерфейсов, а не ограничивается только исходным языком.
Стандартная библиотека
Практическое удобство языка программирования во многом определяется не только его основным синтаксисом, но и стандартной библиотекой. Необходимо решить, в каком объёме стандарт должен предоставлять работу со строками, коллекциями, файлами, временем, математикой, конкурентностью, сетью, сериализацией и другими распространёнными возможностями.
Небольшую стандартную библиотеку легче реализовывать и переносить, а различные внешние библиотеки могут конкурировать между собой. Однако если даже базовые возможности зависят от сторонних пакетов, в разных проектах могут использоваться несовместимые интерфейсы, что приводит к фрагментации экосистемы.
При проектировании стандартной библиотеки необходимо учитывать:
- границу между основными возможностями языка и библиотечными функциями;
- возможности, доступные на всех платформах;
- способы абстрагирования различий операционных систем;
- модель обработки ошибок;
- API конкурентности и асинхронного выполнения;
- двоичную совместимость;
- наименования и согласованность API;
- порядок вывода устаревших возможностей из употребления;
- распределение ролей между стандартной библиотекой и экосистемой внешних пакетов.
API стандартной библиотеки может непосредственно влиять на больший объём пользовательского кода, чем грамматика языка. После публикации имена и поведение типов и функций трудно изменять, поэтому политику совместимости и возможности расширения следует учитывать с самого начала.
Предложения по изменению языка
В зрелом языке новая возможность обычно не добавляется немедленно, а проходит установленную процедуру предложения. Документ предложения должен описывать не только проблему и решение, но и отношения с существующими возможностями, альтернативы, совместимость, реализуемость и недостатки.
Обычный документ предложения включает:
- решаемую проблему;
- реальные сценарии применения;
- предлагаемые синтаксис и семантику;
- правила проверки типов и выполнения;
- совместимость с существующим кодом;
- влияние на стандартную библиотеку и инструменты;
- способ реализации и стоимость производительности;
- последствия для безопасности;
- рассмотренные, но отклонённые альтернативы;
- нерешённые вопросы;
- возможности дальнейшего расширения;
- план тестирования и документирования.
PEP в Python представляет собой проектный документ, который предоставляет сообществу Python информацию или описывает новую возможность языка, процедур либо среды. От PEP требуется содержать техническую спецификацию и обоснование предложения.
Процесс RFC в Rust предназначен для того, чтобы значительные изменения языка и стандартной библиотеки проходили согласованный и контролируемый путь. Предложение обсуждается публично и рассматривается соответствующей командой, после чего принимается или отклоняется. Принятие RFC не означает немедленного превращения возможности в стабильную: отдельно требуются реализация, тестирование и стабилизация.
В процедуре предложений Go сначала создаётся задача с кратким описанием проблемы. В ходе рассмотрения предложение может быть сразу принято или отклонено либо для него может потребоваться более подробный проектный документ. Процесс стремится к формированию общего согласия, а предложения, противоречащие основному устройству языка или не поддающиеся эффективной реализации, могут быть отклонены.
Репозиторий KEEP для Kotlin хранит как находящиеся в разработке предложения по языку и стандартной библиотеке, так и проектные документы уже реализованных изменений.
Swift Evolution публично рассматривает видимые пользователю изменения языка Swift, стандартной библиотеки и основных инструментов. Процедура стремится учитывать широкий спектр мнений сообщества и одновременно сохранять общее направление и концептуальную согласованность языка.
Поэтапное развитие предложений
Языковая возможность обычно не переходит непосредственно из одного предложения в официальный стандарт. Она может проходить следующие этапы:
Идея и постановка проблемы
↓
Первоначальный проект и сценарии применения
↓
Публичный документ предложения
↓
Обсуждение и исправление
↓
Прототипная реализация
↓
Тестирование и сбор опыта реального использования
↓
Подготовка текста спецификации
↓
Рассмотрение стабилизации
↓
Включение в официальный язык или стандарт
На раннем этапе проверяется, действительно ли решаемая проблема достаточно важна. Даже полезная на первый взгляд возможность может не добавляться в язык, если существующего синтаксиса и библиотек достаточно для решения задачи либо сценарии её применения слишком ограничены.
Прототип используется для проверки практической реализуемости предложения и обнаружения проблем в синтаксисе, проверке типов, сообщениях об ошибках, производительности и поддержке инструментами. Если возможность определяется только документом без опыта реализации, скрытая сложность может обнаружиться слишком поздно.
Процесс TC39 для ECMAScript использует несколько этапов, через которые предложение проходит от первоначальной идеи к исследованию, проектированию, обратной связи от реализаций и завершению спецификации. Чтобы достичь завершающей стадии, предложение должно удовлетворять требованиям, связанным с реальными реализациями, тестами и готовым для редактирования текстом спецификации.
Экспериментальные возможности и стабилизация
Если новую возможность сразу объявить стабильной, обнаруженные впоследствии проектные ошибки будет трудно исправить. Чтобы снизить этот риск, языки и платформы могут использовать экспериментальные возможности, предварительные версии и инкубаторные API.
На экспериментальном этапе может допускаться следующее:
- синтаксис и семантика могут измениться в последующих версиях;
- требуется явный параметр компиляции;
- возможность предоставляется в отдельном пространстве имён стандартной библиотеки;
- использование в производственной среде не рекомендуется;
- гарантии совместимости ограничены;
- собираются опыт использования и обратная связь от разработчиков реализаций.
Перед стабилизацией необходимо проверить:
- достаточно ли реальных сценариев применения;
- согласуются ли синтаксис и семантика с существующими возможностями;
- могут ли реализации эффективно поддерживать функцию;
- достаточно ли качественны сообщения об ошибках и поддержка инструментами;
- остаётся ли пространство для будущего расширения;
- отсутствуют ли проблемы безопасности;
- не изменяется ли смысл существующих программ;
- смогут ли другие реализации независимо воспроизвести возможность.
OpenJDK использует процесс JEP для отслеживания значительных возможностей и изменений JDK и платформы Java. Инкубаторные модули и предварительные возможности также управляются как часть процесса JEP.
Если экспериментальная возможность сохраняется слишком долго, граница между стабильными и экспериментальными средствами может стать неясной. Если же стабилизация происходит до накопления достаточного пользовательского опыта, неудачные API и синтаксические решения придётся поддерживать в течение длительного времени. Поэтому важно заранее определить назначение экспериментального периода и условия его завершения.
Согласие и принятие решений
Изменения языка часто трудно определять простым голосованием большинства. Одна возможность может по-разному влиять на существующих пользователей, разработчиков реализаций и инструментов, а также авторов библиотек.
В обычной процедуре достижения согласия учитываются:
- техническая обоснованность;
- долгосрочное направление языка;
- реальные потребности пользователей;
- нагрузка на реализации и инструменты;
- совместимость с существующим кодом;
- наличие альтернатив;
- мнение сообщества;
- стоимость дальнейшего сопровождения;
- способность ответственной команды постоянно поддерживать возможность.
Согласие не означает, что все участники обязаны предпочитать предложение. Оно может означать состояние, при котором существенные возражения достаточно рассмотрены, а ответственная организация способна оценить преимущества и затраты предложения.
Публичное обсуждение помогает обнаруживать различные сценарии применения и проблемы, но число участников обсуждения может не отражать всё сообщество пользователей. Поэтому разработчикам языка следует учитывать не только мнения в дискуссиях, но также исследования существующего кода, измерения производительности, опросы пользователей, реальные случаи ошибок и опыт работы с прототипами.
Управление проектом
Управление языком определяет, кто и по какой процедуре принимает решения о техническом направлении языка и его изменениях.
Управление языком программирования может иметь следующие формы:
- окончательные решения принимает один или несколько разработчиков;
- язык и его реализации поддерживает внутренняя команда компании;
- основная команда и подгруппы открытого проекта разделяют обязанности;
- решения принимаются на основе согласия комитета, состоящего из промышленных участников;
- применяется международная стандартизация с участием национальных представителей;
- административную и ресурсную поддержку предоставляет фонд или некоммерческая организация.
Структура управления влияет на скорость и прозрачность принятия решений, последовательность курса и возможности участия. Руководство одного разработчика позволяет сохранять единое направление и быстро принимать решения, но повышает зависимость от конкретного человека. Комитет способен учитывать интересы разных сторон, однако принятие решений может занимать больше времени.
Хорошая система управления должна ясно и публично определять:
- способ подачи предложения;
- лиц и организации, рассматривающие предложения;
- критерии принятия и отклонения;
- способы предоставления обратной связи;
- порядок разрешения конфликтов интересов;
- объём публикации решений и протоколов заседаний;
- процедуру повторного рассмотрения прежних решений;
- отношения полномочий между командой проекта и комитетом по стандартизации.
Даже при открытой технической спецификации непрозрачный процесс принятия решений затрудняет для внешних разработчиков реализаций и пользователей прогнозирование долгосрочного направления языка.
Международная стандартизация
Международная стандартизация — это процесс создания общей спецификации с участием национальных органов стандартизации и специалистов. В области языков программирования важную роль играет подкомитет SC 22 совместного технического комитета ISO и IEC JTC 1. В сферу деятельности SC 22 входят языки программирования, среды выполнения и интерфейсы системного программного обеспечения.
Международной стандартизацией C (язык программирования) занимается WG14. Эта рабочая группа поддерживает стандарт языка C и связанные технические спецификации.
Международной стандартизацией C++ занимается WG21. Группа разрабатывает стандарт C++, включая основные правила языка, стандартную библиотеку и технические документы. Предложения подаются в виде документов комитета и после рассмотрения исследовательскими, эволюционными и редакционными группами могут быть включены в проект стандарта.
Международный стандарт обычно не объявляет исходный код определённой реализации стандартом, а документально устанавливает требования к программам и реализациям. Стандарт может регулярно пересматриваться, а для отчётов о дефектах и вопросов интерпретации могут действовать отдельные процедуры.
Преимущество международной стандартизации заключается в предоставлении общей основы, независимой от одной компании или реализации. Однако участие национальных организаций, достижение согласия и проверка документов требуют времени, поэтому изменения могут происходить медленнее, чем в открытом программном проекте.
Отраслевые стандарты и консорциумы
Некоторые языки развиваются преимущественно вокруг отраслевых организаций и консорциумов, а не международных стандартов.
ECMAScript является спецификацией языка, поддерживаемой комитетом TC39 организации Ecma International. TC39 ведёт документы предложений и тексты спецификаций в открытых репозиториях и на регулярных заседаниях обсуждает продвижение предложений. Поскольку совместимость реальных реализаций JavaScript и веб-экосистемы имеет ключевое значение, в процесс включается опыт разработчиков различных браузеров и сред выполнения.
Такой подход позволяет развивать язык быстрее международной стандартизации и непосредственно учитывать обратную связь от реализаций. Однако влияние участвующих компаний и основных разработчиков реализаций может быть сравнительно высоким, поэтому особенно важны открытость процедуры, публикация протоколов и ясные критерии согласия.
Процедуры развития открытых проектов
Язык, управляемый открытым программным проектом, может развиваться вокруг репозитория кода, системы отслеживания задач и публичных документов предложений.
Система PEP в Python позволяет рассматривать в проектных документах не только возможности языка, но также процессы разработки, управление и стандарты упаковки. Поскольку сохраняются номер, статус и история изменений PEP, можно изучить основания не только принятых, но также отклонённых и отозванных решений.
Система RFC в Rust требует, чтобы значительные изменения языка и стандартной библиотеки проходили через публичный проектный документ. Предложения рассматриваются ответственными организациями, включая команды языка и библиотек, а реализация и стабилизация продолжаются отдельными процедурами даже после принятия RFC.
Процесс предложений Go не требует длинного проектного документа для каждого небольшого изменения. На стадии первоначального рассмотрения задачи оцениваются значимость и сложность предложения, и подробный документ запрашивается только при необходимости. Таким образом, стоимость процедуры согласуется с масштабом изменения.
KEEP в Kotlin и Swift Evolution также развивают языки и стандартные библиотеки через проектные документы, публичные обсуждения и решения ответственных команд.
Совместимость
После широкого распространения языка программирования сохранение работы существующих программ может стать важнее добавления новых возможностей. Совместимость является не одним понятием, а включает несколько уровней.
| Вид | Значение |
|---|---|
| Совместимость исходного кода | можно ли повторно скомпилировать существующий исходный код новым компилятором |
| Двоичная совместимость | могут ли совместно работать программы и библиотеки без повторной компиляции |
| Совместимость поведения | сохраняет ли программа прежний смысл и результаты |
| Совместимость API | сохраняются ли способы использования открытых функций, типов и модулей |
| Совместимость ABI | сохраняются ли соглашения о вызовах, символы и размещение объектов |
| Совместимость данных | можно ли продолжать читать существующие файловые, сетевые и сериализованные форматы |
| Совместимость инструментов | продолжают ли работать системы сборки, отладчики, форматтеры и анализаторы |
| Совместимость экосистемы | могут ли пакеты и фреймворки совместно использоваться в новой версии языка |
Сохранение совместимости исходного кода не гарантирует двоичной совместимости. Существующий код может работать после повторной сборки новым компилятором, но библиотеку, созданную старым компилятором, иногда нельзя непосредственно связать с новой программой.
И наоборот, при сохранённой двоичной совместимости поведение может слегка измениться. Если меняются обработка ошибок, порядок сортировки или результаты вычислений с плавающей точкой в стандартной библиотеке, программа может продолжать запускаться, но выдавать другие результаты.
Спецификация языка Java подробно различает изменения классов и интерфейсов, которые сохраняют совместимость с существующими двоичными файлами, и изменения, нарушающие её.
Обратная совместимость
Обратная совместимость — это свойство новой версии языка или реализации продолжать обрабатывать программы, написанные для предыдущей версии.
Для сохранения обратной совместимости необходимо учитывать следующие вопросы:
- не конфликтуют ли новые ключевые слова с существующими идентификаторами;
- не изменяются ли результаты выбора перегрузок и вывода типов;
- не становится ли прежний синтаксис неоднозначным из-за нового;
- не возникают ли конфликты имён при добавлении функций в стандартную библиотеку;
- не начинает ли код, ранее считавшийся ошибочным, интерпретироваться как корректный код с другим смыслом;
- не меняются ли порядок выполнения и результаты числовых вычислений;
- сохраняются ли существующие ABI и форматы сериализации.
Абсолютно гарантировать совместимость трудно. Иногда прежнее поведение необходимо изменить для устранения уязвимости безопасности или исправления очевидного дефекта спецификации. Кроме того, программа может перестать работать в новой реализации, если она зависела от поведения, не гарантированного спецификацией.
Документ о совместимости Go 1 устанавливает ожидание, что существующие программы Go 1 продолжат компилироваться и выполняться в последующих реализациях Go 1, но при этом указывает на возможные исключения, связанные, например, с безопасностью и неописанным поведением.
Объявление устаревшими и удаление
Даже если появилась более совершенная возможность или небезопасное средство больше не рекомендуется, его немедленное удаление может нарушить работу большого числа программ. Для этого применяется процедура объявления устаревшим.
Обычный процесс выглядит следующим образом:
Предоставление замены
↓
Объявление прежней возможности устаревающей
↓
Предупреждения компилятора и документации
↓
Предоставление автоматических средств преобразования
↓
Достаточный переходный период
↓
Удаление в основной версии или новом режиме языка
Устаревшая возможность не рекомендуется к использованию, но не обязательно сразу становится недоступной. Желательно, чтобы предупреждение об устаревании содержало сведения о замене и предполагаемом сроке удаления.
Если не удалять устаревшие средства никогда, сложность языка и реализаций будет постоянно расти. Если удалять их слишком быстро, экосистема может не успеть перейти на новую версию. Поэтому необходимо совместно учитывать распространённость функции, риски безопасности, стоимость сопровождения и возможность автоматической миграции.
Версии и редакции языка
Ещё одним важным решением является способ объединения и распространения изменений языка.
К распространённым подходам относятся:
- выпуск новых версий языка через определённые интервалы;
- непрерывное включение завершённых возможностей в спецификацию;
- отдельное предоставление основных версий с длительной поддержкой;
- выбор версии языка в исходном файле или проекте;
- поддержка нескольких редакций языка одним компилятором.
Rust размещает некоторые несовместимые изменения в выбираемых границах, называемых редакциями. Пока проект явно не выберет новую редакцию, для него сохраняются правила прежней. Библиотеки, использующие разные редакции, могут совместно существовать в одном графе зависимостей. Rust также предоставляет автоматические средства миграции исходного кода на новую редакцию.
Такой подход позволяет вводить синтаксически несовместимые изменения, не разделяя весь язык на полностью изолированные версии. Однако компилятор и инструменты должны одновременно поддерживать синтаксические и семантические различия нескольких редакций.
ECMAScript включает завершённые предложения в живую актуальную спецификацию и периодически публикует её в виде ежегодных редакций.
Циклы выпуска стандартов и реализаций
Цикл пересмотра стандартного документа может не совпадать с циклом выпуска компилятора. Реализация способна экспериментально предоставлять возможности, ещё не включённые в окончательный стандарт, или постепенно внедрять функции уже опубликованного стандарта.
В таком случае необходимо различать следующие состояния:
- возможность существует только как предложение и ещё не реализована;
- существует только экспериментальная реализация;
- возможность включена в спецификацию, но поддерживается не всеми реализациями;
- возможность поддерживается основными реализациями, но отсутствует в официальном стандарте как расширение;
- возможность стабильно поддерживается и стандартом, и основными реализациями.
Пользователь должен проверять не только версию языка, но и версию реализации, целевую платформу и активированные параметры возможностей. Даже при указании одной версии языка объём поддержки и набор дефектов могут различаться между реализациями.
Расширения стандарта
Реальные реализации могут предоставлять возможности, отсутствующие в стандарте. К характерным примерам относятся аппаратно-зависимые векторные типы и атрибуты, встроенный ассемблер, API конкретной операционной системы и встроенные функции компилятора.
Расширения позволяют использовать аппаратные и платформенные возможности, которые трудно выразить только стандартными средствами. Однако программы, зависящие от расширений, сложнее переносить на другие компиляторы и платформы.
Реализация может управлять расширениями следующим образом:
- использовать отдельное пространство имён или префикс;
- требовать явного включения расширения;
- диагностировать его использование в стандартном режиме;
- предоставлять макросы или атрибуты для обнаружения возможностей;
- предлагать широко принятые расширения комитету по стандартизации.
Если несколько реализаций независимо предоставляют сходное расширение, оно может стать кандидатом на стандартизацию. Напротив, возможность, чрезмерно зависящую от внутренней архитектуры одного компилятора, трудно превратить в общий стандарт.
Обнаружение возможностей
Чтобы программа поддерживала несколько версий языка и реализаций, ей необходим способ проверять наличие конкретной возможности.
Простая проверка номера версии компилятора может неправильно обрабатывать ответвлённые реализации или отдельные обратные переносы функций. Поэтому язык может предоставлять следующие способы обнаружения:
- проверку поддержки определённой синтаксической возможности;
- проверку наличия API стандартной библиотеки;
- проверку поддержки атрибутов и встроенных функций;
- определение целевой операционной системы и возможностей CPU;
- выбор редакции языка или набора возможностей;
- условную компиляцию.
Обнаружение возможностей увеличивает число условных ветвей и сложность программы. Поэтому желательно определить общую минимально поддерживаемую версию и обрабатывать только действительно необходимые различия.
Дефекты и толкование спецификации
Даже очень подробная спецификация может содержать неоднозначные формулировки, противоречия и пропуски. Проблемы обнаруживаются при разработке реализаций и появлении новых сценариев применения.
Дефекты спецификации могут принимать следующие формы:
- грамматика допускает два толкования;
- правила разных разделов противоречат друг другу;
- результат для определённого граничного случая не определён;
- пример не соответствует нормативному тексту;
- требование невозможно реализовать или оно чрезмерно неэффективно;
- общее поведение существующих реализаций отличается от спецификации.
Организация по стандартизации или языковой проект принимает отчёты о дефектах и исправляет их после рассмотрения исходного намерения, существующих реализаций и совместимости. Простое редакционное исправление можно внести быстро, тогда как изменение смысла программ может потребовать процедуры, сходной с предложением языковой возможности.
При исправлении дефекта необходимо учитывать, на какое толкование уже полагаются существующие программы. Даже переход к теоретически более правильному правилу может потребовать поэтапной миграции, если он нарушает работу широко используемого кода.
Опыт реализации и эмпирическое проектирование
Языковую возможность следует оценивать не только по теоретической согласованности, но и по удобству применения в реальном коде. Прототипные компиляторы, экспериментальные ветви и внешние библиотеки позволяют накопить опыт до включения функции в официальный язык.
При эмпирическом проектировании могут использоваться следующие данные:
- повторяющиеся шаблоны в реальных кодовых базах;
- часто возникающие ошибки компиляции;
- длина и сложность кода с применением возможности;
- время компиляции и производительность выполнения;
- размер создаваемых двоичных файлов;
- сложность реализации в IDE и статических анализаторах;
- результаты обучения новых пользователей;
- сравнение с альтернативой, написанной существующими средствами;
- результаты автоматического преобразования открытых проектов.
В нескольких небольших примерах возможность может выглядеть краткой, но в крупном коде приводить к сложным сообщениям об ошибках и непредсказуемому выводу. И наоборот, первоначально ограниченная возможность может решать общую проблему во множестве областей.
При прежних пересмотрах спецификации Java для оценки способов вывода типов также использовались наборы реального открытого кода, что показывает значение анализа существующих программ при изменении зрелого языка.
Безопасность
При проектировании языка необходимо учитывать не только выразительность и производительность, но и то, способствует ли его устройство возникновению уязвимостей.
К основным предметам анализа относятся:
- выход за границы массива;
- использование освобождённой памяти и неверных указателей;
- переполнение и усечение целых чисел;
- неправильное объединение строк и команд;
- использование неинициализированных значений;
- гонки данных;
- небезопасная десериализация;
- полномочия и границы доверия;
- исключения и освобождение ресурсов;
- гигиена генерации кода и макросов;
- цепочки поставок и исполняемый код пакетов.
Невозможно гарантировать все виды безопасности только на уровне языка. Однако безопасные значения по умолчанию и ограничение опасных операций явными границами уменьшают область, в которой способны возникать дефекты.
Средства безопасности также влияют на стоимость выполнения и сложность реализации. Проверки границ, переполнения и типов времени выполнения могут создавать дополнительные расходы, поэтому семантика и промежуточные представления должны позволять компилятору устранять доказуемо лишние проверки.
Переносимость
Переносимость — это свойство программы работать с минимальными изменениями в разных реализациях, операционных системах и архитектурах процессоров. Переносимость была одной из основных целей стандартизации C и деятельности SC 22.
Для повышения переносимости необходимо учитывать:
- размеры и диапазоны базовых типов;
- целочисленные вычисления и операции с плавающей точкой;
- порядок байтов и выравнивание;
- пути к файлам и кодировки символов;
- потоки и модель памяти;
- различия API операционных систем;
- время и локаль;
- сеть и файловые системы;
- внешние библиотеки и ABI;
- условную компиляцию.
Полностью скрыть различия всех платформ трудно, а чрезмерная абстракция может осложнить доступ к их уникальным возможностям. Язык и стандартная библиотека должны предоставлять общие функции, сохраняя при необходимости путь к платформенным расширениям.
Стабильность и развитие языка
Если язык не меняется, ему трудно отвечать на появление нового аппаратного обеспечения, способов разработки и требований безопасности. Если он меняется слишком быстро, пользователям приходится постоянно исправлять код, а библиотекам и инструментам трудно стабилизироваться.
Для стабильного развития языка необходимы:
- ясные цели проектирования;
- документированная процедура изменений;
- публичные предложения и рассмотрение;
- опыт реальных реализаций и применения;
- тесты на соответствие;
- политика обратной совместимости;
- процедуры устаревания и миграции;
- автоматизированные средства преобразования кода;
- постоянная синхронизация спецификации и реализаций;
- ответственное управление.
Важно не только добавлять функции, но и принимать решения не добавлять их. После стандартизации синтаксис, семантику и API приходится поддерживать долгое время, поэтому необходимо учитывать согласованность языка и стоимость сопровождения, а не только краткосрочное удобство.
Стандартизация также не является процессом замораживания языка и прекращения изменений. Она поддерживает согласованную общую основу, рассматривая новые требования и уравновешивая стабильность существующих программ с возможностью дальнейшего развития.
Таким образом, проектирование и стандартизация языка программирования являются одновременно технической работой по определению синтаксиса и возможностей и созданием правил и структуры принятия решений, которые позволяют реализациям, пользователям, библиотекам и инструментам развиваться совместно в течение длительного времени. Успех языка определяется не количеством отдельных возможностей, а согласованностью принципов проектирования, точностью спецификации, качеством реализаций, политикой совместимости и устойчивостью связей внутри экосистемы.
Области применения
Языки программирования используются практически во всех областях, связанных с вычислительной техникой: от операционных систем и управления устройствами до веб-служб, мобильных приложений, игр, научных вычислений, искусственного интеллекта и автоматизации рабочих процессов. Однако не каждый язык одинаково хорошо подходит для каждой области. Реальный диапазон применения зависит от того, насколько модель выполнения, система типов, управление памятью, поддерживаемые платформы, стандартная библиотека, инструменты разработки и существующая экосистема соответствуют требованиям конкретной сферы.
Область применения языка определяется не только его собственными возможностями. Один и тот же язык может получить широкое распространение в конкретной сфере благодаря сочетанию с определённой операционной системой, фреймворком, средой выполнения или промышленной экосистемой. Kotlin является языком общего назначения, но тесно связан со средой разработки Android. Swift занимает центральное место в разработке приложений для платформ Apple. JavaScript играет важную роль во фронтенд-разработке благодаря своему положению стандартного языка выполнения в веб-браузерах. Google ориентирует разработку Android преимущественно на Kotlin, а Apple предоставляет Swift как язык для своих платформ, включая iOS, iPadOS, macOS, tvOS и watchOS.
Одна программная система также может состоять из компонентов, написанных на нескольких языках. Игровой движок и критичные к производительности подсистемы могут быть реализованы на C++, а игровые правила и инструменты — на C#, Lua или языке визуальных сценариев. Веб-служба может использовать JavaScript или TypeScript в браузере, Java, C#, Go, Python или Rust на сервере и SQL для запросов к базе данных. Система машинного обучения может описывать модель и процесс обучения на Python, а вычислительно тяжёлые операции реализовывать на C++, CUDA или языках для других ускорителей.
Поэтому при рассмотрении областей применения следует не закреплять язык за одной сферой, а совместно анализировать требования этой сферы и роль, которую язык выполняет в системе.
Системное программное обеспечение
Системное программное обеспечение — это программное обеспечение, предоставляющее основу для выполнения других программ, включая операционные системы, драйверы устройств, компиляторы, среды выполнения, файловые системы, движки баз данных, сетевые стеки и виртуальные машины.
Для системного программного обеспечения важны следующие свойства:
- возможность точно управлять размещением памяти и временем её жизни;
- непосредственный доступ к интерфейсам операционной системы и аппаратного обеспечения;
- предсказуемость времени выполнения и использования памяти;
- возможность работать без отдельной крупной среды выполнения;
- поддержка нескольких архитектур CPU и ABI;
- надёжный вызов внешних библиотек и API операционной системы;
- сохранение производительности и совместимости в коде, поддерживаемом длительное время.
C (язык программирования) долгое время используется в операционных системах, системных библиотеках и встраиваемом программном обеспечении благодаря указателям, структурам, явному управлению памятью, сравнительно простой модели выполнения и широко поддерживаемому ABI. Стандарт C определяет представление программ на C, их синтаксис, ограничения, семантические правила и требования, которым должны соответствовать реализации.
C++ сохраняет близкий к C контроль над аппаратным обеспечением и производительность нативного выполнения, одновременно предоставляя классы, шаблоны, обобщённое программирование, управление ресурсами на основе областей видимости и стандартную библиотеку. Он применяется в браузерах, игровых движках, базах данных, компиляторах и крупных настольных программах, где одновременно требуются высокая производительность и сложные абстракции.
Rust — это язык системного программирования, стремящийся обеспечить безопасность памяти без сборщика мусора посредством владения, заимствования и проверки времён жизни. К основным областям его применения относятся программы командной строки, сетевые службы, WebAssembly и встраиваемые устройства.
Системное программирование не требует написания всего кода на низкоуровневом языке. Ядро операционной системы и границы взаимодействия с аппаратным обеспечением могут быть реализованы на нативном языке, а средства управления, установщики, системы сборки и автоматизация тестирования — на Python или языках командной оболочки. Для повышения надёжности всей системы и эффективности разработки часто сочетаются языки разных уровней.
Встраиваемые системы
Встраиваемая система — это компьютерная система, встроенная в определённое устройство или изделие и выполняющая ограниченный набор задач. К ним относятся микроконтроллеры и датчики, автомобильные контроллеры, промышленное оборудование, бытовая техника, медицинские устройства, беспилотные аппараты, средства связи и устройства Интернета вещей.
Встраиваемые среды значительно различаются. Одни устройства используют 8-битные микроконтроллеры с несколькими килобайтами памяти, тогда как другие располагают многоядерными процессорами, операционной системой и несколькими гигабайтами памяти. Документация Embedded Rust также отмечает, что область встраиваемого программирования простирается от небольших 8-битных MCU до многоядерных систем и что ограничения зависят от конкретной цели.
Во встраиваемом программном обеспечении важны:
- ограниченный объём RAM и хранилища;
- низкое энергопотребление;
- управление аппаратными регистрами и периферийными устройствами;
- обработка прерываний;
- реакция в реальном времени;
- выполнение без операционной системы в среде bare-metal;
- долговременная стабильность;
- удалённое обновление и восстановление после сбоев;
- безопасность и защищённость;
- кросс-компиляция для целевого устройства.
C и C++ используются благодаря широко поддерживаемым компиляторам, библиотекам для устройств и SDK производителей аппаратного обеспечения. Rust также поддерживает среды no_std, в которых отсутствуют операционная система и стандартная библиотека, позволяя применять владение и безопасность типов на микроконтроллерах. Embedded Rust Book официально описывает использование Rust на bare-metal-микроконтроллерах.
Во встраиваемых системах конкурентность проявляется не только через потоки операционной системы. Обработчики прерываний могут запускаться в непредсказуемый момент, задачи реального времени — чередоваться, а несколько ядер — независимо выполнять код. В таких структурах необходимо совместно учитывать общее состояние, атомарный доступ, приоритеты прерываний и планирование реального времени.
Системы реального времени
Система реального времени — это не просто быстро работающая система, а система, результат которой считается корректным только при предоставлении в установленный срок. К характерным областям относятся промышленное управление, робототехника, авиационные и автомобильные системы, средства связи, медицинское оборудование и обработка звука.
В системах реального времени важнее средней скорости обработки являются верхняя граница худшего времени выполнения и задержки, приоритеты задач, время реакции на прерывания, конкуренция за ресурсы и предсказуемость планирования. Сборка мусора, способная произвольно приостановить выполнение на длительное время, или компиляция во время выполнения могут не подходить для некоторых сред реального времени. Поэтому применяются ограниченные среды выполнения и предсказуемые способы управления памятью.
Ada развивалась с поддержкой задач, исполнения в реальном времени, строгой системы типов и систем, требующих высокой целостности. Приложение стандарта Ada для высоконадёжных систем рассматривает ограничения и среды выполнения, предназначенные для систем с повышенными требованиями к надёжности и безопасности.
Все компоненты программы реального времени также не обязательно пишутся на одном языке. Управляющий цикл с жёсткими временными ограничениями может быть реализован на C, C++, Ada или Rust, тогда как средства настройки, анализ данных, моделирование и пользовательский интерфейс — на другом высокоуровневом языке.
Настольные приложения
Настольное приложение — это программа, непосредственно выполняющаяся в операционной системе персонального компьютера, такой как Windows, macOS или Linux. К ним относятся текстовые редакторы, графические средства, среды разработки, мессенджеры, мультимедийные программы, системы проектирования, файловые менеджеры и корпоративные клиенты.
Для настольных приложений важны следующие требования:
- интеграция с оконной системой, вводом, файлами и уведомлениями операционной системы;
- сложный графический пользовательский интерфейс;
- доступ к локальным файлам и устройствам;
- автономная работа;
- установка и автоматическое обновление;
- поддержка нескольких операционных систем;
- доступность и интернационализация;
- стабильность при длительной работе;
- нативная производительность и контролируемое использование памяти.
Нативные приложения Windows могут использовать C++, C# и .NET, а приложения macOS — Swift, Objective-C и C++. Для многоплатформенных программ применяются C++ с Qt, многоплатформенные фреймворки на C#, Java и JVM, настольные среды выполнения на основе JavaScript и инструменты GUI для Rust.
Python также применяется в настольных приложениях, сценариях, веб-службах и научных вычислениях и может сочетать библиотеки GUI с нативными модулями расширения.
Даже если весь пользовательский интерфейс настольной программы написан на одном языке, внутренний вычислительный движок может состоять из библиотек на другом языке. Видеоредакторы и средства трёхмерного моделирования часто отделяют слой интерфейса и сценариев от высокопроизводительного слоя обработки мультимедиа и графики.
Мобильные приложения
Мобильные приложения выполняются на смартфонах, планшетах и носимых устройствах и должны учитывать сенсорный ввод, датчики, камеры, уведомления, геолокацию, ограниченный запас энергии и правила фонового выполнения.
На Android основными языками являются Kotlin и Java. Google преимущественно рекомендует Kotlin для разработки Android и поддерживает его интеграцию с существующим кодом Java.
Корутины Kotlin применяются для выражения асинхронных операций, которые не должны блокировать поток пользовательского интерфейса, например сетевых запросов или доступа к базе данных. Официальная документация Android описывает корутины как рекомендуемый способ асинхронного программирования и выделяет структурированную конкурентность и распространение отмены как основные свойства.
На платформах Apple используются Swift и Objective-C. Apple предоставляет Swift как язык для всех своих платформ, а SwiftUI является фреймворком для построения пользовательских интерфейсов на Swift для различных устройств Apple.
В многоплатформенной мобильной разработке применяются фреймворки, позволяющие поддерживать Android и iOS из одной кодовой базы. Kotlin Multiplatform позволяет совместно использовать бизнес-логику между Android и iOS и при необходимости сочетать её с отдельными платформенными или общими интерфейсами.
Мобильные игры и высокопроизводительные мультимедийные программы также могут связывать общий движок на C++ с платформенным слоем на Kotlin и Java либо Swift и Objective-C.
Веб-фронтенд
Веб-фронтенд — это область программного обеспечения, выполняющегося в браузере и отвечающего за визуальное представление документа, пользовательский ввод, сетевое взаимодействие, анимацию и мультимедиа.
Основные веб-технологии имеют разные роли:
- HTML выражает структуру и семантику документа;
- CSS определяет его внешний вид и расположение;
- JavaScript обрабатывает пользовательский ввод, динамические изменения состояния и использование браузерных API.
JavaScript — это язык программирования, добавляющий веб-страницам динамическое обновление содержимого, графику, управление мультимедиа и интерактивность. Браузер предоставляет JavaScript различные Web API для работы с объектной моделью документа, сетью, хранилищем, графикой и звуком.
TypeScript добавляет к JavaScript статические аннотации и анализ типов и преобразуется в обычный JavaScript. В крупных фронтенд-программах типы свойств компонентов, сетевых данных и структур управления состоянием помогают навигации по коду, рефакторингу и обнаружению ошибок.
Веб-фронтенд ограничен средой браузера. Доступ к файлам, устройствам и сети подчиняется модели безопасности браузера и разрешениям пользователя. Также необходимо учитывать поддержку возможностей, производительность и доступность в различных браузерах.
WebAssembly можно использовать как целевой формат для выполнения в веб-среде низкоуровневого кода, созданного из C, C++, Rust и других языков. Вычислительно интенсивные модули обработки изображений и звука, игр, моделирования и сжатия могут реализовываться на WebAssembly, тогда как JavaScript отвечает за пользовательский интерфейс браузера и подключение API.
Веб-бэкенд и серверные приложения
Веб-бэкенд — это серверное программное обеспечение, принимающее запросы клиентов и выполняющее аутентификацию, бизнес-логику, доступ к базе данных, обработку файлов и взаимодействие с внешними службами.
Для веб-серверов и API важны:
- обработка большого числа одновременных соединений;
- сетевой ввод-вывод и асинхронное выполнение;
- соединение с базами данных;
- аутентификация и управление правами;
- проверка входных данных;
- горизонтальное масштабирование;
- наблюдаемость и журналирование;
- обновления безопасности;
- надёжное развёртывание;
- интеграция с существующими службами и библиотеками.
Java и Kotlin могут использовать серверные фреймворки и корпоративную экосистему JVM, а C# — веб- и облачные фреймворки .NET. Python применяется для быстрого создания веб-API, административных служб и ориентированных на данные бэкендов благодаря широкому набору библиотек. JavaScript и TypeScript через серверные среды выполнения, такие как Node.js, позволяют совместно использовать язык и структуры данных в браузере и на сервере.
Go применяется в сетевых службах и облачных инструментах благодаря горутинам, каналам, стандартным библиотекам для сети и HTTP и распространению в виде нативных программ. Rust может использоваться в службах, которым требуются асинхронные среды выполнения, безопасность памяти и нативная производительность. Сетевые службы также рассматриваются как одна из основных областей применения Rust.
Выбор серверного языка определяется не только производительностью обработки одного запроса. Существенны скорость разработки, реагирование на сбои, зрелость библиотек, среда развёртывания, использование памяти, время запуска, опыт обслуживающей команды и совместимость с существующими системами.
Облачные вычисления
В среде облачных вычислений программы выполняются на виртуальных машинах, в контейнерах, бессерверных средах, управляемых службах данных и распределённой инфраструктуре нескольких регионов.
Облачное программное обеспечение может относиться к следующим областям:
- управление инфраструктурой и выделение ресурсов;
- управление контейнерами и кластерами;
- микросервисы и API;
- бессерверные функции;
- сбор журналов и метрик;
- конвейеры обработки данных;
- автоматизация развёртывания и эксплуатации;
- политики безопасности и управление доступом.
Go применяется в инструментах облачной инфраструктуры благодаря самостоятельным исполняемым файлам, сравнительно быстрому запуску, конкурентности и сетевым возможностям. Java и C# широко используются в корпоративных системах и управляемых облачных службах, а Python — для автоматизации через облачные API, обработки данных и бессерверных функций. Rust и C++ могут применяться в прокси, движках хранения и сетевых компонентах, которым необходимы высокая пропускная способность и низкая задержка.
Облачные программы зависят от характеристик среды выполнения языка и эксплуатационных затрат. Потребление памяти и время запуска влияют на стоимость бессерверных сред и массового развёртывания контейнеров. Сборка мусора и JIT-компиляция также проявляют разные свойства в длительно работающих службах и кратковременно выполняющихся функциях.
Распределённые системы
Распределённая система состоит из нескольких компьютеров с независимой памятью и возможностью отдельных отказов, которые через сеть образуют единую службу.
В распределённых системах важны следующие проблемы:
- задержки и разрывы сети;
- дублирование и изменение порядка сообщений;
- частичные отказы, затрагивающие только отдельные узлы;
- репликация и согласованность данных;
- повторное выполнение задач;
- неопределённость времени и порядка;
- обнаружение отказов;
- поиск служб;
- балансировка нагрузки;
- взаимодействие служб разных версий.
Если язык предоставляет передачу сообщений, акторов, асинхронные функции, лёгкие задачи и структурированную конкурентность, некоторые части таких систем можно выражать более непосредственно. Однако языковые возможности сами по себе не решают автоматически задачи распределённого консенсуса, согласованности данных и разделения сети. Способ хранения данных, протоколы связи, политика повторных попыток и модель отказов должны проектироваться отдельно.
Для распределённых служб используются Java, C#, Go, Rust, Erlang, Elixir, Scala, C++, Python и другие языки. Языки семейства Erlang строятся вокруг изолированных процессов, передачи сообщений и структур надзора, тогда как языки на основе JVM и .NET используют зрелые экосистемы серверных и наблюдательных инструментов.
Программы командной строки
Программа командной строки выполняется в терминале или командной оболочке, принимает аргументы и стандартный ввод и выводит результат в стандартный вывод или файл. К ним относятся компиляторы, системы контроля версий, преобразователи файлов, менеджеры пакетов, средства сборки и программы системного администрирования.
Для программ командной строки важны:
- быстрое время запуска;
- ясные аргументы и справка;
- возможность сочетания через каналы и файлы;
- удобные для автоматизации коды завершения;
- распространение на нескольких операционных системах;
- небольшой исполняемый файл и малое число зависимостей;
- понятные сообщения об ошибках и журналы;
- автодополнение командной оболочки.
Языки оболочки подходят для быстрого соединения команд операционной системы и конвейеров, но по мере роста программы становятся заметны ограничения типов, структур данных и обработки ошибок. Python может обрабатывать аргументы командной строки с помощью argparse и других средств стандартной библиотеки, а Rust официально рассматривает разработку CLI как область применения, основанную на нативных исполняемых файлах и экосистеме пакетов.
Go, C++ и C# также используются для самостоятельных административных средств и инструментов разработки. Подходящий язык зависит от процесса установки, целевых платформ, времени запуска и необходимости нативных библиотек.
Сценарии и автоматизация работы
Сценарий — это сравнительно небольшая программа для автоматизации обработки файлов, запуска программ, преобразования данных, настройки системы и повторяющихся задач.
В автоматизации важны:
- обработка строк и файлов;
- запуск команд операционной системы;
- работа с JSON, XML и CSV;
- HTTP-запросы и вызовы API;
- быстрое изменение и выполнение;
- связь с внешними программами;
- обработка ошибок и журналирование;
- поддержка нескольких операционных систем.
Языки оболочки подходят для непосредственного управления файлами, процессами и конвейерами. Python позволяет строить средства работы с файлами, сетью, сериализацией, тестированием и командной строкой с помощью стандартной библиотеки и внешних пакетов. PowerShell предоставляет объектный конвейер и среду администрирования Windows, а JavaScript применяется в инструментах на Node.js и автоматизации веб-разработки.
Для короткой одноразовой задачи может быть достаточно простого сценария. Однако если автоматизация отвечает за развёртывание, безопасность или важные данные, ей необходимы проверки типов, тестирование, обработка ошибок, управление правами и сопровождение на уровне обычного приложения.
DevOps и автоматизация сборки
В DevOps и средах доставки программного обеспечения автоматизируются сборка, тестирование, упаковка и развёртывание исходного кода.
Используемые программы включают:
- сценарии сборки;
- конвейеры непрерывной интеграции;
- создание образов контейнеров;
- описание инфраструктуры;
- средства развёртывания и отката;
- автоматизацию тестирования;
- сбор журналов и состояния;
- настройку среды разработки.
Для написания средств автоматизации применяются оболочки, Python, Go, JavaScript, Ruby и PowerShell. Некоторые системы сборки и инфраструктуры предоставляют декларативные форматы, такие как YAML, или собственные предметно-ориентированные языки.
В автоматизированной среде важны воспроизводимость, идемпотентность, управление секретами, остановка и восстановление при отказе и журналы выполнения. Язык следует выбирать не только по краткости синтаксиса, но и по способности точно обнаруживать сбои и контролировать различия окружения.
Базы данных и управление данными
В области баз данных различаются языки, на которых реализуются движки хранения, и языки запросов к сохранённым данным.
Внутренний движок системы управления базами данных выполняет следующие задачи:
- управление страницами диска и памяти;
- поддержку индексов;
- оптимизацию запросов;
- выполнение транзакций;
- управление конкурентностью;
- журналирование и восстановление;
- репликацию;
- сетевые протоколы.
Эти компоненты требуют точного управления производительностью, памятью и конкурентностью, поэтому могут реализовываться на C, C++, Rust или Java.
Пользователь описывает условия и форму требуемых данных на декларативном языке, таком как SQL. Фактический выбор индексов, порядок соединений и способ параллельной обработки определяет оптимизатор запросов базы данных.
Приложения вокруг базы данных могут использовать Java, C#, Python, JavaScript, Go, PHP и другие языки для выполнения SQL и преобразования результатов в объекты предметной области. Можно применять средства объектно-реляционного отображения, однако они не способны полностью скрыть транзакции, индексы и планы выполнения запросов базы данных.
Инженерия данных
Инженерия данных занимается сбором, очисткой и преобразованием данных из различных источников в форму, пригодную для анализа и использования в службах.
Основные задачи включают:
- сбор данных;
- пакетную и потоковую обработку;
- преобразование схем;
- проверку качества данных;
- перемещение между хранилищами;
- планирование задач;
- распределённую обработку;
- управление метаданными;
- построение аналитических моделей данных.
Python и SQL используются для организации процедур обработки и аналитических систем, а Java и Scala — в распределённых системах обработки данных на JVM. Azure Databricks также поддерживает несколько языков программирования для инженерии данных и анализа, причём доступные области и ограничения различаются в зависимости от языка.
В инженерии данных на общую производительность часто сильнее влияют распределённый исполнительный движок, формат хранения, стоимость сети, разбиение данных на пакеты и секции, чем скорость отдельных операций языка. Задача, написанная на высокоуровневом языке, может внутренне выполняться на JVM, C++ или с помощью нативных векторных операций.
Анализ данных и статистика
Анализ данных занимается исследованием и обобщением данных, поиском статистических зависимостей и визуализацией.
Python и R (язык программирования) предоставляют экосистемы для интерактивного анализа, статистики и визуализации данных. SQL используется для выбора и агрегации данных, а Julia, MATLAB и другие языки и среды — для численных вычислений и исследований.
Для аналитических языков важны:
- обработка массивов и табличных данных;
- статистические функции;
- визуализация;
- интерактивная среда выполнения;
- записные книжки;
- интеграция с научными библиотеками;
- вызов высокопроизводительных библиотек на C, C++ и Fortran;
- воспроизводимость результатов анализа.
Интерактивный аналитический код подходит для быстрых экспериментов, однако перед развёртыванием в производственной системе необходимо усилить проверку входных данных, тестирование, фиксацию версий, контроль ресурсов и обработку отказов.
Искусственный интеллект и машинное обучение
В областях искусственного интеллекта и машинного обучения выполняются подготовка данных, определение моделей, обучение, оценка, вывод и развёртывание сервисов.
Python применяется как высокоуровневый интерфейс для исследования моделей, экспериментов и построения конвейеров обучения благодаря краткому синтаксису и развитой экосистеме обработки данных и численных вычислений. Однако крупномасштабные матричные операции, обработка тензоров и ядра GPU обычно выполняются реализациями на C++, CUDA или других нативных языках. Код Python может управлять такими высокопроизводительными библиотеками и средами выполнения.
CUDA — это платформа параллельных вычислений и модель программирования для ускорения вычислительно интенсивных программ с помощью GPU. Официальная документация NVIDIA CUDA описывает применение вычислений на GPU в глубоком обучении, научных расчётах и высокопроизводительных вычислениях, а также программирование GPU с использованием C++ и Python.
Система машинного обучения может состоять из нескольких уровней:
Эксперименты и определение модели
Python
Фреймворк и исполнительный движок
C++ / Rust / Java
Вычисления на GPU
CUDA / языки шейдеров и ядер
Обработка данных
Python / SQL / Scala / Java
API сервиса
Python / Go / Java / C# / Rust
Вывод на мобильных и периферийных устройствах
C++ / Java / Kotlin / Swift
Выбор языка непосредственно не определяет точность модели. Более прямое влияние оказывают доступные фреймворки и аппаратные ускорители, среда обработки данных, целевая платформа развёртывания и эксплуатационные инструменты.
Научные вычисления
Научные вычисления — это область компьютерного расчёта математических моделей в физике, химии, метеорологии, гидродинамике, астрономии, биологии, численном анализе и инженерном моделировании.
Для научных вычислений важны:
- точность и воспроизводимость вычислений с плавающей точкой;
- операции над массивами и матрицами;
- численные алгоритмы;
- параллельная обработка;
- работа с большими объёмами памяти и хранилищ;
- совместимость с существующим исследовательским кодом;
- сопровождение кода на протяжении нескольких поколений;
- взаимодействие с другими языками и библиотеками.
Fortran исторически применяется в научных и инженерных областях, где вычислительная техника начала использоваться особенно рано. Fortran-lang относит к основным областям применения Fortran прогнозирование погоды и океана, вычислительную гидродинамику, прикладную математику, статистику и финансы.
В научных вычислениях также применяются C, C++, Python, Julia, MATLAB и другие языки. Python может использоваться как высокоуровневый слой для организации исследовательских процедур и анализа данных, передавая фактические вычисления библиотекам на Fortran, C или C++.
В научном программном обеспечении проверенные алгоритмы, форматы данных и накопленный за десятилетия код могут быть ценнее современного синтаксиса. При внедрении нового языка необходимо учитывать взаимодействие с существующим кодом, проверку результатов и различия в поведении вычислений с плавающей точкой.
Высокопроизводительные вычисления
Высокопроизводительные вычисления — это область выполнения крупномасштабных расчётов с помощью суперкомпьютеров, вычислительных кластеров, многоядерных CPU и GPU. Они применяются в прогнозировании погоды, физических симуляциях, анализе геномов, гидродинамике, исследовании материалов, финансовых расчётах и обучении моделей машинного обучения.
В высокопроизводительных вычислениях могут одновременно использоваться следующие уровни параллелизма:
- векторизация на уровне инструкций CPU;
- многопоточность внутри одного узла;
- передача сообщений между узлами;
- разгрузка вычислений на GPU и ускорители;
- гетерогенное выполнение с объединением нескольких вычислительных устройств.
Традиционно применяются Fortran, C и C++, тогда как Python и Julia могут использоваться для высокоуровневой организации задач и анализа. В вычислениях на GPU используются CUDA C++, языки ядер и библиотеки ускорителей. Модель программирования CUDA позволяет писать код для выполнения на GPU и управлять памятью и структурой параллельного исполнения.
Производительность высокопроизводительных вычислений определяется не только теоретической скоростью языка. Важны шаблоны доступа к памяти, размещение данных, объём коммуникации, векторизация, параллельная структура алгоритма и оптимизация под целевое устройство.
Компьютерная графика
В области компьютерной графики реализуются рендеринг двухмерных и трёхмерных сцен, обработка изображений, моделирование, анимация, симуляция и пользовательские интерфейсы.
Графическое программное обеспечение обычно состоит из следующих уровней:
- графический API и абстракция устройства;
- движок рендеринга;
- шейдеры;
- обработка сцен и ресурсов;
- редакторы и инструменты;
- интеграция анимации и физики;
- сценарии содержимого.
Движки рендеринга и графические драйверы часто используют C и C++ для непосредственного доступа к аппаратному обеспечению и управления производительностью. Rust также может применяться для оболочек графических API и движков рендеринга.
Шейдеры пишутся на предметно-ориентированных языках, предназначенных для вершинных, пиксельных и вычислительных этапов GPU. К характерным примерам относятся GLSL, HLSL и Metal Shading Language. Код шейдеров должен учитывать модель массово-параллельного выполнения и структуру памяти, отличающиеся от обычных программ для CPU.
В средствах графического производства и конвейерах Python применяется для преобразования ресурсов, автоматизации повторяющихся операций, подключаемых модулей и производственных инструментов. Поэтому одна графическая программа может одновременно использовать нативный рендерер, язык шейдеров и сценарии для инструментов.
Разработка игр
Разработка игр — это комплексная область, объединяющая рендеринг в реальном времени, физику, звук, сеть, искусственный интеллект, ввод, пользовательский интерфейс, инструменты и создание содержимого.
Разные языки могут использоваться для различных задач:
- игровой движок и рендерер;
- системы физики и звука;
- код игрового процесса;
- сервер и сеть;
- редакторы и производственные инструменты;
- конвейеры сборки и ресурсов;
- шейдеры;
- модификации и пользовательские сценарии.
C++ применяется в крупных игровых движках благодаря высокой производительности, контролю памяти и поддержке консолей и графических API. Unreal Engine предоставляет фреймворк и API для разработчиков C++ и позволяет совместно использовать C++ и визуальные сценарии Blueprint для построения игровых систем и содержимого.
C# применяется в некоторых игровых движках и инструментах благодаря управляемой среде выполнения и высокой производительности разработки. Lua, Python и собственные сценарные языки могут встраиваться в нативный движок для реализации игровых правил, содержимого, модификаций и автоматизации редактора.
Выбор языка в игре определяется не только максимальной частотой кадров. Код реального времени и код инструментов имеют разные требования. Для критических путей рендеринга и физики важны низкая задержка и предсказуемое использование памяти, тогда как для редакторов и конвейеров сборки могут быть важнее скорость разработки, возможности пользовательского интерфейса и работа с файлами.
Обработка звука и мультимедиа
Звуковые и видеопрограммы обрабатывают большие потоки данных в реальном времени или вне его. К ним относятся кодеки, микшеры, обработка сигналов, преобразование видео, редактирование, потоковая передача и системы воспроизведения.
В звуке реального времени пропуск срока обработки может вызвать прерывание воспроизведения, поэтому особенно важны предсказуемое выполнение и ограничение выделения памяти. C и C++ применяются в DSP, кодеках и звуковых движках, а Rust может использоваться в нативной обработке мультимедиа, где важна безопасность памяти.
Программы создания мультимедиа могут сочетать нативный движок обработки со слоем автоматизации и подключаемых модулей на Python или JavaScript. Для обработки видео с помощью GPU могут применяться CUDA и вычислительные шейдеры.
Сетевое программирование
Сетевое программирование занимается реализацией сокетов, протоколов, серверов и клиентов, прокси, сетевого оборудования и коммуникационных библиотек.
Для сетевых программ важны:
- большое число одновременных соединений;
- асинхронный ввод-вывод;
- тайм-ауты и отмена;
- границы сообщений и сериализация;
- протоколы безопасности;
- частичная передача и получение данных;
- закрытие и повторное установление соединений;
- управление буферами и памятью;
- различия сетевых API операционных систем.
C и C++ используются в сетевых стеках операционных систем, высокопроизводительных прокси и реализациях протоколов. Go подходит для разработки служб с помощью горутин и стандартной сетевой библиотеки, а Rust позволяет использовать безопасность памяти и асинхронные библиотеки. Java, C#, Python и JavaScript также применяются в серверных и клиентских приложениях.
Асинхронный синтаксис языка способен уменьшить сложность сетевой программы, однако состояния протокола, ограничения времени, проверка входных данных и защита от атак должны проектироваться отдельно.
Кибербезопасность
В области кибербезопасности разрабатываются защитные продукты, анализируются уязвимости, выполняются сетевой мониторинг, реализация криптографии, анализ вредоносного кода, цифровая криминалистика и автоматизация.
Применяемые языки зависят от задачи:
- C и C++ используются для работы с операционными системами, двоичными файлами, сетями и низкоуровневыми средствами безопасности;
- Rust может применяться для инструментов безопасности и системных компонентов, где важна безопасность памяти;
- Python используется для анализа, автоматизации, интеграции с API безопасности и тестовых инструментов;
- ассемблер необходим для анализа исполняемых файлов и инструкций процессора;
- JavaScript применяется при исследовании веб-безопасности и браузерных сред;
- SQL связан с безопасностью баз данных и анализом запросов.
Использование безопасного языка не делает всю программу автоматически защищённой. Аутентификация, полномочия, криптографические протоколы, проверка входных данных, управление ключами и безопасность цепочки поставок являются отдельными задачами, не сводящимися к безопасности памяти языка.
Криптография
Программное обеспечение в области криптографии реализует алгоритмы шифрования, цифровой подписи, хеширования, обмена ключами и генерации случайных чисел.
При реализации криптографии важны:
- точные целочисленные и побитовые операции;
- постоянное время выполнения;
- управление секретными данными в памяти;
- изменения кода вследствие оптимизации;
- аппаратные инструкции ускорения;
- использование проверенных алгоритмов;
- строгая проверка внешних данных;
- независимый аудит реализации.
Высокопроизводительные криптографические библиотеки могут совместно использовать C, C++, Rust и ассемблер. Приложения на Python, Java, C# и других языках обычно не реализуют криптографические алгоритмы самостоятельно, а вызывают проверенные стандартные библиотеки и платформенные API.
Финансовые и деловые системы
Финансовые, страховые, государственные, торговые, бухгалтерские и корпоративные системы обрабатывают транзакции, клиентов, договоры, платежи, бухгалтерские книги и отчёты.
В этой области важны:
- точность данных;
- транзакции и аудиторские журналы;
- долговременная совместимость;
- управление доступом и соблюдение нормативных требований;
- обработка больших объёмов данных;
- интеграция с существующими системами;
- восстановление после сбоев;
- точные денежные расчёты.
COBOL продолжает использоваться в существующих банковских, страховых, государственных и крупных корпоративных системах. Java, C# и SQL применяются в современных корпоративных серверах и бизнес-приложениях, а JavaScript и TypeScript — в пользовательских интерфейсах.
В высокочастотной торговле и расчётах рисков, где особенно важны задержка и пропускная способность, могут использоваться C++, Java, Rust, Fortran и другие языки. Fortran-lang также относит статистику и финансы к областям научных и численных вычислений.
Замена языка в финансовой системе не является простой задачей преобразования кода. Необходимо точно сохранить существующие правила транзакций, форматы данных, нормативные требования и поведение, накопленное за десятилетия, поэтому важны постепенная модернизация и взаимодействие старых и новых компонентов.
Системы, критичные для безопасности
Система, критичная для безопасности — это система, отказ программного обеспечения которой может привести к гибели или ущербу здоровью людей, вреду окружающей среде либо крупным материальным потерям. К таким системам относятся авиация, железнодорожный транспорт, автомобили, медицинские устройства, промышленное управление и атомная энергетика.
В этой области требуются более строгие процессы разработки и проверки, чем в обычных приложениях:
- трассируемость требований;
- правила написания кода;
- статический анализ;
- измерение покрытия тестами;
- формальная верификация;
- анализ худшего времени выполнения;
- ограничение динамического использования памяти;
- сертифицируемые инструменты;
- управление изменениями и аудиторские записи.
Ada может использоваться в приложениях высокой целостности и системах, требующих сертификации по стандартам безопасности. Её строгая система типов и ограниченные профили выполнения предназначены для уменьшения вероятности ошибок и поддержки верификации.
C и C++ также применяются в автомобильной, авиационной и промышленной областях, но могут использоваться вместе с безопасными подмножествами, стандартами кодирования, статическим анализом и ограничениями среды выполнения. Процесс разработки, инструменты верификации, аппаратное обеспечение и общий проект безопасности системы могут быть важнее выбора конкретного языка.
Робототехника и автономные системы
Робототехника и автономные системы объединяют данные датчиков, определение положения, планирование, управление, обработку изображений и взаимодействие с аппаратным обеспечением.
Основные роли языков можно разделить следующим образом:
- C и C++ используются для управления в реальном времени, обработки данных датчиков и робототехнического промежуточного программного обеспечения;
- Python применяется для исследовательских алгоритмов, экспериментов, инструментов и подключения моделей машинного обучения;
- CUDA и языки программирования GPU ускоряют обработку изображений и нейронных сетей;
- Rust может использоваться для безопасного взаимодействия с устройствами и реализации системных компонентов;
- MATLAB, Simulink и связанные с ними среды применяются для моделирования систем управления и симуляции.
Робототехнические программы непосредственно взаимодействуют с физическим миром, поэтому ошибка может привести не только к завершению программы, но и к опасному поведению устройства. Ограничения реального времени, неопределённость данных датчиков, аварийную остановку и переход в безопасное состояние необходимо рассматривать вместе с системным проектированием, выходящим за рамки самого языка.
Интернет вещей
Интернет вещей — это система, в которой датчики и устройства соединены сетью, собирают данные и выполняют удалённые команды.
Одна система IoT может состоять из нескольких уровней:
Прошивка устройства
C / C++ / Rust
Шлюз
C++ / Rust / Go / Python
Обработка сообщений и сервер
Go / Java / C# / Python / JavaScript
Хранение и анализ данных
SQL / Python / Scala
Интерфейс управления
JavaScript / TypeScript
Для устройств важны ограниченный объём памяти и энергии, возможные разрывы сети и надёжность удалённых обновлений. На серверной стороне существенны подключение большого числа устройств, обработка сообщений, аутентификация устройств и анализ данных.
Устройства IoT могут быть установлены в эксплуатации на длительное время и оставаться труднодоступными для непосредственного обслуживания. Поэтому безопасные обновления, управление ключами, шифрование связи и восстановление после сбоев не менее важны, чем выбор языка.
Блокчейн и распределённые реестры
Блокчейн-системы и распределённые реестры состоят из программ узлов, протоколов консенсуса, сетевого взаимодействия, хранилища и смарт-контрактов.
Для реализации узлов могут использоваться C++, Go, Rust, Java и другие языки, а для смарт-контрактов — предметно-ориентированные или ограниченные универсальные языки конкретной платформы.
Смарт-контракты трудно изменять после развёртывания, а стоимость выполнения и изменения состояния могут быть непосредственно связаны с экономической ценностью. Поэтому важны:
- детерминированное выполнение;
- обработка целых чисел и денежных величин;
- проверка полномочий;
- повторный вход и отношения вызовов;
- ограничения использования ресурсов;
- атомарность изменения состояния;
- формальная верификация и статический анализ;
- политика обновления.
Дефект, который в обычной серверной программе можно исправить и восстановить, в смарт-контракте способен привести к необратимой потере активов. Поэтому ограничения и проверяемость могут иметь больший приоритет, чем общая выразительность языка.
Образование
Языки программирования используются для обучения информатике, разработке программного обеспечения, логическому мышлению и решению задач.
При выборе учебного языка учитываются:
- простота и согласованность синтаксиса;
- качество сообщений об ошибках;
- удобство установки и запуска;
- интерактивные упражнения;
- визуализация и отладка;
- соответствие изучаемым понятиям;
- связь с языками, которые будут изучаться позднее;
- наличие учебников и образовательных материалов;
- доступность на разных операционных системах и устройствах.
Python может применяться в начальном обучении, а также в курсах по данным и естественным наукам благодаря сравнительно краткому синтаксису и интерактивной среде выполнения. Swift Playgrounds предоставляет обучение программированию и разработке приложений с помощью настоящего кода Swift и интерактивных уроков.
Scratch и другие блочные языки уменьшают нагрузку, связанную с синтаксическими ошибками, и позволяют визуально изучать события, циклы, условия и переменные. В университетском обучении информатике в зависимости от целей могут использоваться Python, Java, C, C++, Haskell, Scheme и другие языки.
Язык, простой для начала обучения, не обязательно лучше всего подходит для всех углублённых тем. Для изучения памяти и операционных систем полезны C и ассемблер, а для функций и теории типов — Haskell или языки семейства ML.
Исследования языков программирования
Сами языки программирования также являются предметом исследований. Изучаются новые синтаксические конструкции, системы типов, семантика, оптимизация компиляторов, верификация программ и инструменты разработки.
К основным направлениям относятся:
- теория типов;
- формальная семантика;
- компиляторы;
- статический анализ;
- верификация программ;
- безопасность памяти;
- модели конкурентности;
- предметно-ориентированные языки;
- синтез программ и генерация кода;
- взаимодействие языка с пользователем;
- сообщения об ошибках и инструменты разработки;
- языки квантового программирования;
- вероятностные языки программирования.
Исследовательские языки позволяют экспериментировать с возможностями, которые трудно сразу предоставить в промышленном универсальном языке. Успешно проверенные идеи впоследствии могут быть включены в существующие языки, библиотеки и компиляторы.
Однако успешная исследовательская возможность не обязательно сразу подходит для промышленного языка. Необходимо учитывать стоимость реализации, время компиляции, диагностику ошибок, совместимость с существующим кодом и затраты пользователей на обучение.
Предметно-ориентированные языки
Предметно-ориентированный язык создаётся для непосредственного выражения понятий и правил конкретной предметной области.
К характерным примерам относятся:
- SQL выражает запросы к реляционным данным;
- регулярные выражения описывают шаблоны строк;
- языки шейдеров выражают графические вычисления на GPU;
- языки описания аппаратуры представляют цифровые схемы;
- языки сборки описывают зависимости файлов и задач;
- языки конфигурации выражают настройки программ и систем;
- языки статистических моделей представляют распределения вероятностей и отношения наблюдений;
- языки политик выражают полномочия и правила доступа.
Предметно-ориентированный язык может предоставлять более краткое и ясное выражение задач своей области, чем универсальный язык, а исполнительная система способна использовать предметные знания для оптимизации и проверки. В то же время область применения такого языка ограничена и может требовать отдельных инструментов и обучения.
Предметно-ориентированный язык может существовать как самостоятельный файловый формат либо быть встроен в библиотеку, операторы или макросы универсального языка.
Проектирование аппаратного обеспечения
Для проектирования цифровых схем и процессоров требуется вычислительная модель, отличающаяся от последовательного выполнения программ. Различные части схемы работают одновременно, поэтому необходимо выражать сигналы, такты, задержки и структуру физических ресурсов.
Языки описания аппаратуры, такие как Verilog и VHDL, описывают регистры, комбинационные схемы, конечные автоматы и соединение модулей. Созданный проект можно моделировать или синтезировать в схему FPGA либо ASIC.
Также применяются языки построения аппаратуры поверх универсальных языков, таких как Scala, Python и Haskell. Они используют абстракции и метапрограммирование базового языка, а затем создают промежуточное представление описания аппаратуры.
Шейдеры и программирование GPU
GPU предназначены для параллельного выполнения сходных операций большим количеством вычислительных блоков. Шейдеры и ядра GPU ориентированы на эту модель выполнения.
Графические шейдеры выполняют следующие задачи:
- преобразование координат вершин;
- вычисление материалов поверхностей и освещения;
- выборку текстур;
- вычисление цвета пикселей;
- постобработку;
- трассировку лучей;
- универсальные параллельные вычисления.
В языках GPU необходимо учитывать потоки и рабочие группы, иерархию памяти, синхронизацию и расхождение ветвлений. Даже при синтаксисе, сходном с языками CPU, модель выполнения значительно отличается.
CUDA предоставляет модель использования вычислений GPU из C++ и Python, а графические API могут использовать шейдерные представления HLSL, GLSL, SPIR-V и другие.
Квантовые вычисления
В области квантовых вычислений применяются языки и SDK для представления кубитов, квантовых вентилей, измерений и классического управления.
Квантовая программа обычно имеет смешанную структуру, в которой классическая программа создаёт квантовую схему, запускает её и обрабатывает результаты измерений.
Классическая программа-хост
↓
Создание квантовой схемы
↓
Симулятор или квантовое устройство
↓
Результаты измерения
↓
Классический анализ
Квантовое состояние нельзя свободно копировать подобно обычному значению, а измерение влияет на это состояние. Поэтому исследуются линейные типы, системы эффектов и языки, основанные на квантовых схемах.
В настоящее время квантовое программирование применяется не только с реальными устройствами, но и в симуляторах, образовании и исследованиях алгоритмов. Поддержка аппаратного SDK, компилятора и формата квантовых схем часто важнее выбора одного конкретного языка.
WebAssembly за пределами браузера
WebAssembly может применяться не только в браузерах, но и на серверах, в подключаемых модулях, песочницах и переносимых средах выполнения модулей.
К сценариям применения относятся:
- выполнение модулей из нативных языков в браузере;
- безопасно изолированные подключаемые модули;
- бессерверные функции;
- формат распространения для нескольких операционных систем;
- системы модификаций игр и приложений;
- периферийные вычисления;
- общий бэкенд для сред выполнения языков.
WebAssembly отделяет основную вычислительную модель от файловых и сетевых возможностей среды-хоста. Для доступа реальной программы к функциям операционной системы требуется интерфейс среды, например браузерный API или WASI.
Встраивание и расширение языков
Приложение может содержать интерпретатор или виртуальную машину другого языка программирования. Это позволяет пользователю расширять поведение программы и автоматизировать повторяющиеся задачи.
К основным областям применения относятся:
- игровые сценарии;
- автоматизация редакторов;
- модификации;
- подключаемые модули;
- конфигурация и правила;
- сценарии симуляции;
- конвейеры обработки данных.
В качестве встроенных языков могут использоваться Lua, Python, JavaScript и собственные предметно-ориентированные языки. Программа-хост может предоставлять сценариям только безопасный API и ограничивать время выполнения, память, доступ к файлам и сети.
Встраивание языка повышает гибкость, но требует решения вопросов времени жизни объектов хоста, передачи ошибок, отладки, совместимости версий и изоляции недоверенного кода.
Критерии выбора языка
Выбор языка для определённой области трудно свести только к популярности или скорости выполнения.
К основным критериям относятся:
- поддержка целевых операционных систем и аппаратного обеспечения;
- наличие необходимых библиотек и фреймворков;
- соответствие требованиям производительности и задержки;
- соответствие ограничениям памяти и энергопотребления;
- выполнение требований безопасности и защищённости;
- возможность взаимодействовать с существующим кодом и данными;
- доступность разработчиков и специалистов по сопровождению;
- достаточность инструментов сборки, тестирования и отладки;
- наличие долгосрочной поддержки и политики совместимости;
- простота развёртывания и обновления;
- соответствие требованиям лицензирования, стандартов и сертификации;
- соответствие масштабу проекта и срокам разработки.
Выбор языка не обязательно должен означать использование одного языка для всех частей проекта. Компоненты с разными требованиями можно разделить и связать стабильными интерфейсами, применяя для каждой области более подходящий язык.
Однако с увеличением числа языков растут и дополнительные затраты:
- усложняются система сборки и набор инструментов;
- на языковых границах необходимо преобразовывать данные;
- приходится согласовывать разные среды выполнения и модели управления памятью;
- усложняются отладка и профилирование;
- разработчикам необходимо понимать несколько экосистем;
- требуется управлять ABI и совместимостью версий.
Поэтому многоязычная архитектура уместна, когда границы компонентов ясны, а преимущества добавления языка превышают стоимость взаимодействия.
Отношения между областями и языками
Связь языка программирования с областью применения не является неизменной. С появлением новых сред выполнения, библиотек и компиляторов существующий язык может расширяться в новые сферы. Kotlin вырос из языка JVM в средство разработки Android и многоплатформенных приложений, а Swift расширяется от приложений Apple к серверным и встраиваемым системам. Официальные материалы Apple о Swift также называют среди областей его применения не только мобильные приложения, но и серверы и встраиваемые прошивки.
И наоборот, широкое распространение языка в определённой области не обязательно означает его безусловное техническое превосходство. Сильное влияние оказывают существующий код и библиотеки, учебные материалы, инструменты разработки, поддержка производителей аппаратного обеспечения и рынок специалистов. Проверенные десятилетиями программы, стандарты и возможности взаимодействия могут быть ценнее новых синтаксических средств.
Применение языков развивалось скорее в направлении сотрудничества между языками разных уровней абстракции, чем замены всех областей одним языком. В одной системе могут совместно использоваться системный и производительный базовый слой, управляемый прикладной слой, сценарии автоматизации, языки запросов к данным и предметно-ориентированные языки GPU.
Таким образом, понимание областей применения языков программирования не ограничивается перечислением того, что можно создать на конкретном языке. Оно предполагает анализ вычислительной модели, требований безопасности и производительности, среды выполнения и экосистемы каждой области, а также выбор языка, среды выполнения, библиотек и инструментов в соответствии с назначением всей системы.
Сравнение языков программирования
Сравнение языков программирования не заключается в определении языка, превосходящего остальные во всех отношениях. Каждый язык проектируется для разных задач, сред выполнения, требований безопасности, способов разработки и существующих экосистем, поэтому невозможно установить общее превосходство всех языков по одному критерию. Язык, подходящий для ядра операционной системы, не обязательно лучше подходит для анализа данных, а язык, позволяющий быстро написать короткий сценарий автоматизации, не обязательно предоставляет те же преимущества в критическом коде рендерера реального времени.
Фактические характеристики языка определяются не только синтаксисом. Совместно действуют спецификация, система типов, компилятор или интерпретатор, среда выполнения, сборщик мусора, стандартная библиотека, экосистема пакетов, инструменты разработки и целевая платформа. Даже один язык может различаться по производительности, потреблению памяти, времени запуска и способу развёртывания в зависимости от реализации, параметров компиляции, библиотек и среды исполнения.
Поэтому языки программирования следует сравнивать по нескольким отдельным критериям.
| Критерий сравнения | Основной вопрос |
|---|---|
| Абстракция и управление | насколько непосредственно можно управлять аппаратным обеспечением и памятью |
| Модель выполнения | используются ли нативный код, виртуальная машина или интерпретатор |
| Система типов | когда проверяются ошибки и как представляются типы |
| Управление памятью | кто управляет временем жизни объектов и ресурсов |
| Безопасность | какие виды ошибок предотвращают язык и реализация |
| Производительность | насколько можно управлять пропускной способностью, задержками и использованием памяти |
| Конкурентность | как представляются несколько задач и общее состояние |
| Переносимость | может ли программа выполняться на разных операционных системах и архитектурах |
| Взаимодействие | насколько легко использовать существующие библиотеки и другие языки |
| Производительность разработки | насколько удобно писать, изменять, тестировать код и обнаруживать ошибки |
| Инструменты и экосистема | достаточно ли компиляторов, отладчиков, пакетов и фреймворков |
| Стабильность и совместимость | сохраняют ли язык и платформа существующий код в течение длительного времени |
Ограничения сравнения
При сравнении языков необходимо различать язык и его реализацию. Python является языком, а CPython — его основной реализацией. Java является языком, а JVM — средой выполнения, которой могут пользоваться Java и другие языки. C# является языком и обычно применяется вместе с .NET, однако спецификация C# не требует, чтобы каждая соответствующая реализация обязательно была построена поверх CLI или .NET. Официальный справочник Python также описывает основную грамматику и семантику языка, не закрепляя все детали реализации за CPython.
Разделение на «компилируемые» и «интерпретируемые» языки также не следует считать абсолютным свойством самого языка. Java обычно переводит исходный код в машинно-независимый байт-код, который затем выполняется JVM, а JVM может интерпретировать или JIT-компилировать его. Реализации Python также могут использовать внутренний байт-код, виртуальную машину, JIT-компиляцию и другие стратегии. Спецификация Java описывает машинно-независимый байт-код как обычный результат компиляции, но это не означает, что язык Java закреплён только за одной конкретной стратегией выполнения.
О производительности также нельзя судить только по названию языка. Даже в одном языке результат зависит от алгоритма и структур данных, компилятора, параметров оптимизации, состояния прогрева среды выполнения, настроек сборщика мусора, архитектуры CPU и памяти. Время запуска короткой программы командной строки, максимальная пропускная способность длительно работающего сервера и худшая задержка звуковой системы реального времени являются разными показателями.
Поэтому сравнение языков должно отвечать не столько на вопрос «что быстрее», сколько на вопрос «на каком уровне возникают определённые затраты и насколько их можно прогнозировать и контролировать».
Уровень абстракции и управление аппаратным обеспечением
Языки программирования различаются по степени раскрытия конкретной структуры аппаратного обеспечения.
C (язык программирования) позволяет непосредственно работать с указателями, адресами памяти, целочисленным представлением, размещением структур и ручным управлением памятью. Стандарт C определяет представление и смысл программ и одновременно стремится обеспечить переносимость и эффективное выполнение на различных вычислительных системах. Однако точный размер и выравнивание типов, а также результаты некоторых операций могут зависеть от реализации и целевой среды.
C++ предоставляет низкоуровневый контроль, сходный с C, и добавляет классы, шаблоны, обобщённое программирование, семантику значений и управление ресурсами на основе областей видимости. Одним из важных принципов C++ является принцип абстракций с нулевой стоимостью: неиспользуемая возможность не должна создавать затрат, а правильно реализованная используемая абстракция не должна требовать лишних расходов по сравнению с непосредственно написанным низкоуровневым кодом.
Rust также подходит для системных программ, которым нужны указатели, управление размещением памяти, нативные ABI и непосредственное управление ресурсами. При этом безопасный код ограничивается правилами владения, заимствования и времён жизни. Владение в Rust позволяет компилятору проверять управление памятью без сборщика мусора во время выполнения.
Go (язык программирования) является универсальным языком, созданным с учётом системного программирования, но передаёт среде выполнения больше деталей управления памятью и размещением объектов, чем C или Rust. Спецификация Go описывает его как статически типизированный язык со сборкой мусора, явной поддержкой конкурентности и организацией программ на основе пакетов.
Java, C#, Python и JavaScript обычно не предоставляют непосредственных операций над указателями и размещением объектов в памяти, а используют объектную модель виртуальной машины или среды выполнения. Такая абстракция способна повысить безопасность памяти и переносимость, но делает непосредственное управление размещением объектов, моментами сборки и стоимостью динамических вызовов относительно более сложным.
Уровень абстракции нельзя оценивать только как высокий или низкий. Больший контроль аппаратного обеспечения позволяет применять специальные оптимизации и непосредственно работать с устройствами, но требует самостоятельно учитывать платформенные различия и ошибки памяти. Когда среда выполнения управляет большим числом деталей, разработчику легче сосредоточиться на прикладной логике, однако это может стать ограничением в областях, где важны реакция в реальном времени и конкретное размещение памяти.
Модель выполнения
Обычные модели выполнения основных языков программирования можно сравнить следующим образом.
| Язык | Обычная модель выполнения | Основные характеристики |
|---|---|---|
| C (язык программирования) | предварительная нативная компиляция | небольшая среда выполнения, непосредственный ABI и системный доступ |
| C++ | предварительная нативная компиляция | высокоуровневые абстракции и оптимизация под целевую платформу |
| Rust | предварительная нативная компиляция | создание нативного кода после проверки владения |
| Go (язык программирования) | предварительная нативная компиляция | сборщик мусора и встроенная среда выполнения |
| Java | байт-код JVM с JIT- или AOT-компиляцией | виртуальная машина и управляемая среда выполнения |
| C# | общий промежуточный язык и JIT- или AOT-компиляция .NET | CLR и управляемая среда выполнения |
| Python | интерпретация байт-кода, JIT или нативные расширения в зависимости от реализации | динамическое выполнение и интерактивная разработка |
| JavaScript | интерпретатор и JIT в браузерных и серверных движках | тесная связь со средой-хостом |
| TypeScript | преобразование в JavaScript с последующим выполнением в среде JavaScript | сведения о типах обычно удаляются до выполнения |
| Kotlin | JVM, JavaScript, нативный код, WebAssembly и другие цели | целевые бэкенды и взаимодействие |
| Swift | нативная компиляция | типы-значения и автоматическое управление памятью |
| Fortran | предварительная нативная компиляция | оптимизация численных и массивных вычислений и параллельные возможности |
Эта таблица показывает характерные реализации, но не ограничивает все возможные способы реализации языка. Код C и C++ можно компилировать в WebAssembly или другое промежуточное представление, а программы Java и .NET — распространять с использованием AOT-компиляции. Вычислительно тяжёлые части программ Python также могут передаваться библиотекам на C, C++, Fortran или GPU.
Стандарт JavaScript, ECMAScript, определяет вычислительные правила языка и операции над объектами, но передаёт внешний ввод и вывод среде-хосту, например браузеру или серверной среде выполнения. Поэтому реальные возможности JavaScript являются результатом сочетания языка ECMAScript с браузерными Web API или API Node.js.
TypeScript не заменяет среду выполнения JavaScript. Он статически анализирует формы и поведение значений JavaScript, удаляет или преобразует аннотации типов и некоторые синтаксические конструкции и создаёт JavaScript. Система типов TypeScript преимущественно структурная, поскольку она предназначена для моделирования способов использования объектов JavaScript.
Статическая и динамическая типизация
Статически типизированные языки преимущественно проверяют отношения типов переменных, выражений и вызовов функций до выполнения программы. К ним относятся C, C++, Rust, Go, Java, C#, Kotlin, Swift и Fortran. Спецификация Java определяет Java как статически типизированный язык, в котором тип каждой переменной и выражения известен во время компиляции.
В динамически типизированном языке значения обладают типами во время выполнения, а допустимость операции определяется по фактическому типу значения. Характерными примерами являются Python и JavaScript. В модели данных Python все данные представляются объектами или отношениями между объектами, причём каждый объект обладает идентичностью, типом и значением.
Различие между статической и динамической типизацией не сводится к тому, указывается ли имя типа в объявлении переменной. Rust, Kotlin, Swift и C# способны выводить типы локальных переменных, но при этом выполняют статическую проверку. В Python и JavaScript, напротив, также существуют проверки типов и объектов во время выполнения.
Основные преимущества статической типизации:
- некоторые ошибки можно обнаружить до запуска программы;
- контракты функций и модулей можно выражать через типы;
- IDE легче поддерживать навигацию по коду, автодополнение и рефакторинг;
- компилятор может использовать сведения о значениях и вызовах для оптимизации;
- легче определять влияние изменений в крупной кодовой базе.
Основные преимущества динамической типизации:
- удобно работать с данными и объектами, форма которых изменяется во время выполнения;
- небольшие программы и экспериментальный код можно писать быстрее;
- абстракции можно строить без сложных обобщений и объявлений типов;
- проще непосредственно использовать рефлексию, метапрограммирование и динамическую загрузку.
Даже в статически типизированном языке остаются логические ошибки, не выраженные системой типов, а динамически типизированный язык может обеспечивать высокую надёжность благодаря достаточному тестированию и статическому анализу. Момент проверки типов является только одним из факторов качества программного обеспечения.
Номинальная и структурная типизация
Классы и интерфейсы Java, C#, Kotlin и Swift преимущественно используют номинальную систему типов, основанную на объявленных именах и явных отношениях наследования и реализации. Даже если два класса содержат одинаковые поля и методы, при отсутствии явной связи они обычно не считаются одним типом.
TypeScript преимущественно использует структурную типизацию, сравнивая форму членов объекта. Объект, содержащий все необходимые поля и методы, может быть совместим с определённым типом, даже если он отдельно не объявлял реализацию соответствующего интерфейса.
Интерфейсы Go также имеют структурный характер: тип считается реализующим интерфейс, если удовлетворяет требуемому набору методов, без явного объявления implements. Однако полная система типов Go и структурные объектные типы TypeScript построены по разным правилам, поэтому их нельзя считать полностью одинаковыми.
Номинальные типы удобны для ясного разграничения понятий предметной области и границ API. Структурные типы позволяют использовать существующий объект в требуемом интерфейсе без его изменения, уменьшая связанность. При этом необходимо учитывать возможность непреднамеренной совместимости значений, случайно имеющих одинаковую структуру.
Обобщения и обобщённое программирование
Обобщения C++ и Rust обычно позволяют создавать во время компиляции код, специализированный под конкретные типы. Это поддерживает статическую диспетчеризацию и оптимизацию для каждого типа, но при большом количестве комбинаций способно увеличивать время компиляции и размер кода.
Обобщения Java преимущественно используют стирание типов, позволяя нескольким экземплярам типов совместно использовать общий байт-код. Обобщения .NET сохраняют сведения о типах во время выполнения, а стратегия совместного использования и специализации кода может различаться для типов-значений и ссылочных типов. Система типов C# объединяет типы-значения и ссылочные типы в структуре, где любое значение в конечном счёте может рассматриваться как object.
Параметры типов Go используют ограничения в форме интерфейсов, определяющие допустимые типы и операции. Rust применяет границы трейтов, C++ — шаблоны и concepts, а языки семейства Haskell выражают обобщение преимущественно через параметрический полиморфизм и классы типов.
Даже при похожем синтаксисе обобщений их реализация, ограничения типов и наличие сведений о типах во время выполнения различаются. Поэтому отличаются и проектирование библиотек, и характеристики производительности.
Способы управления памятью
Способ управления памятью является одним из важнейших критериев сравнения языков программирования.
Ручное управление памятью
В C программист самостоятельно определяет моменты выделения и освобождения памяти. Это позволяет точно управлять ресурсами, но требует предотвращать утечки, обращения после освобождения, двойное освобождение, выход за границы буфера и другие ошибки.
C++ позволяет использовать ручное выделение памяти, но также предоставляет RAII, умные указатели и контейнеры, связывающие время жизни объекта с областью видимости. В правильно спроектированном коде C++ можно сократить непосредственное использование new и delete и автоматически освобождать ресурсы при выходе объекта из области. Однако необработанные указатели и ручное управление ресурсами остаются доступными, поэтому общая безопасность зависит от архитектуры и правил кода.
Управление на основе владения
Rust назначает каждому значению владельца и освобождает ресурс, когда владелец выходит из области видимости. Ссылки не владеют значением, а заимствуют его, тогда как компилятор проверяет отношения изменяемых и неизменяемых ссылок и их времена жизни. Такая структура предназначена для обеспечения безопасности памяти без сборщика мусора.
Преимущество Rust состоит в возможности ограничить многие ошибки памяти и потенциальные гонки данных во время компиляции. Однако при представлении сложных общих структур, самоссылочных объектов и времён жизни в асинхронном коде необходимо явно учитывать владение и времена жизни, а для некоторых низкоуровневых операций может потребоваться unsafe.
Сборка мусора
Основные реализации Go, Java, C#, Python и JavaScript предоставляют автоматическое управление памятью. Среда выполнения обнаруживает объекты, к которым программа больше не может обратиться, и освобождает их, поэтому обычные объекты не требуется удалять вручную.
Сборщик мусора .NET размещает объекты в управляемой куче и освобождает те, которые недостижимы от корней, таких как стек, статические поля и регистры CPU. Он также может уплотнять объекты, уменьшая фрагментацию памяти.
Сборка мусора снижает нагрузку, связанную с управлением временем жизни объектов, но может создавать паузы, дополнительное потребление памяти и сложность среды выполнения. Современные сборщики используют поколенческие, конкурентные и инкрементальные алгоритмы, уменьшая эти затраты, однако для систем со строгими требованиями к худшей задержке необходимы дополнительные меры.
Подсчёт ссылок
Swift управляет временем жизни экземпляров классов с помощью автоматического подсчёта ссылок и активно использует типы-значения, включая структуры и перечисления. Документация Swift описывает обычные типы, такие как массивы и словари, как типы-значения и рассматривает инициализацию, проверку границ и безопасность памяти как важные элементы устройства языка.
Подсчёт ссылок позволяет относительно легко понять момент освобождения объекта, поскольку оно происходит после исчезновения последней сильной ссылки. Однако необходимо отдельно предотвращать циклы сильных ссылок, а операции увеличения и уменьшения счётчиков создают дополнительные затраты.
Безопасность памяти
Безопасность памяти — это свойство, гарантирующее, что программа обращается только к допустимой памяти с правильным типом и в пределах времени жизни объекта.
C и C++ разрешают непосредственный доступ к памяти и её переинтерпретацию, предоставляя очень широкий контроль. Поэтому большое значение имеют безопасные подмножества языка, правила написания кода, статический анализ, проверки времени выполнения и проектирование библиотек. Неверные указатели и ошибки границ способны приводить к неопределённому поведению.
Rust проверяет правила владения и заимствования в безопасном коде, но предоставляет unsafe для внешних функций, необработанных указателей и некоторых системных операций. unsafe не отключает все проверки компилятора, а разрешает отдельные дополнительные действия, такие как разыменование необработанного указателя, возлагая ответственность за их безопасность на программиста.
Управляемые языки, такие как Java, C# и Go, ограничивают произвольные операции с указателями в обычном коде и проверяют границы массивов и типы объектов. Большую часть кода C# можно писать как проверяемый безопасный код, однако в явно отмеченной области unsafe разрешаются указатели и прямой доступ к памяти.
Swift также предоставляет безопасное поведение по умолчанию, но для взаимодействия с C и специальных оптимизаций содержит семейство UnsafePointer. При использовании таких указателей разработчик самостоятельно отвечает за автоматическое управление памятью и некоторые гарантии типов и времён жизни.
Поэтому вместо абсолютного деления на «безопасные» и «небезопасные» языки следует сравнивать, какие ошибки предотвращаются в обычном коде, какие механизмы обхода существуют и насколько ясно можно построить границу безопасности.
Семантика значений и семантика ссылок
Языки различаются и по базовому способу копирования и совместного использования значений.
Структуры и обычные объекты C и C++ по умолчанию могут копироваться как значения. Указатели и ссылки позволяют нескольким частям программы обращаться к одному объекту. В C++ способы копирования и перемещения владения можно определять с помощью конструкторов копирования и перемещения и операторов присваивания.
Rust различает некопируемые типы, которые по умолчанию перемещаются, и типы, допускающие явное копирование. При присваивании или передаче функции значения, владеющего ресурсом кучи, его владение может перейти к новому имени, после чего использование прежнего имени ограничивается.
В Java, Python и JavaScript переменная объекта обычно содержит ссылку на объект. При присваивании копируется ссылка, а не весь объект. Некоторые значения, такие как целые числа и строки, могут быть неизменяемыми или подчиняться отдельным правилам значений, однако изменения пользовательского объекта могут наблюдаться через несколько ссылок.
C# явно различает типы-значения и ссылочные типы. Структуры и перечисления являются типами-значениями, и при присваивании или передаче аргумента копируется их значение. Классы являются ссылочными типами.
Swift рассматривает структуры и перечисления как типы-значения, а экземпляры классов — как ссылочные типы. Стандартные коллекции предоставляют семантику значений, но могут внутренне использовать оптимизацию копирования при записи.
Семантика значений может уменьшать непреднамеренное распространение изменений объекта и быть удобной для параллельной обработки. Семантика ссылок позволяет нескольким компонентам совместно использовать одно состояние без копирования крупного объекта, но требует управления псевдонимами и синхронизацией.
Объектно-ориентированные модели
Java и C# поддерживают объектно-ориентированное программирование на основе классов, интерфейсов, наследования, виртуальных методов и номинальных типов. C# также предоставляет структуры и записи как типы-значения и рассматривает все типы в единой системе типов.
C++ предоставляет классы, множественное наследование и виртуальные функции, одновременно поддерживая семантику значений и статический полиморфизм на основе шаблонов. Объектно-ориентированное программирование является только одной из нескольких парадигм C++, и не каждая абстракция обязана использовать наследование и виртуальные вызовы.
Rust не предоставляет традиционного наследования классов. Методы определяются для структур и перечислений, а общие действия абстрагируются через трейты. Можно использовать динамические объекты трейтов, но обычно они сочетаются с обобщениями и статической диспетчеризацией.
Go также не предоставляет наследование классов, используя структуры, методы, интерфейсы и композицию. Типу не требуется явно объявлять реализацию интерфейса: достаточно предоставить необходимый набор методов.
JavaScript использует прототипную объектную модель. Несмотря на наличие синтаксиса class, отношения наследования объектов основаны на цепочках прототипов. Спецификация ECMAScript определяет объект как набор свойств и рассматривает поведение объектов, функций и прототипов как центральную часть семантики языка.
Нельзя считать одну объектную модель безусловно лучшей. Классы и интерфейсы могут быть удобны для стабильных номинальных контрактов API, а типы-значения и типы-суммы — для моделей, ориентированных на данные. В средах, где важны динамическое расширение объектов и метапрограммирование, преимущества могут иметь прототипы и динамические свойства.
Обработка ошибок
Языки различаются и способами передачи ошибок.
C обычно использует возвращаемые значения, коды ошибок и глобальное состояние ошибки. Обработка ошибок остаётся явной, однако ошибка может быть проигнорирована, если вызывающий код не проверит возвращаемое значение.
C++, Java, C#, Python, JavaScript, Kotlin и Swift предоставляют механизмы обработки исключений. Исключение может автоматически распространяться через несколько уровней вызовов, однако по обычному возвращаемому типу функции бывает трудно определить, какие именно сбои способны возникнуть.
Rust преимущественно представляет восстанавливаемые ошибки с помощью типов-сумм, таких как Result<T, E>, а для невосстанавливаемых ошибок может использовать панику. Go в основном возвращает ошибки как обычные значения. Оба подхода явно показывают возможность ошибки в вызывающем коде, но требуют языковых конструкций и принятых шаблонов, уменьшающих объём повторяющегося кода передачи ошибок.
Swift поддерживает throws, конструкции do, try и catch, опциональные значения и типы результатов. Kotlin взаимодействует с Java, но не требует обработки проверяемых исключений через собственную систему типов.
Способ обработки ошибок является не просто вопросом синтаксических предпочтений. Он влияет на API библиотек, освобождение ресурсов, асинхронное выполнение, границы транзакций и стратегию восстановления программы.
Безопасность null
В таких языках, как Java и ранние версии C#, null входит в большинство ссылочных типов, поэтому необходимо отдельно проверять, содержит ли значение реальный объект. Современный C# позволяет различать string и string? на уровне исходного кода с помощью анализа nullable reference types. Однако эти обозначения не создают отдельных типов времени выполнения, а используются статическим анализом компилятора.
Kotlin синтаксически различает ненулевые и nullable-типы. В String в обычных условиях нельзя присвоить null, тогда как String? допускает его. Однако при взаимодействии с Java объявления Java могут не содержать достаточных сведений о nullability, поэтому необходимо учитывать платформенные типы и внешние аннотации.
Rust не включает null в обычные ссылки и представляет отсутствие значения через Option<T>. Swift также использует опциональные типы, отделяя возможность отсутствия значения на уровне типа.
В Python и JavaScript во время динамического выполнения применяются такие значения, как None, null и undefined. Часть связанных с ними ошибок можно выявлять статическими анализаторами и TypeScript.
Средства null-безопасности значительно сокращают область возникновения ошибок нулевых ссылок, но они всё ещё возможны на границах внешнего кода, во время инициализации, при принудительных утверждениях и использовании небезопасных интерфейсов.
Конкурентность и параллелизм
Поддержка конкурентности в значительной степени определяется не только синтаксисом языка, но также средой выполнения и стандартной библиотекой.
C и C++ используют потоки операционной системы, атомарные операции, блокировки и модель памяти. Они обеспечивают низкоуровневый контроль, но требуют самостоятельно управлять корректностью общего состояния, времён жизни и синхронизации.
Rust также поддерживает потоки, блокировки, атомарные операции и асинхронное выполнение. Владение и отношения типов, такие как Send и Sync, могут статически ограничивать возможность перемещения и совместного использования определённых значений между потоками. Конкурентность с общей памятью всё ещё требует правильного проектирования блокировок и атомарности, но некоторые гонки данных могут предотвращаться на этапе проверки типов.
Go предоставляет горутины и каналы как центральную модель конкурентности языка и среды выполнения. Горутина является более лёгкой единицей выполнения, чем поток операционной системы, а канал используется для передачи значений и синхронизации между горутинами. Однако Go также позволяет использовать общую память и блокировки, поэтому каналы не являются единственным механизмом конкурентности.
Java и C# предоставляют пулы потоков, future, task, async и await, конкурентные коллекции и управляемые среды выполнения. Многолетние серверные экосистемы JVM и .NET включают инструменты и библиотеки, необходимые для построения крупных конкурентных служб.
Корутины Kotlin сочетают языковые приостанавливаемые функции с библиотечной моделью выполнения. Планировщики, каналы, flow и другие средства преимущественно предоставляются библиотекой kotlinx.coroutines. Контекст выполнения корутины содержит такие элементы, как задача и диспетчер.
JavaScript обычно обрабатывает асинхронный ввод-вывод с помощью цикла событий, Promise и async·await. Браузерные и серверные среды выполнения могут предоставлять фактическое параллельное или асинхронное выполнение через очереди задач, Web Worker и нативные подсистемы ввода-вывода.
Краткость средств конкурентности не устраняет автоматически гонки, взаимные блокировки, отмену и частичные отказы. Более важным является проектирование общего состояния, времени жизни задач, распространения ошибок и обратного давления.
Производительность и предсказуемость
Производительность включает несколько различных характеристик: пропускную способность, задержку, время запуска, использование памяти, размер двоичного файла и энергопотребление.
C, C++ и Rust обычно используют нативный машинный код и небольшую границу среды выполнения, позволяя подробно управлять размещением памяти, выделением ресурсов и вызовами. Это полезно в областях, где необходимы низкоуровневая оптимизация и предсказуемые задержки. Однако высокая производительность не гарантируется автоматически: неподходящие структуры данных и шаблоны доступа к памяти могут значительно ухудшить её в любом языке.
Go создаёт нативный код, но включает сборщик мусора и планировщик горутин. Это упрощает построение серверных и сетевых программ, однако необходимо учитывать стоимость сборки и планирования.
JIT-компиляторы Java и C# могут оптимизировать код на основе фактических сведений о выполнении. В длительно работающей программе возможны динамическое встраивание функций, специализация типов и другие оптимизации. При этом на производительность влияют запуск и прогрев, кэш кода и сборка мусора.
Динамические языки, такие как Python и JavaScript, могут нести дополнительные затраты на обработку типов значений и операций во время выполнения. JIT-движок JavaScript способен специализировать повторяющиеся типы и формы объектов, но при нарушении предположений требуется деоптимизация. В Python широко применяется подход, при котором массовые операции выполняются библиотеками на C, C++ или Fortran, уменьшая повторяющуюся стоимость интерпретатора.
Fortran развивался вокруг массивов, численных операций и научных вычислений и поддерживает не только императивный и процедурный стили, но также массивные, объектно-ориентированные и функциональные средства. Структура языка и его компиляторы могут быть важным выбором для численных и параллельных программ.
При сравнении производительности необходимо измерять реальную реализацию и конкретную нагрузку, а не ориентироваться на общий образ языка. Обработка строк, ожидание базы данных, численные массивы, выделение памяти и сетевой ввод-вывод предъявляют различные требования к языкам и средам выполнения.
Время компиляции и запуска
Время компиляции C и C++ может значительно зависеть от включения заголовков, инстанцирования шаблонов и уровня оптимизации. Особенно сложные шаблоны C++ и межпроцедурная оптимизация способны требовать значительных затрат.
В Rust время компиляции также может быть существенным для крупных проектов из-за мономорфизации обобщений, проверки типов и владения и оптимизации LLVM.
Быстрая компиляция и простые отношения зависимостей являлись важными направлениями проектирования Go. Зависимости организуются посредством структуры пакетов.
Java и C# способны сравнительно быстро создавать из исходного кода байт-код или промежуточный язык, но затем во время выполнения может добавляться JIT-компиляция. AOT и предварительно подготовленные образы среды выполнения позволяют изменять характеристики запуска.
Python и JavaScript позволяют немедленно запускать программу без отдельной полной нативной сборки, что удобно для разработки и коротких сценариев. Однако время запуска может включать загрузку модулей, инициализацию среды выполнения и подготовку JIT.
Для коротких программ командной строки и бессерверных функций особенно важно время запуска, тогда как для длительно работающего сервера пропускная способность в устойчивом состоянии может быть важнее первоначального прогрева.
Способы развёртывания
Программы на C, C++, Rust и Go обычно распространяются как нативные исполняемые файлы для определённой операционной системы и архитектуры CPU. Статическая компоновка уменьшает число внешних зависимостей, но требует учитывать размер файла, лицензирование и политику системных библиотек. Динамическая компоновка позволяет совместно использовать библиотеки, но зависит от ABI и версий библиотек целевой системы.
Программе Java необходимы JVM и файлы классов или JAR. Среду выполнения можно включить в пакет программы либо использовать JVM, установленную в целевой системе. C# и .NET также поддерживают несколько способов, включая зависимое от фреймворка развёртывание, самодостаточные пакеты и Native AOT.
Для программы Python необходимо подготовить интерпретатор и пакеты. Её можно собрать в самостоятельный пакет, но нативные расширения и платформенные зависимости могут усложнять управление размером и совместимостью.
Во фронтенде JavaScript роль среды выполнения обычно выполняет браузер, а сборщик модулей объединяет код в файлы для распространения. Серверному JavaScript требуется среда выполнения, например Node.js, и необходимые пакеты.
Способ развёртывания Swift и Kotlin зависит от целевой платформы. Kotlin/JVM использует JVM, а Kotlin/Native способен создавать нативные двоичные файлы. Swift может создавать нативные программы для платформ Apple, Linux, Windows и других поддерживаемых систем.
Платформенная переносимость
Реализации C и C++ существуют почти для всех основных операционных систем и процессоров, а доступ к аппаратным и системным API сравнительно прост. Однако программе может потребоваться самостоятельно учитывать различия в размерах типов, ABI, файловых системах и системных интерфейсах.
Rust и Go также поддерживают основные настольные, серверные и некоторые встраиваемые архитектуры. Уровень поддержки зависит не только от спецификации языка, но и от зрелости бэкенда компилятора, стандартной библиотеки и платформенной цепочки инструментов.
Java использует общий уровень выполнения JVM и проектировалась так, чтобы один формат классов мог выполняться в нескольких системах. Однако при использовании нативных библиотек, графики и интеграции с операционной системой платформенные различия снова становятся заметны.
C# и .NET поддерживают несколько операционных систем, но отдельные UI-фреймворки и системные API могут оставаться платформенными.
JavaScript обладает очень широкой общей средой выполнения в виде браузера, однако необходимо учитывать различия поддержки возможностей и API среды-хоста между браузерами.
Даже если Kotlin, Swift или другой язык поддерживает несколько платформ, доступные на каждой из них фреймворки и библиотеки могут различаться. Многоплатформенность языка и возможность запуска всего приложения без изменений на каждой платформе — разные свойства.
Взаимодействие с внешними языками
C ABI сравнительно прост и поддерживается множеством языков и операционных систем, поэтому широко применяется как общая граница межъязыкового взаимодействия. C++, Rust, Swift, Python, Java, C# и другие языки могут предоставлять интерфейсы для вызова функций C или вызова собственного кода из C.
ABI C++ сильнее зависит от классов, декорирования имён, исключений, типов стандартной библиотеки и версии компилятора. Поэтому стабильные внешние интерфейсы часто строятся на C ABI и непрозрачных дескрипторах.
Взаимодействие с Java является одной из основных целей проектирования Kotlin. Большую часть кода Java можно непосредственно использовать из Kotlin, а код Kotlin можно предоставлять Java.
C# использует общую систему типов и промежуточный язык с другими языками .NET и поддерживает P/Invoke и другие средства взаимодействия с нативным кодом.
Python подключает нативные библиотеки через C API и модули расширения. Это позволяет использовать Python как высокоуровневый управляющий слой в научных вычислениях и машинном обучении, передавая вычислительно интенсивные части нативным библиотекам.
Swift поддерживает взаимодействие с Objective-C и C, а область интеграции с C++ продолжает расширяться. При использовании небезопасных внешних интерфейсов необходимо самостоятельно проверять правила памяти и времён жизни.
Стандартизация и стабильность языка
Спецификации C и C++ поддерживаются посредством стандартов ISO/IEC. В настоящее время стандартизацией C занимается WG14, а стандартизацией C++ — WG21. Международные стандарты предоставляют общую основу, позволяющую нескольким независимым реализациям следовать единым правилам языка.
Java предоставляет отдельные спецификации языка и JVM, чётко разграничивая исходный язык и виртуальную машину.
ECMAScript развивается вокруг живой спецификации TC39, в которую включаются завершённые предложения.
Python, Rust, Go, Kotlin и Swift развиваются посредством публичных предложений и проектного управления. Даже без международного стандарта язык может обеспечивать практическую совместимость благодаря ясной справочной документации, политике стабильности и качеству основных реализаций.
Наличие стандарта не означает, что все реализации всегда начинают поддерживать новейшие возможности одновременно. И наоборот, язык, управляемый единым проектом, может быстрее распространять новые функции, но сильнее зависит от конкретной реализации и структуры управления.
Производительность разработки
Производительность разработки нельзя измерять только количеством строк кода. На неё также влияют то, насколько рано язык обнаруживает ошибки, как быстро выполняются сборка и тестирование, насколько легко использовать библиотеки и насколько понятны сообщения об ошибках и инструменты.
Python подходит для автоматизации и экспериментальной разработки благодаря динамическим объектам, краткому синтаксису и интерактивному выполнению. Однако в крупных кодовых базах возрастает значение подсказок типов, статического анализа, тестирования и ясных границ модулей.
Java и C# позволяют управлять крупными приложениями благодаря явной системе типов и объектной модели, обширным стандартным библиотекам и зрелым IDE. Код может быть сравнительно многословным, но предоставляет инструментам много информации для рефакторинга и анализа.
Kotlin сохраняет совместимость с экосистемой Java и одновременно предоставляет null-безопасность, вывод типов, функции расширения и краткое представление данных. Поскольку он использует ту же платформу, можно применять существующие библиотеки, однако необходимо учитывать различия в сведениях о null и принятых способах проектирования API между Java и Kotlin.
TypeScript сохраняет существующую экосистему JavaScript, добавляя статические типы и анализ в редакторе. Его система типов очень гибка, поскольку должна моделировать динамические шаблоны, выразимые в JavaScript. Однако некоторые типы удаляются без реальной проверки во время выполнения, поэтому достоверность внешних данных необходимо проверять отдельно.
Компилятор Rust проверяет владение, времена жизни и ограничения трейтов, обнаруживая многие ошибки до запуска, но начальное проектирование и обучение могут требовать больше усилий. Для системного кода, поддерживаемого в течение длительного времени, затраты на статическую проверку могут окупаться снижением числа дефектов и большей надёжностью рефакторинга.
C и C++ предоставляют широкий доступ к аппаратному обеспечению и существующим библиотекам, но требуют непосредственного управления памятью, ABI, настройками сборки и платформенными различиями. Из-за большого числа абстракций и унаследованных возможностей C++ для команды могут быть особенно важны ограниченное подмножество языка и единые правила написания кода.
Сравнение основных групп языков
C, C++ и Rust
Все три языка применимы для нативного системного программирования и низкоуровневого управления, но различаются подходами к безопасности и абстракциям.
| Критерий | C (язык программирования) | C++ | Rust |
|---|---|---|---|
| Основные абстракции | функции, структуры, указатели | классы, типы-значения, шаблоны, RAII | структуры, перечисления, трейты, владение |
| Управление памятью | преимущественно ручное | сочетание RAII и ручного управления | владение и заимствование |
| Сборка мусора | отсутствует | отсутствует | отсутствует |
| Обобщения | преимущественно макросы и void* | шаблоны и concepts | обобщения и трейты |
| Наследование | отсутствует | поддерживается наследование классов | наследование классов отсутствует |
| Небезопасный доступ | часть обычных возможностей языка | часть обычных возможностей языка | отделён границей unsafe |
| ABI | широко применяется C ABI | сильно зависит от реализации и платформы | внешняя граница обычно строится через C ABI |
| Основные преимущества | простая модель выполнения и широкая поддержка | высокая выразительность и развитая существующая экосистема | статическая безопасность памяти и конкурентности |
| Основные затраты | необходимость самостоятельно обеспечивать безопасность памяти | сложность возможностей и наличие небезопасных унаследованных конструкций | модель владения и стоимость компиляции |
C подходит для пограничного кода, где важны небольшая среда выполнения и простой ABI. C++ обладает преимуществами в высокопроизводительных приложениях, крупных абстракциях и использовании обширных существующих библиотек. Rust удобен для новых системных компонентов, в которых требуется усилить безопасность памяти, однако прямая замена существующей экосистемы C++ и сложных объектных моделей требует дополнительных затрат.
Go, Java и C#
Эти три языка часто сравниваются в контексте серверов, корпоративных приложений и сетевых служб, однако они различаются моделями выполнения и философией проектирования.
| Критерий | Go (язык программирования) | Java | C# |
|---|---|---|---|
| Выполнение | нативный код и среда выполнения Go | JVM | .NET CLR |
| Память | сборка мусора | сборка мусора | сборка мусора |
| Объектная модель | структуры, методы и структурные интерфейсы | классы и номинальные интерфейсы | классы, структуры и номинальные интерфейсы |
| Конкурентность | горутины и каналы | потоки, future, виртуальные потоки и другие средства | task и async·await |
| Обобщения | параметры типов и ограничения | обобщения со стиранием | обобщения со сведениями о типах времени выполнения |
| Развёртывание | нативный исполняемый файл | JVM и байт-код | зависимое от среды, самодостаточное или AOT-развёртывание |
| Направление проектирования | небольшой язык и интегрированные инструменты | стабильная экосистема JVM | широкие языковые возможности и интеграция с .NET |
Go сравнительно ограничивает количество языковых возможностей и подчёркивает простоту конкурентного сетевого программирования, сборки и развёртывания. Сильной стороной Java является JVM и накопленная за длительное время серверная и корпоративная экосистема. C# тесно интегрирован с .NET и предоставляет широкий набор языковых средств, включая типы-значения, LINQ, асинхронные функции и сопоставление с образцом.
Java и Kotlin
Kotlin проектировался не как полностью отдельная замена экосистемы Java, а как средство модернизации языкового выражения при сохранении JVM и библиотек Java.
| Критерий | Java | Kotlin |
|---|---|---|
| Основные среды выполнения | JVM | JVM, JavaScript, Native, WebAssembly |
| Обработка null | традиционные ссылки могут содержать null | nullable-типы выделяются отдельно |
| Выражение кода | явное и консервативное | вывод типов и краткий синтаксис |
| Асинхронность | потоки, future и виртуальные потоки | корутины и библиотечная модель выполнения |
| Взаимодействие | основа экосистемы JVM | непосредственное взаимодействие с Java |
| Процесс развития | JLS и JEP | спецификация Kotlin, KEEP и проектные процедуры |
Если важны существующие организации и библиотеки Java и долговременная совместимость, сама Java может быть более простым выбором. Если в той же экосистеме JVM нужны более строгий анализ null, краткие API и корутины, преимущества может иметь Kotlin. При совместном использовании языков необходимо учитывать сведения Java о null, проверяемые исключения и различия в представлении статических методов и свойств.
JavaScript и TypeScript
TypeScript является не отдельным языком среды выполнения, конкурирующим с JavaScript, а языковым слоем, который статически анализирует программы JavaScript и преобразует их обратно в JavaScript.
| Критерий | JavaScript | TypeScript |
|---|---|---|
| Проверка типов | преимущественно во время выполнения | добавляется статический анализ до компиляции |
| Выполнение | браузерные и серверные движки | выполнение преобразованного JavaScript |
| Система типов | динамические типы объектов | структурная статическая типизация |
| Внешние данные | обрабатываются непосредственно во время выполнения | требуется отдельная проверка времени выполнения помимо объявлений типов |
| Существующий код | может выполняться непосредственно | JavaScript можно включать постепенно |
| Основное преимущество | базовый язык всех веб-сред | поддержка навигации и изменений в крупных кодовых базах |
Для небольших сценариев и динамического кода может быть достаточно JavaScript. В крупном приложении, поддерживаемом несколькими разработчиками, TypeScript помогает выражать контракты API и проводить рефакторинг. Однако типы TypeScript обычно удаляются перед выполнением, поэтому они не гарантируют автоматически, что полученный из сети JSON действительно соответствует объявленному типу.
Python и статически компилируемые языки
Python часто упрощённо характеризуют как язык с более низкой производительностью выполнения, чем нативные статические языки, однако в реальных системах важнее распределение ролей.
Python может отвечать за организацию программы, автоматизацию, интерактивный анализ и определение моделей, тогда как вычислительно интенсивные операции передаются C, C++, Fortran, Rust или библиотекам GPU. В такой структуре важнее передавать нативной библиотеке крупные единицы работы, чем скорость отдельного цикла в Python.
Статические нативные языки удобны для повторяющихся мелких операций, управления размещением памяти и контроля задержек. Python может обеспечивать более высокую скорость разработки и экспериментирования благодаря кратким выражениям, динамическим объектам и развитой экосистеме библиотек.
Поэтому вместо выбора только между Python и C++ может быть уместна смешанная архитектура, в которой Python управляет высокоуровневым рабочим процессом, а C++ выполняет основные вычисления.
Swift и Kotlin
Swift и Kotlin часто сравниваются как основные языки экосистем Apple и Android, хотя области выполнения обоих языков не ограничены мобильными устройствами.
Swift сосредоточен на нативной компиляции, типах-значениях, автоматическом подсчёте ссылок, безопасности памяти и интеграции с фреймворками Apple. Kotlin предоставляет взаимодействие с JVM, сборку мусора, null-безопасность, корутины и несколько целевых бэкендов.
Если основное значение имеют нативный интерфейс и системные API платформ Apple, естественным выбором является Swift, а в экосистемах Android и JVM — Kotlin. Для совместного использования бизнес-логики между двумя платформами можно рассмотреть Kotlin Multiplatform либо общий базовый компонент на C++ или Rust.
Распространённые ошибки сравнения
Отождествление скорости языка со скоростью реализации
Спецификация языка определяет смысл программы, но не устанавливает весь конкретный машинный код и уровень оптимизации. Разные реализации одного языка могут демонстрировать различную производительность.
Предположение, что короткий код всегда повышает производительность разработки
Краткий код может ускорять написание, но скрытые действия, динамические ошибки и стоимость сопровождения способны увеличиваться. Напротив, явные типы и обработка ошибок делают код длиннее, но могут помогать при крупных изменениях.
Предположение, что статическая типизация устраняет ошибки
Статические типы предотвращают только ошибки, выражаемые системой типов. Неверные алгоритмы, ошибки политик безопасности, транзакций базы данных и обработки внешнего ввода требуют отдельных решений.
Предположение, что сборка мусора всегда работает медленно
Сборщик мусора может быстро выделять память и уплотнять объекты, улучшая локальность. При этом важными затратами остаются момент сборки и требуемый запас памяти. Результаты зависят от рабочей нагрузки и устройства сборщика.
Предположение, что нативный язык всегда быстрее управляемого
JIT-компилятор может использовать фактические сведения о выполнении для динамической оптимизации. Нативный компилятор, напротив, способен анализировать программу до запуска и оптимизировать её под целевую платформу. Преимущество зависит от программы, длительности выполнения и конкретной реализации.
Предположение, что новый язык всегда заменяет старый
Существующий код, библиотеки, ABI, специалисты и история проверок являются значительными активами. Fortran, COBOL и C продолжают использоваться не только из-за своего синтаксиса, но и благодаря накопленным системам и экосистемам.
Предположение, что поддержка нескольких парадигм всегда лучше
Большое число возможностей позволяет выражать разные задачи, но повышает сложность языка и кодовой базы. Небольшой и ограниченный язык может помогать всей команде придерживаться одного последовательного подхода.
Метод выбора языка
При выборе языка программирования сначала необходимо отделить обязательные условия проекта от условий, которые можно изменить.
Среда выполнения
Прежде всего следует определить целевые операционные системы и CPU, браузеры, мобильные платформы, игровые консоли и встраиваемые устройства. Языки и SDK, официально поддерживаемые конкретной платформой, могут фактически ограничить доступные варианты.
Требования к производительности
Необходимо конкретно определить не только среднюю пропускную способность, но и худшую задержку, время запуска, предел памяти, размер двоичного файла и энергопотребление. Прототип следует измерять на нагрузке, близкой к реальной.
Безопасность и надёжность
Следует рассмотреть безопасность памяти, ошибки типов, гонки данных, возможность null, обработку ошибок и сертификационные требования. Необходимо оценивать не только языковые возможности, но и стандарты кодирования, статический анализ и инструменты тестирования.
Существующая экосистема
Следует проверить, какие языки поддерживают необходимые драйверы баз данных, графические API, фреймворки машинного обучения, SDK устройств и корпоративные системы. При отсутствии ключевой библиотеки стоимость её самостоятельной реализации может превысить синтаксические преимущества языка.
Команда разработки
Необходимо учитывать опыт разработчиков, возможность найма, затраты на обучение, процессы проверки кода и эксплуатации. Использование языка, который понимает лишь небольшое число специалистов, во всей критической системе может создать долгосрочный риск сопровождения.
Развёртывание и эксплуатация
Следует определить способ установки, обновления и наблюдения программы. Необходимость установки среды выполнения, размер контейнера, холодный запуск, средства отладки и профилирования влияют на эксплуатационные затраты.
Долговременное сопровождение
Необходимо проверить управление языком и реализацией, политику совместимости, статус стандартизации и сроки поддержки версий. Возможность обновлять код и зависимости в течение многих лет может быть важнее краткосрочной скорости разработки.
Практическая таблица для сравнения
| Требование | Приоритетные характеристики | Характерные варианты языков |
|---|---|---|
| Операционная система и драйверы | нативное выполнение, ABI, управление памятью | C, C++, Rust |
| Высокопроизводительные игры и рендеринг | предсказуемая задержка, графическая экосистема | C++, Rust, C# |
| Сетевые службы | конкурентность, развёртывание, серверная экосистема | Go, Java, C#, Rust, Kotlin |
| Корпоративные системы | совместимость, управляемая среда, фреймворки | Java, C#, Kotlin |
| Веб-фронтенд | выполнение в браузере и Web API | JavaScript, TypeScript |
| Автоматизация и инструменты | скорость написания, работа с файлами и процессами | Python, оболочка, PowerShell |
| Анализ данных | интерактивное выполнение, статистическая и численная экосистема | Python, R, Julia |
| Научные и численные вычисления | операции над массивами, параллелизм, существующий код | Fortran, C++, Python, Julia |
| Мобильные приложения | платформенные SDK и UI-фреймворки | Kotlin, Swift |
| Встраиваемые устройства | небольшая среда выполнения, аппаратный доступ | C, C++, Rust, Ada |
| Системы, критичные для безопасности | проверяемость, ограниченная модель выполнения | Ada, C, C++, Rust |
| Подключаемые модули и модификации | встраиваемость, песочница, простой API | Lua, JavaScript, WebAssembly |
| Быстрое прототипирование | немедленное выполнение и изменение | Python, JavaScript |
| Крупный статически анализируемый веб-код | структурная типизация и экосистема JS | TypeScript |
Эта таблица не является абсолютным списком рекомендаций. Даже в одной области более подходящий выбор может зависеть от существующих технологий, опыта команды, платформы и лицензирования.
Итог
Различия между языками программирования не ограничиваются способом записи синтаксиса. Моделирование значений и объектов, момент проверки типов, управление памятью и ресурсами и среда выполнения определяют способы разработки, виды ошибок и характеристики производительности.
C и C++ предоставляют широкий контроль над аппаратным обеспечением и существующей системной экосистемой. Rust усиливает безопасность на основе владения в сходных областях. Go сочетает сравнительно небольшой язык с конкурентностью, поддерживаемой средой выполнения. Java и C# сосредоточены на управляемых платформах и экосистемах крупных приложений. Python и JavaScript обеспечивают динамическое выполнение, быструю разработку и широкий диапазон применения, а TypeScript добавляет статический анализ к экосистеме JavaScript. Kotlin и Swift объединяют современные возможности типов и безопасности с основными платформенными экосистемами. Fortran обладает накопленной научной и инженерной экосистемой, ориентированной на массивы и численные вычисления.
Ни один язык не превосходит остальные одновременно по всем критериям. Усиление низкоуровневого контроля расширяет область ответственности программиста, а усиление автоматического управления увеличивает роль среды выполнения и реализации. Более строгая статическая проверка способна заранее предотвращать некоторые ошибки, но усложняет систему типов и процесс компиляции. Более динамическое выполнение увеличивает гибкость, но повышает ответственность за проверки и тестирование во время выполнения.
Поэтому цель сравнения языков программирования состоит не в определении одного окончательного победителя. Она заключается в понимании того, какие затраты и ограничения выбрал каждый язык для решения определённых задач, и в выборе варианта, наиболее соответствующего среде выполнения, требованиям безопасности и производительности, организации разработки и существующей экосистеме проекта.
Связанные статьи
- Программирование
- Исходный код
- Машинный код
- Ассемблер
- Язык программирования высокого уровня
- Язык программирования низкого уровня
- Универсальный язык программирования
- Предметно-ориентированный язык
- Сценарный язык
- Парадигма программирования
- Императивное программирование
- Объектно-ориентированное программирование
- Функциональное программирование
- Декларативное программирование
- Логическое программирование
- Система типов
- Статическая типизация
- Динамическая типизация
- Синтаксис
- Семантика языков программирования
- Компилятор
- Интерпретатор
- Лексический анализ
- Синтаксический анализ
- Абстрактное синтаксическое дерево
- Промежуточное представление
- Байт-код
- Виртуальная машина
- Система времени выполнения
- Стандартная библиотека
- Спецификация языка
- Стандартизация языков программирования
- C (язык программирования)
- C++
- Fortran
- COBOL
- LISP
- Smalltalk
- Pascal
- Prolog
- Haskell
- Java
- JavaScript
- Python
- Ruby
- PHP
- C#
- Go (язык программирования)
- Rust
- Kotlin
- Swift
- TypeScript
- SQL
- WebAssembly