GROWTH VERSE TECH BLOG

株式会社GROWTH VERSEのテックブログです。

20年書いてきたJavaプログラマからみたGo言語

初めに

はじめまして、AIMSTARを開発している戸部です。

弊社では20年以上昔に設計されJavaで書かれモノリスなコードを、Go言語を使用しメンテナンス性の高いマイクロサービス群への置き換えを行っています。 このプロジェクトに参画して感じたJavaとGo言語の違いを思うがままに記述したいと思います。

Go言語は仕様がシンプル?

よく言われる「Go言語はシンプル」というのは実感しました。言語の理解を行う上でJavaに比べるとGo言語はシンプルです。 基本部分はJavaもシンプルなのですが、Javaを構成する標準クラスが多い事が複雑にしている一つの要因である気がします。

例えば、ある要素の集合(要素数可変)を表わす時に

  • Go言語では単純な配列に近いsliceを使うかmapを使うしかありません
  • Java言語ではSetインターフェースを実装したClassだけでも HashSet TreeSet LinkedHashSet EnumSet ConcurrentSkipListSet などがあります。(MapやListインターフェースを含めればさらに増えます)

javaの場合使用用途に応じて種類が細分化されているので、言語仕様を理解出来人にとっては使い易い言語なのですが覚える事も多く、それを知っている人/知らない人の間で理解の質が変ってきてしまいます。 Go言語の場合言語仕様がシンプルなため、他の人の書いたコードを読む時に何をやっているのかの理解はしやすくなっているのかと思います。

Go言語はオブジェクト指向ではない?

Javaの開発者は無意識のうちにオブジェクト指向的な考えでクラス/インターフェースの継承を考えてしまいます。 継承を使用することで、継承されたクラスでは親と異なる部分のみの実装で済むためコードの記述がシンプルになるという利点があります。

一方、Go言語ではこの継承という物がありません。

他人の書いたJavaのコードを見たことがある人は判ると思いますが、動作全体を理解する場合、該当クラスのコードだけでなく親のクラスのコードまでたどらないと実際の動きが判らないという欠点もあります。

Go言語の場合、継承という概念がないため実行するメソッドをたどればなにをするのがが判りやすくなっています(その分、同じようなコードを何度も書くという欠点もありますが・・・)

継承の使われ方の1つに継承元の変数への代入(ポリモーフィズム)があります。 オブジェクト指向の入門書などにある例で上げると

class Animal {
  void say();
}
class Dog extends Animal {
  void say() {
    System.out.printf("ワン");
  }
}
class Cat extends Animal {
  void say() {
    System.out.println("ニャー");
  }
}

のようなクラス設計にしておき

   Animal animal = ...
   animal.say()

というようなコードとすることで、animalという変数にAnimalを継承した Dog, Cat などのインスタンスを割り当てることが可能になっています。

一方のGo言語は継承がないためこのような記述は出来ません。 そこで出てくるのがinterface。 interfaceはそこに割り当てられる実体が持つメソッドの宣言を行います。 つまり、このinterfaceに割当られる実体はinterfaceで宣言されたメソッドを持つということを表わします。 いうなれば、継承など関係なく該当するメソッドを持っていれば割当が可能となります。

Go言語の場合、メソッドが定義されていればinterfaceの変数に割当できてしまうので、必要なメソッドだけの定義では明示的な実体のグルーピングのような事が出来ません。 たとえば、IDを返すGetIDのようなMethodは用途の違う複数のstructに定義されているので、関係ないインスタンスが割当られてしまう可能性があります。 そのような事を防ぐ為に、あえてダミーとなるメソッドを追加する事もあります。

type Base interface {
  GetID() string
  dummyFunc()
}

type a struct {
}
func (a *a)GetID() string {
  return "A"
}
func (a *a)dummyFunc {
  // dummy
}

type b struct {
}
func (b *b)GetID() string {
  return "B"
}
func (b *b)dummyFunc {
  // dummy
}

type c struct {
}
func (c *b)GetID() string {
  return "C"
}

このような感じにしておくと

  var base Base
  base = struct a{} // -- OK
  base = struct b{} // -- OK
  base = struct c{} // -- NG

のようにdummyFuncが定義されていない cbase に代入することを制限することができます。

また、継承が使われる用途の1つとして、データの継承があります(継承元の属性を継承先の属性と同じように扱う)。

Go言語でもstructの中にstructを埋め込んだ型を使用することで、埋め込まれたstructのデータにアクセスする事は出来ますが、 Javaの継承のように元のstruct型の変数に代入することが出来ません。 そのため前述のDogとCatのどちらかが入る変数が必要な場合は、interfaceを使用してあげる形になります。

// javaの場合
class Base {
  String name
}
class A extends Base {
  int weight
}
A a = new A();
a.name = "AAA" // 継承元の属性にアクセス出来る
a.weight = 30;

Base base = a;  // 継承元に代入出来る
if (base.name.equals("AAA")) {
  //
}
type Base struct {
  Name string
}
type A struct {
  Base // 元のstructを埋め込むことは出来る
  Weight int
}

func f() {
  a := A{}
  a.Name = "AAA" // 埋め込まれたstructの属性にアクセス
  a.Weight = 30

  var b Base
  // b = a    ※ これは出来ない a は Base型ではないため
  b = a.Base  // これはOK ただし埋め込まれたstructにアクセスできるだけ
}
//

Go言語は例外処理が使えない?

JavaとGo言語の大きな違いとして、エラーハンドリングの方法があります。 Javaではエラー/例外が発生する場合、(一部のRuntimeErrorなどを除けば) try - catch で例外を処理するか、メソッドに throws 宣言をおこなっておき呼び出し元に例外をパスするかになります。

Go言語では関数の戻り値としてエラーを返すようにして、呼び出し側でエラーが返ってきたかのチェックを行います。 Goの場合、Errorを返す可能性がある関数では正常時も明示的にエラーが無い事を返す必要があるので、判りやすい反面ちょっと面倒だとおもう部分もあります。

Javaの例外処理ではtry - finally を使った後処理もよく使われます。 これはある処理を行った後で必ず実行しなければならない処理をfinallyに記述することで、例外などの発生でリソースが開かれたままにならないようにする仕組みです。 (ファイルをオープンしたままにしないなど)

Go言語の場合、try-finallyのような仕組みがないので明示的にリソースを開放させる必要があります。 このために defer 文を使用して関数の終了時に後処理用の関数を実行予約します。

Javaの場合

Resource r = new Resource()
r.open();
try {
  // 何らかの処理1
} catch (Exception r) {
  // エラー処理
} finally {
  r.close();  // エラー/成功にかかわらずrをclose
}
何らかの処理2

と書く感じの処理をGo言語では以下のようになります。

r := Resource{}
err := r.open()
if err != nil {
  return err;
}
defer r.close() // methodの終りに実行

// 何らかの処理1
if err != nil {
   return err; // ここでdefer指定の処理が実行
}
// 何らかの処理2

return nil; // ここでdefer指定の処理が実行

Javaと異なりdeferの実行タイミングがreturnで戻る時になるので「何らかの処理2」を実行する前にr.close()を行う場合は関数を分けるなどで対応する必要があります。 一方で複数リソースを閉じる必要がある場合、try-finallyでは多重になる事がありますが、deferは実行対象の処理が積み上がるのでシンプルに書ける利点もあります。

Javaの場合

Resource r1 = new Resource1()
r1.open();
try {
  Resource r2 = new Resource2()
  r2.open()
  try {
     // 何らかの処理
  } finally {
    r2.close();
  }
} catch (Exception r) {
  // エラー処理
} finally {
  r1.close();  // エラー/成功にかかわらずrをclose
}

Go言語の場合

r1 := Resource1{}
err := r1.open()
if err != nil {
  return err;
}
defer r1.close() // methodの終りに実行

r2 := Resource2{}
err = r2.open()
if err != nil {
  return err; // ここでdefer指定のr1.closeが実行
}
defer r2.close() // methodの終りに実行

return nil; // ここでdefer指定のr2.close, r1.closeが実行

という感じでtry - catch - finally が無くても同じような処理が出来るので慣れの問題なのかもしれません

おわりに

今回はJavaの開発者から見てgo言語で出来ない機能ついて比較してみました。 今後Javaでは出来ない/やりにくいがGo言語では簡単にできる機能なども上げていければと思います。

採用情報

弊社ではエンジニアを絶賛募集中です!

findy-code.io