Skip to content

WITH-REDEFS enables rebinding of global functions, inspired by Clojure's with-redefs

License

Notifications You must be signed in to change notification settings

dtenny/with-redefs

Repository files navigation

About

WITH-REDEFS is a macro that allows you to rebind the global functions associated with symbols. It is inspired by the Clojure macro of the same name, however the nature of Common Lisp prohibits it from functioning identically.

The tool is used mainly for mocking functions in test environments, or wrapping functions so that you can intercept and record their activity.

THIS IS A TEST TOOL, YOU SHOULD NOT USE IT IN CODE THAT GOES TO PRODUCTION ENVIRONMENTS

I've packaged this up as an independent project/repo because unlike Clojure where redefining a function is very easy, Common Lisp is chock full of caveats. A compiled lisp follows a different set of rules than Clojure's compile-on-the-fly-after-loading JVM environment. That said, Common Lisp's compilation model also has strengths that help ensure your code will work in production before it gets to production. Contrast this to Clojure, where I could bundle up my grandmother's cookie recipes in an uberjar and it might not fail until it's invoked by some other library dependent on it when it is executed in production.

Also (why I put this in distinct repo), I haven't released a general purpose clojure compatibility library that would be suitable for this macro, however it's useful enough I wanted it where I could quickload it for ad-hoc use and/or just share this documentation. The implementation is trivial, it's the documentation and tests that took a bit of time.

Usage

This lisp system is available via the Ultralisp distribution in QUICKLISP.

If you didn't get this via quickload a quicklisp/ultralisp repo, add it to your ~/quicklisp/localprojects/ directory, update/delete the system-index.txt file accordingly, and then you can quickload it. (Quicklisp rebuilds the system-index.txt if it is missing).

;; To load just the `WITH-REDEFS` package
(ql:quickload :with-redefs)

;; To load `WITH-REDEFS` and `WITH-REDEFS-TEST` packages
(ql:quickload :with-redefs-test)
(with-redefs-test:run-tests)

Then you can:

(with-redefs (some-global-function-name replacement-function
              ...) ; possible additional symbol/replacement-function pairs
  ... body ...) ; returns value of body

See examples below and test module for more examples.

Tested Lisps and implementation-specific notes

All tests were run at default compiler optimization settings. YMMV.

  • SBCL: WORKS

  • CCL 1.12.2: WORKS

  • Allegro CL Express 11.0 (alisp executable): WORKS

    Unable to test NOTINLINE defstruct accessors.

    Allegro also does not return TYPE-ERROR where the spec appears to require it, noted in one conditionalized test.

  • ECL 21.2.1: WORKS

  • ABCL 1.9.0, OpenJDK 17.0.9: WORKS - with style warnings

    It might be nice to use MUFFLE-WARNING for the style warnings on redefined functions, but for now that's left to the caller. Only ABCL issued such style warnings of the lisps tested here.

    Also allows symbols for new-function, though you should consider this non-portable.

  • LispWorks 8.0.1 Personal Edition: WORKS

    Also allows symbols for new-function, though you should consider this non-portable.

Examples and some caveats

(defun my-function (x y)
  (format t "MY-FUNCTION called with args ~s ~s~%" x y)
  (+ x y))

;; Simple replacement of a pre-existing function associated with the symbol MY-FUNCTION
(with-redefs (my-function 
              (lambda (&rest args)
                (format t "MY-FUNCTION redef called with args ~s~%"
                        args))) ;returns nil
  (my-function 1 2 3))

MY-FUNCTION redef called with args (1 2 3)
=> NIL

Note that WITH-REDEFS uses UNWIND-PROTECT to ensure restoration the original function value.

;; Wrapping a pre-existing function
(let ((old-function #'my-function))   ;NOTE: `FUNCTION` (i.e `#'`) is important
(with-redefs (my-function 
              (lambda (&rest args)
                (format t "MY-FUNCTION redef called with args ~s~%" args)
                (funcall old-function (first args) (second args))))
  (my-function 1 2 3))

MY-FUNCTION redef called with args (1 2 3)
MY-FUNCTION called with args 1 2
=> 3

Note that because the symbol-function of the symbol is rebound by WITH-REDEFS callers of the function will not see the changed function slot unless they are either:

a. calling the function in the normal function position of a form, e.g. (my-function 1 2) or b. calling the function via symbol, e.g. (funcall 'my-function 1 2).

However if some caller is calling via (funcall #'my-function 1 2) they are not going to see the redefined symbol-function value because it was obtained prior to the with-redefs update of the symbol function.

You may ask "why can CL:TRACE instrument functions that WITH-REDEFS can't?" even though they are also called with #'my-function. The answer is that CL:TRACE may know enough about the implementation to update the actual compiled code vector so it doesn't need to change the symbol-function slot.

These effects can vary by platform. In all likelihood if you want to redefine a function using WITH-REDEFS, you should plan for it, making sure NOT to use #'my-function (which is the same as (function my-function)).

That may or may not be a small performance hit (having to deref the symbol-function slot) depending on lisp implementation and context, but it's nicer than having test-mode-only logic cluttering the function(s) in question.

More Examples

One of my favorite uses of WITH-REDEFS in testing is to be able to assert whether or not a function was called. E.g.

;; in the production code that I do NOT want to leave permanently
;; instrumented for testing
(defun function-a ()
  ...)

(defun function-b (thing)
  (if thing
      (function-a)
      (function-b)))

;;; in the test module
(test 
  (let ((counter 0)
        (old #'function-a))
    (with-redefs (function-a 
                  (lambda () 
                    (incf counter)
                    (funcall old)))
       ... stuff that calls function-b ...
       ;; Ensure that function-a was/was-not called as per expectations
       (is (= <truth> counter)))))

If your functions are called in a multi-threaded system, ensure you use an atomic or otherwise mutexed counter.

SETF update functions

You can also redefine setf updaters (subject to general caveats outlined below), see the set-f test for examples in with-redefs-test.lisp.

More caveats

Inlined functions, (DECLAIM (NOTINLINE F))

Common Lisp implementations (of which there are many), compiled lisps that may utilize both static and dynamic compilation tricks, also have different ideas about the realm of optimizations that can be applied to code.

Users have some control over this with the OPTIMIZE declarations, however even if you expect that you are not running in super-optimized modes, the compiler may still inline your functions, for example if the function being called is not called or exported outside of the module that defines it.

If your implementation aggressively inlines a function you want to redefine, try (declaim (notinline my-function)) in the compilation unit and see if that helps. Note that defstruct accessors are high-probability candidates for inlining unless directed otherwise.

Note that in some lisps declaring a defstruct accessor notinline is not enough (with with a global DECLAIM, or a local DECLARE in the test), e.g. Allegro. Allegro's describe says:

WITH-REDEFS-TEST(5): (describe 'ship-x)
SHIP-X is a NEW SYMBOL.
  It is unbound.
  It is INTERNAL in the WITH-REDEFS-TEST package.
  Its function binding is
    #<Closure SAFE-DEFSTRUCT-ACCESSOR [SHIP] @ #x10007830ad2>
    which function takes arguments (OBJECT)
  Its property list has these indicator/value pairs:
SYSTEM::.INLINE.            NOTINLINE
EXCL::SETF-INVERSE          (EXCL::DEFSTRUCT-SLOT-DEFSETF-HANDLER . 1)
SYSTEM::LISP-DUAL-ENTRY     SYSTEM::STRUCT-ACCESSOR-HOOK
  Its source-file location is: /home/dave/CommonLisp/with-redefs/with-redefs-test.lisp

So it appears to see something about "notinline" but isn't acting on it. I wasn't able to determine if there's a workaround for Allegro with a cursory search.

Package-locked functions

WITH-REDEFS does not attempt to unlock locked packages, so any attempt to redefine a symbol in those packages will likely fail.

COMMON-LISP package functions

Package locks aside, it is probably a bad idea to try to redefine functions in the COMMON-LISP package.

Compile-time effects

Beware compile-time effects/interactions. For example if you call EVAL in the body of your WITH-REDEFS, and the eval causes the function you've redefined to be recompiled, then your redefinition will likely be lost.

WITH-REDEFS is not thread-safe

The symbol whose symbol-function is being redefined is not manipulated under the control of a mutex. This is fine if you're doing single-threaded test logic.

WITH-REDEFS does not update symbol-value slots

Clojure, being a lisp-1, doesn't distinguish between symbol-value and symbol-function slots. Common Lisp, being a lisp-2, has both, and WITH-REDEFS updates only the symbol-function slot.

FDEFINITION is key, implementations vary

At the end of the day, a global function definition is side-effected through the use of (SETF (FDEFINITION FUNCTION-NAME) NEW-FUNCTION). According to the standard the NEW-FUNCTION argument must be a FUNCTION, which is what is returned by the FUNCTION special form, the function COERCE, or the function COMPILE.

Most tested lisps implement this and will not accept a symbol for NEW-FUNCTION. Some lisps such as ABCL and LispWorks do accept symbols. WITH-REDEFS doesn't judge, whatever SETF (FDEFINITION X) accepts is fine, but be aware of the differences for portability, if you care.

Note that one difference between WITH-REDEFS and SETF (FDEFINITION X) is that WITH-REDEFS take symbols which are not FBOUNDP and turn them into symbols which are FBOUNDP, and vice versa.

See also FDEFINITION Hyperspec content.

You may not rebind macro and special operator definitions

WITH-REDEFS uses the CL functions FBOUNDP, SPECIAL-OPERATOR-P, and MACRO-FUNCTION to try to signal an error if you attempt to rebind macros and special operators. Your mileage may vary, hopefully the logic is portable from a standards compliant standpoint though. Some of these functions, like SPECIAL-OPERATOR-P underwent changes in the standards work.

See also Common Lisp external symbol constraints in the hyperspec.

About

WITH-REDEFS enables rebinding of global functions, inspired by Clojure's with-redefs

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published