Go言語がダメな理由

私はGo言語が気に入っていますし、多くの場面で使用します。現にこのブログもGoで書いています。Goは便利な言語ですが、優れた言語とは言えません。つまり、悪くはないけれど、十分ではないということです。

満足できない言語を使用する際は注意が必要です。注意を怠ると、その言語を次の20年間使い続ける羽目になるかもしれないからです。

私のGoに対する主な不満を本文にまとめました。既に何度も指摘されていることも含まれていますが、中にはこれまでほとんど話題になっていない指摘もあります。

これから列挙する全ての課題には既に解決策があることを示すため、私が優良な言語と考えるRustやHaskellと比較して説明します。

汎用プログラミング

課題

誰でもさまざまな事柄に幅広く対応できるコードを記述したいと考えます。例えば数のリストの合計を求めるために定義した関数が、小数、整数、またその他の合計を求められるもの全てに適用できれば便利です。もし、そのコードが型安全性を維持し、整数や小数のリストを加算する別々の関数もスピーディーに記述できれば上出来です。

良い解決策:制約ベースの汎用プログラミングとパラメータポリモーフィズム

私が今ある中で最高だと思う汎用プログラミングは、Rust並びにHaskellで用いられるシステムで、しばしば”型制約”のシステムと言われます。このシステムは、Haskellでは「型クラス」、Rustでは「トレイト」と呼ばれています。以下の例をご覧ください。

(Rust、バージョン0.11)

fn id<T>(item: T) -> T { 
  item 
}

(Haskell)

id :: t -> t 
id a = a

この簡単な例では、汎用関数idを定義しました。何らかの値を取り、同じ値を返す関数です。この関数が便利なのは、idがある特定の型だけでなく、あらゆるものに適応するということです。Rust並びにHaskellでは、idは渡されるあらゆる変数の型情報を保存し、静的型安全性も維持できる上に、汎用プログラミングによる余分なランタイム・オーバーヘッドも発生しません。clone関数を記述する際には、まさにこのような使い方が想定できるでしょう。

また、このコードは汎用データ構造を生成する時にも使えます。以下の例をご覧ください。

(Rust)

struct Stack<T>{ 
  items: Vec<T> 
}

(Haskell)

data Stack t = Stack [t]

この場合も、完全な静的型安全性が得られ、汎用プログラミングのランタイム・オーバーヘッドも発生しません。

さて、引数に何らかの処理を行う汎用関数を記述する時には、コンパイラにどうにかして「この処理を適用できる引数にのみ関数を作用させなさい」と伝える必要があるでしょう。例えば、3つの引数を加算し合計を返す関数を生成する場合は、この関数に対する引数は全て加算可能でなければならないことをコンパイラに伝えます。以下の例を見てください。

(Rust)

fn add3<T:Num>(a:T, b:T, c:T)->T{ 
  a + b + c 
}

(Haskell)

add3 :: Num t => t -> t -> t -> t 
add3 a b c = a + b + c

ここでコンパイラに伝えているのは、「add3に対する引数は、どんな型tにもなれるが、tをNum(数値型)に限定する制約がある」ということです。コンパイラはNumが加算可能だと分かっているため、このコードは型チェックをパスします。これらの制約はデータ構造定義でも使用できます。この簡潔かつ洗練された方法で、100パーセント型安全で柔軟な汎用プログラミングが実現できるのです。

Goの解決策:interface{}

平凡の域を出ないGoの型システムは、汎用プログラミングへの対応力が非常に乏しくなっています。

汎用関数の記述はとても簡単です。例えばハッシュ可能なオブジェクトのハッシュコードを出力する関数を記述したければ、静的型安全性を保証しつつ、それを実行できるインターフェースを定義できます。以下の例をご覧ください。

(Go)

type Hashable interface { 
  Hash() []byte
}

func printHash(item Hashable) {
  fmt.Println(item.Hash())
}

これでどんなHashableオブジェクトもprintHashに与えることができ、また静的型チェックも行われるため便利です。

では、汎用データ構造を記述する場合にはどうすればよいでしょうか? 単純な連結リストを記述してみましょう。慣用的なやり方でGoを使って汎用データ構造を記述すると以下のようになります。

(Go)

type LinkedList struct {
  value interface{}
  next *LinkedList
}

func (oldNode *LinkedList) prepend(value interface{}) *LinkedList {
  return &LinkedList{value, oldNode}
}

func tail(value interface{}) *LinkedList {
  return &LinkedList{value, nil}
}

func traverse(ll *LinkedList) {
  if ll == nil {
    return
   }
  fmt.Println(ll.value)
  traverse(ll.next)
}

func main() {
  node := tail(5).prepend(6).prepend(7)
  traverse(node)
}

何かに気がつきましたか? valueの型がinterface{}になっています。interface{}は、「トップタイプ」と言われ、他の全ての型はinterface{}のサブタイプになります。これは、JavaのObjectとほぼ同じです。ビックリですよね。(注:Goはサブタイプの存在を認めていないため、果たしてGoにトップタイプが存在するのかについては異論もありますが、とにかくこの例えは有効です。)

Goで汎用データ構造を構築する”正しい”やり方は、値をトップタイプにキャストしてからデータ構造に格納する方法で、2004年頃にはJavaでも使われていました。しかしその後、この方法は型システムの目的を台無しにしてしまうことが判明しました。このようなデータ構造では、型システムがもたらす恩恵を全く得られません。例えば、以下のコードは完全に有効です。

node := tail(5).prepend("Hello").prepend([]byte{1,2,3,4})

しかしこのコードは、きちんと構造化されたプログラムにおいては全く使い物になりません。整数の連結リストを想定するかもしれませんが、景気づけにコーヒーを飲みながら締め切りと闘う疲れたプログラマがそのうち誤ってどこかにストリングを格納してしまうでしょう。というのも、Goの汎用データ構造は値の型を認識しないため、Goのコンパイラはプログラマを訂正してくれないのです。よって、interface{}からダウンキャストしようとすると、プログラムは単純にクラッシュしてしまいます。

この問題は、リストやマップ、グラフ、ツリー、キューなどのあらゆる汎用データ構造に当てはまります。

言語拡張性

課題

高級言語では、大抵キーワードや記号を用いて比較的複雑なタスクを簡略化できます。例えば多くの言語には、配列のようなデータコレクションに含まれる全ての要素を反復処理するための略記法があります。

(Java)

for (String name : names) { ... }

(Python)

for name in names: ...

配列のように言語に組み込まれた型に限らず、あらゆる型のコレクションの処理に対してシンタックスが使えたら便利です。

また、型に対して加算のような処理を定義でき、以下のようなことが可能になれば使い勝手がよいでしょう。

(Python)

point3 = point1 + point2

良い解決策:演算子は関数である

良い解決策は、組み込み演算子をある名前の関数やメソッドに対応させ、標準の関数やメソッドにキーワード・エイリアスを設定することです。

PythonやRust、Haskellのような言語では、演算子を多重定義できます。クラスの関数を定義するだけで、”+”のような所定の演算子を使うたびにあらかじめ定義した関数が呼び出されます。Pythonでは、演算子”+”はadd()に相当します。Rustの場合、”+”はadd()関数によってAddトレイトに定義されます。Haskellでは、”+”はNum型クラスの(+)関数に相当します。

多くの言語において、例えばfor-eachループのようにさまざまなキーワードを拡張する方法があります。Haskellにはループがありませんが、RustやJava、Pythonなどの言語には一般的に”イテレータ”のような概念が存在し、あらゆるコレクションデータ構造に対してfor-eachループのような使い方ができます。

不都合があるとすれば、演算子に実に非直感的な処理を定義してしまう可能性が挙げられるでしょう。例えばよく分かっていないコーダなら、2つのベクトルに”-“の処理を定義してドット積にしてしまうかもしれません。しかし、この問題は演算子の多重定義に限ったことではありません。どんなプログラミング言語を使っても、内容が分かりにくい名前の関数を記述してしまう可能性は常にあるからです。

Goの解決策:なし

Goは演算子の多重定義もキーワードの拡張性もサポートしていません。

それでは、ツリーや連結リストのような構造にrangeキーワードを実装したい場合はどうすればよいでしょうか? 残念ながら、それはGoでは実行できません。Goは組み込み演算子でしかrangeできないのです。makeキーワードについても同様で、スペースの割り当てと組み込みデータ構造の初期化にしか使用できません。

柔軟なiteratorキーワードに最も近いのは、データ構造にラッパを構築し、chanを返してからchanを反復処理するやり方ですが、スピードが遅く複雑でバグも発生しやすくなります。

この対応を正当化するGoチームの言い分は、「Goは非常に分かりやすい言語で、ページ上で読めるコードが実際に実行されるコード」です。つまり、もしGoでrangeのような拡張を可能にすれば、特定のrangeメカニズムの実装詳細が明確でない可能性があるため、混乱が生じる恐れがあるということです。私にとってこの議論はほとんど意味がありません。というのも、Goがデータ構造の反復処理を簡潔かつ明瞭にできてもできなくても、その処理をする羽目になるからです。range()関数の中に実装詳細を隠す代わりに、他のヘルパー関数に実装詳細を隠さざるを得なくなります。これでは大きな改善は望めません。優れたコードは一様に読みやすく、不出来なコードは読みにくいことがほとんどです。Goがそれを変えられないのは明らかです。

基本ケースおよびエラー状態

課題

連結リストやツリーのように再帰的なデータ構造に取り組む時には、何らかの方法でデータ構造の最後尾にたどり着いたことを示したいものです。

また、エラーが起きるかもしれない関数やデータの一部が欠けているデータ構造を扱う場合には、何らかのエラー状態に陥ったことを示す方法が必要です。

Goの解決策:Nil(および複数の戻り値)

ここでは文脈を考慮して、まずGoの解決策を取り上げ、その後でそれより優れた解決策について述べます。

Goにはヌルポインタ(nil)があります。白紙状態の新しい言語に、余計なバグを誘発するこの機能が再実装されると、いつも残念に思います。

ヌルポインタにはバグだらけの歴史があります。歴史的および実用的な理由から、有益なデータがメモリアドレス0x0に格納されることはほとんどないので、0x0へのポインタは大抵、何らかの特別な状態を表すために用いられます。例えば、ポインタを返す関数では、エラーの場合0x0を返すことがあります。再帰的データ構造では、(ツリー構造の葉や連結リストの最後尾のように)0x0を使って基本ケースを表す可能性があります。Goでもそういった使い方をします。

しかし、そのような方法でヌルポインタを使うのは危険です。ヌルポインタは、本質的に型システムで保証されないバックドアであり、ヌルポインタによって実際の型とは全く違う型のインスタンスを作成することができます。ポインタがヌルの可能性があり、(一番マシな場合でも)クラッシュを引き起こし、(最悪の場合)攻撃に利用されかねない脆弱性につながる可能性があることを考慮するのをプログラマがうっかり忘れるのは非常によくあることです。ヌルポインタは型システムをダメにしてしまうため、コンパイラはこれを簡単には防げません。

Goの名誉のために言っておくと、関数がエラーになる可能性がある場合、Goの複数の戻り値を返す仕組みを活用して2番目の”エラー”値を返すのは慣用的に正しい(かつ推奨されている)ことです。しかし、この仕組みは簡単に無視されたり誤用されたりするので、再帰的データ構造で基本ケースを表すのに大抵は役に立ちません。

良い解決策:代数的データ型と型安全なエラーモード

型システムに反してエラー状態と基本ケースを表すのではなく、型システムを使って安全にこれらのケースをカプセル化することができます。

仮に連結リスト型を構築したいとしましょう。可能性のあるケースを2つ表したいとします。連結リストの最後尾ではなくて連結リスト要素がそれにつながるデータを持っているケースと、連結リストの最後尾のケースの2つです。型安全な方法でこれを行うには、両方のケースを表す別々の型を持ち、(代数的データ型を使って)それらを組み合わせて1つの型にします。例えば、つながっているデータを持つ連結リスト要素を表すConsという型と、連結リストの最後尾を表すEndがあるとしたら、次のように書くことができます。

(Rust)

enum List<T> { 
  Cons(T, Box<List<T>>),
  End
}
let my_list = Cons(1, box Cons(2, box Cons(3, box End)));

(Haskell)

data List t = End | Cons t (List t)
let my_list = Cons 1 (Cons 2 (Cons 3 End))

それぞれの型は、データ構造で動作しているあらゆる再帰アルゴリズムのための基本ケース(End)を指定します。RustとHaskellはどちらもヌルポインタを使えないので、ヌルポインタの参照先を取得しようとしてエラーになるバグが発生することは(本当にバカで低レベルなことをしないかぎり)決してないと100パーセント分かっています。

また、これらの代数的データ型は、(後述の)パターンマッチングのような手法を通して、信じられないほど簡潔な(その結果、明らかにバグのない)コードを実現します。

さて、関数が型のようなものを返す場合と返さない場合のどちらもあり得る場合や、データ構造があるデータを含む場合と含まない場合のどちらもあり得る場合、どうすればいいでしょうか? つまり、どうすれば型システムでエラー状態をカプセル化できるでしょうか? これを解決するために、RustにはOptionというものがあり、HaskellにはMaybeと呼ばれるものがあります。

空ではない文字列の配列を調べて”H”で始まる文字列を探し、それが存在すれば該当する1つ目の文字列を返し、存在しなければ何らかのエラー状態を返す関数を想像してみてください。Goでは、エラー時にnilを返すかもしれません。RustやHaskellで、危険なポインタを使わず、安全にこれを行う方法は、以下のとおりです。

(Rust)

fn search<'a>(strings: &'a[String]) -> Option<&'a str>{
  for string in strings.iter() {
    if string.as_slice()[0] == 'H' as u8 {
      return Some(string.as_slice());
    }
  }
  None
}

(Haskell)

search [] = Nothing
search (x:xs) = if (head x) == 'H' then Just x else search xs

文字列やヌルポインタを返す代わりに、文字列を含む場合と含まない場合のどちらもあり得るオブジェクトを返します。ヌルポインタは決して返らないので、search()を使うプログラマは成功と失敗のどちらの可能性もある(その型がそう言っているので)ということを理解して、両方のケースに備えなくてはなりません。ヌルポインタのバグよ、さようなら。

型推論

課題

プログラムで1つ1つの値の型を指定するのは少し古い場合があります。次のように、型の値が明白なことがあるのです。

int x = 5 
y = x*2

明らかにyもint型であるべきです。これが同様に真である、もっと複雑な状況があります。例えば、引数の型に基づいて関数の戻り値の型を推論する場合(またはその逆の場合)です。

良い解決策:一般的な型推論

RustもHaskellもヒンドリー・ミルナー型システムを使っているため、どちらも型推論を非常に得意としており、下記のような素晴らしいことができます。

(Haskell)

map :: (a -> b) -> [a] -> [b] 
let doubleNums nums = map (*2) nums 
doubleNums :: Num t => [t] -> [t] 

関数(*2)はNumを取得してNumを返すので、Haskellはaとbの型がNumであると推測でき、そのため、この関数がNumのリストを取得してそれを返すものだということが推論できます。これはGoやC++などの言語でサポートされている単純な型推論よりもはるかに有用です。これなら明示的な型宣言は最小限で済み、かなり複雑なプログラム構造だったとしても、コンパイラが残りを正確に埋めることができます。

Goの解決策:「:=」

Goは代入演算子”:=”をサポートしており、このように機能します。

(Go)

foo := bar() 

これはただ、bar()の戻り値の型を見て、そこにfooの型を設定するというだけのものです。以下のコードとほぼ同じことです。

(C++)

auto foo = bar();

これは大して興味深いものではありません。bar()の戻り値の型を手動で探す数秒間の手間と、fooの型宣言のために何文字かを打つ手間が省ける程度です。

不変性

課題

不変性は、値の生成の際に一度その値を設定したら二度と変更できないというものです。不変性のメリットは明らかです。値が一定である場合、同じデータ構造を複数の箇所で使用しているのに1箇所だけデータ構造が変更されることによって生じる1つ1つのバグが、解消されるのです。
また、不変性は特定の種類の最適化にも役立ちます。

良い解決策:デフォルトで不変に設定する

プログラマは可能な限り不変なデータ構造を用いるよう努力すべきです。不変性は副作用やプログラムの安全性の診断をはるかに容易にし、クラス全体のバグを防ぎます。

Haskellでは、全ての値が不変です。データ構造を修正したい場合は、正しい変更を加えた全く新しいデータ構造を作成しなければなりません。それでもデフォルトで不変設定にすると非常に速いです。これはHaskellが遅延評価と永続的なデータ構造を採用しているためです。Rustはシステムプログラミングを行う言語なので、遅延評価を用いることができません。Haskellと違って、不変性が常に実用的であるとは言えないということです。そのためRustは、デフォルトでは変数は不変ですが、必要な時には変更が可能となっています。これは素晴らしいことです。これによってプログラマは必然的に扱う変数を変更可能にする必要があるか否かをその都度考えるようになり、良いプログラミングが習慣化され、またコンパイラによる最適化の改善にもつながります。

Goの解決策:なし

Goは不変性の宣言をサポートしていません。

制御フロー構造

課題

制御フロー構造はプログラミング言語とアセンブリを分けるものです。これによって、私たちは抽象的なパターンを使い、整合性の取れたやり方でプログラムの実行を命令することができるのです。間違いなく、全てのプログラミング言語が何かしらの制御フロー構造をサポートしています。していなければ、私たちはその言語を使わないでしょう。しかし、Goには私が欲しい制御フロー構造がいくつか欠けているのです。

良い解決策:パターンマッチングと複合式

パターンマッチングは、データ構造や値を扱うのに大変良い手段です。case/switch文のさらに優れたものという感じです。値のパターンマッチングは以下のように行います。

(Rust)

match x {
  0 | 1 => action_1(), 
  2 .. 9 => action_2(), 
  _ => action_3() 
};

そして、データ構造をこのようにデコンストラクトできます。

(Rust)

deg_kelvin = match temperature { 
  Celsius(t) => t + 273.15, 
  Fahrenheit(t) => (t - 32)/1.8 + 273.15 
};

先の例で示しているのは、「複合式」とも呼ばれるものです。CやGoなどの言語では、if文やcase/switch文はプログラムのフローにただ指示を出し、値を評価しません。RustやHaskellなどの言語では、if文やパターンマッチングは値の評価が可能で、その値を代入できます。別の言い方をすると、if/else文が実際に値を”返す”ことができるということです。これがif文の例です。

(Haskell)

x = if (y == "foo") then 1 else 2

Goの解決策:C言語形式の取るに足らない文

私は、ここでGoを不当に扱うことはしたくありません。Goは並列処理のselectのように特定の項目には良い制御フローの基本形を持っています。しかし、複合式やパターンマッチングといった、私が気に入っているものがないのです。Goはx := 5x := foo()のようなアトミック式からの代入しかサポートしていません。

組み込みプログラミング

組み込みシステムのプログラムを書くのと、フル機能のオペレーションシステムを搭載したコンピュータのプログラムを書くのとでは全く異なります。組み込みプログラミングの特殊な要求に対応する一部の言語が、他よりもはるかに高い適性を持っています。
私は、少なからぬ人々がGoをロボットのプログラミングに用いることを推奨していることに困惑しています。Goは複数の理由から、組み込みシステムのプログラミングには適していません。ここではGoを批判しているわけではありません。Goは組み込みシステム用に設計されたのではないのです。これは、Goを組み込みプログラミングに推奨している人々への批判です。

部分的課題1:ヒープと動的割り当て

ヒープはランタイムに生成された任意の数のオブジェクトを記憶するのに使用できるメモリの領域です。このヒープの使用を、「動的割り当て」と呼びます。
一般的には、組み込みシステムでのヒープメモリの使用は賢明ではないとされています。実用面での最も大きな理由としては、ヒープには通常かなりのオーバーヘッドがかかり、またいくらか複雑なデータ構造を管理することが求められるためで、それでは2KBのRAMを搭載した8MHzのマイクロコントローラでプログラミングする時には困るのです。

また、ヒープをリアルタイムシステム(演算に時間がかかりすぎると落ちてしまう可能性のあるシステム)で使用するのも賢明ではありません。なぜならヒープでメモリの割り当てと解放をするのに要する時間は、非確定的だからです。例えば、あなたのマイクロコントローラでロケットのエンジンを制御しているとして、ヒープのスペースを割り当てようとしたらいつもより何百ミリ秒か長くかかってしまい、そのせいでバルブの開閉のタイミングがずれて大爆発が起こったら最悪ですよね。

動的割り当てが効果的な組み込みプログラムに役立たないのには、他にも理由があります。例えば、ヒープを用いる多くの言語はガベージコレクションを行っており、これは通常プログラムを少しの間停止して、ヒープに使用されなくなったオブジェクトを探し、それを削除する働きをするものです。これを行うと、単純なヒープの割り当ての場合よりもさらに所要時間が読めなくなる傾向があります。

良い解決策:動的割り当てを選択制にする

Rustにはboxなどのようにヒープに依存した標準ライブラリの機能が複数あります。しかし、Rustはコンパイラディレクティブを持ち、ヒープ使用の言語の機能を無効にし、これらの機能が使用されていないことを静的に検証することができます。ヒープを使わずにRustのプログラムを書く方が、どう考えても実用的なのです。

Goの解決策:なし

Goは動的割り当ての使用に大きく依存しています。Goのコードではスタックメモリのみを使用するという制約の方法は実質的にありません。これはGo自体の問題ではありません。Goの意図された使用範囲で使う分には全く問題ないのですから。

また、Goはリアルタイム言語でもありません。それなりに複雑なGoのプログラムですと、実行時間について厳密な保証をするのは、一般的には不可能です。少し混乱を招きそうなので、分かりやすく言いましょう。Goの処理は比較的速いですが、リアルタイムではありません。処理が速いこととリアルタイムであることは全然違います。組み込みプログラミングにとって速いのは良いことですが、しかし本当に重要なのは何かをするのに要する最大の時間を保証できなければならないということです。しかしGoを使う場合、その保証は簡単なことではありません。これはGoがヒープメモリとガベージコレクションの使用に大きく依存していることが大きな理由です。

全く同じ問題が、Haskellなどの言語にも当てはまります。Haskellも同様にヒープを多く使いますから、リアルタイムや組み込みプログラミングに適してはいません。ただ、私はHaskellをロボット工学に推奨する人をこれまで見たことがありませんので、この点を指摘する必要もありませんでした。

部分的課題2:危険な低級コード

組み込みプログラミングを行うとなると、どうしても危険なコード(危険なキャストやポインタ算術演算を行うコード)を書かなければならないという状況は、ほぼ避けられないでしょう。CやC++では、危険なコードを書くことが簡単にできます。例えば、メモリアドレス0x1234に0xFFと書いてLEDを点灯させなければならないとしましょう。これが普通にできるのです。

(C/C++)

*(uint8_t*)0x1234 = 0xFF; 

これは非常に危険で、低級システムのプログラミングでしか意味をなしません。そのため、GoやHaskellではこのようなことを簡単にはできません。どちらもシステムプログラミングを行う言語ではないからです。

良い解決策:危険なコードの分離

Rustは安全性とシステムプログラミングの両方に焦点を当てており、うまく両立させる方法を持っています。それがunsafeコードブロックです。unsafeコードブロックによって、危険なコードを安全なコードから明示的に分離できます。以下はRustでメモリアドレス0x1234に0xFFを書く方法です。

(Rust)

unsafe{ 
  *(0x1234 as *mut u8) = 0xFF; 
}

もしこれをunsafeコードブロックの外でやろうとすると、コンパイラが警告を発します。unsafeブロックは、可能な限りコードの安全性を保ちながら、組み込みプログラミングには嫌でも必要となる危険な演算を、可能にしてくれるものなのです。

Goの解決策:なし

Goはこのようなことを想定していませんので、サポートは組み込まれていません。

まとめ

ここまで読んで、皆さんはこう思っているかもしれませんね。「それで、なぜGoが良くないんだ? ただ不満を並べただけではないか。どんな言語にも不満はあるだろう」と。確かにそうです。完璧な言語などありません。しかし、私が不満を述べることで、Goについて、以下の点を皆さんに知ってほしいのです。

  • Goは何も新しいことをしない。
  • Goは全てにおいてうまく設計されているとは言えない。
  • Goは他のプログラミング言語から退化したものである。

Go言語のロゴ、マスコットはRenée Frenchさんの手によるものです。これらはCreative Commons Attribution 3.0 Unported Licenseで保護されています。