Goブログ
なぜジェネリクスが必要なのか?
はじめに¶
これは、先週のGophercon 2019での私の講演のブログ記事版です。
この記事では、Goにジェネリクスを追加することの意味と、なぜそうすべきだと私が考えているかについて説明します。また、Goにジェネリクスを追加するための可能な設計の更新についても触れます。
Goは2009年11月10日にリリースされました。24時間も経たないうちに、ジェネリクスに関する最初のコメントが投稿されました。(そのコメントでは例外についても言及しており、これは2010年初頭にpanic
とrecover
という形で言語に追加しました。)
3年間のGoアンケートでは、ジェネリクスの欠如が常に言語で修正すべき上位3つの問題の1つとして挙げられてきました。
なぜジェネリクスが必要なのか?¶
しかし、ジェネリクスを追加することとはどういう意味なのでしょうか?そして、なぜそれが必要なのでしょうか?
Jazayeriらを言い換えると、ジェネリックプログラミングは、型を分離して、関数やデータ構造をジェネリック形式で表現できるようにします。
それはどういう意味でしょうか?
簡単な例として、スライスの要素を反転させたいと仮定しましょう。多くのプログラムでそれを行う必要はありませんが、それほど珍しいことでもありません。
それがintのスライスだとしましょう。
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に慣れていない人が驚くことの1つは、任意の型のスライスで機能する単純なReverse
関数を書く方法がないということです。
ほとんどの他の言語では、そのような関数を記述できます。
PythonやJavaScriptのような動的型付け言語では、要素の型を指定することなく、関数を単純に記述できます。Goは静的型付けであるため、スライスの正確な型とスライス要素の型を記述する必要があるため、これはGoでは機能しません。
C++、Java、Rust、Swiftのような他のほとんどの静的型付け言語は、まさにこの種の問題に対処するためにジェネリクスをサポートしています。
今日のGoジェネリックプログラミング¶
では、Goではこの種のコードをどのように書くのでしょうか?
Goでは、インターフェース型を使用し、渡したいスライス型にメソッドを定義することで、異なるスライス型で機能する単一の関数を記述できます。これは、標準ライブラリのsort.Sort
関数がどのように機能するかです。
言い換えれば、Goのインターフェース型はジェネリックプログラミングの一形態です。インターフェース型を使用すると、異なる型の共通の側面をキャプチャし、それらをメソッドとして表現できます。次に、これらのインターフェース型を使用する関数を記述できます。これらの関数は、それらのメソッドを実装する任意の型で機能します。
しかし、このアプローチは私たちが望むものには及びません。インターフェースを使用すると、メソッドを自分で記述する必要があります。スライスを反転させるためだけに、2つのメソッドを持つ名前付き型を定義する必要があるのは面倒です。また、記述するメソッドは各スライス型でまったく同じであるため、ある意味、重複コードを移動して凝縮しただけであり、排除したわけではありません。インターフェースはジェネリクスの1つの形式ですが、ジェネリクスに期待するすべてが得られるわけではありません。
ジェネリクスにインターフェースを使用する別の方法は、メソッドを自分で記述する必要を回避するために、言語にいくつかの種類の型のメソッドを定義させることです。これは今日言語がサポートしているものではありませんが、たとえば、言語はすべてのスライス型に要素を返すIndexメソッドがあることを定義できます。しかし、実際にそのメソッドを使用するためには、空のインターフェース型を返す必要があり、静的型付けのすべての利点が失われます。さらに微妙なことに、同じ要素型を持つ2つの異なるスライスを取る、またはある要素型のマップを取り、同じ要素型のスライスを返すジェネリック関数を定義する方法はありません。Goは、大規模なプログラムを簡単に記述できるようにするために静的型付け言語です。ジェネリクスのメリットを得るために静的型付けのメリットを失いたくはありません。
別の方法としては、reflectパッケージを使用してジェネリックなReverse
関数を記述することが考えられますが、これは記述が非常に面倒で実行に時間がかかるため、それを行う人はほとんどいません。このアプローチでは、明示的な型アサーションも必要であり、静的な型チェックはありません。
または、型を受け取り、その型のスライスのReverse
関数を生成するコードジェネレーターを記述することもできます。まさにそれを行うコードジェネレーターがいくつかあります。しかし、これにより、Reverse
を必要とするすべてのパッケージにもう1つの手順が追加され、すべての異なるコピーをコンパイルする必要があるため、ビルドが複雑になり、マスターソースのバグを修正するには、すべてのインスタンスを再生成する必要があります。その一部はまったく別のプロジェクトにある場合があります。
これらのアプローチはすべて面倒なので、Goでスライスを反転させる必要があるほとんどの人は、必要な特定のスライス型に対して関数を記述するだけだと思います。次に、関数が最初に私が犯したような単純な間違いを犯していないことを確認するために、関数のテストケースを記述する必要があります。そして、それらのテストを定期的に実行する必要があります。
どのように行うにしても、要素の型を除いてまったく同じに見える関数のためだけに、多くの余分な作業を意味します。できないわけではありません。明らかにできますし、Goプログラマーも行っています。ただ、もっと良い方法があるはずです。
Goのような静的型付け言語にとって、そのより良い方法はジェネリクスです。私が以前に書いたように、ジェネリックプログラミングは、型を分離して、関数やデータ構造をジェネリック形式で表現できるようにします。それはまさに私たちがここで望んでいることです。
ジェネリクスがGoにもたらすもの¶
Goのジェネリクスから私たちが望む最初で最も重要なことは、スライスの要素の型を気にせずにReverse
のような関数を記述できるようにすることです。要素の型を分離したいのです。そうすれば、関数を一度記述し、テストを一度記述し、go-gettableパッケージに入れて、必要なときにいつでも呼び出すことができます。
さらに良いことに、これはオープンソースの世界なので、他の人がReverse
を一度記述すれば、私たちはその実装を使用できます。
この時点で、「ジェネリクス」は非常に多くの異なる意味を持つ可能性があることを述べておく必要があります。この記事で、「ジェネリクス」が意味するのは、私が今説明したことです。特に、C++言語にあるテンプレートを意味するものではありません。テンプレートはここで私が書いたよりもはるかに多くのことをサポートします。
Reverse
について詳しく説明しましたが、他にもジェネリックに記述できる関数はたくさんあります。例えば:
- スライス内の最小/最大要素を見つける
- スライスの平均/標準偏差を見つける
- マップの和集合/積集合を計算する
- ノード/エッジグラフで最短経路を見つける
- 変換関数をスライス/マップに適用し、新しいスライス/マップを返す
これらの例は、他のほとんどの言語で利用可能です。実際、私はC++標準テンプレートライブラリを見てこのリストを作成しました。
Goの同時実行性を強力にサポートしている例もあります。
- タイムアウト付きでチャネルから読み取る
- 2つのチャネルを1つのチャネルに結合する
- 関数のリストを並行して呼び出し、結果のスライスを返す
- Contextを使用して関数のリストを呼び出し、最初に完了した関数の結果を返し、余分なgoroutineをキャンセルおよびクリーンアップする
これらの関数はすべて、異なる型で何度も記述されているのを見てきました。Goで記述するのは難しいことではありません。しかし、任意の値型で機能する効率的でデバッグ済みの実装を再利用できると便利です。
明確にするために、これらは単なる例です。ジェネリクスを使用することで、より簡単かつ安全に記述できる汎用関数は他にもたくさんあります。
また、以前に書いたように、それは関数だけではありません。データ構造でもあります。
Goには、言語に組み込まれている2つの汎用ジェネリックデータ構造、スライスとマップがあります。スライスとマップは、保存および取得される値の静的型チェックを使用して、任意のデータ型の値を保持できます。値はインターフェース型としてではなく、そのまま保存されます。つまり、[]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
がstring
または[]byte
のいずれかである場合に、シーケンスs
内のb
のインデックスを返します。この単一のジェネリック関数を使用して、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つのNodes
を返すNodes
メソッドが必要であることを述べています。
実装は省略しましたが、これはGraph
を返すNew
関数のシグネチャと、Graph
のShortestPath
メソッドのシグネチャを示しています。
ここで重要なのは、コントラクトが単一の型だけに関するものではないということです。2つ以上の型間の関係を記述することができます。
順序付けられた型¶
Goに対する驚くほど一般的な不満の1つは、Min
関数がないことです。または、Max
関数も同様です。それは、有用なMin
関数は、順序付けられた任意の型で動作する必要があるため、ジェネリックである必要があるからです。
Min
は自分で記述するのが非常に簡単ですが、有用なジェネリクス実装を使用すると、標準ライブラリに追加できるはずです。これが設計における見た目です。
func Min (type T Ordered) (a, b T) T {
if a < b {
return a
}
return b
}
Ordered
コントラクトは、型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を保持するスロット、またはそれが配置されるべき木の中の位置のいずれかへのポインタを返します。
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 Storeの発表
ブログインデックス