iT邦幫忙

第 12 屆 iT 邦幫忙鐵人賽

DAY 15
0

今天來介紹點大家不是很常碰到的神奇用法,看看下面這段程式:

data class Path(val content: String, val id: String)

fun copyPath() {
    val path = Path("/some/path", "235123")
    val copiedPath = path.copy(id = "5566")    // Path("/some/path", "5566")
}

只要在 Kotlin 的 class 前面加上 data 變成 data class ,就可以使用 copy ,其功能為複製原有的內容並改變指定的成員變數。最棒的是, copy 拿到的資料是一個全新的實例,也就是說是 immutable 的,在寫 functional code 的時候是很方便的工具。

但是如果現在的情況是要複製並更改巢狀的資料的話,就要寫多一點程式碼來實現了:

data class Image(val uri: Uri, val mask: Mask)
data class Mask(val uri: Uri)
data class Uri(val scheme: String, val path: Path)
data class Path(val content: String, val id: String)

fun copyImageWithId(image: Image, id: String): Image {
    return image.copy(uri = image.uri.copy(path = image.uri.path.copy(id = id)))
}

擁有越“巢”的資料,就要呼叫越多次的 copy

Lenses

現在來試試看別種解法:Lenses。Lenses 有兩個 function:一個是 get ,另一個是 set

interface Lenses<T, F> {
    fun get(): F
    fun set(value: T, field: F): T
}

其中 T 是要改變的類別,F 則是要改變的成員變數,我們以 Pathid 為例:

class PathIdLenses: Lenses<Path, String> {
    override fun get(value: Path): String {
        return value.id
    }

    override fun set(value: Path, field: String): Path {
        return value.copy(id = field)
    }
}

fun lensesSample() {
    val path = Path("/some/path", "4455")
    val pathIdLenses = PathIdLenses()
    // "4455"
    val id = pathIdLenses.get(path)
    // Path("/some/path", "7788")
    val newPath = pathIdLenses.set(path, "7788")
}

看起來是一個沒什麼作用的類別,對吧?這些事情即使沒有 Lenses 也可以很簡單的做到,但這介面其實是給我們組合用的,讓我們把剩下的也完成:

class PathIdLenses: Lenses<Path, String> {
    override fun get(value: Path): String {
        return value.id
    }

    override fun set(value: Path, field: String): Path {
        return value.copy(id = field)
    }
}

class UriPathLenses: Lenses<Uri, Path> {
    override fun get(value: Uri): Path {
        return value.path
    }

    override fun set(value: Uri, field: Path): Uri {
        return value.copy(path = field)
    }
}

class MaskUriLenses: Lenses<Mask, Uri> {
    override fun get(value: Mask): Uri {
        return value.uri
    }

    override fun set(value: Mask, field: Uri): Mask {
        return value.copy(uri = field)
    }
}

class ImageMaskLenses: Lenses<Image, Mask> {
    override fun get(value: Image): Mask {
        return value.mask
    }

    override fun set(value: Image, field: Mask): Image {
        return value.copy(mask = field)
    }
}

// 聽說程式碼一個字母就算一個字,這絕對不是在衝鐵人賽的字數XD

有沒有注意到這些 Type 都是頭尾相連的?<Image, Mask> → <Mask, Uri> → <Uri, Path> → <Path, String> ,既然 function 可以組合起來,何不試試看讓類別也組合起來呢?

fun <A, B, C> composeLenses(outer: Lenses<A, B>, inner: Lenses<B, C>): Lenses<A, C> {
    return object : Lenses<A, C> {
        override fun get(value: A): C {
            return inner.get(outer.get(value))
        }

        override fun set(value: A, field: C): A {
            val innerValue = inner.set(outer.get(value), field)
            return outer.set(value, innerValue)
        }
    }
}

如此一來,我們就可以把這四個類別組合起來了,如下:

fun lensesSample(image: Image) {
    
    val imageIdLenses = composeLenses(
        ImageMaskLenses(),
        composeLenses(
            MaskUriLenses(),
            composeLenses(
                UriPathLenses(), 
                PathIdLenses()
            )
        )
    )
    val newImage = imageIdLenses.set(image, "5566")
}

再稍微修改一下原函式,改成 infix function 就可以這樣使用(留給大家當練習):

val imageIdLenses = ImageMaskLenses() compose MaskUriLenses() compose UriPathLenses() compose PathIdLenses()

Optics

相信大家都會覺得上面的做法超麻煩,寧願寫多次的 copy 也不要寫這麼多 boilerplate code(樣板程式),對吧?好家在 Functional programming library - Arrow 有提供另一個替代方案,藉由 Annotation 來自動產生程式碼,如下:

// build.gradle
apply plugin: 'kotlin-kapt'

def arrow_version = "0.11.0"
dependencies {
    implementation "io.arrow-kt:arrow-optics:$arrow_version"
    implementation "io.arrow-kt:arrow-syntax:$arrow_version"
    kapt    "io.arrow-kt:arrow-meta:$arrow_version"
}
@optics
data class Image(val uri: Uri, val mask: Mask) {
    companion object
}

@optics
data class Mask(val uri: Uri) {
    companion object
}

@optics
data class Uri(val scheme: String, val path: Path) {
    companion object
}

@optics
data class Path(val content: String, val id: String) {
    companion object
}

只要在 class 的前面加上 optics ,就會自動幫你產生所有的 Lenses 給你,還有 Compaion object 也是需要的。build 完 code 之後,自動產生的程式碼將會在 Compaion 底下,例如 Image.Compaion.mask 或是Image.Compaion.uri,實際的範例如下:

val image = Image(
    uri = Uri(scheme = "https", path = Path(id = "34", content = "/hello/world")),
    mask = Mask(
        uri = Uri(scheme = "https", path = Path(id = "35", content = "/hello/welcome"))
    )
)
val imageIdLenses = Image.mask compose Mask.uri compose Uri.path compose Path.id

val newImage = imageIdLenses.set(image, "5566")
println(image)
println(newImage)

// Image(uri=Uri(scheme=https, path=Path(content=/hello/world, id=34)), 
//       mask=Mask(uri=Uri(scheme=https, path=Path(content=/hello/welcome, id=35))))
//
// Image(uri=Uri(scheme=https, path=Path(content=/hello/world, id=34)), 
//       mask=Mask(uri=Uri(scheme=https, path=Path(content=/hello/welcome, id=5566))))

小結

不知道大家是否腦洞又更開了呢?原來不只 function 可以組合,Functor 可以組合,現在連 class 都可以組合了!今天留給大家的練習(infix 版本的 composeLenses) 將會在下一篇提供解答。


上一篇
Introduce Monoid
下一篇
Reader Monad
系列文
Functional Programming in Kotlin30

尚未有邦友留言

立即登入留言