nakaoka3の技術ブログ

2023年中に52本なにか書きます

Kotlin で 代数的データ型(ADT) を使ってモデル化

関数型プログラミングでは、型を組み合わせて作る型に直和型(sum type)と直積型(product type)が存在する。直和型と直積型のことを代数的データ型という。

『なっとく!関数型プログラミング』では、Scala、F#, Haskell で代数的データ型によるモデル化の例が紹介されていた。

残念なことに、どれも普段触っているプログラミング言語ではない。仕事で触る可能性があるのはバックエンドでPerl、バックエンド・フロントエンドがTypeScript,AndroidアプリでKotlinおよびJava, iOSアプリでSwiftだ。

なので、その中から Kotlinについて代数的データ型によるモデル化を試して見ることにした。Kotlinのバージョンは1.9で試している。

アーティストのモデル化

アーティストはジャンル、活動期間、出身地の情報を持っていて、これを代数的データ型を使ってモデル化していきたい。『なっとく!関数型プログラミング』の「7.30 newtype 、ADT 、パターンマッチングを使う」(p230)の例を参考にしている。

直和型 Sum Types

Kotlinでは sealed class を使って直和型を表現できる。

kotlinlang.org

sealed class は通常のクラスよりも継承が制限されていて、コンパイル時にサブクラスの集合が決まっている。

Thus, each instance of a sealed class has a type from a limited set that is known when this class is compiled.

Sealed classes and interfaces | Kotlin Documentation

Kotlin の enum 型もこれに似た性質を持つが、複数のインスタンスを持つことができないため状態の異なる複数のインスタンスをもたせる事はできない。

In some sense, sealed classes are similar to enum classes: the set of values for an enum type is also restricted, but each enum constant exists only as a single instance, whereas a subclass of a sealed class can have multiple instances, each with its own state.

Sealed classes and interfaces | Kotlin Documentation

MusicGenre は Rock や Pop などの各音楽ジャンルからなる直和型だ。これは enumでも代替できそうだ。

// Music Genre : Sum type
sealed class MusicGenre {
    data object Rock : MusicGenre()
    data object Pop : MusicGenre()
    data object Jazz : MusicGenre()
    data object Classic : MusicGenre()
}

YearsActive(活動年) はStillActive(まだ活動中)またはActiveBetween(活動した期間)の2つの型か構成される。

年数が入るインスタンスが必要なので、Kotlinのenumでは代替できない。

// YearsActive: Sum type
sealed class YearsActive {
    data class StillActive(val start: Int) : YearsActive()
    data class ActiveBetween(val start: Int, val end: Int) : YearsActive()
}

直積型 Product Types

直積型は data class で表現できる。複数のプロパティを持てるクラスを作るだけなので難しいことはない。

// Location: newtype
data class Location(val name: String)

// Artist: Product type
data class Artist(
    val name: String,
    val genre: MusicGenre,
    val origin: Location,
    val yearsActive: YearsActive
)

アーティストのリストを作る

定義したArtist型は以下のように使う事ができる。

val artists = listOf(
    Artist("The Beatles", MusicGenre.Rock, Location("Liverpool"), YearsActive.ActiveBetween(1960, 1970)),
    Artist("Paul McCartney", MusicGenre.Rock, Location("Liverpool"), YearsActive.StillActive(1960)),
    Artist("George Harrison", MusicGenre.Rock, Location("Liverpool"), YearsActive.ActiveBetween(1960, 2001)),
    Artist("John Lennon", MusicGenre.Rock, Location("Liverpool"), YearsActive.ActiveBetween(1960, 1980)),
    Artist("David Bowie", MusicGenre.Rock, Location("London"), YearsActive.ActiveBetween(1962, 2016)),
    Artist("XTC", MusicGenre.Rock, Location("Swindon"), YearsActive.ActiveBetween(1972, 2006)),
    Artist("New Order", MusicGenre.Rock, Location("Manchester"), YearsActive.StillActive(1980)),
    Artist("Joy Division", MusicGenre.Rock, Location("Manchester"), YearsActive.ActiveBetween(1976, 1980)),
    Artist("The Smiths", MusicGenre.Rock, Location("Manchester"), YearsActive.ActiveBetween(1982, 1987)),
    Artist("Oasis", MusicGenre.Rock, Location("Manchester"), YearsActive.StillActive(1991)),
    Artist("Echo and the Bunnymen", MusicGenre.Rock, Location("Liverpool"), YearsActive.StillActive(1978)),
    Artist("Radio Head", MusicGenre.Rock, Location("Abingdon"), YearsActive.StillActive(1992)),
    Artist("Blur", MusicGenre.Rock, Location("London"), YearsActive.StillActive(1988)),
    Artist("Arctic Monkeys", MusicGenre.Rock, Location("Sheffield"), YearsActive.StillActive(2002)),
)

※出身や活動年数はGitHub Copilotで補完したもので、気がついたところは修正しましたが、正しくない情報が含まれている場合があります。

パターンマッチング

直和型はパターンマッチングで処理していく。直和型とパターンマッチングの操作ができれば、代数的データ型に対応していると言っていいだろう。

以下のようにパターンマッチングを使ってフィルタリングできる。

val artistStillActive = artists
    .filter { artist -> artist.yearsActive is YearsActive.StillActive }
    .map { artist -> artist.name }
println("## Artists still active:")
println(artistStillActive)

アーティストのリストから、現在も活動中のアーティストの名前を取り出す事ができた。

## Artists still active:
[Paul McCartney, New Order, Oasis, Echo and the Bunnymen, Radio Head, Blur, Arctic Monkeys]

パターンマッチングで複数の場合の処理を分けたいときには when を使う。

fun prettyPrintArtist(artist: Artist) {
    when(artist.yearsActive) {
        // Name(YYYY - )
        is YearsActive.StillActive -> println("${artist.name}(${artist.yearsActive.start} - now)")
        // Name(YYYY - YYYY)
        is YearsActive.ActiveBetween -> println("${artist.name}(${artist.yearsActive.start} - ${artist.yearsActive.end})")
    }
}

アーティストのリストから80年代に活動していたアーティストを取り出して、活動開始年によってソートするには以下のようにする。

// Artists active in the 80s
val artistsActiveIn80s = artists
    .filter { artist ->
        when (artist.yearsActive) {
            is YearsActive.StillActive -> artist.yearsActive.start < 1990
            is YearsActive.ActiveBetween -> artist.yearsActive.start < 1990 && artist.yearsActive.end >= 1980
        }
    }
    .sortedBy { artist ->
        when (artist.yearsActive) {
            is YearsActive.StillActive -> artist.yearsActive.start
            is YearsActive.ActiveBetween -> artist.yearsActive.start
        }
    }
println("## Artists active in the 80s:")
artistsActiveIn80s.forEach(::prettyPrintArtist)

以下のように80年代に活動していたアーティストが出力される。

## Artists active in the 80s:
Paul McCartney(1960 - now)
George Harrison(1960 - 2001)
John Lennon(1960 - 1980)
David Bowie(1962 - 2016)
XTC(1972 - 2006)
Joy Division(1976 - 1980)
Echo and the Bunnymen(1978 - now)
New Order(1980 - now)
The Smiths(1982 - 1987)
Blur(1988 - now)

余談だが、ジョン・レノンは1976年に音楽活動を中止しているが、1980年に活動を再開し、1980年12月に亡くなった。そのため1980年代に活動していたアーティストに含まれている。80年代に活動していたイメージはないのはそのためだ。

今回のモデル化では活動休止期間がモデル化されていないので、休止期間を考慮したい場合には別のモデル化が必要だ。

まとめ

Kotlinでは、直和型には sealed class を、直積型には data class を使う。