내부 반복과 지연 연산

·2021년 12월 16일
1
post-thumbnail

외부 반복자가 눈에띄는 명령형 스타일과 다르게 함수현 프로그래밍은 내부 반복자를 사용한다. 내부 반복자는 반복을 단독으로 실행한다. 개발자가 반복에 집중하는 것이 아니라 콜렉션이나 범위에 있는 각 요소에 집중하게 해준다.

📌 외부 반복자 vs 내부 반복자


코틀린의 외부반복자는 Java에 외부 반복자와 비교해봤을 때 이미 발전되어있다. 그럼에도 불구하고 내부 반복자는 훨씬 관습적인 코드를 가진다. 내부 반복자는 편리하고 코틀린의 외부 반복자와 비교해보면 훨씬 표현력이 강하다.

외부 반복자

val numbers = listOf(10, 12, 15, 17, 18, 19)

//외부 반복자
for (i in numbers) {
    if (i % 2 == 0) {
        print("$i, ")
    }
}

내부 반복자

numbers.filter { e -> e % 2 == 0 }
.forEach { e -> print("$e, ") }

코틀린 스탠다드 라이브러리는 콜렉션에 몇 가지 확장 함수를 추가했다. filter()와 forEach() 는 둘 다 고차함수이고, 람다를 전달 받는다.

filter()에 전달된 람다는 람다에 전달된 파라미터가 짝수일 경우 true를 반환하고, 반대의 경우 false를 반환한다.

위의 코드에서 filter() 함수는 numbers 콜렉션에서 짝수인 값의 리스트를 리턴한다. forEach()는 전달된 람다에 주어진 값을 프린트한다.

for 반복문이 장황하지는 않지만 내부 반복자를 사용하는것이 더 간결하다. 두 스타일의 차이점은 수행되는 작업의 복잡성이 증가할수록 크게 나타난다.

💡 짝수를 프린터하는게 아니고 짝수를 2배 만들어 다른 콜렉션에 추가한다고 생각해보자.

외부 반복자

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

뮤터블 리스트를 정의하는 것부터 좋은 코드같이 보이진 않는다.

내부 반복자

val doubledEven = numbers.filter { e -> e % 2 == 0 }
    .map { e -> e * 2 }

forEach() 함수를 사용하는 대신 map()함수를 사용했다. map() 함수는 주어진 콜렉션의 각 요소에 람다를 적용한 세 콜렉션을 만들어준다. 내부 반복자는 함수형 파이프라인에서 나온다. 이 함수형 파이프라인의 결과는 주어진 콜렉션에서 짝수만 두 배로 만든 읽기전용 리스트이다.

코틀린의 외부 반복자도 좋다. 하지만 내부 반복자가 더 뛰어나다.

📌 내부 반복자


외부 반복자는 보통 for을 사용한다. 하지만 내부 반복은 filter(), map(), flatMap(), reduce() 등 많은 도구를 포함하고 있다. 함수형 프로그래밍에서 여러 종류의 작업을 하려면 여러 종류의 올바른 도구를 합쳐서 사용한다.

filter, map, reduce

filter(), map(), reduce()는 함수형 프로그래밍의 삼총사이다.

✔ filter() : 주어진 콜렉션에서 큭정 값을 골라내고 다른 것들은 버린다.
✔ map() : 함수는 콜렉션의 값을 주어진 함수나 람다를 이용해서 변화시킨다.
✔ reduce() : 함수는 요소들을 누적해 연산을 수행한다.

이 세 함수는 보두 주어진 콜렉션을 변경하지 않고 연산을 수행한다. 세 함수는 모두 복사된 값을 리턴한다.

filter, map, reduce

data class Person(val firstName: String, val age: Int)

val people = listOf(
    Person("Olivia", 12),
    Person("Amelia", 51),
    Person("Hazel", 23),
    Person("Lily", 25),
    Person("Harry", 12),
    Person("Liam", 70),
    Person("Elliot", 10),

    )

val result = people.filter { person -> person.age > 20 }
    .map { person -> person.firstName }
    .map { name -> name.uppercase() }
    .reduce { names, name -> "$names, $name" }

println(result) //AMELIA, HAZEL, LILY, LIAM

filter() 는 나이가 20살 이상인 Person만 추출했다. 그리고 추출된 리스트를 map()에 전달되었다. map()은 20살 이상인 Person 리스트를 이름 리스트로 변경했다. 두 번째 map()은 전달받은 이름 리스트를 대문자로 변경했다. 마지막으로 reduce() 함수를 이용해서 대문자 이름들을 하나의 문자열 콤마로 구분해서 넣는다.

코틀린은 sum, max,joinToString 같은 여러 연산에 특화된 reduce 연산을 제공한다. reduce() 를 아래처럼 변경하면 코드가 더 간결해진다.

joinToString

val result2 = people.filter { person -> person.age > 20 }
    .map { person -> person.firstName }
    .map { name -> name.uppercase() }
    .joinToString(", ")

나이를 더한 결과를 원한다면 map()과 resuce()를 사용해서 코딩할 수 있다.


val totalAge = people.map { person -> person.age }
    .reduce { total, age -> total + age }

여기서도 reduce() 대신에 sum()을 이용할 수 있다.

sum


val totalAge2= = people.map { person -> person.age }
    .sum()

특화된 reduce 연산인 sum() 처럼 코틀린은 주어진 콜렉션의 맨 처음 요소를 리턴하는 first() 메소드도 가지고 있다.

first

val nameOfFirstAdult = people.filter { person -> person.age > 17 }
    .map { person -> person.firstName }
    .first()

filter() 함수가 콜렉션에서 17살보다 나이가 많은 사람의 콜렉션을 리턴해줬다. 그리고 map() 함수는 이름을 리턴한다. 마지막으로 first() 함수가 성인의 이름의 리스트의 첫 요소를 리턴한다. last()를 사용하면 마지막 요소를 리턴할 수 있다.

플랫화와 플랫맵

List<List<Person>> 같은 네스티드 리스트가 있다고 가정해보자. 최상위 리스트는 '가족'을 가지고 있고, 가족은 Person의 서브리스트로 되어있다. 이 리스트를 플랫리스트로 만들려면 어떻게 해야될까?

코틀린은 이런 동작을 위해서 flatten() 함수를 가지고 있다.
flatten() 함수에 Iterable<Iterable<T>>를 전달하면 모든 중첩된 반복 가능 객체가 탑레벨로 함쳐진 Iterable<T>를 리턴한다. 그래서 계층구조를 단일화 할 수 있다.

flatten

val families = listOf(
    listOf(Person("Hazel", 40), Person("Lily", 40)),
    listOf(Person("Harry", 18), Person("Liam", 19))
)

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

families변수는 Person 객체들의 네스티드 리스트이다. families의 size의 속성을 보면 외부리스트가 2개의 내부리스트를 가지고 있다는 것을 알려준다. flatten() 함수를 호출하면 네스티드 리스트의 4개의 요소가 탑레벨에 있는 새로운 리스트를 리턴한다.

map

val nameAndReversed = people.map { person -> person.firstName }
    .map { it.lowercase() }
    .map { name -> listOf(name, name.reversed()) }

println(nameAndReversed)

People 콜렉션에서 map() 함수를 이용해서 이름의 리스트를 가져올 수 있다. 그리고 다시 한번 map() 함수를 적용해서 이름을 소문자로 바꾼다. 마지막으로 map()을 사용해서 이름을 거꾸로 만들었다.

💻 출력

[[olivia, aivilo], [amelia, ailema], [hazel, lezah], [lily, ylil], [harry, yrrah], [liam, mail], [elliot, toille]]

nameAndReversed의 타입은 List<List<String>> 이다. List<String> 를 원했지만 다른 결과가 나왔다.

flatten() 을 적용해보자

flatten

val nameAndReversed2 = people.map { person -> person.firstName }
  .map { it.lowercase() }
  .map { name -> listOf(name, name.reversed()) }
  .flatten()

println(nameAndReversed2)

💻 출력

[olivia, aivilo, amelia, ailema, hazel, lezah, lily, ylil, harry, yrrah, liam, mail, elliot, toille]

nameAndReversed2의 타입은 List<String> 이다. 원하는 결과대로 잘 동작했다. map과 flatten을 이용해서 플랫 리스트를 만들었다. 코틀린은 map을 이용해 플랫리스트를 더 쉽게 만들수 있는 flatmap()을 제공한다.

flatmap

val nameAndReversed3 = people.map { person -> person.firstName }
  .map { it.lowercase() }
  .flatMap { name -> listOf(name, name.reversed()) }



🔎 map() vs flatMap()

  • 람다가 one-to-one(객체나 값을 하나만 받고, 리턴도 객체나 값 하나만 하는 경우) 함수라면 콜렉션 변경을 위해서 map()을 사용해라
  • 람다가 one-to-many 함수라면(객체나 값 하나만 받고 콜렉션을 리턴하는 경우) 기존 콜렉션을 변경하여 콜렉션의 콜렉션으로 넣기 위해어 map()을 사용해라
  • 람다가 one-to-many 함수지만 기존 콜렉션을 변경해서 객체나 값의 변경된 콜렉션으로 넣고 싶다면 flatMap() 사용해라

정렬

콜렉션을 반복하는 것뿐만 아니라 반복 중간에 언제든지 정렬을 할 수 있다.

sortedBy

val namesSortedByAge = people.filter { person -> person.age > 17 }
    .sortedBy { person -> person.age }
    .map { person -> person.firstName }
println(namesSortedByAge) //[Hazel, Lily, Amelia, Liam]

17보다 나이가 많은 Person을 필터링하고 그 후 sortedBy() 함수로 Person 객체의 age 속성을 기준이로 정렬을 했다. 내림차순을 원한다면 sortedByDescending()을 이용하면 된다.

객체 그룹화

함수형 파이프라인을 통해서 데이터를 변형시키는 아이디어는 filter, map, reduce 같은 기본적인 형태를 뛰어 넘었다.

예를 들어서, groupBy() 함수를 사용하면 people 콜렉션의 Person을 firstName의 첫번째 글자를 기준으로 그룹화 할 수 있다.

groupBy

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

💻 출력

{O=[Person(firstName=Olivia, age=12)], A=[Person(firstName=Amelia, age=51)], H=[Person(firstName=Hazel, age=23), Person(firstName=Harry, age=12)], L=[Person(firstName=Lily, age=25), Person(firstName=Liam, age=70)], E=[Person(firstName=Elliot, age=10)]}

groupBy 함수는 콜렉션으 각각의 요소에 대해서 주어진 람다를 실행한다. 람다가 리턴한 것에 기반해서 요소들을 적절한 버켓에 담는다. 연산의 결과는 Map<K,List<T>> 이다. 위 예제에서 결과의 타입은 Map<String,List<Person>> 이다.

Person을 그룹핑하는 대신에 이름만 그룹핑할 수도 있다. 이름만 그룹핑 하기 위해선 오버로드된 버전의 groupBy()를 사용하면 된다.
첫번째 파라미터는 기존 콜렉션 요소에서 키를 만드는데 사용된다. 두 번째 파라미터는 밸류에 들어갈 리스트를 만드는데 사용된다.

groupBy


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

💻 출력

{O=[Olivia], A=[Amelia], H=[Hazel, Harry], L=[Lily, Liam], E=[Elliot]}

📌 지연 연산을 위한 시퀀스


코틀린에서 내부 반복자는 콜렉션 사이즈가 작을 때 사용해야 한다. 사이즈가 큰 콜렉션에서는 시퀀스를 이용해서 내부 반복자를 사용해야 한다. 콜렉션은 연산이 계속 실행되는 것과는 다르게 시퀀스에서 호출되는 함수는 지연되어 실행된다. 지연연산은 코드의 실행이 불필요한 경우 실행을 연기한다는 것이다.

시퀀스는 콜렉션의 성능 향상을 위한 최적화된 랩퍼다. 최적화를 하지 않았을 때는 연산 결과가 필요하지 않을 때도 연산을 하기 때문에 시간과 자원을 낭비하게 된다. 하지만 최적화를 하게되면 연산 결과가 필요하지 않을 때는 연산을 하지 않기 때문에 시간과 자원을 절약하게 된다.



시퀀스로 성능 향상하기

첫 번째 성인을 가져오는 예제를 이용해서 시간과 자원이 얼마나 낭비되는지 확인해보자


fun isAdult(person: Person): Boolean {
    println("isAdult called for ${person.firstName}")
    return person.age > 17
}

fun fetchFirstName(person: Person): String {
    println("fetchFirstName called for ${person.firstName}")
    return person.firstName
}

val nameOfFirstAdult = people.filter(::isAdult)
    .map(this::fetchFirstName)
    .first()

println(nameOfFirstAdult)

💻 출력

isAdult called for Olivia
isAdult called for Amelia
isAdult called for Hazel
isAdult called for Lily
isAdult called for Harry
isAdult called for Liam
isAdult called for Elliot
fetchFirstName called for Amelia
fetchFirstName called for Hazel
fetchFirstName called for Lily
fetchFirstName called for Liam
Amelia

filter() 메소드는 isAdult()를 열심히 실행했고, adults 리스트를 만들었다. 그리고 map() 메소드 역시 fetchFirstName() 함수를 열심히 호출했고, adults 리스트를 만들었다. 비록 하나의 값만 최종 결과로 예상됐지만 필요하지 않은 많은 작업이 수행됐다.

이 예제는 작은 콜렉션을 사용했지만 만약 방대한 데이터 요소를 가진 콜렉션을 사용한다면 필요없는 수많은 연산이 이루어질 것이다.

asSequence

val nameOfFirstAdultSequence = people.asSequence()
    .filter(::isAdult)
    .map(this::fetchFirstName)
    .first()

println(nameOfFirstAdultSequence)

💻 출력

isAdult called for Olivia
isAdult called for Amelia
fetchFirstName called for Amelia
Amelia

이전 코드와의 유일한 차이점은 filter()를 호출하기 전에 asSequence() 메소드를 사용하는 것이다. 작은 차이지만 성능상에 큰 이득이 있다.

시퀀스는 마지막 메소드가 호출될 때까지 실행을 연기하고 원하는 결과를 위한 최소한의 연산만 수행한다.

무한 시퀀스

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

generateSequence()를 통해서 소수의 시퀀스를 만들어보자.

isPrime

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

숫자 n이 주어쟜을 때, n이 소수라면 isPrime() 함수는 true를 리턴하고, 소수가 아니라면 false를 리턴한다.

nextPrime

tailrec fun nextPrime(n: Long): Long {
    return if (isPrime(n + 1)) n + 1 else nextPrime(n + 1)
}

숫자 n을 받고 n다음의 소수를 리턴한다. 예를 들어서 5를 받으면 다음 소수인 7을 리턴한다. 메소드는 tailrec로 마크 되어있다. tailrec은 StackOverflowError을 방지해준다.

generateSequence

val primes = generateSequence(5, this::nextPrime)

오버로드된 버전의 generateSequence()는 첫 번째 파라미터로 시작하는 값을 받고, 함수를 두 번째 파라미터로 받는다. primes는 5로 시작하는 무한한 소수의 시퀀스를 가지게 된다.
generateSequence()는 지연 연산을 하고, 값을 요청할 때까지 nextPrime()함수가 실행되지 않는다.

take() 메소드를 이용해서 우리가 보거나 사용하려는 무한 시퀀스 영역을 봉 수 있다. take() 메소드를 사용해서 소수의 무한 시퀀스인 primes의 6개의 값을 알아보자.

take

println(primes.take(6).toList())

💻 출력

[5, 7, 11, 13, 17, 19]

take()가 주어진 숫자만큼의 값만을 제공한다. 무한 시퀀스를 만드는 기능으로 우리는 지연 연산과 관련된 문제를 모델링해서 범위를 미리 알 수 없는 시퀀스를 만들 수 있다.

재귀함수 nextPime()과 generateSequence()를 사용하는 대신 sequence() 함수를 사용할 수도 있다.

sequence()

val primes2 = sequence {
    var i: Long = 0
    while (true) {
        i++
        if (isPrime(i)) {
            yield(i)
        }
    }
}

sequence()함수는 코틀린의 진보되고, 비교적 새로운 주제인 '컨티뉴에이션(연속적으로 동작하는) 람다'를 받는다. 컨티뉴에이션은 함수가 여러 개의 리턴포인트를 가지게 하는 것처럼 보이게 해준다.

💻 출력

[5, 7, 11, 13, 17, 19]

sequence() 함수에 제공된 람다에서 우리는 소수를 발생시키는 무한 루프를 가지게 된다. 람다에 포함된 코드는 시퀀스에서 값의 요청이나 소비가 있을 때만 실행된다. 반복은 i가 0일때 시작되고, 코드가 발생시키는 첫 번째 소수는 2이다.

시퀀스가 2로 시작되깐 drop()을 이용해서 처음 2개의 값을 버리고 그 이후의 6개의 값을 가지고 오자.

println(primes2.drop(2).take(6).toList())

💻 출력

[5, 7, 11, 13, 17, 19]




🔑 정리


내부 반복자는 외부 반복자와 비교해 봤을때, 내부 반복자는 덜 복잡하고, 더 유연하고 표현력이 있고 편리하다.

콜렉션에서 직접 실행되는 내부 반복자는 모든 요소에 대해서 무조건 실행된다. 이런 방식을 크기가 큰 콜렉션에서 성능이 낮아지는 문제가 발생할 수 있다.

시퀀스는 지연 연산 실행으로 필요하지 않은 연산을 제거해 좋은 성능을 내면서 내부 반복자의 장점을 취할 수 있다.



출처 : 다재다능 코틀린 프로그래밍

profile
개발하고싶은사람

0개의 댓글