fix(semantic): merge method overload sets across inheritance

A derived class that declared an `overload` method with the same name as
an overload inherited from its base SHADOWED the inherited variants
instead of merging with them:

    TBase = class
      function F(x: Integer): string; overload;
    end;
    TDerived = class(TBase)
      function F(s: string): string; overload;
    end;
    d.F('a');  // ok
    d.F(5);    // was rejected: "expected string but got Integer"

Two causes, both fixed:

1. ResolveMethodOverload stopped walking the inheritance chain as soon
   as the first class declaring the name yielded any arity match, so the
   base overloads were never considered. It now unions the overload
   groups up the parent chain, stopping only when a level declares the
   name WITHOUT `overload` — a non-overload method hides inherited ones
   (the conventional redeclaration-hides rule), an `overload` method
   extends the set (Delphi semantics).

2. The variable-receiver method-call path used FindMethodDecl (first
   match in the chain) and never invoked overload resolution at all. It
   now analyses the arguments and calls ResolveMethodOverload, falling
   back to FindMethodDecl for the no-overload case. (Arguments must be
   analysed before resolution so candidates can be scored against their
   resolved types.)

Adds TE2EInheritTests.TestRun_OverloadMergeAcrossInheritance (dual
backend) and an "Overload sets merge across inheritance" subsection to
docs/language-rationale.adoc.
This commit is contained in:
Graeme Geldenhuys 2026-06-16 13:10:12 +01:00
parent 519aefdc5c
commit 536610a7c5
3 changed files with 81 additions and 5 deletions

View file

@ -4576,6 +4576,7 @@ var
ExactNew: Integer;
ExactBest: Integer;
S1, S2: Integer;
SawHiding: Boolean;
begin
Result := nil;
if AArgs <> nil then Arity := AArgs.Count else Arity := -1;
@ -4587,19 +4588,26 @@ begin
begin
Key := CurrName + '.' + AMethodName;
Grp := GroupOf(FMethodGroups, Key);
SawHiding := False;
if Grp <> nil then
for K := 0 to Grp.Count - 1 do
begin
Inc(TotalCnt);
Cand := TMethodDecl(Grp.Items[K]);
{ A method declared WITHOUT `overload` hides all inherited methods of
the same name once seen at this (more-derived) level, the parent
chain is not consulted. Methods WITH `overload` merge with the
inherited overload set, so the walk continues to the parent. }
if not Cand.IsOverload then SawHiding := True;
if (Arity < 0) or
((Arity >= MinArity(Cand)) and (Arity <= Cand.Params.Count)) then
ArityMatch.Add(Cand);
end;
if ArityMatch.Count > 0 then Break;
{ Walk parent only if no candidates yet (Delphi: derived overloads
do not mix with inherited ones unless the descendant explicitly
repeats the directive, which our model treats as a fresh slot). }
{ Delphi overload semantics: derived `overload` methods MERGE with the
inherited overload set (walk continues), but a non-`overload` method at
this level hides the inherited ones (stop). Stop too once any class in
the chain actually declares the name without `overload`. }
if SawHiding then Break;
Sym := FTable.Lookup(CurrName);
if (Sym <> nil) and (Sym.TypeDesc is TRecordTypeDesc) then
begin
@ -8792,7 +8800,16 @@ begin
end;
RT := TRecordTypeDesc(ObjSym.TypeDesc);
MDecl := FindMethodDecl(RT.Name, AExpr.Name);
{ Overload-aware resolution: pick the variant matching the argument list
(merging overloads across the inheritance chain). Args must be analysed
first so ResolveMethodOverload can score against their resolved types.
Fall back to a plain chain lookup for the no-overload / single case. }
for I := 0 to AExpr.Args.Count - 1 do
AnalyseExpr(TASTExpr(AExpr.Args.Items[I]));
MDecl := ResolveMethodOverload(RT.Name, AExpr.Name, AExpr.Args,
AExpr.Line, AExpr.Col);
if MDecl = nil then
MDecl := FindMethodDecl(RT.Name, AExpr.Name);
{ Built-in TObject.ToString: virtual dispatch yielding string.
Every class inherits this from TObject (vtable slot 1). }
if (MDecl = nil) and SameText(AExpr.Name, 'ToString') and (AExpr.Args.Count = 0) and

View file

@ -38,6 +38,9 @@ type
vtable, exactly like a direct accessor call does. }
procedure TestRun_VirtualPropertyGetter_Dispatches;
procedure TestRun_VirtualPropertySetter_Dispatches;
{ A derived `overload` method must MERGE with the inherited overload set,
not shadow it both the base and derived variants stay callable. }
procedure TestRun_OverloadMergeAcrossInheritance;
end;
implementation
@ -273,6 +276,26 @@ const
end.
''';
{ TDerived adds F(string) as an overload; the inherited F(Integer) must
remain callable the overload set merges across inheritance. }
SrcOverloadMerge = '''
program P;
type
TBase = class
function F(x: Integer): string; overload; begin Result := 'int:' + IntToStr(x); end;
end;
TDerived = class(TBase)
function F(s: string): string; overload; begin Result := 'str:' + s; end;
end;
var d: TDerived;
begin
d := TDerived.Create;
WriteLn(d.F('a'));
WriteLn(d.F(5));
d := nil;
end.
''';
procedure TE2EInheritTests.TestRun_ThreeLevelVirtualOverride;
begin
if not ToolchainAvailable() then begin Ignore('toolchain unavailable'); Exit; end;
@ -339,6 +362,12 @@ begin
AssertRunsOnAll(SrcVirtualSetter, '42' + LE, 0);
end;
procedure TE2EInheritTests.TestRun_OverloadMergeAcrossInheritance;
begin
if not ToolchainAvailable() then begin Ignore('toolchain unavailable'); Exit; end;
AssertRunsOnAll(SrcOverloadMerge, 'str:a' + LE + 'int:5' + LE, 0);
end;
initialization
RegisterTest(TE2EInheritTests);

View file

@ -1837,6 +1837,36 @@ ambiguity" pitfalls that surface when an `Integer` argument matches
both an `Integer` and a `Double` overload — exact match always wins,
ambiguity is escalated to the user.
=== Overload sets merge across inheritance
When a derived class declares an `overload` method whose name matches an
overload inherited from a base class, the two sets *merge*: both the
inherited and the newly declared variants remain callable on the derived
type.
[source,pascal]
----
TBase = class
function F(x: Integer): string; overload;
end;
TDerived = class(TBase)
function F(s: string): string; overload; // adds to, does not replace
end;
var d: TDerived;
begin
d.F(5); // resolves to TBase.F(Integer)
d.F('a'); // resolves to TDerived.F(string)
end;
----
This follows Delphi: a derived method marked `overload` extends the
inherited overload set rather than hiding it. A derived method declared
*without* `overload` still hides every inherited variant of that name (the
conventional "redeclaration hides" rule) — only the `overload` directive
opts into merging. Resolution collects candidates up the inheritance
chain until it reaches a level that declares the name without `overload`,
then scores the combined set with the normal exact-over-widening rules.
=== Name mangling
Each overload is emitted under a distinct QBE symbol name derived from