diff --git a/CHANGELOG.md b/CHANGELOG.md index bfa4783a..5ce7046e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/pyinterop.rst b/docs/pyinterop.rst index 72826421..a811619c 100644 --- a/docs/pyinterop.rst +++ b/docs/pyinterop.rst @@ -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` diff --git a/src/basilisp/core.lpy b/src/basilisp/core.lpy index 05e8a1e6..4c3259b2 100644 --- a/src/basilisp/core.lpy +++ b/src/basilisp/core.lpy @@ -7,6 +7,7 @@ decimal fractions importlib.util + inspect math multiprocessing os @@ -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])) (map #(list 'quote %)))] `(def ~interface-name (gen-interface :name ~name-str @@ -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))) @@ -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 ;; ;;;;;;;;;;;;; diff --git a/src/basilisp/lang/compiler/analyzer.py b/src/basilisp/lang/compiler/analyzer.py index e52a5180..d8bd3352 100644 --- a/src/basilisp/lang/compiler/analyzer.py +++ b/src/basilisp/lang/compiler/analyzer.py @@ -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, @@ -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 @@ -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) @@ -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), ) diff --git a/src/basilisp/lang/compiler/constants.py b/src/basilisp/lang/compiler/constants.py index d8a10449..3ca67020 100644 --- a/src/basilisp/lang/compiler/constants.py +++ b/src/basilisp/lang/compiler/constants.py @@ -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") diff --git a/src/basilisp/lang/compiler/generator.py b/src/basilisp/lang/compiler/generator.py index 6581041e..5c9ce203 100644 --- a/src/basilisp/lang/compiler/generator.py +++ b/src/basilisp/lang/compiler/generator.py @@ -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( diff --git a/src/basilisp/lang/compiler/nodes.py b/src/basilisp/lang/compiler/nodes.py index 8d14ab18..36067f24 100644 --- a/src/basilisp/lang/compiler/nodes.py +++ b/src/basilisp/lang/compiler/nodes.py @@ -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) diff --git a/src/basilisp/lang/interfaces.py b/src/basilisp/lang/interfaces.py index 9102a893..91beb1fd 100644 --- a/src/basilisp/lang/interfaces.py +++ b/src/basilisp/lang/interfaces.py @@ -372,6 +372,36 @@ def pop(self: Self) -> Self: raise NotImplementedError() +class IProxy(ABC): + """``IProxy`` is a marker interface for proxy types. + + All types created by ``proxy`` are automatically marked with ``IProxy``. + + .. seealso:: + + :ref:`proxies`, :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`""" + + __slots__ = () + + @abstractmethod + def _get_proxy_mappings(self) -> "IPersistentMap[str, Callable]": + raise NotImplementedError() + + @abstractmethod + def _set_proxy_mappings( + self, proxy_mappings: "IPersistentMap[str, Callable]" + ) -> None: + raise NotImplementedError() + + @abstractmethod + def _update_proxy_mappings( + self, proxy_mappings: "IPersistentMap[str, Callable]" + ) -> None: + raise NotImplementedError() + + T_key = TypeVar("T_key") V_contra = TypeVar("V_contra", contravariant=True) diff --git a/tests/basilisp/test_proxies.lpy b/tests/basilisp/test_proxies.lpy new file mode 100644 index 00000000..4b8d742f --- /dev/null +++ b/tests/basilisp/test_proxies.lpy @@ -0,0 +1,119 @@ +(ns tests.basilisp.test-proxies + (:require + [basilisp.test :as test :refer [deftest is are testing]])) + +(def no-op-proxy + (proxy [] [])) + +(definterface Describable + (describe-me [])) + +(deftype ^{:slots false} DescribableType [arg] + Describable + (describe-me [this] (str "I'm a type with " arg))) + +(def single-arity-proxy + (proxy [DescribableType] [:orig] + (describe-me [] + (str "Proxy with: " (proxy-super describe-me))))) + +(definterface ToString + (to-string []) + (to-string [arg1]) + (to-string [arg1 & rest])) + +(deftype ^{:slots false} ConcreteToString [arg] + ToString + (to-string [this] (str "0 " arg)) + (to-string [this arg1] (str "1 " arg " " arg1)) + (to-string [this arg1 & rest] (str "rest" arg arg1 rest))) + +(def multi-arity-proxy + (proxy [ConcreteToString] [1] + (to-string + ([] (str "hi i am 0 arg " (proxy-super to-string))) + ([arg1] (str "i am 1 arg " (proxy-super to-string arg1))) + ([arg1 & args] (str "i am rest " arg1 " " args))))) + +(deftest get-proxy-class-test + (is (identical? (get-proxy-class) (get-proxy-class))) + (is (python/issubclass (get-proxy-class) basilisp.lang.interfaces/IProxy)) + (is (identical? (get-proxy-class DescribableType) (get-proxy-class DescribableType))) + (is (python/issubclass (get-proxy-class DescribableType) basilisp.lang.interfaces/IProxy)) + (is (identical? (get-proxy-class DescribableType ConcreteToString) + (get-proxy-class DescribableType ConcreteToString))) + (is (python/issubclass (get-proxy-class DescribableType ConcreteToString) + basilisp.lang.interfaces/IProxy))) + +(deftest proxy-mappings-test + (is (= {} (proxy-mappings no-op-proxy))) + (is (thrown? basilisp.lang.exception/ExceptionInfo + (proxy-mappings (python/object)))) + (is (= #{"describe_me"} (set (keys (proxy-mappings single-arity-proxy))))) + (is (= #{"to_string"} (set (keys (proxy-mappings multi-arity-proxy)))))) + +(deftest construct-proxy-test + (testing "no args" + (let [obj-proxy-cls (get-proxy-class)] + (is (instance? obj-proxy-cls (construct-proxy obj-proxy-cls))) + (is (thrown? basilisp.lang.exception/ExceptionInfo + (construct-proxy python/object))))) + + (testing "superclass args" + (let [obj-proxy-cls (get-proxy-class DescribableType)] + (is (instance? obj-proxy-cls (construct-proxy obj-proxy-cls 1))) + (is (thrown? python/TypeError (construct-proxy obj-proxy-cls)))))) + +(deftest init-proxy-test + (testing "no proxy methods" + (let [obj-proxy (construct-proxy (get-proxy-class))] + (is (identical? obj-proxy (init-proxy obj-proxy {}))) + (is (thrown? basilisp.lang.exception/ExceptionInfo + (init-proxy (python/object) {}))))) + + (testing "has proxy methods" + (let [obj-proxy (proxy [DescribableType] [1] + (describe-me [] "I'm a proxy"))] + (is (= "I'm a proxy" (.describe-me obj-proxy))) + (is (thrown? basilisp.lang.exception/ExceptionInfo + (init-proxy obj-proxy {"some-other-method" identity}))) + (init-proxy obj-proxy {"describe_me" (fn [this] "I'm not a proxy")}) + (is (= "I'm not a proxy" (.describe-me obj-proxy)))))) + +(deftest update-proxy-test + (testing "no proxy methods" + (let [obj-proxy (construct-proxy (get-proxy-class))] + (is (identical? obj-proxy (update-proxy obj-proxy {}))) + (is (thrown? basilisp.lang.exception/ExceptionInfo + (update-proxy (python/object) {}))))) + + (testing "has proxy methods" + (let [obj-proxy (proxy [DescribableType] [1] + (describe-me [] "I'm a proxy"))] + (is (= "I'm a proxy" (.describe-me obj-proxy))) + (is (thrown? basilisp.lang.exception/ExceptionInfo + (update-proxy obj-proxy {"some-other-method" identity}))) + (update-proxy obj-proxy {"describe_me" nil}) + (is (= "I'm a type with 1" (.describe-me obj-proxy))) + (update-proxy obj-proxy {"describe_me" (fn [this] "I'm a proxy again")}) + (is (= "I'm a proxy again" (.describe-me obj-proxy)))))) + +(deftest proxy-test + (testing "disallows duplicate method overrides" + (is (thrown? basilisp.lang.compiler/CompilerException + (eval '(proxy [Describable] [] + (describe-me [] "I'm a proxy") + (describe-me [] "Proxy")))))) + + (testing "disallows overriding non-superclass methods" + (is (thrown? basilisp.lang.exception/ExceptionInfo + (proxy [Describable] [] + (other-method [] "Proxy"))))) + + (testing "single-arity interface method" + (is (= "Proxy with: I'm a type with :orig" (.describe-me single-arity-proxy)))) + + (testing "multi-arity interface methods" + (is (= "hi i am 0 arg 0 1" (.to-string multi-arity-proxy))) + (is (= "i am 1 arg 1 1 yes" (.to-string multi-arity-proxy "yes"))) + (is (= "i am rest first (:yes)" (.to-string multi-arity-proxy "first" :yes)))))