# In regular Python, generics (parametric types) don't make any # sense: we can put any type anywhere, and collections are # automatically heterogeneous (they can contain values of different # types at the same time). While this is very flexible, it is also # somewhat dangerous: you don't necessarily want a ‹Car› to find its # way into a list of ‹Cat› instances. # To stand any chance of typing consistency, ‹mypy› needs to know # the element types in collections, and for this reason, there «are» # generics in ‹mypy›. They behave pretty much the way you would # expect them to. Let's first look at «generic functions». To # express generic types, we need «type variables» – in Python, these # are regular values (like any other type), with special meaning for # the type checker. They are created as instances of # ‹typing.TypeVar›: from typing import TypeVar # Unfortunately, when creating a type variable, we need to pass its # own name to it as a parameter. This is more than a little ugly. T = TypeVar( 'T' ) # Now that we have a type variable, we can declare a generic # function. Notice that parametric types (‹list› in this case) take # their «type parameters» in square brackets. Remember that # annotations are just regular Python expressions? This syntax # simply re-uses the standard indexing operator. Hold on to that # piece of info – we will look at it more closely when we get to # metaclasses. # Within a single prototype, all mentions of ‹T› refer to the same # type (but it can be any type, of course): def head( records: list[ T ] ) -> T: return records[ 0 ] # Of course, in a new declaration, ‹T› is a fresh type, unrelated to # the above ‹T›, even though it is technically the same value: def tail( records: list[ T ] ) -> list[ T ]: return records[ 1 : ] # Unlike dynamic Python, and unlike generics (templates) in C++, # there is no ‘duck typing’ for generic elements (i.e. for values of # type ‹T›). Which means that there isn't a whole lot that you can # do with them. # The things that are available by default are those that every # Python object is assumed to provide: # # • equality (but not ordering), # • conversion to a string (i.e. values of type ‹T› can be # printed), and somewhat surprisingly, # • hashing (you can make a set of T, even if ‹set[ T ]› wasn't the # type you started with, or use ‹T› as a key in a ‹dict›). # Due to the last point, the following will type-check just fine, # but crash with a ‹TypeError› at runtime (another of those weak # spots): def make_set( value: T ) -> set[ T ]: return { value } # Let's check – you can run this file through ‹mypy› (even ‹mypy # --strict›) and it will not complain. However, observe:¹ def test_hashable() -> None: # demo try: make_set( [] ) assert False except TypeError: pass # To add constraints to a type variable, we can use «protocols», # that offer additional capabilities for types of that variable² – # there are a few builtin protocols, or you can make your own. Let's # try with ‹SupportsInt›: from typing import Sized, Protocol S = TypeVar( 'S', bound = Sized ) def double( value: S ) -> int: return 2 * len( value ) def test_double() -> None: # demo assert double( 'foo' ) == 6 # To make a protocol of your own, simply inherit from ‹Protocol› and # add whatever methods you want to use, then use the protocol just # like the one above. class SupportsThings( Protocol ): an_attribute: int def a_method( self, value: int ) -> bool: ... class AThing: def __init__( self ) -> None: self.an_attribute = 42 def a_method( self, value: int ) -> bool: return True # If you only need to accept one value of the given type, you can # use the protocol as an annotation directly (but if you mention it # twice, unlike type variables, each value can be of a «different # type»): def use_a_thing( a_thing: SupportsThings ) -> None: assert a_thing.a_method( 3 ) assert a_thing.an_attribute == 42 # Or we can of course bind the protocol to a type variable, like # with the one from ‹typing›: R = TypeVar( 'R', bound = SupportsThings ) def use_two_things( a_thing: R, b_thing: R ) -> R: if a_thing.an_attribute > b_thing.an_attribute: return a_thing else: return b_thing # The last thing that we need to know about generics is how to make # our classes generic (and hence accept a type parameter). Like with # a protocol, simply inherit from ‹Generic›. You need to ‘index’ the # ‹Generic› with some type variables, which then become bound to a # single type in the entire scope of that class: from typing import Generic class ABox( Generic[ R ] ): def __init__( self, a_thing: R ) -> None: self.a_thing = a_thing def open( self ) -> R: return self.a_thing def test_a_thing() -> None: # demo a_thing = AThing() b_thing = AThing() b_thing.an_attribute = 32 a_box = ABox( a_thing ) b_box : ABox[ AThing ] = ABox( b_thing ) use_a_thing( a_thing ) assert use_two_things( a_thing, b_thing ) is a_thing assert b_box.open() is b_thing # ¹ The ‹assert False› must trip if ‹make_set› works normally. Which # it doesn't. But if it threw anything other than a ‹TypeError›, # we would not catch that and the program would crash, too. So, # ‹TypeError› it is (if you are wondering if ‹mypy› somehow # detects that we catch the ‹TypeError› and allows the program # because of that – no, it doesn't… you can remove the # ‹try›/‹except› and observe the program crash, though ‹mypy› # still claims everything is fine. # ² If that reminds you of Haskell type classes, or C++ concepts, # or Java constrained generics, you are spot on. It is the same # idea. It is one of these things that keep coming up, just like # generics themselves. if __name__ == '__main__': test_hashable() test_a_thing()