Goブログ

実験、簡素化、リリース

Russ Cox
2019年8月1日

はじめに

これは、先週のGopherCon 2019での講演のブログ記事版です。

私たちは皆、Go 2への道筋を共に歩んでいますが、その道筋がどこに向かうのか、あるいは時にはどの方向に進むのか、誰も正確には知りません。この記事では、Go 2への道筋を実際にどのように見つけて辿っていくのかについて説明します。プロセスは以下のようになります。

私たちは、Goを現状のまま実験して、より深く理解し、何がうまく機能し、何がうまく機能しないかを学びます。次に、考えられる変更点を試行錯誤して、それらをより深く理解し、再び何がうまく機能し、何がうまく機能しないかを学びます。これらの実験から得られた知見に基づいて、簡素化します。そして、再び実験します。そして、再び簡素化します。そして、以下同様に繰り返します。

簡素化の4つのR

このプロセスにおいて、Goプログラムの作成全般のエクスペリエンスを簡素化できる4つの主要な方法があります。それは、再形成、再定義、削除、および制限です。

再形成による簡素化

簡素化の最初の方法は、既存のものを新しい形に再形成することです。これにより、全体としてよりシンプルになります。

私たちが書くGoプログラムはすべて、Go自体をテストするための実験として機能します。Go初期の頃、私たちはすぐに、このような`addToList`関数を書くのが一般的であることを学びました。

func addToList(list []int, x int) []int {
    n := len(list)
    if n+1 > cap(list) {
        big := make([]int, n, (n+5)*2)
        copy(big, list)
        list = big
    }
    list = list[:n+1]
    list[n] = x
    return list
}

バイトのスライスや文字列のスライスなど、同じコードを記述していました。Goが単純すぎたため、私たちのプログラムは複雑すぎました。

そこで、プログラム内の`addToList`のような多くの関数を、Go自体が提供する1つの関数に再形成しました。`append`を追加することで、Go言語はやや複雑になりましたが、`append`に関する学習コストを考慮しても、Goプログラムを作成する全体的なエクスペリエンスはシンプルになりました。

もう1つの例を挙げましょう。Go 1では、Go配布物内の非常に多くの開発ツールを検討し、それらを1つの新しいコマンドに再形成しました。

5a      8g
5g      8l
5l      cgo
6a      gobuild
6cov    gofix         →     go
6g      goinstall
6l      gomake
6nm     gopack
8a      govet

`go`コマンドは現在非常に中心的なものなので、それがない時代がどれほど長く、それがどれだけの追加作業を伴っていたかを忘れがちです。

Go配布物にコードと複雑さを追加しましたが、全体としてはGoプログラムを作成するエクスペリエンスを簡素化しました。新しい構造により、後で説明する他の興味深い実験のためのスペースも作成されました。

再定義による簡素化

簡素化の2番目の方法は、既に持っている機能を再定義して、より多くのことができるようにすることです。再形成による簡素化と同様に、再定義による簡素化はプログラムの作成をシンプルにしますが、今回は新しいものを学ぶ必要はありません。

例えば、`append`はもともとスライスからの読み取り専用に定義されていました。バイトのスライスに追加する場合、別のバイトのスライスからのバイトを追加できますが、文字列からのバイトを追加することはできませんでした。言語に新しいものを追加することなく、文字列からの追加を許可するように`append`を再定義しました。

var b []byte
var more []byte
b = append(b, more...) // ok

var b []byte
var more string
b = append(b, more...) // ok later

削除による簡素化

簡素化の3番目の方法は、機能が予想以上に役に立たなくなったり、重要でなくなったりした場合に、その機能を削除することです。機能を削除すると、学習するものが1つ減り、バグを修正するものが1つ減り、気を取られたり、誤って使用されたりするものが1つ減ります。もちろん、削除するとユーザーは既存のプログラムを更新して、削除した分を補う必要があり、プログラムがより複雑になる可能性があります。しかし、全体の結果として、Goプログラムの作成プロセスは依然としてシンプルになる可能性があります。

その例として、ノンブロッキングチャネル操作のブール型を言語から削除したことが挙げられます。

ok := c <- x  // before Go 1, was non-blocking send
x, ok := <-c  // before Go 1, was non-blocking receive

これらの操作は`select`を使用して行うことも可能だったため、どちらの方法を使用するかを決定する必要があり、混乱を招いていました。それらを削除することで、言語の能力を低下させることなく簡素化されました。

制限による簡素化

許可されているものを制限することによっても簡素化できます。Goソースファイルのエンコーディングは、初日からUTF-8に制限されています。この制限により、Goソースファイルを読み込もうとするすべてのプログラムがシンプルになります。これらのプログラムは、Latin-1、UTF-16、UTF-7、またはその他のエンコーディングでエンコードされたGoソースファイルを心配する必要はありません。

もう1つの重要な制限は、プログラムのフォーマットに関する`gofmt`です。`gofmt`を使用してフォーマットされていないGoコードを拒否するものはありませんが、Goプログラムを書き換えるツールは、`gofmt`形式で残すという慣例を確立しました。プログラムも`gofmt`形式で維持していれば、これらの書き換えツールはフォーマットを変更しません。変更前と変更後を比較すると、表示される差分は実際の変更のみです。この制限により、プログラムの書き換えツールが簡素化され、`goimports`、`gorename`など、多くの成功した実験につながりました。

Go開発プロセス

実験と簡素化のこのサイクルは、過去10年間に行ってきたことをうまくモデル化したものです。しかし、問題があります。それは単純すぎます。実験して簡素化するだけではありません。

結果をリリースする必要があります。使用できるようにする必要があります。もちろん、使用することで、より多くの実験が可能になり、より多くの簡素化が可能になり、プロセスは繰り返し続きます。

2009年11月10日に初めてGoを皆さんにリリースしました。その後、皆さんの助けを借りて、2012年3月にGo 1を共同でリリースしました。そして、それ以来、12個のGoリリースを行いました。これらはすべて、より多くの実験を可能にし、Goについてより多くを学ぶのに役立ち、もちろん本番環境で使用できるようにするための重要なマイルストーンでした。

Go 1をリリースしたとき、私たちはGoの使用に焦点を移し、言語の変更を含むさらなる簡素化を試みる前に、このバージョンの言語をはるかに深く理解する必要がありました。実験して、何がうまく機能し、何がうまく機能しないかを本当に理解する時間が必要でした。

もちろん、Go 1以降、12回のリリースを行っているので、依然として実験と簡素化とリリースを行っています。しかし、重要な言語の変更や既存のGoプログラムの破壊をせずに、Go開発を簡素化するの方法に焦点を当ててきました。たとえば、Go 1.5では最初の同時実行ガベージコレクターがリリースされ、その後のリリースで改善され、一時停止時間を継続的な懸念事項として排除することで、Go開発が簡素化されました。

2017年のGopherconで、5年間の実験の後、Go開発を簡素化する重要な変更について再び検討する時期であると発表しました。Go 2への道筋は、Go 1への道筋と同じです。Go開発を簡素化するという全体的な目標に向かって、実験、簡素化、リリースを行います。

Go 2では、対処することが最も重要だと考えられていた具体的なトピックは、エラー処理、ジェネリクス、および依存関係です。それ以来、もう1つの重要なトピックは開発者ツールであることに気づきました。

この記事の残りの部分では、これらの各分野における私たちの作業がどのようにその道筋に従っているかについて説明します。その過程で、エラー処理についてGo 1.13でまもなくリリースされる技術的な詳細を調べるために、1つの迂回を行います。

エラー

すべての入力が有効で正しく、プログラムが依存するものが失敗していない場合、プログラムがすべての場合に正しく機能するように記述するのは十分に困難です。エラーを組み合わせると、何が起こっても正しく機能するプログラムを作成することはさらに困難になります。

Go 2について検討する際に、Goがその作業をよりシンプルにするのに役立つかどうかをよりよく理解したいと考えています。

簡素化できる可能性のある2つの異なる側面があります。エラー値とエラー構文です。約束した技術的な迂回では、Go 1.13のエラー値の変更に焦点を当てて、それぞれを見ていきます。

エラー値

エラー値はどこからか始めなければなりませんでした。これが、最初のバージョンの`os`パッケージの`Read`関数です。

export func Read(fd int64, b *[]byte) (ret int64, errno int64) {
    r, e := syscall.read(fd, &b[0], int64(len(b)));
    return r, e
}

まだ`File`型もエラー型もありませんでした。`Read`およびパッケージ内の他の関数は、基になるUnixシステムコールから直接`errno int64`を返しました。

このコードは、2008年9月10日午後12時14分にチェックインされました。当時のすべてと同様に、それは実験であり、コードは急速に変更されました。2時間5分後、APIが変更されました。

export type Error struct { s string }

func (e *Error) Print() { … } // to standard error!
func (e *Error) String() string { … }

export func Read(fd int64, b *[]byte) (ret int64, err *Error) {
    r, e := syscall.read(fd, &b[0], int64(len(b)));
    return r, ErrnoToError(e)
}

この新しいAPIでは、最初の`Error`型が導入されました。エラーは文字列を保持し、その文字列を返し、標準エラーに出力することもできました。

ここでの意図は、整数コードを超えて一般化することでした。過去の経験から、オペレーティングシステムのエラー番号は表現が制限されすぎていること、エラーに関するすべての詳細を64ビットに押し込む必要がないようにプログラムを簡素化することが分かっています。エラー文字列の使用は過去に私たちにとってうまく機能していたため、ここで同じことを行いました。この新しいAPIは7か月間続きました。

次の4月、インターフェースの使用経験が増えた後、ユーザー定義のエラー実装を許可し、`os.Error`型自体をインターフェースにすることで、さらに一般化することにしました。`Print`メソッドを削除することで簡素化しました。

2年後、Go 1では、Roger Peppeからの提案に基づいて、`os.Error`は組み込みの`error`型になり、`String`メソッドは`Error`に名前が変更されました。それ以来、何も変わっていません。しかし、多くのGoプログラムを記述したので、結果としてエラーの最適な実装方法と使用方法をたくさん実験してきました。

エラーは値です

`error`を単純なインターフェースにし、さまざまな実装を許可することで、Go言語全体を使用してエラーを定義および検査できます。私たちはエラーは値であると言います。他のGo値と同じです。

ここに例を示します。Unixでは、ネットワーク接続へのダイヤル試行は最終的に`connect`システムコールを使用します。そのシステムコールは`syscall.Errno`を返します。これは、システムコールエラー番号を表す名前付き整数型であり、`error`インターフェースを実装します。

package syscall

type Errno int64

func (e Errno) Error() string { ... }

const ECONNREFUSED = Errno(61)

    ... err == ECONNREFUSED ...

`syscall`パッケージは、ホストオペレーティングシステムで定義されたエラー番号の名前付き定数も定義しています。この場合、このシステムでは、`ECONNREFUSED`は番号61です。関数からエラーを取得するコードは、通常の値の等価性を使用して、エラーが`ECONNREFUSED`かどうかをテストできます。

レベルを上げると、`os`パッケージでは、システムコールの失敗は、試行された操作とエラーを記録するより大きなエラー構造を使用して報告されます。このような構造はいくつかあります。この`SyscallError`は、記録された追加情報のない特定のシステムコールの呼び出しに関するエラーを説明しています。

package os

type SyscallError struct {
    Syscall string
    Err     error
}

func (e *SyscallError) Error() string {
    return e.Syscall + ": " + e.Err.Error()
}

さらにレベルを上げると、`net`パッケージでは、ダイヤルまたはリスンなどの周囲のネットワーク操作の詳細と、関連するネットワークとアドレスを記録するさらに大きなエラー構造を使用して、ネットワークの失敗が報告されます。

package net

type OpError struct {
    Op     string
    Net    string
    Source Addr
    Addr   Addr
    Err    error
}

func (e *OpError) Error() string { ... }

これらをまとめると、net.Dialのような操作から返されるエラーは文字列としてフォーマットできますが、構造化されたGoデータ値でもあります。この場合、エラーはnet.OpErrorであり、os.SyscallErrorにコンテキストを追加し、os.SyscallErrorsyscall.Errnoにコンテキストを追加します。

c, err := net.Dial("tcp", "localhost:50001")

// "dial tcp [::1]:50001: connect: connection refused"

err is &net.OpError{
    Op:   "dial",
    Net:  "tcp",
    Addr: &net.TCPAddr{IP: ParseIP("::1"), Port: 50001},
    Err: &os.SyscallError{
        Syscall: "connect",
        Err:     syscall.Errno(61), // == ECONNREFUSED
    },
}

エラーが値であると言うとき、それはGo言語全体がエラーの定義と検査に使用できることを意味します。

netパッケージの例を挙げましょう。ソケット接続を試行すると、ほとんどの場合、接続が確立されるか、接続が拒否されますが、稀に理由もなくEADDRNOTAVAILが発生することがあります。Goは、このエラーモードを再試行することでユーザープログラムから保護します。そのためには、内部のsyscall.ErrnoEADDRNOTAVAILかどうかを調べるために、エラー構造を検査する必要があります。

コードは以下の通りです。

func spuriousENOTAVAIL(err error) bool {
    if op, ok := err.(*OpError); ok {
        err = op.Err
    }
    if sys, ok := err.(*os.SyscallError); ok {
        err = sys.Err
    }
    return err == syscall.EADDRNOTAVAIL
}

型アサーションは、任意のnet.OpErrorのラッパーを取り除きます。そして、2番目の型アサーションは、任意のos.SyscallErrorのラッパーを取り除きます。その後、関数はラッパーが削除されたエラーとEADDRNOTAVAILの等価性をチェックします。

Goのエラーに関する実験から長年の経験で学んだことは、errorインターフェースの任意の実装を定義し、エラーの構築とデコンストラクトの両方にGo言語全体を使用でき、単一の実装を使用する必要がないことが非常に強力であるということです。

エラーが値であり、必須のエラー実装がないというこれらの特性は、維持することが重要です。

単一のエラー実装を義務付けていないことで、エラーが提供できる追加機能の実験が可能になり、github.com/pkg/errorsgopkg.in/errgo.v2github.com/hashicorp/errwrapupspin.io/errorsgithub.com/spacemonkeygo/errorsなど、多くのパッケージが開発されました。

しかし、制約のない実験の問題は、クライアントとして、遭遇する可能性のあるすべての実装の和集合に対応するプログラムを書かなければならないことです。Go 2で検討する価値があると見えた簡素化は、合意されたオプションインターフェースの形式で、一般的に追加される機能の標準バージョンを定義することで、異なる実装の相互運用を可能にすることでした。

Unwrap

これらのパッケージで最も一般的に追加される機能は、エラーからコンテキストを削除し、内部のエラーを返すことができるメソッドです。パッケージはこの操作に異なる名前と意味を使用しており、場合によっては1レベルのコンテキストを削除し、場合によっては可能な限り多くのレベルを削除します。

Go 1.13では、内部エラーに削除可能なコンテキストを追加するエラー実装は、内部エラーを返すUnwrapメソッドを実装する必要があります。呼び出し元に公開するのに適した内部エラーがない場合は、Unwrapメソッドを実装しないか、Unwrapメソッドはnilを返す必要があります。

// Go 1.13 optional method for error implementations.

interface {
    // Unwrap removes one layer of context,
    // returning the inner error if any, or else nil.
    Unwrap() error
}

このオプションメソッドを呼び出す方法は、ヘルパー関数errors.Unwrapを呼び出すことです。これは、エラー自体がnilである場合や、Unwrapメソッドをまったく持っていない場合などを処理します。

package errors

// Unwrap returns the result of calling
// the Unwrap method on err,
// if err’s type defines an Unwrap method.
// Otherwise, Unwrap returns nil.
func Unwrap(err error) error

Unwrapメソッドを使用して、spuriousENOTAVAILのよりシンプルで一般的なバージョンを作成できます。net.OpErroros.SyscallErrorのような特定のエラーラッパー実装を探す代わりに、一般的なバージョンはループを実行し、EADDRNOTAVAILに到達するか、エラーがなくなるまでUnwrapを呼び出してコンテキストを削除できます。

func spuriousENOTAVAIL(err error) bool {
    for err != nil {
        if err == syscall.EADDRNOTAVAIL {
            return true
        }
        err = errors.Unwrap(err)
    }
    return false
}

しかし、このループは非常に一般的であるため、Go 1.13では、エラーを繰り返しアンラップして特定のターゲットを探す第2の関数errors.Isを定義しています。そのため、ループ全体をerrors.Isへの単一の呼び出しで置き換えることができます。

func spuriousENOTAVAIL(err error) bool {
    return errors.Is(err, syscall.EADDRNOTAVAIL)
}

この時点で、関数を定義する必要すらなくなります。呼び出しサイトで直接errors.Isを呼び出す方が、同様に明確で、シンプルです。

Go 1.13では、特定の実装タイプが見つかるまでアンラップする関数errors.Asも導入されています。

任意にラップされたエラーを処理するコードを作成する場合は、errors.Isはエラーの等価性チェックのラッパー認識バージョンです。

err == target

    →

errors.Is(err, target)

そして、errors.Asはエラーの型アサーションのラッパー認識バージョンです。

target, ok := err.(*Type)
if ok {
    ...
}

    →

var target *Type
if errors.As(err, &target) {
   ...
}

Unwrapするかしないか?

エラーをアンラップできるようにするかどうかは、構造体のフィールドをエクスポートするかどうかと同じように、APIの決定です。呼び出しコードにその詳細を公開することが適切な場合と、そうでない場合があります。適切な場合は、Unwrapを実装します。そうでない場合は、Unwrapを実装しません。

これまで、fmt.Errorf%vでフォーマットされた基礎となるエラーを呼び出し元の検査に公開していませんでした。つまり、fmt.Errorfの結果をアンラップすることはできませんでした。この例を考えてみましょう。

// errors.Unwrap(err2) == nil
// err1 is not available (same as earlier Go versions)
err2 := fmt.Errorf("connect: %v", err1)

err2が呼び出し元に返された場合、その呼び出し元はerr2を開いてerr1にアクセスする方法がありませんでした。Go 1.13では、この特性を維持しました。

fmt.Errorfの結果のアンラップを許可したい場合は、新しい印刷動詞%wも追加しました。これは%vのようにフォーマットされ、エラー値引数を必要とし、結果のエラーのUnwrapメソッドがその引数を返すようにします。例では、%v%wに置き換えるとします。

// errors.Unwrap(err4) == err3
// (%w is new in Go 1.13)
err4 := fmt.Errorf("connect: %w", err3)

これで、err4が呼び出し元に返された場合、呼び出し元はUnwrapを使用してerr3を取得できます。

「常に%vを使用する(またはUnwrapを実装しない)」や「常に%wを使用する(または常にUnwrapを実装する)」などの絶対的なルールは、「構造体のフィールドをエクスポートしない」や「常に構造体のフィールドをエクスポートする」などの絶対的なルールと同じくらい間違っています。代わりに、正しい決定は、%wを使用するかUnwrapを実装することで公開される追加情報を呼び出し元が検査して依存できるかどうかによって異なります。

この点を説明するために、エクスポートされたErrフィールドを持つ標準ライブラリのすべてのエラーラップ型には、そのフィールドを返すUnwrapメソッドがありますが、エクスポートされていないエラーフィールドを持つ実装にはありません。%vを使用してfmt.Errorfを使用する既存の使用方法も%vを使用し、%wを使用しません。

エラー値の印刷(廃止)

Unwrapの設計ドラフトと共に、スタックフレーム情報やローカライズされた翻訳済みエラーのサポートを含む、より高度なエラー印刷のためのオプションメソッドの設計ドラフトも公開しました。

// Optional method for error implementations
type Formatter interface {
    Format(p Printer) (next error)
}

// Interface passed to Format
type Printer interface {
    Print(args ...interface{})
    Printf(format string, args ...interface{})
    Detail() bool
}

これはUnwrapほど単純ではなく、ここでは詳細には触れません。冬の間、Goコミュニティと設計について議論した結果、設計が十分にシンプルではないことがわかりました。個々のエラータイプが実装するには難しすぎ、既存のプログラムを十分に支援しませんでした。全体として、Go開発を簡素化しませんでした。

このコミュニティの議論の結果、この印刷設計は廃止されました。

エラー構文

エラー値について説明しました。次に、廃止された実験であるエラー構文について簡単に見てみましょう。

標準ライブラリのcompress/lzw/writer.goのコードを次に示します。

// Write the savedCode if valid.
if e.savedCode != invalidCode {
    if err := e.write(e, e.savedCode); err != nil {
        return err
    }
    if err := e.incHi(); err != nil && err != errOutOfCodes {
        return err
    }
}

// Write the eof code.
eof := uint32(1)<<e.litWidth + 1
if err := e.write(e, eof); err != nil {
    return err
}

一見すると、このコードは約半分がエラーチェックです。読むと目が疲れます。そして、書くのが面倒で読むのが面倒なコードは、読み間違いやすく、見つけにくいバグの温床になることを知っています。例えば、これら3つのエラーチェックのうち1つは、他のものとは異なり、素早い読み飛ばしでは見逃しやすいものです。このコードをデバッグする場合、どれくらいの時間でそれに気付くでしょうか?

昨年のGopherconで、新しい制御フロー構造の設計ドラフト(キーワードcheckでマーク)を発表しました。checkは、関数呼び出しまたは式からのエラー結果を使用します。エラーがnilでない場合、checkはそのエラーを返します。そうでない場合、checkは呼び出しからの他の結果を評価します。checkを使用して、lzwコードを簡素化できます。

// Write the savedCode if valid.
if e.savedCode != invalidCode {
    check e.write(e, e.savedCode)
    if err := e.incHi(); err != errOutOfCodes {
        check err
    }
}

// Write the eof code.
eof := uint32(1)<<e.litWidth + 1
check e.write(e, eof)

このバージョンの同じコードはcheckを使用しており、4行のコードが削除され、より重要なことに、e.incHiの呼び出しでerrOutOfCodesが返されることが強調されています。

おそらく最も重要なのは、この設計では、後のチェックが失敗した場合に実行されるエラーハンドラーブロックを定義することもできました。これにより、このスニペットのように、コンテキストを追加する共有コードを一度だけ記述できます。

handle err {
    err = fmt.Errorf("closing writer: %w", err)
}

// Write the savedCode if valid.
if e.savedCode != invalidCode {
    check e.write(e, e.savedCode)
    if err := e.incHi(); err != errOutOfCodes {
        check err
    }
}

// Write the eof code.
eof := uint32(1)<<e.litWidth + 1
check e.write(e, eof)

本質的に、checkif文を短く記述する方法であり、handledeferに似ていますが、エラーの戻りパスのみを対象としていました。他の言語の例外とは異なり、この設計では、Goの、潜在的な失敗する呼び出しがコードに明示的にマークされるという重要な特性を維持しました。今回はif err != nilではなくcheckキーワードを使用します。

この設計の大きな問題は、handledeferとあまりにも重複し、混乱を招いたことです。

5月には、3つの簡素化を伴う新しい設計を投稿しました。deferとの混乱を避けるために、deferだけを使用するようにhandleを削除しました。RustやSwiftの同様のアイデアに合わせて、checkの名前をtryに変更しました。そして、gofmtのような既存のパーサーが認識できる方法で実験できるように、check(現在はtry)をキーワードから組み込み関数に変更しました。

同じコードは、次のようになります。

defer errd.Wrapf(&err, "closing writer")

// Write the savedCode if valid.
if e.savedCode != invalidCode {
    try(e.write(e, e.savedCode))
    if err := e.incHi(); err != errOutOfCodes {
        try(err)
    }
}

// Write the eof code.
eof := uint32(1)<<e.litWidth + 1
try(e.write(e, eof))

6月のほとんどをGitHubでこの提案について公開して議論しました。

checkまたはtryの基本的な考え方は、各エラーチェックで繰り返される構文の量を短縮し、特にreturn文をビューから削除して、エラーチェックを明示的にし、興味深いバリエーションを強調することでした。しかし、公開フィードバックの議論で提起された興味深い点の1つは、明示的なif文とreturnがないと、デバッグプリントを置く場所がなく、ブレークポイントを置く場所がなく、コードカバレッジの結果で未実行として表示するコードがないことです。私たちが求めていた利点は、これらの状況をより複雑にするというコストを伴っていました。全体として、これらやその他の考慮事項から、全体的な結果がよりシンプルなGo開発になることは全く明らかではありませんでした。そのため、この実験は中止されました。

エラー処理に関するすべてです。これは今年の主な焦点の1つでした。

ジェネリクス

次に、少し物議を醸さない話題であるジェネリクスについて説明します。

Go 2で特定した2番目の大きなトピックは、型パラメーターを使用してコードを作成する方法です。これにより、ジェネリックなデータ構造を作成し、任意の種類のスライス、チャネル、マップで動作するジェネリックな関数を記述できます。例えば、ジェネリックなチャネルフィルターを次に示します。

// Filter copies values from c to the returned channel,
// passing along only those values satisfying f.
func Filter(type value)(f func(value) bool, c <-chan value) <-chan value {
    out := make(chan value)
    go func() {
        for v := range c {
            if f(v) {
                out <- v
            }
        }
        close(out)
    }()
    return out
}

Goの開発開始以来、ジェネリクスについて考えており、2010年に最初の具体的な設計を作成して却下しました。2013年末までに、さらに3つの設計を作成して却下しました。4つの実験が中止されましたが、失敗した実験ではありません。checktryから学んだように、そこから学びました。毎回、Go 2への道は正確にはその方向ではないことがわかり、探求する価値のある他の方向性に気付きました。しかし、2013年までに、他の懸念事項に集中する必要があると判断したため、数年間このトピック全体を脇に置きました。

昨年、再び探求と実験を開始し、昨夏のGopherconで、契約というアイデアに基づいた新しい設計を発表しました。実験と簡素化を続け、プログラミング言語理論の専門家と協力して、設計をより深く理解してきました。

全体として、Go開発を簡素化する設計に向けて、良い方向に進んでいると期待しています。それでも、この設計がうまくいかない可能性もあります。この実験を放棄し、学んだことを基に方向転換する必要があるかもしれません。結果を見てみましょう。

Gophercon 2019において、Ian Lance TaylorはGoにジェネリクスを追加する理由について話し、最新の設計ドラフトを簡単にプレビューしました。詳細については、彼のブログ記事「ジェネリクスが必要な理由」を参照してください。

依存関係

Go 2のために特定した3番目の大きなトピックは、依存関係管理でした。

2010年、私たちはgoinstallというツールを発表しました。これは「パッケージインストールの実験」と呼ばれていました。これは依存関係をダウンロードし、GOROOTにあるGo配布ツリーに保存していました。

goinstallで実験する中で、Go配布とインストールされたパッケージは分離しておくべきだとわかりました。これにより、すべてのGoパッケージを失うことなく、新しいGo配布に切り替えることが可能になります。そこで2011年、メインのGo配布に見つからないパッケージを探す場所を指定する環境変数GOPATHを導入しました。

GOPATHを追加することでGoパッケージの保存場所が増えましたが、Go配布とGoライブラリを分離することで、Go開発全体を簡素化しました。

互換性

goinstall実験では、パッケージのバージョン管理の明示的な概念を意図的に省きました。代わりに、goinstallは常に最新のものをダウンロードしました。これは、パッケージインストールにおける他の設計上の問題に焦点を当てるためでした。

goinstallはGo 1の一部としてgo getになりました。バージョンについて質問があった場合、私たちは追加のツールを作成して実験することを推奨し、実際そうしました。そして、Go 1ライブラリに対して行ったのと同じ後方互換性をパッケージ作成者に提供するよう推奨しました。Go FAQの引用

「公開を目的としたパッケージは、進化に伴い後方互換性を維持しようとすべきです。

異なる機能が必要な場合は、古い名前を変更する代わりに新しい名前を追加します。

完全に変更が必要な場合は、新しいインポートパスを持つ新しいパッケージを作成します。」

この規則は、作成者ができることを制限することで、パッケージを使用する全体的なエクスペリエンスを簡素化します。APIに対する破壊的変更を避けること、新しい機能に新しい名前を与えること、そして完全に新しいパッケージ設計に新しいインポートパスを与えることです。

もちろん、人々は実験を続けました。最も興味深い実験の1つは、Gustavo Niemeyerによって開始されました。彼は異なるAPIバージョンに対して異なるインポートパスを提供するGitリダイレクターであるgopkg.inを作成し、新しいパッケージ設計に新しいインポートパスを与えるという規則に従うのに役立てました。

例えば、GitHubリポジトリgo-yaml/yamlにあるGoソースコードは、v1とv2のセマンティックバージョンタグに異なるAPIを持っています。gopkg.inサーバーは、これらを異なるインポートパスgopkg.in/yaml.v1gopkg.in/yaml.v2で提供します。

新しいバージョンのパッケージを古いバージョンの代わりに使用できる後方互換性を提供するという規則は、go getの非常に単純なルール「常に最新のものをダウンロードする」が今日でもうまく機能する理由です。

バージョン管理とベンダー化

しかし、本番環境では、ビルドの再現性を確保するために、依存関係のバージョンをより正確に指定する必要があります。

多くの人が、その方法について実験し、ニーズに応えるツールを作成しました。これには、Keith Rarickのgoven(2012年)とgodep(2013年)、Matt Butcherのglide(2014年)、Dave Cheneyのgb(2015年)が含まれます。これらのツールはすべて、依存関係パッケージを独自のソースコード管理リポジトリにコピーするというモデルを使用しています。これらのパッケージをインポート可能にするために使用される正確なメカニズムは異なりましたが、どれも必要以上に複雑でした。

コミュニティ全体での議論の後、私たちはKeith Rarickによる提案を採用し、GOPATHのトリックなしでコピーされた依存関係を参照するための明示的なサポートを追加しました。これは、addToListappendのように、これらのツールはすでにこの概念を実装していましたが、必要以上にぎこちなかったため、再形成による簡素化でした。ベンダーディレクトリに対する明示的なサポートを追加することで、これらの使用が全体として簡素化されました。

goコマンドにベンダーディレクトリを含めることで、ベンダー化自体に関する実験が増え、いくつかの問題が発生していることに気づきました。最も深刻な問題は、パッケージの一意性を失ったことです。以前は、任意のビルド中に、インポートパスが多くの異なるパッケージに表示される可能性があり、すべてのインポートは同じターゲットを参照していました。しかし、ベンダー化を使用すると、異なるパッケージ内の同じインポートパスは、パッケージの異なるベンダー化されたコピーを参照する可能性があり、これらはすべて最終的なバイナリに表示されます。

当時、私たちはこの特性に名前を付けていませんでした。パッケージの一意性です。それはGOPATHモデルが機能していた方法でした。それがなくなるまで、私たちはそれを完全に理解していませんでした。

checktryのエラー構文提案と類似点があります。この場合、可視的なreturn文がどのように機能するかに依存していましたが、削除することを検討するまで、そのことを理解していませんでした。

ベンダーディレクトリサポートを追加したとき、依存関係を管理するためのさまざまなツールがありました。Goプログラムがテキストファイルにどのように保存されるかについての合意が、Goコンパイラ、テキストエディタ、goimportsgorenameなどのツール間の相互運用を可能にするのと同じように、ベンダーディレクトリとベンダー化メタデータの形式に関する明確な合意があれば、さまざまなツールが相互運用できると考えました。

これは、ナイーブに楽観的であることがわかりました。ベンダー化ツールはすべて、微妙なセマンティックな点で異なっていました。相互運用するには、それらをすべて変更してセマンティクスについて合意する必要があり、それによってそれぞれのユーザーを壊す可能性があります。収束は起こりませんでした。

Dep

2016年のGopherconで、依存関係を管理するための単一のツールを定義する取り組みを開始しました。その取り組みの一環として、さまざまな種類の多くのユーザーに対して調査を行い、依存関係管理に関して彼らが何を必要としているかを理解し、チームは新しいツール(depになった)の開発を始めました。

Depは、既存のすべての依存関係管理ツールを置き換えられることを目指していました。目標は、既存のさまざまなツールを単一のツールに再形成することで簡素化することでした。それは部分的に達成されました。Depは、プロジェクトツリーの一番上に1つのベンダーディレクトリのみを持つことで、ユーザー向けのパッケージの一意性を復元しました。

しかし、depは、私たちが完全に理解するまでに時間がかかった深刻な問題も導入しました。その問題は、depglideの設計上の選択を採用し、インポートパスを変更せずに、特定のパッケージへの互換性のない変更をサポートおよび推奨したことです。

例を挙げましょう。独自のプログラムを構築していて、構成ファイルが必要な場合、人気のあるGo YAMLパッケージのバージョン2を使用します。

ここで、プログラムがKubernetesクライアントをインポートするとします。KubernetesはYAMLを広く使用しており、同じ人気のあるパッケージのバージョン1を使用していることがわかります。

バージョン1とバージョン2はAPIが互換性がありませんが、インポートパスも異なります。そのため、特定のインポートが何を意味するのかについてあいまいさはありません。Kubernetesはバージョン1を取得し、構成パーサーはバージョン2を取得し、すべてが機能します。

Depはこのモデルを放棄しました。yamlパッケージのバージョン1とバージョン2は、同じインポートパスを持つようになり、競合が発生します。互換性のない2つのバージョンに対して同じインポートパスを使用すると、パッケージの一意性と組み合わさり、以前はビルドできたこのプログラムをビルドできなくなります。

この問題を理解するまでに時間がかかりました。なぜなら、私たちは長い間「新しいAPIは新しいインポートパスを意味する」という規則を適用していたため、当然のことと考えていたからです。dep実験は、その規則をよりよく理解するのに役立ち、私たちはそれに名前をつけました。「インポート互換性規則」です。

「古いパッケージと新しいパッケージが同じインポートパスを持つ場合、新しいパッケージは古いパッケージと後方互換性がある必要があります。」

Goモジュール

dep実験でうまくいったことと、うまくいかなかったことを学び、vgoと呼ばれる新しい設計を試行しました。vgoでは、パッケージはインポート互換性規則に従っていたため、パッケージの一意性を提供しながら、先ほど見たようなビルドを壊すことはありませんでした。これにより、設計の他の部分も簡素化できました。

インポート互換性規則を復元することに加えて、vgo設計のもう1つの重要な部分は、パッケージのグループに名前を付け、そのグループをソースコードリポジトリの境界から分離できるようにすることでした。Goパッケージのグループの名前はモジュールであるため、このシステムをGoモジュールと呼びます。

Goモジュールは現在goコマンドに統合されているため、ベンダーディレクトリをまったくコピーする必要がありません。

GOPATHの置き換え

Goモジュールが登場することで、グローバル名前空間としてのGOPATHは終焉を迎えました。既存のGoの使用とツールをモジュールに変換するためのほとんどすべての難しい作業は、GOPATHからの移行によって引き起こされます。

GOPATHの基本的な考え方は、GOPATHディレクトリツリーが使用されているバージョンの真実のグローバルソースであり、ディレクトリ間を移動しても使用されているバージョンは変わらないということです。しかし、グローバルGOPATHモードは、プロジェクトごとの再現可能なビルドという本番環境の要件と直接的に矛盾しており、それ自体が多くの重要な点でGo開発と展開のエクスペリエンスを簡素化します。

プロジェクトごとの再現可能なビルドとは、プロジェクトAのチェックアウトで作業している場合、go.modファイルで定義されているように、プロジェクトAの他の開発者がそのコミットで取得する依存関係のバージョンの同じセットを取得できることを意味します。プロジェクトBのチェックアウトで作業に切り替えると、今度はそのプロジェクトが選択した依存関係のバージョン、つまりプロジェクトBの他の開発者が取得する同じセットを取得します。しかし、それらはプロジェクトAとは異なる可能性があります。プロジェクトAからプロジェクトBに移動するときに依存関係のバージョンのセットが変更されることは、AとBの他の開発者との開発を同期させるために必要です。もはや単一のグローバルGOPATHは存在できません。

モジュールの採用における複雑さのほとんどは、単一のグローバルGOPATHの消失から直接生じます。パッケージのソースコードはどこにありますか?以前は、その答えはGOPATH環境変数のみによって決定され、ほとんどの人がめったに変更しませんでした。現在、その答えは、作業中のプロジェクトによって異なります。これは頻繁に変更される可能性があります。この新しい規則に合わせてすべてを更新する必要があります。

ほとんどの開発ツールは、go/buildパッケージを使用してGoソースコードを検索およびロードします。私たちはそれを機能させてきましたが、APIはモジュールを想定しておらず、APIの変更を回避するために追加した回避策は、私たちが望むほど高速ではありません。私たちは代替品であるgolang.org/x/tools/go/packagesを公開しました。開発ツールは、これを使用する必要があります。これはGOPATHとGoモジュールの両方をサポートしており、高速で使いやすくなっています。1、2回のリリースで標準ライブラリに移動するかもしれませんが、現時点ではgolang.org/x/tools/go/packagesは安定しており、使用できます。

Goモジュールプロキシ

モジュールがGo開発を簡素化する手段の1つは、パッケージのグループという概念と、それらが格納されている基盤となるソースコード管理リポジトリを分離することです。

Goユーザーに依存関係について話を聞いたところ、企業でGoを使用しているほぼ全員が、使用できるコードをより適切に制御するために、独自のサーバーを介してgo getパッケージの取得をルーティングする方法を尋ねてきました。そして、オープンソース開発者でさえ、依存関係が予期せず消失したり変更されたりしてビルドが中断されることを懸念していました。モジュールが登場する前は、ユーザーはgoコマンドが実行するバージョン管理コマンドをインターセプトするなど、これらの問題に対する複雑な解決策を試みていました。

Goモジュールの設計により、特定のモジュールバージョンを要求できるモジュールプロキシの概念を簡単に導入できます。

企業は、許可されるものとキャッシュされたコピーの保存場所に関するカスタムルールを使用して、独自のモジュールプロキシを簡単に実行できるようになりました。オープンソースのAthensプロジェクトはまさにそのようなプロキシを構築しており、Aaron SchlesingerがGophercon 2019でそれについて講演を行いました。(ビデオが公開されたら、ここにリンクを追加します。)

個々の開発者とオープンソースチーム向けに、GoogleのGoチームはプロキシを立ち上げました。これは、すべてのオープンソースGoパッケージのパブリックミラーとして機能し、Go 1.13はモジュールモードの場合、デフォルトでそのプロキシを使用します。Katie HockmanはGophercon 2019でこのシステムに関する講演を行いました

Goモジュールの状況

Go 1.11では、モジュールが実験的なオプトインプレビューとして導入されました。私たちは実験と簡素化を継続しています。Go 1.12では改善が実装され、Go 1.13ではさらに多くの改善が実装されます。

モジュールは、ほとんどのユーザーに役立つ段階に達しましたが、まだGOPATHを廃止する準備ができていません。私たちは実験、簡素化、改訂を継続します。

GoユーザーコミュニティがGOPATHを中心に約10年間の経験、ツール、ワークフローを構築してきたことを十分に認識しており、それらをすべてGoモジュールに変換するには時間がかかります。

しかし、繰り返しますが、モジュールは現在ほとんどのユーザーにとって非常にうまく機能すると考えており、Go 1.13がリリースされたらぜひ試してみてください。

データポイントの1つとして、Kubernetesプロジェクトには多くの依存関係があり、それらを管理するためにGoモジュールへの移行を行っています。おそらくあなたもできるでしょう。そして、できない場合は、何がうまくいっていないか、または何が複雑すぎるのかをバグレポートを送信して知らせてください。そうすれば、実験と簡素化を続けます。

ツール

エラー処理、ジェネリクス、依存関係管理には、少なくともあと数年かかり、現在それらに重点的に取り組んでいます。エラー処理はほぼ完了しており、その次にモジュール、そしておそらくジェネリクスに取り組みます。

しかし、実験と簡素化が完了し、エラー処理、モジュール、ジェネリクスが提供されてから数年後のことを考えてみましょう。その後はどうなるでしょうか?将来を予測することは非常に困難ですが、これらの3つが提供されれば、大きな変更のための新たな静寂期が始まるのではないかと思います。その時点での私たちの焦点は、改善されたツールによるGo開発の簡素化に移る可能性が高いです。

ツールの作業の一部はすでに進行中であるため、この投稿ではその点について説明します。

Goモジュールを理解するようにGoコミュニティの既存のすべてのツールを更新するのを手伝いましたが、それぞれ小さなタスクを実行する多数の開発ヘルパーツールは、ユーザーにとって役立っていないことに気づきました。個々のツールは組み合わせが難しく、呼び出しが遅く、使用法が異なりすぎます。

最も一般的に必要な開発ヘルパーを単一のツールに統合する取り組みを開始しました。現在、gopls(「go、please」と発音)と呼ばれています。GoplsLanguage Server Protocol(LSP)を使用し、LSPをサポートする統合開発環境またはテキストエディターと連携します。これは、現時点では事実上すべてです。

Goplsは、Goプロジェクトの焦点の拡大を表しています。go vetgorenameのようなスタンドアロンのコンパイラのようなコマンドラインツールの提供から、完全なIDEサービスの提供へと拡大しています。Rebecca Stamblerは、Gophercon 2019でgoplsとIDEに関する詳細な講演を行いました。(ビデオが公開されたら、ここにリンクを追加します。)

goplsの後には、拡張可能な方法でgo fixを復活させることと、go vetをさらに役立つものにすることについても考えています。

結び

これがGo 2への道筋です。実験と簡素化を繰り返します。そして、提供します。そして、また実験と簡素化を行います。そして、それを繰り返します。道筋が円を描いて回っているように見えたり、感じられたりするかもしれません。しかし、実験と簡素化を行うたびに、Go 2がどのようなものになるかについて少し理解が深まり、それに一歩近づきます。tryや最初の4つのジェネリクス設計、depなど、放棄された実験でさえ無駄ではありません。それらは、提供する前に何を簡素化する必要があるかを学ぶのに役立ち、場合によっては、当然のことと考えていたことをよりよく理解するのに役立ちます。

ある時点で、十分に実験し、十分に簡素化し、十分に提供したことに気づき、Go 2が完成します。

この道筋で実験、簡素化、提供、そして道を見つけるお手伝いをしてくださったGoコミュニティの皆様に感謝いたします。

次の記事:Contributors Summit 2019
前の記事:Why Generics?
ブログインデックス