HydraScript — мой интерпретатор на C#. В нём есть объекты, статическая типизация и вызовы методов. А class, interface и constructor я вообще не добавлял.Точке на плоскости нужны две координаты, для вычисления расстояния до другой точки нужна функция. Хотелось, чтобы эти вещи работали без обязательной упаковки в класс.Тут я вдохновлялся сразу двумя языками: структурной типизацией TypeScript и методами с явным получателем из Go. Подход Go заодно сильно упрощал жизнь мне самому — автору языка. Функции у меня уже работали, и методы можно было построить на их основе, не вдаваясь в реализацию классов. Читать далее
Уровень сложностиПростой
Время на прочтение8 мин
Охват и читатели18K
Кейс

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‑индустрии!
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Как индексация строк изменила систему типов HydraScript | 0 | 8.06 | 17-10-2026 |
| 2 | Почему число это плохой адрес для инструкции? | 0 | 8.52 | 21-10-2026 |
| 3 | Почему "+=" сложнее реализовать, чем "+": опыт HydraScript | 0 | 11.23 | 21-09-2026 |
| 4 | Как я перенёс сборку лексера в compile-time | 0 | 7.63 | 27-09-2026 |
| 5 | Native AOT заставил меня переписать весь .NET проект | 0 | 8.36 | 22-09-2026 |
| 6 | [Перевод] Чему мы научились, написав на Rust среду выполнения для TypeScript | 0 | 6.35 | 12-08-2026 |
| 7 | Как и зачем я сделал свой маппер | -1 | 5.33 | 28-09-2026 |
| 8 | Мне надоело писать один и тот же код. Поэтому я сделал Featuregen | 3 | 6 | 09-07-2026 |
| 9 | [Перевод] Чему мы научились, написав на Rust среду выполнения для TypeScript | 0 | 6.35 | 12-08-2026 |
| 10 | Как сделать свой статический анализатор для Go? | 0 | 8.96 | 12-01-2026 |