Package: typeclasses
Extensible methods for Python mimicking typeclasses in Haskell.MotivationSome statically typed languages havead hoc polymorphismwhere a function
can have multiple implementations depending on the types of its arguments. In
languages like C++ and Java, it is called function overloading. In Haskell, it
is accomplished with type classes.Consider an example of writing atoJsonfunction in C++. The function
takes a single value and returns a string, but it must be implemented
differently for each different type of value:std::stringtoJson(inti);std::stringtoJson(doubled);std::stringtoJson(std::strings);Some implementations may be “recursive”, and call the implementation for
another type:template<typenameT>std::stringtoJson(std::vector<T>const&xs){...for(Tconst&x:xs){...toJson(x)...}...}Many dynamically typed languages, like Python and JavaScript, lack ad hoc
polymorphism in the language, but developers can implement it by hand by
inspecting the argument types and dispatching to implementations accordingly:defto_json(value):ifisinstance(value,int):return...ifisinstance(value,float):return...ifisinstance(value,str):return...ifisinstance(value,list):return'['+','.join(to_json(x)forxinvalue)+']'In addition to being a little uglier, this technique suffers from
a limitation: once we’ve defined the function, we can’t add any more
overloads. Imagine we want to define a JSON serialization for our
user-defined type:from...importto_json@dataclassclassPerson:name:strdefto_json_person(person):returnf'{{"name":{to_json(person.name)}}}'While this example works for serializingPerson, we won’t be able to
serialize alistofPersonbecause the implementation ofto_jsonforlistwon’t callto_json_person.Type ClassesIn many languages, e.g. C++ and Java, two functions with the same name but
different types are calledoverloadsof the name.
In Haskell, these overloads are not permitted: no two functions (or any other
values for that matter) can have the same name in the same scope.
However, type classes offer a way around this limitation.Atype classin Haskell is a group of polymorphic functions, calledmethods, parameterized by a singletype variable.
The type class only needs todeclarethe method signatures;
it does not need to provide any definitions.Aninstancefor a type classdefinesall the methods of the type class
for a specifictype argumentin the place of the type variable.
In other words, a type class has exactly onepolymorphicdeclaration, but
manymonomorphicinstances, one for every possible type argument.
Thus, a method can have many definitions (i.e. implementations), one from each
instance, which means it can be overloaded.At a method call site, how does Haskell know which overload, from which
instance, to use?
Haskell requires that the signature of the method in the type class
declaration mentions the type variable in one of its parameters or its return
type.
It tries to unify that polymorphic declaration signature with the call site to
fill in the type variable; if it succeeds, then it selects the monomorphic
instance for that type argument.TutorialHow can we replicate type classes in Python?Decorate a method signature with a call totypeclass, giving it the
name of a type variable. The decorator will check the signature to make sure
that the type variable appears at least once in the type annotations of the
parameters. Unlike Haskell, Python cannot infer thereturn typeat a call
site, so that path to instance discovery is impossible; the type variablemustbe used as the type of at least oneparameter.T=typing.TypeVar('T')@typeclass(T)defto_json(value:T)->str:"""Serialize a value to JSON."""We may optionally provide a default implementation. If we do not, the
default behavior is to raise aNotImplementedErrordiagnosing
a missing instance for the specific type variable.Thetypeclassdecorator will add aninstanceattribute to the method.
Use that to decorate monomorphic implementations, giving it the type argument:@to_json.instance(str)def_to_json_str(s):returnf'"{s}"'We can decorate an implementation multiple times if it can serve multiple
instances:@to_json.instance(int)@to_json.instance(float)def_to_json_number(n):returnstr(n)We can define an implementation for all types structurally matching
aprotocol. Because it is presently impossible to infer the difference
between a protocol and a type, we must differentiate it for the decorator:@to_json.instance(typing.Iterable,protocol=True)def_to_json_iterable(xs):return'['+','.join(to_json(x)forxinxs)+']'If a type argument matches multiple protocols, the instance that was first
defined will be chosen.Now we can define instances for types whether we defined the type or imported
it.@to_json.instance(Person)def_to_json_person(person):returnf'{{"name":{to_json(person.name)}}}'>>>to_json([Person(name='John')])[{"name":"John"}]