The Go Blog
なぜジェネリクスなのか?
はじめに
これは、先週のGophercon 2019での私の講演のブログ投稿版です。
この記事は、Goにジェネリクスを追加することが何を意味するのか、そしてなぜ私たちがそれを行うべきだと私が考えるのかについて書かれています。Goにジェネリクスを追加するための可能な設計の更新についても触れます。
Goは2009年11月10日にリリースされました。24時間も経たないうちに、ジェネリクスに関する最初のコメントが見られました。(このコメントには例外についても言及されていますが、2010年初頭にpanicとrecoverという形で言語に追加されました。)
Goに関する過去3年間のアンケートでは、ジェネリクスの欠如が常に言語で修正すべき上位3つの問題の1つとして挙げられてきました。
なぜジェネリクスなのか?
しかし、ジェネリクスを追加することは何を意味し、なぜそれが欲しいのでしょうか?
Jazayeri et alを言い換えると、ジェネリックプログラミングは、関数とデータ構造を、型を分離したジェネリックな形式で表現することを可能にします。
それは何を意味するのでしょうか?
簡単な例として、スライス内の要素を逆順にしたいとしましょう。多くのプログラムが必要とすることではありませんが、それほど珍しいことでもありません。
それが整数のスライスだとします。
func ReverseInts(s []int) {
first := 0
last := len(s)
for first < last {
s[first], s[last] = s[last], s[first]
first++
last--
}
}
かなり単純ですが、そのような単純な関数でも、いくつかのテストケースを作成したくなるでしょう。実際、私がそうしたとき、バグを見つけました。多くの読者はすでにそれに気づいているでしょう。
func ReverseInts(s []int) {
first := 0
last := len(s) - 1
for first < last {
s[first], s[last] = s[last], s[first]
first++
last--
}
}
変数`last`を設定するときに1を引く必要があります。
次に、文字列のスライスを逆順にしてみましょう。
func ReverseStrings(s []string) {
first := 0
last := len(s) - 1
for first < last {
s[first], s[last] = s[last], s[first]
first++
last--
}
}
ReverseIntsとReverseStringsを比較すると、パラメータの型を除いて、2つの関数がまったく同じであることがわかります。読者の皆さんもこれに驚くことはないでしょう。
Goに慣れていない一部の人々が驚くのは、あらゆる型のスライスで機能する単純なReverse関数を書く方法がないことです。
他のほとんどの言語では、そのような関数を記述できます。
PythonやJavaScriptのような動的型付け言語では、要素の型を指定する手間をかけずに、単純に関数を書くことができます。これはGoでは機能しません。Goは静的型付けであり、スライスの正確な型とスライス要素の型を記述する必要があるからです。
C++、Java、Rust、Swiftのような他のほとんどの静的型付け言語は、まさにこの種の問題に対処するためにジェネリクスをサポートしています。
今日のGoのジェネリックプログラミング
では、Goで人々はこの種のコードをどのように書いているのでしょうか?
Goでは、インターフェース型を使用し、渡したいスライス型にメソッドを定義することで、異なるスライス型に対応する単一の関数を記述できます。これは、標準ライブラリのsort.Sort関数が機能する方法です。
言い換えれば、Goのインターフェース型は、ジェネリックプログラミングの一形態です。これらによって、異なる型の共通の側面を捉え、それらをメソッドとして表現することができます。その後、それらのインターフェース型を使用する関数を記述することができ、それらの関数は、それらのメソッドを実装するあらゆる型に対して機能します。
しかし、このアプローチは私たちの望むものには及びません。インターフェースを使うと、自分でメソッドを書かなければなりません。スライスを逆順にするためだけに、いくつかのメソッドを持つ名前付き型を定義しなければならないのは不便です。そして、記述するメソッドは、どのスライス型でもまったく同じなので、ある意味、重複するコードを移動して凝縮しただけで、排除したわけではありません。インターフェースはジェネリクスの一形態ではありますが、ジェネリクスに求めるすべてを与えてくれるわけではありません。
メソッドを自分で記述する必要を回避できる、ジェネリクスのための異なるインターフェースの使用方法は、言語がいくつかの種類の型にメソッドを定義することです。これは現在言語がサポートしているものではありませんが、たとえば、言語はすべてのスライス型に要素を返すIndexメソッドがあると定義することができます。しかし、そのメソッドを実際に使用するためには、空のインターフェース型を返さなければならず、その結果、静的型付けのすべての利点を失います。さらに微妙なことには、同じ要素型を持つ2つの異なるスライスを受け取る汎用関数や、1つの要素型のマップを受け取り、同じ要素型のスライスを返す汎用関数を定義する方法がなくなります。Goは静的型付け言語であり、大規模なプログラムをより簡単に書くことができるように設計されています。ジェネリクスの利点を得るために静的型付けの利点を失いたくありません。
もう一つのアプローチは、リフレクトパッケージを使って汎用的なReverse関数を書くことですが、これは書くのが非常に煩雑で実行速度も遅いため、ほとんどの人が行いません。このアプローチでは、明示的な型アサーションも必要で、静的な型チェックもありません。
あるいは、型を受け取って、その型のスライス用のReverse関数を生成するコードジェネレーターを記述することもできます。そのようなコードジェネレーターはいくつか存在します。しかし、これはReverseを必要とするすべてのパッケージに別のステップを追加し、異なるコピーがすべてコンパイルされる必要があるためビルドを複雑にし、マスターソースのバグを修正するには、すべてのインスタンスを再生成する必要があり、その一部はまったく異なるプロジェクトに存在する可能性があります。
これらのアプローチはすべて煩雑なため、Goでスライスを逆順にする必要があるほとんどの人は、必要な特定のスライス型のために関数を記述していると思います。そして、最初に私が犯したような単純な間違いをしていないことを確認するために、その関数のテストケースを記述する必要があります。そして、それらのテストを定期的に実行する必要があります。
どのように行うにしても、要素の型を除いてまったく同じに見える関数を記述するだけで、多くの余分な作業が発生します。それができないわけではありません。明らかに可能であり、Goプログラマーはそれを行っています。ただ、もっと良い方法があるはずだということです。
Goのような静的型付け言語にとって、そのより良い方法はジェネリクスです。私が先に書いたのは、ジェネリックプログラミングは、関数とデータ構造を、型を分離したジェネリックな形式で表現することを可能にするということです。まさにそれがここで私たちが求めているものです。
ジェネリクスがGoにもたらすもの
Goでジェネリクスに求める最初の最も重要なことは、スライスの要素の型を気にせずにReverseのような関数を記述できることです。その要素の型を分離したいのです。そうすれば、関数を一度書き、テストを一度書き、go-gettableなパッケージに入れ、必要なときにいつでも呼び出すことができます。
さらに良いことに、これはオープンソースの世界なので、誰かが一度Reverseを書いてくれれば、私たちはその実装を利用できます。
この時点で、「ジェネリクス」は多くの異なる意味を持つ可能性があることを述べておくべきでしょう。この記事で私が「ジェネリクス」と言うのは、まさに私が今説明したことを意味します。特に、私がここで書いたよりもはるかに多くのことをサポートするC++言語に見られるテンプレートを意味するものではありません。
Reverseについて詳しく説明しましたが、他にも一般的に書ける関数はたくさんあります。
- スライス内で最小/最大の要素を見つける
- スライスの平均/標準偏差を計算する
- マップの結合/交差を計算する
- ノード/エッジグラフで最短パスを見つける
- スライス/マップに変換関数を適用し、新しいスライス/マップを返す
これらの例は、他のほとんどの言語で利用できます。実際、私はC++標準テンプレートライブラリをざっと見てこのリストを作成しました。
Goの強力な並行処理サポートに特有の例もあります。
- タイムアウト付きのチャネルからの読み取り
- 2つのチャネルを1つのチャネルに結合する
- 関数のリストを並行して呼び出し、結果のスライスを返す
- Contextを使用して関数のリストを呼び出し、最初に完了した関数の結果を返し、余分なゴルーチンをキャンセルしてクリーンアップする
私はこれらの関数が異なる型で何度も記述されているのを見てきました。それらをGoで記述するのは難しくありません。しかし、あらゆる値の型に対応する効率的でデバッグ済みの実装を再利用できれば素晴らしいことです。
明確にしておくと、これらはほんの一例です。ジェネリクスを使えば、より簡単かつ安全に記述できる汎用関数は他にもたくさんあります。
また、先に書いたように、関数だけではありません。データ構造もそうです。
Goには、言語に組み込まれた2つの汎用的なジェネリックデータ構造があります。スライスとマップです。スライスとマップは、あらゆるデータ型の値を保持でき、格納および取得される値に対して静的型チェックが行われます。値はインターフェース型ではなく、それ自体として格納されます。つまり、[]intがある場合、スライスはintを直接保持し、インターフェース型に変換されたintではありません。
スライスとマップは最も有用なジェネリックデータ構造ですが、それだけではありません。他にもいくつかの例があります。
- セット
- 自己平衡木(ソートされた順序で効率的な挿入と探索が可能)
- マルチマップ(複数のキーインスタンスを持つ)
- 同時ハッシュマップ(単一のロックなしで並行挿入と検索をサポート)
汎用型を記述できるので、スライスやマップと同じ型チェックの利点を持つ、これらの新しいデータ構造を定義できます。コンパイラは、それらが保持する値の型を静的に型チェックでき、値はインターフェース型ではなく、それ自体として格納できます。
先に述べたようなアルゴリズムを、汎用データ構造に適用することも可能になるはずです。
これらの例はすべて、Reverseと同じであるべきです。つまり、汎用的な関数とデータ構造が一度パッケージに記述され、必要なときにいつでも再利用されます。それらはスライスやマップのように機能すべきであり、空のインターフェース型の値を格納するのではなく、特定の型を格納し、それらの型はコンパイル時にチェックされるべきです。
それがGoがジェネリクスから得られるものです。ジェネリクスは、コードを共有し、プログラムをより簡単に構築できる強力な構成要素を私たちに与えることができます。
なぜこれを探求する価値があるのか、説明できたことを願っています。
メリットとコスト
しかし、ジェネリクスは、太陽が毎日レモネードの泉に輝く土地、ビッグ・ロック・キャンディ・マウンテンから降ってくるわけではありません。すべての言語変更にはコストがかかります。Goにジェネリクスを追加すると、言語がより複雑になることは間違いありません。言語への変更と同様に、メリットを最大化し、コストを最小化することについて話し合う必要があります。
Goでは、独立した直交する言語機能を通じて複雑さを軽減し、それらを自由に組み合わせることを目指してきました。個々の機能を単純にすることで複雑さを軽減し、それらを自由に組み合わせることを許可することで機能のメリットを最大化しています。ジェネリクスでも同じことをしたいと考えています。
これをより具体的にするために、私たちが従うべきいくつかのガイドラインを挙げます。
新しい概念を最小限に抑える
言語に追加する新しい概念は可能な限り少なくすべきです。これは、新しい構文、新しいキーワード、その他の名前を最小限に抑えることを意味します。
複雑さは、汎用コードの作成者にかかるものであり、ユーザーにかかるものではない
可能な限り、複雑さは汎用パッケージを記述するプログラマにかかるべきです。パッケージのユーザーがジェネリクスについて心配する必要がないようにしたいのです。これは、汎用関数を自然な方法で呼び出すことが可能であるべきであり、汎用パッケージの使用におけるエラーは、理解しやすく修正しやすい方法で報告されるべきであることを意味します。汎用コードへの呼び出しをデバッグすることも簡単であるべきです。
作成者とユーザーは独立して作業できる
同様に、ジェネリックコードの作成者とユーザーの関心を分離しやすくすることで、それぞれがコードを独立して開発できるようにすべきです。異なるパッケージにある通常の関数の作成者と呼び出し側が心配する必要がないのと同じように、互いが何をしているかを心配する必要はありません。これは当然のことのように聞こえますが、他のすべてのプログラミング言語のジェネリクスではそうではありません。
短いビルド時間、速い実行時間
当然のことながら、可能な限り、Goが今日提供している短いビルド時間と速い実行時間を維持したいと考えています。ジェネリクスは、通常、高速ビルドと高速実行の間にトレードオフをもたらします。可能な限り、両方を望んでいます。
Goの明確さとシンプルさを維持する
最も重要なことは、今日のGoはシンプルな言語であるということです。Goのプログラムは通常、明確で理解しやすいものです。この分野を探求する私たちの長いプロセスの主要な部分は、その明確さとシンプルさを維持しながらジェネリクスを追加する方法を理解しようとすることでした。既存の言語にうまく適合し、それをまったく異なるものに変えてしまわないメカニズムを見つける必要があります。
これらのガイドラインは、Goのすべてのジェネリクス実装に適用されるべきです。これが今日皆さんに伝えたい最も重要なメッセージです。ジェネリクスは言語に大きなメリットをもたらすことができますが、Goが依然としてGoらしく感じられる場合にのみ、その価値があるのです。
設計草案
幸いなことに、それは可能だと思います。この記事の締めくくりとして、なぜジェネリクスが欲しいのか、そしてそれらに求められる要件は何かという議論から、それらを言語にどのように追加できるかという設計について簡単に議論することに移ります。
2022年1月追記:このブログ投稿は2019年に書かれたものであり、最終的に採用されたジェネリクスのバージョンについては記述していません。最新情報については、言語仕様およびジェネリクス設計ドキュメントにおける型パラメータの記述を参照してください。
今年のGopherconで、Robert Griesemerと私は、Goにジェネリクスを追加するための設計草案を発表しました。詳細については草案を参照してください。ここでは主要な点をいくつか説明します。
この設計における汎用Reverse関数を以下に示します。
func Reverse (type Element) (s []Element) {
first := 0
last := len(s) - 1
for first < last {
s[first], s[last] = s[last], s[first]
first++
last--
}
}
関数の本体はまったく同じであることに気づくでしょう。変更されたのはシグネチャだけです。
スライスの要素型は分離されました。現在はElementという名前になり、私たちが型パラメータと呼ぶものになりました。スライスパラメータの型の一部である代わりに、独立した追加の型パラメータになりました。
型パラメータを持つ関数を呼び出す場合、一般的なケースでは、型以外の引数と同様に、型引数を渡します。
func ReverseAndPrint(s []int) {
Reverse(int)(s)
fmt.Println(s)
}
これがこの例でReverseの後に表示されている(int)です。
幸いにも、この例を含め、ほとんどの場合、コンパイラは通常の引数の型から型引数を推論できるため、型引数を明示的に指定する必要はありません。
ジェネリック関数の呼び出しは、他の関数を呼び出すのとまったく同じように見えます。
func ReverseAndPrint(s []int) {
Reverse(s)
fmt.Println(s)
}
言い換えれば、汎用Reverse関数はReverseIntsやReverseStringsよりもわずかに複雑ですが、その複雑さは関数の作成者にかかるものであり、呼び出し側にはかかりません。
契約
Goは静的型付け言語なので、型パラメータの型について話す必要があります。このメタタイプは、ジェネリック関数を呼び出すときにどのような型の引数が許可されるか、およびジェネリック関数が型パラメータの値でどのような種類の操作を実行できるかをコンパイラに伝えます。
Reverse関数は任意の型のスライスで機能します。Element型の値に対して行う唯一の操作は代入であり、これはGoの任意の型で機能します。この種のジェネリック関数は非常に一般的なケースであり、型パラメータについて特別なことを言う必要はありません。
別の関数を簡単に見てみましょう。
func IndexByte (type T Sequence) (s T, b byte) int {
for i := 0; i < len(s); i++ {
if s[i] == b {
return i
}
}
return -1
}
現在、標準ライブラリのbytesパッケージとstringsパッケージの両方にIndexByte関数があります。この関数は、シーケンスs内のbのインデックスを返します。ここで、sはstringまたは[]byteのいずれかです。この単一のジェネリック関数を使用して、bytesおよびstringsパッケージの2つの関数を置き換えることができます。実際には、そうすることはないかもしれませんが、これは有用な単純な例です。
ここでは、型パラメータTがstringまたは[]byteのように振る舞うことを知る必要があります。それに対してlenを呼び出すことができ、インデックスを付けてアクセスすることができ、インデックス操作の結果をバイト値と比較することができます。
これをコンパイル可能にするためには、型パラメータT自体に型が必要です。それはメタタイプですが、関連する複数の型を記述する必要がある場合があり、汎用関数の実装とその呼び出し側の間の関係を記述するため、私たちはTの型を契約と呼んでいます。ここでは、契約はSequenceと名付けられています。これは型パラメータのリストの後に表示されます。
これがこの例におけるSequence契約の定義方法です。
contract Sequence(T) {
T string, []byte
}
これは単純な例なので、かなり単純です。型パラメータTはstringまたは[]byteのいずれかになります。ここでcontractは新しいキーワードであるか、パッケージスコープで認識される特殊な識別子である可能性があります。詳細については設計草案を参照してください。
Gophercon 2018で発表された設計を覚えている人なら、この契約の書き方がはるかに単純になっていることに気づくでしょう。以前の設計では契約が複雑すぎると多くのフィードバックがあり、それを考慮に入れようと努めました。新しい契約は、書くのも読むのも理解するのもはるかに単純です。
これにより、型パラメータの基本型を指定したり、型パラメータのメソッドをリストしたりすることができます。また、異なる型パラメータ間の関係を記述することもできます。
メソッドを持つ契約
これは別の簡単な例で、Stringメソッドを使用して、s内のすべての要素の文字列表現の[]stringを返す関数です。
func ToStrings (type E Stringer) (s []E) []string {
r := make([]string, len(s))
for i, v := range s {
r[i] = v.String()
}
return r
}
それはかなり単純です。スライスを歩き、各要素でStringメソッドを呼び出し、結果の文字列のスライスを返します。
この関数は、要素型がStringメソッドを実装していることを必要とします。Stringer契約がそれを保証します。
contract Stringer(T) {
T String() string
}
契約は単純に、TがStringメソッドを実装する必要があると言っています。
この契約がfmt.Stringerインターフェースに似ていることに気づくかもしれませんが、ToStrings関数の引数がfmt.Stringerのスライスではないことを指摘しておく価値があります。これは何らかの要素型のスライスであり、その要素型がfmt.Stringerを実装しています。要素型のスライスとfmt.Stringerのスライスのメモリ表現は通常異なり、Goはそれらの間の直接変換をサポートしていません。したがって、fmt.Stringerが存在するにもかかわらず、これを記述する価値があります。
複数の型を持つ契約
ここに、複数の型パラメータを持つ契約の例を示します。
type Graph (type Node, Edge G) struct { ... }
contract G(Node, Edge) {
Node Edges() []Edge
Edge Nodes() (from Node, to Node)
}
func New (type Node, Edge G) (nodes []Node) *Graph(Node, Edge) {
...
}
func (g *Graph(Node, Edge)) ShortestPath(from, to Node) []Edge {
...
}
ここでは、ノードとエッジから構築されたグラフを記述しています。グラフに特定のデータ構造を要求しているわけではありません。代わりに、Node型が、そのNodeに接続するエッジのリストを返すEdgesメソッドを持つ必要があると述べています。そして、Edge型が、そのEdgeが接続する2つのNodeを返すNodesメソッドを持つ必要があります。
実装は省略しましたが、これはGraphを返すNew関数のシグネチャと、Graph上のShortestPathメソッドのシグネチャを示しています。
ここで重要なのは、契約が単一の型だけに関するものではないということです。2つ以上の型間の関係を記述することができます。
順序型
Goに関する驚くほど一般的な不満の1つは、Min関数がないことです。あるいは、同様にMax関数もありません。これは、有用なMin関数は任意の順序型で機能する必要があるためであり、つまりジェネリックである必要があるということです。
Minは自分で書くのが非常に簡単ですが、有用なジェネリクスの実装では、それを標準ライブラリに追加できるようになるはずです。これが私たちの設計でのMinの様子です。
func Min (type T Ordered) (a, b T) T {
if a < b {
return a
}
return b
}
Ordered契約は、型Tが順序型でなければならないことを示しており、これはTがより小さい、より大きいなどの演算子をサポートしていることを意味します。
contract Ordered(T) {
T int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64, uintptr,
float32, float64,
string
}
Ordered契約は、言語で定義されているすべての順序型のリストにすぎません。この契約は、リストされている型、またはその型を基本型とする名前付き型を受け入れます。基本的に、小なり演算子と一緒に使用できる任意の型です。
すべての演算子に機能する新しい表記法を発明するよりも、小なり演算子をサポートする型を単純に列挙する方がはるかに簡単であることがわかりました。結局のところ、Goでは組み込み型のみが演算子をサポートしています。
この同じアプローチは、任意の演算子に、またはより一般的には、組み込み型で機能するように意図された任意の汎用関数に対して契約を記述するために使用できます。これにより、汎用関数の作成者は、関数が使用されると予想される型のセットを明確に指定できます。これにより、汎用関数の呼び出し元は、関数が使用されている型に適用可能かどうかを明確に確認できます。
実際には、この契約は標準ライブラリに入るでしょう。したがって、Min関数(これもどこかの標準ライブラリに含まれるでしょう)は次のようになります。ここでは、contractsパッケージで定義されている契約Orderedを参照しているだけです。
func Min (type T contracts.Ordered) (a, b T) T {
if a < b {
return a
}
return b
}
汎用データ構造
最後に、単純なジェネリックデータ構造である二分木を見てみましょう。この例では、木は比較関数を持つため、要素型に要件はありません。
type Tree (type E) struct {
root *node(E)
compare func(E, E) int
}
type node (type E) struct {
val E
left, right *node(E)
}
新しい二分木を作成する方法を次に示します。比較関数はNew関数に渡されます。
func New (type E) (cmp func(E, E) int) *Tree(E) {
return &Tree(E){compare: cmp}
}
非公開メソッドは、vを保持するスロット、またはツリー内でvが挿入されるべき場所へのポインタを返します。
func (t *Tree(E)) find(v E) **node(E) {
pn := &t.root
for *pn != nil {
switch cmp := t.compare(v, (*pn).val); {
case cmp < 0:
pn = &(*pn).left
case cmp > 0:
pn = &(*pn).right
default:
return pn
}
}
return pn
}
特にこのコードはテストしていないため、ここの詳細は実際には重要ではありません。単に、単純なジェネリックデータ構造を記述する方法を示そうとしているだけです。
これは、ツリーに値が含まれているかどうかをテストするためのコードです。
func (t *Tree(E)) Contains(v E) bool {
return *t.find(e) != nil
}
これは新しい値を挿入するためのコードです。
func (t *Tree(E)) Insert(v E) bool {
pn := t.find(v)
if *pn != nil {
return false
}
*pn = &node(E){val: v}
return true
}
node型が型引数Eを持つことに注目してください。これが汎用データ構造を記述するときの様子です。ご覧のとおり、一般的なGoコードを記述するのと同様に見えますが、一部の型引数が散りばめられています。
ツリーの使用は非常に簡単です。
var intTree = tree.New(func(a, b int) int { return a - b })
func InsertAndCheck(v int) {
intTree.Insert(v)
if !intTree.Contains(v) {
log.Fatalf("%d not found after insertion", v)
}
}
それがそうであるべきです。汎用データ構造を記述するのは少し難しいです。なぜなら、サポートする型に対して型引数を明示的に記述しなければならないことがよくあるからです。しかし、可能な限り、汎用データ構造を使用することは、通常の非汎用データ構造を使用することと変わりありません。
次のステップ
この設計を実験できるように、現在実際の実装に取り組んでいます。作成したい種類のプログラムを記述できることを確認するために、設計を実際に試すことができることが重要です。期待どおりには速く進んでいませんが、これらの実装が利用可能になり次第、詳細をお知らせします。
Robert Griesemerは、go/typesパッケージを変更する予備的なCLを記述しました。これにより、ジェネリクスとコントラクトを使用するコードが型チェックできるかどうかをテストできます。現在は不完全ですが、ほとんどの単一パッケージで機能し、今後も作業を続けていきます。
この実装および今後の実装で人々に試していただきたいのは、ジェネリックコードを記述して使用し、何が起こるかを確認することです。人々が必要なコードを記述し、期待どおりにそれを使用できることを確認したいと考えています。もちろん、最初からすべてが機能するわけではなく、この分野を探求する中で変更が必要になるかもしれません。そして、明確にしておくと、構文の詳細よりもセマンティクスに関するフィードバックにずっと関心があります。
以前の設計にコメントしてくださった皆様、そしてGoでジェネリクスがどのようなものになるかについて議論してくださった皆様に感謝いたします。私たちはすべてのコメントを読み、皆さんがこの作業に費やされた努力に深く感謝しています。その努力なしには、今日の私たちはここにいませんでした。
私たちの目標は、私が今日議論したような汎用コードを記述できるようにする設計に到達することです。ただし、言語が複雑すぎて使いにくいものになったり、Goらしくなくなったりすることなく、という条件付きです。この設計がその目標への一歩となることを願っており、私たちの経験と皆さんの経験から、何が機能し、何が機能しないかを学びながら、調整を続けていくことを期待しています。もしその目標に到達できれば、将来のGoバージョンに提案できるものができるでしょう。
次の記事:実験、簡素化、リリース
前の記事:新しいGoストアの発表
ブログインデックス