はじめに
前回の投稿ではサーバーサイドアプリケーションを開発した際の 戦術的モデリングにのみフォーカスして DDD を実践した話 を失敗談として書きました。この投稿は、私の DDD の実践内容を振り返りつつ、私の DDD の理解に欠けていた戦略的モデリングについての備忘録、考察を提示することを目的としており、扱うトピックがそもそも大きいので非常に長大な投稿となりました。
本当はもっと詳細に語りたかったテーマがたくさんありましたが、テーマからはずれるトピックを入れるような余地がなく、いくつかのテーマの詳細が抜け落ちました。
Java から Kotlin に置き換えた際に得られたメリットを語った部分もそのうちの一つです。Kotlin の機能を利用した結果、多くのメリットを得ることができましたが、失敗談がメインであったので、Kotlin を使ったことで プログラムが硬くなったこと のみとりあげ、Kotlin への置き換えによるデメリットしか詳細に記述できませんでした。
プログラムが硬くなった失敗は Kotlin を導入したから発生したものの、発生要因は我々の設計のミスです。Java を Kotlin に置き換えたデメリットはまったくありませんでした。
Kotlin は、Java に足りていない便利な機能を提供して、Java の設計の問題を解決していると考えています。5 年近く Kotlin を触りましたが、その間に Java で実装しておけばよかった、Java が Kotlin よりも優れたと感じることはありませんでした。
Kotlin は Java 同様に静的型付け言語でありながら、強力な型推論 によって、Ruby や Python が持っている簡易な記述とフレキシビリティをもち合わせている言語であると思っています。
Kotlin の型推論によって型を明示することなく下記のように型安全が実現できます。
val result = "type infer as String"
Java においても、型推論が導入された ものの、var のみしか使えないようです。イミュータブルなプログラミングを実装しようとしたら、Lombok の val を使えば実現できますが、言語として存在しない機能を外部ライブラリにて補うのではなく、言語レベルでサポートしてほしいです。
Kotlin はこの希望を実現します。個人的には、イミュータブルな型推論ができない段階で、Java を使う気にはなれません。
Kotlin の優れた点は型推論だけではありません。型推論は、Kotlin の優れた点の一例であり、その他様々な優れた点があると感じています。
これらの Kotlin の Java に対する優位性についてはしばらく記憶がしっかりしているでしょうが、Kotlin を触らない期間が長くなったりすると薄れていく可能性があり、せっかくの経験が霧散してしまうかもしれないと考えています。そこで、今回の投稿では、前回抜け落ちてしまった、私が感じた、Kotlin のメリットを記述したいと思います。
また、Java にあって Kotlin にはない機能もあるので、それについてもデメリットのようなものとして記述しておこうと思います。なお、私の Java に対する知識は、JDK 11 で止まっているので、なにか別の視点があるかもしれませんので、その点ご留意いただければ幸いです。
Kotlin のデメリット
上述のとおりデメリットはないと考えていますが、2 つだけ「足りてない」と感じるかもしれないポイントがあります。その点について記述しますが、「デメリット」はより良い代替で置き換えられているので、実際のところはデメリットではない、と考えています。
全部非チェック例外
Kotlin は、チェック例外を否定 して、すべての例外を非チェック例外としています。そのため、なにがなんでも呼び出し元に try ~ catch を書かせて例外が発生するかもしれない状況の振る舞いの実装を強制させることはできません。
try (final var is = new BufferedInputStream(Files.newInputStream(file))) {
...
} catch (IOException e) {
...
}
Java では、Files.newInputStream が、IOException チェック例外を発生させる可能性があるので、Files.newInputStream を実行する際は必ず、try ~ catch を記述する必要がありますが、Kotlin では任意です。
絶対に例外が発生することがない局面でいちいち try を書く必要がなくなりますが、時々どうしても呼び出し元に try ~ catch を書かせたくなります。
そのうちの一つは、私が関わったアプリケーションでは、AWS SDK を利用した際に発生しうるライブラリが投げる例外の対処です。勝手に呼び出し先で例外を投げるのではなく、利用元で必ず try ~ catch を書いて、最終的な例外の振る舞いを呼び出し元に決めてもらいたいと考えました。
これを実現するため、AWS SDK のクラスをラップしたクラスを用意して、呼び出し元に、try ~ catch を必ず記述してもらい例外時の振る舞いを実装してもらおうと考えました。
class SesClient(
private val sesService: AmazonSimpleEmailService,
) {
@Throws(SomeCheckedException::class)
fun send(emailAddress: EmailAddress... ) {
try {
sesService.sendEmail(...)
} catch (e: AmazonSimpleEmailServiceException) {
throw SomeCheckedException(e)
}
}
}
SomeCheckedException は、チェック例外です。
利用元が Java である場合は、send メソッドを実行する際は、必ず try ~ catch を実装するか、呼び出し元のメソッド宣言部分に throws を書く必要が生じます。
Kotlin ではそうもいきません。
Result の機能強化
この要件を実現するためのソリューションとして、Kotlin には Result があります。以前は戻り値に指定できず使い道がなさそうでしたが、1.5 から戻り値として指定できる ようになったようです。Result を使う場合、呼び出し元は getOrNull, exceptionOrNull といった関数からメソッドの成功・失敗を判断して振る舞いを決定できます。
class SesClient(
private val sesService: AmazonSimpleEmailService,
) {
fun send(emailAddress: EmailAddress, ... ): Result<Unit> {
return try {
sesService.sendEmail(...)
Result.success(Unit)
} catch (e: AmazonSimpleEmailServiceException) {
Result.failure(e)
}
}
}
class ApplicationService(private val sesClient: SesClient) {
fun sendEmail(...) {
sesClient.send(...).let {
it.exceptionOrNull()?.let { throw it }
}
}
}
Result を使う場合、呼び出し先で勝手に 例外を投げられてしまう副作用 を無くしつつ、呼び出し元で、Result という成功か失敗を意識せざるを得ないオブジェクトを扱うので、例外を無視したうっかり事故を防ぐ可能性が上がると思います。
チェック例外と同等の結果を、簡素な書き方で得ることができる上、呼び出し元になにも考えず、throws を書かれて例外を揉み消されてしまう可能性よりも、Result という型を意識している以上例外が揉み消される可能性が低くなると思われるので優れたチェック例外に対する回答だと考えています。
パッケージプライベートがない
Kotlin には パッケージプライベート のスコープがありません。不必要にインターフェースを公開すると問題が発生するので、一つのパッケージ内で完結させたい機能は、パッケージプライベートがないと不便に感じるかもしれません。
しかし、Kotlin にはこの問題の代替が 2 つあります。一つは 同一ファイル内での private のスコープ です。
Kotlin は Python のように、1 ファイルに複数クラス、関数を記述できます。このクラス・関数のスコープを private にすると、同一ファイル内からのみアクセスできるようになります。
外部に公開したくない、いくつかのクラス・関数で実現している機能は、外部公開用のインターフェースを定義し、あとは、同一ファイル内に private で定義すれば容易に代替できます。
// 同一ファイルからのみアクセスできる関数
private fun getSomething()...
// 同一ファイルからのみアクセスできるクラス
private class SomePrivateClass(...)
// 外部に公開されたメソッド
fun openedMethod() {
getSomething().let {
SomePrivateClass(...)
...
}
}
もう一つは、internal のアクセス修飾子です。
この場合、もっと大きい単位の機能を、gradle なり maven なりでマルチプロジェクト化して、モジュールとして切り出します。これにより、凝集度の高いモジュールを設計するチャンスが生まれます。
モジュールの切り出しがうまくいけば、パッケージプライベートがないことがむしろメリットとして肯定的に捉えることができるかもしれません。
Kotlin のメリット
ここからはメリットを列挙します。よくあるものも含まれていますが、Java と比較して記述します。
デフォルトが NULL 不許可
まっさきに思いつくメリットです。Java で NULL 安全をなんとかする場合、いくつかの方法 があります。コンパイラで見つけることができる、という意味で Optional の方法がよく取られると思います。
しかし、Optional は、返却値としてのみ利用される ことを意図しているので、引数にセットすると IDE が警告を出します。そのため、NULL 安全を徹底すると、アノテーションを使わざるを得ないシーンが現れますが、戻り値を NULL 不許可で宣言したメソッドで NULL を返したとしても、コンパイラはコンパイルエラーを発生させません。
そもそも、アノテーションを記述するという行為が忘れされられてもおかしくない単純かつ冗長な反復的な行為です。全体で徹底させることはかなり難しいんじゃないかと思います。また、忘れないように書いたら書いたで無駄な記述量が増えてコードの可読性もヘタをすると下がります。
Koltin の場合、NULL 不許可がデフォルト で、どうしても NULL 許可にしたい場合は、? をつけるだけです。
Java の場合
if (getSomthing() != null) {
doSomething()
}
// @Nullable をアノテーションできる。しかし、冗長な記述になる上、NULL を考慮しない実装をしても警告ができるだけで、コンパイルエラーになるわけではない。
@Nullable String getSomething() {
...
}
Kotlin の場合
// 戻り値が NULL であることに対してなんらかのアクションがコンパイラに要求される。getSomething()!! と記述すると、Java と同じ結果になるので極力 !! は使わない。
getSomething()?.let {
doSomthing()
}
fun getSomething(): String? {
TODO()
}
最初は奇妙に見えるかもしれませんが、使い続けていると ? を使った NULL 安全を非常に便利だと思うようになると思います。
簡単にイミュータブルにできる
私は、かつて、ワークテーブルに値をセットしたあと、DML でワークテーブルの値を徐々に変えていき、最終的にワークテーブルに入っている値が本当に欲しい値に変わっていくストアドプロシージャのバッチのバグ対応をさせられたことがあります。
ストアドプロシージャのワークテーブルは、1 つだけではなくデータを作るための常套手段として多用されておりました。さらに悪いことに、最終的な成果物を作るためのストアドプロシージャは、3 ~ 4 つ程度存在しており、各ストアドプロシージャの前後関係はジョブ (JP-1) によって管理され、前のストアドプシージャが成功したら、後続のストアドプロシージャが成功したストアドプロシージャの成果物を使って、自分の処理を始める、という設計になっていました。
どのストアドプロシージャも当然のようにカーソルを使い、最大 4 回程度ネストする、カーソルすらありました。このような設計(?)であったため、カーソル内で気軽に SQL が実行されていました。SQL それぞれは単発実行では問題ないのですが、大量データをカーソルで扱うことで、超大量に SQL が乱発されて、チリツモで深刻なパフォーマンスイシューが発生しました。
こんな構成なので、バッチには多くのバグが含まれていましたが、バグが多いことは大した問題ではありませんでした。本当に厄介なのは、なにがどうなって大量のバグが生み出されているのかを、ストアドプロシージャの前後関係・ワークテーブルのデバッグ (実テーブルへの変更) を駆使してひたすら追いかけ続けないといけないのがとてつもなく大変でした。ストアドプロシージャなのでステップ実行できないこともきつかったですが、モジュールの至る所でワークテーブルに DML が発行されて、さながらグローバル変数に対してどこからともなく変更が加えられるプログラムのごとく、一体全体どこでどのようにしてなんのために値が次々と変更されているのかを追跡するのが本当に困難でした。(当然超長時間労働を余儀なくされて地獄を味わいました。)
この経験をして以来、私は突然プログラムのどこかで値を変えられてしまうことに対して、トラウマを持つようになりました。
これほど極端に酷い構成のプログラムにはそれ以降さすがに出会ったことがないですが、しかしその後も、唐突にどこかで値が変えられているおかげで追跡が困難になるアプリケーションの保守を定期的にし続けた結果、setter を憎み、イミュータブル に設計するべきであると考えるようになりました。(さらに、DDD を知りこの考え方 に近い考え方をするようになりました。)
ただし、Java でイミュータブルにしようとすると、いちいち、final をつけるか、Lombok の val を利用することになります。final をつけるのはさすがに面倒であるので、Java でイミュータブルな実装をする場合は、lombok の val が選択肢になると思いますが、
Kotlin の場合は、val が言語レベルで存在しています。
Java の場合
@Nonnull
final String result = getSomething();
Kotlin の場合
val result = getSomething()
クラス定義が非常にシンプル
Java では lombok の @RequiredArgsConstructor (DB の永続化をする場合は消極的理由で @Getter) がないと大量のボイラーテンプレートが必要になります。
Kotlin では Lombok がクラス定義でやりたいことを言語レベル提供します。上記の NULL 安全、イミュータブルの容易性もあいまって、簡単にクラスを定義できます。
Java の場合 (lombok なしの場合)
//// lombok で @RequiredArgsConstructor と @Value をつければメンバ変数だけにできます
public class MyClass {
@Nonnull final String prop1;
@Nonnull final String prop2;
// lombok の @RequiredArgsConstructor で代替可能
public MyClass(@Nonnull String prop1, @Nonnull String prop2) {
this.prop1 = prop1;
this.prop2 = prop2;
}
// lombok の @Value で代替可能。
public @Nonnull String getProp1() {
return this.prop1;
}
public @Nonnull String getProp2() {
return this.prop2;
}
}
Kotlin の場合
class MyClass(val prop1: String, val prop2: String)
Getter の回避
Lombok を使うと、Getter (や Setter) でプロパティアクセスせざるを得なくなり、オブジェクトの本質からそれてしまう実装になってしまいます。
さらに、オブジェクト思考はオブジェクト不変性につながる。 犬の体重の例を、君は以下のように書き換えるだろう。
Dog dog = new Dog("23kg");
int weight = dog.weight();
この犬は不変な生きた有機体であり、だれも外からその体重やサイズや名前などを変更することはできない。この犬は要求に応じて体重や名前を教えてくれる。オブジェクトの中身を要求するパブリックメソッドには何の問題もないが、こういったメソッドは”getter”ではなく、”get”というプレフィックスは決して付かない。私たちは犬から何かを取ろうというのではない。犬から名前を取るのではなく、犬に名前を教えてくれるよう頼むのだ。 この違いが分かるかな?
https://www.kaitoy.xyz/2015/07/22/getters-setters-evil/
プロパティには、get といったプレフィックスはつかないので getter の問題が解決します。ただし、JDK 17 で record class が GA したようなので、シンプルさという観点ではかつてほどの優位性はないかもしれません。
クラスの増やしすぎに注意
脱線しますが、簡単にクラス定義できることによって失敗したと感じている内容を記録しておきます。
たびたび物事を整理するために大きなクラスの役割を削って小さなクラスに分割するべき という議論が出ます。私はこの議論のとおり、クラスの役割をなるべく小さくする設計を心がけました。Kotlin はこの試みにピッタリだと感じていました。
クラスの役割は少なくなり小さなクラスが増えていきました。試みは成功したかと思いましたが、次第に「クラスを小さくする」ことそのものが目的となり 2 つの弊害が発生しました。
怠け者クラス
小さなクラスが自己完結せず、依存関係を持つようになりました。実装したタイミングではクリアだったはずのクラスの関係性が、第三者が見たり、その後見返してみると、直感的ではなくなってしまうことが度々発生してしまいました。
class BridgeClass(private val prop1: String, private val prop2: String) {
fun createGoal() {
return GoalClass(prop1 = prop1, prop2 = prop2)
}
}
class GoalClass(private val prop1: String, private val prop2: String)
極端な例ですが、BridgeClass は GoalClass を作るためだけに存在したクラスです。GoalClass を生成するために 1 つクラスを挟む必要があります。BridgeClass から GoalClass への依存が生まれることで、無駄に複雑な設計になってしまいました。BridgeClass は 怠け者クラス となり凝集度を下げています。
基本データ型への執着への回避のしすぎ
当時の私は、値オブジェクト、リポジトリといった DDD のツールを使って実装することを目的としていたと思います。DDD にとにかく当てはめようとしていたので、
アプリケーション上で取り扱われる「値」は、すべて単純な文字列や数値型で表現するべきではなく、値オブジェクト になるべきであると考えていました。Kotlin で簡単にクラスを定義できることをこれ幸いとして、基本データ型への執着 を回避し、それが DDD の目的を達成していると考え、多くの値を機械的に値オブジェクトとしていきました。機械的に作られた「値オブジェクト」には、下記の考慮がありませんでした。
ここにきて、あらゆるものが値オブジェクトに見えるようになってきたのかもしれない。[...] ただ、少々注意すべき場面もある。非常にシンプルな属性で、特別扱いが一切不要なものを扱う場合だ。おそらくそれは、Boolean の値であったり、自己完結した数値であったりするだろう。特別な機能は不要で、同じエンティティ内の他の属性とも一切関係はない。そのシンプルな属性が単体で、意味のある全体を成している。それなのに、その属性単体を値型でラップしてしまうという「間違い」を犯してしまいかねない。特別な機能を何も持たないそんな値型を作るなら、値を一切作らないほうがまだマシだ。少しやりすぎだと気づいたら、常にリファクタリングを検討しよう。
『実践ドメイン駆動設計』Kindle 版 p.221
また、型を作ることで、メソッドの引数に渡す時の順番を間違えて誤った結果になってしまうミスがなくなるのでそれがメリットだと考えていました。
引数に渡す値を間違えないようにする、という考え方が支配的になり、値オブジェクトとして定義すること自体に問題のない概念に対して、さらに細分化した値オブジェクトを定義をしてしまう場合もありました。
たとえば「住所(PostalAddress)」は値オブジェクトになりうるとしても「出荷先住所(ShippingPostalAddress)」はいずれかのオブジェクトの属性であってこれ自体が値オブジェクトではない。コードにしてみるとものすごい違和感がある…。
// 出荷
public class Shipping {
// 出荷先住所
private ShippingPostalAddress postalAddress;
// あるとしても、こっちでは? PostalAddress postalAddress;
// 以下 略
}
仮に、出荷先住所(ShippingPostalAddress)を是とするならば、住所(PostalAddress)からサブタイプを作るのか。それとも継承ではなく委譲を使って特化した型を作るのか。いずれにしても、出荷先住所(ShippingPostalAddress)は、一般的な住所(PostalAddress)より特化した責務を担うのかどうか。なかなか想像しにくい…。通常なら住所(PostalAddress)で十分ではないかと。
https://blog.j5ik2o.me/entry/2022/05/17/135531
クラスは増やせば増やすほどよいと考えて、コンテキストは考慮されませんでした。
この結果、類似したクラスが増えていき、クラスの差異が実装者以外にはよくわからない、という混乱が生じてしまいました。
名前付き引数をつかえる
引数を指定する際に、Java は順番でしか実現できませんが、Kotlin は引数名を呼び出し元で指定できる、名前付き引数 が利用できます。
引数名があっていれば、引数の順番を無視して正しく引数を渡せますので引数の取り違えがなくなります。先の値オブジェクトの議論で、渡す値を取り違えなくするためクラスを大量に作った、と書きましたが、名前付き引数を使うことでこの問題は解決できる可能性が上がり、クラスを都度増やすモチベーションを下げます。
public BigDecimal calculate(BigDecimal price, BigDecimal discount) {..}
// 上記インターフェースである場合
BigDecimal price = new BigDecimal("1000");
BigDecimal discount = new BigDecimal("10");
// 引数の順番を取り違える可能性がある
calculate(discount, price)
fun calculate(price: BigDecimal, discount: BigDecimal) = TODO("")
val price = BigDecimal("1000");
val discount = BigDecimal("10");
// 名前付き引数で順番が違っていても正しく引数にマッピングできる
calculate(discount = discount, price = price)
また名前付き引数を使えばいちいち変数に値をバインドすることなく直接指定しても混乱は生じにくいです。
// 名前付き引数が変数宣言のような役割になっている
calculate(discount = BigDecimal("10"), price = BigDecimal("1000"))
変数へ値をバインドしない実装は、変数のライフサイクルに関してのメリットがあります。メソッド呼び出しでのみしか使わない変数は、それ以外で使われないのであれば、いちいち宣言をするべきではないと考えます。変数宣言すると、変数を必要としていない場所からでも変数を参照可能になってしまうので、どこでなんのために使われるものであるかの判断をのちのち見誤る可能性があると思っています。
デフォルト引数は使いすぎない方が却ってわかりやすいと思う
名前付き引数の説明 でデフォルト引数について書かれています。
fun foo(
bar: Int = 0,
baz: Int = 1,
qux: () -> Unit,
) { /*...*/ }
foo(1) { println("hello") } // Uses the default value baz = 1
foo(qux = { println("hello") }) // Uses both default values bar = 0 and baz = 1
foo { println("hello") } // Uses both default values bar = 0 and baz = 1
呼び出し元で引数を指定しない場合は、呼び出し先の引数で定義したデフォルト値が採用されます。この機能は便利であるのですが、自分の経験上、やりすぎると暗黙的になって、あとでどういう使われ方をしているかを IDE の階層機能を使って調査をする時、判断を迷う可能性があがります。
個人的にはビジネスロジックを実装する時には使わない方があとあとみやすいように感じています (ケースバイケースではあるとは思いますが)。呼び出し元でなんの値を指定しているかを明白にした方が、たとえ冗長になったとしても、曖昧さがなくなり調査しやすかったです。
デフォルト値を使うと、呼び出し元 (値を指定しているのか指定していないのかの確認) と呼び出し先 (値のデフォルト値はなになのか) を行ったり来たりして、集中力が切れがちになりました。
デフォルト値の活用ポイント
モバイルアプリケーションのコンポーネントを作る場合や、テストクラスで Fixture を作ったりする場合はデフォルト値を使った方がメンテナビリティが向上すると考えています。モバイルアプリケーションの、例えばテキストコンポーネントといった部品は、背景色のプロパティは多くの場合は白になりますし、しかも、コンポーネントの性質上非常に多くのプロパティが存在することが予想されます。呼び出し元で指定する値が類似しており、多くのプロパティがある場合、明示的にすべて指定する方が却ってわかりずらくなってしまいます。
これは、テストクラスの Fixture も同様です。テスト用に作りたいテストオブジェクトで明示的に指定したいプロパティは、テスト観点に沿ったものだけになり、テスト観点に入っていないプロパティはどのような値であっても問題ありません。テストオブジェクト生成時にすべてのプロパティを明示させると、却ってテスト観点をぼやけさせてしまうので問題があると考えています。
data class がある
data class は、lombok の @Data または @Value と同等のことができる上、copy メソッドも持っています。値の変更があるユースケースでは copy メソッドを使うことで、BeanUtils に頼らずとも容易に ディープコピー を実現できます。
val myClass = MyClass(prop1 = "old1", prop2 = "old2")
val copied = myClass.copy(prop1 = "newValue") // prop2 の値はそのままに prop1 だけ値を変えた新規インスタンスを生成できます
data class は多用しない方がよいかもしれない
data class の copy メソッドを使うと、setter を使わず、簡単にイミュータブルな値変更を実現できます。その一方、copy メソッドはお気軽に値が変更された新規オブジェクトを生成します。元になっているインスタンスに変更がないとはいえ、copy メソッドを実行するたびにあらたなインスタンスが現れるので、copy を連発すると多くのインスタンスが生成され、混乱を生み出す可能性があると思います。また、data class は通常のクラスより重たく、6000 個のインスタンスを生成した際に、たんなるクラスよりはるかにインスタンス生成に時間がかかりました。そんな大量のデータを扱う可能性が低いながらも、特別な理由がない限り、基本は data class を使わない方が問題を生みにくいのかもしれない、と考えています。
ただし、value class は、data class の上記問題点を解消しているので、使える場面では積極的に使った方が良いと考えています。
パターンマッチが便利
enum や sealed class/interface を when で使うと、else を書かない限り、すべての可能性を網羅的 (exhaustive) に列挙するよう強制できます。enum であれば、すべての enum のパターンを、sealed ならば継承先のクラスを when の内側に記述しなければコンパイルエラーになります。個人的にはこの設計は、単一責任の原則 を破る可能性があったとしても、よりよい instanceOf を使わないようにする設計の代替 であると考えています。
これまで、私は、分岐が減ったり増えたりした時に、増えた時は追加だけ、減らした時は減らすだけにして、変更対象以外のクラスが変更されないよう Visitor パターン や 区分オブジェクト を使った ストラテジーパターン といった方法で、抽象クラスを定義して条件分岐を記述するコードを減らそうと考えていました。条件分岐が減ると、if や switch が消えて可読性が向上する副次効果もあると考えていました。
こういった実装の抽象化は、実装直後は問題ないのですが、しばらくすると、Visitor パターンはクラスが循環参照するので依存関係の複雑さからコードを読み解くのに時間がかかりましたし、ストラテジーパターンを使うと、確かにコントロール部分と、ロジック部分の依存は消えるものの、どういったユースケースがあるかのコンテキストが enum/sealed class/interface に入り込み、ストラテジーを利用している箇所では「どういった場合にこのストラテジーを使うのか」「なんのためにストラテジーにしているのか」といった理由をコメントがないと保守することが難しくなるように思いました。
また、ストラテジーといった派生のパターンを使うと、「なにもしないメソッド」「親クラスのメソッドを呼び出すのみ」といった実装をしたり、関係のある部分のみをさらにインターフェースで抽出して実装させたりせざるを得ない場合がありました。
interface MyType {
fun value(): Int? = 100
}
class TypeA : MyType {
override fun value() = 200
}
// TypeB, TypeC では value を利用するユースケースで考える必要がないので便宜的に null を返している。
class TypeB : MyType {
override fun value() = null
}
class TypeC : MyType {
override fun value() = null
}
// TypeC では基底のインターフェースと同じ値になるので value() をオーバーライドしない
class TypeD : MyType
当時の設計の問題かもしれませんが、どうにも、TypeB, TypeC の場合は無駄なことをやっているように見え、TypeD の場合では暗黙的に感じるようなシーンが多く感じられました。
なにより、やりたいことに対して多くの実装が必要になり、複雑に感じるようになってきました。
抽象を考えず、個別のユースケースを全部列挙してしまったほうが、モノ・コトを現実に即した形で表現できるのではと考えるようになりました。
enum class MyType {
TypeA,
TypeB,
TypeC,
TypeD
;
}
when (MyType.valueOf(value)) {
TypeA -> 200
TypeB, TypeC -> 0 // 0 を返すことで後続のなんらかの処理で計算に矛盾が生じない。
TypeD -> 100
}.let {
// なんらかの処理
}
いろいろな考え方があると思いますが、if/switch を使った根本的な問題は、可読性を下げるというか、すべての可能性を列挙できないのでパターンの追加の変更が入った時に問題が生じる可能性が高いことであると考えています。when で列挙してしまえばこの問題が解消されます。
とはいえ、相変わらず MyType に新規パターンが加われば、コントロール部分に影響がでるので、それがストラテジーパターンを使わなくなってコンテキストを得た代償とも言えそうです。
これが良いかについては、都度判断していかねばならないと思います。
なお、Java では sealed class は GA したもののパターンマッチがプレビューの状態 のようでした。
既定の拡張メソッドが便利
Java には基本的に足りない機能が結構あると思います。そのため、例えば、文字列に関する機能が足りていない場合は、apache の StringUtils といったライブラリを導入したり、自前で Util を作ったりする機会が結構多い印象です。
しかし、Kotlin には多くの便利な機能が既定で用意されています。そして、こういった機能は、規定のクラスを拡張して実装されているので、Util クラスという不自然なクラスに頼ることなく、自然に利用することができます。
例えば、String クラスに、repeat とか trim といったメソッドが提供されています。
⭐️ 2023/11/23 追記
Kotlin のみ存在しているかと思っていましたが、Java にも repeat, trim が実装されていることをご指摘いただきました!
ただ、trimMargin や take といった、「あるとさらに便利」な関数は未実装のようでした。
⭐️ 2023/11/23 追記
Kotlin の take や takeWhile といった関数に相当する機能も Java にある ことをご指摘いただきました!
JDK 1.8 より前になかった大多数の Java の文字列の操作に関する設計思想は Kotlin とは根本的に異なっているようです。Kotlin の take に相当する limit も同様です。Kotlin の take は、substring をラップしたような本当に文字列クラスの持ち物になるような素朴なメソッドであるのに対して、Java のそれは、Stream の持ち物になっており、独特の操作 が必要のようです。
String cut = "abcdefg".codePoints().limit(2)
.collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
.toString();
// ab が表示される
System.out.println(cut);
生成した StringBuilder に対して、文字コードを突っ込んでいく終端処理になっていると思われます。難しい。
Iterable に拡張された、joinToString といったクラスも便利です。こういった関数が既定の機能として存在しているので、for のループはもちろん、StringBuilder といった煩わしいクラスを使わなくて良くなります。
StringBuilder を使った実装
StringBuilder stb = new StringBulder();
List<String> list = Arrays.asList("Apple", "Orange", "Melon");
for (int i = 0; i < list.size; i++>) {
stb.append(list[i]);
if (i != list.size -1) {
stb.append(",");
}
}
⭐️2023/11/23 追記
Java には、String の静的メソッド join が存在することを教えていただきました。
String[] source = { "a", "b"};
String to = String.join(",", source);
Kotlin の場合
listOf("Apple", "Orange", "Melon").joinToString(",") { it }
クラスの拡張は自由にできる
クラスの拡張は、既定のクラスだけでなく、Ruby のモンキーパッチ とすべてのクラスで実現できます。
たまにフレームワークのクラスが本来もっていて欲しいと思う、機能を追加できるのは便利でした。
ただし、やりすぎるとわけがわからない機能が追加される危険性があるので禁物ですが。
なお、簡単に List を作ることができる listOf も便利だと思ったのですが、知らぬ間に、Java に asList ができていたので、これは Java が追いついたようです。これで簡単に List を生成できるので and や or の代替が簡単にできるようになりました。
Java の場合
// or の場合
Arrays.asList("Apple", "Orange", "Melon").contains("Apple")
// and の場合
Arrays.asList("Apple", "Orange", "Melon").stream().allMatch(x -> x.equals("Apple")
Kotlin の場合
// or の場合
listOf("Apple", "Orange", "Melon").contains("Apple")
// and の場合
listOf("Apple", "Orange", "Melon").all { it == "Apple" }
スコープ関数でスコープを制限しながら変数の値のセットを省略できる
Java だと、なんらかの値を一度一時変数に代入する機会が多くなります。一時変数に値をセットすると、原則すべての後続の手続きで宣言した一時変数にアクセスできるようになります。どの手続きからでもアクセスできてしまうので一時変数を使うと、参照する一時変数を取り違えて思わぬバグを産む場合があります。私もなんども本来触らなければならない一時変数ではなく、触ってはいけない一時変数を触ってバグを生み出しました。
スコープ関数 を使うと、変数の代入する必要がなくなり、アクセスできる箇所を制限できます。関数の結果は、スコープ関数の引数の関数内でのみ利用できる暗黙的な it に入るので、変数のスコープとライフサイクルを短くすることができます。一時変数の代入が消えることで、間違った一時変数へのアクセスによるバグの発生を抑えることができると思います。
Java の場合
MyResult doSomething() {
int aPrice = calculate(100);
...
int myPrice = calculate(aPrice + someResult)
...
// myPrice をセットしたいが aPrice にもアクセスできる。取り違える可能性があるので、MyResult のタイミングでは aPrice にアクセスできないようにしたい。
return new MyResult(myPrice, ....)
}
Kotlin の場合
fun doSomething(): MyResult {
return calculate(100).let {
...
calculate(it + somResult)
}.let {
...
// MyResult 生成時には必要な変数にしかアクセスできない (calculate(100) の結果はこのスコープからは読み取ることができない)
MyResult(it,,, ..)
}
}
if や try が戻り値を持つ
Kotlin は、if、when、try は、文ではなく値を返す式です。値を返してくれるので、Java の時にどうしても必要だった、文開始前の値の一旦の宣言が不要になります。
Java の場合
MyResult doSomething() {
// まず宣言が必要
SomeObject ret = null;
try {
// 代入しないといけない
ret = getSuccessObject();
} catch (e: Exception) {
ret = new Failure();
}
return new MyResult(ret, ....);
}
Kotlin の場合
fun doSomething(): MyResult {
return try {
getSuccessObject()
} catch (e: Exception) {
Failure()
}.let {
...
MyResult(ret, ...)
}
}
文字テンプレートが使える
kotlin には 文字列テンプレートがあります。これまで、StringBuilder や、プレーンの Thymeleaf でなんとかわかりやすくなるよう努力していた問題が解消できました。
Java の場合
StringBuilder stb = new StringBuilder();
stb.append("こんにちは");
stb.append("\n");
stb.append(name);
stb.append("さん");
Thymeleaf の場合下記になります。プレーンテキストの場合記法が独特です。
こんにちは
[[${name}]]さん
Tymeleaf を使うと、テキストを外だししないといけないので、テンプレートが見つからないと実行時例外になります。
静的なコンパイラが存在しないので、記述内容が間違っていても実行時例外が出ます。
コンパイルで不整合を発見できないので、実行時例外の可能性と付き合う必要があります。Kotlin ではこの問題点がすべて解決できます。
kotlin の場合
"""
こんにちは
$nameさん
""".trim()
⭐️ 2023/11/23 追記
Java 13 で、Text Block が登場したことを教えていただきました。
Text Block では、変数をバインドすることはできないようですが、一番少ないインデント行を基準にトリムが行われて、Kotlin の trim 相当のことが実現できるようです。format または formatted を使うと、指定する引数の順番を意識せざるを得ないので可読性に難が生じますが、Kotlin と同等のことができるにはできます。
// Deprecated はマークされていないが、formatted は削除される可能性があるとのこと。。。
String content = """
ようこそ
%s 様
よろしくお願いします。
""".formatted("何某");
String content = String.format(
"""
ようこそ
%s 様
よろしくお願いします。
""", "何某"
);
マージンの操作については、Kotlin には trimMargin メソッドもあるので、
// ご機嫌いかがでしょうかの前に一つスペースを入れたい
"""
|ようこそ
|$name 様
| ご機嫌はいかがでしょうか
""".trimMargin("|")
といった具合に | のような区切り文字を引数に指定してインデントに対するインデントのようなオプションもありますが、Java では、trim メソッドのみになります。同様のことをやる場合、インデント調整を慎重に行いながら実現する (これは Kotlin の trim メソッドも同じ) か、transform で | などの区切りを見つけてインデントする実装が必要になると思われます。
いずれにしても、Java も最近のプログラミング言語の潮流を意識しているような機能を取り入れていて、あの保守的な言語がずいぶん変わったもんだ、と思う次第です。
Java/Kotlin の相互運用が可能
Java からも Kotlin のリソースにアクセスできるし、Kotlin からも Java のリソースにアクセス可能です。言語移行の過渡期に併用できます。
演算子オーバーロードが快適
Kotlin では、演算子オーバーロード が利用できます。この機能を使うと Java で表現すると解読することが困難であったクラスの操作を、直感的に記述できます。
Kotlin の既定の機能で、Java で扱いづらかった、BigDecimal や Collection といったクラス/インターフェースの操作に対して演算子オーバーロードが実装されています。Util クラスといったもので自力実装・拡張することなく、add, plus といったメソッドに対して、直感的な記述ができるようになります。
Kotlin の BigDecimal の演算子オーバーロード
@kotlin.internal.InlineOnly
public inline operator fun BigDecimal.plus(other: BigDecimal): BigDecimal = this.add(other)
/**
* Enables the use of the `-` operator for [BigDecimal] instances.
*/
@kotlin.internal.InlineOnly
public inline operator fun BigDecimal.minus(other: BigDecimal): BigDecimal = this.subtract(other)
/**
* Enables the use of the `*` operator for [BigDecimal] instances.
*/
@kotlin.internal.InlineOnly
public inline operator fun BigDecimal.times(other: BigDecimal): BigDecimal = this.multiply(other)
/**
* Enables the use of the `/` operator for [BigDecimal] instances.
*
* The scale of the result is the same as the scale of `this` (divident), and for rounding the [RoundingMode.HALF_EVEN]
* rounding mode is used.
*/
@kotlin.internal.InlineOnly
public inline operator fun BigDecimal.div(other: BigDecimal): BigDecimal = this.divide(other, RoundingMode.HALF_EVEN)
/**
* Enables the use of the `%` operator for [BigDecimal] instances.
*/
@kotlin.internal.InlineOnly
public inline operator fun BigDecimal.rem(other: BigDecimal): BigDecimal = this.remainder(other)
Kotlin の Collection の演算子オーバーロード
public operator fun <T> Collection<T>.plus(elements: Iterable<T>): List<T> {
if (elements is Collection) {
val result = ArrayList<T>(this.size + elements.size)
result.addAll(this)
result.addAll(elements)
return result
} else {
val result = ArrayList<T>(this)
result.addAll(elements)
return result
}
}
Java の場合
// add メソッドを記述しないといけないので、() がたくさん出てきて見づらい
BigDecimal result = new BigDecimal("1").add(BigDecimal("2"))
// なにかを追加しようとすると、別操作を都度各必要がある。
List<MyObject> list = new ArrayList<>();
list.add(new MyObject(...))
list.add(new MyObject(...))
Kotlin の場合
// 演算子オーバーロードで、BigDecimal の plus は + で足し算を表現できる
val result = BigDecimal(1) + BigDecimal(2)
// 演算子オーバーロードで、Collection の plus は + で Collection への追加結果を生成します。
val result = listOf(MyObject) + listOf(MyObject)
高階関数が使いやすい
Java でコレクション型の操作を高階関数で行う時、Stream オブジェクトを生成する必要があります。なぜ Java の Collection 操作には Stream オブジェクトを生成する手続きを必要とする設計であるかの理由はわかっていませんが、プログラムの 1 利用者としては stream と書くことが冗長に感じられます。
⭐️2023/11/23 追記
Stream オブジェクトを生成する理由を教えていただきました!
Kotlin にて高階関数を使う場合、即時にリストを生成します。
public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.mapTo(destination: C, transform: (T) -> R): C {
for (item in this)
destination.add(transform(item))
return destination
}
対して、Java の Stream オブジェクトが高階関数を使う時は、Stream を実装した StatelessOp オブジェクトを生成するにとどめ、終端処理までリストが生成されるのが遅延される動作になります。
public final <R> Stream<R> map(Function<? super P_OUT, ? extends R> mapper) {
Objects.requireNonNull(mapper);
return new StatelessOp<P_OUT, R>(this, StreamShape.REFERENCE,
StreamOpFlag.NOT_SORTED | StreamOpFlag.NOT_DISTINCT) {
@Override
Sink<P_OUT> opWrapSink(int flags, Sink<R> sink) {
return new Sink.ChainedReference<P_OUT, R>(sink) {
@Override
public void accept(P_OUT u) {
downstream.accept(mapper.apply(u));
}
};
}
};
}
これによって、複数の高階関数を組み合わせた場合 (例: map { it }.filter { it == "s" } )、Kotlin の場合では都度都度リストが生成されるような挙動になりますが、Java では終端処理において一度きりのリスト生成が行われる動作になると思われます。Java では、高階関数を組み合わせた場合を想定してチューニングしたトレードオフとして Stream オブジェクトを生成するのに対し、
kotlin では、Stream に該当するオブジェクトの戦略を取らず素朴な実装で高階関数を実現しています。複数の高階関数を使った際に余分なリソースを使うトレードオフとして、Stream オブジェクトの生成が不要になります。Kotlin の場合、明示しなければ、関数の引数の関数内で処理対象のオブジェクトに it としてアクセスできます。
Java の場合
Arrays.asList("apple", "orange", "melon").stream().map(x -> x + "です").toList()
Kotlin の場合
listOf("apple", "orange", "melon").map { "$itです"}
また、fold などのいくつかの関数は Java にはないと思います。時々複数行あるオブジェクトを、別のオブジェクトにまとめたくなるような場合があり、重宝していました。
⭐️ 2023/11/23 追記
Java にも fold 相当の処理を、reduce で実現できることを教えていただきました!
Kotlin の reduce には、List<T>
-> T
の畳み込みの機能だけしかありませんが、Java の reduce はオーバーロードされていて、そのうちの一つ で Kotlin の fold 相当のことができました。
<U> U reduce(U identity,
BiFunction<U, ? super T, U> accumulator,
BinaryOperator<U> combiner);
このシグネチャを使う場合は、通常の複数行を 1 つのものに畳み込む、
Optional<T> reduce(BinaryOperator<T> accumulator);
とは異なり、Parallel で Stream を動かす時にコールされる第三引数の関数をセットしないといけないようです。
for でオブジェクトを移し替える (Java)
class FromObject {
final int a;
final int b;
FromObject(Int a, Int b) {
this.a = a;
this.b = b;
}
}
class ToObject {
final String c;
final String d;
ToObject(String c, String d) {
this.c = c;
this.d = d;
}
}
List<FromObject> fromList = Arrays.asList(new FromObject(1, 2), new FromObject(2, 3);
List<ToObject> toList = new ArrayList<>();
for (FromObject from : fromList) {
toList.add(new ToObject(from.a.toString(), from.b.toString()));
}
Java の reduce でオブジェクトを移し替える
final BiFunction<List<ToObject>, FromObject, List<ToObject>> operation =
(accm, value) -> {
accm.add(new ToObject(value.a.toString(), value.b.toString()));
return accm;
};
final BinaryOperator<List<ToObject>> binaryOperator = (toObjects, toObjects2) -> toObjects;
var from = Arrays.asList(new FromObject(1, 2), new FromObject(3, 4));
var to = from.stream().reduce(new ArrayList<>(), operation, binaryOperator);
Kotlin の fold でオブジェクトを移し替える
class FromObject(val a: Int, val b: Int)
class ToObject(c: String, d: String)
// 先述の plus メソッドの演算子オーバーロードが Collection オブジェクトへの要素追加 + その結果を戻す関数になっているのでワンライナーで fold の引数の関数を定義できます。
listOf(FromObject(a = 1, b = 2), FromObject(a = 2, b = 3)).fold(emptyList<ToObject>()) { acc, current ->
acc + listOf(ToObject(c = current.a.toString(), d = current.b.toString()))
}
終わりに
私自身、Java はキャリアの最初の方に覚えた言語で、Java は自分のプログラミング言語の規範となるような存在です。当時全盛であった Struts, そして XML 不要の Seasar といったフレームワークが出てくるたびに便利さに感動していました。Java は非常に優位性のある言語であるということに疑念を全く持っていませんでした。
その一方で、何度も同じようなことを書くことにうんざりする場面は結構ありました。
後々 Java 以外の Ruby (Rails) や Python (Django) といった言語を触ってみると、どうにも Javaは使いづらいのでは? と感じるようになっていきました。
Kotlin を触った時の感想も、Ruby や Python を触った時と同じで、利用者のことを考えている直感的な設計・ 簡潔なシンタックスにより、私が Java を使っている時に感じていたフラストレーションの多くが消えたと思います。
なにかを実現するためには必要最小限のことのみを効率的に実装したい、さらにそれを静的な型で実現したい、という願望を実現してくれる非常によい解決策だと思います。
Kotlin を知って得た考え方の多くは、他の言語を習得するのに転用可能です。Dart を触る機会がありましたが、Kotlin 言語設計を知っていたので、全く違和感なく使うことができました。
Kotlin は Java とシンタックスもいかよっているので、Java でフラストレーションを感じている習熟したプログラマであれば、フラストレーションを解消してすぐに使うことができると思います。
新しい考え方を得ると考えることができる幅も広がるので、現状に満足することなく新たな考え方を吸収していくべきであると考えているのですが、Kotlin はそのきっかけとしてとても良いものでした。
Java から の移行のハードルの低さと新たな考え方を得るためのツールとして非常に魅力的ですので、ぜひ今後も Kotlin には発展を遂げていって欲しいものであると思っています。