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

—

от автора

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 из начала статьи получим:

2516

Имя одинаковое, но сигнатуры разные: у первой функции один параметр 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)

Результат:

25160

Обязательные параметры идут первыми, параметры со значениями по умолчанию — в конце. При регистрации такого объявления анализатор заранее добавляет варианты сигнатуры без необязательного хвоста. Для 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‑индустрии!

ссылка на оригинал статьи https://habr.com/ru/articles/1086432/