Skip to content

Commit 20d849a

Browse files
authored
Support isa? based dispatch in multimethods (#644)
* Support isa? based dispatch in multimethods * Doc the string * Linting and type checking * Document strangs * Cache it out * ex er cise
1 parent 15055c7 commit 20d849a

File tree

5 files changed

+253
-35
lines changed

5 files changed

+253
-35
lines changed

src/basilisp/core.lpy

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5017,7 +5017,41 @@
50175017
;;;;;;;;;;;;;;;;;;
50185018

50195019
(defmacro defmulti
5020-
"Define a new multimethod with the dispatch function."
5020+
"Define a new multimethod with the given dispatch function.
5021+
5022+
Multimethod dispatch functions should be defined with the same arity or arities as
5023+
the registered methods. The provided dispatch function will be called first on all
5024+
calls to the multimethod and should return a dispatch value which will be used to
5025+
identify a registered method. If the value returned by the dispatch function does
5026+
not correspond to a registered method, the method registered with the default
5027+
dispatch value, if one is registered, will be called instead. Methods can be
5028+
registered with the `defmethod` macro.
5029+
5030+
Multimethods select a dispatch method by using `isa?` on all available dispatch
5031+
values. Callers can provide a hierarchy to use for `isa?` resolution. For cases where
5032+
multiple dispatch values are registered which may return `true` for a call to `isa?`
5033+
callers must select preferred values using `prefer-method` or a RuntimeException will
5034+
be thrown when a unique method cannot be selected. Callers can view multimethod
5035+
preferences by calling `prefers`.
5036+
5037+
Callers may specify options as key/value pairs after the dispatch function. Options
5038+
include:
5039+
- `:default` - the default value to use if no matching method is found for the
5040+
selected dispatch value; the default value is `:default`
5041+
- `:hierarchy` - the default hierarchy to use for resolving `isa?` relationships;
5042+
the default value is the global hierarchy; hierarchies should be passed as reference
5043+
types (e.g. as a Var)
5044+
5045+
Multimethods are useful for defining polymorphic functions based on characteristics
5046+
of their arguments. This behavior is often called multiple dispatch. Unlike typical
5047+
multiple dispatch implementations, however, it is possible to define multimethods
5048+
which dispatch on characteristics other than the types of input values (though you
5049+
may define multimethods which dispatch solely on the types of arguments).
5050+
5051+
For cases where you might want to define a polymorphic function or functions based
5052+
solely on the type of the first argument, you should consider defining a protocol
5053+
(via `defprotocol`) instead. Protocols should generally perform better in cases
5054+
where the primary goal is dispatching on the type of the first argument."
50215055
[name & body]
50225056
(let [doc (when (string? (first body))
50235057
(first body))
@@ -5029,35 +5063,62 @@
50295063
body)
50305064
dispatch-fn (first body)
50315065
opts (apply hash-map (rest body))]
5032-
`(def ~name (basilisp.lang.multifn/MultiFunction ~(quote name)
5066+
`(def ~name (basilisp.lang.multifn/MultiFunction (quote ~name)
50335067
~dispatch-fn
5034-
~(or (:default opts) :default)))))
5068+
~(or (:default opts) :default)
5069+
~(:hierarchy opts)))))
50355070

50365071
(defmacro defmethod
5037-
"Add a new method to the multi-function which responds to dispatch-val."
5072+
"Add a new method to the multimethod `multifn` which responds to `dispatch-val`.
5073+
5074+
`fn-tail` is the trailing part of a function definition after `fn` including any
5075+
relevant argument vectors.
5076+
5077+
Methods added to a multimethod can be removed using `remove-method`. All methods
5078+
may be removed using `remove-all-methods`.
5079+
5080+
See `defmulti` for more information about multimethods."
50385081
[multifn dispatch-val & fn-tail]
50395082
`(. ~multifn (~'add-method ~dispatch-val (fn ~@fn-tail))))
50405083

50415084
(defn methods
5042-
"Return a map of dispatch values to methods for the given multi function."
5085+
"Return a map of dispatch values to methods for the given multimethod."
50435086
[multifn]
50445087
(.-methods multifn))
50455088

50465089
(defn get-method
5047-
"Return the method which would respond to dispatch-val or nil if no method
5048-
exists for dispatch-val."
5090+
"Return the method of multimethod `multifn` which would respond to `dispatch-val` or
5091+
nil if no method exists for `dispatch-val`."
50495092
[multifn dispatch-val]
50505093
(.get-method multifn dispatch-val))
50515094

5095+
(defn prefer-method
5096+
"Update the mulitmethod `multifn` to prefer `dispatch-val-x` over `dispatch-val-y` in
5097+
cases where the dispatch value selection might be ambiguous between the two.
5098+
5099+
Returns the multimethod."
5100+
[multifn dispatch-val-x dispatch-val-y]
5101+
(.prefer-method multifn dispatch-val-x dispatch-val-y)
5102+
multifn)
5103+
5104+
(defn prefers
5105+
"Return a map of the set of preferrd values to the set of other conflicting values."
5106+
[multifn]
5107+
(.-prefers multifn))
5108+
50525109
(defn remove-method
5053-
"Remove the method which responds to dispatch-val, if it exists. Return the
5054-
multi function."
5110+
"Remove the method from the multimethod `multfin` which responds to `dispatch-val`, if
5111+
it exists.
5112+
5113+
Return the multimethod."
50555114
[multifn dispatch-val]
50565115
(.remove-method multifn dispatch-val)
50575116
multifn)
50585117

50595118
(defn remove-all-methods
5060-
"Remove all method for this multi-function. Return the multi function."
5119+
"Remove all methods for the multimethod `multifn`.
5120+
5121+
Return the multimethod."
50615122
[multifn]
50625123
(.remove-all-methods multifn)
50635124
multifn)

src/basilisp/lang/interfaces.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ def reset_meta(
133133
RefWatcher = Callable[[RefWatchKey, "IRef", Any, Any], None]
134134

135135

136-
class IRef(IReference):
136+
class IRef(IDeref[T]):
137137
__slots__ = ()
138138

139139
@abstractmethod

src/basilisp/lang/multifn.py

Lines changed: 130 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,67 @@
22
from typing import Any, Callable, Generic, Optional, TypeVar
33

44
from basilisp.lang import map as lmap
5+
from basilisp.lang import runtime
56
from basilisp.lang import symbol as sym
6-
from basilisp.lang.interfaces import IPersistentMap
7-
from basilisp.util import Maybe
7+
from basilisp.lang.interfaces import IPersistentMap, IPersistentSet, IRef
8+
from basilisp.lang.set import PersistentSet
89

910
T = TypeVar("T")
1011
DispatchFunction = Callable[..., T]
1112
Method = Callable[..., Any]
1213

1314

15+
_GLOBAL_HIERARCHY_SYM = sym.symbol("global-hierarchy", ns=runtime.CORE_NS)
16+
_ISA_SYM = sym.symbol("isa?", ns=runtime.CORE_NS)
17+
18+
1419
class MultiFunction(Generic[T]):
15-
__slots__ = ("_name", "_default", "_dispatch", "_lock", "_methods")
20+
__slots__ = (
21+
"_name",
22+
"_default",
23+
"_dispatch",
24+
"_lock",
25+
"_methods",
26+
"_cache",
27+
"_prefers",
28+
"_hierarchy",
29+
"_cached_hierarchy",
30+
"_isa",
31+
)
1632

1733
# pylint:disable=assigning-non-slot
1834
def __init__(
19-
self, name: sym.Symbol, dispatch: DispatchFunction, default: T
35+
self,
36+
name: sym.Symbol,
37+
dispatch: DispatchFunction,
38+
default: T,
39+
hierarchy: Optional[IRef] = None,
2040
) -> None:
2141
self._name = name
2242
self._default = default
2343
self._dispatch = dispatch
2444
self._lock = threading.Lock()
2545
self._methods: IPersistentMap[T, Method] = lmap.PersistentMap.empty()
46+
self._cache: IPersistentMap[T, Method] = lmap.PersistentMap.empty()
47+
self._prefers: IPersistentMap[T, IPersistentSet[T]] = lmap.PersistentMap.empty()
48+
self._hierarchy: IRef[IPersistentMap] = hierarchy or runtime.Var.find_safe(
49+
_GLOBAL_HIERARCHY_SYM
50+
)
51+
52+
if not isinstance(self._hierarchy, IRef):
53+
raise runtime.RuntimeException(
54+
f"Expected IRef type for :hierarchy; got {type(hierarchy)}"
55+
)
56+
57+
# Maintain a cache of the hierarchy value to detect when the hierarchy
58+
# has changed. If the hierarchy changes, we need to reset the internal
59+
# caches.
60+
self._cached_hierarchy = self._hierarchy.deref()
61+
62+
# Fetch some items from basilisp.core that we need to compute the final
63+
# dispatch method. These cannot be imported statically because that would
64+
# produce a circular reference between basilisp.core and this module.
65+
self._isa = runtime.Var.find_safe(_ISA_SYM)
2666

2767
def __call__(self, *args, **kwargs):
2868
key = self._dispatch(*args, **kwargs)
@@ -31,34 +71,109 @@ def __call__(self, *args, **kwargs):
3171
return method(*args, **kwargs)
3272
raise NotImplementedError
3373

74+
def _reset_cache(self):
75+
"""Reset the local cache to the base method mapping.
76+
77+
Should be called after methods are added or removed or after preferences are
78+
altered."""
79+
# Does not use a lock to avoid lock reentrance
80+
self._cache = self._methods
81+
self._cached_hierarchy = self._hierarchy.deref()
82+
83+
def _is_a(self, tag: T, parent: T) -> bool:
84+
"""Return True if `tag` can be considered a `parent` type using `isa?`."""
85+
return bool(self._isa.value(self._hierarchy.deref(), tag, parent))
86+
87+
def _has_preference(self, preferred_key: T, other_key: T) -> bool:
88+
"""Return True if this multimethod has `preferred_key` listed as a preference
89+
over `other_key`."""
90+
others = self._prefers.val_at(preferred_key)
91+
return others is not None and other_key in others
92+
93+
def _precedes(self, tag: T, parent: T) -> bool:
94+
"""Return True if `tag` should be considered ahead of `parent` for method
95+
selection."""
96+
return self._has_preference(tag, parent) or self._is_a(tag, parent)
97+
3498
def add_method(self, key: T, method: Method) -> None:
35-
"""Add a new method to this function which will respond for
36-
key returned from the dispatch function."""
99+
"""Add a new method to this function which will respond for key returned from
100+
the dispatch function."""
37101
with self._lock:
38102
self._methods = self._methods.assoc(key, method)
103+
self._reset_cache()
104+
105+
def _find_and_cache_method(self, key: T) -> Optional[Method]:
106+
"""Find and cache the best method for dispatch value `key`."""
107+
with self._lock:
108+
best_key: Optional[T] = None
109+
best_method: Optional[Method] = None
110+
for method_key, method in self._methods.items():
111+
if self._is_a(key, method_key):
112+
if best_key is None or self._precedes(method_key, best_key):
113+
best_key, best_method = method_key, method
114+
if not self._precedes(best_key, method_key):
115+
raise runtime.RuntimeException(
116+
"Cannot resolve a unique method for dispatch value "
117+
f"'{key}'; '{best_key}' and '{method_key}' both match and "
118+
"neither is preferred"
119+
)
120+
121+
if best_method is None:
122+
best_method = self._methods.val_at(self._default)
123+
124+
if best_method is not None:
125+
self._cache = self._cache.assoc(key, best_method)
126+
127+
return best_method
39128

40129
def get_method(self, key: T) -> Optional[Method]:
41-
"""Return the method which would handle this dispatch key or
42-
None if no method defined for this key and no default."""
43-
method_cache = self._methods
44-
# The 'type: ignore' comment below silences a spurious MyPy error
45-
# about having a return statement in a method which does not return.
46-
return Maybe(method_cache.val_at(key, None)).or_else(
47-
lambda: method_cache.val_at(self._default, None) # type: ignore
48-
)
130+
"""Return the method which would handle this dispatch key or None if no method
131+
defined for this key and no default."""
132+
if self._cached_hierarchy != self._hierarchy.deref():
133+
self._reset_cache()
134+
135+
cached_val = self._cache.val_at(key)
136+
if cached_val is not None:
137+
return cached_val
138+
139+
return self._find_and_cache_method(key)
140+
141+
def prefer_method(self, preferred_key: T, other_key: T) -> None:
142+
"""Update the multimethod to prefer `preferred_key` over `other_key` in cases
143+
where method selection might be ambiguous between two values."""
144+
with self._lock:
145+
if self._has_preference( # pylint: disable=arguments-out-of-order
146+
other_key, preferred_key
147+
):
148+
raise runtime.RuntimeException(
149+
f"Cannot set preference for '{preferred_key}' over '{other_key}' "
150+
f"due to existing preference for '{other_key}' over "
151+
f"'{preferred_key}'"
152+
)
153+
existing = self._prefers.val_at(preferred_key, PersistentSet.empty())
154+
assert existing is not None
155+
self._prefers = self._prefers.assoc(preferred_key, existing.cons(other_key))
156+
self._reset_cache()
157+
158+
@property
159+
def prefers(self):
160+
"""Return a mapping of preferred values to the set of other values."""
161+
return self._prefers
49162

50163
def remove_method(self, key: T) -> Optional[Method]:
51164
"""Remove the method defined for this key and return it."""
52165
with self._lock:
53166
method = self._methods.val_at(key, None)
54167
if method:
55168
self._methods = self._methods.dissoc(key)
169+
self._reset_cache()
56170
return method
57171

58172
def remove_all_methods(self) -> None:
59-
"""Remove all methods defined for this multi-function."""
173+
"""Remove all methods defined for this multimethod1."""
60174
with self._lock:
61175
self._methods = lmap.PersistentMap.empty()
176+
self._reset_cache()
62177

63178
@property
64179
def default(self) -> T:
@@ -67,9 +182,3 @@ def default(self) -> T:
67182
@property
68183
def methods(self) -> IPersistentMap[T, Method]:
69184
return self._methods
70-
71-
72-
def multifn(dispatch: DispatchFunction, default=None) -> MultiFunction[T]:
73-
"""Decorator function which can be used to make Python multi functions."""
74-
name = sym.symbol(dispatch.__qualname__, ns=dispatch.__module__)
75-
return MultiFunction(name, dispatch, default)

src/basilisp/lang/reference.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
from basilisp.lang import map as lmap
77
from basilisp.lang.exception import ExceptionInfo
88
from basilisp.lang.interfaces import (
9-
IDeref,
109
IPersistentMap,
1110
IRef,
1211
IReference,
@@ -59,7 +58,7 @@ def reset_meta(self, meta: Optional[IPersistentMap]) -> Optional[IPersistentMap]
5958
T = TypeVar("T")
6059

6160

62-
class RefBase(IDeref[T], IRef, ReferenceBase):
61+
class RefBase(IRef[T], ReferenceBase):
6362
"""
6463
Mixin for IRef classes to define the full IRef interface.
6564

0 commit comments

Comments
 (0)