
HydraScript — мой интерпретатор на C#. В нём есть объекты, статическая типизация и вызовы методов. А class, interface и constructor я вообще не добавлял.
Точке на плоскости нужны две координаты, для вычисления расстояния до другой точки нужна функция. Хотелось, чтобы эти вещи работали без обязательной упаковки в класс.
Тут я вдохновлялся сразу двумя языками: структурной типизацией TypeScript и методами с явным получателем из Go. Подход Go заодно сильно упрощал жизнь мне самому — автору языка. Функции у меня уже работали, и методы можно было построить на их основе, не вдаваясь в реализацию классов.
Сначала данные и функция
Вот вся программа:
type Point = { x: number; y: number; } function lengthSquared(p: Point): number { return p.x * p.x + p.y * p.y } let point: Point = { x: 3; y: 4; } >>> point.lengthSquared()
Она выведет 25.
Функция живёт отдельно от объявления типа. Вызов point.lengthSquared() передаёт point первым аргументом, поэтому функцию можно вызвать и как lengthSquared(point). Параметр p — первый в списке, а потому является receiver. Он явно записан в сигнатуре, а не спрятан за неявным this. В Go для receiver есть отдельная секция параметров перед именем метода, а в HydraScript я использую обычный первый параметр для простоты реализации.
Можно добавить новую операцию, вообще не трогая объявление данных. Связь с ними уже есть в сигнатуре функции, отдельное тело класса для этого не требуется. Главное указать тип объекта.
Объекту не нужно разрешение быть Point
Уберём аннотацию у переменной и вызовем lengthSquared(point) напрямую. Получим те же 25. Мы нигде не объявляли, что литерал что‑то реализует. Его поля уже подходят под тип параметра функции.
Это и есть структурная часть системы типов. Для таких объектов совместимость определяется именами и типами свойств. Можно объявить ещё один тип Coordinates с теми же полями x и y и передать его в lengthSquared. Другое имя само по себе не делает данные несовместимыми.
Все правила совместимости TypeScript я переносить не стал: HydraScript требует одинаковое количество свойств. Добавим z: 5 — вызов будет отклонён, хотя функция вообще не читает z. Заменим число в y на строку — тоже получим ошибку. Слово «структурная» ещё не обещает, что подойдёт любой объект, у которого есть хотя бы нужные поля. При этом вызове сравнивается вся структура.
Нужная часть ObjectType.Equals довольно короткая:
if (obj is ObjectType that) return ReferenceEquals(this, that) || _properties.Count == that._properties.Count && _properties.Zip(that._properties) .All(pair => pair.First.Key == pair.Second.Key && pair.First.Value.Equals(pair.Second.Value));
Если ваш взгляд зацепился за Zip — да, порядок перечисления словаря здесь важен. При построении типов и из объявлений, и из литералов объектов свойства заранее сортируются по имени. Поэтому запись { y: 4; x: 3; } не превратит нашу точку в другой тип.
Как тип узнаёт о своих методах
Компилятору нужно правило, по которому lengthSquared связывается с Point. В HydraScript за это отвечает первый параметр: его тип должен быть записан через имя объектного типа.
Посетитель объявлений создаёт связь здесь:
if (parameters is [ObjectType methodOwner, ..] && visitable.Arguments is [{ TypeValue: TypeIdentValue }, ..]) _methodStorage.BindMethod(methodOwner, functionSymbol, functionSymbolId);
Любопытная часть условия — TypeIdentValue. Мало того, что параметр имеет объектный тип: в аннотации должно стоять имя типа. Если вписать ту же структуру { x: number; y: number; } прямо в параметр, обычный вызов функции продолжит работать, но метод не зарегистрируется. Такое соглашение подсказывает компилятору, куда привязать поведение.
В ObjectType эти сведения хранятся отдельно:
private readonly Dictionary<string, Type> _properties; private readonly List<FunctionSymbolId> _methods = [];
Это представление типа внутри компилятора. Указатель на функцию в каждую созданную во время выполнения точку мы не добавляем.
BindMethod записывает идентификатор метода в конкретный ObjectType, представляющий Point, а саму функцию сохраняет в MethodStorage. По собственному списку типа анализатор проверит, какие имена методов доступны получателю. Через хранилище найдёт подходящую перегрузку. В ключах хранилища участвует структурное равенство, но от этого списки методов всех равных типов не заполняются автоматически.
Перегрузки методов
Раз методы используют обычные функции, перегрузки для них тоже не пришлось придумывать заново. К нашему lengthSquared(p: Point) можно добавить второй вариант — для квадрата расстояния до другой точки:
function lengthSquared(p: Point, other: Point): number { let dx = p.x - other.x let dy = p.y - other.y return dx * dx + dy * dy } let other: Point = { x: 3; y: 0; } >>> point.lengthSquared() >>> point.lengthSquared(other)
Для point из начала статьи получим:
25 16
Имя одинаковое, но сигнатуры разные: у первой функции один параметр Point, у второй — два. Receiver никуда не исчезает из сигнатуры, просто при вызове через точку мы не пишем его внутри скобок. Перегрузки могут отличаться и типами параметров, а не только их количеством.
В FunctionSymbolId ключ строится из имени функции и последовательности типов параметров:
protected override string Value { get; } = $"function {name}({ZString.Join(", ", parameters)})";
Поэтому в MethodStorage под одним именем могут жить разные перегрузки. Привязка к типу сохраняет каждую из них по своему FunctionSymbolId. Для методов мне понадобилось добавить поиск через получателя, а правила различения функций уже были готовы.
Параметры по умолчанию
С необязательными параметрами получилось так же. Например, вместо отдельной перегрузки для каждой комбинации координат можно задать им значения по умолчанию. Добавим к тому же Point ещё один метод:
function distanceSquared(p: Point, x = 0, y = 0): number { let dx = p.x - x let dy = p.y - y return dx * dx + dy * dy } >>> point.distanceSquared() >>> point.distanceSquared(3) >>> point.distanceSquared(3, 4)
Результат:
25 16 0
Обязательные параметры идут первыми, параметры со значениями по умолчанию — в конце. При регистрации такого объявления анализатор заранее добавляет варианты сигнатуры без необязательного хвоста. Для distanceSquared это вызовы с одним, двумя и тремя аргументами, если считать receiver.
В DeclarationVisitor цикл начинается с IndexOfFirstDefaultArgument. Для каждого допустимого числа параметров внутри него выполняется такая регистрация:
var overload = new FunctionSymbolId(visitable.Name, parameters[..i]); var existing = parentTable.FindSymbol(overload); var functionToAdd = existing is not null && existing < functionSymbol ? existing : functionSymbol; parentTable.AddSymbol(functionToAdd, overload); if (parameters is [ObjectType overloadOwner, ..] && visitable.Arguments is [{ TypeValue: TypeIdentValue }, ..]) _methodStorage.BindMethod(overloadOwner, functionToAdd, overload);
Сокращённая сигнатура попадает и в таблицу символов, и в привязки методов. Новое тело функции для неё не создаётся. Но найти функцию — ещё не значит передать ей значения пропущенных аргументов.
При генерации инструкций виртуальной машины InstructionProvider сохраняет значение по умолчанию в PopParameter. При исполнении эта инструкция делает выбор:
parameter.Set( executeParams.Arguments.TryDequeue(out var argument) ? argument : defaultValue);
Статический анализ разрешает вызов с меньшим числом аргументов, а PopParameter подставляет недостающее при входе в функцию. Отдельной версии этого механизма для методов нет: receiver уже стоит первым в очереди аргументов.
Поля одинаковые, методы находятся по‑разному
Оставим объявления Point и lengthSquared, а затем заведём две переменные:
let annotated: Point = { x: 3; y: 4; } let inferred = { x: 3; y: 4; }
Обе можно передать в lengthSquared. Вызвать её через точку может только annotated. На inferred.lengthSquared() анализатор ответит:
Object type {x: number;y: number;} has no field lengthSquared
Слово «поле» тут неудачное — мы ведь попросили метод. Но сообщение указывает на нужный этап: поиск члена с указанным именем у получателя. До выбора перегрузки компилятор ещё не добрался.
Противоречие получается, если читать результат Equals как «эти объекты компилятора взаимозаменяемы во всех отношениях». Но сравнение выше проверяет только свойства. О списках методов оно ничего не говорит.
Для литерала анализатор создаёт новый ObjectType на основе его полей. Он не перебирает все именованные типы в поисках такой же структуры и не собирает их методы. Если же аннотация указана явно, анализатор проверяет совместимость и оставляет объявленный тип. Вот этот выбор в SemanticChecker:
var actualType = registeredSymbol.Type.Equals(_typesService.Undefined) ? sourceType : registeredSymbol.Type;
У annotated остаётся представление Point, к которому привязана функция. У inferred — тип литерала. Совпадение полей само по себе эту связь не копирует.
Указать нужную связь можно и после создания объекта:
let inferred = { x: 3; y: 4; } let point: Point = inferred >>> point.lengthSquared()
Получим 25. Аннотация даёт компилятору сведения о методе. Отдельной операции, которая копирует lengthSquared в объект во время выполнения, здесь нет.
Другое имя для той же структуры тоже не переносит метод. Переменную типа Coordinates по‑прежнему можно передать в обычную функцию lengthSquared, но метод Point у неё от совпадения полей не появится. Для вызова через точку значимы обе аннотации: у переменной и у первого параметра функции.
Виртуальная машина работает только с функциями
Вся эта разница остаётся в статическом анализе. Виртуальная машина ничего не знает про методы.
Для вызова через точку поиск члена сначала проверяет свойства и имена связанных методов получателя. Затем компилятор подбирает перегрузку, считая получателя первым аргументом. Прямой вызов начинается с имени функции и типов аргументов, поэтому предварительный поиск члена ему не нужен.
После выбора функции обе формы укладываются в одну модель выполнения. ExpressionInstructionProvider выдаёт PushParameter(caller) для получателя перед остальными аргументами, а затем CallFunction для найденной функции.
Виртуальная машина получает вызов функции с аргументами. Ей не требуется обходить иерархию классов или заново искать метод. Всю дополнительную работу ради точки уже проделал анализатор.
Вот эта часть решения мне и нравится: объявления функций, разрешение перегрузок и вызовы у нас уже есть. Методы используют их же. Дополнительный механизм связывает объектный тип с функцией и передаёт ей первый аргумент.
Насколько проще стало без классов
Пользователю языка для начала хватает структуры данных и функции. Отсутствие классов в компиляторе заставляет его автора оставить какой‑то способ связать данные с функциями. В HydraScript для этого служат именованные объектные типы и явный параметр‑получатель.
Можно было бы искать методы полностью по структуре. Тогда понадобились бы правила для одинаковых структур с конфликтующими методами и для выбора объявлений функций, участвующих в поиске.
Для небольшого интерпретатора мне нравится возможность реализовать вызов point.lengthSquared() как обычный вызов функции. HydraScript выбирает простоту и учит нас, что это нормально.
Ещё я веду Telegram канал StepOne, куда выкладываю много интересного контента о программировании на C#, даю карьерные советы, рассказываю истории из личного опыта и раскрываю все тайны IT‑индустрии!
Комментарии (42)

rukhi7
25.09.2026 04:14и раскрываю все тайны IT‑индустрии!
Это видимо была первая: мы научились считать квадрат расстояния в программе! Огромный прогресс однако!

Stefanio Автор
25.09.2026 04:14Прогресс действительно огромный, потому что программа сделана на собственном языке программирования. Вы подменяете понятия

N1X
25.09.2026 04:14Что-то пытаюсь понять но суть ускользает. Я правильно понимаю, что объявлена точка (point) и вызван ее метод? Т.е. у точки есть поведение и состояние? Т.е. point по сути является экземпляром класса Point несмотря на отсутствие слова "class"? В общем суть изобретения не ясна.

Stefanio Автор
25.09.2026 04:14Вы не правы, Point здесь это не класс а протокол. А объект это экземпляр протокола. То есть практический настоящее прототипирование

McUrgd
25.09.2026 04:14Насколько проще стало без классов
...
Отсутствие классов в компиляторе заставляет его автора оставить какой-то способ связать данные с функциями.
Выглядит так, что Вы сами создали себе ограничение и придумали велосипед для его обхода, вернувшись в исходную точку.
Можно объявить ещё один тип
Coordinatesс теми же полямиxиyи передать его вlengthSquared. Другое имя само по себе не делает данные несовместимыми.В ООП их просто наследовали бы от единого суперкласса.
К нашему
lengthSquared(p: Point)можно добавить второй вариант — для квадрата расстояния до другой точки:Исходная функция с одним параметром это частный случай расстояния до нуля координат. Зачем тут перегрузка, когда можно обойтись default value.

Stefanio Автор
25.09.2026 04:14Я не преследую цели сделать ООП язык. Перегрузка нужна для демонстрации возможностей интерпретатора. Я же написал полностью с нуля весь движок, который обрабатывает код из статьи

kmatveev
25.09.2026 04:14Я удалил классы, но оставил объекты с методами
Это неправда, у вас именно что классы, только вместо ключевого слова class используется type, а синтаксис объявления метода сделан как частный случай объявления функции. Грубо говоря, нельзя сделать функцию, принимающую type как первый аргумент, и не создать при этом метод. Чего в этом хорошего, я хз. Выглядит как чисто синтаксическое упражнение.

Stefanio Автор
25.09.2026 04:14Вы не правы, это не классы, а протоколы

ksbes
25.09.2026 04:14И в чём отличие? Ну кроме как расстояние по Левенштейну в 8?

Stefanio Автор
25.09.2026 04:14Отличия фундаментальные, странно что вы их не понимаете.
Протоколы - это конструкт статического анализа, который появляется и исчезает на уровне текста программы. Это у меня в языке есть.
Классы - материализация в памяти, которая протекает на низкие уровни и тащит за собой конструкты в духе таблиц виртуальных методов или заголовков синхронизации. Этого у меня в языке нет.

Viacheslav01
25.09.2026 04:14Я тоже не понимаю, ни VT ни синк примитивы не обязаны быть в классах, по факту класс это тот же конструкт статического анализа.
Как не назови хоть type хоть class по сути одно и то же, разметка памяти.
А внешние методы ровно то же, что и методы класса, просто функция первый аргумент которой ссылка на объект типа.
ksbes
25.09.2026 04:14Класс - это вообще “академическое” понятие из прикладной математики ( aka CS). Кокретные реализации и синтаксис на “классовость” не влияют никак.

KonBone
25.09.2026 04:14Чтобы никому обидно не было, есть ещё
structв Си) И мне кажется, что это самое близкое определение этой точки, а возможно и точное

ksbes
25.09.2026 04:14Хотите методы без классов - идите в Раст. Там это всё очень хорошо математически продуманно.

KrimsN
25.09.2026 04:14Работа сама по себе классная — разобрать метод до “функция + receiver + резолв по имени типа” и собрать это в рабочий компилятор не так просто, respect. Но по применимости не уверен: без traits/interfaces (как в Rust или Go) структурная проверка работает “в одну сторону” — метод не увидит literal без явной аннотации, и полиморфизма тут не появляется. По сути это
function(receiver, ...)с доп. слоем резолва через точку, без выгод, ради которых обычно и отказываются от классов. Как учебный проект — топ, но какую реальную задачу это решает лучше, чем прямой вызов функции?

Ciyoxe
25.09.2026 04:14Тоже занимался подобной идеей, но столкнулся со множеством проблем, когда ЯП начинает обрастать "мясом".
Например, как сделать так, чтобы два объекта имели разные методы, что-то типа
myCube.volume()иmySphere.volume()? Получается, что нужно будет разрешать перегрузку функций по типу аргумента, но тогда что делать, если перегрузки конфликтуют друг с другом, что если несколько одинаковых функций импортируются из других модулей, и...В общем, на синтетических примерах идея кажется простой до гениальности, а в реальном рабочем ЯП превращается в глубокую кроличью нору костылей.

plutarh
25.09.2026 04:14Что-то вроде классов типов в Haskell?

lgorSL
25.09.2026 04:14Ага, так и хотелось сказать что это type class на минималках.
Я тоже пробовал свой язык дизайнить и кажется, что если есть типы-суммы с тегами и тайпклассы, то как будто можно построить адекватный язык без наследования. Отличия конечно будут, но мне понравилось что получилось, и штуки типа vtable и прочего если очень надо - можно эмулировать.

Stefanio Автор
25.09.2026 04:14Я больше скажу. Всё это кажется смешно и просто, пока сам с нуля не напишешь

mixsture
25.09.2026 04:14Хотелось, чтобы эти вещи работали без обязательной упаковки в класс.
зачем? как будто классы как раз придуманы, чтобы хранить рядом данные и методы работы с ними - для более простой организации кода.

Ydav359
25.09.2026 04:14Мне структурная типизация из TS тоже видится более элегантной, чем то, что мы имеем в шарпе с явным наследованием
whoisking
Смари чё есть
https://doc.rust-lang.org/rust-by-example/fn/methods.html
Stefanio Автор
А зачем вы мне это прислали? Думаете я не знаю? Команда Rust молодцы, что тут сказать.
Но я думал вы что-то своё покажете, а не чужую работу. Интересно было бы посмотреть на ваши личные компиляторные наработки.
CrushBy
Ну раз уж сами попросили :) Вот платформа lsFusion. Свой язык, открытая платформа. У нас свойства и действия объявляются отдельно от классов, есть множественное наследование и диспетчеризация по классам всех параметров. Более того, вообще нет обычной записи через точку.
Но в целом, зачем вообще привязывать функцию к одному «главному» объекту ? В Вашем же примере расстояние считается между двумя точками. Чем первая принципиально заслужила владеть этой операцией больше второй ?
Например, остаток товара на складе на lsFusion можно описать так:
У свойства два параметра. Зачем выбирать, в какой класс его запихнуть — в товар или в склад ?
А вообще, чтобы сослаться на Rust, обязательно сначала свой компилятор написать ? Вы бы лучше объяснили, чем Ваш подход отличается. Наличие своего компилятора у собеседника на это никак не влияет.
historydev
Выдумывать отдельные языки для прикладных задач, следом искать похвалы на форумах и конечно агрессивно защищать свою бурду - анимешник на лицо. "Подписаться не забудь, я там распальцовку для ссылки на объект показываю"