RubyKaigi2022で "String meets Encoding" というタイトルで話しました

RubyKaigi2022 Day3で "String meets Encoding" というタイトルで話しました。

無事話すことができて良かったです。

スライド

後日YouTubeに動画がアップロードされると思うので、アップロードされたらそちらも貼ります。

きっかけ

本編中に話したとおり、RubyKaigi Takeout 2021 後に見たすとうさんのツイートです。ですが、読み違えていてすとうさんの元ツイは「String#encode高速化してほしい」だったのに「KEN_ALL.CSVを読み込むのが遅いのか…つまりCSV.readか?それを調査するか…よっしString#splitが30%近くかかってるな!これでプロポーザル出しちゃおう!」と読み違えたままその勢いでプロポーザル出しました。その後プロポーザルはアクセプトされ、全く気づかずにString#splitの速度改善してました。なんとかなったので良かったです。

困ったこと

RubyKaigi1週間前まで速度改善できず、見切り発車でプロポーザル出したことを大変後悔していました。職場の同僚にRubyKaigiの進捗を聞かれるたび「やばいです」「だめです」「全然わからなくて困ってます」と答えていました。実際RubyKaigi1週間前の金曜日の徹底解説で話したときには「速度改善できなかったんですけど何か話します…」と言っていました。 そもそもプロポーザル出した時点でC言語読めなくて苦しんで覚えるC言語を読んでました。今もあんまり読めないです。perfがmacOSで使えないということも知りませんでした。何も知らない私にいろいろアドバイスをくださったudzuraさんには本当に感謝しています。

なかなか速度改善できなかった理由はString#splitが何をしているか理解していなかったからでした。それまでperfで何かわかりやすいボトルネックが取れないかとあれこれやってみてはいたんですが、結局perfで見てるだけじゃだめでした。改善したい箇所、今回でいうとString#splitの処理の流れをlldbでステップ実行しながら全体的に把握して、その上でperfを見ることでperfが何を伝えようとしているのか分かるようになりました。perfは注目すべき点を可視化してくれますが、それがどんな意味を持っているかはコンテキストを把握してないとなかなか難しいものだな、と思いました。この辺の話もうまく発表内に組み込めると生生しくてよかったんですが、何分時間がなかったのできれいな一本道っぽく仕立てることにしました。

慣れないツールをガンガン使ったのでバグ踏むと解決に業務後の貴重な調査時間が吸い取られてしんどかったです。特にperfでcallgraph dwarfをつけてperf.dataが1MB以上になるような測定を行うとperfがクラッシュする問題1に苦しみました。結局なんとかtoolsのバージョンの問題を踏んでいて、Ubuntuのバージョンをjammyに上げないと解決しなかったです。

途中でmacOSのバージョンを上げてしまったのでXCodeやら手で入れたGCCが吹っ飛んで入れ直したりということもありました。面倒な手順を踏んでGCC入れてGDBデバッグできるようにしても、なぜかinlineの関数に入れなくて全然ステップ実行できなくて困ったりしました。その問題はGDBやめてlldbでデバッグすることで回避しました。

あとrebaseするとmacOSRubyがビルドできなくなったりもしました。これはどうやって解決したのか覚えていません。make clean などで一旦綺麗にして再実行した気がします。

時間なさすぎてノーレビューでぶっつけ本番に突入したので、反応を見るのが怖かったです。好意的な反応が多くてホッとしました。物理会場でお話するのは初めてでしたが、ふーがさんから聞いていた通り「発表前が一番緊張するけど、始まってしまえばもうあとは話すだけ」でした。

発表時間管理にキーノートのストップウォッチをあてにしていましたが、事前のセッティングでストップウォッチが始まってしまい、発表開始時に戻せず時間管理ができなくなっていたのはわりと困りました。別でストップウォッチは用意しておこうと思いました。また、発表中に適当なキーを触ったらページ数が大きく表示されてしまったのも焦りました。本番では余計なキーを触らないよう気をつけます。

良かったこと

発表前に使えなかった stackprof, perf, lldb が使えるようになりました。CRuby も単純なメソッドなら読めるようになりました。Kaigi駆動開発で自分のやれることをガッと増やす、をここ2年間やって無理やり自分を強くしていましたが、今年のように困ったことになるのでもっと普段からやろうと思いました…

また、発表後に「姿勢を見習いたい」のようなコメントをいくつかいただいたのは私にとって思ってもいないところでした。参考になる点があったなら幸いです。また、対面やオンラインで感想を言ってもらえるのはとてもうれしく励みになりました。ありがとうございました。

発表が終わった後、舞台裏で次の発表の準備をされていたznzさんに少し挨拶していつもるりまのPRを見てくださっているお礼を伝えられて良かったです。その瞬間のことが、なんだか心に焼き付いています。

話足りなかったこと

上にあげた困ったことも話したかったですが、これはいずれブログにでもまとめて昇華させてあげたいです。perfは大変便利なので、macOSを使っていてもわざわざLinux環境を作って入れる価値はあるツールだと思います。(macOSならインストゥルメンタルでもいいはずなんですが、使い方の情報がperf以上に少なくてたいへん)

あとは String のデータ構造や rb_encoding もとい OnigEncodingTypeST という巨大構造体2 も調べて話したかったです。いつかの機会に話せるといいなと思います。

まとめ

プロポーザルを出してからとにかく苦しい生活でした。去年はプレッシャーとの戦いでしたが、今年は成果が出ないことにずっと苦しんでいました。もう見切り発車でプロポーザル出すようなことはしたくないので、362日(今年はもっと短いですが)のRubyistとしての生活に気合をいれて、日々の成果をRubyKaigiにぶつけられるようになりたいです。

あとは PR がまだ draft なのでちゃんと仕上げたいですし、Stringまとめて生成すればオーバーヘッド少なくなりそうというのも見たいですし、宿題にしたStirng#encodeの高速化も見たいのでまだまだやりたいことがたくさんあります。楽しみです。

RubyKaigi Takeout 2021 で文字コードの話をしました

2022年になって今更2021年の話です。

2021年当時にRubyKaigi参加ブログを書こうとしていた下書きを見つけたので、書きかけですがそのままリリースします。

下書きなので途中から箇条書きです。





2021-09-11 (Sat) の RubyKaigi Takeout 2021 Day3 で "Dive into Encoding" というタイトルで、文字コードRuby文字コードの話をしました。

動画はこちら。

youtu.be

この記事ではプロポーザルを提出するきっかけや準備や登壇してみての気持ちなど、感想をつらつらと綴っていきます。

プロポーザルを提出するまで

RubyKaigi Takeout 2021 のプロポーザルの募集が始まった時点では RubyKaigi にプロポーザルを提出するつもりはありませんでした。私は何かを作る人ではなく、RubyKaigi は何かを作る人の発表の場だと思っていたからです。

2021-06-11 時点ではこんな気持ちでした。

2021-06-22 に Fukuoka.rb ランチ会の Nishitetsu.rb に参加しました。その場で複数名がしおいさんに RubyKaigi にプロポーザルを出すよう熱いエールを送っていました。それを聞いていて感じるところがあり、私も RubyKaigi にプロポーザルを出すとしたらどんなことを話せるかな?と考え始めました。合わせて知り合いからima1zumiさんプロポーザル出さないの?というお便りが届いて覚悟を決めてネタ出しをはじめました。

ネタは Unicode Property の話や Emoji の話などいくつか考えてましたが、一番面白そうな「Rubyにオレオレ文字コードを実装してみる」にしました。ネタを考えた時点では本当に実装できるのかよくわかっていませんでしたが、調べるうちに去年CRubyに新たな文字コードが追加されたことを思い出しました。そのPRを見ると意外と変更量が小さかったので自分にも出来るかも?と思いました。 ちなみにこのPRを知ったのはるりま1Ruby 3.0 対応 · Issue #2458 · rurema/doctree をまとめていたおかげでした。この作業をやっていなければ自分に文字コードを追加することが可能だと判断できず、このテーマでプロポーザルを出すことは出来なかったと思います。どこで何がつながるか分からないものだと思いました。

翌日に会社の人に RubyKaigi にプロポーザル出します〜ということをなにかの折に話し、社内レビュー会を開催してもらえることになり 2021-06-29 に社内レビューを受け 2021-06-30 に提出しました。出すと決めてから7日で提出でした。

採択後

しばらくどきどきしながら待っていましたが7月中旬頃に採択されたという連絡がありました。

  • 7月
  • 8月
    • IROHAをビルドできるようになった
    • CRubyのコードリーディング
    • スライドづくり
      • 裏とりが大変
      • 正しい言葉を使う、不確定なことは話さない(もしくは分からないということを話す)
    • 英語化
      • 適当な日本語をそのままDeepLにかけると長すぎる
      • Day1, Day2の他の人のスライドを見て簡潔な英語にしたほうがいいなと思っていろいろ直していた
    • slidev
  • 9月
    • ワクチン2回目の接種で2.5日ほど準備から離れる
      • 休日なしでずっとRubyKaigiのこと考えてたので副反応で寝込む期間がいい休暇になった
    • 9/1にスライド完成してホッとする
    • 英語化
    • 構成の見直し
    • 図を作る

当日

  • 通しで練習できたのは5回くらい?
  • 練習時間はあまり取れなかった
  • めちゃくちゃ緊張した
  • 緊張してチャットは見れなかった
    • 後で会社の人がチャットのログをくれたので見れてよかった
  • 堂々と発表してたというフィードバックを見れてよかった
    • (自信がなかったとしても)自信があるような振る舞いで話すのが大事、とどこかで見かけたので意識していた
    • 裏とりにめちゃくちゃ時間を使ったので、言っていることがほぼ間違っていないだろうということには自信があった
    • UTF-16はじゅうろくで良かった
  • 後でチャットみて楽しんでもらえてよかった…と思った

やってみて

  • RubyKaigi 面白い!プレッシャーは半端ないけどやってよかった
    • 半年以上たって思いかえしても、準備は大変だったけど楽しかったなー!という気持ちになる
  • スライドを git 管理にしたのは安心感があってよかった
    • 戻すことはほぼなかったけど、いざとなったら戻せるという安心感

やっておけばよかったこと

  • 図は仮置でいいから早く作る。後回しにすると自分でも何の図を入れたいのかよくわからなくなる
  • 逆に裏とりは後でもいい
  • 通訳さんがつく場合は原稿があったほうが親切
  • 直前までスライド弄るのは精神と通訳さんによくない
  • 早めに仕上げてレビュー受けるの高速回転すればよかった…
    • 自分が言いたいことはスライドを作ってからでないと見えてこないことがある
  • 英語スライド特有の難しさがある
    • 簡潔に書かないと自分でも読めない
    • 図が多いほうがわかりやすい
    • 日本語より短く簡潔に文の構造を簡単に、を意識する
  • zoomとスライドとプレゼンターモードの配置とか事前に考えておけばよかった
    • カメラ使う場合あんまり下を見て喋りたくないなと思っていたが、そうするとプレゼンターモードの画面を置ける位置が限られるので困った
  • スケジュール管理
  • 家族になるべく負担かけない(だいじ)

配信関連

  • zoom を使って画面共有する形だったので慣れていて戸惑わなかった。よかった
  • プロンプターすごい。
  • チャットのログを遡れなかったのは残念
  • なぜかRubyKaigi teamからのメールがGmailの「プロポーション」に振り分けられ、見落とすことが多かった
  • 字幕の精度がすごい。
  • SpeakerやCommiterやRubyKaigiTeamに色や絵文字がついてたのよかった

今年もやっていくぞ


  1. Rubyリファレンスマニュアル https://docs.ruby-lang.org/ja/

-bash: warning: setlocale: LC_CTYPE: cannot change locale (UTF-8): No such file or directory を解消する

まとめ

(1) .zshrc などで

export LC_CTYPE="ja_JP.UTF-8"

日本語ローカライズに非対応の場合は、

export LC_CTYPE="en_US.UTF-8"

(2) /etc/ssh/ssh_configSendEnv LANG LC_*コメントアウトする

経緯

ssh しようとして以下のエラーが出ることがある。

-bash: warning: setlocale: LC_CTYPE: cannot change locale (UTF-8): No such file or directory

locale を見るとこのように LC_CTYPEUTF-8 となっている。エラーメッセージは UTF-8 という locale がないと言っている。

$ locale
LANG=""
LC_COLLATE="C"
LC_CTYPE="UTF-8"
LC_MESSAGES="C"
LC_MONETARY="C"
LC_NUMERIC="C"
LC_TIME="C"
LC_ALL=

macOS では Terminal.app 起動時に以下の設定がオンな場合、LC_CTYPE="UTF-8" に設定される。

ssh する際に ssh_configSendEnv LANG LC_* が有効で接続先の AcceptEnv LANG LC_* も有効な場合、LC_CTYPE="UTF-8"ssh 先にも送られる。接続先に UTF-8 がない場合は -bash: warning: setlocale: LC_CTYPE: cannot change locale (UTF-8): No such file or directory とエラーになる。

このため解消するには、

  • .zshrc などで export LC_CTYPE する
  • ssh_configSendEnv LANG LC_* を無効にする
  • Terminal.app を使わない
  • Terminal.app の Set locale environment variables on startup をオフにする

という対策が考えられる。なお Terminal.app の設定を変えると日本語入力ができなくなるので注意。

私はたまに Terminal.app を使うのと、日本語ローカライズに対応している場合はその設定を使ってほしいので .zshrcexport LC_CTYPE="ja_JP.UTF-8" を書くことにした。

参考

String#force_encodingは文字コードの強制変換ではない

RubyString#force_encoding は String の encoding を変更するだけで、文字コードの変換を行うわけではありません。そのため、バイト列は変換されません。

force_encoding はどんなメソッドか

String の encoding を変更します。ただし、バイト列は変更せず、encoding に対しバイト列が正しいかどうかは確認しません。

例えば EUC-JP に変換して encode と比較します。

''.encode(Encoding::EUC_JP).encoding
# => #<Encoding:EUC-JP>

''.force_encoding(Encoding::EUC_JP).encoding
# => #<Encoding:EUC-JP>

どちらも encoding は EUC-JP です。

String#valid_encoding? で正当なバイト列かどうか確認できます。

''.encode(Encoding::EUC_JP).valid_encoding?
# => true

''.force_encoding(Encoding::EUC_JP).valid_encoding?
# => false

force_encodingfalse になり、バイト列が正しくないことがわかります。

encodeforce_encoding の返り値を見てみると、

''.encode(Encoding::EUC_JP)
# => "\x{A4A2}"

''.force_encoding(Encoding::EUC_JP)
# => "\xE3\x81\x82"

encodeforce_encoding で結果が異なっています。

どちらも のバイト列ですが、encode の返り値の \xA4\xA2EUC-JP のバイト列で、 force_encoding のほうの \xE3\x81\x82UTF-8 のバイト列です。

''.encode.bytes.map { _1.to_s(16) }
# => ["e3", "81", "82"]

encode は encoding とバイト列を変更しますが、 force_encoding は encoding のみ変更してバイト列は変更しません。

force_encoding はどんなときに使うか

バイト列を変えずに encoding だけを変換したいときに使います。

Array#pack の返り値に対して encoding を設定する場合

Array#pack の返り値の encoding は ASCII-8BIT だったりして人間には読みにくく、また encoding に UTF-8 を期待している場合に使いにくいので、UTF-8 (もしくはお使いの環境の文字コード)の文字列として扱いたい場合 force_encoding をつけると使いやすくなります。

["e38182"].pack("H*")
# => "\xE3\x81\x82"

["e38182"].pack("H*").force_encoding(Encoding::UTF_8)
# => "あ"

バイト列から文字を組み立てる場合

バイト列だけが分かっていて、encoding を後から設定したい場合です。

例えば以下の記事に書いたような、未定義文字に対する変換先の文字を定義する場合です。これを encode で同じことをすると少しややこしくなるので force_encoding したほうがすっきりします。

U+301C from UTF-8 to Windows-31J (Encoding::UndefinedConversionError) に対応する - esm アジャイル事業部 開発者ブログ

str = "\u{2014 301C 2016 2212 00A2 00A3 00AC}"

undefined_signs = {
  "\u2014" => "\x81\x5C".force_encoding(Encoding::Windows_31J), # — EM DASH
  "\u301C" => "\x81\x60".force_encoding(Encoding::Windows_31J), # 〜 WAVE DASH
  "\u2016" => "\x81\x61".force_encoding(Encoding::Windows_31J), # ‖ DOUBLE VERTICAL LINE
  "\u2212" => "\x81\x7C".force_encoding(Encoding::Windows_31J), # − MINUS SIGN
  "\u00A2" => "\x81\x91".force_encoding(Encoding::Windows_31J), # ¢ CENT SIGN
  "\u00A3" => "\x81\x92".force_encoding(Encoding::Windows_31J), # £ POUND SIGN
  "\u00AC" => "\x81\xCA".force_encoding(Encoding::Windows_31J), # ¬ NOT SIGN
}

p str.encode(Encoding::Windows_31J, fallback: undefined_signs)

この場合は String.new でも同じことができます。

String.new("\x81\xCA", encoding: Encoding::CP932)

DB に入れるデータをバイナリ扱いしたい場合

普通にやると別の文字コードに変換されてしまう文字を DB の設定を変えずに DB に格納したいことがあり、そのために encoding に Encoding::ASCII_8BIT をつけてバイナリ扱いして encoding を変換させずに入れるために使ったことがあります。

まとめ

force_encoding では encoding のみを変更し、バイト列は変更しません。また、バイト列が正しいかどうかのチェックも行いません。

encoding に対してバイト列が不正な場合は文字を正しく扱えないため、どうしても force_encoding したい場合は valid_encoding? とセットで正しいバイト列かチェックして使う方が安全だと思います。

参考

Ruby文字コード対応については以下の記事がとても詳しいので、興味のある方はぜひこちらを参照ください。

Ruby M17N の設計と実装

最近の趣味

Rubyist近況 Advent Calendar 2021 - Adventar 7日目

フランス語学習

Duolingo で最近フランス語を勉強している。たしか udzura に英語以外の言語を学習すると面白いと言われたことがきっかけだった気がする。勉強している理由はなくて、英語以外の言語を勉強してみたかったという理由で始めた。これがなかなか癒やしになっている。フランス語に限らず興味本位の学習はとてもワクワクするし、ちょっと俺TUEE感があるので満足感があって楽しい。

執筆時点で Duolingo の英語話者向けフランス語コースの Unit 2 の9割くらいまで進んでいる。日本語話者向けフランス語コースはないので、問題と回答はすべて英語。いまのところ、とても簡単な文を読み書きしているので英語的な面で詰まることはほぼない。あと、英語にはフランス語由来の単語がいくつかあるので、すべてが初見ではない。見た目から「多分こういう意味だろう!」と思うと大概合っていて良い。ただ、単語の見た目は似ていても発音は全然違う事が多い。 詰まるところというと、形容詞の順番をうっかり間違えることがたまによくある。英語は名詞の前に形容詞を置いて修飾するが、フランス語は名詞の後ろに形容詞を置いて修飾するので、フランス語を英語に訳す問題で英語でも形容詞を後ろから修飾したりしてしまう。あと何回やっても動詞の活用を覚えられない。 tu と vous で活用が違うことに最近気がついた。

Duolingo のフランス語コースの全体は Unit 10 まであるので Unit 2 ではまだまだ序盤。日本語話者向け英語コースでは序盤の Unit は簡単すぎると感じたのだが、フランス語コースは Unit 1 が一番難しく感じた。基本的な文法や単語や活用を覚えるところが最初の壁で、全然分からなくて面白く感じられなかった。Unit 2 の途中あたりから基礎的な単語が分かってきて楽しくなってきた。フランス語の場合基本的にラテンアルファベットとアクサンテギュ(アクセント符号)くらいで文字の壁はなかった。韓国語とかだとまず文字を覚えないといけないので文字のハードルがある。

フランス語の発音は難しい。最も基本的な単語の1つである "Je" (= I) の発音がまず難しい。鼻に引っ掛けつつスッと抜けるような音を出さないといけないのだが気を抜くと片仮名の「ジュ」になってしまう。これができないと Duolingo の Audio Lessons で発音問題をなかなかクリアできなくて辛い気持ちになる。Audio Lessons は非常にわかりやすく丁寧に教えてくれるのでとてもおすすめ。Audio Lessons を解くと 20xp (通常の Lesson の1.5〜2倍のxp!) が入るのでお得な感じがする。Duolingo は Lesson 以外も学びの手段があるのがまた飽きなくて良いところ。

フランス語について体系的に知りたくなって一瞬文法書を読んだりNHKラジオのフランス語講座を聞いたりしてみたが、どうも続かなくて結局 Duolingo だけやっている。 Duolingo オンリーで進めると身につく分野が限られているので、いろいろバランスよく摂取したい。ラジオは週3で毎回15分あり、1週間はアプリで聞き逃し配信を聞ける。難易度的にすごくちょうどよかったのだけど、聞き逃し配信を聞き逃してもうだめだ〜となって頓挫してしまった。次の開始(4月とか?)からまた始めたい。

焚き火

某所で宿泊した際にホテルに焚き火体験プランがついていて、その体験をしてから猛烈に焚き火したい人になってしまった。Outer Wilds をプレイしていたこともあり、焚き火とマシュマロがすばらしいものだと感じているのもある。焚き火してマシュマロ焼いて外でコーヒーを飲みたい。ゆるキャン△はけっこうがっつりキャンプしていてすごいと思うようになった。 とりあえず今は冬でキャンプを始めるにはきびしい季節なので、会社のoViceに焚き火BGM無限ループコーナーを作って、テントを立てて、肉を焼いている人のgifを貼るなどの活動を行っている。

お絵かき

今年 iPad Pro と Apple Pencil を購入した。お絵かきのために買ったわけではないけど、せっかく道具が揃ったからとお絵かきを始めることにした。とりあえず人体分かっておけば損はなかろうと思い、ソッカの美術解剖学ノートを模写する活動を始めた。肋骨や頭蓋骨をかけるようになった。iPad Pro はお絵かきしやすいツールだと思う。 しばらく描いて、自分の描きたいものが生き生きしたネコだということがわかってきた。ネコの形は難しくて、写真見ながら描くと耳は平べったくなるし足は立体感がないし胴体は寸胴になってしまう。骨と筋肉と毛はどうなっているんだろう。

GitHubの緑化運動

今年は毎日草生やす!と言っていた人をみかけて私もやってみるかーと思い、"なんでもいいから(issueやPRのopenでも草は生える)1日1つ草を生やす"をモットーに続けてきた。 2021-01-01 から開始したのでそろそろ1年になる。Duolingo の streaks と違って生やしそこねたらもう後がないので、わりと気を張って維持していた。続けると GitHub にコードを push することにまったく抵抗がなくなるのでよかった。あと、dotfiles 弄りを続けていると「そろそろコード書いて草生やしたいな…」と思うようになり、自然とコードを書こうという気持ちになれた。 来年は毎日コードを書いて草を生やそうと思っている。

Vim から esa に日報を投稿できるようにする

ESM Advent Calendar 2021 - Adventar の5日目の記事です。

永和システムマネジメント アジャイル事業部では esa を使ってメンバーが日報を投稿しています。

ですが私は日報をよく後回しにして書かなくなってしまうことがありました。そこで、自分にとってもっと投稿しやすい環境をつくれば日報投稿が捗るのではないかと思い Vim から esa に日報を投稿できるようにしました。

使ったもの

esa のアクセストーク

とにもかくにも POST できる環境が必要です。

esaAPI はこちらにまとまっています。 こちらにも書いてあるとおり、 esa のアクセストークンは https://[team].esa.io/user/applications から作成できます。記事を投稿する目的であれば、 write 権限が必要です。

docs.esa.io

作成したアクセストークンはのちほど使うので、どこかに控えておきます。

スニペットにテンプレートを追加する

日報のテンプレートがあると書きやすいので、テンプレートをすぐに呼び出せるようにします。

私は Alfred というツールでスニペットを管理しています。 Alfred の使い方はここでは割愛しますが、「;nippo」という単語で以下のテンプレートを展開するように設定しています。

## やったこと

- 

## お気持ち

## ひとこと

esa.vim

esa.vim というプラグインをつかって Vim から esa に投稿できるようにします。

github.com

upamune.hatenablog.com

まずチーム名を設定します。

let g:esa_team = 'docs'

日報のタイトルは日付以外ほぼ固定なので、strftime を使って今日の日付のカテゴリを自動で入れるようにします。 また、 ' -w -b' オプションをつけて、 wip で投稿して投稿後にブラウザを開くようにしました。

function! s:esa_nippo()
  let args = ' -w -b'
  let category = strftime(" 日報/%Y/%m/%d/ima1zumi")
  execute ('Esa' . args . category)
endfunction
command! EsaNippo call s:esa_nippo()

初回だけ esa のアクセストークンを聞かれるので、入力します。アクセストークンは ~/esa-vim に保存されます。

~/esa-vim の取り扱いには注意してください。私は念のため .gitignore で Git 管理対象外のファイルにしました。

これで :EsaNippo というコマンドで現在のバッファの文字が 日報/%Y/%m/%d/ima1zumi というカテゴリで wip で投稿されてブラウザが開くようになりました。便利!

Rubyだけで文字コード変換を実装する

Ruby Advent Calendar 2021 - Qiita の5日目の記事です。

こんにちは。ima1zumiです。 私はRubyKaigi Takeout 2021 で Dive into Encoding というタイトルでオレオレ文字コードを作って文字コードを学ぶ話をしました。 その中で、C拡張のgemとして自作文字コードEncoding::IROHARubyで使えるようにしました。

それがこちらです。

https://github.com/ima1zumi/encoding_iroha/

このgemを使うことで、みなさんのお手元でもEncoding::IROHAという文字コードが使えるようになります。 どう作るかとかそういった話はRubyKaigi Takeout 2021のアーカイブをご覧ください。

ですが思ったのです。C拡張のgemってビルド要るからめんどくさいですよね。

ということで、pure Ruby文字コード変換を(雑に)実装してみました。それがこちらです。

https://github.com/ima1zumi/ruby_encoding_iroha/

今日はこのコードについて解説します。これを読み終わるころには、みなさんもRuby文字コード変換が実装できるようになっているはずです。*1

文字コード変換を実装するとは

RubyKaigi で語ったので割愛しますが、Rubyの場合はEncodingクラスの定数として使えるようにすることと変換器が必要です。

Encodingクラスの定数

Encoding には、 replicate というメソッドがあります。これを使うと元となった encoding と同じバイト構造を持つ Encoding クラスの定数を作ることができます。何をやっているか知りたい場合は CRuby の rb_enc_replicate を見るとよいです。

Encoding::IROHA は1バイト固定長の文字コードなので、同じく1バイト固定長の ASCII_8BIT を元に IROHA を作りましょう。

Encoding::ASCII_8BIT.replicate("IROHA")

これで Encoding::IROHA という定数を参照できるようになります。変換器はまだ実装していないので、いろは歌への変換はできません。

replicate の用途に興味があるので、ご存知の方がいればTwitter等で教えてもらえると嬉しいです。

変換器

Encoding::IROHAいろは歌+ASCIIをつかえるようにした文字コードです。RubyCSI方式で多言語対応されているので、何に変換できるようにしてあげるか決めてあげないといけません。とりあえずUnicodeに変換できるようにしましょう。UTF-8を基本としつつ、せっかくなのでUnicode系全般に対応させてみました。

    UNICODE =
    [
      Encoding::UTF_16,
      Encoding::UTF_16BE,
      Encoding::UTF_16LE,
      Encoding::UTF_32,
      Encoding::UTF_32BE,
      Encoding::UTF_32LE,
      Encoding::UTF_7,
      Encoding::UTF_8,
      Encoding::UTF8_DOCOMO,
      Encoding::UTF8_KDDI,
      Encoding::UTF8_MAC,
      Encoding::UTF8_SOFTBANK,
    ]

るりまを見てUTFと入っているものからチョイスしたので漏れや、もはや使わなさそうな文字コードもあるかもしれませんが、大体良さそうです。

さて変換テーブルを実装しましょう。いろは歌部分は50文字もないので、変換テーブルはhashでちゃちゃっと書いてしまいまします。

    UTF8_TO_IROHA_TABLE = {
      "" => "\x80",
      "" => "\x81",
      "" => "\x82",
      "" => "\x83",
      "" => "\x84",
      "" => "\x85",
      "" => "\x86",
      "" => "\x87",
      "" => "\x88",
      "" => "\x89",
      "" => "\x8A",
      "" => "\x8B",
      "" => "\x8C",
      "" => "\x8D",
      "" => "\x8E",
      "" => "\x8F",
      "" => "\x90",
      "" => "\x91",
      "" => "\x92",
      "" => "\x93",
      "" => "\x94",
      "" => "\x95",
      "" => "\x96",
      "" => "\x97",
      "" => "\x98",
      "" => "\x99",
      "" => "\x9A",
      "" => "\x9B",
      "" => "\x9C",
      "" => "\x9D",
      "" => "\x9E",
      "" => "\x9F",
      "" => "\xA0",
      "" => "\xA1",
      "" => "\xA2",
      "" => "\xA3",
      "" => "\xA4",
      "" => "\xA5",
      "" => "\xA6",
      "" => "\xA7",
      "" => "\xA8",
      "" => "\xA9",
      "" => "\xAA",
      "" => "\xAB",
      "" => "\xAC",
      "" => "\xAD",
      "" => "\xAE",
    }

なんで\x80なのかとかはRubyKaigiのアーカイブを見てください。

変換テーブルができたので、UnicodeからIROHAへの変換を実装していきましょう。

UTF8_TO_IROHA_TABLEいろは歌部分とのマッチングをします。次にASCIIを変換しますが、ASCIIの場合はバイト列はUTF-8と同じなので ascii_only? メソッドでASCIIであるかどうかだけ確認してあげます。いろは歌でもASCIIでもない文字…例えばひらがなの "が" は変換不能な文字です。ここでは Encoding::UndefinedConversionError を上げて止めるようにしました。

    def utf8_to_iroha(char)
      iroha = UTF8_TO_IROHA_TABLE[char]

      if iroha
        iroha
      else iroha.nil?
        if char.ascii_only?
          char
        else
          raise Encoding::UndefinedConversionError
        end
      end
    end

変換不能文字の処理にバグがあってうまくいかないパターンがありますが、とりあえずこのままいきましょう。*2

文字を1文字ずつ変換して、最後に連結します。join すると encoding が IROHA ではないので、 force_encoding で Encoding::IROHA に置き換えます。

ちなみに force_encoding は、その Encoding で正しいバイト列かどうかチェックしないので注意してください。Ruby では encoding は String についているラベルのようなものなのですが、ラベルだけ張り替えて変換処理を行わないのが force_encoding です。encode はラベルの張替えだけでなく実際の変換処理を行いバイト列を書き換えます。 ここでは変換処理は utf8_to_iroha で実行しているため、最後に encoding 情報を正しくするという目的で force_encoding しています。

    def to_iroha(encoding)
      raise Encoding::ConverterNotFoundError unless UNICODE.include?(encoding)

      self.each_char.map { |char| utf8_to_iroha(char.encode(Encoding::UTF_8)) }.join.force_encoding(Encoding::IROHA)
    end

同様にIROHAからUnicodeへの変換処理を実装します。

    IROHA_TO_UTF8_TABLE = UTF8_TO_IROHA_TABLE.to_h { |utf8, iroha|
      [iroha.b, utf8]
    }


    def iroha_to_utf8(char)
      utf8 = IROHA_TO_UTF8_TABLE[char.b]

      if utf8
        utf8
      else utf8.nil?
        if char.ascii_only?
          char
        else
          raise Encoding::UndefinedConversionError
        end
      end
    end

.b しているのは encoding が同じでないとうまく比較処理が動かなかったためです。なんでだろう…

後はおおむね IROHA への変換と同じです。違うのは最後の encode(encoding) で、ここでは Unicode 系への変換処理を行うため Ruby 本体の変換器を使えるので、force_encoding ではなく encode しています。

    def from_iroha(encoding)
      raise Encoding::ConverterNotFoundError unless UNICODE.include?(encoding)

      self.each_char.map { |char| iroha_to_utf8(char) }.join.encode(encoding)
    end

String#encode に IROHA 用の変換処理を挟み込みます。 from_encoding の扱いがこれでいいのかはよくわかってないです。また、**options には対応していません。

    def encode(encoding, from_encoding = self.encoding, **options)

      to_enc = convert_encoding_constant(encoding)
      from_enc = convert_encoding_constant(from_encoding)

      if to_enc == Encoding::IROHA
        return to_iroha(from_enc)
      elsif from_encoding == Encoding::IROHA
        return from_iroha(to_enc)
      end

      super
    end

これでできました。 雑にStringに追加しているのでお行儀は悪いのですが、とりあえず動くようになったのでヨシとしましょう。

感想

C拡張でしか実現出来ないと思っていたRubyへの文字コードの実装が、Rubyだけで出来て気持ちが良かったです

*1:本当に文字コードの追加が必要な場合は、自作せずに https://bugs.ruby-lang.org/ で相談しましょう

*2: 'a'.encode(Encoding::UTF_16).encode(Encoding::IROHA)すると空文字列が返ってくる。多分BOMの扱いが間違っていてバグっている。