A generic and a non-generic class cannot share a name
Found 2026-08-16 walking rung 3 of the Pascal OOP corpus
([[feature-pascal-corpus-generics]]). It is the wall that stopped
generics.defaults after the four constant-initializer walls were cleared.
Measured
type
THS = class class function A: LongInt; static; end;
THS<T> = class class function B: LongInt; static; end;
| result | |
|---|---|
FPC 3.2.2 -Mdelphi |
1 2 — both usable |
| pxx | error: unresolved forward: THS.A |
Narrowed against three controls, so the cause is the shared NAME and nothing else:
| shape | pxx |
|---|---|
| generic, no constraint, plain base | OK |
generic with a CONSTRAINED parameter (<T: TCon>) |
OK |
| generic inheriting a DIFFERENTLY-named base | OK |
| generic + non-generic sharing a name, no inheritance at all | FAILS |
Constraints and generic inheritance both work. Only the name collision fails.
Why the reported symptom is misleading
In rtl-generics the collision is first observed through a base clause —
THashService<T: THashFactory> = class(THashService) — and reports:
error: base type not found: THashService$TDelphiHashFactory
$ is pxx's SPECIALIZATION mangling, not nesting. Read quickly, that message
says "a class nested inside a class is missing" and sends you at nested types;
this ticket was very nearly filed that way. What it actually says is that
resolving the base name THashService found the generic template being
specialized rather than the non-generic class, because there is only one entry
under that name. Inheritance is incidental — remove it and the pair still fails.
Cause
A class is keyed by name alone; there is no generic-arity component to the key,
so THS and THS<T> are one row and the later declaration wins. The earlier
class's method bodies then have nothing to attach to, which is what surfaces as
unresolved forward.
This is the class/record name table, one of the six independent name tables
(grep DeclVisible symtab.inc) — so scope the fix to that table and check
whether the same key is built anywhere else before widening.
Shape of the fix, and why it is not a microfix
The honest framing is arity-overloaded class names, not a special case for
this pair: FPC allows TFoo, TFoo<T> and TFoo<T,U> to coexist, and
Generics.Collections uses exactly that (TDictionary alongside
TDictionary<K,V>). A guard that merely tolerates one non-generic plus one
generic would clear this unit and break on the next.
That makes it a name-table key change plus every lookup that builds the key — the sort of thing worth doing once, deliberately, rather than patched at the site that happened to fail. Not started; no half-measure attempted.
Blast radius to check before starting
Specialization is literal token-stream substitution (SpecializeStream in
parser.inc), so a specialized body referring to its own generic's bare name is
already textually rewritten before the parser sees it. Confirm what that does to
a base clause naming the non-generic sibling — it is the interaction most likely
to bite, and it is why the corpus failure appeared in a base clause first.
Gate
The measured table above matching fpc -Mdelphi row for row, plus a
three-way (TFoo, TFoo<T>, TFoo<T,U>) coexistence case; gate.sh quick;
self-host fixedpoint.
2026-08-17 — FIXED in eda43dea7. The diagnosis above was wrong.
Both spellings now work; TD, TD<K> and TD<K,V> coexist and match
fpc -Mdelphi row for row.
The name-table story in this ticket is wrong, and worth leaving on the record
rather than editing out. It says a class is keyed by name with no arity
component, so the two declarations become one row. They do not: generic templates
live in Templates[] and classes in the UCls* table, and the two never
collided. I inferred a shared key from a single symptom
(base type not found: THS$LongInt) and then scoped a name-table redesign — "a
key change plus every lookup that builds the key" — around the inference.
Two unrelated defects were wearing one symptom.
-
ParseSubroutinehanded a bareX.Mimplementation header to a TEMPLATE whenever a template of that name existed, matching on the name alone. That spelling is also how a generic method impl is legitimately written, so the header really is ambiguous and no spelling rule separates them; it is now resolved by asking which class DECLARES the method. This is what producedunresolved forwardon the ordinary class's own method. -
SpecializeStreamrewrites every occurrence of the template's name to the specialized name, which also caught the base-class reference inTHS<T> = class(THS)— emitting a specialization that inherits from itself. A base-class position now keeps the token, since the template's own name cannot mean the specialization there.
Neither is a name table.
What separated them was two controls, in about a minute: drop the inheritance (defect 1 alone still fails), and drop each class's methods in turn (only the ordinary class's method fails). Both were available yesterday when the ticket was filed; I reasoned from the error text instead, which is the exact failure this repo's debugging note warns about. The scope estimate was wrong in the expensive direction — it made the work look like a design change worth deferring, and it was two guards.
Test. test/test_generic_name_overload.pas, wired into test-core. The
inheritance row is the load-bearing one — TD<LongInt>.N must reach the
NON-generic parent's method through the specialization, which a compile-only
check would not catch. A typecast control (TBox<T>(o) inside a template body,
where the name DOES mean the specialization) guards the second fix from
over-reaching.
Unblocks [[feature-pascal-corpus-generics]]: generics.defaults walks from line
525 to 635, where the next wall is an inline array[TTypeKind] of type in a
class field — unrelated.
Log
- 2026-08-17 — resolved, commit 604c26fda.