Введение
В течение нескольких лет я работаю над языком программирования для хобби под названием styx, и одной из важных функций, которую необходимо было добавить, была возможность перегрузки операторов.
Например, компилятор не знает, как сравнить два несвязанных значения (например, struct), и наивное автоматическое сравнение двух связанных значений переходит от плохого (например, сравнение всех членов) к очень плохому (например, побитовое сравнение). Плохой метод может работать с полями, не представляющими идентичность экземпляра, а очень плохой может вернуть неверные результаты из-за указателей и жирных указателей, пропуская сравнение того, на что указывает член.
Возможность перегружать сравнения решает эту проблему (конечно, если вы хотите использовать операторы вместо вызовов).
Я прокомментирую и объясню путь, выбранный styx.
Примеры из других языков
Давайте посмотрим на синтаксис нескольких других систем перегрузки операторов. Я возьму системы, которые основаны, как и Styx, на функциях-членах, и которые позволяют перегружать вызовы.
struct S // D
{
auto opCall(){}
// ^-----special identifier used to recognize the overload
}
struct S // c++
{
void operator()() const {}
/// ^-----special toks used to recognize the overload
};
Мы можем выделить два стиля.
Стиль D основан на предопределенных идентификаторах. Это не на 100% удобное решение для пользователя, потому что вы должны помнить имя метода, но это четкая система, удобная для компилятора, потому что можно быстро найти, если что-то перегружено. Один поиск в таблице символов, а затем можно попробовать перегрузку.
Стиль C++ удобен для пользователя, ()
означает вызов, его легко запомнить. Однако компиляторная сторона этой системы не очень хороша. В частности, я бы критиковал тот факт, что для разбора перегрузок операторов требуется специальное правило грамматики.
Способ Стикса
Синтаксис перегрузки операторов в Styx не требует новой языковой конструкции и не основан на специальных идентификаторах. Он использует параметрический атрибут функции.
Атрибуты Styx имеют вид
Attribute ::= "@" Identifier ("("Expression ("," Expression)*")")?
Например, чтобы указать компилятору, должен ли он инлайнить функцию или нет.
@inline(true) function inlineMePlz();
@inline(false) function dontInlineMePlz();
Операторы повторно используют этот синтаксис, но, в отличие от C++, полное выражение, а не только символы, определяет, что функция должна перегружать, и это не требует специальных случаев в парсере.
struct S
{
@operator(a(b)) function overloadCall();
}
Это работает хорошо, потому что компилятор использует именованные значения, операторы, для различения типа конкретного выражения. Например, эта сильно очищенная версия выражения CallExpression показывает, что его оператор установлен на leftParen
. (обратите внимание, что setOperator()
вызывается унаследованным конструктором)
@final class CallExpression : UnaryExpression
{
@override function setOperator() { operator = leftParen; }
}
Семантика проверяется с помощью паттерна посетителя. Посетитель диспетчеризируется не с помощью динамических кастов, а с помощью оператора nodes. (для быстрого объяснения это связано с тем, что в компиляторе мы часто заботимся только о самом производном типе узла, а динамические приведения ищут идентичность во всем списке наследования, что излишне)
@inline function visitConcreteExpression(Expression* e)
{
var Expression** ae = &e;
switch e.operator do
{
on equal .. lesserEqual do visit( *(ae:CmpExpression**) );
on plus do visit( *(ae:AddExpression**) );
// etc...
on leftParen do visit( *(ae:CallExpression**) );
// etc....
}
}
А при посещении CallExpression достаточно проверить, содержит ли экземпляр агрегата, используемый в качестве параметра this
, функцию, присоединенную к атрибуту @operator
, которая имеет в качестве аргумента выражение с оператором leftParen
.
Перед проверкой семантики выражения по умолчанию вызывается следующая процедура. (она добровольно полностью скопирована, чтобы показать, что обработка перегрузок оператора styx является очень общей).
/** Returns: `null` if operator overload is not possible and a CallExpression otherwise.
* If the result is valid, its semantic is not yet run. */
function tryOperatorOverload(Expression* e; Expression*[] args): Expression*
{
assert(args.length);
assert(args[0].type, "call tryOperatorOverload after una.exp or bina.left sema");
// only allowed on custom types or on pointer to custom types (DotExp implicit deref)
var auto tp = args[0].type.asTypePointer();
var auto ta = tp ? tp.modified.asTypeAggregate() else args[0].type.asTypeAggregate();
if !ta do return null;
// be sure that the aggregate `hasOpOvers` is defined
if ta.declaration.progress != SemanticProgress.done do
assert(0, "cannot try opover if aggregate declsema is not run");
var auto ad = ta.declaration;
var auto ads = ad.symbol;
if !ad.hasOpOvers do
return null;
// handle non-overridden opovers
var Type*[+] chain;
getInheritanceChain(ta, chain);
chain ~= ta;
foreach var auto t in chain.reverse do
{
var auto ts = asTypeAggregate(t).declaration.symbol;
foreach var auto s in ts.children do
{
// on every member funcs and overloads ...
var auto fd = symToFuncDecl(s);
var auto od = symToOverDecl(s);
var auto funOrOverDecl = fd ? od;
if !funOrOverDecl do
continue;
// ... that are annotated @operator....
var Attribute* oa;
if (oa = funOrOverDecl.getAtOperator()) == null do
continue;
// dont check param count if func is an overload set
var auto parametersMatch = true;
if fd do
{
if fd.isStatic() do
parametersMatch = fd.parameters.length + 1 == args.length;
else do
parametersMatch = fd.parameters.length == args.length;
}
foreach var auto p in oa.arguments do
{
var bool isMacroOp;
if var auto oom = asOpOverloadMacro(p) do
isMacroOp = getMacroOperatorKind(e) == oom.kind;
//... that handles the right operator...
if (p.operator == e.operator || isMacroOp) && parametersMatch do
{
var auto id = (new IdentExpression).create(args[0].startPos, funOrOverDecl.name,
fd ? fd.returnType else od.asTypeDeclared, funOrOverDecl.symbol);
var auto de = (new DotExpression).create(args[0].startPos, args[0], id);
de.symbol = funOrOverDecl.symbol;
de.type = fd ? fd.returnType else od.asTypeDeclared;
var CallExpression* ce = (new CallExpression).create(args[0].startPos, de);
ce.arguments = args[1..$];
// ... rewrite as a CallExp
ce.isOpOverRewrite = true;
return ce;
}
}
}
}
return null;
}
Если процедура возвращает не нулевое значение, то перегрузка операторов сработала и семантика по умолчанию пропущена.
Опять же, использование операторов выражения делает обработку перегрузки операторов простой и очень общей, но не полностью…
Темная сторона перегрузки операторов styx
К сожалению, и если вы не в курсе, компилятору часто приходится иметь дело с особыми или угловыми случаями.
Составные выражения
Хотя использование фиктивного выражения для распознавания того, что должно быть перегружено, в целом работает нормально, существует проблема составных выражений.
Рассмотрим пример присваивания индексному выражению
a[0] = 1;
Определим структуру, которая перегружает два выражения, использованные в примере
struct A
{
@operator(a=b) assign(u64 value){}
@operator(a[b]) getElem(u64 index): u64 {return 0;}
}
Наконец, перепишем вручную пример
var A a;
a.getElem(0).assign(1);
Это не работает, правильное this
теряется, и если по ошибке getElem()
вернет A
, то это будет r-значение, то есть не подходящее для присваивания.
Таким образом, в почти идеальном мире перегрузки оператора styx есть случаи, требующие специальной обработки.
Эта обработка заключается в просмотре подвыражений, вложенных в выражение, для распознавания и определения «макрооператора», который заменяет стандартный оператор. Рабочая версия A
наконец-то готова.
struct A
{
@operator(a[b]=c) assignToElem(u64 index; u64 value);
}
Частный случай BoolExpression
Styx позволяет перегружать BoolExpression (например, true
, false
). Эта поддержка была добавлена, потому что styx позволяет пользователю писать сокращенные условия, такие как
var s8[] array;
if array do {} // lowered to `if array.length != 0 do ...`
var s8* ptr;
if ptr do {} // lowered to `if ptr != null do ...`
Перегруженные BoolExpressions используются, когда проверяются сокращенные условия. Для агрегата это имеет следующий вид
struct S
{
@operator(true) function toCondition(): bool {return true;}
}
Но может быть записано
struct S
{
@operator(false) function toCondition(): bool {return true;}
}
поскольку определяющим является оператор выражения, а не его значение.
Но если вы понимаете, что это сбивает с толку, вы можете, например, использовать агрегатные экземпляры в качестве условия в условном выражении, например
struct S
{
@operator(false) function toCondition(): bool {return true;}
}
var S s;
// same as `s.toCondition() ? 0 else 1`
var auto v = s ? 0 else 1;
assert(s == 0);
Наконец
Здесь нет большого вывода, все языки программирования несовершенны. Перегрузка операторов в Styx работает в целом хорошо, за некоторыми исключениями. Наиболее важным является то, что был найден удобный для пользователя синтаксис и что случайно этот синтаксис оказался также полезным в реализации.
См. также
- Styx @оператор
- Перегрузка операторов в D.
- Перегрузка операторов C++.