Metodit ja funktiot, osa 2

Metodit ja funktiot, osa 2

Tässä osassa vertailemme kuinka Kotlin taipuu OOP ja FP tyyleihin esimerkkitapauksen kautta. Edellinen osa löytyy täältä.

Tarkoitus on havainnollistaa kuinka ongelma voidaan toteuttaa OOP (Object Oriented Programming) ja FP (Functional Programming) tyylisesti.

Käytämme Kotlinia joka tukee sekä OOP sekä FP lähestymistapoja. Kielen OOP tuki on vallan mainio mutta FP ominaisuudet ovat jossain määrin rajoittuneet vaikkakin vallan riittävät perustason FP toteutuksiin.

Otetaan kuvitteellinen tapaus:

"Järjestelmä käsittelee tilauspyyntöjä (OrderRequest). Uudet tilauspyynnöt hyväksytään (OrderAccepted) ja jo hyväksytyt hylätään (OrderRejected). Järjestelmän tulee tarjota myös rajapinta, josta voi tarkistaa onko tilaus jo hyväksytty."

Esimerkki on sen verran pieni että sen pystyy tässä mukavasti käsittelemään. Toisaalta se on sen verran suppea että tyylisuuntausten läheskään kaikkia vivahteita ei päästä käsittelemään.

Domain voitaisiin mallintaa vaikka näin

data class OrderId(val id: Int)
data class Order(val someOrderData: String)
data class OrderRequest(val orderId: OrderId, val order: Order)
sealed interface OrderEvent
object OrderAccepted : OrderEvent
object OrderRejected : OrderEvent

Alla olevat molempien lähestymistapojen toteutukset ovat tyylilleen melko karikatyyriset ja suoraviivaiset. Toteutukset on haluttu pitää melko lähellä toisiaan jotta vertailu on helpompaa. Mutta testipuolella on nähtävissä enemmän mielenkiintoisia eroja.

OOP versio

Toteutus voisi näyttää vaikka tältä:

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe

interface OrderCrudRepository {
    fun loadOrder(id: OrderId): Order?
    fun saveOrder(id: OrderId, order: Order)
}

class OrderService(private val orderRepository: OrderCrudRepository) {
    fun isOrderAccepted(orderId: OrderId): Boolean =
        orderRepository.loadOrder(orderId) != null
    fun processOrderRequest(orderRequest: OrderRequest): OrderEvent =
        if (isOrderAccepted(orderRequest.orderId)) {
            OrderRejected
        } else {
            orderRepository.saveOrder(orderRequest.orderId, orderRequest.order)
            OrderAccepted
        }
}

class InMemoryOrderCrudRepository : OrderCrudRepository {
    private val map = mutableMapOf<OrderId, Order>()
    override fun loadOrder(id: OrderId): Order? = map[id]
    override fun saveOrder(id: OrderId, order: Order) {
        map[id] = order
    }
}

class OrderServiceShould : StringSpec({
    val acceptedOrderId = OrderId(1)
    val unacceptedOrderId = OrderId(2)
    val someOrder = Order("someData")
    val unacceptedOrderRequest = OrderRequest(unacceptedOrderId, someOrder)
    val acceptedOrderRequest = OrderRequest(acceptedOrderId, someOrder)
    lateinit var service: OrderService
    beforeTest {
        service = OrderService(InMemoryOrderCrudRepository())
        service.processOrderRequest(acceptedOrderRequest)
    }
    "not accept unaccepted order id" {
        service.isOrderAccepted(unacceptedOrderId) shouldBe false
    }
    "accept accepted order id" {
        service.isOrderAccepted(acceptedOrderId) shouldBe true
    }
    "accept new order request" {
        service.processOrderRequest(unacceptedOrderRequest) shouldBe OrderAccepted
    }
    "reject already accepted order request" {
        service.processOrderRequest(acceptedOrderRequest) shouldBe OrderRejected
    }
})

OOP tyylissä yksikkötesteissä melko usein suositeltu tapa on käyttää triviaalia muistinvaraista tallennustapaa. Itse valitsin myös tämän lähestymisen.

Tästä johtuen testattava luokka OrderService on riippuvainen tilallisesta komponentista (InMemoryOrderCrudRepository) jolloin tila joudutaan alustamaan uudelleen jokaista testiä varten.

FP versio

FP version toteutus voisi olla vaikka:

import io.kotest.assertions.fail
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe

fun interface LoadOrder : (OrderId) -> Order?
fun interface SaveOrder : (OrderId, Order) -> Unit
fun interface AcceptOrder : (OrderId) -> Boolean
fun interface ProcessOrderRequest : (OrderRequest) -> OrderEvent

val acceptOrderId: (LoadOrder) -> AcceptOrder = { loadOrder ->
    AcceptOrder { orderId -> loadOrder(orderId) != null }
}
val processOrderRequest: (AcceptOrder, SaveOrder) -> ProcessOrderRequest =
    { acceptOrderId, saveOrder ->
        ProcessOrderRequest { orderRequest ->
            if (acceptOrderId(orderRequest.orderId)) {
                OrderRejected
            } else {
                saveOrder(orderRequest.orderId, orderRequest.order)
                OrderAccepted
            }
        }
    }

val someOrderId = OrderId(1)
val someOrder = Order("someData")
val someOrderRequest = OrderRequest(someOrderId, someOrder)

class AcceptOrderIdShould : StringSpec({
    val orderIdAccepted = LoadOrder { _ -> someOrder }
    val orderIdNotAccepted = LoadOrder { _ -> null }
    "not accept when order id is unaccepted" {
        acceptOrderId(
            orderIdNotAccepted
        )(someOrderId) shouldBe false
    }
    "accept when order id is accepted" {
        acceptOrderId(
            orderIdAccepted
        )(someOrderId) shouldBe true
    }
})

class ProcessOrderRequestShould : StringSpec({
    val orderIsAccepted = AcceptOrder { true }
    val orderIsNotAccepted = AcceptOrder { false }
    val orderIsNotSaved = SaveOrder { _, _ -> fail("must not save") }
    val orderIsSaved = SaveOrder { _, _ -> }
    "accept non-accepted request" {
        processOrderRequest(
            orderIsNotAccepted,
            orderIsSaved
        )(someOrderRequest) shouldBe OrderAccepted
    }
    "reject accepted request" {
        processOrderRequest(
            orderIsAccepted,
            orderIsNotSaved
        )(someOrderRequest) shouldBe OrderRejected
    }
})

FP tyylissä päädyin testaamaan funktion invariantteja vasten. Toisin sanottuna funktion riippuvuuksia saatetaan vaihtaa testistä toiseen mutta varsinainen argumentti (esim. someOrderRequest) saattaa pysyä samana. Tämä saattaa tuntua perin nurinkuriselta mutta ajattelu on varsin luonnollinen FP:ssä (universumi (=riippuvuudet tässä tapauksessa) muuttaa tilaa, ei sovellus).

Koodia on 3-4 riviä per testi (vaikkakin yksi ekspressio ja ilmaistavissa halutessa yhdellä rivillä).

Vastakkain

Toteutuksien osalta erot ovat pienet ja jossain määrin maku kysymys kumpaa tapaa preferoi.

Testit taas antavat hiukan enemmän pohdittavaa.

Ensimmäinen havainto on että testit on nimetty hiukan eri tavalla vaikka lopulta ne testaavat täysin samaa käyttäytymistä. Tämä selittyy sillä että testit heijastelevat testattavan toiminnallisuuden toteutustapaa.

Koodin luettavuus onkin hankalampi punnittava. Vanhana OOP kettuna OOP versio on kieltämättä aika luettava. Toisaalta myös Kotlinin FP tuki on jossain määrin vaatimaton mikä vaikuttaa omalta osaltaan FP version luettavuuteen.

OOP toteutus riippuu tilallisesta komponentista, mikä tekee testeistä aavistuksen raskaampia ajaa. Toisaalta vaikka sovellus olisi suurempikin tällä ei pitäisi olla suurta vaikutusta niin kauan kuin toteutukset ovat aidosti muistivaraisia. Käytettäessä tilallisia komponentteja on kuitenkin pieni vaara että ajautuu tilanteeseen jossa testejä ei pysty enää ajamaan rinnakkain. Sama tosin koskisi FP lähestymistapaa jos teisteissä hyödynnettäisiin tilallisia komponentteja.

Harmillisen usein projekteissa testejä ei kuitenkaan voi ajaa rinnakkain. Tämä taas näkyy siten että isommissa projekteissa pelkkien yksikkötestien ajaminen mitataan minuuteissa ellei jopa kymmenissä minuuteissa.

Käytettäessä FP lähestymistapaa testit ovat tilattomia, jolloin ne voidaan suorittaa rinnakkain.

Tämä saattaa jakaa mielipiteitä. Onko jompikumpi parempi? Sanoisin että riippuu. Molemmille on paikkansa. Tilalliset testit voivat olla parempia korkeammalla komponenttitasolla kun taas tilattomat testit taas lähempänä yksikkötestitasoa.

Kytkentää

Edellisessä osassa puhuimme kytkennästä ja koheesiosta sekä toiminnallisuuden koostamisesta ja yhdistelystä. Palataan aiheisiin hetkeksi.

Esimerkkimme on sen verran triviaali että siinä näitä ei vielä juurikaan päästy käsittelemään.

Kuvitellaan että muualla sovelluksessa tarvitsee tarkistaa, onko tilaus hyväksytty. OOP mallissa riippuvuutena täytyisi välittää koko OrderService tavalla tai toisella jotta päästään käsiksi varsinaiseen toiminnallisuuteen (isOrderAccepted). FP versiossa usein riittäisi pelkkä lambda, aivan kuten FP esimerkissä.

Juuri riippuvuudet luokista ja niiden mahdollinen alustaminen on OOP tyylissä hiukan hankalampaa. Ja tämä omalta osaltaan puoltaa teisteissä käytettäviä tilallisia komponentteja. Eli luodaan instanssi tilallisesta luokasta kerran, jonka jälkeen instanssin kautta pääsee käsiksi toiminnallisuuksiin, eli metodeihin.

FP:ssä ongelma on yleensä paljon triviaalimpi koska kyse on funktioista, jolloin niiden simuloiminen onnistuu usein yksinkertaisella lambdalla.

Lopuksi

Teimme kevyen katsauksen sekä OOP ja FP tyyleihin konkreettisen esimerkin avulla ja vertailimme lähestymistapoja.

Ainakin Kotlinssa OOP lähestyminen näyttäisi hiukan houkuttelevammalta pelkkää koodia tarkastellessa mutta samalla myös FP:ssä on nähtävissä paljon hyviä puolia.

FP versioon olisi voinut yrittää hyödyntää esimerkiksi Arrow kirjastoa koska kielen FP tuki on rajoittunut. Mutta tällöin ongelmaa olisi ratkottu framework edellä, mikä ei ole hyvä asia.

Ehkä blogautuksen perimmäisenä tarkoituksena oli luoda kevyt silta OOP ja FP lähestymisien välille esimerkin avulla. Molemmat tyylit ovat osaavissa käsissä tehokkaita mutta ymmärtämällä molempia voi parantaa omaa tekemistään ja kenties löytää uusia ulottuvuuksia omalle uralleen.

Syttyykö sisälläsi pieni kipinä aiheesta? Laita viestiä hello@bytecraft.fi ja jatketaan jutustelua.

Haemme uusia kollegoita ja osakkaita! Tutustu palkkaan ja arvoihimme

Muut pohdintamme