Kotlin初心者必見!関数型プログラミングの魅力と実践法
はじめに
こんにちは、ログラスの山家です。
ログラスではKotlinでバックエンド開発しており、関数型プログラミングを取り入れています。
Kotlinと関数型プログラミングを始めた当初は、PHPでの開発経験しかなかったこともあり、Kotlinの言語仕様の理解と、関数型の考え方を習得するのにとても苦労しました。しかし、「なぜ関数型プログラミングを使うのか?」に対して解像度を高めることで、Kotlinの言語仕様や関数型プログラミングに慣れ親しむことができたと考えています。
この記事では関数型プログラミングの概要とその効果を解説します。また、実際にKotlinで関数型プログラミングを実現するアプローチを紹介します。
関数型プログラミングとは
関数型プログラミングの定義は様々ですが、ここではプログラムを関数の集合として構築するパラダイムとして扱います。この関数とは数学的な関数のことを指しています。数学的な関数の特徴は、引数の値が決まれば結果が一意に定まることです。この特徴を参照透過性と呼びます。
y = x + 1
// xが10の場合、必ずyは11
一方、プログラムにおける関数は以下の特徴があります。
- シグネチャと本体で構成されている
- 入力として何らかの値を受け取り、何かを実行し、おそらく出力として値を返す
- 参照透過性がある場合、ない場合がある。
// シグネチャ
fun sum(a: Int, b: Int): Int {
// シグネチャの実装(本体)
return a + b
}
fun divide(a: Int, b: Int): Int {
// 0→Exception、引数が整数でも異なる結果を返す。
return a / b
}
上記の関数のうち、sum関数は参照透過性があり、関数本体を読まなくてもシグネチャで宣言された挙動が予測しやすいです。sum関数のように実装するプログラムを参照透過性を持つ数学的関数に寄せることで、関数本体の挙動が予測しやすい関数(=信頼できる関数)を作ることができます。(Kotlinではメソッドも関数と呼ぶ慣習があり、本記事でも関数と呼ぶこととします。)
純粋関数で信頼できる関数を実現する
シグネチャで宣言された挙動が予測しやすい関数(=信頼できる関数)と前述しましたが、もうすこし信頼できる関数の解像度を上げてみましょう。
シグネチャで宣言された挙動が予測しやすい関数とは、シグネチャで宣言していることと本体で実行していることが違わない関数です。
反対に、宣言された引数以外の値を利用したり、宣言された戻り値を返さない関数はシグネチャの宣言通りに動く関数ではありません。
fun add(a: Int, b: Int): Int {
return a + b
}
fun getFirstCharacter(s: String): Char {
return s[0] // sが空文字の場合、Exception
}
fun addRandomInt(a: Int): Double {
return a + Math.random() // 引数以外を使って計算
}
// リストをソートする関数(副作用がある)
fun sortList(list: MutableList<Int>) {
list.sort() // 引数のリストを直接変更する
}
上記のadd関数以外は、宣言された戻り値を返さなかったり、引数以外の値を使っているため、参照透過性がなく信頼できる関数とは言えません。
上記を踏まえ、シグネチャ宣言通りに動く関数には次の3つの条件があることがわかります。
- 戻り値が一意に求まる
- 引数のみを使って結果を計算する。
- 既存の値(状態)を変えない。
この条件を満たす関数を純粋関数と呼びます。
純粋関数を実現する実装アプローチ
純粋関数がシグネチャの宣言通りに動く信頼できる関数であることはわかりました。では、実際どのようにして純粋関数を実現するのでしょうか?
実際の開発では、オブジェクトの状態変更やファイル操作、例外のスローといった副作用が常に発生します。そんな実際の開発でも、できる限り純粋関数に近づけるアプローチを紹介します。
まずは純粋関数を意識せず、次の仕様を満たすサンプルプログラムを書いてみます。
- 商品(Item)はコード、商品名、価格を持つ。
- 商品名と価格を変更できる。
data class Item(val code: String, val name: String, var price: Int) {
// 戻り値を返していない
fun updatePrice(price: Int) {
// 既存の値を変更
this.price = price
}
}
このプログラムはミュータブルな値を扱っており、バグの原因になりやすいです。このItemクラスを純粋関数の条件である「戻り値が一意に求まる」「既存の値を変更しない」に従ってリファクタすることで、ミュータブルな値をなくすことができます。update関数に次の2つの変更を加えます。
- 戻り値を返さない → update関数は常に、Item型の戻り値を返す。
- 既存の値(クラスのプロパティ)を変更する → 既存の値をコピーして新しいインスタンスを使う。
// priceのキーワードがvarからvalに変更
data class Item(val code: String, val name: String, val price: Int) {
fun updatePrice(price: Int): Item {
return this.copy(price = price)
}
}
この変更によってミュータブルだったpriceプロパティはイミュータブルになり、予期しない値の変更を防ぐことができました。
イミュータブルな値を操作するKotlinの標準関数
Kotlinには前述したイミュータブルな値を操作したり、純粋関数を実現するライブラリが用意されています。例えば、配列操作で使われるfilter関数はイミュータブルなリストを操作する副作用のない関数です。
// 4で割り切れる整数をフィルターする。
fun main() {
// イミュータブルな整数のリスト
val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13)
// filter関数は新しいリストのインスタンスを返却する
val filtered = numbers.filter {
it % 4 == 0
}
println("フィルター前: $numbers")
// 出力 → フィルター前: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]
println("フィルター後: $filtered")
// 出力 → "フィルター後: [4, 8, 12]
}
上記の処理から、filterは既存の値であるnumbers変更せず、新しいリストを生成し戻り値として返却していることがわかります。filter以外に、mapやsortByも同じようにイミュータブルな値を操作し、副作用のない処理を実現します。
宣言的なプログラミングスタイル
前述したfilterやmapをはじめ、関数型プログラミングは宣言的に記述しやすいというメリットがあります。
宣言的だと何が嬉しいでしょうか?
「“a”以外の文字数を計算するプログラム」を命令的、宣言的のそれぞれのアプローチで実装し、それぞれの特徴を見ていきましょう。
// 要求:“a”以外の文字数を計算する
// 命令型なプログラムで実装
fun main() {
var score = 0
val word = "Happy"
for (c in word.toCharArray()) {
if (c != 'a') {
score++
}
}
print(score)
}
// 宣言型なプログラムで実装
fun main() {
val word = "Happy"
val score = word.count { it != 'a' }
print(score)
}
命令的なプログラムは「どのようにして目的の処理を実現するか」を記述しています。そのため、読み手は繰り返し文の中を把握し、この処理が何をしようとしているのかを読み解く必要があります。
一方、宣言型なプログラムでは、「どのように」ではなく「何をなすべきか」に焦点が当たっています。このおかげで、読み手は繰り返し文を追うことなく、プログラムが実現する要求を把握しやすいです。
要求が複雑になることで、宣言的プログラムの恩恵をより感じることができます。
以下のプログラムを読んでみると、「日用品かつ2000円以上の商品をフィルタする→価格にx0.8する→合計する」とプログラムを文章のように読むことができると思います。
// 要求:
// - カートの商品の割引額合計を算出する。
// - 2000円以上の日用品は20%OFF
val discountTotalPrice = cartItems
.filter { it.itemKind = "日用品" && it.price >= 2000 }
.map { it.price * 0.8 }
.sum()
シグネチャの宣言通りに関数を動かすKotlinライブラリ
実際のKotlinによる開発では、往々にして例外を投げる場合があるため、関数が必ずしもシグネチャの宣言通りに動きません。そのような場合でも、Kotlinの関数型ライブラリを利用することで、例外をスローせずシグネチャの宣言通り動く関数を実装することができます。
前述した商品(Item)のサンプルコードに「価格は必ず0円よりも高い」という制約を追加してみましょう。
data class Item(val code: String, val name: String, val price: Int) {
fun updatePrice(price: Int): Item {
if (price <= 0) {
throw IllegalArgumentException("価格は0円よりも高くなければなりません")
}
return this.copy(price = price)
}
}
Itemクラスが持つ関数は0以下の値を引数に受け取った場合、例外をスローします。
このように例外なケースがある関数であっても、Arrowというライブラリを利用することで、例外を返さず常にEither型という単一の戻り値を返すことができます。
Either型はEither<Left, Right>と表されLeftもしくはRightどちらかの値を保持します。慣習的にLeftには異常系の結果、Rightには正常系の結果を保持します。
data class Item(val code: String, val name: String, val price: Int) {
fun updatePrice(price: Int): Either<String, Item> {
return if (price > 0) {
this.copy(price = price).right() // Rightに結果を保持したEitherを返却
} else {
"価格は0円よりも高くなければなりません".left() // Leftに結果を保持したEitherを返却
}
}
}
Eitherを使うことで常に1つの戻り値を返すことを実現できましたが、上記のような例外の代わりにEither型を返却すると何が嬉しいのか、それぞれを実行して違いを確認して見ましょう。
まずは例外を返すupdatePrice関数を実行します。Kotlinには検査例外という例外処理を強制させる仕組みがありません。そのため、シグネチャで宣言されていない例外を返却するという関数内部の事情を考慮し、例外処理を行う必要があります。
fun main() {
try {
val item = Item("001", "Example Item", 100)
val updatedItem = item.updatePrice(200)
println("Updated Item: $updatedItem")
} catch (e: IllegalArgumentException) {
println("Updated error: ${e.message}")
}
}
次に、Either型を返すupdatePrice関数を実行してみます。Either型を返却することで、呼び出し側にエラーハンドリングを強制させるため、前述の例外を返すupdatePrice関数よりも安全です。
加えて、この関数は常にEither<String, Item>型の戻り値を返すため、純粋関数の条件である「戻り値が一意に求まる」を実現しています。
fun main() {
val item = Item("001", "Example Item", 100)
val updatedItemResult = item.updatePrice(200)
updatedItemResult.fold(
{ error -> println("Update error: $error") }, // Left (エラー) の場合の処理
{ updatedItem -> println("Updated Item: $updatedItem") }, // Right (成功) の場合の処理
)
}
Kotlinの関数型プログラミングライブラリを利用することで、オブジェクトの更新というプログラミング的関数を参照透過性を持つ数学的関数に近づけることができました。
まとめ
関数型プログラミングの概要とその効果、Kotlinでの実装アプローチについて解説しました。この記事がこれから関数型プログラミングやKotlinに興味がある方にとって参考になれば幸いです。
参考文献
Płachta, M. (2023). なっとく!関数型プログラミング. 株式会社クイープ(翻訳、監修). ISBN 9784798179803.
Discussion