独学Common Lisp

String Dictionary

概要

Perlは文字列処理のために開発された言語なので文字列操作がとても簡単ですが、Common Lispはより広い汎用言語ですので、文字列処理に特化しているわけではありません。一見、冗長な手続きもありますが、一回覚えてしまうとそれほど難しくはありません。

ここではCommon Lispの文字列処理を紹介します。なお、Common Lispの正規表現処理はCL-PPCREというサードパーティ製ライブラリで行います。このライブラリは大変高速で、Perlの正規表現よりも高速と聞いたことがありますが、言語仕様の標準ではないのでこのページの対象外とします。実用においては正規表現ライブラリの使用を検討してください。

出力(format)

文字列の出力には多くの関数が用意されています。もっとも強力なのはformatです。Minimum Common Lispのファイルと入出力、文字列の扱いにある程度書きましたので、そちらを参照してください。

(format t "~:d~%" 35000)
35,000
; => NIL

(format t "~{~,3f~%~}" '(3.1415 2.71828 1.235813))
3.141
2.718
1.236
; => NIL

たいていの出力には~aで対応できます。小数点以下の出力制御は~,Nfを使います。改行は~%で行います。

便利なのは~:dです。~dだけだと整数表示ですが、コロンを付けると3桁区切りができます。また、~{~}で囲むと引数であるリストに対して繰り返し処理をします。

(format t "~:{~a ~a ~a~%~}" '((1 2 3) (4 5 6) (7 8 9)))
1 2 3
4 5 6
7 8 9
; => NIL

ここまで高度な出力処理が標準で用意されるべき理由というのはあまり見当たりませんが、~:{のようにコロンをつけると二次元リストに対しても繰り返し処理をすることができます。

なお、tの部分をnilにすると文字列データとして出力します。また、ストリームを指定することもできます。

出力(print, princ, write-line)

format以外にも様々な出力関数が用意されており、全ては紹介できませんが、3つ紹介します。

write-lineは改行付きで文字列を出力します。

(write-line "Hello World!")
Hello World!
; => "Hello World!"

出力対象は文字列限定で、第2引数にストリームを指定することもできます。

printは文字列に限らず、あらゆるデータを出力するCommon Lispのライターです。リーダーで読み取れないもの以外は、改めて読み込めるような書式で出力します。

(print "Hello World!")

"Hello World!"
; => "Hello World!"

printは後ろではなく手前に改行を出力します。また、文字列はダブルクォートで囲まれます。

(defvar napier 2.71828d0)
(print napier)

2.71828D0
; => 2.71828D0

数なども再現可能な表記で出力します。d'double-float型を示します。

princは全てをそのまま出力します。

(princ "Hello World!")
Hello World!
; => "Hello World!"

文字列はダブルクォートで囲まれません。

連結(concatenate)

文字列の連結はconcatenateで行います。ただし、この関数はどのような型で連結するかを指定しなければなりませんから、'stringを指定します。

(concatenate 'string "Hello" "World")
; => "HelloWorld"

別の型で連結することもできます。

(concatenate 'list "Hello" "World")
; => (#\H #\e #\l #\l #\o #\W #\o #\r #\l #\d)

(concatenate 'vector "Hello" "World")
; => #(#\H #\e #\l #\l #\o #\W #\o #\r #\l #\d)

全く使ったことはありませんが、concatenateを使うとPerlの文字列繰り返しオペレータxのようなものも簡単に作れます。

(defun x (string n)
  (loop
     repeat n
     with rslt
     do (setf rslt (concatenate 'string rslt string))
     finally (return rslt)))

(x "Hello" 5)
; => "HelloHelloHelloHelloHello"

区切り文字で連結(format)

リストの要素を区切り文字で連結する、というのはよくあると思います。特にCSVとして出力する場合などに使います。

Common Lispでは直接的にそのような関数があるわけではありませんが、出力用のformatが高機能なので、全く同じようなことをすることができます。

(format nil "~{~a~^:~}" '("2017" "01" "29"))
; => "2017:01:29"

なぜ最後の29の後ろに区切り文字:が付かないかというと、formatのループ~{ ~}内で~^を使うと、リストの要素に対して全て出力した後、その場所でループを脱出することができるからです。なので、29を出力した直後にループを脱出するため、最後の区切り文字は出力されません。

一回一回formatのテンプレートを作るのが面倒であれば、自分でjoinを実装しましょう。

(defun join (dlim list)
  (format nil (concatenate 'string "~{~a~^" dlim "~}") list))

(join ":" '("2017" "01" "29"))
; => "2017:01:29"

concatenateformatのテンプレート部分を構築した後、formatのループと脱出を使って区切り文字を挿入して文字列全体を構築します。concatenateは文字列の連結に使うことができますが、文字の連結には使えないので、#\:ではなく":"で与えています。#\:のように文字を指定したければ、string関数を使ってdlimを加工すれば使うことができます((string dlim))。

文字列長(length)

文字列はシーケンスの一種であり、リストやベクタなどと同じように文字列長の取得にはlengthを使えます。

(length "Hello")
; => 5

Common Lispにおける文字列は文字の列であり、バイト列とは関係がありませんので、たとえ日本語のようなマルチバイト文字列でも気にせず使うことができます。

(length "独学")
; => 2

切り出し(subseq)

文字列のようなシーケンスの部分を切り出すにはsubseqを使います。

(subseq "Hello World" 6 11)
; => "World"

あまり使われないと思いますが、アクセッサとして使うことで、切り取る文字列の長さ以下なら置換もできます。

(defvar str "Hello World")
(setf (subseq str 6 11) "Japan")
str
; => "Hello Japan"

検索(search)

文字列の中から文字列を検索するのも、searchというシーケンス汎用関数が使えます。

(search "ll" "Hello World")
; => 2

ファイルに文字列が含まれるかどうかを調べる関数は以下のように簡単に実装できます。

(defun file-search (file string)
  (with-open-file (in file)
    (loop
       for line = (read-line in nil)
       while line
       when (search string line) return t
       finally (return nil))))

loopマクロのfinally節はreturn節で脱出した時には実行されません。ここではwhen節が真の時は脱出するためt、最後まで脱出しない時はnilになります。

文字の削除(remove, string-trim)

文字列の中から文字を削除するにはremoveを使います。

(defvar str (format nil "Hello~%World~%"))
(remove #\newline str)
; => "HelloWorld"

removeは複数回含まれる場合も全て削除します。最初、または最後、または最初と最後だけを削除したい場合、つまり、行末の改行文字だけを削除したい場合などはstring-trimファミリーを使います。

(defvar str (format nil "~%Hello~%World~%"))
(string-trim '(#\newline) str)
; => "Hello
     World"

(string-right-trim '(#\newline) str)
; => "
     Hello
     World!"

(string-left-trim '(#\newline) str)
; => "Hello
     World
     "

string-trimファミリーは第一引数をcharacter-bagとして取ります。これは仕様によれば「文字を含むシーケンス」となっており、以下の3通りが対象です。

文字列については「文字列」として削除対象になるのではなく、あくまでも「文字の集合」として扱われるので注意してください。

(defvar str "Hello World")
(string-trim "Hd" str)
; => "ello Worl"

なお、最初や最後だけでなく、全てを削除したいが、removeのように一文字だけではなく複数の文字を削除したいという場合は、removeとループを用いて簡単に実装することができます。

(defun remove-chars (char-list string)
  (dolist (c char-list)
    (setf string (remove c string)))
  string)

(remove-chars '(#\e #\o) str)
; => "Hll Wrld"

また、改行文字#\newlineにリターン#\returnが含まれるかどうかは処理系やOSによって異なります。通常、read-line関数は改行文字を取り除いて文字列を返してくれますが、Windowsで作ったテキストファイルをUnix/LinuxのSBCLで読み込む場合などは#\returnが付いていることがあります。そのような場合はstring-trimstring-right-trimを用いて改行文字を削除します。

文字の置換(substitute)

文字列の中に含まれる文字を置換するには、substituteが便利です。これもシーケンス汎用関数です。

(substitute #\space #\: "2017:01:29")
; => "2017 01 29"

ちなみに、substitutesubstitute-ifという高階関数版もあり、文字列処理ではなく統計的処理でも役に立ちます。例えば、マイナスのデータが混じっている時に0に置き換えて処理したい場合は、以下のようにします。

(defun negativep (num)
  (< num 0))

(substitute-if 0 #'negativep '(1 2 -5 3 4 -2))
; => (1 2 0 3 4 0)

もう少し複雑な置換であればmapmapcarを用いますが、この程度であればsubstitute-ifが直感的です。

文字列の置換

Common Lispに用意されている文字列置換関数replacesetfsubseqを組み合わせたようなものに近く、あまり使い勝手が良くありません。そこで多くの場合はCL-PPCREを使うのだと思いますが、searchと再帰を用いることで簡単に置換関数を実装することができます。

(defun string-replace (new old string)
  (let ((len (length old)))
    (labels ((repeat (rslt)
               (let ((pos (search old rslt)))
                 (if pos
                     (repeat (with-output-to-string (out)
                               (write-string rslt out 
                                             :end pos)
                               (write-string new out)
                               (write-string rslt out 
                                             :start (+ pos len))))
                     rslt))))
      (repeat string))))

(string-replace "Lisper" "World" "Hello World World")
; => "Hello Lisper Lisper"

write-string関数は文字列を出力する関数であり、with-output-to-stringマクロと共に使うことで文字列の構築に利用できます。write-stringは出力の開始位置と終了位置を:startキーワードと:endキーワードで指定することができます。searchで取得した位置までは元の文字列を出力し、次は大体文字列を出力し、置換対象文字列が終わったところからまた元の文字列を出力していきます。

このような処理を再帰的に行うことで全ての置換対象文字列の置換を行うことができますが、一度に全ての対象文字列を置換するのではないため、効率はあまりよくありません。長い文字列で、しかも置換対象文字列が何度も出現する場合などはCL-PPCREの使用を検討してください。

分割(read-from-string)

文字列の分割は直接的な関数が用意されていませんので、CL-PPCREsplitを用いるか、SPLIT-SEQUENCEを用いるかが多いと思います。

ただ、複雑ではない分割であれば、Minimum Common Lispで示した通り簡単に実装することができます。

(defun string-split (dlim string)
  (read-from-string
   (format nil "(~a)" (substitute #\space dlim string))))

(string-split #\: "2017:01:29")
; => (2017 1 29)

この自作分割関数string-splitは、例えば2017::29のような場合にはうまく対応できません。欠損データを読み飛ばしたい場合はこのままで構いませんが、欠損データを欠損データとして読み込みたい場合は何かシンボルを入れておきます。

(string-split #\: "2017:NA:29")
; => (2017 NA 29)

なお、read-from-stringは何文字読み込んだかという文字数も多値で返します。

文字列とバイト列

Common Lispにおいて文字列は文字の列(ベクタ)であり、文字コード上の対応する数字をバイトで表記したバイト列とは直接関係がありません。しかし、文字コードの変換を行う場合など、バイト列を直接操作したい場合があるかもしれません。

Common Lispの文字コード処理については処理系依存ですが、Babelというデファクトスタンダードなライブラリがありますので、基本的にはそちらを利用します。

Babelを使わずに低水準の操作を行う場合、以下のような方法が考えられます。

  1. ファイルなどのストリームから文字列を文字コード指定で入力・出力する場合、:external-formatキーワードによる文字コードの指定が利用できます。このこと自体は仕様の標準ですから、どの処理系でも問題なく使うことができます。
  2. ただし、どのように文字コードを指定するかは処理系によって異なります。UTF-8は大抵:utf-8ですが、Shift_JISは:sjis:cp932charset:sjisなど異なる場合があります。また、改行コードを指定できる場合もあれば、指定できない場合もあります。
  3. 文字列とバイト列との相互変換についても、各処理系で関数名が異なります。

「オクテット」という言葉は8ビットを指します。「バイト」は現在は8ビットですが、歴史的に様々なビット数で用いられてきました。

Babelを用いるとこのような処理系依存の違いを吸収できますが、ライブラリを用いることができないような場合は参考にしてください。


Copyright © 2017- satoshiweb.net All rights reserved.