2023.01.11
データサイエンティストが感じたプログラミング言語Kotlinの美しさ
次世代システム研究室のT.Sです。私は弊社内で「シニアデータサイエンティスト」という職を頂いており、かなりMLエンジニア寄りではあるもののデータ分析なども実施したりしております。そういったデータ分析の際にはpythonを主に利用していますが、ここ最近それ以外の分野でKotlinを利用する機会が多くなってきました。その都度「この言語素晴らしいなあ…」と感嘆することが多く、ぜひ今回は私が感じたその素晴らしさをここに記すとともに、Kotlinの魅力を少しでも皆様にお伝えできればと思います。ちなみに下記で色々コードを記載していますが、よりよいコードの書き方も有ると思います。そのため参考までにご覧いただければと。
※ちなみにKotlinをデータ分析業務に使うのはかなり厳しく、その分野におけるpythonの優位性というのは崩れません。あくまで今回はpythonを日頃扱う人間がKotlinで受けたマニアックな感動を共有したいというだけの思いでございますのでご容赦ください…
Kotlinとは
Kotlinとは、JetBrainsが開発したプログラミング言語になります。JetBrains社の名前を知らなくてもIntelliJ IDEAやPyCharmにお世話になっている方は多いのではないでしょうか?一般にはこういったIDE(統合開発環境)の会社として有名であります。
Kotlinは以下の特徴を持った言語となります。「簡潔に」という説明がありますが、ここがまさに素晴らしい点でKotlinで記載された簡潔且つ美しいプラグラミングスタイルは本当に惚れ惚れするものであり、ここが今回訴えたい点でございます!
- Javaをより簡潔にかけるように開発された言語
- Java仮想マシン(JVM)上で動作
- Androidアプリ開発などで多く利用
素晴らしいと感じた点
sealed class: 状態によって内容が変わるデータの管理
まず最初はsealed classになります。この機能が私が一番好きで、Kotlinが最も美しいと思えるものです。。。
ユースケースとして以下のものをdata classとして定義したいと思います。よくあるHTTP周りのお話ですね
- あるAPIにリクエストした結果を格納するdata classを定義
- リクエストが成功した場合は、そのHTTPステータスコードと返却された値を格納する
- リクエストが失敗した場合は、そのHTTPステータスコードとエラーメッセージを格納する
さてこれを格納する一つのclassを作るとしたら皆さんどんなものを作るでしょうか??すごく単純に作ろうと思うのとこんな感じでしょうか?
from dataclasses import dataclass @dataclass class ApiResult(): http_status_code: int retrieved_data: str = None error_msg: str = None
たしかにこの3つのフィールドがあればデータは格納できますね。
しかしこのコードには可読性の面で大きな問題があります。それが何かというと[retrieved_data][error_msg]の値有無が、[http_status_code]の値に依存しているということです。
- http_status_codeが成功の場合
- retrieved_dataに値が入る
- error_msgはNoneのまま
- http_status_codeが失敗の場合
- retrieved_dataはNoneのまま
- error_msgに値が入る
「これの何が悪いの?見れば別にわかるでしょう??」と思われる方もいらっしゃるかと思いますが、可読性という点ではこれは非常に問題となります。そもそもどの値がどの場合に入るかコード自体で担保されていないと、どう使ってくるか保証ができません。http_status_codeが失敗の場合にretrieved_dataに値を入れないと断言することもできません。こういうコードを書いてしまうと結局値を詰めている箇所を読まないとすべてが判断できず、コードの把握に時間がかかったり、バグを混在させることになります。こういったソースコードは[直交性]がないコードとして、排除すべきだとよく言われていたりしますね。
pythonでこれを排除しようと思うとこんな感じになるでしょうか???(もっと簡単に書けそうな気もしますがご容赦ください)
ただこの場合でもprivateコンストラクタでないため、人によってはコンストラクタをそのまま使ってしまったり、プロパティが増えるたびにisXXXXに修正有無を考えたりとまだまだ難しい構成になっていそうな気がします。
class ApiResult: # privatre consutructorにできないため完全ではない def __init__(self, http_status_code, retrieved_data, error_msg): self.http_status_code = http_status_code self.retrieved_data = retrieved_data self.error_msg = error_msg @classmethod def asSuccess(cls, http_status_code, retrieved_data): if not http_status_code or not retrieved_data: raise ValueError("args must not be null!") return cls(http_status_code, retrieved_data, None) @classmethod def asError(cls, http_status_code, error_msg): if not http_status_code or not error_msg: raise ValueError("args must not be null!") return cls(http_status_code, None, error_msg) def isSuccess(self): # 結局引数有無で判断してしまっている return self.error_msg is None def isError(self): return self.retrieved_data is None success = ApiResult.asSuccess(200, "success!!") error = ApiResult.asError(500, "Server Down!!") print(success.isSuccess()) print(success.http_status_code) print(success.error_msg) print("-------------------") print(error.isSuccess()) print(error.http_status_code) print(error.error_msg)
Kotlinの場合
この非直交性問題、結構頻発するため頭を悩ませる問題なのですがKotlinではこれをsealed classを使うことにより、非常に簡潔かつ美しくかけます。以下がそのサンプルコードになります。
sealed class ApiResult { // 未アクセス object Loading: ApiResult() // 成功した場合 data class Successs(val httpStatusCode: Int, val retrievedData: String) : ApiResult() // 失敗した場合 data class Error(val httpStatusCode: Int, val errorMsg: String): ApiResult() }
ややこやしかった問題が、たった三行で書けます。なんと美しいことか…
これまですべての状態を重ねわあせたFiledすべてを持っていましたが、ここではそれぞれの状態ごとに必要なものだけ持てていますね。初期状態を表すLoadingと成功したSuccess、失敗したErrorをApiResultという一つのselaed classによって可読性が高く表現されています。またこれをKotlinの推論機能と合わせることで非常に強力に作動します。非直交性は間違った使い方をする可能性のため可読性の低下を招くと書きましたが、selaed classを利用することにより、間違った使い方をした場合にはコンパイルエラーとなってそもそも動作しないということでこれを防げます。
val result: ApiResult .... // 以下はコンパイルエラー // ↑これが大事!静的型チェックにまさる可読性なし! // sApiResultの状態が確定していないためretrievedDataがあることがわかっていない result.retrievedData // 以下はOK // ApiResult.retrievedData // Successであることが自動で推論されている if (result is ApiResult.Success){ print(result.retrievedData) } // 関数を作り、引数でもこのような形で指定できる // この場合、Success以外を渡すとコンパイルエラー fun transform( result: ApiResult.Success, ){ // 推定できるのでOK // status.errorMsgはコンパイルエラーになる print(result.retrievedData) }
Kotlinが与えられた問題に対して非常に簡潔かつ可読性が高い方式でこの問題を解決してくれているのをなんとなく感じていただければ幸いです
スコープ関数
スコープ関数とは、「オブジェクトのコンテキスト内で、コードブロック(ラムダ式)を実行することを目的とした高階関数」と定義されるもので、Kotlinではlet/run/apply/also/withの5種類が存在します。とは言われてもなんのことかわからないと思うので、まずその一つであるalsoを見てみましょう。alsoのソースとサンプルは以下のようになっています
// ------ 定義 ------ // T, Rは任意の型 // 1. [T.also]は、Kotlin内部の任意の型で利用可能。 // StringだろうがListだろうが独自クラスであるが全てでalsoという関数が使える // // 2. block: (T) -> Unit // 2-1. alsoは関数blockを引数にとる(ラムダ式とイメージするとわかりやすい?) // 2-2. 関数blockはalsoを実行している任意の変数そのものを受け取る // 2-3. 最後に返却するのは、引数で与えられた関数を適用したTの値 public inline fun <T> T.also(block: (T) -> Unit): T { // 一部割愛... block(this) return this }
// ------ 使い方例 ------ // 日本時間の時分秒をすべて0にした値を持つ private val baseTime = DateTime .now("Asia/Tokyo") .also { it.withTime(it.hourOfDay, 0, 0, 0) }
さてこれがどう美しさにつながってくるのでしょうか?今回は以下のようなユースケースを考えてみましょう。
- 一位のIDを取得する処理
- 取得のためにはfetch_unique_idという関数を利用する
- ただし取得できなかった場合が存在し、その場合はnull(None)が返却される
- 取得できなかった場合は、UUIDを利用して値を取得する
- UUIDを利用して値を取得した場合はlog_alternative_unique_id関数を利用して、その値をLoggingする
これをPythonで素直に書くとこんな感じでしょうか。まあ特に問題はないですし、そうだよねという内容かなと思います
import uuid unique_id = fetch_unique_id() if not unique_id: unique_id = uuid.uuid4() log_alternative_unique_id(unique_id)
Kotlinの場合
さてこれをKotlinのスコープ関数を利用して書くとどうなるのでしょう?一例としては以下のような物が考えられると思います。
val uniqueId = fetchUniqueId() .getOrNull() ?: UUID.randomUUID().toString() .also { logAlternativeUniqueId(it) }
まあ大きくは変わっていませんね。ただ細かいこだわり点として以下の部分が素敵だなあと感じてしまいました。
UUID.randomUUID().toString() .also { logAlternativeUniqueId(it) }
also自体は中学英語で並んだ単語ですが意味としては[~も][さらに]というものがあります。つまり、ある事象が起きたのに付随して、also以降のものも同時に起きたことを表します。そう考えると上記のコードはUUIDを取得した上で,[さらに]loggingをするということで明確になります。開発者以外の人間がこの2つのコードを読んだとすると、後者の方がよりUUID取得と同時にLoggingをするということが単語として明確になっています。これも些細なことでは有るのですが、こういったことの積み重ねがコードの健全性を保つことになり、後々響いてくるためこういう気遣いができるのはいいなあと思った次第です
ちなみにfetchUniqueIdが非同期関数になった場合、このコードは以下のように変更するだけで対応することもできます。ここらへんのメンテナンスのしやすさも結構好きなところですね
// runCatchingを追加 val uniqueId = runCatching { fetchUniqueId() } .getOrNull() ?: UUID.randomUUID().toString() .also { logAlternativeUniqueId(it) }
組み込み型への拡張関数
拡張関数自体は他の言語(もちろんpython)でもありますが、今回は組み込み型への拡張関数を見ていきましょう。まず拡張関数とは何でしょうか?pythonでもあるとは記載したのですが、そこまで頻度は高くないのかなと言うきもしています。
以下のコードを御覧ください. 元々のクラスに存在しなかったlog関数が拡張され使えるようになっていますね
class Expand: pass e = Expand() # log関数を拡張する e.log = lambda x: print(x) e.log("拡張関数テスト!") ---------------------------------- 拡張関数テスト!
さてこれを利用して、今回は以下のユースケースを考えてみましょう. 組み込み型を拡張することで、どんなlistでも適用できる関数を拡張したいというイメージですね。今回の関数はデータ前処理でもよく使いたくなるユースケースですね。
- 組み込み型であるlistを拡張する
- 拡張関数: まとめる数(n)を指定して、その数ごとにlist内の要素をgroup化する
- e.g. [1,2,3,4,5,6,7,8,9] にn=4を与えた場合に,[[1,2,3,4][5,6,7,8][9]]を返す
では実装してみましょう. list自体を拡張したいので、list型そのものを拡張してみます
def chunked(list, split_size): for i in range(0, len(list), split_size): yield list[i : i + split_size] list.chunked = lambda x: chunked(x) --------------------------------------------------------------------------- TypeError Traceback (most recent call last) <ipython-input-17-369632dd5f97> in <module> 3 yield list[i : i + split_size] 4 ----> 5 list.chunked = lambda x: chunked(x) TypeError: can't set attributes of built-in/extension type 'list'
エラーが出てしまいましたね。。このようにpythonは拡張関数自体はあるものの組み込み型そのものに組み込んで、様々な場所でつかうということができず、ちょっと使い勝手がわるいなと感じていました。ちなみに書こうと思えば下記の様に独自のClassを仲介すればできるのですが、これはこれで冗長なコードが多く実用的にはちょっと厳しいのかなという印象も受けます
class CustomList(list): pass CustomList.chunked = lambda x, y: chunked(x, y) expand_list = CustomList([1,2,3,4,5]) list(expand_list.chunked(3)) ---------------------------------- [[1, 2, 3], [4, 5]]
Kotlinの場合
さてKotlinの場合はこれをどう書くのでしょう。Kotlinでは組み込み型への拡張関数にも対応しているため、このように書けたりします。簡潔ですね
// chunked自体はKotlinにデフォルトで存在しているため名称を変更している fun <T> List<T>.customChunked(size: Int): List<List<T>> = this.chunked(size)
この拡張関数が自在に書けるとなにが嬉しいのでしょうか?色々あるのでしょうが、個人的には以下の2つを感じています
- Listなどの組み込み型関数について、プロジェクト特有で必要となる機能を簡単につけ食われることができる
- 入力支援機能を利用すると、[.]を入力するだけでリストアップされ、使う側に周知させることが簡単
- チェーンでつなげて記載することができ可読性があがる(場合によりますが、、、)
例えば整数Listの各値を2倍にしてそれを上記のchunkedする場合はこんなイメージになるかと思います。これは好き嫌いあるので一概には言えず個人的意見でしかないですが一意見としてご参考ください
# 以下イメージソースコード base = [1,2,3,4,5,6,7] # As-Is transformed_value = map(lambda x: x * 2, base_list) transformed_value = chunked(transformed_value, 2) # 拡張関数使ってこうかけると嬉しい、、 # ほしい値に対して操作が一行ずつまとまっており、読むための脳内コストが少なくなる(気がする) # ※Kotlinだとこういう書き方はできる transformed_value = base .map(lambda x: x * 2, base_list) .chunked(2)
その他細かい点
以下は使っていてすごく使い勝手が良いと感じた機能群です。他のもNull SafetyやCoroutines周りなど色々あるのですが、ここに全部記すには余白が狭すぎるのでほんの一例だけご紹介させていただきます。
when式
pythonにはないもののwhen(switch)自体は結構いろいろな言語に実装されていますので馴染み深いではないのでしょうか。ただKotlinのwhen式は表現力が高く、かなり使い勝手がいいものになっているなという印象を受けています
// sealed classとの組み合わせ // 前述したApiResultがSuccessの場合、画面を表示する when (result) { is ApiResult.Success -> ResultScreen(result.retrieved_data) else -> CircularProgressIndicator() } // Enum判定 // もしEnumの全パターンが網羅できていない場合コンパイルエラーになるので // 後々のEnum追加も安心してい対応可能 enum class Attribute { LAW, NEUTRAL, CHAOS } // この場合CHAOSがないのでコンパイルエラーになり、ミスが即座にわかる when (getAttribute()) { Attribute.LAW -> goLawRoute() Attribute.NEUTRAL -> goNeutralRoute() } // 各種数値判定 // range指定可能 when (x) { 100 -> ... 11 ,13, 15, 17 -> ... in 1..10 -> ... !in 20..99 -> ... else -> print("none of the above") } // when から返り値を返して利用することも可能 val imageResId = when (result) { is ApiResult.Success -> R.drawable.ic_success is ApiResult.Error -> R.drawable.ic_error is ApiResult.Loading -> R.drawable.ic_loading }
次世代システム研究室では、プログラミングが大好きなMLエンジニアも募集しています。興味を持って頂ける方がいらっしゃいましたら、ぜひ 募集職種一覧からご応募をお願いします。
一緒に勉強しながら楽しく働きたい方のご応募をお待ちしております。
グループ研究開発本部の最新情報をTwitterで配信中です。ぜひフォローください。
Follow @GMO_RD