본문 바로가기

프로그래밍 기술 노트/Functional Study

[Optics/Lens] 내 멋대로 Optics 이해하기 Feat) arrow-kt

불변(Immutable) 은 이미 대세라고 할정도로 중요한 개념으로 자리 잡았고,
대수적 데이터 타입(ADT) 로 디자인해서 이케 저케.. 썸타입이 어쩌고, 곱타입이 어쩌고.. 하는게 요즘 스타일인데,

비단 Haskell / Scala 같은 정통적(?) 인 FP 뿐만 아니라 C# / Kotlin 에서도 ADT 디자인을 위한 목적으로 data class 나 sealed class 를 제공해준다. (따지고 보면 다 ADT 이기도 하고)

그냥 내가 하는 소리가 아니라 진짜 Kotlin release 에서 그렇게 사용한다고 적혀있다.  (참고로 한국어 버전에서는 OOP 에서 자주 사용하는 용어인 abstract data type 으로 잘못 번역되어있는데, 영어버전에는 algebraic data type 으로 되어있음)

Sealed classes, and now interfaces, are useful for defining algebraic data type (ADT) hierarchies.

 

딱히 ADT 라는걸 신경쓰지 않아도, kotlin 을 쓰면 data class 나 sealed class 를 종종 사용하게 된다.
특히 data class 는 java 에 비해서 불변데이터를 쉽게 뚝딱뚝딱 찍어낼수있다는게 강점이고,
sealed class를 문제를 뚝딱뚝딱 모델링하고 더 나아가 패턴매칭에서 아주 유용하게 사용할수 있다.

다만 이런 불변 객체를 다루다보면 종종 데이터 카피가 더럽게 귀찮을 때가 있다.

물론 모던한 요즘 언어에서는 불변 데이터와 copy 같은 메소드를 제공해준다.
다만, 데이터 계층구조가 복잡해지거나 복잡해지면 저 copy 메소드를 중첩해서 호출해야한다.

data class Street(val number: Int, val name: String)
data class Address(val city: String, val street: Street)
data class Company(val name: String, val address: Address)
data class Employee(val name: String, val company: Company)

val employee = Employee("John Doe", Company("Arrow", Address("Functional city", Street(23, "lambda street"))))

//거지같은 복사방법
employee.copy(
        company = employee.company.copy(
                address = employee.company.address.copy(
                        street = employee.company.address.street.copy(
                                name = employee.company.address.street.name.capitalize()
                        )
                )
        )
)

만약에 불변을 신경쓰지 않는 고대 언어 스타일이었다면 employee.company.address.street.name.capitalize() 만 하면 끝이다.

이러한 귀찮음을 해결하기 위하여 FP 에서는 주로 "Optics (Lens)" 라는 기술 또는 디자인을 사용하게 된다.
(불변 자체가 FP 가 뿌린 씨앗이니까 책임지고 해결방법을 제공해준다)

간단하게 설명하자면, 함수형(또는 불변데이터) 에서 참조하는 스킬이다.
Optics 라는게 좋은점은, 그냥 거창한 지식 없어도 뚝딱 쓸수있다는것이다.
FP 를 모르는 사람은 FP 가 메인이 아닌 언어(kotlin/C#/js 등) 에 라이브러리를 가져온다고 MonadHKT 같은 기술을 뚝딱뚝딱 이해하고 사용할수는 없다. 많은.... 학습이 필요한데 필자도 아직 제대로 뚝딱뚝딱 사용하지 못한다...

그런데 Optics 는 그냥 이미 우리가 자주쓰는 데이터를 손쉽게 다루는 법을 제공하는 실용적인(=> 그냥 쓸수있는) 개념이다. 실제로 copy 중첩문제는 많은 사람이 겪고있는 문제이기도 하고..
물론 제대로 파고 들면 "멍 개소리야?" 소리가 나오지만.. 

이를 증명(?) 하기 위하여 이번 포스팅에서는 Kotlin 에 fp 라이브러리인 arrow-kt, 그중에서도 arrow optics 만 사용한다. (사실 F# 안한지 넘 오래되서 기억이 안남..)

 

개념이랑은 별 의미 없는 IDEA + arrow optics 설정

더보기

 

arrow-kt 의 github 는 kapt를 사용하고 홈페이지의 가이드는 ksp 를 사용하는데 어찌됫건 설정 그대로 하면 설치가 안된다 ㅡㅡ  내가 잘못한건가...

 

1. arrow optics 설치

arrow-optics-ksp-plugin:1.0.1 이 없다..
1.0.1 이 없자나 ㅡㅡ

- 나는 대충 1.0.3 암거나 집어서 설치했다.

2. IDEA generated source root 설정

idea 에서 사용하기 위해서 arrow 가 생성한 코드를 root 로 설정해준다

plugins {
    kotlin("jvm") version "1.6.10"
    id("com.google.devtools.ksp") version "1.6.10-1.0.2" // ksp 설정 A-B 의 버전에서 A는 위 jvm 버전으로 설정
    idea	// idea 에서 생성된 코드를 인식하기 위해 설정
    application
}
repositories {
    mavenCentral()
}
dependencies {
    implementation("io.arrow-kt:arrow-optics:1.0.1")
    ksp("io.arrow-kt:arrow-optics-ksp-plugin:1.0.3-alpha.1")
}

// idea 에서 생성된 코드를 인식하기 위해 설정, 딱히 폴더가 바뀌지는 않을텐데, compileKotlin 이후 폴더 확인할것
// 그냥 idea 에서 우클릭으로 generated source 설정하는건 빌드는되는데 빨간줄 뜨더라..
val generatedSourcesPath = file("build/generated/ksp")

java.sourceSets["main"].java.srcDir(generatedSourcesPath)

idea {
    module {
        generatedSourceDirs.add(generatedSourcesPath)
    }
}


// 나머지 그래들은 알아서..

 

Optics의 디테일한건 넘어가고, 크게 3가지만 보자면 iso / lens / prism 이다.

  • (이해를 돕고자, kotlin 코드상에 변수의 data type 은 전부 표기했다)
  • @optics 와 compainioni object 는 arrow-kt 가 코드를 생성하기 위해 필요하다.

ISO

iso는 isomorphism(동형 사상)의 앞자리이다... 아마 그 선형대수나 그래프에 그거 맞을텐데 

별이랑 오각형이랑 대충 같은놈이란 이야기

왜 굳이 많고 많은 이름중에 isomorphism 에서 따왔지는지 카테고리론을 좋아하는 함수형 수학자에게 물어봐야 할듯하고.. 뭐 이유가 있겠지.. 암튼 두개가 대충 거기서 그놈이라는 이야기다.

@optics data class AnimeCharacter(
    val Name: String,
    val Career: String
) {
    val sayIt : String
        get() = "내이름은 $Name, $Career 이죠"
    companion object
}

fun main(args: Array<String>) {
    val conan = AnimeCharacter("코난","탐정")
    val conanTuple = ("코난" to "탐정")
}

AnimeCharacter 라는 data class 는 Name 과 Career 라는 2개의 String 으로 구성되어있다.
여기서 Name 이나 Career 라는 변수명은 사실 그냥 개발자를 위한 일종의 메타 데이터이지, 실제 값은 아니다.
즉 AnimeCharacter을 구성하는데 필요한 요소는 결국 2개의 String 이라는 이야기고
2개의 String 데이터는 size 2 짜리 Tuple (Pair) 와 거기서 거기라는것

즉 AnimeCharacter 과 Pair 는 서로 뚝딱뚝딱 바뀔수있다는것인데, 두개의 변환을 arrow optics 가 자동으로 생성해준다.

fun main(args: Array<String>) {
    val conan = AnimeCharacter("코난","탐정")
    val conanTuple = ("코난" to "탐정")

	//AnimeCharacter.iso 는 arrow 가 만들어준 Iso<AnimeCharacter,Pair<String, String>> 타입
    val iso: Iso<AnimeCharacter,Pair<String, String>> = AnimeCharacter.iso
    println (conanTuple == iso.get(conan))        // true
    println (conan == iso.reverseGet(conanTuple)) // true
}

 

근데 만약 AnimeCharacter 과 Pair 가 같은놈이라면 함수도 사이좋게 나눠 쓸수있지 않을까?

그걸 보통 lift (승급) 한다고 표현한다

fun main(args: Array<String>) {
    val conan = AnimeCharacter("코난","탐정" )
    val conanTuple = ("코난" to "탐정")
  
    // 튜플의 각 string 을 각각 reverse 하는 함수
    val reverseString  = { pair: Pair<String,String> -> (pair.first.reversed() to pair.second.reversed())}
    
    // 튜플의 각 string 을 각각 reverse 하는 함수를 AnimeCharacter 타입에 맞게 리프팅
    // 리프팅되면? -> AnimeCharacter 의 각 string (name과 career) 가 각각  reverse 되는 함수가 됨!
    val lifted = iso.lift(reverseString)
    
    println(conan.sayIt) // 내 이름은 코난, 탐정 이죠
    println(lifted(conan).sayIt) // 내 이름은 난코, 정탐 이죠
 }

arrow optics 는 lift 하는 함수도 제공해주기때문에 튜플에 적용되는 함수를 고냥 가져다 사용할수있다.

 

lens

lens 는 맨 처음 말했던, 불변 데이터의 copy 의 중첩을 막아 줄수 있다.
간단하게 말하면, 중첩된 데이터들에서 원하는 부분만 쏙 쏙 바꿀수있는 함수를 그냥 만들어준다
단순히 바꾸는거 뿐(set)만 아니라
보기위한 get 이나 그 부분에만 함수를 적용해주는 고차함수 인 modify 도 제공해준다.
Optional (or nullable) 이라면 이를 처리하기 위한 더 많은 기능을 제공해준다

@optics data class Building(val name: String) {
    companion object
}
@optics data class Street(val name: String,val number: Int) {
    companion object
}
@optics data class Address(val street: Street, val building: Building?) {
    companion object
}
@optics data class Character(val name: String, val address:Address) {
    companion object
}


fun main(args: Array<String>) {
    // 중첩된 데이터
    val conanDetail = Character("코난",Address(Street("베이커가", 5), Building("모리 탐정 사무소")))
    
    // street 에 대한 렌즈 획득
    val lensForStreet: Lens<Character, Street> = Character.address.street
    
    // building 에 대한 렌즈 획득
    // 여기서 building 은 optional(nullable) 이기 때문에 Optional 타입 Lens가 획득
    val lensForBuilding: Optional<Character, Building> = Character.address.building
    
    // Lens 를 이용하여 Set 가능
    val setConan = lensForBuilding.set(conanDetail,Building("브라운박사 하우스"))
    
    // Lens 를 이용하여 modify 가능
    val modifiedConan = lensForStreet.modify(conanDetail,  { it.copy(number = 10) })
    
    // Lens 를 사용하지 않을때 일반적인 copy 중첩
    val nested = conanDetail.copy(
        address = conanDetail.address.copy(
            building = conanDetail.address.building?.copy(name = "브라운박사 하우스")
        )
    )
    //Character(name=코난, address=Address(street=Street(name=베이커가, number=5), building=Building(name=모리 탐정 사무소)))
    println (conanDetail)
    
    //Character(name=코난, address=Address(street=Street(name=베이커가, number=5), building=Building(name=브라운박사 하우스)))
    println (setConan)
    
    //Character(name=코난, address=Address(street=Street(name=베이커가, number=10), building=Building(name=모리 탐정 사무소)))
    println (modifiedConan)
    
    println (nested == setConan) //true
}

Lens 를 사용해서 중첩할 필요없이 원하는 데이터만 쏙쏙 골라서 사용하거나 수정할수있다. 물론 불변데이터의 강점은 그대로가져 갈 수 있으며,
Lens 에서도 lift 가 가능하기 때문에 기존 함수를 리프팅해서 적용시킬수 있다. (아래 prism 의 예시에 Lifted 참고)

 

Prism

lens 가 data class (곱타입) 에서 적용되는 Optics 라면, Prism은 sealed class (합타입) 에 적용되는 Optics 이다

// sealed class 에 @optics 적용 -> prism 생성됨
@optics sealed class Criminal {
    companion object { }
    
    // 물론 내부의 data class 에 @optics 를 적용하여 lens 등을 생성 할 수 있다.
    @optics data class BlackGroup(val name: String, val classLevel:Int) : Criminal() {
        companion object
    }
    data class Murderer(val name: String, val motive : String) : Criminal()
    data class Thief(val name: String, val cost: Double) : Criminal()
}

fun main(args: Array<String>) {
    val jin = Criminal.BlackGroup("진",1)
    val murderer = Criminal.Murderer("한자와","층간소음")

    val blackGroupPrism: Prism<Criminal, Criminal.BlackGroup> = Criminal.blackGroup
    val murdererPrism: Prism<Criminal, Criminal.Murderer> = Criminal.murderer

    //이왕 배우는김에 lens 를 써보자
    val lensForLevel = Criminal.BlackGroup.classLevel
    //BlackGroup 에 해당하는 c의 classlevel 을 1 올리는 함수
    val levelUpInBlackGroup = { c:Criminal.BlackGroup -> lensForLevel.modify(c, {it.inc()})}
    // 당근빠따 lens 에서도 lift 를 통해서 만들수도있다
    val levelUpInBlackGroupLifted = lensForLevel.lift { it.inc() }


    //BlackGroup(name=진, classLevel=1)
    println(jin) 
    
    //BlackGroup(name=진, classLevel=2) -> modify 를 통하여 진의 level 이 오름
    println(blackGroupPrism.modify(jin, levelUpInBlackGroup))



    //Murderer(name=한자와, motive=층간소음)
    println(murderer)
    
    //Murderer(name=한자와, motive=층간소음) -> 한자와씨는 검은조직이 아니라 해당 함수가 먹지않음
    println(blackGroupPrism.modify(murderer,levelUpInBlackGroup))
}

이것 역시 간단히 말하면, sealed class 에서 패턴매칭 (BlackGroup 이면 적용, 아니면 Skip) 을 자동으로 만들어준다.

BlackGroup 의 Jin 과, Murderer 인 한자와씨는 같은 같은 부모(Criminal) 타입을 가지는데,
이때 BlackGroup 용 함수는 BlackGroup 에만 적용된다. 당연한거 아니냐고 생각할수도있는데,
우리가 인터페이스를 통해서 프로그래밍할때 매번 if 문으로 형식 검사를 하고있는걸 생각해보면 쉽다. (Criminal 타입을 리턴받아서 어떠한 처리가 필요한데, Type 에 맞게 실행되도록 할 수 있다.)

(엥 이거 완전 Optional, Result, Either 아니냐? -> 대충 맞다)

 

 

끝맺음

언제나 그렇듯이, 정말 대충 파악하고 설명한 내용이다.
언제나 그렇듯이, 깊이 파고 들면 무궁무진 한 개념이다. (WikibooksHaskell 의 렌즈 설명)

728x90