From 2ca547172d98e0651a7c524e5f98d4a1252a602a Mon Sep 17 00:00:00 2001 From: Frotty Date: Sun, 22 Feb 2026 16:01:02 +0100 Subject: [PATCH 1/3] fix generic override behavior --- .../imtranslation/EliminateGenerics.java | 32 ++++++- .../tests/GenericsWithTypeclassesTests.java | 5 + .../concept/reactiveGenericsDispatch.wurst | 92 +++++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 de.peeeq.wurstscript/testscripts/concept/reactiveGenericsDispatch.wurst diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java index 04d0e0327..8f43f92b0 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java @@ -967,7 +967,13 @@ public void visit(ImFunctionCall f) { @Override public void visit(ImMethodCall mc) { super.visit(mc); - if (!mc.getTypeArguments().isEmpty()) { + ImMethod method = mc.getMethod(); + boolean hasTypeArgs = !mc.getTypeArguments().isEmpty(); + // Interface/base dispatch methods can be non-generic but still require specialization + // when they dispatch to generic implementors. + boolean needsDispatchSpecialization = + methodImplementationIsGeneric(method) || hasGenericSubmethodImplementation(method); + if (hasTypeArgs || needsDispatchSpecialization) { dbg("COLLECT GenericMethodCall: method=" + mc.getMethod().getName() + " " + id(mc.getMethod()) + " impl=" + (mc.getMethod().getImplementation() == null ? "null" : (mc.getMethod().getImplementation().getName() + " " + id(mc.getMethod().getImplementation()))) + " owningClass=" + (mc.getMethod().attrClass() == null ? "null" : (mc.getMethod().attrClass().getName() + " " + id(mc.getMethod().attrClass()))) @@ -1109,6 +1115,30 @@ public void visit(ImTypeIdOfClass f) { }); } + private boolean methodImplementationIsGeneric(ImMethod method) { + ImFunction implementation = method.getImplementation(); + return implementation != null && !implementation.getTypeVariables().isEmpty(); + } + + private boolean hasGenericSubmethodImplementation(ImMethod method) { + return hasGenericSubmethodImplementation(method, Collections.newSetFromMap(new IdentityHashMap<>())); + } + + private boolean hasGenericSubmethodImplementation(ImMethod method, Set visited) { + if (!visited.add(method)) { + return false; + } + for (ImMethod subMethod : method.getSubMethods()) { + if (methodImplementationIsGeneric(subMethod)) { + return true; + } + if (hasGenericSubmethodImplementation(subMethod, visited)) { + return true; + } + } + return false; + } + static boolean isGenericType(ImType type) { return type.match(new ImType.Matcher() { @Override diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/GenericsWithTypeclassesTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/GenericsWithTypeclassesTests.java index a6153e34b..0a8e5c5d7 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/GenericsWithTypeclassesTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/GenericsWithTypeclassesTests.java @@ -2093,6 +2093,11 @@ public void fullArrayListTest() throws IOException { assertFalse(compiled.contains("ArrayList_nextFreeIndex_")); } + @Test + public void fullReactiveGenericDispatchTest() throws IOException { + test().withStdLib().executeProg().executeTests().file(new File(TEST_DIR + "reactiveGenericsDispatch.wurst")); + } + @Test public void genericNullComparison() { testAssertOkLinesWithStdLib(true, diff --git a/de.peeeq.wurstscript/testscripts/concept/reactiveGenericsDispatch.wurst b/de.peeeq.wurstscript/testscripts/concept/reactiveGenericsDispatch.wurst new file mode 100644 index 000000000..ffc6b8c93 --- /dev/null +++ b/de.peeeq.wurstscript/testscripts/concept/reactiveGenericsDispatch.wurst @@ -0,0 +1,92 @@ +package ReactiveGenericDispatchRegression +import NoWurst +import Integer +import Wurstunit + +interface ReactiveSource + function subscribe(Observer observer) + function unsubscribe(Observer observer) + +class Observer + int dependencyChanges = 0 + + function trackSource(ReactiveSource source) + source.subscribe(this) + + function untrackSource(ReactiveSource source) + source.unsubscribe(this) + + function onDependencyChanged() + dependencyChanges += 1 + + +class Signal implements ReactiveSource + private T value + private Observer subscriber = null + + construct(T initial) + value = initial + + function set(T newValue) + value = newValue + if subscriber != null + subscriber.onDependencyChanged() + + function subscriberCount() returns int + return subscriber == null ? 0 : 1 + + override function subscribe(Observer observer) + subscriber = observer + + override function unsubscribe(Observer observer) + if subscriber == observer + subscriber = null + + +class PlainSource implements ReactiveSource + private Observer subscriber = null + + function fire() + if subscriber != null + subscriber.onDependencyChanged() + + function subscriberCount() returns int + return subscriber == null ? 0 : 1 + + override function subscribe(Observer observer) + subscriber = observer + + override function unsubscribe(Observer observer) + if subscriber == observer + subscriber = null + +init + let genericObserver = new Observer() + let genericSignal = new Signal(0) + genericObserver.trackSource(genericSignal) + if genericSignal.subscriberCount() != 1 + testFail("generic subscribe dispatch failed") + genericSignal.set(1) + if genericObserver.dependencyChanges != 1 + testFail("generic notify dispatch failed") + genericObserver.untrackSource(genericSignal) + if genericSignal.subscriberCount() != 0 + testFail("generic unsubscribe dispatch failed") + + let plainObserver = new Observer() + let plainSource = new PlainSource() + plainObserver.trackSource(plainSource) + if plainSource.subscriberCount() != 1 + testFail("plain subscribe dispatch failed") + plainSource.fire() + if plainObserver.dependencyChanges != 1 + testFail("plain notify dispatch failed") + plainObserver.untrackSource(plainSource) + if plainSource.subscriberCount() != 0 + testFail("plain unsubscribe dispatch failed") + + destroy genericSignal + destroy genericObserver + destroy plainSource + destroy plainObserver + testSuccess() From c1917d19eda33e81c8f3fbde56c3c24c47259bab Mon Sep 17 00:00:00 2001 From: Frotty Date: Sun, 22 Feb 2026 16:09:48 +0100 Subject: [PATCH 2/3] some optimizations --- .../imtranslation/EliminateClasses.java | 20 +++++++++++++++++-- .../imtranslation/EliminateGenerics.java | 19 ++++++++++++++---- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateClasses.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateClasses.java index 78aff7739..bdb2e797b 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateClasses.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateClasses.java @@ -628,9 +628,25 @@ private void replaceMethodCall(ImMethodCall mc) { ImExprs arguments = JassIm.ImExprs(receiver); arguments.addAll(mc.getArguments().removeAll()); - ImFunction dispatch = dispatchFuncs.get(mc.getMethod()); + ImMethod method = mc.getMethod(); + // Fast path: with unchecked dispatch, a monomorphic method call can be lowered + // directly to its implementation function. + if (!checkedDispatch + && !method.getIsAbstract() + && method.getSubMethods().isEmpty()) { + mc.replaceBy(JassIm.ImFunctionCall( + mc.getTrace(), + method.getImplementation(), + JassIm.ImTypeArguments(), + arguments, + false, + CallType.NORMAL)); + return; + } + + ImFunction dispatch = dispatchFuncs.get(method); if (dispatch == null) { - throw new CompileError(mc.attrTrace().attrSource(), "Could not find dispatch for " + mc.getMethod().getName()); + throw new CompileError(mc.attrTrace().attrSource(), "Could not find dispatch for " + method.getName()); } mc.replaceBy(JassIm.ImFunctionCall(mc.getTrace(), dispatch, JassIm.ImTypeArguments(), arguments, false, CallType.NORMAL)); diff --git a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java index 8f43f92b0..f82f119b0 100644 --- a/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java +++ b/de.peeeq.wurstscript/src/main/java/de/peeeq/wurstscript/translation/imtranslation/EliminateGenerics.java @@ -955,6 +955,8 @@ private boolean isGlobalInitStmt(ImSet s, ImVar v) { } private void collectGenericUsages(Element element) { + // Cache expensive recursive submethod checks within this traversal. + Map hasGenericSubmethodCache = new IdentityHashMap<>(); element.accept(new Element.DefaultVisitor() { @Override public void visit(ImFunctionCall f) { @@ -969,10 +971,19 @@ public void visit(ImMethodCall mc) { super.visit(mc); ImMethod method = mc.getMethod(); boolean hasTypeArgs = !mc.getTypeArguments().isEmpty(); - // Interface/base dispatch methods can be non-generic but still require specialization - // when they dispatch to generic implementors. - boolean needsDispatchSpecialization = - methodImplementationIsGeneric(method) || hasGenericSubmethodImplementation(method); + boolean needsDispatchSpecialization = false; + // If type args are present, specialization is unconditional, so avoid extra checks. + if (!hasTypeArgs) { + // Interface/base dispatch methods can be non-generic but still require specialization + // when they dispatch to generic implementors. + needsDispatchSpecialization = methodImplementationIsGeneric(method); + if (!needsDispatchSpecialization) { + needsDispatchSpecialization = hasGenericSubmethodCache.computeIfAbsent( + method, + EliminateGenerics.this::hasGenericSubmethodImplementation + ); + } + } if (hasTypeArgs || needsDispatchSpecialization) { dbg("COLLECT GenericMethodCall: method=" + mc.getMethod().getName() + " " + id(mc.getMethod()) + " impl=" + (mc.getMethod().getImplementation() == null ? "null" : (mc.getMethod().getImplementation().getName() + " " + id(mc.getMethod().getImplementation()))) From c9ae9496af9c17605355c60031bfd616176560bb Mon Sep 17 00:00:00 2001 From: Frotty Date: Sun, 22 Feb 2026 16:18:25 +0100 Subject: [PATCH 3/3] unchecked dispatch tests --- .../tests/GenericsWithTypeclassesTests.java | 38 +++++++++++++++++++ .../wurstscript/tests/WurstScriptTest.java | 13 +++++++ 2 files changed, 51 insertions(+) diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/GenericsWithTypeclassesTests.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/GenericsWithTypeclassesTests.java index 0a8e5c5d7..7dee9d9c0 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/GenericsWithTypeclassesTests.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/GenericsWithTypeclassesTests.java @@ -2091,11 +2091,49 @@ public void fullArrayListTest() throws IOException { assertEquals(count, 2); assertFalse(compiled.contains("ArrayList_nextFreeIndex_")); + // In checked mode, virtual method calls should go through dispatch. + assertTrue(compiled.contains("dispatch_ArrayList")); + } + + @Test + public void fullArrayListTestUncheckedDispatch() throws IOException { + test().withStdLib().uncheckedDispatch().executeProg().executeTests().file(new File(TEST_DIR + "arrayList.wurst")); + + String compiled = Files.toString( + new File(TEST_OUTPUT_PATH + "GenericsWithTypeclassesTests_fullArrayListTestUncheckedDispatch_no_opts.jim"), + Charsets.UTF_8); + + // In unchecked dispatch mode, monomorphic ArrayList.get should be lowered directly. + assertTrue(compiled.contains("ArrayList_get")); + assertFalse(compiled.contains("dispatch_ArrayList")); } @Test public void fullReactiveGenericDispatchTest() throws IOException { test().withStdLib().executeProg().executeTests().file(new File(TEST_DIR + "reactiveGenericsDispatch.wurst")); + + String compiled = Files.toString( + new File(TEST_OUTPUT_PATH + "GenericsWithTypeclassesTests_fullReactiveGenericDispatchTest_no_opts.jim"), + Charsets.UTF_8); + + // In checked mode, polymorphic interface calls are dispatched and guarded. + assertTrue(compiled.contains("dispatch_ReactiveSource_ReactiveSource_subscribe")); + assertTrue(compiled.contains("Nullpointer exception when calling ReactiveSource.subscribe")); + assertTrue(compiled.contains("Called ReactiveSource.subscribe on invalid object.")); + } + + @Test + public void fullReactiveGenericDispatchTestUncheckedDispatch() throws IOException { + test().withStdLib().uncheckedDispatch().executeProg().executeTests().file(new File(TEST_DIR + "reactiveGenericsDispatch.wurst")); + + String compiled = Files.toString( + new File(TEST_OUTPUT_PATH + "GenericsWithTypeclassesTests_fullReactiveGenericDispatchTestUncheckedDispatch_no_opts.jim"), + Charsets.UTF_8); + + // ReactiveSource is polymorphic, so dispatch remains, but safety checks should be removed. + assertTrue(compiled.contains("dispatch_ReactiveSource_ReactiveSource_subscribe")); + assertFalse(compiled.contains("Nullpointer exception when calling ReactiveSource.subscribe")); + assertFalse(compiled.contains("Called ReactiveSource.subscribe on invalid object.")); } @Test diff --git a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstScriptTest.java b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstScriptTest.java index 5ca7e61b4..50c45edb7 100644 --- a/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstScriptTest.java +++ b/de.peeeq.wurstscript/src/test/java/tests/wurstscript/tests/WurstScriptTest.java @@ -70,6 +70,7 @@ class TestConfig { private boolean stopOnFirstError = true; private boolean runCompiletimeFunctions; private boolean testLua = false; + private boolean uncheckedDispatch = false; TestConfig(String name) { this.name = name; @@ -123,6 +124,15 @@ public TestConfig executeProgOnlyAfterTransforms(boolean b) { return this; } + public TestConfig uncheckedDispatch() { + return uncheckedDispatch(true); + } + + public TestConfig uncheckedDispatch(boolean b) { + this.uncheckedDispatch = b; + return this; + } + TestConfig expectError(String expectedError) { this.expectedError = expectedError; return this; @@ -186,6 +196,9 @@ private CompilationResult testScript() { if (withStdLib) { runArgs = runArgs.with("-lib", StdLib.getLib()); } + if (uncheckedDispatch) { + runArgs = runArgs.with("-uncheckedDispatch"); + } if (runCompiletimeFunctions) { runArgs = runArgs.with("-runcompiletimefunctions"); }