Repository navigation
Description
The text in PEP 483 that describes how unions behave actually doesn't do a very good job as a definition of a union type.
- Union[t1, t2, …]. Types that are subtype of at least one of t1 etc. are subtypes of this.
- Unions whose components are all subtypes of t1 etc. are subtypes of this. Example: Union[int, str] is a subtype of Union[int, float, str].
- The order of the arguments doesn’t matter. Example: Union[int, str] == Union[str, int].
- If ti is itself a Union the result is flattened. Example: Union[int, Union[float, str]] == Union[int, float, str].
- If ti and tj have a subtype relationship, the less specific type survives. Example: Union[Employee, Manager] == Union[Employee].
- Union[t1] returns just t1. Union[] is illegal, so is Union[()]
- Corollary: Union[..., object, ...] returns object.
This lists some facts that are indeed true if we were to interpret Union[A, B] as a static type that stands for a set of values that is a set-theoretic union of the sets that A and B stand for. However this does not actually define Union[A, B] to be that set.
Many have interpreted the first line: "Types that are subtype of at least one of t1 etc. are subtypes of this." as a definition:
C <: A | Bif and only ifC <: AorC <: B
However this is now how set-theoretic unions work, it is not in general true that
Counterexample:
>>> A = {1}
>>> B = {2}
>>> C = {1, 2}
>>> C.issubset(A | B) == (C.issubset(A) or C.issubset(B))
FalseWhat people probably are thinking of is the formula <: aka
This is a solved problem and I'll cut to the chase: the formulas that define set-theoretic unions and intersections are as follows:
The duality between intersection and union lies in reversing the subset relation, not in replacing an "and" with an "or". In fact it is a well understood idea that transitive relations play extremely poorly with "or", and extremely well with "and". Hence the above definitions both using "and".
What about
Proof
- Substitute
$C = A \cap B$ into the first equation. LHS is true by reflexivity, so RHS must be true too. If we have$A \subseteq D$ then by transitivity$A \cap B \subseteq A \subseteq D$ . Likewise if we have$B \subseteq D$ . So in total if we have either$A \subseteq D$ or$B \subseteq D$ then we have$A \cap B \subseteq D$ . - Same proof but with all relations reversed. Substitute
$C = A \cup B$ into the second equation, RHS must be true. If$D \subseteq A$ then by reflexivity$D \subseteq A \subseteq A \cup B$ . Same for$B$ .
In #1, here, @erictraut lists all 4 of these on equal footing, labelled "non-controversial". I want to emphasize that two of these involving "and" are actually definitions by universal property, and the two involving "or" are special cases/corollaries.
What does the PEP-483 paragraph actually define?
Most of the bullet list is actually describing a reduction procedure for type expressions involving class literals and unions. The proposed algorithm of checking C | D <: A | B with (C <: A or C <: B) and (D <: A or D <: B) only works exactly because each of A, B, C, D can only be a class, as the only other possible form - union - gets flattened first. However in presence of intersections this algorithm already fails:
A & (B | C) <: (A & B) | (A & C) vs A & (B | C) <: A & B or A & (B | C) <: A & C
I'll also cut to the chase here: you want to reduce the left hand side to a disjunctive normal form, and the right hand side to a conjunctive normal form. The | on the left and the & on the right can then be taken apart. But the & on the left and the | on the right cannot. Problems of the kind t1 & ... <: s1 | ... where t1, ..., s1, ... are class literals -- are in general irreducible and will require knowledge of the inheritance tree and whether a multiple inheritance child class exists/can be constructed.
Activity
randolf-scholz commentedon Aug 5, 2023
@mniip
Why is it unsafe to assume there are only finitely many
c? Seems like a fine assumption to make to me. Mathematically, it may be simpler to prove certain things with the infinity assumption, but in principle one should be able to restrict the theory to finite cases as we want to reason about real-world computer code, where only ever finitely many types and runtime values can exist.Can you please expand a bit why it is necessary to know
whether a multiple inheritance child class exists/can be constructed? Why can't we just treatA&Blike an abstract structural type (just like we do withA|B)? Whether *actual* types exist that are subtypes ofA&Bshould be irrelevant when testing subtype relationships.NeilGirdhar commentedon Aug 5, 2023
I think you misunderstood that part. He's just saying that the type of
cmay not some single class, but could be a swath of an infinite number of possible types—too numerous to check. Therefore the definitions of union and intersection should be with respect to sets of types rather than individual types.I do think we should lift this entire theory section into the PEP though. What do you think?
I wondered the same thing. Anyway, this should be discussed in #5
NeilGirdhar commentedon Aug 5, 2023
@mniip Do you have time to replace the section on "Theory" in
specifications.rstwith something along the lines of what you wrote above?mniip commentedon Aug 7, 2023
@NeilGirdhar
I was talking about how a static type is a set of values.$c$ is supposed to range over values here. We do have static types that are narrower than a runtime class (e.g.
Literalorlist[int]).@randolf-scholz
Let's simplify to
int. Are you arguing that because we store the number of digits in a machine word, we can only represent finitely many integers inint, up to 2^(2^64) or so? I think it helps to have at least a pretense of turing completeness here by abstracting from specific hardware and a specific implementation of python, such that this bound could be made arbitrarily high.Even without this, it's simply impractical to test this many values.
There are a couple of fundamental type operations that become nontrivial in presence of intersection types. Given some function type
F, we are interested in the following:dom Fcomputes the widest set of arguments (i.e. a type) with which a function can be called.Tsuch thatT <: dom F, the result typeF ° Tindicates a possibly narrower type of results of applyingf: Fto an argumentx: T.The computation of the result type requires external knowledge about which conjunctions of types are satisfiable (i.e. are a non-empty type). The more precisely we can answer that question the narrower our result types become. For example given
F = (A -> B) & (C -> D)we havedom F = A | C. But the computation ofF ° Adepends on the relationship betweenAandC:A & ~Cis inhabited, i.e. it is not the case thatA <: C, thenF ° A = B.A & ~Cis uninhabited, i.e.A <: C, andA & Cis inhabited, thenF ° A = B & D.A & ~Cis uninhabited, i.e.A <: C, and alsoA & Cis uninhabited, this meansAis actuallyNever, soF ° A = Never.randolf-scholz commentedon Aug 7, 2023
@mniip I recognize that this is a wall of text, so bear with me.
I apologize for the unclear formulation earlier. The way it appears to me is the following
S = {"abcd", 123, [object()], 42.123, …}of all possible runtime values. We can then say the typelistcorresponds to the subsetlistobjects (or subtypes thereof) that could be created at runtime.Xwe havetype[X] <: list⟺xis an 64-bit integer by constructing the set of all 64-bit integers and then looking ifxis in this set. To construct this set in the first place, you'd had to have some exogenous criterion of what a 64-bit integer is, and this same criterion can be used to determine whetherxis a 64-bit integer.A | BandA & BT = {object, set, int, float, Hashable, …}of all possible types that can be created. This set can be equipped with a partial order according to the subtype relationship, essentially giving rise to an ordered graphobjectat the top andNeverat the bottom.Tstarts with a small, finite set of builtin-types and then gets larger as more types are constructed. Note that static analysis doesn't allow us to reason about dynamically created types. What I am saying we can assume that the set of types stays finite because otherwise it implies the program never halts.x <: a | b⟺x∈T_{<: a|b}⟺x ∈ T_{<:a} ∨ x∈T_{<:b}A | BandA & BA <: B, what is the definition ofA <: B? Python seems to allow multiple things≤ɴᴏᴍ).B ≤ɴᴏᴍ Aif and only ifBdirectly or indirectly inherits fromA. The codeclass B(A)creates a type that is always a nominal subtype ofA.A nominal subtype should also be a structural subtype (
X ≤ɴᴏᴍ Y ⟹ X ≤ꜱᴛʀ Y), but errors arising from violations of this are restricted to the class-body of the subclass. After the subclass is defined, it is always considered a nominal subtype of its parents/ancestors.≤ɢʀᴅ). There is a special typeAnythat lives outside the regular subtype graph and satisfies special rules. Contrary to the other two, gradual subtyping is not transitive:Any ≤ɢʀᴅ B ≤ɢʀᴅ Any.≤ꜱᴛʀ).Bis a structural subtype ofAif the interface ofBis compatible with the interface ofA, meaning thatB ≤ꜱᴛʀ Aif and only if for all attributes/methodsfooofAholds thatB.foo <: A.foo. (B.foo ≤ɴᴏᴍ A.fooorB.foo ≤ꜱᴛʀ A.fooorB.foo ≤ɢʀᴅ A.foo), or equality for mutable attributes if one wants to be strict.In summary:
B <: A⟺B ≤ɴᴏᴍ AorB ≤ɢʀᴅ AorB ≤ꜱᴛʀ A.Therefore, what we need to do in the PEP is specify:
C ≤ɴᴏᴍ (A&B)and when(A&B) ≤ɴᴏᴍ C. My suggestion:C ≤ɴᴏᴍ (A&B)ifCinherits from bothAandB, and(A&B) ≤ɴᴏᴍ Cnever, as we can treatA&Bas a structural type.C ≤ɢʀᴅ (A&B)and when(A&B) ≤ɢʀᴅ C. Suggestion: treatAny & Xas irreducible and use the structural definition.C ≤ꜱᴛʀ (A&B)and when(A&B) ≤ꜱᴛʀ C. Suggestion:C ≤ꜱᴛʀ (A&B)if and only if(C ≤ꜱᴛʀ A) and (C ≤ꜱᴛʀ B)and(A&B) ≤ꜱᴛʀ Cif and only if(A ≤ꜱᴛʀ C) or (B ≤ꜱᴛʀ C).While I agree that this would be very nice information to have in some cases, even right now
dom Fis only implicitly known to type-checkers due to overloads. Safe application is tested on a per-case basis, i.e. whenF(<args>)appears in code, the type-checker will simply traverse the list of overloads and see if any match<args>, and generally pick the first match to determine the return type.So if
F = [(T1 -> R1), (T2 -> R2), (T3 -> R3), ...], where[f1, f2, f3, ...]is the ordered list of overloads, the type checker doesn't need to know thatdom F=T1 | T2 | T3 | ..., because it can determine ifX <: dom Fby simply sequentially testing whetherX <: T1,X <: T2,X <: T3, etc. ThenX <: dom Fif and only if a match is found.Again, I believe the by far the easiest solution is to treat
A&Bas a purely structural type. In the exampleF = (A -> B) & (C -> D), the computation ofF ° Xwould simply be:X <: AandX <: C, thenB&DX <: Aand¬(X <: C), thenB¬(X <: B)andX <: CthenD¬(X <: B)and¬(X <: C)then error.Mathematically, this means the intersection simply is the meet operation from partially ordered sets. In general, in partially ordered sets, the meet may not exist. However, in our case we can guarantee the meet exists, since
Neveris a universal lower bound, and we can assume the set of types involved in any halting program is always finite.The only major disagreement at this point in time seems to be whether
A&Bmust be identified withNeverif there is any structural incompatibility betweenAandB. I disagree with this sinceclass C(A, B)would create a nominal subtype of bothAandB, henceC ≤ɴᴏᴍ (A&B). At this point, to be logically consistent, one would have to insist thatCis alsoNever, butCis a class with some interface and clearly notNever.Just like with regular nominal subtyping, if
Ais incompatible withBthis will manifest itself inside the class body ofC. In conclusion, ifAis incompatible withBthen logically we have:X ≤ꜱᴛʀ (A&B)⟹X=Never.X ≤ɢʀᴅ (A&B)withX ≠ Nevermaybe still possible, I am not 100% set on this.X ≤ɴᴏᴍ (A&B)withX ≠ Neverstill possible.Rules (1) and (3) are inconsistent, given the requirement
X ≤ɴᴏᴍ Y ⟹ X ≤ꜱᴛʀ Yfrom before. However, the scope of this error is restricted to the class body of the nominal subtype, just like it is in regular nominal subtyping if you write aclass B(A)with an interface incompatible withA; there is nothing special about the intersection in this regard.randolf-scholz commentedon Aug 7, 2023
The following example checks in both
mypyandpyrightand shows that the structural incompatibility only raises errors insider the class body. Afterwards,class B(A)is considered a nominal subtype ofAand can be used anywhereAis used.https://mypy-play.net/?mypy=latest&python=3.11&gist=89aa3c3f034d0fb9e0be4e0f8b6aa4db
mikeshardmind commentedon Aug 25, 2023
Any example with a type ignore should not be used to make an argument if the type ignore is used to break the rules. From false assumptions, you can construct any desired result.
randolf-scholz commentedon Aug 26, 2023
@mikeshardmind That's entirely missing the point. The question is: when a rule is broken, at which point is/should the error raised? Of course, any example demonstrating a broken rule must include a broken rule.
Python has multiple kinds of subtyping relationships: nominal (
≤ɴᴏᴍ), structural (≤ꜱᴛʀ) and gradual (≤ɢʀᴅ). When a type-checker tests for subtyping relationship, it tries to validate thatA <: B⟺A ≤ɴᴏᴍ BorA ≤ꜱᴛʀ BorA ≤ɢʀᴅ BA ≤ɴᴏᴍ BthenA ≤ꜱᴛʀ BI hope we can agree on the following:
C(A,B)then this is by definition a nominal subtype ofAandB, because it inherits from both. Therefore, subtyping checks ofCagainstA&Bmust succeed by virtue of rule ①.AandBare incompatible classes, then the implementation ofCwill violate rule ②, and hence there must be an error raised ~somewhere~ inside the class body ofC.The only thing we disagree on is that you keep insisting that the PEP must mandate that type-checkers must error on using
A&Bas a type hint. But honestly, this seems out of scope; in order to specify intersections, it should be sufficient to rigorously define whenC <: A&Band whenA&B <: C. I don't buy this divergence problem until we have some really convincing example of how divergence could occur, given thatC <: A&BandA&B <: Care rigorously defined.On the other hand, your proposal of erroring on
A&Bwould lead to some very real divergences, likeisinstance(x, A&B)vsisinstance(x, A) and isinstance(x, B)not being equivalent to a type-checker.mikeshardmind commentedon Aug 27, 2023
It actually isn't. We can't design for what happens if you suppress errors, that's the realm of type checkers.
A & B should be
Neverif A and B are incompatible. Period. Anything else is incorrect. The idea that you would intentionally specify an impossible intersection rather than the type you constructed and then ignored above is outside of what we can design for because you've started by violating one of the rules.randolf-scholz commentedon Aug 27, 2023
Missing the point again. If someone defines a class
C(A,B), thenCis a nominal subtype of bothAandB. Period.What you are implicitly stating is that structural subtyping must take priority over nominal subtyping. However, this is contrary to how type-checkers like
mypyorpyrightwork. Nominal subtyping takes priority over structural subtyping.Just because someone could not define a structural subtype of
A&B, does not imply that no one could define a nominal subtype or a gradual subtype. The nominal subtype would violate the rule "IfA ≤ɴᴏᴍ BthenA ≤ꜱᴛʀ B", but this is not the same as the nominal subtype not existing.mikeshardmind commentedon Aug 28, 2023
I'm not though, the class C(A, B) is already breaking the rules by existing, and your type ignore in your example is hiding that. We cannot define a set of specifications of correctness under the assumption that people are breaking other rules and supressing the warnings for them, or we will be stuck in an endless set of more and more ridiculous exceptions.
randolf-scholz commentedon Aug 28, 2023
I completely agree, and that's why an error needs to be raised inside the class body of
C.Rule breaks will inevitably need to happen, Python is too dynamic to be completely statically typed atm. The point we are discussing is if a rule break happens, then at which point a rule break is messaged.
We both agree that a nominal subclass
C(A,B)that is not a structural subtype of bothAandBis a rule break, and an error needs to be raised.We do not agree that
A&Bneeds to necessarily raise, because the contraposition is thatA&Bis a purely abstract type that is completely and uniquely specified by its subtyping rules, just likeUnionis. It is the greatest lower bound (=meet). If someone writes a functionfoo(x: A&B), there are no subtyping rules broken here. Subtyping only gets violated once someone tries to feed in anxwhich is not a subtype ofA&B, so this is the point at which an error should be raised.The contraposition is that it doesn't matter whether
A&Bis only satisfiable byNeveror not at function definition time, becauseA&Bis viewed as the purely abstract greatest lower bound.mikeshardmind commentedon Aug 28, 2023
I said that A& B for incompatible definitions of A and B must reduce to Never. This is different from the expression itself raising
And that bound is uninhabited, see the very first post in this thread for what that means, along with typing.assert_never showing that it actually isn't just an abstract definition, but something used by both the person calling, and the function itself. other existing typing features need to be able to detect a type being uninhabited, this means
A & Bwhere A and B are incompatible must reduce to Never.randolf-scholz commentedon Aug 28, 2023
From a structural point of view, yes, but there is no necessity to do this reduction since anyone who passes in anything other than
Nevermust have made a structural error earlier on. Again, the whole point here is when is the right place and time to detect structural errors.Also, secondly, I am not sure we agree on what it means for
A & Bto be structurally incompatible, so maybe we should clarify that. My thoughts can be summarized with the following table, which indicates the type of(A&B).fooif the condition is satisfied, otherwiseAandBare structurally incompatible.Notes:
Callable[..., Never]which is a structural subtype of any other Callable.Neveris a subtype ofCallable.mikeshardmind commentedon Aug 28, 2023
@randolf-scholz I was quite clear with my meaning, and you seemed well aware of it in the other thread where you tried to use a type ignore to circumvent it. If it is impossible for a type to exist that satisfies all components of an intersection, the intersection must resolve to Never. This is a discussion better had in the other thread.
CarliJoy commentedon Aug 28, 2023
This is nothing that this PEP needs or should define, but it is up to the type checker maintainers to decide this.
We define should focus on defining a proper set of rules that's it.
We won't be able to define when and how to detect problems for type checkers.
The only thing we could do is write something like:
But nothing more.