Logo wizaman's blog (legacy)

string.Format()のDictionary対応

September 3, 2017
14 min read
Table of Contents

C#のstring.Format()は便利ですが使いにくいです。大まかな理由は下記にまとまると思います。

  1. インスタンスメソッドでない
  2. インデックスを正確に割り振る必要がある
  3. 書式が覚えられない

1.と2.に対して、C# 6.0でひとつの解決法は示されました。

ただ、私はPython使いでもあるので、Pythonぐらい文字列操作が柔軟であると嬉しいなと思ってしまうわけです。

詳しいことは順に説明することとして、名前をキーにしたDictionaryに対応したFormat()を実装するというゴールを目指します。

Format()が使いにくい理由

課題とゴールを確認します。だいたい「Pythonならこうする」という話です。

実装だけ見たければ読み飛ばしてください。

インスタンスメソッドでない

わざわざ型名から書いてstaticメソッドの呼び出しを書かないといけないのが冗長で苦痛です。

// こう書きたい
var result = "x = {0}".Format(1.28);
 
// 現実
var result = string.Format("x = {0}", 1.28);

Pythonなら次のように書けます。すっきり。

result = "x = {0}".format(1.28)

この面倒さはJoin()も同様です。頻繁に使う機能なのでつらい。

幸いC#には拡張メソッドがあるので簡単に対応できます。

public static class StringExtension {
    public static string format(this string str, params object[] args)
    {
        return string.Format(str, args);
    }
 
    public static string join(this string str, params string[] values)
    {
        return string.Join(str, values);
    }
}

インデックスを正確に割り振る必要がある

書式に与える引数は、書式内に記述したインデックスにより対応付けされます。このため、Format()で引数を与えるためには、インデックスを決めないといけないし、インデックスに対応する値を正しく与えないといけません。

例えば、下記のように2つ引数をとってFormat()を利用していたとしましょう(※DateTimeは専用の書式があるので普通こんなことしません)。

var now = DateTime.Now;
var text = string.Format("{0:d2}月{1:d2}日", now.Month, now.Day);

ここで先頭に引数を増やしたいとします。

var now = DateTime.Now;
var text = string.Format("{0}年{1:d2}月{2:d2}日", now.Year, now.Month, now.Day);

インデックスが1つずつずれるので、手で直す必要がありました。この手間によって、ヒューマンエラーが入り込む隙を生み出しています。利用者にインデックス管理を強いるインターフェースは最悪だと思います。

Pythonならインデックスを省略できて、勝手にインクリメントされたインデックスが割り振られます。省略表記のインデックス指定は混在できませんが、かなりマシでしょう。インデックスを省略しても、書式オプションを個別に書くことができます。

from datetime import datetime
now = datetime.now()
text = "{}{:02}{:02}日".format(now.year, now.month, now.day)

さらにPythonは名前付き引数を与えられます。

text = "{year}{month:02}{day:02}日".format(year=now.year, month=now.month, day=now.day)

名前指定ならインデックス管理するより直感的ですし、引数を増やしたからといって既存の引数名を修正する必要もないので扱いやすいですね。引数の順序だって気にする必要がないです。

ところで、C#ではparamsキーワードを用いて可変長引数を受け取ることができますが、これは配列で引数を受け取ることもできます。

var now = DateTime.Now;
var args = new object[] { now.Month, now.Day };
var text = string.Format("{0}月{1}日", args);

Pythonの場合、リストだけでなく、辞書を引数に展開できます。

from datetime import datetime
now = datetime.now()
args = {
    "year": now.year,
    "month": now.month,
    "day": now.day,
}
text = "{year}{month:02}{day:02}日".format(**args)

これはPythonが動的型付け前提だからこそ可能だと思うので、全く同じことをC#でも対応してほしい、というわけではないです。

ただ、Pythonから得られたアイデアとして、下記いずれかの機能は欲しいですね。

  • インデックスの省略
  • インデックスではなく名前で指定

両方対応してもいいのですが、今回は名前付き引数をDictionaryで与える方法だけを考えます。配列だけでなくDictionaryを引数に持てるようになる、というのは大きな進歩だと思います。

書式が覚えられない

書式の仕様が他言語に見られない独自仕様な上に、多機能すぎて複雑怪奇で覚えられません。別記事に愚痴をまとめました。

独自フォーマット仕様を作るのもデメリットが多いので、何か対策するわけでもないです。諦めましょう。

C# 6の文字列補間

C# 6から導入された強力な言語機能として、文字列補間(string interpolation)があります。

var now = DateTime.Now;
 
// 従来方式
var text1 = string.Format("{0}年{1:d2}月{2:d2}日", now.Year, now.Month, now.Day);
 
// 文字列補間による記述
var text2 = $"{now.Year}年{now.Month:d2}月{now.Day:d2}日");

$から始まる文字列リテラルが文字列補間であると解釈され、自動でstring.Format()の呼び出しコードに置き換えられます。つまり、コンパイラが勝手にインデックスとの対応付けを解決してくれます。糖衣構文(シンタックスシュガー)というやつですね。

C# 6が使える環境ならば、もはやstring.Format()を呼び出すコードを書く必要がなくなったし、文字列補間に統一すべきとさえ思います。ただ、Unityを使っている場合、C# 6が使えるようになったのは比較的最近の話なので、採用には慎重になる必要があります。C# 6を使う方法は下記の記事にまとめました。

文字列補間が使えるようになったとしても、string.Format()にDictionaryを渡せるようになると、Dictionaryのインターフェースを通して値を返すオブジェクトを挟む、という別の使い道も用意できるので、文字列補間とはまた別の価値があると考えています。

Dictionary対応Format()を実装

前置きが長くなりましたが実装します。

下記のことを考慮します。

  • メソッドの引数に与えるIDictionaryにはインデクサのみ要求
  • 置換の必要がなければ余計なアロケーションなし
  • string.Format()でインデックス指定していたのが名前指定に置き換わっただけで、残りの機能は引き継ぐ
    • 個別に書式指定
    • 波括弧のエスケープ

与えられた文字列を、キー名からインデックスに変換してstring.Format()に渡すのがシンプルで統一的な挙動にまとまるので、そうします。

最初はStringBuilderの使用を考えていたのですが、内部実装的には編集可能な文字列コンテナというだけのようだったので、冗長だと判断してやめました。

必要に応じて部分文字列リストを作成し、これをstring.Concat()で連結したものをstring.Format()に渡して、あとの処理は任せます。IDictionaryからは必要な値を一度だけ取得してキャッシュすることで、IDictionaryのインデクサが多少冗長でもコストが最小限になるようにしました。

public class FormatDictionary : Dictionary<string, object>
{
    public FormatDictionary()
        : base() { }
    public FormatDictionary(IEqualityComparer<string> comparer)
        : base(comparer) { }
    public FormatDictionary(IDictionary<string, object> dictionary)
        : base(dictionary) { }
    public FormatDictionary(int capacity)
        : base(capacity) { }
    public FormatDictionary(IDictionary<string, object> dictionary, IEqualityComparer<string> comparer)
        : base(dictionary, comparer) { }
    public FormatDictionary(int capacity, IEqualityComparer<string> comparer)
        : base(capacity, comparer) { }
}
 
public static class StringExtension
{
    #region 内部クラス
    private struct Formatter
    {
        public string format(string str, IDictionary<string, object> args, bool useOption)
        {
            clear();
 
            var length = str.Length;
 
            var begin = 0;
            var left = -1;
            var right = -1;
            var nameEnd = -1;
 
            // {key[,alignment][:formatString]}形式のパース
            for(var i = 0; i < length; ++i) {
                var c = str[i];
 
                switch(c) {
                case '{':
                    // 左波括弧の連続はエスケープなのでそのまま通過
                    if(left >= 0 && left == i - 1) {
                        left = -1;
                        continue;
                    }
 
                    left = i;
                    nameEnd = -1;
                    break;
                case '}':
                    // 右波括弧の連続はエスケープなのでそのまま通過
                    if(right >= 0 && right == i - 1) {
                        right = -1;
                        continue;
                    }
 
                    // フォーマット引数の処理
                    if(left >= 0) {
                        right = i;
 
                        // 前方部分文字列の反映
                        append(str.Substring(begin, left - begin + 1));
 
                        // 引数名の取得
                        var nameBegin = left + 1;
                        if(nameEnd < 0) {
                            nameEnd = i;
                        }
                        var key = str.Substring(nameBegin, nameEnd - nameBegin);
 
                        // 引数名をインデックスに変換して反映
                        var index = toIndex(key);
                        if(index == null) {
                            index = addArg(key, args[key]);
                        }
                        append(index);
 
                        // オプションがあれば反映
                        if(useOption && nameEnd < right) {
                            var option = str.Substring(nameEnd, right - nameEnd);
                            append(option);
                        }
 
                        // 閉じ括弧の反映
                        append("}");
 
                        begin = i + 1;
                        left = right = nameEnd = -1;
                        continue;
                    }
 
                    right = i;
                    break;
                case ',':
                case ':':
                    // 引数名の処理中であれば引数名の終了位置として記憶
                    if(useOption && left >= 0 && nameEnd < 0) {
                        nameEnd = i;
                    }
                    break;
                }
            }
 
            // 末尾の追加
            if(begin > 0 && begin < length) {
                append(str.Substring(begin, length - begin));
            }
 
            // フォーマット実行
            if(_list != null) {
                var format = string.Concat(_list.ToArray());
                _cache = format.format(getArgs());
            }
            else {
                _cache = str.format();
            }
 
            return _cache;
        }
 
        public override string ToString()
        {
            return _cache;
        }
 
        private void append(string str)
        {
            if(str.isNullOrEmpty()) return;
 
            // 必要になって初めてアロケート
            if(_list == null) {
                _list = new List<string>();
            }
 
            _list.Add(str);
        }
 
        private string toIndex(string key)
        {
            if(key.isNullOrEmpty()) return null;
            if(_indexes == null) return null;
 
            string index;
            if(_indexes.TryGetValue(key, out index)) {
                return index;
            }
 
            return null;
        }
 
        private string addArg(string key, object value)
        {
            // 必要になって初めてアロケート
            if(_indexes == null) {
                _indexes = new Dictionary<string, string>();
            }
            if(_args == null) {
                _args = new List<object>();
            }
 
            _args.Add(value);
 
            var index = (_args.Count - 1).ToString();
            _indexes[key] = index;
 
            return index;
        }
 
        private object[] getArgs()
        {
            if(_args != null) {
                return _args.ToArray();
            }
            return null;
        }
 
        private void clear()
        {
            if(_list != null) _list.Clear();
            if(_args != null) _args.Clear();
            if(_indexes != null) _indexes.Clear();
 
            _cache = "";
        }
 
        private List<string> _list;
 
        private List<object> _args;
        private Dictionary<string, string> _indexes;
 
        private string _cache;
    }
    #endregion
 
    #region 叙述
    public static bool isNullOrEmpty(this string str)
    {
        return string.IsNullOrEmpty(str);
    }
    #endregion
 
    #region 書式指定
    public static string format(this string str, params object[] args)
    {
        return string.Format(str, args);
    }
 
    public static string formatByName(this string str, IDictionary<string, object> args, bool useOption = true)
    {
        var formatter = new Formatter();
        return formatter.format(str, args, useOption);
    }
    #endregion
}

これで下記のように書けます。FormatDictionaryはいちいちDictionary<string, object>と書きたくないので用意したクラスです。IDictionary<string, object>を実装してインデクサから値が取れればなんでも使えます。

var now = DateTime.Now;
var args = new FormatDictionary {
    { "year", now.Year },
    { "month", now.Month },
    { "day", now.Day },
};
var text = "{year}年{month:d2}月{day:d2}日".formatByName(args);

C# 6ならば、インデクサに対応した初期化子が使えるので、下記のようにも書けます。

var now = DateTime.Now;
var args = new FormatDictionary {
    ["year"] = now.Year,
    ["month"] = now.Month,
    ["day"] = now.Day,
};
var text = "{year}年{month:d2}月{day:d2}日".formatByName(args);

formatByNameの第3引数useOptionをfalseにすると、:後に書かれる書式オプションをstring.Format()に渡さないようになります。無視するのではなく、{key:option}からkeyを取り出して辞書を引いていたところが、key:optionを取り出して辞書を引くようになります。単に波括弧内を辞書のキーにする挙動になるということですね。

ゲームプログラムにおいて、外部データに保存された文字列を使用してフォーマットすることを考えていて、細かい書式指定やらは必要な時にプログラマがやればいい、という想定でいます。なので、書式指定せずに置換だけ行う機能を選択肢に残しています。