グローバル変数なしで行く

デイブ・チェイニーによる記事の翻訳-ピーター・バーゴンの以前の投稿「The Modern Theory of Modern Go」への回答-ゴーが一般にグローバルな範囲で変数なしで見ているかのように、思考実験を試みた。 一部の段落では言語を破ることができますが、投稿は非常に興味深いものです。







パッケージのグローバルスコープ内の変数を削除した場合にGoがどのようになるかを考えて、実験してみましょう。 結果はどうなりますか?この実験からGoプログラムの設計について何を学ぶことができますか?







var



削除についてのみ説明します。他の5つのトップレベルの定義は、実際にはコンパイル段階で定数なので、実験では許可されたままです。 そしてもちろん、関数やブロックで変数を定義し続けることができます。







パッケージのグローバル変数が悪いのはなぜですか?



しかし、まず、パッケージのグローバル変数がなぜ悪いのかという質問に答えましょう。 組み込みの並行性を備えた言語のグローバルな可視状態の明らかな問題は別として、パッケージ内のグローバル変数は、本質的に、弱くあまり接続されていないものの間で状態を暗黙的に変更するために使用されるシングルトーンであり、強力な依存関係を作成し、コードのテストを困難にします。







Peter Burgonが最近書いたように







tl;博士の魔法は悪いです。 グローバル状態は魔法です→パッケージ内のグローバル変数は不良です。 init()関数は必要ありません。


実際にグローバル変数を取り除く



このアイデアをテストするために、Goで最も人気のあるコードベースである標準ライブラリを詳細に調べ、パッケージでグローバル変数を使用する方法を確認し、実験の効果を評価しようとしました。







間違い



パブリックパッケージでのグローバルio.EOF



の最も一般的な使用法の1つはエラーですio.EOF



sql.ErrNoRows



crypto/x509.ErrUnsupportedAlgorithm



など。 これらの変数がないと、エラーを事前定義された値と比較できません。 しかし、それらを何かに置き換えることはできますか?







エラーを分析するときは、タイプではなく動作を調べるようにしてください以前に書きました。 これが不可能な場合、エラー定数の定義により、エラーの修正の可能性排除され、セマンティクスが保持されます。







エラーの残りの変数はプライベートであり、単にエラーメッセージにシンボル名を付けます。 これらの変数はエクスポートできないため、パッケージ外部からの比較には使用できません。 それらが発生する場所ではなく、パッケージの最上位で定義すると、エラーにコンテキストを追加できなくなります。 代わりに、 pkg / errorsのようなものを使用して、発生時のスタックトレースをエラーに保存することをお勧めします。







登録



登録パターンは、標準ライブラリのいくつかのパッケージ( net/http



database/sql



flag



log



れ、 log



でも少し使用されlog



。 通常、マップタイプまたは構造のグローバル変数で構成されます。これは、いくつかのパブリック関数(クラシックシングルトン)によって変更されます。







外部から初期化する必要のあるこのようなダミー変数を作成できないため、 image



database/sql



、およびcrypto



パッケージは、デコーダー、データベースドライバー、および暗号化スキームを登録できません。 しかし、これはPeterが彼の記事で説明しているのとまったく同じ魔法です。パッケージをインポートして、別のパッケージのグローバル状態を暗黙的に変更し、外部からは不吉に見えます。







登録は、ビジネスロジックの繰り返しも促進します。 たとえば、 net/http/pprof



それ自体を登録し、副作用としてnet/http.DefaultServeMux



使用しますが、これは完全に安全ではありません-別のコードでは、pprofが提供する情報を使用せずにデフォルトのマルチプレクサを使用できなくなります-別のマルチプレクサに登録するのはそれほど簡単ではありません。







パケットにグローバル変数がない場合、 net/http/pprof



などのパケットは、特定のhttp.ServeMux



URLパスを登録し、別のパケットのグローバル状態の暗黙的な変更に依存しない関数提供できます







登録パターンを使用する可能性をなくすことは、同じパッケージの複数のコピーの問題を解決するのに役立つ可能性があり、インポートされると、すべてが最初に一緒に登録しようとします。







インターフェース満足度チェック



インターフェイスが型に属するかどうかをチェックするためのこのようなイディオムがあります:







 var _ SomeInterface = new(SomeType)
      
      





標準ライブラリで少なくとも19回検出されます。 私の意見では、そのようなチェックは実際にはテストです。 パッケージをビルドするときに後で削除するために、これらをコンパイルしないでください。 対応する_test.go



ファイルに_test.go



する必要があります。 しかし、パッケージ内のグローバル変数を禁止する場合、これはテストにも適用されるので、このチェックをどのように維持できますか?







1つの解決策は、この変数定義をグローバルスコープから関数のスコープに移すことです。SomeTypeが突然SomeInterface



を満たすのをやめた場合、コンパイルは停止しSomeInterface









 func TestSomeTypeImplementsSomeInterface(t *testing.T) { // won't compile if SomeType does not implement SomeInterface var _ SomeInterface = new(SomeType) }
      
      





しかし、これは実際には単なるテストであるため、このイディオムを通常のテストの形式に書き換えることができます。







 func TestSomeTypeImplementsSomeInterface(t *testing.T) { var i interface{} = new(SomeType) if _, ok := i.(SomeInterface); !ok { t.Fatalf("expected %t to implement SomeInterface", i) } }
      
      





Go仕様では、空の識別子(_)への割り当ては割り当て記号の右側の式を完全に評価することを意味するため、ここではグローバルスコープ内の疑わしい初期化がいくつか隠されている可能性があります。







しかし、それほど単純ではありません



前のセクションでは、すべてがスムーズに進み、グローバル変数を削除する実験は成功したようですが、標準ライブラリにはすべてがそれほど単純ではない場所がいくつかあります。







リアルシングルトン



全体としてシングルトンパターンは、特に登録など、必要のない場所で使用されることが多いと考えていますが、各プログラムには常に実際のシングルトンが存在します。 これの良い例は、 os.Stdout



と会社です。







 package os var ( Stdin = NewFile(uintptr(syscall.Stdin), "/dev/stdin") Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout") Stderr = NewFile(uintptr(syscall.Stderr), "/dev/stderr") )
      
      





この定義にはいくつかの問題があります。 まず、 Stdin



Stdout



およびStderr



は、 io.Reader



またはio.Writer



インターフェースではなく、 *os.File



型の変数です。 これにより、それらを代替品と置き換えるのは非常に問題が多いです。 しかし、それらを置き換えるというまさにアイデアさえ、まさに私たちの実験が取り除こうとしている魔法です。







一定のエラーを伴う前の例が示したように、標準のIO記述子用にシングルトンエンティティを残すことができるため、 log



fmt



などのパッケージはそれらを直接使用できますが、可変グローバル変数として宣言できません。 このようなもの:







 package main import ( "fmt" "syscall" ) type readfd int func (r readfd) Read(buf []byte) (int, error) { return syscall.Read(int(r), buf) } type writefd int func (w writefd) Write(buf []byte) (int, error) { return syscall.Write(int(w), buf) } const ( Stdin = readfd(0) Stdout = writefd(1) Stderr = writefd(2) ) func main() { fmt.Fprintf(Stdout, "Hello world") }
      
      





キャッシュ



パッケージでエクスポートされていないグローバル変数を使用する2番目に最も一般的な方法は、キャッシュです。 それらは、マップタイプ(上記の登録パターンを参照)またはsync.Pool



のオブジェクトで構成される実際のキャッシュと、コンパイルコストを改善する準sync.Pool



変数(翻訳者のメモ-「sht?」)の2つのタイプです。







例としては、 crypto/ecsda



がありcrypto/ecsda



。このcrypto/ecsda



には、入力として渡されるすべてのバッファーをRead()メソッドがリセットするタイプzr



があります。 パッケージには、io.Readerなどの他の構造に組み込まれ、宣言されるたびにヒープにエスケープされる可能性があるため、zr型の単一の変数が含まれています。







 package ecdsa type zr struct { io.Reader } // Read replaces the contents of dst with zeros. func (z *zr) Read(dst []byte) (n int, err error) { for i := range dst { dst[i] = 0 } return len(dst), nil } var zeroReader = &zr{}
      
      





しかし、同時に、 zr



型には組み込みのio.Reader



が含まれていませんzr



io.Reader



実装しているため、未使用のzr.Reader



フィールドを削除して、 zr



を空の構造にすることができます。 私のテストでは、この変更された型は、パフォーマンスを失うことなく明示的に初期化できます。\







 csprng := cipher.StreamReader{ R: zr{}, S: cipher.NewCTR(block, []byte(aesIV)), }
      
      





インライン化とエスケープの分析は、標準ライブラリが作成されてから非常に顕著に改善されているため、おそらく、いくつかのキャッシュソリューションを確認するのが理にかなっています。







テーブル



そして、パッケージ内でプライベートグローバル変数を最後に最も頻繁に使用するのはテーブルです-たとえば、 unicode



crypto/*



およびmath



パッケージ。 これらのテーブルは通常、定数データを整数配列としてエンコードしますが、まれに単純な構造またはマップ型のオブジェクトとしてエンコードします。







グローバル変数を定数で置き換えるには、言語の変更が必要になります 。これは、ここ説明したものと似ています 。 したがって、プログラムの実行中にこれらのテーブルを変更する方法がないと考える場合、これらはこの提案の例外になる可能性があります。







行き過ぎ



この投稿は単なる思考実験であるという事実にもかかわらず、パッケージ内のすべてのグローバル変数の禁止が言語で現実的であるにはあまりにも厳しい基準であることはすでに明らかです。 禁止の問題を回避することは、パフォーマンスの観点から非常に非現実的である可能性があり、「ヒットミー」ポスターを背中に掛けて、Goの嫌いな人全員に楽しんでもらうようなものです。







しかし、同時に、極端に進んで言語仕様を変更することなく、この思考実験から引き出すことができるいくつかの非常に具体的なヒントがあるように思えます。









グローバル変数のプライベート定義はより具体的ですが、いくつかのパターンを抽出できます。









プログラムの実行中に値を変更する可能性のあるグローバル変数をパッケージに追加する前に、要約し、よく考えてください。 これは、魔法のグローバルステートが追加されたことを示している可能性があります。








All Articles