(FP,용어,스칼라) 부분함수


스칼라를 처음 시작하는 사람들은 다양한 용어에 곤란을 겪곤 합니다. 그래서 용어 중심으로 하나씩 스칼라 내용을 풀어가는 작업을 해보려 합니다. 그 첫번째는 부분함수(partial function)입니다.

제 생각에는 스칼라에서 PartialFunction이 커링이나 for 컴프리핸션 정도로 중요한 개념은 아닙니다. 개념이 생소할 뿐이죠. 필요성보다는 그게 뭐고, 어떤 용례가 있는지 이해하고 넘어가면 될 것 같습니다.

부분함수의 정의

수학적으로 부분함수는 정의역 중 일부분에 대해서만 함수 값이 정의되는 함수를 뜻합니다. 예를 들어 X = {1,2,3,4}, Y = {A,B,C}가 있을 때, (1,A),(2,B),(3,C)와 같은 식으로 X의 모든 원소에 대한 대응이 정의되지 않는 경우를 부분함수(또는 부분정의함수)라고 합니다. 영어로는 partial function이나 partially defined function이라고 부릅니다. 수학에선 함수를 더 일반화한 것이 부분함수라고 할 수 있습니다.

스칼라에서도 PartialFunction은 함수입니다. 따라서, Function을 대신할 수 있습니다. 함수를 호출할 수 있는 곳에서는 PartialFunction을 대신 사용할 수 있겠죠.

다만, PartialFunctionisDefinedAt(value)을 제공해야 합니다. 이 메서드는 value에 대해 함수값이 정의돼 있으면 참, 그렇지 않으면 거짓을 반환합니다.

부분함수가 필요한 이유

어떤 함수를 호출하면 결과가 정상적으로 나오지 않을 때가 있다면, 그런 경우를 어떻게 처리해야 할까요? 몇 가지 방법이 있겠죠. 대충 생각나는걸 적어보면…

  1. 예외를 던진다. 굳이 자세한 설명이 필요 없겠죠?
  2. 정상적이지 않은 경우를 표현하는 다른 반환값을 사용한다. 예를 들어 스칼라의 Option이나 Either 등을 사용하면 정상적인 값과 그렇지 않은 값을 처리할 수 있습니다. 자바라면 null을 던지는 것도 한가지 방법이긴 하겠죠(그렇다고 그렇게 해도 된다는 이야기는 아님).

자바의 경우 메서드 뒤에 throws를 붙여서 꼭 발생할 수 있는 예외를 명시해야 하므로 어떤 함수가 예외를 발생시킬지 알 수 있습니다. 하지만 스칼라에서는 예외를 반드시 명시할 필요가 없으므로(@throws 애노테이션이 있지만 쓰기 싫으면 안써도 되죠), 2번처럼 Option이나 Either, Try 등을 활용할 것을 권장합니다. 함수의 반환 타입만 봐도 “아.. 이 함수는 예외적인 경우에 다른 값을 내놓는 구나”하고 감이 오니까요.

반대로 어떤 함수가 특정한 값에 대해서만 작동하거나, 특정한 값에 대해서만 올바른 결과값을 반환할 것을 보장한다면 이를 어떻게 표현할 수 있을까요? 이때는 다음과 같은 방법이 있겠죠.

  1. InvalidArgumentException을 던진다.
  2. 입력오류를 표현하는 결과값을 반환한다.
  3. PartialFunction으로 함수를 만들고, isDefinedAt으로 먼저 함수가 정상적인 값을 반환할지를 물어보고, isDefinedAt이 참을 반환하는 경우에만 함수를 호출한다.

1번 방식은 자바식 방법이겠죠. 2번은 어떤 언어에서건 별로 바람직한 방법은 아닐겁니다. 미리 오류를 표현하는 결과값이 뭔지 알야아 하고, 함수를 호출한 다음 결과값을 매번 체크해야 하고, 예외적 상황과 프로그램 메인 흐름이 분리되지 않으니까요. 3번은 PartialFunction라는 함수의 타입만 보고, 그 함수가 정해진 몇가지 값에 대해서만 정의된 함수라는 것을 알 수 있습니다.

이렇게 표현했을 때 얻을 수 있는 이점 한가지는, PartialFunction끼리 조합해서 새로운 PartialFunction을 만들 수 있다는 점입니다. PartialFunction에 있는 다음 메서드를 봅시다.

  • def andThen[C](k: (B) ⇒ C): PartialFunction[A, C]
  • def orElse[A1 <: A, B1 >: B](that: PartialFunction[A1, B1]): PartialFunction[A1, B1]

andThen은 부분함수가 정의된 경우에, 그 결과에 다른 함수를 적용해서 반환합니다. 반면, orElse는 인자로 들어온 값에 대해 부분함수가 정의된 경우에는 그냥 결과값을 반환하고, 그렇지 않은 경우에는 다른 부분함수를 호출합니다. 그 부분함수가 원래의 인자에 대해 정의돼 있다면 그 결과값이 반환되겠죠. andThenisDefinedAt()이 참인 범위가 확장되지 않고 그대로 유지되지만, orElse는 두 부분함수의 정의역의 합집합이 새로운 정의역이 되겠죠.

부분함수가 지켜야 할 사항

PartialFunctionisDefinedAt을 제공하며, isDefinedAt이 참을 반환하는 경우에는 정상적인 동작과 반환값을 보장한다는 의미가 있습니다. 호출하는 쪽에서는 먼저 isDefinedAt을 호출해서 함수가 정상 작동할지를 확인할 수 있기 때문에 정상작동할거라는 확신을 가지고 함수를 호출할 수 있습니다. 물론 컴파일러가 그런 약속을 강제하지는 않기 때문에, 부분함수를 작성하는 사람이 조심해 작성해야 하고, 쓰는 사람도 가급적 isDefinedAt()을 호출해 호출시 제대로된 출력이 나올지 미리 검토를 해야 합니다.

간단한 예

예를 봅시다.

// 72법칙에 따라 복리계산시 2배 되는 기간을 계산한다.
val doublePeriod = new PartialFunction[Int, Int] {
  def apply(d: Int) = 72 / d
  def isDefinedAt(d: Int) = d > 0 && d < 40 // 너무 이율이 크면 72법칙이 맞지 않는다.
}

이런 경우…

scala> doublePeriod(10)
res0: Int = 7

scala> doublePeriod(0)
java.lang.ArithmeticException: / by zero
  at $anon$1.apply$mcII$sp(<console>:12)
  ... 32 elided

scala> doublePeriod(50)
res2: Int = 1

scala> doublePeriod.isDefinedAt(50)
res3: Boolean = false

scala> doublePeriod.isDefinedAt(0)
res4: Boolean = false

보시면 아시겠지만, isDefinedAt()이 거짓이라고 해서 해당 함수를 호출하지 못하는 것도 아니고, 예외가 발생하지 않는 것도 아닙니다. 그러니까 호출하는 쪽에서 신경을 써줘야 합니다.

andThenorElse의 예를 봅시다.

val oddFt = new PartialFunction[Int, Int] {
  def apply(x: Int) = x + 1
  def isDefinedAt(x: Int) = x%2 == 1
}

val evenFt = new PartialFunction[Int, Int] {
  def apply(x: Int) = x * 2 + 1
  def isDefinedAt(x: Int) = x%2 == 0
} 

이제 이 둘을 가지고 실험합시다.

scala> val range = 1 to 10
range: scala.collection.immutable.Range.Inclusive = Range(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)


scala> range.map(oddFt.isDefinedAt)
res5: scala.collection.immutable.IndexedSeq[Boolean] = Vector(true, false, true, false, true, false, true, false, true, false)

scala> range.map(evenFt.isDefinedAt)
res6: scala.collection.immutable.IndexedSeq[Boolean] = Vector(false, true, false, true, false, true, false, true, false, true)

scala> oddFt(1)
res9: Int = 2

scala> evenFt(2)
res10: Int = 5

scala> val composed = oddFt andThen evenFt
composed: PartialFunction[Int,Int] = <function1>

scala> composed(1) = 5

scala> val merged = oddFt orElse evenFt
merged: PartialFunction[Int,Int] = <function1>

scala> merged(1)
res16: Int = 2

scala> merged(2)
res17: Int = 5

scala> range.map(merged.isDefinedAt)
res18: scala.collection.immutable.IndexedSeq[Boolean] = Vector(true, true, true, true, true, true, true, true, true, true)

스칼라에서 사용중인 부분함수의 예

액터의 receive

스칼라에서 가장 PartialFunction의 예로 유명한 것은 아마도 액터의 receive일 것입니다. 액터는 임의의 메시지를 받을 수 있기 때문에, 액터시스템에서는 어떤 액터가 메시지를 처리할지 아닐지를 미리 알면 좋습니다. 아카 액터의 API스칼라독을 보면:

  • type Receive = PartialFunction[Any, Unit]
  • abstract def receive: Actor.Receive : 최초 액터의 행동을 결정한다. 액터의 동작 로직이 들어있는 부분 함수여야만 한다.

여기서 receive의 타입은 PartialFunction[Any,Unit]입니다. 따라서, 액터 시스템은 어떤 액터가 메시지를 처리할 수 있는지 여부를 receiveisDefinedAt()을 호출해서 알아낼 수 있습니다.

파서 컴비네이터의 ^?

스칼라 파서 컴비네이터의 Parser를 보면 다음과 같은 메서드가 있습니다.

  • def ^?[U](f: PartialFunction[T, U]): Parser[U] : 이 메서드는 파서가 파싱에 성공하면 f를 적용해주되, f이 파싱 결과값에 대해 정의돼 있지 않거나, 파서가 실패했다면 오류 메시지를 표시하고 파싱을 중단하겠죠.
  • def ^?[U](f: PartialFunction[T, U], error: (T) ⇒ String): Parser[U] : 이 메서드는 파서가 파싱에 성공하면 f를 적용해주고, f가 파싱 결과값에 대해 정의돼 있지 않으면 error에 파싱 결과를 넘겨서 오류 메시지를 가져와 표시하고, 파서가 실패했다면 오류 메시지를 표시하고 파싱을 중단하겠죠.

부분함수를 정의하는 여러가지 방법

앞에서 부분함수를 직접 PartialFunction을 사용해 무명 클래스로 구현한 것을 봤습니다. 당연히 무명 클래스가 아닌 일반 클래스로 PartialFunction을 상속하거나 믹스인해서도 구현할 수 있겠죠. 이에 대해서는 따로 설명하지 않겠습니다. 이외에도 몇가지 유용한 방법이 있습니다.

match문을 활용하는 방법

다음 프로그램을 봅시다.

// match.scala라는 이름으로 저장한다.
object MatchTest {
  def main(args: Array[String]):Unit = {
    val x = 1
    val y = (x:Int) => x match {
        case 1 => "One"
        case 2 => "Two"
        case 3 => "Three"
      }
    println(y(x))
    // 위 y와 완전히 같은 람다지만 타입만 다르게 선언
    val z:PartialFunction[Int,String] = (x:Int) => x match {
        case 1 => "One"
        case 2 => "Two"
        case 3 => "Three"
      }
    println(z.isDefinedAt(x))
  }
}

이 프로그램을 scalac -Xprint:all .\match.scala로 컴파일해보면 다음과 같습니다.

[[syntax trees at end of                    parser]] // match.scala
package <empty> {
  object MatchTest extends scala.AnyRef {
    def <init>() = {
      super.<init>();
      ()
    };
    def main(args: Array[String]): Unit = {
      val x = 1;
      val y = ((x: Int) => x match {
        case 1 => "One"
        case 2 => "Two"
        case 3 => "Three"
      });
      println(y(x));
      val z: PartialFunction[Int, String] = ((x: Int) => x match {
        case 1 => "One"
        case 2 => "Two"
        case 3 => "Three"
      });
      println(z.isDefinedAt(x))
    }
  }
}

[[syntax trees at end of                     namer]] // match.scala: tree is unchanged since parser
[[syntax trees at end of            packageobjects]] // match.scala: tree is unchanged since parser
[[syntax trees at end of                     typer]] // match.scala
package <empty> {
  object MatchTest extends scala.AnyRef {
    def <init>(): MatchTest.type = {
      MatchTest.super.<init>();
      ()
    };
    def main(args: Array[String]): Unit = {
      val x: Int = 1;
      val y: Int => String = ((x: Int) => x match {
        case 1 => "One"
        case 2 => "Two"
        case 3 => "Three"
      });
      scala.this.Predef.println(y.apply(x));
      val z: PartialFunction[Int,String] = ({
        @SerialVersionUID(value = 0) final <synthetic> class $anonfun extends scala.runtime.AbstractPartialFunction[Int,String] with Serializable {
          def <init>(): <$anon: Int => String> = {
            $anonfun.super.<init>();
            ()
          };
          final override def applyOrElse[A1 <: Int, B1 >: String](x: A1, default: A1 => B1): B1 = (x: A1 @unchecked) match {
            case 1 => "One"
            case 2 => "Two"
            case 3 => "Three"
            case (defaultCase$ @ _) => default.apply(x)
          };
          final def isDefinedAt(x: Int): Boolean = (x: Int @unchecked) match {
            case 1 => true
            case 2 => true
            case 3 => true
            case (defaultCase$ @ _) => false
          }
        };
        new $anonfun()
      }: PartialFunction[Int,String]);
      scala.this.Predef.println(z.isDefinedAt(x))
    }
  }
}

[[syntax trees at end of                    patmat]] // match.scala
package <empty> {
  object MatchTest extends scala.AnyRef {
    def <init>(): MatchTest.type = {
      MatchTest.super.<init>();
      ()
    };
    def main(args: Array[String]): Unit = {
      val x: Int = 1;
      val y: Int => String = ((x: Int) => {
        case <synthetic> val x1: Int = x;
        x1 match {
          case 1 => "One"
          case 2 => "Two"
          case 3 => "Three"
          case _ => throw new MatchError(x1)
        }
      });
      scala.this.Predef.println(y.apply(x));
      val z: PartialFunction[Int,String] = ({
        @SerialVersionUID(value = 0) final <synthetic> class $anonfun extends scala.runtime.AbstractPartialFunction[Int,String] with Serializable {
          def <init>(): <$anon: Int => String> = {
            $anonfun.super.<init>();
            ()
          };
          final override def applyOrElse[A1 <: Int, B1 >: String](x: A1, default: A1 => B1): B1 = {
            case <synthetic> val x1: A1 = (x: A1 @unchecked);
            case7(){
              if (1.==(x1))
                matchEnd6("One")
              else
                case8()
            };
            case8(){
              if (2.==(x1))
                matchEnd6("Two")
              else
                case9()
            };
            case9(){
              if (3.==(x1))
                matchEnd6("Three")
              else
                case10()
            };
            case10(){
              matchEnd6(default.apply(x))
            };
            matchEnd6(x: B1){
              x
            }
          };
          final def isDefinedAt(x: Int): Boolean = {
            case <synthetic> val x1: Int = (x: Int @unchecked);
            x1 match {
              case 1 => true
              case 2 => true
              case 3 => true
              case _ => false
            }
          }
        };
        new $anonfun()
      }: PartialFunction[Int,String]);
      scala.this.Predef.println(z.isDefinedAt(x))
    }
  }
}
... 이하 생략 ...

잘 보시면 typer가 타입체킹을 하면서 PartialFunction에 해당하는 경우에는 isDefinedAtapplyOrElse를 자동으로 넣어준다는 것을 알 수 있습니다. 바로 이 부분이 match로 부분함수를 만드는 마법에 해당합니다. 우리가 사용한 형태 val z: PartialFunction[Int, String] = ((x: Int) => x match { ... }에서 ((x: Int) => x match 부분의 x는 실제로는 전혀 프로그램의 의미에 영향을 끼치지 못하고, 단지 인자가 1개인 함수를 만들어주는 역할만을 합니다. (참고로 이런 형태로 x가 전혀 들어있지 않은 어떤 식 expr(x:Type) => expr과 같이 함수 형식으로 확장하는 것을 에타확장이라고 부릅니다.) 따라서, 스칼라는 불필요하게 프로그래머가 직접 에타 확장을 할 필요 없이 직접 match식의 case 부분만을 쓸 수 있게 허용합니다. 그에 따라 앞의 z를 다음과 같이 정의할 수도 있습니다.

val z2:PartialFunction[Int,String] = {
  case 1 => "One"
  case 2 => "Two"
  case 3 => "Three"
}
println(z2.isDefinedAt(x))

이 코드를 앞의 match.scala에 넣고 컴파일해보면 람다의 인자 부분을 제외하면 동일한 코드를 만들어준다는 사실을 알 수 있습니다. 이런 케이스 블럭은 PartialFunction 역할을 할 수 있고, PartialFunctionFunction이기도 하기 때문에, 함수가 필요한 곳에 람다 대신 케이스블럭을 넣을 수도 있습니다. 다만, 그런 경우에는 케이스 블럭에서 처리하지 않는 값에 대해서는 MatchError가 발생할 수 있음에 유의해야겠죠.

이런 이유로, 매치문을 (부분함수인 케이스 블럭을 사용해) 쉽게 고차함수에 넘길 수 있습니다.

scala> List(1,2,3) map { 
     | case 1 => "One"
     | case 2 => "Two"
     | case _ => "Other" }
res2: List[String] = List(One, Two, Other)

부분함수와 컬렉션

대표적인 스칼라 컬렉션 List, Map, Array에 대해 생각해 봅시다.

scala> val family = List("Frank","Kevin","Joshua")
family: List[String] = List(Frank, Kevin, Joshua)

scala> family(0)
res6: String = Frank

scala> family(3)
java.lang.IndexOutOfBoundsException: 3
  at scala.collection.LinearSeqOptimized$class.apply(LinearSeqOptimized.scala:65)
  at scala.collection.immutable.List.apply(List.scala:84)
  ... 32 elided

scala> val familyMap = Map( "dad" -> "Frank", "mum" -> "Joyce")
familyMap: scala.collection.immutable.Map[String,String] = Map(dad -> Frank, mum -> Joyce)

scala> familyMap("dad")
res8: String = Frank

scala> familyMap("mistress")
java.util.NoSuchElementException: key not found: mistress
  at scala.collection.MapLike$class.default(MapLike.scala:228)
  at scala.collection.AbstractMap.default(Map.scala:59)
  at scala.collection.MapLike$class.apply(MapLike.scala:141)
  at scala.collection.AbstractMap.apply(Map.scala:59)
  ... 32 elided

scala> val arr = Array(1,2,3,4,5)
arr: Array[Int] = Array(1, 2, 3, 4, 5)

scala> arr(0)
res10: Int = 1

scala> arr(-1)
java.lang.ArrayIndexOutOfBoundsException: -1
  ... 32 elided

ListArrayi번째 원소를 가져오는 연산(실제로는 apply() 메서드)의 특징은 뭘까요?

  • i0 이상이고 배열이나 리스트의 원소개수 미만이라면 해당 위치에 있는 원소를 반환한다.
  • 그 이외의 경우에는 IndexOutOfBoundsException이나 ArrayIndexOutOfBoundsException를 던진다.

마찬가지로, Map의 경우는 다음과 같습니다.

  • 인자가 Map의 키 중 하나와 같으면, 그 키에 대응하는 값을 반환한다.
  • 인자와 일치하는 키가 없다면, NoSuchElementException을 반환한다.

어디서 많이 보던 패턴 아닌가요? 맞습니다. 그래서, 스칼라의 컬렉션들은 PartialFunction을 믹스인 합니다.

scala> family.isDefinedAt(100)
res12: Boolean = false

scala> familyMap.isDefinedAt("Hoho")
res13: Boolean = false

scala> arr.isDefinedAt(Int.MaxValue)
res14: Boolean = false

따라서, 스칼라에서 케이스 블럭을 사용하지 않고, PartialFunction을 정의하는 간단한 방법 중 하나는 Map을 만드는 것입니다. ListArray0부터 시작하고, 1씩 인덱스가 증가하는 연속된 범위에 대한 PartialFunction[Int, 어떤타입]만을 만들 수 있기 때문에 아무래도 쓸모가 좀 덜하겠죠.

scala> val y:PartialFunction[Int, String] = Map( 1->"One", 2->"Two" )
y: PartialFunction[Int,String] = Map(1 -> One, 2 -> Two)

부분함수(partial function)과 흔히 혼동하는 것이 부분 적용한 함수(partially applied function)입니다. 다음에 기회가 되면 그에 대해 다뤄볼까 합니다.

Related Posts

[코틀린] 코틀린 제네릭 클래스의 타입 특화 제약과 invoke를 통한 해결방법

코틀린 제네릭 타입의 타입 특화 관련 문제점과 invoke와 오버로드 해소를 통해 이런 문제점을 어떻게 우회할 수 있는지 설명함

[프로그래밍기초]정적 타입 지정과 변성

정적 타입 지정과 변성에 대해 정리

[하스켈 기초][CIS194] 폴드와 모노이드

CIS194 7강 폴드와 모노이드에 대해 배운다

[하스켈 기초][CIS194] 다형성과 타입 클래스 연습문제 풀이

CIS194 5강 다형성과 타입 클래스 연습문제 풀이입니다.

[하스켈 기초][CIS194] 지연 계산

CIS194 6강 미리계산, 지연계산, 부수효과, 순수성에 대해 설명하고 패턴 매칭이 식을 평가하는 과정을 어떻게 이끄는지 살펴본 다음, 지연 계산이 프로그램 실행에 끼치는 영향에 대해 알아본다.

[하스켈 기초][CIS194] 다형성과 타입 클래스

CIS194 5강 다형성에 대해 설명하고 타입 클래스에 대해 설명한다. 연습 문제에서는 타입 클래스를 활용해 DSL을 편리하게 작성할 수 있음을 보여준다.

[하스켈 기초][CIS194] 고차 프로그래밍과 타입 추론 연습문제 풀이

CIS194 4강 고차 프로그래밍과 타입 추론 연습문제 풀이

[하스켈 기초][CIS194] 4강 - 고차 프로그래밍과 타입 추론

무명 함수(람다) 정의 방법을 알려주고, 함수 합성, 커링, 부분 적용, 폴드와 같은 고차 함수 프로그래밍에 대해 설명한다.

[하스켈 기초][CIS194] 재귀 연습문제 풀이

CIS194 3강 재귀 관련 연습문제 풀이

[하스켈 기초][CIS194] 재귀 패턴, 다형성, 프렐류드

실용적인 재귀 패턴을 설명하고 재귀 패턴을 추상화한 몇몇 함수를 정리한 다음, 하스켈 프렐류드에 대해 설명한다