Home About
Kotlin , Functional Programming , Monad

データ変換を Writer Monad 的に処理する その2

今回は 前回のエントリーで実装した Writer を引き続き使って、 より実践的な例でこれがどのように便利かを説明します。

環境と Writer

実行環境や Writer の実装は 前回と同じなのでそちらを見てください。

対象とするサンプルデータ

まずは次のような昨年・今年の商品ごとの価格データがあるとします。

単なる kotlin のリストですが、エクセルデータから取り出したものであることを想定して XlsxRow というタイプを設定しています。

typealias XlsxRow = Pair<String,Int>

val xlsxRowList2023 = listOf<XlsxRow>(
    Pair("Caffe Americano", 500),
    Pair("Pike Place Roast", 500),
    Pair("Caffe Misto", 500))

val xlsxRowList2024 = listOf<XlsxRow>(
    Pair("Caffe Americano", 600),
    Pair("Pike Place Roast", 500),
    Pair("Caffe Misto", 400))

Caffe Americano, Pike Place Rast, Caffe Misto の3つの商品があり、2023年ではすべて 500円ですが、2024年の価格改定で、 Caffe Americano が 600円に値上げ、 Caffe Misto は 400円に値下げ(ただしこれは誤りの可能性があるとして報告する)です。

この2つのデータをマージして次のような感じのデータをつくるとします。(おおまかなイメージ)

- OK:      Cafffe Americano 600 (500->600) price up
- OK:      Pike Place Roast 500 (500->500) no price change
- WARNING: Caffe Misto      400 (500->400) price down

データクラスの定義

typealias Name = String
enum class Year { Y2023, Y2024 }
data class Item(val name: Name, val price: Int, val year: Year)
typealias ItemPair = Pair<Item, Item>

ItemPair は2つの Item をとりますが、 それぞれ (暗黙に)2つが同じ商品で、first に 2023 の second に 2024 の Item が保持されることを想定しています。

モナド計算イメージとヘルパー関数の定義

このデータマージをどのように処理するかですが、ここでは Writer を使って逐次変換していく形にしたい。

この2つを用意して次のような変換により、あるひとつの商品名から出発して 最後に ItemPair とログを得ることを考えます。

特定の商品(Name) -> 2023のItem -> 2024のItem -> ItemPair

すべての Item を含んだリストをつくるヘルパー関数 toItemList :

val toItemList: (List<XlsxRow>, List<XlsxRow>)-> List<Item> = { xlsxRowList2023, xlsxRowList2024->
    val itemList2023 = xlsxRowList2023.map { Item(it.first, it.second, Year.Y2023) } 
    val itemList2024 = xlsxRowList2024.map { Item(it.first, it.second, Year.Y2024) }
    itemList2023 + itemList2024
}

すべての 商品名(Name) を含んだリストをつくるヘルパー関数 toNameList :

val toNameList: (List<Item>)-> List<Name> = { itemList->
    itemList.map { it.name }.distinct()
}

それではこれを使って 2023,2024 の商品名(Name)を重複なしで取り出してみます。

typealias XlsxRow = Pair<String,Int>

val xlsxRowList2023 = listOf<XlsxRow>(
    Pair("Caffe Americano", 500),
    Pair("Pike Place Roast", 500),
    Pair("Caffe Misto", 500))

val xlsxRowList2024 = listOf<XlsxRow>(
    Pair("Caffe Americano", 600),
    Pair("Pike Place Roast", 500),
    Pair("Caffe Misto", 400))

val itemList = toItemList(xlsxRowList2023, xlsxRowList2024)
val nameList = toNameList(itemList)
println(nameList)

実行します。

$ kotlinc -script main.kts
[Caffe Americano, Pike Place Roast, Caffe Misto]

意図通り商品名を重複なしに列挙できました。

さらに、商品名(Name) と年(Year) から Item をみつける関数 findItemsByNameAndYear を定義:

val findItemsByNameAndYear: (List<Item>, Name, Year)-> List<Item> = { itemList, name, year->
    itemList.filter { it.name == name && it.year == year }
}

これが意図通り機能するか Caffe Americano を例にして確かめます。

val aName = "Caffe Americano"

val item2023 = findItemsByNameAndYear(itemList, aName, Year.Y2023)
println(item2023)

val item2024 = findItemsByNameAndYear(itemList, aName, Year.Y2024)
println(item2024)

実行します。

$ kotlinc -script main.kts
[Item(name=Caffe Americano, price=500, year=Y2023)]
[Item(name=Caffe Americano, price=600, year=Y2024)]

うまく取得できました。

計算を定義

ここまでで必要な道具立て(ヘルパー関数)が用意できたので、いよいよ本命の計算用の純粋関数を定義していきます。 それぞれの関数は bind でつないで連続的に計算していくため、お互いの入出力の型が一致しないと機能しない点に注意しながら実装していきましょう。

商品名から 2023のItem に変換する関数 nameToItem2023 :

val nameToItem2023: (Pair<List<Item>, Name>)-> Writer<Triple<List<Item>, Name, Item>> = { value->
    val (itemList, name) = value
    val item2023List = findItemsByNameAndYear(itemList, name, Year.Y2023)
    if( item2023List.size>0 ){
        val item2023 = item2023List.first()
        val newValue = Triple(itemList, name, item2023)
        Writer.unit(newValue, "${name} to ${item2023}")
    } else {
        Writer.unit("No Items Found")
    }
}

findItemsByNameAndYear の結果は List<Item> ですが、つまり検索の結果、リストが空、リストに一件のアイテム、リストに複数のアイテムの状態をとる可能性があります。本来はこの3つの状態について処理をわける(ログをかき分けるなど)する必要がありますが、ここでは、リストが空の状態(→エラーとして扱う)とそれ以外(→先頭のアイテムを採用する)として処理しています。

計算に使う関数の型に注意してください。 今関心があるのは、商品名(Name) を 2023のItem に変換したいだけなので、次のようにかけるとうれしいのですが・・・

val nameToItem2023: (Name)->Writer<Item> = { name-> }

実際は次のように書く必要があります。

val nameToItem2023: (Pair<List<Item>, Name>)-> Writer<Triple<List<Item>, Name, Item>> = { value-> }

これは nameToItem2023 が純粋な関数であるため、その計算に必要なすべての情報を与える必要があるからです。 そして、結果も単に Item だけでなく、 List<Item>, Name も一緒に結果として返すようにしているのは、 いま時点では必要ないもののあとで後続の計算を追加していくつもりだからです。 (後続の計算に必要な情報をすべて渡す必要がある。)

val aName = "Caffe Americano"

val initValue = Pair(itemList, aName)
val resultWriter =
    Writer.unit(initValue, "init ${aName}").
    bind( nameToItem2023 )

println("result: ${resultWriter.valueOpt.get().third}")
println("log: ${resultWriter.text}")

実行すると、次のような結果になります。

$ kotlinc -script main.kts
result: Item(name=Caffe Americano, price=500, year=Y2023)
log: init Caffe Americano / Caffe Americano to Item(name=Caffe Americano, price=500, year=Y2023)

うまく計算できています。

それでは続けて、item2023ToItemPair 関数を定義します。

val item2023ToItemPair: (Triple<List<Item>, Name, Item>)-> Writer<ItemPair> = { value->
    val (itemList, name, item2023) = value
    val item2024List = findItemsByNameAndYear(itemList, name, Year.Y2024)
    if( item2024List.size>0 ){
        val item2024 = item2024List.first()
        val newValue = ItemPair(item2023, item2024)
        Writer.unit(newValue, "${item2023} to ${newValue}")
    } else {
        Writer.unit("No Items Found")
    }
}

この関数では最終的な計算結果である ItemPair (2023と2024の Item を含んだペア)を計算します。 これ以上は計算を続けないので、計算結果の出力は Writer<ItemPair> のみにしました。

この計算を追加して実行してみます。

val initValue = Pair(itemList, aName)
val resultWriter =
    Writer.unit(initValue, "init ${aName}").
    bind( nameToItem2023 ).
    bind( item2023ToItemPair )

println("result: ${resultWriter.valueOpt}")
println("log: ${resultWriter.text.split(" / ").joinToString(" / \n")}")

ログの出力が長くなってきたので、区切り文字で split して改行しています。

実行します。

$ kotlinc -script main.kts
result: Optional[(Item(name=Caffe Americano, price=500, year=Y2023), Item(name=Caffe Americano, price=600, year=Y2024))]
log: init Caffe Americano / 
Caffe Americano to Item(name=Caffe Americano, price=500, year=Y2023) / 
Item(name=Caffe Americano, price=500, year=Y2023) to (Item(name=Caffe Americano, price=500, year=Y2023), Item(name=Caffe Americano, price=600, year=Y2024))

result: に出力されたように Caffe Americano の 2023, 2024 の Item を計算できました。

では Caffe Americano だけでなく存在する全部の商品について変換を実行しましょう。

nameList.forEach { name->
    val initValue = Pair(itemList, name)
    val resultWriter =
        Writer.unit(initValue, "init ${name}").
        bind( nameToItem2023 ).
        bind( item2023ToItemPair )
    
    println("---")
    println("name: ${name}")
    println("result: ${resultWriter.valueOpt)}")
    println("log: ${resultWriter.text.split(" / ").joinToString(" / \n")}")
}

実行するとすべての商品について、2023,2024 の ItemPair を得ることができました。

Writer など使わなくとも

ここまでは、対象とするデータが整っているため、 わざわざ Writer を使うまでもない計算です。

このように・・・

val itemPairList = nameList.map { name->
    val item2023 = findItemsByNameAndYear(itemList, name, Year.Y2023).first()
    val item2024 = findItemsByNameAndYear(itemList, name, Year.Y2024).first()
    ItemPair(item2023, item2024)
}

itemPairList.forEach { (item2023, item2024)->
    println("- ${item2023.name} (${item2023.price} -> ${item2024.price})")
}

実行。

$ kotlinc -script main.kts
- Caffe Americano (500 -> 600)
- Pike Place Roast (500 -> 500)
- Caffe Misto (500 -> 400)

このように Writer など持ち出さなくても簡単に処理できます。

しかし、現実のデータは欠損があったり、重複していたり、という問題がつきものです。 そのようなデータを使って、Writer Monad な処理の便利さを確かめていきます。

より現実的なデータへの対処

それでは次のような価格情報データを処理することにします。

val xlsxRowList2023 = listOf<XlsxRow>(
    Pair("Caffe Americano", 500),
    Pair("Pike Place Roast", 500),
    Pair("Caffe Misto", 500),
    Pair("Hot Chocolate", 500))

val xlsxRowList2024 = listOf<XlsxRow>(
    Pair("Caffe Americano", 600),
    Pair("Pike Place Roast", 500),
    Pair("Caffe Misto", 400),
    Pair("Cappuccino", 500))

最初の3つの商品(Caffe Americano, Pike Place Roast, Caffe Misto)はそのままですが、 2023 に Hot Chocolate が加わり、2024 に Cappuccino が加わりました。 ただし、 Hot Chocolate は 2024 には存在しない(=廃盤)し、 Cappuccino は 2023 に存在しません(=新商品)、という設定です。

このデータを既存のコードそのままで実行してみます。

kotlinc -script main.kts
---
name: Caffe Americano
result: Optional[(Item(name=Caffe Americano, price=500, year=Y2023), Item(name=Caffe Americano, price=600, year=Y2024))]
log: init Caffe Americano / 
Caffe Americano to Item(name=Caffe Americano, price=500, year=Y2023) / 
Item(name=Caffe Americano, price=500, year=Y2023) to (Item(name=Caffe Americano, price=500, year=Y2023), Item(name=Caffe Americano, price=600, year=Y2024))
---
name: Pike Place Roast
result: Optional[(Item(name=Pike Place Roast, price=500, year=Y2023), Item(name=Pike Place Roast, price=500, year=Y2024))]
log: init Pike Place Roast / 
Pike Place Roast to Item(name=Pike Place Roast, price=500, year=Y2023) / 
Item(name=Pike Place Roast, price=500, year=Y2023) to (Item(name=Pike Place Roast, price=500, year=Y2023), Item(name=Pike Place Roast, price=500, year=Y2024))
---
name: Caffe Misto
result: Optional[(Item(name=Caffe Misto, price=500, year=Y2023), Item(name=Caffe Misto, price=400, year=Y2024))]
log: init Caffe Misto / 
Caffe Misto to Item(name=Caffe Misto, price=500, year=Y2023) / 
Item(name=Caffe Misto, price=500, year=Y2023) to (Item(name=Caffe Misto, price=500, year=Y2023), Item(name=Caffe Misto, price=400, year=Y2024))
---
name: Hot Chocolate
result: Optional.empty
log: init Hot Chocolate / 
Hot Chocolate to Item(name=Hot Chocolate, price=500, year=Y2023) / 
No Items Found
---
name: Cappuccino
result: Optional.empty
log: init Cappuccino / 
No Items Found

プログラムは作動しましたが、 追加した(廃盤と新商品の) Hot Chocolate, Cappuccino については、 結果の ItemPair を得ることができません。( Optional.empty になる。) そして、ログに No Items Found と出力される。

Hot Chocolate はともかく、 Cappuccino については 2024年において存在している商品なので、これが出力されないのは困ります。 そこで、 ItemPairOptioanl を追加して、該当商品が存在しなくても扱えるようにします。 そのような ItemPairOptional 版を ItemOptPair と呼称することにします。

//typealias ItemPair = Pair<Item, Item>
typealias ItemOptPair = Pair<Optional<Item>, Optional<Item>>

ItemPair 廃止し、その代わりに ItemOptPair を導入したので、該当コードを修正します。

nameToItem2023 関数を修正します。 関数名も nameToItemOpt2023 に変えます。

val nameToItemOpt2023: (Pair<List<Item>, Name>)-> Writer<Triple<List<Item>, Name, Optional<Item>>> = { value->
    val (itemList, name) = value
    val item2023List = findItemsByNameAndYear(itemList, name, Year.Y2023)
    if( item2023List.size>0 ){
        val itemOpt2023 = Optional.of(item2023List.first())
        val newValue = Triple(itemList, name, itemOpt2023)
        Writer.unit(newValue, "${name} to ${itemOpt2023}")
    } else {
        val itemOpt2023: Optional<Item> = Optional.empty()
        val newValue = Triple(itemList, name, itemOpt2023)
        Writer.unit(newValue, "${name} to Empty")
        //Writer.unit("No Items Found")
    }
}

item2023ToItemPair 関数を修正します。 関数名も itemOpt2023ToItemOptPair に変更します。

val itemOpt2023ToItemOptPair: (Triple<List<Item>, Name, Optional<Item>>)-> Writer<ItemOptPair> = { value->
    val (itemList, name, itemOpt2023) = value
    val item2024List = findItemsByNameAndYear(itemList, name, Year.Y2024)
    if( item2024List.size>0 ){
        val itemOpt2024 = Optional.of(item2024List.first())
        val newValue = ItemOptPair(itemOpt2023, itemOpt2024)
        Writer.unit(newValue, "${itemOpt2023} to ${newValue}")
    } else {
        val newValue = ItemOptPair(itemOpt2023, Optional.empty())
        Writer.unit(newValue, "${itemOpt2023} to ${newValue}")
        //Writer.unit("No Items Found")
    }
}

これを使って、先程は結果が Optional.empty になっていた Cappuccino を変換してみます。

val aName = "Cappuccino"

val initValue = Pair(itemList, aName)
val resultWriter =
    Writer.unit(initValue, "init ${aName}").
    bind( nameToItemOpt2023 ).
    bind( itemOpt2023ToItemOptPair )

println("result: ${resultWriter.valueOpt.get()}")
println("log: ${resultWriter.text.split(" / ").joinToString(" / \n")}")

実行。

$ kotlinc -script main.kts
result: (Optional.empty, Optional[Item(name=Cappuccino, price=500, year=Y2024)])
log: init Cappuccino /
Cappuccino to Empty /
Optional.empty to (Optional.empty, Optional[Item(name=Cappuccino, price=500, year=Y2024)])

今度は、結果が Optional.empty にならないで、ItemOptPair 値を得ることができました。 2023の Item は存在しないので、そこは Optional.empty として扱われています。

もし価格が下がっていたら警告する

これで欠損データに対処できるようになったので、 今度は価格が下がっていたらログを残すように修正を加えてみます。

itemOpt2023ToItemOptPair 関数で、プライスダウンを検知したら PRICE DOWN WARNING という文字列をログに入れることにしました。

val itemOpt2023ToItemOptPair: (Triple<List<Item>, Name, Optional<Item>>)-> Writer<ItemOptPair> = { value->
    val (itemList, name, itemOpt2023) = value
    val item2024List = findItemsByNameAndYear(itemList, name, Year.Y2024)
    if( item2024List.size>0 ){
        val itemOpt2024 = Optional.of(item2024List.first())
        val newValue = ItemOptPair(itemOpt2023, itemOpt2024)

        // Warn if price down.
        if( itemOpt2023.isPresent() &&
            itemOpt2024.isPresent() &&
            (itemOpt2023.get().price > itemOpt2024.get().price) ){
            Writer.unit(newValue, "PRICE DOWN WARNING ${itemOpt2023} to ${newValue}")
        } else {
            Writer.unit(newValue, "${itemOpt2023} to ${newValue}")
        }
    } else {
        val newValue = ItemOptPair(itemOpt2023, Optional.empty())
        Writer.unit(newValue, "${itemOpt2023} to ${newValue}")
        //Writer.unit("No Items Found")
    }
}

実際にプライスダウンが起きている Caffe Misto で試します。

val aName = "Caffe Misto"

val initValue = Pair(itemList, aName)
val resultWriter =
    Writer.unit(initValue, "init ${aName}").
    bind( nameToItemOpt2023 ).
    bind( itemOpt2023ToItemOptPair )

println("result: ${resultWriter.valueOpt.get()}")
println("log: ${resultWriter.text.split(" / ").joinToString(" / \n")}")

実行すると PRICE DOWN WARNING を出力できました。

kotlinc -script main.kts
result: (Optional[Item(name=Caffe Misto, price=500, year=Y2023)], Optional[Item(name=Caffe Misto, price=400, year=Y2024)])
log: init Caffe Misto / 
Caffe Misto to Optional[Item(name=Caffe Misto, price=500, year=Y2023)] / 
PRICE DOWN WARNING Optional[Item(name=Caffe Misto, price=500, year=Y2023)] to (Optional[Item(name=Caffe Misto, price=500, year=Y2023)], Optional[Item(name=Caffe Misto, price=400, year=Y2024)])

最後にすべての商品を変換して、結果を出します。

val resultWriterList = nameList.map { name->
    val initValue = Pair(itemList, name)
    val resultWriter =
        Writer.unit(initValue, "init ${name}").
        bind( nameToItemOpt2023 ).
        bind( itemOpt2023ToItemOptPair )
    resultWriter
}

resultWriterList.forEach { resultWriter->
    val itemOptPair = resultWriter.valueOpt.get()
    val (itemOpt2023, itemOpt2024) = itemOptPair

    val hasPriceDownWarning = ( resultWriter.text.contains("PRICE DOWN WARNING") )
    val status =
        if( hasPriceDownWarning ){
            "WARNING"
        } else if( itemOpt2024.isEmpty() ){
            "DISCON "
        } else if( itemOpt2023.isEmpty() && itemOpt2024.isPresent() ){
            "NEW    "
        } else {
            "OK     "
        }

    val name = 
        if( itemOpt2023.isPresent() ){
            itemOpt2023.get().name
        } else if( itemOpt2024.isPresent() ){
            itemOpt2024.get().name
        } else { "UNKNOWN" }

    val price = 
        if( itemOpt2024.isPresent() ){
            itemOpt2024.get().price
        } else { "N/A" }

    println("- ${status}: ${name} (${price})")
}

実行。

$ kotlinc -script main.kts
- OK     : Caffe Americano (600)
- OK     : Pike Place Roast (500)
- WARNING: Caffe Misto (400)
- DISCON : Hot Chocolate (N/A)
- NEW    : Cappuccino (500)

まとめ

最後に完成したコードを掲載します。

main.kts

import java.util.Optional

class Writer<T> private constructor(val valueOpt: Optional<T>, var text: String) {
    fun <R> bind(f: (T)->Writer<R>): Writer<R> {
        return if( this.valueOpt.isPresent() ) {
            val v: T = this.valueOpt.get()
            val w = f(v)
            w.text = "${this.text} / ${w.text}"
            w
        } else {
            Writer.unit(this.text)
        }
    }

    companion object {
        fun <T> unit(value: T, text: String): Writer<T>{
            return Writer(Optional.of(value), text)
        }

        fun <T> unit(text: String): Writer<T>{
            return Writer(Optional.empty(), text)
        }
    }
}



typealias XlsxRow = Pair<String, Int>

enum class Year { Y2023, Y2024 }
typealias Name = String

data class Item(val name: Name, val price: Int, val year: Year)
typealias ItemOptPair = Pair<Optional<Item>, Optional<Item>>

val toItemList: (List<XlsxRow>, List<XlsxRow>)-> List<Item> = { xlsxRowList2023, xlsxRowList2024->
    val itemList2023 = xlsxRowList2023.map { Item(it.first, it.second, Year.Y2023) } 
    val itemList2024 = xlsxRowList2024.map { Item(it.first, it.second, Year.Y2024) }
    itemList2023 + itemList2024
}

val toNameList: (List<Item>)-> List<Name> = { itemList->
    itemList.map { it.name }.distinct()
}

val findItemsByNameAndYear: (List<Item>, Name, Year)-> List<Item> = { itemList, name, year->
    itemList.filter { it.name == name && it.year == year }
}

val nameToItemOpt2023: (Pair<List<Item>, Name>)-> Writer<Triple<List<Item>, Name, Optional<Item>>> = { value->
    val (itemList, name) = value
    val item2023List = findItemsByNameAndYear(itemList, name, Year.Y2023)
    if( item2023List.size>0 ){
        val itemOpt2023 = Optional.of(item2023List.first())
        val newValue = Triple(itemList, name, itemOpt2023)
        Writer.unit(newValue, "${name} to ${itemOpt2023}")
    } else {
        val itemOpt2023: Optional<Item> = Optional.empty()
        val newValue = Triple(itemList, name, itemOpt2023)
        Writer.unit(newValue, "${name} to Empty")
    }
}

val itemOpt2023ToItemOptPair: (Triple<List<Item>, Name, Optional<Item>>)-> Writer<ItemOptPair> = { value->
    val (itemList, name, itemOpt2023) = value
    val item2024List = findItemsByNameAndYear(itemList, name, Year.Y2024)
    if( item2024List.size>0 ){
        val itemOpt2024 = Optional.of(item2024List.first())
        val newValue = ItemOptPair(itemOpt2023, itemOpt2024)

        // Warn if price down.
        if( itemOpt2023.isPresent() &&
            itemOpt2024.isPresent() &&
            (itemOpt2023.get().price > itemOpt2024.get().price) ){
            Writer.unit(newValue, "PRICE DOWN WARNING ${itemOpt2023} to ${newValue}")
        } else {
            Writer.unit(newValue, "${itemOpt2023} to ${newValue}")
        }
    } else {
        val newValue = ItemOptPair(itemOpt2023, Optional.empty())
        Writer.unit(newValue, "${itemOpt2023} to ${newValue}")
    }
}

// --- main ---

val xlsxRowList2023 = listOf<XlsxRow>(
    Pair("Caffe Americano", 500),
    Pair("Pike Place Roast", 500),
    Pair("Caffe Misto", 500),
    Pair("Hot Chocolate", 500))

val xlsxRowList2024 = listOf<XlsxRow>(
    Pair("Caffe Americano", 600),
    Pair("Pike Place Roast", 500),
    Pair("Caffe Misto", 400),
    Pair("Cappuccino", 500))


val itemList = toItemList(xlsxRowList2023, xlsxRowList2024)
val nameList = toNameList(itemList)

val resultWriterList = nameList.map { name->
    val initValue = Pair(itemList, name)
    val resultWriter =
        Writer.unit(initValue, "init ${name}").
        bind( nameToItemOpt2023 ).
        bind( itemOpt2023ToItemOptPair )
    resultWriter
}

resultWriterList.forEach { resultWriter->
    val itemOptPair = resultWriter.valueOpt.get()
    val (itemOpt2023, itemOpt2024) = itemOptPair

    val hasPriceDownWarning = ( resultWriter.text.contains("PRICE DOWN WARNING") )
    val status =
        if( hasPriceDownWarning ){
            "WARNING"
        } else if( itemOpt2024.isEmpty() ){
            "DISCON "
        } else if( itemOpt2023.isEmpty() && itemOpt2024.isPresent() ){
            "NEW    "
        } else {
            "OK     "
        }

    val name = 
        if( itemOpt2023.isPresent() ){
            itemOpt2023.get().name
        } else if( itemOpt2024.isPresent() ){
            itemOpt2024.get().name
        } else { "UNKNOWN" }

    val price = 
        if( itemOpt2024.isPresent() ){
            itemOpt2024.get().price
        } else { "N/A" }

    println("- ${status}: ${name} (${price})")
}

次回は今回のコードを改良してもっと簡単に記述します。

以上です。

Liked some of this entry? Buy me a coffee, please.