История языков программирования: как появлялась каждая концепция
Почти всё, чем сегодня пользуется программист: функции, циклы, классы, сборка мусора, проверка типов, — когда-то было новым решением конкретной проблемы. Эта лекция построена не по языкам, а по концепциям. Для каждой описаны четыре вещи: какая проблема её вызвала, когда и где она впервые появилась, как развивалась и что от неё видно в языках, на которых пишут сейчас.
Даты — это год первой публикации или первой работающей реализации. Путь от идеи до массового языка обычно занимает 10–30 лет. Например, классы появились в Simula в 1967 году, а стали привычными для программистов вместе с C++ и Java (1983–1995).
Как идеи переходят из языка в язык
Схема повторяется почти в каждой истории. Сначала кто-то сталкивается с трудной задачей: программы стали большими, данные — сложными, машины — параллельными. Затем появляется исследовательский язык, на котором идею проверяют на узком классе задач. Потом её берёт язык для практики, у которого есть достаточно быстрый компилятор. Наконец идея попадает в массовый язык, где её уже не нужно объяснять, а стандарт и переносимость делают её привычной.
1. Хранимая программа
Проблема. Первые вычислительные машины, например ENIAC (1945), программировались перестановкой проводов и установкой переключателей. Смена задачи занимала дни.
Когда и где впервые. В отчёте Джона фон Неймана об EDVAC (1945) описана машина, в которой программа хранится в той же памяти, что и данные. Одна из первых работающих машин такого типа — Manchester Baby (1948), первая практически полезная — EDSAC в Кембридже (1949).
Развитие. Если программа лежит в памяти, её можно загружать, менять и порождать другими программами. Из этого выросли компиляторы, загрузчики и, позже, компиляция на лету.
Что видно сегодня. Исполняемый файл — это данные, которые процессор читает из памяти. Язык программирования — удобный способ такие данные описать.
2. Подпрограммы и рекурсия
Проблема. Один и тот же фрагмент кода приходилось копировать в каждое место, где он нужен. Исправлять ошибку приходилось во всех копиях.
Когда и где впервые. В EDSAC (1951) Уилкс, Уилер и Гилл создали библиотеку подпрограмм: фрагмент вызывается по адресу, а управление возвращается в точку вызова. Подпрограммы как отдельно компилируемые функции появились в FORTRAN II (1958). Рекурсивные функции были в Лиспе (1958), а ALGOL 60 (1960) сделал рекурсивные процедуры с локальными переменными обычной частью языка.
Развитие. Чтобы рекурсия работала, вызовы должны складываться в стек: у каждого вызова своя копия локальных переменных. Поэтому стек вызовов и локальные переменные появились вместе с подпрограммами.
Что видно сегодня. Функции есть в любом языке, а ошибка «переполнение стека» возникает, когда рекурсия уходит слишком глубоко.
3. Символические имена и первый компилятор
Проблема. В машинных кодах программист помнит адреса ячеек и номера команд. Вставка одной строки сдвигает все адреса, и их приходится пересчитывать вручную.
Когда и где впервые. Около 1950 года появились краткие коды (Short Code): операции записывались словами, а программа-интерпретатор превращала их в действия. В 1952 году Грейс Хоппер создала систему A-0, которая переводила программу, записанную символами, в машинный код. Эту систему часто называют первым компилятором.
Развитие. Если программа умеет переводить текст в машинный код, её можно усложнять: появились ассемблеры, макросы и трансляторы для целых языков. Термин «компилятор» закрепился за такими системами.
Что видно сегодня. Ассемблер, компилятор и интерпретатор — ступени одной идеи: программа переводится в машинные команды, а не пишется ими напрямую.
4. Формулы, циклы и условия: FORTRAN (1957)
Проблема. Инженеры и учёные хотели записывать формулы так, как они записаны на бумаге. Программирование в кодах отнимало время и приводило к ошибкам.
Когда и где впервые. FORTRAN, созданный в IBM под руководством Джона Бэкуса (1957), стал первым широко используемым языком высокого уровня. В нём были арифметические выражения, цикл DO и условный оператор IF. Решающим аргументом было то, что компилятор выдаёт код, почти не уступающий по скорости написанному вручную.
Развитие. FORTRAN II (1958) добавил подпрограммы, а стандарты 1966 и 1978 годов расширили язык. Выражения с приоритетами, цикл с параметром и условие вошли почти во все последующие языки, включая Паскаль и C.
Что видно сегодня. Цикл с параметром, арифметические выражения с приоритетами и условный оператор есть во всех основных языках. Фортран до сих пор используется в научных расчётах.
5. Функции как значения и замыкания
Проблема. Ранние исследования искусственного интеллекта, доказательство теорем и символьное дифференцирование работали с формулами. Нужно было передавать функции как аргументы и создавать новые функции во время работы программы.
Когда и где впервые. Джон Маккарти в LISP (1958) сделал функции значениями: их можно передать, вернуть и создать во время работы. В Лиспе появились условное выражение и лямбда-обозначение, взятое из λ-исчисления Чёрча (1936). Замыкание — функция, которая запоминает переменные того окружения, в котором её создали. В ранних диалектах Лиспа из-за динамической области видимости замыкания работали неверно. Эту проблему решили в Scheme (Стил и Сассман, 1975), где введена лексическая область видимости и замыкания работают правильно.
Развитие. Замыкания появились в JavaScript (1995), где они широко используются в обработчиках событий. Лямбда-выражения пришли в C# 3.0 (2007), C++11 (2011) и Java 8 (2014).
Что видно сегодня. Лямбды и замыкания есть в Python, JavaScript, Java, C++ и C#. В примере ниже функция step помнит свою переменную count, даже когда функция make_counter уже вернула результат.
Пример на Python
def make_counter():
count = 0 # переменная живёт, пока на неё ссылается функция
def step():
nonlocal count
count += 1
return count
return step
first = make_counter()
second = make_counter()
print(first(), first(), first()) # 1 2 3
print(second()) # 1: у каждого замыкания своя переменная
6. Блоки и лексическая область видимости: ALGOL 60 (1960)
Проблема. В больших программах одинаковые имена переменных в разных частях мешали друг другу. Было неясно, какая переменная имеется в виду в данном месте.
Когда и где впервые. Отчёт об ALGOL 60 ввёл блок begin … end с собственными локальными переменными и правило области видимости по тексту программы: имя относится к ближайшему по тексту объявлению. Синтаксис языка в том же отчёте записан формально, в нотации Бэкуса–Наура (БНФ). Это один из первых больших языков, описанных так строго.
Развитие. Блоки и вложенные области видимости унаследовали Паскаль, C и их потомки. Передача параметра «по имени» из ALGOL 60 почти нигде не сохранилась, а передача по значению и по ссылке стала стандартом.
Что видно сегодня. Любая функция с локальными переменными и любой блок в фигурных скобках — прямые наследники этой идеи.
7. Структурное программирование: отказ от goto (1966–1970)
Проблема. Программы с оператором goto трудно читать и проверять. Переходы связывают далёкие части кода в запутанную сеть, и чтобы понять фрагмент, приходится проследить все переходы в программе.
Когда и где впервые. В 1966 году Бём и Якопини доказали, что любую схему вычислений можно записать тремя конструкциями: последовательностью, выбором и циклом. В 1968 году Эдсгер Дейкстра опубликовал в журнале «Communications of the ACM» письмо под заголовком «Go To Statement Considered Harmful» («Оператор goto считается вредным»). Паскаль (Вирт, 1970) стал учебным языком, в котором структурные конструкции были основными.
Развитие. Конструкции if, while, for и досрочный выход из цикла стали общими для языков. Идея структуры привела к процедурному и модульному программированию и подготовила почву для объектов.
Что видно сегодня. В большинстве языков goto отсутствует или применяется редко. В C и Go его оставили, но чаще всего используют для выхода из вложенных циклов или обработки ошибок.
8. Классы, объекты и наследование (1967–1995)
Проблема. Моделирование очередей, движения судов или работы сети требует многих похожих объектов: у них одна структура, но разное состояние. Описывать такие данные и процедуры по отдельности неудобно.
Когда и где впервые. Simula 67 (Оле-Юхан Даль и Кристен Нюгор, 1967) создала классы, объекты, наследование и виртуальные процедуры: процедуру подкласс может переопределить, а вызов выбирает реализацию по фактическому типу объекта. Simula задумывалась как язык моделирования событий. Термины «класс» и «объект» в современном смысле введены в этом языке.
Развитие. Smalltalk (Алан Кей, 1972) сделал объекты единственным способом организации программы: объекту посылают сообщение, а он решает, как его обработать. В C++ классы появились в 1979 году (тогда язык назывался «C с классами»), а название C++ закрепилось в 1983-м. Java (1995) и C# (2000) оставили одиночное наследование классов и добавили интерфейсы.
Что видно сегодня. Классы, наследование и полиморфизм есть в C++, Java, C#, Python и Kotlin. Идеи Smalltalk видны в Ruby (1995) и Objective-C (1984), где вызов метода понимают как посылку сообщения. Шаблон MVC, родившийся в 1979 году в работе над средой Smalltalk, — один из первых шаблонов проектирования; подробнее в разделе о шаблонах проектирования.
9. Модули и скрытие реализации (1972–1983)
Проблема. В растущей программе любая часть может обращаться к данным любой другой части. Изменение внутреннего представления тогда ломает весь проект.
Когда и где впервые. Дэвид Парнас в статье 1972 года предложил разбивать систему на модули по скрытым решениям: модуль прячет то, что может измениться, и показывает только интерфейс. Язык Modula (Вирт, 1975) сделал модули частью языка. В Ada (1983) появились пакеты с отдельной спецификацией (интерфейсом) и телом (реализацией).
Развитие. Из этих идей выросли пакеты Java (1995), пакеты Go (2009) и крейты Rust (2015). Во всех них есть видимость: открытое и закрытое.
Что видно сегодня. Модификаторы доступа public и private и отдельный интерфейс модуля — прямой след этих идей.
10. Вывод типов и обобщённые типы (1969–2022)
Проблема. Статическая проверка типов находит ошибки до запуска, но требует объявлять типы везде. Кроме того, одна и та же функция, например сортировка или список, нужна для разных типов. Без обобщённых типов её приходится дублировать.
Когда и где впервые. Алгоритм вывода типов описал Хиндли (1969). Милнер применил его в языке ML (1973) и опубликовал теорию полиморфизма в 1978 году. Так компилятор стал сам выводить типы выражений, а функции — работать с любым типом элементов. Обобщённые типы появились в Ada (1983), шаблоны в C++ (1990), generics в Java (2004), в C# (2005) и в Go (2022).
Развитие. Разные языки решили обобщение по-разному. Шаблоны C++ создают отдельный код для каждого типа во время компиляции. В Java информация о параметре типа при работе программы стирается, поэтому список целых и список строк во время выполнения — один и тот же класс. В C# типы сохраняются при выполнении. Вывод типов для локальных переменных пришёл в C# (var, 2007), C++ (auto, 2011) и Java (var, 2018).
Что видно сегодня. Стандартные контейнеры построены на обобщённых типах: std::vector<int> в C++, List<String> в Java. Пример шаблона функции, который работает для разных типов:
Пример на C++
#include <iostream>
#include <string>
template <typename T>
T largest(T a, T b) {
return a < b ? b : a;
}
int main() {
std::cout << largest(3, 7) << "\n";
std::cout << largest(std::string("pear"), std::string("apple")) << "\n";
std::cout << largest(2.5, 1.5) << "\n";
return 0;
}
11. Сопоставление с образцом (1970-е — 2021)
Проблема. Разбор данных вида «либо число, либо пара, либо пусто» через цепочки if и проверки полей громоздок, и легко пропустить один из вариантов.
Когда и где впервые. В языках семейства ML (1970-е) и в языке Hope (1980) функция описывается набором образцов. Значение сопоставляется с образцами по порядку, а части значения связываются с именами. Идею широко распространил Haskell (1990).
Развитие. Scala (2004) и F# (2005) переняли образцы. В Rust (2015) компилятор требует, чтобы были рассмотрены все варианты. Python получил конструкцию match в версии 3.10 (2021).
Что видно сегодня. Перечисления с данными и match в Rust и Python — прямое продолжение этой идеи.
12. Логическое программирование: Prolog (1972)
Проблема. Во многих задачах удобнее описать, что является истиной, а не то, как искать решение. Так устроены разбор языка, поиск по базе знаний и решение головоломок.
Когда и где впервые. Ален Колмерауэр с коллегами создал Prolog в 1972 году в Марселе. Программа состоит из фактов и правил. Запрос ищет значения переменных, при которых он выполним: система сопоставляет термы унификацией и при неудаче возвращается назад и перебирает другие варианты (backtracking).
Развитие. Prolog стал основой проектов по искусственному интеллекту 1980-х, в том числе японского проекта компьютеров пятого поколения. Декларативный подход, при котором описывается результат, а не алгоритм, прижился и в других областях: в SQL запрос SELECT задаёт, что нужно получить, а не как это вычислить.
Что видно сегодня. Декларативный стиль есть в SQL. Prolog остаётся в учебных курсах и в отдельных системах анализа языка.
13. Сборка мусора и управление памятью (1959–2015)
Проблема. Ручное освобождение памяти порождает две ошибки: утечки (память не освобождена) и висячие ссылки (память освобождена, но ещё используется). Такие ошибки долго оставались главной причиной сбоев системных программ.
Когда и где впервые. Джон Маккарти в статье о Лиспе (1960) описал сборщик мусора. Он освобождает память, до которой нельзя добраться от корней (переменных и стека). Алгоритм «пометить и освободить» (mark-and-sweep) сохраняется в учебниках до сих пор.
Развитие. Smalltalk (1980) сделал сборку мусора нормой для объектных систем. Java (1995) сделала её массовой. Python (1991) считает ссылки, а сборщик циклов добавлен в версии 2.0 (2000). Go (2009) выполняет сборку параллельно с работой программы.
Альтернатива без сборщика. В C (1972) память освобождается вручную функцией free. В C++ Страуструп выработал идиому RAII (1980-е): ресурс освобождается деструктором, когда объект выходит из области видимости. Так работают умные указатели, например std::unique_ptr (C++11).
Владение в Rust (2015). Компилятор Rust проверяет, что у каждого значения есть один владелец, а ссылки не переживают данные. Исследовательский язык Cyclone (2002) проверял безопасность указателей без сборщика мусора, а Rust 1.0 (2015) сделал проверку заимствований обычной частью компиляции.
Что видно сегодня. Java, C#, Go, Python и JavaScript работают со сборкой мусора. C++ и Rust по умолчанию её не используют.
14. Исключения и значение null (1964–2015)
Проблема. Код ошибки, который функция возвращает вместо результата, легко забыть проверить. Ссылка, которая может быть пустой (null), вызывает сбой там, где её не ждали.
Когда и где впервые. PL/I (IBM, 1964) ввёл условные обработчики, которые вызываются при особых ситуациях, например при переполнении. Это ранняя форма исключений. Null-ссылка появилась в ALGOL W (1965). Тони Хоар позже назвал её своей «ошибкой на миллиард долларов» (доклад 2009 года). Исключения в современном виде, с try/catch и раскруткой стека, есть в Ada (1983) и C++ (1990).
Развитие. Java (1995) сделала часть исключений обязательными для обработки (checked exceptions). Одни считают это полезным, другие — громоздким. Go (2009) вернулся к возвращаемым значениям ошибок. Rust (2015) использует тип Result. Для null появились типы, которые не допускают пустого значения: Maybe в Haskell (1990), nullable-типы в Kotlin (2011), опционалы в Swift (2014) и Option в Rust.
Что видно сегодня. Исключения (Python, Java, C#) и значения ошибок (Go) — два разных ответа на одну проблему. Типы без null обычны в Kotlin, Swift и Rust.
15. Генераторы, сопрограммы и async/await
Проблема. Последовательность удобнее производить по одному элементу, чем целиком держать в памяти. Ожидание ответа сети или диска не должно останавливать всю программу.
Когда и где впервые. Термин «сопрограмма» (coroutine) предложил Мелвин Конвей в конце 1950-х. Сопрограмма приостанавливает работу и продолжает потом с того же места. В Simula 67 (1967) сопрограммы были частью языка. Генераторы с ключевым словом yield появились в Python 2.2 (2001).
Развитие. Асинхронные функции async/await появились в C# 5.0 (2012), затем в Python 3.5 (2015), JavaScript (ES2017), Rust (стабильно с версии 1.39, 2019) и Kotlin (сопрограммы стабильны с версии 1.3, 2018). Асинхронный код стало возможно писать линейно, без цепочек обратных вызовов.
Что видно сегодня. Генератор на Python выдаёт элементы по одному и помнит, где остановился:
Пример на Python
def numbers_from(start):
n = start
while True:
yield n # приостанавливаем функцию и отдаём значение
n += 1
gen = numbers_from(10)
print([next(gen) for _ in range(3)]) # [10, 11, 12]
16. Параллелизм: акторы и каналы (1973–2009)
Проблема. Общие переменные между потоками приводят к гонкам и ошибкам, которые трудно воспроизвести. Нужен способ, при котором части программы обмениваются сообщениями, а не делят память.
Когда и где впервые. Карл Хьюитт описал модель акторов в 1973 году: актор получает сообщения, обрабатывает их по одному и может создавать новых акторов. Тони Хоар в 1978 году предложил CSP (communicating sequential processes): процессы обмениваются значениями через каналы. Erlang (Эрикссон, 1986) построил на акторах отказоустойчивые телекоммуникационные системы.
Развитие. Go (2009) взял каналы из CSP: горутины — лёгкие потоки, а каналы — способ передавать значения между ними. Идея, рождённая в теории, через 30 лет стала частью языка массовой разработки.
Что видно сегодня. Модель акторов есть в Erlang, Elixir и в библиотеке Akka для Scala и Java. Каналы есть в Go и в стандартной библиотеке Rust. Безопасный Rust не допускает гонок данных.
17. Ленивые вычисления (1966–1990)
Проблема. Иногда выгодно не вычислять аргумент, пока он не понадобился. Например, из миллиона элементов нужны только первые три, а последовательность может быть бесконечной.
Когда и где впервые. Питер Ландин в 1966 году описал ленивую стратегию вычислений в статье о языке ISWIM. Фридман и Уайз (1976) показали, как её применить к спискам. Haskell (стандарт 1990) сделал ленивость основой языка: выражение вычисляется, только когда его значение нужно.
Развитие. Чистота Haskell потребовала способа описывать ввод-вывод и состояние. Для этого стали использовать монады: Моджи (1989–1991) ввёл их в теорию вычислений, Филип Уодлер (1992) — в программирование.
Что видно сегодня. Потоки (Stream) в Java (2014), итераторы в Rust и генераторы в Python вычисляют элементы по требованию.
18. Виртуальные машины и байт-код (1970–1999)
Проблема. Программа, скомпилированная для одного процессора, не работает на другом. Переносимость требует промежуточного представления.
Когда и где впервые. В 1970-х компилятор Паскаля генерировал код для абстрактной стековой машины, P-code. Программа работала на любом компьютере, где был интерпретатор P-code. Smalltalk (1980) тоже исполнялся на виртуальной машине.
Развитие. Виртуальная машина Java (JVM, 1995) и CLR в .NET (2002) исполняют байт-код. Чтобы ускорить работу, появилась JIT-компиляция: байт-код переводится в машинный код во время работы. Её впервые широко применили в Smalltalk и Self (1980-е), а в HotSpot для Java (конец 1990-х) она стала массовой.
Что видно сегодня. Java, Kotlin, C# и Python исполняют байт-код. JIT-компиляторы работают в движке V8 для JavaScript и в PyPy.
Сводная таблица
| Концепция | Впервые | Год | Где стала обычной |
|---|---|---|---|
| Хранимая программа | EDVAC (фон Нейман), EDSAC | 1945–1949 | все компьютеры |
| Подпрограммы | EDSAC | 1951 | все языки |
| Компилятор | A-0 (Хоппер) | 1952 | все языки |
| Выражения, цикл с параметром, IF | FORTRAN | 1957 | почти все языки |
| Рекурсия | LISP; процедуры в ALGOL 60 | 1958–1960 | все языки |
| Функции как значения, лямбды | LISP | 1958 | Python, JavaScript, C++11, Java 8 |
| Сборка мусора | LISP (Маккарти) | 1959–1960 | Java, C#, Go, Python, JavaScript |
| Блоки и локальные переменные | ALGOL 60 | 1960 | почти все языки |
| Записи с полями | COBOL | 1959 | структуры C (1972), record в Паскале |
| Структурное программирование | Бём и Якопини; Дейкстра | 1966–1968 | почти все языки |
| Классы и объекты | Simula 67 | 1967 | C++, Java, C#, Python |
| Сопрограммы и генераторы | Конвей; Simula 67; Python | 1958–2001 | Python, JavaScript, C#, Rust |
| Модули и скрытие реализации | Парнас (идея), Modula, Ada | 1972–1983 | Java, Go, Rust |
| Логическое программирование | Prolog | 1972 | SQL (по духу), учебные курсы |
| Вывод типов | Хиндли, Милнер; ML | 1969–1978 | Kotlin, Rust, Swift, C++ (auto), Java (var) |
| Обобщённые типы | Ada; C++; ML | 1973–1990 | Java, C#, C++, Go |
| Исключения | PL/I; Ada; C++ | 1964–1990 | Java, C#, Python, C++ |
| Null-ссылка | ALGOL W | 1965 | Java, C#, Python, C++; без неё — Kotlin, Swift, Rust |
| Акторы и каналы | Хьюитт; Хоар; Erlang | 1973–1986 | Go, Erlang, Elixir, Akka |
| Ленивые вычисления | Ландин; Haskell | 1966–1990 | потоки в Java, итераторы в Rust, генераторы в Python |
| Виртуальная машина и JIT | P-code; Smalltalk; HotSpot | 1970–1999 | Java, C#, JavaScript (V8) |
| Владение и заимствование | Rust | 2015 | системное программирование на Rust |
| async/await | C# 5.0 | 2012 | C#, JavaScript, Python, Rust, Kotlin |
Что из этого следует
- Большинство «новых» идей старше языков, которыми мы пользуемся: хранимая программа появилась в 1945 году, рекурсия и функции как значения — в 1958-м, классы — в 1967-м.
- Путь от идеи до массового языка занимает десятилетия. Классы: Simula (1967) и Java (1995). Лямбды: LISP (1958) и Java 8 (2014).
- Идеи возвращаются. Ленивые вычисления, которые в 1990 году выглядели экзотикой Haskell, сегодня есть в итераторах Rust и потоках Java.
- Каждый язык — набор компромиссов. Python выбрал читаемость и сборку мусора, C++ — полный контроль над памятью и скоростью, Rust — безопасность памяти без сборщика.
Источники
- Джон Маккарти. Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I (1960).
- Эдсгер Дейкстра. Go To Statement Considered Harmful (1968).
- Джон Бэкус. Can Programming Be Liberated from the von Neumann Style? (лекция премии Тьюринга, 1977; опубликована в 1978).
- Алан Кей. The Early History of Smalltalk (1993).
- Бьярне Страуструп. The Design and Evolution of C++ (1994).
- Тони Хоар. Null References: The Billion Dollar Mistake (доклад, 2009).
- Материалы конференций HOPL по истории языков программирования (1978, 1993, 2007, 2020).
См. также на сайте
- История развития языков программирования — старый очерк на этом сайте.
- Фортран, Алгол, функциональное программирование, объектно-ориентированное программирование.
- Утечки памяти и многопоточное программирование.