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)


  1. whoisking
    25.09.2026 04:14

    1. Stefanio Автор
      25.09.2026 04:14

      А зачем вы мне это прислали? Думаете я не знаю? Команда Rust молодцы, что тут сказать.

      Но я думал вы что-то своё покажете, а не чужую работу. Интересно было бы посмотреть на ваши личные компиляторные наработки.


      1. CrushBy
        25.09.2026 04:14

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

        Но в целом, зачем вообще привязывать функцию к одному «главному» объекту ? В Вашем же примере расстояние считается между двумя точками. Чем первая принципиально заслужила владеть этой операцией больше второй ?

        Например, остаток товара на складе на lsFusion можно описать так:

        CLASS Sku 'Товар';
        CLASS Stock 'Склад';
        
        balance 'Остаток' = DATA NUMERIC[14,3] (Sku, Stock);

        У свойства два параметра. Зачем выбирать, в какой класс его запихнуть — в товар или в склад ?

        А вообще, чтобы сослаться на Rust, обязательно сначала свой компилятор написать ? Вы бы лучше объяснили, чем Ваш подход отличается. Наличие своего компилятора у собеседника на это никак не влияет.


      1. historydev
        25.09.2026 04:14

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


  1. rukhi7
    25.09.2026 04:14

    и раскрываю все тайны IT‑индустрии!

    Это видимо была первая: мы научились считать квадрат расстояния в программе! Огромный прогресс однако!


    1. strelkove
      25.09.2026 04:14

      Феноменальное понимание сути статьи!


    1. Stefanio Автор
      25.09.2026 04:14

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


  1. Skubent
    25.09.2026 04:14

    Миллениалы эдак С изобретут


    1. zum
      25.09.2026 04:14

      Миллениалы

      Простите, но в всё‑таки «зумеры».


    1. Siemargl
      25.09.2026 04:14

      Эта техника появилась в Oberon-2, чуть позже


    1. LinkToOS
      25.09.2026 04:14

      Миллениалы эдак С изобретут

      А затем и Бейсик.

      -Это вы на каком языке сейчас программируете?
      -На бейсике.
      -Но у вас же в заголовке окна написано C#.
      -Да я на любом языке могу на бейсике программировать.


  1. N1X
    25.09.2026 04:14

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


    1. Siemargl
      25.09.2026 04:14

      Синьор дочитал документацию до главы Extension methods /s


    1. Stefanio Автор
      25.09.2026 04:14

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


      1. Viacheslav01
        25.09.2026 04:14

        Чем протокол отличается от класса?



  1. McUrgd
    25.09.2026 04:14

    Насколько проще стало без классов

    ...

    Отсутствие классов в компиляторе заставляет его автора оставить какой-то способ связать данные с функциями.

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

    Можно объявить ещё один тип Coordinates с теми же полями x и y и передать его в lengthSquared. Другое имя само по себе не делает данные несовместимыми.

    В ООП их просто наследовали бы от единого суперкласса.

    К нашему lengthSquared(p: Point) можно добавить второй вариант — для квадрата расстояния до другой точки:

    Исходная функция с одним параметром это частный случай расстояния до нуля координат. Зачем тут перегрузка, когда можно обойтись default value.


    1. Stefanio Автор
      25.09.2026 04:14

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


  1. kmatveev
    25.09.2026 04:14

    Я удалил классы, но оставил объекты с методами

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


    1. Stefanio Автор
      25.09.2026 04:14

      Вы не правы, это не классы, а протоколы


      1. ksbes
        25.09.2026 04:14

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


        1. Stefanio Автор
          25.09.2026 04:14

          Отличия фундаментальные, странно что вы их не понимаете.

          Протоколы - это конструкт статического анализа, который появляется и исчезает на уровне текста программы. Это у меня в языке есть.

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


          1. Viacheslav01
            25.09.2026 04:14

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

            Как не назови хоть type хоть class по сути одно и то же, разметка памяти.

            А внешние методы ровно то же, что и методы класса, просто функция первый аргумент которой ссылка на объект типа.


            1. ksbes
              25.09.2026 04:14

              Класс - это вообще “академическое” понятие из прикладной математики ( aka CS). Кокретные реализации и синтаксис на “классовость” не влияют никак.


            1. KonBone
              25.09.2026 04:14

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


              1. DieSlogan
                25.09.2026 04:14

                В C# они тоже имеются


                1. KonBone
                  25.09.2026 04:14

                  Тогда мне совсем не ясно по какой причине автор называет это протоколом


                  1. Stefanio Автор
                    25.09.2026 04:14

                    потому что protocol в Swift и trait в Scala


  1. ksbes
    25.09.2026 04:14

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


  1. Keeper12
    25.09.2026 04:14

    Поздравляю, вы открыли утиную типизацию



  1. KrimsN
    25.09.2026 04:14

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


    1. Stefanio Автор
      25.09.2026 04:14

      Пример решения реальной задачи https://habr.com/ru/articles/1010530/


  1. Ciyoxe
    25.09.2026 04:14

    Тоже занимался подобной идеей, но столкнулся со множеством проблем, когда ЯП начинает обрастать "мясом".

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

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



    1. plutarh
      25.09.2026 04:14

      Что-то вроде классов типов в Haskell?


      1. lgorSL
        25.09.2026 04:14

        Ага, так и хотелось сказать что это type class на минималках.

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


    1. Stefanio Автор
      25.09.2026 04:14

      Я больше скажу. Всё это кажется смешно и просто, пока сам с нуля не напишешь


  1. mixsture
    25.09.2026 04:14

    Хотелось, чтобы эти вещи работали без обязательной упаковки в класс.

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


  1. Ydav359
    25.09.2026 04:14

    Мне структурная типизация из TS тоже видится более элегантной, чем то, что мы имеем в шарпе с явным наследованием


  1. 26rus_mri
    25.09.2026 04:14

    Картинка с троллейбусом из буханки