2025-10-13: Codex CLIでClojure開発する際のメモ: cljコマンドがsandbox下で動かないとき

Codex CLI の非対話シェル環境で clj コマンドを動かす設定方法

Codex CLI や CI/CD 環境などの「非対話シェル」では、~/.bashrc 内の brew shellenvps コマンドを実行するため、サンドボックス制限でエラーを起こすことがあります。 これを回避しつつ Homebrew 経由でインストールしたコマンドを利用するには、非対話シェル時のみ固定パスを設定して即終了するのが最も安全です。

以下は、実際に Codex CLI 環境で clj コマンドを正常に動かせた設定例です。


設定例:~/.bashrc

# For non-interactive shells (e.g. Codex CLI): 
# pass only the fixed PATH of Homebrew and exit immediately
case $- in
  *i*) ;;
  *)
    export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin${PATH:+:$PATH}"
    export PATH="$HOME/local/bin:$HOME/bin:$HOME/.local/bin:/usr/local/bin/:/opt/homebrew/opt/openjdk/bin:$PATH"
    return
    ;;
esac
# From this point down, it is executed only in an interactive shell.

# Homebrew setting
eval "$(/opt/homebrew/bin/brew shellenv)"

Codex CLI サンドボックス内での clj-kondo の実行

CLI がサンドボックス内で実行される場合、lint コマンドは警告のみを出力する場合でも「失敗」することがよくあります。サンドボックスはゼロ以外の終了コードをエラーとして扱うため、clj-kondo のデフォルトの終了コード(警告の場合は 2、設定の問題の場合は 3)により、自動化処理の継続が妨げられることがあります。

対処方法

  1. clj-kondo の生のエイリアスを維持する。 deps.edn:lint-run エイリアスの例を作成し、clj-kondo.main を指定して、希望するオプション(キャッシュディレクトリ、パスなど)を設定します。

  2. ラッパースクリプトを追加する(例:script/lint.clj)。このスクリプトは:
    • clojure -M:lint-run(または生のエイリアス)をシェル実行し、
    • stdout/stderr をそのまま出力し、
    • 終了コードを無視してラッパーが常に 0 で終了するようにします(オプションで抑制されたコードをログに記録)。 これにより、検出結果を保持しながらサンドボックスを正常に動作させます。
  3. 公開エイリアスをラッパーに向ける。 プロジェクトの :lint エイリアス(または開発者が呼び出すコマンド)を変更し、:main-opts ["-i" "script/lint.clj"] または -m clojure.main script/lint.clj を使用してラッパーを読み込むようにします。

  4. lint を再実行する。 clj -M:lint は、Codex CLI サンドボックスの制限下でも、すべての警告/エラーを表示し、正常に終了するようになります。

同じパターンは、基礎となるコマンドが致命的でない理由でゼロ以外の終了コードを返す可能性がある場合、他のツール(fmtspec、カスタムタスク)でも機能します。ツールをラップし、その出力を転送し、終了コードを正規化します。

lint.clj

(require '[clojure.java.shell :as shell])

(let [args (vec *command-line-args*)
      {:keys [exit out err]} (apply shell/sh "clojure" "-M:lint-run" args)]
  (when (seq out)
    (print out)
    (flush))
  (when (seq err)
    (binding [*out* *err*]
      (print err)
      (flush)))
  (when (pos? exit)
    (binding [*out* *err*]
      (println (format "clj-kondo exited with %d; treating as success per repo policy." exit))
      (flush))))

deps.edn

  :lint
  {:main-opts ["-i" "script/lint.clj"]}
  :lint-run
  {:extra-deps {clj-kondo/clj-kondo {:mvn/version "2025.09.22"}}
   :mvn/repos {"clojars" {:url "https://repo.clojars.org/"}}
   :main-opts ["-m" "clj-kondo.main" "--cache-dir" ".clj-kondo/.cache" "--lint" "src" "test"]}

2025-01-05: 関数

関数の定義方法

Common Lispでは、関数を定義する基本的な方法としてdefunが用意されています。 defunは関数名とパラメータリスト、そして関数の本体を指定することで関数を定義します。 本体の最後の式の値が返り値となります。 以下は、defunを使用した基本的な関数定義の例です。

(defun add-two-numbers (a b)
  (+ a b))

この関数 add-two-numbers は、2つの引数abを受け取り、それらを加算した結果を返します。 定義した関数は以下のように呼び出すことができます。

(add-two-numbers 3 5)
; => 8

無名関数(lambda)

関数を一時的に利用したい場合や、関数に名前を付ける必要がない場合、lambda式を使用して関数オブジェクトを直接作ることができます。

(lambda (x) (* x x))

このlambda式は、引数xを平方する無名関数を作ります。この無名関数をその場で呼び出すこともできます。

(funcall (lambda (x) (* x x)) 4)
; => 16

無名関数は、関数を引数として受け取る高階関数(例: mapcarreduce)と組み合わせる際にも便利です。

(mapcar (lambda (x) (* x x)) '(1 2 3 4))
; => (1 4 9 16)

関数定義の柔軟性

Common Lispの関数定義には以下のような柔軟な特徴があります。

  • パラメータリストの柔軟性: パラメータリストには、必須引数だけでなく、デフォルト値を持つオプショナル引数や任意の数の引数を受け取るための機構(optional, rest)が用意されています(詳細は2.2で解説)
  • 再定義可能性: Common Lispでは、既存の関数を動的に再定義することが可能です。これにより、システム全体を再ロードすることなく、特定の関数の振る舞いのみを即座に変更できます
(defun square (n) (* n n))
(defun cube (n) (* n (square n)))

;; squareを最適化されたバージョンで再定義
(defun square (n)
  (declare (type fixnum n)
           (optimize (speed 3) (safety 0)))
  (* n n))

;; cubeはそのまま使える
(cube 3) ; => 27
  • 独自の名前空間の利用: 関数は関数専用の名前空間に属しており、シンボルの名前空間とは区別されています。そのため、関数名と同じ名前を持つ変数を定義しても衝突することはありません
    • 関数名のシンボルから関数の実体を参照するには symbol-function を用います。これとsetfを組み合わせることでdefunを使わずに関数を定義することもできます
    • 通常の名前空間に存在する関数オブジェクトを呼び出すにはfuncallを使います。
;; 関数と変数では名前空間が分かれている
(defun my-function () "This is a function")
(setf my-function "This is a variable")
(my-function) ; => "This is a function"
my-function   ; => "This is a variable"

;; 関数の名前空間に関数オブジェクトを設定することで関数定義と同じことができる
(setf (symbol-function 'square) (lambda (x) (* x x)))
(square 3) ; => 9

;; 変数の名前空間に関数オブジェクトを設定するとfuncallが必要
(setf square-as-variable (lambda (x) (* x x)))
(funcall square-as-variable 3) ; => 9

局所関数

局所関数は、特定のスコープ内でのみ利用可能な関数です。Common Lispでは fletlabels で局所関数を定義します。 これにより、プログラムの構造化、名前の衝突回避、コードの可読性向上が可能です。

flet: シンプルな局所関数の定義

fletは、スコープ内で一時的に利用する関数を定義します。 定義された関数は再帰呼び出しをサポートしません。

(defun calculate-sum (numbers)
  (flet ((square (x) (* x x)))
    (mapcar #'square numbers)))

この例では、局所関数squareを定義し、numbersの各要素を平方する処理を実現しています。

labels: 再帰や相互再帰をサポートする局所関数の定義

labelsは再帰や相互再帰を必要とする局所関数を定義する場合に使用します。

例: 再帰を用いた階乗計算

(defun calculate-factorial (n)
  (labels ((factorial (x acc)
             (if (zerop x)
                 acc
                 (factorial (1- x) (* x acc))))) ; 再帰呼び出し
    (factorial n 1)))

この例では、局所関数factorialが再帰的に自身を呼び出して階乗を計算しています。

flet は再帰呼び出しをサポートしませんが、labels は再帰および相互再帰をサポートします。また、labelsでは複数の関数を並べて定義したときに先に定義したものを後の定義から使えます。

例: 相互再帰を利用した偶数・奇数の判定

(defun is-even (n)
  (labels ((evenp (x) (if (zerop x) t (oddp (1- x))))
           (oddp (x) (if (zerop x) nil (evenp (1- x)))))
    (evenp n)))

再帰の有無で使い分けることで意図を明確化できます。

局所関数は、プログラムのスコープを制御し、命名の衝突を回避するために非常に有効な手段です。 fletlabelsを適切に使い分けることで、コードの構造化と可読性を向上させることができます。これらを活用し、簡潔でメンテナンスしやすいコードを目指しましょう。

パラメータリスト

Common Lispの関数定義には、さまざまなパラメータ指定方法があります。 これにより、より柔軟な関数のインターフェースを設計することが可能です。

必須パラメータ

必須パラメータは、関数が呼び出される際に常に指定される必要がある引数です。 defunで単純にパラメータを列挙することで定義します。

(defun add (a b)
  (+ a b))

(add 3 5) ; => 8

オプショナルパラメータ

オプショナルパラメータは、引数が指定されない場合にデフォルト値を使用する引数です。 &optionalを使って定義します。

(defun greet (name &optional (greeting "Hello"))
  (format t "~A, ~A!~%" greeting name))

(greet "Alice")      ; => Hello, Alice!
(greet "Alice" "Hi") ; => Hi, Alice!

キーワードパラメータ

キーワードパラメータは、引数を名前付きで指定できる引数です。 &keyを使って定義します。

(defun configure (width &key (height 100) (color "blue"))
  (format t "Width: ~A, Height: ~A, Color: ~A~%" width height color))

(configure 200 :height 150 :color "red")
; => Width: 200, Height: 150, Color: red

(configure 200)
; => Width: 200, Height: 100, Color: blue

restパラメータ (可変長引数)

restパラメータは、任意の数の引数をリストとして受け取る引数です。 &restを使って定義します。

(defun sum (&rest numbers)
  (reduce #'+ numbers))

(sum 1 2 3 4) ; => 10
(sum) ; => 0

これらのパラメータリストの組み合わせにより、関数の引数設計は柔軟かつ強力になります。 次節では、それぞれのパラメータタイプを活用した実践例を紹介します。


2024-03-03: On Lisp読書会(2) 参加メモ

On Lisp読書会@Shibuya.lisp

2章 関数

本文

末尾再帰

末尾再帰とは再帰呼び出しから戻ってきた後に何も処理がない再帰のこと。 最近の処理系なら単純なループに変換してくれる。

例として、リストの長さを計算する関数の末尾再帰でないバージョンが出てくる。 #>はデバッグプリント用のリーダーマクロで事前にロードしておく。

(ql:quickload :cl-debug-print)
(cl-debug-print:use-debug-print)

(defun our-length (lst)
  (if (null #>lst)
      0
      #>(1+ (our-length (cdr lst)))))

(our-length '(1 2 3))

; LST => (1 2 3)
; LST => (2 3)
; LST => (3)
; LST => NIL
; (1+ (OUR-LENGTH (CDR LST))) => 1
; (1+ (OUR-LENGTH (CDR LST))) => 2
; (1+ (OUR-LENGTH (CDR LST))) => 3

これを見るとlstが再帰呼び出しの度に縮退していき、空リストに到達してから長さに1ずつ足されていっているのが分かる。

この末尾再帰でないバージョンで100万件のリストの長さをカウントしようとするとstackoverflowになる。

(defun our-length (lst)
  (if (null lst)
      0
      (1+ (our-length (cdr lst)))))

(let ((huge-list (loop for i from 1 to 1000000 collect i)))
  (our-length huge-list))

;; Control stack exhausted (no more space for function call frames).
;; This is probably due to heavily nested or infinitely recursive function
;; calls, or a tail call that SBCL cannot or has not optimized away.

;; PROCEED WITH CAUTION.
;;    [Condition of type SB-KERNEL::CONTROL-STACK-EXHAUSTED]

次にdisassembleしてみる。 大体の見方は

  • RSIなどのRから始まるのがレジスタ
  • L0などがラベル
  • CALLが関数呼び出し
  • JMPJNEがジャンプで指定したラベル位置にジャンプする
    • JNEはJump Not Equalで、直前のTESTが失敗したときにジャンプするという条件分岐命令でもある
(disassemble 'our-length)
; disassembly for OUR-LENGTH
; Size: 83 bytes. Origin: #x54CE23E3                          ; OUR-LENGTH
; 3E3:       498B4510         MOV RAX, [R13+16]               ; thread.binding-stack-pointer
; 3E7:       488945F8         MOV [RBP-8], RAX
; 3EB:       4881FE17010050   CMP RSI, #x50000117             ; NIL
; 3F2:       7505             JNE L1
; 3F4:       31D2             XOR EDX, EDX
; 3F6: L0:   C9               LEAVE
; 3F7:       F8               CLC
; 3F8:       C3               RET
; 3F9: L1:   8D46F9           LEA EAX, [RSI-7]
; 3FC:       A80F             TEST AL, 15
; 3FE:       7531             JNE L2
; 400:       488B5601         MOV RDX, [RSI+1]
; 404:       4883EC10         SUB RSP, 16
; 408:       B902000000       MOV ECX, 2
; 40D:       48892C24         MOV [RSP], RBP
; 411:       488BEC           MOV RBP, RSP
; 414:       E8C9DA65FB       CALL #x5033FEE2                 ; #<FDEFN OUR-LENGTH> <= 再帰呼び出し
; 419:       480F42E3         CMOVB RSP, RBX
; 41D:       488B75F0         MOV RSI, [RBP-16]
; 421:       BF02000000       MOV EDI, 2
; 426:       E8E5EA11FF       CALL #x53E00F10                 ; SB-VM::GENERIC-+
; 42B:       488B75F0         MOV RSI, [RBP-16]
; 42F:       EBC5             JMP L0
; 431: L2:   CC53             INT3 83                         ; OBJECT-NOT-LIST-ERROR
; 433:       18               BYTE #X18                       ; RSI(d)
; 434:       CC10             INT3 16                         ; Invalid argument count trap

42FのJMPと3F6のL0の間でループしており、その途中で再帰呼び出しのCALL命令があるのでループの度に関数が呼ばれていることが分かる。

次に、末尾最適化版の例が出てくる。 アキュームレータaccを導入することで計算の途中結果を関数の引数として次の再帰呼び出し時に渡している。

こちらはlstの縮退とaccへの加算が並行して進んでいることが分かる。

(defun our-length-tco (lst)
  (labels ((rec (lst acc)
             #>lst #>acc
             (if (null lst)
                 acc
                 (rec (cdr lst) (1+ acc)))))
    (rec lst 0)))

(our-length-tco '(1 2 3))

; LST => (1 2 3)
; ACC => 0
; LST => (2 3)
; ACC => 1
; LST => (3)
; ACC => 2
; LST => NIL
; ACC => 3

これは100万要素のリストに対して呼び出してもstackoverflowエラーにならない。

(defun our-length-tco (lst)
  (labels ((rec (lst acc)
             (if (null lst)
                 acc
                 (rec (cdr lst) (1+ acc)))))
    (rec lst 0)))

(let ((huge-list (loop for i from 1 to 1000000 collect i)))
  (our-length-tco huge-list))
; => 1000000

disassembleしてみると、4FAのJNEと4D0のL0の間でループになっており、間には加算以外の関数呼び出しはないことが分かる。

(disassemble 'our-length-tco)
; disassembly for OUR-LENGTH-TCO
; Size: 84 bytes. Origin: #x54CE24B3                          ; OUR-LENGTH-TCO
; 4B3:       498B4510         MOV RAX, [R13+16]               ; thread.binding-stack-pointer
; 4B7:       488945F8         MOV [RBP-8], RAX
; 4BB:       488B75F0         MOV RSI, [RBP-16]
; 4BF:       4531C0           XOR R8D, R8D
; 4C2:       EB2F             JMP L1
; 4C4:       660F1F440000     NOP
; 4CA:       660F1F440000     NOP
; 4D0: L0:   8D46F9           LEA EAX, [RSI-7]
; 4D3:       A80F             TEST AL, 15
; 4D5:       752B             JNE L2
; 4D7:       488B7601         MOV RSI, [RSI+1]
; 4DB:       488975E8         MOV [RBP-24], RSI
; 4DF:       BF02000000       MOV EDI, 2
; 4E4:       498BD0           MOV RDX, R8
; 4E7:       E824EA11FF       CALL #x53E00F10                 ; SB-VM::GENERIC-+
; 4EC:       4C8BC2           MOV R8, RDX
; 4EF:       488B75E8         MOV RSI, [RBP-24]
; 4F3: L1:   4881FE17010050   CMP RSI, #x50000117             ; NIL
; 4FA:       75D4             JNE L0                          ; 再帰呼び出しがなく、L0へのジャンプ(ループになっている)
; 4FC:       498BD0           MOV RDX, R8
; 4FF:       C9               LEAVE
; 500:       F8               CLC
; 501:       C3               RET
; 502: L2:   CC53             INT3 83                         ; OBJECT-NOT-LIST-ERROR
; 504:       18               BYTE #X18                       ; RSI(d)
; 505:       CC10             INT3 16                         ; Invalid argument count trap

SBCLの場合は (proclaim '(optimize speed)) などはしなくても末尾再帰最適化はやってくれるようだ。 再帰アルゴリズムの中には必ずしも末尾再帰の形にできないものもある。(quicksortとか)

(defun quicksort (list)
  (if (null list)
      nil
      (let ((pivot (first list))
            (rest (rest list)))
        (append (quicksort (remove-if-not (lambda (x) (<= x pivot)) rest))
                (list pivot)
                (quicksort (remove-if-not (lambda (x) (> x pivot)) rest))))))

ここで最適化オプションの話が出てきており、1からnまでの整数の和を返す関数triangleの例が出てくる。

10億までの総和にすると10倍くらいの違いが出てくる。

(defun triangle (n)
  (labels ((tri (c n)
             (declare (optimize (speed 3) (safety 0))
                      (type fixnum n c))
             (if (zerop n)
                 c
                 (tri (the fixnum (+ n c))
                      (the fixnum (- n 1))))))
    (tri 0 n)))

(time (triangle 1000000000))

; Evaluation took:
;  0.252 seconds of real time
;  0.251020 seconds of total run time (0.251020 user, 0.000000 system)
;  99.60% CPU
;  953,885,348 processor cycles
;  0 bytes consed

(defun triangle-no-optimize (n)
  (labels ((tri (c n)
             (if (zerop n)
                 c
                 (tri (+ n c)
                      (- n 1)))))
    (tri 0 n)))

(time (triangle-no-optimize 1000000000))

; Evaluation took:
;  2.948 seconds of real time
;  2.950525 seconds of total run time (2.950434 user, 0.000091 system)
;  100.10% CPU
;  11,212,179,642 processor cycles
;  0 bytes consed

コンパイル

compile関数で関数をコンパイルし、compiled-function-pで確認するという流れになっているのだが、SBCLの場合は普通に関数定義などを評価しただけでコンパイル済みになっていることが分かる。 clispのインタプリタで明示的にコンパイルする例を示した。

ここでSBCLはJITコンパイルか?という議論になった。 関数毎にインタラクティブに定義できるが、使われるときになってはじめてコンパイルされる(Just In Time)わけでないのでAhead of Timeコンパイルだろうという話になった。

インライン宣言の話にも軽く触れられている。4章のユーティリティ関数のところで実際に使っているのでそのときに紹介することにした。

3章 関数的プログラミング

関数的プログラミングとは、副作用を使わず、参照透明にしておくこと。(参照透明: 内部状態を持たず、同じ引数に対して常に返値を返すこと) このようになっていると関数単位でテスト、デバッグがしやすい。REPL上でのインタラクティブな開発とも相性がよい。

副作用を使う例:

;; 悪いポイント: 値を返さない。副作用のみを目的にしている
(defun bad-reverse (lst)
  (declare (optimize speed))
  (let* ((len (length lst))
         (ilimit (truncate (/ len 2))))
    (do ((i 0 (1+ i))
         (j (1- len) (1- j)))
        ((>= i ilimit))
      (rotatef (nth i lst) (nth j lst)))))

(setf lst '(a b c))
(bad-reverse lst)
; => NIL

lst ; => (C B A)

rotatefはsetfのように汎変数を受け取って、リスト要素のポインタ操作によって要素の置き換えを実現する。 そのためコンシングは発生しないが、リストは破壊的に変更される。

(time (rotatef (car lst) (caddr lst)))
;  0 bytes consed

lst ; => (A B C)

2024-02-23: On Lisp読書会(1) 参加メモ

On Lisp読書会@Shibuya.lisp

Shiubya.lispで、最近RustでSci-Lispという独自処理系を作られているchaploudさんからご提案があり、週一でOn Lispの読書会に参加することになった。 参加者は予定範囲を読んでいることを前提としており、モデレータが本の流れをなぞりながら都度質問や議論をするという感じの進め方だった。 以下は第1回の個人的なメモである。

1章 拡張可能なプログラミング言語

本文

Lispが拡張可能な言語であり、ボトムアップスタイルのプログラミングに向いているということが主張されている。 ボトムアッププログラミングの対義語はトップダウンなデザインであり、ダムの建設など事前に全てパーツを計画し、それらを順序立てて組み立てていくやり方のことを指している。 ボトムアッププログラミングでは細かく汎用的なユーティリティを作っていき、その層を重ねていくということのようだ。より探索的なアプローチといえる。

プログラムはソフトウェアなのでダムの建設と違って柔らかく、一度作ってしまえば後戻りできないというものでもないため、このようなアプローチが向いている場面もある。

Lispは言語自体が柔軟なので汎用的なユーティリティを作りやすい(例として高階関数とマクロが挙げられている) → 言語自体を成長させていくことが容易、ということだと思った。

この辺はUNIX的な考え方−すなわち直交したシンプルなコマンドラインツールをシェル上で組み合わせることによって複雑なタスクを実現する−と近いという意見も出た。

問題に合わせてDSLを作るとより問題を短く記述できるため、メンテナンス性が増し、独自に導入した流儀を揃えることができる環境では威力を発揮すると主張している。 そのため小規模グループでの開発に向いているとされている。

感想: 最近では、高階関数やパターンマッチベースのマクロ機能はLispの専売特許というわけでもなくなってきているので、他の言語でもボトムアップアプローチはできるし、普通にやられていると思う。 とはいえCommon Lispのいい意味での枯れ具合とインタラクティブ性が高性能な処理系と同居しているというのは、いまだに特異的だと思っている。

2章 関数

本文

Lispはほとんど関数の集合体であり、例外が少ない。 普通の言語では組込みオペレータであるような+も単なる関数として実装されている。

2章冒頭では関数定義の方法、lambdaによる無名関数、関数と変数の名前空間が異なりそれぞれにアクセスする方法が異なることが説明される。 関数もLispのデータ(操作対象)であるという例として、シンボルの属性リストに関数を格納して呼び出す例が出てきた。 Common Lispのシンボルが実はリッチなデータ構造で、属性リストを持てるというのが説明なしに出てくるのでシンボルの中身をインスペクタで見ることで解説した。

(defun behave (animal)
  (funcall (get animal 'behavior)))

(setf (get 'dog 'behavior)
      #'(lambda ()
          (print "bark")))

(behave 'dog)
;; "bark"

(setf (get 'dog 'height) 30)

(inspect 'dog)

#|
The object is a SYMBOL.
0. Name: "DOG"
1. Package: #<PACKAGE "COMMON-LISP-USER">
2. Value: #<unbound slot>
3. Function: #<unbound slot>
4. Plist: (HEIGHT 30 BEHAVIOR #<FUNCTION (LAMBDA ()) {54CEF72B}>)
> 4

The object is a proper list of length 4.
0. 0: HEIGHT
1. 1: 30
2. 2: BEHAVIOR
3. 3: #<FUNCTION (LAMBDA ()) {54CEF72B}>
|#

レキシカルスコープとダイナミックスコープ

最近の言語ではレキシカルスコープが当たり前になっているので、むしろダイナミックスコープが何かという話になった。 defvarなどでスペシャル変数宣言するとダイナミックスコープになる(関数内部で自由な変数を外側のletで束縛するなどして実行時に変更できる)という説明をした(たぶん合ってる)

;; ダイナミックスコープの例
(defvar *y*)

(defun scope-test2 (x)
  (list x *y*))

(let ((*y* 5))
  (scope-test 3))
; => (3 5)

レキシカルクロージャ

関数が作られた環境への参照を関数内に閉じ込めることができ、そのような関数をクロージャと呼ぶ、ということだと理解している。

次の例では db が連想リストで、make-dbms内で作られている3つのアクセサ関数経由でしか参照も変更もできないというカプセル化がなされている。 あとは継承があればオブジェクト指向がほぼ実現できる、と書かれている。

(defun make-dbms (db)
  (list
   ;; referrer
   #'(lambda (key)
       (cdr (assoc key db)))
   ;; setter
   #'(lambda (key val)
       (push (cons key val) db)
       key)
   ;; cleaner
   #'(lambda (key)
       (setf db (delete key db :key #'car))
       key)))

(defparameter cities (make-dbms '((boston . us) (paris . france))))
;; referrer
(funcall (first cities) 'boston) ; => US
(funcall (first cities) 'paris) ; => FRANCE

;; setter
(funcall (second cities) 'london 'england)

(funcall (first cities) 'london) ; => ENGLAND

;; cleaner
(funcall (third cities) 'london)
(funcall (first cities) 'london) ; => NIL

ローカル関数

labelsを導入して関数内の補助関数をローカル関数として定義する例が出てくる。 このinstances-inが外側のobjを閉じ込めているのでこれもクロージャの例になっている。

(defun count-instances (obj lsts)
  (labels ((instances-in (lst)
             (if (consp lst)
                 (+ (if (eq (car lst) obj) 1 0)
                    (instances-in (cdr lst)))
                 0)))
    (mapcar #'instances-in lsts)))

;; リストの各要素リスト内のaの数を数える
(count-instances 'a '((a b c) (d a r p a) (d a r) (a a)))

余談として、labels以外にも fletflet* などもあって、これらを使うと再帰を使っていないことが明確になるという話をした。 とはいえlabelsがあればこれらは基本いらないはず。とにかく言語要素を少なくするという考え方と、用途が微妙に異なる構文を多数用意しておくことでコンテキスト情報を与えるとう考え方があり、Common Lispは後者であるということだと思う。

Schemeのように内部関数定義でもいいのでは?と思うが、defunを使うとトップレベルに定義される。

(defun count-instances (obj lsts)
  (defun instances-in (lst)
    (if (consp lst)
        (+ (if (eq (car lst) obj) 1 0)
           (instances-in (cdr lst)))
        0))
  (mapcar #'instances-in lsts))

;; count-instancesを呼ぶ度にinstances-inが上書かれる
(count-instances 'b '((a b c) (d a r p a) (d a r) (a a)))
(instances-in '(d a r p a)) ; => 0

末尾再帰

ここから次回ということになった。 次回は自分がモデレータなので予習しておかねば…


2024-02-10: Common LispでBOM付きCSVを扱う方法

BOM(Byte Order Mark)とは

CSVファイルを他人から受け取るときに、それがExcelで作られたUTF-8でエンコードされたCSVファイルの場合、BOMと呼ばれるデータがファイルの冒頭3バイトについていることがある。 これがヘッダー検査のときに悪さをしてバリデーションに引っかかって読み込めないということがよくある。 逆に、システムからUTF-8のCSVファイルを出力したが、BOMを付けないとExcel側で文字化けするということもある。

ChatGPTによるBOMの解説

BOM(Byte Order Mark)は、テキストファイルの先頭に配置される特定のバイトシーケンスで、ファイルのエンコーディング形式と、特にエンコーディングが複数のバイトを使用する場合のバイト順(エンディアン)を示します。BOMは、Unicodeテキストファイルを識別するために使われ、特にUTF-16とUTF-32エンコーディング形式ではバイト順を区別するのに役立ちます。

UTF-8の文脈では、BOMはEF BB BFというバイトシーケンスで表され、UTF-8エンコードされたテキストファイルの先頭にオプションで置かれることがあります。UTF-8では、バイトの順序が問題になることはありませんが、BOMを使用すると、ファイルがUTF-8でエンコードされていることを明示的に示すことができます。

BOM付きファイルを出力する

Common LispでBOM付きファイルを出力するならば、with-open-fileの冒頭でEF BB BFの3バイトをくっつけるマクロを定義すればよい。

(defmacro with-output-file-with-bom ((file-stream file) &body body)
  `(progn
     (with-open-file (,file-stream ,file :direction :output :if-exists :supersede
                                     :element-type '(unsigned-byte 8))
       (write-sequence (make-array 3 :element-type '(unsigned-byte 8)
                                     :initial-contents '(#xEF #xBB #xBF))
                       ,file-stream))
     (with-open-file (,file-stream ,file :direction :output :if-exists :append)
       ,@body)))

使用例

(ql:quickload '(:fare-csv :alexandria))

(with-output-file-with-bom (f "/tmp/with-bom.csv")
  (fare-csv:write-csv-lines '(("id" "val")
                              (1 "foo"))
                            f))

;; 比較対象に普通のBOMなしのファイルを出力しておく
(alexandria:with-output-to-file (f "/tmp/without-bom.csv")
  (fare-csv:write-csv-lines '(("id" "val")
                              (1 "foo"))
                            f))

出力されたファイルを読み出してみると、一見同じ文字列だが、インスペクトしてみると冒頭に #\ZERO_WIDTH_NO-BREAK_SPACE が付いていることが分かる。 当然equalは失敗する。

(defparameter *id-with-bom*
  (with-open-file (f "/tmp/with-bom.csv")
    (first (fare-csv:read-csv-line f))))

#|
=> "id"

#<(SIMPLE-ARRAY CHARACTER (3)) {1014FE473F}>
--------------------
Dimensions: (3)
Element type: CHARACTER
Total size: 3
Adjustable: NIL
Fill pointer: NIL
Contents:
0: #\ZERO_WIDTH_NO-BREAK_SPACE
1: #\i
2: #\d
|#

(defparameter *id-without-bom*
  (with-open-file (f "/tmp/without-bom.csv")
    (first (fare-csv:read-csv-line f))))

#|
=> "id"

#<(SIMPLE-ARRAY CHARACTER (2)) {1015020B8F}>
--------------------
Dimensions: (2)
Element type: CHARACTER
Total size: 2
Adjustable: NIL
Fill pointer: NIL
Contents:
0: #\i
1: #\d
|#

(equal *id-with-bom* *id-without-bom*)
;; => NIL

BOMを取り除いて読み込む

BOM付きのファイルから読み出すときは、最初にBOM付きかどうかをチェックした上で、そのままストリームを開くか、冒頭3バイトを削った上で開くマクロが必要。

BOM付きファイルかどうは以下のような関数で判定できる(後にマクロ定義内で使うのでeval-whenでコンパイル時に評価されるようにしておく)

(eval-when (:compile-toplevel :load-toplevel :execute)
  (defun has-bom-p (file)
    (with-open-file (stream file
                            :direction :input
                            :element-type '(unsigned-byte 8))
      (let ((b1 (read-byte stream nil nil))
            (b2 (read-byte stream nil nil))
            (b3 (read-byte stream nil nil)))
        (and b1 b2 b3 (= b1 #xEF) (= b2 #xBB) (= b3 #xBF))))))

with-open-fileでは、途中でファイル読み込みのモードをバイナリモードから文字モードへ切り替えるということはできないので、2回with-open-fileすることになる。 BOM部分がどの文字オブジェクトになるかは処理系依存のようだが、1文字として判定されるのは共通のようなので、BOM付きの場合は冒頭1文字読み飛ばす。

(defmacro with-input-file-with-bom ((file-stream file) &body body)
  (alexandria:with-gensyms (has-bom-p)
    `(let ((,has-bom-p (has-bom-p ,file)))
       (with-open-file (,file-stream ,file
                                     :direction :input
                                     :element-type 'character)
         (when ,has-bom-p
           (read-char ,file-stream))
         ,@body))))

使用例

(equal (with-input-file-with-bom (f "/tmp/without-bom.csv")
         (first (fare-csv:read-csv-line f)))
       (with-input-file-with-bom (f "/tmp/with-bom.csv")
         (first (fare-csv:read-csv-line f))))
;; => T