Новый язык программирования
Внимание!!!
Данная статья содержит описание синтаксиса NewLnag предыдущей версии.
Актуальную версию синтаксиса языка можно посмотреть тут.
Более года назад я начал публикацию статей с описанием особенностей нового языка программирования. С тех пор утекло много воды, было протестировано множество идей, в итоге несколько раз все поменялось кардинальным образом и сейчас представляю на суд читателей описание предфинальной версии языка и его особенностей.
Данная статья предназначена в первую очередь для проверки основных концепций нового языка программирования, а также для получения обратной связи от читателей Хабра. Ведь согласно наблюдению Хабр-ума палата, не замыленный взгляд со стороны очень сильно помогает в проработке новых идей.
Этот проект очень долго был без собственного названия и в публикациях назывался просто и абстрактно “новый язык”. Но после нескольких статей, временное название “новый язык” постепенно превратилось в имя собственное NewLang, которое я и решил в конечном итоге оставить (что еще раз подтверждает поговорку, что нет ничего более постоянного, чем что-то временное).
NewLang - это язык программирования высокого уровня в котором можно сочетать стандартные алгоритмические конструкции с декларативным программированием и тензорными вычислениями для задач машинного обучения.
Основной особенностью языка является легкий, логичный и непротиворечивый синтаксис, который основан не на использовании зарезервированных ключевых слов, а на строгой системе грамматических правил с использованием знаков препинания (в список которых входят и операторы языка). Основные свойства и особенности языка:
- Возможность работы как в режиме интерпретатора, так и компилятора.
- Динамическая и статическая типизация с возможностью указания типов в явном виде.
- Статическая типизация является условно строгой (автоматическое приведение типов отсутствует, но допускается преобразование между некоторыми типами данных, например, целое число может быть автоматически преобразовано в вещественное, но не наоборот)
- Автоматическое управление памятью.
- ООП в виде явного наследования классов и утиная типизация.
- На уровне синтаксиса поддержка нескольких типов функций (обычные и чистые функции без побочных эффектов).
- Необязательные и именованные параметры функций.
- Возможны вставки кода на языке реализации (С/С++).
- Простая интеграция с уже существующими программными библиотеками (в том числе импорт нативных переменных и функций из С/С++).
- Имеется REPL read-eval-print loop — «цикл: чтение — вычисление — вывод».
Зачем нужен NewLang?
У всех современных языков программирования происходит постоянное развитие (читай усложнение) синтаксиса по мере выхода новых версий. Это является своего рода, платой за появление новых возможностей и воспринимается пользователями как естественное явление.
Но одновременно является и серьезной проблемой, т.к. с выходом новых версий добавляются новые ключевые слова и синтаксические конструкции, что неизбежно повышает порог входа для новых пользователей. Еще одним следствием этого процесса становится постоянное повышение сложности разработки и трудоемкости поддержки уже созданных программных продуктов, когда старый код дорабатывается с применением уже новых стандартов.
У NewLang сложность языковых конструкций естественно ограничена за счет разделения синтаксиса языка на две части, что упрощает его изучение и использование. Основной синтаксис — для написания программ в объектно-ориентированном (императивном) и декларативном стилях, который основан не на зарезервированных ключевых словах, а на строгих грамматических правилах и Расширенный синтаксис — когда основного синтаксиса становится недостаточно, или требуется использовать языковую конструкцию языка реализации.
Еще одно неудобство современных мейнстримовых языков, большинство из них были созданы до начала эпохи машинного обучения, поэтому тензорные вычисления у них выполнены в виде отдельных библиотек, а не встроены в основной синтаксис языка и систему базовых типов. У NewLang тензорные вычисления доступны «из коробки» (используется библиотека libtorch), а арифметические типы данных являются скалярами (тензорами нулевой размерности).
Основной синтаксис
Основной синтаксис NewLang - простой и логичный за счет того, что он построен исключительно на грамматических правилах и не использует каких либо зарезервированных ключевых слов, а все буквенно-символьные последовательности рассматриваются как идентификаторы в которых можно использовать любые не-ASCII символы.
Идеализированная цель отказа от ключевых слов, приблизить чтение исходного текста программы к чтению обычного текста за счет использования знаков препинания при описании логики работы алгоритма.
Конечно запятая человек может вычленять ключевые управляющие слова языка и слеш или учитывать форматирование программы запятая чтобы на их основе понимать синтаксические конструкции запятая хотя при обычном чтении мы привыкли опираться именно на семантику знаков препинания точка мы конечно можем писать знаки препинания и обычным текстом точка но согласитесь запятая что тогда открытая скобка например запятая вот такой вот текст закрытая скобка будет очень не удобно читать точка
Названия встроенных типов или имена служебных функций системной библиотеки определяются конкретной реализацией языка, поэтому не являются зарезервированными ключевыми словами и при необходимости могут быть переопределены, например, для создания собственного, предметно-ориентированного диалекта (DSL - domain-specific language), если в этом возникнет необходимость. Но сама структура программы и логика выполняемого алгоритма все равно останутся понятны всем, кто знаком с правилами основного синтаксиса NewLang.
Пример скрипта Hello world! на NewLang
#!./nlc --eval
# Определение функции hello
hello(str) := {
printf := @import('printf(format:Format, ...):Int'); # Импорт стандартной C функции
printf('%s\n', $str); # Вызов C функции с проверкой типов аргументов по строке формата
};
hello('Привет, мир!'); # Вызвать функцию
Вывод: Привет, мир!
Расширенный синтаксис
Расширенный синтаксис — это возможность вставить в текст программы NewLang исходный код на языке реализации. Сейчас это С/С++, что позволяет использовать любые возможности этого мощного языка программирования.
Обработка расширенного синтаксиса происходит на этапе компиляции приложения, а взаимодействие между основным и расширенным синтаксисами происходит за счет совместного использования идентификаторов, которое полностью прозрачно для пользователя и подчиняется единым грамматическим правилам основного синтаксиса.
Еще немного примеров:
Любая последовательность вычислений возвращает результат выполнения последнего оператора. Поэтому выполнение одной команды или последовательности команд всегда возвращает какой-либо результат, а оператор возврата из функции необязателен, так как результатом будет значение последнего вычисленного выражения.
Создание переменных
scalar := 42
42
tensor := [1,2,3,4,5,] # Тип тензора выводится автоматически
[1, 2, 3, 4, 5,]:Char
str := '$1 string'
$1 string
Арифметические операции
tensor * 2
[2, 4, 6, 8, 10,]:Short
tensor * 20
[20, 40, 60, 80, 100,]:Short
tensor * 0.5
[0.5, 1, 1.5, 2, 2.5,]:Double
tensor / 2 # Результат деления — число с плавающей точкой
[0.5, 1, 1.5, 2, 2.5,]:Double
tensor // 2 # Целочисленное деление без остатка
[0, 1, 1, 2, 2,]:Char</source>
tensor % 2 # Целочисленный остаток от деления
[1, 0, 1, 0, 1,]:Char</source>
Строковые операции
str = 'сцепеление строк ' ++ str;
сцепеление строк $1 string
str('строка как шаблон');
сцепеление строк строка как шаблон string
Преобразование тензоров
В эпоху машинного обучения тензоры являются основными элементами вычислений, поэтому для конвертирования данных в тензоры используется отдельная синтаксическая конструкция, состоящая из двойных квадратных скобок [[ данные ]]. Подробнее про особенности преобразования типов можно прочитать далее.
tstr := [["Тест"]] # Создать тензор из строки широких символов
[1058, 1077, 1089, 1090,]:Int
t2 := [[ "Тест" ]]:Int[2,2] # Тоже самое, но тензор двухмерный
[
[1058, 1077,], [1089, 1090,],
]:Int</source>
StrWide(tstr) # Конвертировать тензор обратно в строку
Тест
Double(t2) # Изменить тип данных тезора
[
[1058, 1077,], [1089, 1090,],
]:Double
t3 := [[ t2 ]]:Char[4] # Преобразовать тип данных тензора и его размерность
[34, 53, 65, 66,]:Char
Синтаксис NewLang:
При разработке синтаксиса я старался придерживаться уже сложившихся правил, чтобы не создавать множественных смыслов,
зависящих от контекста. И одновременно «объять необъятное»
Основы
- Операторы разделяются точкой с запятой «;».
- Отступы и переводы строк игнорируются (очень хотелось иметь возможность автоматического форматирование кода).
- Многострочные комментарии в исходном коде соответствуют стилю С/С++ и должны располагаться между символами /* и */. Вложенность многострочных комментариев не поддерживается.
- Однострочные комментарии начинаются с символа «#» до перевода строки, что соответствует комментариям в стиле Python и Bash.
- Последовательность выполняемых команд, которая должна выполняться как единое целое, заключается в фигурные скобки «{}».
- Программные вставки расширенного синтаксиса на языке реализации заключается в фигурные скобки со знаком процента %{ /* тут может быть любой код на C/C++*/ %}.
Создания объектов и присвоения новых значений
Для создания объектов и присвоения им новых значений в NewLang используется сразу три разных оператора.
Оператор «::=» используется только для создания новых объектов, а если объект с таким именем уже существует, то генерируется ошибка.
Оператор «:=» используется для тех же целей, но если объект с таким именем уже существует, то ошибки не происходит,
а новое значение присваивается уже существующему объекту.
И последний оператор «=» применяется только для присвоения значения уже существующим объектам, и если объект с указанным именем отсутствует, то тоже происходит ошибка.
Использование трех разных операторов для создания/изменения объектов позволяет более гибко контролировать подобные операции и выявлять логические ошибки в коде на более раннем этапе.
var ::= 1.0; # Создать новую переменную var без указания типа
var = 100; # Присвоить новое значение уже существующей переменной
printf := @import('printf(format:Format, ...):Int'); /* Создать новый или переопределить
объект printf, который будет результатом выполнения глобальной функции @import */
Идентификаторы объектов и модификаторы
В качестве идентификаторов можно использовать буквы, цифры и знаки подчеркивания в любых комбинациях, при условии, что первый символ идентификатора не является цифрой.
В NewLang существует возможность указания области видимости и времени жизни объекта с помощью модификатора — специального символа перед именем переменной. Это может показаться немного похожим на венгерскую нотацию, но в отличие от нее, модификатор не имеет отношения к типу объекта и не является частью имени идентификатора. К тому же в качестве модификаторов используется строго определённые символы, назначение которых определено заранее.
Так, символ «$» в начале имени обозначает локальную переменную, время жизни которой ограничено текущей областью видимости и при её завершении локальная переменная уничтожается. Символ «@» обозначает глобальную переменную, а сам объект сохраняет свое состояние даже после выхода из текущей области видимости. Так же обозначаются и имена типов данных, например, при создания новых типов, а в качестве модификатора используется символа двоеточия «:»
Семантика обращения к аргументам функций очень похоже на работу с аргументами в bash скриптах, где $1 или $arg — порядковый номер или имя аргумента (происходит обращение к локальным переменным в текущей области видимости).
Использование модификаторов является обязательным только в двух случаях: - При создании нового типа данных, так как типы всегда создаются в глобальной области видимости, а их символьные имена должны быть уникальными - При обращении к объектам NewLang внутри программных вставок кода на языке реализации, так как они используется как маркеры при поиске идентификаторов NewLang в коде С/С++.
В остальных случаях, для обращения к переменным указывать их модификаторы не обязательно. И если при обращении к объекту модификатор не указан, то сперва ищется локальная переменная, а потом глобальная с таким же именем. Причем, локальная переменная будет перекрывать глобальную.
Так же следует иметь в виду, что компилятор может генерировать код для прямого обращения к локальным объектам уже на этапе компиляции, тогда как для обращения к глобальным объектам, или если модификатор области видимости отсутствует, компилятор вынужден каждый раз встраивать runtime вызов функции поиска объекта в таблице символов.
Система типов
Так как система типов языка динамическая, то явное указание типа не влияет на размер переменной и является только своего рода логическим ограничением на возможность присвоения переменной значения другого типа.
Информация о типах используется при проверке их совместимости, когда существующему объекту присваивается значение другого типа. Такая операция возможна только когда типы совместимы между собой и допускают автоматическое приведение. Это справедливо как во время парсинга/компиляции исходного теста, так и во время выполнения в режимах интерпретатора и/или скомпилированного файла.
Арифметические типы:
Арифметические типы данных являются тензорами - массивами чисел одного типа с произвольным количеством измерений и одинаковым размером столбцов в каждом. Единичное число тоже тензор нулевого размера.
Поддерживаются только знаковые целые числа, т.к. в беззнаковых числах особая нужда отсутствует, а проблем с ними можно найти очень много на ровном месте.
Проблемы беззнаковых чисел (из интернета)
Во-первых, вычитание двух беззнаковых чисел, например 3 и 5. 3 минус 5 равно 4294967294, т.к. -2 не может быть представлено как беззнаковое число. Во-вторых, непредвиденное поведение может возникнуть при смешивании целочисленных значений со знаком и без знака. С++ может свободно преобразовывать числа со знаком и без знака, но не проверяет диапазон, чтобы убедиться, что вы не переполняете свой тип данных.
В C++ всё же есть несколько случаев, когда можно (или необходимо) использовать беззнаковые числа. Во-первых, числа без знака предпочтительнее при работе с битами. Во-вторых, использование беззнаковых чисел связаных с индексацией массивов.
Но это мой случай, так как индекс может быть отрицательным и даже не числом, а диапазоном или многоточием. З.Ы. И даже зная об этом, все равно умудрился недавно словить баг с отрицательными индексами у словарей!
Имена встроенных арифметических типов говорят сами за себя: Char, Short, Int, Long, Float, Double, ComplexFloat, ComplexDouble. Отдельным типом идет логический тип Bool, который может принимать значения только 0 или 1 (false/true соответственно), и в зависимости от выполняемой операции может быть отнесен к целочисленным типам, так и не входить в их состав.
(данный подход интерпретации логического типа данных был взят из библиотеки Torch)
// Treat bool as a distinct "category," to be consistent with type promotion
// rules (e.g. `bool_tensor + 5 -> int64_tensor`). If `5` was in the same
// category as `bool_tensor`, we would not promote. Differing categories
// implies `bool_tensor += 5` is disallowed.
//
// NB: numpy distinguishes "unsigned" as a category to get the desired
// `bool_tensor + 5 -> int64_tensor` behavior. We don't, because:
// * We don't want the performance hit of checking the runtime sign of Scalars.
// * `uint8_tensor + 5 -> int64_tensor` would be undesirable.
В будущем планируется добавить классы чисел для длинной арифметики и дробей, для чего зарезервированы названия типов BigNum, Currency и Fraction.
Доступ к элементам тензора происходит по целочисленному индексу, который начинается с 0. Для многомерного тензора, индексы элемента перечисляются в квадратных скобках через запятую. Поддерживается доступ к элементам через отрицательный индекс, который обрабатывается точно так же, как в Python (-1 последний элемент, -2 предпоследний и т.д.).
Литерал тензор в тексте программы записывается в квадратных скобках с обязательной завершающей запятой, т.е. [1, 2,] - это литерал одномерный тензор из двух чисел. После закрывающей скобки тип тензора может быть указан в явном виде. Если тип не указан, то он выводится автоматически на основании указанных данных и выбирается минимально возможный байтовый размер, который позволяет сохранить все значения без потери точности.
Примеры:
$var_char := 123; # Тип Char выводится автоматически
$var_short := 1000; # Тип Short выводится автоматически
$var_bool := [0, 1, 0, 1,]; # Тензор из 4 элементов. Тип Bool выводится автоматически
$tensor[10,10]:Int := 1; # Тензор Int размером 2x2 инициализированный 1
$scalar := $tensor[5,5]; # Присвоить скаляру значение указанного элемента тензора
Строковые типы данных:
Поддерживаются два типа строк, StrWide - символьные (широкие) и StrChar — байтовые. Различия между ними заключается в типе единичного элемента. У символьных строк единичным элементом является широкий символ wchar_t, а у байтовой строки единичным элементом является один байт (точнее char, т. е. байт со знаком). Символьные строки литералы в исходном тексте записывается в «двойных кавычках», а байтовые строки в ‘одинарных кавычках’.
Количество элементов символьной строки возвращается в широких символах, а размер байтовой строки в байтах, поэтому и обращение к элементу строки по индексу происходит соответственно либо к символу, либо к байту.
Важный момент. К любой переменной можно обратиться так же, как к функции (записав после её имени круглые скобки). Результатом этой операции будет создание копии/клона объекта. Причем некоторые типы (словари, классы и символьные строки) можно использовать в качестве шаблона при создании копии объекта с модифицированными свойствами, если новые и/или изменяемые значения указать в скобках, как аргументы при вызовах функций. Так, если при создании копии в скобках указать набор новых данных, то результирующая копия будет содержать уже измененные данные.
Например:
$template := "${name} $1"; # Обычная строка
$result := $template("шаблон", name = "Строка"); # result = "Строка шаблон"
Составные типы данных:
Словарь
Словарь — набор данных произвольного типа с доступом к отдельным элементам по целочисленному индексу или по имени элемента при его наличии (он похож и на tuple и на структуру одновременно). Словари от тензоров отличаются тем, что являются только одномерными массивами, но каждый элемент может содержать произвольное количество элементов любого типа, в том числе и другие словари.
Доступ к элементам словарей происходит по имени элемента, которое записывается через точку от имени переменной, либо по целочисленному индексу. Индекс также начинается с 0 и как у тензоров, тоже может быть отрицательным.
Литерал с типом «словарь» в тексте программы записывается в круглых скобках с обязательной завершающей запятой, т.е. (,) - пустой словарь, (1, 2= «2», name=3,).
Перечисление
Перечисление - это не отдельный тип данных, а обычный словарь, у которого все элементы имеют уникальные имена и целочисленные значение, которое явно указывается при определении или вычисляется автоматически (на единицу больше предыдущего элемента). У перечислений тип значения указывается сразу после закрывающей скобки через двоеточие (ONE=1, TWO=, THREE=): Int.
Классы
Класс (реализовано частично) - тип данных, с помощью которого реализуется один из принципов ООП - наследование. При создании экземпляра класса создается новая переменная, у которой сохраняется информацию о своем родителе и которая наследует от него свойства и методы. Тип данных «класс» аналогичен словарю, но все свойства обязаны иметь имена (хотя доступ к свойствам класса по индексу так же возможен). Литерал с типом «Класс» в тексте программы записывается в круглых скобках без завершающей запятой, т. е. () - пустой класс, (1, 2= «2», name=3).
Пока остальные детали классов до конца не реализованы, поэтому описывать их не буду, т. к. в итоговом варианте синтаксис классов и определения их методов могут поменяться.
Функции
Синтаксис NewLang поддерживать несколько типов функций (а в будущем и методов классов): обычные функции, чистые функции и простые чистые функции.
Для всех типов функций поддерживаются аргументы по умолчанию. При создании функции, её аргументы указываются как в Питоне, т.е. вначале идут обязательные аргументы, потом аргументы со значениями по умолчанию, где имя аргумента отделяется от его значения по умолчанию знаком равно «=». Если функция допускает обработку произвольного количества аргументов, то последним в списке параметров указывается многоточие.
Обычная функция
Обычная функция — такие функции являются именно обычными функциями в понимании С/С++. Внутри них можно писать совершенно любой код, включая проверки условий, циклы, вызовы других функций и т.д.
Внутри обычной функции можно обращаться к локальным и глобальным объектам, и они могут содержаться вставки на языке реализации, например, для вызова функций из внешних библиотек.
Вставки на языке реализации оформляются в виде %{ %} и могут содержать любой текст на С/С++, а прямо из него можно обращаться к локальным и глобальным объектам NewLang так же, как и в обычном синтаксисе, указывая первым символом имени соответствующий модификатор ($ для локальных объектов и @ для глобальных).
Технически, такая программная вставка просто переносится трансплайтером непосредственно в исходный текст генерируемого файла, а все идентификаторы NewLang специальным образом декорируются (добавляются специальные маркеры для их идентификации), после этого исходный текст подается на вход обычному компилятору С++. Для локальных объектов трансплайтер может генерировать код для прямого доступа к объекту на этапе компиляции, а для работы с глобальными объектами вынужден использовать runtime вызовы функции поиска в таблице символов.
Например:
print(str) := {
%{
printf("%s", static_cast<const char *>($str)); /* Прямой вызов С функции */
%}
};
Чистые функции
Чистая функция - это тоже обычная функция, только в том смысле, какой в него вкладывает функциональное программирование. Создания чистой функции происходит с помощью оператора «:-». У чистой функции отсутствует доступ к контексту и глобальным объектам, поэтому она может обрабатывать только те данные, которые были ей переданы в качестве аргументов.
Программные вставки на языке реализации внутри чистых функций не запрещены и могут использоваться, например, для отладки. Но делается это на страх и риск разработчика. Именно он отвечает за их «чистоту», например при вызове функций из внешних библиотек.
Sum(arg1, arg2) :- {$arg1+$arg2;}; # Вернуть сумму аргументов
Так как в языке отсутствует оператор возврата данных из текущего блока выполнения (аналог оператора return <данные>), то возвращаемым значением функции / блока кода всегда является результат выполнения последней операции.
Простые чистые функции
Простые чистые функции — отдельный класс чистых функций, которые предназначены только для вычисления логического результата (т.е. они являются предикатами) и их отличает упрощенная формой записи. Тело простой чистой функции состоит из последовательности операторов, которые разделяются запятыми и заканчиваются, как и любое выражение, точкой с запятой. Все операторы простой чистой функции всегда приводятся к булевому значению, а итоговый результат функции вычисляется по одной из возможных логических операций: И, ИЛИ и исключающее ИЛИ.
Например:
func_and(arg1, arg2) &&= arg1==3, arg2 > 0; # Простая чистая функция Логическое И
func_or(arg1, arg2) ||= arg1==3, arg2 > 0; # Простая чистая функция Логическое ИЛИ
func_xor(arg1, arg2) ^^= arg1==3, arg2 > 0; # Простая чистая функция Исключающее ИЛИ
Специальные типы данных:
None
None (пустой тип) — не содержит значения (точнее имеет одно значение None) и совместим с любым другим типом данных. Указывается в тексте программы как один подчерк «_». Значение None имеют не инициализированные переменные и при попытке чтения из такой переменной возникает ошибка.
Тип переменной может быть явно указан или выведен автоматически из присваиваемого значения. Присвоить новое значение уже инициализированной переменной можно только для совместимого типа, так как неявное преобразование типов не допускаются.
$var := _; # Создать переменную со значением None
$var2 := var; # Ошибка!!! Нельзя прочитать неинициализированную переменную var
$var = 1000; # У переменной будет тип Short (минимальный размер для хранения значения)
$var = 0,5; # Ошибка!!! Short ← Float не совместимы
$var = _; # Очистить значение переменной
$var = 0,5; # Теперь можно, т. к. None совместим с любым типом
Диапазон (Range)
Диапазон (реализовано частично) — специальный тип данных, являющейся приблизительным аналогом типа «генератор» в Python. К диапазону можно обращаться как к итератору и он будет поочередно выдавать элементы в указанном интервале с заданным шагом. Диапазон в тексте программы указывается как два или три элемента через две точки, например 1..5 — диапазон от единицы до пяти с шагом по умолчанию 1. В качестве параметров диапазона можно указывать не только литералы, но и имена переменных. Например, 0,1..$stop..0,1 — диапазон от значения 0,1 до значения, указанного в переменной $stop с шагом 0,1.
Диапазон для целых чисел можно использовать в качестве индекса у тензоров (точнее, у любых объектов, которые допускают доступ к своим элементам по индексу, т.е. тензоры, словари и текстовые строки). Фактический, это поведение аналогично slice в языке Python и array[1:5] в Python означает тоже самое, что и array[1..5] в NewLang.
В качестве индекса у тензоров еще можно указать произвольное количество измерений с помощью многоточия, т.е.
$tensor[…, 0] = 0; # Обнулить все первые элементы в каждом измерении.
Итераторы
Итераторы (в разработке) - самый сложный и неоднозначный тип данных для работы с элементами коллекций.
Для работы с итераторами зарезервированы символы “!” и “?”, но сами итераторы пока не реализованы.
Преобразование типов
Явное приведение типов
Несмотря на динамическую типизацию языка, если тип переменной указан явно, то автоматическое приведение типов не выполняется, и чтобы присвоить переменой значение не совместимого типа, требуется явное преобразование.
Так как символьные названия типов относятся к деталям реализации, то явное преобразование в конкретный тип данных производится с помощью вызова функции с системным именем, т. е. Bool(), StrWide(), Long и т. д. Причем у тензоров при таком преобразовании изменяется только тип данных, но размерность тензора не меняется.
Для преобразования любого типа данных в строку ещё можно использовать оператор конкатенации строк, которой преобразует любой тип данных в строковое представление. Но так как строковых типов два (байтовые и широкие строки), то тип строки определяется первым аргументом в операторе конкатенации, т. е.
"" ++ 123 # "123" - Строка широких символов
'' ++ 123 # '123' - Байтовая строка
Или преобразовать любое значение в строковое с помощью строки-шаблона:
val := 12345;
"$1"(val) # Будет строка "12345"
Tensor comprehensions
В эпоху машинного обучения тензоры являются основным элементом вычислений, поэтому для конвертирования данных в тензоры используется отдельная синтаксическая конструкция, состоящая из двойных квадратных скобок [[ данные ]]. Фактически это оператор и функция времени выполнения в зависимости от указанных между двойные квадратными скобками выражения.
Чтобы преобразовать любую переменную в тензор (с учетом допустимости такого преобразования), её достаточно указать между двойными квадратными скобками. Выражение [[ varibale ]] - преобразует переменную varibale в одномерный тензор с автоматическим выводом типа данных. Для преобразования в одномерный тензор конкретного типа используется выражение [[ varibale ]]:Type, где _Type- - любой из арифметических типов.
Если требуется преобразовать переменную не в одномерный тензор, а в тензор конкретного типа и заданной размерности, то это делается выражением [[ varibale ]]:Type[2,2], которая вернет тензор с размерностью 2х2 и типом Type у элементов.
Внутри двойных квадратных скобок может быть не только любое выражение, но и литерал или диапазон.
В этом случае, они также раскрываются в тензор по таким же правилам.
В будущем планирую добавить возможность указания сразу нескольких значений через запятую для их объединения в один тензор.
Примеры:
>[[(1,2,3)]] # Тензор из словаря
[1, 2, 3,]:Char
>[['first second']] # Байтовая строка в тензор
[102, 105, 114, 115, 116, 32, 115, 101, 99, 111, 110, 100,]:Char
> [[(first='first', space=32, second='second')]] # Получаем тензор из словаря с такими же данными
[102, 105, 114, 115, 116, 32, 115, 101, 99, 111, 110, 100,]:Char
>[[ 0 ... ]]:Double[10,2] # Тензор заданного формата с нулями
[
[0, 0,], [0, 0,], [0, 0,], [0, 0,], [0, 0,], [0, 0,], [0, 0,], [0, 0,], [0, 0,], [0, 0,],
]:Double
>[[ rand() ... ]]: Int[3,2] # Тензор со случайными данными
[
[1804289383, 846930886,], [1681692777, 1714636915,], [1957747793, 424238335,],
]:Int
>[[ 0..10 ]]: Int[5,2] # Тензор из диапзона
[
[0, 1,], [2, 3,], [4, 5,], [6, 7,], [8, 9,],
]:Int
>[[ 0..0.99..0.1 ]] # Или даже так
[0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9,]:Double</source>
Операторы и управляющие конструкции
Операторы:
Все операторы имеют парный оператор с присвоением значения.
- + и += сложение арифметических типов данных
-
- и -= вычитание арифметических типов данных
- / и /= деление (результат число с плавающей точкой)
- // и //= целочисленное деление с округлением к меньшему числу (как в Python)
-
- и *= умножение
- ** и **= возведение в степень (он же используется и для повторения текстовых строк)
- ++ и ++= конкатенация строк с автоматическим приведением аргументов к стоковому типу (символ инкремента специально используется вместо одиночного плюса для того, чтобы в явном виде разделить конкатенацию строк и операторы арифметического сложения)
Операторы сравнения:
- <, >, <=, >= классические для сравнения скаляров
- ==, != операторы сравнения с автоматическим приведением совместимых типов для любых объектов
- ===, !== оператор точного сравнения для любых объектов (автоматического приведения типов не выполняется)s
Проверки типов (в разработке):
Проверка имени класса «~» - немного похож на оператор instanceof в Java. Левым оператором должен быть проверяемый объект, а правым оператором - название типа, строка литерал или объект строкового типа с именем класса. Результатом операции будет истина, если правый операнд содержит название класса проверяемого объекта или он присутствует в иерархии наследования у проверяемого класса.
name := "class"; # Строка с именем класса
var ~ :class;
var ~ "class";
var ~ name;
(field1=«value», field2=2, field3=«33»,) ~~ (); # Истина (т. е. левый операнд словарь)
(field1=«value», field2=2, field3=«33»,) ~~ (field1=_); # Тоже истина (т. к. поле field1 присутствует у левого операнда)
Утиная типизация «~~» - приблизительный аналог функции isinstance() в Python, который для простых типов сравнивает совместимость типа левого операнда по отношению к правому, а для словарей и классов в левом операнде проверяется наличие всех имен полей, присутствующих у правого операнда. т. е.
(field1=«value», field2=2, field3=«33»,) ~~ (); # Истина (т. е. левый операнд словарь)
(field1=«value», field2=2, field3=«33»,) ~~ (field1=_); # Тоже истина (т. к. поле field1 присутствует у левого операнда)
Строгая утиная типизация «~~~» - для простых типов сравнивается идентичности типов без учета совместимости, а для составных типов происходит строгое сравнение всех свойств. Для данной операции, пустой тип совместим только с другим пустим типом!
Управляющие конструкции (в разработке)
Условный оператор
В качестве оператора проверки условия используется синтаксическая конструкция, соответствующая по смыслу термину «следует», т.е. тире и угловая скобка «->». Такая запись условного оператора очень похожа на математическую и легко объединяется в последовательности для проверки множественных условий вида «else if».
В общем случае условный оператор имеет вид:
условие -> действие;
или
(условие) -> {действие};
или
(условие1 || условие2) -> {действие} -> {действие иначе};
Или расширенный вариант «else if», для наглядности записанный с отступами:
(условие1) -> {действие1}
(условие2) -> {действие2}
(условие3) -> {действие3}
-> {действие_иначе};
Операторы циклов (в планах)
Операторы циклов пока в разработке , т. к. плотно связаны с итераторами.
Пока планирую для циклов использовать конструкции: (условие) <–> {тело цикла};
Или так: (условие) -» {тело цикла};
И хотя синтаксис мне не очень нравится, но я решил пока не ломать над этим голову и планирую попробовать несколько вариантов оформления циклов.
Операторы прерывания потока выполнения команд (реализовано частично)
Оператором прерывания потока выполнения команд и возврата из текущей функции, т.е. самым близким аналогом оператора return
является оператор два символа минус «–». Но в отличие от классического return, оператор возврата не возвращает значения,
т.к. значение из любой функции или блока кода возвращается всегда и им является результат выполнения самой последней операции
(или None, если такая операция отсутствует).
Пока не придумал, как оформлять оператор прерывания потока выполнения в случае ошибки (при его выполнении будет происходить генерация исключения), поэтому, если будут предложения, пишите в комментариях к статье (и про оформление циклов тоже).
Обработка ошибок (в планах)
В самом начале работ я ориентировался на классический вариант обработки исключений, который в обычных языках программирования обычно оформляется ключевыми словами try .. catch .. finally с различными вариациями. Но в условиях жестких ограничений на синтаксис языка, и невозможности использовать ключевые слова, комбинировать символы для указания разных типов блоков при обработке исключений, было бы крайне сомнительной затеей. Ведь основная цель разработки NewLang — простота и понятность кода, а тут с самого начала могут появиться комбинации скобочек, стрелочек, палочек и других подобных символов.
И тут в голову пришла очень простая мысль. А ненужно повторять логику обработки ошибок из классических языков программирования! Ведь основная цель подобных синтаксических конструкций - выделить участок кода где возможно возникновение ошибки, перехватить и обработать правильный тип исключения. Ведь классические языки программирования изначально были жестко привязаны к машинному представлению данных в оперативной памяти компьютера и тип данных для них играл принципиально важное значение. Но это не является ограничением для языков с динамической типизацией!
Поэтому, подход к обработке исключений планируется следующий: Программный код, который может привести к ошибке, заключается в двойные фигурные скобки {{ любой код или вызов одиночной функции }}, а результат выполнения такого блока кода присваивается переменной. После этого анализируется возвращенное значение и тип исключения может обрабатываться обычным условным оператором.
Наверно, это проще показать на примере:
$error := {{ # начало блока try
call_or_exception1();
call_or_exception2();
}}; # конец блока try
# Обычные условные операторы вместо типизированных блоков catch
($error ~ :type1)->{ код обработки ошибки 1}
($error ~ :type2)->{ код обработки ошибки 2};
Самое удивительное, что при таком подходе значительно упрощается и семантика блоков try … finally, которые вообще становятся не нужны.
Исходный код на Java:
try {
try {
throw new Exception("a");
} finally {
throw new IOException("b");
}
} catch (IOException ex) {
System.err.println(ex.getMessage());
} catch (Exception ex) {
System.err.println(ex.getMessage());
}
Эквивалентный ему на NewLang:
$catch := {{
$finally := {{
Error1("1");
}};
Error2("2"); # Строка выполнится даже при возникновении исключении Error1
$finally; # Error1 вернется, если не будет Error2
}}
($catch ~ :Error1) -> printf(«%s», $catch)
($catch ~ :Error2) -> printf(«%s», $catch);
Как все это попробовать?
Сейчас сборка проекта реализована только под Linux и если кроме текстового описания захочется в живую поэкспериментировать на своей машине, то придется собрать интерпретатор из исходников самостоятельно.
Так как текущий вариант предназначен первую очередь для отработки концепции, то часть из описанных возможностей пока не реализована (алгоритмические конструкции, наследование классов, итераторы, некоторые операции и т. д).
Но можно поиграться с созданием переменных, вызовом функций и выполнением арифметических операций над данными, чтобы оценить синтаксис, основанный на правилах, и может быть, предложить свои собственные мысли и доработки для его улучшения.
Сборка REPL из исходников (пока только под Linux)
Подготовка репозитория
- Скачать исходники
- Скачать и развернуть архив libtorch в каталоге contrib (PyTorch Build: Stable (1.10.*) -> Your OS: Linux -> Package: LibTorch -> Language: C++ / Java -> Compute Platform: CPU -> Download here (cxx11 ABI): libtorch-cxx11-abi-shared-with-deps-1.10.2+cpu.zip)
- Активировать и скачать исходники субмодулей (git submodule init && git submodule update)
- В каталоге contrib запустить файл build.sh для сборки библиотеки libffi
- В каталоге core запустить файл compile_syntax.sh для генерации файлов парсера и лексического анализатора. Может потребоваться установка утилит flex и bison. Если что, у меня установлены flex 2.6.4 и bison (GNU Bison) 3.7.4
Собрать
- Юнит-тесты (newlang_test): в каталоге core выполнить команду make CONF=UnitTest
- Интерпретатор (nlc): в каталоге core выполнить команду make CONF=Debug
Утилита nlc (NewLangCompiler)
В текущее время nlc поддерживать работу только в режиме интерпретатора (несмотря на название). Для тестирования и простой проверки компилятор не нужен, хотя на первых порах я делал именно его. Но трудоемкость работ по постоянной переделке под новый синтаксис оказалась очень высокой, поэтому на время первичной отладки языковых конструкций было принято волевое решение ограничиться интерпретатором, как более простым и быстрым способом проверки различных гипотез, а разработку настоящего компилятора (в виде трансплайтера на язык С++), отложить до окончательной проработки синтаксиса.
Планы на будущее
Естественное, одна статья и несколько маленьких примеров не дают исчерпывающей информации о возможностях языка. Да и сами возможности пока не раскрыты в полной мере. Ведь текущая версия, это скорее тестовая платформа для проверки декларируемых концепций и основного синтаксиса.
Пока остаются не реализованными некоторые из заявленных возможностей и очень важных хотелок. Но протестировать основной подход можно уже сейчас, и я буду благодарен за любую обратную связь и предложения.
Если говорить о планах (естественно, в будущих версиях что-то может добавиться или измениться порядок их реализации), но в настоящий момент роадмап развития NewLang мне видится следующим образом:
- Доделать стандартные управляющие конструкции, обработку ошибок и итераторы.
- Доработать систему типов с учетом множественного наследования классов.
- Реализовать длинную арифметику и дроби.
- Сделать какую нибудь логическую игру (крестики нолики, судоку или что-то похожее) с алгоритмическим выбором следующего хода и его вычислением с помощью машинного обучения.
- Написать много разных примеров для оценки синтаксиса.
- Доработать синтаксис с учетом полученного опыта и обратной связи.
- Восстановить работоспособность компилятора для генерации исполняемых файлов.
- Сделать очередную большую чистку кода.
- Переработать и задокументировать получившуюся семантику языка с учетом всех возможностей и выпустить первую полнофункциональную версию NewLang.