関数型のテンプレートエンジンを作ってみる(0.1) - 変数の置き換え


最もシンプルなテンプレートエンジンは、あらかじめテキストを用意しておき、テキスト内の特定の箇所(プレースホルダ)を随時好きな値で置き換えて使用するようなものだと思う。とりあえずそれから実装する。


圏論やモナドが、どうして文書処理やXMLと関係するのですか? - 檜山正幸のキマイラ飼育記 (はてなBlog)」にあるトン吉サンプルが動くレベルまでを実装してみた。

イデアと方針

テンプレートエンジンというと、文字列内の特定箇所を置換して文字列を出力するものを考えがちだけど、別に文字列に限らなくてもいいと思う。例えばDOMのようなツリー構造を持つデータかもしれない。そこで、そういう「表現に固有」の部分と、そうでない部分をなるべく分離して考えるようにする。


「テンプレートエンジン作成用フレームワーク」と具体的な「テンプレートエンジンの実装サンプル」を平行して作成していくイメージ。


とは言え文字列がやはり基本という事で、サンプル実装は文字列ベースのものにする。
文字列→テンプレートとテンプレート→文字列の処理は別に作成し、エンジン内ではテンプレート→テンプレートの処理をひたすらぐるぐるまわす。

サンプル実装のテンプレートの仕様

文字列内の「{」「}」で囲われた部分を変数名として扱う。

たとえばこんなテキストをテンプレートとして使用して、

こんにちは、{お客様名}様。{来店日}にはご来店いただき、
まことにありがとうございます。

「お客様名」に「板東トン吉」、「来店日」に「1月21日」を指定して適用してやると、

こんにちは、板東トン吉様。1月21日にはご来店いただき、
まことにありがとうございます。

という文字列を返す。

実装(API)

まずインタフェースと基本的なクラスを定義して行く。この部分はGenericに書く。


テンプレートを表すインタフェース。Templateのapply()メソッドにContextを渡すとTemplateが帰ってくる。

package sample.hotplate.core;

/**
 * テンプレートを表現するインタフェース。実装時はTを継承したものにすること。
 * @param <V> 値の型
 * @param <T> テンプレートの型
 */
public interface Template<V, T extends Template<V, T>> {
    /**
     * テンプレートに変数を適用する。
     * @param context 変数の定義を含むContext
     * @return 適用した結果
     */
    T apply(Context<V, T> context);
    /**
     * @return 既約ならfalseを、そうでなければtrueを戻す。
     */
    boolean isReducible();
}

変数を保持するクラス。Symbolを渡すとTemplateを返してくれる。実装としては内部にHashMapあたりで保持する想定。

package sample.hotplate.core;

/**
 * 変数を格納するコンテキスト
 * @param <V> テンプレートの値の型
 * @param <T> テンプレートの型
 */
public interface Context<V, T extends Template<V, T>> {
    T get(Symbol name);
}

変数名をあらわすシンボルクラス。HashMapのキーに使うのでhashCode()とequals()も実装しておく。

package sample.hotplate.core;

/**
 * 変数名を表すクラス。
 */
public final class Symbol {
    /**
     * @return 文字列labelで表される変数名を戻す。
     * @param label 変数名として使用する文字列
     */
    public static Symbol of(String label) {
        if (label == null) {
            throw new IllegalArgumentException("label required.");
        }
        return new Symbol(label);
    }
    private final String label;
    private Symbol(String label) {
        this.label = label;
    }

    /** @return labelが等しいならtrue。 */
    @Override
    public boolean equals(Object obj) {
        if (obj == null || !getClass().isInstance(obj)) {
            return false;
        }
        return label.equals(getClass().cast(obj).label);
    }
    @Override
    public int hashCode() {
        return label.hashCode();
    }
    @Override
    public String toString() {
        return String.format("<Symbol: '%s'>", label);
    }
}


あとContextの操作が楽になるように便利クラスを作っておく。

package sample.hotplate.core.util;

import java.util.HashMap;
import java.util.Map;

import sample.hotplate.core.Context;
import sample.hotplate.core.Symbol;
import sample.hotplate.core.Template;

public class ContextBuilder<V, T extends Template<V, T>> {
    private final Map<Symbol, T> scope;
    public ContextBuilder() {
        this.scope = new HashMap<Symbol, T>();
    }
    private ContextBuilder(Map<Symbol, T> scope) {
        this.scope = scope;
    }
    public ContextBuilder<V, T> put(Symbol symbol, T value) {
        HashMap<Symbol, T> newScope = new HashMap<Symbol, T>(scope);
        newScope.put(symbol, value);
        return new ContextBuilder<V, T>(newScope);
    }
    public Context<V, T> context() {
        return new ContextImpl<V, T>(scope);
    }
    
    private static class ContextImpl<R, T extends Template<R, T>> implements Context<R, T> {
        private final Map<Symbol, T> scope;
        private ContextImpl(Map<Symbol, T> scope) {
            this.scope = scope;
        }
        @Override
        public T get(Symbol symbol) {
            return scope.get(symbol);
        }
    }
}

実装(文字列ベースのサンプル)

文字列からテンプレートを作り、変数(値はObjectまたはテンプレート)を適用し、最後にテンプレートを文字列に変換するような実装を作ってみる。


まず、Templateを継承したインタフェースを定義して、再帰的Genericだったところを閉じる。
また、あとで文字列化する時に使用するメソッドgetString()を定義する。

package sample.hotplate.sample;

import sample.hotplate.core.Template;

/**
 * テンプレートのサンプル実装の共通インタフェース。
 */
public interface SimpleTemplate extends Template<Object, SimpleTemplate> {
    /**
     * @return 内容を文字列として戻す。
     * @throws IllegalStateException 既約でない場合の例外
     */
    String getString() throws IllegalStateException;
}


これを継承した実クラスを定義していく。まず、変数を含まない、リテラルの文字列や値の表示をおこなうクラス。
変数を含まないため、applyしても変化しないはず。

package sample.hotplate.sample;

import sample.hotplate.core.Context;

/**
 * 値を含むテンプレートを表す。
 */
public class SimpleLiteral implements SimpleTemplate {
    /***/
    private Object value;
    public SimpleLiteral(Object value) {
        this.value = value;
    }
    public Object value() {
        return value;
    }
    /** @returnどんなcontextに対してもthisを戻す */
    @Override
    public SimpleTemplate apply(Context<Object, SimpleTemplate> context) {
        return this;
    }
    /** @return 常にfalseを戻す */
    @Override
    public boolean isReducible() {
        return false;
    }
    /** valueを自然な文字列で返す。 */
    @Override
    public String getString() {
        return value().toString();
    }
}

プレースホルダを表すクラス。
変数の値を含むContextを渡してapplyしてやると、Contextから値を取り出して戻す。つまり、変数がその値に置き換わる。
値が含まれていなければ変化しない、つまりthisを戻す。

package sample.hotplate.sample;

import sample.hotplate.core.Context;
import sample.hotplate.core.Symbol;

/**
 * 変数のプレースホルダを表すテンプレート。
 */
public class SimpleReference implements SimpleTemplate {

    private final Symbol symbol;

    public SimpleReference(Symbol symbol) {
        this.symbol = symbol;
    }

    /**
     *  @return contextが変数の値を含んでいればその値を戻す。
     *          含んでいなければthisを戻す。
     */
    @Override
    public SimpleTemplate apply(Context<Object, SimpleTemplate> context) {
        SimpleTemplate value = context.get(symbol);
        if (value == null) {
            return this;
        }
        return value;
    }

    /**
     * @return 変数を含んでいるため常にtrueを戻す。
     */
    @Override
    public boolean isReducible() {
        return true;
    }

    /**
     * プレースホルダーが残っているということで常に例外を投げる。
     */
    @Override
    public String getString() throws IllegalStateException {
        throw new IllegalStateException("Unbound variable:" + symbol);
    }
}

実際のテンプレートはプレーンな文字列とプレースホルダが並んだものなので、それを表すクラスを作る。
SimpleTemplateのListを渡して初期化する。

package sample.hotplate.sample;

import java.util.ArrayList;
import java.util.List;

import sample.hotplate.core.Context;

/**
 * 複数のテンプレートが繋がったテンプレートを表す。
 */
public class SimpleContainer implements SimpleTemplate {
    private final List<SimpleTemplate> elements;
    private final boolean isReducible;
    public SimpleContainer(List<SimpleTemplate> elements) {
        this.elements = elements;
        for (SimpleTemplate element : elements) {
            if (element.isReducible()) {
                this.isReducible = true;
                return;
            }
        }
        isReducible = false;
    }

    /** @return 要素すべてにcontextを適用し、結果を含むSimpleContainerを戻す */
    @Override
    public SimpleTemplate apply(Context<Object, SimpleTemplate> context) {
        if (!isReducible()) {
            return this;
        }
        List<SimpleTemplate> newElements = new ArrayList<SimpleTemplate>();
        for (SimpleTemplate element : elements) {
            newElements.add(element.apply(context));
        }
        return new SimpleContainer(newElements);
    }

    @Override
    public boolean isReducible() {
        return isReducible;
    }
    /** @return すべての要素を文字列化し、連結して戻す。 */
    public String getString() {
        StringBuilder sb = new StringBuilder();
        for (SimpleTemplate element : elements) {
            sb.append(element.getString());
        }
        return sb.toString();
    }
}

これでエンジンは完成。だけど使う為には、文字列→テンプレートの部分と、テンプレート→文字列の部分が必要なので実装する。
パース(toTemplate())はStringTokenizerで適当に実装。テキスト部分に「{」「}」は使えません。

package sample.hotplate.sample;

import java.util.ArrayList;
import java.util.List;
import java.util.StringTokenizer;

import sample.hotplate.core.Symbol;

public class SimpleTranslator {
    /**
     * 文字列からテンプレートを生成する。
     * 文字列に含まれる「{」から「}」までの部分は変数名として扱う。
     * @return 文字列から生成したテンプレートを戻す。
     * @param rawObject テンプレートの元になる文字列
     */
    public SimpleTemplate toTemplate(String rawObject) {
        List<SimpleTemplate> elements = parse(rawObject);

        return new SimpleContainer(elements);;
    }
    /**
     * @return 適用済みのテンプレートを文字列に戻す。
     * @param template 適用済みのテンプレート
     */
    public String fromTemplate(SimpleTemplate template) {
        return template.getString();
    }

    enum Mode {
        LITERAL,
        TAG
    }
    private List<SimpleTemplate> parse(String rawObject) {
        List<SimpleTemplate> elements = new ArrayList<SimpleTemplate>();
        StringTokenizer stringTokenizer = new StringTokenizer(rawObject, "{}", true);
        Mode mode = Mode.LITERAL;
        while (stringTokenizer.hasMoreTokens()) {
            String token = stringTokenizer.nextToken();
            switch (mode) {
            case LITERAL:
                if (token.equals("{")) {
                    mode = Mode.TAG;
                } else if (token.equals("}")) {
                    throw new IllegalArgumentException(
                            "Illegal format string. Unexpected '}' occured." + rawObject);
                } else {
                    elements.add(new SimpleLiteral(token));
                }
                break;
            case TAG:
                if (token.equals("{")) {
                    throw new IllegalArgumentException(
                            "Illegal format string. Unexpected '{' occured." + rawObject);
                } else if (token.equals("}")) {
                    mode = Mode.LITERAL;
                } else {
                    elements.add(new SimpleReference(Symbol.of(token)));                
                }
                break;
            }
        }
        return elements;
    }
}

テスト(使用サンプル)

リンク先のヤツを書き下ろしてみた。
「{挨拶}{来店御礼}」というトップレベルのテンプレートに対し、まず「{挨拶}」「{来店御礼}」それぞれをサブテンプレートに置換し、その後サブテンプレート内のプレースホルダをさらに置換する。

package sample.hotplate.sample;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;

import org.junit.Test;

import sample.hotplate.core.Context;
import sample.hotplate.core.Symbol;
import sample.hotplate.core.util.ContextBuilder;

public class SimpleCompilerTest {
    @Test
    public void testTonkichi() {
        SimpleTranslator translator = new SimpleTranslator();

        // トップレベルのテンプレート
        SimpleTemplate template = translator.toTemplate("{挨拶}{来店御礼}");

        // コンテキスト1: サプテンプレートが定義されている
        Context<Object, SimpleTemplate> context1 =
            new ContextBuilder<Object, SimpleTemplate>()
            .put(Symbol.of("挨拶"), translator.toTemplate("こんにちは、{お客様名}様。"))
            .put(Symbol.of("来店御礼"),
                translator.toTemplate("{来店日}にはご来店いただき、\nまことにありがとうございます。"))
            .context();
        // コンテキスト1を適用
        SimpleTemplate applied = template.apply(context1);

        // コンテキスト2: サブテンプレート内の変数の値が定義されている
        Context<Object, SimpleTemplate> context2 =
            new ContextBuilder<Object, SimpleTemplate>()
            .put(Symbol.of("お客様名"), new SimpleLiteral("板東トン吉"))
            .put(Symbol.of("来店日"), new SimpleLiteral("1月21日"))
            .context();
        // コンテキスト2を適用
        applied = applied.apply(context2);

        // 最後に文字列に変換
        String output = translator.fromTemplate(applied);

        // すべて展開されているかを確認
        assertEquals(
            "こんにちは、板東トン吉様。1月21日にはご来店いただき、\nまことにありがとうございます。",
            output);
    }
/*

「ルールは大事よね」

例によってモナド則とか言うヤツ。
apply()がずばりbindになっているはず。
Contextが関数の型になっているはず。
unitは変数(Symbol)からプレースホルダのテンプレート(SimpleReference)を作る処理となる。

 */
    public static Context<Object, SimpleTemplate>
        unit =
            new Context<Object, SimpleTemplate>() {
                public SimpleTemplate get(Symbol varname) {
                    return new SimpleReference(varname);
                }
            };
    public static SimpleTemplate unit(Symbol varname) {
        return unit.get(varname);
    }
/*

関数が等しいということは直接テストできないけど、とりあえず同じ入力に対する出力が一致することを確認する。

 */
    // (return x) >>= f ≡ f x」
    @Test
    public void testMonad1() {
        Symbol symbol = Symbol.of("お客様名");
        Context<Object, SimpleTemplate> context =
            new ContextBuilder<Object, SimpleTemplate>()
            .put(symbol, new SimpleLiteral("板東トン吉"))
            .context();
        
        assertEquals(
            unit(symbol).apply(context),
            context.get(symbol));
    }
    // 「m >>= return ≡ m」
    @Test
    public void testRule2() throws Throwable {
        SimpleTranslator translator = new SimpleTranslator();
        SimpleTemplate template = translator.toTemplate("こんにちは、{お客様名}様。");

        assertEquals(
                template.apply(unit),
                template);
    }
    // 「(m >>= f) >>= g ≡ m >>= ( \x -> (f x >>= g) )」
    @Test
    public void testRule3() throws Throwable {
        SimpleTranslator translator = new SimpleTranslator();
        SimpleTemplate template = translator.toTemplate("{挨拶}");

        final Context<Object, SimpleTemplate> context1 =
            new ContextBuilder<Object, SimpleTemplate>()
            .put(Symbol.of("挨拶"), translator.toTemplate("こんにちは、{お客様名}様。"))
            .context();

        final Context<Object, SimpleTemplate> context2 =
            new ContextBuilder<Object, SimpleTemplate>()
            .put(Symbol.of("お客様名"), new SimpleLiteral("板東トン吉"))
            .context();

        Context<Object, SimpleTemplate> preapplied =
            new Context<Object, SimpleTemplate>() {
                public SimpleTemplate get(Symbol name) {
                    return context1.get(name).apply(context2);
                }
            };
        assertEquals(
                (template.apply(context1)).apply(context2),
                template.apply(preapplied));
    }
}


あ、上のテストが通るようにSimple〜クラスには適当にequals()を実装してます。
例: SimpleLiteralでの実装

...
    /** @return valueが等しいならtrue。 */
    @Override
    public boolean equals(Object obj) {
        if (obj == null || !getClass().isInstance(obj)) {
            return false;
        }
        return value.equals(getClass().cast(obj).value);
    }
...