Repository navigation
Better alternative for algebraic data types #2464
Description
Many languages support algebraic data types, including Haskell, OCaml, Scala, Swift and Rust. Many programmers like them and they have some benefits over Python subclassing, which I won't discuss in detail here.
Currently we can kind of fake algebraic data types by using union types and named tuples:
class Circle:
radius: float
class Rectangle:
width: float
height: float
Shape = Union[Circle, Rectangle]
def area(s: Shape) -> float:
if isinstance(s, Circle):
result = math.pi * s.radius**2
elif isinstance(s, Rectangle):
result = s.width * s.height
return result
There are a few problems with this currently:
- If you forget to handle a case, mypy often won't complain about this. Catching these errors is often mentioned as one of the nicest things about algebraic data types.
- You need to write each item type name twice, once in the class definition and once in the union type definition. This is somewhat error-prone.
- The union type definition needs to come after the item types, which feels backwards to me. (If we fixed forward references to types in type aliases, it might help a bit.)
- Mypy doesn't keep track of the name of the union type alias internally, and in error messages it will just print out the entire union. This could be awkward since the union can have many items.
For (1), mypy could detect at least some undefined variables and recognize (some) if statements used for "pattern matching". For example, assume that we added a new shape, Triangle. Now mypy should complain about area(), as it fails to handle triangles (if we'd have used multiple returns, mypy could already have caught the error):
def area(s: Shape) -> float:
if isinstance(s, Circle):
result = math.pi * s.radius**2
elif isinstance(s, Rectangle):
result = s.width * s.height
return result # Error: "result" may be undefined (union item "Triangle" not handled)
For (2), (3) and (4), we could have some new syntax:
from mypy_extensions import NamedTupleUnion
class Shape(NamedTupleUnion): # This would be roughly similar to original union type
pass
class Circle(Shape):
radius: float
class Rectangle(Shape):
width: float
height: float
NamedTupleUnion would have a few special features. All subclasses are named tuples and must be defined in the same module as the NamedTupleUnion type (i.e. it can't be extended outside the current module). This way we can check whether all item types are handled using isinstance checks. Finally, only one level of inheritance would be supported, for simplicity -- though I'm not sure if this restriction is necessary.
[This is just a random idea I wanted to write down and it's not urgent in any way.]
Activity
ilevkivskyi commentedon Nov 16, 2016
This is an interesting idea. Here is some early bikeshedding:
TaggedUnion?Enum-like) nested class syntax:EDIT: Fixed typo pointed by @kirbyfan64
refi64 commentedon Nov 16, 2016
But isn't
Shapeundefined for the duration of its body evaluation?On topic: +10000 to this. ADTs are awesome. :D
ilevkivskyi commentedon Nov 16, 2016
Ah, sorry, that is just a typo, it is not needed.
JukkaL commentedon Nov 16, 2016
Hmm.. it would be less clear that the subclasses are named tuples (we want the default
__init__, unpacking, etc.). Also, there wouldn't be any extra tags required for these unions, as all objects have a type 'tag' by default. But I agree that the nameNamedTupleUnionisn't exactly pretty.This would require using qualified names such as
Shape.Circleinisinstancetests, which would be somewhat clumsy.ilevkivskyi commentedon Nov 16, 2016
This is true. On the other hand the definition is a bit more compact, and it is immediately visible later in code that
Circleis a member ofShapeunion.NamedUnionis a bit shorter and remindsNamedTuple. Also there will be a parallel:UnionandTuplevs.NamedUnionandNamedTuple.elazarg commentedon Nov 17, 2016
I would have liked the enum-like syntax (I have suggested it some time ago) but it is not exactly a tagged union, since
Shape.Circleis notShape, and sadly the syntax does not allow subclassing.Here's a wild idea:
This way we have a bounded scope for the definition of subelements of Shape, and we can also use its name in the subclasses.
datnamer commentedon Nov 17, 2016
Please excuse the noob question, but how would this be different than the classes extending the
Shapeprotocol or abstract class?elazarg commentedon Nov 17, 2016
The point is the declaration "A
Shapemust be one of these types and nothing else" in a way that can be used by mypy (using the declaration syntax) and checked at runtime (usingisinstance()to distinguish between items).ilevkivskyi commentedon Nov 23, 2016
Just another idea for the possible syntax. If we allow only named tuples a s members, then one can write:
At runtime, this will be transformed so that
Shape.Circlewill be aNamedTuple. As well__bases__could be patched so thatissubclass(Shape.Circle, Shape)will returnTrue. Also some other facilities could be added, makingNamedUniona more advanced version ofEnum.grayfall commentedon Jul 4, 2018
@elazarg I really like your syntax proposition. It can also be used to mimic Haskell's type constraints:
NamedUnion[name, constraints]resemblesTypeVarcall signature. We can actually subclass from constraints to further mimic Haskell'sderiving:lubieowoce commentedon Sep 1, 2018
In my sum type library I went with syntax like this:
It's similar to a Haskell GADT declaration in syntax and semantics¹ – there's only one class/type,
Shape, and you specify the signatures of its constructors²,CircleandRectangle.¹ as close as I could get in Python anyway 😄
² modulo a
clsparameter at the front – they get turned into classmethods(I hope this doesn't sound too much like a plug, just wanted to contribute to the discussion!)
stereobutter commentedon Apr 16, 2019
Since the canonical answer to the "where is the switch/case statement in python?" seems to be "use a dict", I think
TypedDictwould be perfect here as It would allow to catch missing cases.Sadly mypy does not allow for creating
TypedDict(orNamedTuple) types dynamically. That means that it is not possible to pull out the types from a regularUnione.g.Union[str, int]and and create a suitableTypedDictwith all the cases from it e.g.TypedDict['Cases', {str: Callable[[str],...], int: Callable[[int], ...]}].See #4128 and #848 for reference
tmke8 commentedon Oct 2, 2019
This syntax actually already kind of works today. The following type checks and runs:
The only ugly thing is that you have to define the
Unionmanually and have to give it a different name.DrPyser commentedon Oct 7, 2019
@thomkeh Yes, the point of special support would be to minimize verbosity, maximize clarity and simplicity. Here you have to juggle with both a base type(useless in itself, except for namespacing the subclasses) and an additional type alias.
Personally, I've found myself naturally wanting and trying to emulate this nested class syntax. I think it just makes sense visually.
And I like having subclasses available in the base class' namespace.
I think it would be intuitive for anyone used to working with
enum.Enum.dckc commentedon Oct 7, 2019
It's good to know that sum types can be partly emulated today. But one of the key features of algebraic datatypes is that they can be recursive (e.g.
data List a = Nil | Cons a List) . I don't see how to apply the pattern above to recursive datatypes.gvanrossum commentedon Oct 7, 2019
Adding support for recursive types is on the roadmap for mypy.
DrPyser commentedon Oct 9, 2019
@dckc Using lazy annotations, I don't see any syntactic issue.
One issue is the incompatibility between
typing.NamedTupleandtyping.Generic.dfroger commentedon Jun 15, 2021
Will this issue be resolved using https://www.python.org/dev/peps/pep-0622/#sealed-classes-as-algebraic-data-types ?
dfroger commentedon Jun 15, 2021
Another question (maybe not directly related to mypy...) : what would be recommended to serialize in JSON, since there is no "tag"? Adding the class name? But the class name may be an implementation detail that may change.
JelleZijlstra commentedon Jun 15, 2021
@dfroger PEP 622 was withdrawn; the final version of pattern matching didn't have sealed. However, it's a natural future extension.
This isn't the right place to discuss JSON serialization.
LeeeeT commentedon Mar 30, 2024
Using the new PEP 695 syntax and pattern matching you can express algebraic data types in a very nice and concise way.
For product types you can use dataclasses.
For sum types, here's the way I propose.
Either
List
Bool
Personally, I think it's not worth it to overcomplicate things by adding a new special syntax or extending the standard library, as it's already fine with the current version of Python.
There's also a Scott encoding way of representing algebraic data types.
(If you feel real nerdy today)
Option
Number