Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix return types #71

Merged
merged 29 commits into from
Nov 16, 2018
Merged

Fix return types #71

merged 29 commits into from
Nov 16, 2018

Conversation

AstraLuma
Copy link
Member

  • Actual algorithm for mixed-type operations
  • Fix more annotations
  • Tests to verify return types

There is an argument to be made for Vector/Vector operations to return the left type.

@AstraLuma AstraLuma requested a review from pathunstrom October 20, 2018 19:33
@nbraud
Copy link
Collaborator

nbraud commented Oct 20, 2018

@astronouth7303 I don't understand what's going on.

Is it making vector/vector operations that involve subclasses of Vector2 return the subclass? If so, what's the motivation?

@AstraLuma
Copy link
Member Author

Consistency of typing without every subclass needing to wrap each operation to return the right type.

Concretely, so ppb.vector.Vector appears and avoiding Vector2 showing up.

@nbraud
Copy link
Collaborator

nbraud commented Oct 20, 2018

Oh, I didn't realise ppb.Vector was different from Vector2.

👍 then, as it causes the bug I mentioned in mutant-game

@nbraud
Copy link
Collaborator

nbraud commented Oct 20, 2018

The type annotations are probably not as precise as they could be, though.
(Because they don't reflect that we return something more precise than Vector2)

@AstraLuma
Copy link
Member Author

Subclasses of the thing are implied in all annotations, afaik.

So, they all return a Vector2. It might be a specialization of Vector2, but it'll be a Vector2.

@nbraud
Copy link
Collaborator

nbraud commented Oct 20, 2018

Yes, but what I mean is that code that does a vector operation, then calls a subclass method/property on the result, would work with your change, but fail to typecheck because the typechecker thinks we return a Vector2

@AstraLuma
Copy link
Member Author

AstraLuma commented Oct 20, 2018

https://mypy.readthedocs.io/en/latest/kinds_of_types.html#class-types

Any instance of a subclass is also compatible with all superclasses

So a type annotation of Vector2 (or its late-bound variant 'Vector2') implicitly includes all subclasses of Vector2.

@nbraud
Copy link
Collaborator

nbraud commented Oct 20, 2018

@astronouth7303 OK, to clarify:

class V(Vector2):
    def method(self):
        pass

x = V(1, 0) + (0, 1)  # x's inferred type will be Vector2, not V
x.method()  # Should fail to typecheck

The type annotation is valid (as you said, a (covariant) annotation of Vector2 includes its subtypes), but it's not precise enough to be useful when people subclass Vector2.

@AstraLuma
Copy link
Member Author

Oh, then yes. I think this is one of the limitations of the python type checker, since (afaik), you can't have a type variable that goes between different bits of a method's annotation.

Like you can't say "x.foo() will return whatever type x is".

@pathunstrom
Copy link
Collaborator

That sounds like a thing that should be discussed on mypy.

tests/test_typing.py Show resolved Hide resolved
# Normalize for reflect
a = op(V1(1, 2), V2(3, 4).normalize())
b = op(V2(1, 2), V1(3, 4).normalize())
assert isinstance(a, V2)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do you mean specifically that we're returning the subclass in all cases?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, because V2 is a subclass of V1, it should return the more specialized V2.

ppb_vector/vector2.py Outdated Show resolved Hide resolved
@@ -34,6 +35,37 @@ def _mkvector(value, *, castto=_fakevector):
raise ValueError(f"Cannot use {value} as a vector-like")


@functools.lru_cache()
def _find_lowest_type(left: type, right: type) -> type:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should probably use typing.Type here.

Copy link
Member Author

Choose a reason for hiding this comment

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

These are documented as equivalent:

Type[Any] is equivalent to Type which in turn is equivalent to type, which is the root of Python’s metaclass hierarchy.

But I'll do it because metaprogramming like this can be weird.

@nbraud
Copy link
Collaborator

nbraud commented Oct 21, 2018

@astronouth7303 It looks like it's doable: https://gist.github.com/nbraud/a592985bb700ac74894efd8243cb08ce

@AstraLuma
Copy link
Member Author

Everything mentioned so far should be taken care of.

@AstraLuma
Copy link
Member Author

With annotations getting this complex, I figured we should actually run it through mypy.

It was informative.

@AstraLuma
Copy link
Member Author

python/mypy#5821 came up, and has some insights into typing.

@AstraLuma
Copy link
Member Author

Sorry, this PR is getting kinda large. I feel like I've touched most of the code at this point.

@nbraud
Copy link
Collaborator

nbraud commented Oct 27, 2018

@astronouth7303 Yeah, there is likely no way to improve the annotations without touching all the methods. Thanks a lot for doing it.

Small question about Realish (which I pronounce “relish” in my head, it's funny): I had also noticed that mypy complains about floats and ints not being Reals, but CPython seems to disagree:

> from numbers import Real

> isinstance(1, Real)
True

> isinstance(1.0, Real)
True

Is mypy in the wrong about ints and floats not being Reals?

@nbraud
Copy link
Collaborator

nbraud commented Oct 27, 2018

Re: the PR becoming too large, I guess we can split off the actual behaviour change (in selecting the return type) and the improvement in type annotations. What do you think?

@AstraLuma
Copy link
Member Author

I suppose I can. I'm not sure it helps much?

@AstraLuma
Copy link
Member Author

AstraLuma commented Oct 29, 2018

Small question about Realish (which I pronounce “relish” in my head, it's funny): I had also noticed that mypy complains about floats and ints not being Reals, but CPython seems to disagree:

This is because mypy doesn't pickup on runtime registered ABC subclasses.

So, mypy is correct in that numbers.Real is not in the MRO of int and float. But, if you ask if int is a subclass of Real at runtime, the answer will be yes (because it's explicitly registered as such).

(And no, this is pretty impure typing. It comes from a pragmatic side because numbers really should be a Python module, but any Python code that is core to CPython--such as a bunch of the import machinery--has to be handled Very Special. And for something as core as int and float, I'm just not sure its possible to import a Python module before int is defined.)

@nbraud
Copy link
Collaborator

nbraud commented Oct 29, 2018

@astronouth7303 Sounds like a bug that should be reported against mypy, then?
In any case, we should definitely keep the Realish union for now, but if the mypy devs fix it, we can in the future remove it.

@AstraLuma
Copy link
Member Author

AstraLuma commented Oct 29, 2018

That's Real Complicated, and there's been Very Large Discussions about mypy and ABCs and The Number Tower (PEP 3141). See python/mypy#2636 and python/mypy#3186 for details on those discussions.

.travis.yml Show resolved Hide resolved
ppb_vector/vector2.py Show resolved Hide resolved
ppb_vector/vector2.py Show resolved Hide resolved
elif isinstance(value, Vector2):
return cls(value.x, value.y)
# FIXME: Allow all types of sequences
elif isinstance(value, (list, tuple)) and len(value) == 2:
Copy link
Collaborator

Choose a reason for hiding this comment

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

isinstance(value, typing.Sequence) ?

Copy link
Member Author

Choose a reason for hiding this comment

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

Copy link
Collaborator

Choose a reason for hiding this comment

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

What's the difference, concretely? (Also, sure, go for it)

Copy link
Member Author

Choose a reason for hiding this comment

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

typing.Sequence is basically a typing-related data structure, only used to construct annotation data.

collections.abc.Sequence is an actual abstract class with default implementations of a pile of methods.

elif isinstance(value, (list, tuple)) and len(value) == 2:
return cls(value[0].__float__(), value[1].__float__())
# FIXME: Allow all types of mappings
elif isinstance(value, dict) and 'x' in value and 'y' in value and len(value) == 2:
Copy link
Collaborator

Choose a reason for hiding this comment

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

isinstance(value, typing.Mapping)?

Also, do we want to enforce len(value) == 2?
It might be useful to be able to build a vector from a dict like {'x': 0, 'y': 1, 'color': 'brown'}.

Copy link
Member Author

Choose a reason for hiding this comment

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

For now, I think we want to keep exact forms to prevent mistakes.

Especially since afaik no code actually uses the dict form.

Copy link
Collaborator

Choose a reason for hiding this comment

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

You added Mapping to the imports in a6161b9 but you forgot to do anything with it.

Copy link
Member Author

Choose a reason for hiding this comment

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

I did, this comment is outdated.


@property
def length(self) -> float:
if not hasattr(self, '_length'):
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is the reflection dance with hasattr to memoize the vector length really faster than recomputing it every time?

Copy link
Collaborator

Choose a reason for hiding this comment

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

OK, I did a quick μbench with timeit, and it looks like the speedup/slowdown of saving the result in _length is below the measurement noise, so I would recommend removing that code and just doing return hypot(self.x, self.y).

$ ./dunder_bench.py
1 successive length computations
        Memoized: 0.41153700300492346
        Computed: 0.41805144600220956
        Speedup:  1.5829543758445919%
5 successive length computations
        Memoized: 1.2452478090126533
        Computed: 1.2318236450082622
        Speedup:  -1.0780315297269971%
10 successive length computations
        Memoized: 2.252152765984647
        Computed: 2.2965358549845405
        Speedup:  1.970696200996347%
25 successive length computations
        Memoized: 5.274616469978355
        Computed: 5.400348082999699
        Speedup:  2.3837110003537276%
50 successive length computations
        Memoized: 10.367006862012204
        Computed: 10.45787951600505
        Speedup:  0.8765563214376826%
100 successive length computations
        Memoized: 21.056249847984873
        Computed: 21.302848722989438
        Speedup:  1.1711433744606925%
./dunder_bench.py  81.75s user 0.00s system 99% cpu 1:21.77 total

Copy link
Member Author

Choose a reason for hiding this comment

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

Does computing it have any significant overhead overhead? I did this to get rid of _fakevector while keeping the performance benefit of not computing the length at construction time.

Copy link
Collaborator

Choose a reason for hiding this comment

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

@astronouth7303 That's what the microbenchmark I wrote was showing: even if you access the computed length 100 times in a row, loading it from self._length isn't faster than recomputing it.

Copy link
Member Author

Choose a reason for hiding this comment

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

I simplified the implementation of length.

except ValueError:
return NotImplemented
rtype = type(other) if isinstance(other, Vector2) else type(self)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is it a good idea to move the computation of rtype before the try/except?
It can be that _find_lowest_vector throws an exception in cases where we would previously have returned NotImplemented

Copy link
Member Author

@AstraLuma AstraLuma Oct 30, 2018

Choose a reason for hiding this comment

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

_find_lowest_vector() doesn't throw exceptions, and they are internal functions meant for DRYing.

ppb_vector/vector2.py Show resolved Hide resolved
@@ -10,7 +10,7 @@
(Vector2(1, 1), Vector2(-1, 0), 135),
(Vector2(0, 1), Vector2(0, -1), 180),
(Vector2(-1, -1), Vector2(1, 0), 135),
(Vector2(-1, -1), Vector2(-1, 0), 45),
(Vector2(-1, -1), Vector2(-1, 0), -45),
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why are there unrelated changed in the angle tests?

Copy link
Member Author

Choose a reason for hiding this comment

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

Because passing tests.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Except that the tests are passing in master, so there is something wrong going on.

Copy link
Member Author

Choose a reason for hiding this comment

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

You're going to make my life difficult, I know it.

Copy link
Member Author

Choose a reason for hiding this comment

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

OK.

This is actually a bug mypy found.

The two tests in this file had the same name, so only the latter one was being run.

This exposed problems in the earlier test because it wasn't running before.

This can be moved to its own PR if y'all would really rather.

tests/test_vector2_reflect.py Outdated Show resolved Hide resolved
@nbraud
Copy link
Collaborator

nbraud commented Oct 29, 2018

@astronouth7303 Thanks for the link to the mypy discussions, re: the numbers tower.

"""
return type(self)(self.x * other, self.y * other)

@typing.overload
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍

@AstraLuma AstraLuma merged commit 96883e0 into master Nov 16, 2018
@AstraLuma AstraLuma deleted the fix-return-types branch November 16, 2018 07:35
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