Razorで空テンプレートとセパレータテンプレート

Razorに限らずT4でもなんでもいいんですが、テンプレートで素のforeachだと、セパレータだったり空の時の代替テンプレートだったりを、どういう風に表現すればいいのかなあ、と悩ましいのです、どうなっているのでしょう実際世の中的に。

WebFormsのRepeaterだとSeparatorTemplateタグと、拡張すればEmptyTemplateなども作れますね。Smarty(PHPのテンプレート、最近ペチパーなので)には{foreachelse}で配列が空の時のテンプレートが吐かれます。カスタムの構文を定義すれば、勿論なんだってありです。

RepeaterにせよSmartyにせよ、よーするところ独自のテンプレート構文だから好き放題できますが、俺々構文って、それ自体の覚える手間もあり、あんまスッキリしないんですよねえ。RazorのIs not a new language、だからEasy to Learn。は大事。また、そういった独自拡張がないからこそ、Compact, Expressive, and Fluidが実現できる(開き@だけで閉じタグレスはやっぱ偉大)し、フルにIntelliSenseなどエディタサポートも効くわけだし。

やりたいことって、コード上のノイズが限りなく少なく、かつ、HTMLという"テキスト"を最大限コントロールの効く形で吐くこと。なわけで、その辺を損なっちゃあ、見失っちゃあ、いけないね。

で、しかしようするところ、やりたいのはforeachを拡張したい。foreachする時に空の時の出力とセパレータの時の出力を足したい。あと、どうせならインデックスも欲しい。あと、最初の値か、とか最後の値か、とかも欲しい(最初はともかく「最後」はindexがないものを列挙すると大変)

そのうえで、Razorの良さである素のC#構文(と、ほぼほぼ同じものとして扱える)というのを生かしたうえで、書きやすくするには(例えばHtmlヘルパーに拡張メソッド定義して、引数でテンプレートやラムダ渡したり、というのは閉じカッコが増えたり空ラムダが出たりして書きづらいしグチャグチャしてしまいクリーンさが消える)、と思って、考えたのが、foreachで回すアイテム自体に情報載せればいいな、と。

<table>
    @foreach (var item in source.ToLoopItem(withEmpty: true, withSeparator: true))
    {
        // empty template
        if (item.IsEmpty)
        {
        <tr>
            <td colspan="2">中身が空だよ!</td>
        </tr>
        }

        // separator
        if (item.IsSeparator)
        { 
        <tr>
            <td colspan="2">------------</td>
        </tr>
        }

        // body
        if (item.IsElement)
        {
        <tr style="@(item.IsLast ? "background-color:red" : null)">
            <td>@item.Index</td>
            <td>@item.Item</td>
        </tr>
        }
    }
</table>

何も足さない何も引かない。とはいえどっかに何か足さなきゃならない。C#として崩さないで足すんなら、単独の要素の一つ上に包んで情報を付与してやりゃあいいんだね、と。foreachで回す時にToLoopItem拡張メソッドを呼べば、情報を足してくれます。

IsEmptyは全体が空の時、IsSeparatorは要素の間の時、IsElementが本体の要素の列挙の時、を指します。Elementの時は、更にIsFirst, IsLast, Indexが取れる。item.Itemはちょっと間抜けか。ともあれ、実際にRazorで書いてみた感触としても悪くなく収まってる。

Emptyだけならばループの外で@if(!source.Any()) /* 空の時のテンプレート */ としてやればいいし、そのほうが綺麗感はある。けれど、それだとsourceがIEnumerableの時キモチワルイ(二度列挙開始が走る)とかもあるし、コレクションに関わるものはforeachのスコープ内に全部収まったほうがスッキリ感も、なくもない。

IndexとIsLastだけが欲しいなら、空テンプレートとセパレータはオプションだから、withEmpty, withSeparatorを共にfalseにすれば、全部Elementなので、if(item.IsElement)は不要になる。

それにしてもRazor V2で属性にnull渡すと属性自体を吐かないでくれる機能は素敵ですなあ。クリーンは正義!

実装はこんな感じ。

public struct LoopItem<T>
{
    public readonly bool IsEmpty;
    public readonly bool IsSeparator;
    public readonly bool IsElement;
    public readonly bool IsFirst;
    public readonly bool IsLast;
    public readonly int Index;
    public readonly T Item;

    public LoopItem(bool isEmpty = false, bool isSeparator = false, bool isElement = false, bool isFirst = false, bool isLast = false, int index = 0, T item = default(T))
    {
        this.IsEmpty = isEmpty;
        this.IsSeparator = isSeparator;
        this.IsElement = isElement;
        this.IsFirst = isFirst;
        this.IsLast = isLast;
        this.Index = index;
        this.Item = item;
    }

    public override string ToString()
    {
        return (IsEmpty) ? "Empty"
             : (IsSeparator) ? "Separator"
             : Index + ":" + Item.ToString();
    }
}

public static class LoopItemEnumerableExtensions
{
    public static IEnumerable<LoopItem<T>> ToLoopItem<T>(this IEnumerable<T> source, bool withEmpty = false, bool withSeparator = false)
    {
        if (source == null) source = Enumerable.Empty<T>();

        var index = 0;
        using (var e = source.GetEnumerator())
        {
            var hasNext = e.MoveNext();
            if (hasNext)
            {
                while (true)
                {
                    var item = e.Current;
                    hasNext = e.MoveNext();
                    if (hasNext)
                    {
                        yield return new LoopItem<T>(index: index, isElement: true, isFirst: (index == 0), item: item);
                    }
                    else
                    {
                        yield return new LoopItem<T>(index: index, isElement: true, isFirst: (index == 0), isLast: true, item: item);
                        break;
                    }

                    if (withSeparator) yield return new LoopItem<T>(index: index, isSeparator: true);
                    index++;
                }
            }
            else
            {
                if (withEmpty)
                {
                    yield return new LoopItem<T>(isEmpty: true);
                }
            }
        }
    }
}

大事なのは、IEnumerable<T>へのループは必ず一回にすること、ね。よくあるAny()で調べてから、ループ本体を廻すと、二度列挙実行が走る(Anyは最初を調べるだけですが、もしIEnumerable<T>が遅延実行の場合、そのコストは読めない)というのは、精神衛生上非常に良くない。

あとIsLastを取るために、一手先を取得してからyield returnをしなければならないので、少しゴチャついてしまいましたが、まあ、こういうのがViewの表面上に現れる苦難を思えば!

最近、イミュータブルな入れ物を作りたい時はコンストラクタにずらずら引数並べるでファイナルアンサー。と思うようになりました、一周回って。名前付き引数で書かせれば、数が多くても可読性落ちたりとかないですし、これでいいでしょう。名前付きで書かせることを強制したいけれど、それは無理なので適度に諦めるとして。

最後にユニットテストを置いておきます。例によってMSTest + Chaining Assertionで。

[TestClass]
public class LoopItemTest
{
    [TestMethod]
    public void Empty()
    {
        Enumerable.Empty<int>().ToLoopItem(withEmpty: false).Any().IsFalse();
        Enumerable.Empty<int>().ToLoopItem(withEmpty: true).Is(new LoopItem<int>(isEmpty: true));
        ((IEnumerable<int>)null).ToLoopItem(withEmpty: false).Any().IsFalse();
        ((IEnumerable<int>)null).ToLoopItem(withEmpty: true).Is(new LoopItem<int>(isEmpty: true));
    }

    [TestMethod]
    public void Separator()
    {
        Enumerable.Range(1, 3).ToLoopItem(withSeparator: false).Is(
            new LoopItem<int>(index: 0, item: 1, isFirst: true, isElement: true),
            new LoopItem<int>(index: 1, item: 2, isElement: true),
            new LoopItem<int>(index: 2, item: 3, isLast: true, isElement: true)
        );

        Enumerable.Range(1, 1).ToLoopItem(withSeparator: true).Is(
            new LoopItem<int>(index: 0, item: 1, isFirst: true, isLast: true, isElement: true)
        );

        Enumerable.Range(1, 3).ToLoopItem(withSeparator: true).Is(
            new LoopItem<int>(index: 0, item: 1, isFirst: true, isElement: true),
            new LoopItem<int>(index: 0, isSeparator: true),
            new LoopItem<int>(index: 1, item: 2, isElement: true),
            new LoopItem<int>(index: 1, isSeparator: true),
            new LoopItem<int>(index: 2, item: 3, isLast: true, isElement: true)
        );

        Enumerable.Range(1, 4).ToLoopItem(withSeparator: true).Is(
            new LoopItem<int>(index: 0, item: 1, isFirst: true, isElement: true),
            new LoopItem<int>(index: 0, isSeparator: true),
            new LoopItem<int>(index: 1, item: 2, isElement: true),
            new LoopItem<int>(index: 1, isSeparator: true),
            new LoopItem<int>(index: 2, item: 3, isLast: false, isElement: true),
            new LoopItem<int>(index: 2, isSeparator: true),
            new LoopItem<int>(index: 3, item: 4, isLast: true, isElement: true)
        );
    }
}

structだと同値比較のために何もしなくていいのが楽ですね、けれどChaining AssertionならIsStructuralEqualがあるので、もしclassでも、やっぱり楽です!

まとめ

RazorだけじゃなくT4でコレクション回す時なんかにも使えます。なにかと毎度毎度、悩みの種なんですよねー。他に、こういうやり方もいいんでないー?とかあったら教えてください。

Profile

Yoshifumi Kawai

Cysharp, Inc
CEO/CTO

Microsoft MVP for Developer Technologies(C#)
April 2011
|
July 2024

Twitter:@neuecc GitHub:neuecc

Archive