6章 文字列を要素に分解する --データベースもXMLも正規表現も共通原理は同じ-- ●この章の概要 この第6章では、どんな文書処理でも最も重要な操作のひとつである「文書中から必要なデータ要素を正しく取り出す」という操作の方法を説明する。それらの方法は、(1)Stringオブジェクトに対する比較的機械的な操作、(2)一定のアルゴリズムに基づいて行う操作、(3)辞書を参照しながら行う操作、以上3種類に大別される。 この章をマスターした読者は、今後どんな形式の文書ファイルやデータファイルに出会っても、悠々対処できるようになる。 そもそも、われわれがコンピュータを使って人間の力によるよりも便利に操作/処理したいと願う文字列、文字の列は、何だろうか?。言い換えると、「一つの文字列」というものは、何を表しているのだろうか?。 いろんな分野の実験データや観測データが、リアルタイムで分析されたり、あるいは後で分析するためにファイルに記録されるようなときは、“一つの文字列が表しているもの”は非常に千差万別であり、それどころか、物理的に何をもって一つの文字列と見なすかという基準すら普遍的には定まっていない。 そういう、非常に多様な自然科学のデータ分野を今回はとりあえず脇に置いて、日常的な文書データに限定して考えるならば、まず、 一つの文字列とは、 (1)最初の入力文字から最初の改行文字までの文字の列 <入力初端>-----------------------------------------------------------<改行文字> または、 (2)改行文字の直後の文字からその次の改行文字までの文字の列 直前の<改行文字>-----------------------------------------------------<改行文字> そして、ときには、一回の入力単位において、 (3)最初の入力文字から最後の入力文字まで(入力の全体) (たとえばファイル全体, 最初から最後まで) あるいは、 (3)'最後の改行文字から最後の入力文字まで 最後の<改行文字>-----------------------------------------------------<入力終端> と定義できるだろう。上の(3)や(3)'のケースは、入力単位の末尾に改行文字が付いていない特殊なケースであり、そんな文字列を実際にプログラムで取り扱うときには、改行文字を付けたほうが、ほかの文字列と共に統一的に扱えるので便利だ。本書で何度も繰り返し述べているように、コンピュータによるデータ処理〜文書処理を効果的に行おうと思ったら、原データ〜原文書を事前に手作業で整形することが何よりも重要である。 原データの、ありとあらゆる不規則性、不定形性に見事に正しく対応できる見事なプログラムを作ろう!、なんて疲れる野心は持つべきでない :)。 末尾の改行文字を終端マークとする一つの文字列は、その性質次第で、さらにそれを複数の文字列に分解することができる。 今後、文字列をめぐる記述がややこしくなるおそれがあるので、改行文字を終端マークとする文字列のことを、仮に『原始文字列』または『一入力単位文字列』と呼ぶことにしよう。さて、そこで、その原始文字列を構成要素に分解するとしたら、次のことが言えるだろう: 1.原始文字列がテキストの1パラグラフならば、それは、 (1)複数のセンテンス(文)に分解できる。 さらにそのセンテンスは、 (1)-(1)複数の語句や品詞に分解できる。 2.原始文字列がデータ集合の1レコードならば、それは (1)複数のデータ項目に分解できる さらに個々のデータ項目を、 (1)-(1)複数の部分データに分解できることもある。 3.原始文字列がデータ集合の1レコードの1データ項目ならば、 (1)それをさらに複数の部分データに分解できることもある。 [パラグラフとその分解の例] 山間の田んぼに一本足の案山子が立っていた。案山子は、蓑笠を着せられていた。しかしその日その人気ない谷間は、雨の気配もなく、真夏の晴天の炎暑に焼かれていた。明るすぎる光を、案山子は耐えかねていた。 [複数のセンテンスに分解] 山間の田んぼに一本足の案山子が立っていた。 案山子は、蓑笠を着せられていた。 しかしその日その人気ない谷間は、雨の気配もなく、真夏の晴天の炎暑に焼かれていた。 明るすぎる光を、案山子は耐えかねていた。 [センテンスを語句や品詞に分解] 山間 の 田んぼ に 一本足 の 案山子 が 立っ て い た 。 (以下略) [レコードとその分解の例] 1011085:草壁絽美:クサカベロミ:19860216:東京都品川区上大崎5-80-45:03-5555-9876 [データ項目に分解] 顧客番号 1011085 氏名 草壁絽美 ふりがな クサカベロミ 生年月日 19860216 住所 東京都品川区上大崎5-80-45 電話番号 03-5555-9876 [データ項目を要素に分解] 姓 草壁 名 絽美 [データ項目とその分解の例](オリジナルが1行=1データ項目である場合) 北海道紋別郡西興部村字西興部738 [データ項目を要素に分解] 都道府県名 北海道 郡名 紋別郡 市町村名 西興部村 町名番地 字西興部738 さて、以上の例で見てきたように、原始文字列を要素に分解する方法は、基本的には二種類ある: (1)要素の区切り記号がある場合は、その記号のところで切りわける。 (2)要素の区切り記号がない場合は、辞書を参照して切りわける。 また、(1)でも(2)でもない特殊な状況(区切り記号もない、辞書もない状況)では、原データが持っている法則性をよく見極め、それらの法則性を自作のアルゴリズムとして実装することにより、正しい要素分解〜要素の切り出しに努めなければならない。 (1)の例としては、たとえば、日本語の文章のパラグラフは、句点‘。’によって文(センテンス)に分解することができる。また、上で見た簡単なデータベースのレコードは、各データ項目がコロン':'によって区切られているので、コロンに着目して各要素=各データ項目を取り出していける。このような場合は、上の(1)のケースに該当する。 一方、“北海道紋別郡西興部村字西興部738”のような住所データから、各要素、たとえば都道府県名を取り出すためには、「北海」でもなく「北海道紋」でもなく、まさに「北海道」が都道府県名である、という判断を、人間ならば脳内の辞書、コンピュータならばディスクやメモリの上の辞書に依存して行わなければならない。また“山間の田んぼに一本足の案山子が立っていた。”という文を品詞に分解する処理も、辞書を使って行わざるをえない。これらはすべて、上の(2)のケースに該当する。 次以降の節では、主に、(1)のケース、すなわち原始データ中に要素の区切り記号がある場合の処理を見ていこう。 6.1 Stringの操作による要素取り出し 文字列中の各要素を区切っている区切り記号〜区切り文字(ときには区切り文字列)のことを、コンピュータ用語でデリミタ(delimiter,区切り文字)と呼ぶ。本書でも今後、デリミタという用語を使っていこう。 4章で、例題プログラムの一つとして、簡単なインデクス作成プログラムを見た(リスト4.8のMakeIndex.java)。このプログラムを、テスト用サンプルの4つのテストファイルに対して、次のように実行すると: java MakeIndex 来た test 「来た.idx」という名前のインデクスファイルが出来上がった。この、来た.idxファイルの各行は、次のような形をしている: 【一つのインデクスデータ】 aki.txt:3:0,2,8,13, すなわち、ご覧のようにこのインデクスデータは、「ファイル名」「行番号」「行内インデクス」という三つのデータ項目が、コロン':'によって区切られている。また、「行内インデクス」という最後のデータ項目は、一つ々々の行内位置情報(インデクスの値)をカンマ','で区切っている。そこで上のインデクスデータは、「“来た”という語句が、ファイルaki.txtの3行目の、冒頭、3文字目、9文字目、そして14文字目にある」という情報を表している(Stringのインデクスは0からはじまる)。 このような原始文字列から、個々のデータ項目を取り出すためには、Stringクラスのメソッドを利用できる: 【Stringのメソッドを利用して要素を取り出す】 //実際には、ファイルから読み出した1行 String line = "aki.txt:3:0,2,8,13,"; String fileName; String lineNumber; String indexes; int colonPos1 = line.indexOf(':'); //最初のコロンの位置 fileName = line.substring(0, colonPos1); //"aki.txt" int colonPos2 = line.indexOf(':', colonPos1 + 1); //次のコロンを見つける lineNumber = line.substring(colonPos1 + 1, colonPos2); //"3" indexes = line.substring(colonPos2 + 1); //"0,2,8,13,"; 上に示したコード例では、各データ要素に最初から名前(fileNameなど)を付けて、その名前のString変数にデータ要素を取り出している。このような方法は、最も素朴な方法であり、実際には、whileループなどを使って次々と機械的に要素を取り出していったり、あるいは各要素をStringの配列やVector, ArrayListなどに収めてから、やおら各要素を取り出す、という方法を採ることが多い。 また、上の例では、indexesという名前のStringが"0,2,8,13,"という形の文字列を指すことになったが、これをさらに、カンマ','というデリミタに着目して各要素…文字位置情報…を取り出すことができる。それらの取り出しを、whileループを使う方法でやってみよう: int frontPos, commaPos, inlineIndex; String indexes = "0,2,8,13,"; frontPos = 0; //まず冒頭を指す commaPos = indexes.indexOf(','); //最初のカンマを見つける while (commaPos != -1){ //カンマがあるかぎりループする inlineIndex = Integer.parseInt(indexes.substring(frontPos, commaPos)); ... //このインデクス値に基づく何らかの処理をここに書く frontPos = commaPos + 1; //次を指す commaPos = indexes.indexOf(',', frontPos); //次のカンマを見つける } なお、上のコード例で、数字文字列を整数値に変換するInteger.parseInt()メソッドは、引数の文字列が数値に変換不可能な文字列(たとえば“xyz”)だったときに例外NumberFormatExceptionを投げるから、実際にはこの呼び出しをtry/catch節で囲まなければならない。Javaの良いところは、try/catch節による例外の捕捉の記述をプログラマが怠ると、コンパイラが絶対に見逃してくれないことだ!。 このように、デリミタというものが明白に存在する文字列から各データ要素を取り出す処理は、Stringとそのメソッドを使っても十分にできる。しかし、このような方法は、まずプログラムの外見が煩雑になるし、また、前の5章の終わりのほうで見たように、多量のStringを操作する方法はパフォーマンスも非常に悪い。 そこでJavaは、うんと初期のバージョン1.0のころから、StringTokenizerというクラスを提供して、デリミタのある文字列からの、データ要素の容易な取り出しをサポートしている。次の節では、そのStringTokenizerの使い方を見よう: 6.2 StringTokenizerを使ったデータ要素の取り出し StringTokenizer(java.util.StringTokenizer)は、Enumeration(java.util.Enumeration)というインタフェイスをimplementsしたクラスだ。 enumerationは、日本語で“列挙”とか“列挙型”と訳される。なにしろ、線型にずらーと並んだデータを、一つずつ次々と取り出せるようにしたオブジェクト、それがJavaのEnumerationオブジェクトだ。その機能は、Javaに後から加わったIteratorとたいへんよく似ている。 Iteratorは、Javaのデータ構造オブジェクトに最初から備わっているが、Enumerationを使うためには、これをimplementsしたクラスを新たに定義しなければならない/新たに定義できる。Enumerationをimplementsしたクラスを定義するときは、このインタフェイスのhasMoreElements()メソッドとnextElement()メソッドを実装しなければならない。 hasMoreElements()メソッドは、このEnumerationオブジェクトの中にまだ未取り出しのデータ項目があればtrueを返し、もう最後まで取り出してしまっていて残りがなければ、falseを返す。 nextElement()メソッドは、呼び出されるたびに、このEnumerationオブジェクトの中のデータ項目を次々と取り出して呼び出し側へ返す。 StringTokenizerも、もちろんEnumerationインタフェイスのこの二つのメソッドを実装している。StringTokenizerのnextElement()メソッドは、デリミタで区切られた各データ要素を順に次々と返す。StringTokenizerは、これら二つのほかにも、いくつかの便利なメソッドを備えており、通常はそれらのメソッドを使ったほうが楽だ。 StringTokenizerを使うためには、そのコンストラクタに渡す引数で、対象となる原始文字列とデリミタを指定する。たとえば: StringTokenizer st = new StringTokenizer(line, ":"); それから、hasMoreElements()メソッドやhasMoreTokens()メソッドでデータがまだあることを確認しながら、nextElement()メソッドで各データ要素を取り出していく。おっと、StringTokenizerを使うときは、Objectを返すnextElement()よりも、Stringを返すnextToken()メソッドを使うべきだ: String tok; while (st.hasMoreTokens()){ //hasMoreElements()でもよい tok = st.nextToken(); //次のトークンを取り出す ... //tokを使う何らかの処理をここに書く } このように、StringTokenizerの使い方はきわめて簡単だ。 ★★★トークンとは何?★★★★★ tokenを英和辞典で引いてみると、いろんなことが書いてある。コンピュータ用語としてのトークンは、「処理システムに対して意味のある最小のデータ単位」だ。たとえば、住所を処理するプログラムにとっては“北海道紋別郡西興部村字西興部738”がトークンだ。都道府県名や郡名や市町村名を処理するプログラムにとっては、“北海道”や“紋別郡”などがトークンだ。Javaのコンパイラにとっては、上のコードの中の、String、tok、whileなどがトークンであり、';'や改行やスペースはデリミタだ。 StringTokenizerの、三引数のコンストラクタを使うと、「デリミタもトークンとして返せ」ということを指定できる: StringTokenizer st = new StringTokenizer(line, ":,", true); そうすると、nextToken()がデリミタも返すから、上の例のように複数種類のデリミタ":,"を指定し、その後、処理中にデリミタの種類をチェックすることによって、その前後のデータ項目の性質を判断できる。上の例では、デリミタが'':'でなく','だったら、インデクス情報の切り出しにさしかかっている。またたとえばソースプログラムを読んでいくJavaのコンパイラは、デリミタが';'だったら、「文の終わり」と解釈する。 コンストラクタに渡す引数で、分解の対象となる文字列だけを指定したときには、デフォルトで、スペース文字、タブ、および改行がデリミタと見なされる。 ★★★★★★★★★★★★★★★★ nextToken()を呼び出すとき、引数を渡すと、「次からデリミタはこの文字だよ」ということを指定できる。たとえばコロンをデリミタと指定してデータ要素を取り出していて、ある時点からはカンマをデリミタに指定したければ、次のような呼び出しを書く: tok = nextToken(","); この方法を使うと、複数のデリミタを使っている複雑な原始文字列でも一つのStringTokenizerで処理できる。しかし、それよりも、デリミタの違いによってデータ項目の意味や性質がはっきり違うときには、複数のStringTokenizerを多段的に使うほうが、プログラムが見た目に分かりやすくなる。たとえばこのようにする: String line = "aki.txt:3:0,2,8,13,"; String fileName; String lineNumber; String indexes; String Tokenizer st = new StringTokenizer(line, ":"); //コロンがデリミタ ... ... //最後にindexesトークン"0,2,8,13,"が得られる /*indexesのためのStringTokenizerを作る*/ st = new StringTokenizer(indexes, ","); //今度はカンマがデリミタ ... //indexesから各要素を取り出していく 2002年に発表されたJavaのバージョン1.4、今の呼び方でJ2SE 1.4では、正規表現という新兵器が導入された(java.util.regexパッケージ)。正規表現は文字列の「パターン」を指定するための仕組みだから、「最後にカンマがある」とか「最後にコロンがある」といった、デリミタの付随する単位文字列のパターンも容易に指定できる。 とくに、StringTokenizerではデリミタとして文字(一種類または複数種類)しか指定できないが、正規表現を使えば文字列やパターンすらデリミタ…または、取り出すべきデータ要素の形…として指定できる。 次の節では、StringTokenizerの代わりに正規表現を使ってデータ要素を取り出すやり方を見よう: 6.3 正規表現を使ってデータ要素を取り出す 6.3.1 Stringのsplit()メソッド Javaが正規表現をサポートするようになってから、「原始文字列をデリミタで区切られたデータ要素に分解する」という処理はきわめて簡単になった。そのためには、Stringクラスのsplit()を使う。たとえばこのように: String[] toks; //Stringの配列 String line = "aki.txt:3:0,2,8,13,"; toks = line.split(":"); これで、配列toksの内容は: {"aki.txt", "3", "0,2,8,13," } になり、配列の各要素をforループやtoks[2]などの記法を使って取り出せる。 このように、Stringのsplit()メソッドを使うと、1つのレコードを構成するデータ要素をすべて配列の中に一度に取り出せる。この特徴には、後で述べるようなデメリットもあるが、レコード中の任意のデータ要素(例:3番目のデータ要素=顧客の累積お買い上げ額)をすぐに調べられるメリットもある。また、特定の条件を満たすレコードを別のデータ構造中に蓄積したり、複数の条件を満たすレコード集合間でAND(論理積)やOR(論理和)の操作もやりやすい。さらに、split()が返す配列中のデータ要素の一部を書き換えて元のデータファイルに書き戻す操作をすれば、いわゆる「データベースの更新処理」も簡単に実現する。より高度なデータファイル操作のためには、java.nioパッケージとそのサブパッケージが提供しているクラスを有効活用しましょう。 それにまた、split()メソッドが返す配列成分の操作へのユーザインタフェイスとして、Swing(javax.swingパッケージ)のGUIコンポーネントを並べた出力画面と入力画面を作れば、自家製データベースの、素朴な初期的バージョン--これからあなたが育てていくバージョン--が完成する。 split()メソッドに渡す引数は正規表現であるが、正規表現のための特殊記法をまったく使っていない単なる文字や文字列も、‘その文字ないし文字列そのもの’として解釈される正規表現の一種だ。だから、上のようにコロン文字そのものを指定している呼び出し方も、split()の使い方として正しい。 split()メソッドの引数を二つ取るバージョンでは、第二引数で“最大幾つまで分割せよ”という、分割回数を指定できる。ただし、このバージョン、 String[] split(String regex, int limit) のsplit()メソッドのlimit引数としては、「分割したい回数+1」を指定しなければならない。そこでたとえば: toks = line.split(":", 2); と書くと、分割は2-1=1、すなわち1回だけ行われ、配列toksの内容はこうなる: {"aki.txt", "3:0,2,8,13,"} ご覧のように、行番号3とそれに続く行内インデクス情報は、分けられていない。 デリミタとして特定の文字列(たとえば"###")を指定するときは、 toks = str.split("###"); でよい。 複数種類のデリミタを指定するときは、正規表現の特殊文字の一つである' |'を使用する(' |'は「または」を意味する): toks = line.split(":|,"); なお、正規表現の特殊文字として使われる文字を、その文字そのものとして指定したいときには、文字の前にバックスラッシュを書く: toks = str.split("\ |"); //' |'がデリミタの場合 デリミタ文字を複数指定するとき、こんな指定も可能だ: toks = str.split("[:,\\s]"); // ':'と','とホワイトスペース文字 // '\\s'という書き方については後述 このほか、なにしろsplit()メソッドの引数は正規表現なので、StringTokenizerにはできない多様なデリミタの指定が可能だ。たとえば次のは: toks = str.split("[0-9]+"); //1個以上の数字の列 原始文字列strの中で、任意の数列が各データ項目を区切っている(デリミタとして使われている)場合だ。ただし、この場合は、データ項目中には数字があってはならない。 それではここで、原始文字列をトークンに分解する、ここまで見てきた三つの方法:(1)Stringの従来的なメソッドを使う、(2)StringTokenizerを使う、(3)正規表現とsplit()メソッドを使う、を実装したプログラムを見て、それを実際に動かしてみよう(リスト6.1)。 【リスト6.1 Tokenizing.java】 リスト6.1のTokenizing.javaプログラムは、ここまで述べてきたこと、およびコード例で示してきたことを、実際に動くプログラムとしてまとめたものである。一つのプログラムの中に上の三つの方法を実装しているので、プログラムを動かすとまったく同じ出力が三回繰り返される。 さてさて、Stringのsplit()メソッドはこのように使い方がとても簡単なので、もうStringTokenizerは物置の奥へ放り込んでしまいたくなるかもしれない。 しかし、split()メソッドの欠点といえば、最初に、すべてのデータ要素を収めた配列ができてしまうことだ。プログラマは、次に、その配列の各要素をforループなどを使って一つずつ読む処理を書かなければならない。データ量が膨大なときは、メモリ上に配列ができてしまうこの方法を、使えないかもしれない。また、パーシング(parsing, parse, 構文解析)などが目的のときには、すべてのデータ要素が最初から揃っていなくてもよい。各データ要素が、順次、分かればよい。 それでは、StringTokenizerのnextToken()メソッドのように、順次一つずつ、原始文字列中のデータ要素を取り出す処理は、正規表現を使う方法ではどう書けばいいのだろう。では、次にそのやり方を見ておこう。 6.3.2 データ要素の正規表現による逐次的取り出し 一つの原始文字列の中に、一つの正規表現にマッチする部分が複数個ある(かもしれない)とき、それらのマッチ部分をStringTokenizerのnextToken()メソッドみたいにシーケンシャルに次々と取り出すためには、次のようなコードを用いる: /*マッチ文字列を次々取り出す標準的なコード*/ String matchedString; String targetString = "aki.txt:3:0,2,8,13,"; String regexString = "[ ^:]+:"; Pattern pat = Pattern.compile(regexString); // (1) Matcher mat = pat.matcher(targetString); // (2) while (mat.find()){ // (3) matchedString = mat.group(); // (4) ... //取り出したmatchedStringを使う何らかの処理をここに書く } 上に示した定型的なコードでは、変数matchedStringに、正規表現にマッチした部分文字列を参照させることにしている。そしてその次のtargetStringが、マッチングの対象となる原始文字列だ。マッチを試みるパターンは、regexStringが指す正規表現文字列である。 上のコードは、Javaの正規表現ファシリティ(facility,便益)を使うときのコードの標準的な形だ。 まずPatternクラスのstaticメソッドcompile()にパターン文字列(正規表現文字列)を与えて、JavaのPatternオブジェクトを作る(1)。こうして、正規表現を表している単なる文字列がPatternオブジェクトになることによって、パターンマッチングに関する重要なメソッドをいくつか使えるようになる。たとえば、前の節で使ったStringクラスのsplit()メソッドも、元々はPatternクラスにあるメソッドなのだ。 Patternオブジェクトを作ったら、次に、そのオブジェクトからmatcher()メソッドを呼び出すことによって、目的文字列(ここではtargetString)とのマッチングを行うMatcherオブジェクトを作る(2)。java.util.regexパッケージにあるクラスは、例外を表現するPatternSyntaxExceptionをのぞけば、PatternとMatcherの二つだけなのだ。だから、使い方を理解するのも、そんなに長時間はかからない。 次に、whileループの条件節の中でMatcherのfind()メソッドを呼び出して、「このあとにまだマッチ文字列はあるか?」ということを確認する。マッチ文字列がある間、ループの処理を続ける(3)。 そしてループの各回で、Matcherのgroup()メソッドを使ってマッチした文字列を次々と取り出していく(4)。マッチした文字列を取り出すこのメソッドに、groupという妙な名前が付いている理由は、すぐ後で読者のみなさまにも分かる。 上のコードで示した、(1)から(4)までの手順は、何度も言うとおり、Javaの正規表現パッケージ(java.util.regexパッケージ)を使うときの標準的な方法であり、正しい方法なのだ。それでは、これを実際にプログラムとして書いて動かしてみよう(リスト6.2)。そうすると、何か変だ: 【リスト6.2 WrongRegxTokens.java】 リスト6.2のWrongRegxTokens.javaプログラムは、名前に'Wrong'と付けたように、このままでは原始文字列からトークンを正しく取り出せない。対象となっている原始文字列は、次のものである: String targetString = "aki.txt:3:0,2,8,13,"; そして、トークンを切り出すために指定した正規表現パターンは、次のものだ: String regexString = "[ ^:]+:"; この正規表現文字列は、「コロンではない文字([ ^:])が1個以上ある(+)」、そして「最後にコロンがある(:)」というパターンを指定している。したがって、このパターンにマッチする部分文字列は、目的とする aki.txt ではなくて、 aki.txt: となる。しかし、われわれの目的としては最後のデリミタ文字':'は要らない。 また、StringTokenizerやsplit()メソッドは、行末にデリミタ文字がなくても、行末そのものを親切にもデリミタと見なしてくれたが、このプログラムのような、正規表現の『正しい』使い方では、そういう都合の良い融通が効かない。したがって今回の例では、最後のトークン"3:0,2,8,13,"が--末尾に':'がないため--マッチ文字列と見なされない。ゆえに、トークンとして取り出せない。そこでこのWrongなプログラムは、次の二つのものしか出力してくれない: aki.txt: 3: そこで、正規表現という仕組みを利用して原始文字列から正しくトークンを取り出すためには、少なくとも次のことが必要だ: (1)デリミタ文字だけでなく行末もデリミタとして指定すること。 (2)マッチした部分文字列からデリミタ文字を除いた部分を得ること。 上の(1)の課題は、意外と簡単だ。「または」を意味する正規表現の特殊文字' |'を使って、「コロン、または、行末」を指定すればよい。行末を意味する正規表現の特殊文字は'$'だ。そこでデリミタの指定は":|$"でよい。改行文字や、改行文字を含むいわゆるホワイトスペース文字を指定する特殊文字、'\n'や'\s'は、「行末」という意味では使えないので、要注意。 上の(2)の課題は、もっと一般化して言うと、正規表現を使うための最も重要な技法の一つとして言い表せる。すなわちそれは、「正規表現パターンがいくつか複数の部分から成るとき、マッチ結果の部分文字列全体の中から、パターンの特定の部分にマッチした部分だけを出力として取り出す」という課題だ。この課題を実現するためには、少なくとも次の二つのことが必要だ: 【正規表現と部分マッチの取り出しのための条件】 (a)パターンを部分分けするためのパターンの表記法 (b)マッチ文字列全体の中から部分マッチを取り出す方法 まず、上の課題の(a)、パターンを部分分けするためには、各部分を括弧で括る。たとえば次のように: 部分的正規表現 A 部分的正規表現 B 部分的正規表現 C 全体としての正規表現 ((A)((B)(C))) 上の最後の正規表現の中には、次のような5つの部分的正規表現を同定できる: ((A)((B)(C))) (A) ((B)(C)) (B) (C) そして、このように括弧で括られた各部分に、番号が付いていると考える。上の例では、それぞれの部分に、1から5までの番号が付く、と考えるのだ: 【番号】 ((A)((B)(C))) 1 (A) 2 ((B)(C)) 3 (B) 4 (C) 5 このような、パターンの各部分のことを、捕捉グループ(capturing group)と呼ぶ。そして、Matcherのgroup()メソッドは、引数がなければマッチした部分文字列の全体を取り出すのに対し、引数があれば、引数に該当する捕捉グループにマッチしている部分だけを取り出す: //マッチ文字列の中の上のBのパターンにマッチしている部分を取り出す String str = mat.group(4); そこで、以上のような、捕捉グループという仕組みを利用すると、今回のわれわれの目的:「パターンにマッチした文字列のデリミタ文字を除いた部分だけが欲しい」という目的は、次のようにして達成できる。まず、パターンを指定するときに、捕捉グループを指定する: String regexString = "([^:]+)(:|$)"; 上では、1番の捕捉グループとして「コロン以外の文字が1個以上並んでいる」という部分パターンを指定し、2番の捕捉グループとして「最後がコロンまたは行末」という部分パターンを指定している。 そしてマッチした部分文字列を取り出すwhileループの中では、前のこんな呼び出しではなく: matchedString = mat.group(); //マッチ文字列全体を取り出す group()メソッドの引数で、目的とする捕捉グループの番号を指定する: matchedString = mat.group(1); //1番の捕捉グループとのマッチ部分を取り出す それでは、正規表現の捕捉グループという仕組みを利用したプログラムを作って、動かしてみよう(リスト6.3): 【リスト6.3 RightRegxTokens.java】 リスト6.3のRightRegxTokensプログラムは、それまでStringTokenizerやsplit()メソッドを使って行ったトークン分解と同じく、正しい結果を出力してくれる。 このような方法は、面倒なようではあるが、しかし捕捉グループという仕組み自体は、たいへん強力で便利な仕組みだ。これがなければ、正規表現という仕組み自体の利用価値が半減する、といっても過言ではない、と私は経験的に言いたい。なぜなら、捕捉グループを指定できないと、マッチした部分文字列に対する部分的な操作が、かなり書きづらいのである(マッチ文字列に対してまた別の正規表現によるマッチを試みたりしなければならない)。 たとえば、捕捉グループという仕組みを使うと、マッチした部分文字列の内、特定の捕捉グループにマッチする部分や、あるいはその残りの部分に対する置換処理を、簡単に記述できる。実際のプログラムを一つ見ておこう(リスト6.4): 【リスト6.4 RegxGroupReplace.java】 リスト6.4のRegxGroupReplace.javaプログラムは、捕捉グループ1を指定して、トークンとデリミタから成る文字列中のトークン部分だけを取り出し、そのそれぞれの末尾に、新たなデリミタ文字列として"###"をくっつけている。このプログラムでは行末もデリミタと見なすので、行末にも"###"が付く。このような、StringBufferを作ってそこにMatcherのappendReplacement()メソッドでマッチ部分や置換後の文字列(および、そのほかの、関係ない部分)をアペンドしていき、最後にMatcherのappendTail()を呼び出して残り部分…があれば…もアペンドする、という手順も、Javaの正規表現を使うときの標準的な定石だ。 appendReplacement()メソッドの、われわれプログラマにとってたいへん都合の良い振る舞いについては、Java APIのドキュメンテーションを見ていただきたい。 実際に、データファイル中のデリミタを後から変える、というニーズはあるのだろうか?。これは、大いにありえるのである。つまり、今まで使っていたデリミタ文字をデータの一部として含むデータ項目をファイルに記帳しなくてはならなくなった。しかも、そのデータ項目中のデリミタ文字を、便宜的にほかの文字に変えることはできない特殊な事情がある…、という場合だ。 ★★★コンピュータによる文書処理〜データ処理の鉄則★★★★ あなたが(あるいは他人が)作ったプログラムを使って、無難に文書処理やデータ処理を行うためには、最初に、対象となる文書ファイルやデータファイルを、プログラムが誤動作しないように手作業で整形するとよい。逆に、文書ファイルやデータファイルの複雑怪奇な特質に合わせてプログラムを改造することは、時間と労苦を多く消費するばかりか、最終的にうまくいかないことが多い。 あなたや私やほかの人、すなわち人間が書くコンピュータのプログラムは、人間の脳のように複雑で臨機応変な判断ができない。そこで: コンピュータで処理するデータ形式〜ファイル形式は、シンプルなほどよろしい。 たとえばそのプログラムが、日本語の文(センテンス)の終端マークとして読点‘。’を期待しているならば、文の終わり以外のところに使われている読点文字を、別の記号に置き換えてから、そのプログラムにかけよう。 あるいは、各データ項目を区切るデリミタとしてカンマ','が使われているなら、データ項目中に存在するカンマを別の文字に置き換えよう。または、データ項目中で使われていない文字または文字列を新たなデリミタとして使おう。ただしこの処理は、コンピュータプログラムによる自動処理は困難であり、新たな間違いも発生しやすいから、人間の目と手による作業が最も確実である。 そのほか、オリジナルファイル中の誤字や誤形式などは、正しい文字〜正しい形式に事前に直してからプログラムにかけたほうが、コンピュータの徒労な誤動作が少なくなる。なにしろ人間の脳に比べるとコンピュータのプログラムの能力はどこまでいってもお猿さん程度なので、事前に人間がいろいろお膳立てをしてあげること、複雑な要素のないやさしい素材を与えてやることが、コンピュータを上手に使うコツ、実用性のあるプログラムをそれほど苦労せずに作れるためのコツである。 また、元々コンピュータには無理なことに、プログラミングで挑戦すべきでない。たとえば翻訳や通訳のような言語処理作業は、正しく行うためには、その場その場の正しい状況判断が必要だ。現実社会の諸状況というものは、生成的(生起的)無限集合なので、コンピュータには逆立ちしても対応できない。こういった問題に関心のあるかたは、「人工知能におけるフレーム問題」という、古典的な難問を勉強してください。 ★★★★★★★★★★★★★★★★★★★★★★★★★★★★★ 正規表現の捕捉グループという仕組みの威力を見るために、もうひとつプログラムを作ってみよう。それは、英文のテキストファイルを整形するプログラムだ。 日本語のプレーンテキストファイルは、改行文字がパラグラフの末尾にだけある形式、すなわち、1パラグラフ=1物理行、という形式のものが多い。そして、パラグラフの先頭には段落、すなわちスペース文字が1つある。 しかし、英文のプレーンテキストファイルは往々にして、1パラグラフが適当な単語末を区切りとして複数の物理行に分割され、パラグラフ境界には空行が一つある、という形式が多い。この形式では、一つの文章(センテンス)が複数の物理行にまたがることも多いので、英文テキストを分析するプログラムを作った場合などに、扱いにくい。 そこで、英文のプレーンテキストファイルを、日本語のそれと同じく、1パラグラフ=1物理行の形式に変換できると便利だ。それをやるのが、リスト6.5に示すReformParagraphsプログラムである: 【リスト6.5 ReformParagraphs.java】 リスト6.5のReformParagraphs.javaプログラムでは、java.nioパッケージに含まれている新しいバッファオブジェクト(ByteBuffer -> MappedByteBuffer)を利用して、ファイルの内容全体を一挙にメモリに読み込み、そしてそれをバイトの配列にしたものをStringのコンストラクタに渡して、巨大な一個のStringをまず作っている(このプログラムでは対象テキストファイルの使用文字コードをUS-ASCIIと特定)。メモリ容量が数百メガバイトという、(昔に比べると夢のような)今日のパソコンの上では、こんな乱暴をしても、まず大丈夫だ。 なお、Patternのmatcher()メソッドに引数として渡す原始文字列を作るためには、CharsetDecoderオブジェクトを使ってByteBufferをCharBufferに変換するよりも、この方法でStringを作ってしまったほうが簡単である(と私は思う)。どちらの方法も、ネイティブ(≒プラットホーム固有)の文字コード列から、Javaの文字、すなわちJavaのcharタイプ、すなわちUnicode文字の文字列を作るのだが。 従来のお行儀のよいファイル処理プログラムでは、ファイルの各行を一度に1行ずつ読んで、各行に対して定型的な処理をしていく。そして、一つの行に対する処理結果を、ふつうはメモリにため込まずにすぐに出力してしまう。しかし、今回のような処理目的では、つねに、連続する2行を読んで、片方が空行でないかを確認しなければならない。片方が空行でなければ、その2行を連結して出力しなければならないが、しかしさらにその次の行も空行でなければ、それもまた、さきの2行に続けて連結しなければならない。 つまり、「各行処理」という従来的な方法では、今回の課題への対応はなかなか難しい。むしろ、ファイルの全体をメモリに収めて、[ふつうの文字][改行文字][ふつうの文字]というシーケンスを見つけ、それらを[ふつうの文字][スペース文字][ふつうの文字]というシーケンスに変換してやるほうが簡単だ。英文の複数の物理行は、改行の直前が単語末なので、次の文字(=次の物理行の冒頭)との間には、スペースを入れてやらなければならない: 【[改行]を取ると、ofとmyの間にはスペースが必要だ】 ......... .... ..the color of[改行] my shoes .... .... そして、ご覧のようにこのReformParagraphs.javaプログラムでは、 [ふつうの文字][改行文字][ふつうの文字] というパターンの、前後の[ふつうの文字]の部分を、正規表現の捕捉グループとして取り出している。そしてappendReplacement()メソッドを使って置換後の文字列を作るとき、二つの捕捉グループのマッチ文字の間に…改行文字に代わって…スペース文字をはさんでいる。 なお、java.util.regexパッケージの正規表現のシンタクスルールでは、\Sがふつうの文字(ホワイトスペース以外の文字)を指示するが、これをそのままJavaのStringを表す"..."の中に書いてしまうと、Javaにとってのエスケープシーケンスと見なされてしまう(たとえば\tというエスケープシーケンスはタブを意味する)。だから、Java様に\Sという文字を渡すためには、「\は文字どおりの\であり、Javaのエスケープシーケンスの開始合図ではない」という意味の\\を、Sの前に付けてやる。そうするとJavaは、その\\Sという表現を、\Sと受け取るのだ。これは、そのほかの正規表現特殊文字、\dや\w等でも同じである。 またこのReformParagraphsプログラムのパターン文字列regexの中では、改行文字として、Windows/MS-DOSの場合(CRLF)、UNIXの場合(LF)、Macintoshのファイルの場合(CR)、という三つの場合を「または」でまとめている。 本書のサポートページ、http://homepage1.nifty.com/algafield/には、本書に登場したプログラムのソースファイルとともに、典型的な英文テキストファイルの例として、旧約聖書「創世記」の冒頭部分を引用したgenesis.txtを置いてあるので、このReformParagraphsプログラムのテストにはこのファイルを使ってもよい。 ひまと好奇心のあるかたは、応用の練習問題をやってみていただきたい:Javaの正規表現ファシリティ、その中のとくに捕捉グループという仕組みを利用して、HTML文書中のタグ名と属性名をすべて大文字に変換するプログラムを作りなさい(属性の値は元のままでよい)。この課題は*たまたま*捕捉グループを使わなくても変換可能だが、最初は、あえて捕捉グループを使うバージョンを作ってみていただきたい。その次に、捕捉グループを使わないバージョンを作ってみよう。 6.4 アルゴリズムによって要素を取り出す デリミタという、目に見える指標物がないデータ集合からデータ要素を取り出すためには、一般的には次の節で述べるように辞書を参照する。 しかし、辞書がない場合や、辞書を作る時間的労力的余裕がない場合には、自作のアルゴリズムによって目的要素を取り出すことがある。 そのアルゴリズムは、データ集合と目的データ要素の中にある何らかの法則性に着目して構築する。たとえばXML文書では、各データ要素は...のような形をした、開きタグと閉じタグの間にある。または、のような形をしたタグの中に必要なデータ要素がある場合もある。XML文書からのデータ要素の取り出しは、このように法則性が単純なのでプログラムを書くのも難しくない。しかしJavaは、J2SE 1.4からXMLパーサ(parser, 構文解析プログラム)をAPIの一環として提供しているから、いざというときにはそれらを使えばよい。 また、Webをはじめ、いろんなインターネットアプリケーションで、通信内容から、プロトコルのやり取りの部分とデータ本体を切りわけること、またデータ本体をさらにヘッダ的情報(頭書き的情報)と本文に分ける操作などは、各アプリケーションのプロトコルの規格で通信内容のあらゆる細部の構造が決まっているから、それらの細部的な法則をそのままプログラムに盛り込めばよい。既存のプロトコルに従ったインターネットアプリケーションを書くことは、したがってそれほど難しくない。ただし、メールとWorld Wide Webに関しては、Java APIが提供しているパッケージやクラスを使って、より簡便にインターネットやイントラネットのためのプログラムを書ける。 本書のこの節では、目的データの法則性がそれほどはっきりしていない、という例に挑戦してみよう。それは、「日本語のテキストを文に分解し、各文の主語を摘出する」という課題だ。この、主語取り出しという課題への取り組みは、今後、テキストの要約作成や、文学研究のための道具の一環としてなど、いろいろな用途が考えられる。 文学研究へのコンピュータの利用というと、従来は、各作家や作品の、文の長さや句の長さを統計的に分析するといった、味気ない(それほど文学的でもない)ものが多かったが、本書で学んだ文字列のさまざまな操作方法や文字列の探索手法を多角的に駆使すれば、もっと多様で中身の濃い、おもしろい作品分析ができるだろう。 ここでご紹介する主語取り出しプログラムは、作者が〈バージョン0.01〉と超謙遜しているように、まだまだ各所に、今後の大幅な改良の余地がある。読者各位が、いろんな角度からの改良に挑戦していただきたい。現状ではこのプログラム(リスト6.6のJpSubjectsAnalyzer.java)は、以下のことを“法則”として記述している: 【JpSubjectsAnalyzerプログラムversion 0.01の動作前提】 1. 各文は読点‘。’で区切られている。 2. 拾い上げる主語は漢字・片仮名・ASCII英語文字の3種類の文字からのみ成る語句とする(技術用語などを想定)。 3. 主語となる語句は直後に主格の格助詞らしき“が”または“は”が付いている。 現バージョンでは、この語句拾い出しプログラムは〈漢字・片仮名・ASCII英語文字の3種類の文字からのみ成る語句〉しか拾わないが、次の6.5節で述べる、辞書利用法を併用すれば、助詞で連結された複合語句…“ナニナニのカニカニ”など…も比較的容易に取り出せるようになる。ただし複合語句の取り出しは、純粋にアルゴリズムに依る方法でも、ある程度までは可能である。 【リスト 6.6 JpSubjectsAnalyzer.java】 リスト6.6のJpSubjectsAnalyzerプログラムは、現状ではこの中のテスト用のmain()メソッドがアプリケーションとして動くようになっている。 このmain()メソッドは、コマンド行で次のように指定した入力ファイルを読み: java JpSubjectsAnalyzer filename 入力ファイルの各行を文(センテンス)に分割し、一つ々々の文の中に主語らしきものを探す。主語らしきものが見つかったら、その語句と、その語句のそれまでの出現回数を、Mapデータ構造(HashMapオブジェクト)に記録していく。そして最後に、このmain()メソッドは、[語句:出現回数]というペア状のデータの集まりを、出現回数の多い順に標準出力へ出力する。単独のファイルでなく複数のファイルを一挙に分析したいときは、リスト4.8のMakeIndex.javaを参考にして、入力ファイル操作〜ディレクトリ操作の部分を書き換えてor書き加えていただきたい。 このような文字列を操作するプログラムでは、毎度のことながら、あるメソッドに引数として渡す文字列=あるメソッドが引数として渡される文字列の長さ等をチェックせずに、StringクラスやStringBufferクラスのメソッドを安易に使ったりすると、てきめんにStringIndexOutOfBoundsExceptionが投げられてしまうことが多い。例外が投げられた場所を特定するためには、例外オブジェクトのprintStackTrace()の結果を見ることが役に立つ。 このJpSubjectsAnalyzerプログラムは、補助的なクラスを二つ使っている。ひとつは、日本語の文字種の判定などを担当するクラス、JpCharacterだ(リスト6.7): 【リスト 6.7 JpCharacter.java】 リスト6.7のJpCharacter.javaは、現状(バージョン0.01)で利用価値があるのは、いくつかのstaticメソッドだ。これら、日本語で書かれたテキスト中の文字種を判定するメソッドの仕様も、今後さまざまな改変や改良の余地がある。というか、現状を見て疑問に思われる読者もおられるはず。 もうひとつの補助的クラスは、主語として拾った語句を語句の出現回数でソート(整列)したいので、そのために特に作ったComparatorクラスだ(リスト6.8): 【リスト 6.8 IntValueComparator.java】 リスト6.8のIntValueComparatorクラスは、HashMapの一つ々々のデータ項目である[キー:値]ペアの集まりを、通常のように〈キー〉でではなく、〈値〉でソートするためのcompare()メソッドを定義している。今回のプログラムでは、〈キー〉は語句を表すString、そして〈値〉はintの値をオブジェクト化したIntegerオブジェクトだ(java.utilパッケージが提供している各種のデータ構造オブジェクトには、プリミティブタイプ…intのような数値タイプやboolean…のデータは入れられないのだ!)。 ★★★Comparatorの自作とSetの罠★★★★★ 今回私が初体験的に学んだことといえば、こうやって独自に定義したComparatorを、迂闊にJavaのSetオブジェクト(HashSetやTreeSet)にインストールしてはならない、ということだ。JavaのSetデータ構造は、“同じデータ項目が複数個ない”ことを鉄則としており、しかも〈同じ〉ということの判断を、自分のComparatorを使って行なうのだ。自作のComparatorであるIntValueComparatorのバージョン0.01のcompare()メソッドは、単純に、語句の出現頻度の値が同じならそれらのデータ項目は--ソートのための指標としては--同じである、という結果を返していた。そのような、同値性判断をするComparatorをSetに与えると、あらゆる出現回数に関して、その回数の語句は一つのみ、という情けない結果になってしまうのだった。たとえば、出現回数が5の語句が3つあっても、Setの中にはその中の1つしか残らないのだ。 というわけでこのプログラムのmain()の中では、最初期(v.0.01)のIntValueComparatorを使った場合の、データ集合の安易で危険ななSet化を避けるために、[キー:値]ペアのデータ集合(Map.Entryオブジェクトの集合)を急遽、ふつうのListオブジェクト(ArrayListオブジェクト)に変換し、それを、やっと安心して、おもむろにソートしている。Setではないデータ構造をソートするために、このような同値性判断のあるComparatorを使うのはOKである。 IntValueComparatorの現バージョン(リスト6.8, version 0.02)では、〈値〉だけでなく〈キー〉もcompare()メソッドのアルゴリズムに動員しているから、「Mapデータ構造ではキーは必ずユニークである(同じキーは二つとない)」という仕様に基づき、compare()メソッドが二つのデータ項目を“同じ”と判断することが絶対にないようになっている。つまり、出現回数が同じでも、語句が違えば違う(=大小がある)データ項目と見なされるように、compare()メソッドの処理内容を改良したのだ。今度は、このComparatorをSetオブジェクトにインストールしても大丈夫である(すでにテスト済み)。 JavaのSetデータ構造の、同じものの重複を認めないという仕様は、検索〜探索の便を考えた仕様と思われる。探索のとき、データ構造側に同一データの重複データがあると困る。同一データの重複がいくつあってもよいデータ構造のことをBagと呼ぶが、現在のJava APIはBagクラス(に相当するクラス)を提供していない。必要なら、われわれが自作するしかない。 ★★★★★★★★★★★★★★★★★★★★★ そもそも、語句データを表すオブジェクトとして、[語句:出現回数]というたった二項から成るデータ、という設計がまずかったかもしれない。このようなペアデータは単純にMapデータ構造に収容できるから扱いは楽だけど…。 しかし語句オブジェクトに備えさせたい情報は、語句と出現回数以外にもっといろいろある。たとえば、ファイル名、ファイル内の位置、といったインデクス的情報。主語としての語句がどんな動詞を従えていたか、という情報。この主語のある文の、前後の文の主語は何だったか、という情報。…というように、利用者の分析意図によって、欲しい情報の種類は増えてくる。 さて、以上見てきたように、純粋にアルゴリズムに依存するデータ要素取り出しの処理は、いきなり完成度の高いものはできにくい、という感触を読者各位はお持ちになったと思う。では、自作アルゴリズムという冒険に依らずに、原データと辞書とを律儀に対照する方法は、どうだろうか?。 [コラム記事] ★★★★XMLについて★★★★ 眉に唾をしつつ一応は真剣につきあおう コンピュータ技術の世界は、栄枯盛衰のサイクルが急速だ。ある時期に大々的に持て囃されていたものほど、ある日突然急速にポシャる、というパターンがよくある。 というわけで、本書がみなさまのお手許に届いたころには、今現在栄耀栄華を誇っているXMLの運命も、どうなっていることやら分からない。 しかし読者諸君、心配はご無用だ。みなさまが、本書に述べられているような、文字と文字列の扱いに関する基本技術をしっかりマスターしていれば、今後、XMLに代わってどんな新しいデータ形式や文書形式が主流に躍り出たとしても、平気の平ちゃらだ。あなたは、何でもいらっしゃい、いらっしゃい、の境地だ!。 今後何が現れてもあなたは、慌てず騒がず、文書中の(1)「データ本体」と、データ本体の区切り記号やデータの性質等を表す(2)「メタデータ」の区別の仕様に基づいて、必要な要素を正しく切り出していけばよいのである。 データ記述言語(コンピュータ業界の専門用語では“マークアップ言語”という)としてのXMLが抱えている大きな問題点は、主に三つある: 1.データ本体とメタデータが同一ファイル中に混在すること。したがって、同一のデータに対する複数の異なるビュー(view,見方、見方の構造)を記述し、それらを関連付けながら同定する処理等が困難であること。…現在では、そのためにXSLTのような制約の多い中間言語を介在させなければならない。 2.メタデータ自体も線型(シーケンシャル)なドキュメントであるため、関係データベースのかなり複雑なスキームとか、オブジェクト指向開発における複雑なオブジェクト間関係など、“立体的な論理構造”を持つデータ本体を簡便に記述できないこと。これがいわゆる“XMLデバッグ地獄”を招来する。そしてまた、そのような論理構造とXML文書間の変換作業が、相当に厄介である。 3.アプリケーションの開発基準が恣意的で曖昧なこと。たとえば、何をelement(要素、タグ要素)とし、何をattribute(属性、タグ要素の属性)にするか、という基準が客観的に存在しないから、異なるアプリケーション間のマークアップの互換性確保は、ほとんど絶望的。 (たとえば“国名”が、あるアプリケーションのDTDではelement、別のアプリケーションのDTDやXMLスキーマではattributeだったりする。) だから、近い将来、XMLのこれらの限界を克服した、新しい、より厳密で高機能なデータ記述仕様が登場する可能性は、十分にあるのである。 しかし、プログラミングをするわれわれにとってXMLの有り難い点は、なにしろ構造が単純なことだ。宣言、コメント、CDATAといった若干の特殊要素を除けば、文書本体はただひたすら、...またはの中にデータ本体が書かれているだけだ。プログラミングの要素として、非常に単純明快だ。 だから、Xerces(http://www.jakarta.org/)やCrimson(J2SE 1.4同梱)といった既存のXMLパーサの振る舞いに気に食わない点があったとき、自分の目的に合ったパーサを自作するのは、本書を読みおえたかたにとっては朝飯前(もしくは翌日の朝飯前!)の簡単な仕事だ。(ヒント:XML文書のようなタグが多重化している文書を分析するプログラムを作るときには、出会った開きタグをjava.util.Stackクラスのオブジェクトにどんどん入れていき(push()し)、閉じタグに出会ったら、その閉じタグに対応する開きタグをスタックトップから取り出す(pop()する)、というワンパターン的な処理を書けばよい。このとき、閉じタグと開きタグが同名でなければ、そのXML文書は不正な文書だ。) Javaでプログラムを書く者にとってXMLの第二の利点は、XMLを操作するためのAPIも関連ソフトも、主要なものはほとんどすべて、Javaで作られていることだ。学習用には無料で使えるJ2SE 1.4にも、XMLを扱うための豊富なAPI群(javax.xml.*, org.w3c.dom, org.xml.*等)やパーサソフトが提供されている。したがって、Javaでプログラミングするかぎり、XML処理の低レベルの基本的な部分をプログラマが自分で書かなけばならないケースは非常に少ない。 また、今現在、XMLの世界は関連システムがものすごく多様化している。w3cの中と外の両方を含む広い業界世界の中で、気の遠くなるほど数多くの応用システムが登場し、それぞれが将来のスタンダードの地位を狙っている。しかしXMLの有り難い点は、これら多様な関連システムのすべてが、XML文書として表現されることだ。言い換えると、関連システムのすべてが、所詮はXMLのアプリケーションである。というか、XMLと直接関係のない一般のアプリケーションでも、コンフィギュレーションをXMLで記述するものが最近は目立って増えている(あなたも一つや二つはご存じでしょう)。 何もかもXML化していくような、今日のコンピュータ世界の動向…。ということは、だからどんな目新しい応用システムを相手にするときでも、今後われわれプログラマは、ある意味では、ゆったりと安心してぶつかれるのである(たとえばどんなアプリケーションに対応するときもパーサの基本骨格は変える必要がない)。XMLが現代の寵児になっている最大の理由が、これ…何でもかんでもXMLで間に合うという点…だ。 さよう!。プログラミングが単純という意味では、われわれはもしかして、XMLの今後の長寿化を願うかもしれないね。あなたは、いかがでしょう?。 ★★★★★★★★★★★★★★★ 6.5 辞書を参照してデータ要素を取り出す 次のような原始文字列には、これを正しくデータ要素に分解するための手がかりとなるデリミタがない。単なる、のっぺらぼーの文字列だ: 北海道紋別郡西興部村字西興部738 この原始文字列から要素を正しく取り出していく方法は、この文字列の性質が分かっている場合と、分かっていない場合とで、かなり異なる。 この文字列が、日本の住所データである、日本の都道府県名・郡名・市町村名・町名番地、というデータ要素の列である、と分かっている場合には、都道府県名や郡名、市町村名などを列記した辞書を参照していけばよい。課題として、比較的簡単かつ明快だ。 この文字列が、どういう性質の文字列か分からないとき、たとえばこれが大きな文書の中の長い文章の一部であるときには、要素の正しい取り出しもやや難しくなる。それでも、「これは日本語の文字列である」と分かっていれば、要素取り出しのために参照する辞書の作成や利用という課題も、まあ、労苦と時間は覚悟しつつも、一定の視野の中に収まる程度の大きさだ。 何語かどうかも分からない、文字コードの種類も分からない、単なるバイトの列だったら、正しい要素の取り出しは警察のハイテク鑑定の仕事になってしまう。暗号化プログラムなどによってコード値を変えられているデータ列なら、さらに解読は難しい。たとえば、これはShift_JISの文字コードの列かもしれないと仮定して、各2バイトの値をShift_JISのコード値と対照したら、たまたま「北海道紋別郡西興部村字西興部738」だったからとっいって、鑑定は完全に成功し終了したとは言えない。Shift_JISの人畜無害な文字列"北海道紋別郡西興部村字西興部738"と読めることが、誰かによる意図的な擬装かもしれないではないか。 この節では、そんな面倒で難解な分野にまでは踏み込まずに、日本の住所データであると分かっているケースと、一般的な日本語文字列であると分かっているケース、この二つについて、辞書参照によるデータ要素取り出しという問題を議論してみよう。 まず、日本の住所データならば、[都道府県][郡][市町村]といった、各要素の末尾に使われる文字を、デリミタに準ずる文字として利用できるから、辞書を参照しなくても自作のアルゴリズムによって正しい要素取り出しができるように思える。しかし、こんな例はどうだろう: 京都府天田郡夜久野町字板生2386番地 これは、ドジなプログラム(アルゴリズム依存プログラムのごく初期的非成熟バージョン)だったら「京都」「府天田郡」という切り出しをするかもしれない。「京都」という都道府県名はない、とプログラムが正しく判断できるためには、辞書を参照させるべきかもしれない。 私は、日本の郡名や市町村名を全部は知らないからこそ、自信をもって申し上げるが、クレバーなアルゴリズムに頼った言語処理プログラムは、いつどんな落とし穴に落ちてしまうか分からないのである。 しかし辞書に依存する馬鹿正直なアルゴリズムでも、ときには、正しいと見なせる切り出し方が二とおり〜三とおりあって、ほんとに正しいのはどちらか分からない、というケースがある。また、日本の住所データのような要素数の比較的少ないものでも、全国のすべての郡、その下のすべての市町村、その下のすべての町名番地を集めた辞書、すなわち住所データが正しい住所データであることをチェックできるための辞書を作るとなると、ちょっと大仕事になる。しかも合併や団地開発などによって末端的な住所データは変わることがあるから、辞書のメンテナンスも面倒だが重要な仕事になる。自治省(現総務省)にも、日本の地名のための、そんな便利な辞書データベースはないみたいだ(あるいは、あっても公開していない?)。 ★★★辞書づくりという難関★★★ コンピュータによる言語処理ないし文書処理という課題は、それを正しく行うために、語彙、語句など単位的な要素を集めた辞書を必要とすることが多い。またその辞書は、タイミング良く正しくメンテナンスされなければならない。さらに重要なこととして、辞書のデータ構造と参照アルゴリズムが一般に公開されなければならない。数多く市販されている電子辞書のような商品は、データ構造が公開されていないから、プログラミングやソフトウェア開発の立場からは利用価値がほとんどない。 どなたにも簡単に想像つくように、辞書の制作とメンテナンス作業は、個人では取り組むことの難しい、たたたたいへんな大仕事だ!。 ★★★★★★★★★★★★★★★★ 住所データという限定を外して、一般的な日本語文字列という前提で分析していくと、「北海道紋別郡西興部村字西興部738」という原始文字列の中の、冒頭の「北」も正しい日本語語句だし、「北海」もそうだ。もちろん「北海道」も、正しい日本語の語句だ。しかし充実した良質な辞書があれば、「北」「海道紋」とか、「北海」「道紋別」などの語句切り出しは正しくない、という判定ができる。 それにまた、英語の文から単語を切り出す処理は、単語と単語の間にあるスペース文字などをデリミタと見なせるから、比較的容易に書ける。しかし、語句を区切る物理的なデリミタがない日本語の文章から、単語のようなものを正しく切り出すためには、相当充実した辞書が必要だ。 日本語の文章からのトークン切り出しを支援するための辞書と、その辞書を使う文章解析プログラムが、ありがたいことに、実はすでにある。それは、奈良先端科学技術大学院大学自然言語処理講座が発表している「茶筅(chasen)」だ(http://www.chasen.org/)。 茶筅の使い方は簡単で、日本語のテキストを収めたファイルがtest.txtという名前だとすると: chasen test.txt というコマンドで、語句解析結果を標準出力へ出力してくれる。このデフォルトの出力結果に不満なら、いろんなオプションや、出力形式の指定方法が用意されているから、それらをすべて試してみるとよい。 たとえば「北海道紋別郡西興部村字西興部738」という日本語文字列を、“読みの候補すべて”と“位置情報付き”というオプション〜出力形式指示でchasenしてみると、こんな結果になる: 【茶筅プログラムの出力例(1)】 北海道 ホッカイドウ 名詞-固有名詞-地域-一般 0 6 紋別 モンベツ 名詞-固有名詞-地域-一般 6 10 郡 グン 名詞-接尾-地域 10 12 西興部 ニシオコッペ 名詞-固有名詞-地域-一般 12 18 村 {ムラ/ソン} 名詞-接尾-地域 18 20 字 {ジ/アザ/アザナ} 名詞-一般 20 22 西興部 ニシオコッペ 名詞-固有名詞-地域-一般 22 28 7 7 未知語 28 29 3 3 未知語 29 30 8 8 未知語 30 31 EOS 上の茶筅の出力例は、各行のデータが、切り出した語句、読みまたは読み候補群、品詞、出現位置(ゼロベースのバイトカウント)、となっている。位置情報は、最初の文字位置と、最後の文字位置+1である。 もうひとつ、「山間の田んぼに一本足の案山子が立っていた。」という日本語文章をchasenしてみると、結果はこうなる(起動時の指定が例(1)とやや異なる): 【茶筅プログラムの出力例(2)】 山間 {サンカン/ヤマアイ} 山間 名詞-一般 1 5 の ノ の 助詞-連体化 5 7 田んぼ タンボ 田んぼ 名詞-一般 7 13 に ニ に 助詞-格助詞-一般 13 15 一 イチ 一 名詞-数 15 17 本 ホン 本 名詞-接尾-助数詞 17 19 足 {アシ/ソク} 足 名詞-一般 19 21 の ノ の 助詞-連体化 21 23 案山子 カカシ 案山子 名詞-一般 23 29 が ガ が 助詞-格助詞-一般 29 31 立っ タッ 立つ 動詞-自立 31 35 て テ て 助詞-接続助詞 35 37 い イ いる 動詞-非自立 37 39 た タ た 助動詞 39 41 。 。 。 。 記号-句点 41 43 EOS この、出力例(2)では、「立っ」に対して「立つ」、「い」に対して「いる」のように、品詞の基本形が出力されている。ここでは位置情報がゼロベースでなくワンベースになっているが、利用するとき気をつけていれば、そのことはそれほど重大な過誤にはならないだろう。 この二つの例を見てお分かりのように、茶筅の出力は、コンピュータの日常的実務ユーザであるわれわれが期待する語句の姿とはやや違う。たとえばわれわれは「738」という数詞がほしいのであって、「7」「3」「8」という三つの〈未知語〉(?!)をほしいわけではない。また、われわれがほしいのは「一本足(イッポンアシ)」という語句および読みであり、「一(イチ)」「本(ホン)」「足{アシ/ソク}」という三つのばらばらの名詞やそれらの読みではない。 このように、茶筅の出力結果が少々おかしい(とわれわれが感じる)のは、元々茶筅の開発スタッフたちが、日本語の形態素(morpheme)の切り出し、という課題にこだわっていたためである。形態素とは、それ以上分解できない最小の、アトム的な、語要素のことだ。私自身は、morphemeという西欧言語学の概念を、文脈依存性の強い日本語のような言語とそのアトムに適用することには、疑問を感じている。たとえば茶筅ですら、「北海道」を「北」「海」「道」とか「北海」「道」(「道」は都道府県名接尾辞)とかに分解しないのは、特定の文脈依存性を分析の前提として認めているからである。しかし、だったら、「一本足」というありふれた成句に関しても、「一」「本」「足」ではなく「一本足」を切り出すべきだ。 しかしともかく、そういう茶筅の独特の『癖』の部分をわれわれ利用者が補う、という立場で見るならば、私の使用経験から言っても、茶筅の語句切り出し能力はかなり良い。そのほかの類似プログラム(たとえばKAKASI)を使った場合のように、“とんでもなくおかしい”と感じる出力には、茶筅では、あまり出会った経験がない。なにしろ辞書づくりはたいへんな大仕事だから、国立大学のような公共機関が作った優れた辞書は、われわれの日常のコンピューティングにおいても積極的に使わせていただくにかぎるのだ!。 茶筅独特の癖という点について言えば、そもそも、日本語には標準文法というものがなくて、文法書を書く学者によって文法なるものの細部が異なっている。このことは、日本語に対してはわれわれ一人々々が、自分の言語ニーズや人生観等に合った自由な見方をしてよい、ということを意味している。絶対的権威的な日本語文法なるものは、幸いにして存在しない。 ゆえに、茶筅の人たちの独特の日本語観は、たとえばあなたの、それとは異なるまた独特な日本語観とパラレルであり互角である。お互い、どちらかが絶対的権威になる必要もなければ、否定し去られる必要もない。たとえば上で例示したように、「日本語には形態素なんてねーよ」と主張する誰かさんの説にも、それなりの一理はあるのだ。しかしともかく、何度も申すように、辞書づくりと辞書のメンテナンスはたいへんな作業であるので、それだけでも、茶筅のみなさまの長年の開発努力には大きな感謝を捧げるべきだ。 私がこの章を書いている時点(2002年夏)では、chasen.orgは茶筅の機能をJavaプログラムから呼び出せるためのJava API(java.util.regexパッケージみたいな簡単で使いやすいやつ!)を提供していない※。しかし、その出力そのものは、上で見たように簡明な形式なので、茶筅の出力を読んで分析・解釈するJavaプログラムというものは、今すぐにでも作れる。上の例で見たように、出力の各行の各要素はスペースまたはタブというデリミタで区切られているから、Javaプログラムによる分析は楽だ。 〔※:今現在茶筅が提供しているC言語による関数を、Javaプログラムから呼び出す方法は、避けたほうがよい。Javaは(現在のJavaのネイティブメソッド呼び出しインタフェイス(JNI)は)、Cのコードが頻繁に犯すメモリエラーに対して完全に無力無抵抗であるから、使うのは危険である。〕 そして、茶筅の語句切り出しの、われわれから見ておかしいと感じる部分は、われわれが書くJavaプログラムの中で必要に応じて補正すればよい。たとえば「連続する名詞は一つの語句へと連結する」とか、「名詞-助詞“の”-名詞」という3形態素のシーケンスは一つの独立語句の候補と見なす、など、われわれの目的に合ったルールを実装すればよい。茶筅自身に対して、連結品詞の定義を指定をすることもできるが、その方法にはあまり柔軟性がないから、あなたが書くJavaプログラムの側で、何を独立語句と見なすかを定義したほうがよい。このようなやり方は、いわば、辞書を使う方法とアルゴリズムに依る方法の折衷だ。 一般的に、辞書を使う“機械的な”要素同定方法も、自分なりの独自の細かいニーズを満たすためには、自作の独自のアルゴリズムで補う必要がある場合が多い。 また、半角(ASCII)の英数字を正しく認識させるためには、茶筅に別の辞書を使うよう指定しなければならない。それが面倒なら、本書のリスト4.9のZenToHanプログラムを参考にして文字種変換プログラム(仮称:HanToZen)を作り、オリジナルテキスト中の半角英数字を全角文字に変換したものを、茶筅に読ませるとよい。 ともかく、読者各位それぞれの、独自の仕事ニーズや研究ニーズのための言語処理を支えるルールは、一朝一夕で明文化〜プログラム化できるものではなく、多くの体験と試行錯誤を要する。私自身もまだ、茶筅というプログラムの『癖』の全体と細部を完全には把握していない。しかし、目的とする語句と、その出現ファイル名、行番号、行内位置といった一般的なインデクス情報ぐらいなら、現在の茶筅の出力の読み方をすこし工夫すれば、簡単に取り出せる。 インデクスをメモリ上に読み込んで高速な検索をしたいときには、Javaならプログラマが自分でツリー状データ構造の実装に苦労しなくても、TreeMapという出来合いのクラスを使える(java.util.TreeMap)。TreeMapに収める一つ々々のデータ項目[キー:値]ペアの、キーの部分は目的語句そのものだが、値の部分をどんなオブジェクトにするかは、あなたの目的ニーズ次第である。ファイル名、行番号、行内位置といったスタンダードなデータ集合だけでなく、もっといろんな情報を盛り込んだインデクス項目のオブジェクトを自作すればよい。 また、インデクス情報に基づいて対象ファイルを検索するときには、java.nioパッケージやjava.nio.channelsパッケージが提供している「ファイルの内容全体がマップされたバッファ(メモリ塊)」というオブジェクトを使えば、非常に高速なデータアクセスやその後の文字列処理等(たとえば正規表現を利用した処理)が可能だ。 この章のまとめ この6章では、一つの原始文字列(最後に改行文字が一つある/あった一つの文字列)を、要素に分解する手法を見てきた。 最初は、デリミタという物理的な区切り記号が原始文字列中に存在する場合について、いくつかの方法を検討した。結論としては、今のJavaでは正規表現(regular expression)の仕組みを使うのがいちばん簡便である。ただし、StringTokenizerを使う方法も、簡単なプログラミングの場合には捨てがたい便利さがある。 本文中では述べなかったが、デリミタで区切られたデータ行、というデータ記録形式は、非常に広範囲に使われている(一例:Microsoft Excel)。このデータ記録形式を利用すれば、データベースの自作も難しくない。 正規表現について検討する過程で、“捕捉グループ”という便利な仕組みについて、やや突っ込んで勉強した。捕捉グループというものがあるお蔭で、正規表現の扱いに大幅な柔軟性が備わっている。 最後に、デリミタという物理的な手がかりがない原始文字列から、独自のアルゴリズムを工夫してデータ要素を取り出す方法と、辞書を参照して取り出す方法について議論した。辞書としては、目的とするデータ要素が「日本語の語句」ならば、奈良先端科学技術大学院大学が提供している茶筅(chasen)プログラムを、独自に工夫しながら(=自作アルゴリズムも併用しながら)使うとよさそうだ。