고차 함수
고차 함수
고차 함수(HOF, Higher-Order Function)는 보통 (a) 다른 함수를 입력 매개변수로 받거나 (b) 함수를 결과로 반환하는 함수로 정의돼요. Scala에서는 함수가 일급 값(first-class value)이기 때문에 HOF가 가능해요.
본문
한 가지 중요한 점은, 여기서는 업계에서 흔히 쓰는 "고차 함수"라는 용어를 쓰지만 Scala에서는 이 말이 메서드와 함수 둘 다를 가리킨다는 거예요. Scala의 Eta Expansion 기술 덕분에 둘은 대체로 같은 자리에 쓸 수 있거든요.
소비자에서 생산자로
이 책의 지금까지 예시에서는 다른 함수를 입력으로 받는 메서드의 *소비자(consumer)*가 되는 법을 보셨어요. map과 filter 같은 HOF를 쓰는 것이죠. 다음 몇 절에서는 HOF의 *생산자(creator)*가 되는 법을 볼 거예요. 여기에는 이런 것들이 포함돼요.
- 함수를 입력 매개변수로 받는 메서드를 작성하는 법
- 메서드에서 함수를 반환하는 법
그 과정에서 이런 것들도 함께 보게 돼요.
- 함수 입력 매개변수를 정의할 때 쓰는 문법
- 함수에 대한 참조를 얻었을 때 그 함수를 호출하는 법
이 논의의 부수적인 이점으로, 이 문법에 익숙해지면 함수 매개변수, 익명 함수, 함수 변수를 정의할 때도 그 문법을 쓰게 되고, 고차 함수의 Scaladoc을 읽는 것도 한결 쉬워져요.
filter의 Scaladoc 이해하기
고차 함수가 어떻게 동작하는지 이해하려면 실제 예시를 파고드는 게 도움이 돼요. 예를 들어 filter가 받는 함수의 타입은 Scaladoc을 보면 알 수 있어요. List[A] 클래스의 filter 정의를 볼게요.
def filter(p: A => Boolean): List[A]
이는 filter가 p라는 함수 매개변수를 받는 메서드라는 뜻이에요. 관례상 p는 *술어(predicate)*를 뜻하는데, 술어는 Boolean 값을 반환하는 함수일 뿐이에요. 그래서 filter는 술어 p를 입력 매개변수로 받아 List[A]를 반환해요. 이때 A는 리스트에 담긴 타입이에요. List[Int]에 filter를 호출하면 A는 Int 타입이 되죠.
이 시점에서 filter 메서드의 목적을 모른다면, 그 알고리즘이 어떻게든 술어 p를 사용해 List[A]를 만들고 반환한다는 것만 알 수 있어요.
함수 매개변수 p의 부분, 즉 filter 설명의 이 부분을 자세히 볼게요.
p: A => Boolean
이것은 어떤 함수를 넘기든 그 함수는 타입 A를 입력 매개변수로 받아 Boolean을 반환해야 한다는 뜻이에요. 그래서 리스트가 List[Int]라면 타입 매개변수 A를 Int로 바꿔서 이렇게 읽으면 돼요.
p: Int => Boolean
isEven이 이런 타입이면, 즉 입력 Int를 결과 Boolean으로 변환한다면 filter와 함께 쓸 수 있어요.
함수 매개변수를 받는 메서드 작성하기
이 배경을 바탕으로, 함수를 입력 매개변수로 받는 메서드를 작성해 볼게요.
참고: 앞으로의 설명을 명확하게 하기 위해, 여러분이 작성하는 코드를 *메서드(method)*라고 하고 입력 매개변수로 받는 코드를 *함수(function)*라고 부를게요.
첫 번째 예시
함수 매개변수를 받는 메서드를 만들려면 다음 두 가지만 하면 돼요.
- 메서드의 매개변수 목록에서, 받아들이고 싶은 함수의 시그니처를 정의한다.
- 메서드 안에서 그 함수를 사용한다.
이를 보여주기 위해, f라는 입력 매개변수를 받는 메서드를 만들어 볼게요. f는 함수예요.
def sayHello(f: () => Unit): Unit = f()
코드의 이 부분, 즉 *타입 시그니처(type signature)*는 f가 함수임을 나타내고, sayHello 메서드가 받아들일 함수의 타입을 정의해요.
f: () => Unit
이렇게 동작해요.
f는 함수 입력 매개변수의 이름이에요.String매개변수를s라고,Int매개변수를i라고 이름 붙이는 것과 같아요.f의 타입 시그니처는 이 메서드가 받아들일 함수의 타입을 지정해요.f시그니처의()부분(=>기호 왼쪽)은f가 입력 매개변수를 받지 않는다는 뜻이에요.- 시그니처의
Unit부분(=>기호 오른쪽)은f가 의미 있는 결과를 반환하지 않아야 한다는 뜻이에요. sayHello메서드 본문(=기호 오른쪽)을 돌아보면, 거기 있는f()구문이 전달받은 함수를 호출해요.
이제 sayHello를 정의했으니, f의 시그니처에 맞는 함수를 만들어 테스트해 볼게요. 다음 함수는 입력 매개변수를 받지 않고 아무것도 반환하지 않으므로 f의 타입 시그니처에 맞아요.
def helloJoe(): Unit = println("Hello, Joe")
타입 시그니처가 맞으므로 helloJoe를 sayHello에 넘길 수 있어요.
sayHello(helloJoe) // prints "Hello, Joe"
이런 걸 처음 해보셨다면 축하해요. 방금 sayHello라는 이름의 메서드를 정의해서, 함수를 입력 매개변수로 받고 그 함수를 메서드 본문에서 호출했어요.
sayHello는 여러 함수를 받을 수 있어요
이 방법의 장점은 sayHello가 하나의 함수를 입력 매개변수로 받을 수 있다는 게 아니라는 점을 아는 게 중요해요. 장점은 f의 시그니처와 맞는 어떤 함수든 받을 수 있다는 거예요. 예를 들어, 다음 함수도 입력 매개변수를 받지 않고 아무것도 반환하지 않으므로 sayHello와 함께 동작해요.
def bonjourJulien(): Unit = println("Bonjour, Julien")
REPL에서 이렇게 확인할 수 있어요.
scala> sayHello(bonjourJulien)
Bonjour, Julien
좋은 시작이에요. 이제 함수 매개변수에 대해 다양한 타입 시그니처를 정의하는 예시를 몇 개 더 보면 돼요.
함수 입력 매개변수를 정의하는 일반적인 문법
이 메서드에서,
def sayHello(f: () => Unit): Unit
f의 타입 시그니처가 다음과 같다고 했죠.
() => Unit
이것이 "입력 매개변수를 받지 않고 의미 있는 것(여기서는 Unit으로 표시)을 반환하지 않는 함수"라는 뜻이라는 걸 알 수 있어요.
타입 시그니처 예시를 더 보여드리기 위해, String 매개변수를 받아 Int를 반환하는 함수를 볼게요.
f: String => Int
어떤 함수가 문자열을 받아 정수를 반환할까요? "문자열 길이"나 체크섬 같은 함수가 두 가지 예시예요.
비슷하게, 이 함수는 두 개의 Int 매개변수를 받아 Int를 반환해요.
f: (Int, Int) => Int
그런 시그니처에 맞는 함수가 어떤 건지 상상할 수 있을까요?
답은 두 개의 Int 입력 매개변수를 받아 Int를 반환하는 함수라면 모두 맞는다는 거예요. 그래서 이 "함수들"(사실은 메서드들)은 모두 맞아요.
def add(a: Int, b: Int): Int = a + b
def subtract(a: Int, b: Int): Int = a - b
def multiply(a: Int, b: Int): Int = a * b
이 예시들에서 알 수 있듯이, 함수 매개변수 타입 시그니처를 정의하는 일반적인 문법은 다음과 같아요.
variableName: (parameterTypes ...) => returnType
함수형 프로그래밍은 일련의 대수 방정식을 만들고 결합하는 것과 비슷해서, 함수와 애플리케이션을 설계할 때 타입에 대해 아주 많이 생각하게 돼요. "타입으로 생각한다(think in types)"고 말할 수 있을 거예요.
함수 매개변수를 다른 매개변수와 함께 받기
HOF가 정말 유용해지려면, 작업할 데이터도 필요해요. List 같은 클래스는 map 메서드가 이미 작업할 데이터를 갖고 있어요. 바로 List 안의 데이터죠. 하지만 자기만의 데이터가 없는 독립적인 HOF라면, 다른 입력 매개변수들로 데이터도 받을 수 있게 해야 해요.
예를 들어, executeNTimes라는 메서드는 함수와 Int라는 두 입력 매개변수를 받아요.
def executeNTimes(f: () => Unit, n: Int): Unit =
for i <- 1 to n do f()
코드가 보여주듯, executeNTimes는 f 함수를 n번 실행해요. 이런 단순한 for 루프는 반환값이 없으므로 executeNTimes는 Unit을 반환해요.
executeNTimes를 테스트하려면 f의 시그니처에 맞는 메서드를 정의해요.
// a method of type `() => Unit`
def helloWorld(): Unit = println("Hello, world")
그런 다음 그 메서드를 Int와 함께 executeNTimes에 넘겨요.
scala> executeNTimes(helloWorld, 3)
Hello, world
Hello, world
Hello, world
좋아요. executeNTimes 메서드는 helloWorld 함수를 세 번 실행했어요.
필요한 만큼 많은 매개변수
메서드는 필요에 따라 얼마든지 복잡해질 수 있어요. 예를 들어 이 메서드는 (Int, Int) => Int 타입의 함수와 두 입력 매개변수를 받아요.
def executeAndPrint(f: (Int, Int) => Int, i: Int, j: Int): Unit =
println(f(i, j))
sum과 multiply 메서드가 그 타입 시그니처에 맞으므로, 두 Int 값과 함께 executeAndPrint에 넘길 수 있어요.
def sum(x: Int, y: Int) = x + y
def multiply(x: Int, y: Int) = x * y
executeAndPrint(sum, 3, 11) // prints 14
executeAndPrint(multiply, 3, 9) // prints 27
함수 타입 시그니처의 일관성
Scala의 함수 타입 시그니처를 배우는 좋은 점 하나는, 함수 입력 매개변수를 정의할 때 쓰는 문법이 함수 리터럴(function literal)을 작성할 때 쓰는 문법과 같다는 거예요.
예를 들어 두 정수의 합을 계산하는 함수를 작성한다면 이렇게 써요.
val f: (Int, Int) => Int = (a, b) => a + b
그 코드는 타입 시그니처로 이루어져 있는데,
val f: (Int, Int) => Int = (a, b) => a + b
-----------------
입력 매개변수는 이 부분이고,
val f: (Int, Int) => Int = (a, b) => a + b
------
함수의 본문은 이 부분이에요.
val f: (Int, Int) => Int = (a, b) => a + b
-----
Scala의 일관성이 여기서 드러나는데, 이 함수 타입,
val f: (Int, Int) => Int = (a, b) => a + b
-----------------
은 함수 입력 매개변수를 정의할 때 쓰는 타입 시그니처와 같아요.
def executeAndPrint(f: (Int, Int) => Int, ...
-----------------
이 문법에 익숙해지면 함수 매개변수, 익명 함수, 함수 변수를 정의할 때 그 문법을 쓰게 되고, 고차 함수의 Scaladoc을 읽는 것도 한결 쉬워져요.