Во многих языках можно объявить несколько функций с одним именем, если у них разные сигнатуры. Это называется перегрузкой функций. Пример перегрузки в 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) меняется так; служебные части кадра не показаны:
Инструкция |
Стек значений выражения |
|---|---|
До вычисления |
|
|
|
|
|
|
|
|
Результат |
Операция 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(): теперь она собирает перегрузки с одним именем в группу и привязывает к этому имени функцию-диспетчер.
Его задача:
Собрать объявления одного имени в текущем списке объявлений.
Преобразовать аннотации в канонические сигнатуры и проверить дубликаты.
Скомпилировать каждое тело существующим механизмом
newfun().Сохранить типы параметров в метаданных скомпилированных функций.
Создать одну функцию-диспетчер и привязать её к имени
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(), не преобразующие значение к другому типу.
Для первого варианта расширения достаточно следующего контракта:
Ситуация |
Правило |
|---|---|
Совпадение |
Число аргументов и каждый тип совпадают точно |
Преобразования |
Строка |
Дубликат |
Одинаковые сигнатуры одного имени в одной группе запрещены |
Нет подходящего тела |
|
Нет аннотации |
Обычная одиночная функция работает как раньше; смешение типизированных и нетипизированных объявлений одной группы отклоняется |
Эти правила относятся к функциям, у которых аннотирован каждый параметр. Парсер принимает и частичные аннотации вроде 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.
Запись запуска:

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