Skip to content

Method resolution fix with overloading and variable arity#282

Merged
jeff5 merged 5 commits into
jython:masterfrom
tpoliaw:mr_tests
Dec 3, 2023
Merged

Method resolution fix with overloading and variable arity#282
jeff5 merged 5 commits into
jython:masterfrom
tpoliaw:mr_tests

Conversation

@tpoliaw
Copy link
Copy Markdown
Contributor

@tpoliaw tpoliaw commented Oct 28, 2023

This is very much a WIP and I've not looked at it since I opened #221 last year. I'll try and make sense of what I was doing but open to suggestions of better ways to approach it until then.

ReflectedCallData callData = new ReflectedCallData();
ReflectedArgs match = null;
// varargs methods should always have lower precedence than a non-varargs
// method that also applies but as they will always have <= arguments than an
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this is true but I don't think it matters. eg

foo(1,2)

would match both

public void foo(int i, int j) {}
public void foo(int i, int j, int... k) {}

The vararg case has more arguments but still shouldn't be used. As (I think) the methods are in arg count order, the vararg method will be later and therefore never checked.

@tpoliaw
Copy link
Copy Markdown
Contributor Author

tpoliaw commented Oct 28, 2023

For reference (mainly to save me having to look it up again next time), the Java spec reference for overloaded method order.

@robertpatrick
Copy link
Copy Markdown

@tpoliaw I cloned your PR branch and tried it. Unfortunately, it is broken...

rpatrick@rpatrick-mac jdk17 % ./jython.sh  
CLASSPATH = /Users/rpatrick/tmp/jdk17/myclasses.jar:/Users/rpatrick/tmp/jython/dist/jython-standalone.jar

Traceback (most recent call last):
  File "/Users/rpatrick/tmp/jdk17/testme.py", line 5, in <module>
    ex = mytest.CreateException("testing CreateException(String message, Throwable cause, Object... params) binding", throwable, ["arg1", "arg2", "arg3"])
	at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
	at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:77)
	at java.base/jdk.internal.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
	at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:499)
	at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:480)
	at org.python.core.PyReflectedConstructor.constructProxy(PyReflectedConstructor.java:229)
java.lang.IllegalArgumentException: java.lang.IllegalArgumentException: wrong number of arguments
rpatrick@rpatrick-mac jdk17 % 

method = argslist[i].method;
break;
} else if (varargMatch == null) {
varargMatch = argslist[i].method;
Copy link
Copy Markdown

@robertpatrick robertpatrick Oct 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still isn't correct. The previous implementation grabs the first method that matches. This one does exactly the same thing for the vararg methods. The real problem is that ReflectedArgs.matches() only has visibility into a single declared method's parameter definition and the args passed. It cannot differentiate between multiple overloaded methods (i.e., multiple vararg methods) that match to determine which one is best. For example, think about this use case:

mymethod(Object... params)
mymethod(String key, Object... params)
mymethod(String key, Throwable error, Object...params)

and then calling it like so:

error = Exception("oops")
obj.mymethod("foo", error, "abc", "def")

The problem is that ReflectedArgs.matches() will return true for all three of these declared versions of mymethod() yet the code picks the first one encountered (when the right one in this particular scenario is actually the one with three params).

The reason that removing #201 works for my overloaded constructors use case is that it does not call ensureBoxedVarargs() (because isVarArgs is false) so it is able to find the correct constructor.

Copy link
Copy Markdown
Contributor Author

@tpoliaw tpoliaw Oct 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It may be that the varargMethod == null check is causing problems. If it always chose the last matching vararg method it would pick the correct one for your example. Are there alternatives where an earlier one matches better than a later one?

Which would java pick out of

Foo(int i, int... rest) {}
Foo(Object i, Object j, int... rest) {}
Foo(Object i, Object j, Object k) {}

When called with Foo(1, 2, 3)?

I still don't think this is caused by #201. The same thing can be seen in non-constructors which weren't affected by the change. The reason reverting it fixes this case is that vararg constructors couldn't be called before (without manually passing lists) so they were excluded from the method checks.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Java, it will call Foo(Object i, Object j, Object k)...

Copy link
Copy Markdown

@robertpatrick robertpatrick Oct 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tpoliaw Here is a tweak to your change that I have confirmed resolves the problem for constructors. The "match score" is really just the length of the varargs method parameters list so this accomplishes finding the best match of the varargs constructors if there is no exact match of a non-varargs constructor.

I suspect that the PyReflectedFunction.__call__() matching logic needs a similar change:

        } else {
            // Just look for a constructor with no keyword args
            List<ReflectedArgs> varargMatches = new ArrayList<>();
            int n = nargs;
            for (int i = 0; i < n; i++) {
                rargs = argslist[i];
                if (rargs.matches(null, args, Py.NoKeywords, callData)) {
                    if (!argslist[i].isVarArgs) {
                        method = argslist[i].method;
                        break;
                    } else {
                        varargMatches.add(argslist[i]);
                    }
                }
            }
            if (method == null) {
                int argsCount = -1;
                for (ReflectedArgs varargsMatch : varargMatches) {
                    if (varargsMatch.args.length > argsCount) {
                        argsCount = varargsMatch.args.length;
                        method = varargsMatch.method;
                    }
                }
            }
        }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And if only the first two options are present it's a compile error so Jython will never have to deal with it.

I think removing the varargMethod == null check achieves the same thing. If there is no matching non-vararg method, the longest (and therefore most specific) vararg method will be used instead.

Copy link
Copy Markdown

@robertpatrick robertpatrick Oct 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And if only the first two options are present it's a compile error so Jython will never have to deal with it.

I think removing the varargMethod == null check achieves the same thing. If there is no matching non-vararg method, the longest (and therefore most specific) vararg method will be used instead.

I don’t see how removing the varargMethod == null check does the same thing UNLESS the ordering is such that the matching varargs constructor with the longest parameter list is ALWAYS the last one encountered. Is that a safe assumption to make? What handles the sorting to ensure that behavior?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tpoliaw Of course, you could collapse the logic to check the length of the ReflectedArgs.args to find the longest matching varargs constructor into the first for loop instead of storing references in a List and then iterating again…

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What handles the sorting to ensure that behavior

It's in ReflectedArgs.compareTo, there are other checks in the method for when the args length matches but I think this should ensure the sort order.

        int n = this.args.length;
        if (n < oargs.length) {
            return -1;
        }
        if (n > oargs.length) {
            return +1;
        }

@tpoliaw
Copy link
Copy Markdown
Contributor Author

tpoliaw commented Oct 29, 2023

I'm not surprised, if I thought it was the full fix I'd have submitted it last year. What example were you running to test that, the same one from this issue?

@jeff5
Copy link
Copy Markdown
Member

jeff5 commented Oct 29, 2023

For reference (mainly to save me having to look it up again next time), the Java spec reference for overloaded method order.

And https://docs.oracle.com/javase/specs/jls/se17/html/jls-15.html#jls-15.12.2

@tpoliaw
Copy link
Copy Markdown
Contributor Author

tpoliaw commented Oct 29, 2023

I cloned your PR branch and tried it. Unfortunately, it is broken...

I think the reason (or one of them) for this is that callData is reused between calls to arglist[i].matches(...) and it maintains state which is then passed to the method. This worked for the previous implementation as the matched method would always be the final method checked. Now that we're storing a vararg method we need to cache the autoboxed args somewhere to ensure that the correct arrangement of args are passed to the method when it ends up being called.

@tpoliaw tpoliaw marked this pull request as ready for review November 2, 2023 14:07
@tpoliaw
Copy link
Copy Markdown
Contributor Author

tpoliaw commented Nov 2, 2023

I'm moving this out of WIP for now. As far as I can make out, this fixes the issues seen with vararg method orders. If the basic premise is ok, I'm happy to look at tidying it up a bit and maybe extracting the common sections.

Copy link
Copy Markdown

@robertpatrick robertpatrick left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me

@jeff5
Copy link
Copy Markdown
Member

jeff5 commented Nov 2, 2023

The test failures on the launcher and spelling are unrelated (and can be fixed by merging from master into your branch), but I think the regrtest failure is real. Not happening on your machine @tpoliaw ?

@tpoliaw
Copy link
Copy Markdown
Contributor Author

tpoliaw commented Nov 5, 2023

@jeff5 I must have missed the new one amongst the expected ones. Should be fixed now. Also rebased onto master and squashed a couple of the fix/format changes.

@jeff5
Copy link
Copy Markdown
Member

jeff5 commented Nov 6, 2023

My fix for the Python version in the launcher test has broken again. (Someone is determined to stamp out Python 2: I wonder who?) I'll fix that again.

But test_joverload is currently failing too.

@tpoliaw
Copy link
Copy Markdown
Contributor Author

tpoliaw commented Nov 7, 2023

I'm getting less confident in this PR with every failure. Locally I'm now only getting

 [exec] 2 tests skipped:
 [exec]     test_codecmaps_hk test_curses
 [exec] 4 tests failed:
 [exec]     test_httplib test_os test_posix test_socket
 [exec] 4 fails unexpected:
 [exec]     test_httplib test_os test_posix test_socket
 [exec] Result: 1

@jeff5
Copy link
Copy Markdown
Member

jeff5 commented Nov 8, 2023

I'm getting less confident in this PR with every failure. Locally I'm now only getting

 [exec] 2 tests skipped:
 [exec]     test_codecmaps_hk test_curses
 [exec] 4 tests failed:
 [exec]     test_httplib test_os test_posix test_socket
 [exec] 4 fails unexpected:
 [exec]     test_httplib test_os test_posix test_socket
 [exec] Result: 1

I would run them individually to see the manner they fail, or cut and paste the list of tests from that output to a command like:

dist/bin/jython -m test.regrtest -v test_httplib test_os test_posix test_socket

If the complaints are about numbers of arguments, we're probably not there yet. If it's networking, they may be spurious (e.g. firewall or ISP interference).

@tpoliaw
Copy link
Copy Markdown
Contributor Author

tpoliaw commented Nov 9, 2023

There's nothing that looks related and I'm getting all the same errors when building master instead of this branch

@jeff5
Copy link
Copy Markdown
Member

jeff5 commented Nov 9, 2023

Well, it passed here on GitHub, and for me at home, so I'll dive into the code with the questions in my mind being:

  • Do the test answers reflect the behaviour we want?
  • Is there enough cover?
  • Is the style ok?

If we have to incorporate from master again, the way I do it is: get merge master on the PR branch. Force pushing invalidates what others may have pulled. It looks rambling but it all get's squeezed out in the squash merge.

@tpoliaw
Copy link
Copy Markdown
Contributor Author

tpoliaw commented Nov 9, 2023

Thanks for looking through it. The main thing I'm not happy about is the duplication and whether it can be extracted to a single place.

If we have to incorporate from master again, the way I do it is: get merge master on the PR branch. Force pushing invalidates what others may have pulled. It looks rambling but it all get's squeezed out in the squash merge.

Sorry about that, it's habit from the Gerrit workflow where the commit is reviewed unit rather than change as a whole.

@jeff5
Copy link
Copy Markdown
Member

jeff5 commented Dec 2, 2023

The main question, of course, is "what would Java do". The specification is quite long and it prescribes several steps and sub-steps (phases) for finding the right definition. It covers a lot of possibilities even when we ignore things Jython 2 doesn't understand (type variables, lambdas). I'm fairly sure our process is not the one prescribed, but do we get the same result?

Since I don't hold all this in my head, I've added to javatests.Reflection a main() that calls the same methods from Java. First interesting observation is that these in Reflection.Overloaded don't compile:

            foo(1);
            foo(1, 2, 3, 4);

because they match both foo(int, int...) and foo(int...) whereas Jython selects the latter.

I've been less successful showing we respect the rule about selecting the most specific definition. If I introduce

        public String foo(Number a, int... b) { return "Number, int...";   }
        public String foo(Object a, int... b) { return "Object, int..."; }

It makes different choices from Java when the first argument s 1L. I think this is because we only ask whether a conversion is possible. We deal with specificity by ordering the signatures, but it looks like black magic to me at the moment.

Combine this with the fact that PyLong.__tojava__ performs narrowing conversions to int etc., which seems dubious to me, and int is as good a match for 1L as Number.

However, I bet a load of stuff will break if we change that.

We call the methods in the test object from Java in order to set
expectations for the Python call.
@robertpatrick
Copy link
Copy Markdown

robertpatrick commented Dec 2, 2023

A couple of thoughts:

  • yes, foo(int a, int b) and foo(int a, int… args) is ambiguous. The real question is do we have to catch this in Jython during method selection or is it ok to let Java catch this for us? Since this won’t even compile, I think it is perfectly fine to not figure out that it is ambiguous in Jython because Java will never actually allow it to happen. Even if something were to slip through, I think it is perfectly fine to have the JVM generate the error at dispatch time rather than Jython needing to catch it.
  • Jython has long suffered from issues with converting literal numbers that could/should be ints to PyLong (doesn’t Python 3 do away with different integer types anyway?). Again, I don’t think we need to try to fix that as part of this fix. Just change your overload test cases to avoid int vs. long literals in Python (e. g., use Integer and Long Java classes instead). Better yet, just change the test cases to use other types to test overloaded method selection.

Jython may never be perfect but last I checked, this PR fixes a real problem and at least, does as good a job as (in fact, a better job than) Jython 2.7.1 and before. My vote would be to wrap this PR up, merge it, and let others report issues if they find them. Clearly, the current 2.7.2/2.7.3 is badly broken and this change supports our real-world use cases as far as I have been able to test it so while it is maybe not perfect, it at least restores pre-2.7.2 functionality that was broken in 2.7.2/2.7.3.

@jeff5
Copy link
Copy Markdown
Member

jeff5 commented Dec 2, 2023

  • Since this won’t even compile, I think it is perfectly fine

That text (as Java, in context) won't compile, to save you from the ambiguity, but it doesn't stop Jython interpreting the similar expression in Python as unambiguously a call to just one of those definitions.

Python 3 has only one kind of int. That may cause more problems than it solves, as noted in #281 (comment). (Not a problem for now.)

@robertpatrick
Copy link
Copy Markdown

robertpatrick commented Dec 2, 2023

  • Since this won’t even compile, I think it is perfectly fine

That text (as Java, in context) won't compile, to save you from the ambiguity, but it doesn't stop Jython interpreting the similar expression in Python as unambiguously a call to just one of those definitions.

again, the JVM will never allow the call (to the wrongly selected method) to even occur, whether due to a compiler error or a runtime dispatch error.

Python 3 has only one kind of int. That may cause more problems than it solves, as noted in #281 (comment). (Not a problem for now.)

So where does that leave us? What do we need to do to get this fix merged (and 2.7.4 released)?

@jeff5 jeff5 added this to the Jython 2.7.4 milestone Dec 2, 2023
@jeff5
Copy link
Copy Markdown
Member

jeff5 commented Dec 2, 2023

So where does that leave us?

You and Peter @tpoliaw have dug into this fairly thoroughly (thank you) and I've played around with it enough now to follow your discussion. If you're happy this fixes the variable arity call I am too.

It doesn't implement the standard process exactly and can't without changes that would almost certainly be problematic elsewhere. (Worth re-thinking for Python 3, though.) So I expect more problems. In fact, I think #276 is one.

@jeff5 jeff5 changed the title WIP Method resolution order update Method resolution fix with overloading and variable arity Dec 2, 2023
@robertpatrick
Copy link
Copy Markdown

@jeff5 I think the issue #276 has nothing to do with how the method selection algorithm is working and more to do with the types of the Python int/long args by the time they get to the method selection code. Should be easy enough to verify…

@jeff5 jeff5 merged commit dd271a5 into jython:master Dec 3, 2023
jeff5 added a commit to jeff5/jython that referenced this pull request Dec 4, 2023
We beef up these tests to show that PR jython#282 also fixes jython#281.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants