Skip to content

Commit e1ccfc8

Browse files
author
Juha Jeronen
committed
Improve multiple dispatch
1 parent deaf709 commit e1ccfc8

File tree

1 file changed

+110
-41
lines changed

1 file changed

+110
-41
lines changed

unpythonic/dispatch.py

Lines changed: 110 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# -*- coding: utf-8; -*-
22
"""A multiple-dispatch decorator for Python.
33
4-
Somewhat like `functools.singledispatch`, but multiple dispatch.
4+
Somewhat like `functools.singledispatch`, but for multiple dispatch.
55
66
https://docs.python.org/3/library/functools.html#functools.singledispatch
77
@@ -11,72 +11,88 @@
1111
1212
**WARNING: PROVISIONAL FEATURE**
1313
14-
This provisional feature is provided for technical preview purposes only.
14+
This provisional feature is a proof-of-concept provided for technical preview
15+
and teaching purposes only.
16+
1517
Details may still change in a backwards-incompatible way, or the whole
1618
feature may still be removed. Do not depend on it in production!
1719
"""
1820

19-
from collections import defaultdict
21+
__all__ = ["generic", "specific"]
22+
2023
from functools import wraps
2124
import inspect
2225
import typing
2326

2427
from .arity import resolve_bindings
2528

26-
registry = defaultdict(list)
27-
2829
def generic(f):
2930
"""Decorator. Make `f` a generic function (in the sense of CLOS or Julia).
3031
31-
Methods are attached by specifying type hints on parameters when defining
32-
implementations. Then, at call time, types of **all** arguments are then
33-
automatically used for choosing which method to call. Multiple parameters
34-
may be used for dispatching.
32+
**How to use**:
33+
34+
The `@generic`-decorated function definition itself *is just a declaration*
35+
that defines the shape and parameter names of the formal parameter list.
36+
The formal parameter list must remain the same across all methods of the
37+
same generic function; only the types of the parameters may vary. That
38+
function is otherwise a stub. It is never called.
39+
40+
Like in `functools.singledispatch`, methods (implementations for specific
41+
combinations of argument types) are registered with the decorator
42+
`@f.register`, where `f` is the function you decorated with `@generic`.
43+
44+
Each method must specify type hints **on all parameters**. Then, at call
45+
time, types of **all** arguments are then automatically used for choosing
46+
which method to call, i.e., multiple parameters are used for dispatching.
47+
48+
The first match wins, in most-recently-registered order. So specify the
49+
implementation with the most generic types first, and then move on to the
50+
more specific ones. The mnemonic is, "the function is generally defined
51+
like this, except if the arguments match these particular types..."
3552
3653
The point of this feature is to eliminate `if`/`elif`/`elif`... blocks
37-
that switch by `isinstance` on arguments (and then raise `TypeError`
38-
in the final `else`), by implementing the machinery once centrally.
54+
that switch by `isinstance` on arguments, and then raise `TypeError`
55+
in the final `else`, by implementing the machinery once centrally.
56+
57+
**Differences to tools in the standard library**:
3958
40-
Unlike `typing.overload`, the implementation belongs right there in the
41-
type-specialized methods. Unlike `functools.singledispatch`, there is
42-
no need to provide a fallback non-annotated implementation (and in fact
43-
that is not supported).
59+
Unlike `functools.singledispatch`, the `@generic` function itself is
60+
unused, except as an interface declaration for the parameter list.
4461
45-
Upon ambiguity, the method that was most recently registered wins. So
46-
specify the implementation with the most generic types first, and then
47-
move on to the more specific ones. The mnemonic is, "the function is
48-
generally defined like this, except if you get arguments that match
49-
these particular types..."
62+
Unlike `typing.overload`, the implementations are to be provided in the
63+
method bodies.
5064
5165
**CAUTION**:
5266
53-
Currently, the shape of the parameter list must agree between all methods
54-
of the same generic function, and advanced features of `typing` are not
55-
supported.
67+
To declare a parameter of a method as dynamically typed, explicitly
68+
annotate it as `typing.Any`; don't just omit the type annotation.
69+
Explicit is better than implicit; this is a feature.
5670
57-
Use `typing.Any` to declare a dynamically typed parameter - don't just omit
58-
the type annotation, that won't work.
71+
Currently, advanced features of `typing` such as `Sequence[...]` are
72+
not supported. This may or may not change in the future.
5973
"""
60-
fullname = "{}.{}".format(f.__module__, f.__qualname__)
61-
signature = typing.get_type_hints(f)
62-
registry[fullname].append((f, signature))
74+
# Dispatcher - this will replace the original f.
6375
@wraps(f)
6476
def multidispatch(*args, **kwargs):
65-
# TODO: This uses the definition of `f` this particular instance of the decorator
66-
# TODO: was applied to. Maybe we should check, upon registration, that the shape
67-
# TODO: of the parameter list matches for each method of the same generic function.
77+
# We use the parameter list of the original `@generic`-decorated
78+
# function to match the function call arguments to the formal
79+
# parameters of the function definition.
6880
bindings = resolve_bindings(f, *args, **kwargs)
6981

7082
def match(signature):
71-
# TODO: handle *args (bindings["vararg"]) and **kwargs (bindings["kwarg"])
83+
# TODO: handle *args (bindings["vararg"])
84+
# TODO: handle **kwargs (bindings["kwarg"])
85+
# TODO: handle advanced features such as Sequence[int], Optional[str], ...
7286
for parameter, value in bindings["args"].items():
7387
p = signature[parameter] # TODO: what if parameter is not there? TypeError?
7488
if p is not typing.Any and not isinstance(value, p):
7589
return False
7690
return True
7791

78-
methods = tuple(reversed(registry[fullname]))
79-
for method, signature in methods:
92+
# Dispatch.
93+
def methods():
94+
return reversed(multidispatch._registry)
95+
for method, signature in methods():
8096
if match(signature):
8197
return method(*args, **kwargs)
8298

@@ -86,7 +102,7 @@ def match(signature):
86102
# TODO: but in the general case this is difficult. We can't just `type(x)`, since
87103
# TODO: the signature may specify something like `Sequence[int]`. Knowing a `list`
88104
# TODO: was passed doesn't help debug that it was `Sequence[str]` when a `Sequence[int]`
89-
# TODO: was expected. The actual value at least contains the type information implicitly.
105+
# TODO: was expected. The actual value at least implicitly contains the type information.
90106
#
91107
# TODO: Compute closest candidates, like Julia does? (see methods, MethodError)
92108
a = [str(a) for a in args]
@@ -97,31 +113,75 @@ def format_method(method): # Taking a page from Julia and some artistic liberty
97113
filename = inspect.getsourcefile(obj)
98114
source, firstlineno = inspect.getsourcelines(obj)
99115
return "{} from {}:{}".format(signature, filename, firstlineno)
100-
methods_str = [" {}".format(format_method(x)) for x in methods]
116+
methods_str = [" {}".format(format_method(x)) for x in methods()]
101117
candidates = "\n".join(methods_str)
102118
msg = ("No method found matching {}({}{}{}).\n"
103119
"Candidate signatures (in order of match attempts):\n{}").format(f.__qualname__,
104120
", ".join(a),
105121
sep, ", ".join(kw),
106122
candidates)
107123
raise TypeError(msg)
124+
125+
# fullname = "{}.{}".format(f.__module__, f.__qualname__)
126+
multidispatch._registry = []
127+
def register(function):
128+
"""Decorator. Register a new method for this generic function.
129+
130+
The method must have type annotations for all of its parameters;
131+
these are used for dispatching.
132+
"""
133+
# TODO: fail-fast: verify the shape of the parameter list of `function`
134+
# is compatible with the `@generic` function to which we are attaching
135+
# `function` as a new method.
136+
signature = typing.get_type_hints(function)
137+
multidispatch._registry.append((function, signature))
138+
return multidispatch # Replace the function with the dispatcher for this generic function.
139+
multidispatch.register = register # publish the @f.register decorator
140+
108141
return multidispatch
109142

143+
144+
def specific(f):
145+
"""Decorator. A one-method pony, which is kind of the opposite of `@generic`.
146+
147+
This restricts the allowed argument types to one combination only. This can
148+
be used to eliminate `isinstance` boilerplate code in the function body, by
149+
allowing the types (for dynamic, run-time checking) to be specified with a
150+
very compact syntax.
151+
152+
Unlike in `@generic`, the function to be decorated simultaneously specifies
153+
both the shape and parameter names of the formal parameter list, as well as
154+
provides the body of the only method implementation.
155+
156+
A `@specific` function has no `.register` attribute; after it is created,
157+
no more methods can be attached to it.
158+
"""
159+
s = generic(f)
160+
s.register(f)
161+
del s.register # remove the ability to attach more methods
162+
return s
163+
110164
# --------------------------------------------------------------------------------
111165

112166
@generic
167+
def zorblify(x, y): # could use the ellipsis `...` as the body, but this is a unit test.
168+
assert False # Stub, used only for interface declaration. Never called.
169+
@zorblify.register
113170
def zorblify(x: int, y: int):
114171
return 2 * x + y
115-
@generic
172+
@zorblify.register
116173
def zorblify(x: str, y: int):
117-
# Because dispatching occurs on both arguments, this is not reached in tests.
118-
assert False
119-
@generic
174+
assert False # Because dispatching occurs on both arguments, this is not reached in tests.
175+
@zorblify.register
120176
def zorblify(x: str, y: float):
121177
return "{} {}".format(x[::-1], y)
122178

123179
# TODO: def zorblify(x: int, *args: typing.Sequence[str]):
124180

181+
@specific
182+
def blubnify(x: int, y: float):
183+
return x * y
184+
125185
def test():
126186
assert zorblify(17, 8) == 42
127187
assert zorblify(17, y=8) == 42 # can also use named arguments
@@ -134,7 +194,16 @@ def test():
134194
except TypeError:
135195
pass
136196
else:
137-
assert False # should have noticed there's no method registered for zorblify(float, float)
197+
assert False # there's no zorblify(float, float)
198+
199+
assert blubnify(2, 21.0) == 42
200+
try:
201+
blubnify(2, 3)
202+
except TypeError:
203+
pass
204+
else:
205+
assert False # blubnify only accepts (int, float)
206+
assert not hasattr(blubnify, "register")
138207

139208
print("All tests PASSED")
140209

0 commit comments

Comments
 (0)