世界を動かす技術を、日本語で。

不整合なRust

概要

  • Rustエコシステムの進化を妨げる 言語仕様上の制約
  • coherence (整合性)と orphan rules (孤児ルール)の影響
  • 既存の基盤クレート(serde等)の置き換え困難
  • ルール緩和に関する 既存提案 の紹介
  • エコシステム進化問題の根本的な難しさ

Rustエコシステム発展の障壁

  • Rustエコシステムでは serde のような基盤クレートが Serialize 等のトレイトを定義
  • すべてのクレートが独自型に対し Serialize を実装する必要
  • 他クレート型へのトレイト実装は orphan rules により禁止
  • 新しいシリアライザ(例:nextserde)登場時、全クレートで個別対応が必要
  • クレート利用者が新シリアライザを使いたい場合、 fork してパッチ適用が必要
  • これにより基盤クレートの代替普及が極めて困難
  • 古いクレートが 置き換え困難 となり、イノベーション阻害
  • 問題の根源は 言語仕様(coherence、orphan rules) に起因

Coherence(整合性)とOrphan Rules(孤児ルール)

  • coherence :トレイト実装が同型・同トレイト・同ジェネリクスで 一意 であることを保証
  • orphan rules :自クレート定義の型またはトレイトでのみ impl 可能という制約
  • 例:外部クレート型・外部クレートトレイトの組み合わせには impl 不可
  • 詳細は Rust Referencenikomatsakis の解説参照

Coherence・Orphan Rulesの必要性

  • HashMap問題 :異なるクレートで異なる Hash 実装→予測不能な振る舞い
  • 型システム健全性 維持のため、coherenceは不可欠
  • Associated Type の異なる実装が混在→安全性崩壊リスク
  • orphan rules は主に 依存関係の合成動的リンク 時の健全性維持が目的
  • 複数クレートで同一型・同一トレイト実装→コンパイル時エラーで防止

既存提案とその課題

  • バイナリクレート例外 :バイナリクレートのみorphan rules撤廃
    • 下流クレートが存在しないため安全だが、標準ライブラリ進化の妨げ
    • エコシステム進化問題の根本解決には至らず
  • Deferred Coherence :最終バイナリでのみcoherenceチェック
    • 構成問題・動的リンク問題・進化阻害
  • Coherence Domains :複数クレートを1つのcoherence単位とみなす
    • ワークスペース単位での制約緩和
    • バージョン違い等、依存関係の互換性問題が発生
  • #[fundamental]属性 :型やトレイトに特別な意味付け
    • 柔軟性向上だが、言語仕様が複雑化
    • エコシステム進化問題は未解決
  • Syntactical Equality :実装内容が同一なら重複許容
    • 動的リンク時の安全性問題
    • バージョン管理や定義の一致判定が技術的に困難

まとめ:Rustエコシステム進化問題の本質

  • coherenceorphan rules は型安全性・依存関係管理のために不可欠
  • しかし、 基盤クレートの置き換えや拡張が困難 という進化阻害要因
  • 既存提案も根本的な問題解決には至らず
  • Rust言語の 設計思想とエコシステム進化 のトレードオフが浮き彫り

Hackerたちの意見

孤立ルールのためのよく知られた(かつよく推奨される)回避策があるんだ。それはラッパー型を作ること。例えば、こんなライブラリがあるとするよ:

pub struct TypeWithSomeSerialization { /* 公開フィールド */ }

で、カスタムシリアライズを定義したいとする。そういう場合は、こう書ける:

pub struct TypeWithDifferentSerialization(TypeWithSomeSerialization)

そしたら、TypeWithDifferentSerializationに対してSerializeとDeserializeを実装するだけ。これで孤立ルールを回避する大半のケースに対応できる。意味的にも、型が違う振る舞いをするなら、同じ型ではないってことだから、合理的だよね。代わりに、ライブラリAがデータ型を定義し、ライブラリBがインターフェースを定義し、ライブラリCがAの型に対してBのインターフェースを実装するなんて状況になると、ほとんどの言語ではこれを許可していない。なぜなら、ライブラリDがCと同じことをしようとしても、やり方が違うと問題が起きるから。回避策はあるけど、複雑さと混乱を増すだけで、それが本当に価値があるかは疑問だね。

でも、TypeWithSomeSerializationを直接使ってるんじゃなくて、SomeOtherTypeWithSomeSerializationの中に含まれている場合はどうなるかっていうと、ややこしくなるんだよね。

これがRustの将来に対してすごく心配してる理由の一つなんだ。言語自体は大好きだし、時には最適じゃない時でも使うことがある。でも、「Removing Coherence」セクションの作り上げられた構文を読むと、頭が痛くなる。Scalaを書いてた時は、型や集合の理論にバックグラウンドがないことを受け入れてたし、理解できない部分もあるって思ってた。Rustみたいな言語だと、そういう感じになってきてる。特定のGAT構文に出くわすと、理解するのに時間がかかることもある。Rustは、すべての機能や構文を理解するために特別な資格が必要な言語じゃないはずだと思う。一方で、Goは学びやすく設計されてるけど、個人的にはあまり好きじゃない理由がいくつかある。でも、Rustが完全に理解できるシステムレベルの言語になってほしいと思ってた。そういえば、C++はラムダが追加される前から使ってないけど、今のC++もRustの難しい部分に匹敵するような厄介な概念や構文があるのかな。

C++が今、Rustの難しい部分に匹敵するような厄介な概念や構文を持ってるのかな。 https://tartanllama.xyz/posts/cpp-initialization-is-bonkers/

Zigはここでどう比較されるんだろう?

Rustは低レベル言語の革新の扉を開いたけど、すでに最も理論的に進んだ実用的な言語である限り、それをさらに推し進めたいと思っている人々を常に引き寄せるだろう。両方のオーディエンスを満足させる方法があるのかは分からない。

C++が今、Rustの難しい部分に匹敵するような厄介な概念や構文を持ってるのかな。 …… … … … C++11の前から、無資格名のルックアップはC++で難しい問題だった。オーバーロード解決のルールは非常に厄介で、標準のルールを理解しようとするだけで数週間かかったこともある。初期化の定義もいくつかあって、もし本当に深く入りたいなら、std::launderやstd::byte、厳格なエイリアスルールやライフタイムルールをいじってみると、Rustのシンプルさが恋しくなるよ。C++は、私が読んだ言語の中で最も複雑なもので、標準が諦めているカテゴリに入る前からそうなんだ。

リフレクション構文(C++26だと思うけど)が、30年以上のC++経験を持つ俺の脳みそを溶かしちゃった。狂ってるわけじゃないけど、ただ…溶ける感じ。

「一貫性を取り除く」セクションの作り上げられた構文を読むと、頭が痛くなる。新機能についての議論はいつも難しい構文が多いね。こういう提案は最初からあったし。幸い、言語チームは提案の構文や使いやすさの問題を認識してる。最初は扱いにくい構文だった提案も、何年もかけて改善されて、より使いやすくなったものがたくさんあるよ。

あんまり心配しなくてもいいと思う。過去10年間のRustでは、新しい構文を探る記事の中で、合併される頃にはほとんどが物議を醸すことはなかった(impl Tの引数の変更以外に大きなRustの構文変更は思いつかない)。唯一の例はasync/awaitで、当時はかなり心配してたけど、実際には全く心配する必要はなかった。

C++には今でも難解な概念や構文があるのかな? 良くも悪くも。今のイディオマティックなC++は、君が慣れ親しんでいるC++のバージョンよりもずっとクリーンで、簡潔で、パワフルだよ。もうCスタイルのマクロは必要ないし、狂ったテンプレートメタプログラミングのハックもなくなった。C++で表現するのが問題だった重要なこと(他のシステム言語も同様だけど)は、今では完全に定義されてる。例えば、std::launderとかね。C++は今、コンパイル時プログラミングの機能が充実していて、高性能なシステムコードには最高だし、重要な点でRustよりも表現力がある。ただ、悪いニュースは、これらすべてが有名なレガシーC++の混乱の上に積み重ねられていて、後方互換性のためにあるってこと。古いC++と新しいC++を混ぜると、最悪の結果になるよ。それが一番悪い状況だ。でも、例えばイディオマティックなC++20のコードベースで作業できるなら、レガシーC++よりもずっとシンプルで良い言語だよ。これまでにいくつかのC++のバージョンアップをコードベースに行ったけど、リファクタリングされたコードベースはいつも小さくて、クリーンで、安全で、メンテナンスも楽だった。

Rustをプロとして6年間使ってきたけど、その不安はよくわかる。下のコメントをしている人たちと同じように、一貫性はあまり大きな問題ではなかった。特に痛い問題空間があるのかな? Rust言語チームは、新しい言語機能でユーザーの問題を解決することの利点と、それによる複雑さの増加をどう天秤にかけてるんだろう? Rustを学んだとき、かなり複雑だと感じたけど、その複雑さのほとんどから実際に価値を得た。でも、どんどん複雑になっていくし、「言語に熟練するために知っておくべきこと」のセットが増えると、言語に関わっている人たちが新しいユーザーや既存のユーザーにとっての実際のコストを考慮しているかどうかは、いつも疑問に思う。

問題が実際に問題だとは思えないんだ。例えば、PairOfNumbersという型を誰かが作って、いくつかのフィールドを持っているとする。その著者はシリアライズを定義していない。別の型でそれを使って、こうシリアライズしたいとする:

{ "a": 1, "b": 2 }

私はこうシリアライズしたい:

[ 1, 2 ]

これで問題ないと思う。お互いに自分のシリアライズを得るべきだよね。でも、どちらかが「PairOfIntsの唯一の正しいシリアライズを決定した」と宣言したら、間違ってると思う。確かに、現在のRustやserdeは非グローバルなシリアライザーを宣言するのが面倒だけど、それが整合性が間違っているって意味じゃないと思う。

我々がやってることは問題ないよ。君は自分のシリアライズを持つべきだし、俺も自分のを持つべきだ。でも、どちらかが「PairOfIntsの唯一の真のシリアライズを決めた」と宣言したら、俺たちは間違ってると思う。まあ、いいけど、そしたら実際にモジュールシステムみたいなものを実装しないと。今はトレイトの実装がプログラム全体に適用されてるから、トレイトのグローバル実装ができないって言うのは、トレイトをまったく実装できないって言ってるのと同じだよ。

カプセル化とコンポジションの間には強い緊張がある気がするし、ここがそのボトルネックになってる。俺は結構Rustを書いてきたし、今はZigをいじってる。だから比較が新鮮に思い浮かぶんだけど、Rustではプライベートフィールドが持てる。一方、Zigではすべてのフィールドがパブリック。これが構造体の印刷方法にどう影響するかはよく示されてる。RustではDebugを派生させるけど、これは定義サイトでDebugトレイトを実装するマクロ。Zigでは、印刷関数がリフレクションを使って提供された構造体のフィールドを列挙し、それに基づいて印刷文字列を作成する。だからRustは定義サイトで表示ロジックを持っていて、Zigは呼び出しサイトでロジックを持ってる。ハッシュマップでも似たようなことがあって、RustではHashとPartialEqトレイトを派生/実装するけど、Zigでは呼び出しサイトでハッシュとeq関数を提供する。それぞれに明確な欠点がある。Zigはすべてがパブリックだから、あなたの不変条件が有効であることを保証できない。誰でも内部をいじれる。Rustはフィールドがプライベートになると(これは慣例)、他の誰も内部をいじれない。これが意味するのは、外部モジュールが内部状態にアクセスできないから、APIが悪いとかなり厳しいことになる。正直、これを解決する方法があるかどうかはわからない。追記:もう一つの考えとして、ZigとRustの違いはオブジェクトの破壊処理にも現れる。RustではDropトレイトを実装するから、各オブジェクトは一つの方法でしか破壊できない。Zigではdefer/errdeferを使うから、どのタイプのデストラクタが実行されるかを選べるけど、これも微妙に破壊処理を間違える可能性がある。

だからAPIが悪いと、かなり厳しいことになるよね。これって本当にそんなに大きな欠点なの?良いAPIを促進するんだよ。すべてが公開されるのが普通になると、大きなシステムやチームではすぐに大きな欠点になることが多いし、「自分で足を撃たないように」って言うのは現実的な戦略じゃない。何か目標を達成するための回避策があれば、人はそれを使うし、結局はメンテナンス不可能な混乱になる。だから、Cで始まる言語がCVEリストに prominently 載るのも納得だよ。

Rustを約14ヶ月使って、完全にRustで作った一つの利益を上げるSaaS製品をリリースした(actix-web、sqlx、askama)。これからはRustを使わないつもり。言語自体は好きだけど、複雑すぎて(頭の中に持っておくのが難しい)。LSPがないと無力感を感じるし、コンパイラとLSPがシステムにかかる負担が嫌だ。ファイルを保存するたびにCPUを使ってファンを回すのが本当に無駄に感じる。LSPとコンパイラを動かすのに30GB以上のメモリを使うのは正当化しにくい。これらはツールに関する不満で、言語自体のせいではないけど、密接に関係してる。vimの組み込みコンパイラ/makeprgを使ったctagsベースのワークフローを試したけど、あまり理想的ではなかった。crates.ioのエコシステムも好きじゃない。crates.ioが何かを公開するのにGitHubアカウントを必要とするのが嫌だ。もうGitHubとMicrosoftに集中してるのに、なんでさらに力を与えるの? crates.ioにはメールベースのサインアップをサポートするオープンな問題があるけど、もう10年も放置されてる。

その依存関係はすぐに複雑で重いことが分かるよね。Rustのせいにするつもりはないけど、ワークスペースやVCSベースの依存関係以上のものはあまり必要ないかな。でも、必要な時は非公式のレジストリを立てて使うのは結構簡単だよ。

ファイルを保存するたびにCPUを使ってファンを回すのは本当に無駄に感じる。LSPとコンパイラを動かすのに30GB以上のメモリを使うのは正当化しにくいな。RustRoverを使ってみたことある?俺は2-3GiBのRAMを超えたのを見たことないけど、そんなに複雑なソフトウェアを書いてるわけじゃないし。 > crates.ioが何かを公開するのにGitHubアカウントを必要とするのが嫌だ。確かに公開するのにGitHubアカウントは必要ないけど、crates.ioに認証するために必要なんだ。どんなGitホストでも使えるけど、アカウントはGitHubに結びついてる。

20年間LSPなしでC++を使ってきたけど、今は普通のエディタには戻りたくないな。

Rustにはこれが必要ないと思う。過去10年間、Rustは今の整合性ルールで素晴らしい成果を上げてきたし、これについて心配しなくていいのは嬉しいよ。整合性が構造的に排除する下流の問題(リンカーエラーみたいな)についても心配しなくて済むしね。

多くの開発者がTypeScriptを見て、静的型システムはどんな言語にも後付けできると思ってるんじゃないかな。こういう開発者たちは、静的型付けが無料で手に入るものだと思って、動的型付けの言語を使いたい理由が分からないって言うけど、実際には堅牢な型システムが言語の設計を大きく左右するんだよね。各選択肢にはそれぞれトレードオフや制限があって、デザインの難しい問題を引き起こす。私たちは、言語が正しいプログラムを書くのを簡単にしてほしいし、逆に間違ったプログラムを書くのを難しくしてほしい。両方を同時に実現するのはすごく難しいんだ。

ユーティリティ機能(シリアライゼーションやロギングなど)で一つのエコシステムのクレートに縛られる問題の正しい解決策は、リフレクションやコンパイル時評価だと思う。問題は孤立ルールじゃなくて、Rustは動的型付け言語よりもリフレクションがずっと必要なんだよね。これがずっと前に追加されるべきだった。今は開発中だけど、安定版が出るまでには数年かかるだろうね。

Rustのエコシステムは「逆依存性注入」と呼ぶことが多いことをやってる。RustのライブラリがTLSのサポートが必要な場合、通常そのライブラリは既存の各TLSバックエンドのための機能を実装して、各バックエンドとのファーストクラスの統合を維持するんだ。明らかなのは、TLSトレイトを作って、各TLSライブラリがそのトレイトを実装することなんだけど(つまり、依存性注入)。孤立ルールのせいで、そのトレイトは小さな自己完結型のライブラリで宣言しなきゃいけないし、各TLSライブラリがそのトレイトを実装することになる。明らかな障害は見当たらないけど(すべてのTLS実装が同じAPIと動作を公開しなきゃいけないってこと以外は)、なぜかRustのエコシステムは「すべてのライブラリがすべてのプロバイダーとファーストクラスの統合を持つ」って道を選んでる。これが、他のライブラリがTLSライブラリに依存するライブラリを作るのをすごく難しくしてる。消費者は、例えばどのTLS実装を使うかを簡単に選べないからね。ライブラリは、依存関係に機能フラグをただ伝播させるだけのものになっちゃう。

それは抵抗が少ない道を選んでいるからだね。解決策の一つは、共通のトレイトを標準ライブラリでバージョン管理することかもしれない。

記事自体は、その問題に至った具体的な理由や、エコシステム内でのさまざまなトレードオフを持つ潜在的な解決策について触れているよ。

Re: 特化とコンパイル時/リフレクションのイニシアティブ 現在のクレートでトレイトが実装されているかどうかを観察できるから、下流のクレートでimplが宣言できると、たぶん不健全になっちゃうね。部分的な解決策ではあるけど、他の解決策を健全に実装するのが難しくなることもある(その逆も然り)。