Go at Google: Language Design in the Service of Software Engineeringの日本語版です。
(本記事は、2012年10月25日にアリゾナ州ツーソンで開催されたSPLASH 2012カンファレンスでRob Pikeが行った基調講演を修正したものです。)
プログラミング言語Goは、2007年末にGoogleでソフトウェアインフラストラクチャを開発する際に直面したいくつかの問題に対する答えとして考え出されたものです。今日のコンピューティングの状況は、主にC++、Java、Pythonといった使用されている言語が作られた環境とはほとんど無関係になっています。マルチコアプロセッサ、ネットワークシステム、大規模な計算クラスター、Webプログラミングモデルによってもたらされた問題は、正面から取り組むのではなく、回避する方向に進んでいました。さらに、規模も変わりました。今日のサーバープログラムは、数千万行のコードから成り、数百人、数千人のプログラマによって作業され、文字通り毎日更新されています。さらに悪いことに、大規模なコンパイルクラスタであっても、ビルド時間は数分、数時間にまで伸びています。
Goはこのような環境での作業をより生産的にするために設計、開発されました。組み込みの並行処理やガベージコレクションといったよく知られた点以外にも、Goの設計では、厳密な依存関係の管理、システムの成長に合わせたソフトウェアアーキテクチャの適応性、コンポーネント間の境界を越えた堅牢性などが考慮されています。
この記事では、これらの課題を解決しながらも、軽量で快適な効率的なコンパイル型プログラミング言語を構築した方法を紹介します。例として、Googleで実際に直面した問題を取り上げて解説します。
Goは、Googleで開発されたコンパイル可能な並列処理、ガベージコレクション、静的型付け言語です。オープンソースのプロジェクトで、Googleが公開リポジトリをインポートしているのであって、その逆ではありません。
Goは効率的で、スケーラブルで、生産的です。Goで働くことを楽しいと感じるプログラマもいれば、想像力に欠け、退屈だとさえ感じるプログラマもいます。この記事では、それらが矛盾した立場でない理由を説明します。Goは、Googleのソフトウェア開発で直面した問題を解決するために設計されました。その結果、画期的な研究言語ではありませんが、それでも大規模ソフトウェアプロジェクトをエンジニアリングするための優れたツールである言語が生まれました。
Goは、GoogleがGoogleの問題を解決するために設計したプログラミング言語であり、Googleは大きな問題を抱えています。
ハードウェアもソフトウェアも巨大です。何百万行ものソフトウェアがあり、サーバーのほとんどはC++で、その他の部分にはJavaやPythonが多く使われています。数千人のエンジニアが、すべてのソフトウェアを構成する1つのツリーの「ヘッド」でコードに取り組んでいるため、日々、ツリーの全レベルに大きな変更が加えられているのです。大規模なカスタム設計の分散ビルドシステムによって、この規模の開発は実現可能ですが、それでもまだ大きいです。
もちろん、これらのソフトウェアは何十億台ものマシンで動作しており、それらはネットワークでつながった独立した適度な数の計算クラスタとして扱われています。
つまり、Googleの開発は大きく、遅く、不器用であることが多いのです。しかし、それは有効ではあります。
Goプロジェクトの目標は、Googleのソフトウェア開発の遅さと不器用さを解消し、そのプロセスをより生産的でスケーラブルなものにすることでした。この言語は、大規模なソフトウェアシステムを書き、読み、デバッグし、保守する人々によって、またその人々のために設計されました。
したがって、Goの目的はプログラミング言語設計の研究ではなく、設計者とその同僚の作業環境を改善することにあるのです。Goはプログラミング言語の研究というより、ソフトウェアエンジニアリングのためのものです。言い換えれば、ソフトウェアエンジニアリングのための言語設計なのです。
しかし、言語がどのようにソフトウェアエンジニアリングに役立つのでしょうか。この記事の続きは、その質問への答えです。
Goが公開されたとき、モダンな言語の必須条件とされる特定の機能や方法論が欠落していると主張する人がいました。このような機能がないのに、どうしてGoに価値があるのでしょうか。それに対する私たちの答えは、Goが持つ特性は、大規模なソフトウェア開発を困難にする問題に対処するものである、ということです。その問題とは次のようなものです。
- 遅いビルド
- 無秩序な依存関係
- 各プログラマが異なる言語のサブセットを使用している
- プログラムの理解度が低い(コードが読みにくい、ドキュメント化が不十分、等々)
- 労力の重複
- 更新のコスト
- バージョンのずれ
- 自動化ツールの作成が困難
- 異なる言語でのビルド
言語の個々の機能では、これらの問題に対処できません。ソフトウェアエンジニアリングの大局的な視点が必要であり、Goの設計では、これらの問題に対する解決策に焦点を当てるように努めました。
シンプルで自己完結した例として、プログラム構造の表現について考えてみましょう。Goの中括弧を使ったC言語のようなブロック構造に異議を唱え、PythonやHaskellのような空白を使ったインデントを好む人もいます。しかし、私たちは、たとえばSWIGの呼び出しによって他の言語に埋め込まれたPythonのスニペットが、周囲のコードのインデントを変更することによって微妙にそして人知れず壊れてしまうような、言語横断ビルドによって引き起こされるビルドとテストの失敗をたくさん追跡してきました。したがって、私たちの立場は、インデント用のスペースは小さなプログラムには良いですが、うまくスケールしませんし、コードベースが大きく不均質であればあるほど、より多くの問題を引き起こす可能性があるということです。安全性と信頼性のためには利便性を犠牲にしたほうがよいので、Goには中括弧で囲まれたブロックがあります。
パッケージの依存性を扱うことで、スケーリングやその他の問題をより実質的に説明することができます。まず、CとC++でどのように機能するかの復習から議論を始めます。
1989年に初めて標準化されたANSI Cでは、標準のヘッダーファイルに#ifndef
の「ガード」という考えを推進しました。これは、各ヘッダファイルを条件付きコンパイル句で括り、そのファイルがエラーなしに複数回含まれるようにするというもので、現在では広く普及しています。たとえば、Unixのヘッダーファイル<sys/stat.h>
は、概略的には次のようなものです。
/* Large copyright and licensing notice */
#ifndef _SYS_STAT_H_
#define _SYS_STAT_H_
/* Types and other definitions */
#endif
これは、Cプリプロセッサがファイルを読み込んでも、2回目以降の読み込みではその内容を無視することを意図しています。シンボル_SYS_STAT_H_
は、ファイルを最初に読み込んだときに定義され、その後の呼び出しを「ガード」しています。
このデザインにはいくつかの良い特性があり、最も重要なのは、各ヘッダーファイルが、他のヘッダーファイルもインクルードするとしても、すべての依存関係を安全に#include
できることです。このルールに従えば、たとえば#include
句をアルファベット順に並べるような、整然としたコード記述が可能になります。
しかし、それはあまりスケールしません。
1984年、Unixのps
コマンドのソースであるps.c
をコンパイルすると、すべての前処理が終わるまでに37回<sys/stat.h>を#includeすることが確認されました。その間に36回内容を破棄しているにもかかわらず、ほとんどのC実装では、ファイルを開き、読み、37回すべてスキャンしてしまうのです。実際、巧妙でなければ、Cプリプロセッサの潜在的に複雑なマクロのセマンティクスによってこのような動作が要求されるのです。
ソフトウェアへの影響としては、C言語のプログラムに#include
句が徐々に蓄積されていくことが挙げられます。インクルードを追加してもプログラムは壊れないですし、いつインクルードが不要になるかを知るのは非常に難しいです。ある#include
を削除して再度プログラムをコンパイルしても、別の#include
がそれ自体を含んでいて、いずれそれを取り込むかもしれないので、それをテストするのに十分でもありません。
技術的に言えば、そうである必要はありません。#ifndef
ガードの使用による長期的な問題を認識していたPlan 9ライブラリの設計者は、異なる非ANSI標準のアプローチを取りました。Plan 9では、ヘッダーファイルにこれ以上#include
句を入れることを禁止し、すべての#include
をトップレベルのCファイルに入れることを義務付けました。もちろん、プログラマは必要な依存関係を一度だけ正しい順序でリストアップしなければならないので、ある程度の規律は必要ですが、ドキュメント化することで助けられ、実際には非常にうまくいきました。その結果、C言語のソースファイルにどれだけ多くの依存関係があったとしても、そのファイルをコンパイルする際には、それぞれの#include
ファイルが一度だけ読まれることになりました。もちろん、#include
が必要かどうかは、それを削除することで簡単に確認することができました。
Plan 9のアプローチで最も重要な結果は、コンパイルの高速化です。#ifndef
ガードを持つライブラリを使ってプログラムをコンパイルする場合と比べて、コンパイルに必要なI/Oの量が劇的に少なくなる可能性があります。
しかし、Plan9以外では、「ガード付き」アプローチはCとC++で受け入れられているプラクティスです。実際、C++は同じアプローチをより細かい粒度で使用することにより、問題を悪化させています。慣習上、C++プログラムは通常1クラスにつき1つのヘッダーファイル、あるいは関連するクラスの小さなセット、たとえば<stdio.h>
よりもはるかに小さなグループ分けで構成されています。したがって、依存関係ツリーは、ライブラリ依存ではなく、完全な型階層を反映したより複雑なものとなっています。さらに、C++のヘッダーファイルには、Cのヘッダーファイルによくある単純な定数や関数シグネチャだけでなく、実際のコード、メソッド、テンプレートの宣言が含まれているのが一般的です。このように、C++はコンパイラに多くのものを押し付けるだけでなく、押し付けられたものはコンパイルしにくく、コンパイラを起動するたびにこの情報を再処理しなければなりません。大きなC++バイナリをビルドするとき、コンパイラはヘッダーファイル<string>
を処理することによって、文字列をどのように表現するかを何千回も教えられるかもしれません。(ちなみに1984年頃、Tom Cargill氏は依存関係の管理にCプリプロセッサを使うことはC++にとって長期的な負債となり、対処すべきであると指摘していました)。
Googleの1つのC++バイナリの構築は、何百もの個別のヘッダーファイルを何万回も開いて読み取る可能性があります。2007年、Googleのビルドエンジニアは、Googleの主要なバイナリのコンパイルを測定しました。そのファイルには約2000のファイルが含まれており、単純に連結すると合計4.2メガバイトになります。#include
が展開されるまでに、8ギガバイト以上がコンパイラの入力に送られ、C++のソース1バイトに対して2000バイトが吹き飛ぶことになりました。
別の事例として、2003年にGoogleのビルドシステムが単一のMakefileからディレクトリ単位のデザインに移行されてより良く管理され、より明確な依存関係を持つようになりました。典型的なバイナリは、より正確な依存関係が記録されるだけでファイルサイズが約40%縮小されました。それでも、C++(あるいはC言語)の特性上、依存関係を自動的に検証することは現実的ではなく、今日でも大規模なGoogle C++バイナリの依存性要件について正確な理解は得られていません。
これらの制御不能な依存関係と大規模化の結果、Googleサーバーのバイナリを一台のコンピュータで構築することは非現実的であるため、大規模な分散コンパイルシステムが作成されました。このシステムは、多くのマシン、多くのキャッシュ、そして多くの複雑さ(ビルドシステムはそれ自体が大きなプログラムです)を伴います。Googleでのビルドは、依然として煩雑であるとしても実用的なものです。
分散ビルドシステムを使っても、大規模なGoogleのビルドには何分もかかることがあります。2007年のバイナリは、先駆的な分散型ビルドシステムを使用して45分かかりました。現在の同プログラムのバージョンでは27分かかりますが、もちろんその間にプログラムとその依存関係は拡大してきました。ビルドシステムを拡張するために必要なエンジニアリングの努力は、構築するソフトウェアの成長にかろうじて先行することができる程度です。
ビルドが遅いと、考える時間があります。Goの起源神話によると、Goが考案されたのは、この45分間のビルドのうちの1回だったと言われています。ウェブサーバーのような大規模なGoogleプログラムを書くのに適した新しい言語を、Googleプログラマの生活の質を向上させるソフトウェアエンジニアリング的配慮をもって設計することは、試みる価値があると信じられていました。
ここまでの議論は依存関係に焦点を当てたものでしたが、他にも注意を払うべき問題が多く存在します。この文脈で成功するためには、どのような言語であっても、主に次のようなことを考慮する必要があります。
- 大規模なプログラム、多数の依存関係、大規模なプログラマチームに対してスケールする必要があります。
- おおよそC言語風の親しみやすいものでなければなりません。Googleで働くプログラマはキャリアが浅く、手続き型言語、特にC言語系に最も慣れています。新しい言語でプログラマを早く生産的にする必要があるため、その言語はあまり過激であってはならないのです。
- モダンなものでなければなりません。C、C++、そしてJavaもある程度は、マルチコアマシン、ネットワーキング、ウェブアプリケーション開発などの出現以前に設計された、かなり古いものです。ビルトインの並行処理など、より新しいアプローチで対応した方が良い現代社会の特徴があります。
このような背景から、Goの設計をソフトウェアエンジニアリングの観点から見てみましょう。
CとC++の依存関係を詳しく見てきたので、この旅を始めるのにはGoが依存関係をどのように扱うかを見るのがよいところでしょう。依存関係は、構文的にもセマンティクス的にも言語によって定義されます。依存関係は明示的で、明確で、「計算可能」です。つまり、分析するためのツールを書くのが簡単なのです。
構文は、package
句(次のセクションの主題)の後に置き、各ソースファイルに1つまたは複数のimport
文を持ちます。import
キーワードとソースファイルにインポートされるパッケージを識別する文字列定数からなります。
import "encoding/json"
Goを依存関係の面でスケールさせるための最初のステップは、使用しない依存関係はコンパイル時のエラーになると言語が定義していることです (警告ではなく、エラーです)。ソースファイルが使用していないパッケージをインポートすると、プログラムはコンパイルされません。これは、Goプログラムの依存関係ツリーが正確で、余計なエッジがないことを構成上保証しています。その結果、プログラムをビルドするときに余分なコードがコンパイルされないことが保証され、コンパイル時間が短縮されます。
もうひとつ、今度はコンパイラの実装で、さらに効率性を保証するステップがあります。3つのパッケージとこの依存関係グラフを持つGoプログラムを考えてみましょう。
- パッケージAはパッケージBをインポートしています。
- パッケージBはパッケージCをインポートしています。
- パッケージAはパッケージCをインポートしていません。
つまり、パッケージAはBの使用を通じてのみCを使用します。つまり、AがBから使用している項目の一部がCを参照していても、C由来の識別子がAのソースコードで参照されていません。たとえば、パッケージAはCで定義されたフィールドをもつBで定義されたstruct
型を参照するかもしれませんが、A自身がCを参照しているわけではありません。動機となる例として、AはCが提供するバッファされたI/O実装を使用するフォーマットされたI/OパッケージBをインポートしていても、A自身はバッファされたI/Oを呼び出さないというのを想像してみてください。
このプログラムをビルドするには、まずCをコンパイルします。依存されるパッケージは、それに依存するパッケージよりも先にビルドする必要があります。次にBをコンパイルし、最後にAをコンパイルして、プログラムをリンクすることができます。
Aをコンパイルするとき、コンパイラはそのソースコードではなく、Bのオブジェクトファイルを読みます。そのB用のオブジェクトファイルには、コンパイラがA用のソースコードでつぎの句を実行するために必要な型情報がすべて含まれています。
import B
この情報には、Bのクライアントがコンパイル時に必要とするCに関するあらゆる情報が含まれています。言い換えれば、Bがコンパイルされるとき、生成されるオブジェクトファイルには、Bのパブリックインターフェースに影響を与えるBのすべての依存関係のための型情報が含まれます。
この設計には、コンパイラがimport句を実行するときに、import句の文字列で識別されるオブジェクトファイルを正確に1つだけ開くという重要な効果があります。Goソースファイルがコンパイルされるときにコンパイラがヘッダーファイルを書き込むという点を除けば、Plan 9 C(ANSI Cとは異なる)の依存関係管理のアプローチを思い起こさせます。このプロセスはPlan 9 C よりも自動的で、さらに効率的です。インポートを評価するときに読み込まれるデータは、一般的なプログラムソースコードではなく、単なる「エクスポートされた」データです。コンパイル時間全体に対する効果は絶大で、コードベースが大きくなっても十分にスケールします。依存関係グラフを実行する時間、つまりコンパイルにかかる時間は、CやC++の「インクルードファイルのインクルード」モデルよりも指数関数的に短くすることができます。
この依存関係管理の一般的なアプローチはオリジナルではありません。アイデアは1970年代に遡り、Modula-2やAdaのような言語の系譜をたどっているのは言及に値します。C言語系ではJavaがこのアプローチの要素を持っています。
コンパイルをさらに効率的にするために、オブジェクトファイルはエクスポートデータがファイルの最初に来るように配置されているので、コンパイラはそのセクションの終わりに到達するとすぐに読み込みを停止することができます。
この依存関係管理のアプローチが、GoのコンパイルがCやC++のコンパイルより高速である唯一最大の理由です。もうひとつの要因は、Goがエクスポートデータをオブジェクトファイルに配置することです。いくつかの言語では、作者がその情報を記述するか、コンパイラが2つ目のファイルを生成する必要があります。これは、開くべきファイルの数が2倍になることを意味します。Goでは、パッケージをインポートするために開くべきファイルは1つだけです。また、1つのファイルというアプローチは、エクスポートデータ(C/C++ではヘッダーファイル)がオブジェクトファイルに対して古くなることがないことを意味します。
参考までに、Goで書かれた大規模なGoogleのプログラムのコンパイルを測定し、ソースコードのファンアウトが先に行ったC++の解析と比較してどうであったかを確認したことがあります。その結果、約40Xで、C++の50倍高性能であることがわかりましたが(単純であるため処理速度も速い)、それでも予想より時間がかかってしまっていることがわかりました。その理由は2つあります。1つ目は、バグを発見したことです。Goコンパイラは、エクスポートセクションに、そこにある必要のない相当量のデータを生成していたのです。2つ目は、エクスポートデータが冗長なエンコーディングを使用しているため、改善できる可能性があることです。これらの問題については、今後対処する予定です。
それでも、1/50になるということは、1分が1秒になり、コーヒーブレイクがインタラクティブなビルドになるのです。
Goの依存関係グラフのもう一つの特徴は、循環がないことです。Go言語では、グラフに循環インポートが存在してはならないと定義されており、コンパイラとリンカの両方が循環インポートが存在しないことをチェックします。循環インポートは便利な場合もありますが、規模が大きくなると重大な問題を引き起こします。コンパイラがより大きなソースファイルのセットを一度に処理する必要があり、インクリメンタルビルドの速度が低下します。さらに重要なことは、私たちの経験では、循環インポートが許可されると、ソースツリーの大部分を独立して管理するのが難しい大きな破片に絡め取ってしまい、バイナリを肥大化させ、初期化、テスト、リファクタリング、リリース、およびソフトウェア開発の他のタスクを複雑にしてしまうことです。
循環インポートがないと時々不便ですが、ツリーをきれいに保ち、パッケージ間の境界をはっきりさせることができます。Goの多くの設計上の決定と同様に、これはプログラマにより大規模な問題(この場合はパッケージの境界)について早めに考えさせるもので、後回しにすると満足のいく対処ができなくなる可能性があります。
標準ライブラリの設計を通じて、依存関係を制御することに多大な努力が払われました。1つの機能のために大きなライブラリを引っ張ってくるより、ちょっとしたコードをコピーする方が良い場合があります。(システムビルドのテストは、新しいコアの依存関係が発生した場合文句を言います。)依存関係の管理は、コードの再利用に優先します。実際の例としては、(低レベルの)net
パッケージが、より大きく依存性の高いフォーマットされたI/Oパッケージに依存しないように、整数から10進への独自の変換ルーチンを持っていることが挙げられます。もうひとつは、文字列変換パッケージのstrconv
が、大きなUnicode文字クラステーブルを利用する代わりに、「プリント可能」文字の定義のプライベートな実装を持っていることです。strconv
がUnicode標準を尊重していることは、パッケージのテストによって検証されています。
Goのパッケージシステムの設計は、ライブラリ、名前空間、モジュールの特性のいくつかを組み合わせて、ひとつの構成にしています。
すべてのGoソースファイル、たとえば 「encoding/json/json.go
」は、以下のようにパッケージ句で始まっています。
package json
ここでjson
は「パッケージ名」、つまり単純な識別子です。パッケージ名は通常簡潔です。
パッケージを使用するために、インポートソースファイルはimport
句でそのパッケージのパスを指定します。「パス」の意味はGoによって定義されていませんが、実際、慣例としてリポジトリ内のソースパッケージのディレクトリパスをスラッシュで区切ったものです。
import "encoding/json"
そして、パッケージ名(パスとは異なる)は、インポートソースファイルでパッケージからの項目を特定するために使用されます。
var dec = json.NewDecoder(reader)
このデザインは明快です。名前がパッケージに対してローカルかどうかは、常にその構文から判断することができます。Name
vs. pkg.Name
。(これについては後で詳しく説明します)
この例では、パッケージのパスは「encoding/json
」で、パッケージ名はjson
です。標準的なリポジトリ以外では、プロジェクト名や会社名を名前空間のルートに配置するのが慣例となっています。
import "google/base/go/log"
パッケージのパスは一意であることを認識することが重要ですが、パッケージ名にはそのような要件はありません。パスはインポートされるパッケージを一意に識別する必要がありますが、名前はパッケージのクライアントがその内容を参照するための慣習にすぎません。パッケージ名は一意である必要はなく、import
句でローカル識別子を指定することで、インポートする各ソースファイルで上書きすることができます。これらの2つのインポートは両方ともlog
というパッケージを参照していますが、1つのソースファイルでそれらをインポートするために、1つは(ローカルで)名前を変更しなければなりません。
import "log" // Standard package
import googlelog "google/base/go/log" // Google-specific package
log
パッケージは各社各様かもしれませんが、パッケージ名をユニークにする必要はありません。全く逆です。Goスタイルでは、衝突を心配するよりパッケージ名を短く、明確に、明白にしておくことを推奨しています。
別の例として、Googleのコードベースには多くのserver
パッケージがあります。
Goのパッケージシステムの重要な特性は、パッケージパスが一般に任意の文字列であり、リポジトリを提供するサイトのURLを指定することによってリモートリポジトリを参照するために使用できることです。
ここでは、github
にあるdoozer
パッケージの使い方を説明します。go get
コマンドはgo build
ツールを使ってサイトからリポジトリを取得し、インストールします。インストール後は、通常のパッケージと同様にインポートして使用することができます。
$ go get github.com/4ad/doozer // パッケージを取得するシェルコマンド
import "github.com/4ad/doozer" // doozerクライアントのimport文
var client doozer.Conn // クライアントのパッケージ利用
go get
コマンドは依存関係を再帰的にダウンロードしますが、これは依存関係が明示されているからこそ可能な性質であることは特筆に値します。また、インポートパスの空間の割り当てはURLに委ねられるため、パッケージの命名が分散され、他の言語で使用される集中型レジストリとは対照的にスケーラブルになっています。
構文は、プログラミング言語のユーザーインターフェースです。言語のセマンティクス(より重要な要素)に対する影響は限定的ですが、構文は言語の読みやすさと明瞭さの決め手になります。また、構文はツールにとっても重要です。言語の解析が困難な場合、自動化ツールは書きにくいものです。
そのため、Goは分かりやすさとツール化を念頭に置いて設計されており、きれいな構文になっています。C言語系の他の言語と比較すると、文法のサイズは控えめで、キーワードはわずか25個です(C99には37個、C++11には84個あり、その数は増え続けています)。さらに重要なのは、文法が規則的であるため、解析が容易であるという点です(大部分はそうですが、修正できたかもしれないのに早期に発見できなかったクセもいくつかあります)。 CやJava、特にC++とは異なり、Goは型情報やシンボルテーブルなしで解析できます。文法は推論しやすいので、ツールを書くのも簡単です。
C言語のプログラマーが驚くGoの構文の詳細の1つは、宣言の構文がC言語よりもPascalに近いということです。宣言された名前は型の前に表示され、キーワードもより多いです。
var fn func([]int) int
type T struct { a, b int }
それに対してCはこうです。
int (*fn)(int[]);
struct T { int a, b; }
キーワードで導入された宣言は、人間にとってもコンピュータにとっても解析しやすく、C言語のように型の構文が式の構文にならないことは、解析に大きな効果をもたらします。文法は追加されますが、あいまいさは解消されます。しかし、よい副次的な効果もあります。初期化宣言の場合、var
キーワードを削除して、変数の型を式の型から取り出すだけでよいです。この2つの宣言は同等であり、後者の方がより短く、より慣用的です。
var buf *bytes.Buffer = bytes.NewBuffer(x) // 明示
buf := bytes.NewBuffer(x) // 派生
Goの宣言の構文と、なぜそれがCとそんなに違うのかについての詳細は、golang.org/decl-syntaxにブログ記事があります。
関数の構文は、単純な関数の場合素直です。この例では、T
型の変数x
を1つ受け取り、float64
の値を1つ返す関数Abs
を宣言しています。
func Abs(x T) float64
メソッドとは、関数に特別なパラメータであるレシーバを付けただけのもので、標準的な「ドット」記法を使って関数に渡すことができます。メソッド宣言の構文では、関数名の前に括弧でレシーバを囲みます。以下は、同じ関数をT
型のメソッドとして宣言したものです。
func (x T) Abs() float64
そして、ここにT
型の引数を持つ変数(クロージャ)があります。Goには一級関数とクロージャがあります。
negAbs := func(x T) float64 { return -Abs(x) }
最後に、Goでは関数は複数の値を返すことができます。よくあるのは、このように関数の結果とエラーの値をペアで返すケースです。
func ReadByte() (c byte, err error)
c, err := ReadByte()
if err != nil { ... }
エラーについては後で詳しく説明します。
Goに欠けている機能のひとつに、デフォルトの関数引数をサポートしていないことがあります。これは意図的に単純化したものです。経験上、デフォルトの引数を使うと、APIの設計上の欠陥を、引数を追加することで簡単に修正できてしまいます。結果として、分離や理解さえ難しい相互作用を持つ引数が多くなりすぎてしまいます。デフォルトの引数がない分、より多くの関数やメソッドを定義する必要があります。1つの関数でインターフェース全体を保持することはできないからです。これらの関数もすべて別々の名前が必要で、どのような組み合わせが存在するかが明確になり、明快さと読みやすさの重要な側面である命名について、より深く考えることができるようになります。
デフォルトの引数がないことの緩和策の1つは、Goには使いやすい型安全な可変長引数関数のサポートがあることです。
Goは識別子の可視性、つまりパッケージのクライアントが識別子で指定された項目を使用できることを定義するのに変わったアプローチをとっています。たとえばprivate
やpublic
といったキーワードとは異なり、Goでは名前自体が情報を持っています。識別子の最初の文字の大文字小文字で可視性が決まります。識別子の頭文字が大文字の場合、その識別子はエクスポートされ、そうでない場合はエクスポートされません。
- 大文字の頭文字:
Name
はパッケージのクライアントから見える - そうでない場合:
name
(または_Name
) はパッケージのクライアントから見えない
このルールは、変数、型、関数、メソッド、定数、フィールドなど全てに適用されます。それがすべてです。
これは簡単な設計上の決定ではありませんでした。識別子の可視性を指定するための表記法を定義するのに1年以上悩みました。しかし、名前の大文字と小文字を使い分けることに決めた途端、この言語にとって最も重要な特性のひとつになったことに気づきました。型ではなく名前で可視性を指定することで、識別子を見たときに、それがパブリックAPIの一部であるかどうかが常に明確になるのです。Goをしばらく使っていると、この情報を発見するために宣言を調べる必要がある他の言語に戻ったときに、負担を感じるようになります。
その結果、プログラムのソーステキストは、プログラマの意図をシンプルに表現することができるようになりました。
もう一つの単純化は、Goのスコープ階層が非常にコンパクトであることです。
- ユニバース (intやstringなどの事前定義された識別子)
- パッケージ (パッケージのすべてのソースファイルが同じスコープにあります)
- ファイル (パッケージのインポートの名前を変更するためだけで、実際にはあまり重要ではありません)
- 関数 (通常)
- ブロック (通常)
名前空間、クラス、その他のラッピング構造は存在しません。ソース内の任意の場所で、識別子はその使われ方に関係なく、正確に一つの言語オブジェクトを表します。(唯一の例外はステートメントラベル、break
文のターゲットなどで、これらは常に関数スコープを持っています)。
これは明瞭性に影響します。たとえば、メソッドは明示的にレシーバを宣言し、その型のフィールドやメソッドにアクセスするために使用しなければならないことに注意してください。暗黙的には行われません。つまり、常につぎのように書く必要があります。
rcvr.Field
(ここで、rcvrはレシーバ変数に選ばれた任意の名前です) そのため、型のすべての要素は常にレシーバの型の値に字句的にバインドされているように見えるのです。同様に、インポートされた名前には常にパッケージ修飾子が付きます。たとえば、io.Reader
はReader
ではなく、io.Reader
と書きます。これは明確であるだけでなく、Reader
という識別子をどのパッケージでも使える便利な名前として解放しています。実際、標準ライブラリにはReader
やPrintf
といった名前のエクスポートされた識別子が複数存在しますが、どれが参照されているかは常に曖昧ではありません。
最後に、これらの規則を組み合わせることで、int
のようなトップレベルの定義済みの名前を除いて、(最初のコンポーネントの)すべての名前は常に現在のパッケージで宣言されることが保証されます。
つまり、名前はローカルなものです。C、C++、Javaでは、y
という名前は何でも参照することができます。Goでは、y
(あるいは Y
)は常にパッケージ内で定義され、x.Y
の解釈は明確です。x
をローカルで見つけると、Y
はそれに属します。
これらのルールは、パッケージにエクスポートされた名前を追加してもそのパッケージのクライアントが壊れないことを保証するため、スケールに重要な特性を提供します。命名規則によってパッケージが切り離され、拡張性、明確性、堅牢性がもたらされるのです。
もう一つ命名規則について触れておくと、メソッドの検索は常に名前だけで、シグネチャ(型)には依存しません。言い換えれば、一つの型が同じ名前のメソッドを二つ持つことはありえません。x.M
というメソッドがある場合、x
に関連するM
は1つだけです。この場合も、名前だけでどのメソッドが参照されているかを簡単に特定することができます。また、メソッド呼び出しの実装もシンプルになります。
Goの文のセマンティクスは、概してC言語的です。コンパイルされ、静的型付けされ、ポインタなどを持つ手続き型言語です。設計上、C言語系の言語に慣れたプログラマには馴染みやすいはずです。GoをC言語ファミリーに根付かせることで、JavaやJavaScript、そしておそらくC言語を知っている若いプログラマが、Goを簡単に学べるようにすることができるのです。
そうは言っても、GoはC言語のセマンティクスに多くの小さな変更を加えており、そのほとんどは堅牢性を高めるためのものです。以下がその例です。
- ポインタ演算がない
- 暗黙の数値変換がない
- 配列の境界は常にチェックされる
- 型の別名はない(
X int
型の後、X
とint
は別個の型であり、別名はない) ++
と--
は式ではなく文である- 代入は式ではない
- スタック変数のアドレスを取得することは合法(推奨される)
- その他多数
また、従来のC、C++、さらにはJavaのモデルから大きく踏み込んだ変更もあります。これには、以下の言語的なサポートが含まれます。
- 並行処理
- ガベージコレクション
- インターフェース型
- リフレクション
- 型スイッチ
以下のセクションでは、Goのこれらのトピックのうちの2つ、並行処理とガベージコレクションについて、主にソフトウェアエンジニアリングの観点から簡単に説明します。言語のセマンティクスと使用法に関する完全な議論については、go.devのウェブサイトにある多くのリソースを参照してください。
並行処理は、マルチコアマシンで複数のクライアントを持つウェブサーバを実行する、典型的なGoogleプログラムと呼ばれるような現代のコンピュータ環境において重要です。この種のソフトウェアは、言語レベルで十分な並行処理のサポートがないC++やJavaでは、特にうまく機能しません。
Goはファーストクラスのチャンネルを持つCSPの変種を具体化したものです。CSPが選ばれた理由は、親しみやすさもありますが(私たちのうちの1人はCSPのアイデアを基にした先行言語に携わっていました)、CSPには手続き型プログラミングのモデルに大きな変更を加えることなく簡単に追加できるという性質があるためです。つまり、C言語風の言語があれば、CSPはほとんど直交する方法でその言語に追加でき、その言語の他の用途を制限することなく表現力を高めることができるのです。つまり、言語の他の部分は「普通」のままでよいのです。
このアプローチは、並行処理に利用しなければ通常の手続き型コードを独立に実行する関数の合成です。
その結果、この言語では、並行処理と計算をスムーズに組み合わせることができます。クライアントの受信呼び出しごとにセキュリティ証明書を検証しなければならないウェブサーバーを考えてみましょう。Goでは、CSPを使ってクライアントを独立に実行する手続きとして管理しながら、高価な暗号計算には効率の良いコンパイル言語のフルパワーを利用できるソフトウェアを簡単に構築することができます。
まとめると、CSPはGoにとってもGoogleにとっても実用的です。Goの正統なプログラムであるWebサーバを書く場合、このモデルは非常に適しています。
Goは並行処理がある場合、純粋にメモリー安全ではありません。共有は妥当であり、チャネル上でポインタを渡すことは慣用的です(そして効率的です)。
並行処理や関数型プログラミングの専門家の中には、Goが並行計算の文脈で値のセマンティクスに一度書けば済むアプローチを取らないこと、GoがたとえばErlangにもっと似ていないことに失望している人もいます。ここでも、その理由は主に親しみやすさと問題領域への適合性にあります。Goの並行処理機能は、ほとんどのプログラマに馴染みのあるコンテキストでうまく機能します。Goはシンプルで安全な並行プログラミングを可能にしますが、悪いプログラミングを禁止するわけではありません。私たちは慣習によって補い、プログラマにメッセージパッシングを所有権管理の一種として考えるよう指導しています。モットーは「メモリを共有して通信するな、通信することでメモリを共有せよ」です。
Goと並行処理プログラミングの両方が初めてのプログラマに対する我々の限られた経験から、これは実用的なアプローチであることがわかりました。プログラマは、並行処理のサポートがネットワークソフトウェアにもたらすシンプルさを楽しみ、シンプルさは堅牢さを生み出します。
システム言語にとって、ガベージコレクションは論争の的になる機能ですが、Goのガベージコレクション利用を決定するのにほとんど時間をかけませんでした。Goには明示的なメモリ解放操作がありません。割り当てられたメモリがプールに戻る唯一の方法は、ガベージコレクタを介することです。
メモリ管理は、言語の実際の動作に大きな影響を与えるため、この決断は簡単でした。CやC++では、メモリの確保と解放に多くのプログラミングの労力が費やされています。その結果、隠せるはずのメモリ管理の詳細が露呈し、逆にメモリを考慮することで使い方が制限される傾向があります。これに対して、ガベージコレクションは、インターフェースを簡単に指定することができます。
さらに、並行実行オブジェクト指向言語では、自動的なメモリ管理がほぼ必須です。なぜなら、メモリの一部の所有権は、並行実行の間で受け渡しされるため、管理が厄介になる可能性があるからです。動作と資源管理を切り離すことが重要なのです。
ガベージコレクションのおかげで、Go言語は遥かに使いやすくなっています。
もちろん、ガベージコレクションには、一般的なオーバーヘッド、レイテンシ、実装の複雑さなど、大きなコストがかかります。それでも、私たちは、主にプログラマが感じる利点は、主に言語実装者が負担するコストを上回ると考えています。
特にサーバー言語としてのJavaの経験から、ユーザー向けのシステムでガベージコレクションを行うことに神経質になっている人がいます。オーバーヘッドは制御不能で、レイテンシは大きくなり、良好なパフォーマンスを得るためには多くのパラメータチューニングが必要です。しかし、Goは違います。言語の特性により、これらの懸念が軽減されます。もちろん、すべてではありませんが。
重要なのは、データ構造のレイアウトを制御することで、割り当てを制限するツールがプログラマに用意されていることです。バイトのバッファ(配列)を含むデータ構造の簡単な型定義について考えてみましょう。
type X struct {
a, b, c int
buf [256]byte
}
Javaでは、buf
フィールドは2回目の割り当てが必要で、それに対するアクセスは2段階目のインダイレクトが必要です。しかし、Goではバッファはそれを含む構造体とともに単一のメモリブロックに割り当てられ、インダイレクトは不要です。システムプログラミングの場合、この設計はコレクタが知っている項目の数を減らすだけでなく、性能も向上させることができます。規模が大きくなれば、これは大きな違いとなります。
より直接的な例として、Goでは2次アロケータを提供するのが簡単で効率的です。たとえば、構造体の大きな配列を割り当てて、それらをフリーリストでリンクするアリーナアロケータがあります。このような小さな構造体をたくさん繰り返し使うライブラリは、適度な事前準備をすれば、ゴミを出さずに効率的で応答性の良いものになります。
Goはガベージコレクション言語ですが、知識のあるプログラマはコレクタにかかる負荷を制限し、それによってパフォーマンスを向上させることができます。(また、Goのインストールには、実行中のプログラムの動的メモリ性能を調査するための優れたツールが付属しています)。
プログラマにこのような柔軟性を与えるために、Goはヒープに割り当てられたオブジェクトへの内部ポインタと呼ばれるものをサポートする必要があります。上の例のX.buf
フィールドは構造体の中にありますが、この内部フィールドのアドレスをキャプチャして、たとえばI/Oルーチンに渡すことは正当なことです。Javaでは、多くのガベージコレクション言語と同様にこのような内部ポインタを構築することはできませんが、Goでは慣例的に可能です。この設計上のポイントは、使用できる収集アルゴリズムに影響し、それらをより難しくするかもしれません。しかし、慎重に考えた結果、プログラマにとっての利点と、(おそらく実装が難しい)コレクタへの負荷を軽減できることから、内部ポインタを認めることが必要であると判断しました。これまでのところ、類似のGoとJavaのプログラムを比較した経験から、内部ポインタの使用は、アリーナの総サイズ、レイテンシ、および収集時間に大きな影響を与える可能性があることが分かっています。
要約すると、Goはガベージコレクションを行いますが、プログラマに収集のオーバーヘッドを制御するツールをいくつか提供します。
ガベージコレクタは現在も活発に開発されています。現在の設計は並列マークアンドスイープ形式のコレクタで、その性能、あるいは設計を改善する機会が残されています(言語仕様では、コレクタの特定の実装を義務づけてはいません)。それでも、プログラマがメモリを賢く使うように気をつければ、現在の実装は実運用で十分に機能します。
Goはオブジェクト指向プログラミングに対して変わったアプローチをとっており、クラスだけでなくあらゆる型に対するメソッドを許可していますが、サブクラス化のような型に基づく継承は一切行っていません。つまり、型階層がないのです。これは意図的な設計上の選択でした。型階層は多くの成功したソフトウェアを構築するために使用されてきましたが、このモデルは乱用されており、いったん手を引く価値があるというのが私たちの意見です。
その代わり、Goにはインターフェースがあります。この考え方は他のところで長く議論されていますが(たとえば、research.swtch.com/interfacesを参照)、ここでは簡単にまとめています。
Goでは、インターフェースは単なるメソッドの集合です。たとえば、標準ライブラリのHash
インターフェースの定義は以下の通りです。
type Hash interface {
Write(p []byte) (n int, err error)
Sum(b []byte) []byte
Reset()
Size() int
BlockSize() int
}
これらのメソッドを実装する全てのデータ型は暗黙のうちにこのインターフェースを満たし、implements
宣言はありません。しかし、インターフェイスを満たすかどうかはコンパイル時に静的にチェックされるので、分離されていてもインターフェースは型安全です。
一つの型は通常多くのインターフェースを満足し、それぞれがメソッドの部分集合に対応します。たとえば、Hash
インターフェースを満たす型はWriter
インターフェースも満たします。
type Writer interface {
Write(p []byte) (n int, err error)
}
このインターフェースの満足に関する流動性は、ソフトウェア構築への異なるアプローチを促します。しかし、それを説明する前に、なぜGoにはサブクラス化がないのかを説明する必要があります。
オブジェクト指向プログラミングは、データの挙動はそのデータの表現とは無関係に一般化できるという強力な洞察を与えてくれます。このモデルは、振る舞い(メソッド集合)が固定されているときに最もよく機能しますが、ひとたび型をサブクラス化してメソッドを追加すると、振る舞いはもはや同一ではなくなります。その代わり、Goの静的定義インターフェースのように振る舞いの集合が固定されていれば、振る舞いの統一性により、データとプログラムを統一的、直交的、かつ安全に構成することができるのです。
極端な例として、Plan 9カーネルでは、すべてのシステムデータ項目が14のメソッドで定義された、ファイルシステムAPIという全く同じインターフェースを実装していました。この統一性により、今日でも他のシステムではめったに達成できないレベルのオブジェクト合成が可能になりました。このような例は枚挙にいとまがありません。たとえば、次のようなものです。あるシステムはTCPスタックをTCPやイーサネットを持たないコンピュータにインポートし、そのネットワークを介して異なるCPUアーキテクチャのマシンに接続し、その/proc
ツリーをインポートし、ローカルデバッガを実行してリモートプロセスのブレークポイントデバッグを行うことができたのです。このような操作は、Plan9では日常的なことであり、特別なことではありませんでした。このようなことを行う機能は設計から抜け落ちており、特別な調整は必要ありませんでした(しかも、すべて素のC言語で行われました)。
私たちは、このようなシステム構築の合成的なスタイルが、型階層による設計を推し進める言語によって無視されてきたと考えます。型階層はもろいコードになるのです。階層構造は早期に、しばしばプログラム設計の最初の段階として設計されなければならず、初期の決定はプログラムが書かれた後に変更することが困難です。その結果、このモデルは早期の過剰設計を助長し、プログラマはソフトウェアが必要とする可能性のあるすべての用途を予測しようとし、念のために型と抽象化の層を追加することになります。これは正反対です。システムの各部分が相互作用する方法は、その成長に合わせて適応させるべきであり、初めから固定されてはならないのです。
そのため、Goは継承よりも合成を推奨しています。単純で、しばしば1メソッドのインターフェースを使用して、コンポーネント間の境界をきれいにし、理解しやすくするために小さな動作を定義します。
上記のWriter
インターフェースは、パッケージio
で定義されています。このシグネチャのWrite
メソッドを持つアイテムは、相補的なReader
インターフェイスとうまく連動しています。
type Reader interface {
Read(p []byte) (n int, err error)
}
この2つの補完的なメソッドにより、一般化されたUnixパイプのような豊かな動作で型安全なチェーン処理が可能になります。ファイル、バッファ、ネットワーク、エンクリプタ、コンプレッサ、画像エンコーダなど、すべて一緒に接続することができます。Fprintf
フォーマットI/Oルーチンは、CのようにFILE*
ではなく、io.Writer
を取ります。書式付きプリンタは、それが何に対して書き込まれているかは知りません。コンプレッサに書き込まれ、次にエンクリプタに書き込まれ、それからネットワーク接続に書き込まれる画像エンコーダかもしれません。
インターフェースの合成は、これまでとは異なるスタイルのプログラミングであり、型階層に慣れた人がこれをうまく行うには考え方を変える必要があります。しかし、その結果、型階層では実現が難しい設計の適応性が得られます。
また、型階層がなくなることで、一種の依存関係階層がなくなることにも注意してください。インターフェースを満足させることで、あらかじめ決められた契約なしに、プログラムを有機的に成長させることができます。インターフェースを変更すると、そのインターフェースのクライアントだけに影響し、更新すべきサブツリーは存在しません。実装宣言がないことは一部の人を悩ませますが、そのおかげでプログラムは自然に、優雅に、安全に成長することができるのです。
Goのインターフェースはプログラム設計に大きな影響を与えます。そのひとつがインターフェースの引数を取る関数の使用です。これはメソッドではなく、関数です。いくつかの例を挙げて、その威力を説明しましょう。ReadAll
はio.Reader
から読み込めるすべてのデータを保持するバイトスライス(配列)を返します。
func ReadAll(r io.Reader) ([]byte, error)
また、インターフェースを受け取ってインターフェースを返す関数であるラッパーも広く使われています.以下にいくつかのプロトタイプを紹介します。LoggingReader
は、受信したReader
のRead
コールをすべてログに記録します。LimitingReader
は、nバイトで読み込みを停止します。ErrorInjector
は、I/Oエラーをシミュレートしてテストを支援します。他にもたくさんあります。
func LoggingReader(r io.Reader) io.Reader
func LimitingReader(r io.Reader, n int64) io.Reader
func ErrorInjector(r io.Reader) io.Reader
その設計は、階層的でサブタイプに継承されたメソッドとは全く異なります。より緩やかで(アドホックでも)、有機的で、非結合で、独立していて、それゆえスケーラブルなのです。
Goには、従来の意味での例外機能はありません。つまり、エラー処理に関連する制御構造はありません。(Goはゼロによる除算のような例外的な状況を処理するメカニズムを提供します。panic
とrecover
と呼ばれる一組の組み込み関数によって、プログラマはそのような事態から保護することができます。しかし、これらの関数は意図的に不格好に作られており、ほとんど使われていませんし、たとえばJavaのライブラリが例外を使うようにライブラリに統合されているわけではありません)。
エラー処理のための重要な言語機能は、error
というあらかじめ定義されたインターフェース型で、Error
メソッドが文字列を返すような値を表します。
type error interface {
Error() string
}
ライブラリでは、error
型を使用してエラーの説明を返します。関数は複数の値を返すことができるので、もしあるならば計算結果をエラー値とともに返すことも簡単です。たとえば、C言語のgetchar
に相当する関数は、EOFで帯域外値を返さず、例外も発生させず、ただ文字と一緒にエラー値を返し、エラー値nil
は成功を意味します。以下は、バッファされたI/Oパッケージのbufio.Reader
型のReadByte
メソッドのシグネチャです。
func (b *Reader) ReadByte() (c byte, err error)
これは明確でシンプルなデザインで、簡単に理解することができます。エラーは単なる値であり、プログラムは他の型の値で計算するのと同じようにエラーで計算します。
Goに例外を組み込まないというのは意図的な選択でした。この決定には多くの批評家が反対していますが、その方がより良いソフトウェアになると私たちが考えるのにはいくつかの理由があります。
第一に、コンピュータプログラムにおけるエラーには、真に例外的なものは何もありません。たとえば、ファイルを開けないというのはよくあることで、特別な言語構成に値するものではありません。
f, err := os.Open(fileName)
if err != nil {
return err
}
また、エラーが特殊な制御構造を使用する場合、エラー処理を行うプログラムでは制御の流れが歪んでしまいます。Java風のtry-catch-finally
ブロックのスタイルは、複雑な方法で相互作用する複数の重なり合った制御の流れを織り交ぜています。これに対してGoは、エラーをチェックするために冗長になりますが、明示的な設計により、制御の流れは文字どおりストレートに保たれます。
結果的にコードが長くなることは間違いありませんが、そのようなコードの明快さと単純さが冗長さを相殺します。明示的なエラーチェックは、エラーが発生したときにプログラマーにエラーについて考えさせ、対処することを強います。例外があると、エラーを処理せずに無視してしまい、問題を修正したり分析したりするのが手遅れになるまでコールスタックの上に責任を転嫁してしまうことになります。
ソフトウェアエンジニアリングはツールを必要とします。どの言語も他の言語やプログラムをコンパイル、編集、デバッグ、プロファイル、テストを実行するための無数のツールとの環境の中で動作しています。
Goの構文、パッケージシステム、命名規則、その他の機能は、ツールを簡単に書けるように設計されており、ライブラリにはこの言語のレキサ、パーサ、型チェッカーが含まれています。
Goのプログラムを操作するツールは非常に書きやすいので、そのようなツールがたくさん作られ、中にはソフトウェアエンジニアリングに興味深い結果をもたらすものもあります。
その中で最もよく知られているのが、Goソースコードフォーマッターであるgofmt
です。プロジェクトの最初から、私たちはGoのプログラムが機械によってフォーマットされることを意図しており、プログラマ間の「私のコードをどのようにレイアウトするか」という議論をすべて排除しています。Gofmt
は私たちが書いたすべてのGoプログラムで実行され、ほとんどのオープンソースコミュニティも使用しています。Gofmt
は、チェックインされたすべてのGoプログラムが同じフォーマットであることを確認するために、コードリポジトリの「サブミット前」チェックとして実行されます。
Gofmt
は言語の一部ではないにもかかわらず、Goの最も優れた機能の1つとしてユーザーによく挙げられています。Gofmt
の存在と使用は、コミュニティが最初からgofmt
でフォーマットされたGoのコードを常に見ていたことを意味し、Goプログラムは今や誰もが知っている単一のスタイルになっています。統一された表示によりコードが読みやすくなり、その結果、作業も速くなります。書式設定に費やされない時間は節約できます。Gofmt
は、スケーラビリティにも影響します。すべてのコードが同じに見えるので、チームで一緒に作業したり、他の人のコードと一緒に作業したりすることが容易になります。
Gofmt
は、私たちがそれほど明確に予見していなかった別の種類のツールを実現しました。このプログラムはソースコードを解析し、解析木そのものから再フォーマットすることで動作します。これによって、フォーマットする前に解析木を編集することが可能になり、一連の自動リファクタリングツールが生まれました。これらは簡単に書くことができ、解析ツリーを直接操作するため意味的に豊かであり、自動的に正規化されたコードを生成します。
最初の例はgofmt
自身の-r
(rewrite)フラグで、簡単なパターンマッチング言語を使って式レベルの書き換えを可能にするものでした。たとえば、ある日スライス式の右辺のデフォルト値として、長さそのものを導入しました。Goのソースツリー全体が、たった1つのコマンドでこのデフォルト値を使うように更新されたとします。
gofmt -r 'a[b:len(a)] -> a[b:]'
この変換の重要な点は、入力と出力が両方とも正規の形式であるため、ソースコードに加えられる変更は意味的なものだけであるということです。
同じような、しかしより複雑な処理により、gofmt
は、文が改行で終わる場合に文の終端記号としてセミコロンを必要としなくなったときに、ツリーを更新するために使用されました。
Gofix
はGo自体で書かれた木構造書き換えモジュールを実行するもので、より高度なリファクタリングが可能なのです。Go 1のリリースに至るまで、gofix
ツールのおかげで、マップからエントリを削除する構文の変更、時間値を操作するための根本的に異なるAPI など、API や言語機能に対する抜本的な変更を行うことができました。このような変更が展開されると、ユーザはシンプルなつぎのコマンドを実行してコードを更新できます。
gofix
これらのツールは、古いコードがまだ動作する場合でもコードを更新できることに注目してください。その結果、Goリポジトリはライブラリの進化に合わせて簡単に最新に保つことができます。古いAPIはすばやく自動的に非推奨にできるので、APIのバージョンは1つだけ維持すればよいのです。たとえば、最近、Goのプロトコルバッファの実装を変更し、以前はインターフェイスになかった「getter
」関数を使用するようにしました。GoogleのすべてのGoコードでgofix
を実行してプロトコルバッファを使用するすべてのプログラムを更新し、今では使用するAPIのバージョンは1つだけになっています。C++やJavaのライブラリに同様の抜本的な変更を加えることは、Googleのコードベースの規模ではほとんど実行不可能です。
標準のGoライブラリに解析パッケージが存在することで、他の多くのツールも利用できるようになりました。たとえば、リモートリポジトリからのパッケージ取得を含むプログラム構築を管理するgo tool
、ライブラリの更新に伴いAPI互換性規約が維持されていることを検証するプログラムgodoc document extractor
など、他にも多くのツールがあります。
このようなツールは言語設計の文脈ではほとんど言及されませんが、言語のエコシステムには不可欠な要素であり、Goがツールを意識して設計されていることは、言語、ライブラリ、コミュニティの発展に大きな影響を与えます。
Goの利用はGoogle内部で拡大しています。
youtube.comやdl.google.com(ChromeやAndroidなどのダウンロードを配信するサーバー)、そして私たちのgo.devなど、ユーザー向けの大きなサービスのいくつかがGoを使用しています。もちろん、多くの小規模なものがそうです。ほとんどは、Google App EngineのGoのネイティブ サポートを使用して構築されています。
他にも多くの企業がGoを使っています。リストは非常に長いのですが、よく知られているものをいくつか紹介します。
- BBC Worldwide
- Canonical
- Heroku
- Nokia
- SoundCloud
Goは、その目標を達成しつつあるように見えます。それでも、成功と断定するのは早計です。特に大規模なプログラム(数百万行のコード)については、スケーラブルな言語を構築する試みが報われたかどうかを知るには、まだ十分な経験がありません。しかし、すべての指標はポジティブです。
もっと小さなスケールで言えば、いくつかのマイナーな点はまったく正しくなく、この言語の後のバージョン(Go 2?)で調整されるかもしれません。たとえば、変数宣言の構文が多すぎたり、プログラマがnil
でないインターフェース内のnil
値の動作に簡単に混乱したり、ライブラリやインターフェースの詳細でもう一度設計する必要があるかもしれないものがたくさんあります。
しかし、gofix
とgofmt
はGoのバージョン1までの間に他の多くの問題を修正する機会を与えてくれたことは注目に値します。そのため、現在のGoは、言語の設計によって実現されたこれらのツールがない場合よりも設計者が望んだものにずっと近いものとなっています。
しかし、すべてが解決されたわけではありません。私たちはまだ学習中です(ただし、言語は今のところ凍結されています)。
この言語の大きな弱点は、実装にまだ工夫が必要なことです。特にコンパイラの生成するコードとランタイムの性能はもっと良くなるはずで、それらについては作業が続いています。実際、いくつかのベンチマークでは、2012年初頭にリリースされたGoバージョン1と比較して、現在の開発版では性能が2倍になったことが示されています。
Goの設計の指針となったのは、ソフトウェアエンジニアリングです。Goは、ほとんどの汎用プログラミング言語よりも、私たちが大規模なサーバーソフトウェアの構築で直面したソフトウェアエンジニアリングの問題に対処するために設計されました。一見すると、Goは退屈で堅苦しい言語に感じるかもしれません。しかし、実際には、設計を通じて明確さ、シンプルさ、統合可能性に焦点を当てた結果、多くのプログラマーが表現力豊かで強力だと感じる、生産的で楽しい言語が誕生したのです。
それをもたらした特性は以下の通りです。
- 明確な依存関係
- 明確な構文
- 明確なセマンティクス
- 継承より合成
- プログラミングモデルによる単純化 (ガベージコレクション、並行処理)
- 簡単なツール(
go tool
、gofmt
、godoc
、gofix
)
もし、まだGoを試していないのであれば、ぜひ試してみてください。