新連載:階層化設計と反復型開発を使ってライン・エディタをテキスト・エディタに進化させる

著者:Ian Darwin

2019年8月20日

本記事のタイトルを読んだ方は、「なぜJavaでテキスト・エディタを作りたいのだろうか。JavaはエンタープライズWebアプリのためのものではないのか」と思うかもしれません。私の最初の答えは、「とんでもない」です。Javaは現在もデスクトップで使われており、活躍しています。私の次の答えは、一部のJava開発者にとって主な「安全地帯」であるWeb環境から離れることで、設計や、興味深い実装の問題に焦点を移すというものです。

本記事では、反復型実装により、簡単なラインモード・テキスト・エディタを作ることに的を絞ります。つまり、シンプルな実装から始め、新機能を追加しながら設計を更新し、その再実装を行います。

次回の記事では、このエディタをグラフィカルなデスクトップ・エディタに進化させます。形はさまざまに変わっても、実体が純粋なテキスト・エディタである点、つまり、プレーン・テキストのファイルを変更するプログラムであることは変わりません。プレーン・テキストとは、フォントや色が変わらないテキストです。バッチ・ファイルやスクリプト・ファイル、プログラムのソース・ファイル、構成ファイル、ログ・ファイルなど、今でも世界中でコンピュータ向けの情報の大部分に使われています。プレーン・テキスト・エディタと完全なワード・プロセッサの間には、大きな隔たりがあります。ワード・プロセッサは、文字の書体やサイズや色の選択、画像やスプレッドシートの埋め込み、テキストの左揃え、右揃え、中央揃えなどの多くの機能を備えています。短い連載記事でこれらすべてを紹介するのは難しいと思われることから、まずはプレーン・テキストに焦点を当てることにします。

エディタを設計する場合、主に2つのことを考える必要があります。1つはユーザー・インタフェース(UI)またはコマンド・レイヤー、もう1つはバッファ管理です。UIまたはコマンド・レイヤーでは、ユーザーがメモリ内のバッファに対して何をすることができるかを定義します。こういったタスクは、たとえば、ライン・エディタであれば簡単なコマンド、スクリーン・エディタならマウス操作、インタラクティブ・システムなら音声コマンドになるでしょう。バッファ管理とは、ユーザーがバッファ内のテキストをどのように扱うかということです。通常、テキスト・エディタ(およびその強力な親戚であるワード・プロセッサ)は、変更しているファイルのコピーをメモリ内に保持しています。このコピーは、メモリ内のバッファに保管されます。エディタでは、ユーザーがエディタを終了したとき、または何らかの「保存」コマンドが発行されたときに、この変更されたテキストが元のファイルの代わりにディスクに書き込まれます。バッファ管理はモデル・コードとしても知られ、メモリ内にある編集中のファイルの内容を管理することに関係しています。最終的なプロダクトを便利でメンテナンスしやすいものにするためには、UIとバッファ管理、そしてその間にあるすべての重要なインタフェースをうまく設計する必要があります。

 

コードのレイヤー間の境界

Javaインタフェースの定義は、コードの論理レイヤーを分離するための強力(で一般的)な方法です。アプリケーションのレイヤー間や、(現在または将来的に)複数の実装を持つ可能性が高いクラスでは、インタフェースを使うべきです。その結果、クラスが特定の実装ではなく、インタフェースに依存するようにすることができます。

この場合、BufferPrimsインタフェースが両方のニーズに合致します。このインタフェースはレイヤー境界であり、複数の実装を持ちます。これは、バックエンドにデータベースがあるアプリケーションに似ています。このようなアプリケーションではおそらく、中間(ビジネス・ロジック)レイヤーとデータベースのコードの間にインタフェースが存在するでしょう。これにより、JDBC、Java Persistence API(JPA)/Hibernate、場合によってはNoSQLデータベースを切り換えて使用できるようになります。JPAのEntityManagerやHibernateのSessionは、この目的では十分に汎用的だと言う方もいるでしょう。また、アプリケーション固有のインタフェースを使うことを提案する方もいるかもしれません。すべてのアプリケーションに当てはまる正しい1つの答えはなく、これは設計上の考慮事項になります。

エディタには、コマンド・レイヤーとバッファ管理レイヤーとの間のインタフェースが必要です。このレイヤー間のインタフェースはとても簡単なもので、たとえば次のようになります。

    public interface BufferPrims {
        addLineAfter(int afterLine, String newLine);
        deleteLine(int lineNumber);
        replaceText(oldText, newText);
    }

このインタフェースでは、バッファ管理がどのように動作するかについてコマンド・コードにほとんど伝えていません。同時に、UIがどのように動作するかについてバッファ管理に何も伝えていません。この点は重要です。相手方に影響を与えずに片方を交換できることが望ましいからです。これは、レイヤー化されたソフトウェア全般に当てはまります。レイヤーは、その上にあるレイヤーについて何も知るべきではありません(たとえば、さまざまなUIから呼び出せるようにするためです)。また、知っているべきなのは、直下のレイヤーを呼び出す方法だけで、他には何も必要ありません。

実際のエディタを作るためには、もう少し包括的なインタフェースが必要です。ここでは、次のようにまとめました。

    public interface BufferPrims {
        final int NO_NUM = 0,
            INF = Integer.MAX_VALUE;
        void addLines(List<String> newLines);
        void addLines(int start, List<String> newLines);
        void deleteLines(int start, int end);
        void clearBuffer();
        void readBuffer(String fileName);
        void writeBuffer(String fileName);
        int getCurrentLineNumber();
        String getCurrentLine();
        int goToLine(int n);
        int size();     // 古いコレクションの時点での行数
        /** 1つまたは複数の行を取得 */
        String getLine(int ln);
        List<String> getLines(int i, int j);
        /** 現在の行のみを対象に、最初またはすべての 
         *  「古い」正規表現を「新しい」テキストに置き換える */
        void replace(String oldRE, String newStr, boolean all)
        /** 各行の最初またはすべての検索対象を置き換える */
        void replace(String oldRE, String newStr, 
            boolean all,
            int startLine, int endLine);
        boolean isUndoSupported();
        /** 直近の操作を元に戻す 
         *  オプション・メソッド */
        default void undo() {
            throw new UnsupportedOperationException();
        }
    }

ほとんどの操作は単純と思われます。本記事のソース・コードでは、BufferPrimsインタフェースの3つの実装を提示しています。オプションのundoメソッドは、1つの実装には含まれていますが、他の2つには含まれていません。この実装については、以前にCommandデザイン・パターンについての記事で説明しました。

ここにはread()write()を入れるべきではないと思う人や、メイン・プログラムで1行ずつファイルを読み取り、いずれかのadd()メソッドを使って行を取り込むべきだと考える人もいるかもしれません。このインタフェースにread()メソッドやwrite()メソッドがあるのは、効率のためです。1回の読取り操作でファイル全体を読み取るバージョンがあっても構わないでしょう。

 

バッファ管理

大きく異なる複数の実装が存在するのは、インタフェースが合理的な設計に基づいている証拠です。しかし、このことは効率について何も触れていません。効率を気にしないのであれば、1つのStringオブジェクトにすべてを保管することもできます。しかし、文字列は不変であるため、このアプローチでは多くの再割当てが必要になります。そのため、純粋な実装の基盤という面では、StringBuilderStringBufferの方が優れています。実際には、バッファ・プリミティブ(BufferPrims)の最初の実装では、BufferPrimsStringBufferを使っています。

StringBufferには、バッファの内容を変更する組込みメソッドなどのいくつかのメリットがあります。しかし、本質的に単語のリストのリスト(さらに正確に言えば、行のリスト)であるものを表す自然な構造ではありません。StringBufferの実装(この実装が可能であることを示すために書くことにしました)では、行が始まる場所と終わる場所を見つけるためだけに、いくらかの作業が必要になります。

整合性を保つため、各行は改行文字1文字(‘\n’)で終わり、キャリッジ・リターン(‘\r’)は完全に禁止することを前提としました。このアプローチにより、改行文字を探すだけで行が終わる場所と次の行が始まる場所を見つけることができます。このクラスのほとんどの操作は、StringBufferのメソッドを呼び出して、そのStringBufferの特定の位置にある文字の取得や設定を行うことで終わります。

たとえば、次に示すのは現在の行の内容を取得するコードです。

    @Override
    public String getCurrentLine() {
        int startOffset = findLineOffset(current);
        int len = findLineLengthAt(startOffset);
        return buffer.substring(startOffset, 
                                startOffset + len);
    }

findLineOffsetメソッドでは、正規表現を使って行を検索します。これは改行文字を探すforループほど簡単ではありませんが、実行可能で純粋な実装だと言えます。

同様に、StringBufferベースの行検出にも、同じ2つのメソッドを使っています。

    @Override
    public void deleteLines(int startLine, int endLine) {
        if (startLine > endLine) {
            throw new IllegalArgumentException();
        }
        int startOffset = findLineOffset(startLine),
                endOffset = findLineOffset(endLine);
        buffer.delete(startOffset,
            endOffset + findLineLengthAt(endOffset) + 1);
    }

おそらく、もっとも複雑なコマンドは置換コマンド(s)でしょう。「UIとコマンド構造」のセクションでは、いくつかの種類の置換コマンドについて説明します。UIレイヤーでは、すべてのコマンド・オプションを解析した後、次に示す2つのメソッドのいずれかを呼び出す必要があります。

    void replace(String oldRE, 
                 String newStr, boolean all);
    void replace(String oldRE, 
                 String newStr, boolean all,
                 int startLine, int endLine);

all変数では、置換対象となるのが一致したすべての結果なのか、1つ目だけなのかを制御します。次に示すのが、replaceを1行で実装したものです。

    @Override
    public void replace(String oldRE, 
                        String newStr, boolean all) {
        int startOffset = findLineOffset(current);
        int length = findLineLengthAt(startOffset);
        String tmp = 
            buffer.substring(startOffset, length);
        tmp = all ? tmp.replaceAll(oldRE, newStr) : 
                    tmp.replace(oldRE newStr);
        buffer.replace(startOffset, length, tmp);
    }

第2の実装:バッファ・コードの第2の実装となるのが、BufferPrimsNoUndoです。この実装では、バッファのデータをList<String>に格納しています。行単位での処理は、こちらのデータ構造を使った方が簡単です。たとえば、現在の行を取得する処理は、次のコードだけで実現できます。

    @Override
    public String getCurrentLine() {
        return buffer.get(
            lineNumToIndex(current));
    }

(行番号は1から始まりますが、Listのインデックスは0から始まります。そのため、lineNumToIndex()メソッドで行番号からリストのインデックスに変換しています。こうする方が、毎回1を引くよりもきれいに見えます。)

「行の削除」操作も簡単になります。

    @Override
    public void deleteLines(int startLnum, 
                            int end) {
        int startIx = lineNumToIndex(startLnum);
        for (int i = startIx; i < end; i++) {
            if (buffer.isEmpty()) {
                System.out.println(
                    "?Deleted all lines!");
                break;
            }
            buffer.remove(startIx); // iではない
        }
        current = startLnum;
    }

なお、このクラスのメソッドの一部が、AbstractBufferPrimsクラスに格納されていることに注意してください。このクラスは、両方のListベースの実装で使用しています。

第3の実装:最後のBufferPrimsWithUndoは、一歩先に進んだ実装で、その名のとおり「元に戻す」操作を実装しています。簡潔に言えば、バッファを変更する操作を行うたびに、その逆の操作を行うコードを含むラムダ式も作成されます。たとえば、ここでの「行の削除」操作は次のようになります。

    @Override
    public void deleteLines(int startLnum, int end) {
        List<String> undoLines = new ArrayList<>();
        for (int i = startLnum; i < end; i++) {
            if (buffer.isEmpty()) {
                System.out.println("?Deleted all lines!");
                break;
            }
            undoLines.add(buffer.remove(i - 1));
        }
        current = startLnum;
        if (!undoLines.isEmpty()) {
            pushUndo("delete lines " + startLnum + " to " + end,
                () -> addLines(startLnum, undoLines));
        }
    }

一部の操作はBufferPrimsNoUndoととてもよく似ているため、実際には、super()を使ってAbstractBufferPrimsクラスの処理を再利用しています。

    @Override
    public void addLine(String newLine) {
        super.addLine(newLine);
        pushUndo("add 1 line", () -> 
            deleteLines(current, current));
    }

pushUndo()メソッドでは、説明文字列(GUIのあるエディタで使うためのもの)と、現在のコマンドを「元に戻す」操作を行うラムダ式をまとめるラッパー・オブジェクトを作成しています。deleteLines()の「元に戻す」操作では、削除された行すべてを追加できることが必要です。そのため、すべての行をListに格納し、削除の逆の操作であるaddLines()に渡しています。「元に戻す」のメカニズムと、そのメカニズムを実装するCommandデザイン・パターンの詳しい説明は、先ほど触れた記事をご覧ください。

 

UIとコマンド構造

すべてをゼロから再発明(通常、これはよくない考え方です)しなくてもよいように、このライン・エディタのUIには、UNIXのedコマンドのUIを堂々と拝借することにします。このUIは、何十年もの間にわたって標準であり続けており、exおよびviの両者のベースとなり、さらにはストリーム・エディタsedの大部分のベースとなりました。 次に示すのは、すべてのコマンドの基本フォーマットです。

    [n,m]C[operands]

この意味は次のとおりです。

  • 角括弧は、その中のテキストが省略可能であることを示します。

  • nmは行番号です(1から始まり、デフォルトは現在の行です。現在の行とは、最後に操作を行った行のことです)。

  • Cは1文字コマンドです(たとえば、dは削除、aは追加、sは置換)。適時最新のコマンド・リストは、ソース・プロジェクトのREADMEファイルに記載されています。

  • operands(オペランド)は、使用するコマンドによって異なります。

なお、小文字1文字でコマンドを表すという、このインタフェースの設計は、最初にこの設計が生み出されたころの、ユーザーとコンピュータとの間の非常に低速なキーボード・インタフェースの影響を受けたものであることには注目する価値があります。しかしこれは、設計を無制限に拡大させないことを意図した選択でもありました。つまり、可能なコマンドは26個ほどしか想定しなかったということです。よいことか悪いことか、後の開発者はいくつかの大文字コマンドを追加しました。今回のエディタで大文字コマンドは実装しませんが、最近のUNIXやLinuxのedではおそらく実装されているでしょう。

すべてのJavaアプリケーションには、起動のためのpublic static void main(String[] args)メソッドが必要です。通常、このメソッドではコマンドライン・オプション(存在する場合)を処理し、ファイルのチェックやオープンを行い、処理用のメソッドに制御を委譲します。一般的に、処理用のメソッドは静的でないインスタンス・メソッドです。今回の例でのメイン・プログラムは、LineEditor.javaに含まれています。

ここでのmainメソッド内にはループがあります。そこでは、コンソールから1行を読み取り、解析してエディタのコマンドである可能性が高いかを確認し、コマンドであれば、その文字を文字コマンド固有のルーチンに渡しています。メイン・コードのループは次のようになっています。

    // エディタのメイン・ループ:
        while ((line = in.readLine())  != null) {
            ParsedCommand pl = 
                LineParser.parse(line, buffPrims);
            if (pl == null) {
                System.out.println("?");
                continue;
            }
            EditCommand c = commands[pl.cmdLetter];
            if (c == null) {
                System.out.println(
                    "?Unknown command in " + line);
            } else {
                c.execute(pl);
            }
    }

1文字コマンドを処理するコードは、Commands.javaで設定しています。具体的には、EditCommandオブジェクトの配列を使い、1文字と、コマンドを実装したラムダ式とのマッピングを行っています。任意のASCII文字を使えるよう、この配列の長さは255にしていますが、実際に実装されているのは20ほどしかありません。コマンド処理の簡単な例として、次にpコマンド(印刷を表す”print”の略です)の実装を示します。

    // p - 行の印刷
        commands['p'] = pl -> {
            buffPrims.getLines(pl.startNum, pl.endNum)
                     .forEach(System.out::println);
    };

このコードの変数plは、ParsedCommand(以前はParsedLineでした)を表します。ParsedCommandは、コマンド、行番号の範囲、そしてオペランドを表すクラスです。ユーザーがコマンドを入力するたびに、LineParserクラスのparse()メソッドによって、そのコマンドを表すParsedCommandが作成されます。

コマンド・ハンドラの中には、先ほどのpのように、短くて扱いやすいものもあります。しかし、中にはかなり長いものもあります。興味がある方は、sコマンド(置換を表す”substitute”の略です)の実装をご覧ください。このコマンドでは、ある範囲の行に対して反復処理を行わなければならないだけでなく、デリミタに任意の文字を使用できなければなりません。さらに、デリミタが2回または3回存在することを確認し、最後のデリミタの後にgpが任意の順番で存在しているかどうかも確認する必要があります。gは”global”(グローバル)または”go across the line”(行をまたぐ)を表します。このコマンドがない場合、edjでは最初に見つかった古いテキストのみが置き換えられます。最後にpを指定した場合、対象の行が印刷されます。以下に、有効なコマンドの例をいくつか示します。

    s/him/them/     # 現在の行を対象に、最初の"him"を"them"に変更
    s=him=them=     # 上記と同じだが、違うデリミタを使用
    2,3/him/them/   # 2行目から3行目を対象に、最初の"him"を"them"に変更
    5,s/him/them/   # 5行目から末尾までを対象に、最初の"him"を"them"に変更
    ,s/him/them/    # すべての行を対象に、最初の"him"を"them"に変更
    s/him/them/g    # すべての行を対象に、すべての"him"を"them"に変更
    s/him/them/p    # 現在の行を対象に、最初の"him"を"them"に変更し、 
                    #   行を印刷
    s/him/them/gp   # 上記の2つの例を組み合わせたもの

この構文を学ぶためには時間が必要です。また、すべての種類を正確に解釈するためには、数行のコードが必要になります(parseSubstitute()をご覧ください)。ただし、このコマンドの簡潔さと強力さを見れば、ed(およびそこから派生したviなどのエディタ)が今もオープンソースの世界でシステム管理者や開発者に愛用されている理由がわかります。

 

パラメータ化テスト

「早い段階から頻繁にテストする」というのは、信頼性の高いコードを書くための格言です。多数の単体テストを作成し、少しでも変更が発生したときに実行するということです。バッファ・プリミティブのテストは簡単で、ほとんどのコードに対してテストが存在します。3種類の実装に対してテストをコピー・アンド・ペーストすることを避けるため、ここではパラメータ化テストという、JUnit 4の機能を使います。@Parametersアノテーションを付加したメソッドからは、コンストラクタの引数(任意の型)リストが返されるようにします。このリストは、テスト・クラスの個々のインスタンスを作成するために使われます。テスト・クラスのフィールドは、コンストラクタによって設定されます。引数は任意の型でよいため、クラス・ディスクリプタ(たとえば、Reflection APIクラス)を使用しています。次に、BufferPrimsTestの関連部分を示します。

    @RunWith(Parameterized.class)
    public class BufferPrimsTest {
        protected BufferPrims target;
        public BufferPrimsTest(Class<BufferPrims> clazz) throws Exception {
            target = clazz.newInstance();
        }
        @Parameters(name = "{0}")
        public static Class<BufferPrims>[] params() {
            return (Class<BufferPrims>[]) new Class<?>[] {
                BufferPrimsStringBuffer.class,
                BufferPrimsNoUndo.class,
                BufferPrimsWithUndo.class };
        }
        // @Testアノテーションを付加したメソッド...
    }

コマンドおよび置換オペランドを解析するコードのテストも存在します。

 

さらなる作業

この状態のedjは、たとえ完全版が手元にあっても、使いたいと思うようなものではないでしょう。あるいは、viやそのさまざまな派生物のようなスクリーンベースのエディタであっても同じでしょう。edのコマンドの一部は実装されておらず、実装されているものもほとんどが不完全です。たとえば、行の範囲を解析する部分には、「検索」の機能がありません。実際のUNIXやLinuxにおけるedの実装では、開始行または終了行の行番号のいずれか(または両方)の代わりに、検索文字列を指定できます。検索文字列を、/pattern/と指定した場合は現在の行から前方(下方向)に検索でき、?pattern?と指定した場合は上方向に検索できます。コードは全体的に、このような機能を追加する必要がある場合、誰でも拡張して完全な実装にできるような設計になっています。

 

コードの再利用

設計全体を検証するために、UNIXのストリーム・エディタsedのとても簡単な概念実証実装を作成しました。UNIXのsedコマンドは、名前のついたファイル(ファイルが指定されていない場合は標準入力)を1行ずつ読み取り、コマンドラインで指定されていたすべての編集コマンドを各行に適用します。次に例を示します。

    sed -e 's/old/new/g' -e 's=more=less=' file1

想像どおりかもしれませんが、file1内のすべての”old”を”new”に置換し、各行の最初の”more”を”less”に置換するものです。これは、バッチ・エディタというよりはストリーム・エディタです。file1は変更せず、変更結果を標準出力に書き込みます。 バッチ・エディタの作成、すなわち、コマンドにエラーがあったときやディスクがいっぱいになったときにファイルを安全に保つことは、今回の概念実証の範囲外です。

このStreamEditorでは、LineEditorとは異なり、1つのコマンドしか実装していません。ただし、実装しているのはもっとも複雑なs(置換)コマンドです。StreamEditorでは、行を解析するコードと置換コマンドの解析コードを再利用しています。このStreamEditorのプロトタイプでは、実装されている1つのコマンドは問題なく動作します。この概念実証のデモとして、stests(stream tests)という名前のシェル・スクリプトを作成しています。次回の記事では、バッファ処理コードを再利用する予定です。

 

コードの入手

このバージョンのエディタを、edj(editor in Java、「エッジ」のように発音します)と呼んでいます。繰り返しになりますが、全体を1か所で見てみたい方は、GitHubからコードをダウンロードできます。

 

まとめ

本記事では、かなり単純なテキスト・エディタをJavaだけで実装するために必要な構造の設計を行う際の問題や、その場合のコードの一部について見てきました。ここで、重要な点をいくつか挙げておきます。

  • 行指向のパラダイムで正規表現の力を活用するUIの設計

  • UIと、土台になるバッファ管理コードとの間のインタフェースの設計

  • 効率的である一方でわかりやすいバッファ管理の実装

UIの設計は、UNIXのedのUIをベースにしています。また、BufferPrimsのインタフェースは、筆者がかなり前に作成したこのエディタのC言語による再実装にかすかに似たものとなっています。その再実装自体は、『Software Tools』という書籍向けにRatForという教育用言語で書いた、さらにその前のバージョンがベースになっています。今回紹介したバージョンのインタフェースは、最初から完全な形で考えついたわけではなく、エディタの進化とともに何度か改訂を重ねた結果です。次回の記事では、このインタフェースを再利用して、実際に使用できるGUIテキスト・エディタを作成する方法を紹介したいと思います。

 

Java Magazine December 2019の他の記事

プロパティベース・テストを習得する
Arquillian:簡単なJakarta EEテスト
ArchUnitでアーキテクチャの単体テストを行う
新しいJava Magazine
クイズに挑戦:Collectorsの使用(上級者向け)
クイズに挑戦:ループ構造の比較(中級者向け)
クイズに挑戦:スレッドとExecutor(上級者向け)
クイズに挑戦:ラッパー・クラス(中級者向け)
書評:Core Java, 11th Ed. Volumes 1 and 2


Ian Darwin

Ian Darwin(@Ian_Darwin):Java Champion。メインフレーム・アプリケーションから、UNIXおよびWindows向けのデスクトップ・パブリッシング・アプリケーション、Javaによるデスクトップ・データベース・アプリケーションやAndroid向けのヘルスケア・アプリまで、あらゆる開発を手がける。『Java Cookbook』、『Android Cookbook』(いずれもO’Reilly)の著者。また、Learning Tree Internationalでいくつかのコースを作成し、多くのコースで教えている。