moonlanderを買って3ヶ月が経った

もっと経ったっけ。

とりあえず、今は結構慣れた。最初の1ヶ月くらいは結構速度が落ちてイライラしていたものの、2ヶ月めには記号もほぼ考えずに打てるようになっていて、こういう文章を書く上で苦労することはほぼなくなった。とはいえ長年指を最適化してきたRealforceでの速度が100%出るかというとまだ少し微妙で、85~90%程度といったところか。

肩への負荷という意味では、やはりmoonlanderのほうが楽だ。というかRealforceを使っているときに窮屈さを感じる。ただRealforceのほうが若干高速に打てるので、急いでいるとき用の限定解除みたいな気持ちで使うことはある。男の子はこういうのがいくつになっても好きですからね。

仕事場では昔から持ち込んでいるRealforceをそのまま使っているので、一日の3割くらいしかMoonlanderを使っていないということもあり(しかも最初の一ヶ月はタイピングが遅くなるのが嫌すぎて家でも結構Realforceを使っていた)、慣れるのに時間がかかってしまった。今では他人のマシンを触るときなどRealforceではない(普通の)キーボードを打っているとき、たまに親指が存在しないキーを打とうとする程度には慣れた。Realforceではそういうことは起きないので、多分本当に脳が今打っているキーボードがRealforceかそれ以外かで切り替えてるんだと思う。


ところで、最初はMoonlanderに備え付けの足を使ってテンティングして使っていたのだが、なんだか打ちにくく感じていた。最初は慣れの問題だろうと思っていたのだが、考えてみると私はRealforceをかなりティルトさせて使っていたので、もしかしてティルトがあったほうが楽なのかなと思ってManfrottoのミニ三脚を買ってみた。こいつだ。

https://www.amazon.co.jp/dp/B00GTLFHT8

これに、1/4ネジの穴がついている磁石を買って接続し(ネジ自体はミニ三脚についてくる)、

https://www.amazon.co.jp/dp/B0CXPPV2B3

Moonlander側にはスチールプレートを買って、それによって接続した。この三脚が結構優秀で、つるつるした机の上でもちょっと手を乗せている程度ではズレない。また、磁石も強力すぎるくらいなのでこちらもずれない。というわけでほぼ問題なく、満足している。値段は結構したが。

ただこの構成には少し問題がある。実はMoonlanderの裏面にはアタッチメントを接続するためのネジ穴が空いており、このネジ穴の縁が盛り上がっているのでスチールプレートがここにしか接着されず、接続が弱くなってしまう。平たい板のようなものよりも、中空のリング状のスチールプレートを購入するといいだろう。今はスチールプレートの上からシールを保護するシートみたいなやつで覆うことでごまかしている。

実はMoonlanderには三脚との接続オプションパーツがあるので、こうなることがわかっていたら最初からそれも買ったのだが、最初は使わないだろうと思っていたので買わなかったのだった。海外から買わないといけない以上オプションパーツ単体で注文するのは少しバカバカしいので購入していない。amazonくらいの気軽さで買えたら良かったのだけど。遊舎工房が扱ってくれないかな。無理か。

というわけで、これによって弱めのテンティングに傾斜キツめのティルトを組み合わせたところ、かなり手に馴染んで高速になった。 意外なところが効いているものだ。個人的なものだろうけども。

ただ、三脚をつけたことによって持ち運びは難しくなってしまった。三脚を持ち運ぶときには折りたたまないといけないのだが、折りたたむと丁度いい傾きを再現するのが手間だからだ。逆に、外に持っていかない以上静音性のことはあまり気にしなくてよくなったため、スイッチを交換して音が出る重めのタクタイルなものにしてみた。個人的にはこのほうが好きだな。


カラムスタッガードで左右分割なので、最初はタイピングでの運指を矯正する効果もあるのではと少し期待していたのだが、そのような効果はなかった。というか、私はキーボードは高速に入力できてなんぼだと思っているので、タイピング速度の最適化に比べれば標準運指なんかバカバカしいと心のどこかで思っている。こんな考え方で運指が矯正されるわけがなかった。

実際、今moonlanderでは右手の運指がRealforce時代よりも少し変になった。Realforceの頃よりも右寄りになった気がする。もしかしたら、左右分割になる前は無意識に左手ゾーンを右手で打っていたのかもしれない。

考えてみると、このQWERTY配列は高頻度で打つキーが変な位置にあるので、運指を最適化すると標準運指から外れるのは当たり前なのだ。標準運指にこだわる前に配列を最適化したほうがいい。とはいえこれをアンラーンするのはかなり大変な上、他のマシンを触るのが難しくなりすぎるので、流石にそこまでやる気はあまりないのだが。

moonlanderを入手して1週間が経った

Realforceが壊れたとかではないのだが、他のキーボードを知らないまま死んでいくのかという気持ちに少しなったのと、分割キーボードは肩こりの対策に実際によいという話を聞いたので、Moonlanderを購入した。

www.zsa.io

そこそこ慣れてきてはいるのだが、配置を結構弄ったのでまだ(特に記号が)マッスルメモリになっていない。頭で(ここにマッピングしたから……)という変換が入るので、文章の打ち始めや記号の入力前に若干のラグがある。 ただ覚えられてはいるので、そのうち指が覚えるだろう。悲観するほどの状態ではない。

いくつか覚書をしておく。

入手まで

はじめはUHKを試してみようと思っていたのだが、「どうせチャレンジするのなら普通のキーボードを割ったやつよりもっと異常なものにチャレンジしたら?」と言われてそれもそうだと思いなおした。

なのでErgodox EZを買おうかなと思って調べると、同じ会社が別バージョンを出していることを知った。小さくて薄型のvoyagerというのもあったのだが、個人的には持ち運びの際にある程度大きくなっても大丈夫な点と、ロープロファイルなキーボードが若干苦手な点からmoonlanderを選んだ。 色は黒にした。PCをはじめデスクの上に黒いものが多いからだ。

軸は静音赤軸にした。タクタイルやクリッキーなのも嫌いじゃない、というか結構好きなのだが、音がうるさすぎると使える場面が限られてしまう可能性があるので静音赤にした。

はんだ付けは中高の頃に少しやっただけであまり慣れているとは言い難いので、自作には手を出さなかった。というかそっちの選択肢をあまりよく知らなかった。

注文してから届くまではかなり早かった。数日後にはShippedのメールが来て、そこから一週間は経っていない。空いている時期だったからかもしれないが、拍子抜けだった。もっと待つと思ってた。

ハマりポイント

これは自分が不注意だっただけなのだが、moonlanderのキーキャップは印字ありとなしの二種類あり、実はプロファイル1が異なる。私はプロファイルの差に気付いていなかった。

印字なし版はキーの入れ替えを考えなくてよいのでOEMになっているが、印字あり版は全て同じ形状、OEMのR3に揃えられている。これはQWERTY以外の配列、例えばdvorak配列とかを使いたい人が困らないようにするためだろう。なまじ傾きが付いていると並び替えたときにガタガタになるからだ。

私はずっとRealforceの形状に慣れていたので、平たいプロファイルで若干精度が落ちた。今は適当に買ったXVXプロファイルの安いやつで印字部分だけ入れ替えて使っている。周辺はもとのまま。それでも若干精度が上がった気がする。

どうせなら大きめの傾斜が付いていて欲しかったので、調べていてMDA Future Suzuri が欲しくなったのだが、終売しているし作者の方も手持ちは出してしまった上今後の再生産は難しそうだと仰っているので、今はもう入手不可能だろう。どうしたものかな。

一応、fkcapsのカスタムではMDAプロファイルのキーキャップが作れるので(MDAプロファイルにはmoonlanderの縦長1.5uはないけれど)、いっそ自分で発注するのもいいかもしれない。MDAではないフラットなキーキャップなら縦長1.5uもあるようだ。 yuzuの方にもあるが……Cherry profileなのか。

……いやこっち行くと沼だな? 落ち着こう。今のキーキャップもそこまで悪いわけではない。

入手初日

家には対人作業用のwindowsと個人で完結する作業用のubuntuがあるのだが、まずどちらも普通に認識した。

……と言いたいところだったのだが、最初に繋いだ時はUSBケーブルを直接繋ぐ左側しか認識されず、右側はLEDはつくものの反応がなかった。windowsubuntuでそれぞれ試したり、普段使っているKVMスイッチを介さずに繋いでみたり、色々やっていたのだが無理でうわー初期不良?故障?……と思っていたところ、左右を繋ぐケーブルを逆に繋いだら動いた。これは何かがおかしい気がする。このケーブルって方向とかあるか? それまでも何度か挿しなおしてはいたのだが、毎回差し込みが甘かったのだろうか。でもLED点いてたんだけど……。

まあいいや。向きを変えた後はどちらも特別何もしなくても動いた。

とりあえずテントさせて手を置いてみた。思っていた以上に端のキーが遠い。というか親指の島のキーも遠い。私は身長が高い方に1.5σ程度の位置にいる(日本人成人男性)ので手もそこそこ大きいはずなのだが、指をホームポジションに、手首をリストレストに置いたままだと親指島の一番内側のキーは相当無理して親指を伸ばさないと押せない。赤いキーも手首を浮かさないと届かないので高速に押すのは難しそうだ。手首を浮かせていればどちらのキーもそこまで大変ではないが、シンプルに疲れる。数字キーはまあ打てるものの、四隅のキーは届きにくい。

ただ、テントせずに平置きした場合、指をホームポジションに置いても親指が島の中央あたりに来る。こうなると端のキーも押しやすい。とはいえテントしている方が姿勢は楽な気はするので微妙なところだ……。

それから、moonlanderはデフォルトで虹色に光る。私は光っているのは嫌いじゃないが、光が動いていると注意を持っていかれてしまって困る。なので、アニメーションを切って単色に統一し、レイヤーごとに色を変えることで今いるレイヤーがすぐにわかるようにした。 とはいえキーのholdでレイヤーを変えるようにすると色での判断は必要なくなったので、全部同色でもいいかもしれない。ちなみにPCのファンも光っているが、同じ理由で色は固定している。

レイアウト最適化

しばらくは練習しつつキー配置の最適化を図った。

一番考えないといけないのは親指島だろう。最初は上の赤いキーにレイヤー切り替え、下の三連に左からspace, ctrl, 英数、かな、shift、Enterと置いていたのだが、赤いキーによるレイヤー切り替えが高速にできなかったのでレイヤー切り替えは早々に変更した。 spaceを長押しで記号数字レイヤーに切り替わり、Enter長押しで矢印移動レイヤーに切り替わるようにしたのだが、レイヤー分けを複雑にするとかなり混乱したので単純化した。 移動レイヤーからはマウス操作を取り除いてvim風のhjklでのカーソル操作に絞り、hjklに使わない左手側のホームであるF長押しで移行するようにした。

そして親指島の一番内側のキーは打つのがあまりにも難しかったので、思い切ってそこは削った。英数・かなは削除し、代わりに全角半角を左の二番目に置いてある。ctrlは結局端に置いた(これだけ不満。脳内を読んでレイヤー切り替えとCtrlを切り替えてほしい)。

次に考えるべきは記号だ。私は小さい頃にJIS配列に慣れてしまったので、今に至るまでずっとJIS配列を使っている。なので最初はJIS配列のこまごまとしたキーを右端に置いて溢れる分を調節していた。 ただ、最初に言った通りこのキーボードだと端の列の上下端を触るのがかなり難しかったので、徐々に使うキーを絞りながら日本語文章を打つ際に必要なキーを選別していった。 プログラミングをしている間はかなり大量の記号を打つので選別の過程ではあまり気にしないことにした。

そうすると、逆に記号レイヤーへの切り替えが必要かどうかで混乱するようになってしまったので、二枚目のレイヤーにほとんどの記号を押し込んで、「記号はとにかく別レイヤー」という風に頭に刷り込んだ。その方が結局楽だった。 今もメインレイヤーに記号が少し残っているが、あまり使っていない。

最初の1,2日は一日二回レイアウトを調整していたが、次第にレイアウト変更の間隔が伸びていき、今は三日変えていない。そろそろ状態が変わらなくなってきた。とりあえずの局所最適には来た感じがする。

ヒートマップを見る限りほぼ押していないキーもあるが(端のshiftや右端のEnterは本当に一回も使ってないっぽい)、たまに暴発しても致命的ではないし、消すまでもないだろう。しばらくはこれで行く。

configure.zsa.io

ハマりポイント

windowsマシンを入手した際に、最初にレジストリを弄ってまずCtrlとCapsLockを入れ替えたのだが、そのせいでleft ctrlが機能しなくなっていてしばらく困っていた。 キーボード的にはLeft Ctrlを送っていたのだが、OSによってcapslockに変換されていたようだ。この設定のことを完全に忘れていたので、しばらくキーボードの設定が悪いと思って試行錯誤していた。 今は面倒になって全てをright ctrlにしている。

それから、moonlanderで日本語配列にするためにQMKのJIS配列設定を見て何がどう扱われているかを調べていた。OSがキーボードをJISだと思っていればShiftで入力される文字なんかは変わらないので、USにないキーを探すだけで事足りたのは楽だった。

細かくはレイアウトを見てもらえばいいのだが、わかりにくかったものをいくつか。

  • 「\ _」は「Int 1」
    • internationalのこと。
  • 「] }」は「Non-US # and ~」
  • 「¥|」は「Int 3」

ところで最近設定を見ていたらAdvanced Configurationに"International"という項目があって、JPというのがあることに気付いた。もしかしてこんな変な設定必要ないのか?

感想

キーマッピングを最適化している途中で自分のタイピングの癖が見えてきたのが面白かった。

私はタイピングの練習というか教本みたいなものを真面目に読んだことがない。ホームポジションという言葉を知らないまま、ポッチが付いているキーを目安にすると便利だなあと自分で発見していたし、標準運指をかなりガン無視している。これは、勉強するよりも前にパソコンで長文を書いたりチャットで発言権を得るために高速で反応したりを繰り返しているうちに染みついてしまった動きで、矯正の機会がなかったのだ。

例えば、私は日本語でも使う子音であるYとHを、7割程度左手の中指と人差し指で押している。これは右手が母音を押しがちなので左右左右と打てるように勝手になったのだろう。 これに関しては最初から分割キーボードを使うにあたって心配だったのだが、moonlanderでは何ら問題になっていない。分割面に追加のキーがあるからだ。私のマッピングにはYとHが左手用と右手用で二つある。

あとは、指しか覚えていないショートカットがたくさんあって、Realforceで指に動いてもらってからそれを再現していた。最初は「うーんこのキーはあんまり押さないから左遷するかー」と思っていたキーがかなり押されていたりして笑った。

ところで、未だにZXCVを押し間違えるというか、CとVの間やXとCの間を押してしまったりして厳しい。ただここは結構みんなそうっぽいので仕方ないのかな。


普通のキーボードが使えなくなるのではという不安はない。今は仕事場でRealforce、家でMoonlanderを使っているが、Moonlanderでの精度と速度が向上している一方で、Realforceでの速度は一切落ちていない。脳みそが手を置いている場所や角度ごとにマクロを構築している感じがする。多分このまま二刀流にできると思う。

プログラミングをまじめに勉強し始めたのがB3の頃、Realforceを買いに行ったのがM1の頃、toml11のv1リリースがM2の頃なので、私のプログラミング生活はほぼRealforceに支えられていたと言っていい。そんなRealforceを完全に手放す気にはあまりなれないので、二刀流にできそうなのはいい傾向だ。


  1. キーキャップの形状のこと。段によってキーの傾きが違ったりする。

自宅鯖の死活監視をする

昨年末から下書きに眠っていた記事を発掘した。


自宅には何匹かサーバー的運用をしているマシンがいるが、中でも使用率の高いものにsamoyedと名前をつけているサーバーがいて、家用のwiki(日記やプライベートなメモ、公開するほどでもない技術メモなどが置かれている)を筆頭に、適当に見つけては役に立ちそうだと思って立ち上げたサーバーが色々と立っている。

ただ、一時期家のネットワークが不安定だった時期があり(本当に回線が落ちていたことも一度あったが、大抵はルーターの問題だったのでその後買い替えた)、そのときに発見が遅れて出先で困ったため、死活監視をすることにした。

とはいえ、確認は本当に生きているかどうかだけでよい(別にwikiの応答が多少悪くなったとしてもどうでもいい)ので、パフォーマンスまで監視するのはオーバーキルだ。 ちょうど家用のslack workspaceがあるので、そこに専用のチャンネルを作り、定時報告させることでとりあえずOKということにした。slackでトークンを発行して、それを使って「:dog: ヘッヘッ」(サモエドなので)とポストするpythonスクリプトを書き、cronjobとして登録した。 samoyed 自身の電源が落ちたりネットワークが落ちたり家が燃えたりするとこれが所定の時間に来なくなるので、何かが起きたとわかる。

ただ、これだけだと片手落ちだ。 人間はsamoyedチャンネルを常に監視しているわけではないので(そんな暇な人間はいない)、定時報告がなかったときに気づかない問題は解決していない。 samoyedは一応毎回生存報告を通知するのだが、「そういえば通知来てないな」と必ず気付けるかというと微妙だ。こういうのは自動化するべきだ。

ところで、発端は家のネットワークが何度か落ちたことだった。 家のネットワークの状態も監視したいのだから、家のネットワークが落ちたら通知できないというのでは困る。なのでこの監視botは外で動いてもらう必要がある、のだが……。 我が家のネットワークは外でスマフォやノートPCから接続するためにVPNを張っており、これを外部サービスと連携するのは(やったことないけど)面倒くさそうなので若干気が重い。 この問題は、slackへのポストを監視対象にすることで解決する。 slackにはトークンさえ発行していればどこからでも繋がるので、botは家のネットワークの外で走らせておけるし、家のネットワークに繋げる必要もない。

というわけで方針は決まった。 samoyedが「ヘッヘッ」を投稿してくる時間はわかっているので、その時刻に反応がなければ何かあったと判断してこちらに通知を送ってくるようにすればよい。

が、ちょっと探しても、定時報告をpostするところまでで満足している記事が出てくるばかりで、あまり真面目に通知を出そうとしているものは出てこなかった。 多分ある程度重要なサーバーになるとdatadogとかその手のやつが使われて、こんな変な方法で死活監視をする人はいないのだろう。

とはいえ、slackbotにはリプライを送ったりすると応答してくるものがいることを我々は知っている。ということは、定時報告が ない ことに応答するものも作れるはずだ。

ただ、私は実はslackbotを作ったことがない。今までは便利なものを連携するばかりで自作はしてこなかった。そういうわけでchatgptに作らせることにした。 内容はシンプルだし、slack botに関しては巷に大量のデータがあるだろうから無料版でも十分できるだろう。自分で調べて作るより楽だと思う。

実装(?)

2024年の12月くらいにやっていた。モデルのことをあまり覚えていないが、4oだった気がする。

なんとなく英語のほうがまともな返事が来る気がするので(初期の印象にいつまでも引っ張られ過ぎかも)英語で聞いているが、ざっくり日本語訳して書く。返答は不要な詳細が大量にあったりして長すぎるので人力要約する。

私:サーバーの死活監視を行いたい。すでに、サーバーが生きているときはslackにポストするようなボットを動かしている。このメッセージが来なかったときに通知を送るようにしたい。どうすればいいだろうか?

ChatGPT-4o:1. Heartbeatやwatchdogを使う、2. Botのコード内でエラーハンドリングを行う、3. エラーが起きたときにSMTPサーバーからメールを送る、4. UptimeRobotやDatadogなどの外部サービスを使う

今見直すと全く話を聞いていないどうしようもない回答だが、私の方もやりたいことを具体的に説明していないので駄目。引き分けというところか(何が?)。

私:私が書いたボットはすでに処理が失敗した場合に通知を送るようにしている。しかしサーバーがネットワークから切断されていたり落ちていた場合、そのサーバー自身は通知を行うことができない。サーバーの外からその状態を監視する必要があるのだ。

ChatGPT-4o:1. PingかHeartbeatを使う、2. DatadogやZabbixを使う、3. 別のサーバーを立てて定期的にpingなどのリクエストを送りそこから通知する

外部サービスを使いたくないとは伝えていないので散々紹介されるのは仕方ない。実際、ビジネスでやっている場合は変なことせずに外部サービスを使ったほうがいいだろうし。でも今回はそこまでするようなものじゃない。

私:私のマシン同士はVPNで繋がっており、外部サービスを接続するのは面倒だから嫌だ。slackではリプライに応答するbotを作れるんだから、その逆で、所定の時刻にポストがなかったら反応するbotも作れるだろう?

ChatGPT-4o:historyを取ってきて最後のポストの時刻をチェックすればいい。「pythonのコード」

最初からこう聞けばよかった。

返答待ちの間にスクリプトを動かすためのサービスを裏で検索して検討し、GASに決めておいた。

私:こういうのでいいんだよ。ところでこれはGoogle Apps Scriptで動かしたい。書き直してくれ。

ChatGTP-4o:はいよ。「コード」

このコードは(トークンとチャンネルIDなどを適切に設定するだけで)動いた。 ただ落ちた際にポストされるだけで通知が来ないなどの問題はあったので、メッセージを犬風にする(「ヘッヘッ」の代わりに「クゥーン」にする)のと同時にそこは手直しした。

なんとなくのポリシーとして、応答に満足した場合はお礼を言っている。特に意味はない。

私:ちゃんと動いたぜ。ありがとよ。

ChatGTP-4o:またな。

その後の経過

GASでは、cron構文自体は使えないものの、特定の時間幅のどこかで動作するトリガーを設定できる。 samoyedはcronで一日に6回定時報告してくるので、その時刻から一時間後までの幅のどこかで実行するように指定したトリガーを6個作った。 この時間幅のどこで実行されるかは不定だそうだから、実行された時点から一時間前までに何もポストがなければ異常として通知を送るようにしている。 もしかすると0時ジャストのsamoyedのpostが完了する前に監視botが動いてしまって間違えて異常通知が来るかもしれないが(今の所そんな状況は観測していない)、その場合は異常通知と正常通知が並ぶ形になるので、人間が見たら誤報だと瞬時にわかる。のでそのあたりは気にしないことにした。

これは昨年末にやっていたのでもう2ヶ月ほど、特に問題なく動いている。実際に一度外にいるときに家のルーターが落ちたことがあったが(落ち過ぎじゃないか?)、ちゃんと通知が来た。 その後ルーターは一回り値段が高いものに買い替えた。そちらは今の所安定している。買い換えるにあたっていくつかのサイトでレビューを見てみると、落ちていたルーターはかなり評判が悪かったので、あまりにも評価が悪い場合はレビューを信じたほうがいいか……と思うようになった。

私はslack botを作ったことがないどころかGASもslack APIも何も知らないが、出てきて動いているものを見たら何をしているかはだいたいわかったので、その後ちょくちょく機能追加をしている。 今のバージョンは定時報告にリアクションで餌をやることができる(肉など食品のスタンプを押すと、定時報告時に検出して食べる)。 たまに定時報告に食べ物の絵文字がついているのを発見していたので、ちょっとしたいたずらのつもりで書いたのだが、思ったより好評なのでちょくちょくアップデートして遊んでいる。

toml11 v4.4.0をリリースした

しました。

今回は主に私自身が使っていて困った機能の実装なのでちょっと見切り発車です。

アクセスチェック機能の追加

概要

TOML11_ENABLE_ACCESS_CHECKを定義してコンパイルする or CMakeでTOML11_ENABLE_ACCESS_CHECK=ONにすると使えるようになる機能です。

toml::valuebool accessed()が追加され、パース後にその値を読み込んだ(toml::findtoml::get.as_integer()など)か、その値の型をチェックした(is_integerなど)場合にはtrueが、そうでない場合はfalseが返るようになります。

これはエイヤで入れた機能なので今後気が変わるかもしれません。また、普段の機能追加よりもライブラリ全体への影響が大きめなので、マクロで切り分けてデフォルトではオンにならないようにしています。

目的

この機能は、以下のような誤りを検出するための機能です。

  • アプリケーションは、tomlファイル内のある値(例えばhoge)を探す
  • 見つからなかったので、デフォルトの値(例えばhoge = 0)を設定して処理を続ける
  • 実はユーザーはhogeと書こうと思ってhige = 1と書いていた
  • ユーザーはhoge = 1だと思っているがアプリケーションはhoge = 0だと思って処理が続いている

このような場合、アプリケーションもそのユーザーも、誤りを検出するのは難しくなります。

アプリケーションが動かなくなるようなデフォルト値を設定する人はあまりいません(そういうことをするくらいならその場で落としたほうが原因がわかりやすいので良いです)。 なのでアプリケーションは異なる設定のまま動作してしまい、ユーザーは自分の指示が通っていないことに気付かない可能性が高いです。

他の設定と不整合が生じてエラーが出る可能性はありますが、その場合ユーザーはデバッグに苦労するでしょう。 ログにhogeの値が出ていたら判定できるかもしれませんが、出ていなかったら、エラーの理由からあたりをつけられない限り、typoの発見にはかなり時間がかかると思います。

むしろ困るのは、デフォルト値でそのまま最後まで動いてしまう場合のほうでしょう。 実は設定が違うのに処理が進んでいる、というのは、思っていたものと異なる結果を得るということで、場合によってはしばらく計算を続けた挙句何の意味もないデータを得る、ということになりかねません。 どこかで気付けばいいですが、そのことに気付かないまま結果に基づいて依存関係のあるタスクを進めてしまっていたら、あるいは他人に報告したり指示を出したりしていたらどんどん影響が大きくなります。

しかし、この場合higeはアクセスされないので、「アクセスされていない値「hige」がある」という警告が出ていればエラーの理由はわかります。 十分に注意深い人であれば、アクセスしていない値の警告を見て、自分の指示が通っていないことに気づけるでしょう。 アプリケーション側で手間を惜しまないのなら、必要なキー一覧との編集距離を取ってtypoの可能性を指摘し、正しい名前をサジェストするのも良いかもしれません。 なんなら、アプリケーションは使っていない値を発見したら異常終了したり再設定を要求して止まったりした方が、エラーを確実に防げるはずです。ユーザーからはウザがられそうですが。

こういった理由から、使っていない値を総ざらいしてエラーを出せる機能があれば、間違っていた時により早く気づき、リカバリに移れるという点でよいと判断したわけです。 昔からこの機能について少し考えてはいたのですが、最近実際に自分でやらかして数日消し飛ばしたのでちょっと無理にでも急いで実装しようという気になりました。

実装

toml::valueatomic<bool>でフラグを埋め込んでいます。パースは並列化されていませんが、その後の使用時にはユーザーに並列でアクセスされる可能性があるので、atomicになっています。このフラグは、構築されたときはfalseで、as_xxxis_xxxを実行するとtrueになります。

ただ、パース中、すでにパースして構築したtableに値を埋め込むことがたまにあります(後方でサブテーブルを定義した場合など)。 このような場合は、ユーザーが触れていないのにtableがアクセス済みと判定されてしまうのでおかしなことになります。

長い先読みを許すことでそのような状況を消せる可能性はありますが、そこまでの先読みはあまりにも面倒になるので、単にパース後に全フラグをfalseにするようにしました。 これは若干パフォーマンスに影響があるかなと心配していましたが、プロファイルを取ったところパースのほうが十分重いので大丈夫そうです。

他の選択肢

こういったことをより汎用的にこなすには、Schemaファイルを用意して、それに従っていないものにエラーを出すという選択肢があります。 そうすれば、必要なキーがあらかじめ渡されるので、先ほど挙げた編集距離でのサジェストも自動化できます。

ただ、それをするには当然Schemaファイルを定義しないといけません。今のところTOML公式での議論ではTOML専用のものを作ることについては消極的で、JSON Schemaを再利用すればいいだろうという意見が大勢を占めています。 とはいえ、その決定自体にあまり合意を取れている状態ではないため、何を採用するにしても独自仕様になってしまいます1。 toml11の「他のライブラリに依存しない」という機能を保つためにはSchemaを自前実装しないといけないため、労力的にあまり現実的ではないかな、と感じました。 それこそマクロで分けるとかしてもいいのですが、むしろパース自体はtoml11でやっても事後のチェックは別のレイヤーで解決したほうが妥当そうな感じはします。

そういうわけで、フラグを埋め込む方法を選択しました。

デメリット

この機能をONにすると、実行時パフォーマンスに若干の影響が出ます。実測したところ5%未満でしたが。 スレッドセーフになるようフラグにatomic<bool>を使っているので、パース後の値からデータを読み出す部分が並列化されている場合、より大きな影響が出るかもしれません。

すでに他の方法(それこそスキーマなど)で同様の機能をユーザーコード側で実装している場合は、不要なのでoffにしておいたほうがいいでしょう。

ところで、この機能のためにベンチマークを取っていて、もう少し速くあってほしいなあと感じたため、今回のリリースでは実行時パフォーマンスの最適化も行いました。

実行時パフォーマンスの向上

概要

toml::parseの速度が約2倍向上しました(手元の環境で、12000行程度のファイルのパース+シリアライズにかかる時間が479ms -> 236ms)。

目的

今回はパフォーマンスを落とすような機能を入れたので、長いファイルをパースして書き出すだけのコードを作って、しばらく時間を計測していました。

以前実際に使ったことのある12000行程度のファイルを使って実行していたところ、v4.3.0 と g++-13.1.0での速度はざっくりIntel i7-11700K(DDR4-3200)で10000行/秒、Ryzen 9700X(DDR5-5600)で25000行/秒程度でした2

これまではかなり長いものを書いていても数万行だったので、少し昔のマシンでも秒間1万行というのは自分では概ね満足できるところではあります。 とはいえ、数万行になると数秒かかるということで、もう少し速いほうが嬉しいのは事実です。 toml11のパース部分はかなり富豪的なコードを書いているので、そこまで頑張らなくてもこの1.5倍くらいはいけるだろうという霊感がありました。

toml11は基本「機能性と速度のトレードオフにでくわした場合は(高確率で)機能性を取る」というスタンスでやっています。 今回のアクセスチェックなんかは明らかに機能性を取っているところですし、TOMLバージョンを変えられる機能も、TOML内のコメントをわざわざパースしたり整数のフォーマットを覚えたりしているのも、速度より機能性を取っている部分です。 逆に、toml::valueの実装にunionを使っている(継承してdynamic_castをしたりはしていない)のは速度を取った部分ですが、これは別に機能性を殺しはしません(書くのは面倒になりましたが)。

速度より機能性を重視するポリシーには、

  • TOMLは設定ファイルに使うものなので、初回と設定リロードの場合しかパースせず、大抵の場合ホットスポットにならない
  • TOMLを採用する理由は「人間の読み易さ・書き易さ」が多いので、現実世界のファイルのほとんどはそんなに長くならない(人間が読み書きできる程度の行数)
  • コメントを読む機能、フォーマットを維持する機能、TOMLバージョンを切り替える機能などを提供する以上どうせ業界最速にはならない
  • パースが多少高速になるより、設定する際のヒューマンエラーを防ぐ機能を色々提供したほうが、ユーザーの目的は結果的に速く達成できると思っている

という理由があります。

とはいっても、私自身は他のプロジェクトではタスクをギリギリまでオーバラップさせた並列化をかけたり、SIMD化するために手動アンロールしたりする程度には速度を出すことが好きです。 なので、toml11も機能性を保った上で、開発を大変にしない程度の最適化はしておきたいとは思っています。 このあたりのバランス感覚(「開発を大変にしない程度」とは?(実は既に大変では?))は個人的なものなのでちゃんとは書きにくいですが……(過不足なく書こうとするとかなり長くなってしまうし個別的になってしまう)。

方法

プロファイル

とりあえずまずはプロファイルです。ファイルをパースしてダンプするだけのプログラムを書き、valgrind / callgrindでプロファイルを取り、kcachegrindで覗いてみました。

プロファイル結果をkcachegrindで開いたもの

わけわかんなくて草。

普段書いているコードはたいてい、ここでそれなりのサイズのブロックが出てきて何をするべきかわかるんですが、これは面倒ですね。

代わりに呼び出し回数と合計時間を眺めてみます。

関数ごとの所要時間と呼び出し回数

これを見ると、new/deleteに伴うmalloc/freeが尋常でない回数呼ばれており、そこそこの時間(40%)を占めているいることがわかります。

toml11に関しては実装を隅から隅まで知っているのでこの時点でなんとなく察していますが、確認のため誰が呼んでいるかを見ます。

_int_freeを呼んでいる関数

sequenceeitherなどのscannerと、toml::syntax以下のクラス(要するにscanner)が呼んでいるようです。

scannerとは

そもそもscannerって何、という話をしないといけないでしょう。

toml11は、文法を確認したあと、文法に従っている前提で意味を解釈するという順番でパースをしています。といってもファイル全体の文法チェックを最初に行うのではなく、かなり小さい単位(keyやvalueのそれぞれ)でこれを行っています。 この最初の段階でscannerを使っています。これは簡単なスキャナを組み合わせて合成できるように作っており、characterliteralcharacter_in_rangeなどをsequenceeitherrepeat_at_leastで組み合わせて、TOMLのABNFと対応する文法をチェックできるようにしています。例えば10進の整数は:

TOML11_INLINE sequence dec_int(const spec& s)
{
    const auto digit19 = []() {
        return character_in_range(char_type('1'), char_type('9'));
    };
    return sequence(
            maybe(character_either{char_type('-'), char_type('+')}),
            either(
                sequence(
                    digit19(),
                    repeat_at_least(1,
                        either(
                            digit(s),
                            sequence(character(char_type('_')), digit(s))
                        )
                    )
                ),
                digit(s)
            )
        );
}

のようになっています(v4.3.0時点のコード)。最初に+-があって、digit単独か(0許容のため)、1~9のどれかのあとにdigitが複数個続く(0以外で始まる複数桁の整数)、ただし_を間に挟んでも良い……という文法がこれで表されています。

整数をパースする際は、まずこれに通るかをチェックされ、これに通ったあとは_を取り除かれてistreamに渡されています3

scannerのうち別のscannerを取るもの、例えばsequenceeithermayberepeat_at_leastは、任意のscannerを格納できなければなりません。 それには、大まかに分けて2つ方法があります。scannerは全員scanner_baseを継承してunique_ptr<scanner_base>を格納する方法と、存在する全てのscannerをunionに入れることです4。 toml11では実装の簡単さのために前者にしています。これにも複数の理由があります。

まず、unionを使って複数の型を扱うのはそこそこ面倒です。toml::valueではunionを使っていますが、あれは結構大変です。内部でしか使わない型でそこまでするのは労力的にちょっと避けたいところです。

また、scannerはエラー時に本来期待していた文字を表示する役割もあるのですが、複雑になってくると期待していた文字を表す文法が人間に読めなくなってしまうので、適宜圧縮しています。 圧縮とは例えば、UTF-8で複数バイト取る関数について「(UTF-8で許されるバイト列の範囲を表す複雑な文法)を期待した」を「non-ascii文字を期待した」に書き換えることです。 これを実現するため、sequenceなどをラップしたクラスをいくつか作っています。これがどの程度の数になるかは書く前は不明だったため、追加する際に書き換える範囲が小さくなる実装を選びました。

そういうわけで、sequencerstd::vector<std::unique_ptr<scanner_base>>を持っています。 そしてtoml::parseは内部で必要になるたび毎回これを作っては壊し、使い捨てています。scannerがnew/deleteを呼びまくって遅い原因はこれでしょう。

実は、toml11 v3では同等の機能がtemplateを使って型レベルで実装されており、このような問題は起きていませんでした。 v4で動的に構築するように切り替えたのは、TOML言語バージョンを切り替えられるようにするためと、コンパイル時間を短くするためです。 これらの目的は変わらないので、scannerを動的に構築するという方向性は変えたくありません。 なので、scannerの構築回数を減らす、あるいは構築の際のnew/deleteを減らす方向性で考えましょう。

方法1: 特別なsyntaxクラスではscannerを使わず素のscannerを持つ

特別なsyntaxクラスは、先ほど書いた複雑な文法をよく見る名前で表示して読みやすくするためのクラスのことです。UTF-8の非ASCII文字であるとか、hex digitとかです。 これらはよく使われるところに出現するのでパフォーマンスへの影響が少し大きめです。頻出要素のパターン(数値のみ、16進数のみ、など)の可読性を高めるために導入したものなので当然かもしれません。

これらはこれまでsequenceを持っていてコンストラクタでその中身を詰めていましたが、よく考えるとそんなことをする必要はありません。 sequenceの中身は決まっているので、バラで全部持ってsequence::scanが実行するループの代わりに手作業で前から順に呼べばいいだけです。 これらのクラスはその後の文法の中で使われるものなので、それ自体はそこまで複雑な構造をしていません。数個の連続程度なので、分解してもコード量はかさみませんでした。

これは機械的にできる変更な割にそれなりの効果があったので、採用しました。

方法2: sequencestatic_sequencedynamic_sequenceに分ける

sequenceの要素数とその型はTOML言語バージョンによって変わる場合もありますが(許される文字が増えたり、一部がoptionalになる場合)、8割方は変化がありません。 なので、持っているscannerの個数と種類が変化しない場合はvector<unique_ptr<scanner_base>>の代わりにtuple<scanners...>を使ったstatic_sequenceにすることで、new/deleteを減らせます。 tupleならunique_ptr<scanner_base>にする必要がないからです。同様のことがeitherでもできます。

ただ、これはコード量が結構増え、かつ今後変更する際に影響が及ぶ範囲が広くなってしまい(未来のバージョンでさらに変化があった場合速度への影響も大きくなる)、しかもC++11で動かないといけないのでそこそこ冗長なメタプログラミングが要求され、コストが高いのでやめました。

方法3: unionを使う方法に切り替える

これは、unique_ptr<scanner_base>の代わりに全ての型を列挙してunionに詰めるというものです。

unionを使うと実装があまりに面倒すぎたのでvariantを使って実験してみたところ、15%ほどの高速化ができ、これやるの……?と思いながら数日見ないふりをしました。

最終的に次の方法で十分早くなったので今はやっていません。

方法4: キャッシュを導入して使い捨てをやめる

そもそもの問題は、毎回sequenceを作っては即捨てていることです。toml::specに指定されるTOML言語バージョンが同じならsyntaxも同じなので、同じscannerの組み合わせで事足りるはずです。

よって、一度作ったscannerを覚えておいて、specを比較して同じものなら同じものへの参照を返すようにしておけば、無駄にコピーすることもなく、無駄にアロケーションすることもありません。 これが一番変更が楽で効果がありそうなので、まずはscannerを格納するクラスを作ります。

cacheは値を構築する関数を受け取っておいて、atアクセスで初めてのspecが来た場合にそれを構築して保存してから返し、既知のものであれば対応する保存済みの値を返します。

template<typename F>
struct syntax_cache
{
    using value_type = cxx::return_type_of_t<F, const spec&>;
    static_assert(std::is_base_of<scanner_base, value_type>::value, "");

    explicit syntax_cache(F f)
        : func_(std::move(f)), cache_{}
    {}

    value_type const& at(const spec& s)
    {
        const auto found = std::find_if(cache_.begin(), cache_.end(),
            [&s](const std::pair<spec, value_type>& kv) { return kv.first == s; });
        if(found == cache_.end())
        {
            this->cache_.emplace_back(s, func_(s));
            return cache_.back().second;
        }
        else
        {
            return found->second;
        }
    }

  private:
    F func_;
    std::vector<std::pair<spec, value_type>> cache_;
};

複数のスレッドが同時に別のファイルをparseして同時にcacheが更新されると大変なので、キャッシュはthread_localにしておきます(共有されなくても各スレッドが自分で更新するだけなのでデメリットはそんなにありません)。 今ある関数は作ったscannerを単に返しているだけだったのを、static thread_localな場所にspecとペアにして保存し、コピーを避けるためそれへの参照を返します。

で、先程も出てきたdec_intを例にしますが、今まで関数内に書いてあった構築ロジックをラムダ式に移して、それを使ってcacheを作ります。 cache.atで未知のキーの場合は構築するので、ここを最初に通るときはscannerが構築され、二度目以降は構築されませんし、破棄もされません。

TOML11_INLINE sequence const& dec_int(const spec& sp)
{
    static thread_local auto cache = make_cache([](const spec& s) {
        const auto digit19 = []() {
            return character_in_range(char_type('1'), char_type('9'));
        };
        return sequence(
                maybe(character_either("+-")),
                either(
                    sequence(
                        digit19(),
                        repeat_at_least(1,
                            either(
                                digit(s),
                                sequence(character(char_type('_')), digit(s))
                            )
                        )
                    ),
                    digit(s)
                )
            );
        });
    return cache.at(sp);
}

これはかなり機械的な書き換えで達成でき、かつ十分な効果がありました。

方法4.1: cache内でvectorを使わない

考えてみると、toml::parseは一度パースを開始したら途中でユーザーに処理を返しません(カスタム数値型のパース方法だけはユーザーがカスタマイズできますが……)。 ということは、単独のスレッド内でファイルを読み終わるより前にtoml::specが切り替わることはありません。toml::parseの一回の実行中は全く同じspecが渡され続けます。 よって、これをvectorにせず、単にpair<spec, scanner>を単体で置いておいてヒットしなかったら構築して入れ替える、としても速度低下はないと期待できます。そうすると少しコードが簡単になります。 むしろvectorに対するfindと要素アクセスが減って若干速度が上がるのではないでしょうか。いやどうせ1,2個しか要素はないからそこまで上がらないかな。

さっき紹介したsyntax_cacheからvector部分を消してみます。

template<typename F>
struct syntax_cache
{
    using value_type = cxx::return_type_of_t<F, const spec&>;
    static_assert(std::is_base_of<scanner_base, value_type>::value, "");

    explicit syntax_cache(F f)
        : func_(std::move(f)), cache_(cxx::make_nullopt())
    {}

    value_type const& at(const spec& s)
    {
        if( ! this->cache_.has_value() || this->cache_.value().first != s)
        {
            this->cache_ = std::make_pair(s, func_(s));
        }
        return this->cache_.value().second;
    }

  private:
    F func_;
    cxx::optional<std::pair<spec, value_type>> cache_;
};

少しシンプルになりましたね。

実際測ってみると、10回平均で速度向上は2%程度でした……。まあコードが短く単純になった方を喜びましょう。

まとめ

今回実装したのはそのあたりです。あと、いくつかpull reqが来ていたので、その機能も取り込んでおきました。 基本的に筋悪なpull reqがほぼ来ないので楽な限りです。普通に忘れてたり間違えてたりするのを結構直してもらっているのでありがたいですね。自分で使わなかったりすると目が行き届かないので……。

不定期ですが、自分で使っていて何かの機能が追加したくなったらまた更新すると思います。


  1. TOMLはネイティブで日付・時刻をサポートしており、これはJSONにはないため、この型をどうサポートするかは結構実装依存になりそうです。
  2. この値はファイルの内容に依存するので(長い配列やinline tableを一行で書くこともできる)、私と全く同じようなファイルを書いている人以外にとっては数値自体にそんなに意味はないです。
  3. charconvC++17以降なのでtoml11では使えません。__cplusplusを見て内部で切り替えたら少し高速化できる気がしますが……。
  4. variantC++17以降なのでtoml11では使えません。なぜ俺はそんな縛りプレイを??

toml11 v4.3.0をリリースした

新年なので初リリースです。

追加

toml::find<std::optional<T>>(...)をサポート

今まで無かったのかよという感じですが、面倒だったので後回しにしていました。ローカルに置いてあるROADMAP.mdを見るともうちょっと早めに実装する予定だったことがうかがえます。 こういうのって始めるとガーっと書いてしまうけど、始めるまでが長いんですよね……これは仕事でも何でもないですし。

今回は自分が半分趣味で書いているソフトウェアで使えなかったのが困ったので実装しました。基本的に新機能は私が困る順番で実装されます。

さて、シンプルに考えるとこれは以下のような実装でよいだろう、と考える人が多いと思います。 (toml11はもっと色々ややこしいことをしていますが、ここでは単純化した実装で紹介します)

template<typename T>
std::enable_if_t<is_optional_v<T>, T>
find(const toml::value& v, const std::string& k)
{
    try
    {
        return std::find<typename T::value_type>(v, k);
    }
    catch(std::exception&)
    {
        return std::nullopt;
    }
}

これだけのことに何をもったいぶってるんだ、と思っている方もいるとは思いますが、今回の実装はこうではありません。

いや、例外をキャッチしたくないからとかではなくて、もっと本質的な問題があります。

上記のコードは、以下の場合にnulloptを返します。

a = "foo"
const auto a = toml::find<std::optional<int>>(input, "a");

本来、C++optional<T>を使用するのは「あってもよいが、なくてもよい」という場合です。 TOMLのような設定ファイルでそのような型を使用するのは、「設定してもよいが、ない場合はデフォルトを使う」場合でしょう。

しかし、この場合はどうでしょうか? 起きていることは、「コードを書いている人はintであることを期待しているところで、設定を書いた人がstringを入れた」という状況です。 これは「値がないのでデフォルト設定にする」で済ませて良いケースでしょうか? 私は違うと思います。この場合は、intを期待したがstringが入っていた、というエラーを出すべき場面でしょう。

このような場合にちゃんとエラーを出せるように、toml::find<std::optional<T>>の実装は毎回ちゃんと値が存在するかをチェックしています。toml::findnulloptを返すのは、値が存在しなかった場合だけです。

toml::visitで複数引数をサポート

std::visitstd::variantに対してのパターンマッチのような機能で、実質的にvariantであるtoml::visitも同様のtoml::visitをサポートしています。

std::visitは単一のビジターに対して複数のvariantを渡すことができます。対してtoml::valueは一つしかtoml::valueを受け取れませんでした。

もともとはstd::visitをイメージしていたので複数引数をサポートしようとしていたのですが、結構ややこしかったので後回しにしていました。

この前思い出してちょっと書いてみたらすんなり動作したので、じゃあ……と実装したわけです。

まあ、この機能を使ってる人がいるかどうかはわかりませんが。

一行が長いファイルでパースが遅くなる問題の修正

自分では気づかなかったのですが、toml11の実装は長い行のカラム数に対してO(n2)の計算量になってしまっていました。 (行数に対しては線形なので、巨大なファイルでも長い配列で細かく改行を入れている場合は高速に読めるが、長い配列を改行なしで書いていると遅い)

言い訳すると、自分で6万行とかの長いファイルを扱う際は、長い行は作らず豊富に改行を入れており、するとまあ言うほど待たずにパース出来るので(そしてそういう場合は計算自体が長いので読み込みはほぼ無視できる)、特に高速化しようという気持ちがなく、よってそういった広範な状況に対するベンチマークを取っていなかったわけです。

パースが行数に対しては線形なのにカラムに対して二乗というのはかなり不思議な話ですね。とはいえ、パースの計算量が変に大きいという問題はv3の時に既に調査したことがありました。

toml11はエラーメッセージにそれなりの努力を投下しており、パース中、あるいは読み込み中に出たエラーの位置をトラッキングできるような仕組みを作っています。 v3ではバイト位置しか追跡していなかったため、行カウントはエラーメッセージ作成時に前からカウントするような実装になっていて、パーサーがトライして巻き戻すごとにその処理が呼ばれてしまって遅くなっていました。 これを思い出して調査してみると、今回も私は「カラム数はそこまで大きくならない(80~100程度)だろう」と思って毎回カラム数をカウントしていたので、それが原因だろうとあたりをつけました。

しかし、それだけだと筋が通りません。というのも、v4では前回の反省を踏まえて、極力エラーメッセージを生成しないという方針に切り替えていたからです。 v4には、型をあてずっぽうでパースして失敗したらやり直す、というシーケンスがありません。 これは変だなと思って調べていたところ、整数と浮動小数点数のパースでは毎回エラーメッセージ用の情報を作っている、つまりカラム番号をカウントしていることに気付きました。

TOML11 v4では、整数と浮動小数点数型はユーザー定義型に変更できます。これは例えば、boost::multiprecision::cpp_intのような任意精度整数を使うためのものです。 このような機能をサポートする場合、ユーザーはパースするための関数も設定できなければなりません。 そのためにoperator>>を要求することも考えましたが、std::istreamに縛るよりは文字列からの変換関数を定義するほうが良かろうと考えました。 というのも、そのような関数が吐き得るエラーはユーザー定義型に特有のものもあるはずで、istreamの状態だけで伝えきれるものではない可能性があるからです。 そう考えると、エラーの場合はユーザー定義の変換関数にメッセージを返してもらうべきでしょう。 となると、その関数にはいざというときにエラーメッセージを構築するための情報を渡さなければなりません。 これは、ユーザー定義のエラーメッセージを出力する際に使うものと同じものでなければなりません。 そこで渡す構造体、toml::source_locationは、当然ながらカラム番号を知っていなければなりません。というわけで、毎回カラム番号をカウントしていたのでした。

ここまでわかると解決は簡単で、パース中に行番号とカラム番号の両方をカウントするようにして、カラム番号の取得を定数時間にしました。 リセットするタイミングがあるので若干面倒ではありましたが、そもそも行番号をカウントアップする際に似たことをしていたので、その処理に便乗した形です。

問題はバックトラックで、パース位置を戻す際に行をまたぐとカラム番号が不明になり、カウントしなおす必要が生じるケースがあります。 もともとはバックトラックは無限にできるような仕様にしていましたが、パーサの実装後は最大で1文字しか巻き戻さないことがわかっていたので、メンバ関数から巻き戻す文字数の引数を削除し、巻き戻しは1文字だけに縛ることにしました。 このおかげで実装はかなり簡単になりました。

というわけで、今は全ての変数がinline tableに登録された長大な行を持つ改行なしのTOMLファイルでも特に問題なく読み込める、はずです。

その他

あとは、限定的なケースで発生する問題の修正がいくつか届いていたのを取り込みました。(運悪く出くわした場合には)ちょっと大きめの問題もありましたが……。

こういった修正が届くのも有難いですね。自分では出くわさないことも多いので。

それから、MSVCの2017でもコンパイルできるようにし、appveyorの設定もしました。MSVC 2017でだけ動かない(2019, 2022では動く、gcc/clangは動く)SFINAEメタ関数があり、まあ結構古いバージョンだしいいか……と思ってサイレントにサポートを切っていたんですが、Issueが何度も立ったので修正しました。今は動きます。

Wshadowがenum classとの衝突に文句を言う場合と言わない場合

今回は短めです。流石に学習しました。


そういえば以前toml11 v3で発生していた-Wshadowの警告が特に意識しないうちに消えているな~と思って、確認していました。

以前出ていた警告は、以下のコードが原因の警告でした。enum classと型エイリアスが衝突しているという話です。

namespace toml
{
using boolean = bool;
enum class value_t
{
    boolean = 0,
    // ...
}
} // toml

value_tenum classなので、これが実際に曖昧になるのはC++20でusing enum toml::value_t;をしたうえでusing namespace tomlをした場合だけのような気がしますが(省略しすぎでは?)、それでも警告は出ます。

今のtoml11 v4では値の型が可変になったのでbooleanのような型エイリアスがなくなり、原因コード自体が消えたために警告も消えているわけですが、再発防止のために条件をちゃんと知っておこうと考えました。 ちょうど別のenumを足そうかと考えていたところですし。

エイリアスの場合

まずはポジティブコントロールを試してみます。v3のときの経験からこれは警告されるはずです。

namespace foo
{
using hoge = int;
enum class bar
{
    hoge = 0,
    piyo = 1,
    fuga = 2
};
} // foo

int main()
{
    return 0;
}

https://wandbox.org/permlink/gKWiLlgb4RUmofz6

されました。

prog.cc:7:5: warning: declaration of 'hoge' shadows a global declaration [-Wshadow]
    7 |     hoge = 0,
      |     ^~~~
prog.cc:4:7: note: shadowed declaration is here
    4 | using hoge = int;
      |       ^~~~

では逆だとどうなるでしょう。

namespace foo
{
enum class bar
{
    hoge = 0,
    piyo = 1,
    fuga = 2
};
using hoge = int;
} // foo

int main()
{
    return 0;
}

https://wandbox.org/permlink/y5dyWmnUIRnU2hEK

あれ、警告出ませんね……。

この時点で若干雲行きがあやしい気がします。main内でusing enum foo::barusing namespace foo;したら衝突しますよね?

namespace foo
{
enum class bar
{
    hoge = 0,
    piyo = 1,
    fuga = 2
};
using hoge = int;
} // foo

int main()
{
    using enum foo::bar;
    using namespace foo;
    hoge x = 42;
    return 0;
}

https://wandbox.org/permlink/CuTOonWZ8V3crmYE

いや、実際にhogeを使ったときだけエラーになるようですね。上のコードからhoge x = 42;を消すと警告も出ません。

prog.cc: In function 'int main()':
prog.cc:16:9: error: expected ';' before 'x'
   16 |     hoge x = 42;
      |         ^~
      |         ;
prog.cc:16:5: warning: statement has no effect [-Wunused-value]
   16 |     hoge x = 42;
      |     ^~~~

結構変な挙動の雰囲気が漂ってきました。

構造体の場合

構造体だとどうなるんでしょうか。

namespace foo
{
struct hoge {int x;};
enum class bar
{
    hoge = 0,
    piyo = 1,
    fuga = 2
};
} // foo

int main()
{
    return 0;
}

https://wandbox.org/permlink/0Ts21RvftAp7KkWR

警告出ませんね?

逆にしたらどうでしょう。

namespace foo
{
enum class bar
{
    hoge = 0,
    piyo = 1,
    fuga = 2
};
struct hoge {int x;};
} // foo

int main()
{
    return 0;
}

https://wandbox.org/permlink/MOaAohd0nZTJhPDX

出ませんね。まあ前ので出なかったならこれも出ないでしょう。

しかしどうしてなんでしょうか。 名前衝突の観点からは、型エイリアスとそんなに違うことはしていないと思いますが。あっでももしかしてtypedef struct {int x;} hoge;にしたら警告出ますこれ?

namespace foo
{
typedef struct {int x;} hoge;
enum class bar
{
    hoge = 0,
    piyo = 1,
    fuga = 2
};
} // foo

int main()
{
    return 0;
}

https://wandbox.org/permlink/soh5yCuIdkYqZRUl

あっ出た。まあこれは完全にusingと同じと言えるので、そうですね。出ますね。

ご想像の通り逆にすると、出ません。

https://wandbox.org/permlink/esZ1NioPCDZOFflQ

inline変数の場合

型ではなく値の場合はどうでしょう。

namespace foo
{
inline int hoge = 42;
enum class bar
{
    hoge = 0,
    piyo = 1,
    fuga = 2
};
} // foo

int main()
{
    return 0;
}

https://wandbox.org/permlink/gll9vRlP0JzCbruY

これは警告が出ますね。まあ、型エイリアスで警告が出るならこれも出ないと変です。

prog.cc:6:5: warning: declaration of 'hoge' shadows a global declaration [-Wshadow]
    6 |     hoge = 0,
      |     ^~~~
prog.cc:3:12: note: shadowed declaration is here
    3 | inline int hoge = 42;
      |            ^~~~

逆だと?

namespace foo
{
enum class bar
{
    hoge = 0,
    piyo = 1,
    fuga = 2
};
inline int hoge = 42;
} // foo

int main()
{
    return 0;
}

https://wandbox.org/permlink/JLODEtxHGP7W14Hf

出なくなりました。ある意味で一貫性はあるものの、この挙動はちょっと、なんというか……。

関数の場合

あとよく使うのは関数ですかね。

namespace foo
{
inline int hoge() {return 42;}
enum class bar
{
    hoge = 0,
    piyo = 1,
    fuga = 2
};
} // foo

int main()
{
    return 0;
}

https://wandbox.org/permlink/pLa5WtmhdAtICBVY

あれ、出ないんですね、警告。

一応逆も見ますか。

namespace foo
{
enum class bar
{
    hoge = 0,
    piyo = 1,
    fuga = 2
};
inline int hoge() {return 42;}
} // foo

int main()
{
    return 0;
}

https://wandbox.org/permlink/f16EnX7iaTIdYsS0

出ない。まあはい。

まとめ

うーん、パターンが掴めそうな感じはありますが、どうにもいくつかの例はシンプルに意図しない挙動のようにも見えてきますね……。

そもそも定義する順番を変えると警告が出たり出なかったりするのがまず変です。下流でのリスクは同じですよね? いや今回はリスクがほぼないので(using enumが使われない限り)警告しない方が正しそうなんですが、片方で警告してもう片方で警告しない理由が思いつきません。

より安全な状況で警告を出しておきながら、絶対に衝突させるという硬い意思がないと書かないだろうusing enumusing namespaceを足してもそれだけではshadowingの警告が出ず、使ってはじめてエラーになるというのもどうも一貫性に欠けるように感じます。 この挙動全体を俯瞰すると、意図して設計されたように見えません。

shadowingを起こし得るexprのリストみたいなのがコンパイラにはあって、それに当てはまる場合だけチェックしている(から後で出てこないとチェックが走らない)とかですかね。shadowingという対象を考えるとそうするのが都合良さそうですし。でもそれだとinline変数を後ろにやったときもチェックは走るべきか……?

うーん、背後のルールはよくわからないですが、ある程度状況がわかったのでこの辺にしておきます。多分このくらいの情報があれば回避可能でしょう。本気で背後のルールを探ろうとするとgccの実装見ないといけませんし。

今日はもう遅いので寝ます。


一応注意しておくんですが、enum classがあるとちょっと妙な挙動をするというだけで、-Wshadowは本来の用途では非常に便利な警告です。

int main()
{
    int x = 42;
    for(int i=0; i<10; ++i)
    {
        double x = 3.14;
    }
    return 0;
}

こういう場合、

https://wandbox.org/permlink/OLIJ8QBRjHt2axrE

prog.cc: In function 'int main()':
prog.cc:6:16: warning: declaration of 'x' shadows a previous local [-Wshadow]
    6 |         double x = 3.14;
      |                ^
prog.cc:3:9: note: shadowed declaration is here
    3 |     int x = 42;
      |         ^
prog.cc:6:16: warning: unused variable 'x' [-Wunused-variable]
    6 |         double x = 3.14;
      |                ^
prog.cc:3:9: warning: unused variable 'x' [-Wunused-variable]
    3 |     int x = 42;
      |         ^

のような警告で教えてくれます。このshadowingが意図しないものだった場合、この警告はかなり貴重です。