💡 Intro
굉장히 아쉽게도 우테코 레벨2를 시작하고 정리하는 글의 첫 주제는 안드로이드가 아니라 코틀린 내용이다. 아마 레벨1에서 다하지 못한 코틀린에 대한 내용도 종종 올라올 것 같다.
reduce
reduce함수가 어떻게 구현되어 있는지 구경해보자.
reduce 함수는 인자로 함수 하나를 받는다. 이 함수는 어떤 accumulate 작업을 할 것인지를 명시한다.
reduce 함수는 accumulate 작업 시 첫 번째 원소로 시작한다.
비어있는 컬렉션에서 reduce 를 호출하면 exception 이 발생할 수 있기 때문에 만약 reduce를 호출하는 컬렉션이 비어있을 수 있다면 reduce대신 reduceOrNull을 쓰라고 한다.
앞에서 reduce는 함수 하나를 인자로 받는다고 했고, 코틀린에서는 함수가 마지막 인자로 넘어오는 경우 람다로 넘길 수 있는 특성을 가지고 있다는 것을 기억하고 아래 코드를 보도록 하자.
val numbers = listOf(1, 2, 3, 4, 5)
val sum = numbers.reduce { total, num -> total + num }
println("reduce sum : $sum")
결과는 1, 2, 3, 4, 5의 합인 15가 출력될 것이다.
비슷하지만 약간 다른 코드 하나를 더 보도록 하자.
val numbers = listOf(2, 4, 6, 8, 10)
val sum = numbers.reduce { total, num -> total + num * 2 }
println("reduce sum : $sum")
우리가 기대하는 결과는 2*2 + 4*2 + 6*2 + 8*2 + 10*2 = 60이다.
하지만 출력된 결과를 보면 알 수 있듯이 기대하는 값이 나오지 않았다. 이러한 이유가 무엇일까?
첫 번째 iteration에서 total이 첫 번째 원소인 2가 사용되고, num은 두 번째 원소인 4가 사용된다. 이러한 특성 때문에 첫 번째 원소에서 *2 연산이 수행되지 않아 기대하는 값이 나오지 않은 것이다.
fold
비슷하지만 약간 다른 fold함수에 대해 알아보자. 역시 fold함수가 어떻게 구현되어 있는지부터 구경하자.
fold 함수는 초기 값과 함수 하나를 인자로 받는다.
만약 컬렉션이 비어있다면 초기 값을 반환한다. 초기 값을 받아 accumulate을 진행하기 때문에 빈 컬렉션에서 fold를 호출해도 별다른 문제가 발생하지 않는다. (reduce와 다르게 exception을 발생시키지 않는다.)
아래 코드를 보도록 하자.
val numbers = listOf(1, 2, 3, 4, 5)
val sum = numbers.fold(10) { total, num -> total + num }
println("fold sum : $sum")
결과는 초기값 10에 1, 2, 3, 4, 5가 더해진 25가 출력되리라 예상할 수 있다.
역시 비슷하지만 약간 다른 코드를 보자.
val numbers = listOf(2, 4, 6, 8, 10)
val sum = numbers.fold(0) { total, num -> total + num * 2 }
println("fold sum : $sum")
초기값으로 0을 넣어준 것 빼고는 reduce 코드와 동일하다. fold를 사용하면 결과가 어떻게 나올까?
우리가 기대하는 결과인 2*2 + 4*2 + 6*2 + 8*2 + 10*2 = 60이 잘 나온다.
fold는 초기값이 첫 번째 iteration의 total로 사용되고, 컬렉션의 첫 번째 원소부터 num이 되기 때문에 모든 원소에 *2 연산이 된 합을 얻을 수 있다.
정리
reduce는 초기 값을 컬렉션의 첫 번째 원소로 지정하고, fold는 초기 값을 지정해줄 수 있다.
컬렉션이 비어있을 수 있는 경우 reduce 대신 fold를 사용하자.
적용
이 내용에 대해 공부하게 된 계기는 다음과 같다. 레벨1 첫 미션인 영화 티켓 예매에서는 할인 정책이 존재한다. 할인은 조조, 야간, 무비데이 할인 총 3가지다. 처음에는 할인을 관리하는 object를 만들어 내부에서 모든 조건(조조, 야간, 무비데이)들을 검사하는 방식으로 구현했다. 하지만 이렇게 구현하면 나중에 할인 조건이 추가되거나 또 다른 할인과 중첩 할인이 된다면 이를 판단하는 코드를 모두 구현해야 할 것이다. 그래서 생각난 게 제이슨이 오목 라이브코딩 당시 렌주룰에서 적용되는 금수 알고리즘을 분리했던 방법이다. 3*3 금수를 판단하는 클래스, 4*4 금수를 판단하는 클래스, 장목 금수를 판단하는 클래스를 각각 만들었고, 이를 조합하여 룰을 구성할 수 있게 했었다.
어떤 방식으로 미션에 적용했는지 정리해보려고 한다. 간단하게 구조부터 설명하면 다음과 같다.
인터페이스 DiscountPolicy는 추상 메서드 getDiscountPrice(price: Int) 함수 하나를 가지고 있다.
조조 할인을 담당하는 클래스 EarlyMorningDiscountPolicy는 DiscountPolicy를 구현한다.
야간 할인을 담당하는 클래스 EveningDiscountPolicy는 DiscountPolicy를 구현한다.
무비데이 할인을 담당하는 클래스 MovieDayDiscountPolicy는 DiscountPolicy를 구현한다.
위 세개의 클래스가 오버라이드 하는 getDiscountPrice(price: Int) 함수는 할인해주는 금액을 반환한다.
무비데이 할인, 조조할인, 야간할인을 모두 가지고 있는 NormalDiscountPolicy의 전체 코드는 다음과 같다.
class NormalDiscountPolicy(
private val date: LocalDate,
private val time: LocalTime,
) : DiscountPolicy {
private val policies = listOf(
MovieDayDiscountPolicy(date),
EveningDiscountPolicy(time),
EarlyMorningDiscountPolicy(time),
)
override fun getDiscountPrice(price: Int): Int {
return policies.fold(price) { total, policy ->
total - policy.getDiscountPrice(price)
}
}
}
위 클래스는 프로퍼티로 정책들을 리스트 형태로 가지고 있다. 그 정책들은 각각 조조, 야간, 무비데이 할인 정책이다.
그리고 오버라이드한 getDiscountPrice 함수에서 fold를 사용함으로써 초기값 price에서 각 정책에 맞는 할인금액을 반환받아 빼주는 방식으로 할인을 모두 적용한 금액을 반환받을 수 있도록 했다.