feat(oop): OPERATOR overloading — + - * / == != < > <= >=

Harbour lets a class define custom behaviour for arithmetic and
comparison operators via `OPERATOR "<sym>" ARG <name> INLINE <expr>`.
Five already had the runtime slot infrastructure (ClassDef.Operators
+ AddOperator + parent-chain copy) but parser skipped the form and
the VM ops never consulted the slots.

Parser: parseOperatorDecl captures the symbol, ARG binding, and
INLINE body into a MethodDecl with IsOperator=true and OperatorOp
set to the hbrt.Op* slot. Synthesised method name is __OP_<idx>
to keep the regular method namespace clean.

Codegen: emitClassDecl routes IsOperator members through
_def.AddOperator instead of AddMethod. Inline body generation is
shared with the MESSAGE/INLINE path (34485cd).

VM: Thread.tryBinaryOp walks the LHS object's class operator slot,
pushes args with Self bound to LHS, and returns true if the slot
is populated. Wired into Plus/Minus/Mult/Divide and Equal/NotEqual/
Less/Greater/LessEqual/GreaterEqual. Falls through to built-in
behaviour when no overload exists — non-object LHS costs one tag
check per op.

Operator symbol→slot mapping keeps `=` and `==` on the same slot
(OpEqual=8) because Five's gengo routes both to t.Equal() and the
VM doesn't distinguish strict vs non-strict equality today.

Tested (/tmp/test_operator.prg): Vec2 + - == < with per-field
results all correct.

FiveSql2 43/43, Harbour compat 56/56, Go test ALL PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 15:54:44 +09:00
parent 34485cd6c8
commit 66f045b97e
6 changed files with 159 additions and 7 deletions

View File

@@ -178,6 +178,8 @@ type MethodDecl struct {
Params []*ParamDecl
IsInline bool // INLINE method
InlineBody Expr // inline expression body — `RETURN <expr>` equivalent
IsOperator bool // OPERATOR overload — OperatorOp carries the slot
OperatorOp int // operator slot (hbrt.Op* constant); valid only when IsOperator
IsSetGet bool // METHOD name(x) SETGET — getter if no arg, setter if arg
IsAccess bool // ACCESS name METHOD getterName
IsAssign bool // ASSIGN name METHOD setterName

View File

@@ -52,18 +52,22 @@ func (g *Generator) emitClassDecl(cls *ast.ClassDecl) {
upperName := strings.ToUpper(md.Name)
goFuncName := fmt.Sprintf("HB_%s_%s", className, upperName)
if md.IsSetGet {
switch {
case md.IsOperator:
// OPERATOR slot — don't pollute the method table.
g.writeln(fmt.Sprintf("_def.AddOperator(%d, %s)", md.OperatorOp, goFuncName))
case md.IsSetGet:
// SETGET: register as both getter and setter
// Getter = method name, Setter = _name
g.writeln(fmt.Sprintf("_def.AddMethod(%q, %s)", upperName, goFuncName))
g.writeln(fmt.Sprintf("_def.AddMethod(%q, %s)", "_"+upperName, goFuncName))
} else if md.IsAccess {
case md.IsAccess:
// ACCESS propName METHOD getterName
g.writeln(fmt.Sprintf("_def.AddMethod(%q, %s)", strings.ToUpper(md.AccessName), goFuncName))
} else if md.IsAssign {
case md.IsAssign:
// ASSIGN propName METHOD setterName
g.writeln(fmt.Sprintf("_def.AddMethod(%q, %s)", "_"+strings.ToUpper(md.AccessName), goFuncName))
} else {
default:
g.writeln(fmt.Sprintf("_def.AddMethod(%q, %s)", upperName, goFuncName))
}
}

View File

@@ -564,8 +564,13 @@ func (p *Parser) parseClassDecl() *ast.ClassDecl {
} else {
p.skipToEndOfLine()
}
case token.INLINE_KW, token.ON, token.DESTRUCTOR, token.OPERATOR_KW:
// Stray INLINE, ON ERROR, DESTRUCTOR, OPERATOR — skip to EOL
case token.OPERATOR_KW:
// OPERATOR "<sym>" ARG <name> INLINE <expr>
if md := p.parseOperatorDecl(); md != nil {
members = append(members, md)
}
case token.INLINE_KW, token.ON, token.DESTRUCTOR:
// Stray INLINE, ON ERROR, DESTRUCTOR — skip to EOL
p.skipToEndOfLine()
p.skipNewlines()
continue
@@ -616,7 +621,12 @@ func (p *Parser) parseClassDecl() *ast.ClassDecl {
// emitClassDecl routes it through AddMethod and the inline
// body emitter generates the HB_<CLASS>_<NAME> function.
members = append(members, p.parseMessageDecl())
} else if upper == "ON" || upper == "OPERATOR" || upper == "DESTRUCTOR" ||
} else if upper == "OPERATOR" {
// OPERATOR "<sym>" ARG <name> INLINE <expr>
if md := p.parseOperatorDecl(); md != nil {
members = append(members, md)
}
} else if upper == "ON" || upper == "DESTRUCTOR" ||
upper == "DELEGATE" || upper == "ERROR" ||
upper == "VIRTUAL" || upper == "DEFERRED" {
// ON ERROR, OPERATOR "+" ARG, DESTRUCTOR, DELEGATE — skip to EOL
@@ -771,6 +781,79 @@ func (p *Parser) parseClassMethodDecl() *ast.MethodDecl {
}
}
// operatorNameIndex maps a Harbour operator symbol to its slot in
// hbrt.ClassDef.Operators. Values mirror hbrt.Op* constants
// (hbrt/class.go:50-73).
//
// `=` and `==` both route through VM Thread.Equal(), which dispatches
// on OpEqual (8) — Five doesn't distinguish them at the VM level, so
// `==` maps to the same slot. `!=`, `<>`, `#` are all OpNotEqual (10).
var operatorNameIndex = map[string]int{
"+": 0, "-": 1, "*": 2, "/": 3, "%": 4, "^": 5,
"++": 6, "--": 7,
"=": 8, "==": 8, "!=": 10, "<>": 10, "#": 10,
"<": 11, "<=": 12, ">": 13, ">=": 14,
":=": 15, "$": 16,
}
// parseOperatorDecl parses:
// OPERATOR "<sym>" [ARG <name>] INLINE <expr>
//
// The ARG binds the RHS operand to a local when the operator is
// dispatched via the VM's binary op. INLINE <expr> is the body.
// Harbour also allows `OPERATOR "<sym>" ... METHOD foo CLASS X`
// (non-inline) but that form is rare; we only support INLINE for
// now and skip the line otherwise.
func (p *Parser) parseOperatorDecl() *ast.MethodDecl {
opPos := p.current.Pos
p.advance() // OPERATOR
if p.current.Kind != token.STRING {
p.skipToEndOfLine()
return nil
}
symbol := p.current.Literal
p.advance()
opIdx, ok := operatorNameIndex[symbol]
if !ok {
p.skipToEndOfLine()
return nil
}
var params []*ast.ParamDecl
if p.current.Kind == token.IDENT && p.currentUpper() == "ARG" {
p.advance()
argName := p.expectMethodName().Literal
params = []*ast.ParamDecl{{Name: argName}}
}
if !(p.current.Kind == token.INLINE_KW ||
(p.current.Kind == token.IDENT && p.currentUpper() == "INLINE")) {
// Non-inline operator — body arrives as a separate METHOD decl.
// Unsupported for now; skip.
p.skipToEndOfLine()
return nil
}
p.advance() // INLINE
body := p.parseExpr()
p.expectEndOfStmt()
// Synthesise a method named __OP_<idx> so the vtable doesn't collide
// with user-declared methods. emitClassDecl sees OperatorOp >= 0 and
// routes registration through AddOperator instead of AddMethod.
return &ast.MethodDecl{
MethodPos: opPos,
Name: fmt.Sprintf("__OP_%d", opIdx),
Params: params,
IsInline: true,
InlineBody: body,
IsOperator: true,
OperatorOp: opIdx,
EndPos: opPos,
}
}
// parseMessageDecl parses `MESSAGE <name> [(params)] INLINE <expr>` in
// a CLASS body and returns a MethodDecl. Harbour semantics: a MESSAGE
// handler is invoked like a method and behaves identically — the form

View File

@@ -284,6 +284,39 @@ func (t *Thread) Send(methodName string, nArgs int) {
t.push(t.retVal)
}
// tryBinaryOp checks whether the LHS of a pending binary operation is
// an object whose class overloads the given operator slot. If so, it
// dispatches the overload (Self=LHS, one positional arg = RHS) and
// returns true with the result pushed in place of the two operands.
// Returns false for non-object LHS or classes without an overload,
// letting the caller fall through to the built-in op.
func (t *Thread) tryBinaryOp(op int) bool {
if t.sp < 2 {
return false
}
a := t.stack[t.sp-2]
if !a.IsObject() {
return false
}
cls := GetClass(a.AsArray().Class)
if cls == nil || cls.Operators[op] == nil {
return false
}
fn := cls.Operators[op]
// Stack layout: [a] [b] → caller expects [result] after return.
b := t.pop()
t.pop() // discard a (Self takes over)
oldSelf := t.self
t.self = a
t.push(b)
t.pendingParams = 1
fn(t)
t.self = oldSelf
t.push(t.retVal)
return true
}
// SendSuper dispatches a method call on Self, but starting the method
// lookup from the parent of the class that defined the currently-
// executing method. Implements `::super:Method(args)`.

View File

@@ -20,6 +20,9 @@ import "math"
// Date + Numeric -> Date
// Timestamp + Numeric -> Timestamp
func (t *Thread) Plus() {
if t.tryBinaryOp(OpPlus) {
return
}
t.sp -= 2
a := t.stack[t.sp]
b := t.stack[t.sp+1]
@@ -88,6 +91,9 @@ func (t *Thread) Plus() {
// Minus pops two values, pushes their difference.
// Harbour: hb_vmMinus (hvm.c:3401)
func (t *Thread) Minus() {
if t.tryBinaryOp(OpMinus) {
return
}
t.sp -= 2
a := t.stack[t.sp]
b := t.stack[t.sp+1]
@@ -145,6 +151,9 @@ func (t *Thread) Minus() {
// Harbour: hb_vmMult (hvm.c:3510)
// Decimal rule: dec = dec1 + dec2
func (t *Thread) Mult() {
if t.tryBinaryOp(OpMult) {
return
}
t.sp -= 2
a := t.stack[t.sp]
b := t.stack[t.sp+1]
@@ -186,6 +195,9 @@ func (t *Thread) Mult() {
// Harbour: hb_vmDivide (hvm.c:3546)
// Always returns Double. Division by zero -> runtime error.
func (t *Thread) Divide() {
if t.tryBinaryOp(OpDivide) {
return
}
b := t.pop()
a := t.pop()

View File

@@ -22,6 +22,9 @@ import "strings"
// Equal pops two values, pushes boolean result.
// Harbour: hb_vmEqual (hvm.c:3974)
func (t *Thread) Equal() {
if t.tryBinaryOp(OpEqual) {
return
}
t.sp -= 2
a := t.stack[t.sp]
b := t.stack[t.sp+1]
@@ -54,6 +57,9 @@ func (t *Thread) ExactEqual() {
// NotEqual pops two values, pushes boolean result.
func (t *Thread) NotEqual() {
if t.tryBinaryOp(OpNotEqual) {
return
}
t.sp -= 2
a := t.stack[t.sp]
b := t.stack[t.sp+1]
@@ -80,6 +86,9 @@ func (t *Thread) NotEqual() {
// Less pops two values, pushes boolean result.
// Harbour: hb_vmLess (hvm.c:4176)
func (t *Thread) Less() {
if t.tryBinaryOp(OpLess) {
return
}
t.sp -= 2
a := t.stack[t.sp]
b := t.stack[t.sp+1]
@@ -107,6 +116,9 @@ func (t *Thread) Less() {
// LessEqual pops two values, pushes boolean result.
func (t *Thread) LessEqual() {
if t.tryBinaryOp(OpLessEqual) {
return
}
t.sp -= 2
a := t.stack[t.sp]
b := t.stack[t.sp+1]
@@ -135,6 +147,9 @@ func (t *Thread) LessEqual() {
// Greater pops two values, pushes boolean result.
func (t *Thread) Greater() {
if t.tryBinaryOp(OpGreater) {
return
}
t.sp -= 2
a := t.stack[t.sp]
b := t.stack[t.sp+1]
@@ -162,6 +177,9 @@ func (t *Thread) Greater() {
// GreaterEqual pops two values, pushes boolean result.
func (t *Thread) GreaterEqual() {
if t.tryBinaryOp(OpGreaterEqual) {
return
}
t.sp -= 2
a := t.stack[t.sp]
b := t.stack[t.sp+1]