Обзор некоторых определяющих характеристик и идей восьми разных языков программирования

Я имел склонность играть с разными языками программирования более 20 лет. Это попытка вспомнить некоторые из тех переживаний - идей, которые действительно выделялись на каждом языке или значение которых я ретроспективно видел.

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

Zig - временной код компиляции

В некоторых областях Zig ведет себя почти как динамический язык, несмотря на статическую типизацию. В языках со статической типизацией код не запускается до тех пор, пока вы его не скомпилируете. Однако в Zig вы можете пометить данные как известные во время компиляции. Если компилятор не может определить значение во время компиляции, это ошибка компиляции.

С другой стороны, код, который полагается исключительно на значения, известные во время компиляции, может выполняться во время компиляции, а не во время выполнения. Посмотрите на этот невинный пример, который очень похож на вызов printf в C:

print("number: {} string: {}", .{num, str});

В C это потенциально опасный вызов функции, потому что во время выполнения код должен проанализировать строку формата, чтобы увидеть, сколько аргументов следует читать. Однако строка формата может не соответствовать списку аргументов. Фактически, это достаточно плохо, что современные компиляторы C рассматривают это как частный случай, который проверяется во время компиляции. Однако это специальное исключение, сделанное только для функции printf.

В Zig особого обращения не требуется. Строка формата помечается как известная во время компиляции специальным ключевым словом comptime:

print(comptime format: []const u8, args: anytype)

Это означает, что если компилятор обнаруживает, что это неизвестно (например, потому что вы читаете эту строку из файла), то будет произведена ошибка компиляции, и вы должны убедиться, что эта строка формата действительно известна во время компиляции.

Переменные num и str имеют значения, которые могут быть неизвестны во время компиляции, но их типы известны. Это позволяет Zig выполнять весь код внутри print, который полагается только на строку формата и знает типы аргументов.

Вы можете себе представить, что это включает в себя набор циклов и операторов if, определяющих, какие другие функции вызывать в зависимости от типа переменных num и str. В окончательно скомпилированной функции весь этот код исчезнет, ​​и останется только код, который будет выполняться во время выполнения.

Юлия - Множественная отправка

О Джулии можно много сказать, но одна особенность, которая выделяется у Джулии, заключается в том, что все построено на множественной отправке. Что это обозначает? По терминологии Джулии, можно иметь функцию fight, но функция имеет несколько реализаций, называемых методами. Таким образом, у нас может быть несколько методов (не путать с ООП), таких как:

fight(a::Archer,  b::Knight)
fight(a::Pikeman, b::Archer)
fight(a::Knight,  b::Knight)

В отличие от объектно-ориентированного программирования, все аргументы определяют, какой код запускается, а не только специальный первый аргумент, такой как this или self. И нет, это не перегрузка функции, поскольку это решение принимается во время выполнения, а не во время компиляции. Вам не нужно знать тип a и b при компиляции.

Это важно для чего-то вроде игры. Во время компиляции вы не можете знать, какой тип солдат будет сражаться друг с другом в игре. Сразится ли лучник с рыцарем или пикинером - это динамическое решение, которое игроки принимают во время выполнения.

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

show(io::IO, x)

Если вы не добавите свой собственный метод, он по умолчанию будет просто использовать отражение, чтобы обнаруживать поля в вашем объекте x и отображать его. Но вы можете определить свое собственное отображение, скажем, типа Point с помощью:

show(io::IO, p::Point) = print(io, "($(p.x), $(p.y)")

Также возможно представление точки другим способом для другого устройства ввода-вывода:

show(io::IOBuffer, p::Point) = print(io, "BufPoint($(p.x), $(p.y)")

Без множественной диспетчеризации добиться такой гибкости было бы сложно. Либо тип Point должен реализовать некоторый интерфейс сериализации, либо вам придется изменить базовый класс IO и его подклассы, чтобы принять новый тип Point.

Swift - Опции

Хотя Swift не был первым языком, который ввел необязательные типы или типы сумм, и не первым языком, который познакомил меня с этой идеей, это был первый язык, на котором я начал регулярно использовать эту концепцию. Вот пример из документации Apple Swift, объясняющий концепцию:

if let firstNumber = Int("4") {
    if let secondNumber = Int("42") {
        if firstNumber < secondNumber && secondNumber < 100 {
            print("\(firstNumber) < \(secondNumber) < 100")
        }
    }
}

// Prints "4 < 42 < 100"

Анализ строки "4" на целое число 4 может завершиться ошибкой. В Swift функция Int вернет значение типа Int?, что означает, что это может быть целое число или null. Тип Int может быть только целым числом. Такие типы, как Int? и String?, нельзя использовать напрямую, так как они могут быть null. Их нужно как-то развернуть. Это то, что делает оператор if let в Swift.

Вот еще один эквивалентный пример:

if let firstNumber = Int("4"), 
   let secondNumber = Int("42"), 
   firstNumber < secondNumber && secondNumber < 100 
{
    print("\(firstNumber) < \(secondNumber) < 100")
}

// Prints "4 < 42 < 100"

Подробнее об этом читайте в документации Apple. Переходя от Objective-C к Swift, я был поражен тем, сколько маленьких хитрых багов я смог поймать на этом. И особенно по сравнению с C ++, мне нравится, как это упростило множество функций. Мне больше не приходилось писать кучу защитного кода, проверяющего нулевые указатели.

Go - структурная типизация

Большинство людей будут говорить о том, что программирование параллелизма - это круто в Go. Но больше всего меня привлекала структурная типизация. Большинство из вас знакомо с тем, что называется номинальной типизацией. Так работает набор текста в C / C ++, Java и C #.

Это дает Go форму утиной печати, очень похожую на динамические языки, но проверяемую во время компиляции. Go не имеет наследования реализации, но вы можете определять структуры с помощью методов и интерфейсов.

Я могу определить функцию fight, которая принимает аргумент типа Soldier, где Soldier - некоторый интерфейс со списком методов. fight затем может принимать объекты типа Pikeman или типа Knight, если у обоих из них есть все методы, определенные в интерфейсе Soldier. Вот пример части такой реализации в Go:

type Soldier interface {
    damage(amount int)
    attack(soldier Soldier)
}
func fight(a Soldier, b Soldier) {
    a.attack(b)
    b.attack(a)
}

Обратите внимание, что при определении Knight мы не указываем, что он реализует интерфейс Soldier; он просто реализует свои методы:

type Knight struct {
    health int
}
func (knight *Knight) damage(amount int) {
    knight.health -= amount
}
func (knight *Knight) attack(soldier Soldier) {
    soldier.damage(4)
}

Мы можем передать Knight и Pikeman функции, ожидающей Soldier типов, и компилятор Go определит, что интерфейсы структурно совпадают.

knight := Knight{12}
pikeman := Pikeman{8}
fight(&knight, &pikeman)

Это похоже, например, на Python или Ruby. Их не волнует, каков именно тип входных данных, до тех пор, пока объекты реагируют на выполняемые с ними вызовы методов. Go позволяет это делать, но безопасным для типов способом. Если тип интерфейса и тип структуры не совпадают, вы получите ошибку компиляции. Например, если бы я не реализовал метод damage для Pikeman, я бы получил следующую ошибку:

cannot use &pikeman (type *Pikeman) as type Soldier in argument to fight:
    *Pikeman does not implement Soldier (missing damage method)

Objective-C - Категории

Одна из вещей, которые мне очень нравились в Objective-C, когда я работал с C ++, - это категории. Категория позволяет добавлять любое количество методов к существующему классу, не создавая его подклассов, и это можно сделать в отдельных библиотеках.

Чем это полезно? Представьте, что вы храните на диске объект-граф, представляющий сложный пользовательский интерфейс, и хотите его воссоздать. Вы можете пройти по этому графу объектов и вызвать, скажем, метод createUI, чтобы создать соответствующий объект пользовательского интерфейса для этого объекта.

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

Однако с помощью категорий вы можете добавить метод createUI к каждому из типов объектов в графе объектов. Вы можете добавить свой код для каждого типа объекта.

Это элегантное решение, которое разработчик на C ++ должен будет решить, скажем, с шаблоном посетителя. Категории больше не являются уникальными для Objective-C. Эта функция также доступна в Swift. Поскольку Swift имеет более обычный синтаксис, может быть полезно показать пример расширения класса Swift, аналогичного категориям:

extension Int {
    func takeAway(value: Int) -> Int {
        return self-value
    }
}

let a = 10
let b = a.takeAway(value: 3)
print(b)

В этом случае мы расширяем тип Int с помощью метода takeAway, что совершенно бесполезно, но это всего лишь простой пример.

Более подробное объяснение того, как более элегантно реализовать шаблон посетителя в Swift, можно найти в этом более подробном объяснении. Он будет одинаково хорошо работать и в Objective-C.

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

Язык программирования LISP и его производные имеют функцию, называемую гомоиконность, которая очаровывала меня долгое время, даже если я не занимаюсь обычным программированием на LISP.

Это означает, что код представлен так же, как и данные, и, таким образом, им можно манипулировать так же, как данными.

В LISP основная структура данных представляет собой связанный список. Вот простой пример списка с некоторыми элементами:

(list 43 "hello" true 2.5 'c')

Обратите внимание, что здесь я использую знакомый синтаксис типа Си для логических значений и символов. LISP будет использовать другой синтаксис.

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

(list 34 (list "hello" true) (list 2.5 'c'))

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

При кодировании в LISP вы создаете это абстрактное синтаксическое дерево напрямую и в принципе можете переупорядочивать и преобразовывать такое дерево кода, как и любой другой код.

Давайте рассмотрим пример, чтобы понять универсальность подхода LISP. Если мы посмотрим на программу Hello World на C, то абстрактное синтаксическое дерево или структура данных, представляющая эту программу, не сразу бросается в глаза.

#include <stdio.h>

int main () {
    printf("hello, world\n");
    return 0;
}

Однако существуют версии C с добавлением LISP, которые позволяют вместо этого писать эту программу в синтаксисе LISP (то, что мы называем s-выражениями).

(import cstdio)

(def main (fn extern-c int (void)
  (printf "hello, world\n")))

Давайте рассмотрим еще один пример, чтобы прояснить, как это работает.

struct Point {
    int x;
    int y;
};

Используя s-выражения LISP, это становится:

(def Point (struct intern (
    (x int) 
    (y int)
)))

Куда я иду с этим? Зачем писать код с таким, казалось бы, уродливым и неудобным синтаксисом? Благодаря этому синтаксису все полностью упорядочено. Например, в этом определении ясно видно, что код определяется как список, где первые два элемента - это def и Point. Третий элемент - это еще один список, который начинается с элементов struct и internt. Он снова имеет третий элемент, который представляет собой еще один список, содержащий все определения переменных в структуре.

Ладно, и что? Что это мне дает? Вы можете поместить, например, этот код LISP в файл, а другой код LISP может загрузить этот код как данные и преобразовать его. Он может перебирать этот код, как любой другой связанный список. Он мог вставлять и заменять элементы.

Но в LISP вам даже не нужно помещать это в отдельный файл. Код может быть помещен как данные непосредственно в другой код LISP с использованием кавычек. Например, в LISP это означает прибавление 4 к переменной x.

(+ 4 x)

В обычном LISP вы можете объявить такую ​​переменную со значением:

(defvar y 10)

А позже измените значение на setf:

(setf y 5)

Но он не обязательно должен содержать число. Мы можем поместить туда что угодно, даже список кода. Но как избежать выполнения кода? Если мы это сделаем, мы просто сохраним 7 в y:

(setf y (+ 3 4))

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

(setf y '(+ 3 4))

Если я использую среду LISP REPL (интерактивную командную строку), такую ​​как SBCL, мы можем проверить это и оценить:

sbcl> y
=> (+ 3 4)

sbcl> (eval y)
=> 7

Но вы можете сами динамически создать такой список кода. Функция cons добавляет узел в начало списка.

sbcl> (cons 3 '(5 8))
=> (3 5 8)

Вы можете использовать это для объединения фрагментов кода в список. Например, в приведенном ниже примере мы выбираем первый элемент y, который является оператором +, а затем добавляем его к списку чисел 4 и 5.

sbcl> (cons (first y) '(4 5))
=> (+ 4 5)

Мы можем оценить это новое выражение, которое мы создали:

sbcl> (eval (cons (first y) '(4 5)))
=> 9

Это, очевидно, гораздо более обширная тема, и я могу только поверхностно ее затронуть. Но суть в том, чтобы показать, что, помещая любой код в s-выражения, вы можете легко преобразовывать этот код и манипулировать им. Таким образом, вы могли бы, например, иметь код C со вкусом LISP, который я показал ранее, внутри обычного кода LISP и выполнять преобразования в этом коде. Затем этот код можно передать в такую ​​программу, как C-Mera, которая превратит C в стиле LISP в обычный код C, который можно скомпилировать.

Это было использовано на Playstation 2 компанией Naughty Dog, которая сделала Game Oriented Assembly LISP для создания таких игр, как Jak and Daxter. По сути, это была оболочка ассемблерного кода PS2 в синтаксисе LISP, давая возможность писать низкоуровневый язык на высокоуровневом.

Вот почему LISP часто считают языком для создания языков. Еще одно преимущество очень регулярного синтаксиса состоит в том, что вы можете использовать специальные редакторы LISP, которые перемещаются по древовидной структуре, а не по строкам и столбцам. Следовательно, у вас есть комбинации клавиш, которые переходят к следующему родственнику в синтаксическом дереве, к родительскому или дочернему элементу. Вы можете делать такие вещи, как выбирать все дочерние узлы.

Smalltalk - Разработка на основе изображений

Smalltalk - не первый язык с разработкой на основе изображений. Фактически, это было впервые сделано с LISP. Однако Smalltalk - это, пожалуй, тот язык, где он наиболее естественен и обеспечивает максимальную мощность.

Конечно, возникает вопрос: что такое разработка на основе изображений?

Нет, это не означает, что вы рисуете изображения для создания программ. Вместо этого мы используем слово изображение для обозначения сериализации данных в памяти. Вы можете думать о программировании Smalltalk как о интерактивном сеансе в среде REPL. Если вы программировали на JavaScript, Python, Ruby или Lua, вы должны быть знакомы с разработкой на основе REPL. Вы определяете переменные, функции и т. Д. В интерактивной среде.

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

Вместо этого вы можете думать о разработке Smalltalk как о манипулировании объектной базой данных с помощью IDE. Но все становится еще более безумным. Вся эта интерактивная среда написана на Smalltalk и является частью той же среды.

Это примерно так: внизу у вас есть виртуальная машина (ВМ), которая загружает изображение, которое по сути является базой данных объектов. Этот образ содержит всю среду разработки Smalltalk. Через среду IDE, которая сама существует в образе Smalltalk, вы выполняете действия, которые добавляют к этому образу дополнительные объекты.

Изображение ниже из современной версии Smalltalk под названием Pharo. Он показывает типичный редактор кода Smalltalk. В крайнем левом углу у вас есть пакеты. Рядом с пакетами у вас есть список классов, определенных в выбранном пакете. Затем у вас есть категории для методов. И, наконец, в крайнем правом углу вы видите методы из этой категории.

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

Так в чем же это значение? Почему это имеет значение? Что дает нам этот подход?

Это означает, что Smalltalk IDE имеет полное, всегда актуальное представление вашего кода в памяти. И это представление - объекты Smalltalk. Таким образом, и вы, и среда IDE можете перемещаться по всем вашим классам, объектам и методам. Это то, что дает Smalltalk IDE такие мощные инструменты рефакторинга. Такие действия, как переименование метода или перемещение метода, не являются операцией замены текста, как в других IDE. Напротив, это манипуляция с объектом. Вы манипулируете живыми объектами.

Поскольку вся среда разработки состоит из классов, методов и объектов, которые вы можете просматривать из среды разработки и которые существуют в реальном времени, вы можете изменить свою собственную среду разработки и сразу увидеть, как изменения происходят. Это означает, что вы можете изменить поведение и добавить новые функции. Фактически, даже сам компилятор написан на Smalltalk, так что вы даже можете изменить способ компиляции ваших классов и методов.

Это означает, что при первом использовании Smalltalk это может немного сбить с толку. Например, Pharo начинается с простой программы запуска, в которой вы выбираете изображение для запуска. Это естественно. В противном случае, что мы будем делать, если вы испортите весь свой образ, поскольку это может означать, что вся ваша среда разработки больше не работает?

К счастью, в Pharo есть множество инструментов для сохранения изображений, их восстановления, сброса и контроля версий. Контроль версий в Smalltalk интересен, поскольку текстовых файлов в принципе нет. Так как бы вы использовали, например, Git и GitHub с Smalltalk?

В Pharo это решается с помощью специального менеджера Git под названием Iceberg, показанного ниже:

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

Важно понимать, что это всего лишь экспортный формат. Вы не можете запустить это напрямую. Вы используете Iceberg для импорта изменений из Git.

Имидж-разработка дает много интересных возможностей. Вы можете, например, сохранить свое изображение в середине сеанса отладки, а затем перезагрузить IDE и продолжить с того места, где вы остановились.

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

Lua - все является хеш-таблицей

В LISP все представляет собой связанный список. В Smalltalk все является объектом. В Lua есть своя уникальная изюминка. В Lua все сосредоточено вокруг хеш-таблицы как основной структуры данных.

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

Эти хеш-таблицы довольно умны. Они могут указывать на мета-хеш-таблицы, которые снова могут иметь свои собственные мета-хеш-таблицы в бесконечной цепочке.

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

Это может образовывать иерархии. Несколько хеш-таблиц могут указывать на одну и ту же мета-хеш-таблицу. И все коллекции мета-хеш-таблиц могут указывать на другую общую мета-хеш-таблицу. Таким образом, мы можем создать иерархическую древовидную структуру.

Это можно использовать для многих целей. Это дает возможность предоставить значения по умолчанию, когда вы еще не определили значение. Lua изначально задумывался как язык конфигурации, на котором вы могли указать сложные параметры конфигурации.

Но вы можете пойти дальше. Вы можете использовать это для определения объектно-ориентированной системы, если хотите. Скажем, мы определяем прямоугольник с полями width и height.

local rectangle = {width = 10, height = 20}

Затем назначьте функциональный объект клавише area, которая принимает прямоугольник в качестве аргумента и вычисляет площадь прямоугольника.

rectangle["area"] = function(rect)
  return rect["width"] * rect["height"]
end

Но есть эквивалентная синтаксическая сахарная версия этой формы, записанная так:

rectangle.area = function(self)
  return self.width * self.height
end

Вот еще одна эквивалентная форма:

function rectangle.area(self)
  return self.width * self.height
end

И, наконец, если мы используем символ : вместо ., тогда нам не нужно явно писать self в качестве первого аргумента, даже если это все еще первый аргумент:

function rectangle:area()
  return self.width * self.height
end

Есть аккуратная параллель с этим при доступе к объекту функции в хэш-таблице rectangle и его вызове. Две формы ниже эквивалентны:

rectangle.area(rectangle)
rectangle:area()

Теперь вместо того, чтобы назначать функцию area каждому прямоугольнику, вы можете вместо этого назначить все функции, которые вы хотите для каждого прямоугольника, другой хеш-таблице; назовем это Rectangle. Затем мы можем установить мета-таблицу для rectangle и любого другого прямоугольного объекта так, чтобы она указывала на эту мета-таблицу. Это означает, что когда вы вызываете rectangle:area(), вы фактически обнаруживаете area в мета-таблице. И вуаля, мы получили объектно-ориентированную систему с наследованием и всем остальным.

Отчасти привлекательность здесь в том, что Lua - действительно крошечный язык. Вы можете определить синтаксис на открытке. Тем не менее, у вас очень гибкий и универсальный язык с несколькими функциями.

Заключительные замечания

Есть много других интересных языков. Хотя в прошлом мне нравилось работать, например, с Ruby и Python, я не могу сказать, что они обязательно предлагали что-то революционно новое, чего я раньше не видел. Вместо этого он был больше о предложении нового пакета функций, которые ранее были исследованы в другом месте. Что касается меня, Ruby, например, позволил мне сделать многое из того, что вы можете делать в Smalltalk, более прагматично. Конечно, Ruby не имеет модели разработки на основе изображений, но он работает со старыми-добрыми файлами и может быть запущен как сценарий, такой как bash. Следовательно, вы можете использовать его для создания простых инструментов оболочки. Smalltalk, напротив, не совсем подходит для этого. Или, по крайней мере, не было, когда я впервые исследовал это.

Кроме того, есть языки, которые по-настоящему новы сами по себе, такие как Haskell, Rebol и Forth, но у меня просто слишком мало опыта, чтобы отдать должное.

Но я могу кратко упомянуть, что Haskell выводит системы типов и функциональное программирование на новый уровень. По крайней мере, так. Сейчас есть более современные варианты, например, Идрис.

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