1 ポイント 投稿者 GN⁺ 5 시간 전 | 1件のコメント | WhatsAppで共有
  • Goのnilチェックはパニックを防げるが、繰り返される場所が誤っていると、コードが「何がnilになり得るのか」を自ら説明できなくなる
  • Redisクライアントのような必須の依存関係を内部メソッドでチェックすると、生成失敗を通常の実行経路のように扱うことになる
  • コンストラクタでnilを排除するだけでは不十分で、NewRedisClient(addr)のような初期化地点で失敗を即座に処理すべき
  • リクエストオブジェクトのように外部から入ってくる値は、HTTPハンドラ、RPCディスパッチ、キューコンシューマのような境界レイヤーで検証し、内部ロジックはその保証を信頼すべき
  • あり得ないはずの状態を静かに許容すると、失敗は静かに・遅れて・曖昧になり、後からメトリクス・ダッシュボード・アラートで失われたシグナルを復元するコストが生じる

nilチェックは常に防御的プログラミングではない

  • 本番環境でパニックを防ぐには、deferred recoverより前に入力、範囲、ポインタを確認する防御的プログラミングが必要
  • 正しい場所でのnilチェックは安全なコードを作るが、誤った場所でのチェックは、どの値がnilになり得るのかを追跡できていないというシグナルになる
  • 生成コードでこのパターンはより頻繁に見られるが、新しい現象でもなく、AIに限った話でもない
  • nilチェックは安価で安全そうに見えるが、次の読み手に「この値はnilになり得る」というメッセージを残し、しばしば誤った意味を伝える

依存関係のnilチェックの問題

  • RateLimiter*redis.Clientをフィールドとして持ち、Allow内でr.redis != nilを確認するコードは、一見すると安全そうに見える
  • Redisクライアントがnilなら、問題はAllow実行時点ではなく、生成時点ですでに発生している
  • 内部メソッドでnilを確認すると、生成失敗状態のまま動き続けることが許容可能な状態のように扱われる
  • こうしたチェックは、オブジェクトの出所、初期化責任、nilであってはならない不変条件をコードが見失っているシグナルになる

コンストラクタでのnilチェックだけでは不十分

  • NewRateLimiter(client *redis.Client)client == nilならエラーを返す方法はより良いが、完全な解決策ではない
  • nilポインタが関数まで渡ってきたという事実自体が、すでに誤った状態がシステムに入ったことを意味する
  • 実際のエラーは、Redisクライアントを作る初期化地点で処理すべき
    • redisClient, err := NewRedisClient(addr)でエラーが発生したら即座に返すべき
    • その後のNewRateLimiter(redisClient)には有効なクライアントだけが渡されるべき
  • こうすれば、RateLimiterのコンストラクタがエラーを返す必要もなくなる
  • ストアが一時的に利用できない状態を許容する必要があるなら、nilを伝播させるのではなく、常にnon-nilな外部型で包み、リトライや性能低下時の処理を内部にカプセル化すべき
  • これはデータベースのNOT NULLや外部キー制約に似ている
    • 不正な行が最初から存在できなければ、すべてのクエリがデータを再確認する必要はない
    • 実行時の値も一度不変条件を確立すれば、残りのコードは繰り返しのチェックを避けられる

静かな失敗のコスト

  • 小さな変更のためにプログラムを停止させたくないので、nilチェックやログだけを残す方法は安定しているように感じられるかもしれない
  • 実際の選択肢は「クラッシュか継続実行か」より、大きく失敗するか静かに失敗するかに近い
  • 明示的に返されたエラーには3つの性質がある
    • 明確さ: 失敗が発生したことを把握できる
    • 即時性: 原因に近い場所で失敗を知ることができる
    • 帰属性: 呼び出し側が失敗をその処理に結び付けられる
  • 握りつぶされたエラーは逆に働く
    • 失敗が静かに消える
    • より多くのコードが実行された後で、後から症状として現れる
    • 症状が見えたときには原因を特定しづらい
  • プログラムが誤った状態のまま生き残る呼び出しが増えるほど、原因と症状の間の距離も大きくなる
  • 正しい修正は、失敗を局所的に隠すことではなく、エラーがどこへ伝播し、どこでリクエスト拒否、ジョブ失敗、リトライ、通知、終了へと変換されるのかを把握すること
  • エラー返却がシステムの必要以上を停止させているなら、問題はその関数ではなくエラー処理の境界にある

失われたシグナルを作り直す二次コスト

  • 失敗が静かになると、実際に何が起きたのか分からず、バグが潜みやすくなる
  • すると、動作していないことを検知するために、メトリクス、ダッシュボード、アラートのような観測インフラを作る必要が出てくる
  • あり得ない状態や未処理の状態を許容するたびに、捨てたシグナルを後から観測で復元するエンジニアリングコストを支払うことになる

外部レイヤーと内部レイヤーの役割

  • 実行が始まり外部データが入ってくる場所は外部レイヤーであり、その呼び出しが到達するより深いコードは内部レイヤー
  • 実行初期には何も保証されていないが、まだ何の作業も行っていない
  • 初期化過程では、プログラムが依存する要素を設定し、各要素が必須か一時的に失われ得るのかを決める必要がある
  • 設計は常に利用可能な依存関係へ傾けるべきで、途中で消え得る依存関係は最小限にすべき

リクエストスコープのデータは境界で検証すべき

  • リクエストオブジェクト、リクエストフィールド、リクエストから導出された値は、固定的な依存関係とは異なる
  • リクエストはHTTPハンドラ、RPC、キュー、テストヘルパー、別パッケージなど外部から呼び出しごとに入ってくる
  • RateLimiter.Allow(ctx, req)内部でreq == nilを確認するのも、依存関係のnilチェックと同じ誤り
  • リクエストはAllowで最初に入ってきたのではなく、より手前の転送境界から入ってコード内部を移動してきた値
  • Allowのような内部関数で再検証すると、外部レイヤーが保証すべきことを深い関数が再チェックすることになり、不確実性が広がる

境界で検証した後は内部ロジックが不変条件を信頼する

  • nilチェックは、信頼できないバイト列が*Requestのような内部型へ変わる境界地点に置くべき
  • HTTPハンドラの例では、DecodeRequest(r)が失敗したらhttp.StatusBadRequestで応答して返る
  • 検証が終わった後のreqは有効な値であり、その後のh.limiter.Allow(r.Context(), req)はその値を信頼できる
  • 外部から受け取るデータは制御できないため、境界でnilや必要な制約をチェックするのは妥当
  • 境界を通過したデータは内部型とビジネスロジックにマッピングされ、その後はシステムの不変条件になる
  • 最終的なAllowはnilチェックなしで実際のロジックに集中する
    • userID := GetUserID(req)
    • userID == ""ならfalse, nilを返す
    • そうでなければr.checkLimit(ctx, userID)を呼ぶ
  • 空のuserIDチェックもHTTPレイヤーへ移せるが、この例ではレートリミッターがそのポリシーを所有する形にしている

繰り返しのnilチェックは新しい分岐と新しい振る舞いを生む

  • このような構造のシステムは推論しやすく、変更もしやすい
  • 逆に不変条件のないシステムでは、あちこちにチェックを追加したうえで、各チェックごとに何をすべきかを決める必要がある
  • 各nilチェックは新たな分岐であり、各分岐は本来存在すべきでない状態に対する振る舞いを新たに定義してしまう
  • nilチェックは、文書化された境界を強制したり、意図的な任意状態をモデル化したりするときには有用
  • プログラムが不可能とみなす状態を静かに処理するnilチェックは疑うべき
  • nilチェックが至る所に現れるなら、次のどちらか
    • 信頼できない境界入力を保護する正常なコード
    • コードベースが不変条件を確立できていない設計上の問題
  • どの引数も信頼できないシステムでは、とりあえずチェックを追加する必要があるかもしれないが、本当の作業は、そのチェックが代わりに担っている不変条件を確立し、信頼できる保証へ置き換えること

1件のコメント

 
GN⁺ 5 시간 전
Lobste.rs の意見
  • 他の Go プログラマーに改めてお願いしたいのだが、エラーをラップしてほしい

    redisClient, err := NewRedisClient(addr)  
    if err != nil {  
      return nil, fmt.Errorf("Couldn't obtain new RedisClient: %w", err)  
    }  
    

    呼び出しスタックがほどけるにつれて、エラーに関する文脈が蓄積されるべき

    • より慣用的な例はこう見える
      redisClient, err := NewRedisClient(addr)  
      if err != nil {  
        return nil, fmt.Errorf("NewRedisClient: %w", err)  
      }  
      
      その後は各レイヤーがエラーがどこで起きたかだけを付け足し、最も内側の err何が起きたかを伝える構造が望ましい
    • 残念ながら、エラーに対する統一された、事実上標準のスタックトレースがない
      実際のところ「ラッピング」は、エラー文字列を grep し、その文字列が一意であることを願い、文字列を一意にするために無理やり創意工夫する作業になりがちだ
    • エラーのスタックが長すぎると不満を言う人もいるが、多くの場合こうしたメッセージは対処可能で有用だと見ている
      以前、ネットワーキング製品であるエンジニアが数百個のエラーメッセージを直すのに1か月を費やしたことがあった。ログに「What the f-ck?」と出るのはエンドユーザーの役に立たなかったからだ
      それらのメッセージを有用なものに変え、上のような理由でエラースタックも追加する必要があった
    • 最近のやり方は、記憶では errors.Join を使う方向だったと思う
  • Go はここで2つの問題を作っていると思う

    1. Go に明示的な**null 可能性(nullability)**があったなら、この問題自体はほとんど消えていただろう
    2. 名前を付けられる型のゼロ初期化を防ぐ方法がなさそうなので、ミスはいつでも紛れ込む可能性がある
    • 記事のこの文が根本問題をよく表していると感じる
      「何を渡されるか制御できないので、その境界で nil かどうかを確認するのは合理的だ」という部分だ
      外部入力についてはその通りだが、すべてのポインターが nil になり得るなら、コードベース内で安全な境界を追跡するには推論が必要になる
      Go の問題は、この推論をコンパイラーではなく、すべてのプログラマーの頭の中で行うよう強制している点にある
  • Rust には Option<T> があり、C# にはnull 許容型がある
    2026年にまだこうした問題を抱えている必要はないと思う

    • 反対側の立場から見ると、「なし」や「欠落」を簡潔に表現できる能力は、特に JSON のような任意のデータ構造を扱うときに非常に有用だ
      言語において構文はたいていそれほど面白い要素ではないが、好きなスクリプト言語で foo.bar.baz と書くほうが、Rust の foo.unwrap().bar.unwrap().baz よりずっと簡単だ
      Rust が好きな立場でもそう思うし、Go と Rust はしばしば同じ括りで扱われるが、Go は C プログラマーが作り直したスクリプト言語にずっと近いと思う
      それでも言語が null を使うなら、デフォルトは非 nullのほうがよい。特に ?.? のような短い構文があるなら、大きなプロジェクトでも構文上の負担を受け入れる価値がある
    • ポインターを使わなければ null もない、万歳… 😭
  • Go は非 null オブジェクトをうまくモデル化する言語ではない、と理解している
    この点では C に似ていて、Option<T>T* で表現できるが、T* が必ずしも Option<T> を意味するわけではない
    全体として記事には同意する。組み込みファームウェア企業で働いていたときも、C++ コードであちこちに null チェックを書くのではなく、assert を使おうと説得していた
    assert はデバッグしやすく、カバレッジの観点では分岐として扱われず、読む人に期待条件を明確に伝える。リリースビルドでは除外されるので、より効率的でもある
    ただし Go では nil の逆参照がすでに良いデバッグ情報を出すため、assert の利点は C++ ほど大きくないと理解している

    • Go の nil 逆参照は、C の null ポインター逆参照よりはよく、決定的に panic を起こすが、実際にポインターが逆参照されるまでエラーにならないので、そこまで素晴らしいわけではない
      記事の例なら checkLimit の奥深くで落ちることになり、そこで nil の出所を逆にたどらなければならない。システムやアーキテクチャによってはかなり複雑になり得る
      だから NewRateLimiter のすぐ中で assert することには明らかにメリットがある。例のコードでは
      if client == nil {  
          return nil, errors.New("redis client is nil")  
      }  
      
      if client == nil {  
          panic("redis client is nil")  
      }  
      
      に変えるようなものだ
      ただし Go チームは assertion に強く反対しており、panic も理想的ではなく、捕捉されなければランタイム全体をクラッシュさせる
    • null チェックと assert はまったく別物だと思う
      assert は「この状態は有効ではない」という意味であり、assert マクロはリリースビルドでその null チェックを何もしないものにできる
      assert マクロの定義方法によっては、未定義動作に関する最適化が起こり、その後のチェックが削除され、分かりにくいクラッシュにつながる可能性がある
      たとえば assert(p); if (!p) { ... } で後ろのチェックが削除されるような assert 定義を見たことがある
      やみくもに「null チェックせず assert を使え」というのは、状態不変条件には当てはまるかもしれないが、エラー確認には当てはまらない
  • 結論部分に良い助言がある
    nil チェックがあちこちに現れるなら、どちらかである。信頼できない境界入力を防御する正常なコードか、コードベースが不変条件を確立できていない設計上の問題かだ
    どのパラメータも信頼できないシステムでの解決策は、チェックをさらに追加することではない。当面はそうせざるを得ないかもしれないが、本当にやるべきことは、そのチェック群が代わりに担っている不変条件を確立し、恐れから生まれたノイズを、システムが依存できる保証へと徐々に置き換えていくことだ
    これは nil チェックを超えた話だと思う。システムの「葉」の部分にチェックや防御コードを追加するのは、不変条件が不足している、または適切に強制されていない症状に対処しようとする方法としてよく現れる
    「チェックをもう1つ追加する」はデフォルトにしやすいが、スケールには限界がある。ある時点でチェックロジックが機能ロジックより多くなり、全体の複雑さが手に負えないほど大きくなる
    バグを1つ2つ防ぐための追加チェックはたいてい有害ではないが、チェックの数と複雑さが増えすぎていると感じたときは、葉だけを直し続けるより一歩引いて根本原因を探すほうが、長期的にはシステムにも保守担当者の人生にもよかった

    • 不変条件を assert するのは、最初からそう始めて継続的に維持するなら素晴らしい
      ただし、開発者に防御的プログラミングをやめるよう訓練するほうが、より難しい問題だ
  • こうした不変条件、ここでは非 null 性のようなものは、Go より表現力の高い型システムでははるかにうまくモデル化できる
    このテーマで一番好きな記事は Alexis King の2019年の記事 Parse, don't validate
    原則はどこにでも適用できるが、Haskell の型システムでは本当に簡単そうに見える。TypeScript で Alexis の助言に数年間従おうとしたが、簡単ではなかった

  • 要約すると、問題はチェックが多すぎることではなく、nil を値で包んでいること

  • この問題は繰り返し出てきたが、エラー処理が第一級機能ではない言語の結果だと思う
    記憶では別のスレッドでも出ていたように、実質的に標準的なリンターがこうした構造を強制するようになる
    これらの nil チェックが論理的に悪いのかは分からない。多くの言語がエラー処理を組み込んでおり、違いは伝播の一貫性と単純さの程度くらいだ
    エラーを出すインターフェースに対応する選択肢はおおよそ4つある。処理して回復する、無視する、エラーを伝播する、エラーを捨てて自分のエラーを伝播する、であり、最後のものは既存のエラーをラップすることもある
    エラー処理が第一級機能である言語は、通常2番と3番を簡単にし、現代的な言語ほどそうだ。そのため4番も言語によってはかなりきれいにできる
    1番は、その処理が必要であることをより明示的にする以外には、第一級のサポートでも大きく助けることはできない
    根本的に、関数がエラーを出し得るなら、実装の有無に関係なく、どの言語も {error,result} = functioncall() の後に if (error) { ... } をしているようなものだ
    Go はエラー処理が第一級ではないため、多くの関数が先回りして (result, err) タプルを返し、リンターが err != nil チェックを事実上強制することで、コードがそのパターンで埋め尽くされる印象を与える
    正しいエラー処理を言語が直接扱わないのは言語設計上の欠陥だと思うが、いったんその位置にいるなら、このモデルがおそらく最善に近いように見える
    Go のコードが慣用的にオプショナルな戻り値型を使って、機能的に無視可能なエラーと「気にすべき」エラーを区別しているのかはよく分からない。そういう場合でも常にエラー型を返すのが慣用なら、リンターは常にこのパターンを強制しそうだ
    Go が嫌いなわけではなく、設計上の選択の1つに同意していないだけだ。ほぼすべての言語の設計選択には不満を言える
    Go の最大の過ちは、実質的にあらゆる場所で明示的な err != nil チェックが機能上必須であり、そのためリンターもそれを要求するようになった点だと思う

  • Go が初めて登場したときも、何百人もの人がこの構造全体がどれほど馬鹿げているかを指摘していた
    しかし言語は大人気になり、Rob Pike のほうがよく分かっているという雰囲気の中で批判は黙殺された
    今になって人々が論理的な根拠に基づいて普通に議論しているのを見るのは良いことだ
    これが何十年も前から悪いアイデアとして知られていなかったわけでもないのに、Google がやるなら良いものなのだろう……だよね?

    • Go ファンではないが、こういうフレーミングは気になる
      「馬鹿げたたわごと」と呼ぶと、もっと見たいと言っていた論理的思考そのものを抑え込んでしまいがちだからだ
      どの Oxide のポッドキャストだったかは忘れたが、Bryan Cantrill が「これをもっと上手に嫌うために研究したい」というようなことを言っていたことがある
      そういう意味で、2010年代に人々がなぜ Go に熱狂したのかを理解したい。一部は確かに過剰な宣伝で、当時の職場で、開発者たちが何が良いのか説明できないまま熱狂している様子を実際に見た
      しかし純粋な誇大広告だけではなかったはずだ。当時 Go を使おうという最も強い steel-man argument は何だったのか知りたい