Введение
С 2016-го года (с перерывами) я разрабатываю собственный язык программирования. Название данного языка — «Ü». Почему Ü? Потому, что хотелось однобуквенного названия, а все буквы из набора A-Z уже заняты.
Данная статья имеет задачу ознакомить публику с данным языком, дать составить общее представление о нём. Статья не ставит целью описать язык полностью, более полное описание языка, если надо, будет дано в последующих статьях.
Зачем нужен ещё один язык?
Я рассмотрел ряд существующих статически-типизированных компилируемых более-менее известных языков, и пришёл к выводу, что ни один из них меня вполне не устраивает. У всех них есть «фатальные» недостатки.
Конкретно по пунктам:
- C — слишком низкоуровневый и небезопасный
- C++ — по наследству получил много недостатков низкоуровневости, появились новые способы выстрелить себе в ногу, отсутствие рефлексии
- D — сборщик мусора, отдельные ссылочные типы
- Java — сборщик мусора, все композитные типы ссылочные, сильная завязанность на виртуальную машину. Многое и этого применимо и к языках на основе JVM.
- C# — недостатки во многом аналогичны Java
- Rust — необходимость явного взятия ссылок и явного их разыменования, (субъективно) опасность подхода, когда всё есть выражение и возвращает результат, наличие явного указания соответствия протоколу, отсутствие наследования
- Go — сборщик мусора, отсутствие шаблонов
- Swift — ссылочные типы, необходимость явного указания соответствия протоколу
Обнаружив несовершенство всех вышеперечисленных языков, я решил создать свой, лишённый, как мне кажется, всех недостатков.
Общее сведения
Ü — компилируемый, статически-типизированный язык со строгой типизацией. Язык содержит свободные функции, структуры, классы, методы для структур и классов. Существующие виды типов — фундаментальные, структурные (структуры, классы), массивы константного размера, кортежи, перечисления, указатели на функции. Классы могут участвовать в наследовании и иметь виртуальные функции. Язык поддерживает шаблоны, есть шаблоны классов и структур, псевдонимов типов, функций и методов. Присутствует перегрузка функций и операторов (с некоторыми ограничениями).
Язык поддерживает деструкторы для классов и структур. Деструкторы используются для управления ресурсов, в т. ч. памятью.
Что уже есть
Реализован компилятор на основе LLVM. Компилятор поддерживает все платформы, что поддерживает LLVM. Чисто исполняемый файлы компилятор создавать пока что не умеет, но умеет порождать объектные файлы, ассемблерный код, бинарный или текстовый llvm-код. Есть возможность связи с C кодом, есть утилита, позволяющая упростить написание Ü-заголовков для C библиотек.
Цели и задачи
Язык создаётся с таким расчётом, чтобы отловить максимальное количество типовых ошибок на этапе компиляции. Решения по дизайну языка принимаются в первую очередь исходя из этой задачи. Второй целью является (относительная) простота написания программ и простота их чтения.
Язык не содержит конструкций, которые могли бы спровоцировать к написанию ошибочного кода, а также не содержит особенностей, которые могли бы существенно осложнить понимание происходящего в программе.
Несмотря на вышеизложенные пункты, нельзя игнорировать вопрос производительности. Поэтому в ущерб производительности решения по дизайну языка не принимаются.
Итак, давайте рассмотрим, какие моменты есть в Ü, которые соответствуют заявленным целям.
Система типов
В отличие от C++, в Ü не существует указателей и ссылок как типов. Указателей нету вообще, ссылки есть, но только как стековые ссылки, ссылочные аргументы функций, ссылочные поля. Также модификаторы mut/imut не являются частью типа, а являются частью переменной, ссылки, аргумента функции, поля.
Благодаря такому упрощению повышается понимание того, где какой тип, особенно в шаблонном коде. Отсутствуют сомнения, будет ли объявлена ссылка или переменная, сомнения в результате typeof (аналог decltype из C++), сомнения в константности/неконстантности.
Обратной стороной этого упрощения является необходимость отдельной обработки константности и ссылок в шаблонах. Хотя, это добавляет наглядности, когда параметр константности передаётся в шаблон явно отдельным аргументом.
Инициализаторы
Невозможно объявить какую-то переменную и не инициализировать её. Любой байт объявленной переменной должен быть инициализирован.
Фундаментальные (и некоторые другие) типы требуют обязательной инициализации:
var i32 x= 22, y(12345);
Требуют инициализации поля классов и структур:
struct Vec{ f32 x; f32 y; }
...
var Vec v{ .x= -56.1f, .y= 42.0f };
var Vec v_uninit; // Будет ошибка
Для структур и классов можно создать конструктор по умолчанию, и тогда явная инициализация не потребуется:
struct Vec
{
f32 x; f32 y;
fn constructor()
( x= 0.0f, y= 0.0f )
{}
}
...
var Vec v; // позовётся конструктор
Конструктор по умолчанию может быть сгенерирован автоматически, если все поля можно сконструировать по умолчанию или если есть инициализатор поля.
Кстати, инициализатор поля:
struct Vec
{
f32 x= 0.0f;
f32 y= 0.0f;
}
Другие виды типов имеют свои инициализаторы, для массивов и кортежей нужен инициализатор [] с количеством элементов, равным количеству элементов в массиве или кортеже:
var [ i32, 4 ] a[ 1, 1, 3, 4 ];
var tup[ i32, f32 ] t[ 8, 0.5f ];
var [ f32, 16 ] a_uninit; // Ошибка, члены массива неконструируемы по умолчанию.
var[ i32, 3 ] aa[ 0, 1 ]; // Ошибка, мало инициализаторов
В отличие от C++, элементы структуры нельзя инициализировать просто передав инициализаторы в определённом порядке, обязательно указывать имя поля. Это предотвращает проблемы, когда структуру поменяли, а инициализаторы — нет.
Язык поддерживает конструкторы и список инициализации для конструкторов. При инициализации членов через список инициализации отслеживается, какие поля уже проинициализированы, а какие — ещё нет. Также из списка инициализации недоступен this, а значит нельзя позвать какие-либо методы/функции, передав им недоинициализированные данные:
struct Vec
{
f32 x; f32 y;
fn constructor()
( x= y, y= 0.0f ) // Ошибка, «y» ещё не инициализирован
{}
fn constructor( f32 val )
( x= val, y= x ) // Нормально. «x» уже инициализирован и его можно использовать для инициализации «y»
{}
}
Отслеживание ссылок
В языке есть механизм отслеживания ссылок. На этапе компиляции проверяется, что на одну и ту же стековую переменную или аргумент функции есть одновременно или только одна изменяемая ссылка, или от нуля до бесконечности неизменяемых ссылок. При обнаружении нарушения данного правила компилятор порождает ошибку.
Простейший пример:
var i32 mut x= 0;
var i32 &mut ref0= x;
++x; // Ошибка, для инкремента нужно создать ещё одну изменяемую ссылку
var i32 &imut ref1= x; // Ошибка, неизменяемую ссылку создать нельзя, т. к. уже есть одна изменяемая
Другой пример:
var i32 mut x= 0;
var i32 &imut ref0= x;
var i32 &mut ref1= x; // Ошибка, нельзя создать изменяемую ссылку, если уже есть неизменяемые ссылки
Однако можно так:
var i32 mut x= 0;
var i32 &mut ref0= x;
var i32 &mut ref1= ref0; // Так можно, т. к. изменяемая ссылка указывает не на саму переменную, а не другую изменяемую ссылку
Ссылки учитываются при вызове функций:
fn Mutate( i32 &mut x, i32 &mut y );
...
var i32 mut x= 0;
Mutate( x, x ); // Ошибка, создаём две изменяемые ссылки на одну и ту же переменную
Данный механизм позволяет отлавливать типичные ошибки доступа к памяти. Пример на C++
std::vector<int> vec;
vec.push_back(1);
int& ref= vec.front();
vec.push_back(2); // Беда, ссылка ref после этого может указывать на неправильный участок памяти
В Ü подобного рода проблемы поймаются на этапе компиляции:
var ust::vector</i32/> mut vec;
vec.push_back(1);
var i32 &imut ref= vec.front();
vec.push_back(2); // Ошибка, создание изменяемой ссылки на переменную vec, на которую уже есть неизменяемые ссылки
Наследование
Участвовать в наследовании могут только классы (не структуры). В наследовании участвуют только полиморфные классы — помеченные как polymorph, interface, abstract или имеющие предков. Унаследоваться от неполиморфного класса нельзя. Полиморфные классы имеют виртуальный деструктор, явно не объявленный виртуальным деструктор всё равно будет виртуальным.
Виды полиморфных классов:
- Интерфейсы. Могу иметь только интерфейсных предков, не могут иметь полей, не могут иметь реализаций виртуальных функций.
Пример:
class A interface { fn virtual pure Foo(this); }
- Абстрактные классы. Могут иметь одного неинтерфейсного предка и несколько интерфейсных предков. Могут не иметь реализаций у некоторых виртуальных функций. Могут иметь конструкторы, но в конструкторах не доступен полностью this, а значит нельзя звать методы, в т. ч. виртуальные. Тем самым ликвидируется проблема abstract call, которая есть, к примеру, в C++.
Пример:
class A abstract { fn virtual pure Foo(this); fn virtual Bar(this){} i32 x= 0; }
- Просто полиморфные классы. Могут иметь одного неинтерфейсного предка и несколько интерфейсных предков. Требуется реализация всех виртуалных функций. Если какая-то виртуальная функция не реализована, будет порождена ошибка.
Пример:
class A interface { fn virtual pure Foo(this); } class B : A { fn virtual override Foo(this){} }
- Финальные полиморфные классы. Такие же, как обычные полиморфные классы, но от них нельзя наследоваться.
Только полиморфные классы имеют право иметь виртуальные функции. Для виртуальных функций требуется явно указывать вид виртуальности.
fn virtual pure Foo(this); // Объявление чистой виртуальной функции. Если предок уже имеет подобное объявление, или имеет объявление такой функции с наличием реализации, будет порождена ошибка.
fn virtual Foo(this){} // Объявление виртуальной функции с реализацией. Будет порождена ошибка, если функция перекрывает виртуальную функцию предка.
fn virtual override Foo(ths){} // Объявление виртуальной функции, перегружающей функцию предкового класса. Если у предкового класса нету объявления этой виртуальной функции, будет порождена ошибка.
fn virtual final Foo(this){} // Объявление виртуальной функции, окончательно перегружающей функцию предкового класса. Если у предкового класса нету объявления этой виртуальной функции, будет порождена ошибка. Если класс-потомок попробует перегрузить данную функцию, будет порождена ошибка.
Контроль потока выполнения
Возможно в 2019—м году это уже банальщина, но тем не менее, в Ü есть проверка верности потока управления. При обнаружении недостижимого кода порождается ошибка. В функциях, которые должны возвращать значение, порождается ошибка, если не доказано, что оно возвращается всегда.
Пример:
fn Foo( i32 mut x ) : i32
{
while(x < 100 )
{
x+= 10;
continue;
x+= 20; // Ошибка, недостижимый код
}
return x;
++x; // Ошибка, недостижимый код
}
Ещё пример:
fn Foo( i32 x ) : i32
{
if( x < 10 ) { return 0; }
else if( x > 100 ) { return 1; }
// Ошибка, функция не всегда возвращает значение
}
Пример выше можно исправить или добавив безусловный else с возвратом, или же добавив return в конце.
Для случаев, когда поток управления куда-то заходить не должен, предусмотрен оператор halt (см ниже). Этот оператор считается терминальным, что означает, что после него код не допустим, а ветвь потока управления учитывается в проверке наличия return.
Многопоточность
Проблема ошибок многопоточности это проблема одновременного доступа на запись к одним и тем же данным из разных потоков. Как нетрудно заметить, эта проблема отчасти похожа на множественный доступ к одним и тем же данным из одного потока (см. отслеживание ссылок). Поэтому данная проблема решается (отчасти) тем же механизмом.
К примеру:
struct CallableObject
{
i32 &mut x;
op()( mut this )
{
++x;
}
}
...
var i32 mut x= 0;
var CallableObject mut obj{ .x= x };
auto thread= ust::thread_create( move(obj) );
++x; // Ошибка, изменение переменной «x», на которую есть уже изменяемая ссылка в «thread»
++ obj.x; // Аналогичная ошибка
Механизм отслеживания ссылок позволяет или передать в отдельный поток ссылки на какие-то данные для модификации, лишая возможности доступа к этим данным текущий поток до разрушения дочернего потока, или же передавать в один или несколько потоков ссылки на чтение каких-либо данных, не отнимая возможности чтения этих данных у родительского потока.
Из возможности свободной передачи неизменяемых ссылок в другие потоки вытекает запрет на внутреннюю модификацию объектов в неизменяющих объект методах (mutable в C++), т. к. потенциально может возникнуть гонка. Если внутренняя изменяемость всё же нужна, её необходимо обезопасить мьютексами или чем-то подобным, чтобы исключить возможность гонки.
Однако, что если надо иметь возможность доступа на чтение и запись для одних и тех же данных из разных потоков? Для этого существуют специальные библиотечные классы разделяемых указателей с блокировкой — shared_ptr_mt и прочие. Поток, которому нужна запись, вызывает метод lock_mut() у класса указателя чтобы получить доступ на запись или lock_imut(), если нужен доступ на чтение. Данные методы блокирующие и гарантируют соблюдения правила «одновременно позволено существование или одной изменяемой ссылки, или нескольких неизменяемых».
Пример:
struct Incrementer
{
ust::shared_ptr_mt_mut</ i32 /> ptr;
op()( mut this )
{
auto mut lock= ptr.lock_mut(); // Разрушение объекта «lock» снимет блокировку
++lock.get_ref();
}
}
...
var ust::shared_ptr_mt_mut</ i32 /> ptr(0);
var size_type mut i(0);
var ust::vector</ ust::thread</Incrementer/> /> mut threads;
while( i < size_type(64) )
{
var Incrementer incrementer{ .ptr= ptr };
threads.push_back( ust::thread_create(incrementer) );
++i;
}
threads.clear(); // Дожидаемся выполнения потоков в вызове деструкторов.
halt if( ptr.lock_imut().get_ref() != 64 ); // Проверяем, что результат тот, что надо
Кроме разделяемых многопоточных указателей в стандартной библиотеке есть обычные разделяемые указатели, для случаев, когда блокировки использовать накладно. Такие указатели в общем случае небезопасно передавать в другой поток. Поэтому классы таких указателей помечаются специальным тегом «shared». При создании потока проверяется, что данные, переданные ему, не содержат в себе каких-либо данных с типом, помеченным как «shared».
Глобальные переменные
Изменяемых переменных нет. Позволены только глобальные константы, при этом не любые, а только constexpr типов. Как следствие, значения всех констант вычисляются на этапе компиляции и нету проблем с порядком инициализации.
Если глобальное изменяемое состояние нужно, реализовывать его придётся не на Ü, а на чём-то другом, например на C.
Операторы
В отличие от C++, операторы присваивания (=, +=, *= и т. д.) не возвращают никакого значения и более того, синтаксически являются утверждениями, а не выражениями. Аналогично и с операторами ++ и –.
Примеры ниже даже не будут синтаксически верными:
if( x = 0 ) {}
x++; // ошибка Можно только префиксный ++
x += x += x; // ошибка, второй += не опознан
x+= x++ + ++x; // и уж тем более нельзя так
Перегруженные операторы проверяются на корректность аргументов и возвращаемого значения. Операторы присваивания и инкремент с декрементом должны возвращать void, иначе будет порождена ошибка компиляции.
В языке присутствует тернарный оператор, но его синтаксис изменён, чтобы ликвидировать неоднозначности с приоритетом. Данный оператор выглядит так:
auto a = select( condition ? x : y );
ключевое слово select и скобки являются частью оператора.
Исключения
Их нет. Совсем нет. И не будет. Никогда не будет.
Почему так? У исключений как таковых есть ряд проблем, из-за которых их наличие приводит к большей вероятности написания ошибочного кода:
- Потенциальная возможность возникновения исключения в каждой строчке программы. Из-за этого сложнее сохранять консистентность данных, требуются как затраты времени программиста на решение проблем консистентности, так и затраты производительности на её реализацию.
- Нарушение уровней абстракции. Фундаментальная возможность пробросить исключение через несколько уровней стека вызовов порождает проблему нарушения абстракций. Какой-нибудь NullPointerException малоинформативен где-нибудь наверху, где его поймают при попытке выполнить какую-то высокоуровневую операцию. Проблему отчасти могло бы решить принуждение к обработке исключений конкретной функции в месте вызова, но тогда теряется смысл механизма исключений, т. к. то же самое поведение можно было бы реализовать через механизм возврата.
- Неоднозначность трактовки понятия исключения. Как следствие, в общем случае непонятно, где нужно кидать исключение, а где возвращать значение. Из-за этого в одной и той же программе может быть принято по-разному реализовывать по сути один и тот же код, если над программой работают более одного человека. Как следствие, ухудшается восприятие кода.
- Проблема обработки исключения при обработке исключения. За тем, чтобы такого не возникло, надо следить вручную.
В случае, когда функция не может вернуть т. н. «ожидаемый» результат, надо возвращать результат или признак неудачи, наподобие std::optional/std::variant из C++ или Option/Result из Rust.
Для случаев, когда возникло что-то, с чем дальше программа работать не может, предусмотрен оператор halt. Данный оператор передаёт управление внешнему (по отношению к Ü программе) обработчику, который должен аварийно завершить программу, возможно, отправив об этом отчёт разработчику.
Модульность
Как таковых модулей пока что нету, но всё-же есть импортирование содержимого из другого файла. Исходный файл, указанный после директивы import, зачитывается и компилируется, после чего объявления из него можно использовать. В отличие от C++, это полноценная компиляция отдельного файла, без учёта имён предыдущих импортированных файлов.
Пока что нету ограничения видимости содержимого импортируемого файла. Но можно разбивать модули на заголовочную и внутреннюю части, аналогично *.cpp и *.hpp из C++.
Заключение
Кроме вышеперечисленных особенностей, есть ещё много других элементов языка, в которых отразились выбранные принципы, всех в одной статье не перечислить.
Ряд элементов языка могут показаться неидеальными в синтаксическом плане, первоочерёдной задачей было создать работающее решение, а над синтаксисом долго я не раздумывал. Но, думаю, это поправимо, язык ещё развивается. Если обнаружится, что синтаксис какого-то элемента неудачен, его можно поменять.
Ссылки
→ Репозиторий с проектом
→ Пример сборочного скрипта, чтобы было понятно, как собрать проект.
→ Примеры кода можно увидеть в тестах и в стандартной библиотеке.
Документации пока что нету, т. к. пока рано её создавать, да и руки до неё не дошли.