たごもりすメモ

コードとかその他の話とか。

源泉徴収票シリアストーク: 情報の不均衡とうまくつきあう

TL;DR

  • 自分の給与額が業界内で高いのか低いのか、知るのは難しい
  • 似たような条件の人どうしでうまく匿名化して共有しあえばいいのでは?
  • という欲求を満たす源泉徴収票シリアストークという試みを紹介する
    • 実際の実行にはレギュレーションが重要です

給与に関する情報の不均衡

給与・所得というやつがあります。生きるのに必要なのはもちろん、たとえば同じ会社や同じ職種・同じ業界の中でどのくらい高い給与・所得を得ているかがある種のバロメーターになって、高い人がエラい、みたいなトンチンカンなことを言い出す人も出てきたりする。もちろん給与が高いからエラいなんてことは絶対にないんだけど、それはそれとして給与は高いほうが嬉しい。

だがしかし、給与額というやつは個人の能力だけではもちろん決まらなくて、儲かっている会社にいるかどうか、所属している会社がどのくらいの給与を払うポリシーなのか、会社内でどのように評価されているか、などに大きく依存する。このため、よっぽど良い知人同士であっても直接に給与額どうしを比較するのはめちゃくちゃな危険を伴い、後々まで有害な影響を残す*1ことが知られている。

とはいえ、そこに情報の不均衡ができる。給与を出す側の人達は、色々な人を見比べて、誰にどの程度の給与を出しているかを知っている。また外部の人材エージェントやヘッドハンターなども知っていることが多い。しかし雇われる側はそう多くの情報を持っておらず、そもそも自分の給与は満足すべきものなのか、それとももっと多い額を望めるものなのか、正確に知ることはまず不可能と言ってもいいだろう。

求める情報を整理して限定する

さて情報の不均衡はあるが、そこで情報を求めるにしても、誰かれ構わず給与額を聞いたって仕方がない。自分と較べるんだから、自分と同じくらいの評価を受けているのではないか、という人の情報を聞いてこそ意味がある。職種・職位でともに似たようなポジションで、転職の際に、まさにその人の替わりとなって入る、くらいだと理想的だ。あるいは、単に自分と似たような技術的知識と経験をもっている、と思われるくらいでも構わないだろう。

また、雑に「年収」と言ったときに人が想像するものが意外に異なっていることもある。多くは税引き前の給与総額を指すだろうが、実際の暮らしぶりは税引き後の金額を見ないとわからない、とする人もいるだろう。しかし税金は家族構成や居住地などに影響を受ける一方、出す側が考慮することは少ない*2だろう。福利厚生などまで考えだすとどんどん話が膨れてしまい、とても公平には考慮できない。

自分はソフトウェアエンジニアのためその事情も考えると、特にシニアになってくると給与以外の収入があることもある。技術書や雑誌原稿の執筆による印税および原稿料、副業としての勤務や顧問業などをもっている人もいる。これらも「その人の収入」としては含まれるだろう。

しかし、考えてみれば、いま求めているのは「自分の給与額を評価するための指標」であって、これは他人が原稿料を稼いでいようが副業をやっていようが、何ひとつ関係がない。福利厚生だって転職先を決めるには必要な情報だろうが、今の給与額の評価には関係がない。

求める情報は源泉徴収票にある

必要な情報とは、つまり、源泉徴収票の「支払い金額」*3である。

自分と同じようなポジションの、あるいは自分と同じような技術・知識を持った人の源泉徴収票において、「支払い金額」にどういう金額が書かれているかが知りたい。個人名として「誰の」は実際には重要ではなく、自分と比較できる人だと分かっていればそれでいい。

源泉徴収票シリアストーク

ここまで書かれているようなことを自分が考えたのは、もう8年以上も前のことだ。そのとき話が合った人達とやったのが「源泉徴収票シリアストーク」である*4。ある夜にこっそりと居酒屋の一室にあつまり、強固なレギュレーションのもと、それぞれの年収を安全に共有した*5。このくらいの人達はだいたいどのくらいの給与を得ているのか、自分はその中で高低どこに位置するのか。

結論としては、実に有意義な試みだった。ものすごく生々しく確実な形で、自分の給与額をどう評価すればよいかの情報を得た。参加者の一人からは、後に、あの情報がその後のキャリア形成に実に役立ったということも聞いた。ということで、他の人も条件が整えられるのであればやるといいのではないかと思う。

このエントリは以後、源泉徴収票シリアストークをどのように開催すればよいかを紹介する。ざっくり言うと、数名で行われる飲み会として実行する。

人選が何よりも重要

とにかく、給与額という比較的センシティブな情報を、匿名化した状況とはいえ、共有してよいという人だけを集める。よい友人関係があることが前提になるだろう。また個人名と給与額の結び付きを推測できる条件はできるだけ無くしたいため、同じ会社の同僚などは避けたほうがよいだろう。

自分と比較できる人たちである、ということも重要な条件である。明らかに自分よりもシニアな人、明らかにジュニアな人と給与額を比較する意味はまったくない。「同じくらいもらっていて不思議ではない人」、業界的あるいは知識・経験的に自分と同格程度であろう、という人たちを集めて行うべきである*6

あまり少ない人数だと、誰がいくらもらったかの推測が容易になってしまうため、ある程度の人数がいたほうがいい。おそらく6人以上が望ましいだろう。一方、あまり多い人数になると同格で信用できる人を集めるのも難しいし、全員が同じものを同時に見るのも厳しくなる*7。6〜8人程度が最適なのではないだろうか。

レギュレーションを決める

ここに当時使用したレギュレーション実物がある。これはすべて非常に重要なので、表現を変えつつ理由なども含めて解説する。

レギュレーションの主目的は、どの金額が誰のものなのかを分からなくすることにある。情報は安全に共有できなくては意味がなく、どの金額が誰のものかを推測できそうな手がかりは可能な限り潰しておこう。また金額を見る瞬間を全員で同じにすること、見たときのリアクションを全員で統一することも重要で、これによっていつ出た金額が誰のものかを推測する手がかりも潰しておく。

準備

準備は非常に重要である。守れなさそうな人は参加者に入れてはいけない。

  • 源泉徴収票 20xx年 のものから「支払い金額」のみを転記する
    • 「円」などの表記を入れず、数字のみを書くこと
    • ひと目でわかるよう、3桁ごとにカンマを入れること (例: 8,000,000)
  • 白い紙にプリンタで、黒で印字すること
    • 全員がひと目で見られるよう、数字をできるだけ大きくなるよう印刷すること
    • 数字以外の情報は一切書かないこと

共有すべき情報をシンプルかつ確実に定義する。また「ひと目でわかるよう」は重要で、これで全員が同時に金額を理解できるようにする*8。筆跡などの情報を残さないようプリンタでの印刷は当然だ。

  • 給与所得のもののみを記載すること
    • 原稿料、印税など、主たる給与所得以外のものは含まない
    • 20xx年途中での転職などがある場合は合計金額を記載すること
    • ただし転職前後で著しく給与が異なる場合や不労期間が含まれる場合などは20xx年末の給与所得基準で善意のもと12ヶ月分の給与になるよう補正をかけてよい

これも共有される情報の定義である。単に守ればよい。

  • 白、長形4号の二重封筒に入れること
    • ローソンで販売されているものが望ましい
    • 紙を入れたら密封すること
    • 封筒の外側には一切何も記載しないこと
    • 封筒を折り曲げたりしないこと

これは当日、全員分を出したときに区別がつかないようにするため*9。全員が白い封筒なのに一人だけ茶封筒だったりしたら台無しである。こういう細部をきちんと守れてこそ、安全に重要な情報が共有できると言える。

当日の行動

ここからは当日の行動。実行したときは、できるだけ参加者の知人がいなさそうな街の、できるだけ奥まった場所にある、確実にドアつき個室がとれる店を選んで予約した*10。今にして思えば、ドアつき個室でさえあれば店の外で誰と遭遇しても大して問題にはならなかったんじゃないかという気もするが、しかし秘密の会合めいたイベントを開催するにあたっての雰囲気づくりとしては実に良かった。楽しかった。

さて、当日の行動においても、情報を安全に共有するために守るべき点はいくつもある。

  • 飲み会終了15分前になったら行動を開始する
  • 全員の封筒を取り出し、全員でシャッフルする

これらの事項は単純だが、重要でもある。状況の共有は飲み会の終了直前がよいと思う。見た内容についてお互いに話し合うようなことは何もないし、一人で考える時間をとる方が健全だろう。封筒を全員でシャッフルするのは、もちろん匿名性のためだ。

  • 全員の目の届くところでひとつずつ開封する
    • このとき、参加者は数字を見たら必ず「ふーむ」と言うこと
    • それ以外は口にしないこと

開封時のリアクションを、これらの項目で強制している。強制的に「ふーむ」と言わせることで自分の金額が出てきたときもリアクションのブレをおさえられる*11。これも安全な情報の共有のためには非常に重要。

  • 封筒全部を開封したらハサミで細断する
    • 店を出たら全員分をコンビニのゴミ箱などで確実に投棄する
    • 以降、当日見たことについては口外しないこと

当然である。

給与額がすべてではない、しかしお金は大事

このようにして、安全に、自分の給与額を評価するための指標を手に入れることができる。いい仲間を集められる人は試してみてもよいだろう。

もちろん、給与額がすべてではない。給与額より重要なことはいくらでもある。しかし同時に、被雇用者として働く以上は、あるいはいい人生を送るためにはある程度はお金について考えることは欠かせない。そのための情報を得るひとつの手段として、こういうのもあるよ、という話でした。

*1:人によります。

*2:つまり雇用主からの評価は税引き前の金額に反映されているはず

*3:給与として勤務先から支払われた、一年分の税引き前の総額

*4:当時、ソフトウェア関連の勉強会で 〜 Casual Talk という名前をつけることが多く、それが名前の由来になっている

*5:この安全性のために重要なのがレギュレーションである

*6:自分の場合はソフトウェアエンジニアのコミュニティ内での友人たちのうち、これはという人達で実行した

*7:これは当日の行動として重要な条件である。後述。

*8:例えば数字が小さかったり読みにくかったりすると、よく見ようとする人と見なくても分かってしまった本人の間でリアクションに差が生まれる可能性がある

*9:なぜローソンのものが望ましいという条件をつけたかはもう覚えていない。なんだっけ……たまたま近くにあったのかな。どのコンビニ等でもよいと思うが、どこかのもので統一できれば理想的だろう。

*10:ある冬の日の実行で、あのブリしゃぶは非常においしかった

*11:どうでもいいが、このとき参加者がみんなで「ふーむ」ってtweetしまくってたら、参加者以外がなにやら不穏な空気を感じとったり真似たりしていてちょっと面白かった

Xcode上のSwift(iOS App)プロジェクトでTestsターゲットのみビルドエラーが起きる

iOSアプリを書いてるんだけどちょっと一部(データの変換とかで)ちゃんとユニットテスト書こうかなと思ったりしても、なんか変なエラーが起きてビルドできなかったりする。なんでだよ。

おそらくAWS Amplifyへの依存、およびその依存ライブラリaws-crt-swiftなどが関係している。ちゃんと理屈のついた解決方法はまだ見付かっていない。

Testsターゲットがビルドできない

テストを書くためのTestsターゲット*1をビルドしようとすると、一部の依存関係が見付からないと出る。直接依存関係として指定したものではない。

Xcode : missing required modules: 'AwsCAuth', 'AwsCCal', 'AwsCCommon', 'AwsCHttp', 'AwsCIo', 'AwsCMqtt', 'AwsCSdkUtils'

なんだこれ、と思ってあれこれ調べると、依存ライブラリを全部明示的にリンク対象としてセットすれば解決するぞ、みたいな話を読んだ(だいぶ前のことで、どこを参考にしたか今となっては不明)。 この設定自体はプロジェクトを開いてから TARGETS → Testsターゲットを選択 → "Build Phases" → "Link Binary With Libraries" を開いて、そこで "+" して出てくるもの全部を選択していた。

が、これをやると、今度は次の問題が出た。

XCTestsが見付からない

次は、テストコードファイル(*Tests.swift)の import XCTest 行でエラーが出たり出なかったりするようになった。出るときは "No such module 'XCTest'"。エディタ上のエラーとして、あるいはビルド時のエラーとして出る。以下のようなエラーのこともある気がする。

Cannot load underlying module for 'XCTest'

これが出たり出なかったりで、本当によくわからない。たまにテストが走るまでいくんだけど、いかないときは全然ダメ。Clean Build Folderしてからのビルドなども試したが、そんな単純な方法では回避できなかった。

いくらか調べたところによると、*Tests.swiftファイルがテストじゃない通常ターゲットのビルド対象ファイルに入っているとそういうことが起きる、ことがあるらしいんだけど、自分の手元ではテストファイルが通常ターゲットのビルド対象に入ってたりはしなかった。

最小限の依存ライブラリのみ明示的にリンクする

いくらか試行錯誤したけれど、以下のようにしたら、テスト走行までできるようになった。

  1. Testsターゲットを新しく作りなおす("Unit Testing Bundle")
  2. ビルドエラーが出なくなるまで、ライブラリをひとつずつ、テストターゲットの明示的なリンク対象に追加する

自分の場合、以下のものを追加したところでエラーが出なくなった。XCTestが見付からなくなることも(今のところ)ない。

やれやれ。

結論

やれやれ。なんだろうね。ググってもほとんど同じような話が出てこない。

対症療法でしかないんだけど、とりあえずはこんな感じで。

*1:ProjプロジェクトであればProjTestsターゲット

TokyuRuby会議14に参加してしゃべってきた

久し振りに開催されたTokyuRuby会議14に参加した。LTも申し込んでいて通ったので、LTもやってきた。なんかTokyuRuby会議が行われると、イベントが戻ってきたなあ、という気がする。よかった。

しゃべった

LTの内容は最近やっているNameSpaceまわりの話。

5分LTを最後にやったのは2019年のRubyKaigiだった。5分のLTなんて体に染み付いてるから息をするようにやれるじゃろ、と思ったら完全にペースを間違ってぜんぜん終わらなかった。なんてこった。何でも、やらないと衰えるなあ。

話の内容自体は、あとにあった3分追加でしゃべっていい枠で話せて満足。これは引き続きやってて、9月の松江Ruby会議10でも話す予定です。

料理持っていった

TokyuRuby会議14、おでんとローストビーフマリネ

今回作って持っていったのはミニチュアおでん、ローストビーフのわさびマリネ。どっちも全部食べてもらったし、おいしかったと何度も言ってもらえて大満足。ミニチュアおでんは大根の型抜きして下茹でしてるところを前日Twitterに放り込んでいたら、あれなんだったんですかって何人もに聞かれたのが狙い通り。

参加した

あとはもう……いつものTokyuRuby会議だったので、何がどうだったとも言いがたいが、RubyKaigiで話せなかったなという人ともけっこう話せたり、今回の自分のネタについても追加で議論する機会が持てたりで、じつによいイベントだった。楽しかったです。

SwiftUI PhotosPickerで選択した項目からJPEG/PNGを取り出す

SwiftUIで作るiOSアプリで、画像を選択し、その画像をどこかにアップロードしたい。アップロード先がHEICに対応してないのでJPEG/PNGあたりのフォーマットでやりたい。

これを考えたとき、iOS 16.0+ ならPhotosPickerが使える。が、実際に選択したあとでどうやってJPEG/PNGのデータを取り出すかにだいぶ悩んだのでメモっておく。他にもっとマトモな方法ないの? と思っている。

PhotosPickerを使う

これは既存のビューでボタンを押すとシートで下から出てくる感じにしたかったので、こんなコードをざっと書けばよい。これは1枚だけ選ばせたい場合で、複数枚選ばせたい場合は呼び出しのシグネチャが少し変わるのに注意。

@State var isPickingPhoto: Bool = false
@State var selectedPhotoItem: PhotosPickerItem? = nil

var body: some View {
    AnyView()
    .sheet(isPresented: $isPickingPhoto) {
        PhotosPicker(
            selection: $selectedPhotoItem,
            matching: .images,
            preferredItemEncoding: .current,
            label: { Text("Choose your profile image") }
        )
        .onChange(of: selectedPhotoItem) { photoItem in
            guard let photoItem else {
                return
            }
            uploadSelectedPhoto(photoItem)
        }
        .presentationDetents([.height(280)])
        .presentationDragIndicator(.visible)
    }

んで実際のデータ取り出しおよびアップロードはuploadSelectedPhoto()で行う。

PhotosPickerItemからデータを取り出す

実際にデータを取り出すとき、JPEGPNGのどちらかが欲しいとする。しかしiPhoneのカメラで撮影した画像はHEICがプライマリの画像形式になっている(ことが多い)ため、JPEGPNGが欲しい、と指定する必要がある。選択した写真が対応している形式はsupportedContentTypesで取得できる、が、これはUTTypeを返す。

photoItem.supportedContentTypes //=> [UTType.heic, UTType.jpeg]

ところで、PhotosPickerItemから実際に扱うデータの取り出しはloadTransferable(type:)関数で行うが、ここでtypeに指定するのはSwiftの型であってUTTypeでもMIME Typeでもない。

let transferable = try await photoItem.loadTransferable(type: Data.self)

えー、このDataの中身はなんなんだよ、対応しているUTTypeの形式で取り出させてくれよ、と思うが、そのようなAPIがなさそうに見える。本当に?

で、現状どうしているかというと、しょうがないからいちどUIImageを経由している。これ、画像変換が行われちゃってないかなあ、大丈夫? と思うものの、他に方法が見付かっていない。

// JPEGの場合
let data = UIImage(data: transferable)?.jpegData(compressionQuality: 1.0)
// PNGの場合
let data = UIImage(data: transferable)?.pngData()

このへんをまとめて、必要な場所で次のようなextensionを書いておいた。Swiftのfileprivate便利だよね。

typealias UploadPictureData = (data: Data, mimeType: String)

let PERMITTED_IMAGE_TYPES = [
    UTType.jpeg,
    UTType.png,
]

fileprivate extension PhotosPickerItem {
    func pictureData() async -> UploadPictureData? {
        var transferable: Data? = nil
        do {
            transferable = try await self.loadTransferable(type: Data.self)
        }
        catch {
            // logging
            return nil
        }
        guard let transferable else {
            // logging
            return nil
        }
        var data: Data? = nil
        var mimeType: String? = nil
        if self.supportedContentTypes.contains(UTType.jpeg) {
            data = UIImage(data: transferable)?.jpegData(compressionQuality: 1.0)
            mimeType = UTType.jpeg.preferredMIMEType
        } else if self.supportedContentTypes.contains(UTType.png) {
            data = UIImage(data: transferable)?.pngData()
            mimeType = UTType.png.preferredMIMEType
        } else {
            // logging
            return nil
        }
        guard let data, let mimeType else {
            // logging
            return nil
        }
        return (data, mimeType)
    }
}

もうちょっといいやりかた無いもんかなあ。

RubyKaigi 2023に行ってきた

2020年に開催できなかったのち、3年を経て松本市で行われたRubyKaigiに行ってきた。自分は2020年のときはスポンサー企業の一人としてやることがある予定だったのが、3年経って、なんでもないいち参加者として行くことになったなど、いろんな変化を感じた。それでもほぼフルに参加者が戻ってきたいつものRubyKaigiでもあって、なんかもう、すごくよかった。

RubyKaigiの成果

聞いてきた

Implementing "++" operator, stepping into parse.y

Implementing "++" operator, stepping into parse.y - RubyKaigi 2023

とにかく最高だったのはしおい(@coe401_)さんのやつ。これはやっている内容といい、プレゼンそのものの楽しさといい、本当に最高だった。

speakerdeck.com

スライドでもいいけど、動画が公開されたら見てない人にはぜひ見てほしい。自分ももう一度見たい。最高。「これを実装する」という目的に対して、とれそうな手段をRubyのいろんなレイヤであれこれ試してて、順を追ってRubyのすごく深いところまでどんどん潜っていっているし、あちこちで使ってる手段が本当に目的のためには手段を選んでいない感じで面白すぎる。ちゃんとオチまでつけていて、プレゼンとしての完成度もすばらしい。

自分がいちばん好きだったのはBinding#local_variable_setをメソッドのレシーバ自身を置き換えるために使っているところで、普通そんなことはできないんだけど一度パーサを経由してるならパーサがレシーバ自身が格納されている変数の名前を知っているじゃん、っていう、もう最高としか言いようのない使いかたをしていた。

このトークを聞いていた他の人々と話していると、けっこうみんな「自分が最高にウケたポイント」が違っていて*1、それがこのトークの幅の広さを表してもいると思う。好き。

The future vision of Ruby Parser

The future vision of Ruby Parser - RubyKaigi 2023

いつもお世話になっている金子(@spikeolaf)さんのやつ。

speakerdeck.com

金子さんのパーサにかける情熱がどこから湧いてくるのかいまいちわからないんだけど、結果として我々*2に利益があるいろんな改善が行われている上に、話を聞いているととにかく楽しそうにやっているのが本当に好き。LTもそうだけど、話を聞いているとついうっかりparse.yをさわりそうになってしまう魅力があってたまらないと思う。しおいさんのトークもそういう背景があって出てくるのかなと思える。こういうふうに色々な個人をひっぱっていける魅力のある話はほんと聞いてて楽しい。好き。

Ruby JIT Hacking Guide

https://rubykaigi.org/2023/presentations/k0kubun.html#day3

@k0kubunさんのやつ。楽しそうにぴょんぴょんしながら話してるのを見てると楽しくなる。

speakerdeck.com

とにかくruby-jit-challengeをやるぞ! という気分。これ作って出してくれてほんと嬉しい。RubyKaigi前にちょっとRJITさわってみようとしたんだけど、あまりに情報がなさすぎて挫折したんだよね。k0kubunさんも、もうとにかく速くしたいんだな、というのが聞いてると常に伝わってくるのがじつによい。あとJIT民主化というか、これも多くの人を巻き込む方向の技術なのがすばらしいと思う。好き。

Multiverse Ruby

Multiverse Ruby - RubyKaigi 2023

@shioyamaさんのトークで、これは自分がLFAを作ったときの問題意識と思いっきりかぶってたのが印象的で面白く聞いていた。

speakerdeck.com

話の内容も書いているコードもいいんだけど、特に同じ問題意識の人がいるのを発見したのが嬉しかった。偶然にも2日目の夜中にパブ(Old Rock)で本人と同席できて、名前空間どうにかしたい!!!!! っていう話でものすごく盛り上がった。これからしばらく自分がプライベートでやりたい活動に直結してて、もしかしたらこれがこのRubyKaigiで自分に一番大きい影響があったかもしれない。

Authorsrb & Fluentd実践入門販売

RubyKaigi本体では書店は出ないけど、最近本を出したRubyistで野良企画スタンプラリーをやろう、という #authorsrb に誘っていただいた。んで当日サイン会もやろうかという話になっていたので、だったら自分の本もあったほうがいいかなと思って急遽準備して会場でFluentd実践入門の手売りもやっていた*3

もう出てから半年以上経つし、そもそもRubyそのものの本でもない。ということで2〜3冊くらい売れるかなとか思ってたんだけど、今回松本に持っていったぶんは2日目の昼には全部売れてしまった。さらにまだ数人の方からないんですかと声をかけていただいたので、ちょっと持っていく数を誤ったかもしれない。でも、あまりに急遽準備したので、やれるだけはやった。会場だとこんなの買ってもらえるんだというのはすごい。あと実際にお金をいただいて売るという行為、だいぶ久しぶりにしたけど、これはなんつーか、いいですね。

その中でも、Fluentd実践入門が実際に役に立ったという話を聞かせてもらったりもして、本当にやってよかった。#authorsrb のスタンプラリーで声をかけていただくことも多かったし、他の著者陣の人達といっしょにやってる感もあって、楽しい&嬉しいイベントだった。

そのほかKaigi中のこと

RubyKaigi Day -1 のAsakusa.rbから始まってずーっと人としゃべってた感じ。Shopifyからの人達とも去年はあまりしゃべる機会が作れなかったけど、今年は話しておきたかった人達と話せてよかった。今回はやんちゃハウスに泊まったので、そこでもいろんな人と話ができた。モリスハウスを京都でやって以来で、毎回これでなくてもいいかなとは思うけど、やっぱこういうのも楽しいですね。モリスハウスまたやってみるのもいいなあ。

久々の人とも話せたし、でも人が多過ぎてあの人もいたはずなのに話せなかったなあというのもあるし。個人的な目的だったいくつかの会話はできたのでよかった。

自分のトークがないのはさびしい

しかし、本来はこのRubyKaigiに参加できない予定だったのを突然来ることになったのもあって、自分のトークは何もできなかった。去年のRubyKaigi 2022でもしなかったので、2回連続で壇上で何も話していない。これはなんというか、寂しいなあというか、悲しいなあというか。これに慣れちゃうと、なんか昔やってたらしいけど今はたんにカオが大きいだけ、みたいになっちゃうのかな。嫌だ……。

ということで、やることを決めたので書いたのが名前空間が欲しいの話。やるぞ!

*1:"+" "+"を"+="と"1"に置き換えるのもよかったですね……

*2:誰だろうね

*3:RubyKaigiの3日前!に「会場で売りたいんですけど……」という相談をもちかけるという、極めてひどい進行に @inao さんふくめ技術評論社の方々には本当にお世話になりました

ご意見募集: Rubyに名前空間サポート的なものが欲しいという話

LFAを書いたときの話にあるKernel#loadの第2引数で名前空間的なものを作れるんだけど、loadした先のファイルでrequireされてたらダメなんだよね、という話の続き。ダメなんだよねー、で終わってたんだけどRubyKaigi2023で@shioyamaさんのMultiverse Rubyを聞いて、ここに仲間がいた!!! ってなって、さらにそのあとバーで飲みながらやろうやろうって盛り上がったので、なんか色々考えている。

RubyKaigiの話は別途書くとして、いまはとりあえずこっち。

後半に、こんなものが欲しい、という話、および読んだ人の意見が欲しいということが書いてあるので、このあたりに何か思うところがある人はぜひ読んでみてください。どっちかというと、自分以外のRubyユーザがどう考えているのかを、bugsに出す前にまず知りたいなと思っています。

動機

Rubyにはみなさんご存知の通り、名前空間的なものがない。どんなスクリプト・ライブラリ・アプリケーションでも、クラス・モジュールを定義するとき、基本的には名前空間のトップレベルに置くか、あるいは既存のモジュール・クラスの下に置く。しかしこれには以下に説明するような問題がある。

名前の衝突

複数のライブラリが同じ名前を使っている場合、もちろん競合する。同じ種類(クラスorモジュール)だった場合はお互いを上書きするし、異なる種類だった場合にはふたつめを読み込んだ時点で例外となる。

またRubyの世界で支配的なRuby on Railsが「基本的にアプリケーションのクラス・モジュールはすべてトップレベルに置く」という規約を用いているのがこの制約を破滅的にしている。トップレベルの名前空間が膨大な数のユーザー定義クラスに消費されている「かもしれない」。このため、ライブラリ作者がライブラリを作るときには、トップレベルにはUserAccountStrategyGuildRecipeも使えない。*1

トップレベルで名前が衝突したとき、片方がアプリケーションであれば名前を変更できるかもしれないが、ふたつのライブラリが同じ名前を使っていた場合、ひとつのアプリケーションから両方を使うことは選択肢から無くなる。現状であれば、どちらかを諦めなくてはいけない。

複数バージョンの使用不可

ライブラリはバージョンアップを行っても、多くの場合、当然同じ名前を用いる。このため現在のRubyではどうがんばっても特定ライブラリの複数バージョンを1プロセス内には共存させられない。これは、アプリケーションが依存する複数のライブラリ(A, B)が同じライブラリ(C)に依存するが、しかしそれぞれバージョン制約が異なる、という状況を解決できないことを意味する。

App ---> A ---> C (~> 1.4.0)
    |
    +--> B ---> C (~> 2.0)

これはアプリケーション開発者にとってはかなり厳しい。上記の状況であればライブラリAの開発者に依存関係を更新してもらうようお願いすることになる。Aの開発者は依存関係を更新してその状況でAの動作確認とリリースを行い、その上でアプリケーション開発者がまた依存関係の更新とテストを行うことになる。

危険なライブラリグローバル

ライブラリに設定を行うことがあるが、これはクラス変数を用いて実装されている可能性がある。例えば動作が速いからと使われることも多いojは、以下のように設定を行う。

Oj.default_options[:allow_gc] #=> true
Oj.default_options = {allow_gc: false}

Oj.default_options[:allow_gc] #=> false

これはもちろんOj.parseしたときの動作を変更する。つまり、どこか(自分に責任のない)アプリケーションやライブラリの片隅で設定を変更されると、プロセス内すべてにおけるライブラリの動作に影響を与える。

名前空間による解決

ここに書いた問題は、基本的に名前空間があれば解決できる、と自分は信じている。Rubyの動作をいきなり変える必要はない。オプショナルに指定できる名前空間があればよい、と思っている。

Imを使ってみた

名前空間Rubyで実現する試み(Multiverse Rubyと表現されている)として@shioyamaさんが作ったものがIm*2

github.com

これは次のように、ライブラリのローダを作る。ローダはアクセスされたモジュール・クラス名から、それが定義されているファイル名を特定して、ローダ((実体はModuleの派生モジュール))自身の下部に読み込む。

require "im"
loader = Im::Loader.for_gem
loader.setup # ready!

loader::MyGem # ロードパス中の 'my_gem.rb' が特定され自動的に読み込まれる

これをKaigi中に試してみたんだけど、LFAでは以下の理由があって使えない。またgemなどのライブラリに使うのにも現状だと厳しいと思う。

loader.setupする前の時点でロードパスを確定する必要がある

これはImがZeitwerkをforkして作られているからという実装上の理由が大きいかもしれない。内部的にloader.setupした時点でロードパスにある全てのファイルをリストアップして定数名とautoloadする対象ファイル名の対応表を作るため、loader.setupした後で別のディレクトリにあるファイルからクラス・モジュール定義を読み込もうと思っても難しい。((unloadしてロードパスを追加後にsetupしなおせば可能だけど、なかなか常用には厳しいと思う。))

LFAはアプリケーションの起動時にロードパスを確定できるのでこの点は問題ではないが、通常のRubyに入れる、という機能としては厳しい制約だと思う。

ロード対象のクラス・モジュール名とファイル名の関係に強い制約がある

これもZeitwerk由来だからだが、参照された定数名をフックにして読み込むファイル名を特定する関係上、サポートできないものが多くなる。

loader::MyGem # -> my_gem
loader::MessagePack # -> message_pack だが msgpack.rb を読んでほしい……
loader::StringIO # -> requireする対象は stringio だよね

LFAでは読み込み対象のファイルもそこに書かれているクラス・モジュール名も設定ファイルから指定する*3ため、名前ベースで読み込み対象を決定する規約は不要というか、制約となって使用できない。また一般的に、gemでも難しいと思う。Ruby本体の標準添付ライブラリでもこの命名規約に従っていないものは多い。

拡張ライブラリに対応できない

Kernel#loadの第2引数を用いたやりかたはpure Rubyなライブラリでrequireを使っていないものにはうまく動くんだけど、そうでない場合にはうまくいかない。まあrequireを使われていてもその先がpure Rubyコードならなんとかなるかなと思う((具体的にはKernel#requireを上書きしてloadをwrapモジュールと一緒に呼ぶようにする))けど、拡張ライブラリの場合にはそういった方法も使えない。

欲しいもの

自分が欲しいものは以下のように使える名前空間オブジェクトだ。実体としては、拡張された機能のrequireおよびloadメソッドをもつModuleのサブモジュールになる。これが実現できれば、例に示すように、トップレベ名前空間での衝突の回避、異なるバージョンのライブラリの読み込み、クラス変数の使用による意図しない挙動変化の防止、どれもが実現できる。

この名前空間を実現するモジュールを、以下のコード例ではModuleBoxと呼ぶ。なお@shioyamaさんと話していたときには「Hakoと呼ぶのがよいのでは」ということになっていた*4

box1 = ModuleBox.new # or ModuleBox.new(load_path: $LOAD_PATH + ['...'])
box2 = ModuleBox.new

基本的にはインスタンスを作り、その名前空間内でのみ何かをしたいとき、そのインスタンスに対して操作する。

隠蔽された名前空間での読み込み

# 隠蔽された空間でのスクリプトのload
box1.load('my_client.rb')
box1::MyClient #=> MyClient

box2.require('guild')
box2::Guild #=> Guild

MyClient #=> NameError
Guild #=> NameError

box1::Guild #=> NameError
box2::MyClient => NameError

# import to a different name can be done
MyGuild = box2::Guild
MyGuild.build(...)

異なるバージョンのライブラリの読み込み

box1.require('msgpack', version: '1.6.0')
box2.require('msgpack') #=> latest one

msgpack1 = box1.const_get(:MessagePack)
msgpack1::VERSION #=> "1.6.0"
msgpack2 = box2::MessagePack
msgpack2::VERSION #=> "1.7.0"

クラス変数を用いた設定変更の影響の局所化

require('oj')
box1.require('oj')

oj1 = box1.const_get(:Oj)
oj1.default_options[:allow_gc] #=> true
oj1.default_options = oj1.defualt_options.merge({allow_gc: false})
oj1.default_options[:allow_gc] #=> false

Oj.default_options[:allow_gc] #=> true

これから

自分のアイデアはもちろんただのアイデアなので、まだ何ひとつ実現できていない。関係しそうなコードは読んでみた結果、Ruby本体を変更しないと実現できそうにないのはわかっているので、bugs.ruby-lang.orgにFeatureを出してみて、自分の手元で実装にチャレンジしてみるかなあと思っている。

これを読んだ人には、上記のアイデアがどんなもんに見えるかを考えてみてほしい。印象を聞きたい。 またこの機能だけでは不十分だとか、APIが悪いとか、そういうフィードバックがあればぜひ教えてほしい。

*1:トップレベルにそんなの置くなよ、というのはあるが、まあ……。

*2:イムと読む。ImportのImだから。

*3:AWS Lambdaがそのようになっているので、LFAの設定も当然それを踏襲している

*4:英語話者からしてもHakoというのは響きがよい……らしい。

sqldefをMySQLに対してAWS Lambdaから実行するパッケージを作った

k0kubun/sqldefはすばらしいプロダクトで便利に使ってるんだけど、もちろんDBに接続できる場所から実行する必要がある。で、DBはAWSのprivate VPCにあるのでラップトップやCI環境からやるというわけにはいかない。しょうがないので、現在はEC2インスタンスを作成して使うときだけ起動、終了したら停止してた。んだけど、これがまた面倒なんだよね。起動と停止も面倒だし、なんかあったときに確実に作り直せるようにするには……とか考えるのもダルいし、EC2へのSSHする方法やEC2へリポジトリをチェックアウトする方法も考えないといけないし。

なのでしばらく考えてたんだけど、Lambdaでやれるといいんだよな、という希望を現実的に考えて実装してみた。のがこちら。

github.com

こいつは大変便利。リポジトリをcloneして、プライベートリポジトリからスキーマファイルを読み込むならデプロイキーのファイルをぶちこんだ状態で./build.shを叩けばLambda用のzipファイルが完成するから、あとはこれを指定してLambda関数を作成すればいい。あとはtestでもaws lambda invokeでも他のなんのトリガーででも起動すればよい。どんなプロジェクト向けに作り直すのも簡単。やったね!