함수형 프로그래밍이란? (with 코틀린 예제)

반응형
728x90
반응형

명령어 스타일 (imperative style)

  • 컴퓨터에게 정해진 명령 또는 지시를 하나하나 내림으로써 각 명령 단계마다 시스템의 상태를 바꾼다.
  • 처음에는 단순화하려는 의도나, 시스템이 커질수록 복잡해지며, 그 결과 코드를 더이상 유지보수할 수 없게 되고, 테스트 하기 어려워지며 코드를 추론하는데에 어려워진다.

 

 

함수형 프로그래밍 (FP, Functional Programming)

  • 위 명령어 스타일의 대안으로, '부수 효과'를 완전히 없애는 개념이다.
  • 함수형 프로그래밍의 전제는, 순수 함수를 통해 프로그램을 구성한다는 것이다.

 

 

부수 효과 (Side Effect)

결과를 반환하는 것 외에 무언가 다른 일을 하는 함수는 부수 효과가 있는 함수다.

  • 변경이 일어나는 블록 외부 영역에 있는 변수를 변경한다.
  • 데이터 구조를 인플레이스로 변경한다. (즉, 메모리의 내용을 직접 변경한다.)
  • 객체의 필드를 설정한다.
  • 예외를 던지거나 예외를 발생시키면서 프로그램을 중단시킨다.
  • 콘솔에 출력을 하거나 사용자 입력을 읽는다.
  • 파일을 읽거나 쓴다.
  • 화면에 무언가를 그린다.

 

 

함수형 프로그래밍(FP)의 장점 : 예제로 알아보기

1. 부수 효과가 있는 프로그램 (=순수하지 않은 프로그램)

class CreditCard {
    fun charge(price: Float): Unit = println("charge")
}

data class Coffee(val price: Float = 2.50F)

class Cafe {
    fun buyCoffee(cc: CreditCard): Coffee {
        val cup = Coffee() // <1>
        cc.charge(cup.price) // <2>
        return cup // <3>
    }
}

 

main()
fun main() {
    val cafe = Cafe()
    val card = CreditCard()

    val cup = cafe.buyCoffee(card)
    print(cup.price)
}

 

실행결과
charge
2.5

 

1) CreditCard 객체의 charge() 메서드를 호출한다. 이로써 부수 효과가 생긴다.

charge는 input은 있지만, return값이 없어서 어떤 수행을 하는지 알 수 없다.

cc.charge(cup.price) // <2>
  • 신용카드를 청구하려면, 신용 카드사에 요청해야하므로 외부에서 부수적으로 벌어지는 일이다. 반환하는 객체는 단지 Coffee 객체다.
  • 이 부수효과로 인해서, 테스트가 어려워진다. 실제 신용 카드사에 접속해서 요청하는 것은 원하지 않는다.
  • CreditCard는 신용카드사에 접속해 비용을 청구하는 방법을 알아서는 안된다.
  • CreditCard가 이런 관심사를 알지 못하게 하고, buyCoffee에 Payments 객체를 넘김으로써 이 코드를 좀더 모듈화하고 테스트성을 향상시킬 수 있다.

 

2. 테스트성 향상

data class Coffee(val price: Float = 2.95F)

class CreditCard {

}

class Payments {
    fun charge(cc: CreditCard, price: Float): Unit = println("charge")
}

//tag::init2[]
class Cafe {
    fun buyCoffee(cc: CreditCard, p: Payments): Coffee {
        val cup = Coffee()
        p.charge(cc, cup.price)
        return cup
    }
}

 

main()
fun main() {
    val cafe = Cafe()
    val card = CreditCard()
    val payments = Payments()

    val cup = cafe.buyCoffee(card, payments)
    print(cup.price)
}

 

실행결과
charge
2.95

 

1) Payments를 인터페이스로 선언할 수 있고, 이 인터페이스에에 대해 테스트에 적합한  mock 객체를 구현할 수 있다.

@Test
fun chargeTest() {
    ...
    
    val p: Payments = mock()
    
    ...
}
  • 불필요하게 Payments를 인터페이스로 선언해야한다.

 

2) buyCoffee()를 재사용하기가 어렵다.

  • 한 고객이 커피를 12잔 주문할 경우, for문으로 buyCoffee()를 호출할 것이다. 이런 식으로 호출하면 charge() 메서드가 12번 수행되어 신용카드사에 12번 연결해서 청구라는 행위를 수행하게 된다.
  • 위 문제의 처리 방안으로, buyCoffess()라는 새로운 함수를 작성해서 한꺼번에 청구하는 로직을 넣을 수 있다.

 

 

3. 함수형 해법

부수 효과를 제거하고 buyCoffee가 Coffee와 함께 청구할 금액을 반환하게하자.

class CreditCard

data class Coffee(val price: Float = 2.50F)

data class Charge(val cc: CreditCard, val amount: Float)

//tag::init3[]
class Cafe {
    fun buyCoffee(cc: CreditCard): Pair<Coffee, Charge> {
        val cup = Coffee()
        return Pair(cup, Charge(cc, cup.price)) // 어떤 금액 청구를 만드는 관심사(Coffee), 청구를 처리하거나 해석하는 관심사(Charge)
    }
}

 

main()
fun main() {
    val cafe = Cafe()
    val card = CreditCard()

    val pair = cafe.buyCoffee(card)
    println(pair.first)
    println(pair.second)
}

 

실행결과
Coffee(price=2.5)
Charge(cc=chapter1.sec1.CreditCard@531be3c5, amount=2.5)

 

1) 두 관심사로 분리했다.

이제 Coffee와 신용카드 청구건인 Charge를 Pair로 함께 리턴한다.

return Pair(cup, Charge(cc, cup.price))
  • 어떤 금액 청구를 만드는 관심사 = Coffee
  • 청구를 처리하거나 해석하는 관심사 = Charge

 

2) Charge()

  • CreditCard와 amount를 포함한다.
  • 같은 CreditCard에 대한 청구를 하나로 묶어줄때 편리하게 쓸 수 있는 combine 함수를 제공한다.

 

4. combine() 함수 구현

class CreditCard

//tag::init4[]
data class Charge(val cc: CreditCard, val amount: Float) { // <1> 생성자와 불변 필드가 있는 데이터 클래스 선언
    fun combine(other: Charge): Charge = // <2>같은 신용카드에 대한 청구를 하나로 묶음
        if (cc == other.cc) // <3> 같은 카드인지 검사. 그 외의 경우 에러 발생
            Charge(cc, amount + other.amount) // <4> 이 Charge와 다른 Charge의 금액을 합산한 새로운 Charge를 반환
        else throw Exception(
            "Cannot combine charges to different cards"
        )
}

 

main()
fun main() {
    val card = CreditCard()
    val charge1 = Charge(card, 2.5F)
    val charge2 = Charge(card, 7.5F)

    val getCharge = charge1.combine(charge2)
    println(getCharge.cc)
    println(getCharge.amount)
}

 

실행결과
chapter1.sec1.CreditCard@617c74e5
10.0

 

5. buyCoffees 생성

이제는 우리 바람대로 buyCoffee를 바탕으로 이 함수를 구현할 수 있다.

class CreditCard

data class Coffee(val price: Float = 2.50F)

data class Charge(val cc: CreditCard, val amount: Float) {
    fun combine(other: Charge): Charge = // <2>같은 신용카드에 대한 청구를 하나로 묶음
        if (cc == other.cc) // <3> 같은 카드인지 검사. 그 외의 경우 에러 발생
            Charge(cc, amount + other.amount) // <4> 이 Charge와 다른 Charge의 금액을 합산한 새로운 Charge를 반환
        else throw Exception(
            "Cannot combine charges to different cards"
        )
}

//tag::init5[]
class Cafe {

    fun buyCoffee(cc: CreditCard): Pair<Coffee, Charge> {
        val cup = Coffee()
        return Pair(cup, Charge(cc, cup.price)) // 어떤 금액 청구를 만드는 관심사(Coffee), 청구를 처리하거나 해석하는 관심사(Charge)
    }

    fun buyCoffees(
        cc: CreditCard,
        n: Int // 구매할 커피잔 수
    ): Pair<List<Coffee>, Charge> {

        val purchases: List<Pair<Coffee, Charge>> =
            List(n) { buyCoffee(cc) } // <1> 자체적으로 초기화되는 리스트를 생성한다.

        val (coffees, charges) = purchases.unzip() // <2> Pair의 리스트를 두 리스트로 분리한다. List<Coffee>, List<Charge>

        return Pair(
            coffees,
            charges.reduce { c1, c2 -> c1.combine(c2) }
        ) // <3> coffees를 한 Charge로 합친 출력을 생성한다.
    }
}
  • 이제는 buyCoffees 함수를 정의할때 직접 buyCoffee를 재사용할 수 있다.
  • Payments 인터페이스에 대한 복잡한 mock 구현을 정의하지 않아도 이 두 함수를 아주 쉽게 테스트할 수 있다.
  • Cafe 객체는 이제 Charge 값이 어떻게 처리되는지와는 무관하다.

 

main()
fun main() {
    val card = CreditCard()
    val cafe = Cafe()

    val pair = cafe.buyCoffees(card, 10)

    println(pair.first)
    println(pair.second)
}

 

실행결과
[Coffee(price=2.5), Coffee(price=2.5), Coffee(price=2.5), Coffee(price=2.5), Coffee(price=2.5), Coffee(price=2.5), Coffee(price=2.5), Coffee(price=2.5), Coffee(price=2.5), Coffee(price=2.5)]
Charge(cc=chapter1.sec1.CreditCard@4590c9c3, amount=25.0)

 

디버깅으로 다시한번 분석하자.

1) 리스트를 생성한다.

n이 10이므로, 아래와 같이 List의 size가 10이다.

 

2) unzip() 메서드로 각 coffees, charges 리스트로 나눈다.

▶ coffees

 

▶ charges

 

3) Pair 객체를 리턴한다.

 

6. 같은 카드에 청구하는 금액을 모두 합치기

class CreditCard

data class Coffee(val price: Float = 2.50F)

data class Charge(val cc: CreditCard, val amount: Float) {
    fun combine(other: Charge): Charge = // <2>같은 신용카드에 대한 청구를 하나로 묶음
        if (cc == other.cc) // <3> 같은 카드인지 검사. 그 외의 경우 에러 발생
            Charge(cc, amount + other.amount) // <4> 이 Charge와 다른 Charge의 금액을 합산한 새로운 Charge를 반환
        else throw Exception(
            "Cannot combine charges to different cards"
        )
}

fun List<Charge>.coalesce(): List<Charge> =
    this.groupBy { it.cc }.values
        .map { it.reduce { a, b -> a.combine(b) } }
  • 청구 금액의 리스트를 취해서 사용한 신용카드에 따라 그룹으로 나누고, 각 그룹의 청구 금액을 하나로 합쳐서 카드마다 하나씩 청구로 만들어낸다.

 

main()
fun main() {
    val card = CreditCard()
    val card2 = CreditCard()

    val charge1 = Charge(card, 2.5F)
    val charge2 = Charge(card, 7.5F)
    val charge3 = Charge(card2, 5.5F)

    var charges = listOf(charge1, charge2, charge3)

    val coalesce = charges.coalesce()

    println(coalesce)
}

 

실행결과
[Charge(cc=chapter1.sec1.CreditCard@32e6e9c3, amount=10.0)
, Charge(cc=chapter1.sec1.CreditCard@5056dfcb, amount=5.5)]

 

 

순수 함수란?

  • 어떤 함수가 주어진 입력으로부터 결과를 계산하는 것 외에 다른 어떤 관찰 가능한 효과가 없다 -> "부수효과가 없다"
  • "부수 효과가 없는 함수" -> "순수 함수"
    • ex) String의 length 함수 : 주어진 문자열에 대해 항상 같은 길이가 반환되며, 다른 일은 발생하지 않는다.
  • 참조 투명성(RT, Referential Transparency)이라는 개념을 사용해 형식화할 수 있다.
    • 예시로 이해하자. 2 + 3은 순수함수 plus(2, 3)에 적용하는 식이다. 이 식에는 아무 부수효과가 없다.
      • 결과는 언제나 5다.
      • 실제로 프로그램에서 2 + 3을 볼때마다 이 식을 5로 치환할 수 있다. 이렇게 해도 프로그램의 의미가 전혀 바뀌지 않는다.
      • 이는 어떤 식이 참조 투명하다는 말이 지닌 뜻의 전부다.
    • 어떤 프로그램에서 프로그램의 의미를 변경하지 않으면서 식을 그 결괏값으로 치환할 수 있다면, 이 식은 참조 투명하다.
    • 어떤 함수를 참조 투명한 인자를 사용해 호출한 결과가 참조 투명하다면 이 함수도 참조 투명하다.

 

 

순수 함수를 만족하지 못하는 예제

class CreditCard {
    fun charge(price: Float): Unit = TODO()
}

data class Coffee(val price: Float = 2.50F)

//tag::init[]
fun buyCoffee(cc: CreditCard): Coffee {
    val cup = Coffee()
    cc.charge(cup.price)
    return cup
}
  • buyCoffee()는 cc.charge(cup.price)의 반환 타입과 관계없이 이 함수 호출의 반환값을 무시한다.
  • 따라서 buyCoffee()를 평가한 결과는 그냥 cup이고, 이 값은 Coffee()와 동일하다.
    • 순수 함수가 되기 위해서는 p에 관계없이 p(buyCoffee(aliceCreditCard)), p(Coffee())가 똑같이 작동해야한다.
      • 성립되지 않는다.
      • p(buyCoffee(aliceCreditCard)) : 카드사를 통해 커피 값을 청구한다.
      • p(Coffee()) : 아무일도 하지 않는다.

 

 

참조 투명성 예제

>>> val x = "Hello, World"
>>> val r1 = x.reversed()
>>> val r2 = x.reversed()

 

x가 등장하는 부분을 x가 가리키는 식으로 바꿔치기하자.

 

>>> val r1 = "Hello, World".reversed()
>>> val r2 = "Hello, World".reversed()
  • 위 r1, r2가 같은 값으로 평가된다.
  • x가 참조 투명하기 때문에 r1, r2 값은 예전과 같다. -> r1, r2도 참조 투명하다.

 

 

참조 투명성을 위배하는 예제

>>> val x = StringBuilder("Hello")
>>> val y = x.append(", World")
>>> val r1 = y.toString()
>>> val r2 = y.toString()
  • append() 함수 : StringBuilder에 작용하며 객체 내부를 변화시킨다. append()가 호출될 때마다 StringBuilder의 이전 상태가 파괴된다.
  • StringBuilder에 대해 toString()을 여러번 호출해도 항상 똑같은 결과를 얻는다.

 

>>> val x = StringBuilder("Hello")
>>> val r1 = x.append(", World").toString()
>>> val r2 = x.append(", World").toString()
  • y를 모두 append() 호출로 치환했다. -> 순수 함수가 아니라고 결론을 내릴 수 있다.
  • StringBuilder에 대해 toString()을 여러번 호출해도 결코 같은 결과가 생기지 않는다.
  • r1, r2는 같은 식처럼 보이지만, 실제로는 같은 StringBuilder 객체의 다른 두 값을 가르킨다.

 

반응형

Designed by JB FACTORY