함수형 프로그래밍에서 이터레이터와 함수형 변환 - foreach, map, for 표현식의 이해와 활용
View: 208
0
0
작성자: 달빛제이크
카테고리: Scala Language
발행: 2024-06-30
수정
2024-06-30
안녕하세요. 달빛제이크입니다.
함수형 프로그래밍에서 이터레이터와 함수형 변환을 foreach, map, for 표현식을 사용해서 구현해 보겠습니다. 지난 글 스칼라에서 지원하는 고차 함수
에서 foreach에 대한 사용법을 설명 드렸었는데, 이번에는 map, for 표현식과 함께 다양한 예제를 다루어보려고 합니다.
1. 이터레이팅을 위한 foreach
foreach는 IterableOnceOps 트레이트에 선언되어 있는 메소드 중에 하나로, 모든 Scala Collection에서 이 trait를 상속 받아 다양한 고차 함수를 제공하고 있습니다. foreach는 Unit을 Return type으로 가지고 있기 때문에 보통 부수 효과(Side effect)를 발생 시키는 작업을 수행할 때 사용합니다. 스칼라 함수형 프로그래밍에서는 Side effect를 세상의 끝, 다시 말하면 논리 구조의 마지막 단계에서 프로그램의 외부와 소통이 필요할 때 사용하기 때문에 foreach를 사용하는 경우는 보통 프로그램의 논리 블록 마지막 단에서 Collection의 내용을 출력하거나, 파일에 쓰거나, 데이터베이스에 저장하는 등의 작업을 할 때 사용합니다. 그 외에도 반환 값이 필요하지 않은 UI 업데이트, Map을 사용한 설정, 테스트 등에 활용할 수 있습니다. 그러나 함수형 프로그래밍에서 일반적으로 대체 가능한 메소드들이 존재하고 Side effect 사용을 권장하지 않고 있기 때문에 논리를 전개해 나가는 과정에서는 디버깅 용도로만 주로 활용하고 있습니다.
// 출력
val list = List(1, 2, 3, 4, 5)
list.foreach(println)
// 파일 쓰기
val data = List("line1", "line2", "line3")
import java.io.*
val writer = new PrintWriter(new File("output.txt"))
data.foreach(writer.println)
writer.close()
// 데이터베이스 저장
val records = List(("Alice", 25), ("Bob", 30))
records.foreach { case (name, age) =>
saveToDatabase(name, age)
}
// UI 업데이트
val items = List("Item1", "Item2", "Item3")
items.foreach(item => updateUI(item))
2. 함수형 변환을 위한 map
map은 컬렉션의 각 요소에 대해 주어진 함수를 적용해서 새로운 컬렉션을 생성하는 함수입니다. 절차형 프로그래밍에서는 for 문을 통해 순차적으로 컬렉션 요소에 접근해서 주어진 작업을 수행한 후 그 결과를 미리 선언해 둔 컬렉션 변수에 저장하는 방식으로 구현이 되기 때문에 그 처리 방식을 일일이 작성해 주어야 합니다. 그러나 스칼라의 map을 사용하면 컬렉션의 각 요소에 접근하는 과정에 대해 신경 쓸 필요 없이 데이터를 처리하는 Logic에만 집중 할 수 있습니다. 예제를 통해 컬렉션 종류 별로 map의 사용법을 살펴 보겠습니다.
//List에서 map을 사용해서 각 요소를 두 배로 변환하는 예제
val list = List(1, 2, 3, 4, 5)
val doubled = list.map(_ * 2)
println(doubled) // List(2, 4, 6, 8, 10)
// List의 각 요소를 문자열 타입으로 변환하는 예제
val list = List(1, 2, 3, 4, 5)
val strings = list.map(x => s"Number: $x")
println(strings) // List("Number: 1", "Number: 2", "Number: 3", "Number: 4", "Number: 5")
// 배열(Array)에서 map을 사용해서 각 요소에 1을 더하는 예제
val array = Array(1, 2, 3, 4, 5)
val incremented = array.map(_ + 1)
println(incremented.mkString(", ")) // 2, 3, 4, 5, 6
// Map 타입에서 각 값에 10을 더하는 예제
val map = Map("a" -> 1, "b" -> 2, "c" -> 3)
val incrementedMap = map.map { case (key, value) =>
(key, value + 10)
}
println(incrementedMap) // Map(a -> 11, b -> 12, c -> 13)
// Set 타입에서 각 요소를 제곱하는 예제
val set = Set(1, 2, 3, 4, 5)
val squaredSet = set.map(x => x * x)
println(squaredSet) // Set(1, 4, 9, 16, 25)
// Vector 타입에서 각 요소를 문자열로 변환하는 예제
val vector = Vector(1, 2, 3, 4, 5)
val stringVector = vector.map(x => s"Element: $x")
println(stringVector) // Vector("Element: 1", "Element: 2", "Element: 3", "Element: 4", "Element: 5")
// 중첩된 구조에서 각 요소를 두 배로 변환하는 예제
val nestedList = List(List(1, 2, 3), List(4, 5, 6))
val doubledNestedList = nestedList.map(innerList => innerList.map(_ * 2))
println(doubledNestedList) // List(List(2, 4, 6), List(8, 10, 12))
map 함수는 스칼라 컬렉션의 IterableOnceOps
트레이트에 추상 메소드로 선언되어 있고, IterableOnceOps와 IterableOnce를 상속 받은 IterableOps
에 구현되어 있습니다. 이 IterableOps 트레이트는 다른 모든 컬렉션 클래스에 상속되어 map을 포함한 다양한 고차 함수를 제공합니다. 아래는 map의 실제 구현 코드의 일부 입니다.
trait IterableOnceOps[+A, +CC[_], +C] extends Any { this: IterableOnce[A] =>
def map[B](f: A => B): CC[B]
// Other methods...
}
trait IterableOnce[+A] extends Any {
def iterator: Iterator[A]
// Other methods...
}
trait IterableOps[+A, +CC[_], +C] extends Any with IterableOnce[A] with IterableOnceOps[A, CC, C] {
def map[B](f: A => B): CC[B] = iterableFactory.from(new View.Map(this, f))
// Other methods...
}
CC라는 객체의 정체, iterableFactory의 역할, View.Map 객체에 대해서 궁금하지만 결국은 map을 통해 이터레이터와 실행하고자 하는 함수의 정보를 넘겨서 새로운 Type의 객체를 반환하는 일을 수행하는 코드라고 보시면 됩니다.
3. for 표현식의 활용 : for-do와 for-yield
앞에서 우리는 고차 함수 중 이터레이터의 처리와 형 변환을 담당하는 foreach와 map에 대해서 살펴봤습니다. foreach와 map을 사용함으로 해서 자칫 길어지고 중복될 수 있는 코드가 간결해지고 가독성이 좋아졌습니다. 그런데 만약 하나의 컬렉션에 대한 작업이 아니라 동시에 여러가지 데이터를 한번에 처리해서 결과를 내야 한다면 어떻게 해야 할까요? foreach와 map을 사용한다면 함수 내부에 반복 적으로 컬렉션을 사용해야 하기 때문에 코드가 지저분해지고 가독성도 떨어질 우려가 있습니다.
val listA = List(1, 2, 3)
val listB = List("a", "b" "c")
// foreach 중복 예제
listA.foreach( elemA => listB.foreach( elemB => println(s"$elemA $elemB"))) // 정상 출력
// map 중복 예제
val mapResult = listA.map( elemA => listB.map( elemB => s"$elemA $elemB")) // List[List[String]] type으로 형 변환
foreach 중복 예제에서는 그나마 의도한 대로 출력이 되는 데, map을 중복으로 작성하면 List 타입이 중복된, List[List[String]] 타입으로 변경됩니다. 이를 해결하기 위해 listA.map
대신에 listA.flatMap
을 사용하면 List의 중복 없이 동일한 List[String] 타입으로 결과 값이 반환되지만, 어찌되었던 가독성이 떨어지고 코드를 이해하기에 좀 복잡한 면이 있습니다.
이와 같은 상황에서는 우리는 for 표현식을 통해 간결하게 코드를 작성할 수 있습니다. 다음 예제에서 앞의 코드를 for 표현식으로 변경했습니다.
val listA = List(1, 2, 3)
val listB = List("a", "b", "c")
// foreach를 대체하는 for-do 표현식
for
elemA <- listA
elemB <- listB
do
println(s"$elemA $elemB")
// map을 대체하는 for-yield 표현식
val result =
for
elemA <- listA
elemB <- listB
yield
s"$elemA $elemB"
for 표현식으로 변경하니 가독성이 좋아지고 코드의 목적이 명확히 보입니다. 다만 for 표현식은 절차형 프로그래밍 방식이 적용된 모습입니다. 함수형 프로그래밍 방식 또는 절차형 프로그래밍 방식에 대해 논하기에는 매우 가벼운 예제이지만, 함수의 불변성이 유지 되면서 가독성을 높이고 코드의 이해도를 올릴 수 있는 가장 적합한 방식을 적용하는 것이 중요할 것 같습니다.
이번 글에서는 이터레이터와 함수형 변환의 대표적인 함수인 foreach와 map에 대해 알아보았고, for-do, for-yield 표현식을 통해 동일한 작업을 수행하는 것은 물론, 상황에 따라서는 더 간결하고 가독성 좋은 코드를 작성할 수 있다는 것도 알게 되었습니다.
다음 글에서는 동시성 프로그래밍과 null 값에 대한 처리를 목적으로 Future, Option 타입으로 대표하는 스칼라의 모나드(Monad)에 대해서 이야기 하겠습니다. 감사합니다.
