Kotlin - 11. 내부 반복과 지연 연산

2021/11/10
language

함수형 프로그래밍에서는 반복을 외부에 직접적으로 표현하지 않습니다.
일단 명시적인 반복문의 경우 명시적인 반복을 위한 상태를 표시해줘야 하기 때문에 함수형 프로그래밍과 어울리지 않으며 또한 각 내부 상태를 고치기에 용이하기 때문에, 함수형 프로그래밍에서 지향하는 불변성과는 거리가 멀어집니다.
따라서 함수형 프로그래밍에서는 내부 반복자를 통해 반복문을 대체합니다. 내부 반복자는 내부에서 반복을 처리하며 외부에서는 단순히 해당 범위내의 상태만 변경하기 때문에 별도의 반복을 위한 상태를 관리할 필요가 없고 반복 처리의 범위가 현재 처리하는 상태에만 한정되어 불변성을 유지할 수 있는 장점이 있습니다.

기본적으로 filter, map 같은 함수들을 생각하면 좋은데, 이들은 컬렉션 객체들을 순회하면서 미리 정의된 람다에 따라 컬렉션을 처리하는 구조를 가집니다.
물론 앞서 살펴본대로 기본적으로 람다의 실행은 함수의 실행이기 때문에 이로인한 성능상의 영향이 있을 수 있지만 편리하고 표현력이 강하다는 장점이 있기 때문에 많은 곳에서 활용하게 됩니다.

외부 반복자 vs 내부 반복자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

val l = listOf(1,2,3,4,5)

// 외부 반복자를 통한 반복
for (i in l) {
if ((i % 2) == 0) {
println(i)
}
}

// 내부 반복자를 통한 반복
l
.filter { (it % 2) == 0 }
.forEach { println(it) }

위 코드에서 두 반복의 결과는 동일합니다. 일단 내부 반복자를 사용한 부분이 코드가 단순하여 읽기 좋습니다.
만약 이 반복에 조건이 추가 되고 그에 따라 처리해야 할 작업도 늘어난다면 작업에 대해 선언만 하면 되는 내부 반복자는 이후 코드의 유지보수 관점에서도 코드를 더 간결하게 관리할 수 있는 좋은 방법 중 하나입니다.

내부 반복자

위에서 본 것 처럼 외부 반복자는 for(혹은 while) 와 if의 조합으로 코드를 작성합니다.
내부 반복자의 경우 filter, map, flatMap, reduce 같은 함수들을 조합하여 코드를 작성합니다.

filter, map, reduce

내부 반복자에서 가장 많이 사용하게 될 함수들로 역할은 이름 그대로를 따릅니다.

filter는 조건에 맞춰 반복자를 통과하는 데이터들을 판별하여 사용여부를 결정하는 함수 입니다.
map은 mapping 이라는 이름처럼 기존 값을 다른 값으로 매핑하는 역할을 가집니다.
reduce는 단어의 뜻은 줄어든다는 뜻인데, 코드상에서는 반복하는 데이터의 수를 줄이는 함수입니다. 일반적으로 데이터들의 합이나 다른 컬렉션으로의 변형등을 할 때 사용합니다.
일반적으로 많이 사용하는 로직의 경우에는 개별적인 함수(sum, count, joinToString, toList, toSet, first, last 등)로도 정의되있습니다.

1
2
3
4
5
6
7
// 0 부터 10 범위내 짝수의 제곱의 합
val r = (0...10)
.filter { it % 2 == 0 }
.map { it * it }
.reduce { (acc, i) -> acc + i }

println(r) // 4 + 16 + 36 + 64 + 100 = 220

flatMap과 flatten

기본적으로 어떤 복잡한 반복을 평평하게 만들어주는 기능을 합니다. 가장 대표적인 예로 이중 컬렉션을 하나의 컬렉션으로 병합입니다.

1
2
3
4
5
6
7
8
9
val l = listOf(
listOf(1,2,3),
listOf(4,5,6),
listOf(7,8,9)
)

println(l.flatten()
.map { it * it }
.sum()) //285

하지만 단순히 그렇게만 보면 안됩니다. flatMap은 앞서 설명한 filter, map의 기반이 됩니다. 그게 가능한 이유는 flatmap 은 지금 반복의 흐름을 바꿀 수 있기 때문입니다.

1
2
3
4
5
6
7
fun <T> List<T>.myFilter(condition: (T) -> Boolean) = flatMap {
if (condition(it)) {
listOf(it)
} else {
listOf<T>()
}
}

위 예제처럼 flatMap을 이용해 조건에 충족하는 값만 사용하도록 반복의 흐름을 조정 할 수 있습니다. 이는 map 과는 다른 flatMap의 중요하고도 핵심적인 차이점이라 할 수 있습니다.
또한 이러한 역할이 비단 이런 내부 반복자 외에 많은 선언형 프로그래밍에서 사용되기 때문에, 이에 대해 자세히 이해하는것이 좋습니다.

정렬

반복 중 정렬기능도 제공합니다. sorted, sortedDescending, sortedBy, sortedByDescending
sortedBy 함수는 파라메터로 넘어가는 람다를 이용하여 데이터를 비교가능한 데이터로 만들어 비교 후 정렬을 하도록 도와줍니다.

1
2
3
val ages = listOf(14, 10, 22, 8)

ages.sorted().forEach(::println) // 8, 10, 14, 22

그룹화

특정 조건으로 분기하여 여러가지 그룹으로 데이터를 분류 할 수 있습니다. 분기 방식에 따라 결과값인 Map 타입의 제네릭 타입에 반영되는 형태입니다.

1
val oddOrEven: Map<Boolean, List<Int>> = (0...10).groupBy { it%2 == 1 }

groupBy 는 데이터를 통해 실행한 람다식의 결과 값이 같은 것들을 리스트 형태로 모아 Map 형태로 묶어주는 함수 입니다.
람다의 결과 값이 Boolean 이기 때문에, 결과 Map의 키가 Boolean 이 되고 원본데이터가 Int 형태이기 때문에 List로 묶어 List<Int> 타입이 값이 됩니다.
람다 하나를 더 추가하면 value 의 값도 조절 할 수 있습니다.

지연연산

콜렉션에서의 내부 연산은 생각보다 많은 오버헤드를 부릅니다. 컬렉션에서의 반복자 함수의 결과를 보면 모두 해당 컬렉션의 타입 그대로임을 알 수 있습니다.
이는 반복자 함수가 한번 실행 할 때마다 컬렉션을 생성한다는 의미입니다. 컬렉션 내부 데이터에 대해 모두 연산을 진행 한 후 이를 새로운 컬렉션에 담아 리턴하고 다음 연산을 진행하는 형태로 설명만 보더라도 상당히 비효율적임을 알 수 있습니다.
작은 크기의 컬렉션이면 괜찮겠지만 크기가 커지면 불필요한 연산도 많아지고 사용하는 메모리도 많아지는 여러모로 위험한 방식입니다.

이를 회피하기 위해선 Sequence를 사용합니다. Sequence 는 컬랙션과 마찬가지로 filter, map, reduce등의 내부 반복자 연산을 제공하지만 다른점은 단말 연산이 실행되어야 반복을 시작한다는 점입니다.

Sequence

1
2
3
4
5
val l = listOf(1,2,3,4)
val mappedL = l.map {it + 1}
println(mappedL.size) // 4
val filteredL = mappedL.filter {it%2 == 0}
println(filteredL.size) // 2

일반적인 컬렉션 상에서 내부 반복자를 통해 처리할 경우 각 함수 별로 새로운 리스트를 생성하기 때문에 위 데이터가 표시되는 것을 알 수 있습니다.

1
2
3
4
5
val s = listOf(1,2,3,4).asSequence()
val mappedS = s.map { it + 1 }
println(mappedS.size) // Unresolved reference : size
val filteredS = mappedS.filter {it%2 == 0}
println(filteredS.size) // Unresolved reference : size

Sequence는 사이즈를 측정 할 수 없습니다. 아직까지 선언만 되었을 뿐 데이터가 처리되지 않았기 때문에, 데이터가 몇 개인지 판별 할 수 없습니다.
또한 위 코드 까지는 단순히 작업을 선언만 했기 때문에, 람다식들은 연산조차 되지 않았습니다.

무한 Sequence

위에서 언급한대로 Sequence는 실제 연산이 되지 않기 때문에, 무한한 데이터를 가지는 (사실은 선언만되었지만) 시퀸스를 정의 할 수 있습니다.

1
2
3
4
val random = Random(System.currentTimeMillis())
generateSequence { random.nextInt() }
.filter { it > 0 }
.forEach(::println) // infinite loop, print only positive number

generateSequence는 Sequence를 생성하는 함수입니다. 위에서는 랜덤한 수를 계속 생성하는 시퀸스를 생성하는데에 사용했습니다.
그리고 forEach가 실행될 때 generateSequence의 람다가 실행되어 랜덤한 수를 가져오는 구조 입니다. 이런 forEach, toList 같은 단말 함수가 실행되면 그때서야 데이터의 반복이 시작되는 것을 알 수 있습니다.

그리고 원하는 만큼 데이터를 얻으면 종료 시킬 수 있습니다.

1
2
3
4
5
val random = Random(System.currentTimeMillis())
generateSequence { random.nextInt() }
.filter { it > 0 }
.take(10)
.forEach(::println) // 10 number of positive number

여기서는 take 를 이용하여 양수 10개까지만 가져오도록 수정했습니다.

다른 방식으로 sequence 함수를 사용하는 방법도 있습니다.

1
2
3
4
5
6
sequence<Int> { 
while(true) {
yield(random.nextInt())
}
}
.forEach { println(it) }

sequence 함수 내부에 반복문을 통해 데이터를 반복적으로 생성하고 yield 함수를 통해 데이터를 반복시키는 형태를 가집니다.


참고자료

다재다능 코틀린 프로그래밍