A Piece of Advice

01/11/2020

Update: Added a helpful link explaining more opcodes.

Note: This is an expanded version of this Reddit post.

Advice is one of those Emacs Lisp features that you don’t see often in other programming languages. It enables you to extend almost any function you’d like by executing code before/after/instead of it and messing with arguments/return values. But how does it work? And which of the two implementations of it should be used?

On advice.el

Somewhat surprisingly, advice.el consists of more than 3000 lines, but more than half of them are comments. It doesn’t quite reach literate programming level of commentary, but explains its internals and includes a small tutorial explaining how it works. There are many bells and whistles, but to keep things simple I’ll focus on the part of the tutorial that changes a function to manipulate its argument before execution of the function body. That body can be programmatically obtained using symbol-function:

(defun foo (x)
  "Add 1 to X."
  (1+ x))

(symbol-function 'foo)
;; => (defun foo (x) "Add 1 to X." (1+ x))

The example advice fg-add2 adds one to x again before the actual code is run:

(defadvice foo (before fg-add2 first)
  "Add 2 to X."
  (setq x (1+ x)))

(symbol-function 'foo)
;; #[128 "<bytecode>"
;;   [apply ad-Advice-foo (lambda (x) "Add 1 to X." (1+ x)) nil]
;;   5 nil]

Yikes. How does one make sense of the byte-code?

Interlude: Byte-code disassembly

Emacs Lisp contains two interpreters, a tree walker (takes a s-exp as input, walks along it and evaluates the branches) and a byte-code interpreter (takes bytecode, interprets it using a stack VM). bytecomp.el and byte-opt.el transform s-expressions into optimized byte-code. I can recommend studying these to understand how a simple compiler works. The result of this is code expressed in a stack-oriented fashion using up to 256 fundamental operations[1]. One can look at it with the disassemble function, which accepts both function symbols and function definitions:

(disassemble (lambda () 1))
;; byte code:
;;   args: nil
;; 0       constant  1
;; 1       return

What happens here is that the constant 1 is pushed to the stack, then the top of stack is returned. Arguments are treated in a similar manner:

(disassemble (lambda (x) x))
;; byte code:
;;   args: (x)
;; 0       varref    x
;; 1       return

Instead of putting a constant on the stack, the value of x is looked up and pushed to the stack. Finally, an easy function call looks as follows:

(disassemble (lambda (a b) (message "%S: %S" a b)))
;; byte code:
;;   args: (a b)
;; 0       constant  message
;; 1       constant  "%S: %S"
;; 2       varref    a
;; 3       varref    b
;; 4       call      3
;; 5       return

Four values are pushed on the stack in function call order, then a function is called with three arguments. The four stack values are replaced with its result, then returned. We’re almost ready to tackle the actually interesting disassembly now and can look up all other unknown opcodes in this unofficial manual.

You may wonder though, why bother? Why not just use a decompiler? Or even avoid dealing with byte-compiled code in the first place… It turns out there are a few reasons going for it:

  • Ideally you’d always have access to source code. This is not always an option. For example it’s not unheard of for an Emacs installation to only ship byte-compiled sources (hello Debian). Likewise defining advice as above will byte-compile the function. Byte-code compilation is done as performance enhancement and backtraces from optimized functions will contain byte-code.
  • The byte-code decompiler we have is clunky and incomplete. It sometimes fails to make sense of byte-code, meaning you cannot rely on it. Another thing to consider is that byte-code doesn’t have to originate from the official byte-code compiler, there’s other projects generating byte-code that the decompiler may not target. Suppose someone wants to thwart analysis of (presumably malicious code), hand-written byte-code would be an option.
  • Sometimes byte-code is studied to understand the performance of an Emacs Lisp function. It’s easier to reason about byte-code than regular code, especially to see the effects of lexical binding.
  • It’s educational to wade through bytecode.c and other Emacs internals. While there isn’t too much benefit of understanding Emacs byte-code, the same lessons apply to other stack-oriented VMs, such as the JVM. Learning this makes reversing proprietary programs targeting the JVM (such as Android apps) much easier and enables advanced techniques such as binary patching[2].

On advice.el (continued)

We’re ready to unravel what foo does:

(disassemble 'foo)
;; byte code for foo:
;;   args: (x)
;; 0       constant  apply
;; 1       constant  ad-Advice-foo
;; 2       constant  (lambda (x) "Add 1 to X." (1+ x))
;; 3       stack-ref 3
;; 4       call      3
;; 5       return

apply, ad-Advice-foo and a lambda are placed on the stack. Then, stack element 3 (zero-indexed) is added to the top of stack. We already know that elements 0, 1 and 2 are the three constants, element 3 however is the first argument passed to the function. As it turns out, when lexical binding is enabled, the stack-ref opcode is used instead of varref. Therefore the byte-code presented is equivalent to (lambda (&rest arg) (apply 'ad-Advice-foo (lambda (x) "Add 1 to X." (1+ x))) arg). You can verify by disassembling that lambda and compare the output with the previous disassembly.

What does ad-Advice-foo do though?

(disassemble 'ad-Advice-foo)
;; byte code for ad-Advice-foo:
;;   args: (ad--addoit-function x)
;; 0       constant  nil
;; 1       varbind   ad-return-value
;; 2       varref    x
;; 3       add1
;; 4       varset    x
;; 5       varref    ad--addoit-function
;; 6       varref    x
;; 7       call      1
;; 8       dup
;; 9       varset    ad-return-value
;; 10      unbind    1
;; 11      return

This is a bit more to unravel. varbind introduces a temporary variable, unbind undoes this binding, varset is equivalent to set and dup pushes a copy of top of stack (kind of like stack-ref 0 would do). The sequence of constant nil and varbind ad-return-value is the same as (let ((ad-return-value nil)) ...). x is retrieved, incremented by 1 and x set to the result of that, therefore (setq x (1+ x)). Then ad--addoit-function is called with x as argument. The result of that is duplicated and ad-return-value is set to it. Finally stack item 1 is unbound, presumably the temporary variable. Therefore the byte-code is equivalent to (let (ad-return-value) (setq x (1+ x)) (setq ad-return-value (funcall ad--addoit-function x))). Let’s see how nadvice.el fares.

On nadvice.el

It’s tiny compared to advice.el, at only 391 lines of code. To nobody’s surprise it’s lacking bells and whistles such as changing argument values directly or not activating advice immediately. Therefore some adjustments are required to create the equivalent advice with it:

(defun foo-advice (args)
  (mapcar '1+ args))

(advice-add 'foo :filter-args 'foo-advice)

(symbol-function 'foo)
;; #[128 "<bytecode>" [apply foo-advice (lambda (x) "Add 1 to X." (1+ x)) nil] 5 nil]

(disassemble 'foo)
;; byte code for foo:
;;   args: (x)
;; 0       constant  apply
;; 1       constant  (lambda (x) "Add 1 to X." (1+ x))
;; 2       constant  foo-advice
;; 3       stack-ref 3
;; 4       call      1
;; 5       call      2
;; 6       return

We have our three constants and x on the stack. At first a function is called with one argument, that would be foo-advice with x (which represents the argument list). Then a function is called with two arguments, that is apply with the lambda and the result of the previous function call. In other words, (lambda (&rest x) (apply (lambda (x) "Add 1 to X." (1+ x)) (foo-advice x))). It was a bit less convenient to write, but far easier to understand.

Conclusion

nadvice.el is surprisingly elegant, striking a good balance between amount of overall features and technical simplicity. Unless you maintain a package that must keep compatibility with Emacs 24.3 or earlier, I don’t see a good reason to go for advice.el.

[1]Or in short, opcode. A byte represents up to 256 values, hence the “byte-code” name.
[2]Simple protections rely on checking a conditional and executing good/bad code. This tends to compile down to a conditional jump. Switch out the jump opcode for the opposite one and it will execute bad/good code instead…