Today I Learned

내부 반복과 지연 연산 본문

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

내부 반복과 지연 연산

하이라이터 2023. 9. 22. 15:19
728x90

외부 반복자 vs 내부 반복자

val numbers = listOf(10, 12, 15, 17, 18, 19)
for (i in numbers) {
  if (i % 2 == 0) {
    print("$i, ") //10, 12, 18, 
  }
}
  • 전통적인 외부 반복자(for..in)에서 리스트의 짝수만 출력하려고하면 위와 같이 변수 i를 사용해서 확인한다.
numbers.filter { e -> e % 2 == 0 }
  .forEach { e -> print("$e, ") } //10, 12, 18,
  • 코틀린 스탠다드 라이브러리는 콜렉션에 몇 가지 확장 함수를 추가했다. 위 예제에서는 구 중 filter()와 forEach()를 사용한다.
  • 위와 같이 내부 반복자를 샅용하면 더 간결하고 읽기 쉽다.
  • 두 스타일의 차이점은 수행되는 작업의 복잡성이 증가할수록 크게 나타난다.
val doubled = mutableListOf<Int>()

for (i in numbers) {
  if (i % 2 == 0) {
    doubled.add(i * 2)
  }
}                    

println(doubled) //[20, 24, 36]
  • 외부 반복자를 사용하는 위 코드는 짝수 리스트를 만들기위해 빈 뮤터블 리스트를 만들어야 한다.
val doubledEven = numbers.filter { e -> e % 2 == 0 }
  .map { e -> e * 2 }
  
println(doubledEven) //[20, 24, 36]
  • 위 코드는 내부 반복자를 사용해서 결과에 대한 읽기전용 리스트를 만든다.

 

내부 반복자

filter, map, reduce

  • filter() 함수는 주어진 콜렉션에서 특정 값을 골라내고 다른 것들은 버린다.
  • map() 함수는 콜렉션의 값을 주어진 함수나 람다를 이용해서 변화시킨다,
  • reduce() 함수는 요소들을 누적해서 연산을 수행한다.
data class Person(val firstName: String, val age: Int)

val people = listOf(
  Person("Sara", 12),
  Person("Jill", 51),
  Person("Paula", 23),
  Person("Paul", 25),
  Person("Mani", 12),
  Person("Jack", 70),
  Person("Sue", 10))
  • 위 콜렉션에서 내부 반복자를 사용해서 20살 이상인 사람의 이름을 대문자로 바꾸고, 콤마로 구분해보자.
val result = people.filter { person -> person.age > 20 }
  .map { person -> person.firstName }
  .map { name -> name.toUpperCase() }
  .reduce { names, name -> "$names, $name" }
  • filter() 함수로 나이가 20살 이상인 Person만 추출했고, map() 함수로 Person 리스트를 firstName 리스트로 변경했다.
  • 그리고 두번째 map()은 전달받은 이름 리스트를 대문자로 변경했다.
  • 마지막으로 reduce() 함수를 이용해서 대문자 이름들을 하나의 문자열로 콤바로 구분해서 넣는다.
  • reduce()는 콜렉션 값들에 대한 일반적인 누적 연산을 가능하게 해준다. 하지만 sum이나 join같은 특화된 함수도 사용가능하다.

 

첫번째와 마지막 가져오기

  • first(), last() 함수를 이용해서 콜렉션의 첫번째 또는 마지막 요소를 추출할 수 있다.
val nameOfFirstAdult = people.filter { person -> person.age > 17 }
  .map { person -> person.firstName }
  .first()

 

플랫화와 플랫맵

  • List<List<Person>>과 같은 Iterable<Iterable<T>> 형태의 구조에 flatten() 함수를 사용하면 계층 구조가 단일화된 Iterable<T>로 리턴한다.
val families = listOf(
   listOf(Person("Jack", 40), Person("Jill", 40)),
   listOf(Person("Eve", 18), Person("Adam", 18)))
   
println(families.size) //2
println(families.flatten().size) //4
  • 위와 같이 의도적으로 리스트 내부에 네스티드 리스트를 만들지 않더라도, map() 연산의 결과로 네스팅이 만들어지기도 한다.
val namesAndReversed = people.map { person -> person.firstName }
  .map(String::toLowerCase)
  .map { name -> listOf(name, name.reversed())}

println(namesAndReversed.size) //7
  • 마지막 단계에서 전달받은 리스트로 인해 List<List<String>> 타입의 결과가 만들어지게 되었다.
  • 원하는 결과가 List<String> 형태라면 flatten() 함수를 이용하면 된다.
println(namesAndReversed2.flatten().size) //14
  • flatMap() 함수를 사용하면 map 연산과 flatten 연산을 하나로 합칠 수 있다.
val namesAndReversed3 = people.map { person -> person.firstName }
  .map(String::toLowerCase)  
  .flatMap { name -> listOf(name, name.reversed())}

println(namesAndReversed3.size) //14
  • map()과 flatMap() 중 어느 것을 사용할지에 대한 팁
    • 람다가 one-to-one 함수라면(객체나 값을 하나만 받고, 리턴도 객체나 값 하나만 하는 경우) 콜렉션 변경을 위해서 map()을 사용
    • 람다가 one-to-many 함수라면(객체나 값 하나만 받고 콜렉션을 리턴하는 경우) 기존 콜렉션을 변경하여 콜렉션의 콜렉션으로 넣기 위해서 map()을 사용
    • 람다가 one-to-many 함수지만 기존 콜렉션을 변경해서 객체나 값의 변경된 콜렉션으로 넣고 싶다면 flatMap()을 사용

 

정렬

  • 콜렉션을 반복하는 중간에 언제든 정렬할 수 있으며, 함수형 파이프라인 안에서 정렬을 위한 기준을 세울 수 있다.
val namesSortedByAge = people.filter { person -> person.age > 17 }
  .sortedBy { person -> person.age }
  .map { person -> person.firstName }
  • 내림차순 정렬을 원한다면 sortedByDescending() 함수를 사용하면 된다.

 

객체 그룹화

  • groupBy() 함수를 사용하면 각기 다른 기준이나 속성을 기반으로 객체를 그룹화할 수 있다.
val groupBy1stLetter = people.groupBy { person -> person.firstName.first() }

println(groupBy1stLetter)
//{S=[Person(firstName=Sara, age=12), Person(firstName=Sue, age=10)], J=[...
  • 위 예제에선 firtName의 첫 글자가 같은 Person이 같은 그룹에 들어가게 된다. 연산의 결과는 Map<L, List<T>>이다.
  • 람다는 결과 Map의 키의 타입을 결정한다.
  • 아규먼트를 2개 받는 오버로드된 버전의 groupBy()를 사용해서 첫 번째 람다 파라미터는 기존 콜렉션의 요소에서 키를 만드는데 사용하고, 두 번째 파라미터는 밸류로 들어갈 리스트를 만들 수 있다.
val namesBy1stLetter = 
  people.groupBy({ person -> person.firstName.first() }) {
    person -> person.firstName
  }

println(namesBy1stLetter)
//{S=[Sara, Sue], J=[Jill, Jack], P=[Paula, Paul], M=[Mani]}

 

지연 연산을 위한 시퀀스

  • Java와 다르게 코틀린에서 filter(), map() 등의 메소드는 Stream<T>에서만 사용 가능한게 아니라 List<T> 같은 콜렉션에서 직접 사용 가능하다.
  • 코틀린에서 콜렉션의 내부 반복자는 사이즈가 작을때만 사용하고 사이즈가 클때는 시퀀스를 이용해야 하는데, 
    시퀀스는 지연 연산으로 코드의 실행이 불필요한 경우 실행을 연기해서 자원을 최적화하기 때문이다.

 

시퀀스로 성능 향상하기

fun is Adult(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(::fetchFirstName)
  .first()
println(nameOfFirstAdult)
  • 위 수식의 결과값을 얻기 위해 filter() 함수는 isAdult() 함수를 호출해서 adults 리스트를 만들고, map() 함수는 fetchFirstName() 함수를 호출해서 adults의 이름 리스트를 만들었다. 이 두 임시 리스트들은 first()가 map()에 의해서 리턴된 리스트의 첫 번째 요소만 가지고 오면서 사라졌다.
isAdult called for Sara
isAdult called for Jill
isAdult called for Paula
isAdult called for Paul
isAdult called for Mani
isAdult called for Jack
isAdult called for Sue
fetchFirstName called for Jill
fetchFirstName called for Paula
fetchFirstName called for Paul
fetchFirstName called for Jack
Jill
  • 비록 하나의 값만이 최종 결과로 예상되었지만, 출력에서 볼 수있는 것처럼 실행이 종료되기까지 많은 작업을 수행했다.
val nameOfFirstAdult = people.asSequence()
  .filter(::isAdult)
  .map(::fetchFirstName)
  .first()
println(nameOfFirstAdult)
  • asSequence() 함수를 이용해서 콜렉션을 시퀀스로 감싼 후 출력하면 성능 상의 큰 이득이 있다.
isAdult called for Sara
isAdult called for Jill
fetchFirstName called for Jill
Jill
  • 시퀀스는 일을 적게 하니 모든 내부 반복자를 콜렉션 대신 시퀀스에서 사용해야할까?
  • 콜렉션이 작을 경우 퍼포먼스 차이는 무시할 만큼 적으며, 지연 연산을 사용하지 않는 것이 디버그하기 편하고 추론하기 쉽다.
728x90
Comments