Во многих языках можно объявить несколько функций с одним именем, если у них разные сигнатуры. Это называется перегрузкой функций. Пример перегрузки в C# -

class Calculator
{
    // Два целых числа
    public static int Sum(int a, int b)
    {
        return a + b;
    }

    // Два дробных числа
    public static double Sum(double a, double b)
    {
        return a + b;
    }

    // Три целых числа
    public static int Sum(int a, int b, int c)
    {
        return a + b + c;
    }
}

К сожалению (скорее к счастью), перегрузки не были добавлены в JS. Мне захотелось добавить их, чтобы попробовать написать что-то вроде следующего примера:

function random() {
  return Math.random();
}

// Случайное число от 0 до max
function random(max) {
  return Math.random() * max;
}

// Случайное число от min до max
function random(min, max) {
  return min + Math.random() * (max - min);
}

random();        // например, 0.73
random(10);      // например, 7.3
random(20, 30);  // например, 27.3

В примере random функции различаются только числом аргументов. Здесь начнём с перегрузок с явными аннотациями number и string. Как включать в такую группу функции без аннотаций или без параметров, пока оставим открытым.

Для эксперимента возьмём MuJS - небольшой интерпретатор JavaScript на C. Проследим, как он выполняет return a + b, а затем добавим аннотации параметров и выбор перегрузки.

1. Исполнение кода

Для MuJS основная цепочка выглядит так:

Исходный текст JavaScript -> лексер: jslex.c
Токены -> парсер: jsparse.c
AST - абстрактное синтаксическое дерево -> компилятор: jscompile.c
Байткод стековой виртуальной машины -> исполнитель: jsrun.c
Результат

Для разбора возьмем функцию суммирования двух переданных аргументов:

function sum(a, b) {
  return a + b;
}

Лексер MuJS читает исходный текст программы и выдаёт токены по запросу парсера. При чтении следующего токена jsP_next() вызывает jsY_lex(J) и обновляет J->lookahead. Ниже - условная запись последовательности для sum(a, b); конец потока опущен:

["function", "sum", "(", "a", ",", "b", ")", "{", "return", "a", "+", "b", ";", "}"]

Парсер MuJS написан методом рекурсивного спуска: он использует текущий токен и запрашивает следующий через jsP_next(). Так постепенно строится AST. Для наглядности представим дерево условной JSON-схемой:

{
  "type": "FunctionDeclaration",
  "id": {
    "type": "Identifier",
    "name": "sum"
  },
  "params": [
    { "type": "Identifier", "name": "a" },
    { "type": "Identifier", "name": "b" }
  ],
  "body": {
    "type": "BlockStatement",
    "body": [
      {
        "type": "ReturnStatement",
        "argument": {
          "type": "BinaryExpression",
          "operator": "+",
          "left": { "type": "Identifier", "name": "a" },
          "right": { "type": "Identifier", "name": "b" }
        }
      }
    ]
  }
}

MuJS хранит AST в C-структурах js_Ast. Утилита mujs-pp -s печатает его в виде S-выражений. Вот фрагмент вывода с объявлением sum:

(fundec (identifier sum) [(identifier a) (identifier b)] [
  (stm_return (exp_add (exp_identifier a) (exp_identifier b)))
])

Компилятор MuJS преобразует AST в байткод. Исполнитель jsR_run() читает инструкции байткода и выполняет их обработчики. Сам интерпретатор заранее скомпилирован из C в машинный код; отдельная машинная функция для sum в этом пути не создаётся.

Сокращённый дамп тела sum из mujs-pp -c без адресов и служебных строк:

getlocal a
getlocal b
add
return

После явного return компилятор также оставляет завершающие undef; return; здесь они опущены.

cparams() регистрирует параметры как локальные переменные. При компиляции EXP_ADD функция cbinary() сначала выдаёт инструкции для левого и правого операндов, затем OP_ADD; оператор return выдаёт OP_RETURN. В дампе имена a и b подставлены утилитой из таблицы переменных: в байткоде операнды getlocal числовые.

В jsrun.c обработчик OP_CALL вызывает js_call(). Для JavaScript-функции исполнитель подготавливает кадр вызова и запускает её байткод.

Стек вычисления выражения в теле sum(2, 3) меняется так; служебные части кадра не показаны:

Инструкция

Стек значений выражения

До вычисления

[]

getlocal a

[2]

getlocal b

[2, 3]

add

[5]

return

Результат 5 передан вызывающему коду

Операция add сохраняет семантику JavaScript. В MuJS её обработчик обращается к js_concat(), которая реализует и числовое сложение, и конкатенацию после необходимых преобразований. Поэтому результат зависит от значений:

sum(2, 3);   // 5
sum(2, "3"); // "23"

Оба результата проверены запуском исходного MuJS. Одно и то же тело функции обрабатывает разные типы динамически.

2. Добавление перегрузки

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

function sum(a: number, b: number) {
  return a + b;
}

function sum(a: number, b: string) {
  return a + b;
}

Исходный MuJS не принимает аннотации параметров.

Лексер уже умеет читать двоеточие: в jsY_lexx() есть обработчик ':'. Слово number можно прочитать как обычный TK_IDENTIFIER, не превращая его в новое ключевое слово. Последовательность токенов будет такой:

function sum ( a : number , b : number ) {
  return a + b ;
}

Аннотация number относится к параметру в объявлении функции; токены выражения return a + b остаются прежними.

Следовательно, для этого варианта синтаксиса изменения лексера не нужны. Нужно научить parameters() разбирать правило:

parameter := identifier [ ':' type_name ]
parameters := [ parameter ( ',' parameter )* ]

3. Где хранить тип параметра

В нашей JSON-схеме поле type обозначает вид узла - в данном случае Identifier. Для аннотации параметра добавим поле typeAnnotation :

{
  "type": "Identifier",
  "name": "a",
  "typeAnnotation": "number"
}

В MuJS аналогично нельзя менять js_Ast->type: там должно остаться AST_IDENTIFIER. Один из простых вариантов расширения - добавить отдельное поле paramType и перечисление допустимых аннотаций:

enum js_ParamType {
    JS_PARAM_ANY,
    JS_PARAM_NUMBER,
    JS_PARAM_STRING
};

struct js_Ast
{
    enum js_AstType type;
    enum js_ParamType paramtype;
    /* остальные поля */
};

В jsparse.c добавим функцию parameter() для разбора имени параметра и его аннотации:

static js_Ast *parameter(js_State *J)
{
    js_Ast *param = identifier(J);

    if (jsP_accept(J, ':')) {
        if (J->lookahead != TK_IDENTIFIER)
            jsP_error(J, "expected parameter type");

        if (!strcmp(J->text, "number"))
            param->paramtype = JS_PARAM_NUMBER;
        else if (!strcmp(J->text, "string"))
            param->paramtype = JS_PARAM_STRING;
        else
            jsP_error(J,
                "unsupported parameter type: %s",
                J->text);

        jsP_next(J);
    }

    return param;
}

После добавления поля и его инициализации обе операции LIST(identifier(J)) в parameters() заменяются на LIST(parameter(J)). Форма списка сохраняется, а объявления параметров получают метаданные:

static js_Ast *parameters(js_State *J)
{
    js_Ast *head, *tail;
    if (J->lookahead == ')')
        return NULL;
    head = tail = LIST(parameter(J));
    while (jsP_accept(J, ',')) {
        tail = tail->b = LIST(parameter(J));
    }
    return jsP_list(head);
}
AST_FUNDEC "sum"
├── параметры
│   ├── AST_IDENTIFIER "a"
│   │   └── paramtype = JS_PARAM_NUMBER
│   └── AST_IDENTIFIER "b"
│       └── paramtype = JS_PARAM_NUMBER
└── тело
    └── STM_RETURN
        └── EXP_ADD
            ├── EXP_IDENTIFIER "a"
            └── EXP_IDENTIFIER "b"

На схеме опущены служебные узлы AST_LIST, а отдельный узел имени функции заменён подписью.

Компилятор связывает использование a с его объявлением через область видимости, поэтому достаточно хранить аннотацию у параметра.

У такого изменения есть граница: parameters() используется также другими путями создания функций, включая разбор параметров конструктора Function. При внедрении нужно решить, где разрешён новый синтаксис, и проверить все эти пути. Первое расширение удобно ограничить объявлениями функций и явно диагностировать неподдерживаемые формы.

4. От аннотаций к перегрузкам

Сохранённые типы ещё не обеспечивают выбор реализации. Теперь движку нужно разрешить несколько объявлений одного имени и собрать их в одну вызываемую группу:

function sum(a: number, b: number) {
  return a + b;
}

function sum(a: number, b: string) {
  return a * 5 + b;
}

Ожидаемый результат:

sum(2, 3);   // 5: выбрано первое тело
sum(2, "3"); // "103": выбрано второе тело

Второй результат - строка: 2 * 5 даёт 10, а 10 + "3" даёт "103".

Что делает сигнатуру уникальной

Для выбора перегрузки важны группа в конкретной области видимости, количество параметров и упорядоченный список типов:

sum / 2 / [number, number]
sum / 2 / [number, string]

Имена параметров не влияют на уникальность: sum(x: number, y: number) повторяет первую сигнатуру. Порядок типов влияет: [number, string] и [string, number] - разные варианты. Тип результата здесь не участвует в выборе, поскольку до выполнения тела результат ещё не получен.

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

Что меняется в компиляторе MuJS

В исходной версии MuJS функция cfundecs() обрабатывает каждое объявление отдельно. Если имя повторяется, последнее объявление заменяет предыдущее. В прототипе изменено cfundecs(): теперь она собирает перегрузки с одним именем в группу и привязывает к этому имени функцию-диспетчер.

Его задача:

  1. Собрать объявления одного имени в текущем списке объявлений.

  2. Преобразовать аннотации в канонические сигнатуры и проверить дубликаты.

  3. Скомпилировать каждое тело существующим механизмом newfun().

  4. Сохранить типы параметров в метаданных скомпилированных функций.

  5. Создать одну функцию-диспетчер и привязать её к имени sum.

Например, в js_Function можно добавить массив paramTypes длиной numParams и признак overloaded. Таблицу funtab функции-диспетчера можно использовать для ссылок на реализации, если явно определить такое назначение для перегруженной функции:

Внешний скрипт
└── funtab[0]: диспетчер sum, overloaded = 1
    ├── funtab[0]: реализация sum(number, number)
    │   ├── paramtypes: [NUMBER, NUMBER]
    │   └── code: getlocal a; getlocal b; add; return; ...
    └── funtab[1]: реализация sum(number, string)
        ├── paramtypes: [NUMBER, STRING]
        └── code: getlocal a; integer 5; mul;
                 getlocal b; add; return; ...

В обычных функциях funtab по-прежнему хранит вложенные функции. Группа не должна смешивать эти два назначения без проверки признака overloaded.

Аннотации копируются в скомпилированное представление: хранить только указатели на AST нельзя, поскольку дерево разбора может быть освобождено до вызова функции. Для новых полей нужны инициализация и освобождение памяти. В частности, выделенный массив paramTypes освобождается в jsG_freefunction(). Ссылки на реализации должны учитываться сборщиком мусора; существующий обход funtab в jsG_markfunction() уже показывает место, которое нужно проверить.

Во внешнем коде останется одна последовательность closure; setlocal; pop для всей группы. Вызов всё ещё можно выразить обычной инструкцией call 2.

5. Где выбирать реализацию при вызове

Лексер и парсер работают с программным текстом. Они не могут в общем случае узнать тип значения, которое переменная получит во время выполнения:

var value = readValue();
sum(2, value);

Поэтому в рассматриваемом расширении выбор происходит при вызове. Естественная точка в MuJS - js_call() в jsrun.c, до запуска тела JavaScript-функции.

Сначала движок получает вызываемый объект. Если это обычная функция, он использует существующий путь. Если это группа перегрузок, он сравнивает число и типы фактически переданных аргументов с метаданными реализаций, выбирает одну из них и запускает её через существующие jsR_calllwfunction() или jsR_callfunction().

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

До перенастройки кадра в js_call(J, n) стек имеет следующий хвост:

... | функция | this | аргумент 0 | ... | аргумент n-1

Значит, первый аргумент доступен по относительному индексу -n, второй - по -n + 1. Для проверки можно использовать существующие предикаты MuJS js_isnumber() и js_isstring(), не преобразующие значение к другому типу.

Для первого варианта расширения достаточно следующего контракта:

Ситуация

Правило

Совпадение

Число аргументов и каждый тип совпадают точно

Преобразования

Строка "3" не превращается в число ради выбора перегрузки

Дубликат

Одинаковые сигнатуры одного имени в одной группе запрещены

Нет подходящего тела

TypeError с именем функции и фактическими типами

Нет аннотации

Обычная одиночная функция работает как раньше; смешение типизированных и нетипизированных объявлений одной группы отклоняется

Эти правила относятся к функциям, у которых аннотирован каждый параметр. Парсер принимает и частичные аннотации вроде f(a: number, b), но правила выбора для таких перегрузок ещё не заданы. Если трактовать ANY как отсутствие ограничения, одному вызову могут соответствовать несколько сигнатур.

На первом этапе поддерживаются только аннотации number и string. Другие значения не совпадают с ними. Если позже появятся null, массивы, функции или классы, нужно определить их отдельно. Например, простое использование typeof недостаточно для различения null, массива и обычного объекта.

Проверка аннотаций при входе в функцию не равна полной статической типизации: она не запрещает присвоить параметру значение другого типа внутри тела. Запись похожа на TypeScript, но полной системы типов здесь нет.

6. Результат: запускаем перегрузки

Проверим эту схему на экспериментальной сборке MuJS. В ней аннотации проходят через AST в метаданные функций, объявления собираются в группу, а js_call() выбирает тело по фактическим типам аргументов.

Для короткой демонстрации используем такой код:

function sum(a: number, b: number) {
  return a + b;
}

function sum(a: number, b: string) {
  return a * 5 + b;
}

print('sum(2, 3) = ' + JSON.stringify(sum(2, 3)));
print('sum(2, "3") = ' + JSON.stringify(sum(2, "3")));

try {
  sum(2, true);
} catch (error) {
  print(error.name + ': ' + error.message);
}

Запускаем сборку:

.\mujs-overloads.exe .\overloads.js

Вывод:

sum(2, 3) = 5
sum(2, "3") = "103"
TypeError: no overload for sum(number, boolean); received 2 argument(s)

Оба вызова выбрали нужные тела. Благодаря JSON.stringify() строковый результат "103" показан в кавычках. Для числа и логического значения перегрузки нет, поэтому третий вызов выдаёт TypeError.

Эта демонстрационная сборка ограничена полностью аннотированными объявлениями с number и string: число аргументов и типы должны совпадать точно. Частичные аннотации, смешанные группы, типизированные функциональные выражения и вызов группы через new пока отклоняются. Обычные нетипизированные функции сохраняют поведение исходного MuJS.

Запись запуска:

Рис. Демонстрация запуска
Рис. Демонстрация запуска

Заключение

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

Комментарии (0)