Alternative to Union Types
As shown, union types can be used to represent alternatives of several different types, without requiring those types to be part of a custom-crafted class hierarchy, or requiring explicit wrapping.
Pre-planning the Class Hierarchy Other languages would require pre-planning of the class hierarchy, like
[Responding to John Hagen] Thanks for writing up the PEP. However, like Eric, I'm strongly negative on this proposal. I find it unergonomic and not worth it for the benefits it claims to add. ## Favor Composition over Inheritance On a high level, we want to favor composition over inheritance. With `sealed`, we would be restricted to classes being defined in the same module and all part of a fixed class hierarchy. With union types, we can freely mix and match library classes and user-defined classes. And we can use a class as part of any number of unions, in any module. A *very* common pattern I see with type aliases is to have a large union of types imported from various modules, including third-party libraries. (For open-source examples, see [1].) This flexible mixing would not be possible with sealed classes: ``` from foo import Foo from bar import Bar MyUnion = Foo | Bar | ... def foo(x: MyUnion) -> int: if isinstance(x, Foo): return x.foo_method() if isinstance(x, Bar): return x.bar_method() ``` ## Scala and Kotlin: Sealed Classes for the Lack of Union Types You mention Scala as a language that has sealed types. But Scala only got union types in Scala 3 [2]. They were restricted to sealed classes before that. Their own docs point out the weaknesses of sealed classes/traits compared to union types [3]: the following example illustrates:
trait UsernameOrPassword case class Username(name: String) extends UsernameOrPassword case class Password(hash: Hash) extends UsernameOrPassword def help(id: UsernameOrPassword) = ...
Pre-planning does not scale very well since, for example, requirements of
Union types would make a big difference in Kotlin. At the moment, a sealed class is the closest thing to a union type, but it has several
API users might not be foreseeable. Additionally, cluttering the type hierarchy with marker traits like UsernameOrPassword also makes the code more difficult to read. The other reason Scala needed explicit case classes was to enable pattern-matching, since case classes automatically derive the necessary `unapply` method [6]. Python's structural pattern-matching doesn't have that limitation. Kotlin also lacks union types, and the language designers seem to frequently mention the same downsides as above [4]: limitations, the biggest being that you can only have subtypes defined by you as part of a sealed class.
[PEP] A design pattern where a group of record-like classes is combined into a union is popular in other languages that support pattern matching
TypeScript's type system is closer to Python's than Scala or Java. And TypeScript heavily uses union types for exhaustive pattern-matching. ## Motivating Example I found your motivating code snippet pretty hard to understand when it was written using `sealed`. We have to make each of the classes inherit from the sealed superclass. This is cumbersome for users and ties them down to a class hierarchy. This goes against the spirit of static duck typing, where we shouldn't have to reach into a class definition and add a parent just to make it acceptable to the type system. With `sealed`, we are limited to classes defined by the user in the same module. In real-world code, I find a lot of examples of unions of types imported from different modules, but very few of the same-module classes that you have. How often do you think this will be used? The PEP's approach also requires us to define empty shell classes for `Node`, `Expression`, or `Statement`. Finally, to understand the intended meaning of the `Expression` class, I have to go over the rest of the file and hunt for its subclasses (which could be anywhere in the module). That kind of non-local reasoning is highly unintuitive. With a union, `Expression = Name | Operation`, I can see the components of `Expression` right where it is defined. Contrast the motivating example to the more idiomatic `Union` version, where we have independent classes: ``` from __future__ import annotations from typing import * from dataclasses import dataclass @dataclass class Name: name: str @dataclass class Operation: left: Expression op: str right: Expression @dataclass class Assignment: target: str value: Expression @dataclass class Print: value: Expression Expression = Name | Operation Statement = Assignment | Print Node = Expression | Statement def dump(node: Node) -> None: match node: case Assignment(target, value): reveal_type(target) reveal_type(value) case Print(value): reveal_type(value) case Operation(left, op, right): reveal_type(left) reveal_type(op) reveal_type(right) case _: assert False ``` The above works just fine in Pyright. I also tested something similar in Pyre [5]. ## Specification
Once disadvantage [of explicitly listing subclasses, as in Java] is that requires that all subclasses to be written twice: once when defined and once in the enumerated list on the base class.
Doesn't your proposal also require the base class to be written an extra time for each subclass? For example, in your motivating example, Node, Expression, and Statement are repeated for each subclass. ## Rejected Ideas
Having to remember whether to use the base class or the union type in each situation is particularly unfriendly to the user of a sealed class.
Agreed that this is a drawback of the union approach. But this seems minor given that the example was somewhat contrived: the user matched on `Node | None` and did nothing with the `Node` branch. The most common use case is where we are matching on the individual variants of `Node`: `Expression`, `Statement`, etc. As Thomas (tmkehrenberg) mentioned:
Unions do support isinstance since python 3.10 but apparently they do not support pattern matching. This feels like an oversight to me.
That should be a reasonable solution to this problem. Even without that, I don't think this is a significant drawback. ## Conclusion It looks like the main benefits proposed by this PEP are: 1. Not having to import base classes in the cases where we want to match on the whole union instead of matching the individual variants. This seems like a minor benefit and can be addressed by allowing Unions in match. 2. Automatic exhaustiveness-checking: That is, if the match statement for `Node` doesn't check for `Name`, the type checker should warn about it. This is currently already expressible in 3.11 using Jelle's `case _: assert_never()`. Yes, this is cumbersome to add, but it's not always illegal to leave out a case branch. In cases where it is illegal - say, if each case branch defines a variable, but there is no case branch for `Name` - then the type checker already complains about it. For example: ``` match node: case Assignment(target, value): x = 1 case Print(value): x = 2 case Operation(left, op, right): x = 3 print(x) # Pyright: "x" is possibly unbound (since there was no branch for Name) ``` A better solution for your use case might be to have type checkers complain about missing case branches in their "strict" mode. That should satisfy users who want `match` to check for exhaustiveness without `assert_never`, while avoiding unnecessary noise for most users. Other than these two minor benefits, I don't see any compelling advantages. There are many downsides, such as: 1. Not being able to create an ADT using existing types. 2. Tying the user to a class hierarchy. 3. Causing unidiomatic code, with empty base classes that need to be repeated for each subclass and non-local reasoning needed to understand the meaning of the sealed base class. If we want "versatile ADTs", unions are much more flexible and versatile than the `@sealed` alternative, while providing similar exhaustiveness-checking. So, I'm strongly negative on this PEP. [1]: https://grep.app/search?q=%3D%20Union%5B&case=true&filter[lang][0]=Python [2]: http://dotty.epfl.ch/docs/reference/new-types/union-types-spec.html [3]: https://docs.scala-lang.org/scala3/book/types-union.html#alternative-to-unio... [4]: https://discuss.kotlinlang.org/t/union-types/77/103 [5]: shorturl.at/acF26 [6]: https://docs.scala-lang.org/overviews/scala-book/case-classes.html#an-unappl... On Sat, May 7, 2022 at 5:16 PM Guido van Rossum <guido@python.org> wrote:
On Sat, May 7, 2022 at 4:39 PM Tin Tvrtković <tinchester@gmail.com> wrote:
Just like Eric, appreciate the work that has gone into this. I am excited by the idea of ADTs since they are a key part of making invalid state unrepresentable, which is amazing for software correctness. However, I would be disappointed if this version of ADTs was accepted since I think basing our ADTs on the Pythonized version of the Rust syntax would be more usable and elegant. The argument given against it is that this would be a more invasive change, which may not matter for us to get it right the first time?
Rust-style enums containing both classes and values would be my ideal scenario. I'm very happy to share actual examples from work where I've wished I had robust ADTs if that would help.
Few people here know Rust. (At least I'll admit that *I* don't know it.) I don't believe actual examples from your work would be that helpful (without knowing Rust I wouldn't be able to understand them). But perhaps you could sketch out a counter-proposal with a Pythonic syntax and some tutorial examples? (More than foo+bar, less than real-world code.) I certainly am not married to the @sealed syntax, it's just the only thing I feel I have a decent understanding of (roughly, "final outside this module").
-- --Guido van Rossum (python.org/~guido) *Pronouns: he/him **(why is my pronoun here?)* <http://feministing.com/2015/02/03/how-using-they-as-a-singular-pronoun-can-change-the-world/> _______________________________________________ Typing-sig mailing list -- typing-sig@python.org To unsubscribe send an email to typing-sig-leave@python.org https://mail.python.org/mailman3/lists/typing-sig.python.org/ Member address: gohanpra@gmail.com
-- S Pradeep Kumar