Standard ML on LuaTeXしてみる

この記事は TeX & LaTeX Advent Calendar 2021 の15日目の記事です。

目次

TeXの上でのプログラミング

TeXの上でプログラムを動かすといった場合、どういうものを想像されるでしょうか。

まず、TeX自身はプログラミング言語としての性質を持ちます。LaTeXTeXで記述されています。

次に、LuaTeXというTeXエンジンを使うと、汎用スクリプト言語Luaでプログラムを文書内またはパッケージ内に書くことができます。

最後に、他のプログラミング言語処理系を外部コマンドとして呼び出すという方法があります。そういうパッケージの例としてPythonTeXがあります。

ここでは、筆者が作成している、Luaを出力するStandard MLコンパイラー「LunarML」を使って、Standard MLで書かれたプログラムをTeX文書から利用してみたいと思います。3つの方法の中では2番目の方法(Luaで書く)の亜種になるかと思います。

Standard MLの紹介

Standard MLは多相型を持つ静的な型システム、代数的データ型とパターンマッチ、プログラムの抽象化と再利用を可能とするモジュールシステムを特徴とする関数型言語です。1990年に最初の仕様 “The Definition of Standard ML” が、1997年に改訂版が出ています。その後も言語の欠陥を修正したり新機能を追加しようという動きがあり、Successor MLと呼ばれています。

処理系としてはSML/NJ, MLton, Poly/ML, SML#など複数開発されています。筆者が作成しているLunarMLもその一つになる予定です。

いくつかコード例を挙げてみます。まずはフィボナッチ数の計算をするコードです:

(* コメントは (* *) で囲う(ネストもできる) *)
(* パターンマッチによる関数定義ができる *)
fun slowFib 0 = 0
  | slowFib 1 = 1
  | slowFib n = slowFib (n - 1) + slowFib (n - 2);
fun loop i = if i >= 10 then
                 ()
             else
                 ( print (Int.toString (slowFib i) ^ "\n"); (* カッコ内のセミコロンにより複数の式を逐次実行できる *)
                   loop (i + 1)
                 );
val () = loop 0; (* loop 0 の結果を空レコードパターン () にマッチさせている *)

お馴染みFizzBuzzです:

fun loop i = if i > 100 then
                 ()
             else
                 let val message = if i mod 15 = 0 then (* mod は中値演算子として使える。値の比較のイコールは一つ *)
                                       "FizzBuzz"
                                   else if i mod 5 = 0 then
                                       "Buzz"
                                   else if i mod 3 = 0 then
                                       "Fizz"
                                   else
                                       Int.toString i
                 in print (message ^ "\n");
                    loop (i + 1)
                 end;
loop 1;

Standard ML については、最近、日本語での書籍「プログラミング言語Standard ML入門」の改訂版が出ました。本格的に勉強したい方は読んでみると良いでしょう。

LunarMLの紹介

LunarMLは筆者が数年前から開発している、Luaをターゲット言語とするStandard MLコンパイラーです。このブログでも定期的に進捗報告をしています(直近の記事)が、この一年でだいぶ使い物になるようになってきました。

動機について少し触れておきます。世の中には「Luaでしかプログラムが書けない状況、あるいはLuaでプログラムを書くのが有利な状況」が時々あります。TeXがまさにその例で、LuaTeXにとってLuaが特別な立ち位置の言語であることはもちろん、TeX Liveにプログラムを収録しようとすると(インタープリター同梱の関係で)Perlと並んでLuaが有力な選択肢となります。TeX以外では、PandocもLuaを組み込んでいます。

Lua自身も優れたプログラミング言語だと個人的には思っていますが、それでも静的な型システムの欠如は大規模な開発には辛いものがあります。実際、筆者はLuaでClutTeXというプログラムを書いていて辛くなってきました。

プログラムをLuaコードとして動かしたいが、素のLuaは書きづらい。であれば、他の言語を入力としてLuaコードを出力するコンパイラーを書けばいい(altJSと同じ発想ですね)。そういう発想で作り始めたのがLunarMLです。

「他の言語」というのは当然静的型があるのが大前提となりますが、多相型、代数的データ型とパターンマッチなどの現代的な特徴があるのが望ましいです。候補としてはStandard ML, OCaml, Haskell, PureScriptなどを検討し、仕様の小ささなどからStandard MLを選択しました。当時筆者はStandard ML初心者だったので、Standard MLに慣れる意味もあってコンパイラーもStandard MLで書くことにしました。

経緯についてはこのくらいにしておいて、LunarMLの現状はこの間書いた記事を見てください。

要約すると、Standard MLの固まった仕様であるSML ’97の言語機能はsignatureやfunctorも含めてほぼ一通り実装しました。今はライブラリーを整えたり最適化を改良したりしている段階です。拡張機能として、Luaとのやりとりもできるようになっています。

利用例

Standard MLで書いたプログラムをLuaLaTeX文書から利用してみます。

例1:ベルヌーイ数

ベルヌーイ数または関・ベルヌーイ数は、\[\begin{aligned}B_0&=1,\\B_n&=-\frac{1}{n+1}\sum_{k=0}^{n-1}\binom{n+1}{k}B_k\quad(n\ge 1)\end{aligned}\]という漸化式で定義される有理数列です(同値な定義の仕方は他にもあります)。ベルヌーイ数は\(\sum_{k=1}^n k^p\)の公式の係数に登場します。

Standard MLでベルヌーイ数を計算する関数は次のようになります:

fun computeBernoulliNumbers (limit : int) : Rational.rational vector
    = let val bern = Array.array (limit, Rational.fromInt 0)
          val () = Array.update (bern, 0, Rational.fromInt 1)
          val () = let fun loop (n, comb) = if n >= limit then
                                                ()
                                            else
                                                let val comb = Vector.tabulate (Vector.length comb + 1, fn k => if k = 0 orelse k = Vector.length comb then
                                                                                                                    IntInf.fromInt 1
                                                                                                                else
                                                                                                                    Vector.sub (comb, k - 1) + Vector.sub (comb, k)
                                                                               )
                                                    val sum = let fun s (k, acc) = if k >= n then
                                                                                       acc
                                                                                   else
                                                                                       s (k + 1, Rational.+ (acc, Rational.* (Rational.fromIntInf (Vector.sub (comb, k)), Array.sub (bern, k))))
                                                              in s (0, Rational.fromInt 0)
                                                              end
                                                    val bern_n = Rational.~ (Rational./ (sum, Rational.fromInt (n + 1)))
                                                in Array.update (bern, n, bern_n)
                                                 ; loop (n + 1, comb)
                                                end
                   in loop (1, vector [IntInf.fromInt 1, IntInf.fromInt 1])
                   end
      in Vector.tabulate (limit, fn i => Array.sub (bern, i))
      end

Standard MLでは型構築子を後置するので、返り値の型 Rational.rational vector は要素を Rational.rational とする vector を表します。他の言語だと vector<Rational.rational> のように書くと思います。

ここで、 Rational というのは有理数計算を行うための独自に定義したストラクチャー(モジュール)です(Standard MLの標準ライブラリーには有理数はありません)。せっかくなので定義も見てみましょう:

structure Rational :> sig
              eqtype rational
              val fromInt : Int.int -> rational
              val fromIntInf : IntInf.int -> rational
              val makeRational : IntInf.int * IntInf.int -> rational
              val numerator : rational -> IntInf.int
              val denominator : rational -> IntInf.int
              val + : rational * rational -> rational
              val - : rational * rational -> rational
              val * : rational * rational -> rational
              val / : rational * rational -> rational
              val abs : rational -> rational
              val ~ : rational -> rational
              val compare : rational * rational -> order
              val < : rational * rational -> bool
              val <= : rational * rational -> bool
              val > : rational * rational -> bool
              val >= : rational * rational -> bool
              val toString : rational -> string
          end = struct
(* invariant: den is strictly positive, gcd (num, den) = 1 *)
type rational = { num : IntInf.int, den : IntInf.int }
...
end

rational 型の実体は numden というフィールドを持つレコード型ですが、ストラクチャーに不透明な制約 :> をかけているため、ストラクチャーの外からは rational 型の中身にアクセスすることができません。これによって、不変条件(分母と分子が互いに素、分母は正)を満たさない値が構築されるのを防ぐことができます。

IntInf というのはStandard MLの標準で規定された、多倍長整数を提供するストラクチャーです。IntInf の実装はSML処理系にとって必須ではないのですが、あった方が便利なため、他の多くの処理系と同じようにLunarMLでも提供しています(今後実装するオプションで IntInf をカットすることもできるようにするかもしれません)。

現在のLunarMLでは、 export という名前のストラクチャーで定義した値がLua側から見えるようになっています(この仕様は予告なく変更される場合があります)。そこで、 export ストラクチャーを次のように定義します:

structure export = struct
val computeBernoulliNumbers = computeBernoulliNumbers
fun rationalToFrac (x : Rational.rational) : string = let val num = Rational.numerator x
                                                          val den = Rational.denominator x
                                                      in if den = IntInf.fromInt 1 then
                                                             if IntInf.sign num = ~1 then
                                                                 "-" ^ IntInf.toString (~ num)
                                                             else
                                                                 IntInf.toString num
                                                         else
                                                             if IntInf.sign num = ~1 then
                                                                 "-\\frac{" ^ IntInf.toString (~ num) ^ "}{" ^ IntInf.toString den ^ "}"
                                                             else
                                                                 "\\frac{" ^ IntInf.toString num ^ "}{" ^ IntInf.toString den ^ "}"
                                                      end
end

ベルヌーイ数を計算して配列(vector)として返す関数 computeBernoulliNumbers : int -> Rational.rational vector と、有理数をLaTeX形式の分数として文字列化する関数 rationalToFrac : Rational.rational -> string をエクスポートしています。

これを

$ lunarml -mlib seki-bernoulli.sml

という感じでコンパイルすると、 seki-bernoulli.lua が生成されます。-mlibexport ストラクチャーの中身をLua側にエクスポートするためのオプションですが、オプションの名前は今後変更するかもしれません。

早速LuaLaTeX文書から利用してみましょう。次のようなLaTeX文書を書きます:

\documentclass{article}
\usepackage{amsmath}
\usepackage{luacode}
\begin{document}
\allowdisplaybreaks
\begin{luacode*}
local M = require "seki-bernoulli"
local N = 100
local bern = M.computeBernoulliNumbers(N)
tex.print("\\begin{align*}")
for i = 0, N-1 do
  local frac = M.rationalToFrac(bern[i+1])
  local lineterm
  if i == N-1 then
    lineterm = ""
  else
    lineterm = "\\\\"
  end
  tex.print(string.format("B_{%i}&={%s}%s",i,frac,lineterm))
end
tex.print("\\end{align*}")
\end{luacode*}
\end{document}

Luaコードの local M = require "seki-bernoulli" という部分で先ほど生成した seki-bernoulli.lua を読み込んでいます。SMLコードの export ストラクチャーに書いた部分は require の返り値、このコードで言う変数 M のフィールドとして入っています。つまり、 M.computeBernoulliNumbersM.rationalToFrac という形で使えます。

SMLの配列(vector)はLuaからは1始まりの配列(テーブル)として見えます。SMLの文字列はLuaの文字列と同じです。今回は登場しませんが、SMLの関数は常にただ一つの引数を受け取ります。複数の引数を受け取る関数というのはタプル(Luaでいうテーブル)を受け取る関数として表現されます。それらに気をつければあとは普通にLuaコードからSMLの関数を使うことができます。

生成物はこんな感じになります:

多倍長整数も問題なく扱えているようです。

完全なソースコードと、生成された seki-bernoulli.lua (でかい)は ここ (Gist) に置いておきます。

書いていて思ったのですが、この例だと「Standard MLでベルヌーイ数を計算したらそのままLaTeXを出力すれば良くない?LuaTeXの機能を使う必要性は?」という感じがします。複数回処理させるとその分ベルヌーイ数の計算が走って重くなるし……。

というわけで次の例に行ってみましょう。

例2:数式処理もどき

LaTeX文書から数式処理系を呼び出したいという欲望はちょいちょい見かけます。その願い、LunarMLで、外部コマンドの起動なしに叶えてみましょう。

これを実現するには、以下の3つの機能を実装する必要があります:

  • 文字列として記述された数式をパースする
  • 数式をガチャガチャする
  • 生成された数式をLaTeX形式の文字列として書き出す

今回扱う数式の構文木は次のような感じにします:

datatype expr = CONST of Int.int
              | VAR of string
              | NEG of expr
              | ADD of expr * expr
              | SUB of expr * expr
              | MUL of expr * expr
              | DIV of expr * expr
              | SIN of expr
              | COS of expr

四則演算にsin, cosを入れたような簡易的な代物です。本格的な数式処理システムならもっと考えた方が良いのでしょうが、今回はデモなので……。

まず、パースにはパーサーコンビネーターを使うことにします。本当はStandard MLにはML-LexやML-Yaccという(標準的な?)パーサージェネレーターがあるのですが、LunarMLへの移植が済んでいないので、純SMLで書けるパーサーコンビネーターを使います。

今回使うパーサーコンビネーターは、LunarMLに同梱しているplutoという名前のやつです。筆者が作りました。インターフェース的にはHaskellのParsecのパクリです。

これを使うと、パーサーの定義はこんな感じにできます:

val whiteSpace = P.skipMany ((() <$ CP.space) <?> "")
fun lexeme p = p <* whiteSpace
val int : Int.int P.parser = lexeme (List.foldl (fn (x,y) => 10 * y + ord x - ord #"0") 0 <$> P.many1 CP.digit)
val identifier : string P.parser = lexeme (P.try ((String.implode <$> P.many1 CP.letter) >>= (fn name => if name = "sin" orelse name = "cos" then P.fail "reserved name" else P.pure name)) <?> "identifier")
val lparen = lexeme (CP.char #"(")
val rparen = lexeme (CP.char #")")
val expr = P.fix (fn expr =>
                     let val factor = Expr.CONST <$> int
                                                 <|> Expr.VAR <$> identifier
                                                 <|> P.between (lparen, rparen) expr
                                                 <|> (lexeme (P.try (CP.string "sin" >> P.notFollowedBy CP.letter)) >> P.between (lparen, rparen) (Expr.SIN <$> expr))
                                                 <|> (lexeme (P.try (CP.string "cos" >> P.notFollowedBy CP.letter)) >> P.between (lparen, rparen) (Expr.COS <$> expr))
                         fun term1 x = (lexeme (CP.char #"*") >> factor >>= (fn y => term1 (Expr.MUL (x, y))))
                                           <|> (lexeme (CP.char #"/") >> factor >>= (fn y => term1 (Expr.DIV (x, y))))
                                           <|> P.pure x
                         val term = factor >>= term1
                         fun expr1 x = (lexeme (CP.char #"+") >> term >>= (fn y => expr1 (Expr.ADD (x, y))))
                                           <|> (lexeme (CP.char #"-") >> term >>= (fn y => expr1 (Expr.SUB (x, y))))
                                           <|> P.pure x
                     in term >>= expr1
                     end
                 )

数式をガチャガチャする部分ですが、今回は微分を実装してみましょう。先程の構文木に対して、微分は次のように実装できます:

fun diff v = let fun d (CONST _) = CONST 0
                   | d (VAR v') = if v = v' then
                                      CONST 1
                                  else
                                      CONST 0
                   | d (NEG x) = NEG (d x)
                   | d (ADD (x, y)) = ADD (d x, d y)
                   | d (SUB (x, y)) = SUB (d x, d y)
                   | d (MUL (x, y)) = ADD (MUL (d x, y), MUL (x, d y))
                   | d (DIV (x, y)) = DIV (SUB (MUL (d x, y), MUL (x, d y)), MUL (y, y))
                   | d (SIN x) = MUL (COS x, d x)
                   | d (COS x) = NEG (MUL (SIN x, d x))
             in d
             end

パターンマッチが使えるのがLuaに比べたStandard MLの良いところです。

このほか、0による乗算を消したりして数式を簡単にする関数 simplify : expr -> expr も実装します。

エクスポート部は次のようにします:

structure export = struct
val parse = ExprParser.parse : string -> Expr.expr ExprParser.result
val simplify = Expr.simplify : Expr.expr -> Expr.expr
val diff = Expr.diff : string -> Expr.expr -> Expr.expr
val toTeXString = Expr.toTeXString 0 : Expr.expr -> string
end

$ lunarml -mlib expr.mlb という感じでビルドします。標準外のライブラリー(pluto)を利用するため、MLBファイルというものを利用しています。

これを利用するLuaLaTeX文書の例は次のようになります:

\documentclass{article}
\usepackage{amsmath}
\usepackage{luacode}
\begin{document}
\begin{luacode*}
local M = require "expr"
function diff(expr, var)
  local result = M.parse(expr)
  if result.tag == "Ok" then
    local e = M.simplify(M.diff(var)(result.payload))
    tex.print(M.toTeXString(e))
  else
    tex.print("\\text{Failed to parse:"..result.payload.."}")
  end
end
\end{luacode*}
\[\mathrm{diff}(\sin(2x+1),x)=\directlua{diff("sin(2*x+1)","x")}\]
\end{document}

生成物は次のようになります:

それっぽく計算できているようですね。

完全なソースコードと生成された expr.luaここ (Gist) に置いておきます。

その他まだ実現してないやつ

本当はソースコードのシンタックスハイライトみたいなやつもやりたかったんですが時間不足でできませんでした。

ちなみに、Lua側からStandard MLの関数を呼び出すだけではなく、Standard ML側からLuaの関数を呼び出すこともできます。詳しくは LuaInterface.md を参照してください。

余談:LuaTeX以外のTeXでStandard ML on TeXはできないのか

LunarMLではLuaをターゲットにしていますが、将来的にはJavaScriptなどの他の言語もターゲットにしようと考えています。ここで仮に直接TeX言語を出力できるようにすればLuaTeX以外でもStandard MLで書かれたプログラムを実行できるようになります。果たしてそれは可能なのでしょうか。

先行事例として、ELVM (EsoLangVM)というのがあります。ELVMはC言語(など?)を入力とし、Brαinf*ckをはじめとして数多くの言語のバックエンドを持ちます。その中にはTeX言語のバックエンドもあります(生成物は文書中から利用できる代物ではなさそうですが)。

要するに、適当な抽象機械を想定してやり、その実行器をTeX言語で実装し、その抽象機械向けのコンパイラーバックエンドを用意してやれば理論上は可能でしょう。しかし、C言語とは異なりStandard MLを動かすにはGC等のメモリー管理システムや例外処理などのランタイムをTeX上で実装する必要があります。どれだけ腕力が要るのか、そこまでしてやる意味があるのか、という話になってきます。

(ちなみに、最近出た「コンパイラ 原理と構造」には関数型言語のコンパイルターゲットとしてSECD機械という抽象機械が設定されています。)

まあ、妄想するだけなら楽しいよね、という話です。

【追記】@bd_gfngfnさんが去年のAdvent Calendarにそういう話を書いておられました。私も読んでいたはずなのですが、完全に忘却していました。

まとめ

Standard MLで書いたプログラムをLunarMLでコンパイルしてLuaTeXで利用してみました。今回動かしたプログラムは簡単なものですが、創意工夫あるいは腕力次第でもっと実用的なプログラムを動かせるようになるでしょう。

LunarMLはまだまだ発展途上のプロジェクトです。引き続き開発していきたいと思っているので、面白そうと思われた方は(GitHubスター等で)応援よろしくお願いします。


コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です