GolangをJavaと比べてみた~Java愛好家がGoの機能を見たときの第一印象~

  • このエントリーをはてなブックマークに追加

   

GolangをJavaと比べてみた~Java愛好家がGoの機能を見たときの第一印象~

最初に断っておきたいのだが、私はGoのエキスパートではない。2~3週間前にGoを勉強し始めたばかりなので、ここで述べていることは私のGoに対する第一印象のようなものだ。この記事の主観的な部分には、間違っているところもあるかもしれない。これについては多分、後にレビューを書くつもりだ。しかし、その時まではここに書いておく。もしあなたがJavaプログラマなら、私の気持ちや経験をぜひ見てもらいたいし、もし間違っている記述があった場合は、コメント、訂正ともに大歓迎だ。

Golangは素晴らしい

Javaとは対照的にGoは機械語にコンパイルされ、直接実行される。C言語とほぼ同じだ。VMマシンではないため、Javaとは大きく異なる。Goはオブジェクト指向、かつ、ある程度機能的でもあるので、単に自動ガベージコレクション機能の付いた新しいC言語というわけではない。プログラミング言語の世界が直線状だと仮定するなら(実際はそうじゃないけれど)、GoはCとC++の中間あたりに位置すると言えるだろう。一人のJavaプログラマの視点からすると、GoはJavaと大きく異なる部分があって、学びがいがある。そして、プログラミングの言語構造や、オブジェクトやクラスがどのようなものなのか、理解を一層深めてくれるかもしれない…Javaにおいてでさえも。

つまり、オブジェクト指向がGoでどのように実装されているかが理解できれば、Javaがなぜそれを違う方法で実現しているのかという理由も理解できるかもしれない。

じれったいと思っただろうか。要するに、この言語の一見おかしな構造に取り乱さないようにすることだ。Goを学んでみれば、たとえGoで開発されるプロジェクトがなくても、あなたの知識と理解を深めてくれるだろう。

GCと非GC

メモリ管理はプログラミング言語において極めて重要だ。アセンブリでは、すべてのことを行うことができる。いやむしろ、すべて行う必要がある。Cでは、標準ライブラリにサポート機能があるが、それでもmalloc関数を呼び出す前に割り当てたメモリを全て開放するかどうかは、あなた次第だ。C++、Python、Swift、Javaではどこかで自動化されたメモリ管理が開始される。Golangもこのカテゴリに属する。

PythonとSwiftでは、参照カウントが使われている。オブジェクトへの参照があるとき、オブジェクト自体が指し示す参照数を数えるカウンターを保持する。それまでにポインタや参照はなくても、新たな参照によって値が取得されオブジェクトが参照され始めるとカウンターは増え、参照がnullやnilになったり、他のオブジェクトを参照したりするとカウンターは減る。つまり、カウンターがゼロのときは、オブジェクトへの参照はなく、破棄することができる。この方法の問題は、カウンターが正数なのに、オブジェクトが到達不能である可能性があるということだ。互いに参照し合っているオブジェクト・サークルがあり、このサークル内の最後のオブジェクトが静的かつローカル、または到達可能な参照から放たれた場合、サークルがメモリ内を水中の泡のように浮き始める。つまり、カウンターはすべて正数でも、オブジェクトは到達不可能ということだ。Swiftのチュートリアルで、この挙動とその避け方について、非常に良い説明がされている。だが、論点はまだある。メモリ管理にやや気を配らなければならないのだ。

Javaやその他JVM言語(PythonのJVM実装を含む)の場合、メモリはJVMによって管理される。そこには本格的なガベージコレクションがあり、1つまたは複数のスレッドで実行すると同時に、スレッドを動かしたり、時に到達不能オブジェクトをマークしてスレッドを停止させたり(別名stop the world)、それらを一掃したり、散在していると思われるメモリを圧縮したりしてくれる。気に掛ける必要があるのは、パフォーマンスだけだ。

Golangも、ごく一部の例外を除き、このカテゴリに属する。Golangには参照がない。あるのは、ポインタだ。その違いは決定的だ。Goは外部Cコードに組み込まれ、パフォーマンス上の理由から、実行時、参照レジストリのようなものは存在しない。実際のポインタは実行システムには知られていない。割り当てられたメモリは、依然として到達可能性についての情報を集めるために分析され、未使用の“オブジェクト”はマークされて掃き出されるが、圧縮をするためにメモリを移動させることはできない。私はこのことをドキュメントからわからなかったし、ポインタ処理を理解していたので、Golangのエキスパートたちが圧縮目的で実装した魔法を探し求めていた。実際、そのような実装などしていないと知って残念だ。魔法は存在しなかった。

Golangにはガベージコレクションがあるが、メモリ圧縮がないので、JavaにあるようなFull GCではない。これが悪いことだとは一概に言い切れない。実行中のサーバを長時間持たせることができ、それでもメモリが断片化されることはない。JVMのガベージコレクションの中にも、GCポーズを減らすために古い世代のクリーニング時に圧縮ステップをとばし、最後の手段としてのみ圧縮を行うものもある。Goでは、この最終手段のステップが欠けていて、問題を引き起こすことがあるが稀だ。Goを学んでいるうちにこの問題に直面することはないだろう。

ローカル変数

ローカル変数(そして、時に新しいバージョンのオブジェクト)はJava言語ではスタックに格納される。これはC、C++、そしてコールスタックなどが使われているその他言語にも当てはまる。Golangも例外ではない、ただし…

ただし、関数からローカル変数へのポインタを返せるという点を除く。これはCにおいては致命的なミスだ。Goのケースでは、コンパイラが割り当てられた“オブジェクト”(なぜ私が引用符を用いているかについては後で説明する)がメソッドをエスケープしていて、それに基づいて割り当てを行っていることを認識しているため、関数の戻り値を切り抜ける。そしてポインタは、信頼できるデータのない、すでに破棄されたメモリ位置を指し示すことはない。

したがって、次のように書くのが間違いなく正式だ

package main
import (
    "fmt"
)
type Record struct {
    i int
}
func returnLocalVariableAddress() *Record {
    return &Record{1}
}
func main() {
    r := returnLocalVariableAddress()
    fmt.Printf("%d", r.i)
}

クロージャ

関数を関数の中に書くことができ、まさに関数型言語のように(Goは一種の関数型言語)関数を返すことができる。そして、その周りのローカル変数はクロージャの変数として機能する。

package main
import (
    "fmt"
)
func CounterFactory(j int) func() int {
    i := j
    return func() int {
        i++
        return i
    }
}
func main() {
    r := CounterFactory(13)
    fmt.Printf("%d\n", r())
    fmt.Printf("%d\n", r())
    fmt.Printf("%d\n", r())
}

関数の戻り値

関数が返す値は一つではなく、複数のこともある。これは、適切に使わないと悪い習慣になってしまうようだ。Pythonでもそうだし、Perlでもそうだ。うまく活用できるかもしれない。主に、値と“nil”またはエラーコードを返すのに使われる。このように、エラーを、戻り値の型(通常エラーコードとしては-1を返し、標準Cライブラリの呼び出しにあるような、重要な戻り値がある場合には負でない値を返す)にエンコードするという古い習慣は、はるかに読みやすいものに取って代わられている。

代入の両側の、複数ある値は、関数に渡されるだけではない。二つの値を入れ替えるには、次のように書くことができる。

 a,b = b,a

オブジェクト指向

関数とクロージャが第一級オブジェクトであるGoは、少なくともJavaScriptのようなオブジェクト指向だ。しかし実際はそれ以上だ。Golangにはインターフェースと構造体がある。だが、これらは別にクラスというわけではなく、値型だ。これらに値が渡され、メモリのどこに格納されていても、オブジェクトヘッダなどのない純粋なデータのみ存在する。Goの構造体は、C言語の構造体と非常に良く似ている。フィールドを含めることはできるが、互いに拡張することや、メソッドを含めることはできない。オブジェクト指向にはやや違ったアプローチが取られる。

メソッドをクラス定義に詰め込むのではなく、メソッド自体を定義するときに構造体を指定することができる。構造体は他の構造体も含むことができ、フィールド名がない場合は、型(暗黙的にその名前になる)で参照することが可能だ。または単純に、一番上の構造体に属していたとして、フィールドやメソッドを参照することもできる。

(例)

package main
import (
    "fmt"
)
type A struct {
    a int
}
func (a *A) Printa() {
    fmt.Printf("%d\n", a.a)
}
type B struct {
    A
    n string
}
func main() {
    b := B{}
    b.Printa()
    b.A.a = 5
    fmt.Printf("%d\n", b.a)
}

これは一種の継承だ。

メソッドを呼び出すことができる構造体を指定するときは、構造体自体、またはその構造体へのポインタを指定できる。メソッドが構造体に適用される場合、そのメソッドは呼び出し側構造体のコピーにアクセスする(この構造体は値渡しされる)。構造体へのポインタにメソッドが適用された場合、(参照渡しのように)ポインタが渡される。後者の場合、メソッドは構造体を修正することもできる(そういう意味では、値型は不変なので、ここでの構造体は値型ではない)。いずれかを用いて、インターフェースの要件を満たすことができる。上の例の場合、構造体AへのポインタにPrintaが適用されている。Goは、Aがメソッドのレシーバであると示している。

また、Goの構文は構造体とポインタにやや寛容だ。C言語では構造体を持つことができ、b.aと書けば構造体のフィールドにアクセスすることができる。Cの構造体へのポインタの場合、同じフィールドにアクセスするにはb->aと書く必要がある。ポインタの場合、b.aと書くと構文エラーだ。Goでは、b->aと書くのは(文字通り)pointless(無意味)だとされる。ドット演算子がオーバーロードされる可能性があるとき、なぜアロー演算子を使ったコードを捨て散らかすのだろう?構造体の場合はフィールドへのアクセス、そして、ポインタによるフィールドへのアクセス。実に論理的だ。

ポインタは構造体自体と同じくらい良い(ある程度までは)ので、以下のように書くことができる。

package main
import (
    "fmt"
)
type A struct {
    a int
}
func (a *A) Printa() {
    if a == nil {
        fmt.Println("a is nil")
    } else {
        fmt.Printf("%d\n", a.a)
    }
}
func main() {
    var a *A = nil
    a.Printa()
}

そう、これがポイントだ。真のJavaプログラマとして、取り乱してはならない。nilポインタのメソッドを呼び出したのだ!どうしたらそんなことが可能になるのだろうか?

オブジェクトではなく変数に入力する

これが、私が”オブジェクト“に引用符を付けていた理由だ。Goでは、構造体を格納するとき、これがメモリの一部となっている。オブジェクトヘッダはない(ただし、実装の問題であり言語定義の問題ではないので、もしかしたらあるかもしれないが、合理的には、ない)。これは、値の型を保持する変数だ。変数型が構造体の場合、すでにコンパイル時に認識されている。インターフェースの場合であれば、変数は値を指し、同時にその値を持つ実際の型を参照する。

変数aがインターフェースで、構造体へのポインタではない場合、同じことはできず、実行時エラーが発生する。

インターフェースの実装

Goのインターフェースはとてもシンプルで、同時に非常に複雑だ(または少なくともJavaのインターフェースとは異なる)。インターフェースは、そのインターフェースに準拠したい場合、構造体が実装するはずの一連の関数を宣言する。継承は、構造体の時と同じように行われる。不思議なことに、構造体の場合インターフェースが実装されていれば、そのことを指定する必要がない。結局、インターフェースを実装するのは、実際には構造体ではなく、むしろ構造体や構造体へのポインタをレシーバとして使う一連の関数だ。関数が全て実装されると、その後、構造体がインターフェースを実装する。関数が全て揃わないと、実装は完了しない。

なぜ「実装」というキーワードがJavaに必要でGoには不要なのか?Goが実装を必要としないのは、Goは完全にコンパイルされ、実行時にコンパイルされたコードを個別にロードするクラスローダのようなものはないためだ。構造体がインターフェースを実装することになっていたのにしなかった場合、構造体がインターフェースを実装していることを明示的に分類することなく、コンパイル時にこれが検出される。 (Goの持つ)リフレクションを使えば、これを乗り切り、実行時エラーを起こすことができるが、「実装」宣言はいずれにせよ役に立たない。

Goはコンパクト

Goのコードはコンパクトで、寛容でない。他の言語には、全く役に立たない文字がある。C言語が発明されて以来、私たちは40年間それに慣れてきたし、その他の全言語がその構文に従ってきたが、それが必ずしも最良の方法だとは言えない。私たちは皆、C言語の「末尾のelse」問題は、 「if」命令文では{ と }をコード分岐点の周りに使うのが最善だということを知っている。(多分Perlが同じような要求をする、最初に主流となったCに似た構文言語かもしれない。)しかし、ブレース(中括弧)が必須なら、その条件を括弧で囲んでも意味がない。上のコードを見てもわかるように:

...
    if a == nil {
        fmt.Println("a is nil")
    } else {
        fmt.Printf("%d\n", a.a)
    }
...

必要ないし、Goが許してくれない。セミコロンがないことにも気づいただろうか。セミコロンは使えるが、使わなくてもいい。セミコロンの挿入はソースコードの前処理ステップで、非常に効果的だ。いずれにせよ、たいていの場合セミコロンは不要だ。

「:=」を使って、新しい変数の宣言をし、その変数に値を割り当てることができる。通常その右側に、式で型を定義するため、「var x typeOfX = expression」と書く必要はない。一方、パッケージをインポートし、後で使わない変数を入れるとバグになる。コンパイル時にコードエラーとして検出されるため、コンパイルに失敗する。とても賢い。(たまに、使おうと思ってパッケージをインポートして、参照する前にコードを保存すると、IntelliJが賢くインポートを削除してくれるのが厄介だけど。)

スレッドとキュー

スレッドとキューは言語に組み込まれている。これらはゴルーチンとチャネルと呼ばれる。ゴルーチンを始めるには、go functioncall()を書くだけで、関数が異なるスレッドに開始される。Goの標準ライブラリには“オブジェクト”をロックするためのメソッド/関数があるが、ネイティブのマルチスレッドプログラミングではチャネルが使われている。チャネルはGoでは組み込み型で、固定サイズFIFOだ。チャネルに値を押し込み、ゴルーチンでそれを取り出すことができる。チャネルがブロックを押し込んでいて一杯のときや、チャネルが空の場合、取得しない。

エラーは存在する。例外はない。パニック!

Goには例外処理があるが、Javaのように使用されることを想定されていない。例外は「panic」と呼ばれ、コード内で実際にパニックが存在する際に使われる。Java用語では「…Error」で終わるthrowableに似ている。例外的ケースや処理可能なエラーがある場合、この状態はシステムコールによって返され、アプリケーション関数は同様のパターンに従うことになっている。
例えば

package main
import (
    "log"
    "os"
)
func main() {
    f, err := os.Open("filename.ext")
    if err != nil {
        log.Fatal(err)
    }
    defer f.Close()
}

関数 ‘Open’はファイルハンドラとnil、またはnilとエラーコードを返す。Go Playgroundで実行すると(上のリンクをクリック)、エラーが表示される。

これは、私たちがJavaでプログラミングするときに慣れ親しんだ習慣にはあまり適さない。エラー状態を見逃しやすく、このように書いて

package main
import (
    "os"
)
func main() {
    f := os.Open("filename.ext")
    defer f.Close()
}

エラーを無視するだけだ。それに、もっと長いコマンドチェーンに興味がある場合(この中で実際にエラーを起こすものがあってもあまり気にしないという場合)、エラーを返す可能性があるシステムコールやアプリケーションコールのエラーの可能性をいちいちチェックするのは面倒だ。

Finallyではなく、Defer

例外処理と密接に関連しているのは、Javaがtry / catch / finally機能で実装している機能だ。Javaでは、何があってもfinallyコードで実行されるコードを持つことができる。Goにはキーワード 「defer」があり、パニックがあってもメソッドが返る前に呼び出される関数呼び出しを指定できる。これは、誤用の恐れを少なくしてくれるソリューションだ。関数呼び出しだけを遅延して実行する任意のコードを書くことはできない。Javaでは、finallyブロックでreturn文を使用することもできるし、finallyブロックで実行されるコードが例外をスローすることもある。Goではそれが生じやすい。私はそれが好きだ。

その他…

最初変に思えるかもしれないものは

  • 公開関数と変数が大文字で書かれていて、「public」、「private」のようなキーワードがない
  • ライブラリのソースコードがプロジェクトのソースにインポートされる(これに関しては私が正しく理解できているかわからない)
  • ジェネリックの欠如
  • コメントディレクティブの形式で言語内に組み込まれたコード生成サポート(これは本当になんじゃこりゃ)

概して、Goは面白い言語だ。言語レベルにおいても、Javaの代わりにはならない。JavaとGoは同じタイプのタスク処理を想定されていない。Javaはエンタープライズ開発言語、Goはシステムプログラミング言語だ。Javaと同様、Goは継続的に発展しており、将来的にはそれに変化が見られるかもしれない。

原文:https://dzone.com/articles/comparing-golang-with-java(2017-6-14)
※元記事の筆者には直接翻訳の許可を頂いて、翻訳・公開しております。

 -Tech

FAworksではプロのコンサルタントが案件をお探しします

  関連記事

1家に1人!旦那がエンジニアだと便利な5つのこと

よく便利なものに対して「1家に1台」とか言いますよね。 まさしくエンジニアはその「1家に1人」の便利

MySQL Cluster―MySQLはいかに2億QPSのスケーリングを実現したか

この記事は、オラクル社MySQL主任プロダクトマネージャ、アンドリュー・モルガン氏からのゲスト投稿で

フルスタックエンジニアを目指すには。

フルスタックエンジニアを目指すには。

by Shunsuke Kobayashi 習うより慣れろと言うことわざがありますが、 エンジニアで

あなたも聞いてる?テック系ポッドキャスト3選

あなたも聞いてる?テック系ポッドキャスト3選

最近、エンジニアの間でにわかに話題になっているのが「ポッドキャスト配信」です。エンジニア自身が時事的

待ち遠しい次の祝日がコマンドラインでわかる!‐cal‐ 端末にカレンダーを表示しよう

待ち遠しい次の祝日がコマンドラインでわかる!‐cal‐ 端末にカレンダーを表示しよう

これは“コマンドライン・マンデー”シリーズの最初の投稿です。このシリーズでは、毎週月曜日に使えるコマ

【企業別】Advent Calendar2014 はてブ数ランキングまとめ

皆さん、2014年も残す所1週間を切りましたが、いかがお過ごしでしょうか。もはや師走の風物詩となった

Javaアプリケーションのパフォーマンスを(ほぼ)自動的に上げる方法

Javaアプリケーションのパフォーマンスを(ほぼ)自動的に上げる方法

コードを書き換えずに簡単な手順をいくつか踏むだけで、複雑なJavaアプリケーションを10%以上スピー

なぜあなたのインターネットは遅いのか?問題を切り分けて特定するトラブルシューティング

はじめに 家庭、コーヒーショップ、またはオフィスでの作業中に「インターネット(インフラ)にまつわる問

【カテゴリ別】海外エンジニアたちに人気の英語Techメディア・Techブログ50選(2016年版)

    日々目まぐるしい速さで変化していくIT業界。特にエンジニアという職種にお

Lispをあなたの言語にも取り入れる方法

僕はプログラミング言語Lispのファンだ。だが、多くの不慣れなプログラマにとって、その素晴らしいまで