스칼라 함수형 프로그래밍 - 제어 추상화를 위한 고차함수의 개념과 활용 (Higher-order functions for Control Abstraction)
View: 186
0
0
작성자: 달빛제이크
카테고리: Scala Language
발행: 2024-06-25
수정
2024-06-29
안녕하세요. 달빛제이크입니다.
오늘은 스칼라 함수형 프로그래밍에서 제어 추상화를 구현하기 위한 고차함수(Higher-order function)에 대해서 알아보겠습니다.
1. 제어 추상화 (Control abstractions)
스칼라 언어에서는 문법 상으로 지원하는 제어 구조 (Control structures)가 if, while, for, try, match 다섯 가지로 많지 않습니다. 스칼라 문법 상으로 지원하는 이 소수의 제어 구조를 가지고도 다양한 논리 구조를 만들기 위해서는 제어 추상화라는 새로운 개념이 필요합니다.
제어 추상화 (Control abstraction)는 함수 또는 메소드를 사용하여 일반적인 제어 구조인 반복문, 조건문 등을 캡슐화하고, 이를 매개변수를 통해 사용자 정의 동작으로 대체할 수 있게 하는 프로그래밍 기법입니다. 제어 추상화를 통해 반복적인 제어 구조를 추상화하여 더 간결하고 유연한 코드 구현이 가능하게 됩니다. 제어 추상화는 주로 고차 함수 (Higher-order functions)를 사용하여 구현됩니다.
// 제어 추상화 코드에서 호출할 함수. 커링 적용 함수
def operation(x: Int)(y: Int => Int): Int = y(y(x)) // y 함수를 두 번 반복
// 제어 추상화의 일반적인 모습
val result = operation(1) { (x: Int) =>
val a = 3
val b = 4
x + a + b } // 15
2. 함수 값 (Function value) - First-class function
먼저 함수 값 (Function values)에 대해서 언급 하지 않을 수 없습니다. 함수 값이 존재하기 때문에 함수를 first-class citizen으로 취급할 수 있고 다음과 같은 일을 가능하게 합니다. (*함수를 first-class citizen으로 취급하는 개념을 First-class function이라고 부릅니다.)
- 함수를 변수에 할당할 수 있다.
- 함수를 다른 함수의 인수로 전달할 수 있다.
- 함수를 다른 함수의 반환 값으로 사용할 수 있다.
- 함수를 데이터 구조에 저장할 수 있다.
간단히 예제를 살펴 보겠습니다.
- 함수를 변수에 할당
val add = (x: Int, y: Int) => x + y
println(add(3,5)) // 8
add
변수에 익명 함수를 할당했습니다.
- 함수를 다른 함수의 인수로 전달
val add = (x: Int, y: Int) => x + y
def applyFunction(f: (Int, Int) => Int, a: Int, b: Int): Int = f(a, b)
val sum = applyFunction(add, 3, 5)
println(sum) // 8
applyFunction
함수는 함수 리터럴이 정의된 add를 인수로 전달받아서 해당 함수를 호출합니다.
- 함수를 다른 함수의 반환 값으로 사용
def createMultiplier(factor: Int): Int => Int = (x: Int) => x * factor
val multiplyByTwo = createMultiplier(2)
println(multiplyByTwo(5)) // 10
createMultiplier
함수에 2를 전달하면 (x: Int) => x * 2
익명 함수가 반환됩니다. 이 익명 함수를 multiplyByTwo
에 할당한 후 x
파라미터의 인수로 5를 전달해서 5 * 2 = 10인 결과를 얻습니다.
- 함수를 데이터 구조에 저장
val add = (x: Int, y: Int) => x + y
val functions = List(add, (x: Int, y: Int) => x - y)
println(functions(0)(10, 5)) // 15
println(functions(1)(10,5)) // 5
List는 동일한 Type의 elements를 소유할 수 있습니다. 따라서 add
변수에 할당된 익명 함수의 Type과, List를 정의할 때 직접 작성한 (x: Int, y: Int) => x - y
의 Type이 (Int, Int) => Int
로 동일합니다. 다음으로 익명 함수를 원소로 갖는 List를 functions
변수에 할당합니다. println 문에서 첫 번째 파라미터의 인수로 전달된 0과 1은 List의 원소를 선택하고, (10, 5)는 익명 함수의 파라미터로 전달 됩니다.
3. 고차 함수 (Higher-order functions)
고차 함수 (Higher-order functino)란 다른 함수를 인수로 받거나, 함수를 반환하는 함수를 말합니다. 함수를 인수로 받기 때문에 몸체에서 구현한 알고리즘에, 인수로 넘어온 함수에서 제공하는 알고리즘을 더해서 함수의 기능을 더 유연하고 다채롭게 만들 수 있습니다. 또한 함수를 반환하게 되면 클로저 (Closures)를 통해 몸체에서 제공하는 알고리즘의 Context 내에서 추상화를 제어할 수 있습니다.
간단히 예제를 살펴 보겠습니다.
- 함수를 인수로 받는 함수
def applyOperation(a: Int, b: Int, operation: (Int, Int) => Int): Int =
operation(a, b)
val sum = (x: Int, y: Int) => x + y
val result = applyOperation(3, 4, sum)
println(result) // 7
sum
에 할당된 익명 함수를 applyOperation
함수에 인수로 전달하고 7이라는 결과를 얻었습니다.
- 함수를 반환하는 함수
def createAdder(x: Int): Int => Int =
(y: Int) => x + y
val addFive = createAdder(5)
println(addFive(10)) // 15
createAdder
함수는 Int => Int
type의 익명 함수(클로저)를 반환하는 데 x
의 인수로 5를 전달해서 free variable인 x를 closing 해 줍니다. addFive
변수에 할당된 함수는 (y: Int) => 5 + y
가 되고 println 문의 addFive(10)
의 결과로 15를 얻게 됩니다.
다음으로 보여드릴 예제는 Programming in Scala 책에서 예제로 사용한 코드 입니다. 고차 함수를 통해서 코드의 반복을 어떻게 간결하게 만들어나가는지를 Refactoring을 통해서 보여주고 있습니다.
object FileMatcher:
// 현재 디렉토리에 있는 파일의 리스트
private def filesHere = (new java.io.File(".")).listFiles
// 마지막 부분과 일치하는 파일 찾기
def filesEnding(query: String) =
for file <- filesHere if file.getName.endsWith(query)
yield file
// 동일한 키워드가 포함된 파일 찾기
def filesContaining(query: String) =
for file <- filesHere if file.getName.contains(query)
yield file
// 정규 표현식을 사용해서 파일 찾기
def filesRegex(query: String) =
for file <- filesHere if file.getName.matches(query)
yield file
현재 디렉토리의 파일 목록을 얻는 filesHere
메소드를 정의하고 이 목록에서 파일을 찾는 방법을 각각 구현하였습니다. 보기에 깔끔합니다. 이상할 게 없습니다. 다만 파일을 찾는 메소드들의 형태가 동일합니다. 코드의 반복이 눈에 들어옵니다. 이를 더욱 간단하게 만들 수 있는 방법은 없을까요? 만약 endsWith
, contains
, matches
자리에 메소드를 직접 전달 받아 넣어준다면 가능할 것 같습니다. 스칼라에서는 함수 값(Function value)과 Helper Method를 사용해서 구현할 수 있습니다.
object FileMatcher:
private def filesHere = (new java.io.File(".")).listFiles
private def filesMatching(matcher: String => Boolean) =
for file <- filesHere if matcher(files.getName)
yield file
// 클로저를 만들어 filesMatching에 인수로 전달
def filesEnding(query: String) =
filesMatching(_.endsWith(query))
def filesContaining(query: String) =
filesMatching(_.contains(query))
def filesRegex(query: String) =
filesMatching(_.matches(query))
Refactoring한 코드에는 여러가지 기법이 들어가 있습니다.
- 먼저
filesHere
와filesMatching
메소드에 접근 제어자private
이 사용되었습니다. 이는 두 메소드가 외부에 노출될 필요가 없는 내부에서만 사용하는 메소드이기 때문입니다. - 함수 리터럴
_.endsWith(query)
,_.contains(query)
,_.matches(query)
을 만들어 Helper method인filesMatching
에 인수로 전달합니다. 이 함수 리터럴은 클로저(Closure)이며, Runtime에서 함수 값(Function value)으로 전환됩니다. 예제에서는 placeholder 문법을 사용해서 간단하게 표현했습니다.
// _.endsWith(query)의 본래 모습
(fileName: String, query: String) => fileName.endsWith(query)
// 파라미터에서 String type 제거 가능.
// endsWith는 원래 String의 함수이고, String을 인수로 받으며, String을 반환하는 함수
(fileName, query) => fileName.endsWith(query)
// Placehold syntax 적용
// fileName과 query가 함수 리터럴 몸체에 순서대로 한번 씩만 사용되기 때문에 Placeholder로 대체 가능
_.endsWith(_)
// query를 free variable로 전환하여 클로저로 만듦
// 함수 리터럴이 포함된 메소드에서 파라미터로 전달 받게 됨.
_.endWith(query)
- for 문 내부에 조건문을 사용해서, 전달된 함수 리터럴을 적용합니다. 스칼라에서 for 문은 표현식(expression)입니다. for-yield 표현식과 for-do 표현식 두 가지 방식으로 사용이 가능하며, for-yield 표현식을 사용할 경우, 다른 언어와 다르게, 실행 후 결과 값이 남아서 이를 변수에 저장하거나 앞의 예제에서 처럼 메소드의 반환값으로 활용할 수 있습니다. 예제에서는 for 표현식 내부에서 filesHere 메소드를 바로 실행시켜서 이를 file이라는 for 내부 변수에 Iterator로 값을 순차적으로 할당하고, if 문 내 matcher 변수에 전달 받은 함수 리터럴의 파라미터로 활용합니다. 조건문을 만족하는 file만 for-yield의 결과 값으로 만들어져서 List로 반환됩니다. for-yield 표현식은 내부에서 순차적으로 사용하는 콜렉션의 Type에 따라 결과 값의 Type이 결정됩니다. 여기서는 filesHere의 Type이 List이기 때문에 List로 결과 값을 반환합니다.
예제를 통해 Refactoring 전후의 코드 모습을 보여드렸습니다. 앞의 예제에서는 비슷한 기능을 하는 메소드들 끼리 코드가 반복되는 형태가 되어 코드를 간결하게 만들고자 했는데, Refactoring을 하고 나니 Helper method의 등장으로 정작 코드의 양은 그리 줄지 않은 것 같고 이해하기만 어렵게 된 것 같습니다. 그러나 실제로 Refactoring 한 형태로 코드를 구현을 하게 되면 filesEnding
, filesContaining
, filesRegex
를 직접 작성하지 않고 filesMatching
메소드를 활용해서 사용자가 원하는 방식으로 코드를 작성할 수 있도록 합니다. 스칼라에서 제공하는 많은 고차 함수들이 이와 같은 방식으로 구현되었습니다. 다음은 사용자가 개발에 활용할 수 있도록 filesMatching
메소드를 재 작성한 예제입니다.
object FileMatcher:
// 현재 디렉토리에 있는 파일의 리스트
private def filesHere = (new java.io.File(".")).listFiles
def filesMatching(query: String, matcher: (String, String) => Boolean) =
for file <- filesHere if matcher(file.getName, query)
yield file
// 사용 예시
FileMatcher.filesMatching("a", _.contains(_)) // 현재 디렉토리에서 a를 포함한 모든 파일과 디렉토리 명이 반환됨
지금까지 제어 추상화를 위한 고차 함수에 대해서 살펴 보았습니다. 고차 함수에 대한 개념에 대해서는 이 글을 통해 명확히 이해할 수 있기를 기대합니다. 함수 값 (Function value)은 스칼라의 함수형 프로그래밍에서 매우 중요한 요소이고 함수 값이 존재하기 때문에 고차 함수 구현이 가능합니다. 고차 함수는 프로그램을 구현하면서 필요한 반복문 형태의 다양한 구조들을 매우 간단하게 처리할 수 있도록 막강한 기능을 제공합니다.
다음 글에서는 스칼라에서 제공하는 고차 함수와 그 활용 예에 대해서 말씀드리겠습니다.
감사합니다.
