Skip to content
Merged

Proxies #1147

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
* Added support for referring imported Python names as by `from ... import ...` (#1154)
* Added the `basilisp.url` namespace for structured URL manipulation (#1239)
* Added support for proxies (#425)
* Added a `:slots` meta flag for `deftype` to disable creation of `__slots__` on created types (#1241)

### Changed
* Removed implicit support for single-use iterables in sequences, and introduced `iterator-seq` to expliciltly handle them (#1192)
Expand Down
27 changes: 27 additions & 0 deletions docs/pyinterop.rst
Original file line number Diff line number Diff line change
Expand Up @@ -405,3 +405,30 @@ Users still have the option to use the native :external:py:func:`operator.floord
.. seealso::

:lpy:fn:`quot`, :lpy:fn:`rem`, :lpy:fn:`mod`

.. _proxies:

Proxies
-------

Basilisp supports creating instances of anonymous classes deriving from one or more concrete types with the :lpy:fn:`proxy` macro.
It may be necessary to use ``proxy`` in preference to :lpy:fn:`reify` for cases when the superclass type is concrete, where ``reify`` would otherwise fail.
Proxies can also be useful in cases where it is necessary to wrap superclass methods with additional functionality or access internal state of class instances.

.. code-block::

(def p
(proxy [io/StringIO] []
(write [s]
(println "length" (count s))
(proxy-super write s))))

(.write p "blah") ;; => 4
;; prints "length 4"
(.getvalue p) ;; => "blah"

.. seealso::

:lpy:fn:`proxy`, :lpy:fn:`proxy-mappings`, :lpy:fn:`proxy-super`,
:lpy:fn:`construct-proxy`, :lpy:fn:`init-proxy`, :lpy:fn:`update-proxy`,
:lpy:fn:`get-proxy-class`
262 changes: 260 additions & 2 deletions src/basilisp/core.lpy
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
decimal
fractions
importlib.util
inspect
math
multiprocessing
os
Expand Down Expand Up @@ -6447,7 +6448,7 @@
(let [name-str (name interface-name)
method-sigs (->> methods
(map (fn [[method-name args docstring]]
[method-name (conj args 'self) docstring]))
[method-name (vec (concat ['self] args)) docstring]))
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was always shoving self at the end of the argument vector.

(map #(list 'quote %)))]
`(def ~interface-name
(gen-interface :name ~name-str
Expand Down Expand Up @@ -6883,7 +6884,16 @@

Methods must supply a ``this`` or ``self`` parameter. ``recur`` special forms used in
the body of a method should not include that parameter, as it will be supplied
automatically."
automatically.

.. note::

``deftype`` creates new types with ``__slots__`` by default. To disable usage
of ``__slots__``, provide the ``^{:slots false}`` meta key on the type name.

.. code-block::

(deftype ^{:slots false} Point [x y z])"
[type-name fields & method-impls]
(let [ctor-name (with-meta
(symbol (str "->" (name type-name)))
Expand Down Expand Up @@ -6951,6 +6961,254 @@
~@methods)
(meta &form))))

;;;;;;;;;;;;;
;; Proxies ;;
;;;;;;;;;;;;;

(def ^:private excluded-proxy-methods
#{"__getattribute__"
"__init__"
"__new__"
"__subclasshook__"})

(def ^:private proxy-cache (atom {}))

;; One consequence of adhering so closely to the Clojure proxy model is that this
;; style of dispatch method doesn't align well with the Basilisp style of defining
;; multi-arity methods (which involves creating the "main" entrypoint method which
;; dispatches to private implementations for all of the defined arities).
;;
;; Fortunately, since the public interface of even multi-arity methods is a single
;; public method, when callers provide a multi-arity override for such methods,
;; only the public entrypoint method is overridden in the proxy mappings. This
;; should be a sufficient compromise, but it does mean that the superclass arity
;; implementations are never overridden.
(defn ^:private proxy-base-methods
[base]
(->> (inspect/getmembers base inspect/isroutine)
(remove (comp excluded-proxy-methods first))
(mapcat (fn [[method-name method]]
(let [meth-sym (symbol method-name)
meth `(fn ~meth-sym [~'self & args#]
(if-let [override (get (.- ~'self ~'-proxy-mappings) ~method-name)]
(apply override ~'self args#)
(-> (.- ~'self __class__)
(python/super ~'self)
(.- ~meth-sym)
(apply args#))))]
[method-name (eval meth *ns*)])))))

(defn ^:private proxy-type
"Generate a proxy class with the given bases."
[bases]
(let [methods (apply hash-map (mapcat proxy-base-methods bases))
method-names (set (map munge (keys methods)))
base-methods {"__init__" (fn __init__ [self proxy-mappings & args]
(apply (.- (python/super (.- self __class__) self) __init__) args)
(. self (_set_proxy_mappings proxy-mappings))
nil)
"_get_proxy_mappings" (fn _get_proxy_mappings [self]
(.- self -proxy-mappings))
"_set_proxy_mappings" (fn _set_proxy_mappings [self proxy-mappings]
(let [provided-methods (set (keys proxy-mappings))]
(when-not (.issubset provided-methods method-names)
(throw
(ex-info "Proxy override methods must correspond to methods on the declared supertypes"
{:expected method-names
:given provided-methods
:diff (.difference provided-methods method-names)}))))
(set! (.- self -proxy-mappings) proxy-mappings)
nil)
"_update_proxy_mappings" (fn _update_proxy_mappings [self proxy-mappings]
(let [updated-mappings (->> proxy-mappings
(reduce* (fn [m [k v]]
(if v
(assoc! m k v)
(dissoc! m k)))
(transient (.- self -proxy-mappings)))
(persistent!))]
(. self (_set_proxy_mappings updated-mappings))
nil))
"__setattr__" (fn __setattr__ [self attr val]
"Override __setattr__ specifically for _proxy_mappings."
(if (= attr "_proxy_mappings")
(. python/object __setattr__ self attr val)
((.- (python/super (.- self __class__) self) __setattr__) attr val)))
"_proxy_mappings" nil}

;; Remove Python ``object`` from the bases if it is present to avoid errors
;; about creating a consistent MRO for the given bases
proxy-bases (concat (remove #{python/object} bases) [basilisp.lang.interfaces/IProxy])]
(python/type (basilisp.lang.util/genname "Proxy")
(python/tuple proxy-bases)
(python/dict (merge methods base-methods)))))

(defn get-proxy-class
"Given zero or more base classes, return a proxy class for the given classes.

If no classes, Python's ``object`` will be used as the superclass.

Generated classes are cached, such that the same set of base classes will always
return the same resulting proxy class."
[& bases]
(let [base-set (if (seq bases)
(set bases)
#{python/object})]
(-> (swap! proxy-cache (fn [cache]
(if (get cache base-set)
cache
(assoc cache base-set (proxy-type base-set)))))
(get base-set))))

(defn proxy-mappings
"Return the current method map for the given proxy.

Throws an exception if ``proxy`` is not a proxy."
[proxy]
(if-not (instance? basilisp.lang.interfaces/IProxy proxy)
(throw
(ex-info "Cannot get proxy mappings from object which does not implement IProxy"
{:obj proxy}))
(. proxy (_get-proxy-mappings))))

(defn construct-proxy
"Construct an instance of the proxy class ``c`` with the given constructor arguments.

Throws an exception if ``c`` is not a subclass of
:py:class:`basilisp.lang.interfaces.IProxy`.

.. note::

In JVM Clojure, this function is useful for selecting a specific constructor based
on argument count, but Python does not support multi-arity methods (including
constructors), so this is likely of limited use."
[c & ctor-args]
(if-not (python/issubclass c basilisp.lang.interfaces/IProxy)
(throw
(ex-info "Cannot construct instance of class which is not a subclass of IProxy"
{:class c :args ctor-args}))
(apply c {} ctor-args)))

(defn init-proxy
"Set the current proxy method map for the given proxy.

Method maps are maps of string method names to their implementations as Basilisp
functions.

Throws an exception if ``proxy`` is not a proxy."
[proxy mappings]
(if-not (instance? basilisp.lang.interfaces/IProxy proxy)
(throw
(ex-info "Cannot set proxy mappings for an object which does not implement IProxy"
{:obj proxy}))
(do
(. proxy (_set-proxy-mappings mappings))
proxy)))

(defn update-proxy
"Update the current proxy method map for the given proxy.

Method maps are maps of string method names to their implementations as Basilisp
functions. If ``nil`` is passed in place of a function for a method, that method will
revert to its default behavior.

Throws an exception if ``proxy`` is not a proxy."
[proxy mappings]
(if-not (instance? basilisp.lang.interfaces/IProxy proxy)
(throw
(ex-info "Cannot update proxy mappings for object which does not implement IProxy"
{:obj proxy}))
(do
(. proxy (_update-proxy-mappings mappings))
proxy)))

(defmacro proxy
"Create a new proxy class instance.

The proxy class may implement 0 or more interface (or subclass 0 or more classes),
which are given as the vector ``class-and-interfaces``. If 0 such supertypes are
provided, Python's ``object`` type will be used.

If the supertype constructors take arguments, those arguments are given in the
potentially empty vector ``args``.

The remaining forms (if any) should be method overrides for any methods of the
declared classes and interfaces. Not every method needs to be overridden. Override
declarations may be multi-arity to simulate multi-arity methods. Overrides need
not include ``this``, as it will be automatically added and is available within
all proxy methods. Proxy methods may access the proxy superclass using the
:lpy:fn:`proxy-super` macro.

Overrides take the following form::

(single-arity []
...)

(multi-arity
([] ...)
([arg1] ...)
([arg1 & others] ...))

.. note::

Proxy override methods can be defined with Python keyword argument support since
they are just standard Basilisp functions. See :ref:`basilisp_functions_with_kwargs`
for more information.

.. warning::

The ``proxy`` macro does not verify that the provided override implementations
arities match those of the method being overridden.

.. warning::

Attempting to create a proxy with multiple superclasses defined with ``__slots__``
may fail with a ``TypeError``. If you control any of the designated superclasses,
removing conflicting ``__slots__`` should enable creation of the proxy type."
[class-and-interfaces args & fs]
(let [formatted-single (fn [method-name [arg-vec & body]]
(apply list
'fn
method-name
(with-meta (vec (concat ['this] arg-vec)) (meta arg-vec))
body))
formatted-multi (fn [method-name & arities]
(apply list
'fn
method-name
(map (fn [[arg-vec & body]]
(apply list
(with-meta (vec (concat ['this] arg-vec)) (meta arg-vec))
body))
arities)))
methods (map (fn [[method-name & body :as form]]
[(munge method-name)
(with-meta
(if (vector? (first body))
(formatted-single method-name body)
(apply formatted-multi method-name body))
(meta form))])
fs)
method-map (reduce* (fn [m [method-name method]]
(if-let [existing-method (get m method-name)]
(throw
(ex-info "Cannot define proxy class with duplicate method"
{:method-name method-name
:impls [existing-method method]}))
(assoc m method-name method)))
{}
methods)]
`((get-proxy-class ~@class-and-interfaces) ~method-map ~@args)))

(defmacro proxy-super
"Macro which expands to a call to the method named ``meth`` on the superclass
with the provided ``args``.

Note this macro explicitly captures the implicit ``this`` parameter added to proxy
methods."
[meth & args]
`(. (~'python/super (.- ~'this ~'__class__) ~'this) (~meth ~@args)))

;;;;;;;;;;;;;
;; Records ;;
;;;;;;;;;;;;;
Expand Down
7 changes: 5 additions & 2 deletions src/basilisp/lang/compiler/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
SYM_PRIVATE_META_KEY,
SYM_PROPERTY_META_KEY,
SYM_REDEF_META_KEY,
SYM_SLOTS_META_KEY,
SYM_STATICMETHOD_META_KEY,
SYM_TAG_META_KEY,
SYM_USE_VAR_INDIRECTION_KEY,
Expand Down Expand Up @@ -659,13 +660,13 @@ def AnalyzerException(
MetaGetter = Callable[[Union[IMeta, Var]], Any]


def _bool_meta_getter(meta_kw: kw.Keyword) -> BoolMetaGetter:
def _bool_meta_getter(meta_kw: kw.Keyword, default: bool = False) -> BoolMetaGetter:
"""Return a function which checks an object with metadata for a boolean
value by meta_kw."""

def has_meta_prop(o: Union[IMeta, Var]) -> bool:
return bool(
Maybe(o.meta).map(lambda m: m.val_at(meta_kw, None)).or_else_get(False)
Maybe(o.meta).map(lambda m: m.val_at(meta_kw, None)).or_else_get(default)
)

return has_meta_prop
Expand All @@ -688,6 +689,7 @@ def get_meta_prop(o: Union[IMeta, Var]) -> Any:
_is_py_classmethod = _bool_meta_getter(SYM_CLASSMETHOD_META_KEY)
_is_py_property = _bool_meta_getter(SYM_PROPERTY_META_KEY)
_is_py_staticmethod = _bool_meta_getter(SYM_STATICMETHOD_META_KEY)
_is_slotted_type = _bool_meta_getter(SYM_SLOTS_META_KEY, True)
_is_macro = _bool_meta_getter(SYM_MACRO_META_KEY)
_is_no_inline = _bool_meta_getter(SYM_NO_INLINE_META_KEY)
_is_allow_var_indirection = _bool_meta_getter(SYM_NO_WARN_ON_VAR_INDIRECTION_META_KEY)
Expand Down Expand Up @@ -2022,6 +2024,7 @@ def _deftype_ast( # pylint: disable=too-many-locals
verified_abstract=type_abstractness.is_statically_verified_as_abstract,
artificially_abstract=type_abstractness.artificially_abstract_supertypes,
is_frozen=is_frozen,
use_slots=_is_slotted_type(name),
use_weakref_slot=not type_abstractness.supertype_already_weakref,
env=ctx.get_node_env(pos=ctx.syntax_position),
)
Expand Down
1 change: 1 addition & 0 deletions src/basilisp/lang/compiler/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ class SpecialForm:
SYM_PRIVATE_META_KEY = kw.keyword("private")
SYM_CLASSMETHOD_META_KEY = kw.keyword("classmethod")
SYM_DEFAULT_META_KEY = kw.keyword("default")
SYM_SLOTS_META_KEY = kw.keyword("slots")
SYM_DYNAMIC_META_KEY = kw.keyword("dynamic")
SYM_PROPERTY_META_KEY = kw.keyword("property")
SYM_MACRO_META_KEY = kw.keyword("macro")
Expand Down
2 changes: 1 addition & 1 deletion src/basilisp/lang/compiler/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -1548,7 +1548,7 @@ def _deftype_to_py_ast( # pylint: disable=too-many-locals
verified_abstract=node.verified_abstract,
artificially_abstract_bases=artificially_abstract_bases,
is_frozen=node.is_frozen,
use_slots=True,
use_slots=node.use_slots,
use_weakref_slot=node.use_weakref_slot,
),
ast.Call(
Expand Down
1 change: 1 addition & 0 deletions src/basilisp/lang/compiler/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ class DefType(Node[SpecialForm]):
verified_abstract: bool = False
artificially_abstract: IPersistentSet[DefTypeBase] = lset.EMPTY
is_frozen: bool = True
use_slots: bool = True
use_weakref_slot: bool = True
meta: NodeMeta = None
children: Sequence[kw.Keyword] = vec.v(FIELDS, MEMBERS)
Expand Down
Loading