Во многих языках можно объявить несколько функций с одним именем, если у них разные сигнатуры. Это называется перегрузкой функций. Пример перегрузки в 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.
Запись запуска:
Заключение
Получилось достаточно интересно, я полностью удовлетворил свое университетское желание, перегрузки получились достаточно ограниченными, но рабочими, чем я несказанно рад.

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