DMM.comの、一番深くておもしろいトコロ。

元JavaエンジニアがGoに感じた「表現力の低さ」と「開発生産性」の話

元JavaエンジニアがGoに感じた「表現力の低さ」と「開発生産性」の話

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

この記事は Calendar for DMMグループ Advent Calendar 2021 | Advent Calendar 2021 - Qiita の10日目の記事です。

プラットフォーム事業本部マイクロサービスアーキテクトグループのaanriiです。 もともと僕は前職で3年ほどJava (主に8〜11あたり) とSpring Bootを扱ってWebサービス開発をしていました。 それから2020年10月にDMMに中途入社して現在のチームに配属されたのですが、 現チームではGoを主要な開発言語として採用しており、僕は当時未経験ながらGoでの開発に参画することとなりました。

Goの主要な特徴として、言語仕様の薄さがあります。 たとえばJava 17の言語仕様は、A4にしておよそ800ページ分もあるわけですが、 Goの言語仕様は、たった80ページ程度で収まってしまうほどです。 それだけ、Goが持つ仕様や機能の構成がとてもミニマルであることがわかります。

一方、かつてJava + Spring Bootという技術スタックに絶大な信頼を置いていた僕は、Goでの開発の中で、当初は強い違和感を覚えていました。 特に、Goが持つ仕様の薄さに対して「GoにはJavaにあった、あんな機能やこんな機能がない!」と不満に思うことが多くありました。例を挙げると、

  • Goにはコンストラクタもprivate修飾子もないから、オブジェクトをイミュータブルにするのが困難である
  • Goのリフレクションは貧弱すぎて、Javaのようにモックを柔軟に作れず、単体テストの独立性を保つのが難しい
  • Goには (1.17時点では) ジェネリクスがなく、JavaでいうStreamのようなものもない。大抵の場合、愚直にfor文を書く以外の方法でシーケンス操作が実装できない

といったような具合です。

しかし、開発に取り組んでいく中で、Goにおいてはこの言語機能の少なさ、ないしは表現力の低さこそが、生産性を強力に押し上げる原動力になっていることを強く実感しました。 こうしたこともあり、今では「Webサービスを組織開発するなら、まずGoを検討するかな」と考えるほど、僕は (Java以上に) Goを信頼するようになりました。 その理由は色々とあるのですが、本稿では特に「Goの表現力の低さと、それが組織開発もたらす開発生産性」についてピックアップし、 かつてJavaエンジニアだった僕が、この1年間Goでの開発現場に立った経験をもとに考察したいと思います。

機能があっても、なかなか正しく使われない

Javaは、とても豊富な機能をもつ言語です。 オブジェクト指向プログラミングのための機能群(クラス、継承、インターフェースなど)はもちろんのこと、 標準ライブラリでは、宣言的なシーケンス処理を実現するためのStreamなど、非常に多くのAPIを提供しているほか、 メタプログラミングやアノテーションプロセッサ等による強力なコード解析・補完も可能です。

一方で、それらの機能群を正しく使いこなすことは、容易ではありません。 例として、ここでは変数をnull-safeに扱うために使われるOptionalクラスについて取り上げてみたいと思います。 Javaにおいては、nullableな変数を直接扱ってNullPointerExceptionを発生させないよう、 変数をOptionalでラップして安全に扱えるようにすることがあります。

    // itemはnullable
    Item item = functionThatReturnsNullableItem();
    // item.genInventory()の呼び出し時にNullPointerExceptionが発生する可能性がある
    int inventory = item.genInventory();
    
    // optionalItemはnullableでない
    Optional<Item> optionalItem = Optional.ofNullable(item);
    // itemがnullでもNullPointerExceptionは発生しない
    Optional<Integer> optionalInventory = optionalItem.map(Item::getInventory);

ところがこのOptional、使い方を誤ると、全くnull-safeでない危険なコードが書けてしまいます。 2014年にJava 8がリリースされ、Optionalが発表されてから7年も経った今、 さすがにそうした危うい使い方をしているコードは、もはや駆逐されている…なんてことは全然なく、 少なくとも僕がいた当時の現場ではよく見られる、典型的な誤りのひとつでした。

例として、このOptionalを使ってECサイトなどでありそうな「ある商品の在庫があるかどうかを判定する関数」を実装してみます。 前提として、商品IDに紐づく商品 (Itemクラス) を返すための、以下のようなインターフェースをもつクラスがあったとします。

public interface ItemRepository {
    // 与えられた商品IDに紐づく商品を返す。
    // 該当する商品が存在しないときは、Optional.empty() (=中身がnullなOptional) を返す
    Optional<Item> findById(String id);
}

これを使って、「ある商品IDに紐づく商品の在庫があるかどうかを判定する関数」は、以下のように実装できます。

public boolean isInStock(String itemId) {
    Item item = itemRepository.findById(itemId).get();
    // Item::getInventoryは、商品の在庫数を返す
    return item.getInventory() > 0;
}

実はこちらが、誤った使い方の一例になります (Javaエンジニアにとっては、流石にあからさますぎるかもしれません) 。

Optionalのget()というメソッドは、Optionalがラップしている変数の中身をそのまま返してくれるものなのですが、 中身がnull (empty) だった場合はNoSuchElementExceptionが発生します。 itemRepository.findOne(itemId)の戻り値がemptyな (=商品IDに紐づく商品がない) 場合、 itemRepository.findOne(itemId).get()の呼び出し時に上記例外が発生し、その時点で処理が失敗します。 そもそもnull-safetyのためにOptionalでラップしているのに、それを無闇に剥がしてしまっては、意味がありません。

では、以下のような実装はどうでしょう。

public boolean isInStock(String itemId) {
    Optional<Item> item = itemRepository.findById(itemId);
    if (item.isEmpty()) {
        return false; // 商品が存在しなければ、在庫なし
    }
    return item.get().getInventory() > 0;
}

実はこちらも (間違いとまでは言えないにしろ) 良い例とは言えません。 事前にitem.isEmpty()によるチェックを挟んでいるので、item.get()が例外を投げることはありません。 ですが、これなら素の変数をnullチェックをしているのとほぼ変わりません。 Optionalについてよく知っているJavaエンジニアなら、以下のように書くのではないでしょうか。

public boolean isInStock(String itemId) {
    Optional<Item> item = itemRepository.findById(itemId);
    return item.map(Item::getInventory) // 商品が存在するならば、その在庫数を取得する
        .map(inventory -> inventory > 0) 
        .orElse(false); // 商品が存在しなければ、在庫なし
}

基本的に、Optionalのget()を呼び出してはいけないとされます。 Optionalにはmapやfilter、orElseなどといった、Optionalのままで値を操作できるメソッド群が用意されており、 これらを駆使することで、nullチェックをせずともnull-safeに処理を書くことができるようになっています。 get()が本当に必要なシーンというのは限られますし、そもそも危険なので可能な限り避けるべきなのです。

…というようなことは、僕が今更あえて言うまでもなく、インターネット上では何年も前から何度も言及されてきたことです1。 しかし、僕は今まで業務の中でOptionalのget()を呼び出すコードをいくつも見てきましたし、かつての僕自身でさえこういうコードを書いていました。 なにしろ、みんながみんなOptionalを適切に扱えるだけの知識をもった状態でJavaでの開発現場に入れるわけではないのです。 もとより、人員の入れ替わりが激しいWebサービス開発の現場において、 「関わるエンジニアが全員、その開発言語について深く理解している」なんていうことは殆どありません。 言語経験が全くないエンジニアが現場に突然アサインされることすら、よくあることです (もちろん、現場にもよりますが) 。

一方Goには、Optionalやそれに相当する概念は一切ありません。 したがって、あるエンティティの不在はnil (Javaでいうnull) により表現することがままあります。

type ItemRepository interface {
    // 与えられた商品IDに紐づく商品を返す。存在しなければnilを返す
    findOne(ID string) *Item
}

したがって、isInStock関数も以下のような書き方しかあり得ません。

func isInStock(itemID string) bool {
    if item := itemRepository.findOne(itemID); item != nil {
        return item.Inventory > 0
    }
    return false
}

このコードは、Goのチュートリアルをかじったことがある人間であれば本当に誰でも理解できるし、書けます。 使い方を誤る余地すらほぼありません。 「そりゃそうだろう」というツッコミが聞こえてきそうですけど、これが案外バカにならないのです。

単純な言語だからこそ、すぐに使いこなせる

Javaはかなり多機能で表現力の高い言語なので、先のisInStock関数のような単純なお題であっても、実装方法は何通りも考えられます。 その中で、最適な実装とは何か?というのにたどり着くためには、それなりにノウハウの蓄積が必要です。 これを会得するためには、たくさんコードを読み、たくさんコードを書き、レビューに出し、 レビュワーに叩かれ、叩かれ、叩かれ、叩かれる中で、知識を獲得していかなければなりません。修行です。 Java未経験者がSpring Bootアプリケーションの開発現場にコミットできるようになるまで、どの程度の期間が必要になるでしょうか? これは個々人によって、現場によって全く異なるかとは思いますが、僕は一年半程度はかかった気がします。

一方のGoですが、先述の通りJavaと比べて大幅に言語仕様が少ないです。 前述のOptionalのほか、クラスレベルのアクセス修飾子や継承、コンストラクタなどという概念もありません。 したがって、コード上の表現力も限られています。ある要件があったときに、それを実装するために考えられるパターンが、自ずと絞られていきます。 結果、Goの経験が浅い初心者でも、熟練の上級者でも、局所的なコード表現においてはさほど違いが出ません。 極論すると、(ミクロな視点で見れば) 初心者でも上級者と同じアウトプットができるということです。 もちろん、現場で通用するコードを書くためのノウハウを習得する努力は、たとえGoであってもある程度必要になりますが、 僕の体感でいうと、その量はJavaよりもかなり少なく済んだ印象です (僕で言うと、だいたい半年ぐらい) 。

これは、変化の激しいWebサービスの現場では特に有利に働きます。 あるサービスを、同じエンジニアがローンチからEOLまで一貫して面倒をみるケースなんて、ほとんどないでしょう。 たいていの現場では、開発の過程で初期メンバーのエンジニアが辞めたり、新規メンバーが参入したり、ということがままあるかと思います。 中には、サービスが書かれた言語について一切経験のないエンジニアが急にアサインされることだってあるわけです。 かくいう僕も、Goの経験ゼロの状態でGoを開発言語としているチームに途中参加しました。 しかし、参加して三ヶ月ほどで (メンターに支えられつ) 小規模なサービスをローンチさせることができ、 半年経った頃にはチーム内のコードレビューや、他チームへの技術支援を担当するまでに至りました。 それは、Goそのものがとてもシンプルな仕様・機能で構成されており、学習コストが少なく抑えられているからこそできた事だと思います (Javaだったら、とてもじゃないけどこう上手くはいかなかったでしょう…)。 Goの機動力は、どんなエンジニアでも即戦力にする可能性をもたらし、チームにスケーラビリティを与えてくれます。

品質の良し悪しは言語のせいか?

「…それはそうかもしれないけど、null-safeな書き方ができないのなら、結局は安全性を犠牲にしているということではないか?」

はい、仰る通りです。GoはJavaでいうOptionalのように、nullチェックに依らずnull-safetyを担保できる機能を持ちません。 チェック例外のような機構もないです。errorの発生を無視することを仕組みで防ぐことはできません。 Javaのようなレベルで、言語機能でコーディングに制約をもたらし、安全性を高めることはできません。

ただ、僕自身さまざまなJavaのコードを見てきた経験を振り返ってみても、結局のところ「言語機能はさほど問題ではない」と感じます。 Optionalを使ったとしても、get()メソッドを無闇に呼び出されてしまえば意味がないのと同じことで、 そもそもnull-safetyという概念をエンジニアが理解していなければ、どれだけ言語機能があろうと価値は発揮されません。 チェック例外があろうと、コンパイルを通したいがためにろくにハンドリングせず握りつぶすようなコードが書かれてしまうことだってあるわけです (実際、こうしたコードをレビューで突き返した経験が何度もあります…)。

Goにおいては、null-safeにしたいときはnilチェックを書けますし、 チェック例外がなくとも戻り値のerrorを確認することでエラーハンドリング自体は実装できます。 Javaほど豊富な機能はないですし、スマートな書き方もできないかもしれませんが、 プログラミングにおいてよくあるこの手の課題を解決する方法自体はちゃんと提供されているので、そこまで困ることはありません。

組織開発においては、言語機能をいかに使いこなすかということよりも、 「この変数、nilになるパターンあるかも」「この変数がnilだとバグるかも」と思考を巡らせ、適切な対策を検討できることの方が、 生産性に直結する本質的な課題だと思うのです。これは言語が何であれ関係ありません。 先の例で言うと、isEmpty() などのチェックなしでOptionalのget()を呼び出しているコードが書かれてしまう場合、 エンジニアがそもそも「与えられたIDに紐づく商品が存在しないパターンはあるのか?」という発想に至っていない可能性があります。 逆に、そうした発想ができるエンジニアは、どんな言語であれ破滅的なコードを書くことはそうない、と僕は思っています。

表現力は低い方がいいのか?

ここまで、GoとJava、それぞれを"言語仕様のボリューム差に伴う、開発生産性へのアプローチの違い"という観点から比較していきました。 僕の主張をまとめると、

  1. GoはJavaよりも言語仕様が薄く、コード上の表現力が限られる
  2. ゆえに、開発言語固有の知識量 (≒言語習熟度) がコードの書き方に与える影響が少ない
  3. だから、言語習熟度が浅くても十分な品質のコードを書けるポテンシャルがある
  4. 開発メンバーの入れ替わりが激しく、様々なスキルレベルをもつエンジニアが参入しうる開発プロジェクトにおいて、Goは比較的安定した開発生産性を供給できる可能性がある

ということになります。

一方で、僕は「Javaの表現力の高さが、開発生産性を全面的に低下させている」とは全く思っていません。 Javaは、その機能の豊富さを活かしてきわめて宣言的なコードを記述することが可能で、 場合によってはGoよりも、実現したい要件を簡潔かつ的確に表現するコードを書けることがあると感じています。 こうしたJavaの特性が、コードの可読性を向上させ、結果的に開発生産性を高められるポテンシャルがあると思っています。

例えば、先ほどのお題をすこし変え、「与えられた商品群の中から最も価格の高い商品を返す関数」について、 JavaとGoでそれぞれ実装してみましょう。

まずは、こちらがJavaでの実装例です。

public Optional<Item> getMostExpensiveItem(List<Item> items) {
    return items.stream().max(Comparator.comparing(Item::getPrice));
}

続いて、こちらがGoでの実装例です。

func getMostExpensiveItem(items []*Item) *Item {
    if len(items) == 0 {
        return nil
    }

    maxPrice := items[0].price
    maxPriceItem := items[0]

    for _, item := range items {
        if price := item.price; price > maxPrice {
            maxPrice = price
            maxPriceItem = item
        }
    }

    return maxPriceItem
}

見た目の差が歴然としていますね。 Javaの方は、かなり宣言的な記述になっており、 コードを流し読みするだけで「与えられた商品リストの中から、価格が最大な商品を返却する」という要件がはっきり伝わります。 一方で、Goの方はより手続き的で、日本語で要約するなら 「与えられた商品配列を走査し、より価格の高い商品があれば、最高価格商品を更新する。走査が終わったら、最高価格商品の最終状態を返却する」 となるでしょうか 2 。 JavaよりGoの例のほうが、コードの内容を咀嚼し理解するのに少々時間が取られる印象があります。

ここでは詳しく紹介しませんが、Javaにおいては言語の標準機能や、 SpringやLombok等といったフレームワーク、ライブラリを駆使することで、ほぼ全ての処理を宣言的に記述することができます。 そうしたコードはきわめてシンプルで読みやすく、もはや自然言語で記述されたドキュメントのようですらあります。 一方のGoは、言語仕様が薄く、また (1.17現在では) ジェネリクスやメタプログラミングがサポートされていない 3 こともあり、 Javaのように宣言的なコードを書こうと思うと少々苦労しますし、 そういう事情もあってかプリミティブで手続き的なコードを読み書きしなければならないシーンが多くあるような印象があります。

しかし、上記の例で利用したStream APIは開発者向けのインターフェースこそシンプルですが、その内部実装は見た目以上に複雑です 4 。 通常は、そうした部分を意識せずに利用できるようになってはいるのですが、 例えばStreamを利用したコードで想定外のバグが起こった時、原因調査の際にそうした複雑な内側まで立ち入る必要が出てくることもあるかもしれません (実際Streamでは無かったのですが、Spring Boot関連だとこうしたことは本当によくありました。おかげでBeanのライフサイクルについては詳しくなりました…) 。 また、内部実装について深く理解せずこうした機能を利用することで、思わぬ脆弱性や、パフォーマンスの劣化を見逃してしまう危険性もあります。 このように、Javaがもつさまざまな機能を使いこなすには、機能自体への深い理解と、その裏側に広がる複雑さと向き合う覚悟が求められるわけです。

まとめ

総合的にみて、GoとJavaとを比較した時の、僕が感じたそれぞれの印象をまとめると、以下のようになります。

  • Goは、言語習熟度の浅いエンジニアでも開発生産性を発揮しやすい性質をもっている
  • Javaは、機能を駆使することで開発生産性を高められるポテンシャルがあるが、使いこなすには言語習熟度がそれなりに要求される

このことは、「Goは初心者向け、Javaは上級者向け」「GoはJavaより簡単にWebサービス開発ができる」等ということを意味するわけでは決してありません。 Goであったとしても、実際にWebサービスを開発するためには、言語仕様や文法などといったミクロな構造だけでなく、 アプケーションアーキテクチャやフレームワークなどといった、よりマクロな構造への理解が必要不可欠です。 僕が思うGoの良い点は、そうしたミクロな部分での (言語への依存度が高い) 知識の習得コストが低いことにより、 Webサービス開発におけるマクロな部分での (言語への依存度が低い) 知識さえあれば、開発現場において比較的速やかにコミットできるようになるところにあります。

過去、Javaでの開発現場にいた時、新メンバーの教育を担当することが何度かありましたが、 言語機能において知っておかなければならない知識が非常に多く、それなりに大変だった記憶があります。 一方、現職でGoでの開発現場に携わったり、他チームへGo言語の導入支援をすることがあり、 その一環でGoの経験が浅い、あるいは全く無いエンジニアがGoでの開発に取り組む様子を見ることがあるのですが、 言語仕様のキャッチアップでつまずいている例はほぼなく、他言語で培った開発能力をすぐに活かせているように感じます。

Webサービスの開発現場は流動性が高く、携わるエンジニアは常に入れ替わる可能性があります。 Goは、そうした変化への対応力があり、開発生産性を推進しやすい性質を持っていると感じます。 こうした言語はなかなか他にないのではないか?と個人的には思っており、僕がGoを信頼する理由のひとつです。


  1. ぱっと調べるだけでも、こんな記事こんな記事が見つかります

  2. Goの標準ライブラリにあるsortパッケージを使って商品配列を価格で降順にソートし、先頭の要素を取るという方法もあります。こちらの方が、コードとしてはかなりシンプルになります。しかし、Javaのコード例だと計算量はO(n)となるのに対し、ソートの場合は計算量がO(n)よりは悪化するため、フェアな比較にはならないと考え、愚直に配列を操作する方法で記載しました。

  3. Goの次期バージョン、1.18ではジェネリクスの導入が予定されており、いずれはStream APIのようにシーケンス操作の宣言的記述ができるようになる可能性はあります。そうしたら、このような原始的なコードは書かなくて済むようになるかもしれません。

  4. Java8 Stream の裏舞台は、きっとあなたが考えているより忙しない - A Memorandum が詳しいです。