Skip to content

Commit 886f556

Browse files
authored
Take a closure's instantiation from its construction on Lua (#1233)
1 parent 7fa5c15 commit 886f556

5 files changed

Lines changed: 355 additions & 27 deletions

File tree

BACKLOG.md

Lines changed: 45 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -43,28 +43,35 @@ because `LOOP.md` refers to items by number.
4343
mistake, and the alias it *should* produce is the class qualified with the declared name.
4444
Fixing it changes emitted slot names, so it wants its own commit and its own suite run.
4545

46-
5. **Lua dispatch inside a closure.** Works on Jass since #1229. On Lua the specialised class
47-
is built correctly but nothing calls it, because the closure is reached through its
48-
interface and `specializeMethod` renames the method out of its dispatch slot.
49-
`TypeClassTests.dispatchInsideClosureIsRejectedForLua` pins the current diagnostic and
50-
should become a success test. Related to item 1; AGENTS.md flags this machinery.
51-
52-
6. **Lua dispatch inside the constructor** of a bounded generic class. Works on Jass.
46+
6. **Lua dispatch inside the constructor** of a bounded generic class. Works on Jass; there is now
47+
a repro for both targets, `TypeClassTests.dispatchInsideConstructor` and
48+
`dispatchInsideConstructorIsRejectedForLua`, the second pinning the current diagnostic.
49+
50+
Not the same gap as item 5, and the fix from it does not reach: a constructor belongs to the
51+
class rather than to a generic function of its own, so the call that runs it carries no type
52+
arguments at all. The intermediate language has `b = new_Box(21)` with `b` typed
53+
`Box<integer{show}>`, and `new_Box` still generic; the calls *inside* it
54+
(`construct_Box<T>`, `Box_init<T>`) do carry the class's type variable, but nothing gives the
55+
outermost one a concrete argument. `collectGenericNewUse` requires non-empty type arguments, so
56+
it never starts.
57+
58+
The instantiation is only on the type of what the call is assigned to. Three ways to get at it,
59+
roughly in order of how much they would disturb: attach the class's type arguments to
60+
constructor calls when the intermediate language is built, which is where the frontend still
61+
knows them and would serve both targets uniformly — but it changes the Jass path, which reaches
62+
the same answer another way today, so the emitted `.j` needs checking; read them from the
63+
assignment target on the Lua path, which is a syntactic shape and would miss
64+
`foo(new Box<int>(21))`; or specialise from the `#alloc` inside the constructor, which is the
65+
item 5 mechanism but would have to reach back out to the caller. The first looks right; confirm
66+
it is what the Jass path already relies on before changing it.
5367

5468
7. **Module bounds.** `module M<T: Show>` is rejected with a clear message today. Needs
5569
receiver rewriting during expansion, or type parameters on `ModuleInstanciation`.
5670

57-
8. **`MOD_INT`/`DIV_INT` return the left operand's type** rather than `int`
58-
(`AttrExprType.java`, the `case MOD_INT` branch), where `caseMathOperation` returns
59-
`WurstTypeInt.instance()` for `+`, `-`, `*`. It *is* reachable: `WurstTypeIntLiteral` is a
60-
proper subtype of both int and real, and `caseMathOperation` collapses two literals to `int`
61-
precisely so `real r = 1 + 1` stays an error. Returning `leftType` skips that collapse, so
62-
`real r = 7 div 2` and `real r = 7 mod 2` should be accepted where `+` is rejected. Confirm
63-
with a test first — that is the failing repro — then return `WurstTypeInt.instance()`. Small.
64-
65-
9. **Keep `WURST_LANGUAGE.md` and `CHANGELOG.md` current** as items land. The bounds section
66-
says nothing about closures, which now work on Jass. Fold this into whichever item changes
67-
the behaviour rather than doing it as a separate pass.
71+
9. **Keep `WURST_LANGUAGE.md` and `CHANGELOG.md` current** as items land — a standing practice
72+
rather than a task to finish. Fold it into whichever item changes the behaviour rather than
73+
doing it as a separate pass. Both now cover closures on either target, which is what this item
74+
originally pointed at.
6875

6976
10. **One `ImTypeVar` per type parameter.** Name-tolerant lookups remain in
7077
`EliminateGenerics.indexOfTypeVar`, `inheritTypeClassBinding` and
@@ -135,6 +142,26 @@ because `LOOP.md` refers to items by number.
135142

136143
## Done
137144

145+
- 8. `div` and `mod` return int rather than the left operand's type, matching `caseMathOperation`.
146+
Reachable, not harmless: an integer literal is a proper subtype of both int and real, and
147+
addition collapses two of them to int precisely so `real r = 1 + 1` stays an error — returning
148+
`leftType` skipped that, so `real r = 7 div 2` was accepted. Three tests in `ExpressionTests`:
149+
both operators rejected against a real, and both still int.
150+
- 17. A failing Lua test says so. `translateAndTestLua` now sets the environment label instead of
151+
reporting under whatever Jass configuration ran last.
152+
- 5 (+ the part of 9 that follows it). A type class bound now dispatches from inside a closure on
153+
Lua, and `TypeClassTests.dispatchInsideClosureLua` is a success test. The note in this file was
154+
wrong about the cause: no specialised class was being built at all. Lua specialisation is driven
155+
by calls that carry type arguments, and a closure has none — it is reached through the interface
156+
it implements, which is not generic, so only the construction knows the instantiation. Three
157+
pieces were missing, all present already for Jass: collect the instantiation from `ImAlloc`,
158+
collect the member access so the capture write lands on the specialised field, and bind the
159+
specialised methods to the roots the originals were submethods of (registering the original
160+
implementation as specialised so `settleRemainingDispatches` neutralises what it leaves behind).
161+
All three are gated on the class being closure-generated. Widening them to any constructed class
162+
made the two mechanisms disagree — the object came from the specialised class while its methods
163+
were bound to the erased one — and broke every FastHashMap Lua test, which is the shape of
164+
regression AGENTS.md §9 warns about. `WURST_LANGUAGE.md` and `CHANGELOG.md` say so now.
138165
- 4. The FastHashMap proof is complete. `remove` leaves a tombstone, which `slotFor` passes over
139166
when searching and reuses when putting; the probe is bounded by capacity rather than running
140167
until it finds a gap, so a table full of tombstones cannot spin. `emittedCodeCostsNothingExtra`

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@
3737
return () -> T.toIndex(x)
3838

3939
Substituting a type variable now carries the instance chosen for it along with the type, rather than the
40-
type alone, so lifting a body into a class of its own no longer loses it. Jass only for now: Lua reaches
41-
such a class through its interface and still reports the bound as unresolvable there.
40+
type alone, so lifting a body into a class of its own no longer loses it. This works on both targets.
41+
Lua reaches such a class through the interface it implements, so no call names the instantiation and the
42+
construction is what the specialisation is taken from.
4243

4344
- Added new pseudo-natives for debugging memory leaks:
4445

de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import de.peeeq.wurstscript.WLogger;
66
import de.peeeq.wurstscript.ast.ClassDef;
77
import de.peeeq.wurstscript.ast.ConstructorDef;
8+
import de.peeeq.wurstscript.ast.ExprClosure;
89
import de.peeeq.wurstscript.ast.InterfaceDef;
910
import de.peeeq.wurstscript.ast.PackageOrGlobal;
1011
import de.peeeq.wurstscript.ast.WPackage;
@@ -188,6 +189,18 @@ public void visit(ImMethodCall call) {
188189
super.visit(call);
189190
collectGenericNewUse(call);
190191
}
192+
193+
@Override
194+
public void visit(ImAlloc alloc) {
195+
super.visit(alloc);
196+
collectGenericNewUse(alloc);
197+
}
198+
199+
@Override
200+
public void visit(ImMemberAccess memberAccess) {
201+
super.visit(memberAccess);
202+
collectGenericNewUse(memberAccess);
203+
}
191204
});
192205
}
193206

@@ -204,6 +217,18 @@ public void visit(ImMethodCall call) {
204217
super.visit(call);
205218
collectGenericNewUse(call);
206219
}
220+
221+
@Override
222+
public void visit(ImAlloc alloc) {
223+
super.visit(alloc);
224+
collectGenericNewUse(alloc);
225+
}
226+
227+
@Override
228+
public void visit(ImMemberAccess memberAccess) {
229+
super.visit(memberAccess);
230+
collectGenericNewUse(memberAccess);
231+
}
207232
});
208233
}
209234

@@ -225,6 +250,106 @@ && functionNeedsSpecialization(call.getFunc(), Collections.newSetFromMap(new Ide
225250
}
226251
}
227252

253+
/**
254+
* A construction states an instantiation that no call site has to mention. A closure is the case
255+
* that needs it: its class is built from the enclosing type variables and reached through its
256+
* interface, so the call carries no type arguments at all and only the allocation knows what the
257+
* body dispatches on. Restricted to classes that actually dispatch on a bound, so this stays a
258+
* targeted specialisation rather than general monomorphisation on Lua.
259+
*/
260+
private void collectGenericNewUse(ImAlloc alloc) {
261+
ImClassType clazz = alloc.getClazz();
262+
if (clazz.getTypeArguments().isEmpty()
263+
|| typeArgumentsContainTypeVariable(clazz.getTypeArguments())
264+
|| !isConstructionOnlyInstantiation(clazz.getClassDef())) {
265+
return;
266+
}
267+
genericsUses.add(new GenericClazzUse(alloc));
268+
}
269+
270+
/**
271+
* Whether the construction is the only place a class's instantiation is stated.
272+
* <p>
273+
* A class the user writes is used through calls that carry its type arguments, and those already
274+
* specialise what they need onto the erased class. A closure has no such call: it is reached
275+
* through the interface it implements, which is not generic, so the allocation is the only thing
276+
* that knows what the body dispatches on. Widening this beyond that case makes the two
277+
* mechanisms disagree — the object comes from the specialised class while its methods were bound
278+
* to the erased one.
279+
*/
280+
private boolean isConstructionOnlyInstantiation(ImClass classDef) {
281+
return classDef.attrTrace() instanceof ExprClosure closure
282+
&& !isInsideAnotherClosure(closure)
283+
&& classReachesDispatch(classDef);
284+
}
285+
286+
/**
287+
* A closure written inside another one is left alone.
288+
* <p>
289+
* Its captured environment is reached through a receiver belonging to the enclosing closure,
290+
* which by then has been specialised itself, and specialising the owner again with what is
291+
* left over fails inside the rewrite. Supporting that is a further step; until it is taken,
292+
* saying the bound could not be resolved - which is what happens without any of this - is
293+
* better than an error about generics of the wrong size.
294+
*/
295+
private static boolean isInsideAnotherClosure(ExprClosure closure) {
296+
de.peeeq.wurstscript.ast.Element parent = closure.getParent();
297+
return parent != null && parent.attrNearestExprClosure() != null;
298+
}
299+
300+
/**
301+
* Whether anything the class does ends in a dispatch on a bound, including through the
302+
* functions it calls. `classNeedsSpecialization` asks only whether a dispatch sits in the class
303+
* itself, which is the wrong question here: a closure whose body is `() -> helper(x)` has no
304+
* dispatch of its own, and the instantiation it needs is still only known at its construction.
305+
* That question is kept as it is, because widening it would change what gets specialised on
306+
* paths that have nothing to do with closures.
307+
*/
308+
private boolean classReachesDispatch(ImClass classDef) {
309+
for (ImFunction f : classDef.getFunctions()) {
310+
if (functionNeedsSpecialization(f, Collections.newSetFromMap(new IdentityHashMap<>()),
311+
Collections.newSetFromMap(new IdentityHashMap<>()))) {
312+
return true;
313+
}
314+
}
315+
for (ImMethod m : classDef.getMethods()) {
316+
if (m.getImplementation() != null
317+
&& functionNeedsSpecialization(m.getImplementation(),
318+
Collections.newSetFromMap(new IdentityHashMap<>()),
319+
Collections.newSetFromMap(new IdentityHashMap<>()))) {
320+
return true;
321+
}
322+
}
323+
return false;
324+
}
325+
326+
/**
327+
* A field of a class specialised from a construction has to be reached on the copy. The write
328+
* that captures a closure's environment is the case that needs it: it names the field of the
329+
* generic class, which nothing allocates any more once the construction was redirected.
330+
*/
331+
private void collectGenericNewUse(ImMemberAccess memberAccess) {
332+
ImVar field = memberAccess.getVar();
333+
if (field.getParent() == null || !(field.getParent().getParent() instanceof ImClass owningClass)) {
334+
return;
335+
}
336+
// A class that has already been specialised has nothing left to select, and asking the
337+
// receiver to adapt to it fails outright: the receiver is still typed by the generic class
338+
// the specialised one was copied from, which is not a superclass of it.
339+
if (owningClass.getTypeVariables().isEmpty() || !isConstructionOnlyInstantiation(owningClass)) {
340+
return;
341+
}
342+
if (memberAccess.getTypeArguments().isEmpty()) {
343+
// The access names a field, not an instantiation; the receiver is what knows which one.
344+
addMemberTypeArguments(memberAccess, owningClass);
345+
}
346+
if (memberAccess.getTypeArguments().isEmpty()
347+
|| typeArgumentsContainTypeVariable(memberAccess.getTypeArguments())) {
348+
return;
349+
}
350+
genericsUses.add(new GenericMemberAccess(memberAccess));
351+
}
352+
228353
private void collectGenericNewUse(ImMethodCall call) {
229354
if (specializedCallSites.contains(call)) {
230355
return;
@@ -1316,12 +1441,56 @@ private ImClass specializeClass(ImClass c, GenericTypes generics) {
13161441
// NEW: Create specialized global variables for this class instantiation
13171442
createSpecializedGlobals(c, generics, typeVars);
13181443

1444+
if (genericNewOnly && isConstructionOnlyInstantiation(c)) {
1445+
attachSpecializedClassMethods(c, newC, generics);
1446+
}
13191447

13201448
onSpecializedClassTriggers.get(c).forEach(consumer ->
13211449
consumer.accept(generics, newC));
13221450
return newC;
13231451
}
13241452

1453+
/**
1454+
* Makes the methods of a class specialised from a construction reachable.
1455+
* <p>
1456+
* A class specialised because a call named its instantiation is reached through that call.
1457+
* One specialised because it was constructed is not: the receiver is held as its interface, so
1458+
* dispatch goes through the root method, whose submethods still list only the generic original.
1459+
* Each copy is bound to the same roots, and the original's implementation is recorded as having
1460+
* a specialisation so the dispatch left behind in it settles instead of reaching the backend.
1461+
*/
1462+
private void attachSpecializedClassMethods(ImClass original, ImClass specialized, GenericTypes generics) {
1463+
List<ImMethod> originalMethods = original.getMethods();
1464+
List<ImMethod> specializedMethods = specialized.getMethods();
1465+
if (originalMethods.size() != specializedMethods.size()) {
1466+
// The copy is structural, so this cannot happen; bail rather than pair the wrong ones.
1467+
return;
1468+
}
1469+
Map<ImMethod, ImMethod> specializationOf = new IdentityHashMap<>();
1470+
for (int i = 0; i < originalMethods.size(); i++) {
1471+
ImMethod copy = specializedMethods.get(i);
1472+
copy.setMethodClass(JassIm.ImClassType(specialized, JassIm.ImTypeArguments()));
1473+
specializationOf.put(originalMethods.get(i), copy);
1474+
1475+
ImFunction implementation = originalMethods.get(i).getImplementation();
1476+
ImFunction copyImplementation = copy.getImplementation();
1477+
if (implementation != null && copyImplementation != null && implementation != copyImplementation
1478+
&& specializedFunctions.get(implementation, generics) == null) {
1479+
specializedFunctions.put(implementation, generics, copyImplementation);
1480+
}
1481+
}
1482+
for (ImClass c : new ArrayList<>(prog.getClasses())) {
1483+
for (ImMethod root : c.getMethods()) {
1484+
for (ImMethod sub : new ArrayList<>(root.getSubMethods())) {
1485+
ImMethod copy = specializationOf.get(sub);
1486+
if (copy != null && !root.getSubMethods().contains(copy)) {
1487+
root.getSubMethods().add(copy);
1488+
}
1489+
}
1490+
}
1491+
}
1492+
}
1493+
13251494
private ImExpr rewriteGenericGlobalsInExpr(ImExpr e, ImClass owningClass, GenericTypes generics) {
13261495
e.accept(new Element.DefaultVisitor() {
13271496
@Override public void visit(ImVarAccess va) {

de.peeeq.wurstscript/src/main/resources/agent-docs/WURST_LANGUAGE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,14 @@ Instances are unique and must be declared next to what they relate. An instance
186186

187187
An instance must implement each requirement with the signature it has after the interface's type parameter is replaced by the instance type; a matching name is not enough. An interface used as a bound must not extend another interface, because the requirements of a bound are the interface's own functions.
188188

189+
A requirement can also be dispatched from inside a closure written in a bounded generic. The closure captures the type parameter along with the values it uses, so the instance is still chosen by the caller's type argument:
190+
191+
```wurst
192+
function foo<Q: ToIndex>(Q x) returns int
193+
Producer p = () -> Q.toIndex(x)
194+
return p.produce()
195+
```
196+
189197
A generic which passes its own type parameter to another bounded generic must declare that bound itself:
190198

191199
```wurst

0 commit comments

Comments
 (0)