이 글은 Szabolcs Andrási가 작성한 Understanding Scala's partially applied functions and partial functions를 번역한 글이다. 스칼라 스터디에서 정대원님이 partial function을 설명하는 걸 듣고 관심이 생겨서 찾아보다가 번역을 하게 되었다. 참고로 번역은 원 글을 쓴 Szabolcs Andrási의 허락을 받고 올린다.(항상 그렇듯이 번역품질은 보장 못하니 영어되시는 분들은 원문을....) 그리고 이 글에서는 번역이 애매한 용어는(partial function같은 ) 그냥 원문을 그대로 사용했다.
비슷해 보이는 partially applied function과 partial function 이 두 용어는 함수형 프로그래밍 언어를 처음 사용하는 사람들에게는 꽤 헷갈리는 용어이다. 스칼라가 비슷한 이름의 두 함수 타입의 차이점을 이해하는데 도움이 주지 않으므로 꽤 어렵다. 예제를 통해서 두 함수의 차임점을 설명해 보고자 한다.
먼저 몇가지 용어를 정의하고 시작하자.
- 스칼라에서 메서드는 자바처럼 클래스(또는 객체)의 일부분이고 완전한 시그니처(이름, 인자, 반환값, 추상메서드가 아니면 메서드를 구현하는 바디)를 가진다. 예를 들면 다음과 같다.
class Math {
def add(a: Int, b: Int): Int = a + b
}
편의성을 위해서 반환값은 생략할 수 있으므로 스칼라는 메서드의 마지막 표현식에서 타입을 추론한다.
- 반면 스칼라 함수(Scala function)는 사실 클래스다. 스칼라에는 여러가지 갯수의 인자를 갖는 함수를 나타내는 트레이트(trait)들이 있다. 파라미터가 없으면
Function0
이고 파라미터가 하나면Function1
, 파라미터가 둘이면Function2
같은 방식으로Function22
까지 있다. 이러한 트레이트로 함수는 트레이트(실제 파라미터 수에 따락 적합한 트레이트)를 믹스인한 클래스로 메서드를 가진다. 여기서 가장 중요한 메서드는 함수바디의 구현이 있는apply
메서드다.apply
메서드의 인자 갯수는 믹스인한 트레이트 이름의 수와 완전히 같다. 즉,Function0
이면 0개이고Function1
이면 1개,Function2
이면 2개 등이다. 예를 들면 다음과 같다.
object add extends Function2[Int, Int, Int] {
override def apply(a: Int, b: Int): Int = a + b
}
스칼라는 여러 가지 편의문법을 제공하는데 특수한 apply
문법도 편의문법 중 하나이다. 심볼이름 뒤에 괄호와 함께 인자 목록을 작성하면 스칼라는 해당 이름을 가진 객체의 apply
메서드를 호출하도록 변환한다. 위 예제에서 add.apply(1, 2)
대신 add(1, 2)
로 작성해서 같은 결과를 얻을 수 있다.
- 함수 리터럴이나 익명 함수는 함수를 정의하는 대체 문법이다.
FunctionN
트레이트를 믹스인하고apply
메서드를 오버라이딩하는 새로운 클래스를 생성하는 대신 괄호안에 함수 파라미터명의 목록을 작성하고 우측방향의 화살표를 쓴 다음 함수의 바디를 쓸 수 있다. 예를 들면 다음과 같다.
(a: Int, b: Int) => a + b
이러한 함수 리터럴에서 스칼라 컴파일러는 FunctionN
트레이트 중 하나를 믹스인한 function
객체를 생성한다. =>
의 좌측부분은 파라미터 목록이 되고 우측부분은 apply
메서드의 구현부가 된다. 여기서 보듯이 function
의 단축형식일 뿐이다.
- 함수값(function value)은
FunctionN
트레이트를 확장하는 클래스의 인스턴스다. 예를 들어 함수 리터럴은 일단FunctionN
트레이트를 믹스인하는 클래스로 컴파일되고 런타임에서 인스턴스화된다. 여기서 생성된 인스턴스는 함수값이다. 함수값이 객체기 때문에 변수에 저장할 수 있지만 동시에 함수이기도 해서 괄호를 사용하는 함수호출 표기법으로 호출할 수 있다.(사실 변수에 할당된 함수클래스 인스턴스의apply
메서드를 호출하도록 변환될 것이다.) 예를 들면 다음과 같다.
val add = (a: Int, b: Int) => a + b
add(1, 2) // returns 3
Partially applied function
이제 용어에 익숙해졌으므로 partially applied function을 얘기할 차례이다. 지금부터는 메서드와 함수라는 용어를 같은 의미로 바꿔가면서 사용할 수 있다. 엄밀히 말하자면 같은 의미가 아니지만 partially applied functions를 설명할 때는 그리 중요하지 않으므로 여기서는 같은 의미로 사용할 것이다.
일반적으로 메서드나 함수를 호출할 때는 필요한 인자를 전부 전달하고 함수는 받은 인자를 가지고 값을 계산한다. 하지만 스칼라에서는 반드시 인자를 전부 전달해야 하는 것은 아니고 인자 중에서 일부만 전달하거나 인자를 전혀 전달하지 않을 수도 있다. 이러한 경우에 누락된 인자가 있으므로 스칼라는 값을 계산하지 않고 대신 제공된 인자로 partially applied function를 자동적으로 생성하고 partially applied function에서 function value를 생성한다. 생성한 partially applied function은 FunctionN
트레이트를 믹스인하고 여기서 N
은 누락된 함수 인자의 갯수이다. 생성된 함수의 apply
메서드 바디는 partially applied function의 apply
메서드에 전달된 인자와 원래 제공된 인자로 인자를 모두 채워서 원래 함수의 apply
메서드를 호출한다. 꽤 복잡해 보이기는 하지만 예제로 보면 별로 복잡하지 않다.
val add = (a: Int, b: Int) => a + b
val inc = add(_: Int, 1)
inc(10) // returns 11
이 예제에서는 다음과 같은 과정이 진행된다. Int
인자는 제공하지 않고 다른 인자는 숫자 1로 지정해서 add(Int, Int)
함수를 호출한다. 여기서 누락한 인자는 언더스코어(_)로 대치했다. 인자 중에서 일부만 제공했기 때문에 add(Int, Int)
를 실행할 수 없어서 inc
는 함수의 결과를 저장하는 것이 아니라 생성된 partially applied function에서 인스턴스화된 function value에 대한 참조를 저장할 것이다. 여기서 생성된 함수의 타입은 무엇일까? 누락된 인자의 갯수가 하나뿐이므로 타입은 Function1
이 될 것이다. Function1
은 두가지 타입의 파라미터를 가지는데 하나는 함수의 입력 파라미터이고 다른 하나는 결과 타입이다. add
의 누락된 인자 타입은 Int
이고 결과 타입도 Int
이므로 두 타입 파라미터는 모두 Int
가 된다. 그리고 이 함수의 apply(Int)
메서드는 정확히 무엇을 할까? apply
에 전달한 파라미터와 고정된 인자값 1
로 원래의 add
함수를 호출한다. 이제 모든 내용을 알았으므로 직접 partially applied function를 구현할 수도 있지만 스칼라 컴파일러가 이 작업을 해주므로 직접 할 필요는 없다.
object inc extends Function1[Int, Int] {
override def apply(v: Int): Int = add(v, 1)
}
inc(10) // returns 11
또 다른 경우는 인자를 전혀 제공하지 않은 경우이다. 누락된 인자를 언더스코어로 나타낼 수 있지만 스칼라는 더 간단한 형식을 지원하고 있다. 전체 파라미터 목록 대신에 함수 이름뒤에 언더스코어 문자를 하나 붙힐 수 있다. 즉, add _
처럼 작성할 수 있다.(함수 이름과 언더스코어 사이에 공백이 있음에 주의해야 한다! 이 공백 문자는 반드시 넣어주어야 하고 공백이 없으면 스칼라 컴파일러가 add_
를 호출하는 함수로 생각할 것이다.) 언더스코어문자까지도 없애서 더 간단하게 사용할 수도 있는데 코드가 다른 함수를 인자로 받는 higer order function인 경우이다. 예를 들어 Array(1, 2, 3).foreach(println)
처럼 GenTraversableOnce
트레이트가 정의한 foreach
메서드는 파라미터로 함수를 받는다. 여기서 (scala.Predef
객체에 정의된) println(Any)
메서드를 배열의 foreach
메서드에 전달한다. println
전달하는 인자가 없고 foreach
가 함수를 받으므로 println
뒤에 언더스코어를 붙히지 않아도 된다. 스칼라 컴파일러는 println
메서드에서 딱 하나의 인자를 받는 partially applied function를 생성한다. foreach
메서드가 배열을 순회하면서 각 요소를 partially applied가 적용된 println에 인자로 전달하므로 배열의 각 요소가 콘솔에 출력된다.
Partial function
이름은 비슷하지만 partial function은 partially applied function와 전혀 관계가 없다. 하지만 수학을 공부했다면 여기서 partial function이 의미하는 것을 알 것이다. X에서 Y로의 partial function은 함수 ƒ: X' → Y이고 여기서 X'는 X의 서브셋이다. 즉, 함수는 X의 모든 요소에 대해 정의된 것이 아니다. 가장 명확한 예제가 나눗셈 함수인데 나눗셈 함수는 숫자를 다른 숫자로 메핑하지만 0에는 매핑된 숫자가 없다. 더 형식적으로 적으면 ƒ(x, y) = x / y이고 x, y ∈ ℝ이고 y ≠ 0이다.
스칼라의 partial function은 이와 동일하다. 즉, 가능한 입력 인자의 서브셋에 대해서만 정의된 함수이다. 실제로 어떻게 구현하는 지 보기 전에 패턴매칭을 먼저 보자. 패턴매칭은 이 글의 범위를 벗어나기 때문에 여기서는 변수 정의부터 패턴매칭의 표현식까지 스칼라가 패턴을 많이 사용하고 있다는 것을 아는 것만으로도 충분하다. 매턴매칭에서 match
표현식의 case
문으로 다수의 경우의 수 중에서 선택할 수 있다.
args(0) match {
case "foo" => println("bar")
case _ => println("?")
}
다양한 종류의 패턴이 존재하지만 간단히 말해서 case
키워드 뒤에 오는 것은 무엇이든지 간에 모두 패턴이라고 할 수 있고 표현식이 해당 패턴에 일치한다면 화살표의 우측부분을 실행한다. case
문(case sequence)을 사용할 수 있는 곳에만 match
표현식을 사용할 수 있는 것은 아니다. 사실 함수리터럴을 사용할 수 있는 곳에는 모두 중괄호안의 case
문을 사용할 수 있다. 그 이유는 이 생성과정이 함수리터럴이기 때문이다. 하지만 좀 더 일반적으로 이야기하자면 일반적인 함수나 메서드가 해당 파라미터 목록으로 딱 하나의 진입점만을 갖는 반면에 case
문은 여러 진입점을 가지면서 각 진입점은 각자의 파라미터(패턴으로 정의한)를 가진다. 또 다른 차이점은 하나의 함수는 딱 하나의 함수 바디를 가지지만 case
문은 case
형식만큼의 다양한 함수 바디를 가진다는 것이다. 즉, 각 case
문(또는 진입점)의 우측부분이 함수바디가 된다. 이러한 일반화를 통해 case
문이 인자의 서브셋(어떤 패턴매칭이든)에서만 해석하고 그외의 다른 경우에는 정의되지 않으므로 partial function이 된다.
val div: (Double, Double) => Double = {
case (x, y) if y != 0 => x / y
}
위 예제는 앞에서 얘기한 나눗셈 함수의 구현이다. 다양한 사례를 보여주지는 않지만 (x, y) if y != 0
은 partial function을 설명하기에 충분하다. 0으로 나누는 경우를 제외하고는 모든 숫자가 쌍으로 정의되어 있다. div(1, 0)
를 호출하면 무엇을 출력할까? 이 함수에 0으로 나누는 경우에 대한 패턴을 정의하지 않았으므로 MatchError
이 발생하면서 실패할 것이다.
하지만 이 방법으로 case
문을 정의하는 데는 두가지 작은 문제점이 존재한다. 첫번째 문제는 입력인자가 유효한 값의 서브셋이라면 테스트할 방법이 없다는 것이다.(대신 함수 자체를 호출한다.) 예외를 피하려면 함수호출을 try-catch
블럭으로 감싸야 하지만 코드가 지저분해진다. 두번째 문제는 case
문에서 사용한 패턴에서 가능한 값을 스칼라 컴파일러가 전부 알고 있다면 모든 경우에 대해서 정의하지 않을 경우 경고 메시지를 보여줄 것이다. 이러한 경우에 누락된 패턴값에서는 런타임 예외가 발생하므로 컴파일러의 경고를 들어야 한다. 하지만 partial function에서는 전부가 아닌 일부만 구현하기를 원할 것이다.
다행히 스칼라는 이 두가지 문제를 해결하는 우아한 방법을 제공한다. partial function를 생성하고자 한다고 컴파일러에게 알려주면 합수가 입력인자에 정의되었는지 검사할 뿐만 아니라 누락된 패턴을 경고하지 않을 것이다. 그러면 partial function를 원한다고 어떻게 컴파일러에게 알려줄 수 있는가? 아주 간단하다. 그냥 PartialFunction
타입으로 함수를 정의하기만 하면 된다. div
함수를 다시 보자. 이번에는 실제로 partial function이다.
val div: PartialFunction[(Double, Double), Double] = {
case (x, y) if y != 0 => x /y
}
기본적으로 PartialFunction
은 특별한 Function1
이다. PartialFunction
은 Function1
가 가진 모든 메서드를 가지고 그외 몇가지 편리한 메서드를 더 가지고 있다. 예를 들어 함수가 입력인자에 정의되었는지 검사할 수 있는 isDefinedAt()
가 있다.
div.isDefinedAt(1, 0) // returns false
이 메서드를 사용해서 try-catch
블럭을 피할수도 있고 MatchError
예외를 잡는것보다 더 표현력이 좋다.
스칼라에 익숙해 질수록 partially applied function과 partial function이 많은 곳에서 사용되고 있다는 것을 알게 될 것이다. 예를 들어 higher order function을 사용할 때 실제로는 partially applied function으로 동작한다. 또는 try-catch
블락에서 catch
블락이 본질적으로는 partial function
이라는 것을 알게될 것이다. 이 글이 partially applied function과 partial function에 대한 속설을 명확하게 하는데 도움이 되기를 바하고 이제 자신의 함수를 직접 작성할 수 있다.(그리고 사용할 수 있는 곳을 찾아야 할 것이다.)
Comments