setf の最適化に bug

以下の条件を満たすと place の subforms が評価される順番が変わってしまう。

  • PLACE に2つ以上の引数(subforms)
  • その中にシンボルがある
  • シンボルより後にシンボルではない式がある

3つ目の「シンボルではない式」が2つ目の「シンボル」の値に影響するような副作用を持っていると、本来のものとは違う挙動になってしまう。

再現

ものっそい分かりにくいうえに長くて申し訳ありません。

;; 子 hash-table を保持する親 hash-table
(defparameter *table* (make-hash-table))
=> *table*

;; key に対応する子 hash-table を返す
(defun get-sub-table (key)
  (or (gethash key *table*)
      (setf (gethash key *table*) (make-hash-table))))
=> get-sub-table

;; (親キー 子キー) に対応する値を返す
(defun get-value (key-parent key-child)
  (gethash key-child (get-sub-table key-parent)))
=> get-value

;; (親キー 子キー) の値を変更する
(defsetf get-value (key-parent key-child) (value)
  `(setf (gethash ,key-child (get-sub-table ,key-parent)) ,value))
=> (setf get-value)

;; ふつーに使う分にはだいじょぶ
(setf (get-value 1 2) 12)
=> 12

(get-value 1 2)
=> 12
=> t

;; place の subform に副作用があると困ったことに
(let ((x 2))
  (setf (get-value x (incf x))  ; (get-value 2 3) に保存したつもりなのに
        '(should be stored at (2 3))))
=> (should be stored at (2 3))

(get-value 2 3) ;ない
=> nil
=> nil

(get-value 3 3) ;こんなところに
=> (should be stored at (2 3))
=> t

;; 展開形
(macroexpand-1 '(setf (get-value x (incf x)) :NEW-VALUE))
=> (let* ((#:G729 (incf x))
          (#:G730 :NEW-VALUE))
     (setf (gethash #:G729 (get-sub-table x)) #:G730))
;; ホントはこうならないといけない
;; (let* ((#:G-1st x)
;;        (#:G-2nd (incf x))
;;        (#:G-new :NEW-VALUE))
;;   (setf (gethash #:G-2nd (get-sub-table #:G-1st)) #:G-new))

原因

setf を展開する時に Setf Expansion というものを使って place の subforms を一時変数に束縛する let* 式を作るのだけど、xyzzy の setf は subform が symbol だったときに変数に束縛するんじゃなくて store-form 内の一時変数をその symbol に置き換えるという最適化をしてる(lisp/setf.l の optimize-setf-method)。場合によっては let* で包む必要がなくなる。

;; こんな setf 式を展開したものが
(setf (hoge foo (do-bar)) (new-value-form))

;; Before: foo -> (do-bar) -> (new-value-form) の順に評価される
(let ((#:G01 foo)
      (#:G02 (do-bar))
      (#:G03 (new-value-form)))
  (update-hoge #:G03 #:G01 #:G02))

;; After: (do-bar) -> (new-value-form) -> foo の順に評価される
(let ((#:G02 (do-bar))
      (#:G03 (new-value-form)))
  (update-hoge #:03 foo #:02))

どうしよ

疲れたので思いつきメモだけ。

optimize-setf-method 使わない、というのは置いといて。

optimize-setf-method で vals, stores を前から見ていってるのを逆順にして、かつ副作用があり得る式(constantp でも symbolp でもない式?)があったらそれ以上最適化しないようにすれば大丈夫な気がする。
setf-values で上書きしてしまってるのでどうしよう。と思って見てみたら setf-values で再定義した optimize-setf-method は、newvalues が (values ...) じゃない時に stores を最適化してない事に気づいた。