Windows上でeclを使ってCommon Lispの関数をC++から実行する

はじめに

Windows上でeclを使ってCommon Lispの関数をC++から実行する方法を紹介します。 eclを使えばC++からCommon Lispを実行することができるため、 エントリポイントはプラットフォームが提供するC++DSLで実装するしかなさそうなんだけど、 メインの処理はスムーズに開発できるCommon Lispで書きたい、ということが可能になります。 このeclの使い方はlinux限定かなと勝手に思い込んでいたのですが、実はWindowsでも使えるようでした。

実行環境

eclを使うにあたってまずはeclをビルドして環境を構築する必要があります。 今回はCドライブの直下にlispフォルダを作って以下のような構成でeclを実行します。

C:/lisp/
  + ecl-build/               // eclのソースコード、ツール
    + ecl-21.2.1/            // eclのソースコード
    + yasm-1.3.0-win64.exe

  + ecl/                     // eclの実行ファイル、ランタイム

  + print-factorial/         // C++から実行するCommon Lispのシステム
    + print-factorial.lisp
    + print-factorial.asd

  + print-factorial-lib/     // Common Lispから生成したlibファイルを置く場所

  + print-factorial-entry/   // MSVCのプロジェクト (C++のエントリポイント)

lispフォルダ

eclのビルドとインストール

参考: https://ecl.common-lisp.dev/static/manual/Building-ECL.html

Visual Studio 2022をインストールする

  • 2022でなくてもいいかもしれないですが、少なくともx64 Native Tools Command Promptは必要です

yasm-1.3.0-win64.exeをダウンロードする

  • 上述したようにC:/lisp/ecl-build下にダウンロードします

x64 Native Tools Command Prompt for VS 2022を起動する

  • 「スタートメニュー > Visual Studio 2022 > x64 Native Tools Command Prompt for VS 2022」にあります

msvcディレクトリに移動する

  • cd C:/lisp/ecl-build/ecl-21.2.1/msvc

yasmへのパスを通す

  • set path=C:\lisp\ecl-build;%path%

ビルドする

  • nmake ECL_CMP=1 ECL_ASDF=1 ECL_WIN64=1 GMP_TYPE=AMD64

インストールする

  • nmake install prefix=C:\lisp\ecl

quicklispをダウンロードしてインストールする

  • どこにダウンロードしてもいいです

サンプルシステムの作成

C++から実行するシステムを作ります。

;; print-factorial.asd
(asdf:defsystem :print-factorial
  :components
  ((:file "print-factorial"))
  :depends-on
  (:alexandria))
;; print-factorial.lisp
(defpackage :print-factorial
  (:use :cl)
  (:export :print-factorial))
(in-package :print-factorial)

(defun print-factorial (n)
  (print (alexandria:factorial n))
  (force-output))

Common Lispのコードからlibファイルの作成

eclを起動する

  • x64 Native Tools Command Prompt for VS 2022を起動します
    • このコマンドプロンプトでないとasdf:make-build時に「 指定されたファイルが見つかりません。」エラーが出ます。
    • おそらく、64bitビルドしたlibファイルを読み込めることができないのだと思われます。
  • cd c:\lisp\します
    • asdf:make-buildをすると一時ファイルが現在のフォルダに作られるため、一時ファイルを問題なく作れるフォルダに移動します
  • コマンドプロンプトからc:\lisp\ecl\eclを実行します

libファイルを生成する

  • eclのREPLから以下を実行します
;; print-factorialの読み込み
(push "C:/lisp/print-factorial/" asdf:*central-registry*)
(ql:quickload :print-factorial)

;; asdf:make-buildのための事前準備
(require 'cmp)

;; libファイルの生成
(asdf:make-build :print-factorial :type :static-library :monolithic t :move-here "C:\\lisp\\print-factorial-lib" :init-name "init_print_factorial")

eclのドキュメントにはasdf:make-buildをすればlibファイルが生成されるとありますが、実際はその前に(require 'cmp)しないとファイルが生成されません。
参考: https://stackoverflow.com/questions/55086558/what-is-the-correct-way-to-compile-a-file-using-embeddable-common-lisp

asdf:make-buildを実行すると以下のようなログが出力され、ファイルが生成されます。

> (asdf:make-build :print-factorial :type :static-library :monolithic t :move-here "C:\\lisp\\print-factorial-lib" :init-name "init_print_factorial")

;;;
;;; Compiling C:/lisp/print-factorial/print-factorial.lisp.
;;; OPTIMIZE levels: Safety=2, Space=0, Speed=3, Debug=0
;;;
;;; End of Pass 1.
;;; Finished compiling C:/lisp/print-factorial/print-factorial.lisp.
;;;
(#P"C:/lisp/print-factorial-lib/print-factorial--all-systems.lib")

MSVCでプロジェクトを作成し、eclで生成したlibファイルと一緒にビルド

MSVCプロジェクトを作成する。

ソリューションエクスプローラー > プロパティで「構成: すべての構成」「プラットフォーム: x64」を選択し、以下のように設定する。

  • 構成プロパティ > C/C++ > 全般 > 追加のインクルード ディレクトリにC:\lisp\eclを追加

  • 構成プロパティ > リンカー > 全般 > 追加のライブラリ ディレクトリに以下を追加

C:\lisp\print-factorial-lib
C:\lisp\ecl
  • 構成プロパティ > リンカー > 入力 > 追加の依存ファイルに以下を追加
    • print-factorial--all-systems.lib以外のファイルはeclフォルダ下にあるlibファイルで、dir /b C:\lisp\ecl\*.libで得られます
print-factorial--all-systems.lib
asdf.lib
cmp.lib
deflate.lib
defsystem.lib
ecl-cdb.lib
ecl-curl.lib
ecl-help.lib
ecl-quicklisp.lib
ecl.lib
package-locks.lib
profile.lib
ql-minitar.lib
rt.lib
sb-bsd-sockets.lib
sockets.lib

ソースコードを書く。

#include <ecl/ecl.h>

extern "C" {
    void init_print_factorial(cl_object);
}


int main()
{
    _wputenv_s(L"ECLDIR", L"C:\\lisp\\ecl\\");

    const char* ECL_STRING = "ecl";
    char ecl[sizeof(ECL_STRING)];
    strncpy_s(ecl, ECL_STRING, sizeof(ecl));
    char* argv[] = { ecl };
    cl_boot(1, argv);

    ecl_init_module(NULL, init_print_factorial);

    cl_funcall(2,
               cl_eval(c_string_to_object("'print-factorial:print-factorial")),
               MAKE_FIXNUM(10));

    cl_shutdown();

    return 0;
}

このようにMSVCのプロジェクトを作成してビルドすると、Windowsの実行ファイルが生成されるはずです。

C:\lisp\eclフォルダに移動して (ecl.dllを読み込ませるため) 実行ファイルを実行すると、無事結果が表示されます。

c:\lisp\ecl> C:\lisp\print-factorial-entry\x64\Debug\print-factorial-entry.exe

3628800

おわりに

Windows上でeclを使ってCommon Lispの関数をC++から実行する方法を紹介しました。 Common LispのコードとWindowsの関数とを組み合わせることができるので、いろいろできることが広がりそうです。

Common Lispで動くcurses (cl-charms)で日本語を出力する

cl-charmsのAPIをそのまま使うと日本語の文字列は文字化けして出力されます。 対処の方法を調べるのに時間がかかったのでここにメモ。

手元で試した限りだと、cl-charmsの処理に入る前に(cl-setlocale:set-all-to-native)を実行しておけば大丈夫そうでした。 localeを設定すれば大丈夫というのは検索すれば出てくるのですが、Common Lispからどうやればいいのかは分からず、結局lemのコードを読んでいて見つけました。(ありがとうございます!)

以下、サンプルコードです。 charms-ja.lispという名前で保存し、ros -s cl-charms -s cl-setlocale -l charms-ja.lisp -e '(charms-ja:main)' -q と打てば実行できると思います。

(defpackage :charms-ja
  (:use :cl)
  (:export :main))
(in-package :charms-ja)

(defun main ()
  (cl-setlocale:set-all-to-native)
  (charms:with-curses ()
    (charms:disable-echoing)
    (charms:enable-raw-input :interpret-control-characters t)
    (charms:enable-non-blocking-mode charms:*standard-window*)
    (loop for c = (charms:get-char charms:*standard-window*
                                   :ignore-error t)
          do (progn
               (charms:clear-window charms:*standard-window*)
               (charms:write-string-at-point charms:*standard-window*
                                             "こんにちは世界"
                                             0
                                             0)
               (charms:refresh-window charms:*standard-window*)
               (case c
                 ((#\q #\Q) (return)))))))

IMEと確定結果の扱い

はじめに

かな漢字変換の結果を確定した時に確定した漢字を記憶しておき、次回以降の変換時に優先的に出力するのは、IMEのよくある機能です。 今回、この機能の劣化版を個人開発のIMEでも作ってみました。 まだ全然使い物にはならないものなのですが、 機能の作りの方針に関しては今後の開発にも参考にできそうに思ったのでメモしておきます。

既存のつくり

stateful-imeというオブジェクトがあり、ユーザーとの対話を担当しています。 stateful-imeは、state + statelessな機能というつくりになっています。 詳しくは以下にあります。 mhkoji.hatenablog.com

採用した方針

既存のつくりを素直に拡張する方針を採用し、次のように変更しました。 確定結果はhistoryと名付けました (今思うと適当な名前です)。 historyも状態の一つと言えそうに思い、stateのメンバとしました。

stateful-imeの役割は基本的には状態を保持・更新するだけであり、機能自体はstateless側の役割です。 今回は、確定した変換結果もstateful-imeに返却するようにstateless側を修正しました。 stateful-imeは確定した結果をstateless側から受け取ると、その確定結果でもって保持しているhistoryを更新します。

詳しい処理は下のようになります。 キー入力が現在の状態で確定するものだった場合、 senn.win.im.process-input:executeが確定した変換情報 (committed-segments) を返すので、 それでhistoryを更新します。

(defun process-input (stateful-ime key)
  (with-accessors ((history state-history)
                   (input-state state-input-state)
                   (input-mode state-input-mode))
      (stateful-ime-state stateful-ime)
    (let ((result (senn.win.im.process-input:execute
                   stateful-ime input-state input-mode key)))
      (destructuring-bind (can-process view
                           &key state committed-segments)
          result
        ;; update application state
        (when state
          (setf input-state state))
        (when committed-segments
          (dolist (seg committed-segments)
            (history-put
             history
             (senn.segment:segment-pron seg)
             (senn.segment:segment-current-form seg))))
        ...

input-stateの更新とhistoryの更新はどちらもstateの更新ということで兄弟のような関係になっています。

次にeffected-imeを定義しました。 effectedにはstateに影響を受けるという意味を持たせているつもりです。

(defclass effected-ime (senn.im:ime)
  ((kkc
    :initarg :kkc
    :reader effected-ime-kkc)))

メンバのkkcはviterbiアルゴリズムによるかな漢字変換を実行するためのオブジェクトです。

imeのレイヤーではsenn.im:convertメソッドがひらがな列をかな漢字混じり列に変換する機能を提供します。 effected-imeに対してsenn.im:convertメソッドが呼び出されると、 viterbiによるかな漢字変換 (senn.im.kkc:convert) 結果を得た後、 状態を取り出し (stateful-ime-state)、状態のhistoryを参照して上書きします(history-apply)。

(defmethod senn.im:convert ((ime effected-ime) (pron string)
                            &key 1st-boundary-index)
  (with-accessors ((kkc effected-ime-kkc)
                   (state stateful-ime-state)) ime
    (let ((segs (senn.im.kkc:convert
                 kkc pron
                 :1st-boundary-index 1st-boundary-index)))
      (history-apply (state-history state) segs)))))

effected-imeは状態を参照するだけで、状態自体を保持するようにはしませんでした。これはなんとなくです。 状態の保持・参照を同じオブジェクトに定義する必要はないかなとなんとなく思って分離しました。

状態の扱いの定義は分離された一方、実行時には全部入りのオブジェクトが必要です。 なのでstateful-imeとeffected-imeをミックインし、状態を保持・参照・更新できるクラスを定義します。 今後はこのオブジェクトがユーザーとの対話を担当します。

(defclass stateful-effected-ime (stateful-ime
                                 effected-ime)
  ())

採用しなかった他の方針

上述したように、sennのかな漢字変換はviterbiアルゴリズムというよく知られた方法を利用しています。 history-applyをこのviterbiレイヤーに持って来るように修正しても今回の機能を実現することはできるのですが、 この方針は採用しませんでした。

viterbiレイヤーには言語モデルやviterbiアルゴリズムなど、一般的に知られた処理が属しています。 一方、以前の変換を利用するのは、よく知られてはいるものの、 以前の変換の定義については一般的には知られていないため、senn独自のものになります。 実際、今回の実装ではこの以前の変換を定義しているのは、上で述べたようにhistoryになっており、 これは一般的に知られていない、今後変更されそうな、今この瞬間のsenn独自のものです。 したがって、viterbiレイヤーは一般的に知られた処理のためのレイヤーとしてそのままにしておき、 今回の処理はsenn独自のレイヤーに持っていくことにしました。

おわりに

採用した方針と採用しなかった方針、採用した方針における状態の扱い方について書いてみました。 今後の機能づくりの参考になればと思います。

SBCLでUnix-domain socketを使う

はじめに

Common Lispでsocketプログラミングをするには通常usocketを使うのですが、 usocketはUnix-domain socketに対応してなさそうです。 そこで、SBCLUnix-domain socketを使う方法について調べました。 また、簡単なechoサーバーも書いてみました。

local-socket

SBCLのマニュアル によると、Unix-domain socketにはlocal-socketクラスを使うようです。 また、local-abstract-socketというクラスもあり、こちらを使うと抽象ソケットを作ることができるようです。

これらのクラスからlistenするソケットを次のように生成できます。

(defun socket-listen (socket-name &key use-abstract)
  (let ((socket (make-instance (if use-abstract
                                   'sb-bsd-sockets:local-abstract-socket
                                   'sb-bsd-sockets:local-socket)
                               :type :stream)))
    (handler-case
        (progn
          (sb-bsd-sockets:socket-bind socket socket-name)
          (sb-bsd-sockets:socket-listen socket 100)
          socket)
      (sb-bsd-sockets:address-in-use-error ()
        nil))))

生成したソケットはsb-bsd-sockets:socket-acceptで接続を待ち受けることができます。

接続する側のソケットも同様にlocal-socketを使います。

(defun connect-to (socket-name &key use-abstract)
  (let ((socket (make-instance (if use-abstract
                                   'sb-bsd-sockets:local-abstract-socket
                                   'sb-bsd-sockets:local-socket)
                               :type :stream)))
    (sb-bsd-sockets:socket-connect socket socket-name)
     socket))

echoサーバー

下は、echoサーバーの例です。 (start-server)して接続を待ち受けた後、別ターミナルで(connect-and-send)をすると、サーバーに文字列を送っていることが確認できます。

(ql:quickload :bordeaux-threads)

;;; server側
(defun handle-socket (socket)
  (let ((stream (sb-bsd-sockets:socket-make-stream
                 socket
                 :input t
                 :output t
                 :buffering :full)))
    (bt:make-thread
     (lambda ()
       (format t "Connected")
       (unwind-protect
           (loop for line = (read-line stream nil nil)
                 while line
                 do (progn
                      (print line)
                      (write-line line stream)
                      (force-output stream)))
         (sb-bsd-sockets:socket-close socket))
       (format t "Disconnected")))))

(defun start-server ()
    (let ((threads nil)
          (listen-socket (socket-listen "/tmp/server")))
      (when listen-socket
        (unwind-protect
            (loop do
                (let* ((socket (sb-bsd-sockets:socket-accept listen-socket))
                       (thread (handle-socket socket)))
                  (push thread threads)))
          (mapc #'bt:destroy-thread threads)
          (sb-bsd-sockets:socket-close listen-socket)))))

;;; client側
(defun connect-and-send ()
  (let ((socket (connect-to "/tmp/server")))
    (unwind-protect
        (let ((stream (sb-bsd-sockets:socket-make-stream
                       socket
                       :input t
                       :output t
                       :buffering :full)))
          (write-line "Hello" stream)
          (force-output stream)
          (print (read-line stream))
          (write-line "World" stream)
          (force-output stream)
          (print (read-line stream))
          (values))
      (sb-bsd-sockets:socket-close socket))))

IMEと入力モードの変更

sennという名前のWindowsで動くIMEを自作しています。 sennの言語バーには入力モードを表示・切り替えるボタンがあり、このボタンをクリックするとひらがな入力と直接入力とを切り替えることができます。 この振る舞いは単純なのですが、内部実装はちょっと複雑になっているためここにメモしておきます。

sennはネットワークでつながったフロントエンドとバックエンドで構成されています。 sennの状態はすべてバックエンドで保持されており、入力モードもバックエンド側で管理してます。 フロントエンドは画面の表示のみを担当しています。

入力モードを表示・切り替えるボタンにはITfLangBarItemButtonを利用しています。 ユーザーがこのボタンをクリックすると、以下のように処理が進みます。

  • Windowsが(?)フロントエンドにあるボタンのITfLangBarItemButton::OnClickメソッドを実行する
  • フロントエンドがバックエンドに対して入力モードを切り替えるメソッド (ToggleInputMode) を実行する
  • フロントエンドがWindowsに (?) 対してアイコンアップデートメソッド (ITfLangBarItemSink::OnUpdate(TF_LBI_ICON))を実行する
  • Windowsが(?)フロントエンドにあるボタンのGetIconメソッドを実行する
  • ボタン (のコールバック) がバックエンドに対して現在の入力モードを問い合わせる (GetInputMode)
  • フロントエンドが入力モードに応じてアイコンを返却する

シーケンス図は次の通りです。

f:id:mhkoji:20211104203759p:plain
シーケンス図

ITfLangBarItemButtonのアイコンを更新するにはボタン自身にGetIconメソッドでアイコンを返却させるという仕組みになっているようで、 外側からsetterでボタンのアイコンを指定するようなことはできなさそうでした。 この事情により、バックエンドはToggleInputModeメソッドとGetInputModeメソッドを提供しており、 ToggleInputModeでは更新後の入力モードを返却せず、 GetIconメソッド用のGetInputModeメソッドが別途入力モードを返却するようにしています。

おわりに

ネットワーク越しに状態を更新し、参照するのは、大げさな感がありますが、面白いです。 なお、バックエンドのToggleInputModeGetIconの処理を担当しているのはstateful-imeです。

IMEでの状態の扱い

はじめに

sennという名前のWindowsで動くIMEを個人で開発しています。 IMEの状態の扱いがなかなか複雑なので、ここでメモしてみます。

stateful-ime

sennにはstateful-imeというオブジェクトがあります。キー入力の処理等、ユーザーと対話するのはこのstateful-ime (の派生) です。 stateful-imeは、stateとstatelessな機能の組み合わせです。 stateは画面の状態等、いろいろな状態を保持しています。

stateful-imeはstateの保持、更新を担当するのみで、具体的な機能はstatelessのほうが担当します。 stateful-imeのメソッドが呼び出されると、引数と保持している状態とを合わせてstatelessな機能に処理を委譲します。 stateful-imeはstatelessから返り値と新しい状態を受け取り、受け取った状態で現在の状態を更新したのちに呼び出し側に返り値を渡します。

たとえば、下はprocess-inputの定義です。このメソッドはユーザーのキー入力を処理します。

(defun process-input (stateful-ime key)
  (with-accessors ((input-state state-input-state)
                   (input-mode state-input-mode))
      (stateful-ime-state stateful-ime)
    (let ((result (senn.win.im.process-input:execute
                   stateful-ime input-state input-mode key)))
      (destructuring-bind (can-process view &key state)
          result
        ;; update application state
        (when state
          (setf input-state state))
        (format nil "~A ~A~%"
                (if (and can-process view) 1 0)
                (or view ""))))))

stateful-imestateful-ime-stateで自身の状態を取り出し、statelessなsenn.win.im.process-input:executeに渡します。 senn.win.im.process-input:executeが新しいinput-stateを返却すると、それでstateful-imeの状態を更新します。 (format nil ...の部分は返り値で、この情報はC++実装のフロントエンドが画面を更新するために利用します。

テストを書くときは、このstateful-imeprocess-inputに対してのみテストを書けばよく、senn.win.im.process-input:executeのテストは書かなくていいかなと思っています。 stateful-imeは、ユーザーと対話しているため、ユーザーの行動をテストで模擬し、ちゃんと対話できているのかを確認するのは意味があると思っています。 一方、senn.win.im.process-input:executeは内部処理であったり、stateの定義変更により影響を受けやすいため、stateful-imeほどテストする意味はないと思っています。 stateful-imeは状態をカプセル化しているため、stateの定義が変わっていてもテストケースは変化しないはずです。

おわりに

以上、IMEの状態の扱い方の方針でした。