TOMLで型の異なる要素を持つ配列が許可された

起きたこと

TOML v0.5.0までは、配列に異なる型の要素を混ぜるのは許可されていなかった。

array    = [1, 2, 3]              # OK
invalid1 = [1, "foo", 2019-11-08] # error
invalid2 = [1, 2, 3.14, 4, 5]     # error

が、混ぜたいという人がかなり多く、ずっと議論が続いていた(Make arrays heterogeneous · Issue #665 · toml-lang/toml · GitHub)。そしてついに、長らくTom氏と共にTOMLライブラリを管理してきたPradyun Gedam氏の賛成によってPRが作られTom氏の賛成もあってマージされた。

TOMLファイルへの影響

この変更によって、どんなメリットがあるか。まずはマージされたPRにある例を持ちだそう。

# Mixed-type arrays are allowed
numbers = [ 0.1, 0.2, 0.5, 1, 2, 5 ]
contributors = [
  "Foo Bar <foo@example.com>",
  { name = "Baz Qux", email = "bazqux@example.com", url = "https://example.com/bazqux" }
]

1つめの例は比較的自明なもので、数値の配列において整数と浮動小数点数を区別する必要がなくなる。2つめの例はもうすこし実践的なもので、簡潔なフォーマットと詳細なフォーマットを混在させられるというものだ。この例では、ライブラリのコントリビュータ一覧で、「名前とメールアドレスを繋げた文字列」という伝統的なフォーマットと、「名前、メールアドレス、個人のwebページなどのフォームを持つテーブル」というより詳細でよく定義された(が、書くのが面倒な)フォーマットを混在させられるようになる。

他に、Issueで議論されていた他の例も紹介してみよう。例えば、以下はサーバーの情報を管理する例だ。一つのサーバーごとに以下のような情報を覚えておくことにする。

[connection]
host = "127.0.0.1"
port = 6000
data_timeout = 1000
conn_timeout = 5000
lazy_connect = true
only_udp = false

このようなサーバーが複数ある時、v0.5.0までのTOMLでは以下の2つのやり方があった。1つめは、そのままこのテーブルをArray of tablesにする方法。

[[connections]]
host = "127.0.0.1"
port = 6000
data_timeout = 1000
conn_timeout = 5000
lazy_connect = true
only_udp = false

[[connections]]
host = "1.2.3.4"
port = 6000
data_timeout = 500
conn_timeout = 5000
lazy_connect = false
only_udp = false

# 必要なだけ続く...

これでは縦に長くなりすぎるだろう。そういった場合のため、inline tableの表記を使う方法もある。

connections = [
    {host = "127.0.0.1", port = 6000, data_timeout = 1000, conn_timeout = 5000, lazy_connect =  true, only_udp = false},
    {host =   "1.2.3.4", port = 6000, data_timeout =  500, conn_timeout = 5000, lazy_connect = false, only_udp = false},
    # ...
]

今度は横に長過ぎる。

もしArrayが型が入り乱れることを許してくれていれば、以下のような書き方が許されるだろう。

connections = [
    # Host            Port  Data    Conn    Lazy     Only
    #                       timeout timeout connect  UDP
    # -------------------------------------------------------
    [ "127.0.0.1",    6000, 1000,   5000,   true,    false ],
    [ "1.2.3.4",      6000,  500,   5000,   false,   false ],
    [ "5.5.5.5",      6000,  100,   2000,   true,    true  ],
    [ "90.10.133.17", 6000, 1000,   5000,   true,    false ],
    [ "18.20.18.20",  7777, 5000,   1000,   false,   true  ],
    # -------------------------------------------------------
]

個人的な感想

これは 非常に 大きな変更だ。TOMLを使っているアプリケーションの中はフォーマットの再考をするものも出てくるかも知れない。私の個人的な意見は、この変更は積極的に反対はしないが完全に肯定するわけでもない、という微妙な立場だ。とはいえ無関心ではない。ここまで大きな変更に無関心でいるのは難しい。正確に言うと、私は簡潔な書き方が選べるようになることを喜びつつも、多分私はこの書き方を採用した場合に起きるかもしれない混乱(とまではいかないだろうが)を少し心配している。喜び7、心配3くらいの比率で。私は元来少し度が過ぎた心配症だからかもしれないし、あるいは、メリットだけが強調されている場合、デメリットを考えてバランスを取りたくなるのかもしれない。

別に諸手を上げて賛成していない理由はtoml11で配列中の型の混在を指摘するエラーメッセージを作るのに苦労したからではない(比較的苦労はなかった--テーブルとdotted keyの関係の方がよっぽど苦労した)し、toml11でこの機能がサポートできないからでもない。実際、これに対応するための変更は配列を読んだ時に型チェックをやめることだけだ。

基本的にこの変更は今までの書き方を壊すものではないし、むしろより簡潔な書き方を許す拡張だ。なので積極的に否定する理由はない。選択肢が増えるのは、たいていの場合よいことだ。では何を心配しているのか。これによって簡潔さを高めようとすると、厳密性やエラーメッセージのわかりやすさを若干犠牲にするからだ。この記法を採用する際は、アプリケーション側でエラー処理などに力を入れるか、あるいはエラーからの回復やエラーメッセージのわかりやすさをある程度までは諦めなければならなくなる。

例として、サーバーの情報を書くテーブルで、ミスで数字を一つ抜かしてしまったとしよう。

connections = [
    # Host            Port  Data    Conn    Lazy     Only
    #                       timeout timeout connect  UDP
    # -------------------------------------------------------
    [ "127.0.0.1",          1000,   5000,   true,    false ],
]

このミスはパース時には見つけられない。文法違反はどこにもないからだ。パーサからすると、これは配列の配列があるだけだ。問題はパース結果から設定を読み込む際に起きる。アプリケーションは6個の要素を要求したが、5つしか要素がない。

toml11でこれをサポートした場合、ユーザーは恐らく以下のようなコードを書くだろう。

for(const auto& connection : toml::find(file, "connections").as_array())
{
    const auto server = toml::get<
            std::tuple<std::string, int, int, int, bool, bool>
        >(connection);
}

その場合のエラーメッセージは以下のようになる。

[erorr] toml::get specified std::tuple with 6elements, but
 there are 5 elements in toml array.
 --> TOML literal encoded in a C++ code
 5 | [ "127.0.0.1",          1000,   5000,   true,    false]
   | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ here

あ、エラーメッセージで要素数の後に空白入れるの忘れてるな。しかも[error]の部分typoしてるし。後で直します。

この場合、要素数が異なること以上の情報はなく、ポート番号が抜けているという情報はない。これをアプリケーションが指摘するには、型チェックを行い、何番目の要素が歯抜けになっているかを探さなければならない。今回は一番最初の文字列と後ろの論理値2つが存在しているので、ポートか、データタイムアウトかコネクションタイムアウトのどれかが抜けていることはわかるが、その3つのうちどれが抜けているかはわからない。この3つは同じ整数型で、どの一つを取り除いても2つの整数が残るからだ。2つの整数があるということだけからは、どの値が抜けているかは確定できない。ポート番号としてはあり得るがタイムアウトとしてはあり得ない値、みたいなものがあればヒントになるが、そういうものがなければお手上げだ。推測すらできない。となると、ユーザー自身に抜けている値を探させることになる。まあ普通探せるだろうとは思うが。

もしこれがテーブルだったなら、toml::findを使うはずで、「キー"port"がありません」というエラーメッセージが出ただろう。

[error] key "port" not found
 --> TOML literal encoded in a C++ code
 1 | {host = "127.0.0.1", data_timeout = 1000, conn_timeout = 5000, lazy_connect = true, only_udp = false}
   | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ in this table

これなら、ユーザーはファイルを開いて注意深く読むよりも前に「あ、ポート番号忘れてた」と自分のミスを知る機会が得られる。また、アプリケーションにはポート番号にデフォルト値を与える機会を得られる。

キーがわからないというのなら、例えばCSVファイルでよく行われているように、キーを明示的に配列として渡してはどうか。

connections = [
    ["Host", "Port", "Data timeout", "Conn timeout", "Lazy connect", "Only UDP"],
    # ------------------------------------------------------
    [ "127.0.0.1",          1000,   5000,   true,    false ],
]

残念ながらこれでも解消されはしない。問題はデータの配列にある。これでもやはりまだ、"Port", "Data timeout", "Conn timeout"のどれが欠けているかという情報は提供されないままだ。ユーザーがカラムごとのデータを明示できることはよいことだが、全ての問題が解決するわけではない。

要するに、キーの並び順だけで意味のある(が型が同一である)値を指定させる場合は、そのアプリケーションのユーザーに十分な知識と忍耐力と正確さを要求しなければならないわけだ。

もちろん、これは心配のしすぎだ。この程度のことでこの拡張をリジェクトするようなら、人々はPythonC++の代わりにHaskellとRustを使っているだろう。入力する情報を削ることによって簡潔さを向上させると、エラーからの回復やエラー箇所の推測は難しくなる。当然のことだ。情報がなくなっているのだから。それでも簡潔さを取った方がいい場合や、ファイルの簡潔さが重視されるアプリケーション、簡潔さが何よりも勝る文化圏というものはあるだろう。選択肢が増えることは純粋に喜ばしいことだ。

とはいえ、この機能を使って設定ファイルを書こうと思っているアプリケーション開発者は、情報を削ることによって難しくなることもあるということは念頭に置くべきだろう。それが簡潔さと比べて受け入れられるデメリットなら採用すればいいし、そうではないなら従来通りの記法を使う。選択肢が増えるのが喜ばしいことに変わりはないが、選択肢が増えたことによって自分の選択に責任が生じるのは開発者のつらいところだな。覚悟はいいか? オレはできてる。

追記

実装した。あとはCIを待ってマージしたら、TOML11_USE_UNRELEASED_TOML_FEATURESを定義すると使えるようになるはずだ。

ところでコミットメッセージでPRの番号をメモったらGitHubで勝手にコミットメッセージがリンクされてしまった(自分のレポジトリに#xxxってつけた時かリンクをフルに書いたときだけだと思っていたのでhtttps://github.com/の部分を取っていたのだが、普通に検出されてしまった)。別にアピールする気はなかったのだが。