Study/kotlin

[코틀린 프로그래밍] Chapter.11 내부 반복과 지연 연산

에디개발자 2021. 5. 25. 07:00
반응형

\나를 닮았다고 한다...

외부 반복자가 눈에 띄는 명령형 스타일과는 다르게 함수형 프로그래밍은 내부 반복자를 사용한다. 개발자는 반복에 집중하는 것이 아니라 콜렉션이나 범위에 있는 각 요소에 집중하게 한다. 또한 내부 반복자는 명시적 뮤터빌리티을 피하게 해주기 때문에 경쟁조건의 위험없이 반복을 쉽게 병렬화할 수 있다.

코틀린의 내부 반복자는 편리하고 표현력이 강하고 외부 반복자와 비교했을 때 복잡성을 낮춰준다. 하지만 퍼포먼스가 안 좋을 수 있다.

내부 반복자는 외부 반복자와 비교해 봤을 때 연산을 약간 더 많이 한다. 콜렉션의 요소의 크기가 수백개 정도로 비교적 작은 경우엔 영향이 없지만 수천 개를 다루는 아주 큰 데이터의 콜렉션을 다루는 경우에는 오버헤드가 이슈가 될 수 있습니다. 이럴 때 바로 코틀린의 시퀀스가 필요합니다. 시퀀스는 내부 반복자입니다. 시퀀스는 내부적으로 다르게 구현되어있습니다. 시퀀스는 실행을 지연시키고 반복할 부분이 꼭 필요할 때만 반복을 진행합니다.

 

외부 반복자 vs 내부 반복자

외부 반복자 내부 반복자
Java나 C언어 개발자들에게 친숙한 방법, 흔하지만 복잡 친숙하지 않지만 간단하고 편리하다

코틀린의 외부 반복자와 내부 반복자를 비교해보도록 하겠습니다. 

외부 반복자

val numbers = listOf(10, 12, 15, 17, 18, 19)
for (number in numbers) {
    if (number % 2 == 0) {
        print("$number, ")
    }
}

반복 진행에 따라서 변수 number는 콜렉션의 다른값을 가지게 됩니다. 그리고 반복 흐름에 break나 continue를 추가할 수 있습니다. 다음으로는 프린트하는게 아니고 짝수를 2배로 만들어서 다른 콜렉션에 추가하는 코드를 작성해보겠습니다.

val doubled = mutableListOf<Int>()
for (number in numbers) {
    if (number % 2 == 0) {
        doubled.add(number)
    }
}

 

내부 반복자

numbers.filter { e -> e % 2 == 0 }
    .forEach { e -> print("$e, ") }
// 내부 반복자는 함수형 파이프라인이다.
numbers.filter { e -> e % 2 == 0 }
    .map { e -> e * 2 }
println(doubled)

이 코드에서는 filter()와 forEach(), map() 함수를 사용하였습니다. 둘 다 고차함수이고 람다를 전달 받습니다. 외부 반복자와 코드를 비교하였을 때 훨씬 가독성이 좋고 간결합니다. 

 

내부 반복자

외부 반복자는 보통 for를 사용하지만 내부 반복자는 filter(), map(), flatMap(), reduce()등 특별한 도구를 사용할 수 있습니다. 코틀린 스탠다드 라이브러리는 내부 반복을 위한 충분한 고차함수를 제공합니다. 

 

filter, map, reduce

filter()

  • 주어진 콜렉션에서 특정 값을 골라내고 다른 것들을 버립니다.
  • 리턴한 콜렉션의 사이즈는 0부터 N까지입니다. 여기서 N은 주어진 콜렉션의 사이즈입니다. 람다가 요소에서 연산을 수행했을 때 true을 반환하는 경우 기존 콜렉션의 요소가 리턴되는 콜렉션에 포함됩니다.

map()

  • 콜렉션의 값을 주어진 함수나 람다를 이용해서 변화시킵니다.
  • 콜렉션의 사이즈는 기존 콜렉션과 일치합니다. 

reduce()

  • 요소들을 누적해 연산을 수행합니다. reduce()는 종종 하나의 값으로 귀결되기도 합니다.
  • filter(), map()에 전달되는 파라미터는 하나지만 reduce()에 전달되는 람다는 2개의 파라미터를 가집니다. (1)는 누적 값이고, (2)는 원래 콜렉션의 요소입니다. 람다의 결과는 새로운 누적값이 됩니다.

이 세 함수는 모두 주어진 콜렉션을 변경하지 않고 연산을 수행합니다.  세 함수는 모두 복사된 값을 리턴합니다. 위 3개의 함수를 간단한 예시를 통해서 이해해봅니다.

 

// set test data 
val people = listOf(
    Person("AS", 10),
    Person("BW", 11),
    Person("CE", 12),
    Person("DH", 13),
    Person("EM", 14),
    Person("FH", 15),
    Person("GD", 16),
    Person("HZ", 17),
    Person("IP", 18),
)

// 이름 나열
val result = people.filter { person -> person.age > 15 }
    .map { person -> person.firstName }
    .map { name -> name.toLowerCase() }
//    .joinToString(", ")   // 아래의 코드와 동일한 동작
    .reduce { names, name -> "$names, $name" }
// reduce에 전달된 람다는 줄을 뛰어넘어가면서 연산을 진행

println(result)  // gd, hz, ip

먼저 테스트할 데이터를 리스트에 담습니다. 담은 리스트를 filter 함수를 이용하여 age 필드의 값이 15 이상인 리스트만 리턴합니다. 그리고 map 함수를 통해 객체의 firstName 필드만 가져오고 소문자로 변경합니다. 마지막으로 reduce 함수를 이용해 String 객체에 담습니다.

위에서 사용한 함수말고도 유용하게 사용할 수 있는 다양한 함수들도 제공합니다.

  • joinToString(", ") : 리스트를 작성한 파라미터로 Seperator하여 String 객체로 리턴
  • sum(): 리스트의 값을 모두 더한다.
  • firt(): 리스트의 첫 번째 값을 가져온다.
  • last(): 리스트의 마지막 값을 가져온다.

 

플랫화와 플랫맵

리스트에 리스트가 있는 객체를 플랫리스트로 만들려고 할 때 사용됩니다. 하나의 예시를 들어보겠습니다. 

List<List<Person>> 네스티드 리스트가 있습니다. 최상위 리스트는 가족을 가지고 가족은 Person 리스트를 가지고 있습니다. 이 때 flatten() 함수를 사용하여 계층구조를 단일화할 수 있습니다.

val families = listOf(
    listOf(Person("AE", 30), Person("BH", 40)),
    listOf(Person("CS", 50), Person("DZ", 20))
)

println(families.size) // 2
println(families.flatten().size) // 4

 

falmilies 변수의 사이즈는 2고, families.flatten의 사이즈는 4인 것을 확인할 수 있습니다. 이전에 사용했던 people을 이용하여 예제를 살펴보겠습니다. people 콜렉션을 다시 확인하고 각각의 사람들의 이름을 소문자로 변환한 후 거꾸로 뒤집어보겠습니다. 

val namesAndReversed = people.map { person -> person.firstName }
    .map(String::toLowerCase)
    .map { name -> listOf(name, name.reversed()) }
    
    
println(namesAndReversed) // [[as, sa], [bw, wb], [ce, ec], [dh, hd], [em, me], [fh, hf], [gd, dg], [hz, zh], [ip, pi]]
println(namesAndReversed.size) // 9

 

위 예제에서 flatten() 함수를 이용해보겠습니다. 

val namesAndReversed2 = people.map { person -> person.firstName }
    .map(String::toLowerCase)
    .map { name -> listOf(name, name.reversed()) }
    .flatten()
    
println(namesAndReversed2.size)  // [as, sa, bw, wb, ce, ec, dh, hd, em, me, fh, hf, gd, dg, hz, zh, ip, pi]    
println(namesAndReversed2.size)  // 18

 

위 코드를 좀 더 간결하게 수정해보겠습니다. map과 flatten 함수 2가지 기능을 처리하는 flatmap 함수를 사용해보겠습니다. 

val namesAndReversed3 = people.map { person -> person.firstName }
    .map(String::toLowerCase)
    .flatMap { name -> listOf(name, name.reversed()) }
    
println(namesAndReversed3)  // [as, sa, bw, wb, ce, ec, dh, hd, em, me, fh, hf, gd, dg, hz, zh, ip, pi]
println(namesAndReversed3.size)  // 18

 

map()과 flatMap() 중 어떤 것을 사용해야 할지 고민이라면 아래의 팁을 참고하자!

  - 람다가 one-to-one 함수라면 ( 1param, 1return) 콜렉션 변경을 위해서 map() 사용

val personNames = people.map { person -> person.firstName }
println(personNames)  // [AS, BW, CE, DH, EM, FH, GD, HZ, IP]


  - 람다가 one-to-many 함수라면 기존 콜렉션을 변경하여 콜렉션의 콜렉션으로 넣기 위해서 map()사용

val personNames2 = people.map { person -> listOf(person.firstName, "temp${person.firstName}") }
println(personNames2)  // [[AS, tempAS], [BW, tempBW], [CE, tempCE], [DH, tempDH], [EM, tempEM], [FH, tempFH], [GD, tempGD], [HZ, tempHZ], [IP, tempIP]]


  - 람다가 one-to-many 함수지만 기존 콜렉션을 변경해서 객체나 값의 변경된 콜렉션으로 넣고 싶다면 flatMap 사용

val personNames3 = people.flatMap { person -> listOf(person.firstName, "temp${person.firstName}") }
println(personNames3)  // [AS, tempAS, BW, tempBW, CE, tempCE, DH, tempDH, EM, tempEM, FH, tempFH, GD, tempGD, HZ, tempHZ, IP, tempIP]

 

정렬

콜렉션을 반복 중간에 정렬을 할 수 있습니다. 함수형 파이프라인 안에서 정렬을 위한 기준을 잡을 수 있습니다. 예제로 살펴보겠습니다.

people 콜렉션에서 성인의 이름을 가져온 후 나이에 따라서 정렬해보겠습니다.

// set data
val people = listOf(
    Person("A", 10),
    Person("B", 11),
    Person("A", 12),
    Person("D", 13),
    Person("E", 14),
    Person("F", 15),
    Person("G", 16),
    Person("A", 17),
    Person("I", 18),
)

val namesSortedByAge = people.filter { person -> person.age > 15 }
    .sortedBy { person -> person.age }  // 작은순
//    .sortedByDescending { person -> person.age }  // 높은순
    .map { person -> person.firstName }
    
println(namesSortedByAge)  // [G, A, I]

 

객체 그룹화

함수형 파이프라인을 통해서 데이터를 변형시키는 아이디어는 filter, map, reduce 같은 기본적인 형태를 뛰어 넘었습니다. 각기 다른 기준이나 속성을 기반으로 객체를 그룹화 하거나 버켓에 넣을 수 있습니다. 

val groupBy1stLetter = people.groupBy { person -> person.firstName.first() }

println(groupBy1stLetter)  // {A=[Person(firstName=A, age=10), Person(firstName=A, age=12), Person(firstName=A, age=17)], B=[Person(firstName=B, age=11)], D=[Person(firstName=D, age=13)], E=[Person(firstName=E, age=14)], F=[Person(firstName=F, age=15)], G=[Person(firstName=G, age=16)], I=[Person(firstName=I, age=18)]}

 

val people2 = listOf(
    Person("AS", 10),
    Person("BW", 11),
    Person("AE", 12),
    Person("DH", 13),
    Person("AM", 14),
    Person("BH", 15),
    Person("AD", 16),
    Person("BZ", 17),
    Person("DP", 18),
)

val namesBy1stLetter = people2.groupBy({ person -> person.firstName.first() }) {
    person -> person.firstName
}

println(namesBy1stLetter)  // {A=[AS, AE, AM, AD], B=[BW, BH, BZ], D=[DH, DP]}

 

지연 연산을 위한 시퀀스

시퀀스는 콜렉션의 성능 향상을 위한 최적화된 랩퍼입니다. 코틀린은 Java와 다르게 성능적인 이슈보단 편의성에 중점을 두어 filter(), map()과 같은 함수를 사용하도록 결정했습니다. 코틀린에서 내부 반복자는 콜렉션 사이즈가 작을 때 사용해야합니다. 사이즈가 큰 콜렉션에서는 시퀀스를 이용해서 내부 반복자를 사용해야합니다. 이유는 연산 결과가 필요하지 않을 경우 연산을 하지 않도록 하여 시간과 자원을 절약하게 도와줍니다. 

 

시퀀스로 성능 향상하기

예제를 통해서 살펴보겠습니다. 

// data 클래스 생성
data class Person(val firstName: String, val age: Int)

// set test data
val people = listOf(
    Person("A", 10),
    Person("B", 11),
    Person("A", 12),
    Person("D", 13),
    Person("E", 14),
    Person("F", 21),
    Person("G", 26),
    Person("A", 27),
    Person("I", 28)
)

// 17세 이상 여부 판단
fun isAdult(person: Person): Boolean {
    println("isAdult called for ${person.firstName}")
    return person.age > 17
}

// 객체의 firstName을 가져온다.
fun fetchFirstName(person: Person): String {
    println("fetchFirstName called for ${person.firstName}")
    return person.firstName
}

// 시퀀스 연산자가 아닌경우 정말 많은 일을 한다.
val nameOfFirstAdult = people
    .filter(::isAdult)
    .map(::fetchFirstName)
    .first()

println(nameOfFirstAdult)

 

위 코드를 간단하게 정리하자면 테스트 데이터에 조건에 해당하는 값의 firstName을 리스트로 만들고 첫번째 데이터를 가져오는 것 입니다. 결과는 아래와 같습니다. 

isAdult called for A
isAdult called for B
isAdult called for A
isAdult called for D
isAdult called for E
isAdult called for F
isAdult called for G
isAdult called for A
isAdult called for I
fetchFirstName called for F
fetchFirstName called for G
fetchFirstName called for A
fetchFirstName called for I
F

결과에서 알 수 있는 것은 우리가 필요한 결과는 첫 번째 데이터 하나인데 데이터 리스트를 전부 루프도는 것을 알 수 있습니다. 현재는 적은 양의 데이터 리스트였지만 수천, 수만개의 리스트가 같은 행위를 한다면 성능상에 이슈가 생길 것 입니다. 이때 시퀀스( asSequence() )를 사용합니다. 위 코드에 시퀀스를 적용해보겠습니다.

 

val nameOfFirstAdult1 = people.asSequence()  // 시퀀스 적용
    .filter(::isAdult)
    .map(::fetchFirstName)
    .first()

println(nameOfFirstAdult1)

 

결과는 아래와 같습니다. 조건에 부합하는 결과가 나오면 즉시 루프를 중지하고 결과를 뽑아내어 성능이 좋아질 것 입니다.

isAdult called for A
isAdult called for B
isAdult called for A
isAdult called for D
isAdult called for E
isAdult called for F
fetchFirstName called for F
F

그렇다면 모든 루프에 시퀀스를 달면 좋치 않을까?? 답은 아닙니다. 콜렉션이 작을 경우 퍼포먼스의 차이는 무시할 정도이고 콜렉션이 작을 경우에는 지연 연산을 사용하지 않은 것이 디버그에 유용하기 때문입니다. 

 

무한 시퀀스

성능은 지연 연산의 유일한 장점이 아닙니다. 지연 연산은 온디맨드 연산에 도움을 주고 있습니다. 그리고 온디맨드 연산은 요소의 무하 시퀀스를 만드는데 도움을 줍니다. 

코틀린은 무한 시퀀스를 만드는 방법으로 몇 가지 방법을 제공합니다. generateSequence() 함수가 그 중 하나이고 이 함수로 소수의 시퀀스를 만들어보겠습니다. 

 

fun isPrive(n: Long) = n > 1 && (2 until n).none { i -> n % i == 0L }

println(isPrive(2))    // true
println(isPrive(3))    // true
println(isPrive(4))    // false
println(isPrive(5))    // true

위 예제는 파라미터에 넘어온 값이 소수면 true, 아니면 false를 리턴하는 함수입니다.

 

다음 예제로 파라미터 n을 받으면 다음의 소수를 리턴하는 함수입니다.

tailrec fun nextPrime(n: Long): Long = if (isPrive(n + 1)) n + 1 else nextPrime(n + 1)

println(nextPrime(2))    // 3
println(nextPrime(3))    // 5
println(nextPrime(11))   // 13

 

두 예제 함수를 통해서 generateSeqeunce() 함수를 이용하여 아무 소수로나 시작하는 소수의 무한 시퀀스를 만들 수 있습니다. 

val primes = generateSequence(5, ::nextPrime)

// 아래의 루프를 실행하면 소수 시퀀스를 무한으로 생성합니다 
for (prime in primes) {
    println(prime)
}

println(primes.take(6).toList())  // [5, 7, 11, 13, 17, 19]

오버로드된 버전의 generateSequence()는 첫 번째 파라미터로 시작하는 값을 받고, 함수를 두 번째 파라미터로 받습니다. 람다는 값을 받고, 값을 리턴합니다. 이 예제에서는 숫자를 받은 다음에 다음 소수를 리턴하는 nextPrime()을 사용하였기에 primes는 5부터 시작하는 무한한 소수의 시퀀스를 가지게 되었습니다. 값이 몇 개든 상관없이 take() 메소드를 이용해서 값을 요청할 수 있습니다. 

 

재귀 함수 nextPrime()을 작성하고 generateSequence()를 사용하는 대신 sequence()를 사용할 수도 있습니다. sequence() 함수는 코틀린의 진보되고 비교적 새로운 주제인 컨티뉴에이션 람다를 받습니다. 위 예제를 수정해보겠습니다. 

val primes2 = sequence {
    var i: Long = 0
    while (true) {
        i++
        if (isPrive(i))
            yield(i)  // 값을 호출자에게 리턴하고 다음 라인의 코드를 계속 실행하는 것
    }
}

println(primes2.drop(2).take(6).toList())  // [5, 7, 11, 13, 17, 19]

결과값은 동일합니다. 시퀀스의 다음 값을 생산하는 nextPrime() 같은 분리된 함수가 있다면 generateSequence() 함수와 함께 사용해서 무한 시퀀스를 만드는 것이 좋고, 시퀀스의 다음 값을 생성하는 코드와 코드를 합쳐서 무한 시퀀스를 만들고 싶다면 sequence() 함수를 사용하도록 합니다.

 

정리

- 외부 반복자 = 명령형, 내부 반복자 = 함수형
- 복잡도 외부 > 내부
- 코틀린은 콜렉션에서 직접 사용 가능.
- 크기가 작은 컬렉션일 경우 사용!
    - 이유는 필요하지 않는 실행까지 함
    - 그래서 시퀀스를 랩퍼로 제공 -> 결과로 좋은 성능을 내면서 내부 반복자의 장점을 취득

반응형