Introduction

For a project I’m doing at work, I wanted to get a better understanding of some of Pydantic’s underlying mechanics related to generics. This led me down a rabbit hole and I was left wondering if I could do arbitrary computation with python types. That is, could I ergonomically do computations using types directly while never explicitly instantiating a class.

Implementation

For this exercise I’m going to look at classes that represent integers and implement the ability to add these classes to produce a new class. To do this I’m going to leverage __class_getitem__. As background, in Python, one can implement the method __getitem__(self, key) to define what indexing into an object using [] does. For example:

Note, this and the following examples were all executed in an ipython shell using python 3.10.13.

from typing import Any

class Indexable:
  def __getitem__(self, key: Any) -> Any:
    return f'my_{key}'

indexable = Indexable()
indexable[1]  # 'my_1'
indexable["computer"]  # 'my_computer'

__class_getitem__ is an analogous method for indexing classes and lets one implement using [] on a class. Usually (in all cases I’ve seen), the argument inside of [] is a class and is used for defining generics.

from typing import TypeVar, Generic

T = TypeVar('T')
class MyClass(Generic[T]): ...

Now one can instantiate classes via MyClass[int](), MyClass[str](), etc.

However, there is nothing restricting one to only pass classes to []. One could pass arbitrary data. Combining this with the ability to dynamically make classes on the fly, one could generate a new class for each integer. For example:

from types import new_class
from typing import Any

class Integer:

    integer_type_cache: dict[int, type] = {}

    def __class_getitem__(cls, integer: int) -> type:
        if integer not in Integer.integer_type_cache:
            neg = "Neg" if integer < 0 else ""
            Integer.integer_type_cache[integer] = new_class(
                f"Integer{neg}{abs(integer)}",
                bases=(),
                kwds=None,
                exec_body=lambda ns: ns.update(
                    {
                        "INTEGER": integer,
                    }
                ),
            )
        return Integer.integer_type_cache[integer]

# Create an integer type
three = Integer[3]
three  # <class 'types.Integer3'>
type(three)  # type

# We can instantiate one if we want
three()  # <types.Integer3 object at 0x1067fd700>

If we want to add we could define a function like:

def addIntegers(a: type, b: type) -> type:
    # I'm loose with types in this function. I'm only passing in classes
    # generated by Integer[N] to this method.
    return Integer[a.INTEGER + b.INTEGER]  # type: ignore[misc, attr-defined]

addIntegers(Integer[1],  Integer[2]) == three  # True

We can now add classes to get new classes! We could make this more ergonomic by overloading the + sign to add these classes. Overloading + is done by implementing __add__ on the class/type of an instance. However, the type of our dynamically generated types, say Integer3 is type, which is not modifiable. However, we can set a metaclass on the dynamically created type. Doing this will give us a hook to define __add__ since the type of Integer3 will be our metaclass. Since Integer3 is a class, it is still a type.

from types import new_class
from typing import cast


class MetaInteger(type):
    INTEGER: int

    def __add__(cls: "MetaInteger", other: "MetaInteger") -> "MetaInteger":
        # Using [] on a class without using Generics will make mypy complain
        return Integer[cls.INTEGER + other.INTEGER] # type: ignore[misc]

class Integer:

    integer_type_cache: dict[int, MetaInteger] = {}

    def __class_getitem__(cls, integer: int) -> MetaInteger:
        if integer not in Integer.integer_type_cache:
            neg = "Neg" if integer < 0 else ""
            # new_class returns a type object. We cast to a MetaInteger
            # since we are explicitly setting this as the metaclass and
            # MetaInteger is a subclass of type.
            Integer.integer_type_cache[integer] = cast(MetaInteger, new_class(
                f"Integer{neg}{abs(integer)}",
                bases=(),
                kwds={"metaclass": MetaInteger},
                exec_body=lambda ns: ns.update(
                    {
                        "INTEGER": integer,
                    }
                ),
            ))
        return Integer.integer_type_cache[integer]


# Adding Integers
Integer[3] + Integer[4] == Integer[7]  # True, adding works!
isinstance(Integer[7], type)  # True, it's a type
type(Integer[7]) # __main__.MetaInteger, MetaInteger is a type subclass
MetaInteger.mro(MetaInteger)  # [__main__.MetaInteger, type, object], the metaclass type hierarchy.

Conclusion

This is clearly ridiculous. By using [] to instantiate new types we can store arbitrary data on a type that is as ergonomic as instantiating a class with (). We can also put arbitrary methods on the class as well as overload operators using a metaclass. We’ve made metaclasses analogous to classes, classes analogous to instances, and instances (eg Integer[3]()), well they’re a new thing at the bottom of the hierarchy. This gives us the ability to do arbitrary computation while never explicitly instantiating a python class.

Would one ever want to do this? No, this is tomfoolery. But, I couldn’t resist trying to see if this was possible.


Published

Category

python

Tags

Contact