Kengo's blog

Technical articles about original projects, JVM, Static Analysis and TypeScript.

エコシステムにビルドツールがたくさんあるのは悪いことではない

JavaやNodeJSには多数のビルドツールがあります。ものによってはビルドツールではなくタスクランナーとかワークフローとか名前が付いてるかもしれませんが些細なことです、ここでは以下のようなツールのことをまとめてビルドツールと呼びます:

一方で言語公式のビルドツールを用意している言語もあります。これによってプロジェクトごとに異なる技術を学ぶ必要性が減りますし、一貫性のある開発体験を得ることができます。javac javadoc のような単純なコマンドしか提供しないJavaとは異なる方針を言語として持っていることは明らかでしょう。

では言語のエコシステムにビルドツールがたくさんあることはモダンではなく不便なのでしょうか?そんなことはないだろうというのが自分の考えです。もちろん欠点がないとは言いませんが、以下に私見を述べます:

プロジェクトによってビルドツールに求められる役割は異なるため、きめ細かな選択肢を選べる

例えばプログラマが若干名のプロジェクトでは、コンパイルやテストが一箇所にまとまっていてフットワーク良く改善を回せていけることが望ましいでしょう。複数リポジトリやサブプロジェクトを作る必要性もまだ薄いでしょうし、そこまで統制について考えることもありません。自分なら開発が活発でパフォーマンスも良いGradleを選択することになると思います。

一方で何百人ものプログラマが関与するプロジェクトでは、ビルドツールやワークフローについても統制を考えるケースが出てきます。 mvn test を実行したらテスト実行結果が必ずJUnitのXML形式で target/surefire-reports/TEST-*.xml に吐き出されなければならないとか、Reproducible Buildsに準拠するとか、developブランチにマージしたらSonarQubeを実行せよとか、ビルドするにはJava 8を使わなければならないとか、そういったベースとなる要求をすべてのプロジェクトに守らせることでリポジトリ横断的な品質改善に役立てたりするわけです。

今だとこういった要求もGradleで満たせそうですが、7年くらい前?に自分が似た状況にあったときは、Mavenのparent projectによる制約の中央管理とバージョン管理が非常にマッチしました。DSLがないので自由度が低く、統制側としては考慮すべきことが減るというのもあります。中央管理する以上は各リポジトリの困りごとをきちんと拾い上げる姿勢は必要になりますが、その工数を考慮してもMavenに軍配が上がることはあるでしょう。

ビルドツールの思想に種類があることを学べる

そもそもこうした違いはどこから生じるのでしょうか。モダンな技術を使って開発された新しいビルドツールは常にレガシーなものよりも優れているべきではないのでしょうか?実はそうではなく、むしろ最もレガシーなApache Antと最もモダンなGradleはかなり近い特徴があります。

Apache AntとGradleはタスクを繋いで有向非巡回グラフ(DAG)を作るという発想で作られています。テストはテストケースのコンパイルに依存し、テストケースのコンパイルは実装のコンパイルに依存し、実装のコンパイルはアノテーションを使ったコード生成に依存する……といったタスクの間の依存関係を明示することで、タスクを並列実行したり不要なタスクの準備を省いたりして高速化ができるのです:

graph LR;
  annotation-processing --> compile --> testCompile --> test --> check --> build;
  compile --> jar --> assemble --> build;

特定のタスクだけ実行する・特定のタスクだけ除外するといった操作も簡単に行なえます。プロジェクト固有のタスクや概念を導入することも容易ですが、一方でタスク実行時に必要な入力がすでに生成されているかどうかを管理するため、タスクの入出力を宣言したり、タスクが依存するタスクを明記する必要があります。DAGをメンテナンスする責任をユーザが負い、それを前提にタスクの内部実装を気にせずに済むようになっています。

Mavenはビルドライフサイクルという概念があり、すべてのプロジェクトはこのライフサイクルに従うことを期待されています。ビルドライフサイクルをゼロから作ることも可能ですが、かなり重い作業です。

ビルドライフサイクルにはフェーズが定義されており、このフェーズにプラグインのゴールを紐付けることで、どのようなプロジェクトでも同じビルドライフサイクルで臨んだ結果を得られるようにしています:

graph LR;
  subgraph compile
    compiler:compile
  end
  subgraph test-compile
    compiler:testCompile
  end
  subgraph test
    surefire:test
  end
  subgraph package
    jar:jar
  end
  compile --> test-compile --> test --> package

そのフェーズに入った時点で以前のフェーズはすべて完了していると信じられるため、ゴールの入力がすでに生成されているかを気にする必要は比較的薄いでしょう。フェーズ内で実行する処理に依存関係がある場合、 compiler:compile ゴールがアノテーションプロセッシングとコンパイルの両方を行うように、ひとつのゴールにまとめてしまうことで単純化します。

一方でやはり柔軟性には欠けます。ライフサイクルの一部だけ実行したい場合、例えばテストを再実行してレポートを生成する場合など、必要なプラグインのゴールを特定してそれを直接実行しなければなりません。逆にテスト以外のすべてを実行する場合も、プラグインの実装を理解して -DskipTests オプションを指定するといったことも必要です。依存先のゴールを自動的に推定・実行することもないためゴールの入力が不正になることも多く、昔は「とにかく mvn clean してやりなおす」ということもよくやっていました。おそらく多くのMavenプロジェクトでは、開発時の混乱を避けるためにREADME.mdCONTRIBUTING.mdにこういうときはこうするというコマンド一覧が載っていると思います。

長くなりましたが、すべての状況にマッチするツールが存在しないのは、ツールの根底にある思想によって適した現場がそれぞれ異なるからだと考えられます。これらの思想そのものは20年以上変化していない時代の荒波に揉まれたものですので、一長一短はあれど使い所が合えば価値の高いものだと言えます。ビルドツールの多様性は、それすなわち言語の活用幅の広さだということなのでしょう。

単に歴史が長いのでビルドツールが数多く生まれてきた

特にJavaは言語としての歴史が長いので、多くのビルドツールが作成され検討されてきたという側面はあると思います。例えばApache Antを使っているfb-contribは2005年からあります。当時からJavaのビルドツールが成長せず、Antだけでここまで来れたかというと、ちょっと考えられませんね。最近(と言っても8年前ですが)Java Moduleにネイティブで対応するビルドツールも提案されていたりして、今でも新しい形が模索されています。

それで言うと今はひとつしかビルドツールを備えていない言語も、もしかしたら今後はビルドツールが2つ3つと増えてくるかもしれませんね。どんなニーズにもひとつのツールチェインで応えようとすると収集つかないこともありそうなので。

とはいえ新しいツールに乗り換えたほうがいいこともある

古いビルドツールをずっと使い続けると技術革新の恩恵を得にくいみたいなところはありますので、ビルドツールをモダンなものに変えていく努力はしたほうが良いことはあります。これからも活発に変更を入れていくプロジェクトであれば特に、更新が活発なビルドツールに移行したほうが良いでしょう、私もFindBugsをSpotBugsにforkするときはAntとMavenを使っていたプロジェクトをGradleで書き換えるという経験をしました

異なる思想を持つツールに移行する場合は両方のツールに詳しくないと思わぬところで失敗するなんてこともあるので、一時的に有識者に手伝ってもらうことも検討しましょう。私もビルドツール移行の副業を受け付けております(唐突な宣伝):

youtrust.jp

まとめ

エコシステムにビルドツールがたくさんあることは悪いことではありません。キャッチアップが大変とか、コミュニティの知見が分散してしまうとかはもちろんあるのですが、コミュニティの抱えるプロジェクトの多様性を担保し、歴史あるプロジェクトと新鋭気鋭なプロジェクトとが同居する上でとても重要な貢献をしています。

キャッチアップコストが気になる場合はREADMEを整備するとか情報の多い新しめのツールに乗り換えるとか、自衛策を取ることもできます。最初からビルドツールがひとつだったら払わなくて済むコストでは確かにあるのですが、言語の歴史と実績に思いを馳せていただければと思います。

JVMにおけるServiceLoaderとjavaagent

本エントリーはJava Advent Calendarシリーズ2の19日目です。

qiita.com

OpenTelemetryの自動計装機能を調べていたら、 ServiceLoader とjavaagentを活用した実装になっていました。これ前知識がないと魔法に見えるやつだなって思ったのですが、 ServiceLoader はともかくjavaagentなんてなかなか使わないだろうなぁと思ったので簡単な説明を書いてみます。

ServiceLoaderはJVM標準のDI技術

ServiceLoader とはJava 1.6から提供されている仕組みです。ので現代で使われているJVMでは必ず使える機能のひとつですね。

サービスとは、既知のインタフェースおよびクラス(通常は抽象クラス)のセットです。サービス・プロバイダとは、特定のサービスの実装です。通常、プロバイダのクラスによって、サービス自体に定義されているクラスのインタフェースとサブクラスが実装されます。 https://docs.oracle.com/javase/jp/8/docs/api/java/util/ServiceLoader.html

ServiceLoader を端的に説明すると、Javaプログラマに通りのいい表現としては、DIのようなものです。このインタフェースを実装してるクラスの実装がほしいよ〜、というコードを書いておくと、それをCLASSPATHから探して渡してくれるんです。

class よくあるDI {
  @Inject
  よくあるDI(Collection<適当なインタフェース> 引数) {
    // 指定したインタフェースの実装をコンストラクタに渡してくれる
  }
}
class ServiceLoader動作イメージ {
  void method() {
    ServiceLoader serviceLoader = ServiceLoader.load(適当なインタフェース.class)
    serviceLoader.iterator() // 指定したインタフェースの実装をイテレートするIteratorを返してくれる
  }
}

なお実装のインスタンスはデフォルトコンストラクタによって作成されます。このため複雑なインスタンスを作ることはできませんが、特定の処理を開始する起点としては充分なことが多いです。

例えばログファサードライブラリであるSLF4Jでは、この機能を使ってバインディングと呼ばれるインタフェースの実装を探しています。SLF4JはLog4j2やLogbackなどのロギングライブラリに処理を委譲しますが、このときに「Log4j2が使えるのか、Logbackが使えるのか?」を判断するのにService Loaderを使っているわけです。

OpenTelemetryでは InstrumentationModule の実装を探すのに ServiceLoader を使っています。何からデータを集めたいのか(RDB、gRPC、HTTPサーバなどなど)に応じたライブラリをCLASSPATHに入れておけば、起動時に ServiceLoader がよしなに必要なコードを拾い集めて初期化してくれる、そういうコードが簡単に書けるわけです。業務ロジック側では、それらのライブラリの存在すら認識する必要はありません。

ちなみに ServiceLoader でインスタンス化される側のコードを書く場合、ほぼセットで利用するのがGoogleのAuto Serviceです。Service Loaderでインスタンス化される側のコードを書く際に「Service Loaderに実装の居場所を教えるためのファイル」を作成する必要があるのですが、これをアノテーションから自動的に生成してくれます。便利ですね。

main関数以前の処理を実現するjavaagent

次にjavaagentの話をします。皆さんがJavaプログラマであれば、 public static void main(String[] args) な関数を一度は書いたことがあるでしょう。え? public static なんて今ドキ書かないって?2023年ですね。

ところでJVMはこの main 関数を実行する前、何をしているのでしょうか。メモリの確保とか、GCの準備とか、 ClassLoader の作成とか、プロセス引数から String[] を作るとか、色々やってそうですよね。そこで自分のコードを動かせたら最高じゃないですか?例えば ClassLoader 作成に一枚噛めるということは、これは事実上JVMが利用するほぼすべてのクラスのロードに介入して改変できることを意味しています。実際OpenTelemetryではメソッドの実装に介入してメソッド実行時に引数を差し替えるなんて芸当をしています。

実際こういう実装の差し替えとかやりはじめると、考慮することが結構あって大変なんですけどね。bytecode manipilationと呼ばれる分野で、筆者の好物のひとつでもあります。

さてjavaagentの話に戻ります。javaagentは java.lang.instrument パッケージに説明があります。javaagentにはそのままズバリの premain という関数を実装すればよさそうです。なるほど、 main 関数の前に呼び出されそうな名前ですね。さらにクラス定義を差し替えるためのメソッドなども提供されていて、いかにもJVMをハックするためのAPIという感じです。

のでひとつ注意点があって、javaagentは使うとどうしてもJVM起動速度にペナルティがかかります。迅速に起動を終わらせてリクエストを受け付けられるようにしたいんだという場合、javaagentの利用を避けて手で各種初期化を行うことも検討せざるを得ない……ということもあるでしょう。

以上でざっくりした説明を終わります。OpenTelemetryは ServiceLoader とjavaagentの合わせ技で、javaagentのCLASSPATHに入っているインタフェースの実装を main 関数実行前に使うことで、 ClassLoader に手を加えて計測対象モジュールに対して自動的に初期化を行っていました。一見便利ですがjavaagentの持つ速度的なペナルティや、javaagentのCLASSPATHが大きくなりがちという問題も潜んでいます。ご利用は計画的に。

SLF4JとLogbackは2023年末現在で積極採用していいよ

2年前のブログが未だにブクマされるので、念のため掲題の件について書いておきます。 端的に書くと、あのブログで挙げた懸念事項が解消されたのでどんどん使うと良いと思います。

SLF4J v2の安定版がリリースされた

良かったですね。ちなみにv2.0.9から slf4j.provider プロパティでproviderを指定できるようになったので、Service Loaderによるprovider探索をガツッとスキップできます。多くのユースケースでは利用したほうがログの単純化や起動の高速化に有効のはずです。

SLF4Jの活動は最近活発

JIRAのデータを見れば一目瞭然。私もGitHub Issueで回答に回ったりしますが、著者の方も頻繁にコメントしてくれてます。最近のLogbackの脆弱性への対応も充分に早かったのではないでしょうか。

図1 2023年の活動状況。緑の線が6ヶ月近く右肩上がりなのに注目。
図2 比較用に前回のブログに貼ったやつ。緑の線がほぼ横ばい。

ということで、好きにすればいいと思います。もちろんLog4j2も適材適所でどんどん使っていきましょう。

おまけ:依存を小さくするためという誘惑に負けずにSLF4J使っとけ、という話

SLF4Jを使ったソフトウェアを配布される方、最近なかのひとがこちらの投稿をされていたのでご参考まで。

ChatGPTによる要約を掲載しておきます。なおここで言うoptional dependencyとはMavenで言う <optional>true</optional> な依存のことではなく、独自のログAPIを提供したうえでクラスパスにSLF4JがいるかどうかによってそのAPIの挙動を変える書き方を想定していると思われます。

このテキストでは、ソフトウェアプロジェクトがロギング戦略を考える際に、SLF4Jを任意の依存関係にすることについて議論しています。SLF4JをWombatというライブラリの依存関係に加えることは、新しい依存関係を生み出します。一部の開発者はSLF4Jのラッパーを作成して、SLF4Jを任意の依存関係にすることを検討するかもしれません。しかし、このラッパーは将来的な変更に対して複雑さを増すだけでなく、SLF4Jの内部インターフェースに依存するため、メジャーバージョンによる互換性の問題が生じる可能性があります。さらに、各ライブラリが独自のロギングラッパーを持つと、ユーザーは複数のロギングフレームワークを扱う必要があり、これは利用者にとって煩わしいことです。そのため、開発者は独自のロギングラッパーを書くことに抵抗すべきです。

私は最近gRPCを使うんですが、あれはJUL(java.util.logging)のロガーに依存しているので、jul-to-slf4j とかいうおまじないを強制されるんですよね。こんな感じのコードを main() 直後に埋めて回る必要があるわけです:

// io.grpc がJULを使っているため、JULがSLF4Jを経由してLogbackに送るようにする
SLF4JBridgeHandler.removeHandlersForRootLogger()
SLF4JBridgeHandler.install()

Java標準APIでさえこれなのに、配布ソフトウェアが独自のログAPIを提供していたらもっとめんどくさいだろうなというのは想像できます。JVM世界でコードを書くなら難しいことを考えずにSLF4Jに乗っとくくらいがちょうどよいのは実際そうでしょう*1

*1:NodeJS書いてるとまともなロギングファサードのあるJVM世界がすごい羨ましくなるやつ

プログラミング言語が抱える課題を解決するには言語を乗り換えるのが一番いい、かもしれない

この記事は集まれKotlin好き!Kotlin愛好会 vol.47の懇親会でちょっと触れた内容を、膨らましてブログ用にまとめ直したものです。

注意点として、Java用静的解析OSSの開発保守を長年やってきたJavaプログラマがKotlinに乗り換えて1年経ったころに書いている、という強力なコンテキストがあります。また「何十年前の話をしてんの?」という部分が多く存在しますが見逃してください 🙇‍♂️

プログラミング言語の成長はすべてを解決する

どんなプログラミング言語にも固有の問題は必ずあります。私が一番長く書いてきたプログラミング言語であるJavaでも、いくつかの課題が指摘されてきました:

  • 不要な同期を取りすぎている(StringBuffer, Hashtable, Vectorなど)
  • シリアライズ・デシリアライズが遅い(Serializableインタフェース)
  • コレクションにどのようなインスタンスが入っているのかわからない
  • null安全ではない
  • 記述が長くなりやすい

こうした問題に対して、Javaとそのコミュニティはとても良く対応してきたと思います:

  • StringBuilder, HashMap, ArrayListなどの同期を取らないクラスが標準で提供された
  • シリアライズ・デシリアライズ用ライブラリが充実した
  • ジェネリクスが実装されてコレクションに入っているインスタンスがわかりやすくなった
  • Optional が標準で提供されるようになった
  • より抽象的に扱えるように、try-with-resourcesやStream APIなどを順次導入してきた

これに限らず様々な改良も進んでおり、JVM(Java仮想マシン)の改善も相まってこれからも強力な言語で有り続けるだろうと思っています。また言語とは別の外付けの改良も進んでいて、インクリメンタルビルドの実現、アノテーションによるコンパイラやツールへの情報提供、ツールやIDEによる静的解析なども盛んです。

実行環境に対するコントロールが効かないアプリやオンプレサーバの開発ではわかりませんが、一般にこうした言語や周辺環境の成長の恩恵を受けることは近年とても容易になってきています。こうしたアップデートを即座に取り込む、あるいは自分で作りに行くことは、多くの生産性の課題を解決するうえでとても重要です。

それでも成長による課題解決には限界がある、特に初学者にとっては

それではJava言語にはもうコーディングの現場で考慮すべき問題はないのでしょうか。もちろんあります。むしろ解決済みのはずの問題にすら気を配らなければならないケースがほとんどです。

StringBuffer, Hashtable, VectorなどのクラスはまだJava言語に存在します。個人的につらいなと思っているのは Stack クラスです。「スタック構造がほしいときは Stack じゃなくて Deque を使いましょう!」なんて一見意味の分からない注意事項が現役なので。。。 Properties が現役なのもわかりにくい。

シリアライズやデシリアライズは言語機能ではなくライブラリを使いましょう、というのも初学者にはつまづきやすいポイントだと思います。しかも聞く人によってkryoがいいよ、いやGSONでしょ、あれJacksonじゃないの?なんてことにもなるわけで、やはり標準が定まっていないのはわかりにくいなと思います。

ジェネリクスや Optional は便利ですが、プロジェクトの依存先を含め多くのインタフェースで利用されて初めて価値が出ます。特に長命のプロジェクトでは、導入が難しいこともあるでしょう。また「 null が入りそうだからこのフィールドは Optional にしよう」みたいな誤用の機会も多く潜んでおり、正しく使うのはいつでも難しいものです。

強調したいのは、自分はJavaとJVMが提供する強力な互換性はとても好ましいと思っています。またJava Platform Module System (JPMS、旧称Project Jigsaw)を使って不要な部分を取り除いたJVMを自分で作れる未来は来るだろうとも期待しています。

が、それが実現したとしてもそれは今ではないですし、ここに挙げられていないいくつかの課題はきっと残るでしょう。そのうえ更に「ここは歴史的経緯で2つ選択肢があって、今はこっちを使います」のような要考慮点はきっと増えていると考えると、初学者にとってはちょっと辛そうだな、という印象です。

外付けの改良は中長期的には開発体験を損なう方向に作用する

ではアノテーションやIDE、ツールなどを駆使していけば良いのでは?という発想が次に来ます。SonarQubeのようなサービスを導入し、こうした課題をPull Requestレビューの段階で洗い出せば、初学者でも学びながらプログラムを書いていけるのではないでしょうか。

実際にこれは可能だと思います。私自身、JenkinsやTravis CI、GitHub ActionsなどでJSR305やPMDやFindBugs、SpotBugsにGoogle erorr-proneなどを組み合わせて使ってきました。IDE組み込みの解析機も良いものですし、GitHub code scanningやSonarLintも魅力的な選択肢です。

ではこれが永続的な解決かというと、ちょっとそうは思えないなというのが私の意見です。プロジェクトの成長に伴って実行時間がかかるようになりますし、どの警告を受け入れてどの警告を無視するかという判断も必要になりますし、棚上げした課題の棚卸しも必要です。インクリメンタル分析によって速度を改善しようとすると変更しなかった部分との咬み合わせがうまくいかなかったりしますし、俯瞰的に見て初めて見つかる課題をどうやって見つけて対応しようという課題もあります。

ので外付けツールはケアレスミスを洗い出したり中期的な改善施策を検討するうえでの材料にはなりますが、経験者によるPull Requestレビューに比べての教育効果はそこまででもないのでは…と思っています。そもそも言語の課題なのにさらに計算機資源と時間をつっこんで解決しないといけないということ自体が非効率という思いもあり、頼り切りたくは無い感じです。

言語を乗り換えると問題を一網打尽にできる、かもしれない

Kotlinに乗り換えれば、null安全やジェネリクスの課題は改善されます。またコードの長さも短縮されます(愛好会で事例をひとつ紹介しました)。ひとつのアクションで多くの課題が解決されるのは、新しい言語が人類が発見した知見を取り入れて設計されているからです。

外部ツールへの依存が減ることで、開発体験を改善することも期待できます。残念ながらKotlinのコンパイルはまだ遅いですが(手元で試した限りではK2もあまり解決にならない)、静的解析ツールの必須度が大きく減っていることから、相対的には改善されているかなと思っています。

もちろん言語を乗り換えても解決されない問題はあります。Kotlinでも古いコレクションは使えますし、外付けツールに頼っている問題発見もまだまだあります。ので私はここ5年とか10年とかはこうした解決を積み上げながら、また一網打尽にできる言語の登場を待つのでしょう。Flixみたいな実験的な言語をウォッチしているのも、新しい言語が得た知見を不格好でも今の言語に持ち込めないかと期待しているからです。

生成AIがすべてを解決するかもという期待

生成AIによるゲームチェンジを期待しているのが、こうした「人間が気をつけなければいけない」問題を劇的になくしてくれるのではということです。外付けツールとして今まで以上に優秀な解決を提供してくれるかもしれないですし、そもそも人間に書かせないことで問題を根っこから解決してくれるかもしれません。

ただ実行時間がどうしても遅いというところで、まだ自分が期待する「1分以内にフィードバックを返してくれる、開発体験を改善する存在」には遠いかなというイメージですが、これもあと数ヶ月したら変わってるかもしれないですよね。楽しみです。

あわせてよみたい

Threaddump, JFR, JMC周りの知識のアップデート

久々に古い知識を整理していて、けっこう更新されているものが多いのでここにまとめる。

JDK Mission Control (JMC)

JMCはOracleのウェブサイトからダウンロードできる。

標準ではOS標準のロケールが利用される。UIを日本語化する場合は jmc.iniuser.language システムプロパティを設定する。これはJVMに渡す設定なので必ず -vmargs よりも後ろに書く(Eclipseの設定と同じ)。

-Duser.language=ja

利用するJVMは jmc.ini-vm システムプロパティを設定する。これはJVM起動前に使うので -vmargs よりも前に書く。

-vm
C:\Program Files\Java\jdk-17.0.7.7\bin\javaw.exe

ThreaddumpとJFR

昔はThreaddumpファイルを複数取ってIBM Thread Dump Analyzerなどで解析していたが、今ならJFRファイルにスレッドダンプの情報が含まれるためJFRを取得すれば充分そう。

JMCで取得したJFRファイルには、期間中のスレッドダンプの情報が含まれている

JFRファイルはJMCで取得するのが一番手軽。

オブジェクト指向か関数型か、という話題に私達はどう接するべきか

私がコードを書くときには「オブジェクト指向でいくか、それとも関数型か?」みたいなことはほとんど気にしていません。特にオブジェクト指向については人によって定義から違うこともままあるため、この手の議論がとても遠回りになることも多いと感じます。

ただきしださんのLT資料を拝見して、もしかしたらまだ需要があるのかなということで、この話題にどう接するべきか考えていることを書いてみます。

どう書くべきかはコンテキスト次第

結論から書くと、どのようにコードを書くべきかはチームや解決したい課題、利用言語や既存資産などのコンテキストによって変わります。 ので「何がオワコンでこれからは何が来る」みたいな議論は、チーム内という限られたスコープでのみ有効なはずです。 チームよりも広い場で議論する場合は、「どういったコンテキストにおいてどのような書き方をするか」のように若干抽象的なテーマが適切でしょう。

言い換えると、コードの書き方において絶対善や絶対悪は存在しないはずと考えています。例えばバッチ処理ないしウェブアプリケーションでは、複数スレッドから同一データを共有することで性能を高めるため、メソッドの戻り値をキャッシュしたりメモ化を施したりするかもしれません。このためにはデータが不変であると便利でしょう。 しかしこうした実装に登場するデータすべてが不変であるべきかというとそうではなく、むしろ可変データによって性能や可読性が向上することだってあるはずです。

私の経験した範囲でいうと、Repository内部で扱うデータを完全に不変にした結果コピーコンストラクタやシリアライズ・デシリアライズが頻出する読みにくいコードになったことがあります。 今ならライブラリの力を借りてビルダーを実装するなどもっとうまくできる気もしますが、単体テストによる品質担保を厚めにしつつ可変データを導入する手もあったはずでした。

トレンドの書き方が良いソフトウェアを届けるのに必須ということはない

OSSから例を出すと、OpenJDKやKotlin, Gradleといった著名なプロジェクトで使われているObjectWeb ASMはオブジェクト指向で書かれており、継承や配列といった今ならまず採用を避けるであろう書き方も頻出しています。また異なる意味を持つ intString も多く登場し、「この文字列はクラス名だっけティスクリプタだっけ?」といった注意を払いながらコードを読む必要があります。一部ではTypeTypePathみたいな型が用意されていて取り回すこともできますが、そのAPIはカプセル化やTellDontAskといった近年プログラマが慣れ親しんだものとは程遠いものです。

この面ではObjectWeb ASMは「プログラマの認知負荷を下げる」トレンドからは大きく離れていると言えます。

ですが、ObjectWeb ASMは事実上オワコンでなく、JVMエキスパートからの支持を集めて止まないわけです。 加速したJavaのバージョンアップにも速やかに追随し、コミュニティからの貢献を受け付けて修正をデリバリするとともに、リファクタリングや性能改善も行っています。今日SonarQubeを見たところではカバレッジ96.6%でした。まさに「質とスピード」を地で行くプロジェクトです。 *1

この面ではObjectWeb ASMは「ユーザに高い品質とかけがえのない価値を継続的に提供する」ソフトウェアの理想像に限りなく近いと言えます。 だいぶ極端な例ではありますが、トレンドの書き方が良いソフトウェアを届けるのに必須ということはないことを説明する良い事例だと思います。

曖昧な定義や由来を明らかにすることが重要

何がいいかはコンテキストによるのでコンテキストを明らかにしないまま議論をするのはやめましょう、というのが私の主張ではありますが、コンテキストを限定せずとも行うべき重要な議論・問題提起はあります。不明瞭な定義や由来に補足をしてただすものがそうです。言葉が曖昧だと議論が噛み合わず、建設的な議論になりません。例えばまさに今日読んだ Value Objectについて整理しよう - Software Transactional Memo はまさにこの貢献をするもので、とても勉強になりました。

定義や由来を明確にすることは、コンテキストが明らかなチームにおいても重要です。 例えばオブジェクト指向だと 2021年の「オブジェクト指向」を考える で指摘されるように、様々な定義が想起されます。 今話しているオブジェクト指向が何を意味しているのか、議論に参加する各々がきちんとすり合わせる必要があります。

まとめ

コードの書き方は結局、チームが望む働きを実現する道具のひとつです。チームの中で合意が取れているか、コードの理解と変更が容易か、APIや性能が利用者にも受け入れられているか……そういった要件に目を向けるべきです。オブジェクト指向や関数型も私達の道具箱に入っている道具のひとつとして、それぞれ尊重して理解につとめていきたいです。

*1:またAPIの認知負荷が高いのも、言い換えれば他のことに特化していると言えます。これは想像ですが、クラスファイルとプリミティブとの架け橋に徹し高い性能を実現することが設計の主目的かなと感じます。認知負荷については、各利用者が自分のコンテキストに最適化されたAPIを設計しそれでObjectWeb ASMを包むことで解決できます。ObjectWeb ASMはその用途が幅広いため、いたずらに抽象化してしまうと特定ユーザにとって使いにくいものになるでしょう。今のVisitorベースとTreeベースのAPIを提供するくらいがちょうどいいという判断かもしれません。

「非nullのint配列」をアノテーションで表すのは `@NonNull int[]` ではない

正解は int @NonNull [] です。な、なんだってー!

本当です。Java言語仕様書にも記載がありますが、配列を修飾する場合は [] の手前にアノテーションを書く必要があります。JVM仕様書に記載の例のほうがわかりやすいかもしれません:

@Foo String[][]   // Annotates the class type String
String @Foo [][]  // Annotates the array type String[][]
String[] @Foo []  // Annotates the array type String[]

組み合わせて考えると、「要素も配列自体も非nullのString配列」は @NonNull String @NonNull [] になります。コレクションは @NonNull List<@NonNull String> みたいにわかりやすいんですけどね。JavaのRecordでは配列を使わないほうが良いという話の時にも思いましたが、Javaは配列周りに非直感的な挙動が多い気がします。

なお配列だけでなく内部クラスでも同様で、パラメータが非nullの内部クラスを要求することをアノテーションで表現する場合は @NonNull Outer.Inner param ではなく Outer. @NonNull Inner param内部クラス名の手前にアノテーションを書く必要があります。これTwitterで11名にアンケートご協力いただいた限りでは、正答を答えられたのは1名だけという難問でした。

他にアノテーション周りで驚く機能としては、 this を修飾する方法も提供されています。receiver parameterと言います。Pythonのように第1引数にthisを書く形です:

class MyClass {
  void method(@Foo MyClass this, String param) {
    // ...
  }
}

いずれもJava8(2014年)からあった機能ですが、自分は今日まで知りませんでした。クラスファイルパーサを書くことが無ければ、このまま気づかなかったかもしれないです。Java、奥が深い。