What goes on/what's going on:2021年04月21日分

2021/04/21(Wed)

[NなんとかBSD] N HEADのLC_NUMERIC/LC_MONETARY定義などが地味にぶっ壊れてる件

printf(3)の桁区切り実装の記事書きながらついでなのでオレオレN6のLC_NUMERICデータ精査してたんだけど、ginsbach@だったかがFから持ってきたLC_NUMERIC定義、間違いだらけ仕様違反だらけでウンザリしてしまった。今回ネタに使おうと思ってたインドの桁区切りなんかも順序が逆になってんじゃねーか。 他にもおかしな定義が満載で、もう全部捨ててやろうかって気分になっている。

本家の方もどうせ放置やろうなと興味本位で覗いたらもっと酷いことになってた。名前も出したくない某ドイツ人が数年前にCLDR(Common Locale Data Repository)を元にUTF-8 localeなLC_NUMERIC他を生成して上書きコミットしとるんだけど、まともに仕様読んでねえいつものパターンで thousands_sepとかdecimal_pointに仕様上許されてない(未定義動作になる)マルチバイト文字(U+00A0つまり\xc2\xa0)が入っちゃってるファイルが多数あるもよう。

# Decimal Delimiter/Radix Character (decimal_point)
,
# Separator for groups of digits left of above (thousands_sep)
\xc2\xa0
# Grouping Sequence (grouping)
#    A sequence of integers separated by semi-colons ';'.
3
# EOF

普段からUTF-8 localeなんぞに設定してるとエディタ上でスペースにしか見えないパターンなんやなw

仕様通りに1byte決め打ちで実装しとるコードがちょん切って不正なバイトシーケンス出力しちゃいますなぁこれ。

decimal_point
	The operand is a string containing the symbol that shall be used as the decimal delimiter (radix character) in numeric,
	non-monetary formatted quantities. This keyword cannot be omitted and cannot be set to the empty string.
	In contexts where standards limit the decimal_point to a single byte, the result of specifying a multi-byte operand shall be unspecified.

thousands_sep
	The operand is a string containing the symbol that shall be used as a separator for groups of digits to the left of the decimal delimiter in numeric,
	non-monetary formatted monetary quantities.
	In contexts where standards limit the thousands_sep to a single byte, the result of specifying a multi-byte operand shall be unspecified.

はい、どちらも仕様で「シングルバイトに限る、マルチバイト指定したら未定義動作」とあるよね。ほんとあいかわらず仕様読まねえ確認しねえピーだなあいつ。 うーんピーだってのは長年のやりとりで知ってたからこそ関係切りたくてNのdeveloper辞めたんだが、さすがにCLDRとPOSIX localeは似て非なるものだという事すらわからんレベルだったとはね。

ちなみにvfwprintf.cの実装もあらためて確認したけど、thousands_sepもdecimal_pointも

		case '\'':
			thousands_sep = *(localeconv_l(loc)->thousands_sep);

とか

					if (prec || flags & ALT)
						PRINT(decimal_point, 1);
...
					if (prec || flags & ALT) {
						buf[0] = *decimal_point;
...
					buf[1] = *decimal_point;

どちらもシングルバイトであると仮定したコードになってるので、ちょん切れて出力されちまうってわけよ。

ついでにvfscanf.c/vfwscanf.cもだな。

	char decpt = *localeconv_l(loc)->decimal_point;
...
	wchar_t decpt = (wchar_t)(unsigned char)*localeconv_l(loc)->decimal_point;

小数点認識できず下だけ読み飛ばされるから誤差がいつのまにか溜まってドカンとくるやつだコワ~、まぁでもscanf使ってる時点でコワ~ではあるけど。

同じ仕様上の制限はLC_MONETARYの方のmon_decimal_point/mon_thousands_sepにもあって、そっちもやっぱりマルチバイト文字入っちゃってるし、strfmon(3)もアウトやね。

	thousands_sep = *lc->mon_thousands_sep;
	if (thousands_sep == '\0')
		thousands_sep = *lc->thousands_sep;

というかstrfmon(3)なんて関数の存在をすっかり忘れていた。

そもそもなんで桁区切り文字がU+00A0(NO-BREAK SPACE)に化けるんだろうな…まぁCLDRはXMLだから がU+0020に変換すべきをU+00A0に変換されてこの惨状ってのは容易に想像つくんだけど、変換スクリプトすらまともに書けねえとはなぁ。(追記) これはISO-8859-1とUTF-8の非互換性の問題ですわ、確かにISO8859-1なら\xa0はシングルバイトだ。UTF-8に変換したとたん\xc2\xa0のマルチバイトに豹変するがな。

まぁISO-CやPOSIXのシングルバイト文字以外の小数点はダメってのも、中東なんかじゃMomayyez(ARABIC DECIMAL SEPARATOR, U+066B)を使うからよろしくないんだけどね。 でも今回の問題はそもそも文字が化けて不正なデータになってる時点で何一つ擁護できるポイントがねぇんですわ。

[Linux] どうやらglibc2のLC_NUMERICも仕様違反しとるもよう

うーん、glibc2もLC_NUMERICに仕様に反してマルチバイト文字列な桁区切り文字指定しとるのな。

 LC_NUMERIC
 decimal_point             ","
 thousands_sep             "<U202F>"
 grouping                  3
 END LC_NUMERIC

これU+202FってNARROW NO-BREAK SPACEかぁ、U+0020でいいじゃんこんなのほんとUnicodeって…

ただglibc2の場合は未定義動作であるマルチバイトな桁区切り文字でもちょん切らずに全部出力しとるから不正なバイト列になるとかは無いので、まぁ意図的だろうな。

            do
              *--w = thousands_sep[--cnt];
            while (cnt > 0);

小数点の方も大丈夫っぽい。

          decimal_len = strlen (decimal);
...
              cp = (char *) __mempcpy (cp, decimal, decimal_len);

まぁglibc2は書式指定子にIという拡張があるくらいだからこれくらいやるかもなとは思っていたのでソース確認したのだ。

基本的に小数点と桁区切り文字ってlocaleconv(3)やnl_langinfo(3)経由でlibc外にも公開されてるものなので、仕様違反やらかして移植性無くすのほんと勘弁してほしいですわ…

ちなみにマルチバイト文字を認めると、ISO-2022-JPのようなロッキングシフトによる状態遷移の発生する文字コードで破綻するのだよね。 まぁそれは元々破綻してるからいいんだけどさ…

[Solaris] Solaris10のバヤイ

どうもSolaris10のlocaledefもやらかしとるっぽい、フランス式桁区切り地域のUTF-8 localeは全滅かなぁこれ。

まず/usr/lib/localedef/src/locales/fr_FR.UTF-8.src.bz2

**************
LC_NUMERIC
**************

decimal_point	"<COMMA>"
thousands_sep	"<NO-BREAK_SPACE>"
grouping	3

そんで/usr/lib/localedef/src/charmaps/charmap.utf-8.bz2

<NO-BREAK_SPACE>                      \xC2\xA0

どちらのファイルも(C)はUnicode, Inc. まぁこいつらが伝染元なんだろう。昔っからお粗末な変換表なんかで世界に大迷惑かけてきた体質は変わらんな…

ところがですね、実際にSolaris10のprintf(1)コマンド使ってフランス式の桁区切りを試してみたんだけど

$ LANG=fr_FR.UTF-8
$ export LANG
$ printf "%'d\n" 1000
1 000
$ printf "%'d" 1000 | od -x
0000000 2031 3030 0030
0000005

!?定義ファイル上はU+202F(NO-BREAK SPACE)なのに、出力はU+0020(SPACE)になって仕様通りになっとる…

locale -k thousands_sepの結果もU+0020だしナニコレ。

$ locale -k thousands_sep
thousands_sep=" "
$ locale -k thousands_sep|od -x
0000000 6874 756f 6173 646e 5f73 6573 3d70 2022
0000020 0a22
0000022

さっきのglibc2みたいに全部出力するなら\xC2\x\A0のはずだし、Nみたいに先頭1バイトだけなら\xC2で不正なUTF-8シーケンスのはずだし、何が起きてるんだこれ。

まぁ中途半端な文字コードの知識だとISO-8859-1のNO-BREAK SPACEは0xA0だからUnicodeと互換性あるしUTF-8でも同じと勘違いするやついるよね。 肝に銘じろISO-8859-1とUnicodeはCCSとして互換はあるが、CESとしてUTF-8を使うのならバイトシーケンスに互換性は無いのだ。 なのでUTF-8を採用した場合にISO-8859-1ではできたことができなくなる劣化は当然ありうるってことにな! 通貨記号とかも同じだわな。

ということであとでOpenSolarisかOpenIndianaのソース探すかなぁ、国際化まわりは非公開だった気がするけど。 まぁ多分パッケージとソースが不一致なんじゃねぇかなとしょーもないオチな気はするが。

[NなんとかBSD] 続・N HEADのLC_NUMERIC/LC_MONETARY定義などが地味にぶっ壊れてる件

なるへ、最初にginsbachがFからLC_NUMERIC移植した時の仕事からしてバグっとったんだな。 src/share/locale/numeric/Makefileで *.UTF-8のソースを*.ISO8859-1とかやらかしよる。

LOCALESRC_fr_FR.ISO8859-15=     fr_FR.ISO8859-1
LOCALESRC_fr_FR.UTF-8=          fr_FR.ISO8859-1

かつてUnicode派がISO8859-1とUnicodeは上位互換だから移行作業必要なしとか大嘘ぶっこいてたのに騙された無知がこういうところで爆発するんだよなぁ。

たしかに符号化文字集合(CCS)としてはコードポイントは上位互換なんだけど、文字符号化手法(CES)に落とし込むと0x80以降が1byteから2byteになるから互換性なぞ皆無なのよな。 Unicode派のデマ信じてUTF-8としてISO8859-1を変換せずにそのまま流すと事故起きるというパターン、うおおおおおおおおおおおじゃねぇかマジで。

おそらくsend-prかなんかがあってginsbachの仕事をバグとして認識したまではいいのだが、例のドイツ人が俺は国際化に詳しいとかいってろくにLC_NUMERICの仕様読まずにISO-8859-1→UTF-8への変換してより事態が悪化したってことですわ。

このへんワイはUTF-8 localeいっぱい増えるの嫌で、すでにlegacy encodingなlocaleインストールされてたらUTF-8 locale要求された時はlibc内でiconv(3)するかなと考えてたんだけど、このthousands_sep/decimal_point問題とかあるから断念したんだっけ、ああなんかいろいろ思い出してきた…

[NなんとかBSD] 続々・N HEADのLC_NUMERIC/LC_MONETARY定義などが地味にぶっ壊れてる件

どーせglibc2がthousands_sepやgroupingにマルチバイト許容してるんだし、Nも同じ様に直せばいいやんと開き直られるとアレなので、マルチバイトにすると発生する問題を書き残して先制攻撃しておくか。

お前は一体誰と戦っているんだ、天井の染みの声とだよ!

まずは実装上のお話、過去回でちょこっと触れたけれどもNのvfwprintf.cは変換バッファに

/*
 * The size of the buffer we use as scratch space for integer
 * conversions, among other things.  Technically, we would need the
 * most space for base 10 conversions with thousands' grouping
 * characters between each pair of digits.  100 bytes is a
 * conservative overestimate even for a 128-bit uintmax_t.
 */
#define BUF     100

        CHAR_T buf[BUF];        /* buffer with space for digits of uintmax_t */

を用意し、これで128bit integer時代も万全とかいってるわけだけど、この大前提が崩れるのよな。

なおこの数字はこなみ感あふれるクッソ適当な値にみえて、1桁毎に桁区切り文字がはいったとして128bit最大値で

$ printf "3,4,0,2,8,2,3,6,6,9,2,0,9,3,8,4,6,3,4,6,3,3,7,4,6,0,7,4,3,1,7,6,8,2,1,1,4,5,6" | wc -c
77

で77バイトだからわりと妥当な数字ではある。

ところがthousands_sepにマルチバイトを指定できるってことは長さはMB_LEN_MAXすなわち無限大となるわけで、この変換バッファはもはや静的に確保できない事を意味する。 当然性能アレになるよね。

つーかこの程度でmalloc(3)呼ぶ羽目になっとったら、Nの拡張APIであるけどsnprintf_ss(シグナルセーフ版snprintf)が大概シグナルアンセーフになるのである。 さすがにdoubleは出力できませんって仕様制限は許容できてもint出力できないってのはもうこのAPIの存在意義無くなるでしょ。

まぁそれでも100%無いとは思うがもし将来的にISO-C/POSIXがマルチバイト可に仕様が変わったら、オレオレN6でも実装せざるを得ない。 ワイなら変換バッファにはthousands_sepの代わりに非digit charなマーカーつっこんどいて、出力時にthousands_sepと置き換えるって方法で実装するけど、桁区切り毎にfwrite(3)を分割して呼ぶことになるからわりと性能劣化するのがネックやな。

あとこれも前にちょっと書きかけたけど、ISO-2022-JPのようなlocking shiftを伴うstateful encodingにおいては、thousands_sepに指定する文字はinitial stateでvaild(つまりsingle byte)でないと複雑さが増す。 桁区切り文字や小数点文字を出力する時に、マルチバイトをいったんワイド文字に変換してまた戻すってやらんと状態遷移できないからな。 おそらく仕様がマルチバイトを禁止してるのはこれも理由の一つと思われる、かつて日本のメーカーが人出してた時代はちゃんとその辺考えてたそうだしぃ。

そんで仕様がシングルバイト文字縛りにしている最大の理由、それはprintf(3)の幅指定やゼロパディングとの整合性なんやな。

この幅指定とか桁区切りってのは結局のところ「見やすく文書を整形するための機能」であって、マルチバイトな桁区切り文字や小数点がここに入ってきて幅指定のバイト数を喰うと表示がグチャグチャに崩れてしまうんよな。

まぁシングルバイトの幅が1とは限らんしそもそもそういう意図なら仕様は「幅指定はバイト数でなくwcwidth(3)の幅に従う」に変えろなんだが、そもそもこの21世紀にフォントの幅が固定とかありえないwcwidth(3)自体オワコンという話なので、いまさら仕様を変える意味は無いのだ。

結局のところprintf(3)のこの辺の仕様自体もう時代にそぐわない過去の遺物なので、glibc2よろしく「拡張したぜ便利だぜ」とかぬかすのは無能の働き者の証拠よ、もう触らずにそっとしておけというお話。 古代文明の遺跡で発見されたトラックっぽい巨大ロボを原理も知らずに運転してるとそのうち宇宙ごと人類まとめて死ぬといういつもの話。

そもそも桁区切り文字にSPACE(U+0020)でなくわざわざNO-BREAK SPACE(U+00A0)なんつーのを発明した理由って、自動改行をスペースなんぞに頼っとるから数値の桁区切りは例外としたいという横着にしかみえんもんな。 なんなら日本語もいちいち辞書使って分かち書きとかしなくていいよう単語の境界を表すゼロ幅の「文字」を要求してもいいんじゃねぇかな…ってもうZERO WIDTH SPACE(U+200B)あるからそれ使えっていわれるか…うん…文字ってなんだ(哲学)

本質的にSPACEとNO-BREAK SPACEでそこに何の違いもありゃしないことを学ばずに、NARROW NO-BREAK SPACE(U+202F)とかFIGURE SPACE(U+2007)とかどんどん増えていくUnicodeほんと地獄としかいいようがない。

[NなんとかBSD] locale(1)コマンドのバグめっけた

もういっこNのしょんないバグめっけた、CitrusのNマージ時にFから持ってきたlocale(1)なんだけど、LC_NUMERICのgroupingおよびLC_MONETARYのmon_groupingの表示がバグっとるわ。

$ LANG=en_US.US-ASCII locale -k grouping
grouping="^C"
$ LANG=en_US.US-ASCII locale -k grouping| od -x
0000000     7267    756f    6970    676e    223d    2203    000a
0000015

"\003"という値が入ってるけど、これちゃんと人間が読めるように

$ LANG=en_US.US-ASCII locale -k grouping
grouping="3"

と表示されるようにせんとダメなのだ、NのHEADも直ってないねぇ。ちなみにFの方は4年前くらいに修正されとるもよう。

これginsbachがLC_NUMERIC/LC_MONETARYをFから移植した時に一緒に直すべきだったもんだけど、そもそも彼は仕様なぞまったく把握しておらずlibc内では"3"ではなく"\003"に変換しないとならないことすら知らんかったから直せるはずもなく。 というかlibc内では不正な値がgroupingに入ってるけど、locale -k groupingでみりゃ正しい値にみえてるって状態だから気づかんわなそりゃ。

なのでワイが尻ぬぐいでfix_groupingという関数を実装してこのバグ潰したんだが、同時にlocale(1)も直さんといけんことに気づくべきではあった。 でもなぁ当時ワークエリア外の人間に断りなく好き勝手やられて、その上嫌ならrevertでなくお前が直せしかも枝切りの時期だから今すぐやれ言われてマジ内心ブチ切れてたんで気づく余裕なんかねーよ。

そもそもその時俺はデスマの真っ最中に家族の大病が発覚し、会社拝み倒して介護のため現場抜けようとしたら引き継ぎ相手の家族も倒れて白紙に戻るというまともじゃない状況でな。 よくその時に我慢して時間を無理矢理作って直したと思うよ、移動中くらいしか時間ないし現場にゃPC持ち込めんから紙にコード書いてたりもしてたしな。

そんで次にワークエリア外の奴になんかやられたらはもう二度目は無いと決めてたのでmulti-localeの件でうん国際連盟を堂々と退場し、今ではBitBucketで自分の好きなように植民地経営と軍拡を行っているわけだ、ああ地上の楽園被害を被る国民も自分一人だし気楽でいいわこれから毎日核実験しようZ